@weatherboard/gyde-design 0.3.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.
package/cli.mjs ADDED
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * G-53 — `gyde design`, first version.
4
+ *
5
+ * Four commands.
6
+ *
7
+ * scan <path> measure a repository as it is
8
+ * plan <path> say exactly what would be emitted, and write nothing
9
+ * init <path> write it; refuse to overwrite anything
10
+ * gate <path> fail on anything new since the committed ledger
11
+ *
12
+ * `plan` and `init` call the same `buildEmission()`, so the dry run cannot
13
+ * describe a file the real run would not write. A scaffolder whose dry run and
14
+ * real run can differ is one nobody can review before it writes into their
15
+ * repository, and sharing the call is what makes that structural rather than a
16
+ * promise.
17
+ *
18
+ * Everything printed carries its denominator. CHARTER §5's rule reaches the CLI
19
+ * too: "Gyde did not measure this" and "Gyde found nothing here" must never
20
+ * render the same, so skipped packages are listed by name and rules that
21
+ * matched nothing are reported as suspicious rather than omitted.
22
+ */
23
+
24
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
25
+ import { execFileSync } from "node:child_process";
26
+ import { join, resolve, dirname } from "node:path";
27
+
28
+ import { discover, summarise, detectScope, detectRunner } from "./workspace.mjs";
29
+ import { scan, format } from "./scan.mjs";
30
+ import { SEED, generateCss, validate } from "./tokens.mjs";
31
+ import {
32
+ seedBoundaries, seedDependencyBoundaries, checkBoundaries,
33
+ checkDependencyBoundaries, findVersionDrift,
34
+ } from "./boundaries.mjs";
35
+ import { emitTokens, emitSystem, emitConfig, SEED_COMPONENTS, SCAFFOLD_VERSION, NOT_UPGRADEABLE } from "./emit.mjs";
36
+ import { emitCatalogue } from "./catalogue.mjs";
37
+ import { emitAgentDocs } from "./agentdocs.mjs";
38
+ import { emitWorkflow } from "./workflow.mjs";
39
+ import { buildManifest, readManifest, writeManifest, applyUpgrade, formatUpgrade, MANIFEST } from "./upgrade.mjs";
40
+ import { exportedComponents, renderableComponents, checkWiring, formatWiring, WIRING } from "./wiring.mjs";
41
+ import { checkClientBoundary, formatClientBoundary, blocks as clientBoundaryBlocks } from "./clientboundary.mjs";
42
+ import { checkDocDrift, formatDocDrift } from "./docdrift.mjs";
43
+ import { reconcile } from "./adoption.mjs";
44
+ import { detectTailwind, formatTailwind } from "./tailwind.mjs";
45
+ import { migrationProgress, formatMigration } from "./migration.mjs";
46
+ import { checkStyleX, formatStyleX } from "./stylex.mjs";
47
+ import { blocksAt, formatSchedule, SCHEDULE } from "./enforcement.mjs";
48
+ import { buildUsage, guidance, formatUsage } from "./usage.mjs";
49
+ import { record, gate, formatGate, adopt, LEDGER_NOTE } from "./ratchet.mjs";
50
+ import { loadRules } from "./rules.mjs";
51
+
52
+ /**
53
+ * The single source of what would be written.
54
+ *
55
+ * `plan` prints these paths and `init` writes these contents — the same call, so
56
+ * the dry run cannot describe something the real run does not do. Any future
57
+ * emitter belongs here and nowhere else.
58
+ */
59
+ /**
60
+ * The repository's default branch, read from git rather than assumed.
61
+ *
62
+ * `main` is the common case and not the universal one — the first repository
63
+ * this was installed into uses `staging`, where a hardcoded `branches: [main]`
64
+ * silently never fires. Returns null when it cannot tell, so the caller falls
65
+ * back explicitly instead of this function inventing an answer.
66
+ */
67
+ function detectDefaultBranch(root) {
68
+ try {
69
+ const head = execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
70
+ { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
71
+ return head.replace(/^origin\//, "") || null;
72
+ } catch { /* no remote, or a repo with no origin/HEAD set */ }
73
+ try {
74
+ return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"],
75
+ { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
76
+ } catch { return null; }
77
+ }
78
+
79
+ function buildEmission(design, { hasTokens, hasSystem, scope, rootDir }) {
80
+ const systemPath = design.systemPath || "packages/design-system";
81
+ const tokensPath = design.tokensPath || "packages/design-tokens";
82
+ const primitive = design.primitive || "@base-ui/react";
83
+
84
+ /**
85
+ * The names the emitted code imports.
86
+ *
87
+ * Derived from the workspace's own scope, never invented. A hardcoded
88
+ * placeholder made every emitted import resolve to nothing — a scaffolded
89
+ * repository that would not build, with every test green because each
90
+ * compared the emitted text against itself.
91
+ *
92
+ * A workspace with no scope at all has to say what it wants: guessing a name
93
+ * is how you get a package nobody can import and nobody can find.
94
+ */
95
+ const ns = design.scope || scope;
96
+ if (!ns) {
97
+ throw new Error(
98
+ "Could not determine an npm scope for this workspace, and will not invent one.\n" +
99
+ "Set `design.scope` in gyde.config.json (e.g. \"@acme\") — emitted code has to\n" +
100
+ "import the packages it emits, and a guessed name resolves to nothing.");
101
+ }
102
+ const systemPackage = design.systemPackage || `${ns}/design-system`;
103
+ const tokensPackage = design.tokensPackage || `${ns}/design-tokens`;
104
+ const cataloguePackage = design.cataloguePackage || `${ns}/design-catalogue`;
105
+
106
+ let files = {};
107
+ // An existing package is audited, never regenerated over. Emitting into a
108
+ // directory somebody already owns is the one thing a scaffolder must not do
109
+ // by default (G-47).
110
+ if (!hasTokens) files = { ...files, ...emitTokens(undefined, { path: tokensPath, tokensPackage }) };
111
+ if (!hasSystem) {
112
+ files = { ...files, ...emitSystem({ primitive, path: systemPath, tokensPackage, systemPackage }) };
113
+ // The catalogue is emitted with the set, never separately: it is generated
114
+ // from the same component list the barrel is, which is what makes coverage
115
+ // true by construction rather than by a test somebody has to keep passing.
116
+ files = { ...files, ...emitCatalogue({ components: SEED_COMPONENTS, path: design.cataloguePath || "apps/design", systemPackage, tokensPackage, cataloguePackage }) };
117
+ }
118
+
119
+ /**
120
+ * The agent doc, decided by what is ON DISK — never by the caller's flags.
121
+ *
122
+ * Outside the `hasSystem` branch on purpose, and G-66 is why. `upgrade`
123
+ * passes `hasSystem: false` deliberately, so a scaffolded repository still
124
+ * receives component template fixes. Any doc decision taken inside that
125
+ * branch therefore reads "greenfield" in EVERY repository, including ones
126
+ * with fifty-six components of their own.
127
+ *
128
+ * That is not hypothetical — it shipped, an hour after the same bug was
129
+ * fixed one level up. System B received a document containing
130
+ * `### Button` and a prop table, for a Button Gyde has never seen. The flag
131
+ * said greenfield; the repository was not. A fix that trusts the same wrong
132
+ * input as the bug is not a fix.
133
+ *
134
+ * So the question is put to the filesystem instead. A parseable barrel with
135
+ * real components means the product owns the set and Gyde documents names
136
+ * only. No barrel means Gyde is about to emit the seed set, and can describe
137
+ * it fully because it wrote it.
138
+ *
139
+ * This is the one emitted artefact that changes what gets WRITTEN rather
140
+ * than judging what was: an agent that knows the set uses it, and one that
141
+ * does not gets audited for not using it — the expensive order.
142
+ */
143
+ const theirs = renderableComponents(rootDir, systemPath);
144
+ files = { ...files, ...emitAgentDocs(theirs
145
+ ? { components: theirs, systemPackage, verified: false }
146
+ : { components: SEED_COMPONENTS, systemPackage, verified: true }) };
147
+
148
+ /**
149
+ * The gate.
150
+ *
151
+ * Emitted even when a design system already exists, because the workflow is
152
+ * what makes the verdict binding and an adopting repository needs it most.
153
+ * Without it `gate` is a command somebody remembers to type, which is the
154
+ * most complete skip there is.
155
+ */
156
+ // Detected ONCE, here, then handed to both the workflow and the config it is
157
+ // recorded in. Re-detecting on a later run is how a workflow silently
158
+ // changes under a repository (G-66).
159
+ const runsOn = design.runsOn || detectRunner(rootDir) || "ubuntu-latest";
160
+ const defaultBranch = design.defaultBranch || detectDefaultBranch(rootDir) || "main";
161
+
162
+ files = { ...files, ...emitWorkflow({
163
+ action: design.action || undefined,
164
+ failOn: design.failOn || undefined,
165
+ runsOn,
166
+ defaultBranch,
167
+ }) };
168
+ files = { ...files, ...emitConfig({ systemPath, tokensPath, primitive, scope: ns, foreignPaths: design.foreignPaths || [], defaultBranch, runsOn }) };
169
+ return files;
170
+ }
171
+
172
+ const PRIMITIVES = [
173
+ { name: "@base-ui/react", label: "Base UI" },
174
+ { name: "@base-ui-components/react", label: "Base UI (pre-1.0 package name)" },
175
+ { name: "@radix-ui/react-dialog", label: "Radix", family: /^@radix-ui\// },
176
+ ];
177
+
178
+ /** Read gyde.config.json if the project has one. Absent is fine; guessed is not. */
179
+ function readConfig(root) {
180
+ const path = join(root, "gyde.config.json");
181
+ if (!existsSync(path)) return { present: false, design: {} };
182
+ try {
183
+ const json = JSON.parse(readFileSync(path, "utf8"));
184
+ return { present: true, design: json.design || {} };
185
+ } catch (e) {
186
+ throw new Error(`gyde.config.json exists but could not be parsed: ${e.message}\n` +
187
+ "Refusing to continue on a default — a misread config is how a project ends up measured against a scope nobody chose.");
188
+ }
189
+ }
190
+
191
+ /** What the repository already uses, read from manifests rather than assumed. */
192
+ function detectStack(root, packages) {
193
+ const found = { primitives: [], styling: [], tokens: null };
194
+ for (const pkg of packages) {
195
+ const manifest = join(root, pkg.path === "." ? "" : pkg.path, "package.json");
196
+ if (!existsSync(manifest)) continue;
197
+ let json; try { json = JSON.parse(readFileSync(manifest, "utf8")); } catch { continue; }
198
+ const deps = { ...(json.dependencies || {}), ...(json.devDependencies || {}) };
199
+ for (const name of Object.keys(deps)) {
200
+ for (const p of PRIMITIVES) {
201
+ if (name === p.name || (p.family && p.family.test(name))) {
202
+ const label = `${p.label} (${name}@${deps[name]})`;
203
+ if (!found.primitives.some((x) => x.startsWith(p.label))) found.primitives.push(`${p.label} — first seen ${pkg.path}`);
204
+ }
205
+ }
206
+ if (/^tailwindcss$/.test(name)) found.styling.push(`Tailwind ${deps[name]} in ${pkg.path}`);
207
+ if (/^@stylexjs\/stylex$/.test(name)) found.styling.push(`StyleX ${deps[name]} in ${pkg.path}`);
208
+ }
209
+ }
210
+ found.primitives = [...new Set(found.primitives)];
211
+ found.styling = [...new Set(found.styling)];
212
+ return found;
213
+ }
214
+
215
+ function cmdScan(root, config) {
216
+ const ws = discover(root);
217
+ const sum = summarise(ws);
218
+
219
+ console.log(`workspace ${sum.source}`);
220
+ console.log(` ${sum.carryUI.length} package(s) render UI: ${sum.carryUI.join(", ") || "none"}`);
221
+ if (sum.skipped.length) {
222
+ console.log(`skipped ${sum.skipped.length}, each with a reason:`);
223
+ for (const s of sum.skipped) console.log(` ${s.path} — ${s.because}`);
224
+ }
225
+ if (sum.uiWithoutPipeline.length) {
226
+ console.log(`no pipeline ${sum.uiWithoutPipeline.join(", ")}`);
227
+ }
228
+
229
+ const stack = detectStack(root, ws.packages);
230
+ console.log(`primitives ${stack.primitives.join("; ") || "none detected"}`);
231
+ console.log(`styling ${stack.styling.join("; ") || "none detected"}`);
232
+ console.log("");
233
+
234
+ // The barrel is read once and handed to the scan, so markup adoption (G-73)
235
+ // and the compound check (G-72) are measured against the same export list the
236
+ // rest of this command reports on. Re-deriving it per call site is how the
237
+ // three `scan` invocations in this file drifted the first time.
238
+ const scanRoots = exportedComponents(root, config.design?.systemPath || "packages/design-system");
239
+ const result = scan(root, {
240
+ config: { ...config.design, componentRoots: scanRoots ? new Set(scanRoots) : null },
241
+ });
242
+ console.log(format(result));
243
+
244
+ const drift = findVersionDrift(root, { packages: ws.packages, match: /^(@radix-ui|@base-ui)/ });
245
+ if (drift.length) {
246
+ console.log(`\ndrift ${drift.length} primitive package(s) pinned at more than one version:`);
247
+ for (const d of drift.slice(0, 8)) {
248
+ console.log(` ${d.dependency} ${Object.entries(d.versions).map(([p, v]) => `${p}=${v}`).join(" ")}`);
249
+ }
250
+ if (drift.length > 8) console.log(` … and ${drift.length - 8} more`);
251
+ }
252
+
253
+ const design = config.design || {};
254
+ const systemPath = design.systemPath || "packages/design-system";
255
+ const components = exportedComponents(root, systemPath);
256
+
257
+ if (components || ws.packages.some((p) => p.path === systemPath)) {
258
+ console.log("");
259
+ console.log(formatWiring(checkWiring(root, ws.packages, {
260
+ systemPath,
261
+ systemPackage: design.systemPackage || null,
262
+ tokensPackage: design.tokensPackage || null,
263
+ })));
264
+ }
265
+
266
+ // G-67. Reported wherever the system is measured, because the defect it
267
+ // finds is invisible at every other gate: the source reads as correct and
268
+ // the build passes.
269
+ console.log("");
270
+ console.log(formatClientBoundary(checkClientBoundary(root, { packages: ws.packages })));
271
+
272
+ // G-70. contract.md ranks docs-rot the highest-priority intake class, because
273
+ // a doc naming a deleted component still looks authoritative and an agent
274
+ // reads it first.
275
+ // G-98. Printed with the class count beside it, because the dependency must
276
+ // not be removed while classes still resolve through it.
277
+ // G-100. The positive half of G-98: one styling layer, and it is wired.
278
+ console.log("");
279
+ console.log(formatStyleX(checkStyleX(root, { packages: ws.packages, renders: sum.carryUI })));
280
+
281
+ console.log("");
282
+ console.log(formatTailwind(detectTailwind(root, {
283
+ packages: ws.packages,
284
+ utilityClasses: result.tailwindClasses,
285
+ cssDirectives: result.tailwindCssDirectives,
286
+ })));
287
+
288
+ console.log("");
289
+ console.log(formatDocDrift(checkDocDrift(root, {
290
+ systemPackage: design.systemPackage || null,
291
+ exports: components,
292
+ // G-96. Which documents the product declares are component contracts.
293
+ // Undeclared, the prose assertion does not run and the report says so.
294
+ contractDocs: design.contractDocs || [],
295
+ })));
296
+
297
+ if (components) {
298
+ const usage = buildUsage(root, {
299
+ components,
300
+ excludePaths: [systemPath, ...(design.usageExcludePaths || [])],
301
+ classPrefixes: design.classPrefixes || ["ds-"],
302
+ });
303
+ const found = guidance(usage);
304
+ console.log("");
305
+ console.log(formatUsage(usage, found));
306
+ }
307
+
308
+ if (!config.present) {
309
+ console.log(
310
+ "\nnote no gyde.config.json — every path was treated as product code.\n" +
311
+ " `systemPaths` and `foreignPaths` suppress findings, so they are\n" +
312
+ " configuration a project commits, never something Gyde infers.",
313
+ );
314
+ }
315
+ return result.findings.length === 0 ? 0 : 0; // scan reports; the gate decides (G-52)
316
+ }
317
+
318
+ function cmdPlan(root, config) {
319
+ const ws = discover(root);
320
+ const sum = summarise(ws);
321
+ const stack = detectStack(root, ws.packages);
322
+ const design = config.design || {};
323
+ const systemPath = design.systemPath || "packages/design-system";
324
+ const tokensPath = design.tokensPath || "packages/design-tokens";
325
+
326
+ const alreadyHasSystem = ws.packages.some((p) => p.path === systemPath);
327
+ const alreadyHasTokens = ws.packages.some((p) => p.path === tokensPath);
328
+
329
+ /**
330
+ * An existing component library does not have to be at the path Gyde would
331
+ * have chosen.
332
+ *
333
+ * The first version checked only `packages/design-system` and declared
334
+ * System C GREENFIELD — a repository with 22 shadcn components, a
335
+ * token dictionary and two apps consuming it in 109 files. Scaffolding on top
336
+ * of that would have been the most destructive thing this tool could do, and
337
+ * it would have been done confidently. So look for the shape, not the name:
338
+ * a UI-bearing package that is not an application.
339
+ */
340
+ const candidateLibraries = ws.packages.filter((p) =>
341
+ p.carriesUI && p.pipelines.every((x) => x.kind !== "next" && x.kind !== "expo") &&
342
+ p.path !== systemPath && p.path !== tokensPath);
343
+
344
+ const mode = alreadyHasSystem || alreadyHasTokens || candidateLibraries.length ? "adoption" : "greenfield";
345
+
346
+ console.log(`mode ${mode}`);
347
+ if (mode === "adoption") {
348
+ console.log(" a component library already exists here. Gyde records current debt");
349
+ console.log(" as allowances and ratchets from there. Amnesty never (CHARTER §4).");
350
+ for (const c of candidateLibraries) {
351
+ console.log(` found: ${c.path}${c.name ? ` (${c.name})` : ""} — not at Gyde's default path,`);
352
+ console.log(" so adopting it in place is a decision, not a default (G-61).");
353
+ }
354
+ } else {
355
+ console.log(" no UI-bearing library package found; the baseline starts and stays at zero.");
356
+ }
357
+ console.log("");
358
+
359
+ // The SAME call init makes. plan cannot describe a file init would not write.
360
+ const files = buildEmission(design, { hasTokens: alreadyHasTokens, hasSystem: alreadyHasSystem, scope: detectScope(ws).scope, rootDir: root });
361
+ console.log(`would emit ${Object.keys(files).length} file(s)`);
362
+ for (const path of Object.keys(files).sort()) {
363
+ const exists = existsSync(join(root, path));
364
+ console.log(` ${exists ? "SKIP" : " "} ${path}${exists ? " already exists — init refuses to overwrite" : ""}`);
365
+ }
366
+ if (alreadyHasTokens) console.log(` ${tokensPath}/ EXISTS — audited, never regenerated over`);
367
+ if (alreadyHasSystem) console.log(` ${systemPath}/ EXISTS — closure enforced on it, not replaced`);
368
+
369
+ console.log("\nwould wire");
370
+ const pipelines = sum.pipelines;
371
+ if (pipelines.length === 0) console.log(" nothing — no build pipeline was found in any UI package");
372
+ for (const p of pipelines) console.log(` ${p}`);
373
+ if (sum.uiWithoutPipeline.length) {
374
+ console.log(` NOT WIRED, and reported rather than skipped: ${sum.uiWithoutPipeline.join(", ")}`);
375
+ }
376
+
377
+ console.log("\nwould leave alone");
378
+ for (const s of sum.skipped) console.log(` ${s.path.padEnd(42)} ${s.because}`);
379
+
380
+ console.log("\nprimitive layer");
381
+ if (stack.primitives.length === 0) {
382
+ console.log(" none present — Gyde would install one (G-58 decides which)");
383
+ } else {
384
+ console.log(` ${stack.primitives.join("\n ")}`);
385
+ console.log(" present already. Whether Gyde adopts this in place or migrates it is G-61,");
386
+ console.log(" and it is an open question rather than something this command should assume.");
387
+ }
388
+
389
+ const boundaries = seedBoundaries({
390
+ primitive: design.primitive || "@base-ui/react",
391
+ systemPath, tokensPath,
392
+ appPaths: sum.carryUI.filter((p) => p !== systemPath && p !== tokensPath),
393
+ });
394
+ const { breaches } = checkBoundaries(root, boundaries);
395
+ const depBreaches = checkDependencyBoundaries(root, seedDependencyBoundaries({
396
+ primitive: design.primitive || "@base-ui/react", systemPath,
397
+ }), { packages: ws.packages });
398
+
399
+ console.log("\nboundaries it would enforce");
400
+ for (const b of boundaries) {
401
+ for (const f of b.forbid) {
402
+ console.log(` ${(b.from || "(everywhere)").padEnd(28)} may not import ${f.pattern.source}`);
403
+ }
404
+ }
405
+ console.log(` → ${breaches.length} import breach(es), ${depBreaches.length} dependency breach(es) today`);
406
+ for (const b of breaches.slice(0, 5)) console.log(` ${b.file}:${b.line} ${b.source}`);
407
+ for (const b of depBreaches.slice(0, 5)) console.log(` ${b.package} declares ${b.dependency}@${b.version}`);
408
+
409
+ const roots = exportedComponents(root, design.systemPath || "packages/design-system");
410
+ const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
411
+ console.log(`\nbaseline it would record`);
412
+ console.log(` ${result.findings.length} finding(s) across ${result.scope.filesWithStyling} file(s) with styling`);
413
+ console.log(` adoption ${result.adoption.percent ?? "n/a"}% (${result.adoption.tokenised} of ${result.adoption.of} declarations)`);
414
+ console.log(" This is a debt with a direction: the list may only ever get shorter.");
415
+
416
+ console.log("\nnothing was written. `gyde design init` executes exactly this emission.");
417
+ return 0;
418
+ }
419
+
420
+ /**
421
+ * Write the plan.
422
+ *
423
+ * Refuses to overwrite anything. Gyde emits once and the file becomes the
424
+ * product's (G-47); a scaffolder that clobbers an existing file has taken
425
+ * ownership of something it does not own, and it would do it silently.
426
+ */
427
+ function cmdInit(root, config) {
428
+ const ws = discover(root);
429
+ const design = config.design || {};
430
+ const systemPath = design.systemPath || "packages/design-system";
431
+ const tokensPath = design.tokensPath || "packages/design-tokens";
432
+
433
+ const files = buildEmission(design, {
434
+ hasTokens: ws.packages.some((p) => p.path === tokensPath),
435
+ hasSystem: ws.packages.some((p) => p.path === systemPath),
436
+ scope: detectScope(ws).scope,
437
+ rootDir: root,
438
+ });
439
+
440
+ const written = [];
441
+ const refused = [];
442
+ for (const [rel, contents] of Object.entries(files)) {
443
+ const full = join(root, rel);
444
+ if (existsSync(full)) { refused.push(rel); continue; }
445
+ mkdirSync(dirname(full), { recursive: true });
446
+ writeFileSync(full, contents);
447
+ written.push(rel);
448
+ }
449
+
450
+ // Provenance for everything actually written. Without it an upgrade cannot
451
+ // tell the product's edit from Gyde's change, and has to choose between
452
+ // clobbering and doing nothing — which is shadcn's unsolved problem (G-61).
453
+ if (written.length) {
454
+ const emitted = Object.fromEntries(
455
+ written.filter((w) => !NOT_UPGRADEABLE.has(w)).map((w) => [w, files[w]]));
456
+ const existing = readManifest(root);
457
+ const manifest = buildManifest(emitted, { version: SCAFFOLD_VERSION });
458
+ if (existing) manifest.files = { ...existing.files, ...manifest.files };
459
+ writeManifest(root, manifest);
460
+ }
461
+
462
+ for (const w of written.sort()) console.log(`wrote ${w}`);
463
+ for (const r of refused.sort()) console.log(`refused ${r} already exists; Gyde never overwrites`);
464
+ if (written.length) console.log(`wrote ${MANIFEST} (provenance: what was emitted, so an upgrade can tell your edits from ours)`);
465
+
466
+ console.log(`\n${written.length} written, ${refused.length} refused.`);
467
+ if (written.length) {
468
+ console.log("\nEvery emitted file is yours now. Replace the placeholder values —");
469
+ console.log("that is the point. What stays checked is the shape: closure, the token");
470
+ console.log("contract, and the boundary rules.");
471
+ }
472
+ // A half-finished scaffold must not look like a finished one.
473
+ return refused.length && !written.length ? 1 : 0;
474
+ }
475
+
476
+ /**
477
+ * Take a new template version into an already-scaffolded repository.
478
+ *
479
+ * The whole mechanism is in upgrade.mjs; this is the surface. `--dry-run`
480
+ * shares the same call, for the same reason `plan` and `init` do.
481
+ */
482
+ function cmdUpgrade(root, config, { dryRun }) {
483
+ const ws = discover(root);
484
+ const design = config.design || {};
485
+ const systemPath = design.systemPath || "packages/design-system";
486
+ const tokensPath = design.tokensPath || "packages/design-tokens";
487
+
488
+ const manifest = readManifest(root);
489
+ if (!manifest) {
490
+ console.log(`no ${MANIFEST} — this repository was not scaffolded by a version of Gyde that`);
491
+ console.log("records provenance. Every emitted path is therefore UNTRACKED, and untracked");
492
+ console.log("means untouched: an upgrade would be a guess about which side owns each file.");
493
+ console.log("\nRun `init` in a scratch copy and diff, or adopt the current files by hand.");
494
+ return 1;
495
+ }
496
+
497
+ // What THIS version would emit for a fresh repo, regardless of what exists —
498
+ // an upgrade compares against the templates, not against a skip decision.
499
+ // The same call init makes, so a fresh scaffold reports no drift.
500
+ const next = buildEmission(design, { hasTokens: false, hasSystem: false, scope: detectScope(ws).scope, rootDir: root });
501
+ delete next["gyde.config.json"];
502
+
503
+ const result = applyUpgrade(root, next, manifest, { version: SCAFFOLD_VERSION, dryRun });
504
+ console.log(formatUpgrade(result, { manifest, version: SCAFFOLD_VERSION }));
505
+
506
+ if (!dryRun) {
507
+ writeManifest(root, result.nextManifest);
508
+ console.log(`\nprovenance updated. An unapplied change keeps its old baseline, so a`);
509
+ console.log("conflict is offered again next time rather than quietly becoming yours.");
510
+ } else {
511
+ console.log("\nnothing was written.");
512
+ }
513
+
514
+ // Conflicts are not a failure of the upgrade — they are its output. But they
515
+ // need somebody, so they exit non-zero rather than scrolling past in CI.
516
+ return result.conflicts.length ? 1 : 0;
517
+ }
518
+
519
+ /**
520
+ * The gate.
521
+ *
522
+ * Reads the committed ledger, rescans, and fails on anything new. With no
523
+ * ledger it records one and says so — recording debt is the sanctioned entry
524
+ * path (CHARTER §4, amnesty never), and it is never a silent pass.
525
+ */
526
+ /**
527
+ * Evidence that the gate actually ran.
528
+ *
529
+ * An exit code is not evidence: a step that was skipped, filtered out by a
530
+ * `paths:` rule, or never reached leaves a green job behind and no way to tell
531
+ * it from a clean pass. That is the failure `verify-tier` documents six times
532
+ * over, and CHARTER §5 exists because of it.
533
+ *
534
+ * WRITTEN ON EVERY EXIT PATH, INCLUDING THE FAILURES. The first version wrote
535
+ * it only after a verdict, so the baseline-recording run — which exits non-zero
536
+ * on purpose — produced none, and the workflow's proof step then reported "the
537
+ * gate did not run" for a gate that had just run and recorded a baseline.
538
+ *
539
+ * That is the same confusion this product is built to prevent, in its own
540
+ * code: a check that cannot distinguish ABSENT from DID SOMETHING ELSE will
541
+ * report the wrong one, confidently. So the evidence carries `why`, and every
542
+ * path writes it.
543
+ */
544
+ function writeEvidence(root, result, { ok, why, clientBoundary = null, enforcement = null }) {
545
+ mkdirSync(join(root, ".gyde"), { recursive: true });
546
+ writeFileSync(join(root, ".gyde", "last-verdict.json"), JSON.stringify({
547
+ "//": "Written by `gyde-design gate`. Proof the gate ran, not just that a job went green.",
548
+ ranAt: process.env.GYDE_RUN_ID || null,
549
+ ok,
550
+ why,
551
+ findings: result ? result.findings.length : null,
552
+ filesWithStyling: result ? result.scope.filesWithStyling : null,
553
+ filesWalked: result ? result.scope.filesWalked : null,
554
+ /**
555
+ * G-74. The RECONCILED figure, not the token layer.
556
+ *
557
+ * action.yml reads `adoption.percent` for the job summary. Leaving the
558
+ * token number here while the CLI printed the weakest layer would have put
559
+ * 75% on a pull request and 20% in a terminal, for the same commit — which
560
+ * is the two-teams-disagree failure this issue exists to end, reintroduced
561
+ * by the fix for it.
562
+ *
563
+ * `percent` may be null when a layer could not be measured. The action
564
+ * already renders an empty adoption as `n/a`, which is the correct reading:
565
+ * not measured is not zero.
566
+ */
567
+ adoption: result ? reconcile({
568
+ token: { ...result.adoption, used: result.adoption.tokenised },
569
+ markup: result.markup?.unknown ? null : result.markup?.adoption,
570
+ }) : null,
571
+ rulesThatMatchedNothing: result ? result.coverage.filter((c) => !c.matched).map((c) => c.rule) : [],
572
+ clientBoundary,
573
+ enforcement,
574
+ }, null, 2) + "\n");
575
+ }
576
+
577
+ function cmdGate(root, config) {
578
+ const design = config.design || {};
579
+ const ledgerPath = join(root, "gyde-allowance.json");
580
+ const roots = exportedComponents(root, design.systemPath || "packages/design-system");
581
+ const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
582
+
583
+ if (!existsSync(ledgerPath)) {
584
+ const led = record(result.findings, {
585
+ recorded: process.env.GYDE_DATE || null,
586
+ rules: result.rulesRun,
587
+ });
588
+ writeFileSync(ledgerPath, JSON.stringify(led, null, 2) + "\n");
589
+ console.log(`no ledger found — recorded ${led.total} existing finding(s) as the baseline.`);
590
+ console.log(`wrote gyde-allowance.json\n`);
591
+ console.log(LEDGER_NOTE);
592
+ console.log("\nThis run recorded a baseline rather than judging one. It is not a pass.");
593
+ writeEvidence(root, result, { ok: false, why: "recorded a baseline; nothing was judged" });
594
+ return 1;
595
+ }
596
+
597
+ let ledger;
598
+ try { ledger = JSON.parse(readFileSync(ledgerPath, "utf8")); }
599
+ catch (e) {
600
+ console.error(`gyde-allowance.json could not be parsed: ${e.message}`);
601
+ writeEvidence(root, result, { ok: false, why: `the ledger could not be parsed: ${e.message}` });
602
+ return 1;
603
+ }
604
+
605
+ const verdict = gate([{ name: "design-system", findings: result.findings, ledger }]);
606
+ console.log(formatGate(verdict));
607
+
608
+ // G-99. No gate and no verdict change — the ratchet was already correct for a
609
+ // migration. This subtracts two numbers the ledger has carried since G-52, so
610
+ // that "not started" and "not visible from here" stop reading the same.
611
+ const migrating = design.migrating || [];
612
+ if (migrating.length) {
613
+ console.log("");
614
+ console.log(formatMigration(migrationProgress(result.findings, ledger, { migrating })));
615
+ }
616
+
617
+ // G-68. Written, not just reported. An adoption that stays in stdout is
618
+ // adopted again on the next run, so the rule reports forever and ratchets
619
+ // never — which is the to-do-list-nobody-reads failure LEDGER_NOTE names.
620
+ if (verdict.adopted.length) {
621
+ writeFileSync(ledgerPath, JSON.stringify(
622
+ adopt(ledger, verdict.adopted, { rules: result.rulesRun }), null, 2) + "\n");
623
+ }
624
+
625
+ // G-67. Deliberately NOT a ledger finding. A crossing is a runtime defect
626
+ // rather than style debt, so allowancing one would record "this renders
627
+ // undefined props, and that is fine for now" as a committed number. It is
628
+ // gated on the product's own template version instead — see BLOCKS_FROM.
629
+ // Discovered once and shared by every check below. Calling `discover` per
630
+ // check re-walks the workspace and, more importantly, lets two checks in the
631
+ // same run disagree about which packages exist.
632
+ const ws = discover(root);
633
+ const cb = checkClientBoundary(root, { packages: ws.packages });
634
+ const cbBlocks = clientBoundaryBlocks(readManifest(root)?.version);
635
+ if (cb.crossings.length || cb.unresolved.length) {
636
+ console.log("");
637
+ console.log(formatClientBoundary(cb, { blocking: cb.crossings.length ? cbBlocks : null }));
638
+ }
639
+
640
+ /**
641
+ * G-101. The mandates, enforced on the same schedule as the client boundary.
642
+ *
643
+ * Each is reported wherever it is measured and blocks only from the template
644
+ * version in the SCHEDULE, which the product takes by running `upgrade`. The
645
+ * schedule is printed whenever anything on it is advisory, so a consumer can
646
+ * see what taking the next version costs before they take it — the question
647
+ * a changelog answers badly and a generated table answers exactly.
648
+ */
649
+ const templateVersion = readManifest(root)?.version ?? null;
650
+
651
+ const tw = detectTailwind(root, {
652
+ packages: ws.packages,
653
+ utilityClasses: result.tailwindClasses,
654
+ cssDirectives: result.tailwindCssDirectives,
655
+ });
656
+ const sx = checkStyleX(root, { packages: ws.packages, renders: summarise(ws).carryUI });
657
+
658
+ const breaches = [
659
+ { rule: "client-boundary", n: cb.crossings.length, what: `${cb.crossings.length} value(s) crossing the "use client" boundary` },
660
+ { rule: "tailwind-present", n: tw.present ? tw.dependencies.length + tw.configs.length : 0, what: "Tailwind is installed" },
661
+ { rule: "tailwind-in-css", n: result.tailwindCssDirectives, what: `${result.tailwindCssDirectives} Tailwind directive(s) in stylesheets` },
662
+ { rule: "styling-layer", n: sx.unknown ? 0 : sx.missing.length + sx.unwired.length + sx.competing.length, what: "the styling layer is not StyleX, or is not wired" },
663
+ ].filter((b) => b.n > 0);
664
+
665
+ const blocking = breaches.filter((b) => blocksAt(b.rule, templateVersion));
666
+
667
+ if (breaches.length) {
668
+ console.log("");
669
+ console.log(formatSchedule(templateVersion));
670
+ }
671
+
672
+ const ok = verdict.ok && blocking.length === 0;
673
+ const why = !verdict.ok ? "new findings since the ledger"
674
+ : blocking.length ? blocking.map((b) => b.what).join("; ")
675
+ : "held";
676
+
677
+ writeEvidence(root, result, {
678
+ ok, why,
679
+ clientBoundary: { crossings: cb.crossings.length, unresolved: cb.unresolved.length, blocking: cbBlocks },
680
+ enforcement: {
681
+ templateVersion,
682
+ blocking: blocking.map((b) => b.rule),
683
+ advisory: breaches.filter((b) => !blocksAt(b.rule, templateVersion)).map((b) => b.rule),
684
+ },
685
+ });
686
+
687
+ return ok ? 0 : 1;
688
+ }
689
+
690
+ function main(argv) {
691
+ const [cmd, pathArg] = argv;
692
+ if (!cmd || ["-h", "--help", "help"].includes(cmd)) {
693
+ console.log("gyde design <scan|plan|init|gate|tokens> [path]");
694
+ console.log(" scan measure a repository as it is");
695
+ console.log(" plan say what scaffolding would be emitted; write nothing");
696
+ console.log(" init write exactly what plan described; never overwrites");
697
+ console.log(" gate fail on anything new since the committed ledger");
698
+ console.log(" upgrade take a new template version; never merges a conflict");
699
+ console.log(" (--dry-run to see the decisions and write nothing)");
700
+ console.log(" tokens print the generated stylesheet for the seed dictionary");
701
+ return 0;
702
+ }
703
+ if (cmd === "tokens") {
704
+ const problems = validate(SEED);
705
+ if (problems.length) { console.error(problems.join("\n")); return 1; }
706
+ console.log(generateCss(SEED));
707
+ return 0;
708
+ }
709
+
710
+ const root = resolve(pathArg || ".");
711
+ if (!existsSync(root)) { console.error(`no such path: ${root}`); return 1; }
712
+ const config = readConfig(root);
713
+
714
+ if (cmd === "scan") return cmdScan(root, config);
715
+ if (cmd === "plan") return cmdPlan(root, config);
716
+ if (cmd === "init") return cmdInit(root, config);
717
+ if (cmd === "gate") return cmdGate(root, config);
718
+ if (cmd === "upgrade") return cmdUpgrade(root, config, { dryRun: argv.includes("--dry-run") });
719
+ console.error(`unknown command: ${cmd}`);
720
+ return 1;
721
+ }
722
+
723
+ process.exitCode = main(process.argv.slice(2));