@ultimat3/cli 1.1.0 → 2.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 (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
@@ -4,6 +4,10 @@
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
6
  import { names } from './naming';
7
+ import { wrapImport, wrapList } from './wrap';
8
+
9
+ /** The columns that leave the server. One list, read by the declaration and by its test. */
10
+ const VIEW_KEYS: readonly string[] = ["'id'", "'title'", "'price'", "'createdAt'"];
7
11
 
8
12
  export interface FeatureTarget {
9
13
  /** `apps/web/app` or `apps/web/site` — the surface the feature lives in. */
@@ -28,16 +32,18 @@ export const ${name.camel} = entity('${table}', {
28
32
  id: uuid().primaryKey(),
29
33
  orgId: uuid(),
30
34
  title: text({ max: 200 }),
31
- // One property, two physical columns: price_minor bigint + price_currency char(3).
32
- // Money is integer minor units plus an ISO code, never a float.
35
+ // One property, three physical columns: price_minor bigint + price_currency char(3) +
36
+ // the nullable price_scale integer. Money is integer minor units plus an ISO code, never a
37
+ // float; the scale is what lets an amount name a sub-cent value, and NULL is not 0.
33
38
  price: money(),
34
39
  // Always timestamptz. Stored UTC; formatted at the edge with an explicit IANA time zone.
35
40
  createdAt: timestamp().defaultNow(),
36
41
  },
37
42
  // Each rule runs in the app on write AND as a Postgres CHECK — one declaration, both sides.
38
- invariants: [
39
- invariant('${snake}_title_not_blank', (c) => c.title.trimmed().minLength(1)),
40
- invariant('${snake}_price_non_negative', (c) => c.price.minor.atLeast(0)),
43
+ // \`c\` is typed from the columns above: \`c.titel\` is a compile error that names \`title\`.
44
+ invariants: (c) => [
45
+ invariant('${snake}_title_not_blank', c.title.trimmed().minLength(1)),
46
+ invariant('${snake}_price_non_negative', c.price.minor.atLeast(0)),
41
47
  ],
42
48
  indexes: [{ on: ['orgId', 'createdAt'] }],
43
49
  });
@@ -47,14 +53,31 @@ export type ${name.pascal} = typeof ${name.camel}.$row;
47
53
  // What leaves the server: an action writes \`output: ${name.pascal}View\` and the shape is the
48
54
  // columns', never a second declaration to keep in sync. The tenant column is not in it — an org
49
55
  // id is the caller's context, not the client's data.
50
- export const ${name.pascal}View = ${name.camel}.$view(['id', 'title', 'price', 'createdAt']);
56
+ ${wrapList('', `export const ${name.pascal}View = ${name.camel}.$view([`, VIEW_KEYS, ']);')}
51
57
  export type ${name.pascal}View = typeof ${name.pascal}View.$row;
52
58
  `;
53
59
 
54
- const repoSource = (
55
- name: NameSet,
56
- table: string,
57
- ): string => `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
60
+ const repoSource = (name: NameSet, table: string): string => {
61
+ const row = name.pascal;
62
+ const byIdCall = wrapList(
63
+ ' ',
64
+ `const row = await db().one<${row}>(`,
65
+ [`sql\`select * from ${table} where id = \${id}\``],
66
+ ');',
67
+ );
68
+ const listSignature = wrapList(
69
+ '',
70
+ 'export async function listByOrg(',
71
+ ['orgId: string', 'limit = 50'],
72
+ `): Promise<readonly ${row}[]> {`,
73
+ );
74
+ const insertSignature = wrapList(
75
+ '',
76
+ 'export async function insert(',
77
+ [`row: Omit<${row}, 'id' | 'createdAt'>`],
78
+ `): Promise<${row}> {`,
79
+ );
80
+ return `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
58
81
  // queries; actions call services; services call this.
59
82
  // \`db()\` is the ambient handle: inside a transaction it IS the transaction, so these functions
60
83
  // join the caller's transaction without knowing one is open.
@@ -64,43 +87,53 @@ import { dbDrift, newId } from '@ultimat3/entity';
64
87
  import type { ${name.pascal} } from './entity';
65
88
 
66
89
  export async function byId(id: string): Promise<${name.pascal} | undefined> {
67
- const row = await db().one<${name.pascal}>(sql\`select * from ${table} where id = \${id}\`);
90
+ ${byIdCall}
68
91
  return row ?? undefined;
69
92
  }
70
93
 
71
- export async function listByOrg(orgId: string, limit = 50): Promise<readonly ${name.pascal}[]> {
94
+ ${listSignature}
72
95
  // Ordered and bounded: an unordered page is a different page on every request.
73
96
  return db().query<${name.pascal}>(
74
97
  sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
75
98
  );
76
99
  }
77
100
 
78
- export async function insert(row: Omit<${name.pascal}, 'id' | 'createdAt'>): Promise<${name.pascal}> {
79
- // Money is two physical columns — integer minor units plus the ISO code, never a float.
101
+ ${insertSignature}
102
+ // Money is three physical columns — integer minor units, the ISO code, and the scale, never a
103
+ // float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
104
+ // and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
80
105
  const created = await db().one<${name.pascal}>(sql\`
81
- insert into ${table} (id, org_id, title, price_minor, price_currency)
82
- values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency})
106
+ insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
107
+ values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
108
+ \${row.price.scale ?? null})
83
109
  returning *\`);
84
110
  if (created === null) throw dbDrift('${table}', 'id');
85
111
  return created;
86
112
  }
87
113
  `;
114
+ };
88
115
 
89
116
  const entityTest = (
90
117
  name: NameSet,
91
118
  snake: string,
92
119
  table: string,
93
- ): string => `import { expect, unitTest } from '@ultimat3/testing';
120
+ ): string => `// The ${name.kebab} entity's declaration: the table it maps to and the invariants it names. Both
121
+ // are what the migration generator and every query read, so both are worth pinning.
122
+ import { expect, unitTest } from '@ultimat3/testing';
94
123
  import type { ${name.pascal} } from './entity';
95
- import { ${name.pascal}View, ${name.camel} } from './entity';
124
+ ${wrapImport([`${name.pascal}View`, name.camel], './entity')}
125
+
126
+ type Over = Partial<${name.pascal}>;
96
127
 
97
- const row = (over: Partial<${name.pascal}> = {}): ${name.pascal} => ({
128
+ const row = (over: Over = {}): ${name.pascal} => ({
98
129
  id: '00000000-0000-4000-8000-000000000001',
99
130
  orgId: '00000000-0000-4000-8000-000000000002',
100
131
  title: 'valid title',
101
- // \`money()\` puts \`MoneyValue\` on the row, whose minor units are bigint — the column is a
102
- // Postgres bigint, and a JS number would silently lose precision above 2^53 minor units.
103
- price: { minor: 1000n, currency: 'USD' },
132
+ // \`money()\` puts \`MoneyValue\` on the row — the same type \`@ultimat3/money\`'s \`Money\` is,
133
+ // so this value goes straight to \`add()\`, \`formatMoney()\` and \`<Money>\` with no conversion.
134
+ // Integer minor units, never a float; the column is a Postgres bigint and a stored value past
135
+ // ±2^53 is refused when it is read rather than rounded into the row.
136
+ price: { minor: 1000, currency: 'USD' },
104
137
  createdAt: new Date(0),
105
138
  ...over,
106
139
  });
@@ -117,24 +150,32 @@ unitTest('${name.camel} describes itself for the manifest', () => {
117
150
  // a column added below reaches every one of them without a second declaration.
118
151
  const described = ${name.camel}.$describe();
119
152
  expect(described.orgScoped).toBe(true);
120
- // One \`price\` property, two physical columns: integer minor units plus the ISO code. The
121
- // description is where that shows, and it is what fails here if money ever becomes a float.
153
+ // One \`price\` property, three physical columns: integer minor units, the ISO code, and the
154
+ // nullable scale. The description is where that shows, and it is what fails here if money ever
155
+ // becomes a float — or if a migration forgets the scale column and unscales every sub-cent row.
122
156
  expect(described.columns.map((column) => column.column)).toContain('price_minor');
123
157
  expect(described.columns.map((column) => column.column)).toContain('price_currency');
124
- expect(described.invariants.map((rule) => rule.name)).toContain('${snake}_price_non_negative');
158
+ expect(described.columns.map((column) => column.column)).toContain('price_scale');
159
+ // Named first: the assertion line carries the entity's own name and stays under the app's
160
+ // formatter width whatever that name is.
161
+ const rules = described.invariants.map((rule) => rule.name);
162
+ expect(rules).toContain('${snake}_price_non_negative');
125
163
  });
126
164
 
127
- unitTest('${name.pascal}View projects the row an action returns, without the tenant', () => {
128
- expect(${name.pascal}View.$keys).toEqual(['id', 'title', 'price', 'createdAt']);
165
+ unitTest('${name.pascal}View projects the row an action returns', () => {
166
+ const keys = ${name.pascal}View.$keys;
167
+ expect(keys).toEqual([${VIEW_KEYS.join(', ')}]);
129
168
  // The org id is the caller's context, never the client's data: a view that leaked it would
130
169
  // let a response carry a tenant boundary the policy already decided.
131
- expect(${name.pascal}View.$keys).not.toContain('orgId');
170
+ expect(keys).not.toContain('orgId');
132
171
  });
133
172
 
134
- unitTest('${name.camel} invariants reject a blank title and a negative price', () => {
173
+ unitTest('${name.camel} invariants reject blank and negative', () => {
174
+ const blank = row({ title: ' ' });
175
+ const negative = row({ price: { minor: -1, currency: 'USD' } });
135
176
  expect(() => ${name.camel}.$assert(row())).not.toThrow();
136
- expect(() => ${name.camel}.$assert(row({ title: ' ' }))).toThrow();
137
- expect(() => ${name.camel}.$assert(row({ price: { minor: -1n, currency: 'USD' } }))).toThrow();
177
+ expect(() => ${name.camel}.$assert(blank)).toThrow();
178
+ expect(() => ${name.camel}.$assert(negative)).toThrow();
138
179
  });
139
180
 
140
181
  unitTest('${name.camel} parses a row through its own columns', () => {
@@ -0,0 +1,143 @@
1
+ // `x g guard <name>` — the app's own convention, scaffolded as a build error. Not a primitive and
2
+ // not a check the framework owns: what the emitted file has to get right is the directory (it is
3
+ // the registration), the exported `guard` the gate looks for, and a rule that fires on something
4
+ // real, so an author replacing the example knows what a working one looks like.
5
+
6
+ import type { GeneratedFile } from './naming';
7
+ import { kebab } from './naming';
8
+
9
+ /**
10
+ * The code the emitted guard raises, derived from its name — the same shape `x g action` derives
11
+ * `X_<FEATURE>_NOT_FOUND` in. Derived and never written as a literal here for the same reason: an
12
+ * `X_*` literal in framework source is a framework code, and it would have to be registered and
13
+ * documented in `wiki/Error-Codes.md`. The app owns the codes its own conventions raise.
14
+ */
15
+ export const guardCode = (name: string): string =>
16
+ `X_${kebab(name).toUpperCase().split('-').join('_')}`;
17
+
18
+ const guardSource = (
19
+ name: string,
20
+ ): string => `// ${name}: one convention this app enforces, as a build error. \`x verify\` discovers every file in
21
+ // \`guards/\` and runs its \`guard\` inside the \`boundaries\` step — nothing registers this file, so
22
+ // nothing can forget to. Replace the rule below with the one this app needs; the shape is the
23
+ // contract, and the findings it returns are what \`--json\` and the exit code are made of.
24
+
25
+ import type { Finding, Guard } from '@ultimat3/cli';
26
+
27
+ /**
28
+ * The example rule, and the class of failure a guard exists for: a migration that adds a NOT NULL
29
+ * column with no DEFAULT applies cleanly to an empty local database and fails on the first
30
+ * production table that already holds rows. Nothing else in the gate can see it — \`x verify\`'s
31
+ * \`drift\` step reads these same files and asks a different question (is every destructive
32
+ * statement declared?), and a test suite runs against a database this statement has never met.
33
+ *
34
+ * The code is this guard's own name: the line an agent reads has to say WHICH convention broke, so
35
+ * a guard is named after its convention and the two are renamed together.
36
+ */
37
+ const CODE = '${guardCode(name)}';
38
+
39
+ /** A column definition begins at \`add column\` and ends at the comma or semicolon after it. */
40
+ const ADD_COLUMN = /add\\s+column\\s+(?:if\\s+not\\s+exists\\s+)?"?([\\w]+)"?([^,;]*)/gi;
41
+
42
+ export interface MigrationFile {
43
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
44
+ readonly path: string;
45
+ readonly sql: string;
46
+ }
47
+
48
+ /**
49
+ * Pure — the caller does the I/O — so the rule is testable without a filesystem, which is the same
50
+ * split the framework's own checks use. A guard returns findings and nothing else: it never
51
+ * prints, never throws for a normal result and never decides the exit code.
52
+ */
53
+ export function unsafeAdditions(files: readonly MigrationFile[]): readonly Finding[] {
54
+ const findings: Finding[] = [];
55
+ for (const file of files) {
56
+ // Comments first, block before line: a statement inside \`/* … */\` is a note, and reading one
57
+ // as real is a finding an author cannot act on — it blocks \`x verify\` over nothing.
58
+ const sql = file.sql.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, ' ').replaceAll(/--[^\\n]*/g, ' ');
59
+ for (const statement of sql.split(';')) {
60
+ if (!/\\balter\\s+table\\b/i.test(statement)) continue;
61
+ for (const match of statement.matchAll(ADD_COLUMN)) {
62
+ const definition = match[2] ?? '';
63
+ if (!/\\bnot\\s+null\\b/i.test(definition)) continue;
64
+ // \`DEFAULT NULL\` is a default in syntax and none in effect: every existing row still
65
+ // takes NULL and still violates NOT NULL, which is this rule's whole subject.
66
+ const nullDefault = /\\bdefault\\s+\\(?\\s*null\\b/i.test(definition);
67
+ if (/\\bdefault\\b/i.test(definition) && !nullDefault) continue;
68
+ const column = match[1] ?? 'the column';
69
+ findings.push({
70
+ code: CODE,
71
+ cause: \`\${file.path} adds \${column} NOT NULL with no usable DEFAULT — every row already in the table takes NULL and violates it the moment this runs against data\`,
72
+ fix: \`give \${column} a non-null DEFAULT in \${file.path}, then: x db migrate\`,
73
+ at: file.path,
74
+ });
75
+ }
76
+ }
77
+ }
78
+ return findings;
79
+ }
80
+
81
+ export const guard: Guard = {
82
+ summary: 'a migration never adds a NOT NULL column without a DEFAULT',
83
+ async check(root) {
84
+ const files: MigrationFile[] = [];
85
+ for await (const path of new Bun.Glob('packages/db/migrations/*.sql').scan({
86
+ cwd: root,
87
+ absolute: false,
88
+ })) {
89
+ files.push({ path, sql: await Bun.file(\`\${root}/\${path}\`).text() });
90
+ }
91
+ return unsafeAdditions(files);
92
+ },
93
+ };
94
+ `;
95
+
96
+ const guardTest = (
97
+ name: string,
98
+ ): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
99
+ // a green gate over the convention it was written to enforce.
100
+
101
+ import { expect, unitTest } from '@ultimat3/testing';
102
+ import { unsafeAdditions } from './${name}';
103
+
104
+ const migration = (sql: string) => [{ path: 'packages/db/migrations/0002_probe.sql', sql }];
105
+
106
+ unitTest('a NOT NULL column added with no DEFAULT is refused', () => {
107
+ const findings = unsafeAdditions(migration('ALTER TABLE posts ADD COLUMN slug text NOT NULL;'));
108
+ expect(findings).toHaveLength(1);
109
+ expect(findings[0]?.at).toBe('packages/db/migrations/0002_probe.sql');
110
+ expect(findings[0]?.cause).toContain('slug');
111
+ });
112
+
113
+ unitTest('a DEFAULT makes the same addition safe', () => {
114
+ const sql = "ALTER TABLE posts ADD COLUMN slug text NOT NULL DEFAULT '';";
115
+ expect(unsafeAdditions(migration(sql))).toHaveLength(0);
116
+ });
117
+
118
+ unitTest('DEFAULT NULL is a default in syntax and none in effect', () => {
119
+ const sql = 'ALTER TABLE posts ADD COLUMN slug text NOT NULL DEFAULT NULL;';
120
+ expect(unsafeAdditions(migration(sql))).toHaveLength(1);
121
+ });
122
+
123
+ unitTest('a nullable column was always safe, and a new table is not an addition', () => {
124
+ expect(unsafeAdditions(migration('ALTER TABLE posts ADD COLUMN slug text;'))).toHaveLength(0);
125
+ expect(unsafeAdditions(migration('CREATE TABLE posts (slug text NOT NULL);'))).toHaveLength(0);
126
+ });
127
+
128
+ unitTest('a commented-out statement is not a statement', () => {
129
+ const block = '/* ALTER TABLE posts ADD COLUMN slug text NOT NULL; */';
130
+ expect(unsafeAdditions(migration(block))).toHaveLength(0);
131
+ const line = '-- ALTER TABLE posts ADD COLUMN slug text NOT NULL;';
132
+ expect(unsafeAdditions(migration(line))).toHaveLength(0);
133
+ });
134
+ `;
135
+
136
+ /** `guards/<name>.ts` and its test. No index, no registry, no manifest row — the directory is it. */
137
+ export function guardFiles(rawName: string): readonly GeneratedFile[] {
138
+ const name = kebab(rawName);
139
+ return [
140
+ { path: `guards/${name}.ts`, contents: guardSource(name) },
141
+ { path: `guards/${name}.test.ts`, contents: guardTest(name) },
142
+ ];
143
+ }
@@ -4,11 +4,17 @@
4
4
  export type { ActionOptions } from './action';
5
5
  export { actionFiles } from './action';
6
6
  export { adminFiles } from './admin';
7
+ export type { AdminPageOptions } from './admin-page';
8
+ export { adminPageFiles } from './admin-page';
9
+ export { backfillFiles } from './backfill';
7
10
  export type { FeatureTarget } from './entity';
8
11
  export { entityFiles } from './entity';
12
+ export { guardCode, guardFiles } from './guard';
13
+ export type { IslandOptions } from './island';
14
+ export { islandFiles } from './island';
9
15
  export { jobFiles, taskFiles } from './job';
10
16
  export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
11
- export type { GeneratedFile, NameSet } from './naming';
17
+ export type { GeneratedFile, GeneratedFoundationFile, NameSet } from './naming';
12
18
  export { camel, kebab, names, pascal, plural, titleKey } from './naming';
13
19
  export { policyFiles } from './policy';
14
20
  export type { QueryOptions } from './query';
@@ -18,7 +24,12 @@ export { resourceFiles } from './resource';
18
24
  export type { RouteOptions, Surface } from './route';
19
25
  export { routeFiles } from './route';
20
26
  export { appFiles } from './scaffold-app';
27
+ export { claudeFiles } from './scaffold-claude';
28
+ export { claudeAgentFiles } from './scaffold-claude-agents';
29
+ export { claudeCommandFiles } from './scaffold-claude-commands';
21
30
  export { containerFiles } from './scaffold-container';
22
31
  export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
23
32
  export { i18nIndex } from './scaffold-i18n';
24
33
  export { repoFiles } from './scaffold-repo';
34
+ export type { SliceModule } from './slice-foundation';
35
+ export { sliceFoundation } from './slice-foundation';
@@ -0,0 +1,67 @@
1
+ // `x g island <name>` — the one file on a route that ships JavaScript. Not a ninth primitive and
2
+ // not a component generator: an island is a client ENTRY POINT, so what the scaffold has to get
3
+ // right is the filename (the bundler discovers by it) and the `mount` export (the hydration
4
+ // runtime calls it by name). Both are pinned by the emitted test.
5
+
6
+ import type { GeneratedFile } from './naming';
7
+ import { kebab, pascal } from './naming';
8
+
9
+ export interface IslandOptions {
10
+ /** Directory the entry lands in, app-root-relative and POSIX — normally a route's own folder. */
11
+ readonly dir: string;
12
+ }
13
+
14
+ const islandSource = (name: string): string => {
15
+ const Name = pascal(name);
16
+ return `// ${Name}: the interactive half of an otherwise static page, and the only module on this
17
+ // route the browser downloads.
18
+ //
19
+ // The page names this file by SPECIFIER, never by import:
20
+ // const ${Name} = island({ src: './${name}.island.tsx', props: ['label'] });
21
+ // A string has no import edge, so nothing follows one into this file and the page's bundle graph
22
+ // stays the page's (axiom 6). WHEN it wakes is the route's \`hydrate\`, never a declaration here.
23
+
24
+ /** What the server sends. Declared here AND in the page's \`island({ props })\` — both, or neither. */
25
+ export interface ${Name}Props {
26
+ /** Already translated: this runs in the browser, where \`t()\`'s catalog is not. */
27
+ readonly label: string;
28
+ }
29
+
30
+ /**
31
+ * The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
32
+ * \`el\` is the wrapper the page rendered, with the server's own markup already inside it, so a
33
+ * mount that replaces the markup instead of taking it over is a visible flash on every load.
34
+ */
35
+ export function mount(el: HTMLElement, props: ${Name}Props): void {
36
+ el.textContent = props.label;
37
+ el.addEventListener('click', () => {
38
+ // \`dataset.open\`, not \`dataset['open']\`: the bracket form is lint/complexity/useLiteralKeys,
39
+ // which the app's own \`biome check\` fails on — twice, in the one file every island copies.
40
+ el.dataset.open = el.dataset.open === 'true' ? 'false' : 'true';
41
+ });
42
+ }
43
+ `;
44
+ };
45
+
46
+ const islandTest = (
47
+ name: string,
48
+ ): string => `// The runtime boots an island by calling \`mount\` on whatever the module exports. A renamed or
49
+ // deleted export is a page that renders, serves, passes every other gate and does nothing when
50
+ // clicked — which is exactly the failure nothing else in the build can see.
51
+
52
+ import { expect, unitTest } from '@ultimat3/testing';
53
+ import * as entry from './${name}.island';
54
+
55
+ unitTest('${name}.island exports the mount the hydration runtime calls', () => {
56
+ expect(typeof entry.mount).toBe('function');
57
+ });
58
+ `;
59
+
60
+ export function islandFiles(rawName: string, options: IslandOptions): readonly GeneratedFile[] {
61
+ const name = kebab(rawName);
62
+ const dir = options.dir.replace(/\/+$/, '');
63
+ return [
64
+ { path: `${dir}/${name}.island.tsx`, contents: islandSource(name) },
65
+ { path: `${dir}/${name}.island.test.ts`, contents: islandTest(name) },
66
+ ];
67
+ }
@@ -1,10 +1,12 @@
1
1
  // `x g job` / `x g task` — durable background work and the cron trigger that enqueues it. The
2
- // idempotency key is required by the type, so the generator always emits one; the generated test
3
- // pins it through a real driver, because a key that is not stable is a job that runs twice.
2
+ // idempotency key and the tenant are both required by the type, so the generator always emits
3
+ // both; the generated test pins them through a real driver, because a key that is not stable is a
4
+ // job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
4
5
 
5
6
  import type { FeatureTarget } from './entity';
6
7
  import type { GeneratedFile, NameSet } from './naming';
7
8
  import { names } from './naming';
9
+ import { sliceFoundation } from './slice-foundation';
8
10
 
9
11
  const jobSource = (
10
12
  name: NameSet,
@@ -16,7 +18,14 @@ import { job, t } from '@ultimat3/jobs';
16
18
  import * as repo from '../repo';
17
19
 
18
20
  export const ${name.camel} = job({
19
- input: t.object({ id: t.uuid }),
21
+ input: t.object({ id: t.uuid, orgId: t.uuid }),
22
+ // The org this run's body acts as, derived from the job's OWN input — never from whoever
23
+ // enqueued it, who may have changed orgs by the time a retried job settles. \`orgId\` is in the
24
+ // input for this and no other reason: \`x g entity\` scaffolds \`tenant: 'orgId'\`, so every read
25
+ // below is tenant-scoped. \`tenant: 'none'\` is the other spelling and it STRIPS the org, which
26
+ // makes a tenant-scoped read fail closed with X_TENANCY_ACTOR_ORG_REQUIRED — use it only for a
27
+ // job that touches no tenanted table.
28
+ tenant: (input) => input.orgId,
20
29
  idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
21
30
  retry: { attempts: 5, backoff: 'exponential' },
22
31
  async run({ input, step }) {
@@ -42,17 +51,34 @@ import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
42
51
  export const ${name.camel} = task({
43
52
  cron: '0 3 * * *',
44
53
  tz: 'UTC',
45
- enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
54
+ // The org rides in the payload because the job DECLARES its tenant from its own input: a task
55
+ // has no request behind it, so there is no caller whose org could be read instead.
56
+ enqueue: () => [
57
+ [
58
+ ${jobName.camel},
59
+ {
60
+ id: '00000000-0000-4000-8000-000000000001',
61
+ orgId: '00000000-0000-4000-8000-000000000002',
62
+ },
63
+ ],
64
+ ],
46
65
  });
47
66
  `;
48
67
 
49
68
  const jobTest = (
50
69
  name: NameSet,
51
- ): string => `import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
70
+ ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
71
+ // are the framework's, so what this pins is that THIS job's steps run and are idempotent.
72
+ import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
52
73
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
53
74
  import { ${name.camel} } from './${name.kebab}';
54
75
 
55
76
  const id = '00000000-0000-4000-8000-000000000001';
77
+ const orgId = '00000000-0000-4000-8000-000000000002';
78
+ const input = { id, orgId };
79
+ // The key this job owes, spelled once. Named rather than inlined so the assertion below carries
80
+ // the job's own name and still fits the formatter width the app's \`lint\` step enforces.
81
+ const expectedKey = \`${name.kebab}:\${id}\`;
56
82
 
57
83
  // The driver is process-global, so it is installed and released around this file rather than
58
84
  // left behind for whichever test happens to run next.
@@ -61,14 +87,22 @@ beforeAll(() => {
61
87
  });
62
88
  afterAll(resetJobDriver);
63
89
 
64
- jobTest('${name.camel} declares an idempotency key and a retry policy', () => {
90
+ jobTest('${name.camel} declares a key and a retry policy', () => {
65
91
  expect(${name.camel}.kind).toBe('job');
66
- expect(${name.camel}.idempotencyKeyFor({ id })).toBe(\`${name.kebab}:\${id}\`);
92
+ expect(${name.camel}.idempotencyKeyFor(input)).toBe(expectedKey);
67
93
  expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
68
94
  });
69
95
 
70
96
  jobTest('${name.camel} derives the same key for the same input', () => {
71
- expect(${name.camel}.idempotencyKeyFor({ id })).toBe(${name.camel}.idempotencyKeyFor({ id }));
97
+ const key = ${name.camel}.idempotencyKeyFor(input);
98
+ expect(${name.camel}.idempotencyKeyFor(input)).toBe(key);
99
+ });
100
+
101
+ jobTest('${name.camel} runs as the org its own input names', () => {
102
+ // Not a formality: \`tenant: 'none'\` compiles just as well and strips the org, and every read in
103
+ // this job is tenant-scoped — so this declaration is the whole of what stands between the body
104
+ // and X_TENANCY_ACTOR_ORG_REQUIRED, or worse, another org's rows.
105
+ expect(${name.camel}.tenantFor(input)).toBe(orgId);
72
106
  });
73
107
 
74
108
  jobTest('${name.camel} projects itself into the manifest', () => {
@@ -80,9 +114,9 @@ jobTest('${name.camel} projects itself into the manifest', () => {
80
114
  jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
81
115
  // The whole point of the key: an at-least-once caller may enqueue twice and the work still
82
116
  // happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
83
- const first = await ${name.camel}.enqueue({ id });
117
+ const first = await ${name.camel}.enqueue(input);
84
118
  expect(first.deduped).toBe(false);
85
- const again = await ${name.camel}.enqueue({ id });
119
+ const again = await ${name.camel}.enqueue(input);
86
120
  expect(again.deduped).toBe(true);
87
121
  });
88
122
  `;
@@ -90,7 +124,9 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
90
124
  const taskTest = (
91
125
  name: NameSet,
92
126
  jobName: NameSet,
93
- ): string => `import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
127
+ ): string => `// ${name.camel}: the schedule it declares, the timezone it declares it in, and the job it
128
+ // enqueues. A cron with no explicit IANA zone fires at a different hour twice a year.
129
+ import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
94
130
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
95
131
  import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
96
132
  import { ${name.camel} } from './${name.kebab}';
@@ -106,7 +142,7 @@ jobTest('${name.camel} declares a cron with an explicit time zone', () => {
106
142
  expect(${name.camel}.tz).toBe('UTC');
107
143
  });
108
144
 
109
- jobTest('${name.camel} enqueues ${jobName.camel} and nothing else', () => {
145
+ jobTest('${name.camel} enqueues exactly one job', () => {
110
146
  const pairs = ${name.camel}.entries();
111
147
  expect(pairs).toHaveLength(1);
112
148
  expect(pairs[0]?.[0]).toBe(${jobName.camel});
@@ -118,7 +154,7 @@ jobTest('${name.camel} describes its schedule and its jobs', () => {
118
154
  expect(described.jobs).toHaveLength(1);
119
155
  });
120
156
 
121
- jobTest('${name.camel} fires its entries onto the same queue the scheduler would', async () => {
157
+ jobTest('${name.camel} fires its declared entries', async () => {
122
158
  // \`.enqueue()\` is the backfill path: the declared entries, through the facade a job handle
123
159
  // uses, with no scheduler and no leader involved.
124
160
  const results = await ${name.camel}.enqueue();
@@ -131,6 +167,10 @@ export function jobFiles(rawName: string, target: FeatureTarget): readonly Gener
131
167
  const name = names(rawName);
132
168
  const dir = `${target.surfaceDir}/${target.feature}/jobs`;
133
169
  return [
170
+ // The job's steps read through `../repo`, which carries `../entity` for its row type. No
171
+ // policy: a job has no request behind it and evaluates none, so a generated one would be a
172
+ // file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
173
+ ...sliceFoundation(target, ['entity']),
134
174
  { path: `${dir}/${name.kebab}.ts`, contents: jobSource(name) },
135
175
  { path: `${dir}/${name.kebab}.test.ts`, contents: jobTest(name) },
136
176
  ];
@@ -27,13 +27,29 @@ export interface GeneratedSourceFile {
27
27
  readonly merge?: undefined;
28
28
  }
29
29
 
30
+ /**
31
+ * A module the feature slice owns and no single generator does — `entity.ts`, `repo.ts`,
32
+ * `policy.ts`, `errors.ts`. Several generators import one and none of them wrote it, so `x g job`
33
+ * into a slice that has no `repo.ts` emitted an import against nothing. Emitting it as an ordinary
34
+ * file is the opposite failure: the copy on disk is the author's, and rewriting it (or reporting
35
+ * `X_GENERATE_CONFLICT` and abandoning the whole set) is how `x g action` into an existing slice
36
+ * stops working. `'if-absent'` is the third answer — written when the slice lacks it, left exactly
37
+ * as it is when the slice has it, `--force` included, and never a conflict either way.
38
+ */
39
+ export interface GeneratedFoundationFile {
40
+ /** POSIX path relative to the app root. */
41
+ readonly path: string;
42
+ readonly contents: string;
43
+ readonly merge: 'if-absent';
44
+ }
45
+
30
46
  /**
31
47
  * Split on `merge` rather than widening one shape's `contents` in place, so the split is
32
48
  * load-bearing, not cosmetic: a `merge: 'json'` file's `contents` stays a plain `string` at the
33
49
  * type level, which is what stops a byte-carrying file from ever reaching
34
50
  * `cmd-generate.ts`'s JSON parser — the compiler refuses the call before the code can run.
35
51
  */
36
- export type GeneratedFile = GeneratedJsonFile | GeneratedSourceFile;
52
+ export type GeneratedFile = GeneratedJsonFile | GeneratedSourceFile | GeneratedFoundationFile;
37
53
 
38
54
  const words = (input: string): readonly string[] =>
39
55
  input