@motion-proto/live-tokens 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +33 -38
  2. package/.claude/skills/live-tokens-create-component/SKILL copy.md +196 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +198 -146
  4. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +95 -52
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +2 -2
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +2 -2
  7. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +12 -12
  8. package/.claude/skills/live-tokens-create-component/references/token-naming.md +2 -1
  9. package/.claude/skills/live-tokens-create-page/SKILL.md +187 -0
  10. package/.claude/skills/live-tokens-create-page/references/interaction-sources.md +66 -0
  11. package/.claude/skills/live-tokens-create-page/references/layout-sources.md +87 -0
  12. package/.claude/skills/live-tokens-create-theme/SKILL.md +52 -48
  13. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
  14. package/.claude/skills/live-tokens-fix-findings/SKILL.md +69 -60
  15. package/.claude/skills/live-tokens-pick-component/SKILL.md +64 -79
  16. package/.claude/skills/live-tokens-set-colors/SKILL.md +47 -38
  17. package/.claude/skills/live-tokens-set-geometry/SKILL.md +58 -35
  18. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +5 -3
  19. package/.claude/skills/live-tokens-set-type/SKILL.md +30 -30
  20. package/CHANGELOG.md +138 -0
  21. package/README.md +17 -7
  22. package/bin/check-component.mjs +91 -19
  23. package/bin/check-page.mjs +102 -21
  24. package/bin/cli.mjs +87 -113
  25. package/bin/contractRunner.mjs +945 -0
  26. package/bin/create.mjs +1 -1
  27. package/bin/lib/catalogue.mjs +37 -30
  28. package/bin/lib/findings.mjs +46 -15
  29. package/bin/lib/report.mjs +3 -3
  30. package/bin/lib/tokenVocabulary.mjs +4 -4
  31. package/bin/migrate-routes.mjs +5 -5
  32. package/bin/migrate.mjs +4 -4
  33. package/bin/save-theme.mjs +8 -9
  34. package/bin/set-colors.mjs +9 -11
  35. package/bin/set-geometry.mjs +7 -7
  36. package/bin/set-type.mjs +5 -7
  37. package/bin/setup-claude.mjs +110 -0
  38. package/dist-plugin/{chunk-W6Y4BWFB.js → chunk-6WGFOJXO.js} +22 -0
  39. package/dist-plugin/{chunk-7VRTBGJT.js → chunk-PDNL4NC5.js} +9 -2
  40. package/dist-plugin/{chunk-V3YF6CGT.js → chunk-SWXRVZKT.js} +43 -1
  41. package/dist-plugin/{dataPaths-BhWzd5cL.d.cts → dataPaths-BpIK_Obx.d.cts} +1 -0
  42. package/dist-plugin/{dataPaths-BhWzd5cL.d.ts → dataPaths-BpIK_Obx.d.ts} +1 -0
  43. package/dist-plugin/index.cjs +310 -147
  44. package/dist-plugin/index.d.cts +1 -1
  45. package/dist-plugin/index.d.ts +1 -1
  46. package/dist-plugin/index.js +218 -122
  47. package/dist-plugin/migrateData/index.cjs +66 -2
  48. package/dist-plugin/migrateData/index.d.cts +1 -1
  49. package/dist-plugin/migrateData/index.d.ts +1 -1
  50. package/dist-plugin/migrateData/index.js +3 -3
  51. package/dist-plugin/setColors/index.cjs +61 -4
  52. package/dist-plugin/setColors/index.d.cts +1 -1
  53. package/dist-plugin/setColors/index.d.ts +1 -1
  54. package/dist-plugin/setColors/index.js +5 -5
  55. package/dist-plugin/setGeometry/index.cjs +107 -44
  56. package/dist-plugin/setGeometry/index.d.cts +5 -5
  57. package/dist-plugin/setGeometry/index.d.ts +5 -5
  58. package/dist-plugin/setGeometry/index.js +51 -45
  59. package/dist-plugin/setType/index.cjs +15 -0
  60. package/dist-plugin/setType/index.d.cts +1 -1
  61. package/dist-plugin/setType/index.d.ts +1 -1
  62. package/dist-plugin/setType/index.js +1 -1
  63. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  64. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  65. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  66. package/package.json +35 -8
  67. package/src/app/site.css +19 -9
  68. package/src/editor/bootstrap.ts +2 -0
  69. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +4 -4
  70. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  71. package/src/editor/component-editor/MenuSelectEditor.svelte +4 -1
  72. package/src/editor/component-editor/SegmentedControlEditor.svelte +6 -1
  73. package/src/editor/component-editor/TabBarEditor.svelte +1 -1
  74. package/src/editor/component-editor/TableEditor.svelte +2 -2
  75. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +6 -3
  76. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +41 -20
  77. package/src/editor/core/components/adjustAliases.ts +59 -45
  78. package/src/editor/core/components/aliasKinds.ts +9 -5
  79. package/src/editor/core/preview/themePreview.ts +9 -2
  80. package/src/editor/core/sketch/sketchLayer.ts +22 -0
  81. package/src/editor/core/store/editorStore.ts +10 -1
  82. package/src/editor/core/themes/buildColors.ts +3 -3
  83. package/src/editor/core/themes/liveStateStream.ts +26 -0
  84. package/src/editor/core/themes/migrations/2026-09-07-stroke-role-renames.ts +52 -0
  85. package/src/editor/core/themes/migrations/index.ts +2 -0
  86. package/src/editor/core/themes/themeDocumentSync.ts +22 -11
  87. package/src/editor/core/themes/themeService.ts +9 -2
  88. package/src/editor/pages/ComponentEditorPage.svelte +17 -1
  89. package/src/editor/pages/liveTokensEditorHandle.ts +23 -0
  90. package/src/editor/skill-atlas/SkillAtlas.svelte +75 -573
  91. package/src/editor/skill-atlas/TreeCanvas.svelte +263 -0
  92. package/src/editor/skill-atlas/TreeNodeCard.svelte +136 -53
  93. package/src/editor/skill-atlas/edges.ts +31 -0
  94. package/src/editor/skill-atlas/skillSources.generated.ts +20 -19
  95. package/src/editor/skill-atlas/skillTrees.ts +19 -3840
  96. package/src/editor/skill-atlas/trees/check-compliance.ts +183 -0
  97. package/src/editor/skill-atlas/trees/create-component.ts +275 -0
  98. package/src/editor/skill-atlas/trees/create-page.ts +320 -0
  99. package/src/editor/skill-atlas/trees/create-theme.ts +255 -0
  100. package/src/editor/skill-atlas/trees/fix-findings.ts +469 -0
  101. package/src/editor/skill-atlas/trees/pick-component.ts +300 -0
  102. package/src/editor/skill-atlas/trees/set-colors.ts +148 -0
  103. package/src/editor/skill-atlas/trees/set-geometry.ts +136 -0
  104. package/src/editor/skill-atlas/trees/set-type.ts +142 -0
  105. package/src/editor/skill-atlas/types.ts +3 -4
  106. package/src/editor/skill-atlas/wireLayout.ts +287 -0
  107. package/src/live-tokens/data/themes/autumn.json +15 -15
  108. package/src/live-tokens/data/themes/halloween.json +15 -15
  109. package/src/live-tokens/data/themes/midnight-study.json +15 -15
  110. package/src/live-tokens/data/themes/ocean.json +15 -15
  111. package/src/live-tokens/data/themes/royal-velvet.json +15 -15
  112. package/src/live-tokens/data/themes/sketchy.json +15 -15
  113. package/src/live-tokens/data/themes/spring-meadow.json +15 -15
  114. package/src/live-tokens/data/themes/sunset.json +15 -15
  115. package/src/system/components/Badge.svelte +7 -0
  116. package/src/system/components/Button.svelte +7 -0
  117. package/src/system/components/Callout.svelte +10 -6
  118. package/src/system/components/Card.svelte +23 -4
  119. package/src/system/components/CodeSnippet.svelte +4 -3
  120. package/src/system/components/CollapsibleSection.svelte +23 -8
  121. package/src/system/components/CornerBadge.svelte +6 -0
  122. package/src/system/components/Dialog.svelte +13 -6
  123. package/src/system/components/IconButton.svelte +9 -0
  124. package/src/system/components/Image.svelte +8 -0
  125. package/src/system/components/ImageLightbox.svelte +6 -0
  126. package/src/system/components/InlineEditActions.svelte +7 -0
  127. package/src/system/components/Input.svelte +7 -0
  128. package/src/system/components/MenuSelect.svelte +7 -0
  129. package/src/system/components/Notification.svelte +7 -0
  130. package/src/system/components/Panel.svelte +6 -0
  131. package/src/system/components/ProgressBar.svelte +5 -0
  132. package/src/system/components/RadioButton.svelte +11 -5
  133. package/src/system/components/SectionDivider.svelte +8 -0
  134. package/src/system/components/SegmentedControl.svelte +6 -0
  135. package/src/system/components/SideNavigation.svelte +6 -0
  136. package/src/system/components/Slider.svelte +7 -4
  137. package/src/system/components/TabBar.svelte +15 -9
  138. package/src/system/components/Table.svelte +8 -3
  139. package/src/system/components/Toggle.svelte +4 -4
  140. package/src/system/components/Tooltip.svelte +6 -0
  141. package/src/testing-js/chunk-AO7EZHYV.js +776 -0
  142. package/src/testing-js/chunk-AO7EZHYV.js.map +1 -0
  143. package/src/testing-js/chunk-FAFOAWYL.js +39 -0
  144. package/src/testing-js/chunk-FAFOAWYL.js.map +1 -0
  145. package/src/testing-js/chunk-L73N4NSO.js +23 -0
  146. package/src/testing-js/chunk-L73N4NSO.js.map +1 -0
  147. package/src/testing-js/chunk-LXR3MN6N.js +3063 -0
  148. package/src/testing-js/chunk-LXR3MN6N.js.map +1 -0
  149. package/src/testing-js/chunk-ZMSX6CXR.js +53 -0
  150. package/src/testing-js/chunk-ZMSX6CXR.js.map +1 -0
  151. package/src/testing-js/component-alias.contract.js +81 -0
  152. package/src/testing-js/component-alias.contract.js.map +1 -0
  153. package/src/testing-js/component-editor.contract.js +62 -0
  154. package/src/testing-js/component-editor.contract.js.map +1 -0
  155. package/src/testing-js/component-render.contract.js +568 -0
  156. package/src/testing-js/component-render.contract.js.map +1 -0
  157. package/src/testing-js/index.d.ts +293 -0
  158. package/src/testing-js/index.js +222 -0
  159. package/src/testing-js/index.js.map +1 -0
  160. package/src/testing-js/registry.contract.js +39 -0
  161. package/src/testing-js/registry.contract.js.map +1 -0
  162. package/src/testing-js/vitest-BE6uGF31.d.ts +73 -0
  163. package/src/testing-js/vitest.d.ts +3 -0
  164. package/src/testing-js/vitest.js +13 -0
  165. package/src/testing-js/vitest.js.map +1 -0
  166. package/template/README.md +13 -0
  167. package/template/_gitignore +6 -0
  168. package/template/package.json +3 -1
  169. package/template/src/pages/Home.svelte +4 -18
  170. package/.claude/skills/live-tokens-build-page/SKILL.md +0 -103
  171. package/.claude/skills/live-tokens-build-page/references/layout-sources.md +0 -48
