@ultimat3/cli 9.0.0 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +71 -0
- package/package.json +28 -26
- package/src/affected.ts +0 -3
- package/src/app-boundaries.ts +4 -5
- package/src/app-env.ts +7 -2
- package/src/browser-launcher.ts +0 -2
- package/src/budgets.ts +10 -3
- package/src/cmd-build.ts +2 -2
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-deploy.ts +14 -3
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-docs.ts +2 -1
- package/src/cmd-doctor.ts +3 -5
- package/src/cmd-env.ts +2 -2
- package/src/cmd-fix.ts +2 -4
- package/src/cmd-new.ts +36 -6
- package/src/cmd-shot.ts +22 -2
- package/src/command.ts +12 -0
- package/src/db-finding.ts +2 -2
- package/src/db-seed.ts +0 -3
- package/src/dev-assets.ts +6 -0
- package/src/dev-cache.ts +12 -5
- package/src/dev-hooks.ts +8 -0
- package/src/dev-lock.ts +8 -7
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dev-runtime.ts +11 -3
- package/src/dev-storage.ts +8 -2
- package/src/dev-sync.ts +37 -2
- package/src/dispatch.ts +8 -0
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +3 -2
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +19 -2
- package/src/error-contract.ts +30 -7
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +26 -29
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/flag-reads.ts +2 -2
- package/src/generate-write.ts +3 -2
- package/src/guards.ts +4 -4
- package/src/hold.ts +73 -7
- package/src/index.ts +16 -6
- package/src/island-bundle.ts +32 -4
- package/src/island-routes.ts +7 -1
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +7 -2
- package/src/messages.ts +6 -4
- package/src/metrics-endpoint.ts +0 -2
- package/src/output.ts +2 -2
- package/src/prerender.ts +64 -22
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- package/src/static-report.ts +41 -3
- package/src/templates/admin-page.ts +11 -7
- package/src/templates/imports.ts +26 -0
- package/src/templates/route.ts +2 -2
- package/src/templates/scaffold-app.ts +18 -6
- package/src/templates/scaffold-auth.ts +151 -0
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +1 -1
- package/src/templates/scaffold-domain-package.ts +3 -1
- package/src/templates/scaffold-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +17 -8
- package/src/templates/slice-foundation.ts +3 -5
- package/src/test-shards.ts +2 -2
- package/src/tsconfig-references.ts +2 -2
- package/src/verify-checks.ts +10 -3
- package/src/verify-floor.ts +8 -6
- package/src/verify-run.ts +2 -2
- package/src/verify-step.ts +2 -1
- package/src/verify-test-run.ts +2 -2
- package/src/workspace-checks.ts +10 -12
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +7 -1
package/src/fix-path.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The other half of the citation rule: a `fix:` may name a FILE, and nothing resolved it. Only
|
|
2
|
+
// `x <command>` citations were checked (`fix-command.ts`), so a fix telling its reader to open a
|
|
3
|
+
// path that no longer exists — or never did — satisfied every gate the repo has, while a file token
|
|
4
|
+
// is one of the four things that make a fix an instruction at all (`COMMAND_TOKENS`).
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no synchronous existence primitive — `Bun.file(p).exists()` is async and answers
|
|
7
|
+
// false for a DIRECTORY, and this rule has to judge both. Delete when Bun ships one.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
// why: Bun exposes no path-join or dirname primitive. The same necessity `error-contract.ts`
|
|
10
|
+
// already records for `join`.
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The extensions a fix line may cite a file by — the SAME set `COMMAND_TOKENS`' file pattern is
|
|
15
|
+
* built from, exported here so the token that lets a fix count as an instruction and the token this
|
|
16
|
+
* rule resolves can never drift apart. Two lists would mean a citation that satisfies the first
|
|
17
|
+
* rule and is invisible to the second, which is the hole this file exists to close.
|
|
18
|
+
*/
|
|
19
|
+
export const CITED_FILE_EXTENSIONS = [
|
|
20
|
+
'ts',
|
|
21
|
+
'tsx',
|
|
22
|
+
'js',
|
|
23
|
+
'json',
|
|
24
|
+
'md',
|
|
25
|
+
'toml',
|
|
26
|
+
'yaml',
|
|
27
|
+
'yml',
|
|
28
|
+
'css',
|
|
29
|
+
'scss',
|
|
30
|
+
'sql',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/** `[\w.@-]*\/[\w.@-]+\.(?:ts|…)`, as one source string both rules read. */
|
|
34
|
+
export const FILE_TOKEN_PATTERN = String.raw`[\w.@-]*\/[\w.@-]+\.(?:${CITED_FILE_EXTENSIONS.join('|')})\b`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A whole path, not the last two segments `FILE_TOKEN_PATTERN` matches: this rule resolves the
|
|
38
|
+
* citation, so it needs every segment. `*` is in the class because a glob is a citation too.
|
|
39
|
+
*/
|
|
40
|
+
const PATH_CITATION = new RegExp(
|
|
41
|
+
String.raw`(?:[\w.@*-]+\/)+[\w.@*-]+\.(?:${CITED_FILE_EXTENSIONS.join('|')})\b`,
|
|
42
|
+
'g',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** A URL is not a repo path, and `https://ultimate.dev/errors/x.md` is shaped like one. */
|
|
46
|
+
const URL_SPAN = /\b[a-z][\w+.-]*:\/\/\S+/gi;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether this repo can judge the citation at all. THREE exclusions, each one a shape that resolves
|
|
50
|
+
* against something other than the root the gate is running in — reporting any of them would be a
|
|
51
|
+
* finding nobody can act on:
|
|
52
|
+
*
|
|
53
|
+
* - a scoped module specifier (`@ultimat3/ui/global.scss`) resolves through `node_modules`;
|
|
54
|
+
* - a dot-relative path (`./global.scss`) resolves against the reader's own file, which the fix
|
|
55
|
+
* line does not name;
|
|
56
|
+
* - a path whose PARENT DIRECTORY does not exist here is app-facing by construction — `src/errors.ts`
|
|
57
|
+
* means "in the package you are editing", `apps/web/server.ts` and `packages/i18n/catalogs/en.json`
|
|
58
|
+
* name directories a generated app has and this repo does not. What is left is the citation this
|
|
59
|
+
* root really can answer: a directory that exists, named as holding a file it does not hold.
|
|
60
|
+
*/
|
|
61
|
+
function isJudgeable(token: string, root: string): boolean {
|
|
62
|
+
if (token.startsWith('@') || token.startsWith('./') || token.startsWith('../')) return false;
|
|
63
|
+
const star = token.indexOf('*');
|
|
64
|
+
// A glob's FIXED prefix is what has to exist; the segments a `*` stands for are the answer. Cut
|
|
65
|
+
// at the separator before the star rather than at the star — `dirname('packages/')` is `.`, and
|
|
66
|
+
// a glob whose first segment is the wildcard has no prefix to check at all.
|
|
67
|
+
const cut = star === -1 ? -1 : token.lastIndexOf('/', star);
|
|
68
|
+
const base = star === -1 ? dirname(token) : token.slice(0, Math.max(cut, 0));
|
|
69
|
+
return base !== '.' && base !== '' && existsSync(join(root, base));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every path-shaped citation on a fix line that this root can resolve, in the order written. */
|
|
73
|
+
export function pathCitations(fix: string, root: string): readonly string[] {
|
|
74
|
+
const prose = fix.replaceAll(URL_SPAN, ' ');
|
|
75
|
+
return [...prose.matchAll(PATH_CITATION)]
|
|
76
|
+
.map((match) => match[0])
|
|
77
|
+
.filter((token) => isJudgeable(token, root));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A glob matches when at least one file answers it; anything else must exist as written. */
|
|
81
|
+
async function resolves(token: string, root: string): Promise<boolean> {
|
|
82
|
+
if (!token.includes('*')) return existsSync(join(root, token));
|
|
83
|
+
for await (const _match of new Bun.Glob(token).scan({ cwd: root, onlyFiles: false })) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The first path a fix cites that this repo does not have, as one sentence for a `cause:`. One
|
|
91
|
+
* finding per fix line, never one per token — the same rule `citedCommandProblem` holds to.
|
|
92
|
+
*
|
|
93
|
+
* Read off the STATIC form of the fix (the caller blanks `${…}` first): a path assembled at run
|
|
94
|
+
* time is not a path this can resolve, and guessing at one reports findings nobody can act on.
|
|
95
|
+
*/
|
|
96
|
+
export async function citedPathProblem(fix: string, root: string): Promise<string | undefined> {
|
|
97
|
+
for (const token of pathCitations(fix, root)) {
|
|
98
|
+
if (!(await resolves(token, root))) {
|
|
99
|
+
const kind = token.includes('*') ? 'matches no file' : 'is not a file in this repository';
|
|
100
|
+
return `cites "${token}", which ${kind}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
package/src/flag-reads.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
11
11
|
import { join, relative } from 'node:path';
|
|
12
|
-
import {
|
|
12
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
13
13
|
import type { Finding } from './output';
|
|
14
14
|
import type { CommandSpec, FlagSpec } from './parse';
|
|
15
15
|
import { GLOBAL_FLAGS } from './parse';
|
|
@@ -64,7 +64,7 @@ const unreadFinding = (declared: DeclaredFlag, at: string): Finding => ({
|
|
|
64
64
|
code: 'X_CLI_FLAG_UNREAD',
|
|
65
65
|
cause: `x ${declared.command} declares --${declared.flag.name} ("${declared.flag.summary}") and no file in the CLI's source reads it, so the flag parses and changes nothing`,
|
|
66
66
|
fix: `read it in ${at} with flag${declared.flag.type === 'boolean' ? 'Bool' : 'String'}(ctx.args, '${declared.flag.name}'), or delete it from the spec's flags`,
|
|
67
|
-
docs:
|
|
67
|
+
docs: ERROR_DOCS_URL,
|
|
68
68
|
at,
|
|
69
69
|
});
|
|
70
70
|
|
package/src/generate-write.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import { resolve, sep } from 'node:path';
|
|
10
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
11
|
import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
|
|
11
12
|
import { mergeJsonDeep } from './json-merge';
|
|
12
13
|
import type { Finding } from './output';
|
|
@@ -125,7 +126,7 @@ async function planJsonMerge(
|
|
|
125
126
|
code: 'X_GENERATE_CONFLICT',
|
|
126
127
|
cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
|
|
127
128
|
fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
|
|
128
|
-
docs:
|
|
129
|
+
docs: ERROR_DOCS_URL,
|
|
129
130
|
at: file.path,
|
|
130
131
|
},
|
|
131
132
|
};
|
|
@@ -178,7 +179,7 @@ function planFile(
|
|
|
178
179
|
// when run, and a `fix:` is copied and pasted verbatim. Same construction as
|
|
179
180
|
// `generate-kinds.ts`'s `assertSurfaceSupported`.
|
|
180
181
|
fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
|
|
181
|
-
docs:
|
|
182
|
+
docs: ERROR_DOCS_URL,
|
|
182
183
|
at: file.path,
|
|
183
184
|
},
|
|
184
185
|
};
|
package/src/guards.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
|
-
import { renderCauseValue, renderThrowable } from '@ultimat3/core';
|
|
12
|
+
import { ERROR_DOCS_URL, renderCauseValue, renderThrowable } from '@ultimat3/core';
|
|
13
13
|
import { fixProblem } from './error-contract';
|
|
14
14
|
import type { Finding } from './output';
|
|
15
15
|
import type { HostCheck } from './verify-step';
|
|
@@ -105,7 +105,7 @@ const failed = (path: string, cause: string): Finding => ({
|
|
|
105
105
|
code: 'X_GUARD_FAILED',
|
|
106
106
|
cause,
|
|
107
107
|
fix: `return a finding from ${path} instead of throwing, then: x verify`,
|
|
108
|
-
docs:
|
|
108
|
+
docs: ERROR_DOCS_URL,
|
|
109
109
|
at: path,
|
|
110
110
|
});
|
|
111
111
|
|
|
@@ -113,7 +113,7 @@ const invalid = (path: string, cause: string): Finding => ({
|
|
|
113
113
|
code: 'X_GUARD_INVALID',
|
|
114
114
|
cause,
|
|
115
115
|
fix: `export a \`guard\` object — { summary, check } — from ${path}, then: x verify`,
|
|
116
|
-
docs:
|
|
116
|
+
docs: ERROR_DOCS_URL,
|
|
117
117
|
at: path,
|
|
118
118
|
});
|
|
119
119
|
|
|
@@ -121,7 +121,7 @@ const findingInvalid = (path: string, cause: string): Finding => ({
|
|
|
121
121
|
code: 'X_GUARD_FINDING_INVALID',
|
|
122
122
|
cause: `${path} returned a finding that is not one: ${cause}`,
|
|
123
123
|
fix: `rewrite what ${path} returns as a code, a cause and a fix naming a command or a file, then: x verify`,
|
|
124
|
-
docs:
|
|
124
|
+
docs: ERROR_DOCS_URL,
|
|
125
125
|
at: path,
|
|
126
126
|
});
|
|
127
127
|
|
package/src/hold.ts
CHANGED
|
@@ -3,7 +3,23 @@
|
|
|
3
3
|
// before the exit code. Ctrl-C then takes core's own three-phase drain (stop accepting, finish
|
|
4
4
|
// in-flight, close) instead of killing a query mid-round-trip.
|
|
5
5
|
|
|
6
|
-
import { drain, installSignalHandlers, onShutdown } from '@ultimat3/core';
|
|
6
|
+
import { drain, installSignalHandlers, logger, onShutdown, systemClock } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
export interface HoldOptions {
|
|
9
|
+
/**
|
|
10
|
+
* What to call once the release is done, with `0`. Omit and nothing is called.
|
|
11
|
+
*
|
|
12
|
+
* There is exactly one caller: `runRole` in `serve.ts`, which is what `apps/web/server.ts`
|
|
13
|
+
* awaits — the one entry point with nothing above it to end the process. `bin.ts` ends in
|
|
14
|
+
* `process.exit(code)`, so `x dev` and `x mcp` need none of this; a container has no such line,
|
|
15
|
+
* and one non-unref'd interval anywhere in the app then holds an event loop with nothing left to
|
|
16
|
+
* do until `terminationGracePeriodSeconds` runs out and the kubelet SIGKILLs it.
|
|
17
|
+
*
|
|
18
|
+
* A function rather than a boolean because `process.exit` in a library is untestable: this is
|
|
19
|
+
* the seam the test passes a spy through, and the caller is the one that knows.
|
|
20
|
+
*/
|
|
21
|
+
readonly exit?: (code: number) => void;
|
|
22
|
+
}
|
|
7
23
|
|
|
8
24
|
/**
|
|
9
25
|
* Wait for a shutdown, then release what core's lifecycle does not own.
|
|
@@ -16,15 +32,29 @@ import { drain, installSignalHandlers, onShutdown } from '@ultimat3/core';
|
|
|
16
32
|
* `release` runs after the drain completes, so in-flight requests still see the database the
|
|
17
33
|
* handler opened them against. It is the resources core never learned about: the embedded
|
|
18
34
|
* Postgres, the worker, the file watcher.
|
|
35
|
+
*
|
|
36
|
+
* It runs INSIDE the drain's own deadline, and that is not a detail. `drain()` ABANDONS a hook
|
|
37
|
+
* that overruns `ShutdownReason.deadlineAt` — the process is meant to exit without it — and
|
|
38
|
+
* `release` here re-enters the very same teardown one call later: `app.stop()` ->
|
|
39
|
+
* `startRoles().stop()` -> `worker.stop()`, memoised in the package that owns it, so awaiting it
|
|
40
|
+
* is awaiting the promise the drain just walked away from. Unbounded, that hangs forever and the
|
|
41
|
+
* deadline buys nothing.
|
|
19
42
|
*/
|
|
20
|
-
export function holdUntilShutdown(
|
|
43
|
+
export function holdUntilShutdown(
|
|
44
|
+
name: string,
|
|
45
|
+
release: () => Promise<void>,
|
|
46
|
+
options: HoldOptions = {},
|
|
47
|
+
): () => Promise<void> {
|
|
21
48
|
const uninstall = installSignalHandlers({ exit: false });
|
|
22
49
|
let unregister = (): void => {};
|
|
23
|
-
|
|
50
|
+
// The hook's own `reason`, not a stopwatch of ours: `deadlineAt` is the instant core computed
|
|
51
|
+
// when the drain began, on the same real monotonic clock, so this budget IS the drain's budget
|
|
52
|
+
// rather than a second one that happens to be the same length.
|
|
53
|
+
const shuttingDown = new Promise<number>((resolve) => {
|
|
24
54
|
unregister = onShutdown(
|
|
25
55
|
`cli:${name}:hold`,
|
|
26
|
-
() => {
|
|
27
|
-
resolve();
|
|
56
|
+
(reason) => {
|
|
57
|
+
resolve(reason.deadlineAt);
|
|
28
58
|
},
|
|
29
59
|
{ phase: 'accept' },
|
|
30
60
|
);
|
|
@@ -35,14 +65,50 @@ export function holdUntilShutdown(name: string, release: () => Promise<void>): (
|
|
|
35
65
|
// Memoised: awaiting a hold twice must not release twice, and `dispatch` is not the only
|
|
36
66
|
// caller a test can be.
|
|
37
67
|
held ??= (async () => {
|
|
38
|
-
await shuttingDown;
|
|
68
|
+
const deadlineAt = await shuttingDown;
|
|
39
69
|
// Idempotent in core: this joins the drain already in flight and resolves when its last
|
|
40
70
|
// phase is done. Calling it is what makes `release` the step after the drain, not beside it.
|
|
41
71
|
await drain();
|
|
42
72
|
unregister();
|
|
43
73
|
uninstall();
|
|
44
|
-
await release();
|
|
74
|
+
await releaseWithin(name, release, deadlineAt - systemClock.monotonic());
|
|
75
|
+
options.exit?.(0);
|
|
45
76
|
})();
|
|
46
77
|
return held;
|
|
47
78
|
};
|
|
48
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `release()` raced against what is left of the drain's budget.
|
|
83
|
+
*
|
|
84
|
+
* A local race and not core's `settleWithin`, which is internal to `lifecycle-deadline.ts` and not
|
|
85
|
+
* on core's barrel. The semantics are deliberately the same, including the one that matters: a
|
|
86
|
+
* REJECTION still rejects — `dispatch` awaits the hold inside its own `try`, and an embedded
|
|
87
|
+
* database that would not close is a finding on the way out, never a clean exit over it.
|
|
88
|
+
*
|
|
89
|
+
* A budget already spent is `0`, and that abandons immediately by design: past `deadlineAt` the
|
|
90
|
+
* orchestrator is already counting down to SIGKILL, so the honest move is to say so and exit
|
|
91
|
+
* rather than to start a second grace period nobody granted.
|
|
92
|
+
*/
|
|
93
|
+
async function releaseWithin(
|
|
94
|
+
name: string,
|
|
95
|
+
release: () => Promise<void>,
|
|
96
|
+
budgetMs: number,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const budget = Math.max(0, budgetMs);
|
|
99
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
100
|
+
const abandoned = new Promise<'abandoned'>((resolve) => {
|
|
101
|
+
timer = setTimeout(() => resolve('abandoned'), budget);
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
const outcome = await Promise.race([release().then(() => 'released' as const), abandoned]);
|
|
105
|
+
if (outcome === 'released') return;
|
|
106
|
+
logger.warn('X_SHUTDOWN_TIMEOUT', {
|
|
107
|
+
code: 'X_SHUTDOWN_TIMEOUT',
|
|
108
|
+
cause: `the "${name}" release was still running ${budget}ms after the drain finished and has been ABANDONED — the process exits without it, so anything it held may not be closed`,
|
|
109
|
+
fix: 'raise the budget past the slowest teardown — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
110
|
+
});
|
|
111
|
+
} finally {
|
|
112
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -116,6 +116,7 @@ export {
|
|
|
116
116
|
export type { ErrorCatalog } from './error-catalog';
|
|
117
117
|
export {
|
|
118
118
|
buildErrorCatalog,
|
|
119
|
+
CATALOG_OPTIONAL_HOSTS,
|
|
119
120
|
CATALOG_PACKAGES,
|
|
120
121
|
loadErrorCatalog,
|
|
121
122
|
registeredErrorCodes,
|
|
@@ -167,6 +168,13 @@ export {
|
|
|
167
168
|
} from './errors';
|
|
168
169
|
export type { ExecOptions, ExecResult, Runner } from './exec';
|
|
169
170
|
export { exec, execOutput } from './exec';
|
|
171
|
+
export {
|
|
172
|
+
defaultFavicon,
|
|
173
|
+
FAVICON_PATH,
|
|
174
|
+
FAVICON_SOURCE,
|
|
175
|
+
faviconResponse,
|
|
176
|
+
faviconRoute,
|
|
177
|
+
} from './favicon';
|
|
170
178
|
export type { CitationFault, CitationRules, CommandCatalog, FixCitation } from './fix-command';
|
|
171
179
|
export {
|
|
172
180
|
citationFault,
|
|
@@ -177,6 +185,12 @@ export {
|
|
|
177
185
|
} from './fix-command';
|
|
178
186
|
export type { HelperResolver } from './fix-imports';
|
|
179
187
|
export { candidatePaths, createHelperResolver, scanImports } from './fix-imports';
|
|
188
|
+
export {
|
|
189
|
+
CITED_FILE_EXTENSIONS,
|
|
190
|
+
citedPathProblem,
|
|
191
|
+
FILE_TOKEN_PATTERN,
|
|
192
|
+
pathCitations,
|
|
193
|
+
} from './fix-path';
|
|
180
194
|
export type { FixHelper, FixScan } from './fix-scan';
|
|
181
195
|
export { scanFixes, scanFixHelpers, scanFixSites } from './fix-scan';
|
|
182
196
|
export type { DeclaredFlag } from './flag-reads';
|
|
@@ -221,12 +235,7 @@ export {
|
|
|
221
235
|
} from './output';
|
|
222
236
|
export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
|
|
223
237
|
export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
|
|
224
|
-
export type {
|
|
225
|
-
PrerenderedPage,
|
|
226
|
-
PrerenderOptions,
|
|
227
|
-
PrerenderReport,
|
|
228
|
-
UnmeasuredRoute,
|
|
229
|
-
} from './prerender';
|
|
238
|
+
export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
|
|
230
239
|
export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
|
|
231
240
|
export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
|
|
232
241
|
export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
|
|
@@ -254,6 +263,7 @@ export type {
|
|
|
254
263
|
SkippedRoute,
|
|
255
264
|
SkipReason,
|
|
256
265
|
StaticReport,
|
|
266
|
+
UnmeasuredRoute,
|
|
257
267
|
} from './static-report';
|
|
258
268
|
export {
|
|
259
269
|
parseStaticReport,
|
package/src/island-bundle.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
|
|
7
7
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
8
8
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
9
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
9
10
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
10
11
|
import { contentHash } from '@ultimat3/render/server';
|
|
11
12
|
import { IslandBuildFailedError } from './errors';
|
|
@@ -118,11 +119,38 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
118
119
|
* import or syntax error, and flattening them is what puts the line number in the cause instead of
|
|
119
120
|
* the word "Bundle failed".
|
|
120
121
|
*/
|
|
121
|
-
function describeBuildError(error: unknown): string {
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
export function describeBuildError(error: unknown): string {
|
|
123
|
+
// `renderThrowable`, never `instanceof` + `.message` + `String()`. All three run on a value this
|
|
124
|
+
// process did not build — a `Proxy` traps `getPrototypeOf`, a `message` getter can raise, and
|
|
125
|
+
// `String()` throws outright on a Symbol — and what comes back is carried in
|
|
126
|
+
// `IslandBuildFailedError.logs`, which `errors.ts` interpolates straight into a `cause:`. That
|
|
127
|
+
// is a cross-file hop neither `scripts/catch-render.ts` nor `scripts/error-render.ts` can
|
|
128
|
+
// follow: a throw here loses the whole refusal and replaces it with a TypeError about reporting.
|
|
129
|
+
//
|
|
130
|
+
// The AggregateError branch stays, and it is the reason this function exists: `Bun.build` packs
|
|
131
|
+
// one entry per unresolved import or syntax error into `errors`, and flattening them is what
|
|
132
|
+
// puts a line number in the cause instead of the words "Bundle failed". `stringField` decides
|
|
133
|
+
// whether the value really is that shape, because `instanceof` is a question a Proxy answers.
|
|
134
|
+
const aggregate = aggregatedErrors(error);
|
|
135
|
+
if (aggregate !== undefined && aggregate.length > 0) {
|
|
136
|
+
return aggregate.map((one: unknown) => renderThrowable(one)).join('; ');
|
|
137
|
+
}
|
|
138
|
+
return renderThrowable(error);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `value.errors`, read the way `@ultimat3/core`'s `stringField` reads a string field: narrowed
|
|
143
|
+
* first, dereferenced inside a `try`, `undefined` for anything else. `instanceof AggregateError`
|
|
144
|
+
* is a question a `Proxy` answers with its own `getPrototypeOf` trap, so it is not a check.
|
|
145
|
+
*/
|
|
146
|
+
function aggregatedErrors(value: unknown): readonly unknown[] | undefined {
|
|
147
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
148
|
+
try {
|
|
149
|
+
const held: unknown = (value as Record<string, unknown>)['errors'];
|
|
150
|
+
return Array.isArray(held) ? held : undefined;
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
124
153
|
}
|
|
125
|
-
return error instanceof Error ? error.message : String(error);
|
|
126
154
|
}
|
|
127
155
|
|
|
128
156
|
export interface BuildIslandsOptions {
|
package/src/island-routes.ts
CHANGED
|
@@ -34,7 +34,13 @@ export function islandRoutes(source: IslandSource): readonly Route[] {
|
|
|
34
34
|
error: {
|
|
35
35
|
code: 'X_ROUTE_NOT_FOUND',
|
|
36
36
|
cause: `no island chunk is built at ${request.pathname} — the document that asked for it was rendered against an older build`,
|
|
37
|
-
|
|
37
|
+
// No `x` citation, deliberately — `dev-lock.ts`'s shape. This route is mounted
|
|
38
|
+
// in exactly two places (`cmd-dev.ts`, `serve.ts`) and NEITHER reads `.x/static`:
|
|
39
|
+
// in `x dev` the chunks are rebuilt on the watcher tick, and in the container they
|
|
40
|
+
// were built at boot. `x build --target static` — which is what this said — writes
|
|
41
|
+
// an export directory neither process serves from, so running it changed nothing
|
|
42
|
+
// for the only two readers this line has ever had.
|
|
43
|
+
fix: 'reload the page — this process serves only the chunks it built, and the document holding this URL came from an earlier build',
|
|
38
44
|
},
|
|
39
45
|
},
|
|
40
46
|
{ status: 404 },
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// One question only this package can ask: a route SUBSCRIBES to live rows, and does anything on
|
|
2
|
+
// that route ever run in a browser to receive them? `@ultimat3/realtime` cannot see a route and
|
|
3
|
+
// `@ultimat3/render` may not import realtime, so the two halves meet here — beside the island
|
|
4
|
+
// build, which is the other place the route table and the client graph are both in scope.
|
|
5
|
+
//
|
|
6
|
+
// The failure it closes is silent by construction (#271): with no island the page server-renders
|
|
7
|
+
// its `loading` branch, answers 200, and stays that way forever — nothing throws, nothing logs,
|
|
8
|
+
// and every suite passes.
|
|
9
|
+
|
|
10
|
+
// why: Bun exposes no path API, and a route file's imports are resolved against its own directory.
|
|
11
|
+
import { join, posix } from 'node:path';
|
|
12
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
13
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
14
|
+
import { ISLAND_EXTENSION, routeEntries } from '@ultimat3/render';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The exports that only work with a registered `LiveClient`. Each one either subscribes, mutates
|
|
19
|
+
* or reads the connection, so a module naming one is a module that needs a browser to have booted
|
|
20
|
+
* it — `hasLiveClient` and `LiveClient` itself are deliberately absent: the first IS the guard, and
|
|
21
|
+
* the second is what an island's `mount()` constructs.
|
|
22
|
+
*/
|
|
23
|
+
export const LIVE_HOOKS = [
|
|
24
|
+
'useLive',
|
|
25
|
+
'liveHookFor',
|
|
26
|
+
'useConnection',
|
|
27
|
+
'useMutation',
|
|
28
|
+
'useMutationQueue',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one escape hatch, and it is a call an author writes on purpose: a module that ASKS whether
|
|
33
|
+
* there is a client has already written what happens when there is none. `app/update-banner.tsx`
|
|
34
|
+
* in the reference app is the shape — imported by the layout, so by every page, and correct on all
|
|
35
|
+
* of them.
|
|
36
|
+
*/
|
|
37
|
+
const GUARD = 'hasLiveClient';
|
|
38
|
+
|
|
39
|
+
/** Value imports only: `import type` is erased, so it boots nothing and needs nothing. */
|
|
40
|
+
const REALTIME_IMPORT = /import\s+([^;]*?)from\s*['"]@ultimat3\/realtime(?:\/[\w-]+)?['"]/g;
|
|
41
|
+
|
|
42
|
+
const bindingsOf = (clause: string): readonly string[] =>
|
|
43
|
+
(/\{([^}]*)\}/.exec(clause)?.[1] ?? '')
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((entry) => entry.split(/\bas\b/)[0]?.trim() ?? '')
|
|
46
|
+
.filter((name) => name.length > 0 && !name.startsWith('type '));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which live hooks one module imports, or `[]` — including for a module that guards, which is a
|
|
50
|
+
* per-FILE verdict on purpose: the guard is written next to the read it protects.
|
|
51
|
+
*/
|
|
52
|
+
export function liveHooksIn(source: string): readonly string[] {
|
|
53
|
+
const hooks: string[] = [];
|
|
54
|
+
for (const match of source.matchAll(REALTIME_IMPORT)) {
|
|
55
|
+
const clause = match[1] ?? '';
|
|
56
|
+
if (clause.trimStart().startsWith('type ')) continue;
|
|
57
|
+
const names = bindingsOf(clause);
|
|
58
|
+
if (names.includes(GUARD)) return [];
|
|
59
|
+
for (const hook of LIVE_HOOKS) if (names.includes(hook)) hooks.push(hook);
|
|
60
|
+
}
|
|
61
|
+
return hooks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a relative specifier can be on disk. The list `fix-imports.ts` already resolves against. */
|
|
65
|
+
const candidates = (base: string): readonly string[] => [
|
|
66
|
+
`${base}.ts`,
|
|
67
|
+
`${base}.tsx`,
|
|
68
|
+
`${base}/index.ts`,
|
|
69
|
+
`${base}/index.tsx`,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
async function readModule(
|
|
73
|
+
root: string,
|
|
74
|
+
file: string,
|
|
75
|
+
): Promise<{ path: string; source: string } | undefined> {
|
|
76
|
+
for (const path of file.endsWith('.ts') || file.endsWith('.tsx') ? [file] : candidates(file)) {
|
|
77
|
+
const handle = Bun.file(join(root, path));
|
|
78
|
+
if (await handle.exists()) return { path, source: await handle.text() };
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where a route's graph first reaches a live hook. */
|
|
84
|
+
export interface LiveReach {
|
|
85
|
+
/** App-root-relative module that imports it. */
|
|
86
|
+
readonly at: string;
|
|
87
|
+
readonly hook: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walk the route module's own import graph and answer the first live hook in it.
|
|
92
|
+
*
|
|
93
|
+
* Relative specifiers only. A bare one resolves through `node_modules` or a workspace name, and
|
|
94
|
+
* following either would mean guessing which package a name came from — the limit `fix-imports.ts`
|
|
95
|
+
* records for the same walk. So this UNDER-reports rather than over-reports: a finding here is
|
|
96
|
+
* always a real one, which is what lets the rule ship with no pin table.
|
|
97
|
+
*/
|
|
98
|
+
export async function liveReachOf(root: string, file: string): Promise<LiveReach | undefined> {
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
const queue = [file];
|
|
101
|
+
while (queue.length > 0) {
|
|
102
|
+
const next = queue.shift();
|
|
103
|
+
if (next === undefined || seen.has(next)) continue;
|
|
104
|
+
seen.add(next);
|
|
105
|
+
const module = await readModule(root, next);
|
|
106
|
+
if (module === undefined) continue;
|
|
107
|
+
const hook = liveHooksIn(module.source)[0];
|
|
108
|
+
if (hook !== undefined) return { at: module.path, hook };
|
|
109
|
+
const loader = module.path.endsWith('x') ? 'tsx' : 'ts';
|
|
110
|
+
// Bun's transpiler is the parser, exactly as in `scripts/boundaries.ts`: it erases type-only
|
|
111
|
+
// imports and finds the dynamic ones, which no regex over this source could do.
|
|
112
|
+
for (const scanned of new Bun.Transpiler({ loader }).scanImports(module.source)) {
|
|
113
|
+
if (!scanned.path.startsWith('.')) continue;
|
|
114
|
+
queue.push(posix.normalize(posix.join(posix.dirname(module.path), scanned.path)));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface LiveRouteGap extends LiveReach {
|
|
121
|
+
readonly route: string;
|
|
122
|
+
readonly file: string;
|
|
123
|
+
/** What the route declares. `'never'` is the second way nothing boots. */
|
|
124
|
+
readonly hydrate: string;
|
|
125
|
+
readonly islands: readonly string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The `x g island` invocation that fixes it, built from this route's own file — never a placeholder. */
|
|
129
|
+
const generatorFor = (file: string): string => {
|
|
130
|
+
const dir = posix.dirname(file);
|
|
131
|
+
return `x g island ${posix.basename(dir)} --at ${dir}`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Every route that reads live rows with nothing to receive them. Two shapes, one condition — no
|
|
136
|
+
* island at all, and an island the route declares `hydrate: 'never'` for. `X_ISLAND_NOT_HYDRATED`
|
|
137
|
+
* covers the second only at render time, and only for a render that reaches the island, so a route
|
|
138
|
+
* can hold the contradiction and never be asked.
|
|
139
|
+
*/
|
|
140
|
+
export async function liveRouteGaps(
|
|
141
|
+
root: string,
|
|
142
|
+
entries: readonly RouteEntry[],
|
|
143
|
+
): Promise<readonly LiveRouteGap[]> {
|
|
144
|
+
const gaps: LiveRouteGap[] = [];
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (entry.surface === 'api') continue;
|
|
147
|
+
if (entry.islands.length > 0 && entry.config.hydrate !== 'never') continue;
|
|
148
|
+
const reach = await liveReachOf(root, entry.file);
|
|
149
|
+
if (reach === undefined) continue;
|
|
150
|
+
gaps.push({
|
|
151
|
+
...reach,
|
|
152
|
+
route: entry.path,
|
|
153
|
+
file: entry.file,
|
|
154
|
+
hydrate: entry.config.hydrate,
|
|
155
|
+
islands: entry.islands,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return gaps;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const liveRouteFindingFor = (gap: LiveRouteGap): Finding => ({
|
|
162
|
+
code: 'X_LIVE_ROUTE_NO_ISLAND',
|
|
163
|
+
cause:
|
|
164
|
+
`${gap.route} reads ${gap.hook}() (${gap.at}) and ` +
|
|
165
|
+
(gap.islands.length === 0
|
|
166
|
+
? 'declares no island'
|
|
167
|
+
: `declares hydrate: 'never' beside ${gap.islands.join(', ')}`) +
|
|
168
|
+
', so no module of this route ever runs in a browser: its rows have nowhere to arrive and the page renders its loading branch forever, at 200',
|
|
169
|
+
fix:
|
|
170
|
+
`${generatorFor(gap.file)}, declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) above defineRoute in ${gap.file}, ` +
|
|
171
|
+
`and move the ${gap.hook}() read into its mount() — which is where setLiveClient() can be called`,
|
|
172
|
+
docs: ERROR_DOCS_URL,
|
|
173
|
+
at: gap.at,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What this rule contributes to `x verify`'s `budgets` step — the step that already loaded the app
|
|
178
|
+
* and already asks what JavaScript a route's document boots.
|
|
179
|
+
*/
|
|
180
|
+
export const liveRouteFindings = async (root: string): Promise<readonly Finding[]> =>
|
|
181
|
+
(await liveRouteGaps(root, routeEntries())).map(liveRouteFindingFor);
|
package/src/mcp-errors.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { describeErrorCode, hasErrorCode, listErrorCodes } from '@ultimat3/core';
|
|
6
6
|
import type { ErrorExplanation } from '@ultimat3/mcp';
|
|
7
7
|
import type { CliErrorCode } from './error-codes';
|
|
8
|
-
import { CLI_ERROR_CODES
|
|
8
|
+
import { CLI_ERROR_CODES } from './error-codes';
|
|
9
9
|
import { codeFixes, codeFixScan } from './error-fixes';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -48,6 +48,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
48
48
|
X_JOB_UNKNOWN: 'x jobs ls --json',
|
|
49
49
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
50
50
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
51
|
+
X_ERROR_FIX_PATH_MISSING:
|
|
52
|
+
'x verify --json # the finding names the fix line and the path it cites',
|
|
51
53
|
X_WORKSPACE_DEP_UNDECLARED:
|
|
52
54
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
53
55
|
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
@@ -89,6 +91,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
89
91
|
// this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
|
|
90
92
|
// same code. Byte-identical to `checkBudgets`'s own finding, which is the other half of the pair.
|
|
91
93
|
X_BUDGET_UNMEASURED: 'x build --target static --json && x verify --json',
|
|
94
|
+
// `x routes` first, because the finding is about a ROUTE and the table names its file and its
|
|
95
|
+
// islands; the generator that fixes it takes the directory that table just printed.
|
|
96
|
+
X_LIVE_ROUTE_NO_ISLAND: 'x routes --json # then: x g island <route-dir> --at <route-dir>',
|
|
92
97
|
X_BUILD_FAILED: 'x build --json # the finding names the failing step',
|
|
93
98
|
X_BUILD_ENTRY_MISSING:
|
|
94
99
|
'x new scratch-app --dry-run --json # the file list names every entry a build needs',
|
|
@@ -214,7 +219,7 @@ export function explainErrorCode(code: string): ErrorExplanation | undefined {
|
|
|
214
219
|
code,
|
|
215
220
|
cause: described.title,
|
|
216
221
|
fix: cli ? CLI_FIXES[code] : projectedFix(code),
|
|
217
|
-
docs:
|
|
222
|
+
docs: described.docs,
|
|
218
223
|
};
|
|
219
224
|
}
|
|
220
225
|
|
package/src/messages.ts
CHANGED
|
@@ -135,10 +135,12 @@ const CATALOG = {
|
|
|
135
135
|
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
136
136
|
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
137
137
|
'cli.mcp.scopes': ' scopes {scopes}',
|
|
138
|
-
// `
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// `bin/setup` and nothing else: the scaffold ships it, `README.md` and `bin/check` both name it,
|
|
139
|
+
// and it is the only spelling that is right on a fresh clone — it installs, writes
|
|
140
|
+
// `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
|
|
141
|
+
// drift step is red until it has), migrates and seeds. The four-command line this replaced named
|
|
142
|
+
// `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
|
|
143
|
+
'cli.new.done': 'created {name} — next: cd {name} && bin/setup && x dev',
|
|
142
144
|
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
143
145
|
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
144
146
|
// command is a broken one — the same split `Finding.fix` already makes.
|