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

@ -3,7 +3,6 @@ import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
import { registerConnectionCommands } from './commands/connection-commands.js';
import { registerIngestCommands } from './commands/ingest-commands.js';
import { registerWikiCommands } from './commands/knowledge-commands.js';
import { registerScanCommands } from './commands/scan-commands.js';
import { registerSetupCommands } from './commands/setup-commands.js';
import { registerSlCommands } from './commands/sl-commands.js';
import { registerStatusCommands } from './commands/status-commands.js';
@ -53,7 +52,8 @@ type CommandPathNode = CommandWithGlobalOptions & {
parent?: CommandPathNode | null;
};
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'scan']);
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
const REMOVED_ROOT_COMMANDS = new Set(['scan']);
export interface CommandWithGlobalOptions {
opts: () => object;
@ -313,7 +313,6 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>
await (ingestDeps.ingest ?? defaultRunIngest)(ingestArgs, ingestIo),
});
registerScanCommands(program, context);
registerWikiCommands(program, context);
registerSlCommands(program, context);
registerStatusCommands(program, context);
@ -367,6 +366,11 @@ export async function runCommanderKtxCli(
return 0;
}
if (REMOVED_ROOT_COMMANDS.has(argv[0] ?? '')) {
io.stderr.write(`error: unknown command '${argv[0]}'\n`);
return 1;
}
try {
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
} catch (error) {

View file

@ -6,7 +6,6 @@ import type { KtxIngestArgs } from './ingest.js';
import type { KtxKnowledgeArgs } from './knowledge.js';
import type { KtxPublicIngestArgs } from './public-ingest.js';
import type { KtxRuntimeArgs } from './runtime.js';
import type { KtxScanArgs } from './scan.js';
import type { KtxSetupArgs } from './setup.js';
import type { KtxSlArgs } from './sl.js';
import { profileMark, profileSpan } from './startup-profile.js';
@ -33,7 +32,6 @@ export interface KtxCliDeps {
ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise<number>;
runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise<number>;
scan?: (args: KtxScanArgs, io: KtxCliIo) => Promise<number>;
knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise<number>;
sl?: (args: KtxSlArgs, io: KtxCliIo) => Promise<number>;
}

View file

@ -1,76 +0,0 @@
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
import type { KtxScanArgs } from '../scan.js';
import { profileMark } from '../startup-profile.js';
profileMark('module:commands/scan-commands');
async function runScanArgs(context: KtxCliCommandContext, args: KtxScanArgs): Promise<void> {
const runner = context.deps.scan ?? (await import('../scan.js')).runKtxScan;
context.setExitCode(await runner(args, context.io));
}
type KtxScanModeOption = Extract<KtxScanArgs, { command: 'run' }>['mode'];
const REMOVED_SCAN_SUBCOMMAND_NAMES = new Set([
'status',
'report',
'relationships',
'relationship-apply',
'relationship-feedback',
'relationship-calibration',
'relationship-thresholds',
]);
function parseScanModeOption(value: string): KtxScanModeOption {
if (value === 'structural' || value === 'enriched' || value === 'relationships') {
return value;
}
throw new InvalidArgumentError('Allowed choices are structural, enriched, relationships');
}
function parseConnectionId(value: string): string {
if (REMOVED_SCAN_SUBCOMMAND_NAMES.has(value)) {
throw new InvalidArgumentError(`"${value}" is not a scan connection id`);
}
return value;
}
export function registerScanCommands(program: Command, context: KtxCliCommandContext): void {
program
.command('scan', { hidden: true })
.description('Run a standalone connection scan')
.argument('<connectionId>', 'KTX connection id to scan', parseConnectionId)
.option(
'--mode <mode>',
'Scan mode: structural, enriched, relationships (default: structural)',
parseScanModeOption,
)
.option('--dry-run', 'Run without writing scan results', false)
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
.option('--no-input', 'Disable interactive managed runtime installation')
.showHelpAfterError()
.addHelpText(
'after',
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.\n',
)
.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('scan', actionCommand);
})
.action(async (connectionId: string, options, command) => {
const mode = options.mode ?? 'structural';
await runScanArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),
connectionId,
mode,
detectRelationships: mode === 'relationships',
dryRun: options.dryRun === true,
databaseIntrospectionUrl: options.databaseIntrospectionUrl,
cliVersion: context.packageInfo.version,
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
});
});
}

