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 () => {