@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `x doctor` — everything that makes an environment lie to you, checked in one pass. Every finding
|
|
2
|
+
// carries the command that fixes it; a diagnostic that only describes a problem has handed the
|
|
3
|
+
// work back to the reader.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { usesDevCursorSecret } from '@ultimat3/core';
|
|
8
|
+
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
9
|
+
import type { CliCommand, CommandContext } from './command';
|
|
10
|
+
import { ICON_SOURCE } from './dev-assets';
|
|
11
|
+
import { checkDrift } from './drift';
|
|
12
|
+
import { msg } from './messages';
|
|
13
|
+
import type { CommandResult, Finding } from './output';
|
|
14
|
+
import { flagString } from './parse';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
18
|
+
* `wiki/Upgrading.md` covers `X_*` codes, the eight primitive shapes, the `x` CLI surface, the
|
|
19
|
+
* tier table and `app.config.ts` fields, and not this — so a new fact the probe must report is a
|
|
20
|
+
* REQUIRED field: an optional one lets an implementation skip the check and still typecheck.
|
|
21
|
+
*/
|
|
22
|
+
export interface DoctorProbe {
|
|
23
|
+
readonly bunVersion: string;
|
|
24
|
+
/** App root, or undefined when the command runs outside an app. */
|
|
25
|
+
readonly root: string | undefined;
|
|
26
|
+
readonly port: number;
|
|
27
|
+
/** True while cursors are signed with the key shipped in the published package. */
|
|
28
|
+
readonly devCursorSecret: boolean;
|
|
29
|
+
/** True when this process believes it is serving real clients. */
|
|
30
|
+
readonly production: boolean;
|
|
31
|
+
exists(relativePath: string): boolean;
|
|
32
|
+
portFree(port: number): Promise<boolean>;
|
|
33
|
+
drift(): Promise<readonly Finding[]>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
37
|
+
|
|
38
|
+
const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
|
|
39
|
+
at === undefined
|
|
40
|
+
? { code, cause, fix, docs: docs(code) }
|
|
41
|
+
: { code, cause, fix, docs: docs(code), at };
|
|
42
|
+
|
|
43
|
+
export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ordered cheapest-first so the first failure is usually the root cause: a wrong Bun explains
|
|
47
|
+
* every other symptom, and running outside an app explains the rest.
|
|
48
|
+
*/
|
|
49
|
+
export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
50
|
+
const findings: Finding[] = [];
|
|
51
|
+
if (!versionAtLeast(probe.bunVersion, REQUIRED_BUN)) {
|
|
52
|
+
findings.push(
|
|
53
|
+
finding(
|
|
54
|
+
'X_BUN_VERSION',
|
|
55
|
+
`Bun ${probe.bunVersion} is older than the required ${REQUIRED_BUN}`,
|
|
56
|
+
'bun upgrade',
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (probe.root === undefined) {
|
|
61
|
+
findings.push(
|
|
62
|
+
finding('X_NOT_IN_APP', 'no app.config.ts at or above the working directory', 'x new myapp'),
|
|
63
|
+
);
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
if (!probe.exists('.env.development')) {
|
|
67
|
+
findings.push(
|
|
68
|
+
finding(
|
|
69
|
+
'X_ENV_MISSING',
|
|
70
|
+
'.env.development is missing, so committed defaults cannot be read',
|
|
71
|
+
'x new --force to restore the committed defaults, or create .env.development',
|
|
72
|
+
'.env.development',
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// Production only, and the gate is the point: every development environment signs with the
|
|
77
|
+
// shipped key on purpose — that is what lets `x dev` page with no configuration — so an
|
|
78
|
+
// unconditional finding would make `x doctor` red for every developer on day one and teach the
|
|
79
|
+
// reader to skim past the report. The key is a defect only where cursors reach real clients,
|
|
80
|
+
// who can read it out of the published package and forge a page position.
|
|
81
|
+
// Sits here because it costs two env reads and a comparison — cheaper than binding a port.
|
|
82
|
+
if (probe.production && probe.devCursorSecret) {
|
|
83
|
+
findings.push(
|
|
84
|
+
finding(
|
|
85
|
+
'X_CURSOR_SECRET_DEV',
|
|
86
|
+
'cursors are signed with the shipped development key, so a client can forge a page position',
|
|
87
|
+
'export ULTIMATE_CURSOR_SECRET="$(openssl rand -hex 32)"',
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!(await probe.portFree(probe.port))) {
|
|
92
|
+
findings.push(
|
|
93
|
+
finding(
|
|
94
|
+
'X_PORT_IN_USE',
|
|
95
|
+
`port ${probe.port} is already listening`,
|
|
96
|
+
`x dev --port ${probe.port + 1}`,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
// `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
|
|
101
|
+
// `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
|
|
102
|
+
// names — two codes for one condition, one of them registered by nobody, so `x errors explain`
|
|
103
|
+
// answered for the package's and refused the CLI's.
|
|
104
|
+
if (!probe.exists(ICON_SOURCE)) {
|
|
105
|
+
findings.push(
|
|
106
|
+
finding(
|
|
107
|
+
'X_PWA_ICON_MISSING',
|
|
108
|
+
`${ICON_SOURCE} is missing, so install icons and og images cannot be generated`,
|
|
109
|
+
// An edit naming the file, in `@ultimat3/pwa`'s own words (`requireSourceIcon`). Not
|
|
110
|
+
// `x new`: it takes an app name and refuses to run inside the app that is missing the icon,
|
|
111
|
+
// so offering it here hands the reader a command that cannot work where they are standing.
|
|
112
|
+
`add a 1024x1024 square PNG at ${ICON_SOURCE}`,
|
|
113
|
+
ICON_SOURCE,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!probe.exists(OFFLINE_FALLBACK)) {
|
|
118
|
+
findings.push(
|
|
119
|
+
finding(
|
|
120
|
+
'X_PWA_NO_OFFLINE_FALLBACK',
|
|
121
|
+
`${OFFLINE_FALLBACK} is missing, so an offline navigation falls back to the browser error page`,
|
|
122
|
+
'x g route offline --surface app',
|
|
123
|
+
OFFLINE_FALLBACK,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
findings.push(...(await probe.drift()));
|
|
128
|
+
return findings;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const portFree = async (port: number): Promise<boolean> => {
|
|
132
|
+
try {
|
|
133
|
+
const server = Bun.serve({ port, fetch: () => new Response('') });
|
|
134
|
+
await server.stop(true);
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function probeFor(cwd: string, bunVersion: string, port: number): DoctorProbe {
|
|
142
|
+
const root = findAppRoot(cwd)?.dir;
|
|
143
|
+
return {
|
|
144
|
+
bunVersion,
|
|
145
|
+
root,
|
|
146
|
+
port,
|
|
147
|
+
devCursorSecret: usesDevCursorSecret(),
|
|
148
|
+
// `X_ENV` first, then `NODE_ENV`: the order `@ultimat3/admin`'s dev-server guard already
|
|
149
|
+
// reads them in, and a second order would be a second convention.
|
|
150
|
+
production: (Bun.env['X_ENV'] ?? Bun.env['NODE_ENV']) === 'production',
|
|
151
|
+
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
152
|
+
portFree,
|
|
153
|
+
drift: async () => (root === undefined ? [] : checkDrift(root)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const doctorCommand: CliCommand = {
|
|
158
|
+
spec: {
|
|
159
|
+
name: 'doctor',
|
|
160
|
+
summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
|
|
161
|
+
usage: 'x doctor [--port 3000] [--json]',
|
|
162
|
+
flags: [{ name: 'port', type: 'string', summary: 'port to test', default: '3000' }],
|
|
163
|
+
},
|
|
164
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
165
|
+
const port = Number.parseInt(flagString(ctx.args, 'port') ?? '3000', 10);
|
|
166
|
+
const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
|
|
167
|
+
return {
|
|
168
|
+
ok: findings.length === 0,
|
|
169
|
+
command: 'doctor',
|
|
170
|
+
summary:
|
|
171
|
+
findings.length === 0
|
|
172
|
+
? msg('cli.doctor.clean')
|
|
173
|
+
: msg('cli.doctor.findings', { count: findings.length }),
|
|
174
|
+
findings,
|
|
175
|
+
data: { count: findings.length, codes: findings.map((entry) => entry.code) },
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `x errors explain <CODE>` / `x errors list` — the error table, programmatically. An agent that
|
|
2
|
+
// hits an `X_*` code should not have to leave the terminal to learn what it means, and a code it
|
|
3
|
+
// invented should come back refused: the answer to an unregistered code is "no such code", never
|
|
4
|
+
// a plausible-sounding explanation an agent would then act on.
|
|
5
|
+
|
|
6
|
+
import type { ErrorExplanation } from '@ultimat3/mcp';
|
|
7
|
+
import type { CliCommand, CommandContext } from './command';
|
|
8
|
+
import type { ErrorCatalog } from './error-catalog';
|
|
9
|
+
import { loadErrorCatalog } from './error-catalog';
|
|
10
|
+
import { BadFlagError, ErrorCodeUnknownError } from './errors';
|
|
11
|
+
import { explainErrorCode, explainEveryErrorCode } from './mcp-errors';
|
|
12
|
+
import { msg } from './messages';
|
|
13
|
+
import type { CommandResult, JsonValue } from './output';
|
|
14
|
+
import { nearest } from './parse';
|
|
15
|
+
|
|
16
|
+
export const ERRORS_SUBCOMMANDS = ['explain', 'list'] as const;
|
|
17
|
+
|
|
18
|
+
const asJson = (explanation: ErrorExplanation): JsonValue => ({
|
|
19
|
+
code: explanation.code,
|
|
20
|
+
cause: explanation.cause,
|
|
21
|
+
fix: explanation.fix,
|
|
22
|
+
docs: explanation.docs,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** The 3-line contract format, minus the leading blank code line `renderFinding` would add. */
|
|
26
|
+
const detailLines = (explanation: ErrorExplanation): readonly string[] => [
|
|
27
|
+
` cause: ${explanation.cause}`,
|
|
28
|
+
` fix: ${explanation.fix}`,
|
|
29
|
+
` docs: ${explanation.docs}`,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function explainOne(code: string): CommandResult {
|
|
33
|
+
const explanation = explainErrorCode(code);
|
|
34
|
+
if (explanation === undefined) {
|
|
35
|
+
const suggestion = nearest(
|
|
36
|
+
code,
|
|
37
|
+
explainEveryErrorCode().map((entry) => entry.code),
|
|
38
|
+
);
|
|
39
|
+
throw new ErrorCodeUnknownError(suggestion === undefined ? { code } : { code, suggestion });
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
command: 'errors',
|
|
44
|
+
summary: msg('cli.errors.explained', { code: explanation.code, title: explanation.cause }),
|
|
45
|
+
lines: detailLines(explanation),
|
|
46
|
+
data: asJson(explanation),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A package that resolved and then threw is a defect, not a host gap: its codes are missing from
|
|
52
|
+
* this answer and something is broken that a `fix:` can address. Reported as findings — and `ok`
|
|
53
|
+
* goes false — so the incomplete catalog cannot read as a complete one.
|
|
54
|
+
*/
|
|
55
|
+
function listAll(catalog: ErrorCatalog): CommandResult {
|
|
56
|
+
const all = explainEveryErrorCode();
|
|
57
|
+
return {
|
|
58
|
+
ok: catalog.failed.length === 0,
|
|
59
|
+
command: 'errors',
|
|
60
|
+
summary: msg('cli.errors.count', { count: all.length }),
|
|
61
|
+
lines: all.map((entry) => ` ${entry.code.padEnd(30)} ${entry.cause}`),
|
|
62
|
+
findings: catalog.failed,
|
|
63
|
+
data: {
|
|
64
|
+
codes: all.map(asJson),
|
|
65
|
+
unavailable: [...catalog.unavailable],
|
|
66
|
+
failed: catalog.failed.map((finding) => ({
|
|
67
|
+
code: finding.code,
|
|
68
|
+
cause: finding.cause,
|
|
69
|
+
fix: finding.fix,
|
|
70
|
+
at: finding.at ?? null,
|
|
71
|
+
})),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const errorsCommand: CliCommand = {
|
|
77
|
+
spec: {
|
|
78
|
+
name: 'errors',
|
|
79
|
+
summary: 'an X_* code, explained: cause, runnable fix, docs URL',
|
|
80
|
+
usage: 'x errors [explain <CODE>|list] [--json]',
|
|
81
|
+
subcommands: ERRORS_SUBCOMMANDS,
|
|
82
|
+
},
|
|
83
|
+
// `async` is load-bearing: a synchronous throw would escape every caller that awaits the
|
|
84
|
+
// promise this signature promises, including the dispatcher's own error path.
|
|
85
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
86
|
+
const catalog = await loadErrorCatalog();
|
|
87
|
+
if (ctx.args.subcommand === 'list') return listAll(catalog);
|
|
88
|
+
const code = ctx.args.positionals[0];
|
|
89
|
+
if (code === undefined) {
|
|
90
|
+
throw new BadFlagError({
|
|
91
|
+
flag: 'code',
|
|
92
|
+
command: 'errors',
|
|
93
|
+
reason: 'x errors explain <CODE> needs a code',
|
|
94
|
+
fix: 'x errors list --json',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return explainOne(code);
|
|
98
|
+
},
|
|
99
|
+
};
|
package/src/cmd-fix.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// `x fix boundary <file>` — the minimal cut for an import that crossed a surface boundary.
|
|
2
|
+
// Analysis and a plan only: it never rewrites a file, and there is no `--write` flag
|
|
3
|
+
// (`docs/architecture/02-boundaries.md`) — a caller runs the printed edit, or the generated
|
|
4
|
+
// `git mv`, itself.
|
|
5
|
+
|
|
6
|
+
import { appImportGraph, readAppSources } from './app-boundaries';
|
|
7
|
+
import { requireAppRoot } from './app-root';
|
|
8
|
+
import type { BoundaryCut } from './boundary-cuts';
|
|
9
|
+
import { planBoundaryCuts } from './boundary-cuts';
|
|
10
|
+
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { BadFlagError, FixTargetUnknownError } from './errors';
|
|
12
|
+
import { msg } from './messages';
|
|
13
|
+
import type { CommandResult, Finding, JsonValue } from './output';
|
|
14
|
+
import { nearest } from './parse';
|
|
15
|
+
|
|
16
|
+
export type { BoundaryCut };
|
|
17
|
+
export { planBoundaryCuts };
|
|
18
|
+
|
|
19
|
+
export const FIX_SUBCOMMANDS = ['boundary'] as const;
|
|
20
|
+
|
|
21
|
+
const docsUrl = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Accept either an app-root-relative path or a suffix that matches exactly one scanned file —
|
|
25
|
+
* an agent copying the path out of a `fix:` line has the short form
|
|
26
|
+
* (`packages/render/README.md:89` emits exactly that shape).
|
|
27
|
+
*/
|
|
28
|
+
function resolveTarget(input: string, paths: readonly string[]): string {
|
|
29
|
+
const matches = paths.filter((path) => path === input || path.endsWith(`/${input}`));
|
|
30
|
+
if (matches.length > 1) {
|
|
31
|
+
throw new BadFlagError({
|
|
32
|
+
flag: 'file',
|
|
33
|
+
command: 'fix',
|
|
34
|
+
reason: `"${input}" matches ${matches.length} files: ${matches.join(', ')}`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const [only] = matches;
|
|
38
|
+
if (only !== undefined) return only;
|
|
39
|
+
// Compare on the last segment too: a wrong directory is the common miss, and edit distance
|
|
40
|
+
// over the whole path would score every file in the right directory as equally far away.
|
|
41
|
+
const suggestion =
|
|
42
|
+
nearest(input, [...paths]) ??
|
|
43
|
+
paths.find((path) => path.split('/').at(-1) === input.split('/').at(-1));
|
|
44
|
+
throw new FixTargetUnknownError({
|
|
45
|
+
file: input,
|
|
46
|
+
scanned: paths.length,
|
|
47
|
+
...(suggestion === undefined ? {} : { suggestion }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const splitJson = (split: BoundaryCut['split']): JsonValue =>
|
|
52
|
+
split === null
|
|
53
|
+
? null
|
|
54
|
+
: {
|
|
55
|
+
module: split.module,
|
|
56
|
+
surface: split.surface,
|
|
57
|
+
to: split.to,
|
|
58
|
+
command: split.command,
|
|
59
|
+
importers: split.importers,
|
|
60
|
+
// The human `edit` line names every specifier the move invalidates, so `--json` carries
|
|
61
|
+
// them structured — a plan an agent can apply without re-parsing the sentence.
|
|
62
|
+
edits: split.edits.map((edit) => ({
|
|
63
|
+
file: edit.file,
|
|
64
|
+
imported: edit.imported,
|
|
65
|
+
specifier: edit.specifier,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const cutJson = (cut: BoundaryCut): JsonValue => ({
|
|
70
|
+
code: cut.code,
|
|
71
|
+
rule: cut.rule,
|
|
72
|
+
entry: cut.entry,
|
|
73
|
+
at: cut.at,
|
|
74
|
+
edge: { from: cut.edge.from, to: cut.edge.to },
|
|
75
|
+
chain: cut.chain,
|
|
76
|
+
edit: cut.edit,
|
|
77
|
+
split: splitJson(cut.split),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const findingForCut = (cut: BoundaryCut): Finding => ({
|
|
81
|
+
code: cut.code,
|
|
82
|
+
cause: cut.cause,
|
|
83
|
+
fix: cut.edit,
|
|
84
|
+
docs: docsUrl(cut.code),
|
|
85
|
+
at: cut.at,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Distinct edges among the cuts — one edit can clear more than one flagged rule at once. */
|
|
89
|
+
const editCount = (cuts: readonly BoundaryCut[]): number =>
|
|
90
|
+
new Set(cuts.map((cut) => JSON.stringify([cut.edge.from, cut.edge.to]))).size;
|
|
91
|
+
|
|
92
|
+
export const fixCommand: CliCommand = {
|
|
93
|
+
spec: {
|
|
94
|
+
name: 'fix',
|
|
95
|
+
summary: 'the minimal cut for an import that crossed a surface boundary',
|
|
96
|
+
usage: 'x fix boundary <file> [--json]',
|
|
97
|
+
requiresApp: true,
|
|
98
|
+
subcommands: FIX_SUBCOMMANDS,
|
|
99
|
+
},
|
|
100
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
101
|
+
const root = requireAppRoot('fix', ctx.cwd).dir;
|
|
102
|
+
const files = await readAppSources(root);
|
|
103
|
+
const target = resolveTarget(
|
|
104
|
+
ctx.args.positionals[0] ?? '',
|
|
105
|
+
files.map((file) => file.path),
|
|
106
|
+
);
|
|
107
|
+
const cuts = planBoundaryCuts(target, appImportGraph(files));
|
|
108
|
+
|
|
109
|
+
if (cuts.length === 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
command: 'fix',
|
|
113
|
+
summary: msg('cli.fix.clean', { file: target }),
|
|
114
|
+
data: { file: target, cuts: [] },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
command: 'fix',
|
|
121
|
+
summary: msg('cli.fix.plan', { count: cuts.length, file: target, edits: editCount(cuts) }),
|
|
122
|
+
findings: cuts.map(findingForCut),
|
|
123
|
+
data: { file: target, cuts: cuts.map(cutJson) },
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|