Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions scripts/__tests__/docs-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 17 additions & 11 deletions scripts/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>' }
Expand Down
36 changes: 33 additions & 3 deletions scripts/lib/docs-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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

Expand All @@ -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) ?? []
Expand All @@ -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)
}
Comment thread
amondnet marked this conversation as resolved.
34 changes: 23 additions & 11 deletions skills/spring-docs/scripts/docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 <project>" };
Expand Down
Loading