@atelier-ui/create-workspace 0.2.41 → 0.2.43

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 (39) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/package.json +1 -1
  3. package/src/generators/preset/files/contracts/README.md +29 -0
  4. package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
  5. package/src/generators/preset/files/contracts/types.ts.template +55 -0
  6. package/src/generators/preset/files/figma/snapshot.json +164 -0
  7. package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
  8. package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
  9. package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
  10. package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
  11. package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
  12. package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
  13. package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
  14. package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
  15. package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
  16. package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
  17. package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
  18. package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
  19. package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
  20. package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
  21. package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
  22. package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
  23. package/src/generators/preset/files/styles/tokens.css +59 -43
  24. package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
  25. package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
  26. package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
  27. package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
  28. package/src/generators/preset/files/tools/scripts/preflight.mjs +220 -30
  29. package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
  30. package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
  31. package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
  32. package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
  33. package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
  34. package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
  35. package/src/generators/preset/preset.d.ts +1 -0
  36. package/src/generators/preset/preset.js +955 -5
  37. package/src/generators/preset/preset.js.map +1 -1
  38. package/src/generators/preset/schema.d.ts +1 -0
  39. package/src/generators/preset/schema.json +5 -0
@@ -0,0 +1,1644 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-contracts.mjs — ADR-0121 Decision 4, stage 1 of "the stories are the spec".
4
+ *
5
+ * Offline, no Storybook build, no browser: joins three DERIVED inputs with the
6
+ * hand-written micro-contracts (`libs/spec/src/contracts/*.contract.ts`) and reports
7
+ * drift. One process per framework, driven by the recipe proven in
8
+ * `tasks/docgen-spike-2026-09-10.md`:
9
+ *
10
+ * 1. Docgen per component — Angular/Vue via the Storybook framework worker
11
+ * (`@storybook/{angular-vite,vue3}/internal/docgen-worker`, story file as the
12
+ * entry point); React via `react-docgen`'s own `parse()` directly (the worker's
13
+ * React export is the react-component-meta engine, inactive in this repo).
14
+ * 2. Story `args` per story via `storybook/internal/csf-tools`
15
+ * (`loadCsf(...).parse()` + `createStoryArgsResolver`).
16
+ * 3. The Figma snapshot, `tools/figma/snapshot.json`.
17
+ * 4. The contracts, read statically with `tools/scripts/lib/ts-eval.js`.
18
+ *
19
+ * Rules, tags, and the CLI surface are documented in `libs/spec/src/contracts/README.md`
20
+ * and this file's own comments above each check. Run via `npm run check:contracts`.
21
+ *
22
+ * [CONTRACT-IMPORT] (S5b): a component story file whose component has a contract
23
+ * must import it — by the `@atelier-ui/spec/contracts/<name>.contract` alias here,
24
+ * or by a relative path to the contracts directory in a scaffold — and set
25
+ * `contract` in the meta's `parameters`; error where `docs-block.ts` ships beside
26
+ * the contracts, warning otherwise — a textual check, the same heuristic
27
+ * `check-story-descriptions.js` uses for `component: metadata.purpose`.
28
+ */
29
+ 'use strict';
30
+
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { createRequire } from 'node:module';
35
+ import { loadCsf, createStoryArgsResolver } from 'storybook/internal/csf-tools';
36
+ import {
37
+ findStoryFiles,
38
+ resolveWithExtensions,
39
+ makeWorkerDocgen,
40
+ normalizeAngular,
41
+ normalizeVue,
42
+ makeReactDocgenTools,
43
+ normalizeReactDocgen,
44
+ findExternalPackageDir,
45
+ errorMessage,
46
+ } from './lib/docgen.mjs';
47
+
48
+ // ts-eval.js is always required relative to THIS script's own directory
49
+ // (`lib/ts-eval.js` beside it, via createRequire(import.meta.url) — not the
50
+ // cwd) so a copied pair (canonical script + lib/ts-eval.js) works unmodified
51
+ // wherever it is dropped, including inside a scaffolded workspace.
52
+ const require = createRequire(import.meta.url);
53
+ const { parseExportedVars } = require('./lib/ts-eval.js');
54
+
55
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
56
+ // ROOT is likewise derived from the script's own location, not the cwd — a
57
+ // byte-identical copy at <scaffold>/tools/scripts/check-contracts.mjs
58
+ // therefore defaults to the SCAFFOLD's own tree (its own libs/spec,
59
+ // tools/figma/snapshot.json) with zero changes below, whenever CLI flags and
60
+ // contracts.config.json don't override a given path.
61
+ const ROOT = path.resolve(__dirname, '../..');
62
+ // Framework docgen packages (react-docgen, the Storybook framework workers),
63
+ // by contrast, are resolved from the CWD's node_modules — not this script's
64
+ // own directory — so that running `node tools/scripts/check-contracts.mjs`
65
+ // from inside a scaffold picks up the scaffold's own installed Storybook
66
+ // packages rather than whatever happens to be findable by walking up from
67
+ // this file (which, for a copied pair, is usually the same tree anyway, but
68
+ // CWD is the explicit, unambiguous contract).
69
+ const CWD = process.cwd();
70
+ const cwdRequire = createRequire(path.join(CWD, 'package.json'));
71
+ const { reactParseFile } = makeReactDocgenTools(cwdRequire);
72
+ const FRAMEWORKS = ['angular', 'react', 'vue'];
73
+
74
+ // Interaction values on a `state` axis (ADR-0114): CSS pseudo-classes, not code-modelled
75
+ // state. Every OTHER value on a `state` axis (completed, optional, error, filled, open,
76
+ // invalid, checked, filtered, selected, ...) is data-flavoured and must be covered by an
77
+ // `axisMap` entry or a `figmaOnly` entry named `state=<value>`.
78
+ const INTERACTION_STATE_VALUES = new Set([
79
+ 'default',
80
+ 'hover',
81
+ 'focus',
82
+ 'focus-visible',
83
+ 'active',
84
+ 'pressed',
85
+ ]);
86
+
87
+ const UNRESOLVABLE = Symbol('unresolvable');
88
+
89
+ const TAG_LEVEL = {
90
+ 'CONTRACT-MISSING': 'error',
91
+ 'CONTRACT-DUPLICATE': 'error',
92
+ 'CONTRACT-ORPHAN': 'warning',
93
+ 'CONTRACT-NODE': 'error',
94
+ AXIS: 'error',
95
+ BOOLEAN: 'error',
96
+ 'FIGMA-ONLY': 'warning',
97
+ 'STALE-EXEMPTION': 'error',
98
+ 'FW-ONLY': 'warning',
99
+ 'ENUM-UNDRAWN': 'error',
100
+ 'NO-MASTER': 'warning',
101
+ COVERAGE: 'error',
102
+ 'COVERAGE-BOOL': 'warning',
103
+ 'DOCGEN-EMPTY': 'error',
104
+ 'DOCGEN-FAILED': 'error',
105
+ 'UNRESOLVED-ARGS': 'warning',
106
+ UNMIRRORED: 'warning',
107
+ 'NO-STORY-META': 'warning',
108
+ ROSTER: 'error',
109
+ // 'CONTRACT-IMPORT' is set below, once CONTRACTS_DIR is resolved — its
110
+ // severity (error vs. warning) depends on whether this tree ships
111
+ // `docs-block.ts` beside its contracts (see the assignment near CONTRACTS_DIR).
112
+ };
113
+
114
+ // ─── CLI args ───────────────────────────────────────────────────────────────
115
+
116
+ function parseArgs(argv) {
117
+ const out = {
118
+ fw: null,
119
+ snapshot: null,
120
+ report: false,
121
+ emit: null,
122
+ contracts: null,
123
+ stories: [],
124
+ };
125
+ for (let i = 0; i < argv.length; i++) {
126
+ const a = argv[i];
127
+ if (a === '--fw') out.fw = argv[++i];
128
+ else if (a === '--snapshot') out.snapshot = argv[++i];
129
+ else if (a === '--report') out.report = true;
130
+ else if (a === '--emit') out.emit = argv[++i];
131
+ else if (a === '--contracts') out.contracts = argv[++i];
132
+ else if (a === '--stories') out.stories.push(argv[++i]);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ const args = parseArgs(process.argv.slice(2));
138
+
139
+ // ─── Resolution layer (portable across the monorepo and a scaffolded
140
+ // single-framework workspace) ────────────────────────────────────────────
141
+ // Precedence per field: CLI flag > `contracts.config.json` at the cwd root >
142
+ // monorepo default. Nothing below changes what a plain
143
+ // `node tools/scripts/check-contracts.mjs` run from the repo root does — no
144
+ // flags, no contracts.config.json at the repo root, so every field falls
145
+ // through to the same defaults this script always used.
146
+ const CONFIG_PATH = path.join(CWD, 'contracts.config.json');
147
+ let fileConfig = null;
148
+ if (fs.existsSync(CONFIG_PATH)) {
149
+ try {
150
+ fileConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
151
+ } catch (e) {
152
+ console.error(`${CONFIG_PATH}: invalid JSON (${e.message})`);
153
+ process.exit(2);
154
+ }
155
+ }
156
+
157
+ const requestedFw = args.fw || fileConfig?.framework || null;
158
+ const targetFrameworks = requestedFw ? [requestedFw] : FRAMEWORKS;
159
+ for (const fw of targetFrameworks) {
160
+ if (!FRAMEWORKS.includes(fw)) {
161
+ console.error(
162
+ `Unknown framework '${fw}' — expected one of ${FRAMEWORKS.join(', ')}`,
163
+ );
164
+ process.exit(2);
165
+ }
166
+ }
167
+
168
+ // `--stories` (repeatable) or config `stories: [...]` — one or more roots
169
+ // walked recursively for `**/*.stories.{ts,tsx}` (node_modules excluded).
170
+ // `null` means "no override": findStoryFiles() falls back to its historic
171
+ // per-framework `libs/<fw>/src/lib` walk.
172
+ const STORIES_DIRS = args.stories.length
173
+ ? args.stories.map((d) => path.resolve(CWD, d))
174
+ : Array.isArray(fileConfig?.stories) && fileConfig.stories.length
175
+ ? fileConfig.stories.map((d) => path.resolve(CWD, d))
176
+ : null;
177
+
178
+ // ─── Findings ───────────────────────────────────────────────────────────────
179
+
180
+ const findings = [];
181
+ function report(tag, fw, msg) {
182
+ const level = TAG_LEVEL[tag];
183
+ if (!level) throw new Error(`unknown tag '${tag}'`);
184
+ findings.push({ tag, level, fw, msg });
185
+ }
186
+
187
+ // ─── codeOnly cross-framework staleness tracking (R1d) ─────────────────────
188
+ // `codeOnly` staleness can only be judged once every requested framework has run:
189
+ // a prop absent from one framework's manifest but present in another is
190
+ // [FW-ONLY] (ADR-0093 territory), not a stale exemption. Populated per-framework
191
+ // inside processComponent, evaluated once in runCodeOnlyStalenessChecks() after
192
+ // the whole `targetFrameworks` loop completes.
193
+ const reachedByFw = new Map(); // selector -> Set<fw> the component's docgen was reached in
194
+ const codeOnlyPresentByFw = new Map(); // selector -> Map<entryName, Set<fw>> where the prop exists
195
+
196
+ // ─── Snapshot ───────────────────────────────────────────────────────────────
197
+
198
+ const snapshotPath = args.snapshot
199
+ ? path.resolve(CWD, args.snapshot)
200
+ : fileConfig?.snapshot
201
+ ? path.resolve(CWD, fileConfig.snapshot)
202
+ : path.join(ROOT, 'tools/figma/snapshot.json');
203
+ const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
204
+ const snapshotBySelector = new Map(
205
+ snapshot.components.map((c) => [c.selector, c]),
206
+ );
207
+
208
+ function stripId(name) {
209
+ const i = name.indexOf('#');
210
+ return i === -1 ? name : name.slice(0, i);
211
+ }
212
+
213
+ // ─── Contracts ──────────────────────────────────────────────────────────────
214
+
215
+ const CONTRACTS_DIR = args.contracts
216
+ ? path.resolve(CWD, args.contracts)
217
+ : fileConfig?.contracts
218
+ ? path.resolve(CWD, fileConfig.contracts)
219
+ : path.join(ROOT, 'libs/spec/src/contracts');
220
+
221
+ // Whether CONTRACTS_DIR is this repo's own monorepo contracts directory — the
222
+ // only place the `@atelier-ui/spec/contracts/<base>` path alias actually
223
+ // resolves (a scaffold neither ships nor depends on a `@atelier-ui/spec`
224
+ // package). Named once here so `hasContractImport` (accepting the alias) and
225
+ // `suggestedContractSpecifier` (suggesting it) read the same test rather than
226
+ // each re-deriving it and risking drift.
227
+ const CONTRACTS_DIR_IS_MONOREPO =
228
+ CONTRACTS_DIR === path.join(ROOT, 'libs/spec/src/contracts');
229
+
230
+ // [CONTRACT-IMPORT] exists because `libs/spec/src/contracts/docs-block.ts`'s
231
+ // `ContractBlock` reads `parameters.contract` off the story meta and renders
232
+ // it — an unwired story is a real gap in THIS repo (error) because that block
233
+ // ships here. A fresh `create-atelier-ui-workspace` scaffold doesn't ship
234
+ // `docs-block.ts` yet (S5b's "block that displays it" is monorepo-only so
235
+ // far), so the identical finding has nothing to render into there — downgrade
236
+ // to a warning rather than fail a scaffold's `check:contracts` over wiring
237
+ // with no visible effect yet. Set once here (CONTRACTS_DIR is resolved by
238
+ // this point) rather than in the TAG_LEVEL literal above.
239
+ const CONTRACT_IMPORT_HAS_DOCS_BLOCK = fs.existsSync(
240
+ path.join(CONTRACTS_DIR, 'docs-block.ts'),
241
+ );
242
+ TAG_LEVEL['CONTRACT-IMPORT'] = CONTRACT_IMPORT_HAS_DOCS_BLOCK
243
+ ? 'error'
244
+ : 'warning';
245
+
246
+ const contractFiles = fs
247
+ .readdirSync(CONTRACTS_DIR)
248
+ .filter((f) => f.endsWith('.contract.ts'))
249
+ .sort();
250
+
251
+ /** selector -> contract object (plain literal, per ts-eval's static read).
252
+ * `.set(contract.component, …)` below silently keeps only the LAST of two
253
+ * contract files declaring the same `component:` — the same class of bug
254
+ * allowlists.js's own SCAFFOLD_PORT_EXEMPT collision guard exists to stop
255
+ * (see that file's comment), just against a hand-maintained Map instead of a
256
+ * generated one. Detected here, before the overwrite, and reported as
257
+ * [CONTRACT-DUPLICATE] naming both files — a silently dropped contract is
258
+ * exactly the kind of measurement gap this whole change exists to close. */
259
+ const contractsBySelector = new Map();
260
+ for (const file of contractFiles) {
261
+ const full = path.join(CONTRACTS_DIR, file);
262
+ const vars = parseExportedVars(full);
263
+ const contract = vars.contract;
264
+ if (!contract || typeof contract !== 'object') {
265
+ console.error(
266
+ `${file}: 'contract' did not evaluate to a static object literal — skipped.`,
267
+ );
268
+ continue;
269
+ }
270
+ const existing = contractsBySelector.get(contract.component);
271
+ if (existing) {
272
+ report(
273
+ 'CONTRACT-DUPLICATE',
274
+ null,
275
+ `${contract.component}: declared by both ${existing.__file} and ${file} — only ${file} is kept, ${existing.__file}'s contract is silently dropped`,
276
+ );
277
+ }
278
+ contractsBySelector.set(contract.component, { ...contract, __file: file });
279
+ }
280
+
281
+ // ─── Global (framework-independent) checks ─────────────────────────────────
282
+ // CONTRACT-ORPHAN, CONTRACT-NODE, FIGMA-ONLY (UNEXPLAINED), the "no longer on the
283
+ // master" half of STALE-EXEMPTION, and UNMIRRORED only need the contract and the
284
+ // snapshot — run once, not once per framework.
285
+
286
+ function isNameOnMaster(master, name) {
287
+ if (!master) return false;
288
+ if (Object.prototype.hasOwnProperty.call(master.variantAxes || {}, name))
289
+ return true;
290
+ for (const key of Object.keys(master.properties || {})) {
291
+ if (stripId(key) === name) return true;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function isStateValueOnMaster(master, value) {
297
+ return !!((master && (master.variantAxes || {}).state) || []).includes(value);
298
+ }
299
+
300
+ function runGlobalChecks() {
301
+ for (const [selector, contract] of contractsBySelector) {
302
+ const master = snapshotBySelector.get(selector);
303
+
304
+ if (!master) {
305
+ report(
306
+ 'CONTRACT-ORPHAN',
307
+ null,
308
+ `${selector} (${contract.__file}): selector is in no snapshot master`,
309
+ );
310
+ continue; // nothing else below is checkable without a master
311
+ }
312
+
313
+ if (contract.figmaNodeId !== master.nodeId) {
314
+ report(
315
+ 'CONTRACT-NODE',
316
+ null,
317
+ `${selector}: contract.figmaNodeId '${contract.figmaNodeId}' ≠ snapshot nodeId '${master.nodeId}'`,
318
+ );
319
+ }
320
+
321
+ for (const entry of contract.figmaOnly || []) {
322
+ if (/UNEXPLAINED/.test(entry.reason)) {
323
+ report(
324
+ 'FIGMA-ONLY',
325
+ null,
326
+ `${selector}: figmaOnly '${entry.name}' reason is UNEXPLAINED`,
327
+ );
328
+ }
329
+ const m = /^state=(.+)$/.exec(entry.name);
330
+ if (m) {
331
+ if (!isStateValueOnMaster(master, m[1])) {
332
+ report(
333
+ 'STALE-EXEMPTION',
334
+ null,
335
+ `${selector}: figmaOnly '${entry.name}' — 'state' axis no longer has value '${m[1]}'`,
336
+ );
337
+ }
338
+ if (!(master.description || '').includes(m[1])) {
339
+ report(
340
+ 'UNMIRRORED',
341
+ null,
342
+ `${selector}: figmaOnly '${entry.name}' — '${m[1]}' not mentioned in the master description`,
343
+ );
344
+ }
345
+ } else {
346
+ if (!isNameOnMaster(master, entry.name)) {
347
+ report(
348
+ 'STALE-EXEMPTION',
349
+ null,
350
+ `${selector}: figmaOnly '${entry.name}' names a property no longer on the master`,
351
+ );
352
+ }
353
+ if (!(master.description || '').includes(entry.name)) {
354
+ report(
355
+ 'UNMIRRORED',
356
+ null,
357
+ `${selector}: figmaOnly '${entry.name}' not mentioned in the master description`,
358
+ );
359
+ }
360
+ }
361
+ }
362
+
363
+ for (const entry of contract.codeOnly || []) {
364
+ if (/UNEXPLAINED/.test(entry.reason)) {
365
+ report(
366
+ 'FIGMA-ONLY',
367
+ null,
368
+ `${selector}: codeOnly '${entry.name}' reason is UNEXPLAINED`,
369
+ );
370
+ }
371
+ if (!(master.description || '').includes(entry.name)) {
372
+ report(
373
+ 'UNMIRRORED',
374
+ null,
375
+ `${selector}: codeOnly '${entry.name}' not mentioned in the master description`,
376
+ );
377
+ }
378
+ }
379
+
380
+ for (const entry of contract.axisMap || []) {
381
+ if (!(master.description || '').includes(entry.figmaAxis)) {
382
+ report(
383
+ 'UNMIRRORED',
384
+ null,
385
+ `${selector}: axisMap figmaAxis '${entry.figmaAxis}' not mentioned in the master description`,
386
+ );
387
+ }
388
+ }
389
+ }
390
+
391
+ // CONTRACT-MISSING needs a docgen payload (framework-dependent), so it is emitted
392
+ // per framework below, not here.
393
+ }
394
+
395
+ // ─── Per-framework docgen ───────────────────────────────────────────────────
396
+ // collectStoryFilesUnder / findStoryFiles / toRepoImportPath now live in
397
+ // ./lib/docgen.mjs (ADR-0121 §5 / S6(a)), shared with check-manifest-parity.mjs.
398
+ // findStoryFiles(fw) here is always findStoryFiles(fw, { root: ROOT, storiesDirs: STORIES_DIRS }).
399
+
400
+ // ─── CONTRACT-IMPORT (S5b) ──────────────────────────────────────────────────
401
+ // The Storybook docs page's `ContractBlock` (`libs/spec/src/contracts/docs-
402
+ // block.ts`) reads `parameters.contract` off the current story meta — it has
403
+ // nothing to render unless the story file imports the component's contract
404
+ // and wires it in. Textual, not AST: the same convention-following heuristic
405
+ // `check-story-descriptions.js` uses for `component: metadata.purpose`. Every
406
+ // story file in this repo follows one shape (`const meta ... export default
407
+ // meta;`), so a lexical scan for that shape is enough.
408
+
409
+ /** The `const meta = {...}; export default meta;` slice, or `null` if the file
410
+ * doesn't follow that convention (treated as "not wired" below, not a crash). */
411
+ function extractMetaBlock(source) {
412
+ const metaStart = source.indexOf('\nconst meta');
413
+ if (metaStart === -1) return null;
414
+ const exportIdx = source.indexOf('\nexport default meta;', metaStart);
415
+ if (exportIdx === -1) return null;
416
+ return source.slice(metaStart, exportIdx);
417
+ }
418
+
419
+ /** The text strictly inside the `parameters: { ... }` object of a meta block,
420
+ * found by brace-depth matching (the object nests, e.g. `docs: { description:
421
+ * { ... } }`, so a single-level `[^}]*` regex would stop too early). */
422
+ function extractParamsBody(metaBlock) {
423
+ const paramsKeyIdx = metaBlock.indexOf('parameters:');
424
+ if (paramsKeyIdx === -1) return null;
425
+ const openBraceIdx = metaBlock.indexOf('{', paramsKeyIdx);
426
+ if (openBraceIdx === -1) return null;
427
+ let depth = 0;
428
+ for (let i = openBraceIdx; i < metaBlock.length; i++) {
429
+ if (metaBlock[i] === '{') depth++;
430
+ else if (metaBlock[i] === '}') {
431
+ depth--;
432
+ if (depth === 0) return metaBlock.slice(openBraceIdx + 1, i);
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+
438
+ /** Every raw import specifier (`from '...'` / `from "..."`) appearing anywhere in
439
+ * `source` — a lexical scan, not an AST walk, matching this file's other
440
+ * story-meta heuristics (`extractMetaBlock`, `extractParamsBody`). */
441
+ function findImportSpecifiers(source) {
442
+ return [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1]);
443
+ }
444
+
445
+ /**
446
+ * Whether `contract` is imported by any specifier in `source`, accepting
447
+ * EITHER of two forms: the monorepo's `@atelier-ui/spec/contracts/<base>`
448
+ * path alias — only accepted when `CONTRACTS_DIR_IS_MONOREPO`, since that
449
+ * alias only ever resolves inside this repo; a scaffold neither ships nor
450
+ * depends on a `@atelier-ui/spec` package, so the identical string in a
451
+ * scaffold's story would be a specifier that can never resolve there, not a
452
+ * valid wiring — or a RELATIVE specifier that, resolved from `storyFile`'s own
453
+ * directory (a trailing `.js` stripped first — a NodeNext-style
454
+ * `./contracts/button.contract.js` import resolves to the `.ts` source file
455
+ * under bundler/NodeNext resolution — then `.ts` appended when the resolved
456
+ * path doesn't already end in it), lands on the exact file `CONTRACTS_DIR`
457
+ * holds this contract at. The relative form is what a scaffold's own contract
458
+ * import looks like — `CONTRACTS_DIR` there is `<app>/src/contracts`, not a
459
+ * workspace alias.
460
+ */
461
+ function hasContractImport(storyFile, source, contract) {
462
+ const expectedBase = contract.__file.replace(/\.ts$/, '');
463
+ const aliasSpecifier = `@atelier-ui/spec/contracts/${expectedBase}`;
464
+ const targetFile = path.join(CONTRACTS_DIR, contract.__file);
465
+ return findImportSpecifiers(source).some((spec) => {
466
+ if (CONTRACTS_DIR_IS_MONOREPO && spec === aliasSpecifier) return true;
467
+ if (!spec.startsWith('.')) return false;
468
+ let resolved = path.resolve(path.dirname(storyFile), spec);
469
+ if (resolved.endsWith('.js')) resolved = resolved.slice(0, -3);
470
+ if (!resolved.endsWith('.ts')) resolved += '.ts';
471
+ return resolved === targetFile;
472
+ });
473
+ }
474
+
475
+ /**
476
+ * The specifier suggested in the [CONTRACT-IMPORT] message: the monorepo's
477
+ * path alias when `CONTRACTS_DIR_IS_MONOREPO` (the only place that alias
478
+ * resolves), otherwise a relative path from `storyFile`'s directory to the
479
+ * contract file — extensionless, forward slashes, `./`-prefixed unless it
480
+ * already climbs upward with `../` — i.e. exactly the form `hasContractImport`
481
+ * above accepts, so following the suggestion always clears the finding.
482
+ */
483
+ function suggestedContractSpecifier(storyFile, contract) {
484
+ const expectedBase = contract.__file.replace(/\.ts$/, '');
485
+ if (CONTRACTS_DIR_IS_MONOREPO) {
486
+ return `@atelier-ui/spec/contracts/${expectedBase}`;
487
+ }
488
+ const rel = path
489
+ .relative(path.dirname(storyFile), path.join(CONTRACTS_DIR, expectedBase))
490
+ .split(path.sep)
491
+ .join('/');
492
+ return rel.startsWith('.') ? rel : `./${rel}`;
493
+ }
494
+
495
+ /** Reports [CONTRACT-IMPORT] when `name`'s contract exists but `storyFile`
496
+ * neither imports it (see `hasContractImport`) nor sets `contract` in the
497
+ * meta's `parameters`. No-op when `name` has no contract. Severity is decided
498
+ * once, near `CONTRACTS_DIR` (`TAG_LEVEL['CONTRACT-IMPORT']`): error in this
499
+ * repo (docs-block.ts renders the wiring), warning in a scaffold that doesn't
500
+ * ship that block yet. */
501
+ function checkContractImport(fw, storyFile, source, name, contract) {
502
+ if (!contract) return;
503
+ const hasImport = hasContractImport(storyFile, source, contract);
504
+
505
+ const metaBlock = extractMetaBlock(source);
506
+ const paramsBody = metaBlock ? extractParamsBody(metaBlock) : null;
507
+ const hasContractParam =
508
+ paramsBody != null &&
509
+ /(^|[{,\s])contract(\s*[,}]|\s*:|\s*$)/.test(paramsBody);
510
+
511
+ if (!hasImport || !hasContractParam) {
512
+ const suggestion = suggestedContractSpecifier(storyFile, contract);
513
+ const suffix = CONTRACT_IMPORT_HAS_DOCS_BLOCK
514
+ ? ''
515
+ : ' (warning here: no docs-block.ts beside the contracts, so nothing renders the wiring yet)';
516
+ report(
517
+ 'CONTRACT-IMPORT',
518
+ fw,
519
+ `${path.relative(ROOT, storyFile)}: ${name} has a contract but its story meta ` +
520
+ `does not wire it in — add "import { contract } from '${suggestion}';" ` +
521
+ `and set 'contract' in the meta's parameters.${suffix}`,
522
+ );
523
+ }
524
+ }
525
+
526
+ // --- Angular/Vue worker docgen, React's react-docgen parse(), and both
527
+ // frameworks' normalizers now live in ./lib/docgen.mjs (ADR-0121 §5 / S6(a)) —
528
+ // makeWorkerDocgen(fw, cwdRequire, ROOT), normalizeAngular, normalizeVue,
529
+ // makeReactDocgenTools(cwdRequire).reactParseFile, normalizeReactDocgen —
530
+ // shared with check-manifest-parity.mjs so the two scripts read one framework
531
+ // payload the same way.
532
+
533
+ // ─── Dotted child-prop resolution (R2) ──────────────────────────────────────
534
+ // `axisMap[].codeProp` may be `Child.prop`: the prop lives on a CHILD component's own
535
+ // manifest, not the story's primary component. React finds it for free — one
536
+ // `react-docgen` parse already returns every exported component in the file, so every
537
+ // sibling is registered. Angular/Vue's worker only ever returns the ONE component
538
+ // `meta.component` names, so the child's props are recovered with a lightweight
539
+ // source-level scan (the same "read the component source with a regex" idiom
540
+ // check-defaults.js already uses for cross-framework default extraction) rather than a
541
+ // second full docgen pass, which the worker's story-file-only entry point does not
542
+ // support for an arbitrary export.
543
+
544
+ const siblingRegistry = {
545
+ angular: new Map(),
546
+ react: new Map(),
547
+ vue: new Map(),
548
+ };
549
+
550
+ function registerSibling(fw, name, props) {
551
+ if (!name || siblingRegistry[fw].has(name)) return;
552
+ siblingRegistry[fw].set(name, props);
553
+ }
554
+
555
+ function walkSourceFiles(dir) {
556
+ const out = [];
557
+ if (!fs.existsSync(dir)) return out;
558
+ (function walk(d) {
559
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
560
+ const full = path.join(d, entry.name);
561
+ if (entry.isDirectory()) walk(full);
562
+ else if (
563
+ entry.isFile() &&
564
+ /\.(ts|tsx|vue)$/.test(entry.name) &&
565
+ !/\.(spec|stories)\./.test(entry.name)
566
+ ) {
567
+ out.push(full);
568
+ }
569
+ }
570
+ })(dir);
571
+ return out;
572
+ }
573
+
574
+ /** Classify a TypeScript type-text fragment (a generic argument, an interface field's
575
+ * type, ...) into the same { kind, members } shape the docgen normalizers produce, for
576
+ * the regex fallback below. Recognises `boolean` and a closed string-literal union
577
+ * (`'a' | 'b' | 'c'`); anything else is 'other'. */
578
+ function classifyTypeText(typeText, literalArg) {
579
+ const t = (typeText || '').trim();
580
+ if (/\bboolean\b/.test(t) || literalArg === 'true' || literalArg === 'false')
581
+ return { kind: 'boolean' };
582
+ const segments = t
583
+ .split('|')
584
+ .map((s) => s.trim())
585
+ .filter((s) => s && s !== 'undefined' && s !== 'null');
586
+ const literals = segments.filter((s) => /^['"].*['"]$/.test(s));
587
+ if (segments.length > 0 && literals.length === segments.length) {
588
+ return { kind: 'enum', members: literals.map((s) => s.slice(1, -1)) };
589
+ }
590
+ return { kind: 'other' };
591
+ }
592
+
593
+ /** Best-effort existence + kind ('boolean' | 'enum' | 'other') of `propName` on Angular
594
+ * class / Vue SFC / React function `childName`, by regex over its source — used only
595
+ * when the sibling registry (React's free case for a JSX-shaped component) has nothing
596
+ * for `childName`. Not a full docgen: good enough to validate an axisMap's declared
597
+ * mapping, not to run AXIS/COVERAGE against the child itself (deferred to stage 2, see
598
+ * [NO-STORY-META]). */
599
+ function regexResolveChildProp(fw, childName, propName, contextDir) {
600
+ const searchDirs = [contextDir, path.join(ROOT, 'libs', fw, 'src/lib')];
601
+ for (const dir of searchDirs) {
602
+ for (const file of walkSourceFiles(dir)) {
603
+ const src = fs.readFileSync(file, 'utf-8');
604
+ if (fw === 'angular') {
605
+ const classRe = new RegExp(
606
+ `class\\s+${childName}\\b[\\s\\S]*?(?=\\n@Component|\\nexport class\\s|$)`,
607
+ );
608
+ const m = classRe.exec(src);
609
+ if (!m) continue;
610
+ const scoped = m[0];
611
+ const propRe = new RegExp(
612
+ `\\b${propName}\\s*=\\s*input(?:\\.required)?(?:<([^>]*)>)?\\(([^)]*)\\)`,
613
+ );
614
+ const pm = propRe.exec(scoped);
615
+ if (!pm) return { exists: false };
616
+ const generic = pm[1] || '';
617
+ const arg = (pm[2] || '').trim();
618
+ return { exists: true, ...classifyTypeText(generic, arg) };
619
+ } else if (fw === 'vue') {
620
+ if (
621
+ !new RegExp(
622
+ `defineOptions\\(\\s*\\{\\s*name:\\s*['"]${childName}['"]`,
623
+ ).test(src)
624
+ )
625
+ continue;
626
+ const propRe = new RegExp(`\\b${propName}\\??:\\s*([\\w'"| ]+)[;,\\n]`);
627
+ const pm = propRe.exec(src);
628
+ if (!pm) return { exists: false };
629
+ return { exists: true, ...classifyTypeText(pm[1]) };
630
+ } else if (fw === 'react') {
631
+ // react-docgen's FindExportedDefinitionsResolver only recognises functions whose
632
+ // body looks like a component (JSX, forwardRef, class); a pass-through function
633
+ // (e.g. `function AtlStep({ children }) { return children; }`) is invisible to
634
+ // it, so the sibling registry above can be empty even though the export is real.
635
+ // Fall back to the same interface-literal regex idiom as Angular/Vue.
636
+ if (
637
+ !new RegExp(
638
+ `(?:function\\s+${childName}\\s*\\(|const\\s+${childName}\\s*=)`,
639
+ ).test(src)
640
+ )
641
+ continue;
642
+ const ifaceRe = new RegExp(
643
+ `(?:interface|type)\\s+${childName}Props\\b[^{]*\\{([\\s\\S]*?)\\n\\}`,
644
+ );
645
+ const m = ifaceRe.exec(src);
646
+ if (!m) return { exists: false };
647
+ const propRe = new RegExp(`\\b${propName}\\??:\\s*([^;\\n]+)[;\\n]`);
648
+ const pm = propRe.exec(m[1]);
649
+ if (!pm) return { exists: false };
650
+ return { exists: true, ...classifyTypeText(pm[1]) };
651
+ }
652
+ }
653
+ }
654
+ return { exists: false, notFound: true };
655
+ }
656
+
657
+ function resolveCodeProp(fw, codeProp, propByName, contextDir) {
658
+ const dot = codeProp.indexOf('.');
659
+ if (dot === -1) {
660
+ const p = propByName.get(codeProp);
661
+ if (!p) return { exists: false };
662
+ return { exists: true, kind: p.kind, members: p.members };
663
+ }
664
+ const childName = codeProp.slice(0, dot);
665
+ const propName = codeProp.slice(dot + 1);
666
+ const sibling = siblingRegistry[fw].get(childName);
667
+ if (sibling) {
668
+ const p = sibling.find((pp) => pp.name === propName && !pp.isOutput);
669
+ if (!p) return { exists: false };
670
+ return { exists: true, kind: p.kind, members: p.members };
671
+ }
672
+ return regexResolveChildProp(fw, childName, propName, contextDir);
673
+ }
674
+
675
+ function isMemberOfType(value, resolved) {
676
+ if (resolved.kind === 'boolean') return value === true || value === false;
677
+ if (resolved.kind === 'enum')
678
+ return value === null ? true : (resolved.members || []).includes(value);
679
+ return true; // 'other' kind: cannot verify statically, don't block on it
680
+ }
681
+
682
+ // ─── Story args (csf-tools) ─────────────────────────────────────────────────
683
+
684
+ function getStoryOwnObjectNode(csf, key) {
685
+ const stmt = csf._storyStatements && csf._storyStatements[key];
686
+ if (!stmt) return null;
687
+ const varDecl = stmt.declaration || stmt;
688
+ const decl = varDecl && varDecl.declarations && varDecl.declarations[0];
689
+ let init = decl && decl.init;
690
+ while (
691
+ init &&
692
+ (init.type === 'TSAsExpression' ||
693
+ init.type === 'TSSatisfiesExpression' ||
694
+ init.type === 'TSTypeAssertion')
695
+ ) {
696
+ init = init.expression;
697
+ }
698
+ return init && init.type === 'ObjectExpression' ? init : null;
699
+ }
700
+
701
+ function getOwnStoryKeys(csf, key) {
702
+ const init = getStoryOwnObjectNode(csf, key);
703
+ if (!init) return [];
704
+ return init.properties
705
+ .map((p) => (p.key && (p.key.name || p.key.value)) || null)
706
+ .filter(Boolean);
707
+ }
708
+
709
+ /** The property names the story's OWN `args: {...}` object literal declares, or `null`
710
+ * when the story has no own `args` object at all (R1b: "omits the prop entirely" means
711
+ * either shape). Used only to decide whether a manifest DEFAULT value counts as covered —
712
+ * never to resolve an actual value, which stays the resolver's job. */
713
+ function getOwnArgKeys(csf, key) {
714
+ const init = getStoryOwnObjectNode(csf, key);
715
+ if (!init) return null;
716
+ const argsProp = init.properties.find(
717
+ (p) =>
718
+ !p.computed && p.key && (p.key.name === 'args' || p.key.value === 'args'),
719
+ );
720
+ if (
721
+ !argsProp ||
722
+ !argsProp.value ||
723
+ argsProp.value.type !== 'ObjectExpression'
724
+ )
725
+ return null;
726
+ return argsProp.value.properties
727
+ .map((p) => (p.key && (p.key.name || p.key.value)) || null)
728
+ .filter(Boolean);
729
+ }
730
+
731
+ // ─── Render-source literal scanning (R1a) ───────────────────────────────────
732
+ // A story that sets a variant inside a `render` function / Angular `template:`
733
+ // string / Vue template — rather than in `args` — is invisible to the args-based
734
+ // evidence above. Scan the story's own source text (plus the meta's `render`, when
735
+ // the story has none of its own) for literal prop assignments in the three idioms:
736
+ // JSX prop="value" prop={'value'}
737
+ // Angular prop="value" [prop]="'value'"
738
+ // Vue prop="value" :prop="'value'"
739
+ // plus one more idiom explicitly carved out: a `const array = ['a','b'].map(x => ...)`
740
+ // that forwards the loop variable straight into the same prop — credit every literal
741
+ // in the array, without trying to evaluate the loop. Evidence only ever ADDS coverage;
742
+ // it never removes what the args-based resolver already found.
743
+
744
+ function escapeRegExp(s) {
745
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
746
+ }
747
+
748
+ function addCovered(map, name, value) {
749
+ if (!map.has(name)) map.set(name, new Set());
750
+ map.get(name).add(value);
751
+ }
752
+
753
+ /** The meta-level `render` property's source text (`''` when the meta has none) — the
754
+ * fallback evidence blob for a story that declares no `render` of its own. Shared by
755
+ * `buildSourceCoverage` and `collectStoryClaims` so both scan the identical range. */
756
+ function getMetaRenderText(csf, source) {
757
+ const metaNode = csf._metaNode;
758
+ const metaRenderProp =
759
+ metaNode && Array.isArray(metaNode.properties)
760
+ ? metaNode.properties.find(
761
+ (p) =>
762
+ !p.computed &&
763
+ p.key &&
764
+ (p.key.name === 'render' || p.key.value === 'render'),
765
+ )
766
+ : null;
767
+ return metaRenderProp
768
+ ? source.slice(metaRenderProp.start, metaRenderProp.end)
769
+ : '';
770
+ }
771
+
772
+ /** One story's evidence blob: its own node range (from csf-tools), plus the meta's
773
+ * `render` text when the story declares no `render` of its own. This is the scope R1a's
774
+ * literal/object scans and the R1a forwarding check all share. */
775
+ function getStoryEvidenceBlob(csf, source, key, metaRenderText) {
776
+ const stmt = csf._storyStatements && csf._storyStatements[key];
777
+ if (!stmt) return '';
778
+ let blob = source.slice(stmt.start, stmt.end);
779
+ if (!getOwnStoryKeys(csf, key).includes('render') && metaRenderText)
780
+ blob += `\n${metaRenderText}`;
781
+ return blob;
782
+ }
783
+
784
+ /** Whether a render/template's source text forwards the resolved `args` object to the
785
+ * component, rather than being a hardcoded demo that ignores them (R1a). Recognises:
786
+ * React `{...args}`; Vue `v-bind="args"` / `props: args` / `...args` in a setup/render;
787
+ * Angular `argsToTemplate(args)` / the `props: args` + `[prop]="prop"` binding idiom;
788
+ * plus, as a catch-all, the identifier `args` appearing anywhere in the render body at
789
+ * all — which is what actually fires for most of this codebase's
790
+ * `render: (args) => ({ props: args, ... })` stories. Also checks a RENAMED render
791
+ * parameter (`render: (props) => ...`) spread the same way, since "the render signature
792
+ * names them" is an explicit case in the spec, not just literal `args`. */
793
+ function isForwardingRender(text) {
794
+ if (!text) return false;
795
+ if (/\bargs\b/.test(text)) return true;
796
+ const paramMatch = /\brender\s*:\s*\(\s*([A-Za-z_$][\w$]*)/.exec(text);
797
+ const param = paramMatch && paramMatch[1];
798
+ if (param && param !== 'args') {
799
+ const p = escapeRegExp(param);
800
+ if (new RegExp(`\\{\\s*\\.\\.\\.\\s*${p}\\s*\\}`).test(text)) return true; // React spread
801
+ if (new RegExp(`v-bind\\s*=\\s*"${p}"`).test(text)) return true; // Vue v-bind
802
+ if (new RegExp(`\\bprops\\s*:\\s*${p}\\b`).test(text)) return true; // Angular/Vue props: X
803
+ if (new RegExp(`\\.\\.\\.\\s*${p}\\b`).test(text)) return true; // Vue setup spread
804
+ }
805
+ return false;
806
+ }
807
+
808
+ /** `prop="value"` (shared literal-attribute idiom) plus each framework's own
809
+ * bound-literal idiom, scanned generically (prop name is a capture group, not a
810
+ * parameter) so one pass credits every prop a text blob demonstrates. */
811
+ function scanLiteralAttrs(text, fw, into) {
812
+ const plainRe = /\b([A-Za-z_$][\w-]*)\s*=\s*"([^"]*)"/g;
813
+ let m;
814
+ while ((m = plainRe.exec(text))) addCovered(into, m[1], m[2]);
815
+
816
+ if (fw === 'react') {
817
+ const braceRe = /\b([A-Za-z_$][\w-]*)\s*=\s*\{\s*['"]([^'"]*)['"]\s*\}/g;
818
+ while ((m = braceRe.exec(text))) addCovered(into, m[1], m[2]);
819
+ } else if (fw === 'angular') {
820
+ const boundRe = /\[([A-Za-z_$][\w-]*)\]\s*=\s*"'([^']*)'"/g;
821
+ while ((m = boundRe.exec(text))) addCovered(into, m[1], m[2]);
822
+ } else if (fw === 'vue') {
823
+ const boundRe = /:([A-Za-z_$][\w-]*)\s*=\s*"'([^']*)'"/g;
824
+ while ((m = boundRe.exec(text))) addCovered(into, m[1], m[2]);
825
+ }
826
+ }
827
+
828
+ /** `(['a', 'b'] as const).map(x => <Foo prop={x} />)` — same "read the source with a
829
+ * regex" idiom `check-defaults.js` uses for cross-framework default extraction. Does
830
+ * not evaluate the loop; only credits the array's literal members when the loop
831
+ * variable is then forwarded verbatim into some prop in the same text. */
832
+ function scanArrayMapCredit(text, fw, into) {
833
+ const arrayMapRe =
834
+ /\[\s*((?:['"][\w-]+['"]\s*,?\s*)+)\]\s*(?:as const)?\s*\)?\s*\.map\(\s*\(?\s*(\w+)\s*\)?\s*=>/g;
835
+ let m;
836
+ while ((m = arrayMapRe.exec(text))) {
837
+ const items = [...m[1].matchAll(/['"]([\w-]+)['"]/g)].map((x) => x[1]);
838
+ const varName = escapeRegExp(m[2]);
839
+ // Every attribute forwarding the loop variable verbatim is a candidate — not just
840
+ // the first one in source order (a JSX `key={size}` list-key attribute routinely
841
+ // precedes the real `size={size}` prop and would otherwise swallow the match).
842
+ let usedRe;
843
+ if (fw === 'react')
844
+ usedRe = new RegExp(
845
+ `\\b([A-Za-z_$][\\w-]*)\\s*=\\s*\\{\\s*${varName}\\s*\\}`,
846
+ 'g',
847
+ );
848
+ else if (fw === 'angular')
849
+ usedRe = new RegExp(
850
+ `\\[([A-Za-z_$][\\w-]*)\\]\\s*=\\s*"${varName}"`,
851
+ 'g',
852
+ );
853
+ else if (fw === 'vue')
854
+ usedRe = new RegExp(`:([A-Za-z_$][\\w-]*)\\s*=\\s*"${varName}"`, 'g');
855
+ else continue;
856
+ let um;
857
+ while ((um = usedRe.exec(text))) {
858
+ for (const v of items) addCovered(into, um[1], v);
859
+ }
860
+ }
861
+ }
862
+
863
+ /** `prop: 'value'` / `prop: "value"` object-literal idiom (R1b): the imperative
864
+ * call-site shape (Angular `AtlToast`: `toastService.show(msg, { variant: 'success' })`)
865
+ * and any general config-object case (`{ variant: 'success', duration: 2000 }`). Same
866
+ * scope as `scanLiteralAttrs` — the story's own node range, plus the meta's `render`
867
+ * when the story has none. */
868
+ function scanObjectLiteralProps(text, into) {
869
+ const re = /\b([A-Za-z_$][\w-]*)\s*:\s*(['"])([^'"]*)\2/g;
870
+ let m;
871
+ while ((m = re.exec(text))) addCovered(into, m[1], m[3]);
872
+ }
873
+
874
+ /** Per-component source-literal coverage: Map<propName, Set<value>>, built across every
875
+ * story file that feeds this component's docgen. Each story contributes its own node
876
+ * range (from csf-tools) plus the meta's `render` property text when the story has no
877
+ * `render` of its own; the array+map idiom is checked over the whole file, per its
878
+ * looser "present in the file" wording. */
879
+ function buildSourceCoverage(fw, files) {
880
+ const merged = new Map();
881
+ for (const { csf, source } of files) {
882
+ const metaRenderText = getMetaRenderText(csf, source);
883
+
884
+ for (const key of Object.keys(csf._stories || {})) {
885
+ if (!csf._storyStatements || !csf._storyStatements[key]) continue;
886
+ const blob = getStoryEvidenceBlob(csf, source, key, metaRenderText);
887
+ scanLiteralAttrs(blob, fw, merged);
888
+ scanObjectLiteralProps(blob, merged);
889
+ }
890
+ scanArrayMapCredit(source, fw, merged);
891
+ }
892
+ return merged;
893
+ }
894
+
895
+ function nodeToPrimitive(node) {
896
+ if (node == null) return UNRESOLVABLE;
897
+ if (typeof node !== 'object') return node;
898
+ switch (node.type) {
899
+ case 'StringLiteral':
900
+ case 'NumericLiteral':
901
+ case 'BooleanLiteral':
902
+ return node.value;
903
+ case 'NullLiteral':
904
+ return null;
905
+ case 'Identifier':
906
+ return node.name === 'undefined' ? undefined : UNRESOLVABLE;
907
+ default:
908
+ return UNRESOLVABLE;
909
+ }
910
+ }
911
+
912
+ function collectStoryClaims(csf, fw, componentName, source) {
913
+ const resolver = createStoryArgsResolver(csf);
914
+ const claims = [];
915
+ const metaRenderText = getMetaRenderText(csf, source);
916
+ for (const key of Object.keys(csf._stories || {})) {
917
+ const stats = csf._stories[key].__stats || {};
918
+ const ownKeys = getOwnStoryKeys(csf, key);
919
+ const isRenderOnlyDemo = !!stats.render && !ownKeys.includes('args');
920
+ // R1a: a render-only story whose render/template source FORWARDS the resolved args
921
+ // object to the component (rather than ignoring them) is a claim, not a demo — its
922
+ // effective args are the meta args, so it participates in R1b's default-omission
923
+ // credit below. A pure demo (render body never references args) stays excluded.
924
+ const isForwardingDemo =
925
+ isRenderOnlyDemo &&
926
+ isForwardingRender(
927
+ getStoryEvidenceBlob(csf, source, key, metaRenderText),
928
+ );
929
+ let resolved;
930
+ try {
931
+ resolved = resolver.resolve(key);
932
+ } catch {
933
+ continue;
934
+ }
935
+ if (resolved.unresolved && resolved.unresolved.length) {
936
+ report(
937
+ 'UNRESOLVED-ARGS',
938
+ fw,
939
+ `${componentName}: story '${key}' has unresolved args`,
940
+ );
941
+ }
942
+ if (isRenderOnlyDemo && !isForwardingDemo) continue;
943
+ const values = {};
944
+ for (const [k, v] of Object.entries(resolved.args || {})) {
945
+ const prim = nodeToPrimitive(v);
946
+ if (prim !== UNRESOLVABLE) values[k] = prim;
947
+ }
948
+ // R1b: null means "no own args object at all" — every prop is omitted, so the
949
+ // manifest default is trivially demonstrated by whatever the component renders
950
+ // unconfigured. A non-null array still counts as omitting any key it doesn't list.
951
+ claims.push({ story: key, values, ownArgKeys: getOwnArgKeys(csf, key) });
952
+ }
953
+ return claims;
954
+ }
955
+
956
+ // ─── Tokens (for --emit) ────────────────────────────────────────────────────
957
+
958
+ function scanCssTokens(dir) {
959
+ const tokens = new Set();
960
+ let files = [];
961
+ try {
962
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.css'));
963
+ } catch {
964
+ /* dir missing */
965
+ }
966
+ for (const f of files) {
967
+ const src = fs.readFileSync(path.join(dir, f), 'utf-8');
968
+ const re = /var\(\s*(--ui-[\w-]+)/g;
969
+ let m;
970
+ while ((m = re.exec(src))) tokens.add(m[1]);
971
+ }
972
+ return [...tokens].sort();
973
+ }
974
+
975
+ // ─── Per-component checks ───────────────────────────────────────────────────
976
+
977
+ function processComponent(
978
+ fw,
979
+ name,
980
+ docgenResult,
981
+ contract,
982
+ contextDir,
983
+ allClaims,
984
+ sourceCoverage,
985
+ ) {
986
+ const master = snapshotBySelector.get(name);
987
+ const docgenProps = docgenResult.props;
988
+ const hasContract = !!contract;
989
+ const hasSnapshot = !!master;
990
+
991
+ if (!reachedByFw.has(name)) reachedByFw.set(name, new Set());
992
+ reachedByFw.get(name).add(fw);
993
+
994
+ if (hasSnapshot && !hasContract) {
995
+ report(
996
+ 'CONTRACT-MISSING',
997
+ fw,
998
+ `${name}: docgen payload and snapshot master (${master.nodeId}) exist but no contract file`,
999
+ );
1000
+ }
1001
+ if (hasContract && docgenProps.length === 0) {
1002
+ report(
1003
+ 'DOCGEN-EMPTY',
1004
+ fw,
1005
+ `${name}: contract exists but the docgen payload has zero props`,
1006
+ );
1007
+ }
1008
+
1009
+ const figmaOnlyNames = new Set();
1010
+ const stateValueExemptions = new Set();
1011
+ for (const e of (contract && contract.figmaOnly) || []) {
1012
+ const m = /^state=(.+)$/.exec(e.name);
1013
+ if (m) stateValueExemptions.add(m[1]);
1014
+ else figmaOnlyNames.add(e.name);
1015
+ }
1016
+ const axisMapByFigmaAxis = new Map();
1017
+ for (const e of (contract && contract.axisMap) || []) {
1018
+ if (!axisMapByFigmaAxis.has(e.figmaAxis))
1019
+ axisMapByFigmaAxis.set(e.figmaAxis, []);
1020
+ axisMapByFigmaAxis.get(e.figmaAxis).push(e);
1021
+ }
1022
+ const axisMapCodeProps = new Set(
1023
+ ((contract && contract.axisMap) || []).map((e) => e.codeProp),
1024
+ );
1025
+ const codeOnlyNames = new Set(
1026
+ ((contract && contract.codeOnly) || []).map((e) => e.name),
1027
+ );
1028
+
1029
+ const propByName = new Map(
1030
+ docgenProps.filter((p) => !p.isOutput).map((p) => [p.name, p]),
1031
+ );
1032
+
1033
+ const enumCoverageTargets = []; // { propName, values: Set }
1034
+ const boolCoverageTargets = new Set(); // propName
1035
+
1036
+ // ── AXIS ──
1037
+ if (hasSnapshot) {
1038
+ for (const [axisName, axisValues] of Object.entries(
1039
+ master.variantAxes || {},
1040
+ )) {
1041
+ if (figmaOnlyNames.has(axisName)) continue;
1042
+ const isStateAxis = axisName === 'state';
1043
+ const excluded = isStateAxis ? INTERACTION_STATE_VALUES : new Set();
1044
+ const valuesToCheck = axisValues.filter((v) => !excluded.has(v));
1045
+ const mapEntries = axisMapByFigmaAxis.get(axisName) || [];
1046
+
1047
+ if (mapEntries.length > 0) {
1048
+ const covered = new Set();
1049
+ for (const entry of mapEntries) {
1050
+ const resolved = resolveCodeProp(
1051
+ fw,
1052
+ entry.codeProp,
1053
+ propByName,
1054
+ contextDir,
1055
+ );
1056
+ if (!resolved.exists) {
1057
+ report(
1058
+ 'AXIS',
1059
+ fw,
1060
+ `${name}: axisMap ${axisName} -> ${entry.codeProp} — code prop not found`,
1061
+ );
1062
+ continue;
1063
+ }
1064
+ if (entry.values) {
1065
+ for (const [figVal, codeVal] of Object.entries(entry.values)) {
1066
+ covered.add(figVal);
1067
+ if (!isMemberOfType(codeVal, resolved)) {
1068
+ report(
1069
+ 'AXIS',
1070
+ fw,
1071
+ `${name}: axisMap ${axisName}=${figVal} -> ${entry.codeProp} value ${JSON.stringify(codeVal)} is not a member of the prop's type`,
1072
+ );
1073
+ }
1074
+ }
1075
+ if (!entry.codeProp.includes('.')) {
1076
+ if (resolved.kind === 'boolean')
1077
+ boolCoverageTargets.add(entry.codeProp);
1078
+ else if (resolved.kind === 'enum') {
1079
+ enumCoverageTargets.push({
1080
+ propName: entry.codeProp,
1081
+ values: new Set(Object.values(entry.values)),
1082
+ default:
1083
+ propByName.get(entry.codeProp) &&
1084
+ propByName.get(entry.codeProp).default,
1085
+ });
1086
+ }
1087
+ }
1088
+ } else {
1089
+ for (const v of valuesToCheck) covered.add(v);
1090
+ if (resolved.kind === 'enum' && resolved.members) {
1091
+ const propSet = new Set(resolved.members);
1092
+ const axisSet = new Set(axisValues);
1093
+ const missing = axisValues.filter((v) => !propSet.has(v));
1094
+ const extra = resolved.members.filter((v) => !axisSet.has(v));
1095
+ if (missing.length || extra.length) {
1096
+ report(
1097
+ 'AXIS',
1098
+ fw,
1099
+ `${name}: axisMap ${axisName} -> ${entry.codeProp} (identity mapping) mismatch — missing ${JSON.stringify(
1100
+ missing,
1101
+ )}, extra ${JSON.stringify(extra)}`,
1102
+ );
1103
+ }
1104
+ }
1105
+ if (!entry.codeProp.includes('.')) {
1106
+ if (resolved.kind === 'boolean')
1107
+ boolCoverageTargets.add(entry.codeProp);
1108
+ else if (resolved.kind === 'enum') {
1109
+ enumCoverageTargets.push({
1110
+ propName: entry.codeProp,
1111
+ values: new Set(resolved.members || []),
1112
+ default:
1113
+ propByName.get(entry.codeProp) &&
1114
+ propByName.get(entry.codeProp).default,
1115
+ });
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+ for (const v of valuesToCheck)
1121
+ if (stateValueExemptions.has(v)) covered.add(v);
1122
+ const missing = valuesToCheck.filter((v) => !covered.has(v));
1123
+ if (missing.length) {
1124
+ report(
1125
+ 'AXIS',
1126
+ fw,
1127
+ `${name}: axis '${axisName}' values not covered by axisMap or figmaOnly: ${missing.join(', ')}`,
1128
+ );
1129
+ }
1130
+ } else if (isStateAxis) {
1131
+ const missing = valuesToCheck.filter(
1132
+ (v) => !stateValueExemptions.has(v),
1133
+ );
1134
+ if (missing.length) {
1135
+ report(
1136
+ 'AXIS',
1137
+ fw,
1138
+ `${name}: 'state' axis values not covered by axisMap or figmaOnly: ${missing.join(', ')}`,
1139
+ );
1140
+ }
1141
+ } else {
1142
+ const prop = propByName.get(axisName);
1143
+ if (!prop) {
1144
+ report(
1145
+ 'AXIS',
1146
+ fw,
1147
+ `${name}: no manifest prop named '${axisName}' for snapshot axis '${axisName}'`,
1148
+ );
1149
+ } else if (prop.kind !== 'enum' || !prop.members) {
1150
+ report(
1151
+ 'AXIS',
1152
+ fw,
1153
+ `${name}: manifest prop '${axisName}' is not an enum (kind=${prop.kind})`,
1154
+ );
1155
+ } else {
1156
+ const propSet = new Set(prop.members);
1157
+ const axisSet = new Set(axisValues);
1158
+ const missing = axisValues.filter((v) => !propSet.has(v));
1159
+ const extra = prop.members.filter((v) => !axisSet.has(v));
1160
+ if (missing.length || extra.length) {
1161
+ report(
1162
+ 'AXIS',
1163
+ fw,
1164
+ `${name}: axis '${axisName}' ≠ prop '${axisName}' — missing ${JSON.stringify(missing)}, extra ${JSON.stringify(extra)}`,
1165
+ );
1166
+ }
1167
+ enumCoverageTargets.push({
1168
+ propName: axisName,
1169
+ values: new Set(prop.members),
1170
+ default: prop.default,
1171
+ });
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ // ── BOOLEAN ──
1177
+ for (const [rawName, kind] of Object.entries(master.properties || {})) {
1178
+ if (kind !== 'BOOLEAN') continue;
1179
+ const propName = stripId(rawName);
1180
+ if (figmaOnlyNames.has(propName)) continue;
1181
+ if (
1182
+ axisMapCodeProps.has(propName) ||
1183
+ ((contract && contract.axisMap) || []).some(
1184
+ (e) => e.figmaAxis === propName,
1185
+ )
1186
+ )
1187
+ continue;
1188
+ const prop = propByName.get(propName);
1189
+ if (!prop || prop.kind !== 'boolean') {
1190
+ report(
1191
+ 'BOOLEAN',
1192
+ fw,
1193
+ `${name}: snapshot BOOLEAN '${propName}' has no matching boolean manifest prop`,
1194
+ );
1195
+ } else {
1196
+ boolCoverageTargets.add(propName);
1197
+ }
1198
+ // R3: a figmaOnly entry claiming this same name is pointless if it DOES match.
1199
+ if (figmaOnlyNames.has(propName) && prop && prop.kind === 'boolean') {
1200
+ report(
1201
+ 'STALE-EXEMPTION',
1202
+ fw,
1203
+ `${name}: figmaOnly '${propName}' — a compatible boolean manifest prop exists; the exemption suppresses nothing`,
1204
+ );
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ // ── ENUM-UNDRAWN (R1c: only for components with a snapshot master) ──
1210
+ if (!hasSnapshot) {
1211
+ report(
1212
+ 'NO-MASTER',
1213
+ fw,
1214
+ `${name}: docgen payload exists but no snapshot master in tools/figma/snapshot.json — nothing to compare against Figma; ENUM-UNDRAWN and coverage skipped`,
1215
+ );
1216
+ } else {
1217
+ const axisNames = new Set(Object.keys(master.variantAxes || {}));
1218
+ for (const prop of docgenProps) {
1219
+ if (prop.isOutput) continue;
1220
+ if (prop.kind !== 'enum' || !prop.members || prop.members.length < 2)
1221
+ continue;
1222
+ if (axisNames.has(prop.name)) continue;
1223
+ if (axisMapCodeProps.has(prop.name)) continue;
1224
+ if (codeOnlyNames.has(prop.name)) continue;
1225
+ report(
1226
+ 'ENUM-UNDRAWN',
1227
+ fw,
1228
+ `${name}: manifest prop '${prop.name}' (enum: ${prop.members.join('|')}) has no snapshot axis, axisMap or codeOnly entry`,
1229
+ );
1230
+ }
1231
+ }
1232
+
1233
+ // R3, variant-axis half: a figmaOnly entry naming an axis that DOES have a matching
1234
+ // verbatim enum prop suppresses nothing.
1235
+ for (const axisName of figmaOnlyNames) {
1236
+ if (!hasSnapshot || !(master.variantAxes || {})[axisName]) continue;
1237
+ const prop = propByName.get(axisName);
1238
+ if (prop && prop.kind === 'enum' && prop.members) {
1239
+ const propSet = new Set(prop.members);
1240
+ const axisSet = new Set(master.variantAxes[axisName]);
1241
+ const equal =
1242
+ propSet.size === axisSet.size &&
1243
+ [...axisSet].every((v) => propSet.has(v));
1244
+ if (equal) {
1245
+ report(
1246
+ 'STALE-EXEMPTION',
1247
+ fw,
1248
+ `${name}: figmaOnly '${axisName}' — a verbatim-matching enum manifest prop exists; the exemption suppresses nothing`,
1249
+ );
1250
+ }
1251
+ }
1252
+ }
1253
+
1254
+ // ── COVERAGE / COVERAGE-BOOL ──
1255
+ // Evidence, in order: (1) args, as resolved by the story-args resolver — unchanged,
1256
+ // still primary; (2) render-source literal scan (R1a); (3) the manifest default,
1257
+ // credited when some non-render-only story's OWN args omit the prop (R1b) — that
1258
+ // story renders the component unconfigured, which trivially demonstrates the default
1259
+ // regardless of whether the literal string appears anywhere in the file.
1260
+ for (const target of enumCoverageTargets) {
1261
+ const srcCovered = sourceCoverage.get(target.propName);
1262
+ const defaultOmittedSomewhere =
1263
+ target.default !== undefined &&
1264
+ allClaims.some(
1265
+ (c) => c.ownArgKeys === null || !c.ownArgKeys.includes(target.propName),
1266
+ );
1267
+ const missing = [...target.values].filter((v) => {
1268
+ if (
1269
+ allClaims.some(
1270
+ (c) =>
1271
+ Object.prototype.hasOwnProperty.call(c.values, target.propName) &&
1272
+ c.values[target.propName] === v,
1273
+ )
1274
+ ) {
1275
+ return false;
1276
+ }
1277
+ if (srcCovered && srcCovered.has(String(v))) return false;
1278
+ if (defaultOmittedSomewhere && v === target.default) return false;
1279
+ return true;
1280
+ });
1281
+ if (missing.length) {
1282
+ report(
1283
+ 'COVERAGE',
1284
+ fw,
1285
+ `${name}: prop '${target.propName}' — no story sets value(s) ${JSON.stringify(missing)}`,
1286
+ );
1287
+ }
1288
+ }
1289
+ for (const propName of boolCoverageTargets) {
1290
+ const observed = allClaims.some((c) => c.values[propName] === true);
1291
+ if (!observed) {
1292
+ report(
1293
+ 'COVERAGE-BOOL',
1294
+ fw,
1295
+ `${name}: boolean prop '${propName}' is never true in any story`,
1296
+ );
1297
+ }
1298
+ }
1299
+
1300
+ // ── codeOnly staleness (R1d: cross-framework — tracked here, evaluated once after
1301
+ // every requested framework has run, in runCodeOnlyStalenessChecks()) ──
1302
+ for (const entry of (contract && contract.codeOnly) || []) {
1303
+ const present =
1304
+ propByName.has(entry.name) ||
1305
+ docgenProps.some((p) => p.isOutput && p.name === entry.name);
1306
+ if (!present) continue;
1307
+ if (!codeOnlyPresentByFw.has(name))
1308
+ codeOnlyPresentByFw.set(name, new Map());
1309
+ const byEntry = codeOnlyPresentByFw.get(name);
1310
+ if (!byEntry.has(entry.name)) byEntry.set(entry.name, new Set());
1311
+ byEntry.get(entry.name).add(fw);
1312
+ }
1313
+ }
1314
+
1315
+ // ─── Per-framework orchestration ───────────────────────────────────────────
1316
+
1317
+ async function runFramework(fw) {
1318
+ const t0 = performance.now();
1319
+ const storyFiles = findStoryFiles(fw, {
1320
+ root: ROOT,
1321
+ storiesDirs: STORIES_DIRS,
1322
+ });
1323
+ const workerDocgen =
1324
+ fw !== 'react' ? await makeWorkerDocgen(fw, cwdRequire, ROOT) : null;
1325
+
1326
+ const byComponent = new Map(); // name -> { docgenResult, contextDir, files: [csf...] }
1327
+ const reachedMeta = new Set();
1328
+ let noComponentCount = 0;
1329
+ let externalCount = 0;
1330
+ let docgenFailedCount = 0;
1331
+
1332
+ // Snapshotted BEFORE the story loop (rather than after it, where this used
1333
+ // to sit) so the per-framework `warnings:`/`errors:` delta printed below
1334
+ // covers EVERYTHING this framework's run reports — the story loop's own
1335
+ // CONTRACT-IMPORT and [DOCGEN-FAILED] findings, and the [ROSTER] check
1336
+ // right after it, not just the later per-component / NO-STORY-META passes.
1337
+ // A snapshot taken after the loop silently excluded exactly the findings
1338
+ // this change exists to surface — the summary line would print `errors: 0`
1339
+ // for a framework mid docgen-blackout while the run total said otherwise.
1340
+ const errorsBefore = findings.filter((f) => f.level === 'error').length;
1341
+ const warningsBefore = findings.filter((f) => f.level === 'warning').length;
1342
+
1343
+ for (const storyFile of storyFiles) {
1344
+ const source = fs.readFileSync(storyFile, 'utf-8');
1345
+ let csf;
1346
+ try {
1347
+ csf = loadCsf(source, {
1348
+ makeTitle: (t) => t,
1349
+ fileName: storyFile,
1350
+ }).parse();
1351
+ } catch (e) {
1352
+ console.error(
1353
+ ` ! ${path.relative(ROOT, storyFile)}: csf-tools failed to parse (${e.message})`,
1354
+ );
1355
+ continue;
1356
+ }
1357
+ const metaComponent =
1358
+ typeof csf._meta.component === 'string' ? csf._meta.component : null;
1359
+ if (!metaComponent) {
1360
+ noComponentCount++;
1361
+ continue;
1362
+ }
1363
+
1364
+ // A component imported from an installed PACKAGE (a real scaffold's
1365
+ // `@atelier-ui/<fw>`, not this monorepo's tsconfig path alias of the same
1366
+ // name — that alias has no `node_modules` entry and so never matches
1367
+ // here) gets skipped before any docgen call, uniformly across all three
1368
+ // frameworks. Left to each engine's own accident, the three frameworks
1369
+ // disagree on what "can't read into node_modules" means: React's relative-
1370
+ // path-only resolver and Vue's worker both simply return nothing for a
1371
+ // bare specifier, but Angular's worker (`angular-component-meta` over a
1372
+ // real TS program) happily follows the import into the package's `.d.ts`
1373
+ // and returns a real, nameful payload with zero inputs/outputs — which
1374
+ // this script would otherwise mistake for a workspace component with no
1375
+ // props and fail on ([DOCGEN-EMPTY], [AXIS], [BOOLEAN], ...). Skipping the
1376
+ // call here — rather than filtering its result afterwards — is what makes
1377
+ // the scaffold's promise ("local docgen cannot read into node_modules, so
1378
+ // the check has nothing to compare and reports only [NO-STORY-META]")
1379
+ // actually true for Angular too, by construction, instead of by luck.
1380
+ if (findExternalPackageDir(storyFile, csf._rawComponentPath)) {
1381
+ externalCount++;
1382
+ continue;
1383
+ }
1384
+
1385
+ let docgenResult = null;
1386
+ let docgenFailed = false;
1387
+ const contextDir = path.dirname(storyFile);
1388
+
1389
+ if (fw === 'react') {
1390
+ const rawPath = csf._rawComponentPath;
1391
+ const spec = csf._componentImportSpecifier;
1392
+ const localName = spec && spec.local && spec.local.name;
1393
+ if (rawPath && localName) {
1394
+ const componentFile = resolveWithExtensions(
1395
+ path.resolve(contextDir, rawPath),
1396
+ );
1397
+ if (componentFile) {
1398
+ try {
1399
+ const docgens = reactParseFile(componentFile);
1400
+ for (const d of docgens)
1401
+ registerSibling(fw, d.displayName, normalizeReactDocgen(d));
1402
+ const match =
1403
+ docgens.find((d) => d.displayName === localName) ||
1404
+ docgens.find((d) => d.displayName === metaComponent);
1405
+ if (match) {
1406
+ docgenResult = {
1407
+ name: match.displayName,
1408
+ props: normalizeReactDocgen(match),
1409
+ description: match.description,
1410
+ slots: undefined,
1411
+ };
1412
+ }
1413
+ } catch (e) {
1414
+ docgenFailed = true;
1415
+ docgenFailedCount++;
1416
+ report(
1417
+ 'DOCGEN-FAILED',
1418
+ fw,
1419
+ `${path.relative(ROOT, storyFile)}: react-docgen failed — ${errorMessage(e)}`,
1420
+ );
1421
+ }
1422
+ }
1423
+ }
1424
+ } else {
1425
+ const result = await workerDocgen(storyFile, csf);
1426
+ if (result.ok) {
1427
+ const payload = result.payload;
1428
+ const normalized =
1429
+ fw === 'angular' ? normalizeAngular(payload) : normalizeVue(payload);
1430
+ docgenResult = {
1431
+ name: payload.name,
1432
+ props: normalized.props,
1433
+ description: normalized.description,
1434
+ slots: normalized.slots,
1435
+ };
1436
+ registerSibling(fw, payload.name, normalized.props);
1437
+ } else {
1438
+ docgenFailed = true;
1439
+ docgenFailedCount++;
1440
+ report(
1441
+ 'DOCGEN-FAILED',
1442
+ fw,
1443
+ `${path.relative(ROOT, storyFile)}: ${fw} docgen failed — ${result.reason}`,
1444
+ );
1445
+ }
1446
+ }
1447
+
1448
+ if (!docgenResult) {
1449
+ // A docgen FAILURE (provider threw / empty payload / payload.error, or
1450
+ // react-docgen threw — reported as [DOCGEN-FAILED] just above) is kept
1451
+ // OUT of noComponentCount on purpose: this story file DID have a
1452
+ // resolvable meta.component, so it is still "measurable" for the
1453
+ // [ROSTER] check below. Folding it into noComponentCount is exactly
1454
+ // the ADR-0124 bug — it let a broken docgen worker shrink the roster
1455
+ // instead of showing up as a hole in it.
1456
+ if (!docgenFailed) noComponentCount++;
1457
+ continue;
1458
+ }
1459
+
1460
+ reachedMeta.add(docgenResult.name);
1461
+ checkContractImport(
1462
+ fw,
1463
+ storyFile,
1464
+ source,
1465
+ docgenResult.name,
1466
+ contractsBySelector.get(docgenResult.name),
1467
+ );
1468
+ let entry = byComponent.get(docgenResult.name);
1469
+ if (!entry) {
1470
+ entry = { docgenResult, contextDir, files: [] };
1471
+ byComponent.set(docgenResult.name, entry);
1472
+ }
1473
+ entry.files.push({ storyFile, csf, source });
1474
+ }
1475
+
1476
+ // [ROSTER]: `measurable` is every story file that COULD have contributed a
1477
+ // component — everything except a story with no meta.component at all
1478
+ // (noComponentCount) and a deliberately-skipped external-package import
1479
+ // (externalCount, see the comment above the findExternalPackageDir() call).
1480
+ // Subtracting externalCount before the floor is what keeps a scaffolded
1481
+ // one-framework workspace — whose only story imports from
1482
+ // `@atelier-ui/<fw>` — at measurable === 0 and therefore silent here,
1483
+ // exactly as that comment promises ("the check has nothing to compare and
1484
+ // reports only [NO-STORY-META]"). If there WAS something measurable and
1485
+ // byComponent still ended up empty, docgen measured nothing this run.
1486
+ const measurable = storyFiles.length - noComponentCount - externalCount;
1487
+ if (measurable > 0 && byComponent.size === 0) {
1488
+ report(
1489
+ 'ROSTER',
1490
+ fw,
1491
+ `${fw}: ${measurable} of ${storyFiles.length} story file(s) were measurable ` +
1492
+ `(${noComponentCount} no-component, ${externalCount} external) but docgen produced 0 component(s) — the gate measured nothing`,
1493
+ );
1494
+ }
1495
+
1496
+ for (const [name, entry] of byComponent) {
1497
+ const contract = contractsBySelector.get(name);
1498
+ const allClaims = entry.files.flatMap(({ csf, source }) =>
1499
+ collectStoryClaims(csf, fw, name, source),
1500
+ );
1501
+ const sourceCoverage = buildSourceCoverage(fw, entry.files);
1502
+ processComponent(
1503
+ fw,
1504
+ name,
1505
+ entry.docgenResult,
1506
+ contract,
1507
+ entry.contextDir,
1508
+ allClaims,
1509
+ sourceCoverage,
1510
+ );
1511
+
1512
+ if (args.report) {
1513
+ console.log(
1514
+ ` ok ${name} (props: ${entry.docgenResult.props.length}, stories: ${entry.files.reduce((n, f) => n + Object.keys(f.csf._stories || {}).length, 0)})`,
1515
+ );
1516
+ }
1517
+
1518
+ if (args.emit) {
1519
+ const props = entry.docgenResult.props.filter((p) => !p.isOutput);
1520
+ const events = entry.docgenResult.props.filter((p) => p.isOutput);
1521
+ const codeSpec = {
1522
+ componentAPI: {
1523
+ props: props.map((p) => ({
1524
+ name: p.name,
1525
+ type: p.typeText || p.kind,
1526
+ values: p.kind === 'enum' ? p.members : undefined,
1527
+ defaultValue: p.default,
1528
+ description: p.description,
1529
+ required: p.required,
1530
+ })),
1531
+ events: events.map((e) => ({
1532
+ name: e.name,
1533
+ type: e.typeText,
1534
+ description: e.description,
1535
+ })),
1536
+ slots: entry.docgenResult.slots,
1537
+ },
1538
+ metadata: { name, description: entry.docgenResult.description },
1539
+ tokens: { usedTokens: scanCssTokens(entry.contextDir) },
1540
+ };
1541
+ const outDir = path.join(path.resolve(args.emit), fw);
1542
+ fs.mkdirSync(outDir, { recursive: true });
1543
+ fs.writeFileSync(
1544
+ path.join(outDir, `${name}.codespec.json`),
1545
+ JSON.stringify(codeSpec, null, 2),
1546
+ );
1547
+ }
1548
+ }
1549
+
1550
+ // ── NO-STORY-META (R5) ──
1551
+ for (const selector of contractsBySelector.keys()) {
1552
+ if (reachedMeta.has(selector)) continue;
1553
+ if (!snapshotBySelector.has(selector)) continue; // CONTRACT-ORPHAN already covers this
1554
+ report(
1555
+ 'NO-STORY-META',
1556
+ fw,
1557
+ `${selector}: has a contract and a snapshot master, but no story file's meta.component resolves to it in ${fw}`,
1558
+ );
1559
+ }
1560
+
1561
+ const t1 = performance.now();
1562
+ const errorsAfter = findings.filter((f) => f.level === 'error').length;
1563
+ const warningsAfter = findings.filter((f) => f.level === 'warning').length;
1564
+ console.log(
1565
+ `[${fw}] components: ${byComponent.size}, contracts: ${contractsBySelector.size}, stories: ${storyFiles.length} ` +
1566
+ `(no-component: ${noComponentCount}, external: ${externalCount}, docgen-failed: ${docgenFailedCount}), warnings: ${warningsAfter - warningsBefore}, errors: ${errorsAfter - errorsBefore}, ${(t1 - t0).toFixed(0)} ms`,
1567
+ );
1568
+ }
1569
+
1570
+ // ─── codeOnly cross-framework staleness (R1d) ──────────────────────────────
1571
+ // Runs once, after every requested framework has processed every component, so a
1572
+ // codeOnly entry's presence can be judged across the full set of manifests rather
1573
+ // than one framework at a time. A component never reached by ANY run framework is
1574
+ // silently skipped, same as before this rule existed — NO-STORY-META already
1575
+ // covers "the contract exists but nothing resolves to it".
1576
+ function runCodeOnlyStalenessChecks() {
1577
+ const singleFwRun = targetFrameworks.length === 1;
1578
+ for (const [selector, contract] of contractsBySelector) {
1579
+ const reached = reachedByFw.get(selector);
1580
+ if (!reached || reached.size === 0) continue;
1581
+ for (const entry of contract.codeOnly || []) {
1582
+ const present =
1583
+ (codeOnlyPresentByFw.get(selector) || new Map()).get(entry.name) ||
1584
+ new Set();
1585
+ const absent = [...reached].filter((fw) => !present.has(fw)).sort();
1586
+ if (absent.length === 0) continue;
1587
+ if (singleFwRun) {
1588
+ report(
1589
+ 'FW-ONLY',
1590
+ absent[0],
1591
+ `${selector}: codeOnly '${entry.name}' names a prop not in the manifest — single-framework run (--fw ${targetFrameworks[0]}), cannot confirm cross-framework presence; downgraded from STALE-EXEMPTION`,
1592
+ );
1593
+ } else if (absent.length === reached.size) {
1594
+ report(
1595
+ 'STALE-EXEMPTION',
1596
+ null,
1597
+ `${selector}: codeOnly '${entry.name}' names a prop no longer in any framework's manifest (checked: ${[...reached].sort().join(', ')})`,
1598
+ );
1599
+ } else {
1600
+ report(
1601
+ 'FW-ONLY',
1602
+ null,
1603
+ `${selector}: codeOnly '${entry.name}' missing from ${absent.join(', ')} — present in the other framework(s) (ADR-0093 territory)`,
1604
+ );
1605
+ }
1606
+ }
1607
+ }
1608
+ }
1609
+
1610
+ // ─── Main ───────────────────────────────────────────────────────────────────
1611
+
1612
+ async function main() {
1613
+ runGlobalChecks();
1614
+ for (const fw of targetFrameworks) {
1615
+ await runFramework(fw);
1616
+ }
1617
+ runCodeOnlyStalenessChecks();
1618
+
1619
+ const byTag = new Map();
1620
+ for (const f of findings) {
1621
+ if (!byTag.has(f.tag)) byTag.set(f.tag, []);
1622
+ byTag.get(f.tag).push(f);
1623
+ }
1624
+ const tagOrder = Object.keys(TAG_LEVEL);
1625
+ console.log('\n--- findings ---');
1626
+ for (const tag of tagOrder) {
1627
+ const list = byTag.get(tag);
1628
+ if (!list || list.length === 0) continue;
1629
+ for (const f of list) {
1630
+ console.log(`[${f.tag}]${f.fw ? ` (${f.fw})` : ''} ${f.msg}`);
1631
+ }
1632
+ }
1633
+
1634
+ const totalErrors = findings.filter((f) => f.level === 'error').length;
1635
+ const totalWarnings = findings.filter((f) => f.level === 'warning').length;
1636
+ console.log(`\ntotal: ${totalErrors} error(s), ${totalWarnings} warning(s)`);
1637
+ process.exitCode = totalErrors > 0 ? 1 : 0;
1638
+ process.exit(process.exitCode);
1639
+ }
1640
+
1641
+ main().catch((e) => {
1642
+ console.error(e);
1643
+ process.exit(1);
1644
+ });