@weatherboard/gyde-design 0.3.0 → 0.4.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.
Files changed (4) hide show
  1. package/cli.mjs +31 -5
  2. package/emit.mjs +1 -1
  3. package/package.json +1 -1
  4. package/stylex.mjs +108 -0
package/cli.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * matched nothing are reported as suspicious rather than omitted.
22
22
  */
23
23
 
24
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
25
25
  import { execFileSync } from "node:child_process";
26
26
  import { join, resolve, dirname } from "node:path";
27
27
 
@@ -43,7 +43,7 @@ import { checkDocDrift, formatDocDrift } from "./docdrift.mjs";
43
43
  import { reconcile } from "./adoption.mjs";
44
44
  import { detectTailwind, formatTailwind } from "./tailwind.mjs";
45
45
  import { migrationProgress, formatMigration } from "./migration.mjs";
46
- import { checkStyleX, formatStyleX } from "./stylex.mjs";
46
+ import { checkStyleX, formatStyleX, checkClosedStyling, formatClosedStyling } from "./stylex.mjs";
47
47
  import { blocksAt, formatSchedule, SCHEDULE } from "./enforcement.mjs";
48
48
  import { buildUsage, guidance, formatUsage } from "./usage.mjs";
49
49
  import { record, gate, formatGate, adopt, LEDGER_NOTE } from "./ratchet.mjs";
@@ -64,6 +64,24 @@ import { loadRules } from "./rules.mjs";
64
64
  * silently never fires. Returns null when it cannot tell, so the caller falls
65
65
  * back explicitly instead of this function inventing an answer.
66
66
  */
