From 011d694ed3e74cf98f77cdd6a01da8f7e7366706 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 22:16:17 +0200 Subject: [PATCH] fix(cli): remove top-level scan command --- packages/cli/src/cli-program.ts | 10 +- packages/cli/src/cli-runtime.ts | 2 - packages/cli/src/commands/scan-commands.ts | 76 ------------ packages/cli/src/dev.test.ts | 108 ++---------------- packages/cli/src/index.test.ts | 81 +++---------- packages/cli/src/project-dir.test.ts | 30 ++--- packages/cli/src/standalone-smoke.test.ts | 52 +++------ scripts/relationship-orbit-verification.mjs | 68 +++++++++-- .../relationship-orbit-verification.test.mjs | 106 +++++++++-------- scripts/run-ktx.test.mjs | 8 +- 10 files changed, 182 insertions(+), 359 deletions(-) delete mode 100644 packages/cli/src/commands/scan-commands.ts diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 69437aec..e9265c09 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -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) { diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index 77d7c33c..6f25204d 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -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; publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise; runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise; - scan?: (args: KtxScanArgs, io: KtxCliIo) => Promise; knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise; sl?: (args: KtxSlArgs, io: KtxCliIo) => Promise; } diff --git a/packages/cli/src/commands/scan-commands.ts b/packages/cli/src/commands/scan-commands.ts deleted file mode 100644 index 0fd9b225..00000000 --- a/packages/cli/src/commands/scan-commands.ts +++ /dev/null @@ -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 { - const runner = context.deps.scan ?? (await import('../scan.js')).runKtxScan; - context.setExitCode(await runner(args, context.io)); -} - -type KtxScanModeOption = Extract['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('', 'KTX connection id to scan', parseConnectionId) - .option( - '--mode ', - 'Scan mode: structural, enriched, relationships (default: structural)', - parseScanModeOption, - ) - .option('--dry-run', 'Run without writing scan results', false) - .option('--database-introspection-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), - }); - }); -} diff --git a/packages/cli/src/dev.test.ts b/packages/cli/src/dev.test.ts index d8b88449..1f3c3db9 100644 --- a/packages/cli/src/dev.test.ts +++ b/packages/cli/src/dev.test.ts @@ -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] ', '--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 () => { diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index eff60cd4..bc16c5fd 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -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(); diff --git a/packages/cli/src/project-dir.test.ts b/packages/cli/src/project-dir.test.ts index c0022d4d..ed8bdeb3 100644 --- a/packages/cli/src/project-dir.test.ts +++ b/packages/cli/src/project-dir.test.ts @@ -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, ); diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/src/standalone-smoke.test.ts index c6fefd96..1302953c 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/src/standalone-smoke.test.ts @@ -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 () => { diff --git a/scripts/relationship-orbit-verification.mjs b/scripts/relationship-orbit-verification.mjs index 81f81187..5f9ada62 100644 --- a/scripts/relationship-orbit-verification.mjs +++ b/scripts/relationship-orbit-verification.mjs @@ -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, diff --git a/scripts/relationship-orbit-verification.test.mjs b/scripts/relationship-orbit-verification.test.mjs index a6dc3607..65a200c4 100644 --- a/scripts/relationship-orbit-verification.test.mjs +++ b/scripts/relationship-orbit-verification.test.mjs @@ -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; diff --git a/scripts/run-ktx.test.mjs b/scripts/run-ktx.test.mjs index 3263ef30..1533b67c 100644 --- a/scripts/run-ktx.test.mjs +++ b/scripts/run-ktx.test.mjs @@ -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'], ]); });