feat(cli): clean up dev command surface

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

View file

@ -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',
],
});

View file

@ -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),
];
}

View file

@ -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}`,
],
},

View file

@ -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);

View file

@ -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>;

View file

@ -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']),
});

View file

@ -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);
});
}

View file

@ -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>',

View file

@ -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;
}
}

View file

@ -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) });
},
);
}

View file

@ -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);
});
}

View file

@ -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')

View file

@ -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,
});
});
}

View file

@ -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;
}

View file

@ -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('');
});

View file

@ -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;
}

View file

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

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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('');
});

View file

@ -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, {

View file

@ -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 });

View file

@ -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',
);
});

View file

@ -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));

View file

@ -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 = {

View file

@ -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

View file

@ -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

View file

@ -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;
});

View file

@ -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();

View file

@ -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';

View file

@ -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');