@@ -0,0 +1,945 @@
1
+ // `check-component --tests`: runs the registry contract under Vitest and the
2
+ // component contract suites under Playwright for one component or every
3
+ // authored one, and maps their results onto findings by rule.
4
+ //
5
+ // Spawns the two tools as child processes rather than importing their APIs, so
6
+ // this module needs neither `@playwright/test` nor `vitest` at its own module
7
+ // top — it is loaded lazily, only when `--tests` is passed (see cli.mjs).
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import {
11
+ cpSync,
12
+ existsSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ realpathSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { basename, dirname, join, relative, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { discoverComponents, resolveComponentPaths } from './check-component.mjs';
25
+ import { lineOf } from './lib/findings.mjs';
26
+
27
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
+
29
+ const TEST_DATA_DIR_ENV = 'LIVE_TOKENS_TEST_DATA_DIR';
30
+ const DATA_DIR_ENV = 'LIVE_TOKENS_DATA_DIR';
31
+ const COMPONENT_ENV = 'LIVE_TOKENS_COMPONENT';
32
+
33
+ // Mirrors src/testing/isolation.ts's own list. Duplicated rather than shared:
34
+ // that module compiles into src/testing-js, which does not exist until
35
+ // `build:testing` runs, and this one ships unbuilt.
36
+ const SESSION_FILES = new Set(['_working.json', '_active.json', '_production.json']);
37
+
38
+ const REQUIRED_TOOLS = [
39
+ {
40
+ name: '@playwright/test',
41
+ install: 'npm install -D @playwright/test',
42
+ extra: 'Then run `npx playwright install chromium`.',
43
+ },
44
+ { name: 'vitest', install: 'npm install -D vitest' },
45
+ { name: 'happy-dom', install: 'npm install -D happy-dom' },
46
+ ];
47
+
48
+ const HARD_FAILURE_RULES = new Set(['tests-not-installed', 'tests-setup', 'tests-incomplete']);
49
+
50
+ /** Design decision 8's fixed rule ids, the full set reconciliation checks
51
+ * coverage against. */
52
+ const ALL_CONTRACT_RULES = [
53
+ 'contract-registry',
54
+ 'contract-listed',
55
+ 'contract-alias',
56
+ 'contract-states',
57
+ 'contract-interaction',
58
+ 'contract-persist',
59
+ 'contract-theme',
60
+ 'contract-sketch',
61
+ 'contract-render',
62
+ ];
63
+
64
+ /**
65
+ * `component-editor.contract.ts` runs each component through the same eight
66
+ * `test()` calls in the same order (`describe.serial` preserves declaration
67
+ * order in the reporter). Position, not title text, is what identifies the
68
+ * obligation: two positions read "resolves every alias" and "answers the
69
+ * pointer", but rewording either string must not silently stop this from
70
+ * recognising it — only reordering or adding/removing a `test()` call would,
71
+ * and that is a deliberate change to the suite itself.
72
+ */
73
+ export const EDITOR_SUITE_POSITIONAL_RULES = [
74
+ 'contract-listed',
75
+ 'contract-alias', // declares every part and every shipped alias (assertInventory)
76
+ 'contract-alias', // resolves every alias it paints with
77
+ 'contract-states', // previews the state being edited
78
+ 'contract-interaction', // answers the pointer and the keyboard
79
+ 'contract-persist',
80
+ 'contract-theme',
81
+ 'contract-sketch',
82
+ ];
83
+
84
+ // ─── tool + path resolution ─────────────────────────────────────────────────
85
+
86
+ /** Node's own node_modules resolution, walked by hand: a monorepo hoists a
87
+ * peer to an ancestor, so checking only `root/node_modules` under-reports. */
88
+ function findPeerRoot(root, name) {
89
+ let dir = resolve(root);
90
+ for (;;) {
91
+ const candidate = join(dir, 'node_modules', ...name.split('/'));
92
+ if (existsSync(candidate)) return candidate;
93
+ const parent = dirname(dir);
94
+ if (parent === dir) return null;
95
+ dir = parent;
96
+ }
97
+ }
98
+
99
+ export function missingToolFindings(root) {
100
+ return REQUIRED_TOOLS.filter((tool) => !findPeerRoot(root, tool.name)).map((tool) => ({
101
+ rule: 'tests-not-installed',
102
+ file: 'package.json',
103
+ line: 1,
104
+ message: `${tool.name} is not installed. Run \`${tool.install}\`.${tool.extra ? ` ${tool.extra}` : ''}`,
105
+ }));
106
+ }
107
+
108
+ function peerBin(root, name, relBin) {
109
+ const peerRoot = findPeerRoot(root, name);
110
+ if (!peerRoot) throw new Error(`${name} is not installed under ${root}`);
111
+ return join(peerRoot, relBin);
112
+ }
113
+
114
+ /** The newest mtime among the source tree's own `.ts` files. `tsup`'s
115
+ * `splitting: true` spreads one entry across several output chunks, so an
116
+ * edit that matters can land in a file `name` never names directly (e.g.
117
+ * `support/contractHarness.ts` feeding `component-editor.contract.js`), so
118
+ * the whole source tree is the unit of staleness. */
119
+ function newestSourceMtime(dir) {
120
+ let newest = 0;
121
+ for (const entry of readdirSync(dir, { recursive: true })) {
122
+ if (!entry.endsWith('.ts')) continue;
123
+ const mtime = statSync(join(dir, entry)).mtimeMs;
124
+ if (mtime > newest) newest = mtime;
125
+ }
126
+ return newest;
127
+ }
128
+
129
+ /** The shipped testing module: compiled once `build:testing` has run,
130
+ * source in this repo's own dev loop otherwise. Resolved to an absolute path
131
+ * so the generated configs below need no bare-specifier or extensionless
132
+ * resolution of their own.
133
+ *
134
+ * A tarball install ships only the compiled copy (`src/testing` is not in
135
+ * `package.json`'s `files`), so `sourceDir` never exists there and this
136
+ * always returns `compiled` unconditionally, same as before. In this repo's
137
+ * own dev loop both exist, and an edit under `src/testing` used to leave the
138
+ * compiled copy silently stale: measured by editing `contractHarness.ts` and
139
+ * confirming the compiled `component-editor.contract.js` in `src/testing-js`
140
+ * still ran the old assertion. Preferring the newer one makes the dev loop
141
+ * self-healing. */
142
+ function resolveTestingEntry(name) {
143
+ const compiled = join(PKG_ROOT, 'src/testing-js', `${name}.js`);
144
+ const sourceDir = join(PKG_ROOT, 'src/testing');
145
+ const source = join(sourceDir, `${name}.ts`);
146
+ const compiledExists = existsSync(compiled);
147
+ const sourceExists = existsSync(source);
148
+ if (compiledExists && sourceExists && newestSourceMtime(sourceDir) > statSync(compiled).mtimeMs) {
149
+ console.warn(
150
+ `[live-tokens] src/testing/ has changes newer than src/testing-js/${name}.js. ` +
151
+ `Using the source directly. Run \`npm run build:testing\` to refresh the compiled copy.`,
152
+ );
153
+ return source;
154
+ }
155
+ if (compiledExists) return compiled;
156
+ if (sourceExists) return source;
157
+ throw new Error(
158
+ `Cannot find the shipped testing module "${name}" under ${PKG_ROOT}. ` +
159
+ 'Run `npm run build:testing` (development) or reinstall the package.',
160
+ );
161
+ }
162
+
163
+ function settingsFilePath(root) {
164
+ for (const name of ['live-tokens.testing.ts', 'live-tokens.testing.mts', 'live-tokens.testing.js', 'live-tokens.testing.mjs']) {
165
+ const path = join(root, name);
166
+ if (existsSync(path)) return path;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * A generated config's own extensionless relative imports (Vite/Playwright's
173
+ * loaders resolve those, matching how this repo's own `vite.config.ts` and
174
+ * `live-tokens.testing.ts` are written) only get that treatment for a
175
+ * *static* import. A dynamic `import()` of the same path, called after a Vite
176
+ * config finishes loading, runs through plain Node resolution instead and
177
+ * fails on the same files: verified against `vitest.contract.config.ts` with
178
+ * both a `file://` URL and a plain absolute path as the dynamic specifier,
179
+ * identical `ERR_MODULE_NOT_FOUND` both times. So neither `settings.viteConfig`
180
+ * nor `settings.dataDir` (the resolved values `resolveTestingConfig` computes)
181
+ * has a reader here on purpose — reading either would mean importing the
182
+ * settings file dynamically first, which reintroduces exactly this failure.
183
+ * Regexing the settings file's *source text* for a field, imprecise as that
184
+ * is, is what stays inside the static-import constraint: a wrong guess still
185
+ * fails loudly (a bad `viteConfig` throws on its own static import in the
186
+ * generated file; a bad `dataDir` throws "no data directory at ..." below),
187
+ * never as a silent pass. A settings-level `dataDir` this cannot see at all
188
+ * is worse than one resolved this imprecisely, since a project that names its
189
+ * data directory only in `live-tokens.testing.ts` would otherwise have its
190
+ * contracts checked against whatever happens to sit at the default path.
191
+ */
192
+ function stripComments(text) {
193
+ // Block comments first: a `//` inside one (`/* // note */`) must not seed a
194
+ // second, overlapping strip.
195
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
196
+ }
197
+
198
+ /** Measured across eleven settings-file shapes: a commented-out `dataDir:`
199
+ * above the real one won the regex, because the regex only sees text
200
+ * position, never comment syntax, and a comment naming the field with
201
+ * nothing else present invented a setting out of prose. Comments are
202
+ * stripped before the field ever gets a chance to match. A field present in
203
+ * live code as a template literal, a computed value, or an import throws
204
+ * instead of falling through to a default silently: the two guesses this
205
+ * feeds, `viteConfig` and `dataDir`, can each resolve to a real path that
206
+ * simply names the wrong tree, which then reads as a clean run. */
207
+ function scrapeSettingsField(settingsPath, fieldName) {
208
+ if (!settingsPath) return null;
209
+ let text;
210
+ try {
211
+ text = readFileSync(settingsPath, 'utf8');
212
+ } catch {
213
+ return null;
214
+ }
215
+ const live = stripComments(text);
216
+ const literal = new RegExp(`\\b${fieldName}\\s*:\\s*['"]([^'"]+)['"]`).exec(live);
217
+ if (literal) return literal[1];
218
+ if (new RegExp(`\\b${fieldName}\\s*:`).test(live)) {
219
+ throw new Error(
220
+ `"${fieldName}" in ${settingsPath} is set to something other than a plain string literal, ` +
221
+ `so --tests cannot read it statically. Use a literal string, or remove the key to fall back to the default.`,
222
+ );
223
+ }
224
+ return null;
225
+ }
226
+
227
+ function guessViteConfigPath(root, settingsPath) {
228
+ return resolve(root, scrapeSettingsField(settingsPath, 'viteConfig') ?? 'vite.config.ts');
229
+ }
230
+
231
+ /**
232
+ * `dataDir` as the plugin resolves it: a `dataDir` field scraped from the
233
+ * settings file (see above), else `live-tokens.config.json`'s own key, else
234
+ * the default. Mirrors `resolveTestingConfig`'s own `configuredDataDir`
235
+ * fallback (`src/testing/config.ts`) rather than importing it: that module
236
+ * compiles into `src/testing-js`, which does not exist until `build:testing`
237
+ * runs, and importing the *source* `.ts` module to reach it hits the same
238
+ * extensionless-import failure documented above — measured with
239
+ * `src/testing-js` moved aside, `resolveTestingEntry`'s `.ts` fallback threw
240
+ * exactly that trying to load `vitest.ts`. Duplicating this small a resolver
241
+ * has precedent in this file already, at `SESSION_FILES`.
242
+ */
243
+ export function resolveSourceDataDir(root, settingsPath) {
244
+ const scraped = scrapeSettingsField(settingsPath, 'dataDir');
245
+ if (scraped) return resolve(root, scraped);
246
+ try {
247
+ const parsed = JSON.parse(readFileSync(join(root, 'live-tokens.config.json'), 'utf8'));
248
+ if (parsed && typeof parsed === 'object' && typeof parsed.dataDir === 'string') {
249
+ return resolve(root, parsed.dataDir);
250
+ }
251
+ } catch {
252
+ // Missing or unparseable reads as absent, matching the plugin's own resolver.
253
+ }
254
+ return resolve(root, 'src/live-tokens/data');
255
+ }
256
+
257
+ // ─── data isolation ─────────────────────────────────────────────────────────
258
+
259
+ function copyIsolatedDataDir(sourceDataDir) {
260
+ if (!existsSync(sourceDataDir)) {
261
+ throw new Error(
262
+ `No data directory at ${sourceDataDir}. Set "dataDir" in live-tokens.config.json ` +
263
+ 'when the project keeps its live-tokens data somewhere else.',
264
+ );
265
+ }
266
+ // realpath: macOS resolves os.tmpdir() through a symlink, and the plugin
267
+ // compares resolved paths when it decides whether a write is in scope.
268
+ const dataDir = realpathSync(mkdtempSync(join(tmpdir(), 'live-tokens-check-')));
269
+ cpSync(sourceDataDir, dataDir, { recursive: true });
270
+ for (const entry of readdirSync(dataDir, { recursive: true })) {
271
+ if (SESSION_FILES.has(basename(entry))) rmSync(join(dataDir, entry), { force: true });
272
+ }
273
+ return dataDir;
274
+ }
275
+
276
+ /** The one child process a `withCleanup` signal handler needs to stop before
277
+ * it is safe to remove the directories that process is still writing into.
278
+ * Module-scoped because exactly one `runContractTests` call is ever active
279
+ * in a given process. */
280
+ let activeChild = null;
281
+
282
+ /**
283
+ * Removes `paths` on completion, on an uncaught exception, and on SIGINT or
284
+ * SIGTERM. On a signal, it first asks the active child (Playwright or Vitest)
285
+ * to stop and waits, briefly, for it to exit — measured against a live
286
+ * `webServer`: Playwright only tears its dev server down on SIGINT, never
287
+ * SIGTERM, so this always sends SIGINT to the child regardless of which
288
+ * signal this process received, and falls back to SIGKILL if the child
289
+ * ignores it. Only then does it remove the directories, so a slow child
290
+ * cannot recreate a report or a trace file in a directory that is already
291
+ * gone (also measured: without the wait, Playwright wrote into `test-results/`
292
+ * after cleanup had already removed it).
293
+ *
294
+ * The handler removes its own listener and re-sends the original signal to
295
+ * this process rather than just cleaning up and returning: any listener at
296
+ * all cancels Node's default terminate-on-signal behaviour, so a handler that
297
+ * only cleans up would swallow the first Ctrl+C in a process that installed
298
+ * no other SIGINT listener.
299
+ */
300
+ function withCleanup(paths) {
301
+ let done = false;
302
+ const cleanup = () => {
303
+ if (done) return;
304
+ done = true;
305
+ for (const path of paths) rmSync(path, { recursive: true, force: true });
306
+ };
307
+ const stopActiveChild = () =>
308
+ new Promise((resolveStop) => {
309
+ if (!activeChild || activeChild.exitCode !== null || activeChild.signalCode !== null) {
310
+ resolveStop();
311
+ return;
312
+ }
313
+ const child = activeChild;
314
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5_000);
315
+ child.once('exit', () => {
316
+ clearTimeout(timer);
317
+ resolveStop();
318
+ });
319
+ child.kill('SIGINT');
320
+ });
321
+ const onSignal = async (signal) => {
322
+ await stopActiveChild();
323
+ cleanup();
324
+ process.removeListener(signal, onSignal);
325
+ process.kill(process.pid, signal);
326
+ };
327
+ process.once('SIGINT', onSignal);
328
+ process.once('SIGTERM', onSignal);
329
+ process.once('exit', cleanup);
330
+ return cleanup;
331
+ }
332
+
333
+ // ─── generated tool configs ─────────────────────────────────────────────────
334
+
335
+ export function writeGeneratedConfigs({ configDir, root }) {
336
+ // `configDir` sits under the OS temp directory, which has no ancestor
337
+ // `package.json`. Without one naming `"type": "module"` here, Node treats
338
+ // these configs as CommonJS by default, and `createPlaywrightConfig`'s
339
+ // `import.meta.url` throws "Cannot use 'import.meta' outside a module".
340
+ writeFileSync(join(configDir, 'package.json'), JSON.stringify({ type: 'module' }));
341
+
342
+ const settingsPath = settingsFilePath(root);
343
+ const testingIndex = resolveTestingEntry('index');
344
+ const testingVitest = resolveTestingEntry('vitest');
345
+ const viteConfigPath = guessViteConfigPath(root, settingsPath);
346
+
347
+ const settingsImport = settingsPath ? `import settingsModule from ${JSON.stringify(settingsPath)};\n` : '';
348
+ // A source fragment for the generated files below, not an actual settings
349
+ // object: this module never imports the consumer's settings file itself
350
+ // (see the static-import note above), so it only ever sees this as text.
351
+ // `settingsModule` is already the file's default export (a default import
352
+ // unwraps it), not a module namespace object — it has no `.default` of its
353
+ // own.
354
+ const settingsExpr = settingsPath ? 'settingsModule' : '{}';
355
+
356
+ const playwrightConfigPath = join(configDir, 'playwright.config.ts');
357
+ writeFileSync(
358
+ playwrightConfigPath,
359
+ `${settingsImport}import { createPlaywrightConfig } from ${JSON.stringify(testingIndex)};
360
+
361
+ export default createPlaywrightConfig({
362
+ ...${settingsExpr},
363
+ root: ${JSON.stringify(root)},
364
+ });
365
+ `,
366
+ );
367
+
368
+ const vitestConfigPath = join(configDir, 'vitest.config.ts');
369
+ writeFileSync(
370
+ vitestConfigPath,
371
+ `${settingsImport}import viteConfigModule from ${JSON.stringify(viteConfigPath)};
372
+ import { createVitestConfig, resolveTestingConfig } from ${JSON.stringify(testingVitest)};
373
+
374
+ const settings = resolveTestingConfig(${settingsExpr}, ${JSON.stringify(root)});
375
+
376
+ export default createVitestConfig(viteConfigModule.default ?? viteConfigModule, {
377
+ registrySetup: settings.registrySetup,
378
+ });
379
+ `,
380
+ );
381
+
382
+ return { playwrightConfigPath, vitestConfigPath };
383
+ }
384
+
385
+ // ─── subprocess execution ───────────────────────────────────────────────────
386
+
387
+ function runCli(command, args, { cwd, env }) {
388
+ return new Promise((resolveRun) => {
389
+ const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
390
+ activeChild = child;
391
+ let stdout = '';
392
+ let stderr = '';
393
+ child.stdout.on('data', (chunk) => (stdout += chunk));
394
+ child.stderr.on('data', (chunk) => (stderr += chunk));
395
+ const finish = (result) => {
396
+ if (activeChild === child) activeChild = null;
397
+ resolveRun(result);
398
+ };
399
+ child.on('close', (code) => finish({ code, stdout, stderr }));
400
+ child.on('error', (error) => finish({ code: -1, stdout, stderr: `${stderr}\n${error.message}` }));
401
+ });
402
+ }
403
+
404
+ function setupFinding(tool, code, stdout, stderr) {
405
+ return {
406
+ rule: 'tests-setup',
407
+ file: 'package.json',
408
+ line: 1,
409
+ message: `${tool} exited with code ${code} before producing a report.\n${`${stdout}\n${stderr}`.trim().slice(-2000)}`,
410
+ };
411
+ }
412
+
413
+ /** Reads a JSON report or explains, as a `tests-setup` finding, why there
414
+ * isn't one. Split from the process-spawning around it so a malformed or
415
+ * absent report is testable without a subprocess. */
416
+ export function readReportOrSetupFinding(tool, reportPath, code, stdout, stderr) {
417
+ if (!existsSync(reportPath)) return { setupFinding: setupFinding(tool, code, stdout, stderr) };
418
+ try {
419
+ return { report: JSON.parse(readFileSync(reportPath, 'utf8')) };
420
+ } catch (error) {
421
+ return { setupFinding: setupFinding(tool, code, stdout, `report at ${reportPath} did not parse: ${error.message}`) };
422
+ }
423
+ }
424
+
425
+ export async function runPlaywrightSuite({ root, configDir, playwrightConfigPath }) {
426
+ const reportPath = join(configDir, 'playwright-report.json');
427
+ const bin = peerBin(root, '@playwright/test', 'cli.js');
428
+ const { code, stdout, stderr } = await runCli(
429
+ process.execPath,
430
+ [bin, 'test', '-c', playwrightConfigPath, '--reporter=json'],
431
+ { cwd: root, env: { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: reportPath } },
432
+ );
433
+ return readReportOrSetupFinding('Playwright', reportPath, code, stdout, stderr);
434
+ }
435
+
436
+ export async function runRegistrySuite({ root, configDir, vitestConfigPath }) {
437
+ const reportPath = join(configDir, 'vitest-report.json');
438
+ const bin = peerBin(root, 'vitest', 'vitest.mjs');
439
+ const { code, stdout, stderr } = await runCli(
440
+ process.execPath,
441
+ [bin, 'run', '--config', vitestConfigPath, '--reporter=json', '--outputFile', reportPath],
442
+ { cwd: root, env: process.env },
443
+ );
444
+ const outcome = readReportOrSetupFinding('Vitest', reportPath, code, stdout, stderr);
445
+ // Kept alongside a successfully-parsed report too: a file that failed to
446
+ // collect a single test still carries a report, and `mapVitestResults`
447
+ // falls back to this when that file names no message of its own.
448
+ return outcome.setupFinding ? outcome : { ...outcome, stderr };
449
+ }
450
+
451
+ // ─── result mapping: shared ─────────────────────────────────────────────────
452
+
453
+ const TOKEN_RE = /(--[a-z0-9-]+)/;
454
+
455
+ export function extractToken(message) {
456
+ return TOKEN_RE.exec(message ?? '')?.[1] ?? null;
457
+ }
458
+
459
+ /** The real line a token sits on, found by literal search with a trailing
460
+ * boundary so a shorter token cannot match inside a longer one that starts
461
+ * the same way (`--card-default-body` inside `--card-default-body-padding`,
462
+ * measured against every shipped alias: 34 pairs resolved to the wrong line
463
+ * without this boundary). Falls back to line 1 (decision 8's documented
464
+ * fallback) when the token is absent from the message or the file. */
465
+ export function findTokenLine(filePath, token) {
466
+ if (!token || !existsSync(filePath)) return 1;
467
+ const text = readFileSync(filePath, 'utf8');
468
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
469
+ const match = new RegExp(`${escaped}(?![a-z0-9-])`).exec(text);
470
+ return match ? lineOf(text, match.index) : 1;
471
+ }
472
+
473
+ function componentConfigPath(sourceDataDir, id) {
474
+ return join(sourceDataDir, 'component-configs', id, 'default.json');
475
+ }
476
+
477
+ /** A message block ends at the first blank line: `ContractViolation`'s own
478
+ * multi-line messages (e.g. a list of aliases, one per line) never contain
479
+ * one, while Playwright always separates the message from the ANSI code
480
+ * frame it appends with one. Splitting on the first single `\n` instead
481
+ * (as an earlier version of this file did) silently dropped every line
482
+ * after the first. */
483
+ function messageBlock(rawMessage) {
484
+ return String(rawMessage ?? '').split(/\n\n/)[0].trim();
485
+ }
486
+
487
+ /** Signatures Playwright's own errors carry that are not a component contract
488
+ * obligation failing — a missing browser or a crashed worker — checked
489
+ * before any rule/component-shaped parsing runs. Measured: with
490
+ * `PLAYWRIGHT_BROWSERS_PATH` pointed at an empty directory, every obligation
491
+ * failed with `browserType.launch: Executable doesn't exist` and, before
492
+ * this check existed, each one produced an unrelated contract finding. */
493
+ export function classifyInfrastructureError(message) {
494
+ const text = String(message ?? '');
495
+ if (/Executable doesn't exist|browserType\.launch:/.test(text)) {
496
+ return {
497
+ rule: 'tests-not-installed',
498
+ message: 'Chromium is not installed for Playwright. Run `npx playwright install chromium`.',
499
+ };
500
+ }
501
+ if (/Worker process exited unexpectedly|Test process crashed/.test(text)) {
502
+ return { rule: 'tests-setup', message: `Playwright's worker process crashed: ${text.split('\n')[0]}` };
503
+ }
504
+ return null;
505
+ }
506
+
507
+ // ─── result mapping: Playwright ─────────────────────────────────────────────
508
+
509
+ /** `component-editor.contract.ts` names each component in a
510
+ * `describe.serial(id, ...)`, its immediate parent; the other two suites
511
+ * name it as the leading word of the spec title instead. Either way, the
512
+ * candidate is only trusted against `knownIds` — a leading word like
513
+ * "every" or "component" is not a component id. */
514
+ export function identifyComponent(describeTitles, specTitle, knownIds) {
515
+ const parent = describeTitles[describeTitles.length - 1];
516
+ if (parent && knownIds.has(parent)) return parent;
517
+ const prefix = specTitle.split(' ')[0];
518
+ return knownIds.has(prefix) ? prefix : null;
519
+ }
520
+
521
+ /** One entry per *test*, not per attempt: `test.status` is Playwright's own
522
+ * reconciliation of every retry ('expected' | 'unexpected' | 'flaky' |
523
+ * 'skipped'), and `test.annotations` — not `result.annotations`, which is
524
+ * always empty — is where an inapplicable reason actually lands (measured
525
+ * against a real sketch-inapplicable run). Reading `test.results` per entry
526
+ * instead double-reported a flaky test's failed attempt as an error finding
527
+ * alongside its final `passed` coverage. */
528
+ export function readPlaywrightTests(report) {
529
+ const out = [];
530
+ const walk = (suite, titles) => {
531
+ for (const child of suite.suites ?? []) walk(child, [...titles, child.title]);
532
+ for (const spec of suite.specs ?? []) {
533
+ for (const test of spec.tests ?? []) {
534
+ out.push({
535
+ describeTitles: titles,
536
+ specTitle: spec.title,
537
+ specFile: spec.file,
538
+ specLine: spec.line,
539
+ status: test.status,
540
+ annotations: test.annotations ?? [],
541
+ lastResult: test.results?.[test.results.length - 1] ?? null,
542
+ });
543
+ }
544
+ }
545
+ };
546
+ for (const suite of report.suites ?? []) walk(suite, []);
547
+ return out;
548
+ }
549
+
550
+ /**
551
+ * Where a contract rule's finding points: the consumer's own artifact, never
552
+ * the shipped `.contract.ts` suite (absent from the tarball, and Playwright
553
+ * reports no assertion location for it once compiled — see the plan's Wave
554
+ * 3b/4 notes). `contract-alias`/`persist`/`theme` name the shipped config;
555
+ * `contract-listed` names the editor; render/preview/sketch name the runtime.
556
+ */
557
+ export function artifactForContractRule(root, sourceDataDir, rule, componentId, token) {
558
+ if (!componentId) return { file: 'package.json', line: 1 };
559
+ const paths = resolveComponentPaths(componentId, root);
560
+ if (rule === 'contract-alias' || rule === 'contract-persist' || rule === 'contract-theme') {
561
+ const target = componentConfigPath(sourceDataDir, componentId);
562
+ return { file: relative(root, target), line: findTokenLine(target, token) };
563
+ }
564
+ if (rule === 'contract-listed') {
565
+ return { file: relative(root, paths.editorPath), line: 1 };
566
+ }
567
+ return { file: relative(root, paths.runtimePath), line: findTokenLine(paths.runtimePath, token) };
568
+ }
569
+
570
+ const VIOLATION_RE = /^ContractViolation:\s*\[([a-z-]+)\]\s+([a-z0-9-]+):\s*([\s\S]*)$/;
571
+
572
+ /** The rule a passing or failing test exercises, derived from *where* the
573
+ * test lives rather than its title text: position within a component's
574
+ * `describe.serial` group for the eight editor-suite obligations (see
575
+ * `EDITOR_SUITE_POSITIONAL_RULES`), the suite file for the other two. A
576
+ * `ContractViolation`'s own `[rule]` prefix, when present, is more
577
+ * authoritative than either and wins in the caller. */
578
+ function structuralRule(entry, componentId, positionCounters) {
579
+ if (componentId && entry.describeTitles[entry.describeTitles.length - 1] === componentId) {
580
+ const index = positionCounters.get(componentId) ?? 0;
581
+ positionCounters.set(componentId, index + 1);
582
+ return EDITOR_SUITE_POSITIONAL_RULES[index] ?? null;
583
+ }
584
+ const file = basename(entry.specFile ?? '');
585
+ if (file.startsWith('component-render.contract')) return 'contract-render';
586
+ if (file.startsWith('component-alias.contract')) return 'contract-alias';
587
+ return null;
588
+ }
589
+
590
+ /** failed beats flaky beats passed beats inapplicable, so a component whose
591
+ * interaction is genuinely inapplicable but whose states obligation is a
592
+ * real pass reads as passed, and
593
+ * either reading as failed always wins. `flaky` outranks `passed` so a
594
+ * retried obligation stays visible rather than being overwritten by a
595
+ * sibling that passed clean the first time. */
596
+ const COVERAGE_PRIORITY = { failed: 4, flaky: 3, passed: 2, inapplicable: 1 };
597
+
598
+ export function mapPlaywrightResults(report, { root, sourceDataDir, knownIds }) {
599
+ const zeroCollected = (report.suites ?? []).length === 0;
600
+ if (zeroCollected || (report.errors ?? []).length > 0) {
601
+ const detail = (report.errors ?? []).map((e) => e.message).join('\n') || 'the run collected no tests';
602
+ return {
603
+ findings: [{ rule: 'tests-incomplete', file: 'package.json', line: 1, message: `Playwright collected nothing to check: ${detail}` }],
604
+ coverage: {},
605
+ // Distinct from a per-test `tests-incomplete` (a single timed-out
606
+ // obligation, say): this one finding already explains every missing
607
+ // (component, rule) pair, so reconciliation must not also flag each of
608
+ // them — that would multiply one collection failure into dozens of
609
+ // findings that all say the same thing.
610
+ explained: true,
611
+ };
612
+ }
613
+
614
+ const tests = readPlaywrightTests(report);
615
+
616
+ for (const t of tests) {
617
+ if (t.status !== 'unexpected') continue;
618
+ const infra = classifyInfrastructureError(t.lastResult?.errors?.[0]?.message);
619
+ if (infra?.rule === 'tests-not-installed') {
620
+ return { findings: [{ rule: infra.rule, file: 'package.json', line: 1, message: infra.message }], coverage: {}, explained: true };
621
+ }
622
+ }
623
+
624
+ const findings = [];
625
+ const coverage = {};
626
+ const positionCounters = new Map();
627
+ const markCoverage = (id, rule, status, reason) => {
628
+ if (!id || !rule) return;
629
+ coverage[id] ??= {};
630
+ const existing = coverage[id][rule];
631
+ if (!existing || COVERAGE_PRIORITY[status] >= COVERAGE_PRIORITY[existing.status]) {
632
+ coverage[id][rule] = reason ? { status, reason } : { status };
633
+ }
634
+ };
635
+
636
+ for (const t of tests) {
637
+ const componentId = identifyComponent(t.describeTitles, t.specTitle, knownIds);
638
+ const rule = structuralRule(t, componentId, positionCounters);
639
+ const inapplicable = t.annotations.find((a) => a.type === 'inapplicable')?.description;
640
+
641
+ if (t.status === 'expected' || t.status === 'flaky') {
642
+ // Flaky reaches a passing final attempt, same as expected, but a retry
643
+ // was needed to get there: worth a distinct coverage status so it stays
644
+ // visible rather than reading identically to a clean pass. It carries no
645
+ // finding and never flips exit status. CI's retry budget exists to
646
+ // absorb a shared dev server wobbling under CI load; failing the run on
647
+ // the very condition retries exist to tolerate would defeat the point.
648
+ const status = t.status === 'flaky' ? 'flaky' : inapplicable ? 'inapplicable' : 'passed';
649
+ markCoverage(componentId, rule, status, inapplicable);
650
+ continue;
651
+ }
652
+ // `describe.serial` skips the rest of a component's obligations after its
653
+ // first failure; reconciliation (in runContractTests) accounts for these,
654
+ // so a bare skip here is not, by itself, a second thing to report.
655
+ if (t.status === 'skipped') continue;
656
+
657
+ const last = t.lastResult;
658
+ if (last?.status === 'timedOut' || last?.status === 'interrupted') {
659
+ findings.push({
660
+ rule: 'tests-incomplete',
661
+ file: 'package.json',
662
+ line: 1,
663
+ message: `${componentId ? `${componentId}: ` : ''}"${t.specTitle}" did not finish (${last.status})`,
664
+ context: { suite: 'playwright', title: [...t.describeTitles, t.specTitle].join(' > '), suiteFile: t.specFile, suiteLine: t.specLine },
665
+ });
666
+ markCoverage(componentId, rule, 'failed');
667
+ continue;
668
+ }
669
+
670
+ const rawErrors = last?.errors?.length ? last.errors : [{ message: `${t.status}: ${t.specTitle}` }];
671
+ for (const error of rawErrors) {
672
+ const infra = classifyInfrastructureError(error.message);
673
+ if (infra) {
674
+ findings.push({
675
+ rule: infra.rule,
676
+ file: 'package.json',
677
+ line: 1,
678
+ message: infra.message,
679
+ context: { suite: 'playwright', title: [...t.describeTitles, t.specTitle].join(' > ') },
680
+ });
681
+ markCoverage(componentId, rule, 'failed');
682
+ continue;
683
+ }
684
+ const block = messageBlock(error.message);
685
+ const violation = VIOLATION_RE.exec(block);
686
+ const finalRule = violation?.[1] ?? rule ?? 'tests-setup';
687
+ const id = violation?.[2] ?? componentId;
688
+ const message = violation?.[3] ?? block;
689
+ const token = extractToken(message);
690
+ const { file, line } = artifactForContractRule(root, sourceDataDir, finalRule, id, token);
691
+ findings.push({
692
+ rule: finalRule,
693
+ file,
694
+ line,
695
+ message: id ? `${id}: ${message}` : message,
696
+ context: {
697
+ suite: 'playwright',
698
+ title: [...t.describeTitles, t.specTitle].join(' > '),
699
+ suiteFile: t.specFile,
700
+ suiteLine: t.specLine,
701
+ attachments: (last?.attachments ?? []).map((a) => a.path).filter(Boolean),
702
+ },
703
+ });
704
+ markCoverage(id, finalRule, 'failed');
705
+ }
706
+ }
707
+ return { findings, coverage };
708
+ }
709
+
710
+ // ─── result mapping: Vitest (registry contract) ─────────────────────────────
711
+
712
+ /** `checkRegistryEntry`'s violation strings, read back out of Vitest's
713
+ * `expected [ ... ] to deeply equal []` failure text. `createVitestConfig`
714
+ * sets `chaiConfig.truncateThreshold: 0` so this array is never summarized
715
+ * away before it gets here. */
716
+ export function extractViolationArray(text) {
717
+ const m = /expected \[([\s\S]*?)\] to deeply equal \[\]/.exec(text ?? '');
718
+ if (!m) return [];
719
+ return [...m[1].matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((x) => x[1]);
720
+ }
721
+
722
+ /** `checkRegistryEntry` prefixes every violation with its category
723
+ * (`registration:`, `uniqueness:`, `runtime:`, `default config:`,
724
+ * `opacity floor:`, `round-trip:`); the two config-shaped ones point at the
725
+ * shipped default, everything else at the runtime the schema describes. */
726
+ export function mapRegistryViolation(root, sourceDataDir, componentId, text) {
727
+ const category = text.split(':')[0];
728
+ const token = extractToken(text);
729
+ const paths = resolveComponentPaths(componentId, root);
730
+ const target =
731
+ category === 'default config' || category === 'opacity floor'
732
+ ? componentConfigPath(sourceDataDir, componentId)
733
+ : paths.runtimePath;
734
+ return { file: relative(root, target), line: findTokenLine(target, token) };
735
+ }
736
+
737
+ /** Mirrors the Playwright side's zero-collection check: a file that failed
738
+ * before it ran a single assertion (`assertionResults` empty, `status`
739
+ * 'failed') carries its own `message` — measured against a bad
740
+ * `registrySetup` path, "Cannot find module '.../does-not-exist.ts'". Left
741
+ * unhandled, this fell through with empty coverage and no finding of its
742
+ * own, relying entirely on reconciliation's generic "did not run" message,
743
+ * which never names what actually failed to load. */
744
+ export function mapVitestResults(report, { root, sourceDataDir, stderr } = {}) {
745
+ const files = report.testResults ?? [];
746
+ if (files.length === 0) {
747
+ return {
748
+ findings: [{ rule: 'tests-incomplete', file: 'package.json', line: 1, message: `Vitest collected nothing to check: ${stderr?.trim() || 'the run collected no tests'}` }],
749
+ coverage: {},
750
+ explained: true,
751
+ };
752
+ }
753
+
754
+ const collectionFailures = files.filter((f) => f.status === 'failed' && (f.assertionResults ?? []).length === 0);
755
+ const findings = collectionFailures.map((f) => ({
756
+ rule: 'tests-incomplete',
757
+ file: 'package.json',
758
+ line: 1,
759
+ message: `Vitest collected nothing from ${f.name ?? 'a test file'}: ${f.message || stderr?.trim() || 'no message reported'}`,
760
+ }));
761
+ // Only when every file failed to load does this already account for every
762
+ // missing (component, rule) pair; one crashed file beside others that ran
763
+ // fine still leaves real gaps reconciliation has to name on its own.
764
+ if (collectionFailures.length === files.length) {
765
+ return { findings, coverage: {}, explained: true };
766
+ }
767
+
768
+ const coverage = {};
769
+ for (const file of files) {
770
+ if (collectionFailures.includes(file)) continue;
771
+ for (const assertion of file.assertionResults ?? []) {
772
+ const componentId = assertion.ancestorTitles.length >= 2 ? assertion.ancestorTitles[1] : null;
773
+ if (assertion.status !== 'failed') {
774
+ if (assertion.status === 'passed' && componentId) {
775
+ coverage[componentId] ??= {};
776
+ coverage[componentId]['contract-registry'] = { status: 'passed' };
777
+ }
778
+ continue;
779
+ }
780
+ const text = (assertion.failureMessages ?? []).join('\n');
781
+ const violations = componentId ? extractViolationArray(text) : [];
782
+ if (violations.length > 0) {
783
+ for (const violation of violations) {
784
+ const { file: f, line } = mapRegistryViolation(root, sourceDataDir, componentId, violation);
785
+ findings.push({
786
+ rule: 'contract-registry',
787
+ file: f,
788
+ line,
789
+ message: `${componentId}: ${violation}`,
790
+ context: { suite: 'vitest', title: assertion.fullName },
791
+ });
792
+ }
793
+ } else {
794
+ findings.push({
795
+ rule: componentId ? 'contract-registry' : 'tests-setup',
796
+ file: componentId ? relative(root, resolveComponentPaths(componentId, root).editorPath) : 'package.json',
797
+ line: 1,
798
+ message: text ? `${assertion.fullName}: ${text.split('\n')[0]}` : assertion.fullName,
799
+ context: { suite: 'vitest', title: assertion.fullName },
800
+ });
801
+ }
802
+ if (componentId) {
803
+ coverage[componentId] ??= {};
804
+ coverage[componentId]['contract-registry'] = { status: 'failed' };
805
+ }
806
+ }
807
+ }
808
+ return { findings, coverage };
809
+ }
810
+
811
+ function mergeCoverage(a, b) {
812
+ const out = {};
813
+ for (const [id, rules] of [...Object.entries(a), ...Object.entries(b)]) {
814
+ out[id] = { ...out[id], ...rules };
815
+ }
816
+ return out;
817
+ }
818
+
819
+ // ─── reconciliation ─────────────────────────────────────────────────────────
820
+
821
+ /**
822
+ * Every expected (component, rule) pair coverage does not already carry a
823
+ * verdict for becomes `incomplete`. A component with a `failed` rule already
824
+ * explains its own gaps (`describe.serial` stops after the first failure);
825
+ * `explainedGlobally` does the same for a setup-level finding that stopped
826
+ * the whole run. Anything left over is a genuinely unexpected skip, which
827
+ * gets its own `tests-incomplete` finding — the plan requires those to fail
828
+ * the run, not read as a clean, partial pass.
829
+ */
830
+ export function reconcileCoverage(coverage, expectedIds, { expectedRules = ALL_CONTRACT_RULES, explainedGlobally = false } = {}) {
831
+ const out = {};
832
+ for (const [id, rules] of Object.entries(coverage)) out[id] = { ...rules };
833
+ const findings = [];
834
+ for (const id of expectedIds) {
835
+ const rules = (out[id] ??= {});
836
+ const explained = explainedGlobally || Object.values(rules).some((r) => r.status === 'failed');
837
+ for (const rule of expectedRules) {
838
+ if (rules[rule]) continue;
839
+ rules[rule] = { status: 'incomplete' };
840
+ if (!explained) {
841
+ findings.push({
842
+ rule: 'tests-incomplete',
843
+ file: 'package.json',
844
+ line: 1,
845
+ message: `${id}: ${rule} did not run, and nothing else for ${id} failed to explain why`,
846
+ });
847
+ }
848
+ }
849
+ }
850
+ return { coverage: out, findings };
851
+ }
852
+
853
+ // ─── entry point ────────────────────────────────────────────────────────────
854
+
855
+ export async function runContractTests(id, { root = process.cwd(), dataDir: explicitDataDir } = {}) {
856
+ const toolFindings = missingToolFindings(root);
857
+ if (toolFindings.length > 0) return { findings: toolFindings, coverage: {} };
858
+
859
+ const ids = id ? [id] : discoverComponents(root);
860
+ if (ids.length === 0) {
861
+ return {
862
+ findings: [
863
+ {
864
+ rule: 'tests-setup',
865
+ file: 'package.json',
866
+ line: 1,
867
+ message: 'no component authored under src/system/components yet; nothing for --tests to run',
868
+ },
869
+ ],
870
+ coverage: {},
871
+ };
872
+ }
873
+
874
+ let sourceDataDir;
875
+ let dataDir;
876
+ let configDir;
877
+ try {
878
+ // `dataDir` bypasses resolution entirely: the seam a caller (a test, or a
879
+ // future batch/CI runner) uses to point an isolated copy at a source tree
880
+ // other than the project's own, without ever touching the real one.
881
+ sourceDataDir = explicitDataDir ?? resolveSourceDataDir(root, settingsFilePath(root));
882
+ dataDir = copyIsolatedDataDir(sourceDataDir);
883
+ configDir = mkdtempSync(join(tmpdir(), 'live-tokens-check-cfg-'));
884
+ } catch (error) {
885
+ if (dataDir) rmSync(dataDir, { recursive: true, force: true });
886
+ if (configDir) rmSync(configDir, { recursive: true, force: true });
887
+ return {
888
+ findings: [{ rule: 'tests-setup', file: 'package.json', line: 1, message: `could not prepare an isolated run: ${error.message}` }],
889
+ coverage: {},
890
+ };
891
+ }
892
+
893
+ const cleanup = withCleanup([dataDir, configDir]);
894
+
895
+ try {
896
+ const { playwrightConfigPath, vitestConfigPath } = writeGeneratedConfigs({ configDir, root });
897
+ process.env[TEST_DATA_DIR_ENV] = dataDir;
898
+ process.env[DATA_DIR_ENV] = dataDir;
899
+ if (id) process.env[COMPONENT_ENV] = id;
900
+ else delete process.env[COMPONENT_ENV];
901
+
902
+ const knownIds = new Set([...discoverComponents(root), ...discoverComponents(PKG_ROOT)]);
903
+ const expectedIds = id ? [id] : [...knownIds];
904
+
905
+ // Sequential: both share the one isolated data directory, and the
906
+ // registry run's own Vite instance and the Playwright run's dev server
907
+ // would otherwise regenerate the same derived files concurrently.
908
+ const registryOutcome = await runRegistrySuite({ root, configDir, vitestConfigPath });
909
+ const playwrightOutcome = await runPlaywrightSuite({ root, configDir, playwrightConfigPath });
910
+
911
+ const registryMapped = registryOutcome.setupFinding
912
+ ? { findings: [registryOutcome.setupFinding], coverage: {}, explained: true }
913
+ : mapVitestResults(registryOutcome.report, { root, sourceDataDir, stderr: registryOutcome.stderr });
914
+ const playwrightMapped = playwrightOutcome.setupFinding
915
+ ? { findings: [playwrightOutcome.setupFinding], coverage: {}, explained: true }
916
+ : mapPlaywrightResults(playwrightOutcome.report, { root, sourceDataDir, knownIds });
917
+
918
+ const mergedFindings = [...registryMapped.findings, ...playwrightMapped.findings];
919
+ // A finding from either suite's own setup path, or an explicit `explained`
920
+ // flag from a whole-run short-circuit (zero collected, missing browser),
921
+ // already accounts for every missing (component, rule) pair — without
922
+ // this, one such failure would multiply into one reconciliation finding
923
+ // per expected id on top of it.
924
+ const explainedGlobally =
925
+ registryMapped.explained ||
926
+ playwrightMapped.explained ||
927
+ mergedFindings.some((f) => f.rule === 'tests-setup' || f.rule === 'tests-not-installed');
928
+ const reconciled = reconcileCoverage(mergeCoverage(registryMapped.coverage, playwrightMapped.coverage), expectedIds, {
929
+ explainedGlobally,
930
+ });
931
+
932
+ return { findings: [...mergedFindings, ...reconciled.findings], coverage: reconciled.coverage };
933
+ } catch (error) {
934
+ return {
935
+ findings: [{ rule: 'tests-setup', file: 'package.json', line: 1, message: `check-component --tests crashed: ${error.message}` }],
936
+ coverage: {},
937
+ };
938
+ } finally {
939
+ cleanup();
940
+ }
941
+ }
942
+
943
+ export function hasHardFailure(findings) {
944
+ return findings.some((f) => HARD_FAILURE_RULES.has(f.rule));
945
+ }