@ultimat3/cli 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
@@ -0,0 +1,268 @@
1
+ // The half of the error contract that a text rule cannot decide: a `fix:` may cite `x <command>`
2
+ // and that command may not exist. Six shipped fix lines named `x db status`, `x logs tail`,
3
+ // `x trace`, `x metrics`, `x auth whoami` and `x ai prompts` — every one of them passed the
4
+ // `errors` step, because the step checks that a fix NAMES a command, never that the build ships it.
5
+ //
6
+ // It reads THREE words for the same reason it reads two: `x db branch ls --json` shipped as a fix
7
+ // while `x db branch` had no `ls`, because a rule stopping at the subcommand never saw the word
8
+ // that decided what ran.
9
+
10
+ import type { CommandSpec } from './parse';
11
+ import { GLOBAL_FLAGS } from './parse';
12
+
13
+ /**
14
+ * The rule is CONDITIONAL, and that is the whole design.
15
+ *
16
+ * *If* a fix cites `x <something>`, that something must resolve. It does NOT say every fix must
17
+ * name a command — axiom 4 asks for an executable instruction, and
18
+ * `set OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318` or
19
+ * `counter('orders_total', { maxSeries: 4000 })` are executable and correctly cite nothing. A
20
+ * universal rule would push an author towards citing a command that does not really fix it, which
21
+ * is a worse error than one with no command in it.
22
+ */
23
+ // Digits are part of a name, not a boundary: `x i18n check` read through `[a-z-]*` alone cites
24
+ // `x i`, which is not a command — a false finding on three of the framework's own fix lines.
25
+ //
26
+ // The THIRD slot also matches a `<placeholder>`, and only the third. A slot with a closed set is a
27
+ // slot where the reader has nothing to substitute, so `x db branch <name>` — two shipped fix lines
28
+ // in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
29
+ // was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
30
+ // `x db branch drop <name>`), where a placeholder is exactly right.
31
+ const CITATION =
32
+ /(?:^|[\s;|&("'`])x\s+([a-z][a-z\d-]*)(?:\s+([a-z][a-z\d-]*))?(?:\s+([a-z][a-z\d-]*|<[^>]*>))?/g;
33
+
34
+ /**
35
+ * A long flag, `--` stripped. `--no-<name>` is the parser's negation of a boolean, so it resolves
36
+ * against `<name>` — reporting `no-example` as an unknown flag would be a finding about a working
37
+ * invocation. A `-j` short form is deliberately not read: one letter is too weak a signal in prose.
38
+ */
39
+ const FLAG = /(?:^|\s)--(?:no-)?([a-z][a-z\d-]*)/g;
40
+
41
+ /**
42
+ * Where a citation's argument list ends. `;`, `|` and `&` start a second shell word, `#` starts a
43
+ * comment, and a backtick or a quote closes the span the citation was written in — past any of
44
+ * them a `--flag` belongs to something else.
45
+ */
46
+ const ARGUMENT_END = /[;|&#`'"]/;
47
+
48
+ /** One `x …` citation, as written. `sub` is the next bare word, which may not be a subcommand. */
49
+ export interface FixCitation {
50
+ readonly command: string;
51
+ readonly sub: string | undefined;
52
+ /** The bare word after `sub`. Judged only against a declared `subcommandPositionals` set. */
53
+ readonly positional: string | undefined;
54
+ /** Long flags written after it, in order, `--` and any `no-` stripped. */
55
+ readonly flags: readonly string[];
56
+ }
57
+
58
+ /**
59
+ * Every `x <command> [<word>] [--flag …]` a fix line cites.
60
+ *
61
+ * Read off the STATIC form of the fix — the caller blanks `${…}` first — because a command name
62
+ * assembled at run time is not a name this can resolve, and guessing at one would report findings
63
+ * nobody can act on. `x` alone, or `x --json`, cites nothing: the regex needs a bare lowercase
64
+ * word after the space.
65
+ *
66
+ * The flag list stops at the NEXT citation as well as at `ARGUMENT_END`: one fix line routinely
67
+ * names two commands (`x db migrate, then confirm with x db query "…" --json`), and charging the
68
+ * second command's flags to the first would report a finding on the wrong half of the sentence.
69
+ */
70
+ export function fixCitations(fix: string): readonly FixCitation[] {
71
+ const matches = [...fix.matchAll(CITATION)].filter((match) => match[1] !== undefined);
72
+ return matches.map((match, index) => {
73
+ const start = match.index + match[0].length;
74
+ const next = matches[index + 1]?.index ?? fix.length;
75
+ const tail = fix.slice(start, next);
76
+ const stop = ARGUMENT_END.exec(tail)?.index;
77
+ const args = stop === undefined ? tail : tail.slice(0, stop);
78
+ return {
79
+ command: match[1] as string,
80
+ sub: match[2],
81
+ positional: match[3],
82
+ flags: [...args.matchAll(FLAG)].map((flag) => flag[1] as string),
83
+ };
84
+ });
85
+ }
86
+
87
+ /** Long flags a spec accepts: its own, plus the four every command takes. */
88
+ const declaredFlags = (spec: CommandSpec): ReadonlySet<string> =>
89
+ new Set([...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((flag) => flag.name));
90
+
91
+ export interface CommandCatalog {
92
+ /** Every spec the registry holds, planned ones included — `x help` lists those too. */
93
+ readonly specs: readonly CommandSpec[];
94
+ /** Names that parse but exit `X_NOT_IMPLEMENTED`. Citing one is the bug this check closes. */
95
+ readonly planned: ReadonlySet<string>;
96
+ /** `"<command> <subcommand>"` pairs that parse and exit `X_NOT_IMPLEMENTED`. */
97
+ readonly plannedSubcommands: ReadonlySet<string>;
98
+ }
99
+
100
+ /**
101
+ * What a caller accepts from a citation. A `fix:` hands its reader a command to RUN, so a planned
102
+ * one is a defect; a doc page may legitimately *say* a command is planned, and a rule that refused
103
+ * that would delete `wiki/CLI-Reference.md`'s planned table one true row at a time.
104
+ *
105
+ * `allowPlanned` covers `PLANNED_SUBCOMMANDS` as well as `PLANNED_COMMANDS` — `x db studio` is the
106
+ * single entry in the first table, and four pages name it as planned.
107
+ */
108
+ export interface CitationRules {
109
+ readonly allowPlanned?: boolean;
110
+ }
111
+
112
+ /**
113
+ * One citation that did not resolve, split so a caller can key on WHAT failed.
114
+ *
115
+ * `subject` is the invocation spelled the way it would be typed — `x db query`, `x env check --fix`
116
+ * — and it is deliberately stable under a doc edit that only moves the sentence around it. That is
117
+ * what lets `scripts/doc-commands-allow.ts` allow one page to name one non-command (the pages that
118
+ * say "there is no `x serve` command" are saying something TRUE) without waiving the rule for the
119
+ * rest of that page.
120
+ */
121
+ export interface CitationFault {
122
+ readonly subject: string;
123
+ readonly reason: string;
124
+ }
125
+
126
+ /**
127
+ * A second word is judged as a subcommand ONLY when the spec declares subcommands at all, or
128
+ * against a declared closed set of positionals. `x new my-app` and `x g route posts` take open
129
+ * positionals, and reporting `my-app` as an unknown subcommand would be a finding about a working
130
+ * example.
131
+ */
132
+ function wordFault(
133
+ spec: CommandSpec,
134
+ word: string,
135
+ catalog: CommandCatalog,
136
+ rules: CitationRules,
137
+ ): CitationFault | undefined {
138
+ const subject = `x ${spec.name} ${word}`;
139
+ if (spec.subcommands !== undefined) {
140
+ if (!spec.subcommands.includes(word)) {
141
+ return {
142
+ subject,
143
+ reason: `and ${spec.name} has no such subcommand (${spec.subcommands.join(', ')})`,
144
+ };
145
+ }
146
+ if (catalog.plannedSubcommands.has(`${spec.name} ${word}`) && rules.allowPlanned !== true) {
147
+ return { subject, reason: 'which is planned and exits X_NOT_IMPLEMENTED' };
148
+ }
149
+ return undefined;
150
+ }
151
+ const choices = spec.positionalChoices;
152
+ if (choices === undefined || choices.includes(word)) return undefined;
153
+ return {
154
+ subject,
155
+ reason: `and ${word} is not one of ${spec.name}'s positionals (${choices.join(', ')})`,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The third word, judged ONLY where the subcommand declares a closed set. `x jobs show <id>` and
161
+ * `x db gen "add publish_at"` take open positionals, so a universal third-word rule would report
162
+ * findings about working invocations — the same conditionality `wordFault` applies to the second.
163
+ */
164
+ function positionalFault(spec: CommandSpec, sub: string, word: string): CitationFault | undefined {
165
+ const choices = spec.subcommandPositionals?.[sub];
166
+ if (choices === undefined || choices.includes(word)) return undefined;
167
+ // A placeholder is judged the same as a wrong word, and deliberately: there is nothing the
168
+ // reader could substitute that would make `x db branch <name>` run, because the slot is a verb.
169
+ return {
170
+ subject: `x ${spec.name} ${sub} ${word}`,
171
+ reason: `and ${spec.name} ${sub} takes one of ${choices.join(', ')}`,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Why a citation does not resolve, or `undefined` when it does. FIVE levels, because the drift is
177
+ * mostly BELOW the command name: `x db query` names a real command and an unreal subcommand,
178
+ * `x env check --fix` names both and an unreal flag, `x test summarize` names a first positional
179
+ * that is not a `TestType`, and `x db branch ls` named a real subcommand and a third word that
180
+ * `x db branch` read as a branch NAME. A rule stopping at the command name accepted all four.
181
+ *
182
+ * The planned check is the one the whole thing exists for: a PLANNED command is in the registry and
183
+ * parses, so a resolution that only asked "is this a known name" would accept `x logs tail` — the
184
+ * exact citation that throws `X_NOT_IMPLEMENTED` at the reader.
185
+ *
186
+ * Flags are NOT judged on a planned command. `cmd-planned.ts` builds its spec from a name, a
187
+ * summary and a usage line and declares no flags at all, so every flag its own usage line documents
188
+ * would read as unknown — while the real refusal is `X_NOT_IMPLEMENTED` one level up.
189
+ */
190
+ export function citationFault(
191
+ citation: FixCitation,
192
+ catalog: CommandCatalog,
193
+ rules: CitationRules = {},
194
+ ): CitationFault | undefined {
195
+ const spec = catalog.specs.find(
196
+ (candidate) =>
197
+ candidate.name === citation.command || candidate.aliases?.includes(citation.command) === true,
198
+ );
199
+ if (spec === undefined) {
200
+ return { subject: `x ${citation.command}`, reason: 'which is not a command' };
201
+ }
202
+ const planned = catalog.planned.has(spec.name);
203
+ if (planned && rules.allowPlanned !== true) {
204
+ return {
205
+ subject: `x ${citation.command}`,
206
+ reason: 'which is planned and exits X_NOT_IMPLEMENTED',
207
+ };
208
+ }
209
+ if (citation.sub !== undefined) {
210
+ const fault = wordFault(spec, citation.sub, catalog, rules);
211
+ if (fault !== undefined) return fault;
212
+ if (citation.positional !== undefined) {
213
+ const deeper = positionalFault(spec, citation.sub, citation.positional);
214
+ if (deeper !== undefined) return deeper;
215
+ }
216
+ }
217
+ if (planned) return undefined;
218
+ const declared = declaredFlags(spec);
219
+ const unknown = citation.flags.find((flag) => !declared.has(flag));
220
+ if (unknown === undefined) return undefined;
221
+ return {
222
+ subject: `x ${spec.name} --${unknown}`,
223
+ reason: `and ${spec.name} declares no such flag — the parser refuses it with X_CLI_BAD_FLAG (known: ${[...declared].join(', ')})`,
224
+ };
225
+ }
226
+
227
+ /** The same answer as one sentence, which is what a `cause:` line wants. */
228
+ export function citationProblem(
229
+ citation: FixCitation,
230
+ catalog: CommandCatalog,
231
+ rules: CitationRules = {},
232
+ ): string | undefined {
233
+ const fault = citationFault(citation, catalog, rules);
234
+ return fault === undefined ? undefined : `cites "${fault.subject}", ${fault.reason}`;
235
+ }
236
+
237
+ /** The first citation that does not resolve. One finding per fix line, not one per word. */
238
+ export function citedCommandProblem(
239
+ fix: string,
240
+ catalog: CommandCatalog,
241
+ rules: CitationRules = {},
242
+ ): string | undefined {
243
+ for (const citation of fixCitations(fix)) {
244
+ const problem = citationProblem(citation, catalog, rules);
245
+ if (problem !== undefined) return problem;
246
+ }
247
+ return undefined;
248
+ }
249
+
250
+ /**
251
+ * The registry, as this check reads it.
252
+ *
253
+ * Imported dynamically because `registry.ts` → `cmd-verify.ts` → `error-contract.ts` closes a
254
+ * cycle back to the caller. The precedent is `cmd-build.ts`'s `await import('./cmd-verify')`:
255
+ * one break, inside a function that is already async, rather than a second copy of the command
256
+ * list here — which would be a catalog that can disagree with the one `x help` prints.
257
+ */
258
+ export async function loadCommandCatalog(): Promise<CommandCatalog> {
259
+ const { SPECS } = await import('./registry');
260
+ const { PLANNED_COMMANDS, PLANNED_SUBCOMMANDS } = await import('./cmd-planned');
261
+ return {
262
+ specs: SPECS,
263
+ planned: new Set(PLANNED_COMMANDS.map((planned) => planned.name)),
264
+ plannedSubcommands: new Set(
265
+ PLANNED_SUBCOMMANDS.map((planned) => `${planned.command} ${planned.subcommand}`),
266
+ ),
267
+ };
268
+ }
@@ -0,0 +1,67 @@
1
+ // One integer-flag reader for every command that takes one. `Number.parseInt` alone accepts a
2
+ // prefix and answers `NaN` for the rest, and three commands took it bare: `x doctor --port abc`
3
+ // probed `NaN`, which `portFree` reports as free — a check that CANNOT FAIL — `x dev --port abc`
4
+ // handed `NaN` to `Bun.serve` and bound an arbitrary port, and `x test --workers 4abc` ran four.
5
+
6
+ import { BadFlagError } from './errors';
7
+ import type { ParsedArgs } from './parse';
8
+ import { flagString } from './parse';
9
+
10
+ export interface IntFlag {
11
+ readonly name: string;
12
+ /** The command as it appears in the cause line, e.g. `doctor` for `x doctor`. */
13
+ readonly command: string;
14
+ readonly min: number;
15
+ readonly max?: number;
16
+ /** A runnable invocation carrying a good value — never a `<placeholder>`. */
17
+ readonly example: string;
18
+ }
19
+
20
+ const bound = (flag: IntFlag): string =>
21
+ flag.max === undefined ? `>= ${flag.min}` : `from ${flag.min} to ${flag.max}`;
22
+
23
+ /** `/^\d+$/` first: it is the only test that refuses `4abc`, `4.9`, `0x10`, `+4` and ` 4`. */
24
+ export function parseIntFlag(raw: string, flag: IntFlag): number {
25
+ const value = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
26
+ if (
27
+ !Number.isInteger(value) ||
28
+ value < flag.min ||
29
+ (flag.max !== undefined && value > flag.max)
30
+ ) {
31
+ throw new BadFlagError({
32
+ flag: flag.name,
33
+ command: flag.command,
34
+ reason: `expects an integer ${bound(flag)}, got "${raw}"`,
35
+ fix: flag.example,
36
+ });
37
+ }
38
+ return value;
39
+ }
40
+
41
+ /** The flag's value, or `undefined` when it was not given. Throws `X_CLI_BAD_FLAG` on a bad one. */
42
+ export function readIntFlag(args: ParsedArgs, flag: IntFlag): number | undefined {
43
+ const raw = flagString(args, flag.name);
44
+ return raw === undefined ? undefined : parseIntFlag(raw, flag);
45
+ }
46
+
47
+ /** The same, with a default — for a flag whose spec already declares one. */
48
+ export const intFlagOr = (args: ParsedArgs, flag: IntFlag, fallback: number): number =>
49
+ readIntFlag(args, flag) ?? fallback;
50
+
51
+ /**
52
+ * Every port flag answers to the same bounds `serve.ts`'s `portValue` already enforces on `PORT`,
53
+ * 0 included — 0 is "let the kernel pick", which is how `x dev --port 0` boots a test server on a
54
+ * free port. Two ranges for one concept is the drift this constant exists to prevent.
55
+ */
56
+ export const PORT_RANGE = { min: 0, max: 65_535 } as const;
57
+
58
+ /**
59
+ * A free-port suggestion the thing being fixed will actually accept. `port + 1` at the top of the
60
+ * range names 65536, which is not a port — so a `fix:` built that way reproduces a failure instead
61
+ * of ending one: `x doctor` emitted `x dev --port 65536`, which `x dev` refuses with
62
+ * `X_CLI_BAD_FLAG`. The neighbour below is a port; the one above does not exist. Here rather than
63
+ * beside either caller, because two ports-are-bounded rules is the drift `PORT_RANGE` above
64
+ * already exists to prevent.
65
+ */
66
+ export const neighbouringPort = (port: number): number =>
67
+ port < PORT_RANGE.max ? port + 1 : PORT_RANGE.max - 1;
@@ -0,0 +1,49 @@
1
+ // Where the `@ultimat3` packages THIS `x` is pinned against live on disk. One resolver, because
2
+ // two answers to "which framework is installed" is two answers to every question read off it —
3
+ // the docs `x docs` quotes and the `fix:` lines `x errors explain` projects come from the same
4
+ // directory or they describe different builds.
5
+
6
+ // `node:fs`/`node:path` because Bun ships neither: `dirname` walks a resolved module up to the
7
+ // directory that owns it, and `existsSync` is what says which directory that is.
8
+ import { existsSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ /**
12
+ * Deep enough for `src/index.ts` and for any entry an `exports` map could point at, shallow enough
13
+ * that a resolver answering something unexpected stops rather than walking to `/`.
14
+ */
15
+ const MAX_DEPTH = 6;
16
+
17
+ /**
18
+ * Resolved from the CLI's own dependency on `@ultimat3/core` rather than from the user's cwd:
19
+ * these are the packages this `x` would actually run. Resolution follows the symlink, so a
20
+ * workspace-linked app lands on the same `packages/` this monorepo does — one code path for both,
21
+ * and a directory listing rather than a hardcoded package list, because an app installs the
22
+ * subset it uses.
23
+ *
24
+ * The **exported entry** is what gets resolved, never `@ultimat3/core/package.json`: every package
25
+ * here declares `"exports": { ".": "./src/index.ts" }` and nothing else, so a subpath specifier is
26
+ * asking a resolver for something the package does not publish. Bun 1.3 happens to answer it
27
+ * anyway; a resolver that enforced `exports` would answer `undefined`, and the failure would be
28
+ * silent — `x docs` reporting no installed packages and `x errors explain` reporting that no
29
+ * framework raises the code. Walking up from the entry to the directory that owns its
30
+ * `package.json` depends on nothing but the entry that is already imported.
31
+ *
32
+ * `undefined` means the CLI cannot see its own dependency, which is a broken install and not
33
+ * merely an undocumented one; every caller reports that rather than answering emptily.
34
+ */
35
+ export function frameworkScopeDir(): string | undefined {
36
+ let dir: string;
37
+ try {
38
+ dir = dirname(Bun.resolveSync('@ultimat3/core', import.meta.dir));
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
43
+ if (existsSync(join(dir, 'package.json'))) return dirname(dir);
44
+ const parent = dirname(dir);
45
+ if (parent === dir) return undefined;
46
+ dir = parent;
47
+ }
48
+ return undefined;
49
+ }
@@ -0,0 +1,97 @@
1
+ // Which generators exist, and how one is named on a command line. Split from `cmd-generate.ts`
2
+ // because "what a generator emits" and "which spelling reaches it" are two jobs — and the file
3
+ // that held both had reached the 500-line ceiling, one generator short of failing its own gate.
4
+
5
+ import { BadFlagError, MissingPositionalError, UnknownCommandError } from './errors';
6
+ import type { Surface } from './templates';
7
+
8
+ export const GENERATORS = [
9
+ 'resource',
10
+ 'action',
11
+ 'mutator',
12
+ 'backfill',
13
+ 'job',
14
+ 'route',
15
+ 'policy',
16
+ 'entity',
17
+ 'query',
18
+ 'task',
19
+ 'island',
20
+ 'admin:page',
21
+ 'guard',
22
+ ] as const;
23
+
24
+ export type Generator = (typeof GENERATORS)[number];
25
+
26
+ /**
27
+ * A resource slice is app-surface by construction: it ships a live query, a form with a signal,
28
+ * two actions and a policy, and `site/` is the 0kb, never-hydrated surface that may not import
29
+ * `app/`. The documented shape is the slice in `app/` plus a separate public route
30
+ * (`docs/architecture/15-adding-a-feature.md`), so the flag is refused before anything is written
31
+ * rather than emitting a slice that fails the app's own budget and boundary gates.
32
+ */
33
+ export function assertSurfaceSupported(kind: Generator, surface: Surface, name: string): void {
34
+ if (kind !== 'resource' || surface !== 'site') return;
35
+ throw new BadFlagError({
36
+ flag: 'surface',
37
+ command: 'g resource',
38
+ reason: 'a resource slice is app-surface — site/ ships 0kb JS and may not import app/',
39
+ // The caller's own name, not `<name>`: a `fix:` is copied and run verbatim, and `x g resource
40
+ // <name>` is a shell redirect (`bash: name: No such file or directory`), not a command.
41
+ fix: `x g resource ${name} && x g route ${name} --surface site`,
42
+ });
43
+ }
44
+
45
+ export function readKind(raw: string | undefined): Generator {
46
+ const kinds: readonly string[] = GENERATORS;
47
+ if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
48
+ throw new UnknownCommandError({
49
+ path: `g ${raw ?? ''}`.trim(),
50
+ known: GENERATORS,
51
+ suggestion: 'g resource',
52
+ });
53
+ }
54
+
55
+ /** Two surfaces, spelled exactly. A typo that fell through to `app` would scaffold the wrong one. */
56
+ export function readSurface(raw: string | undefined, kind: Generator, name: string): Surface {
57
+ if (raw === undefined || raw === 'app') return 'app';
58
+ if (raw === 'site') return 'site';
59
+ throw new BadFlagError({
60
+ flag: 'surface',
61
+ command: 'g',
62
+ reason: `"${raw}" is not a surface (site, app)`,
63
+ fix: `x g ${kind} ${name} --surface app`,
64
+ });
65
+ }
66
+
67
+ /**
68
+ * The missing `<name>` positional. It used to throw `X_CLI_UNKNOWN_COMMAND` — for a command form
69
+ * that IS known — with `fix: "x g route <name>"`, which pasted into a shell is a redirect
70
+ * (`bash: name: No such file or directory`). The code now says what is actually wrong, and the fix
71
+ * is a command that runs.
72
+ */
73
+ export function readName(raw: string | undefined, kind: Generator): string {
74
+ if (raw !== undefined) return raw;
75
+ throw new MissingPositionalError({
76
+ command: `g ${kind}`,
77
+ positional: 'name',
78
+ example: `x g ${kind} ${EXAMPLE_NAME[kind]}`,
79
+ });
80
+ }
81
+
82
+ /** One runnable example per generator, so the `fix:` is a command and not a shape. */
83
+ const EXAMPLE_NAME: Readonly<Record<Generator, string>> = {
84
+ resource: 'invoice',
85
+ action: 'publish-post',
86
+ mutator: 'rename-post',
87
+ backfill: 'backfill-slugs',
88
+ job: 'send-digest',
89
+ route: 'posts',
90
+ policy: 'post',
91
+ entity: 'post',
92
+ query: 'recent-posts',
93
+ task: 'nightly-digest',
94
+ island: 'counter',
95
+ 'admin:page': 'ops',
96
+ guard: 'migration-safety',
97
+ };
package/src/guards.ts ADDED
@@ -0,0 +1,186 @@
1
+ // An app's own convention, made into a build error (axiom 3). A file in `guards/` exports one
2
+ // `guard`; the gate discovers the directory and runs each one inside the `boundaries` step. The
3
+ // framework decides what a guard IS — a function returning findings, held to the error contract —
4
+ // and nothing about what a guard may check.
5
+
6
+ // Bun ships no equivalent for either: `existsSync` answers whether this app has a guards
7
+ // directory, `join` builds the host-separator path, and `pathToFileURL` is the only spelling of an
8
+ // absolute path `import()` accepts on every host.
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { renderCauseValue, renderThrowable } from '@ultimat3/core';
13
+ import { fixProblem } from './error-contract';
14
+ import type { Finding } from './output';
15
+ import type { HostCheck } from './verify-step';
16
+
17
+ /** The directory IS the registration. An app-side list is a list an app can forget to add to. */
18
+ export const GUARD_DIR = 'guards';
19
+
20
+ export interface Guard {
21
+ /** What this app refuses, in one line. It names the rule when the guard itself is the problem. */
22
+ readonly summary: string;
23
+ /**
24
+ * The rule, over the app root. Returns findings — it never prints, never decides an exit code
25
+ * and never throws for a normal result, because `--json`, the step table and the exit code are
26
+ * all projections of what it returns (axiom 2).
27
+ */
28
+ check(root: string): Promise<readonly Finding[]> | readonly Finding[];
29
+ }
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
32
+ typeof value === 'object' && value !== null;
33
+
34
+ const isGuard = (value: unknown): value is Guard =>
35
+ isRecord(value) &&
36
+ typeof value['summary'] === 'string' &&
37
+ value['summary'].trim() !== '' &&
38
+ typeof value['check'] === 'function';
39
+
40
+ const CODE = /^X_[A-Z0-9_]+$/;
41
+
42
+ /**
43
+ * A value named in a cause, without ever throwing to name it — the one thing this validator may
44
+ * never do is fail while it is explaining a failure, because then a bug in an app's guard reaches
45
+ * its author as a stack trace out of framework internals. The rendering itself is
46
+ * `@ultimat3/core`'s `renderCauseValue`: the local copy this used to hold called `String(value)` on
47
+ * an unnarrowed `unknown`, so a guard returning an object with a throwing `toString` destroyed the
48
+ * refusal — the case a scan over `String(` cannot see, because the call is one helper away.
49
+ * The `typeof` prefix stays: "object null" and "number 42" say what a bare literal does not.
50
+ */
51
+ const shown = (value: unknown): string =>
52
+ typeof value === 'string' ? `"${value}"` : `${typeof value} ${renderCauseValue(value)}`;
53
+
54
+ /**
55
+ * Why a returned value is not a finding, or `undefined` when it is one. The `fix:` half is
56
+ * `fixProblem` — the identical rule `x verify`'s `errors` step applies to every shipped `fix:` in
57
+ * the framework — because a mechanism for producing errors that are not instructions is worse than
58
+ * no mechanism. It runs on the returned value rather than on the source, which is the half a
59
+ * static scan cannot reach: a `fix` assembled at run time has no literal to read.
60
+ */
61
+ export function findingProblem(value: unknown): string | undefined {
62
+ if (!isRecord(value)) return `${shown(value)} is not a finding object`;
63
+ const code = value['code'];
64
+ if (typeof code !== 'string' || !CODE.test(code)) {
65
+ return `${shown(code)} is not an X_SCREAMING_SNAKE code, so nothing can explain it`;
66
+ }
67
+ const cause = value['cause'];
68
+ if (typeof cause !== 'string' || cause.trim() === '') return `${code} states no cause`;
69
+ const fix = value['fix'];
70
+ if (typeof fix !== 'string') return `${code} carries no fix line`;
71
+ return fixProblem(fix);
72
+ }
73
+
74
+ /** Only what a finding may carry, so a guard cannot smuggle fields the renderers never show. */
75
+ function findingOf(value: Record<string, unknown>, at: string): Finding {
76
+ const docs = value['docs'];
77
+ const located = value['at'];
78
+ return {
79
+ code: value['code'] as string,
80
+ cause: value['cause'] as string,
81
+ fix: value['fix'] as string,
82
+ ...(typeof docs === 'string' ? { docs } : {}),
83
+ at: typeof located === 'string' && located !== '' ? located : at,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Every guard file, app-root-relative and sorted, so two machines report the same findings in the
89
+ * same order. A `*.test.ts` beside a guard is its test, never a second guard — the generator emits
90
+ * one, and importing it would run the suite inside the gate.
91
+ */
92
+ export async function guardPaths(root: string): Promise<readonly string[]> {
93
+ const dir = join(root, GUARD_DIR);
94
+ if (!existsSync(dir)) return [];
95
+ const paths: string[] = [];
96
+ for await (const entry of new Bun.Glob('*.{ts,tsx}').scan({ cwd: dir, absolute: false })) {
97
+ const path = entry.split('\\').join('/');
98
+ if (/\.(?:test|d)\.tsx?$/.test(path)) continue;
99
+ paths.push(`${GUARD_DIR}/${path}`);
100
+ }
101
+ return paths.sort();
102
+ }
103
+
104
+ const failed = (path: string, cause: string): Finding => ({
105
+ code: 'X_GUARD_FAILED',
106
+ cause,
107
+ fix: `return a finding from ${path} instead of throwing, then: x verify`,
108
+ docs: 'https://ultimate.dev/errors/X_GUARD_FAILED',
109
+ at: path,
110
+ });
111
+
112
+ const invalid = (path: string, cause: string): Finding => ({
113
+ code: 'X_GUARD_INVALID',
114
+ cause,
115
+ fix: `export a \`guard\` object — { summary, check } — from ${path}, then: x verify`,
116
+ docs: 'https://ultimate.dev/errors/X_GUARD_INVALID',
117
+ at: path,
118
+ });
119
+
120
+ const findingInvalid = (path: string, cause: string): Finding => ({
121
+ code: 'X_GUARD_FINDING_INVALID',
122
+ cause: `${path} returned a finding that is not one: ${cause}`,
123
+ fix: `rewrite what ${path} returns as a code, a cause and a fix naming a command or a file, then: x verify`,
124
+ docs: 'https://ultimate.dev/errors/X_GUARD_FINDING_INVALID',
125
+ at: path,
126
+ });
127
+
128
+ /** Same reason as `shown`: an app's guard may throw a value that fights every way of reading it. */
129
+ const messageOf = (error: unknown): string => renderThrowable(error);
130
+
131
+ /** One guard: import it, run it, and hold what it returns to the contract. Never throws. */
132
+ async function runGuard(root: string, path: string): Promise<readonly Finding[]> {
133
+ let loaded: unknown;
134
+ try {
135
+ loaded = await import(pathToFileURL(join(root, path)).href);
136
+ } catch (error) {
137
+ return [failed(path, `${path} could not be imported: ${messageOf(error)}`)];
138
+ }
139
+ const exported = isRecord(loaded) ? loaded['guard'] : undefined;
140
+ if (!isGuard(exported)) {
141
+ return [
142
+ invalid(
143
+ path,
144
+ exported === undefined
145
+ ? `${path} exports no \`guard\`, so a file in ${GUARD_DIR}/ enforces nothing`
146
+ : `${path} exports a \`guard\` with no summary and no check()`,
147
+ ),
148
+ ];
149
+ }
150
+ let returned: unknown;
151
+ try {
152
+ returned = await exported.check(root);
153
+ } catch (error) {
154
+ return [failed(path, `${path} ("${exported.summary}") threw: ${messageOf(error)}`)];
155
+ }
156
+ if (!Array.isArray(returned)) {
157
+ return [findingInvalid(path, `check() answered ${typeof returned}, not a list of findings`)];
158
+ }
159
+ const findings: Finding[] = [];
160
+ for (const candidate of returned as readonly unknown[]) {
161
+ // Reading a candidate can throw on its own — a getter that raises, a proxy that refuses — and
162
+ // `findingProblem` is total only for values it can read. Per candidate, so one unreadable
163
+ // entry costs its own line and not the readable findings beside it.
164
+ try {
165
+ const problem = findingProblem(candidate);
166
+ if (problem !== undefined) findings.push(findingInvalid(path, problem));
167
+ else findings.push(findingOf(candidate as Record<string, unknown>, path));
168
+ } catch (error) {
169
+ findings.push(findingInvalid(path, `it could not be read: ${messageOf(error)}`));
170
+ }
171
+ }
172
+ return findings;
173
+ }
174
+
175
+ /**
176
+ * Every guard this app declares, as findings the step it rides on adds to its own. Typed as a
177
+ * `HostCheck` because that is exactly the seam's shape — a rule the repo enforces on itself,
178
+ * contributed to a step that already exists. A guard can never add, remove, reorder or skip a
179
+ * step, which is what keeps "green" meaning one thing (axiom 5) while the app still gets to make
180
+ * its own convention a build error.
181
+ */
182
+ export const guardFindings: HostCheck = async (root) => {
183
+ const findings: Finding[] = [];
184
+ for (const path of await guardPaths(root)) findings.push(...(await runGuard(root, path)));
185
+ return findings;
186
+ };