@ultimat3/cli 19.2.0 → 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 (47) hide show
  1. package/CLAUDE.md +108 -6
  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/budgets.ts +17 -6
  6. package/src/cmd-dev.ts +29 -39
  7. package/src/cmd-doctor.ts +61 -23
  8. package/src/cmd-generate.ts +5 -2
  9. package/src/cmd-i18n.ts +10 -3
  10. package/src/cmd-jobs.ts +56 -10
  11. package/src/cmd-test.ts +15 -10
  12. package/src/db-seed.ts +2 -1
  13. package/src/dev-queue.ts +16 -2
  14. package/src/dev-reload.ts +46 -0
  15. package/src/dev-runtime.ts +4 -1
  16. package/src/dev-sync.ts +11 -3
  17. package/src/dev-watch-tree.ts +226 -0
  18. package/src/dev-watch.ts +59 -37
  19. package/src/doctor-offline.ts +122 -0
  20. package/src/error-catalog.ts +4 -5
  21. package/src/fix-command.ts +40 -1
  22. package/src/fix-path.ts +10 -11
  23. package/src/flag-number.ts +15 -0
  24. package/src/generate-kinds.ts +54 -4
  25. package/src/generate-write.ts +25 -2
  26. package/src/gitignore.ts +145 -0
  27. package/src/hold.ts +50 -17
  28. package/src/index.ts +1 -1
  29. package/src/island-bundle.ts +2 -1
  30. package/src/island-states-load.ts +2 -1
  31. package/src/jobs-driver.ts +4 -1
  32. package/src/mcp-host.ts +18 -9
  33. package/src/parse.ts +17 -0
  34. package/src/path-segments.ts +14 -0
  35. package/src/prerender.ts +46 -20
  36. package/src/retry-memo.ts +37 -0
  37. package/src/serve.ts +5 -1
  38. package/src/source-files.ts +3 -1
  39. package/src/sw-artifacts.ts +71 -7
  40. package/src/templates/admin-page.ts +49 -1
  41. package/src/templates/scaffold-container.ts +12 -0
  42. package/src/templates/scaffold-repo.ts +7 -2
  43. package/src/test-passes.ts +79 -0
  44. package/src/test-shards.ts +110 -36
  45. package/src/verify-checks.ts +6 -6
  46. package/src/verify-step.ts +4 -4
  47. package/src/verify-tests.ts +14 -2
package/src/dev-watch.ts CHANGED
@@ -1,53 +1,75 @@
1
- // Which writes under the app root are a source change, and which are noise. Split out of
2
- // `cmd-dev.ts` because it is a rule with cases rather than four lines of glue, and because the
3
- // answer needs a test of its own: a watcher that reloads on the wrong write is invisible — the
4
- // dev server stays correct and merely does the most expensive thing it can do, repeatedly.
1
+ // Which paths under the app root `x dev` must not watch, and the rule is REGISTRATION rather than
2
+ // filtering: `watch(root, { recursive: true })` takes one inotify descriptor per directory before
3
+ // any filter runs, so an answer given after the event has already cost the kernel queue, a JS
4
+ // callback and a slot out of `max_user_watches`. Measured on a monorepo root: 1901 descriptors,
5
+ // 1490 of them under `.git/` and `node_modules/`, and one `git status` delivering 5 events.
5
6
  //
