@weatherboard/gyde-design 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -70,7 +70,7 @@ jobs:
70
70
  runs-on: ubuntu-latest
71
71
  steps:
72
72
  - uses: actions/checkout@v4
73
- - uses: Another-Iteration/gyde@v1
73
+ - uses: Weatherboard-Studio/gyde@v1
74
74
  with:
75
75
  fail-on: new
76
76
  ```
@@ -135,6 +135,8 @@ against all five.
135
135
  | `usage.mjs` | Which component is used where, and what the product keeps reinventing. |
136
136
  | `upgrade.mjs` | Provenance, and the three-way classification that uses it. |
137
137
  | `agentdocs.mjs` | What an agent building the product reads before writing UI. |
138
+ | `ruleindex.mjs` | Every rule as data — id, name, intent, fix shape. Describes; never decides. |
139
+ | `selfgate.mjs` | Emits a scaffold and gates it. Gyde held to its own rules. |
138
140
 
139
141
  ## Measured, on the three repositories it was built from
140
142
 
@@ -168,6 +170,65 @@ into their repository.
168
170
  product's; a scaffolder that clobbers has taken ownership of something it does
169
171
  not own, silently.
170
172
 
173
+ ## The rules, as data
174
+
175
+ A rule id reaches you three times — in a finding, in the `rules` stamp inside
176
+ `gyde-allowance.json`, and in a failing gate — and every time it is a bare slug.
177
+ `optional-prop` says what matched. It does not say what the rule defends or what
178
+ the fix looks like.
179
+
180
+ So the rules are also available as data:
181
+
182
+ ```js
183
+ import { rules, rulesJson } from "@weatherboard/gyde-design/ruleindex.mjs";
184
+
185
+ rules();
186
+ // [
187
+ // {
188
+ // id: "optional-prop",
189
+ // name: "Optional prop",
190
+ // intent: "A prop a caller may omit, leaving the component to make the decision silently.",
191
+ // fix: "Make it required. Where absence is itself a real answer, make it required and NULLABLE …",
192
+ // },
193
+ // …
194
+ // ]
195
+ ```
196
+
197
+ `rules()` returns frozen entries sorted by id; `rulesJson()` is the same thing as
198
+ a JSON string, and `node ruleindex.mjs` prints it. Use it to generate a rules
199
+ page, to give an agent the fix shape alongside the finding, or to diff what a
200
+ version change means.
201
+
202
+ **It describes and it never decides.** Nothing in the scan, the ledger or the
203
+ gate reads it, and nothing may start to: a description that can change a verdict
204
+ is a second copy of the rule kept in prose, and it will disagree with the first
205
+ one quietly. The predicates stay in `rules.mjs`, `props.mjs`, `compound.mjs` and
206
+ `docdrift.mjs`.
207
+
208
+ Completeness is checked in both directions, against the `rules` stamp that a
209
+ real `gate` run wrote rather than a list typed out beside it. A rule implemented
210
+ and not described fails; a rule described and not implemented fails too — an
211
+ index naming a rule nobody runs is the shape that reads as coverage.
212
+
213
+ ## Gyde is held to its own rules
214
+
215
+ `npm run verify` emits a scaffold into a temporary directory, runs
216
+ `gyde-design gate` over it through the CLI, and fails unless the recorded
217
+ baseline is **zero**.
218
+
219
+ The check is on the baseline and not on the exit code, and that distinction is
220
+ the whole of it. A first `gate` on a tree with no ledger RECORDS what it finds
221
+ and exits 1 on purpose; the second run then passes whatever the first recorded.
222
+ So a scaffold shipping ten violations gates green — correctly, for a customer
223
+ adopting Gyde, and uselessly as a check on ourselves.
224
+
225
+ That is not hypothetical. The scaffold emitted on 2026-09-15 carried ten
226
+ `optional-prop` findings across all five seed components, and the consuming
227
+ repository found them, not us. The emitted set now declares every prop required,
228
+ nullable where absence is a real answer, with the ordinary values in
229
+ `defaults.ts` — exported from the package barrel, because an escape hatch behind
230
+ a deep import into `src/` is one the boundary rules forbid you to use.
231
+
171
232
  ## Rules that go quiet
172
233
 
173
234
  A rule that matched nothing anywhere is reported as **suspicious**, not clean.
@@ -246,6 +307,13 @@ only one of them knew it was a compromise.
246
307
  Gyde does not write into your `CLAUDE.md`. It emits a fragment for you to
247
308
  include — that file is the last one a tool should edit unasked.
248
309
 
310
+ For an existing product-owned design system, run `gyde-design guidance .` to
311
+ check whether the generated guide matches its current component exports. Run
312
+ `gyde-design guidance . --write` to refresh the guide, then review the diff.
313
+ This command updates only `.gyde/design-system.md` and refuses to replace a
314
+ handwritten file. It lists component names without guessing their props or
315
+ claiming theme APIs that Gyde has not verified in that product.
316
+
249
317
  ## Not built yet
250
318
 
251
319
  Build-pipeline wiring for a compiled token layer — moot while the emitted tokens
package/agentdocs.mjs CHANGED
@@ -184,13 +184,45 @@ A gap you report becomes a component. A gap you paper over becomes a shadow
184
184
  system, and the measured cost of one of those is two applications reporting 0%
185
185
  adoption while nobody broke a single rule.
186
186
 
187
- ## Runtime theming
187
+ ${verified ? `## Runtime theming
188
188
 
