@weatherboard/gyde-design 0.4.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 +69 -1
- package/agentdocs.mjs +33 -1
- package/catalogue.mjs +33 -2
- package/cli.mjs +188 -19
- package/emit.mjs +89 -27
- package/index.mjs +2 -1
- package/markup.mjs +2 -2
- package/normalise.mjs +115 -0
- package/package.json +3 -2
- package/props.mjs +8 -5
- package/ratchet.mjs +191 -31
- package/ruleindex.mjs +138 -0
- package/rules.mjs +17 -0
- package/scope.mjs +195 -0
- package/selfgate.mjs +162 -0
- package/theme-choice.mjs +111 -0
- package/tokens.mjs +2 -0
- package/wiring.mjs +59 -4
- package/workflow.mjs +1 -1
package/ratchet.mjs
CHANGED
|
@@ -116,7 +116,13 @@ export function rulesKnownTo(ledger) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
* Fold
|
|
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
|
|
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-
|
|
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
|
-
//
|
|
179
|
-
// the
|
|
180
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
L.push("
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
L.push("
|
|
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
|
+
}
|