@ultimat3/cli 19.3.1 → 19.3.3

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.
package/CLAUDE.md CHANGED
@@ -1124,6 +1124,16 @@ The roles live in `@ultimat3/core` (`ROLES`, `isRole`), never in a second list h
1124
1124
  driver, a dev-only authorizer or a dev-only queue is the bug this design exists to prevent — the
1125
1125
  only thing dev changes is which driver is behind an interface.
1126
1126
 
1127
+ ### `HOST` is the interface, read the way `PORT` is
1128
+
1129
+ `serve.ts`'s `hostnameFromEnv` — `HOST`, trimmed, empty is `0.0.0.0` — and `ServeOptions.hostname`
1130
+ overrides it as `port` overrides `PORT`; `containerBinding(env, hostname)` is the one `WebBinding`
1131
+ `serveApp` hands `startRoles`, so `web`, `sync` and the metrics endpoint bind the same interface.
1132
+ Before 2026-09-07 `CONTAINER_BINDING` was the only production binding and an app whose auth mode
1133
+ admits one implicit actor without a login — which must refuse a public interface — could not run in
1134
+ a container at all. A loopback bind in a container is unreachable through `-p`; the wiki row says
1135
+ where it IS reachable. Not `HOSTNAME`: Docker sets that to the container id.
1136
+
1127
1137
  ### `RuntimeOverrides` is the only way to hand the framework a driver
1128
1138
 
1129
1139
  `ServeOptions` was `{ root, env, role?, port?, metricsPort? }`, so the ONLY way an app could
@@ -1256,9 +1266,23 @@ Delete `graphHash` the day `Bun.build` is deterministic.
1256
1266
 
1257
1267
  **`x dev`, the container and the static export all mount the same table.** `serve.ts` builds the
1258
1268
  islands at boot for the same reason it mounts `apiRoutes()`: a seam that works in dev and not in the
1259
- image is the same failure one release later. `x dev` rebuilds them on the watcher tick, and that is
1260
- the one reload that actually takes effect — an island is the single module this process never
1261
- imports, so there is no Bun module cache to invalidate.
1269
+ image is the same failure one release later. `x dev` rebuilds them on the watcher tick — an island
1270
+ is the single module this process never imports, so there is no Bun module cache to invalidate —
1271
+ and the SAME tick re-imports the route module beside them when its source changed
1272
+ (`app-load.ts`'s `reloadRoute`: `<path>?x-reload=<hash>` is the one cache key Bun honours,
1273
+ `registerRoute` replaces the entry for the same file, and `dev-render.ts` reads the entry back from
1274
+ the table on every request rather than closing over the one it was built from). Until 2026-09-07
1275
+ the island was the only reload that took effect, so a save served a new island under an old page —
1276
+ the old props, the placeholder the new island renders when they are missing. A route module and
1277
+ nothing else: an action, a query or an entity is held by every module that imported it, and no
1278
+ re-import can rebind those. `@ultimat3/render`'s loader admits the query
1279
+ (`/\.tsx(?:\?[^/]*)?$/`) and strips it before reading the file — anchored on `.tsx$`, the
1280
+ re-import fell through to Bun's own JSX loader and every reloaded page died on `__xh`.
1281
+
1282
+ **The dev fixture is its own repository** (`.git/HEAD` in `DEV_FIXTURE_FILES`). The framework's root
1283
+ `.gitignore` lists `packages/cli/.dev-fixture/`, `devIgnore` honours every ancestor up to a `.git`,
1284
+ and so the watcher admitted the fixture root and nothing under it: every run booted the reload path
1285
+ and none exercised it. The marker is what lets `cmd-dev.test.ts` save a page and await the tick.
1262
1286
 
1263
1287
  **`app-load.ts` skips `*.island.tsx` deliberately.** It registers no primitive, and importing it
1264
1288
  would put the one module guaranteed to be outside the server's graph inside this process's, where a
package/README.md CHANGED
@@ -123,7 +123,7 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
123
123
  | `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
124
124
  | `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
125
125
  | `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
126
- | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it |
126
+ | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each |
127
127
  | `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
128
128
  | `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT` |
