@ultimat3/cli 2.0.0 → 4.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 +109 -13
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +31 -8
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +10 -14
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +80 -10
- package/src/command.ts +10 -2
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-roles.ts +5 -3
- package/src/dev-runtime.ts +2 -2
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/dispatch.ts +33 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +11 -0
- package/src/error-contract.ts +31 -4
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-number.ts +11 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +19 -5
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +13 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +15 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +25 -176
- package/src/tsconfig-references.ts +27 -2
- package/src/verify-step.ts +5 -0
package/src/error-catalog.ts
CHANGED
package/src/error-codes.ts
CHANGED
|
@@ -67,6 +67,11 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
67
67
|
'X_DB_MIGRATE_FAILED',
|
|
68
68
|
'X_DB_BRANCH_FAILED',
|
|
69
69
|
'X_DB_STUDIO_FAILED',
|
|
70
|
+
// Refusing to seed production is a REFUSAL, not a malformed invocation, so it is not
|
|
71
|
+
// `X_CLI_BAD_FLAG`: the argv was well formed and the answer is no. Its own code is what lets
|
|
72
|
+
// `x errors explain` hand back the one remedy — name the tier — instead of the flag code's
|
|
73
|
+
// "unknown flag, missing value, or a value the command refuses", which covers a dozen causes.
|
|
74
|
+
'X_SEED_ENVIRONMENT',
|
|
70
75
|
// The five app-surface boundary codes. `@ultimat3/render` owns the *rule* (`checkSurfaceBoundary`)
|
|
71
76
|
// and the CLI owns the diagnostic, because `x verify` and `x fix boundary` are the two commands
|
|
72
77
|
// that report it — see `app-boundaries.ts`, which holds the one rule-to-code table.
|
|
@@ -81,6 +86,10 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
81
86
|
'X_GUARD_INVALID',
|
|
82
87
|
'X_GUARD_FAILED',
|
|
83
88
|
'X_GUARD_FINDING_INVALID',
|
|
89
|
+
// The CLI's own declarations, held to each other. A flag the parser accepts and no code reads
|
|
90
|
+
// is a promise in `x help` with nothing behind it — `x deploy --critical` said "forces clients
|
|
91
|
+
// to reload" and reached no reader outside the plan JSON it was written into.
|
|
92
|
+
'X_CLI_FLAG_UNREAD',
|
|
84
93
|
// The two halves of `x secrets edit` that belong to the terminal rather than to the envelope.
|
|
85
94
|
// `@ultimat3/core` owns every X_SECRETS_* code about the file and the key; an editor is the
|
|
86
95
|
// CLI's problem alone, and core would have no `fix:` to offer for one.
|
|
@@ -165,6 +174,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
165
174
|
X_DB_MIGRATE_FAILED: 'x db migrate failed',
|
|
166
175
|
X_DB_BRANCH_FAILED: 'an x db branch step failed',
|
|
167
176
|
X_DB_STUDIO_FAILED: 'x db studio failed',
|
|
177
|
+
X_SEED_ENVIRONMENT: 'the seed tier is not one this environment runs',
|
|
168
178
|
X_BOUNDARY_SITE_TO_APP: 'site/ imported app/',
|
|
169
179
|
X_BOUNDARY_SHARED_LEAF: 'shared/ imported a surface',
|
|
170
180
|
X_BOUNDARY_APP_TO_API: 'app/ imported api/ at runtime',
|
|
@@ -173,6 +183,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
173
183
|
X_GUARD_INVALID: 'a file in guards/ exports no usable guard',
|
|
174
184
|
X_GUARD_FAILED: 'an app guard threw instead of returning findings',
|
|
175
185
|
X_GUARD_FINDING_INVALID: "an app guard's finding breaks the error contract",
|
|
186
|
+
X_CLI_FLAG_UNREAD: 'a command declares a flag no code reads',
|
|
176
187
|
X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
|
|
177
188
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
178
189
|
};
|
package/src/error-contract.ts
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { docsFor } from './error-codes';
|
|
10
10
|
import { citedCommandProblem, loadCommandCatalog } from './fix-command';
|
|
11
|
+
import { createHelperResolver } from './fix-imports';
|
|
12
|
+
import { scanFixSites } from './fix-scan';
|
|
11
13
|
import type { Finding } from './output';
|
|
12
14
|
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
13
15
|
import type { CodeSite, FixSite } from './ts-scan';
|
|
14
|
-
import { isCodeRegistry, scanBorrowedCodes, scanCodes
|
|
16
|
+
import { isCodeRegistry, scanBorrowedCodes, scanCodes } from './ts-scan';
|
|
15
17
|
|
|
16
18
|
/** Advice, not instruction. The list is the one in `docs/architecture/04-error-contract.md`. */
|
|
17
19
|
export const BANNED_PHRASES: readonly RegExp[] = [
|
|
@@ -77,12 +79,33 @@ const fixFinding = (site: FixSite, problem: string): Finding => ({
|
|
|
77
79
|
* The catalog is loaded ONCE per run rather than per fix line: it is a dynamic import (see
|
|
78
80
|
* `fix-command.ts` for the cycle it breaks) and this walks every shipped source file.
|
|
79
81
|
*/
|
|
80
|
-
export
|
|
82
|
+
export interface ErrorFixReport {
|
|
83
|
+
readonly findings: readonly Finding[];
|
|
84
|
+
/** Fix literals actually read, and held to both rules. */
|
|
85
|
+
readonly checked: number;
|
|
86
|
+
/**
|
|
87
|
+
* Fix arguments at a known builder that hold no single literal — a parameter passed through, a
|
|
88
|
+
* concatenation, a table lookup. The step prints it, because a gate that says "checked 412,
|
|
89
|
+
* could not read 27" is honest and one that says nothing is the false green this check exists to
|
|
90
|
+
* close. It does NOT cover a builder imported from another PACKAGE: `candidatePaths` resolves
|
|
91
|
+
* relative specifiers only, and that gap is 3 call sites across this repo, measured 2026-08.
|
|
92
|
+
*/
|
|
93
|
+
readonly unreadable: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function checkErrorFixReport(root: string): Promise<ErrorFixReport> {
|
|
81
97
|
const findings: Finding[] = [];
|
|
82
98
|
const catalog = await loadCommandCatalog();
|
|
99
|
+
const imports = createHelperResolver(root);
|
|
100
|
+
let checked = 0;
|
|
101
|
+
let unreadable = 0;
|
|
83
102
|
for await (const path of eachSourceFile(root)) {
|
|
84
103
|
if (isTest(path) || isGenerated(path)) continue;
|
|
85
|
-
|
|
104
|
+
const source = await Bun.file(join(root, path)).text();
|
|
105
|
+
const scan = scanFixSites(source, path, await imports(path, source));
|
|
106
|
+
checked += scan.sites.length;
|
|
107
|
+
unreadable += scan.unreadable;
|
|
108
|
+
for (const site of scan.sites) {
|
|
86
109
|
// The interpolation-blanked form for both rules: `x ${name}` names no command this can
|
|
87
110
|
// resolve, and reading `<value>` as one would be a finding nobody can act on.
|
|
88
111
|
const fix = staticFix(site.fix);
|
|
@@ -90,9 +113,13 @@ export async function checkErrorFixes(root: string): Promise<readonly Finding[]>
|
|
|
90
113
|
if (problem !== undefined) findings.push(fixFinding(site, problem));
|
|
91
114
|
}
|
|
92
115
|
}
|
|
93
|
-
return findings;
|
|
116
|
+
return { findings, checked, unreadable };
|
|
94
117
|
}
|
|
95
118
|
|
|
119
|
+
/** The findings alone, for every caller that reports no coverage line. */
|
|
120
|
+
export const checkErrorFixes = async (root: string): Promise<readonly Finding[]> =>
|
|
121
|
+
(await checkErrorFixReport(root)).findings;
|
|
122
|
+
|
|
96
123
|
/**
|
|
97
124
|
* A code is documented when the reference page names it. Deliberately not "owns a table row": the
|
|
98
125
|
* page legitimately groups near-identical codes onto one row, and a rule that forbade that would
|
package/src/exec.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// `UltimateError` straight from core rather than a class in `./errors`: this module is imported by
|
|
6
6
|
// every command, and `./errors` runs `registerErrorCodes` on import — a subprocess boundary must
|
|
7
7
|
// not decide when the CLI's registry is populated. `X_CLI_UNEXPECTED` is owned there all the same.
|
|
8
|
-
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
9
|
+
// `shell-quote.ts` is a leaf — it imports nothing, so the subprocess boundary stays importable
|
|
10
|
+
// from anywhere, this file's header rule about a single boundary included.
|
|
11
|
+
import { quoteArg } from './shell-quote';
|
|
9
12
|
|
|
10
13
|
export interface ExecResult {
|
|
11
14
|
readonly command: readonly string[];
|
|
@@ -27,6 +30,43 @@ export type Runner = (command: readonly string[], options: ExecOptions) => Promi
|
|
|
27
30
|
/** performance.now(), not Date.now(): the test preload freezes the wall clock on purpose. */
|
|
28
31
|
const now = (): number => performance.now();
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The failure mode this file's header already promised was identical everywhere, and the one it
|
|
35
|
+
* never coded. `x deploy` on a machine without `docker` threw Bun's own `Error: Executable not
|
|
36
|
+
* found in $PATH`; `dispatch.ts` rendered it as `X_CLI_UNEXPECTED` with `fix: x doctor --json`,
|
|
37
|
+
* and `runDoctor` checks nothing about an absent binary — an instruction that cannot close the
|
|
38
|
+
* error is axiom 4 inverted. The code stays `X_CLI_UNEXPECTED` (the CLI already owns it for a
|
|
39
|
+
* failure of its own machinery); what changes is that the fix names the program to install.
|
|
40
|
+
*
|
|
41
|
+
* The program name goes through `quoteArg` at BOTH references: `docker compose` or any name a
|
|
42
|
+
* shell would resplit produced a `fix:` that runs something else, which is axiom 4 inverted twice
|
|
43
|
+
* in one line.
|
|
44
|
+
*
|
|
45
|
+
* The thrown value goes through core's render helper and is never interpolated: an `unknown`
|
|
46
|
+
* reaching a `cause:` through `${…}` is what `bun run error-render` refuses, and this one is
|
|
47
|
+
* genuinely unknown — Bun raises `ENOENT` for a missing program, `EACCES` for an unrunnable one.
|
|
48
|
+
*
|
|
49
|
+
* The return type is inferred so this stays one statement of `Bun.spawn`'s own shape.
|
|
50
|
+
*/
|
|
51
|
+
function spawnOrRefuse(command: readonly string[], options: ExecOptions) {
|
|
52
|
+
const [head = '', ...rest] = command;
|
|
53
|
+
try {
|
|
54
|
+
return Bun.spawn([head, ...rest], {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
57
|
+
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
58
|
+
stdout: 'pipe',
|
|
59
|
+
stderr: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new UltimateError({
|
|
63
|
+
code: 'X_CLI_UNEXPECTED',
|
|
64
|
+
cause: `the CLI could not run "${head}" from ${options.cwd}: ${renderThrowable(error)}`,
|
|
65
|
+
fix: `install ${quoteArg(head)} and put it on PATH, then re-run — confirm with: command -v ${quoteArg(head)}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
export const exec: Runner = async (command, options) => {
|
|
31
71
|
const started = now();
|
|
32
72
|
const [head, ...rest] = command;
|
|
@@ -40,13 +80,7 @@ export const exec: Runner = async (command, options) => {
|
|
|
40
80
|
fix: 'pass the program as the first element: exec(["bun", "test"], { cwd })',
|
|
41
81
|
});
|
|
42
82
|
}
|
|
43
|
-
const proc =
|
|
44
|
-
cwd: options.cwd,
|
|
45
|
-
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
46
|
-
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
47
|
-
stdout: 'pipe',
|
|
48
|
-
stderr: 'pipe',
|
|
49
|
-
});
|
|
83
|
+
const proc = spawnOrRefuse([head, ...rest], options);
|
|
50
84
|
const [stdout, stderr, code] = await Promise.all([
|
|
51
85
|
new Response(proc.stdout).text(),
|
|
52
86
|
new Response(proc.stderr).text(),
|
package/src/fix-command.ts
CHANGED
|
@@ -28,8 +28,15 @@ import { GLOBAL_FLAGS } from './parse';
|
|
|
28
28
|
// in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
|
|
29
29
|
// was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
|
|
30
30
|
// `x db branch drop <name>`), where a placeholder is exactly right.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// A `:` is part of a word only when a letter follows it, which is what separates the shipped
|
|
32
|
+
// positional `admin:page` from prose that ends a citation with a colon (`x verify: the gate`).
|
|
33
|
+
// Read without it, `x g admin:page` cites `x g admin` — a positional the CLI does not ship —
|
|
34
|
+
// and the one documented invocation of the admin-page generator was a standing false finding.
|
|
35
|
+
const WORD = String.raw`[a-z][a-z\d-]*(?::[a-z][a-z\d-]*)?`;
|
|
36
|
+
const CITATION = new RegExp(
|
|
37
|
+
String.raw`(?:^|[\s;|&("'\x60])x\s+(${WORD})(?:\s+(${WORD}))?(?:\s+(${WORD}|<[^>]*>))?`,
|
|
38
|
+
'g',
|
|
39
|
+
);
|
|
33
40
|
|
|
34
41
|
/**
|
|
35
42
|
* A long flag, `--` stripped. `--no-<name>` is the parser's negation of a boolean, so it resolves
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A fix-building helper the calling file did not declare: `invalidIconDataError` lives in
|
|
2
|
+
// `packages/ui/src/errors.ts` and every fix it is handed is written in `icons/build-icons.ts`.
|
|
3
|
+
// One specifier, one file read, one parameter position — deliberately still not `tsc`.
|
|
4
|
+
|
|
5
|
+
// `dirname`/`join` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import type { FixHelper } from './fix-scan';
|
|
8
|
+
import { scanFixHelpers } from './fix-scan';
|
|
9
|
+
import { endOfLiteral, maskLiterals } from './ts-scan';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A named import, matched over the MASKED source and anchored at the start of a line, so an
|
|
13
|
+
* `import …` written inside a template literal is not read as one — `packages/cli/src/templates/`
|
|
14
|
+
* emits a dozen of them as generated app source, and resolving those pointed the scan at a module
|
|
15
|
+
* that only exists in the app the template writes. Masking blanks a literal's contents and keeps
|
|
16
|
+
* its delimiters, so the specifier is read back out of the raw source at the quote's own offset.
|
|
17
|
+
*
|
|
18
|
+
* `import type` is skipped whole: a type has no call site. A default or namespace import is not
|
|
19
|
+
* matched at all — this repo ships no default exports, and `errors.raise(…)` is a member access
|
|
20
|
+
* that `helperFixSites` refuses by design.
|
|
21
|
+
*/
|
|
22
|
+
const IMPORT_CLAUSE = /^import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]/gm;
|
|
23
|
+
|
|
24
|
+
/** `a`, `a as b`, and the inline `type a` that carries no value. */
|
|
25
|
+
const parseClause = (clause: string): { readonly exported: string; readonly local: string }[] =>
|
|
26
|
+
clause
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((part) => part.trim())
|
|
29
|
+
.filter((part) => part !== '' && !/^type\s/.test(part))
|
|
30
|
+
.map((part) => {
|
|
31
|
+
const [exported, local] = part.split(/\s+as\s+/);
|
|
32
|
+
return { exported: (exported ?? '').trim(), local: (local ?? exported ?? '').trim() };
|
|
33
|
+
})
|
|
34
|
+
.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name.exported) && name.local !== '');
|
|
35
|
+
|
|
36
|
+
interface LocalImport {
|
|
37
|
+
readonly specifier: string;
|
|
38
|
+
readonly names: readonly { readonly exported: string; readonly local: string }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every value import in this file, specifier as written. */
|
|
42
|
+
export function scanImports(source: string): readonly LocalImport[] {
|
|
43
|
+
const masked = maskLiterals(source);
|
|
44
|
+
const imports: LocalImport[] = [];
|
|
45
|
+
for (const match of masked.matchAll(IMPORT_CLAUSE)) {
|
|
46
|
+
if (match[1] !== undefined) continue;
|
|
47
|
+
const names = parseClause(match[2] ?? '');
|
|
48
|
+
const quote = match.index + match[0].length - 1;
|
|
49
|
+
const specifier = source.slice(quote + 1, endOfLiteral(masked, quote) - 1);
|
|
50
|
+
if (names.length > 0 && specifier !== '') imports.push({ specifier, names });
|
|
51
|
+
}
|
|
52
|
+
return imports;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The repo-relative paths a relative specifier could name, in resolution order. Only relative
|
|
57
|
+
* ones: `@ultimat3/db` and `node:path` are somebody else's file set, and a scanner that guessed
|
|
58
|
+
* which package a bare name came from would read an unrelated function's argument as a fix. That
|
|
59
|
+
* gap is real and named — `x verify`'s `errors` step counts what it could not read rather than
|
|
60
|
+
* passing over it silently, which is the failure this file exists to end.
|
|
61
|
+
*/
|
|
62
|
+
export function candidatePaths(from: string, specifier: string): readonly string[] {
|
|
63
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../')) return [];
|
|
64
|
+
const base = join(dirname(from), specifier);
|
|
65
|
+
// A path that climbed out of the repo root is not a file this scan may open.
|
|
66
|
+
if (base.startsWith('..')) return [];
|
|
67
|
+
return [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx')];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The helpers one file can call, named as that file names them.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately NOT also a count of the imports it could not open: that number is 1504 in this
|
|
74
|
+
* repo and 1310 of them are `@ultimat3/*` names like `join` and `UltimateError` — a figure nobody
|
|
75
|
+
* can act on. What the gate reports instead is `FixScan.unreadable`, which counts only arguments
|
|
76
|
+
* in a KNOWN fix position, and is therefore a count of fixes rather than of imports.
|
|
77
|
+
*/
|
|
78
|
+
export type HelperResolver = (path: string, source: string) => Promise<readonly FixHelper[]>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One resolver per run, because the module cache is the whole point: `packages/ui/src/errors.ts`
|
|
82
|
+
* is imported by every file in the package, and re-reading and re-scanning it per importer turns
|
|
83
|
+
* a one-pass walk into a quadratic one.
|
|
84
|
+
*
|
|
85
|
+
* A name declared in the imported module wins by NAME alone — no export check. The tree
|
|
86
|
+
* typechecks, so a name this file imports is a name that module exports; a second rule reading
|
|
87
|
+
* `export` keywords would only be able to disagree with `tsc`, never to add anything.
|
|
88
|
+
*/
|
|
89
|
+
export function createHelperResolver(root: string): HelperResolver {
|
|
90
|
+
const modules = new Map<string, readonly FixHelper[] | undefined>();
|
|
91
|
+
|
|
92
|
+
const helpersIn = async (path: string): Promise<readonly FixHelper[] | undefined> => {
|
|
93
|
+
const cached = modules.get(path);
|
|
94
|
+
if (cached !== undefined || modules.has(path)) return cached;
|
|
95
|
+
const file = Bun.file(join(root, path));
|
|
96
|
+
const found = (await file.exists())
|
|
97
|
+
? scanFixHelpers(maskLiterals(await file.text()))
|
|
98
|
+
: undefined;
|
|
99
|
+
modules.set(path, found);
|
|
100
|
+
return found;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return async (path, source) => {
|
|
104
|
+
const helpers: FixHelper[] = [];
|
|
105
|
+
for (const declaration of scanImports(source)) {
|
|
106
|
+
let declared: readonly FixHelper[] | undefined;
|
|
107
|
+
for (const candidate of candidatePaths(path, declaration.specifier)) {
|
|
108
|
+
declared = await helpersIn(candidate);
|
|
109
|
+
if (declared !== undefined) break;
|
|
110
|
+
}
|
|
111
|
+
for (const name of declaration.names) {
|
|
112
|
+
const helper = (declared ?? []).find((one) => one.name === name.exported);
|
|
113
|
+
if (helper !== undefined) helpers.push({ name: name.local, index: helper.index });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return helpers;
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/fix-scan.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Every string a `fix:` can evaluate to, in the three shapes a fix arrives in: under a key, in the
|
|
2
|
+
// argument position of a factory that builds an error, and in the argument position of an error
|
|
3
|
+
// class's constructor. Split out of `ts-scan.ts` when the third shape and cross-file resolution
|
|
4
|
+
// (`fix-imports.ts`) took the file past the 500-line ceiling; the masking primitives stay there.
|
|
5
|
+
|
|
6
|
+
import type { FixSite } from './ts-scan';
|
|
7
|
+
import {
|
|
8
|
+
CLOSERS,
|
|
9
|
+
endOfLiteral,
|
|
10
|
+
lineIndex,
|
|
11
|
+
maskLiterals,
|
|
12
|
+
OPENERS,
|
|
13
|
+
QUOTES,
|
|
14
|
+
valueLiterals,
|
|
15
|
+
} from './ts-scan';
|
|
16
|
+
|
|
17
|
+
/** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
|
|
18
|
+
const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
|
|
19
|
+
|
|
20
|
+
/** Text between the bracket at `open` and its match, or `undefined` when it never closes. */
|
|
21
|
+
function bracketSpan(masked: string, open: number): string | undefined {
|
|
22
|
+
let depth = 0;
|
|
23
|
+
for (let i = open; i < masked.length; i += 1) {
|
|
24
|
+
const ch = masked[i] as string;
|
|
25
|
+
// Only `()[]{}`. An angle bracket is a generic in a parameter list and the tail of `=>` in the
|
|
26
|
+
// very same list, so counting it makes `(fn: () => void)` end the span in the wrong place.
|
|
27
|
+
if (OPENERS.has(ch)) depth += 1;
|
|
28
|
+
else if (CLOSERS.has(ch)) {
|
|
29
|
+
depth -= 1;
|
|
30
|
+
if (depth === 0) return masked.slice(open + 1, i);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Split at depth-0 commas. Safe on masked text, where a comma inside a literal is already gone. */
|
|
37
|
+
function topLevelParts(text: string): readonly string[] {
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let start = 0;
|
|
41
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
42
|
+
const ch = text[i] as string;
|
|
43
|
+
if (OPENERS.has(ch)) depth += 1;
|
|
44
|
+
else if (CLOSERS.has(ch)) depth -= 1;
|
|
45
|
+
else if (ch === ',' && depth === 0) {
|
|
46
|
+
parts.push(text.slice(start, i));
|
|
47
|
+
start = i + 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
parts.push(text.slice(start));
|
|
51
|
+
return parts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A callable that builds an error and takes its fix positionally, and where in its list. */
|
|
55
|
+
export interface FixHelper {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly index: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const HELPER_DECL =
|
|
61
|
+
/(?<![.\w$])(?:function\s+([A-Za-z_$][\w$]*)\s*\(|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*(?:async\s+)?\()/g;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A class, and — because the helper's NAME is the class's and the parameter list is its
|
|
65
|
+
* constructor's — the two are found in two steps rather than one regex. `@ultimat3/render`'s
|
|
66
|
+
* `errors.ts` declares fourteen of these taking `(cause, fix)` positionally, and until this the
|
|
67
|
+
* form was unread everywhere: measured over the tree, it has ZERO same-file call sites, so the
|
|
68
|
+
* same-file rule was dead code for it and 15 of render's codes never had a fix line checked.
|
|
69
|
+
*/
|
|
70
|
+
const CLASS_DECL = /(?<![.\w$])class\s+([A-Za-z_$][\w$]*)[^{;]*\{/g;
|
|
71
|
+
const CONSTRUCTOR = /(?<![.\w$])constructor\s*\(/;
|
|
72
|
+
|
|
73
|
+
const FIX_PARAM = /^\s*fix\s*:\s*string\s*$/;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What separates a helper that BUILDS a fix from one that CONSUMES one. `citedCommandProblem(fix:
|
|
77
|
+
* string, …)` in `fix-command.ts` takes a fix in order to judge it, and reading its call sites as
|
|
78
|
+
* declarations would report findings about strings that are already findings. A builder names a
|
|
79
|
+
* `code` or constructs an `…Error`; a consumer does neither.
|
|
80
|
+
*/
|
|
81
|
+
const BUILDS_ERROR = /(?<![.\w$])code\s*[:=]|new\s+[A-Za-z_$][\w$]*Error\s*\(/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The body of the declaration whose parameter list ends at `after`, and never a `{` belonging to
|
|
85
|
+
* something below it. An unbounded `indexOf('{')` reads the next object literal in the FILE when
|
|
86
|
+
* the body is a concise expression, so `const label = (fix: string) => fix.trim();` followed
|
|
87
|
+
* anywhere by a `{ code: … }` was read as an error builder and every `label(…)` call handed the
|
|
88
|
+
* gate a string to judge as a fix — a false gate failure over innocent source.
|
|
89
|
+
*
|
|
90
|
+
* The scan therefore ends at the `;` that ends the declaration, or at a bracket closing a scope
|
|
91
|
+
* this declaration is inside. Both directions of that bound answer `''`, which classifies the
|
|
92
|
+
* helper as a non-builder: a missed fix line costs one unchecked citation, a wrongly claimed one
|
|
93
|
+
* costs a build. A `{` inside a return-type annotation (`(): { ok: boolean } => …`) is read as the
|
|
94
|
+
* body and answers `''` for the same reason.
|
|
95
|
+
*/
|
|
96
|
+
function bodyOf(masked: string, after: number): string {
|
|
97
|
+
for (let i = after; i < masked.length; i += 1) {
|
|
98
|
+
const ch = masked[i] as string;
|
|
99
|
+
if (ch === '{') return bracketSpan(masked, i) ?? '';
|
|
100
|
+
if (ch === ';' || CLOSERS.has(ch)) break;
|
|
101
|
+
}
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One declaration judged: it must build an error, and its fix must sit at a position a call site
|
|
107
|
+
* can be read at. A rest parameter makes the position of everything after it unknowable, and a
|
|
108
|
+
* destructured one has no position at all — its `fix:` key at the CALL site is already read by
|
|
109
|
+
* `FIX_KEY`.
|
|
110
|
+
*/
|
|
111
|
+
function helperAt(masked: string, name: string, open: number): FixHelper | undefined {
|
|
112
|
+
const params = bracketSpan(masked, open);
|
|
113
|
+
if (params === undefined || params.includes('...')) return undefined;
|
|
114
|
+
const parts = topLevelParts(params);
|
|
115
|
+
if (parts.some((part) => /^\s*[[{]/.test(part))) return undefined;
|
|
116
|
+
const index = parts.findIndex((part) => FIX_PARAM.test(part));
|
|
117
|
+
if (index === -1) return undefined;
|
|
118
|
+
if (!BUILDS_ERROR.test(bodyOf(masked, open + params.length + 2))) return undefined;
|
|
119
|
+
return { name, index };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every fix-building callable this file declares — a function, an arrow bound to a const, or a
|
|
124
|
+
* class whose constructor takes the fix. Exported because `fix-imports.ts` asks the same question
|
|
125
|
+
* of a file this one merely imports FROM; there is no second reader of a declaration.
|
|
126
|
+
*/
|
|
127
|
+
export function scanFixHelpers(masked: string): readonly FixHelper[] {
|
|
128
|
+
const helpers: FixHelper[] = [];
|
|
129
|
+
for (const declaration of masked.matchAll(HELPER_DECL)) {
|
|
130
|
+
const name = declaration[1] ?? declaration[2];
|
|
131
|
+
const open = declaration.index + declaration[0].length - 1;
|
|
132
|
+
if (name === undefined || masked[open] !== '(') continue;
|
|
133
|
+
const helper = helperAt(masked, name, open);
|
|
134
|
+
if (helper !== undefined) helpers.push(helper);
|
|
135
|
+
}
|
|
136
|
+
for (const declaration of masked.matchAll(CLASS_DECL)) {
|
|
137
|
+
const name = declaration[1];
|
|
138
|
+
const body = declaration.index + declaration[0].length - 1;
|
|
139
|
+
const inner = bracketSpan(masked, body);
|
|
140
|
+
const found = inner === undefined ? null : CONSTRUCTOR.exec(inner);
|
|
141
|
+
if (name === undefined || found === null) continue;
|
|
142
|
+
const helper = helperAt(masked, name, body + 1 + found.index + found[0].length - 1);
|
|
143
|
+
if (helper !== undefined) helpers.push(helper);
|
|
144
|
+
}
|
|
145
|
+
return helpers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A fix argument this scan could not read, at a call site it could: the callee is a known helper
|
|
150
|
+
* and the fix position holds something other than one literal. Counted rather than dropped, so
|
|
151
|
+
* `x verify`'s `errors` step can say "checked 412, could not read 27" — a gate that stays silent
|
|
152
|
+
* about its own blind spot is the false green this file exists to close (axiom 4 applies to it too).
|
|
153
|
+
*/
|
|
154
|
+
export interface FixScan {
|
|
155
|
+
readonly sites: readonly FixSite[];
|
|
156
|
+
readonly unreadable: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The argument in that position at every call to that helper in this file. `new X(…)` is a call
|
|
161
|
+
* like any other here: the lookbehind sees the space after `new`, so a class needs no second rule.
|
|
162
|
+
*/
|
|
163
|
+
function helperFixSites(
|
|
164
|
+
masked: string,
|
|
165
|
+
source: string,
|
|
166
|
+
at: string,
|
|
167
|
+
helper: FixHelper,
|
|
168
|
+
lineAt: (index: number) => number,
|
|
169
|
+
unreadable: { count: number },
|
|
170
|
+
): readonly FixSite[] {
|
|
171
|
+
const sites: FixSite[] = [];
|
|
172
|
+
// The lookbehind is `FIX_KEY`'s: `reporter.rejected(…)` is some other object's method.
|
|
173
|
+
const call = new RegExp(`(?<![.\\w$])${helper.name}\\s*\\(`, 'g');
|
|
174
|
+
for (const match of masked.matchAll(call)) {
|
|
175
|
+
const open = match.index + match[0].length - 1;
|
|
176
|
+
const args = bracketSpan(masked, open);
|
|
177
|
+
if (args === undefined) continue;
|
|
178
|
+
const parts = topLevelParts(args);
|
|
179
|
+
const argument = parts[helper.index];
|
|
180
|
+
// A call one argument short passes no fix at all — nothing was written here to read.
|
|
181
|
+
if (argument === undefined) continue;
|
|
182
|
+
// Stricter than the `fix:` path, and deliberately: the whole argument must BE one literal.
|
|
183
|
+
// `valueLiterals` alone reads `prefix + 'x doctor'` as one literal, because the identifier
|
|
184
|
+
// half contributes none — and publishing half a fix as the whole one is the failure
|
|
185
|
+
// `soleLiteral` already names. A key at least declares that what follows is the value.
|
|
186
|
+
const quote = argument.trim()[0];
|
|
187
|
+
if (quote === undefined || !QUOTES.has(quote)) {
|
|
188
|
+
unreadable.count += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const literal = argument.indexOf(quote);
|
|
192
|
+
if (argument.slice(endOfLiteral(argument, literal)).trim() !== '') {
|
|
193
|
+
unreadable.count += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const from = parts.slice(0, helper.index).reduce((n, part) => n + part.length + 1, open + 1);
|
|
197
|
+
const literals = valueLiterals(masked, source, from, lineAt);
|
|
198
|
+
if (literals.length === 1) sites.push({ ...(literals[0] as FixSite), at });
|
|
199
|
+
else unreadable.count += 1;
|
|
200
|
+
}
|
|
201
|
+
return sites;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Every string a `fix:` can evaluate to. Searched over the masked source, so a `fix:` written
|
|
206
|
+
* inside a doc comment or interpolated into a message is not mistaken for a declaration. A `fix`
|
|
207
|
+
* computed at runtime — a bare identifier, a parameter, a table lookup with no literal fallback —
|
|
208
|
+
* has nothing to read and is beyond a static scan; the gate says so rather than guessing.
|
|
209
|
+
*
|
|
210
|
+
* Three shapes, because a fix does not always arrive under a key. `@ultimat3/mcp`'s
|
|
211
|
+
* `readonly-sql.ts` hands every one of its fixes positionally to a local `rejected(cause, fix)`
|
|
212
|
+
* helper, so the key rule alone returned `[]` for the whole file — 20 non-test files in that
|
|
213
|
+
* package and the scanner saw fixes in three — and two stale `x db branch <name>` lines shipped
|
|
214
|
+
* through the hole.
|
|
215
|
+
*
|
|
216
|
+
* `imported` is the third: the helpers this file can call that it did not declare, resolved by
|
|
217
|
+
* `fix-imports.ts` and passed in, because a scanner over one string cannot open a second file.
|
|
218
|
+
* The four rules a call site is read under are `helperAt`'s and do not change with where the
|
|
219
|
+
* declaration was found.
|
|
220
|
+
*/
|
|
221
|
+
export function scanFixSites(
|
|
222
|
+
source: string,
|
|
223
|
+
at: string,
|
|
224
|
+
imported: readonly FixHelper[] = [],
|
|
225
|
+
): FixScan {
|
|
226
|
+
const unreadable = { count: 0 };
|
|
227
|
+
const masked = maskLiterals(source);
|
|
228
|
+
const lineAt = lineIndex(masked);
|
|
229
|
+
const sites: FixSite[] = [];
|
|
230
|
+
for (const key of masked.matchAll(FIX_KEY)) {
|
|
231
|
+
const start = key.index + key[0].length;
|
|
232
|
+
for (const literal of valueLiterals(masked, source, start, lineAt)) {
|
|
233
|
+
sites.push({ ...literal, at });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// A name declared here wins over one imported under the same name: the declaration is what a
|
|
237
|
+
// call in this file actually reaches, and reading both would report one argument twice.
|
|
238
|
+
const local = scanFixHelpers(masked);
|
|
239
|
+
const names = new Set(local.map((helper) => helper.name));
|
|
240
|
+
for (const helper of [...local, ...imported.filter((one) => !names.has(one.name))]) {
|
|
241
|
+
sites.push(...helperFixSites(masked, source, at, helper, lineAt, unreadable));
|
|
242
|
+
}
|
|
243
|
+
return { sites, unreadable: unreadable.count };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The sites alone, for every caller that has no second file to resolve an import against. */
|
|
247
|
+
export const scanFixes = (
|
|
248
|
+
source: string,
|
|
249
|
+
at: string,
|
|
250
|
+
imported: readonly FixHelper[] = [],
|
|
251
|
+
): readonly FixSite[] => scanFixSites(source, at, imported).sites;
|
package/src/flag-number.ts
CHANGED
|
@@ -54,3 +54,14 @@ export const intFlagOr = (args: ParsedArgs, flag: IntFlag, fallback: number): nu
|
|
|
54
54
|
* free port. Two ranges for one concept is the drift this constant exists to prevent.
|
|
55
55
|
*/
|
|
56
56
|
export const PORT_RANGE = { min: 0, max: 65_535 } as const;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A free-port suggestion the thing being fixed will actually accept. `port + 1` at the top of the
|
|
60
|
+
* range names 65536, which is not a port — so a `fix:` built that way reproduces a failure instead
|
|
61
|
+
* of ending one: `x doctor` emitted `x dev --port 65536`, which `x dev` refuses with
|
|
62
|
+
* `X_CLI_BAD_FLAG`. The neighbour below is a port; the one above does not exist. Here rather than
|
|
63
|
+
* beside either caller, because two ports-are-bounded rules is the drift `PORT_RANGE` above
|
|
64
|
+
* already exists to prevent.
|
|
65
|
+
*/
|
|
66
|
+
export const neighbouringPort = (port: number): number =>
|
|
67
|
+
port < PORT_RANGE.max ? port + 1 : PORT_RANGE.max - 1;
|