gstack/scripts/lenses/reconcile.ts

330 lines
14 KiB
TypeScript

import { createHash } from 'crypto';
import { loadLensRegistry, resolveLensName } from './registry';
import {
CONFIDENCE_LEVELS,
DECISION_IMPACTS,
EVIDENCE_KINDS,
EVIDENCE_STRENGTHS,
INFERENCE_STATUSES,
REMEDIATION_EFFECTS,
URGENCIES,
type BaselineFinding,
type EvidenceCluster,
type LensEvidence,
type LensFinding,
type LensFindingInput,
type LensSpec,
type NoveltyStatus,
type ReconcileInput,
type ReconcileOutput,
type SynthesisPlan,
} from './types';
const EVIDENCE_RANK: Record<string, number> = { WEAK: 1, MODERATE: 2, STRONG: 3 };
const IMPACT_RANK: Record<string, number> = { ADVISORY: 1, MATERIAL: 2, BLOCKING: 3 };
const REQUIRED_EVIDENCE_RANK: Record<string, number> = { ANY: 1, STRONG_OR_MODERATE: 2, STRONG_ONLY: 3 };
const REQUIRED_IMPACT_RANK: Record<string, number> = { ANY: 1, MATERIAL_OR_BLOCKING: 2, BLOCKING_ONLY: 3 };
const STRUCTURAL_KEY_RE = /^[a-z][a-z0-9-]{1,95}$/;
const OPPOSITE_EFFECTS = new Set([
'ADD:REMOVE', 'REMOVE:ADD',
'ENABLE:DISABLE', 'DISABLE:ENABLE',
'ALLOW:DENY', 'DENY:ALLOW',
'RETAIN:DELETE', 'DELETE:RETAIN',
'REQUIRE:RELAX', 'RELAX:REQUIRE',
]);
function hash(input: string, length = 12): string {
return createHash('sha256').update(input).digest('hex').slice(0, length);
}
function normalizePath(value: string | undefined): string {
return (value ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
}
export function normalizeStructuralKey(value: string): string {
return value.trim().toLowerCase().replace(/_/g, '-');
}
function validateStructuralKey(value: unknown, field: string, errors: string[]): void {
if (typeof value !== 'string' || !STRUCTURAL_KEY_RE.test(normalizeStructuralKey(value))) {
errors.push(`${field} must be a kebab-case structural key between 2 and 96 characters`);
}
}
function normalizeDescription(value: string | undefined): string {
return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
}
export function evidenceKey(evidence: Partial<LensEvidence>): string {
const kind = evidence.kind ?? 'unknown';
const filePath = normalizePath(evidence.path);
if (kind === 'file_line') return `file_line:${filePath}:${evidence.line ?? '*'}`;
if (kind === 'file_range') return `file_range:${filePath}:${evidence.line ?? '*'}-${evidence.end_line ?? '*'}`;
if (kind === 'policy_mismatch') return `policy_mismatch:${evidence.policy_ref ?? evidence.scope ?? filePath}`;
if (kind === 'cross_file') {
const paths = [...(evidence.paths ?? []), ...(filePath ? [filePath] : [])].map(normalizePath).sort();
const scope = evidence.scope ?? normalizeDescription(evidence.description);
return `cross_file:${hash(`${paths.join('|')}|${scope}`, 20)}`;
}
if (['missing_artifact', 'missing_control', 'missing_record', 'unmeasured_claim'].includes(kind)) {
return `${kind}:${normalizePath(evidence.scope) || filePath || hash(normalizeDescription(evidence.description), 20)}`;
}
return `${kind}:${filePath}:${evidence.line ?? '*'}`;
}
function baselineEvidenceKey(finding: BaselineFinding): string | null {
if (finding.evidence_key?.trim()) return finding.evidence_key.trim();
if (finding.evidence?.kind) return evidenceKey(finding.evidence);
if (finding.scope) return `missing_control:${normalizePath(finding.scope)}`;
if (finding.path && finding.line != null) return `file_line:${normalizePath(finding.path)}:${finding.line}`;
return null;
}
export function noveltyAgainst(
finding: LensFindingInput,
baseline: BaselineFinding[] | undefined,
mode: 'production' | 'evaluation',
): NoveltyStatus {
if (baseline === undefined) return 'NOT_MEASURED';
const claimKey = normalizeStructuralKey(finding.claim_key);
const control = normalizeStructuralKey(finding.control_or_asset);
const key = evidenceKey(finding.evidence);
let unstructuredEvidenceOverlap = false;
for (const other of baseline) {
const otherClaim = other.claim_key ? normalizeStructuralKey(other.claim_key) : null;
const otherControl = other.control_or_asset ? normalizeStructuralKey(other.control_or_asset) : null;
const otherEvidence = baselineEvidenceKey(other);
if (otherClaim && otherClaim === claimKey) return 'OVERLAPS_BASELINE';
if (otherEvidence && otherEvidence === key && otherControl && otherControl === control) {
return 'OVERLAPS_BASELINE';
}
if (otherEvidence && otherEvidence === key) unstructuredEvidenceOverlap = true;
}
// Same evidence without a structured material claim is not enough to call the
// lens finding new or duplicative. Preserve the uncertainty explicitly.
if (unstructuredEvidenceOverlap) return 'AMBIGUOUS';
// Production review cannot prove semantic novelty from an exact-match miss.
// Evaluation fixtures provide a labeled baseline and may record NOVEL.
return mode === 'evaluation' ? 'NOVEL' : 'NOT_MEASURED';
}
export function stableFindingId(finding: LensFindingInput): string {
const claim = normalizeStructuralKey(finding.claim_key);
const evidence = evidenceKey(finding.evidence);
return `${finding.lens}:${claim}:${hash(evidence, 20)}`;
}
function validateEnum(value: unknown, allowed: readonly string[], field: string, errors: string[]): void {
if (typeof value !== 'string' || !allowed.includes(value)) errors.push(`${field} must be one of ${allowed.join(', ')}`);
}
export function validateFinding(input: LensFindingInput, spec: LensSpec): string[] {
const errors: string[] = [];
if (input.lens !== spec.lens) errors.push(`lens must be '${spec.lens}'`);
if (!spec.severity.includes(input.severity)) errors.push(`severity '${input.severity}' is not valid for ${spec.lens}`);
validateStructuralKey(input.claim_key, 'claim_key', errors);
validateStructuralKey(input.control_or_asset, 'control_or_asset', errors);
validateStructuralKey(input.remediation_key, 'remediation_key', errors);
validateEnum(input.remediation_effect, REMEDIATION_EFFECTS, 'remediation_effect', errors);
if (!input.evidence || typeof input.evidence !== 'object') {
errors.push('evidence is required');
return errors;
}
validateEnum(input.evidence.kind, EVIDENCE_KINDS, 'evidence.kind', errors);
if (!spec.allowed_evidence_kinds.includes(input.evidence.kind)) {
errors.push(`evidence kind '${input.evidence.kind}' is not allowed for ${spec.lens}`);
}
if (!input.evidence.description?.trim()) errors.push('evidence.description is required');
if (input.evidence.kind === 'file_line' && (!input.evidence.path || input.evidence.line == null)) {
errors.push('file_line evidence requires path and line');
}
if (input.evidence.kind === 'file_range' && (!input.evidence.path || input.evidence.line == null || input.evidence.end_line == null)) {
errors.push('file_range evidence requires path, line, and end_line');
}
if (input.evidence.kind === 'cross_file' && (!input.evidence.paths || input.evidence.paths.length < 2)) {
errors.push('cross_file evidence requires at least two paths');
}
if (['missing_artifact', 'missing_control', 'missing_record', 'unmeasured_claim'].includes(input.evidence.kind) && !input.evidence.scope && !input.evidence.path) {
errors.push(`${input.evidence.kind} evidence requires scope or path`);
}
if (!input.stakeholder_frame?.trim()) errors.push('stakeholder_frame is required');
if (!input.recommended_action?.trim()) errors.push('recommended_action is required');
validateEnum(input.decision_impact, DECISION_IMPACTS, 'decision_impact', errors);
validateEnum(input.evidence_strength, EVIDENCE_STRENGTHS, 'evidence_strength', errors);
validateEnum(input.inference_status, INFERENCE_STATUSES, 'inference_status', errors);
validateEnum(input.urgency, URGENCIES, 'urgency', errors);
validateEnum(input.confidence_evidence_exists, CONFIDENCE_LEVELS, 'confidence_evidence_exists', errors);
validateEnum(input.confidence_interpretation_correct, CONFIDENCE_LEVELS, 'confidence_interpretation_correct', errors);
validateEnum(input.confidence_consequence_material, CONFIDENCE_LEVELS, 'confidence_consequence_material', errors);
if ((EVIDENCE_RANK[input.evidence_strength] ?? 0) < REQUIRED_EVIDENCE_RANK[spec.evidence_threshold]) {
errors.push(`finding is below evidence threshold ${spec.evidence_threshold}`);
}
if ((IMPACT_RANK[input.decision_impact] ?? 0) < REQUIRED_IMPACT_RANK[spec.materiality_threshold]) {
errors.push(`finding is below materiality threshold ${spec.materiality_threshold}`);
}
if (spec.autofix_policy === 'ask_always' && input.classification === 'FIXABLE') {
errors.push('V0.5 lens findings cannot be FIXABLE when autofix_policy is ask_always');
}
return errors;
}
function actionsConverge(group: LensFinding[]): boolean {
if (group.length < 2) return false;
const keys = new Set(group.map((finding) => `${normalizeStructuralKey(finding.remediation_key)}:${finding.remediation_effect}`));
return keys.size === 1;
}
function actionsContradict(a: LensFinding, b: LensFinding): boolean {
if (normalizeStructuralKey(a.control_or_asset) !== normalizeStructuralKey(b.control_or_asset)) return false;
return OPPOSITE_EFFECTS.has(`${a.remediation_effect}:${b.remediation_effect}`);
}
function buildSynthesisPlan(findings: LensFinding[], clusters: EvidenceCluster[]): SynthesisPlan {
const materialFindings = findings.filter((finding) => finding.decision_impact === 'MATERIAL' || finding.decision_impact === 'BLOCKING');
const lenses = new Set(materialFindings.map((finding) => finding.lens));
if (lenses.size < 2) {
return {
required: false,
reason: 'CTO synthesis requires material or blocking findings from at least two independent lenses',
input: null,
};
}
return {
required: true,
reason: `Material findings span ${lenses.size} independent lenses`,
input: {
findings: materialFindings.map((finding) => ({
finding_id: finding.finding_id,
lens: finding.lens,
claim_key: finding.claim_key,
control_or_asset: finding.control_or_asset,
remediation_key: finding.remediation_key,
remediation_effect: finding.remediation_effect,
decision_impact: finding.decision_impact,
evidence_strength: finding.evidence_strength,
stakeholder_frame: finding.stakeholder_frame,
recommended_action: finding.recommended_action,
evidence_cluster_id: finding.evidence_cluster_id,
contradiction: finding.contradiction,
})),
clusters: clusters.filter((cluster) => cluster.finding_ids.some((id) => materialFindings.some((finding) => finding.finding_id === id))),
},
};
}
export function reconcileLensResults(repoRoot: string, input: ReconcileInput): ReconcileOutput {
const specs = loadLensRegistry(repoRoot);
const findings: LensFinding[] = [];
const insufficient: ReconcileOutput['insufficient_evidence'] = [];
const noMaterial: string[] = [];
const malformed: ReconcileOutput['malformed_or_invalid'] = [];
const noveltyMode = input.novelty_mode ?? 'production';
for (const result of input.lens_results) {
const spec = resolveLensName(specs, result.lens);
if (!spec) {
malformed.push({ lens: result.lens, reason: 'unknown lens result', raw: result });
continue;
}
if (result.status === 'INSUFFICIENT_EVIDENCE') {
insufficient.push({ ...result, lens: spec.lens });
continue;
}
if (result.status === 'NO_MATERIAL_FINDINGS') {
noMaterial.push(spec.lens);
continue;
}
if (result.status !== 'FINDINGS' || !Array.isArray(result.findings)) {
malformed.push({ lens: spec.lens, reason: 'invalid lens result envelope', raw: result });
continue;
}
for (const raw of result.findings) {
const normalizedInput: LensFindingInput = {
...raw,
lens: spec.lens,
claim_key: normalizeStructuralKey(raw.claim_key ?? ''),
control_or_asset: normalizeStructuralKey(raw.control_or_asset ?? ''),
remediation_key: normalizeStructuralKey(raw.remediation_key ?? ''),
};
const errors = validateFinding(normalizedInput, spec);
if (errors.length > 0) {
malformed.push({ lens: spec.lens, reason: errors.join('; '), raw });
continue;
}
findings.push({
...normalizedInput,
finding_id: normalizedInput.finding_id ?? stableFindingId(normalizedInput),
classification: 'INVESTIGATE',
evidence_cluster_id: null,
novelty_vs_tech_review: noveltyAgainst(normalizedInput, input.tech_findings, noveltyMode),
novelty_vs_generic_adversarial: noveltyAgainst(normalizedInput, input.generic_adversarial_findings, noveltyMode),
contradiction: false,
validation_errors: [],
});
}
}
const byEvidence = new Map<string, LensFinding[]>();
for (const finding of findings) {
const key = evidenceKey(finding.evidence);
const group = byEvidence.get(key) ?? [];
group.push(finding);
byEvidence.set(key, group);
}
const clusters: EvidenceCluster[] = [];
for (const [key, group] of [...byEvidence.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const lenses = [...new Set(group.map((finding) => finding.lens))].sort();
if (lenses.length < 2) continue;
const id = `EC-${hash(key, 10).toUpperCase()}`;
let contradiction = false;
for (let i = 0; i < group.length; i++) {
for (let j = i + 1; j < group.length; j++) {
contradiction = contradiction || actionsContradict(group[i], group[j]);
}
}
for (const finding of group) {
finding.evidence_cluster_id = id;
finding.contradiction = contradiction;
}
const tags: EvidenceCluster['tags'] = ['SHARED_EVIDENCE', 'MULTI_LENS', 'EVIDENCE_CLUSTER'];
if (contradiction) tags.push('CONTRADICTION');
clusters.push({
id,
evidence_key: key,
tags,
finding_ids: group.map((finding) => finding.finding_id).sort(),
lenses,
control_or_assets: [...new Set(group.map((finding) => finding.control_or_asset))].sort(),
remediation_keys: [...new Set(group.map((finding) => finding.remediation_key))].sort(),
convergent_remediation: actionsConverge(group),
contradiction,
});
}
findings.sort((a, b) => {
const impact = (IMPACT_RANK[b.decision_impact] ?? 0) - (IMPACT_RANK[a.decision_impact] ?? 0);
if (impact !== 0) return impact;
const evidence = (EVIDENCE_RANK[b.evidence_strength] ?? 0) - (EVIDENCE_RANK[a.evidence_strength] ?? 0);
if (evidence !== 0) return evidence;
return a.finding_id.localeCompare(b.finding_id);
});
return {
findings,
clusters,
insufficient_evidence: insufficient,
no_material_findings: noMaterial,
malformed_or_invalid: malformed,
synthesis: buildSynthesisPlan(findings, clusters),
};
}