@weatherboard/gyde-design 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/adoption.mjs +138 -0
- package/agentdocs.mjs +246 -0
- package/boundaries.mjs +350 -0
- package/catalogue.mjs +506 -0
- package/cli.mjs +723 -0
- package/clientboundary.mjs +399 -0
- package/compound.mjs +123 -0
- package/docdrift.mjs +439 -0
- package/emit.mjs +862 -0
- package/enforcement.mjs +100 -0
- package/index.mjs +40 -0
- package/markup.mjs +177 -0
- package/migration.mjs +148 -0
- package/normalise.mjs +416 -0
- package/package.json +59 -0
- package/props.mjs +255 -0
- package/ratchet.mjs +290 -0
- package/rules.mjs +258 -0
- package/scan.mjs +291 -0
- package/stylex.mjs +178 -0
- package/tailwind.mjs +238 -0
- package/tokens.mjs +398 -0
- package/upgrade.mjs +344 -0
- package/usage.mjs +245 -0
- package/wiring.mjs +297 -0
- package/workflow.mjs +221 -0
- package/workspace.mjs +318 -0
package/props.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-68 — the optional-props ban.
|
|
3
|
+
*
|
|
4
|
+
* Absorbed from a product that wrote it by hand, under the promotion route in
|
|
5
|
+
* contract.md §7. It passes the test there: it can be stated without naming a
|
|
6
|
+
* component, a token, a route or a brand value, and a second product on a
|
|
7
|
+
* different stack would want it.
|
|
8
|
+
*
|
|
9
|
+
* WHAT IT IS FOR.
|
|
10
|
+
*
|
|
11
|
+
* `emit.mjs` states the principle on the one prop it mattered most for:
|
|
12
|
+
*
|
|
13
|
+
* "Required and nullable rather than optional. An omittable onClick on a real
|
|
14
|
+
* button compiles, renders, and does nothing — pass null to say the no-op is
|
|
15
|
+
* deliberate."
|
|
16
|
+
*
|
|
17
|
+
* That is the whole rule generalised. An optional prop lets a caller omit a
|
|
18
|
+
* decision, and the component then makes it silently. The omission and the
|
|
19
|
+
* decision are indistinguishable afterwards: nobody reading the call site can
|
|
20
|
+
* tell whether `size` was left out because medium was wanted, or because the
|
|
21
|
+
* author did not think about it. Required-and-nullable forces the author to say
|
|
22
|
+
* which, once, in the place where it is legible.
|
|
23
|
+
*
|
|
24
|
+
* WHY IT COUNTS RATHER THAN BLOCKS.
|
|
25
|
+
*
|
|
26
|
+
* Every real component set has these already — Gyde's own emitted set has ten.
|
|
27
|
+
* A ban that fails on all of them on day one is an outage, so this enters the
|
|
28
|
+
* allowance ledger like any other finding and ratchets down from wherever a
|
|
29
|
+
* repository starts. That is CHARTER §4 exactly: debt is recorded, never
|
|
30
|
+
* amnestied, and the list may only get shorter.
|
|
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.
|
|
37
|
+
*
|
|
38
|
+
* WHAT IT DELIBERATELY DOES NOT DO.
|
|
39
|
+
*
|
|
40
|
+
* It does not read defaults. `type = "button"` in a destructure is a real
|
|
41
|
+
* default and a reasonable argument for `type?`, but reading destructures to
|
|
42
|
+
* excuse declarations would make the rule depend on two places agreeing, and a
|
|
43
|
+
* rule that needs two places to agree fires on the disagreement rather than on
|
|
44
|
+
* the thing it is about. The ratchet already handles "this one is fine" — it
|
|
45
|
+
* is recorded, and it stops being counted the day somebody makes it required.
|
|
46
|
+
*
|
|
47
|
+
* @gyde-emits-source-for-another-repo — the fixtures in the test beside this
|
|
48
|
+
* file are TypeScript written as data for a repository that is not this one.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { readFileSync } from "node:fs";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
|
|
54
|
+
export const RULE = "optional-prop";
|
|
55
|
+
|
|
56
|
+
/** A Props type: `export type XProps = {`, `export interface XProps {`, and the non-exported forms. */
|
|
57
|
+
const PROPS_DECL = /\b(?:type|interface)\s+(\w*Props)\b[^{]*\{/;
|
|
58
|
+
|
|
59
|
+
const stripComments = (text) =>
|
|
60
|
+
text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
|
61
|
+
.replace(/\/\/[^\n]*/g, "");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An optional member declaration.
|
|
65
|
+
*
|
|
66
|
+
* Anchored to the start of a line so a `?` inside a type expression — a
|
|
67
|
+
* conditional type, an optional tuple element, a nested object's own member —
|
|
68
|
+
* cannot be read as a member of THIS type. Missing one of those is a miss; a
|
|
69
|
+
* false positive on a repository's existing code is a rule people switch off.
|
|
70
|
+
*/
|
|
71
|
+
const OPTIONAL_MEMBER = /^\s*(?:readonly\s+)?(\w+)\s*\?\s*:/;
|
|
72
|
+
|
|
73
|
+
/** Any member declaration: `name: T`, `name?: T`, `readonly name: T`. */
|
|
74
|
+
const MEMBER = /^\s*(?:readonly\s+)?(\w+)\s*(\?)?\s*:\s*(.+?)\s*;?\s*$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Does this type include `null` as its own alternative?
|
|
78
|
+
*
|
|
79
|
+
* Union members only — `A | null`, `(() => void) | null`. A `null` appearing
|
|
80
|
+
* inside a generic argument (`Map<string, null>`) is that generic's business,
|
|
81
|
+
* not this member's, so the split is on top-level `|` with bracket depth zero.
|
|
82
|
+
*/
|
|
83
|
+
function unionIncludesNull(type) {
|
|
84
|
+
let depth = 0, current = "", prev = "";
|
|
85
|
+
const parts = [];
|
|
86
|
+
for (const ch of type) {
|
|
87
|
+
// `>` closes a generic — unless it is the tail of an arrow. Counting the
|
|
88
|
+
// arrow in `(() => void) | null` drove the depth negative, so the top-level
|
|
89
|
+
// `|` was never seen and the most important required-nullable shape in the
|
|
90
|
+
// emitted set read as non-nullable.
|
|
91
|
+
if ("<([{".includes(ch)) depth++;
|
|
92
|
+
else if (")]}".includes(ch)) depth--;
|
|
93
|
+
else if (ch === ">" && prev !== "=") depth--;
|
|
94
|
+
|
|
95
|
+
if (ch === "|" && depth === 0) { parts.push(current); current = ""; prev = ch; continue; }
|
|
96
|
+
current += ch;
|
|
97
|
+
prev = ch;
|
|
98
|
+
}
|
|
99
|
+
parts.push(current);
|
|
100
|
+
return parts.some((p) => p.trim() === "null");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Find every optional member of every Props type in a source file.
|
|
105
|
+
*
|
|
106
|
+
* Brace-depth tracked rather than regex-matched to the closing brace: a member
|
|
107
|
+
* whose type is itself an object literal (`options: { value: string }[]`) would
|
|
108
|
+
* otherwise end the block early and hide every member after it.
|
|
109
|
+
*/
|
|
110
|
+
export function propMembers(text, { filename = "" } = {}) {
|
|
111
|
+
const lines = stripComments(text).split("\n");
|
|
112
|
+
const found = [];
|
|
113
|
+
|
|
114
|
+
let depth = 0;
|
|
115
|
+
let typeName = null;
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
const line = lines[i];
|
|
119
|
+
|
|
120
|
+
if (typeName === null) {
|
|
121
|
+
const m = line.match(PROPS_DECL);
|
|
122
|
+
if (!m) continue;
|
|
123
|
+
typeName = m[1];
|
|
124
|
+
depth = countBraces(line.slice(line.indexOf("{")));
|
|
125
|
+
if (depth <= 0) typeName = null; // a one-line type, already closed
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Only members at the type's own top level count. A nested object's members
|
|
130
|
+
// are that object's shape, not this component's API.
|
|
131
|
+
if (depth === 1) {
|
|
132
|
+
const optional = OPTIONAL_MEMBER.test(line);
|
|
133
|
+
const m = line.match(MEMBER);
|
|
134
|
+
if (m) {
|
|
135
|
+
found.push({
|
|
136
|
+
file: filename, line: i + 1, prop: m[1], type: typeName,
|
|
137
|
+
optional,
|
|
138
|
+
nullable: unionIncludesNull(m[3]),
|
|
139
|
+
declared: m[3],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
depth += countBraces(line);
|
|
145
|
+
if (depth <= 0) typeName = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function optionalProps(text, { filename = "" } = {}) {
|
|
152
|
+
return propMembers(text, { filename })
|
|
153
|
+
.filter((m) => m.optional)
|
|
154
|
+
.map((m) => ({ file: m.file, rule: RULE, line: m.line, prop: m.prop, type: m.type }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function countBraces(s) {
|
|
158
|
+
let n = 0;
|
|
159
|
+
for (const ch of s) { if (ch === "{") n++; else if (ch === "}") n--; }
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read one file from disk and report its optional props. Returns [] for anything unreadable. */
|
|
164
|
+
export function optionalPropsIn(root, rel) {
|
|
165
|
+
let text;
|
|
166
|
+
try { text = readFileSync(join(root, rel), "utf8"); } catch { return []; }
|
|
167
|
+
if (!/\bProps\b/.test(text)) return []; // cheap reject before the line walk
|
|
168
|
+
return optionalProps(text, { filename: rel });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const GUARD_RULE = "required-nullable-guard";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* G-69 — a guard against `undefined` on a prop that can never be undefined.
|
|
175
|
+
*
|
|
176
|
+
* This is the defect the optional-props ban CREATES if nobody looks for it.
|
|
177
|
+
* Converting `onClick?: () => void` to `onClick: (() => void) | null` is the
|
|
178
|
+
* fix G-68 asks for, and it silently turns every existing
|
|
179
|
+
* `if (onClick !== undefined)` into a condition that is always true. The
|
|
180
|
+
* component keeps compiling. The branch keeps running. The case the guard was
|
|
181
|
+
* written to exclude is now included, and nothing anywhere reports it.
|
|
182
|
+
*
|
|
183
|
+
* TypeScript will not catch it: the comparison is legal, and with
|
|
184
|
+
* `strictNullChecks` the narrowed type is simply unchanged. A linter's
|
|
185
|
+
* no-unnecessary-condition rule can catch it, but it requires type information,
|
|
186
|
+
* which means it is off in most repositories that most need it.
|
|
187
|
+
*
|
|
188
|
+
* The three spellings are all here because they are all the same mistake, and a
|
|
189
|
+
* rule that catches two of three teaches people which spelling is safe:
|
|
190
|
+
*
|
|
191
|
+
* x !== undefined x === undefined typeof x === "undefined"
|
|
192
|
+
*
|
|
193
|
+
* `x ?? fallback` and `x?.y` are NOT findings. Nullish coalescing and optional
|
|
194
|
+
* chaining both test null and undefined together, so they stay correct across
|
|
195
|
+
* exactly the change that breaks the explicit comparisons. They are the shape
|
|
196
|
+
* people should be moving to.
|
|
197
|
+
*
|
|
198
|
+
* SCOPE, and its honest limit. A bare identifier is matched, because props
|
|
199
|
+
* arrive destructured and that is how they are written. A local variable that
|
|
200
|
+
* shares a prop's name would be a false positive. That is accepted: the guard
|
|
201
|
+
* would still be reading a name the file declares as required-nullable, which
|
|
202
|
+
* is worth a second look either way.
|
|
203
|
+
*/
|
|
204
|
+
export function requiredNullableGuards(text, { filename = "" } = {}) {
|
|
205
|
+
const members = propMembers(text, { filename }).filter((m) => !m.optional && m.nullable);
|
|
206
|
+
if (members.length === 0) return [];
|
|
207
|
+
|
|
208
|
+
const byName = new Map(members.map((m) => [m.prop, m]));
|
|
209
|
+
const src = stripComments(text);
|
|
210
|
+
const found = [];
|
|
211
|
+
|
|
212
|
+
src.split("\n").forEach((line, i) => {
|
|
213
|
+
for (const [name, member] of byName) {
|
|
214
|
+
const compare = new RegExp(`(?<![\\w$.])${name}\\s*[!=]==\\s*undefined`);
|
|
215
|
+
const typeofCmp = new RegExp(`typeof\\s+${name}\\s*[!=]==\\s*["']undefined["']`);
|
|
216
|
+
if (!compare.test(line) && !typeofCmp.test(line)) continue;
|
|
217
|
+
|
|
218
|
+
found.push({
|
|
219
|
+
file: filename, rule: GUARD_RULE, line: i + 1, prop: name, type: member.type,
|
|
220
|
+
declared: member.declared, source: line.trim(),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return found;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function formatRequiredNullableGuards(findings) {
|
|
229
|
+
if (findings.length === 0) return "no guards compare a required-nullable prop against undefined";
|
|
230
|
+
const L = [`${findings.length} always-true guard(s):`];
|
|
231
|
+
for (const f of findings) {
|
|
232
|
+
L.push(` ${f.file}:${f.line} ${f.type}.${f.prop} is \`${f.declared}\` — required, so never undefined`);
|
|
233
|
+
L.push(` ${f.source}`);
|
|
234
|
+
}
|
|
235
|
+
L.push("");
|
|
236
|
+
L.push(" The prop is required, so it cannot be undefined and this condition is always");
|
|
237
|
+
L.push(" true. The case it was written to exclude is being included. Compare against");
|
|
238
|
+
L.push(" null, or use ?? / ?. which test both and stay correct.");
|
|
239
|
+
return L.join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function formatOptionalProps(findings) {
|
|
243
|
+
if (findings.length === 0) return "no optional props in any Props type";
|
|
244
|
+
const byFile = {};
|
|
245
|
+
for (const f of findings) (byFile[f.file] ??= []).push(f);
|
|
246
|
+
const L = [`${findings.length} optional prop(s) across ${Object.keys(byFile).length} file(s):`];
|
|
247
|
+
for (const [file, fs] of Object.entries(byFile)) {
|
|
248
|
+
L.push(` ${file} ${fs.map((f) => `${f.type}.${f.prop}`).join(", ")}`);
|
|
249
|
+
}
|
|
250
|
+
L.push("");
|
|
251
|
+
L.push(" An optional prop lets a caller omit a decision and the component makes it");
|
|
252
|
+
L.push(" silently. Afterwards nobody can tell an omission from a choice. Required and");
|
|
253
|
+
L.push(" nullable says which, once, where it is legible.");
|
|
254
|
+
return L.join("\n");
|
|
255
|
+
}
|
package/ratchet.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-52 — the allowance ledger, and the gate that reads it.
|
|
3
|
+
*
|
|
4
|
+
* A gate that blocks on every existing violation is an outage. System B had ~100
|
|
5
|
+
* pre-existing hits and System C has 4,061, so the only usable shape is
|
|
6
|
+
* to record what is already wrong and judge a change on what it introduced.
|
|
7
|
+
*
|
|
8
|
+
* THE SENTENCE THAT MAKES IT WORK, carried from System B almost verbatim,
|
|
9
|
+
* because without it an allowance file becomes a to-do list nobody reads:
|
|
10
|
+
*
|
|
11
|
+
* "This list may only ever get shorter: a new violation in any of these files
|
|
12
|
+
* fails the gate, and so does re-adding an entry that has been cleared. It is
|
|
13
|
+
* not a permission to leave them — it is the record of what was already there
|
|
14
|
+
* when the measurement became honest."
|
|
15
|
+
*
|
|
16
|
+
* THE MECHANISM WORTH COPYING EXACTLY. A cleared entry is **deleted** from the
|
|
17
|
+
* ledger, never zeroed. That single choice makes "a new violation" and "a
|
|
18
|
+
* violation somebody re-added" the same code path, so the third failure mode
|
|
19
|
+
* needs no code of its own and cannot be forgotten.
|
|
20
|
+
*
|
|
21
|
+
* WHAT System B ADDS. Compare **per scope**, never on the headline. Their gate had
|
|
22
|
+
* to be rewritten because widening the measurement to a newly-included
|
|
23
|
+
* app moved the average down while no file had got worse. An average is a
|
|
24
|
+
* number that can regress with nothing behind it regressing; a per-file tally
|
|
25
|
+
* cannot.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const LEDGER_NOTE =
|
|
29
|
+
"This list may only ever get shorter: a new violation in any of these files " +
|
|
30
|
+
"fails the gate, and so does re-adding an entry that has been cleared. It is " +
|
|
31
|
+
"not a permission to leave them — it is the record of what was already there " +
|
|
32
|
+
"when the measurement became honest. Regenerate ONLY after clearing " +
|
|
33
|
+
"violations, never to make a failure go away.";
|
|
34
|
+
|
|
35
|
+
/** Tally findings into `{ file: { rule: count } }`. */
|
|
36
|
+
export function tally(findings) {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const f of findings) {
|
|
39
|
+
(out[f.file] ??= {});
|
|
40
|
+
out[f.file][f.rule] = (out[f.file][f.rule] || 0) + 1;
|
|
41
|
+
}
|
|
42
|
+
return sortDeep(out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sortDeep(obj) {
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const k of Object.keys(obj).sort()) {
|
|
48
|
+
out[k] = Object.fromEntries(Object.entries(obj[k]).sort(([a], [b]) => a.localeCompare(b)));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record the current state as the baseline.
|
|
55
|
+
*
|
|
56
|
+
* CHARTER §4: *"Amnesty never: adoption of an existing codebase records current
|
|
57
|
+
* debt as allowances and ratchets from there; it does not reset anything to zero
|
|
58
|
+
* by declaration."* This function is that sentence — it is the only sanctioned
|
|
59
|
+
* way debt enters the ledger, and it always enters as a recorded number rather
|
|
60
|
+
* than as an exemption.
|
|
61
|
+
*/
|
|
62
|
+
export function record(findings, { recorded, ticket = null, rules = null } = {}) {
|
|
63
|
+
const allowed = tally(findings);
|
|
64
|
+
const total = Object.values(allowed).reduce(
|
|
65
|
+
(n, rules) => n + Object.values(rules).reduce((m, c) => m + c, 0), 0);
|
|
66
|
+
return {
|
|
67
|
+
note: LEDGER_NOTE,
|
|
68
|
+
// Passed in rather than read from the clock: a generated file whose content
|
|
69
|
+
// depends on when it ran cannot be diffed against its generator.
|
|
70
|
+
recorded: recorded ?? null,
|
|
71
|
+
ticket,
|
|
72
|
+
// G-68. Which rules this ledger was measured against — NOT which ones
|
|
73
|
+
// found something. Without it, "a rule that did not exist when this was
|
|
74
|
+
// recorded" and "a rule whose every entry has been cleared" are the same
|
|
75
|
+
// observation: absent from `allowed`. They must not be, because one has to
|
|
76
|
+
// be recorded and the other has to fail. See `rulesKnownTo`.
|
|
77
|
+
rules: rules ? [...new Set(rules)].sort() : null,
|
|
78
|
+
total,
|
|
79
|
+
allowed,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The rule set that existed before ledgers recorded their own.
|
|
85
|
+
*
|
|
86
|
+
* A ledger written before G-68 has no `rules` field, and the question "what did
|
|
87
|
+
* it know?" has to be answered somehow. The first attempt inferred it from the
|
|
88
|
+
* entries the ledger carries — and that is wrong in a way the existing tests
|
|
89
|
+
* caught immediately: a ledger recorded on a clean repository carries no
|
|
90
|
+
* entries, so it would appear to know nothing, and the first real violation
|
|
91
|
+
* would be adopted as pre-existing debt instead of failing.
|
|
92
|
+
*
|
|
93
|
+
* That is enforcement shrinking without anybody choosing it, which is the exact
|
|
94
|
+
* failure contract.md §7 forbids. So the answer is not inferred, it is
|
|
95
|
+
* recorded: these are the five rules that existed when the field did not.
|
|
96
|
+
* Anything on this list is enforced against a legacy ledger exactly as before.
|
|
97
|
+
*/
|
|
98
|
+
export const LEGACY_RULES = [
|
|
99
|
+
"arbitrary-shadow",
|
|
100
|
+
"untokenised-colour",
|
|
101
|
+
"untokenised-radius",
|
|
102
|
+
"untokenised-space",
|
|
103
|
+
"untokenised-type",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The rules a ledger was measured against.
|
|
108
|
+
*
|
|
109
|
+
* Fails closed. A ledger that does not say is treated as knowing every rule
|
|
110
|
+
* that existed before ledgers said — never as knowing none — so adoption can
|
|
111
|
+
* only ever apply to a rule that genuinely postdates it.
|
|
112
|
+
*/
|
|
113
|
+
export function rulesKnownTo(ledger) {
|
|
114
|
+
if (Array.isArray(ledger?.rules)) return { rules: new Set(ledger.rules), inferred: false };
|
|
115
|
+
return { rules: new Set(LEGACY_RULES), inferred: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fold adopted entries into a ledger, and record which rules it now knows.
|
|
120
|
+
*
|
|
121
|
+
* Separate from `record` because it is not the same act. `record` takes a
|
|
122
|
+
* measurement of everything; this takes an existing measurement and adds the
|
|
123
|
+
* rules that did not exist when it was made, leaving every other number exactly
|
|
124
|
+
* as it was. A rule arriving must not silently re-baseline a file whose debt
|
|
125
|
+
* somebody has been paying down.
|
|
126
|
+
*
|
|
127
|
+
* `rules` is the full set the scan ran, not just the adopted ones — otherwise a
|
|
128
|
+
* rule that found nothing this time stays unknown, and its first violation
|
|
129
|
+
* would be adopted instead of failed.
|
|
130
|
+
*/
|
|
131
|
+
export function adopt(ledger, adopted, { rules }) {
|
|
132
|
+
const allowed = JSON.parse(JSON.stringify(ledger?.allowed ?? {}));
|
|
133
|
+
for (const a of adopted) {
|
|
134
|
+
(allowed[a.file] ??= {});
|
|
135
|
+
allowed[a.file][a.rule] = a.count;
|
|
136
|
+
}
|
|
137
|
+
const known = new Set([...(rulesKnownTo(ledger).rules), ...rules]);
|
|
138
|
+
const total = Object.values(allowed).reduce(
|
|
139
|
+
(n, rs) => n + Object.values(rs).reduce((m, c) => m + c, 0), 0);
|
|
140
|
+
return {
|
|
141
|
+
...ledger,
|
|
142
|
+
note: LEDGER_NOTE,
|
|
143
|
+
rules: [...known].sort(),
|
|
144
|
+
total,
|
|
145
|
+
allowed: sortDeep(allowed),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compare a fresh scan against the ledger.
|
|
151
|
+
*
|
|
152
|
+
* Returns `{ ok, failures, cleared, unchanged }`. `cleared` is a *result*, not a
|
|
153
|
+
* pass: a count that went down must be committed, or the next run silently
|
|
154
|
+
* allows the violation to come back. Progress that is not written down is not
|
|
155
|
+
* ratcheted.
|
|
156
|
+
*/
|
|
157
|
+
export function compare(findings, ledger) {
|
|
158
|
+
const current = tally(findings);
|
|
159
|
+
const allowed = ledger?.allowed ?? {};
|
|
160
|
+
const known = rulesKnownTo(ledger);
|
|
161
|
+
|
|
162
|
+
const failures = [];
|
|
163
|
+
const cleared = [];
|
|
164
|
+
const adopted = [];
|
|
165
|
+
let unchanged = 0;
|
|
166
|
+
|
|
167
|
+
for (const [file, rules] of Object.entries(current)) {
|
|
168
|
+
for (const [rule, count] of Object.entries(rules)) {
|
|
169
|
+
const budget = allowed[file]?.[rule];
|
|
170
|
+
|
|
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.
|
|
177
|
+
//
|
|
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.
|
|
181
|
+
if (budget === undefined && !known.rules.has(rule)) {
|
|
182
|
+
adopted.push({ file, rule, count, inferred: known.inferred });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (budget === undefined) {
|
|
187
|
+
// Covers BOTH "never seen" and "cleared and re-added", because a
|
|
188
|
+
// cleared entry is deleted rather than zeroed. One code path, so the
|
|
189
|
+
// re-adding case cannot be the one somebody forgets to write.
|
|
190
|
+
failures.push({
|
|
191
|
+
file, rule, count, allowed: 0, kind: "new",
|
|
192
|
+
why: "not in the ledger — either new, or cleared once and re-added. Both fail.",
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (count > budget) {
|
|
197
|
+
failures.push({
|
|
198
|
+
file, rule, count, allowed: budget, kind: "increased",
|
|
199
|
+
why: `was ${budget}, now ${count}. The ledger may only ever get shorter.`,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (count < budget) {
|
|
204
|
+
cleared.push({ file, rule, count, allowed: budget });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
unchanged++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// An entry in the ledger with nothing behind it any more. Reported so a stale
|
|
212
|
+
// allowance cannot sit there forever quietly permitting a file that was
|
|
213
|
+
// deleted — the ledger has to track reality in both directions or it stops
|
|
214
|
+
// describing anything.
|
|
215
|
+
for (const [file, rules] of Object.entries(allowed)) {
|
|
216
|
+
for (const [rule, budget] of Object.entries(rules)) {
|
|
217
|
+
if (current[file]?.[rule] === undefined) cleared.push({ file, rule, count: 0, allowed: budget });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { ok: failures.length === 0, failures, cleared, adopted, unchanged };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The gate's verdict over one or more scopes.
|
|
226
|
+
*
|
|
227
|
+
* Scopes are compared independently and the worst result wins. This is System B's
|
|
228
|
+
* lesson made structural: there is no averaging step for a newly-measured
|
|
229
|
+
* scope to dilute, because there is no average.
|
|
230
|
+
*
|
|
231
|
+
* `unmeasured` is a first-class outcome, not an empty result. CHARTER §5 — a
|
|
232
|
+
* surface Gyde could not review must fail, never pass quietly. "Gyde did not run
|
|
233
|
+
* here" and "Gyde found nothing here" must never render the same.
|
|
234
|
+
*/
|
|
235
|
+
export function gate(scopes) {
|
|
236
|
+
const results = [];
|
|
237
|
+
for (const s of scopes) {
|
|
238
|
+
if (s.unmeasured) {
|
|
239
|
+
results.push({
|
|
240
|
+
scope: s.name, ok: false, unmeasured: true,
|
|
241
|
+
why: s.why || "could not be measured, which fails by default rather than passing quietly",
|
|
242
|
+
failures: [], cleared: [],
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
results.push({ scope: s.name, unmeasured: false, ...compare(s.findings, s.ledger) });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
ok: results.every((r) => r.ok),
|
|
251
|
+
results,
|
|
252
|
+
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 }))),
|
|
254
|
+
unmeasured: results.filter((r) => r.unmeasured).map((r) => r.scope),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A short report. Failures first — they are the only part that changes an exit code. */
|
|
259
|
+
export function formatGate(verdict) {
|
|
260
|
+
const L = [];
|
|
261
|
+
for (const r of verdict.results) {
|
|
262
|
+
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
|
+
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; }
|
|
268
|
+
L.push(`✖ ${r.scope}: ${r.failures.length} failure(s)`);
|
|
269
|
+
for (const f of r.failures.slice(0, 10)) L.push(` ${f.file} ${f.rule} ${f.why}`);
|
|
270
|
+
if (r.failures.length > 10) L.push(` … and ${r.failures.length - 10} more`);
|
|
271
|
+
}
|
|
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.");
|
|
285
|
+
}
|
|
286
|
+
if (verdict.unmeasured.length) {
|
|
287
|
+
L.push("", `${verdict.unmeasured.length} scope(s) could not be measured. That is a failure, not a pass.`);
|
|
288
|
+
}
|
|
289
|
+
return L.join("\n");
|
|
290
|
+
}
|