fix(cli): remove top-level scan command

This commit is contained in:
Andrey Avtomonov 2026-05-13 22:16:17 +02:00
parent 601591bfbf
commit 011d694ed3
10 changed files with 182 additions and 359 deletions

View file

@ -41,8 +41,8 @@ export function defaultOrbitVerificationProjectDir() {
return defaultProjectDir;
}
function shellCommand(argv) {
return ['pnpm', 'run', 'ktx', '--', ...argv].join(' ');
function internalScanCommand(input) {
return `internal runKtxScan connection=${input.connectionId} mode=relationships projectDir=${input.projectDir}`;
}
function firstNonEmptyLine(...values) {
@ -55,7 +55,7 @@ function firstNonEmptyLine(...values) {
return line;
}
}
return 'Orbit scan command failed before producing diagnostic output';
return 'Orbit relationship scan failed before producing diagnostic output';
}
function parseArgs(argv) {
@ -88,8 +88,15 @@ function parseArgs(argv) {
return options;
}
export function buildOrbitScanArgv(input) {
return ['scan', input.connectionId, '--mode', 'relationships', '--project-dir', input.projectDir];
export function buildOrbitScanArgs(input) {
return {
command: 'run',
projectDir: input.projectDir,
connectionId: input.connectionId,
mode: 'relationships',
detectRelationships: true,
dryRun: false,
};
}
export function extractRunId(stdout) {
@ -171,7 +178,7 @@ function formatBlocked(result) {
'',
'## Evidence',
'',
'- Orbit verification was not executed because the current local Orbit scan command failed.',
'- Orbit verification was not executed because the current local Orbit relationship scan failed.',
'- Re-run with `--report-path` to write verification evidence to a custom location.',
'',
'Scan stdout:',
@ -228,6 +235,36 @@ async function runBufferedWorkspaceKtx(runner, argv, rootDir, execFile) {
};
}
function cliScanModulePath(rootDir) {
return resolve(rootDir, 'packages/cli/dist/scan.js');
}
async function loadRunKtxScan(rootDir) {
const module = await import(pathToFileURL(cliScanModulePath(rootDir)).href);
return module.runKtxScan;
}
async function runBufferedInternalScan(input) {
const stdout = new BufferWriter();
const stderr = new BufferWriter();
let runKtxScan = input.runKtxScan;
if (!runKtxScan) {
const build = await runBufferedWorkspaceKtx(input.runner, ['--version'], input.rootDir, input.execFile);
if (build.exitCode !== 0) {
return build;
}
runKtxScan = await loadRunKtxScan(input.rootDir);
}
const exitCode = await runKtxScan(input.scanArgs, { stdout, stderr });
return {
exitCode,
stdout: stdout.text(),
stderr: stderr.text(),
};
}
function orbitVerificationEnv(projectDir) {
if (projectDir !== defaultProjectDir) {
return process.env;
@ -253,8 +290,15 @@ export async function runOrbitVerification(options = {}) {
const env = options.env ?? orbitVerificationEnv(projectDir);
const runWithEnv = (argv, runnerOptions) => runner(argv, { ...runnerOptions, env });
const scanArgv = buildOrbitScanArgv({ connectionId, projectDir });
const scan = await runBufferedWorkspaceKtx(runWithEnv, scanArgv, rootDir, execFile);
const scanArgs = buildOrbitScanArgs({ connectionId, projectDir });
const scanCommand = internalScanCommand({ connectionId, projectDir });
const scan = await runBufferedInternalScan({
scanArgs,
rootDir,
execFile,
runner: runWithEnv,
runKtxScan: options.runKtxScan,
});
let result;
if (scan.exitCode !== 0) {
@ -263,7 +307,7 @@ export async function runOrbitVerification(options = {}) {
date,
connectionId,
projectDir,
scanCommand: shellCommand(scanArgv),
scanCommand,
scanExitCode: scan.exitCode,
blocker: firstNonEmptyLine(scan.stderr, scan.stdout),
scanStdout: scan.stdout,
@ -277,7 +321,7 @@ export async function runOrbitVerification(options = {}) {
date,
connectionId,
projectDir,
scanCommand: shellCommand(scanArgv),
scanCommand,
scanExitCode: scan.exitCode,
blocker: 'KTX scan completed without printing a Run id',
scanStdout: scan.stdout,
@ -291,7 +335,7 @@ export async function runOrbitVerification(options = {}) {
date,
connectionId,
projectDir,
scanCommand: shellCommand(scanArgv),
scanCommand,
scanExitCode: scan.exitCode,
blocker: 'KTX scan completed without printing a report artifact path',
scanStdout: scan.stdout,
@ -304,7 +348,7 @@ export async function runOrbitVerification(options = {}) {
date,
connectionId,
projectDir,
scanCommand: shellCommand(scanArgv),
scanCommand,
reportPath: fullScanReportPath,
scanExitCode: scan.exitCode,
scanStdout: scan.stdout,

View file

@ -1,9 +1,8 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { describe, it } from 'node:test';
import {
buildOrbitScanArgv,
buildOrbitScanArgs,
defaultOrbitVerificationProjectDir,
extractReportPath,
extractRunId,
@ -49,6 +48,14 @@ function successReportJson() {
});
}
function successfulRunKtxScan(calls = []) {
return async (args, io) => {
calls.push(args);
io.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
return 0;
};
}
describe('relationship Orbit verification helper', () => {
it('exposes the Orbit verification command from the KTX workspace package', async () => {
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
@ -59,20 +66,19 @@ describe('relationship Orbit verification helper', () => {
);
});
it('builds the current KTX launcher arguments for scan commands', () => {
assert.deepEqual(buildOrbitScanArgv({ connectionId: 'orbit', projectDir: '/tmp/orbit-project' }), [
'scan',
'orbit',
'--mode',
'relationships',
'--project-dir',
'/tmp/orbit-project',
]);
it('builds the internal relationship scan arguments', () => {
assert.deepEqual(buildOrbitScanArgs({ connectionId: 'orbit', projectDir: '/tmp/orbit-project' }), {
command: 'run',
projectDir: '/tmp/orbit-project',
connectionId: 'orbit',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
});
});
it('uses the checked-in Orbit verification project by default', async () => {
const calls = [];
const envs = [];
const scanCalls = [];
const writes = [];
const defaultProjectDir = defaultOrbitVerificationProjectDir();
@ -83,27 +89,28 @@ describe('relationship Orbit verification helper', () => {
writeFile: async (path, content) => {
writes.push({ path, content });
},
runWorkspaceKtx: async (argv, options) => {
calls.push(argv);
envs.push(options.env);
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
return 0;
},
runKtxScan: successfulRunKtxScan(scanCalls),
readFile: async () => successReportJson(),
});
assert.equal(result.status, 'success');
assert.deepEqual(calls, [
['scan', 'orbit', '--mode', 'relationships', '--project-dir', defaultProjectDir],
assert.deepEqual(scanCalls, [
{
command: 'run',
projectDir: defaultProjectDir,
connectionId: 'orbit',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
},
]);
assert.equal(envs[0].GIT_CEILING_DIRECTORIES, dirname(defaultProjectDir));
assert.equal(writes.length, 1);
assert.match(writes[0].content, new RegExp(defaultProjectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
it('uses KTX_PROJECT_DIR for the Orbit verification project override', async () => {
const previousProjectDir = process.env.KTX_PROJECT_DIR;
const calls = [];
const scanCalls = [];
try {
process.env.KTX_PROJECT_DIR = '/tmp/orbit-project-from-env';
@ -113,17 +120,20 @@ describe('relationship Orbit verification helper', () => {
now: () => new Date('2026-05-07T10:00:00.000Z'),
mkdir: async () => {},
writeFile: async () => {},
runWorkspaceKtx: async (argv, options) => {
calls.push(argv);
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
return 0;
},
runKtxScan: successfulRunKtxScan(scanCalls),
readFile: async () => successReportJson(),
});
assert.equal(result.projectDir, '/tmp/orbit-project-from-env');
assert.deepEqual(calls, [
['scan', 'orbit', '--mode', 'relationships', '--project-dir', '/tmp/orbit-project-from-env'],
assert.deepEqual(scanCalls, [
{
command: 'run',
projectDir: '/tmp/orbit-project-from-env',
connectionId: 'orbit',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
},
]);
} finally {
if (previousProjectDir === undefined) {
@ -146,7 +156,7 @@ describe('relationship Orbit verification helper', () => {
date: '2026-05-07',
connectionId: 'orbit',
projectDir: '/tmp/orbit-project',
scanCommand: 'pnpm run ktx -- scan orbit --mode relationships --project-dir /tmp/orbit-project',
scanCommand: 'internal runKtxScan connection=orbit mode=relationships projectDir=/tmp/orbit-project',
reportPath: '/tmp/orbit-project/reports/scan-report.json',
scanExitCode: 0,
scanStdout: 'KTX scan completed\nRun: scan-orbit-1\n',
@ -171,7 +181,7 @@ describe('relationship Orbit verification helper', () => {
date: '2026-05-07',
connectionId: 'orbit',
projectDir: '/tmp/orbit-project',
scanCommand: 'pnpm run ktx -- scan orbit --mode relationships --project-dir /tmp/orbit-project',
scanCommand: 'internal runKtxScan connection=orbit mode=relationships projectDir=/tmp/orbit-project',
scanExitCode: 1,
blocker: 'Connection "orbit" was not found',
scanStdout: '',
@ -180,12 +190,12 @@ describe('relationship Orbit verification helper', () => {
assert.match(markdown, /Exit code: 1/);
assert.match(markdown, /Connection "orbit" was not found/);
assert.match(markdown, /Orbit verification was not executed because the current local Orbit scan command failed/);
assert.match(markdown, /Orbit verification was not executed because the current local Orbit relationship scan failed/);
assert.doesNotMatch(markdown, /scan\.enrichment\.mode is required/);
});
it('runs scan then reads the report artifact and writes success Markdown', async () => {
const calls = [];
const scanCalls = [];
const writes = [];
const result = await runOrbitVerification({
connectionId: 'orbit',
@ -196,24 +206,27 @@ describe('relationship Orbit verification helper', () => {
writeFile: async (path, content) => {
writes.push({ path, content });
},
runWorkspaceKtx: async (argv, options) => {
calls.push(argv);
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
return 0;
},
runKtxScan: successfulRunKtxScan(scanCalls),
readFile: async () => successReportJson(),
});
assert.equal(result.status, 'success');
assert.deepEqual(calls, [
['scan', 'orbit', '--mode', 'relationships', '--project-dir', '/tmp/orbit-project'],
assert.deepEqual(scanCalls, [
{
command: 'run',
projectDir: '/tmp/orbit-project',
connectionId: 'orbit',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
},
]);
assert.equal(writes.length, 1);
assert.equal(writes[0].path, '/tmp/orbit-report.md');
assert.match(writes[0].content, /Accepted: 14/);
});
it('writes blocked Markdown when the scan command fails before a run id exists', async () => {
it('writes blocked Markdown when the internal scan fails before a run id exists', async () => {
const writes = [];
const result = await runOrbitVerification({
connectionId: 'orbit',
@ -224,8 +237,8 @@ describe('relationship Orbit verification helper', () => {
writeFile: async (path, content) => {
writes.push({ path, content });
},
runWorkspaceKtx: async (_argv, options) => {
options.stderr.write('Connection "orbit" was not found\n');
runKtxScan: async (_args, io) => {
io.stderr.write('Connection "orbit" was not found\n');
return 1;
},
});
@ -236,7 +249,7 @@ describe('relationship Orbit verification helper', () => {
assert.match(writes[0].content, /Connection "orbit" was not found/);
});
it('runs the workspace launcher in buffered mode so real scan errors are captured', async () => {
it('runs the workspace launcher in buffered mode when preparing the internal scan module', async () => {
let sawExecFile = false;
const result = await runOrbitVerification({
connectionId: 'orbit',
@ -246,7 +259,8 @@ describe('relationship Orbit verification helper', () => {
mkdir: async () => {},
writeFile: async () => {},
execFile: async () => ({ stdout: '', stderr: '' }),
runWorkspaceKtx: async (_argv, options) => {
runWorkspaceKtx: async (argv, options) => {
assert.deepEqual(argv, ['--version']);
sawExecFile = typeof options.execFile === 'function';
options.stderr.write('ENOENT: no such file or directory, open \'/tmp/orbit-project/ktx.yaml\'\n');
return 1;

View file

@ -152,7 +152,7 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
const logs = [];
let sourceMtimeMs = 3000;
const exitCode = await runWorkspaceKtx(['scan', 'orbit', '--mode', 'relationships'], {
const exitCode = await runWorkspaceKtx(['status', '--json', '--no-input'], {
rootDir: '/workspace/ktx',
access: async () => undefined,
stat: async (path) => ({
@ -174,7 +174,7 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
sourceMtimeMs = 1000;
return { stdout: 'build ok\n', stderr: '' };
}
return { stdout: 'scan ok\n', stderr: '' };
return { stdout: '{"status":"ready"}\n', stderr: '' };
},
stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
@ -185,12 +185,12 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
calls.map((call) => [call.command, call.args]),
[
['pnpm', ['run', 'build']],
[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'scan', 'orbit', '--mode', 'relationships']],
[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'status', '--json', '--no-input']],
],
);
assert.deepEqual(logs, [
['stderr', 'KTX CLI build output is stale. Rebuilding it now with `pnpm run build`...\n'],
['stdout', 'build ok\n'],
['stdout', 'scan ok\n'],
['stdout', '{"status":"ready"}\n'],
]);
});