diff --git a/scripts/__tests__/docs-cache.test.ts b/scripts/__tests__/docs-cache.test.ts index ce22977..e46167c 100644 --- a/scripts/__tests__/docs-cache.test.ts +++ b/scripts/__tests__/docs-cache.test.ts @@ -174,6 +174,22 @@ describe('summarizeCatalog', () => { }, } + /** The order `summarizeCatalog` puts these boot versions in. */ + function publishedOrder(...versions: string[]): string[] { + const catalog = { + ...MULTI, + projects: { + boot: Object.fromEntries( + versions.map(v => [v, { tag: `boot-${v}`, released_at: '2026-01-01T00:00:00Z' }]), + ), + }, + } + const summary = summarizeCatalog(catalog, 'boot') + if (summary.kind !== 'coverage') + throw new Error(`expected coverage, got ${summary.kind}`) + return summary.projects[0]?.published ?? [] + } + test('reports every project, with reserved tags kept out of the published list', () => { expect(summarizeCatalog(MULTI)).toEqual({ kind: 'coverage', @@ -206,18 +222,14 @@ describe('summarizeCatalog', () => { }) test('orders a shorter version before the longer one it prefixes', () => { - const catalog = { - ...MULTI, - projects: { - boot: { - '4.0': { tag: 'boot-4.0', released_at: '2026-01-01T00:00:00Z' }, - '4.0.8': { tag: 'boot-4.0.8', released_at: '2026-01-01T00:00:00Z' }, - '4': { tag: 'boot-4', released_at: '2026-01-01T00:00:00Z' }, - }, - }, - } - const summary = summarizeCatalog(catalog, 'boot') - expect(summary.kind === 'coverage' && summary.projects[0]?.published).toEqual(['4', '4.0', '4.0.8']) + expect(publishedOrder('4.0', '4.0.8', '4')).toEqual(['4', '4.0', '4.0.8']) + }) + + test('keeps comparing past a numeric tie, so a leading zero cannot hide a later difference', () => { + // '1.02.3' and '1.2.4' agree at the '02'/'2' chunk. Settling on that tie + // reported two different versions as equal and left the rest of the list + // in input order. + expect(publishedOrder('1.2.4', '1.02.3', '1.2.1')).toEqual(['1.2.1', '1.02.3', '1.2.4']) }) test('refuses a catalog schema it does not understand, as lookupTag does', () => { diff --git a/scripts/docs.ts b/scripts/docs.ts index 1333a1e..03003ac 100644 --- a/scripts/docs.ts +++ b/scripts/docs.ts @@ -778,18 +778,24 @@ export function parseArgs(argv: string[]): ParsedArgs | { error: string } { else positional.push(arg) } - if (list) { - // Neither flag has anything to act on: the catalog is read fresh every - // time and never cached, so accepting them would promise behaviour that - // does not exist. - if (refresh || noFetch) - return { error: '--list takes no --refresh or --no-fetch' } - const [project, ...extra] = positional - if (extra.length > 0) - return { error: `unexpected argument: ${extra[0]}` } - return project === undefined ? { mode: 'list' } : { mode: 'list', project } - } + return list + ? parseList(positional, refresh || noFetch) + : parseResolve(positional, refresh, noFetch) +} + +function parseList(positional: string[], cacheFlags: boolean): ParsedArgs | { error: string } { + // Neither flag has anything to act on: the catalog is read fresh every time + // and never cached, so accepting them would promise behaviour that does not + // exist. + if (cacheFlags) + return { error: '--list takes no --refresh or --no-fetch' } + const [project, ...extra] = positional + if (extra.length > 0) + return { error: `unexpected argument: ${extra[0]}` } + return project === undefined ? { mode: 'list' } : { mode: 'list', project } +} +function parseResolve(positional: string[], refresh: boolean, noFetch: boolean): ParsedArgs | { error: string } { const [project, version, ...extra] = positional if (!project) return { error: 'missing ' } diff --git a/scripts/lib/docs-cache.ts b/scripts/lib/docs-cache.ts index 60312be..81cc0dd 100644 --- a/scripts/lib/docs-cache.ts +++ b/scripts/lib/docs-cache.ts @@ -232,7 +232,7 @@ export function summarizeCatalog(catalog: Catalog, project?: string): CoverageRe if (catalog.version !== SUPPORTED_CATALOG_VERSION) return { kind: 'schema', found: catalog.version } - const names = Object.keys(catalog.projects).sort() + const names = Object.keys(catalog.projects).sort(compareCodeUnits) if (project !== undefined && !names.includes(project)) return { kind: 'unknown-project', project, known: names } @@ -251,6 +251,26 @@ function coverageOf(catalog: Catalog, project: string): ProjectCoverage { return { project, published, unpublished } } +/** + * Order two strings by UTF-16 code unit — the order a bare `.sort()` implies. + * + * Code *unit*, not code point: `<` compares UTF-16 units, so a non-BMP + * character (stored as a surrogate pair) sorts by its leading surrogate rather + * than by its scalar value. Left that way on purpose. What this order has to be + * is reproducible, since it is asserted in tests and read by tooling, and code + * unit order is exactly as reproducible as code point order; a key that could + * expose the difference carries a character `isSafeSegment` rejects, so it + * never resolves to documentation whatever position it sorts into. + * + * Spelled out rather than left implicit, and deliberately not `localeCompare`, + * which would vary with the locale of the machine running the CLI. + */ +function compareCodeUnits(a: string, b: string): number { + if (a === b) + return 0 + return a < b ? -1 : 1 +} + /** Digit runs and non-digit runs, so `3.5.10` sorts after `3.5.9` rather than before it. */ const VERSION_CHUNK_RE = /\d+|\D+/g @@ -269,6 +289,12 @@ const DIGIT_CHUNK_RE = /^\d/ * today, but that is a property of a generator in another repository, and a * coverage report that silently reorders itself when that generator changes is * worse than one that always decides for itself. + * + * Numerically equal chunks do not settle the comparison, because equal as a + * number is not equal as text: `1.02.3` and `1.2.4` agree at `02`/`2` and + * differ afterwards. Returning that 0 would call two different versions equal + * and stop before the chunk that separates them, so the loop carries on and a + * run of numeric ties falls through to the whole string. */ function compareVersions(a: string, b: string): number { const left = a.match(VERSION_CHUNK_RE) ?? [] @@ -283,7 +309,11 @@ function compareVersions(a: string, b: string): number { if (x === y) continue const numeric = DIGIT_CHUNK_RE.test(x) && DIGIT_CHUNK_RE.test(y) - return numeric ? Number(x) - Number(y) : (x < y ? -1 : 1) + if (!numeric) + return compareCodeUnits(x, y) + const diff = Number(x) - Number(y) + if (diff !== 0) + return diff } - return 0 + return compareCodeUnits(a, b) } diff --git a/skills/spring-docs/scripts/docs.mjs b/skills/spring-docs/scripts/docs.mjs index 1dcec08..f041b0a 100644 --- a/skills/spring-docs/scripts/docs.mjs +++ b/skills/spring-docs/scripts/docs.mjs @@ -89,7 +89,7 @@ function parseChecksum(contents, expectedName) { function summarizeCatalog(catalog, project) { if (catalog.version !== SUPPORTED_CATALOG_VERSION) return { kind: "schema", found: catalog.version }; - const names = Object.keys(catalog.projects).sort(); + const names = Object.keys(catalog.projects).sort(compareCodeUnits); if (project !== undefined && !names.includes(project)) return { kind: "unknown-project", project, known: names }; const wanted = project === undefined ? names : [project]; @@ -105,6 +105,11 @@ function coverageOf(catalog, project) { unpublished.sort(compareVersions); return { project, published, unpublished }; } +function compareCodeUnits(a, b) { + if (a === b) + return 0; + return a < b ? -1 : 1; +} var VERSION_CHUNK_RE = /\d+|\D+/g; var DIGIT_CHUNK_RE = /^\d/; function compareVersions(a, b) { @@ -120,9 +125,13 @@ function compareVersions(a, b) { if (x === y) continue; const numeric = DIGIT_CHUNK_RE.test(x) && DIGIT_CHUNK_RE.test(y); - return numeric ? Number(x) - Number(y) : x < y ? -1 : 1; + if (!numeric) + return compareCodeUnits(x, y); + const diff = Number(x) - Number(y); + if (diff !== 0) + return diff; } - return 0; + return compareCodeUnits(a, b); } // scripts/docs.ts @@ -436,14 +445,17 @@ function parseArgs(argv) { else positional.push(arg); } - if (list) { - if (refresh || noFetch) - return { error: "--list takes no --refresh or --no-fetch" }; - const [project, ...extra] = positional; - if (extra.length > 0) - return { error: `unexpected argument: ${extra[0]}` }; - return project === undefined ? { mode: "list" } : { mode: "list", project }; - } + return list ? parseList(positional, refresh || noFetch) : parseResolve(positional, refresh, noFetch); +} +function parseList(positional, cacheFlags) { + if (cacheFlags) + return { error: "--list takes no --refresh or --no-fetch" }; + const [project, ...extra] = positional; + if (extra.length > 0) + return { error: `unexpected argument: ${extra[0]}` }; + return project === undefined ? { mode: "list" } : { mode: "list", project }; +} +function parseResolve(positional, refresh, noFetch) { const [project, version, ...extra] = positional; if (!project) return { error: "missing " };