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
6 changes: 0 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,6 @@ jobs:

# main must never carry a published version. Retried because merges can
# land on main while the release runs.
#
# The capabilities page stamps the version it covers and the drift check
# compares that stamp to package.json, so the version bump restamps the
# page in the same commit. The collector reads runtime sources and needs
# no device.
- name: Commit the next development version
env:
VERSION: ${{ needs.plan.outputs.version }}
Expand All @@ -267,7 +262,6 @@ jobs:
git fetch origin main
git reset --hard origin/main
next="$(node scripts/release-version.mjs advance "$VERSION")"
pnpm generate:capabilities
if git diff --quiet; then
echo "main already builds towards $next."
exit 0
Expand Down
52 changes: 11 additions & 41 deletions scripts/capability-matrix/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// `website/docs/docs/capabilities.md` from the collected matrix.
//
// Table cells, column sets, and the partial-support notes all come from
// `collect.ts`; this file adds the labels and links from `annotations.ts` and the
// header's version stamp. Columns whose command sets are identical print once.
// `collect.ts`; this file adds the labels and links from `annotations.ts`. Columns
// whose command sets are identical print once. The page carries no version or date,
// so it changes only when the facts or the annotations do.
// Without `--write` the script compares the page on disk against the generated
// page and fails when they differ, which is what the drift guard runs.

Expand All @@ -25,21 +26,14 @@ function targetById(id: string): Target {
}

export type MatrixData = Readonly<{
version: string;
generatedAt: string;
commands: readonly string[];
support: ReadonlyMap<string, ReadonlySet<string>>;
operations: ReadonlyMap<string, ReadonlySet<string>>;
}>;

