@atelier-ui/create-workspace 0.2.42 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/package.json +1 -1
  3. package/src/generators/preset/files/contracts/README.md +29 -0
  4. package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
  5. package/src/generators/preset/files/contracts/types.ts.template +55 -0
  6. package/src/generators/preset/files/figma/snapshot.json +164 -0
  7. package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
  8. package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
  9. package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
  10. package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
  11. package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
  12. package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
  13. package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
  14. package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
  15. package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
  16. package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
  17. package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
  18. package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
  19. package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
  20. package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
  21. package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
  22. package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
  23. package/src/generators/preset/files/styles/tokens.css +59 -43
  24. package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
  25. package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
  26. package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
  27. package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
  28. package/src/generators/preset/files/tools/scripts/preflight.mjs +163 -31
  29. package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
  30. package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
  31. package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
  32. package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
  33. package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
  34. package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
  35. package/src/generators/preset/preset.d.ts +1 -0
  36. package/src/generators/preset/preset.js +955 -5
  37. package/src/generators/preset/preset.js.map +1 -1
  38. package/src/generators/preset/schema.d.ts +1 -0
  39. package/src/generators/preset/schema.json +5 -0
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Shared docgen plumbing (ADR-0121 §5 / S6(a)).
3
+ *
4
+ * Extracted out of check-contracts.mjs so check-manifest-parity.mjs can reuse the
5
+ * exact same recipe rather than re-deriving it: the framework-worker docgen call
6
+ * for Angular/Vue (`@storybook/{angular-vite,vue3}/internal/docgen-worker`, a
7
+ * story file as the entry point), `react-docgen`'s own `parse()` for React (the
8
+ * worker's React export is the inactive react-component-meta engine in this
9
+ * repo), and each framework's raw-payload normaliser into the common
10
+ * `{ name, kind: 'enum'|'boolean'|'other', members, default, isOutput }` shape
11
+ * check-contracts.mjs already established. Both scripts importing from one place
12
+ * means they cannot silently diverge on what a framework's docgen payload means
13
+ * — the exact drift class this repo's gates exist to prevent elsewhere.
14
+ *
15
+ * Every function here is parameterized (root, cwdRequire) rather than reading
16
+ * module-level globals, so it works identically whichever script imports it and
17
+ * from whatever cwd that script resolves its own framework packages.
18
+ */
19
+ 'use strict';
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ export const FRAMEWORKS = ['angular', 'react', 'vue'];
26
+
27
+ /** Recursively collect `**\/*.stories.{ts,tsx}` under `dir`, excluding `node_modules`. */
28
+ export function collectStoryFilesUnder(dir) {
29
+ const out = [];
30
+ if (!fs.existsSync(dir)) return out;
31
+ (function walk(d) {
32
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
33
+ if (entry.name === 'node_modules') continue;
34
+ const full = path.join(d, entry.name);
35
+ if (entry.isDirectory()) walk(full);
36
+ else if (entry.isFile() && /\.stories\.(ts|tsx)$/.test(entry.name))
37
+ out.push(full);
38
+ }
39
+ })(dir);
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Story files for `fw`: every `storiesDirs` root walked recursively when given
45
+ * (a scaffold's one roster for its one framework), else the monorepo's
46
+ * `libs/<fw>/src/lib` convention under `root`.
47
+ */
48
+ export function findStoryFiles(fw, { root, storiesDirs } = {}) {
49
+ if (storiesDirs) {
50
+ const out = new Set();
51
+ for (const dir of storiesDirs)
52
+ for (const f of collectStoryFilesUnder(dir)) out.add(f);
53
+ return [...out].sort();
54
+ }
55
+ const base = path.join(root, 'libs', fw, 'src', 'lib');
56
+ return collectStoryFilesUnder(base).sort();
57
+ }
58
+
59
+ export function toRepoImportPath(absPath, root) {
60
+ return './' + path.relative(root, absPath).split(path.sep).join('/');
61
+ }
62
+
63
+ /** Best-effort human-readable message for a caught value that might not be an
64
+ * `Error` (a thrown string, a plain object, ...). Shared so a [DOCGEN-FAILED]
65
+ * reason reads consistently across every catch site that reports one: this
66
+ * file's own `makeWorkerDocgen()`, and check-contracts.mjs's / check-
67
+ * manifest-parity.mjs's own try/catch around `reactParseFile()` (react-docgen
68
+ * isn't guaranteed to throw an `Error` instance either). */
69
+ export function errorMessage(e) {
70
+ return e && e.message ? e.message : String(e);
71
+ }
72
+
73
+ /**
74
+ * Whether `rawSpecifier` — a story meta's `component:` import path, csf-tools'
75
+ * `_rawComponentPath` (set for ANY framework whose meta resolves `component` to
76
+ * a statically-imported identifier, not just React) — is a BARE package
77
+ * specifier (`@scope/name[/sub]` or `name[/sub]`, never `./`, `../` or `/`)
78
+ * that a real install answers, walked up from `storyFile`'s own directory the
79
+ * same way Node's own `node_modules` resolution does (parent directories,
80
+ * `node_modules/<pkg>/package.json` at each). Returns that `node_modules/<pkg>`
81
+ * directory, or `null` when `rawSpecifier` is missing/relative/absolute, when
82
+ * the walk finds no matching install (e.g. a workspace tsconfig path alias
83
+ * like this monorepo's own `@atelier-ui/*`, which has no real `node_modules`
84
+ * entry at all), or when the matching `node_modules/<pkg>` entry is an
85
+ * npm-workspaces SYMLINK back into the repo's own source (e.g. this repo's own
86
+ * `node_modules/@atelier-ui/generators -> tools/generators`) — resolved via
87
+ * `fs.realpathSync` and classed as external only when the REAL path still
88
+ * contains a `node_modules` path segment; a workspace symlink's real path
89
+ * doesn't, so it's workspace code and must be docgen'd, not skipped.
90
+ *
91
+ * Deliberately NOT `require.resolve`: a package's `exports` map makes that
92
+ * throw for a subpath it doesn't list and for an ESM-only package resolved
93
+ * through a CJS `createRequire` — exactly the packages this check exists to
94
+ * catch (`@atelier-ui/angular` et al. in a real scaffold install). The
95
+ * directory walk sidesteps both failure modes because it only needs to know
96
+ * the package is INSTALLED, not import anything from it.
97
+ */
98
+ export function findExternalPackageDir(storyFile, rawSpecifier) {
99
+ if (!rawSpecifier) return null;
100
+ if (
101
+ rawSpecifier.startsWith('./') ||
102
+ rawSpecifier.startsWith('../') ||
103
+ rawSpecifier.startsWith('/')
104
+ )
105
+ return null;
106
+
107
+ const segments = rawSpecifier.split('/');
108
+ const pkg = rawSpecifier.startsWith('@')
109
+ ? segments.slice(0, 2).join('/')
110
+ : segments[0];
111
+ if (!pkg) return null;
112
+
113
+ let dir = path.dirname(storyFile);
114
+ for (;;) {
115
+ const candidate = path.join(dir, 'node_modules', pkg);
116
+ if (fs.existsSync(path.join(candidate, 'package.json'))) {
117
+ let real;
118
+ try {
119
+ real = fs.realpathSync(candidate);
120
+ } catch {
121
+ real = candidate;
122
+ }
123
+ return real.includes(`${path.sep}node_modules${path.sep}`)
124
+ ? candidate
125
+ : null;
126
+ }
127
+ const parent = path.dirname(dir);
128
+ if (parent === dir) return null;
129
+ dir = parent;
130
+ }
131
+ }
132
+
133
+ /** First of `base`, `base.tsx`, `base.ts`, `base/index.tsx`, `base/index.ts` that exists as a file. */
134
+ export function resolveWithExtensions(base) {
135
+ const candidates = [
136
+ base,
137
+ `${base}.tsx`,
138
+ `${base}.ts`,
139
+ path.join(base, 'index.tsx'),
140
+ path.join(base, 'index.ts'),
141
+ ];
142
+ for (const c of candidates) {
143
+ if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Angular/Vue docgen via the Storybook framework worker, story file as the
150
+ * entry point. The worker package itself is resolved from `cwdRequire`'s
151
+ * node_modules (the caller's cwd), not this file's own directory, so a copy
152
+ * dropped into a scaffold picks up the scaffold's own installed Storybook —
153
+ * same portability rule check-contracts.mjs already followed.
154
+ *
155
+ * The returned function resolves to a DISCRIMINATED result rather than a bare
156
+ * payload-or-null: `{ ok: true, payload }` on success, `{ ok: false, reason }`
157
+ * for every failure mode — the provider throwing, an empty/falsy payload, or
158
+ * `payload.error` being set. Collapsing all three into `null` (the shape this
159
+ * function had until ADR-0124) is exactly what let a broken docgen worker
160
+ * read as "no component here": both call sites treated `null` as "skip,
161
+ * nothing to see" with no residual signal at all (ADR-0034's
162
+ * roster-derivation convention, applied to this gate by ADR-0124). `reason`
163
+ * is always a string, ready to drop straight into a finding message.
164
+ */
165
+ export async function makeWorkerDocgen(fw, cwdRequire, root) {
166
+ const spec =
167
+ fw === 'angular'
168
+ ? '@storybook/angular-vite/internal/docgen-worker'
169
+ : '@storybook/vue3/internal/docgen-worker';
170
+ const mod = await import(pathToFileURL(cwdRequire.resolve(spec)).href);
171
+ const middleware =
172
+ fw === 'angular'
173
+ ? mod.createDocgenProvider({ propsTable: 'api' })
174
+ : mod.createDocgenProvider();
175
+ const provider = middleware(async () => undefined);
176
+ return async (storyFilePath, csf) => {
177
+ const entry = {
178
+ type: 'story',
179
+ subtype: 'story',
180
+ id: `${path.basename(storyFilePath)}--docgen`,
181
+ name: 'Docgen',
182
+ title: csf._meta.title,
183
+ importPath: toRepoImportPath(storyFilePath, root),
184
+ tags: [],
185
+ };
186
+ let payload;
187
+ try {
188
+ payload = await provider({ entry });
189
+ } catch (e) {
190
+ return { ok: false, reason: errorMessage(e) };
191
+ }
192
+ if (!payload) return { ok: false, reason: 'empty payload' };
193
+ if (payload.error)
194
+ return { ok: false, reason: errorMessage(payload.error) };
195
+ return { ok: true, payload };
196
+ };
197
+ }
198
+
199
+ /** Angular worker payload -> `{ props, description, slots }`, `props` in the common shape. */
200
+ export function normalizeAngular(payload) {
201
+ const out = [];
202
+ for (const [key, at] of Object.entries(payload.argTypes || {})) {
203
+ const category = at.table && at.table.category;
204
+ if (category !== 'inputs' && category !== 'outputs') continue;
205
+ const isOutput = category === 'outputs';
206
+ let kind = 'other';
207
+ let members;
208
+ const typeName = at.type && at.type.name;
209
+ if (typeName === 'enum' && Array.isArray(at.type.value)) {
210
+ kind = 'enum';
211
+ members = at.type.value;
212
+ } else if (typeName === 'boolean') {
213
+ kind = 'boolean';
214
+ }
215
+ out.push({
216
+ name: key,
217
+ kind,
218
+ members,
219
+ default:
220
+ at.table && at.table.defaultValue
221
+ ? at.table.defaultValue.summary
222
+ : undefined,
223
+ description: at.description,
224
+ required: undefined,
225
+ isOutput,
226
+ typeText: at.table && at.table.type ? at.table.type.summary : undefined,
227
+ });
228
+ }
229
+ return { props: out, description: payload.description, slots: undefined };
230
+ }
231
+
232
+ /** Vue worker payload -> `{ props, description, slots }`, `props` in the common shape (events appended, isOutput: true). */
233
+ export function normalizeVue(payload) {
234
+ const out = [];
235
+ const meta = payload.vueComponentMeta;
236
+ for (const p of (meta && meta.props) || []) {
237
+ if (p.global) continue;
238
+ let kind = 'other';
239
+ let members;
240
+ const schema = p.schema;
241
+ if (schema && schema.kind === 'enum' && Array.isArray(schema.schema)) {
242
+ const raw = schema.schema.filter((v) => v !== 'undefined');
243
+ const isBoolSet =
244
+ raw.length > 0 && raw.every((v) => v === 'true' || v === 'false');
245
+ const isStringEnum =
246
+ raw.length > 0 &&
247
+ raw.every(
248
+ (v) => typeof v === 'string' && v.startsWith('"') && v.endsWith('"'),
249
+ );
250
+ if (isBoolSet) kind = 'boolean';
251
+ else if (isStringEnum) {
252
+ kind = 'enum';
253
+ members = raw.map((v) => v.slice(1, -1));
254
+ }
255
+ }
256
+ let defaultValue;
257
+ if (p.default !== undefined) {
258
+ try {
259
+ defaultValue = JSON.parse(p.default);
260
+ } catch {
261
+ defaultValue = p.default;
262
+ }
263
+ }
264
+ out.push({
265
+ name: p.name,
266
+ kind,
267
+ members,
268
+ default: defaultValue,
269
+ description: p.description || undefined,
270
+ required: p.required,
271
+ isOutput: false,
272
+ typeText: p.type,
273
+ });
274
+ }
275
+ for (const e of (meta && meta.events) || []) {
276
+ out.push({
277
+ name: e.name,
278
+ kind: 'other',
279
+ description: e.description || undefined,
280
+ isOutput: true,
281
+ typeText: e.type,
282
+ });
283
+ }
284
+ const slots = ((meta && meta.slots) || []).map((s) => ({
285
+ name: s.name,
286
+ description: s.description || undefined,
287
+ }));
288
+ return { props: out, description: payload.description, slots };
289
+ }
290
+
291
+ /**
292
+ * React docgen tooling bound to one `cwdRequire`. `react-docgen` is required
293
+ * lazily and from the caller's cwd (an Angular/Vue-only scaffold never installs
294
+ * it), mirroring check-contracts.mjs's original lazy `getReactDocgen()`.
295
+ */
296
+ export function makeReactDocgenTools(cwdRequire) {
297
+ let _reactDocgen = null;
298
+ function getReactDocgen() {
299
+ if (!_reactDocgen) _reactDocgen = cwdRequire('react-docgen');
300
+ return _reactDocgen;
301
+ }
302
+ function makeReactImporter() {
303
+ const { makeFsImporter } = getReactDocgen();
304
+ return makeFsImporter((filename, basedir) => {
305
+ if (!filename.startsWith('.'))
306
+ throw new Error(`non-relative import '${filename}'`);
307
+ const resolved = resolveWithExtensions(path.resolve(basedir, filename));
308
+ if (!resolved)
309
+ throw new Error(`cannot resolve '${filename}' from '${basedir}'`);
310
+ return resolved;
311
+ });
312
+ }
313
+ function reactParseFile(filePath) {
314
+ const {
315
+ parse: rdParse,
316
+ builtinResolvers,
317
+ defaultHandlers,
318
+ } = getReactDocgen();
319
+ const code = fs.readFileSync(filePath, 'utf-8');
320
+ const resolver = new builtinResolvers.FindExportedDefinitionsResolver();
321
+ const importer = makeReactImporter();
322
+ const results = rdParse(code, {
323
+ resolver,
324
+ handlers: defaultHandlers,
325
+ importer,
326
+ filename: filePath,
327
+ });
328
+ // Stash the raw source (and its path) on each result — normalizeReactDocgen's
329
+ // destructuring fallback below needs both, and this is the one place a
330
+ // component's file is actually read, so they are attached here rather than
331
+ // re-read per component.
332
+ for (const r of results) {
333
+ r.__source = code;
334
+ r.__file = filePath;
335
+ }
336
+ return results;
337
+ }
338
+ return { getReactDocgen, makeReactImporter, reactParseFile };
339
+ }
340
+
341
+ /**
342
+ * Walk up from `startDir` looking for `libs/spec/src/index.ts` — the
343
+ * framework-agnostic contract every `Atl<X>Spec` extends. Bounded (10 levels)
344
+ * and returns `null` rather than throwing when nothing is found, so a
345
+ * standalone scaffold that doesn't carry `libs/spec` at all (see this file's
346
+ * own "parameterized, no module globals" header note) degrades to no
347
+ * candidates instead of an error.
348
+ */
349
+ function findSpecIndexFile(startDir) {
350
+ let dir = startDir;
351
+ for (let i = 0; i < 10; i++) {
352
+ const candidate = path.join(dir, 'libs', 'spec', 'src', 'index.ts');
353
+ if (fs.existsSync(candidate)) return candidate;
354
+ const parent = path.dirname(dir);
355
+ if (parent === dir) break;
356
+ dir = parent;
357
+ }
358
+ return null;
359
+ }
360
+
361
+ const _formFieldStringPropsCache = new Map(); // specFile -> string[]
362
+
363
+ /**
364
+ * The string-typed field names declared on `AtlFormFieldSpec` in
365
+ * `libs/spec/src/index.ts` (found by walking up from `componentFilePath`) —
366
+ * `['name']` today (`value` is `any`, `onValueChange` a callback, `disabled`/
367
+ * `invalid`/`required` boolean). This is the ONLY candidate set the
368
+ * destructuring fallback below considers: it is the exact, narrow class of
369
+ * prop this fallback exists for (a string field inherited two levels deep
370
+ * through `Atl<X>Spec extends Omit<AtlFormFieldSpec, …>`, with no default for
371
+ * react-docgen's own heuristic to latch onto) — never a general "every
372
+ * destructured identifier react-docgen missed" scan, which would just as
373
+ * happily "recover" a native HTML passthrough attribute (`id`, `style`,
374
+ * `onChange`, …) a component destructures for its own unrelated reasons.
375
+ */
376
+ function formFieldStringProps(componentFilePath) {
377
+ const specFile = findSpecIndexFile(path.dirname(componentFilePath));
378
+ if (!specFile) return [];
379
+ if (_formFieldStringPropsCache.has(specFile))
380
+ return _formFieldStringPropsCache.get(specFile);
381
+ const src = fs.readFileSync(specFile, 'utf-8');
382
+ const ifaceMatch = /interface\s+AtlFormFieldSpec\s*\{([\s\S]*?)\n\}/.exec(
383
+ src,
384
+ );
385
+ const out = [];
386
+ if (ifaceMatch) {
387
+ const fieldRe = /^\s*(\w+)\??:\s*(.+?);\s*$/gm;
388
+ let fm;
389
+ while ((fm = fieldRe.exec(ifaceMatch[1])) !== null) {
390
+ if (fm[2].trim() === 'string') out.push(fm[1]);
391
+ }
392
+ }
393
+ _formFieldStringPropsCache.set(specFile, out);
394
+ return out;
395
+ }
396
+
397
+ /**
398
+ * Best-effort scan of `componentName`'s OWN function declaration in `source` for
399
+ * a destructured props parameter (`export function Name({ a, b = false, ... }:
400
+ * Props) {`), returning `{ name, default }` for every top-level, non-rest
401
+ * identifier found there. `default` is populated only when the destructuring
402
+ * default is a bare string/boolean/number literal — an array, object or call
403
+ * expression (`errors = []`) is left `undefined` rather than guessed at.
404
+ *
405
+ * Narrow on purpose (see normalizeReactDocgen's fallback below for why this
406
+ * exists at all): it never inspects a TYPE, only the parameter list actually
407
+ * written in the function signature, so it can't invent a prop that isn't
408
+ * really destructured there. A renamed entry (`name: local`) still yields the
409
+ * PROP name (`name`), not the local binding.
410
+ */
411
+ function scanDestructuredReactProps(source, componentName) {
412
+ const declRe = new RegExp(`function\\s+${componentName}\\s*\\(`);
413
+ const declMatch = declRe.exec(source);
414
+ if (!declMatch) return [];
415
+
416
+ let i = declMatch.index + declMatch[0].length;
417
+ while (i < source.length && /\s/.test(source[i])) i++;
418
+ if (source[i] !== '{') return []; // not a destructured-object parameter
419
+
420
+ const start = i + 1;
421
+ let depth = 1;
422
+ let j = start;
423
+ for (; j < source.length && depth > 0; j++) {
424
+ if (source[j] === '{') depth++;
425
+ else if (source[j] === '}') depth--;
426
+ }
427
+ if (depth !== 0) return []; // unbalanced — bail rather than guess
428
+ const body = source.slice(start, j - 1);
429
+
430
+ // Split top-level entries on commas, respecting nested {}/[]/() so a default
431
+ // like `errors = []` or a nested destructure doesn't get split mid-token.
432
+ const items = [];
433
+ let cur = '';
434
+ let nestDepth = 0;
435
+ for (const ch of body) {
436
+ if (ch === '{' || ch === '[' || ch === '(') nestDepth++;
437
+ else if (ch === '}' || ch === ']' || ch === ')') nestDepth--;
438
+ if (ch === ',' && nestDepth === 0) {
439
+ items.push(cur);
440
+ cur = '';
441
+ } else {
442
+ cur += ch;
443
+ }
444
+ }
445
+ if (cur.trim()) items.push(cur);
446
+
447
+ const out = [];
448
+ const itemRe =
449
+ /^([A-Za-z_$][\w$]*)\s*(?::\s*[A-Za-z_$][\w$]*)?\s*(?:=\s*([\s\S]+))?$/;
450
+ for (const raw of items) {
451
+ const item = raw.trim();
452
+ if (!item || item.startsWith('...')) continue;
453
+ const m = itemRe.exec(item);
454
+ if (!m) continue;
455
+ const name = m[1];
456
+ const defaultExpr = m[2] ? m[2].trim() : undefined;
457
+ let literalDefault;
458
+ if (defaultExpr !== undefined) {
459
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(defaultExpr);
460
+ if (quoted) literalDefault = quoted[2];
461
+ else if (defaultExpr === 'true') literalDefault = true;
462
+ else if (defaultExpr === 'false') literalDefault = false;
463
+ else if (/^-?\d+(\.\d+)?$/.test(defaultExpr))
464
+ literalDefault = Number(defaultExpr);
465
+ // anything else (array/object/call expression) — left undefined
466
+ }
467
+ out.push({ name, default: literalDefault });
468
+ }
469
+ return out;
470
+ }
471
+
472
+ /** react-docgen's raw per-component payload -> array of props in the common shape. */
473
+ export function normalizeReactDocgen(d) {
474
+ const out = [];
475
+ const known = new Set();
476
+ for (const [propName, info] of Object.entries(d.props || {})) {
477
+ known.add(propName);
478
+ const tsType = info.tsType;
479
+ let kind = 'other';
480
+ let members;
481
+ if (tsType) {
482
+ if (
483
+ tsType.name === 'union' &&
484
+ Array.isArray(tsType.elements) &&
485
+ tsType.elements.every((e) => e.name === 'literal')
486
+ ) {
487
+ const raw = tsType.elements.map((e) =>
488
+ typeof e.value === 'string'
489
+ ? e.value.replace(/^['"]|['"]$/g, '')
490
+ : e.value,
491
+ );
492
+ if (raw.every((v) => v === 'true' || v === 'false')) kind = 'boolean';
493
+ else {
494
+ kind = 'enum';
495
+ members = raw;
496
+ }
497
+ } else if (tsType.name === 'boolean') {
498
+ kind = 'boolean';
499
+ }
500
+ } else if (
501
+ info.defaultValue &&
502
+ (info.defaultValue.value === 'true' ||
503
+ info.defaultValue.value === 'false')
504
+ ) {
505
+ // react-docgen sometimes resolves a prop inherited through a multi-level interface
506
+ // chain (AtlCheckboxSpec extends AtlFormFieldSpec) without a tsType at all — seen
507
+ // on 'disabled'/'required' here, though the sibling 'invalid' (declared one level
508
+ // shallower) resolves fine. A literal true/false default is otherwise only ever a
509
+ // boolean prop in this codebase, so infer the kind from it rather than losing the
510
+ // prop to 'other' and under-reporting a real BOOLEAN drift.
511
+ kind = 'boolean';
512
+ }
513
+ let defaultValue;
514
+ if (info.defaultValue && !info.defaultValue.computed) {
515
+ const raw = info.defaultValue.value;
516
+ if (raw === 'true') defaultValue = true;
517
+ else if (raw === 'false') defaultValue = false;
518
+ else if (typeof raw === 'string')
519
+ defaultValue = raw.replace(/^['"]|['"]$/g, '');
520
+ }
521
+ out.push({
522
+ name: propName,
523
+ kind,
524
+ members,
525
+ default: defaultValue,
526
+ description: info.description || undefined,
527
+ required: !!info.required,
528
+ isOutput: /^on[A-Z]/.test(propName),
529
+ typeText: (tsType && (tsType.raw || tsType.name)) || undefined,
530
+ });
531
+ }
532
+ // Fallback for a prop react-docgen resolves NOTHING for at all: it can only
533
+ // infer a boolean kind (above) from a literal true/false DEFAULT VALUE it
534
+ // already found, which requires it to have returned an entry in the first
535
+ // place — that happens for 'disabled'/'required' here (inherited two levels
536
+ // deep through `AtlCheckboxSpec extends Omit<AtlFormFieldSpec, …>`, same as
537
+ // 'name', but destructured WITH a `= false` default for react-docgen's
538
+ // resolver to latch onto). A same-depth prop with no default at all (`name`,
539
+ // no default) gives react-docgen nothing to find, so it is missing from
540
+ // `d.props` entirely rather than merely untyped. Recovered from the
541
+ // component's own destructured props parameter — same regex-over-the-source
542
+ // idiom check-defaults.js/check-contracts.mjs use — but ONLY for the exact,
543
+ // narrow candidate set formFieldStringProps() names (see its own doc comment
544
+ // for why: this is not a general "every destructured identifier react-docgen
545
+ // missed" scan, which would just as happily manufacture a finding for a
546
+ // native HTML passthrough attribute — `id`, `style`, `onChange`, … — that a
547
+ // component destructures for its own, unrelated reasons and that react-docgen
548
+ // fails to resolve for a completely different reason (a generic DOM type
549
+ // react-docgen's resolver can't traverse at all, not this two-level-Omit
550
+ // shape).
551
+ if (d.__source && d.displayName && d.__file) {
552
+ const candidates = new Set(formFieldStringProps(d.__file));
553
+ if (candidates.size > 0) {
554
+ for (const {
555
+ name,
556
+ default: literalDefault,
557
+ } of scanDestructuredReactProps(d.__source, d.displayName)) {
558
+ if (known.has(name) || !candidates.has(name)) continue;
559
+ out.push({
560
+ name,
561
+ kind: 'other',
562
+ members: undefined,
563
+ default: literalDefault,
564
+ description: undefined,
565
+ required: false,
566
+ isOutput: /^on[A-Z]/.test(name),
567
+ typeText: undefined,
568
+ });
569
+ }
570
+ }
571
+ }
572
+ return out;
573
+ }
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+ /**
3
+ * Shared TypeScript Compiler API helpers — extract literal values from a
4
+ * `.ts` source file without running it. Used by every drift-gate or
5
+ * generator that needs to read a structured-but-typed export
6
+ * (component data, metadata, token manifests).
7
+ *
8
+ * The pattern: parse the file with `ts.createProgram`, walk the AST
9
+ * looking for `VariableStatement` nodes whose initializer is an array,
10
+ * object, or literal we can evaluate statically. `evalNode` handles the
11
+ * common literal shapes (strings, numbers, booleans, nested arrays,
12
+ * nested object literals, `as const` casts).
13
+ *
14
+ * This is intentionally a *static* evaluator — it does not execute code,
15
+ * resolve identifiers, or follow imports. Drift-gates want a fast,
16
+ * deterministic read of a configuration-shaped file, not a JS runtime.
17
+ *
18
+ * Extracted from `tools/scripts/gen-llms-txt.mjs` (commit 7a61c26-era)
19
+ * so `check-metadata.js`, `check-css-tokens.js`, and the generator share
20
+ * one implementation. CommonJS so the existing `check-*.js` scripts can
21
+ * `require` it without an ESM wrapper.
22
+ */
23
+
24
+ const ts = require('typescript');
25
+
26
+ /**
27
+ * Evaluate an AST node as a static literal. Returns `null` for nodes the
28
+ * evaluator does not understand (function calls, identifiers, computed
29
+ * property names, etc.) — callers should treat `null` as "not statically
30
+ * evaluable" rather than "absent".
31
+ */
32
+ function evalNode(node) {
33
+ if (!node) return null;
34
+ if (ts.isStringLiteralLike(node)) return node.text;
35
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
36
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
37
+ if (ts.isNumericLiteral(node)) return Number(node.text);
38
+ if (
39
+ ts.isPrefixUnaryExpression(node) &&
40
+ node.operator === ts.SyntaxKind.MinusToken
41
+ ) {
42
+ const v = evalNode(node.operand);
43
+ return typeof v === 'number' ? -v : null;
44
+ }
45
+ if (ts.isArrayLiteralExpression(node)) return node.elements.map(evalNode);
46
+ if (ts.isObjectLiteralExpression(node)) {
47
+ const obj = {};
48
+ for (const prop of node.properties) {
49
+ if (!ts.isPropertyAssignment(prop)) continue;
50
+ const key = ts.isStringLiteralLike(prop.name)
51
+ ? prop.name.text
52
+ : ts.isIdentifier(prop.name)
53
+ ? prop.name.text
54
+ : null;
55
+ if (!key) continue;
56
+ obj[key] = evalNode(prop.initializer);
57
+ }
58
+ return obj;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Parse a `.ts` source file and return the exported `VariableStatement`
65
+ * initializers keyed by identifier. `as const` and `satisfies` casts are
66
+ * unwrapped automatically. Non-evaluable initializers map to `null`.
67
+ *
68
+ * Example:
69
+ * const { metadata, COMPONENT_METADATA_REGISTRY } = parseExportedVars(
70
+ * '/abs/path/to/file.ts'
71
+ * );
72
+ */
73
+ function parseExportedVars(filePath) {
74
+ const program = ts.createProgram([filePath], {
75
+ target: ts.ScriptTarget.Latest,
76
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
77
+ noEmit: true,
78
+ });
79
+ const sourceFile = program.getSourceFile(filePath);
80
+ if (!sourceFile) throw new Error(`Could not load ${filePath}`);
81
+
82
+ const out = {};
83
+ ts.forEachChild(sourceFile, (node) => {
84
+ if (!ts.isVariableStatement(node)) return;
85
+ for (const decl of node.declarationList.declarations) {
86
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
87
+ let init = decl.initializer;
88
+ if (ts.isAsExpression(init)) init = init.expression;
89
+ if (ts.isSatisfiesExpression && ts.isSatisfiesExpression(init))
90
+ init = init.expression;
91
+ out[decl.name.text] = evalNode(init);
92
+ }
93
+ });
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Parse a `.ts` source file and return the names of every exported
99
+ * `interface` declaration that ends in a given suffix (default `Spec`).
100
+ * Used by `check-metadata.js` to discover which spec interfaces exist
101
+ * without hard-coding the list.
102
+ */
103
+ function findExportedInterfaces(filePath, suffix = 'Spec') {
104
+ const program = ts.createProgram([filePath], {
105
+ target: ts.ScriptTarget.Latest,
106
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
107
+ noEmit: true,
108
+ });
109
+ const sourceFile = program.getSourceFile(filePath);
110
+ if (!sourceFile) throw new Error(`Could not load ${filePath}`);
111
+
112
+ const names = [];
113
+ ts.forEachChild(sourceFile, (node) => {
114
+ if (!ts.isInterfaceDeclaration(node)) return;
115
+ const hasExport = (node.modifiers || []).some(
116
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
117
+ );
118
+ if (!hasExport) return;
119
+ const name = node.name.text;
120
+ if (suffix && !name.endsWith(suffix)) return;
121
+ names.push(name);
122
+ });
123
+ return names;
124
+ }
125
+
126
+ module.exports = { evalNode, parseExportedVars, findExportedInterfaces };