@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/workspace.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-53 — finding the packages a design system has to cover.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN MODULE, AND WHY IT IS FIRST.
|
|
5
|
+
*
|
|
6
|
+
* The obvious implementation is `glob("apps/*")`, and it is wrong in a way that
|
|
7
|
+
* fails silently. Measured across the three repositories we have:
|
|
8
|
+
*
|
|
9
|
+
* System B pnpm workspace apps/* + packages/* 7 apps
|
|
10
|
+
* System A pnpm workspace apps/* + packages/* 3 apps
|
|
11
|
+
* System C pnpm + turbo control-plane/*, 2 apps
|
|
12
|
+
* customer-facing/*,
|
|
13
|
+
* agentic-ai/*, packages/*
|
|
14
|
+
*
|
|
15
|
+
* Two of three match the convention. The third has no `apps/` directory at all,
|
|
16
|
+
* so a scaffolder that assumes one would discover nothing, emit a design system
|
|
17
|
+
* no application consumes, and report success. That is CHARTER §5's failure mode
|
|
18
|
+
* — a check that passes without running — reached before a single rule executes.
|
|
19
|
+
*
|
|
20
|
+
* So: read the workspace declaration, never the directory names.
|
|
21
|
+
*
|
|
22
|
+
* WHY IT REPORTS WHAT IT SKIPPED.
|
|
23
|
+
*
|
|
24
|
+
* `System C` has Python services (Poetry) in the same workspace as its
|
|
25
|
+
* TypeScript packages. They cannot carry a design system and must be excluded —
|
|
26
|
+
* but excluding them *silently* removes them from the denominator, and an
|
|
27
|
+
* adoption percentage that quietly stopped counting some of the tree is the
|
|
28
|
+
* measurement bug this product exists to prevent. Every package is classified
|
|
29
|
+
* and every exclusion carries a reason, so "we skipped it" and "it passed"
|
|
30
|
+
* never render the same.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
34
|
+
import { join, relative, sep } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** Directories never worth walking into. */
|
|
37
|
+
const SKIP_DIRS = new Set([
|
|
38
|
+
"node_modules", "dist", "build", ".next", ".turbo", ".git",
|
|
39
|
+
"coverage", "test-results", ".venv", "__pycache__", ".pytest_cache",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build-pipeline signatures, in the order we report them.
|
|
44
|
+
*
|
|
45
|
+
* A package can match more than one — `apps/design` in System B has both a
|
|
46
|
+
* postcss.config.mjs and a vite.config.ts — so this is a list rather than a
|
|
47
|
+
* lookup. The `styleLayerPlugin` field names where a token compiler would have
|
|
48
|
+
* to be wired, which is what G-48's "unwired pipeline fails loudly" rule needs
|
|
49
|
+
* to know before it can check anything.
|
|
50
|
+
*/
|
|
51
|
+
const PIPELINES = [
|
|
52
|
+
{ kind: "next", files: ["next.config.ts", "next.config.js", "next.config.mjs"], styleLayerPlugin: "next.config" },
|
|
53
|
+
{ kind: "vite", files: ["vite.config.ts", "vite.config.js", "vite.config.mjs"], styleLayerPlugin: "vite.config" },
|
|
54
|
+
{ kind: "postcss", files: ["postcss.config.mjs", "postcss.config.js", "postcss.config.cjs"], styleLayerPlugin: "postcss.config" },
|
|
55
|
+
{ kind: "expo", files: ["app.json", "app.config.ts"], styleLayerPlugin: null },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why a package carries no design system. Each value is a *reason*, not a flag:
|
|
60
|
+
* the same discipline as System A's RENDERS_UI_WITHOUT_THE_SYSTEM map,
|
|
61
|
+
* where the note exists "because 'we forgot' and 'we decided' look identical
|
|
62
|
+
* from outside".
|
|
63
|
+
*/
|
|
64
|
+
export const EXCLUSION = {
|
|
65
|
+
NOT_JS: "no package.json — not a JavaScript or TypeScript package",
|
|
66
|
+
NO_UI: "declares no UI framework dependency, so it renders nothing",
|
|
67
|
+
TOOLING: "a build or CLI package, not a rendered surface",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse the `packages:` globs out of pnpm-workspace.yaml.
|
|
72
|
+
*
|
|
73
|
+
* Hand-rolled rather than pulling in a YAML dependency. The file is a single
|
|
74
|
+
* list of strings in every real example, and the parse is deliberately strict:
|
|
75
|
+
* anything it does not recognise is returned as `null` so the caller falls back
|
|
76
|
+
* to another declaration rather than proceeding on a half-read file. A silently
|
|
77
|
+
* empty glob list is exactly the failure this module exists to prevent, so
|
|
78
|
+
* "I could not read this" must be distinguishable from "it declared nothing".
|
|
79
|
+
*/
|
|
80
|
+
export function parsePnpmWorkspace(text) {
|
|
81
|
+
const lines = text.split("\n");
|
|
82
|
+
const start = lines.findIndex((l) => /^packages\s*:/.test(l));
|
|
83
|
+
if (start === -1) return null;
|
|
84
|
+
|
|
85
|
+
const globs = [];
|
|
86
|
+
for (const line of lines.slice(start + 1)) {
|
|
87
|
+
if (/^\S/.test(line)) break; // dedented out of the block
|
|
88
|
+
const m = line.match(/^\s*-\s*["']?([^"'#]+?)["']?\s*(?:#.*)?$/);
|
|
89
|
+
if (m) globs.push(m[1].trim());
|
|
90
|
+
else if (line.trim() && !line.trim().startsWith("#")) return null;
|
|
91
|
+
}
|
|
92
|
+
return globs.length ? globs : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where this workspace declares its packages, and how we know.
|
|
97
|
+
*
|
|
98
|
+
* The `source` is reported because a scaffolder that guessed wrong should be
|
|
99
|
+
* debuggable from its output alone.
|
|
100
|
+
*/
|
|
101
|
+
export function readWorkspaceGlobs(root) {
|
|
102
|
+
const pnpmPath = join(root, "pnpm-workspace.yaml");
|
|
103
|
+
if (existsSync(pnpmPath)) {
|
|
104
|
+
const globs = parsePnpmWorkspace(readFileSync(pnpmPath, "utf8"));
|
|
105
|
+
if (globs) return { source: "pnpm-workspace.yaml", globs };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const pkgPath = join(root, "package.json");
|
|
109
|
+
if (existsSync(pkgPath)) {
|
|
110
|
+
let pkg;
|
|
111
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { pkg = null; }
|
|
112
|
+
const ws = pkg && pkg.workspaces;
|
|
113
|
+
const globs = Array.isArray(ws) ? ws : ws && Array.isArray(ws.packages) ? ws.packages : null;
|
|
114
|
+
if (globs && globs.length) return { source: "package.json#workspaces", globs };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A single-package repository is a legitimate shape, not an error. Say so
|
|
118
|
+
// explicitly rather than returning an empty list that reads as "found none".
|
|
119
|
+
return { source: "none — treating the root as a single package", globs: ["."] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Expand one workspace glob. Only the `*` and `**` forms that appear in real manifests. */
|
|
123
|
+
function expandGlob(root, glob) {
|
|
124
|
+
const clean = glob.replace(/\/+$/, "");
|
|
125
|
+
if (clean === ".") return [root];
|
|
126
|
+
|
|
127
|
+
const parts = clean.split("/");
|
|
128
|
+
let dirs = [root];
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const next = [];
|
|
131
|
+
for (const dir of dirs) {
|
|
132
|
+
if (part === "*" || part === "**") {
|
|
133
|
+
let entries = [];
|
|
134
|
+
try { entries = readdirSync(dir); } catch { continue; }
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
if (SKIP_DIRS.has(e) || e.startsWith(".")) continue;
|
|
137
|
+
const full = join(dir, e);
|
|
138
|
+
try { if (statSync(full).isDirectory()) next.push(full); } catch { /* raced */ }
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
const full = join(dir, part);
|
|
142
|
+
try { if (statSync(full).isDirectory()) next.push(full); } catch { /* absent */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
dirs = next;
|
|
146
|
+
}
|
|
147
|
+
return dirs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Anything that renders UI needs one of these. Kept broad on purpose. */
|
|
151
|
+
const UI_DEPS = [/^react$/, /^react-dom$/, /^next$/, /^vue$/, /^svelte$/, /^solid-js$/, /^preact$/];
|
|
152
|
+
|
|
153
|
+
/** Packages whose job is tooling. A CLI that imports React to render Ink is not a surface. */
|
|
154
|
+
const TOOLING_HINTS = [/-cli$/, /^cli$/, /eslint/, /^config$/, /-config$/, /^tsconfig/];
|
|
155
|
+
|
|
156
|
+
function classify(dir, pkg) {
|
|
157
|
+
if (!pkg) return { carriesUI: false, reason: EXCLUSION.NOT_JS };
|
|
158
|
+
|
|
159
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}) };
|
|
160
|
+
const names = Object.keys(deps);
|
|
161
|
+
const hasUI = names.some((n) => UI_DEPS.some((re) => re.test(n)));
|
|
162
|
+
if (!hasUI) return { carriesUI: false, reason: EXCLUSION.NO_UI };
|
|
163
|
+
|
|
164
|
+
const short = (pkg.name || "").split("/").pop() || "";
|
|
165
|
+
if (TOOLING_HINTS.some((re) => re.test(short)) && !pkg.dependencies?.next) {
|
|
166
|
+
return { carriesUI: false, reason: EXCLUSION.TOOLING };
|
|
167
|
+
}
|
|
168
|
+
return { carriesUI: true, reason: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function detectPipelines(dir) {
|
|
172
|
+
const found = [];
|
|
173
|
+
for (const p of PIPELINES) {
|
|
174
|
+
const file = p.files.find((f) => existsSync(join(dir, f)));
|
|
175
|
+
if (file) found.push({ kind: p.kind, file, styleLayerPlugin: p.styleLayerPlugin });
|
|
176
|
+
}
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Discover every package in a workspace, classified.
|
|
182
|
+
*
|
|
183
|
+
* Returns `{ root, source, globs, packages }` where each package carries its
|
|
184
|
+
* path, name, whether it renders UI, its build pipelines, and — when it is
|
|
185
|
+
* excluded — the reason. Nothing is dropped from the list; exclusion is a field,
|
|
186
|
+
* not a filter, so a caller cannot accidentally report a percentage over a
|
|
187
|
+
* denominator that quietly shrank.
|
|
188
|
+
*/
|
|
189
|
+
export function discover(root) {
|
|
190
|
+
const { source, globs } = readWorkspaceGlobs(root);
|
|
191
|
+
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
const packages = [];
|
|
194
|
+
for (const glob of globs) {
|
|
195
|
+
for (const dir of expandGlob(root, glob)) {
|
|
196
|
+
if (seen.has(dir)) continue;
|
|
197
|
+
seen.add(dir);
|
|
198
|
+
|
|
199
|
+
const pkgPath = join(dir, "package.json");
|
|
200
|
+
let pkg = null;
|
|
201
|
+
if (existsSync(pkgPath)) {
|
|
202
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { pkg = null; }
|
|
203
|
+
}
|
|
204
|
+
const { carriesUI, reason } = classify(dir, pkg);
|
|
205
|
+
const rel = relative(root, dir) || ".";
|
|
206
|
+
packages.push({
|
|
207
|
+
path: rel.split(sep).join("/"),
|
|
208
|
+
absolute: dir,
|
|
209
|
+
name: pkg?.name ?? null,
|
|
210
|
+
carriesUI,
|
|
211
|
+
excludedBecause: reason,
|
|
212
|
+
pipelines: carriesUI ? detectPipelines(dir) : [],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
packages.sort((a, b) => a.path.localeCompare(b.path));
|
|
218
|
+
return { root, source, globs, packages };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The npm scope this workspace names its packages under.
|
|
223
|
+
*
|
|
224
|
+
* WHY THIS IS DETECTED RATHER THAN ASKED.
|
|
225
|
+
*
|
|
226
|
+
* Emitted code has to import the packages it emits — `@x/design-system`,
|
|
227
|
+
* `@x/design-tokens` — and the first version hardcoded a placeholder scope. A
|
|
228
|
+
* scaffolded repository therefore contained imports that resolved to nothing,
|
|
229
|
+
* and would not build. It looked right in every test, because every test
|
|
230
|
+
* compared the emitted text against itself.
|
|
231
|
+
*
|
|
232
|
+
* The workspace already knows the answer: both real consumers name every
|
|
233
|
+
* package under one scope. So take the most common one, report it, and let
|
|
234
|
+
* `gyde.config.json` override — a project with no scope at all, or two, needs
|
|
235
|
+
* to say which it wants rather than have one guessed.
|
|
236
|
+
*/
|
|
237
|
+
export function detectScope(result) {
|
|
238
|
+
const counts = new Map();
|
|
239
|
+
for (const p of result.packages) {
|
|
240
|
+
const m = (p.name ?? "").match(/^(@[^/]+)\//);
|
|
241
|
+
if (m) counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
|
|
242
|
+
}
|
|
243
|
+
if (counts.size === 0) return { scope: null, why: "no package in this workspace uses a scope" };
|
|
244
|
+
|
|
245
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
246
|
+
return {
|
|
247
|
+
scope: ranked[0][0],
|
|
248
|
+
why: `${ranked[0][1]} of ${result.packages.length} package(s) use it`,
|
|
249
|
+
// Reported so a repo with two scopes is a decision rather than a coin toss.
|
|
250
|
+
others: ranked.slice(1).map(([s, n]) => ({ scope: s, count: n })),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The one-line summary a scaffolder prints before it writes anything.
|
|
256
|
+
*
|
|
257
|
+
* It names the skipped packages rather than counting them. A run that says
|
|
258
|
+
* "4 packages excluded" invites nobody to check whether the right four were
|
|
259
|
+
* excluded; a run that names them does.
|
|
260
|
+
*/
|
|
261
|
+
export function summarise(result) {
|
|
262
|
+
const ui = result.packages.filter((p) => p.carriesUI);
|
|
263
|
+
const skipped = result.packages.filter((p) => !p.carriesUI);
|
|
264
|
+
const unwireable = ui.filter((p) => p.pipelines.length === 0);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
source: result.source,
|
|
268
|
+
total: result.packages.length,
|
|
269
|
+
carryUI: ui.map((p) => p.path),
|
|
270
|
+
pipelines: ui.flatMap((p) => p.pipelines.map((x) => `${p.path}:${x.kind}`)),
|
|
271
|
+
skipped: skipped.map((p) => ({ path: p.path, because: p.excludedBecause })),
|
|
272
|
+
// Surfaces that render but have no build pipeline we recognise. Not an
|
|
273
|
+
// error here — G-48 decides what to do — but never silently fine either.
|
|
274
|
+
uiWithoutPipeline: unwireable.map((p) => p.path),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The runner this repository already uses, read from its workflows.
|
|
280
|
+
*
|
|
281
|
+
* Same rule as the default branch: the repository is the source of truth and a
|
|
282
|
+
* guess is worse than a fallback. Found in System B, where eight of nine
|
|
283
|
+
* workflows are on Blacksmith and the ninth — Gyde's — was the only
|
|
284
|
+
* `ubuntu-latest` in the repo.
|
|
285
|
+
*
|
|
286
|
+
* WHY THE MAJORITY, AND WHY IT MUST BE A REAL LABEL.
|
|
287
|
+
*
|
|
288
|
+
* A wrong runner label does not fail: GitHub queues the job against a label
|
|
289
|
+
* nothing listens on, and it waits. A queued job is neither a pass nor a fail,
|
|
290
|
+
* nothing reports it, and the gate is silently absent — which is the CHARTER §5
|
|
291
|
+
* failure with no error message attached. So this only ever returns a label
|
|
292
|
+
* already in use by a workflow that runs in this repository today, and returns
|
|
293
|
+
* null rather than inventing one.
|
|
294
|
+
*
|
|
295
|
+
* Matrix expressions are skipped: `runs-on: ${{ matrix.os }}` names no runner,
|
|
296
|
+
* and copying it into a job with no matrix produces exactly the empty label
|
|
297
|
+
* above.
|
|
298
|
+
*/
|
|
299
|
+
export function detectRunner(root) {
|
|
300
|
+
const dir = join(root, ".github", "workflows");
|
|
301
|
+
if (!existsSync(dir)) return null;
|
|
302
|
+
|
|
303
|
+
const counts = new Map();
|
|
304
|
+
for (const file of readdirSync(dir)) {
|
|
305
|
+
if (!/\.ya?ml$/.test(file)) continue;
|
|
306
|
+
// Gyde's own workflow is excluded, or the first run's choice becomes
|
|
307
|
+
// self-justifying and a repository can never be detected as having moved.
|
|
308
|
+
if (file === "gyde.yml") continue;
|
|
309
|
+
let text; try { text = readFileSync(join(dir, file), "utf8"); } catch { continue; }
|
|
310
|
+
for (const [, label] of text.matchAll(/^\s*runs-on:\s*(\S.*?)\s*$/gm)) {
|
|
311
|
+
if (label.includes("${{") || label.startsWith("[")) continue;
|
|
312
|
+
const clean = label.replace(/^["']|["']$/g, "");
|
|
313
|
+
counts.set(clean, (counts.get(clean) || 0) + 1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!counts.size) return null;
|
|
317
|
+
return [...counts].sort((a, b) => b[1] - a[1])[0][0];
|
|
318
|
+
}
|