ktx/packages/cli/src/scan.ts
Andrey Avtomonov 9dad936ac7
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design

* build: add bundled python runtime wheel builder

* build: make local embedding dependencies optional

* build: bundle python runtime wheel in cli artifacts

* build: track bundled python runtime release artifact

* test: verify bundled python runtime wheel

* docs: add plan for bundled python runtime wheel

* test: cover managed python runtime lifecycle

* feat: add managed python runtime installer

* feat: add runtime command runner

* feat: expose runtime management commands

* test: verify managed python runtime commands

* docs: add plan for managed python runtime installer

* feat: add managed python command helper

* feat: use managed runtime for sl query compute

* feat: route sl query managed runtime policy

* docs: add plan for managed runtime sl query integration

* feat: add managed runtime daemon metadata

* feat: manage python daemon lifecycle

* feat: add runtime daemon start stop commands

* fix: verify managed runtime daemon lifecycle

* docs: add plan for managed runtime daemon lifecycle

* feat: add managed local embeddings config marker

* feat: add managed local embeddings daemon helper

* feat: use managed runtime for local embedding setup

* feat: pass managed runtime policy through setup

* docs: add plan for managed local embeddings runtime

* feat: read CLI package metadata dynamically

* feat: assemble public kaelio ktx npm package

* feat: release one public kaelio ktx npm artifact

* test: cover public kaelio ktx package invocations

* chore: verify public kaelio ktx package artifacts

* docs: add plan for public kaelio ktx npm package

* test: verify managed runtime in public package smoke

* test: finalize managed runtime release smoke

* docs: add plan for managed runtime release smoke

* test: specify local embeddings release smoke

* feat: add local embeddings runtime smoke

* chore: register local embeddings smoke

* fix: verify local embeddings smoke

* fix: restore artifact smoke python env helper

* docs: add plan for managed local embeddings release smoke

* refactor: share managed runtime install policy parsing

* feat: use managed runtime for agent semantic queries

* feat: use managed runtime for MCP semantic compute

* docs: add plan for managed agent and MCP semantic runtime

* feat(cli): add managed daemon HTTP helpers

* feat(cli): route local adapters through managed daemon

* feat(cli): use managed daemon for ingest helpers

* feat(cli): pass managed daemon options to scan

* feat(context): pass MCP ingest pull config options

* feat(cli): pass managed daemon options to serve ingest

* test: verify managed local ingest daemon runtime

* docs: add plan for managed local ingest daemon runtime

* docs: align managed runtime examples

* docs: add plan for managed runtime docs cleanup

* test: cover published package runtime smoke commands

* test: validate published package smoke outputs

* docs: add plan for published package runtime smoke

* build: stamp public npm package version

* release: add npm public release policy

* release: add guarded npm publish script

* release: document public npm release handoff

* docs: add plan for public npm release handoff

* test: cover managed runtime prune in package smoke

* docs: document managed runtime prune

* docs: add plan for managed runtime prune smoke and docs

* chore: encode uv runtime prerequisite policy

* fix: clarify missing uv runtime error

* docs: document uv runtime prerequisite

* docs: add plan for uv runtime prerequisite contract

* refactor: limit release artifacts to public package runtime

* chore: align release policy with bundled runtime wheel

* docs: describe single public runtime artifact surface

* test: verify single public runtime artifact contract

* docs: add plan for single public runtime artifact cleanup

* fix: align local embeddings smoke with public version

* docs: add plan for local embeddings smoke public version

* release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag

Publish target moves to the pre-release version 0.1.0-rc.0 under the next
dist-tag so npm install @kaelio/ktx (which resolves to latest) does not
pick up the soft-launch build. Users opt in via @kaelio/ktx@next.

* Fix release script boundary checks

* Remove PostHog from public package bundle
2026-05-11 15:50:34 +02:00

