/** * cli:sources-ingest — validate.ts * * Spec validation + prerequisites. Doctrine: an error here names its * remediation; mode/kind/origin incoherences are refused BEFORE any IO so an * empty registry can never mean « inputs were absent ». */ import { existsSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { IngestInputSchema, type ValidationResult } from './types.js' export function validateSpec(raw: unknown, workdir?: string): ValidationResult { const parsed = IngestInputSchema.safeParse(raw) if (!parsed.success) { return { valid: false, errors: parsed.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`), } } const spec = parsed.data const base = workdir ?? process.cwd() const resolvedBaRoot = isAbsolute(spec.baRoot) ? spec.baRoot : join(base, spec.baRoot) const errors: string[] = [] const originIsPath = 'path' in spec.origin if (spec.kind === 'file' && !originIsPath) { errors.push('kind=file requires origin.path (origin.url is for kind=web).') } if (spec.kind === 'web' && originIsPath) { errors.push('kind=web requires origin.url + origin.fetchedAt (origin.path is for kind=file).') } let resolvedOriginPath: string | undefined if (spec.kind === 'file' && originIsPath) { const p = (spec.origin as { path: string }).path resolvedOriginPath = isAbsolute(p) ? p : join(base, p) if (!existsSync(resolvedOriginPath)) { errors.push(`origin.path not found: ${resolvedOriginPath}`) } } if (spec.mode === 'write' && !spec.analysis) { errors.push('mode=write requires `analysis` — run mode=plan first, read the content, judge, then write.') } if (spec.mode === 'register-blocked') { if (!spec.reason) { errors.push('mode=register-blocked requires `reason` — a typed unread status is an ADMISSION, it says why.') } if (spec.analysis) { errors.push('mode=register-blocked takes no `analysis` — the content is precisely what could not be read.') } } if (spec.supersedes) { if (spec.mode !== 'write') { errors.push('`supersedes` only makes sense in mode=write — the replacement must actually be ingested.') } if (spec.as && spec.as === spec.supersedes) { errors.push('`as` and `supersedes` on the same code are contradictory — attach OR replace, not both.') } } if (spec.kind === 'web' && spec.copyRaw === true) { errors.push('kind=web has no raw/ copy — the citation is URL + fetch date + verbatim extracts.') } return { valid: errors.length === 0, errors, spec, resolvedBaRoot, resolvedOriginPath } }