@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,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
+ }
@@ -3,7 +3,7 @@
3
3
  // commands actually need — so without this, `x errors explain X_UNAUTHENTICATED` answered "not a
4
4
  // registered error code" for a code the framework throws on every unauthenticated request.
5
5
 
6
- import { listErrorCodes } from '@ultimat3/core';
6
+ import { listErrorCodes, stringField } from '@ultimat3/core';
7
7
  import type { Finding } from './output';
8
8
  import { findingFrom } from './output';
9
9
 
@@ -82,10 +82,9 @@ let cached: Promise<ErrorCatalog> | undefined;
82
82
  * the host gap. Anything else escaped the package's own module evaluation and is its defect.
83
83
  */
84
84
  const isUnresolved = (thrown: unknown): boolean =>
85
- typeof thrown === 'object' &&
86
- thrown !== null &&
87
- 'code' in thrown &&
88
- thrown.code === 'ERR_MODULE_NOT_FOUND';
85
+ // `stringField`, never `'code' in thrown && thrown.code`: see `cmd-i18n.ts`'s `isAlreadyExists`
86
+ // — an `in` guard narrows the type and reads the property off a foreign value all the same.
87
+ stringField(thrown, 'code') === 'ERR_MODULE_NOT_FOUND';
89
88
 
90
89
  /** The package's own error, named and located, so the report says what broke and what to run. */