async function collectPageData(): Promise<MatrixData> {
const packageJson = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) as {
version: string;
};
const { commands, support, operations } = await collectMatrix();
return {
version: packageJson.version,
generatedAt: new Date().toISOString().slice(0, 10),
commands,
support: new Map(Object.entries(support).map(([id, list]) => [id, new Set<string>(list)])),
operations: new Map(
Expand Down Expand Up @@ -229,10 +223,10 @@ function rowLimits(rows: readonly FactRow[]): string[] {
}

const GROUPS: ReadonlyArray<{ readonly group: TargetGroup; readonly caption: string }> = [
{ group: 'local-mobile', caption: 'Mobile targets' },
{ group: 'local-mobile', caption: 'mobile targets' },
{ group: 'local-tv', caption: 'TV targets' },
{ group: 'local-desktop-web', caption: 'Desktop and web targets' },
{ group: 'provider', caption: 'Hosted provider sessions' },
{ group: 'local-desktop-web', caption: 'desktop and web targets' },
{ group: 'provider', caption: 'hosted provider sessions' },
];

export function renderPage(data: MatrixData): string {
Expand Down Expand Up @@ -271,7 +265,6 @@ export function renderPage(data: MatrixData): string {
'---',
'',
'The target tables below are generated from the runtime command-admission facts that back the `capabilities` command: a command that leaves the code leaves this page. Labels, limits, and integration rows are maintained beside the generator, and command availability does not establish support for every operation, subcommand, or device model.',
`It covers agent-device ${data.version}, generated ${data.generatedAt}.`,
'',
'**Yes** means the facts admit every command in that row on the target and every operation the row advertises. **No** means at least one is refused. A qualified cell names the operations that answer there. Targets that share the same available operations print as one column.',
'',
Expand All @@ -294,7 +287,8 @@ export function renderPage(data: MatrixData): string {
lines.push(`## ${section.title}`, '');
for (const { caption, group } of GROUPS) {
const groupColumns = columns.get(group) as Column[];
lines.push(`### ${caption}`, '');
// Section and group in one heading, so search results and anchors stay distinct.
lines.push(`### ${section.title}: ${caption}`, '');
lines.push(...tableFor(section.rows, groupColumns, data), '');
}
const limits = rowLimits(section.rows);
Expand Down Expand Up @@ -333,39 +327,15 @@ function firstDifference(committed: string, generated: string): string {
return '(none)';
}

/**
* The generation date in the page header's provenance line; see
* {@link withNeutralProvenance}. Matching the full line structure keeps every
* other word in the comparison: a change to the sentence, a different version,
* or text appended after the date no longer matches and shows as drift.
*/
const PROVENANCE_LINE = /^(It covers agent-device [0-9][^,\n]*, generated )\d{4}-\d{2}-\d{2}(\.)$/m;

/**
* The header's generation date is provenance, not a fact: it moves with every
* new day the page is written. Drift comparison masks that one date so the
* facts can be re-collected without a diff, while the version stays compared:
* a page stamped with an older version than package.json is stale and fails
* until it is regenerated. The pattern demands the exact shape the renderer
* writes, so edited or appended text around the stamp still shows as drift.
*/
function withNeutralProvenance(page: string): string {
return page.replace(PROVENANCE_LINE, '$1<date>$2');
}

/**
* The drift report between the committed page and the generated one, or null
* when they agree. Both are date-masked: the header's generation date moves
* with every regeneration, while the version stamp and every table cell stay
* compared exactly.
* when they agree. The page carries no date or version, so it is compared whole.
*/
export function driftReport(committed: string, generated: string): string | null {
const a = withNeutralProvenance(committed);
const b = withNeutralProvenance(generated);
if (a === b) return null;
if (committed === generated) return null;
return (
`${PAGE_PATH} is stale. Run \`pnpm generate:capabilities\`.\nFirst difference:\n` +
firstDifference(a, b)
firstDifference(committed, generated)
);
}

Expand Down
44 changes: 10 additions & 34 deletions test/repo-guards/capability-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ import type { CollectedMatrix } from '../../scripts/capability-matrix/run.ts';
// exactly the commands the registry derives from device facts, and every
// qualified cell still names facts that hold. All three are checked, so a
// capability cannot be added, renamed, or removed without an edit under
// scripts/capability-matrix/ and a regenerated page. The page's generation-date
// is provenance rather than a fact, so it is masked before the comparison — and
// negative tests pin what the mask does and does not hide: the version is a
// fact about the tree and stays compared.
// scripts/capability-matrix/ and a regenerated page. The page carries no version
// or date, so the comparison is exact.
//
// The facts are collected in a child process on purpose: the provider runtimes
// need `node:module` loader hooks to keep their third-party SDKs out of the run,
Expand Down Expand Up @@ -62,14 +60,7 @@ function toSets(
const support = toSets(matrix.support);
const operations = toSets(matrix.operations);
const committed = fs.readFileSync(path.join(REPO_ROOT, PAGE_PATH), 'utf8');
// The real package version, so this guard also proves the committed page is
// stamped with the version the tree builds; the generated page must agree.
const packageVersion = (
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) as { version: string }
).version;
const data: MatrixData = {
version: packageVersion,
generatedAt: '2000-01-01',
commands: matrix.commands,
support,
operations,
Expand Down Expand Up @@ -119,30 +110,15 @@ describe('capabilities page matches runtime facts', () => {
});

test('drift detection proves itself', () => {
// The committed page is current, so every mutation below must report drift —
// including one inside the masked provenance line's surroundings.
// The committed page is current, so every mutation below must report drift.
assert.notEqual(
driftReport(committed.replace(/Yes \| Yes \| No/, 'Yes | No | No'), committed),
null,
);
const dated = committed.replace(/generated \d{4}-\d{2}-\d{2}\./, 'generated 2000-01-01.');
assert.equal(driftReport(dated, committed), null, 'the generation date is provenance');
const versioned = committed.replace(
/It covers agent-device [^,]+,/,
'It covers agent-device 9.9.9,',
);
assert.notEqual(
driftReport(versioned, committed),
null,
'a page stamped with another version than the tree must read as stale',
);
assert.notEqual(
driftReport(
committed.replace(/generated [^.]+\./, 'generated 2000-01-01 and hand edited.'),
committed,
),
driftReport(committed.replace(/tables below/, 'tables here'), committed),
null,
'text appended to the provenance sentence must not be masked',
'any edit to the prose reads as drift',
);
assert.notEqual(
driftReport(committed.replace(/\n## Inspect and verify the UI/, ''), committed),
Expand Down Expand Up @@ -315,10 +291,10 @@ describe('capabilities page matches runtime facts', () => {
`Target "${target.id}" (label "${target.label}") is not named in any column header on the page.`,
);
}
// The committed page carries the real stamp, not the mask.
assert.match(
committed,
/^It covers agent-device \d+\.\d+\.\d+[^,\n]*, generated \d{4}-\d{2}-\d{2}\.$/m,
);
// No version or date: the page changes only with the facts.
assert.doesNotMatch(committed, /It covers agent-device|generated \d{4}-\d{2}-\d{2}/);
// Every heading is distinct, so search results and anchors name one table each.
const headings = committed.split('\n').filter((line) => line.startsWith('#'));
assert.deepEqual(headings, [...new Set(headings)], 'the page repeats a heading');
});
});
Loading
Loading