@weatherboard/gyde-design 0.3.0 → 0.4.1
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 +33 -1
- package/catalogue.mjs +33 -2
- package/cli.mjs +219 -24
- 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/stylex.mjs +108 -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
|
@@ -184,13 +184,45 @@ A gap you report becomes a component. A gap you paper over becomes a shadow
|
|
|
184
184
|
system, and the measured cost of one of those is two applications reporting 0%
|
|
185
185
|
adoption while nobody broke a single rule.
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
${verified ? `## Runtime theming
|
|
188
188
|
|
|
189
189
|
If a surface takes colours that are only known at request time — a per-tenant
|
|
190
190
|
brand — do **not** reach for inline styles. Spread \`themeVars(theme)\` onto an
|
|
191
191
|
element you already own and set the component's tone to \`themed\`. It returns
|
|
192
192
|
CSS custom properties only, which is why it is not an escape hatch.
|
|
193
193
|
|
|
194
|
+
## App theme choice
|
|
195
|
+
|
|
196
|
+
The design-system package supplies \`src/theme-bootstrap.js\` and exports
|
|
197
|
+
\`${systemPackage}/theme-choice\`. The product must wire both into its app shell:
|
|
198
|
+
|
|
199
|
+
1. Put the **contents** of \`theme-bootstrap.js\` in a synchronous inline script
|
|
200
|
+
in the document head, before any stylesheet or app script can paint. Use the
|
|
201
|
+
framework's raw-text import/build facility and the site's CSP nonce or hash.
|
|
202
|
+
A module script, effect, or hydrated provider is too late for first paint.
|
|
203
|
+
2. Start \`startThemeChoice()\` once in browser app startup, after the document
|
|
204
|
+
root exists. Keep the returned controller across route changes and call
|
|
205
|
+
\`stop()\` at app teardown. Use \`setChoice("light" | "dark" | "system")\`
|
|
206
|
+
for the preference control; subscribe for its selected and resolved state.
|
|
207
|
+
3. When a router replaces the document root or owns the root's \`data-theme\`,
|
|
208
|
+
call \`reconcile()\` after navigation. The controller also observes root
|
|
209
|
+
replacement and repairs external attribute changes. Keep local route theme
|
|
210
|
+
overrides on inner containers so they do not overwrite the global choice.
|
|
211
|
+
|
|
212
|
+
The storage key is scoped to this design-system package. \`system\` is an
|
|
213
|
+
explicit stored choice that removes \`data-theme\` from the document root and
|
|
214
|
+
lets \`prefers-color-scheme\` decide. Unavailable storage keeps the current tab
|
|
215
|
+
working, without persistence. Changes from another tab and bfcache restoration
|
|
216
|
+
are reconciled by the controller.
|
|
217
|
+
|
|
218
|
+
**Rendered regression evidence belongs in this app's browser CI.** Test both
|
|
219
|
+
system preferences, stored light and dark on reload before first paint, route
|
|
220
|
+
navigation and root replacement, a change from another tab, and back/forward
|
|
221
|
+
cache restoration. Assert the root attribute, computed colour and
|
|
222
|
+
\`color-scheme\`, and capture screenshots in both palettes. Gyde's static gate
|
|
223
|
+
cannot prove when the head script runs or what a browser actually paints.
|
|
224
|
+
` : ""}
|
|
225
|
+
|
|
194
226
|
## The components
|
|
195
227
|
|
|
196
228
|
${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
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* matched nothing are reported as suspicious rather than omitted.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
25
25
|
import { execFileSync } from "node:child_process";
|
|
26
26
|
import { join, resolve, dirname } from "node:path";
|
|
27
27
|
|
|
@@ -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";
|
|
@@ -43,11 +43,12 @@ import { checkDocDrift, formatDocDrift } from "./docdrift.mjs";
|
|
|
43
43
|
import { reconcile } from "./adoption.mjs";
|
|
44
44
|
import { detectTailwind, formatTailwind } from "./tailwind.mjs";
|
|
45
45
|
import { migrationProgress, formatMigration } from "./migration.mjs";
|
|
46
|
-
import { checkStyleX, formatStyleX } from "./stylex.mjs";
|
|
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.
|
|
@@ -64,6 +65,24 @@ import { loadRules } from "./rules.mjs";
|
|
|
64
65
|
* silently never fires. Returns null when it cannot tell, so the caller falls
|
|
65
66
|
* back explicitly instead of this function inventing an answer.
|
|
66
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* The design system's own component sources, read from disk.
|
|
70
|
+
*
|
|
71
|
+
* Read rather than imported (G-100): a prop removed from a type but still
|
|
72
|
+
* spread onto the element is a hole an import-based check cannot see, and the
|
|
73
|
+
* emitted set makes the same choice for the same reason.
|
|
74
|
+
*/
|
|
75
|
+
function systemComponents(root, design) {
|
|
76
|
+
const dir = join(root, design.systemPath || "packages/design-system", "src");
|
|
77
|
+
let names; try { names = readdirSync(dir); } catch { return []; }
|
|
78
|
+
return names
|
|
79
|
+
.filter((n) => /^[A-Z]\w*\.tsx$/.test(n))
|
|
80
|
+
.map((n) => {
|
|
81
|
+
try { return { file: n, text: readFileSync(join(dir, n), "utf8") }; } catch { return null; }
|
|
82
|
+
})
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
function detectDefaultBranch(root) {
|
|
68
87
|
try {
|
|
69
88
|
const head = execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
@@ -179,13 +198,30 @@ const PRIMITIVES = [
|
|
|
179
198
|
function readConfig(root) {
|
|
180
199
|
const path = join(root, "gyde.config.json");
|
|
181
200
|
if (!existsSync(path)) return { present: false, design: {} };
|
|
201
|
+
let json;
|
|
182
202
|
try {
|
|
183
|
-
|
|
184
|
-
return { present: true, design: json.design || {} };
|
|
203
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
185
204
|
} catch (e) {
|
|
186
205
|
throw new Error(`gyde.config.json exists but could not be parsed: ${e.message}\n` +
|
|
187
206
|
"Refusing to continue on a default — a misread config is how a project ends up measured against a scope nobody chose.");
|
|
188
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 };
|
|
189
225
|
}
|
|
190
226
|
|
|
191
227
|
/** What the repository already uses, read from manifests rather than assumed. */
|
|
@@ -276,7 +312,11 @@ function cmdScan(root, config) {
|
|
|
276
312
|
// not be removed while classes still resolve through it.
|
|
277
313
|
// G-100. The positive half of G-98: one styling layer, and it is wired.
|
|
278
314
|
console.log("");
|
|
279
|
-
console.log(
|
|
315
|
+
console.log(formatClosedStyling(checkClosedStyling(root, {
|
|
316
|
+
packages: ws.packages,
|
|
317
|
+
renders: sum.carryUI,
|
|
318
|
+
components: systemComponents(root, config.design || {}),
|
|
319
|
+
})));
|
|
280
320
|
|
|
281
321
|
console.log("");
|
|
282
322
|
console.log(formatTailwind(detectTailwind(root, {
|
|
@@ -516,6 +556,42 @@ function cmdUpgrade(root, config, { dryRun }) {
|
|
|
516
556
|
return result.conflicts.length ? 1 : 0;
|
|
517
557
|
}
|
|
518
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
|
+
|
|
519
595
|
/**
|
|
520
596
|
* The gate.
|
|
521
597
|
*
|
|
@@ -574,12 +650,57 @@ function writeEvidence(root, result, { ok, why, clientBoundary = null, enforceme
|
|
|
574
650
|
}, null, 2) + "\n");
|
|
575
651
|
}
|
|
576
652
|
|
|
577
|
-
|
|
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 } = {}) {
|
|
578
661
|
const design = config.design || {};
|
|
579
662
|
const ledgerPath = join(root, "gyde-allowance.json");
|
|
580
|
-
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);
|
|
581
692
|
const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
|
|
582
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
|
+
|
|
583
704
|
if (!existsSync(ledgerPath)) {
|
|
584
705
|
const led = record(result.findings, {
|
|
585
706
|
recorded: process.env.GYDE_DATE || null,
|
|
@@ -602,9 +723,42 @@ function cmdGate(root, config) {
|
|
|
602
723
|
return 1;
|
|
603
724
|
}
|
|
604
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
|
+
|
|
605
753
|
const verdict = gate([{ name: "design-system", findings: result.findings, ledger }]);
|
|
606
754
|
console.log(formatGate(verdict));
|
|
607
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
|
+
|
|
608
762
|
// G-99. No gate and no verdict change — the ratchet was already correct for a
|
|
609
763
|
// migration. This subtracts two numbers the ledger has carried since G-52, so
|
|
610
764
|
// that "not started" and "not visible from here" stop reading the same.
|
|
@@ -614,22 +768,43 @@ function cmdGate(root, config) {
|
|
|
614
768
|
console.log(formatMigration(migrationProgress(result.findings, ledger, { migrating })));
|
|
615
769
|
}
|
|
616
770
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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;
|
|
623
798
|
}
|
|
624
799
|
|
|
625
800
|
// G-67. Deliberately NOT a ledger finding. A crossing is a runtime defect
|
|
626
801
|
// rather than style debt, so allowancing one would record "this renders
|
|
627
802
|
// undefined props, and that is fine for now" as a committed number. It is
|
|
628
803
|
// gated on the product's own template version instead — see BLOCKS_FROM.
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
// same run disagree about which
|
|
632
|
-
|
|
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.
|
|
633
808
|
const cb = checkClientBoundary(root, { packages: ws.packages });
|
|
634
809
|
const cbBlocks = clientBoundaryBlocks(readManifest(root)?.version);
|
|
635
810
|
if (cb.crossings.length || cb.unresolved.length) {
|
|
@@ -653,13 +828,17 @@ function cmdGate(root, config) {
|
|
|
653
828
|
utilityClasses: result.tailwindClasses,
|
|
654
829
|
cssDirectives: result.tailwindCssDirectives,
|
|
655
830
|
});
|
|
656
|
-
const sx =
|
|
831
|
+
const sx = checkClosedStyling(root, {
|
|
832
|
+
packages: ws.packages,
|
|
833
|
+
renders: summarise(ws).carryUI,
|
|
834
|
+
components: systemComponents(root, design),
|
|
835
|
+
});
|
|
657
836
|
|
|
658
837
|
const breaches = [
|
|
659
838
|
{ rule: "client-boundary", n: cb.crossings.length, what: `${cb.crossings.length} value(s) crossing the "use client" boundary` },
|
|
660
839
|
{ rule: "tailwind-present", n: tw.present ? tw.dependencies.length + tw.configs.length : 0, what: "Tailwind is installed" },
|
|
661
840
|
{ rule: "tailwind-in-css", n: result.tailwindCssDirectives, what: `${result.tailwindCssDirectives} Tailwind directive(s) in stylesheets` },
|
|
662
|
-
{ rule: "styling-layer", n: sx.unknown ? 0 : sx.
|
|
841
|
+
{ rule: "styling-layer", n: sx.unknown || sx.ok ? 0 : sx.hatches.length + sx.competing.length, what: "the styling layer is open — a call site can restyle the component set" },
|
|
663
842
|
].filter((b) => b.n > 0);
|
|
664
843
|
|
|
665
844
|
const blocking = breaches.filter((b) => blocksAt(b.rule, templateVersion));
|
|
@@ -688,15 +867,27 @@ function cmdGate(root, config) {
|
|
|
688
867
|
}
|
|
689
868
|
|
|
690
869
|
function main(argv) {
|
|
691
|
-
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("-"));
|
|
692
876
|
if (!cmd || ["-h", "--help", "help"].includes(cmd)) {
|
|
693
|
-
console.log("gyde design <scan|plan|init|gate|tokens> [path]");
|
|
877
|
+
console.log("gyde design <scan|plan|init|gate|upgrade|guidance|tokens> [path]");
|
|
694
878
|
console.log(" scan measure a repository as it is");
|
|
695
879
|
console.log(" plan say what scaffolding would be emitted; write nothing");
|
|
696
880
|
console.log(" init write exactly what plan described; never overwrites");
|
|
697
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)");
|
|
698
887
|
console.log(" upgrade take a new template version; never merges a conflict");
|
|
699
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)");
|
|
700
891
|
console.log(" tokens print the generated stylesheet for the seed dictionary");
|
|
701
892
|
return 0;
|
|
702
893
|
}
|
|
@@ -714,8 +905,12 @@ function main(argv) {
|
|
|
714
905
|
if (cmd === "scan") return cmdScan(root, config);
|
|
715
906
|
if (cmd === "plan") return cmdPlan(root, config);
|
|
716
907
|
if (cmd === "init") return cmdInit(root, config);
|
|
717
|
-
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
|
+
});
|
|
718
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") });
|
|
719
914
|
console.error(`unknown command: ${cmd}`);
|
|
720
915
|
return 1;
|
|
721
916
|
}
|