@ultimat3/cli 1.2.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 +83 -16
  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 +165 -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 +201 -138
  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 +84 -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 +4 -3
  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 +170 -10
  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
@@ -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
@@ -4,16 +4,16 @@
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
6
  import { names } from './naming';
7
+ import { wrapList } from './wrap';
7
8
 
8
9
  /** Biome would rewrap this itself, so the generator emits the already-formatted form. */
9
- const permissionSet = (feature: NameSet): string => {
10
- const read = `'${feature.kebab}:read'`;
11
- const write = `'${feature.kebab}:write'`;
12
- const line = `export const ${feature.camel}Permissions = definePermissions([${read}, ${write}]);`;
13
- return line.length <= 100
14
- ? line
15
- : `export const ${feature.camel}Permissions = definePermissions([\n ${read},\n ${write},\n]);`;
16
- };
10
+ const permissionSet = (feature: NameSet): string =>
11
+ wrapList(
12
+ '',
13
+ `export const ${feature.camel}Permissions = definePermissions([`,
14
+ [`'${feature.kebab}:read'`, `'${feature.kebab}:write'`],
15
+ ']);',
16
+ );
17
17
 
18
18
  const policySource = (
19
19
  feature: NameSet,
@@ -67,38 +67,45 @@ export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
67
67
  );