129
129
  | `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "19.3.1",
3
+ "version": "19.3.3",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,34 +37,34 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "19.3.1",
41
- "@ultimat3/admin": "19.3.1",
42
- "@ultimat3/ai": "19.3.1",
43
- "@ultimat3/auth": "19.3.1",
44
- "@ultimat3/cache": "19.3.1",
45
- "@ultimat3/core": "19.3.1",
46
- "@ultimat3/db": "19.3.1",
47
- "@ultimat3/entity": "19.3.1",
48
- "@ultimat3/flags": "19.3.1",
49
- "@ultimat3/http": "19.3.1",
50
- "@ultimat3/i18n": "19.3.1",
51
- "@ultimat3/jobs": "19.3.1",
52
- "@ultimat3/mail": "19.3.1",
53
- "@ultimat3/manifest": "19.3.1",
54
- "@ultimat3/mcp": "19.3.1",
55
- "@ultimat3/money": "19.3.1",
56
- "@ultimat3/notify": "19.3.1",
57
- "@ultimat3/policy": "19.3.1",
58
- "@ultimat3/pwa": "19.3.1",
59
- "@ultimat3/query": "19.3.1",
60
- "@ultimat3/realtime": "19.3.1",
61
- "@ultimat3/render": "19.3.1",
62
- "@ultimat3/schema": "19.3.1",
63
- "@ultimat3/scraping": "19.3.1",
64
- "@ultimat3/seo": "19.3.1",
65
- "@ultimat3/storage": "19.3.1",
66
- "@ultimat3/testing": "19.3.1",
67
- "@ultimat3/time": "19.3.1",
40
+ "@ultimat3/action": "19.3.3",
41
+ "@ultimat3/admin": "19.3.3",
42
+ "@ultimat3/ai": "19.3.3",
43
+ "@ultimat3/auth": "19.3.3",
44
+ "@ultimat3/cache": "19.3.3",
45
+ "@ultimat3/core": "19.3.3",
46
+ "@ultimat3/db": "19.3.3",
47
+ "@ultimat3/entity": "19.3.3",
48
+ "@ultimat3/flags": "19.3.3",
49
+ "@ultimat3/http": "19.3.3",
50
+ "@ultimat3/i18n": "19.3.3",
51
+ "@ultimat3/jobs": "19.3.3",
52
+ "@ultimat3/mail": "19.3.3",
53
+ "@ultimat3/manifest": "19.3.3",
54
+ "@ultimat3/mcp": "19.3.3",
55
+ "@ultimat3/money": "19.3.3",
56
+ "@ultimat3/notify": "19.3.3",
57
+ "@ultimat3/policy": "19.3.3",
58
+ "@ultimat3/pwa": "19.3.3",
59
+ "@ultimat3/query": "19.3.3",
60
+ "@ultimat3/realtime": "19.3.3",
61
+ "@ultimat3/render": "19.3.3",
62
+ "@ultimat3/schema": "19.3.3",
63
+ "@ultimat3/scraping": "19.3.3",
64
+ "@ultimat3/seo": "19.3.3",
65
+ "@ultimat3/storage": "19.3.3",
66
+ "@ultimat3/testing": "19.3.3",
67
+ "@ultimat3/time": "19.3.3",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -15,11 +15,22 @@ export interface AgentsMdOutcome {
15
15
  readonly warnings: readonly string[];
16
16
  }
17
17
 
18
- /** `assertAgentsMd` throws `X_AGENTS_MD_*`; a gate step reports, so the error becomes a finding. */
19
- export async function checkAgentsMd(root: string): Promise<AgentsMdOutcome> {
18
+ /**
19
+ * `assertAgentsMd` throws `X_AGENTS_MD_*`; a gate step reports, so the error becomes a finding.
20
+ *
21
+ * `maxBytes` is this repository's own budget, out of `x.verify.json`, and `undefined` means the
22
+ * 12kB default. `@ultimat3/manifest` has taken the option since it was written and this function
23
+ * never passed one, so the default was the only budget any app could have: a repository whose
24
+ * conventions genuinely need more room had to delete a rule to make space, which is the opposite
25
+ * of what a context-file budget is for.
26
+ */
27
+ export async function checkAgentsMd(root: string, maxBytes?: number): Promise<AgentsMdOutcome> {
20
28
  const path = join(root, AGENTS_MD_FILENAME);
21
29
  try {
22
- const { warnings } = await assertAgentsMd({ path });
30
+ const { warnings } = await assertAgentsMd({
31
+ path,
32
+ ...(maxBytes === undefined ? {} : { maxBytes }),
33
+ });
23
34
  return { findings: [], warnings };
24
35
  } catch (error) {
25
36
  return { findings: [{ ...findingFrom(error), at: AGENTS_MD_FILENAME }], warnings: [] };
package/src/app-load.ts CHANGED
@@ -10,6 +10,7 @@ import { registerActions } from '@ultimat3/action';
10
10
  import { localeConfig } from '@ultimat3/i18n';
11
11
  import type { ErrorCodeFact } from '@ultimat3/manifest';
12
12
  import { registerQueries } from '@ultimat3/query';
13
+ import type { RouteConfig } from '@ultimat3/render';
13
14
  import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
14
15
  // For the SIDE EFFECT, and it is this module's to hold: importing `@ultimat3/render/server`
15
16
  // installs the `.tsx`/`.scss` Bun plugin, a plugin only transforms modules loaded AFTER it, and
@@ -74,20 +75,32 @@ export interface LoadedApp {
74
75
  readonly findings: readonly Finding[];
75
76
  }
76
77
 
77
- // A module is imported and registered exactly once per PROCESS: `import()` caches, and a registry
78
- // rejects a second registration of a name. So a rescan refreshes only the facts DERIVED from the
79
- // registries — the manifest and its build id — and never the primitives themselves: an edited route
80
- // config, action or query needs a restart. Clearing the registries would not change that. Bun
81
- // exposes no way to invalidate a cached module, so the re-import hands back the same stale exports,
82
- // and a cache-busting query string leaks a fresh module instance on every save.
78
+ // A module is imported and registered once per PROCESS: `import()` caches, and a registry rejects
79
+ // a second registration of a name. So a rescan refreshes the facts DERIVED from the registries —
80
+ // the manifest and its build id — and, for exactly one kind of module, the primitive itself. A
81
+ // ROUTE module whose source changed is imported again under `?x-reload=<hash>`, the one cache key
82
+ // Bun honours, and `registerRoute` replaces the entry for the same file; its own imports resolve
83
+ // to the modules already cached, which is what makes it safe. An action, a query or an entity
84
+ // stays registered once: its exports are held by every module that imported it, a second instance
85
+ // would be a duplicate name in its registry, and no re-import can rebind the importers — those
86
+ // edits need a restart. Until 2026-09-07 the route module took the same rule, so a save re-bundled
87
+ // the island (`buildIslands` reads the disk) and kept the FIRST page component — a new island
88
+ // rendering under an old page's props, which is the mixed generation `x dev` served.
83
89
  const registered = new Set<string>();
84
90
  // A registration failure is sticky: the file is never retried, so the finding is replayed.
85
91
  const failures = new Map<string, Finding>();
92
+ // Route modules only: the hash of the source each one registered from, which is what a rescan
93
+ // compares the disk against. A save that leaves the bytes alone re-imports nothing.
94
+ const routeSources = new Map<string, bigint>();
95
+
96
+ /** The query a re-imported route module carries. `module-loader.ts`'s filter admits it. */
97
+ const RELOAD_QUERY = 'x-reload';
86
98
 
87
99
  /** Test seam, and what `x dev` would call if it ever restarted the registries in-process. */
88
100
  export function resetAppLoad(): void {
89
101
  registered.clear();
90
102
  failures.clear();
103
+ routeSources.clear();
91
104
  }
92
105
 
93
106
  export async function loadApp(root: string): Promise<LoadedApp> {
@@ -104,6 +117,13 @@ export async function loadApp(root: string): Promise<LoadedApp> {
104
117
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
105
118
  continue;
106
119
  }
120
+ // The source is read BEFORE the import, and only on the file's first pass — a rescan of a
121
+ // registered module reads nothing here. A route entry is bound to the bytes the module was
122
+ // evaluated from, and a read AFTER the import cannot know which bytes those were: a save
123
+ // landing between the two bound V1's component to V2's hash, so the next scan saw nothing to
124
+ // do and served V1 until the save after. Read first, the worst case is one re-import the
125
+ // next tick, of a file that did change.
126
+ const snapshot = registered.has(absolute) ? undefined : await Bun.file(absolute).text();
107
127
  let module: Record<string, unknown>;
108
128
  try {
109
129
  module = (await import(absolute)) as Record<string, unknown>;
@@ -112,7 +132,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
112
132
  continue;
113
133
  }
114
134
  files.push(file);
115
- const finding = await register(absolute, file, module);
135
+ const finding = await register(absolute, file, module, snapshot);
116
136
  if (finding !== undefined) findings.push(finding);
117
137
  }
118
138
  }
@@ -128,35 +148,29 @@ export async function loadApp(root: string): Promise<LoadedApp> {
128
148
  };
129
149
  }
130
150
 
131
- /** Registers a module once; every later call replays whatever the first one reported. */
151
+ /**
152
+ * Registers a module once; every later call replays whatever the first one reported — except for
153
+ * a route module, which a later call re-registers from disk when its source has changed.
154
+ * `snapshot` is the source read before the module's first import, and absent on every later call.
155
+ */
132
156
  async function register(
133
157
  absolute: string,
134
158
  file: string,
135
159
  module: Record<string, unknown>,
160
+ snapshot: string | undefined,
136
161
  ): Promise<Finding | undefined> {
137
162
  const previous = failures.get(absolute);
138
163
  if (previous !== undefined) return previous;
139
- if (registered.has(absolute)) return undefined;
164
+ if (snapshot === undefined) return reloadRoute(absolute, file);
140
165
  registered.add(absolute);
141
166
  try {
142
167
  const config = module['config'];
143
- if (isRouteConfig(config)) {
144
- // The build counts boundaries from the compiled JSX; before a build there is only the
145
- // source, and `render: 'stream'` is rejected without one — so count them in the text.
146
- const source = await Bun.file(absolute).text();
147
- // The page component comes from the same module as its config, resolved by render's own
148
- // rule — the CLI does not decide which export is a page any more than it decides what a
149
- // route is. A module with no component registers without one, and renders a bare shell.
150
- const component = pageComponentOf(module);
151
- registerRoute({
152
- file,
153
- config,
154
- suspenseBoundaries: countSuspense(source),
155
- ...(component === undefined ? {} : { component }),
156
- });
157
- }
168
+ const route = isRouteConfig(config) ? config : undefined;
169
+ if (route !== undefined) registerRouteModule(file, module, route, snapshot);
158
170
  registerActions(module);
159
171
  registerQueries(module);
172
+ // Only a route module is ever re-imported, so only a route module's hash is worth keeping.
173
+ if (route !== undefined) routeSources.set(absolute, Bun.hash.wyhash(snapshot));
160
174
  return undefined;
161
175
  } catch (error) {
162
176
  const finding: Finding = { ...findingFrom(error), at: file };
@@ -165,6 +179,59 @@ async function register(
165
179
  }
166
180
  }
167
181
 
182
+ /**
183
+ * The route half of a registration, and the whole of a re-registration. `source` is the text the
184
+ * module was imported from — read by the caller BEFORE the import, never here after it — and the
185
+ * hash the entry is bound to is that text's. Until 2026-09-07 this read the file again, and a save
186
+ * between the import and that read registered the old component under the new hash: the next
187
+ * scan compared equal, and the page on disk was not served until the save after it.
188
+ */
189
+ function registerRouteModule(
190
+ file: string,
191
+ module: Record<string, unknown>,
192
+ config: RouteConfig,
193
+ source: string,
194
+ ): void {
195
+ // The page component comes from the same module as its config, resolved by render's own
196
+ // rule — the CLI does not decide which export is a page any more than it decides what a
197
+ // route is. A module with no component registers without one, and renders a bare shell.
198
+ const component = pageComponentOf(module);
199
+ registerRoute({
200
+ file,
201
+ config,
202
+ // The build counts boundaries from the compiled JSX; before a build there is only the
203
+ // source, and `render: 'stream'` is rejected without one — so count them in the text.
204
+ suspenseBoundaries: countSuspense(source),
205
+ ...(component === undefined ? {} : { component }),
206
+ });
207
+ }
208
+
209
+ /**
210
+ * A registered route module, on a rescan: imported again if the file no longer hashes to what it
211
+ * registered from, and left alone otherwise. A save that will not import is a finding at the file
212
+ * and NOT a sticky one — the next save is tried again — and the entry it would have replaced stays
213
+ * registered, so the last page that did import is the one served meanwhile.
214
+ */
215
+ async function reloadRoute(absolute: string, file: string): Promise<Finding | undefined> {
216
+ const registeredFrom = routeSources.get(absolute);
217
+ if (registeredFrom === undefined) return undefined;
218
+ const source = await Bun.file(absolute).text();
219
+ const hash = Bun.hash.wyhash(source);
220
+ if (hash === registeredFrom) return undefined;
221
+ try {
222
+ const module = (await import(`${absolute}?${RELOAD_QUERY}=${hash}`)) as Record<string, unknown>;
223
+ const config = module['config'];
224
+ // A file that stopped being a route is not un-registered — the table has no verb for it, and a
225
+ // deleted file needs a restart either way. Its hash is recorded so the same save is not
226
+ // re-imported on every later tick.
227
+ if (isRouteConfig(config)) registerRouteModule(file, module, config, source);
228
+ routeSources.set(absolute, hash);
229
+ return undefined;
230
+ } catch (error) {
231
+ return { ...findingFrom(error), at: file };
232
+ }
233
+ }
234
+
168
235
  const countSuspense = (source: string): number => source.match(/<Suspense[\s/>]/g)?.length ?? 0;
169
236
 
170
237
  /** `packages/db/src/errors.ts` → `packages/db`; `apps/web/app/posts/errors.ts` → `apps/web`. */
@@ -7,6 +7,8 @@
7
7
  // app.config.ts the root marker a real `x dev` cannot start without; `ai.mcp` by default
8
8
  // apps/web/mcp.ts the app's own MCP endpoint, mounted by the web role
9
9
  // apps/web/runtime.ts the app's middleware, reaching a development process
10
+ // .git/HEAD the fixture is its own repository — see the entry below
11
+ // apps/web/app/hello/* an SSR page with a component, edited on disk while `x dev` runs
10
12
  // apps/web/app/notes/* a memory-backed entity and a live query, fed by the in-process bridge
11
13
  // apps/web/app/posts/* an action, a policy and a query, mounted as HTTP routes
12
14
  // apps/web/site/pricing/* a static page with its own stylesheet, under the CSP `x dev` sends
@@ -24,6 +26,12 @@ import { resetAppLoad } from './app-load';
24
26
  export const DEV_FIXTURE_FILES: Readonly<Record<string, string>> = {
25
27
  'package.json': JSON.stringify({ name: 'dev-fixture', version: '1.4.0' }),
26
28
 
29
+ // Its own repository, so the ignore walk stops HERE. The framework's root `.gitignore` lists
30
+ // `packages/cli/.dev-fixture/`, and `devIgnore` honours every ancestor up to a `.git` — without
31
+ // this marker the watcher admitted the fixture root and nothing under it, so no save in this
32
+ // tree ever reached `rebuild`, and the reload path was booted by every run and exercised by none.
33
+ '.git/HEAD': 'ref: refs/heads/main\n',
34
+
27
35
  // The root marker a real `x dev` cannot start without, and where `ai.mcp` is declared — by
28
36
  // default `{ expose: true, path: '/mcp' }`, which is what the MCP mount reads.
29
37
  'app.config.ts': `import { defineConfig } from '@ultimat3/core';
