@ultimat3/cli 7.0.0 → 9.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 (78) hide show
  1. package/CLAUDE.md +24 -4
  2. package/README.md +8 -3
  3. package/package.json +26 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/app-load.ts +7 -0
  6. package/src/bin.ts +6 -3
  7. package/src/ci-log.ts +0 -0
  8. package/src/cmd-db-backfill.ts +240 -0
  9. package/src/cmd-db-branch.ts +3 -2
  10. package/src/cmd-db.ts +35 -156
  11. package/src/cmd-deploy.ts +43 -6
  12. package/src/cmd-dev.ts +7 -1
  13. package/src/cmd-errors.ts +2 -3
  14. package/src/cmd-fix.ts +3 -3
  15. package/src/cmd-i18n.ts +67 -5
  16. package/src/cmd-jobs.ts +27 -4
  17. package/src/cmd-mcp.ts +18 -9
  18. package/src/cmd-new.ts +91 -4
  19. package/src/cmd-policy.ts +3 -2
  20. package/src/cmd-pr.ts +55 -4
  21. package/src/cmd-registries.ts +3 -2
  22. package/src/cmd-shot.ts +68 -6
  23. package/src/cmd-tasks.ts +9 -4
  24. package/src/cmd-verify.ts +47 -6
  25. package/src/dev-assets.ts +4 -7
  26. package/src/dev-cache.ts +130 -33
  27. package/src/dev-lock.ts +124 -12
  28. package/src/dev-purge.ts +120 -0
  29. package/src/dev-queue.ts +39 -9
  30. package/src/dev-render.ts +11 -14
  31. package/src/dev-replicator.ts +3 -7
  32. package/src/dev-roles-fixture.ts +1 -1
  33. package/src/dev-roles.ts +40 -8
  34. package/src/dev-runtime.ts +137 -6
  35. package/src/dev-sync.ts +9 -4
  36. package/src/dispatch.ts +35 -5
  37. package/src/document-styles.ts +2 -1
  38. package/src/drift.ts +52 -7
  39. package/src/error-codes.ts +5 -0
  40. package/src/framework-scope.ts +57 -5
  41. package/src/generate-kinds.ts +19 -1
  42. package/src/i18n-registration.ts +67 -4
  43. package/src/index.ts +1 -1
  44. package/src/island-bundle.ts +2 -6
  45. package/src/island-styles.ts +1 -1
  46. package/src/jobs-report.ts +10 -13
  47. package/src/mcp-errors.ts +3 -0
  48. package/src/messages.ts +12 -0
  49. package/src/output.ts +22 -2
  50. package/src/parse.ts +81 -37
  51. package/src/prerender.ts +2 -1
  52. package/src/realtime-browser-probe-fixture.ts +9 -0
  53. package/src/runtime-overrides.ts +12 -4
  54. package/src/serve.ts +1 -1
  55. package/src/shot-settle.ts +57 -0
  56. package/src/shot-verdict.ts +27 -4
  57. package/src/solid-loader.ts +1 -1
  58. package/src/style-csp.ts +2 -1
  59. package/src/sync-authenticator.ts +86 -14
  60. package/src/templates/guard-bare-error.ts +122 -0
  61. package/src/templates/guard-raw-colour.ts +138 -0
  62. package/src/templates/guard-untranslated-string.ts +138 -0
  63. package/src/templates/guard-unzoned-date.ts +142 -0
  64. package/src/templates/index.ts +3 -0
  65. package/src/templates/island.ts +2 -1
  66. package/src/templates/route.ts +1 -1
  67. package/src/templates/scaffold-app.ts +3 -82
  68. package/src/templates/scaffold-container.ts +30 -4
  69. package/src/templates/scaffold-db-package.ts +14 -6
  70. package/src/templates/scaffold-docs.ts +34 -16
  71. package/src/templates/scaffold-entries.ts +131 -0
  72. package/src/templates/scaffold-guards.ts +26 -0
  73. package/src/templates/scaffold-repo.ts +40 -7
  74. package/src/test-select.ts +4 -3
  75. package/src/verify-run.ts +25 -3
  76. package/src/verify-step.ts +11 -2
  77. package/src/verify-tests.ts +11 -3
  78. package/src/write-line.ts +23 -5
