@ultimat3/cli 19.2.0 → 19.3.2

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 (59) hide show
  1. package/CLAUDE.md +135 -9
  2. package/README.md +1 -1
  3. package/package.json +29 -29
  4. package/src/app-agents-md.ts +14 -3
  5. package/src/app-boundaries.ts +11 -2
  6. package/src/app-load.ts +96 -25
  7. package/src/budgets.ts +17 -6
  8. package/src/cmd-dev-fixture.ts +25 -0
  9. package/src/cmd-dev.ts +48 -46
  10. package/src/cmd-doctor.ts +61 -23
  11. package/src/cmd-generate.ts +25 -3
  12. package/src/cmd-i18n.ts +10 -3
  13. package/src/cmd-jobs.ts +56 -10
  14. package/src/cmd-test.ts +15 -10
  15. package/src/db-seed.ts +2 -1
  16. package/src/dev-queue.ts +16 -2
  17. package/src/dev-reload.ts +46 -0
  18. package/src/dev-render.ts +35 -9
  19. package/src/dev-runtime.ts +4 -1
  20. package/src/dev-sync.ts +11 -3
  21. package/src/dev-watch-tree.ts +226 -0
  22. package/src/dev-watch.ts +59 -37
  23. package/src/doctor-offline.ts +122 -0
  24. package/src/error-catalog.ts +4 -5
  25. package/src/fix-command.ts +40 -1
  26. package/src/fix-path.ts +10 -11
  27. package/src/flag-number.ts +15 -0
  28. package/src/generate-files.ts +24 -2
  29. package/src/generate-kinds.ts +54 -4
  30. package/src/generate-write.ts +25 -2
  31. package/src/gitignore.ts +145 -0
  32. package/src/hold.ts +50 -17
  33. package/src/index.ts +1 -1
  34. package/src/island-bundle.ts +2 -1
  35. package/src/island-states-load.ts +2 -1
  36. package/src/jobs-driver.ts +4 -1
  37. package/src/mcp-host.ts +18 -9
  38. package/src/parse.ts +17 -0
  39. package/src/path-segments.ts +14 -0
  40. package/src/prerender.ts +46 -20
  41. package/src/retry-memo.ts +37 -0
  42. package/src/scaffold-fixture.ts +17 -0
  43. package/src/serve.ts +40 -5
  44. package/src/source-files.ts +3 -1
  45. package/src/sw-artifacts.ts +71 -7
  46. package/src/templates/action.ts +47 -16
  47. package/src/templates/admin-page.ts +49 -1
  48. package/src/templates/island.ts +4 -2
  49. package/src/templates/scaffold-container.ts +12 -0
  50. package/src/templates/scaffold-docs.ts +7 -0
  51. package/src/templates/scaffold-entries.ts +4 -2
  52. package/src/templates/scaffold-repo.ts +7 -2
  53. package/src/templates/slice-foundation.ts +36 -0
  54. package/src/test-passes.ts +79 -0
  55. package/src/test-shards.ts +110 -36
  56. package/src/verify-checks.ts +11 -8
  57. package/src/verify-floor.ts +59 -3
  58. package/src/verify-step.ts +4 -4
  59. package/src/verify-tests.ts +14 -2
@@ -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;
@@ -48,6 +48,13 @@ export interface GenerateOptions {
48
48
  * package, and only then does a generated file import `t` from `@ultimat3/i18n` instead.
49
49
  */
50
50
  readonly catalogModule?: string;
51
+ /**
52
+ * `action` and `mutator`: the slice's `errors.ts` as it stands on disk, absent when there is
53
+ * none. Supplied by `run` for `catalogModule`'s reason — whether the slice declares
54
+ * `<Feature>NotFoundError` is a fact about THIS app, and a template that assumed it wrote an
55
+ * import of a class the app never declared. Read at `sliceDir(surface, feature)/errors.ts`.
56
+ */
57
+ readonly sliceErrors?: string;
51
58
  }
52
59
 
53
60
  const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
@@ -55,6 +62,10 @@ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
55
62
  app: 'apps/web/app',
56
63
  };
57
64
 
65
+ /** Where a feature slice lives, relative to the app root — the one derivation `run` reads from. */
66
+ export const sliceDir = (surface: Surface, feature: string): string =>
67
+ `${DEFAULT_SURFACE_DIR[surface]}/${feature}`;
68
+
58
69
  /**
59
70
  * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
60
71
  * on them, and nothing has to run a filesystem to review what a generator produces.
@@ -76,9 +87,20 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
76
87
  }),
77
88
  );
78
89
  case 'action':
79
- return dedupe(actionFiles(options.name, target));
90
+ return dedupe(
91
+ actionFiles(options.name, {
92
+ ...target,
93
+ ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
94
+ }),
95
+ );
80
96
  case 'mutator':
81
- return dedupe(actionFiles(options.name, { ...target, mutator: true }));
97
+ return dedupe(
98
+ actionFiles(options.name, {
99
+ ...target,
100
+ mutator: true,
101
+ ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
102
+ }),
103
+ );
82
104
  case 'backfill':
83
105
  return dedupe(backfillFiles(options.name, target));
84
106
  case 'entity':
@@ -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
  /**
@@ -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
+ }