@@ -98,6 +106,23 @@ export const echoPost = action({
98
106
  return { word: input.word };
99
107
  },
100
108
  });
109
+ `,
110
+
111
+ // A page with a body, on the surface an author edits all day. What the reload test rewrites: the
112
+ // module must be imported again for the new body to be served, which `import()` alone never does.
113
+ 'apps/web/app/hello/page.tsx': `import { defineRoute } from '@ultimat3/render';
114
+
115
+ export const config = defineRoute({
116
+ render: 'ssr',
117
+ hydrate: 'visible',
118
+ offline: 'runtime',
119
+ budget: { js: '60kb' },
120
+ meta: () => ({ title: 'Hello', description: 'A page that is edited while x dev runs' }),
121
+ });
122
+
123
+ export function Page() {
124
+ return <p>generation one</p>;
125
+ }
101
126
  `,
102
127
 
103
128
  // A stylesheet the page imports, because that import is what registers it — and the document's
package/src/cmd-dev.ts CHANGED
@@ -16,7 +16,6 @@ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
16
  import { describeRoutes } from '@ultimat3/render';
17
17
  import { apiRoutes } from './api-routes';
18
18
  import { loadSignInPath } from './app-auth';
19
- import { loadApp } from './app-load';
20
19
  import { appManifest } from './app-manifest';
21
20
  import { mountAppMcp } from './app-mcp';
22
21
  import { requireAppRoot } from './app-root';
@@ -88,10 +87,17 @@ interface DevState {
88
87
  reloads: number;
89
88
  /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
90
89
  reloadFinding: Finding | undefined;
90
+ /**
91
+ * Modules that would not import and primitives that would not register, as of the LAST scan —
92
+ * the boot's, then each rebuild's. A page saved with a syntax error is a finding here until the
93
+ * save that fixes it, and the boot's list alone would never show it.
94
+ */
95
+ appFindings: readonly Finding[];
91
96
  /**
92
97
  * The client entries, rebuilt on the same tick as the manifest. An island is the one module this
93
- * process never imports, so a fresh `Bun.build` is the whole of its reload — no module cache to
94
- * invalidate, which is exactly why editing one takes effect where editing a route does not.
98
+ * process never imports, so a fresh `Bun.build` is the whole of its reload. The route module
99
+ * beside it is re-imported by that same scan (`app-load.ts`) — the two are one generation, or
100
+ * a save serves a new island under an old page, which is what it did until 2026-09-07.
95
101
  */
96
102
  islands: IslandBundle;
97
103
  }
@@ -135,11 +141,16 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
135
141
  // uninstalled, and nothing more (axiom 6).
136
142
  const statements = createStatementLedger();
137
143
  setStatementObserver(statements.observer);
138
- const app = await loadApp(options.root);
144
+ // ONE load at boot, the same call the rebuild below makes: the manifest and the findings are
145
+ // two projections of one scan. Until 2026-09-07 this was `loadApp` for the findings and then
146
+ // `appManifest` — which loads again — for the manifest, so a save landing between the two put
147
+ // `/_x`'s findings and its manifest on different registration states.
148
+ const app = await appManifest(options.root);
139
149
  const state: DevState = {
140
- manifest: (await appManifest(options.root)).manifest,
150
+ manifest: app.manifest,
141
151
  reloads: 0,
142
152
  reloadFinding: undefined,
153
+ appFindings: app.findings,
143
154
  islands: await buildIslands(options.root),
144
155
  };
145
156
  // The manifest's build id is a content hash of every fact below it, so a dev document's
@@ -279,11 +290,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
279
290
  const rebuild = coalesceReloads(
280
291
  async (file) => {
281
292
  const started = performance.now();
282
- const [{ manifest }, islands] = await Promise.all([
293
+ const [{ manifest, findings }, islands] = await Promise.all([
283
294
  appManifest(options.root),
284
295
  buildIslands(options.root),
285
296
  ]);
286
297
  state.manifest = manifest;
298
+ state.appFindings = findings;
287
299
  state.islands = islands;
288
300
  state.reloads += 1;
289
301
  state.reloadFinding = undefined;
@@ -316,8 +328,8 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
316
328
  get findings(): readonly Finding[] {
317
329
  const loops = statements.repeats().map(loopFacts).map(loopFinding);
318
330
  return state.reloadFinding === undefined
319
- ? [...app.findings, ...loops]
320
- : [...app.findings, state.reloadFinding, ...loops];
331
+ ? [...state.appFindings, ...loops]
332
+ : [...state.appFindings, state.reloadFinding, ...loops];
321
333
  },
322
334
  running,
323
335
  runtime,
@@ -7,7 +7,8 @@ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
7
7
  import { appManifest, writeAppManifest } from './app-manifest';
8
8
  import { requireAppRoot } from './app-root';
9
9
  import type { CliCommand, CommandContext } from './command';
10
- import { generate } from './generate-files';
10
+ import { generate, sliceDir } from './generate-files';
11
+ import type { Generator } from './generate-kinds';
11
12
  import { GENERATORS, readKind, readName, readPermission, readSurface } from './generate-kinds';
12
13
  import { containedPath, writeFiles } from './generate-write';
13
14
  import { resolveCatalogModule } from './i18n-audit';
@@ -70,10 +71,13 @@ export const generateCommand: CliCommand = {
70
71
  // Read before a file is planned, like the flags above: which module a generated component
71
72
  // imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
72
73
  const catalogModule = await resolveCatalogModule(root);
74
+ // Read for the same reason: which errors the slice declares is written on THIS app's disk.
75
+ const sliceErrors = await readSliceErrors(root, kind, sliceDir(surface, featureFlag ?? name));
73
76
  const files = generate({
74
77
  kind,
75
78
  name,
76
79
  ...(featureFlag === undefined ? {} : { feature: featureFlag }),
80
+ ...(sliceErrors === undefined ? {} : { sliceErrors }),
77
81
  ...(at === undefined ? {} : { at }),
78
82
  ...(permission === undefined ? {} : { permission }),
79
83
  surface,
@@ -138,3 +142,18 @@ export const generateCommand: CliCommand = {
138
142
  };
139
143
  },
140
144
  };
145
+
146
+ /**
147
+ * The slice's `errors.ts`, for the two generators whose template throws from it. Only those two:
148
+ * a route or an island names no slice, and the "feature" the fallback derives for them is a path
149
+ * that exists nowhere — reading it would be answering a question nobody asked.
150
+ */
151
+ async function readSliceErrors(
152
+ root: string,
153
+ kind: Generator,
154
+ slice: string,
155
+ ): Promise<string | undefined> {
156
+ if (kind !== 'action' && kind !== 'mutator') return undefined;
157
+ const file = containedPath(root, `${slice}/errors.ts`);
158
+ return existsSync(file) ? await Bun.file(file).text() : undefined;
159
+ }
package/src/dev-render.ts CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  renderHead,
28
28
  routeDataFor,
29
29
  routeEntries,
30
+ routeFor,
31
+ routeStatusOf,
30
32
  seoRenderers,
31
33
  } from '@ultimat3/render';
32
34
  import type { IsrController } from '@ultimat3/render/server';
@@ -212,12 +214,15 @@ async function resultFor(
212
214
  // ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
213
215
  // route's `load` runs exactly once however its mode splits head from body.
214
216
  const data = await routeDataFor(entry.config, request);
217
+ // The status the loader answered through `withStatus`, 200 when it said nothing. Read once,
218
+ // here, and handed to every mode: this file mints the `Response`, render owns the seam.
219
+ const status = routeStatusOf(data);
215
220
  switch (entry.config.render) {
216
221
  case 'static': {
217
222
  // Not `renderStatic`: that enumerates every prerendered path for the build. A request
218
223
  // names exactly one, and it earns the same content-hashed headers.
219
224
  const body = await documentFrom(entry, request, data, options);
220
- return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
225
+ return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
221
226
  }
222
227
  case 'isr': {
223
228
  // `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
