@ultimat3/cli 7.0.0 → 8.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 (67) hide show
  1. package/CLAUDE.md +15 -1
  2. package/README.md +8 -3
  3. package/package.json +25 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/bin.ts +6 -3
  6. package/src/ci-log.ts +0 -0
  7. package/src/cmd-db-backfill.ts +240 -0
  8. package/src/cmd-db-branch.ts +3 -2
  9. package/src/cmd-db.ts +35 -156
  10. package/src/cmd-deploy.ts +37 -3
  11. package/src/cmd-dev.ts +7 -1
  12. package/src/cmd-errors.ts +2 -3
  13. package/src/cmd-fix.ts +3 -3
  14. package/src/cmd-i18n.ts +67 -5
  15. package/src/cmd-jobs.ts +27 -4
  16. package/src/cmd-mcp.ts +18 -9
  17. package/src/cmd-new.ts +91 -4
  18. package/src/cmd-policy.ts +3 -2
  19. package/src/cmd-pr.ts +55 -4
  20. package/src/cmd-registries.ts +3 -2
  21. package/src/cmd-shot.ts +68 -6
  22. package/src/cmd-tasks.ts +9 -4
  23. package/src/cmd-verify.ts +47 -6
  24. package/src/dev-cache.ts +1 -1
  25. package/src/dev-lock.ts +124 -12
  26. package/src/dev-queue.ts +12 -7
  27. package/src/dev-replicator.ts +3 -7
  28. package/src/dev-roles-fixture.ts +1 -1
  29. package/src/dev-roles.ts +40 -8
  30. package/src/dev-runtime.ts +96 -4
  31. package/src/dev-sync.ts +9 -4
  32. package/src/dispatch.ts +35 -5
  33. package/src/drift.ts +52 -7
  34. package/src/error-codes.ts +5 -0
  35. package/src/framework-scope.ts +57 -5
  36. package/src/generate-kinds.ts +19 -1
  37. package/src/i18n-registration.ts +67 -4
  38. package/src/index.ts +1 -1
  39. package/src/jobs-report.ts +10 -13
  40. package/src/mcp-errors.ts +3 -0
  41. package/src/messages.ts +12 -0
  42. package/src/output.ts +22 -2
  43. package/src/parse.ts +81 -37
  44. package/src/realtime-browser-probe-fixture.ts +9 -0
  45. package/src/runtime-overrides.ts +11 -3
  46. package/src/shot-settle.ts +57 -0
  47. package/src/shot-verdict.ts +27 -4
  48. package/src/sync-authenticator.ts +86 -14
  49. package/src/templates/guard-bare-error.ts +122 -0
  50. package/src/templates/guard-raw-colour.ts +138 -0
  51. package/src/templates/guard-untranslated-string.ts +138 -0
  52. package/src/templates/guard-unzoned-date.ts +142 -0
  53. package/src/templates/index.ts +3 -0
  54. package/src/templates/island.ts +2 -1
  55. package/src/templates/route.ts +1 -1
  56. package/src/templates/scaffold-app.ts +3 -82
  57. package/src/templates/scaffold-container.ts +30 -4
  58. package/src/templates/scaffold-db-package.ts +14 -6
  59. package/src/templates/scaffold-docs.ts +24 -13
  60. package/src/templates/scaffold-entries.ts +131 -0
  61. package/src/templates/scaffold-guards.ts +26 -0
  62. package/src/templates/scaffold-repo.ts +37 -6
  63. package/src/test-select.ts +4 -3
  64. package/src/verify-run.ts +25 -3
  65. package/src/verify-step.ts +11 -2
  66. package/src/verify-tests.ts +11 -3
  67. package/src/write-line.ts +23 -5
package/src/drift.ts CHANGED
@@ -11,7 +11,9 @@
11
11
 
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { describeEntities } from '@ultimat3/entity';
14
15
  import { countDeclaredEntities } from './app-entities';
16
+ import { loadApp } from './app-load';
15
17
  // One declaration of where migrations live, and it belongs to the module that reads them —
16
18
  // `x db migrate` and this sidecar must never disagree about the directory they share.
17
19
  import { hashFileName, MIGRATIONS_DIR } from './migrations';
