@weatherboard/gyde-design 0.4.0 → 0.4.2

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/emit.mjs CHANGED
@@ -36,6 +36,7 @@
36
36
  */
37
37
 
38
38
  import { generateCss, SEED, varName, CSS_HEADER } from "./tokens.mjs";
39
+ import { emitThemeChoice } from "./theme-choice.mjs";
39
40
 
40
41
  /**
41
42
  * The version of what this emits.
@@ -60,7 +61,7 @@ import { generateCss, SEED, varName, CSS_HEADER } from "./tokens.mjs";
60
61
  * `emit.test.mjs` enforces it: a fresh scaffold must pass the gate Gyde would
61
62
  * run against it.
62
63
  */
63
- export const SCAFFOLD_VERSION = "0.3.0";
64
+ export const SCAFFOLD_VERSION = "0.3.2";
64
65
 
65
66
  const GENERATED = (what) => `/* GENERATED BY GYDE — then yours.
66
67
  *
@@ -199,8 +200,8 @@ export function generateCss(dict) {
199
200
  " }", "}", "",
200
201
  "/* An explicit choice, on any element — which is what makes both themes",
201
202
  " renderable on one page, and what a portalled popup looks up. */",
202
- '[data-theme="dark"] {', decls("dark"), "}", "",
203
- '[data-theme="light"] {', decls("light"), "}", "",
203
+ '[data-theme="dark"] {', ' color-scheme: dark;', decls("dark"), "}", "",
204
+ '[data-theme="light"] {', ' color-scheme: light;', decls("light"), "}", "",
204
205
  ].join("\\n");
205
206
  }
206
207
  `;
@@ -385,11 +386,25 @@ const defaultsModule = `${GENERATED("absence of a \"use client\" directive in th
385
386
  * both graphs and its values survive on the server.
386
387
  */
387
388
 
388
- export const BUTTON_DEFAULTS = { tone: "neutral", emphasis: "filled", size: "md" } as const;
389
- export const TEXT_DEFAULTS = { role: "body" } as const;
390
- export const CARD_DEFAULTS = { tone: "default", inset: "md" } as const;
391
- export const CHECKBOX_DEFAULTS = { disabled: false, indeterminate: false } as const;
392
- export const SELECT_DEFAULTS = { disabled: false } as const;
389
+ /**
390
+ * G-127. EVERY prop a component declares appears here, except its children.
391
+ *
392
+ * The optional-props ban (G-68) removed the other way of saying "this one has
393
+ * an obvious answer". A destructure default — \`type = "button"\` — cannot say it
394
+ * any more, because the declaration it excused is gone. So the obvious answer
395
+ * moves here, where a caller opts into it explicitly by spreading, and the
396
+ * component itself makes no decision on anybody's behalf.
397
+ *
398
+ * The consequence to keep true: \`<Button {...BUTTON_DEFAULTS} onClick={...}>text</Button>\`
399
+ * must type-check. If a prop is added to a component and not added here, that
400
+ * sentence stops being true, and the only thing that will tell you is the
401
+ * scaffold's own gate.
402
+ */
403
+ export const BUTTON_DEFAULTS = { tone: "neutral", emphasis: "filled", size: "md", type: "button", disabled: false, describedBy: null } as const;
404
+ export const TEXT_DEFAULTS = { role: "body", as: null } as const;
405
+ export const CARD_DEFAULTS = { tone: "default", inset: "md", as: "div" } as const;
406
+ export const CHECKBOX_DEFAULTS = { disabled: false, indeterminate: false, name: null } as const;
407
+ export const SELECT_DEFAULTS = { disabled: false, name: null } as const;
393
408
  `;
394
409
 
395
410
  /* -------------------------------------------------------------- components */
@@ -403,6 +418,22 @@ export const SELECT_DEFAULTS = { disabled: false } as const;
403
418
  */
404
419
  export const SEED_COMPONENTS = ["Button", "Text", "Card", "Checkbox", "Select"];
405
420
 
