@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.
@@ -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
+ }