@ultimat3/cli 11.1.0 → 11.3.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 +62 -1
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher.ts +91 -9
- package/src/cmd-dev.ts +11 -0
- package/src/cmd-shot-island.ts +151 -0
- package/src/cmd-shot.ts +66 -95
- package/src/compile-externals.ts +7 -4
- package/src/error-codes.ts +11 -0
- package/src/island-bundle.ts +18 -7
- 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-browser.ts +84 -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
- package/src/island-solid-production.ts +0 -129
package/src/messages.ts
CHANGED
|
@@ -196,6 +196,20 @@ const CATALOG = {
|
|
|
196
196
|
'cli.shot.verdict': ' verdict {path}',
|
|
197
197
|
'cli.shot.blind.status':
|
|
198
198
|
'HTTP response status is not observed — the port records requests, never responses',
|
|
199
|
+
// `--island`. A component capture reports per STATE, so the summary counts pictures and the
|
|
200
|
+
// lines name one state each; nothing here restates a fact `--json` does not carry.
|
|
201
|
+
'cli.shot.island.ok':
|
|
202
|
+
'{island} clean — {pictures} picture(s), every one mounted, nothing logged and nothing threw',
|
|
203
|
+
'cli.shot.island.failed':
|
|
204
|
+
'{island}: {taken} of {expected} declared picture(s) taken — verdict.json names each refusal',
|
|
205
|
+
'cli.shot.island.state': ' {state} {theme} {width}x{height} {file}',
|
|
206
|
+
'cli.shot.island.missing': ' missing {file} — no picture was taken for this declared state',
|
|
207
|
+
'cli.shot.island.picture': ' pictures {path}',
|
|
208
|
+
'cli.shot.island.verdict': ' verdict {path}',
|
|
209
|
+
'cli.shot.island.blind.crop':
|
|
210
|
+
'the picture is the viewport, not a crop — the browser port takes no clip rectangle, so a state sizes its own frame with viewport',
|
|
211
|
+
'cli.shot.island.blind.locale':
|
|
212
|
+
'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
|
|
199
213
|
'cli.ci.failed':
|
|
200
214
|
'{failed} of {runs} workflow run(s) on {branch} failed — {findings} finding(s) recovered from the log',
|
|
201
215
|
'cli.ci.green': 'every one of {runs} workflow run(s) on {branch} passed',
|
|
@@ -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,84 @@
|
|
|
1
|
+
// Which browser a `x shot` run gets: start one in this container, or ATTACH to one somebody else is
|
|
2
|
+
// running. Three rules over plain inputs and no `ParsedArgs`, so each is testable without a boot —
|
|
3
|
+
// the `cmd-jobs.ts` / `jobs-report.ts` split, repeated for the one decision that is easy to get
|
|
4
|
+
// silently wrong.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
browserBinaryExists,
|
|
8
|
+
cdpUrlFrom,
|
|
9
|
+
cdpUrlProblem,
|
|
10
|
+
executablePathFrom,
|
|
11
|
+
} from './browser-launcher';
|
|
12
|
+
import { BadFlagError } from './errors';
|
|
13
|
+
|
|
14
|
+
/** A runnable example, not a placeholder: every refusal below hands one of these back. */
|
|
15
|
+
const CDP_FIX = 'x shot / --cdp-url wss://cdp.example.com/session/abc';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Exactly one of these is set. `undefined` on both is the ordinary local run where the library
|
|
19
|
+
* finds its own Chrome — which is why neither is required rather than a union of two shapes.
|
|
20
|
+
*/
|
|
21
|
+
export interface ShotBrowserChoice {
|
|
22
|
+
/** Attach here. When set, nothing about a local executable was read. */
|
|
23
|
+
readonly cdpUrl?: string | undefined;
|
|
24
|
+
/** Launch this. Already proved to exist on disk. */
|
|
25
|
+
readonly executablePath?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ShotBrowserInput {
|
|
29
|
+
/** `--cdp-url` as typed. The env fallback is applied here, not by the caller. */
|
|
30
|
+
readonly cdpFlag?: string | undefined;
|
|
31
|
+
/** `--browser` as typed, before `PUPPETEER_EXECUTABLE_PATH` / `CHROME_PATH`. */
|
|
32
|
+
readonly browserFlag?: string | undefined;
|
|
33
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decided before anything boots, because a typo must not cost an embedded Postgres — and, on the
|
|
38
|
+
* attach path, a provider session — to report.
|
|
39
|
+
*
|
|
40
|
+
* The three rules, each chosen against a silent failure rather than for symmetry:
|
|
41
|
+
*
|
|
42
|
+
* 1. **Both FLAGS is refused, never ranked.** One names a Chrome to START and the other says the
|
|
43
|
+
* browser is somebody else's, so honouring either ignores what was typed.
|
|
44
|
+
* 2. **An exported `SCRAPE_CDP_URL` loses to `--browser`.** A shell-wide default is not a typed
|
|
45
|
+
* intent. The alternative is a flag that parses, reports nothing and quietly attaches somewhere
|
|
46
|
+
* else — the `--critical` defect class `flag-reads.ts` exists for and cannot see here, because
|
|
47
|
+
* the flag IS read.
|
|
48
|
+
* 3. **On an attach, no executable is read at all.** Checking the filesystem for a binary this run
|
|
49
|
+
* will never execute is how a correct remote capture gets refused on a box with no Chrome.
|
|
50
|
+
*/
|
|
51
|
+
export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
|
|
52
|
+
if (input.cdpFlag !== undefined && input.browserFlag !== undefined) {
|
|
53
|
+
throw new BadFlagError({
|
|
54
|
+
flag: 'cdp-url',
|
|
55
|
+
command: 'shot',
|
|
56
|
+
reason:
|
|
57
|
+
'--browser names a Chrome to launch here and --cdp-url attaches to one already running',
|
|
58
|
+
fix: CDP_FIX,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const cdpUrl = input.browserFlag === undefined ? cdpUrlFrom(input.cdpFlag, input.env) : undefined;
|
|
62
|
+
if (cdpUrl !== undefined) {
|
|
63
|
+
const problem = cdpUrlProblem(cdpUrl);
|
|
64
|
+
if (problem !== undefined) {
|
|
65
|
+
throw new BadFlagError({
|
|
66
|
+
flag: 'cdp-url',
|
|
67
|
+
command: 'shot',
|
|
68
|
+
reason: `"${cdpUrl}" ${problem}`,
|
|
69
|
+
fix: CDP_FIX,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { cdpUrl };
|
|
73
|
+
}
|
|
74
|
+
const executablePath = executablePathFrom(input.browserFlag, input.env);
|
|
75
|
+
if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
|
|
76
|
+
throw new BadFlagError({
|
|
77
|
+
flag: 'browser',
|
|
78
|
+
command: 'shot',
|
|
79
|
+
reason: `no executable at "${executablePath}"`,
|
|
80
|
+
fix: 'x shot / --browser /usr/bin/chromium',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return executablePath === undefined ? {} : { executablePath };
|
|
84
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
// Every `solid-js` import in an island chunk resolves to Solid's PRODUCTION browser build.
|
|
2
|
-
// `Bun.build({ target: 'browser' })` always adds the `development` export condition and offers no
|
|
3
|
-
// option that removes it — `conditions`, `production`, `env` and `define` were each measured under
|
|
4
|
-
// Bun 1.4 and none of them does — so without this seam an island ships the dev bundle silently.
|
|
5
|
-
|
|
6
|
-
// `node:path` by necessity: Bun ships no path API, and this file resolves a package entry back
|
|
7
|
-
// to the directory its `exports` map is relative to.
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
import type { BunPlugin } from 'bun';
|
|
10
|
-
import { IslandBuildFailedError } from './errors';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The conditions an island's `solid-js` subpath is resolved under. `development` is the one NOT in
|
|
14
|
-
* the set, which is the whole point of the file; `production` is in it because an island chunk is
|
|
15
|
-
* only ever built to be shipped — `x dev` serves the same chunk the container does, so a second
|
|
16
|
-
* answer here would be a bundle the byte budget never measured.
|
|
17
|
-
*/
|
|
18
|
-
const ISLAND_CONDITIONS: ReadonlySet<string> = new Set([
|
|
19
|
-
'production',
|
|
20
|
-
'browser',
|
|
21
|
-
'module',
|
|
22
|
-
'import',
|
|
23
|
-
'default',
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
/** `solid-js` and its subpaths, and nothing else: Solid is the runtime an island is compiled for. */
|
|
27
|
-
const SOLID_SPECIFIER = /^solid-js(?:\/|$)/;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Node's conditional-exports walk, restricted to what this file needs: the first key of the object
|
|
31
|
-
* that the build's condition set contains, depth-first, with an array as an ordered fallback list.
|
|
32
|
-
* Written out rather than delegated to `Bun.resolveSync` because the ONE thing it has to do
|
|
33
|
-
* differently from Bun's resolver is refuse `development` — and `types` with it, which would
|
|
34
|
-
* otherwise win on Solid's map and hand the bundler a `.d.ts`.
|
|
35
|
-
*/
|
|
36
|
-
export function selectCondition(node: unknown, conditions: ReadonlySet<string>): string | null {
|
|
37
|
-
if (typeof node === 'string') return node;
|
|
38
|
-
if (Array.isArray(node)) {
|
|
39
|
-
for (const alternative of node as readonly unknown[]) {
|
|
40
|
-
const picked = selectCondition(alternative, conditions);
|
|
41
|
-
if (picked !== null) return picked;
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
if (typeof node !== 'object' || node === null) return null;
|
|
46
|
-
for (const [condition, value] of Object.entries(node)) {
|
|
47
|
-
if (!conditions.has(condition)) continue;
|
|
48
|
-
const picked = selectCondition(value, conditions);
|
|
49
|
-
if (picked !== null) return picked;
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** One parse per manifest: an island imports Solid from several files, and every file asks again. */
|
|
55
|
-
const exportsCache = new Map<string, unknown>();
|
|
56
|
-
|
|
57
|
-
async function exportsOf(manifest: string): Promise<unknown> {
|
|
58
|
-
const hit = exportsCache.get(manifest);
|
|
59
|
-
if (hit !== undefined || exportsCache.has(manifest)) return hit;
|
|
60
|
-
const parsed: unknown = JSON.parse(await Bun.file(manifest).text());
|
|
61
|
-
const field =
|
|
62
|
-
typeof parsed === 'object' && parsed !== null && 'exports' in parsed
|
|
63
|
-
? (parsed as { readonly exports?: unknown }).exports
|
|
64
|
-
: undefined;
|
|
65
|
-
exportsCache.set(manifest, field);
|
|
66
|
-
return field;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Test seam: the cache is process-global because `x dev` rebuilds in one process. */
|
|
70
|
-
export function clearSolidExportsCache(): void {
|
|
71
|
-
exportsCache.clear();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The absolute file `specifier` must resolve to, or `null` for "Bun's own answer is already the
|
|
76
|
-
* right one" — which is every subpath Solid declares as a plain string or a pattern, since a
|
|
77
|
-
* declaration with no conditions on it cannot select the development build.
|
|
78
|
-
*/
|
|
79
|
-
export async function solidProductionEntry(
|
|
80
|
-
specifier: string,
|
|
81
|
-
resolveDir: string,
|
|
82
|
-
importer: string,
|
|
83
|
-
): Promise<string | null> {
|
|
84
|
-
let manifest: string;
|
|
85
|
-
try {
|
|
86
|
-
// `solid-js/package.json` is an `exports` entry of Solid's own map, so this reaches the exact
|
|
87
|
-
// copy the island would have imported — not a hoisted sibling at a different version.
|
|
88
|
-
manifest = Bun.resolveSync('solid-js/package.json', resolveDir);
|
|
89
|
-
} catch {
|
|
90
|
-
// Solid is not installed here. Bun's resolver says so, in its own words, naming the importer.
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
const field = await exportsOf(manifest);
|
|
94
|
-
const subpath = specifier === 'solid-js' ? '.' : `.${specifier.slice('solid-js'.length)}`;
|
|
95
|
-
if (typeof field !== 'object' || field === null || !(subpath in field)) return null;
|
|
96
|
-
|
|
97
|
-
const entry = selectCondition((field as Record<string, unknown>)[subpath], ISLAND_CONDITIONS);
|
|
98
|
-
const file = entry === null ? null : join(dirname(manifest), entry);
|
|
99
|
-
if (file === null || !(await Bun.file(file).exists())) {
|
|
100
|
-
throw new IslandBuildFailedError({
|
|
101
|
-
file: importer.length > 0 ? importer : specifier,
|
|
102
|
-
logs:
|
|
103
|
-
`${specifier} has no production browser entry: ${manifest} answers ` +
|
|
104
|
-
`${entry === null ? 'nothing' : JSON.stringify(entry)} under ` +
|
|
105
|
-
`[${[...ISLAND_CONDITIONS].join(', ')}], and an island may not ship Solid's ` +
|
|
106
|
-
'development build',
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
return file;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The plugin `island-bundle.ts` hands `Bun.build`, beside `solidJsxPlugin`. Stateless apart from
|
|
114
|
-
* the manifest cache, so one frozen descriptor serves every concurrent island build.
|
|
115
|
-
*
|
|
116
|
-
* The absolute path it answers with no longer matches `SOLID_SPECIFIER`, so Solid's own internal
|
|
117
|
-
* `import … from 'solid-js'` is the only re-entry — and that one is wanted: it is how `web.js`
|
|
118
|
-
* reaches `solid.js` rather than `dev.js`.
|
|
119
|
-
*/
|
|
120
|
-
export const solidProductionPlugin: BunPlugin = {
|
|
121
|
-
name: 'ultimate-island-solid-production',
|
|
122
|
-
setup(build): void {
|
|
123
|
-
build.onResolve({ filter: SOLID_SPECIFIER }, async ({ path, importer, resolveDir }) => {
|
|
124
|
-
const from = resolveDir.length > 0 ? resolveDir : dirname(importer);
|
|
125
|
-
const entry = await solidProductionEntry(path, from, importer);
|
|
126
|
-
return entry === null ? undefined : { path: entry };
|
|
127
|
-
});
|
|
128
|
-
},
|
|
129
|
-
};
|