@ultimat3/cli 22.15.0 → 23.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 (169) hide show
  1. package/CLAUDE.md +23 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +25 -1
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
@@ -45,7 +45,7 @@ export const config = defineRoute({
45
45
  }),
46
46
  });
47
47
 
48
- export function HomePage() {
48
+ export function Page() {
49
49
  const t = useT();
50
50
 
51
51
  return (
@@ -259,23 +259,49 @@ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
259
259
  }
260
260
  `;
261
261
 
262
- const sitePageTest =
263
- (): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
264
- // its config, and both are the kind that rot silently when someone adds one import.
265
- import { metaContextFor, routeDataFor } from '@ultimat3/render';
266
- import { expect, unitTest } from '@ultimat3/testing';
267
- import { config } from './page';
268
-
269
- // The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
270
- // \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
271
- const ctx = { params: {}, url: 'https://example.test/' };
272
-
273
- unitTest('the landing page ships zero JS and declares metadata', async () => {
274
- expect(config.render).toBe('static');
275
- expect(config.hydrate).toBe('never');
276
- expect(config.budget.js).toBe('0kb');
277
- const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
278
- expect(meta.title ?? '').not.toBe('');
262
+ const sitePageTest = (
263
+ app: NameSet,
264
+ ): string => `// The landing page, rendered: what a visitor reads, where its two calls to action lead, and that
265
+ // it ships zero JS. Each is a promise the file makes, and each rots silently on one import.
266
+ ${sortedImports([
267
+ `import { useT } from '@${app.kebab}/i18n';`,
268
+ "import { expect, renderRoute, unitTest } from '@ultimat3/testing';",
269
+ ])}
270
+ import * as page from './page';
271
+
272
+ const url = 'https://example.test/';
273
+
274
+ unitTest('the landing page says what the catalog says, under one h1', async () => {
275
+ const t = useT();
276
+ const view = await renderRoute(page, { url });
277
+ expect(view.html.match(/<h1\\b/g)).toHaveLength(1);
278
+ expect(view.text).toContain(t('site.home.headline'));
279
+ expect(view.text).toContain(t('site.home.lede'));
280
+ // Three facts, each a heading and a body.
281
+ expect(view.html.match(/<h2\\b/g)).toHaveLength(3);
282
+ for (const key of ['site.home.f1Title', 'site.home.f2Body', 'site.home.f3Title'] as const) {
283
+ expect(view.text).toContain(t(key));
284
+ }
285
+ // A key no catalog holds renders as \u27e6key\u27e7 — to a visitor, with every other check green.
286
+ expect(view.text).not.toContain('\u27e6');
287
+ });
288
+
289
+ unitTest('its two calls to action lead into the product', async () => {
290
+ const view = await renderRoute(page, { url });
291
+ const hrefs = [...view.html.matchAll(/<a\\b[^>]*href="([^"]*)"/g)].map((match) => match[1]);
292
+ expect(hrefs).toEqual(['/dashboard', '/admin']);
293
+ });
294
+
295
+ unitTest('it ships zero JS and declares the metadata a search result renders', async () => {
296
+ const t = useT();
297
+ const view = await renderRoute(page, { url });
298
+ expect(page.config.render).toBe('static');
299
+ expect(page.config.hydrate).toBe('never');
300
+ expect(page.config.budget.js).toBe('0kb');
301
+ // What the render DID, beside what the config declares: no island left the page.
302
+ expect(view.islands).toEqual([]);
303
+ expect(view.meta.title).toBe(t('site.home.title'));
304
+ expect(view.meta.description).toBe(t('site.home.description'));
279
305
  });
280
306
  `;
281
307
 
@@ -283,5 +309,5 @@ unitTest('the landing page ships zero JS and declares metadata', async () => {
283
309
  export const siteFiles = (app: NameSet): readonly GeneratedFile[] => [
284
310
  { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
285
311
  { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
286
- { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
312
+ { path: 'apps/web/site/page.test.ts', contents: sitePageTest(app) },
287
313
  ];
@@ -31,6 +31,23 @@ export function Card(props: CardProps) {
31
31
  }
32
32
  `;
33
33
 
34
+ const uiCardTest =
35
+ (): string => `// The card, rendered the way a page renders it. What a typecheck cannot see is where a prop
36
+ // lands: the title is the card's heading, and the children follow it inside the same section.
37
+ import { expect, renderView, unitTest } from '@ultimat3/testing';
38
+ import { Card } from './card';
39
+
40
+ unitTest('the card puts its title in a heading and its children after it', async () => {
41
+ const view = await renderView(Card, { title: 'Usage', children: 'body copy' });
42
+ expect(view.html).toMatch(/^<section[^>]*><h2[^>]*>Usage<\\/h2>body copy<\\/section>$/);
43
+ });
44
+
45
+ unitTest('a card with no children is its heading and nothing else', async () => {
46
+ const view = await renderView(Card, { title: 'Empty' });
47
+ expect(view.text).toBe('Empty');
48
+ });
49
+ `;
50
+
34
51
  const uiCardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
35
52
 
36
53
  .card {
@@ -52,5 +69,6 @@ export const uiPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
52
69
  ...packageShapeFiles(app, 'ui', DESCRIPTION),
53
70
  { path: 'packages/ui/src/index.ts', contents: uiIndex() },
54
71
  { path: 'packages/ui/src/card.tsx', contents: uiCard() },
72
+ { path: 'packages/ui/src/card.test.ts', contents: uiCardTest() },
55
73
  { path: 'packages/ui/src/card.module.scss', contents: uiCardStyle() },
56
74
  ];
@@ -4,6 +4,7 @@
4
4
  // none of them, so each emitted TS2307 in any slice a resource had not been run in first.
5
5
 
6
6
  import { stripComments } from '@ultimat3/core';
7
+ import { adminCatalogFiles } from './admin-catalog';
7
8
  import type { FeatureTarget } from './entity';
8
9
  import { entityFiles } from './entity';
9
10
  import type { GeneratedFile, NameSet } from './naming';
@@ -74,16 +75,24 @@ const ifAbsent = (files: readonly GeneratedFile[]): readonly GeneratedFile[] =>
74
75
  export function sliceFoundation(
75
76
  target: FeatureTarget,
76
77
  needs: readonly SliceModule[],
78
+ /** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
79
+ locales?: readonly string[],
77
80
  ): readonly GeneratedFile[] {
78
81
  const feature = names(target.feature);
79
82
  const dir = `${target.surfaceDir}/${target.feature}`;
80
- return ifAbsent([
81
- ...(needs.includes('entity') ? entityFiles(target.feature, target) : []),
82
- ...(needs.includes('policy') ? policyFiles(target.feature, target) : []),
83
- ...(needs.includes('errors')
84
- ? [{ path: `${dir}/errors.ts`, contents: errorsSource(feature) }]
85
- : []),
86
- ]);
83
+ return [
84
+ ...ifAbsent([
85
+ ...(needs.includes('entity') ? entityFiles(target.feature, target) : []),
86
+ ...(needs.includes('policy') ? policyFiles(target.feature, target) : []),
87
+ ...(needs.includes('errors')
88
+ ? [{ path: `${dir}/errors.ts`, contents: errorsSource(feature) }]
89
+ : []),
90
+ ]),
91
+ // A planted entity joins the typed handle, and an entity in the handle IS an admin screen —
92
+ // so it gets the labels `x g entity` writes for one, or its nav entry renders a missing-key
93
+ // marker. A catalog MERGE, never `if-absent`: a key already there keeps the value it has.
94
+ ...(needs.includes('entity') ? adminCatalogFiles(target.feature, target, locales) : []),
95
+ ];
87
96
  }
88
97
 
89
98
  /** The exported names an `export { a, b as c }` list declares — `c`, never `b`. */
@@ -117,10 +126,39 @@ export function sliceExports(source: string, name: string): boolean {
117
126
  const code = stripComments(source);
118
127
  // `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
119
128
  // `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
120
- // `byId`/`listByOrg` on a real repo.ts always read `false`. `async` has no meaning before
129
+ // `byId`/`list` on a real repo.ts always read `false`. `async` has no meaning before
121
130
  // `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
122
131
  const declared = new RegExp(
123
132
  `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
124
133
  );
125
134
  return declared.test(code) || listedExports(code).includes(name);
126
135
  }
136
+
137
+ /** ` title: text({ max: 200 }),` — one column of an entity, as its name and its builder. */
138
+ const COLUMN_LINE = /^ {4}([A-Za-z_$][\w$]*): ([A-Za-z_$][\w$]*)\(/gm;
139
+
140
+ const columnsOf = (entitySource: string): string =>
141
+ [...stripComments(entitySource).matchAll(COLUMN_LINE)]
142
+ .map((match) => `${match[1] ?? ''}: ${match[2] ?? ''}`)
143
+ .join(', ');
144
+
145
+ /**
146
+ * Whether a generated TEST may store a row in this slice: its entity declares exactly the columns
147
+ * `x g entity` scaffolds for this feature — read off the entity this generator would write, never
148
+ * a second list — and its `repo.ts`, when one is on disk, still exports `insert`. A slice an author
149
+ * reshaped has a row this generator cannot spell (one more required column is enough), so its
150
+ * tests assert the branches that need none and leave the stored-row case to the author.
151
+ */
152
+ export function sliceTakesScaffoldRow(
153
+ target: FeatureTarget,
154
+ sliceEntity: string | undefined,
155
+ sliceRepo: string | undefined,
156
+ ): boolean {
157
+ if (sliceEntity === undefined) return false;
158
+ const scaffold = entityFiles(target.feature, target).find((file) =>
159
+ file.path.endsWith('/entity.ts'),
160
+ );
161
+ const wanted = columnsOf(String(scaffold?.contents ?? ''));
162
+ if (wanted === '' || columnsOf(sliceEntity) !== wanted) return false;
163
+ return sliceRepo === undefined || sliceExports(sliceRepo, 'insert');
164
+ }
@@ -11,8 +11,21 @@ export interface TestCounts {
11
11
  readonly ran: number;
12
12
  /** Tests bun reported as skipped or todo, which is the same thing here: they did not run. */
13
13
  readonly skipped: number;
14
+ /**
15
+ * Errors raised OUTSIDE any test — a `describe` body or a module that threw on load, a failed
16
+ * hook — which bun counts on a line of their own (`1 error`) beside `0 fail`. Present only when
17
+ * there were some: the exit code already makes the step red, and without this number the step
18
+ * line read "12 ran, 0 skipped" over a run whose file never got as far as declaring a test.
19
+ */
20
+ readonly errors?: number;
14
21
  }
15
22
 
23
+ /** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
24
+ const errorsIn = (output: string): number => {
25
+ const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
26
+ return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
27
+ };
28
+
16
29
  /**
17
30
  * Summed across every process the step spawned, because a step is one line in the gate whether it
18
31
  * ran on one worker or eight.
@@ -26,10 +39,13 @@ export interface TestCounts {
26
39
  export const countsOf = (results: readonly ExecResult[]): TestCounts => {
27
40
  let ran = 0;
28
41
  let skipped = 0;
42
+ let errors = 0;
29
43
  for (const result of results) {
30
- const run = parseBunTest(execOutput(result), result.durationMs);
44
+ const output = execOutput(result);
45
+ const run = parseBunTest(output, result.durationMs);
31
46
  ran += run.passed + run.failed;
32
47
  skipped += run.skipped;
48
+ errors += errorsIn(output);
33
49
  }
34
- return { ran, skipped };
50
+ return { ran, skipped, ...(errors === 0 ? {} : { errors }) };
35
51
  };
@@ -0,0 +1,105 @@
1
+ // The four ways browser code opens its own connection, found in ONE file's text: a `fetch(` CALL,
2
+ // `new WebSocket(`, `new XMLHttpRequest(`, `new EventSource(`. Pure, so every false positive the
3
+ // rule has to leave alone is a fixture string. Which file may hold which shape is the caller's.
4
+
5
+ import { maskLiterals } from '@ultimat3/core';
6
+ import { lineIndex } from './ts-scan';
7
+
8
+ export type TransportShape = 'fetch' | 'websocket' | 'xhr' | 'eventsource';
9
+
10
+ export interface TransportCall {
11
+ readonly shape: TransportShape;
12
+ readonly line: number;
13
+ /** What the source spells — `fetch`, `globalThis.fetch`, `WebSocket` — so a finding quotes it. */
14
+ readonly spelled: string;
15
+ }
16
+
17
+ const CONSTRUCTED: ReadonlyMap<string, TransportShape> = new Map([
18
+ ['WebSocket', 'websocket'],
19
+ ['XMLHttpRequest', 'xhr'],
20
+ ['EventSource', 'eventsource'],
21
+ ]);
22
+
23
+ /** The global object's three spellings. `server.fetch(` is a method, never the global. */
24
+ const GLOBAL = String.raw`(?:(?:globalThis|window|self)\s*\.\s*)`;
25
+ const NEW = new RegExp(
26
+ String.raw`\bnew\s+${GLOBAL}?(WebSocket|XMLHttpRequest|EventSource)\s*\(`,
27
+ 'g',
28
+ );
29
+ const FETCH = new RegExp(String.raw`(?<![\w$.])(${GLOBAL}?)fetch\s*\(`, 'g');
30
+
31
+ /**
32
+ * Whether the file binds `fetch` itself — a `const`/`let`/`var`/`function`, an import, a
33
+ * destructured declaration or a parameter — which makes a BARE `fetch(` its own function, not the
34
+ * page's. `packages/action/src/idempotency-postgres.ts` calls a local `fetch(key)`. Per file, not
35
+ * per scope: a floor, and the one direction it errs is silence on a file that shadows the global
36
+ * AND calls it bare. `globalThis.fetch(` is reported regardless.
37
+ */
38
+ export function bindsFetch(code: string): boolean {
39
+ return (
40
+ /\b(?:const|let|var|function)\s+fetch\b/.test(code) ||
41
+ /\bimport\s+[^;]*\bfetch\b[^;]*\bfrom\b/.test(code) ||
42
+ /\b(?:const|let|var)\s*\{[^}]*\bfetch\b[^}]*\}\s*=/.test(code) ||
43
+ /\(\s*(?:[\w$]+\s*(?::[^,()]+)?,\s*)*fetch\s*[?:,)=][^()]*\)\s*(?::[^={;]+)?(?:=>|\{)/.test(
44
+ code,
45
+ )
46
+ );
47
+ }
48
+
49
+ /**
50
+ * The `)` closing the `(` at `open`, or `-1`. A bare depth count is exact HERE and only here: the
51
+ * text is `maskLiterals`' output, where every string, template, comment and regex body is already
52
+ * blank — so each parenthesis left is code.
53
+ */
54
+ function closeOf(masked: string, open: number): number {
55
+ let depth = 0;
56
+ for (let index = open; index < masked.length; index += 1) {
57
+ if (masked[index] === '(') depth += 1;
58
+ else if (masked[index] === ')') {
59
+ depth -= 1;
60
+ if (depth === 0) return index;
61
+ }
62
+ }
63
+ return -1;
64
+ }
65
+
66
+ /** `fetch(req) {` and `fetch(req): Promise<Response> {` define a method; they call nothing. */
67
+ function isDefinition(masked: string, open: number, start: number): boolean {
68
+ if (/\b(?:function|async|get|set)\s*$/.test(masked.slice(Math.max(0, start - 16), start))) {
69
+ return true;
70
+ }
71
+ const close = closeOf(masked, open);
72
+ if (close < 0) return false;
73
+ const after = masked.slice(close + 1).trimStart();
74
+ return after.startsWith('{') || (after.startsWith(':') && !after.startsWith('::'));
75
+ }
76
+
77
+ /** A file naming none of the four cannot call one — and masking it is the expensive half. */
78
+ const NAMES_A_TRANSPORT = /fetch|WebSocket|XMLHttpRequest|EventSource/;
79
+
80
+ export function transportCalls(source: string): readonly TransportCall[] {
81
+ if (!NAMES_A_TRANSPORT.test(source)) return [];
82
+ // Comments AND string contents blanked, offsets kept: `'fetch('` in a message is not a call, and
83
+ // neither is the one a template-literal scaffold emits for somebody else's file.
84
+ const masked = maskLiterals(source);
85
+ const lineAt = lineIndex(masked);
86
+ const calls: TransportCall[] = [];
87
+ for (const found of masked.matchAll(NEW)) {
88
+ const name = found[1] ?? '';
89
+ const shape = CONSTRUCTED.get(name);
90
+ if (shape !== undefined) calls.push({ shape, line: lineAt(found.index), spelled: name });
91
+ }
92
+ const local = bindsFetch(masked);
93
+ for (const found of masked.matchAll(FETCH)) {
94
+ const qualified = (found[1] ?? '').length > 0;
95
+ if (!qualified && local) continue;
96
+ const open = found.index + found[0].length - 1;
97
+ if (!qualified && isDefinition(masked, open, found.index)) continue;
98
+ calls.push({
99
+ shape: 'fetch',
100
+ line: lineAt(found.index),
101
+ spelled: qualified ? `${(found[1] ?? '').replace(/\s/g, '')}fetch` : 'fetch',
102
+ });
103
+ }
104
+ return calls.sort((a, b) => a.line - b.line);
105
+ }
@@ -0,0 +1,54 @@
1
+ // A `defineAdmin()` the app scan never imports. The admin's one home is `apps/admin/app/admin/`
2
+ // (`ADMIN_FILE`, what `x new` and `x g admin:page` write); `apps/<app>/src/` is outside every
3
+ // glob `app-load.ts` imports, so a declaration there never runs and `/admin` is never mounted —
4
+ // silently, which is how the reference app shipped an admin nobody could open.
5
+
6
+ import { renderFixShellArg, stripComments } from '@ultimat3/core';
7
+ import { describeRoutes } from '@ultimat3/render';
8
+ import { ADMIN_FILE } from './admin-registration';
9
+ import type { Finding } from './output';
10
+ import { hasPathSegment } from './path-segments';
11
+ import { isTest } from './source-files';
12
+
13
+ /** The one place an admin used to be written that the scan cannot see. */
14
+ const UNSCANNED = 'apps/*/src/**/*.{ts,tsx}';
15
+
16
+ const DECLARES = /\bdefineAdmin\(/;
17
+
18
+ /** Any admin this process mounted — then every declaration was reached, wherever it sits. */
19
+ export const adminMounted = (): boolean =>
20
+ describeRoutes().some((route) => route.mount?.by === 'defineAdmin');
21
+
22
+ /** Where the file belongs: its own name under the admin's home, `index.ts` as the declaration. */
23
+ const homeOf = (file: string): string => {
24
+ const name = file.slice(file.lastIndexOf('/') + 1);
25
+ const home = ADMIN_FILE.slice(0, ADMIN_FILE.lastIndexOf('/'));
26
+ return /^index\.tsx?$/.test(name) ? ADMIN_FILE : `${home}/${name}`;
27
+ };
28
+
29
+ const unscannedFinding = (file: string): Finding => ({
30
+ code: 'X_ADMIN_UNSCANNED',
31
+ cause: `${file} calls defineAdmin(), and the app scan imports apps/*/{site,app,api,shared}/** only — no boot evaluates it, so the admin is never mounted and /admin answers 404`,
32
+ // A file name is the app's: screened, so a name carrying shell syntax reads as a placeholder.
33
+ fix: `git mv ${renderFixShellArg(file, '<the file above>')} ${renderFixShellArg(homeOf(file), '<apps/admin/app/admin/its-name.ts>')} # then repoint its relative imports and x verify --only manifest --json`,
34
+ at: file,
35
+ });
36
+
37
+ /**
38
+ * One finding per unscanned file that declares an admin. Read only when nothing mounted one: an app
39
+ * whose admin is mounted pays for no glob, and comments are masked so prose naming the call is not
40
+ * a declaration.
41
+ */
42
+ export async function unscannedAdminFindings(
43
+ root: string,
44
+ mounted: () => boolean = adminMounted,
45
+ ): Promise<readonly Finding[]> {
46
+ if (mounted()) return [];
47
+ const files: string[] = [];
48
+ for await (const file of new Bun.Glob(UNSCANNED).scan({ cwd: root })) {
49
+ const path = file.replaceAll('\\', '/');
50
+ if (hasPathSegment(path, 'node_modules') || isTest(path)) continue;
51
+ if (DECLARES.test(stripComments(await Bun.file(`${root}/${path}`).text()))) files.push(path);
52
+ }
53
+ return files.sort().map(unscannedFinding);
54
+ }
@@ -15,6 +15,7 @@ import {
15
15
  import type { MetaIssue } from '@ultimat3/seo';
16
16
  import { validateMeta } from '@ultimat3/seo';
17
17
  import { checkAgentsMd } from './app-agents-md';
18
+ import { readAppSources } from './app-boundaries';
18
19
  import { envExampleFindings } from './app-env';
19
20
  import { appManifest, readAppManifest } from './app-manifest';
20
21
  import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
@@ -36,10 +37,12 @@ import { msg } from './messages';
36
37
  import type { Finding } from './output';
37
38
  import { findingFrom } from './output';
38
39
  import { checkMigrationDrift } from './schema-drift';
40
+ import { checkSchemaDump } from './schema-dump-drift';
39
41
  import { scanSiteMeta } from './seo-meta';
40
42
  import { quoteArg } from './shell-quote';
41
43
  import { readStaticReport } from './static-report';
42
44
  import { floorProblemFindings, readVerifyFloor } from './verify-floor';
45
+ import { roleLoadFindings } from './verify-role-load';
43
46
  import type { VerifyStep } from './verify-step';
44
47
  import { fromExec, fromFindings, hostFindings } from './verify-step';
45
48
  import { TEST_STEPS } from './verify-tests';
@@ -90,7 +93,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
90
93
  },
91
94
  {
92
95
  name: 'boundaries',
93
- summary: "surface, layer and package-tier imports, and the app's own guards",
96
+ summary:
97
+ "surface, layer and package-tier imports, one browser transport, and the app's own guards",
94
98
  // An app's `guards/` rides here rather than becoming a step of its own, for the reason the
95
99
  // seam already states: a host adds findings to a step, it can never add, remove, reorder or
96
100
  // skip one — so "green" keeps meaning exactly what it meant. This is the step whose host slot
@@ -101,12 +105,18 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
101
105
  // Discovered, not registered: `guardFindings` reads the directory. A guard that had to
102
106
  // announce itself is a guard an app can forget to announce, which is the coupling axiom 8's
103
107
  // extension model rejects.
104
- run: async (ctx) =>
105
- fromFindings([
106
- ...(await appBoundaryFindings(ctx.root)),
107
- ...(await guardFindings(ctx.root)),
108
+ //
109
+ // ONE read of the app's surface files for the whole step: the import rules, the transport
110
+ // rule and every guard are handed the same text. The guards used to walk and read the tree a
111
+ // second time (`guard-sources.ts`).
112
+ async run(ctx) {
113
+ const sources = await readAppSources(ctx.root);
114
+ return fromFindings([
115
+ ...(await appBoundaryFindings(ctx.root, sources)),
116
+ ...(await guardFindings(ctx.root, sources)),
108
117
  ...(await hostFindings(ctx, 'boundaries')),
109
- ]),
118
+ ]);
119
+ },
110
120
  },
111
121
  {
112
122
  name: 'filesize',
@@ -161,7 +171,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
161
171
  {
162
172
  name: 'drift',
163
173
  summary:
164
- 'entity declarations vs migrations, every destructive statement declared, and every statement no declaration carries',
174
+ 'entity declarations vs migrations, every destructive statement declared, every statement no declaration carries, and the committed schema dump',
165
175
  // Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
166
176
  // Source, not database: the gate runs in CI with nothing listening, and the database half is
167
177
  // the post-migrate verification `runMigrations` performs where a connection is already open.
@@ -179,6 +189,10 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
179
189
  // a squash silently drops. Same directory, same reader, no database — this step's own
180
190
  // question, which is why it is not a step of its own.
181
191
  ...(await checkUngeneratableMigrations(ctx.root)),
192
+ // The fourth, and the only one that opens a database — a scratch embedded one it boots
193
+ // itself, so CI still needs nothing listening: the committed `packages/db/schema/` against
194
+ // what replaying the migrations produces, and that dump loaded back (`X_SCHEMA_DUMP_DRIFT`).
195
+ ...(await checkSchemaDump(ctx.root, ctx.env ?? Bun.env)),
182
196
  ]),
183
197
  },
184
198
  {
@@ -328,6 +342,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
328
342
  const agents = await checkAgentsMd(ctx.root, floor?.agentsMdMaxBytes);
329
343
  // The app's load failures, once, here — every other step that loaded it points at this one.
330
344
  const app = existsSync(join(ctx.root, APP_CONFIG_FILE));
345
+ // Started first and awaited last: it is a child process, and it runs beside the rest.
346
+ const roleLoad = app ? roleLoadFindings(ctx.root, ctx.runner) : Promise.resolve([]);
331
347
  const drift = await driftFindings(ctx.root);
332
348
  const findings = [
333
349
  ...manifestMissingFindings(ctx.root),
@@ -336,6 +352,9 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
336
352
  ...(existsSync(join(ctx.root, APP_CONFIG_FILE))
337
353
  ? await unregisteredJobFindings(ctx.root)
338
354
  : []),
355
+ // What a worker never registers: a declaration beside a component the API index does not
356
+ // import (`verify-role-load.ts`). The boot checks jobs and tasks only, and only at boot.
357
+ ...(await roleLoad),
339
358
  ...(await envExampleFindings(ctx.root)),
340
359
  ...floorProblemFindings(floor),
341
360
  ...agents.findings,