@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.
- package/CHANGELOG.md +33 -4
- package/package.json +1 -1
- package/src/generators/preset/files/contracts/README.md +29 -0
- package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
- package/src/generators/preset/files/contracts/types.ts.template +55 -0
- package/src/generators/preset/files/figma/snapshot.json +164 -0
- package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
- package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
- package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
- package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
- package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
- package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
- package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
- package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
- package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
- package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
- package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
- package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
- package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
- package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
- package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
- package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
- package/src/generators/preset/files/styles/tokens.css +59 -43
- package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
- package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
- package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
- package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
- package/src/generators/preset/files/tools/scripts/preflight.mjs +220 -30
- package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
- package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
- package/src/generators/preset/preset.d.ts +1 -0
- package/src/generators/preset/preset.js +955 -5
- package/src/generators/preset/preset.js.map +1 -1
- package/src/generators/preset/schema.d.ts +1 -0
- 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
|
+
});
|