@ultimat3/cli 19.1.3 → 19.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 +18 -3
- package/package.json +29 -29
- 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 +43 -1
- package/src/cmd-dev.ts +27 -7
- package/src/cmd-shot.ts +3 -1
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-sync.ts +7 -1
- package/src/dev-watch.ts +53 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-codes.ts +6 -0
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +121 -9
- package/src/island-harness.ts +11 -4
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +3 -0
- package/src/prerender.ts +29 -3
- package/src/serve.ts +12 -1
- package/src/shot-browser.ts +23 -4
- 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 +13 -5
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-repo.ts +6 -0
- package/src/verify-checks.ts +7 -1
- package/src/verify-tests.ts +20 -7
- package/src/web-binding.ts +22 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Two installed copies of one registry-holding framework package, found before either registry is
|
|
2
|
+
// asked anything. `@ultimat3/i18n`, `@ultimat3/policy` and `@ultimat3/entity` each keep their
|
|
3
|
+
// registry at MODULE scope, so a second module instance is a second, EMPTY registry: an app whose
|
|
4
|
+
// `packages/i18n` workspace pinned `@ultimat3/i18n@19.0.0` while its root had 19.1.0 registered
|
|
5
|
+
// every catalog into the workspace's copy, the CLI read the root's, and `x i18n check` answered
|
|
6
|
+
// `X_CATALOG_UNREGISTERED` with a fix that said to move a `defineCatalogs()` call that was already
|
|
7
|
+
// exactly where the fix said to put it (ai-maxxing, 2026-09-05). The same split is the one
|
|
8
|
+
// `local-cli.ts` hands over for — a global `x` is a second copy of `@ultimat3/entity` — except this
|
|
9
|
+
// one lives inside the app's own `node_modules`, where no hand-over can reach it.
|
|
10
|
+
|
|
11
|
+
// why: Bun ships no symlink-resolving stat and no path API of its own. `realpathSync` is the whole
|
|
12
|
+
// decision — two symlinks to one directory are one module instance, and two store entries at one
|
|
13
|
+
// version are still two — and `dirname`/`join` walk a resolved entry up to the package that owns it.
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
|
+
// why: Bun exposes no path API — `dirname` walks a resolved entry up to the package that owns it,
|
|
16
|
+
// `join` reaches its manifest, and `relative` is what turns an absolute hit into the repo-relative
|
|
17
|
+
// path a finding names.
|
|
18
|
+
import { dirname, join, relative } from 'node:path';
|
|
19
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
20
|
+
import type { Finding } from './output';
|
|
21
|
+
import { readWorkspaceGraph } from './workspace-graph';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The packages whose registry is a module-scope table, and what that table is called in a finding.
|
|
25
|
+
* A second copy of any other `@ultimat3/*` package is a mixed-version install the CHANGELOG already
|
|
26
|
+
* warns against; a second copy of one of these is an app that registers into a registry nothing
|
|
27
|
+
* reads.
|
|
28
|
+
*/
|
|
29
|
+
export const REGISTRY_PACKAGES: Readonly<Record<string, string>> = {
|
|
30
|
+
'@ultimat3/i18n': 'catalog registry',
|
|
31
|
+
'@ultimat3/policy': 'permission registry',
|
|
32
|
+
'@ultimat3/entity': 'entity registry',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** One resolution of one package from one directory. `dir` is the package's REAL directory. */
|
|
36
|
+
export interface InstalledCopy {
|
|
37
|
+
readonly pkg: string;
|
|
38
|
+
/** Where the resolution started, app-root-relative: `.`, a workspace dir, or `CLI_ORIGIN`. */
|
|
39
|
+
readonly from: string;
|
|
40
|
+
readonly dir: string;
|
|
41
|
+
readonly version: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `from` label of the copy the running CLI itself imports. */
|
|
45
|
+
export const CLI_ORIGIN = 'the x CLI';
|
|
46
|
+
|
|
47
|
+
/** One distinct copy of a duplicated package, and every origin that resolves to it. */
|
|
48
|
+
export interface DuplicateCopy {
|
|
49
|
+
readonly dir: string;
|
|
50
|
+
readonly version: string;
|
|
51
|
+
readonly from: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DuplicateInstall {
|
|
55
|
+
readonly pkg: string;
|
|
56
|
+
/** Newest version first; ties in `dir` order, so a report is stable across runs. */
|
|
57
|
+
readonly copies: readonly DuplicateCopy[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Semver order, through Bun's own comparator so `19.1.0-beta.1` sorts BELOW `19.1.0` and
|
|
62
|
+
* `19.10.0` above `19.9.0`; a string that is not a version falls back to its text, because the
|
|
63
|
+
* report still has to be stable over whatever a hand-edited manifest carries.
|
|
64
|
+
*/
|
|
65
|
+
function compareVersions(left: string, right: string): number {
|
|
66
|
+
try {
|
|
67
|
+
return Bun.semver.order(left, right);
|
|
68
|
+
} catch {
|
|
69
|
+
return left.localeCompare(right);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The pure half: every package that resolves to more than one real directory. Keyed by REALPATH,
|
|
75
|
+
* never by version — two workspace symlinks to one checkout are one instance whatever the labels
|
|
76
|
+
* say, and two store entries both stamped `19.1.0` are two instances all the same, because a
|
|
77
|
+
* module registry is per module instance and not per version string.
|
|
78
|
+
*/
|
|
79
|
+
export function duplicateInstalls(copies: readonly InstalledCopy[]): readonly DuplicateInstall[] {
|
|
80
|
+
const byPackage = new Map<string, Map<string, { version: string; from: string[] }>>();
|
|
81
|
+
for (const copy of copies) {
|
|
82
|
+
const dirs = byPackage.get(copy.pkg) ?? new Map<string, { version: string; from: string[] }>();
|
|
83
|
+
const entry = dirs.get(copy.dir) ?? { version: copy.version, from: [] };
|
|
84
|
+
if (!entry.from.includes(copy.from)) entry.from.push(copy.from);
|
|
85
|
+
dirs.set(copy.dir, entry);
|
|
86
|
+
byPackage.set(copy.pkg, dirs);
|
|
87
|
+
}
|
|
88
|
+
const duplicates: DuplicateInstall[] = [];
|
|
89
|
+
for (const [pkg, dirs] of byPackage) {
|
|
90
|
+
if (dirs.size < 2) continue;
|
|
91
|
+
const sorted = [...dirs.entries()]
|
|
92
|
+
.map(([dir, entry]) => ({ dir, version: entry.version, from: [...entry.from].sort() }))
|
|
93
|
+
.sort(
|
|
94
|
+
(left, right) =>
|
|
95
|
+
compareVersions(right.version, left.version) || left.dir.localeCompare(right.dir),
|
|
96
|
+
);
|
|
97
|
+
duplicates.push({ pkg, copies: sorted });
|
|
98
|
+
}
|
|
99
|
+
return duplicates.sort((left, right) => left.pkg.localeCompare(right.pkg));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The filesystem this reader touches, injectable so a fixture needs no install. */
|
|
103
|
+
export interface DuplicateIo {
|
|
104
|
+
/** The resolved entry file, or throws — `Bun.resolveSync`'s contract. */
|
|
105
|
+
resolve(specifier: string, from: string): string;
|
|
106
|
+
realpath(path: string): string;
|
|
107
|
+
exists(path: string): boolean;
|
|
108
|
+
readText(path: string): string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const nodeIo: DuplicateIo = {
|
|
112
|
+
resolve: (specifier, from) => Bun.resolveSync(specifier, from),
|
|
113
|
+
realpath: (path) => realpathSync(path),
|
|
114
|
+
exists: (path) => existsSync(path),
|
|
115
|
+
readText: (path) => readFileSync(path, 'utf8'),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deep enough for `src/index.ts` and for any entry an `exports` map could point at, shallow enough
|
|
120
|
+
* that a resolver answering something unexpected stops rather than walking to `/` — the same bound
|
|
121
|
+
* `framework-scope.ts` walks under.
|
|
122
|
+
*/
|
|
123
|
+
const MAX_DEPTH = 6;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The real directory owning `entry`, judged by the `package.json` that NAMES `pkg`. The entry alone
|
|
127
|
+
* is not enough: a workspace checkout resolves to `packages/i18n/src/index.ts` and a published
|
|
128
|
+
* install to `…/dist/index.js`, and the first `package.json` upward from either is the one that
|
|
129
|
+
* says which package it is — a stray manifest in a `src/` would otherwise be reported as a copy.
|
|
130
|
+
*/
|
|
131
|
+
function packageDirOf(
|
|
132
|
+
pkg: string,
|
|
133
|
+
entry: string,
|
|
134
|
+
io: DuplicateIo,
|
|
135
|
+
): { dir: string; version: string } | undefined {
|
|
136
|
+
let dir = dirname(entry);
|
|
137
|
+
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
138
|
+
const manifest = join(dir, 'package.json');
|
|
139
|
+
if (io.exists(manifest)) {
|
|
140
|
+
let parsed: unknown;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(io.readText(manifest));
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
147
|
+
const fields = parsed as Record<string, unknown>;
|
|
148
|
+
if (fields['name'] !== pkg) return undefined;
|
|
149
|
+
const version = fields['version'];
|
|
150
|
+
return { dir: io.realpath(dir), version: typeof version === 'string' ? version : '0.0.0' };
|
|
151
|
+
}
|
|
152
|
+
const parent = dirname(dir);
|
|
153
|
+
if (parent === dir) return undefined;
|
|
154
|
+
dir = parent;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One resolution per (package, origin), for every origin that IMPORTS the package in a real boot:
|
|
161
|
+
* the app root, every workspace the root manifest claims, and this CLI's own directory — which is
|
|
162
|
+
* what `x i18n check` reads its registry through. A directory that cannot resolve the package
|
|
163
|
+
* contributes nothing: a workspace with no dependency on `@ultimat3/policy` is not a copy of it.
|
|
164
|
+
*
|
|
165
|
+
* `cliDir` is injectable so a test can stand the CLI somewhere with no framework under it;
|
|
166
|
+
* production passes nothing, and the only defensible base is this module's own directory.
|
|
167
|
+
*/
|
|
168
|
+
export async function installedCopies(
|
|
169
|
+
root: string,
|
|
170
|
+
packages: readonly string[],
|
|
171
|
+
io: DuplicateIo = nodeIo,
|
|
172
|
+
cliDir: string = import.meta.dir,
|
|
173
|
+
): Promise<readonly InstalledCopy[]> {
|
|
174
|
+
const origins: { from: string; dir: string }[] = [{ from: '.', dir: root }];
|
|
175
|
+
for (const node of await readWorkspaceGraph(root)) {
|
|
176
|
+
origins.push({ from: node.dir, dir: join(root, node.dir) });
|
|
177
|
+
}
|
|
178
|
+
origins.push({ from: CLI_ORIGIN, dir: cliDir });
|
|
179
|
+
|
|
180
|
+
const copies: InstalledCopy[] = [];
|
|
181
|
+
for (const pkg of packages) {
|
|
182
|
+
for (const origin of origins) {
|
|
183
|
+
let entry: string;
|
|
184
|
+
try {
|
|
185
|
+
entry = io.resolve(pkg, origin.dir);
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const owner = packageDirOf(pkg, entry, io);
|
|
190
|
+
if (owner === undefined) continue;
|
|
191
|
+
copies.push({ pkg, from: origin.from, dir: owner.dir, version: owner.version });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return copies;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The two steps composed — what `x i18n check` and the gate's `policy` step call. */
|
|
198
|
+
export async function findDuplicateInstalls(
|
|
199
|
+
root: string,
|
|
200
|
+
packages: readonly string[],
|
|
201
|
+
): Promise<readonly DuplicateInstall[]> {
|
|
202
|
+
return duplicateInstalls(await installedCopies(root, packages));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** What a caller injects in place of `findDuplicateInstalls`, so a fixture needs no install. */
|
|
206
|
+
export type DuplicateProbe = (
|
|
207
|
+
root: string,
|
|
208
|
+
packages: readonly string[],
|
|
209
|
+
) => Promise<readonly DuplicateInstall[]>;
|
|
210
|
+
|
|
211
|
+
/** `<dir>@<version> (resolved from ., packages/i18n)` — one clause per copy. */
|
|
212
|
+
const renderCopy = (root: string, copy: DuplicateCopy): string => {
|
|
213
|
+
const shown = relative(root, copy.dir).replaceAll('\\', '/');
|
|
214
|
+
const dir = shown === '' || shown.startsWith('..') ? copy.dir : shown;
|
|
215
|
+
return `${dir}@${copy.version} (resolved from ${copy.from.join(', ')})`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** The manifest an origin pins its dependencies in — the file the fix names an edit to. */
|
|
219
|
+
const manifestOf = (from: string): string | undefined =>
|
|
220
|
+
from === CLI_ORIGIN ? undefined : from === '.' ? 'package.json' : `${from}/package.json`;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The one sentence both findings share: which copies, where each is read from, and why that is
|
|
224
|
+
* an empty registry rather than a version skew. `root` is what turns a store path into the
|
|
225
|
+
* app-relative one an agent can open.
|
|
226
|
+
*/
|
|
227
|
+
export function duplicateCause(root: string, duplicate: DuplicateInstall): string {
|
|
228
|
+
const registry = REGISTRY_PACKAGES[duplicate.pkg] ?? 'registry';
|
|
229
|
+
const copies = duplicate.copies.map((copy) => renderCopy(root, copy)).join(' and ');
|
|
230
|
+
return (
|
|
231
|
+
`${duplicate.copies.length} copies of ${duplicate.pkg} are installed: ${copies} — each is ` +
|
|
232
|
+
`its own module instance with its own ${registry}, so what the app registers into one, the ` +
|
|
233
|
+
'other never sees'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The command that leaves ONE copy, then the command that re-checks. Two shapes, because the two
|
|
239
|
+
* causes repair differently: copies at different versions are a workspace pinning an older
|
|
240
|
+
* range, and the fix names that manifest and the version to pin; copies at ONE version are two
|
|
241
|
+
* resolutions of one range — a stale nested `node_modules`, or two peer sets in the store — and
|
|
242
|
+
* the only edit that can collapse them is the install itself.
|
|
243
|
+
*/
|
|
244
|
+
export function duplicateFix(duplicate: DuplicateInstall, recheck: string): string {
|
|
245
|
+
const newest = duplicate.copies[0];
|
|
246
|
+
const older = duplicate.copies.filter((copy) => copy.version !== newest?.version);
|
|
247
|
+
if (newest !== undefined && older.length > 0) {
|
|
248
|
+
const manifests = [
|
|
249
|
+
...new Set(older.flatMap((copy) => copy.from.map(manifestOf)).filter(Boolean)),
|
|
250
|
+
];
|
|
251
|
+
const where = manifests.length === 0 ? 'every package.json that pins it' : manifests.join(', ');
|
|
252
|
+
return `set "${duplicate.pkg}": "${newest.version}" in ${where}, then: bun install && ${recheck}`;
|
|
253
|
+
}
|
|
254
|
+
return `bun install --force # one resolution of ${duplicate.pkg} for every workspace; then: ${recheck}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The finding both steps report. Its own code rather than the registry's own (`X_CATALOG_UNREGISTERED`,
|
|
259
|
+
* `X_PERMISSION_UNKNOWN`): those name a symptom that has a source-level repair, and this names an
|
|
260
|
+
* install with no source-level repair at all — an agent handed the registry's fix edits a file that
|
|
261
|
+
* is already right, re-runs, and is red again.
|
|
262
|
+
*/
|
|
263
|
+
export function duplicateFinding(
|
|
264
|
+
root: string,
|
|
265
|
+
duplicate: DuplicateInstall,
|
|
266
|
+
recheck: string,
|
|
267
|
+
): Finding {
|
|
268
|
+
const at = duplicate.copies
|
|
269
|
+
.flatMap((copy) => copy.from.map(manifestOf))
|
|
270
|
+
.find((manifest): manifest is string => manifest !== undefined);
|
|
271
|
+
return {
|
|
272
|
+
code: 'X_PACKAGE_DUPLICATED',
|
|
273
|
+
cause: duplicateCause(root, duplicate),
|
|
274
|
+
fix: duplicateFix(duplicate, recheck),
|
|
275
|
+
docs: ERROR_DOCS_URL,
|
|
276
|
+
...(at === undefined ? {} : { at }),
|
|
277
|
+
};
|
|
278
|
+
}
|
package/src/error-codes.ts
CHANGED
|
@@ -148,6 +148,11 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
148
148
|
'X_SECRETS_EDITOR_MISSING',
|
|
149
149
|
'X_SECRETS_EDIT_FAILED',
|
|
150
150
|
'X_WORKSPACE_DEP_UNDECLARED',
|
|
151
|
+
// Two module instances of a registry-holding package inside ONE app's `node_modules`. Its own
|
|
152
|
+
// code and not the registry's (`X_CATALOG_UNREGISTERED`, `X_PERMISSION_UNKNOWN`): those carry a
|
|
153
|
+
// source-level fix, and an agent following it here edits a file that is already right — the
|
|
154
|
+
// repair is an install, and only `duplicate-packages.ts` can see that.
|
|
155
|
+
'X_PACKAGE_DUPLICATED',
|
|
151
156
|
'X_SHOT_BROWSER_MISSING',
|
|
152
157
|
// `x shot --island` — one code per way a component's named state fails to become a picture.
|
|
153
158
|
// The last of the four is the one that gates: it is checked against the expansion computed
|
|
@@ -290,6 +295,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
290
295
|
X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
|
|
291
296
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
292
297
|
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
298
|
+
X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
|
|
293
299
|
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
294
300
|
X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
|
|
295
301
|
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
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
|
@@ -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,12 +1,13 @@
|
|
|
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';
|
|
@@ -32,7 +33,10 @@ export interface IslandChunk {
|
|
|
32
33
|
readonly file: string;
|
|
33
34
|
/** `islandModuleId` of the filename — the id the document, the budget and a finding all name. */
|
|
34
35
|
readonly moduleId: string;
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Immutable, source-addressed URL. What `data-x-entry` carries and what a route serves — stable
|
|
38
|
+
* for as long as the sources, the framework version and the Bun version are. See `graphHash`.
|
|
39
|
+
*/
|
|
36
40
|
readonly url: string;
|
|
37
41
|
/** The built JavaScript. Held in memory so `x dev` and the container serve without a disk hop. */
|
|
38
42
|
readonly code: string;
|
|
@@ -101,30 +105,138 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
101
105
|
// `x dev` serves the same chunk the container does, and bytes that depend on the ambient
|
|
102
106
|
// NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
|
|
103
107
|
define: { 'process.env.NODE_ENV': '"production"' },
|
|
108
|
+
// The fourth, and it is asked for its INPUT list rather than its output: `sourcesContent` is
|
|
109
|
+
// the whole module graph this chunk was built from, which is the only stable identity a
|
|
110
|
+
// chunk has. See `graphHash`. Measured on 1.4.0 against a 131 kB island: 277ms with it and
|
|
111
|
+
// 276ms without, so the map costs nothing worth naming.
|
|
112
|
+
sourcemap: 'external',
|
|
104
113
|
});
|
|
105
114
|
} catch (error) {
|
|
106
115
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
107
116
|
}
|
|
108
117
|
const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
|
|
109
|
-
|
|
118
|
+
const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
|
|
119
|
+
if (!built.success || output === undefined || map === undefined) {
|
|
110
120
|
throw new IslandBuildFailedError({
|
|
111
121
|
file,
|
|
112
122
|
logs: built.logs.map((log) => String(log)).join('; '),
|
|
113
123
|
});
|
|
114
124
|
}
|
|
115
|
-
const code = await output.text();
|
|
125
|
+
const code = stripDebugId(await output.text());
|
|
126
|
+
const hash = graphHash(file, await map.text());
|
|
116
127
|
const moduleId = islandModuleId(basename(file));
|
|
117
128
|
return {
|
|
118
129
|
file,
|
|
119
130
|
moduleId,
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
url: `${ISLAND_BASE_PATH}/${moduleId}-${hash}.js`,
|
|
132
|
+
// The FIRST bytes this process emitted for these inputs, so a URL served `immutable` answers
|
|
133
|
+
// one byte string for as long as the process lives. Without it `x dev` re-mints the chunk on
|
|
134
|
+
// every watcher tick and a browser holding the previous one under `max-age=31536000` has two
|
|
135
|
+
// different files at one address.
|
|
136
|
+
code: stableCode(file, hash, code),
|
|
124
137
|
bytes: new TextEncoder().encode(code).byteLength,
|
|
125
138
|
};
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
/**
|
|
142
|
+
* `sourcemap: 'external'` appends `//# debugId=<hex>` to the chunk. It is a pointer to a map this
|
|
143
|
+
* framework does not serve, so it is removed rather than shipped — and removing it makes the
|
|
144
|
+
* emitted bytes identical to what the same build produced before the map was asked for, which is
|
|
145
|
+
* what keeps `bytes` a budget number and not a build-flag artefact. `slice`, never a `replace` with
|
|
146
|
+
* an empty replacement — `bun run sql-literal-copies` refuses that shape anywhere but `db/sql.ts`.
|
|
147
|
+
*/
|
|
148
|
+
const DEBUG_ID_COMMENT = '\n//# debugId=';
|
|
149
|
+
|
|
150
|
+
function stripDebugId(code: string): string {
|
|
151
|
+
const at = code.lastIndexOf(DEBUG_ID_COMMENT);
|
|
152
|
+
return at === -1 ? code : code.slice(0, at);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The chunk's identity, computed from what went IN rather than from what came out.
|
|
157
|
+
*
|
|
158
|
+
* `Bun.build` is not byte-deterministic under `minify`. Measured on 1.4.0, one entry point, no
|
|
159
|
+
* source file touched: a 131,589-byte island alternated between two outputs of IDENTICAL length
|
|
160
|
+
* differing only in minified identifier names (`var ca=Object.defineProperty` against
|
|
161
|
+
* `var la=…`) — roughly one build in ten, which is a race in the renamer and not anything a caller
|
|
162
|
+
* can order. Hashing that output made the URL flap: ten distinct `session-console-*.js` names in
|
|
163
|
+
* ten minutes, so a service worker's precache manifest named a chunk that already 404ed and a
|
|
164
|
+
* browser's `immutable` cache never hit on a 131 kB download. Twelve consecutive builds hash
|
|
165
|
+
* identically here.
|
|
166
|
+
*
|
|
167
|
+
* `sourcesContent`, hashed per file and SORTED, so the identity is independent of the order the
|
|
168
|
+
* bundler happened to visit the graph in. The PATHS are deliberately not in it: they are absolute
|
|
169
|
+
* on the build machine and would make a chunk built in a container disagree with the same chunk
|
|
170
|
+
* built on a laptop for no difference a browser could observe. `file` is, so two islands with
|
|
171
|
+
* byte-identical sources under different names stay two chunks; the framework version and the Bun
|
|
172
|
+
* version are, because both decide the emitted bytes while no source file moves — an upgrade must
|
|
173
|
+
* mint a new URL rather than leave a stale chunk pinned in a browser for a year.
|
|
174
|
+
*
|
|
175
|
+
* What this gives up, stated plainly: the URL is source-addressed, not byte-addressed, so two
|
|
176
|
+
* processes building the same sources can serve two byte-strings at one URL. They are the same
|
|
177
|
+
* program under different local identifier names. That is the trade a nondeterministic bundler
|
|
178
|
+
* forces, and the alternative — `minify: { identifiers: false }`, which IS deterministic — was
|
|
179
|
+
* measured at 193,590 bytes against 131,649, +47% raw and +20% gzipped, on every island of every
|
|
180
|
+
* app. Delete this the day `Bun.build` is deterministic.
|
|
181
|
+
*/
|
|
182
|
+
function graphHash(file: string, map: string): string {
|
|
183
|
+
const parsed: unknown = JSON.parse(map);
|
|
184
|
+
const contents = sourcesContentOf(parsed);
|
|
185
|
+
if (contents === undefined) {
|
|
186
|
+
throw new IslandBuildFailedError({
|
|
187
|
+
file,
|
|
188
|
+
logs: 'the bundler emitted a source map with no sourcesContent, so the chunk has no stable identity',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const graph = contents.map((source) => contentHash(source)).sort();
|
|
192
|
+
return contentHash([file, frameworkVersion(), Bun.version, ...graph].join('\u0000'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `sourcesContent`, read the way `aggregatedErrors` below reads `errors`: narrowed first,
|
|
197
|
+
* dereferenced inside a `try`, `undefined` for anything that is not a full list of strings. A
|
|
198
|
+
* partial list is refused rather than padded — a graph with holes in it hashes two different
|
|
199
|
+
* islands the same.
|
|
200
|
+
*/
|
|
201
|
+
function sourcesContentOf(value: unknown): readonly string[] | undefined {
|
|
202
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
203
|
+
try {
|
|
204
|
+
const held: unknown = (value as Record<string, unknown>)['sourcesContent'];
|
|
205
|
+
if (!Array.isArray(held) || held.length === 0) return undefined;
|
|
206
|
+
return held.every((one: unknown) => typeof one === 'string')
|
|
207
|
+
? (held as readonly string[])
|
|
208
|
+
: undefined;
|
|
209
|
+
} catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The code this process already emitted for these inputs, or the code it just built.
|
|
216
|
+
*
|
|
217
|
+
* Keyed by PATH and validated by the input hash, `transformIslandTsx`'s cache's shape and for its
|
|
218
|
+
* reason: one entry per island bounds the map by the island count, which is the only quantity that
|
|
219
|
+
* should bound it, and an entry whose hash no longer matches is replaced rather than served.
|
|
220
|
+
*/
|
|
221
|
+
const emitted = new Map<string, { readonly graph: string; readonly code: string }>();
|
|
222
|
+
|
|
223
|
+
/** Test seam: the table is process-global because the dev server it serves is too. */
|
|
224
|
+
export function clearIslandChunkCache(): void {
|
|
225
|
+
emitted.clear();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `graph`, never `hash`: `bun run secret-compare` reads the NAME of a comparison's operands, and a
|
|
230
|
+
* value called `hash` is a digest an attacker may be probing. This one is a build input's
|
|
231
|
+
* identity — the same reason `pr-threads.ts` calls a review state `wanted`.
|
|
232
|
+
*/
|
|
233
|
+
function stableCode(file: string, graph: string, code: string): string {
|
|
234
|
+
const hit = emitted.get(file);
|
|
235
|
+
if (hit !== undefined && hit.graph === graph) return hit.code;
|
|
236
|
+
emitted.set(file, { graph, code });
|
|
237
|
+
return code;
|
|
238
|
+
}
|
|
239
|
+
|
|
128
240
|
/**
|
|
129
241
|
* The bundler's own diagnostics, kept verbatim. An `AggregateError` holds one entry per unresolved
|
|
130
242
|
* 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.
|
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
|
package/src/mcp-host.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// the gate. The description half is the framework's own `frameworkIntrospection`, so nothing here
|
|
4
4
|
// is a second catalog of routes, entities, actions, queries or jobs.
|
|
5
5
|
|
|
6
|
+
// why: Bun exposes no path API. Every use here builds a path this host then hands to `Bun.file`
|
|
7
|
+
// or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
|
|
8
|
+
// manifest — and string concatenation would answer a different path on a Windows checkout.
|
|
6
9
|
import { join } from 'node:path';
|
|
7
10
|
import { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
8
11
|
import type { DbClient } from '@ultimat3/db';
|