@@ -227,9 +232,12 @@ async function resultFor(
227
232
  // The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
228
233
  // already negotiated for THIS request — never `currentLocale()`, which would read the same
229
234
  // value through an ambient store the key does not need.
230
- const served = await isr.serve(isrKey(url, ctx.locale), () =>
231
- documentFrom(entry, request, data, options),
232
- );
235
+ // `{ html, status }`, never the bare string: the entry stores the status beside the HTML
236
+ // and serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
237
+ const served = await isr.serve(isrKey(url, ctx.locale), async () => ({
238
+ html: await documentFrom(entry, request, data, options),
239
+ status,
240
+ }));
233
241
  return served.result;
234
242
  }
235
243
  case 'stream': {
@@ -253,13 +261,14 @@ async function resultFor(
253
261
  holes: [],
254
262
  },
255
263
  { buildId: options.buildId },
264
+ status,
256
265
  );
257
266
  }
258
267
  default:
259
268
  return renderSsr(
260
269
  { entry, params: request.params, url, ctx },
261
270
  () => documentFrom(entry, request, data, options),
262
- { buildId: options.buildId },
271
+ { buildId: options.buildId, status },
263
272
  );
264
273
  }
265
274
  }
@@ -281,15 +290,32 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
281
290
  ...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
282
291
  });
283
292
 