67
+ /**
68
+ * The design system's own component sources, read from disk.
69
+ *
70
+ * Read rather than imported (G-100): a prop removed from a type but still
71
+ * spread onto the element is a hole an import-based check cannot see, and the
72
+ * emitted set makes the same choice for the same reason.
73
+ */
74
+ function systemComponents(root, design) {
75
+ const dir = join(root, design.systemPath || "packages/design-system", "src");
76
+ let names; try { names = readdirSync(dir); } catch { return []; }
77
+ return names
78
+ .filter((n) => /^[A-Z]\w*\.tsx$/.test(n))
79
+ .map((n) => {
80
+ try { return { file: n, text: readFileSync(join(dir, n), "utf8") }; } catch { return null; }
81
+ })
82
+ .filter(Boolean);
83
+ }
84
+
67
85
  function detectDefaultBranch(root) {
68
86
  try {
69
87
  const head = execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
@@ -276,7 +294,11 @@ function cmdScan(root, config) {
276
294
  // not be removed while classes still resolve through it.
277
295
  // G-100. The positive half of G-98: one styling layer, and it is wired.
278
296
  console.log("");
279
- console.log(formatStyleX(checkStyleX(root, { packages: ws.packages, renders: sum.carryUI })));
297
+ console.log(formatClosedStyling(checkClosedStyling(root, {
298
+ packages: ws.packages,
299
+ renders: sum.carryUI,
300
+ components: systemComponents(root, config.design || {}),
301
+ })));
280
302
 
281
303
  console.log("");
282
304
  console.log(formatTailwind(detectTailwind(root, {
@@ -653,13 +675,17 @@ function cmdGate(root, config) {
653
675
  utilityClasses: result.tailwindClasses,
654
676
  cssDirectives: result.tailwindCssDirectives,
655
677
  });
656
- const sx = checkStyleX(root, { packages: ws.packages, renders: summarise(ws).carryUI });
678
+ const sx = checkClosedStyling(root, {
679
+ packages: ws.packages,
680
+ renders: summarise(ws).carryUI,
681
+ components: systemComponents(root, design),
682
+ });
657
683
 
658
684
  const breaches = [
659
685
  { rule: "client-boundary", n: cb.crossings.length, what: `${cb.crossings.length} value(s) crossing the "use client" boundary` },
660
686
  { rule: "tailwind-present", n: tw.present ? tw.dependencies.length + tw.configs.length : 0, what: "Tailwind is installed" },
661
687
  { rule: "tailwind-in-css", n: result.tailwindCssDirectives, what: `${result.tailwindCssDirectives} Tailwind directive(s) in stylesheets` },
662
- { rule: "styling-layer", n: sx.unknown ? 0 : sx.missing.length + sx.unwired.length + sx.competing.length, what: "the styling layer is not StyleX, or is not wired" },
688
+ { rule: "styling-layer", n: sx.unknown || sx.ok ? 0 : sx.hatches.length + sx.competing.length, what: "the styling layer is open a call site can restyle the component set" },
663
689
  ].filter((b) => b.n > 0);
664
690
 
665
691
  const blocking = breaches.filter((b) => blocksAt(b.rule, templateVersion));
package/emit.mjs CHANGED
@@ -60,7 +60,7 @@ import { generateCss, SEED, varName, CSS_HEADER } from "./tokens.mjs";
60
60
  * `emit.test.mjs` enforces it: a fresh scaffold must pass the gate Gyde would
61
61
  * run against it.
62
62
  */
63
- export const SCAFFOLD_VERSION = "0.2.4";
63
+ export const SCAFFOLD_VERSION = "0.3.0";
64
64
 
65
65
  const GENERATED = (what) => `/* GENERATED BY GYDE — then yours.
66
66
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weatherboard/gyde-design",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Scaffolds a design system into a product repository, then keeps auditing it.",
6
6
  "type": "module",
package/stylex.mjs CHANGED
@@ -45,6 +45,7 @@
45
45
  */
46
46
 
47
47
  import { readFileSync, existsSync, readdirSync } from "node:fs";
48
+ import { propMembers } from "./props.mjs";
48
49
  import { join } from "node:path";
49
50
 
50
51
  export const RULE = "styling-layer";
@@ -176,3 +177,110 @@ export function formatStyleX(result) {
176
177
  }
177
178
  return L.join("\n");
178
179
  }
180
+
181
+ /**
182
+ * G-100, narrowed — the styling layer must be CLOSED, not specifically StyleX.
183
+ *
184
+ * The original mandate named an implementation, and its own scaffold failed it.
185
+ * Gyde emits components that style through data attributes against one
186
+ * stylesheet of custom properties, exposing no `className` and no `style` prop
187
+ * at all — and `emit.mjs` ships a test that fails the build if one appears.
188
+ *
189
+ * That set is STRICTER than StyleX, not looser. `stylex.props()` returns a
190
+ * className, which is the escape hatch this rule exists to close. Converting
191
+ * the templates to satisfy the letter of G-100 would have weakened the wall and
192
+ * added a compiler to every scaffold — the cost G-58 Decision 3 measured and
193
+ * declined.
194
+ *
195
+ * So the mandate is restated as the property it was always about:
196
+ *
197
+ * A design system's components must not accept styling from the call site.
198
+ *
199
+ * StyleX satisfies it when wired. So does a closed component set over custom
200
+ * properties. Tailwind does not, which was the actual point (G-98), and neither
201
+ * does a component that takes `className`.
202
+ *
203
+ * WHY THE COMPONENT API IS THE REAL CHECK.
204
+ *
205
+ * Package detection asks what a repository INSTALLED. This asks what its
206
+ * components ACCEPT, which is the thing that decides whether every other rule
207
+ * is enforceable or advisory: one `className` prop on one shared component
208
+ * makes the token layer optional everywhere it is used, whatever the manifest
209
+ * says. G-58 Decision 2 called the wrapper the wall; this is the test that the
210
+ * wall has no door in it.
211
+ */
212
+ export const CLOSED_RULE = "styling-layer";
213
+
214
+ /** Props that hand styling control back to the call site. */
215
+ const ESCAPE_HATCH = /^(className|class|style|css|sx|tw|unstyled|classNames)$/;
216
+
217
+ /**
218
+ * Does the design system expose a way to style it from outside?
219
+ *
220
+ * `components` is the source of the system's own components, as
221
+ * `[{ file, text }]`. Read rather than imported: a prop removed from a type but
222
+ * still spread onto the element is a hole an import-based check cannot see.
223
+ */
224
+ export function escapeHatches(components) {
225
+ const found = [];
226
+ for (const { file, text } of components) {
227
+ for (const m of propMembers(text, { filename: file })) {
228
+ if (!ESCAPE_HATCH.test(m.prop)) continue;
229
+ found.push({
230
+ file, rule: CLOSED_RULE, line: m.line, raw: m.prop,
231
+ prop: m.prop, type: m.type, kind: "escape-hatch",
232
+ });
233
+ }
234
+ }
235
+ return found;
236
+ }
237
+
238
+ /**
239
+ * Is the styling layer closed?
240
+ *
241
+ * Two ways to satisfy it, and the check reports WHICH, because "closed by
242
+ * construction" and "closed by StyleX" fail differently later and a consumer
243
+ * should know which one they are relying on.
244
+ */
245
+ export function checkClosedStyling(root, { packages = [], renders = null, components = [] } = {}) {
246
+ const hatches = escapeHatches(components);
247
+ const sx = checkStyleX(root, { packages, renders });
248
+
249
+ // A competing runtime CSS-in-JS is still a second way to express styles, and
250
+ // that stays a finding whichever implementation is chosen.
251
+ const competing = sx.unknown ? [] : sx.competing;
252
+
253
+ const hasStyleX = !sx.unknown && sx.missing.length === 0 && sx.unwired.length === 0;
254
+ const closedByConstruction = components.length > 0 && hatches.length === 0;
255
+
256
+ return {
257
+ unknown: components.length === 0 && sx.unknown,
258
+ hatches, competing,
259
+ implementation: hasStyleX ? "stylex" : closedByConstruction ? "closed-set" : null,
260
+ stylex: sx,
261
+ ok: hatches.length === 0 && competing.length === 0 && (hasStyleX || closedByConstruction),
262
+ componentsChecked: components.length,
263
+ };
264
+ }
265
+
266
+ export function formatClosedStyling(r) {
267
+ if (r.unknown) return "styling layer could not tell — no components read and no package manifest to judge";
268
+ if (r.ok) {
269
+ return r.implementation === "stylex"
270
+ ? `styling layer closed, via StyleX (${r.componentsChecked} component(s) expose no styling prop)`
271
+ : `styling layer closed by construction — ${r.componentsChecked} component(s) accept no className, style or css prop`;
272
+ }
273
+
274
+ const L = [`styling layer ${r.hatches.length + r.competing.length} problem(s)`];
275
+ for (const h of r.hatches) L.push(` ${h.file}:${h.line} ${h.type}.${h.prop} — the call site can restyle this component`);
276
+ for (const c of r.competing) L.push(` ${c.package} ${c.name}@${c.version} — ${c.why}`);
277
+ if (r.hatches.length) {
278
+ L.push("");
279
+ L.push(" One styling prop on one shared component makes the token layer optional");
280
+ L.push(" everywhere it is used, whatever the manifest says. The wrapper is the wall");
281
+ L.push(" (G-58 §2); this is the test that the wall has no door in it.");
282
+ } else if (!r.implementation) {
283
+ L.push(` no closed styling layer detected — neither StyleX wired, nor a component set without escape hatches`);
284
+ }
285
+ return L.join("\n");
286
+ }