test: split cli tests from source tree (#216)

* feat(cli): define full warehouse dialect contract

* test(cli): keep dialect edge tests focused

* fix(cli): stabilize dialect contract foundation

* refactor(connectors): own read-only query preparation

* refactor(connectors): resolve dialects through registry

* refactor(connectors): keep concrete dialect classes internal

* chore(workspace): enforce dialect import boundary

* refactor(cli): resolve relationship dialect at scan boundary

* refactor(cli): use dialect display parsing for entity details

* refactor(cli): use dialect display parsing for warehouse catalog

* refactor(cli): use dialect SQL in relationship workflows

* test(cli): verify solid dialect scan workflow closure

* test: split cli tests from source tree

* refactor(cli): standardize BigQuery scope listing

* feat(sqlite): implement connector scope listing

* test(connectors): cover required table listing

* feat(cli): add warehouse driver registry

* refactor(setup): route scope discovery through driver registry

* refactor(cli): route local query execution through driver registry

* refactor(historic-sql): route dialect support through driver registry

* refactor(cli): test warehouse connections through driver registry

* fix(cli): close driver registry type export gaps

* Improve setup daemon diagnostics

* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback

Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.

* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match

The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.

Align the picker boundary with the canonical 3-level KtxTableRef:

- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
  resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
  (resolveEnabledTables already accepts the 3-part shape) and
  schemasFromEnabledTables now goes through parseDottedTableEntry so it
  recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
  reuse.

Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).

* fix(cli): allow debug telemetry under opt-out env
This commit is contained in:
Andrey Avtomonov 2026-05-26 08:49:05 +02:00 committed by GitHub
parent 924868841d
commit 56985b7e09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
548 changed files with 5048 additions and 2228 deletions

View file