189
189
  If a surface takes colours that are only known at request time — a per-tenant
190
190
  brand — do **not** reach for inline styles. Spread \`themeVars(theme)\` onto an
191
191
  element you already own and set the component's tone to \`themed\`. It returns
192
192
  CSS custom properties only, which is why it is not an escape hatch.
193
193
 
194
+ ## App theme choice
195
+
196
+ The design-system package supplies \`src/theme-bootstrap.js\` and exports
197
+ \`${systemPackage}/theme-choice\`. The product must wire both into its app shell:
198
+
199
+ 1. Put the **contents** of \`theme-bootstrap.js\` in a synchronous inline script
200
+ in the document head, before any stylesheet or app script can paint. Use the
201
+ framework's raw-text import/build facility and the site's CSP nonce or hash.
202
+ A module script, effect, or hydrated provider is too late for first paint.
203
+ 2. Start \`startThemeChoice()\` once in browser app startup, after the document
204
+ root exists. Keep the returned controller across route changes and call
205
+ \`stop()\` at app teardown. Use \`setChoice("light" | "dark" | "system")\`
206
+ for the preference control; subscribe for its selected and resolved state.
207
+ 3. When a router replaces the document root or owns the root's \`data-theme\`,
208
+ call \`reconcile()\` after navigation. The controller also observes root
209
+ replacement and repairs external attribute changes. Keep local route theme
210
+ overrides on inner containers so they do not overwrite the global choice.
211
+
212
+ The storage key is scoped to this design-system package. \`system\` is an
213
+ explicit stored choice that removes \`data-theme\` from the document root and
214
+ lets \`prefers-color-scheme\` decide. Unavailable storage keeps the current tab
215
+ working, without persistence. Changes from another tab and bfcache restoration
216
+ are reconciled by the controller.
217
+
218
+ **Rendered regression evidence belongs in this app's browser CI.** Test both
219
+ system preferences, stored light and dark on reload before first paint, route
220
+ navigation and root replacement, a change from another tab, and back/forward
221
+ cache restoration. Assert the root attribute, computed colour and
222
+ \`color-scheme\`, and capture screenshots in both palettes. Gyde's static gate
223
+ cannot prove when the head script runs or what a browser actually paints.
224
+ ` : ""}
225
+
194
226
  ## The components
195
227
 