@@ -20,7 +22,48 @@ import type { Finding } from './output';
20
22
  export const DB_PACKAGE = join('packages', 'db');
21
23
  const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
22
24
 
23
- /** Content hash of the whole schema, order-independent per file path. */
25
+ /**
26
+ * Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
27
+ * BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
28
+ * otherwise move every app's hash and report drift over a framework upgrade nobody made.
29
+ */
30
+ function canonicalJson(value: unknown): string {
31
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
32
+ if (typeof value === 'object' && value !== null) {
33
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
34
+ a < b ? -1 : a > b ? 1 : 0,
35
+ );
36
+ return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
37
+ }
38
+ // `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
39
+ return JSON.stringify(value) ?? 'null';
40
+ }
41
+
42
+ /**
43
+ * What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
44
+ * see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
45
+ * merely re-exports it, so a column added there moved NO byte the glob reads: three generated
46
+ * entities and one migration reported clean, with every `.hash` sidecar identical. The registry is
47
+ * the same fact `x db gen` diffs (`describeEntities()`), so the check and the generator now read
48
+ * one schema instead of two.
49
+ *
50
+ * `loadApp` is what fills that registry, and it is called on every path rather than only where the
51
+ * caller happens to have loaded already: a hash computed against an EMPTY registry would differ
52
+ * from the one `x db gen` recorded with the app loaded, and every app would read as drifted.
53
+ * A module that will not import leaves the registry SHORT rather than raising — the stance
54
+ * `countDeclaredEntities` already documents — so `x doctor` still answers on the app it diagnoses.
55
+ */
56
+ async function declaredSchemaJson(root: string): Promise<string> {
57
+ await loadApp(root);
58
+ return canonicalJson(describeEntities());
59
+ }
60
+
61
+ /**
62
+ * Content hash of the whole schema: the entity registry first, then every non-test file under
63
+ * `packages/db/src`, order-independent per file path. Both halves, because they answer different
64
+ * questions — the registry is what reaches the database, and the glob is what catches a seed or a
65
+ * helper moving under it (which is what `reconcileSchemaHash` exists to re-record).
66
+ */
24
67
  export async function schemaHash(root: string): Promise<string> {
25
68
  const glob = new Bun.Glob(SCHEMA_GLOB);
26
69
  const paths: string[] = [];
@@ -29,6 +72,7 @@ export async function schemaHash(root: string): Promise<string> {
29
72
  }
30
73
  paths.sort();
31
74
  const hasher = new Bun.CryptoHasher('sha256');
75
+ hasher.update(await declaredSchemaJson(root));
32
76
  for (const path of paths) {
33
77
  hasher.update(path);
34
78
  hasher.update(await Bun.file(join(root, path)).text());
@@ -77,9 +121,10 @@ export interface HashReconciliation {
77
121
  }
78
122
 
79
123
  /**
80
- * Re-record the sidecar for a migration that is already the right one. `SCHEMA_GLOB` covers every
81
- * non-test file under `packages/db/src`, not only the ones that imply DDL, so editing a seed or a
82
- * helper moves the hash with no diff behind it — and `X_DB_DRIFT`'s `fix:` has to have somewhere to
124
+ * Re-record the sidecar for a migration that is already the right one. Neither half of the hash is
125
+ * DDL-only — `SCHEMA_GLOB` covers every non-test file under `packages/db/src`, and the registry
126
+ * carries invariants and tags a diff can leave empty — so an edit can move the hash with no
127
+ * statement behind it, and `X_DB_DRIFT`'s `fix:` has to have somewhere to
83
128
  * land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
84
129
  * move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
85
130
  * function decides only whether a write is needed.
@@ -107,9 +152,9 @@ export type DeclaredEntityCount = () => Promise<number>;
107
152
  * Empty result = no drift. A missing db package is not drift (an app may have no database yet);
108
153
  * a schema with no migration at all is — *provided* the app declares an entity for one to record.
109
154
  *
110
- * The entity count is read lazily and ONLY in that first branch, so an app past its first migration
111
- * pays nothing for it: every other path answers from file hashes alone, with no app load and no
112
- * database, which is what lets the gate run this in a CI with neither.
155
+ * The entity count is read lazily and ONLY in that first branch; the hash itself loads the app on
156
+ * every path, because the registry is half of what it covers. Still no database anywhere here,
157
+ * which is what lets the gate run this in a CI with nothing listening.
113
158
  */
114
159
  export async function checkSourceDrift(
115
160
  root: string,
@@ -56,6 +56,10 @@ export const CLI_OWNED_ERROR_CODES = [
56
56
  'X_ROLE_UNKNOWN',
57
57
  'X_PORT_INVALID',
58
58
  'X_DEV_ALREADY_RUNNING',
59
+ // The OTHER thing the preflight can find, and it was reported as the one above: an unreadable
60
+ // lock this process could not remove made `DevAlreadyRunningError` name THIS pid as the holder,
61
+ // so the remedy printed was `kill <self>` — unrunnable, and a cause that was simply untrue.
62
+ 'X_DEV_LOCK_UNREADABLE',
59
63
  // The boot's own consistency check. `startServices` captures the drivers it built, and
60
64
  // `loadApp` runs AFTER it — so an app module calling `setJobDriver(theirs)` moved the ambient
61
65
  // slot and left the captured object alone: every `handle.enqueue()` went to their queue while
@@ -177,6 +181,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
177
181
  X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
178
182
  X_PORT_INVALID: 'PORT is not a TCP port number',
179
183
  X_DEV_ALREADY_RUNNING: 'another x dev already owns this checkout',
184
+ X_DEV_LOCK_UNREADABLE: 'the dev lock file cannot be read or removed',
180
185
  X_RUNTIME_DRIVER_SPLIT: 'the ambient driver is not the one this process serves',
181
186
  X_GENERATE_CONFLICT: 'a generator would overwrite a file',
182
187
  X_PORT_IN_USE: 'the dev port is taken',
@@ -4,9 +4,10 @@
4
4
  // directory or they describe different builds.
5
5
 
6
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.
7
+ // directory that owns it, `basename` names the two segments the store layout is recognised by,
8
+ // and `existsSync` is what says which directory is really there.
8
9
  import { existsSync } from 'node:fs';
9
- import { dirname, join } from 'node:path';
10
+ import { basename, dirname, join } from 'node:path';
10
11
 
11
12
  /**
12
13
  * Deep enough for `src/index.ts` and for any entry an `exports` map could point at, shallow enough
@@ -14,6 +15,45 @@ import { dirname, join } from 'node:path';
14
15
  */
15
16
  const MAX_DEPTH = 6;
16
17
 
18
+ /** The two segments Bun's isolated layout is recognised by: `node_modules/.bun/<pkg>@<version>/`. */
19
+ const STORE_DIR = '.bun';
20
+ const NODE_MODULES = 'node_modules';
21
+
22
+ /**
23
+ * `node_modules/.bun/@ultimat3+core@7.0.0/node_modules/@ultimat3` → `node_modules/@ultimat3`.
24
+ *
25
+ * Bun's **isolated** layout gives every package its own store entry, and a store entry's scope
26
+ * directory holds exactly the one package it was created for. `Bun.resolveSync` follows the
27
+ * install's symlink into that store, so walking up from the resolved entry lands there rather
28
+ * than in the tree an app actually installed: measured on a fixture install, `x docs` saw
29
+ * **1** of 6 packages, and `x errors explain` answered *"nothing in the installed framework raises
30
+ * X_…"* — with `ok: true` — for 400 of 405 codes. A confident wrong answer, from a walk that had
31
+ * never looked at the app's own `node_modules`.
32
+ *
33
+ * The store is recognised by its own shape and never by an app root handed in from outside: the
34
+ * two callers here are a module-scope memo (`error-fixes.ts`) and a command that may run under
35
+ * `--cwd`, so a cwd-derived root would be wrong for one of them and absent for the other.
36
+ *
37
+ * `undefined` — leaving the caller on the resolved answer — whenever this is not a store path or
38
+ * the sibling scope directory is not there. A hoisted install already resolves to
39
+ * `node_modules/@ultimat3/core` and a workspace checkout to `packages/core`, and neither has a
40
+ * `.bun` above it.
41
+ */
42
+ function installedScopeFor(storeScope: string): string | undefined {
43
+ const scopeName = basename(storeScope);
44
+ let dir = storeScope;
45
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
46
+ const parent = dirname(dir);
47
+ if (parent === dir) return undefined;
48
+ if (basename(dir) === STORE_DIR && basename(parent) === NODE_MODULES) {
49
+ const candidate = join(parent, scopeName);
50
+ return existsSync(candidate) ? candidate : undefined;
51
+ }
52
+ dir = parent;
53
+ }
54
+ return undefined;
55
+ }
56
+
17
57
  /**
18
58
  * Resolved from the CLI's own dependency on `@ultimat3/core` rather than from the user's cwd:
19
59
  * these are the packages this `x` would actually run. Resolution follows the symlink, so a
@@ -29,18 +69,30 @@ const MAX_DEPTH = 6;
29
69
  * framework raises the code. Walking up from the entry to the directory that owns its
30
70
  * `package.json` depends on nothing but the entry that is already imported.
31
71
  *
72
+ * Following the symlink is also what makes the last step necessary rather than optional: under
73
+ * Bun's isolated layout the entry resolves *into the store*, whose scope directory holds one
74
+ * package. `installedScopeFor` is the correction, and it is a shape test on the path — never a
75
+ * `readdir` of the resolved package's parent, which is the read that reported one package as the
76
+ * whole framework.
77
+ *
78
+ * `resolveFrom` exists so a test can point this at a fixture install; nothing passes it in
79
+ * production, where the only defensible base is this module's own directory.
80
+ *
32
81
  * `undefined` means the CLI cannot see its own dependency, which is a broken install and not
33
82
  * merely an undocumented one; every caller reports that rather than answering emptily.
34
83
  */
35
- export function frameworkScopeDir(): string | undefined {
84
+ export function frameworkScopeDir(resolveFrom: string = import.meta.dir): string | undefined {
36
85
  let dir: string;
37
86
  try {
38
- dir = dirname(Bun.resolveSync('@ultimat3/core', import.meta.dir));
87
+ dir = dirname(Bun.resolveSync('@ultimat3/core', resolveFrom));
39
88
  } catch {
40
89
  return undefined;
41
90
  }
42
91
  for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
43
- if (existsSync(join(dir, 'package.json'))) return dirname(dir);
92
+ if (existsSync(join(dir, 'package.json'))) {
93
+ const scope = dirname(dir);
94
+ return installedScopeFor(scope) ?? scope;
95
+ }
44
96
  const parent = dirname(dir);
45
97
  if (parent === dir) return undefined;
46
98
  dir = parent;
@@ -2,6 +2,7 @@
2
2
  // because "what a generator emits" and "which spelling reaches it" are two jobs — and the file
3
3
  // that held both had reached the 500-line ceiling, one generator short of failing its own gate.
4
4
 
5
+ import { nearestName } from '@ultimat3/core';
5
6
  import { BadFlagError, MissingPositionalError, UnknownCommandError } from './errors';
6
7
  import type { Surface } from './templates';
7
8
 
@@ -42,13 +43,30 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
42
43
  });
43
44
  }
44
45
 
46
+ /**
47
+ * The generator a spelling reaches, or a refusal that leads with the one it is nearest.
48
+ *
49
+ * The lead used to be the literal `g resource`, whatever was typed: `x g rout x` — one edit from
50
+ * `route` — answered `fix: x g resource`, the WRONG PRIMITIVE, and one that refuses in turn
51
+ * because it carries no `<name>`. `nearestName` is what `parse.ts` already does with a mistyped
52
+ * command, over this file's own list.
53
+ *
54
+ * Two rules hold the fix line to a command that runs. A near miss is completed with that
55
+ * generator's own `EXAMPLE_NAME`, because `x g route <name>` pasted into a shell is a redirect.
56
+ * A word near NOTHING gets `x help g` rather than an invented lead — the same rule `parse.test.ts`
57
+ * pins for a command that resembles nothing, and the reason `nearestName` is never asked about an
58
+ * ABSENT kind: the empty string is within the cutoff of `job`, so `x g` would "suggest" a
59
+ * generator nobody typed.
60
+ */
45
61
  export function readKind(raw: string | undefined): Generator {
46
62
  const kinds: readonly string[] = GENERATORS;
47
63
  if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
64
+ const near = raw === undefined ? undefined : nearestName(raw, kinds);
65
+ const suggestion = GENERATORS.find((kind) => kind === near);
48
66
  throw new UnknownCommandError({
49
67
  path: `g ${raw ?? ''}`.trim(),
50
68
  known: GENERATORS,
51
- suggestion: 'g resource',
69
+ suggestion: suggestion === undefined ? 'help g' : `g ${suggestion} ${EXAMPLE_NAME[suggestion]}`,
52
70
  });
53
71
  }
54
72
 
@@ -12,6 +12,7 @@ import {
12
12
  catalogRegistrationGaps,
13
13
  catalogsNeverRegistered,
14
14
  catalogUnregistered,
15
+ pluralVariantsOf,
15
16
  registeredLocales,
16
17
  } from '@ultimat3/i18n';
17
18
  import { loadApp } from './app-load';
@@ -105,9 +106,68 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
105
106
  }
106
107
 
107
108
  /**
108
- * The file half: a key source uses that a locale's catalog does not define. Built here rather than
109
- * in `cmd-i18n.ts` because `x verify`'s `i18n` step reports the same finding, and a second
110
- * construction of it is two renderers of one fact waiting to drift.
109
+ * `⟦key⟧` — `@ultimat3/i18n`'s own loud miss, spelled ONCE for the whole CLI. `x i18n sync <default>`
110
+ * writes it and the two checks below refuse it, so a second spelling would be a placeholder one
111
+ * half of this package writes and the other half cannot see.
112
+ */
113
+ const MISS_OPEN = '\u27E6';
114
+ const MISS_CLOSE = '\u27E7';
115
+
116
+ /** What `x i18n sync` seeds a key with when there is no catalog above it to copy a value from. */
117
+ export const loudMiss = (key: string): string => `${MISS_OPEN}${key}${MISS_CLOSE}`;
118
+
119
+ /**
120
+ * A value that is a placeholder rather than a translation. Any `⟦…⟧`, not just `⟦<this key>⟧`:
121
+ * an author who renames a key and leaves the old marker behind still ships a placeholder.
122
+ */
123
+ export const isLoudMiss = (value: string | undefined): boolean =>
124
+ value?.startsWith(MISS_OPEN) === true && value.endsWith(MISS_CLOSE);
125
+
126
+ /**
127
+ * Whether `locale` answers `key` with a placeholder. Every spelling `definesKey` accepts is
128
+ * checked — `pluralVariantsOf` is `@ultimat3/i18n`'s own list, the same one `auditCatalogs` uses,
129
+ * so "defined" and "defined with a real string" can never disagree about which entries count.
130
+ * `some`, not `every`: a plural family with one untranslated category renders `⟦items_many⟧` on
131
+ * exactly the rows that hit it.
132
+ */
133
+ const answersWithPlaceholder = (catalog: Catalog, key: string): boolean =>
134
+ [key, ...pluralVariantsOf(key)].some(
135
+ (candidate) => Object.hasOwn(catalog, candidate) && isLoudMiss(catalog[candidate]),
136
+ );
137
+
138
+ /**
139
+ * The audit, with every placeholder counted as the missing key it stands in for.
140
+ *
141
+ * `auditCatalogs` asks `Object.hasOwn` and nothing else, so a key present with ANY value is not
142
+ * missing — including `⟦key⟧`, which is the value `x i18n sync <defaultLocale>` writes for every
143
+ * gap it closes. Without this, following `X_CATALOG_MISSING_KEYS`'s own `fix:` turns the `i18n`
144
+ * gate step green over strings no human has ever read: issue #249's ending, reached by running the
145
+ * command the error recommends. The hole predates the seeding — a hand-written `"TODO"` bought the
146
+ * same green — but one command now writes sixteen of them, so it is a hole with a shortcut to it.
147
+ *
148
+ * Applied HERE and not in `auditCatalogs`: `⟦…⟧` is what the CLI writes, and `@ultimat3/i18n`'s
149
+ * audit answering "is this key defined" is a different question from "is this app shippable".
150
+ */
151
+ export function withPlaceholdersMissing(
152
+ report: ExtractReport,
153
+ catalogs: Readonly<Record<Locale, Catalog>>,
154
+ ): ExtractReport {
155
+ const locales = report.locales.map((audit) => {
156
+ const catalog = catalogs[audit.locale] ?? {};
157
+ const known = new Set(audit.missing);
158
+ const placeheld = report.used.filter(
159
+ (key) => !known.has(key) && answersWithPlaceholder(catalog, key),
160
+ );
161
+ if (placeheld.length === 0) return audit;
162
+ return { ...audit, missing: [...audit.missing, ...placeheld].sort() };
163
+ });
164
+ return { ...report, locales, ok: locales.every((audit) => audit.missing.length === 0) };
165
+ }
166
+
167
+ /**
168
+ * The file half: a key source uses that a locale's catalog does not define, or defines with a
169
+ * placeholder. Built here rather than in `cmd-i18n.ts` because `x verify`'s `i18n` step reports
170
+ * the same finding, and a second construction of it is two renderers of one fact waiting to drift.
111
171
  */
112
172
  export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
113
173
  return report.locales
@@ -126,5 +186,8 @@ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
126
186
  export async function catalogFindings(root: string): Promise<readonly Finding[]> {
127
187
  const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
128
188
  const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
129
- return [...missingKeyFindings(report), ...registration.findings];
189
+ return [
190
+ ...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
191
+ ...registration.findings,
192
+ ];
130
193
  }
package/src/index.ts CHANGED
@@ -323,4 +323,4 @@ export type { WorkspaceNode, WorkspaceScan } from './workspace-graph';
323
323
  // nothing to read. `checkWorkspaceDependencies` stays internal — it is reached through
324
324
  // `x verify`, which is the one way a rule is enforced here.
325
325
  export { readWorkspaceGraph, scanWorkspaces } from './workspace-graph';
326
- export { writeLine } from './write-line';
326
+ export { writeErrorLine, writeLine } from './write-line';
@@ -18,23 +18,20 @@ import {
18
18
  inspectJob,
19
19
  inspectJobList,
20
20
  inspectQueues,
21
+ isJobState,
22
+ JOB_STATES,
21
23
  retryFromStep,
22
24
  } from '@ultimat3/jobs';
23
25
  import { BadFlagError, JobUnknownError } from './errors';
24
26
 
25
- /** Mirrors `JobState` from `@ultimat3/jobs`, which exports the type but no runtime list. */
26
- export const JOB_STATES: readonly JobState[] = [
27
- 'ready',
28
- 'delayed',
29
- 'running',
30
- 'suspended',
31
- 'done',
32
- 'failed',
33
- 'dead',
34
- ];
35
-
36
- const isJobState = (value: string): value is JobState =>
37
- (JOB_STATES as readonly string[]).includes(value);
27
+ /**
28
+ * The queue's own vocabulary, re-exported rather than restated — this file carried a copy of it,
29
+ * and the copy was one member short. `cancelled` shipped in `@ultimat3/jobs` and never here, so
30
+ * `x jobs cancel` created a state `x jobs ls --state cancelled` then refused to filter on: two
31
+ * commands of one CLI disagreeing about what a job can be. Kept on this module's surface because
32
+ * `index.ts` exports it from here.
33
+ */
34
+ export { JOB_STATES } from '@ultimat3/jobs';
38
35
 
39
36
  export function parseStateFlag(value: string | undefined): JobState | undefined {
40
37
  if (value === undefined) return undefined;
package/src/mcp-errors.ts CHANGED
@@ -102,6 +102,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
102
102
  X_PORT_IN_USE: 'x dev --port 3001 --json',
103
103
  X_DEV_ALREADY_RUNNING:
104
104
  'x dev --json # after stopping the x dev that already owns this checkout',
105
+ // The path, not `x dev`: the boot has already refused and rerunning it refuses again. The
106
+ // error's own `fix:` carries the resolved `.x/dev.lock`; this table cannot know the state dir.
107
+ X_DEV_LOCK_UNREADABLE: 'rm .x/dev.lock # then: x dev --json',
105
108
  // Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
106
109
  // so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
107
110
  // reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
package/src/messages.ts CHANGED
@@ -139,6 +139,11 @@ const CATALOG = {
139
139
  // the app has a schema no migration records and `x verify`'s drift step says so until it runs.
140
140
  'cli.new.done':
141
141
  'created {name} — next: cd {name} && bun install && x db gen "initial" && x db migrate && x dev',
142
+ // The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
143
+ // one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
144
+ // command is a broken one — the same split `Finding.fix` already makes.
145
+ 'cli.new.wrote': ' {count} files in {dir}',
146
+ 'cli.new.noRepository': ' no repository — {problem}',
142
147
  'cli.policy.count':
143
148
  '{permissions} permission(s), {roles} role(s), {enforced} enforced by a declaration',
144
149
  // One row per (declaration, actor) pair, never per role: a permission two declarations enforce
@@ -178,6 +183,9 @@ const CATALOG = {
178
183
  'cli.shot.canvasUnreadable': ' canvas unreadable — {bytes} byte(s), not a decodable image',
179
184
  'cli.shot.islands': ' islands {booted} of {declared} mounted ({strategies})',
180
185
  'cli.shot.islandsUnknown': ' islands not counted — the page answered no probe',
186
+ // The failure a picture cannot show and a console count cannot see: a rejected mount promise
187
+ // calls no console method, so the FIRST one is named here rather than left to verdict.json.
188
+ 'cli.shot.islandFailed': '{route}: {failed} island(s) failed to mount — {island}: {message}',
181
189
  'cli.shot.network': ' network {requests} request(s), {refused} refused, {dropped} dropped',
182
190
  'cli.shot.console': ' console {level}: {text}',
183
191
  'cli.shot.threw': '{route}: {thrown} uncaught exception(s) — {first}',
@@ -228,6 +236,10 @@ const CATALOG = {
228
236
  'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
229
237
  'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
230
238
  'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
239
+ // The banner a `--only` run carries, in front of whichever summary above it renders. Rendered
240
+ // output, so it lives here — `data.notAGateRun` is the machine marker, and a reader testing for
241
+ // one narrowed run reads that boolean rather than substring-matching this line.
242
+ 'cli.verify.notAGateRun': 'NOT A GATE RUN — {summary}',
231
243
  'cli.verify.pass': 'all {count} steps passed in {ms}ms',
232
244
  'cli.verify.fail': '{failed} of {count} steps failed',
233
245
  // A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
package/src/output.ts CHANGED
@@ -52,6 +52,17 @@ export interface CommandResult {
52
52
  * could show is how the two drift.
53
53
  */
54
54
  readonly hold?: () => Promise<void>;
55
+ /**
56
+ * Which fd this result is written to. `stdout` for every command, absent included — and
57
+ * `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
58
+ * --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
59
+ * 13 tools` line printed after the loop exits is a malformed frame to whatever is reading.
60
+ *
61
+ * Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
62
+ * where a rendered line goes, and a payload that also claimed it would be a second answer to a
63
+ * question `dispatch` has already answered by choosing the sink.
64
+ */
65
+ readonly stream?: 'stdout' | 'stderr';
55
66
  }
56
67
 
57
68
  export interface UltimateErrorShape {
@@ -158,13 +169,22 @@ export function renderHuman(result: CommandResult, verbose = false): string {
158
169
  for (const step of result.steps ?? []) {
159
170
  out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}`);
160
171
  for (const finding of step.findings) out.push(renderFinding(finding, ' '));
172
+ // NOT escaped, and that is the one exception: `output` is this process's own captured
173
+ // subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
174
+ // already split on its real newlines rather than carrying them inside one entry.
161
175
  if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
162
176
  for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
163
177
  }
164
178
  }
165
- for (const line of result.lines ?? []) out.push(line);
179
+ // Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
180
+ // where text the CLI did not write reaches fd 1: a GitHub review body (`x pr`), a CI log tail
181
+ // (`x ci`), a page's own console (`x shot`). It was emitted verbatim, so an ESC byte in a PR
182
+ // comment retitled the window and cleared the screen, and a newline in one entry printed a
183
+ // second line a reader — or the agent this command exists for — takes for the renderer's own.
184
+ for (const line of result.lines ?? []) out.push(singleLine(line));
166
185
  for (const finding of result.findings ?? []) out.push(renderFinding(finding, ' '));
167
- out.push(`${result.ok ? '✓' : '✗'} ${result.summary}`);
186
+ // The summary is foreign too: `cli.shot.threw` interpolates a page's own error message.
187
+ out.push(`${result.ok ? '✓' : '✗'} ${singleLine(result.summary)}`);
168
188
  return out.join('\n');
169
189
  }
170
190