import type { Finding } from '../../core/gate/Finding'; import type { GateOutcome } from '../../core/gate/GateOutcome'; import type { GateStage } from '../../core/gate/GateStage'; import type { Severity } from '../../core/rules/Severity'; import type { AiEvidenceV2_1, ConsolidationSuppressedFinding, CompatibilityViolation, EvidenceLines, EvidenceOperationalHints, HumanIntentState, LedgerEntry, PlatformState, RepoState, RulesetState, SnapshotEvaluationMetrics, SnapshotRulesCoverage, SddMetrics, SnapshotFinding, SnapshotFindingNode, } from './schema'; import type { TddBddSnapshot } from '../tdd/types'; import { buildSnapshotPlatformSummaries } from './platformSummary'; import { resolveHumanIntent } from './humanIntent'; import { normalizeSnapshotEvaluationMetrics } from './evaluationMetrics'; import { normalizeSnapshotRulesCoverage } from './rulesCoverage'; import { buildEvidenceOperationalHints } from './operationalHints'; type BuildFindingInput = Finding & { file?: string; lines?: EvidenceLines; }; export type BuildEvidenceParams = { stage: GateStage; auditMode?: 'gate' | 'engine'; findings: ReadonlyArray; gateOutcome?: GateOutcome; filesScanned?: number; previousEvidence?: AiEvidenceV2_1; humanIntent?: HumanIntentState | null; detectedPlatforms: Record; loadedRulesets: ReadonlyArray; evaluationMetrics?: SnapshotEvaluationMetrics; rulesCoverage?: SnapshotRulesCoverage; tddBdd?: TddBddSnapshot; memoryShadow?: { recommended_outcome: GateOutcome; actual_outcome: GateOutcome; confidence: number; reason_codes: string[]; }; sddMetrics?: SddMetrics; repoState?: RepoState; operationalHintsExtra?: Partial< Pick >; }; const normalizeLines = (lines?: EvidenceLines): EvidenceLines | undefined => { if (typeof lines === 'undefined') { return undefined; } if (typeof lines === 'string') { const trimmed = lines.trim(); return trimmed.length > 0 ? trimmed : undefined; } if (typeof lines === 'number') { return Number.isFinite(lines) ? Math.trunc(lines) : undefined; } const normalized = Array.from( new Set(lines.filter((line) => Number.isFinite(line)).map((line) => Math.trunc(line))) ).sort((a, b) => a - b); return normalized.length > 0 ? normalized : undefined; }; const linesKey = (lines?: EvidenceLines): string => { if (typeof lines === 'undefined') { return ''; } if (typeof lines === 'string' || typeof lines === 'number') { return String(lines); } return lines.join(','); }; const findingKey = (finding: Pick): string => { return `${finding.ruleId}::${finding.file}::${linesKey(finding.lines)}`; }; const compareFindingEntries = ( left: Pick, right: Pick ): number => { return findingKey(left).localeCompare(findingKey(right), undefined, { numeric: true }); }; const ledgerKey = (entry: Pick): string => { return `${entry.ruleId}::${entry.file}::${linesKey(entry.lines)}`; }; const normalizeOptionalString = (value: unknown) => { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const normalizeOptionalNonNegativeInt = (value: unknown) => { if (typeof value !== 'number' || !Number.isFinite(value)) { return undefined; } return Math.max(0, Math.trunc(value)); }; const normalizeOptionalBoolean = (value: unknown): boolean | undefined => { return typeof value === 'boolean' ? value : undefined; }; const normalizeFindingNode = (node: unknown): SnapshotFindingNode | undefined => { if (typeof node !== 'object' || node === null) { return undefined; } const candidate = node as { kind?: unknown; name?: unknown; lines?: unknown; }; if ( candidate.kind !== 'class' && candidate.kind !== 'property' && candidate.kind !== 'call' && candidate.kind !== 'member' ) { return undefined; } const name = normalizeOptionalString(candidate.name); if (!name) { return undefined; } return { kind: candidate.kind, name, lines: normalizeLines(candidate.lines as EvidenceLines | undefined), }; }; const normalizeFindingNodes = (nodes: unknown): readonly SnapshotFindingNode[] | undefined => { if (!Array.isArray(nodes)) { return undefined; } const normalized = nodes .map((node) => normalizeFindingNode(node)) .filter((node): node is SnapshotFindingNode => Boolean(node)); return normalized.length > 0 ? normalized : undefined; }; const countFilesAffected = (findings: ReadonlyArray): number => { const files = new Set(); for (const finding of findings) { const file = finding.file.trim(); if (file.length === 0) { continue; } files.add(file); } return files.size; }; const normalizeFinding = (finding: BuildFindingInput): SnapshotFinding => { const file = (finding.filePath ?? finding.file ?? 'unknown').replace(/\\/g, '/'); return { ruleId: finding.ruleId, severity: finding.severity, code: finding.code, message: finding.message, file, lines: normalizeLines(finding.lines), matchedBy: normalizeOptionalString(finding.matchedBy), source: normalizeOptionalString(finding.source), blocking: normalizeOptionalBoolean(finding.blocking), primary_node: normalizeFindingNode(finding.primary_node), related_nodes: normalizeFindingNodes(finding.related_nodes), why: normalizeOptionalString(finding.why), impact: normalizeOptionalString(finding.impact), expected_fix: normalizeOptionalString(finding.expected_fix), }; }; const pickDeterministicDuplicateFinding = ( current: SnapshotFinding, candidate: SnapshotFinding ): SnapshotFinding => { const bySeverity = severityRank[candidate.severity] - severityRank[current.severity]; if (bySeverity > 0) { return candidate; } if (bySeverity < 0) { return current; } const tuple = (finding: SnapshotFinding): string => { return [ finding.code, finding.message, finding.matchedBy ?? '', finding.source ?? '', ].join('\u0000'); }; return tuple(candidate).localeCompare(tuple(current)) < 0 ? candidate : current; }; const severityRank: Record = { INFO: 0, WARN: 1, ERROR: 2, CRITICAL: 3, }; type RuleOrigin = 'project-rules' | 'skills' | 'platform-preset' | 'heuristics'; const ruleOriginRank: Record = { heuristics: 0, 'platform-preset': 1, skills: 2, 'project-rules': 3, }; const inferRuleOrigin = (ruleId: string): RuleOrigin => { if (ruleId.startsWith('heuristics.')) { return 'heuristics'; } if (ruleId.startsWith('skills.')) { return 'skills'; } if ( ruleId.startsWith('ios.') || ruleId.startsWith('android.') || ruleId.startsWith('backend.') || ruleId.startsWith('frontend.') ) { return 'platform-preset'; } return 'project-rules'; }; const equivalentRuleFamilies: ReadonlyArray> = [ ['ios.no-force-unwrap', 'skills.ios.no-force-unwrap', 'heuristics.ios.force-unwrap.ast'], ['skills.ios.no-force-try', 'heuristics.ios.force-try.ast'], ['skills.ios.no-force-cast', 'heuristics.ios.force-cast.ast'], ['ios.no-anyview', 'skills.ios.no-anyview', 'heuristics.ios.anyview.ast'], [ 'ios.no-completion-handlers-outside-bridges', 'skills.ios.no-callback-style-outside-bridges', 'heuristics.ios.callback-style.ast', ], ['ios.no-gcd', 'skills.ios.no-dispatchqueue', 'heuristics.ios.dispatchqueue.ast'], [ 'skills.ios.guideline.ios.mainactor-para-co-digo-que-debe-ejecutarse-en-el-hilo-principal', 'heuristics.ios.concurrency.ui-state-without-mainactor.ast', ], [ 'skills.ios.guideline.ios.actor-para-estado-compartido-thread-safe', 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast', ], [ 'skills.ios.guideline.ios.sincronizacio-n-nativa-actors-para-estado-compartido-osallocatedunfair', 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast', ], ['ios.no-gcd', 'skills.ios.no-dispatchgroup', 'heuristics.ios.dispatchgroup.ast'], ['ios.no-gcd', 'skills.ios.no-dispatchsemaphore', 'heuristics.ios.dispatchsemaphore.ast'], ['ios.no-gcd', 'skills.ios.no-operation-queue', 'heuristics.ios.operation-queue.ast'], ['skills.ios.no-task-detached', 'heuristics.ios.task-detached.ast'], ['skills.ios.no-unchecked-sendable', 'heuristics.ios.unchecked-sendable.ast'], ['skills.ios.no-preconcurrency', 'heuristics.ios.preconcurrency.ast'], [ 'skills.ios.guideline.ios.no-usar-mainactor-como-parche-justificar-el-aislamiento', 'heuristics.ios.concurrency.mainactor-run-patch.ast', ], [ 'skills.ios.guideline.ios-concurrency.use-grep-for-swiftstrictconcurrency-or-swiftdefaultactorisolation-in-p', 'heuristics.ios.concurrency.strict-concurrency-below-complete.ast', 'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast', 'heuristics.ios.concurrency.upcoming-feature-disabled.ast', 'heuristics.ios.concurrency.swiftpm-default-isolation-not-mainactor.ast', 'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast', ], [ 'skills.ios.guideline.ios.validar-configuracio-n-de-concurrencia-swiftstrictconcurrency-swiftdef', 'heuristics.ios.concurrency.strict-concurrency-below-complete.ast', 'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast', 'heuristics.ios.concurrency.upcoming-feature-disabled.ast', 'heuristics.ios.concurrency.swiftpm-default-isolation-not-mainactor.ast', 'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast', ], ['skills.ios.no-nonisolated-unsafe', 'heuristics.ios.nonisolated-unsafe.ast'], [ 'skills.ios.guideline.ios.feature-first-cada-feature-es-un-bounded-context-no-se-acoplan-entre-s', 'heuristics.ios.architecture.cross-feature-import.ast', ], [ 'skills.ios.guideline.ios.las-features-no-se-importan-entre-si', 'heuristics.ios.architecture.cross-feature-import.ast', ], [ 'skills.ios.guideline.ios.shared-kernel-solo-tipos-mi-nimos-compartidos-entre-features', 'heuristics.ios.architecture.cross-feature-import.ast', ], [ 'skills.ios.guideline.ios.clean-por-feature-presentation-application-domain-infrastructure-domai', 'heuristics.ios.architecture.layer-direction-violation.ast', ], [ 'skills.ios.guideline.ios.seguir-clean-architecture-domain-application-infrastructure-presentati', 'heuristics.ios.architecture.layer-direction-violation.ast', ], [ 'skills.ios.guideline.ios.ddd-entidades-value-objects-repositorios-protocolos-eventos-de-dominio', 'heuristics.ios.architecture.layer-direction-violation.ast', ], [ 'skills.ios.guideline.ios.public-api-solo-exponer-lo-necesario-public-internal-private', 'heuristics.ios.architecture.excessive-public-api.ast', ], ['skills.ios.no-assume-isolated', 'heuristics.ios.assume-isolated.ast'], ['skills.ios.no-observable-object', 'heuristics.ios.observable-object.ast'], [ 'skills.ios.no-legacy-swiftui-observable-wrapper', 'heuristics.ios.legacy-swiftui-observable-wrapper.ast', ], [ 'skills.ios.no-passed-value-state-wrapper', 'heuristics.ios.passed-value-state-wrapper.ast', ], ['skills.ios.no-navigation-view', 'heuristics.ios.navigation-view.ast'], [ 'skills.ios.guideline.ios.deep-links-deben-mapear-a-rutas-del-path', 'heuristics.ios.navigation.path-without-restoration.ast', ], [ 'skills.ios.guideline.ios.state-restoration-rehidratar-path-desde-estado-persistido-si-aplica', 'heuristics.ios.navigation.path-without-restoration.ast', ], ['skills.ios.no-foreground-color', 'heuristics.ios.foreground-color.ast'], ['skills.ios.no-corner-radius', 'heuristics.ios.corner-radius.ast'], ['skills.ios.no-tab-item', 'heuristics.ios.tab-item.ast'], ['skills.ios.no-on-tap-gesture', 'heuristics.ios.on-tap-gesture.ast'], ['skills.ios.no-string-format', 'heuristics.ios.string-format.ast'], ['skills.ios.no-foreach-indices', 'heuristics.ios.foreach-indices.ast'], ['skills.ios.no-contains-user-filter', 'heuristics.ios.contains-user-filter.ast'], ['skills.ios.no-geometryreader', 'heuristics.ios.geometryreader.ast'], ['skills.ios.no-font-weight-bold', 'heuristics.ios.font-weight-bold.ast'], [ 'skills.ios.no-scrollview-shows-indicators', 'heuristics.ios.scrollview-shows-indicators.ast', ], ['skills.ios.no-sheet-is-presented', 'heuristics.ios.sheet-is-presented.ast'], ['skills.ios.no-legacy-onchange', 'heuristics.ios.legacy-onchange.ast'], ['skills.ios.no-uiscreen-main-bounds', 'heuristics.ios.uiscreen-main-bounds.ast'], ['skills.ios.prefer-swift-testing', 'heuristics.ios.testing.xctest-import.ast'], ['skills.ios.prefer-swift-testing', 'heuristics.ios.testing.xctest-suite-modernizable.ast'], ['skills.ios.no-xctassert', 'heuristics.ios.testing.xctassert.ast'], ['skills.ios.no-xctunwrap', 'heuristics.ios.testing.xctunwrap.ast'], [ 'skills.ios.no-wait-for-expectations', 'heuristics.ios.testing.wait-for-expectations.ast', ], [ 'skills.ios.no-legacy-expectation-description', 'heuristics.ios.testing.legacy-expectation-description.ast', ], ['skills.ios.no-mixed-testing-frameworks', 'heuristics.ios.testing.mixed-frameworks.ast'], [ 'skills.ios.guideline.ios.xcuitest-ui-testing-nativo', 'heuristics.ios.testing.third-party-ui-test-framework.ast', ], [ 'skills.ios.no-nsmanagedobject-boundary', 'heuristics.ios.core-data.nsmanagedobject-boundary.ast', ], [ 'skills.ios.no-nsmanagedobject-async-boundary', 'heuristics.ios.core-data.nsmanagedobject-async-boundary.ast', ], ['skills.ios.no-core-data-layer-leak', 'heuristics.ios.core-data.layer-leak.ast'], [ 'skills.ios.no-nsmanagedobject-state-leak', 'heuristics.ios.core-data.nsmanagedobject-state-leak.ast', ], [ 'backend.no-console-log', 'frontend.no-console-log', 'skills.backend.no-console-log', 'skills.frontend.no-console-log', 'heuristics.ts.console-log.ast', ], [ 'backend.avoid-explicit-any', 'skills.backend.avoid-explicit-any', 'skills.frontend.avoid-explicit-any', 'heuristics.ts.explicit-any.ast', ], [ 'backend.no-empty-catch', 'skills.backend.no-empty-catch', 'skills.frontend.no-empty-catch', 'heuristics.ts.empty-catch.ast', ], [ 'android.no-thread-sleep', 'skills.android.no-thread-sleep', 'heuristics.android.thread-sleep.ast', ], [ 'android.no-global-scope', 'skills.android.no-globalscope', 'heuristics.android.globalscope.ast', ], [ 'skills.android.no-runblocking', 'heuristics.android.run-blocking.ast', ], ]; const ruleFamilyIndex = new Map(); equivalentRuleFamilies.forEach((family, familyIndex) => { family.forEach((ruleId) => { ruleFamilyIndex.set(ruleId, familyIndex); }); }); const semanticKeywordFamilies: ReadonlyArray = [ ['keyword:explicit-any', /(?:^|[._-])explicit(?:[._-])?any(?:$|[._-])/i], ['keyword:empty-catch', /(?:^|[._-])empty(?:[._-])?catch(?:$|[._-])/i], ['keyword:console-log', /(?:^|[._-])console(?:[._-])?log(?:$|[._-])/i], ['keyword:anyview', /(?:^|[._-])anyview(?:$|[._-])/i], ['keyword:force-unwrap', /(?:^|[._-])force(?:[._-])?unwrap(?:$|[._-])/i], ]; const normalizeAnchorLine = (lines?: EvidenceLines): number => { if (typeof lines === 'number' && Number.isFinite(lines)) { return Math.max(0, Math.trunc(lines)); } if (typeof lines === 'string') { const match = lines.match(/-?\d+/); if (!match) { return 0; } const value = Number.parseInt(match[0], 10); return Number.isFinite(value) ? Math.max(0, value) : 0; } if (!Array.isArray(lines) || lines.length === 0) { return 0; } const finite = lines.filter((line: number) => Number.isFinite(line)).map((line: number) => Math.trunc(line)); if (finite.length === 0) { return 0; } return Math.max(0, Math.min(...finite)); }; const inferPlatformFromFinding = (finding: Pick): string => { const file = finding.file.replace(/\\/g, '/').toLowerCase(); if (file.startsWith('apps/ios/') || file.startsWith('ios/')) { return 'ios'; } if (file.startsWith('apps/backend/')) { return 'backend'; } if (file.startsWith('apps/web/') || file.startsWith('apps/frontend/')) { return 'frontend'; } if (file.startsWith('apps/android/')) { return 'android'; } if (finding.ruleId.startsWith('ios.') || finding.ruleId.startsWith('skills.ios.')) { return 'ios'; } if (finding.ruleId.startsWith('backend.') || finding.ruleId.startsWith('skills.backend.')) { return 'backend'; } if (finding.ruleId.startsWith('frontend.') || finding.ruleId.startsWith('skills.frontend.')) { return 'frontend'; } if (finding.ruleId.startsWith('android.') || finding.ruleId.startsWith('skills.android.')) { return 'android'; } return 'generic'; }; const toSemanticFamilyToken = (ruleId: string): string => { for (const [token, pattern] of semanticKeywordFamilies) { if (pattern.test(ruleId)) { return token; } } const familyIndex = ruleFamilyIndex.get(ruleId); if (typeof familyIndex === 'number') { return `family:${familyIndex}`; } return `rule:${ruleId}`; }; const toSemanticCollision = ( stage: GateStage, finding: SnapshotFinding ): { key: string; platform: string } => { const platform = inferPlatformFromFinding(finding); const anchorLine = normalizeAnchorLine(finding.lines); const semanticFamily = toSemanticFamilyToken(finding.ruleId); return { key: `${stage}::${platform}::${finding.file}::${anchorLine}::${semanticFamily}`, platform, }; }; const preferFindingForSemanticCollision = ( current: SnapshotFinding, candidate: SnapshotFinding ): SnapshotFinding => { const bySeverity = severityRank[candidate.severity] - severityRank[current.severity]; if (bySeverity > 0) { return candidate; } if (bySeverity < 0) { return current; } const byOrigin = ruleOriginRank[inferRuleOrigin(candidate.ruleId)] - ruleOriginRank[inferRuleOrigin(current.ruleId)]; if (byOrigin > 0) { return candidate; } if (byOrigin < 0) { return current; } const byRuleId = candidate.ruleId.localeCompare(current.ruleId); if (byRuleId < 0) { return candidate; } if (byRuleId > 0) { return current; } const tuple = (finding: SnapshotFinding): string => { return [ finding.code, finding.message, finding.matchedBy ?? '', finding.source ?? '', ].join('\u0000'); }; return tuple(candidate).localeCompare(tuple(current)) < 0 ? candidate : current; }; const consolidateEquivalentFindings = ( stage: GateStage, findings: ReadonlyArray ): { findings: SnapshotFinding[]; suppressed: ConsolidationSuppressedFinding[] } => { const selectedByCollision = new Map(); const platformByCollision = new Map(); for (const finding of findings) { const collision = toSemanticCollision(stage, finding); const current = selectedByCollision.get(collision.key); if (!current) { selectedByCollision.set(collision.key, finding); platformByCollision.set(collision.key, collision.platform); continue; } selectedByCollision.set( collision.key, preferFindingForSemanticCollision(current, finding) ); } const suppressed: ConsolidationSuppressedFinding[] = []; const filtered = findings.filter((finding) => { const collision = toSemanticCollision(stage, finding); const selected = selectedByCollision.get(collision.key); if (!selected) { return true; } const keep = findingKey(selected) === findingKey(finding); if (!keep) { suppressed.push({ ruleId: finding.ruleId, file: finding.file, lines: finding.lines, replacedByRuleId: selected.ruleId, replacementRuleId: selected.ruleId, platform: platformByCollision.get(collision.key), reason: 'semantic-family-precedence', }); } return keep; }); suppressed.sort((left, right) => { const byFile = left.file.localeCompare(right.file); if (byFile !== 0) { return byFile; } const byRule = left.ruleId.localeCompare(right.ruleId); if (byRule !== 0) { return byRule; } const byReplacement = left.replacedByRuleId.localeCompare(right.replacedByRuleId); if (byReplacement !== 0) { return byReplacement; } return (left.platform ?? '').localeCompare(right.platform ?? ''); }); return { findings: filtered, suppressed, }; }; const normalizeAndDedupeFindings = ( stage: GateStage, findings: ReadonlyArray ): { findings: SnapshotFinding[]; suppressed: ConsolidationSuppressedFinding[] } => { const unique = new Map(); for (const finding of findings) { const normalized = normalizeFinding(finding); const key = findingKey(normalized); const current = unique.get(key); unique.set( key, current ? pickDeterministicDuplicateFinding(current, normalized) : normalized ); } const deduped = Array.from(unique.values()).sort(compareFindingEntries); const consolidated = consolidateEquivalentFindings(stage, deduped); return { findings: consolidated.findings.sort(compareFindingEntries), suppressed: consolidated.suppressed, }; }; const toGateOutcome = (findings: ReadonlyArray): GateOutcome => { if (findings.some((finding) => finding.severity === 'CRITICAL')) { return 'BLOCK'; } return findings.length > 0 ? 'WARN' : 'PASS'; }; const bySeverity = (findings: ReadonlyArray): Record => { const counts: Record = { INFO: 0, WARN: 0, ERROR: 0, CRITICAL: 0, }; for (const finding of findings) { counts[finding.severity] += 1; } return counts; }; const toEnterpriseBySeverity = ( severity: Readonly> ): Record<'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW', number> => { return { CRITICAL: severity.CRITICAL, HIGH: severity.ERROR, MEDIUM: severity.WARN, LOW: severity.INFO, }; }; const toCompatibilityViolations = ( findings: ReadonlyArray ): CompatibilityViolation[] => { return findings.map((finding) => ({ ruleId: finding.ruleId, level: finding.severity, code: finding.code, message: finding.message, file: finding.file, lines: finding.lines, matchedBy: finding.matchedBy, source: finding.source, })); }; const updateLedger = (params: { findings: ReadonlyArray; previousEvidence?: AiEvidenceV2_1; now: string; }): LedgerEntry[] => { const previous = new Map(); for (const entry of params.previousEvidence?.ledger ?? []) { previous.set(ledgerKey(entry), entry); } const activeLedger = params.findings.map((finding) => { const key = findingKey(finding); const prior = previous.get(key); return { ruleId: finding.ruleId, file: finding.file, lines: finding.lines, firstSeen: prior?.firstSeen ?? params.now, lastSeen: params.now, }; }); return activeLedger.sort((left, right) => ledgerKey(left).localeCompare(ledgerKey(right))); }; const normalizePlatforms = ( platforms: Record ): Record => { const ordered: Record = {}; for (const platform of Object.keys(platforms).sort()) { ordered[platform] = { detected: platforms[platform].detected, confidence: platforms[platform].confidence, }; } return ordered; }; const normalizeRulesets = (rulesets: ReadonlyArray): RulesetState[] => { const unique = new Map(); for (const ruleset of rulesets) { const key = `${ruleset.platform}::${ruleset.bundle}`; if (!unique.has(key)) { unique.set(key, { platform: ruleset.platform, bundle: ruleset.bundle, hash: ruleset.hash, }); } } return Array.from(unique.values()).sort((left, right) => { const byPlatform = left.platform.localeCompare(right.platform); return byPlatform !== 0 ? byPlatform : left.bundle.localeCompare(right.bundle); }); }; const normalizeRepoState = (repoState?: RepoState): RepoState | undefined => { if (!repoState) { return undefined; } return { repo_root: repoState.repo_root, git: { available: repoState.git.available, branch: repoState.git.branch ?? null, upstream: repoState.git.upstream ?? null, ahead: Number.isFinite(repoState.git.ahead) ? Math.max(0, Math.trunc(repoState.git.ahead)) : 0, behind: Number.isFinite(repoState.git.behind) ? Math.max(0, Math.trunc(repoState.git.behind)) : 0, dirty: repoState.git.dirty, staged: Number.isFinite(repoState.git.staged) ? Math.max(0, Math.trunc(repoState.git.staged)) : 0, unstaged: Number.isFinite(repoState.git.unstaged) ? Math.max(0, Math.trunc(repoState.git.unstaged)) : 0, }, lifecycle: { installed: repoState.lifecycle.installed, package_version: repoState.lifecycle.package_version ?? null, lifecycle_version: repoState.lifecycle.lifecycle_version ?? null, hooks: { pre_commit: repoState.lifecycle.hooks.pre_commit, pre_push: repoState.lifecycle.hooks.pre_push, }, hard_mode: repoState.lifecycle.hard_mode ? { enabled: repoState.lifecycle.hard_mode.enabled, profile: repoState.lifecycle.hard_mode.profile ?? null, config_path: repoState.lifecycle.hard_mode.config_path, } : undefined, }, }; }; const normalizeMemoryShadow = ( memoryShadow: | { recommended_outcome: GateOutcome; actual_outcome: GateOutcome; confidence: number; reason_codes: string[]; } | undefined ): { recommended_outcome: GateOutcome; actual_outcome: GateOutcome; confidence: number; reason_codes: string[]; } | undefined => { if (!memoryShadow) { return undefined; } const confidence = Number.isFinite(memoryShadow.confidence) ? Math.max(0, Math.min(1, Number(memoryShadow.confidence.toFixed(6)))) : 0; const reasonCodes = Array.from( new Set(memoryShadow.reason_codes.map((code) => code.trim()).filter((code) => code.length > 0)) ).sort((a, b) => a.localeCompare(b)); return { recommended_outcome: memoryShadow.recommended_outcome, actual_outcome: memoryShadow.actual_outcome, confidence, reason_codes: reasonCodes, }; }; export function buildEvidence(params: BuildEvidenceParams): AiEvidenceV2_1 { const now = new Date().toISOString(); const consolidatedFindings = normalizeAndDedupeFindings(params.stage, params.findings); const normalizedFindings = consolidatedFindings.findings; const normalizedFilesScanned = normalizeOptionalNonNegativeInt(params.filesScanned) ?? 0; const normalizedFilesAffected = countFilesAffected(normalizedFindings); const normalizedEvaluationMetrics = normalizeSnapshotEvaluationMetrics(params.evaluationMetrics); const normalizedRulesCoverage = normalizeSnapshotRulesCoverage(params.stage, params.rulesCoverage); const normalizedMemoryShadow = normalizeMemoryShadow(params.memoryShadow); const outcome = params.gateOutcome ?? toGateOutcome(normalizedFindings); const gateStatus = outcome === 'BLOCK' ? 'BLOCKED' : 'ALLOWED'; const severity = bySeverity(normalizedFindings); const humanIntent = resolveHumanIntent({ now, inputIntent: params.humanIntent, previousEvidence: params.previousEvidence, }); const operational_hints = buildEvidenceOperationalHints({ stage: params.stage, outcome, findings: normalizedFindings, rulesCoverage: normalizedRulesCoverage, evaluationMetrics: normalizedEvaluationMetrics, extra: params.operationalHintsExtra, }); return { version: '2.1', timestamp: now, operational_hints, snapshot: { stage: params.stage, audit_mode: params.auditMode ?? 'gate', outcome, files_scanned: normalizedFilesScanned, files_affected: normalizedFilesAffected, evaluation_metrics: normalizedEvaluationMetrics, rules_coverage: normalizedRulesCoverage, ...(params.tddBdd ? { tdd_bdd: params.tddBdd } : {}), ...(normalizedMemoryShadow ? { memory_shadow: normalizedMemoryShadow } : {}), findings: normalizedFindings, platforms: buildSnapshotPlatformSummaries( normalizedFindings.map((finding) => ({ ruleId: finding.ruleId, severity: finding.severity, file: finding.file, })) ), }, ledger: updateLedger({ findings: normalizedFindings, previousEvidence: params.previousEvidence, now, }), platforms: normalizePlatforms(params.detectedPlatforms), rulesets: normalizeRulesets(params.loadedRulesets), human_intent: humanIntent, ai_gate: { status: gateStatus, violations: toCompatibilityViolations(normalizedFindings), human_intent: humanIntent, }, severity_metrics: { gate_status: gateStatus, total_violations: normalizedFindings.length, by_severity: severity, by_enterprise_severity: toEnterpriseBySeverity(severity), }, sdd_metrics: params.sddMetrics ? { enforced: params.sddMetrics.enforced, stage: params.sddMetrics.stage, decision: { allowed: params.sddMetrics.decision.allowed, code: params.sddMetrics.decision.code, message: params.sddMetrics.decision.message, }, } : undefined, repo_state: normalizeRepoState(params.repoState), consolidation: consolidatedFindings.suppressed.length > 0 ? { suppressed: consolidatedFindings.suppressed } : undefined, }; }