ktx/packages/cli/src/scan.test.ts

1268 lines
40 KiB
TypeScript
Raw Normal View History

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2026-05-10 23:12:26 +02:00
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SourceAdapter } from '@ktx/context/ingest';
2026-05-10 23:51:24 +02:00
import { initKtxProject } from '@ktx/context/project';
2026-05-10 23:12:26 +02:00
import type {
2026-05-10 23:51:24 +02:00
KtxScanReport,
2026-05-10 23:12:26 +02:00
LocalScanRunResult,
RunLocalScanOptions,
2026-05-10 23:51:24 +02:00
} from '@ktx/context/scan';
2026-05-10 23:12:26 +02:00
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createCliScanProgress, runKtxScan, type KtxScanDeps } from './scan.js';
2026-05-10 23:12:26 +02:00
const sqlServerExtractSchema = vi.hoisted(() =>
vi.fn(async (connectionId: string) => ({
connectionId,
extractedAt: '2026-04-29T16:00:00.000Z',
metadata: { database: 'analytics' },
tables: [
{
catalog: 'analytics',
db: 'dbo',
name: 'orders',
columns: [{ name: 'id', type: 'int', nullable: false, primaryKey: true }],
foreignKeys: [],
},
],
})),
);
const createSqlServerLiveDatabaseIntrospection = vi.hoisted(() =>
vi.fn(() => ({ extractSchema: sqlServerExtractSchema })),
);
2026-05-10 23:51:24 +02:00
const isKtxSqlServerConnectionConfig = vi.hoisted(() =>
2026-05-10 23:12:26 +02:00
vi.fn((connection: { driver?: string } | undefined) => connection?.driver === 'sqlserver'),
);
2026-05-10 23:51:24 +02:00
const KtxSqlServerScanConnector = vi.hoisted(
2026-05-10 23:12:26 +02:00
() =>
class {
readonly id: string;
readonly driver = 'sqlserver';
constructor(options: { connectionId: string }) {
this.id = `sqlserver:${options.connectionId}`;
}
},
);
const bigQueryExtractSchema = vi.hoisted(() =>
vi.fn(async (connectionId: string) => ({
connectionId,
extractedAt: '2026-04-29T17:00:00.000Z',
metadata: { project_id: 'project-1', datasets: ['analytics'] },
tables: [
{
catalog: 'project-1',
db: 'analytics',
name: 'orders',
columns: [{ name: 'id', type: 'INT64', nullable: false, primaryKey: true }],
foreignKeys: [],
},
],
})),
);
const createBigQueryLiveDatabaseIntrospection = vi.hoisted(() =>
vi.fn(() => ({ extractSchema: bigQueryExtractSchema })),
);
2026-05-10 23:51:24 +02:00
const isKtxBigQueryConnectionConfig = vi.hoisted(() =>
2026-05-10 23:12:26 +02:00
vi.fn((connection: { driver?: string } | undefined) => connection?.driver === 'bigquery'),
);
2026-05-10 23:51:24 +02:00
const KtxBigQueryScanConnector = vi.hoisted(
2026-05-10 23:12:26 +02:00
() =>
class {
readonly id: string;
readonly driver = 'bigquery';
constructor(options: { connectionId: string }) {
this.id = `bigquery:${options.connectionId}`;
}
},
);
const snowflakeExtractSchema = vi.hoisted(() =>
vi.fn(async (connectionId: string) => ({
connectionId,
extractedAt: '2026-04-29T18:00:00.000Z',
metadata: { database: 'ANALYTICS', schemas: ['PUBLIC'] },
tables: [
{
catalog: 'ANALYTICS',
db: 'PUBLIC',
name: 'ORDERS',
columns: [{ name: 'ID', type: 'NUMBER', nullable: false, primaryKey: true }],
foreignKeys: [],
},
],
})),
);
const createSnowflakeLiveDatabaseIntrospection = vi.hoisted(() =>
vi.fn(() => ({ extractSchema: snowflakeExtractSchema })),
);
2026-05-10 23:51:24 +02:00
const isKtxSnowflakeConnectionConfig = vi.hoisted(() =>
2026-05-10 23:12:26 +02:00
vi.fn((connection: { driver?: string } | undefined) => connection?.driver === 'snowflake'),
);
2026-05-10 23:51:24 +02:00
const KtxSnowflakeScanConnector = vi.hoisted(
2026-05-10 23:12:26 +02:00
() =>
class {
readonly id: string;
readonly driver = 'snowflake';
constructor(options: { connectionId: string }) {
this.id = `snowflake:${options.connectionId}`;
}
},
);
const postgresExtractSchema = vi.hoisted(() =>
vi.fn(async (connectionId: string) => ({
connectionId,
extractedAt: '2026-04-29T12:00:00.000Z',
metadata: { database: 'analytics' },
tables: [],
})),
);
const createPostgresLiveDatabaseIntrospection = vi.hoisted(() =>
vi.fn(() => ({ extractSchema: postgresExtractSchema })),
);
2026-05-10 23:51:24 +02:00
const isKtxPostgresConnectionConfig = vi.hoisted(() =>
2026-05-10 23:12:26 +02:00
vi.fn((connection: { driver?: string } | undefined) =>
['postgres', 'postgresql'].includes(String(connection?.driver ?? '').toLowerCase()),
),
);
2026-05-10 23:51:24 +02:00
const KtxPostgresScanConnector = vi.hoisted(
2026-05-10 23:12:26 +02:00
() =>
class {
readonly id: string;
readonly driver = 'postgres';
constructor(options: { connectionId: string }) {
this.id = `postgres:${options.connectionId}`;
}
},
);
2026-05-10 23:51:24 +02:00
vi.mock('@ktx/connector-sqlserver', () => ({
2026-05-10 23:12:26 +02:00
createSqlServerLiveDatabaseIntrospection,
2026-05-10 23:51:24 +02:00
isKtxSqlServerConnectionConfig,
KtxSqlServerScanConnector,
2026-05-10 23:12:26 +02:00
}));
2026-05-10 23:51:24 +02:00
vi.mock('@ktx/connector-bigquery', () => ({
2026-05-10 23:12:26 +02:00
createBigQueryLiveDatabaseIntrospection,
2026-05-10 23:51:24 +02:00
isKtxBigQueryConnectionConfig,
KtxBigQueryScanConnector,
2026-05-10 23:12:26 +02:00
}));
2026-05-10 23:51:24 +02:00
vi.mock('@ktx/connector-snowflake', () => ({
2026-05-10 23:12:26 +02:00
createSnowflakeLiveDatabaseIntrospection,
2026-05-10 23:51:24 +02:00
isKtxSnowflakeConnectionConfig,
KtxSnowflakeScanConnector,
2026-05-10 23:12:26 +02:00
}));
2026-05-10 23:51:24 +02:00
vi.mock('@ktx/connector-postgres', () => ({
2026-05-10 23:12:26 +02:00
createPostgresLiveDatabaseIntrospection,
2026-05-10 23:51:24 +02:00
isKtxPostgresConnectionConfig,
KtxPostgresScanConnector,
2026-05-10 23:12:26 +02:00
}));
function makeIo(options: { isTTY?: boolean } = {}) {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
isTTY: options.isTTY,
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function fakeLiveDatabaseAdapter(
createIntrospection: (options: { connections: unknown }) => {
extractSchema: (connectionId: string) => Promise<unknown>;
},
): SourceAdapter {
return {
source: 'live-database',
skillNames: [],
async detect() {
return true;
},
async fetch(_pullConfig: unknown, stagedDir: string, ctx: { connectionId: string }) {
await mkdir(stagedDir, { recursive: true });
const schema = await createIntrospection({ connections: {} }).extractSchema(ctx.connectionId);
await writeFile(
join(stagedDir, 'connection.json'),
JSON.stringify({ connectionId: ctx.connectionId, schema }, null, 2),
'utf-8',
);
},
async chunk() {
return { workUnits: [] };
},
};
}
2026-05-10 23:51:24 +02:00
const report: KtxScanReport = {
2026-05-10 23:12:26 +02:00
connectionId: 'warehouse',
driver: 'postgres',
syncId: 'sync-1',
runId: 'scan-run-1',
trigger: 'cli',
mode: 'structural',
dryRun: false,
artifactPaths: {
rawSourcesDir: 'raw-sources/warehouse/live-database/sync-1',
reportPath: 'raw-sources/warehouse/live-database/sync-1/scan-report.json',
manifestShards: [],
enrichmentArtifacts: [],
},
diffSummary: {
tablesAdded: 1,
tablesModified: 0,
tablesDeleted: 0,
tablesUnchanged: 0,
columnsAdded: 0,
columnsModified: 0,
columnsDeleted: 0,
},
manifestShardsWritten: 0,
structuralSyncStats: {
tablesCreated: 0,
tablesUpdated: 0,
tablesDeleted: 0,
columnsCreated: 0,
columnsUpdated: 0,
columnsDeleted: 0,
},
enrichment: {
dataDictionary: 'skipped',
tableDescriptions: 'skipped',
columnDescriptions: 'skipped',
embeddings: 'skipped',
deterministicRelationships: 'skipped',
llmRelationshipValidation: 'skipped',
statisticalValidation: 'skipped',
},
capabilityGaps: [],
warnings: [],
relationships: { accepted: 0, review: 0, rejected: 0, skipped: 0 },
enrichmentState: {
resumedStages: [],
completedStages: [],
failedStages: [],
},
createdAt: '2026-04-29T09:00:00.000Z',
};
2026-05-10 23:51:24 +02:00
const reportWithAttention: KtxScanReport = {
2026-05-10 23:12:26 +02:00
...report,
mode: 'relationships',
diffSummary: {
tablesAdded: 3,
tablesModified: 2,
tablesDeleted: 0,
tablesUnchanged: 13,
columnsAdded: 18,
columnsModified: 5,
columnsDeleted: 0,
},
capabilityGaps: ['columnStats'],
warnings: [
{
code: 'connector_capability_missing',
2026-05-10 23:51:24 +02:00
message: 'KTX scan connector is missing optional capability: columnStats',
2026-05-10 23:12:26 +02:00
recoverable: true,
metadata: { capability: 'columnStats' },
},
{
code: 'relationship_validation_failed',
message: 'Could not validate relationship orders.customer_id -> customers.id',
table: 'orders',
column: 'customer_id',
recoverable: true,
},
],
relationships: { accepted: 7, review: 3, rejected: 2, skipped: 4 },
enrichmentState: {
resumedStages: ['relationships'],
completedStages: ['descriptions', 'relationships'],
failedStages: [],
},
artifactPaths: {
...report.artifactPaths,
manifestShards: ['raw-sources/warehouse/live-database/sync-1/_schema/shard-000.json'],
enrichmentArtifacts: ['raw-sources/warehouse/live-database/sync-1/_enrichment/relationships.json'],
},
};
2026-05-10 23:51:24 +02:00
describe('runKtxScan', () => {
2026-05-10 23:12:26 +02:00
let tempDir: string;
const noLocalIngestAdapters = () => [];
2026-05-10 23:12:26 +02:00
beforeEach(async () => {
2026-05-10 23:51:24 +02:00
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-scan-'));
2026-05-10 23:12:26 +02:00
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('runs structural scans and prints a dev-friendly plain summary', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
}),
);
const io = makeIo();
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'warehouse',
mode: 'structural',
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
connector: undefined,
}),
);
2026-05-10 23:51:24 +02:00
expect(io.stdout()).toContain('KTX scan completed\n');
2026-05-10 23:12:26 +02:00
expect(io.stdout()).toContain('Run: scan-run-1');
expect(io.stdout()).toContain('Mode: structural');
expect(io.stdout()).toContain('What changed\n');
expect(io.stdout()).toContain('New tables: 1\n');
expect(io.stdout()).toContain('Changed tables: 0\n');
expect(io.stdout()).toContain('Removed tables: 0\n');
expect(io.stdout()).toContain('Unchanged tables: 0\n');
expect(io.stdout()).toContain('Needs attention\n None\n');
expect(io.stdout()).toContain('Artifacts\n');
expect(io.stdout()).toContain('Report: raw-sources/warehouse/live-database/sync-1/scan-report.json');
expect(io.stdout()).toContain('Next:\n');
expect(io.stdout()).toContain('ktx status --project-dir ');
expect(io.stdout()).not.toContain('ktx admin scan status');
expect(io.stdout()).not.toContain('ktx admin scan report');
2026-05-10 23:12:26 +02:00
expect(io.stdout()).not.toContain('\u001b[');
expect(io.stdout()).not.toContain('✓');
expect(io.stdout()).not.toContain('+1');
expect(io.stdout()).not.toContain('/~');
});
refactor(release): drop release-policy.json runtime dep and next branch (#180) * chore: standardize daemon naming on "KTX daemon" Replace inconsistent names ("KTX Python daemon", "KTX local embeddings daemon", "KTX managed daemon", "Python daemon") with the single name "KTX daemon" in CLI output, errors, command descriptions, test assertions, smoke scripts, docs, AGENTS.md, issue templates, and codecov flags. The daemon is a portable compute server with endpoints for SQL analysis, semantic layer, LookML, database introspection, and embeddings; the previous labels misrepresented it as embeddings-only or exposed implementation details ("Python", "managed"). The "KTX Python runtime" concept (installed interpreter + packages) is deliberately left as-is — it is a separate concept from the daemon process. * refactor(release): drop release-policy.json runtime dep and next branch Strips the release-policy.json fallback from release-version.ts so the CLI reads its version straight from packages/cli/package.json. dev → 0.0.0-private, installed @kaelio/ktx → the real semver baked into the published package.json. KtxCliPackageInfo collapses to { name, version, contextPackageName }; /health no longer depends on version files surviving past a CI run. Replaces the dual-branch (main + next) semantic-release model with a single- branch model on main. rcs and stables interleave on the same branch via { name: 'main', prerelease: 'rc', channel: 'next' } / ['main']. Drops @semantic-release/git and @semantic-release/changelog (nothing is committed back to the repo on any channel) and the workflow's "Prepare next prerelease branch" step plus the KTX_PRERELEASE_BRANCH plumbing. The git tag plus the published npm artifact carry the version forward. Updates docs/release.md, removes the two now-unused devDeps, regenerates pnpm-lock.yaml. 611/611 @ktx/cli tests, 173/173 script tests, type-check, biome, knip all clean. * fix(release): don't throw on non-main branches at config-load time knip loads .releaserc.cjs on every PR run, where GITHUB_REF_NAME is the merge ref (e.g. 180/merge). The previous version of releaseBranches threw immediately when the branch wasn't main, which made knip fail to evaluate the config and then mis-flag @semantic-release/exec as an unused dep. semantic-release already refuses to publish when the current branch doesn't match a configured release branch, so the explicit throw was redundant. Drop it (and the unused currentBranch helper) and replace the "rejects releases from non-main" assertion with one that exercises a CI- shaped GITHUB_REF_NAME and confirms the config loads.
2026-05-20 13:53:14 +02:00
it('passes KTX daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
await initKtxProject({ projectDir: tempDir });
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
const createLocalIngestAdapters = vi.fn(() => []);
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
}),
);
const io = makeIo();
const runtimeIo = makeIo({ isTTY: true });
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
await expect(
runKtxScan(
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
},
io.io,
{ runLocalScan, createLocalIngestAdapters, runtimeIo: runtimeIo.io } as KtxScanDeps & {
runtimeIo: typeof runtimeIo.io;
},
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
),
).resolves.toBe(0);
expect(createLocalIngestAdapters).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir }), {
managedDaemon: {
cliVersion: '0.2.0',
projectDir: tempDir,
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
installPolicy: 'auto',
io: runtimeIo.io,
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
},
});
});
2026-05-10 23:12:26 +02:00
it('explains warnings, capability gaps, and relationships in human scan summaries', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'relationships',
dryRun: false,
syncId: 'sync-1',
report: reportWithAttention,
}),
);
const io = makeIo();
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(io.stdout()).toContain('Semantic layer comparison found 5 changes across 18 tables');
expect(io.stdout()).toContain('New columns: 18');
expect(io.stdout()).toContain('Changed columns: 5');
expect(io.stdout()).toContain('Relationships\n');
expect(io.stdout()).toContain('Accepted: 7');
expect(io.stdout()).toContain('Review: 3');
expect(io.stdout()).toContain('Rejected: 2');
expect(io.stdout()).toContain('Skipped: 4');
expect(io.stdout()).toContain('Needs attention\n');
expect(io.stdout()).toContain('2 warnings');
expect(io.stdout()).toContain('1 capability gap');
expect(io.stdout()).toContain('columnStats is unavailable; relationship confidence may be lower.');
expect(io.stdout()).toContain(
'relationship_validation_failed: orders.customer_id: Could not validate relationship orders.customer_id -> customers.id',
);
expect(io.stdout()).not.toContain('+3');
expect(io.stdout()).not.toContain('~2');
expect(io.stdout()).not.toContain('=13');
});
it('prints review-only relationship summaries and validation capability warnings', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:51:24 +02:00
const reviewOnlyReport: KtxScanReport = {
2026-05-10 23:12:26 +02:00
...reportWithAttention,
capabilityGaps: [],
warnings: [
{
code: 'connector_capability_missing',
2026-05-10 23:51:24 +02:00
message: 'KTX scan connector cannot run read-only SQL relationship validation',
2026-05-10 23:12:26 +02:00
recoverable: true,
metadata: { capability: 'readOnlySql' },
},
],
relationships: { accepted: 0, review: 12, rejected: 44, skipped: 0 },
};
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-review',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'relationships',
dryRun: false,
syncId: 'sync-review',
report: reviewOnlyReport,
}),
);
const io = makeIo();
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(io.stdout()).toContain('Relationships');
expect(io.stdout()).toContain('Accepted: 0');
expect(io.stdout()).toContain('Review: 12');
expect(io.stdout()).toContain('Rejected: 44');
expect(io.stdout()).toContain(
2026-05-10 23:51:24 +02:00
'connector_capability_missing: KTX scan connector cannot run read-only SQL relationship validation',
2026-05-10 23:12:26 +02:00
);
});
it('passes a scan progress port and prints TTY progress messages', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.15, 'Inspecting database schema');
await input.progress?.update(0.55, 'Semantic layer comparison found 5 changes across 18 tables');
return {
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'relationships',
dryRun: false,
syncId: 'sync-1',
report: reportWithAttention,
};
});
const io = makeIo({ isTTY: true });
const previousCi = process.env.CI;
delete process.env.CI;
try {
2026-05-10 23:51:24 +02:00
const exitCode = await runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
);
expect({ exitCode, stderr: io.stderr() }).toEqual({ exitCode: 0, stderr: '' });
} finally {
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
}
expect(runLocalScan.mock.calls[0]?.[0].progress).toBeDefined();
expect(io.stdout()).toContain('[15%] Inspecting database schema');
expect(io.stdout()).toContain('[55%] Semantic layer comparison found 5 changes across 18 tables');
});
it('uses injected structured progress without requiring TTY progress output', async () => {
await initKtxProject({ projectDir: tempDir });
const progressEvents: Array<{ progress: number; message?: string; transient?: boolean }> = [];
const structuredProgress = {
async update(progress: number, message?: string, options?: { transient?: boolean }) {
progressEvents.push({
progress,
...(message !== undefined ? { message } : {}),
...(options?.transient !== undefined ? { transient: options.transient } : {}),
});
},
startPhase() {
return structuredProgress;
},
};
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.42, 'Generating descriptions 4/10 tables', { transient: true });
return {
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
};
});
const io = makeIo();
await expect(
runKtxScan(
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters, progress: structuredProgress },
),
).resolves.toBe(0);
expect(progressEvents).toContainEqual({
progress: 0.42,
message: 'Generating descriptions 4/10 tables',
transient: true,
});
expect(io.stdout()).not.toContain('[42%] Generating descriptions 4/10 tables');
});
2026-05-10 23:12:26 +02:00
it('updates transient TTY progress messages in place', async () => {
const io = makeIo({ isTTY: true });
const previousCi = process.env.CI;
delete process.env.CI;
try {
const progress = createCliScanProgress(io.io);
await progress.update(0.84, 'Generating descriptions 1/35 tables', { transient: true });
await progress.update(0.85, 'Generating descriptions 2/35 tables', { transient: true });
await progress.update(0.9, 'Building embeddings 1/4 batches');
} finally {
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
}
expect(io.stdout()).toContain('\r[84%] Generating descriptions 1/35 tables');
expect(io.stdout()).toContain('\r[85%] Generating descriptions 2/35 tables');
expect(io.stdout()).toContain('\n[90%] Building embeddings 1/4 batches\n');
});
it('scales nested progress phases by the parent phase weight', async () => {
const io = makeIo({ isTTY: true });
const previousCi = process.env.CI;
delete process.env.CI;
try {
const progress = createCliScanProgress(io.io);
await progress.update(0.82, 'Enriching schema metadata');
const enrichmentProgress = progress.startPhase(0.18);
await enrichmentProgress.update(0.05, 'Loaded schema snapshot with 56 tables');
const descriptionProgress = enrichmentProgress.startPhase(0.45);
await descriptionProgress.update(37 / 56, 'Generating descriptions 37/56 tables', { transient: true });
await descriptionProgress.update(1, 'Generated descriptions for 56 tables');
} finally {
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
}
expect(io.stdout()).toContain('\r[88%] Generating descriptions 37/56 tables');
expect(io.stdout()).toContain('\n[91%] Generated descriptions for 56 tables\n');
expect(io.stdout()).not.toContain('[100%] Generating descriptions 37/56 tables');
});
2026-05-10 23:12:26 +02:00
it('flushes transient TTY progress messages before printing scan failures', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.42, 'Generating descriptions 3/35 tables', { transient: true });
throw new Error('scan failed');
});
const io = makeIo({ isTTY: true });
const previousCi = process.env.CI;
delete process.env.CI;
try {
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: () => [] },
),
).resolves.toBe(1);
} finally {
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
}
expect(io.stdout()).toContain('\r[42%] Generating descriptions 3/35 tables\u001b[K\n');
expect(io.stderr()).toBe('scan failed\n');
});
it('does not print live progress messages for non-TTY output', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.15, 'Inspecting database schema');
return {
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
};
});
const io = makeIo();
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(io.stdout()).not.toContain('[15%]');
expect(io.stdout()).not.toContain('Inspecting database schema');
});
it('uses terminal-aware visual styling only for TTY output', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
}),
);
const io = makeIo({ isTTY: true });
const previousNoColor = process.env.NO_COLOR;
const previousCi = process.env.CI;
const previousTerm = process.env.TERM;
delete process.env.NO_COLOR;
delete process.env.CI;
process.env.TERM = 'xterm-256color';
try {
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
} finally {
if (previousNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = previousNoColor;
}
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
if (previousTerm === undefined) {
delete process.env.TERM;
} else {
process.env.TERM = previousTerm;
}
}
expect(io.stdout()).toContain('✓');
2026-05-10 23:51:24 +02:00
expect(io.stdout()).toContain('KTX scan completed');
2026-05-10 23:12:26 +02:00
expect(io.stdout()).toContain('\u001b[');
});
it('honors NO_COLOR for TTY scan summaries', async () => {
await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
}),
);
const io = makeIo({ isTTY: true });
const previousNoColor = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
try {
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
} finally {
if (previousNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = previousNoColor;
}
}
2026-05-10 23:51:24 +02:00
expect(io.stdout()).toContain('KTX scan completed');
2026-05-10 23:12:26 +02:00
expect(io.stdout()).not.toContain('\u001b[');
});
it('passes native CLI adapters into local scan runs for mysql configs', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: mysql',
' url: env:MYSQL_URL',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(expect.objectContaining({ adapters: expect.any(Array) }));
await rm(tempProject, { recursive: true, force: true });
});
it('creates a native connector for standalone relationship scans', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-relationships-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: sqlite',
' path: warehouse.db',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'relationships',
dryRun: false,
syncId: 'sync-1',
report: { ...report, mode: 'relationships' },
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'relationships',
detectRelationships: true,
connector: expect.objectContaining({ driver: 'sqlite' }),
}),
);
await rm(tempProject, { recursive: true, force: true });
});
it('routes standalone postgres scans through the native connector before daemon fallback', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-postgres-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: postgres',
' host: db.example.test',
' database: analytics',
' username: reader',
' password: env:POSTGRES_PASSWORD',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{
runLocalScan,
createLocalIngestAdapters: () => [fakeLiveDatabaseAdapter(createPostgresLiveDatabaseIntrospection)],
},
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(expect.objectContaining({ adapters: expect.any(Array) }));
const scanOptions = runLocalScan.mock.calls[0]?.[0];
const liveDatabase = scanOptions?.adapters?.find((adapter) => adapter.source === 'live-database');
if (!liveDatabase?.fetch) {
throw new Error('Expected scan adapters to include a fetch-capable live-database adapter');
}
const stagedDir = join(tempProject, 'postgres-staged');
await liveDatabase.fetch(undefined, stagedDir, { connectionId: 'warehouse', sourceKey: 'live-database' });
expect(createPostgresLiveDatabaseIntrospection).toHaveBeenCalledWith({ connections: expect.any(Object) });
expect(postgresExtractSchema).toHaveBeenCalledWith('warehouse');
await expect(readFile(join(stagedDir, 'connection.json'), 'utf-8')).resolves.toContain(
'"connectionId": "warehouse"',
);
await rm(tempProject, { recursive: true, force: true });
});
it('passes native CLI adapters into local scan runs for clickhouse configs', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-clickhouse-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: clickhouse',
' host: env:CLICKHOUSE_HOST',
' database: analytics',
' username: reader',
' password: env:CLICKHOUSE_PASSWORD',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report: { ...report, driver: 'clickhouse' },
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(expect.objectContaining({ adapters: expect.any(Array) }));
await rm(tempProject, { recursive: true, force: true });
});
it('passes native CLI adapters into local scan runs for sqlserver configs', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-sqlserver-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: sqlserver',
' host: env:SQLSERVER_HOST',
' database: analytics',
' username: reader',
' schema: dbo',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report: { ...report, driver: 'sqlserver' },
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{
runLocalScan,
createLocalIngestAdapters: () => [fakeLiveDatabaseAdapter(createSqlServerLiveDatabaseIntrospection)],
},
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(expect.objectContaining({ adapters: expect.any(Array) }));
const scanOptions = runLocalScan.mock.calls[0]?.[0];
const liveDatabase = scanOptions?.adapters?.find((adapter) => adapter.source === 'live-database');
if (!liveDatabase?.fetch) {
throw new Error('Expected scan adapters to include a fetch-capable live-database adapter');
}
const stagedDir = join(tempProject, 'sqlserver-staged');
await liveDatabase.fetch(undefined, stagedDir, { connectionId: 'warehouse', sourceKey: 'live-database' });
expect(createSqlServerLiveDatabaseIntrospection).toHaveBeenCalledWith({ connections: expect.any(Object) });
expect(sqlServerExtractSchema).toHaveBeenCalledWith('warehouse');
await expect(readFile(join(stagedDir, 'connection.json'), 'utf-8')).resolves.toContain(
'"connectionId": "warehouse"',
);
await rm(tempProject, { recursive: true, force: true });
});
it('passes native CLI adapters into local scan runs for bigquery configs', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-bigquery-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: bigquery',
' dataset_id: analytics',
' credentials_json: env:BIGQUERY_CREDENTIALS_JSON',
' location: US',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report: { ...report, driver: 'bigquery' },
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{
runLocalScan,
createLocalIngestAdapters: () => [fakeLiveDatabaseAdapter(createBigQueryLiveDatabaseIntrospection)],
},
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(expect.objectContaining({ adapters: expect.any(Array) }));
const scanOptions = runLocalScan.mock.calls[0]?.[0];
const liveDatabase = scanOptions?.adapters?.find((adapter) => adapter.source === 'live-database');
if (!liveDatabase?.fetch) {
throw new Error('Expected scan adapters to include a fetch-capable live-database adapter');
}
const stagedDir = join(tempProject, 'bigquery-staged');
await liveDatabase.fetch(undefined, stagedDir, { connectionId: 'warehouse', sourceKey: 'live-database' });
expect(createBigQueryLiveDatabaseIntrospection).toHaveBeenCalledWith({ connections: expect.any(Object) });
expect(bigQueryExtractSchema).toHaveBeenCalledWith('warehouse');
await expect(readFile(join(stagedDir, 'connection.json'), 'utf-8')).resolves.toContain(
'"connectionId": "warehouse"',
);
await rm(tempProject, { recursive: true, force: true });
});
it('passes native CLI adapters into local scan runs for snowflake configs', async () => {
2026-05-10 23:51:24 +02:00
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-snowflake-'));
await initKtxProject({ projectDir: tempProject });
2026-05-10 23:12:26 +02:00
await writeFile(
2026-05-10 23:51:24 +02:00
join(tempProject, 'ktx.yaml'),
2026-05-10 23:12:26 +02:00
[
'connections:',
' warehouse:',
' driver: snowflake',
' authMethod: password',
' account: env:SNOWFLAKE_ACCOUNT',
' warehouse: WH',
' database: ANALYTICS',
' schema_name: PUBLIC',
' username: reader',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report: { ...report, driver: 'snowflake' },
}),
);
await expect(
2026-05-10 23:51:24 +02:00
runKtxScan(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir: tempProject,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{
runLocalScan,
createLocalIngestAdapters: () => [fakeLiveDatabaseAdapter(createSnowflakeLiveDatabaseIntrospection)],
},
2026-05-10 23:12:26 +02:00
),
).resolves.toBe(0);
expect(runLocalScan).toHaveBeenCalledWith(expect.objectContaining({ adapters: expect.any(Array) }));
const scanOptions = runLocalScan.mock.calls[0]?.[0];
const liveDatabase = scanOptions?.adapters?.find((adapter) => adapter.source === 'live-database');
if (!liveDatabase?.fetch) {
throw new Error('Expected scan adapters to include a fetch-capable live-database adapter');
}
const stagedDir = join(tempProject, 'snowflake-staged');
await liveDatabase.fetch(undefined, stagedDir, { connectionId: 'warehouse', sourceKey: 'live-database' });
expect(createSnowflakeLiveDatabaseIntrospection).toHaveBeenCalledWith({ connections: expect.any(Object) });
expect(snowflakeExtractSchema).toHaveBeenCalledWith('warehouse');
await expect(readFile(join(stagedDir, 'connection.json'), 'utf-8')).resolves.toContain(
'"connectionId": "warehouse"',
);
await rm(tempProject, { recursive: true, force: true });
});
});