mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
fix(cli): remove top-level scan command
This commit is contained in:
parent
601591bfbf
commit
011d694ed3
10 changed files with 182 additions and 359 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue