@ultimat3/cli 11.0.0 → 11.2.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 +87 -0
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher.ts +12 -0
- package/src/cmd-dev.ts +11 -0
- package/src/cmd-shot-island.ts +146 -0
- package/src/cmd-shot.ts +50 -86
- package/src/error-codes.ts +16 -0
- package/src/error-contract.ts +36 -2
- package/src/index.ts +10 -1
- package/src/island-harness-route.ts +91 -0
- package/src/island-harness-script.ts +147 -0
- package/src/island-harness.ts +98 -0
- package/src/island-shot-errors.ts +94 -0
- package/src/island-shot.ts +303 -0
- package/src/island-states-load.ts +78 -0
- package/src/island-verdict.ts +194 -0
- package/src/mcp-errors.ts +13 -0
- package/src/messages.ts +14 -0
- package/src/reexport-manifest.ts +62 -0
- package/src/shot-server.ts +90 -0
- package/src/shot-settle.ts +33 -3
- package/src/ts-scan.ts +151 -11
- package/src/verify-checks.ts +10 -2
- package/src/workspace-checks.ts +23 -7
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Whether a file is a pure re-export manifest — every statement in it an `import` or an `export`
|
|
2
|
+
// that declares nothing. The line ceiling is a rule about REVIEWABLE LOGIC, and such a file has
|
|
3
|
+
// none: its length is a function of the package's API size, so the ceiling measures the wrong
|
|
4
|
+
// thing there. One added statement of logic disqualifies it and re-arms the ceiling on the spot.
|
|
5
|
+
|
|
6
|
+
import { CLOSERS, maskLiterals, OPENERS } from './ts-scan';
|
|
7
|
+
|
|
8
|
+
/** Every statement a manifest may hold begins with one of these two words. */
|
|
9
|
+
const IMPORT_OR_EXPORT = /^(?:import|export)\b/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An `export` that DECLARES rather than re-exports. `export const LIMIT = 1` is a value with an
|
|
13
|
+
* initialiser, `export function` is logic outright, and both are exactly what the ceiling is for —
|
|
14
|
+
* so a file holding one is an ordinary source file that happens to start with re-exports.
|
|
15
|
+
*/
|
|
16
|
+
const DECLARES =
|
|
17
|
+
/^export\s+(?:default|declare|abstract|async|const|let|var|function|class|enum|namespace|module|interface)\b/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `export type { Ctx } from './ctx'` is a re-export; `export type Ctx = { … }` is a type alias, and
|
|
21
|
+
* an alias is a declaration a reviewer reads. The brace is the whole distinction.
|
|
22
|
+
*/
|
|
23
|
+
const TYPE_ALIAS = /^export\s+type\s+[A-Za-z_$]/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Top-level statements, split at the `;` that ends each one at bracket depth 0, over MASKED source
|
|
27
|
+
* — comments and string contents blanked — so a `;` inside a specifier or a comment is not read as
|
|
28
|
+
* a boundary. `undefined` when the file ends in something this cannot read as a statement: a scan
|
|
29
|
+
* that guesses would exempt a file on the strength of not understanding it.
|
|
30
|
+
*/
|
|
31
|
+
function topLevelStatements(masked: string): readonly string[] | undefined {
|
|
32
|
+
const statements: string[] = [];
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let start = 0;
|
|
35
|
+
for (let i = 0; i < masked.length; i += 1) {
|
|
36
|
+
const ch = masked[i] as string;
|
|
37
|
+
if (OPENERS.has(ch)) depth += 1;
|
|
38
|
+
// Clamped, because an unbalanced closer would otherwise put every later `;` at a negative
|
|
39
|
+
// depth and the whole file would read as one unterminated statement.
|
|
40
|
+
else if (CLOSERS.has(ch)) depth = Math.max(0, depth - 1);
|
|
41
|
+
else if (ch === ';' && depth === 0) {
|
|
42
|
+
statements.push(masked.slice(start, i));
|
|
43
|
+
start = i + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return masked.slice(start).trim() === '' ? statements : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when every statement in `source` is an import or a declaration-free export. A file with no
|
|
51
|
+
* statement at all is NOT a manifest: an exemption has to be earned by what a file holds, and
|
|
52
|
+
* "this scan found nothing" is the one answer that must never grant one.
|
|
53
|
+
*/
|
|
54
|
+
export function isReExportManifest(source: string): boolean {
|
|
55
|
+
const statements = topLevelStatements(maskLiterals(source));
|
|
56
|
+
if (statements === undefined || statements.length === 0) return false;
|
|
57
|
+
return statements.every((statement) => {
|
|
58
|
+
const text = statement.trim();
|
|
59
|
+
if (text === '') return true;
|
|
60
|
+
return IMPORT_OR_EXPORT.test(text) && !DECLARES.test(text) && !TYPE_ALIAS.test(text);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The server a shot is taken against, and the hosts the page may reach while it is: the two
|
|
2
|
+
// facts a ROUTE capture and a COMPONENT capture share, and the only ones. Its own file so
|
|
3
|
+
// `island-shot.ts` can have them without importing the command that photographs a route, which
|
|
4
|
+
// would be a cycle between two files that otherwise have nothing to say to each other.
|
|
5
|
+
|
|
6
|
+
// why: no Bun native joins a path; `.x/shot` is a path both capture paths write under.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { startDev } from './cmd-dev';
|
|
9
|
+
import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
|
|
10
|
+
import { DEV_BINDING } from './dev-roles';
|
|
11
|
+
import { resolveServices } from './dev-services';
|
|
12
|
+
|
|
13
|
+
export const SHOT_DIR = join('.x', 'shot');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
|
|
17
|
+
* call, for the reason every `Runner` in this package is one: the failure path below — a boot that
|
|
18
|
+
* throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
|
|
19
|
+
*/
|
|
20
|
+
export type BootDevServer = (input: {
|
|
21
|
+
readonly root: string;
|
|
22
|
+
readonly port: number;
|
|
23
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
24
|
+
}) => Promise<{ readonly url: string; stop(): Promise<void> }>;
|
|
25
|
+
|
|
26
|
+
export interface ShotServer {
|
|
27
|
+
readonly url: string;
|
|
28
|
+
/** Which server the picture is of. Reported, because the two have different failure modes. */
|
|
29
|
+
readonly origin: 'booted' | 'reused';
|
|
30
|
+
stop(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
|
|
35
|
+
* Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
|
|
36
|
+
* on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
|
|
37
|
+
*/
|
|
38
|
+
export async function devServerFor(
|
|
39
|
+
root: string,
|
|
40
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
41
|
+
port: number,
|
|
42
|
+
boot: BootDevServer = (input) => startDev(input),
|
|
43
|
+
): Promise<ShotServer> {
|
|
44
|
+
const services = resolveServices(root, env);
|
|
45
|
+
const file = Bun.file(lockPath(services.stateDir));
|
|
46
|
+
if (await file.exists()) {
|
|
47
|
+
const lock = parseLock(await file.text());
|
|
48
|
+
if (lock !== null && isProcessAlive(lock.pid)) {
|
|
49
|
+
return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const { release } = await preflight({
|
|
53
|
+
stateDir: services.stateDir,
|
|
54
|
+
port,
|
|
55
|
+
hostname: DEV_BINDING.hostname,
|
|
56
|
+
embeddedDb: services.db.mode === 'embedded',
|
|
57
|
+
});
|
|
58
|
+
// The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
|
|
59
|
+
// looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
|
|
60
|
+
// same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
|
|
61
|
+
// checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
|
|
62
|
+
// teardown must never replace the failure it is cleaning up after.
|
|
63
|
+
const dev = await boot({ root, port, env }).catch((error: unknown) => {
|
|
64
|
+
release();
|
|
65
|
+
throw error;
|
|
66
|
+
});
|
|
67
|
+
await writeLock(services.stateDir, {
|
|
68
|
+
pid: process.pid,
|
|
69
|
+
port,
|
|
70
|
+
url: dev.url,
|
|
71
|
+
startedAt: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
url: dev.url,
|
|
75
|
+
origin: 'booted',
|
|
76
|
+
async stop() {
|
|
77
|
+
clearLock(services.stateDir);
|
|
78
|
+
await dev.stop();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
|
|
84
|
+
export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
|
|
85
|
+
const named = (extra ?? '')
|
|
86
|
+
.split(',')
|
|
87
|
+
.map((host) => host.trim())
|
|
88
|
+
.filter((host) => host.length > 0);
|
|
89
|
+
return [new URL(url).hostname, ...named];
|
|
90
|
+
};
|
package/src/shot-settle.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// When a shot may be TAKEN: the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// When a shot may be TAKEN: the rules that decide whether the page has finished, separated from
|
|
2
|
+
// both the command that drives a browser and the verdict that judges what came back. Plain values
|
|
3
|
+
// and an injected sleep, so every loop here is proved with neither.
|
|
4
4
|
|
|
5
|
+
import type { IslandReadiness } from './island-verdict';
|
|
5
6
|
import type { IslandCount } from './shot-verdict';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -55,3 +56,32 @@ export async function settleIslands(
|
|
|
55
56
|
}
|
|
56
57
|
return answer;
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the harness's readiness until the page goes QUIET or the window runs out.
|
|
62
|
+
*
|
|
63
|
+
* Quiet and not idle, which is the whole rule: the page's own watcher sets `ready` after N
|
|
64
|
+
* consecutive frames in which no request started and none settled, so a state whose fixture is
|
|
65
|
+
* deliberately `pending` still reaches it. Waiting for nothing in flight would hang on exactly the
|
|
66
|
+
* state an author declared on purpose, and a fixed sleep would photograph whatever a slow machine
|
|
67
|
+
* had painted by then.
|
|
68
|
+
*
|
|
69
|
+
* A `null` answer never overwrites a real one, for `settleIslands`' reason: `null` is "the page
|
|
70
|
+
* answered no probe", and a probe that fails once must not turn a ready page into an unready one.
|
|
71
|
+
*/
|
|
72
|
+
export async function settleReadiness(
|
|
73
|
+
probe: () => Promise<IslandReadiness | null>,
|
|
74
|
+
options: SettleOptions,
|
|
75
|
+
): Promise<IslandReadiness | null> {
|
|
76
|
+
const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
|
|
77
|
+
let answer = await probe();
|
|
78
|
+
let waited = 0;
|
|
79
|
+
while (answer?.ready !== true && waited < options.windowMs) {
|
|
80
|
+
// At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
|
|
81
|
+
const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
|
|
82
|
+
await sleep(step);
|
|
83
|
+
waited += step;
|
|
84
|
+
answer = (await probe()) ?? answer;
|
|
85
|
+
}
|
|
86
|
+
return answer;
|
|
87
|
+
}
|
package/src/ts-scan.ts
CHANGED
|
@@ -19,6 +19,17 @@ export interface CodeSite extends SourceSite {
|
|
|
19
19
|
readonly code: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface UnresolvedCodeSite extends SourceSite {
|
|
23
|
+
/** The identifier exactly as written at the `code:` position. */
|
|
24
|
+
readonly name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One file's codes, and the names at a `code:` position this scan could not turn into one. */
|
|
28
|
+
export interface CodeScan {
|
|
29
|
+
readonly sites: readonly CodeSite[];
|
|
30
|
+
readonly unresolved: readonly UnresolvedCodeSite[];
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
// `ReadonlySet`, so a consumer cannot mutate what every scan in this package reads.
|
|
23
34
|
export const QUOTES: ReadonlySet<string> = new Set(["'", '"', '`']);
|
|
24
35
|
export const OPENERS: ReadonlySet<string> = new Set(['(', '[', '{']);
|
|
@@ -150,10 +161,18 @@ export function lineIndex(text: string): (index: number) => number {
|
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
/**
|
|
153
|
-
* Every string literal
|
|
154
|
-
* depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and
|
|
155
|
-
* instead of silently skipped; the depth rule is what keeps
|
|
156
|
-
* `table['key']`'s key out — an argument is not a fix.
|
|
164
|
+
* Every string literal the value expression starting at `from` can EVALUATE TO, at the
|
|
165
|
+
* expression's own bracket depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and
|
|
166
|
+
* `table[k] ?? 'c'` checkable instead of silently skipped; the depth rule is what keeps
|
|
167
|
+
* `command.join(' ')`'s separator and `table['key']`'s key out — an argument is not a fix.
|
|
168
|
+
*
|
|
169
|
+
* A ternary's CONDITION is dropped, which is the difference between reading the expression and
|
|
170
|
+
* reading every literal in it. `fix: input.slug === '' ? 'x g …' : 'x g …'` published the empty
|
|
171
|
+
* string as a fix line — `X_ERROR_FIX_INVALID`, "the fix line is empty", against source whose two
|
|
172
|
+
* real fixes are both correct — and `input.key === 'timeZone' ? … : …` published `timeZone`, a
|
|
173
|
+
* string then judged for banned phrases and cited paths that is not a fix at all. Costly enough
|
|
174
|
+
* that `@ultimat3/testing`'s island-state errors carry two classes under one code rather than one
|
|
175
|
+
* class with a ternary in it.
|
|
157
176
|
*/
|
|
158
177
|
export function valueLiterals(
|
|
159
178
|
masked: string,
|
|
@@ -162,21 +181,42 @@ export function valueLiterals(
|
|
|
162
181
|
lineAt: (index: number) => number,
|
|
163
182
|
): readonly FixSite[] {
|
|
164
183
|
const found: { value: string; index: number }[] = [];
|
|
184
|
+
// The literals of the segment being read. A segment ended by `?` is a condition and is dropped
|
|
185
|
+
// whole; one ended by `:` or by the end of the expression is a value the fix can evaluate to.
|
|
186
|
+
let segment: { value: string; index: number }[] = [];
|
|
187
|
+
const keep = (): void => {
|
|
188
|
+
found.push(...segment);
|
|
189
|
+
segment = [];
|
|
190
|
+
};
|
|
165
191
|
let depth = 0;
|
|
192
|
+
/** Open `?`s still waiting for their `:`, so a `:` outside a ternary stays an ordinary char. */
|
|
193
|
+
let conditionals = 0;
|
|
166
194
|
for (let i = from; i < masked.length; i += 1) {
|
|
167
195
|
const ch = masked[i] as string;
|
|
168
196
|
if (QUOTES.has(ch)) {
|
|
169
197
|
const end = endOfLiteral(masked, i);
|
|
170
198
|
// A quote that never closes is one character of code, not an empty literal to report.
|
|
171
199
|
if (end === i + 1) continue;
|
|
172
|
-
if (depth === 0)
|
|
200
|
+
if (depth === 0) segment.push({ value: source.slice(i + 1, end - 1), index: i });
|
|
173
201
|
i = end - 1;
|
|
174
202
|
} else if (OPENERS.has(ch)) depth += 1;
|
|
175
203
|
else if (CLOSERS.has(ch)) {
|
|
176
204
|
if (depth === 0) break;
|
|
177
205
|
depth -= 1;
|
|
206
|
+
} else if (depth === 0 && ch === '?') {
|
|
207
|
+
// `??` and `?.` are operators and end no segment: `input?.fix ?? 'x help'` evaluates to the
|
|
208
|
+
// literal, and dropping what came before it would drop the only answer the expression has.
|
|
209
|
+
if (masked[i + 1] === '?') i += 1;
|
|
210
|
+
else if (masked[i + 1] !== '.') {
|
|
211
|
+
segment = [];
|
|
212
|
+
conditionals += 1;
|
|
213
|
+
}
|
|
214
|
+
} else if (depth === 0 && ch === ':' && conditionals > 0) {
|
|
215
|
+
keep();
|
|
216
|
+
conditionals -= 1;
|
|
178
217
|
} else if (depth === 0 && (ch === ',' || ch === ';')) break;
|
|
179
218
|
}
|
|
219
|
+
keep();
|
|
180
220
|
return found.map((literal) => ({
|
|
181
221
|
at: '',
|
|
182
222
|
line: lineAt(literal.index),
|
|
@@ -202,27 +242,111 @@ const CODE_AT_KEY = /\bcode\s*[:=]\s*(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
|
202
242
|
const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
203
243
|
const CODE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
|
|
204
244
|
|
|
245
|
+
/**
|
|
246
|
+
* A `code` KEY, and never a member assignment: `found.code = SOMETHING` projects somebody else's
|
|
247
|
+
* code and declares none. The literal form above keeps its looser `\b` deliberately — a scanner
|
|
248
|
+
* that stopped collecting a code it has collected for four majors would shrink the manifest.
|
|
249
|
+
*/
|
|
250
|
+
const CODE_KEY_POSITION = /(?<![.\w$])code\s*[:=]\s*/g;
|
|
251
|
+
|
|
252
|
+
/** Cheap enough to run on every file, so the masking pass below is paid only where it can pay. */
|
|
253
|
+
const HAS_CODE_IDENTIFIER = /(?<![.\w$])code\s*[:=]\s*[A-Za-z_$]/;
|
|
254
|
+
|
|
255
|
+
/** Sticky: the value expression is read at an exact offset, never out of a slice that may cut. */
|
|
256
|
+
const VALUE_IDENTIFIER = /([A-Za-z_$][\w$]*)\s*([.([]?)/y;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A module-scope `const NAME = 'X_…'`, and the names of every other module-scope const. Anchored
|
|
260
|
+
* at column 0, which is what makes it module scope without a parser: a `const` inside a function
|
|
261
|
+
* can be shadowed by another in a sibling scope, and a resolver that picked one of them would be
|
|
262
|
+
* guessing. The second set is the answer "that name IS declared here, and it is not a code" —
|
|
263
|
+
* `const STATUS_NOT_FOUND = 404` in `@ultimat3/realtime`'s NATS fake is the live instance, and a
|
|
264
|
+
* rule that reported it would be a rule the reader has to argue with.
|
|
265
|
+
*/
|
|
266
|
+
const CODE_CONST =
|
|
267
|
+
/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]*)?=\s*(['"`])(X_[A-Z0-9_]+)\2/gm;
|
|
268
|
+
const MODULE_CONST = /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*[:=]/gm;
|
|
269
|
+
|
|
270
|
+
/** House shape for a constant. A lowercase name at a `code:` is a type annotation or a re-raise. */
|
|
271
|
+
const CODE_CONSTANT_NAME = /^[A-Z][A-Z0-9_]+$/;
|
|
272
|
+
|
|
273
|
+
interface ModuleConstants {
|
|
274
|
+
/** Name → the code it holds. */
|
|
275
|
+
readonly codes: ReadonlyMap<string, string>;
|
|
276
|
+
/** Every module-scope const name, code-valued or not. */
|
|
277
|
+
readonly names: ReadonlySet<string>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function moduleConstants(text: string): ModuleConstants {
|
|
281
|
+
const codes = new Map<string, string>();
|
|
282
|
+
const names = new Set<string>();
|
|
283
|
+
for (const match of text.matchAll(MODULE_CONST)) names.add(match[1] as string);
|
|
284
|
+
for (const match of text.matchAll(CODE_CONST)) codes.set(match[1] as string, match[3] as string);
|
|
285
|
+
return { codes, names };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The bare identifier a key's value is, or `undefined` when the value is anything else. A member
|
|
290
|
+
* read, an index and a call are all refused: `SEO_ERROR_CODES.metaMissing` is how two packages
|
|
291
|
+
* raise every code they own, the registry branch below already collects those literals, and
|
|
292
|
+
* judging the read would report eighteen working sites as broken.
|
|
293
|
+
*/
|
|
294
|
+
function valueIdentifier(masked: string, from: number): string | undefined {
|
|
295
|
+
VALUE_IDENTIFIER.lastIndex = from;
|
|
296
|
+
const match = VALUE_IDENTIFIER.exec(masked);
|
|
297
|
+
return match === null || match[2] !== '' ? undefined : match[1];
|
|
298
|
+
}
|
|
299
|
+
|
|
205
300
|
/**
|
|
206
301
|
* Codes this file declares: every `code:` / `code =` throw site, plus — in a package's own code
|
|
207
302
|
* registry — every entry of its code list or title table, whichever shape it uses. A registry is
|
|
208
303
|
* the only place a bare `X_*` literal is a declaration; anywhere else it is a reference (an env
|
|
209
304
|
* var named `X_BUILD_ID`, an HTTP status map keyed by code) and collecting it would invent a code.
|
|
305
|
+
*
|
|
306
|
+
* A `code:` written as an IDENTIFIER is resolved against the module-scope consts of the same file,
|
|
307
|
+
* and reported as `unresolved` when nothing there gives it a value (#277). Both halves matter and
|
|
308
|
+
* neither is optional: `const STALE = 'X_DOC_PACKAGE_GRAPH_STALE'` is what a DRY author writes, and
|
|
309
|
+
* a scan that skipped it silently left the code out of the manifest, out of `wiki/Error-Codes.md`'s
|
|
310
|
+
* demanded rows, out of `bun run gate-codes` and out of `x errors explain` — permissive, and quiet.
|
|
311
|
+
* The identifier half reads the MASKED text: `packages/cli/src/templates/` emits app source by the
|
|
312
|
+
* dozen inside template literals, and a `code: STALE` in one of those is text, not a declaration.
|
|
210
313
|
*/
|
|
211
|
-
export function
|
|
314
|
+
export function scanCodeDeclarations(source: string, at: string): CodeScan {
|
|
212
315
|
const text = stripComments(source);
|
|
213
316
|
const lineAt = lineIndex(text);
|
|
214
317
|
const sites = new Map<string, CodeSite>();
|
|
318
|
+
const unresolved: UnresolvedCodeSite[] = [];
|
|
215
319
|
const add = (code: string, index: number): void => {
|
|
216
320
|
if (!sites.has(code)) sites.set(code, { at, line: lineAt(index), code });
|
|
217
321
|
};
|
|
218
322
|
for (const match of text.matchAll(CODE_AT_KEY)) add(match[2] as string, match.index);
|
|
323
|
+
if (HAS_CODE_IDENTIFIER.test(text)) {
|
|
324
|
+
const masked = maskLiterals(source);
|
|
325
|
+
const constants = moduleConstants(text);
|
|
326
|
+
for (const key of masked.matchAll(CODE_KEY_POSITION)) {
|
|
327
|
+
const name = valueIdentifier(masked, key.index + key[0].length);
|
|
328
|
+
if (name === undefined) continue;
|
|
329
|
+
const code = constants.codes.get(name);
|
|
330
|
+
if (code !== undefined) add(code, key.index);
|
|
331
|
+
else if (!constants.names.has(name) && CODE_CONSTANT_NAME.test(name)) {
|
|
332
|
+
unresolved.push({ at, line: lineAt(key.index), name });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
219
336
|
if (isCodeRegistry(text)) {
|
|
220
337
|
for (const match of text.matchAll(CODE_LITERAL)) add(match[2] as string, match.index);
|
|
221
338
|
for (const match of text.matchAll(CODE_KEY)) add(match[1] as string, match.index);
|
|
222
339
|
}
|
|
223
|
-
return [...sites.values()];
|
|
340
|
+
return { sites: [...sites.values()], unresolved };
|
|
224
341
|
}
|
|
225
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The codes alone, for every caller that has no report to attach a finding to. One scanner, one
|
|
345
|
+
* answer: the manifest, the docs check, `bun run gate-codes` and `x errors explain` all read this.
|
|
346
|
+
*/
|
|
347
|
+
export const scanCodes = (source: string, at: string): readonly CodeSite[] =>
|
|
348
|
+
scanCodeDeclarations(source, at).sites;
|
|
349
|
+
|
|
226
350
|
export interface CodeFixSite extends CodeSite {
|
|
227
351
|
/**
|
|
228
352
|
* The fix literal exactly as written, `${…}` included. Absent when the throw site builds its
|
|
@@ -250,6 +374,8 @@ function soleLiteral(
|
|
|
250
374
|
return found.length === 1 ? found[0] : undefined;
|
|
251
375
|
}
|
|
252
376
|
|
|
377
|
+
const CODE_NAME = /^X_[A-Z0-9_]+$/;
|
|
378
|
+
|
|
253
379
|
/**
|
|
254
380
|
* Every `X_*` code paired with the `fix:` written beside it — in the SAME object literal, which is
|
|
255
381
|
* the whole rule. `new UltimateError({ code, cause, fix })` is the one shape this framework raises
|
|
@@ -263,6 +389,15 @@ function soleLiteral(
|
|
|
263
389
|
export function scanCodeFixSites(source: string, at: string): readonly CodeFixSite[] {
|
|
264
390
|
const masked = maskLiterals(source);
|
|
265
391
|
const lineAt = lineIndex(masked);
|
|
392
|
+
// Lazily, because most files hold no `code:` at all and stripping is a whole extra pass over
|
|
393
|
+
// the text. Same resolver `scanCodeDeclarations` reads, so `x errors explain` can never see a
|
|
394
|
+
// smaller set of throw sites than the manifest does.
|
|
395
|
+
let constants: ModuleConstants | undefined;
|
|
396
|
+
const constantCode = (name: string | undefined): string | undefined => {
|
|
397
|
+
if (name === undefined) return undefined;
|
|
398
|
+
constants ??= moduleConstants(stripComments(source));
|
|
399
|
+
return constants.codes.get(name);
|
|
400
|
+
};
|
|
266
401
|
const keys = new Map<number, { readonly kind: 'code' | 'fix'; readonly from: number }>();
|
|
267
402
|
for (const key of masked.matchAll(CODE_OR_FIX_KEY)) {
|
|
268
403
|
keys.set(key.index, {
|
|
@@ -293,11 +428,16 @@ export function scanCodeFixSites(source: string, at: string): readonly CodeFixSi
|
|
|
293
428
|
const scope = stack.at(-1);
|
|
294
429
|
if (key === undefined || scope === undefined) continue;
|
|
295
430
|
const literal = soleLiteral(masked, source, key.from, lineAt);
|
|
296
|
-
if (literal === undefined) continue;
|
|
297
431
|
if (key.kind === 'fix') {
|
|
298
|
-
if (!fixes.has(scope)) fixes.set(scope, literal.fix);
|
|
299
|
-
|
|
300
|
-
|
|
432
|
+
if (literal !== undefined && !fixes.has(scope)) fixes.set(scope, literal.fix);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
// A fix has no second reading, so it stays literal-only; a code has exactly one, which is the
|
|
436
|
+
// module-scope const its own file declares it in.
|
|
437
|
+
const code =
|
|
438
|
+
literal === undefined ? constantCode(valueIdentifier(masked, key.from)) : literal.fix;
|
|
439
|
+
if (code !== undefined && CODE_NAME.test(code) && !codes.has(scope)) {
|
|
440
|
+
codes.set(scope, { at, line: literal?.line ?? lineAt(key.from), code });
|
|
301
441
|
}
|
|
302
442
|
}
|
|
303
443
|
return [...codes].map(([scope, site]) => {
|
package/src/verify-checks.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { checkBudgets, readBuildStats } from './budgets';
|
|
|
24
24
|
import { checkDestructiveMigrations } from './db-destructive';
|
|
25
25
|
import { checkDocumentStyles, documentSurfaces } from './document-styles';
|
|
26
26
|
import { checkSourceDrift } from './drift';
|
|
27
|
-
import { checkErrorFixReport } from './error-contract';
|
|
27
|
+
import { checkErrorCodeResolution, checkErrorFixReport } from './error-contract';
|
|
28
28
|
import { guardFindings } from './guards';
|
|
29
29
|
import { catalogFindings } from './i18n-registration';
|
|
30
30
|
import { liveRouteFindings } from './live-routes';
|
|
@@ -125,7 +125,15 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
125
125
|
// it does not have. "checked 412, could not read 27" is what a reader can act on.
|
|
126
126
|
async run(ctx) {
|
|
127
127
|
const report = await checkErrorFixReport(ctx.root);
|
|
128
|
-
|
|
128
|
+
// The third rule on this step, and the one that is about the code rather than the fix: a
|
|
129
|
+
// `code:` reached through a name nothing in its own file declares is a code no reader of the
|
|
130
|
+
// set can see — not the manifest, not the reference page's coverage rule, not
|
|
131
|
+
// `x errors explain`. It runs here because it needs source and nothing else (#277).
|
|
132
|
+
const findings = [
|
|
133
|
+
...report.findings,
|
|
134
|
+
...(await checkErrorCodeResolution(ctx.root)),
|
|
135
|
+
...(await hostFindings(ctx, 'errors')),
|
|
136
|
+
];
|
|
129
137
|
return {
|
|
130
138
|
...fromFindings(findings),
|
|
131
139
|
output: msg('cli.verify.fixCoverage', {
|
package/src/workspace-checks.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// Four shape rules the gate owns: one file, one job (a hard line ceiling),
|
|
2
|
-
// package shipping the same contract files, every published package's tarball
|
|
3
|
-
// manifest promises, and every published package being in the root build graph.
|
|
4
|
-
// findings — a shape rule that is only written down is not a rule (axiom 3).
|
|
1
|
+
// Four shape rules the gate owns: one file, one job (a hard line ceiling on REVIEWABLE LOGIC),
|
|
2
|
+
// every workspace package shipping the same contract files, every published package's tarball
|
|
3
|
+
// matching what its manifest promises, and every published package being in the root build graph.
|
|
4
|
+
// All report findings — a shape rule that is only written down is not a rule (axiom 3).
|
|
5
|
+
//
|
|
6
|
+
// The ceiling exempts a pure re-export manifest, and only that. Such a file has one job by
|
|
7
|
+
// construction and its length is a function of the package's API size rather than of its
|
|
8
|
+
// complexity: `@ultimat3/core`'s `src/index.ts` reached 514 lines with 513 statements and not one
|
|
9
|
+
// of them logic, so the ceiling had stopped protecting anything and started refusing every new
|
|
10
|
+
// public subject. `isReExportManifest` is the whole carve-out — one statement of logic in such a
|
|
11
|
+
// file re-arms the ceiling on the same save, which is what keeps it from being a hole.
|
|
5
12
|
|
|
6
13
|
import { existsSync } from 'node:fs';
|
|
7
14
|
import { join } from 'node:path';
|
|
8
15
|
import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
|
|
9
16
|
import type { Finding } from './output';
|
|
17
|
+
import { isReExportManifest } from './reexport-manifest';
|
|
10
18
|
import { eachSourceFile, isGenerated } from './source-files';
|
|
11
19
|
import { checkRootReferences } from './tsconfig-references';
|
|
12
20
|
|
|
@@ -30,13 +38,21 @@ export const tooLongFinding = (path: string, lines: number): Finding => ({
|
|
|
30
38
|
export const countLines = (text: string): number =>
|
|
31
39
|
text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
32
40
|
|
|
33
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Files are the unit of review: one file, one job, hard ceiling 500 lines of reviewable logic.
|
|
43
|
+
*
|
|
44
|
+
* The source is read before the count is judged rather than after, because the exemption is a
|
|
45
|
+
* question about CONTENTS: a 3,000-line file of re-exports is one job and a 501-line file with one
|
|
46
|
+
* statement of logic in it is not, and only reading tells them apart.
|
|
47
|
+
*/
|
|
34
48
|
export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
|
|
35
49
|
const findings: Finding[] = [];
|
|
36
50
|
for await (const path of eachSourceFile(root)) {
|
|
37
51
|
if (isGenerated(path)) continue;
|
|
38
|
-
const
|
|
39
|
-
|
|
52
|
+
const source = await Bun.file(join(root, path)).text();
|
|
53
|
+
const lines = countLines(source);
|
|
54
|
+
if (lines <= LINE_CEILING || isReExportManifest(source)) continue;
|
|
55
|
+
findings.push(tooLongFinding(path, lines));
|
|
40
56
|
}
|
|
41
57
|
return findings;
|
|
42
58
|
}
|