mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
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:
parent
52dd89481c
commit
38e71a94e7
8 changed files with 511 additions and 524 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue