@ultimat3/cli 1.2.0 → 2.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.
- package/CLAUDE.md +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +83 -16
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +165 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +84 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +4 -3
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +170 -10
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/errors.ts
CHANGED
|
@@ -1,136 +1,8 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
/** Codes this package declares and owns. */
|
|
7
|
-
export const CLI_OWNED_ERROR_CODES = [
|
|
8
|
-
'X_CLI_UNKNOWN_COMMAND',
|
|
9
|
-
'X_CLI_BAD_FLAG',
|
|
10
|
-
'X_VERIFY_FAILED',
|
|
11
|
-
'X_NOT_IN_APP',
|
|
12
|
-
'X_BUN_VERSION',
|
|
13
|
-
'X_TEST_NO_FILES',
|
|
14
|
-
'X_TEST_SHARD_FAILED',
|
|
15
|
-
'X_SCAFFOLD_PATH_ESCAPE',
|
|
16
|
-
'X_GENERATE_JSON_INVALID',
|
|
17
|
-
'X_APP_PACKAGE_INVALID',
|
|
18
|
-
'X_ERROR_CODE_UNKNOWN',
|
|
19
|
-
'X_DECLARATION_UNKNOWN',
|
|
20
|
-
'X_JOB_UNKNOWN',
|
|
21
|
-
'X_FIX_TARGET_UNKNOWN',
|
|
22
|
-
'X_ERROR_FIX_INVALID',
|
|
23
|
-
'X_ERROR_CODE_UNDOCUMENTED',
|
|
24
|
-
'X_ERROR_CODE_UNREGISTERED',
|
|
25
|
-
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
26
|
-
// `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
|
|
27
|
-
// carries an `X_*` code to the same reader a throw does; the registry is what makes that code
|
|
28
|
-
// explainable, unique and documented-or-fail, so a code the CLI emits is a code the CLI owns.
|
|
29
|
-
'X_CLI_UNEXPECTED',
|
|
30
|
-
'X_TYPECHECK_FAILED',
|
|
31
|
-
'X_LINT_FAILED',
|
|
32
|
-
'X_TEST_FAILED',
|
|
33
|
-
'X_FILE_TOO_LONG',
|
|
34
|
-
'X_PACKAGE_SHAPE',
|
|
35
|
-
'X_RELEASE_VERSION_SKEW',
|
|
36
|
-
'X_MANIFEST_STALE',
|
|
37
|
-
'X_BUDGET_UNMEASURED',
|
|
38
|
-
'X_BUILD_FAILED',
|
|
39
|
-
'X_BUILD_ENTRY_MISSING',
|
|
40
|
-
'X_DEPLOY_FAILED',
|
|
41
|
-
// The two the container's own environment can get wrong. A PaaS injects `PORT` and a supervisor
|
|
42
|
-
// injects `ROLE`; both arrive as strings from outside the app, so both are validated at boot
|
|
43
|
-
// rather than defaulted — a web role that quietly bound 3000 when the platform said 8080 fails
|
|
44
|
-
// its health check with nothing in the log that names the cause.
|
|
45
|
-
'X_ROLE_UNKNOWN',
|
|
46
|
-
'X_PORT_INVALID',
|
|
47
|
-
'X_GENERATE_CONFLICT',
|
|
48
|
-
'X_PORT_IN_USE',
|
|
49
|
-
'X_DB_GEN_FAILED',
|
|
50
|
-
'X_DB_MIGRATE_FAILED',
|
|
51
|
-
'X_DB_BRANCH_FAILED',
|
|
52
|
-
'X_DB_STUDIO_FAILED',
|
|
53
|
-
// The five app-surface boundary codes. `@ultimat3/render` owns the *rule* (`checkSurfaceBoundary`)
|
|
54
|
-
// and the CLI owns the diagnostic, because `x verify` and `x fix boundary` are the two commands
|
|
55
|
-
// that report it — see `app-boundaries.ts`, which holds the one rule-to-code table.
|
|
56
|
-
'X_BOUNDARY_SITE_TO_APP',
|
|
57
|
-
'X_BOUNDARY_SHARED_LEAF',
|
|
58
|
-
'X_BOUNDARY_APP_TO_API',
|
|
59
|
-
'X_BOUNDARY_ROUTE_TO_DB',
|
|
60
|
-
'X_BOUNDARY_SERVICE_TO_HTTP',
|
|
61
|
-
] as const;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — `CliNotImplementedError` and every planned command
|
|
65
|
-
* throw it, and none of them may declare a title for it. The CLI is the process that imports every
|
|
66
|
-
* package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
|
|
67
|
-
* order rather than by ownership.
|
|
68
|
-
*/
|
|
69
|
-
export const CLI_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
70
|
-
|
|
71
|
-
/** Every code the CLI can throw: the ones it owns plus the one it borrows. */
|
|
72
|
-
export const CLI_ERROR_CODES = [...CLI_OWNED_ERROR_CODES, ...CLI_BORROWED_ERROR_CODES] as const;
|
|
73
|
-
|
|
74
|
-
export type CliOwnedErrorCode = (typeof CLI_OWNED_ERROR_CODES)[number];
|
|
75
|
-
export type CliErrorCode = (typeof CLI_ERROR_CODES)[number];
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Registered titles, so `x errors list` enumerates the CLI's codes alongside every other
|
|
79
|
-
* package's instead of leaving a hole an agent has to read source to fill. Typed over
|
|
80
|
-
* `CliOwnedErrorCode`, so adding a code without a title is a build error.
|
|
81
|
-
*/
|
|
82
|
-
export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
83
|
-
X_CLI_UNKNOWN_COMMAND: 'not a command in the registry',
|
|
84
|
-
X_CLI_BAD_FLAG: 'unknown flag, missing value, or a value the command refuses',
|
|
85
|
-
X_VERIFY_FAILED: 'at least one x verify step failed',
|
|
86
|
-
X_NOT_IN_APP: 'the command needs an app root and found none',
|
|
87
|
-
X_BUN_VERSION: 'Bun is older than the framework floor',
|
|
88
|
-
X_TEST_NO_FILES: 'the test selection matched no files',
|
|
89
|
-
X_TEST_SHARD_FAILED: 'a test shard exited non-zero',
|
|
90
|
-
X_SCAFFOLD_PATH_ESCAPE: 'a generated path resolves outside the directory it is written into',
|
|
91
|
-
X_GENERATE_JSON_INVALID: "a generator's own merge: 'json' output does not parse as a JSON object",
|
|
92
|
-
X_APP_PACKAGE_INVALID: "the app's package.json supplies no name and version",
|
|
93
|
-
X_ERROR_CODE_UNKNOWN: 'no package registered this error code',
|
|
94
|
-
X_DECLARATION_UNKNOWN: 'no declaration with this name is registered',
|
|
95
|
-
X_JOB_UNKNOWN: 'the queue holds no job with this id',
|
|
96
|
-
X_FIX_TARGET_UNKNOWN: 'the named file is not one of the app source files',
|
|
97
|
-
X_ERROR_FIX_INVALID: "an error's fix line is not a runnable instruction",
|
|
98
|
-
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
99
|
-
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
100
|
-
X_CLI_UNEXPECTED: 'the CLI itself failed',
|
|
101
|
-
X_TYPECHECK_FAILED: 'tsc failed',
|
|
102
|
-
X_LINT_FAILED: 'Biome failed',
|
|
103
|
-
X_TEST_FAILED: 'a test type failed',
|
|
104
|
-
X_FILE_TOO_LONG: 'a source file is over 500 lines',
|
|
105
|
-
X_PACKAGE_SHAPE: 'a workspace package is missing a contract file',
|
|
106
|
-
X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
|
|
107
|
-
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
108
|
-
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
109
|
-
X_BUILD_FAILED: 'x build failed',
|
|
110
|
-
X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
|
|
111
|
-
X_DEPLOY_FAILED: 'a deploy step failed',
|
|
112
|
-
X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
|
|
113
|
-
X_PORT_INVALID: 'PORT is not a TCP port number',
|
|
114
|
-
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
115
|
-
X_PORT_IN_USE: 'the dev port is taken',
|
|
116
|
-
X_DB_GEN_FAILED: 'x db gen failed',
|
|
117
|
-
X_DB_MIGRATE_FAILED: 'x db migrate failed',
|
|
118
|
-
X_DB_BRANCH_FAILED: 'an x db branch step failed',
|
|
119
|
-
X_DB_STUDIO_FAILED: 'x db studio failed',
|
|
120
|
-
X_BOUNDARY_SITE_TO_APP: 'site/ imported app/',
|
|
121
|
-
X_BOUNDARY_SHARED_LEAF: 'shared/ imported a surface',
|
|
122
|
-
X_BOUNDARY_APP_TO_API: 'app/ imported api/ at runtime',
|
|
123
|
-
X_BOUNDARY_ROUTE_TO_DB: 'a route touched the database',
|
|
124
|
-
X_BOUNDARY_SERVICE_TO_HTTP: 'a service imported HTTP',
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
|
128
|
-
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
129
|
-
registerErrorCodes(
|
|
130
|
-
Object.fromEntries(Object.entries(CLI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
export const docsFor = (code: CliErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
1
|
+
// The error classes @ultimat3/cli throws. One class per condition, each naming the exact command
|
|
2
|
+
// that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
|
|
3
|
+
// so a package importing a class does not pull the table and vice versa.
|
|
4
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { docsFor } from './error-codes';
|
|
134
6
|
|
|
135
7
|
/** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
|
|
136
8
|
export class UnknownCommandError extends UltimateError {
|
|
@@ -160,6 +32,49 @@ export class BadFlagError extends UltimateError {
|
|
|
160
32
|
}
|
|
161
33
|
}
|
|
162
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A required POSITIONAL argument that was not given. Its own class rather than a `BadFlagError`,
|
|
37
|
+
* because the cause then names a flag that does not exist — `x errors --json` reported
|
|
38
|
+
* `--code on "x errors"` and sent an agent straight into a second `X_CLI_BAD_FLAG` for the
|
|
39
|
+
* `--code` flag it had just been told about — and rather than `X_CLI_UNKNOWN_COMMAND`, which said
|
|
40
|
+
* "x g route is not a command" about a command form that is. `example` is a REAL invocation:
|
|
41
|
+
* `x g route <name>` pasted into a shell is a redirect, not a command.
|
|
42
|
+
*/
|
|
43
|
+
export class MissingPositionalError extends UltimateError {
|
|
44
|
+
constructor(input: { command: string; positional: string; example: string }) {
|
|
45
|
+
super({
|
|
46
|
+
code: 'X_CLI_BAD_FLAG',
|
|
47
|
+
cause: `"x ${input.command}" needs a <${input.positional}> positional and got none`,
|
|
48
|
+
fix: input.example,
|
|
49
|
+
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
|
|
56
|
+
*
|
|
57
|
+
* `X_CLI_BAD_FLAG` is the code a missing positional already takes (`MissingPositionalError`), and a
|
|
58
|
+
* subcommand is one — a second code for "you left out an argument" is the synonym the registry
|
|
59
|
+
* exists to prevent. Its own class because the cause must not name a flag: the parser answered
|
|
60
|
+
* `subcommands[0]` before this existed, so `x db` ran `gen` and wrote a migration file nobody asked
|
|
61
|
+
* for. Help is the fix because which of six was meant is exactly what the caller did not say.
|
|
62
|
+
*
|
|
63
|
+
* `x help <command>`, never `x <command> --help`: the parser resolves the subcommand AFTER the
|
|
64
|
+
* flag loop, so `x db --help` throws THIS error again — a fix line that reproduces its own
|
|
65
|
+
* failure, verbatim, forever. `x help db` prints the subcommand list and the flags.
|
|
66
|
+
*/
|
|
67
|
+
export class MissingSubcommandError extends UltimateError {
|
|
68
|
+
constructor(input: { command: string; known: readonly string[] }) {
|
|
69
|
+
super({
|
|
70
|
+
code: 'X_CLI_BAD_FLAG',
|
|
71
|
+
cause: `"x ${input.command}" takes a subcommand and got none (one of: ${input.known.join(', ')})`,
|
|
72
|
+
fix: `x help ${input.command}`,
|
|
73
|
+
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
163
78
|
/** At least one `x verify` step failed. The step findings carry the per-step fixes. */
|
|
164
79
|
export class VerifyFailedError extends UltimateError {
|
|
165
80
|
constructor(input: { failed: readonly string[] }) {
|
|
@@ -211,7 +126,7 @@ export class NoTestFilesError extends UltimateError {
|
|
|
211
126
|
super({
|
|
212
127
|
code: 'X_TEST_NO_FILES',
|
|
213
128
|
cause: `no *.test.ts files${where} under ${input.root}`,
|
|
214
|
-
fix: parts.length === 0 ? 'x test --
|
|
129
|
+
fix: parts.length === 0 ? 'x test --json # run it from the repo root' : 'x test',
|
|
215
130
|
docs: docsFor('X_TEST_NO_FILES'),
|
|
216
131
|
});
|
|
217
132
|
}
|
|
@@ -282,7 +197,7 @@ export class AppPackageInvalidError extends UltimateError {
|
|
|
282
197
|
super({
|
|
283
198
|
code: 'X_APP_PACKAGE_INVALID',
|
|
284
199
|
cause: `${input.path} ${input.problem}, so the manifest has no app name or version to gate on`,
|
|
285
|
-
fix: 'bun pm pkg set name
|
|
200
|
+
fix: 'bun pm pkg set name=my-app version=0.1.0',
|
|
286
201
|
docs: docsFor('X_APP_PACKAGE_INVALID'),
|
|
287
202
|
});
|
|
288
203
|
}
|
|
@@ -374,12 +289,28 @@ export class BuildEntryMissingError extends UltimateError {
|
|
|
374
289
|
super({
|
|
375
290
|
code: 'X_BUILD_ENTRY_MISSING',
|
|
376
291
|
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
377
|
-
fix: `x new
|
|
292
|
+
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
378
293
|
docs: docsFor('X_BUILD_ENTRY_MISSING'),
|
|
379
294
|
});
|
|
380
295
|
}
|
|
381
296
|
}
|
|
382
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
|
|
300
|
+
* entry point like any other, and the target's own logs are what says which line. The fix builds
|
|
301
|
+
* exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
|
|
302
|
+
*/
|
|
303
|
+
export class IslandBuildFailedError extends UltimateError {
|
|
304
|
+
constructor(input: { file: string; logs: string }) {
|
|
305
|
+
super({
|
|
306
|
+
code: 'X_BUILD_FAILED',
|
|
307
|
+
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
308
|
+
fix: `bun build --target browser ${input.file}`,
|
|
309
|
+
docs: docsFor('X_BUILD_FAILED'),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
383
314
|
/**
|
|
384
315
|
* `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
|
|
385
316
|
* otherwise start, serve nothing and report healthy — the one failure a rolling deploy cannot see.
|
|
@@ -389,12 +320,35 @@ export class RoleUnknownError extends UltimateError {
|
|
|
389
320
|
super({
|
|
390
321
|
code: 'X_ROLE_UNKNOWN',
|
|
391
322
|
cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
|
|
392
|
-
fix: `docker run -e ROLE=web
|
|
323
|
+
fix: `docker run -e ROLE=web my-app:latest # one of: ${input.known.join(', ')}`,
|
|
393
324
|
docs: docsFor('X_ROLE_UNKNOWN'),
|
|
394
325
|
});
|
|
395
326
|
}
|
|
396
327
|
}
|
|
397
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The enqueue side and the claim side are looking at two different queues.
|
|
331
|
+
*
|
|
332
|
+
* `startServices` builds the drivers and captures them; `loadApp` imports the app's modules after
|
|
333
|
+
* it, and a module calling `setJobDriver()` at import time moves the ambient slot without touching
|
|
334
|
+
* the captured object. The worker then claims from what was captured while every
|
|
335
|
+
* `handle.enqueue()` publishes to what is ambient — jobs that are accepted, acknowledged, visible
|
|
336
|
+
* in `/_x` and never run. Refused at boot, because the alternative is a deployment that only ever
|
|
337
|
+
* looks healthy.
|
|
338
|
+
*/
|
|
339
|
+
export class RuntimeDriverSplitError extends UltimateError {
|
|
340
|
+
constructor(input: { driver: string; ambient: string; captured: string }) {
|
|
341
|
+
super({
|
|
342
|
+
code: 'X_RUNTIME_DRIVER_SPLIT',
|
|
343
|
+
// Both names are printed even when they are the same string — two `memory` drivers are two
|
|
344
|
+
// queues, and "they match" is exactly the reading that makes this bug invisible.
|
|
345
|
+
cause: `an app module installed a ${input.driver} driver (ambient: "${input.ambient}") that is not the object this boot captured ("${input.captured}"), so enqueues and claims would use different queues`,
|
|
346
|
+
fix: `pass the driver to the boot instead of installing it from an app module: runRole({ root, env, runtime: { ${input.driver}: yourDriver } })`,
|
|
347
|
+
docs: docsFor('X_RUNTIME_DRIVER_SPLIT'),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
398
352
|
/**
|
|
399
353
|
* Every PaaS injects `PORT` and expects the process to bind exactly it. Defaulting past a value
|
|
400
354
|
* that will not parse is how a deploy comes up on 3000, fails the platform's health probe, and
|
|
@@ -407,12 +361,32 @@ export class PortInvalidError extends UltimateError {
|
|
|
407
361
|
super({
|
|
408
362
|
code: 'X_PORT_INVALID',
|
|
409
363
|
cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
|
|
410
|
-
fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090}
|
|
364
|
+
fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} my-app:latest`,
|
|
411
365
|
docs: docsFor('X_PORT_INVALID'),
|
|
412
366
|
});
|
|
413
367
|
}
|
|
414
368
|
}
|
|
415
369
|
|
|
370
|
+
/**
|
|
371
|
+
* `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
|
|
372
|
+
* writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
|
|
373
|
+
* present", both read as a working environment declaration to whoever runs the command next.
|
|
374
|
+
*
|
|
375
|
+
* `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
|
|
376
|
+
* `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
|
|
377
|
+
* than minting a synonym.
|
|
378
|
+
*/
|
|
379
|
+
export class EnvSchemaMissingError extends UltimateError {
|
|
380
|
+
constructor(input: { subcommand: string }) {
|
|
381
|
+
super({
|
|
382
|
+
code: 'X_CONFIG_INVALID',
|
|
383
|
+
cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
|
|
384
|
+
fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
|
|
385
|
+
docs: docsFor('X_CONFIG_INVALID'),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
416
390
|
/** An interface-complete command path whose remote/native half is not written yet. */
|
|
417
391
|
export class CliNotImplementedError extends UltimateError {
|
|
418
392
|
constructor(input: { feature: string; fix: string }) {
|
|
@@ -424,3 +398,92 @@ export class CliNotImplementedError extends UltimateError {
|
|
|
424
398
|
});
|
|
425
399
|
}
|
|
426
400
|
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The process could not obtain a storage disk to write to.
|
|
404
|
+
*
|
|
405
|
+
* Thrown at boot rather than at the first upload, and with a `fix` naming the two real options —
|
|
406
|
+
* a writable volume or an object store — because the failure it replaces was a bare `EROFS` from
|
|
407
|
+
* inside Bun's `mkdirSync`, with no code, no fix, and no mention of storage. A hardened container
|
|
408
|
+
* (`readOnlyRootFilesystem: true`) CrashLooped 22 times on it before anyone could tell what the
|
|
409
|
+
* process wanted.
|
|
410
|
+
*/
|
|
411
|
+
export class StorageUnwritableError extends UltimateError {
|
|
412
|
+
constructor(cause: string, fix: string) {
|
|
413
|
+
super({ code: 'X_STORAGE_UNWRITABLE', cause, fix, docs: docsFor('X_STORAGE_UNWRITABLE') });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* A non-local boot that fell through to the embedded disk with no `STORAGE_SIGNING_SECRET`. The
|
|
419
|
+
* key it would sign with is a string published in this repo, and `acceptSignedUpload` trusts a
|
|
420
|
+
* signed `maxBytes`/`contentType` over the app's own `uploadPolicy` — so anyone holding it mints
|
|
421
|
+
* an unlimited upload of any type, for any key, including another org's.
|
|
422
|
+
*
|
|
423
|
+
* `X_ENV_MISSING`, the code `@ultimat3/storage` already refuses this with, rather than a CLI twin:
|
|
424
|
+
* two codes for one condition is what `cmd-doctor.ts` says out loud about the PWA pair. What this
|
|
425
|
+
* adds is the sentence storage cannot write — that the disk itself was a fallback nobody chose.
|
|
426
|
+
* The fix names object storage first, because that is the answer for most deployments; the volume
|
|
427
|
+
* rung is behind the `#`, so the line still runs verbatim.
|
|
428
|
+
*/
|
|
429
|
+
export class LocalDiskUnsafeError extends UltimateError {
|
|
430
|
+
constructor(input: { environment: string; root: string }) {
|
|
431
|
+
super({
|
|
432
|
+
code: 'X_ENV_MISSING',
|
|
433
|
+
cause:
|
|
434
|
+
`no S3_ENDPOINT/S3_BUCKET, so this ${input.environment} process fell back to the embedded ` +
|
|
435
|
+
`disk at ${input.root} — and with no STORAGE_SIGNING_SECRET it would sign upload grants ` +
|
|
436
|
+
'with the development key published in @ultimat3/storage',
|
|
437
|
+
fix: 'export S3_ENDPOINT=https://s3.example.com S3_BUCKET=my-app-uploads # or keep the disk on a mounted volume: export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
438
|
+
docs: docsFor('X_ENV_MISSING'),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
|
|
445
|
+
* guessing one and opening a decrypted file in it is the last place a surprise belongs.
|
|
446
|
+
*/
|
|
447
|
+
export class SecretsEditorMissingError extends UltimateError {
|
|
448
|
+
constructor(input: { vars: readonly string[] }) {
|
|
449
|
+
super({
|
|
450
|
+
code: 'X_SECRETS_EDITOR_MISSING',
|
|
451
|
+
cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
|
|
452
|
+
fix: 'EDITOR=nano x secrets edit',
|
|
453
|
+
docs: docsFor('X_SECRETS_EDITOR_MISSING'),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
|
|
460
|
+
* and the committed file is left exactly as it was: resealing a buffer whose editor failed would
|
|
461
|
+
* commit whatever half-written state the crash left behind.
|
|
462
|
+
*/
|
|
463
|
+
export class SecretsEditFailedError extends UltimateError {
|
|
464
|
+
constructor(input: { editor: string; code: number }) {
|
|
465
|
+
super({
|
|
466
|
+
code: 'X_SECRETS_EDIT_FAILED',
|
|
467
|
+
cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
|
|
468
|
+
fix: 'x secrets edit',
|
|
469
|
+
docs: docsFor('X_SECRETS_EDIT_FAILED'),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
|
|
476
|
+
* package's own code for exactly that, and a second name for "a generator would clobber something"
|
|
477
|
+
* is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
|
|
478
|
+
* the committed file it opens is then ciphertext nobody can read again.
|
|
479
|
+
*/
|
|
480
|
+
export class SecretsExistsError extends UltimateError {
|
|
481
|
+
constructor(input: { path: string; fix: string }) {
|
|
482
|
+
super({
|
|
483
|
+
code: 'X_GENERATE_CONFLICT',
|
|
484
|
+
cause: `${input.path} already exists, and x secrets init would replace it`,
|
|
485
|
+
fix: input.fix,
|
|
486
|
+
docs: docsFor('X_GENERATE_CONFLICT'),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -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
|
+
}
|