@ultimat3/cli 22.15.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 (169) hide show
  1. package/CLAUDE.md +23 -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 +25 -1
  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-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
@@ -0,0 +1,375 @@
1
+ // The `raw-length` guard `x new` ships: no stylesheet and no inline style in this app writes a px.
2
+ // Until it existed only raw COLOUR was a build error; `padding: 12px` beside `tokens.space(3)` was
3
+ // green on `x verify`, and one surveyed app carried 870 off-scale lengths from before its own rule.
4
+ // The same file refuses a local `@function rem` / `fluid`, which silently shadows the framework's.
5
+
6
+ import { guardCode } from './guard';
7
+ import type { GeneratedFile } from './naming';
8
+
9
+ /** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
10
+ const NAME = 'raw-length';
11
+ const CODE = guardCode(NAME);
12
+
13
+ const source =
14
+ (): string => `// raw-length: every length in this app comes off the scale, so a layout holds at 200% text zoom.
15
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
16
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
17
+ //
18
+ // A DEFINITION is not a use: \`$gutter: 12px\`, a map entry and a parameter default name a value in
19
+ // one place, which is what a token is, and are never reported. What is reported is a px literal
20
+ // written where a property, an \`@include\` or an inline \`style\` takes its value.
21
+
22
+ import type { Finding, Guard } from '@ultimat3/cli';
23
+
24
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
25
+ const CODE = '${CODE}';
26
+
27
+ /** px → the step of \`@ultimat3/ui/tokens\`' space scale that IS that length. */
28
+ export const SPACE: Readonly<Record<string, string>> = {
29
+ '4': '1',
30
+ '8': '2',
31
+ '12': '3',
32
+ '16': '4',
33
+ '20': '5',
34
+ '24': '6',
35
+ '32': '8',
36
+ '40': '10',
37
+ '48': '12',
38
+ '64': '16',
39
+ };
40
+ /** px → the rung of the stroke scale: a border, an outline, a focus ring. */
41
+ export const STROKE: Readonly<Record<string, string>> = { '2': 'thick', '3': 'heavy' };
42
+ /** The properties a stroke is written in. Anywhere else 2px is a length, not a line weight. */
43
+ const STROKED = /^(?:border|outline|column-rule|text-decoration-thickness|stroke-width)/;
44
+ /** Owned by \`raw-shadow\`, which reads the whole value: one line, one finding. */
45
+ const SHADOWED = /^(?:box-shadow|text-shadow)$/;
46
+
47
+ export interface StyleFile {
48
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
49
+ readonly path: string;
50
+ readonly scss: string;
51
+ }
52
+
53
+ export interface MarkupFile {
54
+ readonly path: string;
55
+ readonly tsx: string;
56
+ }
57
+
58
+ const blank = (text: string): string => text.replaceAll(/[^\\n]/g, ' ');
59
+
60
+ /**
61
+ * Comments blanked rather than removed, so a reported line number still points at the source
62
+ * line. \`//\` is skipped when a \`:\` precedes it — \`url(https://…)\` is a value.
63
+ */
64
+ const blankComments = (scss: string): string =>
65
+ scss.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, blank).replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, blank);
66
+
67
+ /** Quoted text is a filename or a token name, never a length: \`url('hero-640px.png')\`. */
68
+ const blankQuoted = (scss: string): string => scss.replaceAll(/'[^'\\n]*'|"[^"\\n]*"/g, blank);
69
+
70
+ /** The index just past the \`)\` that closes the \`(\` at \`open\`, or \`undefined\` when none does. */
71
+ const closeOf = (text: string, open: number): number | undefined => {
72
+ let depth = 0;
73
+ for (let index = open; index < text.length; index += 1) {
74
+ const character = text.charAt(index);
75
+ if (character === '(') depth += 1;
76
+ else if (character === ')') {
77
+ depth -= 1;
78
+ if (depth === 0) return index + 1;
79
+ }
80
+ }
81
+ return undefined;
82
+ };
83
+
84
+ /** Every balanced \`(…)\` opened by a match of \`opener\` (which must END at the \`(\`), blanked. */
85
+ const blankCalls = (text: string, opener: RegExp): string => {
86
+ let out = text;
87
+ for (const match of text.matchAll(opener)) {
88
+ const open = match.index + match[0].length - 1;
89
+ const close = closeOf(out, open);
90
+ if (close !== undefined)
91
+ out = out.slice(0, open) + blank(out.slice(open, close)) + out.slice(close);
92
+ }
93
+ return out;
94
+ };
95
+
96
+ /** \`$name: …;\` at the start of a statement — a definition, map bodies included. */
97
+ const VARIABLE = /(?<=(?:^|[{};])\\s*)\\$[\\w-]+\\s*:[^;]*;/g;
98
+ /** The parameter list of a \`@mixin\` / \`@function\`: its defaults are definitions too. */
99
+ const PARAMETERS = /@(?:mixin|function)\\s+[\\w-]+\\s*\\(/g;
100
+ /** \`rem(24px)\` and \`fluid(16px, 32px)\` are how an off-scale length is written. */
101
+ const CONVERSION = /(?<![\\w-])(?:[\\w-]+\\.)?(?:rem|fluid)\\(/g;
102
+ /** A viewport query is \`raw-breakpoint\`'s line. */
103
+ const MEDIA = /@media[^{]*/g;
104
+
105
+ /** \`#{…}\` is a value that carries braces; as parentheses it no longer ends a statement. */
106
+ const INTERPOLATION = /#\\{([^}]*)\\}/g;
107
+
108
+ /** What is left is every place a length is USED. Same length as the source, offset for offset. */
109
+ const usesOf = (scss: string): string =>
110
+ blankCalls(
111
+ blankCalls(
112
+ blankQuoted(blankComments(scss))
113
+ .replaceAll(INTERPOLATION, (_match, inner: string) => \`#(\${inner})\`)
114
+ .replaceAll(VARIABLE, blank),
115
+ PARAMETERS,
116
+ ),
117
+ CONVERSION,
118
+ ).replaceAll(MEDIA, blank);
119
+
120
+ /** A px literal: signed, and never the tail of a name (\`.gap-12px\`) or a hex (\`#12px\`). */
121
+ const PX = /(?<=[\\s(,:/*+]-?)(\\d*\\.?\\d+)px\\b/g;
122
+
123
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
124
+
125
+ /** The \`as <name>\` the token entry point is used under in this sheet; \`tokens\` when it is not. */
126
+ const namespaceOf = (scss: string): string =>
127
+ /@use\\s+['"][^'"]*tokens[^'"]*['"]\\s+as\\s+([\\w-]+)/.exec(scss)?.[1] ?? 'tokens';
128
+
129
+ /** The statement around \`index\` — from the \`;\`, \`{\` or \`}\` before it to the one after. */
130
+ const statementAt = (
131
+ text: string,
132
+ index: number,
133
+ ): { readonly start: number; readonly end: number } => {
134
+ let start = index;
135
+ while (start > 0 && !';{}'.includes(text.charAt(start - 1))) start -= 1;
136
+ let end = index;
137
+ while (end < text.length && !';{}'.includes(text.charAt(end))) end += 1;
138
+ return { start, end };
139
+ };
140
+
141
+ /** What \`<n>px\` is written as: a step of the scale where it is one, \`rem()\` where it is not. */
142
+ const tokenFor = (px: string, negative: boolean, property: string, ns: string): string => {
143
+ const stroke = STROKED.test(property) ? STROKE[px] : undefined;
144
+ const call =
145
+ stroke !== undefined
146
+ ? \`\${ns}.stroke(\${stroke})\`
147
+ : SPACE[px] !== undefined
148
+ ? \`\${ns}.space(\${SPACE[px]})\`
149
+ : undefined;
150
+ if (call === undefined) return \`\${ns}.rem(\${negative ? '-' : ''}\${px}px)\`;
151
+ return negative ? \`calc(-1 * #{\${call}})\` : call;
152
+ };
153
+
154
+ const SHADOWING = /@function\\s+(rem|fluid)\\s*\\(/g;
155
+
156
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
157
+ export function rawLengths(files: readonly StyleFile[]): readonly Finding[] {
158
+ const findings: Finding[] = [];
159
+ for (const file of files) {
160
+ const ns = namespaceOf(file.scss);
161
+ const commentless = blankComments(file.scss);
162
+ for (const match of commentless.matchAll(SHADOWING)) {
163
+ const line = lineOf(commentless, match.index);
164
+ findings.push({
165
+ code: CODE,
166
+ cause: \`\${file.path}:\${line} defines its own \${match[1]}() — @ultimat3/ui/tokens ships one, and a local definition silently shadows it, so two sheets can disagree about what a px is\`,
167
+ fix: \`delete @function \${match[1]} from \${file.path}:\${line} and call \${ns}.\${match[1]}(), then: x verify\`,
168
+ at: \`\${file.path}:\${line}\`,
169
+ });
170
+ }
171
+
172
+ const uses = usesOf(file.scss);
173
+ const reported = new Set<number>();
174
+ for (const match of uses.matchAll(PX)) {
175
+ if (Number(match[1]) === 1 || Number(match[1]) === 0) continue;
176
+ const { start, end } = statementAt(uses, match.index);
177
+ if (reported.has(start)) continue;
178
+ const statement = commentless.slice(start, end).trim().replaceAll(/\\s+/g, ' ');
179
+ const property = /^([\\w-]+)\\s*:/.exec(statement)?.[1] ?? '';
180
+ if (SHADOWED.test(property)) continue;
181
+ reported.add(start);
182
+ // Rewritten from the BLANKED statement's matches, so a px inside \`rem(…)\` or a quoted
183
+ // filename in the same statement is left exactly as written.
184
+ let rewritten = '';
185
+ let cursor = start;
186
+ for (const literal of uses.slice(start, end).matchAll(PX)) {
187
+ if (Number(literal[1]) === 1 || Number(literal[1]) === 0) continue;
188
+ const at = start + literal.index;
189
+ const negative = uses.charAt(at - 1) === '-';
190
+ rewritten += commentless.slice(cursor, negative ? at - 1 : at);
191
+ rewritten += tokenFor(literal[1] ?? '', negative, property, ns);
192
+ cursor = at + literal[0].length;
193
+ }
194
+ rewritten = (rewritten + commentless.slice(cursor, end)).trim().replaceAll(/\\s+/g, ' ');
195
+ const line = lineOf(uses, match.index);
196
+ findings.push({
197
+ code: CODE,
198
+ cause: \`\${file.path}:\${line} writes \\\`\${statement}\\\` — a px length off the scale: it does not grow with the reader's text size, and no token names it\`,
199
+ fix: \`\${rewritten} — at \${file.path}:\${line}, then: x verify\`,
200
+ at: \`\${file.path}:\${line}\`,
201
+ });
202
+ }
203
+ }
204
+ return findings;
205
+ }
206
+
207
+ /** \`style={{ … }}\`, \`style="…"\` and \`style={\`…\`}\` — the three ways markup carries a length. */
208
+ const INLINE_STYLE = /\\bstyle=(?:\\{\\{([\\s\\S]*?)\\}\\}|"([^"]*)"|\\{\`([^\`]*)\`\\})/g;
209
+ const INLINE_PX = /(?<![\\w.#-])(\\d*\\.?\\d+)px\\b/g;
210
+
211
+ /** The same rule for markup: an inline \`style\` is a stylesheet nobody lints. */
212
+ export function rawInlineLengths(files: readonly MarkupFile[]): readonly Finding[] {
213
+ const findings: Finding[] = [];
214
+ for (const file of files) {
215
+ for (const style of file.tsx.matchAll(INLINE_STYLE)) {
216
+ const body = style[1] ?? style[2] ?? style[3] ?? '';
217
+ for (const literal of body.matchAll(INLINE_PX)) {
218
+ const px = literal[1] ?? '';
219
+ if (Number(px) === 1 || Number(px) === 0) continue;
220
+ const line = lineOf(file.tsx, style.index);
221
+ const step = SPACE[px];
222
+ const written = step === undefined ? \`\${Number(px) / 16}rem\` : \`var(--space-\${step})\`;
223
+ findings.push({
224
+ code: CODE,
225
+ cause: \`\${file.path}:\${line} sets \${px}px in an inline style — a length off the scale, in the one stylesheet nothing else reads\`,
226
+ fix: \`\${written} in place of \${px}px — at \${file.path}:\${line}; better, move the declaration into the element's .module.scss, then: x verify\`,
227
+ at: \`\${file.path}:\${line}\`,
228
+ });
229
+ }
230
+ }
231
+ }
232
+ return findings;
233
+ }
234
+
235
+ export const guard: Guard = {
236
+ summary: 'a length is a token or rem(), never a raw px',
237
+ async check(_root, sources) {
238
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
239
+ const markup = await sources.files('{apps,packages}/**/*.tsx');
240
+ return [
241
+ ...rawLengths(sheets.map((file) => ({ path: file.path, scss: file.text }))),
242
+ ...rawInlineLengths(markup.map((file) => ({ path: file.path, tsx: file.text }))),
243
+ ];
244
+ },
245
+ };
246
+ `;
247
+
248
+ const test =
249
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
250
+ // a green gate over the convention it was written to enforce.
251
+
252
+ import { expect, unitTest } from '@ultimat3/testing';
253
+ import { rawInlineLengths, rawLengths } from './raw-length';
254
+
255
+ const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
256
+ const fixes = (scss: string) => rawLengths(sheet(scss)).map((finding) => finding.fix);
257
+
258
+ unitTest('a px on the space scale is refused, and the fix is the declaration to write', () => {
259
+ const findings = rawLengths(sheet('.hero {\\n padding: 12px;\\n}\\n'));
260
+ expect(findings).toHaveLength(1);
261
+ expect(findings[0]?.code).toBe('${CODE}');
262
+ expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:2');
263
+ expect(findings[0]?.cause).toContain('padding: 12px');
264
+ expect(findings[0]?.fix).toStartWith('padding: tokens.space(3) — ');
265
+ });
266
+
267
+ unitTest('a px off the scale is written through rem()', () => {
268
+ expect(fixes('.a { font-size: 14px; }')[0]).toStartWith('font-size: tokens.rem(14px) — ');
269
+ });
270
+
271
+ unitTest('one statement is one finding, with every literal in it rewritten', () => {
272
+ const findings = rawLengths(sheet('.a { margin: 8px 14px -16px; }'));
273
+ expect(findings).toHaveLength(1);
274
+ expect(findings[0]?.fix).toStartWith(
275
+ 'margin: tokens.space(2) tokens.rem(14px) calc(-1 * #{tokens.space(4)}) — ',
276
+ );
277
+ });
278
+
279
+ unitTest('the fix speaks the namespace the sheet uses the tokens under', () => {
280
+ const scss = "@use '../../shared/tokens' as t;\\n.a { gap: 8px; }";
281
+ expect(fixes(scss)[0]).toStartWith('gap: t.space(2) — ');
282
+ });
283
+
284
+ unitTest('a line weight is a stroke, not a space step', () => {
285
+ expect(fixes('.a { border: 2px solid currentcolor; }')[0]).toStartWith(
286
+ 'border: tokens.stroke(thick) solid currentcolor — ',
287
+ );
288
+ expect(fixes('.a { outline-offset: -3px; }')[0]).toStartWith(
289
+ 'outline-offset: calc(-1 * #{tokens.stroke(heavy)}) — ',
290
+ );
291
+ });
292
+
293
+ unitTest('an @include argument is a use', () => {
294
+ expect(fixes('.a { @include tokens.focus-ring(accent, 4px); }')[0]).toStartWith(
295
+ '@include tokens.focus-ring(accent, tokens.space(1)) — ',
296
+ );
297
+ });
298
+
299
+ unitTest('a hairline and a zero are not lengths anyone scales', () => {
300
+ expect(rawLengths(sheet('.a { border: 1px solid; margin: -1px; inset: 0px; }'))).toEqual([]);
301
+ });
302
+
303
+ // The legitimate lookalikes — the reason the rule can stay switched on.
304
+ unitTest('a px handed to rem() or fluid() is the sanctioned spelling', () => {
305
+ expect(rawLengths(sheet('.a { width: tokens.rem(340px); }'))).toEqual([]);
306
+ expect(rawLengths(sheet('.a { font-size: tokens.fluid(16px, 32px); }'))).toEqual([]);
307
+ expect(rawLengths(sheet('.a { margin: calc(-1 * #{tokens.rem(6px)}) 0; }'))).toEqual([]);
308
+ });
309
+
310
+ unitTest('a definition names a value in one place, and is not a use', () => {
311
+ expect(rawLengths(sheet('$gutter: 12px;\\n$map: (\\n sm: 480px,\\n md: 768px,\\n);\\n'))).toEqual(
312
+ [],
313
+ );
314
+ expect(rawLengths(sheet('@mixin ring($width: 2px) { outline-width: $width; }'))).toEqual([]);
315
+ });
316
+
317
+ unitTest('a named ARGUMENT is a use, however much it looks like a definition', () => {
318
+ expect(rawLengths(sheet('.a { @include ring($width: 4px); }'))).toHaveLength(1);
319
+ });
320
+
321
+ unitTest('a comment, a quoted name and a class name are not lengths', () => {
322
+ expect(rawLengths(sheet('// padding: 12px;\\n/* gap: 12px */\\n.a { padding: 0; }'))).toEqual([]);
323
+ expect(rawLengths(sheet(".a { background: url('hero-640px.png'); }"))).toEqual([]);
324
+ expect(rawLengths(sheet('.gap-12px { padding: 0; }'))).toEqual([]);
325
+ });
326
+
327
+ unitTest('a comment above a declaration is not part of the statement the finding quotes', () => {
328
+ const findings = rawLengths(sheet('.a {\\n // why this is wide\\n padding: 12px;\\n}\\n'));
329
+ expect(findings[0]?.cause).toContain('writes \`padding: 12px\`');
330
+ expect(findings[0]?.fix).toStartWith('padding: tokens.space(3) — ');
331
+ expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:3');
332
+ });
333
+
334
+ unitTest('a viewport query and a shadow belong to their own guards', () => {
335
+ expect(rawLengths(sheet('@media (min-width: 768px) { .a { padding: 0; } }'))).toEqual([]);
336
+ expect(rawLengths(sheet('.a { box-shadow: 0 4px 12px black; }'))).toEqual([]);
337
+ });
338
+
339
+ unitTest('a value after an interpolation is still read as part of its declaration', () => {
340
+ const findings = rawLengths(sheet('.a { margin: calc(-1 * #{tokens.space(4)}) 12px; }'));
341
+ expect(findings[0]?.fix).toStartWith('margin: calc(-1 * #{tokens.space(4)}) tokens.space(3) — ');
342
+ });
343
+
344
+ unitTest('a local rem() or fluid() shadows the framework one, and is refused', () => {
345
+ const findings = rawLengths(sheet('@function rem($value) {\\n @return $value;\\n}\\n'));
346
+ expect(findings).toHaveLength(1);
347
+ expect(findings[0]?.cause).toContain('defines its own rem()');
348
+ expect(findings[0]?.fix).toStartWith(
349
+ 'delete @function rem from apps/web/site/page.module.scss:1',
350
+ );
351
+ });
352
+
353
+ const markup = (tsx: string) => [{ path: 'apps/web/site/page.tsx', tsx }];
354
+
355
+ unitTest('a px in an inline style is the same mistake in markup', () => {
356
+ const findings = rawInlineLengths(markup("<div style={{ padding: '12px', width: '90px' }} />"));
357
+ expect(findings.map((finding) => finding.fix.split(' in place of ')[0])).toEqual([
358
+ 'var(--space-3)',
359
+ '5.625rem',
360
+ ]);
361
+ expect(rawInlineLengths(markup('<div style="margin-top: 20px" />'))).toHaveLength(1);
362
+ });
363
+
364
+ unitTest('a custom property and a hairline in an inline style are silent', () => {
365
+ expect(
366
+ rawInlineLengths(markup("<div style={{ '--fill': '40%', border: '1px solid' }} />")),
367
+ ).toEqual([]);
368
+ });
369
+ `;
370
+
371
+ /** `guards/raw-length.ts` and its test. No index, no registry — the directory is the registration. */
372
+ export const rawLengthGuardFiles = (): readonly GeneratedFile[] => [
373
+ { path: `guards/${NAME}.ts`, contents: source() },
374
+ { path: `guards/${NAME}.test.ts`, contents: test() },
375
+ ];
@@ -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
+ ];