@ultimat3/cli 10.0.0 → 11.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 +98 -0
- package/package.json +28 -26
- package/src/budgets.ts +6 -0
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-new.ts +34 -4
- package/src/command.ts +12 -0
- package/src/dev-assets.ts +6 -0
- package/src/dev-hooks.ts +8 -0
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dispatch.ts +8 -0
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +15 -0
- package/src/error-contract.ts +61 -4
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +25 -0
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/hold.ts +73 -7
- package/src/index.ts +24 -1
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +7 -0
- package/src/messages.ts +6 -4
- package/src/prerender.ts +32 -3
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- 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-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +1 -0
- package/src/ts-scan.ts +117 -6
- package/src/verify-checks.ts +17 -3
package/src/error-contract.ts
CHANGED
|
@@ -9,11 +9,12 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
10
|
import { citedCommandProblem, loadCommandCatalog } from './fix-command';
|
|
11
11
|
import { createHelperResolver } from './fix-imports';
|
|
12
|
+
import { citedPathProblem, FILE_TOKEN_PATTERN } from './fix-path';
|
|
12
13
|
import { scanFixSites } from './fix-scan';
|
|
13
14
|
import type { Finding } from './output';
|
|
14
15
|
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
15
|
-
import type { CodeSite, FixSite } from './ts-scan';
|
|
16
|
-
import { isCodeRegistry, scanBorrowedCodes, scanCodes } from './ts-scan';
|
|
16
|
+
import type { CodeSite, FixSite, UnresolvedCodeSite } from './ts-scan';
|
|
17
|
+
import { isCodeRegistry, scanBorrowedCodes, scanCodeDeclarations, scanCodes } from './ts-scan';
|
|
17
18
|
|
|
18
19
|
/** Advice, not instruction. The list is the one in `docs/architecture/04-error-contract.md`. */
|
|
19
20
|
export const BANNED_PHRASES: readonly RegExp[] = [
|
|
@@ -38,7 +39,9 @@ export const COMMAND_TOKENS: readonly RegExp[] = [
|
|
|
38
39
|
/(?:^|[\s;|&("'`])x\s+[a-z][a-z-]*/,
|
|
39
40
|
/\b(?:bun|bunx|npm|npx|node|git|docker|kubectl|helm|psql|curl|openssl|biome|tsc)\b/,
|
|
40
41
|
/\b[A-Za-z_$][\w$]*\(/,
|
|
41
|
-
|
|
42
|
+
// Built from `fix-path.ts`'s extension list, because the token that makes a fix count as an
|
|
43
|
+
// instruction is exactly the token `citedPathProblem` then has to resolve.
|
|
44
|
+
new RegExp(FILE_TOKEN_PATTERN),
|
|
42
45
|
/\b(?:app\.config\.ts|package\.json|tsconfig\.json|bunfig\.toml|\.env(?:\.[\w.-]+)?)\b/,
|
|
43
46
|
];
|
|
44
47
|
|
|
@@ -67,6 +70,19 @@ const fixFinding = (site: FixSite, problem: string): Finding => ({
|
|
|
67
70
|
at: `${site.at}:${site.line}`,
|
|
68
71
|
});
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Its own code, not `X_ERROR_FIX_INVALID`: that one means the fix is not an instruction, and this
|
|
75
|
+
* one means it IS one and points at nothing. The repairs are different — rewrite the sentence
|
|
76
|
+
* versus correct the path — and one code for both would hand two readers the same wrong edit.
|
|
77
|
+
*/
|
|
78
|
+
const pathFinding = (site: FixSite, problem: string): Finding => ({
|
|
79
|
+
code: 'X_ERROR_FIX_PATH_MISSING',
|
|
80
|
+
cause: `the fix at ${site.at}:${site.line} ${problem}`,
|
|
81
|
+
fix: `correct the path in the fix at ${site.at}:${site.line} to one this repo holds, or create the file it names`,
|
|
82
|
+
docs: ERROR_DOCS_URL,
|
|
83
|
+
at: `${site.at}:${site.line}`,
|
|
84
|
+
});
|
|
85
|
+
|
|
70
86
|
/**
|
|
71
87
|
* Every `fix:` an agent can be handed, read out of shipped source and held to BOTH rules: it must
|
|
72
88
|
* be an instruction, and any `x <command>` it cites must be one this build ships.
|
|
@@ -110,7 +126,14 @@ export async function checkErrorFixReport(root: string): Promise<ErrorFixReport>
|
|
|
110
126
|
// resolve, and reading `<value>` as one would be a finding nobody can act on.
|
|
111
127
|
const fix = staticFix(site.fix);
|
|
112
128
|
const problem = fixProblem(site.fix) ?? citedCommandProblem(fix, catalog);
|
|
113
|
-
if (problem !== undefined)
|
|
129
|
+
if (problem !== undefined) {
|
|
130
|
+
findings.push(fixFinding(site, problem));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Third rule, and the one nothing resolved: a fix that names a file this repo does not have.
|
|
134
|
+
// Reported only where the first two hold — a line already being rewritten needs one finding.
|
|
135
|
+
const missing = await citedPathProblem(fix, root);
|
|
136
|
+
if (missing !== undefined) findings.push(pathFinding(site, missing));
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
139
|
return { findings, checked, unreadable };
|
|
@@ -236,6 +259,40 @@ export async function collectDeclaredCodes(root: string): Promise<readonly CodeS
|
|
|
236
259
|
return [...sites.values()].map(([site]) => site).sort((a, b) => a.code.localeCompare(b.code));
|
|
237
260
|
}
|
|
238
261
|
|
|
262
|
+
/**
|
|
263
|
+
* A `code:` this scan could not turn into a code. Its own finding rather than a silent skip, which
|
|
264
|
+
* is the whole of #277: `const STALE = 'X_DOC_PACKAGE_GRAPH_STALE'` and then `code: STALE` is the
|
|
265
|
+
* DRY thing to write, `scripts/package-map-graph.ts` wrote it, and the code went into no manifest,
|
|
266
|
+
* demanded no row on the reference, was exempt from `bun run gate-codes` and could not be explained
|
|
267
|
+
* — every reader silent, and every one of them permissive. Resolution closes the same-file case;
|
|
268
|
+
* this closes the rest, because a scanner that reads only what it likes enforces only what it sees.
|
|
269
|
+
*/
|
|
270
|
+
const unresolvedCodeFinding = (site: UnresolvedCodeSite): Finding => ({
|
|
271
|
+
code: 'X_ERROR_CODE_UNRESOLVED',
|
|
272
|
+
cause: `the code at ${site.at}:${site.line} is the name ${site.name}, and no module-scope const in that file gives it a value — so the manifest, the reference page and "x errors explain" are all blind to whatever code it holds`,
|
|
273
|
+
fix: `write the X_* code as a string literal at ${site.at}:${site.line}, or declare it as a module-scope const in that same file`,
|
|
274
|
+
docs: ERROR_DOCS_URL,
|
|
275
|
+
at: `${site.at}:${site.line}`,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The other half of `collectDeclaredCodes`, on its own walk rather than folded into that one: this
|
|
280
|
+
* asks whether a file's codes are READABLE, and the collector's callers — `x manifest`, the gate's
|
|
281
|
+
* `errors` step, `bun run gate-codes` — want the codes and not the findings. It runs wherever
|
|
282
|
+
* source does, with no reference page to check against, which is why it is not a host check.
|
|
283
|
+
*/
|
|
284
|
+
export async function checkErrorCodeResolution(root: string): Promise<readonly Finding[]> {
|
|
285
|
+
const findings: Finding[] = [];
|
|
286
|
+
for await (const source of eachSourceFile(root)) {
|
|
287
|
+
if (isTest(source) || isGenerated(source)) continue;
|
|
288
|
+
const text = await Bun.file(join(root, source)).text();
|
|
289
|
+
for (const site of scanCodeDeclarations(text, source).unresolved) {
|
|
290
|
+
findings.push(unresolvedCodeFinding(site));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return findings;
|
|
294
|
+
}
|
|
295
|
+
|
|
239
296
|
/**
|
|
240
297
|
* Every `X_*` code shipped source declares must appear on the repo's error reference. The page is
|
|
241
298
|
* the host repo's to name — a framework monorepo publishes one, a generated app does not — which
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The app's own error page, which only a process with a disk can find: `@ultimat3/http` renders the
|
|
2
|
+
// framework's and declares the seam, this file is the half that reads a file. Rails' `public/404.html`,
|
|
3
|
+
// one directory further in — `apps/web/site/` is already where an app's public files live, which is
|
|
4
|
+
// where `favicon.ico` is overridden too.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and the source path is app-root-relative — the same
|
|
7
|
+
// necessity `favicon.ts` records for `FAVICON_SOURCE`.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { renderErrorPage } from '@ultimat3/http';
|
|
10
|
+
import { currentLocale } from '@ultimat3/i18n';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One file per status, named by the status, and NO generic `error.html` beside it: a second rung
|
|
14
|
+
* would need a precedence rule, and a `{{status}}` slot would be a template language the framework
|
|
15
|
+
* does not otherwise have. An app that wants one page for three statuses writes three files.
|
|
16
|
+
*/
|
|
17
|
+
export const ERROR_PAGE_DIR = 'apps/web/site/errors';
|
|
18
|
+
|
|
19
|
+
/** What `x verify`, a `fix:` and this reader all name — never spelled twice. */
|
|
20
|
+
export const errorPageSource = (status: number): string =>
|
|
21
|
+
`${ERROR_PAGE_DIR}/${String(status)}.html`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a static host serves for a path that matches no file. `404.html` at the export root is the
|
|
25
|
+
* convention S3, Cloudflare Pages, Netlify and nginx already look for, so the artifact needs no
|
|
26
|
+
* configuration to answer the way the served process does.
|
|
27
|
+
*/
|
|
28
|
+
export const STATIC_ERROR_PAGE = '404.html';
|
|
29
|
+
|
|
30
|
+
/** A status a `Response` can carry. A number outside it never becomes a path. */
|
|
31
|
+
const isStatus = (status: number): boolean =>
|
|
32
|
+
Number.isInteger(status) && status >= 100 && status <= 599;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The app's page for one status, or `undefined`.
|
|
36
|
+
*
|
|
37
|
+
* Read per REQUEST, never cached at boot, for `favicon.ts`'s reason: `x dev` is a running process
|
|
38
|
+
* an author drops a file into, and a reader that captured "there was none" at startup would keep
|
|
39
|
+
* answering the framework's page until the server was restarted.
|
|
40
|
+
*/
|
|
41
|
+
export async function errorPageOverride(root: string, status: number): Promise<string | undefined> {
|
|
42
|
+
if (!isStatus(status)) return undefined;
|
|
43
|
+
const file = Bun.file(join(root, errorPageSource(status)));
|
|
44
|
+
return (await file.exists()) ? file.text() : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `ServerHooks.errorPage`, bound to one app root. Installed by `startWeb` so `x dev` and the
|
|
49
|
+
* container cannot answer a browser differently — the rule `assetRoutes` already holds for
|
|
50
|
+
* `/favicon.ico`.
|
|
51
|
+
*/
|
|
52
|
+
export const errorPageHook =
|
|
53
|
+
(root: string) =>
|
|
54
|
+
(status: number): Promise<string | undefined> =>
|
|
55
|
+
errorPageOverride(root, status);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The document that goes into a static export: the app's file if it has one, the framework's page
|
|
59
|
+
* otherwise. Its own function because the export has no process to ask, exactly as `faviconBytes`
|
|
60
|
+
* is — a second rule for which page a static build carries would be an artifact that disagrees
|
|
61
|
+
* with the server it was built from.
|
|
62
|
+
*
|
|
63
|
+
* No request id and no pathname: nothing about this file is per-request, and the renderer omits
|
|
64
|
+
* both rather than inventing them.
|
|
65
|
+
*/
|
|
66
|
+
export async function errorPageDocument(root: string, status: number): Promise<string> {
|
|
67
|
+
const own = await errorPageOverride(root, status);
|
|
68
|
+
return (
|
|
69
|
+
own ??
|
|
70
|
+
renderErrorPage({
|
|
71
|
+
status,
|
|
72
|
+
// What the served 404 carries, so the artifact and the process name one code.
|
|
73
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
74
|
+
// The app's default, resolved after `loadApp` registered its catalogs — a build has no
|
|
75
|
+
// request to negotiate against.
|
|
76
|
+
locale: currentLocale(),
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
|
|
3
3
|
// so a package importing a class does not pull the table and vice versa.
|
|
4
4
|
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { quoteArg } from './shell-quote';
|
|
5
6
|
|
|
6
7
|
/** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
|
|
7
8
|
export class UnknownCommandError extends UltimateError {
|
|
@@ -47,6 +48,30 @@ export class MissingPositionalError extends UltimateError {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* `x new /srv/apps/shop` — a PATH where a NAME goes.
|
|
53
|
+
*
|
|
54
|
+
* `names()` slugifies whatever it is given, so the separators became hyphens and the whole path
|
|
55
|
+
* turned into ONE directory inside the cwd: `x new /tmp/probe/vision` wrote `tmp-probe-vision`
|
|
56
|
+
* into the current directory and `git init`-ed it there. Nothing failed, and the app the caller
|
|
57
|
+
* asked for did not exist.
|
|
58
|
+
*
|
|
59
|
+
* `--dir` is the flag that takes a path, so the two are one flag apart and the fix is the
|
|
60
|
+
* invocation the caller meant — built from what they typed, never a placeholder.
|
|
61
|
+
*/
|
|
62
|
+
export class AppNameIsPathError extends UltimateError {
|
|
63
|
+
constructor(input: { name: string; parent: string; base: string; invocation?: string }) {
|
|
64
|
+
// The invocation the caller typed, not the literal `x new`: the same command is the whole of
|
|
65
|
+
// `bunx create-ultimate`, which runs before `x` is installed.
|
|
66
|
+
const invocation = input.invocation ?? 'x new';
|
|
67
|
+
super({
|
|
68
|
+
code: 'X_CLI_BAD_FLAG',
|
|
69
|
+
cause: `"${invocation}" takes a NAME and got the path "${input.name}" — it would be slugified into one directory name`,
|
|
70
|
+
fix: `${invocation} ${quoteArg(input.base)} --dir ${quoteArg(input.parent)}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
|
|
52
77
|
*
|
package/src/favicon.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// `/favicon.ico`, which every browser requests unprompted and no Ultimate app answered: the
|
|
2
|
+
// scaffold wrote no file and neither served surface mounted a route, so a permanent 404 sat in the
|
|
3
|
+
// console of every app the framework produces. The app's own file wins; the framework answers when
|
|
4
|
+
// there is none, so an app inherits a clean console rather than a file it has to remember to add.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and `FAVICON_SOURCE` is app-root-relative, so resolving
|
|
7
|
+
// it against the root is string work no `Bun.file` overload does — the same necessity
|
|
8
|
+
// `dev-assets.ts` records for `ICON_SOURCE`.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { createRaster, encodeImage } from '@ultimat3/core';
|
|
11
|
+
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
12
|
+
import { applyCacheHeaders } from '@ultimat3/http';
|
|
13
|
+
|
|
14
|
+
/** What a browser asks for with no `<link rel="icon">` to tell it otherwise. */
|
|
15
|
+
export const FAVICON_PATH = '/favicon.ico';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The one file an app overrides it with — beside `ICON_SOURCE`, in the same directory, because
|
|
19
|
+
* `apps/web/site/` is already where an app's public files live. One path, never a search order: a
|
|
20
|
+
* mechanism that accepted `favicon.png` too would be two ways to do one thing, and an app whose
|
|
21
|
+
* icon is not there would have to be told which of them won.
|
|
22
|
+
*/
|
|
23
|
+
export const FAVICON_SOURCE = 'apps/web/site/favicon.ico';
|
|
24
|
+
|
|
25
|
+
/** 32px, which is what a browser tab and a bookmark bar both ask for. */
|
|
26
|
+
const DEFAULT_SIZE = 32;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Not a colour: one mid-grey LEVEL written to all three channels, exactly as
|
|
30
|
+
* `templates/scaffold-icon.ts` argues — `@ultimat3/ui` owns the colour roles and `cli -> ui` is a
|
|
31
|
+
* boundary error, so a placeholder here must claim no brand colour to begin with.
|
|
32
|
+
*/
|
|
33
|
+
const MARK_LEVEL = 128;
|
|
34
|
+
const OPAQUE = 255;
|
|
35
|
+
|
|
36
|
+
const FAVICON_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The framework's answer when the app declares none: a solid 32x32 PNG, encoded through
|
|
40
|
+
* `@ultimat3/core`'s own pipeline rather than shipped as an opaque blob — the same encoder
|
|
41
|
+
* `x new`'s icon goes through, so there is no second image format in the tree and no base64
|
|
42
|
+
* constant nobody can verify. Served as `image/png` under an `.ico` URL, which every browser reads
|
|
43
|
+
* by content type; a real ICO container would be a fourth format for one placeholder.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately NOT derived from `ICON_SOURCE`: resizing the app's install icon needs
|
|
46
|
+
* `@ultimat3/pwa`'s pipeline and would make the answer depend on a file that may be missing, which
|
|
47
|
+
* is a third rung under a mechanism that has exactly two.
|
|
48
|
+
*/
|
|
49
|
+
export function defaultFavicon(): Uint8Array {
|
|
50
|
+
const raster = createRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
|
|
51
|
+
const { pixels } = raster;
|
|
52
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
53
|
+
pixels[i] = MARK_LEVEL;
|
|
54
|
+
pixels[i + 1] = MARK_LEVEL;
|
|
55
|
+
pixels[i + 2] = MARK_LEVEL;
|
|
56
|
+
pixels[i + 3] = OPAQUE;
|
|
57
|
+
}
|
|
58
|
+
return encodeImage(raster, 'png');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Encoded once per process: the bytes are a pure function of two constants. */
|
|
62
|
+
let builtin: Uint8Array | undefined;
|
|
63
|
+
|
|
64
|
+
const builtinBytes = (): Uint8Array => {
|
|
65
|
+
builtin ??= defaultFavicon();
|
|
66
|
+
return builtin;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const iconResponse = (bytes: Uint8Array, contentType: string): Response =>
|
|
70
|
+
applyCacheHeaders(
|
|
71
|
+
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
72
|
+
// `SharedArrayBuffer`, which `Response` does not accept — `dev-assets.ts`'s rule, verbatim.
|
|
73
|
+
new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
|
|
74
|
+
FAVICON_CACHE,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** What a surface answers with, and what it says the bytes are. */
|
|
78
|
+
export interface FaviconBytes {
|
|
79
|
+
readonly bytes: Uint8Array;
|
|
80
|
+
readonly contentType: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read per REQUEST, never once at boot: `x dev` is a running process an author drops a favicon
|
|
85
|
+
* into, and a route that captured "there was no file" at startup would keep answering the
|
|
86
|
+
* placeholder until the server was restarted — the class of dev/prod difference this package's
|
|
87
|
+
* own header forbids.
|
|
88
|
+
*
|
|
89
|
+
* Its own function because the static export has no process to ask: `prerenderSite` writes these
|
|
90
|
+
* same bytes into the artifact, and a second rule for which favicon a static build carries would
|
|
91
|
+
* be a build whose tab icon differs from the server's.
|
|
92
|
+
*/
|
|
93
|
+
export async function faviconBytes(root: string): Promise<FaviconBytes> {
|
|
94
|
+
const own = Bun.file(join(root, FAVICON_SOURCE));
|
|
95
|
+
if (await own.exists()) return { bytes: await own.bytes(), contentType: 'image/x-icon' };
|
|
96
|
+
return { bytes: builtinBytes(), contentType: 'image/png' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function faviconResponse(root: string): Promise<Response> {
|
|
100
|
+
const favicon = await faviconBytes(root);
|
|
101
|
+
return iconResponse(favicon.bytes, favicon.contentType);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mounted through `assetRoutes`, so `x dev`, the container and every test that boots either get it
|
|
106
|
+
* from one place. Public by definition — a browser requests it before anyone has signed in.
|
|
107
|
+
*/
|
|
108
|
+
export const faviconRoute = (root: string): Route => ({
|
|
109
|
+
method: 'GET',
|
|
110
|
+
path: FAVICON_PATH,
|
|
111
|
+
meta: { name: 'assets.favicon', auth: 'public', cache: FAVICON_CACHE, tags: ['assets'] },
|
|
112
|
+
handler: async (_request: UltimateRequest): Promise<Response> => faviconResponse(root),
|
|
113
|
+
});
|
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/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,
|
|
@@ -129,6 +130,7 @@ export {
|
|
|
129
130
|
COMMAND_TOKENS,
|
|
130
131
|
checkErrorCodeDocs,
|
|
131
132
|
checkErrorCodeRegistry,
|
|
133
|
+
checkErrorCodeResolution,
|
|
132
134
|
checkErrorFixes,
|
|
133
135
|
checkErrorFixReport,
|
|
134
136
|
collectDeclaredCodes,
|
|
@@ -167,6 +169,13 @@ export {
|
|
|
167
169
|
} from './errors';
|
|
168
170
|
export type { ExecOptions, ExecResult, Runner } from './exec';
|
|
169
171
|
export { exec, execOutput } from './exec';
|
|
172
|
+
export {
|
|
173
|
+
defaultFavicon,
|
|
174
|
+
FAVICON_PATH,
|
|
175
|
+
FAVICON_SOURCE,
|
|
176
|
+
faviconResponse,
|
|
177
|
+
faviconRoute,
|
|
178
|
+
} from './favicon';
|
|
170
179
|
export type { CitationFault, CitationRules, CommandCatalog, FixCitation } from './fix-command';
|
|
171
180
|
export {
|
|
172
181
|
citationFault,
|
|
@@ -177,6 +186,12 @@ export {
|
|
|
177
186
|
} from './fix-command';
|
|
178
187
|
export type { HelperResolver } from './fix-imports';
|
|
179
188
|
export { candidatePaths, createHelperResolver, scanImports } from './fix-imports';
|
|
189
|
+
export {
|
|
190
|
+
CITED_FILE_EXTENSIONS,
|
|
191
|
+
citedPathProblem,
|
|
192
|
+
FILE_TOKEN_PATTERN,
|
|
193
|
+
pathCitations,
|
|
194
|
+
} from './fix-path';
|
|
180
195
|
export type { FixHelper, FixScan } from './fix-scan';
|
|
181
196
|
export { scanFixes, scanFixHelpers, scanFixSites } from './fix-scan';
|
|
182
197
|
export type { DeclaredFlag } from './flag-reads';
|
|
@@ -269,11 +284,19 @@ export { belongsToType, discoverTests, sampleFiles } from './test-select';
|
|
|
269
284
|
export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
|
|
270
285
|
export { planShards, reproduceFor, runShards, shardArgs } from './test-shards';
|
|
271
286
|
export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
|
|
272
|
-
export type {
|
|
287
|
+
export type {
|
|
288
|
+
CodeFixSite,
|
|
289
|
+
CodeScan,
|
|
290
|
+
CodeSite,
|
|
291
|
+
FixSite,
|
|
292
|
+
SourceSite,
|
|
293
|
+
UnresolvedCodeSite,
|
|
294
|
+
} from './ts-scan';
|
|
273
295
|
export {
|
|
274
296
|
isCodeRegistry,
|
|
275
297
|
maskLiterals,
|
|
276
298
|
scanBorrowedCodes,
|
|
299
|
+
scanCodeDeclarations,
|
|
277
300
|
scanCodeFixSites,
|
|
278
301
|
scanCodes,
|
|
279
302
|
stripComments,
|