@ultimat3/cli 19.1.3 → 19.3.1
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 +125 -8
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/app-openapi.ts +13 -5
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +53 -4
- package/src/budgets.ts +60 -7
- package/src/cmd-dev.ts +49 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-shot.ts +3 -1
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +17 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +75 -0
- package/src/doctor-offline.ts +122 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-catalog.ts +4 -5
- package/src/error-codes.ts +6 -0
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +3 -1
- package/src/island-bundle.ts +123 -10
- package/src/island-harness.ts +11 -4
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +21 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +68 -16
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +17 -2
- package/src/shot-browser.ts +23 -4
- package/src/source-files.ts +3 -1
- package/src/static-report.ts +21 -1
- package/src/style-bundle.ts +124 -0
- package/src/style-csp.ts +14 -12
- package/src/style-routes.ts +56 -0
- package/src/sw-artifacts.ts +84 -12
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +13 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +13 -7
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +33 -8
- package/src/web-binding.ts +22 -0
package/src/gitignore.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// What git ignores, as data. The one reader of a `.gitignore` in this package: `dev-watch.ts` asks
|
|
2
|
+
// it which directories `x dev` must not watch, and `fix-path.ts` asks it which citations name a
|
|
3
|
+
// file this repository never commits. A second reader would be a second answer, and the two would
|
|
4
|
+
// disagree on the first pattern neither author anticipated.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no synchronous file read and no synchronous existence primitive —
|
|
7
|
+
// `Bun.file(p).text()` is async, and the ignore set is rebuilt inside an `fs.watch` callback where
|
|
8
|
+
// an await opens a window for the next event. Delete when Bun ships sync equivalents.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join, dirname or relative primitive. The same necessity `fix-path.ts`
|
|
11
|
+
// already records for `join`.
|
|
12
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** One line of a `.gitignore`, read for what it states rather than for what a reader assumes. */
|
|
15
|
+
export interface IgnorePattern {
|
|
16
|
+
/** The glob, without its leading `/` or trailing `/`. */
|
|
17
|
+
readonly glob: string;
|
|
18
|
+
/** Held against the whole path relative to the file's own directory, never the basename. */
|
|
19
|
+
readonly anchored: boolean;
|
|
20
|
+
/** A trailing `/`: it matches a directory and never a file of that name. */
|
|
21
|
+
readonly directoryOnly: boolean;
|
|
22
|
+
/** A leading `!`: it re-includes what an earlier pattern ignored, at the same level. */
|
|
23
|
+
readonly negated: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One `.gitignore` file and the directory every one of its patterns is relative to. */
|
|
27
|
+
export interface IgnoreScope {
|
|
28
|
+
readonly base: string;
|
|
29
|
+
readonly patterns: readonly IgnorePattern[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A pattern is ANCHORED when it holds a `/` anywhere but at its end — git's own rule, and the one
|
|
34
|
+
* a hand-rolled reader gets wrong in both directions: `coverage/` ignores a `coverage` directory at
|
|
35
|
+
* any depth, while `/coverage/` and a pattern holding an inner slash only ever match where they
|
|
36
|
+
* are written.
|
|
37
|
+
*/
|
|
38
|
+
export function parseGitignore(text: string): readonly IgnorePattern[] {
|
|
39
|
+
const patterns: IgnorePattern[] = [];
|
|
40
|
+
for (const raw of text.split('\n')) {
|
|
41
|
+
// Trailing whitespace carries no rule unless escaped; a leading `#` is a comment, and `\#` is
|
|
42
|
+
// a filename that starts with one.
|
|
43
|
+
const line = raw.replace(/\\?\s+$/, (match) => (match.startsWith('\\') ? match : ''));
|
|
44
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
45
|
+
const negated = line.startsWith('!');
|
|
46
|
+
const body = (negated ? line.slice(1) : line).replace(/^\\(?=[#!])/, '');
|
|
47
|
+
if (body === '' || body === '/') continue;
|
|
48
|
+
const directoryOnly = body.endsWith('/');
|
|
49
|
+
const trimmed = directoryOnly ? body.slice(0, -1) : body;
|
|
50
|
+
const rooted = trimmed.startsWith('/');
|
|
51
|
+
const glob = rooted ? trimmed.slice(1) : trimmed;
|
|
52
|
+
if (glob === '') continue;
|
|
53
|
+
patterns.push({ glob, anchored: rooted || glob.includes('/'), directoryOnly, negated });
|
|
54
|
+
}
|
|
55
|
+
return patterns;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The patterns one directory's own `.gitignore` states, or none where it has no such file. */
|
|
59
|
+
export function readIgnoreFile(directory: string): readonly IgnorePattern[] {
|
|
60
|
+
const file = join(directory, '.gitignore');
|
|
61
|
+
return existsSync(file) ? parseGitignore(readFileSync(file, 'utf8')) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `.gitignore` files that govern `root`: its own last, then every ancestor up to and including
|
|
66
|
+
* the directory holding `.git`. Outermost first, so a nearer file's pattern is read last and wins —
|
|
67
|
+
* `examples/dummy` carries no ignore file of its own and every rule about it lives in the
|
|
68
|
+
* repository root's, which is why reading one file made `touch tsconfig.tsbuildinfo` a full reload.
|
|
69
|
+
*/
|
|
70
|
+
export function ignoreScopes(root: string): readonly IgnoreScope[] {
|
|
71
|
+
const scopes: IgnoreScope[] = [];
|
|
72
|
+
let directory = root;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const patterns = readIgnoreFile(directory);
|
|
75
|
+
if (patterns.length > 0) scopes.unshift({ base: directory, patterns });
|
|
76
|
+
if (existsSync(join(directory, '.git'))) break;
|
|
77
|
+
const parent = dirname(directory);
|
|
78
|
+
if (parent === directory) break;
|
|
79
|
+
directory = parent;
|
|
80
|
+
}
|
|
81
|
+
return scopes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compiled once per pattern: one `x dev` walk asks the same glob of every directory it meets. */
|
|
85
|
+
const globs = new Map<string, Bun.Glob>();
|
|
86
|
+
|
|
87
|
+
function globFor(pattern: string): Bun.Glob {
|
|
88
|
+
const known = globs.get(pattern);
|
|
89
|
+
if (known !== undefined) return known;
|
|
90
|
+
const glob = new Bun.Glob(pattern);
|
|
91
|
+
globs.set(pattern, glob);
|
|
92
|
+
return glob;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The path relative to `base`, POSIX-separated, or `undefined` when it is not under it. */
|
|
96
|
+
function relativeUnder(base: string, path: string): string | undefined {
|
|
97
|
+
const rel = relative(base, path).split(sep).join('/');
|
|
98
|
+
return rel === '' || rel.startsWith('../') || rel === '..' ? undefined : rel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matches(pattern: IgnorePattern, path: string, isDirectory: boolean): boolean {
|
|
102
|
+
if (pattern.directoryOnly && !isDirectory) return false;
|
|
103
|
+
const subject = pattern.anchored ? path : (path.split('/').at(-1) ?? path);
|
|
104
|
+
return globFor(pattern.glob).match(subject);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Last match wins, across every scope in order — git's rule, and the reason `!` works at all. */
|
|
108
|
+
function verdictFor(
|
|
109
|
+
scopes: readonly IgnoreScope[],
|
|
110
|
+
path: string,
|
|
111
|
+
isDirectory: boolean,
|
|
112
|
+
): boolean | undefined {
|
|
113
|
+
let verdict: boolean | undefined;
|
|
114
|
+
for (const scope of scopes) {
|
|
115
|
+
const rel = relativeUnder(scope.base, path);
|
|
116
|
+
if (rel === undefined) continue;
|
|
117
|
+
for (const pattern of scope.patterns) {
|
|
118
|
+
if (matches(pattern, rel, isDirectory)) verdict = !pattern.negated;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return verdict;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether git would ignore this absolute path. Every ancestor between the outermost scope and the
|
|
126
|
+
* path is judged as a DIRECTORY first: an ignored directory takes everything under it, which is how
|
|
127
|
+
* `coverage/` reaches `coverage/lcov.info` and the only reason a walk may prune at all.
|
|
128
|
+
*/
|
|
129
|
+
export function isGitIgnored(
|
|
130
|
+
scopes: readonly IgnoreScope[],
|
|
131
|
+
path: string,
|
|
132
|
+
isDirectory: boolean,
|
|
133
|
+
): boolean {
|
|
134
|
+
const outermost = scopes[0];
|
|
135
|
+
if (outermost === undefined) return false;
|
|
136
|
+
const rel = relativeUnder(outermost.base, path);
|
|
137
|
+
if (rel === undefined) return false;
|
|
138
|
+
const segments = rel.split('/');
|
|
139
|
+
for (let depth = 0; depth < segments.length; depth += 1) {
|
|
140
|
+
const prefix = join(outermost.base, ...segments.slice(0, depth + 1));
|
|
141
|
+
const last = depth === segments.length - 1;
|
|
142
|
+
if (verdictFor(scopes, prefix, last ? isDirectory : true) === true) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
package/src/hold.ts
CHANGED
|
@@ -33,12 +33,15 @@ export interface HoldOptions {
|
|
|
33
33
|
* handler opened them against. It is the resources core never learned about: the embedded
|
|
34
34
|
* Postgres, the worker, the file watcher.
|
|
35
35
|
*
|
|
36
|
-
* It
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* It is BOUNDED, and that is not a detail. `drain()` ABANDONS a hook that overruns
|
|
37
|
+
* `ShutdownReason.deadlineAt` — the process is meant to exit without it — and `release` here
|
|
38
|
+
* re-enters the very same teardown one call later: `app.stop()` -> `startRoles().stop()` ->
|
|
39
|
+
* `worker.stop()`, memoised in the package that owns it, so awaiting it is awaiting the promise
|
|
40
|
+
* the drain just walked away from. Unbounded, that hangs forever and the deadline buys nothing.
|
|
41
|
+
*
|
|
42
|
+
* The bound is what is LEFT of the drain's budget, under a floor of `MIN_RELEASE_MS` — see there
|
|
43
|
+
* for why the remainder alone answered `0` on every busy pod and abandoned the teardown before it
|
|
44
|
+
* closed anything.
|
|
42
45
|
*/
|
|
43
46
|
export function holdUntilShutdown(
|
|
44
47
|
name: string,
|
|
@@ -48,8 +51,8 @@ export function holdUntilShutdown(
|
|
|
48
51
|
const uninstall = installSignalHandlers({ exit: false });
|
|
49
52
|
let unregister = (): void => {};
|
|
50
53
|
// The hook's own `reason`, not a stopwatch of ours: `deadlineAt` is the instant core computed
|
|
51
|
-
// when the drain began, on the same real monotonic clock, so
|
|
52
|
-
//
|
|
54
|
+
// when the drain began, on the same real monotonic clock, so what is left of the drain's budget
|
|
55
|
+
// is read off the drain's own number and never off a second one of the same length.
|
|
53
56
|
const shuttingDown = new Promise<number>((resolve) => {
|
|
54
57
|
unregister = onShutdown(
|
|
55
58
|
`cli:${name}:hold`,
|
|
@@ -71,7 +74,7 @@ export function holdUntilShutdown(
|
|
|
71
74
|
await drain();
|
|
72
75
|
unregister();
|
|
73
76
|
uninstall();
|
|
74
|
-
await releaseWithin(name, release, deadlineAt - systemClock.monotonic());
|
|
77
|
+
await releaseWithin(name, release, releaseBudgetMs(deadlineAt - systemClock.monotonic()));
|
|
75
78
|
options.exit?.(0);
|
|
76
79
|
})();
|
|
77
80
|
return held;
|
|
@@ -79,33 +82,63 @@ export function holdUntilShutdown(
|
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/**
|
|
82
|
-
* `release
|
|
85
|
+
* The floor under `release`'s budget, and the reason the drain's REMAINDER alone is the wrong one.
|
|
86
|
+
*
|
|
87
|
+
* The remainder is what is left of a budget that was spent on something else. A drain that used
|
|
88
|
+
* all of it — one request over budget is enough, and that is the ordinary shutdown on a busy pod —
|
|
89
|
+
* hands `release` a NEGATIVE number, `Math.max(0, …)` reads it as `0`, and `setTimeout(resolve, 0)`
|
|
90
|
+
* wins against any teardown whose first await is real work. Measured at `deadlineMs: 60` with one
|
|
91
|
+
* `beginWork()` outstanding: the release STARTED and was abandoned 0ms later, so `app.stop()` never
|
|
92
|
+
* reached the pool close, the NATS close, the cache tiers or the mail driver, and the outbox relay
|
|
93
|
+
* was abandoned somewhere between `driver.enqueue` and `markPublished` — a duplicate job on the
|
|
94
|
+
* next boot. The abandonment is not the harmless "exit slightly early" it was written as: it is
|
|
95
|
+
* every resource the process holds, left to the kernel.
|
|
96
|
+
*
|
|
97
|
+
* So `release` gets a budget of its own, floored, never a leftover. 5s, and the arithmetic is
|
|
98
|
+
* what makes it defensible rather than a feel: `DEFAULT_DEADLINE_MS` is 25s
|
|
99
|
+
* (`packages/core/src/lifecycle.ts`) and `docker/helm/templates/deployments.yaml` sets
|
|
100
|
+
* `terminationGracePeriodSeconds: 45`, so a drain that spends everything PLUS a release that
|
|
101
|
+
* spends everything is 30s — still inside the grace period, so the kubelet never SIGKILLs a
|
|
102
|
+
* process this floor kept alive. It stays a floor and not a clamp: an app that raised
|
|
103
|
+
* `deadlineMs` for a slow teardown keeps the bigger number.
|
|
104
|
+
*/
|
|
105
|
+
export const MIN_RELEASE_MS = 5_000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What `release` really gets. Non-finite is the floor rather than the input, for the reason
|
|
109
|
+
* `packages/core/src/lifecycle-bounds.test.ts` exists: `Math.max(n, NaN)` is `NaN` and
|
|
110
|
+
* `setTimeout(fn, NaN)` fires on the next tick, which is this whole defect a second time.
|
|
111
|
+
*/
|
|
112
|
+
export const releaseBudgetMs = (remainingMs: number): number =>
|
|
113
|
+
Number.isFinite(remainingMs) ? Math.max(MIN_RELEASE_MS, remainingMs) : MIN_RELEASE_MS;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `release()` raced against its own budget.
|
|
83
117
|
*
|
|
84
118
|
* A local race and not core's `settleWithin`, which is internal to `lifecycle-deadline.ts` and not
|
|
85
119
|
* on core's barrel. The semantics are deliberately the same, including the one that matters: a
|
|
86
120
|
* REJECTION still rejects — `dispatch` awaits the hold inside its own `try`, and an embedded
|
|
87
121
|
* database that would not close is a finding on the way out, never a clean exit over it.
|
|
88
|
-
*
|
|
89
|
-
* A budget already spent is `0`, and that abandons immediately by design: past `deadlineAt` the
|
|
90
|
-
* orchestrator is already counting down to SIGKILL, so the honest move is to say so and exit
|
|
91
|
-
* rather than to start a second grace period nobody granted.
|
|
92
122
|
*/
|
|
93
123
|
async function releaseWithin(
|
|
94
124
|
name: string,
|
|
95
125
|
release: () => Promise<void>,
|
|
96
126
|
budgetMs: number,
|
|
97
127
|
): Promise<void> {
|
|
98
|
-
|
|
128
|
+
// Screened by `releaseBudgetMs`, which is the one answer to "how long does a teardown get" —
|
|
129
|
+
// a second `Math.max` here would be a second, quieter one.
|
|
99
130
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
100
131
|
const abandoned = new Promise<'abandoned'>((resolve) => {
|
|
101
|
-
timer = setTimeout(() => resolve('abandoned'),
|
|
132
|
+
timer = setTimeout(() => resolve('abandoned'), budgetMs);
|
|
102
133
|
});
|
|
103
134
|
try {
|
|
104
135
|
const outcome = await Promise.race([release().then(() => 'released' as const), abandoned]);
|
|
105
136
|
if (outcome === 'released') return;
|
|
106
137
|
logger.warn('X_SHUTDOWN_TIMEOUT', {
|
|
107
138
|
code: 'X_SHUTDOWN_TIMEOUT',
|
|
108
|
-
|
|
139
|
+
// Rounded: `deadlineAt - monotonic()` is a float, and `4923.185900000001ms` in a shutdown
|
|
140
|
+
// log reads as a bug in the number rather than as the budget it is.
|
|
141
|
+
cause: `the "${name}" release was still running ${Math.round(budgetMs)}ms after the drain finished and has been ABANDONED — the process exits without it, so anything it held may not be closed`,
|
|
109
142
|
fix: 'raise the budget past the slowest teardown — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
110
143
|
});
|
|
111
144
|
} finally {
|
package/src/i18n-registration.ts
CHANGED
|
@@ -19,12 +19,17 @@ import {
|
|
|
19
19
|
registeredLocales,
|
|
20
20
|
} from '@ultimat3/i18n';
|
|
21
21
|
import { loadApp } from './app-load';
|
|
22
|
+
import type { DuplicateProbe } from './duplicate-packages';
|
|
23
|
+
import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
|
|
22
24
|
import { auditApp } from './i18n-audit';
|
|
23
25
|
import { I18N_INDEX_PATH } from './i18n-index';
|
|
24
26
|
import type { Finding } from './output';
|
|
25
27
|
import { findingFrom } from './output';
|
|
26
28
|
import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
27
29
|
|
|
30
|
+
/** The one package this check probes for a duplicate: its registry is the one it reads. */
|
|
31
|
+
const I18N_PKG = '@ultimat3/i18n';
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
34
|
* What this check needs of a boot. The seam is injected so a fixture can be exactly "the app
|
|
30
35
|
* loaded and registered nothing" — the shipped shape of the bug — without a temp directory that
|
|
@@ -43,6 +48,8 @@ export interface RegistrationInput {
|
|
|
43
48
|
readonly extraction: Extraction;
|
|
44
49
|
readonly ignoreUnused: readonly string[];
|
|
45
50
|
readonly load?: AppLoader;
|
|
51
|
+
/** The duplicate-install probe; `findDuplicateInstalls` is the production value. */
|
|
52
|
+
readonly duplicates?: DuplicateProbe;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export interface RegistrationReport {
|
|
@@ -74,17 +81,39 @@ function unresolvedUsedKeys(input: RegistrationInput, locale: Locale): readonly
|
|
|
74
81
|
return report.locales[0]?.missing ?? [];
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
/** What `X_PACKAGE_DUPLICATED`'s fix runs once the install is one copy again. */
|
|
85
|
+
const RECHECK = 'x i18n check --json';
|
|
86
|
+
|
|
77
87
|
export async function checkRegistration(input: RegistrationInput): Promise<RegistrationReport> {
|
|
78
88
|
// Importing the app's modules IS the registration, in this process exactly as in the server's.
|
|
79
89
|
const app = await (input.load ?? loadApp)(input.root);
|
|
80
90
|
|
|
91
|
+
// Asked BEFORE the registry is: two copies of `@ultimat3/i18n` in one app are two registries,
|
|
92
|
+
// and every gap below is then a symptom of the install, not of where `defineCatalogs()` sits.
|
|
93
|
+
// Reported even with no gap — the CLI may share the app's index module's copy while a page in
|
|
94
|
+
// another workspace reads the other, which renders `⟦key⟧` under a green gate.
|
|
95
|
+
const duplicate = (await (input.duplicates ?? findDuplicateInstalls)(input.root, [I18N_PKG]))[0];
|
|
81
96
|
const gaps = catalogRegistrationGaps(input.catalogs);
|
|
82
97
|
const index = await indexSource(input.root);
|
|
83
|
-
const findings: Finding[] =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
98
|
+
const findings: Finding[] =
|
|
99
|
+
duplicate === undefined ? [] : [duplicateFinding(input.root, duplicate, RECHECK)];
|
|
100
|
+
for (const gap of gaps) {
|
|
101
|
+
const finding = findingFrom(catalogUnregistered(gap));
|
|
102
|
+
findings.push(
|
|
103
|
+
duplicate === undefined
|
|
104
|
+
? { ...finding, ...unregisteredFix(gap.locale, index), at: catalogPath(gap.locale) }
|
|
105
|
+
: // The registry's own fix names a source edit — move the `defineCatalogs()` call — that an
|
|
106
|
+
// agent following it performs on a file that is already right (ai-maxxing, 2026-09-05).
|
|
107
|
+
// With a duplicate on disk the cause IS the install, so the finding says so and the fix
|
|
108
|
+
// is the one the duplicate carries.
|
|
109
|
+
{
|
|
110
|
+
...finding,
|
|
111
|
+
cause: `${duplicateCause(input.root, duplicate)}; ${gap.missing.length} of ${catalogPath(gap.locale)}'s ${gap.shipped} key(s) registered into the copy the CLI does not read`,
|
|
112
|
+
fix: findings[0]?.fix ?? finding.fix,
|
|
113
|
+
at: catalogPath(gap.locale),
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
88
117
|
let unregistered = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
|
|
89
118
|
let locales = gaps.length;
|
|
90
119
|
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export { deployCommand, planDeploy } from './cmd-deploy';
|
|
|
68
68
|
export type { DevServer, StartDevOptions } from './cmd-dev';
|
|
69
69
|
export { devCommand, startDev } from './cmd-dev';
|
|
70
70
|
export type { DoctorProbe } from './cmd-doctor';
|
|
71
|
-
export { doctorCommand,
|
|
71
|
+
export { doctorCommand, ENV_DEVELOPMENT, probeFor, runDoctor } from './cmd-doctor';
|
|
72
72
|
export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
|
|
73
73
|
export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
|
|
74
74
|
export type { GenerateOptions, Generator } from './cmd-generate';
|
|
@@ -361,6 +361,8 @@ export {
|
|
|
361
361
|
skipReasonFor,
|
|
362
362
|
writeStaticReport,
|
|
363
363
|
} from './static-report';
|
|
364
|
+
export type { StyleBundle, StyleChunk } from './style-bundle';
|
|
365
|
+
export { STYLE_BASE_PATH, styleBundle } from './style-bundle';
|
|
364
366
|
export type { TestCounts } from './test-counts';
|
|
365
367
|
export { countsOf } from './test-counts';
|
|
366
368
|
export type { TestFile } from './test-select';
|
package/src/island-bundle.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// The island chunk table: every `*.island.tsx` in the app compiled as its OWN bundle entry point,
|
|
2
|
-
//
|
|
2
|
+
// addressed by a hash of its SOURCE GRAPH (`graphHash` — `Bun.build`'s minified output is not
|
|
3
|
+
// byte-deterministic), plus the resolver that turns a page's `src` specifier into the URL its
|
|
3
4
|
// `data-x-entry` carries. One entry point per island is axiom 6 made mechanical — the page's graph
|
|
4
5
|
// never reaches an island, so a `site/` document stays at 0kb whatever the island imports.
|
|
5
6
|
|
|
6
7
|
// Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
|
|
7
8
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
8
9
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
9
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
10
|
+
import { frameworkVersion, renderThrowable } from '@ultimat3/core';
|
|
10
11
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
11
12
|
import { contentHash } from '@ultimat3/render/server';
|
|
12
13
|
import { IslandBuildFailedError } from './errors';
|
|
13
14
|
import { islandStylesPlugin } from './island-styles';
|
|
15
|
+
import { hasPathSegment } from './path-segments';
|
|
14
16
|
import { solidJsxPlugin } from './solid-loader';
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -32,7 +34,10 @@ export interface IslandChunk {
|
|
|
32
34
|
readonly file: string;
|
|
33
35
|
/** `islandModuleId` of the filename — the id the document, the budget and a finding all name. */
|
|
34
36
|
readonly moduleId: string;
|
|
35
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Immutable, source-addressed URL. What `data-x-entry` carries and what a route serves — stable
|
|
39
|
+
* for as long as the sources, the framework version and the Bun version are. See `graphHash`.
|
|
40
|
+
*/
|
|
36
41
|
readonly url: string;
|
|
37
42
|
/** The built JavaScript. Held in memory so `x dev` and the container serve without a disk hop. */
|
|
38
43
|
readonly code: string;
|
|
@@ -55,7 +60,7 @@ export interface IslandBundle {
|
|
|
55
60
|
export async function discoverIslands(root: string): Promise<readonly string[]> {
|
|
56
61
|
const files: string[] = [];
|
|
57
62
|
for await (const absolute of new Bun.Glob(ISLAND_GLOB).scan({ cwd: root, absolute: true })) {
|
|
58
|
-
if (absolute
|
|
63
|
+
if (hasPathSegment(absolute, 'node_modules')) continue;
|
|
59
64
|
files.push(relative(root, absolute).split(sep).join('/'));
|
|
60
65
|
}
|
|
61
66
|
return files.sort();
|
|
@@ -101,30 +106,138 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
101
106
|
// `x dev` serves the same chunk the container does, and bytes that depend on the ambient
|
|
102
107
|
// NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
|
|
103
108
|
define: { 'process.env.NODE_ENV': '"production"' },
|
|
109
|
+
// The fourth, and it is asked for its INPUT list rather than its output: `sourcesContent` is
|
|
110
|
+
// the whole module graph this chunk was built from, which is the only stable identity a
|
|
111
|
+
// chunk has. See `graphHash`. Measured on 1.4.0 against a 131 kB island: 277ms with it and
|
|
112
|
+
// 276ms without, so the map costs nothing worth naming.
|
|
113
|
+
sourcemap: 'external',
|
|
104
114
|
});
|
|
105
115
|
} catch (error) {
|
|
106
116
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
107
117
|
}
|
|
108
118
|
const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
|
|
109
|
-
|
|
119
|
+
const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
|
|
120
|
+
if (!built.success || output === undefined || map === undefined) {
|
|
110
121
|
throw new IslandBuildFailedError({
|
|
111
122
|
file,
|
|
112
123
|
logs: built.logs.map((log) => String(log)).join('; '),
|
|
113
124
|
});
|
|
114
125
|
}
|
|
115
|
-
const code = await output.text();
|
|
126
|
+
const code = stripDebugId(await output.text());
|
|
127
|
+
const hash = graphHash(file, await map.text());
|
|
116
128
|
const moduleId = islandModuleId(basename(file));
|
|
117
129
|
return {
|
|
118
130
|
file,
|
|
119
131
|
moduleId,
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
url: `${ISLAND_BASE_PATH}/${moduleId}-${hash}.js`,
|
|
133
|
+
// The FIRST bytes this process emitted for these inputs, so a URL served `immutable` answers
|
|
134
|
+
// one byte string for as long as the process lives. Without it `x dev` re-mints the chunk on
|
|
135
|
+
// every watcher tick and a browser holding the previous one under `max-age=31536000` has two
|
|
136
|
+
// different files at one address.
|
|
137
|
+
code: stableCode(file, hash, code),
|
|
124
138
|
bytes: new TextEncoder().encode(code).byteLength,
|
|
125
139
|
};
|
|
126
140
|
}
|
|
127
141
|
|
|
142
|
+
/**
|
|
143
|
+
* `sourcemap: 'external'` appends `//# debugId=<hex>` to the chunk. It is a pointer to a map this
|
|
144
|
+
* framework does not serve, so it is removed rather than shipped — and removing it makes the
|
|
145
|
+
* emitted bytes identical to what the same build produced before the map was asked for, which is
|
|
146
|
+
* what keeps `bytes` a budget number and not a build-flag artefact. `slice`, never a `replace` with
|
|
147
|
+
* an empty replacement — `bun run sql-literal-copies` refuses that shape anywhere but `db/sql.ts`.
|
|
148
|
+
*/
|
|
149
|
+
const DEBUG_ID_COMMENT = '\n//# debugId=';
|
|
150
|
+
|
|
151
|
+
function stripDebugId(code: string): string {
|
|
152
|
+
const at = code.lastIndexOf(DEBUG_ID_COMMENT);
|
|
153
|
+
return at === -1 ? code : code.slice(0, at);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The chunk's identity, computed from what went IN rather than from what came out.
|
|
158
|
+
*
|
|
159
|
+
* `Bun.build` is not byte-deterministic under `minify`. Measured on 1.4.0, one entry point, no
|
|
160
|
+
* source file touched: a 131,589-byte island alternated between two outputs of IDENTICAL length
|
|
161
|
+
* differing only in minified identifier names (`var ca=Object.defineProperty` against
|
|
162
|
+
* `var la=…`) — roughly one build in ten, which is a race in the renamer and not anything a caller
|
|
163
|
+
* can order. Hashing that output made the URL flap: ten distinct `session-console-*.js` names in
|
|
164
|
+
* ten minutes, so a service worker's precache manifest named a chunk that already 404ed and a
|
|
165
|
+
* browser's `immutable` cache never hit on a 131 kB download. Twelve consecutive builds hash
|
|
166
|
+
* identically here.
|
|
167
|
+
*
|
|
168
|
+
* `sourcesContent`, hashed per file and SORTED, so the identity is independent of the order the
|
|
169
|
+
* bundler happened to visit the graph in. The PATHS are deliberately not in it: they are absolute
|
|
170
|
+
* on the build machine and would make a chunk built in a container disagree with the same chunk
|
|
171
|
+
* built on a laptop for no difference a browser could observe. `file` is, so two islands with
|
|
172
|
+
* byte-identical sources under different names stay two chunks; the framework version and the Bun
|
|
173
|
+
* version are, because both decide the emitted bytes while no source file moves — an upgrade must
|
|
174
|
+
* mint a new URL rather than leave a stale chunk pinned in a browser for a year.
|
|
175
|
+
*
|
|
176
|
+
* What this gives up, stated plainly: the URL is source-addressed, not byte-addressed, so two
|
|
177
|
+
* processes building the same sources can serve two byte-strings at one URL. They are the same
|
|
178
|
+
* program under different local identifier names. That is the trade a nondeterministic bundler
|
|
179
|
+
* forces, and the alternative — `minify: { identifiers: false }`, which IS deterministic — was
|
|
180
|
+
* measured at 193,590 bytes against 131,649, +47% raw and +20% gzipped, on every island of every
|
|
181
|
+
* app. Delete this the day `Bun.build` is deterministic.
|
|
182
|
+
*/
|
|
183
|
+
function graphHash(file: string, map: string): string {
|
|
184
|
+
const parsed: unknown = JSON.parse(map);
|
|
185
|
+
const contents = sourcesContentOf(parsed);
|
|
186
|
+
if (contents === undefined) {
|
|
187
|
+
throw new IslandBuildFailedError({
|
|
188
|
+
file,
|
|
189
|
+
logs: 'the bundler emitted a source map with no sourcesContent, so the chunk has no stable identity',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const graph = contents.map((source) => contentHash(source)).sort();
|
|
193
|
+
return contentHash([file, frameworkVersion(), Bun.version, ...graph].join('\u0000'));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `sourcesContent`, read the way `aggregatedErrors` below reads `errors`: narrowed first,
|
|
198
|
+
* dereferenced inside a `try`, `undefined` for anything that is not a full list of strings. A
|
|
199
|
+
* partial list is refused rather than padded — a graph with holes in it hashes two different
|
|
200
|
+
* islands the same.
|
|
201
|
+
*/
|
|
202
|
+
function sourcesContentOf(value: unknown): readonly string[] | undefined {
|
|
203
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
204
|
+
try {
|
|
205
|
+
const held: unknown = (value as Record<string, unknown>)['sourcesContent'];
|
|
206
|
+
if (!Array.isArray(held) || held.length === 0) return undefined;
|
|
207
|
+
return held.every((one: unknown) => typeof one === 'string')
|
|
208
|
+
? (held as readonly string[])
|
|
209
|
+
: undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The code this process already emitted for these inputs, or the code it just built.
|
|
217
|
+
*
|
|
218
|
+
* Keyed by PATH and validated by the input hash, `transformIslandTsx`'s cache's shape and for its
|
|
219
|
+
* reason: one entry per island bounds the map by the island count, which is the only quantity that
|
|
220
|
+
* should bound it, and an entry whose hash no longer matches is replaced rather than served.
|
|
221
|
+
*/
|
|
222
|
+
const emitted = new Map<string, { readonly graph: string; readonly code: string }>();
|
|
223
|
+
|
|
224
|
+
/** Test seam: the table is process-global because the dev server it serves is too. */
|
|
225
|
+
export function clearIslandChunkCache(): void {
|
|
226
|
+
emitted.clear();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `graph`, never `hash`: `bun run secret-compare` reads the NAME of a comparison's operands, and a
|
|
231
|
+
* value called `hash` is a digest an attacker may be probing. This one is a build input's
|
|
232
|
+
* identity — the same reason `pr-threads.ts` calls a review state `wanted`.
|
|
233
|
+
*/
|
|
234
|
+
function stableCode(file: string, graph: string, code: string): string {
|
|
235
|
+
const hit = emitted.get(file);
|
|
236
|
+
if (hit !== undefined && hit.graph === graph) return hit.code;
|
|
237
|
+
emitted.set(file, { graph, code });
|
|
238
|
+
return code;
|
|
239
|
+
}
|
|
240
|
+
|
|
128
241
|
/**
|
|
129
242
|
* The bundler's own diagnostics, kept verbatim. An `AggregateError` holds one entry per unresolved
|
|
130
243
|
* import or syntax error, and flattening them is what puts the line number in the cause instead of
|
package/src/island-harness.ts
CHANGED
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
islandModuleId,
|
|
14
14
|
SURFACES,
|
|
15
15
|
} from '@ultimat3/render';
|
|
16
|
-
import { stylesFor } from '@ultimat3/render/server';
|
|
17
16
|
import type { IslandShotTarget, IslandState } from '@ultimat3/testing';
|
|
18
17
|
import { harnessScript } from './island-harness-script';
|
|
18
|
+
import { styleBundle } from './style-bundle';
|
|
19
19
|
|
|
20
20
|
/** Where the harness lives in `x dev`'s own namespace, so no app route can shadow it. */
|
|
21
21
|
export const ISLAND_HARNESS_PATH = '/_x/island';
|
|
@@ -43,8 +43,13 @@ export function surfaceOf(island: string): Surface | null {
|
|
|
43
43
|
* off because a picture taken mid-transition is a picture of a moment no user experiences; the
|
|
44
44
|
* caret is invisible because a focused input blinks and two otherwise identical runs then differ.
|
|
45
45
|
* Colours are semantic tokens, never literals — the app's own global layer defines them.
|
|
46
|
+
*
|
|
47
|
+
* Exported so `x dev` can hash it into `style-src`: it is emitted INLINE, so a policy that does
|
|
48
|
+
* not name it blocks the frame under an enforced CSP. It was never hashed at all until
|
|
49
|
+
* 2026-09-06 — invisible because `x dev` sends the policy report-only, which is exactly how the
|
|
50
|
+
* hydration runtime shipped blocked once already.
|
|
46
51
|
*/
|
|
47
|
-
const FRAME_STYLE = `
|
|
52
|
+
export const FRAME_STYLE = `
|
|
48
53
|
*,*::before,*::after{animation:none !important;transition:none !important;
|
|
49
54
|
scroll-behavior:auto !important;caret-color:transparent !important}
|
|
50
55
|
html{background:rgb(var(--color-bg) / 1)}
|
|
@@ -74,13 +79,15 @@ export function harnessPage(input: HarnessPageInput): string {
|
|
|
74
79
|
entry: input.entry,
|
|
75
80
|
props: input.state.props,
|
|
76
81
|
};
|
|
77
|
-
|
|
82
|
+
// The same content-hashed file every real document links, so the picture is taken against the
|
|
83
|
+
// bytes a visitor gets — and the harness stops carrying 157 kB of inline CSS of its own.
|
|
84
|
+
const href = styleBundle().hrefFor(surfaceOf(input.target.island));
|
|
78
85
|
return [
|
|
79
86
|
'<!doctype html>',
|
|
80
87
|
`<html lang="en" data-theme="${input.target.theme}">`,
|
|
81
88
|
'<head><meta charset="utf-8">',
|
|
82
89
|
`<title>${input.target.name} · ${input.target.state} · ${input.target.theme}</title>`,
|
|
83
|
-
|
|
90
|
+
href === undefined ? '' : `<link rel="stylesheet" href="${href}">`,
|
|
84
91
|
`<style>${FRAME_STYLE}</style>`,
|
|
85
92
|
// Before the body and before every module script, which is the only ordering in which the
|
|
86
93
|
// seal can catch a component's first request.
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@ultimat3/testing';
|
|
17
17
|
import { ISLAND_GLOB } from './island-bundle';
|
|
18
18
|
import { IslandStatesFileEmptyError } from './island-shot-errors';
|
|
19
|
+
import { hasPathSegment } from './path-segments';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* `.island.states.ts`, built from the two constants that own its halves and restated as neither:
|
|
@@ -37,7 +38,7 @@ export async function discoverIslandStates(root: string): Promise<readonly strin
|
|
|
37
38
|
const files: string[] = [];
|
|
38
39
|
const scan = new Bun.Glob(ISLAND_STATES_GLOB).scan({ cwd: root, absolute: true });
|
|
39
40
|
for await (const absolute of scan) {
|
|
40
|
-
if (absolute
|
|
41
|
+
if (hasPathSegment(absolute, 'node_modules')) continue;
|
|
41
42
|
files.push(relative(root, absolute).split(sep).join('/'));
|
|
42
43
|
}
|
|
43
44
|
return files.sort();
|
package/src/jobs-driver.ts
CHANGED
|
@@ -24,7 +24,10 @@ export async function withJobDriver(
|
|
|
24
24
|
const ambient = jobDriver();
|
|
25
25
|
if (ambient !== undefined) return fn(ambient);
|
|
26
26
|
const services = resolveServices(root, ctx.env);
|
|
27
|
-
|
|
27
|
+
// The command's own environment on both halves: `resolveServices` reads DATABASE_URL off it, so
|
|
28
|
+
// a queue that resolved its standby from `process.env` would talk to a pair the same call had
|
|
29
|
+
// just decided against.
|
|
30
|
+
const queue = await startQueue(services, undefined, ctx.env);
|
|
28
31
|
try {
|
|
29
32
|
return await fn(queue.jobs);
|
|
30
33
|
} finally {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -52,6 +52,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
52
52
|
'x verify --json # the finding names the fix line and the path it cites',
|
|
53
53
|
X_WORKSPACE_DEP_UNDECLARED:
|
|
54
54
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
55
|
+
X_PACKAGE_DUPLICATED:
|
|
56
|
+
'x i18n check --json # the finding names both copies and the package.json to pin',
|
|
55
57
|
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
56
58
|
// The four island-capture codes. Each one's real repair is an edit to the app's own states file
|
|
57
59
|
// or component, which no command can perform — so each names the command that REPRODUCES it with
|