@ouispec/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/analyze.d.ts +282 -0
  4. package/dist/analyze.d.ts.map +1 -0
  5. package/dist/analyze.js +1421 -0
  6. package/dist/analyze.js.map +1 -0
  7. package/dist/assemble.d.ts +50 -0
  8. package/dist/assemble.d.ts.map +1 -0
  9. package/dist/assemble.js +555 -0
  10. package/dist/assemble.js.map +1 -0
  11. package/dist/cli.d.ts +12 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +54 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/config.d.ts +54 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +126 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/contract.d.ts +10 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +15 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/generate.d.ts +49 -0
  24. package/dist/generate.d.ts.map +1 -0
  25. package/dist/generate.js +178 -0
  26. package/dist/generate.js.map +1 -0
  27. package/dist/index.d.ts +9 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/inputs.d.ts +134 -0
  32. package/dist/inputs.d.ts.map +1 -0
  33. package/dist/inputs.js +705 -0
  34. package/dist/inputs.js.map +1 -0
  35. package/dist/knowledge.d.ts +19 -0
  36. package/dist/knowledge.d.ts.map +1 -0
  37. package/dist/knowledge.js +366 -0
  38. package/dist/knowledge.js.map +1 -0
  39. package/dist/program.d.ts +60 -0
  40. package/dist/program.d.ts.map +1 -0
  41. package/dist/program.js +302 -0
  42. package/dist/program.js.map +1 -0
  43. package/dist/recipes.d.ts +17 -0
  44. package/dist/recipes.d.ts.map +1 -0
  45. package/dist/recipes.js +48 -0
  46. package/dist/recipes.js.map +1 -0
  47. package/dist/tier2.d.ts +74 -0
  48. package/dist/tier2.d.ts.map +1 -0
  49. package/dist/tier2.js +303 -0
  50. package/dist/tier2.js.map +1 -0
  51. package/package.json +62 -0
