@ultimat3/cli 19.0.0 → 19.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/README.md +24 -0
- package/package.json +29 -29
- package/src/app-mcp.ts +152 -0
- package/src/app-runtime.ts +39 -0
- package/src/bin.ts +18 -0
- package/src/budgets.ts +1 -1
- package/src/cmd-dev-fixture.ts +147 -0
- package/src/cmd-dev.ts +28 -2
- package/src/dev-live-feed.ts +61 -0
- package/src/dev-roles.ts +20 -0
- package/src/dev-sync.ts +5 -1
- package/src/local-cli.ts +71 -0
- package/src/measurement-actor.ts +26 -0
- package/src/messages.ts +1 -0
- package/src/prerender.ts +16 -3
- package/src/serve.ts +26 -3
- package/src/source-files.ts +14 -2
- package/src/templates/entity.ts +7 -0
- package/src/ts-scan.ts +8 -111
package/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
4
4
|
|
|
5
5
|
| Rule | Detail |
|
|
6
6
|
|---|---|
|
|
7
|
-
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
|
|
7
|
+
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only. Before `dispatch`, `local-cli.ts` decides whether THIS process is the app's own CLI: a global `x` inside an app re-executes `node_modules/@ultimat3/cli/src/bin.ts` when that is a different realpath, because a second module instance of `@ultimat3/entity` is an empty registry (a zero-entity manifest, green, 2026-09-05). Same realpath (a workspace symlink), no app, a compiled binary, or `ULTIMATE_KEEP_GLOBAL_CLI` set: no hand-over |
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
9
|
| stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
|
|
10
10
|
| Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
|
package/README.md
CHANGED
|
@@ -34,6 +34,25 @@ Everything in [CLI reference](../../wiki/CLI-Reference.md)'s planned table is al
|
|
|
34
34
|
and exits `X_NOT_IMPLEMENTED` with a `fix:` naming the closest shipped command — "not built yet"
|
|
35
35
|
and "not a command" are different facts.
|
|
36
36
|
|
|
37
|
+
## Which `x` runs
|
|
38
|
+
|
|
39
|
+
**The app's own.** `x` is a workspace dependency, and `bunx x` / the `package.json` scripts resolve
|
|
40
|
+
`node_modules/.bin/x`. A **globally** installed `x` — `bun link` of a checkout, `bun add -g` — is
|
|
41
|
+
a second copy of every `@ultimat3/*` package, and a second copy of `@ultimat3/entity` is a second,
|
|
42
|
+
**empty** registry: the app's entities register into the instance under its `node_modules`, and
|
|
43
|
+
only the CLI under that same `node_modules` can see them. Measured 2026-09-05, in an app run with a
|
|
44
|
+
linked checkout's `x`: `x entities list` answered `0 entities`, `x policy list` answered
|
|
45
|
+
`0 permission(s), 0 role(s)`, and `x manifest` wrote a manifest with **zero entities and zero
|
|
46
|
+
actions** — exit 0, green — which `x db gen` then read as "drop every table".
|
|
47
|
+
|
|
48
|
+
So a global `x` inside an app **hands over** to `node_modules/@ultimat3/cli/src/bin.ts` when that
|
|
49
|
+
is a different file (`local-cli.ts`), prints one line on stderr saying so, and exits with the
|
|
50
|
+
child's code; fd 1 is the child's alone, so a `--json` consumer sees one document. A workspace
|
|
51
|
+
symlink resolves to the same file and is not handed over (both tracked apps, and every scaffold
|
|
52
|
+
CI installs), and a compiled `x` keeps itself — its own path is not one `realpath` can resolve.
|
|
53
|
+
`ULTIMATE_KEEP_GLOBAL_CLI=1` keeps the CLI that was invoked, for the one deliberate case: running a
|
|
54
|
+
checkout's `x` against an app pinned to an older release to see what the next one would say.
|
|
55
|
+
|
|
37
56
|
## The output contract
|
|
38
57
|
|
|
39
58
|
Every command returns one `CommandResult`; the human renderer and the JSON renderer are
|
|
@@ -95,6 +114,11 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
|
|
|
95
114
|
| `cmd-*.ts` | one command group each |
|
|
96
115
|
| `templates/` | scaffolding as typed string modules, not copied fixtures |
|
|
97
116
|
| `app-load.ts` | import an app's modules so the framework registries hold it |
|
|
117
|
+
| `app-mcp.ts` | the app's own MCP endpoint: `apps/<app>/mcp.ts` exports `mcp`, and both boots mount `POST config.ai.mcp.path` through this one call |
|
|
118
|
+
| `app-runtime.ts` | the app's `RuntimeOverrides`: `apps/<app>/runtime.ts` exports `runtime`, read by `x dev` and by `runRole` when its caller passed none |
|
|
119
|
+
| `local-cli.ts` | which `x` runs: a global CLI inside an app hands over to the app's own, because a second module instance is an empty registry |
|
|
120
|
+
| `measurement-actor.ts` | the actor a weigh-and-discard render runs as — every permission, never served |
|
|
121
|
+
| `dev-live-feed.ts` | what feeds the sync node this process booted: the in-process row observer under the embedded database, the WAL decoder with a real one, nothing without the role — `live=` on the ready line |
|
|
98
122
|
| `app-manifest.ts` | `x.manifest.json`, projected by `@ultimat3/manifest` |
|
|
99
123
|
| `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
|
|
100
124
|
| `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.1.0",
|
|
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.
|
|
41
|
-
"@ultimat3/admin": "19.
|
|
42
|
-
"@ultimat3/ai": "19.
|
|
43
|
-
"@ultimat3/auth": "19.
|
|
44
|
-
"@ultimat3/cache": "19.
|
|
45
|
-
"@ultimat3/core": "19.
|
|
46
|
-
"@ultimat3/db": "19.
|
|
47
|
-
"@ultimat3/entity": "19.
|
|
48
|
-
"@ultimat3/flags": "19.
|
|
49
|
-
"@ultimat3/http": "19.
|
|
50
|
-
"@ultimat3/i18n": "19.
|
|
51
|
-
"@ultimat3/jobs": "19.
|
|
52
|
-
"@ultimat3/mail": "19.
|
|
53
|
-
"@ultimat3/manifest": "19.
|
|
54
|
-
"@ultimat3/mcp": "19.
|
|
55
|
-
"@ultimat3/money": "19.
|
|
56
|
-
"@ultimat3/notify": "19.
|
|
57
|
-
"@ultimat3/policy": "19.
|
|
58
|
-
"@ultimat3/pwa": "19.
|
|
59
|
-
"@ultimat3/query": "19.
|
|
60
|
-
"@ultimat3/realtime": "19.
|
|
61
|
-
"@ultimat3/render": "19.
|
|
62
|
-
"@ultimat3/schema": "19.
|
|
63
|
-
"@ultimat3/scraping": "19.
|
|
64
|
-
"@ultimat3/seo": "19.
|
|
65
|
-
"@ultimat3/storage": "19.
|
|
66
|
-
"@ultimat3/testing": "19.
|
|
67
|
-
"@ultimat3/time": "19.
|
|
40
|
+
"@ultimat3/action": "19.1.0",
|
|
41
|
+
"@ultimat3/admin": "19.1.0",
|
|
42
|
+
"@ultimat3/ai": "19.1.0",
|
|
43
|
+
"@ultimat3/auth": "19.1.0",
|
|
44
|
+
"@ultimat3/cache": "19.1.0",
|
|
45
|
+
"@ultimat3/core": "19.1.0",
|
|
46
|
+
"@ultimat3/db": "19.1.0",
|
|
47
|
+
"@ultimat3/entity": "19.1.0",
|
|
48
|
+
"@ultimat3/flags": "19.1.0",
|
|
49
|
+
"@ultimat3/http": "19.1.0",
|
|
50
|
+
"@ultimat3/i18n": "19.1.0",
|
|
51
|
+
"@ultimat3/jobs": "19.1.0",
|
|
52
|
+
"@ultimat3/mail": "19.1.0",
|
|
53
|
+
"@ultimat3/manifest": "19.1.0",
|
|
54
|
+
"@ultimat3/mcp": "19.1.0",
|
|
55
|
+
"@ultimat3/money": "19.1.0",
|
|
56
|
+
"@ultimat3/notify": "19.1.0",
|
|
57
|
+
"@ultimat3/policy": "19.1.0",
|
|
58
|
+
"@ultimat3/pwa": "19.1.0",
|
|
59
|
+
"@ultimat3/query": "19.1.0",
|
|
60
|
+
"@ultimat3/realtime": "19.1.0",
|
|
61
|
+
"@ultimat3/render": "19.1.0",
|
|
62
|
+
"@ultimat3/schema": "19.1.0",
|
|
63
|
+
"@ultimat3/scraping": "19.1.0",
|
|
64
|
+
"@ultimat3/seo": "19.1.0",
|
|
65
|
+
"@ultimat3/storage": "19.1.0",
|
|
66
|
+
"@ultimat3/testing": "19.1.0",
|
|
67
|
+
"@ultimat3/time": "19.1.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-mcp.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The app's own MCP endpoint, mounted by the web role. `defineAppMcp` built `mcp.route` — a
|
|
2
|
+
// `POST` handler with token auth and per-class rate limits — and `app.config.ts` declared
|
|
3
|
+
// `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
|
|
4
|
+
// neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
|
|
5
|
+
// every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
|
|
6
|
+
// exports `mcp`, an `AppMcp`; this module finds it, and both boots mount what it carries.
|
|
7
|
+
|
|
8
|
+
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join primitive; the config file and each candidate are joined to root.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { logger } from '@ultimat3/core';
|
|
13
|
+
import type { Route } from '@ultimat3/http';
|
|
14
|
+
import { type AppMcp, McpAppUnmountedError } from '@ultimat3/mcp';
|
|
15
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
16
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
17
|
+
|
|
18
|
+
/** The one file an app writes, per app directory. */
|
|
19
|
+
export const APP_MCP_GLOB = 'apps/*/mcp.ts';
|
|
20
|
+
/** The export that file makes — an `AppMcp`, the value `defineAppMcp` returns. */
|
|
21
|
+
export const APP_MCP_EXPORT = 'mcp';
|
|
22
|
+
/** What the boot line and `/_x` call the route. */
|
|
23
|
+
export const APP_MCP_ROUTE_NAME = 'mcp';
|
|
24
|
+
|
|
25
|
+
export interface AppMcpMount {
|
|
26
|
+
/** `[]` when `expose` is false, when nothing exports `mcp`, or when the export has no route. */
|
|
27
|
+
readonly routes: readonly Route[];
|
|
28
|
+
/** `POST <path>` when mounted, else `null` — the boot line prints it. */
|
|
29
|
+
readonly path: string | null;
|
|
30
|
+
/** Set exactly when `expose` is true and `routes` is empty: the reason, as an instruction. */
|
|
31
|
+
readonly warning: McpAppUnmountedError | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExposeDeclaration {
|
|
35
|
+
readonly expose: boolean;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
40
|
+
typeof value === 'object' && value !== null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `config.ai.mcp`, off the app's own resolved config — the exported `config` is what
|
|
44
|
+
* `defineConfig` returned, so both keys are present and defaulted. Read the same way
|
|
45
|
+
* `loadSignInPath` reads `auth.signInPath`: the config file is imported, never re-parsed.
|
|
46
|
+
* An app with no config file has nothing exposed and nothing to warn about.
|
|
47
|
+
*/
|
|
48
|
+
async function exposeDeclaration(root: string): Promise<ExposeDeclaration | undefined> {
|
|
49
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
50
|
+
if (!(await Bun.file(configPath).exists())) return undefined;
|
|
51
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
52
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
53
|
+
if (!isRecord(config) || !isRecord(config['ai']) || !isRecord(config['ai']['mcp'])) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const mcp = config['ai']['mcp'];
|
|
57
|
+
const path = mcp['path'];
|
|
58
|
+
return {
|
|
59
|
+
expose: mcp['expose'] === true,
|
|
60
|
+
path: typeof path === 'string' && path.startsWith('/') ? path : '/mcp',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isAppMcp = (value: unknown): value is AppMcp =>
|
|
65
|
+
isRecord(value) && 'server' in value && 'tools' in value && 'route' in value;
|
|
66
|
+
|
|
67
|
+
/** Every `apps/<app>/mcp.ts`, app-root-relative and sorted, so two apps answer in one order. */
|
|
68
|
+
async function candidates(root: string): Promise<readonly string[]> {
|
|
69
|
+
// A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
|
|
70
|
+
// is answered as "none", and the warning below says which file to write.
|
|
71
|
+
if (!existsSync(join(root, 'apps'))) return [];
|
|
72
|
+
const files: string[] = [];
|
|
73
|
+
for await (const file of new Bun.Glob(APP_MCP_GLOB).scan({ cwd: root })) files.push(file);
|
|
74
|
+
return files.sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The route to mount, or the reason there is none. Pure over the filesystem it is pointed at;
|
|
79
|
+
* `mountAppMcp` below is the one place the warning becomes a log line.
|
|
80
|
+
*
|
|
81
|
+
* `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
|
|
82
|
+
* `mcp.route.handle` is the one evaluation, and it reads `Authorization: Bearer` through the
|
|
83
|
+
* `resolveToken` the app gave `defineAppMcp`, then decides per tool through the same policy every
|
|
84
|
+
* other surface evaluates. A pipeline `auth: 'required'` would demand a session cookie an agent
|
|
85
|
+
* does not have and answer 401 before the token was ever read.
|
|
86
|
+
*/
|
|
87
|
+
export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
88
|
+
const declared = await exposeDeclaration(root);
|
|
89
|
+
if (declared === undefined || !declared.expose)
|
|
90
|
+
return { routes: [], path: null, warning: undefined };
|
|
91
|
+
const files = await candidates(root);
|
|
92
|
+
const fallbackFile = 'apps/web/mcp.ts';
|
|
93
|
+
if (files.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
routes: [],
|
|
96
|
+
path: null,
|
|
97
|
+
warning: new McpAppUnmountedError({
|
|
98
|
+
reason: 'missing',
|
|
99
|
+
path: declared.path,
|
|
100
|
+
file: fallbackFile,
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
106
|
+
const exported = module[APP_MCP_EXPORT];
|
|
107
|
+
if (!isAppMcp(exported)) continue;
|
|
108
|
+
const route = exported.route;
|
|
109
|
+
if (route === undefined) {
|
|
110
|
+
return {
|
|
111
|
+
routes: [],
|
|
112
|
+
path: null,
|
|
113
|
+
warning: new McpAppUnmountedError({ reason: 'no-route', path: declared.path, file }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
routes: [
|
|
118
|
+
{
|
|
119
|
+
method: 'POST',
|
|
120
|
+
path: declared.path,
|
|
121
|
+
handler: (request) => route.handle(request.raw),
|
|
122
|
+
meta: { name: APP_MCP_ROUTE_NAME, auth: 'public', enforcedBy: 'handler' },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
path: declared.path,
|
|
126
|
+
warning: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
routes: [],
|
|
131
|
+
path: null,
|
|
132
|
+
warning: new McpAppUnmountedError({
|
|
133
|
+
reason: 'missing',
|
|
134
|
+
path: declared.path,
|
|
135
|
+
file: files[0] ?? fallbackFile,
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The boot's call: the routes to spread into the table, with the warning already logged ONCE and
|
|
142
|
+
* the mount announced. Both `x dev` and `runRole` go through here, so a developer's terminal and a
|
|
143
|
+
* container's log say the same thing about the same endpoint.
|
|
144
|
+
*/
|
|
145
|
+
export async function mountAppMcp(root: string): Promise<AppMcpMount> {
|
|
146
|
+
const mount = await appMcpMount(root);
|
|
147
|
+
if (mount.warning !== undefined) {
|
|
148
|
+
logger.warn(`${mount.warning.code}: ${mount.warning.cause} — fix: ${mount.warning.fix}`);
|
|
149
|
+
}
|
|
150
|
+
if (mount.path !== null) logger.info('app mcp mounted', { method: 'POST', path: mount.path });
|
|
151
|
+
return mount;
|
|
152
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The app's `RuntimeOverrides`, found rather than handed over. `runRole({ runtime })` has taken
|
|
2
|
+
// them since the seam existed, and the scaffolded `apps/web/server.ts` passes none — so an app's
|
|
3
|
+
// own middleware reached no process the framework boots, and `x dev` had no parameter to reach at
|
|
4
|
+
// all (measured 2026-09-05: `x dev` passed only the read-replica override). The contract is one
|
|
5
|
+
// file: `apps/<app>/runtime.ts` exports `runtime`, a `RuntimeOverrides`; `x dev` reads it, and
|
|
6
|
+
// `runRole` reads it when its caller passed nothing, so the two boots compose the same chain.
|
|
7
|
+
|
|
8
|
+
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join primitive; each candidate is joined to the app root.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
13
|
+
|
|
14
|
+
/** The one file an app writes, per app directory. */
|
|
15
|
+
export const APP_RUNTIME_GLOB = 'apps/*/runtime.ts';
|
|
16
|
+
/** The export that file makes — a `RuntimeOverrides`. */
|
|
17
|
+
export const APP_RUNTIME_EXPORT = 'runtime';
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
+
typeof value === 'object' && value !== null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first `apps/<app>/runtime.ts` exporting `runtime`, or `undefined` when no app declares one.
|
|
24
|
+
* Sorted, so two apps answer in one order; the object is handed on as declared, because every key
|
|
25
|
+
* of `RuntimeOverrides` already means "replace the resolved default" wherever a boot reads it.
|
|
26
|
+
*/
|
|
27
|
+
export async function loadAppRuntime(root: string): Promise<RuntimeOverrides | undefined> {
|
|
28
|
+
// A root with no `apps/` — a bare test fixture, a directory that does not exist — is no app
|
|
29
|
+
// declaring an override, never a boot failure: the scan's ENOENT is answered as "none".
|
|
30
|
+
if (!existsSync(join(root, 'apps'))) return undefined;
|
|
31
|
+
const files: string[] = [];
|
|
32
|
+
for await (const file of new Bun.Glob(APP_RUNTIME_GLOB).scan({ cwd: root })) files.push(file);
|
|
33
|
+
for (const file of files.sort()) {
|
|
34
|
+
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
35
|
+
const exported = module[APP_RUNTIME_EXPORT];
|
|
36
|
+
if (isRecord(exported)) return exported as RuntimeOverrides;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
package/src/bin.ts
CHANGED
|
@@ -3,12 +3,30 @@
|
|
|
3
3
|
// dispatch.ts, so the whole CLI is testable without spawning a process.
|
|
4
4
|
|
|
5
5
|
import { dispatch } from './dispatch';
|
|
6
|
+
import { resolveLocalCli } from './local-cli';
|
|
6
7
|
// The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
|
|
7
8
|
// one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
|
|
8
9
|
// because fd 1 is not always this process's to write on — `x mcp serve --transport stdio` hands it
|
|
9
10
|
// to the protocol, and `dispatch` addresses that result to the second.
|
|
10
11
|
import { writeErrorLine, writeLine } from './write-line';
|
|
11
12
|
|
|
13
|
+
// An app's own CLI sees the app's entity registry; a global one does not. Hand over before
|
|
14
|
+
// deciding anything — see local-cli.ts for the zero-entity manifest this prevents. On fd 2, so a
|
|
15
|
+
// `--json` consumer reading fd 1 sees exactly the child's one document. `process.execPath` is the
|
|
16
|
+
// Bun that is already running, never a `PATH` lookup: the child's `import.meta.path` resolves to
|
|
17
|
+
// the app's file, so its own `resolveLocalCli` answers "same file" and the chain stops at one hop.
|
|
18
|
+
const local = resolveLocalCli({ cwd: process.cwd(), selfPath: import.meta.path, env: Bun.env });
|
|
19
|
+
if (local !== undefined) {
|
|
20
|
+
writeErrorLine(`x: using the app's own @ultimat3/cli at ${local}`);
|
|
21
|
+
const child = Bun.spawn([process.execPath, local, ...Bun.argv.slice(2)], {
|
|
22
|
+
stdin: 'inherit',
|
|
23
|
+
stdout: 'inherit',
|
|
24
|
+
stderr: 'inherit',
|
|
25
|
+
env: Bun.env,
|
|
26
|
+
});
|
|
27
|
+
process.exit(await child.exited);
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
const code = await dispatch({
|
|
13
31
|
argv: Bun.argv.slice(2),
|
|
14
32
|
cwd: process.cwd(),
|
package/src/budgets.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function checkBudgets(
|
|
|
111
111
|
if (js !== null && measured.jsBytes > js) {
|
|
112
112
|
findings.push({
|
|
113
113
|
code: 'X_BUDGET_EXCEEDED',
|
|
114
|
-
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
|
|
114
|
+
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
|
|
115
115
|
fix: `x routes --json to see the chain, then move the heavy import behind hydrate: 'interaction'`,
|
|
116
116
|
docs: ERROR_DOCS_URL,
|
|
117
117
|
at: route.url,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The app `x dev` boots in its tests, and the one place it is declared: every assertion that
|
|
2
|
+
// needs a booted app lives in `cmd-dev.test.ts`, because a process has ONE lifecycle — the second
|
|
3
|
+
// in-process boot is refused (X_LIFECYCLE_DRAINED) — and the coverage gate runs a package in one
|
|
4
|
+
// process. So the fixture is a module of its own, and that file keeps its line budget for tests.
|
|
5
|
+
//
|
|
6
|
+
// What the app declares, and what each declaration is here to prove:
|
|
7
|
+
// app.config.ts the root marker a real `x dev` cannot start without; `ai.mcp` by default
|
|
8
|
+
// apps/web/mcp.ts the app's own MCP endpoint, mounted by the web role
|
|
9
|
+
// apps/web/runtime.ts the app's middleware, reaching a development process
|
|
10
|
+
// apps/web/app/notes/* a memory-backed entity and a live query, fed by the in-process bridge
|
|
11
|
+
// apps/web/app/posts/* an action, a policy and a query, mounted as HTTP routes
|
|
12
|
+
// apps/web/site/pricing/* a static page with its own stylesheet, under the CSP `x dev` sends
|
|
13
|
+
import { resetRegistry as resetActions } from '@ultimat3/action';
|
|
14
|
+
import { clearRegistry as clearEntities } from '@ultimat3/entity';
|
|
15
|
+
import { resetJobs, resetTasks } from '@ultimat3/jobs';
|
|
16
|
+
import { clearPermissions, clearRoles } from '@ultimat3/policy';
|
|
17
|
+
import { resetRegistry as resetQueries } from '@ultimat3/query';
|
|
18
|
+
import type { Frame } from '@ultimat3/realtime';
|
|
19
|
+
import { decode } from '@ultimat3/realtime';
|
|
20
|
+
import type { WsLike } from '@ultimat3/realtime/server';
|
|
21
|
+
import { clearRoutes } from '@ultimat3/render';
|
|
22
|
+
import { resetAppLoad } from './app-load';
|
|
23
|
+
|
|
24
|
+
export const DEV_FIXTURE_FILES: Readonly<Record<string, string>> = {
|
|
25
|
+
'package.json': JSON.stringify({ name: 'dev-fixture', version: '1.4.0' }),
|
|
26
|
+
|
|
27
|
+
// The root marker a real `x dev` cannot start without, and where `ai.mcp` is declared — by
|
|
28
|
+
// default `{ expose: true, path: '/mcp' }`, which is what the MCP mount reads.
|
|
29
|
+
'app.config.ts': `import { defineConfig } from '@ultimat3/core';
|
|
30
|
+
export const config = defineConfig({ name: 'dev-fixture' });
|
|
31
|
+
`,
|
|
32
|
+
|
|
33
|
+
// The app's own MCP endpoint, in the contract `app-mcp.ts` reads. `resolveToken` answering
|
|
34
|
+
// `null` rejects every bearer, which is enough to prove the ROUTE is mounted (401, not 404).
|
|
35
|
+
'apps/web/mcp.ts': `import { defineAppMcp } from '@ultimat3/mcp';
|
|
36
|
+
export const mcp = defineAppMcp({ include: 'exposed', resolveToken: () => null });
|
|
37
|
+
`,
|
|
38
|
+
|
|
39
|
+
// The app's own middleware, in the contract `app-runtime.ts` reads: a header on every response
|
|
40
|
+
// is the cheapest proof that the chain `x dev` composed is the app's and not only the replica's.
|
|
41
|
+
'apps/web/runtime.ts': `const stamp = async (request, ctx, next) => {
|
|
42
|
+
const response = await next(request, ctx);
|
|
43
|
+
const headers = new Headers(response.headers);
|
|
44
|
+
headers.set('x-dev-runtime', 'app');
|
|
45
|
+
return new Response(response.body, { status: response.status, headers });
|
|
46
|
+
};
|
|
47
|
+
export const runtime = { middleware: [stamp] };
|
|
48
|
+
`,
|
|
49
|
+
|
|
50
|
+
// A memory-backed entity, so the fixture needs no migration: the row observer sits on
|
|
51
|
+
// `database()`'s repo wrapper, the same seam a Postgres-backed repo writes through.
|
|
52
|
+
'apps/web/app/notes/entity.ts': `import { database, entity, memoryDriver, text, uuid } from '@ultimat3/entity';
|
|
53
|
+
export const notes = entity('notes', { columns: { id: uuid().primaryKey(), title: text() } });
|
|
54
|
+
export const db = database({ notes }, { driver: memoryDriver() });
|
|
55
|
+
`,
|
|
56
|
+
'apps/web/app/notes/live.ts': `import { allow } from '@ultimat3/policy';
|
|
57
|
+
import { from, query, t } from '@ultimat3/query';
|
|
58
|
+
import { db } from './entity';
|
|
59
|
+
export const liveNotes = query({
|
|
60
|
+
input: t.object({}),
|
|
61
|
+
policy: allow('public'),
|
|
62
|
+
live: true,
|
|
63
|
+
subscribes: ['notes'],
|
|
64
|
+
sql: () =>
|
|
65
|
+
from<{ id: string; title: string }>('notes', () => db.notes.where({}).all())
|
|
66
|
+
.orderBy('id')
|
|
67
|
+
.limit(50),
|
|
68
|
+
});
|
|
69
|
+
`,
|
|
70
|
+
|
|
71
|
+
'apps/web/app/posts/policy.ts': `import { allow, can, definePermissions, defineRoles } from '@ultimat3/policy';
|
|
72
|
+
export const permissions = definePermissions(['post:publish'] as const);
|
|
73
|
+
export const roles = defineRoles({
|
|
74
|
+
author: { grants: ['post:publish'] },
|
|
75
|
+
reader: { grants: [] },
|
|
76
|
+
});
|
|
77
|
+
export const canPostWrite = can('post:publish');
|
|
78
|
+
export const anyone = allow();
|
|
79
|
+
`,
|
|
80
|
+
|
|
81
|
+
'apps/web/app/posts/actions.ts': `import { action, t } from '@ultimat3/action';
|
|
82
|
+
import { anyone, canPostWrite } from './policy';
|
|
83
|
+
|
|
84
|
+
export const publishPost = action({
|
|
85
|
+
input: t.object({ id: t.uuid }),
|
|
86
|
+
output: t.object({ id: t.uuid }),
|
|
87
|
+
policy: canPostWrite,
|
|
88
|
+
async handle({ input }) {
|
|
89
|
+
return { id: input.id };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const echoPost = action({
|
|
94
|
+
input: t.object({ word: t.string }),
|
|
95
|
+
output: t.object({ word: t.string }),
|
|
96
|
+
policy: anyone,
|
|
97
|
+
async handle({ input }) {
|
|
98
|
+
return { word: input.word };
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
`,
|
|
102
|
+
|
|
103
|
+
// A stylesheet the page imports, because that import is what registers it — and the document's
|
|
104
|
+
// inline `<style>` is what the CSP has to name. Without one this file served no styled page and
|
|
105
|
+
// could not have caught the policy that blanked every deployed app.
|
|
106
|
+
'apps/web/site/pricing/page.module.scss': `.price { color: #123456; }
|
|
107
|
+
`,
|
|
108
|
+
|
|
109
|
+
'apps/web/site/pricing/page.tsx': `import { defineRoute } from '@ultimat3/render';
|
|
110
|
+
import './page.module.scss';
|
|
111
|
+
|
|
112
|
+
export const config = defineRoute({
|
|
113
|
+
render: 'static',
|
|
114
|
+
offline: 'precache',
|
|
115
|
+
hydrate: 'never',
|
|
116
|
+
budget: { js: '0kb' },
|
|
117
|
+
meta: () => ({ title: 'Pricing', description: 'What it costs' }),
|
|
118
|
+
});
|
|
119
|
+
`,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const resetRegistries = (): void => {
|
|
123
|
+
resetActions();
|
|
124
|
+
resetQueries();
|
|
125
|
+
clearEntities();
|
|
126
|
+
clearRoutes();
|
|
127
|
+
resetJobs();
|
|
128
|
+
resetTasks();
|
|
129
|
+
clearPermissions();
|
|
130
|
+
clearRoles();
|
|
131
|
+
resetAppLoad();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** A `WsLike` that keeps every frame the node sends, decoded — what a live assertion reads. */
|
|
135
|
+
export class FakeWs implements WsLike {
|
|
136
|
+
readonly frames: Frame[] = [];
|
|
137
|
+
send(data: string): number {
|
|
138
|
+
this.frames.push(decode(data));
|
|
139
|
+
return data.length;
|
|
140
|
+
}
|
|
141
|
+
close(): void {}
|
|
142
|
+
subscribe(): void {}
|
|
143
|
+
unsubscribe(): void {}
|
|
144
|
+
getBufferedAmount(): number {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/cmd-dev.ts
CHANGED
|
@@ -19,11 +19,14 @@ import { apiRoutes } from './api-routes';
|
|
|
19
19
|
import { loadSignInPath } from './app-auth';
|
|
20
20
|
import { loadApp } from './app-load';
|
|
21
21
|
import { appManifest } from './app-manifest';
|
|
22
|
+
import { mountAppMcp } from './app-mcp';
|
|
22
23
|
import { requireAppRoot } from './app-root';
|
|
24
|
+
import { loadAppRuntime } from './app-runtime';
|
|
23
25
|
import type { CliCommand, CommandContext } from './command';
|
|
24
26
|
import { assetRoutes } from './dev-assets';
|
|
25
27
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
26
28
|
import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
29
|
+
import { liveFeedLabel } from './dev-live-feed';
|
|
27
30
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
28
31
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
29
32
|
import { appRoutes } from './dev-render';
|
|
@@ -71,6 +74,8 @@ export interface DevServer {
|
|
|
71
74
|
readonly runtime: RunningServices;
|
|
72
75
|
/** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
|
|
73
76
|
readonly panels: readonly string[];
|
|
77
|
+
/** `POST <path>` of the app's own MCP endpoint, or `null` when nothing was mounted. */
|
|
78
|
+
readonly mcp: string | null;
|
|
74
79
|
stop(): Promise<void>;
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -188,11 +193,16 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
188
193
|
? undefined
|
|
189
194
|
: serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands: state.islands });
|
|
190
195
|
|
|
196
|
+
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
197
|
+
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
198
|
+
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
199
|
+
const mcpMount = await mountAppMcp(options.root);
|
|
191
200
|
const routes: readonly Route[] = [
|
|
192
201
|
...devDashboardRoutes(dashboard),
|
|
193
202
|
// The same API table the container serves: a read that answers here and 404s in production
|
|
194
203
|
// is exactly the drift one composition exists to prevent.
|
|
195
204
|
...apiRoutes(),
|
|
205
|
+
...mcpMount.routes,
|
|
196
206
|
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
197
207
|
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
198
208
|
// never shadow `/icons` or `/media`.
|
|
@@ -222,7 +232,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
222
232
|
}),
|
|
223
233
|
];
|
|
224
234
|
|
|
225
|
-
|
|
235
|
+
// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
|
|
236
|
+
// `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
|
|
237
|
+
// first argument was `undefined` here and an app's middleware reached no development process.
|
|
238
|
+
const replicaOverride = replicaOverrides(
|
|
239
|
+
await loadAppRuntime(options.root),
|
|
240
|
+
services.db,
|
|
241
|
+
options.env,
|
|
242
|
+
);
|
|
226
243
|
const running = await startRoles({
|
|
227
244
|
roles: options.roles ?? DEV_ROLES,
|
|
228
245
|
port: options.port,
|
|
@@ -278,6 +295,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
278
295
|
url: running.url ?? `http://localhost:${options.port}`,
|
|
279
296
|
services,
|
|
280
297
|
roles: running.roles,
|
|
298
|
+
mcp: mcpMount.path,
|
|
281
299
|
get buildId(): string {
|
|
282
300
|
return state.manifest.buildId;
|
|
283
301
|
},
|
|
@@ -380,7 +398,7 @@ export const devCommand: CliCommand = {
|
|
|
380
398
|
panels: server.panels.length,
|
|
381
399
|
// Rendered text, so the mail and CDN halves come from the catalog; `data` below carries the
|
|
382
400
|
// status values a script parses, which is why the two are different calls and not one.
|
|
383
|
-
services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)}`,
|
|
401
|
+
services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)} ${liveFeedLabel(server.running.liveFeed)}`,
|
|
384
402
|
}),
|
|
385
403
|
findings: server.findings,
|
|
386
404
|
// Every fact `lines` prints is a fact `--json` carries, `manifest` included — or the two
|
|
@@ -405,10 +423,15 @@ export const devCommand: CliCommand = {
|
|
|
405
423
|
// on one database is the one topology mistake that cannot be seen from the outside, so the
|
|
406
424
|
// slot is a scriptable fact rather than a line in a log.
|
|
407
425
|
replicationSlot: server.running.replicator?.slot ?? null,
|
|
426
|
+
// Which change feed the sync node has: `in-process` under the embedded database, where
|
|
427
|
+
// this process's own writes reach subscribers; `replication` with a real one; `none`
|
|
428
|
+
// when no sync role runs here. The label on the ready line is this same fact.
|
|
429
|
+
liveFeed: server.running.liveFeed,
|
|
408
430
|
buildId: server.buildId,
|
|
409
431
|
manifest: join(root, MANIFEST_FILENAME),
|
|
410
432
|
introspect: `${server.url}/_x`,
|
|
411
433
|
panels: [...server.panels],
|
|
434
|
+
mcp: server.mcp,
|
|
412
435
|
},
|
|
413
436
|
lines: [
|
|
414
437
|
// A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
|
|
@@ -418,6 +441,9 @@ export const devCommand: CliCommand = {
|
|
|
418
441
|
msg('cli.dev.panels', { panels: server.panels.join(', ') }),
|
|
419
442
|
msg('cli.dev.manifest', { path: join(root, MANIFEST_FILENAME) }),
|
|
420
443
|
msg('cli.dev.introspect', { url: `${server.url}/_x` }),
|
|
444
|
+
// Only when something was mounted: the unmounted case has already said why, once, as a
|
|
445
|
+
// warning with a fix, and a summary line reading `mcp none` would be a second copy of it.
|
|
446
|
+
...(server.mcp === null ? [] : [msg('cli.dev.mcp', { path: server.mcp })]),
|
|
421
447
|
],
|
|
422
448
|
};
|
|
423
449
|
await writeLock(services.stateDir, {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Where a `sync` node's changes come from when this process boots one. Production decodes the
|
|
2
|
+
// write-ahead log (`x dev --role replicator`, `PgLogicalReplicationFeed`, a real `DATABASE_URL`).
|
|
3
|
+
// The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
|
|
4
|
+
// subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
|
|
5
|
+
// every scaffolded app was dead in development — which is where an author first tries one.
|
|
6
|
+
//
|
|
7
|
+
// The bridge is `@ultimat3/testing`'s `startLiveReplicator`, the same in-process row observer the
|
|
8
|
+
// framework's own live tests run on: a repository write in THIS process becomes a `ChangeEvent`
|
|
9
|
+
// shaped exactly as the WAL decoder shapes one, fanned into the node's registry. Its honest bound
|
|
10
|
+
// is stated there and repeated here — a write another process makes is invisible — and `x dev` is
|
|
11
|
+
// the one boot where that bound holds by construction: every role runs in this one process.
|
|
12
|
+
|
|
13
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
+
import type { LiveReplicator } from '@ultimat3/testing';
|
|
15
|
+
import { startLiveReplicator } from '@ultimat3/testing';
|
|
16
|
+
import type { ServiceMode } from './dev-services';
|
|
17
|
+
import type { RunningSync } from './dev-sync';
|
|
18
|
+
|
|
19
|
+
/** What feeds the sync node, said out loud on the boot line and in `--json`. */
|
|
20
|
+
export type LiveFeed = 'in-process' | 'replication' | 'none';
|
|
21
|
+
|
|
22
|
+
export interface RunningLiveFeed {
|
|
23
|
+
readonly feed: LiveFeed;
|
|
24
|
+
/** The installed bridge, so a test can await `settled()`; `null` for the other two feeds. */
|
|
25
|
+
readonly bridge: LiveReplicator | null;
|
|
26
|
+
stop(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LiveFeedInput {
|
|
30
|
+
/** The sync node this process booted, or `null` when the role was not selected. */
|
|
31
|
+
readonly sync: RunningSync | null;
|
|
32
|
+
/** The database's binding: `embedded` is PGlite, which has no log to decode. */
|
|
33
|
+
readonly dbMode: ServiceMode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
|
|
37
|
+
export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `embedded` → the in-process bridge; anything else → replication, which is the WAL decoder's
|
|
41
|
+
* job whether the replicator role runs in this process or another. Never both: with a real
|
|
42
|
+
* database the decoder already delivers this process's own writes, and a bridge beside it would
|
|
43
|
+
* deliver every one of them twice. No sync node, no feed to speak of.
|
|
44
|
+
*/
|
|
45
|
+
export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
|
|
46
|
+
if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
|
|
47
|
+
if (input.dbMode !== 'embedded') {
|
|
48
|
+
return { feed: 'replication', bridge: null, stop: () => undefined };
|
|
49
|
+
}
|
|
50
|
+
const bridge = await startLiveReplicator({
|
|
51
|
+
registry: input.sync.registry,
|
|
52
|
+
// Logged, never thrown: one change nobody could fan out must not take the dev server down.
|
|
53
|
+
onError: (error) =>
|
|
54
|
+
logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
|
|
55
|
+
});
|
|
56
|
+
logger.info('live feed in-process', {
|
|
57
|
+
detail:
|
|
58
|
+
'the embedded database has no walsender; repository writes in this process reach subscribers',
|
|
59
|
+
});
|
|
60
|
+
return { feed: 'in-process', bridge, stop: () => bridge.stop() };
|
|
61
|
+
}
|
package/src/dev-roles.ts
CHANGED
|
@@ -26,7 +26,11 @@ import {
|
|
|
26
26
|
jobDriver,
|
|
27
27
|
pgSchedulerState,
|
|
28
28
|
} from '@ultimat3/jobs';
|
|
29
|
+
import type { LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
30
|
+
import type { LiveReplicator } from '@ultimat3/testing';
|
|
29
31
|
import { devHooks } from './dev-hooks';
|
|
32
|
+
import type { LiveFeed } from './dev-live-feed';
|
|
33
|
+
import { startLiveFeed } from './dev-live-feed';
|
|
30
34
|
import { pgExecutorFor } from './dev-queue';
|
|
31
35
|
import type { RunningReplicator } from './dev-replicator';
|
|
32
36
|
import { startReplicator } from './dev-replicator';
|
|
@@ -130,6 +134,12 @@ export interface RunningRoles {
|
|
|
130
134
|
readonly scheduler: Scheduler | null;
|
|
131
135
|
/** The slot and feed this process holds; null when the replicator was not selected. */
|
|
132
136
|
readonly replicator: RunningReplicator | null;
|
|
137
|
+
/** What feeds the sync node: this process's own writes, the WAL decoder, or nothing. */
|
|
138
|
+
readonly liveFeed: LiveFeed;
|
|
139
|
+
/** The in-process bridge when `liveFeed` is `in-process`, so a test can await `settled()`. */
|
|
140
|
+
readonly liveBridge: LiveReplicator | null;
|
|
141
|
+
/** The sync node's registry, so a test can hold a real subscription; null without the role. */
|
|
142
|
+
readonly liveRegistry: LiveQueryRegistry | null;
|
|
133
143
|
stop(): Promise<void>;
|
|
134
144
|
}
|
|
135
145
|
|
|
@@ -426,6 +436,12 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
426
436
|
: null;
|
|
427
437
|
if (replicator !== null) started.push(() => replicator.stop());
|
|
428
438
|
|
|
439
|
+
// What feeds the sync node this process booted. The embedded database has no log to decode,
|
|
440
|
+
// so under it the node is fed by this process's own repository writes (`dev-live-feed.ts`);
|
|
441
|
+
// with a real database the WAL decoder above is the feed, here or in another process.
|
|
442
|
+
const live = await startLiveFeed({ sync, dbMode: options.runtime.services.db.mode });
|
|
443
|
+
started.push(async () => live.stop());
|
|
444
|
+
|
|
429
445
|
return {
|
|
430
446
|
roles: selected,
|
|
431
447
|
url: server === null ? null : server.url(),
|
|
@@ -435,8 +451,12 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
435
451
|
worker,
|
|
436
452
|
scheduler,
|
|
437
453
|
replicator,
|
|
454
|
+
liveFeed: live.feed,
|
|
455
|
+
liveBridge: live.bridge,
|
|
456
|
+
liveRegistry: sync?.registry ?? null,
|
|
438
457
|
async stop() {
|
|
439
458
|
// Reverse boot order, so the slot is released before the bus it published to closes.
|
|
459
|
+
live.stop();
|
|
440
460
|
await replicator?.stop();
|
|
441
461
|
await scheduler?.stop();
|
|
442
462
|
// Before the worker, so nothing publishes into a queue whose consumer has already gone —
|
package/src/dev-sync.ts
CHANGED
|
@@ -96,6 +96,8 @@ export function syncPortFor(port: number): number {
|
|
|
96
96
|
/** What `startRoles` holds on to: where the node listens, and how to take it down. */
|
|
97
97
|
export interface RunningSync {
|
|
98
98
|
readonly url: string;
|
|
99
|
+
/** The node's registry, so the boot can hand it a change feed the database cannot produce. */
|
|
100
|
+
readonly registry: LiveQueryRegistry;
|
|
99
101
|
stop(): Promise<void>;
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -154,9 +156,10 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
154
156
|
// override is how a deployment states a window its credential already declares (a token's
|
|
155
157
|
// `exp`), or resolves identity from a header the adapter deliberately does not retain.
|
|
156
158
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
159
|
+
const registry = registerLiveQueries(options);
|
|
157
160
|
const node = createSyncNode({
|
|
158
161
|
hub,
|
|
159
|
-
registry
|
|
162
|
+
registry,
|
|
160
163
|
transport: options.runtime.transport,
|
|
161
164
|
buildId: options.buildId,
|
|
162
165
|
sockets,
|
|
@@ -177,6 +180,7 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
177
180
|
const listener = listenSyncNode(node, { port });
|
|
178
181
|
return {
|
|
179
182
|
url: listener.url,
|
|
183
|
+
registry,
|
|
180
184
|
stop: async () => {
|
|
181
185
|
listener.stop();
|
|
182
186
|
await node.stop();
|
package/src/local-cli.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// A globally installed `x` defers to the app's own `@ultimat3/cli` when the two are different
|
|
2
|
+
// files. Measured 2026-09-05 in an app whose global `x` was a `bun link` of this checkout: the
|
|
3
|
+
// global CLI's `@ultimat3/entity` was a second module instance with an EMPTY registry, so `x g`,
|
|
4
|
+
// `x db gen` and `x manifest` wrote a manifest with zero entities and proposed dropping every
|
|
5
|
+
// table — silently, with a green exit code. The app's entities register into the instance under
|
|
6
|
+
// its `node_modules`; only the CLI under that same `node_modules` can see them. So the rule is the
|
|
7
|
+
// one `tsc` and `eslint` follow: the project-local binary wins, and the global one only says so.
|
|
8
|
+
//
|
|
9
|
+
// The same registry split is what turned `x verify` green over that app: `@ultimat3/policy`'s
|
|
10
|
+
// `isKnownPermission` deliberately checks nothing while no permission is declared, and the global
|
|
11
|
+
// CLI's instance had none declared — so the `policy` step that exists to refuse an undeclared
|
|
12
|
+
// grant (`app-permissions.ts`) had an empty set to refuse against. One process, one registry.
|
|
13
|
+
|
|
14
|
+
// why: `realpathSync` is the whole decision — Bun ships no symlink-resolving stat of its own, and
|
|
15
|
+
// `Bun.file(path).exists()` follows a link without saying where it went.
|
|
16
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
17
|
+
// why: Bun exposes no path-join primitive; the local bin is assembled from the app root.
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { findAppRoot } from './app-root';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Set to any value to keep the CLI that was invoked. Nothing in this repository's CI needs it —
|
|
23
|
+
* both tracked apps symlink `node_modules/@ultimat3/cli` to `packages/cli`, so the realpath test
|
|
24
|
+
* below already answers "same file" — it exists for the one deliberate case: running a checkout's
|
|
25
|
+
* CLI against an app that pins an older release, to see what the next release would say.
|
|
26
|
+
*/
|
|
27
|
+
export const KEEP_GLOBAL_CLI_ENV = 'ULTIMATE_KEEP_GLOBAL_CLI';
|
|
28
|
+
|
|
29
|
+
export const LOCAL_CLI_BIN = join('node_modules', '@ultimat3', 'cli', 'src', 'bin.ts');
|
|
30
|
+
|
|
31
|
+
export interface LocalCliIo {
|
|
32
|
+
exists(path: string): boolean;
|
|
33
|
+
realpath(path: string): string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const nodeIo: LocalCliIo = {
|
|
37
|
+
exists: existsSync,
|
|
38
|
+
realpath: (path) => realpathSync(path),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The app-local `bin.ts` to re-execute, or undefined when this process already IS the app's CLI
|
|
43
|
+
* (a workspace symlink resolves to the same file), when there is no app, when this process is a
|
|
44
|
+
* compiled binary, or when the caller opted out. Pure over the injected filesystem so the
|
|
45
|
+
* decision has a test without a checkout.
|
|
46
|
+
*
|
|
47
|
+
* A compiled `x` (`x build --target binary`, the container's `/app/x`) has an `import.meta.path`
|
|
48
|
+
* inside Bun's virtual `/$bunfs/` — no such file exists on disk, so `realpath` throws. That is
|
|
49
|
+
* the keep case, not the hand-over case: the binary is the deliberate artifact, and the runtime
|
|
50
|
+
* image carries no `bun` to hand over to. The other unresolvable side — a `node_modules` entry
|
|
51
|
+
* that exists but whose link is dangling — is kept for the same reason: nothing there can run.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveLocalCli(
|
|
54
|
+
input: {
|
|
55
|
+
readonly cwd: string;
|
|
56
|
+
readonly selfPath: string;
|
|
57
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
58
|
+
},
|
|
59
|
+
io: LocalCliIo = nodeIo,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (input.env[KEEP_GLOBAL_CLI_ENV] !== undefined) return undefined;
|
|
62
|
+
const root = findAppRoot(input.cwd);
|
|
63
|
+
if (root === undefined) return undefined;
|
|
64
|
+
const local = join(root.dir, LOCAL_CLI_BIN);
|
|
65
|
+
if (!io.exists(local)) return undefined;
|
|
66
|
+
try {
|
|
67
|
+
return io.realpath(local) === io.realpath(input.selfPath) ? undefined : local;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The actor a route is rendered AS when the render exists only to be weighed. `app/` pages are
|
|
2
|
+
// authed by construction: a `load` that calls a policy-guarded query denies an anonymous actor with
|
|
3
|
+
// `X_UNAUTHENTICATED`, so under the anonymous build context every authed page was reported
|
|
4
|
+
// `X_BUDGET_UNMEASURED` and a real app could not pass the `budgets` step (measured 2026-09-05).
|
|
5
|
+
// Weighing bytes needs no data authority — the rendered document is discarded — so this actor holds
|
|
6
|
+
// every permission. It is handed ONLY to the weigh-and-discard branch of `prerender.ts`, never to
|
|
7
|
+
// `renderStatic`: a `site/` artifact is published to everyone, and a guarded query inside its `load`
|
|
8
|
+
// must keep failing the build rather than rendering another actor's rows into a file.
|
|
9
|
+
|
|
10
|
+
import type { Actor } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
/** The id every trace and log line under a measurement render carries, so it is recognisable. */
|
|
13
|
+
export const MEASUREMENT_ACTOR_ID = 'x-build-measure';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `kind: 'service'` and not `'user'`: an app's own `requireMember()`-style helper that resolves a
|
|
17
|
+
* user to a row has nothing to resolve here, and the honest kind says so. `'*'` is the grant
|
|
18
|
+
* `actorHas` reads as everything — the same spelling a role map uses for a superuser.
|
|
19
|
+
*/
|
|
20
|
+
export const measurementActor = (): Actor => ({
|
|
21
|
+
kind: 'service',
|
|
22
|
+
id: MEASUREMENT_ACTOR_ID,
|
|
23
|
+
roles: [],
|
|
24
|
+
scopes: [],
|
|
25
|
+
permissions: ['*'],
|
|
26
|
+
});
|
package/src/messages.ts
CHANGED
|
@@ -83,6 +83,7 @@ const CATALOG = {
|
|
|
83
83
|
'cli.dev.panels': ' panels {panels}',
|
|
84
84
|
'cli.dev.introspect': ' introspect {url}',
|
|
85
85
|
'cli.dev.manifest': ' manifest {path}',
|
|
86
|
+
'cli.dev.mcp': ' mcp POST {path}',
|
|
86
87
|
'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
|
|
87
88
|
'cli.doctor.clean': 'no findings — environment is shippable',
|
|
88
89
|
'cli.doctor.findings': '{count} finding(s)',
|
package/src/prerender.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
|
|
|
17
17
|
import { FAVICON_PATH, faviconBytes } from './favicon';
|
|
18
18
|
import type { IslandBundle } from './island-bundle';
|
|
19
19
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
20
|
+
import { measurementActor } from './measurement-actor';
|
|
20
21
|
import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
|
|
21
22
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
22
23
|
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
@@ -194,13 +195,25 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
194
195
|
// documents, and this build's own id so a component reading `ctx.buildId` stamps the artifact
|
|
195
196
|
// with the id the report and the stats carry.
|
|
196
197
|
const ctx = createContext({ role: 'web', buildId });
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
// A SECOND context, for the branch below that renders only to weigh. Its actor holds every
|
|
199
|
+
// permission (`measurement-actor.ts`), because an `app/` page's `load` calls policy-guarded
|
|
200
|
+
// queries and denied the anonymous one with `X_UNAUTHENTICATED` — every authed page unmeasured.
|
|
201
|
+
// `renderStatic` keeps `ctx`: its output is a published file, and a `site/` load that a policy
|
|
202
|
+
// refuses must fail the build, never render another actor's rows into it.
|
|
203
|
+
const measureCtx = createContext({ role: 'web', buildId, actor: measurementActor() });
|
|
204
|
+
const documentAs = (
|
|
205
|
+
as: typeof ctx,
|
|
206
|
+
entry: RouteEntry,
|
|
207
|
+
data: { url: string; params: Record<string, string> },
|
|
208
|
+
) =>
|
|
209
|
+
runWithContext(as, () =>
|
|
199
210
|
routeDocument(entry, data, {
|
|
200
211
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
201
212
|
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
202
213
|
}),
|
|
203
214
|
);
|
|
215
|
+
const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
|
|
216
|
+
documentAs(ctx, entry, data);
|
|
204
217
|
|
|
205
218
|
for (const entry of routeEntries()) {
|
|
206
219
|
const facts = { surface: entry.surface, render: entry.config.render, route: entry.path };
|
|
@@ -213,7 +226,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
213
226
|
// routes it never used to touch would be a worse regression than the gap it closes. A route
|
|
214
227
|
// that will not render here is reported, gets no stats entry, and stays `X_BUDGET_UNMEASURED`.
|
|
215
228
|
try {
|
|
216
|
-
const html = await
|
|
229
|
+
const html = await documentAs(measureCtx, entry, {
|
|
217
230
|
url: new URL(entry.path, origin).href,
|
|
218
231
|
params: {},
|
|
219
232
|
});
|
package/src/serve.ts
CHANGED
|
@@ -26,6 +26,8 @@ import { apiRoutes } from './api-routes';
|
|
|
26
26
|
import { loadSignInPath } from './app-auth';
|
|
27
27
|
import { loadApp } from './app-load';
|
|
28
28
|
import { appManifest } from './app-manifest';
|
|
29
|
+
import { mountAppMcp } from './app-mcp';
|
|
30
|
+
import { loadAppRuntime } from './app-runtime';
|
|
29
31
|
import { acceptCreatedTables } from './db-accept-created';
|
|
30
32
|
import { assetRoutes } from './dev-assets';
|
|
31
33
|
import { startQueue } from './dev-queue';
|
|
@@ -252,7 +254,22 @@ export async function releaseBoot(
|
|
|
252
254
|
* table is the same three contributions minus the dashboard — a `/_x` in production would expose
|
|
253
255
|
* the app's policy matrix, its outbox and its spans to the internet.
|
|
254
256
|
*/
|
|
255
|
-
|
|
257
|
+
/**
|
|
258
|
+
* A caller's `runtime` wins; with none, the app's own `apps/<app>/runtime.ts` is what this boot
|
|
259
|
+
* reads — the SAME file `x dev` reads — so the two boots compose one middleware chain, one
|
|
260
|
+
* rate-limit store, one ISR store, rather than a development set and a production set. Resolved
|
|
261
|
+
* ONCE, at each public entry, so every reader below (`startServices`, `startQueue`, the asset and
|
|
262
|
+
* ISR seams, the replica override) sees one object: a per-read fallback would be the partial read
|
|
263
|
+
* this repository names as its most repeated defect.
|
|
264
|
+
*/
|
|
265
|
+
export async function withAppRuntime(options: ServeOptions): Promise<ServeOptions> {
|
|
266
|
+
if (options.runtime !== undefined) return options;
|
|
267
|
+
const runtime = await loadAppRuntime(options.root);
|
|
268
|
+
return runtime === undefined ? options : { ...options, runtime };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function serveApp(input: ServeOptions): Promise<ServedApp> {
|
|
272
|
+
const options = await withAppRuntime(input);
|
|
256
273
|
const role = options.role ?? roleFromEnv(options.env);
|
|
257
274
|
const runtime = await startServices(
|
|
258
275
|
resolveServices(options.root, options.env),
|
|
@@ -313,8 +330,11 @@ async function bootRoles(boot: {
|
|
|
313
330
|
pwa === undefined
|
|
314
331
|
? undefined
|
|
315
332
|
: serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
|
|
333
|
+
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
334
|
+
const mcpMount = await mountAppMcp(options.root);
|
|
316
335
|
const routes: readonly Route[] = [
|
|
317
336
|
...apiRoutes(),
|
|
337
|
+
...mcpMount.routes,
|
|
318
338
|
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
319
339
|
...assetRoutes({
|
|
320
340
|
root: options.root,
|
|
@@ -386,8 +406,11 @@ async function bootRoles(boot: {
|
|
|
386
406
|
* holds for every other role until core's drain completes, so SIGTERM from a rolling restart takes
|
|
387
407
|
* the three-phase path (stop accepting, finish in-flight, close) instead of killing a query.
|
|
388
408
|
*/
|
|
389
|
-
export async function runRole(
|
|
390
|
-
|
|
409
|
+
export async function runRole(input: ServeOptions): Promise<StartedApp> {
|
|
410
|
+
// The role FIRST: a bad `ROLE` is refused before the root is read at all, so a boot that was
|
|
411
|
+
// always going to fail creates nothing under it — the same order `resolveServices` is held to.
|
|
412
|
+
const role = input.role ?? roleFromEnv(input.env);
|
|
413
|
+
const options = await withAppRuntime(input);
|
|
391
414
|
if (role === 'migrate') {
|
|
392
415
|
const migrated = await runMigrations({ ...options, role });
|
|
393
416
|
// The release phase has one channel — the exit code — so drift is thrown here rather than
|
package/src/source-files.ts
CHANGED
|
@@ -29,12 +29,24 @@ export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
|
|
|
29
29
|
/** Every opt-in suffix (`*.{contract,live,job,eval,e2e}.test.ts`) still ends `.test.ts`. */
|
|
30
30
|
export const isTest = (path: string): boolean => /\.test\.tsx?$/.test(path);
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Every source file under `root`, repo-relative and deduplicated across the globs — in a SORTED
|
|
34
|
+
* order per pattern, `As of 2026-09-05`. `Bun.Glob.scan` yields in the filesystem's `readdir`
|
|
35
|
+
* order, which ext4 hashes: the first offender a scan names was `index.ts` on one machine and
|
|
36
|
+
* `tools.ts` on a GitHub runner, so a finding's `cause` depended on which disk held the checkout
|
|
37
|
+
* (`workspace-graph.test.ts`, red on CI and green everywhere else). Code-unit compare, never
|
|
38
|
+
* `localeCompare`, for the reason `describeRoutes` states: one order on every machine.
|
|
39
|
+
*/
|
|
33
40
|
export async function* eachSourceFile(root: string): AsyncGenerator<string> {
|
|
34
41
|
const seen = new Set<string>();
|
|
35
42
|
for (const pattern of SOURCE_GLOBS) {
|
|
43
|
+
const matches: string[] = [];
|
|
36
44
|
for await (const path of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
|
|
37
|
-
if (isVendored(path)
|
|
45
|
+
if (!isVendored(path)) matches.push(path);
|
|
46
|
+
}
|
|
47
|
+
matches.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
48
|
+
for (const path of matches) {
|
|
49
|
+
if (seen.has(path)) continue;
|
|
38
50
|
seen.add(path);
|
|
39
51
|
yield path;
|
|
40
52
|
}
|
package/src/templates/entity.ts
CHANGED
|
@@ -27,6 +27,13 @@ import { entity, invariant, money, text, timestamp, uuid } from '@ultimat3/entit
|
|
|
27
27
|
export const ${name.camel} = entity('${table}', {
|
|
28
28
|
// Naming the tenant column is what turns tenancy on: a read without an org predicate then
|
|
29
29
|
// fails with X_TENANCY_UNSCOPED instead of leaking another org's rows.
|
|
30
|
+
//
|
|
31
|
+
// SINGLE-TENANT APP? There is no flag: \`x g action\`, \`x g query\` and \`x g policy\` decide on
|
|
32
|
+
// \`orgId\` too, so the edit is per slice and it is this: delete \`tenant: 'orgId'\` and the
|
|
33
|
+
// \`orgId: uuid()\` column below, drop \`'orgId'\` from \`indexes\`, and in repo.ts turn
|
|
34
|
+
// \`listByOrg(orgId, limit)\` into \`list(limit)\` with no \`org_id\` predicate and no \`org_id\` in
|
|
35
|
+
// the insert. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false, and its
|
|
36
|
+
// \`row()\` fixture loses \`orgId\`. Nothing else reads the column.
|
|
30
37
|
tenant: 'orgId',
|
|
31
38
|
columns: {
|
|
32
39
|
id: uuid().primaryKey(),
|
package/src/ts-scan.ts
CHANGED
|
@@ -30,120 +30,17 @@ export interface CodeScan {
|
|
|
30
30
|
readonly unresolved: readonly UnresolvedCodeSite[];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// The masking itself — `QUOTES`, `endOfLiteral`, `stripComments`, `maskLiterals` — lives in
|
|
34
|
+
// `@ultimat3/core`'s `source-mask.ts` since 2026-09-05, because `@ultimat3/i18n`'s key extractor
|
|
35
|
+
// (tier 1) needed it and could not reach a tier-5 package. Re-exported here so every scanner in
|
|
36
|
+
// this package, and this package's public API, keep the names they had.
|
|
37
|
+
import { endOfLiteral, maskLiterals, QUOTES, stripComments } from '@ultimat3/core';
|
|
38
|
+
|
|
39
|
+
export { endOfLiteral, maskLiterals, QUOTES, stripComments };
|
|
40
|
+
|
|
33
41
|
// `ReadonlySet`, so a consumer cannot mutate what every scan in this package reads.
|
|
34
|
-
export const QUOTES: ReadonlySet<string> = new Set(["'", '"', '`']);
|
|
35
42
|
export const OPENERS: ReadonlySet<string> = new Set(['(', '[', '{']);
|
|
36
43
|
export const CLOSERS: ReadonlySet<string> = new Set([')', ']', '}']);
|
|
37
|
-
const WORD = /[\w$]/;
|
|
38
|
-
|
|
39
|
-
/** After one of these words a `/` opens a regex; after any other identifier it divides. */
|
|
40
|
-
const REGEX_AFTER_WORDS = new Set(
|
|
41
|
-
'await case delete do else in instanceof new of return throw typeof void yield'.split(' '),
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Index just past the closing quote of the literal opening at `from`, or `from + 1` when a `'`/`"`
|
|
46
|
-
* does not close on its own line — which makes it text, not a literal. Only a template literal may
|
|
47
|
-
* span a newline, so the apostrophe in `<p>Don't panic</p>` is JSX text; read as an opener it ran
|
|
48
|
-
* forward to the next `'` in the FILE (the next `fix:` line) and blanked every declaration between,
|
|
49
|
-
* silently emptying the `errors` gate for the whole file. Same rule `endOfRegex` applies to a `/`.
|
|
50
|
-
* An escaped newline is still a continuation: the escape is consumed before the line test.
|
|
51
|
-
*/
|
|
52
|
-
export function endOfLiteral(text: string, from: number): number {
|
|
53
|
-
const quote = text[from] as string;
|
|
54
|
-
const spansLines = quote === '`';
|
|
55
|
-
for (let i = from + 1; i < text.length; i += 1) {
|
|
56
|
-
if (text[i] === '\\') i += 1;
|
|
57
|
-
else if (text[i] === quote) return i + 1;
|
|
58
|
-
else if (!spansLines && text[i] === '\n') return from + 1;
|
|
59
|
-
}
|
|
60
|
-
return spansLines ? text.length : from + 1;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
|
|
65
|
-
* avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
|
|
66
|
-
* above, a number, `)`, `]`, a string's closing quote. Every other position is an operator's and
|
|
67
|
-
* opens one; `</` and `/>` are JSX delimiters. Read from the masked prefix, so a comment is space.
|
|
68
|
-
*/
|
|
69
|
-
function opensRegex(out: readonly string[], at: number): boolean {
|
|
70
|
-
if (out[at + 1] === '>') return false;
|
|
71
|
-
let i = at - 1;
|
|
72
|
-
while (i >= 0 && /\s/.test(out[i] as string)) i -= 1;
|
|
73
|
-
if (i < 0) return true;
|
|
74
|
-
const ch = out[i] as string;
|
|
75
|
-
if (ch === '<' || ch === ')' || ch === ']' || QUOTES.has(ch)) return false;
|
|
76
|
-
if (!WORD.test(ch)) return true;
|
|
77
|
-
let start = i;
|
|
78
|
-
while (start >= 0 && WORD.test(out[start] as string)) start -= 1;
|
|
79
|
-
return REGEX_AFTER_WORDS.has(out.slice(start + 1, i + 1).join(''));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Index just past the closing `/` of the regex opening at `from`, or `from + 1` when it does not
|
|
84
|
-
* close on its own line — a literal may not span one, so an unterminated candidate was a division
|
|
85
|
-
* or a JSX delimiter after all. A `/` inside a `[…]` class does not close the literal.
|
|
86
|
-
*/
|
|
87
|
-
function endOfRegex(text: string, from: number): number {
|
|
88
|
-
let inClass = false;
|
|
89
|
-
let escaped = false;
|
|
90
|
-
for (let i = from + 1; i < text.length; i += 1) {
|
|
91
|
-
const ch = text[i];
|
|
92
|
-
if (ch === '\n') break;
|
|
93
|
-
if (escaped) escaped = false;
|
|
94
|
-
else if (ch === '\\') escaped = true;
|
|
95
|
-
else if (inClass) inClass = ch !== ']';
|
|
96
|
-
else if (ch === '[') inClass = true;
|
|
97
|
-
else if (ch === '/') return i + 1;
|
|
98
|
-
}
|
|
99
|
-
return from + 1;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Comments — and optionally string contents — replaced by spaces, newlines kept so line numbers
|
|
104
|
-
* survive and quote delimiters kept so the caller can still find where a literal starts and ends.
|
|
105
|
-
* A regex body is masked the same way: `/(['"`])/` holds three quotes that delimit nothing, and
|
|
106
|
-
* reading one as an opening quote desyncs every literal after it.
|
|
107
|
-
*/
|
|
108
|
-
function blankRegions(text: string, strings: boolean): string {
|
|
109
|
-
// `split('')` and NOT `[...text]`: the spread yields one element per CODE POINT while every
|
|
110
|
-
// index below runs over UTF-16 units (`text.length`, `text[i]`). One astral character — an emoji
|
|
111
|
-
// in a fixture, `piñata 🎉` — and `out` is shorter than `text`, so every write past it lands a
|
|
112
|
-
// slot early and the returned mask no longer aligns with the input. Measured: 22 files in this
|
|
113
|
-
// tree desynced, shipped source included, and eight rules read this mask.
|
|
114
|
-
const out = text.split('');
|
|
115
|
-
const blank = (from: number, to: number): void => {
|
|
116
|
-
for (let n = from; n < to; n += 1) if (out[n] !== '\n') out[n] = ' ';
|
|
117
|
-
};
|
|
118
|
-
let i = 0;
|
|
119
|
-
while (i < text.length) {
|
|
120
|
-
const ch = text[i] as string;
|
|
121
|
-
if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
|
|
122
|
-
const line = text[i + 1] === '/';
|
|
123
|
-
const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
|
|
124
|
-
const stop = end === -1 ? text.length : line ? end : end + 2;
|
|
125
|
-
blank(i, stop);
|
|
126
|
-
i = stop;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
// Not code: a regex body or a literal. `end === i + 1` blanks nothing and steps one char.
|
|
130
|
-
const end =
|
|
131
|
-
ch === '/' && opensRegex(out, i)
|
|
132
|
-
? endOfRegex(text, i)
|
|
133
|
-
: QUOTES.has(ch)
|
|
134
|
-
? endOfLiteral(text, i)
|
|
135
|
-
: i + 1;
|
|
136
|
-
if (strings) blank(i + 1, end - 1);
|
|
137
|
-
i = end;
|
|
138
|
-
}
|
|
139
|
-
return out.join('');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Comments gone, string literals intact — what a scan for declared codes reads. */
|
|
143
|
-
export const stripComments = (text: string): string => blankRegions(text, false);
|
|
144
|
-
|
|
145
|
-
/** Comments and string contents gone, delimiters kept — what a scan for code structure reads. */
|
|
146
|
-
export const maskLiterals = (text: string): string => blankRegions(text, true);
|
|
147
44
|
|
|
148
45
|
/**
|
|
149
46
|
* Line numbers for one text, in one pass. Counting newlines per lookup is O(index), which a scan
|