@ultimat3/cli 22.14.0 → 23.0.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 (174) hide show
  1. package/CLAUDE.md +25 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +35 -3
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -0,0 +1,213 @@
1
+ // The `raw-motion` guard `x new` ships: a duration and an easing curve are `tokens.duration()` and
2
+ // `tokens.easing()`. A literal `300ms` beside the scale's `220ms` is two tempos in one app, and
3
+ // nothing but this reads a stylesheet for it.
4
+
5
+ import { guardCode } from './guard';
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ /** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
9
+ const NAME = 'raw-motion';
10
+ const CODE = guardCode(NAME);
11
+
12
+ const source =
13
+ (): string => `// raw-motion: every duration and curve comes off the motion scale, so the app moves at one tempo.
14
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
15
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
16
+ //
17
+ // A keyword is not a literal: \`linear\`, \`ease\`, \`infinite\` and \`0s\` are never reported, and a
18
+ // multiple of a token is still the token — \`calc(#{tokens.duration(slower)} * 2)\`.
19
+
20
+ import type { Finding, Guard } from '@ultimat3/cli';
21
+
22
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
23
+ const CODE = '${CODE}';
24
+
25
+ /** \`@ultimat3/ui/tokens\`' duration scale, in ms. */
26
+ export const DURATIONS: readonly (readonly [string, number])[] = [
27
+ ['instant', 0],
28
+ ['fast', 120],
29
+ ['base', 220],
30
+ ['slow', 400],
31
+ ['slower', 640],
32
+ ];
33
+ /** \`@ultimat3/ui/tokens\`' easing curves, as the control points each one is. */
34
+ export const EASINGS: Readonly<Record<string, string>> = {
35
+ '0.16,1,0.3,1': 'out',
36
+ '0.5,0,0.75,0': 'in',
37
+ '0.65,0,0.35,1': 'in-out',
38
+ '0.34,1.56,0.64,1': 'spring',
39
+ };
40
+
41
+ export interface StyleFile {
42
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
43
+ readonly path: string;
44
+ readonly scss: string;
45
+ }
46
+
47
+ const blank = (text: string): string => text.replaceAll(/[^\\n]/g, ' ');
48
+
49
+ /** Comments blanked rather than removed, so a reported line number still points at the source. */
50
+ const blankComments = (scss: string): string =>
51
+ scss.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, blank).replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, blank);
52
+
53
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
54
+
55
+ /** The \`as <name>\` the token entry point is used under in this sheet; \`tokens\` when it is not. */
56
+ const namespaceOf = (scss: string): string =>
57
+ /@use\\s+['"][^'"]*tokens[^'"]*['"]\\s+as\\s+([\\w-]+)/.exec(scss)?.[1] ?? 'tokens';
58
+
59
+ /** A declaration; a Sass \`#{…}\` interpolation is a value that carries braces. */
60
+ const DECLARATION = /(?<![\\w$-])([\\w-]+)\\s*:\\s*((?:#\\{[^}]*\\}|[^;{}])+)/g;
61
+ const MOTION_PROPERTY = /^(?:transition|animation)(?:-|$)/;
62
+ /** A token read, with its argument list: \`tokens.duration(fast)\`, \`var(--duration-fast)\`. */
63
+ const TOKEN_CALL = /(?<![\\w-])(?:var|(?:[\\w-]+\\.)[\\w-]+|duration|easing)\\([^()]*\\)/g;
64
+ const TIME = /(?<![\\w.#-])(\\d*\\.?\\d+)(ms|s)\\b/g;
65
+ const CURVE = /cubic-bezier\\(([^)]*)\\)/g;
66
+
67
+ /** The step a duration means: exact where it is one, the nearest where it is not. */
68
+ const durationFor = (ms: number): string => {
69
+ let best: readonly [string, number] | undefined;
70
+ for (const step of DURATIONS) {
71
+ // \`instant\` is no motion at all; a time someone wrote is never repaired to nothing.
72
+ if (step[1] === 0) continue;
73
+ if (best === undefined || Math.abs(step[1] - ms) < Math.abs(best[1] - ms)) best = step;
74
+ }
75
+ return best?.[0] ?? 'base';
76
+ };
77
+
78
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
79
+ export function rawMotion(files: readonly StyleFile[]): readonly Finding[] {
80
+ const findings: Finding[] = [];
81
+ for (const file of files) {
82
+ const scss = blankComments(file.scss);
83
+ const ns = namespaceOf(file.scss);
84
+ for (const match of scss.matchAll(DECLARATION)) {
85
+ const property = match[1] ?? '';
86
+ if (!MOTION_PROPERTY.test(property)) continue;
87
+ const value = (match[2] ?? '').trim().replaceAll(/\\s+/g, ' ');
88
+ const read = value.replaceAll(TOKEN_CALL, (call) => ' '.repeat(call.length));
89
+ const literals: string[] = [];
90
+ let written = '';
91
+ let cursor = 0;
92
+ const rewrite = (index: number, length: number, replacement: string): void => {
93
+ written += value.slice(cursor, index) + replacement;
94
+ cursor = index + length;
95
+ };
96
+ // Offsets in \`read\` are offsets in \`value\`: a token call is blanked, never removed.
97
+ const hits = [
98
+ ...[...read.matchAll(CURVE)].map((curve) => ({
99
+ index: curve.index,
100
+ text: curve[0],
101
+ token: \`\${ns}.easing(\${EASINGS[(curve[1] ?? '').replaceAll(/\\s+/g, '')] ?? 'out'})\`,
102
+ })),
103
+ ...[...read.replaceAll(CURVE, (curve) => ' '.repeat(curve.length)).matchAll(TIME)]
104
+ .filter((time) => Number(time[1]) !== 0)
105
+ .map((time) => ({
106
+ index: time.index,
107
+ text: time[0],
108
+ token: \`\${ns}.duration(\${durationFor(Number(time[1]) * (time[2] === 's' ? 1000 : 1))})\`,
109
+ })),
110
+ ].sort((a, b) => a.index - b.index);
111
+ for (const hit of hits) {
112
+ literals.push(hit.text);
113
+ rewrite(hit.index, hit.text.length, hit.token);
114
+ }
115
+ if (literals.length === 0) continue;
116
+ written += value.slice(cursor);
117
+ const line = lineOf(scss, match.index);
118
+ findings.push({
119
+ code: CODE,
120
+ cause: \`\${file.path}:\${line} writes \\\`\${property}: \${value}\\\` — \${literals.join(' and ')} off the motion scale, a tempo of this file's own\`,
121
+ fix: \`\${property}: \${written} — at \${file.path}:\${line} (durations: \${DURATIONS.map(([name]) => name).join(' ')}; a longer one is calc(#{\${ns}.duration(slower)} * 2)), then: x verify\`,
122
+ at: \`\${file.path}:\${line}\`,
123
+ });
124
+ }
125
+ }
126
+ return findings;
127
+ }
128
+
129
+ export const guard: Guard = {
130
+ summary: 'a duration and an easing curve come off the motion scale, never a literal',
131
+ async check(_root, sources) {
132
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
133
+ return rawMotion(sheets.map((file) => ({ path: file.path, scss: file.text })));
134
+ },
135
+ };
136
+ `;
137
+
138
+ const test =
139
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
140
+ // a green gate over the convention it was written to enforce.
141
+
142
+ import { expect, unitTest } from '@ultimat3/testing';
143
+ import { rawMotion } from './raw-motion';
144
+
145
+ const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
146
+ const fixes = (scss: string) => rawMotion(sheet(scss)).map((finding) => finding.fix);
147
+
148
+ unitTest('a duration literal is refused, and the fix is the declaration to write', () => {
149
+ const findings = rawMotion(sheet('.a {\\n transition: opacity 120ms ease;\\n}\\n'));
150
+ expect(findings).toHaveLength(1);
151
+ expect(findings[0]?.code).toBe('${CODE}');
152
+ expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:2');
153
+ expect(findings[0]?.fix).toStartWith('transition: opacity tokens.duration(fast) ease — ');
154
+ });
155
+
156
+ unitTest('seconds are read as milliseconds, and a time off the scale gets the nearest step', () => {
157
+ expect(fixes('.a { animation-duration: 0.4s; }')[0]).toStartWith(
158
+ 'animation-duration: tokens.duration(slow) — ',
159
+ );
160
+ expect(fixes('.a { transition-duration: 300ms; }')[0]).toStartWith(
161
+ 'transition-duration: tokens.duration(base) — ',
162
+ );
163
+ expect(fixes('.a { animation: spin 2.4s linear infinite; }')[0]).toStartWith(
164
+ 'animation: spin tokens.duration(slower) linear infinite — ',
165
+ );
166
+ });
167
+
168
+ unitTest('a cubic-bezier is refused, and one that IS a token is named', () => {
169
+ expect(
170
+ fixes('.a { transition: opacity tokens.duration(fast) cubic-bezier(0.16, 1, 0.3, 1); }')[0],
171
+ ).toStartWith('transition: opacity tokens.duration(fast) tokens.easing(out) — ');
172
+ expect(
173
+ rawMotion(sheet('.a { animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }')),
174
+ ).toHaveLength(1);
175
+ });
176
+
177
+ unitTest('one declaration is one finding, with every literal in it rewritten', () => {
178
+ const findings = rawMotion(
179
+ sheet('.a { transition: color 220ms cubic-bezier(0.65, 0, 0.35, 1), opacity 640ms ease; }'),
180
+ );
181
+ expect(findings).toHaveLength(1);
182
+ expect(findings[0]?.fix).toStartWith(
183
+ 'transition: color tokens.duration(base) tokens.easing(in-out), opacity tokens.duration(slower) ease — ',
184
+ );
185
+ });
186
+
187
+ unitTest('the fix speaks the namespace the sheet uses the tokens under', () => {
188
+ const scss = "@use '../shared/tokens' as t;\\n.a { transition-duration: 120ms; }";
189
+ expect(fixes(scss)[0]).toStartWith('transition-duration: t.duration(fast) — ');
190
+ });
191
+
192
+ // The legitimate lookalikes — the reason the rule can stay switched on.
193
+ unitTest('a token, a keyword, a zero and a multiple of a token are silent', () => {
194
+ const silent = [
195
+ '.a { transition: opacity tokens.duration(fast) tokens.easing(out); }',
196
+ '.a { transition: opacity var(--duration-fast) var(--easing-out); }',
197
+ '.a { animation: spin calc(#{tokens.duration(slower)} * 2) linear infinite; }',
198
+ '.a { transition: none; }',
199
+ '.a { transition-delay: 0s; animation-delay: 0ms; }',
200
+ '.a { animation-iteration-count: 3; }',
201
+ '// transition: opacity 120ms;\\n.a { transition: none; }',
202
+ '.a { width: 100ms; }',
203
+ '$tempo: 120ms;',
204
+ ];
205
+ for (const scss of silent) expect(rawMotion(sheet(scss))).toEqual([]);
206
+ });
207
+ `;
208
+
209
+ /** `guards/raw-motion.ts` and its test. No index, no registry — the directory is the registration. */
210
+ export const rawMotionGuardFiles = (): readonly GeneratedFile[] => [
211
+ { path: `guards/${NAME}.ts`, contents: source() },
212
+ { path: `guards/${NAME}.test.ts`, contents: test() },
213
+ ];
@@ -0,0 +1,187 @@
1
+ // The `raw-shadow` guard `x new` ships: elevation is `tokens.shadow()` or `@include tokens.surface`.
2
+ // The shadow scale is THEMED — dark gets deeper, higher-alpha shadows — so a hand-written offset
3
+ // and blur is the one elevation that does not change when the theme does.
4
+
5
+ import { guardCode } from './guard';
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ /** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
9
+ const NAME = 'raw-shadow';
10
+ const CODE = guardCode(NAME);
11
+
12
+ const source =
13
+ (): string => `// raw-shadow: elevation comes off the shadow scale, so dark theme deepens it instead of losing it.
14
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
15
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
16
+ //
17
+ // A shadow is RAW when a length in it was written by hand. \`tokens.shadow('md')\`, \`none\`, a
18
+ // hairline ring (\`inset 0 0 0 1px …\`) and a line drawn from tokens (\`inset 0 #{tokens.stroke(thick)} …\`)
19
+ // carry no such length and are never reported.
20
+
21
+ import type { Finding, Guard } from '@ultimat3/cli';
22
+
23
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
24
+ const CODE = '${CODE}';
25
+
26
+ export interface StyleFile {
27
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
28
+ readonly path: string;
29
+ readonly scss: string;
30
+ }
31
+
32
+ const blank = (text: string): string => text.replaceAll(/[^\\n]/g, ' ');
33
+
34
+ /** Comments blanked rather than removed, so a reported line number still points at the source. */
35
+ const blankComments = (scss: string): string =>
36
+ scss.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, blank).replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, blank);
37
+
38
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
39
+
40
+ /** The \`as <name>\` the token entry point is used under in this sheet; \`tokens\` when it is not. */
41
+ const namespaceOf = (scss: string): string =>
42
+ /@use\\s+['"][^'"]*tokens[^'"]*['"]\\s+as\\s+([\\w-]+)/.exec(scss)?.[1] ?? 'tokens';
43
+
44
+ /** A declaration; a Sass \`#{…}\` interpolation is a value that carries braces. */
45
+ const DECLARATION = /(?<![\\w$-])([\\w-]+)\\s*:\\s*((?:#\\{[^}]*\\}|[^;{}])+)/g;
46
+ const SHADOW_PROPERTY = /^(?:box-shadow|text-shadow)$/;
47
+
48
+ /** The index just past the \`)\` that closes the \`(\` at \`open\`, or \`undefined\` when none does. */
49
+ const closeOf = (text: string, open: number): number | undefined => {
50
+ let depth = 0;
51
+ for (let index = open; index < text.length; index += 1) {
52
+ const character = text.charAt(index);
53
+ if (character === '(') depth += 1;
54
+ else if (character === ')') {
55
+ depth -= 1;
56
+ if (depth === 0) return index + 1;
57
+ }
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ /**
63
+ * A token read — \`var(--shadow-md)\`, a module call (\`tokens.space(2)\`, \`t.rem(-4px)\`) or the bare
64
+ * \`shadow(md)\` — with its whole argument list, so the px handed to \`rem()\` is not read as raw.
65
+ */
66
+ const TOKEN_CALL = /(?<![\\w-])(?:var|(?:[\\w-]+\\.)[\\w-]+|shadow|rem|space|stroke)\\(/g;
67
+
68
+ const withoutTokens = (value: string): string => {
69
+ let out = value;
70
+ for (const match of value.matchAll(TOKEN_CALL)) {
71
+ const open = match.index + match[0].length - 1;
72
+ const close = closeOf(out, open);
73
+ if (close !== undefined) {
74
+ out = out.slice(0, match.index) + blank(out.slice(match.index, close)) + out.slice(close);
75
+ }
76
+ }
77
+ return out;
78
+ };
79
+
80
+ /** A hand-written length: any unit, any sign — except \`0\` and the \`1px\` of a hairline ring. */
81
+ const LENGTH = /(?<![\\w.#-])-?(\\d*\\.?\\d+)(px|rem|em|vh|vw|%)/g;
82
+
83
+ const rawLength = (value: string): string | undefined => {
84
+ for (const match of withoutTokens(value).matchAll(LENGTH)) {
85
+ const amount = Number(match[1]);
86
+ if (amount === 0 || (amount === 1 && match[2] === 'px')) continue;
87
+ return match[0];
88
+ }
89
+ return undefined;
90
+ };
91
+
92
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
93
+ export function rawShadows(files: readonly StyleFile[]): readonly Finding[] {
94
+ const findings: Finding[] = [];
95
+ for (const file of files) {
96
+ const scss = blankComments(file.scss);
97
+ const ns = namespaceOf(file.scss);
98
+ for (const match of scss.matchAll(DECLARATION)) {
99
+ const property = match[1] ?? '';
100
+ const value = (match[2] ?? '').trim().replaceAll(/\\s+/g, ' ');
101
+ const dropShadow = /drop-shadow\\(/.test(value);
102
+ if (!SHADOW_PROPERTY.test(property) && !dropShadow) continue;
103
+ const literal = rawLength(value);
104
+ if (literal === undefined) continue;
105
+ const line = lineOf(scss, match.index);
106
+ // An inset shadow is a LINE drawn inside the box, not elevation: its repair is the stroke
107
+ // scale, and telling its author to reach for \`shadow()\` would be telling them to delete it.
108
+ const written = /\\binset\\b/.test(value)
109
+ ? \`write each length of this inset line as \${ns}.stroke(thick), \${ns}.space(<step>) or \${ns}.rem(<px>)\`
110
+ : \`\${property}: \${ns}.shadow('sm') — or 'xs' 'md' 'lg' 'xl'; a card or popover takes @include \${ns}.surface\`;
111
+ findings.push({
112
+ code: CODE,
113
+ cause: \`\${file.path}:\${line} writes \\\`\${property}: \${value}\\\` — a shadow with the hand-written length \${literal}, which no theme restates: dark mode keeps the light theme's elevation\`,
114
+ fix: \`\${written} — at \${file.path}:\${line}, then: x verify\`,
115
+ at: \`\${file.path}:\${line}\`,
116
+ });
117
+ }
118
+ }
119
+ return findings;
120
+ }
121
+
122
+ export const guard: Guard = {
123
+ summary: 'a shadow comes off the shadow scale, never a hand-written offset and blur',
124
+ async check(_root, sources) {
125
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
126
+ return rawShadows(sheets.map((file) => ({ path: file.path, scss: file.text })));
127
+ },
128
+ };
129
+ `;
130
+
131
+ const test =
132
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
133
+ // a green gate over the convention it was written to enforce.
134
+
135
+ import { expect, unitTest } from '@ultimat3/testing';
136
+ import { rawShadows } from './raw-shadow';
137
+
138
+ const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
139
+
140
+ unitTest('a hand-written box-shadow is refused, and the fix is the declaration to write', () => {
141
+ const findings = rawShadows(sheet('.card {\\n box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);\\n}\\n'));
142
+ expect(findings).toHaveLength(1);
143
+ expect(findings[0]?.code).toBe('${CODE}');
144
+ expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:2');
145
+ expect(findings[0]?.cause).toContain('4px');
146
+ expect(findings[0]?.fix).toStartWith("box-shadow: tokens.shadow('sm') — ");
147
+ });
148
+
149
+ unitTest('text-shadow, a rem length and a drop-shadow filter are the same rule', () => {
150
+ expect(rawShadows(sheet('.a { text-shadow: 0 0.25rem 0 currentcolor; }'))).toHaveLength(1);
151
+ expect(rawShadows(sheet('.a { filter: drop-shadow(0 2px 4px currentcolor); }'))).toHaveLength(1);
152
+ });
153
+
154
+ unitTest('an inset line is repaired with the stroke scale, not the elevation scale', () => {
155
+ const findings = rawShadows(sheet('.tab { box-shadow: inset 0 -2px 0 0 currentcolor; }'));
156
+ expect(findings).toHaveLength(1);
157
+ expect(findings[0]?.fix).toContain('tokens.stroke(thick)');
158
+ expect(findings[0]?.fix).not.toContain('tokens.shadow(');
159
+ });
160
+
161
+ unitTest('the fix speaks the namespace the sheet uses the tokens under', () => {
162
+ const scss = "@use '../shared/tokens' as t;\\n.a { box-shadow: 0 2px 6px black; }";
163
+ expect(rawShadows(sheet(scss))[0]?.fix).toStartWith("box-shadow: t.shadow('sm') — ");
164
+ });
165
+
166
+ // The legitimate lookalikes — the reason the rule can stay switched on.
167
+ unitTest('a token, none, a hairline ring and a line drawn from tokens are silent', () => {
168
+ const silent = [
169
+ ".a { box-shadow: tokens.shadow('md'); }",
170
+ '.a { box-shadow: var(--shadow-sm); }',
171
+ '.a { box-shadow: none; }',
172
+ ".a { box-shadow: inset 0 0 0 1px tokens.role('accent', 0.24); }",
173
+ '.a { box-shadow: inset 0 calc(-1 * #{tokens.stroke(thick)}) 0 0 currentcolor; }',
174
+ '.a { box-shadow: inset #{tokens.rem(-4.5px)} #{tokens.rem(-3px)} 0 0 currentcolor; }',
175
+ ".a { box-shadow: tokens.shadow('sm'), inset 0 0 0 1px currentcolor; }",
176
+ '// box-shadow: 0 4px 12px black;\\n.a { box-shadow: none; }',
177
+ '.a { padding: 4px 12px; }',
178
+ ];
179
+ for (const scss of silent) expect(rawShadows(sheet(scss))).toEqual([]);
180
+ });
181
+ `;
182
+
183
+ /** `guards/raw-shadow.ts` and its test. No index, no registry — the directory is the registration. */
184
+ export const rawShadowGuardFiles = (): readonly GeneratedFile[] => [
185
+ { path: `guards/${NAME}.ts`, contents: source() },
186
+ { path: `guards/${NAME}.test.ts`, contents: test() },
187
+ ];
@@ -0,0 +1,166 @@
1
+ // The `raw-z-index` guard `x new` ships: a `z-index` is a named layer of `@ultimat3/ui/tokens`' z
2
+ // ladder. `_z.scss` has always said "a magic number in a component is a review failure"; this is
3
+ // that sentence as a build error.
4
+
5
+ import { guardCode } from './guard';
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ /** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
9
+ const NAME = 'raw-z-index';
10
+ const CODE = guardCode(NAME);
11
+
12
+ const source =
13
+ (): string => `// raw-z-index: a layer is a name on the one z ladder, so "above the dialog" means one thing.
14
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
15
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
16
+ //
17
+ // The NEGATIVE of a named layer is still a named layer — \`calc(-1 * #{tokens.z(raised)})\` puts a
18
+ // decoration behind its own stacking context — so a \`-1 *\` beside a token is not a number.
19
+
20
+ import type { Finding, Guard } from '@ultimat3/cli';
21
+
22
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
23
+ const CODE = '${CODE}';
24
+
25
+ /** \`@ultimat3/ui/tokens\`' z ladder. */
26
+ export const LAYERS: readonly (readonly [string, number])[] = [
27
+ ['base', 0],
28
+ ['raised', 10],
29
+ ['sticky', 100],
30
+ ['dropdown', 200],
31
+ ['drawer', 300],
32
+ ['dialog', 400],
33
+ ['popover', 500],
34
+ ['tooltip', 600],
35
+ ['toast', 700],
36
+ ['skip-nav', 800],
37
+ ];
38
+
39
+ export interface StyleFile {
40
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
41
+ readonly path: string;
42
+ readonly scss: string;
43
+ }
44
+
45
+ const blank = (text: string): string => text.replaceAll(/[^\\n]/g, ' ');
46
+
47
+ /** Comments blanked rather than removed, so a reported line number still points at the source. */
48
+ const blankComments = (scss: string): string =>
49
+ scss.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, blank).replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, blank);
50
+
51
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
52
+
53
+ /** The \`as <name>\` the token entry point is used under in this sheet; \`tokens\` when it is not. */
54
+ const namespaceOf = (scss: string): string =>
55
+ /@use\\s+['"][^'"]*tokens[^'"]*['"]\\s+as\\s+([\\w-]+)/.exec(scss)?.[1] ?? 'tokens';
56
+
57
+ const Z_INDEX = /(?<![\\w$-])z-index\\s*:\\s*((?:#\\{[^}]*\\}|[^;{}])+)/g;
58
+ /** A token read: \`tokens.z(dialog)\`, \`z('dialog')\`, \`var(--z-dialog)\` — and the negation of one. */
59
+ const TOKEN = /(?:[\\w-]+\\.)?z\\([^)]*\\)|var\\(\\s*--z-[\\w-]+\\s*\\)|-1\\s*\\*|\\*\\s*-1/g;
60
+
61
+ /** The nearest layer to a number. Never \`base\` for a non-zero one: \`-0\` is not behind anything. */
62
+ const layerFor = (value: number): string => {
63
+ let best: readonly [string, number] | undefined;
64
+ for (const layer of LAYERS) {
65
+ if (value !== 0 && layer[1] === 0) continue;
66
+ if (
67
+ best === undefined ||
68
+ Math.abs(layer[1] - Math.abs(value)) < Math.abs(best[1] - Math.abs(value))
69
+ ) {
70
+ best = layer;
71
+ }
72
+ }
73
+ return best?.[0] ?? 'raised';
74
+ };
75
+
76
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
77
+ export function rawZIndexes(files: readonly StyleFile[]): readonly Finding[] {
78
+ const findings: Finding[] = [];
79
+ for (const file of files) {
80
+ const scss = blankComments(file.scss);
81
+ const ns = namespaceOf(file.scss);
82
+ for (const match of scss.matchAll(Z_INDEX)) {
83
+ const value = (match[1] ?? '').trim();
84
+ const literal = /-?\\d+/.exec(value.replaceAll(TOKEN, ' '))?.[0];
85
+ if (literal === undefined) continue;
86
+ const line = lineOf(scss, match.index);
87
+ const layer = \`\${ns}.z(\${layerFor(Number(literal))})\`;
88
+ const written = Number(literal) < 0 ? \`calc(-1 * #{\${layer}})\` : layer;
89
+ findings.push({
90
+ code: CODE,
91
+ cause: \`\${file.path}:\${line} writes \\\`z-index: \${value}\\\` — a number only this file knows, on no ladder another layer can be placed against\`,
92
+ fix: \`z-index: \${written} — at \${file.path}:\${line} (layers: \${LAYERS.map(([name]) => name).join(' ')}), then: x verify\`,
93
+ at: \`\${file.path}:\${line}\`,
94
+ });
95
+ }
96
+ }
97
+ return findings;
98
+ }
99
+
100
+ export const guard: Guard = {
101
+ summary: 'a z-index is a named layer, never a number',
102
+ async check(_root, sources) {
103
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
104
+ return rawZIndexes(sheets.map((file) => ({ path: file.path, scss: file.text })));
105
+ },
106
+ };
107
+ `;
108
+
109
+ const test =
110
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
111
+ // a green gate over the convention it was written to enforce.
112
+
113
+ import { expect, unitTest } from '@ultimat3/testing';
114
+ import { rawZIndexes } from './raw-z-index';
115
+
116
+ const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
117
+ const fixes = (scss: string) => rawZIndexes(sheet(scss)).map((finding) => finding.fix);
118
+
119
+ unitTest('a numeric z-index is refused, and the fix is the layer it means', () => {
120
+ const findings = rawZIndexes(sheet('.menu {\\n z-index: 200;\\n}\\n'));
121
+ expect(findings).toHaveLength(1);
122
+ expect(findings[0]?.code).toBe('${CODE}');
123
+ expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:2');
124
+ expect(findings[0]?.fix).toStartWith('z-index: tokens.z(dropdown) — ');
125
+ });
126
+
127
+ unitTest('a number off the ladder gets the nearest layer', () => {
128
+ expect(fixes('.a { z-index: 9999; }')[0]).toStartWith('z-index: tokens.z(skip-nav) — ');
129
+ expect(fixes('.a { z-index: 1; }')[0]).toStartWith('z-index: tokens.z(raised) — ');
130
+ expect(fixes('.a { z-index: 0; }')[0]).toStartWith('z-index: tokens.z(base) — ');
131
+ });
132
+
133
+ unitTest('a negative number is the negation of a layer, never of base', () => {
134
+ expect(fixes('.a { z-index: -1; }')[0]).toStartWith('z-index: calc(-1 * #{tokens.z(raised)}) — ');
135
+ });
136
+
137
+ unitTest('a number beside a token is still a number', () => {
138
+ expect(rawZIndexes(sheet('.a { z-index: calc(#{tokens.z(dialog)} + 1); }'))).toHaveLength(1);
139
+ });
140
+
141
+ unitTest('the fix speaks the namespace the sheet uses the tokens under', () => {
142
+ const scss = "@use '../shared/tokens' as t;\\n.a { z-index: 400; }";
143
+ expect(fixes(scss)[0]).toStartWith('z-index: t.z(dialog) — ');
144
+ });
145
+
146
+ // The legitimate lookalikes — the reason the rule can stay switched on.
147
+ unitTest('a token, its negation and a keyword are silent', () => {
148
+ const silent = [
149
+ '.a { z-index: tokens.z(dialog); }',
150
+ ".a { z-index: tokens.z('skip-nav'); }",
151
+ '.a { z-index: var(--z-toast); }',
152
+ '.a { z-index: calc(-1 * #{tokens.z(raised)}); }',
153
+ '.a { z-index: auto; }',
154
+ '.a { z-index: inherit; }',
155
+ '// z-index: 5;\\n.a { z-index: auto; }',
156
+ '$z-index: 5;',
157
+ ];
158
+ for (const scss of silent) expect(rawZIndexes(sheet(scss))).toEqual([]);
159
+ });
160
+ `;
161
+
162
+ /** `guards/raw-z-index.ts` and its test. No index, no registry — the directory is the registration. */
163
+ export const rawZIndexGuardFiles = (): readonly GeneratedFile[] => [
164
+ { path: `guards/${NAME}.ts`, contents: source() },
165
+ { path: `guards/${NAME}.test.ts`, contents: test() },
166
+ ];