753 lines
28 KiB
TypeScript

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';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
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;
};
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 {
return io.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== 'dumb' && !process.env.CI;
}
function green(text: string): string {
return `\u001b[32m${text}\u001b[39m`;
}
function dim(text: string): string {
return `\u001b[2m${text}\u001b[22m`;
}
function quoteCliArg(value: string): string {
if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
function plural(count: number, singular: string, pluralValue = `${singular}s`): string {
return count === 1 ? singular : pluralValue;
}
function tableChangeCount(report: KtxScanReport): number {
return report.diffSummary.tablesAdded + report.diffSummary.tablesModified + report.diffSummary.tablesDeleted;
}
function totalTableCount(report: KtxScanReport): number {
return tableChangeCount(report) + report.diffSummary.tablesUnchanged;
}
function writeScanIdentity(report: KtxScanReport, io: KtxCliIo): void {
io.stdout.write(`Run: ${report.runId}\n`);
io.stdout.write(`Connection: ${report.connectionId}\n`);
io.stdout.write(`Mode: ${report.mode}\n`);
io.stdout.write(`Sync: ${report.syncId}\n`);
io.stdout.write(`Dry run: ${report.dryRun ? 'yes' : 'no'}\n`);
}
function writeWhatChanged(report: KtxScanReport, io: KtxCliIo): void {
const changedTables = tableChangeCount(report);
const totalTables = totalTableCount(report);
io.stdout.write('\nWhat changed\n');
const tableNoun = plural(totalTables, 'table');
const changeNoun = plural(changedTables, 'change');
io.stdout.write(
` Semantic layer comparison found ${changedTables} ${changeNoun} across ${totalTables} ${tableNoun}\n`,
);
io.stdout.write(` New tables: ${report.diffSummary.tablesAdded}\n`);
io.stdout.write(` Changed tables: ${report.diffSummary.tablesModified}\n`);
io.stdout.write(` Removed tables: ${report.diffSummary.tablesDeleted}\n`);
io.stdout.write(` Unchanged tables: ${report.diffSummary.tablesUnchanged}\n`);
if (
report.diffSummary.columnsAdded > 0 ||
report.diffSummary.columnsModified > 0 ||
report.diffSummary.columnsDeleted > 0
) {
io.stdout.write(` New columns: ${report.diffSummary.columnsAdded}\n`);
io.stdout.write(` Changed columns: ${report.diffSummary.columnsModified}\n`);
io.stdout.write(` Removed columns: ${report.diffSummary.columnsDeleted}\n`);
}
}
function hasRelationshipResults(report: KtxScanReport): boolean {
return (
report.relationships.accepted > 0 ||
report.relationships.review > 0 ||
report.relationships.rejected > 0 ||
report.relationships.skipped > 0
);
}
function writeRelationships(report: KtxScanReport, io: KtxCliIo): void {
if (!hasRelationshipResults(report)) {
return;
}
io.stdout.write('\nRelationships\n');
io.stdout.write(` Accepted: ${report.relationships.accepted}\n`);
io.stdout.write(` Review: ${report.relationships.review}\n`);
io.stdout.write(` Rejected: ${report.relationships.rejected}\n`);
io.stdout.write(` Skipped: ${report.relationships.skipped}\n`);
}
function capabilityGapMessage(gap: string): string {
if (gap === 'columnStats') {
return 'columnStats is unavailable; relationship confidence may be lower.';
}
if (gap === 'tableSampling' || gap === 'columnSampling') {
return `${gap} is unavailable; descriptions may be less specific.`;
}
if (gap === 'readOnlySql') {
return 'readOnlySql is unavailable; relationship and validation checks may be limited.';
}
return `${gap} is unavailable; scan results may be less complete.`;
}
function warningLine(warning: KtxScanWarning): string {
const location = warning.table ? `${warning.table}${warning.column ? `.${warning.column}` : ''}: ` : '';
return `${warning.code}: ${location}${warning.message}`;
}
function managedDaemonOptionsForScanRun(args: Extract<KtxScanArgs, { command: 'run' }>, io: KtxCliIo) {
if (args.databaseIntrospectionUrl || !args.cliVersion || !args.runtimeInstallPolicy) {
return undefined;
}
return {
cliVersion: args.cliVersion,
installPolicy: args.runtimeInstallPolicy,
io,
};
}
function writeNeedsAttention(report: KtxScanReport, io: KtxCliIo): void {
io.stdout.write('\nNeeds attention\n');
if (report.warnings.length === 0 && report.capabilityGaps.length === 0) {
io.stdout.write(' None\n');
return;
}
if (report.warnings.length > 0) {
io.stdout.write(` ${report.warnings.length} ${plural(report.warnings.length, 'warning')}\n`);
for (const warning of report.warnings.slice(0, 5)) {
io.stdout.write(` - ${warningLine(warning)}\n`);
}
if (report.warnings.length > 5) {
io.stdout.write(` - ${report.warnings.length - 5} more warnings in the JSON report\n`);
}
}
if (report.capabilityGaps.length > 0) {
io.stdout.write(` ${report.capabilityGaps.length} capability ${plural(report.capabilityGaps.length, 'gap')}\n`);
for (const gap of report.capabilityGaps) {
io.stdout.write(` - ${capabilityGapMessage(gap)}\n`);
}
}
}
function writeArtifacts(report: KtxScanReport, io: KtxCliIo): void {
io.stdout.write('\nArtifacts\n');
io.stdout.write(` Report: ${report.artifactPaths.reportPath ?? 'none'}\n`);
io.stdout.write(` Raw sources: ${report.artifactPaths.rawSourcesDir ?? 'none'}\n`);
if (report.artifactPaths.manifestShards.length > 0) {
io.stdout.write(` Schema shards: ${report.artifactPaths.manifestShards.length}\n`);
}
if (report.artifactPaths.enrichmentArtifacts.length > 0) {
io.stdout.write(` Enrichment artifacts: ${report.artifactPaths.enrichmentArtifacts.length}\n`);
}
}
function writeHumanReportBody(report: KtxScanReport, io: KtxCliIo): void {
writeScanIdentity(report, io);
writeWhatChanged(report, io);
writeRelationships(report, io);
writeNeedsAttention(report, io);
writeArtifacts(report, io);
}
function writeRunSummary(report: KtxScanReport, projectDir: string, io: KtxCliIo): void {
const styled = shouldUseStyledOutput(io);
io.stdout.write(`${styled ? green('✓') : ''}${styled ? ' ' : ''}KTX scan completed\n`);
io.stdout.write('Status: done\n');
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`);
}
}
interface KtxCliScanProgressState {
progress: number;
hasPendingTransient: boolean;
}
interface KtxCliScanProgressUpdateOptions {
transient?: boolean;
}
interface KtxCliScanProgress extends Omit<KtxProgressPort, 'update'> {
update(progress: number, message?: string, options?: KtxCliScanProgressUpdateOptions): Promise<void>;
flush(): void;
}
export function createCliScanProgress(
io: KtxCliIo,
state: KtxCliScanProgressState = { progress: 0, hasPendingTransient: false },
start = 0,
weight = 1,
): KtxCliScanProgress {
const shouldWrite = io.stdout.isTTY === true && !process.env.CI;
const progress: KtxCliScanProgress = {
async update(value: number, message?: string, options?: KtxCliScanProgressUpdateOptions) {
const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight;
state.progress = Math.max(state.progress, Math.min(1, absoluteValue));
if (!shouldWrite || !message) {
return;
}
const percent = Math.max(0, Math.min(100, Math.round(absoluteValue * 100)));
const line = `[${percent}%] ${message}`;
if (options?.transient === true) {
io.stdout.write(`\r${line}\u001b[K`);
state.hasPendingTransient = true;
return;
}
progress.flush();
io.stdout.write(`${line}\n`);
},
startPhase(phaseWeight: number) {
return createCliScanProgress(io, state, state.progress, phaseWeight);
},
flush() {
if (!shouldWrite || !state.hasPendingTransient) {
return;
}
io.stdout.write('\n');
state.hasPendingTransient = false;
},
};
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
? await createKtxCliScanConnector(project, args.connectionId)
: undefined;
const progress = createCliScanProgress(io);
try {
const result = await (deps.runLocalScan ?? runLocalScan)({
project,
connectionId: args.connectionId,
mode: args.mode,
detectRelationships: args.detectRelationships,
dryRun: args.dryRun,
trigger: 'cli',
databaseIntrospectionUrl: args.databaseIntrospectionUrl,
connector,
adapters: (deps.createLocalIngestAdapters ?? createKtxCliLocalIngestAdapters)(project, {
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
...(managedDaemon ? { managedDaemon } : {}),
}),
progress,
});
progress.flush();
writeRunSummary(result.report, args.projectDir, io);
} finally {
progress.flush();
}
return 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}