@ -0,0 +1,545 @@
import { describe, expect, it } from 'vitest';
import {
buildDefaultKtxProjectConfig,
generateKtxProjectConfigJsonSchema,
parseKtxProjectConfig,
serializeKtxProjectConfig,
validateKtxProjectConfig,
} from '../../../src/context/project/config.js';
describe('KTX project config', () => {
it.each(['status', 'replay', 'run', 'watch'])('accepts former ingest subcommand name "%s" as a connection id', (connectionId) => {
expect(
parseKtxProjectConfig(`
connections:
${connectionId}:
driver: postgres
`),
).toMatchObject({
connections: {
[connectionId]: { driver: 'postgres' },
},
});
});
it('builds the default standalone project config', () => {
expect(buildDefaultKtxProjectConfig()).toEqual({
connections: {},
storage: {
state: 'sqlite',
search: 'sqlite-fts5',
git: {
auto_commit: true,
author: 'ktx <ktx@example.com>',
},
},
llm: {
provider: {
backend: 'none',
},
models: {},
},
ingest: {
adapters: [],
embeddings: {
backend: 'none',
dimensions: 8,
},
workUnits: {
stepBudget: 40,
maxConcurrency: 1,
failureMode: 'continue',
},
},
agent: {
run_research: {
enabled: false,
max_iterations: 20,
default_toolset: ['sl_query', 'wiki_search', 'sl_read_source'],
},
},
memory: {
auto_commit: true,
},
scan: {
enrichment: {
mode: 'none',
},
relationships: {
enabled: true,
llmProposals: true,
validationRequiredForManifest: true,
acceptThreshold: 0.85,
reviewThreshold: 0.55,
maxLlmTablesPerBatch: 40,
maxCandidatesPerColumn: 25,
profileSampleRows: 10000,
profileConcurrency: 4,
validationConcurrency: 4,
},
},
});
});
it('round-trips through YAML with stable defaults', () => {
const serialized = serializeKtxProjectConfig(buildDefaultKtxProjectConfig());
const parsed = parseKtxProjectConfig(serialized);
expect(serialized).not.toContain('project:');
expect(serialized).not.toContain('live-database');
expect(serialized).toContain(' embeddings:\n backend: none\n dimensions: 8');
expect(parsed.ingest.adapters).toEqual([]);
expect(parsed.ingest.embeddings).toEqual({
backend: 'none',
dimensions: 8,
});
});
it('parses and serializes setup warehouse metadata without setup progress', () => {
const config = parseKtxProjectConfig(`
setup:
database_connection_ids:
- warehouse
- analytics
connections:
warehouse:
driver: postgres
url: env:WAREHOUSE_URL
`);
expect(config.setup).toEqual({
database_connection_ids: ['warehouse', 'analytics'],
});
const serialized = serializeKtxProjectConfig(config);
expect(serialized).toContain('setup:');
expect(serialized).toContain('database_connection_ids:');
expect(serialized).not.toContain('completed_steps:');
});
it('parses global direct Anthropic LLM config', () => {
const config = parseKtxProjectConfig(`
llm:
provider:
backend: anthropic
anthropic:
api_key: env:ANTHROPIC_API_KEY
models:
default: claude-sonnet-4-6
triage: claude-haiku-4-5
repair: claude-opus-4-7
promptCaching:
enabled: false
ingest:
workUnits:
stepBudget: 30
maxConcurrency: 2
failureMode: abort
`);
expect(config.llm).toMatchObject({
provider: {
backend: 'anthropic',
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
},
models: {
default: 'claude-sonnet-4-6',
triage: 'claude-haiku-4-5',
repair: 'claude-opus-4-7',
},
promptCaching: { enabled: false },
});
expect(config.ingest.workUnits).toEqual({
stepBudget: 30,
maxConcurrency: 2,
failureMode: 'abort',
});
});
it('parses global Vertex LLM config', () => {
const config = parseKtxProjectConfig(`
llm:
provider:
backend: vertex
vertex:
project: local-gcp-project
location: us-east5
models:
default: claude-sonnet-4-6
triage: claude-haiku-4-5
`);
expect(config.llm.provider.backend).toBe('vertex');
expect(config.llm.provider.vertex).toEqual({ project: 'local-gcp-project', location: 'us-east5' });
expect(config.llm.models).toEqual({
default: 'claude-sonnet-4-6',
triage: 'claude-haiku-4-5',
});
});
it('requires a non-empty Vertex location when the Vertex provider block is present', () => {
const yaml = `
llm:
provider:
backend: vertex
vertex:
project: local-gcp-project
`;
expect(() => parseKtxProjectConfig(yaml)).toThrow(/llm\.provider\.vertex\.location/);
const validation = validateKtxProjectConfig(yaml);
expect(validation.ok).toBe(false);
expect(validation.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'llm.provider.vertex.location',
}),
]),
);
});
it('parses Claude Code as a first-class LLM backend', () => {
const config = parseKtxProjectConfig(`
llm:
provider:
backend: claude-code
models:
default: sonnet
triage: haiku
candidateExtraction: sonnet
curator: sonnet
reconcile: sonnet
repair: opus
`);
expect(config.llm.provider.backend).toBe('claude-code');
expect(config.llm.models).toEqual({
default: 'sonnet',
triage: 'haiku',
candidateExtraction: 'sonnet',
curator: 'sonnet',
reconcile: 'sonnet',
repair: 'opus',
});
});
it('parses gateway LLM, OpenAI scan embeddings, and sentence-transformers ingest embeddings', () => {
const config = parseKtxProjectConfig(`
llm:
provider:
backend: gateway
gateway:
api_key: env:AI_GATEWAY_API_KEY
base_url: https://gateway.example/v1
models:
default: anthropic/claude-sonnet-4-6
ingest:
embeddings:
backend: sentence-transformers
model: all-MiniLM-L6-v2
dimensions: 384
sentenceTransformers:
base_url: http://127.0.0.1:18081
pathPrefix: ""
batchSize: 16
scan:
enrichment:
mode: llm
embeddings:
backend: openai
model: text-embedding-3-small
dimensions: 1536
openai:
api_key: env:OPENAI_API_KEY
batchSize: 32
`);
expect(config.ingest.embeddings).toMatchObject({
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { base_url: 'http://127.0.0.1:18081', pathPrefix: '' },
batchSize: 16,
});
expect(config.llm.models.default).toBe('anthropic/claude-sonnet-4-6');
expect(config.scan.enrichment.mode).toBe('llm');
expect(config.scan.enrichment.embeddings?.dimensions).toBe(1536);
});
it('parses scan relationship settings', () => {
const config = parseKtxProjectConfig(`
scan:
relationships:
enabled: false
llmProposals: false
validationRequiredForManifest: true
acceptThreshold: 0.91
reviewThreshold: 0.61
maxLlmTablesPerBatch: 12
maxCandidatesPerColumn: 7
profileSampleRows: 500
profileConcurrency: 3
validationConcurrency: 2
validationBudget: 0
`);
expect(config.scan.relationships).toEqual({
enabled: false,
llmProposals: false,
validationRequiredForManifest: true,
acceptThreshold: 0.91,
reviewThreshold: 0.61,
maxLlmTablesPerBatch: 12,
maxCandidatesPerColumn: 7,
profileSampleRows: 500,
profileConcurrency: 3,
validationConcurrency: 2,
validationBudget: 0,
});
expect(serializeKtxProjectConfig(config)).toContain('enabled: false');
expect(serializeKtxProjectConfig(config)).toContain('llmProposals: false');
expect(serializeKtxProjectConfig(config)).toContain('validationRequiredForManifest: true');
expect(serializeKtxProjectConfig(config)).toContain('acceptThreshold: 0.91');
expect(serializeKtxProjectConfig(config)).toContain('reviewThreshold: 0.61');
expect(serializeKtxProjectConfig(config)).toContain('maxLlmTablesPerBatch: 12');
expect(serializeKtxProjectConfig(config)).toContain('maxCandidatesPerColumn: 7');
expect(serializeKtxProjectConfig(config)).toContain('profileSampleRows: 500');
expect(serializeKtxProjectConfig(config)).toContain('profileConcurrency: 3');
expect(serializeKtxProjectConfig(config)).toContain('validationConcurrency: 2');
expect(serializeKtxProjectConfig(config)).toContain('validationBudget: 0');
});
it('parses the scan relationship validation budget sentinel', () => {
const config = parseKtxProjectConfig(`
scan:
relationships:
validationBudget: all
`);
expect(config.scan.relationships.validationBudget).toBe('all');
expect(serializeKtxProjectConfig(config)).toContain('validationBudget: all');
});
it('rejects out-of-range scan relationship numeric settings', () => {
const yaml = `
scan:
relationships:
acceptThreshold: 2
reviewThreshold: -1
maxLlmTablesPerBatch: 0
maxCandidatesPerColumn: -4
profileSampleRows: 0
profileConcurrency: 0
validationConcurrency: 0
validationBudget: 1.5
`;
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.acceptThreshold/);
const validation = validateKtxProjectConfig(yaml);
expect(validation.ok).toBe(false);
const paths = validation.issues.map((issue) => issue.path);
expect(paths).toEqual(
expect.arrayContaining([
'scan.relationships.acceptThreshold',
'scan.relationships.reviewThreshold',
'scan.relationships.maxLlmTablesPerBatch',
'scan.relationships.maxCandidatesPerColumn',
'scan.relationships.profileSampleRows',
'scan.relationships.profileConcurrency',
'scan.relationships.validationConcurrency',
'scan.relationships.validationBudget',
]),
);
});
it('rejects invalid scan relationship validation budget strings', () => {
const yaml = `
scan:
relationships:
validationBudget: infinite
`;
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.validationBudget/);
});
it('rejects unsupported local LLM and embedding fields', () => {
expect(() =>
parseKtxProjectConfig(`
ingest:
llm:
backend: anthropic
`),
).toThrow('Unsupported ingest.llm: unknown field');
expect(() =>
parseKtxProjectConfig(`
scan:
enrichment:
backend: gateway
`),
).toThrow('Unsupported scan.enrichment.backend: unknown field');
expect(() =>
parseKtxProjectConfig(`
scan:
enrichment:
mode: llm
llm:
backend: gateway
`),
).toThrow('Unsupported scan.enrichment.llm: unknown field');
expect(() =>
parseKtxProjectConfig(`
ingest:
embeddings:
provider: gateway
max_batch_size: 32
`),
).toThrow('Unsupported ingest.embeddings.provider');
});
it('rejects gateway embedding configs', () => {
expect(() =>
parseKtxProjectConfig(`
ingest:
embeddings:
backend: gateway
model: provider/text-embedding
dimensions: 1536
`),
).toThrow('Unsupported ingest.embeddings.backend: gateway');
expect(() =>
parseKtxProjectConfig(`
scan:
enrichment:
mode: llm
embeddings:
backend: gateway
model: provider/text-embedding
dimensions: 1536
`),
).toThrow('Unsupported scan.enrichment.embeddings.backend: gateway');
});
it('fills optional sections when a minimal config is loaded', () => {
const config = parseKtxProjectConfig('{}\n');
expect(config).toEqual(buildDefaultKtxProjectConfig());
expect(config.ingest.embeddings).toEqual({
backend: 'none',
dimensions: 8,
});
});
it('rejects configs without an object root', () => {
expect(() => parseKtxProjectConfig('- nope\n')).toThrow('ktx.yaml must contain a YAML object');
});
it('accepts configs without a project name', () => {
expect(parseKtxProjectConfig('connections: {}\n')).toMatchObject({
connections: {},
});
});
it('rejects unknown top-level fields under strict mode', () => {
expect(() =>
parseKtxProjectConfig(`
storrage:
state: sqlite
`),
).toThrow(/Unsupported storrage/);
});
});
describe('validateKtxProjectConfig', () => {
it('returns ok: true with no issues for a valid config', () => {
const result = validateKtxProjectConfig('connections: {}\n');
expect(result).toEqual({ ok: true, issues: [] });
});
it('collects every schema issue without throwing', () => {
const result = validateKtxProjectConfig(`
storage:
search: not-a-real-backend
scan:
relationships:
acceptThreshold: 1.7
`);
expect(result.ok).toBe(false);
const paths = result.issues.map((issue) => issue.path);
expect(paths).toEqual(
expect.arrayContaining([
'storage.search',
'scan.relationships.acceptThreshold',
]),
);
});
it('reports YAML parse errors as a root-level issue', () => {
const result = validateKtxProjectConfig(': not valid yaml :\n');
expect(result.ok).toBe(false);
expect(result.issues[0]?.path).toBe('');
expect(result.issues[0]?.message).toMatch(/ktx\.yaml parse error/);
});
it('reports a YAML scalar root as a single issue', () => {
const result = validateKtxProjectConfig('- nope\n');
expect(result).toEqual({
ok: false,
issues: [{ path: '', message: 'ktx.yaml must contain a YAML object' }],
});
});
});
describe('generateKtxProjectConfigJsonSchema', () => {
const schema = generateKtxProjectConfigJsonSchema();
it('emits draft-07 metadata', () => {
expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#');
expect(schema.$id).toBe('https://ktx.dev/schemas/ktx-project-config.json');
expect(schema.title).toBe('ktx.yaml');
expect(schema.type).toBe('object');
});
it('exposes every top-level ktx.yaml section under properties', () => {
const properties = schema.properties as Record<string, unknown>;
expect(Object.keys(properties).sort()).toEqual(['agent', 'connections', 'ingest', 'llm', 'memory', 'scan', 'setup', 'storage'].sort());
});
it('does not require any top-level fields', () => {
expect(schema.required).toBeUndefined();
});
it('carries .describe() text on top-level fields', () => {
const properties = schema.properties as Record<string, { description?: string }>;
expect(properties.llm?.description).toMatch(/LLM/);
expect(properties.scan?.description).toMatch(/Schema-scan/);
});
it('propagates enum values through to nested fields', () => {
const llm = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).llm;
const provider = llm?.properties?.provider as { properties?: Record<string, unknown> };
const backend = provider?.properties?.backend as { enum?: readonly string[] };
expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code']);
const storage = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).storage;
const state = storage?.properties?.state as { enum?: readonly string[] };
expect(state?.enum).toEqual(['sqlite', 'postgres']);
});
it('carries descriptions on deeply nested leaves', () => {
const scan = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).scan;
const relationships = scan?.properties?.relationships as { properties?: Record<string, { description?: string }> };
expect(relationships?.properties?.acceptThreshold?.description).toMatch(/auto-accepted/);
});
it('emits the mappings shapes under connections', () => {
const serialized = JSON.stringify(schema);
expect(serialized).toContain('databaseMappings');
expect(serialized).toContain('connectionMappings');
expect(serialized).toContain('expectedLookerConnectionName');
});
});

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { connectionConfigSchema } from '../../../src/context/project/driver-schemas.js';
describe('connectionConfigSchema (driver discriminated union)', () => {
it.each([
['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret
['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret
['snowflake', 'snowflake://account/db'],
['bigquery', 'bigquery://project/dataset'],
['sqlite', 'sqlite:///tmp/db.sqlite'],
['clickhouse', 'clickhouse://host:8123/db'],
['sqlserver', 'sqlserver://host:1433;database=db'],
])('parses %s warehouse connection', (driver, url) => {
expect(connectionConfigSchema.parse({ driver, url })).toMatchObject({ driver, url });
});
it('preserves unknown warehouse fields via looseObject passthrough', () => {
const parsed = connectionConfigSchema.parse({
driver: 'postgres',
url: 'postgres://x',
customField: { enabled: true },
context: { queryHistory: { enabled: false } },
});
expect(parsed).toMatchObject({
driver: 'postgres',
customField: { enabled: true },
context: { queryHistory: { enabled: false } },
});
});
it('rejects an unknown driver', () => {
expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow();
});
it('rejects legacy warehouse driver aliases', () => {
expect(() => connectionConfigSchema.parse({ driver: 'postgresql', url: 'postgresql://host/db' })).toThrow();
});
});
describe('connectionConfigSchema - context source drivers with mappings', () => {
it('parses a metabase connection with mappings', () => {
const parsed = connectionConfigSchema.parse({
driver: 'metabase',
api_url: 'https://metabase.example.com',
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
mappings: {
databaseMappings: { '3': 'prod-warehouse' },
syncEnabled: { '3': true },
syncMode: 'ONLY',
},
});
expect(parsed).toMatchObject({
driver: 'metabase',
api_url: 'https://metabase.example.com',
mappings: {
databaseMappings: { '3': 'prod-warehouse' },
syncMode: 'ONLY',
},
});
});
it('parses a looker connection with connectionMappings', () => {
const parsed = connectionConfigSchema.parse({
driver: 'looker',
base_url: 'https://looker.example.com',
client_id: 'abc',
client_secret_ref: 'env:LOOKER_CLIENT_SECRET', // pragma: allowlist secret
mappings: { connectionMappings: { bigquery_prod: 'wh' } },
});
expect(parsed.mappings).toEqual({ connectionMappings: { bigquery_prod: 'wh' } });
});
it('parses a lookml connection with expectedLookerConnectionName', () => {
const parsed = connectionConfigSchema.parse({
driver: 'lookml',
repoUrl: 'https://github.com/acme/looker.git',
branch: 'main',
mappings: { expectedLookerConnectionName: 'bigquery_prod' },
});
expect(parsed.mappings).toEqual({ expectedLookerConnectionName: 'bigquery_prod' });
});
it('rejects metabase mapping with non-integer database key', () => {
expect(() =>
connectionConfigSchema.parse({
driver: 'metabase',
api_url: 'https://x',
mappings: { databaseMappings: { abc: 'wh' } },
}),
).toThrow();
});
});
describe('connectionConfigSchema - notion / dbt / metricflow', () => {
it('parses a notion connection with selected_roots crawl', () => {
const parsed = connectionConfigSchema.parse({
driver: 'notion',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
root_page_ids: ['abc', 'def'],
max_pages_per_run: 500,
});
expect(parsed).toMatchObject({
driver: 'notion',
crawl_mode: 'selected_roots',
root_page_ids: ['abc', 'def'],
max_pages_per_run: 500,
});
});
it('rejects notion with unknown crawl_mode', () => {
expect(() =>
connectionConfigSchema.parse({
driver: 'notion',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'everything',
}),
).toThrow();
});
it('parses a dbt connection from a local source_dir', () => {
const parsed = connectionConfigSchema.parse({
driver: 'dbt',
source_dir: '/tmp/dbt-project',
target: 'dev',
});
expect(parsed).toMatchObject({ driver: 'dbt', source_dir: '/tmp/dbt-project', target: 'dev' });
});
it('parses a metricflow connection with nested config', () => {
const parsed = connectionConfigSchema.parse({
driver: 'metricflow',
metricflow: {
repoUrl: 'https://github.com/acme/sl.git',
branch: 'main',
},
});
expect(parsed).toMatchObject({
driver: 'metricflow',
metricflow: { repoUrl: 'https://github.com/acme/sl.git' },
});
});
});

View file

@ -0,0 +1,102 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { GitService } from '../../../src/context/core/git.service.js';
import type { KtxCoreConfig } from '../../../src/context/core/config.js';
import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js';
describe('LocalGitFileStore', () => {
let tempDir: string;
let store: LocalGitFileStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-store-'));
const coreConfig: KtxCoreConfig = {
storage: { configDir: tempDir, homeDir: tempDir },
git: {
userName: 'ktx',
userEmail: 'ktx@example.com',
bootstrapMessage: 'Initialize test project',
bootstrapAuthor: 'ktx',
bootstrapAuthorEmail: 'ktx@example.com',
},
};
const git = new GitService(coreConfig);
await git.onModuleInit();
store = new LocalGitFileStore({ rootDir: tempDir, git });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('writes, commits, and reads a project file', async () => {
const write = await store.writeFile(
'wiki/global/revenue.md',
'# Revenue\n',
'Agent',
'agent@example.com',
'Add revenue page',
);
expect(write.commitHash).toMatch(/^[0-9a-f]{40}$/);
await expect(readFile(join(tempDir, 'wiki/global/revenue.md'), 'utf-8')).resolves.toBe('# Revenue\n');
await expect(store.readFile('wiki/global/revenue.md')).resolves.toMatchObject({
content: '# Revenue\n',
});
});
it('lists files recursively and can strip the requested prefix', async () => {
await store.writeFile('wiki/global/a.md', 'a', 'Agent', 'agent@example.com', 'Add a');
await store.writeFile('wiki/global/nested/b.md', 'b', 'Agent', 'agent@example.com', 'Add b');
await expect(store.listFiles('wiki')).resolves.toEqual({
files: ['wiki/global/a.md', 'wiki/global/nested/b.md'],
});
await expect(store.listFiles('wiki/global', true)).resolves.toEqual({
files: ['a.md', 'nested/b.md'],
});
});
it('deletes and commits an existing file', async () => {
await store.writeFile('semantic-layer/conn/orders.yaml', 'name: orders\n', 'Agent', 'agent@example.com', 'Add SL');
const deleted = await store.deleteFile(
'semantic-layer/conn/orders.yaml',
'Agent',
'agent@example.com',
'Delete SL',
);
expect(deleted?.commitHash).toMatch(/^[0-9a-f]{40}$/);
await expect(stat(join(tempDir, 'semantic-layer/conn/orders.yaml'))).rejects.toThrow();
});
it('returns null when deleting a missing file', async () => {
await expect(store.deleteFile('missing.md', 'Agent', 'agent@example.com', 'Delete missing')).resolves.toBeNull();
});
it('exposes Git history for a file', async () => {
await store.writeFile('wiki/global/history.md', 'v1', 'Agent', 'agent@example.com', 'Add history');
await store.writeFile('wiki/global/history.md', 'v2', 'Agent', 'agent@example.com', 'Update history');
const history = await store.getFileHistory('wiki/global/history.md');
expect(Array.isArray(history)).toBe(true);
expect(history[0]).toMatchObject({ message: 'Update history' });
expect(history[1]).toMatchObject({ message: 'Add history' });
});
it('rejects absolute paths and parent-directory traversal', async () => {
await expect(store.writeFile('/tmp/outside.md', 'bad', 'Agent', 'agent@example.com', 'Bad write')).rejects.toThrow(
'Path must be relative',
);
await expect(store.readFile('../outside.md')).rejects.toThrow('Path escapes the project directory');
});
it('rejects direct .git access', async () => {
await expect(store.readFile('.git/config')).rejects.toThrow('Path cannot access .git');
});
});

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
lookerMappingsSchema,
lookmlMappingsSchema,
metabaseMappingsSchema,
parseConnectionMappingBootstrap,
parseLookmlMappingBootstrap,
parseLookerMappingBootstrap,
parseMetabaseMappingBootstrap,
} from '../../../src/context/project/mappings-yaml-schema.js';
describe('ktx.yaml mapping bootstrap schema', () => {
it('parses Metabase mapping intent with CLI syncMode default ALL', () => {
const bootstrap = parseMetabaseMappingBootstrap('prod-metabase', {
driver: 'metabase',
mappings: {
databaseMappings: { '1': 'prod-warehouse', '2': null },
syncEnabled: { '1': true, '2': false },
selections: { collections: [12], items: [345] },
defaultTagNames: ['ktx', 'prod'],
},
});
expect(bootstrap).toEqual({
adapter: 'metabase',
connectionId: 'prod-metabase',
databaseMappings: { '1': 'prod-warehouse', '2': null },
syncEnabled: { '1': true, '2': false },
syncMode: 'ALL',
selections: { collections: [12], items: [345] },
defaultTagNames: ['ktx', 'prod'],
});
});
it('rejects Metabase non-integer mapping keys', () => {
expect(() =>
parseMetabaseMappingBootstrap('prod-metabase', {
driver: 'metabase',
mappings: { databaseMappings: { abc: 'warehouse' } },
}),
).toThrow(/databaseMappings key "abc" must be a positive integer string/);
});
it('parses Looker connection mapping intent', () => {
const bootstrap = parseLookerMappingBootstrap('prod-looker', {
driver: 'looker',
mappings: {
connectionMappings: {
bigquery_prod: 'prod-warehouse',
snowflake_dev: null,
},
},
});
expect(bootstrap).toEqual({
adapter: 'looker',
connectionId: 'prod-looker',
connectionMappings: {
bigquery_prod: 'prod-warehouse',
snowflake_dev: null,
},
});
});
it('parses LookML expected connection from mappings block', () => {
expect(
parseLookmlMappingBootstrap('prod-lookml', {
driver: 'lookml',
repo_url: 'https://github.com/acme/looker.git',
mappings: { expectedLookerConnectionName: 'bigquery_prod' },
}),
).toEqual({
adapter: 'lookml',
connectionId: 'prod-lookml',
expectedLookerConnectionName: 'bigquery_prod',
});
});
it('dispatches by flat driver and returns null for connections with no mappings block', () => {
expect(parseConnectionMappingBootstrap('warehouse', { driver: 'postgres', url: 'env:DATABASE_URL' })).toBeNull();
expect(
parseConnectionMappingBootstrap('prod-looker', {
driver: 'looker',
mappings: { connectionMappings: { analytics: 'prod-warehouse' } },
}),
).toMatchObject({ adapter: 'looker', connectionId: 'prod-looker' });
});
it('exports mapping shapes that parse documented examples', () => {
expect(metabaseMappingsSchema.parse({ databaseMappings: { '1': 'wh' } })).toMatchObject({
databaseMappings: { '1': 'wh' },
syncMode: 'ALL',
});
expect(lookerMappingsSchema.parse({ connectionMappings: { x: 'wh' } })).toEqual({
connectionMappings: { x: 'wh' },
});
expect(lookmlMappingsSchema.parse({ expectedLookerConnectionName: 'x' })).toEqual({
expectedLookerConnectionName: 'x',
});
});
});

View file

@ -0,0 +1,73 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { initKtxProject, loadKtxProject } from '../../../src/context/project/project.js';
describe('KTX local project runtime', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-project-runtime-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('initializes the standalone project layout and commits it', async () => {
const projectDir = join(tempDir, 'warehouse');
const result = await initKtxProject({
projectDir,
authorName: 'Agent',
authorEmail: 'agent@example.com',
});
expect(result.projectDir).toBe(projectDir);
expect(result.commitHash).toMatch(/^[0-9a-f]{40}$/);
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
const gitignore = await readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8');
expect(gitignore).toContain('cache/');
expect(gitignore).toContain('db.sqlite');
expect(gitignore).toContain('db.sqlite-*');
expect(gitignore).toContain('ingest-transcripts/');
expect(gitignore).toContain('secrets/');
expect(gitignore).toContain('setup/');
expect(gitignore).toContain('agents/');
await expect(stat(join(projectDir, 'wiki/global/.gitkeep'))).resolves.toBeDefined();
await expect(stat(join(projectDir, 'semantic-layer/.gitkeep'))).resolves.toBeDefined();
await expect(stat(join(projectDir, '_schema/.gitkeep'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(stat(join(projectDir, 'raw-sources/.gitkeep'))).resolves.toBeDefined();
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
});
it('loads an initialized project with a working file store', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });
const loaded = await loadKtxProject({ projectDir });
await loaded.fileStore.writeFile(
'wiki/global/revenue.md',
'# Revenue\n',
'Agent',
'agent@example.com',
'Add revenue page',
);
await expect(loaded.fileStore.readFile('wiki/global/revenue.md')).resolves.toMatchObject({
content: '# Revenue\n',
});
});
it('rejects reinitializing an existing project unless force is set', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });
await expect(initKtxProject({ projectDir })).rejects.toThrow('Project already contains ktx.yaml');
await expect(initKtxProject({ projectDir, force: true })).resolves.toMatchObject({
configPath: join(projectDir, 'ktx.yaml'),
});
});
});

