Repository navigation
Expand file tree
/
Copy pathgenerate.ts
More file actions
267 lines (243 loc) · 10.3 KB
/
Copy pathgenerate.ts
File metadata and controls
267 lines (243 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// `node scripts/capability-matrix/generate.ts [--write]` — renders
// `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.
// 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.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { MATRIX_SECTIONS, SCOPE_ROWS, type FactRow } from './annotations.ts';
import { collectMatrix } from './run.ts';
import { groupIdenticalColumns, TARGETS, type TargetGroup } from './targets.ts';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const PAGE_PATH = path.join('website', 'docs', 'docs', 'capabilities.md');
export type MatrixData = Readonly<{
version: string;
generatedAt: string;
commands: readonly string[];
support: 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 } = 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)])),
};
}
export type Column = Readonly<{
/** Representative target id, whose facts answer for the whole group. */
id: string;
label: string;
}>;
function labelFor(id: string): string {
const target = TARGETS.find((candidate) => candidate.id === id);
if (!target) throw new Error(`Unknown target "${id}".`);
return target.kind === 'cloud-webdriver' ? target.mergeLabel : target.label;
}
/** One column per distinct command set, labelled with every target it covers. */
function columnsForGroup(group: TargetGroup, data: MatrixData): Column[] {
const ids = TARGETS.filter((target) => target.group === group).map((target) => target.id);
return groupIdenticalColumns(ids, data.support, data.commands).map((merged) => ({
id: merged.id,
label:
merged.ids.length === 1
? labelFor(merged.id)
: [...new Set(merged.ids.map((id) => labelFor(id)))].join(' / '),
}));
}
/** Commands no row reports, and commands a row names that the registry does not have. */
export function annotationSymmetry(
commands: readonly string[],
rows: readonly FactRow[],
): { readonly unrowed: readonly string[]; readonly unknown: readonly string[] } {
const declared = rows.flatMap((row) => row.commands);
const declaredSet = new Set(declared);
const known = new Set(commands);
return {
unrowed: commands.filter((command) => !declaredSet.has(command)),
unknown: [...new Set(declared.filter((command) => !known.has(command)))].sort(),
};
}
/**
* Commands the column's representative target does not support. A row and a
* column may never disagree on a single command without the page losing that
* detail, which is what the guard's split-row check prevents.
*/
function unsupportedCommands(row: FactRow, supported: ReadonlySet<string>): string[] {
return row.commands.filter((command) => !supported.has(command));
}
function answer(row: FactRow, supported: ReadonlySet<string>): string {
return unsupportedCommands(row, supported).length === 0 ? 'Yes' : 'No';
}
function tableFor(
rows: readonly FactRow[],
columns: readonly Column[],
data: MatrixData,
): string[] {
const lines = [
`| Capability and proof |${columns.map((column) => ` ${column.label} |`).join('')}`,
`| --- |${columns.map(() => ' --- |').join('')}`,
];
for (const row of rows) {
const cells = columns.map((column) => {
const supported = data.support.get(column.id);
if (!supported) throw new Error(`No facts collected for column "${column.id}".`);
return ` ${answer(row, supported)} |`;
});
lines.push(`| [${row.label}](${row.link}) |${cells.join('')}`);
}
return lines;
}
/** Row limits worth reading even when every cell answered Yes. */
function rowLimits(rows: readonly FactRow[]): string[] {
return rows.filter((row) => row.note).map((row) => `- **${row.label}**: ${row.note as string}`);
}
const GROUPS: ReadonlyArray<{ readonly group: TargetGroup; readonly caption: string }> = [
{ 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' },
];
export function renderPage(data: MatrixData): string {
const rows = MATRIX_SECTIONS.flatMap((section) => section.rows);
const { unrowed, unknown } = annotationSymmetry(data.commands, rows);
if (unrowed.length > 0 || unknown.length > 0) {
throw new Error(
`Capability annotations are out of sync with the command registry. Unrowed: ` +
`[${unrowed.join(', ')}]; named but unknown: [${unknown.join(', ')}]. ` +
`Update scripts/capability-matrix/annotations.ts.`,
);
}
const columns = new Map<TargetGroup, Column[]>(
GROUPS.map(({ group }) => [group, columnsForGroup(group, data)]),
);
const lines: string[] = [
'---',
'title: Capabilities',
'description: Which commands each target supports, read from the runtime code that decides it.',
'---',
'',
'# Capabilities',
'',
'Every cell below is generated from the runtime decisions that back the `capabilities` command, so a capability that leaves the code leaves this page.',
`It covers agent-device ${data.version}, generated ${data.generatedAt}.`,
'',
'**Yes** means every command in that row runs on the target. **No** means at least one does not. Targets that share the same command set print as one column.',
'',
'One capability depends on the agent machine rather than the target: system audio sampling works only when the daemon runs on macOS, which is what this page assumes.',
'',
'Check the device in front of you rather than trusting a table:',
'',
'```bash',
'agent-device capabilities --platform ios --device "iPhone 17 Pro"',
'agent-device capabilities --session my-session --json',
'```',
'',
'A provider column answers for that provider in its default configuration. Provider credentials, device selection, and artifact retrieval live on the provider pages.',
'',
];
for (const section of MATRIX_SECTIONS) {
lines.push(`## ${section.title}`, '');
for (const { caption, group } of GROUPS) {
const groupColumns = columns.get(group) as Column[];
lines.push(`### ${caption}`, '');
lines.push(...tableFor(section.rows, groupColumns, data), '');
}
const limits = rowLimits(section.rows);
if (limits.length > 0) {
lines.push('What a **Yes** does not claim:', '', ...limits, '');
}
}
lines.push('## Capabilities that are not per-device commands', '');
lines.push(
'These come with the tool rather than with a target, so no column above decides them.',
'',
);
lines.push('| Capability | What it needs | Where it is documented |');
lines.push('| --- | --- | --- |');
for (const row of SCOPE_ROWS) {
lines.push(`| ${row.label} | ${row.scope} | [${row.label}](${row.link}) |`);
}
lines.push('');
return `${lines.join('\n')}\n`;
}
/** Line-by-line difference between the committed page and the generated one. */
function firstDifference(committed: string, generated: string): string {
const a = committed.split('\n');
const b = generated.split('\n');
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if (a[index] !== b[index]) {
return (
`- line ${index + 1}\n` +
` committed: ${a[index] ?? '(end of file)'}\n` +
` generated: ${b[index] ?? '(end of file)'}`
);
}
}
return '(none)';
}
/**
* The whole provenance line in the page header; see {@link withNeutralProvenance}.
* Matching the full line structure keeps every word in the comparison: a change
* to the sentence, 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 version and generation date are provenance, not facts: they move
* with every release commit and every new day. Drift comparison masks that one
* line so a release that only bumps `package.json` does not fail the docs lane,
* while every table cell and every other sentence stays compared exactly. 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, 'It covers agent-device <version>, generated <date>.');
}
/**
* The drift report between the committed page and the generated one, or null
* when they agree. Both are provenance-masked: the header's version and
* generation date move with every release commit and every new day, while every
* table cell stays compared exactly.
*/
export function driftReport(committed: string, generated: string): string | null {
const a = withNeutralProvenance(committed);
const b = withNeutralProvenance(generated);
if (a === b) return null;
return (
`${PAGE_PATH} is stale. Run \`pnpm generate:capabilities\`.\nFirst difference:\n` +
firstDifference(a, b)
);
}
async function main(argv: readonly string[]): Promise<number> {
const page = renderPage(await collectPageData());
const target = path.join(REPO_ROOT, PAGE_PATH);
const committed = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
if (!argv.includes('--write')) {
const drift = driftReport(committed, page);
if (drift === null) {
process.stdout.write(`${PAGE_PATH} is current.\n`);
return 0;
}
process.stderr.write(`${drift}\n`);
return 1;
}
if (committed === page) {
process.stdout.write(`${PAGE_PATH} unchanged.\n`);
return 0;
}
fs.writeFileSync(target, page);
process.stdout.write(`Wrote ${PAGE_PATH}.\n`);
return 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
process.exit(await main(process.argv.slice(2)));
}