@ultimat3/cli 6.0.0 → 8.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 (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
@@ -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
+ ];
@@ -0,0 +1,138 @@
1
+ // The `untranslated-string` guard `x new` ships: no user-facing string is typed into a page.
2
+ // `AGENTS.md` has always stated the rule and NOTHING enforced it — a hardcoded JSX string sitting
3
+ // beside a `t()` call in a scaffolded page was green on `x verify`, and `x i18n check` cannot see
4
+ // it either: a literal that is in no catalog is a literal the catalog audit has no key for.
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 = 'untranslated-string';
16
+ const CODE = guardCode(NAME);
17
+
18
+ const source =
19
+ (): string => `// untranslated-string: every user-facing string on a rendered surface goes through \`t()\`.
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
+ * \`<tag …>text</tag>\`, matched on the CLOSING tag rather than on the next \`<\`.
30
+ *
31
+ * That is the whole reason this rule can run over TypeScript at all: \`createSignal<State>('idle')\`
32
+ * is a \`>\` followed by prose-shaped source, and a pattern reading to the next \`<\` reports every
33
+ * generic in the file. A closing tag that names the same element cannot be a type argument.
34
+ *
35
+ * Only the INNERMOST element matches — the content class excludes \`<\` and \`>\` — which is what the
36
+ * rule wants: a parent whose children are elements has no text of its own.
37
+ */
38
+ const ELEMENT = /<([A-Za-z][\\w.:-]*)(?:\\s[^<>]*)?>([^<>]*?)<\\/\\1>/g;
39
+ /** A \`{…}\` child is an expression — \`{t('key')}\`, \`{props.row.title}\` — never typed prose. */
40
+ const EXPRESSION = /\\{[^{}]*\\}/g;
41
+ /** Two word characters in a row. One is \`&\`, \`×\`, an initial — never a sentence. */
42
+ const PROSE = /[\\p{L}\\p{N}]{2,}/u;
43
+
44
+ export interface SourceFile {
45
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
46
+ readonly path: string;
47
+ readonly source: string;
48
+ }
49
+
50
+ /** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
51
+ const blank = (text: string): string =>
52
+ text
53
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
54
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
55
+
56
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
57
+
58
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
59
+ export function untranslatedStrings(files: readonly SourceFile[]): readonly Finding[] {
60
+ const findings: Finding[] = [];
61
+ for (const file of files) {
62
+ const text = blank(file.source);
63
+ for (const match of text.matchAll(ELEMENT)) {
64
+ const typed = (match[2] ?? '').replaceAll(EXPRESSION, ' ').trim();
65
+ if (!PROSE.test(typed)) continue;
66
+ findings.push({
67
+ code: CODE,
68
+ cause: \`\${file.path}:\${lineOf(text, match.index)} renders the typed string "\${typed}" inside <\${match[1] ?? 'element'}> — it is in no catalog, so every locale but the one it was typed in reads it verbatim\`,
69
+ fix: \`add a key for "\${typed}" to packages/i18n/catalogs/en.json, render it as {t('…')} in \${file.path}, then: x i18n check\`,
70
+ at: file.path,
71
+ });
72
+ }
73
+ }
74
+ return findings;
75
+ }
76
+
77
+ export const guard: Guard = {
78
+ summary: 'a rendered string comes from t(), never typed into the page',
79
+ async check(root) {
80
+ const files: SourceFile[] = [];
81
+ // Every rendered surface: an app's \`site/\` and \`app/\`, and the shared components under
82
+ // \`packages/*/src\` — \`x new\` scaffolds a \`packages/ui\` whose components render to a user, so
83
+ // a hardcoded string there used to be green. \`api/\` renders nothing and \`shared/\` is a leaf of
84
+ // helpers; \`packages/*/dist\` is a build output, not source.
85
+ //
86
+ // TWO globs, never one with a leading \`{a,b}\` group: \`Bun.Glob.scan()\` matches nothing at all
87
+ // for a pattern that starts with a brace group — measured — so folding these into one line
88
+ // silently turns the guard off, which is worse than the hole it closes.
89
+ for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
90
+ for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
91
+ const path = entry.split('\\\\').join('/');
92
+ if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
93
+ files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
94
+ }
95
+ }
96
+ return untranslatedStrings(files);
97
+ },
98
+ };
99
+ `;
100
+
101
+ const test =
102
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
103
+ // a green gate over the convention it was written to enforce.
104
+
105
+ import { expect, unitTest } from '@ultimat3/testing';
106
+ import { untranslatedStrings } from './untranslated-string';
107
+
108
+ const file = (source: string) => [{ path: 'apps/web/site/page.tsx', source }];
109
+
110
+ unitTest('a typed JSX string is refused, and the finding quotes it', () => {
111
+ const findings = untranslatedStrings(file('<h1>Welcome back</h1>'));
112
+ expect(findings).toHaveLength(1);
113
+ expect(findings[0]?.code).toBe('${CODE}');
114
+ expect(findings[0]?.cause).toContain('Welcome back');
115
+ });
116
+
117
+ unitTest('a t() child satisfies it, and so does any other expression', () => {
118
+ expect(untranslatedStrings(file("<h1>{t('site.home.title')}</h1>"))).toEqual([]);
119
+ expect(untranslatedStrings(file('<li class={styles.item}>{row.title}</li>'))).toEqual([]);
120
+ });
121
+
122
+ // The reason the rule reads a CLOSING tag: a generic type argument is a > followed by source that
123
+ // looks exactly like prose, and a pattern reading to the next < reports every one of them.
124
+ unitTest('a generic type argument is not a JSX text node', () => {
125
+ const generic = "const [state, setState] = createSignal<SaveState>('idle');\\nconst n = 1;";
126
+ expect(untranslatedStrings(file(generic))).toEqual([]);
127
+ });
128
+
129
+ unitTest('one word character is a symbol, not a sentence', () => {
130
+ expect(untranslatedStrings(file('<span>&</span>'))).toEqual([]);
131
+ });
132
+ `;
133
+
134
+ /** `guards/untranslated-string.ts` and its test. The directory is the registration. */
135
+ export const untranslatedStringGuardFiles = (): readonly GeneratedFile[] => [
136
+ { path: 'guards/untranslated-string.ts', contents: source() },
137
+ { path: 'guards/untranslated-string.test.ts', contents: test() },
138
+ ];