@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,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
+ ];
@@ -0,0 +1,142 @@
1
+ // The `unzoned-date` guard `x new` ships: no date is formatted without an explicit IANA zone.
2
+ // `AGENTS.md` has always stated the rule and NOTHING enforced it — `toLocaleDateString('en-US')`
3
+ // in a scaffolded page was green on `x verify`, and it renders the SERVER's zone, so the same row
4
+ // reads as two different days depending on which container answered.
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 = 'unzoned-date';
16
+ const CODE = guardCode(NAME);
17
+
18
+ const source =
19
+ (): string => `// unzoned-date: a date is stored in UTC and formatted with an explicit IANA zone, never with the
20
+ // host's ambient one. \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside
21
+ // the \`boundaries\` 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
+ /** Every call whose output depends on a zone. The \`(\` is the start of the argument list. */
29
+ const FORMATTER = /(?:\\.toLocale(?:Date|Time)?String|Intl\\.DateTimeFormat)\\s*\\(/g;
30
+
31
+ export interface SourceFile {
32
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
33
+ readonly path: string;
34
+ readonly source: string;
35
+ }
36
+
37
+ /**
38
+ * Comments blanked IN PLACE — not deleted — so the line number a finding reports still points at
39
+ * the source line. What it does not blank is a string body: a call spelled inside a quoted string
40
+ * is reported, which is the one false positive this rule can produce and the reason its own test
41
+ * lives in \`guards/\`, which nothing here scans.
42
+ */
43
+ const blank = (text: string): string =>
44
+ text
45
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
46
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
47
+
48
+ /**
49
+ * The call's own argument list, from its \`(\` to the \`)\` that closes it. Depth-counted rather
50
+ * than "up to the next \`)\`": \`toLocaleDateString(locale, { timeZone: zoneFor(x) })\` closes twice
51
+ * before the one that ends the call, and reading only the first would report a zoned call.
52
+ */
53
+ function argumentsOf(text: string, open: number): string {
54
+ let depth = 0;
55
+ for (let index = open; index < text.length; index += 1) {
56
+ const char = text[index];
57
+ if (char === '(') depth += 1;
58
+ else if (char === ')') {
59
+ depth -= 1;
60
+ if (depth === 0) return text.slice(open + 1, index);
61
+ }
62
+ }
63
+ return text.slice(open + 1);
64
+ }
65
+
66
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
67
+
68
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
69
+ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
70
+ const findings: Finding[] = [];
71
+ for (const file of files) {
72
+ const text = blank(file.source);
73
+ for (const match of text.matchAll(FORMATTER)) {
74
+ const open = match.index + match[0].length - 1;
75
+ if (argumentsOf(text, open).includes('timeZone')) continue;
76
+ const line = lineOf(text, match.index);
77
+ findings.push({
78
+ code: CODE,
79
+ cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
80
+ fix: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
81
+ at: file.path,
82
+ });
83
+ }
84
+ }
85
+ return findings;
86
+ }
87
+
88
+ export const guard: Guard = {
89
+ summary: 'a date is never formatted without an explicit IANA time zone',
90
+ async check(root) {
91
+ const files: SourceFile[] = [];
92
+ for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
93
+ cwd: root,
94
+ absolute: false,
95
+ })) {
96
+ const path = entry.split('\\\\').join('/');
97
+ // A test's subject is often the wrong shape on purpose, and \`node_modules\` is not this
98
+ // app's source. Neither exclusion hides a rendered date from a user.
99
+ if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
100
+ files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
101
+ }
102
+ return unzonedDates(files);
103
+ },
104
+ };
105
+ `;
106
+
107
+ const test =
108
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
109
+ // a green gate over the convention it was written to enforce.
110
+
111
+ import { expect, unitTest } from '@ultimat3/testing';
112
+ import { unzonedDates } from './unzoned-date';
113
+
114
+ const file = (source: string) => [{ path: 'apps/web/app/dashboard/page.tsx', source }];
115
+
116
+ unitTest('toLocaleDateString with no timeZone is refused', () => {
117
+ const findings = unzonedDates(file("const shown = at.toLocaleDateString('en-US');"));
118
+ expect(findings).toHaveLength(1);
119
+ expect(findings[0]?.code).toBe('${CODE}');
120
+ expect(findings[0]?.fix).toContain('timeZone');
121
+ });
122
+
123
+ unitTest('an explicit zone satisfies it, even nested behind another call', () => {
124
+ const zoned = "at.toLocaleDateString('en-US', { timeZone: zoneFor(actor) });";
125
+ expect(unzonedDates(file(zoned))).toEqual([]);
126
+ });
127
+
128
+ unitTest('Intl.DateTimeFormat and toLocaleTimeString are the same rule', () => {
129
+ expect(unzonedDates(file("new Intl.DateTimeFormat('en-US').format(at);"))).toHaveLength(1);
130
+ expect(unzonedDates(file("at.toLocaleTimeString('en-US');"))).toHaveLength(1);
131
+ });
132
+
133
+ unitTest('a commented-out call is a note, not a call', () => {
134
+ expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
135
+ });
136
+ `;
137
+
138
+ /** `guards/unzoned-date.ts` and its test. No index, no registry — the directory registers it. */
139
+ export const unzonedDateGuardFiles = (): readonly GeneratedFile[] => [
140
+ { path: 'guards/unzoned-date.ts', contents: source() },
141
+ { path: 'guards/unzoned-date.test.ts', contents: test() },
142
+ ];
@@ -39,6 +39,9 @@ export { claudeAgentFiles } from './scaffold-claude-agents';
39
39
  export { claudeCommandFiles } from './scaffold-claude-commands';
40
40
  export { containerFiles } from './scaffold-container';
41
41
  export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
42
+ export { entryFiles } from './scaffold-entries';
43
+ // The four guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
44
+ export { scaffoldGuardFiles } from './scaffold-guards';
42
45
  export { i18nIndex } from './scaffold-i18n';
43
46
  export { repoFiles } from './scaffold-repo';
44
47
  export type { SliceModule } from './slice-foundation';
@@ -78,7 +78,8 @@ export function mount(el: HTMLElement, props: ${Name}Props): void {
78
78
  `;