@@ -0,0 +1,57 @@
1
+ // When a shot may be TAKEN: the two rules that decide whether the page has finished hydrating,
2
+ // separated from both the command that drives a browser and the verdict that judges what came
3
+ // back. Plain values and an injected sleep, so the whole loop is proved with neither.
4
+
5
+ import type { IslandCount } from './shot-verdict';
6
+
7
+ /**
8
+ * When there is nothing left to wait for. `booted` counts the islands whose chunk the runtime
9
+ * ASKED for; `mounted` and `failed` are the two ways that request can end — so the outcome of
10
+ * every boot exists exactly when they add up to it. An island that never booted is not something
11
+ * to wait for (`visible` with nothing scrolled to it, `never` by declaration), and `null` is "the
12
+ * page answered no probe", which no amount of waiting turns into an answer.
13
+ */
14
+ export const islandsSettled = (islands: IslandCount | null): boolean =>
15
+ islands === null || islands.mounted + islands.failed >= islands.booted;
16
+
17
+ /**
18
+ * How often the island probe is re-read while the page settles. Short enough that a page whose
19
+ * mounts have already resolved pays one extra read and nothing else.
20
+ */
21
+ export const SETTLE_POLL_MS = 100;
22
+
23
+ export interface SettleOptions {
24
+ /** The extra budget a mount gets AFTER the boot deadline. Bounded: a picture is still owed. */
25
+ readonly windowMs: number;
26
+ readonly pollMs: number;
27
+ /** Injected by the test, so the poll is proved without spending its own window in real time. */
28
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
29
+ }
30
+
31
+ /**
32
+ * Read the probe until every booted island has settled, or the window runs out.
33
+ *
34
+ * `DEFAULT_SETTLE_MS` is the deadline at which the hydration runtime CALLS `import()` — `mounted`
35
+ * and `failed` land after it — so a single read at that instant reports `mounted: 0` for a page
36
+ * that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
37
+ * is the only shape that ends EARLY on a fast page and still bounds a slow one.
38
+ *
39
+ * A `null` answer never overwrites a real count: `null` means "not counted", and a probe that
40
+ * fails once would otherwise turn a page with islands into a page reported to have none.
41
+ */
42
+ export async function settleIslands(
43
+ probe: () => Promise<IslandCount | null>,
44
+ options: SettleOptions,
45
+ ): Promise<IslandCount | null> {
46
+ const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
47
+ let answer = await probe();
48
+ let waited = 0;
49
+ while (!islandsSettled(answer) && waited < options.windowMs) {
50
+ // At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
51
+ const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
52
+ await sleep(step);
53
+ waited += step;
54
+ answer = (await probe()) ?? answer;
55
+ }
56
+ return answer;
57
+ }
@@ -33,6 +33,7 @@ export const SHOT_MESSAGE_KEYS = [
33
33
  'cli.shot.canvasUnreadable',
34
34
  'cli.shot.islands',
35
35
  'cli.shot.islandsUnknown',
36
+ 'cli.shot.islandFailed',
36
37
  'cli.shot.network',
37
38
  'cli.shot.console',
38
39
  'cli.shot.threw',
@@ -176,19 +177,28 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
176
177
  lines.filter((line) => line.level === level).length;
177
178
 
178
179
  /**
179
- * `ok` is three conditions, and every one is something a picture cannot show: nothing on the page
180
- * logged an error, nothing THREW, and the document photographed is the route that was asked for.
180
+ * `ok` is four conditions, and every one is something a picture cannot show: nothing on the page
181
+ * logged an error, nothing THREW, no island's `mount()` REJECTED, and the document photographed is
182
+ * the route that was asked for.
181
183
  *
182
184
  * The throw is its own clause rather than folded into `errors` because an uncaught exception calls
183
185
  * no console method — a page whose island died can log nothing at all, and `errors === 0` would
184
- * then pass it. A redirect is a failure of the CAPTURE rather than of the app: an agent that
186
+ * then pass it. A rejected mount is a third silent one and was read by NOTHING until 2026-08-22:
187
+ * the prelude pays 129 B an island to write `data-x-failed`, the probe counted it into the
188
+ * artifact, and every island on a page could reject while the run reported "clean". `?? 0` keeps
189
+ * an uncounted probe (`null`) out of the verdict — "not counted" is not "none failed".
190
+ * A redirect is a failure of the CAPTURE rather than of the app: an agent that
185
191
  * photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
186
192
  */
187
193
  export function buildVerdict(input: ShotInput): ShotVerdict {
188
194
  const errors = levelCount(input.console, 'error');
189
195
  return {
190
196
  ...input,
191
- ok: errors === 0 && input.pageErrors.length === 0 && input.requestedUrl === input.finalUrl,
197
+ ok:
198
+ errors === 0 &&
199
+ input.pageErrors.length === 0 &&
200
+ (input.islands?.failed ?? 0) === 0 &&
201
+ input.requestedUrl === input.finalUrl,
192
202
  redirected: input.requestedUrl !== input.finalUrl,
193
203
  errors,
194
204
  warnings: levelCount(input.console, 'warn'),
@@ -328,6 +338,19 @@ export const shotSummary = (verdict: ShotVerdict): string => {
328
338
  first: verdict.pageErrors[0]?.message ?? '',
329
339
  });
330
340
  }
341
+ // Ahead of the console count for the same reason, and it is the same silence: a rejected mount
342
+ // promise calls no console method either, so a page whose every island died can read `errors: 0`.
343
+ // The first failure is NAMED — "1 island failed" sends a reader back to the artifact for the one
344
+ // fact they need to start.
345
+ const failure = verdict.islands?.failures[0];
346
+ if (failure !== undefined) {
347
+ return msg('cli.shot.islandFailed', {
348
+ route: verdict.route,
349
+ failed: verdict.islands?.failed ?? 0,
350
+ island: failure.island,
351
+ message: failure.message,
352
+ });
353
+ }
331
354
  if (verdict.errors > 0) {
332
355
  return msg('cli.shot.errors', { route: verdict.route, errors: verdict.errors });
333
356
  }
@@ -7,7 +7,7 @@
7
7
  // `tsc` compiles this file inside ITS program, where a `.d.ts` sitting in this directory is
8
8
  // not included and its `declare module` never applies. `tsc -b` proves it in this repo.
9
9
  import { transformAsync } from '@babel/core';
10
- import { contentHash } from '@ultimat3/render';
10
+ import { contentHash } from '@ultimat3/render/server';
11
11
  import solidPreset from 'babel-preset-solid';
12
12
  import type { BunPlugin } from 'bun';
13
13
  import { IslandBuildFailedError } from './errors';
package/src/style-csp.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // stylesheet the document no longer carries — and the CSP would block the framework's own CSS.
5
5
 
6
6
  import { cspHashSource } from '@ultimat3/http';
7
- import { SURFACES, stylesFor } from '@ultimat3/render';
7
+ import { SURFACES } from '@ultimat3/render';
8
+ import { stylesFor } from '@ultimat3/render/server';
8
9
 
9
10
  /**
10
11
  * Call AFTER `loadApp`. One hash per distinct body: `stylesFor` is what `dev-render.ts` puts in
@@ -4,7 +4,8 @@
4
4
  // per-tenant subscription cap all decided against an anonymous actor. Realtime was single-tenant
5
5
  // by wiring, not by design.
6
6
 
7
- import type { Actor } from '@ultimat3/core';
7
+ import type { Actor, Clock } from '@ultimat3/core';
8
+ import { systemClock } from '@ultimat3/core';
8
9
  import type { HttpConfig } from '@ultimat3/http';
9
10
  import {
10
11
  configuredAuthenticator,
@@ -12,7 +13,37 @@ import {
12
13
  defineHttpConfig,
13
14
  UltimateRequest,
14
15
  } from '@ultimat3/http';
15
- import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime';
16
+ import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime/server';
17
+
18
+ /**
19
+ * How long one grant stands before the node re-decides it.
20
+ *
21
+ * A grant with no expiry never appears in `GrantBook.expired()`, so `sweepGrants` — the only path
22
+ * to `hub.onActorChange` and `registry.reauthorize` — never fired for a socket this adapter opened.
23
+ * `logout`, `revokeSession`, `disableUser` and `updatePrivileges` closed the HTTP session and never
24
+ * the websocket, and the client's 15s heartbeat beats the 120s idle sweep, so the socket stayed up
25
+ * with the revoked actor's authority for as long as the tab was open.
26
+ *
27
+ * Five minutes, against `DEFAULT_REAUTH_INTERVAL_MS` (30s): the window a revoked actor keeps its
28
+ * socket is this plus one sweep, and the cost is one resolver call per socket per window — 167/s
29
+ * on the 50,000-socket node this repo has measured, against 1,667/s at a 30s TTL. A deployment
30
+ * whose credential has a shorter real lifetime passes `runtime.syncAuthenticate` and states it.
31
+ */
32
+ export const SYNC_GRANT_TTL_MS = 5 * 60_000;
33
+
34
+ /**
35
+ * The credential this adapter retains per socket, and nothing else.
36
+ *
37
+ * `sync-auth.ts` says the seam is a closure precisely so a node does not hold one `Request` per
38
+ * connection for the life of that connection: `SyncSocket`'s budget is ~1KB and the grant sits
39
+ * beside it. Two header values is what an app that closes over a token string would hold.
40
+ *
41
+ * Dropping the rest can only make a refresh MORE restrictive, never more permissive: an app that
42
+ * resolves identity from some other header sees its refresh answer `null`, which closes the socket
43
+ * with `1008` and the client re-dials carrying that header again. One reconnect per window, not an
44
+ * escalation — and `runtime.syncAuthenticate` is the declared seam for stating something else.
45
+ */
46
+ const CREDENTIAL_HEADERS = ['cookie', 'authorization'] as const;
16
47
 
17
48
  /**
18
49
  * The upgrade request, dressed as the request an `Authenticator` reads.
@@ -27,33 +58,74 @@ function upgradeConfig(buildId: string): HttpConfig {
27
58
  return defineHttpConfig({ buildId, rateLimit: { enabled: false, scope: 'process' } });
28
59
  }
29
60
 
61
+ /** What the closure keeps: enough to ask the app's resolver the same question a second time. */
62
+ interface Credential {
63
+ readonly url: string;
64
+ readonly method: string;
65
+ readonly headers: Headers;
66
+ }
67
+
68
+ function credentialOf(request: Request): Credential {
69
+ const headers = new Headers();
70
+ for (const name of CREDENTIAL_HEADERS) {
71
+ const value = request.headers.get(name);
72
+ if (value !== null) headers.set(name, value);
73
+ }
74
+ return { url: request.url, method: request.method, headers };
75
+ }
76
+
77
+ export interface SyncAuthenticatorOptions {
78
+ /** The clock a grant's window is measured on. Injected so a re-auth is provable without sleeping. */
79
+ readonly clock?: Clock;
80
+ /** Overrides `SYNC_GRANT_TTL_MS`. A test names its own window; nothing in the boot passes one. */
81
+ readonly ttlMs?: number;
82
+ }
83
+
30
84
  /**
31
85
  * What the sync node is given when the app configured an authenticator, and `undefined` when it
32
86
  * did not — which keeps `x dev` anonymous and makes the node log that it is, exactly as
33
87
  * `createSyncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
34
88
  *
35
- * The grant carries **no `expiresAt` and no `refresh`**, and that is the honest limit of this
36
- * adapter rather than an omission: `configureAuthenticator()` resolves an `Actor` and says nothing
37
- * about how long it stays true, so inventing a window here would either close live sockets that
38
- * are still authorized or claim a lifetime the app never promised. A deployment whose credential
39
- * has a real expiry passes `runtime.syncAuthenticate` and gets re-authorization; the timer for it
40
- * already lives in `createSyncNode.start()`.
89
+ * The grant carries an `expiresAt` and a `refresh`, and both are the app's own resolver asked
90
+ * again: `configureAuthenticator` says who is dialling, and the only honest way to learn that it
91
+ * has stopped being true is to ask. A `null` second answer is a revocation the node turns into a
92
+ * `1008`; a THROW is a backend failure, and `sweepGrants` keeps the grant and retries — the
93
+ * adapter must not collapse those two, here or on the refresh path.
41
94
  */
42
- export function syncAuthenticator(buildId: string): SyncAuthenticator | undefined {
95
+ export function syncAuthenticator(
96
+ buildId: string,
97
+ options: SyncAuthenticatorOptions = {},
98
+ ): SyncAuthenticator | undefined {
43
99
  const authenticate = configuredAuthenticator();
44
100
  if (authenticate === undefined) return undefined;
45
101
  // Once per node, not once per upgrade: resolving a config is pure and a 50k-socket node pays
46
102
  // this per connection otherwise.
47
103
  const config = upgradeConfig(buildId);
48
- return async (request: Request): Promise<SyncGrant | null> => {
104
+ const clock = options.clock ?? systemClock;
105
+ const ttlMs = options.ttlMs ?? SYNC_GRANT_TTL_MS;
106
+
107
+ const resolve = async (credential: Credential): Promise<SyncGrant | null> => {
108
+ const request = new Request(credential.url, {
109
+ method: credential.method,
110
+ headers: credential.headers,
111
+ });
49
112
  const ctx = createRequestContext({
50
- url: new URL(request.url),
51
- method: request.method,
113
+ url: new URL(credential.url),
114
+ method: credential.method,
52
115
  role: 'sync',
53
116
  config,
54
- requestHeaders: request.headers,
117
+ requestHeaders: credential.headers,
55
118
  });
56
119
  const actor: Actor | null = await authenticate(new UltimateRequest(request, ctx), ctx);
57
- return actor === null ? null : { actor };
120
+ if (actor === null) return null;
121
+ return {
122
+ // The window is measured from the answer, not from the upgrade: a refreshed grant that
123
+ // returned its original instant would be expired again on the very next pass.
124
+ actor,
125
+ expiresAt: clock.now().getTime() + ttlMs,
126
+ refresh: () => resolve(credential),
127
+ };
58
128
  };
129
+
130
+ return async (request: Request): Promise<SyncGrant | null> => resolve(credentialOf(request));
59
131
  }
@@ -0,0 +1,122 @@
1
+ // The `bare-error` guard `x new` ships: no shipped module throws a bare `Error`.
2
+ // `AGENTS.md` has always stated the rule and NOTHING enforced it — `throw new Error(...)` in a
3
+ // scaffolded `repo.ts` was green on `x verify`, and it reaches an agent as a stack trace with no
4
+ // code, no cause and nothing to run.
5
+
6
+ import { guardCode } from './guard';
7
+ import type { GeneratedFile } from './naming';
8
+
9
+ /**
10
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
11
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
12
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
13
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
14
+ */
15
+ const NAME = 'bare-error';
16
+ const CODE = guardCode(NAME);
17
+
18
+ const source =
19
+ (): string => `// bare-error: a failure this app raises carries a code, a cause and an executable fix.
20
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
21
+ // step — nothing registers this file, so nothing can forget to.
22
+
23
+ import type { Finding, Guard } from '@ultimat3/cli';
24
+
25
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
26
+ const CODE = '${CODE}';
27
+
28
+ /**
29
+ * A THROW, never a construction. \`new Error(…)\` handed to something as INPUT is legitimate — a
30
+ * test fixture, an \`AbortSignal\` reason, a rejection this module is passing along — and only the
31
+ * throw is this module stating its own verdict.
32
+ */
33
+ const BARE_THROW = /\\bthrow\\s+new\\s+(Error|TypeError|RangeError|SyntaxError)\\s*\\(/g;
34
+
35
+ export interface SourceFile {
36
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
37
+ readonly path: string;
38
+ readonly source: string;
39
+ }
40
+
41
+ /** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
42
+ const blank = (text: string): string =>
43
+ text
44
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
45
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
46
+
47
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
48
+
49
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
50
+ export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
51
+ const findings: Finding[] = [];
52
+ for (const file of files) {
53
+ const text = blank(file.source);
54
+ for (const match of text.matchAll(BARE_THROW)) {
55
+ const line = lineOf(text, match.index);
56
+ const thrown = match[1] ?? 'Error';
57
+ findings.push({
58
+ code: CODE,
59
+ cause: \`\${file.path}:\${line} throws a bare \${thrown} — it reaches its reader as a stack trace with no code, no cause and nothing to run\`,
60
+ fix: \`subclass UltimateError in \${file.path} with an X_SCREAMING_SNAKE code, a cause and a fix naming a command, then: x verify\`,
61
+ at: file.path,
62
+ });
63
+ }
64
+ }
65
+ return findings;
66
+ }
67
+
68
+ export const guard: Guard = {
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);
83
+ },
84
+ };
85
+ `;
86
+
87
+ const test =
88
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
89
+ // a green gate over the convention it was written to enforce.
90
+
91
+ import { expect, unitTest } from '@ultimat3/testing';
92
+ import { bareThrows } from './bare-error';
93
+
94
+ const file = (source: string) => [{ path: 'apps/web/app/post/repo.ts', source }];
95
+
96
+ unitTest('a bare throw is refused, and the finding names the line', () => {
97
+ const findings = bareThrows(file("const x = 1;\\nthrow new Error('no post');"));
98
+ expect(findings).toHaveLength(1);
99
+ expect(findings[0]?.code).toBe('${CODE}');
100
+ expect(findings[0]?.cause).toContain(':2');
101
+ });
102
+
103
+ unitTest('TypeError and RangeError are the same rule', () => {
104
+ expect(bareThrows(file("throw new TypeError('x');"))).toHaveLength(1);
105
+ expect(bareThrows(file("throw new RangeError('x');"))).toHaveLength(1);
106
+ });
107
+
108
+ unitTest('an UltimateError subclass is what the rule asks for', () => {
109
+ expect(bareThrows(file('throw new PostError(missingPost(id));'))).toEqual([]);
110
+ });
111
+
112
+ unitTest('a bare Error that is INPUT is not a verdict', () => {
113
+ expect(bareThrows(file("controller.abort(new Error('cancelled'));"))).toEqual([]);
114
+ expect(bareThrows(file("// throw new Error('x');"))).toEqual([]);
115
+ });
116
+ `;
117
+
118
+ /** `guards/bare-error.ts` and its test. No index, no registry — the directory registers it. */
119
+ export const bareErrorGuardFiles = (): readonly GeneratedFile[] => [
120
+ { path: 'guards/bare-error.ts', contents: source() },
121
+ { path: 'guards/bare-error.test.ts', contents: test() },
122
+ ];
@@ -0,0 +1,138 @@
1
+ // The `raw-colour` guard `x new` ships: no stylesheet in this app names a colour.
2
+ // `AGENTS.md` has always stated the rule and NOTHING enforced it — `verify-checks.ts` said it rode
3
+ // on `packages/ui/src/tokens/tokens.test.ts`, which covers the framework's stylesheets and never
4
+ // the app's, so `color: #ff0000` in a scaffolded `page.module.scss` was green on `x verify`.
5
+
6
+ import { guardCode } from './guard';
7
+ import type { GeneratedFile } from './naming';
8
+
9
+ /**
10
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
11
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
12
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
13
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
14
+ */
15
+ const NAME = 'raw-colour';
16
+ const CODE = guardCode(NAME);
17
+
18
+ const source =
19
+ (): string => `// raw-colour: every colour in this app is a semantic token, so dark theme is not a later project.
20
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
21
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
22
+
23
+ import type { Finding, Guard } from '@ultimat3/cli';
24
+
25
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
26
+ const CODE = '${CODE}';
27
+
28
+ /** A hex literal. \`#{$x}\` is Sass interpolation, not a colour, and \`{\` is not a hex digit. */
29
+ const HEX = /#[0-9a-fA-F]{3,8}\\b/;
30
+ const CHANNEL_FUNCTION = /\\b(?:rgba?|hsla?|lab|lch|oklab|oklch|color)\\(/i;
31
+ /** The named colours a human actually types. The full CSS list would report \`.item\` selectors. */
32
+ const NAMED =
33
+ /\\b(?:white|black|red|green|blue|yellow|orange|purple|pink|brown|gray|grey|silver|navy|teal|olive|lime|aqua|maroon|fuchsia|gold|beige|coral|crimson|indigo|violet|khaki|salmon|tan|turquoise|wheat)\\b/i;
34
+
35
+ /**
36
+ * A DECLARATION, never a whole line: a selector carries no colon, so \`#hero { … }\` is not a value
37
+ * and is never reported. The value stops at the first \`;\`, \`{\` or \`}\`.
38
+ */
39
+ const DECLARATION = /([\\w-]+)\\s*:\\s*([^;{}]+)/g;
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
+ /**
48
+ * Comments blanked rather than removed, so the reported line number still points at the source
49
+ * line. \`//\` is skipped when a \`:\` precedes it — \`url(https://…)\` is a value, not a comment.
50
+ */
51
+ const blankComments = (scss: string): string =>
52
+ scss
53
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
54
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
55
+
56
+ /** Quoted text is a filename or a token name, never a colour: \`url('red.png')\`, \`role('bg')\`. */
57
+ const unquote = (value: string): string => value.replaceAll(/'[^']*'|"[^"]*"/g, ' ');
58
+
59
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
60
+
61
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
62
+ export function rawColours(files: readonly StyleFile[]): readonly Finding[] {
63
+ const findings: Finding[] = [];
64
+ for (const file of files) {
65
+ const scss = blankComments(file.scss);
66
+ for (const match of scss.matchAll(DECLARATION)) {
67
+ const property = match[1] ?? '';
68
+ const value = unquote(match[2] ?? '');
69
+ const literal = HEX.exec(value) ?? CHANNEL_FUNCTION.exec(value) ?? NAMED.exec(value);
70
+ if (literal === null) continue;
71
+ findings.push({
72
+ code: CODE,
73
+ cause: \`\${file.path}:\${lineOf(scss, match.index)} sets \${property} to the raw colour \${literal[0]} — a value no theme can restate, so dark theme renders it unchanged\`,
74
+ fix: \`replace \${literal[0]} in \${file.path} with tokens.role('fg'), tokens.role('bg') or the role this element means, then: x verify\`,
75
+ at: file.path,
76
+ });
77
+ }
78
+ }
79
+ return findings;
80
+ }
81
+
82
+ export const guard: Guard = {
83
+ summary: 'a stylesheet names a semantic token, never a colour',
84
+ async check(root) {
85
+ const files: StyleFile[] = [];
86
+ for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
87
+ cwd: root,
88
+ absolute: false,
89
+ })) {
90
+ const path = entry.split('\\\\').join('/');
91
+ if (path.includes('node_modules/')) continue;
92
+ files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
93
+ }
94
+ return rawColours(files);
95
+ },
96
+ };
97
+ `;
98
+
99
+ const test =
100
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
101
+ // a green gate over the convention it was written to enforce.
102
+
103
+ import { expect, unitTest } from '@ultimat3/testing';
104
+ import { rawColours } from './raw-colour';
105
+
106
+ const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
107
+
108
+ unitTest('a hex literal in a declaration is refused', () => {
109
+ const findings = rawColours(sheet('.hero {\\n color: #ff0000;\\n}\\n'));
110
+ expect(findings).toHaveLength(1);
111
+ expect(findings[0]?.code).toBe('${CODE}');
112
+ expect(findings[0]?.cause).toContain('#ff0000');
113
+ expect(findings[0]?.cause).toContain(':2');
114
+ });
115
+
116
+ unitTest('rgb(), hsl() and a named colour are the same rule', () => {
117
+ expect(rawColours(sheet('.a { background: rgb(1 2 3); }'))).toHaveLength(1);
118
+ expect(rawColours(sheet('.a { background: hsl(1 2% 3%); }'))).toHaveLength(1);
119
+ expect(rawColours(sheet('.a { border-color: white; }'))).toHaveLength(1);
120
+ });
121
+
122
+ unitTest('a token, a selector and a quoted filename are not colours', () => {
123
+ expect(rawColours(sheet(".a { background: tokens.role('bg'); }"))).toEqual([]);
124
+ expect(rawColours(sheet('#hero { padding: 0; }'))).toEqual([]);
125
+ expect(rawColours(sheet(".a { background: url('red-hero.png'); }"))).toEqual([]);
126
+ });
127
+
128
+ unitTest('a commented-out colour is a note, not a declaration', () => {
129
+ expect(rawColours(sheet('// color: #ff0000;\\n.a { padding: 0; }'))).toEqual([]);
130
+ expect(rawColours(sheet('/* color: #ff0000; */\\n.a { padding: 0; }'))).toEqual([]);
131
+ });
132
+ `;
133
+
134
+ /** `guards/raw-colour.ts` and its test. No index, no registry — the directory is the registration. */
135
+ export const rawColourGuardFiles = (): readonly GeneratedFile[] => [
136
+ { path: 'guards/raw-colour.ts', contents: source() },
137
+ { path: 'guards/raw-colour.test.ts', contents: test() },
138
+ ];