68
68
  `;
69
69
 
70
- const policyTest = (feature: NameSet): string => `import { testActor } from '@ultimat3/policy';
70
+ const policyTest = (
71
+ feature: NameSet,
72
+ ): string => `// The ${feature.kebab} rules, from the DENIAL side: anonymous, cross-org, and the actor holding
73
+ // only read. A policy whose tests all pass is a policy nobody has tried to get past.
74
+ import { testActor } from '@ultimat3/policy';
71
75
  import { expect, unitTest } from '@ultimat3/testing';
72
76
  import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy';
73
77
 
74
78
  const org = '00000000-0000-4000-8000-000000000002';
75
79
  const otherOrg = '00000000-0000-4000-8000-000000000009';
76
80
 
77
- // Direct grants rather than roles: the role map is app-global and defineRoles() replaces it
78
- // wholesale, so a generated test that installed one would decide authz for every other test in
79
- // the process. \`permissions\` is the same check one layer down.
80
- const reader = testActor('reader', { orgId: org, permissions: ['${feature.kebab}:read'] }).actor;
81
- const writer = testActor('writer', {
82
- orgId: org,
83
- permissions: ['${feature.kebab}:read', '${feature.kebab}:write'],
84
- }).actor;
85
- const outsider = testActor('outsider', {
86
- orgId: otherOrg,
87
- permissions: ['${feature.kebab}:read', '${feature.kebab}:write'],
88
- }).actor;
81
+ // Direct grants rather than roles: defineRoles() MERGES into one app-global map that outlives this
82
+ // file, so a role installed here would still be granting permissions to every later test in the
83
+ // process — and a second test declaring the same name differently is X_ROLE_REDEFINED rather than
84
+ // an override. The app's roles live in apps/web/shared/roles.ts, once. \`permissions\` is the same
85
+ // check one layer down.
86
+ // The two grants and the org, each named once. Not decoration: every assertion below then carries
87
+ // the feature's own name and still fits the formatter's width, whatever that name is — a generated
88
+ // file the app's \`lint\` step rewrites is a red gate over code nobody typed.
89
+ const read = '${feature.kebab}:read';
90
+ const write = '${feature.kebab}:write';
91
+ const input = { orgId: org };
92
+
93
+ const reader = testActor('reader', { orgId: org, permissions: [read] }).actor;
94
+ const writer = testActor('writer', { orgId: org, permissions: [read, write] }).actor;
95
+ const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read, write] }).actor;
89
96
 
90
97
  unitTest('${feature.camel} read denies anonymous and cross-org actors', async () => {
91
- await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input: { orgId: org } });
92
- await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input: { orgId: org } });
93
- await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input: { orgId: org } });
98
+ await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input });
99
+ await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input });
100
+ await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input });
94
101
  });
95
102
 
96
- unitTest('${feature.camel} write denies an actor holding only the read grant', async () => {
103
+ unitTest('${feature.camel} write denies the read-only actor', async () => {
97
104
  // The outsider holds the grant and is still denied: the predicate is a second, independent
98
105
  // gate, and this is the assertion that fails if someone deletes it.
99
- await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input: { orgId: org } });
100
- await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input: { orgId: org } });
101
- await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input: { orgId: org } });
106
+ await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input });
107
+ await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input });
108
+ await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
102
109
  });
103
110
 
104
111
  unitTest('${feature.camel} rules name the permission they require', () => {
@@ -5,6 +5,8 @@
5
5
  import type { FeatureTarget } from './entity';
6
6
  import type { GeneratedFile, NameSet } from './naming';
7
7
  import { names } from './naming';
8
+ import { sliceFoundation } from './slice-foundation';
9
+ import { wrapImport } from './wrap';
8
10
 
9
11
  /** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
10
12
  const cacheLine = (feature: NameSet, live: boolean): string =>
@@ -20,7 +22,10 @@ const querySource = (
20
22
 
21
23
  import { from, query, t } from '@ultimat3/query';
22
24
  import type { ${feature.pascal} } from '../entity';
23
- import { can${feature.pascal}Read${live ? '' : `, ${feature.camel}Tag`} } from '../policy';
25
+ ${wrapImport(
26
+ live ? [`can${feature.pascal}Read`] : [`can${feature.pascal}Read`, `${feature.camel}Tag`],
27
+ '../policy',
28
+ )}
24
29
  import * as repo from '../repo';
25
30
 
26
31
  export const ${name.camel} = query({
@@ -28,7 +33,7 @@ export const ${name.camel} = query({
28
33
  policy: can${feature.pascal}Read,
29
34
  live: ${String(live)},${cacheLine(feature, live)}
30
35
  // Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
31
- mcp: { expose: true, description: '${name.raw} — generated, edit the description' },
36
+ mcp: { expose: true, description: '${name.raw} — edit this description' },
32
37
  sql: ({ orgId, limit }) =>
33
38
  // \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
34
39
  // and the entity created the table as snake_case.
@@ -45,7 +50,9 @@ export const ${name.camel} = query({
45
50
 
46
51
  const queryTest = (name: NameSet, feature: NameSet, live: boolean): string => {
47
52
  const wrapper = live ? 'liveTest' : 'unitTest';
48
- return `import { testActor } from '@ultimat3/policy';
53
+ return `// ${name.camel}: the shape it reads, the policy it asserts, and the actor it refuses. The read is
54
+ // declarative, so what a test can get wrong is the authz, and that is what this pins.
55
+ import { testActor } from '@ultimat3/policy';
49
56
  import { sourceFor } from '@ultimat3/query';
50
57
  import { expect, ${wrapper} } from '@ultimat3/testing';
51
58
  import { ${name.camel} } from './${name.kebab}';
@@ -74,8 +81,16 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
74
81
  // The SQL text is the contract an agent reads to self-correct, so assert on it, not on a
75
82
  // shape. \`sourceFor\` is the one read path — it parses the input and builds the source exactly
76
83
  // as a request does. \`actor: null\` gives the call a context of its own rather than borrowing
77
- // an ambient one, and \`enforce: false\` leaves the policy to the test below.
78
- const source = await sourceFor(target, { orgId, limit: 50 }, { actor: null, enforce: false });
84
+ // an ambient one, and \`unenforced\` states WHY the policy is skipped: the escape hatch takes a
85
+ // written reason, never a boolean, because a boolean reads exactly like forgetting the policy.
86
+ const source = await sourceFor(
87
+ target,
88
+ { orgId, limit: 50 },
89
+ {
90
+ actor: null,
91
+ unenforced: 'a scaffolded test asserts the SQL text; the policy is asserted separately',
92
+ },
93
+ );
79
94
  const { sql } = source.toSQL();
80
95
  const text = sql.toLowerCase();
81
96
  expect(text).toContain('order by');
@@ -110,6 +125,10 @@ export function queryFiles(rawName: string, target: QueryOptions): readonly Gene
110
125
  const live = target.live === true;
111
126
  const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
112
127
  return [
128
+ // A read declares the row type it returns (`../entity`), the rule that admits it (`../policy`)
129
+ // and the one module allowed to query the table (`../repo`) — all three are the slice's, and
130
+ // `--live` changes only which directory this file lands in.
131
+ ...sliceFoundation(target, ['entity', 'policy']),
113
132
  { path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
114
133
  { path: `${dir}/${name.kebab}.test.ts`, contents: queryTest(name, feature, live) },
115
134
  ];
@@ -27,11 +27,16 @@ import * as repo from './repo';
27
27
  /** Derived from the row, never restated: a new column reaches this input without an edit here. */
28
28
  export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>;
29
29
 
30
- export async function create(input: Create${feature.pascal}Input): Promise<${feature.pascal}> {
30
+ /** The row, aliased once, so every signature below reads at one width whatever the feature is
31
+ * called — a generated file the app's own formatter rewrites is a red \`lint\` over code nobody
32
+ * typed. */
33
+ type Row = ${feature.pascal};
34
+
35
+ export async function create(input: Create${feature.pascal}Input): Promise<Row> {
31
36
  return repo.insert(input);
32
37
  }
33
38
 
34
- export async function require${feature.pascal}(id: string): Promise<${feature.pascal}> {
39
+ export async function require${feature.pascal}(id: string): Promise<Row> {
35
40
  const row = await repo.byId(id);
36
41
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id });
37
42
  return row;
@@ -40,7 +45,9 @@ export async function require${feature.pascal}(id: string): Promise<${feature.pa
40
45
 
41
46
  const serviceTest = (
42
47
  feature: NameSet,
43
- ): string => `import { expect, unitTest } from '@ultimat3/testing';
48
+ ): string => `// The ${feature.kebab} feature's failure, pinned: a code an agent can match on, a cause naming
49
+ // the row, and a fix that is an instruction. A bare Error would satisfy none of the three.
50
+ import { expect, unitTest } from '@ultimat3/testing';
44
51
  import { ${feature.pascal}NotFoundError } from './errors';
45
52
 
46
53
  unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => {
@@ -69,9 +76,10 @@ export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
69
76
  return (
70
77
  <ul class={styles.list}>
71
78
  <For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
72
- {/* The item arrives as an accessor: reading it inside the row is what keeps the update
73
- surgical instead of re-rendering the list. */}
74
- {(row) => <li class={styles.item}>{row().title}</li>}
79
+ {/* <For> is keyed by value identity, so the item arrives directly and a row is only
80
+ re-created when its value changes. <Index> is the accessor-shaped one — reach for it
81
+ when the list is a fixed set of slots whose contents mutate. */}
82
+ {(row) => <li class={styles.item}>{row.title}</li>}
75
83
  </For>
76
84
  </ul>
77
85
  );
@@ -82,14 +90,14 @@ const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
82
90
 
83
91
  .list {
84
92
  display: grid;
85
- gap: tokens.$space-2;
93
+ gap: tokens.space(2);
86
94
  }
87
95
 
88
96
  .item {
89
- padding: tokens.$space-2;
90
- border-radius: tokens.$radius-sm;
91
- background: tokens.$surface-raised;
92
- color: tokens.$text-primary;
97
+ padding: tokens.space(2);
98
+ border-radius: tokens.radius('sm');
99
+ background: tokens.role('surface-raised');
100
+ color: tokens.role('fg');
93
101
  }
94
102
  `;
95
103