@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
@@ -65,7 +65,7 @@ export function islandsWithoutStates(
65
65
 
66
66
  export const guard: Guard = {
67
67
  summary: 'an island declares the states it can be photographed in',
68
- async check(root) {
68
+ async check(_root, sources) {
69
69
  const islands: string[] = [];
70
70
  const states: string[] = [];
71
71
  // Two patterns, one per root. These two CAN fold — \`{apps,packages}/**/*.island.*\` matches the
@@ -74,9 +74,7 @@ export const guard: Guard = {
74
74
  // \`examples/dummy\` where \`apps/*/{site,app}/**/*.tsx\` matches 17, which is why the guards
75
75
  // scanning \`site\`/\`app\` keep the loop — and this one keeps its shape to match them.
76
76
  for (const pattern of ['apps/**/*.island.*', 'packages/**/*.island.*']) {
77
- for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
78
- const path = entry.split('\\\\').join('/');
79
- if (path.includes('node_modules/') || path.includes('/dist/')) continue;
77
+ for (const { path } of await sources.files(pattern)) {
80
78
  if (path.endsWith(ISLAND_SUFFIX)) islands.push(path);
81
79
  else if (path.endsWith(STATES_SUFFIX)) states.push(path);
82
80
  }
@@ -0,0 +1,210 @@
1
+ // The `raw-breakpoint` guard `x new` ships: a viewport query is `respond-to` / `respond-down` /
2
+ // `respond-between` over the one ladder, never a hand-written `@media (min-width: …)`. It also
3
+ // refuses a local `@mixin respond-*`: Sass lets a sheet that forwards the tokens redefine one with
4
+ // no error, which is how an app ends up with two breakpoint ladders and nothing saying so.
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-breakpoint';
11
+ const CODE = guardCode(NAME);
12
+
13
+ const source =
14
+ (): string => `// raw-breakpoint: a viewport query names a rung of the one breakpoint ladder, never a width.
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
+ // Only WIDTH queries are a breakpoint. \`prefers-reduced-motion\`, \`prefers-color-scheme\`, \`print\`,
19
+ // \`hover\` and a height query ask a different question and are never reported; a \`@container\`
20
+ // query sizes by the component and is the better tool, so it is not reported either.
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
+ /** \`@ultimat3/ui/tokens\`' breakpoint ladder, in px. */
28
+ export const RUNGS: readonly (readonly [string, number])[] = [
29
+ ['sm', 480],
30
+ ['md', 768],
31
+ ['lg', 1024],
32
+ ['xl', 1280],
33
+ ['2xl', 1536],
34
+ ];
35
+
36
+ export interface StyleFile {
37
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
38
+ readonly path: string;
39
+ readonly scss: string;
40
+ }
41
+
42
+ const blank = (text: string): string => text.replaceAll(/[^\\n]/g, ' ');
43
+
44
+ /** Comments blanked rather than removed, so a reported line number still points at the source. */
45
+ const blankComments = (scss: string): string =>
46
+ scss.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, blank).replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, blank);
47
+
48
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
49
+
50
+ /** The \`as <name>\` the token entry point is used under in this sheet; \`tokens\` when it is not. */
51
+ const namespaceOf = (scss: string): string =>
52
+ /@use\\s+['"][^'"]*tokens[^'"]*['"]\\s+as\\s+([\\w-]+)/.exec(scss)?.[1] ?? 'tokens';
53
+
54
+ const MEDIA = /@media([^{;]*)/g;
55
+ /** \`(min-width: …)\`, \`(max-width: …)\`, and the range forms \`(width >= …)\` / \`(… <= width)\`. */
56
+ const WIDTH_FEATURE =
57
+ /\\(\\s*(?:(?:min-|max-)?(?:width|inline-size)\\s*[:<>=]|[^()]*[<>=]\\s*(?:width|inline-size)\\b)/;
58
+ const BOUND = /\\(\\s*(min|max)-(?:width|inline-size)\\s*:\\s*(\\d*\\.?\\d+)(px|rem|em)\\s*\\)/g;
59
+
60
+ /** The rung a width means: exact where it is one, the nearest where it is not. */
61
+ const rungFor = (px: number): string => {
62
+ let best = RUNGS[0];
63
+ for (const rung of RUNGS) {
64
+ if (best === undefined || Math.abs(rung[1] - px) < Math.abs(best[1] - px)) best = rung;
65
+ }
66
+ return best?.[0] ?? 'md';
67
+ };
68
+
69
+ /** The \`@include\` a hand-written width query is, where its bounds can be read. */
70
+ const includeFor = (prelude: string, ns: string): string | undefined => {
71
+ let min: string | undefined;
72
+ let max: string | undefined;
73
+ for (const bound of prelude.matchAll(BOUND)) {
74
+ const px = Number(bound[2]) * (bound[3] === 'px' ? 1 : 16);
75
+ // A max-width is written just UNDER its rung (767px, 767.98px), so it rounds up to one.
76
+ if (bound[1] === 'min') min = rungFor(px);
77
+ else max = rungFor(Math.ceil(px + 0.5));
78
+ }
79
+ if (min !== undefined && max !== undefined) {
80
+ return min === max ? undefined : \`@include \${ns}.respond-between(\${min}, \${max})\`;
81
+ }
82
+ if (min !== undefined) return \`@include \${ns}.respond-to(\${min})\`;
83
+ if (max !== undefined) return \`@include \${ns}.respond-down(\${max})\`;
84
+ return undefined;
85
+ };
86
+
87
+ const SHADOWING = /@mixin\\s+(respond-to|respond-down|respond-between)\\b/g;
88
+
89
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
90
+ export function rawBreakpoints(files: readonly StyleFile[]): readonly Finding[] {
91
+ const findings: Finding[] = [];
92
+ for (const file of files) {
93
+ const scss = blankComments(file.scss);
94
+ const ns = namespaceOf(file.scss);
95
+ for (const match of scss.matchAll(SHADOWING)) {
96
+ const line = lineOf(scss, match.index);
97
+ findings.push({
98
+ code: CODE,
99
+ cause: \`\${file.path}:\${line} defines its own \${match[1]} — @ultimat3/ui/tokens ships one, and a local definition silently shadows it, so the app has two breakpoint ladders\`,
100
+ fix: \`delete @mixin \${match[1]} from \${file.path}:\${line} and write @include \${ns}.\${match[1]}(…), then: x verify\`,
101
+ at: \`\${file.path}:\${line}\`,
102
+ });
103
+ }
104
+ for (const match of scss.matchAll(MEDIA)) {
105
+ const prelude = match[1] ?? '';
106
+ if (!WIDTH_FEATURE.test(prelude)) continue;
107
+ const line = lineOf(scss, match.index);
108
+ const include =
109
+ includeFor(prelude, ns) ??
110
+ \`@include \${ns}.respond-to(<rung>), \${ns}.respond-down(<rung>) or \${ns}.respond-between(<from>, <to>) — rungs: \${RUNGS.map(([name]) => name).join(' ')}\`;
111
+ findings.push({
112
+ code: CODE,
113
+ cause: \`\${file.path}:\${line} writes \\\`@media\${prelude.trimEnd()}\\\` — a viewport width of its own, off the breakpoint ladder every other sheet shares\`,
114
+ fix: \`\${include} — 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 viewport query names a breakpoint rung, never a width',
124
+ async check(_root, sources) {
125
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
126
+ return rawBreakpoints(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 { rawBreakpoints } from './raw-breakpoint';
137
+
138
+ const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
139
+ const fixes = (scss: string) => rawBreakpoints(sheet(scss)).map((finding) => finding.fix);
140
+
141
+ unitTest('a hand-written min-width is refused, and the fix is the include to write', () => {
142
+ const findings = rawBreakpoints(
143
+ sheet('.a {\\n @media (min-width: 768px) {\\n gap: 0;\\n }\\n}\\n'),
144
+ );
145
+ expect(findings).toHaveLength(1);
146
+ expect(findings[0]?.code).toBe('${CODE}');
147
+ expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:2');
148
+ expect(findings[0]?.fix).toStartWith('@include tokens.respond-to(md) — ');
149
+ });
150
+
151
+ unitTest('a max-width just under a rung is respond-down of that rung', () => {
152
+ expect(fixes('@media (max-width: 767px) { .a { gap: 0; } }')[0]).toStartWith(
153
+ '@include tokens.respond-down(md) — ',
154
+ );
155
+ expect(fixes('@media (max-width: 767.98px) { .a { gap: 0; } }')[0]).toStartWith(
156
+ '@include tokens.respond-down(md) — ',
157
+ );
158
+ });
159
+
160
+ unitTest(
161
+ 'both bounds are respond-between, and a width off the ladder gets the nearest rung',
162
+ () => {
163
+ const both = '@media screen and (min-width: 48em) and (max-width: 1023px) { .a { gap: 0; } }';
164
+ expect(fixes(both)[0]).toStartWith('@include tokens.respond-between(md, lg) — ');
165
+ expect(fixes('@media (min-width: 800px) { .a { gap: 0; } }')[0]).toStartWith(
166
+ '@include tokens.respond-to(md) — ',
167
+ );
168
+ },
169
+ );
170
+
171
+ unitTest('the range syntax is the same query, and names the three mixins', () => {
172
+ const findings = rawBreakpoints(sheet('@media (width >= 768px) { .a { gap: 0; } }'));
173
+ expect(findings).toHaveLength(1);
174
+ expect(findings[0]?.fix).toContain('tokens.respond-between(<from>, <to>)');
175
+ });
176
+
177
+ unitTest('the fix speaks the namespace the sheet uses the tokens under', () => {
178
+ const scss = "@use '../shared/tokens' as t;\\n@media (min-width: 1024px) { .a { gap: 0; } }";
179
+ expect(fixes(scss)[0]).toStartWith('@include t.respond-to(lg) — ');
180
+ });
181
+
182
+ // The legitimate lookalikes — the reason the rule can stay switched on.
183
+ unitTest('a query that is not about the viewport width is not a breakpoint', () => {
184
+ const silent = [
185
+ '@media (prefers-reduced-motion: no-preference) { .a { gap: 0; } }',
186
+ '@media (prefers-color-scheme: dark) { .a { gap: 0; } }',
187
+ '@media print { .a { gap: 0; } }',
188
+ '@media (hover: hover) and (min-height: 600px) { .a { gap: 0; } }',
189
+ '@container card (min-width: 30rem) { .a { gap: 0; } }',
190
+ '.a { @include tokens.respond-to(md) { gap: 0; } }',
191
+ '// @media (min-width: 768px) {}\\n.a { gap: 0; }',
192
+ ];
193
+ for (const scss of silent) expect(rawBreakpoints(sheet(scss))).toEqual([]);
194
+ });
195
+
196
+ unitTest('a local respond-* mixin shadows the framework one, and is refused', () => {
197
+ const findings = rawBreakpoints(sheet('@mixin respond-down($bp) {\\n @content;\\n}\\n'));
198
+ expect(findings).toHaveLength(1);
199
+ expect(findings[0]?.cause).toContain('defines its own respond-down');
200
+ expect(findings[0]?.fix).toStartWith(
201
+ 'delete @mixin respond-down from apps/web/site/page.module.scss:1',
202
+ );
203
+ });
204
+ `;
205
+
206
+ /** `guards/raw-breakpoint.ts` and its test. No index, no registry — the directory is the registration. */
207
+ export const rawBreakpointGuardFiles = (): readonly GeneratedFile[] => [
208
+ { path: `guards/${NAME}.ts`, contents: source() },
209
+ { path: `guards/${NAME}.test.ts`, contents: test() },
210
+ ];
@@ -153,17 +153,10 @@ export function rawColours(files: readonly StyleFile[]): readonly Finding[] {
153
153
 
154
154
  export const guard: Guard = {
155
155
  summary: 'a stylesheet names a semantic token, never a colour',
156
- async check(root) {
157
- const files: StyleFile[] = [];
158
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
159
- cwd: root,
160
- absolute: false,
161
- })) {
162
- const path = entry.split('\\\\').join('/');
163
- if (path.includes('node_modules/')) continue;
164
- files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
165
- }
166
- return rawColours(files);
156
+ async check(_root, sources) {
157
+ // The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
158
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
159
+ return rawColours(sheets.map((file) => ({ path: file.path, scss: file.text })));
167
160
  },
168
161
  };
169
162
  `;
@@ -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
+ ];