@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
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-67 — values must not cross the "use client" boundary.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS.
|
|
5
|
+
*
|
|
6
|
+
* `emit.mjs` already knows this failure. It writes the defaults module without a
|
|
7
|
+
* directive, and puts a shouting comment on top telling the reader not to add
|
|
8
|
+
* one. That comment is knowledge addressed to ourselves: it protects the file
|
|
9
|
+
* Gyde emitted, on the day it was emitted, and it protects nothing afterwards.
|
|
10
|
+
* The moment a product adds a fourth component and puts its defaults beside it —
|
|
11
|
+
* the natural thing to do, which is exactly why the comment predicts it — Gyde
|
|
12
|
+
* has no way to notice. It emits code shaped to avoid a failure it cannot
|
|
13
|
+
* detect.
|
|
14
|
+
*
|
|
15
|
+
* THE FAILURE, PRECISELY.
|
|
16
|
+
*
|
|
17
|
+
* A module carrying "use client" does not export values to a server component.
|
|
18
|
+
* It exports *client references*: opaque objects with no enumerable properties.
|
|
19
|
+
* So `<Card {...CARD_DEFAULTS}>` on a server page spreads `{}`, every prop
|
|
20
|
+
* reverts to undefined, and React reports "Element type is invalid … got:
|
|
21
|
+
* undefined" — naming neither the element nor the prop that vanished.
|
|
22
|
+
*
|
|
23
|
+
* Nothing throws at the boundary. The build passes. The styling rules pass,
|
|
24
|
+
* because the source says `{...CARD_DEFAULTS}` and that is a perfectly
|
|
25
|
+
* tokenised thing to write. This is the shape of defect the whole product
|
|
26
|
+
* exists for: correct-looking code, silent at every gate, wrong at runtime.
|
|
27
|
+
*
|
|
28
|
+
* WHAT IS AND IS NOT A CROSSING.
|
|
29
|
+
*
|
|
30
|
+
* Importing a client COMPONENT into a server component is the React Server
|
|
31
|
+
* Components model working as designed, and this rule must never flag it — a
|
|
32
|
+
* rule that fires on the normal case is one people switch off in a week. React
|
|
33
|
+
* knows how to render a client reference. It does not know how to spread one.
|
|
34
|
+
*
|
|
35
|
+
* So the rule is narrow on purpose: a value-position use — an object spread or
|
|
36
|
+
* a property read — of a binding that came across the boundary. `<Button />`
|
|
37
|
+
* is fine. `{...BUTTON_DEFAULTS}` is not. `BUTTON_DEFAULTS.tone` is not.
|
|
38
|
+
*
|
|
39
|
+
* Type-only imports are erased before they reach a runtime and are skipped.
|
|
40
|
+
*
|
|
41
|
+
* WHAT IT CANNOT SEE, AND WHY THAT IS REPORTED RATHER THAN ASSUMED.
|
|
42
|
+
*
|
|
43
|
+
* Resolution is source-level: relative paths, and workspace packages resolved
|
|
44
|
+
* through their own barrel. It follows `export { X } from "./y"` and `export *
|
|
45
|
+
* from "./y"` one package deep. It does not run a bundler, so an import it
|
|
46
|
+
* cannot resolve is returned in `unresolved` rather than being counted as
|
|
47
|
+
* clean. `wiring.mjs` established that discipline — "could not tell" is its own
|
|
48
|
+
* outcome, never folded into "fine" — and the same reason applies here: this
|
|
49
|
+
* rule's whole value is catching a silence, so it must not produce one.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
53
|
+
// G-101. The schedule lives in one place; a second copy here would be free to
|
|
54
|
+
// disagree with the table a consumer reads to plan their upgrade.
|
|
55
|
+
import { SCHEDULE, blocksAt } from "./enforcement.mjs";
|
|
56
|
+
import { join, relative, extname, sep, dirname, resolve as resolvePath } from "node:path";
|
|
57
|
+
|
|
58
|
+
const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
59
|
+
const SKIP = new Set(["node_modules", "dist", "build", ".next", "coverage", "out"]);
|
|
60
|
+
|
|
61
|
+
/** Extension order for a bare specifier. `.tsx` first: components outnumber modules. */
|
|
62
|
+
const RESOLVE_ORDER = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
|
|
63
|
+
|
|
64
|
+
function walk(dir, root, out) {
|
|
65
|
+
let entries;
|
|
66
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
67
|
+
for (const name of entries) {
|
|
68
|
+
if (SKIP.has(name) || name.startsWith(".")) continue;
|
|
69
|
+
const full = join(dir, name);
|
|
70
|
+
let st; try { st = statSync(full); } catch { continue; }
|
|
71
|
+
if (st.isDirectory()) walk(full, root, out);
|
|
72
|
+
else if (SOURCE.has(extname(name))) out.push(relative(root, full).split(sep).join("/"));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A test file discusses the boundary; it does not cross it. Same exclusion as boundaries.mjs. */
|
|
77
|
+
const isTest = (p) => /\.(test|spec)\.[jt]sx?$/.test(p) || /(^|\/)__tests__\//.test(p);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A file whose string literals are source for a DIFFERENT repository.
|
|
81
|
+
*
|
|
82
|
+
* A generator holds `import { Card } from "./card"` as data. Reading that as an
|
|
83
|
+
* import of this repo's `./card` produced fifteen phantom "could not tell"
|
|
84
|
+
* entries the first time this ran against Gyde itself — a checker whose whole
|
|
85
|
+
* value is refusing to say "clean" without evidence, manufacturing doubt out of
|
|
86
|
+
* a template. The marker is the repo's existing convention (paths.test.mjs);
|
|
87
|
+
* honoured here so one declaration covers both checks.
|
|
88
|
+
*/
|
|
89
|
+
const TEMPLATE_MARKER = "@gyde-emits-source-for-another-repo";
|
|
90
|
+
|
|
91
|
+
const stripComments = (text) =>
|
|
92
|
+
text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Is this module a client module?
|
|
96
|
+
*
|
|
97
|
+
* The directive is only a directive when it leads the file. A "use client"
|
|
98
|
+
* further down is an ordinary string expression and React ignores it — so this
|
|
99
|
+
* checks position, not presence. The emitted defaults module states the rule in
|
|
100
|
+
* a comment containing the exact characters, which is why comments come off
|
|
101
|
+
* first: a file explaining the boundary must not read as one that declares it.
|
|
102
|
+
*/
|
|
103
|
+
export function isClientModule(text) {
|
|
104
|
+
const head = stripComments(text).trim();
|
|
105
|
+
return /^["']use client["']\s*;?/.test(head);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Imports, with their bindings. Type-only imports and bindings are dropped:
|
|
110
|
+
* they do not exist at runtime and cannot be spread.
|
|
111
|
+
*/
|
|
112
|
+
export function parseImports(text) {
|
|
113
|
+
const src = stripComments(text);
|
|
114
|
+
const out = [];
|
|
115
|
+
const re = /import\s+(type\s+)?([^;'"]*?)\s*from\s*["']([^"']+)["']/g;
|
|
116
|
+
let m;
|
|
117
|
+
while ((m = re.exec(src))) {
|
|
118
|
+
const [, typeOnly, clause, spec] = m;
|
|
119
|
+
if (typeOnly) continue;
|
|
120
|
+
const line = src.slice(0, m.index).split("\n").length;
|
|
121
|
+
const bindings = [];
|
|
122
|
+
|
|
123
|
+
const named = clause.match(/\{([^}]*)\}/);
|
|
124
|
+
if (named) {
|
|
125
|
+
for (const part of named[1].split(",")) {
|
|
126
|
+
const p = part.trim();
|
|
127
|
+
if (!p || /^type\s/.test(p)) continue;
|
|
128
|
+
const [imported, local] = p.split(/\s+as\s+/).map((s) => s.trim());
|
|
129
|
+
bindings.push({ imported, local: local || imported });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// A default or namespace binding is whatever sits outside the braces.
|
|
133
|
+
const outside = clause.replace(/\{[^}]*\}/, "").replace(/^\s*,|,\s*$/g, "").trim();
|
|
134
|
+
if (outside && !outside.startsWith("*")) {
|
|
135
|
+
bindings.push({ imported: "default", local: outside });
|
|
136
|
+
}
|
|
137
|
+
if (bindings.length) out.push({ spec, bindings, line });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Re-exports, for following a barrel to the module that actually declares a name. */
|
|
143
|
+
export function parseReexports(text) {
|
|
144
|
+
const src = stripComments(text);
|
|
145
|
+
const out = [];
|
|
146
|
+
const re = /export\s+(type\s+)?(\*|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
|
|
147
|
+
let m;
|
|
148
|
+
while ((m = re.exec(src))) {
|
|
149
|
+
const [, typeOnly, clause, spec] = m;
|
|
150
|
+
if (typeOnly) continue;
|
|
151
|
+
if (clause === "*") { out.push({ spec, star: true, names: [] }); continue; }
|
|
152
|
+
const names = clause.replace(/[{}]/g, "").split(",")
|
|
153
|
+
.map((s) => s.trim()).filter(Boolean).filter((s) => !/^type\s/.test(s))
|
|
154
|
+
.map((s) => { const [i, a] = s.split(/\s+as\s+/).map((x) => x.trim()); return { imported: i, exported: a || i }; });
|
|
155
|
+
out.push({ spec, star: false, names });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Does this module declare (rather than re-export) the name? */
|
|
161
|
+
function declaresName(text, name) {
|
|
162
|
+
const src = stripComments(text);
|
|
163
|
+
if (name === "default") return /export\s+default\b/.test(src);
|
|
164
|
+
return new RegExp(`export\\s+(?:async\\s+)?(?:const|let|var|function|class)\\s+${name}\\b`).test(src)
|
|
165
|
+
|| new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*(?!from)`).test(src);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readFile(root, rel) {
|
|
169
|
+
try { return readFileSync(join(root, rel), "utf8"); } catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolve a source-relative specifier to a repo-relative file, or null. */
|
|
173
|
+
function resolveRelative(root, fromFile, spec) {
|
|
174
|
+
const base = resolvePath("/" + dirname(fromFile), spec).slice(1);
|
|
175
|
+
const candidates = [
|
|
176
|
+
...RESOLVE_ORDER.map((e) => base + e),
|
|
177
|
+
...RESOLVE_ORDER.map((e) => base + "/index" + e),
|
|
178
|
+
base,
|
|
179
|
+
];
|
|
180
|
+
for (const c of candidates) if (readFile(root, c) !== null) return c;
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a workspace package specifier to its barrel.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately does not read `exports` maps in full. It takes the package's
|
|
188
|
+
* `main`/`module`/`exports["."]` if that points at source, and otherwise tries
|
|
189
|
+
* the conventional source barrel. A build output path is useless here — the
|
|
190
|
+
* directive lives in source, and a compiled bundle has already erased the
|
|
191
|
+
* question.
|
|
192
|
+
*/
|
|
193
|
+
function resolvePackage(root, spec, packages) {
|
|
194
|
+
const pkg = packages.find((p) => p.name === spec || spec.startsWith(p.name + "/"));
|
|
195
|
+
if (!pkg) return null;
|
|
196
|
+
|
|
197
|
+
if (spec !== pkg.name) {
|
|
198
|
+
const sub = spec.slice(pkg.name.length + 1);
|
|
199
|
+
return resolveRelative(root, pkg.path + "/x", "./" + sub);
|
|
200
|
+
}
|
|
201
|
+
for (const guess of ["src/index", "index", "src/main"]) {
|
|
202
|
+
const hit = resolveRelative(root, pkg.path + "/x", "./" + guess);
|
|
203
|
+
if (hit) return hit;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveSpec(root, fromFile, spec, packages) {
|
|
209
|
+
if (spec.startsWith(".")) return resolveRelative(root, fromFile, spec);
|
|
210
|
+
return resolvePackage(root, spec, packages);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Follow `name` from `file` to the module that declares it.
|
|
215
|
+
*
|
|
216
|
+
* Depth-limited and cycle-guarded. A barrel that re-exports a barrel is normal;
|
|
217
|
+
* a barrel that eventually re-exports itself is not, and must not hang a gate.
|
|
218
|
+
*/
|
|
219
|
+
function originOf(root, file, name, packages, seen = new Set(), depth = 0) {
|
|
220
|
+
if (depth > 8 || seen.has(file + "#" + name)) return null;
|
|
221
|
+
seen.add(file + "#" + name);
|
|
222
|
+
|
|
223
|
+
const text = readFile(root, file);
|
|
224
|
+
if (text === null) return null;
|
|
225
|
+
if (declaresName(text, name)) return { file, text };
|
|
226
|
+
|
|
227
|
+
for (const rx of parseReexports(text)) {
|
|
228
|
+
const target = resolveSpec(root, file, rx.spec, packages);
|
|
229
|
+
if (!target) continue;
|
|
230
|
+
if (rx.star) {
|
|
231
|
+
const hit = originOf(root, target, name, packages, seen, depth + 1);
|
|
232
|
+
if (hit) return hit;
|
|
233
|
+
} else {
|
|
234
|
+
const match = rx.names.find((n) => n.exported === name);
|
|
235
|
+
if (match) {
|
|
236
|
+
const hit = originOf(root, target, match.imported, packages, seen, depth + 1);
|
|
237
|
+
if (hit) return hit;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Value-position uses of a local binding.
|
|
246
|
+
*
|
|
247
|
+
* Two forms, both unambiguous and both the documented failure:
|
|
248
|
+
* {...X} the spread that produced the original defect
|
|
249
|
+
* X.prop a property read, which yields undefined for the same reason
|
|
250
|
+
*
|
|
251
|
+
* JSX use (`<X`, `</X`) is explicitly not a crossing. Anything subtler than
|
|
252
|
+
* these two forms is left alone rather than guessed at: a false positive here
|
|
253
|
+
* costs more than a miss, because the miss is still caught by the next person
|
|
254
|
+
* to spread the thing.
|
|
255
|
+
*/
|
|
256
|
+
export function valueUses(text, local) {
|
|
257
|
+
const src = stripComments(text);
|
|
258
|
+
const uses = [];
|
|
259
|
+
const spread = new RegExp(`\\{\\s*\\.\\.\\.\\s*${local}\\s*\\}`, "g");
|
|
260
|
+
const member = new RegExp(`(?<![\\w$.<])${local}\\s*\\.\\s*[A-Za-z_$]`, "g");
|
|
261
|
+
|
|
262
|
+
src.split("\n").forEach((line, i) => {
|
|
263
|
+
if (spread.test(line)) uses.push({ line: i + 1, form: "spread", source: line.trim() });
|
|
264
|
+
else if (member.test(line)) uses.push({ line: i + 1, form: "member", source: line.trim() });
|
|
265
|
+
spread.lastIndex = 0; member.lastIndex = 0;
|
|
266
|
+
});
|
|
267
|
+
return uses;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Check a tree for values crossing the client boundary.
|
|
272
|
+
*
|
|
273
|
+
* `packages` is the workspace listing from `discover()` — `{ name, path }` — and
|
|
274
|
+
* without it only relative imports resolve. That is a real reduction in reach
|
|
275
|
+
* and is reported: `unresolved` is the count of imports this could not follow,
|
|
276
|
+
* and a caller that prints findings without printing that number is publishing
|
|
277
|
+
* a clean bill it did not earn.
|
|
278
|
+
*/
|
|
279
|
+
export function checkClientBoundary(root, { packages = [] } = {}) {
|
|
280
|
+
const files = [];
|
|
281
|
+
walk(root, root, files);
|
|
282
|
+
|
|
283
|
+
const crossings = [];
|
|
284
|
+
const unresolved = [];
|
|
285
|
+
let serverFilesChecked = 0;
|
|
286
|
+
|
|
287
|
+
for (const file of files.sort()) {
|
|
288
|
+
if (isTest(file)) continue;
|
|
289
|
+
const text = readFile(root, file);
|
|
290
|
+
if (text === null) continue;
|
|
291
|
+
if (text.includes(TEMPLATE_MARKER)) continue;
|
|
292
|
+
if (isClientModule(text)) continue; // a client file may spread freely
|
|
293
|
+
serverFilesChecked++;
|
|
294
|
+
|
|
295
|
+
for (const imp of parseImports(text)) {
|
|
296
|
+
const target = resolveSpec(root, file, imp.spec, packages);
|
|
297
|
+
if (!target) {
|
|
298
|
+
if (imp.spec.startsWith(".") || packages.some((p) => imp.spec.startsWith(p.name))) {
|
|
299
|
+
unresolved.push({ file, line: imp.line, spec: imp.spec });
|
|
300
|
+
}
|
|
301
|
+
continue; // third-party: not our boundary to police
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const b of imp.bindings) {
|
|
305
|
+
const uses = valueUses(text, b.local);
|
|
306
|
+
if (!uses.length) continue; // imported but never used as a value
|
|
307
|
+
|
|
308
|
+
const origin = originOf(root, target, b.imported, packages);
|
|
309
|
+
if (!origin) { unresolved.push({ file, line: imp.line, spec: imp.spec, name: b.imported }); continue; }
|
|
310
|
+
if (!isClientModule(origin.text)) continue;
|
|
311
|
+
|
|
312
|
+
for (const u of uses) {
|
|
313
|
+
crossings.push({
|
|
314
|
+
file, line: u.line, form: u.form, source: u.source,
|
|
315
|
+
binding: b.local, imported: b.imported, from: imp.spec, declaredIn: origin.file,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { crossings, unresolved, serverFilesChecked, filesWalked: files.length };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The template version from which a crossing blocks the gate.
|
|
327
|
+
*
|
|
328
|
+
* WHY THIS IS KEYED TO THE PRODUCT'S TEMPLATE VERSION, NOT GYDE'S.
|
|
329
|
+
*
|
|
330
|
+
* Every existing consumer has a committed ledger. A new finding class that
|
|
331
|
+
* blocks on upgrade turns their gate red for something they have not been told
|
|
332
|
+
* about yet — and "amnesty never" (CHARTER §4) means it cannot be quietly
|
|
333
|
+
* baselined away either, so there would be no good move available to them.
|
|
334
|
+
*
|
|
335
|
+
* Keying to Gyde's own version would be worse: the day Gyde reaches this
|
|
336
|
+
* number, every consumer's gate changes behaviour at once without anyone
|
|
337
|
+
* deciding to change it. That is a time bomb wearing a version number.
|
|
338
|
+
*
|
|
339
|
+
* So it reads the version in the product's OWN `gyde-emitted.json`, which only
|
|
340
|
+
* moves when they run `upgrade` and take the change. Blocking becomes something
|
|
341
|
+
* a product opts into, visible in a diff, at a moment of their choosing.
|
|
342
|
+
*
|
|
343
|
+
* A repository with no manifest — an adoption repo where `init` found an
|
|
344
|
+
* existing system and refused to write over it — is advisory forever by this
|
|
345
|
+
* rule, because it never opted into anything. That is correct, and it is also
|
|
346
|
+
* a gap: those are exactly the repositories most likely to have the defect.
|
|
347
|
+
* Closing it needs a different mechanism than a template version.
|
|
348
|
+
*/
|
|
349
|
+
export const BLOCKS_FROM = SCHEDULE["client-boundary"].version;
|
|
350
|
+
|
|
351
|
+
const parts = (v) => String(v).split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0);
|
|
352
|
+
|
|
353
|
+
/** Is `a` at or beyond `b`? Numeric compare; a malformed version reads as 0.0.0 and does not block. */
|
|
354
|
+
export function atLeast(a, b) {
|
|
355
|
+
const [x, y, z] = parts(a), [p, q, r] = parts(b);
|
|
356
|
+
return x !== p ? x > p : y !== q ? y > q : z >= r;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Does a crossing block the gate for a product at this template version? */
|
|
360
|
+
export function blocks(templateVersion) {
|
|
361
|
+
return blocksAt("client-boundary", templateVersion);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function formatClientBoundary(result, { blocking = null } = {}) {
|
|
365
|
+
const lines = [];
|
|
366
|
+
if (result.crossings.length === 0) {
|
|
367
|
+
lines.push(`no values cross the "use client" boundary (${result.serverFilesChecked} server file(s) checked)`);
|
|
368
|
+
} else {
|
|
369
|
+
lines.push(`${result.crossings.length} value(s) crossing the "use client" boundary:`);
|
|
370
|
+
for (const c of result.crossings) {
|
|
371
|
+
lines.push(` ${c.file}:${c.line} ${c.form} of \`${c.binding}\``);
|
|
372
|
+
lines.push(` declared in ${c.declaredIn}, which is "use client"`);
|
|
373
|
+
lines.push(` ${c.source}`);
|
|
374
|
+
}
|
|
375
|
+
lines.push("");
|
|
376
|
+
lines.push(" A server component importing a value from a \"use client\" module gets an");
|
|
377
|
+
lines.push(" opaque client reference, not the value. Spreading one yields {} and every");
|
|
378
|
+
lines.push(" prop silently reverts to undefined. Move the value to a module with no");
|
|
379
|
+
lines.push(" directive, importing only types.");
|
|
380
|
+
|
|
381
|
+
// Said in words, every run. An advisory finding that reports quietly is
|
|
382
|
+
// indistinguishable from one that found nothing — the same reason
|
|
383
|
+
// `fail-on: none` is loud in action.yml.
|
|
384
|
+
if (blocking === false) {
|
|
385
|
+
lines.push("");
|
|
386
|
+
lines.push(` ADVISORY — this did not fail the gate. It blocks from template v${BLOCKS_FROM},`);
|
|
387
|
+
lines.push(" which you take by running `upgrade`. Fixing them now costs nothing later.");
|
|
388
|
+
} else if (blocking === true) {
|
|
389
|
+
lines.push("");
|
|
390
|
+
lines.push(` BLOCKING — your template version is at or beyond v${BLOCKS_FROM}.`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (result.unresolved.length) {
|
|
394
|
+
lines.push("");
|
|
395
|
+
lines.push(` could not tell for ${result.unresolved.length} import(s) — not counted as clean:`);
|
|
396
|
+
for (const u of result.unresolved.slice(0, 5)) lines.push(` ${u.file}:${u.line} ${u.spec}`);
|
|
397
|
+
}
|
|
398
|
+
return lines.join("\n");
|
|
399
|
+
}
|
package/compound.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-72 — a compound part with no root of its own above it.
|
|
3
|
+
*
|
|
4
|
+
* `<Select.Trigger>` outside a `<Select>` reads its root's context and gets
|
|
5
|
+
* nothing. What happens next depends on the library: a thrown error if it
|
|
6
|
+
* checks, `undefined` spreading through the part's props if it does not, and
|
|
7
|
+
* most often a control that renders, looks approximately right, and does not
|
|
8
|
+
* work. The compiler has no opinion — `Select.Trigger` is a real export and the
|
|
9
|
+
* JSX is valid.
|
|
10
|
+
*
|
|
11
|
+
* It is the same family as G-67. The type system is satisfied, every styling
|
|
12
|
+
* rule passes, and the defect is a relationship between two places in a tree
|
|
13
|
+
* that nothing in the toolchain looks at.
|
|
14
|
+
*
|
|
15
|
+
* THE FALSE POSITIVE THAT SHAPES THIS RULE.
|
|
16
|
+
*
|
|
17
|
+
* The obvious implementation walks the JSX tree and reports any part without an
|
|
18
|
+
* enclosing root. It is wrong, and commonly wrong:
|
|
19
|
+
*
|
|
20
|
+
* function Header() { return <Card.Header>…</Card.Header>; } // no root above it
|
|
21
|
+
* function Page() { return <Card><Header /></Card>; } // …and it is fine
|
|
22
|
+
*
|
|
23
|
+
* Extracting a part into its own component is normal, good, and indistinguishable
|
|
24
|
+
* from the defect without following the call graph across files. A rule that
|
|
25
|
+
* fires on that is a rule that fires on refactoring, and it would be switched
|
|
26
|
+
* off the first week.
|
|
27
|
+
*
|
|
28
|
+
* So the rule is narrower than the title and says so: a part is reported when
|
|
29
|
+
* its root appears NOWHERE in the file. That is the case no composition can
|
|
30
|
+
* explain away locally — the file uses `Card.Header` and has no idea `Card`
|
|
31
|
+
* exists. It catches the orphan left behind when a root is deleted or renamed,
|
|
32
|
+
* which is the shape that actually ships.
|
|
33
|
+
*
|
|
34
|
+
* The stricter version needs a resolved component graph, which is G-73's
|
|
35
|
+
* territory. Recording the limit here so the next person does not "fix" this by
|
|
36
|
+
* widening it into the refactoring case.
|
|
37
|
+
*
|
|
38
|
+
* @gyde-emits-source-for-another-repo — the fixtures beside this file are JSX
|
|
39
|
+
* written as data for a repository that is not this one.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export const RULE = "compound-part-without-root";
|
|
43
|
+
|
|
44
|
+
/** `Root.Part`, and the nested `Root.Group.Item` form some libraries use. */
|
|
45
|
+
const PART = /^([A-Z]\w*)((?:\.[A-Z]\w*)+)$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every JSX element opened in a source file.
|
|
49
|
+
*
|
|
50
|
+
* Attributes are allowed to span lines, because in real JSX they almost always
|
|
51
|
+
* do. Matching `[^>]*` on a single line finds the opening `<Card` of a
|
|
52
|
+
* multi-line element and never its name, so the root would appear absent in
|
|
53
|
+
* exactly the files most likely to be real.
|
|
54
|
+
*/
|
|
55
|
+
function elements(text) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const re = /<\s*([A-Z][\w.]*)(\s[\s\S]*?)?(\/?)>/g;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = re.exec(text))) {
|
|
60
|
+
out.push({ name: m[1], index: m.index });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stripComments = (text) =>
|
|
66
|
+
text.replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, " "))
|
|
67
|
+
.replace(/^\s*\/\/[^\n]*/gm, "");
|
|
68
|
+
|
|
69
|
+
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compound parts used in a file whose root is not used anywhere in that file.
|
|
73
|
+
*
|
|
74
|
+
* `roots` narrows the check to a known set — the design system's own exports —
|
|
75
|
+
* so `Foo.Bar` from an unrelated namespace object is not mistaken for a
|
|
76
|
+
* compound component. Without it every `Object.keys` shaped identifier in JSX
|
|
77
|
+
* position would be a candidate, and the rule would be guessing.
|
|
78
|
+
*/
|
|
79
|
+
export function orphanedParts(text, { filename = "", roots = null } = {}) {
|
|
80
|
+
const src = stripComments(text);
|
|
81
|
+
const els = elements(src);
|
|
82
|
+
if (els.length === 0) return [];
|
|
83
|
+
|
|
84
|
+
const opened = new Set(els.map((e) => e.name));
|
|
85
|
+
const found = [];
|
|
86
|
+
const reported = new Set();
|
|
87
|
+
|
|
88
|
+
for (const el of els) {
|
|
89
|
+
const m = el.name.match(PART);
|
|
90
|
+
if (!m) continue;
|
|
91
|
+
const [, root, rest] = m;
|
|
92
|
+
|
|
93
|
+
if (roots && !roots.has(root)) continue; // not one of ours to judge
|
|
94
|
+
|
|
95
|
+
// The root itself, or the explicit `Root.Root` some libraries require.
|
|
96
|
+
if (opened.has(root) || opened.has(`${root}.Root`)) continue;
|
|
97
|
+
|
|
98
|
+
const key = `${el.name}@${el.index}`;
|
|
99
|
+
if (reported.has(key)) continue;
|
|
100
|
+
reported.add(key);
|
|
101
|
+
|
|
102
|
+
found.push({
|
|
103
|
+
file: filename, rule: RULE, line: lineOf(src, el.index),
|
|
104
|
+
part: el.name, root, member: rest.slice(1),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatOrphanedParts(findings) {
|
|
112
|
+
if (findings.length === 0) return "no compound part is used without its root";
|
|
113
|
+
const L = [`${findings.length} compound part(s) used with no root in the file:`];
|
|
114
|
+
for (const f of findings) {
|
|
115
|
+
L.push(` ${f.file}:${f.line} <${f.part}> — <${f.root}> is not used anywhere in this file`);
|
|
116
|
+
}
|
|
117
|
+
L.push("");
|
|
118
|
+
L.push(" A part reads its root's context. Without one it gets nothing, and most");
|
|
119
|
+
L.push(" libraries render something that looks approximately right and does not work.");
|
|
120
|
+
L.push(" Either wrap it in its root, or the root was renamed and this is what was");
|
|
121
|
+
L.push(" left behind.");
|
|
122
|
+
return L.join("\n");
|
|
123
|
+
}
|