/** * parse-csproj-version.ts — Extract the project version for migration naming. * * The {context}_v{version}_{seq}_{Description} naming convention requires a * version tag. Resolution order mirrors how the version is ACTUALLY produced: * 1. csproj / / / * 2. walking UP from the project dir, at each level: * a. package.json "version" — the GitFlow-managed SINGLE SOURCE OF TRUTH. * SmartStack repos centralise the version there and have MSBuild READ it * (Directory.Build.props evaluates a Regex over package.json at build * time), so package.json is what the assemblies are actually stamped * with. It is checked FIRST because any literal in the props file is, * by construction, only a fallback that nothing keeps in sync. * b. Directory.Build.props — repos that hardcode the version centrally. * Like MSBuild, only the NEAREST props file is consulted: a version-less * one ends the search (MSBuild does not chain to a higher props unless * the file imports it explicitly). * 3. default "0.1.0" * * Pre-release suffixes and build metadata are stripped so "0.1.0-dev.3" and * "0.1.0+sha.abc" both normalize to "0.1.0". * * ── Why this file keeps being the site of the same bug ──────────────────────── * A wrong version here is SILENT: the migration is simply named * core_v0_1_0_007_Foo against a core_v3_65_0_… history. Nothing fails, so it * ships. Two regressions so far, both from reading MSBuild XML as plain text: * 2026-06 — the reader looked only at csproj files; SmartStack.app carries no * tag (central props) → fell back to 0.1.0. * 2026-08 — the props walk was added, but SmartStack.app declares * `$([System.…Regex]::Match(…))`: * the tag carries an ATTRIBUTE (the old regex demanded `` * exactly) and its value is an MSBuild EXPRESSION, not a literal. * Both candidate matches failed → fell back to 0.1.0 again. * Hence: tags are matched WITH optional attributes, EVERY candidate occurrence * is tried (not just the first), non-literal values are skipped rather than * aborting, and package.json — the value MSBuild itself resolves to — wins. */ import path from 'node:path'; import { readText } from '../../../lib/fs.js'; const DEFAULT_VERSION = '0.1.0'; /** Tags that may carry a version, most specific first. */ const VERSION_TAGS = ['Version', 'VersionPrefix', 'AssemblyVersion', 'FileVersion'] as const; export async function parseCsprojVersion(csprojPath: string): Promise { const csprojContent = await tryReadText(csprojPath); const fromCsproj = csprojContent === null ? null : extractVersion(csprojContent); if (fromCsproj) return fromCsproj; let dir = path.dirname(path.resolve(csprojPath)); for (;;) { // package.json first: it is what Directory.Build.props reads, what the // release pipeline reads, and what GitFlow bumps. const fromPackageJson = extractPackageJsonVersion(await tryReadText(path.join(dir, 'package.json'))); if (fromPackageJson) return fromPackageJson; const propsContent = await tryReadText(path.join(dir, 'Directory.Build.props')); if (propsContent !== null) return extractVersion(propsContent) ?? DEFAULT_VERSION; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return DEFAULT_VERSION; } async function tryReadText(filePath: string): Promise { try { return await readText(filePath); } catch { return null; } } function extractPackageJsonVersion(content: string | null): string | null { if (content === null) return null; try { const version = (JSON.parse(content) as { version?: unknown }).version; return typeof version === 'string' ? normalizeLiteral(version) : null; } catch { return null; } } function extractVersion(content: string): string | null { for (const tag of VERSION_TAGS) { for (const raw of matchTagAll(content, tag)) { // A tag may legitimately hold an MSBuild expression rather than a literal // (e.g. a Regex over package.json). Skip it and keep looking instead of // giving up — a later occurrence often carries the literal fallback. const literal = normalizeLiteral(raw); if (literal) return literal; } } return null; } /** Strip pre-release / build metadata and keep only a numeric literal. */ function normalizeLiteral(value: string): string | null { const core = value.split(/[-+]/)[0].trim(); return /^\d+(\.\d+){0,3}$/.test(core) ? core : null; } /** * Every occurrence of `` — WITH optional attributes, which MSBuild uses * heavily (``). Matching `` exactly is what * made the 2026-08 regression invisible. */ function matchTagAll(content: string, tag: string): string[] { const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([^<]*)`, 'gi'); return [...content.matchAll(re)].map((m) => m[1].trim()); } export function normalizeVersionForName(version: string): string { // Defensive: strip pre-release / build metadata even if the caller forgot // to run it through parseCsprojVersion first. const core = version.split(/[-+]/)[0].trim() || version; return core.replace(/\./g, '_'); }