79
79
  };
80
80
 
81
- const islandStyle = (): string => `// Semantic tokens only — a raw hex here is a dark-theme bug and
81
+ const islandStyle =
82
+ (): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step, not by lint — a dark-theme bug and
82
83
  // a lint failure. Scoped by the island build, with the class names the server hashed.
83
84
  @use '@ultimat3/ui/tokens' as tokens;
84
85
 
@@ -138,7 +138,7 @@ export function ${name}Page() {${translatorBinding(module)}
138
138
  };
139
139
 
140
140
  const styleSource =
141
- (): string => `// Semantic tokens only — a raw hex here is a dark-theme bug and a lint failure.
141
+ (): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step (guards/raw-colour.ts), not by lint — a dark-theme bug in every scheme but the one it was written in.
142
142
  @use '@ultimat3/ui/tokens' as tokens;
143
143
 
144
144
  .page {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
6
  import { apiFiles } from './scaffold-api';
7
+ import { entryFiles } from './scaffold-entries';
7
8
  import { icon } from './scaffold-icon';
8
9
  import { rolesFiles } from './scaffold-roles';
9
10
 
@@ -349,86 +350,6 @@ export function AdminHome() {
349
350
  }
350
351
  `;
351
352
 
352
- // The two entry files a deploy needs. Both are deliberately thin: which role a container is, which
353
- // port it binds, how it drains and what a static build enumerates are the framework's answers, so
354
- // an upgrade moves them without a codemod in every app that ever shipped.
355
-
356
- const server =
357
- (): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
358
- // ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
359
- // applies the migrations and exits. PORT is bound on every interface, because a container bound to
360
- // localhost is unreachable through its own port mapping.
361
-
362
- import { join } from 'node:path';
363
- import { runRole } from '@ultimat3/cli';
364
-
365
- // MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
366
- //
367
- // import { configureIdempotency } from '@ultimat3/action';
368
- // configureIdempotency({ scope: 'shared' });
369
- //
370
- // \`idempotent: true\` on an action promises that a retry does not repeat the work. Under the
371
- // process-scoped default that promise holds inside ONE process — a client retrying
372
- // \`POST /api/payments/charge\` after a timeout lands on another replica, which has never seen the
373
- // key, and charges the card twice, silently, with \`x verify\` green. Declaring \`'shared'\` is what
374
- // makes that a boot error (\`X_IDEMPOTENCY_NOT_SHARED\`) unless a shared store is installed.
375
- // \`runRole\` installs the Postgres one for you, on the connection it resolved from \`DATABASE_URL\`,
376
- // so the declaration is all this app owes. It must run before \`runRole\` imports the actions.
377
-
378
- /**
379
- * Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
380
- * A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
381
- * holds this module's bundled imports and none of the app's source, and the framework's registries
382
- * are filled by scanning that source at boot. So a binary reads its root from the directory it is
383
- * started in — it is a launcher for an app tree, not a self-contained copy of one.
384
- */
385
- const root = import.meta.dir.startsWith('/$bunfs')
386
- ? process.cwd()
387
- : join(import.meta.dir, '..', '..');
388
-
389
- // Guarded, because the framework's module scan imports every file under apps/*/ to fill its
390
- // registries — an unguarded boot would start a server inside \`x verify\`.
391
- if (import.meta.main) {
392
- await runRole({ root, env: Bun.env });
393
- }
394
- `;
395
-
396
- const prerender =
397
- (): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
398
- // file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
399
- // behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
400
- //
401
- // Skipped is not unweighed: a route that declares a \`budget:\` is rendered in memory and measured
402
- // whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
403
- // this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
404
- // the reason is.
405
- //
406
- // It writes the whole of \`pages\` and \`skipped\`, never a COUNT of either. A count is what let a
407
- // partial artifact read as a complete one: someone pointed a screenshot tool at \`.x/static\` and
408
- // filed "the island did not mount" against a route that had never been emitted (issue #242). Each
409
- // skipped route carries its own \`reason\` and \`why\`, and \`report\` is where the same inventory
410
- // landed on disk — which is what \`x build --target static --json\` reads back.
411
-
412
- import { join } from 'node:path';
413
- import { prerenderSite } from '@ultimat3/cli';
414
-
415
- const root = join(import.meta.dir, '..', '..');
416
- const flag = Bun.argv.indexOf('--out');
417
- const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
418
- // SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
419
- // Property access, not \`Bun.env['SITE_ORIGIN']\`: the scaffolded tsconfig does not set
420
- // \`noPropertyAccessFromIndexSignature\`, so the bracket form is the one biome's useLiteralKeys
421
- // reports — a diagnostic in an app's first lint run over a file the app never wrote.
422
- const origin = Bun.env.SITE_ORIGIN;
423
-
424
- if (import.meta.main) {
425
- const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
426
- await Bun.stdout.write(
427
- \`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report })}\\n\`,
428
- );
429
- }
430
- `;
431
-
432
353
  const placeholder = (surface: string, app: NameSet): string => `# ${surface}
433
354
 
434
355
  Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
@@ -447,8 +368,8 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
447
368
  return [
448
369
  { path: 'apps/web/package.json', contents: webPackage(app) },
449
370
  { path: 'apps/web/tsconfig.json', contents: tsconfig() },
450
- { path: 'apps/web/server.ts', contents: server() },
451
- { path: 'apps/web/prerender.ts', contents: prerender() },
371
+ // The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
372
+ ...entryFiles(),
452
373
  { path: 'apps/web/site/icon.png', contents: icon() },
453
374
  { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
454
375
  { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
@@ -52,8 +52,10 @@ ENV NODE_ENV=production \\
52
52
  # own port mapping, its load balancer and every health probe alike.
53
53
  EXPOSE 3000
54
54
 
55
- # Every role serves /healthz and /readyz. /readyz flips to 503 on SIGTERM *before* the socket
56
- # closes, so a rolling restart drains in-flight work instead of dropping it.
55
+ # The probe for the roles that SERVE HTTP — \`web\` and \`sync\`. /readyz flips to 503 on SIGTERM
56
+ # *before* the socket closes, so a rolling restart drains in-flight work instead of dropping it.
57
+ # Every other role opens the scrape listener alone and never binds $PORT, so each one overrides
58
+ # this in docker/docker-compose.prod.yml rather than reporting \`unhealthy\` for its whole life.
57
59
  HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
58
60
  bun --eval "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
59
61
 
@@ -120,6 +122,22 @@ x-image: &image
120
122
  depends_on:
121
123
  db: { condition: service_healthy }
122
124
 
125
+ # The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
126
+ # socket — every other role gets the scrape listener and nothing else. A service that inherits that
127
+ # probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
128
+ # gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
129
+ x-metrics-probe: &metrics-probe
130
+ test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:'+(process.env.METRICS_PORT||9090)+'/metrics').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
131
+ interval: 10s
132
+ timeout: 3s
133
+ start_period: 30s
134
+ retries: 3
135
+
136
+ # Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
137
+ # nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
138
+ x-run-once-probe: &run-once-probe
139
+ disable: true
140
+
123
141
  services:
124
142
  db:
125
143
  image: postgres:17-alpine
@@ -138,6 +156,7 @@ services:
138
156
  <<: *image
139
157
  environment: [ROLE=migrate]
140
158
  restart: 'no'
159
+ healthcheck: *run-once-probe
141
160
 
142
161
  # Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
143
162
  # UPDATE there holds the deploy open against a database still serving the previous version.
@@ -158,6 +177,7 @@ services:
158
177
  # listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
159
178
  web: { condition: service_healthy }
160
179
  restart: 'no'
180
+ healthcheck: *run-once-probe
161
181
 
162
182
  web:
163
183
  <<: *image
@@ -184,6 +204,7 @@ services:
184
204
  depends_on:
185
205
  db: { condition: service_healthy }
186
206
  migrate: { condition: service_completed_successfully }
207
+ healthcheck: *metrics-probe
187
208
  deploy: { replicas: 1 } # scales on queue depth
188
209
 
189
210
  scheduler:
@@ -192,7 +213,12 @@ services:
192
213
  depends_on:
193
214
  db: { condition: service_healthy }
194
215
  migrate: { condition: service_completed_successfully }
195
- deploy: { replicas: 1 } # fixed 1; leadership is a Postgres advisory lock
216
+ # Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (dev-roles.ts,
217
+ # driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
218
+ # process — it outlives every transaction and no pooled node can renew it or prove it still
219
+ # holds one. A second instance is harmless but idle.
220
+ healthcheck: *metrics-probe
221
+ deploy: { replicas: 1 }
196
222
 
197
223
  volumes:
198
224
  pgdata:
@@ -208,7 +234,7 @@ nothing to rebuild between staging and production.
208
234
  | \`web\` | HTTP: pages, actions, assets | \`$PORT\` (default 3000) |
209
235
  | \`sync\` | websockets for live queries | \`$PORT + 1\` |
210
236
  | \`worker\` | the job queue | — |
211
- | \`scheduler\` | cron tasks; leadership is a Postgres advisory lock | — |
237
+ | \`scheduler\` | cron tasks; leadership is an expiring lease row in \`x_scheduler_leader\` | — |
212
238
  | \`replicator\` | the logical replication slot, exactly one per database | — |
213
239
  | \`migrate\` | applies pending migrations and **exits** | — |
214
240
 
@@ -1,6 +1,6 @@
1
- // The generated app's `packages/db`: the entity re-export list the migration generator reads and
2
- // the deterministic seed. No business logic — that is the package's own stated boundary, and it is
3
- // why `example` reaches only the two files describing the slice's table.
1
+ // The generated app's `packages/db`: the entity re-export list the app's own modules import from
2
+ // and the deterministic seed. No business logic — that is the package's own stated boundary, and it
3
+ // is why `example` reaches only the two files describing the slice's table.
4
4
  //
5
5
  // No migration. `x db gen` is the ONE writer of `packages/db/migrations`, and a scaffold that hand-
6
6
  // wrote `0000_initial.sql` was a second one: it declared a `posts` table the generator had never
@@ -56,8 +56,16 @@ export * as schema from './schema';
56
56
  // never written, so each one ships its empty counterpart instead of a reference to a file that is
57
57
  // not there — `export { post } from …` alone made `x new --no-example` an app that cannot compile.
58
58
 
59
- const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
60
- // reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
59
+ // Not "what the migration generator reads" — that claim shipped into every generated app and was
60
+ // false. `x db gen` and `x verify`'s `drift` step both project the ENTITY REGISTRY that `loadApp`
61
+ // fills (`describeEntities()`, `packages/cli/src/app-entities.ts`), so an entity declared anywhere
62
+ // `loadApp` reaches is already in the migration whether or not this file names it. Re-exporting an
63
+ // entity here does exactly one thing, and it is worth doing: it gives `seed.ts` and every other
64
+ // consumer ONE import to reach the app's tables through.
65
+ const SCHEMA_HEADER = `// Every entity the app declares, re-exported here so the seed and anything else that needs a table
66
+ // reach them through one import. It is not what the migration generator reads: \`x db gen\` and the
67
+ // \`drift\` step project the entity registry, so an entity is in the migration because it was
68
+ // declared, never because it was listed here.`;
61
69
 
62
70
  const dbSchema = (app: NameSet, example: boolean): string =>
63
71
  example
@@ -65,7 +73,7 @@ const dbSchema = (app: NameSet, example: boolean): string =>
65
73
  export { post } from '@${app.kebab}/web/app/post/entity';
66
74
  `
67
75
  : `${SCHEMA_HEADER}
68
- // \`x g entity <name>\` writes the entity; add its export here so the database learns about it.
76
+ // \`x g entity <name>\` writes the entity; add its export here to reach it through \`@${app.kebab}/db\`.
69
77
  export {};
70
78
  `;
71
79
 
@@ -13,19 +13,30 @@ const agents = (app: NameSet): string => `# AGENTS.md
13
13
  Human-authored, short, stable. Facts live in \`x.manifest.json\`; this file holds only what an
14
14
  agent cannot infer from the code.
15
15
 
16
- | Rule | Detail |
17
- |---|---|
18
- | One gate | \`x verify\` — green means shippable. Never merge red. |
19
- | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` |
20
- | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf |
21
- | Data | routes call actions and queries; only \`repo.ts\` touches the database |
22
- | Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix |
23
- | Money | integer minor units + ISO code, never a float |
24
- | Time | store UTC, format with an explicit IANA time zone |
25
- | Strings | every user-facing string goes through \`t()\` |
26
- | Colour | semantic tokens only, never a raw hex |
27
-
28
- Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x db branch create <name>\`, \`x doctor\`.
16
+ Every row below names what refuses it. A rule with nothing in the last column is a rule that does
17
+ not exist — five of these had an empty column and were each measured green on \`x verify\`.
18
+
19
+ | Rule | Detail | Refused by |
20
+ |---|---|---|
21
+ | One gate | \`x verify\` — green means shippable. Never merge red. | the gate itself |
22
+ | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
23
+ | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
24
+ | Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
25
+ | Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix | \`guards/bare-error.ts\` |
26
+ | Money | \`{ minor, currency }\`, never a float — \`money()\` on the column | the type: \`price: 19.99\` is TS2322, \`number\` is not \`MoneyInput\` |
27
+ | Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
28
+ | Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
29
+ | Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
30
+
31
+ \`guards/\` is yours: each file is one rule, discovered by \`x verify\` and run inside its
32
+ \`boundaries\` step. Delete one to drop the rule, and \`x g guard <name>\` writes the next.
33
+
34
+ Money is the one row with no guard, deliberately: a float has no static signature a text rule can
35
+ see, and the type already fires — measured, \`price: 19.99\` in a seed is
36
+ \`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
37
+ it would be worse than the type that really does.
38
+
39
+ Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x g guard <name>\`, \`x db branch create <name>\`, \`x doctor\`.
29
40
 
30
41
  Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
31
42
  `;
@@ -126,27 +137,34 @@ exec bunx x verify "$@"
126
137
  const composeDev = (
127
138
  app: NameSet,
128
139
  ): string => `# Optional: x dev needs none of this. Use it when you want the real Postgres/NATS/MinIO locally.
140
+ #
141
+ # Every published port binds 127.0.0.1, not 0.0.0.0. This stack ships its credentials in the file,
142
+ # as a dev stack reasonably does — so the short form \`'5432:5432'\` would put an authenticated
143
+ # database and an open object store on every interface this machine has, including the café wifi.
144
+ # Docker publishes a port by writing DNAT rules, so a host firewall does not stop it. To reach this
145
+ # stack from another machine, put a tunnel in front of it (\`ssh -L\`) rather than widening the bind;
146
+ # production topology is docker-compose.prod.yml, and it is a different file for a reason.
129
147
  services:
130
148
  db:
131
149
  image: postgres:17-alpine
132
150
  environment:
133
151
  POSTGRES_PASSWORD: ${app.kebab}
134
152
  POSTGRES_DB: ${app.kebab}
135
- ports: ['5432:5432']
153
+ ports: ['127.0.0.1:5432:5432']
136
154
  healthcheck:
137
155
  test: ['CMD-SHELL', 'pg_isready -U postgres']
138
156
  interval: 5s
139
157
  nats:
140
158
  image: nats:2-alpine
141
159
  command: ['-js']
142
- ports: ['4222:4222']
160
+ ports: ['127.0.0.1:4222:4222']
143
161
  s3:
144
162
  image: minio/minio
145
163
  command: ['server', '/data']
146
164
  environment:
147
165
  MINIO_ROOT_USER: ${app.kebab}
148
166
  MINIO_ROOT_PASSWORD: ${app.kebab}-dev
149
- ports: ['9000:9000']
167
+ ports: ['127.0.0.1:9000:9000']
150
168
  `;
151
169
 
152
170
  /** Docs, shims and container files for a new app, in the order a reader meets them. */