View file

@ -0,0 +1,58 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js';
import {
markKtxSetupStateStepComplete,
mergeKtxSetupGitignoreEntries,
readKtxSetupState,
setKtxSetupDatabaseConnectionIds,
} from '../../../src/context/project/setup-config.js';
describe('KTX setup config helpers', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-state-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('marks setup steps complete in local state without duplicating existing state', async () => {
await markKtxSetupStateStepComplete(tempDir, 'project');
await markKtxSetupStateStepComplete(tempDir, 'project');
await markKtxSetupStateStepComplete(tempDir, 'llm');
await markKtxSetupStateStepComplete(tempDir, 'runtime');
await markKtxSetupStateStepComplete(tempDir, 'context');
expect(await readKtxSetupState(tempDir)).toEqual({
completed_steps: ['project', 'llm', 'runtime', 'context'],
});
await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
`${JSON.stringify({ completed_steps: ['project', 'llm', 'runtime', 'context'] }, null, 2)}\n`,
);
});
it('sets setup database connection ids without duplicates', () => {
const config = buildDefaultKtxProjectConfig();
const withDatabases = setKtxSetupDatabaseConnectionIds(config, ['warehouse', 'analytics', 'warehouse']);
expect(withDatabases.setup).toEqual({
database_connection_ids: ['warehouse', 'analytics'],
});
expect(config.setup).toBeUndefined();
});
it('merges setup-local gitignore entries without removing existing lines', () => {
expect(mergeKtxSetupGitignoreEntries('cache/\ndb.sqlite\n')).toBe(
['cache/', 'db.sqlite', 'db.sqlite-*', 'ingest-transcripts/', 'secrets/', 'setup/', 'agents/', ''].join('\n'),
);
expect(mergeKtxSetupGitignoreEntries('cache/\nsecrets/\n')).toBe(
['cache/', 'secrets/', 'db.sqlite', 'db.sqlite-*', 'ingest-transcripts/', 'setup/', 'agents/', ''].join('\n'),
);
});
});