@@ -0,0 +1,1421 @@
1
+ /**
2
+ * What a page offers, read from its component tree (ADR-0220 §2.4).
3
+ *
4
+ * From the route's page component, through the app's own components, every
5
+ * design-system control is recorded with its binding, the props its schema
6
+ * comes from, and the containers a person goes through to reach it: the tab
7
+ * that shows it, the dialog that holds it (and the controls that open that
8
+ * dialog), the menu it is an entry of. Rooms are recorded where a page
9
+ * registers one or renders one of its hosts.
10
+ */
11
+ import { dirname, relative } from 'node:path';
12
+ import ts from 'typescript';
13
+ import { CALLBACK_PROP, effectKind, INTERACTIVE_HANDLERS, INTERACTIVE_TAGS, isAgentBinding, mappedOptions, } from '@ouispec/bindings';
14
+ import { componentDeclaration } from './inputs.js';
15
+ import { evaluate, hasJsxAttribute, humanizeId, isComparison, jsxAttribute, jsxText, objectProperty, resolveSymbol, stringLiterals, UNKNOWN, unwrap, } from './program.js';
16
+ const ITERATORS = new Set(['map', 'flatMap']);
17
+ export class PageAnalyzer {
18
+ source;
19
+ catalogs;
20
+ personOnly;
21
+ /** Entry objects made once per row, by a `map` or `flatMap`. */
22
+ rowEntries = new Set();
23
+ /** Every control the pages can use: the design systems' and the bound modules'. */
24
+ controls;
25
+ /** The design systems, and each bound module under its key. */
26
+ designSystem;
27
+ /** The packages whose tables were read, and each bound module. */
28
+ tablesRead;
29
+ /** Each bound module, by its file and by its key; each mapping, by the package it maps. */
30
+ boundByModule;
31
+ boundByKey;
32
+ mappedByPackage;
33
+ /** A bound control's descriptor → its mapping and part. */
34
+ tier2Of = new Map();
35
+ /** The files whose imports this page's analysis has checked. */
36
+ importsChecked = new Set();
37
+ constructor(source, controls, catalogs, designSystem,
38
+ /** The design-system packages whose tables were read. */
39
+ tablesRead = new Set(designSystem),
40
+ /** A package's person-only components (`oui.personOnly`), by export name, with why. */
41
+ personOnly = () => new Map(),
42
+ /** The app's tier 2 mappings, each with its bound module (ADR-0226 §2.3). */
43
+ mappings = []) {
44
+ this.source = source;
45
+ this.catalogs = catalogs;
46
+ this.personOnly = personOnly;
47
+ const all = new Map(controls);
48
+ for (const mapping of mappings) {
49
+ for (const [path, descriptor] of mapping.descriptors) {
50
+ all.set(`${mapping.key}#${path}`, descriptor);
51
+ this.tier2Of.set(descriptor, { mapping, info: mapping.parts.get(path), path });
52
+ }
53
+ }
54
+ this.controls = all;
55
+ this.designSystem = [...designSystem, ...mappings.map(m => m.key)];
56
+ this.tablesRead = new Set([...tablesRead, ...mappings.map(m => m.key)]);
57
+ this.boundByModule = new Map(mappings.map(m => [m.module, m]));
58
+ this.boundByKey = new Map(mappings.map(m => [m.key, m]));
59
+ this.mappedByPackage = new Map(mappings.map(m => [m.package, m]));
60
+ }
61
+ analyze(pageDecl) {
62
+ const result = {
63
+ controls: [],
64
+ rooms: new Set(),
65
+ dialogs: [],
66
+ header: { title: null, subtitles: [] },
67
+ displays: [],
68
+ unbound: [],
69
+ errors: [],
70
+ tier2Uses: [],
71
+ };
72
+ this.importsChecked = new Set();
73
+ const seen = new Set();
74
+ this.walk(pageDecl, { reach: [], itemized: false, props: new Map(), depth: 0 }, result, seen, true);
75
+ this.resolveOpeners(result);
76
+ this.checkApprovedOnce(result);
77
+ return result;
78
+ }
79
+ // ─── Components ────────────────────────────────────────────────────────────
80
+ body(decl) {
81
+ if (ts.isFunctionDeclaration(decl) || ts.isFunctionExpression(decl) || ts.isArrowFunction(decl)) {
82
+ return decl.body ?? null;
83
+ }
84
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
85
+ const init = unwrap(decl.initializer);
86
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init))
87
+ return init.body;
88
+ if (ts.isCallExpression(init)) {
89
+ // forwardRef(...), memo(...), memo(forwardRef(...))
90
+ for (const arg of init.arguments) {
91
+ const inner = unwrap(arg);
92
+ if (ts.isArrowFunction(inner) || ts.isFunctionExpression(inner))
93
+ return inner.body;
94
+ if (ts.isCallExpression(inner)) {
95
+ const f = inner.arguments
96
+ .map(unwrap)
97
+ .find(a => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
98
+ if (f && (ts.isArrowFunction(f) || ts.isFunctionExpression(f)))
99
+ return f.body;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+ componentFunction(decl) {
107
+ if (ts.isFunctionDeclaration(decl) || ts.isFunctionExpression(decl) || ts.isArrowFunction(decl))
108
+ return decl;
109
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
110
+ const init = unwrap(decl.initializer);
111
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init))
112
+ return init;
113
+ if (ts.isCallExpression(init)) {
114
+ for (const arg of init.arguments) {
115
+ const inner = unwrap(arg);
116
+ if (ts.isArrowFunction(inner) || ts.isFunctionExpression(inner))
117
+ return inner;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ componentName(decl) {
124
+ if ((ts.isFunctionDeclaration(decl) || ts.isVariableDeclaration(decl)) &&
125
+ decl.name &&
126
+ ts.isIdentifier(decl.name)) {
127
+ return decl.name.text;
128
+ }
129
+ return '(anonymous)';
130
+ }
131
+ /** The props a component destructures: local name → prop name. */
132
+ destructuredProps(decl) {
133
+ const fn = this.componentFunction(decl);
134
+ const out = new Map();
135
+ const param = fn?.parameters[0];
136
+ if (param && ts.isObjectBindingPattern(param.name)) {
137
+ for (const el of param.name.elements) {
138
+ if (ts.isIdentifier(el.name)) {
139
+ const prop = el.propertyName ? el.propertyName.getText() : el.name.text;
140
+ out.set(el.name.text, prop);
141
+ }
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ walk(decl, ctx, out, seen, isPage) {
147
+ const body = this.body(decl);
148
+ if (!body)
149
+ return;
150
+ const sf = decl.getSourceFile();
151
+ if (!this.source.isAppFile(sf))
152
+ return;
153
+ const key = `${sf.fileName}:${decl.pos}:${ctx.element ? `${ctx.element.getSourceFile().fileName}@${ctx.element.pos}` : ''}:${ctx.reach.map(r => JSON.stringify({ ...r, owner: undefined, setters: undefined })).join('>')}:${ctx.itemized}`;
154
+ if (seen.has(key) || ctx.depth > 40)
155
+ return;
156
+ seen.add(key);
157
+ const component = this.componentName(decl);
158
+ const tabs = this.boundTabs(body);
159
+ this.checkMappedImports(sf, out);
160
+ // Rooms this component registers, itself or through the app's own hooks.
161
+ this.findRegistrations(body, out, new Set());
162
+ const visit = (n) => {
163
+ if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n))
164
+ this.element(n, decl, component, body, ctx, tabs, out, seen, isPage);
165
+ ts.forEachChild(n, visit);
166
+ };
167
+ visit(body);
168
+ }
169
+ /**
170
+ * `useRoomRegistration(catalog, …)` in a body, or in an app hook it calls
171
+ * (`useVectorBoardSurface()`), followed hook by hook.
172
+ */
173
+ findRegistrations(node, out, seen, args = new Map()) {
174
+ // An argument named by one of the hook's own parameters is what its caller passed.
175
+ const passed = (expr) => {
176
+ const e = unwrap(expr);
177
+ return ts.isIdentifier(e) && args.has(e.text) ? args.get(e.text) : expr;
178
+ };
179
+ const visit = (n) => {
180
+ if (ts.isCallExpression(n)) {
181
+ const callee = n.expression;
182
+ if (callee.getText() === 'useRoomRegistration' && n.arguments[0]) {
183
+ const catalog = this.catalogFor(passed(n.arguments[0]));
184
+ if (catalog)
185
+ out.rooms.add(catalog);
186
+ }
187
+ else if (ts.isIdentifier(callee) && /^use[A-Z]/.test(callee.text)) {
188
+ const symbol = resolveSymbol(this.source.checker, callee);
189
+ const decl = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
190
+ if (decl && this.source.isAppFile(decl.getSourceFile())) {
191
+ // A hook that registers the catalog it is given is followed once per catalog it is given.
192
+ const callArgs = n.arguments.map(passed);
193
+ const key = `${decl.getSourceFile().fileName}:${decl.pos}:${callArgs.map(a => a.getText()).join(',')}`;
194
+ if (!seen.has(key)) {
195
+ seen.add(key);
196
+ const body = this.body(decl);
197
+ const params = this.componentFunction(decl)?.parameters ?? [];
198
+ const inner = new Map();
199
+ params.forEach((param, i) => {
200
+ if (ts.isIdentifier(param.name) && callArgs[i])
201
+ inner.set(param.name.text, callArgs[i]);
202
+ });
203
+ if (body)
204
+ this.findRegistrations(body, out, seen, inner);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ ts.forEachChild(n, visit);
210
+ };
211
+ visit(node);
212
+ }
213
+ /** The catalog an expression names: a package's, by import, or the app's own, by module and export. */
214
+ catalogFor(expr) {
215
+ const pkg = this.importSource(expr);
216
+ const byPackage = pkg ? this.catalogs.find(c => c.package === pkg) : undefined;
217
+ if (byPackage)
218
+ return byPackage;
219
+ const id = unwrap(expr);
220
+ if (!ts.isIdentifier(id))
221
+ return undefined;
222
+ const symbol = resolveSymbol(this.source.checker, id);
223
+ const decl = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
224
+ if (!decl || !symbol)
225
+ return undefined;
226
+ const file = decl.getSourceFile().fileName;
227
+ return this.catalogs.find(c => c.appModule?.file === file && c.appModule.export === symbol.name);
228
+ }
229
+ /**
230
+ * The package a JSX tag or identifier is imported from, when it is a bare
231
+ * package specifier; a bound module's key (`bound:<package>`) when it comes
232
+ * from a tier 2 bound module, however the app imports that module.
233
+ */
234
+ importSource(node) {
235
+ const bound = this.boundModuleOf(node);
236
+ if (bound)
237
+ return bound.key;
238
+ const id = ts.isPropertyAccessExpression(node) ? node.expression : node;
239
+ if (!ts.isIdentifier(id))
240
+ return null;
241
+ const symbol = this.source.checker.getSymbolAtLocation(id);
242
+ const decl = symbol?.declarations?.[0];
243
+ if (!decl)
244
+ return null;
245
+ let n = decl;
246
+ while (n && !ts.isImportDeclaration(n))
247
+ n = n.parent;
248
+ if (!n || !ts.isImportDeclaration(n) || !ts.isStringLiteral(n.moduleSpecifier))
249
+ return null;
250
+ return n.moduleSpecifier.text;
251
+ }
252
+ /** The bound module a tag or identifier comes from, through any alias or namespace import of it. */
253
+ boundModuleOf(node) {
254
+ if (!this.boundByModule.size)
255
+ return undefined;
256
+ let id = node;
257
+ while (ts.isPropertyAccessExpression(id))
258
+ id = id.expression;
259
+ if (!ts.isIdentifier(id))
260
+ return undefined;
261
+ const target = resolveSymbol(this.source.checker, id);
262
+ const decl = target?.valueDeclaration ?? target?.declarations?.[0];
263
+ return decl ? this.boundByModule.get(decl.getSourceFile().fileName) : undefined;
264
+ }
265
+ /** A bound tag's export path in its package (`Select.Root`), through `as` renames and namespace imports. */
266
+ boundExportPath(tag) {
267
+ const chain = [];
268
+ let node = tag;
269
+ while (ts.isPropertyAccessExpression(node)) {
270
+ chain.unshift(node.name.text);
271
+ node = node.expression;
272
+ }
273
+ const decl = this.source.checker.getSymbolAtLocation(node)?.declarations?.[0];
274
+ if (decl && ts.isNamespaceImport(decl))
275
+ return chain.join('.');
276
+ const head = decl && ts.isImportSpecifier(decl) ? (decl.propertyName ?? decl.name).text : node.getText();
277
+ return [head, ...chain].join('.');
278
+ }
279
+ /** The name a control is looked up by in its package's table: a bound tag's export path, or the imported name. */
280
+ controlName(pkg, tag) {
281
+ return this.boundByKey.has(pkg) ? this.boundExportPath(tag) : this.importedName(tag);
282
+ }
283
+ /**
284
+ * On an enforced page, a mapped control is imported from its bound module,
285
+ * never straight from its package (ADR-0226 §2.3, §2.5 row 6): the
286
+ * package's own control carries no binding. Each import that does is
287
+ * unbound, naming the import to use instead.
288
+ */
289
+ checkMappedImports(sf, out) {
290
+ if (!this.mappedByPackage.size || this.importsChecked.has(sf.fileName))
291
+ return;
292
+ this.importsChecked.add(sf.fileName);
293
+ for (const statement of sf.statements) {
294
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
295
+ continue;
296
+ const mapping = this.mappedByPackage.get(statement.moduleSpecifier.text);
297
+ const bindings = statement.importClause?.namedBindings;
298
+ if (!mapping || !bindings || statement.importClause?.isTypeOnly)
299
+ continue;
300
+ const names = [];
301
+ if (ts.isNamedImports(bindings)) {
302
+ for (const element of bindings.elements) {
303
+ const name = (element.propertyName ?? element.name).text;
304
+ if (!element.isTypeOnly && mapping.exports.has(name))
305
+ names.push({ name, node: element });
306
+ }
307
+ }
308
+ else {
309
+ // `import * as M from '<package>'`: each mapped export the file reads through it.
310
+ const local = bindings.name.text;
311
+ const used = new Set();
312
+ const visit = (n) => {
313
+ if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === local && mapping.exports.has(n.name.text))
314
+ used.add(n.name.text);
315
+ ts.forEachChild(n, visit);
316
+ };
317
+ visit(sf);
318
+ for (const name of [...used].sort())
319
+ names.push({ name, node: bindings });
320
+ }
321
+ let spec = relative(dirname(sf.fileName), mapping.module).replace(/\.ts$/, '');
322
+ if (!spec.startsWith('.'))
323
+ spec = `./${spec}`;
324
+ for (const { name, node } of names) {
325
+ out.unbound.push({
326
+ file: this.source.rel(sf),
327
+ line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
328
+ message: `${name} is imported straight from ${mapping.package}, which ${mapping.file} maps: import it from "${spec}", whose ${name} carries the assistant’s binding`,
329
+ });
330
+ }
331
+ }
332
+ }
333
+ /** A use of a mapped control's root: what it passes, and whether that includes the prop that shows its value. */
334
+ recordTier2Use(el, tag, info, out) {
335
+ const sf = el.getSourceFile();
336
+ const props = el.attributes.properties.flatMap(a => (ts.isJsxAttribute(a) ? [a.name.getText()] : []));
337
+ const spread = el.attributes.properties.some(a => ts.isJsxSpreadAttribute(a));
338
+ const controlled = info.mapping.controlled;
339
+ out.tier2Uses.push({
340
+ component: info.control,
341
+ tag,
342
+ file: this.source.rel(sf),
343
+ line: sf.getLineAndCharacterOfPosition(el.getStart()).line + 1,
344
+ props,
345
+ ...(controlled ? { controlled } : {}),
346
+ uncontrolled: !!controlled && !props.includes(controlled),
347
+ spread,
348
+ });
349
+ }
350
+ /**
351
+ * A mapped control's options: the items a compound control renders under
352
+ * it, or the entries of the prop its mapping names. Empty when any of them
353
+ * cannot be read at build time, so the schema is never narrower than the
354
+ * control.
355
+ */
356
+ tier2Options(el, descriptor, mapping, info) {
357
+ const item = info.mapping.parts?.item;
358
+ if (!item) {
359
+ if (!descriptor.options)
360
+ return [];
361
+ const value = evaluate(this.source.checker, asExpr(jsxAttribute(el, descriptor.options.prop)));
362
+ return value === UNKNOWN ? [] : (mappedOptions(descriptor.options, value) ?? []);
363
+ }
364
+ if (!ts.isJsxOpeningElement(el))
365
+ return [];
366
+ const valueProp = item.valueProp ?? 'value';
367
+ const titleProps = item.titleProps ?? ['children'];
368
+ const options = [];
369
+ let unreadable = false;
370
+ const visit = (n) => {
371
+ if (unreadable)
372
+ return;
373
+ if ((ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && this.importSource(n.tagName) === mapping.key && this.boundExportPath(n.tagName) === item.export) {
374
+ const value = evaluate(this.source.checker, asExpr(jsxAttribute(n, valueProp)));
375
+ if (typeof value !== 'string' && typeof value !== 'number') {
376
+ unreadable = true;
377
+ return;
378
+ }
379
+ let title = '';
380
+ for (const prop of titleProps) {
381
+ if (prop === 'children')
382
+ title = ts.isJsxOpeningElement(n) ? deepText(this.source.checker, n.parent) : '';
383
+ else {
384
+ const v = evaluate(this.source.checker, asExpr(jsxAttribute(n, prop)));
385
+ title = typeof v === 'string' ? v.trim() : '';
386
+ }
387
+ if (title)
388
+ break;
389
+ }
390
+ options.push({ value, title: title || String(value), ...(jsxAttribute(n, 'disabled') === true ? { disabled: true } : {}) });
391
+ }
392
+ ts.forEachChild(n, visit);
393
+ };
394
+ el.parent.children.forEach(visit);
395
+ return unreadable ? [] : options;
396
+ }
397
+ /** The exported name a JSX tag imports from a package (through `as` renames). */
398
+ importedName(tag) {
399
+ const id = ts.isPropertyAccessExpression(tag) ? tag.expression : tag;
400
+ const symbol = this.source.checker.getSymbolAtLocation(id);
401
+ const decl = symbol?.declarations?.[0];
402
+ if (decl && ts.isImportSpecifier(decl))
403
+ return (decl.propertyName ?? decl.name).text;
404
+ return tag.getText();
405
+ }
406
+ // ─── One JSX element ───────────────────────────────────────────────────────
407
+ element(el, decl, component, body, ctx, tabs, out, seen, isPage) {
408
+ const tagText = el.tagName.getText();
409
+ if (!/^[A-Z]/.test(tagText.split('.').pop() ?? '')) {
410
+ this.intrinsic(el, tagText, out);
411
+ return;
412
+ }
413
+ const pkg = this.importSource(el.tagName);
414
+ const local = this.localReach(el, body, tabs, out, decl, ctx);
415
+ const reach = [...ctx.reach, ...local.reach];
416
+ const itemized = local.itemized ?? ctx.itemized;
417
+ if (pkg && this.designSystem.includes(pkg)) {
418
+ const name = this.controlName(pkg, el.tagName);
419
+ if (isPage && name === 'PageHeader' && out.header.title === null) {
420
+ const title = evaluate(this.source.checker, asExpr(jsxAttribute(el, 'title')));
421
+ out.header.title = typeof title === 'string' ? title : null;
422
+ out.header.subtitles = stringLiterals(asExpr(jsxAttribute(el, 'subtitle')));
423
+ }
424
+ const descriptor = this.controls.get(`${pkg}#${name}`);
425
+ const bound = this.boundByKey.get(pkg);
426
+ if (descriptor)
427
+ this.control(el, descriptor, decl, component, ctx, reach, itemized, out);
428
+ else if (bound)
429
+ this.foreign(el, tagText, `the bound ${bound.package}`, out, `is not mapped in ${bound.file}: map it there`);
430
+ else if (this.tablesRead.has(pkg))
431
+ this.foreign(el, tagText, pkg, out, `is not in ${pkg}'s control table: bind it there`);
432
+ return;
433
+ }
434
+ // A mapped control imported straight from its package: its import is reported (checkMappedImports).
435
+ if (pkg && this.mappedByPackage.get(pkg)?.exports.has(this.importedName(el.tagName)))
436
+ return;
437
+ const personOnly = pkg ? this.personOnly(pkg).get(this.importedName(el.tagName)) : undefined;
438
+ if (pkg && personOnly) {
439
+ this.personOnlyElement(el, tagText, pkg, personOnly, out);
440
+ return;
441
+ }
442
+ const room = pkg && this.catalogs.find(c => c.package === pkg && c.hosts.includes(this.importedName(el.tagName)));
443
+ if (room) {
444
+ out.rooms.add(room);
445
+ return;
446
+ }
447
+ // One of the app's own components: read it with what this element passes it.
448
+ const target = this.appComponent(el.tagName);
449
+ if (!target) {
450
+ this.foreign(el, tagText, pkg, out, 'is neither a design-system control nor a room host: use a bound control that does the same');
451
+ return;
452
+ }
453
+ const appRoom = this.catalogs.find(c => c.appModule && c.hosts.includes(tagText));
454
+ if (appRoom)
455
+ out.rooms.add(appRoom);
456
+ const props = new Map();
457
+ for (const attr of el.attributes.properties) {
458
+ if (ts.isJsxAttribute(attr) &&
459
+ attr.initializer &&
460
+ ts.isJsxExpression(attr.initializer) &&
461
+ attr.initializer.expression) {
462
+ props.set(attr.name.getText(), { expr: attr.initializer.expression, owner: decl, ctx });
463
+ }
464
+ }
465
+ this.walk(target, { reach, itemized, props, depth: ctx.depth + 1, element: el }, out, seen, false);
466
+ }
467
+ /** The app's own component a tag names, through `lazy` and `React.lazy`; null for anything else. */
468
+ appComponent(tag) {
469
+ const decl = componentDeclaration(this.source, tag);
470
+ return decl && this.source.isAppFile(decl.getSourceFile()) ? decl : null;
471
+ }
472
+ /**
473
+ * A component with no control table entry — a third-party library's, or a
474
+ * design-system export the table leaves out — cannot carry a binding, so a
475
+ * callback passed to it is something someone can use that the assistant
476
+ * never can. On an enforced page it must be bound, or say why the assistant
477
+ * never needs it with `data-non-agent="<reason>"`. `fix` says which.
478
+ */
479
+ foreign(el, tag, pkg, out, fix) {
480
+ const callbacks = el.attributes.properties.flatMap(attr => ts.isJsxAttribute(attr) && CALLBACK_PROP.test(attr.name.getText()) && this.takesFunction(attr)
481
+ ? [attr.name.getText()]
482
+ : []);
483
+ if (!callbacks.length || this.nonAgentReason(el))
484
+ return;
485
+ const sf = el.getSourceFile();
486
+ out.unbound.push({
487
+ file: this.source.rel(sf),
488
+ line: sf.getLineAndCharacterOfPosition(el.getStart()).line + 1,
489
+ message: `<${tag}> from ${pkg ?? 'outside the app'} takes ${callbacks.join(', ')}, but ${fix}, or data-non-agent="<reason>"`,
490
+ });
491
+ }
492
+ /**
493
+ * A component only the person may use (ADR-0228 §2.2.3): it takes no `agent`
494
+ * binding, not even `nonAgent`, and no spread, which could carry one the
495
+ * build cannot read. What it renders is the package's own and never bound,
496
+ * so its callbacks are not something the assistant is missing.
497
+ */
498
+ personOnlyElement(el, tag, pkg, why, out) {
499
+ const sf = el.getSourceFile();
500
+ const at = { file: this.source.rel(sf), line: sf.getLineAndCharacterOfPosition(el.getStart()).line + 1 };
501
+ if (hasJsxAttribute(el, 'agent')) {
502
+ out.errors.push({ ...at, message: `<${tag}> from ${pkg} is the person's own and takes no agent binding: ${why}` });
503
+ }
504
+ if (el.attributes.properties.some(attr => ts.isJsxSpreadAttribute(attr))) {
505
+ out.errors.push({
506
+ ...at,
507
+ message: `<${tag}> from ${pkg} is the person's own: write its props out, not as a spread, so the build can see none is an agent binding`,
508
+ });
509
+ }
510
+ }
511
+ /** Whether an attribute's value is a function: written as one, or typed as one. */
512
+ takesFunction(attr) {
513
+ const init = attr.initializer;
514
+ if (!init || !ts.isJsxExpression(init) || !init.expression)
515
+ return false;
516
+ const e = unwrap(init.expression);
517
+ if (ts.isArrowFunction(e) || ts.isFunctionExpression(e))
518
+ return true;
519
+ const type = this.source.checker.getNonNullableType(this.source.checker.getTypeAtLocation(e));
520
+ return type.getCallSignatures().length > 0;
521
+ }
522
+ /** `data-non-agent="<reason>"`, when it gives a reason. */
523
+ nonAgentReason(el) {
524
+ const reason = jsxAttribute(el, 'data-non-agent');
525
+ if (reason === undefined || reason === true)
526
+ return false;
527
+ const text = evaluate(this.source.checker, reason);
528
+ return typeof text === 'string' && !!text.trim();
529
+ }
530
+ /**
531
+ * A raw element someone can use — a `<button>`, an `<input>`, a clickable
532
+ * `<div>` — can carry no binding, so the assistant can never use it. On an
533
+ * enforced page it must be a design-system control instead, or say why the
534
+ * assistant never needs it with `data-non-agent="<reason>"`.
535
+ */
536
+ intrinsic(el, tag, out) {
537
+ const interactive = INTERACTIVE_TAGS.has(tag) || INTERACTIVE_HANDLERS.some(h => hasJsxAttribute(el, h));
538
+ if (!interactive)
539
+ return;
540
+ if (tag === 'input' &&
541
+ ['hidden', 'file'].includes(String(evaluate(this.source.checker, asExpr(jsxAttribute(el, 'type')))))) {
542
+ if (!INTERACTIVE_HANDLERS.some(h => hasJsxAttribute(el, h)))
543
+ return;
544
+ }
545
+ if (this.nonAgentReason(el))
546
+ return;
547
+ const sf = el.getSourceFile();
548
+ out.unbound.push({
549
+ file: this.source.rel(sf),
550
+ line: sf.getLineAndCharacterOfPosition(el.getStart()).line + 1,
551
+ message: `A raw <${tag}> someone can use cannot carry a binding: use the design system's control, or data-non-agent="<reason>"`,
552
+ });
553
+ }
554
+ // ─── Where an element sits in its component ────────────────────────────────
555
+ /** SimpleTabs-like containers bound in this component, by the expression their state prop reads. */
556
+ boundTabs(body) {
557
+ const out = new Map();
558
+ const visit = (n) => {
559
+ if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) {
560
+ const pkg = this.importSource(n.tagName);
561
+ const descriptor = pkg ? this.controls.get(`${pkg}#${this.controlName(pkg, n.tagName)}`) : undefined;
562
+ if (descriptor?.container?.kind === 'tabs') {
563
+ const state = asExpr(jsxAttribute(n, descriptor.container.stateProp));
564
+ const agent = evaluate(this.source.checker, asExpr(jsxAttribute(n, 'agent')));
565
+ if (state && isAgentBinding(agent)) {
566
+ const options = new Map();
567
+ for (const o of this.options(n, descriptor))
568
+ options.set(String(o.value), o.title);
569
+ out.set(state.getText(), { binding: agent.id, options });
570
+ }
571
+ }
572
+ }
573
+ ts.forEachChild(n, visit);
574
+ };
575
+ visit(body);
576
+ return out;
577
+ }
578
+ /** The containers between an element and its component's root, and whether it is rendered per row. */
579
+ localReach(el, body, tabs, out, decl, ctx) {
580
+ const steps = [];
581
+ // Whether the element is one of a list's rows. A dialog between it and a
582
+ // list resets that: a dialog rendered per row is open for one row at a
583
+ // time. `null` means nothing here decides, so the parent's answer stands.
584
+ let itemized = null;
585
+ let child = ts.isJsxOpeningElement(el) ? el.parent : el;
586
+ let node = child.parent;
587
+ while (node && node !== body) {
588
+ if (ts.isCallExpression(node) &&
589
+ ts.isPropertyAccessExpression(node.expression) &&
590
+ ITERATORS.has(node.expression.name.text)) {
591
+ if (node.arguments.includes(child) && itemized === null)
592
+ itemized = true;
593
+ }
594
+ const tab = this.tabCondition(node, child, tabs);
595
+ if (tab)
596
+ steps.unshift(tab);
597
+ const panel = tab ? null : this.panelCondition(node, child, out, decl, ctx);
598
+ if (panel)
599
+ steps.unshift(panel);
600
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
601
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
602
+ const dialog = this.dialogStep(opening, out, decl, ctx);
603
+ if (dialog && opening !== el) {
604
+ steps.unshift(dialog);
605
+ if (itemized === null)
606
+ itemized = false;
607
+ }
608
+ }
609
+ child = node;
610
+ node = node.parent;
611
+ }
612
+ return { reach: steps, itemized };
613
+ }
614
+ /**
615
+ * `selectedId && <Panel/>` or `open ? <Panel/> : …`: a part of the page shown
616
+ * once some state is set. Its openers are the bound controls whose callback
617
+ * sets that state (found with the dialogs' openers).
618
+ */
619
+ panelCondition(node, child, out, decl, ctx) {
620
+ let condition = null;
621
+ let branch = null;
622
+ if (ts.isBinaryExpression(node) &&
623
+ node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
624
+ child === node.right) {
625
+ condition = node.left;
626
+ branch = node.right;
627
+ }
628
+ else if (ts.isConditionalExpression(node) && child === node.whenTrue) {
629
+ condition = node.condition;
630
+ branch = node.whenTrue;
631
+ }
632
+ if (!condition || !branch)
633
+ return null;
634
+ const gates = gateIdentifiers(condition);
635
+ if (!gates.length)
636
+ return null;
637
+ const site = `${node.getSourceFile().fileName}:${node.pos}:panel`;
638
+ const existing = out.dialogs.find(d => d.site === site);
639
+ if (existing)
640
+ return existing;
641
+ const resolved = gates.map(g => this.throughProps(g, decl, ctx));
642
+ const tag = firstComponentTag(branch);
643
+ const step = {
644
+ kind: 'panel',
645
+ title: tag ? words(tag) : humanizeId(gates[0].getText()),
646
+ openedBy: [],
647
+ site,
648
+ owner: resolved[0].owner,
649
+ setters: resolved.flatMap(r => identifiers(r.expr)).map(n => `set${n[0].toUpperCase()}${n.slice(1)}`),
650
+ };
651
+ out.dialogs.push(step);
652
+ return step;
653
+ }
654
+ /** `tab === 'x' ? <A/> : …` or `tab === 'x' && <A/>`, where `tab` is a bound tab set's state. */
655
+ tabCondition(node, child, tabs) {
656
+ let condition = null;
657
+ let whenTrue = true;
658
+ if (ts.isConditionalExpression(node) && (child === node.whenTrue || child === node.whenFalse)) {
659
+ condition = node.condition;
660
+ whenTrue = child === node.whenTrue;
661
+ }
662
+ else if (ts.isBinaryExpression(node) &&
663
+ node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
664
+ child === node.right) {
665
+ condition = node.left;
666
+ }
667
+ if (!condition)
668
+ return null;
669
+ const c = unwrap(condition);
670
+ if (!ts.isBinaryExpression(c) || !isComparison(c.operatorToken.kind))
671
+ return null;
672
+ const negated = c.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
673
+ c.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken;
674
+ if (negated === whenTrue)
675
+ return null;
676
+ const [stateSide, literalSide] = ts.isStringLiteral(unwrap(c.right))
677
+ ? [c.left, c.right]
678
+ : [c.right, c.left];
679
+ const value = evaluate(this.source.checker, literalSide);
680
+ const tab = tabs.get(unwrap(stateSide).getText());
681
+ if (!tab || typeof value !== 'string')
682
+ return null;
683
+ return { kind: 'tab', binding: tab.binding, value, title: tab.options.get(value) ?? value };
684
+ }
685
+ dialogStep(opening, out, decl, ctx) {
686
+ const pkg = this.importSource(opening.tagName);
687
+ if (!pkg || !this.designSystem.includes(pkg))
688
+ return null;
689
+ const descriptor = this.controls.get(`${pkg}#${this.controlName(pkg, opening.tagName)}`);
690
+ if (descriptor?.container?.kind !== 'dialog')
691
+ return null;
692
+ const site = `${opening.getSourceFile().fileName}:${opening.pos}`;
693
+ const existing = out.dialogs.find(d => d.site === site);
694
+ if (existing)
695
+ return existing;
696
+ const titleProp = descriptor.titleProps[0];
697
+ const title = titleProp
698
+ ? evaluate(this.source.checker, asExpr(jsxAttribute(opening, titleProp)))
699
+ : UNKNOWN;
700
+ const agent = evaluate(this.source.checker, asExpr(jsxAttribute(opening, 'agent')));
701
+ const bound = isAgentBinding(agent) ? agent : null;
702
+ const step = {
703
+ kind: 'dialog',
704
+ title: bound?.title ??
705
+ (typeof title === 'string' && title.trim()
706
+ ? title
707
+ : bound
708
+ ? humanizeId(bound.id)
709
+ : words(this.componentName(decl))),
710
+ openedBy: [],
711
+ site,
712
+ ...(isAgentBinding(agent) ? { binding: agent.id } : {}),
713
+ };
714
+ const state = asExpr(jsxAttribute(opening, descriptor.container.stateProp));
715
+ if (state) {
716
+ const resolved = this.throughProps(state, decl, ctx);
717
+ step.owner = resolved.owner;
718
+ step.setters = identifiers(resolved.expr).map(n => `set${n[0].toUpperCase()}${n.slice(1)}`);
719
+ }
720
+ out.dialogs.push(step);
721
+ return step;
722
+ }
723
+ /**
724
+ * An expression followed up through the props it was passed down as: a
725
+ * dialog's `open={open}` in a child is the parent's `open={createOpen}`,
726
+ * and a button's `onClick={onCreate}` is the parent's `onCreate={() => …}`.
727
+ * Returns the expression where it is written and the component it is in.
728
+ */
729
+ throughProps(expr, owner, ctx) {
730
+ const e = unwrap(expr);
731
+ if (ts.isIdentifier(e)) {
732
+ const prop = this.destructuredProps(owner).get(e.text);
733
+ const from = prop !== undefined ? ctx.props.get(prop) : undefined;
734
+ if (from)
735
+ return this.throughProps(from.expr, from.owner, from.ctx);
736
+ }
737
+ if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression)) {
738
+ const fn = this.componentFunction(owner);
739
+ const param = fn?.parameters[0];
740
+ if (param && ts.isIdentifier(param.name) && param.name.text === e.expression.text) {
741
+ const from = ctx.props.get(e.name.text);
742
+ if (from)
743
+ return this.throughProps(from.expr, from.owner, from.ctx);
744
+ }
745
+ }
746
+ return { expr, owner };
747
+ }
748
+ /**
749
+ * The `agent` a control is given, followed through the props of the
750
+ * wrappers that forward it. `missing` is the wrapper element that forwards
751
+ * nothing because its own user gave it no binding.
752
+ */
753
+ forwardedAgent(expr, owner, ctx) {
754
+ if (!expr)
755
+ return {};
756
+ const e = unwrap(expr);
757
+ let prop;
758
+ if (ts.isIdentifier(e))
759
+ prop = this.destructuredProps(owner).get(e.text);
760
+ else if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression)) {
761
+ const param = this.componentFunction(owner)?.parameters[0];
762
+ if (param && ts.isIdentifier(param.name) && param.name.text === e.expression.text)
763
+ prop = e.name.text;
764
+ }
765
+ if (prop === undefined)
766
+ return { expr };
767
+ const from = ctx.props.get(prop);
768
+ if (!from)
769
+ return ctx.element ? { missing: ctx.element } : { expr };
770
+ return this.forwardedAgent(from.expr, from.owner, from.ctx);
771
+ }
772
+ // ─── One design-system control ─────────────────────────────────────────────
773
+ control(el, descriptor, decl, component, ctx, reachIn, itemized, out) {
774
+ let reach = reachIn;
775
+ const sf = el.getSourceFile();
776
+ const resolved = (e) => (e ? [this.throughProps(e, decl, ctx)] : []);
777
+ const at = { file: this.source.rel(sf), line: sf.getLineAndCharacterOfPosition(el.getStart()).line + 1 };
778
+ const tier2 = this.tier2Of.get(descriptor);
779
+ const tag = tier2 ? tier2.path : this.importedName(el.tagName);
780
+ if (tier2?.info.part === 'root')
781
+ this.recordTier2Use(el, tag, tier2.info, out);
782
+ // A wrapper may forward its own `agent` prop to the control it renders:
783
+ // the binding is then declared where the wrapper is used.
784
+ const forwarded = this.forwardedAgent(asExpr(jsxAttribute(el, 'agent')), decl, ctx);
785
+ if (forwarded.missing) {
786
+ const parent = forwarded.missing;
787
+ const psf = parent.getSourceFile();
788
+ out.unbound.push({
789
+ file: this.source.rel(psf),
790
+ line: psf.getLineAndCharacterOfPosition(parent.getStart()).line + 1,
791
+ message: `<${parent.tagName.getText()}> passes no agent to the <${tag}> it renders`,
792
+ });
793
+ return;
794
+ }
795
+ const agentExpr = forwarded.expr;
796
+ // A binding forwarded through a wrapper is declared where the wrapper is used,
797
+ // so each use is its own site, even when the uses sit in the wrapper's own file.
798
+ const isForwarded = !!agentExpr && agentExpr !== asExpr(jsxAttribute(el, 'agent'));
799
+ const agent = agentExpr ? evaluate(this.source.checker, agentExpr) : undefined;
800
+ if (agentExpr && (agent === UNKNOWN || typeof agent !== 'object' || agent === null)) {
801
+ out.errors.push({
802
+ ...at,
803
+ message: `<${tag} agent={…}> must be readable at build time: an object literal or a constant`,
804
+ });
805
+ return;
806
+ }
807
+ if (agentExpr && isForwarded) {
808
+ const asf = agentExpr.getSourceFile();
809
+ at.file = this.source.rel(asf);
810
+ at.line = asf.getLineAndCharacterOfPosition(agentExpr.getStart()).line + 1;
811
+ }
812
+ const siteOf = (suffix = '') => `${at.file}:${agentExpr && isForwarded ? agentExpr.pos : el.pos}${suffix}`;
813
+ const agentObj = (agent ?? null);
814
+ const base = {
815
+ declaredIn: at.file,
816
+ line: at.line,
817
+ component,
818
+ owner: decl,
819
+ schemaProps: this.schemaProps(el, descriptor),
820
+ };
821
+ const slotItem = agentObj && 'item' in agentObj;
822
+ // A dialog's own buttons (confirm, cancel, close) are in the dialog.
823
+ if (descriptor.container?.kind === 'dialog') {
824
+ const own = this.dialogStep(el, out, decl, ctx);
825
+ if (own)
826
+ reach = [...reach, own];
827
+ }
828
+ // A display of facts: not a control; bound, it names what the page reports.
829
+ if (descriptor.display) {
830
+ if (agentObj && !('nonAgent' in agentObj)) {
831
+ const binding = this.binding(agentObj, at, tag, out);
832
+ if (binding) {
833
+ // Its facts' labels, read as a menu's entries are: literals, spreads, conditionals, a .map.
834
+ const labelKey = descriptor.display.labelKey;
835
+ const labels = [
836
+ ...new Set(this.arrayOf(asExpr(jsxAttribute(el, descriptor.display.itemsProp)), []).flatMap(o => {
837
+ const label = evaluate(this.source.checker, objectProperty(o, labelKey));
838
+ return typeof label === 'string' ? [label] : [];
839
+ })),
840
+ ];
841
+ out.displays.push({
842
+ site: siteOf(),
843
+ binding,
844
+ title: binding.title ?? this.title(el, descriptor),
845
+ labels,
846
+ reach,
847
+ declaredIn: at.file,
848
+ line: at.line,
849
+ });
850
+ }
851
+ }
852
+ return;
853
+ }
854
+ // The control itself.
855
+ if (descriptor.kind) {
856
+ const interactive = descriptor.callbacks.length === 0 || descriptor.callbacks.some(cb => hasJsxAttribute(el, cb));
857
+ if (!agentObj) {
858
+ if (interactive)
859
+ out.unbound.push({ ...at, message: `<${tag}> has no agent binding` });
860
+ }
861
+ else if (!('nonAgent' in agentObj)) {
862
+ const binding = this.binding(agentObj, at, tag, out);
863
+ if (binding) {
864
+ out.controls.push({
865
+ ...base,
866
+ ...this.fieldGuidance(el, descriptor.kind),
867
+ site: siteOf(),
868
+ binding,
869
+ kind: descriptor.kind,
870
+ title: binding.title ?? this.title(el, descriptor),
871
+ itemized: 'item' in agentObj || !!descriptor.rows,
872
+ reach,
873
+ callbacks: descriptor.callbacks.flatMap(cb => resolved(asExpr(jsxAttribute(el, cb)))),
874
+ });
875
+ this.checkRow(itemized, 'item' in agentObj || !!descriptor.rows, at, binding.id, out);
876
+ }
877
+ }
878
+ else if (typeof agentObj.nonAgent !== 'string' || !agentObj.nonAgent.trim()) {
879
+ out.errors.push({
880
+ ...at,
881
+ message: `<${tag} agent={{ nonAgent }}> needs the reason it is not for the assistant`,
882
+ });
883
+ }
884
+ }
885
+ // A composite's slots.
886
+ for (const [slot, { kind, callback, rows, defaults }] of Object.entries(descriptor.slots ?? {})) {
887
+ // The prop that makes the slot interactive: a callback, or a flag (a table's bare `sortable`).
888
+ // A slot with no such prop is always interactive.
889
+ const attr = callback ? jsxAttribute(el, callback) : true;
890
+ if (attr === undefined)
891
+ continue;
892
+ const cbExpr = asExpr(attr);
893
+ const slotValue = agentObj?.[slot];
894
+ // An EmptyState's `action` is an object whose own `agent` binds it.
895
+ // It may differ by state (`cond ? { label, onClick } : undefined`): each object is bound.
896
+ const objects = cbExpr ? objectsOf(cbExpr).filter(o => objectProperty(o, 'onClick')) : [];
897
+ if (objects.length) {
898
+ for (const o of objects)
899
+ this.entry(o, kind, 'onClick', 'label', decl, component, reach, itemized, tag, base, out);
900
+ continue;
901
+ }
902
+ if (slotValue === undefined) {
903
+ out.unbound.push({
904
+ ...at,
905
+ message: callback
906
+ ? `<${tag}> has ${callback} but no agent binding for "${slot}"`
907
+ : `<${tag}> has no agent binding for "${slot}"`,
908
+ });
909
+ continue;
910
+ }
911
+ if (slotValue && typeof slotValue === 'object' && 'nonAgent' in slotValue)
912
+ continue;
913
+ const binding = this.binding(slotValue, at, `${tag} ${slot}`, out);
914
+ if (!binding)
915
+ continue;
916
+ const titleFromEl = this.title(el, descriptor);
917
+ out.controls.push({
918
+ ...base,
919
+ ...(defaults ? { schemaProps: this.schemaProps(el, descriptor, defaults) } : {}),
920
+ site: siteOf(`:${slot}`),
921
+ binding,
922
+ kind,
923
+ title: binding.title ?? `${humanizeId(slot)}${titleFromEl ? ` (${titleFromEl})` : ''}`,
924
+ itemized: !!slotItem || !!rows,
925
+ reach,
926
+ callbacks: cbExpr ? resolved(cbExpr) : [],
927
+ });
928
+ this.checkRow(itemized, !!slotItem || !!rows, at, binding.id, out);
929
+ }
930
+ // Entries of a toolbar, menu or selection bar.
931
+ if (descriptor.entries) {
932
+ const entriesExpr = asExpr(jsxAttribute(el, descriptor.entries.prop));
933
+ const blind = [];
934
+ const arr = this.arrayOf(entriesExpr, blind);
935
+ for (const node of blind) {
936
+ const nsf = node.getSourceFile();
937
+ out.unbound.push({
938
+ file: this.source.rel(nsf),
939
+ line: nsf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
940
+ message: `Entries of <${tag}>'s \`${descriptor.entries.prop}\` are built where the generator cannot read them ` +
941
+ `(\`${node.getText().slice(0, 60)}\`): build them as an array literal, a .map, or pushes of object literals`,
942
+ });
943
+ }
944
+ const menuStep = descriptor.entries.prop === 'items' && descriptor.kind === undefined && tag !== 'Toolbar'
945
+ ? { kind: 'menu', title: this.title(el, descriptor) || tag }
946
+ : null;
947
+ for (const obj of arr) {
948
+ this.entry(obj, descriptor.entries.kind, descriptor.entries.callback, descriptor.entries.titleKey, decl, component, menuStep ? [...reach, menuStep] : reach, itemized, tag, base, out);
949
+ }
950
+ }
951
+ }
952
+ entry(obj, kind, callback, titleKey, _decl, component, reach, itemized, tag, base, out) {
953
+ const cb = objectProperty(obj, callback);
954
+ const agentExpr = objectProperty(obj, 'agent');
955
+ const sf = obj.getSourceFile();
956
+ const here = {
957
+ file: this.source.rel(sf),
958
+ line: sf.getLineAndCharacterOfPosition(obj.getStart()).line + 1,
959
+ };
960
+ if (!agentExpr) {
961
+ if (cb)
962
+ out.unbound.push({ ...here, message: `An entry of <${tag}> has ${callback} but no agent binding` });
963
+ return;
964
+ }
965
+ const agent = evaluate(this.source.checker, agentExpr);
966
+ if (agent === UNKNOWN || !agent || typeof agent !== 'object') {
967
+ out.errors.push({
968
+ ...here,
969
+ message: `An entry's agent binding in <${tag}> must be readable at build time`,
970
+ });
971
+ return;
972
+ }
973
+ if ('nonAgent' in agent)
974
+ return;
975
+ const binding = this.binding(agent, here, tag, out);
976
+ if (!binding)
977
+ return;
978
+ const title = evaluate(this.source.checker, objectProperty(obj, titleKey));
979
+ out.controls.push({
980
+ ...base,
981
+ component,
982
+ declaredIn: here.file,
983
+ line: here.line,
984
+ site: `${here.file}:${obj.pos}`,
985
+ binding,
986
+ kind,
987
+ title: binding.title ?? (typeof title === 'string' && title.trim() ? title : humanizeId(binding.id)),
988
+ itemized: 'item' in agent,
989
+ reach,
990
+ callbacks: cb ? [{ expr: cb, owner: base.owner }] : [],
991
+ });
992
+ this.checkRow(itemized || this.rowEntries.has(obj), 'item' in agent, here, binding.id, out);
993
+ }
994
+ checkRow(rendered, hasItem, at, id, out) {
995
+ if (rendered && !hasItem) {
996
+ out.errors.push({
997
+ ...at,
998
+ message: `"${id}" is rendered once per row, so its binding needs \`item\` naming the row`,
999
+ });
1000
+ }
1001
+ }
1002
+ binding(value, at, tag, out) {
1003
+ const { id, description, title, effect, destructive, confirm } = value;
1004
+ if (typeof id !== 'string' || typeof description !== 'string' || !description.trim()) {
1005
+ out.errors.push({ ...at, message: `<${tag}>'s binding needs a literal \`id\` and \`description\`` });
1006
+ return null;
1007
+ }
1008
+ if (title !== undefined && typeof title !== 'string') {
1009
+ out.errors.push({ ...at, message: `"${id}": \`title\` must be a literal` });
1010
+ return null;
1011
+ }
1012
+ if (effect !== undefined &&
1013
+ (effect === UNKNOWN || (typeof effect !== 'string' && typeof effect !== 'object'))) {
1014
+ out.errors.push({ ...at, message: `"${id}": \`effect\` must be a literal` });
1015
+ return null;
1016
+ }
1017
+ return {
1018
+ id,
1019
+ description: description.trim(),
1020
+ ...(typeof title === 'string' ? { title } : {}),
1021
+ ...(effect !== undefined ? { effect: effect } : {}),
1022
+ ...(destructive === true ? { destructive: true } : {}),
1023
+ ...(confirm === true ? { confirm: true } : {}),
1024
+ };
1025
+ }
1026
+ title(el, descriptor) {
1027
+ for (const prop of descriptor.titleProps) {
1028
+ if (prop === 'children') {
1029
+ const parent = ts.isJsxOpeningElement(el) ? el.parent : null;
1030
+ const text = parent ? jsxText(this.source.checker, parent) : '';
1031
+ if (text)
1032
+ return text;
1033
+ continue;
1034
+ }
1035
+ const v = evaluate(this.source.checker, asExpr(jsxAttribute(el, prop)));
1036
+ if (typeof v === 'string' && v.trim())
1037
+ return v.trim();
1038
+ }
1039
+ return '';
1040
+ }
1041
+ options(el, descriptor) {
1042
+ const tier2 = this.tier2Of.get(descriptor);
1043
+ if (tier2)
1044
+ return this.tier2Options(el, descriptor, tier2.mapping, tier2.info);
1045
+ if (!descriptor.options)
1046
+ return [];
1047
+ const v = evaluate(this.source.checker, asExpr(jsxAttribute(el, descriptor.options.prop)));
1048
+ if (!Array.isArray(v))
1049
+ return [];
1050
+ const out = [];
1051
+ for (const o of v) {
1052
+ if (!o || typeof o !== 'object')
1053
+ return [];
1054
+ const rec = o;
1055
+ const value = rec[descriptor.options.value];
1056
+ const title = rec[descriptor.options.title];
1057
+ if ((typeof value !== 'string' && typeof value !== 'number') || typeof title !== 'string')
1058
+ return [];
1059
+ out.push({ value, title, ...(rec.disabled === true ? { disabled: true } : {}) });
1060
+ }
1061
+ return out;
1062
+ }
1063
+ /**
1064
+ * A control's `hint`, and a text field's `placeholder`, when each is a
1065
+ * literal or a constant: the words the person reads beside the field. A
1066
+ * value only known at run time (a count, a prop) is left out, so the output
1067
+ * is the same on every build.
1068
+ */
1069
+ fieldGuidance(el, kind) {
1070
+ const text = (prop) => {
1071
+ const attr = jsxAttribute(el, prop);
1072
+ if (attr === undefined || attr === true)
1073
+ return undefined;
1074
+ const v = evaluate(this.source.checker, attr);
1075
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
1076
+ };
1077
+ const hint = text('hint');
1078
+ const placeholder = kind === 'text' ? text('placeholder') : undefined;
1079
+ return { ...(hint ? { hint } : {}), ...(placeholder ? { placeholder } : {}) };
1080
+ }
1081
+ schemaProps(el, descriptor, slotDefaults) {
1082
+ const props = { ...(descriptor.defaults ?? {}), ...(slotDefaults ?? {}) };
1083
+ for (const [key, prop] of Object.entries(descriptor.schemaProps ?? {})) {
1084
+ const attr = jsxAttribute(el, prop);
1085
+ if (attr === undefined)
1086
+ continue;
1087
+ const v = attr === true ? true : evaluate(this.source.checker, attr);
1088
+ if (v === UNKNOWN)
1089
+ continue;
1090
+ props[key] = v;
1091
+ }
1092
+ const options = this.options(el, descriptor);
1093
+ if (options.length)
1094
+ props.options = options;
1095
+ return props;
1096
+ }
1097
+ /**
1098
+ * The object literals of an array prop: a literal, a constant, a
1099
+ * `useMemo(() => [...])`, a `.map`/`.flatMap` per row, or an array built with
1100
+ * `push` (in a loop, entries per row). What it cannot read — a prop passed
1101
+ * from elsewhere, a call, a push of something unknown — goes in `blind`, so
1102
+ * entries the PA would never see are reported rather than silently missing.
1103
+ */
1104
+ arrayOf(expr, blind, depth = 0) {
1105
+ if (!expr)
1106
+ return [];
1107
+ if (depth > 4) {
1108
+ blind.push(expr);
1109
+ return [];
1110
+ }
1111
+ const e = unwrap(expr);
1112
+ if (ts.isArrayLiteralExpression(e)) {
1113
+ return e.elements.flatMap(el => this.arrayElement(el, blind, depth));
1114
+ }
1115
+ // `items.map(i => ({ …, agent }))` and `groups.flatMap(g => [...])`: entries made per row.
1116
+ if (ts.isCallExpression(e) &&
1117
+ ts.isPropertyAccessExpression(e.expression) &&
1118
+ ITERATORS.has(e.expression.name.text)) {
1119
+ const fn = e.arguments[0] && unwrap(e.arguments[0]);
1120
+ if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) {
1121
+ const body = ts.isBlock(fn.body)
1122
+ ? fn.body.statements.find(ts.isReturnStatement)?.expression
1123
+ : fn.body;
1124
+ if (!body)
1125
+ return [];
1126
+ const inner = unwrap(body);
1127
+ const objects = ts.isObjectLiteralExpression(inner) ? [inner] : this.arrayOf(inner, blind, depth + 1);
1128
+ for (const o of objects)
1129
+ this.rowEntries.add(o);
1130
+ return objects;
1131
+ }
1132
+ }
1133
+ if (ts.isConditionalExpression(e))
1134
+ return [...this.arrayOf(e.whenTrue, blind, depth + 1), ...this.arrayOf(e.whenFalse, blind, depth + 1)];
1135
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
1136
+ return this.arrayOf(e.right, blind, depth + 1);
1137
+ if (ts.isIdentifier(e)) {
1138
+ const symbol = this.source.checker.getSymbolAtLocation(e);
1139
+ const decl = symbol?.valueDeclaration;
1140
+ if (decl && ts.isVariableDeclaration(decl) && decl.initializer && symbol) {
1141
+ return [...this.arrayOf(decl.initializer, blind, depth + 1), ...this.pushed(decl, symbol, blind, depth)];
1142
+ }
1143
+ blind.push(e);
1144
+ return [];
1145
+ }
1146
+ if (ts.isCallExpression(e) && /^(useMemo|useCallback)$/.test(e.expression.getText())) {
1147
+ const fn = e.arguments[0] && unwrap(e.arguments[0]);
1148
+ if (fn && ts.isArrowFunction(fn)) {
1149
+ if (!ts.isBlock(fn.body))
1150
+ return this.arrayOf(fn.body, blind, depth + 1);
1151
+ const ret = fn.body.statements.find(ts.isReturnStatement);
1152
+ return this.arrayOf(ret?.expression, blind, depth + 1);
1153
+ }
1154
+ }
1155
+ blind.push(e);
1156
+ return [];
1157
+ }
1158
+ /** One element of an array literal, or of a `push`: an entry, or entries spread into it. */
1159
+ arrayElement(el, blind, depth) {
1160
+ const x = unwrap(ts.isSpreadElement(el) ? el.expression : el);
1161
+ if (ts.isObjectLiteralExpression(x))
1162
+ return [x];
1163
+ if (ts.isConditionalExpression(x))
1164
+ return [...this.arrayOf(x.whenTrue, blind, depth + 1), ...this.arrayOf(x.whenFalse, blind, depth + 1)];
1165
+ if (ts.isArrayLiteralExpression(x) || ts.isIdentifier(x) || ts.isCallExpression(x))
1166
+ return this.arrayOf(x, blind, depth + 1);
1167
+ // A divider or a separator string is not an entry; anything else is unread.
1168
+ if (!ts.isStringLiteral(x) && x.kind !== ts.SyntaxKind.NullKeyword && x.kind !== ts.SyntaxKind.FalseKeyword)
1169
+ blind.push(x);
1170
+ return [];
1171
+ }
1172
+ /**
1173
+ * The entries `push`ed onto an array declared in the same function: each
1174
+ * call `name.push(...)` whose receiver is this declaration. A push inside a
1175
+ * loop, or inside a callback such as `forEach`, makes entries per row.
1176
+ */
1177
+ pushed(decl, symbol, blind, depth) {
1178
+ const scope = enclosingScope(decl);
1179
+ if (!scope)
1180
+ return [];
1181
+ const out = [];
1182
+ const visit = (n) => {
1183
+ if (ts.isCallExpression(n) &&
1184
+ ts.isPropertyAccessExpression(n.expression) &&
1185
+ n.expression.name.text === 'push' &&
1186
+ ts.isIdentifier(n.expression.expression) &&
1187
+ this.source.checker.getSymbolAtLocation(n.expression.expression) === symbol) {
1188
+ const perRow = repeats(n, scope);
1189
+ for (const arg of n.arguments) {
1190
+ const objects = this.arrayElement(arg, blind, depth + 1);
1191
+ if (perRow)
1192
+ for (const o of objects)
1193
+ this.rowEntries.add(o);
1194
+ out.push(...objects);
1195
+ }
1196
+ }
1197
+ ts.forEachChild(n, visit);
1198
+ };
1199
+ visit(scope);
1200
+ return out;
1201
+ }
1202
+ // ─── Dialog openers ────────────────────────────────────────────────────────
1203
+ /**
1204
+ * Each dialog's openers: the bound controls whose callback — directly or
1205
+ * through a function of the same component — calls the setter of the state
1206
+ * the dialog's `open` reads, with something other than false or null; and
1207
+ * any control whose binding declares `effect: { kind: 'open', container }`.
1208
+ */
1209
+ resolveOpeners(out) {
1210
+ for (const dialog of out.dialogs) {
1211
+ const openers = new Set();
1212
+ for (const c of out.controls) {
1213
+ const effect = c.binding.effect;
1214
+ if (effect &&
1215
+ typeof effect === 'object' &&
1216
+ effect.kind === 'open' &&
1217
+ dialog.binding &&
1218
+ effect.container === dialog.binding) {
1219
+ openers.add(c.binding.id);
1220
+ }
1221
+ }
1222
+ if (dialog.setters?.length && dialog.owner) {
1223
+ const setters = new Set(dialog.setters);
1224
+ const localFns = openerFunctions(dialog.owner, setters);
1225
+ for (const c of out.controls) {
1226
+ if (c.reach.includes(dialog))
1227
+ continue;
1228
+ if (c.callbacks.some(cb => cb.owner === dialog.owner && callsSetter(cb.expr, setters, localFns))) {
1229
+ openers.add(c.binding.id);
1230
+ }
1231
+ }
1232
+ }
1233
+ dialog.openedBy = [...openers].sort();
1234
+ }
1235
+ }
1236
+ /**
1237
+ * A destructive step runs only on the person's approval of that call
1238
+ * (ADR-0228), so the control that only opens the dialog where it is
1239
+ * confirmed must not be destructive too: the person would approve opening a
1240
+ * dialog, then approve the step itself. A control that does something of
1241
+ * its own before the dialog shows is approved for that, and is left alone.
1242
+ */
1243
+ checkApprovedOnce(out) {
1244
+ for (const dialog of out.dialogs) {
1245
+ const step = out.controls.find(c => c.binding.destructive && c.reach.includes(dialog));
1246
+ if (!step)
1247
+ continue;
1248
+ for (const opener of out.controls) {
1249
+ if (!opener.binding.destructive || !dialog.openedBy.includes(opener.binding.id) || opener.reach.includes(dialog))
1250
+ continue;
1251
+ // A control that does something itself (rotates a key, then shows the new
1252
+ // one in a dialog) is approved for that; only one that just opens is not.
1253
+ const effect = opener.binding.effect;
1254
+ if (effect !== undefined && effectKind(effect) !== 'open')
1255
+ continue;
1256
+ out.errors.push({
1257
+ file: opener.declaredIn,
1258
+ line: opener.line,
1259
+ message: `"${opener.binding.id}" opens the "${dialog.title}" dialog, where "${step.binding.id}" is the destructive step: ` +
1260
+ `the person approves that one, so "${opener.binding.id}" is not destructive itself. Remove its \`destructive\``,
1261
+ });
1262
+ }
1263
+ }
1264
+ }
1265
+ }
1266
+ function asExpr(v) {
1267
+ return v === true ? undefined : v;
1268
+ }
1269
+ /** The object literals an expression can be: through conditionals and `&&`. */
1270
+ function objectsOf(expr) {
1271
+ const e = unwrap(expr);
1272
+ if (ts.isObjectLiteralExpression(e))
1273
+ return [e];
1274
+ if (ts.isConditionalExpression(e))
1275
+ return [...objectsOf(e.whenTrue), ...objectsOf(e.whenFalse)];
1276
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
1277
+ return objectsOf(e.right);
1278
+ return [];
1279
+ }
1280
+ /** The state a condition waits for: `x`, `!!x`, `x != null`, joined by `&&`. Comparisons with literals are tabs, not state. */
1281
+ function gateIdentifiers(condition) {
1282
+ const e = unwrap(condition);
1283
+ if (ts.isIdentifier(e))
1284
+ return [e];
1285
+ if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) {
1286
+ const inner = unwrap(e.operand);
1287
+ if (ts.isPrefixUnaryExpression(inner) && inner.operator === ts.SyntaxKind.ExclamationToken) {
1288
+ return gateIdentifiers(inner.operand);
1289
+ }
1290
+ return [];
1291
+ }
1292
+ if (ts.isBinaryExpression(e)) {
1293
+ if (e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
1294
+ return [...gateIdentifiers(e.left), ...gateIdentifiers(e.right)];
1295
+ }
1296
+ const notNull = (e.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken ||
1297
+ e.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken) &&
1298
+ (e.right.kind === ts.SyntaxKind.NullKeyword || e.right.getText() === 'undefined');
1299
+ const left = unwrap(e.left);
1300
+ if (notNull && ts.isIdentifier(left))
1301
+ return [left];
1302
+ }
1303
+ return [];
1304
+ }
1305
+ /** The first component (capitalized JSX tag) a branch renders. */
1306
+ function firstComponentTag(node) {
1307
+ let found = null;
1308
+ const visit = (n) => {
1309
+ if (found)
1310
+ return;
1311
+ if ((ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && /^[A-Z]/.test(n.tagName.getText())) {
1312
+ found = n.tagName.getText();
1313
+ return;
1314
+ }
1315
+ ts.forEachChild(n, visit);
1316
+ };
1317
+ visit(node);
1318
+ return found;
1319
+ }
1320
+ /** An element's text, its nested elements' included (`<Item><ItemText>Cash</ItemText></Item>`). */
1321
+ function deepText(checker, node) {
1322
+ const parts = [jsxText(checker, node)];
1323
+ if (ts.isJsxElement(node) || ts.isJsxFragment(node)) {
1324
+ for (const child of node.children)
1325
+ if (ts.isJsxElement(child) || ts.isJsxFragment(child))
1326
+ parts.push(deepText(checker, child));
1327
+ }
1328
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
1329
+ }
1330
+ /** `VoiceDetailPanel` → `Voice Detail Panel`. */
1331
+ function words(name) {
1332
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
1333
+ }
1334
+ function identifiers(expr) {
1335
+ const out = [];
1336
+ const visit = (n) => {
1337
+ if (ts.isIdentifier(n) && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n))
1338
+ out.push(n.text);
1339
+ ts.forEachChild(n, visit);
1340
+ };
1341
+ visit(expr);
1342
+ return [...new Set(out)];
1343
+ }
1344
+ const CLOSING = new Set(['false', 'null', 'undefined']);
1345
+ function callsSetter(node, setters, localFns) {
1346
+ let found = false;
1347
+ const visit = (n) => {
1348
+ if (found)
1349
+ return;
1350
+ if (ts.isCallExpression(n)) {
1351
+ const callee = n.expression.getText();
1352
+ const arg = n.arguments[0]?.getText();
1353
+ if (setters.has(callee) && arg !== undefined && !CLOSING.has(arg))
1354
+ found = true;
1355
+ if (localFns.has(callee))
1356
+ found = true;
1357
+ }
1358
+ if (ts.isIdentifier(n) && localFns.has(n.text))
1359
+ found = true;
1360
+ ts.forEachChild(n, visit);
1361
+ };
1362
+ visit(node);
1363
+ return found;
1364
+ }
1365
+ /** Functions declared in a component whose body opens the dialog: `const openCreate = () => setOpen(true)`. */
1366
+ function openerFunctions(owner, setters) {
1367
+ const out = new Set();
1368
+ const visit = (n) => {
1369
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) {
1370
+ const init = unwrap(n.initializer);
1371
+ const fn = ts.isArrowFunction(init) || ts.isFunctionExpression(init)
1372
+ ? init
1373
+ : ts.isCallExpression(init) && init.expression.getText() === 'useCallback'
1374
+ ? init.arguments[0]
1375
+ : undefined;
1376
+ if (fn && callsSetter(fn, setters, new Set()))
1377
+ out.add(n.name.text);
1378
+ }
1379
+ if (ts.isFunctionDeclaration(n) && n.name && n.body && callsSetter(n.body, setters, new Set()))
1380
+ out.add(n.name.text);
1381
+ ts.forEachChild(n, visit);
1382
+ };
1383
+ visit(owner);
1384
+ return out;
1385
+ }
1386
+ /** Literal routes a callback navigates to: `navigate('/voices/create')`. */
1387
+ export function navigateTargets(nodes) {
1388
+ const out = [];
1389
+ const visit = (n) => {
1390
+ if (ts.isCallExpression(n) && /(^|\.)navigate$/.test(n.expression.getText())) {
1391
+ const arg = n.arguments[0];
1392
+ if (arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)))
1393
+ out.push(arg.text);
1394
+ }
1395
+ ts.forEachChild(n, visit);
1396
+ };
1397
+ nodes.forEach(visit);
1398
+ return [...new Set(out)];
1399
+ }
1400
+ /** The function or source file a declaration is in: where its pushes are. */
1401
+ function enclosingScope(node) {
1402
+ let n = node.parent;
1403
+ while (n && !ts.isFunctionLike(n) && !ts.isSourceFile(n))
1404
+ n = n.parent;
1405
+ return n;
1406
+ }
1407
+ /** Whether a call runs once per row: inside a loop, or a callback, below `scope`. */
1408
+ function repeats(node, scope) {
1409
+ for (let n = node.parent; n && n !== scope; n = n.parent) {
1410
+ if (ts.isForStatement(n) ||
1411
+ ts.isForOfStatement(n) ||
1412
+ ts.isForInStatement(n) ||
1413
+ ts.isWhileStatement(n) ||
1414
+ ts.isDoStatement(n) ||
1415
+ ts.isArrowFunction(n) ||
1416
+ ts.isFunctionExpression(n))
1417
+ return true;
1418
+ }
1419
+ return false;
1420
+ }
1421
+ //# sourceMappingURL=analyze.js.map