91
90
  function initFailure(specifier: string, thrown: unknown): Finding {
@@ -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',
@@ -45,6 +45,18 @@ const CITATION = new RegExp(
45
45
  */
46
46
  const FLAG = /(?:^|\s)--(?:no-)?([a-z][a-z\d-]*)/g;
47
47
 
48
+ /**
49
+ * A bare `--`: the boundary past which the words belong to ANOTHER tool, not to `x`. Only `x test`
50
+ * declares one (`CommandSpec.passthrough`), and every other command refuses a non-empty tail — so
51
+ * this is read for two reasons at once: the tail's flags are not the command's, and a tail cited
52
+ * on a command that hands nothing on is a documented `X_CLI_BAD_FLAG`.
53
+ *
54
+ * Without it, `x test unit -- --coverage --bail` — the only spelling that reaches bun's own flags
55
+ * — read as `x test --coverage`, and the one line documenting the passthrough was a standing
56
+ * false finding on the rule written to keep documented invocations runnable.
57
+ */
58
+ const BARE_TAIL = /(?:^|\s)--(?=\s|$)/;
59
+
48
60
  /**
49
61
  * Where a citation's argument list ends. `;`, `|` and `&` start a second shell word, `#` starts a
50
62
  * comment, and a backtick or a quote closes the span the citation was written in — past any of
@@ -60,6 +72,11 @@ export interface FixCitation {
60
72
  readonly positional: string | undefined;
61
73
  /** Long flags written after it, in order, `--` and any `no-` stripped. */
62
74
  readonly flags: readonly string[];
75
+ /**
76
+ * The words after a bare `--`, when the citation has one. Absent otherwise, so a citation with
77
+ * no tail is the same object it has always been.
78
+ */
79
+ readonly tail?: readonly string[];
63
80
  }
64
81
 
65
82
  /**
@@ -82,11 +99,24 @@ export function fixCitations(fix: string): readonly FixCitation[] {
82
99
  const tail = fix.slice(start, next);
83
100
  const stop = ARGUMENT_END.exec(tail)?.index;
84
101
  const args = stop === undefined ? tail : tail.slice(0, stop);
102
+ // The tail is split off BEFORE the flags are read: `--coverage` after a bare `--` is bun's
103
+ // flag, and charging it to `x test` is the same error as charging a second citation's flags
104
+ // to the first one, which the slice above already exists to prevent.
105
+ const cut = BARE_TAIL.exec(args);
106
+ const head = cut === null ? args : args.slice(0, cut.index);
107
+ const handed =
108
+ cut === null
109
+ ? undefined
110
+ : args
111
+ .slice(cut.index + cut[0].length)
112
+ .split(/\s+/)
113
+ .filter((word) => word !== '');
85
114
  return {
86
115
  command: match[1] as string,
87
116
  sub: match[2],
88
117
  positional: match[3],
89
- flags: [...args.matchAll(FLAG)].map((flag) => flag[1] as string),
118
+ flags: [...head.matchAll(FLAG)].map((flag) => flag[1] as string),
119
+ ...(handed === undefined ? {} : { tail: handed }),
90
120
  };
91
121
  });
92
122
  }
@@ -222,6 +252,15 @@ export function citationFault(
222
252
  }
223
253
  }
224
254
  if (planned) return undefined;
255
+ // Judged before the flags, because a tail is why they are not the command's. A command that
256
+ // declares no `passthrough` refuses a non-empty `--` outright (`parse.ts`), so a page handing a
257
+ // reader one is handing them `X_CLI_BAD_FLAG` — the same class as an undeclared flag.
258
+ if (citation.tail !== undefined && citation.tail.length > 0 && spec.passthrough !== true) {
259
+ return {
260
+ subject: `x ${spec.name} --`,
261
+ reason: `and ${spec.name} hands nothing to another tool — the parser refuses the -- with X_CLI_BAD_FLAG rather than dropping ${citation.tail.join(' ')}`,
262
+ };
263
+ }
225
264
  const declared = declaredFlags(spec);
226
265
  const unknown = citation.flags.find((flag) => !declared.has(flag));
227
266
  if (unknown === undefined) return undefined;
package/src/fix-path.ts CHANGED
@@ -5,10 +5,11 @@
5
5
 
6
6
  // why: Bun exposes no synchronous existence primitive — `Bun.file(p).exists()` is async and answers
7
7
  // false for a DIRECTORY, and this rule has to judge both. Delete when Bun ships one.
8
- import { existsSync, readFileSync } from 'node:fs';
8
+ import { existsSync } from 'node:fs';
9
9
  // why: Bun exposes no path-join or dirname primitive. The same necessity `error-contract.ts`
10
10
  // already records for `join`.
11
11
  import { dirname, join } from 'node:path';
12
+ import { readIgnoreFile } from './gitignore';
12
13
 
13
14
  /**
14
15
  * The extensions a fix line may cite a file by — the SAME set `COMMAND_TOKENS`' file pattern is
@@ -77,22 +78,20 @@ function isJudgeable(token: string, root: string): boolean {
77
78
  /**
78
79
  * The directories the root `.gitignore` lists as ignored, as `dir/` prefixes. Only the plain
79
80
  * directory form is read (`.personal/`, `/tmp/`, `dist`): a negation, a glob or a nested pattern
80
- * is a rule about files the repo may still hold, and this exclusion errs towards judging.
81
+ * is a rule about files the repo may still hold, and this exclusion errs towards judging — which
82
+ * is why it keeps its own narrow policy over `parseGitignore`'s answer rather than asking
83
+ * `isGitIgnored`. What it may NOT keep is a second PARSER: `dev-watch.ts` needs full gitignore
84
+ * semantics, and two readers of one file are two answers to what an app committed.
81
85
  */
82
86
  const ignoredDirs = new Map<string, readonly string[]>();
83
87
 
84
88
  function ignoredDirectories(root: string): readonly string[] {
85
89
  const known = ignoredDirs.get(root);
86
90
  if (known !== undefined) return known;
87
- const file = join(root, '.gitignore');
88
- const lines = existsSync(file) ? readFileSync(file, 'utf8').split('\n') : [];
89
- const dirs = lines
90
- .map((line) => line.trim())
91
- .filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
92
- .filter((line) => !/[*?[\]]/.test(line))
93
- .map((line) => line.replace(/^\//, '').replace(/\/$/, ''))
94
- .filter((line) => line !== '' && !line.includes('/'))
95
- .map((dir) => `${dir}/`);
91
+ const dirs = readIgnoreFile(root)
92
+ .filter((pattern) => !pattern.negated)
93
+ .filter((pattern) => !/[*?[\]]/.test(pattern.glob) && !pattern.glob.includes('/'))
94
+ .map((pattern) => `${pattern.glob}/`);
96
95
  ignoredDirs.set(root, dirs);
97
96
  return dirs;
98
97
  }
@@ -65,3 +65,18 @@ export const PORT_RANGE = { min: 0, max: 65_535 } as const;
65
65
  */
66
66
  export const neighbouringPort = (port: number): number =>
67
67
  port < PORT_RANGE.max ? port + 1 : PORT_RANGE.max - 1;
68
+
69
+ /**
70
+ * The same suggestion for a caller that binds a PAIR. `x dev --port N` occupies N and N + 1, so
71
+ * `neighbouringPort` hands back the neighbour — which, when it is the neighbour that was taken, is
72
+ * the very socket the refusal is about: `x dev --port 3999` died on 4000 and its `fix:` said
73
+ * `x dev --port 4000`, and `x doctor` said it too, about the same pair (both pinned by a test
74
+ * named "its fix is a command that ends the failure"). Two above is the nearest base whose own
75
+ * pair touches neither.
76
+ *
77
+ * Downward at the top of the range, for `neighbouringPort`'s reason and one further: the answer's
78
+ * OWN neighbour has to be a port, or `syncPortFor` refuses the suggestion with `X_PORT_INVALID`.
79
+ * Only reachable above 65532, so the subtraction can never go below the range.
80
+ */
81
+ export const portPairAfter = (port: number): number =>
82
+ port + 2 < PORT_RANGE.max ? port + 2 : port - 2;
@@ -3,7 +3,12 @@
3
3
  // that held both had reached the 500-line ceiling, one generator short of failing its own gate.
4
4
 
5
5
  import { nearestName } from '@ultimat3/core';
6
- import { BadFlagError, MissingPositionalError, UnknownCommandError } from './errors';
6
+ import {
7
+ BadFlagError,
8
+ MissingPositionalError,
9
+ MissingSubcommandError,
10
+ UnknownCommandError,
11
+ } from './errors';
7
12
  import type { Surface } from './templates';
8
13
 
9
14
  export const GENERATORS = [
@@ -57,14 +62,26 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
57
62
  * pins for a command that resembles nothing, and the reason `nearestName` is never asked about an
58
63
  * ABSENT kind: the empty string is within the cutoff of `job`, so `x g` would "suggest" a
59
64
  * generator nobody typed.
65
+ *
66
+ * And a THIRD rule, for the word that is not there at all: no generator is a missing subcommand,
67
+ * never an unknown command — see the branch below.
60
68
  */
61
69
  export function readKind(raw: string | undefined): Generator {
62
70
  const kinds: readonly string[] = GENERATORS;
63
71
  if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
64
- const near = raw === undefined ? undefined : nearestName(raw, kinds);
72
+ // A MISSING generator is not an unknown command. `x g --json` answered `X_CLI_UNKNOWN_COMMAND:
73
+ // "x g" is not a command` — false, and it sends an agent hunting a typo it did not make: `g` is
74
+ // in `x help`, the parser reaches it, and `x g route foo` runs. The same argument `readName`
75
+ // makes below about the missing `<name>`, one word earlier. `MissingSubcommandError` because
76
+ // the generator is a closed vocabulary the caller did not choose from — its cause lists every
77
+ // one, which is the answer to "which of these did I leave out"; the near-miss reader below is
78
+ // deliberately never asked about an absent word, since the empty string is within `job`'s
79
+ // cutoff and would "suggest" a generator nobody typed.
80
+ if (raw === undefined) throw new MissingSubcommandError({ command: 'g', known: GENERATORS });
81
+ const near = nearestName(raw, kinds);
65
82
  const suggestion = GENERATORS.find((kind) => kind === near);
66
83
  throw new UnknownCommandError({
67
- path: `g ${raw ?? ''}`.trim(),
84
+ path: `g ${raw}`.trim(),
68
85
  known: GENERATORS,
69
86
  suggestion: suggestion === undefined ? 'help g' : `g ${suggestion} ${EXAMPLE_NAME[suggestion]}`,
70
87
  });
@@ -93,7 +110,40 @@ export function readName(raw: string | undefined, kind: Generator): string {
93
110
  throw new MissingPositionalError({
94
111
  command: `g ${kind}`,
95
112
  positional: 'name',
96
- example: `x g ${kind} ${EXAMPLE_NAME[kind]}`,
113
+ example: exampleFor(kind),
114
+ });
115
+ }
116
+
117
+ /**
118
+ * The one read of `EXAMPLE_NAME`, and it is guarded: `kind` is a validated union today, which is
119
+ * the argument every instance `scripts/proto-index.ts` reports had before it stopped being true —
120
+ * a `Record` object literal answers an `Object.prototype` member for a key nobody declared, and
121
+ * this string is pasted into a shell. Two callers, one read.
122
+ */
123
+ const exampleFor = (kind: Generator): string =>
124
+ `x g ${kind} ${Object.hasOwn(EXAMPLE_NAME, kind) ? EXAMPLE_NAME[kind] : '<name>'}`;
125
+
126
+ /**
127
+ * `resource:verb`, or nothing is written. `--permission` reaches the generated page in three
128
+ * places — the `permissions:` array, a `definePermissions()` call and a `PermissionRegistry`
129
+ * augmentation — and `@ultimat3/policy`'s `Permission` type is `${string}:${string}`, so
130
+ * `x g admin:page ops --permission ops` used to emit a page that does not compile. A quote or a
131
+ * space is refused for the same reason one step earlier: the value is spliced into a string
132
+ * literal in emitted source, and neither is a permission any app declares.
133
+ *
134
+ * It cannot ask whether the app DECLARES the permission — `x g` writes files against a root, it
135
+ * never loads the app, and an app that will not import is exactly when a generator is reached for.
136
+ * The generated page declaring it is what closes that half (`templates/admin-page.ts`).
137
+ */
138
+ const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*:[a-z0-9*][a-z0-9_.*-]*$/i;
139
+
140
+ export function readPermission(raw: string | undefined, kind: Generator): string | undefined {
141
+ if (raw === undefined || PERMISSION_SHAPE.test(raw)) return raw;
142
+ throw new BadFlagError({
143
+ flag: 'permission',
144
+ command: 'g',
145
+ reason: `expects a permission of the form <resource>:<verb>, got "${raw}"`,
146
+ fix: `${exampleFor(kind)} --permission ops:read`,
97
147
  });
98
148
  }
99
149
 
@@ -27,11 +27,34 @@ function parseJsonObject(text: string): Record<string, unknown> | undefined {
27
27
  : undefined;
28
28
  }
29
29
 
30
+ /**
31
+ * Every key of every level, sorted. A catalog is authored NESTED (`{ nav: { home: … } }`) and this
32
+ * sorted the top level only, so the keys INSIDE `app` kept the order their generators ran in —
33
+ * `x g route zebra` then `x g route alpha` wrote different bytes from the same two runs in the
34
+ * other order, which is the reordering diff the sort exists to prevent. Arrays keep their order:
35
+ * a list's order is its content.
36
+ *
37
+ * CODE UNIT, never `localeCompare`: it is `Intl`-backed, so the bytes a catalog is written with
38
+ * moved with the machine's locale and its ICU version — `x g route` on a `tr-TR` box and the same
39
+ * command in CI produced two orderings of one file, which is exactly the reordering diff this sort
40
+ * exists to prevent, one layer down. `@ultimat3/render` states the same rule as `byCodeUnit`; the
41
+ * comparison is inlined rather than imported because that one is package-internal and this is the
42
+ * whole of it.
43
+ */
44
+ function sortDeep(value: unknown): unknown {
45
+ if (Array.isArray(value)) return value.map(sortDeep);
46
+ if (typeof value !== 'object' || value === null) return value;
47
+ return Object.fromEntries(
48
+ Object.entries(value)
49
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
50
+ .map(([key, entry]) => [key, sortDeep(entry)]),
51
+ );
52
+ }
53
+
30
54
  /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
31
55
  * the keys a run actually changed, never a reordering. */
32
56
  function prettyJson(value: Record<string, unknown>): string {
33
- const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
34
- return `${JSON.stringify(sorted, null, 2)}\n`;
57
+ return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
35
58
  }
36
59
 
37
60
  /**