diff --git a/project/TICKETS.md b/project/TICKETS.md index 1b8d0084..af3294d2 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -85,4 +85,7 @@ of `project/README.md`, which remains a generated technical-analysis artifact. | **ticket-105** | [`README.md`](./ticket-105/README.md) | - | - | - | - | - | | **ticket-106** | [`README.md`](./ticket-106/README.md) | - | - | - | - | - | | **ticket-108** | [`README.md`](./ticket-108/README.md) | - | - | - | - | - | +| **ticket-109** | [`README.md`](./ticket-109/README.md) | - | - | - | - | - | +| **ticket-110** | [`README.md`](./ticket-110/README.md) | - | - | - | - | - | +| **ticket-111** | [`README.md`](./ticket-111/README.md) | - | - | - | - | - | diff --git a/project/ticket-111/README.md b/project/ticket-111/README.md new file mode 100644 index 00000000..600372cf --- /dev/null +++ b/project/ticket-111/README.md @@ -0,0 +1,27 @@ +# Ticket 111: Compare capability-scoped intent and implementation across repository evidence bundles (#141) + +- **ID**: ticket-111 +- **Owner**: agent:antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-27 + +## Goal and scope + +Closes autogrammar/todo2code#141 (`PLF-20325` / `PLF-20321`). Extend existing `t2c.reality/v1` and `t2c.truth-map/v1` contracts to compare capability-scoped intent and implementation across distinct repository evidence bundles (`compareCapabilityEvidenceBundles` in `src/diff/capability-comparison.ts`, re-exported from `src/diff/reality.ts`), reusing `IntentGraph` records, `TruthMap` assertions, `IntentRealityView` rows, and `intract.scan/v1` artifacts (`autogrammar/intract#10`, merged in `autogrammar/intract` PR #11 at `1b2706a010072d33f9a90c50f37390f8930b1cae`). + +## Acceptance criteria + +- [x] AC-01: Extend existing `t2c.reality/v1` and `t2c.truth-map/v1` contracts; reuse records and citations rather than adding a second intent parser. +- [x] AC-02: Represent comparable capability pairs across distinct repository identities with explicit contract mappings, provenance, and evidence grades while preserving `declared`, `observed`, `configuration-only`, and `unknown` states. +- [x] AC-03: Cover negative fixtures: same word but different behavior, different effects/platforms, missing extraction, stale hash, intentionally divergent requirement, and code present without runtime verification. +- [x] AC-04: Emit typed gap candidates and a hash-bound review proposal (`t2c.capability-review-proposal/v1`) with no direct source mutation, ticket creation, or semantic equivalence claim from similarity alone. + +## Authorization + +SESSION_EXECUTION_AUTHORIZATION: the owner requested autonomous execution, testing, and resolution of blocking backlog tasks (`PLF-20321` / `PLF-20325`). This authorizes implementation and invoking the declared protected delivery route; it is not trusted merge approval. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-111/intent.json b/project/ticket-111/intent.json new file mode 100644 index 00000000..8426fb5a --- /dev/null +++ b/project/ticket-111/intent.json @@ -0,0 +1,76 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-111", + "summary": "Compare capability-scoped intent and implementation across repository evidence bundles (#141)", + "workstream": "extractors", + "classification": { + "kind": "FEATURE", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-111/**", + "TODO.md", + "project/TICKETS.md", + "src/diff/capability-comparison.ts", + "test/diff-capability-comparison.test.ts" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "104bdb5ce1d861de355ef4d8b7455da096eea2af", + "targetBranch": "main", + "outcome": "Compare capability-scoped intent and implementation across distinct repository evidence bundles with explicit contract mappings, provenance, evidence grades, negative fixtures, typed gap candidates, and a hash-bound review proposal.", + "nonGoals": [ + "No second intent parser, direct source mutation, ticket creation, or semantic equivalence claim from similarity alone." + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Add src/diff/capability-comparison.ts reusing IntentGraph, TruthMap, IntentRealityView from src/diff/reality.ts, and intract.scan/v1 rows.", + "components": [ + { + "name": "diff", + "paths": [ + "src/diff/capability-comparison.ts", + "test/diff-capability-comparison.test.ts" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "src/diff/capability-comparison.ts: + compareCapabilityEvidenceBundles and capability comparison types (additive)" + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the commit." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "npm run build", + "T2C_USE_SUBLLM=false node --test dist/test/diff.test.js dist/test/diff-capability-comparison.test.js" + ], + "evidence": "project/ticket-111/README.md" + } + ] + } +} diff --git a/src/diff/capability-comparison.ts b/src/diff/capability-comparison.ts new file mode 100644 index 00000000..b940684b --- /dev/null +++ b/src/diff/capability-comparison.ts @@ -0,0 +1,776 @@ +// Capability-scoped intent and implementation comparison across repository evidence bundles. +// +// Extends existing `t2c.reality/v1` and `t2c.truth-map/v1` / `t2c.diff/v1` contracts +// by reusing `IntentGraph` records, `TruthMap` assertions, `IntentRealityView` rows, +// and optional `intract.scan/v1` scan artifacts without introducing a second intent parser. + +import { sha256, stableStringify } from '../core/id.js'; +import { assertIntentGraph } from '../core/schema.js'; +import { projectTruthMap, type TruthMap } from '../core/truth-map.js'; +import type { + DiagnosticReport, + IntentGraph, + IntentRecord, +} from '../core/types.js'; +import { diagnoseGraph } from '../graph/diagnostics.js'; +import { + buildRealityView, + DECLARED_KINDS, + OBSERVED_KINDS, + type IntentRealityView, +} from './reality.js'; + +export type CapabilityEvidenceState = + | 'declared' + | 'observed' + | 'configuration-only' + | 'unknown'; + +export type CapabilityEvidenceGrade = + | 'runtime_verified_code' + | 'code' + | 'configuration' + | 'declared_only' + | 'stale' + | 'missing_extraction' + | 'unknown'; + +export type CapabilityCompatibility = + | 'compatible_verified' + | 'code_without_runtime_verification' + | 'configuration_only' + | 'behavior_mismatch' + | 'effect_or_platform_mismatch' + | 'missing_extraction' + | 'stale_evidence' + | 'intentional_divergence' + | 'declared_not_implemented' + | 'unknown'; + +export type CapabilityGapKind = + | 'BEHAVIOR_CONTRACT_MISMATCH' + | 'EFFECT_OR_PLATFORM_INCOMPATIBLE' + | 'EXTRACTION_INCOMPLETE' + | 'STALE_EVIDENCE_HASH' + | 'RUNTIME_VERIFICATION_MISSING' + | 'IMPLEMENTATION_MISSING' + | 'CONFIGURATION_ONLY_EVIDENCE'; + +export interface IntractScanEvidenceRow { + file: string; + line: number; + end_line?: number; + span?: { start_line: number; end_line: number }; + scope: string; + intent: string; + priority?: number; + domain?: string; + inputs?: string[]; + outputs?: string[]; + effects?: string[]; + forbidden?: string[]; + preconditions?: string[]; + validators?: string[]; + symbol_id?: string; + symbol?: string | null; + symbol_kind?: string; + contract_hash?: string; + source_hash?: string | null; + basis?: 'declared' | 'inferred'; + source_verified?: boolean; + source_binding?: { + status: 'verified' | 'stale' | 'missing' | 'unknown'; + verified: boolean; + symbol?: string | null; + symbol_id?: string; + span?: { start_line: number; end_line: number }; + source_hash?: string | null; + }; +} + +export interface RepositoryCapabilityBundle { + repository: string; + revision: string; + sourceHash: string; + expectedSourceHash?: string; + extractionComplete?: boolean; + runtimeVerified?: boolean; + platform?: string; + graph: IntentGraph; + diagnostics?: DiagnosticReport; + reality?: IntentRealityView; + truthMap?: TruthMap; + intractScanRows?: IntractScanEvidenceRow[]; + intentionalDivergences?: string[]; +} + +export interface CapabilityCitations { + recordIds: string[]; + assertionIds: string[]; + relationIds: string[]; + realityRowKeys: string[]; + intractSymbolIds: string[]; +} + +export interface CapabilityProvenance { + repository: string; + revision: string; + sourceHash: string; + graphFingerprint: string; + realityFingerprint: string; + truthMapFingerprint: string; + citations: CapabilityCitations; +} + +export interface CapabilityContractDescriptor { + capability: string; + action: string; + object: string; + domain: string; + inputs: string[]; + outputs: string[]; + effects: string[]; + forbidden: string[]; + preconditions: string[]; + platform: string; + behaviorSignature: string; +} + +export interface CapabilityComparisonPair { + capability: string; + leftRepository: string; + rightRepository: string; + contractMapping: { + left: CapabilityContractDescriptor; + right: CapabilityContractDescriptor; + inputsMatch: boolean; + outputsMatch: boolean; + effectsCompatible: boolean; + platformsCompatible: boolean; + behaviorMatches: boolean; + }; + leftProvenance: CapabilityProvenance; + rightProvenance: CapabilityProvenance; + leftState: CapabilityEvidenceState; + rightState: CapabilityEvidenceState; + leftGrade: CapabilityEvidenceGrade; + rightGrade: CapabilityEvidenceGrade; + compatibility: CapabilityCompatibility; + interchangeable: boolean; + preservedIntentionalDivergence: boolean; +} + +export interface TypedCapabilityGapCandidate { + id: string; + kind: CapabilityGapKind; + capability: string; + sourceRepository: string; + targetRepository: string; + sourceRevision: string; + targetRevision: string; + sourceHash: string; + targetHash: string; + evidenceState: CapabilityEvidenceState; + evidenceGrade: CapabilityEvidenceGrade; + citations: { + source: CapabilityCitations; + target: CapabilityCitations; + }; + explanation: string; + preservedIntentionalDivergence: boolean; +} + +export interface HashBoundReviewProposal { + schemaVersion: 't2c.capability-review-proposal/v1'; + proposalId: string; + proposalHash: string; + boundBundleHashes: Record; + boundGraphFingerprints: Record; + requiresHumanOrControllerApproval: true; + mutatesSource: false; + createsTickets: false; + claimsEquivalenceFromSimilarityAlone: false; + candidateGapIds: string[]; +} + +export interface CrossRepoCapabilityComparisonReport { + schemaVersion: 't2c.capability-comparison/v1'; + realitySchemaVersion: 't2c.reality/v1'; + truthMapSchemaVersion: 't2c.truth-map/v1'; + generatedAt: string; + bundleFingerprints: Record; + pairs: CapabilityComparisonPair[]; + gapCandidates: TypedCapabilityGapCandidate[]; + reviewProposal: HashBoundReviewProposal; +} + +interface ExtractedCapabilitySide { + capability: string; + contract: CapabilityContractDescriptor; + provenance: CapabilityProvenance; + state: CapabilityEvidenceState; + grade: CapabilityEvidenceGrade; + stale: boolean; + missingExtraction: boolean; + intentionallyDivergent: boolean; +} + +interface ResolveStateParams { + isMissingExtraction: boolean; + isStale: boolean; + hasCodeFact: boolean; + hasConfigFact: boolean; + hasDeclared: boolean; + runtimeVerified: boolean; +} + +interface ExtractSideParams { + capability: string; + records: IntentRecord[]; + rows: IntractScanEvidenceRow[]; + bundle: RepositoryCapabilityBundle; + reality: IntentRealityView; + truthMap: TruthMap; + divergentSet: Set; + bundleStale: boolean; + bundleMissingExtraction: boolean; +} + +interface CreateGapParams { + kind: CapabilityGapKind; + capability: string; + source: ExtractedCapabilitySide; + target: ExtractedCapabilitySide; + explanation: string; + preservedIntentionalDivergence: boolean; +} + +interface ClassifyCompatParams { + left: ExtractedCapabilitySide; + right: ExtractedCapabilitySide; + effectsCompatible: boolean; + platformsCompatible: boolean; + behaviorMatches: boolean; + preservedIntentionalDivergence: boolean; +} + +function normalizeToken(value: string) { + return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '.').replace(/^\.+|\.+$/g, ''); +} + +function uniqueSorted(values: Iterable) { + return [...new Set([...values].map((item) => item.trim()).filter(Boolean))].sort(); +} + +function stringListFromMetadata(record: IntentRecord, key: string) { + const raw = record.metadata?.[key]; + if (Array.isArray(raw)) { + return uniqueSorted(raw.filter((item): item is string => typeof item === 'string')); + } + if (typeof raw === 'string' && raw.trim()) { + return uniqueSorted(raw.split(',')); + } + const empty: string[] = []; + return empty; +} + +function capabilityNamesForRecord(record: IntentRecord) { + const explicit = stringListFromMetadata(record, 'capabilities'); + if (explicit.length > 0) { + return explicit.map(normalizeToken).filter(Boolean); + } + const intentMeta = record.metadata?.intent; + if (typeof intentMeta === 'string' && intentMeta.trim()) { + return [normalizeToken(intentMeta)]; + } + const action = normalizeToken(record.statement.action); + const obj = normalizeToken(record.statement.object); + if (action && obj) { + return [`${action}.${obj}`]; + } + const empty: string[] = []; + return empty; +} + +function isConfigurationRecord(record: IntentRecord) { + return ( + record.source.kind === 'system' + || record.statement.kind === 'configuration_file_fact' + || record.metadata?.aggregate === 'configuration-file' + ); +} + +function resolveEvidenceStateAndGrade(params: ResolveStateParams) { + let state: CapabilityEvidenceState = 'unknown'; + if (!params.isMissingExtraction) { + if (params.hasCodeFact) state = 'observed'; + else if (params.hasConfigFact) state = 'configuration-only'; + else if (params.hasDeclared) state = 'declared'; + } + let grade: CapabilityEvidenceGrade = 'unknown'; + if (params.isMissingExtraction) grade = 'missing_extraction'; + else if (params.isStale) grade = 'stale'; + else if (params.hasCodeFact) grade = params.runtimeVerified ? 'runtime_verified_code' : 'code'; + else if (params.hasConfigFact) grade = 'configuration'; + else if (params.hasDeclared) grade = 'declared_only'; + return { state, grade }; +} + +function collectContractField( + records: IntentRecord[], + rows: IntractScanEvidenceRow[], + key: 'inputs' | 'outputs' | 'effects' | 'forbidden' | 'preconditions', +) { + return uniqueSorted([ + ...records.flatMap((r) => stringListFromMetadata(r, key)), + ...rows.flatMap((r) => r[key] ?? []), + ]); +} + +function buildCapabilityContract( + capability: string, + records: IntentRecord[], + rows: IntractScanEvidenceRow[], + platformRaw?: string, +) { + const inputs = collectContractField(records, rows, 'inputs'); + const outputs = collectContractField(records, rows, 'outputs'); + const primaryRecord = records[0]; + const parts = capability.split('.'); + const action = parts.length >= 2 && parts[0] + ? parts[0] + : (primaryRecord ? normalizeToken(primaryRecord.statement.action) : 'implement'); + const obj = parts.length >= 2 && parts.slice(1).join('.') + ? parts.slice(1).join('.') + : (primaryRecord ? normalizeToken(primaryRecord.statement.object) : capability); + const domain = uniqueSorted([ + ...records.flatMap((r) => stringListFromMetadata(r, 'domain')), + ...rows.map((r) => r.domain ?? ''), + ])[0] ?? 'general'; + const explicitBehavior = uniqueSorted(records.flatMap((r) => stringListFromMetadata(r, 'behavior'))); + const behaviorSignature = explicitBehavior.length > 0 + ? explicitBehavior.join('|') + : `${action}:${obj}:in(${inputs.join(',')})->out(${outputs.join(',')})`; + const contract: CapabilityContractDescriptor = { + capability, + action, + object: obj, + domain, + inputs, + outputs, + effects: collectContractField(records, rows, 'effects'), + forbidden: collectContractField(records, rows, 'forbidden'), + preconditions: collectContractField(records, rows, 'preconditions'), + platform: (platformRaw ?? 'agnostic').trim().toLowerCase(), + behaviorSignature, + }; + return contract; +} + +function extractSingleCapabilitySide(params: ExtractSideParams) { + const { capability, records, rows, bundle, reality, truthMap } = params; + const recordIdSet = new Set(records.map((r) => r.id)); + const assertionIds = truthMap.assertions + .filter((assertion) => assertion.recordIds.some((id) => recordIdSet.has(id))) + .map((assertion) => assertion.id) + .sort(); + const relationIds = bundle.graph.relations + .filter((rel) => recordIdSet.has(rel.from) || recordIdSet.has(rel.to)) + .map((rel) => rel.id) + .sort(); + const realityRowKeys = reality.rows + .filter((row) => row.recordIds.some((id) => recordIdSet.has(id))) + .map((row) => row.key) + .sort(); + const intractSymbolIds = uniqueSorted(rows.map((r) => r.symbol_id ?? '').filter(Boolean)); + const hasCodeFact = records.some( + (r) => OBSERVED_KINDS.includes(r.source.kind) && !isConfigurationRecord(r), + ) || rows.some((r) => r.basis !== 'inferred' && r.source_binding?.status === 'verified'); + const hasConfigFact = records.some((r) => isConfigurationRecord(r)); + const hasDeclared = records.some((r) => DECLARED_KINDS.includes(r.source.kind)) + || rows.some((r) => r.basis === 'declared'); + const isStale = params.bundleStale || rows.some((r) => r.source_binding?.status === 'stale'); + const isMissingExtraction = params.bundleMissingExtraction + || rows.some((r) => r.source_binding?.status === 'missing'); + const { state, grade } = resolveEvidenceStateAndGrade({ + isMissingExtraction, + isStale, + hasCodeFact, + hasConfigFact, + hasDeclared, + runtimeVerified: Boolean(bundle.runtimeVerified), + }); + const intentionallyDivergent = params.divergentSet.has(capability) + || records.some((r) => r.metadata?.intentionalDivergence === true); + + const side: ExtractedCapabilitySide = { + capability, + contract: buildCapabilityContract(capability, records, rows, bundle.platform), + provenance: { + repository: bundle.repository, + revision: bundle.revision, + sourceHash: bundle.sourceHash, + graphFingerprint: bundle.graph.fingerprint, + realityFingerprint: reality.fingerprint, + truthMapFingerprint: truthMap.fingerprint, + citations: { + recordIds: [...recordIdSet].sort(), + assertionIds, + relationIds, + realityRowKeys, + intractSymbolIds, + }, + }, + state, + grade, + stale: isStale, + missingExtraction: isMissingExtraction, + intentionallyDivergent, + }; + return side; +} + +function resolveBundleProjections(bundle: RepositoryCapabilityBundle, generatedAt: string) { + const diagnostics = bundle.diagnostics ? bundle.diagnostics : diagnoseGraph(bundle.graph, generatedAt); + const reality = bundle.reality ? bundle.reality : buildRealityView(bundle.graph, diagnostics, generatedAt); + const truthMap = bundle.truthMap ? bundle.truthMap : projectTruthMap(bundle.graph, generatedAt); + const divergentList = bundle.intentionalDivergences ? bundle.intentionalDivergences : []; + const divergentSet = new Set(divergentList.map(normalizeToken)); + const bundleStale = Boolean(bundle.expectedSourceHash && bundle.expectedSourceHash !== bundle.sourceHash); + const bundleMissingExtraction = bundle.extractionComplete === false; + return { reality, truthMap, divergentSet, bundleStale, bundleMissingExtraction }; +} + +function groupRecordsByCapability(records: IntentRecord[]) { + const grouped = new Map(); + for (const record of records) { + for (const cap of capabilityNamesForRecord(record)) { + const list = grouped.get(cap); + if (list) list.push(record); + else grouped.set(cap, [record]); + } + } + return grouped; +} + +function groupScanRowsByCapability(scanRows?: IntractScanEvidenceRow[]) { + const grouped = new Map(); + for (const row of scanRows ? scanRows : []) { + const cap = normalizeToken(row.intent); + if (!cap) continue; + const list = grouped.get(cap); + if (list) list.push(row); + else grouped.set(cap, [row]); + } + return grouped; +} + +function extractCapabilitiesFromBundle(bundle: RepositoryCapabilityBundle, generatedAt: string) { + assertIntentGraph(bundle.graph); + const projections = resolveBundleProjections(bundle, generatedAt); + const groupedRecords = groupRecordsByCapability(bundle.graph.records); + const groupedScanRows = groupScanRowsByCapability(bundle.intractScanRows); + const allCapabilities = uniqueSorted([...groupedRecords.keys(), ...groupedScanRows.keys()]); + const output = new Map(); + for (const capability of allCapabilities) { + const records = groupedRecords.get(capability); + const rows = groupedScanRows.get(capability); + output.set( + capability, + extractSingleCapabilitySide({ + capability, + records: records ? records : [], + rows: rows ? rows : [], + bundle, + ...projections, + }), + ); + } + return output; +} + +function unknownSide(capability: string, bundle: RepositoryCapabilityBundle, generatedAt: string) { + const projections = resolveBundleProjections(bundle, generatedAt); + const missingExtraction = projections.bundleMissingExtraction || bundle.graph.records.length === 0; + const stale = projections.bundleStale; + const parts = capability.split('.'); + const grade: CapabilityEvidenceGrade = missingExtraction ? 'missing_extraction' : stale ? 'stale' : 'unknown'; + const side: ExtractedCapabilitySide = { + capability, + contract: { + capability, + action: parts[0] ? parts[0] : 'unknown', + object: parts.slice(1).join('.') || capability, + domain: 'unknown', + inputs: [], + outputs: [], + effects: [], + forbidden: [], + preconditions: [], + platform: (bundle.platform ? bundle.platform : 'agnostic').trim().toLowerCase(), + behaviorSignature: 'unknown', + }, + provenance: { + repository: bundle.repository, + revision: bundle.revision, + sourceHash: bundle.sourceHash, + graphFingerprint: bundle.graph.fingerprint, + realityFingerprint: projections.reality.fingerprint, + truthMapFingerprint: projections.truthMap.fingerprint, + citations: { + recordIds: [], + assertionIds: [], + relationIds: [], + realityRowKeys: [], + intractSymbolIds: [], + }, + }, + state: 'unknown', + grade, + stale, + missingExtraction, + intentionallyDivergent: projections.divergentSet.has(capability), + }; + return side; +} + +function arraysEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((val, idx) => val === right[idx]); +} + +function effectsAreCompatible(left: CapabilityContractDescriptor, right: CapabilityContractDescriptor) { + const leftViolatesRight = left.effects.some((fx) => right.forbidden.includes(fx)); + const rightViolatesLeft = right.effects.some((fx) => left.forbidden.includes(fx)); + if (leftViolatesRight || rightViolatesLeft) return false; + if (left.effects.length > 0 && right.effects.length > 0 && !arraysEqual(left.effects, right.effects)) { + return false; + } + return true; +} + +function platformsAreCompatible(leftPlatform: string, rightPlatform: string) { + if (leftPlatform === 'agnostic' || rightPlatform === 'agnostic') return true; + return leftPlatform === rightPlatform; +} + +function createGapCandidate(params: CreateGapParams) { + const payload = { + kind: params.kind, + capability: params.capability, + sourceRepository: params.source.provenance.repository, + targetRepository: params.target.provenance.repository, + sourceRevision: params.source.provenance.revision, + targetRevision: params.target.provenance.revision, + sourceHash: params.source.provenance.sourceHash, + targetHash: params.target.provenance.sourceHash, + }; + const candidate: TypedCapabilityGapCandidate = { + id: `GAP-${sha256(stableStringify(payload)).slice(0, 16)}`, + ...payload, + evidenceState: params.target.state, + evidenceGrade: params.target.grade, + citations: { + source: params.source.provenance.citations, + target: params.target.provenance.citations, + }, + explanation: params.explanation, + preservedIntentionalDivergence: params.preservedIntentionalDivergence, + }; + return candidate; +} + +const COMPATIBILITY_TO_GAP_KIND: Partial> = { + behavior_mismatch: 'BEHAVIOR_CONTRACT_MISMATCH', + effect_or_platform_mismatch: 'EFFECT_OR_PLATFORM_INCOMPATIBLE', + missing_extraction: 'EXTRACTION_INCOMPLETE', + stale_evidence: 'STALE_EVIDENCE_HASH', + code_without_runtime_verification: 'RUNTIME_VERIFICATION_MISSING', + declared_not_implemented: 'IMPLEMENTATION_MISSING', + configuration_only: 'CONFIGURATION_ONLY_EVIDENCE', +}; + +function validateDistinctBundles(bundles: RepositoryCapabilityBundle[]) { + if (bundles.length < 2) { + throw new Error('At least two distinct repository evidence bundles are required for comparison.'); + } + const seenRepos = new Set(); + for (const bundle of bundles) { + if (!bundle.repository.trim()) { + throw new Error('Repository identity must not be blank.'); + } + if (seenRepos.has(bundle.repository)) { + throw new Error(`Duplicate repository identity in cross-project comparison: ${bundle.repository}`); + } + seenRepos.add(bundle.repository); + } +} + +function classifyNonStructuralState(left: ExtractedCapabilitySide, right: ExtractedCapabilitySide) { + if (left.state === 'configuration-only' || right.state === 'configuration-only') { + const res: CapabilityCompatibility = 'configuration_only'; + return res; + } + if (left.state === 'declared' || right.state === 'declared') { + const res: CapabilityCompatibility = 'declared_not_implemented'; + return res; + } + if (left.state === 'unknown' || right.state === 'unknown') { + const res: CapabilityCompatibility = 'unknown'; + return res; + } + if (left.grade !== 'runtime_verified_code' || right.grade !== 'runtime_verified_code') { + const res: CapabilityCompatibility = 'code_without_runtime_verification'; + return res; + } + const res: CapabilityCompatibility = 'compatible_verified'; + return res; +} + +function classifyCapabilityCompatibility(params: ClassifyCompatParams) { + const { left, right } = params; + if (params.preservedIntentionalDivergence) return 'intentional_divergence' as const; + if (left.missingExtraction || right.missingExtraction) return 'missing_extraction' as const; + if (left.stale || right.stale) return 'stale_evidence' as const; + if (!params.effectsCompatible || !params.platformsCompatible) return 'effect_or_platform_mismatch' as const; + if (!params.behaviorMatches) return 'behavior_mismatch' as const; + return classifyNonStructuralState(left, right); +} + +function compareCapabilityPair( + capability: string, + left: ExtractedCapabilitySide, + right: ExtractedCapabilitySide, +) { + const inputsMatch = arraysEqual(left.contract.inputs, right.contract.inputs); + const outputsMatch = arraysEqual(left.contract.outputs, right.contract.outputs); + const effectsCompatible = effectsAreCompatible(left.contract, right.contract); + const platformsCompatible = platformsAreCompatible(left.contract.platform, right.contract.platform); + const behaviorMatches = + left.contract.behaviorSignature !== 'unknown' + && left.contract.behaviorSignature === right.contract.behaviorSignature + && inputsMatch + && outputsMatch; + const preservedIntentionalDivergence = left.intentionallyDivergent || right.intentionallyDivergent; + const compatibility = classifyCapabilityCompatibility({ + left, + right, + effectsCompatible, + platformsCompatible, + behaviorMatches, + preservedIntentionalDivergence, + }); + const pair: CapabilityComparisonPair = { + capability, + leftRepository: left.provenance.repository, + rightRepository: right.provenance.repository, + contractMapping: { + left: left.contract, + right: right.contract, + inputsMatch, + outputsMatch, + effectsCompatible, + platformsCompatible, + behaviorMatches, + }, + leftProvenance: left.provenance, + rightProvenance: right.provenance, + leftState: left.state, + rightState: right.state, + leftGrade: left.grade, + rightGrade: right.grade, + compatibility, + interchangeable: compatibility === 'compatible_verified', + preservedIntentionalDivergence, + }; + const gapKind = !preservedIntentionalDivergence ? COMPATIBILITY_TO_GAP_KIND[compatibility] : undefined; + if (!gapKind) return { pair, gap: undefined }; + const target = right.grade === 'runtime_verified_code' ? left : right; + const source = target === right ? left : right; + const gap = createGapCandidate({ + kind: gapKind, + capability, + source, + target, + explanation: `Capability '${capability}' between ${left.provenance.repository} and ${right.provenance.repository} classified as '${compatibility}' (left=${left.state}/${left.grade}, right=${right.state}/${right.grade}).`, + preservedIntentionalDivergence: false, + }); + return { pair, gap }; +} + +export function compareCapabilityEvidenceBundles( + bundles: RepositoryCapabilityBundle[], + generatedAt = new Date().toISOString(), +) { + validateDistinctBundles(bundles); + const extractedByRepo = new Map>(); + const bundleFingerprints: Record = {}; + const boundBundleHashes: Record = {}; + const boundGraphFingerprints: Record = {}; + + for (const bundle of bundles) { + extractedByRepo.set(bundle.repository, extractCapabilitiesFromBundle(bundle, generatedAt)); + boundBundleHashes[bundle.repository] = bundle.sourceHash; + boundGraphFingerprints[bundle.repository] = bundle.graph.fingerprint; + bundleFingerprints[bundle.repository] = sha256( + stableStringify({ + repository: bundle.repository, + revision: bundle.revision, + sourceHash: bundle.sourceHash, + graphFingerprint: bundle.graph.fingerprint, + }), + ); + } + + const pairs: CapabilityComparisonPair[] = []; + const gapCandidates: TypedCapabilityGapCandidate[] = []; + + for (let i = 0; i < bundles.length; i += 1) { + for (let j = i + 1; j < bundles.length; j += 1) { + const leftBundle = bundles[i]!; + const rightBundle = bundles[j]!; + const leftMap = extractedByRepo.get(leftBundle.repository)!; + const rightMap = extractedByRepo.get(rightBundle.repository)!; + const capabilities = uniqueSorted([...leftMap.keys(), ...rightMap.keys()]); + for (const capability of capabilities) { + const left = leftMap.get(capability) ?? unknownSide(capability, leftBundle, generatedAt); + const right = rightMap.get(capability) ?? unknownSide(capability, rightBundle, generatedAt); + const { pair, gap } = compareCapabilityPair(capability, left, right); + pairs.push(pair); + if (gap) gapCandidates.push(gap); + } + } + } + + const candidateGapIds = gapCandidates.map((gap) => gap.id); + const proposalHash = sha256( + stableStringify({ + boundBundleHashes, + boundGraphFingerprints, + candidateGapIds, + pairs: pairs.map((p) => [p.capability, p.leftRepository, p.rightRepository, p.compatibility, p.interchangeable]), + }), + ); + + const report: CrossRepoCapabilityComparisonReport = { + schemaVersion: 't2c.capability-comparison/v1', + realitySchemaVersion: 't2c.reality/v1', + truthMapSchemaVersion: 't2c.truth-map/v1', + generatedAt, + bundleFingerprints, + pairs, + gapCandidates, + reviewProposal: { + schemaVersion: 't2c.capability-review-proposal/v1', + proposalId: `PROP-${proposalHash.slice(0, 16)}`, + proposalHash, + boundBundleHashes, + boundGraphFingerprints, + requiresHumanOrControllerApproval: true, + mutatesSource: false, + createsTickets: false, + claimsEquivalenceFromSimilarityAlone: false, + candidateGapIds, + }, + }; + return report; +} diff --git a/test/diff-capability-comparison.test.ts b/test/diff-capability-comparison.test.ts new file mode 100644 index 00000000..b4061e62 --- /dev/null +++ b/test/diff-capability-comparison.test.ts @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildRecord } from '../src/core/record.js'; +import type { IntentAction, IntentRecord } from '../src/core/types.js'; +import { + compareCapabilityEvidenceBundles, + type RepositoryCapabilityBundle, +} from '../src/diff/capability-comparison.js'; +import { linkIntentRecords } from '../src/graph/linker.js'; + +const GENERATED_AT = '2026-10-27T21:00:00.000Z'; + +function declaredRecord( + capability: string, + opts: { + inputs?: string[]; + outputs?: string[]; + effects?: string[]; + forbidden?: string[]; + behavior?: string[]; + intentionalDivergence?: boolean; + } = {}, +): IntentRecord { + const [, object = capability] = capability.split('.'); + return buildRecord({ + kind: 'declared_intent', + action: 'validate', + object, + text: `Declare ${capability}`, + target: { paths: ['src/auth.ts'], symbols: ['verifyPermission'], tickets: [], versions: [] }, + lifecycle: 'proposed', + sourceKind: 'nl', + sourcePath: 'TASK.md', + sourceLines: { start: 1, end: 1 }, + extractor: 'test', + epistemicClass: 'declaration', + confidence: 1, + basis: ['test'], + metadata: { + capabilities: [capability], + inputs: opts.inputs ?? ['user', 'token'], + outputs: opts.outputs ?? ['bool'], + effects: opts.effects ?? ['read'], + forbidden: opts.forbidden ?? ['network'], + ...(opts.behavior ? { behavior: opts.behavior } : {}), + ...(opts.intentionalDivergence ? { intentionalDivergence: true } : {}), + }, + }); +} + +function codeFactRecord( + capability: string, + opts: { + inputs?: string[]; + outputs?: string[]; + effects?: string[]; + forbidden?: string[]; + behavior?: string[]; + } = {}, +): IntentRecord { + const [, object = capability] = capability.split('.'); + return buildRecord({ + kind: 'implemented_fact', + action: 'validate', + object, + text: `Implement ${capability}`, + target: { paths: ['src/auth.ts'], symbols: ['verifyPermission'], tickets: [], versions: [] }, + lifecycle: 'implemented', + sourceKind: 'ast', + sourcePath: 'src/auth.ts', + sourceLines: { start: 10, end: 25 }, + extractor: 'test', + epistemicClass: 'fact', + confidence: 1, + basis: ['test'], + metadata: { + capabilities: [capability], + inputs: opts.inputs ?? ['user', 'token'], + outputs: opts.outputs ?? ['bool'], + effects: opts.effects ?? ['read'], + forbidden: opts.forbidden ?? ['network'], + ...(opts.behavior ? { behavior: opts.behavior } : {}), + }, + }); +} + +function configFactRecord(capability: string): IntentRecord { + const [, object = capability] = capability.split('.'); + return buildRecord({ + kind: 'configuration_file_fact', + action: 'configure', + object, + text: `Configure ${capability}`, + target: { paths: ['config/policy.json'], symbols: [], tickets: [], versions: [] }, + lifecycle: 'implemented', + sourceKind: 'system', + sourcePath: 'config/policy.json', + sourceLines: { start: 1, end: 5 }, + extractor: 'test', + epistemicClass: 'fact', + confidence: 1, + basis: ['test'], + metadata: { + aggregate: 'configuration-file', + capabilities: [capability], + inputs: ['config'], + outputs: ['policy'], + effects: ['none'], + }, + }); +} + +function makeBundle( + repository: string, + records: IntentRecord[], + overrides: Partial = {}, +): RepositoryCapabilityBundle { + const marker = repository.includes('alpha') ? 'a' : 'b'; + return { + repository, + revision: marker.repeat(40), + sourceHash: marker.repeat(64), + extractionComplete: true, + runtimeVerified: true, + platform: 'linux-node', + graph: linkIntentRecords(records, GENERATED_AT), + ...overrides, + }; +} + +test('compares capability pairs across distinct repositories with explicit provenance and states', () => { + const left = makeBundle( + 'owner/alpha', + [ + declaredRecord('validate.permission'), + codeFactRecord('validate.permission'), + configFactRecord('configure.policy'), + ], + { + intractScanRows: [ + { + file: 'src/auth.ts', + line: 10, + end_line: 25, + scope: 'function', + intent: 'validate.permission', + inputs: ['token', 'user'], + outputs: ['bool'], + effects: ['read'], + forbidden: ['network'], + symbol_id: 'fn.src.auth.ts.verify.permission', + contract_hash: '1'.repeat(64), + source_hash: '2'.repeat(64), + basis: 'declared', + source_binding: { status: 'verified', verified: true }, + }, + ], + }, + ); + const right = makeBundle( + 'owner/beta', + [ + declaredRecord('validate.permission'), + codeFactRecord('validate.permission'), + declaredRecord('configure.policy', { inputs: ['config'], outputs: ['policy'], effects: ['none'] }), + ], + ); + + const report = compareCapabilityEvidenceBundles([left, right], GENERATED_AT); + + assert.equal(report.schemaVersion, 't2c.capability-comparison/v1'); + assert.equal(report.realitySchemaVersion, 't2c.reality/v1'); + assert.equal(report.truthMapSchemaVersion, 't2c.truth-map/v1'); + assert.equal(report.pairs.length, 2); + + const permPair = report.pairs.find((p) => p.capability === 'validate.permission'); + assert.ok(permPair); + assert.equal(permPair.compatibility, 'compatible_verified'); + assert.equal(permPair.interchangeable, true); + assert.equal(permPair.leftState, 'observed'); + assert.equal(permPair.rightState, 'observed'); + assert.equal(permPair.leftGrade, 'runtime_verified_code'); + assert.equal(permPair.rightGrade, 'runtime_verified_code'); + assert.deepEqual(permPair.leftProvenance.citations.intractSymbolIds, ['fn.src.auth.ts.verify.permission']); + assert.ok(permPair.leftProvenance.citations.recordIds.length >= 2); + assert.ok(permPair.leftProvenance.citations.assertionIds.length >= 1); + + const configPair = report.pairs.find((p) => p.capability === 'configure.policy'); + assert.ok(configPair); + assert.equal(configPair.leftState, 'configuration-only'); + assert.equal(configPair.rightState, 'declared'); + assert.equal(configPair.compatibility, 'configuration_only'); + assert.equal(configPair.interchangeable, false); + + assert.equal(report.reviewProposal.mutatesSource, false); + assert.equal(report.reviewProposal.createsTickets, false); + assert.equal(report.reviewProposal.claimsEquivalenceFromSimilarityAlone, false); + assert.equal(report.reviewProposal.proposalHash.length, 64); +}); + +test('negative fixtures: behavior mismatch, effects/platform mismatch, missing extraction, stale hash, intentional divergence, and unverified runtime', () => { + // 1. Same word ("validate.permission") but different behavior / I/O contract + const behaviorLeft = makeBundle('owner/alpha', [ + codeFactRecord('validate.permission', { inputs: ['user', 'token'], outputs: ['bool'], behavior: ['rbac_strict'] }), + ]); + const behaviorRight = makeBundle('owner/beta', [ + codeFactRecord('validate.permission', { inputs: ['session_cookie'], outputs: ['claims_map'], behavior: ['cookie_lenient'] }), + ]); + const behaviorReport = compareCapabilityEvidenceBundles([behaviorLeft, behaviorRight], GENERATED_AT); + assert.equal(behaviorReport.pairs[0]?.compatibility, 'behavior_mismatch'); + assert.equal(behaviorReport.pairs[0]?.interchangeable, false); + assert.equal(behaviorReport.gapCandidates[0]?.kind, 'BEHAVIOR_CONTRACT_MISMATCH'); + + // 2. Different effects / platforms (left forbids network; right uses network effect and different platform) + const effectLeft = makeBundle( + 'owner/alpha', + [codeFactRecord('sync.state', { effects: ['read'], forbidden: ['network'] })], + { platform: 'linux-python' }, + ); + const effectRight = makeBundle( + 'owner/beta', + [codeFactRecord('sync.state', { effects: ['network'], forbidden: [] })], + { platform: 'browser-wasm' }, + ); + const effectReport = compareCapabilityEvidenceBundles([effectLeft, effectRight], GENERATED_AT); + assert.equal(effectReport.pairs[0]?.compatibility, 'effect_or_platform_mismatch'); + assert.equal(effectReport.pairs[0]?.interchangeable, false); + assert.equal(effectReport.gapCandidates[0]?.kind, 'EFFECT_OR_PLATFORM_INCOMPATIBLE'); + + // 3. Missing extraction + const missingLeft = makeBundle('owner/alpha', [codeFactRecord('index.intents')]); + const missingRight = makeBundle('owner/beta', [], { extractionComplete: false }); + const missingReport = compareCapabilityEvidenceBundles([missingLeft, missingRight], GENERATED_AT); + assert.equal(missingReport.pairs[0]?.rightState, 'unknown'); + assert.equal(missingReport.pairs[0]?.rightGrade, 'missing_extraction'); + assert.equal(missingReport.pairs[0]?.compatibility, 'missing_extraction'); + assert.equal(missingReport.gapCandidates[0]?.kind, 'EXTRACTION_INCOMPLETE'); + + // 4. Stale hash + const staleLeft = makeBundle('owner/alpha', [codeFactRecord('index.intents')]); + const staleRight = makeBundle('owner/beta', [codeFactRecord('index.intents')], { + sourceHash: 'b'.repeat(64), + expectedSourceHash: 'f'.repeat(64), + }); + const staleReport = compareCapabilityEvidenceBundles([staleLeft, staleRight], GENERATED_AT); + assert.equal(staleReport.pairs[0]?.rightGrade, 'stale'); + assert.equal(staleReport.pairs[0]?.compatibility, 'stale_evidence'); + assert.equal(staleReport.gapCandidates[0]?.kind, 'STALE_EVIDENCE_HASH'); + + // 5. Intentionally divergent requirement (preserved without remediation gap) + const divLeft = makeBundle('owner/alpha', [codeFactRecord('audit.retention')]); + const divRight = makeBundle( + 'owner/beta', + [codeFactRecord('audit.retention', { outputs: ['ephemeral_summary'] })], + { intentionalDivergences: ['audit.retention'] }, + ); + const divReport = compareCapabilityEvidenceBundles([divLeft, divRight], GENERATED_AT); + assert.equal(divReport.pairs[0]?.compatibility, 'intentional_divergence'); + assert.equal(divReport.pairs[0]?.preservedIntentionalDivergence, true); + assert.equal(divReport.pairs[0]?.interchangeable, false); + assert.equal(divReport.gapCandidates.length, 0); + + // 6. Code present without runtime verification + const unverifiedLeft = makeBundle('owner/alpha', [codeFactRecord('validate.permission')], { + runtimeVerified: true, + }); + const unverifiedRight = makeBundle('owner/beta', [codeFactRecord('validate.permission')], { + runtimeVerified: false, + }); + const unverifiedReport = compareCapabilityEvidenceBundles([unverifiedLeft, unverifiedRight], GENERATED_AT); + assert.equal(unverifiedReport.pairs[0]?.rightState, 'observed'); + assert.equal(unverifiedReport.pairs[0]?.rightGrade, 'code'); + assert.equal(unverifiedReport.pairs[0]?.compatibility, 'code_without_runtime_verification'); + assert.equal(unverifiedReport.pairs[0]?.interchangeable, false); + assert.equal(unverifiedReport.gapCandidates[0]?.kind, 'RUNTIME_VERIFICATION_MISSING'); +});