@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 +27 -3
- package/README.md +1 -1
- package/package.json +29 -29
- package/src/app-agents-md.ts +14 -3
- package/src/app-load.ts +91 -24
- package/src/cmd-dev-fixture.ts +25 -0
- package/src/cmd-dev.ts +20 -8
- package/src/cmd-generate.ts +20 -1
- package/src/dev-render.ts +35 -9
- package/src/generate-files.ts +24 -2
- package/src/scaffold-fixture.ts +17 -0
- package/src/serve.ts +35 -4
- package/src/templates/action.ts +47 -16
- package/src/templates/island.ts +4 -2
- package/src/templates/scaffold-docs.ts +7 -0
- package/src/templates/scaffold-entries.ts +4 -2
- package/src/templates/slice-foundation.ts +36 -0
- package/src/verify-checks.ts +14 -4
- package/src/verify-floor.ts +106 -3
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
|
|
1260
|
-
the
|
|
1261
|
-
|
|
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.
|
|
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.
|
|
41
|
-
"@ultimat3/admin": "19.3.
|
|
42
|
-
"@ultimat3/ai": "19.3.
|
|
43
|
-
"@ultimat3/auth": "19.3.
|
|
44
|
-
"@ultimat3/cache": "19.3.
|
|
45
|
-
"@ultimat3/core": "19.3.
|
|
46
|
-
"@ultimat3/db": "19.3.
|
|
47
|
-
"@ultimat3/entity": "19.3.
|
|
48
|
-
"@ultimat3/flags": "19.3.
|
|
49
|
-
"@ultimat3/http": "19.3.
|
|
50
|
-
"@ultimat3/i18n": "19.3.
|
|
51
|
-
"@ultimat3/jobs": "19.3.
|
|
52
|
-
"@ultimat3/mail": "19.3.
|
|
53
|
-
"@ultimat3/manifest": "19.3.
|
|
54
|
-
"@ultimat3/mcp": "19.3.
|
|
55
|
-
"@ultimat3/money": "19.3.
|
|
56
|
-
"@ultimat3/notify": "19.3.
|
|
57
|
-
"@ultimat3/policy": "19.3.
|
|
58
|
-
"@ultimat3/pwa": "19.3.
|
|
59
|
-
"@ultimat3/query": "19.3.
|
|
60
|
-
"@ultimat3/realtime": "19.3.
|
|
61
|
-
"@ultimat3/render": "19.3.
|
|
62
|
-
"@ultimat3/schema": "19.3.
|
|
63
|
-
"@ultimat3/scraping": "19.3.
|
|
64
|
-
"@ultimat3/seo": "19.3.
|
|
65
|
-
"@ultimat3/storage": "19.3.
|
|
66
|
-
"@ultimat3/testing": "19.3.
|
|
67
|
-
"@ultimat3/time": "19.3.
|
|
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
|
}
|
package/src/app-agents-md.ts
CHANGED
|
@@ -15,11 +15,22 @@ export interface AgentsMdOutcome {
|
|
|
15
15
|
readonly warnings: readonly string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
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({
|
|
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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
/**
|
|
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 (
|
|
164
|
+
if (snapshot === undefined) return reloadRoute(absolute, file);
|
|
140
165
|
registered.add(absolute);
|
|
141
166
|
try {
|
|
142
167
|
const config = module['config'];
|
|
143
|
-
|
|
144
|
-
|
|
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`. */
|
package/src/cmd-dev-fixture.ts
CHANGED
|
@@ -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
|
|
94
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
? [...
|
|
320
|
-
: [...
|
|
331
|
+
? [...state.appFindings, ...loops]
|
|
332
|
+
: [...state.appFindings, state.reloadFinding, ...loops];
|
|
321
333
|
},
|
|
322
334
|
running,
|
|
323
335
|
runtime,
|
package/src/cmd-generate.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
/**
|
|
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((
|
|
310
|
+
return routeEntries().map((registered) => ({
|
|
288
311
|
method: 'GET' as const,
|
|
289
|
-
path:
|
|
290
|
-
meta:
|
|
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
|
},
|
package/src/generate-files.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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':
|
package/src/scaffold-fixture.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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
|
-
/**
|
|
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:
|
|
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
|
package/src/templates/action.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/templates/island.ts
CHANGED
|
@@ -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
|
|
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
|
|
13
|
-
//
|
|
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
|
+
}
|
package/src/verify-checks.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
322
|
+
...floorProblemFindings(floor),
|
|
313
323
|
...agents.findings,
|
|
314
324
|
...(await hostFindings(ctx, 'manifest')),
|
|
315
325
|
];
|
package/src/verify-floor.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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:
|
|
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
|
+
};
|