@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/ratchet.mjs CHANGED
@@ -116,7 +116,13 @@ export function rulesKnownTo(ledger) {
116
116
  }
117
117
 
118
118
  /**
119
- * Fold adopted entries into a ledger, and record which rules it now knows.
119
+ * Fold new-rule entries into a ledger, and record which rules it now knows.
120
+ *
121
+ * G-108: this is now reached ONLY from `gate --record-new-rules`. It used to run
122
+ * automatically whenever `compare` adopted, which meant merely running the gate
123
+ * on a laptop could make a consumer's ledger 80 entries longer — a ledger
124
+ * getting longer by a mechanism nobody chose. `gate` no longer writes the ledger
125
+ * at all unless asked in those words.
120
126
  *
121
127
  * Separate from `record` because it is not the same act. `record` takes a
122
128
  * measurement of everything; this takes an existing measurement and adds the
@@ -146,6 +152,127 @@ export function adopt(ledger, adopted, { rules }) {
146
152
  };
147
153
  }
148
154
 
155
+ /**
156
+ * G-113 — take a shortening, and nothing else.
157
+ *
158
+ * THE GAP THIS CLOSES. Every other writer does strictly more than remove.
159
+ * `record` re-baselines everything currently found; `adopt` only adds. So the
160
+ * one operation the standing rule always wants — *make the ledger shorter* —
161
+ * had no command, and the only mechanically available route was "delete the
162
+ * ledger and re-run `gate`". That route is provably identical to a correct trim
163
+ * **only when `failures` is 0**, which is a precondition invisible to anybody
164
+ * reading the diff six months later. Ten scoped issues each end in a shortening;
165
+ * doing it that way ten times is ten chances to perform the exact operation the
166
+ * mechanism exists to prevent.
167
+ *
168
+ * WHY IT REFUSES RATHER THAN CLAMPS. The obvious implementation takes
169
+ * `min(budget, count)` per entry and can therefore never grow the ledger. It is
170
+ * also wrong, and wrong in the direction that looks like success: pointed at a
171
+ * tree with a real regression it silently writes a *shorter* ledger while the
172
+ * regression goes unreported, converting a red gate into a green one and calling
173
+ * it progress. A trim that can absorb anything is `record` with a friendlier
174
+ * name. So a would-add and a would-raise are both hard refusals, the whole
175
+ * write is abandoned, and the caller is told which entries objected.
176
+ *
177
+ * WHAT IT PRESERVES. `ticket`, `recorded` and `rules` are carried through
178
+ * untouched. `rules` especially: dropping it re-opens G-108 exactly, because a
179
+ * ledger that does not name its rules is treated as knowing only LEGACY_RULES,
180
+ * and every rule shipped since would be re-adopted as pre-existing debt on the
181
+ * next run. A trim that loses `rules` is a trim that quietly widens the
182
+ * allowance it was meant to narrow.
183
+ *
184
+ * Returns `{ ok, ledger, removed, lowered, unchanged, refusals, before, after }`.
185
+ * `ledger` is null when `ok` is false — there is no partial trim to write.
186
+ */
187
+ export function trim(ledger, findings) {
188
+ const current = tally(findings);
189
+ const allowed = ledger?.allowed ?? {};
190
+
191
+ const removed = [];
192
+ const lowered = [];
193
+ const refusals = [];
194
+ let unchanged = 0;
195
+
196
+ const next = {};
197
+ for (const [file, rules] of Object.entries(allowed)) {
198
+ for (const [rule, budget] of Object.entries(rules)) {
199
+ const count = current[file]?.[rule] ?? 0;
200
+ if (count > budget) {
201
+ // A genuine regression. Not ours to absorb, and not ours to hide.
202
+ refusals.push({
203
+ file, rule, count, allowed: budget, kind: "increased",
204
+ why: `was ${budget}, now ${count}. A trim only ever removes; this is a regression for \`gate\` to fail on.`,
205
+ });
206
+ continue;
207
+ }
208
+ if (count === 0) { removed.push({ file, rule, allowed: budget }); continue; }
209
+ if (count < budget) {
210
+ lowered.push({ file, rule, count, allowed: budget });
211
+ (next[file] ??= {})[rule] = count;
212
+ continue;
213
+ }
214
+ unchanged++;
215
+ (next[file] ??= {})[rule] = budget;
216
+ }
217
+ }
218
+
219
+ // Anything the scan found that the ledger does not carry would have to be
220
+ // ADDED, and adding is the one thing this command must never do — whether the
221
+ // entry is new, was cleared and re-added, or belongs to a rule this ledger has
222
+ // never named. `gate` distinguishes those three because the conversations
223
+ // differ; here the answer is the same for all of them, so they share a path.
224
+ for (const [file, rules] of Object.entries(current)) {
225
+ for (const [rule, count] of Object.entries(rules)) {
226
+ if (allowed[file]?.[rule] === undefined) {
227
+ refusals.push({
228
+ file, rule, count, allowed: 0, kind: "new",
229
+ why: "not in the ledger — a trim would have to ADD it. Run `gate` to judge this properly.",
230
+ });
231
+ }
232
+ }
233
+ }
234
+
235
+ const before = Object.values(allowed).reduce(
236
+ (n, rs) => n + Object.values(rs).reduce((m, c) => m + c, 0), 0);
237
+
238
+ if (refusals.length) {
239
+ return { ok: false, ledger: null, removed: [], lowered: [], unchanged, refusals, before, after: before };
240
+ }
241
+
242
+ const total = Object.values(next).reduce(
243
+ (n, rs) => n + Object.values(rs).reduce((m, c) => m + c, 0), 0);
244
+
245
+ return {
246
+ ok: true,
247
+ // Spread first so `recorded`, `ticket` and anything a future field adds
248
+ // survives; the named keys below are the only ones a trim may change.
249
+ ledger: { ...ledger, note: LEDGER_NOTE, total, allowed: sortDeep(next) },
250
+ removed, lowered, unchanged, refusals: [], before, after: total,
251
+ };
252
+ }
253
+
254
+ /** A per-entry account of a trim. The audit trail IS the point of this file. */
255
+ export function formatTrim(result) {
256
+ const L = [];
257
+ if (!result.ok) {
258
+ L.push(`✖ refused to trim: ${result.refusals.length} entry(ies) would have to be added or raised.`);
259
+ L.push("");
260
+ for (const r of result.refusals.slice(0, 20)) L.push(` ${r.file} ${r.rule} ${r.why}`);
261
+ if (result.refusals.length > 20) L.push(` … and ${result.refusals.length - 20} more`);
262
+ L.push("");
263
+ L.push("A trim only ever removes. Nothing was written — the ledger on disk is unchanged.");
264
+ L.push("This tree has findings the ledger does not cover; that is a gate failure, not a trim.");
265
+ return L.join("\n");
266
+ }
267
+ for (const r of result.removed) L.push(` - ${r.file} ${r.rule} ${r.allowed} → gone`);
268
+ for (const r of result.lowered) L.push(` ~ ${r.file} ${r.rule} ${r.allowed} → ${r.count}`);
269
+ if (!L.length) L.push(" (nothing to remove — every entry still has findings behind it)");
270
+ L.push("");
271
+ L.push(`${result.removed.length} entry(ies) removed, ${result.lowered.length} lowered, ${result.unchanged} unchanged.`);
272
+ L.push(`gyde-allowance.json: ${result.before} → ${result.after} findings. This ledger got SHORTER.`);
273
+ return L.join("\n");
274
+ }
275
+
149
276
  /**
150
277
  * Compare a fresh scan against the ledger.
151
278
  *
@@ -161,25 +288,43 @@ export function compare(findings, ledger) {
161
288
 
162
289
  const failures = [];
163
290
  const cleared = [];
164
- const adopted = [];
291
+ const newRules = new Set();
165
292
  let unchanged = 0;
166
293
 
167
294
  for (const [file, rules] of Object.entries(current)) {
168
295
  for (const [rule, count] of Object.entries(rules)) {
169
296
  const budget = allowed[file]?.[rule];
170
297
 
171
- // G-68. A rule this ledger was never measured against did not exist when
172
- // the measurement was taken, so its hits are pre-existing debt rather
173
- // than something this change introduced. Recording them is CHARTER §4
174
- // working as written — debt enters as a recorded allowance, never as zero
175
- // by declaration — and it is what contract.md §5.2 promises a customer:
176
- // a new rule arrives as an allowance-backed ratchet, not a red build.
298
+ // G-108. A rule this ledger does not name is NEW, and a new rule fails.
177
299
  //
178
- // This deliberately does NOT weaken the delete-not-zero guarantee. A rule
179
- // the ledger knows about, with an entry that has been cleared, is still
180
- // absent from `allowed` and still fails below.
300
+ // G-68 made this branch adopt: it recorded the hits as pre-existing debt
301
+ // and let the build stay green, reading contract.md §5.2 as a promise
302
+ // that a new rule never turns a customer red. That was the wrong half of
303
+ // the sentence. `optional-prop` shipped after one consumer's baseline and
304
+ // was absorbed on every run — 173 findings across 80 files, announced in
305
+ // the log and counted by nobody — while CI stayed green and the ledger on
306
+ // disk stopped describing what the gate enforced. A ratchet that adopts
307
+ // is not a ratchet; it is a ledger that grows on a schedule set by
308
+ // somebody else's release.
309
+ //
310
+ // The promise §5.2 actually has to keep is that the product is not
311
+ // ambushed, and a failure with a one-command remedy keeps it. Recording
312
+ // is still available and still allowance-backed — it is now an act
313
+ // somebody performs (`gate --record-new-rules`) rather than a side effect
314
+ // of running the gate. Either way it is a decision on the record, which
315
+ // is the difference between CHARTER §4 and amnesty.
316
+ //
317
+ // `known` is still consulted, because *why* an entry is missing is worth
318
+ // saying: a rule the ledger names, cleared and re-added, is a different
319
+ // conversation from a rule that has never been measured here.
181
320
  if (budget === undefined && !known.rules.has(rule)) {
182
- adopted.push({ file, rule, count, inferred: known.inferred });
321
+ newRules.add(rule);
322
+ failures.push({
323
+ file, rule, count, allowed: 0, kind: "unrecorded-rule",
324
+ inferred: known.inferred,
325
+ why: `\`${rule}\` is not named in this ledger's \`rules\`, so it is new to this ledger. ` +
326
+ "Fix these findings, or record them deliberately with `gate --record-new-rules`.",
327
+ });
183
328
  continue;
184
329
  }
185
330
 
@@ -218,7 +363,15 @@ export function compare(findings, ledger) {
218
363
  }
219
364
  }
220
365
 
221
- return { ok: failures.length === 0, failures, cleared, adopted, unchanged };
366
+ return {
367
+ ok: failures.length === 0,
368
+ failures, cleared, unchanged,
369
+ // The rules this scan ran that the ledger does not name. Reported as a set
370
+ // rather than left to be re-derived from `failures`, because the remedy is
371
+ // per-rule ("record these two") while the failures are per-file-per-rule.
372
+ newRules: [...newRules].sort(),
373
+ inferredRuleSet: known.inferred,
374
+ };
222
375
  }
223
376
 
224
377
  /**
@@ -250,7 +403,7 @@ export function gate(scopes) {
250
403
  ok: results.every((r) => r.ok),
251
404
  results,
252
405
  failures: results.flatMap((r) => r.failures.map((f) => ({ ...f, scope: r.scope }))),
253
- adopted: results.flatMap((r) => (r.adopted || []).map((a) => ({ ...a, scope: r.scope }))),
406
+ newRules: [...new Set(results.flatMap((r) => r.newRules || []))].sort(),
254
407
  unmeasured: results.filter((r) => r.unmeasured).map((r) => r.scope),
255
408
  };
256
409
  }
@@ -260,28 +413,35 @@ export function formatGate(verdict) {
260
413
  const L = [];
261
414
  for (const r of verdict.results) {
262
415
  if (r.unmeasured) { L.push(`✖ ${r.scope}: UNMEASURED — ${r.why}`); continue; }
263
- if (r.ok && r.cleared.length === 0 && (r.adopted || []).length === 0) {
264
- L.push(`✔ ${r.scope}: held (${r.unchanged} allowed entries unchanged)`); continue;
265
- }
266
416
  if (r.ok && r.cleared.length === 0) { L.push(`✔ ${r.scope}: held (${r.unchanged} allowed entries unchanged)`); continue; }
267
- if (r.ok) { L.push(`✔ ${r.scope}: held, and ${r.cleared.length} entry(ies) improved — commit the ledger to keep the ground`); continue; }
417
+ // G-113. This used to say "commit the ledger to keep the ground", which was
418
+ // true until G-108 removed the write and left the instruction pointing at
419
+ // it. A message naming a mechanism that no longer exists is worse than no
420
+ // message: it reads as free progress somebody forgot to collect, and the
421
+ // only way to act on it was to delete the ledger and re-record — the one
422
+ // operation the ratchet exists to prevent.
423
+ if (r.ok) {
424
+ L.push(`✔ ${r.scope}: held, and ${r.cleared.length} entry(ies) improved — \`gate --trim\` takes that ground`);
425
+ continue;
426
+ }
268
427
  L.push(`✖ ${r.scope}: ${r.failures.length} failure(s)`);
269
428
  for (const f of r.failures.slice(0, 10)) L.push(` ${f.file} ${f.rule} ${f.why}`);
270
429
  if (r.failures.length > 10) L.push(` … and ${r.failures.length - 10} more`);
271
430
  }
272
- // Adoption is reported at the volume of a failure without being one. A rule
273
- // that arrives, records silently and is never mentioned is a rule the product
274
- // never learns it now has — and an unmentioned allowance is the to-do list
275
- // nobody reads that LEDGER_NOTE exists to prevent.
276
- if ((verdict.adopted || []).length) {
277
- const byRule = {};
278
- for (const a of verdict.adopted) (byRule[a.rule] ??= []).push(a);
279
- L.push("", `${verdict.adopted.length} entry(ies) adopted for ${Object.keys(byRule).length} rule(s) this ledger predates:`);
280
- for (const [rule, entries] of Object.entries(byRule)) {
281
- const n = entries.reduce((s, e) => s + e.count, 0);
282
- L.push(` ${rule}: ${n} across ${entries.length} file(s) — recorded as existing debt, not judged`);
283
- }
284
- L.push(" Commit gyde-allowance.json, or the next run adopts them again and the rule never ratchets.");
431
+ // G-108. The remedy, printed with the failure, because a red build whose fix
432
+ // is "read the source of the gate" is the ambush contract.md §5.2 forbids.
433
+ // This is the whole difference between failing and being unusable.
434
+ if ((verdict.newRules || []).length) {
435
+ const rules = verdict.newRules;
436
+ L.push("", `${rules.length} rule(s) this ledger does not name: ${rules.join(", ")}`);
437
+ L.push(" A rule the ledger does not name is NEW, and a new rule fails rather than being absorbed.");
438
+ L.push(" Either fix the findings above, or record them as existing debt on purpose:");
439
+ L.push("");
440
+ L.push(" npx gyde design gate . --record-new-rules");
441
+ L.push("");
442
+ L.push(" That writes gyde-allowance.json and still exits non-zero — it records a");
443
+ L.push(" baseline for these rules rather than judging one, so the decision lands in a");
444
+ L.push(" diff somebody reviews. Every other number in the ledger is left alone.");
285
445
  }
286
446
  if (verdict.unmeasured.length) {
287
447
  L.push("", `${verdict.unmeasured.length} scope(s) could not be measured. That is a failure, not a pass.`);
package/ruleindex.mjs ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * G-127 — every rule Gyde enforces, as data.
3
+ *
4
+ * ===========================================================================
5
+ * WHAT THIS IS FOR
6
+ * ===========================================================================
7
+ *
8
+ * A rule id reaches a customer in three places — a finding, the `rules` stamp
9
+ * in `gyde-allowance.json`, and a failing gate — and in all three it is a bare
10
+ * slug. `optional-prop` says what matched. It does not say what the rule is
11
+ * defending, or what the fix looks like, and both of those lived only in a
12
+ * module comment inside this repository, which the person reading the failure
13
+ * does not have.
14
+ *
15
+ * So: one machine-readable index, for a consuming repository's docs, an agent
16
+ * briefing itself on a finding, or a person who typed `gate` and got a slug.
17
+ *
18
+ * ===========================================================================
19
+ * IT DESCRIBES. IT DOES NOT DECIDE.
20
+ * ===========================================================================
21
+ *
22
+ * Nothing here is consulted by `scan`, `run`, `coverage` or the ledger, and
23
+ * nothing here may become so. A description that can change a verdict is a
24
+ * second implementation of the rule, kept in prose, and it will disagree with
25
+ * the first one silently. The `test` predicates stay in `rules.mjs`,
26
+ * `props.mjs`, `compound.mjs` and `docdrift.mjs`, where they are the only copy.
27
+ *
28
+ * The consequence to respect when editing: getting a sentence here wrong
29
+ * misleads a reader. Getting a predicate there wrong changes what fails. They
30
+ * are different kinds of mistake and they are deliberately in different files.
31
+ *
32
+ * ===========================================================================
33
+ * COMPLETENESS IS CHECKED, IN BOTH DIRECTIONS
34
+ * ===========================================================================
35
+ *
36
+ * `ruleindex.test.mjs` runs a real scan and compares its `rulesRun` stamp
37
+ * against the ids below. A rule implemented and not described fails; a rule
38
+ * described and not implemented fails too. The second direction matters as much
39
+ * as the first: an index naming a rule nobody runs is exactly the "all clear
40
+ * printed over unread data" shape, and it would read as coverage.
41
+ */
42
+
43
+ import { RULES_BY_ID } from "./rules.mjs";
44
+
45
+ /**
46
+ * The declaration rules' `intent` is READ FROM `rules.mjs` rather than restated.
47
+ *
48
+ * Two sentences about one rule is two sentences that can disagree, and the one
49
+ * a reader believes is whichever they found first.
50
+ */
51
+ const summaryOf = (id) => {
52
+ const rule = RULES_BY_ID.get(id);
53
+ if (!rule) throw new Error(`ruleindex names ${id}, which rules.mjs does not declare`);
54
+ return rule.summary;
55
+ };
56
+
57
+ const ENTRIES = [
58
+ {
59
+ id: "untokenised-radius",
60
+ name: "Untokenised radius",
61
+ intent: summaryOf("untokenised-radius"),
62
+ fix: "Replace the literal with the dictionary's radius token — card, control or pill.",
63
+ },
64
+ {
65
+ id: "untokenised-space",
66
+ name: "Untokenised spacing",
67
+ intent: summaryOf("untokenised-space"),
68
+ fix: "Take the nearest step from the spacing scale; if none fits, the scale is the thing to change.",
69
+ },
70
+ {
71
+ id: "untokenised-type",
72
+ name: "Untokenised type size",
73
+ intent: summaryOf("untokenised-type"),
74
+ fix: "Use a type role rather than a size — the role is the decision, the pixels are its consequence.",
75
+ },
76
+ {
77
+ id: "untokenised-colour",
78
+ name: "Untokenised colour",
79
+ intent: summaryOf("untokenised-colour"),
80
+ fix: "Use a semantic colour token (text, border, raised, accent, danger …), not a palette entry or a literal.",
81
+ },
82
+ {
83
+ id: "arbitrary-shadow",
84
+ name: "Arbitrary shadow",
85
+ intent: summaryOf("arbitrary-shadow"),
86
+ fix: "Use the dictionary's elevation, or argue for a new one — depth is a semantic decision, not a garnish.",
87
+ },
88
+ {
89
+ id: "optional-prop",
90
+ name: "Optional prop",
91
+ intent: "A prop a caller may omit, leaving the component to make the decision silently.",
92
+ fix: "Make it required. Where absence is itself a real answer, make it required and NULLABLE " +
93
+ "(`describedBy: string | null`) and supply the ordinary value from the package's defaults " +
94
+ "module, spread at the call site. Do not reach for `x !== undefined` afterwards — see " +
95
+ "required-nullable-guard.",
96
+ },
97
+ {
98
+ id: "required-nullable-guard",
99
+ name: "Always-true undefined guard",
100
+ intent: "A guard comparing a required prop against `undefined`, which it can never be.",
101
+ fix: "Compare against null, or use `??` / `?.` — both test null and undefined, so they stay " +
102
+ "correct across exactly the change that breaks the explicit comparison.",
103
+ },
104
+ {
105
+ id: "compound-part-without-root",
106
+ name: "Orphaned compound part",
107
+ intent: "A compound component's part used without the root that supplies its context.",
108
+ fix: "Render the part inside its root, or use the wrapper that does.",
109
+ },
110
+ {
111
+ id: "stale-source-path",
112
+ name: "Stale path in documentation",
113
+ intent: "A path named in prose that no longer exists in the repository.",
114
+ fix: "Point it at the file that exists now, or delete the sentence — a path that does not resolve " +
115
+ "is read as a file somebody deleted by mistake.",
116
+ },
117
+ ];
118
+
119
+ /** Frozen, because a caller mutating the shared index would change what every later caller reads. */
120
+ const FROZEN = Object.freeze(ENTRIES.map((e) => Object.freeze({ ...e })));
121
+
122
+ /**
123
+ * Every rule, as `{ id, name, intent, fix }`, sorted by id.
124
+ *
125
+ * Sorted rather than source-ordered so a consumer diffing two versions sees the
126
+ * rules that changed rather than the rules that moved.
127
+ */
128
+ export function rules() {
129
+ return [...FROZEN].sort((a, b) => a.id.localeCompare(b.id));
130
+ }
131
+
132
+ /** The same thing as a JSON string, for anything that is not JavaScript. */
133
+ export function rulesJson() {
134
+ return JSON.stringify(rules(), null, 2) + "\n";
135
+ }
136
+
137
+ /** `node packages/design/ruleindex.mjs` prints it. */
138
+ if (process.argv[1] && process.argv[1].endsWith("ruleindex.mjs")) process.stdout.write(rulesJson());
package/rules.mjs CHANGED
@@ -112,6 +112,14 @@ export const RULES_BY_ID = new Map(RULES.map((r) => [r.id, r]));
112
112
  * other. That is the point of the whole module: if `untokenised-radius` is
