397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
6.4 KiB
JavaScript
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;
|
|
}
|