@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/boundaries.mjs ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * G-55 — boundary rules, and the register of what is exempt.
3
+ *
4
+ * The shape is System A's (`packages/design-audit/src/boundaries.ts`),
5
+ * adopted rather than reinvented, with two things made structural that were
6
+ * conventions there.
7
+ *
8
+ * 1. **`why` is required.** Every boundary carries an argument written as prose,
9
+ * not a label. A rule with a name survives until the first person it
10
+ * inconveniences; a rule with a reason has to be argued with. Their Base UI
11
+ * entry is the model, and it is quoted in full in the seed below.
12
+ *
13
+ * 2. **An exemption keeps counting.** `RENDERS_UI_WITHOUT_THE_SYSTEM` exists
14
+ * *"because 'we forgot' and 'we decided' look identical from outside, and
15
+ * this is the list that says which one it was."* The load-bearing detail is
16
+ * that exempt code's violations stay in the allowance rather than being
17
+ * excused — an exemption that shrinks the denominator hides the thing it
18
+ * exempts, which is how two of System B's apps reported 0% adoption without
19
+ * anybody bypassing anything.
20
+ *
21
+ * THE DEPENDENCY RULE IS NEW, and System C is why. Its dashboard
22
+ * declares twelve Radix packages it does not import — at versions newer than
23
+ * the shared UI package pins. An import rule catches somebody reaching past the
24
+ * wrapper; only a dependency rule catches the standing invitation to do it.
25
+ *
26
+ * @gyde-emits-source-for-another-repo — the G-71 fixtures below are import
27
+ * lines written as data, for a repository that is not this one. Without the
28
+ * marker, paths.test.mjs resolves `"./scale"` and `"../utils"` against this
29
+ * directory and reports them as broken. This module imports nothing but
30
+ * `node:` builtins, so the marker costs no real coverage here.
31
+ */
32
+
33
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
34
+ import { join, relative, extname, sep } from "node:path";
35
+
36
+ const SKIP = new Set(["node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage"]);
37
+ const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".css"]);
38
+
39
+ /**
40
+ * The seed boundaries.
41
+ *
42
+ * `from: ""` means everywhere. Paths are workspace-relative prefixes; the
43
+ * primitive package name is configuration, because which headless library a
44
+ * project uses is the product's choice (G-58) and a rule that hard-codes one
45
+ * would be a leak.
46
+ */
47
+ export function seedBoundaries({ primitive = "@base-ui/react", systemPath = "packages/design-system", tokensPath = "packages/design-tokens", appPaths = [] } = {}) {
48
+ return [
49
+ {
50
+ from: tokensPath,
51
+ forbid: [{
52
+ pattern: new RegExp(escape(systemPath.split("/").pop())),
53
+ why: "the dictionary cannot depend on the components; it is the layer beneath them, and the cycle would make either one unusable alone",
54
+ // The pattern is the LAST SEGMENT of a configured path put through a
55
+ // regex escape. Four transformations from what a reader pictures, and
56
+ // any of them can yield a pattern that never fires.
57
+ catches: [`import { Button } from "@acme/${systemPath.split("/").pop()}";`],
58
+ allows: ["import { space } from \"./scale\";"],
59
+ }],
60
+ },
61
+ {
62
+ from: systemPath,
63
+ forbid: appPaths.map((app) => ({
64
+ pattern: new RegExp(escape(app)),
65
+ why: "a component that knows which app it is in is not a component",
66
+ catches: [`import { route } from "../../${app}/routes";`],
67
+ allows: ["import { cn } from \"../utils\";"],
68
+ })),
69
+ },
70
+ {
71
+ from: "",
72
+ exceptIn: [systemPath],
73
+ forbid: [{
74
+ pattern: new RegExp(escape(primitive)),
75
+ catches: [`import { Checkbox } from "${primitive}/checkbox";`],
76
+ allows: [`import { Checkbox } from "@acme/${systemPath.split("/").pop()}";`],
77
+ why: `${primitive} is the BEHAVIOUR under ${systemPath} and nothing else may reach it. Its components accept className, style and a render prop on every part — by design, because they are unstyled primitives — so an app importing one directly makes every refusal in the component set advisory again. The design system wraps them and keeps its own closed API; that wrapper is the wall, and this is the rule that stops somebody walking round it`,
78
+ }],
79
+ },
80
+ ].filter((b) => b.forbid.length > 0);
81
+ }
82
+
83
+ const escape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+
85
+ /**
86
+ * Dependency boundaries: who may DECLARE a package, not just import one.
87
+ *
88
+ * Separate from the import rules because the failure is different. An undeclared
89
+ * import is a build error somebody notices; a declared-but-unused dependency is
90
+ * a loaded gun sitting in a manifest, and it is what System C has today.
91
+ */
92
+ export function seedDependencyBoundaries({ primitive = "@base-ui/react", systemPath = "packages/design-system" } = {}) {
93
+ return [{
94
+ packagePattern: new RegExp("^" + escape(primitive)),
95
+ onlyIn: [systemPath],
96
+ catches: [primitive, primitive + "/checkbox"],
97
+ allows: ["react", "@acme/" + systemPath.split("/").pop()],
98
+ why: `only ${systemPath} may depend on the primitive library. A package that declares it can import it, and the boundary check would then be the only thing standing between an app and the unwrapped API — one rule deep instead of two`,
99
+ }];
100
+ }
101
+
102
+ /**
103
+ * Does this line breach this pattern?
104
+ *
105
+ * Extracted so the fixtures below are proved through the SAME predicate the
106
+ * real check runs. A fixture verified against a re-implementation of the rule
107
+ * proves the re-implementation.
108
+ */
109
+ export function isImportBreach(line, pattern) {
110
+ const code = String(line).replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
111
+ if (!/\b(import|require|from)\b/.test(code)) return false;
112
+ return pattern.test(code);
113
+ }
114
+
115
+ /** Every boundary must carry an argument. Checked at load, like the rule fixtures. */
116
+ export function validateBoundaries(boundaries) {
117
+ const problems = [];
118
+ for (const b of boundaries) {
119
+ for (const f of b.forbid || []) {
120
+ if (!f.pattern) problems.push(`boundary from "${b.from}": no pattern`);
121
+ if (!f.why || String(f.why).trim().split(/\s+/).length < 8) {
122
+ problems.push(
123
+ `boundary from "${b.from}" (${f.pattern}): the reason is missing or is a label rather than an argument. ` +
124
+ `A rule with a name survives until the first person it inconveniences`,
125
+ );
126
+ }
127
+ }
128
+ }
129
+ return problems;
130
+ }
131
+
132
+ /**
133
+ * G-71 — prove every boundary bites, and prove it discriminates.
134
+ *
135
+ * WHY PRESENCE IS NOT ENOUGH HERE.
136
+ *
137
+ * `loadRules` checks that fixtures EXIST. For a boundary that is too weak, and
138
+ * the reason is the shape of the thing being checked: a boundary is a regular
139
+ * expression, and the failure mode is a regex that silently matches nothing.
140
+ * `new RegExp(escape(systemPath.split("/").pop()))` is four transformations
141
+ * away from the string a reader has in mind, and every one of them can produce
142
+ * a pattern that is valid, loads cleanly, and never fires.
143
+ *
144
+ * A ban enforced with an empty allow-list makes that undetectable. Silence is
145
+ * its success condition — "no breaches" is exactly what a working ban and a
146
+ * broken pattern both print. So these fixtures are EXECUTED, not merely
147
+ * counted, and they are executed through `isImportBreach`, the same predicate
148
+ * `checkBoundaries` runs.
149
+ *
150
+ * Both directions are required, for the reason rules.mjs gives: the fix for a
151
+ * rule that catches nothing is to widen it, and the natural end of widening is
152
+ * a rule that catches everything. `catches` pins one end, `allows` the other.
153
+ *
154
+ * This is the check a product's own fixture linting was doing. It must exist
155
+ * here BEFORE that product retires theirs, or the retirement is a real
156
+ * reduction in enforcement wearing the clothes of a tidy-up (contract.md §7).
157
+ */
158
+ export function proveBoundaries(boundaries) {
159
+ const problems = [];
160
+ for (const b of boundaries) {
161
+ for (const f of b.forbid || []) {
162
+ const where = `boundary from "${b.from}" (${f.pattern})`;
163
+
164
+ if (!Array.isArray(f.catches) || f.catches.length === 0) {
165
+ problems.push(`${where}: no \`catches\` fixture — nothing proves this pattern ever fires`);
166
+ }
167
+ if (!Array.isArray(f.allows) || f.allows.length === 0) {
168
+ problems.push(`${where}: no \`allows\` fixture — nothing proves this pattern is not simply matching everything`);
169
+ }
170
+ if (!f.pattern) continue;
171
+
172
+ for (const line of f.catches || []) {
173
+ if (!isImportBreach(line, f.pattern)) {
174
+ problems.push(`${where}: does NOT catch its own fixture — ${JSON.stringify(line)}`);
175
+ }
176
+ }
177
+ for (const line of f.allows || []) {
178
+ if (isImportBreach(line, f.pattern)) {
179
+ problems.push(`${where}: fires on a line it must allow — ${JSON.stringify(line)}`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return problems;
185
+ }
186
+
187
+ /**
188
+ * Validate and prove, or refuse to run.
189
+ *
190
+ * Throwing rather than warning, for the reason `loadRules` throws: a warning is
191
+ * read once and then lives in a log nobody opens, and the thing being guarded
192
+ * against is a boundary that is present, believed, and not working.
193
+ */
194
+ export function loadBoundaries(boundaries) {
195
+ const problems = [...validateBoundaries(boundaries), ...proveBoundaries(boundaries)];
196
+ if (problems.length) {
197
+ throw new Error(
198
+ "Invalid boundary rules:\n " + problems.join("\n ") +
199
+ "\nA boundary that matches nothing prints the same 'no breaches' as one that works.",
200
+ );
201
+ }
202
+ return boundaries;
203
+ }
204
+
205
+ /** The dependency half. Same discipline, matched against package NAMES rather than import lines. */
206
+ export function proveDependencyBoundaries(rules) {
207
+ const problems = [];
208
+ for (const r of rules) {
209
+ const where = `dependency boundary (${r.packagePattern})`;
210
+ if (!r.packagePattern) { problems.push(`${where}: no packagePattern`); continue; }
211
+ if (!r.why || String(r.why).trim().split(/\s+/).length < 8) {
212
+ problems.push(`${where}: the reason is missing or is a label rather than an argument`);
213
+ }
214
+ if (!Array.isArray(r.catches) || r.catches.length === 0) {
215
+ problems.push(`${where}: no \`catches\` fixture — nothing proves this pattern ever fires`);
216
+ }
217
+ if (!Array.isArray(r.allows) || r.allows.length === 0) {
218
+ problems.push(`${where}: no \`allows\` fixture`);
219
+ }
220
+ for (const name of r.catches || []) {
221
+ if (!r.packagePattern.test(name)) problems.push(`${where}: does NOT catch ${JSON.stringify(name)}`);
222
+ }
223
+ for (const name of r.allows || []) {
224
+ if (r.packagePattern.test(name)) problems.push(`${where}: fires on ${JSON.stringify(name)}, which it must allow`);
225
+ }
226
+ }
227
+ return problems;
228
+ }
229
+
230
+ export function loadDependencyBoundaries(rules) {
231
+ const problems = proveDependencyBoundaries(rules);
232
+ if (problems.length) {
233
+ throw new Error("Invalid dependency boundary rules:\n " + problems.join("\n "));
234
+ }
235
+ return rules;
236
+ }
237
+
238
+ function walk(dir, root, out) {
239
+ let entries;
240
+ try { entries = readdirSync(dir); } catch { return; }
241
+ for (const name of entries) {
242
+ if (SKIP.has(name) || name.startsWith(".")) continue;
243
+ const full = join(dir, name);
244
+ let st; try { st = statSync(full); } catch { continue; }
245
+ if (st.isDirectory()) walk(full, root, out);
246
+ else if (SOURCE.has(extname(name))) out.push(relative(root, full).split(sep).join("/"));
247
+ }
248
+ }
249
+
250
+ const inScope = (file, prefix) => prefix === "" || file === prefix || file.startsWith(prefix + "/");
251
+
252
+ /** A test file discusses imports; it does not make them. */
253
+ const isTest = (p) => /\.(test|spec)\.[jt]sx?$/.test(p) || /(^|\/)__tests__\//.test(p);
254
+
255
+ /**
256
+ * Check import boundaries across a tree.
257
+ *
258
+ * Three things are excluded deliberately, and all three were found by a rule
259
+ * firing on the thing that described it:
260
+ *
261
+ * - **comments**, so the sentence explaining a boundary is not a breach of it;
262
+ * - **`package.json` name fields**, which System A's own test pins;
263
+ * - **test files**, which quote forbidden imports as string fixtures. Running
264
+ * this against System A reported its `boundaries.test.ts` as the
265
+ * repository's only breach — the test proving their rule works, flagged by
266
+ * ours. A rule that fires on its own evidence is a rule people switch off.
267
+ */
268
+ export function checkBoundaries(root, boundaries, { exemptions = new Map() } = {}) {
269
+ loadBoundaries(boundaries);
270
+
271
+ const files = [];
272
+ walk(root, root, files);
273
+ const breaches = [];
274
+
275
+ for (const file of files.sort()) {
276
+ if (isTest(file)) continue;
277
+ let text; try { text = readFileSync(join(root, file), "utf8"); } catch { continue; }
278
+
279
+ for (const b of boundaries) {
280
+ if (!inScope(file, b.from)) continue;
281
+ if ((b.exceptIn || []).some((p) => inScope(file, p))) continue;
282
+
283
+ text.split("\n").forEach((line, i) => {
284
+ for (const f of b.forbid) {
285
+ if (isImportBreach(line, f.pattern)) {
286
+ breaches.push({ file, line: i + 1, from: b.from, why: f.why, source: line.trim() });
287
+ }
288
+ }
289
+ });
290
+ }
291
+ }
292
+
293
+ return {
294
+ breaches,
295
+ exemptions: [...exemptions.entries()].map(([path, why]) => ({ path, why })),
296
+ filesChecked: files.length,
297
+ };
298
+ }
299
+
300
+ /** Check who DECLARES a forbidden package, whether or not they import it. */
301
+ export function checkDependencyBoundaries(root, rules, { packages = [] } = {}) {
302
+ loadDependencyBoundaries(rules);
303
+ const breaches = [];
304
+ for (const pkg of packages) {
305
+ const manifest = join(root, pkg.path === "." ? "" : pkg.path, "package.json");
306
+ if (!existsSync(manifest)) continue;
307
+ let json; try { json = JSON.parse(readFileSync(manifest, "utf8")); } catch { continue; }
308
+ const declared = { ...(json.dependencies || {}), ...(json.devDependencies || {}) };
309
+
310
+ for (const rule of rules) {
311
+ if (rule.onlyIn.some((p) => inScope(pkg.path, p))) continue;
312
+ for (const [name, version] of Object.entries(declared)) {
313
+ if (rule.packagePattern.test(name)) {
314
+ breaches.push({ package: pkg.path, dependency: name, version, why: rule.why });
315
+ }
316
+ }
317
+ }
318
+ }
319
+ return breaches;
320
+ }
321
+
322
+ /**
323
+ * The same package pinned at different versions in one workspace.
324
+ *
325
+ * Not a boundary, but it lives here because it is the same failure one layer
326
+ * down: System C has twelve Radix packages at two versions between its
327
+ * shared UI package and the dashboard that consumes it, and nobody decided
328
+ * that. It is what a copy-in component library does when nothing watches the
329
+ * manifests, and it is the measured form of the problem G-54 has to solve.
330
+ */
331
+ export function findVersionDrift(root, { packages = [], match = /./ } = {}) {
332
+ const versions = new Map();
333
+ for (const pkg of packages) {
334
+ const manifest = join(root, pkg.path === "." ? "" : pkg.path, "package.json");
335
+ if (!existsSync(manifest)) continue;
336
+ let json; try { json = JSON.parse(readFileSync(manifest, "utf8")); } catch { continue; }
337
+ for (const [name, v] of Object.entries({ ...(json.dependencies || {}), ...(json.devDependencies || {}) })) {
338
+ if (!match.test(name)) continue;
339
+ if (!versions.has(name)) versions.set(name, new Map());
340
+ versions.get(name).set(pkg.path, v);
341
+ }
342
+ }
343
+
344
+ const drifted = [];
345
+ for (const [name, byPkg] of versions) {
346
+ const distinct = new Set(byPkg.values());
347
+ if (distinct.size > 1) drifted.push({ dependency: name, versions: Object.fromEntries(byPkg) });
348
+ }
349
+ return drifted.sort((a, b) => a.dependency.localeCompare(b.dependency));
350
+ }