@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/enforcement.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-101 — when a rule starts blocking, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Three checks now need the same answer: the client boundary (G-67), the
|
|
5
|
+
* Tailwind ban (G-98) and the styling-layer mandate (G-100). Each arrived
|
|
6
|
+
* reporting-only, because a rule that starts failing a consumer's build the day
|
|
7
|
+
* it ships is the surprise red build contract.md §5.2 forbids.
|
|
8
|
+
*
|
|
9
|
+
* G-67 established the mechanism and this generalises it: blocking is keyed to
|
|
10
|
+
* the version in the PRODUCT's own `gyde-emitted.json`, which only moves when
|
|
11
|
+
* they run `upgrade` and take the change. Keying it to Gyde's version would
|
|
12
|
+
* change every consumer's gate on the day Gyde reached the number, with nobody
|
|
13
|
+
* deciding to — a time bomb wearing a version number.
|
|
14
|
+
*
|
|
15
|
+
* WHY ONE THRESHOLD AND NOT THREE.
|
|
16
|
+
*
|
|
17
|
+
* Three staggered thresholds means three separate mornings where a consumer's
|
|
18
|
+
* gate fails for a reason they have to go and look up. One threshold means one
|
|
19
|
+
* conversation, one upgrade, one diff — and a consumer can read this table and
|
|
20
|
+
* know exactly what taking v0.3.0 costs them before they take it. The
|
|
21
|
+
* information is the product here as much as the enforcement is.
|
|
22
|
+
*
|
|
23
|
+
* A rule may still be given a later version when it genuinely needs more notice
|
|
24
|
+
* than its neighbours. What it may not do is arrive unlisted: a check that
|
|
25
|
+
* blocks without appearing here is a check nobody could have planned for.
|
|
26
|
+
*
|
|
27
|
+
* THE SCHEDULE IS DATA, DELIBERATELY.
|
|
28
|
+
*
|
|
29
|
+
* `pending()` reads it to answer the question a consumer actually has —
|
|
30
|
+
* "what will start failing me if I upgrade?" — which no amount of prose in a
|
|
31
|
+
* changelog answers as well as a list generated from the thing that decides.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Rule id → the template version from which a finding blocks the gate. */
|
|
35
|
+
export const SCHEDULE = {
|
|
36
|
+
"client-boundary": { version: "0.3.0", why: "a value crossing the \"use client\" boundary renders undefined props (G-67)" },
|
|
37
|
+
"tailwind-present": { version: "0.3.0", why: "Tailwind is not a permitted styling layer (G-98)" },
|
|
38
|
+
"tailwind-in-css": { version: "0.3.0", why: "Tailwind's vocabulary in a stylesheet is the same ban, one file over (G-98)" },
|
|
39
|
+
"styling-layer": { version: "0.3.0", why: "StyleX is the mandated styling layer, and must be wired (G-100)" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const parts = (v) => String(v).split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0);
|
|
43
|
+
|
|
44
|
+
/** Is `a` at or beyond `b`? Numeric, so 0.10.0 is after 0.9.0. A malformed version reads as 0.0.0. */
|
|
45
|
+
export function atLeast(a, b) {
|
|
46
|
+
const [x, y, z] = parts(a), [p, q, r] = parts(b);
|
|
47
|
+
return x !== p ? x > p : y !== q ? y > q : z >= r;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does this rule block for a product at this template version?
|
|
52
|
+
*
|
|
53
|
+
* Fails open on purpose. A repository with no manifest never scaffolded, so it
|
|
54
|
+
* cannot have opted into anything, and blocking it would be enforcement
|
|
55
|
+
* arriving without notice — which is the whole thing this module prevents.
|
|
56
|
+
* That is a known gap: adoption repositories are exactly the ones most likely
|
|
57
|
+
* to have these defects, and closing it needs a mechanism other than a template
|
|
58
|
+
* version.
|
|
59
|
+
*/
|
|
60
|
+
export function blocksAt(rule, templateVersion) {
|
|
61
|
+
const entry = SCHEDULE[rule];
|
|
62
|
+
if (!entry || !templateVersion) return false;
|
|
63
|
+
return atLeast(templateVersion, entry.version);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every scheduled rule that is not blocking yet for this product, and when it will. */
|
|
67
|
+
export function pending(templateVersion) {
|
|
68
|
+
return Object.entries(SCHEDULE)
|
|
69
|
+
.filter(([rule]) => !blocksAt(rule, templateVersion))
|
|
70
|
+
.map(([rule, e]) => ({ rule, from: e.version, why: e.why }))
|
|
71
|
+
.sort((a, b) => a.from.localeCompare(b.from) || a.rule.localeCompare(b.rule));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every scheduled rule that IS blocking for this product. */
|
|
75
|
+
export function enforced(templateVersion) {
|
|
76
|
+
return Object.entries(SCHEDULE)
|
|
77
|
+
.filter(([rule]) => blocksAt(rule, templateVersion))
|
|
78
|
+
.map(([rule, e]) => ({ rule, from: e.version, why: e.why }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatSchedule(templateVersion) {
|
|
82
|
+
const now = enforced(templateVersion);
|
|
83
|
+
const later = pending(templateVersion);
|
|
84
|
+
const L = [];
|
|
85
|
+
|
|
86
|
+
L.push(templateVersion
|
|
87
|
+
? `enforcement template v${templateVersion} — ${now.length} rule(s) blocking, ${later.length} advisory`
|
|
88
|
+
: `enforcement no gyde-emitted.json — nothing blocks, because nothing was opted into`);
|
|
89
|
+
|
|
90
|
+
for (const r of now) L.push(` blocking ${r.rule.padEnd(20)} since v${r.from}`);
|
|
91
|
+
for (const r of later) L.push(` advisory ${r.rule.padEnd(20)} blocks from v${r.from}`);
|
|
92
|
+
|
|
93
|
+
if (later.length) {
|
|
94
|
+
L.push("");
|
|
95
|
+
L.push(" Advisory rules report and do not fail. They begin blocking when you take the");
|
|
96
|
+
L.push(" template version beside them, by running `upgrade` — so the change arrives in");
|
|
97
|
+
L.push(" a diff you reviewed, not as a build that broke overnight.");
|
|
98
|
+
}
|
|
99
|
+
return L.join("\n");
|
|
100
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@weatherboard/gyde-design` — the public surface.
|
|
3
|
+
*
|
|
4
|
+
* Everything below is re-exported deliberately rather than by a wildcard, so
|
|
5
|
+
* adding a module does not silently widen what customers depend on. The v0.1
|
|
6
|
+
* consumers are two repositories inside the same organisation; that is exactly
|
|
7
|
+
* the point at which an accidental export becomes something you cannot remove.
|
|
8
|
+
*
|
|
9
|
+
* ZERO RUNTIME DEPENDENCIES. Only `node:` builtins, which is what makes a git
|
|
10
|
+
* dependency viable at all: nothing to install, nothing to build, and no
|
|
11
|
+
* transitive tree to audit. If that ever stops being true it should be a
|
|
12
|
+
* decision recorded here, not a line in a lockfile.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { discover, summarise, detectScope, detectRunner, readWorkspaceGlobs, parsePnpmWorkspace, EXCLUSION } from "./workspace.mjs";
|
|
16
|
+
export { normaliseSource, normaliseCss, normaliseUtilities, normaliseDeclaration, toPx, PROP } from "./normalise.mjs";
|
|
17
|
+
export { loadRules, run as runRules, coverage, RULES_BY_ID, FIXTURES, CLASSIFICATION, DEFAULTS as RULE_DEFAULTS } from "./rules.mjs";
|
|
18
|
+
export { scan, format as formatScan, classify as classifyFile } from "./scan.mjs";
|
|
19
|
+
|
|
20
|
+
export { SEED, themed, validate, generateCss, generateConstants, generateDtcg, generateStyleX, varName, kebab, CSS_HEADER } from "./tokens.mjs";
|
|
21
|
+
export { seedBoundaries, seedDependencyBoundaries, validateBoundaries, proveBoundaries, proveDependencyBoundaries, loadBoundaries, loadDependencyBoundaries, isImportBreach, checkBoundaries, checkDependencyBoundaries, findVersionDrift } from "./boundaries.mjs";
|
|
22
|
+
export { record, compare, gate, formatGate, tally, LEDGER_NOTE } from "./ratchet.mjs";
|
|
23
|
+
|
|
24
|
+
export { emitTokens, emitSystem, emitConfig, SEED_COMPONENTS, SCAFFOLD_VERSION, NOT_UPGRADEABLE } from "./emit.mjs";
|
|
25
|
+
export { emitCatalogue, CATALOGUE_ENTRIES } from "./catalogue.mjs";
|
|
26
|
+
export { emitAgentDocs, agentDoc, agentInstructionsFragment } from "./agentdocs.mjs";
|
|
27
|
+
|
|
28
|
+
export { hash, buildManifest, readManifest, writeManifest, classify as classifyUpgrade, applyUpgrade, diff, formatUpgrade, STATUS as UPGRADE_STATUS, MANIFEST } from "./upgrade.mjs";
|
|
29
|
+
export { buildUsage, guidance, formatUsage } from "./usage.mjs";
|
|
30
|
+
export { exportedComponents, renderableComponents, isComponentName, checkWiring, checkPackage, formatWiring, WIRING } from "./wiring.mjs";
|
|
31
|
+
export { propMembers, optionalProps, optionalPropsIn, formatOptionalProps, requiredNullableGuards, formatRequiredNullableGuards, RULE as OPTIONAL_PROP_RULE, GUARD_RULE } from "./props.mjs";
|
|
32
|
+
export { SCHEDULE, blocksAt, pending, enforced, formatSchedule } from "./enforcement.mjs";
|
|
33
|
+
export { checkStyleX, stylexFindings, formatStyleX, RULE as STYLEX_RULE } from "./stylex.mjs";
|
|
34
|
+
export { migrationProgress, formatMigration } from "./migration.mjs";
|
|
35
|
+
export { detectTailwind, tailwindFindings, formatTailwind, importsTailwind, tailwindInCss, formatTailwindCss, RULE as TAILWIND_RULE, CSS_RULE as TAILWIND_CSS_RULE } from "./tailwind.mjs";
|
|
36
|
+
export { reconcile, formatAdoption, LAYERS as ADOPTION_LAYERS } from "./adoption.mjs";
|
|
37
|
+
export { classifyMarkup, markupAdoption, formatMarkup, KIND as MARKUP_KIND } from "./markup.mjs";
|
|
38
|
+
export { orphanedParts, formatOrphanedParts, RULE as COMPOUND_RULE } from "./compound.mjs";
|
|
39
|
+
export { checkDocDrift, importedFrom, formatDocDrift, namedInProse, isContractDoc, isFixtureDeclaration, stalePaths, rootDirectories, formatStalePaths, RULE as DOC_DRIFT_RULE, PROSE_RULE, PATH_RULE } from "./docdrift.mjs";
|
|
40
|
+
export { isClientModule, parseImports, parseReexports, valueUses, checkClientBoundary, formatClientBoundary } from "./clientboundary.mjs";
|
package/markup.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-73 — importing the system is not using it.
|
|
3
|
+
*
|
|
4
|
+
* CHARTER §2 asks for "markup-aware adoption (bespoke-pattern density, not just
|
|
5
|
+
* imports)". Every adoption number Gyde has produced so far is import-shaped or
|
|
6
|
+
* declaration-shaped, and both can be high while the rendered page is
|
|
7
|
+
* hand-rolled:
|
|
8
|
+
*
|
|
9
|
+
* - **Import-level** says a file imports `Button`. A file can import Button
|
|
10
|
+
* once, render it once, and hand-roll forty styled divs underneath.
|
|
11
|
+
* - **Declaration-level** — Gyde's current number — says what fraction of style
|
|
12
|
+
* DECLARATIONS are tokenised. A page built entirely from bare `<div>`s with
|
|
13
|
+
* semantic utility classes scores beautifully, because every declaration it
|
|
14
|
+
* makes is tokenised. It just is not using the component set.
|
|
15
|
+
*
|
|
16
|
+
* Neither is wrong. They answer different questions, and the measured spread
|
|
17
|
+
* between them in one repository — 95% against 15% — is what G-74 exists to
|
|
18
|
+
* reconcile. This module supplies the missing third view: of the elements that
|
|
19
|
+
* carry visual weight, how many are the system's?
|
|
20
|
+
*
|
|
21
|
+
* WHAT IS COUNTED, AND WHY THE DENOMINATOR IS NOT "EVERY ELEMENT".
|
|
22
|
+
*
|
|
23
|
+
* A layout `<div>` with no styling is not a missed opportunity to use a
|
|
24
|
+
* component. It is a div. Counting it as un-adopted makes the number a measure
|
|
25
|
+
* of how much markup a page has, which no team can act on and every team will
|
|
26
|
+
* learn to ignore.
|
|
27
|
+
*
|
|
28
|
+
* So the denominator is elements that carry a VISUAL DECISION — a system
|
|
29
|
+
* component, or a bare element that was styled at the call site. That is the
|
|
30
|
+
* population where "should this have been a component?" is a real question.
|
|
31
|
+
*
|
|
32
|
+
* system <Button>, <Card.Header> the set, used
|
|
33
|
+
* bespoke <div className="…"> a styled element that is not the set
|
|
34
|
+
* structural <div>, <span> no visual decision; not counted
|
|
35
|
+
* foreign <Image>, <Suspense> somebody else's component; not counted
|
|
36
|
+
*
|
|
37
|
+
* `foreign` is excluded rather than counted against, for the same reason
|
|
38
|
+
* `structural` is: a framework's `<Image>` is not a component the design system
|
|
39
|
+
* was ever going to provide, and a number that falls when you adopt Next.js is
|
|
40
|
+
* a number that is measuring the wrong thing.
|
|
41
|
+
*
|
|
42
|
+
* THE NUMBER IS NEVER EMITTED WITHOUT ITS DENOMINATOR. That is this module's
|
|
43
|
+
* inherited contract (scan.mjs), and it matters more here than anywhere,
|
|
44
|
+
* because a markup ratio over four elements is noise wearing a percentage sign.
|
|
45
|
+
*
|
|
46
|
+
* @gyde-emits-source-for-another-repo — the fixtures beside this file are JSX
|
|
47
|
+
* written as data for a repository that is not this one.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export const KIND = {
|
|
51
|
+
SYSTEM: "system",
|
|
52
|
+
BESPOKE: "bespoke",
|
|
53
|
+
STRUCTURAL: "structural",
|
|
54
|
+
FOREIGN: "foreign",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An opening JSX element, with its attribute text.
|
|
59
|
+
*
|
|
60
|
+
* Attributes may span lines — in real JSX they usually do — so the attribute
|
|
61
|
+
* group crosses newlines deliberately. A single-line match finds `<div` and
|
|
62
|
+
* never the `className` three lines down, which would classify every
|
|
63
|
+
* multi-line styled element as structural and inflate adoption exactly where
|
|
64
|
+
* the markup is most complex.
|
|
65
|
+
*/
|
|
66
|
+
function elements(text) {
|
|
67
|
+
const out = [];
|
|
68
|
+
const re = /<\s*([A-Za-z][\w.]*)((?:\s[^<>]*?)?)(\/?)>/g;
|
|
69
|
+
let m;
|
|
70
|
+
while ((m = re.exec(text))) out.push({ name: m[1], attrs: m[2] || "", index: m.index });
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stripComments = (text) =>
|
|
75
|
+
text.replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, " "))
|
|
76
|
+
.replace(/^\s*\/\/[^\n]*/gm, "");
|
|
77
|
+
|
|
78
|
+
/** Does this element carry a visual decision made at the call site? */
|
|
79
|
+
const STYLED = /\b(className|class|style|css|sx|tw)\s*=/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classify every element in a source file.
|
|
83
|
+
*
|
|
84
|
+
* `systemNames` is the design system's own exports. Without it nothing can be
|
|
85
|
+
* classified as `system`, so the caller gets `unknown` rather than a confident
|
|
86
|
+
* zero — a repository whose barrel could not be read has not scored 0% markup
|
|
87
|
+
* adoption, it has not been measured.
|
|
88
|
+
*/
|
|
89
|
+
export function classifyMarkup(text, { systemNames = null } = {}) {
|
|
90
|
+
if (!systemNames) return { unknown: true, why: "the design system's exports are not known, so nothing can be attributed to it" };
|
|
91
|
+
|
|
92
|
+
const src = stripComments(text);
|
|
93
|
+
const counts = { system: 0, bespoke: 0, structural: 0, foreign: 0 };
|
|
94
|
+
const bespokeElements = [];
|
|
95
|
+
|
|
96
|
+
for (const el of elements(src)) {
|
|
97
|
+
const isComponent = /^[A-Z]/.test(el.name);
|
|
98
|
+
const root = el.name.split(".")[0];
|
|
99
|
+
|
|
100
|
+
if (isComponent) {
|
|
101
|
+
if (systemNames.has(root)) counts.system++;
|
|
102
|
+
else counts.foreign++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (STYLED.test(el.attrs)) {
|
|
107
|
+
counts.bespoke++;
|
|
108
|
+
bespokeElements.push({ name: el.name, line: src.slice(0, el.index).split("\n").length });
|
|
109
|
+
} else {
|
|
110
|
+
counts.structural++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const of = counts.system + counts.bespoke;
|
|
115
|
+
return {
|
|
116
|
+
unknown: false,
|
|
117
|
+
counts,
|
|
118
|
+
// The population where "should this have been a component?" is a real
|
|
119
|
+
// question. Never the total element count.
|
|
120
|
+
adoption: { used: counts.system, of, percent: of ? Math.round((counts.system / of) * 100) : null },
|
|
121
|
+
bespokeElements,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Markup adoption across a set of already-read files.
|
|
127
|
+
*
|
|
128
|
+
* Takes `[{ file, text }]` rather than walking, so the caller's scope decisions
|
|
129
|
+
* — which files are product code, which are templates for another repository —
|
|
130
|
+
* are made in one place instead of being re-derived here and drifting.
|
|
131
|
+
*/
|
|
132
|
+
export function markupAdoption(files, { systemNames = null } = {}) {
|
|
133
|
+
if (!systemNames) {
|
|
134
|
+
return { unknown: true, why: "the design system's exports are not known, so nothing can be attributed to it", perFile: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const totals = { system: 0, bespoke: 0, structural: 0, foreign: 0 };
|
|
138
|
+
const perFile = [];
|
|
139
|
+
|
|
140
|
+
for (const { file, text } of files) {
|
|
141
|
+
const r = classifyMarkup(text, { systemNames });
|
|
142
|
+
if (r.unknown) continue;
|
|
143
|
+
if (r.adoption.of === 0) continue; // no visual decisions here at all
|
|
144
|
+
for (const k of Object.keys(totals)) totals[k] += r.counts[k];
|
|
145
|
+
perFile.push({ file, ...r.adoption, counts: r.counts });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const of = totals.system + totals.bespoke;
|
|
149
|
+
return {
|
|
150
|
+
unknown: false,
|
|
151
|
+
counts: totals,
|
|
152
|
+
adoption: { used: totals.system, of, percent: of ? Math.round((totals.system / of) * 100) : null },
|
|
153
|
+
perFile: perFile.sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101)),
|
|
154
|
+
filesWithMarkup: perFile.length,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatMarkup(result) {
|
|
159
|
+
if (result.unknown) return `markup adoption: could not tell — ${result.why}`;
|
|
160
|
+
|
|
161
|
+
const { counts, adoption } = result;
|
|
162
|
+
const L = [];
|
|
163
|
+
L.push(`markup ${adoption.used} of ${adoption.of} styled element(s) are the system` +
|
|
164
|
+
(adoption.percent === null ? " (nothing to measure)" : ` — ${adoption.percent}%`));
|
|
165
|
+
L.push(` ${counts.bespoke} bespoke, ${counts.structural} structural (not counted), ${counts.foreign} foreign (not counted)`);
|
|
166
|
+
|
|
167
|
+
const worst = result.perFile.filter((f) => f.percent !== null && f.percent < 100).slice(0, 5);
|
|
168
|
+
if (worst.length) {
|
|
169
|
+
L.push("");
|
|
170
|
+
L.push(" most bespoke markup:");
|
|
171
|
+
for (const f of worst) L.push(` ${f.file} ${f.used}/${f.of} (${f.percent}%)`);
|
|
172
|
+
}
|
|
173
|
+
L.push("");
|
|
174
|
+
L.push(" A structural div is not a missed component and is not counted against you.");
|
|
175
|
+
L.push(" This asks only about elements that made a visual decision at the call site.");
|
|
176
|
+
return L.join("\n");
|
|
177
|
+
}
|
package/migration.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-99 — a migration is a ratchet you can see moving.
|
|
3
|
+
*
|
|
4
|
+
* G-98 raised the objection honestly: 4,842 findings with no incremental fix is
|
|
5
|
+
* a migration, not a ratchet, and a shrink-only ledger that sits flat for weeks
|
|
6
|
+
* is one people stop reading.
|
|
7
|
+
*
|
|
8
|
+
* Half of that turned out to be wrong, and it is worth writing down which half.
|
|
9
|
+
*
|
|
10
|
+
* THE GATE WAS ALREADY CORRECT. `compare` fails on an increase, passes on flat,
|
|
11
|
+
* and reports a decrease. For a framework removal that is exactly the behaviour
|
|
12
|
+
* wanted: no NEW Tailwind may appear anywhere, and the existing 4,842 may sit
|
|
13
|
+
* while the work happens. Nobody has to justify a file they did not touch. The
|
|
14
|
+
* instinct to build a second gate for migrations would have replaced a correct
|
|
15
|
+
* mechanism with a parallel one.
|
|
16
|
+
*
|
|
17
|
+
* THE REPORT WAS NOT. A flat 4,842 renders identically whether the migration is
|
|
18
|
+
* progressing in a branch, blocked on a decision, or dead — and "the number has
|
|
19
|
+
* not moved" is the only thing anybody can see. That is CHARTER §5's rule again:
|
|
20
|
+
* "no progress was made" and "progress is not visible from here" must not
|
|
21
|
+
* render the same.
|
|
22
|
+
*
|
|
23
|
+
* So this adds no gate and changes no verdict. It reads the baseline the ledger
|
|
24
|
+
* already recorded, compares it to now, and says how far a declared migration
|
|
25
|
+
* has come. The ledger has carried the answer since G-52; nothing had ever
|
|
26
|
+
* subtracted the two numbers.
|
|
27
|
+
*
|
|
28
|
+
* WHY THE BASELINE IS THE LEDGER AND NOT A SEPARATE FILE.
|
|
29
|
+
*
|
|
30
|
+
* A migration target stored anywhere else is a second record of the same debt,
|
|
31
|
+
* free to disagree with the first, and the disagreement would be discovered
|
|
32
|
+
* during the migration it was meant to track. The ledger is already the
|
|
33
|
+
* committed, reviewed, shrink-only record of what was there when the
|
|
34
|
+
* measurement became honest — which is the definition of a migration baseline.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Sum a `{ file: { rule: count } }` tally for one rule. */
|
|
38
|
+
function totalFor(allowed, rule) {
|
|
39
|
+
let total = 0, files = 0;
|
|
40
|
+
for (const rules of Object.values(allowed || {})) {
|
|
41
|
+
const n = rules?.[rule];
|
|
42
|
+
if (n === undefined) continue;
|
|
43
|
+
total += n; files++;
|
|
44
|
+
}
|
|
45
|
+
return { total, files };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tallyFindings(findings) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const f of findings) {
|
|
51
|
+
(out[f.file] ??= {});
|
|
52
|
+
out[f.file][f.rule] = (out[f.file][f.rule] || 0) + 1;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Progress of each declared migration, against the ledger's baseline.
|
|
59
|
+
*
|
|
60
|
+
* `migrating` is a list of rule ids the project has declared it is removing
|
|
61
|
+
* entirely, rather than ratcheting down indefinitely. Declaring it is the
|
|
62
|
+
* product's call: the difference between "we are reducing this" and "this is
|
|
63
|
+
* going to zero and here is the date" is a commitment, not a measurement, and
|
|
64
|
+
* Gyde should not infer it from a trend.
|
|
65
|
+
*/
|
|
66
|
+
export function migrationProgress(findings, ledger, { migrating = [] } = {}) {
|
|
67
|
+
if (!ledger) {
|
|
68
|
+
return { unknown: true, why: "no ledger, so there is no baseline to measure progress against", rules: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const current = tallyFindings(findings);
|
|
72
|
+
const rules = [];
|
|
73
|
+
|
|
74
|
+
for (const rule of migrating) {
|
|
75
|
+
const base = totalFor(ledger.allowed, rule);
|
|
76
|
+
const now = totalFor(current, rule);
|
|
77
|
+
|
|
78
|
+
// A rule the ledger never recorded has no baseline, so it has no progress —
|
|
79
|
+
// reported as such rather than as 100% complete, which is what a naive
|
|
80
|
+
// subtraction from zero would produce.
|
|
81
|
+
if (base.total === 0 && now.total === 0) {
|
|
82
|
+
rules.push({ rule, unknown: true, why: "not in the ledger and not in the scan — nothing to migrate", baseline: 0, current: 0 });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (base.total === 0) {
|
|
86
|
+
rules.push({ rule, unknown: true, why: "not in the ledger, so this scan has no baseline to compare against", baseline: 0, current: now.total });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cleared = base.total - now.total;
|
|
91
|
+
rules.push({
|
|
92
|
+
rule,
|
|
93
|
+
unknown: false,
|
|
94
|
+
baseline: base.total, baselineFiles: base.files,
|
|
95
|
+
current: now.total, currentFiles: now.files,
|
|
96
|
+
cleared,
|
|
97
|
+
percent: Math.round((cleared / base.total) * 100),
|
|
98
|
+
done: now.total === 0,
|
|
99
|
+
// A negative number here is not an error to hide. The gate has already
|
|
100
|
+
// failed the increase; this is the same fact in the units the migration
|
|
101
|
+
// is measured in.
|
|
102
|
+
regressed: cleared < 0,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const measured = rules.filter((r) => !r.unknown);
|
|
107
|
+
const baseline = measured.reduce((n, r) => n + r.baseline, 0);
|
|
108
|
+
const remaining = measured.reduce((n, r) => n + r.current, 0);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
unknown: false,
|
|
112
|
+
rules,
|
|
113
|
+
recordedAt: ledger.recorded ?? null,
|
|
114
|
+
total: {
|
|
115
|
+
baseline, remaining, cleared: baseline - remaining,
|
|
116
|
+
percent: baseline ? Math.round(((baseline - remaining) / baseline) * 100) : null,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatMigration(result) {
|
|
122
|
+
if (result.unknown) return `migration could not tell — ${result.why}`;
|
|
123
|
+
if (result.rules.length === 0) return "migration none declared";
|
|
124
|
+
|
|
125
|
+
const L = [];
|
|
126
|
+
const t = result.total;
|
|
127
|
+
L.push(
|
|
128
|
+
t.baseline === 0
|
|
129
|
+
? "migration nothing to measure — no declared rule has a baseline"
|
|
130
|
+
: `migration ${t.percent}% complete (${t.cleared} of ${t.baseline} cleared, ${t.remaining} remaining)` +
|
|
131
|
+
(result.recordedAt ? ` since ${result.recordedAt}` : ""),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
for (const r of result.rules) {
|
|
135
|
+
if (r.unknown) { L.push(` ${r.rule}: ${r.why}`); continue; }
|
|
136
|
+
const state = r.done ? "done" : r.regressed ? `REGRESSED by ${-r.cleared}` : `${r.percent}%`;
|
|
137
|
+
L.push(` ${r.rule.padEnd(26)} ${String(r.current).padStart(5)} of ${r.baseline} ${state}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (t.baseline > 0 && t.cleared === 0) {
|
|
141
|
+
L.push("");
|
|
142
|
+
L.push(" Nothing cleared since the baseline. That is a fact about the work, not about");
|
|
143
|
+
L.push(" the gate — the gate passes a migration that has not moved, because nobody");
|
|
144
|
+
L.push(" should have to justify a file they did not touch. It is reported so that");
|
|
145
|
+
L.push(" 'not started' and 'not visible from here' do not read the same.");
|
|
146
|
+
}
|
|
147
|
+
return L.join("\n");
|
|
148
|
+
}
|