421
+ /**
422
+ * G-127. Which defaults constant belongs to which component.
423
+ *
424
+ * Derived rather than hard-coded looks tidier — `Button` -> `BUTTON_DEFAULTS`
425
+ * is a `toUpperCase()` away — and it would emit a barrel export for a constant
426
+ * `defaults.ts` does not declare the moment somebody emits a set that is not
427
+ * the seed five. A map is the thing that can be wrong out loud.
428
+ */
429
+ export const DEFAULTS_EXPORTS = {
430
+ Button: "BUTTON_DEFAULTS",
431
+ Text: "TEXT_DEFAULTS",
432
+ Card: "CARD_DEFAULTS",
433
+ Checkbox: "CHECKBOX_DEFAULTS",
434
+ Select: "SELECT_DEFAULTS",
435
+ };
436
+
406
437
  function componentSources(primitive) {
407
438
  return {
408
439
  "Button.tsx": `"use client";
@@ -434,13 +465,22 @@ export type ButtonProps = {
434
465
  * deliberate.
435
466
  */
436
467
  onClick: (() => void) | null;
437
- type?: "button" | "submit";
438
- disabled?: boolean;
439
- /** A disabled control with no explanation is the most common accessibility defect there is. */
440
- describedBy?: string;
468
+ /**
469
+ * Required, with no default in the destructure. \`type = "button"\` looked like
470
+ * the same thing and is not: it put the answer where no caller could see it.
471
+ * BUTTON_DEFAULTS carries it instead.
472
+ */
473
+ type: "button" | "submit";
474
+ disabled: boolean;
475
+ /**
476
+ * A disabled control with no explanation is the most common accessibility
477
+ * defect there is. Required so the absence is a decision, nullable because
478
+ * "there is nothing to point at" is a real answer to it.
479
+ */
480
+ describedBy: string | null;
441
481
  };
442
482
 
443
- export function Button({ children, tone, emphasis, size, onClick, type = "button", disabled = false, describedBy }: ButtonProps) {
483
+ export function Button({ children, tone, emphasis, size, onClick, type, disabled, describedBy }: ButtonProps) {
444
484
  return (
445
485
  <Base
446
486
  // focusableWhenDisabled: a disabled button that leaves the tab order is a
@@ -448,7 +488,7 @@ export function Button({ children, tone, emphasis, size, onClick, type = "button
448
488
  focusableWhenDisabled
449
489
  type={type}
450
490
  disabled={disabled}
451
- aria-describedby={describedBy}
491
+ aria-describedby={describedBy ?? undefined}
452
492
  onClick={onClick ?? undefined}
453
493
  data-ds="button"
454
494
  data-tone={tone}
@@ -478,7 +518,11 @@ export type TextRole = "micro" | "small" | "body" | "lead" | "title" | "display"
478
518
  export type TextProps = {
479
519
  children: ReactNode;
480
520
  role: TextRole;
481
- as?: "p" | "span" | "code";
521
+ /**
522
+ * Nullable rather than optional: null means "take the element this role
523
+ * implies", which is a choice, and DEFAULT_ELEMENT below is where it is read.
524
+ */
525
+ as: "p" | "span" | "code" | null;
482
526
  };
483
527
 
484
528
  const DEFAULT_ELEMENT: Record<TextRole, "p" | "span"> = {
@@ -530,10 +574,15 @@ export type CardProps = {
530
574
  children: ReactNode;
531
575
  tone: CardTone;
532
576
  inset: CardInset;
533
- as?: "div" | "section" | "article" | "li";
577
+ /**
578
+ * Not nullable. Unlike Text there is no role to derive an element from, so
579
+ * "no answer" would have to mean \`div\` silently — which is the omission the
580
+ * ban is about. CARD_DEFAULTS says \`div\` out loud.
581
+ */
582
+ as: "div" | "section" | "article" | "li";
534
583
  };
535
584
 
536
- export function Card({ children, tone, inset, as: Tag = "div" }: CardProps) {
585
+ export function Card({ children, tone, inset, as: Tag }: CardProps) {
537
586
  return <Tag data-ds="card" data-tone={tone} data-inset={inset}>{children}</Tag>;
538
587
  }
539
588
  `,
@@ -560,17 +609,18 @@ export type CheckboxProps = {
560
609
  onChange: (checked: boolean) => void;
561
610
  label: string;
562
611
  id: string;
563
- name?: string;
564
- disabled?: boolean;
565
- indeterminate?: boolean;
612
+ /** Nullable: a checkbox outside a form has no name, and that is an answer. */
613
+ name: string | null;
614
+ disabled: boolean;
615
+ indeterminate: boolean;
566
616
  };
567
617
 
568
- export function Checkbox({ checked, onChange, label, id, name, disabled = false, indeterminate = false }: CheckboxProps) {
618
+ export function Checkbox({ checked, onChange, label, id, name, disabled, indeterminate }: CheckboxProps) {
569
619
  return (
570
620
  <span data-ds="checkbox">
571
621
  <Base.Root
572
622
  id={id}
573
- name={name}
623
+ name={name ?? undefined}
574
624
  checked={checked}
575
625
  indeterminate={indeterminate}
576
626
  disabled={disabled}
@@ -610,18 +660,19 @@ export type SelectProps = {
610
660
  onChange: (value: string) => void;
611
661
  options: readonly SelectOption[];
612
662
  label: string;
613
- disabled?: boolean;
614
- name?: string;
663
+ disabled: boolean;
664
+ /** Nullable: a select outside a form has no name, and that is an answer. */
665
+ name: string | null;
615
666
  };
616
667
 
617
- export function Select({ value, onChange, options, label, disabled = false, name }: SelectProps) {
668
+ export function Select({ value, onChange, options, label, disabled, name }: SelectProps) {
618
669
  const trigger = useRef<HTMLDivElement>(null);
619
670
 
620
671
  return (
621
672
  <div ref={trigger} data-ds="select">
622
673
  <Base.Root
623
674
  value={value}
624
- name={name}
675
+ name={name ?? undefined}
625
676
  disabled={disabled}
626
677
  // The primitive can clear to null; this control has no clear affordance,
627
678
  // so narrowing here keeps the callback honest about what can happen.
@@ -770,6 +821,7 @@ function stylesheet(dict) {
770
821
  export function emitSystem({ dict = SEED, primitive = "@base-ui/react", path = "packages/design-system", components = SEED_COMPONENTS, tokensPackage = "@scaffold/design-tokens", systemPackage = "@scaffold/design-system" } = {}) {
771
822
  const sources = componentSources(primitive);
772
823
  const out = {};
824
+ Object.assign(out, emitThemeChoice({ path, storageKey: `${systemPackage}:theme` }));
773
825
 
774
826
  for (const name of components) {
775
827
  const src = sources[`${name}.tsx`];
@@ -789,7 +841,7 @@ export function emitSystem({ dict = SEED, primitive = "@base-ui/react", path = "
789
841
  type: "module",
790
842
  main: "./src/index.ts",
791
843
  types: "./src/index.ts",
792
- exports: { ".": "./src/index.ts", "./styles.css": "./src/styles.css" },
844
+ exports: { ".": "./src/index.ts", "./styles.css": "./src/styles.css", "./theme-choice": "./src/theme-choice.js", "./theme-bootstrap.js": "./src/theme-bootstrap.js" },
793
845
  // The primitive is a real dependency of THIS package and of nothing else —
794
846
  // the boundary rule forbids an app from even declaring it.
795
847
  dependencies: { [primitive]: "^1.0.0", [tokensPackage]: "workspace:*" },
@@ -800,6 +852,16 @@ export function emitSystem({ dict = SEED, primitive = "@base-ui/react", path = "
800
852
  out[`${path}/src/index.ts`] =
801
853
  `${GENERATED("completeness of this barrel — set.test.ts fails if a component is missing")}
802
854
  ${components.map((n) => `export { ${n} } from "./${n}";\nexport type { ${n}Props } from "./${n}";`).join("\n")}
855
+ /**
856
+ * G-127. The defaults are part of the public API, not an internal detail.
857
+ *
858
+ * Every prop is required (G-68), so \`{...BUTTON_DEFAULTS}\` is how a caller says
859
+ * "the ordinary answers, please" without restating six of them. Leaving these
860
+ * out of the barrel left the only ergonomic path behind a deep import into
861
+ * \`src/\`, which the boundary rules forbid — so the ban had no usable escape
862
+ * and every call site paid for it.
863
+ */
864
+ export { ${components.filter((n) => DEFAULTS_EXPORTS[n]).map((n) => DEFAULTS_EXPORTS[n]).join(", ")} } from "./defaults";
803
865
  export { themeVars, themeVar } from "./theme";
804
866
  export type { SurfaceTheme, ThemeSlot } from "./theme";
805
867
  `;
package/index.mjs CHANGED
@@ -21,7 +21,7 @@ export { SEED, themed, validate, generateCss, generateConstants, generateDtcg, g
21
21
  export { seedBoundaries, seedDependencyBoundaries, validateBoundaries, proveBoundaries, proveDependencyBoundaries, loadBoundaries, loadDependencyBoundaries, isImportBreach, checkBoundaries, checkDependencyBoundaries, findVersionDrift } from "./boundaries.mjs";
22
22
  export { record, compare, gate, formatGate, tally, LEDGER_NOTE } from "./ratchet.mjs";
23
23
 
24
- export { emitTokens, emitSystem, emitConfig, SEED_COMPONENTS, SCAFFOLD_VERSION, NOT_UPGRADEABLE } from "./emit.mjs";
24
+ export { emitTokens, emitSystem, emitConfig, SEED_COMPONENTS, DEFAULTS_EXPORTS, SCAFFOLD_VERSION, NOT_UPGRADEABLE } from "./emit.mjs";
25
25
  export { emitCatalogue, CATALOGUE_ENTRIES } from "./catalogue.mjs";
26
26
  export { emitAgentDocs, agentDoc, agentInstructionsFragment } from "./agentdocs.mjs";
27
27
 
@@ -37,4 +37,5 @@ export { reconcile, formatAdoption, LAYERS as ADOPTION_LAYERS } from "./adoption
37
37
  export { classifyMarkup, markupAdoption, formatMarkup, KIND as MARKUP_KIND } from "./markup.mjs";
38
38
  export { orphanedParts, formatOrphanedParts, RULE as COMPOUND_RULE } from "./compound.mjs";
39
39
  export { checkDocDrift, importedFrom, formatDocDrift, namedInProse, isContractDoc, isFixtureDeclaration, stalePaths, rootDirectories, formatStalePaths, RULE as DOC_DRIFT_RULE, PROSE_RULE, PATH_RULE } from "./docdrift.mjs";
40
+ export { rules, rulesJson } from "./ruleindex.mjs";
40
41
  export { isClientModule, parseImports, parseReexports, valueUses, checkClientBoundary, formatClientBoundary } from "./clientboundary.mjs";
package/markup.mjs CHANGED
@@ -117,7 +117,7 @@ export function classifyMarkup(text, { systemNames = null } = {}) {
117
117
  counts,
118
118
  // The population where "should this have been a component?" is a real
119
119
  // question. Never the total element count.
120
- adoption: { used: counts.system, of, percent: of ? Math.round((counts.system / of) * 100) : null },
120
+ adoption: { used: counts.system, of, percent: of ? Math.floor((counts.system / of) * 100) : null },
121
121
  bespokeElements,
122
122
  };
123
123
  }
@@ -149,7 +149,7 @@ export function markupAdoption(files, { systemNames = null } = {}) {
149
149
  return {
150
150
  unknown: false,
151
151
  counts: totals,
152
- adoption: { used: totals.system, of, percent: of ? Math.round((totals.system / of) * 100) : null },
152
+ adoption: { used: totals.system, of, percent: of ? Math.floor((totals.system / of) * 100) : null },
153
153
  perFile: perFile.sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101)),
154
154
  filesWithMarkup: perFile.length,
155
155
  };
package/normalise.mjs CHANGED
@@ -342,6 +342,111 @@ export function normaliseUtilities(classString, { spacingStep = TAILWIND_SPACING
342
342
  return out;
343
343
  }
344
344
 
345
+ /**
346
+ * Does this quoted run read as a class list, or as an English sentence?
347
+ *
348
+ * G-110. The unattached-string matcher below exists for `cva("… rounded-md
349
+ * px-4 …")`, which carries no `className`. Its gate was a word test — the run
350
+ * had to mention `shadow`, `border`, `flex` and so on — and the word `shadow`
351
+ * is *also* a complete Tailwind utility. So
352
+ *
353
+ * includes(f, "gl.shadowMap.enabled", "parks renderer shadow map must be enabled")
354
+ *
355
+ * parsed as a class list containing one class, and a Node assertion harness
356
+ * with no markup in it at all scored four `arbitrary-shadow` violations. A
357
+ * false positive of that shape cannot be fixed — nobody can tokenise an English
358
+ * sentence — so the only available response is a ledger entry, and the ledger is
359
+ * supposed to only ever get shorter.
360
+ *
361
+ * The distinguishing property is not vocabulary, it is SHAPE. Utility classes
362
+ * are overwhelmingly compound — `rounded-md`, `px-4`, `hover:bg-primary`,
363
+ * `w-[7px]` — while English words are bare. So: at least two compound tokens,
364
+ * and compound tokens in the majority.
365
+ *
366
+ * A ratio rather than "every token", deliberately. Real class lists are full of
367
+ * bare utilities (`flex`, `grid`, `border`, `truncate`, `italic`) and that set
368
+ * is open-ended; requiring all of them to be recognised is the widening that
369
+ * ends in a rule which matches nothing. This test is checked in both directions
370
+ * by fixtures: prose must not parse, and `cva("inline-flex … shadow-sm")` must.
371
+ */
372
+ const COMPOUND_CLASS = /^-?[a-z0-9]+[a-z0-9./[\]-]*(?:[-:/[][^\s]*)$/i;
373
+
374
+ export function looksLikeClassList(run) {
375
+ const tokens = String(run).trim().split(/\s+/).filter(Boolean);
376
+ if (tokens.length < 3) return false;
377
+ const compound = tokens.filter((t) => /[-:/[]/.test(t) && COMPOUND_CLASS.test(t)).length;
378
+ return compound >= 2 && compound * 2 > tokens.length;
379
+ }
380
+
381
+ /**
382
+ * Is this `prop: value` inside a string literal a declaration, or a sentence
383
+ * with a colon in it?
384
+ *
385
+ * G-110, the same defect reached by the other matcher. The style-object pass
386
+ * reads `prop: value` out of raw line text, so
387
+ *
388
+ * assert(ok, "expected background: #ffffff to be applied")
389
+ *
390
+ * produced an `untokenised-colour` finding against a message.
391
+ *
392
+ * Refusing string literals wholesale would be wrong in the expensive direction,
393
+ * and it was measured before it was reasoned about: a consumer repository builds
394
+ * markup as strings —
395
+ *
396
+ * html += '<div style="display:flex;gap:8px;margin-bottom:20px">'
397
+ *
398
+ * — and blanket-skipping literals silently dropped **seven real findings** in
399
+ * four files. A styled-components template is the same shape. So the test is
400
+ * per-declaration and structural: the `;{}`-delimited SEGMENT the match sits in
401
+ * must be exactly `ident: value`, with nothing either side of it. `gap:8px` is;
402
+ * `expected background: #ffffff to be applied` is not, because of the words in
403
+ * front of the colon.
404
+ *
405
+ * The residual is narrow and worth naming: a sentence that BEGINS with a real
406
+ * CSS property name and a plausible value — `"color: red is expected"` — still
407
+ * parses. It takes a segment boundary to hide behind, and no such string exists
408
+ * in either repository measured.
409
+ */
410
+ const DECL_SEGMENT = /^\s*-{0,2}[A-Za-z][\w$-]*\s*:\s*[^:;{}]+$/;
411
+ // Nested quotes are a boundary too: the whole reason these literals contain
412
+ // declarations is `style="…"` inside built markup, and without it the first
413
+ // declaration in every inline style block keeps the `<div style="` prefix and
414
+ // fails the test. That was measured — it cost `background:#4a3000` on the first
415
+ // attempt at this fix.
416
+ const SEGMENT_BOUNDARY = /[;{}"'`]/;
417
+
418
+ /** The `;{}"'`-delimited segment of `text` containing offset `at`. */
419
+ export function segmentAround(text, at) {
420
+ let start = at;
421
+ while (start > 0 && !SEGMENT_BOUNDARY.test(text[start - 1])) start--;
422
+ let end = at;
423
+ while (end < text.length && !SEGMENT_BOUNDARY.test(text[end])) end++;
424
+ return text.slice(start, end);
425
+ }
426
+
427
+ /** Single-line string literal spans on a line, as [start, end) over the QUOTED text. */
428
+ function stringSpans(line) {
429
+ const spans = [];
430
+ let i = 0;
431
+ while (i < line.length) {
432
+ const ch = line[i];
433
+ if (ch === '"' || ch === "'" || ch === "`") {
434
+ let j = i + 1;
435
+ while (j < line.length) {
436
+ if (line[j] === "\\") { j += 2; continue; }
437
+ if (line[j] === ch) break;
438
+ j++;
439
+ }
440
+ if (j >= line.length) return spans; // unterminated: a multi-line template
441
+ spans.push({ start: i, end: j + 1, content: line.slice(i + 1, j) });
442
+ i = j + 1;
443
+ continue;
444
+ }
445
+ i++;
446
+ }
447
+ return spans;
448
+ }
449
+
345
450
  function utilityProperty(prefix) {
346
451
  if (prefix === "rounded") return PROP.RADIUS;
347
452
  if (prefix === "text") return PROP.TYPE;
@@ -395,6 +500,9 @@ export function normaliseSource(text, { filename = "", rootFontSize = 16, spacin
395
500
  // the same string seen twice.
396
501
  for (const m of line.matchAll(/["'`]([a-z0-9-]+(?:\s+[a-z0-9:./[\]-]+){2,})["'`]/gi)) {
397
502
  if (overlaps(m.index, m.index + m[0].length)) continue;
503
+ // G-110. Shape first: a word test admits any sentence containing the word
504
+ // `shadow`, and `shadow` is itself a complete utility.
505
+ if (!looksLikeClassList(m[1])) continue;
398
506
  if (/\b(rounded|text-|bg-|p-|px-|py-|m-|gap-|border|shadow|flex|grid|h-|w-)/.test(m[1])) {
399
507
  claim(m.index, m.index + m[0].length);
400
508
  at(normaliseUtilities(m[1], { spacingStep }));
@@ -402,7 +510,14 @@ export function normaliseSource(text, { filename = "", rootFontSize = 16, spacin
402
510
  }
403
511
 
404
512
  // Style objects: `borderRadius: 8`, `padding: "16px"`, `color: colors.ink`.
513
+ //
514
+ // G-110. A `prop: value` pair INSIDE a string literal is only styling if
515
+ // the whole literal is declaration shaped — `styled.div\`padding: 12px\`` is,
516
+ // `"expected background: #ffffff to be applied"` is not.
517
+ const literals = stringSpans(line);
405
518
  for (const m of line.matchAll(/([A-Za-z][\w$]*)\s*:\s*("[^"]*"|'[^']*'|`[^`]*`|[\w$.()#%-]+)/g)) {
519
+ const inside = literals.find((s) => m.index > s.start && m.index < s.end - 1);
520
+ if (inside && !DECL_SEGMENT.test(segmentAround(inside.content, m.index - inside.start - 1))) continue;
406
521
  const value = m[2].replace(/^["'`]|["'`]$/g, "");
407
522
  at(normaliseDeclaration(m[1], value, { source: "object", rootFontSize }));
408
523
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@weatherboard/gyde-design",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "description": "Scaffolds a design system into a product repository, then keeps auditing it.",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/Another-Iteration/gyde.git",
10
+ "url": "git+https://github.com/Weatherboard-Studio/gyde.git",
11
11
  "directory": "packages/design"
12
12
  },
13
13
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "./workspace.mjs": "./workspace.mjs",
19
19
  "./normalise.mjs": "./normalise.mjs",
20
20
  "./rules.mjs": "./rules.mjs",
21
+ "./ruleindex.mjs": "./ruleindex.mjs",
21
22
  "./scan.mjs": "./scan.mjs",
22
23
  "./tokens.mjs": "./tokens.mjs",
23
24
  "./boundaries.mjs": "./boundaries.mjs",
package/props.mjs CHANGED
@@ -29,11 +29,14 @@
29
29
  * repository starts. That is CHARTER §4 exactly: debt is recorded, never
30
30
  * amnestied, and the list may only get shorter.
31
31
  *
32
- * The mechanism that lets it arrive at all is G-68's other half — a rule a
33
- * ledger predates is adopted as existing debt rather than failing the build
34
- * (ratchet.mjs, `rulesKnownTo`). Without it this rule could not ship to an
35
- * existing consumer without turning their gate red for code they did not
36
- * change.
32
+ * The mechanism that lets it arrive at all is `gate --record-new-rules`: a rule
33
+ * the ledger does not name fails, and the failure carries the command that
34
+ * records its findings as existing debt (ratchet.mjs, `rulesKnownTo`, `adopt`).
35
+ *
36
+ * G-68 made that recording automatic and this rule is the reason we know it was
37
+ * wrong — it shipped after one consumer's baseline and was absorbed on every
38
+ * run: 173 findings across 80 files, gate green, ledger silently growing. G-108
39
+ * kept the escape hatch and removed the silence.
37
40
  *
38
41
  * WHAT IT DELIBERATELY DOES NOT DO.
39
42
  *