113
113
  * tested only against `border-radius: 8px`, it is being validated against
114
114
  * exactly the rule we are trying not to write again.
115
+ *
116
+ * G-110 added a PROSE entry to every rule's `good` list. Each rule reads the
117
+ * normalised form, but the normaliser reads source text, and it was reading
118
+ * English sentences inside string literals as if they were class attributes —
119
+ * `"parks renderer shadow map must be enabled"` scored as `arbitrary-shadow`
120
+ * against a Node assertion harness with no markup in it. A test that only
121
+ * asserts real violations are caught cannot tell a precise rule from an eager
122
+ * one, so the prose fixtures are the half that was missing.
115
123
  */
116
124
  export const FIXTURES = {
117
125
  "untokenised-radius": {
@@ -127,6 +135,8 @@ export const FIXTURES = {
127
135
  { css: ".a { border-radius: 0 }" },
128
136
  { source: "const s = { borderRadius: radius.control }", filename: "a.ts" },
129
137
  { source: '<div className="rounded-card" />', filename: "a.tsx" }, // project semantic radius
138
+ // G-110. Prose, not markup: an English sentence naming a radius is not a radius.
139
+ { source: 'assert(ok, "the rounded-lg corner is wrong here");', filename: "a.mjs" },
130
140
  ],
131
141
  },
132
142
  "untokenised-space": {
@@ -140,6 +150,8 @@ export const FIXTURES = {
140
150
  { css: ".a { padding: var(--space-3) }" },
141
151
  { css: ".a { padding: 0 var(--space-2) }" },
142
152
  { css: ".a { margin: 0 }" },
153
+ { source: 'assert(ok, "the p-4 spacing is wrong here");', filename: "a.mjs" },
154
+ { source: 'assert(ok, "set padding: 12px on the container");', filename: "a.mjs" },
143
155
  ],
144
156
  },
145
157
  "untokenised-type": {
@@ -151,6 +163,7 @@ export const FIXTURES = {
151
163
  good: [
152
164
  { css: ".a { font-size: var(--text-body) }" },
153
165
  { source: "const s = { fontSize: text.body }", filename: "a.ts" },
166
+ { source: 'assert(ok, "the text-3xl heading must be present");', filename: "a.mjs" },
154
167
  ],
155
168
  },
156
169
  "untokenised-colour": {
@@ -167,6 +180,7 @@ export const FIXTURES = {
167
180
  { source: '<div className="bg-primary" />', filename: "a.tsx" }, // shadcn semantic utility
168
181
  { source: '<div className="text-muted-foreground" />', filename: "a.tsx" },
169
182
  { css: ":root { --color-surface: #0b0d10 }" }, // a second palette, legitimately
183
+ { source: 'assert(ok, "expected background: #ffffff to be applied");', filename: "a.mjs" },
170
184
  ],
171
185
  },
172
186
  "arbitrary-shadow": {
@@ -177,6 +191,9 @@ export const FIXTURES = {
177
191
  good: [
178
192
  { css: ".a { box-shadow: var(--shadow-overlay) }" },
179
193
  { css: ".a { box-shadow: none }" },
194
+ // G-110, verbatim from the harness that produced four false findings.
195
+ { source: 'includes("parksCanvas", "gl.shadowMap.enabled = true", "parks renderer shadow map must be enabled");', filename: "a.mjs" },
196
+ { source: 'includes("nativeShadows", "receiveShadow = true", "parks tile meshes must receive shadows");', filename: "a.mjs" },
180
197
  ],
181
198
  },
182
199
  };
package/scope.mjs ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * G-123 — `design.scope`, validated.
3
+ *
4
+ * WHAT WAS WRONG.
5
+ *
6
+ * `design.scope` reads as a guard and guarded nothing. A consumer could set it
7
+ * to `@definitely-not-a-scope`, and the gate exited 0 with byte-identical
8
+ * output — measured in a consuming repository, 2026-09-15. The key was
9
+ * read in exactly one place, `buildEmission`, which runs on `init`, `plan` and
10
+ * `upgrade`. The command a consumer wires into CI — `gate` — never looked at it.
11
+ *
12
+ * That is this repository's signature defect: a configuration key that a reader
13
+ * takes for a declaration Gyde checks, when nothing checks it. The value is not
14
+ * inert, either. It names the packages the emitted code imports, so a wrong one
15
+ * produces a scaffold whose every import resolves to nothing — and the first
16
+ * proof of that is a build failure in somebody else's repository.
17
+ *
18
+ * TWO CHECKS, AND THEY ARE DIFFERENT KINDS.
19
+ *
20
+ * `validateScopeShape` is a SCHEMA check. It asks whether the value is a
21
+ * package scope at all, needs nothing but the string, and therefore runs
22
+ * wherever the config is read — including `init` on a greenfield repository
23
+ * that has no packages yet.
24
+ *
25
+ * `checkScopeAgreement` is a MEASUREMENT. It asks whether the declared scope
26
+ * matches what the repository actually contains, so it needs the workspace and
27
+ * it only runs on the check path.
28
+ *
29
+ * WHY AGREEMENT DOES NOT SIMPLY REFUSE AN UNUSED SCOPE.
30
+ *
31
+ * Because "no package uses this scope" has two causes that must not render the
32
+ * same (CHARTER §5). A workspace whose packages are `@acme/*` and whose
33
+ * config says `@definitely-not-a-scope` is CONTRADICTED — there is evidence and
34
+ * the evidence disagrees. A workspace whose packages are all unscoped is
35
+ * UNMEASURED — `workspace.mjs` documents that case as the exact reason the
36
+ * override exists ("a project with no scope at all … needs to say which it
37
+ * wants"), and refusing it would fail the one config that has to declare a
38
+ * scope because nothing can be detected.
39
+ *
40
+ * So this refuses on contradiction and reports on absence, and says which one
41
+ * it did. A check that failed on absence would be a check that fails a correct
42
+ * config, which is how a gate gets switched off.
43
+ *
44
+ * THE DESIGN PACKAGE'S OWN NAME OUTRANKS THE HEADCOUNT.
45
+ *
46
+ * `detectScope` takes the most common scope, which is the right guess when
47
+ * guessing. It is the wrong authority here: the scope exists to name the
48
+ * emitted design packages, so if `packages/design-system` is called
49
+ * `@acme/design-system`, that is what `design.scope` has to be, however many
50
+ * `@vendor/*` packages sit beside it.
51
+ */
52
+
53
+ /**
54
+ * A package scope, by npm's own rules for the part before the slash.
55
+ *
56
+ * npm lowercases scopes and forbids a leading dot or underscore. This is
57
+ * deliberately not a loose `^@\S+$`: the value is concatenated into a package
58
+ * name that has to be installable, and `@Acme` is not.
59
+ */
60
+ export const SCOPE_PATTERN = /^@[a-z0-9-~][a-z0-9-._~]*$/;
61
+
62
+ /**
63
+ * Is this a package scope?
64
+ *
65
+ * Absent is fine — the workspace's own scope is detected when the key is not
66
+ * declared, which is the documented default. Present and wrong is not fine, and
67
+ * the distinction is the whole point of the `declared` flag.
68
+ *
69
+ * @param {unknown} scope
70
+ * @returns {{ ok: boolean, declared: boolean, error: string|null }}
71
+ */
72
+ export function validateScopeShape(scope) {
73
+ if (scope === undefined || scope === null) {
74
+ return { ok: true, declared: false, error: null };
75
+ }
76
+ if (typeof scope !== "string") {
77
+ return {
78
+ ok: false,
79
+ declared: true,
80
+ error: `design.scope must be a string like "@acme" (got ${JSON.stringify(scope)})`,
81
+ };
82
+ }
83
+ if (!SCOPE_PATTERN.test(scope)) {
84
+ const hint = scope.startsWith("@")
85
+ ? scope.includes("/")
86
+ ? ` — it is the scope alone, not a package name: use "${scope.split("/")[0]}"`
87
+ : ` — a scope is lowercase and may not start with "." or "_"`
88
+ : ` — a scope starts with "@": did you mean "@${scope}"?`;
89
+ return {
90
+ ok: false,
91
+ declared: true,
92
+ error: `design.scope must be a package scope like "@acme" (got ${JSON.stringify(scope)})${hint}`,
93
+ };
94
+ }
95
+ return { ok: true, declared: true, error: null };
96
+ }
97
+
98
+ /** The scope part of a package name, or null when the name is unscoped. */
99
+ export function scopeOf(name) {
100
+ const m = (typeof name === "string" ? name : "").match(/^(@[^/]+)\//);
101
+ return m ? m[1] : null;
102
+ }
103
+
104
+ /**
105
+ * Does the declared scope agree with the repository?
106
+ *
107
+ * @param {object} opts
108
+ * @param {string|null|undefined} opts.declared `design.scope` as committed
109
+ * @param {{path: string, name: string|null}[]} opts.packages the discovered workspace
110
+ * @param {string} opts.systemPath where the design system lives
111
+ * @param {string|null} [opts.systemPackageName] that package's own `name`, read from disk
112
+ * @returns {{ ok: boolean, measured: boolean, why: string, against: string|null }}
113
+ */
114
+ export function checkScopeAgreement({ declared, packages = [], systemPath, systemPackageName = null }) {
115
+ const shape = validateScopeShape(declared);
116
+ if (!shape.declared) {
117
+ return {
118
+ ok: true,
119
+ measured: false,
120
+ why: "no design.scope is declared — the workspace's own scope is detected instead",
121
+ against: null,
122
+ };
123
+ }
124
+ // A malformed value never reaches here in the CLI (the config read refuses
125
+ // first), but a caller that skipped that must not be told the scope agrees.
126
+ if (!shape.ok) {
127
+ return { ok: false, measured: true, why: shape.error, against: "the schema" };
128
+ }
129
+
130
+ // The design package's own name is the authority when it exists, because the
131
+ // scope's entire job is to name that package.
132
+ const own = scopeOf(systemPackageName);
133
+ if (own) {
134
+ if (own === declared) {
135
+ return {
136
+ ok: true,
137
+ measured: true,
138
+ why: `the design package at ${systemPath} is named "${systemPackageName}"`,
139
+ against: systemPackageName,
140
+ };
141
+ }
142
+ return {
143
+ ok: false,
144
+ measured: true,
145
+ why: `design.scope is "${declared}", but the design package at ${systemPath} is named ` +
146
+ `"${systemPackageName}" — scope "${own}". The emitted code imports "${declared}/design-system", ` +
147
+ `which is not what that package is called, so those imports resolve to nothing.`,
148
+ against: systemPackageName,
149
+ };
150
+ }
151
+
152
+ const inUse = new Map();
153
+ for (const p of packages) {
154
+ const s = scopeOf(p?.name);
155
+ if (s) inUse.set(s, (inUse.get(s) ?? 0) + 1);
156
+ }
157
+
158
+ if (inUse.size === 0) {
159
+ return {
160
+ ok: true,
161
+ measured: false,
162
+ why: `no package in this workspace uses a scope, so "${declared}" could not be contradicted. ` +
163
+ `This is NOT a pass for the value — it is the absence of evidence either way.`,
164
+ against: null,
165
+ };
166
+ }
167
+
168
+ if (!inUse.has(declared)) {
169
+ const listed = [...inUse.entries()]
170
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
171
+ .map(([s, n]) => `${s} (${n} package(s))`)
172
+ .join(", ");
173
+ return {
174
+ ok: false,
175
+ measured: true,
176
+ why: `design.scope is "${declared}", but no package in this workspace uses it. ` +
177
+ `In use here: ${listed}.`,
178
+ against: listed,
179
+ };
180
+ }
181
+
182
+ return {
183
+ ok: true,
184
+ measured: true,
185
+ why: `${inUse.get(declared)} package(s) in this workspace use "${declared}"`,
186
+ against: `${inUse.get(declared)} package(s)`,
187
+ };
188
+ }
189
+
190
+ /** The gate's line for this check, carrying its denominator either way. */
191
+ export function formatScope(r) {
192
+ if (!r.ok) return [`✖ design.scope: ${r.why}`, "", "Fix the value in gyde.config.json, or rename the package it names."].join("\n");
193
+ if (!r.measured) return `ℹ design.scope: ${r.why}`;
194
+ return `✔ design.scope: agrees — ${r.why}`;
195
+ }