View file

@ -129,17 +129,12 @@ describe('dev Commander tree', () => {
argv: ['dev', 'runtime', '--help'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status'],
},
{
argv: ['scan', '--help'],
expected: ['Usage: ktx scan [options] <connectionId>', '--mode <mode>', 'structural', 'relationships', '--dry-run'],
},
])('prints generated nested help for $argv', async ({ argv, expected }) => {
const io = makeIo();
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const scan = vi.fn(async () => 0);
await expect(runKtxCli(argv, io.io, { doctor, ingest, scan })).resolves.toBe(0);
await expect(runKtxCli(argv, io.io, { doctor, ingest })).resolves.toBe(0);
for (const text of expected) {
expect(io.stdout()).toContain(text);
@ -151,7 +146,6 @@ describe('dev Commander tree', () => {
expect(io.stderr()).toBe('');
expect(doctor).not.toHaveBeenCalled();
expect(ingest).not.toHaveBeenCalled();
expect(scan).not.toHaveBeenCalled();
});
it('keeps legacy adapter-backed ingest run callable but hidden from ingest help', async () => {
@ -175,100 +169,20 @@ describe('dev Commander tree', () => {
);
});
it('dispatches top-level scan through Commander with injected dependencies', async () => {
const scanIo = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['scan', 'warehouse', '--project-dir', '/tmp/project', '--dry-run'], scanIo.io, { scan }),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'run',
projectDir: '/tmp/project',
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: true,
databaseIntrospectionUrl: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
},
scanIo.io,
);
expect(scanIo.stderr()).toBe('Project: /tmp/project\n');
});
it('dispatches top-level scan --mode relationships through Commander', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'], io.io, {
scan,
}),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'run',
projectDir: '/tmp/project',
connectionId: 'warehouse',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
databaseIntrospectionUrl: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
},
io.io,
);
expect(io.stderr()).toBe('Project: /tmp/project\n');
});
it.each(['--enrich', '--detect-relationships'])('rejects removed scan shorthand option %s', async (option) => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(runKtxCli(['scan', 'warehouse', option], io.io, { scan })).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toContain(`unknown option '${option}'`);
});
it('rejects scan without a connection id', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(runKtxCli(['scan', '--dry-run'], io.io, { scan })).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/missing required argument/i);
});
it('rejects invalid scan modes before dispatch', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(runKtxCli(['scan', 'warehouse', '--mode', 'deep'], io.io, { scan })).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toContain("argument 'deep' is invalid");
expect(io.stderr()).toContain('Allowed choices are structural, enriched, relationships');
});
it.each([
['scan', 'report', 'scan-run-1'],
['scan', 'relationships', 'scan-run-1'],
])('rejects removed scan subcommand %s %s', async (command, subcommand, runId) => {
{ argv: ['scan'] },
{ argv: ['scan', '--help'] },
{ argv: ['scan', 'warehouse'] },
{ argv: ['scan', 'warehouse', '--project-dir', '/tmp/project', '--dry-run'] },
{ argv: ['scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'] },
])('rejects removed top-level scan command $argv', async ({ argv }) => {
const io = makeIo();
const scan = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
await expect(runKtxCli([command, subcommand, runId], io.io, { scan })).resolves.toBe(1);
await expect(runKtxCli(argv, io.io, { ingest })).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/too many arguments|unknown command|error:/);
expect(ingest).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/unknown command|error:/);
});
it('dispatches top-level ingest run through the low-level ingest Commander registration', async () => {

View file

@ -1565,63 +1565,20 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toContain('[debug] dispatch=connection');
});
it('routes scan through the top-level command with top-level project-dir', async () => {
it.each([
{ argv: ['scan'] },
{ argv: ['scan', '--help'] },
{ argv: ['scan', 'warehouse'] },
{ argv: ['scan', 'warehouse', '--project-dir', '/tmp/project'] },
{ argv: ['scan', 'warehouse', '--mode', 'relationships'] },
])('rejects removed top-level scan command $argv', async ({ argv }) => {
const testIo = makeIo();
const scan = vi.fn().mockResolvedValue(0);
const ingest = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(
0,
);
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
expect(scan).toHaveBeenCalledWith(
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
databaseIntrospectionUrl: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
},
testIo.io,
);
});
it('routes scan managed runtime install policies', async () => {
const autoIo = makeIo();
const neverIo = makeIo();
const conflictIo = makeIo();
const scan = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--yes'], autoIo.io, { scan }))
.resolves.toBe(0);
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--no-input'], neverIo.io, { scan }))
.resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--yes', '--no-input'], conflictIo.io, {
scan,
}),
).resolves.toBe(1);
expect(scan).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
command: 'run',
runtimeInstallPolicy: 'auto',
}),
autoIo.io,
);
expect(scan).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: 'run',
runtimeInstallPolicy: 'never',
}),
neverIo.io,
);
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
expect(testIo.stderr()).toMatch(/unknown command|error:/);
expect(ingest).not.toHaveBeenCalled();
});
it('rejects removed public serve command options before dispatch', async () => {
@ -1669,27 +1626,17 @@ describe('runKtxCli', () => {
it('rejects removed dev command groups without invoking execution', async () => {
for (const command of ['scan', 'ingest', 'mapping']) {
const testIo = makeIo();
const scan = vi.fn().mockResolvedValue(0);
const ingest = vi.fn().mockResolvedValue(0);
const sl = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['dev', command], testIo.io, { scan, sl })).resolves.toBe(1);
await expect(runKtxCli(['dev', command], testIo.io, { ingest, sl })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/);
expect(scan).not.toHaveBeenCalled();
expect(ingest).not.toHaveBeenCalled();
expect(sl).not.toHaveBeenCalled();
}
});
it('rejects removed scan subcommands without invoking scan execution', async () => {
const testIo = makeIo();
const scan = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['scan', 'report'], testIo.io, { scan })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/too many arguments|unknown command|error:/);
expect(scan).not.toHaveBeenCalled();
});
it('rejects removed reserved dev subcommands', async () => {
const testIo = makeIo();

View file

@ -33,9 +33,9 @@ describe('project directory defaults', () => {
const connection = vi.fn(async () => 0);
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const scan = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const setup = vi.fn(async () => 0);
const deps: KtxCliDeps = { connection, doctor, ingest, scan, setup };
const deps: KtxCliDeps = { connection, doctor, ingest, publicIngest, setup };
const cases: Array<{
argv: string[];
@ -68,9 +68,9 @@ describe('project directory defaults', () => {
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['scan', 'warehouse'],
spy: scan,
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
argv: ['ingest', 'warehouse', '--no-input'],
spy: publicIngest,
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', targetConnectionId: 'warehouse' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
];
@ -86,13 +86,15 @@ describe('project directory defaults', () => {
it('lets explicit global --project-dir override KTX_PROJECT_DIR before and after nested commands', async () => {
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
const scan = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const scanIo = makeIo();
const publicIngestIo = makeIo();
const ingestIo = makeIo();
await expect(
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'scan', 'warehouse'], scanIo.io, { scan }),
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'ingest', 'warehouse', '--no-input'], publicIngestIo.io, {
publicIngest,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['ingest', 'status', 'run-1', '--project-dir=/tmp/ktx-explicit-project'], ingestIo.io, {
@ -100,15 +102,15 @@ describe('project directory defaults', () => {
}),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
expect(publicIngest).toHaveBeenCalledWith(
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
scanIo.io,
publicIngestIo.io,
);
expect(ingest).toHaveBeenCalledWith(
expect.objectContaining({ command: 'status', projectDir: '/tmp/ktx-explicit-project' }),
ingestIo.io,
);
expect(scanIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(publicIngestIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(ingestIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
});
@ -126,18 +128,18 @@ describe('project directory defaults', () => {
await writeFile(join(projectDir, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
const expectedProjectDir = await realpath(projectDir);
const scan = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const testIo = makeIo();
try {
process.chdir(nestedDir);
await expect(runKtxCli(['scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(0);
await expect(runKtxCli(['ingest', 'warehouse', '--no-input'], testIo.io, { publicIngest })).resolves.toBe(0);
} finally {
process.chdir(originalCwd);
await rm(root, { recursive: true, force: true });
}
expect(scan).toHaveBeenCalledWith(
expect(publicIngest).toHaveBeenCalledWith(
expect.objectContaining({ command: 'run', projectDir: expectedProjectDir }),
testIo.io,
);

View file

@ -204,8 +204,8 @@ describe('standalone built ktx CLI smoke', () => {
expect([0, 1]).toContain(result.code);
});
it('runs structural and enriched scans through the built binary with manifest artifacts', async () => {
const projectDir = join(tempDir, 'scan-project');
it('runs fast public database ingest through the built binary with manifest artifacts', async () => {
const projectDir = join(tempDir, 'database-ingest-project');
const init = await runSetupNewProject(projectDir);
expectProjectStderr(init, projectDir);
@ -219,43 +219,19 @@ describe('standalone built ktx CLI smoke', () => {
expect(connectionTest.stdout).toContain('Driver: sqlite');
expect(connectionTest.stdout).toContain('Tables: 2');
const structural = await runBuiltCli(['scan', 'warehouse', '--project-dir', projectDir]);
expectProjectStderr(structural, projectDir);
expect(structural.stdout).toContain('Status: done');
expect(structural.stdout).toContain('Mode: structural');
expect(structural.stdout).toContain('Schema shards: 1');
const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']);
expectProjectStderr(ingest, projectDir);
expect(ingest.stdout).toContain('Ingest finished');
expect(ingest.stdout).toContain('warehouse');
expect(ingest.stdout).toContain('Database schema');
expect(ingest.stdout).toContain('warehouse done');
expect(ingest.stdout).not.toContain('KTX scan completed');
const structuralManifest = await readFile(
join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'),
'utf-8',
);
expect(structuralManifest).toContain('customers:');
expect(structuralManifest).toContain('orders:');
expect(structuralManifest).toContain('source: formal');
expect(structuralManifest).not.toContain('ai:');
const providerlessEnriched = await runBuiltCli([
'scan',
'warehouse',
'--project-dir',
projectDir,
'--mode',
'enriched',
]);
expectProjectStderr(providerlessEnriched, projectDir);
expect(providerlessEnriched.stdout).toContain('Mode: enriched');
expect(providerlessEnriched.stdout).toContain('Relationships');
expect(providerlessEnriched.stdout).toContain('Accepted: 1');
expect(providerlessEnriched.stdout).toContain('scan_enrichment_backend_not_configured');
expect(providerlessEnriched.stdout).toContain('Enrichment artifacts: 3');
await writeSqliteScanConfig(projectDir, dbPath, true);
const enriched = await runBuiltCli(['scan', 'warehouse', '--project-dir', projectDir, '--mode', 'enriched']);
expectProjectStderr(enriched, projectDir);
expect(enriched.stdout).toContain('Mode: enriched');
expect(enriched.stdout).toContain('Enrichment artifacts:');
const enrichedManifest = await readFile(join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'), 'utf-8');
expect(enrichedManifest).toContain('Deterministic description');
const manifest = await readFile(join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'), 'utf-8');
expect(manifest).toContain('customers:');
expect(manifest).toContain('orders:');
expect(manifest).toContain('source: formal');
expect(manifest).not.toContain('ai:');
}, 30_000);
it('parses gateway LLM config and OpenAI enrichment embeddings used by standalone scans without network calls', async () => {

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'],
]);
});