@ultimat3/cli 3.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 +69 -10
- package/package.json +24 -24
- package/src/budgets.ts +8 -5
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-docs.ts +7 -3
- 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 +9 -13
- 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-verify.ts +55 -3
- package/src/command.ts +10 -2
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-runtime.ts +2 -2
- package/src/dispatch.ts +33 -4
- package/src/error-codes.ts +5 -0
- package/src/error-contract.ts +31 -4
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +8 -1
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +5 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +3 -0
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -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/ts-scan.ts +12 -174
- package/src/verify-step.ts +5 -0
|
@@ -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;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A flag a command declares and nothing reads. The parser accepts every declared flag, so a flag
|
|
2
|
+
// with no reader is not a parse error and not a type error — it is a promise in the help text with
|
|
3
|
+
// no code behind it, and only a rule over the two halves together can see that.
|
|
4
|
+
//
|
|
5
|
+
// The bound of this rule, stated where it is enforced: it sees the flag NAME reaching a reader,
|
|
6
|
+
// not the value reaching an effect. `x deploy --critical` satisfied it by being written into the
|
|
7
|
+
// plan JSON, where nothing read the field; that flag is deleted rather than wired, and a second
|
|
8
|
+
// one of its shape would pass here too.
|
|
9
|
+
|
|
10
|
+
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
import { docsFor } from './error-codes';
|
|
13
|
+
import type { Finding } from './output';
|
|
14
|
+
import type { CommandSpec, FlagSpec } from './parse';
|
|
15
|
+
import { GLOBAL_FLAGS } from './parse';
|
|
16
|
+
import { stripComments } from './ts-scan';
|
|
17
|
+
|
|
18
|
+
/** A flag as declared, with the command that declares it. */
|
|
19
|
+
export interface DeclaredFlag {
|
|
20
|
+
readonly command: string;
|
|
21
|
+
readonly flag: FlagSpec;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every flag a command declares. The global four are excluded: `--json`, `--help`, `--cwd` and
|
|
26
|
+
* `--verbose` are the parser's and the dispatcher's, read once for every command rather than by
|
|
27
|
+
* the command that lists them, and a per-command rule would report all 30 of them as unread.
|
|
28
|
+
*/
|
|
29
|
+
export function declaredFlags(specs: readonly CommandSpec[]): readonly DeclaredFlag[] {
|
|
30
|
+
const global = new Set(GLOBAL_FLAGS.map((flag) => flag.name));
|
|
31
|
+
return specs.flatMap((spec) =>
|
|
32
|
+
(spec.flags ?? [])
|
|
33
|
+
.filter((flag) => !global.has(flag.name))
|
|
34
|
+
.map((flag) => ({ command: spec.name, flag })),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A flag name is `[a-z][a-z-]*`, so nothing in it is a regex metacharacter to escape. */
|
|
39
|
+
const literalOf = (name: string): RegExp => new RegExp(`(['"\`])${name}\\1`, 'g');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The declaration itself, which is never a read. `{ name: 'critical', … }` and its `short:` twin
|
|
43
|
+
* are the two places the name appears as a spec field; every other occurrence of the bare literal
|
|
44
|
+
* is a reader — `flagBool(ctx.args, 'critical')`, a table key, a constant the reader indexes with.
|
|
45
|
+
*/
|
|
46
|
+
const declarationOf = (name: string): RegExp =>
|
|
47
|
+
new RegExp(`(?:name|short)\\s*:\\s*(['"\`])${name}\\1`, 'g');
|
|
48
|
+
|
|
49
|
+
const countIn = (text: string, pattern: RegExp): number => [...text.matchAll(pattern)].length;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether this file reads the flag, as against merely declaring it. Deliberately generous: a flag
|
|
53
|
+
* consumed only by being echoed into `--json`, or read through a shared constant rather than by
|
|
54
|
+
* name at the call site, is still read — the rule exists to catch a flag NOTHING mentions, and a
|
|
55
|
+
* gate that guessed at intent would report findings about working commands.
|
|
56
|
+
*/
|
|
57
|
+
export const readsFlag = (text: string, name: string): boolean =>
|
|
58
|
+
countIn(text, literalOf(name)) > countIn(text, declarationOf(name));
|
|
59
|
+
|
|
60
|
+
const declaresFlag = (text: string, name: string): boolean =>
|
|
61
|
+
countIn(text, declarationOf(name)) > 0;
|
|
62
|
+
|
|
63
|
+
const unreadFinding = (declared: DeclaredFlag, at: string): Finding => ({
|
|
64
|
+
code: 'X_CLI_FLAG_UNREAD',
|
|
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
|
+
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: docsFor('X_CLI_FLAG_UNREAD'),
|
|
68
|
+
at,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every declared flag held to one rule: something reads it.
|
|
73
|
+
*
|
|
74
|
+
* Scans source rather than the runtime, because "is this value ever consumed?" is not a question
|
|
75
|
+
* a `run` can be asked without running it — and running every command is not a check, it is the
|
|
76
|
+
* program. Comments are stripped first: a flag named only in the prose above the spec is not read,
|
|
77
|
+
* and a scanner that counted it would pass exactly the flags most likely to be dead.
|
|
78
|
+
*/
|
|
79
|
+
export async function checkFlagReads(
|
|
80
|
+
specs: readonly CommandSpec[],
|
|
81
|
+
srcDir: string,
|
|
82
|
+
): Promise<readonly Finding[]> {
|
|
83
|
+
const paths: string[] = [];
|
|
84
|
+
try {
|
|
85
|
+
for await (const path of new Bun.Glob('**/*.ts').scan({ cwd: srcDir, absolute: false })) {
|
|
86
|
+
if (!/\.test\.tsx?$/.test(path)) paths.push(path);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// The directory is not there. `Bun.Glob.scan` raises rather than yielding nothing, so the
|
|
90
|
+
// absent case has to be caught here — see the `texts.size` guard below for why it answers [].
|
|
91
|
+
// The scan is ALL that is inside the `try`, deliberately: a file the scan found and this
|
|
92
|
+
// cannot read must propagate, or an unreadable source answers "no findings" and the rule
|
|
93
|
+
// reports green over the half it could not see.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const texts = new Map<string, string>();
|
|
97
|
+
for (const path of paths) {
|
|
98
|
+
texts.set(path, stripComments(await Bun.file(join(srcDir, path)).text()));
|
|
99
|
+
}
|
|
100
|
+
// No CLI source under this root: the rule holds two halves against each other and only one is
|
|
101
|
+
// here, so there is nothing it can decide. Derived, not "is this the framework repo" — the same
|
|
102
|
+
// condition `scripts/release-workflow.ts` uses for a tree with no publishable workspace. Scanning
|
|
103
|
+
// on would report EVERY declared flag as unread, which is the false-positive direction and the
|
|
104
|
+
// one that trains a reader to ignore the check.
|
|
105
|
+
if (texts.size === 0) return [];
|
|
106
|
+
const findings: Finding[] = [];
|
|
107
|
+
for (const declared of declaredFlags(specs)) {
|
|
108
|
+
const name = declared.flag.name;
|
|
109
|
+
if ([...texts.values()].some((text) => readsFlag(text, name))) continue;
|
|
110
|
+
const declaringFile = [...texts].find(([, text]) => declaresFlag(text, name))?.[0];
|
|
111
|
+
findings.push(unreadFinding(declared, join(relative('', srcDir), declaringFile ?? '')));
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
package/src/i18n-audit.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// root-relative POSIX shape every CLI-reported path is keyed by.
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { join, relative, sep } from 'node:path';
|
|
11
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
11
12
|
import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
|
|
12
13
|
import {
|
|
13
14
|
auditCatalogs,
|
|
@@ -51,7 +52,7 @@ function parseCatalogJson(path: string, raw: string): unknown {
|
|
|
51
52
|
try {
|
|
52
53
|
return JSON.parse(raw);
|
|
53
54
|
} catch (error) {
|
|
54
|
-
throw catalogInvalid(path,
|
|
55
|
+
throw catalogInvalid(path, renderThrowable(error));
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
package/src/index.ts
CHANGED
|
@@ -117,12 +117,14 @@ export {
|
|
|
117
117
|
} from './error-catalog';
|
|
118
118
|
export type { CliErrorCode } from './error-codes';
|
|
119
119
|
export { CLI_ERROR_CODES, CLI_ERROR_TITLES } from './error-codes';
|
|
120
|
+
export type { ErrorFixReport } from './error-contract';
|
|
120
121
|
export {
|
|
121
122
|
BANNED_PHRASES,
|
|
122
123
|
COMMAND_TOKENS,
|
|
123
124
|
checkErrorCodeDocs,
|
|
124
125
|
checkErrorCodeRegistry,
|
|
125
126
|
checkErrorFixes,
|
|
127
|
+
checkErrorFixReport,
|
|
126
128
|
collectDeclaredCodes,
|
|
127
129
|
documentedCodes,
|
|
128
130
|
fixProblem,
|
|
@@ -167,6 +169,12 @@ export {
|
|
|
167
169
|
fixCitations,
|
|
168
170
|
loadCommandCatalog,
|
|
169
171
|
} from './fix-command';
|
|
172
|
+
export type { HelperResolver } from './fix-imports';
|
|
173
|
+
export { candidatePaths, createHelperResolver, scanImports } from './fix-imports';
|
|
174
|
+
export type { FixHelper, FixScan } from './fix-scan';
|
|
175
|
+
export { scanFixes, scanFixHelpers, scanFixSites } from './fix-scan';
|
|
176
|
+
export type { DeclaredFlag } from './flag-reads';
|
|
177
|
+
export { checkFlagReads, declaredFlags, readsFlag } from './flag-reads';
|
|
170
178
|
export type { Guard } from './guards';
|
|
171
179
|
export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
|
|
172
180
|
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
@@ -241,7 +249,6 @@ export {
|
|
|
241
249
|
scanBorrowedCodes,
|
|
242
250
|
scanCodeFixSites,
|
|
243
251
|
scanCodes,
|
|
244
|
-
scanFixes,
|
|
245
252
|
stripComments,
|
|
246
253
|
} from './ts-scan';
|
|
247
254
|
// The one spelling rule for a `references` entry. Exported because the two gate scripts ask the
|
package/src/jobs-drain.ts
CHANGED
|
@@ -75,7 +75,12 @@ async function copySteps(source: JobDriver, target: JobDriver, runId: string): P
|
|
|
75
75
|
/**
|
|
76
76
|
* Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
|
|
77
77
|
* a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
|
|
78
|
-
* dead-letter a job nobody ever ran.
|
|
78
|
+
* dead-letter a job nobody ever ran.
|
|
79
|
+
*
|
|
80
|
+
* It returns to `ready`, which is what "as the drain found it" means — the drain leased a ready
|
|
81
|
+
* job and could not move it. It used to land in `suspended`, not by intent but because
|
|
82
|
+
* `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
|
|
83
|
+
* `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
|
|
79
84
|
*/
|
|
80
85
|
async function releaseLease(source: JobDriver, id: string): Promise<void> {
|
|
81
86
|
try {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -22,6 +22,11 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
22
22
|
// Runnable first, the narrowing behind a `#`: `x help <command> --json` pasted into a shell
|
|
23
23
|
// is a redirect, not a command, and this table is copied verbatim by whoever reads it.
|
|
24
24
|
X_CLI_BAD_FLAG: 'x help --json # then narrow to the command the cause names',
|
|
25
|
+
// Not an `x` command: this rule is about the CLI's OWN declarations, it can only fire in this
|
|
26
|
+
// repo, and the suite that applies it is what reproduces the finding. A placeholder command
|
|
27
|
+
// would fail this table's own no-`<placeholder>` rule, and rightly — it would not run.
|
|
28
|
+
X_CLI_FLAG_UNREAD:
|
|
29
|
+
'bun test packages/cli/src/flag-reads.test.ts # the finding names the flag and the file to read it in',
|
|
25
30
|
X_VERIFY_FAILED: 'x verify --json',
|
|
26
31
|
X_NOT_IN_APP: 'x new myapp --json && cd myapp',
|
|
27
32
|
X_BUN_VERSION: 'bun upgrade',
|
package/src/mcp-host.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// is a second catalog of routes, entities, actions, queries or jobs.
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { agentActor, isUltimateError, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
8
8
|
import type { DbClient } from '@ultimat3/db';
|
|
9
9
|
import {
|
|
10
10
|
ensureReadOnlyRole,
|
|
@@ -199,7 +199,9 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
199
199
|
if (isUltimateError(error)) throw error;
|
|
200
200
|
throw new UltimateError({
|
|
201
201
|
code: 'X_DB_MIGRATE_FAILED',
|
|
202
|
-
|
|
202
|
+
// The blessed total renderer: `String(error)` runs the value's own `toString`, and
|
|
203
|
+
// this is the last hop before an agent is handed the three-line result.
|
|
204
|
+
cause: renderThrowable(error),
|
|
203
205
|
fix: 'x db reset',
|
|
204
206
|
});
|
|
205
207
|
}
|
package/src/messages.ts
CHANGED
|
@@ -170,6 +170,9 @@ const CATALOG = {
|
|
|
170
170
|
'cli.verify.passSkipped':
|
|
171
171
|
'{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
|
|
172
172
|
'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
|
|
173
|
+
// The `errors` step's own coverage, in `output`: a scan without a parser reads most fix lines
|
|
174
|
+
// and not all of them, and a step that reports findings alone claims a completeness it lacks.
|
|
175
|
+
'cli.verify.fixCoverage': 'checked {checked} fix line(s), could not read {unreadable}',
|
|
173
176
|
'cli.verify.serial': 'serial',
|
|
174
177
|
'cli.verify.workers': '{workers} workers',
|
|
175
178
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
package/src/otlp-export.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
configureMetrics,
|
|
8
8
|
configureTelemetry,
|
|
9
9
|
logger,
|
|
10
|
+
noopExporter,
|
|
11
|
+
noopMetricExporter,
|
|
10
12
|
onShutdown,
|
|
11
13
|
otlpMetricExporter,
|
|
12
14
|
otlpSpanExporter,
|
|
@@ -29,6 +31,14 @@ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
|
29
31
|
* Both are registered with `onShutdown(..., { phase: 'close' })`: the last spans of a drain are
|
|
30
32
|
* the ones that explain the drain, and a process that exits with a full queue loses exactly the
|
|
31
33
|
* window an operator went looking for.
|
|
34
|
+
*
|
|
35
|
+
* The release UNINSTALLS what it installed, per signal. `configureTelemetry`/`configureMetrics`
|
|
36
|
+
* merge into process-global state, so stopping the timer and dropping the drain hooks left the
|
|
37
|
+
* first boot's exporter configured: a second `serveApp` in the same process exported its spans
|
|
38
|
+
* into a released exporter — queued against a collector nothing will flush to, on a timer nothing
|
|
39
|
+
* clears. `cmd-dev.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
|
|
40
|
+
* never unconditionally: `x dev` configures a trace RECORDER before calling this, and a boot that
|
|
41
|
+
* installed no exporter must not uninstall one it never owned.
|
|
32
42
|
*/
|
|
33
43
|
export function startOtlpExport(env: Env = process.env): () => void {
|
|
34
44
|
const releases: (() => void)[] = [];
|
|
@@ -41,6 +51,9 @@ export function startOtlpExport(env: Env = process.env): () => void {
|
|
|
41
51
|
if (traces !== undefined) {
|
|
42
52
|
const exporter = otlpSpanExporter({ endpoint: traces });
|
|
43
53
|
configureTelemetry({ exporter });
|
|
54
|
+
// Pushed first, so the reversed run below applies it LAST — after the drain hook is dropped,
|
|
55
|
+
// the same order `cmd-dev.ts` releases the recorder in.
|
|
56
|
+
releases.push(() => configureTelemetry({ exporter: noopExporter }));
|
|
44
57
|
releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
|
|
45
58
|
logger.info('ultimate otlp traces', { endpoint: traces });
|
|
46
59
|
}
|
|
@@ -49,6 +62,7 @@ export function startOtlpExport(env: Env = process.env): () => void {
|
|
|
49
62
|
if (metrics !== undefined) {
|
|
50
63
|
const exporter = otlpMetricExporter({ endpoint: metrics });
|
|
51
64
|
configureMetrics({ exporter });
|
|
65
|
+
releases.push(() => configureMetrics({ exporter: noopMetricExporter }));
|
|
52
66
|
// The push loop, and not only the exporter: `configureMetrics` names where a snapshot goes
|
|
53
67
|
// and nothing decides when one is taken, so without this the collector receives one export —
|
|
54
68
|
// the drain's — for the whole life of the process.
|
package/src/parse.ts
CHANGED
|
@@ -47,7 +47,12 @@ export interface CommandSpec {
|
|
|
47
47
|
*/
|
|
48
48
|
readonly subcommandPositionals?: Readonly<Record<string, readonly string[]>>;
|
|
49
49
|
readonly flags?: readonly FlagSpec[];
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Command needs an app root (`app.config.ts`). The dispatcher enforces it — `dispatch.ts`, before
|
|
52
|
+
* `target.run` — and until 2026-08 nothing read this field at all: the guarantee was kept only by
|
|
53
|
+
* each of the 17 declaring commands remembering to call `requireAppRoot` itself, so a new command
|
|
54
|
+
* that declared it and forgot the call ran outside an app with no refusal.
|
|
55
|
+
*/
|
|
51
56
|
readonly requiresApp?: boolean;
|
|
52
57
|
}
|
|
53
58
|
|