196
228
  ${verified
package/catalogue.mjs CHANGED
@@ -49,8 +49,16 @@
49
49
  */
50
50
  export const CATALOGUE_ENTRIES = {
51
51
  Button: {
52
+ defaults: "BUTTON_DEFAULTS",
52
53
  purpose: "The one action a view wants, and every lesser action beside it.",
53
54
  replaces: ["a styled <button>", "an <a> that looks like a button"],
55
+ /**
56
+ * G-127. `onClick` is required-nullable and is NOT in BUTTON_DEFAULTS —
57
+ * deliberately, because "this button does nothing" should be typed out at
58
+ * the call site rather than inherited. The catalogue is a call site, so it
59
+ * types it out.
60
+ */
61
+ handlers: ["onClick"],
54
62
  states: [
55
63
  { label: "accent / filled", props: { tone: "accent", emphasis: "filled", size: "md" }, children: "Save changes" },
56
64
  { label: "neutral / outline", props: { tone: "neutral", emphasis: "outline", size: "md" }, children: "Cancel" },
@@ -60,6 +68,7 @@ export const CATALOGUE_ENTRIES = {
60
68
  ],
61
69
  },
62
70
  Text: {
71
+ defaults: "TEXT_DEFAULTS",
63
72
  purpose: "Every piece of prose, at the role it plays rather than the size it is.",
64
73
  replaces: ["a <p> with a font-size", "a <span> with a colour"],
65
74
  states: [
@@ -71,6 +80,7 @@ export const CATALOGUE_ENTRIES = {
71
80
  ],
72
81
  },
73
82
  Card: {
83
+ defaults: "CARD_DEFAULTS",
74
84
  purpose: "A bordered surface. The only one, which is the point.",
75
85
  replaces: ["a div with a border and a radius", "a panel", "a well"],
76
86
  states: [
@@ -81,6 +91,7 @@ export const CATALOGUE_ENTRIES = {
81
91
  ],
82
92
  },
83
93
  Checkbox: {
94
+ defaults: "CHECKBOX_DEFAULTS",
84
95
  purpose: "A binary choice that is part of a form.",
85
96
  replaces: ["an <input type=checkbox> with a label beside it"],
86
97
  controlled: { checked: false },
@@ -92,6 +103,7 @@ export const CATALOGUE_ENTRIES = {
92
103
  ],
93
104
  },
94
105
  Select: {
106
+ defaults: "SELECT_DEFAULTS",
95
107
  purpose: "One of a known set of options.",
96
108
  replaces: ["a <select>", "a dropdown built from a button and a list"],
97
109
  controlled: { value: "b" },
@@ -138,11 +150,26 @@ const lit = (v) =>
138
150
  */
139
151
  function exampleJsx(name, state, entry) {
140
152
  const props = { ...(entry.controlled ?? {}), ...state.props };
141
- const attrs = Object.entries(props).map(([k, v]) => `${k}=${lit(v)}`);
153
+
154
+ /**
155
+ * G-127. The defaults spread comes FIRST, and it is not decoration.
156
+ *
157
+ * Every prop on every seed component is required now (G-68), so an example
158
+ * listing only the props it is demonstrating does not compile. The catalogue
159
+ * was already in that state before anyone noticed — `onClick` has been
160
+ * required for longer than this comment, and every emitted Button example
161
+ * omitted it — because nothing in this repository type-checks what it emits.
162
+ * It still does not; the gate checks the shape, not the types. So keep this
163
+ * spread, and if you add a required prop to a component, add it to
164
+ * `defaults.ts` or to `handlers` below.
165
+ */
166
+ const attrs = entry.defaults ? [`{...${entry.defaults}}`] : [];
167
+ attrs.push(...Object.entries(props).map(([k, v]) => `${k}=${lit(v)}`));
142
168
 
143
169
  // A controlled component needs a handler. The catalogue holds no state on
144
170
  // purpose — what is being reviewed is the rendering, not the interaction.
145
171
  if (entry.controlled) attrs.push("onChange={() => {}}");
172
+ for (const h of entry.handlers ?? []) attrs.push(`${h}={() => {}}`);
146
173
 
147
174
  const open = `<${name} ${attrs.join(" ")}`;
148
175
  return state.children ? `${open}>${state.children}</${name}>` : `${open} />`;
@@ -185,6 +212,10 @@ function entriesSource(components, systemPackage) {
185
212
  );
186
213
  }
187
214
 
215
+ const defaultsUsed = components
216
+ .map((name) => CATALOGUE_ENTRIES[name].defaults)
217
+ .filter(Boolean);
218
+
188
219
  const blocks = components.map((name) => {
189
220
  const e = CATALOGUE_ENTRIES[name];
190
221
  const states = e.states.map((s) =>
@@ -208,7 +239,7 @@ ${states}
208
239
  * component name to \`undefined\` and pass while covering nothing.
209
240
  */
210
241
  import type { ReactNode } from "react";
211
- import { ${components.join(", ")} } from "${systemPackage}";
242
+ import { ${[...components, ...defaultsUsed].join(", ")} } from "${systemPackage}";
212
243
 
213
244
  export type Example = { label: string; node: ReactNode };
214
245
  export type Entry = { name: string; purpose: string; replaces: readonly string[]; notes: readonly string[]; examples: readonly Example[] };
package/cli.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * matched nothing are reported as suspicious rather than omitted.
22
22
  */
23
23
 
24
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
25
25
  import { execFileSync } from "node:child_process";
26
26
  import { join, resolve, dirname } from "node:path";
27
27
 
@@ -34,7 +34,7 @@ import {
34
34
  } from "./boundaries.mjs";
35
35
  import { emitTokens, emitSystem, emitConfig, SEED_COMPONENTS, SCAFFOLD_VERSION, NOT_UPGRADEABLE } from "./emit.mjs";
36
36
  import { emitCatalogue } from "./catalogue.mjs";
37
- import { emitAgentDocs } from "./agentdocs.mjs";
37
+ import { agentDoc, emitAgentDocs } from "./agentdocs.mjs";
38
38
  import { emitWorkflow } from "./workflow.mjs";
39
39
  import { buildManifest, readManifest, writeManifest, applyUpgrade, formatUpgrade, MANIFEST } from "./upgrade.mjs";
40
40
  import { exportedComponents, renderableComponents, checkWiring, formatWiring, WIRING } from "./wiring.mjs";
@@ -43,11 +43,12 @@ import { checkDocDrift, formatDocDrift } from "./docdrift.mjs";
43
43
  import { reconcile } from "./adoption.mjs";
44
44
  import { detectTailwind, formatTailwind } from "./tailwind.mjs";
45
45
  import { migrationProgress, formatMigration } from "./migration.mjs";
46
- import { checkStyleX, formatStyleX } from "./stylex.mjs";
46
+ import { checkStyleX, formatStyleX, checkClosedStyling, formatClosedStyling } from "./stylex.mjs";
47
47
  import { blocksAt, formatSchedule, SCHEDULE } from "./enforcement.mjs";
48
48
  import { buildUsage, guidance, formatUsage } from "./usage.mjs";
49
- import { record, gate, formatGate, adopt, LEDGER_NOTE } from "./ratchet.mjs";
49
+ import { record, gate, formatGate, adopt, trim, formatTrim, LEDGER_NOTE } from "./ratchet.mjs";
50
50
  import { loadRules } from "./rules.mjs";
51
+ import { validateScopeShape, checkScopeAgreement, formatScope } from "./scope.mjs";
51
52
 
52
53
  /**
53
54
  * The single source of what would be written.
@@ -64,6 +65,24 @@ import { loadRules } from "./rules.mjs";
64
65
  * silently never fires. Returns null when it cannot tell, so the caller falls
65
66
  * back explicitly instead of this function inventing an answer.
66
67
  */
68
+ /**
69
+ * The design system's own component sources, read from disk.
70
+ *
71
+ * Read rather than imported (G-100): a prop removed from a type but still
72
+ * spread onto the element is a hole an import-based check cannot see, and the
73
+ * emitted set makes the same choice for the same reason.
74
+ */
75
+ function systemComponents(root, design) {
76
+ const dir = join(root, design.systemPath || "packages/design-system", "src");
77
+ let names; try { names = readdirSync(dir); } catch { return []; }
78
+ return names
79
+ .filter((n) => /^[A-Z]\w*\.tsx$/.test(n))
80
+ .map((n) => {
81
+ try { return { file: n, text: readFileSync(join(dir, n), "utf8") }; } catch { return null; }
82
+ })
83
+ .filter(Boolean);
84
+ }
85
+
67
86
  function detectDefaultBranch(root) {
68
87
  try {
69
88
  const head = execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
@@ -179,13 +198,30 @@ const PRIMITIVES = [
179
198
  function readConfig(root) {
180
199
  const path = join(root, "gyde.config.json");
181
200
  if (!existsSync(path)) return { present: false, design: {} };
201
+ let json;
182
202
  try {
183
- const json = JSON.parse(readFileSync(path, "utf8"));
184
- return { present: true, design: json.design || {} };
203
+ json = JSON.parse(readFileSync(path, "utf8"));
185
204
  } catch (e) {
186
205
  throw new Error(`gyde.config.json exists but could not be parsed: ${e.message}\n` +
187
206
  "Refusing to continue on a default — a misread config is how a project ends up measured against a scope nobody chose.");
188
207
  }
208
+ const design = json.design || {};
209
+
210
+ /**
211
+ * G-123. The schema half, applied at the one place the config is read.
212
+ *
213
+ * Here rather than in `gate` alone because a malformed scope is wrong on
214
+ * every command: `init` would scaffold a package nobody can import, and the
215
+ * cheapest place to find that out is before anything is written. This needs
216
+ * only the string, so it costs nothing and works on an empty repository.
217
+ */
218
+ const shape = validateScopeShape(design.scope);
219
+ if (!shape.ok) {
220
+ throw new Error(`gyde.config.json: ${shape.error}\n` +
221
+ "A scope names the packages the emitted code imports, so a wrong one produces imports that resolve to nothing.");
222
+ }
223
+
224
+ return { present: true, design };
189
225
  }
190
226
 
191
227
  /** What the repository already uses, read from manifests rather than assumed. */
@@ -276,7 +312,11 @@ function cmdScan(root, config) {
276
312
  // not be removed while classes still resolve through it.
277
313
  // G-100. The positive half of G-98: one styling layer, and it is wired.
278
314
  console.log("");
279
- console.log(formatStyleX(checkStyleX(root, { packages: ws.packages, renders: sum.carryUI })));
315
+ console.log(formatClosedStyling(checkClosedStyling(root, {
316
+ packages: ws.packages,
317
+ renders: sum.carryUI,
318
+ components: systemComponents(root, config.design || {}),
319
+ })));
280
320
 
281
321
  console.log("");
282
322
  console.log(formatTailwind(detectTailwind(root, {
@@ -516,6 +556,42 @@ function cmdUpgrade(root, config, { dryRun }) {
516
556
  return result.conflicts.length ? 1 : 0;
517
557
  }
518
558
 
559
+ /** Refresh the generated component guide from a product's current exports. */
560
+ function cmdGuidance(root, config, { write }) {
561
+ const design = config.design || {};
562
+ const systemPath = design.systemPath || "packages/design-system";
563
+ const components = renderableComponents(root, systemPath);
564
+ if (!components) {
565
+ console.error(`cannot read renderable components from ${systemPath}; guidance was not changed`);
566
+ return 1;
567
+ }
568
+ const systemPackage = design.systemPackage || systemPackageName(root, systemPath);
569
+ if (!systemPackage) {
570
+ console.error(`cannot read a package name from ${systemPath}; guidance was not changed`);
571
+ return 1;
572
+ }
573
+
574
+ const path = join(root, ".gyde", "design-system.md");
575
+ const next = agentDoc({ components, systemPackage, verified: false });
576
+ const current = existsSync(path) ? readFileSync(path, "utf8") : null;
577
+ if (current === next) {
578
+ console.log("component guidance matches the current design-system exports");
579
+ return 0;
580
+ }
581
+ if (!write) {
582
+ console.error("component guidance is stale; run `gyde-design guidance . --write` and review the diff");
583
+ return 1;
584
+ }
585
+ if (current !== null && !current.startsWith("<!-- GENERATED BY GYDE")) {
586
+ console.error("refusing to overwrite a component guide Gyde did not generate");
587
+ return 1;
588
+ }
589
+ mkdirSync(dirname(path), { recursive: true });
590
+ writeFileSync(path, next);
591
+ console.log("wrote .gyde/design-system.md from the current design-system exports; review the diff");
592
+ return 0;
593
+ }
594
+
519
595
  /**
520
596
  * The gate.
521
597
  *
@@ -574,12 +650,57 @@ function writeEvidence(root, result, { ok, why, clientBoundary = null, enforceme
574
650
  }, null, 2) + "\n");
575
651
  }
576
652
 
577
- function cmdGate(root, config) {
653
+ /** The design system package's own `name`, read from disk. Null when there is no package there. */
654
+ function systemPackageName(root, systemPath) {
655
+ try {
656
+ return JSON.parse(readFileSync(join(root, systemPath, "package.json"), "utf8")).name ?? null;
657
+ } catch { return null; }
658
+ }
659
+
660
+ function cmdGate(root, config, { recordNewRules = false, trimLedger = false } = {}) {
578
661
  const design = config.design || {};
579
662
  const ledgerPath = join(root, "gyde-allowance.json");
580
- const roots = exportedComponents(root, design.systemPath || "packages/design-system");
663
+ const systemPath = design.systemPath || "packages/design-system";
664
+
665
+ /**
666
+ * G-123. Before anything is measured or recorded.
667
+ *
668
+ * First because a disagreeing scope invalidates the run rather than adding a
669
+ * finding to it: the emitted code imports packages that are not there, and
670
+ * recording a baseline underneath that would commit a number taken against a
671
+ * repository Gyde has misread. It is also the cheapest failure available,
672
+ * which is the order `verify` uses and this command should too.
673
+ *
674
+ * The workspace is discovered once here and shared with the checks below.
675
+ * Two `discover` calls in one run can disagree about which packages exist,
676
+ * and this file has done that before.
677
+ */
678
+ const ws = discover(root);
679
+ const scopeCheck = checkScopeAgreement({
680
+ declared: design.scope,
681
+ packages: ws.packages,
682
+ systemPath,
683
+ systemPackageName: systemPackageName(root, systemPath),
684
+ });
685
+ if (!scopeCheck.ok) {
686
+ console.error(formatScope(scopeCheck));
687
+ writeEvidence(root, null, { ok: false, why: `design.scope does not agree with the repository: ${scopeCheck.why}` });
688
+ return 1;
689
+ }
690
+
691
+ const roots = exportedComponents(root, systemPath);
581
692
  const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
582
693
 
694
+ // G-113. `--trim` on a repository with no ledger must NOT fall through to the
695
+ // baseline-recording path below. That path writes a ledger from scratch, which
696
+ // is the largest possible add, and reaching it by typing the word "trim" is
697
+ // exactly the confusion this command exists to remove.
698
+ if (trimLedger && !existsSync(ledgerPath)) {
699
+ console.error("no gyde-allowance.json here — there is nothing to trim.");
700
+ console.error("`gate` records a baseline when none exists; `gate --trim` never creates one.");
701
+ return 1;
702
+ }
703
+
583
704
  if (!existsSync(ledgerPath)) {
584
705
  const led = record(result.findings, {
585
706
  recorded: process.env.GYDE_DATE || null,
@@ -602,9 +723,42 @@ function cmdGate(root, config) {
602
723
  return 1;
603
724
  }
604
725
 
726
+ /**
727
+ * G-113. The only command that takes a shortening, and it does nothing else.
728
+ *
729
+ * It runs before the gate's verdict and returns, because it is not a
730
+ * judgement. Both other writers end with "This run recorded a baseline rather
731
+ * than judging one. It is not a pass." and return non-zero; this keeps that,
732
+ * for the same reason. `gate --trim` wired into CI would otherwise be a gate
733
+ * that always passes, which is the failure mode this whole file is arranged
734
+ * against. Judging is what plain `gate` is for, and it is one command away.
735
+ */
736
+ if (trimLedger) {
737
+ const t = trim(ledger, result.findings);
738
+ console.log(formatTrim(t));
739
+ if (!t.ok) {
740
+ writeEvidence(root, result, { ok: false, why: `refused to trim: ${t.refusals.length} entry(ies) would be added or raised` });
741
+ return 1;
742
+ }
743
+ writeFileSync(ledgerPath, JSON.stringify(t.ledger, null, 2) + "\n");
744
+ console.log("");
745
+ console.log("wrote gyde-allowance.json — review the diff before committing it.");
746
+ console.log("");
747
+ console.log("This run rewrote the ledger rather than judging one. It is not a pass.");
748
+ console.log("Run `gyde design gate .` to judge the tree.");
749
+ writeEvidence(root, result, { ok: false, why: `trimmed the ledger ${t.before} → ${t.after}; nothing was judged` });
750
+ return 1;
751
+ }
752
+
605
753
  const verdict = gate([{ name: "design-system", findings: result.findings, ledger }]);
606
754
  console.log(formatGate(verdict));
607
755
 
756
+ // G-123. Printed on the passing path too. A check that is only visible when
757
+ // it fails is one nobody can tell ran, which is the same defect as the key
758
+ // that guarded nothing.
759
+ console.log("");
760
+ console.log(formatScope(scopeCheck));
761
+
608
762
  // G-99. No gate and no verdict change — the ratchet was already correct for a
609
763
  // migration. This subtracts two numbers the ledger has carried since G-52, so
610
764
  // that "not started" and "not visible from here" stop reading the same.
@@ -614,22 +768,43 @@ function cmdGate(root, config) {
614
768
  console.log(formatMigration(migrationProgress(result.findings, ledger, { migrating })));
615
769
  }
616
770
 
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");
771
+ /**
772
+ * G-108. `gate` does not write the ledger. It reads one and judges against it.
773
+ *
774
+ * G-68 wrote it whenever a rule was adopted, which made the gate's own
775
+ * execution a way for a ledger to grow: running it on a laptop against a
776
+ * consumer whose baseline predated `optional-prop` would have added 80
777
+ * entries, unasked. "A ledger may only ever get shorter" cannot survive a
778
+ * command that lengthens one as a side effect of measuring.
779
+ *
780
+ * The write now happens only when somebody typed the words, and it still
781
+ * exits non-zero — it recorded a baseline for these rules, it did not judge
782
+ * one, and that distinction is the same one the no-ledger path above makes.
783
+ */
784
+ if (recordNewRules && verdict.newRules.length) {
785
+ const before = ledger.total ?? 0;
786
+ const entries = verdict.failures.filter((f) => f.kind === "unrecorded-rule");
787
+ const next = adopt(ledger, entries, { rules: result.rulesRun });
788
+ writeFileSync(ledgerPath, JSON.stringify(next, null, 2) + "\n");
789
+ console.log("");
790
+ console.log(`recorded ${entries.length} entry(ies) for ${verdict.newRules.length} rule(s) not previously named: ${verdict.newRules.join(", ")}`);
791
+ console.log(`gyde-allowance.json: ${before} → ${next.total} findings. This ledger got LONGER, which is the`);
792
+ console.log("one case where that is allowed — a new rule finds drift that was always there.");
793
+ console.log("Review the diff before committing it; it is a decision, not a formality.");
794
+ console.log("");
795
+ console.log("This run recorded a baseline rather than judging one. It is not a pass.");
796
+ writeEvidence(root, result, { ok: false, why: `recorded a baseline for ${verdict.newRules.length} new rule(s); nothing was judged` });
797
+ return 1;
623
798
  }
624
799
 
625
800
  // G-67. Deliberately NOT a ledger finding. A crossing is a runtime defect
626
801
  // rather than style debt, so allowancing one would record "this renders
627
802
  // undefined props, and that is fine for now" as a committed number. It is
628
803
  // 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);
804
+ // The workspace was discovered once at the top of this command and is shared
805
+ // by every check below. Calling `discover` per check re-walks the workspace
806
+ // and, more importantly, lets two checks in the same run disagree about which
807
+ // packages exist.
633
808
  const cb = checkClientBoundary(root, { packages: ws.packages });
634
809
  const cbBlocks = clientBoundaryBlocks(readManifest(root)?.version);
635
810
  if (cb.crossings.length || cb.unresolved.length) {
@@ -653,13 +828,17 @@ function cmdGate(root, config) {
653
828
  utilityClasses: result.tailwindClasses,
654
829
  cssDirectives: result.tailwindCssDirectives,
655
830
  });
656
- const sx = checkStyleX(root, { packages: ws.packages, renders: summarise(ws).carryUI });
831
+ const sx = checkClosedStyling(root, {
832
+ packages: ws.packages,
833
+ renders: summarise(ws).carryUI,
834
+ components: systemComponents(root, design),
835
+ });
657
836
 
658
837
  const breaches = [
659
838
  { rule: "client-boundary", n: cb.crossings.length, what: `${cb.crossings.length} value(s) crossing the "use client" boundary` },
660
839
  { rule: "tailwind-present", n: tw.present ? tw.dependencies.length + tw.configs.length : 0, what: "Tailwind is installed" },
661
840
  { 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" },
841
+ { rule: "styling-layer", n: sx.unknown || sx.ok ? 0 : sx.hatches.length + sx.competing.length, what: "the styling layer is open — a call site can restyle the component set" },
663
842
  ].filter((b) => b.n > 0);
664
843
 
665
844
  const blocking = breaches.filter((b) => blocksAt(b.rule, templateVersion));
@@ -688,15 +867,27 @@ function cmdGate(root, config) {
688
867
  }
689
868
 
690
869
  function main(argv) {
691
- const [cmd, pathArg] = argv;
870
+ const [cmd] = argv;
871
+ // G-108. The path is the first non-flag argument, not argv[1]. `gate
872
+ // --record-new-rules` would otherwise resolve "--record-new-rules" as a
873
+ // directory and die with "no such path" — a usage error reported as a missing
874
+ // repository, which is the least helpful sentence available.
875
+ const pathArg = argv.slice(1).find((a) => !a.startsWith("-"));
692
876
  if (!cmd || ["-h", "--help", "help"].includes(cmd)) {
693
- console.log("gyde design <scan|plan|init|gate|tokens> [path]");
877
+ console.log("gyde design <scan|plan|init|gate|upgrade|guidance|tokens> [path]");
694
878
  console.log(" scan measure a repository as it is");
695
879
  console.log(" plan say what scaffolding would be emitted; write nothing");
696
880
  console.log(" init write exactly what plan described; never overwrites");
697
881
  console.log(" gate fail on anything new since the committed ledger");
882
+ console.log(" (--record-new-rules to record a rule the ledger does not name");
883
+ console.log(" as existing debt; writes the ledger and still exits non-zero)");
884
+ console.log(" (--trim to remove entries the scan no longer produces and lower");
885
+ console.log(" counts that have fallen; never adds, never raises, refuses if it");
886
+ console.log(" would; writes the ledger and still exits non-zero)");
698
887
  console.log(" upgrade take a new template version; never merges a conflict");
699
888
  console.log(" (--dry-run to see the decisions and write nothing)");
889
+ console.log(" guidance check the generated component guide against current exports");
890
+ console.log(" (--write to refresh only that guide; never changes provenance)");
700
891
  console.log(" tokens print the generated stylesheet for the seed dictionary");
701
892
  return 0;
702
893
  }
@@ -714,8 +905,12 @@ function main(argv) {
714
905
  if (cmd === "scan") return cmdScan(root, config);
715
906
  if (cmd === "plan") return cmdPlan(root, config);
716
907
  if (cmd === "init") return cmdInit(root, config);
717
- if (cmd === "gate") return cmdGate(root, config);
908
+ if (cmd === "gate") return cmdGate(root, config, {
909
+ recordNewRules: argv.includes("--record-new-rules"),
910
+ trimLedger: argv.includes("--trim"),
911
+ });
718
912
  if (cmd === "upgrade") return cmdUpgrade(root, config, { dryRun: argv.includes("--dry-run") });
913
+ if (cmd === "guidance") return cmdGuidance(root, config, { write: argv.includes("--write") });
719
914
  console.error(`unknown command: ${cmd}`);
720
915
  return 1;
721
916
  }