@weatherboard/gyde-design 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/adoption.mjs +138 -0
- package/agentdocs.mjs +246 -0
- package/boundaries.mjs +350 -0
- package/catalogue.mjs +506 -0
- package/cli.mjs +723 -0
- package/clientboundary.mjs +399 -0
- package/compound.mjs +123 -0
- package/docdrift.mjs +439 -0
- package/emit.mjs +862 -0
- package/enforcement.mjs +100 -0
- package/index.mjs +40 -0
- package/markup.mjs +177 -0
- package/migration.mjs +148 -0
- package/normalise.mjs +416 -0
- package/package.json +59 -0
- package/props.mjs +255 -0
- package/ratchet.mjs +290 -0
- package/rules.mjs +258 -0
- package/scan.mjs +291 -0
- package/stylex.mjs +178 -0
- package/tailwind.mjs +238 -0
- package/tokens.mjs +398 -0
- package/upgrade.mjs +344 -0
- package/usage.mjs +245 -0
- package/wiring.mjs +297 -0
- package/workflow.mjs +221 -0
- package/workspace.mjs +318 -0
package/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
|
+
}
|