@ultimat3/cli 1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,141 @@
1
+ // The `/_x` timeline panel's source: core's own spans, recorded in this process. `x dev` installs
2
+ // the exporter at boot, so every `withSpan` the framework already opens — the request, the action,
3
+ // the query, the cache bust — arrives here and is assembled back into the tree the flame draws.
4
+ // A tracer written here instead would be a second one, disagreeing with `x trace` by construction.
5
+
6
+ import type { RequestTrace, SpanKind, TimelineSpan } from '@ultimat3/admin/dev';
7
+ import type { ReadableSpan, SpanExporter } from '@ultimat3/core';
8
+
9
+ /** Traces retained. A dev panel shows recent requests; it does not page through history. */
10
+ const DEFAULT_LIMIT = 50;
11
+
12
+ export interface TraceRecorder {
13
+ /** Hand this to `configureTelemetry({ exporter })`. */
14
+ readonly exporter: SpanExporter;
15
+ /** Complete request traces, newest first. */
16
+ traces(): readonly RequestTrace[];
17
+ reset(): void;
18
+ }
19
+
20
+ /**
21
+ * Span name → the vocabulary the panel renders. The framework's span names are prefixed by the
22
+ * subsystem that opened them (`action.publishPost`, `query.feed`, `cache.invalidate`), so the
23
+ * prefix IS the kind — no registry of names to keep in step with the packages that emit them.
24
+ */
25
+ const KIND_BY_PREFIX: readonly (readonly [string, SpanKind])[] = [
26
+ ['query.', 'sql'],
27
+ ['cache.', 'cache'],
28
+ ['action.', 'action'],
29
+ ['policy.', 'policy'],
30
+ ['job.', 'job'],
31
+ ['render.', 'render'],
32
+ ];
33
+
34
+ function kindOf(name: string, isRoot: boolean): SpanKind {
35
+ if (isRoot) return 'http';
36
+ for (const [prefix, kind] of KIND_BY_PREFIX) {
37
+ if (name.startsWith(prefix)) return kind;
38
+ }
39
+ // Everything the framework opens carries a prefix; anything else came from app code, which is
40
+ // work the request did — filed under `action` rather than dropped from the flame it happened in.
41
+ return 'action';
42
+ }
43
+
44
+ const attrString = (span: ReadableSpan, key: string): string | undefined => {
45
+ const value = span.attributes[key];
46
+ return typeof value === 'string' ? value : undefined;
47
+ };
48
+
49
+ const attrNumber = (span: ReadableSpan, key: string): number | undefined => {
50
+ const value = span.attributes[key];
51
+ return typeof value === 'number' ? value : undefined;
52
+ };
53
+
54
+ /**
55
+ * The pipeline names its root span `<METHOD> <path>` and tags it with `http.*`. Reading the
56
+ * attributes first and the name only as a fallback keeps the panel working against a root span
57
+ * from any host, while still preferring the facts the pipeline states outright.
58
+ */
59
+ function requestFacts(root: ReadableSpan): { method: string; path: string } {
60
+ const [namedMethod = '', namedPath = ''] = root.name.split(' ');
61
+ return {
62
+ method: attrString(root, 'http.method') ?? namedMethod,
63
+ path: attrString(root, 'http.route') ?? namedPath,
64
+ };
65
+ }
66
+
67
+ const isHttpRoot = (span: ReadableSpan): boolean =>
68
+ span.parentSpanId === undefined &&
69
+ (span.attributes['http.request_id'] !== undefined || /^[A-Z]+ \//.test(span.name));
70
+
71
+ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTrace {
72
+ const { method, path } = requestFacts(root);
73
+ const origin = root.startedAt;
74
+ return {
75
+ requestId: attrString(root, 'http.request_id') ?? root.context.traceId,
76
+ method,
77
+ path,
78
+ status: attrNumber(root, 'http.status_code') ?? 0,
79
+ startedAt: new Date(origin).toISOString(),
80
+ totalMs: root.durationMs,
81
+ spans: spans
82
+ .map(
83
+ (span): TimelineSpan => ({
84
+ id: span.context.spanId,
85
+ // The root anchors the flame at depth 0, so its parent is null even though the span
86
+ // itself may have arrived with a parent from an inbound `traceparent`.
87
+ parentId: span === root ? null : (span.parentSpanId ?? null),
88
+ kind: kindOf(span.name, span === root),
89
+ name: span.name,
90
+ startMs: Math.max(0, span.startedAt - origin),
91
+ durationMs: span.durationMs,
92
+ // The panel counts repeats of `detail` to find the N+1, and a framework span's name is
93
+ // exactly the identity that repeats — `query.feed` twice is two reads of one query.
94
+ detail: attrString(span, 'db.statement') ?? span.name,
95
+ }),
96
+ )
97
+ .sort((a, b) => a.startMs - b.startMs),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Spans end innermost-first, so a trace is only whole once its root arrives — which is also the
103
+ * moment the request finished. Grouping by trace id and reporting only groups that have an HTTP
104
+ * root is what keeps a half-finished request, and a job's spans, out of a panel about requests.
105
+ */
106
+ export function createTraceRecorder(options: { limit?: number } = {}): TraceRecorder {
107
+ const limit = options.limit ?? DEFAULT_LIMIT;
108
+ // Insertion-ordered: the oldest trace id is the first key, which is the one eviction drops.
109
+ const byTrace = new Map<string, ReadableSpan[]>();
110
+
111
+ const record = (span: ReadableSpan): void => {
112
+ const traceId = span.context.traceId;
113
+ const spans = byTrace.get(traceId);
114
+ if (spans === undefined) {
115
+ byTrace.set(traceId, [span]);
116
+ // Bounded by trace, not by span: dropping half a request would leave a flame with holes.
117
+ while (byTrace.size > limit) {
118
+ const oldest = byTrace.keys().next();
119
+ if (oldest.done === true) break;
120
+ byTrace.delete(oldest.value);
121
+ }
122
+ return;
123
+ }
124
+ spans.push(span);
125
+ };
126
+
127
+ return {
128
+ exporter: { export: record },
129
+ traces(): readonly RequestTrace[] {
130
+ const traces: RequestTrace[] = [];
131
+ for (const spans of byTrace.values()) {
132
+ const root = spans.find(isHttpRoot);
133
+ if (root !== undefined) traces.push(toTrace(root, spans));
134
+ }
135
+ return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
136
+ },
137
+ reset(): void {
138
+ byTrace.clear();
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,98 @@
1
+ // The one place the CLI does I/O: parse, run, render, exit. Commands return data; this decides
2
+ // whether it is printed as text or JSON and what the process exits with. Errors take the same
3
+ // path as results, so a failure is machine-readable exactly like a success.
4
+
5
+ import { isAbsolute, resolve } from 'node:path';
6
+ import { requireBunVersion } from './app-root';
7
+ import { createHelpCommand } from './cmd-help';
8
+ import type { CommandContext } from './command';
9
+ import { UnknownCommandError } from './errors';
10
+ import type { Runner } from './exec';
11
+ import { exec } from './exec';
12
+ import type { CommandResult } from './output';
13
+ import { exitCodeFor, findingFrom, render } from './output';
14
+ import type { ParsedArgs } from './parse';
15
+ import { parseArgs } from './parse';
16
+ import { commandFor, SPECS } from './registry';
17
+
18
+ export interface DispatchOptions {
19
+ readonly argv: readonly string[];
20
+ readonly cwd: string;
21
+ readonly env: Readonly<Record<string, string | undefined>>;
22
+ readonly bunVersion: string;
23
+ readonly runner?: Runner;
24
+ readonly write: (line: string) => void;
25
+ }
26
+
27
+ const resolveCwd = (cwd: string, flag: string | undefined): string => {
28
+ if (flag === undefined) return cwd;
29
+ return isAbsolute(flag) ? flag : resolve(cwd, flag);
30
+ };
31
+
32
+ const errorResult = (command: string, error: unknown): CommandResult => ({
33
+ ok: false,
34
+ command,
35
+ summary: 'command failed',
36
+ findings: [findingFrom(error)],
37
+ exitCode: 1,
38
+ });
39
+
40
+ /**
41
+ * Returns the exit code instead of calling process.exit, so a test can drive the whole CLI end to
42
+ * end without terminating the test runner.
43
+ */
44
+ export async function dispatch(options: DispatchOptions): Promise<number> {
45
+ let args: ParsedArgs;
46
+ try {
47
+ requireBunVersion(options.bunVersion);
48
+ args = parseArgs(options.argv, SPECS);
49
+ } catch (error) {
50
+ const result = errorResult('x', error);
51
+ options.write(render(result, options.argv.includes('--json')));
52
+ return 1;
53
+ }
54
+
55
+ const command = commandFor(args.command);
56
+ if (command === undefined) {
57
+ const result = errorResult(
58
+ args.command,
59
+ new UnknownCommandError({
60
+ path: args.command,
61
+ known: SPECS.map((spec) => spec.name),
62
+ }),
63
+ );
64
+ options.write(render(result, args.json));
65
+ return 1;
66
+ }
67
+
68
+ // `--help` on any command is answered by help itself, never by the command's own branch.
69
+ const target = args.help ? createHelpCommand(() => SPECS) : command;
70
+ const helpArgs: ParsedArgs = args.help
71
+ ? { ...args, command: 'help', positionals: [args.command] }
72
+ : args;
73
+
74
+ const ctx: CommandContext = {
75
+ args: helpArgs,
76
+ cwd: resolveCwd(
77
+ options.cwd,
78
+ typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
79
+ ),
80
+ runner: options.runner ?? exec,
81
+ env: options.env,
82
+ bunVersion: options.bunVersion,
83
+ };
84
+
85
+ try {
86
+ const result = await target.run(ctx);
87
+ options.write(render(result, args.json, args.flags.get('verbose') === true));
88
+ // `x dev` and `x mcp serve --transport http` are still listening here: report first, so the
89
+ // url is on stdout the moment it is reachable, then stay in the process until the drain that
90
+ // stops them. Without this the exit code below is what takes the server down.
91
+ await result.hold?.();
92
+ return exitCodeFor(result);
93
+ } catch (error) {
94
+ const result = errorResult(args.command, error);
95
+ options.write(render(result, args.json));
96
+ return 1;
97
+ }
98
+ }
package/src/drift.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Migration drift detection. `x db gen` records the hash of the app's entity schema next to the
2
+ // migration it produced; drift is "the schema hashes to something no migration recorded". The
3
+ // hash file is committed beside the migration, so a fresh clone can detect drift with no local
4
+ // state and CI needs no database to answer the question.
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import type { Finding } from './output';
9
+
10
+ export const DB_PACKAGE = join('packages', 'db');
11
+ export const MIGRATIONS_DIR = join(DB_PACKAGE, 'migrations');
12
+ const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
13
+
14
+ /** Content hash of the whole schema, order-independent per file path. */
15
+ export async function schemaHash(root: string): Promise<string> {
16
+ const glob = new Bun.Glob(SCHEMA_GLOB);
17
+ const paths: string[] = [];
18
+ for await (const path of glob.scan({ cwd: root, absolute: false })) {
19
+ if (!path.includes('.test.')) paths.push(path);
20
+ }
21
+ paths.sort();
22
+ const hasher = new Bun.CryptoHasher('sha256');
23
+ for (const path of paths) {
24
+ hasher.update(path);
25
+ hasher.update(await Bun.file(join(root, path)).text());
26
+ }
27
+ return hasher.digest('hex').slice(0, 16);
28
+ }
29
+
30
+ export interface MigrationRecord {
31
+ readonly file: string;
32
+ readonly hash: string;
33
+ }
34
+
35
+ /** Every `<n>_<name>.hash` sidecar, sorted by filename so the last entry is the newest. */
36
+ export async function recordedHashes(root: string): Promise<readonly MigrationRecord[]> {
37
+ const dir = join(root, MIGRATIONS_DIR);
38
+ if (!existsSync(dir)) return [];
39
+ const glob = new Bun.Glob('*.hash');
40
+ const files: string[] = [];
41
+ for await (const file of glob.scan({ cwd: dir, absolute: false })) files.push(file);
42
+ files.sort();
43
+ const out: MigrationRecord[] = [];
44
+ for (const file of files) {
45
+ out.push({ file, hash: (await Bun.file(join(dir, file)).text()).trim() });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export async function writeSchemaHash(root: string, migrationName: string): Promise<string> {
51
+ const hash = await schemaHash(root);
52
+ await Bun.write(join(root, MIGRATIONS_DIR, `${migrationName}.hash`), `${hash}\n`);
53
+ return hash;
54
+ }
55
+
56
+ /**
57
+ * Empty result = no drift. A missing db package is not drift (an app may have no database yet);
58
+ * a schema with no migration at all is.
59
+ */
60
+ export async function checkDrift(root: string): Promise<readonly Finding[]> {
61
+ if (!existsSync(join(root, DB_PACKAGE))) return [];
62
+ const current = await schemaHash(root);
63
+ const records = await recordedHashes(root);
64
+ const latest = records.at(-1);
65
+ if (latest === undefined) {
66
+ return [
67
+ {
68
+ code: 'X_DB_DRIFT',
69
+ cause: 'packages/db has a schema but no migration recorded it',
70
+ fix: 'x db gen "initial"',
71
+ docs: 'https://ultimate.dev/errors/X_DB_DRIFT',
72
+ at: MIGRATIONS_DIR,
73
+ },
74
+ ];
75
+ }
76
+ if (records.some((record) => record.hash === current)) return [];
77
+ return [
78
+ {
79
+ code: 'X_DB_DRIFT',
80
+ cause: `schema hashes to ${current}, newest migration ${latest.file} recorded ${latest.hash}`,
81
+ fix: 'x db gen "describe the change"',
82
+ docs: 'https://ultimate.dev/errors/X_DB_DRIFT',
83
+ at: `${DB_PACKAGE}/src`,
84
+ },
85
+ ];
86
+ }
@@ -0,0 +1,156 @@
1
+ // Every framework package's error codes, present in this process before `x errors` answers.
2
+ // A package registers its titles when it is imported, and the CLI only imports the packages its
3
+ // commands actually need — so without this, `x errors explain X_UNAUTHENTICATED` answered "not a
4
+ // registered error code" for a code the framework throws on every unauthenticated request.
5
+
6
+ import { listErrorCodes, registerErrorCodes } from '@ultimat3/core';
7
+ import { SCHEMA_ERROR_CODES } from '@ultimat3/schema';
8
+ import type { Finding } from './output';
9
+ import { findingFrom } from './output';
10
+
11
+ /**
12
+ * Every `@ultimat3/*` package that owns `X_*` codes, `cli` excluded — `errors.ts` registers its
13
+ * own at import. Importing one already in the graph is a module-cache hit, so the list needs no
14
+ * knowledge of which commands pulled what. `error-catalog.test.ts` asserts it against the
15
+ * workspace, which is what stops a new package from silently missing its codes.
16
+ */
17
+ export const CATALOG_PACKAGES = [
18
+ '@ultimat3/action',
19
+ '@ultimat3/admin',
20
+ '@ultimat3/ai',
21
+ '@ultimat3/auth',
22
+ '@ultimat3/cache',
23
+ '@ultimat3/core',
24
+ '@ultimat3/db',
25
+ '@ultimat3/entity',
26
+ '@ultimat3/http',
27
+ '@ultimat3/i18n',
28
+ '@ultimat3/jobs',
29
+ '@ultimat3/mail',
30
+ '@ultimat3/manifest',
31
+ '@ultimat3/mcp',
32
+ '@ultimat3/money',
33
+ '@ultimat3/policy',
34
+ '@ultimat3/pwa',
35
+ '@ultimat3/query',
36
+ '@ultimat3/realtime',
37
+ '@ultimat3/render',
38
+ '@ultimat3/schema',
39
+ '@ultimat3/seo',
40
+ '@ultimat3/storage',
41
+ '@ultimat3/testing',
42
+ '@ultimat3/time',
43
+ '@ultimat3/ui',
44
+ ] as const;
45
+
46
+ export interface ErrorCatalog {
47
+ /** Packages whose codes are now registered. */
48
+ readonly loaded: readonly string[];
49
+ /**
50
+ * Packages this process could not *resolve*, so their codes are absent from the answer. The one
51
+ * tolerated case is the optional host: `@ultimat3/ui` and `@ultimat3/admin` reach for a JSX
52
+ * runtime an app has and a bare CLI process does not, and a list silently missing their codes is
53
+ * worse than one that says which packages are missing. A package that resolved and then threw is
54
+ * a defect, not a host gap, and goes to `failed`.
55
+ */
56
+ readonly unavailable: readonly string[];
57
+ /**
58
+ * Packages that resolved and threw while initializing — a duplicate code, an invalid
59
+ * registration, a module that cannot evaluate. Carried with the thrown error's own code, cause
60
+ * and fix so `x errors` reports them as findings: reporting a package defect as merely
61
+ * "unavailable" is a partial catalog with no cause and nothing to run.
62
+ */
63
+ readonly failed: readonly Finding[];
64
+ }
65
+
66
+ let cached: Promise<ErrorCatalog> | undefined;
67
+ let schemaRegistered = false;
68
+
69
+ /**
70
+ * `@ultimat3/schema` is tier 0 alongside `core`, so it cannot register its own codes — it exports
71
+ * the declarations and names the CLI as the package that may import both tiers. This is that, in
72
+ * one unguarded call: a `hasErrorCode()` skip would swallow the exact collision
73
+ * `registerErrorCodes` raises `X_ERROR_CODE_DUPLICATE` for, and `x errors` would then explain a
74
+ * schema code with whatever title the package that claimed it first gave it. Once per process,
75
+ * because the code registry is process-global while this module's cache is not.
76
+ */
77
+ function registerSchemaCodes(): void {
78
+ if (schemaRegistered) return;
79
+ schemaRegistered = true;
80
+ registerErrorCodes(SCHEMA_ERROR_CODES);
81
+ }
82
+
83
+ /**
84
+ * Bun reports an unresolvable specifier as a `ResolveMessage` carrying `ERR_MODULE_NOT_FOUND` —
85
+ * the host gap. Anything else escaped the package's own module evaluation and is its defect.
86
+ */
87
+ const isUnresolved = (thrown: unknown): boolean =>
88
+ typeof thrown === 'object' &&
89
+ thrown !== null &&
90
+ 'code' in thrown &&
91
+ thrown.code === 'ERR_MODULE_NOT_FOUND';
92
+
93
+ /** The package's own error, named and located, so the report says what broke and what to run. */
94
+ function initFailure(specifier: string, thrown: unknown): Finding {
95
+ const finding = findingFrom(thrown);
96
+ return {
97
+ ...finding,
98
+ cause: `${specifier} failed to initialize: ${finding.cause}`,
99
+ at: specifier,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Test seam: the catalog over an injected loader. `loadErrorCatalog()` hands it `import()`; a test
105
+ * hands it one that throws, which is the only way to reach the failure paths in a repo where every
106
+ * package initializes.
107
+ */
108
+ export async function buildErrorCatalog(
109
+ load: (specifier: string) => Promise<unknown>,
110
+ ): Promise<ErrorCatalog> {
111
+ const loaded: string[] = [];
112
+ const unavailable: string[] = [];
113
+ const failed: Finding[] = [];
114
+ await Promise.all(
115
+ CATALOG_PACKAGES.map(async (specifier) => {
116
+ try {
117
+ await load(specifier);
118
+ loaded.push(specifier);
119
+ } catch (thrown) {
120
+ if (isUnresolved(thrown)) unavailable.push(specifier);
121
+ else failed.push(initFailure(specifier, thrown));
122
+ }
123
+ }),
124
+ );
125
+ return {
126
+ loaded: loaded.sort(),
127
+ unavailable: unavailable.sort(),
128
+ failed: failed.sort((a, b) => (a.at ?? '').localeCompare(b.at ?? '')),
129
+ };
130
+ }
131
+
132
+ async function importAll(): Promise<ErrorCatalog> {
133
+ registerSchemaCodes();
134
+ return buildErrorCatalog((specifier) => import(specifier));
135
+ }
136
+
137
+ /** Memoised: the registry is process-global, so the imports are worth paying for exactly once. */
138
+ export function loadErrorCatalog(): Promise<ErrorCatalog> {
139
+ cached ??= importAll();
140
+ return cached;
141
+ }
142
+
143
+ /** Test seam — the counterpart to every other reset in this package. */
144
+ export function resetErrorCatalog(): void {
145
+ cached = undefined;
146
+ }
147
+
148
+ /**
149
+ * Every code `x errors explain` can answer for, as a set. The `errors` step checks the reference
150
+ * page against exactly this: a documented code missing from here is one an agent can read but not
151
+ * look up. Loads the catalog first, so the answer does not depend on which commands ran before it.
152
+ */
153
+ export async function registeredErrorCodes(): Promise<ReadonlySet<string>> {
154
+ await loadErrorCatalog();
155
+ return new Set(listErrorCodes().map((entry) => entry.code));
156
+ }