@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
@@ -185,17 +185,10 @@ function layoutFinding(path: string, at: string, property: string, where: string
185
185
 
186
186
  export const guard: Guard = {
187
187
  summary: 'an animation moves transform and opacity, never the layout',
188
- async check(root) {
189
- const files: StyleFile[] = [];
190
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
191
- cwd: root,
192
- absolute: false,
193
- })) {
194
- const path = entry.split('\\\\').join('/');
195
- if (path.includes('node_modules/')) continue;
196
- files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
197
- }
198
- return animatedLayoutProperties(files);
188
+ async check(_root, sources) {
189
+ // The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
190
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
191
+ return animatedLayoutProperties(sheets.map((file) => ({ path: file.path, scss: file.text })));
199
192
  },
200
193
  };
201
194
  `;
@@ -67,19 +67,15 @@ export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
67
67
 
68
68
  export const guard: Guard = {
69
69
  summary: 'a failure carries a code, a cause and an executable fix — never a bare Error',
70
- async check(root) {
71
- const files: SourceFile[] = [];
72
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
73
- cwd: root,
74
- absolute: false,
75
- })) {
76
- const path = entry.split('\\\\').join('/');
77
- // A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
78
- // terms; \`node_modules\` is not this app's source.
79
- if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
80
- files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
81
- }
82
- return bareThrows(files);
70
+ async check(_root, sources) {
71
+ // A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
72
+ // terms. The run's ONE read of the source: every guard asking for this glob shares the walk.
73
+ const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
74
+ return bareThrows(
75
+ code
76
+ .filter((file) => !/\\.(?:test|d)\\.tsx?$/.test(file.path))
77
+ .map((file) => ({ path: file.path, source: file.text })),
78
+ );
83
79
  },
84
80
  };
85
81
  `;
@@ -161,17 +161,10 @@ export function unreplacedFocusRings(files: readonly StyleFile[]): readonly Find
161
161
 
162
162
  export const guard: Guard = {
163
163
  summary: 'a stylesheet replaces the focus ring, never only removes it',
164
- async check(root) {
165
- const files: StyleFile[] = [];
166
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
167
- cwd: root,
168
- absolute: false,
169
- })) {
170
- const path = entry.split('\\\\').join('/');
171
- if (path.includes('node_modules/')) continue;
172
- files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
173
- }
174
- return unreplacedFocusRings(files);
164
+ async check(_root, sources) {
165
+ // The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
166
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
167
+ return unreplacedFocusRings(sheets.map((file) => ({ path: file.path, scss: file.text })));
175
168
  },
176
169
  };
177
170
  `;
@@ -141,19 +141,19 @@ export function unsizedImages(files: readonly SourceFile[]): readonly Finding[]
141
141
 
142
142
  export const guard: Guard = {
143
143
  summary: 'an image declares its box, and the priority one is never lazy',
144
- async check(root) {
144
+ async check(_root, sources) {
145
145
  const files: SourceFile[] = [];
146
146
  // TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
147
147
  // 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
148
148
  // \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
149
149
  // matches 17 — so folding these into one line silently turns the guard off, which is worse than
150
- // the hole it closes. A LEADING group is fine and four guards here rely on it:
150
+ // the hole it closes. A LEADING group is fine and the stylesheet guards here rely on it:
151
151
  // \`{apps,packages}/**/*.scss\` matches all 15.
152
152
  for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
153
- for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
154
- const path = entry.split('\\\\').join('/');
155
- if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
156
- files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
153
+ // The run's ONE read: every guard asking for these two globs shares the walk.
154
+ for (const file of await sources.files(pattern)) {
155
+ if (/\\.test\\.tsx?$/.test(file.path)) continue;
156
+ files.push({ path: file.path, source: file.text });
157
157
  }
158
158
  }
159
159
  return unsizedImages(files);
@@ -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
  `;