@weatherboard/gyde-design 0.4.3 → 0.4.4
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 +82 -0
- package/cli.mjs +66 -1
- package/floor.mjs +479 -0
- package/htmlscan.mjs +149 -0
- package/index.mjs +1 -0
- package/package.json +5 -1
- package/theme-entry.mjs +5 -1
- package/themehost.mjs +4 -38
- package/vendor/README.md +43 -0
- package/vendor/acorn.mjs +6313 -0
package/floor.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-133 — a scoped adoption floor, and the exceptions that are allowed to sit
|
|
3
|
+
* outside it.
|
|
4
|
+
*
|
|
5
|
+
* ===========================================================================
|
|
6
|
+
* WHY A FLOOR IS NOT A LEDGER
|
|
7
|
+
* ===========================================================================
|
|
8
|
+
*
|
|
9
|
+
* `ratchet.mjs` already answers "has this got worse?" — it records debt and
|
|
10
|
+
* judges the delta. That is the right instrument for a repository with 4,061
|
|
11
|
+
* existing findings and the wrong one for the opposite case: a product that
|
|
12
|
+
* has finished the work over a defined slice of its tree and wants the
|
|
13
|
+
* finishing to stay finished.
|
|
14
|
+
*
|
|
15
|
+
* A ledger cannot express that. An empty ledger for a scope says "nothing was
|
|
16
|
+
* recorded here", which is also what a scope nobody measured says, and the two
|
|
17
|
+
* must never render the same (CHARTER §5). A floor says something a ledger
|
|
18
|
+
* cannot: *of the eligible visual elements under this path, this many must be
|
|
19
|
+
* the system's, and here is the denominator.*
|
|
20
|
+
*
|
|
21
|
+
* ===========================================================================
|
|
22
|
+
* IT IS OPT-IN, AND THAT IS LOAD-BEARING RATHER THAN POLITE
|
|
23
|
+
* ===========================================================================
|
|
24
|
+
*
|
|
25
|
+
* There is no implicit floor and no repository-wide default. A product that
|
|
26
|
+
* declares nothing is measured exactly as it is today. CHARTER §2 keeps the
|
|
27
|
+
* DEFINITION of adoption Gyde's — a product cannot redefine the metric — but
|
|
28
|
+
* *where* a product has decided to hold a line is a product decision, and a
|
|
29
|
+
* floor Gyde imposed on a tree nobody had finished would be a gate that is red
|
|
30
|
+
* on arrival, which is a gate people switch off.
|
|
31
|
+
*
|
|
32
|
+
* ===========================================================================
|
|
33
|
+
* 100% MEANS ZERO, AND IS NEVER ROUNDED INTO EXISTENCE
|
|
34
|
+
* ===========================================================================
|
|
35
|
+
*
|
|
36
|
+
* `Math.round(999 / 1000 * 100)` is 100, and a report that prints "100%" over a
|
|
37
|
+
* denominator holding one bespoke element is a lie that reads as an
|
|
38
|
+
* achievement. So the comparison here is made on the COUNTS, not on the
|
|
39
|
+
* percentage: a floor of 100 is met when the bespoke count is zero, full stop.
|
|
40
|
+
* The percentage is a rendering of the fraction, floored, and it never appears
|
|
41
|
+
* without its numerator, its denominator and the scope it was taken over.
|
|
42
|
+
*
|
|
43
|
+
* ===========================================================================
|
|
44
|
+
* EXCEPTIONS ARE NAMED, REASONED, AND REPORTED OUTSIDE THE FIGURE
|
|
45
|
+
* ===========================================================================
|
|
46
|
+
*
|
|
47
|
+
* Some elements are not a missed component and never will be — a containment
|
|
48
|
+
* frame whose whole job is to be a sized box around something else. Counting
|
|
49
|
+
* them against a product teaches it to ignore the number; hiding them inside
|
|
50
|
+
* the NUMERATOR teaches it that adoption went up when nothing was adopted.
|
|
51
|
+
*
|
|
52
|
+
* So an exception leaves the denominator instead, individually, by name, with
|
|
53
|
+
* a reason, and is printed in its own block underneath the figure. The reader
|
|
54
|
+
* sees "17 of 17 (100%), 6 elements excepted" and can disagree with all six.
|
|
55
|
+
*
|
|
56
|
+
* THEY CANNOT GROW QUIETLY. An exception is a committed line of configuration,
|
|
57
|
+
* so adding one is a diff somebody reviews; and until it is declared the
|
|
58
|
+
* element it would have covered is an ordinary bespoke element that fails the
|
|
59
|
+
* floor. The ledger discipline this repository already applies reaches here
|
|
60
|
+
* unchanged — the list may only ever get shorter without a deliberate,
|
|
61
|
+
* reviewable act.
|
|
62
|
+
*
|
|
63
|
+
* AN EXCEPTION THAT MATCHES NOTHING IS AN ERROR. A stale entry is not
|
|
64
|
+
* harmless: it is a line of prose asserting that a hazard exists at a path
|
|
65
|
+
* where nothing does, and the next reader will believe it. It is also the only
|
|
66
|
+
* way the list could shrink invisibly — the element is refactored away and the
|
|
67
|
+
* exemption stays behind, ready to cover the next thing that lands there.
|
|
68
|
+
*
|
|
69
|
+
* ===========================================================================
|
|
70
|
+
* EVERY PATH FAILS CLOSED, AND THE ZERO-FILE CASE IS THE IMPORTANT ONE
|
|
71
|
+
* ===========================================================================
|
|
72
|
+
*
|
|
73
|
+
* A scope that matches no files would otherwise report 100% forever: no
|
|
74
|
+
* bespoke elements, floor met, green. That is this repository's oldest defect
|
|
75
|
+
* wearing a new hat — "a rule that matches nothing reads exactly like a rule
|
|
76
|
+
* that passes" — so it is an ERROR naming the scope, and so is an
|
|
77
|
+
* unresolvable path, a malformed entry, an unreadable barrel, and an exception
|
|
78
|
+
* naming something that is no longer there.
|
|
79
|
+
*
|
|
80
|
+
* None of them is a finding, none is allowanceable, and none can be turned into
|
|
81
|
+
* a pass by recording a baseline.
|
|
82
|
+
*
|
|
83
|
+
* ===========================================================================
|
|
84
|
+
* WHAT THIS DOES NOT PROTECT AGAINST
|
|
85
|
+
* ===========================================================================
|
|
86
|
+
*
|
|
87
|
+
* Stated plainly, because an unstated limit gets read as a covered one.
|
|
88
|
+
*
|
|
89
|
+
* - **It does not judge the exceptions.** Gyde checks that each one names a
|
|
90
|
+
* real bespoke element and carries a reason somebody wrote. It cannot tell a
|
|
91
|
+
* true reason from a plausible sentence, and a product that is willing to
|
|
92
|
+
* write six false reasons can except six real components. What it buys is
|
|
93
|
+
* that the six are on the artefact, in the diff, with names attached.
|
|
94
|
+
* - **It measures markup, not rendering.** An element classified as the
|
|
95
|
+
* system's is one whose tag is an export of the declared design system. A
|
|
96
|
+
* component that imports the system and then restyles it at the call site
|
|
97
|
+
* still counts as system here; that is `stylex.mjs`'s question, not this
|
|
98
|
+
* one.
|
|
99
|
+
* - **The scope is a path prefix, not a semantic boundary.** A floor over
|
|
100
|
+
* `apps/x/src` covers what is under that path today. Code moved out from
|
|
101
|
+
* under it silently leaves the floor's denominator — the figure stays true
|
|
102
|
+
* and the coverage shrinks, and nothing here notices. The scope is printed
|
|
103
|
+
* with the figure precisely so that a reader can.
|
|
104
|
+
* - **It says nothing about files with no visual decisions in them.** A scope
|
|
105
|
+
* of two hundred files where only three carry markup reports a denominator
|
|
106
|
+
* of three. The denominator is the honest part; whether three is enough
|
|
107
|
+
* evidence is a judgement Gyde does not make for you.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
111
|
+
import { join, relative, sep } from "node:path";
|
|
112
|
+
|
|
113
|
+
import { classifyMarkup, KIND } from "./markup.mjs";
|
|
114
|
+
|
|
115
|
+
/** The config key a product declares floors under. */
|
|
116
|
+
export const KEY = "design.adoptionFloors";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Directories never walked, matching `scan.mjs`.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately a copy rather than an import, and deliberately noted as one:
|
|
122
|
+
* `scan.mjs` does not export it. If a third copy ever appears, that is the
|
|
123
|
+
* moment to hoist it, not before.
|
|
124
|
+
*/
|
|
125
|
+
const SKIP_DIRS = new Set([
|
|
126
|
+
"node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage",
|
|
127
|
+
"test-results", "__pycache__", ".venv", "e2e", "fixtures", "snapshots",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/** Markup lives in JSX. A `.ts` file makes no elements. */
|
|
131
|
+
const MARKUP_EXT = /\.(tsx|jsx)$/;
|
|
132
|
+
|
|
133
|
+
/** Same exclusion `scan.mjs` makes: a test describes behaviour, it is not a surface. */
|
|
134
|
+
const isTest = (p) => /\.(test|spec)\.(?:[cm]?[jt]sx?)$/.test(p) || /__tests__/.test(p);
|
|
135
|
+
|
|
136
|
+
/** A file whose string literals are source for a DIFFERENT repository. */
|
|
137
|
+
const TEMPLATE_MARKER = "@gyde-emits-source-for-another-repo";
|
|
138
|
+
|
|
139
|
+
const FLOOR_KEYS = new Set(["scope", "minimum", "exceptions"]);
|
|
140
|
+
const EXCEPTION_KEYS = new Set(["file", "element", "because"]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A reason has to be a sentence somebody wrote, not a placeholder.
|
|
144
|
+
*
|
|
145
|
+
* Twelve characters is not a quality bar — nothing here can be — it is a
|
|
146
|
+
* tripwire for `"n/a"`, `"todo"` and `"-"`, which is what an empty-string check
|
|
147
|
+
* alone lets through. The same trick `verify.mjs` plays on a truncated
|
|
148
|
+
* contract, for the same reason.
|
|
149
|
+
*/
|
|
150
|
+
const MIN_REASON = 12;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate the declared floors.
|
|
154
|
+
*
|
|
155
|
+
* A SCHEMA check: it needs nothing but the value, so it runs wherever the
|
|
156
|
+
* config is read and costs nothing on a repository with no files yet. Every
|
|
157
|
+
* error names the entry it came from — "adoptionFloors[1].minimum" — because a
|
|
158
|
+
* config error that does not say which entry is a config error somebody fixes
|
|
159
|
+
* by guessing.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} raw `design.adoptionFloors` as committed
|
|
162
|
+
* @returns {{ ok: boolean, declared: boolean, floors: object[], errors: string[] }}
|
|
163
|
+
*/
|
|
164
|
+
export function validateFloors(raw) {
|
|
165
|
+
if (raw === undefined || raw === null) {
|
|
166
|
+
return { ok: true, declared: false, floors: [], errors: [] };
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(raw)) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false, declared: true, floors: [],
|
|
171
|
+
errors: [`${KEY} must be an array of floor objects (got ${typeOf(raw)})`],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (raw.length === 0) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false, declared: true, floors: [],
|
|
177
|
+
errors: [
|
|
178
|
+
`${KEY} is an empty array. Declaring no floors and declaring the key are ` +
|
|
179
|
+
`different intentions, and an empty list would hold no line while looking ` +
|
|
180
|
+
`like it does — remove the key instead.`,
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const errors = [];
|
|
186
|
+
const floors = [];
|
|
187
|
+
const seen = new Map();
|
|
188
|
+
|
|
189
|
+
raw.forEach((floor, i) => {
|
|
190
|
+
const at = `${KEY}[${i}]`;
|
|
191
|
+
if (!isPlainObject(floor)) {
|
|
192
|
+
errors.push(`${at} must be an object (got ${typeOf(floor)})`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const key of Object.keys(floor)) {
|
|
196
|
+
if (!FLOOR_KEYS.has(key)) {
|
|
197
|
+
errors.push(`${at}.${key} is not a key Gyde knows. A key Gyde ignores reads as a ` +
|
|
198
|
+
`declaration Gyde honours; known keys are ${[...FLOOR_KEYS].join(", ")}.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const scope = floor.scope;
|
|
203
|
+
if (typeof scope !== "string" || scope.trim() === "") {
|
|
204
|
+
errors.push(`${at}.scope must be a non-empty path relative to the repository root, such as "apps/web/src"`);
|
|
205
|
+
} else if (scope.startsWith("/") || /(^|\/)\.\.(\/|$)/.test(scope)) {
|
|
206
|
+
errors.push(`${at}.scope must stay inside the repository: "${scope}" is absolute or escapes the root`);
|
|
207
|
+
} else if (seen.has(norm(scope))) {
|
|
208
|
+
errors.push(`${at}.scope "${scope}" is already declared at ${KEY}[${seen.get(norm(scope))}]. ` +
|
|
209
|
+
`Two floors over one scope is two answers to one question — merge them.`);
|
|
210
|
+
} else {
|
|
211
|
+
seen.set(norm(scope), i);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const min = floor.minimum;
|
|
215
|
+
if (typeof min !== "number" || !Number.isFinite(min) || !Number.isInteger(min) || min < 0 || min > 100) {
|
|
216
|
+
errors.push(`${at}.minimum must be a whole number from 0 to 100 (got ${JSON.stringify(min)})`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const exceptions = floor.exceptions ?? [];
|
|
220
|
+
if (!Array.isArray(exceptions)) {
|
|
221
|
+
errors.push(`${at}.exceptions must be an array (got ${typeOf(exceptions)})`);
|
|
222
|
+
} else {
|
|
223
|
+
exceptions.forEach((ex, j) => {
|
|
224
|
+
const eat = `${at}.exceptions[${j}]`;
|
|
225
|
+
if (!isPlainObject(ex)) {
|
|
226
|
+
errors.push(`${eat} must be an object (got ${typeOf(ex)})`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
for (const key of Object.keys(ex)) {
|
|
230
|
+
if (!EXCEPTION_KEYS.has(key)) {
|
|
231
|
+
errors.push(`${eat}.${key} is not a key Gyde knows; known keys are ${[...EXCEPTION_KEYS].join(", ")}.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (typeof ex.file !== "string" || ex.file.trim() === "") {
|
|
235
|
+
errors.push(`${eat}.file must be the path of the file the element is in, relative to the repository root`);
|
|
236
|
+
}
|
|
237
|
+
if (typeof ex.element !== "string" || !/^[a-z][\w.-]*$/.test(ex.element)) {
|
|
238
|
+
errors.push(`${eat}.element must be the lowercase tag name of the bespoke element, such as "div". ` +
|
|
239
|
+
`A capitalised name is a component, and a component is never bespoke markup.`);
|
|
240
|
+
}
|
|
241
|
+
if (typeof ex.because !== "string" || ex.because.trim().length < MIN_REASON) {
|
|
242
|
+
errors.push(`${eat}.because must say why this element is structural and will never be a component ` +
|
|
243
|
+
`(at least ${MIN_REASON} characters). An exception nobody had to justify is an exemption, not an exception.`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
floors.push({
|
|
249
|
+
scope: typeof scope === "string" ? norm(scope) : scope,
|
|
250
|
+
minimum: min,
|
|
251
|
+
exceptions: Array.isArray(exceptions) ? exceptions : [],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return { ok: errors.length === 0, declared: true, floors: errors.length ? [] : floors, errors };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Every markup-bearing product file under a scope.
|
|
260
|
+
*
|
|
261
|
+
* Returns an `error` rather than an empty list for the two cases that must not
|
|
262
|
+
* look like a clean measurement: the path is not there, and the path is there
|
|
263
|
+
* and holds nothing to measure.
|
|
264
|
+
*/
|
|
265
|
+
export function collectScopeFiles(root, scope) {
|
|
266
|
+
const base = join(root, scope);
|
|
267
|
+
let st;
|
|
268
|
+
try { st = statSync(base); } catch {
|
|
269
|
+
return {
|
|
270
|
+
files: [], error:
|
|
271
|
+
`scope "${scope}" does not resolve to anything in this repository. ` +
|
|
272
|
+
`A floor over a path that is not there would measure nothing and pass forever.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (!st.isDirectory() && !MARKUP_EXT.test(scope)) {
|
|
276
|
+
return { files: [], error: `scope "${scope}" is a file Gyde cannot read markup from; name a directory or a .tsx/.jsx file` };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const out = [];
|
|
280
|
+
const walk = (dir) => {
|
|
281
|
+
let entries;
|
|
282
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
283
|
+
for (const name of entries.sort()) {
|
|
284
|
+
if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
|
|
285
|
+
const full = join(dir, name);
|
|
286
|
+
let s; try { s = statSync(full); } catch { continue; }
|
|
287
|
+
if (s.isDirectory()) { walk(full); continue; }
|
|
288
|
+
if (!MARKUP_EXT.test(name) || isTest(full)) continue;
|
|
289
|
+
let text; try { text = readFileSync(full, "utf8"); } catch { continue; }
|
|
290
|
+
// A generator holding another repository's source as string data is not
|
|
291
|
+
// this repository's markup, exactly as `scan.mjs` decides.
|
|
292
|
+
if (text.includes(TEMPLATE_MARKER)) continue;
|
|
293
|
+
out.push({ file: relative(root, full).split(sep).join("/"), text });
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (st.isDirectory()) walk(base);
|
|
298
|
+
else {
|
|
299
|
+
let text; try { text = readFileSync(base, "utf8"); } catch { text = null; }
|
|
300
|
+
if (text !== null && !text.includes(TEMPLATE_MARKER)) {
|
|
301
|
+
out.push({ file: norm(scope), text });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (out.length === 0) {
|
|
306
|
+
return {
|
|
307
|
+
files: [], error:
|
|
308
|
+
`scope "${scope}" matched no markup files. This is an error rather than 100%: ` +
|
|
309
|
+
`a floor over an empty denominator reports success forever, and a rule that ` +
|
|
310
|
+
`matches nothing reads exactly like a rule that passes.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return { files: out.sort((a, b) => a.file.localeCompare(b.file)), error: null };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Measure one floor over an already-collected file set.
|
|
318
|
+
*
|
|
319
|
+
* Takes the files rather than walking, for the reason `markupAdoption` does:
|
|
320
|
+
* the scope decision is made in one place and cannot drift between the
|
|
321
|
+
* measurement and the thing printed beside it.
|
|
322
|
+
*
|
|
323
|
+
* @returns a result carrying `used`, `of`, `percent`, the scope, the exceptions
|
|
324
|
+
* it applied, and — on any fail-closed path — `errors`.
|
|
325
|
+
*/
|
|
326
|
+
export function measureFloor(floor, files, { systemNames = null } = {}) {
|
|
327
|
+
const base = { scope: floor.scope, minimum: floor.minimum, used: null, of: null, percent: null,
|
|
328
|
+
bespoke: [], exceptions: [], errors: [], ok: false };
|
|
329
|
+
|
|
330
|
+
if (!systemNames || systemNames.size === 0) {
|
|
331
|
+
return { ...base, errors: [
|
|
332
|
+
`scope "${floor.scope}": the design system's exports could not be read, so no element ` +
|
|
333
|
+
`can be attributed to it. An unmeasurable floor is an error, never a pass — ` +
|
|
334
|
+
`"Gyde did not measure this" and "Gyde found nothing here" must not render the same.`,
|
|
335
|
+
] };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Every bespoke element under the scope, in file then line order. */
|
|
339
|
+
const bespoke = [];
|
|
340
|
+
let system = 0;
|
|
341
|
+
for (const { file, text } of files) {
|
|
342
|
+
const r = classifyMarkup(text, { systemNames });
|
|
343
|
+
if (r.unknown) continue;
|
|
344
|
+
system += r.counts[KIND.SYSTEM];
|
|
345
|
+
for (const el of r.bespokeElements) bespoke.push({ file, element: el.name, line: el.line });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Apply the exceptions, one element each.
|
|
350
|
+
*
|
|
351
|
+
* One entry removes ONE element. An entry that covered every `div` in a file
|
|
352
|
+
* would let a seventh arrive under an exemption written for six, which is
|
|
353
|
+
* the silent expansion this whole mechanism exists to prevent.
|
|
354
|
+
*/
|
|
355
|
+
const remaining = [...bespoke];
|
|
356
|
+
const applied = [];
|
|
357
|
+
const unmatched = [];
|
|
358
|
+
for (const ex of floor.exceptions) {
|
|
359
|
+
const at = remaining.findIndex((b) => b.file === norm(ex.file) && b.element === ex.element);
|
|
360
|
+
if (at === -1) { unmatched.push(ex); continue; }
|
|
361
|
+
applied.push({ ...remaining[at], because: ex.because });
|
|
362
|
+
remaining.splice(at, 1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (unmatched.length) {
|
|
366
|
+
return { ...base, exceptions: applied, errors: unmatched.map((ex) =>
|
|
367
|
+
`scope "${floor.scope}": the declared exception for <${ex.element}> in ${ex.file} matches no ` +
|
|
368
|
+
`bespoke element there. Either it was refactored away — in which case delete the exception, ` +
|
|
369
|
+
`because a stale one is an exemption waiting for the next element to land under it — or the ` +
|
|
370
|
+
`path is wrong and the real element is still in the denominator.`) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const of = system + remaining.length;
|
|
374
|
+
if (of === 0) {
|
|
375
|
+
return { ...base, exceptions: applied, errors: [
|
|
376
|
+
`scope "${floor.scope}": ${files.length} file(s) were read and not one of them made a visual ` +
|
|
377
|
+
`decision, so there is nothing to hold a floor over. This is not 100%.`,
|
|
378
|
+
] };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The verdict is taken on the COUNTS, never on the percentage.
|
|
383
|
+
*
|
|
384
|
+
* 999 of 1000 floors to 99 here, so this happens to agree — but it agrees by
|
|
385
|
+
* luck of the rounding direction, and a floor of 99 met by a rounded 99 is
|
|
386
|
+
* the same defect one point down. Comparing the fraction against the floor
|
|
387
|
+
* directly removes the question.
|
|
388
|
+
*/
|
|
389
|
+
const percent = Math.floor((system / of) * 100);
|
|
390
|
+
const ok = floor.minimum === 100 ? remaining.length === 0 : system * 100 >= floor.minimum * of;
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
scope: floor.scope, minimum: floor.minimum,
|
|
394
|
+
used: system, of, percent,
|
|
395
|
+
files: files.length,
|
|
396
|
+
bespoke: remaining,
|
|
397
|
+
exceptions: applied,
|
|
398
|
+
errors: [],
|
|
399
|
+
ok,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Check every declared floor against the repository.
|
|
405
|
+
*
|
|
406
|
+
* `errors` and `failures` are kept apart on purpose: an error means Gyde could
|
|
407
|
+
* not take the measurement and the run is invalid; a failure means it took the
|
|
408
|
+
* measurement and the line was not held. Both block, and a reader has to be
|
|
409
|
+
* able to tell which happened.
|
|
410
|
+
*/
|
|
411
|
+
export function checkFloors(root, design = {}, { systemNames = null } = {}) {
|
|
412
|
+
const schema = validateFloors(design.adoptionFloors);
|
|
413
|
+
if (!schema.declared) {
|
|
414
|
+
return { declared: false, ok: true, results: [], errors: [], failures: [] };
|
|
415
|
+
}
|
|
416
|
+
if (!schema.ok) {
|
|
417
|
+
return { declared: true, ok: false, results: [], errors: schema.errors, failures: [] };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const results = [];
|
|
421
|
+
const errors = [];
|
|
422
|
+
for (const floor of schema.floors) {
|
|
423
|
+
const { files, error } = collectScopeFiles(root, floor.scope);
|
|
424
|
+
if (error) {
|
|
425
|
+
errors.push(error);
|
|
426
|
+
results.push({ scope: floor.scope, minimum: floor.minimum, used: null, of: null,
|
|
427
|
+
percent: null, bespoke: [], exceptions: [], errors: [error], ok: false });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const r = measureFloor(floor, files, { systemNames });
|
|
431
|
+
results.push(r);
|
|
432
|
+
for (const e of r.errors) errors.push(e);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const failures = results.filter((r) => r.errors.length === 0 && !r.ok);
|
|
436
|
+
return { declared: true, ok: errors.length === 0 && failures.length === 0, results, errors, failures };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The gate's lines for this check. Never a bare percentage. */
|
|
440
|
+
export function formatFloors(result) {
|
|
441
|
+
if (!result.declared) {
|
|
442
|
+
return `adoption floors none declared — ${KEY} is opt-in, so nothing is held to a line here`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const L = [`adoption floors ${result.results.length} declared`];
|
|
446
|
+
for (const r of result.results) {
|
|
447
|
+
if (r.errors.length) {
|
|
448
|
+
L.push(` ✖ ${r.scope} COULD NOT MEASURE`);
|
|
449
|
+
for (const e of r.errors) L.push(` ${e}`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const mark = r.ok ? "✔" : "✖";
|
|
453
|
+
L.push(` ${mark} ${r.scope} ${r.used} of ${r.of} eligible element(s) are the system ` +
|
|
454
|
+
`— ${r.percent}%, floor ${r.minimum}% (${r.files} file(s) read)`);
|
|
455
|
+
if (r.exceptions.length) {
|
|
456
|
+
L.push(` ${r.exceptions.length} structural exception(s), excluded from the denominator above and listed here rather than counted as adoption:`);
|
|
457
|
+
for (const e of r.exceptions) L.push(` <${e.element}> ${e.file}:${e.line} — ${e.because}`);
|
|
458
|
+
}
|
|
459
|
+
if (!r.ok) {
|
|
460
|
+
L.push(` ${r.bespoke.length} bespoke element(s) are below the floor and are not declared exceptions:`);
|
|
461
|
+
for (const b of r.bespoke.slice(0, 10)) L.push(` <${b.element}> ${b.file}:${b.line}`);
|
|
462
|
+
if (r.bespoke.length > 10) L.push(` … and ${r.bespoke.length - 10} more`);
|
|
463
|
+
L.push(` Use the component set, or declare each one in ${KEY} with a reason.`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (result.errors.length) {
|
|
468
|
+
L.push("");
|
|
469
|
+
L.push(" A floor Gyde could not measure is an error, not a pass. Nothing here is allowanceable:");
|
|
470
|
+
L.push(" a floor is a line the product drew, and a ledger cannot record a line as debt.");
|
|
471
|
+
}
|
|
472
|
+
return L.join("\n");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// -------------------------------------------------------------------- bits
|
|
476
|
+
|
|
477
|
+
const norm = (p) => String(p).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
478
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
479
|
+
const typeOf = (v) => (v === null ? "null" : Array.isArray(v) ? "an array" : `a ${typeof v}`);
|
package/htmlscan.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A first-party HTML scanner for the theme-host gate — G-138.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT parse5.
|
|
5
|
+
*
|
|
6
|
+
* `action.yml` runs this package straight out of the action checkout with no
|
|
7
|
+
* install step, so every module it reaches may import `node:` builtins and
|
|
8
|
+
* files inside the action path, and nothing else. `themehost.mjs` imported
|
|
9
|
+
* `parse5`, which is not carried there, and every consumer's gate went red the
|
|
10
|
+
* moment the tag moved (G-138). parse5 v8 is also a fifteen-file ESM tree with
|
|
11
|
+
* its own `entities` dependency, so carrying it would mean carrying a
|
|
12
|
+
* dependency tree by hand and re-copying it on every update.
|
|
13
|
+
*
|
|
14
|
+
* The job here is small and fully specified by `themehost.test.mjs`: find the
|
|
15
|
+
* elements a browser would actually execute in a plain HTML entry, and refuse
|
|
16
|
+
* to be fooled by markup written inside comments, templates, raw text, RCDATA
|
|
17
|
+
* or foreign content. That is worth 120 lines of our own code. Parsing
|
|
18
|
+
* arbitrary JavaScript is not, which is why `theme-entry.mjs` carries a
|
|
19
|
+
* vendored acorn instead of a hand-rolled lexer — see `vendor/README.md`.
|
|
20
|
+
*
|
|
21
|
+
* WHERE IT IS DELIBERATELY STRICTER THAN A BROWSER.
|
|
22
|
+
*
|
|
23
|
+
* Foreign content (`<svg>`, `<math>`) is skipped whole, including the HTML
|
|
24
|
+
* integration points — `foreignObject`, `desc`, `annotation-xml` — where a
|
|
25
|
+
* browser would return to the HTML namespace. A gate may only err towards
|
|
26
|
+
* refusing wiring it cannot prove: the cost is an author being told to move a
|
|
27
|
+
* bootstrap out of an SVG, and the alternative is a false pass. The same
|
|
28
|
+
* applies to `<noscript>`, whose contents are parsed as markup only with
|
|
29
|
+
* scripting disabled, and to `<template>`, whose contents are inert in the
|
|
30
|
+
* host document.
|
|
31
|
+
*
|
|
32
|
+
* Character references are not decoded. Raw text (`script`, `style`) is not
|
|
33
|
+
* entity-decoded by a browser either, and it is the only text this gate
|
|
34
|
+
* compares, so decoding RCDATA would add a difference nothing reads.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Contents are text, never markup, and are not entity-decoded. */
|
|
38
|
+
const RAW_TEXT = new Set(["script", "style"]);
|
|
39
|
+
/** Contents are text, never markup. */
|
|
40
|
+
const RCDATA = new Set(["textarea", "title"]);
|
|
41
|
+
/** Never have contents, so they never open a scope. */
|
|
42
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img",
|
|
43
|
+
"input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
44
|
+
/** Contents are not executed in the host document, so they are not scanned. */
|
|
45
|
+
const OPAQUE = new Set(["template", "noscript", "svg", "math"]);
|
|
46
|
+
|
|
47
|
+
const NAME = /[a-zA-Z][^\s/>]*/y;
|
|
48
|
+
const ATTR = /\s*([^\s/>=]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]*))?/y;
|
|
49
|
+
|
|
50
|
+
/** Skip to the end tag that closes `name`, honouring nesting of the same name. */
|
|
51
|
+
function skipSubtree(html, from, name) {
|
|
52
|
+
let depth = 1;
|
|
53
|
+
const tag = new RegExp(`<(/?)${name}(?=[\\s/>])`, "giy");
|
|
54
|
+
for (let i = from; i < html.length; i += 1) {
|
|
55
|
+
tag.lastIndex = i;
|
|
56
|
+
const m = tag.exec(html);
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
depth += m[1] ? -1 : 1;
|
|
59
|
+
if (depth === 0) return html.indexOf(">", m.index) + 1 || html.length;
|
|
60
|
+
i = m.index;
|
|
61
|
+
}
|
|
62
|
+
return html.length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every element a browser would put in the HTML namespace of this document,
|
|
67
|
+
* in source order, with the byte offset of its start tag.
|
|
68
|
+
*
|
|
69
|
+
* `index` is that offset, so "did the bootstrap come before this stylesheet"
|
|
70
|
+
* is answered from the source rather than from tree order, which an implied
|
|
71
|
+
* `<head>` would otherwise distort.
|
|
72
|
+
*/
|
|
73
|
+
export function htmlElements(html) {
|
|
74
|
+
const nodes = [];
|
|
75
|
+
const stack = [];
|
|
76
|
+
let i = 0;
|
|
77
|
+
const addText = (text) => { if (stack.length) stack.at(-1).text += text; };
|
|
78
|
+
|
|
79
|
+
while (i < html.length) {
|
|
80
|
+
const lt = html.indexOf("<", i);
|
|
81
|
+
if (lt === -1) { addText(html.slice(i)); break; }
|
|
82
|
+
addText(html.slice(i, lt));
|
|
83
|
+
|
|
84
|
+
if (html.startsWith("<!--", lt)) {
|
|
85
|
+
const end = html.indexOf("-->", lt + 4);
|
|
86
|
+
i = end === -1 ? html.length : end + 3;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
|
|
90
|
+
const end = html.indexOf(">", lt);
|
|
91
|
+
i = end === -1 ? html.length : end + 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (html.startsWith("</", lt)) {
|
|
95
|
+
NAME.lastIndex = lt + 2;
|
|
96
|
+
const closing = NAME.exec(html)?.[0]?.toLowerCase();
|
|
97
|
+
const end = html.indexOf(">", lt);
|
|
98
|
+
i = end === -1 ? html.length : end + 1;
|
|
99
|
+
if (closing) {
|
|
100
|
+
const at = stack.findLastIndex((n) => n.name === closing);
|
|
101
|
+
if (at !== -1) stack.length = at;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
NAME.lastIndex = lt + 1;
|
|
107
|
+
const raw = NAME.exec(html)?.[0];
|
|
108
|
+
if (!raw) { addText("<"); i = lt + 1; continue; }
|
|
109
|
+
const name = raw.toLowerCase();
|
|
110
|
+
|
|
111
|
+
const attrs = new Map();
|
|
112
|
+
let cursor = lt + 1 + raw.length;
|
|
113
|
+
let selfClosing = false;
|
|
114
|
+
for (;;) {
|
|
115
|
+
while (html[cursor] === "/" ) { selfClosing = true; cursor += 1; }
|
|
116
|
+
if (cursor >= html.length || html[cursor] === ">") break;
|
|
117
|
+
ATTR.lastIndex = cursor;
|
|
118
|
+
const attr = ATTR.exec(html);
|
|
119
|
+
if (!attr || attr.index + attr[0].length === cursor) break;
|
|
120
|
+
cursor = ATTR.lastIndex;
|
|
121
|
+
const value = attr[2] ?? "";
|
|
122
|
+
attrs.set(attr[1].toLowerCase(), /^["']/.test(value) ? value.slice(1, -1) : value);
|
|
123
|
+
}
|
|
124
|
+
const open = html.indexOf(">", cursor);
|
|
125
|
+
i = open === -1 ? html.length : open + 1;
|
|
126
|
+
|
|
127
|
+
// Foreign and inert subtrees contribute no executable HTML element — not
|
|
128
|
+
// the container, and not anything inside it.
|
|
129
|
+
if (OPAQUE.has(name)) {
|
|
130
|
+
if (!selfClosing) i = skipSubtree(html, i, name);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const node = { name, attrs, parent: stack.at(-1)?.name ?? null, index: lt, text: "" };
|
|
135
|
+
nodes.push(node);
|
|
136
|
+
if (selfClosing || VOID.has(name)) continue;
|
|
137
|
+
|
|
138
|
+
if (RAW_TEXT.has(name) || RCDATA.has(name)) {
|
|
139
|
+
const close = new RegExp(`</${name}(?=[\\s/>])`, "i").exec(html.slice(i));
|
|
140
|
+
const end = close ? i + close.index : html.length;
|
|
141
|
+
node.text = html.slice(i, end);
|
|
142
|
+
const gt = html.indexOf(">", end);
|
|
143
|
+
i = close ? (gt === -1 ? html.length : gt + 1) : html.length;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
stack.push(node);
|
|
147
|
+
}
|
|
148
|
+
return nodes;
|
|
149
|
+
}
|
package/index.mjs
CHANGED
|
@@ -38,4 +38,5 @@ export { classifyMarkup, markupAdoption, formatMarkup, KIND as MARKUP_KIND } fro
|
|
|
38
38
|
export { orphanedParts, formatOrphanedParts, RULE as COMPOUND_RULE } from "./compound.mjs";
|
|
39
39
|
export { checkDocDrift, importedFrom, formatDocDrift, namedInProse, isContractDoc, isFixtureDeclaration, stalePaths, rootDirectories, formatStalePaths, RULE as DOC_DRIFT_RULE, PROSE_RULE, PATH_RULE } from "./docdrift.mjs";
|
|
40
40
|
export { rules, rulesJson } from "./ruleindex.mjs";
|
|
41
|
+
export { validateFloors, collectScopeFiles, measureFloor, checkFloors, formatFloors, KEY as ADOPTION_FLOORS_KEY } from "./floor.mjs";
|
|
41
42
|
export { isClientModule, parseImports, parseReexports, valueUses, checkClientBoundary, formatClientBoundary } from "./clientboundary.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weatherboard/gyde-design",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Scaffolds a design system into a product repository, then keeps auditing it.",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
"./props.mjs": "./props.mjs",
|
|
34
34
|
"./docdrift.mjs": "./docdrift.mjs",
|
|
35
35
|
"./compound.mjs": "./compound.mjs",
|
|
36
|
+
"./htmlscan.mjs": "./htmlscan.mjs",
|
|
36
37
|
"./markup.mjs": "./markup.mjs",
|
|
38
|
+
"./floor.mjs": "./floor.mjs",
|
|
37
39
|
"./adoption.mjs": "./adoption.mjs",
|
|
38
40
|
"./tailwind.mjs": "./tailwind.mjs",
|
|
39
41
|
"./migration.mjs": "./migration.mjs",
|
|
@@ -43,6 +45,8 @@
|
|
|
43
45
|
"files": [
|
|
44
46
|
"*.mjs",
|
|
45
47
|
"!*.test.mjs",
|
|
48
|
+
"vendor/*.mjs",
|
|
49
|
+
"vendor/README.md",
|
|
46
50
|
"README.md",
|
|
47
51
|
"LICENSE"
|
|
48
52
|
],
|
package/theme-entry.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** Parse the linked JavaScript module before accepting theme startup wiring. */
|
|
2
|
-
|
|
2
|
+
// Vendored rather than depended on: `action.yml` installs nothing, so every
|
|
3
|
+
// module it reaches may import only node: builtins and files inside the
|
|
4
|
+
// action path. G-138 is what happened when that was a comment. See
|
|
5
|
+
// vendor/README.md, and selfcontained.test.mjs for the guard.
|
|
6
|
+
import { parse } from "./vendor/acorn.mjs";
|
|
3
7
|
|
|
4
8
|
const directCall = (node, binding) => node?.type === "CallExpression" &&
|
|
5
9
|
node.callee.type === "Identifier" && node.callee.name === binding &&
|