feat(cli): clean up dev command surface

This commit is contained in:
Andrey Avtomonov 2026-05-13 11:14:16 +02:00
parent e13350c970
commit 2fe03b2212
53 changed files with 406 additions and 3907 deletions

View file

@ -29,11 +29,14 @@ describe('dev Commander tree', () => {
await expect(runKtxCli(['dev', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
for (const command of ['init', 'runtime', 'scan', 'ingest', 'mapping']) {
for (const command of ['init', 'runtime']) {
expect(testIo.stdout()).toContain(command);
}
for (const removed of [
'doctor',
'scan',
'ingest',
'mapping',
'knowledge',
'model',
'replay',
@ -102,6 +105,12 @@ describe('dev Commander tree', () => {
it('rejects removed dev command groups', async () => {
for (const argv of [
['dev', 'doctor', 'setup'],
['dev', 'runtime', 'doctor'],
['dev', 'scan', 'warehouse'],
['dev', 'ingest', 'run'],
['dev', 'mapping', 'list'],
['dev', 'completion', 'zsh'],
['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', ''],
['dev', 'knowledge', 'list'],
['dev', 'model', 'list'],
['dev', 'artifacts'],
@ -117,90 +126,15 @@ describe('dev Commander tree', () => {
it.each([
{
argv: ['dev', 'runtime', '--help'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'doctor', 'prune'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'prune'],
},
{
argv: ['dev', 'scan', '--help'],
expected: [
'Usage: ktx dev scan',
'--mode <mode>',
'structural',
'relationships',
'--dry-run',
'status',
'report',
'relationships',
'relationship-apply',
'relationship-feedback',
'relationship-calibration',
'relationship-thresholds',
],
argv: ['scan', '--help'],
expected: ['Usage: ktx scan [options] <connectionId>', '--mode <mode>', 'structural', 'relationships', '--dry-run'],
},
{
argv: ['dev', 'scan', 'report', '--help'],
expected: ['Usage: ktx dev scan report [options] <runId>', '<runId>', '--json'],
},
{
argv: ['dev', 'scan', 'relationships', '--help'],
expected: [
'Usage: ktx dev scan relationships [options] <runId>',
'--status <status>',
'--limit <count>',
'--accept <candidateId>',
'--reject <candidateId>',
'--note <text>',
'--reviewer <name>',
'--json',
],
},
{
argv: ['dev', 'scan', 'relationship-apply', '--help'],
expected: [
'Usage: ktx dev scan relationship-apply [options] <runId>',
'--all-accepted',
'--candidate <candidateId>',
'--dry-run',
],
},
{
argv: ['dev', 'scan', 'relationship-thresholds', '--help'],
expected: [
'Usage: ktx dev scan relationship-thresholds [options]',
'--connection <connectionId>',
'--min-total-labels <count>',
'--min-accepted-labels <count>',
'--min-rejected-labels <count>',
'--json',
],
},
{
argv: ['dev', 'scan', 'relationship-feedback', '--help'],
expected: [
'Usage: ktx dev scan relationship-feedback [options]',
'--connection <connectionId>',
'--decision <decision>',
'--json',
'--jsonl',
],
},
{
argv: ['dev', 'scan', 'relationship-calibration', '--help'],
expected: [
'Usage: ktx dev scan relationship-calibration [options]',
'--connection <connectionId>',
'--decision <decision>',
'--accept-threshold <value>',
'--review-threshold <value>',
'--json',
],
},
{
argv: ['dev', 'ingest', 'run', '--help'],
expected: ['Usage: ktx dev ingest run [options]', '--connection-id <connectionId>', '--adapter <adapter>'],
},
{
argv: ['dev', 'mapping', 'sync-state', 'set', '--help'],
expected: ['Usage: ktx dev mapping sync-state set [options] <connectionId>', '--mode <mode>'],
argv: ['ingest', 'run', '--help'],
expected: ['Usage: ktx ingest run [options]', '--connection-id <connectionId>', '--adapter <adapter>'],
},
])('prints generated nested help for $argv', async ({ argv, expected }) => {
const io = makeIo();
@ -219,12 +153,12 @@ describe('dev Commander tree', () => {
expect(scan).not.toHaveBeenCalled();
});
it('dispatches dev scan through Commander with injected dependencies', async () => {
it('dispatches top-level scan through Commander with injected dependencies', async () => {
const scanIo = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['dev', 'scan', 'warehouse', '--project-dir', '/tmp/project', '--dry-run'], scanIo.io, { scan }),
runKtxCli(['scan', 'warehouse', '--project-dir', '/tmp/project', '--dry-run'], scanIo.io, { scan }),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
@ -244,12 +178,12 @@ describe('dev Commander tree', () => {
expect(scanIo.stderr()).toBe('Project: /tmp/project\n');
});
it('dispatches dev scan --mode relationships through Commander', async () => {
it('dispatches top-level scan --mode relationships through Commander', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['dev', 'scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'], io.io, {
runKtxCli(['scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'], io.io, {
scan,
}),
).resolves.toBe(0);
@ -275,375 +209,53 @@ describe('dev Commander tree', () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(runKtxCli(['dev', 'scan', 'warehouse', option], io.io, { scan })).resolves.toBe(1);
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 dev scan without a connection id or subcommand', async () => {
it('rejects scan without a connection id', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(runKtxCli(['dev', 'scan', '--dry-run'], io.io, { scan })).resolves.toBe(1);
await expect(runKtxCli(['scan', '--dry-run'], io.io, { scan })).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stdout()).toContain('Usage: ktx dev scan');
expect(io.stderr()).toContain('ktx dev scan requires <connectionId> or a subcommand');
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(['dev', 'scan', 'warehouse', '--mode', 'deep'], io.io, { scan })).resolves.toBe(1);
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('prints dev scan subcommand help with the canonical command name', async () => {
it.each([
['scan', 'report', 'scan-run-1'],
['scan', 'relationships', 'scan-run-1'],
])('rejects removed scan subcommand %s %s', async (command, subcommand, runId) => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(runKtxCli(['dev', 'scan', 'report', '--help'], io.io, { scan })).resolves.toBe(0);
expect(io.stdout()).toContain('--project-dir is inherited from `ktx dev scan`');
expect(io.stdout()).not.toContain('--project-dir is inherited from `ktx scan`');
expect(scan).not.toHaveBeenCalled();
});
it('dispatches dev scan report in human and json modes', async () => {
const humanIo = makeIo();
const jsonIo = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['dev', 'scan', 'report', 'scan-run-1', '--project-dir', '/tmp/project'], humanIo.io, { scan }),
).resolves.toBe(0);
await expect(
runKtxCli(['dev', 'scan', 'report', 'scan-run-2', '--project-dir', '/tmp/project', '--json'], jsonIo.io, {
scan,
}),
).resolves.toBe(0);
expect(scan).toHaveBeenNthCalledWith(
1,
{ command: 'report', projectDir: '/tmp/project', runId: 'scan-run-1', json: false },
humanIo.io,
);
expect(scan).toHaveBeenNthCalledWith(
2,
{ command: 'report', projectDir: '/tmp/project', runId: 'scan-run-2', json: true },
jsonIo.io,
);
});
it('dispatches dev scan relationships with filters through Commander', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationships',
'scan-run-review',
'--project-dir',
'/tmp/project',
'--status',
'rejected',
'--limit',
'5',
'--json',
],
io.io,
{ scan },
),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'relationships',
projectDir: '/tmp/project',
runId: 'scan-run-review',
status: 'rejected',
json: true,
limit: 5,
},
io.io,
);
expect(io.stderr()).toBe('');
});
it('dispatches dev scan relationship decision recording through Commander', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationships',
'scan-run-review',
'--project-dir',
'/tmp/project',
'--accept',
'orders:orders.customer_id->customers:customers.id',
'--reviewer',
'Andrey',
'--note',
'Looks right',
'--json',
],
io.io,
{ scan },
),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'relationshipDecision',
projectDir: '/tmp/project',
runId: 'scan-run-review',
candidateId: 'orders:orders.customer_id->customers:customers.id',
decision: 'accepted',
reviewer: 'Andrey',
note: 'Looks right',
json: true,
},
io.io,
);
expect(io.stderr()).toBe('');
});
it.each(['--accept', '--reject'])('rejects empty relationship decision candidate ids for %s', async (option) => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['dev', 'scan', 'relationships', 'scan-run-review', option, ''], io.io, { scan }),
).resolves.toBe(1);
await expect(runKtxCli([command, subcommand, runId], io.io, { scan })).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toContain('must not be empty');
expect(io.stderr()).toMatch(/too many arguments|unknown command|error:/);
});
it('rejects relationship feedback JSON and JSONL output together', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['dev', 'scan', 'relationship-feedback', '--json', '--jsonl'], io.io, { scan }),
).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/conflict|cannot be used/i);
});
it('dispatches relationship apply command args', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationship-apply',
'scan-run-a',
'--project-dir',
'/tmp/project',
'--candidate',
'orders:orders.customer_id->customers:customers.id',
'--dry-run',
'--json',
],
io.io,
{ scan },
),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'relationshipApply',
projectDir: '/tmp/project',
runId: 'scan-run-a',
applyAllAccepted: false,
candidateIds: ['orders:orders.customer_id->customers:customers.id'],
dryRun: true,
json: true,
},
io.io,
);
});
it('dispatches scan relationship feedback command with filters and JSONL output', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationship-feedback',
'--project-dir',
'/tmp/project',
'--connection',
'warehouse',
'--decision',
'accepted',
'--jsonl',
],
io.io,
{ scan },
),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'relationshipFeedback',
projectDir: '/tmp/project',
connectionId: 'warehouse',
decision: 'accepted',
json: false,
jsonl: true,
},
io.io,
);
});
it('dispatches scan relationship calibration command with thresholds', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationship-calibration',
'--project-dir',
'/tmp/project',
'--connection',
'warehouse',
'--decision',
'rejected',
'--accept-threshold',
'0.9',
'--review-threshold',
'0.5',
'--json',
],
io.io,
{ scan },
),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'relationshipCalibration',
projectDir: '/tmp/project',
connectionId: 'warehouse',
decision: 'rejected',
acceptThreshold: 0.9,
reviewThreshold: 0.5,
json: true,
},
io.io,
);
});
it('dispatches relationship threshold advice command args', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationship-thresholds',
'--project-dir',
'/tmp/project',
'--connection',
'warehouse',
'--min-total-labels',
'12',
'--min-accepted-labels',
'4',
'--min-rejected-labels',
'3',
'--json',
],
io.io,
{ scan },
),
).resolves.toBe(0);
expect(scan).toHaveBeenCalledWith(
{
command: 'relationshipThresholds',
projectDir: '/tmp/project',
connectionId: 'warehouse',
minTotalLabels: 12,
minAcceptedLabels: 4,
minRejectedLabels: 3,
json: true,
},
io.io,
);
});
it('rejects invalid relationship calibration thresholds before dispatch', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(['dev', 'scan', 'relationship-calibration', '--accept-threshold', '1.5'], io.io, { scan }),
).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toContain('Allowed range is 0 through 1');
});
it('rejects relationship accept and reject options together before dispatch', async () => {
const io = makeIo();
const scan = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'scan',
'relationships',
'scan-run-review',
'--accept',
'orders:orders.customer_id->customers:customers.id',
'--reject',
'orders:orders.customer_id->customers:customers.id',
],
io.io,
{ scan },
),
).resolves.toBe(1);
expect(scan).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/conflict|cannot be used/i);
});
it('dispatches dev ingest run through the low-level ingest Commander registration', async () => {
it('dispatches top-level ingest run through the low-level ingest Commander registration', async () => {
const io = makeIo();
const ingest = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'dev',
'ingest',
'run',
'--connection-id',