@ultimat3/cli 11.1.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 +60 -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 +11 -0
- 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 +11 -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 +34 -5
- package/src/workspace-checks.ts +23 -7
|
@@ -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
|
@@ -161,10 +161,18 @@ export function lineIndex(text: string): (index: number) => number {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Every string literal
|
|
165
|
-
* depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and
|
|
166
|
-
* instead of silently skipped; the depth rule is what keeps
|
|
167
|
-
* `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.
|
|
168
176
|
*/
|
|
169
177
|
export function valueLiterals(
|
|
170
178
|
masked: string,
|
|
@@ -173,21 +181,42 @@ export function valueLiterals(
|
|
|
173
181
|
lineAt: (index: number) => number,
|
|
174
182
|
): readonly FixSite[] {
|
|
175
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
|
+
};
|
|
176
191
|
let depth = 0;
|
|
192
|
+
/** Open `?`s still waiting for their `:`, so a `:` outside a ternary stays an ordinary char. */
|
|
193
|
+
let conditionals = 0;
|
|
177
194
|
for (let i = from; i < masked.length; i += 1) {
|
|
178
195
|
const ch = masked[i] as string;
|
|
179
196
|
if (QUOTES.has(ch)) {
|
|
180
197
|
const end = endOfLiteral(masked, i);
|
|
181
198
|
// A quote that never closes is one character of code, not an empty literal to report.
|
|
182
199
|
if (end === i + 1) continue;
|
|
183
|
-
if (depth === 0)
|
|
200
|
+
if (depth === 0) segment.push({ value: source.slice(i + 1, end - 1), index: i });
|
|
184
201
|
i = end - 1;
|
|
185
202
|
} else if (OPENERS.has(ch)) depth += 1;
|
|
186
203
|
else if (CLOSERS.has(ch)) {
|
|
187
204
|
if (depth === 0) break;
|
|
188
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;
|
|
189
217
|
} else if (depth === 0 && (ch === ',' || ch === ';')) break;
|
|
190
218
|
}
|
|
219
|
+
keep();
|
|
191
220
|
return found.map((literal) => ({
|
|
192
221
|
at: '',
|
|
193
222
|
line: lineAt(literal.index),
|
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
|
}
|