@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,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
 
@@ -1,6 +1,8 @@
1
1
  // `x g route <path>` — a URL, its render mode, its metadata, its offline strategy and its budget.
2
- // The generated test pins metadata presence and the offline fallback: a route with no title is an
3
- // SEO regression and a route with no fallback is a blank screen on a train.
2
+ // Two generated tests, because the gate types a test by its filename: `page.test.ts` pins metadata
3
+ // presence and the budget declaration on the `unit` step, `page.e2e.test.ts` pins the offline
4
+ // fallback on the `e2e` step. A route with no title is an SEO regression and a route with no
5
+ // fallback is a blank screen on a train.
4
6
 
5
7
  import { catalogJson } from './catalog-json';
6
8
  import { catalogPath, resolveLocales } from './locales';
@@ -9,7 +11,14 @@ import { kebab, pascal, titleKey } from './naming';
9
11
 
10
12
  export type Surface = 'site' | 'app';
11
13
 
12
- const RENDER: Record<Surface, string> = { site: 'isr', app: 'stream' };
14
+ /**
15
+ * `ssr` on `app/`, not `stream`, for the reason `scaffold-app.ts`'s dashboard already states:
16
+ * `stream` sets `needsSuspense`, the framework ships no hole marker, and `defineRoute` therefore
17
+ * throws `X_ROUTE_MODE_INVALID` at import. That is not a failing page — it is a page that registers
18
+ * NO route at all, so every `x g route --surface app` and every `x g resource` scaffolded a URL
19
+ * that was absent from `x routes`, from the manifest and from `budgets`. Ship the mode that works.
20
+ */
21
+ const RENDER: Record<Surface, string> = { site: 'isr', app: 'ssr' };
13
22
  const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
14
23
  const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
15
24
  /** Structured, not a literal string: the route and the test that pins it read the same fact. */
@@ -22,12 +31,35 @@ const budgetLiteral = (surface: Surface): string =>
22
31
  /** `isr` without a trigger is `static` wearing a costume — @ultimat3/render rejects it at boot. */
23
32
  const REVALIDATE: Record<Surface, string> = { site: "\n revalidate: { ttl: '1h' },", app: '' };
24
33
 
25
- const routeDir = (surface: Surface, path: string): string =>
26
- `apps/web/${surface}/${path
34
+ /** `[slug]`, `[...rest]` — the repo's own convention (`app/posts/[id]`, `site/blog/[slug]`). */
35
+ const DYNAMIC_SEGMENT = /^\[(?<spread>\.\.\.)?(?<name>[^\]]+)\]$/;
36
+
37
+ /**
38
+ * One URL segment. `kebab()` strips `[` and `]` along with every other non-alphanumeric, so
39
+ * `x g route "posts/[slug]"` scaffolded `apps/web/app/posts/slug/page.tsx` — a different, STATIC
40
+ * route — and reported `ok:true` with no warning. Only the parameter NAME is kebabed now.
41
+ */
42
+ export const routeSegment = (part: string): string => {
43
+ const match = DYNAMIC_SEGMENT.exec(part);
44
+ if (match === null) return kebab(part);
45
+ return `[${match.groups?.['spread'] ?? ''}${kebab(match.groups?.['name'] ?? '')}]`;
46
+ };
47
+
48
+ const segmentsOf = (path: string): readonly string[] =>
49
+ path
27
50
  .split('/')
28
51
  .filter((part) => part.length > 0)
29
- .map(kebab)
30
- .join('/')}`;
52
+ .map(routeSegment);
53
+
54
+ /** Every `[param]` the URL declares, in order — what a render actually hands the page. */
55
+ export const routeParams = (path: string): readonly string[] =>
56
+ segmentsOf(path).flatMap((part) => {
57
+ const name = DYNAMIC_SEGMENT.exec(part)?.groups?.['name'];
58
+ return name === undefined ? [] : [name];
59
+ });
60
+
61
+ const routeDir = (surface: Surface, path: string): string =>
62
+ `apps/web/${surface}/${segmentsOf(path).join('/')}`;
31
63
 
