@weatherboard/gyde-design 0.4.0 → 0.4.2
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 +69 -1
- package/agentdocs.mjs +38 -1
- package/catalogue.mjs +33 -2
- package/cli.mjs +188 -19
- package/emit.mjs +89 -27
- package/index.mjs +2 -1
- package/markup.mjs +2 -2
- package/normalise.mjs +115 -0
- package/package.json +3 -2
- package/props.mjs +8 -5
- package/ratchet.mjs +191 -31
- package/ruleindex.mjs +138 -0
- package/rules.mjs +17 -0
- package/scope.mjs +195 -0
- package/selfgate.mjs +162 -0
- package/theme-choice.mjs +111 -0
- package/tokens.mjs +2 -0
- package/wiring.mjs +59 -4
- package/workflow.mjs +1 -1
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ jobs:
|
|
|
70
70
|
runs-on: ubuntu-latest
|
|
71
71
|
steps:
|
|
72
72
|
- uses: actions/checkout@v4
|
|
73
|
-
- uses:
|
|
73
|
+
- uses: Weatherboard-Studio/gyde@v1
|
|
74
74
|
with:
|
|
75
75
|
fail-on: new
|
|
76
76
|
```
|
|
@@ -135,6 +135,8 @@ against all five.
|
|
|
135
135
|
| `usage.mjs` | Which component is used where, and what the product keeps reinventing. |
|
|
136
136
|
| `upgrade.mjs` | Provenance, and the three-way classification that uses it. |
|
|
137
137
|
| `agentdocs.mjs` | What an agent building the product reads before writing UI. |
|
|
138
|
+
| `ruleindex.mjs` | Every rule as data — id, name, intent, fix shape. Describes; never decides. |
|
|
139
|
+
| `selfgate.mjs` | Emits a scaffold and gates it. Gyde held to its own rules. |
|
|
138
140
|
|
|
139
141
|
## Measured, on the three repositories it was built from
|
|
140
142
|
|
|
@@ -168,6 +170,65 @@ into their repository.
|
|
|
168
170
|
product's; a scaffolder that clobbers has taken ownership of something it does
|
|
169
171
|
not own, silently.
|
|
170
172
|
|
|
173
|
+
## The rules, as data
|
|
174
|
+
|
|
175
|
+
A rule id reaches you three times — in a finding, in the `rules` stamp inside
|
|
176
|
+
`gyde-allowance.json`, and in a failing gate — and every time it is a bare slug.
|
|
177
|
+
`optional-prop` says what matched. It does not say what the rule defends or what
|
|
178
|
+
the fix looks like.
|
|
179
|
+
|
|
180
|
+
So the rules are also available as data:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
import { rules, rulesJson } from "@weatherboard/gyde-design/ruleindex.mjs";
|
|
184
|
+
|
|
185
|
+
rules();
|
|
186
|
+
// [
|
|
187
|
+
// {
|
|
188
|
+
// id: "optional-prop",
|
|
189
|
+
// name: "Optional prop",
|
|
190
|
+
// intent: "A prop a caller may omit, leaving the component to make the decision silently.",
|
|
191
|
+
// fix: "Make it required. Where absence is itself a real answer, make it required and NULLABLE …",
|
|
192
|
+
// },
|
|
193
|
+
// …
|
|
194
|
+
// ]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`rules()` returns frozen entries sorted by id; `rulesJson()` is the same thing as
|
|
198
|
+
a JSON string, and `node ruleindex.mjs` prints it. Use it to generate a rules
|
|
199
|
+
page, to give an agent the fix shape alongside the finding, or to diff what a
|
|
200
|
+
version change means.
|
|
201
|
+
|
|
202
|
+
**It describes and it never decides.** Nothing in the scan, the ledger or the
|
|
203
|
+
gate reads it, and nothing may start to: a description that can change a verdict
|
|
204
|
+
is a second copy of the rule kept in prose, and it will disagree with the first
|
|
205
|
+
one quietly. The predicates stay in `rules.mjs`, `props.mjs`, `compound.mjs` and
|
|
206
|
+
`docdrift.mjs`.
|
|
207
|
+
|
|
208
|
+
Completeness is checked in both directions, against the `rules` stamp that a
|
|
209
|
+
real `gate` run wrote rather than a list typed out beside it. A rule implemented
|
|
210
|
+
and not described fails; a rule described and not implemented fails too — an
|
|
211
|
+
index naming a rule nobody runs is the shape that reads as coverage.
|
|
212
|
+
|
|
213
|
+
## Gyde is held to its own rules
|
|
214
|
+
|
|
215
|
+
`npm run verify` emits a scaffold into a temporary directory, runs
|
|
216
|
+
`gyde-design gate` over it through the CLI, and fails unless the recorded
|
|
217
|
+
baseline is **zero**.
|
|
218
|
+
|
|
219
|
+
The check is on the baseline and not on the exit code, and that distinction is
|
|
220
|
+
the whole of it. A first `gate` on a tree with no ledger RECORDS what it finds
|
|
221
|
+
and exits 1 on purpose; the second run then passes whatever the first recorded.
|
|
222
|
+
So a scaffold shipping ten violations gates green — correctly, for a customer
|
|
223
|
+
adopting Gyde, and uselessly as a check on ourselves.
|
|
224
|
+
|
|
225
|
+
That is not hypothetical. The scaffold emitted on 2026-09-15 carried ten
|
|
226
|
+
`optional-prop` findings across all five seed components, and the consuming
|
|
227
|
+
repository found them, not us. The emitted set now declares every prop required,
|
|
228
|
+
nullable where absence is a real answer, with the ordinary values in
|
|
229
|
+
`defaults.ts` — exported from the package barrel, because an escape hatch behind
|
|
230
|
+
a deep import into `src/` is one the boundary rules forbid you to use.
|
|
231
|
+
|
|
171
232
|
## Rules that go quiet
|
|
172
233
|
|
|
173
234
|
A rule that matched nothing anywhere is reported as **suspicious**, not clean.
|
|
@@ -246,6 +307,13 @@ only one of them knew it was a compromise.
|
|
|
246
307
|
Gyde does not write into your `CLAUDE.md`. It emits a fragment for you to
|
|
247
308
|
include — that file is the last one a tool should edit unasked.
|
|
248
309
|
|
|
310
|
+
For an existing product-owned design system, run `gyde-design guidance .` to
|
|
311
|
+
check whether the generated guide matches its current component exports. Run
|
|
312
|
+
`gyde-design guidance . --write` to refresh the guide, then review the diff.
|
|
313
|
+
This command updates only `.gyde/design-system.md` and refuses to replace a
|
|
314
|
+
handwritten file. It lists component names without guessing their props or
|
|
315
|
+
claiming theme APIs that Gyde has not verified in that product.
|
|
316
|
+
|
|
249
317
|
## Not built yet
|
|
250
318
|
|
|
251
319
|
Build-pipeline wiring for a compiled token layer — moot while the emitted tokens
|
package/agentdocs.mjs
CHANGED
|
@@ -122,6 +122,9 @@ export function agentDoc({
|
|
|
122
122
|
* requirements rather than as facts about the implementation.
|
|
123
123
|
*/
|
|
124
124
|
verified = true,
|
|
125
|
+
// An older Gyde-emitted set may predate the app theme controller. Its
|
|
126
|
+
// component metadata is verified, but this optional capability is absent.
|
|
127
|
+
themeChoice = verified,
|
|
125
128
|
} = {}) {
|
|
126
129
|
const closure = verified
|
|
127
130
|
? `**Never pass \`className\` or \`style\` to a design-system component.** They do not
|
|
@@ -184,13 +187,47 @@ A gap you report becomes a component. A gap you paper over becomes a shadow
|
|
|
184
187
|
system, and the measured cost of one of those is two applications reporting 0%
|
|
185
188
|
adoption while nobody broke a single rule.
|
|
186
189
|
|
|
187
|
-
|
|
190
|
+
${verified ? `## Runtime theming
|
|
188
191
|
|
|
189
192
|
If a surface takes colours that are only known at request time — a per-tenant
|
|
190
193
|
brand — do **not** reach for inline styles. Spread \`themeVars(theme)\` onto an
|
|
191
194
|
element you already own and set the component's tone to \`themed\`. It returns
|
|
192
195
|
CSS custom properties only, which is why it is not an escape hatch.
|
|
193
196
|
|
|
197
|
+
` : ""}
|
|
198
|
+
|
|
199
|
+
${themeChoice ? `## App theme choice
|
|
200
|
+
|
|
201
|
+
The design-system package supplies \`src/theme-bootstrap.js\` and exports
|
|
202
|
+
\`${systemPackage}/theme-choice\`. The product must wire both into its app shell:
|
|
203
|
+
|
|
204
|
+
1. Put the **contents** of \`theme-bootstrap.js\` in a synchronous inline script
|
|
205
|
+
in the document head, before any stylesheet or app script can paint. Use the
|
|
206
|
+
framework's raw-text import/build facility and the site's CSP nonce or hash.
|
|
207
|
+
A module script, effect, or hydrated provider is too late for first paint.
|
|
208
|
+
2. Start \`startThemeChoice()\` once in browser app startup, after the document
|
|
209
|
+
root exists. Keep the returned controller across route changes and call
|
|
210
|
+
\`stop()\` at app teardown. Use \`setChoice("light" | "dark" | "system")\`
|
|
211
|
+
for the preference control; subscribe for its selected and resolved state.
|
|
212
|
+
3. When a router replaces the document root or owns the root's \`data-theme\`,
|
|
213
|
+
call \`reconcile()\` after navigation. The controller also observes root
|
|
214
|
+
replacement and repairs external attribute changes. Keep local route theme
|
|
215
|
+
overrides on inner containers so they do not overwrite the global choice.
|
|
216
|
+
|
|
217
|
+
The storage key is scoped to this design-system package. \`system\` is an
|
|
218
|
+
explicit stored choice that removes \`data-theme\` from the document root and
|
|
219
|
+
lets \`prefers-color-scheme\` decide. Unavailable storage keeps the current tab
|
|
220
|
+
working, without persistence. Changes from another tab and bfcache restoration
|
|
221
|
+
are reconciled by the controller.
|
|
222
|
+
|
|
223
|
+
**Rendered regression evidence belongs in this app's browser CI.** Test both
|
|
224
|
+
system preferences, stored light and dark on reload before first paint, route
|
|
225
|
+
navigation and root replacement, a change from another tab, and back/forward
|
|
226
|
+
cache restoration. Assert the root attribute, computed colour and
|
|
227
|
+
\`color-scheme\`, and capture screenshots in both palettes. Gyde's static gate
|
|
228
|
+
cannot prove when the head script runs or what a browser actually paints.
|
|
229
|
+
` : ""}
|
|
230
|
+
|
|
194
231
|
## The components
|
|
195
232
|
|
|
196
233
|
${verified
|
package/catalogue.mjs
CHANGED
|
@@ -49,8 +49,16 @@
|
|
|
49
49
|
*/
|
|
50
50
|
export const CATALOGUE_ENTRIES = {
|
|
51
51
|
Button: {
|
|
52
|
+
defaults: "BUTTON_DEFAULTS",
|
|
52
53
|
purpose: "The one action a view wants, and every lesser action beside it.",
|
|
53
54
|
replaces: ["a styled <button>", "an <a> that looks like a button"],
|
|
55
|
+
/**
|
|
56
|
+
* G-127. `onClick` is required-nullable and is NOT in BUTTON_DEFAULTS —
|
|
57
|
+
* deliberately, because "this button does nothing" should be typed out at
|
|
58
|
+
* the call site rather than inherited. The catalogue is a call site, so it
|
|
59
|
+
* types it out.
|
|
60
|
+
*/
|
|
61
|
+
handlers: ["onClick"],
|
|
54
62
|
states: [
|
|
55
63
|
{ label: "accent / filled", props: { tone: "accent", emphasis: "filled", size: "md" }, children: "Save changes" },
|
|
56
64
|
{ label: "neutral / outline", props: { tone: "neutral", emphasis: "outline", size: "md" }, children: "Cancel" },
|
|
@@ -60,6 +68,7 @@ export const CATALOGUE_ENTRIES = {
|
|
|
60
68
|
],
|
|
61
69
|
},
|
|
62
70
|
Text: {
|
|
71
|
+
defaults: "TEXT_DEFAULTS",
|
|
63
72
|
purpose: "Every piece of prose, at the role it plays rather than the size it is.",
|
|
64
73
|
replaces: ["a <p> with a font-size", "a <span> with a colour"],
|
|
65
74
|
states: [
|
|
@@ -71,6 +80,7 @@ export const CATALOGUE_ENTRIES = {
|
|
|
71
80
|
],
|
|
72
81
|
},
|
|
73
82
|
Card: {
|
|
83
|
+
defaults: "CARD_DEFAULTS",
|
|
74
84
|
purpose: "A bordered surface. The only one, which is the point.",
|
|
75
85
|
replaces: ["a div with a border and a radius", "a panel", "a well"],
|
|
76
86
|
states: [
|
|
@@ -81,6 +91,7 @@ export const CATALOGUE_ENTRIES = {
|
|
|
81
91
|
],
|
|
82
92
|
},
|
|
83
93
|
Checkbox: {
|
|
94
|
+
defaults: "CHECKBOX_DEFAULTS",
|
|
84
95
|
purpose: "A binary choice that is part of a form.",
|
|
85
96
|
replaces: ["an <input type=checkbox> with a label beside it"],
|
|
86
97
|
controlled: { checked: false },
|
|
@@ -92,6 +103,7 @@ export const CATALOGUE_ENTRIES = {
|
|
|
92
103
|
],
|
|
93
104
|
},
|
|
94
105
|
Select: {
|
|
106
|
+
defaults: "SELECT_DEFAULTS",
|
|
95
107
|
purpose: "One of a known set of options.",
|
|
96
108
|
replaces: ["a <select>", "a dropdown built from a button and a list"],
|
|
97
109
|
controlled: { value: "b" },
|
|
@@ -138,11 +150,26 @@ const lit = (v) =>
|
|
|
138
150
|
*/
|
|
139
151
|
function exampleJsx(name, state, entry) {
|
|
140
152
|
const props = { ...(entry.controlled ?? {}), ...state.props };
|
|
141
|
-
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* G-127. The defaults spread comes FIRST, and it is not decoration.
|
|
156
|
+
*
|
|
157
|
+
* Every prop on every seed component is required now (G-68), so an example
|
|
158
|
+
* listing only the props it is demonstrating does not compile. The catalogue
|
|
159
|
+
* was already in that state before anyone noticed — `onClick` has been
|
|
160
|
+
* required for longer than this comment, and every emitted Button example
|
|
161
|
+
* omitted it — because nothing in this repository type-checks what it emits.
|
|
162
|
+
* It still does not; the gate checks the shape, not the types. So keep this
|
|
163
|
+
* spread, and if you add a required prop to a component, add it to
|
|
164
|
+
* `defaults.ts` or to `handlers` below.
|
|
165
|
+
*/
|
|
166
|
+
const attrs = entry.defaults ? [`{...${entry.defaults}}`] : [];
|
|
167
|
+
attrs.push(...Object.entries(props).map(([k, v]) => `${k}=${lit(v)}`));
|
|
142
168
|
|
|
143
169
|
// A controlled component needs a handler. The catalogue holds no state on
|
|
144
170
|
// purpose — what is being reviewed is the rendering, not the interaction.
|
|
145
171
|
if (entry.controlled) attrs.push("onChange={() => {}}");
|
|
172
|
+
for (const h of entry.handlers ?? []) attrs.push(`${h}={() => {}}`);
|
|
146
173
|
|
|
147
174
|
const open = `<${name} ${attrs.join(" ")}`;
|
|
148
175
|
return state.children ? `${open}>${state.children}</${name}>` : `${open} />`;
|
|
@@ -185,6 +212,10 @@ function entriesSource(components, systemPackage) {
|
|
|
185
212
|
);
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
const defaultsUsed = components
|
|
216
|
+
.map((name) => CATALOGUE_ENTRIES[name].defaults)
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
|
|
188
219
|
const blocks = components.map((name) => {
|
|
189
220
|
const e = CATALOGUE_ENTRIES[name];
|
|
190
221
|
const states = e.states.map((s) =>
|
|
@@ -208,7 +239,7 @@ ${states}
|
|
|
208
239
|
* component name to \`undefined\` and pass while covering nothing.
|
|
209
240
|
*/
|
|
210
241
|
import type { ReactNode } from "react";
|
|
211
|
-
import { ${components.join(", ")} } from "${systemPackage}";
|
|
242
|
+
import { ${[...components, ...defaultsUsed].join(", ")} } from "${systemPackage}";
|
|
212
243
|
|
|
213
244
|
export type Example = { label: string; node: ReactNode };
|
|
214
245
|
export type Entry = { name: string; purpose: string; replaces: readonly string[]; notes: readonly string[]; examples: readonly Example[] };
|
package/cli.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from "./boundaries.mjs";
|
|
35
35
|
import { emitTokens, emitSystem, emitConfig, SEED_COMPONENTS, SCAFFOLD_VERSION, NOT_UPGRADEABLE } from "./emit.mjs";
|
|
36
36
|
import { emitCatalogue } from "./catalogue.mjs";
|
|
37
|
-
import { emitAgentDocs } from "./agentdocs.mjs";
|
|
37
|
+
import { agentDoc, emitAgentDocs } from "./agentdocs.mjs";
|
|
38
38
|
import { emitWorkflow } from "./workflow.mjs";
|
|
39
39
|
import { buildManifest, readManifest, writeManifest, applyUpgrade, formatUpgrade, MANIFEST } from "./upgrade.mjs";
|
|
40
40
|
import { exportedComponents, renderableComponents, checkWiring, formatWiring, WIRING } from "./wiring.mjs";
|
|
@@ -46,8 +46,9 @@ import { migrationProgress, formatMigration } from "./migration.mjs";
|
|
|
46
46
|
import { checkStyleX, formatStyleX, checkClosedStyling, formatClosedStyling } from "./stylex.mjs";
|
|
47
47
|
import { blocksAt, formatSchedule, SCHEDULE } from "./enforcement.mjs";
|
|
48
48
|
import { buildUsage, guidance, formatUsage } from "./usage.mjs";
|
|
49
|
-
import { record, gate, formatGate, adopt, LEDGER_NOTE } from "./ratchet.mjs";
|
|
49
|
+
import { record, gate, formatGate, adopt, trim, formatTrim, LEDGER_NOTE } from "./ratchet.mjs";
|
|
50
50
|
import { loadRules } from "./rules.mjs";
|
|
51
|
+
import { validateScopeShape, checkScopeAgreement, formatScope } from "./scope.mjs";
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* The single source of what would be written.
|
|
@@ -197,13 +198,30 @@ const PRIMITIVES = [
|
|
|
197
198
|
function readConfig(root) {
|
|
198
199
|
const path = join(root, "gyde.config.json");
|
|
199
200
|
if (!existsSync(path)) return { present: false, design: {} };
|
|
201
|
+
let json;
|
|
200
202
|
try {
|
|
201
|
-
|
|
202
|
-
return { present: true, design: json.design || {} };
|
|
203
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
203
204
|
} catch (e) {
|
|
204
205
|
throw new Error(`gyde.config.json exists but could not be parsed: ${e.message}\n` +
|
|
205
206
|
"Refusing to continue on a default — a misread config is how a project ends up measured against a scope nobody chose.");
|
|
206
207
|
}
|
|
208
|
+
const design = json.design || {};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* G-123. The schema half, applied at the one place the config is read.
|
|
212
|
+
*
|
|
213
|
+
* Here rather than in `gate` alone because a malformed scope is wrong on
|
|
214
|
+
* every command: `init` would scaffold a package nobody can import, and the
|
|
215
|
+
* cheapest place to find that out is before anything is written. This needs
|
|
216
|
+
* only the string, so it costs nothing and works on an empty repository.
|
|
217
|
+
*/
|
|
218
|
+
const shape = validateScopeShape(design.scope);
|
|
219
|
+
if (!shape.ok) {
|
|
220
|
+
throw new Error(`gyde.config.json: ${shape.error}\n` +
|
|
221
|
+
"A scope names the packages the emitted code imports, so a wrong one produces imports that resolve to nothing.");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { present: true, design };
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
/** What the repository already uses, read from manifests rather than assumed. */
|
|
@@ -538,6 +556,42 @@ function cmdUpgrade(root, config, { dryRun }) {
|
|
|
538
556
|
return result.conflicts.length ? 1 : 0;
|
|
539
557
|
}
|
|
540
558
|
|
|
559
|
+
/** Refresh the generated component guide from a product's current exports. */
|
|
560
|
+
function cmdGuidance(root, config, { write }) {
|
|
561
|
+
const design = config.design || {};
|
|
562
|
+
const systemPath = design.systemPath || "packages/design-system";
|
|
563
|
+
const components = renderableComponents(root, systemPath);
|
|
564
|
+
if (!components) {
|
|
565
|
+
console.error(`cannot read renderable components from ${systemPath}; guidance was not changed`);
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
const systemPackage = design.systemPackage || systemPackageName(root, systemPath);
|
|
569
|
+
if (!systemPackage) {
|
|
570
|
+
console.error(`cannot read a package name from ${systemPath}; guidance was not changed`);
|
|
571
|
+
return 1;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const path = join(root, ".gyde", "design-system.md");
|
|
575
|
+
const next = agentDoc({ components, systemPackage, verified: false });
|
|
576
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
577
|
+
if (current === next) {
|
|
578
|
+
console.log("component guidance matches the current design-system exports");
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
if (!write) {
|
|
582
|
+
console.error("component guidance is stale; run `gyde-design guidance . --write` and review the diff");
|
|
583
|
+
return 1;
|
|
584
|
+
}
|
|
585
|
+
if (current !== null && !current.startsWith("<!-- GENERATED BY GYDE")) {
|
|
586
|
+
console.error("refusing to overwrite a component guide Gyde did not generate");
|
|
587
|
+
return 1;
|
|
588
|
+
}
|
|
589
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
590
|
+
writeFileSync(path, next);
|
|
591
|
+
console.log("wrote .gyde/design-system.md from the current design-system exports; review the diff");
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
541
595
|
/**
|
|
542
596
|
* The gate.
|
|
543
597
|
*
|
|
@@ -596,12 +650,57 @@ function writeEvidence(root, result, { ok, why, clientBoundary = null, enforceme
|
|
|
596
650
|
}, null, 2) + "\n");
|
|
597
651
|
}
|
|
598
652
|
|
|
599
|
-
|
|
653
|
+
/** The design system package's own `name`, read from disk. Null when there is no package there. */
|
|
654
|
+
function systemPackageName(root, systemPath) {
|
|
655
|
+
try {
|
|
656
|
+
return JSON.parse(readFileSync(join(root, systemPath, "package.json"), "utf8")).name ?? null;
|
|
657
|
+
} catch { return null; }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function cmdGate(root, config, { recordNewRules = false, trimLedger = false } = {}) {
|
|
600
661
|
const design = config.design || {};
|
|
601
662
|
const ledgerPath = join(root, "gyde-allowance.json");
|
|
602
|
-
const
|
|
663
|
+
const systemPath = design.systemPath || "packages/design-system";
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* G-123. Before anything is measured or recorded.
|
|
667
|
+
*
|
|
668
|
+
* First because a disagreeing scope invalidates the run rather than adding a
|
|
669
|
+
* finding to it: the emitted code imports packages that are not there, and
|
|
670
|
+
* recording a baseline underneath that would commit a number taken against a
|
|
671
|
+
* repository Gyde has misread. It is also the cheapest failure available,
|
|
672
|
+
* which is the order `verify` uses and this command should too.
|
|
673
|
+
*
|
|
674
|
+
* The workspace is discovered once here and shared with the checks below.
|
|
675
|
+
* Two `discover` calls in one run can disagree about which packages exist,
|
|
676
|
+
* and this file has done that before.
|
|
677
|
+
*/
|
|
678
|
+
const ws = discover(root);
|
|
679
|
+
const scopeCheck = checkScopeAgreement({
|
|
680
|
+
declared: design.scope,
|
|
681
|
+
packages: ws.packages,
|
|
682
|
+
systemPath,
|
|
683
|
+
systemPackageName: systemPackageName(root, systemPath),
|
|
684
|
+
});
|
|
685
|
+
if (!scopeCheck.ok) {
|
|
686
|
+
console.error(formatScope(scopeCheck));
|
|
687
|
+
writeEvidence(root, null, { ok: false, why: `design.scope does not agree with the repository: ${scopeCheck.why}` });
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const roots = exportedComponents(root, systemPath);
|
|
603
692
|
const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
|
|
604
693
|
|
|
694
|
+
// G-113. `--trim` on a repository with no ledger must NOT fall through to the
|
|
695
|
+
// baseline-recording path below. That path writes a ledger from scratch, which
|
|
696
|
+
// is the largest possible add, and reaching it by typing the word "trim" is
|
|
697
|
+
// exactly the confusion this command exists to remove.
|
|
698
|
+
if (trimLedger && !existsSync(ledgerPath)) {
|
|
699
|
+
console.error("no gyde-allowance.json here — there is nothing to trim.");
|
|
700
|
+
console.error("`gate` records a baseline when none exists; `gate --trim` never creates one.");
|
|
701
|
+
return 1;
|
|
702
|
+
}
|
|
703
|
+
|
|
605
704
|
if (!existsSync(ledgerPath)) {
|
|
606
705
|
const led = record(result.findings, {
|
|
607
706
|
recorded: process.env.GYDE_DATE || null,
|
|
@@ -624,9 +723,42 @@ function cmdGate(root, config) {
|
|
|
624
723
|
return 1;
|
|
625
724
|
}
|
|
626
725
|
|
|
726
|
+
/**
|
|
727
|
+
* G-113. The only command that takes a shortening, and it does nothing else.
|
|
728
|
+
*
|
|
729
|
+
* It runs before the gate's verdict and returns, because it is not a
|
|
730
|
+
* judgement. Both other writers end with "This run recorded a baseline rather
|
|
731
|
+
* than judging one. It is not a pass." and return non-zero; this keeps that,
|
|
732
|
+
* for the same reason. `gate --trim` wired into CI would otherwise be a gate
|
|
733
|
+
* that always passes, which is the failure mode this whole file is arranged
|
|
734
|
+
* against. Judging is what plain `gate` is for, and it is one command away.
|
|
735
|
+
*/
|
|
736
|
+
if (trimLedger) {
|
|
737
|
+
const t = trim(ledger, result.findings);
|
|
738
|
+
console.log(formatTrim(t));
|
|
739
|
+
if (!t.ok) {
|
|
740
|
+
writeEvidence(root, result, { ok: false, why: `refused to trim: ${t.refusals.length} entry(ies) would be added or raised` });
|
|
741
|
+
return 1;
|
|
742
|
+
}
|
|
743
|
+
writeFileSync(ledgerPath, JSON.stringify(t.ledger, null, 2) + "\n");
|
|
744
|
+
console.log("");
|
|
745
|
+
console.log("wrote gyde-allowance.json — review the diff before committing it.");
|
|
746
|
+
console.log("");
|
|
747
|
+
console.log("This run rewrote the ledger rather than judging one. It is not a pass.");
|
|
748
|
+
console.log("Run `gyde design gate .` to judge the tree.");
|
|
749
|
+
writeEvidence(root, result, { ok: false, why: `trimmed the ledger ${t.before} → ${t.after}; nothing was judged` });
|
|
750
|
+
return 1;
|
|
751
|
+
}
|
|
752
|
+
|
|
627
753
|
const verdict = gate([{ name: "design-system", findings: result.findings, ledger }]);
|
|
628
754
|
console.log(formatGate(verdict));
|
|
629
755
|
|
|
756
|
+
// G-123. Printed on the passing path too. A check that is only visible when
|
|
757
|
+
// it fails is one nobody can tell ran, which is the same defect as the key
|
|
758
|
+
// that guarded nothing.
|
|
759
|
+
console.log("");
|
|
760
|
+
console.log(formatScope(scopeCheck));
|
|
761
|
+
|
|
630
762
|
// G-99. No gate and no verdict change — the ratchet was already correct for a
|
|
631
763
|
// migration. This subtracts two numbers the ledger has carried since G-52, so
|
|
632
764
|
// that "not started" and "not visible from here" stop reading the same.
|
|
@@ -636,22 +768,43 @@ function cmdGate(root, config) {
|
|
|
636
768
|
console.log(formatMigration(migrationProgress(result.findings, ledger, { migrating })));
|
|
637
769
|
}
|
|
638
770
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
771
|
+
/**
|
|
772
|
+
* G-108. `gate` does not write the ledger. It reads one and judges against it.
|
|
773
|
+
*
|
|
774
|
+
* G-68 wrote it whenever a rule was adopted, which made the gate's own
|
|
775
|
+
* execution a way for a ledger to grow: running it on a laptop against a
|
|
776
|
+
* consumer whose baseline predated `optional-prop` would have added 80
|
|
777
|
+
* entries, unasked. "A ledger may only ever get shorter" cannot survive a
|
|
778
|
+
* command that lengthens one as a side effect of measuring.
|
|
779
|
+
*
|
|
780
|
+
* The write now happens only when somebody typed the words, and it still
|
|
781
|
+
* exits non-zero — it recorded a baseline for these rules, it did not judge
|
|
782
|
+
* one, and that distinction is the same one the no-ledger path above makes.
|
|
783
|
+
*/
|
|
784
|
+
if (recordNewRules && verdict.newRules.length) {
|
|
785
|
+
const before = ledger.total ?? 0;
|
|
786
|
+
const entries = verdict.failures.filter((f) => f.kind === "unrecorded-rule");
|
|
787
|
+
const next = adopt(ledger, entries, { rules: result.rulesRun });
|
|
788
|
+
writeFileSync(ledgerPath, JSON.stringify(next, null, 2) + "\n");
|
|
789
|
+
console.log("");
|
|
790
|
+
console.log(`recorded ${entries.length} entry(ies) for ${verdict.newRules.length} rule(s) not previously named: ${verdict.newRules.join(", ")}`);
|
|
791
|
+
console.log(`gyde-allowance.json: ${before} → ${next.total} findings. This ledger got LONGER, which is the`);
|
|
792
|
+
console.log("one case where that is allowed — a new rule finds drift that was always there.");
|
|
793
|
+
console.log("Review the diff before committing it; it is a decision, not a formality.");
|
|
794
|
+
console.log("");
|
|
795
|
+
console.log("This run recorded a baseline rather than judging one. It is not a pass.");
|
|
796
|
+
writeEvidence(root, result, { ok: false, why: `recorded a baseline for ${verdict.newRules.length} new rule(s); nothing was judged` });
|
|
797
|
+
return 1;
|
|
645
798
|
}
|
|
646
799
|
|
|
647
800
|
// G-67. Deliberately NOT a ledger finding. A crossing is a runtime defect
|
|
648
801
|
// rather than style debt, so allowancing one would record "this renders
|
|
649
802
|
// undefined props, and that is fine for now" as a committed number. It is
|
|
650
803
|
// gated on the product's own template version instead — see BLOCKS_FROM.
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
// same run disagree about which
|
|
654
|
-
|
|
804
|
+
// The workspace was discovered once at the top of this command and is shared
|
|
805
|
+
// by every check below. Calling `discover` per check re-walks the workspace
|
|
806
|
+
// and, more importantly, lets two checks in the same run disagree about which
|
|
807
|
+
// packages exist.
|
|
655
808
|
const cb = checkClientBoundary(root, { packages: ws.packages });
|
|
656
809
|
const cbBlocks = clientBoundaryBlocks(readManifest(root)?.version);
|
|
657
810
|
if (cb.crossings.length || cb.unresolved.length) {
|
|
@@ -714,15 +867,27 @@ function cmdGate(root, config) {
|
|
|
714
867
|
}
|
|
715
868
|
|
|
716
869
|
function main(argv) {
|
|
717
|
-
const [cmd
|
|
870
|
+
const [cmd] = argv;
|
|
871
|
+
// G-108. The path is the first non-flag argument, not argv[1]. `gate
|
|
872
|
+
// --record-new-rules` would otherwise resolve "--record-new-rules" as a
|
|
873
|
+
// directory and die with "no such path" — a usage error reported as a missing
|
|
874
|
+
// repository, which is the least helpful sentence available.
|
|
875
|
+
const pathArg = argv.slice(1).find((a) => !a.startsWith("-"));
|
|
718
876
|
if (!cmd || ["-h", "--help", "help"].includes(cmd)) {
|
|
719
|
-
console.log("gyde design <scan|plan|init|gate|tokens> [path]");
|
|
877
|
+
console.log("gyde design <scan|plan|init|gate|upgrade|guidance|tokens> [path]");
|
|
720
878
|
console.log(" scan measure a repository as it is");
|
|
721
879
|
console.log(" plan say what scaffolding would be emitted; write nothing");
|
|
722
880
|
console.log(" init write exactly what plan described; never overwrites");
|
|
723
881
|
console.log(" gate fail on anything new since the committed ledger");
|
|
882
|
+
console.log(" (--record-new-rules to record a rule the ledger does not name");
|
|
883
|
+
console.log(" as existing debt; writes the ledger and still exits non-zero)");
|
|
884
|
+
console.log(" (--trim to remove entries the scan no longer produces and lower");
|
|
885
|
+
console.log(" counts that have fallen; never adds, never raises, refuses if it");
|
|
886
|
+
console.log(" would; writes the ledger and still exits non-zero)");
|
|
724
887
|
console.log(" upgrade take a new template version; never merges a conflict");
|
|
725
888
|
console.log(" (--dry-run to see the decisions and write nothing)");
|
|
889
|
+
console.log(" guidance check the generated component guide against current exports");
|
|
890
|
+
console.log(" (--write to refresh only that guide; never changes provenance)");
|
|
726
891
|
console.log(" tokens print the generated stylesheet for the seed dictionary");
|
|
727
892
|
return 0;
|
|
728
893
|
}
|
|
@@ -740,8 +905,12 @@ function main(argv) {
|
|
|
740
905
|
if (cmd === "scan") return cmdScan(root, config);
|
|
741
906
|
if (cmd === "plan") return cmdPlan(root, config);
|
|
742
907
|
if (cmd === "init") return cmdInit(root, config);
|
|
743
|
-
if (cmd === "gate") return cmdGate(root, config
|
|
908
|
+
if (cmd === "gate") return cmdGate(root, config, {
|
|
909
|
+
recordNewRules: argv.includes("--record-new-rules"),
|
|
910
|
+
trimLedger: argv.includes("--trim"),
|
|
911
|
+
});
|
|
744
912
|
if (cmd === "upgrade") return cmdUpgrade(root, config, { dryRun: argv.includes("--dry-run") });
|
|
913
|
+
if (cmd === "guidance") return cmdGuidance(root, config, { write: argv.includes("--write") });
|
|
745
914
|
console.error(`unknown command: ${cmd}`);
|
|
746
915
|
return 1;
|
|
747
916
|
}
|