ktx/packages/cli/src/scan.ts

738 lines
27 KiB
TypeScript
Raw Normal View History

2026-05-10 23:51:24 +02:00
import { loadKtxProject } from '@ktx/context/project';
2026-05-10 23:12:26 +02:00
import {
type ApplyLocalScanRelationshipReviewDecisionsResult,
adviseLocalRelationshipFeedbackThresholds,
applyLocalScanRelationshipReviewDecisions,
calibrateLocalRelationshipFeedbackLabels,
type ExportLocalRelationshipFeedbackLabelsResult,
exportLocalRelationshipFeedbackLabels,
2026-05-10 23:51:24 +02:00
formatKtxRelationshipFeedbackCalibrationMarkdown,
formatKtxRelationshipFeedbackLabelsJsonl,
formatKtxRelationshipThresholdAdviceMarkdown,
2026-05-10 23:12:26 +02:00
getLocalScanReport,
getLocalScanStatus,
2026-05-10 23:51:24 +02:00
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,
2026-05-10 23:12:26 +02:00
type LocalScanStatusResponse,
readLocalScanRelationshipArtifacts,
runLocalScan,
type WriteLocalScanRelationshipReviewDecisionResult,
writeLocalScanRelationshipReviewDecision,
2026-05-10 23:51:24 +02:00
} from '@ktx/context/scan';
import type { KtxCliIo } from './index.js';
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
2026-05-10 23:12:26 +02:00
import { profileMark } from './startup-profile.js';
profileMark('module:scan');
2026-05-10 23:51:24 +02:00
export type KtxScanArgs =
2026-05-10 23:12:26 +02:00
| {
command: 'run';
projectDir: string;
connectionId: string;
2026-05-10 23:51:24 +02:00
mode: KtxScanMode;
2026-05-10 23:12:26 +02:00
detectRelationships: boolean;
dryRun: boolean;
databaseIntrospectionUrl?: string;
}
| { command: 'status'; projectDir: string; runId: string }
| { command: 'report'; projectDir: string; runId: string; json: boolean }
| {
command: 'relationships';
projectDir: string;
runId: string;
2026-05-10 23:51:24 +02:00
status: KtxRelationshipArtifactStatus;
2026-05-10 23:12:26 +02:00
json: boolean;
limit: number;
}
| {
command: 'relationshipDecision';
projectDir: string;
runId: string;
candidateId: string;
2026-05-10 23:51:24 +02:00
decision: KtxRelationshipReviewDecisionValue;
2026-05-10 23:12:26 +02:00
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;
2026-05-10 23:51:24 +02:00
decision: KtxRelationshipFeedbackDecisionFilter;
2026-05-10 23:12:26 +02:00
json: boolean;
jsonl: boolean;
}
| {
command: 'relationshipCalibration';
projectDir: string;
connectionId: string | null;
2026-05-10 23:51:24 +02:00
decision: KtxRelationshipFeedbackDecisionFilter;
2026-05-10 23:12:26 +02:00
acceptThreshold: number;
reviewThreshold: number;
json: boolean;
}
| {
command: 'relationshipThresholds';
projectDir: string;
connectionId: string | null;
minTotalLabels: number;
minAcceptedLabels: number;
minRejectedLabels: number;
json: boolean;
};
2026-05-10 23:51:24 +02:00
interface KtxScanDeps {
2026-05-10 23:12:26 +02:00
runLocalScan?: typeof runLocalScan;
2026-05-10 23:51:24 +02:00
createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
2026-05-10 23:12:26 +02:00
getLocalScanStatus?: typeof getLocalScanStatus;
getLocalScanReport?: typeof getLocalScanReport;
readLocalScanRelationshipArtifacts?: typeof readLocalScanRelationshipArtifacts;
writeLocalScanRelationshipReviewDecision?: typeof writeLocalScanRelationshipReviewDecision;
applyLocalScanRelationshipReviewDecisions?: typeof applyLocalScanRelationshipReviewDecisions;
exportLocalRelationshipFeedbackLabels?: typeof exportLocalRelationshipFeedbackLabels;
2026-05-10 23:51:24 +02:00
formatKtxRelationshipFeedbackLabelsJsonl?: typeof formatKtxRelationshipFeedbackLabelsJsonl;
2026-05-10 23:12:26 +02:00
calibrateLocalRelationshipFeedbackLabels?: typeof calibrateLocalRelationshipFeedbackLabels;
2026-05-10 23:51:24 +02:00
formatKtxRelationshipFeedbackCalibrationMarkdown?: typeof formatKtxRelationshipFeedbackCalibrationMarkdown;
2026-05-10 23:12:26 +02:00
adviseLocalRelationshipFeedbackThresholds?: typeof adviseLocalRelationshipFeedbackThresholds;
2026-05-10 23:51:24 +02:00
formatKtxRelationshipThresholdAdviceMarkdown?: typeof formatKtxRelationshipThresholdAdviceMarkdown;
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
function shouldUseStyledOutput(io: KtxCliIo): boolean {
2026-05-10 23:12:26 +02:00
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;
}
2026-05-10 23:51:24 +02:00
function tableChangeCount(report: KtxScanReport): number {
2026-05-10 23:12:26 +02:00
return report.diffSummary.tablesAdded + report.diffSummary.tablesModified + report.diffSummary.tablesDeleted;
}
2026-05-10 23:51:24 +02:00
function totalTableCount(report: KtxScanReport): number {
2026-05-10 23:12:26 +02:00
return tableChangeCount(report) + report.diffSummary.tablesUnchanged;
}
2026-05-10 23:51:24 +02:00
function writeScanIdentity(report: KtxScanReport, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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`);
}
2026-05-10 23:51:24 +02:00
function writeWhatChanged(report: KtxScanReport, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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`);
}
}
2026-05-10 23:51:24 +02:00
function hasRelationshipResults(report: KtxScanReport): boolean {
2026-05-10 23:12:26 +02:00
return (
report.relationships.accepted > 0 ||
report.relationships.review > 0 ||
report.relationships.rejected > 0 ||
report.relationships.skipped > 0
);
}
2026-05-10 23:51:24 +02:00
function writeRelationships(report: KtxScanReport, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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.`;
}
2026-05-10 23:51:24 +02:00
function warningLine(warning: KtxScanWarning): string {
2026-05-10 23:12:26 +02:00
const location = warning.table ? `${warning.table}${warning.column ? `.${warning.column}` : ''}: ` : '';
return `${warning.code}: ${location}${warning.message}`;
}
2026-05-10 23:51:24 +02:00
function writeNeedsAttention(report: KtxScanReport, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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`);
}
}
}
2026-05-10 23:51:24 +02:00
function writeArtifacts(report: KtxScanReport, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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`);
}
}
2026-05-10 23:51:24 +02:00
function writeHumanReportBody(report: KtxScanReport, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
writeScanIdentity(report, io);
writeWhatChanged(report, io);
writeRelationships(report, io);
writeNeedsAttention(report, io);
writeArtifacts(report, io);
}
2026-05-10 23:51:24 +02:00
function writeRunSummary(report: KtxScanReport, projectDir: string, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
const styled = shouldUseStyledOutput(io);
2026-05-10 23:51:24 +02:00
io.stdout.write(`${styled ? green('✓') : ''}${styled ? ' ' : ''}KTX scan completed\n`);
2026-05-10 23:12:26 +02:00
io.stdout.write('Status: done\n');
writeHumanReportBody(report, io);
const projectDirArg = quoteCliArg(projectDir);
io.stdout.write('\nNext:\n');
2026-05-10 23:51:24 +02:00
const statusCommand = styled ? dim('ktx dev scan status') : 'ktx dev scan status';
const reportCommand = styled ? dim('ktx dev scan report') : 'ktx dev scan report';
2026-05-10 23:12:26 +02:00
io.stdout.write(` ${statusCommand} --project-dir ${projectDirArg} ${report.runId}\n`);
io.stdout.write(` ${reportCommand} --project-dir ${projectDirArg} ${report.runId}\n`);
}
2026-05-10 23:51:24 +02:00
function writeReport(report: KtxScanReport, io: KtxCliIo): void {
io.stdout.write('KTX scan report\n');
2026-05-10 23:12:26 +02:00
writeHumanReportBody(report, io);
}
2026-05-10 23:51:24 +02:00
function formatRelationshipEndpoint(edge: KtxRelationshipArtifactEdge, side: 'from' | 'to'): string {
2026-05-10 23:12:26 +02:00
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);
}
2026-05-10 23:51:24 +02:00
function relationshipStatusTitle(status: Exclude<KtxRelationshipArtifactStatus, 'all'>): string {
2026-05-10 23:12:26 +02:00
if (status === 'accepted') {
return 'Accepted relationships';
}
if (status === 'review') {
return 'Review relationships';
}
if (status === 'rejected') {
return 'Rejected relationships';
}
return 'Skipped relationships';
}
function filteredRelationshipArtifact(
2026-05-10 23:51:24 +02:00
relationships: KtxRelationshipArtifact,
status: KtxRelationshipArtifactStatus,
): KtxRelationshipArtifact {
2026-05-10 23:12:26 +02:00
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 : [],
};
}
2026-05-10 23:51:24 +02:00
function writeRelationshipEdge(edge: KtxRelationshipArtifactEdge, index: number, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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(
2026-05-10 23:51:24 +02:00
status: Exclude<KtxRelationshipArtifactStatus, 'all'>,
relationships: KtxRelationshipArtifact,
2026-05-10 23:12:26 +02:00
limit: number,
2026-05-10 23:51:24 +02:00
io: KtxCliIo,
2026-05-10 23:12:26 +02:00
): 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;
2026-05-10 23:51:24 +02:00
status: KtxRelationshipArtifactStatus;
2026-05-10 23:12:26 +02:00
limit: number;
2026-05-10 23:51:24 +02:00
summary: KtxRelationshipArtifact;
relationships: KtxRelationshipArtifact;
diagnostics: KtxRelationshipDiagnosticsArtifact | null;
2026-05-10 23:12:26 +02:00
relationshipsPath: string;
2026-05-10 23:51:24 +02:00
io: KtxCliIo;
2026-05-10 23:12:26 +02:00
}): void {
2026-05-10 23:51:24 +02:00
input.io.stdout.write('KTX relationship artifacts\n');
2026-05-10 23:12:26 +02:00
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`);
2026-05-10 23:51:24 +02:00
const statuses: Array<Exclude<KtxRelationshipArtifactStatus, 'all'>> =
2026-05-10 23:12:26 +02:00
input.status === 'all' ? ['accepted', 'review', 'rejected', 'skipped'] : [input.status];
for (const status of statuses) {
writeRelationshipGroup(status, input.relationships, input.limit, input.io);
}
}
2026-05-10 23:51:24 +02:00
function writeRelationshipDecisionResult(result: WriteLocalScanRelationshipReviewDecisionResult, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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`);
}
2026-05-10 23:51:24 +02:00
function writeRelationshipApplyResult(result: ApplyLocalScanRelationshipReviewDecisionsResult, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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;
}
2026-05-10 23:51:24 +02:00
function feedbackEndpoint(label: KtxRelationshipFeedbackLabel, side: 'from' | 'to'): string {
2026-05-10 23:12:26 +02:00
if (side === 'from') {
return `${feedbackTableShortName(label.fromTable)}.${formatFeedbackColumns(label.fromColumns)}`;
}
return `${feedbackTableShortName(label.toTable)}.${formatFeedbackColumns(label.toColumns)}`;
}
2026-05-10 23:51:24 +02:00
function writeRelationshipFeedbackSummary(result: ExportLocalRelationshipFeedbackLabelsResult, io: KtxCliIo): void {
io.stdout.write('KTX relationship feedback labels\n');
2026-05-10 23:12:26 +02:00
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`);
}
}
2026-05-10 23:51:24 +02:00
interface KtxCliScanProgressState {
2026-05-10 23:12:26 +02:00
progress: number;
hasPendingTransient: boolean;
}
2026-05-10 23:51:24 +02:00
interface KtxCliScanProgressUpdateOptions {
2026-05-10 23:12:26 +02:00
transient?: boolean;
}
2026-05-10 23:51:24 +02:00
interface KtxCliScanProgress extends Omit<KtxProgressPort, 'update'> {
update(progress: number, message?: string, options?: KtxCliScanProgressUpdateOptions): Promise<void>;
2026-05-10 23:12:26 +02:00
flush(): void;
}
export function createCliScanProgress(
2026-05-10 23:51:24 +02:00
io: KtxCliIo,
state: KtxCliScanProgressState = { progress: 0, hasPendingTransient: false },
2026-05-10 23:12:26 +02:00
start = 0,
weight = 1,
2026-05-10 23:51:24 +02:00
): KtxCliScanProgress {
2026-05-10 23:12:26 +02:00
const shouldWrite = io.stdout.isTTY === true && !process.env.CI;
2026-05-10 23:51:24 +02:00
const progress: KtxCliScanProgress = {
async update(value: number, message?: string, options?: KtxCliScanProgressUpdateOptions) {
2026-05-10 23:12:26 +02:00
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;
}
2026-05-10 23:51:24 +02:00
function writeStatus(status: LocalScanStatusResponse, io: KtxCliIo): void {
2026-05-10 23:12:26 +02:00
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`);
}
2026-05-10 23:51:24 +02:00
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> {
2026-05-10 23:12:26 +02:00
try {
2026-05-10 23:51:24 +02:00
const project = await loadKtxProject({ projectDir: args.projectDir });
2026-05-10 23:12:26 +02:00
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(
2026-05-10 23:51:24 +02:00
(deps.formatKtxRelationshipFeedbackLabelsJsonl ?? formatKtxRelationshipFeedbackLabelsJsonl)(result),
2026-05-10 23:12:26 +02:00
);
} 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) {
2026-05-10 23:51:24 +02:00
io.stdout.write(`${JSON.stringify(result satisfies KtxRelationshipFeedbackCalibrationReport, null, 2)}\n`);
2026-05-10 23:12:26 +02:00
} else {
io.stdout.write(
2026-05-10 23:51:24 +02:00
(deps.formatKtxRelationshipFeedbackCalibrationMarkdown ?? formatKtxRelationshipFeedbackCalibrationMarkdown)(
2026-05-10 23:12:26 +02:00
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) {
2026-05-10 23:51:24 +02:00
io.stdout.write(`${JSON.stringify(result satisfies KtxRelationshipThresholdAdviceReport, null, 2)}\n`);
2026-05-10 23:12:26 +02:00
} else {
io.stdout.write(
2026-05-10 23:51:24 +02:00
(deps.formatKtxRelationshipThresholdAdviceMarkdown ?? formatKtxRelationshipThresholdAdviceMarkdown)(result),
2026-05-10 23:12:26 +02:00
);
}
return 0;
}
const connector =
args.mode !== 'structural' || args.detectRelationships
2026-05-10 23:51:24 +02:00
? await createKtxCliScanConnector(project, args.connectionId)
2026-05-10 23:12:26 +02:00
: 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,
2026-05-10 23:51:24 +02:00
adapters: (deps.createLocalIngestAdapters ?? createKtxCliLocalIngestAdapters)(project, {
2026-05-10 23:12:26 +02:00
databaseIntrospectionUrl: args.databaseIntrospectionUrl,
}),
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;
}
}