32
64
  const pageSource = (surface: Surface, path: string): string => {
33
65
  const name = pascal(
@@ -69,28 +101,55 @@ const styleSource =
69
101
  @use '@ultimat3/ui/tokens' as tokens;
70
102
 
71
103
  .page {
72
- padding: tokens.$space-6;
73
- background: tokens.$surface-base;
74
- color: tokens.$text-primary;
104
+ padding: tokens.space(6);
105
+ background: tokens.role('bg');
106
+ color: tokens.role('fg');
75
107
  }
76
108
  `;
77
109
 
110
+ /** A value for a `[param]`, distinct per parameter so a two-param URL reads unambiguously. */
111
+ const sampleValue = (name: string): string => `${name}-1`;
112
+
113
+ /** The URL a render would actually match, with every `[param]` filled in. */
114
+ const sampleUrl = (path: string): string =>
115
+ segmentsOf(path)
116
+ .map((part) => {
117
+ const name = DYNAMIC_SEGMENT.exec(part)?.groups?.['name'];
118
+ return name === undefined ? part : sampleValue(name);
119
+ })
120
+ .join('/');
121
+
122
+ /** The params object that URL implies. `{}` was a lie for every dynamic route. */
123
+ const paramsLiteral = (path: string): string => {
124
+ const params = routeParams(path);
125
+ if (params.length === 0) return '{}';
126
+ return `{ ${params.map((name) => `${name}: '${sampleValue(name)}'`).join(', ')} }`;
127
+ };
128
+
78
129
  const routeTest = (
79
130
  surface: Surface,
80
131
  path: string,
81
- ): string => `import { e2eTest, expect, unitTest } from '@ultimat3/testing';
132
+ ): string => `// /${path}'s declaration, on the \`unit\` step: metadata that renders, and the render mode,
133
+ // offline strategy and budget it promises. A route with no title is an SEO regression.
134
+ import { metaContextFor, routeDataFor } from '@ultimat3/render';
135
+ import { expect, unitTest } from '@ultimat3/testing';
82
136
  import { config } from './page';
83
137
 
138
+ // What a render gives this route: the URL it matched and the params in it. \`routeDataFor\` is the
139
+ // one resolver — with no \`load\` the context IS the data, and with one the loader decides — so a
140
+ // test asserts on the same object the page component and \`meta\` are handed in production.
141
+ const ctx = { params: ${paramsLiteral(path)}, url: 'https://example.test/${sampleUrl(path)}' };
142
+
84
143
  unitTest('/${path} declares metadata', async () => {
85
144
  expect(config.kind).toBe('route');
86
145
  // meta() takes the route's data and always resolves — awaiting is the one shape, whether the
87
146
  // declaration was written sync or async.
88
- const meta = await config.meta({});
147
+ const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
89
148
  expect(meta.title ?? '').not.toBe('');
90
149
  expect(meta.description ?? '').not.toBe('');
91
150
  });
92
151
 
93
- unitTest('/${path} declares a render mode, an offline strategy and a budget', () => {
152
+ unitTest('/${path} declares its render, offline and budget', () => {
94
153
  expect(config.render).toBe('${RENDER[surface]}');
95
154
  expect(config.offline).toBe('${OFFLINE[surface]}');
96
155
  // budget is always on the descriptor, so pin the number: presence cannot fail.
@@ -100,9 +159,24 @@ unitTest('/${path} declares a render mode, an offline strategy and a budget', ()
100
159
  unitTest('/${path} stays inside its byte budget declaration', () => {
101
160
  expect(config.budget.js).toBe('${BUDGET[surface].js}');
102
161
  });
162
+ `;
103
163
 
104
- e2eTest('/${path} renders offline from its fallback', async ({ page, offline }) => {
105
- await page.goto('/${path}');
164
+ /**
165
+ * The offline assertion, in its own file — and that is the whole point of the split. The gate
166
+ * types a test by its FILENAME, so an `e2eTest` living in `page.test.ts` was classified as a UNIT
167
+ * test and could never reach the `e2e` step no matter what drove it. `page.e2e.test.ts` is the
168
+ * name the `e2e` step selects on.
169
+ */
170
+ const routeE2eTest = (
171
+ path: string,
172
+ ): string => `// /${path} on a train: the offline fallback, asserted against the BUILT output. Its own file
173
+ // because the gate types a test by its FILENAME — an e2eTest in page.test.ts is a unit test.
174
+ import { e2eTest, expect } from '@ultimat3/testing';
175
+
176
+ // Runs under the \`e2e\` step, against the BUILT output. Without a registered browser driver
177
+ // \`e2eTest\` reports itself skipped, naming the command that builds what it would drive.
178
+ e2eTest('/${path} renders offline', async ({ page, offline }) => {
179
+ await page.goto('/${sampleUrl(path)}');
106
180
  await offline();
107
181
  await page.reload();
108
182
  expect(await page.title()).not.toBe('');
@@ -129,6 +203,7 @@ export function routeFiles(rawPath: string, options: RouteOptions): readonly Gen
129
203
  { path: `${dir}/page.tsx`, contents: pageSource(options.surface, path) },
130
204
  { path: `${dir}/page.module.scss`, contents: styleSource() },
131
205
  { path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
206
+ { path: `${dir}/page.e2e.test.ts`, contents: routeE2eTest(path) },
132
207
  ...locales.map((locale) => ({
133
208
  path: catalogPath(locale),
134
209
  contents: catalogSource(path),