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.
This commit is contained in:
Andrey Avtomonov 2026-05-14 15:22:46 +02:00
parent 52dd89481c
commit 38e71a94e7
8 changed files with 511 additions and 524 deletions

View file

@ -324,6 +324,95 @@ describe('runKtxDoctor', () => {
expect(parsed.projectDir).toBe(tempDir);
});
it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'storrage:',
' state: sqlite',
'ingest:',
' llm:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('Config');
expect(out).toContain('Unsupported storrage: unknown field');
expect(out).toContain('Unsupported ingest.llm: use top-level llm.provider');
expect(out).toContain('ktx.yaml');
});
it('emits structured JSON when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['project: warehouse', 'storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const parsed = JSON.parse(testIo.stdout()) as {
error: string;
projectDir: string;
issues: Array<{ path: string; message: string }>;
};
expect(parsed.error).toBe('invalid_config');
expect(parsed.projectDir).toBe(tempDir);
expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true);
});
it('shows a Config row labelled "ktx.yaml schema valid" on the happy path', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('ktx.yaml schema valid');
delete process.env.ANTHROPIC_API_KEY;
});
it('runs project checks against a valid ktx.yaml', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(

View file

@ -1,9 +1,10 @@
import { execFile } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { access, readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxConfigIssue } from '@ktx/context/project';
import type { BuildProjectStatusOptions } from './status-project.js';
const execFileAsync = promisify(execFile);
@ -450,6 +451,50 @@ function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io:
io.stdout.write(renderPlainReport(report, options));
}
export function renderInvalidConfigMessage(
projectDir: string,
issues: KtxConfigIssue[],
outputMode: KtxDoctorOutputMode,
io: KtxDoctorIo,
): void {
if (outputMode === 'json') {
io.stdout.write(
`${JSON.stringify(
{
error: 'invalid_config',
projectDir,
issues,
},
null,
2,
)}\n`,
);
return;
}
const useColor = shouldUseColor(io);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
const abbreviated = abbreviateHome(projectDir) ?? projectDir;
const lines: string[] = [];
lines.push(`${bold('KTX status')} ${dim('·')} ${abbreviated}`);
lines.push('');
lines.push(` ${status('fail', '✗')} ${bold('Config')} ktx.yaml has ${issues.length} schema issue${issues.length === 1 ? '' : 's'}`);
for (const issue of issues) {
lines.push(` ${status('fail', '✗')} ${issue.message}`);
if (issue.fix) {
lines.push(` ${dim(`${issue.fix}`)}`);
}
}
lines.push('');
lines.push(` ${dim('Fix the issues in')} ${join(abbreviated, 'ktx.yaml')} ${dim('and rerun')} ${bold('ktx status')}.`);
lines.push('');
io.stdout.write(lines.join('\n'));
}
export function renderMissingProjectMessage(
projectDir: string,
outputMode: KtxDoctorOutputMode,
@ -507,10 +552,16 @@ export async function runKtxDoctor(
renderMissingProjectMessage(args.projectDir, args.outputMode, io);
return 1;
}
const { loadKtxProject } = await import('@ktx/context/project');
const { loadKtxProject, validateKtxProjectConfig } = await import('@ktx/context/project');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const rawConfig = await readFile(configPath, 'utf-8');
const validation = validateKtxProjectConfig(rawConfig);
if (!validation.ok) {
renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io);
return 1;
}
const project = await loadKtxProject({ projectDir: args.projectDir });
const projectStatus = await buildProjectStatus(project, deps);
const projectStatus = await buildProjectStatus(project, { ...deps, configIssues: validation.issues });
const verbose = args.verbose ?? false;
const toolchainChecks = verbose ? await runSetupChecks() : undefined;
if (args.outputMode === 'json') {

View file

@ -1,4 +1,5 @@
import type {
KtxConfigIssue,
KtxLocalProject,
KtxProjectConfig,
KtxProjectConnectionConfig,
@ -56,6 +57,12 @@ interface StorageStatus {
gitAuthor: string;
}
interface ConfigStatus {
status: ProjectStatusLevel;
detail: string;
issues: KtxConfigIssue[];
}
interface WarningItem {
message: string;
fix?: string;
@ -72,6 +79,7 @@ function hasOwnField(value: Record<string, unknown>, key: string): boolean {
export interface ProjectStatus {
projectName: string;
projectDir: string;
config: ConfigStatus;
llm: LlmStatus;
embeddings: EmbeddingsStatus;
storage: StorageStatus;
@ -610,12 +618,26 @@ function buildVerdict(
export interface BuildProjectStatusOptions {
env?: NodeJS.ProcessEnv;
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
configIssues?: KtxConfigIssue[];
}
function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
const list = issues ?? [];
if (list.length === 0) {
return { status: 'ok', detail: 'ktx.yaml schema valid', issues: [] };
}
return {
status: 'warn',
detail: `${list.length} issue${list.length === 1 ? '' : 's'} in ktx.yaml`,
issues: list,
};
}
export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<ProjectStatus> {
const env = options.env ?? process.env;
const config = project.config;
const configStatus = buildConfigStatus(options.configIssues);
const llm = buildLlmStatus(config.llm, env);
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config);
@ -630,6 +652,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
return {
projectName: config.project,
projectDir: project.projectDir,
config: configStatus,
llm,
embeddings,
storage,
@ -719,6 +742,13 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
lines.push(` ${label('Embeddings')} ${embedDetail} ${sym(status.embeddings.status)} ${dim(status.embeddings.detail)}`);
lines.push(` ${label('Storage')} ${dim(`${status.storage.state} (state) · ${status.storage.search} (search)`)}`);
lines.push(` ${label('Config')} ${sym(status.config.status)} ${dim(status.config.detail)}`);
if (status.config.issues.length > 0) {
for (const issue of status.config.issues) {
lines.push(` ${color('warn', SYMBOL.warn)} ${issue.message}`);
if (issue.fix) lines.push(` ${dim(`${issue.fix}`)}`);
}
}
lines.push('');
// Connections