6
- // What it was: `filename.includes('.x/') || filename.includes('node_modules')`. Measured against
7
- // ai-maxxing, whose checkout carries `.git/`, `.personal/`, `.claude/worktrees/` (two FULL copies
8
- // of the app) and `coverage/`, every write under any of them ran a whole `appManifest()` plus a
9
- // `buildIslands()` over ten islands. `git status`, an agent's scratch file and a coverage run each
10
- // cost a full rebuild, and each rebuild re-minted every island chunk.
11
- //
12
- // `includes` was also the wrong operator, not just the wrong list: a directory legitimately named
13
- // `my-node_modules-notes/` was excluded from the dev loop for a substring, and `notes/.xyz/` for
14
- // another. The match is on a PATH SEGMENT, so only the directory itself is ever ignored.
7
+ // The ignore set is the app's own `.gitignore`, read with git's own anchoring (`gitignore.ts`),
8
+ // plus a floor of directory names an ignore file need not name. It was seven hand-listed names,
9
+ // and both halves of that were wrong: nothing read `.gitignore`, so `tsconfig.tsbuildinfo` —
10
+ // rewritten by every `bun run typecheck` — ran a full `appManifest()` plus `buildIslands()`; and
11
+ // `dist` and `coverage` were matched at ANY depth, so an app's own `/dist` or `/coverage` route
12
+ // never reloaded at all, silently.
13
+
14
+ // why: Bun exposes no path-join primitive. The same necessity `fix-path.ts` already records.
15
+ import { join } from 'node:path';
16
+ import type { IgnoreScope } from './gitignore';
17
+ import { ignoreScopes, isGitIgnored } from './gitignore';
18
+ import { pathSegments } from './path-segments';
15
19
 
16
20
  /**
17
- * Directories whose writes are never an app source change.
21
+ * Directory names no `.gitignore` can be relied on to carry, matched as a path SEGMENT at any
22
+ * depth. Every entry earns its line, and every one is either dotted or `node_modules` — which is
23
+ * what makes the any-depth match safe: a `site/` subtree is a URL tree, and neither a dot-directory
24
+ * nor an install is ever a route.
18
25
  *
19
- * Every entry earns its line and none is a guess:
20
- * - `.x` — the framework's own state directory: PGlite's data, the dev lock, the static export,
21
- * `build-stats.json`. `x build` writes here, which made a build trigger reloads of the process
22
- * that was running it.
23
- * - `node_modules` — an install, not an edit. `x dev` does not reload for a dependency change
24
- * because it cannot: the modules are already in this process's cache.
25
- * - `.git` — the reason this list exists. `git status`, `git fetch` and every commit rewrite index
26
- * and ref files continuously, and none of them is a source edit; a checkout of a branch IS one,
27
- * but it also writes the source files themselves, which this list does not touch.
28
- * - `.personal` — an app's uncommitted local state (ai-maxxing's fleet file, its inventory, its
29
- * credentials). Written by scripts while the dev server runs.
26
+ * - `.git` — git never names its own directory in an ignore file, and `git status`, `git fetch` and
27
+ * every commit rewrite index and ref files continuously. The reason this list exists.
28
+ * - `.x` — the framework's own state: PGlite's data directory (which THIS process writes
29
+ * continuously), the dev lock, the static export, `build-stats.json`.
30
+ * - `node_modules` — an install, not an edit. `x dev` cannot reload for a dependency change: the
31
+ * modules are already in this process's cache.
32
+ * - `.personal` — an app's uncommitted local state, written by scripts while the dev server runs.
30
33
  * - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
31
34
  * the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
32
- * - `dist`, `coverage` — build and test output. Both are written by commands an author runs
33
- * BESIDE `x dev`, which is exactly when a spurious rebuild costs the most.
35
+ *
36
+ * `dist` and `coverage` were here and are deliberately not: both are ordinary build output that
37
+ * every ignore file already names, and hand-listing them cost an app its own routes.
34
38
  */
