@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/wiring.mjs ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * G-48 — is the design system actually connected to the app that uses it?
3
+ *
4
+ * WHY THIS IS SMALLER THAN IT LOOKED, AND STILL NECESSARY.
5
+ *
6
+ * The epic began assuming Gyde would wire a compiler plugin into every build
7
+ * pipeline. G-58 reversed that: the emitted token layer is plain CSS custom
8
+ * properties, so there is no plugin to install and nothing per-pipeline to
9
+ * configure. Most of the wiring problem dissolved with the decision.
10
+ *
11
+ * What did not dissolve is the connection itself. Custom properties come from a
12
+ * stylesheet, and a stylesheet has to be imported. An app that renders
13
+ * design-system components without importing the tokens gets **components with
14
+ * no values** — every `var(--color-text)` resolves to nothing, and the page
15
+ * renders as unstyled HTML.
16
+ *
17
+ * That failure is silent in exactly the way CHARTER §5 is about. It does not
18
+ * throw. The build succeeds. The audit is delighted: the app imports only
19
+ * design-system components and spells no literal values, so it scores **100%
20
+ * adoption while rendering nothing recognisable**. A metric that peaks when the
21
+ * page is broken is worse than no metric.
22
+ *
23
+ * So: this checks the link, and reports "could not tell" as its own outcome
24
+ * rather than collapsing it into "fine".
25
+ */
26
+
27
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
28
+ import { join, extname, relative, sep } from "node:path";
29
+
30
+ const SKIP = new Set(["node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage"]);
31
+ const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".css", ".scss"]);
32
+
33
+ export const WIRING = {
34
+ OK: "wired",
35
+ NO_TOKENS: "renders design-system components but never imports the token stylesheet",
36
+ NO_SYSTEM: "imports the token stylesheet but renders no design-system component",
37
+ UNUSED: "neither imports the tokens nor renders a component — not a consumer",
38
+ UNKNOWN: "could not be determined",
39
+ };
40
+
41
+ function walk(dir, root, out) {
42
+ let entries;
43
+ try { entries = readdirSync(dir); } catch { return; }
44
+ for (const name of entries) {
45
+ if (SKIP.has(name) || name.startsWith(".")) continue;
46
+ const full = join(dir, name);
47
+ let st; try { st = statSync(full); } catch { continue; }
48
+ if (st.isDirectory()) walk(full, root, out);
49
+ else if (SOURCE.has(extname(name))) out.push(relative(root, full).split(sep).join("/"));
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Read the component names the design system actually exports.
55
+ *
56
+ * Derived from the barrel, never hardcoded. System B's audit does the same and its
57
+ * comment explains why: a curated list drifts from the package silently, and a
58
+ * list that has drifted reports adoption against components nobody has.
59
+ *
60
+ * Returns `null` — not `[]` — when the barrel cannot be read. An empty list
61
+ * would make every app look like it uses nothing, and the resulting 0% is a
62
+ * measurement failure wearing the costume of a finding.
63
+ */
64
+ export function exportedComponents(root, systemPath) {
65
+ for (const candidate of [join(root, systemPath, "src", "index.ts"), join(root, systemPath, "index.ts"), join(root, systemPath, "src", "index.tsx")]) {
66
+ if (!existsSync(candidate)) continue;
67
+ let text; try { text = readFileSync(candidate, "utf8"); } catch { continue; }
68
+ const names = new Set();
69
+ for (const m of text.matchAll(/export\s+\{([^}]*)\}/g)) {
70
+ // `export type { X }` is a type, not a component that can be rendered.
71
+ if (/export\s+type\s*\{/.test(m[0])) continue;
72
+ for (const raw of m[1].split(",")) {
73
+ const name = raw.trim().split(/\s+as\s+/).pop()?.trim();
74
+ if (name && /^[A-Z]/.test(name)) names.add(name);
75
+ }
76
+ }
77
+ /**
78
+ * `export * from "./button"` — the shadcn convention, and the shape of
79
+ * System C's barrel. The names are not in this file at all, so
80
+ * follow one level into each re-exported module.
81
+ *
82
+ * Without this the barrel reads as unparseable and every consumer comes
83
+ * back UNKNOWN — technically honest, and useless against the most common
84
+ * component-library layout there is.
85
+ */
86
+ for (const m of text.matchAll(/export\s+\*\s+from\s+["'](\.[^"']+)["']/g)) {
87
+ const base = join(candidate, "..", m[1]);
88
+ for (const ext of [".tsx", ".ts", "/index.tsx", "/index.ts"]) {
89
+ if (!existsSync(base + ext)) continue;
90
+ let inner; try { inner = readFileSync(base + ext, "utf8"); } catch { break; }
91
+ for (const e of inner.matchAll(/export\s+(?:const|function|class)\s+([A-Z]\w*)/g)) names.add(e[1]);
92
+ for (const e of inner.matchAll(/export\s+\{([^}]*)\}/g)) {
93
+ if (/export\s+type\s*\{/.test(e[0])) continue;
94
+ for (const raw of e[1].split(",")) {
95
+ const n = raw.trim().split(/\s+as\s+/).pop()?.trim();
96
+ if (n && /^[A-Z]/.test(n)) names.add(n);
97
+ }
98
+ }
99
+ break;
100
+ }
101
+ }
102
+
103
+ if (names.size === 0) return null; // a barrel we could not parse is not an empty barrel
104
+ return [...names].sort();
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Resolve `@import "<pkg>/<file>"` across the workspace, transitively.
111
+ *
112
+ * THE FALSE POSITIVE THIS EXISTS TO PREVENT, which was live before it did.
113
+ *
114
+ * The correct arrangement is for an app to import the DESIGN SYSTEM's
115
+ * stylesheet, and for that stylesheet to import the tokens it depends on:
116
+ *
117
+ * apps/manager/globals.css @import "@x/design-system/styles.css"
118
+ * design-system/styles.css @import "@x/design-tokens/tokens.css"
119
+ *
120
+ * A one-level check reports the app as unwired, which is both wrong and the
121
+ * worst direction to be wrong in — a gate that fails a correctly-wired
122
+ * repository gets switched off, and then it is not protecting the repositories
123
+ * that ARE broken either.
124
+ *
125
+ * Depth is bounded and cycles are tracked, because a stylesheet importing
126
+ * itself should be a finding somewhere else, not a hang here.
127
+ */
128
+ function reachesTokens(root, startFile, { packageDirs, tokensPackage, seen = new Set(), depth = 0 }) {
129
+ if (depth > 8 || seen.has(startFile)) return false;
130
+ seen.add(startFile);
131
+
132
+ let text; try { text = readFileSync(join(root, startFile), "utf8"); } catch { return false; }
133
+ if (tokensPackage && text.includes(`${tokensPackage}/`) && /tokens\.css/.test(text)) return true;
134
+
135
+ for (const m of text.matchAll(/@import\s+["']([^"']+)["']/g)) {
136
+ const spec = m[1];
137
+ if (tokensPackage && spec.startsWith(`${tokensPackage}/`)) return true;
138
+
139
+ // A workspace package specifier: `@x/design-system/styles.css`.
140
+ for (const [name, dir] of Object.entries(packageDirs)) {
141
+ if (!spec.startsWith(name + "/")) continue;
142
+ const sub = spec.slice(name.length + 1);
143
+ for (const guess of [join(dir, sub), join(dir, "src", sub)]) {
144
+ if (existsSync(join(root, guess))) {
145
+ if (reachesTokens(root, guess, { packageDirs, tokensPackage, seen, depth: depth + 1 })) return true;
146
+ }
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Check one package's connection to the design system.
155
+ *
156
+ * `packageName` is the workspace name of the design-system package, so a
157
+ * consumer is recognised by what it imports rather than by where it sits.
158
+ */
159
+ export function checkPackage(root, pkg, { systemPackage, tokensPackage, components, packageDirs = {} }) {
160
+ const files = [];
161
+ walk(join(root, pkg.path === "." ? "" : pkg.path), root, files);
162
+
163
+ let importsTokens = false;
164
+ let importsSystem = false;
165
+ const rendered = new Set();
166
+
167
+ for (const rel of files) {
168
+ let text; try { text = readFileSync(join(root, rel), "utf8"); } catch { continue; }
169
+
170
+ // The stylesheet arrives as a JS import, a CSS @import, or — most often, and
171
+ // correctly — transitively through the design system's own stylesheet.
172
+ if (!importsTokens) {
173
+ if (tokensPackage && text.includes(`${tokensPackage}/tokens.css`)) importsTokens = true;
174
+ else if (/\.css$/.test(rel) || /import\s+["'][^"']*\.css["']/.test(text)) {
175
+ if (reachesTokens(root, rel, { packageDirs, tokensPackage })) importsTokens = true;
176
+ else {
177
+ // A JS file importing a local stylesheet: follow that too.
178
+ for (const m of text.matchAll(/import\s+["'](\.[^"']*\.css)["']/g)) {
179
+ const target = join(rel, "..", m[1]);
180
+ if (reachesTokens(root, target, { packageDirs, tokensPackage })) { importsTokens = true; break; }
181
+ }
182
+ for (const m of text.matchAll(/import\s+["']([^".'][^"']*\.css)["']/g)) {
183
+ const spec = m[1];
184
+ for (const [name, dir] of Object.entries(packageDirs)) {
185
+ if (!spec.startsWith(name + "/")) continue;
186
+ const sub = spec.slice(name.length + 1);
187
+ for (const guess of [join(dir, sub), join(dir, "src", sub)]) {
188
+ if (existsSync(join(root, guess)) && reachesTokens(root, guess, { packageDirs, tokensPackage })) importsTokens = true;
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ if (systemPackage && text.includes(systemPackage)) importsSystem = true;
196
+
197
+ if (components) {
198
+ for (const name of components) {
199
+ if (new RegExp(`<${name}[\\s/>]`).test(text)) rendered.add(name);
200
+ }
201
+ }
202
+ }
203
+
204
+ const usesComponents = rendered.size > 0 || importsSystem;
205
+ let status;
206
+ if (components === null) status = WIRING.UNKNOWN;
207
+ else if (!usesComponents && !importsTokens) status = WIRING.UNUSED;
208
+ else if (usesComponents && !importsTokens) status = WIRING.NO_TOKENS;
209
+ else if (!usesComponents && importsTokens) status = WIRING.NO_SYSTEM;
210
+ else status = WIRING.OK;
211
+
212
+ return {
213
+ package: pkg.path,
214
+ status,
215
+ importsTokens,
216
+ importsSystem,
217
+ rendered: [...rendered].sort(),
218
+ filesRead: files.length,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Check every UI-bearing package.
224
+ *
225
+ * `blocking` is the subset that must fail a gate. An app rendering components
226
+ * without the stylesheet is broken on screen while scoring perfectly, which is
227
+ * the one result a design-system audit must never produce quietly.
228
+ */
229
+ export function checkWiring(root, packages, { systemPath = "packages/design-system", systemPackage = null, tokensPackage = null } = {}) {
230
+ const components = exportedComponents(root, systemPath);
231
+ const consumers = packages.filter((p) => p.carriesUI && p.path !== systemPath);
232
+
233
+ // name -> directory, so a specifier can be followed to a real file.
234
+ const packageDirs = {};
235
+ for (const p of packages) if (p.name) packageDirs[p.name] = p.path === "." ? "" : p.path;
236
+
237
+ const results = consumers.map((p) => checkPackage(root, p, { systemPackage, tokensPackage, components, packageDirs }));
238
+
239
+ return {
240
+ componentsKnown: components ? components.length : null,
241
+ // Stated rather than implied: if we could not read the barrel, every result
242
+ // below is UNKNOWN and none of them is evidence of anything.
243
+ barrelReadable: components !== null,
244
+ results,
245
+ blocking: results.filter((r) => r.status === WIRING.NO_TOKENS || r.status === WIRING.UNKNOWN),
246
+ };
247
+ }
248
+
249
+ export function formatWiring(w) {
250
+ const L = [];
251
+ if (!w.barrelReadable) {
252
+ L.push("wiring UNKNOWN — the design system's barrel could not be read.");
253
+ L.push(" Every result below is undetermined, not clean.");
254
+ } else {
255
+ L.push(`wiring ${w.componentsKnown} exported component(s) known`);
256
+ }
257
+ for (const r of w.results) {
258
+ // Only a BLOCKING status gets a failure mark. NO_SYSTEM — a package that
259
+ // loads the tokens and renders none of the components — is a legitimate
260
+ // arrangement (a surface with its own declared vocabulary), so marking it
261
+ // as a failure trains people to read past the marks that matter.
262
+ const blocking = r.status === WIRING.NO_TOKENS || r.status === WIRING.UNKNOWN;
263
+ const mark = blocking ? "✖" : r.status === WIRING.OK ? "✔" : "·";
264
+ L.push(` ${mark} ${r.package.padEnd(34)} ${r.status}`);
265
+ if (r.status === WIRING.NO_TOKENS) {
266
+ L.push(` renders ${r.rendered.slice(0, 5).join(", ")}${r.rendered.length > 5 ? "…" : ""} with no values behind them.`);
267
+ L.push(" This scores as perfect adoption and renders as unstyled HTML.");
268
+ }
269
+ }
270
+ return L.join("\n");
271
+ }
272
+
273
+ /**
274
+ * Of a barrel's exports, the ones that are components (G-66).
275
+ *
276
+ * `exportedComponents` returns everything capitalised, which is right for
277
+ * wiring and usage — a re-exported constant is still a thing the barrel
278
+ * publishes. It is wrong for a document that tells an agent what it can
279
+ * render: System B's barrel yields 172 names, of which more than a
280
+ * hundred are `BADGE_DEFAULTS`-style constants and connector maps.
281
+ *
282
+ * The test is lowercase somewhere, not "starts with a capital". `UIButton` is
283
+ * a component and `BADGE_DEFAULTS` is not, and a first-two-letters rule gets
284
+ * the first one wrong.
285
+ *
286
+ * Deliberately a separate function rather than a narrowing of the existing
287
+ * one: adoption percentages are computed from that list, and quietly changing
288
+ * its contents would move a published number for an unrelated reason.
289
+ */
290
+ export const isComponentName = (name) => /^[A-Z]/.test(name) && !/^[A-Z0-9_]+$/.test(name);
291
+
292
+ export function renderableComponents(root, systemPath) {
293
+ const all = exportedComponents(root, systemPath);
294
+ if (all === null) return null; // unparseable is not empty
295
+ const named = all.filter(isComponentName);
296
+ return named.length ? named : null; // a barrel of only constants tells us nothing
297
+ }
package/workflow.mjs ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * G-53 — the workflow that makes the verdict binding.
3
+ * G-63 — and which reaches Gyde through an action, not a dependency.
4
+ *
5
+ * @gyde-emits-source-for-another-repo
6
+ *
7
+ * WHY THIS IS THE DIFFERENCE BETWEEN INSTALLED AND DEPLOYED.
8
+ *
9
+ * Without it, `gate` is a command somebody remembers to type. The whole premise
10
+ * of the product is that the verdict is the decision of record and cannot be
11
+ * skipped (CHARTER §4) — and "skippable by not typing it" is the most complete
12
+ * skip there is.
13
+ *
14
+ * WHAT THE FILE IS DEFENDING AGAINST, WHICH IS NOT VIOLATIONS.
15
+ *
16
+ * It is defending against ITSELF not running. `verify-tier` documents six
17
+ * incidents where green meant nothing ran: a `paths:` filter that skipped a
18
+ * job, a `pnpm --filter` that matched nothing and exited 0. CHARTER §5 states
19
+ * the rule this file is built around — "Gyde did not run" and "Gyde found
20
+ * nothing" must never render the same.
21
+ *
22
+ * WHAT CHANGED IN v0.2, AND WHY THE FILE GOT SHORTER.
23
+ *
24
+ * It used to install `@weatherboard/gyde-design` as a devDependency, which
25
+ * meant an `.npmrc`, an `NPM_TOKEN` secret, and a step whose failure had to be
26
+ * explained. Installing that into two repositories turned every CI job red —
27
+ * not Gyde's job, all of them — because a private package 404s without a token
28
+ * and every job runs `pnpm install --frozen-lockfile`.
29
+ *
30
+ * The action removes the install entirely. There is no token in the consumer's
31
+ * CI, nothing added to their dependency graph, and an npm outage cannot turn
32
+ * their unrelated tests red. Two of the four load-bearing comments this file
33
+ * used to carry were about defending a fragile install; they are gone because
34
+ * the fragility is.
35
+ *
36
+ * What remains is the part that was never about packaging: no `paths:` filter,
37
+ * no `continue-on-error`. The proof that the gate ran moved INTO the action,
38
+ * where it cannot be edited out of a consumer's repository by accident.
39
+ */
40
+
41
+ const YAML = ({ action, defaultBranch, failOn, runsOn }) => `# GENERATED BY GYDE — then yours.
42
+ #
43
+ # Gyde wrote this once and will never overwrite it. Edit it freely.
44
+ #
45
+ # ---------------------------------------------------------------------------
46
+ # BEFORE YOU CHANGE THIS FILE, three things are load-bearing:
47
+ # ---------------------------------------------------------------------------
48
+ #
49
+ # 1. There is deliberately NO \`paths:\` filter.
50
+ #
51
+ # It is the obvious optimisation — only run when design files change — and
52
+ # it is the documented cause of a check that reports success without
53
+ # running. A design-system regression arrives through files no glob
54
+ # predicts: a component's own source, a token value, a config, a new app
55
+ # directory nobody added to the list. The whole run is seconds. Adding a
56
+ # filter here trades that for a gate that is green because it was skipped.
57
+ #
58
+ # 2. There is deliberately NO \`continue-on-error\`.
59
+ #
60
+ # It makes every failure look like a pass, which is the one outcome this
61
+ # gate exists to prevent. If you need the gate to stop blocking, set
62
+ # \`fail-on: none\` below: it still runs, still reports, and still fails if
63
+ # it could not run — it just does not block on the verdict. It is visible in
64
+ # the diff and reads as the decision it is, which \`continue-on-error\`
65
+ # does not.
66
+ #
67
+ # 3. Nothing is installed here, and that is deliberate too.
68
+ #
69
+ # The action carries the engine, which depends on nothing but Node itself.
70
+ # Your dependency graph is untouched, no registry token is needed, and an
71
+ # npm outage cannot fail this job. If you find yourself adding an install
72
+ # step to make Gyde work, something is wrong with the action rather than
73
+ # with this file.
74
+ #
75
+ # The action also proves it ran — it writes .gyde/last-verdict.json stamped
76
+ # with this run's id and checks the stamp before applying any verdict. That
77
+ # check lives in the action rather than here so it cannot be edited away.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ name: Gyde
81
+
82
+ on:
83
+ pull_request:
84
+ push:
85
+ branches: [${defaultBranch}]
86
+
87
+ permissions:
88
+ contents: read
89
+
90
+ jobs:
91
+ design-system:
92
+ runs-on: ${runsOn}
93
+ steps:
94
+ - uses: actions/checkout@v4
95
+
96
+ # Fails on any finding not already in the committed ledger, and on any
97
+ # surface it could not measure. An unmeasured surface is an unknown, and
98
+ # an unknown is never a pass.
99
+ #
100
+ # The first run in a repository with existing debt records a baseline and
101
+ # exits non-zero on purpose — recording debt is how it enters, and a run
102
+ # that recorded rather than judged is not a pass. Commit the ledger it
103
+ # writes and the next run judges against it.
104
+ - uses: ${action}
105
+ with:
106
+ fail-on: ${failOn}
107
+ `;
108
+
109
+ /**
110
+ * The workflow.
111
+ *
112
+ * One file now. The `.npmrc` this used to emit alongside it existed only to
113
+ * authenticate the install, and there is no install — emitting a registry
114
+ * config into a repository that does not need one would be a third piece of
115
+ * Gyde state in somebody else's repo for no benefit (G-65).
116
+ */
117
+ export function emitWorkflow({
118
+ /**
119
+ * The action reference, pinned to a tag.
120
+ *
121
+ * A moving ref (`@main`) would mean a consumer's gate changes under them
122
+ * without a commit, which is the same class of problem as a template
123
+ * upgrading silently. Dependabot bumps a `uses:` tag as readily as it bumps
124
+ * a dependency, so pinning costs nothing and buys a reviewable diff.
125
+ */
126
+ // The action ADDRESS, not a customer name. The de-attribution pass (P-scrub)
127
+ // rewrote this to "System A/gyde@v1" and two tests caught it — an emitted
128
+ // workflow pointing at an organisation that does not exist. The org and one
129
+ // of the products genuinely share a name; only one of the two uses is data.
130
+ action = "Another-Iteration/gyde@v1",
131
+ /**
132
+ * Blocking by default.
133
+ *
134
+ * An adopting repository may want `none` for a fortnight while it decides
135
+ * whether its recorded baseline is right. That is a decision it makes in its
136
+ * own workflow file, not a default Gyde hands out — a gate that ships
137
+ * advisory tends to stay advisory.
138
+ */
139
+ failOn = "new",
140
+ /**
141
+ * The runner label, MATCHED to what the repository already uses.
142
+ *
143
+ * Found in System B, where eight of nine workflows run on Blacksmith
144
+ * (`blacksmith-4vcpu-ubuntu-2404`) and the ninth — the one Gyde wrote — was
145
+ * the only `ubuntu-latest` in the repository. That is not just a cost
146
+ * difference. A repository standardising its runners has a reason, usually
147
+ * cache locality or a self-hosted pool, and a scaffolder that quietly opts
148
+ * out of it hands somebody a job that behaves differently from every other
149
+ * job they have, with no note saying why.
150
+ *
151
+ * `ubuntu-latest` remains the fallback because it is the only label that
152
+ * always exists. A Blacksmith label in a repository with no Blacksmith
153
+ * installation produces a job that queues forever — which is the worst
154
+ * possible failure for a gate, since a queued job is neither a pass nor a
155
+ * fail and nothing reports it. So this is detected from the repository's own
156
+ * workflows by the caller, never assumed here.
157
+ */
158
+ runsOn = "ubuntu-latest",
159
+ path = ".github/workflows/gyde.yml",
160
+ /**
161
+ * The repository's default branch, NOT assumed to be `main`.
162
+ *
163
+ * Found installing into a real repository whose default branch is `staging`.
164
+ * The hardcoded `branches: [main]` meant the post-merge run would never fire
165
+ * there — and it would never fire *silently*, because a trigger that matches
166
+ * nothing produces no failed job to notice. Pull requests were still gated,
167
+ * so the gap was partial, which is worse: enough of it worked that nobody
168
+ * would go looking.
169
+ *
170
+ * Detected by the caller from the repository itself rather than guessed here.
171
+ */
172
+ defaultBranch = "main",
173
+ } = {}) {
174
+ /**
175
+ * One branch or several.
176
+ *
177
+ * System B promotes `staging` → `main`, and its own CI audit requires
178
+ * every workflow to trigger on both. Its reasoning is worth adopting
179
+ * rather than working around: the release pull request — the merge that puts
180
+ * code in front of users — was the least-checked pull request in the
181
+ * repository, because four of six workflows listed only `[staging]` and a
182
+ * promotion therefore ran none of them.
183
+ *
184
+ * A single default branch stays the common case. A repository with a release
185
+ * branch says so, and gets a gate on both.
186
+ */
187
+ const branches = Array.isArray(defaultBranch) ? defaultBranch : [defaultBranch];
188
+ if (!branches.length || branches.some((b) => !b || /[[\],]/.test(b))) {
189
+ throw new Error(
190
+ `Refusing to emit a push trigger for ${JSON.stringify(defaultBranch)}.\n` +
191
+ "A trigger that matches no branch produces no failed job to notice — the gate\n" +
192
+ "would simply never fire, which is indistinguishable from finding nothing.");
193
+ }
194
+
195
+ return {
196
+ [path]: YAML({ action, defaultBranch: branches.join(", "), failOn, runsOn }),
197
+
198
+ /**
199
+ * The verdict file is per-run output, and committing it is actively harmful.
200
+ *
201
+ * `.gyde/` holds two different things: generated content a repository should
202
+ * commit (the agent doc), and `last-verdict.json`, which is written on every
203
+ * gate run. A committed verdict goes stale immediately — and a stale verdict
204
+ * is the precise thing the action's proof step exists to catch, so leaving
205
+ * it tracked means every pull request carries a diff nobody reads and the
206
+ * one file that must be fresh is the one under version control.
207
+ *
208
+ * Scoped to `.gyde/` rather than added to the repository's root .gitignore,
209
+ * which is theirs and not a file a scaffolder should be editing.
210
+ */
211
+ ".gyde/.gitignore": `# GENERATED BY GYDE — then yours.
212
+ #
213
+ # last-verdict.json is written by every \`gyde-design gate\` run. It is evidence
214
+ # that the gate ran in a particular job, so a committed copy is stale by
215
+ # definition — and a stale one is exactly what the action's proof step is
216
+ # looking for. Everything else in this directory is generated content you
217
+ # should commit.
218
+ last-verdict.json
219
+ `,
220
+ };
221
+ }