284
- /** One HTTP route per registered `route` primitive, in the table's own order. */
293
+ /**
294
+ * One HTTP route per registered `route` primitive, in the table's own order.
295
+ *
296
+ * The URL and the method are the table's at the time this is called; the ENTRY — the component the
297
+ * handler renders AND the `meta` the pipeline enforces — is read back from the table on every
298
+ * request. `x dev` re-registers a route module when its source changes (`app-load.ts`), and a
299
+ * handler closing over the entry it was built from kept serving the first component after every
300
+ * save — the table had moved and this closure had not. `meta` was the same defect one stage
301
+ * earlier: a snapshot taken here, so a `policy` added in a save was not enforced until a restart
302
+ * while the page behind it was already the new one — the pipeline's `auth` and `authz` stages read
303
+ * `route.meta` per request, and this getter is what makes that read the table's. The path cannot
304
+ * move under a reload (the table derives it from the file, and the file is the reload's key), so
305
+ * the entry captured here is only the fallback for a table cleared under a running server, which
306
+ * only a test does — and then guard and page fall back together.
307
+ */
285
308
  export function appRoutes(options: DevRenderOptions): readonly Route[] {
286
309
  const isr = options.isr ?? createIsrController({ buildId: options.buildId });
287
- return routeEntries().map((entry) => ({
310
+ return routeEntries().map((registered) => ({
288
311
  method: 'GET' as const,
289
- path: entry.path,
290
- meta: metaOf(entry),
312
+ path: registered.path,
313
+ get meta(): HttpRouteMeta {
314
+ return metaOf(routeFor(registered.path) ?? registered);
315
+ },
291
316
  // `ctx.params` is the router's own match — the CLI never re-parses a path it did not match.
292
317
  handler: async (request, ctx): Promise<Response> => {
318
+ const entry = routeFor(registered.path) ?? registered;
293
319
  const data: DevRouteData = { url: request.url.href, params: ctx.params };
294
320
  return responseOf(await resultFor(entry, data, options, isr, asCtx(ctx)));
295
321
  },
@@ -48,6 +48,13 @@ export interface GenerateOptions {
48
48
  * package, and only then does a generated file import `t` from `@ultimat3/i18n` instead.
49
49
  */
50
50
  readonly catalogModule?: string;
51
+ /**
52
+ * `action` and `mutator`: the slice's `errors.ts` as it stands on disk, absent when there is
53
+ * none. Supplied by `run` for `catalogModule`'s reason — whether the slice declares
54
+ * `<Feature>NotFoundError` is a fact about THIS app, and a template that assumed it wrote an
55
+ * import of a class the app never declared. Read at `sliceDir(surface, feature)/errors.ts`.
56
+ */
57
+ readonly sliceErrors?: string;
51
58
  }
52
59
 
53
60
  const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
@@ -55,6 +62,10 @@ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
55
62
  app: 'apps/web/app',
56
63
  };
57
64
 
65
+ /** Where a feature slice lives, relative to the app root — the one derivation `run` reads from. */
66
+ export const sliceDir = (surface: Surface, feature: string): string =>
67
+ `${DEFAULT_SURFACE_DIR[surface]}/${feature}`;
68
+
58
69
  /**
59
70
  * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
60
71
  * on them, and nothing has to run a filesystem to review what a generator produces.
@@ -76,9 +87,20 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
76
87
  }),
77
88
  );
78
89
  case 'action':
79
- return dedupe(actionFiles(options.name, target));
90
+ return dedupe(
91
+ actionFiles(options.name, {
92
+ ...target,
93
+ ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
94
+ }),
95
+ );
80
96
  case 'mutator':
81
- return dedupe(actionFiles(options.name, { ...target, mutator: true }));
97
+ return dedupe(
98
+ actionFiles(options.name, {
99
+ ...target,
100
+ mutator: true,
101
+ ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
102
+ }),
103
+ );
82
104
  case 'backfill':
83
105
  return dedupe(backfillFiles(options.name, target));
84
106
  case 'entity':
@@ -10,6 +10,17 @@ import type { GeneratedFile } from './templates';
10
10
  /** The app the fixture scaffolds. Kebab, multi-word: single-word names hide casing bugs. */
11
11
  export const FIXTURE_APP = 'ledger-demo';
12
12
 
13
+ /**
14
+ * An `errors.ts` an author wrote: it declares what the slice throws, and not the generated name.
15
+ * The generator's INPUT only — the sandbox's own `errors.ts` is the one the resource wrote — so it
16
+ * carries no `X_*` code: a literal here would be one shipped source hands a reader, and the
17
+ * registry rule would ask where it was registered.
18
+ */
19
+ export const HANDWRITTEN_ERRORS = `import { UltimateError } from '@ultimat3/core';
20
+
21
+ export class LedgerClosedError extends UltimateError {}
22
+ `;
23
+
13
24
  /**
14
25
  * One realistic invocation of every generator, on top of `x new --example`. Names differ from
15
26
  * their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
@@ -25,6 +36,12 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
25
36
  { kind: 'policy', name: 'credit-note', feature: 'credit-note' },
26
37
  { kind: 'action', name: 'send-invoice', feature: 'invoice' },
27
38
  { kind: 'mutator', name: 'rename-invoice', feature: 'invoice' },
39
+ // The other shape both templates have: a slice whose `errors.ts` is the author's and declares
40
+ // no `InvoiceNotFoundError`. The resource's own `errors.ts` still lands in the sandbox (it does
41
+ // declare one), which is the point — this compiles the file `x g action` writes when it must
42
+ // not import that class, beside the one it writes when it may.
43
+ { kind: 'action', name: 'ping-invoice', feature: 'invoice', sliceErrors: HANDWRITTEN_ERRORS },
44
+ { kind: 'mutator', name: 'touch-invoice', feature: 'invoice', sliceErrors: HANDWRITTEN_ERRORS },
28
45
  { kind: 'query', name: 'invoice-search', feature: 'invoice' },
29
46
  { kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true },
30
47
  { kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
package/src/serve.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // What a container starts. `apps/web/server.ts` is three lines that call `runRole`, so the boot a
2
2
  // production process performs is framework code with tests rather than app code the author has to
3
3
  // get right — and it is the SAME code `x dev` runs, minus the watcher, minus `/_x`, minus
4
- // `dev: true`. The only production-shaped decisions live here: which role, which port, and the
5
- // fact that a container must bind every interface.
4
+ // `dev: true`. The only production-shaped decisions live here: which role, which port, and which
5
+ // interface — every one by default, because a container is reached through a port mapping.
6
6
 
7
7
  import type { Role } from '@ultimat3/core';
8
8
  import {
@@ -56,9 +56,35 @@ import { serviceWorkerRoutes } from './sw-routes';
56
56
 
57
57
  export const DEFAULT_PORT = 3000;
58
58
 
59
- /** Every interface. A container bound to loopback is unreachable through its own port mapping. */
59
+ /**
60
+ * Every interface, which is what a role binds when nothing says otherwise: a container bound to
61
+ * loopback is unreachable through its own port mapping. `HOST` and `ServeOptions.hostname` are the
62
+ * two ways of saying otherwise — see `hostnameFromEnv`.
63
+ */
60
64
  export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
61
65
 
66
+ /**
67
+ * The interface the `web` and `sync` roles bind, and the metrics endpoint with them (`WebBinding`
68
+ * is one decision). Read the way `PORT` is: empty or whitespace is the default.
69
+ *
70
+ * Exists because a container had exactly one binding, `0.0.0.0`, and an app whose auth mode is
71
+ * "nobody logs in, one implicit actor" must refuse a public interface — so it could not run in a
72
+ * container at all. `HOST=127.0.0.1` is unreachable through `docker run -p` (the proxy connects to
73
+ * the container's bridge address, never its loopback); it is reachable where the container shares
74
+ * the host's network namespace (`--network host`), or through a sidecar and `ssh -L` inside it —
75
+ * which is the exposure such an app wants. Not `HOSTNAME`: Docker sets that to the container id.
76
+ */
77
+ export function hostnameFromEnv(env: Env): string {
78
+ const raw = env['HOST']?.trim();
79
+ return raw === undefined || raw.length === 0 ? CONTAINER_BINDING.hostname : raw;
80
+ }
81
+
82
+ /** What `serveApp` hands `startRoles`: the caller's hostname, else `HOST`, else every interface. */
83
+ export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
84
+ dev: false,
85
+ hostname: hostname ?? hostnameFromEnv(env),
86
+ });
87
+
62
88
  /**
63
89
  * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
64
90
  * to `web` would start a process that serves nothing the operator asked for and reports healthy.
@@ -144,6 +170,11 @@ export interface ServeOptions {
144
170
  readonly port?: number;
145
171
  /** Overrides `METRICS_PORT`, on the same terms. */
146
172
  readonly metricsPort?: number;
173
+ /**
174
+ * Overrides `HOST`: the interface the HTTP roles bind. An app that must never answer on a public
175
+ * interface passes `'127.0.0.1'` here rather than trusting the deployment to set the variable.
176
+ */
177
+ readonly hostname?: string;
147
178
  /**
148
179
  * The drivers this deployment supplies instead of the ones the environment would select.
149
180
  *
@@ -391,7 +422,7 @@ async function bootRoles(boot: {
391
422
  // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
392
423
  // process and `x dev` cannot answer a browser differently.
393
424
  root: options.root,
394
- http: CONTAINER_BINDING,
425
+ http: containerBinding(options.env, options.hostname),
395
426
  // The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
396
427
  // passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
397
428
  // `defaultClient()` is the one composer of a replicated pair and it runs only when an app
@@ -5,12 +5,25 @@
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';
8
+ import { sliceExports, sliceFoundation } from './slice-foundation';
9
9
  import { wrapImport } from './wrap';
10
10
 
11
+ /**
12
+ * The handler's lookup-by-id, or the comment that says what a slice needs before it can have one.
13
+ * `x g resource` writes a slice whose `errors.ts` declares `<Feature>NotFoundError`, and so does
14
+ * the foundation this generator lays under an empty directory; a slice an author wrote by hand
15
+ * (ai-maxxing's `fleet`, with `HostNotFoundError` and `SessionNotFoundError`) declares the errors
16
+ * it has and not this one. Importing it anyway is a file that fails at import.
17
+ */
18
+ const missingLookup = (feature: NameSet): string =>
19
+ ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
20
+ // there needs one to be thrown for it. Declare it there — the shape \`x g resource\` writes —
21
+ // then read the row through ../repo and throw it when the read answers nothing.`;
22
+
11
23
  const actionSource = (
12
24
  name: NameSet,
13
25
  feature: NameSet,
26
+ lookup: boolean,
14
27
  ): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs
15
28
  // and the policy is the same object the MCP tool and the HTTP route evaluate.
16
29
  // \`t\` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
@@ -18,23 +31,25 @@ const actionSource = (
18
31
  import { action, t } from '@ultimat3/action';
19
32
  // One directory up: actions live in \`actions/\`, the feature's errors, policy and repo are the
20
33
  // slice's own files and are shared by every action in it.
21
-
22
- import { ${feature.pascal}NotFoundError } from '../errors';
23
- ${wrapImport([`can${feature.pascal}Write`, `${feature.camel}Tag`], '../policy')}
24
- import * as repo from '../repo';
25
-
34
+ ${lookup ? `\nimport { ${feature.pascal}NotFoundError } from '../errors';\n` : ''}${wrapImport([`can${feature.pascal}Write`, `${feature.camel}Tag`], '../policy')}
35
+ ${lookup ? "import * as repo from '../repo';\n" : ''}
26
36
  export const ${name.camel} = action({
27
37
  // orgId is part of the input because the policy decides on it — authz reads the declaration,
28
38
  // never the database.
29
39
  input: t.object({ id: t.uuid, orgId: t.uuid }),
30
- output: t.object({ id: t.uuid, title: t.string }),
40
+ output: t.object({ id: t.uuid${lookup ? ', title: t.string' : ''} }),
31
41
  policy: can${feature.pascal}Write,
32
42
  cache: { invalidates: [${feature.camel}Tag] },
33
43
  mcp: { expose: true, description: '${name.raw} — edit this description' },
34
44
  async handle({ input }) {
35
- const row = await repo.byId(input.id);
45
+ ${
46
+ lookup
47
+ ? ` const row = await repo.byId(input.id);
36
48
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
37
- return { id: row.id, title: row.title };
49
+ return { id: row.id, title: row.title };`
50
+ : `${missingLookup(feature)}
51
+ return { id: input.id };`
52
+ }
38
53
  },
39
54
  });
40
55
  `;
@@ -42,14 +57,13 @@ export const ${name.camel} = action({
42
57
  const mutatorSource = (
43
58
  name: NameSet,
44
59
  feature: NameSet,
60
+ lookup: boolean,
45
61
  ): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client
46
62
  // store immediately; the server half is authoritative and reconciles on conflict.
47
63
 
48
64
  import { mutator, t } from '@ultimat3/action';
49
- import { ${feature.pascal}NotFoundError } from '../errors';
50
- import { can${feature.pascal}Write } from '../policy';
51
- import * as repo from '../repo';
52
-
65
+ ${lookup ? `import { ${feature.pascal}NotFoundError } from '../errors';\n` : ''}import { can${feature.pascal}Write } from '../policy';
66
+ ${lookup ? "import * as repo from '../repo';\n" : ''}
53
67
  interface Local${feature.pascal} {
54
68
  readonly id: string;
55
69
  readonly title: string;
@@ -71,9 +85,14 @@ export const ${name.camel} = mutator({
71
85
  });
72
86
  },
73
87
  async server(_ctx, input) {
74
- const row = await repo.byId(input.id);
88
+ ${
89
+ lookup
90
+ ? ` const row = await repo.byId(input.id);
75
91
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
76
- return { id: row.id, title: input.title };
92
+ return { id: row.id, title: input.title };`
93
+ : `${missingLookup(feature)}
94
+ return { id: input.id, title: input.title };`
95
+ }
77
96
  },
78
97
  conflict: 'server-wins',
79
98
  });
@@ -181,6 +200,13 @@ contractTest('${name.camel} projects one tool and one operation', () => {
181
200
 
182
201
  export interface ActionOptions extends FeatureTarget {
183
202
  readonly mutator?: boolean;
203
+ /**
204
+ * The slice's `errors.ts` as it stands on disk, or absent when the slice has none yet. Absent,
205
+ * the foundation writes one declaring `<Feature>NotFoundError` and the action may throw it;
206
+ * present, the file is the author's and is never rewritten, so the action throws it only when
207
+ * `sliceExports` finds it there.
208
+ */
209
+ readonly sliceErrors?: string;
184
210
  }
185
211
 
186
212
  export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
@@ -188,6 +214,9 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
188
214
  const feature = names(target.feature);
189
215
  const dir = `${target.surfaceDir}/${target.feature}/actions`;
190
216
  const isMutator = target.mutator === true;
217
+ const lookup =
218
+ target.sliceErrors === undefined ||
219
+ sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`);
191
220
  return [
192
221
  // The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
193
222
  // (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
@@ -195,7 +224,9 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
195
224
  ...sliceFoundation(target, ['entity', 'policy', 'errors']),
196
225
  {
197
226
  path: `${dir}/${name.kebab}.ts`,
198
- contents: isMutator ? mutatorSource(name, feature) : actionSource(name, feature),
227
+ contents: isMutator
228
+ ? mutatorSource(name, feature, lookup)
229
+ : actionSource(name, feature, lookup),
199
230
  },
200
231
  // TWO test files, because the gate types a test by its FILENAME and this declaration owes two
201
232
  // suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
@@ -71,9 +71,11 @@ function ${Name}(props: ${Name}Props): JSX.Element {
71
71
  * container already has children, so without it the server's markup stays on screen above a
72
72
  * second, live copy of the same thing.
73
73
  */
74
- export function mount(el: HTMLElement, props: ${Name}Props): void {
74
+ export function mount(el: HTMLElement, props: ${Name}Props): () => void {
75
75
  el.textContent = '';
76
- render(() => <${Name} {...props} />, el);
76
+ // Solid's \`render\` answers its disposer; returning it is what lets \`mountIsland\` in
77
+ // \`@ultimat3/testing\` stop this island — its timers included — when a test is done with it.
78
+ return render(() => <${Name} {...props} />, el);
77
79
  }
78
80
  `;
79
81
  };
@@ -4,6 +4,7 @@
4
4
  // ignore file, the production topology and the deploy page are `scaffold-container.ts`; the
5
5
  // `.claude/` harness that reads AGENTS.md is `scaffold-claude.ts`.
6
6
 
7
+ import { LINE_CEILING } from '../workspace-checks';
7
8
  import type { GeneratedFile, NameSet } from './naming';
8
9
  import { claudeFiles } from './scaffold-claude';
9
10
  import { containerFiles } from './scaffold-container';
@@ -27,10 +28,16 @@ not exist — five of these had an empty column and were each measured green on
27
28
  | Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
28
29
  | Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
29
30
  | Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
31
+ | Size | one file, one job — ${LINE_CEILING} lines of reviewable logic, and split past it | \`X_FILE_TOO_LONG\` |
30
32
 
31
33
  \`guards/\` is yours: each file is one rule, discovered by \`x verify\` and run inside its
32
34
  \`boundaries\` step. Delete one to drop the rule, and \`x g guard <name>\` writes the next.
33
35
 
36
+ Size is a hard line and not a style note: past ${LINE_CEILING} lines a file has stopped being the
37
+ unit of review, and \`x verify\` refuses it. The one exemption is a file that is nothing but re-exports —
38
+ it has one job by construction, and its length tracks the API's size rather than its complexity;
39
+ one statement of logic in such a file re-arms the ceiling on the same save.
40
+
34
41
  Money is the one row with no guard, deliberately: a float has no static signature a text rule can
35
42
  see, and the type already fires — measured, \`price: 19.99\` in a seed is
36
43
  \`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
@@ -9,8 +9,10 @@ import type { GeneratedFile } from './naming';
9
9
  const server =
10
10
  (): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
11
11
  // ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
12
- // applies the migrations and exits. PORT is bound on every interface, because a container bound to
13
- // localhost is unreachable through its own port mapping.
12
+ // applies the migrations and exits. PORT is bound on HOST, and HOST defaults to every interface,
13
+ // because a container bound to loopback is unreachable through its own port mapping. HOST=127.0.0.1
14
+ // is for a process that must never answer a public interface: reachable only where the container
15
+ // shares the host's network namespace (\`--network host\`), or through a sidecar and \`ssh -L\`.
14
16
 
15
17
  import { join } from 'node:path';
16
18
  import { runRole } from '@ultimat3/cli';
@@ -3,6 +3,7 @@
3
3
  // `policyFiles`; the five generators that write *into* a slice imported the same files and wrote
4
4
  // none of them, so each emitted TS2307 in any slice a resource had not been run in first.
5
5
 
6
+ import { stripComments } from '../ts-scan';
6
7
  import type { FeatureTarget } from './entity';
7
8
  import { entityFiles } from './entity';
8
9
  import type { GeneratedFile, NameSet } from './naming';
@@ -84,3 +85,38 @@ export function sliceFoundation(
84
85
  : []),
85
86
  ]);
86
87
  }
88
+
89
+ /** The exported names an `export { a, b as c }` list declares — `c`, never `b`. */
90
+ const listedExports = (code: string): readonly string[] =>
91
+ [...code.matchAll(/\bexport\s*\{([^}]*)\}/g)].flatMap((match) =>
92
+ (match[1] ?? '')
93
+ .split(',')
94
+ .map(
95
+ (entry) =>
96
+ entry
97
+ .trim()
98
+ .split(/\s+as\s+/)
99
+ .at(-1)
100
+ ?.trim() ?? '',
101
+ )
102
+ .filter((entry) => entry !== ''),
103
+ );
104
+
105
+ /**
106
+ * Whether `source` — a slice module as it stands on the app's disk — exports the VALUE `name`.
107
+ * Read at generation time because assuming it was measured: `x g action` into ai-maxxing's
108
+ * `fleet` slice wrote `import { FleetNotFoundError } from '../errors'` into a slice whose
109
+ * `errors.ts` declared `HostNotFoundError` and `SessionNotFoundError` and no `FleetNotFoundError`.
110
+ * The file failed at import — and because `x db gen` and `x manifest` load every module, one
111
+ * generated-and-not-yet-edited action made both refuse to run.
112
+ *
113
+ * Comments are masked first: `// TODO: add FleetNotFoundError` is not an export. A `type` or an
114
+ * `interface` of that name is not one either — the generated code constructs it.
115
+ */
116
+ export function sliceExports(source: string, name: string): boolean {
117
+ const code = stripComments(source);
118
+ const declared = new RegExp(
119
+ `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|function|enum)\\s+${name}\\b`,
120
+ );
121
+ return declared.test(code) || listedExports(code).includes(name);
122
+ }
@@ -50,14 +50,21 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
50
50
  {
51
51
  name: 'typecheck',
52
52
  summary: 'tsc -b across every project the root references',
53
+ // `typecheckBin` (`x.verify.json`, beside `agentsMdMaxBytes`) swaps the binary and nothing
54
+ // else: same `-b --pretty false` invocation, same output format to parse, same `X_TYPECHECK_
55
+ // FAILED` finding either way. Absent means `tsc` — the only checker every app already has,
56
+ // since `typescript` is a framework dependency and a drop-in replacement is the app's own
57
+ // devDependency to add, never a default this step could assume.
53
58
  async run(ctx) {
54
- const result = await ctx.runner(['bunx', 'tsc', '-b', '--pretty', 'false'], {
59
+ const floor = await readVerifyFloor(ctx.root);
60
+ const bin = floor?.typecheckBin ?? 'tsc';
61
+ const result = await ctx.runner(['bunx', bin, '-b', '--pretty', 'false'], {
55
62
  cwd: ctx.root,
56
63
  });
57
64
  return fromExec(result, {
58
65
  code: 'X_TYPECHECK_FAILED',
59
66
  cause: 'the project does not typecheck',
60
- fix: 'bunx tsc -b --pretty false',
67
+ fix: `bunx ${bin} -b --pretty false`,
61
68
  });
62
69
  },
63
70
  },
@@ -304,12 +311,15 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
304
311
  // vanish, so a typo in the floor covers nothing — which is the false green the floor exists to
305
312
  // close, and it is only visible if something reads the file for its own sake.
306
313
  async run(ctx) {
307
- const agents = await checkAgentsMd(ctx.root);
314
+ // The floor is read FIRST: it is where a repository declares its own `AGENTS.md` budget,
315
+ // and reading it after the check would enforce the default on a repo that raised it.
316
+ const floor = await readVerifyFloor(ctx.root);
317
+ const agents = await checkAgentsMd(ctx.root, floor?.agentsMdMaxBytes);
308
318
  const findings = [
309
319
  ...manifestMissingFindings(ctx.root),
310
320
  ...(await driftFindings(ctx.root)),
311
321
  ...(await envExampleFindings(ctx.root)),
312
- ...floorProblemFindings(await readVerifyFloor(ctx.root)),
322
+ ...floorProblemFindings(floor),
313
323
  ...agents.findings,
314
324
  ...(await hostFindings(ctx, 'manifest')),
315
325
  ];
@@ -8,6 +8,7 @@
8
8
  import { existsSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
11
+ import { AGENTS_MD_MAX_BYTES } from '@ultimat3/manifest';
11
12
  import type { Finding } from './output';
12
13
  import { VERIFY_STEP_NAMES } from './verify-step';
13
14
 
@@ -17,10 +18,81 @@ export const VERIFY_FLOOR_FILE = 'x.verify.json';
17
18
  export interface VerifyFloor {
18
19
  /** Declared step names this run may not report as skipped. */
19
20
  readonly steps: readonly string[];
21
+ /**
22
+ * This repository's `AGENTS.md` budget, in bytes, when it declares one. `@ultimat3/manifest`
23
+ * has always taken a `maxBytes` and the gate never passed one, so the 12kB default was the only
24
+ * budget an app could have — and an app whose conventions genuinely need more had no move left
25
+ * but to delete a rule to make room, which is the opposite of what the budget is for.
26
+ *
27
+ * It is here rather than in `x.config.ts` because this is the file that configures the GATE,
28
+ * and it is read by the very step that enforces the budget. Raising it is a commit a reviewer
29
+ * sees, which is the whole safeguard: the number is small, visible, and argued for in one place.
30
+ */
31
+ readonly agentsMdMaxBytes?: number;
32
+ /**
33
+ * The binary the `typecheck` step invokes in place of `tsc` — `bunx <typecheckBin> -b --pretty
34
+ * false`, unchanged otherwise. Absent means `tsc`, which is the only binary every app already
35
+ * has: `typescript` is a framework dependency, not one this file can assume an app added.
36
+ *
37
+ * Here for the same reason `agentsMdMaxBytes` is: this is the file that configures the GATE,
38
+ * and it is read by the very step the key names. A drop-in `tsc -b` compatible checker (Microsoft's
39
+ * `tsgo`, `@typescript/native-preview`) is a devDependency + a one-line commit here, never a
40
+ * hardcoded default — an app that has not installed the binary this names gets `command not
41
+ * found` from its own shell, not a framework opinion about which compiler is correct.
42
+ */
43
+ readonly typecheckBin?: string;
20
44
  /** Why part of the file is not a floor. The `manifest` step reports these; nothing swallows them. */
21
45
  readonly problems: readonly string[];
22
46
  }
23
47
 
48
+ /** The floor's budget key. Named once: the problem quotes it and the fix repairs it. */
49
+ export const BUDGET_FIELD = 'agentsMdMaxBytes';
50
+
51
+ /** The floor's typecheck-binary key. Named once: the problem quotes it and the fix repairs it. */
52
+ export const TYPECHECK_BIN_FIELD = 'typecheckBin';
53
+
54
+ /**
55
+ * `agentsMdMaxBytes`, or a reason it is not one. A budget that is not a positive whole number is
56
+ * the caller's bug and must not silently fall back to the default: a floor file that says
57
+ * `"agentsMdMaxBytes": "16kb"` and is quietly ignored is a repository that believes it raised a
58
+ * budget it did not, and finds out when the gate goes red on a commit that changed nothing.
59
+ */
60
+ function readBudget(payload: Record<string, unknown> | undefined): {
61
+ budget?: number;
62
+ problems: readonly string[];
63
+ } {
64
+ const raw = payload?.[BUDGET_FIELD];
65
+ if (raw === undefined) return { problems: [] };
66
+ if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw <= 0)
67
+ return {
68
+ problems: [
69
+ `"${BUDGET_FIELD}" is ${JSON.stringify(raw)}, which is not a positive whole number of bytes`,
70
+ ],
71
+ };
72
+ return { budget: raw, problems: [] };
73
+ }
74
+
75
+ /**
76
+ * `typecheckBin`, or a reason it is not one. A non-string value must not silently fall back to
77
+ * `tsc`: a floor that names `"typecheckBin": 7` and gets `tsc` anyway is a repository that
78
+ * believes the `typecheck` step is running a checker it is not, which is the same false green
79
+ * `readBudget` above already refuses for the byte count. An empty string is refused for the same
80
+ * reason `-b`'s own project argument may not be empty — `bunx '' -b …` is not a step that failed
81
+ * to typecheck, it is a step that never ran a compiler at all.
82
+ */
83
+ function readTypecheckBin(payload: Record<string, unknown> | undefined): {
84
+ bin?: string;
85
+ problems: readonly string[];
86
+ } {
87
+ const raw = payload?.[TYPECHECK_BIN_FIELD];
88
+ if (raw === undefined) return { problems: [] };
89
+ if (typeof raw !== 'string' || raw.trim().length === 0)
90
+ return {
91
+ problems: [`"${TYPECHECK_BIN_FIELD}" is ${JSON.stringify(raw)}, which is not a binary name`],
92
+ };
93
+ return { bin: raw, problems: [] };
94
+ }
95
+
24
96
  const asRecord = (value: unknown): Record<string, unknown> | undefined =>
25
97
  typeof value === 'object' && value !== null && !Array.isArray(value)
26
98
  ? (value as Record<string, unknown>)
@@ -48,19 +120,35 @@ export function parseVerifyFloor(
48
120
  // was meant to make the path safe (`metrics-endpoint.ts` states the same rule over `stringField`).
49
121
  return { steps: [], problems: [`it does not parse as JSON (${renderThrowable(error)})`] };
50
122
  }
51
- const steps = asRecord(payload)?.['steps'];
123
+ const record = asRecord(payload);
124
+ const budget = readBudget(record);
125
+ const typecheckBin = readTypecheckBin(record);
126
+ const steps = record?.['steps'];
52
127
  if (!Array.isArray(steps)) {
53
- return { steps: [], problems: ['it has no "steps" array of step names'] };
128
+ return {
129
+ steps: [],
130
+ ...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
131
+ ...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
132
+ problems: [
133
+ 'it has no "steps" array of step names',
134
+ ...budget.problems,
135
+ ...typecheckBin.problems,
136
+ ],
137
+ };
54
138
  }
55
139
  const named = steps.filter((step): step is string => typeof step === 'string');
56
140
  const unknown = named.filter((step) => !declared.includes(step));
57
141
  return {
58
142
  steps: named.filter((step) => declared.includes(step)),
143
+ ...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
144
+ ...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
59
145
  problems: [
60
146
  ...(named.length === steps.length ? [] : ['"steps" holds an entry that is not a string']),
147
+ ...typecheckBin.problems,
61
148
  ...(unknown.length === 0
62
149
  ? []
63
150
  : [`"steps" names ${unknown.join(', ')}, which x verify does not run`]),
151
+ ...budget.problems,
64
152
  ],
65
153
  };
66
154
  }
@@ -129,7 +217,22 @@ export const floorProblemFindings = (floor: VerifyFloor | undefined): readonly F
129
217
  (floor?.problems ?? []).map((problem) => ({
130
218
  code: 'X_CONFIG_INVALID',
131
219
  cause: `${VERIFY_FLOOR_FILE} is not a suite floor: ${problem}`,
132
- fix: `x verify --json # then write ${VERIFY_FLOOR_FILE} as {"steps":["unit","contract"]}, naming only steps it ran`,
220
+ fix: fixFor(problem),
133
221
  docs: ERROR_DOCS_URL,
134
222
  at: VERIFY_FLOOR_FILE,
135
223
  }));
224
+
225
+ /**
226
+ * The edit that repairs THIS problem, not the file in general. The steps-shaped fix is useless
227
+ * against a bad budget — it prints a `{"steps":[…]}` example with no `agentsMdMaxBytes` in it, so
228
+ * an author who followed it verbatim would still have the value that failed. A finding whose fix
229
+ * does not fix it is the failure `packages/cli/CLAUDE.md` names, and the budget is the first
230
+ * problem this file can report that is not about `steps` at all.
231
+ */
232
+ const fixFor = (problem: string): string => {
233
+ if (problem.includes(`"${BUDGET_FIELD}"`))
234
+ return `x verify --json # then set "${BUDGET_FIELD}" in ${VERIFY_FLOOR_FILE} to a positive whole number of bytes, or drop the key for the ${AGENTS_MD_MAX_BYTES}B default`;
235
+ if (problem.includes(`"${TYPECHECK_BIN_FIELD}"`))
236
+ return `x verify --json # then set "${TYPECHECK_BIN_FIELD}" in ${VERIFY_FLOOR_FILE} to a non-empty binary name, or drop the key to run tsc`;
237
+ return `x verify --json # then write ${VERIFY_FLOOR_FILE} as {"steps":["unit","contract"]}, naming only steps it ran`;
238
+ };