#!/usr/bin/env node /** * context-verity (§5.6) — машинная сверка verified-context артефакта наставника. * Резолв EXTRACTED-цитат по СИМВОЛУ/контенту (anchor-подстрока в файле; номер строки в * ref — подсказка, SE-1). Чистое ядро (I/O инъектируется), образец — plan-lock.refResolves. * verity = РЕЗОЛВ цитат, НЕ истина (SE-6) — ревью владельца обязателен (§5.10). */ // Допустимые виды цитат (§5.5). export const CITATION_KINDS = Object.freeze(['EXTRACTED', 'INFERRED']); // SE-A2: минимальная длина anchor. Очень короткая подстрока (1-3 символа) встречается // почти в любом файле → символ-резолв вырождается (тривиально «находится»). Порог 4 — // большинство значащих идентификаторов кода ≥4; короче порога EXTRACTED не резолвится // и понижается до INFERRED (secure-default). Тонкая настройка — design-choice (родственно O9). export const MIN_ANCHOR_LENGTH = 4; /** Парс "file:line" → {file, line:number|null} | null (не-строка). Голый файл → line:null. */ export function parseRef(ref) { if (typeof ref !== 'string') return null; const m = ref.match(/^(.*):(\d+)$/); if (!m) return { file: ref, line: null }; return { file: m[1], line: Number(m[2]) }; } /** * Резолв одной EXTRACTED-цитаты: anchor-подстрока ОБЯЗАНА встретиться в файле * (SE-1: по символу/контенту, не по номеру строки — он лишь подсказка). readFileImpl * инъектируется (в проде — fs.readFileSync; в тестах — мок). Возвращает {resolved, reason}. */ export function resolveCitation(cite, readFileImpl) { const parsed = parseRef(cite && cite.ref); if (!parsed || !parsed.file) return { resolved: false, reason: 'ref не парсится' }; const anchor = String((cite && cite.anchor) || '').trim(); if (!anchor) return { resolved: false, reason: 'нет anchor — резолв по символу невозможен (SE-1)' }; if (anchor.length < MIN_ANCHOR_LENGTH) return { resolved: false, reason: `anchor короче порога ${MIN_ANCHOR_LENGTH} — символ-резолв вырождается (SE-A2)` }; let src; try { src = readFileImpl(parsed.file); } catch { src = null; } if (src == null) return { resolved: false, reason: `файл не прочитан: ${parsed.file}` }; return String(src).includes(anchor) ? { resolved: true, reason: 'anchor найден (символ-резолв)' } : { resolved: false, reason: 'anchor не найден в файле' }; } /** * Проверить весь артефакт. EXTRACTED-записи обязаны резолвиться (по anchor). Неразрешённая * EXTRACTED попадает в flagged И downgraded (понижение — secure-default S2). INFERRED не * резолвится, но ОБЯЗАН нести непустой derivation_ref (VF-1 — иначе flagged, не молчаливый * проход; лимит ДОЛИ INFERRED — O9, вне этого модуля). Битая запись (null/не-объект) → * flagged, не крашит (зеркало N2 action-journal). * ok = есть ≥1 EXTRACTED (VA-9) И нет неразрешённых И нет INFERRED-без-derivation_ref (VF-1). * @returns {{ok, flagged:[{id,reason}], downgraded:[{id,reason}], extractedCount, entries}} */ export function verifyArtifact(artifact, readFileImpl) { const list = Array.isArray(artifact) ? artifact : []; const flagged = []; const downgraded = []; let extractedCount = 0; let inferredMissingDerivation = 0; let unknownKind = 0; const entries = []; for (const e of list) { if (!e || typeof e !== 'object' || Array.isArray(e)) { flagged.push({ id: null, reason: 'битая запись' }); continue; } if (e.kind === 'EXTRACTED') { extractedCount++; const r = resolveCitation({ ref: e.ref, anchor: e.anchor }, readFileImpl); if (!r.resolved) { flagged.push({ id: e.id ?? null, reason: r.reason }); downgraded.push({ id: e.id ?? null, reason: r.reason }); entries.push({ ...e, kind: 'INFERRED', downgraded_from: 'EXTRACTED' }); // secure-default (S2) continue; } } else if (e.kind === 'INFERRED' && !String(e.derivation_ref || '').trim()) { inferredMissingDerivation++; flagged.push({ id: e.id ?? null, reason: 'INFERRED без derivation_ref (VF-1)' }); } else if (!CITATION_KINDS.includes(e.kind)) { // SE-A1: kind — controller-влияемая метка; неизвестный kind НЕ обходит проверку молча // (третья полоса обхода после S2-мис-метки и VF-1) → flagged + ok:false (fail-closed). unknownKind++; flagged.push({ id: e.id ?? null, reason: `неизвестный kind: ${JSON.stringify(e.kind)} (SE-A1)` }); } entries.push(e); } const ok = extractedCount > 0 && downgraded.length === 0 && inferredMissingDerivation === 0 && unknownKind === 0; return { ok, flagged, downgraded, extractedCount, entries }; } /** * ✅O2 freeze-side guard: артефакт с ХОТЬ ОДНОЙ неразрешённой EXTRACTED не печатается. * Чистый предикат для подключения в plan-lock.freezeArtifact (wiring — sub-plan C, где * verified-context артефакт встраивается в sealed-артефакт). Здесь — только готовый guard. * ⚠️ SE-A3: guard НЕ покрывает VA-9 — на пустом/null артефакте возвращает false («чисто»), * т.к. судит ТОЛЬКО неразрешённые EXTRACTED. Потребитель (freeze-gate, sub-plan C) обязан * дополнительно проверять verifyArtifact().ok (там VA-9/VF-1/SE-A1 учтены). */ export function artifactHasUnresolvedExtracted(artifact, readFileImpl) { return verifyArtifact(artifact, readFileImpl).downgraded.length > 0; }