Files
brain/tools/context-verity.mjs
T

99 lines
6.4 KiB
JavaScript

#!/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;
}