fix(cli): survive ktx.yaml version skew and derive repo ownership from disk (#293)

* fix(cli): survive ktx.yaml version skew and derive repo ownership from disk

Loading ktx.yaml is now tolerant of keys this ktx version does not
recognize: they are stripped from the in-memory config (the file on disk
is never rewritten) and reported by ktx status as non-blocking warnings,
while invalid values on recognized fields still fail hard. Repo
ownership is derived from observed state (a .git directory plus a root
ktx.yaml) instead of a ktx.managed git-config marker, so projects
created by any past or future ktx classify identically. initKtxProject
now runs an explicit foreign-repo pre-check and writes ktx.yaml before
initializing git, so an interrupted init leaves only recoverable
residue instead of a bare .git misread as foreign.

* style(cli): trim comment blocks to constraint-only notes

* docs(agents): require constraint-only code comments
This commit is contained in:
Andrey Avtomonov 2026-06-11 22:10:47 +02:00 committed by GitHub
parent a278d2f7d0
commit 0689d709d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 502 additions and 146 deletions

View file

@ -263,6 +263,26 @@ and route ingest, setup, memory, indexing, and docs through it. Do not add an
`auto_commit`-style switch unless the user explicitly asks for staged-only runs `auto_commit`-style switch unless the user explicitly asks for staged-only runs
and accepts the extra runtime path. and accepts the extra runtime path.
## Code Comments
Code must be self-explanatory. A comment exists only to state a constraint the
code cannot show; everything else belongs in the PR description or nowhere.
- **MUST**: Keep each comment to 1-3 lines stating only what the code cannot
show: a cross-file invariant ("error-severity issues never reach here — the
doctor exits on them first"), a required ordering ("ktx.yaml is written
before git init, so a crash cannot leave a bare `.git`"), or a library quirk
("zod reports unknown record keys as `invalid_key`").
- **MUST**: State each invariant once, at the public entry point. Do not repeat
the same guarantee across a helper, its wrapper, and the call site.
- **MUST NOT**: Write prose comment blocks — design rationale, alternatives
considered, change narration ("is now written before…"), caller enumerations
("shared by X, Y, and Z"), or restatements of what the code already shows.
That is the author addressing the reviewer, and it rots once merged.
- **MAY**: Open a regression test with a 1-3 line comment stating the scenario
it guards when the test name cannot carry it. Omit design history and
references to removed designs.
## TypeScript Standards ## TypeScript Standards
- Use Node 22+ and pnpm workspace commands. - Use Node 22+ and pnpm workspace commands.

View file

@ -94,6 +94,6 @@ stats, and are always shown (they do not require external communication).
|-------|-------|----------| |-------|-------|----------|
| No **ktx** project found | Current directory has no `ktx.yaml` and `KTX_PROJECT_DIR` is unset | `ktx status` runs setup checks; run from a **ktx** project or set `KTX_PROJECT_DIR` for project checks | | No **ktx** project found | Current directory has no `ktx.yaml` and `KTX_PROJECT_DIR` is unset | `ktx status` runs setup checks; run from a **ktx** project or set `KTX_PROJECT_DIR` for project checks |
| Project config check fails | The project directory is missing or has an invalid `ktx.yaml` | Run `ktx setup` to resume setup | | Project config check fails | The project directory is missing or has an invalid `ktx.yaml` | Run `ktx setup` to resume setup |
| Schema validation fails | `ktx.yaml` does not match the current config schema | Run `ktx status --validate --json` for structured issue details, then edit `ktx.yaml` or rerun `ktx setup` | | Schema validation fails | A field **ktx** recognizes has an invalid value. Unrecognized keys are reported as non-blocking warnings (exit `0`), not failures | Run `ktx status --validate --json` for structured issue details, then edit `ktx.yaml` or rerun `ktx setup` |
| Semantic search check warns | Embeddings are not configured or the provider probe failed | Run `ktx setup` or inspect the check's `fix` field in JSON output | | Semantic search check warns | Embeddings are not configured or the provider probe failed | Run `ktx setup` or inspect the check's `fix` field in JSON output |
| Query history check warns | A database has query history enabled but the warehouse prerequisites are missing | Fix the warehouse extension, grants, or history access, then rerun `ktx status` | | Query history check warns | A database has query history enabled but the warehouse prerequisites are missing | Fix the warehouse extension, grants, or history access, then rerun `ktx status` |

View file

@ -666,11 +666,21 @@ agent:
## Validating your config ## Validating your config
**ktx** validates `ktx.yaml` strictly: unknown keys at the top level or inside **ktx** validates `ktx.yaml` when it loads, and treats two kinds of problems
strict blocks cause setup and CLI commands to fail with a precise path differently:
(`scan.relationships.acceptThreshhold: Unrecognized key`). Warehouse
connections accept extra driver-specific fields, so passthrough values like - **An invalid value on a field ktx recognizes** (for example
`historicSql` and `context.queryHistory` are allowed. `llm.provider.backend: nope`) is a hard error. Setup and CLI commands stop and
report the exact path so you can fix it.
- **An unrecognized key** — one left over from a different **ktx** version, or a
typo such as `scan.relationships.acceptThreshhold` — is tolerated, not fatal.
**ktx** ignores the key and keeps running, so a misspelled field quietly falls
back to its default instead of taking effect. `ktx status` lists each ignored
key as a warning (and exits `0`) so you can remove or correct it when
convenient.
Warehouse connections accept extra driver-specific fields, so passthrough values
like `historicSql` and `context.queryHistory` are allowed.
To re-validate without running anything else: To re-validate without running anything else:

View file

@ -27,11 +27,9 @@ export interface WorktreeEntry {
head: string | null; head: string | null;
} }
const KTX_MANAGED_GIT_CONFIG_KEY = 'ktx.managed';
export type KtxRepoOwnership = 'unowned' | 'ktx-managed' | 'foreign'; export type KtxRepoOwnership = 'unowned' | 'ktx-managed' | 'foreign';
class KtxForeignGitRepositoryError extends Error { export class KtxForeignGitRepositoryError extends Error {
constructor(configDir: string) { constructor(configDir: string) {
super( super(
`${configDir} is already a git repository that ktx did not create. ` + `${configDir} is already a git repository that ktx did not create. ` +
@ -46,21 +44,16 @@ function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException {
} }
/** /**
* Classify whether ktx may own a git repository rooted exactly at `dir`. * Classify whether ktx may own a git repository rooted exactly at `dir`. A root
* `ktx.yaml` is the ownership signal; the working tree decides, not git history,
* because older ktx versions left `ktx.yaml` uncommitted (it holds secret refs).
* *
* - `unowned`: there is no git repository for ktx to avoid here ktx may * - `unowned`: no repo here (including a missing or non-directory path) ktx may `git init`.
* `git init`. Covers a fresh standalone directory, a fresh directory nested * - `ktx-managed`: `<dir>/.git` is a directory and `ktx.yaml` sits at the root.
* inside a parent repo, a path that does not exist yet, and a path that is not * - `foreign`: any other repo no root `ktx.yaml`, or a `.git` *file* (a linked
* a directory at all (whether the path is a usable project directory is a * worktree). ktx must never adopt or mutate it.
* separate concern for the caller to validate).
* - `ktx-managed`: `<dir>/.git` is a directory carrying ktx's ownership marker.
* - `foreign`: a repo ktx did not create a `.git` directory without the marker,
* or a `.git` *file* (a linked worktree). ktx must never adopt or mutate it.
* *
* Reads only `<dir>/.git` directly and never walks up the directory tree, so the * Reads only `<dir>` itself; never walks up, so a parent repo cannot change the answer.
* classification of a project dir never depends on whether a parent repo exists.
* Shared by `GitService.initialize()` (the invariant) and the setup wizard (the
* pre-flight guidance) so both decide ownership from the same rule.
*/ */
export async function classifyKtxRepoOwnership(dir: string): Promise<KtxRepoOwnership> { export async function classifyKtxRepoOwnership(dir: string): Promise<KtxRepoOwnership> {
let dotGitIsDirectory: boolean; let dotGitIsDirectory: boolean;
@ -78,13 +71,9 @@ export async function classifyKtxRepoOwnership(dir: string): Promise<KtxRepoOwne
return 'foreign'; return 'foreign';
} }
try { try {
const marker = await createSimpleGit(dir).raw([ // stat (not lstat): follow symlinks, matching what `loadKtxProject`'s
'config', // readFile accepts — a dir that loads as a ktx project classifies as one.
'--local', return (await fs.stat(join(dir, 'ktx.yaml'))).isFile() ? 'ktx-managed' : 'foreign';
'--get',
KTX_MANAGED_GIT_CONFIG_KEY,
]);
return marker.trim() === 'true' ? 'ktx-managed' : 'foreign';
} catch { } catch {
return 'foreign'; return 'foreign';
} }
@ -168,7 +157,6 @@ export class GitService {
} }
if (ownership === 'unowned') { if (ownership === 'unowned') {
await this.git.init(); await this.git.init();
await this.git.addConfig(KTX_MANAGED_GIT_CONFIG_KEY, 'true');
this.logger.log('Initialized ktx-managed git repository'); this.logger.log('Initialized ktx-managed git repository');
} }
// ownership === 'ktx-managed' → ktx's own repo; proceed with the normal re-run path. // ownership === 'ktx-managed' → ktx's own repo; proceed with the normal re-run path.

View file

@ -301,6 +301,11 @@ export interface KtxConfigIssue {
path: string; path: string;
message: string; message: string;
fix?: string; fix?: string;
/**
* 'error' blocks the project (bad value on a recognized field); 'warning' is
* a condition the loader recovers from on its own (an ignored unknown key).
*/
severity: 'error' | 'warning';
} }
export interface KtxConfigValidation { export interface KtxConfigValidation {
@ -325,24 +330,61 @@ function valueAtPath(root: unknown, path: ReadonlyArray<PropertyKey>): unknown {
return cursor; return cursor;
} }
function formatIssue(issue: z.core.$ZodIssue, input: unknown): KtxConfigIssue[] { interface UnknownKeyLocation {
const basePath = dottedPath(issue.path); containerPath: ReadonlyArray<PropertyKey>;
key: string;
}
/**
* Zod reports unknown keys in two shapes: strict objects emit
* `unrecognized_keys` (path container, `keys` offenders), enum-keyed
* records (`llm.models`) emit one `invalid_key` per offender (path ends with
* the key). Normalize both so the warning report and the strip always agree.
*/
function unknownKeyLocations(issue: z.core.$ZodIssue): UnknownKeyLocation[] {
if (issue.code === 'unrecognized_keys') { if (issue.code === 'unrecognized_keys') {
const keys = (issue as { keys?: readonly string[] }).keys ?? []; return issue.keys.map((key) => ({ containerPath: issue.path, key }));
return keys.map((key) => { }
const fullPath = basePath.length > 0 ? `${basePath}.${key}` : key; if (issue.code === 'invalid_key' && issue.path.length > 0) {
return { path: fullPath, message: `Unsupported ${fullPath}: unknown field` }; return [
{
containerPath: issue.path.slice(0, -1),
key: String(issue.path[issue.path.length - 1]),
},
];
}
return [];
}
function formatIssue(issue: z.core.$ZodIssue, input: unknown): KtxConfigIssue[] {
const unknownKeys = unknownKeyLocations(issue);
if (unknownKeys.length > 0) {
return unknownKeys.map(({ containerPath, key }) => {
const base = dottedPath(containerPath);
const fullPath = base.length > 0 ? `${base}.${key}` : key;
return {
path: fullPath,
message: `Unsupported ${fullPath}: unknown field (ignored)`,
fix: 'Unknown to this ktx version; it is ignored. Delete it from ktx.yaml when convenient.',
severity: 'warning',
};
}); });
} }
const basePath = dottedPath(issue.path);
const lastSegment = issue.path[issue.path.length - 1]; const lastSegment = issue.path[issue.path.length - 1];
if (lastSegment === 'backend' && (issue.code === 'invalid_value' || issue.code === 'invalid_type')) { if (lastSegment === 'backend' && (issue.code === 'invalid_value' || issue.code === 'invalid_type')) {
const value = valueAtPath(input, issue.path); const value = valueAtPath(input, issue.path);
return [{ path: basePath, message: `Unsupported ${basePath}: ${String(value)}` }]; return [{ path: basePath, message: `Unsupported ${basePath}: ${String(value)}`, severity: 'error' }];
} }
return [{ path: basePath, message: basePath.length > 0 ? `${basePath}: ${issue.message}` : issue.message }]; return [
{
path: basePath,
message: basePath.length > 0 ? `${basePath}: ${issue.message}` : issue.message,
severity: 'error',
},
];
} }
function collectIssues(error: z.ZodError, input: unknown): KtxConfigIssue[] { function collectIssues(error: z.ZodError, input: unknown): KtxConfigIssue[] {
@ -359,16 +401,45 @@ export function buildDefaultKtxProjectConfig(): KtxProjectConfig {
return ktxProjectConfigSchema.parse({}); return ktxProjectConfigSchema.parse({});
} }
function stripUnrecognizedKeys(input: Record<string, unknown>): Record<string, unknown> {
const result = ktxProjectConfigSchema.safeParse(input);
if (result.success) {
return input;
}
const unknownKeys = result.error.issues.flatMap(unknownKeyLocations);
if (unknownKeys.length === 0) {
return input;
}
const value = structuredClone(input);
for (const { containerPath, key } of unknownKeys) {
const container = valueAtPath(value, containerPath);
if (container === null || typeof container !== 'object') continue;
delete (container as Record<string, unknown>)[key];
}
return value;
}
function parseTolerant(input: Record<string, unknown>): KtxProjectConfig {
const value = stripUnrecognizedKeys(input);
const result = ktxProjectConfigSchema.safeParse(value);
if (!result.success) {
throw new Error(formatZodError(result.error, value));
}
return result.data;
}
/**
* Parse and validate a ktx.yaml document. Keys this ktx version does not
* recognize are stripped from the returned config never from the file, which
* a load must not rewrite so a config written by a different ktx version
* still loads. Malformed values on recognized fields still throw.
*/
export function parseKtxProjectConfig(raw: string): KtxProjectConfig { export function parseKtxProjectConfig(raw: string): KtxProjectConfig {
const parsed = YAML.parse(raw) as unknown; const parsed = YAML.parse(raw) as unknown;
if (!isRecord(parsed)) { if (!isRecord(parsed)) {
throw new Error('ktx.yaml must contain a YAML object'); throw new Error('ktx.yaml must contain a YAML object');
} }
const result = ktxProjectConfigSchema.safeParse(parsed); return parseTolerant(parsed);
if (!result.success) {
throw new Error(formatZodError(result.error, parsed));
}
return result.data;
} }
export function validateKtxProjectConfig(raw: string): KtxConfigValidation { export function validateKtxProjectConfig(raw: string): KtxConfigValidation {
@ -377,16 +448,18 @@ export function validateKtxProjectConfig(raw: string): KtxConfigValidation {
parsed = YAML.parse(raw); parsed = YAML.parse(raw);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return { ok: false, issues: [{ path: '', message: `ktx.yaml parse error: ${message}` }] }; return { ok: false, issues: [{ path: '', message: `ktx.yaml parse error: ${message}`, severity: 'error' }] };
} }
if (!isRecord(parsed)) { if (!isRecord(parsed)) {
return { ok: false, issues: [{ path: '', message: 'ktx.yaml must contain a YAML object' }] }; return { ok: false, issues: [{ path: '', message: 'ktx.yaml must contain a YAML object', severity: 'error' }] };
} }
const result = ktxProjectConfigSchema.safeParse(parsed); const result = ktxProjectConfigSchema.safeParse(parsed);
if (result.success) { if (result.success) {
return { ok: true, issues: [] }; return { ok: true, issues: [] };
} }
return { ok: false, issues: collectIssues(result.error, parsed) }; const issues = collectIssues(result.error, parsed);
const ok = !issues.some((issue) => issue.severity === 'error');
return { ok, issues };
} }
export function generateKtxProjectConfigJsonSchema(): Record<string, unknown> { export function generateKtxProjectConfigJsonSchema(): Record<string, unknown> {

View file

@ -1,6 +1,6 @@
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path'; import { basename, dirname, join, resolve } from 'node:path';
import { GitService } from '../../context/core/git.service.js'; import { classifyKtxRepoOwnership, GitService, KtxForeignGitRepositoryError } from '../../context/core/git.service.js';
import { type KtxCoreConfig, type KtxLogger, noopLogger } from '../../context/core/config.js'; import { type KtxCoreConfig, type KtxLogger, noopLogger } from '../../context/core/config.js';
import type { KtxProjectConfig } from './config.js'; import type { KtxProjectConfig } from './config.js';
import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js'; import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js';
@ -112,14 +112,24 @@ export async function initKtxProject(options: InitKtxProjectOptions): Promise<In
throw new Error(`Project already contains ktx.yaml: ${configPath}`); throw new Error(`Project already contains ktx.yaml: ${configPath}`);
} }
const config = buildDefaultKtxProjectConfig(); // Must run before ktx.yaml is written: once that file exists the directory
const runtime = await createRuntime(projectDir, config, authorName, authorEmail, logger); // classifies as ktx-managed, so a foreign repo would be silently adopted.
if ((await classifyKtxRepoOwnership(projectDir)) === 'foreign') {
throw new KtxForeignGitRepositoryError(projectDir);
}
await writeProjectFile(projectDir, 'ktx.yaml', serializeKtxProjectConfig(config)); const config = buildDefaultKtxProjectConfig();
// ktx.yaml (the ownership signal) is written before git init, so an
// interrupted init can never leave a bare `.git` without it — residue that
// would classify as a foreign repo and be unrecoverable.
await fs.mkdir(join(projectDir, '.ktx/cache'), { recursive: true }); await fs.mkdir(join(projectDir, '.ktx/cache'), { recursive: true });
for (const file of TRACKED_SCAFFOLD_FILES) { for (const file of TRACKED_SCAFFOLD_FILES) {
await writeProjectFile(projectDir, file.path, file.content); await writeProjectFile(projectDir, file.path, file.content);
} }
await writeProjectFile(projectDir, 'ktx.yaml', serializeKtxProjectConfig(config));
const runtime = await createRuntime(projectDir, config, authorName, authorEmail, logger);
const commit = await runtime.git.commitFiles( const commit = await runtime.git.commitFiles(
['ktx.yaml', ...TRACKED_SCAFFOLD_FILES.map((file) => file.path)], ['ktx.yaml', ...TRACKED_SCAFFOLD_FILES.map((file) => file.path)],

View file

@ -481,12 +481,18 @@ export function renderInvalidConfigMessage(
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text); const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
const abbreviated = abbreviateHome(projectDir) ?? projectDir; const abbreviated = abbreviateHome(projectDir) ?? projectDir;
const errorCount = issues.filter((issue) => issue.severity === 'error').length;
const warningCount = issues.length - errorCount;
const lines: string[] = []; const lines: string[] = [];
lines.push(`${bold('ktx status')} ${dim('·')} ${abbreviated}`); lines.push(`${bold('ktx status')} ${dim('·')} ${abbreviated}`);
lines.push(''); lines.push('');
lines.push(` ${status('fail', '✗')} ${bold('Config')} ktx.yaml has ${issues.length} schema issue${issues.length === 1 ? '' : 's'}`); lines.push(
` ${status('fail', '✗')} ${bold('Config')} ktx.yaml has ${errorCount} schema issue${errorCount === 1 ? '' : 's'}${warningCount > 0 ? ` · ${warningCount} ignored field${warningCount === 1 ? '' : 's'}` : ''}`,
);
for (const issue of issues) { for (const issue of issues) {
lines.push(` ${status('fail', '✗')} ${issue.message}`); const glyph = issue.severity === 'error' ? status('fail', '✗') : status('warn', '⚠');
lines.push(` ${glyph} ${issue.message}`);
if (issue.fix) { if (issue.fix) {
lines.push(` ${dim(`${issue.fix}`)}`); lines.push(` ${dim(`${issue.fix}`)}`);
} }
@ -502,6 +508,7 @@ export function renderValidConfigMessage(
projectDir: string, projectDir: string,
outputMode: KtxDoctorOutputMode, outputMode: KtxDoctorOutputMode,
io: KtxDoctorIo, io: KtxDoctorIo,
warnings: KtxConfigIssue[] = [],
): void { ): void {
if (outputMode === 'json') { if (outputMode === 'json') {
io.stdout.write( io.stdout.write(
@ -509,6 +516,7 @@ export function renderValidConfigMessage(
{ {
ok: true, ok: true,
projectDir, projectDir,
...(warnings.length > 0 ? { warnings } : {}),
}, },
null, null,
2, 2,
@ -526,7 +534,19 @@ export function renderValidConfigMessage(
const lines: string[] = []; const lines: string[] = [];
lines.push(`${bold('ktx status')} ${dim('·')} ${abbreviated}`); lines.push(`${bold('ktx status')} ${dim('·')} ${abbreviated}`);
lines.push(''); lines.push('');
lines.push(` ${status('pass', '✓')} ${bold('Config')} ${dim('ktx.yaml schema valid')}`); if (warnings.length > 0) {
lines.push(
` ${status('warn', '⚠')} ${bold('Config')} ktx.yaml schema valid · ${warnings.length} ignored field${warnings.length === 1 ? '' : 's'}`,
);
for (const warning of warnings) {
lines.push(` ${status('warn', '⚠')} ${warning.message}`);
if (warning.fix) {
lines.push(` ${dim(`${warning.fix}`)}`);
}
}
} else {
lines.push(` ${status('pass', '✓')} ${bold('Config')} ${dim('ktx.yaml schema valid')}`);
}
lines.push(''); lines.push('');
io.stdout.write(lines.join('\n')); io.stdout.write(lines.join('\n'));
@ -589,14 +609,14 @@ export async function runKtxDoctor(
renderMissingProjectMessage(args.projectDir, args.outputMode, io); renderMissingProjectMessage(args.projectDir, args.outputMode, io);
return 1; return 1;
} }
const { validateKtxProjectConfig } = await import('./context/project/config.js');; const { validateKtxProjectConfig } = await import('./context/project/config.js');
const rawConfig = await readFile(configPath, 'utf-8'); const rawConfig = await readFile(configPath, 'utf-8');
const validation = validateKtxProjectConfig(rawConfig); const validation = validateKtxProjectConfig(rawConfig);
if (!validation.ok) { if (!validation.ok) {
renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io); renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io);
return 1; return 1;
} }
renderValidConfigMessage(args.projectDir, args.outputMode, io); renderValidConfigMessage(args.projectDir, args.outputMode, io, validation.issues);
return 0; return 0;
} }
@ -607,7 +627,7 @@ export async function runKtxDoctor(
return 1; return 1;
} }
const { loadKtxProject } = await import('./context/project/project.js'); const { loadKtxProject } = await import('./context/project/project.js');
const { validateKtxProjectConfig } = await import('./context/project/config.js');; const { validateKtxProjectConfig } = await import('./context/project/config.js');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js'); const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const rawConfig = await readFile(configPath, 'utf-8'); const rawConfig = await readFile(configPath, 'utf-8');
const validation = validateKtxProjectConfig(rawConfig); const validation = validateKtxProjectConfig(rawConfig);

View file

@ -721,9 +721,10 @@ function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
if (list.length === 0) { if (list.length === 0) {
return { status: 'ok', detail: 'ktx.yaml schema valid', issues: [] }; return { status: 'ok', detail: 'ktx.yaml schema valid', issues: [] };
} }
// Error-severity issues never reach here — the doctor exits on them first.
return { return {
status: 'warn', status: 'warn',
detail: `${list.length} issue${list.length === 1 ? '' : 's'} in ktx.yaml`, detail: `ktx.yaml schema valid · ${list.length} ignored field${list.length === 1 ? '' : 's'}`,
issues: list, issues: list,
}; };
} }

View file

@ -6,10 +6,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { KtxCoreConfig } from '../../../src/context/core/config.js'; import type { KtxCoreConfig } from '../../../src/context/core/config.js';
import { GitService } from '../../../src/context/core/git.service.js'; import { GitService } from '../../../src/context/core/git.service.js';
// Regression for bootstrapping a marked ktx repo on a machine with no configured // Regression for bootstrapping a ktx-owned repo on a machine with no configured
// git identity. A foreign pre-existing repo is rejected by the ownership rule; // git identity. A foreign pre-existing repo is rejected by the ownership rule;
// this test covers the still-valid path where the repo is already ktx-managed // this test covers the still-valid path where the repo is already ktx's own
// but has no HEAD yet. // (root ktx.yaml present) but has no HEAD yet.
describe('GitService.initialize without a configured git identity', () => { describe('GitService.initialize without a configured git identity', () => {
let repoDir: string; let repoDir: string;
let homeDir: string; let homeDir: string;
@ -58,11 +58,7 @@ describe('GitService.initialize without a configured git identity', () => {
} }
execFileSync('git', ['init'], { cwd: repoDir, env: process.env, stdio: 'ignore' }); execFileSync('git', ['init'], { cwd: repoDir, env: process.env, stdio: 'ignore' });
execFileSync('git', ['config', '--local', 'ktx.managed', 'true'], { await writeFile(join(repoDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
cwd: repoDir,
env: process.env,
stdio: 'ignore',
});
}); });
afterEach(async () => { afterEach(async () => {

View file

@ -56,10 +56,11 @@ describe('GitService repository ownership', () => {
git(parentDir, ['commit', '-m', 'parent baseline']); git(parentDir, ['commit', '-m', 'parent baseline']);
const parentHeadBefore = git(parentDir, ['rev-parse', 'HEAD']); const parentHeadBefore = git(parentDir, ['rev-parse', 'HEAD']);
await writeFile(join(projectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const service = new GitService(coreConfig(projectDir)); const service = new GitService(coreConfig(projectDir));
await service.onModuleInit(); await service.onModuleInit();
expect(git(projectDir, ['config', '--local', '--get', 'ktx.managed'])).toBe('true'); expect(await classifyKtxRepoOwnership(projectDir)).toBe('ktx-managed');
expect(git(parentDir, ['rev-parse', 'HEAD'])).toBe(parentHeadBefore); expect(git(parentDir, ['rev-parse', 'HEAD'])).toBe(parentHeadBefore);
expect(await realpath(git(projectDir, ['rev-parse', '--show-toplevel']))).toBe(await realpath(projectDir)); expect(await realpath(git(projectDir, ['rev-parse', '--show-toplevel']))).toBe(await realpath(projectDir));
@ -83,18 +84,22 @@ describe('GitService repository ownership', () => {
expect(await readFile(join(projectDir, '.git', 'config'), 'utf-8')).toBe(configBefore); expect(await readFile(join(projectDir, '.git', 'config'), 'utf-8')).toBe(configBefore);
}); });
it('rejects a gitfile at the project dir as foreign', async () => { it('rejects a gitfile at the project dir as foreign even when a ktx.yaml sits beside it', async () => {
// A linked worktree is never ktx's own repo, whatever files live in it.
const projectDir = join(tempDir, 'linked-worktree'); const projectDir = join(tempDir, 'linked-worktree');
await mkdir(projectDir, { recursive: true }); await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, '.git'), 'gitdir: ../actual.git\n', 'utf-8'); await writeFile(join(projectDir, '.git'), 'gitdir: ../actual.git\n', 'utf-8');
await writeFile(join(projectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const service = new GitService(coreConfig(projectDir)); const service = new GitService(coreConfig(projectDir));
await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/); await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/);
}); });
it('accepts a marked ktx repo and does not create a second bootstrap commit', async () => { it('re-initializes an existing ktx project repo without a second bootstrap commit', async () => {
const projectDir = join(tempDir, 'owned'); const projectDir = join(tempDir, 'owned');
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const service = new GitService(coreConfig(projectDir)); const service = new GitService(coreConfig(projectDir));
await service.onModuleInit(); await service.onModuleInit();
const before = await service.revParseHead(); const before = await service.revParseHead();
@ -103,7 +108,37 @@ describe('GitService repository ownership', () => {
await second.onModuleInit(); await second.onModuleInit();
expect(await second.revParseHead()).toBe(before); expect(await second.revParseHead()).toBe(before);
expect(git(projectDir, ['config', '--local', '--get', 'ktx.managed'])).toBe('true'); });
it('accepts a project created by an older ktx: repo history plus an untracked root ktx.yaml', async () => {
// Older projects have ktx commit history and an uncommitted root ktx.yaml
// (it holds secret refs); the on-disk file is still the ownership signal.
const projectDir = join(tempDir, 'legacy');
await mkdir(join(projectDir, '.ktx'), { recursive: true });
git(projectDir, ['init']);
await writeFile(join(projectDir, '.ktx', '.gitignore'), 'secrets/\n', 'utf-8');
git(projectDir, ['add', '.ktx/.gitignore']);
git(projectDir, ['commit', '-m', 'Initialize KTX project: legacy']);
await writeFile(join(projectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const headBefore = git(projectDir, ['rev-parse', 'HEAD']);
const service = new GitService(coreConfig(projectDir));
await expect(service.onModuleInit()).resolves.toBeUndefined();
expect(await service.revParseHead()).toBe(headBefore);
expect(git(projectDir, ['status', '--short'])).toContain('?? ktx.yaml');
});
it('still rejects a user repo with history but no root ktx.yaml', async () => {
const projectDir = join(tempDir, 'app-repo');
await mkdir(projectDir, { recursive: true });
git(projectDir, ['init']);
await writeFile(join(projectDir, 'README.md'), '# App\n', 'utf-8');
git(projectDir, ['add', 'README.md']);
git(projectDir, ['commit', '-m', 'app baseline']);
const service = new GitService(coreConfig(projectDir));
await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/);
}); });
}); });
@ -132,9 +167,11 @@ describe('classifyKtxRepoOwnership', () => {
expect(await classifyKtxRepoOwnership(nestedDir)).toBe('unowned'); expect(await classifyKtxRepoOwnership(nestedDir)).toBe('unowned');
}); });
it('reports ktx-managed for a repo ktx initialized', async () => { it('reports ktx-managed for a repo with a root ktx.yaml (even untracked)', async () => {
const dir = join(tempDir, 'owned'); const dir = join(tempDir, 'owned');
await new GitService(coreConfig(dir)).onModuleInit(); await mkdir(dir, { recursive: true });
git(dir, ['init']);
await writeFile(join(dir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
expect(await classifyKtxRepoOwnership(dir)).toBe('ktx-managed'); expect(await classifyKtxRepoOwnership(dir)).toBe('ktx-managed');
}); });
@ -145,6 +182,16 @@ describe('classifyKtxRepoOwnership', () => {
expect(await classifyKtxRepoOwnership(dir)).toBe('foreign'); expect(await classifyKtxRepoOwnership(dir)).toBe('foreign');
}); });
it('reports foreign for a non-ktx repo that has commits but no ktx.yaml', async () => {
const dir = join(tempDir, 'app');
await mkdir(dir, { recursive: true });
git(dir, ['init']);
await writeFile(join(dir, 'README.md'), '# App\n', 'utf-8');
git(dir, ['add', 'README.md']);
git(dir, ['commit', '-m', 'baseline']);
expect(await classifyKtxRepoOwnership(dir)).toBe('foreign');
});
it('reports foreign for a .git file (linked worktree)', async () => { it('reports foreign for a .git file (linked worktree)', async () => {
const dir = join(tempDir, 'linked'); const dir = join(tempDir, 'linked');
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });

View file

@ -1,4 +1,3 @@
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@ -27,8 +26,12 @@ describe('GitService', () => {
}, },
}; };
// Mirror production: initKtxProject writes ktx.yaml before the git repo is
// initialized (the root ktx.yaml is the ownership signal) and commits it.
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
service = new GitService(coreConfig); service = new GitService(coreConfig);
await service.onModuleInit(); await service.onModuleInit();
await service.commitFile('ktx.yaml', 'Initialize KTX project', 'Test', 'test@example.com');
}); });
afterEach(async () => { afterEach(async () => {
@ -61,14 +64,9 @@ describe('GitService', () => {
describe('cold-start bootstrap commit', () => { describe('cold-start bootstrap commit', () => {
it('writes an empty commit on init so HEAD always resolves', async () => { it('writes an empty commit on init so HEAD always resolves', async () => {
// beforeEach already ran onModuleInit() against an empty temp dir. // beforeEach already ran onModuleInit() against a fresh temp dir.
const head = await service.revParseHead(); const head = await service.revParseHead();
expect(head).toMatch(/^[0-9a-f]{40}$/); expect(head).toMatch(/^[0-9a-f]{40}$/);
const marker = execFileSync('git', ['config', '--local', '--get', 'ktx.managed'], {
cwd: tempDir,
encoding: 'utf-8',
}).trim();
expect(marker).toBe('true');
}); });
it('does not double-commit when re-initialized', async () => { it('does not double-commit when re-initialized', async () => {

View file

@ -91,25 +91,61 @@ connections:
}); });
}); });
it('rejects removed auto-commit config keys', () => { it('tolerates unrecognized keys left over from older ktx versions', () => {
expect(() => // A project written by an older ktx still carries fields that newer ktx
parseKtxProjectConfig(` // removed (storage.git.auto_commit, the top-level memory block). Loading
// must not brick every command — the keys are dropped, not rejected.
const config = parseKtxProjectConfig(`
storage: storage:
git: git:
${removedAutoCommitKey}: false ${removedAutoCommitKey}: false
`),
).toThrow(new RegExp(`storage\\.git\\.${removedAutoCommitKey}`));
expect(() =>
parseKtxProjectConfig(`
memory: memory:
${removedAutoCommitKey}: false ${removedAutoCommitKey}: false
`), `);
).toThrow(/memory/); expect(config.storage.git).toEqual({ author: 'ktx <ktx@example.com>' });
expect(config).not.toHaveProperty('memory');
});
expect(validateKtxProjectConfig(`storage:\n git:\n ${removedAutoCommitKey}: false\n`)).toMatchObject({ it('reports dropped keys as warnings, not blocking errors', () => {
const validation = validateKtxProjectConfig(
`storage:\n git:\n ${removedAutoCommitKey}: false\nmemory:\n ${removedAutoCommitKey}: false\n`,
);
expect(validation.ok).toBe(true);
expect(validation.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: `storage.git.${removedAutoCommitKey}`, severity: 'warning' }),
expect.objectContaining({ path: 'memory', severity: 'warning' }),
]),
);
});
it('tolerates llm.models roles this ktx version does not define', () => {
// Enum-keyed record entries surface as zod `invalid_key`, not
// `unrecognized_keys` — a distinct path from unknown object fields.
const config = parseKtxProjectConfig(`
llm:
models:
default: claude-sonnet-4-6
summarizer_from_the_future: some-model
`);
expect(config.llm.models).toEqual({ default: 'claude-sonnet-4-6' });
const validation = validateKtxProjectConfig(
'llm:\n models:\n default: claude-sonnet-4-6\n summarizer_from_the_future: some-model\n',
);
expect(validation.ok).toBe(true);
expect(validation.issues).toEqual([
expect.objectContaining({ path: 'llm.models.summarizer_from_the_future', severity: 'warning' }),
]);
});
it('still rejects malformed values on recognized fields', () => {
// Tolerance is only for unknown keys. A bad value on a known field is a
// real misconfiguration and must still fail loudly.
expect(() => parseKtxProjectConfig('storage:\n state: mariadb\n')).toThrow(/storage\.state/);
expect(validateKtxProjectConfig('storage:\n state: mariadb\n')).toMatchObject({
ok: false, ok: false,
issues: [expect.objectContaining({ path: `storage.git.${removedAutoCommitKey}` })], issues: [expect.objectContaining({ path: 'storage.state', severity: 'error' })],
}); });
}); });
@ -471,41 +507,34 @@ scan:
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.validationBudget/); expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.validationBudget/);
}); });
it('rejects unsupported local LLM and embedding fields', () => { it('tolerates unsupported nested fields and surfaces them as warnings', () => {
// Unknown nested keys (whether obsolete or a typo) are dropped rather than
// bricking the command; ktx status surfaces them via validate warnings.
expect(() => expect(() =>
parseKtxProjectConfig(` parseKtxProjectConfig(`
ingest: ingest:
llm: llm:
backend: anthropic backend: anthropic
`), `),
).toThrow('Unsupported ingest.llm: unknown field'); ).not.toThrow();
expect(() => const validation = validateKtxProjectConfig(`
parseKtxProjectConfig(` ingest:
llm:
backend: anthropic
scan: scan:
enrichment: enrichment:
backend: gateway backend: gateway
`), ingest_embeddings_typo:
).toThrow('Unsupported scan.enrichment.backend: unknown field'); provider: gateway
`);
expect(() => expect(validation.ok).toBe(true);
parseKtxProjectConfig(` expect(validation.issues).toEqual(
scan: expect.arrayContaining([
enrichment: expect.objectContaining({ path: 'ingest.llm', severity: 'warning' }),
mode: llm expect.objectContaining({ path: 'scan.enrichment.backend', severity: 'warning' }),
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', () => { it('rejects gateway embedding configs', () => {
@ -552,13 +581,19 @@ scan:
}); });
}); });
it('rejects unknown top-level fields under strict mode', () => { it('tolerates an unknown top-level field but warns about it', () => {
// A typo like `storrage` no longer bricks every command; it is dropped and
// reported as a warning so the user can notice the setting did not apply.
expect(() => expect(() =>
parseKtxProjectConfig(` parseKtxProjectConfig(`
storrage: storrage:
state: sqlite state: sqlite
`), `),
).toThrow(/Unsupported storrage/); ).not.toThrow();
const validation = validateKtxProjectConfig('storrage:\n state: sqlite\n');
expect(validation.ok).toBe(true);
expect(validation.issues).toEqual([expect.objectContaining({ path: 'storrage', severity: 'warning' })]);
}); });
}); });
@ -598,7 +633,7 @@ scan:
const result = validateKtxProjectConfig('- nope\n'); const result = validateKtxProjectConfig('- nope\n');
expect(result).toEqual({ expect(result).toEqual({
ok: false, ok: false,
issues: [{ path: '', message: 'ktx.yaml must contain a YAML object' }], issues: [{ path: '', message: 'ktx.yaml must contain a YAML object', severity: 'error' }],
}); });
}); });
}); });

View file

@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -61,6 +61,31 @@ describe('ktx local project runtime', () => {
}); });
}); });
it('loads a ktx.yaml carrying fields removed in a newer ktx without mutating it on disk', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });
// Simulate a project written by a different ktx: inject unknown fields into
// the existing storage.git block and as a top-level memory block.
const configPath = join(projectDir, 'ktx.yaml');
const original = await readFile(configPath, 'utf-8');
const withStaleKeys = `${original.replace(
'author: ktx <ktx@example.com>',
'auto_commit: true\n author: ktx <ktx@example.com>',
)}memory:\n auto_commit: true\n`;
await writeFile(configPath, withStaleKeys, 'utf-8');
const loaded = await loadKtxProject({ projectDir });
// Loading tolerates the unknown fields instead of throwing: they are stripped
// from the in-memory config so every command still runs.
expect(loaded.config).not.toHaveProperty('memory');
expect(loaded.config.storage.git).toEqual({ author: 'ktx <ktx@example.com>' });
// The file on disk stays exactly as the user wrote it.
await expect(readFile(configPath, 'utf-8')).resolves.toBe(withStaleKeys);
});
it('initializes a dedicated git repo at the project dir even when nested inside an enclosing repo', async () => { it('initializes a dedicated git repo at the project dir even when nested inside an enclosing repo', async () => {
// A ktx project dir living below an existing git working tree (e.g. an analytics // A ktx project dir living below an existing git working tree (e.g. an analytics
// subfolder of an app repo). ktx must own its own repo rooted at the project dir, // subfolder of an app repo). ktx must own its own repo rooted at the project dir,
@ -95,4 +120,40 @@ describe('ktx local project runtime', () => {
configPath: join(projectDir, 'ktx.yaml'), configPath: join(projectDir, 'ktx.yaml'),
}); });
}); });
it('refuses to initialize inside a foreign git repo and writes nothing into it', async () => {
// A user's own repo: has history, no root ktx.yaml. The guard must reject
// before writing ktx.yaml — that file would make the repo classify as ktx's.
const projectDir = join(tempDir, 'app-repo');
await mkdir(projectDir, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: projectDir });
await writeFile(join(projectDir, 'README.md'), '# App\n', 'utf-8');
execFileSync('git', ['add', 'README.md'], { cwd: projectDir });
execFileSync(
'git',
['-c', 'user.name=App', '-c', 'user.email=app@example.com', 'commit', '-q', '-m', 'baseline'],
{ cwd: projectDir },
);
await expect(initKtxProject({ projectDir })).rejects.toThrow(
/already a git repository that ktx did not create/,
);
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toMatchObject({ code: 'ENOENT' });
const tracked = execFileSync('git', ['ls-files'], { cwd: projectDir, encoding: 'utf-8' });
expect(tracked).not.toContain('ktx.yaml');
});
it('recovers an init interrupted after ktx.yaml was written but before git finished', async () => {
// ktx.yaml is written before git init, so the only crash residue is a valid
// ktx.yaml with no `.git` — the next load must re-init, not reject as foreign.
const projectDir = join(tempDir, 'half-init');
await initKtxProject({ projectDir });
await rm(join(projectDir, '.git'), { recursive: true, force: true });
const loaded = await loadKtxProject({ projectDir });
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
expect(await loaded.git.revParseHead()).toMatch(/^[0-9a-f]{40}$/);
});
}); });

View file

@ -9,6 +9,8 @@ import {
type DoctorCheck, type DoctorCheck,
} from '../src/doctor.js'; } from '../src/doctor.js';
const removedAutoCommitKey = ['auto', 'commit'].join('_');
function makeIo() { function makeIo() {
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@ -353,17 +355,10 @@ describe('runKtxDoctor', () => {
expect(parsed.projectDir).toBe(tempDir); expect(parsed.projectDir).toBe(tempDir);
}); });
it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => { it('prints schema issues and exits 1 when ktx.yaml has an invalid value', async () => {
await writeFile( await writeFile(
join(tempDir, 'ktx.yaml'), join(tempDir, 'ktx.yaml'),
[ ['storage:', ' state: mariadb', ''].join('\n'),
'storrage:',
' state: sqlite',
'ingest:',
' llm:',
' backend: anthropic',
'',
].join('\n'),
'utf-8', 'utf-8',
); );
const testIo = makeIo(); const testIo = makeIo();
@ -379,15 +374,14 @@ describe('runKtxDoctor', () => {
const out = testIo.stdout(); const out = testIo.stdout();
expect(out).toContain('ktx status'); expect(out).toContain('ktx status');
expect(out).toContain('Config'); expect(out).toContain('Config');
expect(out).toContain('Unsupported storrage: unknown field'); expect(out).toContain('storage.state');
expect(out).toContain('Unsupported ingest.llm: unknown field');
expect(out).toContain('ktx.yaml'); expect(out).toContain('ktx.yaml');
}); });
it('emits structured JSON when ktx.yaml fails Zod validation', async () => { it('emits structured JSON when ktx.yaml has an invalid value', async () => {
await writeFile( await writeFile(
join(tempDir, 'ktx.yaml'), join(tempDir, 'ktx.yaml'),
['storrage: {}', ''].join('\n'), ['storage:', ' state: mariadb', ''].join('\n'),
'utf-8', 'utf-8',
); );
const testIo = makeIo(); const testIo = makeIo();
@ -407,7 +401,7 @@ describe('runKtxDoctor', () => {
}; };
expect(parsed.error).toBe('invalid_config'); expect(parsed.error).toBe('invalid_config');
expect(parsed.projectDir).toBe(tempDir); expect(parsed.projectDir).toBe(tempDir);
expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true); expect(parsed.issues.some((issue) => issue.path === 'storage.state')).toBe(true);
}); });
it('shows a Config row labelled "ktx.yaml schema valid" on the happy path', async () => { it('shows a Config row labelled "ktx.yaml schema valid" on the happy path', async () => {
@ -490,6 +484,49 @@ describe('runKtxDoctor', () => {
delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY;
}); });
it('exits 0 and shows a Config warn row when ktx.yaml carries stale unknown fields', async () => {
// The default `ktx status` path (command: 'project') must keep working on a
// ktx.yaml written by a different ktx version: unknown fields surface as an
// ignored-fields warning on the Config row, never as a failure.
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
' models:',
' default: claude-sonnet-4-5',
'storage:',
' git:',
` ${removedAutoCommitKey}: false`,
'memory: {}',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
const out = testIo.stdout();
expect(out).toContain('Config');
expect(out).toContain('ktx.yaml schema valid · 2 ignored fields');
expect(out).toContain(`⚠ Unsupported storage.git.${removedAutoCommitKey}: unknown field (ignored)`);
expect(out).toContain('⚠ Unsupported memory: unknown field (ignored)');
delete process.env.ANTHROPIC_API_KEY;
});
it('reports Claude Code auth failures and ignored prompt-caching fields in project doctor output', async () => { it('reports Claude Code auth failures and ignored prompt-caching fields in project doctor output', async () => {
await writeFile( await writeFile(
join(tempDir, 'ktx.yaml'), join(tempDir, 'ktx.yaml'),
@ -795,17 +832,10 @@ describe('runKtxDoctor', () => {
expect(JSON.parse(testIo.stdout())).toEqual({ ok: true, projectDir: tempDir }); expect(JSON.parse(testIo.stdout())).toEqual({ ok: true, projectDir: tempDir });
}); });
it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => { it('prints schema issues and exits 1 when ktx.yaml has an invalid value', async () => {
await writeFile( await writeFile(
join(tempDir, 'ktx.yaml'), join(tempDir, 'ktx.yaml'),
[ ['storage:', ' state: mariadb', ''].join('\n'),
'storrage:',
' state: sqlite',
'ingest:',
' llm:',
' backend: anthropic',
'',
].join('\n'),
'utf-8', 'utf-8',
); );
const testIo = makeIo(); const testIo = makeIo();
@ -819,14 +849,13 @@ describe('runKtxDoctor', () => {
).resolves.toBe(1); ).resolves.toBe(1);
const out = testIo.stdout(); const out = testIo.stdout();
expect(out).toContain('Unsupported storrage: unknown field'); expect(out).toContain('storage.state');
expect(out).toContain('Unsupported ingest.llm: unknown field');
}); });
it('emits structured JSON issues when validation fails', async () => { it('emits structured JSON issues when validation fails', async () => {
await writeFile( await writeFile(
join(tempDir, 'ktx.yaml'), join(tempDir, 'ktx.yaml'),
['storrage: {}', ''].join('\n'), ['storage:', ' state: mariadb', ''].join('\n'),
'utf-8', 'utf-8',
); );
const testIo = makeIo(); const testIo = makeIo();
@ -841,7 +870,75 @@ describe('runKtxDoctor', () => {
const parsed = JSON.parse(testIo.stdout()) as { error: string; issues: Array<{ path: string }> }; const parsed = JSON.parse(testIo.stdout()) as { error: string; issues: Array<{ path: string }> };
expect(parsed.error).toBe('invalid_config'); expect(parsed.error).toBe('invalid_config');
expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true); expect(parsed.issues.some((issue) => issue.path === 'storage.state')).toBe(true);
});
it('tolerates unknown fields, reporting them as warnings and exiting 0', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['storrage:', ' state: sqlite', 'ingest:', ' llm:', ' backend: anthropic', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
const out = testIo.stdout();
expect(out).toContain('storrage');
expect(out).toContain('ingest.llm');
expect(out).toContain('ignored');
});
it('emits structured JSON warnings for unknown fields and exits 0', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
const parsed = JSON.parse(testIo.stdout()) as { ok: boolean; warnings: Array<{ path: string; severity: string }> };
expect(parsed.ok).toBe(true);
expect(parsed.warnings.some((warning) => warning.path === 'storrage' && warning.severity === 'warning')).toBe(true);
});
it('renders unknown fields as ignored even when a real error blocks', async () => {
// Mixed file: a bad value on a recognized field (blocks) plus a stale
// unknown key (ignored). Only the error counts as a schema issue; the
// warning keeps the ⚠ ignored-field treatment instead of a misleading ✗.
await writeFile(
join(tempDir, 'ktx.yaml'),
['storage:', ' state: mariadb', 'memory: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const out = testIo.stdout();
expect(out).toContain('ktx.yaml has 1 schema issue · 1 ignored field');
expect(out).toContain('✗ storage.state');
expect(out).toContain('⚠ Unsupported memory: unknown field (ignored)');
}); });
it('prints the missing-project message and exits 1 when ktx.yaml is absent', async () => { it('prints the missing-project message and exits 1 when ktx.yaml is absent', async () => {