mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
feat(cli): clean up dev command surface
This commit is contained in:
parent
e13350c970
commit
2fe03b2212
53 changed files with 406 additions and 3907 deletions
|
|
@ -15,7 +15,7 @@ describe('agent semantic-layer search readiness guidance', () => {
|
|||
nextSteps: [
|
||||
'ktx setup --project-dir /tmp/ktx-search',
|
||||
'ktx status --project-dir /tmp/ktx-search',
|
||||
'ktx ingest <connection>',
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
'ktx agent sl list --json --query "gross revenue" --project-dir /tmp/ktx-search',
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function baseNextSteps(projectDir: string, query: string | undefined): string[]
|
|||
return [
|
||||
`ktx setup --project-dir ${projectDir}`,
|
||||
`ktx status --project-dir ${projectDir}`,
|
||||
'ktx ingest <connection>',
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
projectSearchCommand(projectDir, query),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ describe('runKtxAgent', () => {
|
|||
nextSteps: [
|
||||
`ktx setup --project-dir ${tempDir}`,
|
||||
`ktx status --project-dir ${tempDir}`,
|
||||
'ktx ingest <connection>',
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
`ktx agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
|
|
@ -355,7 +355,7 @@ describe('runKtxAgent', () => {
|
|||
nextSteps: [
|
||||
`ktx setup --project-dir ${tempDir}`,
|
||||
`ktx status --project-dir ${tempDir}`,
|
||||
'ktx ingest <connection>',
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
|||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
||||
import { registerAgentCommands } from './commands/agent-commands.js';
|
||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
import { registerPublicIngestCommands } from './commands/public-ingest-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 +54,7 @@ type CommandPathNode = CommandWithGlobalOptions & {
|
|||
parent?: CommandPathNode | null;
|
||||
};
|
||||
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'scan']);
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
|
|
@ -151,7 +152,7 @@ function isProjectAwareCommand(path: string[]): boolean {
|
|||
|
||||
const rootCommand = path[1];
|
||||
if (rootCommand === 'dev') {
|
||||
return path[2] !== undefined && path[2] !== 'completion' && path[2] !== 'runtime';
|
||||
return path[2] !== undefined && path[2] !== 'runtime';
|
||||
}
|
||||
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
|
||||
}
|
||||
|
|
@ -176,9 +177,6 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
|
|||
}
|
||||
|
||||
if (commandPathKey === 'ktx ingest watch') {
|
||||
return options.json !== true;
|
||||
}
|
||||
if (commandPathKey === 'ktx dev ingest watch') {
|
||||
return options.json !== true && options.plain !== true;
|
||||
}
|
||||
if (commandPathKey === 'ktx connection notion pick') {
|
||||
|
|
@ -230,7 +228,7 @@ function createBaseProgram(info: KtxCliPackageInfo, io: KtxCliIo): Command {
|
|||
.configureHelp({ showGlobalOptions: true })
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nAdvanced:\n ktx dev Low-level diagnostics, scans, adapter commands, and mapping tools.\n',
|
||||
'\nAdvanced:\n ktx dev Low-level project initialization and runtime management.\n',
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.exitOverride()
|
||||
|
|
@ -315,7 +313,11 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
|||
|
||||
registerSetupCommands(program, context);
|
||||
registerConnectionCommands(program, context);
|
||||
registerPublicIngestCommands(program, context);
|
||||
registerIngestCommands(program, context, {
|
||||
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>
|
||||
await (ingestDeps.ingest ?? defaultRunIngest)(ingestArgs, ingestIo),
|
||||
});
|
||||
registerScanCommands(program, context);
|
||||
registerWikiCommands(program, context);
|
||||
registerSlCommands(program, context);
|
||||
registerStatusCommands(program, context);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { KtxConnectionArgs } from './connection.js';
|
|||
import type { KtxDoctorArgs } from './doctor.js';
|
||||
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';
|
||||
|
|
@ -37,7 +36,6 @@ export interface KtxCliDeps {
|
|||
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -68,20 +68,3 @@ export const slQueryCommandSchema = z.object({
|
|||
runtimeInstallPolicy: z.enum(['prompt', 'auto', 'never']),
|
||||
maxRows: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const publicIngestRunCommandSchema = z.object({
|
||||
command: z.literal('run'),
|
||||
projectDir: projectDirSchema,
|
||||
targetConnectionId: safeConnectionIdSchema.optional(),
|
||||
all: z.boolean(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
|
||||
export const publicIngestReadCommandSchema = z.object({
|
||||
command: z.enum(['status', 'watch']),
|
||||
projectDir: projectDirSchema,
|
||||
runId: z.string().min(1).optional(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import type { CommandUnknownOpts } from '@commander-js/extra-typings';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import { completeCommanderInput, installZshCompletion, zshCompletionScript } from '../completion.js';
|
||||
|
||||
export function registerCompletionCommands(
|
||||
program: CommandUnknownOpts,
|
||||
context: KtxCliCommandContext,
|
||||
completionRoot: CommandUnknownOpts = program,
|
||||
): void {
|
||||
program
|
||||
.command('completion')
|
||||
.description('Generate shell completion scripts')
|
||||
.command('zsh')
|
||||
.description('Generate zsh completion script')
|
||||
.option('--install', 'Install zsh completion into ~/.zfunc and update ~/.zshrc', false)
|
||||
.action(async (options: { install?: boolean }) => {
|
||||
if (options.install === true) {
|
||||
const result = await installZshCompletion();
|
||||
context.io.stdout.write(`Installed zsh completion: ${result.completionPath}\n`);
|
||||
context.io.stdout.write(`Updated zsh config: ${result.zshrcPath}\n`);
|
||||
context.io.stdout.write('Restart your shell or run: source ~/.zshrc\n');
|
||||
context.setExitCode(0);
|
||||
return;
|
||||
}
|
||||
context.io.stdout.write(zshCompletionScript());
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
program
|
||||
.command('__complete', { hidden: true })
|
||||
.description('Internal shell completion endpoint')
|
||||
.requiredOption('--shell <shell>', 'Shell requesting completions')
|
||||
.requiredOption('--position <position>', 'Current shell word position', (value) => Number(value))
|
||||
.argument('[words...]', 'Current shell words')
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.action((words: string[], options: { shell: string; position: number }) => {
|
||||
if (options.shell !== 'zsh') {
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
for (const completion of completeCommanderInput(completionRoot, { position: options.position, words })) {
|
||||
context.io.stdout.write(`${completion}\n`);
|
||||
}
|
||||
context.setExitCode(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export function registerConnectionMetabaseCommands(connection: Command, context:
|
|||
' ktx connection mapping refresh <connectionId> --auto-accept\n' +
|
||||
' ktx connection mapping set <connectionId> databaseMappings <id>=<target>\n' +
|
||||
' ktx connection mapping set-sync-enabled <connectionId> <id> --enabled true\n' +
|
||||
' ktx ingest <connectionId>\n',
|
||||
' ktx ingest run --connection-id <connectionId> --adapter metabase\n',
|
||||
)
|
||||
.option(
|
||||
'--map <metabaseDatabaseId=targetConnectionId>',
|
||||
|
|
|
|||
|
|
@ -743,7 +743,9 @@ export async function runKtxConnectionMetabaseSetup(
|
|||
|
||||
io.stdout.write(`Connection: ${connectionId}\n`);
|
||||
io.stdout.write(`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Next: ktx ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
io.stdout.write(
|
||||
`Next: ktx ingest run --connection-id ${connectionId} --adapter metabase --project-dir ${args.projectDir}\n`,
|
||||
);
|
||||
|
||||
if (args.runIngest) {
|
||||
const ingestRunner = deps.runPublicIngest ?? runKtxPublicIngest;
|
||||
|
|
@ -759,7 +761,9 @@ export async function runKtxConnectionMetabaseSetup(
|
|||
io,
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
io.stderr.write(`Ingest failed; re-run: ktx ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
io.stderr.write(
|
||||
`Ingest failed; re-run: ktx ingest run --connection-id ${connectionId} --adapter metabase --project-dir ${args.projectDir}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import type { Command } from '@commander-js/extra-typings';
|
||||
import { type CommandWithGlobalOptions, type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KtxDoctorArgs } from '../doctor.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/doctor-commands');
|
||||
|
||||
function outputMode(options: { json?: boolean }): 'plain' | 'json' {
|
||||
return options.json === true ? 'json' : 'plain';
|
||||
}
|
||||
|
||||
function inputMode(options: { input?: boolean }): { inputMode?: 'disabled' } {
|
||||
return options.input === false ? { inputMode: 'disabled' } : {};
|
||||
}
|
||||
|
||||
async function runDoctorArgs(context: KtxCliCommandContext, args: KtxDoctorArgs): Promise<void> {
|
||||
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerDoctorCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const doctor = program
|
||||
.command('doctor')
|
||||
.description('Check KTX setup and project readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { json?: boolean; input?: boolean }, command) => {
|
||||
await runDoctorArgs(context, {
|
||||
command: 'project',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
doctor
|
||||
.command('setup')
|
||||
.description('Check KTX install, build, and local runtime readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(
|
||||
async (
|
||||
_options: { json?: boolean; input?: boolean },
|
||||
command: CommandWithGlobalOptions,
|
||||
) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as {
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
};
|
||||
await runDoctorArgs(context, { command: 'setup', outputMode: outputMode(options), ...inputMode(options) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { InvalidArgumentError, type Command } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { publicIngestReadCommandSchema, publicIngestRunCommandSchema } from '../command-schemas.js';
|
||||
import type { KtxPublicIngestArgs, KtxPublicIngestInputMode } from '../public-ingest.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/public-ingest-commands');
|
||||
|
||||
interface PublicIngestOptions {
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
function inputMode(options: { input?: boolean }): KtxPublicIngestInputMode {
|
||||
return options.input === false ? 'disabled' : 'auto';
|
||||
}
|
||||
|
||||
async function runPublicIngestArgs(context: KtxCliCommandContext, args: KtxPublicIngestArgs): Promise<void> {
|
||||
const runner = context.deps.publicIngest ?? (await import('../public-ingest.js')).runKtxPublicIngest;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function parsePublicIngestConnectionId(value: string): string {
|
||||
if (value === 'run') {
|
||||
throw new InvalidArgumentError('run is reserved; use ktx dev ingest run for low-level adapter syntax');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function registerPublicIngestCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const ingest = program
|
||||
.command('ingest')
|
||||
.description('Build and refresh KTX context from configured sources')
|
||||
.usage('[options] [connectionId]')
|
||||
.argument('[connectionId]', 'Connection id to ingest', parsePublicIngestConnectionId)
|
||||
.option('--all', 'Ingest every eligible configured source', false)
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.addHelpText(
|
||||
'after',
|
||||
[
|
||||
'',
|
||||
'Examples:',
|
||||
' ktx ingest <connectionId> [options]',
|
||||
' ktx ingest --all [options]',
|
||||
' ktx ingest status [runId] [options]',
|
||||
' ktx ingest watch [runId] [options]',
|
||||
'',
|
||||
'Project directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.',
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('ingest', actionCommand);
|
||||
})
|
||||
.action(async (connectionId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = command.opts();
|
||||
if (options.all === true && connectionId) {
|
||||
throw new Error('ktx ingest accepts either --all or <connectionId>, not both');
|
||||
}
|
||||
const args = publicIngestRunCommandSchema.parse({
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(connectionId ? { targetConnectionId: connectionId } : {}),
|
||||
all: options.all === true,
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('status')
|
||||
.description('Print status for the latest or selected public ingest run')
|
||||
.argument('[runId]', 'Public ingest run id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as PublicIngestOptions;
|
||||
const args = publicIngestReadCommandSchema.parse({
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('watch')
|
||||
.description('Open the latest or selected public ingest visual report')
|
||||
.argument('[runId]', 'Public ingest run id')
|
||||
.option('--json', 'Print JSON output instead of the visual report', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as PublicIngestOptions;
|
||||
const args = publicIngestReadCommandSchema.parse({
|
||||
command: 'watch',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
}
|
||||
|
|
@ -74,18 +74,6 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
|
|||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('doctor')
|
||||
.description('Check managed Python runtime prerequisites and installation')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'doctor',
|
||||
cliVersion: context.packageInfo.version,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('prune')
|
||||
.description('Remove stale managed Python runtimes for older CLI versions')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
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';
|
||||
|
|
@ -13,6 +13,16 @@ async function runScanArgs(context: KtxCliCommandContext, args: KtxScanArgs): Pr
|
|||
|
||||
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;
|
||||
|
|
@ -20,82 +30,18 @@ function parseScanModeOption(value: string): KtxScanModeOption {
|
|||
throw new InvalidArgumentError('Allowed choices are structural, enriched, relationships');
|
||||
}
|
||||
|
||||
type KtxRelationshipStatusOption = Extract<KtxScanArgs, { command: 'relationships' }>['status'];
|
||||
type KtxRelationshipFeedbackDecisionOption = Extract<KtxScanArgs, { command: 'relationshipFeedback' }>['decision'];
|
||||
|
||||
function parseRelationshipStatusOption(value: string): KtxRelationshipStatusOption {
|
||||
if (value === 'accepted' || value === 'review' || value === 'rejected' || value === 'skipped' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are accepted, review, rejected, skipped, all');
|
||||
}
|
||||
|
||||
function parseRelationshipFeedbackDecisionOption(value: string): KtxRelationshipFeedbackDecisionOption {
|
||||
if (value === 'accepted' || value === 'rejected' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are accepted, rejected, all');
|
||||
}
|
||||
|
||||
function parseNonEmptyOption(value: string): string {
|
||||
if (value.trim().length === 0) {
|
||||
throw new InvalidArgumentError('must not be empty');
|
||||
function parseConnectionId(value: string): string {
|
||||
if (REMOVED_SCAN_SUBCOMMAND_NAMES.has(value)) {
|
||||
throw new InvalidArgumentError(`"${value}" is not a scan connection id`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRelationshipCalibrationThreshold(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed range is 0 through 1');
|
||||
}
|
||||
|
||||
function relationshipDecisionArgs(options: {
|
||||
accept?: string;
|
||||
reject?: string;
|
||||
reviewer?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}): Pick<
|
||||
Extract<KtxScanArgs, { command: 'relationshipDecision' }>,
|
||||
'candidateId' | 'decision' | 'reviewer' | 'note' | 'json'
|
||||
> | null {
|
||||
const decisionCount = [options.accept !== undefined, options.reject !== undefined].filter(Boolean).length;
|
||||
if (decisionCount > 1) {
|
||||
throw new Error('Only one relationship review decision option can be used: --accept and --reject conflict');
|
||||
}
|
||||
if (options.accept !== undefined) {
|
||||
return {
|
||||
candidateId: options.accept,
|
||||
decision: 'accepted',
|
||||
reviewer: options.reviewer ?? 'ktx',
|
||||
note: options.note ?? null,
|
||||
json: options.json === true,
|
||||
};
|
||||
}
|
||||
if (options.reject !== undefined) {
|
||||
return {
|
||||
candidateId: options.reject,
|
||||
decision: 'rejected',
|
||||
reviewer: options.reviewer ?? 'ktx',
|
||||
note: options.note ?? null,
|
||||
json: options.json === true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectRelationshipCandidateOption(value: string, previous: string[]): string[] {
|
||||
return [...previous, parseNonEmptyOption(value)];
|
||||
}
|
||||
|
||||
export function registerScanCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const scan = program
|
||||
program
|
||||
.command('scan')
|
||||
.description('Run or inspect standalone connection scans')
|
||||
.argument('[connectionId]', 'KTX connection id to scan')
|
||||
.description('Run a standalone connection scan')
|
||||
.argument('<connectionId>', 'KTX connection id to scan', parseConnectionId)
|
||||
.option(
|
||||
'--mode <mode>',
|
||||
'Scan mode: structural, enriched, relationships (default: structural)',
|
||||
|
|
@ -113,13 +59,7 @@ export function registerScanCommands(program: Command, context: KtxCliCommandCon
|
|||
.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('scan', actionCommand);
|
||||
})
|
||||
.action(async (connectionId: string | undefined, options, command) => {
|
||||
if (!connectionId) {
|
||||
scan.outputHelp();
|
||||
context.io.stderr.write('ktx dev scan requires <connectionId> or a subcommand\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
.action(async (connectionId: string, options, command) => {
|
||||
const mode = options.mode ?? 'structural';
|
||||
await runScanArgs(context, {
|
||||
command: 'run',
|
||||
|
|
@ -133,226 +73,4 @@ export function registerScanCommands(program: Command, context: KtxCliCommandCon
|
|||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('status')
|
||||
.description('Print status for a local scan run')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, _options: unknown, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('report')
|
||||
.description('Print a local scan report')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option('--json', 'Print the raw scan report JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'report',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationships')
|
||||
.description('Print relationship artifacts for a local scan run')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Relationship status: accepted, review, rejected, skipped, all',
|
||||
parseRelationshipStatusOption,
|
||||
'review',
|
||||
)
|
||||
.option('--limit <count>', 'Maximum relationships to print per status', parsePositiveIntegerOption, 25)
|
||||
.addOption(
|
||||
new Option('--accept <candidateId>', 'Record a reviewer accepted decision for a relationship candidate')
|
||||
.argParser(parseNonEmptyOption)
|
||||
.conflicts('reject'),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--reject <candidateId>', 'Record a reviewer rejected decision for a relationship candidate')
|
||||
.argParser(parseNonEmptyOption)
|
||||
.conflicts('accept'),
|
||||
)
|
||||
.option('--note <text>', 'Attach a note when recording a relationship review decision')
|
||||
.option('--reviewer <name>', 'Reviewer name for a relationship review decision')
|
||||
.option('--json', 'Print relationship artifacts as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
const decision = relationshipDecisionArgs(options);
|
||||
if (decision) {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipDecision',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
candidateId: decision.candidateId,
|
||||
decision: decision.decision,
|
||||
reviewer: decision.reviewer,
|
||||
note: decision.note,
|
||||
json: decision.json,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runScanArgs(context, {
|
||||
command: 'relationships',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
status: options.status,
|
||||
json: options.json === true,
|
||||
limit: options.limit,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-apply')
|
||||
.description('Apply accepted relationship review decisions as manual manifest joins')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option('--all-accepted', 'Apply all accepted relationship review decisions for the scan run', false)
|
||||
.option(
|
||||
'--candidate <candidateId>',
|
||||
'Apply one accepted relationship review decision',
|
||||
collectRelationshipCandidateOption,
|
||||
[],
|
||||
)
|
||||
.option('--dry-run', 'Preview relationships that would be written without rewriting manifest shards', false)
|
||||
.option('--json', 'Print the apply result as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
const parentOptions = command.parent?.opts() as { dryRun?: boolean } | undefined;
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipApply',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
applyAllAccepted: options.allAccepted === true,
|
||||
candidateIds: options.candidate,
|
||||
dryRun: options.dryRun === true || parentOptions?.dryRun === true,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-feedback')
|
||||
.description('Export persisted relationship review decisions as calibration labels')
|
||||
.option('--connection <connectionId>', 'Only export labels for one KTX connection')
|
||||
.option(
|
||||
'--decision <decision>',
|
||||
'Relationship feedback decision: accepted, rejected, all',
|
||||
parseRelationshipFeedbackDecisionOption,
|
||||
'all',
|
||||
)
|
||||
.addOption(new Option('--json', 'Print the export as JSON').default(false).conflicts('jsonl'))
|
||||
.addOption(new Option('--jsonl', 'Print labels as newline-delimited JSON').default(false).conflicts('json'))
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipFeedback',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
decision: options.decision,
|
||||
json: options.json === true,
|
||||
jsonl: options.jsonl === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-calibration')
|
||||
.description('Summarize relationship feedback labels against current score thresholds')
|
||||
.option('--connection <connectionId>', 'Only calibrate labels for one KTX connection')
|
||||
.option(
|
||||
'--decision <decision>',
|
||||
'Relationship feedback decision: accepted, rejected, all',
|
||||
parseRelationshipFeedbackDecisionOption,
|
||||
'all',
|
||||
)
|
||||
.option(
|
||||
'--accept-threshold <value>',
|
||||
'Score threshold treated as predicted accepted',
|
||||
parseRelationshipCalibrationThreshold,
|
||||
0.85,
|
||||
)
|
||||
.option(
|
||||
'--review-threshold <value>',
|
||||
'Score threshold treated as predicted review',
|
||||
parseRelationshipCalibrationThreshold,
|
||||
0.55,
|
||||
)
|
||||
.option('--json', 'Print the calibration report as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipCalibration',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
decision: options.decision,
|
||||
acceptThreshold: options.acceptThreshold,
|
||||
reviewThreshold: options.reviewThreshold,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-thresholds')
|
||||
.description('Evaluate relationship feedback labels for offline threshold advice')
|
||||
.option('--connection <connectionId>', 'Only evaluate labels for one KTX connection')
|
||||
.option(
|
||||
'--min-total-labels <count>',
|
||||
'Minimum scored labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
20,
|
||||
)
|
||||
.option(
|
||||
'--min-accepted-labels <count>',
|
||||
'Minimum accepted labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
5,
|
||||
)
|
||||
.option(
|
||||
'--min-rejected-labels <count>',
|
||||
'Minimum rejected labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
5,
|
||||
)
|
||||
.option('--json', 'Print the threshold advice report as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipThresholds',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
minTotalLabels: options.minTotalLabels,
|
||||
minAcceptedLabels: options.minAcceptedLabels,
|
||||
minRejectedLabels: options.minRejectedLabels,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,353 +0,0 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { CommandUnknownOpts, Option } from '@commander-js/extra-typings';
|
||||
|
||||
export interface CompletionRequest {
|
||||
position: number;
|
||||
words: string[];
|
||||
}
|
||||
|
||||
interface CompletionCandidate {
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CommandWithHiddenFlag extends CommandUnknownOpts {
|
||||
_hidden?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveState {
|
||||
command: CommandUnknownOpts;
|
||||
pendingOption?: Option;
|
||||
positionalIndex: number;
|
||||
}
|
||||
|
||||
export interface ZshCompletionInstallResult {
|
||||
completionPath: string;
|
||||
zshrcPath: string;
|
||||
}
|
||||
|
||||
const KTX_COMPLETION_BLOCK_START = '# >>> ktx completion >>>';
|
||||
const KTX_COMPLETION_BLOCK_END = '# <<< ktx completion <<<';
|
||||
const KTX_COMPLETION_BLOCK_PATTERN = new RegExp(
|
||||
`\\n?${escapeRegExp(KTX_COMPLETION_BLOCK_START)}[\\s\\S]*?${escapeRegExp(KTX_COMPLETION_BLOCK_END)}\\n?`,
|
||||
'g',
|
||||
);
|
||||
|
||||
export function zshCompletionScript(): string {
|
||||
const zshWords = '$' + '{words[@]}';
|
||||
const zshCompletionCapture = [
|
||||
'$',
|
||||
`{(@f)$("${'$'}{ktx_completion_command[@]}" dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}" 2>/dev/null)}`,
|
||||
].join('');
|
||||
const zshCompletionsCount = '$' + '{#completions[@]}';
|
||||
const zshCompletionCommand = '$' + '(eval "print -r -- $' + '{KTX_COMPLETION_COMMAND:-ktx}")';
|
||||
|
||||
return [
|
||||
'#compdef ktx',
|
||||
'',
|
||||
'_ktx() {',
|
||||
' local -a completions',
|
||||
' local -a ktx_completion_command',
|
||||
` ktx_completion_command=("\${(@z)${zshCompletionCommand}}")`,
|
||||
` completions=("${zshCompletionCapture}")`,
|
||||
` if (( ${zshCompletionsCount} )); then`,
|
||||
" _describe 'ktx completions' completions",
|
||||
' else',
|
||||
' _files',
|
||||
' fi',
|
||||
'}',
|
||||
'',
|
||||
'compdef _ktx ktx',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function installZshCompletion(): Promise<ZshCompletionInstallResult> {
|
||||
const homeDir = process.env.HOME || homedir();
|
||||
const zshConfigDir = process.env.ZDOTDIR || homeDir;
|
||||
const completionDir = join(homeDir, '.zfunc');
|
||||
const completionPath = join(completionDir, '_ktx');
|
||||
const zshrcPath = join(zshConfigDir, '.zshrc');
|
||||
|
||||
await mkdir(completionDir, { recursive: true });
|
||||
await mkdir(dirname(zshrcPath), { recursive: true });
|
||||
await writeFile(completionPath, zshCompletionScript(), 'utf-8');
|
||||
|
||||
const existingZshrc = await readOptionalTextFile(zshrcPath);
|
||||
const nextZshrc = updateZshrcCompletionBlock(existingZshrc);
|
||||
await writeFile(zshrcPath, nextZshrc, 'utf-8');
|
||||
|
||||
return { completionPath, zshrcPath };
|
||||
}
|
||||
|
||||
export function completeCommanderInput(program: CommandUnknownOpts, request: CompletionRequest): string[] {
|
||||
const words = completionWordsForPosition(request.words, request.position);
|
||||
const tokens = stripProgramName(program, words);
|
||||
const current = tokens.at(-1) ?? '';
|
||||
const previous = tokens.slice(0, -1);
|
||||
const state = resolveCommandState(program, previous);
|
||||
|
||||
return candidatesForState(state, current).map(formatZshCandidate);
|
||||
}
|
||||
|
||||
function completionWordsForPosition(words: string[], position: number): string[] {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
return words;
|
||||
}
|
||||
return words.slice(0, position);
|
||||
}
|
||||
|
||||
function stripProgramName(program: CommandUnknownOpts, words: string[]): string[] {
|
||||
const [first, ...rest] = words;
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
return first === program.name() || first.endsWith(`/${program.name()}`) ? rest : words;
|
||||
}
|
||||
|
||||
function resolveCommandState(program: CommandUnknownOpts, tokens: string[]): ResolveState {
|
||||
let command = program;
|
||||
let positionalIndex = 0;
|
||||
let pendingOption: Option | undefined;
|
||||
let positionalOnly = false;
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (pendingOption) {
|
||||
pendingOption = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
positionalOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!positionalOnly && token.startsWith('-')) {
|
||||
const option = findOption(command, optionNameFromToken(token));
|
||||
if (option && !token.includes('=') && optionTakesValue(option)) {
|
||||
if (index === tokens.length - 1) {
|
||||
pendingOption = option;
|
||||
} else if (option.required || !tokens[index + 1]?.startsWith('-')) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = findVisibleSubcommand(command, token);
|
||||
if (child) {
|
||||
command = child;
|
||||
positionalIndex = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
positionalIndex += 1;
|
||||
}
|
||||
|
||||
return { command, pendingOption, positionalIndex };
|
||||
}
|
||||
|
||||
function candidatesForState(state: ResolveState, current: string): CompletionCandidate[] {
|
||||
const optionValue = splitOptionValueToken(current);
|
||||
if (optionValue) {
|
||||
const option = findOption(state.command, optionValue.optionName);
|
||||
return choiceCandidates(option?.argChoices, optionValue.valuePrefix, optionValue.optionPrefix);
|
||||
}
|
||||
|
||||
if (state.pendingOption) {
|
||||
return choiceCandidates(state.pendingOption.argChoices, current);
|
||||
}
|
||||
|
||||
if (current.startsWith('-')) {
|
||||
return visibleOptions(state.command)
|
||||
.map(optionCandidate)
|
||||
.filter((candidate) => candidate.value.startsWith(current));
|
||||
}
|
||||
|
||||
const commandCandidates = visibleSubcommands(state.command)
|
||||
.map(commandCandidate)
|
||||
.filter((candidate) => candidate.value.startsWith(current));
|
||||
const argument = state.command.registeredArguments[state.positionalIndex];
|
||||
return [...commandCandidates, ...choiceCandidates(argument?.argChoices, current)];
|
||||
}
|
||||
|
||||
function visibleSubcommands(command: CommandUnknownOpts): CommandUnknownOpts[] {
|
||||
return command.commands.filter((subcommand) => (subcommand as CommandWithHiddenFlag)._hidden !== true);
|
||||
}
|
||||
|
||||
function findVisibleSubcommand(command: CommandUnknownOpts, name: string): CommandUnknownOpts | undefined {
|
||||
return visibleSubcommands(command).find(
|
||||
(subcommand) => subcommand.name() === name || subcommand.aliases().includes(name),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleOptions(command: CommandUnknownOpts): Option[] {
|
||||
const options: Option[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const current of commandChain(command)) {
|
||||
for (const option of current.options) {
|
||||
if (option.hidden) {
|
||||
continue;
|
||||
}
|
||||
const key = option.long ?? option.short ?? option.flags;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push(option);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function commandChain(command: CommandUnknownOpts): CommandUnknownOpts[] {
|
||||
const chain: CommandUnknownOpts[] = [];
|
||||
let current: CommandUnknownOpts | null = command;
|
||||
while (current) {
|
||||
chain.unshift(current);
|
||||
current = current.parent;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function findOption(command: CommandUnknownOpts, name: string): Option | undefined {
|
||||
return visibleOptions(command).find((option) => option.long === name || option.short === name);
|
||||
}
|
||||
|
||||
function optionTakesValue(option: Option): boolean {
|
||||
return option.required || option.optional;
|
||||
}
|
||||
|
||||
function optionNameFromToken(token: string): string {
|
||||
return token.split('=', 1)[0] ?? token;
|
||||
}
|
||||
|
||||
function splitOptionValueToken(
|
||||
token: string,
|
||||
): { optionName: string; optionPrefix: string; valuePrefix: string } | null {
|
||||
const separatorIndex = token.indexOf('=');
|
||||
if (!token.startsWith('-') || separatorIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
optionName: token.slice(0, separatorIndex),
|
||||
optionPrefix: token.slice(0, separatorIndex + 1),
|
||||
valuePrefix: token.slice(separatorIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function commandCandidate(command: CommandUnknownOpts): CompletionCandidate {
|
||||
return {
|
||||
value: command.name(),
|
||||
description: command.summary() || command.description(),
|
||||
};
|
||||
}
|
||||
|
||||
function optionCandidate(option: Option): CompletionCandidate {
|
||||
return {
|
||||
value: option.long ?? option.short ?? option.flags,
|
||||
description: option.description,
|
||||
};
|
||||
}
|
||||
|
||||
function choiceCandidates(
|
||||
choices: readonly string[] | undefined,
|
||||
prefix: string,
|
||||
completionPrefix = '',
|
||||
): CompletionCandidate[] {
|
||||
return (choices ?? [])
|
||||
.filter((choice) => choice.startsWith(prefix))
|
||||
.map((choice) => ({ value: `${completionPrefix}${choice}` }));
|
||||
}
|
||||
|
||||
function formatZshCandidate(candidate: CompletionCandidate): string {
|
||||
if (!candidate.description) {
|
||||
return escapeZshCompletion(candidate.value);
|
||||
}
|
||||
return `${escapeZshCompletion(candidate.value)}:${escapeZshDescription(candidate.description)}`;
|
||||
}
|
||||
|
||||
function escapeZshCompletion(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
|
||||
}
|
||||
|
||||
function escapeZshDescription(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\\/g, '\\\\').replace(/:/g, '\\:').trim();
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(path, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function updateZshrcCompletionBlock(contents: string): string {
|
||||
const withoutManagedBlock = contents.replace(KTX_COMPLETION_BLOCK_PATTERN, normalizeTrailingNewline);
|
||||
const hasCompinit = /^.*\bcompinit\b.*$/m.test(withoutManagedBlock);
|
||||
const block = zshrcCompletionBlock({ includeCompinit: !hasCompinit });
|
||||
|
||||
if (!hasCompinit) {
|
||||
return appendBlock(withoutManagedBlock, block);
|
||||
}
|
||||
|
||||
const compinitMatch = /^.*\bcompinit\b.*$/m.exec(withoutManagedBlock);
|
||||
if (!compinitMatch || compinitMatch.index === undefined) {
|
||||
return appendBlock(withoutManagedBlock, block);
|
||||
}
|
||||
|
||||
return [
|
||||
withoutManagedBlock.slice(0, compinitMatch.index),
|
||||
block,
|
||||
'\n',
|
||||
withoutManagedBlock.slice(compinitMatch.index),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function zshrcCompletionBlock(options: { includeCompinit: boolean }): string {
|
||||
return [
|
||||
KTX_COMPLETION_BLOCK_START,
|
||||
'_ktx_completion_command() {',
|
||||
' local dir="$PWD"',
|
||||
' while [[ "$dir" != "/" ]]; do',
|
||||
` if [[ -f "$dir/package.json" ]] && command grep -q '"name": "ktx-workspace"' "$dir/package.json" 2>/dev/null; then`,
|
||||
' print -r -- "node $dir/scripts/run-ktx.mjs --"',
|
||||
' return',
|
||||
' fi',
|
||||
' dir="' + '$' + '{dir:h}"',
|
||||
' done',
|
||||
' print -r -- "ktx"',
|
||||
'}',
|
||||
"export KTX_COMPLETION_COMMAND='$(_ktx_completion_command)'",
|
||||
'setopt complete_aliases',
|
||||
'fpath=("$HOME/.zfunc" $fpath)',
|
||||
...(options.includeCompinit ? ['autoload -Uz compinit', 'compinit'] : []),
|
||||
KTX_COMPLETION_BLOCK_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function appendBlock(contents: string, block: string): string {
|
||||
if (!contents.trim()) {
|
||||
return `${block}\n`;
|
||||
}
|
||||
return `${contents.replace(/\s*$/, '\n\n')}${block}\n`;
|
||||
}
|
||||
|
||||
function normalizeTrailingNewline(match: string): string {
|
||||
return match.startsWith('\n') || match.endsWith('\n') ? '\n' : '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
|
|
@ -310,8 +310,8 @@ describe('runKtxConnection', () => {
|
|||
expect(io.stdout()).toContain('Mappings:');
|
||||
expect(io.stdout()).toContain('1 -> [unmapped]');
|
||||
expect(io.stdout()).toContain('Next:');
|
||||
expect(io.stdout()).toContain('ktx ingest prod-metabase');
|
||||
expect(io.stdout()).toContain('ktx dev mapping');
|
||||
expect(io.stdout()).toContain('ktx ingest run --connection-id prod-metabase --adapter metabase');
|
||||
expect(io.stdout()).toContain('ktx connection mapping');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -326,8 +326,8 @@ async function runPublicConnectionMap(
|
|||
io.stdout.write('\nMappings:\n');
|
||||
io.stdout.write(listIo.stdoutText().trim() ? listIo.stdoutText() : 'No mappings found.\n');
|
||||
io.stdout.write('\nNext:\n');
|
||||
io.stdout.write(` ktx ingest ${args.sourceConnectionId}\n`);
|
||||
io.stdout.write(` ktx dev mapping list ${args.sourceConnectionId}\n`);
|
||||
io.stdout.write(` ktx ingest run --connection-id ${args.sourceConnectionId} --adapter <adapter>\n`);
|
||||
io.stdout.write(` ktx connection mapping list ${args.sourceConnectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { resolve } from 'node:path';
|
||||
import type { Command } from '@commander-js/extra-typings';
|
||||
import { type CommandWithGlobalOptions, type KtxCliCommandContext, resolveCommandProjectDir } from './cli-program.js';
|
||||
import { registerCompletionCommands } from './commands/completion-commands.js';
|
||||
import { registerConnectionMappingCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerRuntimeCommands } from './commands/runtime-commands.js';
|
||||
import { registerScanCommands } from './commands/scan-commands.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:dev');
|
||||
|
|
@ -13,7 +9,7 @@ profileMark('module:dev');
|
|||
export function registerDevCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const dev = program
|
||||
.command('dev', { hidden: true })
|
||||
.description('Low-level diagnostics, scans, adapter commands, and mapping tools')
|
||||
.description('Low-level project initialization and runtime management')
|
||||
.showHelpAfterError();
|
||||
|
||||
dev.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
|
|
@ -51,11 +47,4 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
|
|||
);
|
||||
|
||||
registerRuntimeCommands(dev, context);
|
||||
registerScanCommands(dev, context);
|
||||
registerIngestCommands(dev, context, {
|
||||
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>
|
||||
await (ingestDeps.ingest ?? defaultRunIngest)(ingestArgs, ingestIo),
|
||||
});
|
||||
registerConnectionMappingCommands(dev, context);
|
||||
registerCompletionCommands(dev, context, program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ describe('standalone local warehouse example', () => {
|
|||
expect(parseJsonOutput<{ yaml: string }>(slRead.stdout).yaml).toContain('name: orders');
|
||||
|
||||
const ingest = await runBuiltCli([
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -120,7 +119,7 @@ describe('standalone local warehouse example', () => {
|
|||
]);
|
||||
expect(ingest).toMatchObject({ code: 1, stdout: '' });
|
||||
expect(ingest.stderr).toContain(
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
|
|
|
|||
|
|
@ -123,10 +123,10 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
||||
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']) {
|
||||
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'scan']) {
|
||||
expect(testIo.stdout()).toContain(`${command}`);
|
||||
}
|
||||
for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion', 'runtime', 'serve']) {
|
||||
for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'completion', 'serve']) {
|
||||
expect(testIo.stdout()).not.toContain(`${removed} [`);
|
||||
expect(testIo.stdout()).not.toContain(`${removed} `);
|
||||
}
|
||||
|
|
@ -146,7 +146,6 @@ describe('runKtxCli', () => {
|
|||
const stopIo = makeIo();
|
||||
const stopAllIo = makeIo();
|
||||
const statusIo = makeIo();
|
||||
const doctorIo = makeIo();
|
||||
const pruneIo = makeIo();
|
||||
|
||||
await expect(
|
||||
|
|
@ -160,7 +159,6 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['dev', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
|
||||
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
|
|
@ -212,15 +210,6 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
{
|
||||
command: 'doctor',
|
||||
cliVersion: '0.0.0-private',
|
||||
json: false,
|
||||
},
|
||||
doctorIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
7,
|
||||
{
|
||||
command: 'prune',
|
||||
cliVersion: '0.0.0-private',
|
||||
|
|
@ -229,7 +218,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
pruneIo.io,
|
||||
);
|
||||
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, doctorIo, pruneIo]) {
|
||||
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, pruneIo]) {
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
});
|
||||
|
|
@ -247,16 +236,15 @@ describe('runKtxCli', () => {
|
|||
});
|
||||
|
||||
it('skips the project directory line for JSON and TUI output modes', async () => {
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const jsonIo = makeIo();
|
||||
const vizIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--json'], jsonIo.io, { publicIngest }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json'], jsonIo.io, { ingest }))
|
||||
.resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'dev', 'ingest', 'status', 'run-1', '--viz'],
|
||||
['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--viz'],
|
||||
vizIo.io,
|
||||
{ ingest },
|
||||
),
|
||||
|
|
@ -503,158 +491,17 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('prints a zsh completion function', async () => {
|
||||
const testIo = makeIo();
|
||||
const zshWords = '$' + '{words[@]}';
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('#compdef ktx');
|
||||
expect(testIo.stdout()).toContain('KTX_COMPLETION_COMMAND:-ktx');
|
||||
expect(testIo.stdout()).toContain(`dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}"`);
|
||||
expect(testIo.stdout()).toContain('compdef _ktx ktx');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('installs zsh completions into the user zsh config directory', async () => {
|
||||
const testIo = makeIo();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousZdotdir = process.env.ZDOTDIR;
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-'));
|
||||
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.ZDOTDIR;
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
const completionFile = await readFile(join(tempHome, '.zfunc', '_ktx'), 'utf-8');
|
||||
const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8');
|
||||
expect(completionFile).toContain('#compdef ktx');
|
||||
expect(zshrc).toContain('# >>> ktx completion >>>');
|
||||
expect(zshrc).toContain('_ktx_completion_command()');
|
||||
expect(zshrc).toContain('"name": "ktx-workspace"');
|
||||
expect(zshrc).toContain('scripts/run-ktx.mjs');
|
||||
expect(zshrc).toContain("export KTX_COMPLETION_COMMAND='$(_ktx_completion_command)'");
|
||||
expect(zshrc).toContain('setopt complete_aliases');
|
||||
expect(zshrc).toContain('fpath=("$HOME/.zfunc" $fpath)');
|
||||
expect(zshrc).toContain('autoload -Uz compinit');
|
||||
expect(zshrc).toContain('compinit');
|
||||
expect(testIo.stdout()).toContain('Installed zsh completion:');
|
||||
expect(testIo.stdout()).toContain('Restart your shell or run: source ~/.zshrc');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR;
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates zsh completion install block idempotently before existing compinit', async () => {
|
||||
const firstIo = makeIo();
|
||||
const secondIo = makeIo();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousZdotdir = process.env.ZDOTDIR;
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-'));
|
||||
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.ZDOTDIR;
|
||||
await writeFile(join(tempHome, '.zshrc'), 'export EDITOR=vim\nautoload -Uz compinit\ncompinit\n', 'utf-8');
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], firstIo.io)).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], secondIo.io)).resolves.toBe(0);
|
||||
|
||||
const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8');
|
||||
expect(zshrc.match(/# >>> ktx completion >>>/g)).toHaveLength(1);
|
||||
expect(zshrc.indexOf('fpath=("$HOME/.zfunc" $fpath)')).toBeLessThan(zshrc.indexOf('autoload -Uz compinit'));
|
||||
expect(zshrc.match(/_ktx_completion_command\(\)/g)).toHaveLength(1);
|
||||
expect(zshrc.match(/^compinit$/gm)).toHaveLength(1);
|
||||
expect(secondIo.stdout()).toContain('Updated zsh config:');
|
||||
expect(firstIo.stderr()).toBe('');
|
||||
expect(secondIo.stderr()).toBe('');
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR;
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('completes root and nested Commander command names', async () => {
|
||||
const rootIo = makeIo();
|
||||
const connectionIo = makeIo();
|
||||
it('rejects removed shell completion commands', async () => {
|
||||
const completionIo = makeIo();
|
||||
const hiddenIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh'], completionIo.io)).resolves.toBe(1);
|
||||
await expect(
|
||||
runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], rootIo.io),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['dev', '__complete', '--shell', 'zsh', '--position', '3', '--', 'ktx', 'connection', 'm'],
|
||||
connectionIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], hiddenIo.io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(rootIo.stdout()).toContain('connection:Add, list, test, and map data sources');
|
||||
expect(rootIo.stdout()).not.toContain('__complete');
|
||||
expect(connectionIo.stdout()).toContain('map:Refresh and validate BI-to-warehouse mappings');
|
||||
expect(connectionIo.stdout()).toContain('mapping:Manage Metabase warehouse mappings');
|
||||
expect(rootIo.stderr()).toBe('');
|
||||
expect(connectionIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('completes options and Commander choices', async () => {
|
||||
const optionIo = makeIo();
|
||||
const choiceIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['dev', '__complete', '--shell', 'zsh', '--position', '4', '--', 'ktx', 'connection', 'add', '--cr'],
|
||||
optionIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'__complete',
|
||||
'--shell',
|
||||
'zsh',
|
||||
'--position',
|
||||
'7',
|
||||
'--',
|
||||
'ktx',
|
||||
'connection',
|
||||
'add',
|
||||
'notion',
|
||||
'docs',
|
||||
'--crawl-mode',
|
||||
'',
|
||||
],
|
||||
choiceIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(optionIo.stdout()).toContain('--crawl-mode:Notion crawl mode');
|
||||
expect(choiceIo.stdout()).toContain('all_accessible');
|
||||
expect(choiceIo.stdout()).toContain('selected_roots');
|
||||
expect(optionIo.stderr()).toBe('');
|
||||
expect(choiceIo.stderr()).toBe('');
|
||||
expect(completionIo.stderr()).toMatch(/unknown command|error:/);
|
||||
expect(hiddenIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('rejects removed serve commands', async () => {
|
||||
|
|
@ -666,35 +513,22 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('routes public ingest through the public ingest parser', async () => {
|
||||
it('rejects removed public ingest shorthand', async () => {
|
||||
const testIo = makeIo();
|
||||
const ingest = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse'], testIo.io, { publicIngest: ingest }),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse'], testIo.io, { ingest }))
|
||||
.resolves.toBe(1);
|
||||
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: '/tmp/project',
|
||||
targetConnectionId: 'warehouse',
|
||||
all: false,
|
||||
json: false,
|
||||
inputMode: 'auto',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('prints public ingest watch help from Commander', async () => {
|
||||
it('prints ingest watch help from Commander', async () => {
|
||||
const testIo = makeIo();
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const lowLevelIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['ingest', 'watch', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest }),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['ingest', 'watch', '--help'], testIo.io, { ingest })).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest watch [options] [runId]');
|
||||
expect(testIo.stdout()).toContain('[runId]');
|
||||
|
|
@ -702,43 +536,42 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stdout()).toContain('--json');
|
||||
expect(testIo.stdout()).toContain('--no-input');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
expect(lowLevelIngest).not.toHaveBeenCalled();
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches public ingest status and watch through Commander', async () => {
|
||||
it('dispatches ingest status and watch through Commander', async () => {
|
||||
const statusIo = makeIo();
|
||||
const watchIo = makeIo();
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json', '--no-input'], statusIo.io, {
|
||||
publicIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'ingest', 'watch', '--no-input'], watchIo.io, {
|
||||
publicIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(publicIngest).toHaveBeenNthCalledWith(
|
||||
expect(ingest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
command: 'status',
|
||||
projectDir: tempDir,
|
||||
runId: 'run-1',
|
||||
json: true,
|
||||
outputMode: 'json',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
statusIo.io,
|
||||
);
|
||||
expect(publicIngest).toHaveBeenNthCalledWith(
|
||||
expect(ingest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
command: 'watch',
|
||||
projectDir: tempDir,
|
||||
json: false,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
watchIo.io,
|
||||
|
|
@ -778,60 +611,44 @@ describe('runKtxCli', () => {
|
|||
expect(setup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints public ingest help without invoking ingest execution', async () => {
|
||||
it('prints ingest help without invoking ingest execution', async () => {
|
||||
const testIo = makeIo();
|
||||
const publicIngest = vi.fn();
|
||||
const lowLevelIngest = vi.fn();
|
||||
const ingest = vi.fn();
|
||||
|
||||
await expect(runKtxCli(['ingest', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['ingest', '--help'], testIo.io, { ingest })).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
|
||||
expect(testIo.stdout()).toContain('Build and refresh KTX context from configured sources');
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [command]');
|
||||
expect(testIo.stdout()).toContain('Run or inspect local ingest memory-flow output');
|
||||
expect(testIo.stdout()).toContain('run');
|
||||
expect(testIo.stdout()).toContain('status');
|
||||
expect(testIo.stdout()).toContain('watch');
|
||||
expect(testIo.stdout()).toContain('ktx ingest --all [options]');
|
||||
expect(testIo.stdout()).toContain('ktx ingest status [runId] [options]');
|
||||
expect(testIo.stdout()).toContain('ktx ingest watch [runId] [options]');
|
||||
expect(testIo.stdout()).not.toContain('ktx ingest replay <runId> [options]');
|
||||
expect(testIo.stdout()).toContain('--no-input');
|
||||
expect(testIo.stdout()).not.toContain('--adapter');
|
||||
expect(testIo.stdout()).toContain('replay');
|
||||
expect(testIo.stdout()).not.toContain('--all');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
expect(lowLevelIngest).not.toHaveBeenCalled();
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reserves public ingest run while keeping dev ingest run available', async () => {
|
||||
const publicRunIo = makeIo();
|
||||
const publicHelpIo = makeIo();
|
||||
it('routes ingest run at the top level and rejects removed dev ingest', async () => {
|
||||
const runIo = makeIo();
|
||||
const devRunIo = makeIo();
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const lowLevelIngest = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxCli(['ingest', 'run'], publicRunIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(
|
||||
1,
|
||||
);
|
||||
expect(publicRunIo.stderr()).toMatch(/invalid argument|reserved|run/i);
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['ingest', 'run', '--help'], publicHelpIo.io, { publicIngest, ingest: lowLevelIngest }),
|
||||
runKtxCli(['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], runIo.io, { ingest }),
|
||||
).resolves.toBe(0);
|
||||
expect(publicHelpIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
|
||||
expect(publicHelpIo.stdout()).not.toContain('Usage: ktx ingest ' + 'run');
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
|
||||
publicIngest,
|
||||
ingest: lowLevelIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(lowLevelIngest).toHaveBeenCalledWith(
|
||||
).resolves.toBe(1);
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'run', connectionId: 'warehouse', adapter: 'metabase' }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(devRunIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('rejects removed dev doctor while keeping ingest parser cases under dev', async () => {
|
||||
it('rejects removed dev doctor while keeping ingest parser cases at the root', async () => {
|
||||
const doctor = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const doctorIo = makeIo();
|
||||
|
|
@ -842,7 +659,6 @@ describe('runKtxCli', () => {
|
|||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -862,7 +678,7 @@ describe('runKtxCli', () => {
|
|||
{ ingest },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
|
||||
|
||||
expect(doctor).not.toHaveBeenCalled();
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
|
|
@ -881,7 +697,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
ingestRunIo.io,
|
||||
);
|
||||
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx dev ingest replay [options] <runId>');
|
||||
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx ingest replay [options] <runId>');
|
||||
expect(ingestReplayHelpIo.stdout()).toContain('<runId>');
|
||||
expect(doctorIo.stderr()).toMatch(/unknown command|error:/);
|
||||
expect(ingestRunIo.stderr()).toBe('');
|
||||
|
|
@ -896,7 +712,6 @@ describe('runKtxCli', () => {
|
|||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -914,7 +729,6 @@ describe('runKtxCli', () => {
|
|||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -1729,7 +1543,7 @@ describe('runKtxCli', () => {
|
|||
'ktx connection mapping refresh <connectionId> --auto-accept',
|
||||
'ktx connection mapping set <connectionId> databaseMappings <id>=<target>',
|
||||
'ktx connection mapping set-sync-enabled <connectionId> <id> --enabled true',
|
||||
'ktx ingest <connectionId>',
|
||||
'ktx ingest run --connection-id <connectionId> --adapter metabase',
|
||||
]) {
|
||||
expect(helpIo.stdout()).toContain(line);
|
||||
}
|
||||
|
|
@ -1870,7 +1684,6 @@ describe('runKtxCli', () => {
|
|||
for (const argv of [
|
||||
['init'],
|
||||
['connect', 'list'],
|
||||
['scan', 'warehouse'],
|
||||
['knowledge', 'list'],
|
||||
['ask', 'What sources are connected?'],
|
||||
]) {
|
||||
|
|
@ -2041,11 +1854,11 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toContain('[debug] dispatch=connection');
|
||||
});
|
||||
|
||||
it('routes low-level scan through ktx dev with top-level project-dir', async () => {
|
||||
it('routes scan through the top-level command with top-level project-dir', async () => {
|
||||
const testIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
|
|
@ -2071,12 +1884,12 @@ describe('runKtxCli', () => {
|
|||
const conflictIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--yes'], autoIo.io, { scan }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--yes'], autoIo.io, { scan }))
|
||||
.resolves.toBe(0);
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--no-input'], neverIo.io, { scan }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--no-input'], neverIo.io, { scan }))
|
||||
.resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--yes', '--no-input'], conflictIo.io, {
|
||||
runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--yes', '--no-input'], conflictIo.io, {
|
||||
scan,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
|
@ -2131,44 +1944,38 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['dev'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
|
||||
expect(testIo.stdout()).toContain('Low-level diagnostics');
|
||||
expect(testIo.stdout()).toContain('scan');
|
||||
expect(testIo.stdout()).toContain('ingest');
|
||||
expect(testIo.stdout()).toContain('mapping');
|
||||
expect(testIo.stdout()).toContain('Low-level project initialization');
|
||||
expect(testIo.stdout()).toContain('init');
|
||||
expect(testIo.stdout()).toContain('runtime');
|
||||
expect(testIo.stdout()).not.toContain('scan');
|
||||
expect(testIo.stdout()).not.toContain('ingest');
|
||||
expect(testIo.stdout()).not.toContain('mapping');
|
||||
expect(testIo.stdout()).not.toContain('model');
|
||||
expect(testIo.stdout()).not.toContain('knowledge');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints dev command help without invoking low-level execution', async () => {
|
||||
for (const [command, expected] of [
|
||||
['scan', ['Usage: ktx dev scan', '--dry-run', 'status', 'report']],
|
||||
['ingest', ['Usage: ktx dev ingest', 'run', 'replay']],
|
||||
['mapping', ['Usage: ktx dev mapping', 'sync-state', 'validate']],
|
||||
] as const) {
|
||||
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 sl = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['dev', command, '--help'], testIo.io, { scan, sl })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', command], testIo.io, { scan, sl })).resolves.toBe(1);
|
||||
|
||||
for (const text of expected) {
|
||||
expect(testIo.stdout()).toContain(text);
|
||||
}
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(sl).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints dev scan subcommand help without invoking scan execution', async () => {
|
||||
it('rejects removed scan subcommands without invoking scan execution', async () => {
|
||||
const testIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['dev', 'scan', 'report', '--help'], testIo.io, { scan })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['scan', 'report'], testIo.io, { scan })).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx dev scan report [options] <runId>');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toMatch(/too many arguments|unknown command|error:/);
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -2184,8 +1991,8 @@ describe('runKtxCli', () => {
|
|||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
|
||||
['dev', 'ingest', 'status', 'run-1', '--json', '--viz'],
|
||||
['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
|
||||
['ingest', 'status', 'run-1', '--json', '--viz'],
|
||||
]) {
|
||||
const testIo = makeIo();
|
||||
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ describe('runKtxIngest', () => {
|
|||
expect(statusIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints provider setup guidance when a skip-llm setup project runs dev ingest', async () => {
|
||||
it('prints provider setup guidance when a skip-llm setup project runs ingest', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const setupIo = makeIo();
|
||||
await expect(
|
||||
|
|
@ -168,7 +168,7 @@ describe('runKtxIngest', () => {
|
|||
|
||||
expect(runIo.stdout()).toBe('');
|
||||
expect(runIo.stderr()).toContain(
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
);
|
||||
expect(runIo.stderr()).toContain(
|
||||
`ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
|
||||
|
|
@ -663,7 +663,7 @@ describe('runKtxIngest', () => {
|
|||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('source-dir uploads are not supported for the Metabase fan-out adapter');
|
||||
expect(io.stderr()).not.toContain('ktx dev ingest run requires llm.provider.backend');
|
||||
expect(io.stderr()).not.toContain('ktx ingest run requires llm.provider.backend');
|
||||
expect(io.stdout()).toBe('');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -518,7 +518,9 @@ export async function runKtxIngest(
|
|||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const env = deps.env ?? process.env;
|
||||
if (args.command === 'run') {
|
||||
const createAdapters = deps.createAdapters ?? createKtxCliLocalIngestAdapters;
|
||||
const createAdapters =
|
||||
deps.createAdapters ??
|
||||
(deps.runLocalIngest || deps.runLocalMetabaseIngest ? () => [] : createKtxCliLocalIngestAdapters);
|
||||
const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest;
|
||||
const localIngestOptions = deps.localIngestOptions ?? {};
|
||||
const managedDaemon = managedDaemonOptionsForIngestRun(args, io);
|
||||
|
|
@ -645,7 +647,7 @@ export async function runKtxIngest(
|
|||
throw new Error(
|
||||
args.runId
|
||||
? `Local ingest run or report "${args.runId}" was not found`
|
||||
: 'No local ingest reports were found. Run `ktx ingest --all` first.',
|
||||
: 'No local ingest reports were found. Run `ktx ingest run --connection-id <id> --adapter <adapter>` first.',
|
||||
);
|
||||
}
|
||||
await writeReportRecord(report, args.outputMode, io, {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,10 @@ describe('project directory defaults', () => {
|
|||
const connection = vi.fn(async () => 0);
|
||||
const doctor = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const scan = vi.fn(async () => 0);
|
||||
const setup = vi.fn(async () => 0);
|
||||
const agent = vi.fn(async () => 0);
|
||||
const deps: KtxCliDeps = { agent, connection, doctor, ingest, publicIngest, scan, setup };
|
||||
const deps: KtxCliDeps = { agent, connection, doctor, ingest, scan, setup };
|
||||
|
||||
const cases: Array<{
|
||||
argv: string[];
|
||||
|
|
@ -59,8 +58,8 @@ describe('project directory defaults', () => {
|
|||
},
|
||||
{
|
||||
argv: ['ingest', 'status', 'run-1'],
|
||||
spy: publicIngest,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1' },
|
||||
spy: ingest,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1', outputMode: 'plain' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
|
|
@ -70,7 +69,7 @@ describe('project directory defaults', () => {
|
|||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'warehouse'],
|
||||
argv: ['scan', 'warehouse'],
|
||||
spy: scan,
|
||||
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
|
|
@ -95,16 +94,16 @@ describe('project directory defaults', () => {
|
|||
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 ingestIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'dev', 'scan', 'warehouse'], scanIo.io, { scan }),
|
||||
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'scan', 'warehouse'], scanIo.io, { scan }),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['ingest', 'status', 'run-1', '--project-dir=/tmp/ktx-explicit-project'], ingestIo.io, {
|
||||
publicIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
|
|
@ -112,7 +111,7 @@ describe('project directory defaults', () => {
|
|||
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
|
||||
scanIo.io,
|
||||
);
|
||||
expect(publicIngest).toHaveBeenCalledWith(
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'status', projectDir: '/tmp/ktx-explicit-project' }),
|
||||
ingestIo.io,
|
||||
);
|
||||
|
|
@ -139,7 +138,7 @@ describe('project directory defaults', () => {
|
|||
|
||||
try {
|
||||
process.chdir(nestedDir);
|
||||
await expect(runKtxCli(['dev', 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
driver: 'notion',
|
||||
operation: 'source-ingest',
|
||||
adapter: 'notion',
|
||||
debugCommand: 'ktx dev ingest run --connection-id docs --adapter notion --debug',
|
||||
debugCommand: 'ktx ingest run --connection-id docs --adapter notion --debug',
|
||||
steps: ['source-ingest', 'memory-update'],
|
||||
},
|
||||
{
|
||||
|
|
@ -65,7 +65,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
driver: 'metabase',
|
||||
operation: 'source-ingest',
|
||||
adapter: 'metabase',
|
||||
debugCommand: 'ktx dev ingest run --connection-id prod_metabase --adapter metabase --debug',
|
||||
debugCommand: 'ktx ingest run --connection-id prod_metabase --adapter metabase --debug',
|
||||
steps: ['source-ingest', 'memory-update'],
|
||||
},
|
||||
],
|
||||
|
|
@ -76,7 +76,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||
|
||||
expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: false })).toThrow(
|
||||
'ktx ingest requires <connectionId> or --all in this release',
|
||||
'Context build requires a connection id or all targets',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ function targetForConnection(connectionId: string, connection: KtxProjectConnect
|
|||
operation: 'source-ingest',
|
||||
adapter,
|
||||
...(sourceDir ? { sourceDir } : {}),
|
||||
debugCommand: `ktx dev ingest run --connection-id ${connectionId} --adapter ${adapter} --debug`,
|
||||
debugCommand: `ktx ingest run --connection-id ${connectionId} --adapter ${adapter} --debug`,
|
||||
steps: ['source-ingest', 'memory-update'],
|
||||
};
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ export function buildPublicIngestPlan(
|
|||
args: { projectDir: string; targetConnectionId?: string; all: boolean },
|
||||
): KtxPublicIngestPlan {
|
||||
if (!args.all && !args.targetConnectionId) {
|
||||
throw new Error('ktx ingest requires <connectionId> or --all in this release');
|
||||
throw new Error('Context build requires a connection id or all targets');
|
||||
}
|
||||
|
||||
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import type {
|
||||
ManagedPythonRuntimeDoctorCheck,
|
||||
ManagedPythonRuntimeInstallResult,
|
||||
ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
|
@ -290,28 +289,6 @@ describe('runKtxRuntime', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns failure for doctor when any check fails', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
|
||||
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
|
||||
{
|
||||
id: 'runtime',
|
||||
label: 'Managed Python runtime',
|
||||
status: 'fail',
|
||||
detail: 'No runtime manifest',
|
||||
fix: 'Run: ktx dev runtime install --yes',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'doctor', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(1);
|
||||
|
||||
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
|
||||
expect(io.stdout()).toContain('FAIL Managed Python runtime: No runtime manifest');
|
||||
expect(io.stdout()).toContain('Fix: Run: ktx dev runtime install --yes');
|
||||
});
|
||||
|
||||
it('requires --yes before pruning stale runtime directories', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import {
|
|||
type ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import {
|
||||
doctorManagedPythonRuntime,
|
||||
installManagedPythonRuntime,
|
||||
pruneManagedPythonRuntimes,
|
||||
readManagedPythonRuntimeStatus,
|
||||
type KtxRuntimeFeature,
|
||||
type ManagedPythonRuntimeDoctorCheck,
|
||||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
type ManagedPythonRuntimeLayoutOptions,
|
||||
|
|
@ -26,7 +24,6 @@ export type KtxRuntimeArgs =
|
|||
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'stop'; cliVersion: string; all: boolean }
|
||||
| { command: 'status'; cliVersion: string; json: boolean }
|
||||
| { command: 'doctor'; cliVersion: string; json: boolean }
|
||||
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
|
||||
|
||||
export interface KtxRuntimeDeps {
|
||||
|
|
@ -39,7 +36,6 @@ export interface KtxRuntimeDeps {
|
|||
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
|
||||
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
|
||||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
|
||||
pruneRuntime?: (options: {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
|
|
@ -149,16 +145,6 @@ function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
|
|||
}
|
||||
}
|
||||
|
||||
function writeDoctor(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void {
|
||||
io.stdout.write('KTX Python runtime doctor\n');
|
||||
for (const check of checks) {
|
||||
io.stdout.write(`${check.status.toUpperCase()} ${check.label}: ${check.detail}\n`);
|
||||
if (check.fix) {
|
||||
io.stdout.write(` Fix: ${check.fix}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writePrune(io: KtxCliIo, result: ManagedPythonRuntimePruneResult, dryRun: boolean): void {
|
||||
if (result.stale.length === 0) {
|
||||
io.stdout.write(`No stale KTX Python runtimes found under ${result.runtimeRoot}\n`);
|
||||
|
|
@ -218,16 +204,6 @@ export async function runKtxRuntime(
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'doctor') {
|
||||
const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime;
|
||||
const checks = await doctorRuntime({ cliVersion: args.cliVersion });
|
||||
if (args.json) {
|
||||
writeJson(io, { checks });
|
||||
} else {
|
||||
writeDoctor(io, checks);
|
||||
}
|
||||
return checks.some((check) => check.status === 'fail') ? 1 : 0;
|
||||
}
|
||||
if (!args.dryRun && !args.yes) {
|
||||
io.stderr.write('Refusing to prune without --yes. Preview with: ktx dev runtime prune --dry-run\n');
|
||||
return 1;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +1,10 @@
|
|||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
type ApplyLocalScanRelationshipReviewDecisionsResult,
|
||||
adviseLocalRelationshipFeedbackThresholds,
|
||||
applyLocalScanRelationshipReviewDecisions,
|
||||
calibrateLocalRelationshipFeedbackLabels,
|
||||
type ExportLocalRelationshipFeedbackLabelsResult,
|
||||
exportLocalRelationshipFeedbackLabels,
|
||||
formatKtxRelationshipFeedbackCalibrationMarkdown,
|
||||
formatKtxRelationshipFeedbackLabelsJsonl,
|
||||
formatKtxRelationshipThresholdAdviceMarkdown,
|
||||
getLocalScanReport,
|
||||
getLocalScanStatus,
|
||||
type KtxProgressPort,
|
||||
type KtxRelationshipArtifact,
|
||||
type KtxRelationshipArtifactEdge,
|
||||
type KtxRelationshipArtifactStatus,
|
||||
type KtxRelationshipDiagnosticsArtifact,
|
||||
type KtxRelationshipFeedbackCalibrationReport,
|
||||
type KtxRelationshipFeedbackDecisionFilter,
|
||||
type KtxRelationshipFeedbackLabel,
|
||||
type KtxRelationshipReviewDecisionValue,
|
||||
type KtxRelationshipThresholdAdviceReport,
|
||||
type KtxScanMode,
|
||||
type KtxScanReport,
|
||||
type KtxScanWarning,
|
||||
type LocalScanStatusResponse,
|
||||
readLocalScanRelationshipArtifacts,
|
||||
runLocalScan,
|
||||
type WriteLocalScanRelationshipReviewDecisionResult,
|
||||
writeLocalScanRelationshipReviewDecision,
|
||||
} from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './index.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
|
|
@ -38,88 +14,21 @@ import { profileMark } from './startup-profile.js';
|
|||
|
||||
profileMark('module:scan');
|
||||
|
||||
export type KtxScanArgs =
|
||||
| {
|
||||
command: 'run';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: KtxScanMode;
|
||||
detectRelationships: boolean;
|
||||
dryRun: boolean;
|
||||
databaseIntrospectionUrl?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
| { command: 'status'; projectDir: string; runId: string }
|
||||
| { command: 'report'; projectDir: string; runId: string; json: boolean }
|
||||
| {
|
||||
command: 'relationships';
|
||||
projectDir: string;
|
||||
runId: string;
|
||||
status: KtxRelationshipArtifactStatus;
|
||||
json: boolean;
|
||||
limit: number;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipDecision';
|
||||
projectDir: string;
|
||||
runId: string;
|
||||
candidateId: string;
|
||||
decision: KtxRelationshipReviewDecisionValue;
|
||||
reviewer: string;
|
||||
note: string | null;
|
||||
json: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipApply';
|
||||
projectDir: string;
|
||||
runId: string;
|
||||
applyAllAccepted: boolean;
|
||||
candidateIds: string[];
|
||||
dryRun: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipFeedback';
|
||||
projectDir: string;
|
||||
connectionId: string | null;
|
||||
decision: KtxRelationshipFeedbackDecisionFilter;
|
||||
json: boolean;
|
||||
jsonl: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipCalibration';
|
||||
projectDir: string;
|
||||
connectionId: string | null;
|
||||
decision: KtxRelationshipFeedbackDecisionFilter;
|
||||
acceptThreshold: number;
|
||||
reviewThreshold: number;
|
||||
json: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipThresholds';
|
||||
projectDir: string;
|
||||
connectionId: string | null;
|
||||
minTotalLabels: number;
|
||||
minAcceptedLabels: number;
|
||||
minRejectedLabels: number;
|
||||
json: boolean;
|
||||
};
|
||||
export interface KtxScanArgs {
|
||||
command: 'run';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: KtxScanMode;
|
||||
detectRelationships: boolean;
|
||||
dryRun: boolean;
|
||||
databaseIntrospectionUrl?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
|
||||
interface KtxScanDeps {
|
||||
runLocalScan?: typeof runLocalScan;
|
||||
createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
getLocalScanStatus?: typeof getLocalScanStatus;
|
||||
getLocalScanReport?: typeof getLocalScanReport;
|
||||
readLocalScanRelationshipArtifacts?: typeof readLocalScanRelationshipArtifacts;
|
||||
writeLocalScanRelationshipReviewDecision?: typeof writeLocalScanRelationshipReviewDecision;
|
||||
applyLocalScanRelationshipReviewDecisions?: typeof applyLocalScanRelationshipReviewDecisions;
|
||||
exportLocalRelationshipFeedbackLabels?: typeof exportLocalRelationshipFeedbackLabels;
|
||||
formatKtxRelationshipFeedbackLabelsJsonl?: typeof formatKtxRelationshipFeedbackLabelsJsonl;
|
||||
calibrateLocalRelationshipFeedbackLabels?: typeof calibrateLocalRelationshipFeedbackLabels;
|
||||
formatKtxRelationshipFeedbackCalibrationMarkdown?: typeof formatKtxRelationshipFeedbackCalibrationMarkdown;
|
||||
adviseLocalRelationshipFeedbackThresholds?: typeof adviseLocalRelationshipFeedbackThresholds;
|
||||
formatKtxRelationshipThresholdAdviceMarkdown?: typeof formatKtxRelationshipThresholdAdviceMarkdown;
|
||||
}
|
||||
|
||||
function shouldUseStyledOutput(io: KtxCliIo): boolean {
|
||||
|
|
@ -284,208 +193,8 @@ function writeRunSummary(report: KtxScanReport, projectDir: string, io: KtxCliIo
|
|||
writeHumanReportBody(report, io);
|
||||
const projectDirArg = quoteCliArg(projectDir);
|
||||
io.stdout.write('\nNext:\n');
|
||||
const statusCommand = styled ? dim('ktx dev scan status') : 'ktx dev scan status';
|
||||
const reportCommand = styled ? dim('ktx dev scan report') : 'ktx dev scan report';
|
||||
io.stdout.write(` ${statusCommand} --project-dir ${projectDirArg} ${report.runId}\n`);
|
||||
io.stdout.write(` ${reportCommand} --project-dir ${projectDirArg} ${report.runId}\n`);
|
||||
}
|
||||
|
||||
function writeReport(report: KtxScanReport, io: KtxCliIo): void {
|
||||
io.stdout.write('KTX scan report\n');
|
||||
writeHumanReportBody(report, io);
|
||||
}
|
||||
|
||||
function formatRelationshipEndpoint(edge: KtxRelationshipArtifactEdge, side: 'from' | 'to'): string {
|
||||
const endpoint = edge[side];
|
||||
if (endpoint.columns.length === 1) {
|
||||
return `${endpoint.table.name}.${endpoint.columns[0]}`;
|
||||
}
|
||||
return `${endpoint.table.name}.(${endpoint.columns.join(',')})`;
|
||||
}
|
||||
|
||||
function formatRelationshipScore(value: number | null): string {
|
||||
return value === null ? 'n/a' : value.toFixed(2);
|
||||
}
|
||||
|
||||
function relationshipStatusTitle(status: Exclude<KtxRelationshipArtifactStatus, 'all'>): string {
|
||||
if (status === 'accepted') {
|
||||
return 'Accepted relationships';
|
||||
}
|
||||
if (status === 'review') {
|
||||
return 'Review relationships';
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return 'Rejected relationships';
|
||||
}
|
||||
return 'Skipped relationships';
|
||||
}
|
||||
|
||||
function filteredRelationshipArtifact(
|
||||
relationships: KtxRelationshipArtifact,
|
||||
status: KtxRelationshipArtifactStatus,
|
||||
): KtxRelationshipArtifact {
|
||||
if (status === 'all') {
|
||||
return relationships;
|
||||
}
|
||||
return {
|
||||
connectionId: relationships.connectionId,
|
||||
accepted: status === 'accepted' ? relationships.accepted : [],
|
||||
review: status === 'review' ? relationships.review : [],
|
||||
rejected: status === 'rejected' ? relationships.rejected : [],
|
||||
skipped: status === 'skipped' ? relationships.skipped : [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeRelationshipEdge(edge: KtxRelationshipArtifactEdge, index: number, io: KtxCliIo): void {
|
||||
io.stdout.write(
|
||||
` ${index + 1}. ${formatRelationshipEndpoint(edge, 'from')} -> ${formatRelationshipEndpoint(edge, 'to')}\n`,
|
||||
);
|
||||
io.stdout.write(
|
||||
` type=${edge.relationshipType} source=${edge.source} confidence=${edge.confidence.toFixed(2)} pkScore=${formatRelationshipScore(edge.pkScore)} fkScore=${formatRelationshipScore(edge.fkScore)}\n`,
|
||||
);
|
||||
io.stdout.write(` reasons=${edge.reasons.length > 0 ? edge.reasons.join(', ') : 'none'}\n`);
|
||||
}
|
||||
|
||||
function writeRelationshipGroup(
|
||||
status: Exclude<KtxRelationshipArtifactStatus, 'all'>,
|
||||
relationships: KtxRelationshipArtifact,
|
||||
limit: number,
|
||||
io: KtxCliIo,
|
||||
): void {
|
||||
if (status === 'skipped') {
|
||||
io.stdout.write(`\n${relationshipStatusTitle(status)} (${relationships.skipped.length})\n`);
|
||||
relationships.skipped.slice(0, limit).forEach((item, index) => {
|
||||
io.stdout.write(` ${index + 1}. ${item.relationshipId}\n`);
|
||||
io.stdout.write(` reason=${item.reason}\n`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const edges =
|
||||
status === 'accepted'
|
||||
? relationships.accepted
|
||||
: status === 'review'
|
||||
? relationships.review
|
||||
: relationships.rejected;
|
||||
io.stdout.write(`\n${relationshipStatusTitle(status)} (${edges.length})\n`);
|
||||
edges.slice(0, limit).forEach((edge, index) => {
|
||||
writeRelationshipEdge(edge, index, io);
|
||||
});
|
||||
if (edges.length > limit) {
|
||||
io.stdout.write(` ${edges.length - limit} more not shown; rerun with --limit ${edges.length}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeRelationshipArtifactSummary(input: {
|
||||
runId: string;
|
||||
connectionId: string;
|
||||
syncId: string;
|
||||
status: KtxRelationshipArtifactStatus;
|
||||
limit: number;
|
||||
summary: KtxRelationshipArtifact;
|
||||
relationships: KtxRelationshipArtifact;
|
||||
diagnostics: KtxRelationshipDiagnosticsArtifact | null;
|
||||
relationshipsPath: string;
|
||||
io: KtxCliIo;
|
||||
}): void {
|
||||
input.io.stdout.write('KTX relationship artifacts\n');
|
||||
input.io.stdout.write(`Run: ${input.runId}\n`);
|
||||
input.io.stdout.write(`Connection: ${input.connectionId}\n`);
|
||||
input.io.stdout.write(`Sync: ${input.syncId}\n`);
|
||||
input.io.stdout.write(
|
||||
`Summary: accepted=${input.summary.accepted.length} review=${input.summary.review.length} rejected=${input.summary.rejected.length} skipped=${input.summary.skipped.length}\n`,
|
||||
);
|
||||
if (input.diagnostics?.noAcceptedReason) {
|
||||
input.io.stdout.write(`Reason: ${input.diagnostics.noAcceptedReason}\n`);
|
||||
}
|
||||
input.io.stdout.write(`Artifacts: ${input.relationshipsPath}\n`);
|
||||
|
||||
const statuses: Array<Exclude<KtxRelationshipArtifactStatus, 'all'>> =
|
||||
input.status === 'all' ? ['accepted', 'review', 'rejected', 'skipped'] : [input.status];
|
||||
for (const status of statuses) {
|
||||
writeRelationshipGroup(status, input.relationships, input.limit, input.io);
|
||||
}
|
||||
}
|
||||
|
||||
function writeRelationshipDecisionResult(result: WriteLocalScanRelationshipReviewDecisionResult, io: KtxCliIo): void {
|
||||
io.stdout.write('Recorded relationship decision\n');
|
||||
io.stdout.write(`Decision: ${result.decision.decision}\n`);
|
||||
io.stdout.write(`Candidate: ${result.decision.candidateId}\n`);
|
||||
io.stdout.write(`Previous status: ${result.decision.previousStatus}\n`);
|
||||
io.stdout.write(`Reviewer: ${result.decision.reviewer}\n`);
|
||||
if (result.decision.note) {
|
||||
io.stdout.write(`Note: ${result.decision.note}\n`);
|
||||
}
|
||||
io.stdout.write(`Path: ${result.path}\n`);
|
||||
}
|
||||
|
||||
function writeRelationshipApplyResult(result: ApplyLocalScanRelationshipReviewDecisionsResult, io: KtxCliIo): void {
|
||||
io.stdout.write('Relationship review apply\n');
|
||||
io.stdout.write(`Run: ${result.runId}\n`);
|
||||
io.stdout.write(`Connection: ${result.connectionId}\n`);
|
||||
io.stdout.write(`Sync: ${result.syncId}\n`);
|
||||
io.stdout.write(`Mode: ${result.dryRun ? 'dry-run' : 'write'}\n`);
|
||||
io.stdout.write(`Decisions: ${result.selectedDecisions} ${plural(result.selectedDecisions, 'accepted decision')}\n`);
|
||||
io.stdout.write(
|
||||
`Applied: ${result.appliedRelationships} manual ${plural(result.appliedRelationships, 'relationship')}\n`,
|
||||
);
|
||||
io.stdout.write(`Schema shards written: ${result.manifestShardsWritten}\n`);
|
||||
if (result.manifestShards.length > 0) {
|
||||
io.stdout.write('Schema shards:\n');
|
||||
for (const shard of result.manifestShards) {
|
||||
io.stdout.write(` - ${shard}\n`);
|
||||
}
|
||||
}
|
||||
io.stdout.write(`Decisions: ${result.decisionsPath}\n`);
|
||||
}
|
||||
|
||||
function formatFeedbackColumns(columns: readonly string[]): string {
|
||||
return columns.length === 1 ? (columns[0] ?? 'unknown') : `(${columns.join(',')})`;
|
||||
}
|
||||
|
||||
function feedbackTableShortName(value: string): string {
|
||||
return value.split('.').at(-1) ?? value;
|
||||
}
|
||||
|
||||
function feedbackEndpoint(label: KtxRelationshipFeedbackLabel, side: 'from' | 'to'): string {
|
||||
if (side === 'from') {
|
||||
return `${feedbackTableShortName(label.fromTable)}.${formatFeedbackColumns(label.fromColumns)}`;
|
||||
}
|
||||
return `${feedbackTableShortName(label.toTable)}.${formatFeedbackColumns(label.toColumns)}`;
|
||||
}
|
||||
|
||||
function writeRelationshipFeedbackSummary(result: ExportLocalRelationshipFeedbackLabelsResult, io: KtxCliIo): void {
|
||||
io.stdout.write('KTX relationship feedback labels\n');
|
||||
io.stdout.write(`Generated: ${result.generatedAt}\n`);
|
||||
io.stdout.write(`Filter connection: ${result.filters.connectionId ?? 'all'}\n`);
|
||||
io.stdout.write(`Filter decision: ${result.filters.decision}\n`);
|
||||
io.stdout.write(`Total: ${result.summary.total}\n`);
|
||||
io.stdout.write(`Accepted: ${result.summary.accepted}\n`);
|
||||
io.stdout.write(`Rejected: ${result.summary.rejected}\n`);
|
||||
io.stdout.write(`Connections: ${result.summary.connections}\n`);
|
||||
io.stdout.write(`Runs: ${result.summary.runs}\n`);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
io.stdout.write('\nWarnings\n');
|
||||
for (const warning of result.warnings.slice(0, 5)) {
|
||||
io.stdout.write(` - ${warning.path}: ${warning.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.labels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
io.stdout.write('\nLabels\n');
|
||||
for (const label of result.labels.slice(0, 25)) {
|
||||
io.stdout.write(` - ${feedbackEndpoint(label, 'from')} -> ${feedbackEndpoint(label, 'to')}\n`);
|
||||
io.stdout.write(
|
||||
` decision=${label.decision} previous=${label.previousStatus} score=${formatRelationshipScore(label.score)} reviewer=${label.reviewer}\n`,
|
||||
);
|
||||
}
|
||||
if (result.labels.length > 25) {
|
||||
io.stdout.write(` ${result.labels.length - 25} more labels not shown; rerun with --jsonl for the full dataset\n`);
|
||||
}
|
||||
const statusCommand = styled ? dim('ktx status') : 'ktx status';
|
||||
io.stdout.write(` ${statusCommand} --project-dir ${projectDirArg}\n`);
|
||||
}
|
||||
|
||||
interface KtxCliScanProgressState {
|
||||
|
|
@ -540,184 +249,9 @@ export function createCliScanProgress(
|
|||
return progress;
|
||||
}
|
||||
|
||||
function writeStatus(status: LocalScanStatusResponse, io: KtxCliIo): void {
|
||||
io.stdout.write(`Run: ${status.runId}\n`);
|
||||
io.stdout.write(`Status: ${status.status}\n`);
|
||||
io.stdout.write(`Connection: ${status.connectionId}\n`);
|
||||
io.stdout.write(`Mode: ${status.mode}\n`);
|
||||
io.stdout.write(`Sync: ${status.syncId}\n`);
|
||||
io.stdout.write(`Progress: ${status.progress}\n`);
|
||||
io.stdout.write(`Report: ${status.reportPath ?? 'none'}\n`);
|
||||
}
|
||||
|
||||
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> {
|
||||
try {
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
if (args.command === 'status') {
|
||||
const status = await (deps.getLocalScanStatus ?? getLocalScanStatus)(project, args.runId);
|
||||
if (!status) {
|
||||
throw new Error(`Scan run "${args.runId}" was not found`);
|
||||
}
|
||||
writeStatus(status, io);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'report') {
|
||||
const report = await (deps.getLocalScanReport ?? getLocalScanReport)(project, args.runId);
|
||||
if (!report) {
|
||||
throw new Error(`Scan report "${args.runId}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else {
|
||||
writeReport(report, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationships') {
|
||||
const result = await (deps.readLocalScanRelationshipArtifacts ?? readLocalScanRelationshipArtifacts)(
|
||||
project,
|
||||
args.runId,
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`Scan run "${args.runId}" was not found`);
|
||||
}
|
||||
const filtered = filteredRelationshipArtifact(result.relationships, args.status);
|
||||
if (args.json) {
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
runId: result.runId,
|
||||
connectionId: result.connectionId,
|
||||
syncId: result.syncId,
|
||||
status: args.status,
|
||||
paths: result.paths,
|
||||
diagnostics: result.diagnostics,
|
||||
summary: {
|
||||
accepted: result.relationships.accepted.length,
|
||||
review: result.relationships.review.length,
|
||||
rejected: result.relationships.rejected.length,
|
||||
skipped: result.relationships.skipped.length,
|
||||
},
|
||||
relationships: filtered,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
} else {
|
||||
writeRelationshipArtifactSummary({
|
||||
runId: result.runId,
|
||||
connectionId: result.connectionId,
|
||||
syncId: result.syncId,
|
||||
status: args.status,
|
||||
limit: args.limit,
|
||||
summary: result.relationships,
|
||||
relationships: filtered,
|
||||
diagnostics: result.diagnostics,
|
||||
relationshipsPath: result.paths.relationships,
|
||||
io,
|
||||
});
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipDecision') {
|
||||
const result = await (deps.writeLocalScanRelationshipReviewDecision ?? writeLocalScanRelationshipReviewDecision)(
|
||||
project,
|
||||
{
|
||||
runId: args.runId,
|
||||
candidateId: args.candidateId,
|
||||
decision: args.decision,
|
||||
reviewer: args.reviewer,
|
||||
note: args.note,
|
||||
},
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`Scan run "${args.runId}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
writeRelationshipDecisionResult(result, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipApply') {
|
||||
const result = await (
|
||||
deps.applyLocalScanRelationshipReviewDecisions ?? applyLocalScanRelationshipReviewDecisions
|
||||
)(project, {
|
||||
runId: args.runId,
|
||||
applyAllAccepted: args.applyAllAccepted,
|
||||
candidateIds: args.candidateIds,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
if (args.json) {
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(result satisfies ApplyLocalScanRelationshipReviewDecisionsResult, null, 2)}\n`,
|
||||
);
|
||||
} else {
|
||||
writeRelationshipApplyResult(result, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipFeedback') {
|
||||
const result = await (deps.exportLocalRelationshipFeedbackLabels ?? exportLocalRelationshipFeedbackLabels)(
|
||||
project,
|
||||
{
|
||||
connectionId: args.connectionId,
|
||||
decision: args.decision,
|
||||
},
|
||||
);
|
||||
if (args.jsonl) {
|
||||
io.stdout.write(
|
||||
(deps.formatKtxRelationshipFeedbackLabelsJsonl ?? formatKtxRelationshipFeedbackLabelsJsonl)(result),
|
||||
);
|
||||
} else if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
writeRelationshipFeedbackSummary(result, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipCalibration') {
|
||||
const result = await (deps.calibrateLocalRelationshipFeedbackLabels ?? calibrateLocalRelationshipFeedbackLabels)(
|
||||
project,
|
||||
{
|
||||
connectionId: args.connectionId,
|
||||
decision: args.decision,
|
||||
acceptThreshold: args.acceptThreshold,
|
||||
reviewThreshold: args.reviewThreshold,
|
||||
},
|
||||
);
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result satisfies KtxRelationshipFeedbackCalibrationReport, null, 2)}\n`);
|
||||
} else {
|
||||
io.stdout.write(
|
||||
(deps.formatKtxRelationshipFeedbackCalibrationMarkdown ?? formatKtxRelationshipFeedbackCalibrationMarkdown)(
|
||||
result,
|
||||
),
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipThresholds') {
|
||||
const result = await (
|
||||
deps.adviseLocalRelationshipFeedbackThresholds ?? adviseLocalRelationshipFeedbackThresholds
|
||||
)(project, {
|
||||
connectionId: args.connectionId,
|
||||
minTotalLabels: args.minTotalLabels,
|
||||
minAcceptedLabels: args.minAcceptedLabels,
|
||||
minRejectedLabels: args.minRejectedLabels,
|
||||
});
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result satisfies KtxRelationshipThresholdAdviceReport, null, 2)}\n`);
|
||||
} else {
|
||||
io.stdout.write(
|
||||
(deps.formatKtxRelationshipThresholdAdviceMarkdown ?? formatKtxRelationshipThresholdAdviceMarkdown)(result),
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const managedDaemon = managedDaemonOptionsForScanRun(args, io);
|
||||
const connector =
|
||||
args.mode !== 'structural' || args.detectRelationships
|
||||
|
|
|
|||
|
|
@ -897,7 +897,7 @@ describe('setup databases step', () => {
|
|||
commandIo.stdout.write(' Raw sources: raw-sources/postgres-warehouse/live-database/2026-05-09-221301-local-moywh3ky\n');
|
||||
commandIo.stdout.write(' Schema shards: 1\n\n');
|
||||
commandIo.stdout.write('Next:\n');
|
||||
commandIo.stdout.write(` ktx dev scan status --project-dir ${tempDir} local-moywh3ky\n`);
|
||||
commandIo.stdout.write(` ktx status --project-dir ${tempDir} local-moywh3ky\n`);
|
||||
return 0;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1207,7 +1207,7 @@ async function validateAndScanConnection(input: {
|
|||
if (scanCode !== 0) {
|
||||
flushBufferedCommandOutput(input.io, scanIo);
|
||||
input.io.stderr.write(`Structural scan failed for ${input.connectionId}.\n`);
|
||||
input.io.stderr.write(`Debug command: ktx dev scan --project-dir ${input.projectDir} ${input.connectionId}\n`);
|
||||
input.io.stderr.write(`Debug command: ktx scan --project-dir ${input.projectDir} ${input.connectionId}\n`);
|
||||
return false;
|
||||
}
|
||||
const scanOutput = scanIo.stdoutText();
|
||||
|
|
|
|||
|
|
@ -728,7 +728,7 @@ async function runInitialSourceIngestWithRecovery(input: {
|
|||
}
|
||||
if (action === 'continue') {
|
||||
input.io.stdout.write(`Context source saved without a completed context build for ${input.connectionId}.\n`);
|
||||
input.io.stdout.write(`Run later: ktx ingest ${input.connectionId}\n`);
|
||||
input.io.stdout.write(`Run later: ktx ingest run --connection-id ${input.connectionId} --adapter <adapter>\n`);
|
||||
return 'continue';
|
||||
}
|
||||
return 'back';
|
||||
|
|
|
|||
|
|
@ -50,14 +50,6 @@ async function runBuiltCli(args: string[], options: { env?: NodeJS.ProcessEnv }
|
|||
}
|
||||
}
|
||||
|
||||
function getRunId(stdout: string): string {
|
||||
const match = stdout.match(/^Run: (.+)$/m);
|
||||
if (!match) {
|
||||
throw new Error(`Could not find run id in output:\n${stdout}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function writeWarehouseConfig(projectDir: string): Promise<void> {
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
|
|
@ -181,7 +173,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
await writeSourceFixture(sourceDir);
|
||||
|
||||
const run = await runBuiltCli([
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -195,7 +186,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
]);
|
||||
expect(run).toMatchObject({ code: 1, stdout: '' });
|
||||
expect(run.stderr).toContain(
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -228,7 +219,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
nextSteps: [
|
||||
`ktx setup --project-dir ${projectDir}`,
|
||||
`ktx status --project-dir ${projectDir}`,
|
||||
'ktx ingest <connection>',
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${projectDir}`,
|
||||
],
|
||||
},
|
||||
|
|
@ -260,31 +251,11 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect(connectionTest.stdout).toContain('Driver: sqlite');
|
||||
expect(connectionTest.stdout).toContain('Tables: 2');
|
||||
|
||||
const structural = await runBuiltCli(['dev', 'scan', 'warehouse', '--project-dir', projectDir]);
|
||||
const structural = await runBuiltCli(['scan', 'warehouse', '--project-dir', projectDir]);
|
||||
expectProjectStderr(structural, projectDir);
|
||||
expect(structural.stdout).toContain('Status: done');
|
||||
expect(structural.stdout).toContain('Mode: structural');
|
||||
const structuralRunId = getRunId(structural.stdout);
|
||||
|
||||
const structuralReportResult = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
structuralRunId,
|
||||
]);
|
||||
expect(structuralReportResult).toMatchObject({ code: 0, stderr: '' });
|
||||
const structuralReport = parseJsonOutput<{
|
||||
mode: string;
|
||||
artifactPaths: { manifestShards: string[]; enrichmentArtifacts: string[] };
|
||||
manifestShardsWritten: number;
|
||||
}>(structuralReportResult.stdout);
|
||||
expect(structuralReport.mode).toBe('structural');
|
||||
expect(structuralReport.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
expect(structuralReport.artifactPaths.enrichmentArtifacts).toEqual([]);
|
||||
expect(structuralReport.manifestShardsWritten).toBe(1);
|
||||
expect(structural.stdout).toContain('Schema shards: 1');
|
||||
|
||||
const structuralManifest = await readFile(
|
||||
join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'),
|
||||
|
|
@ -296,7 +267,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect(structuralManifest).not.toContain('ai:');
|
||||
|
||||
const providerlessEnriched = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'warehouse',
|
||||
'--project-dir',
|
||||
|
|
@ -310,89 +280,11 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect(providerlessEnriched.stdout).toContain('Accepted: 1');
|
||||
expect(providerlessEnriched.stdout).toContain('scan_enrichment_backend_not_configured');
|
||||
expect(providerlessEnriched.stdout).toContain('Enrichment artifacts: 3');
|
||||
const providerlessRunId = getRunId(providerlessEnriched.stdout);
|
||||
|
||||
const providerlessReportResult = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
providerlessRunId,
|
||||
]);
|
||||
expect(providerlessReportResult).toMatchObject({ code: 0, stderr: '' });
|
||||
const providerlessReport = parseJsonOutput<{
|
||||
mode: string;
|
||||
enrichment: {
|
||||
tableDescriptions: string;
|
||||
columnDescriptions: string;
|
||||
embeddings: string;
|
||||
deterministicRelationships: string;
|
||||
statisticalValidation: string;
|
||||
};
|
||||
relationships: { accepted: number; review: number; rejected: number; skipped: number };
|
||||
warnings: Array<{ code: string }>;
|
||||
artifactPaths: { enrichmentArtifacts: string[]; manifestShards: string[] };
|
||||
}>(providerlessReportResult.stdout);
|
||||
expect(providerlessReport.mode).toBe('enriched');
|
||||
expect(providerlessReport.enrichment).toMatchObject({
|
||||
tableDescriptions: 'skipped',
|
||||
columnDescriptions: 'skipped',
|
||||
embeddings: 'skipped',
|
||||
deterministicRelationships: 'completed',
|
||||
statisticalValidation: 'completed',
|
||||
});
|
||||
expect(providerlessReport.relationships).toEqual({ accepted: 1, review: 0, rejected: 0, skipped: 0 });
|
||||
expect(providerlessReport.warnings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'scan_enrichment_backend_not_configured' })]),
|
||||
);
|
||||
expect(providerlessReport.artifactPaths.enrichmentArtifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('/enrichment/relationships.json'),
|
||||
expect.stringContaining('/enrichment/relationship-profile.json'),
|
||||
expect.stringContaining('/enrichment/relationship-diagnostics.json'),
|
||||
]),
|
||||
);
|
||||
expect(providerlessReport.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
|
||||
await writeSqliteScanConfig(projectDir, dbPath, true);
|
||||
const enriched = await runBuiltCli(['dev', 'scan', 'warehouse', '--project-dir', projectDir, '--mode', 'enriched']);
|
||||
const enriched = await runBuiltCli(['scan', 'warehouse', '--project-dir', projectDir, '--mode', 'enriched']);
|
||||
expectProjectStderr(enriched, projectDir);
|
||||
expect(enriched.stdout).toContain('Mode: enriched');
|
||||
const enrichedRunId = getRunId(enriched.stdout);
|
||||
|
||||
const enrichedReportResult = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
enrichedRunId,
|
||||
]);
|
||||
expect(enrichedReportResult).toMatchObject({ code: 0, stderr: '' });
|
||||
const enrichedReport = parseJsonOutput<{
|
||||
mode: string;
|
||||
enrichment: { tableDescriptions: string; columnDescriptions: string; embeddings: string };
|
||||
artifactPaths: { enrichmentArtifacts: string[]; manifestShards: string[] };
|
||||
}>(enrichedReportResult.stdout);
|
||||
expect(enrichedReport.mode).toBe('enriched');
|
||||
expect(enrichedReport.enrichment).toMatchObject({
|
||||
tableDescriptions: 'completed',
|
||||
columnDescriptions: 'completed',
|
||||
embeddings: 'completed',
|
||||
});
|
||||
expect(enrichedReport.artifactPaths.enrichmentArtifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('/enrichment/descriptions.json'),
|
||||
expect.stringContaining('/enrichment/embeddings.json'),
|
||||
expect.stringContaining('/enrichment/relationships.json'),
|
||||
expect.stringContaining('/enrichment/relationship-profile.json'),
|
||||
expect.stringContaining('/enrichment/relationship-diagnostics.json'),
|
||||
]),
|
||||
);
|
||||
expect(enrichedReport.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue