@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/rules.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-51 — the violation rules, written once against the normalised form.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IS DIFFERENT ABOUT THESE.
|
|
5
|
+
*
|
|
6
|
+
* Every rule here takes a `Decl` (see normalise.mjs), never a line of source.
|
|
7
|
+
* That is the whole design: `rounded-lg`, `border-radius: 8px`, `borderRadius: 8`
|
|
8
|
+
* and a cva variant string all arrive as the same radius decision, so one rule
|
|
9
|
+
* covers three repositories written in three idioms.
|
|
10
|
+
*
|
|
11
|
+
* TWO STRUCTURAL GUARANTEES, both learned from rules that failed silently.
|
|
12
|
+
*
|
|
13
|
+
* 1. **A rule with no failing fixture does not load.** System A's
|
|
14
|
+
* phrasing is the standard: "A rule without one is assumed broken." Here it
|
|
15
|
+
* is not a convention — `loadRules()` throws. A rule nobody proved against a
|
|
16
|
+
* known-bad example is worse than no rule, because it reports zero.
|
|
17
|
+
*
|
|
18
|
+
* 2. **A rule with no passing fixture does not load either.** The fix for a rule
|
|
19
|
+
* that catches nothing is to widen it, and the natural end of widening is a
|
|
20
|
+
* rule that catches everything. A noisy rule gets switched off exactly as
|
|
21
|
+
* fast as a silent one, so both directions are pinned.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THE RULES DELIBERATELY DO NOT DO.
|
|
24
|
+
*
|
|
25
|
+
* They never name a product's component, token, route or brand value. If a rule
|
|
26
|
+
* cannot be stated without one, it is a leak and belongs in per-project
|
|
27
|
+
* configuration (G-55's genericity check enforces this). The thresholds and the
|
|
28
|
+
* allowed token prefixes are configuration; the concepts are not.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { PROP } from "./normalise.mjs";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Classifications that are not violations, and why that matters.
|
|
35
|
+
*
|
|
36
|
+
* System A finished its remediation with 33 violations, of which three were
|
|
37
|
+
* "containment rules for foreign content — the iframe's column, the iframe, a
|
|
38
|
+
* screenshot of someone else's page. Not a visual vocabulary."
|
|
39
|
+
*
|
|
40
|
+
* A rule set that cannot tell foreign-content containment from bespoke styling
|
|
41
|
+
* reports those three forever, and a number that never reaches zero stops being
|
|
42
|
+
* read. So the category exists, it is declared per project, and code inside it
|
|
43
|
+
* is COUNTED AND REPORTED SEPARATELY rather than dropped — the same discipline
|
|
44
|
+
* as the exemption registry in boundaries.mjs.
|
|
45
|
+
*/
|
|
46
|
+
export const CLASSIFICATION = {
|
|
47
|
+
SYSTEM: "system", // the design system's own source — it IS the tokens
|
|
48
|
+
FOREIGN: "foreign", // containment for content we do not control
|
|
49
|
+
PRODUCT: "product", // ordinary product code: the rules apply in full
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const DEFAULTS = {
|
|
53
|
+
/** Prefixes that mark a class as belonging to a declared vocabulary rather than being bespoke. */
|
|
54
|
+
classPrefixes: ["ds-", "dx-", "cx-"],
|
|
55
|
+
/** How many distinct type sizes a project may spell before it has a scale problem. */
|
|
56
|
+
maxTypeSizes: 6,
|
|
57
|
+
/** Shadows are a deliberate, countable decision. System A shipped with exactly one. */
|
|
58
|
+
maxShadows: 1,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every rule: `{ id, summary, property, test }`.
|
|
63
|
+
*
|
|
64
|
+
* `test(decl, config)` returns true when the declaration VIOLATES the rule.
|
|
65
|
+
* Keeping the predicate positive ("this is wrong") rather than negative reads
|
|
66
|
+
* the same way the finding does, which is a small thing that stops sign errors.
|
|
67
|
+
*/
|
|
68
|
+
const RULES = [
|
|
69
|
+
{
|
|
70
|
+
id: "untokenised-radius",
|
|
71
|
+
property: PROP.RADIUS,
|
|
72
|
+
summary: "A radius spelled at the call site rather than taken from the dictionary.",
|
|
73
|
+
// Both mature systems here independently closed radius to exactly three
|
|
74
|
+
// semantic values (card, control, pill), and System A measured
|
|
75
|
+
// that radius had NOT drifted even while type and space had — 2 distinct
|
|
76
|
+
// values across 807 lines. So this is the cheapest rule to adopt and the
|
|
77
|
+
// one most likely to already pass.
|
|
78
|
+
test: (d) => !d.tokenised && !d.neutral && d.px !== null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "untokenised-space",
|
|
82
|
+
property: PROP.SPACE,
|
|
83
|
+
summary: "A padding, margin or gap spelled at the call site.",
|
|
84
|
+
test: (d) => !d.tokenised && !d.neutral && d.px !== null,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "untokenised-type",
|
|
88
|
+
property: PROP.TYPE,
|
|
89
|
+
summary: "A font size spelled at the call site.",
|
|
90
|
+
test: (d) => !d.tokenised && !d.neutral && d.px !== null,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "untokenised-colour",
|
|
94
|
+
property: PROP.COLOR,
|
|
95
|
+
summary: "A colour literal, or a palette colour chosen at the call site.",
|
|
96
|
+
test: (d) => !d.tokenised && !d.neutral,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "arbitrary-shadow",
|
|
100
|
+
property: PROP.SHADOW,
|
|
101
|
+
summary: "A shadow that is not from the dictionary. Depth is a semantic decision, not a garnish.",
|
|
102
|
+
test: (d) => !d.tokenised && !d.neutral,
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
export const RULES_BY_ID = new Map(RULES.map((r) => [r.id, r]));
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fixtures. Every rule needs both lists, and `loadRules` enforces it.
|
|
110
|
+
*
|
|
111
|
+
* The `bad` entries are deliberately spelled in DIFFERENT IDIOMS from each
|
|
112
|
+
* other. That is the point of the whole module: if `untokenised-radius` is
|
|
113
|
+
* tested only against `border-radius: 8px`, it is being validated against
|
|
114
|
+
* exactly the rule we are trying not to write again.
|
|
115
|
+
*/
|
|
116
|
+
export const FIXTURES = {
|
|
117
|
+
"untokenised-radius": {
|
|
118
|
+
bad: [
|
|
119
|
+
{ css: ".a { border-radius: 8px }" },
|
|
120
|
+
{ source: '<div className="rounded-lg" />', filename: "a.tsx" },
|
|
121
|
+
{ source: '<div className="rounded-3xl" />', filename: "a.tsx" }, // the spelling one hand-written audit missed
|
|
122
|
+
{ source: "const s = { borderRadius: 8 }", filename: "a.ts" },
|
|
123
|
+
{ source: '<div className="rounded-[7px]" />', filename: "a.tsx" },
|
|
124
|
+
],
|
|
125
|
+
good: [
|
|
126
|
+
{ css: ".a { border-radius: var(--radius-card) }" },
|
|
127
|
+
{ css: ".a { border-radius: 0 }" },
|
|
128
|
+
{ source: "const s = { borderRadius: radius.control }", filename: "a.ts" },
|
|
129
|
+
{ source: '<div className="rounded-card" />', filename: "a.tsx" }, // project semantic radius
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
"untokenised-space": {
|
|
133
|
+
bad: [
|
|
134
|
+
{ css: ".a { padding: 12px }" },
|
|
135
|
+
{ css: ".a { margin: 0 auto 12px }" }, // only the third value is wrong
|
|
136
|
+
{ source: '<div className="px-4" />', filename: "a.tsx" },
|
|
137
|
+
{ source: "const s = { gap: 16 }", filename: "a.ts" },
|
|
138
|
+
],
|
|
139
|
+
good: [
|
|
140
|
+
{ css: ".a { padding: var(--space-3) }" },
|
|
141
|
+
{ css: ".a { padding: 0 var(--space-2) }" },
|
|
142
|
+
{ css: ".a { margin: 0 }" },
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
"untokenised-type": {
|
|
146
|
+
bad: [
|
|
147
|
+
{ css: ".a { font-size: 13px }" },
|
|
148
|
+
{ css: ".a { font-size: 1.125rem }" },
|
|
149
|
+
{ source: '<span className="text-3xl" />', filename: "a.tsx" },
|
|
150
|
+
],
|
|
151
|
+
good: [
|
|
152
|
+
{ css: ".a { font-size: var(--text-body) }" },
|
|
153
|
+
{ source: "const s = { fontSize: text.body }", filename: "a.ts" },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
"untokenised-colour": {
|
|
157
|
+
bad: [
|
|
158
|
+
{ css: ".a { color: #ff0000 }" },
|
|
159
|
+
{ css: ".a { background: rgb(12 14 16) }" },
|
|
160
|
+
{ css: ".a { border-color: red }" },
|
|
161
|
+
{ source: '<div className="bg-red-500" />', filename: "a.tsx" },
|
|
162
|
+
{ source: 'el.style.background = "#fff"', filename: "a.ts" },
|
|
163
|
+
],
|
|
164
|
+
good: [
|
|
165
|
+
{ css: ".a { color: var(--color-text) }" },
|
|
166
|
+
{ css: ".a { background: transparent }" },
|
|
167
|
+
{ source: '<div className="bg-primary" />', filename: "a.tsx" }, // shadcn semantic utility
|
|
168
|
+
{ source: '<div className="text-muted-foreground" />', filename: "a.tsx" },
|
|
169
|
+
{ css: ":root { --color-surface: #0b0d10 }" }, // a second palette, legitimately
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
"arbitrary-shadow": {
|
|
173
|
+
bad: [
|
|
174
|
+
{ css: ".a { box-shadow: 0 1px 2px rgba(0,0,0,.2) }" },
|
|
175
|
+
{ source: '<div className="shadow-lg" />', filename: "a.tsx" },
|
|
176
|
+
],
|
|
177
|
+
good: [
|
|
178
|
+
{ css: ".a { box-shadow: var(--shadow-overlay) }" },
|
|
179
|
+
{ css: ".a { box-shadow: none }" },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load the rules, refusing any that is not proved in both directions.
|
|
186
|
+
*
|
|
187
|
+
* Throwing rather than warning is deliberate. A warning at load time is read
|
|
188
|
+
* once and then lives in a log nobody opens; the failure mode we are guarding
|
|
189
|
+
* against is precisely a rule that is present, believed, and not working.
|
|
190
|
+
*/
|
|
191
|
+
export function loadRules({ fixtures = FIXTURES } = {}) {
|
|
192
|
+
const missing = [];
|
|
193
|
+
for (const rule of RULES) {
|
|
194
|
+
const f = fixtures[rule.id];
|
|
195
|
+
if (!f || !Array.isArray(f.bad) || f.bad.length === 0) missing.push(`${rule.id}: no failing fixture`);
|
|
196
|
+
if (!f || !Array.isArray(f.good) || f.good.length === 0) missing.push(`${rule.id}: no passing fixture`);
|
|
197
|
+
}
|
|
198
|
+
if (missing.length) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"Refusing to load rules that are not proved in both directions:\n " + missing.join("\n ") +
|
|
201
|
+
"\nA rule with no failing fixture reports zero and looks identical to a rule that works.",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return RULES;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run every rule over a set of normalised declarations.
|
|
209
|
+
*
|
|
210
|
+
* `classification` decides whether findings apply: the design system's own
|
|
211
|
+
* source defines the tokens rather than consuming them, and foreign-content
|
|
212
|
+
* containment is not a visual vocabulary. Both are reported in their own
|
|
213
|
+
* buckets rather than filtered away, so the denominator never moves silently.
|
|
214
|
+
*/
|
|
215
|
+
export function run(decls, { config = DEFAULTS, classification = CLASSIFICATION.PRODUCT } = {}) {
|
|
216
|
+
const rules = loadRules();
|
|
217
|
+
const findings = [];
|
|
218
|
+
|
|
219
|
+
if (classification === CLASSIFICATION.PRODUCT) {
|
|
220
|
+
for (const d of decls) {
|
|
221
|
+
for (const rule of rules) {
|
|
222
|
+
if (rule.property !== d.property) continue;
|
|
223
|
+
if (!rule.test(d, config)) continue;
|
|
224
|
+
findings.push({
|
|
225
|
+
rule: rule.id,
|
|
226
|
+
property: d.property,
|
|
227
|
+
raw: d.raw,
|
|
228
|
+
line: d.line ?? null,
|
|
229
|
+
source: d.source,
|
|
230
|
+
// Carried through so a reviewer can tell a measured violation from an
|
|
231
|
+
// inferred one. A finding derived from an assumed Tailwind scale is
|
|
232
|
+
// still a finding, but it is not the same evidence as a literal.
|
|
233
|
+
confidence: d.confidence,
|
|
234
|
+
scaleHint: d.scaleHint,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { classification, findings, counted: decls.length };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Coverage: which rules matched nothing anywhere.
|
|
245
|
+
*
|
|
246
|
+
* "A rule that matched zero files across an entire repository is reported as
|
|
247
|
+
* SUSPICIOUS, not as clean." This is the direct countermeasure to the
|
|
248
|
+
* failure, where `no-rounded-lg` reported zero against 178 real hits and nobody
|
|
249
|
+
* could tell. Silence and success must not render the same.
|
|
250
|
+
*/
|
|
251
|
+
export function coverage(allFindings, { rules = loadRules() } = {}) {
|
|
252
|
+
const seen = new Set(allFindings.map((f) => f.rule));
|
|
253
|
+
return rules.map((r) => ({
|
|
254
|
+
rule: r.id,
|
|
255
|
+
matched: seen.has(r.id),
|
|
256
|
+
verdict: seen.has(r.id) ? "firing" : "suspicious — matched nothing; verify it can still match at all",
|
|
257
|
+
}));
|
|
258
|
+
}
|
package/scan.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-51/G-52 — walk a project, normalise it, run the rules, report honestly.
|
|
3
|
+
*
|
|
4
|
+
* THE MEASUREMENT BUGS THIS IS BUILT AGAINST.
|
|
5
|
+
*
|
|
6
|
+
* Both prior implementations shipped a correct rule set and still reported the
|
|
7
|
+
* wrong number, twice, for reasons that had nothing to do with the rules:
|
|
8
|
+
*
|
|
9
|
+
* - System B measured only `apps/pubs/src` for a long stretch, then widened. Adding
|
|
10
|
+
* a newly-measured app moved the headline DOWN while no file got worse, so
|
|
11
|
+
* their gate had to compare per-app rather than on the average.
|
|
12
|
+
* - `apps/map` and `apps/pints` reported **0% adoption** while composing a
|
|
13
|
+
* shared package that renders design-system UI, because the import graph was
|
|
14
|
+
* resolved at barrel level rather than per name.
|
|
15
|
+
*
|
|
16
|
+
* Both are scope errors, not rule errors. So this module's contract is that
|
|
17
|
+
* **scope is always explicit and always reported**: every file walked is
|
|
18
|
+
* counted, every file skipped is named with a reason, and a percentage is never
|
|
19
|
+
* emitted without the denominator beside it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
23
|
+
import { join, relative, extname, sep } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { normaliseSource } from "./normalise.mjs";
|
|
26
|
+
import { run, coverage, CLASSIFICATION, DEFAULTS, loadRules } from "./rules.mjs";
|
|
27
|
+
import { optionalProps, requiredNullableGuards, RULE as OPTIONAL_PROP_RULE, GUARD_RULE } from "./props.mjs";
|
|
28
|
+
import { orphanedParts, RULE as COMPOUND_RULE } from "./compound.mjs";
|
|
29
|
+
import { stalePaths, rootDirectories, PATH_RULE } from "./docdrift.mjs";
|
|
30
|
+
import { markupAdoption } from "./markup.mjs";
|
|
31
|
+
import { reconcile, formatAdoption } from "./adoption.mjs";
|
|
32
|
+
import { tailwindInCss } from "./tailwind.mjs";
|
|
33
|
+
|
|
34
|
+
const SKIP_DIRS = new Set([
|
|
35
|
+
"node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage",
|
|
36
|
+
"test-results", "__pycache__", ".venv", "e2e", "fixtures", "snapshots",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const READ = new Set([".css", ".scss", ".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
40
|
+
|
|
41
|
+
/** A file whose string literals are source for a DIFFERENT repository. */
|
|
42
|
+
const TEMPLATE_MARKER = "@gyde-emits-source-for-another-repo";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A test file describes behaviour; it is not a surface anybody sees.
|
|
46
|
+
*
|
|
47
|
+
* `[jt]sx?` missed `.mjs` and `.cjs`, so `foo.test.mjs` was walked as product
|
|
48
|
+
* code. It cost nothing while the rules only read styling — a test file has
|
|
49
|
+
* little — and cost forty false findings the moment a rule started reading
|
|
50
|
+
* string literals, because a test's fixtures are the one place paths and
|
|
51
|
+
* component APIs are written to be wrong on purpose.
|
|
52
|
+
*/
|
|
53
|
+
const isTest = (p) => /\.(test|spec)\.(?:[cm]?[jt]sx?)$/.test(p) || /__tests__/.test(p);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How a file is classified, and why the answer must be declared rather than guessed.
|
|
57
|
+
*
|
|
58
|
+
* `system` and `foreign` both suppress findings, so a wrong classification is a
|
|
59
|
+
* silent pass — the failure mode this whole product exists to prevent. They are
|
|
60
|
+
* therefore taken from configuration the project commits, never inferred from a
|
|
61
|
+
* path heuristic, and every classified path is echoed in the report so a
|
|
62
|
+
* reviewer can see what was excused and disagree with it.
|
|
63
|
+
*/
|
|
64
|
+
export function classify(relPath, config) {
|
|
65
|
+
const match = (globs = []) => globs.some((g) => {
|
|
66
|
+
if (g.endsWith("/**")) return relPath.startsWith(g.slice(0, -3));
|
|
67
|
+
if (g.endsWith("/")) return relPath.startsWith(g);
|
|
68
|
+
return relPath === g || relPath.startsWith(g + "/");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (match(config.systemPaths)) return CLASSIFICATION.SYSTEM;
|
|
72
|
+
if (match(config.foreignPaths)) return CLASSIFICATION.FOREIGN;
|
|
73
|
+
return CLASSIFICATION.PRODUCT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function walk(dir, root, out) {
|
|
77
|
+
let entries;
|
|
78
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
79
|
+
for (const name of entries) {
|
|
80
|
+
if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
|
|
81
|
+
const full = join(dir, name);
|
|
82
|
+
let st;
|
|
83
|
+
try { st = statSync(full); } catch { continue; }
|
|
84
|
+
if (st.isDirectory()) { walk(full, root, out); continue; }
|
|
85
|
+
if (!READ.has(extname(name))) continue;
|
|
86
|
+
if (isTest(full)) continue;
|
|
87
|
+
out.push(relative(root, full).split(sep).join("/"));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan a directory tree.
|
|
93
|
+
*
|
|
94
|
+
* `config.systemPaths` / `config.foreignPaths` are project configuration: the
|
|
95
|
+
* design system's own source, and the places that legitimately contain styling
|
|
96
|
+
* for content we do not control. Everything else is product code.
|
|
97
|
+
*/
|
|
98
|
+
export function scan(root, { config = {}, ruleConfig = DEFAULTS } = {}) {
|
|
99
|
+
const cfg = { systemPaths: [], foreignPaths: [], componentRoots: null, ...config };
|
|
100
|
+
|
|
101
|
+
const files = [];
|
|
102
|
+
walk(root, root, files);
|
|
103
|
+
files.sort();
|
|
104
|
+
|
|
105
|
+
// G-70. Computed once: the anchor for deciding whether a path-shaped string
|
|
106
|
+
// is a claim about THIS repository.
|
|
107
|
+
const rootDirs = rootDirectories(root);
|
|
108
|
+
let pathsSkipped = 0;
|
|
109
|
+
|
|
110
|
+
// G-73. Collected during the walk rather than re-read afterwards, so the
|
|
111
|
+
// scope decisions above — product code only, no templates written for another
|
|
112
|
+
// repository — are made once and cannot drift between the two adoption
|
|
113
|
+
// numbers. A metric measured over a different file set than the one it is
|
|
114
|
+
// printed beside is the scope error this module's header exists about.
|
|
115
|
+
const markupFiles = [];
|
|
116
|
+
|
|
117
|
+
// G-98. Counted here because normalise.mjs is the one place that knows what a
|
|
118
|
+
// Tailwind utility is. Counting them again in tailwind.mjs would be a second
|
|
119
|
+
// implementation of the same question, free to disagree with the first.
|
|
120
|
+
let tailwindClasses = 0;
|
|
121
|
+
let tailwindCssDirectives = 0;
|
|
122
|
+
|
|
123
|
+
const findings = [];
|
|
124
|
+
const perFile = [];
|
|
125
|
+
const byClassification = { system: 0, foreign: 0, product: 0 };
|
|
126
|
+
|
|
127
|
+
for (const rel of files) {
|
|
128
|
+
let text;
|
|
129
|
+
try { text = readFileSync(join(root, rel), "utf8"); } catch { continue; }
|
|
130
|
+
|
|
131
|
+
const classification = classify(rel, cfg);
|
|
132
|
+
byClassification[classification]++;
|
|
133
|
+
|
|
134
|
+
// G-68. Before the styling short-circuit below: a file can declare a
|
|
135
|
+
// component API and contain no styling at all, and that file is exactly
|
|
136
|
+
// where a Props type lives.
|
|
137
|
+
// A generator holds another repository's source as string data. Reading it
|
|
138
|
+
// as this repository's own code is how Gyde scanning itself reported ten
|
|
139
|
+
// optional props that live in its emitted TEMPLATES, and thirty-six stale
|
|
140
|
+
// paths that resolve perfectly well in the repository they are written for.
|
|
141
|
+
//
|
|
142
|
+
// The marker is the convention paths.test.mjs and clientboundary.mjs
|
|
143
|
+
// already use. It is an opt-out a file must DECLARE, not a heuristic — and
|
|
144
|
+
// it does not weaken the checks in a consumer repository, where the
|
|
145
|
+
// emitted components are real files that say no such thing.
|
|
146
|
+
const isTemplateSource = text.includes(TEMPLATE_MARKER);
|
|
147
|
+
|
|
148
|
+
if (classification === CLASSIFICATION.PRODUCT && !isTemplateSource) {
|
|
149
|
+
for (const f of optionalProps(text, { filename: rel })) findings.push(f);
|
|
150
|
+
// G-69. The defect G-68 creates. The ban pushes optional props towards
|
|
151
|
+
// required-nullable, and every guard already written against undefined
|
|
152
|
+
// becomes always-true at that moment, with no compiler error.
|
|
153
|
+
for (const f of requiredNullableGuards(text, { filename: rel })) findings.push(f);
|
|
154
|
+
// G-72. `componentRoots` is the design system's own exports, passed in
|
|
155
|
+
// rather than read here — scan walks a tree, it does not resolve barrels.
|
|
156
|
+
// Absent, the check is skipped rather than run against every `Foo.Bar` in
|
|
157
|
+
// JSX position, because a rule with no allow-list here would be guessing.
|
|
158
|
+
if (cfg.componentRoots) {
|
|
159
|
+
for (const f of orphanedParts(text, { filename: rel, roots: cfg.componentRoots })) findings.push(f);
|
|
160
|
+
}
|
|
161
|
+
// G-70, second half. A path rendered to a reader is documentation
|
|
162
|
+
// whatever file it lives in — a consumer lost six of twenty registry
|
|
163
|
+
// entries to a migration because their doc gate only read markdown.
|
|
164
|
+
const paths = stalePaths(root, rel, text, { rootDirs });
|
|
165
|
+
for (const f of paths.found) findings.push(f);
|
|
166
|
+
pathsSkipped += paths.skipped.length;
|
|
167
|
+
if (/\.(tsx|jsx)$/.test(rel)) markupFiles.push({ file: rel, text });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const decls = normaliseSource(text, { filename: rel });
|
|
171
|
+
if (decls.length === 0) continue;
|
|
172
|
+
|
|
173
|
+
for (const d of decls) if (d.source === "tailwind") tailwindClasses++;
|
|
174
|
+
// G-98, fourth level. A stylesheet can carry the whole vocabulary with no
|
|
175
|
+
// class in any component, and it breaks on removal the same silent way.
|
|
176
|
+
if (/\.(css|scss)$/.test(rel)) tailwindCssDirectives += tailwindInCss(text, { filename: rel }).length;
|
|
177
|
+
|
|
178
|
+
const result = run(decls, { config: ruleConfig, classification });
|
|
179
|
+
const tokenised = decls.filter((d) => d.tokenised || d.neutral).length;
|
|
180
|
+
|
|
181
|
+
perFile.push({
|
|
182
|
+
file: rel,
|
|
183
|
+
classification,
|
|
184
|
+
declarations: decls.length,
|
|
185
|
+
tokenised,
|
|
186
|
+
findings: result.findings.length,
|
|
187
|
+
});
|
|
188
|
+
for (const f of result.findings) findings.push({ ...f, file: rel });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const product = perFile.filter((f) => f.classification === CLASSIFICATION.PRODUCT);
|
|
192
|
+
const declarations = product.reduce((n, f) => n + f.declarations, 0);
|
|
193
|
+
const tokenised = product.reduce((n, f) => n + f.tokenised, 0);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
root,
|
|
197
|
+
// Scope, always reported beside the numbers it produced.
|
|
198
|
+
scope: {
|
|
199
|
+
filesWalked: files.length,
|
|
200
|
+
filesWithStyling: perFile.length,
|
|
201
|
+
byClassification,
|
|
202
|
+
systemPaths: cfg.systemPaths,
|
|
203
|
+
foreignPaths: cfg.foreignPaths,
|
|
204
|
+
},
|
|
205
|
+
// Adoption as a fraction with its denominator visible, never a bare percent.
|
|
206
|
+
adoption: { tokenised, of: declarations, percent: declarations ? Math.round((tokenised / declarations) * 100) : null },
|
|
207
|
+
/**
|
|
208
|
+
* G-73. A different question, deliberately reported separately rather than
|
|
209
|
+
* folded into the number above.
|
|
210
|
+
*
|
|
211
|
+
* Declaration adoption asks what fraction of style decisions are tokenised.
|
|
212
|
+
* A page built entirely from bare divs with semantic utility classes scores
|
|
213
|
+
* beautifully on it and is not using the component set at all. Markup
|
|
214
|
+
* adoption asks what fraction of the elements that made a visual decision
|
|
215
|
+
* are the system's.
|
|
216
|
+
*
|
|
217
|
+
* Neither is wrong and neither replaces the other. Averaging them would
|
|
218
|
+
* produce a third number describing nothing. Reconciling them is G-74.
|
|
219
|
+
*/
|
|
220
|
+
markup: markupAdoption(markupFiles, { systemNames: cfg.componentRoots }),
|
|
221
|
+
findings,
|
|
222
|
+
/**
|
|
223
|
+
* Every rule this scan RAN, whether or not it found anything.
|
|
224
|
+
*
|
|
225
|
+
* The ledger stamps this (G-68), and the first version stamped
|
|
226
|
+
* `loadRules()` instead — which knows only the five Decl rules. The two
|
|
227
|
+
* Props rules were therefore never in the stamp, so they were unknown to
|
|
228
|
+
* every ledger, adopted on every run, and could never ratchet. A rule that
|
|
229
|
+
* reports forever and gates never is the exact silence this product
|
|
230
|
+
* exists to prevent, and it was introduced by the mechanism meant to let
|
|
231
|
+
* rules arrive safely.
|
|
232
|
+
*
|
|
233
|
+
* Derived here rather than assembled by the caller, so a rule added to
|
|
234
|
+
* this function cannot be left out of the stamp.
|
|
235
|
+
*/
|
|
236
|
+
rulesRun: [...new Set([
|
|
237
|
+
...loadRules().map((r) => r.id),
|
|
238
|
+
OPTIONAL_PROP_RULE, GUARD_RULE,
|
|
239
|
+
// Only when it actually ran. Stamping a rule the scan SKIPPED is the same
|
|
240
|
+
// bug in reverse: the ledger would claim to know it, so the day roots
|
|
241
|
+
// become available its first finding fails instead of adopting — the
|
|
242
|
+
// surprise red build the stamp exists to prevent.
|
|
243
|
+
PATH_RULE,
|
|
244
|
+
...(cfg.componentRoots ? [COMPOUND_RULE] : []),
|
|
245
|
+
])].sort(),
|
|
246
|
+
pathsSkipped,
|
|
247
|
+
tailwindClasses,
|
|
248
|
+
tailwindCssDirectives,
|
|
249
|
+
byRule: tally(findings, (f) => f.rule),
|
|
250
|
+
byFile: tally(findings, (f) => f.file),
|
|
251
|
+
// Which rules never fired. Silence and success must not render the same.
|
|
252
|
+
coverage: coverage(findings),
|
|
253
|
+
perFile,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function tally(items, key) {
|
|
258
|
+
const out = {};
|
|
259
|
+
for (const i of items) out[key(i)] = (out[key(i)] || 0) + 1;
|
|
260
|
+
return Object.fromEntries(Object.entries(out).sort((a, b) => b[1] - a[1]));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A short human report.
|
|
265
|
+
*
|
|
266
|
+
* Prints the denominator, the classified-out paths, and the rules that matched
|
|
267
|
+
* nothing — in that order, because those are the three things that make a
|
|
268
|
+
* headline number either trustworthy or meaningless.
|
|
269
|
+
*/
|
|
270
|
+
export function format(result) {
|
|
271
|
+
const L = [];
|
|
272
|
+
const { scope, adoption } = result;
|
|
273
|
+
L.push(`scope ${scope.filesWithStyling} files with styling, of ${scope.filesWalked} walked`);
|
|
274
|
+
L.push(` product ${scope.byClassification.product}, system ${scope.byClassification.system}, foreign ${scope.byClassification.foreign}`);
|
|
275
|
+
// G-74. One figure, defined as the weakest layer, with both layers under it.
|
|
276
|
+
// Printing two headline percentages was the state this replaces: a reader had
|
|
277
|
+
// to decide which one meant "adopted", and two teams reading the same output
|
|
278
|
+
// reached different answers.
|
|
279
|
+
L.push(formatAdoption(reconcile({
|
|
280
|
+
token: { ...adoption, used: adoption.tokenised },
|
|
281
|
+
markup: result.markup?.unknown ? null : result.markup?.adoption,
|
|
282
|
+
})));
|
|
283
|
+
L.push(`findings ${result.findings.length}`);
|
|
284
|
+
for (const [rule, n] of Object.entries(result.byRule)) L.push(` ${String(n).padStart(5)} ${rule}`);
|
|
285
|
+
const silent = result.coverage.filter((c) => !c.matched);
|
|
286
|
+
if (silent.length) {
|
|
287
|
+
L.push(`suspicious ${silent.length} rule(s) matched nothing anywhere:`);
|
|
288
|
+
for (const s of silent) L.push(` ${s.rule}`);
|
|
289
|
+
}
|
|
290
|
+
return L.join("\n");
|
|
291
|
+
}
|