@nurix/etna 0.18.18 → 0.18.20

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 (100) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/harness.json +2 -2
  3. package/data/harnesses/code/rules/general.md +2 -1
  4. package/data/harnesses/code/skills/design/SKILL.md +65 -0
  5. package/data/harnesses/code/skills/design/data/catalog/rules.csv +132 -0
  6. package/data/harnesses/code/skills/design/data/design-md/SCHEMA.md +317 -0
  7. package/data/harnesses/code/skills/design/data/design-md/library/airbnb.md +235 -0
  8. package/data/harnesses/code/skills/design/data/design-md/library/airtable.md +232 -0
  9. package/data/harnesses/code/skills/design/data/design-md/library/anthropic-claude.md +220 -0
  10. package/data/harnesses/code/skills/design/data/design-md/library/apple.md +220 -0
  11. package/data/harnesses/code/skills/design/data/design-md/library/binance.md +223 -0
  12. package/data/harnesses/code/skills/design/data/design-md/library/bmw.md +219 -0
  13. package/data/harnesses/code/skills/design/data/design-md/library/cal.md +220 -0
  14. package/data/harnesses/code/skills/design/data/design-md/library/clay.md +220 -0
  15. package/data/harnesses/code/skills/design/data/design-md/library/clickhouse.md +220 -0
  16. package/data/harnesses/code/skills/design/data/design-md/library/cohere.md +227 -0
  17. package/data/harnesses/code/skills/design/data/design-md/library/coinbase.md +237 -0
  18. package/data/harnesses/code/skills/design/data/design-md/library/composio.md +220 -0
  19. package/data/harnesses/code/skills/design/data/design-md/library/cursor.md +220 -0
  20. package/data/harnesses/code/skills/design/data/design-md/library/elevenlabs.md +220 -0
  21. package/data/harnesses/code/skills/design/data/design-md/library/expo.md +228 -0
  22. package/data/harnesses/code/skills/design/data/design-md/library/figma.md +221 -0
  23. package/data/harnesses/code/skills/design/data/design-md/library/framer.md +236 -0
  24. package/data/harnesses/code/skills/design/data/design-md/library/hashicorp.md +220 -0
  25. package/data/harnesses/code/skills/design/data/design-md/library/ibm.md +234 -0
  26. package/data/harnesses/code/skills/design/data/design-md/library/intercom.md +240 -0
  27. package/data/harnesses/code/skills/design/data/design-md/library/kraken.md +236 -0
  28. package/data/harnesses/code/skills/design/data/design-md/library/linear-app.md +237 -0
  29. package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +220 -0
  30. package/data/harnesses/code/skills/design/data/design-md/library/meta.md +220 -0
  31. package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +220 -0
  32. package/data/harnesses/code/skills/design/data/design-md/library/mintlify.md +220 -0
  33. package/data/harnesses/code/skills/design/data/design-md/library/miro.md +233 -0
  34. package/data/harnesses/code/skills/design/data/design-md/library/mistral-ai.md +220 -0
  35. package/data/harnesses/code/skills/design/data/design-md/library/mongodb.md +236 -0
  36. package/data/harnesses/code/skills/design/data/design-md/library/notion.md +220 -0
  37. package/data/harnesses/code/skills/design/data/design-md/library/nvidia.md +222 -0
  38. package/data/harnesses/code/skills/design/data/design-md/library/ollama.md +226 -0
  39. package/data/harnesses/code/skills/design/data/design-md/library/opencode-ai.md +223 -0
  40. package/data/harnesses/code/skills/design/data/design-md/library/posthog.md +234 -0
  41. package/data/harnesses/code/skills/design/data/design-md/library/raycast.md +220 -0
  42. package/data/harnesses/code/skills/design/data/design-md/library/replicate.md +220 -0
  43. package/data/harnesses/code/skills/design/data/design-md/library/resend.md +285 -0
  44. package/data/harnesses/code/skills/design/data/design-md/library/revolut.md +225 -0
  45. package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +219 -0
  46. package/data/harnesses/code/skills/design/data/design-md/library/sanity.md +251 -0
  47. package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +244 -0
  48. package/data/harnesses/code/skills/design/data/design-md/library/shopify.md +227 -0
  49. package/data/harnesses/code/skills/design/data/design-md/library/slack.md +240 -0
  50. package/data/harnesses/code/skills/design/data/design-md/library/spacex.md +252 -0
  51. package/data/harnesses/code/skills/design/data/design-md/library/spotify.md +248 -0
  52. package/data/harnesses/code/skills/design/data/design-md/library/stripe.md +241 -0
  53. package/data/harnesses/code/skills/design/data/design-md/library/supabase.md +255 -0
  54. package/data/harnesses/code/skills/design/data/design-md/library/superhuman.md +226 -0
  55. package/data/harnesses/code/skills/design/data/design-md/library/tesla.md +233 -0
  56. package/data/harnesses/code/skills/design/data/design-md/library/theverge.md +248 -0
  57. package/data/harnesses/code/skills/design/data/design-md/library/together-ai.md +220 -0
  58. package/data/harnesses/code/skills/design/data/design-md/library/uber.md +220 -0
  59. package/data/harnesses/code/skills/design/data/design-md/library/vercel.md +220 -0
  60. package/data/harnesses/code/skills/design/data/design-md/library/voltagent.md +245 -0
  61. package/data/harnesses/code/skills/design/data/design-md/library/warp.md +220 -0
  62. package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +220 -0
  63. package/data/harnesses/code/skills/design/data/design-md/library/wired.md +220 -0
  64. package/data/harnesses/code/skills/design/data/design-md/library/wise.md +220 -0
  65. package/data/harnesses/code/skills/design/data/design-md/library/x-ai.md +220 -0
  66. package/data/harnesses/code/skills/design/data/design-md/library/zapier.md +238 -0
  67. package/data/harnesses/code/skills/design/data/design-md/nurix.md +217 -0
  68. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +249 -0
  69. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +249 -0
  70. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +248 -0
  71. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +245 -0
  72. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +223 -0
  73. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +249 -0
  74. package/data/harnesses/code/skills/design/data/operator-pack/fixtures.md +88 -0
  75. package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +132 -0
  76. package/data/harnesses/code/skills/design/data/operator-pack/trust-rubric.md +76 -0
  77. package/data/harnesses/code/skills/design/references/create.md +126 -0
  78. package/data/harnesses/code/skills/design/references/diagnosis.md +108 -0
  79. package/data/harnesses/code/skills/design/references/hard-rules.md +155 -0
  80. package/data/harnesses/code/skills/design/references/improve.md +85 -0
  81. package/data/harnesses/code/skills/design/references/interaction.md +77 -0
  82. package/data/harnesses/code/skills/design/references/introspect.md +100 -0
  83. package/data/harnesses/code/skills/design/references/lifecycle.md +73 -0
  84. package/data/harnesses/code/skills/design/references/personality.md +504 -0
  85. package/data/harnesses/code/skills/design/references/structure.md +138 -0
  86. package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +706 -0
  87. package/data/harnesses/code/skills/design/scripts/detector.mjs +898 -0
  88. package/data/harnesses/code/skills/design/scripts/generate-fixtures.mjs +551 -0
  89. package/data/harnesses/code/skills/design/scripts/hook-config.json +11 -0
  90. package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +386 -0
  91. package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +280 -0
  92. package/data/harnesses/code/skills/design/scripts/render-rung.mjs +690 -0
  93. package/data/harnesses/code/skills/design/scripts/scaffold.mjs +552 -0
  94. package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +432 -0
  95. package/data/harnesses/etna/CLAUDE.md +1 -1
  96. package/data/harnesses/etna/harness.json +2 -2
  97. package/data/harnesses/etna/rules/general.md +2 -1
  98. package/data/harnesses/mobile/harness.json +2 -2
  99. package/data/harnesses/mobile/rules/general.md +2 -1
  100. package/package.json +1 -1
