@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.
Files changed (65) hide show
  1. package/CLAUDE.md +125 -8
  2. package/package.json +29 -29
  3. package/src/app-boundaries.ts +11 -2
  4. package/src/app-load.ts +5 -1
  5. package/src/app-openapi.ts +13 -5
  6. package/src/app-permissions.ts +0 -0
  7. package/src/browser-launcher.ts +53 -4
  8. package/src/budgets.ts +60 -7
  9. package/src/cmd-dev.ts +49 -39
  10. package/src/cmd-doctor.ts +61 -23
  11. package/src/cmd-generate.ts +5 -2
  12. package/src/cmd-i18n.ts +10 -3
  13. package/src/cmd-jobs.ts +56 -10
  14. package/src/cmd-shot.ts +3 -1
  15. package/src/cmd-test.ts +15 -10
  16. package/src/db-seed.ts +2 -1
  17. package/src/dev-queue.ts +16 -2
  18. package/src/dev-reload.ts +46 -0
  19. package/src/dev-render.ts +28 -7
  20. package/src/dev-roles.ts +9 -8
  21. package/src/dev-runtime.ts +4 -1
  22. package/src/dev-sync.ts +17 -3
  23. package/src/dev-watch-tree.ts +226 -0
  24. package/src/dev-watch.ts +75 -0
  25. package/src/doctor-offline.ts +122 -0
  26. package/src/duplicate-packages.ts +278 -0
  27. package/src/error-catalog.ts +4 -5
  28. package/src/error-codes.ts +6 -0
  29. package/src/fix-command.ts +40 -1
  30. package/src/fix-path.ts +10 -11
  31. package/src/flag-number.ts +15 -0
  32. package/src/generate-kinds.ts +54 -4
  33. package/src/generate-write.ts +25 -2
  34. package/src/gitignore.ts +145 -0
  35. package/src/hold.ts +50 -17
  36. package/src/i18n-registration.ts +34 -5
  37. package/src/index.ts +3 -1
  38. package/src/island-bundle.ts +123 -10
  39. package/src/island-harness.ts +11 -4
  40. package/src/island-states-load.ts +2 -1
  41. package/src/jobs-driver.ts +4 -1
  42. package/src/mcp-errors.ts +2 -0
  43. package/src/mcp-host.ts +21 -9
  44. package/src/parse.ts +17 -0
  45. package/src/path-segments.ts +14 -0
  46. package/src/prerender.ts +68 -16
  47. package/src/retry-memo.ts +37 -0
  48. package/src/serve.ts +17 -2
  49. package/src/shot-browser.ts +23 -4
  50. package/src/source-files.ts +3 -1
  51. package/src/static-report.ts +21 -1
  52. package/src/style-bundle.ts +124 -0
  53. package/src/style-csp.ts +14 -12
  54. package/src/style-routes.ts +56 -0
  55. package/src/sw-artifacts.ts +84 -12
  56. package/src/templates/admin-page.ts +49 -1
  57. package/src/templates/resource-form-island.ts +13 -3
  58. package/src/templates/scaffold-container.ts +12 -0
  59. package/src/templates/scaffold-repo.ts +13 -2
  60. package/src/test-passes.ts +79 -0
  61. package/src/test-shards.ts +110 -36
  62. package/src/verify-checks.ts +13 -7
  63. package/src/verify-step.ts +4 -4
  64. package/src/verify-tests.ts +33 -8
  65. package/src/web-binding.ts +22 -0
@@ -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 runs INSIDE the drain's own deadline, and that is not a detail. `drain()` ABANDONS a hook
37
- * that overruns `ShutdownReason.deadlineAt` — the process is meant to exit without it — and
38
- * `release` here re-enters the very same teardown one call later: `app.stop()` ->
39
- * `startRoles().stop()` -> `worker.stop()`, memoised in the package that owns it, so awaiting it
40
- * is awaiting the promise the drain just walked away from. Unbounded, that hangs forever and the
41
- * deadline buys nothing.
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 this budget IS the drain's budget
52
- // rather than a second one that happens to be the same length.
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()` raced against what is left of the drain's budget.
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
- const budget = Math.max(0, budgetMs);
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'), budget);
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
- cause: `the "${name}" release was still running ${budget}ms after the drain finished and has been ABANDONED — the process exits without it, so anything it held may not be closed`,
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 {
@@ -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[] = gaps.map((gap) => ({
84
- ...findingFrom(catalogUnregistered(gap)),
85
- ...unregisteredFix(gap.locale, index),
86
- at: catalogPath(gap.locale),
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, OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor';
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';
@@ -1,16 +1,18 @@
1
1
  // The island chunk table: every `*.island.tsx` in the app compiled as its OWN bundle entry point,
2
- // content-hashed, plus the resolver that turns a page's `src` specifier into the URL its
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
- /** Immutable, content-addressed URL. What `data-x-entry` carries and what a route serves. */
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.includes('node_modules')) continue;
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
- if (!built.success || output === undefined) {
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
- // Hashed with the framework's own `contentHash`, the function that already stamps an ETag and
121
- // a precache revision — one identity for a byte string, not a third.
122
- url: `${ISLAND_BASE_PATH}/${moduleId}-${contentHash(code)}.js`,
123
- code,
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
@@ -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
- const css = stylesFor(surfaceOf(input.target.island));
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
- css.length === 0 ? '' : `<style>${css}</style>`,
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.includes('node_modules')) continue;
41
+ if (hasPathSegment(absolute, 'node_modules')) continue;
41
42
  files.push(relative(root, absolute).split(sep).join('/'));
42
43
  }
43
44
  return files.sort();
@@ -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
- const queue = await startQueue(services);
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