35
- export const IGNORED_DIRECTORIES: readonly string[] = [
39
+ export const ALWAYS_IGNORED_DIRECTORIES: readonly string[] = [
40
+ '.git',
36
41
  '.x',
37
42
  'node_modules',
38
- '.git',
39
43
  '.personal',
40
44
  '.claude',
41
- 'dist',
42
- 'coverage',
43
45
  ];
44
46
 
47
+ /** The ignore set as one question, so the walk and the event filter cannot disagree. */
48
+ export interface DevIgnore {
49
+ /** `path` is app-root-relative; `isDirectory` decides every trailing-slash rule git holds. */
50
+ ignores(path: string, isDirectory: boolean): boolean;
51
+ /** The `.gitignore` files this set was built from, outermost first — what `--json` can report. */
52
+ readonly scopes: readonly IgnoreScope[];
53
+ }
54
+
45
55
  /**
46
- * A path segment, never a substring. `filename` arrives from `node:fs`'s watcher root-relative and
47
- * with the platform's separator, so both are normalised before the split — a rule that reads
48
- * `foo/node_modules/bar` and not `foo\\node_modules\\bar` is a rule that does not exist on Windows.
56
+ * Read once, at boot and again on every write that names `.gitignore`. A snapshot rather than a
57
+ * live reader: the watcher rebuilds the whole set and re-walks, so a directory the author just
58
+ * ignored drops its descriptor instead of keeping one nothing will ever read an event from.
59
+ *
60
+ * Two limits, both deliberate. An ANCESTOR ignore file is read at boot and is not watched — it sits
61
+ * outside the app root, so `x dev` has no descriptor there and a restart is the way to pick up an
62
+ * edit to it. A NESTED one (`apps/web/.gitignore`) is not read at all: `ignoreScopes` walks upward
63
+ * only, and watching for one would mean a scope per directory in the tree.
49
64
  */
50
- export function isIgnoredPath(filename: string): boolean {
51
- const segments = filename.replaceAll('\\', '/').split('/');
52
- return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
65
+ export function devIgnore(root: string): DevIgnore {
66
+ const scopes = ignoreScopes(root);
67
+ return {
68
+ scopes,
69
+ ignores(path: string, isDirectory: boolean): boolean {
70
+ const segments = pathSegments(path);
71
+ if (segments.some((segment) => ALWAYS_IGNORED_DIRECTORIES.includes(segment))) return true;
72
+ return isGitIgnored(scopes, join(root, ...segments), isDirectory);
73
+ },
74
+ };
53
75
  }
@@ -0,0 +1,122 @@
1
+ // `x doctor`'s offline-fallback check: the path the app DECLARED, against the routes it really
2
+ // serves. Its own file because answering it needs the app's config and its route table, and
3
+ // `cmd-doctor.ts`'s job is the probe rather than the loading — the `db-backfill.ts` split, one
4
+ // diagnostic over.
5
+
6
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
7
+ import { describeRoutes } from '@ultimat3/render';
8
+ import { loadApp } from './app-load';
9
+ import { APP_CONFIG_FILE } from './app-root';
10
+ import type { Finding } from './output';
11
+ import { loadPwaArtifacts } from './pwa-artifacts';
12
+
13
+ /** A route as this check reads one: the URL it answers, and the surface that answers it. */
14
+ export interface NavigableRoute {
15
+ readonly path: string;
16
+ readonly surface: string;
17
+ }
18
+
19
+ export interface OfflineFallbackFact {
20
+ /**
21
+ * `pwa.offline.fallback` as `loadPwaArtifacts` read it, or `null` — which is both "this app
22
+ * declares no PWA" and "it declares one with no fallback". Either way no service worker is
23
+ * emitted at all (`serviceWorkerArtifacts` answers `undefined`), so the two share one remedy.
24
+ */
25
+ readonly fallback: string | null;
26
+ /**
27
+ * Every registered route, or `undefined` when the app would not load. `appEntities`' rule
28
+ * (`schema-drift.ts`) one registry over: a module that will not import leaves the registry
29
+ * short, and a short registry reads as "no route serves it" — a generator handed out for a route
30
+ * the app already has, over one file's syntax error.
31
+ */
32
+ readonly routes: readonly NavigableRoute[] | undefined;
33
+ }
34
+
35
+ /**
36
+ * The one surface an OFFLINE navigation can land on. `api/` answers a JSON document and `shared/`
37
+ * is not a URL at all — the pair `sw-artifacts.ts` excludes for the same reason — and `app/` is
38
+ * excluded for a third: `SURFACE_SPECS` allows it `stream | ssr` and nothing else, only a `static`
39
+ * route is prerendered, and the service worker precaches a rendered DOCUMENT
40
+ * (`serviceWorkerArtifacts` reads `documents.get(fallback)`). So an `app/` fallback has nothing
41
+ * to precache and the offline navigation it is supposed to answer reaches the network and fails —
42
+ * this check passing for it is the false green it exists to prevent.
43
+ *
44
+ * It accepted `app` until 2026-09, on the argument that both surfaces answer the same URL and it
45
+ * is the FIX that is opinionated. True about URLs and wrong about offline: the fix's own comment
46
+ * below already refuses `--surface app`, so the check and the remedy disagreed about one code.
47
+ *
48
+ * Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
49
+ * either. `NavigableRoute` carries no render mode, and `describeRoutes()` has one — the narrower
50
+ * check belongs with it.
51
+ */
52
+ const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
53
+
54
+ /**
55
+ * A fallback `x g route <name> --surface site` can actually create: ONE path segment, which is
56
+ * what the generator turns into `apps/<app>/site/<name>/page.tsx` and therefore into `/<name>`. A
57
+ * nested or punctuated path would be slugified into a DIFFERENT url (`/support/offline` →
58
+ * `/support-offline`), so offering the command there is a fix that runs and leaves the finding
59
+ * exactly where it was.
60
+ *
61
+ * `--surface site`, and never `app`: the document that answers a lost network must render with no
62
+ * network, no session and no database, which `app/` (`ssr | stream`) cannot promise — the reason
63
+ * `x new` scaffolds it under `site/` (`wiki/Upgrading.md`) — and it is the line `@ultimat3/pwa`'s
64
+ * own `X_PWA_NO_OFFLINE_FALLBACK` hands out. Two fixes for one code are two answers.
65
+ */
66
+ const GENERATABLE = /^\/([a-z][a-z0-9-]*)$/;
67
+
68
+ const finding = (cause: string, fix: string): Finding => ({
69
+ code: 'X_PWA_NO_OFFLINE_FALLBACK',
70
+ cause,
71
+ fix,
72
+ docs: ERROR_DOCS_URL,
73
+ at: APP_CONFIG_FILE,
74
+ });
75
+
76
+ /**
77
+ * The declared fallback, judged against the route table — never against a filename.
78
+ *
79
+ * It WAS a filename: the literal `apps/web/app/offline.tsx`, which `assertRouteFilename` refuses
80
+ * outright (the directory is the URL, so a page is `page.tsx`), while `x new` scaffolds
81
+ * `apps/web/site/offline/page.tsx` and this finding's own `fix:` writes
82
+ * `apps/web/app/offline/page.tsx`. Every one of the three is a different path, so the check was
83
+ * red for every app the framework has ever produced and no invocation could clear it — the shape
84
+ * `budgets.ts` calls a false green read backwards, and the reason a diagnostic is held to being
85
+ * closable by its own fix.
86
+ */
87
+ export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | undefined {
88
+ if (fact.routes === undefined) return undefined;
89
+ const fallback = fact.fallback;
90
+ if (fallback === null) {
91
+ return finding(
92
+ 'no pwa.offline.fallback is declared, so no service worker is emitted and an offline navigation falls back to the browser error page',
93
+ `set pwa: { offline: { fallback: '/offline' } } in ${APP_CONFIG_FILE}`,
94
+ );
95
+ }
96
+ if (fact.routes.some((route) => route.path === fallback && NAVIGABLE.has(route.surface))) {
97
+ return undefined;
98
+ }
99
+ const cause = `pwa.offline.fallback is "${fallback}" and no site/ route serves it, so an offline navigation falls back to the browser error page`;
100
+ const name = GENERATABLE.exec(fallback)?.[1];
101
+ return name === undefined
102
+ ? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
103
+ : finding(cause, `x g route ${name} --surface site`);
104
+ }
105
+
106
+ /**
107
+ * The fact, read off a real app root. Both halves come from the framework's own answers — the
108
+ * config through `loadPwaArtifacts` (the one reader of that file) and the routes through
109
+ * `describeRoutes()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
110
+ * built from), so this check and the service worker cannot disagree about which routes exist.
111
+ */
112
+ export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
113
+ const pwa = await loadPwaArtifacts(root);
114
+ const app = await loadApp(root);
115
+ return {
116
+ fallback: pwa?.offline.fallback ?? null,
117
+ routes:
118
+ app.findings.length > 0
119
+ ? undefined
120
+ : describeRoutes().map((route) => ({ path: route.path, surface: route.surface })),
121
+ };
122
+ }
@@ -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 {
@@ -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
  /**