@@ -0,0 +1,898 @@
1
+ #!/usr/bin/env node
2
+ // skills/design/scripts/detector.mjs — per-edit source detector (beam 2, D19): every detector=source catalog rule as pure regex/token analysis over .tsx/.ts/.css/.html text, plus the lever-manifest drift check (cod-02, D10). Exit 1 on violations; --json emits [{rule,file,line,excerpt,fix}].
3
+ import { readFileSync } from 'node:fs';
4
+ import process from 'node:process';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // CLI parse
8
+ // ---------------------------------------------------------------------------
9
+
10
+ // Split argv into the file list, an optional --manifest path, and the --json flag.
11
+ function parseArgs(argv) {
12
+ const files = [];
13
+ let manifest = null;
14
+ let json = false;
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const a = argv[i];
17
+ if (a === '--json') json = true;
18
+ else if (a === '--manifest') manifest = argv[++i];
19
+ else if (a.startsWith('--manifest=')) manifest = a.slice('--manifest='.length);
20
+ else if (a === '--help' || a === '-h') { printUsage(); process.exit(0); }
21
+ else files.push(a);
22
+ }
23
+ return { files, manifest, json };
24
+ }
25
+
26
+ // Print the one-line usage banner.
27
+ function printUsage() {
28
+ process.stdout.write('usage: detector.mjs <file...> [--manifest design.md] [--json]\n');
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Color parsing (zero-dep): hex / rgb / hsl / oklch -> saturation signal
33
+ // ---------------------------------------------------------------------------
34
+
35
+ // Expand a 3- or 4-nibble hex to its 6/8-nibble form; return the lowercased hex digits.
36
+ function expandHex(hex) {
37
+ let h = hex.replace('#', '').toLowerCase();
38
+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
39
+ return h;
40
+ }
41
+
42
+ // Convert an r/g/b triple (0-255) to HSL saturation and lightness (both 0-1).
43
+ function rgbToSl(r, g, b) {
44
+ const rr = r / 255, gg = g / 255, bb = b / 255;
45
+ const max = Math.max(rr, gg, bb), min = Math.min(rr, gg, bb);
46
+ const l = (max + min) / 2;
47
+ const d = max - min;
48
+ const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
49
+ return { s, l };
50
+ }
51
+
52
+ // Parse one color literal into { sat, light } (HSL, 0-1) or { oklchChroma } or null.
53
+ function parseColor(lit) {
54
+ const s = lit.trim().toLowerCase();
55
+ if (s.startsWith('#')) {
56
+ const h = expandHex(s.match(/#[0-9a-f]{3,8}/i)?.[0] || s);
57
+ if (h.length < 6) return null;
58
+ const r = parseInt(h.slice(0, 2), 16);
59
+ const g = parseInt(h.slice(2, 4), 16);
60
+ const b = parseInt(h.slice(4, 6), 16);
61
+ if ([r, g, b].some(Number.isNaN)) return null;
62
+ const { s: sat, l } = rgbToSl(r, g, b);
63
+ return { sat, light: l };
64
+ }
65
+ const rgb = s.match(/rgba?\(([^)]*)\)/);
66
+ if (rgb) {
67
+ const parts = rgb[1].split(/[\s,/]+/).filter(Boolean).slice(0, 3).map(Number);
68
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
69
+ const { s: sat, l } = rgbToSl(parts[0], parts[1], parts[2]);
70
+ return { sat, light: l };
71
+ }
72
+ const hsl = s.match(/hsla?\(([^)]*)\)/);
73
+ if (hsl) {
74
+ const parts = hsl[1].split(/[\s,/]+/).filter(Boolean);
75
+ if (parts.length < 3) return null;
76
+ const sat = parseFloat(parts[1]) / 100;
77
+ const light = parseFloat(parts[2]) / 100;
78
+ if (Number.isNaN(sat) || Number.isNaN(light)) return null;
79
+ return { sat, light };
80
+ }
81
+ const okl = s.match(/oklch\(([^)]*)\)/);
82
+ if (okl) {
83
+ const parts = okl[1].split(/[\s,/]+/).filter(Boolean);
84
+ if (parts.length < 2) return null;
85
+ const chroma = parseFloat(parts[1]);
86
+ if (Number.isNaN(chroma)) return null;
87
+ return { oklchChroma: chroma };
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // Decide whether a parsed color exceeds the accent saturation ceiling (col-02, <80%).
93
+ function overAccentSaturation(parsed) {
94
+ if (!parsed) return false;
95
+ if (typeof parsed.oklchChroma === 'number') return parsed.oklchChroma > 0.37 * 0.8; // 0.296 heuristic
96
+ if (parsed.light == null || parsed.light < 0.12 || parsed.light > 0.92) return false; // sat is unstable near black/white
97
+ return parsed.sat >= 0.8;
98
+ }
99
+
100
+ // Normalize a color literal for set membership (drift compare): strip inner spaces, lowercase, expand short hex.
101
+ function normColor(lit) {
102
+ let s = lit.trim().toLowerCase().replace(/\s+/g, '');
103
+ const hx = s.match(/^#[0-9a-f]{3,8}$/);
104
+ if (hx) return '#' + expandHex(s);
105
+ return s;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Value regexes shared by several rules
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const RE_COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
113
+ const TW_UTILITY = '(?:bg|text|border|ring|ring-offset|fill|stroke|from|via|to|outline|divide|decoration|caret|accent|placeholder|shadow)';
114
+ const PALETTE_HUES = 'red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|grey|zinc|neutral|stone';
115
+ const RE_PALETTE_CLASS = new RegExp(`\\b${TW_UTILITY}-(${PALETTE_HUES})-(\\d{2,3})\\b`, 'g');
116
+ const STATUS_HUES = new Set(['red', 'rose', 'orange', 'amber', 'yellow', 'green', 'emerald', 'lime']);
117
+
118
+ // A spacing value in px is on the 4/8 scale (or the dense 2px step) when it is 0, 2, or a multiple of 4.
119
+ function spacingOnScale(px) { return px === 0 || px === 2 || px % 4 === 0; }
120
+ const RADIUS_SCALE = new Set([0, 2, 4, 6, 8, 12, 16, 24, 9999]);
121
+ const ZINDEX_SCALE = new Set([0, 10, 20, 40, 100, 1000]);
122
+
123
+ // Convert a rem or px length token to px (16px root); return null when it is neither.
124
+ function toPx(numStr, unit) {
125
+ const n = parseFloat(numStr);
126
+ if (Number.isNaN(n)) return null;
127
+ if (unit === 'rem') return n * 16;
128
+ if (unit === 'px' || unit === '') return n;
129
+ return null;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Findings sink
134
+ // ---------------------------------------------------------------------------
135
+
136
+ // Build a findings collector that de-dupes on (rule, file, line, excerpt).
137
+ function makeSink() {
138
+ const seen = new Set();
139
+ const list = [];
140
+ return {
141
+ add(rule, file, line, excerpt, fix) {
142
+ const ex = (excerpt || '').trim().slice(0, 200);
143
+ const key = `${rule}|${file}|${line}|${ex}`;
144
+ if (seen.has(key)) return;
145
+ seen.add(key);
146
+ list.push({ rule, file, line, excerpt: ex, fix });
147
+ },
148
+ list,
149
+ };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Per-line checks (run once per source line)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ // Run every single-line source check against one line and record any violations.
157
+ function checkLine(add, file, ext, lineNo, line, fileText) {
158
+ const isJsx = ext === 'tsx' || ext === 'ts' || ext === 'html';
159
+ const isCss = ext === 'css';
160
+
161
+ // --- Color ---
162
+ if (isJsx) {
163
+ // col-05: literal palette classes, hex/rgb/oklch literals, and dark: prefixes in JSX.
164
+ for (const m of line.matchAll(RE_PALETTE_CLASS)) {
165
+ add('col-05', file, lineNo, m[0], 'Replace the literal palette class with a semantic token class (bg-primary, text-muted-foreground, border-border).');
166
+ if (STATUS_HUES.has(m[1])) add('col-04', file, lineNo, m[0], 'Route status color through a semantic token: --success, --warning, or --destructive (e.g. text-success), never a raw palette hue.');
167
+ }
168
+ if (/\bdark:/.test(line)) add('col-05', file, lineNo, (line.match(/\bdark:[\w[\]/.-]+/) || ['dark:'])[0], 'Drop the dark: prefix; light is the default theme and dark is opted in at the token layer, not per class.');
169
+ for (const m of line.matchAll(RE_COLOR_LITERAL)) {
170
+ add('col-05', file, lineNo, m[0], 'Move the color literal into a semantic token; components consume tokens (bg-card, text-foreground), never raw hex/rgb/oklch.');
171
+ }
172
+ }
173
+ // col-02: accent saturation ceiling. JSX: any literal; CSS: only accent-role token definitions.
174
+ if (isJsx) {
175
+ for (const m of line.matchAll(RE_COLOR_LITERAL)) {
176
+ if (overAccentSaturation(parseColor(m[0]))) add('col-02', file, lineNo, m[0], 'Desaturate the accent below the 80% saturation ceiling (or oklch chroma <= 0.30); reserve one restrained accent.');
177
+ }
178
+ } else if (isCss && /--(?:primary|accent|ring)\b/.test(line)) {
179
+ for (const m of line.matchAll(RE_COLOR_LITERAL)) {
180
+ if (overAccentSaturation(parseColor(m[0]))) add('col-02', file, lineNo, m[0], 'The accent/primary/ring token exceeds the 80% saturation ceiling (oklch chroma <= 0.30); pull it down a step.');
181
+ }
182
+ }
183
+ // col-06: pure black / pure white literals.
184
+ {
185
+ const pm = line.match(/#000000\b|#000\b|#ffffff\b|#fff\b|rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)/i);
186
+ if (pm) add('col-06', file, lineNo, pm[0], 'Never pure #000000/#ffffff; start near #111827/#1F2937 for ink and an off-white surface, declared as a token.');
187
+ }
188
+ // col-07: opacity- or rgba-fabricated shades (rgba/hsla with alpha, 8-digit hex).
189
+ {
190
+ const fm = line.match(/#[0-9a-fA-F]{8}\b|rgba\([^)]*\)|hsla\([^)]*\)/);
191
+ if (fm && !/\b(?:overlay|scrim|backdrop|modal)\b/i.test(line)) add('col-07', file, lineNo, fm[0], 'Do not fabricate a shade via alpha; declare the shade as an explicit hex/oklch token instead.');
192
+ }
193
+ // col-09: disabled-state opacity band (0.38-0.5).
194
+ for (const m of line.matchAll(/(?:disabled:opacity-|opacity-)(\d{1,3})\b/g)) {
195
+ if (!/disabled/.test(line)) continue;
196
+ const o = parseInt(m[1], 10) / 100;
197
+ if (o < 0.38 || o > 0.5) add('col-09', file, lineNo, m[0], 'Set the disabled opacity into the 0.38-0.5 muted band (Tailwind opacity-40/45).');
198
+ }
199
+ // col-10: modal scrim opacity band (40-60% black).
200
+ if (/\b(?:overlay|scrim|backdrop)\b/i.test(line)) {
201
+ for (const m of line.matchAll(/bg-black\/(\d{1,3})\b/g)) {
202
+ const p = parseInt(m[1], 10);
203
+ if (p < 40 || p > 60) add('col-10', file, lineNo, m[0], 'Put the modal scrim in the 40-60% black band (bg-black/50).');
204
+ }
205
+ }
206
+ // col-13: semantic status color used as a solid fill (never a fill).
207
+ {
208
+ const cm = line.match(/\bbg-(?:success|warning)\b|\bbg-(?:green|emerald|amber|yellow|orange|rose)-\d{2,3}\b/);
209
+ if (cm && !/\/\d/.test(cm[0])) add('col-13', file, lineNo, cm[0], 'Status/price color is text-only, never a solid fill; use the token as text/border (text-success) or a low-opacity tint on a badge.');
210
+ }
211
+
212
+ // --- Typography ---
213
+ // typ-02: any explicit font-size below 11px.
214
+ for (const m of line.matchAll(/(?:font-size\s*:\s*|text-\[)(\d*\.?\d+)(px|rem)?/g)) {
215
+ const px = toPx(m[1], m[2] || 'px');
216
+ if (px != null && px < 11) add('typ-02', file, lineNo, m[0], 'Never render below 11px; body sits at 14px+ on operator surfaces.');
217
+ }
218
+ // typ-03: input font-size below 16px, only on a touch/mobile surface (scope-guarded).
219
+ if (/<(?:input|textarea)\b/i.test(line) && /(?:touch|mobile|viewport-fit|maximum-scale)/i.test(fileText)) {
220
+ for (const m of line.matchAll(/text-\[(\d*\.?\d+)(px|rem)?\]/g)) {
221
+ const px = toPx(m[1], m[2] || 'px');
222
+ if (px != null && px < 16) add('typ-03', file, lineNo, m[0], 'Form inputs on touch/mobile surfaces are >= 16px to defeat iOS zoom.');
223
+ }
224
+ }
225
+ // typ-05: prose measure declared in ch, outside 45-75.
226
+ for (const m of line.matchAll(/(?:max-width\s*:\s*|max-w-\[)(\d+)ch/g)) {
227
+ const ch = parseInt(m[1], 10);
228
+ if (ch < 45 || ch > 75) add('typ-05', file, lineNo, m[0], 'Keep prose measure in the 45-75ch readable band (data tables are exempt).');
229
+ }
230
+ // typ-08: uppercase micro-label tracking and the negative tracking floor.
231
+ {
232
+ const neg = line.match(/(?:letter-spacing\s*:\s*|tracking-\[)(-?\d*\.?\d+)em/);
233
+ if (neg && parseFloat(neg[1]) < -0.04) add('typ-08', file, lineNo, neg[0], 'Letter-spacing never below the -0.04em floor.');
234
+ if (/tracking-tighter\b/.test(line)) add('typ-08', file, lineNo, 'tracking-tighter', 'tracking-tighter (-0.05em) is below the -0.04em floor; use tracking-tight at most.');
235
+ if (/\buppercase\b/.test(line) && !/tracking-(?:wide|wider|widest|\[)/.test(line)) add('typ-08', file, lineNo, 'uppercase', 'All-caps micro-labels carry positive tracking (>= 0.02em, caps band 0.05-0.12em); add tracking-wide.');
236
+ }
237
+ // typ-09: viewport-fluid font sizing.
238
+ {
239
+ const vm = line.match(/(?:font-size\s*:[^;]*|text-\[[^\]]*)(?:\d*\.?\d+(?:vw|vh)|clamp\()/);
240
+ if (vm) add('typ-09', file, lineNo, vm[0], 'Font sizes come from a fixed rem scale; ban vw/vh/clamp on font-size.');
241
+ }
242
+ // typ-11: UI font weight below 400.
243
+ {
244
+ const wm = line.match(/(?:font-weight\s*:\s*|font-\[)(\d{3})\]?/);
245
+ if (wm && parseInt(wm[1], 10) < 400) add('typ-11', file, lineNo, wm[0], 'No UI font weight below 400.');
246
+ if (/\bfont-(?:thin|extralight|light)\b/.test(line)) add('typ-11', file, lineNo, (line.match(/font-(?:thin|extralight|light)/) || [''])[0], 'No UI font weight below 400 (font-thin/extralight/light are 100-300).');
247
+ }
248
+ // typ-12: type sized in em.
249
+ {
250
+ const em = line.match(/(?:font-size\s*:\s*|text-\[)(\d*\.?\d+)em/);
251
+ if (em) add('typ-12', file, lineNo, em[0], 'Never size type in em (it compounds); use px or rem.');
252
+ }
253
+ // typ-16: serif font family (not sans-serif).
254
+ {
255
+ const sf = line.match(/font-?family\s*:\s*[^;}]*\bserif\b|font-serif\b/i);
256
+ if (sf && !/sans-serif/i.test(sf[0])) add('typ-16', file, lineNo, sf[0].trim(), 'Serif faces are banned in dashboards/software UIs; use the sans family.');
257
+ }
258
+ // typ-19: mono/code type sized below 16px.
259
+ if (/font-mono\b|font-family\s*:[^;]*monospace|<(?:code|pre)\b/i.test(line)) {
260
+ for (const m of line.matchAll(/text-\[(\d*\.?\d+)(px|rem)?\]|text-(xs|sm)\b/g)) {
261
+ if (m[3]) { add('typ-19', file, lineNo, m[0], 'Code/mono type is fixed at 16px; long lines scroll horizontally, never shrink.'); continue; }
262
+ const px = toPx(m[1], m[2] || 'px');
263
+ if (px != null && px < 16) add('typ-19', file, lineNo, m[0], 'Code/mono type is fixed at 16px; long lines scroll horizontally, never shrink.');
264
+ }
265
+ }
266
+
267
+ // --- Spatial ---
268
+ // spc-01: explicit (arbitrary/CSS) spacing value off the 4/8 scale.
269
+ for (const m of line.matchAll(/(?:[pm][trblxy]?|gap(?:-[xy])?|space-[xy]|inset)-\[(\d*\.?\d+)(px|rem)?\]/g)) {
270
+ const px = toPx(m[1], m[2] || 'px');
271
+ if (px != null && !spacingOnScale(px)) add('spc-01', file, lineNo, m[0], `Land the spacing on the 4/8 scale (nearest step to ${px}px); ${px}px is off-scale.`);
272
+ }
273
+ if (isCss) {
274
+ for (const m of line.matchAll(/(?:padding|margin|gap|top|left|right|bottom)\s*:\s*([^;]+);/g)) {
275
+ for (const v of m[1].matchAll(/(\d*\.?\d+)(px|rem)/g)) {
276
+ const px = toPx(v[1], v[2]);
277
+ if (px != null && px !== 1 && !spacingOnScale(px)) add('spc-01', file, lineNo, v[0], `Land the spacing on the 4/8 scale (nearest step to ${px}px); ${px}px is off-scale.`);
278
+ }
279
+ }
280
+ }
281
+ // spc-04: center-aligned data column.
282
+ if (/<t[dh]\b/i.test(line) && /\btext-center\b/.test(line)) add('spc-04', file, lineNo, 'text-center', 'Numeric columns right-align, text left-aligns; never center-align a data column.');
283
+ // spc-06: corner radius off the fixed scale.
284
+ for (const m of line.matchAll(/(?:border-radius\s*:\s*|rounded-\[)(\d*\.?\d+)(px|rem)?/g)) {
285
+ const px = toPx(m[1], m[2] || 'px');
286
+ if (px != null && !RADIUS_SCALE.has(px)) add('spc-06', file, lineNo, m[0], `Corners land on the fixed radius scale (0/2/4/6/8/12/full); ${px}px is off-scale.`);
287
+ }
288
+ // spc-07: a button taller than the 28px dense-row cap sitting inside a table row.
289
+ if (/<t[dh]\b/i.test(line) && /<[Bb]utton\b/.test(line)) {
290
+ const bh = line.match(/\bh-(?:8|9|10|11|12|14|16|20)\b|\bh-\[(\d+)px\]|\bmin-h-\[(\d+)px\]/);
291
+ if (bh) {
292
+ const px = (bh[1] || bh[2]) ? parseInt(bh[1] || bh[2], 10) : 99;
293
+ if (px > 28) add('spc-07', file, lineNo, bh[0], 'Dense table rows compact their buttons to 28px (h-7); trim the control height and keep mono numerals on figures.');
294
+ }
295
+ }
296
+ // spc-08: full-viewport height via h-screen / 100vh instead of dvh.
297
+ {
298
+ const hm = line.match(/\b(?:min-)?h-screen\b|100vh|h-\[100vh\]/);
299
+ if (hm) add('spc-08', file, lineNo, hm[0], 'Full-viewport height uses min-h-[100dvh]; never h-screen or 100vh.');
300
+ }
301
+ // spc-09: calc-percentage layout arithmetic.
302
+ {
303
+ const cm = line.match(/calc\([^)]*%[^)]*\)/);
304
+ if (cm) add('spc-09', file, lineNo, cm[0], 'Ban calc-percentage layout arithmetic; use CSS Grid instead.');
305
+ }
306
+
307
+ // --- Depth ---
308
+ // dep-01: z-index off the declared scale, or the banned 999/9999.
309
+ for (const m of line.matchAll(/(?:z-index\s*:\s*|z-\[)(\d{1,4})/g)) {
310
+ const z = parseInt(m[1], 10);
311
+ if (z === 999 || z === 9999 || !ZINDEX_SCALE.has(z)) add('dep-01', file, lineNo, m[0], 'z-index only from the declared scale (0/10/20/40/100/1000); ban 999 and 9999.');
312
+ }
313
+ // dep-04: a border and a heavy elevation shadow doing the same separation job.
314
+ if (/\bborder(?:-[24]|-solid|-\w+-\d)?\b/.test(line) && /\bshadow-(?:lg|xl|2xl)\b/.test(line)) {
315
+ add('dep-04', file, lineNo, (line.match(/shadow-(?:lg|xl|2xl)/) || [''])[0], 'Border and shadow are doing the same separation job; keep one (a hairline border or a shadow), not both heavy.');
316
+ }
317
+
318
+ // --- Interaction ---
319
+ // int-12: a non-button clickable element without cursor:pointer.
320
+ if (/<(?:div|span|li)\b[^>]*\bonClick=/.test(line) && !/cursor-pointer/.test(line)) {
321
+ add('int-12', file, lineNo, (line.match(/<(?:div|span|li)\b[^>]*onClick=[^ >]*/) || ['onClick'])[0], 'cursor:pointer on every clickable element; add cursor-pointer (or use a real <button>).');
322
+ }
323
+ // int-03: a hover-revealed control with no keyboard/focus path.
324
+ if (/(?:group-hover|hover):(?:opacity-100|block|flex|visible)\b/.test(line) && !/(?:group-focus-within|focus-within|focus-visible|group-focus):/.test(fileText)) {
325
+ add('int-03', file, lineNo, (line.match(/(?:group-hover|hover):(?:opacity-100|block|flex|visible)/) || [''])[0], 'No pointer-only affordances; give the hover-revealed control a focus/keyboard path (group-focus-within:).');
326
+ }
327
+ // int-04: a modal built on a div role="dialog" instead of native <dialog>.
328
+ if (/<div\b[^>]*role=["']dialog["']/.test(line)) add('int-04', file, lineNo, 'role="dialog"', 'Modals use native <dialog> with inert focus containment; prefer a side panel before a modal.');
329
+ // int-08: placeholder-as-label on a non-search input.
330
+ if (/<input\b/i.test(line) && /placeholder=/.test(line) && !/aria-label|aria-labelledby|type=["']search["']/.test(line) && !/placeholder=["'][^"']*[Ss]earch/.test(line) && !/<label/i.test(fileText)) {
331
+ add('int-08', file, lineNo, (line.match(/placeholder=["'][^"']*["']/) || ['placeholder'])[0], 'Labels stay visible above the input; ban placeholder-as-label (errors render inline below).');
332
+ }
333
+ // int-09: a sortable header with no aria-sort.
334
+ if (/<th\b/i.test(line) && /(?:onClick=|cursor-pointer|sortable)/.test(line) && !/aria-sort/.test(line)) {
335
+ add('int-09', file, lineNo, '<th> sortable', 'Sortable headers carry aria-sort; give the sortable <th> an aria-sort state.');
336
+ }
337
+ // int-11: a button group with a sub-8px gap between targets.
338
+ {
339
+ const gm = line.match(/\b(?:gap|space-x)-\[(\d*\.?\d+)px\]|\b(?:gap|space-x)-0(?:\.5)?\b|gap-px\b/);
340
+ if (gm && /<button|role=["']button["']|<a\b/.test(line)) {
341
+ const n = gm[1] != null ? parseFloat(gm[1]) : 0;
342
+ if (n < 8) add('int-11', file, lineNo, gm[0], 'Target-to-target spacing clears the 8px minimum gap.');
343
+ }
344
+ }
345
+ // int-02: outline removed with no replacement focus style anywhere in the file.
346
+ if (/\boutline-none\b|outline\s*:\s*none/.test(line) && !/(?:focus-visible:|focus:ring|:focus-visible|ring-[12]|focus:outline-\[)/.test(fileText)) {
347
+ add('int-02', file, lineNo, (line.match(/outline-none|outline\s*:\s*none/) || [''])[0], 'Ban outline:none without a replacement; add a :focus-visible ring (focus-visible:ring-2).');
348
+ }
349
+ // int-14: a raw button styling hover but neither focus nor disabled states.
350
+ if (/<button\b/i.test(line) && /hover:/.test(line) && !/(?:focus-visible:|focus:)/.test(line) && !/disabled:/.test(line)) {
351
+ add('int-14', file, lineNo, '<button>', 'All five button states are required: default, hover, active, focus, disabled; this button is missing focus/disabled.');
352
+ }
353
+
354
+ // --- Motion ---
355
+ // mot-01: an animation/transition duration over the 500ms ceiling.
356
+ for (const m of line.matchAll(/duration-\[(\d+)ms\]|duration-(700|1000)\b|(?:transition|animation)(?:-duration)?\s*:[^;]*?(\d*\.?\d+)(ms|s)/g)) {
357
+ let ms = null;
358
+ if (m[1]) ms = parseInt(m[1], 10);
359
+ else if (m[2]) ms = parseInt(m[2], 10);
360
+ else if (m[3]) ms = m[4] === 's' ? parseFloat(m[3]) * 1000 : parseFloat(m[3]);
361
+ if (ms != null && ms > 500) add('mot-01', file, lineNo, m[0], `UI transitions hold under the 500ms ceiling; ${ms}ms is too long.`);
362
+ }
363
+ // mot-04: banned easing (spring/bounce on operator surfaces, linear everywhere).
364
+ {
365
+ const lm = line.match(/\bease-linear\b|transition-timing-function\s*:\s*linear|animation-timing-function\s*:\s*linear/);
366
+ if (lm) add('mot-04', file, lineNo, lm[0], 'Ban linear easing; use the token ease-out (cubic-bezier(0,0,0.2,1)).');
367
+ if (/cubic-bezier\(\s*-?\d/.test(line)) {
368
+ const cb = line.match(/cubic-bezier\([^)]*\)/)[0];
369
+ const nums = cb.match(/-?\d*\.?\d+/g).map(Number);
370
+ if (nums.some((n) => n < 0 || n > 1)) add('mot-04', file, lineNo, cb, 'Ban spring/bounce easing (control points outside 0-1) on operator surfaces; use the token ease-out.');
371
+ }
372
+ if (/\b(?:ease-spring|ease-bounce|spring\()/.test(line)) add('mot-04', file, lineNo, (line.match(/ease-spring|ease-bounce|spring\(/) || [''])[0], 'Ban spring/bounce easing on operator surfaces; use the token ease-out.');
373
+ }
374
+ // mot-05: animating a layout property instead of transform/opacity.
375
+ {
376
+ const am = line.match(/transition-(?:all|left|top|width|height|margin)\b|transition\s*:\s*[^;]*\b(?:left|top|width|height|margin)\b|animate-\[[^\]]*\b(?:left|top|width|height|margin)\b/);
377
+ if (am) add('mot-05', file, lineNo, am[0], 'Animate only transform and opacity, never top/left/width/height/margin (transition-all included).');
378
+ }
379
+ // mot-06: will-change on a layout property or left standing without an animation.
380
+ {
381
+ const wm = line.match(/will-change\s*:\s*([^;]+)|will-change-\[?([\w,-]+)/);
382
+ if (wm) {
383
+ const val = (wm[1] || wm[2] || '').toLowerCase();
384
+ if (/left|top|width|height|margin|auto|all|scroll/.test(val) || (!/transform|opacity/.test(val))) {
385
+ add('mot-06', file, lineNo, wm[0], 'will-change only rides an in-progress transform/opacity animation; never blanket, never on a layout property, never left dangling.');
386
+ }
387
+ }
388
+ }
389
+ // mot-07: a loading indicator delay outside the 150-300ms band.
390
+ if (/skeleton|spinner|loading/i.test(line)) {
391
+ for (const m of line.matchAll(/(?:animation-delay|transition-delay|delay-\[)\s*:?\s*(\d+)ms/g)) {
392
+ const d = parseInt(m[1], 10);
393
+ if (d > 0 && (d < 150 || d > 300)) add('mot-07', file, lineNo, m[0], 'Loading-indicator delay sits in the 150-300ms band (nothing appears under 500ms).');
394
+ }
395
+ }
396
+ // mot-09: scroll-event listener driving animation.
397
+ if (/addEventListener\(\s*["']scroll["']/.test(line)) add('mot-09', file, lineNo, 'addEventListener("scroll")', 'Ban scroll-event listeners for animation; use IntersectionObserver or scroll-linked scrubbing.');
398
+ // mot-10: useState fed by a continuous scroll/pointer value.
399
+ if (/set\w+\(/.test(line) && /on(?:Scroll|MouseMove|PointerMove)=/.test(line)) {
400
+ add('mot-10', file, lineNo, (line.match(/on(?:Scroll|MouseMove|PointerMove)=[^ >]*/) || [''])[0], 'Ban useState for continuous scroll/pointer values; drive them with motion values instead.');
401
+ }
402
+
403
+ // --- States ---
404
+ // sta-05: a toast duration outside the 3-5s band.
405
+ for (const m of line.matchAll(/duration\s*[:=]\s*\{?\s*(\d{3,5})/g)) {
406
+ if (!/toast|Toaster|sonner/i.test(line)) continue;
407
+ const d = parseInt(m[1], 10);
408
+ if (d < 3000 || d > 5000) add('sta-05', file, lineNo, m[0], 'Toasts auto-dismiss in the 3-5s band, announce politely (aria-live="polite"), and never steal focus.');
409
+ }
410
+
411
+ // --- Code & tokens ---
412
+ // cod-03: a raw color/spacing/radius/duration literal inside an inline style object.
413
+ if (isJsx && /style=\{\{/.test(line)) {
414
+ const sm = line.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|oklch\([^)]*\)|:\s*['"]?\d+px|:\s*['"]?\d+ms/);
415
+ if (sm) add('cod-03', file, lineNo, sm[0], 'Every color/spacing/radius/duration resolves to an emitted token; do not inline a raw literal in a style prop.');
416
+ }
417
+ // cod-04: elision comments and brevity markers.
418
+ {
419
+ const cm = line.match(/\/\/\s*\.\.\.|\/\/\s*(?:rest of|remaining|existing)\b|\{\s*\/\*\s*\.\.\.|\/\*\s*\.\.\.\s*\*\/|\bfor brevity\b|\/\/\s*TODO\b/i);
420
+ if (cm) add('cod-04', file, lineNo, cm[0], 'No elision/brevity/TODO comments; emit the whole file, compiling.');
421
+ }
422
+ // cod-05: fabricated names, placeholder companies, and fake round metrics.
423
+ {
424
+ const fm = line.match(/\bJohn Doe\b|\bJane Doe\b|\bAcme\b|\bLorem ipsum\b|\bBY THE NUMBERS\b|\b99\.9[89]%|\b100% (?:uptime|guaranteed)\b/i);
425
+ if (fm) add('cod-05', file, lineNo, fm[0], 'Anti-fabrication: no invented metrics or placeholder names; use real data, a [bracket] placeholder, or mark mock numbers.');
426
+ }
427
+ // cod-07: a Google Fonts <link>/@import in production.
428
+ if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(line)) add('cod-07', file, lineNo, (line.match(/https?:\/\/fonts\.[^"'\s)]+/) || ['fonts.googleapis.com'])[0], 'Never a Google Fonts link in prod; self-host or use next/font with font-display: swap.');
429
+ // cod-08: an <img> hotlinked or without declared dimensions / lazy-load.
430
+ if (/<img\b/i.test(line)) {
431
+ if (!/\bwidth=|\bheight=/.test(line)) add('cod-08', file, lineNo, '<img>', 'Images declare width/height (avoid CLS); below-the-fold images add loading="lazy".');
432
+ const src = line.match(/src=["'](https?:\/\/[^"']+)["']/);
433
+ if (src && !/picsum\.photos\/seed\//.test(src[1])) add('cod-08', file, lineNo, src[1], 'Image placeholders are deterministic seeds (picsum.photos/seed/{seed}/{w}/{h}), never a live hotlink.');
434
+ }
435
+ // cod-06: emoji character in source.
436
+ if (RE_EMOJI.test(line)) add('cod-06', file, lineNo, (line.match(RE_EMOJI) || [''])[0], 'No emoji anywhere and no emoji-as-icons; use inline SVG from one icon family.');
437
+ RE_EMOJI.lastIndex = 0;
438
+ }
439
+
440
+ const RE_EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/u;
441
+
442
+ // Blank out // line comments and /* */ (incl. JSX {/* */}) block comments, preserving line count, so copy checks skip commentary.
443
+ function cleanComments(lines) {
444
+ const out = [];
445
+ let inBlock = false;
446
+ for (let raw of lines) {
447
+ let s = raw;
448
+ if (inBlock) {
449
+ const end = s.indexOf('*/');
450
+ if (end < 0) { out.push(''); continue; }
451
+ s = ' '.repeat(end + 2) + s.slice(end + 2);
452
+ inBlock = false;
453
+ }
454
+ // strip inline /* ... */ occurrences
455
+ s = s.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length));
456
+ const openBlock = s.indexOf('/*');
457
+ if (openBlock >= 0 && s.indexOf('*/', openBlock) < 0) { s = s.slice(0, openBlock); inBlock = true; }
458
+ // strip // line comment when not part of :// (a URL scheme)
459
+ const lc = s.match(/(^|[^:])\/\/.*$/);
460
+ if (lc) s = s.slice(0, lc.index + lc[1].length);
461
+ out.push(s);
462
+ }
463
+ return out;
464
+ }
465
+
466
+ // Scan comment-stripped copy for em-dashes (typ-15) and three-period ellipses (typ-14).
467
+ function checkCopy(add, file, cleaned) {
468
+ cleaned.forEach((line, i) => {
469
+ if (line.includes('—')) add('typ-15', file, i + 1, line.trim(), 'Zero em-dashes in UI copy; recast the sentence or use a colon/en-dash.');
470
+ const el = line.match(/\.\.\.(?![\w$.([{])/);
471
+ if (el) add('typ-14', file, i + 1, '...', 'Use the ellipsis glyph (or an explicit loading label), not three periods.');
472
+ });
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Whole-file checks (need more than one line of context)
477
+ // ---------------------------------------------------------------------------
478
+
479
+ // Run the file-scoped source checks over one file's full text.
480
+ function checkFile(add, file, ext, text, lines) {
481
+ const isJsx = ext === 'tsx' || ext === 'ts';
482
+
483
+ // dep-02: more than one <h1> in a view.
484
+ const h1s = [...text.matchAll(/<h1\b/gi)];
485
+ if (h1s.length > 1) {
486
+ const idx = text.slice(0, h1s[1].index).split('\n').length;
487
+ add('dep-02', file, idx, `${h1s.length} <h1> elements`, 'One h1 per view; keep a single <h1> and never skip heading levels.');
488
+ }
489
+
490
+ // mot-03: an animation present with no prefers-reduced-motion guard.
491
+ const hasAnim = /@keyframes\b|\banimate-(?!none\b)[\w[\]-]+|\.animate\(|animation\s*:/i.test(text);
492
+ const hasGuard = /prefers-reduced-motion|motion-reduce:|useReducedMotion/i.test(text);
493
+ if (hasAnim && !hasGuard) {
494
+ const idx = (text.match(/@keyframes|animate-|animation\s*:/i) ? text.slice(0, text.search(/@keyframes|animate-|animation\s*:/i)).split('\n').length : 1);
495
+ add('mot-03', file, idx, 'animation without reduced-motion guard', 'Every animation is gated on prefers-reduced-motion (0.01ms reset / motion-reduce:).');
496
+ }
497
+
498
+ // typ-07: a numeric table context with no tabular figures anywhere in the file.
499
+ if (isJsx && /<td\b/i.test(text)) {
500
+ const numeric = /toLocaleString\(|toFixed\(|Intl\.NumberFormat|\$\{?[\w.]+\}?\s*(?:USD|amount|price|total)|\bformatCurrency\b/i.test(text);
501
+ const tabular = /tabular-nums|font-variant-numeric|lining-nums|slashed-zero|tnum/i.test(text);
502
+ if (numeric && !tabular) {
503
+ const idx = text.slice(0, text.search(/<td\b/i)).split('\n').length;
504
+ add('typ-07', file, idx, 'numeric table cells without tabular-nums', 'Tabular lining numerals (tabular-nums) on every numeric column, KPI, timer, and ID.');
505
+ }
506
+ }
507
+
508
+ // sta-01: a collection render with none of loading/empty/error handling.
509
+ // A module-local literal array (e.g. a static nav constant) is not a data
510
+ // surface — only collections that arrive as data (imports, props, hooks,
511
+ // fetches) demand lifecycle states.
512
+ if (isJsx && /\.map\(/.test(text)) {
513
+ const hasState = /length\s*===?\s*0|isEmpty|Empty|isLoading|\bloading\b|Skeleton|Spinner|isError|\berror\b|catch\b/i.test(text);
514
+ if (!hasState) {
515
+ const mapRe = /(\w+)(?:\.\w+)*\.map\(/g;
516
+ let m;
517
+ let firstDataMapIdx = -1;
518
+ while ((m = mapRe.exec(text)) !== null) {
519
+ const localLiteral = new RegExp(`const\\s+${m[1]}(?:\\s*:\\s*[^=]+)?=\\s*\\[`).test(text);
520
+ if (!localLiteral) { firstDataMapIdx = m.index; break; }
521
+ }
522
+ if (firstDataMapIdx !== -1) {
523
+ const idx = text.slice(0, firstDataMapIdx).split('\n').length;
524
+ add('sta-01', file, idx, 'collection render with no loading/empty/error state', 'Every data surface ships loading, empty, error, and partial/degraded states; none are present here.');
525
+ }
526
+ }
527
+ }
528
+ // sta-02: an empty state that dead-ends (literal no-data message) or carries >1 action.
529
+ if (isJsx && /(?:isEmpty|length\s*===?\s*0|Empty)/i.test(text)) {
530
+ const deadEnd = text.match(/>\s*(?:No data(?:\s+available)?|Nothing (?:here|to show)|No results found\.?)\s*</i);
531
+ if (deadEnd) add('sta-02', file, text.slice(0, deadEnd.index).split('\n').length, deadEnd[0].trim(), 'Empty states are text-first with a next step, never a literal no-data dead-end; 1-2 sentences and at most one action.');
532
+ }
533
+ // sta-03: an error-state render that dead-ends — no recovery affordance in the rendered error block.
534
+ // The three-part error copy (what / why / how) stays judged; this shell flags only the mechanical dead
535
+ // end that an error state offers the operator no way forward. Matches the canonical error-render forms
536
+ // (an early return, an inline &&/? render, or a catch that returns JSX) and looks for any recovery
537
+ // affordance (retry / reload / link / action / handler) in the block that follows.
538
+ if (isJsx) {
539
+ const errorRender = /if\s*\(\s*(?:isError|hasError|loadError)\b[^)]*\)\s*(?:\{[^}]*)?return|(?:isError|hasError|loadError)\s*(?:&&|\?)\s*\(?\s*<|if\s*\(\s*status\s*===?\s*['"](?:error|failed)['"][^)]*\)\s*(?:\{[^}]*)?return|\}\s*catch\s*(?:\([^)]*\))?\s*\{[^}]*return\s*[<(]/g;
540
+ const RECOVERY = /onClick|onRetry|onReload|onRefresh|href=|<a\b|<Button\b|<button\b|\bretry\b|\breload\b|\brefresh\b|\brefetch\b|try again|go back/i;
541
+ let em;
542
+ while ((em = errorRender.exec(text)) !== null) {
543
+ const windowText = text.slice(em.index, em.index + 600);
544
+ if (!RECOVERY.test(windowText)) {
545
+ add('sta-03', file, text.slice(0, em.index).split('\n').length, 'error state with no recovery affordance', 'Error states never dead-end: state what happened, why, and how to recover, and give a way forward (retry, reload, or a link out) — not a bare failure message.');
546
+ break;
547
+ }
548
+ }
549
+ }
550
+
551
+ // int-01: a :focus / :focus-visible CSS rule that removes the outline with no visible ring replacement.
552
+ if (ext === 'css') {
553
+ for (const m of text.matchAll(/:focus(?:-visible)?\s*\{([^}]*)\}/g)) {
554
+ const body = m[1];
555
+ const removesOutline = /outline\s*:\s*(?:none|0)\b/.test(body);
556
+ const hasRing = /box-shadow\s*:\s*[^;]*\d/.test(body) || /outline-width\s*:/.test(body) || /outline-offset\s*:/.test(body) || /outline\s*:\s*[^;]*(?:px|rem|em|solid|auto|\d)/i.test(body) || /border(?:-\w+)?\s*:\s*[^;]*(?:px|solid|\d)/i.test(body) || /\bring-/.test(body);
557
+ if (removesOutline && !hasRing) add('int-01', file, text.slice(0, m.index).split('\n').length, m[0].replace(/\s+/g, ' ').slice(0, 60), 'Every interactive element ships a visible :focus-visible ring (>= 2px with offset); add a ring, do not merely remove the outline.');
558
+ }
559
+ }
560
+
561
+ // int-10: live-updating status (polling) with no aria-live region present.
562
+ if (isJsx && /setInterval\(/.test(text) && !/aria-live|role=["'](?:status|alert)["']/.test(text)) {
563
+ add('int-10', file, text.slice(0, text.search(/setInterval\(/)).split('\n').length, 'polling status update without aria-live', 'An aria-live region is present wherever async status updates render; wrap the live status in role="status" / aria-live="polite".');
564
+ }
565
+
566
+ // cod-01: a token-defining CSS file that omits some of the 34 consumed tokens.
567
+ if (ext === 'css' && /--background\b/.test(text) && /--foreground\b/.test(text)) {
568
+ const missing = REQUIRED_TOKENS.filter((t) => !new RegExp(`--${t}\\s*:`).test(text));
569
+ if (missing.length) add('cod-01', file, 1, `missing tokens: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`, 'Emitted CSS defines every token the substrate consumes; add the missing --token definitions.');
570
+ }
571
+
572
+ // cod-09: a component file well over the line ceiling.
573
+ if (isJsx && lines.length > 250) add('cod-09', file, lines.length, `${lines.length} lines`, 'Component line ceilings hold (< 120 ln table, < 80 ln section); split this file.');
574
+
575
+ // cod-11: an exported component that takes props with no typed Props.
576
+ if (ext === 'tsx') {
577
+ const untyped = text.match(/export\s+(?:default\s+)?function\s+[A-Z]\w*\(\s*\{[^}]*\}\s*\)(?!\s*:)/) ||
578
+ text.match(/export\s+(?:default\s+)?function\s+[A-Z]\w*\(\s*props\s*\)/);
579
+ const hasTypedProps = /interface\s+\w*Props\b|type\s+\w*Props\b|:\s*\w*Props\b|React\.FC</.test(text);
580
+ if (untyped && !hasTypedProps) {
581
+ add('cod-11', file, text.slice(0, untyped.index).split('\n').length, untyped[0].slice(0, 60), 'Every emitted component exports a typed Props interface; annotate the props.');
582
+ }
583
+ }
584
+ }
585
+
586
+ const REQUIRED_TOKENS = [
587
+ 'background', 'foreground', 'card', 'card-foreground', 'muted', 'muted-foreground', 'border', 'input', 'ring',
588
+ 'primary', 'primary-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground', 'success', 'warning',
589
+ 'popover', 'popover-foreground', 'secondary', 'secondary-foreground',
590
+ 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5',
591
+ 'sidebar', 'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
592
+ 'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
593
+ ];
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Cross-file checks (aggregate over all files passed this invocation)
597
+ // ---------------------------------------------------------------------------
598
+
599
+ // Run the checks that need the whole passed-in file set (token-set counts, distinct sizes).
600
+ function checkCrossFile(add, docs) {
601
+ // typ-06: more than two font families declared across the passed files.
602
+ const families = new Set();
603
+ const iconLibs = new Set();
604
+ const explicitSizes = new Map(); // px -> {file,line,text}
605
+ const hexByHue = new Map(); // hue bucket -> Set(normHex)
606
+ let sectionCount = 0;
607
+ let capsTrackCount = 0;
608
+ let capsTrackAnchor = null;
609
+
610
+ for (const d of docs) {
611
+ d.lines.forEach((line, i) => {
612
+ // font families from declarations / --font-* / font-[Family]
613
+ for (const m of line.matchAll(/font-family\s*:\s*([^;{}]+)[;}]?|--font-[\w-]+\s*:\s*([^;]+);|font-\[["']?([A-Za-z][\w\s-]+)["']?\]/g)) {
614
+ const raw = (m[1] || m[2] || m[3] || '').trim();
615
+ const first = raw.split(',')[0].replace(/["']/g, '').trim().toLowerCase();
616
+ if (first && !/^var\(/.test(first)) families.add(first);
617
+ }
618
+ // icon libraries from imports
619
+ const imp = line.match(/from\s+["'](lucide-react|@heroicons\/\w+|react-icons(?:\/\w+)?|@phosphor-icons\/\w+|phosphor-react)["']/);
620
+ if (imp) iconLibs.add(imp[1].split('/')[0]);
621
+ // explicit numeric font sizes (arbitrary tailwind or CSS), for typ-10
622
+ for (const m of line.matchAll(/text-\[(\d*\.?\d+)(px|rem)\]|font-size\s*:\s*(\d*\.?\d+)(px|rem)/g)) {
623
+ const px = m[1] ? toPx(m[1], m[2]) : toPx(m[3], m[4]);
624
+ if (px != null && !explicitSizes.has(px)) explicitSizes.set(px, { file: d.file, line: i + 1, text: (m[0]) });
625
+ }
626
+ // hex color families for col-08 over-count
627
+ for (const m of line.matchAll(/#[0-9a-fA-F]{6}\b/g)) {
628
+ const hue = hexHueBucket(m[0]);
629
+ if (!hexByHue.has(hue)) hexByHue.set(hue, new Set());
630
+ hexByHue.get(hue).add(normColor(m[0]));
631
+ }
632
+ // typ-18 section + caps-tracking tally
633
+ if (/<section\b/i.test(line)) sectionCount += (line.match(/<section\b/gi) || []).length;
634
+ if (/\buppercase\b/.test(line) && /tracking-(?:wide|wider|widest)/.test(line)) {
635
+ capsTrackCount++;
636
+ if (!capsTrackAnchor) capsTrackAnchor = { file: d.file, line: i + 1 };
637
+ }
638
+ });
639
+ }
640
+
641
+ if (families.size > 2) {
642
+ const d = docs[0];
643
+ add('typ-06', d.file, 1, `${families.size} font families: ${[...families].join(', ')}`, 'At most two font families per app (a sans and a mono); drop the extra family.');
644
+ }
645
+
646
+ // typ-10: adjacent explicitly-declared type-scale steps closer than 25%.
647
+ const sizes = [...explicitSizes.keys()].sort((a, b) => a - b);
648
+ if (sizes.length >= 3) {
649
+ for (let i = 1; i < sizes.length; i++) {
650
+ if (sizes[i] / sizes[i - 1] < 1.25) {
651
+ const anc = explicitSizes.get(sizes[i]);
652
+ add('typ-10', anc.file, anc.line, `${sizes[i - 1]}px and ${sizes[i]}px`, 'Adjacent type-scale steps stay >= 25% apart; merge these near-duplicate steps.');
653
+ }
654
+ }
655
+ }
656
+ // spc-02 is checked with the same near-duplicate logic over explicit spacing (below).
657
+
658
+ // col-08: a color family exceeding the 10-shade ceiling.
659
+ for (const [hue, set] of hexByHue) {
660
+ if (set.size > 10) {
661
+ const d = docs[0];
662
+ add('col-08', d.file, 1, `${set.size} ${hue} shades`, 'Shade counts stay bounded (greys 8-10, each accent family 5-10); this family exceeds 10.');
663
+ }
664
+ }
665
+
666
+ // typ-18: uppercase-tracking instances beyond ceil(sectionCount/3).
667
+ if (sectionCount > 0 && capsTrackCount > Math.ceil(sectionCount / 3) && capsTrackAnchor) {
668
+ add('typ-18', capsTrackAnchor.file, capsTrackAnchor.line, `${capsTrackCount} caps-tracked labels vs ${sectionCount} sections`, 'Ration eyebrow/micro-label uppercase-tracking against section count (<= ceil(sectionCount/3)).');
669
+ }
670
+
671
+ if (iconLibs.size > 1) {
672
+ const d = docs[0];
673
+ add('cod-06', d.file, 1, `icon families: ${[...iconLibs].join(', ')}`, 'One icon family per app; consolidate onto a single family (lucide).');
674
+ }
675
+ }
676
+
677
+ // Run spc-02 over explicit spacing values collected across files.
678
+ function checkSpacingScale(add, docs) {
679
+ const spacings = new Map();
680
+ for (const d of docs) {
681
+ d.lines.forEach((line, i) => {
682
+ for (const m of line.matchAll(/(?:[pm][trblxy]?|gap(?:-[xy])?|space-[xy]|inset)-\[(\d*\.?\d+)(px|rem)\]/g)) {
683
+ const px = toPx(m[1], m[2]);
684
+ if (px != null && !spacings.has(px)) spacings.set(px, { file: d.file, line: i + 1 });
685
+ }
686
+ });
687
+ }
688
+ const vals = [...spacings.keys()].sort((a, b) => a - b);
689
+ if (vals.length >= 3) {
690
+ for (let i = 1; i < vals.length; i++) {
691
+ if (vals[i] / vals[i - 1] < 1.25) {
692
+ const anc = spacings.get(vals[i]);
693
+ add('spc-02', anc.file, anc.line, `${vals[i - 1]}px and ${vals[i]}px`, 'Adjacent spacing values stay >= 25% apart; these declared steps are too close.');
694
+ }
695
+ }
696
+ }
697
+ }
698
+
699
+ // Bucket a 6-digit hex into a coarse hue family name for shade counting.
700
+ function hexHueBucket(hex) {
701
+ const h = expandHex(hex);
702
+ const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
703
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
704
+ if (max - min < 24) return 'grey';
705
+ let hue;
706
+ const d = max - min;
707
+ if (max === r) hue = ((g - b) / d) % 6;
708
+ else if (max === g) hue = (b - r) / d + 2;
709
+ else hue = (r - g) / d + 4;
710
+ hue = (hue * 60 + 360) % 360;
711
+ if (hue < 20 || hue >= 340) return 'red';
712
+ if (hue < 45) return 'orange';
713
+ if (hue < 70) return 'yellow';
714
+ if (hue < 165) return 'green';
715
+ if (hue < 200) return 'teal';
716
+ if (hue < 255) return 'blue';
717
+ if (hue < 290) return 'violet';
718
+ return 'pink';
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // cod-02 — lever-manifest drift check (D10)
723
+ // ---------------------------------------------------------------------------
724
+
725
+ // Extract the fenced ```yaml lever-manifest block body from a design.md's text.
726
+ function extractLeverManifest(text) {
727
+ const m = text.match(/```yaml\s+lever-manifest\s*\n([\s\S]*?)\n```/);
728
+ return m ? m[1] : null;
729
+ }
730
+
731
+ // Tolerantly pull the allowed vocabulary (colors, scales, fonts) out of a lever-manifest body.
732
+ function parseManifestVocab(body) {
733
+ const stripComment = (s) => s.replace(/\s+#.*$/, '');
734
+ const colors = new Set();
735
+ const fonts = new Set();
736
+ // color-tokens region: from `color-tokens:` up to the next top-level (0-indent) key.
737
+ const ctStart = body.search(/^color-tokens\s*:/m);
738
+ if (ctStart >= 0) {
739
+ const rest = body.slice(ctStart).split('\n');
740
+ let region = [rest[0]];
741
+ for (let i = 1; i < rest.length; i++) {
742
+ if (/^\S/.test(rest[i]) && !/^\s/.test(rest[i])) break; // next top-level key
743
+ region.push(rest[i]);
744
+ }
745
+ for (const line of region) {
746
+ for (const c of stripComment(line).matchAll(RE_COLOR_LITERAL)) colors.add(normColor(c[0]));
747
+ for (const c of stripComment(line).matchAll(/"([^"]+)"|'([^']+)'/g)) {
748
+ const v = (c[1] || c[2]).trim();
749
+ if (RE_COLOR_LITERAL.test(v)) { RE_COLOR_LITERAL.lastIndex = 0; colors.add(normColor(v)); }
750
+ RE_COLOR_LITERAL.lastIndex = 0;
751
+ }
752
+ }
753
+ }
754
+ // numeric scales
755
+ const numList = (key) => {
756
+ const mm = body.match(new RegExp(`^${key}\\s*:\\s*\\[([^\\]]*)\\]`, 'm'));
757
+ if (!mm) return null;
758
+ return new Set(mm[1].split(',').map((s) => parseFloat(s)).filter((n) => !Number.isNaN(n)));
759
+ };
760
+ const spacing = numList('spacing-scale');
761
+ const radius = numList('radius-scale');
762
+ let durations = null;
763
+ const dm = body.match(/durations\s*:\s*\[([^\]]*)\]/);
764
+ if (dm) durations = new Set(dm[1].split(',').map((s) => parseFloat(s)).filter((n) => !Number.isNaN(n)));
765
+ // fonts (inline object)
766
+ const fm = body.match(/fonts\s*:\s*\{([^}]*)\}/);
767
+ if (fm) {
768
+ for (const p of fm[1].matchAll(/(?:sans|mono)\s*:\s*"([^"]+)"/g)) {
769
+ fonts.add(p[1].split(',')[0].replace(/["']/g, '').trim().toLowerCase());
770
+ }
771
+ }
772
+ return { colors, spacing, radius, durations, fonts };
773
+ }
774
+
775
+ // Flag any color/spacing/radius/duration/font literal in the diff files outside the manifest vocabulary (cod-02).
776
+ function checkDrift(add, docs, vocab) {
777
+ for (const d of docs) {
778
+ if (d.ext === 'css' && vocab.colors.size) {
779
+ // token-definition files legitimately hold the manifest's own literals; only flag component-side drift
780
+ }
781
+ d.lines.forEach((line, i) => {
782
+ const isJsx = d.ext === 'tsx' || d.ext === 'ts' || d.ext === 'html';
783
+ // colors
784
+ if (vocab.colors.size) {
785
+ for (const m of line.matchAll(RE_COLOR_LITERAL)) {
786
+ if (!vocab.colors.has(normColor(m[0]))) add('cod-02', d.file, i + 1, m[0], 'Value drifts from the lever manifest; use a color already in color-tokens, not a fresh literal.');
787
+ }
788
+ }
789
+ // spacing (explicit only)
790
+ if (vocab.spacing && vocab.spacing.size) {
791
+ for (const m of line.matchAll(/(?:[pm][trblxy]?|gap(?:-[xy])?|space-[xy]|inset)-\[(\d*\.?\d+)(px|rem)\]/g)) {
792
+ const px = toPx(m[1], m[2]);
793
+ if (px != null && !vocab.spacing.has(px)) add('cod-02', d.file, i + 1, m[0], `Spacing ${px}px is off the manifest spacing-scale; snap to a listed step.`);
794
+ }
795
+ }
796
+ // radius (explicit only)
797
+ if (vocab.radius && vocab.radius.size) {
798
+ for (const m of line.matchAll(/rounded-\[(\d*\.?\d+)(px|rem)?\]|border-radius\s*:\s*(\d*\.?\d+)(px|rem)/g)) {
799
+ const px = m[1] ? toPx(m[1], m[2] || 'px') : toPx(m[3], m[4]);
800
+ if (px != null && !vocab.radius.has(px)) add('cod-02', d.file, i + 1, m[0], `Radius ${px}px is off the manifest radius-scale; use a listed radius.`);
801
+ }
802
+ }
803
+ // durations (explicit only)
804
+ if (vocab.durations && vocab.durations.size) {
805
+ for (const m of line.matchAll(/duration-\[(\d+)ms\]|duration-(\d{2,4})\b/g)) {
806
+ const ms = m[1] ? parseInt(m[1], 10) : parseInt(m[2], 10);
807
+ if (!vocab.durations.has(ms)) add('cod-02', d.file, i + 1, m[0], `Duration ${ms}ms is off the manifest motion.durations; use a listed duration.`);
808
+ }
809
+ }
810
+ // fonts (declared families)
811
+ if (isJsx && vocab.fonts.size) {
812
+ for (const m of line.matchAll(/font-\[["']?([A-Za-z][\w\s-]+)["']?\]/g)) {
813
+ const fam = m[1].split(',')[0].replace(/["']/g, '').trim().toLowerCase();
814
+ if (!vocab.fonts.has(fam)) add('cod-02', d.file, i + 1, m[0], `Font "${m[1]}" is not in the manifest fonts; use the declared sans/mono.`);
815
+ }
816
+ }
817
+ });
818
+ }
819
+ }
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // Output
823
+ // ---------------------------------------------------------------------------
824
+
825
+ // Print findings as the fix-or-classify directive (human) or a JSON array.
826
+ function report(findings, json) {
827
+ if (json) {
828
+ process.stdout.write(JSON.stringify(findings, null, 2) + '\n');
829
+ return;
830
+ }
831
+ if (findings.length === 0) {
832
+ process.stdout.write('detector: clean — no source violations.\n');
833
+ return;
834
+ }
835
+ const useColor = process.stdout.isTTY;
836
+ const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s);
837
+ const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s);
838
+ const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
839
+ process.stdout.write(bold(red(`detector: ${findings.length} source violation${findings.length === 1 ? '' : 's'}\n\n`)));
840
+ for (const f of findings) {
841
+ process.stdout.write(`${bold(red('[' + f.rule + ']'))} ${f.file}:${f.line}\n`);
842
+ process.stdout.write(` ${dim('offending:')} ${f.excerpt}\n`);
843
+ process.stdout.write(` ${dim('fix:')} ${f.fix}\n\n`);
844
+ }
845
+ process.stdout.write(bold('Fix each now, or classify why the rule does not apply and record that classification\n'));
846
+ process.stdout.write(bold('in the decay ledger (docs/memory/design-decay.md) — never ignore silently.\n'));
847
+ }
848
+
849
+ // ---------------------------------------------------------------------------
850
+ // Main
851
+ // ---------------------------------------------------------------------------
852
+
853
+ // Detect the file's extension bucket, or null when it is not a checkable type.
854
+ function extOf(path) {
855
+ const m = path.toLowerCase().match(/\.(tsx|ts|css|html)$/);
856
+ return m ? m[1] : null;
857
+ }
858
+
859
+ // Entry point: read the passed files, run every source check, emit findings, exit 1 on any.
860
+ function main() {
861
+ const { files, manifest, json } = parseArgs(process.argv.slice(2));
862
+ if (files.length === 0) { printUsage(); process.exit(2); }
863
+
864
+ const sink = makeSink();
865
+ const docs = [];
866
+ for (const file of files) {
867
+ const ext = extOf(file);
868
+ if (!ext) continue;
869
+ let text;
870
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
871
+ const lines = text.split('\n');
872
+ docs.push({ file, ext, text, lines });
873
+ }
874
+
875
+ for (const d of docs) {
876
+ d.lines.forEach((line, i) => checkLine(sink.add, d.file, d.ext, i + 1, line, d.text));
877
+ checkFile(sink.add, d.file, d.ext, d.text, d.lines);
878
+ checkCopy(sink.add, d.file, cleanComments(d.lines));
879
+ }
880
+ checkCrossFile(sink.add, docs);
881
+ checkSpacingScale(sink.add, docs);
882
+
883
+ if (manifest) {
884
+ let mtext;
885
+ try { mtext = readFileSync(manifest, 'utf8'); } catch { mtext = null; }
886
+ if (mtext) {
887
+ const body = extractLeverManifest(mtext);
888
+ if (body) checkDrift(sink.add, docs, parseManifestVocab(body));
889
+ }
890
+ }
891
+
892
+ // Stable sort: by file, then line, then rule id.
893
+ sink.list.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule));
894
+ report(sink.list, json);
895
+ process.exit(sink.list.length > 0 ? 1 : 0);
896
+ }
897
+
898
+ main();