refactor(context): validate ktx.yaml with Zod and surface issues in status (#91)

* refactor(context): validate ktx.yaml with Zod and surface issues in status

- Replace hand-rolled ktx.yaml parsing with a strict Zod schema and
  derive KtxProjectConfig types from it.
- Add validateKtxProjectConfig returning structured KtxConfigIssue[]
  with migration hints for deprecated keys (ingest.llm,
  scan.enrichment.backend, etc.).
- Wire ktx status/doctor to run validation, render schema issues in
  plain and JSON output, and add a Config row to project status.
- Update the orbit example to camelCase scan.relationships keys to
  match the schema.

* fix(context): tolerate legacy setup.completed_steps and optional driver

- Accept and drop the legacy setup.completed_steps field so existing
  ktx.yaml files migrated from older versions still load.
- Make connections.<id>.driver optional in the schema; runtime code
  already produces a clear "no driver" error at use time.

* feat(cli): add ktx status --validate to run only ktx.yaml schema validation

- New --validate flag dispatches a focused runKtxDoctor 'validate' branch
  that reads ktx.yaml, runs validateKtxProjectConfig, and skips LLM,
  connection, embedding, and query-history checks.
- Plain output prints a single Config row; JSON output emits
  {ok: true} on success or the existing invalid_config / missing_project
  shapes on failure.
This commit is contained in:
Andrey Avtomonov 2026-05-14 15:36:35 +02:00 committed by GitHub
parent 49f1e2720e
commit b3be54e3fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 783 additions and 545 deletions

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js';
import {
buildDefaultKtxProjectConfig,
parseKtxProjectConfig,
serializeKtxProjectConfig,
validateKtxProjectConfig,
} from './config.js';
describe('KTX project config', () => {
it.each(['status', 'replay', 'run', 'watch'])('accepts former ingest subcommand name "%s" as a connection id', (connectionId) => {
@ -277,8 +282,8 @@ scan:
expect(serializeKtxProjectConfig(config)).toContain('validationBudget: all');
});
it('falls back to safe scan relationship defaults for invalid numeric settings', () => {
const config = parseKtxProjectConfig(`
it('rejects out-of-range scan relationship numeric settings', () => {
const yaml = `
project: demo
scan:
relationships:
@ -289,28 +294,33 @@ scan:
profileSampleRows: 0
validationConcurrency: 0
validationBudget: 1.5
`);
`;
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.acceptThreshold/);
expect(config.scan.relationships).toMatchObject({
acceptThreshold: 0.85,
reviewThreshold: 0.55,
maxLlmTablesPerBatch: 40,
maxCandidatesPerColumn: 25,
profileSampleRows: 10000,
validationConcurrency: 4,
});
expect(config.scan.relationships).not.toHaveProperty('validationBudget');
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.validationConcurrency',
'scan.relationships.validationBudget',
]),
);
});
it('falls back for invalid scan relationship validation budget strings', () => {
const config = parseKtxProjectConfig(`
it('rejects invalid scan relationship validation budget strings', () => {
const yaml = `
project: demo
scan:
relationships:
validationBudget: infinite
`);
expect(config.scan.relationships).not.toHaveProperty('validationBudget');
`;
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.validationBudget/);
});
it('rejects unsupported local LLM and embedding fields', () => {
@ -398,4 +408,80 @@ scan:
it('rejects configs with a missing project name', () => {
expect(() => parseKtxProjectConfig('connections: {}\n')).toThrow('ktx.yaml field "project" is required');
});
it('rejects unknown top-level fields under strict mode', () => {
expect(() =>
parseKtxProjectConfig(`
project: demo
storrage:
state: sqlite
`),
).toThrow(/Unsupported storrage/);
});
});
describe('validateKtxProjectConfig', () => {
it('returns ok: true with no issues for a valid config', () => {
const result = validateKtxProjectConfig('project: warehouse\n');
expect(result).toEqual({ ok: true, issues: [] });
});
it('collects every schema issue without throwing', () => {
const result = validateKtxProjectConfig(`
project: ""
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([
'project',
'storage.search',
'scan.relationships.acceptThreshold',
]),
);
});
it('attaches migration hints for known deprecated keys', () => {
const result = validateKtxProjectConfig(`
project: demo
ingest:
llm:
backend: anthropic
scan:
enrichment:
backend: none
`);
expect(result.ok).toBe(false);
const findIssue = (path: string) => result.issues.find((issue) => issue.path === path);
expect(findIssue('ingest.llm')).toMatchObject({
message: 'Unsupported ingest.llm: use top-level llm.provider, llm.models, and ingest.workUnits',
fix: 'use top-level llm.provider, llm.models, and ingest.workUnits',
});
expect(findIssue('scan.enrichment.backend')).toMatchObject({
message: 'Unsupported scan.enrichment.backend: use scan.enrichment.mode',
fix: 'use scan.enrichment.mode',
});
});
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' }],
});
});
});