From 208699fcff02674336ce5177ac0f646473835b7b Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:21:02 +0530 Subject: [PATCH 001/141] feat(verify): define versioned runtime contracts --- .../lib/warm-verification/contracts.test.ts | 304 ++++++ .../src/lib/warm-verification/contracts.ts | 999 ++++++++++++++++++ 2 files changed, 1303 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/contracts.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/contracts.ts diff --git a/apps/desktop/src/lib/warm-verification/contracts.test.ts b/apps/desktop/src/lib/warm-verification/contracts.test.ts new file mode 100644 index 0000000..fa01d86 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/contracts.test.ts @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + VERIFY_CONTRACT_LIMITS, + exitCodeForOutcome, + validateDaemonRequestEnvelope, + validateDaemonResponseEnvelope, + type DaemonRequestEnvelope, + type DaemonResponseEnvelope, + type VerifyResult, +} from './contracts'; + +const now = '2026-07-15T10:00:00.000Z'; +const later = '2026-07-15T10:00:01.000Z'; +const sha256 = 'a'.repeat(64); +const gitSha = 'b'.repeat(40); + +function validRequest(): DaemonRequestEnvelope { + return { + protocol_version: 1, + request_id: 'request-1', + sent_at: now, + request: { + type: 'verify_changed', + run_id: 'run-1', + change_set: { + kind: 'worktree', + target_sha: gitSha, + identity: sha256, + changed_paths: ['src/features/portfolio/Portfolio.tsx'], + }, + options: { detailed_capture: false, batch_timeout_ms: 30_000 }, + }, + }; +} + +function validResult(): VerifyResult { + return { + schema_version: 1, + protocol_version: 1, + run_id: 'run-1', + outcome: 'passed', + started_at: now, + finished_at: later, + warm: true, + stale: false, + model_call_count: 0, + source: { + target_sha: gitSha, + change_set_kind: 'worktree', + change_set_identity: sha256, + change_set_revision: 'HEAD+index+worktree+untracked', + config_hash: sha256, + manifest_hash: sha256, + source_hash_before: sha256, + source_hash_after: sha256, + }, + observation_policy: { schema_version: 1, profile_id: 'strict-default-v1' }, + selection: { + changed_paths: ['src/features/portfolio/Portfolio.tsx'], + selected_scenario_ids: ['portfolio-funded'], + mandatory_smoke_ids: [], + fallback_scenario_ids: [], + complete: true, + explanation: 'portfolio capability matched the changed path', + }, + scenarios: [{ scenario_id: 'portfolio-funded', outcome: 'passed', duration_ms: 850 }], + timings: [{ stage: 'total', duration_ms: 1_000 }], + observations: [], + limitations: [], + artifacts: [], + cancellation: { state: 'not_requested' }, + }; +} + +function validResponse(result = validResult()): DaemonResponseEnvelope { + return { + protocol_version: 1, + request_id: 'request-1', + sent_at: later, + response: { type: 'verify_result', result }, + }; +} + +function validHealthResponse(): DaemonResponseEnvelope { + const process = { + kind: 'process' as const, + state: 'ready' as const, + owned: true, + pid: 42, + start_identity: 'pid-42-start-100', + restart_attempts: 0, + last_exit: null, + }; + return { + protocol_version: 1, + request_id: 'request-health', + sent_at: later, + response: { + type: 'health', + health: { + schema_version: 1, + daemon_pid: 41, + daemon_start_identity: 'pid-41-start-99', + target_root: '/Users/developer/app', + target_sha: gitSha, + config_hash: sha256, + chromium_revision: 'chromium-1245', + warm: true, + server: process, + browser: { + ...process, + kind: 'browser', + pid: null, + start_identity: 'chromium-1245-generation-1', + }, + active_run_ids: ['run-1'], + resources: { + rss_bytes: 100_000_000, + heap_used_bytes: 20_000_000, + active_contexts: 1, + retained_artifact_bytes: 0, + }, + checked_at: later, + }, + }, + }; +} + +describe('daemon wire contracts', () => { + it('accepts a bounded versioned changed-verification request', () => { + const validation = validateDaemonRequestEnvelope(validRequest()); + assert.equal(validation.ok, true); + if (validation.ok) assert.ok(validation.bytes > 0); + }); + + it('rejects unsupported protocol versions and invalid change identities', () => { + const request = validRequest() as unknown as Record; + request.protocol_version = 2; + const payload = request.request as Record; + (payload.change_set as Record).identity = 'not-a-hash'; + + const validation = validateDaemonRequestEnvelope(request); + assert.equal(validation.ok, false); + if (!validation.ok) { + assert.ok(validation.issues.some((issue) => issue.path === '$.protocol_version')); + assert.ok(validation.issues.some((issue) => issue.path === '$.request.change_set.identity')); + } + }); + + it('rejects frames and collections beyond their published bounds', () => { + const request = validRequest(); + if (request.request.type !== 'verify_changed') assert.fail('expected verify request'); + request.request.change_set.changed_paths = Array.from( + { length: VERIFY_CONTRACT_LIMITS.maxChangedPaths + 1 }, + (_, index) => `src/feature-${index}.tsx` + ); + request.request.change_set.revision = 'x'.repeat(VERIFY_CONTRACT_LIMITS.maxFrameBytes); + + const validation = validateDaemonRequestEnvelope(request); + assert.equal(validation.ok, false); + if (!validation.ok) { + assert.ok(validation.issues.some((issue) => issue.message.includes('frame exceeds'))); + assert.ok( + validation.issues.some( + (issue) => + issue.path === '$.request.change_set.changed_paths' && issue.message.includes('exceeds') + ) + ); + } + }); + + it('rejects circular or deeply nested payloads without throwing', () => { + const circular: Record = { ...validRequest() }; + circular.loop = circular; + const validation = validateDaemonRequestEnvelope(circular); + assert.equal(validation.ok, false); + if (!validation.ok) { + assert.ok(validation.issues.some((issue) => issue.message === 'must be JSON serializable')); + assert.ok(validation.issues.some((issue) => issue.message.includes('nesting depth'))); + } + }); + + it('validates detailed owned-process health and bounded resources', () => { + assert.equal(validateDaemonResponseEnvelope(validHealthResponse()).ok, true); + const invalid = validHealthResponse(); + if (invalid.response.type !== 'health') assert.fail('expected health response'); + invalid.response.health.server = { + kind: 'process', + state: 'ready', + owned: false, + pid: 99, + start_identity: 'foreign-process', + restart_attempts: 2, + last_exit: null, + }; + const validation = validateDaemonResponseEnvelope(invalid); + assert.equal(validation.ok, false); + if (!validation.ok) { + const messages = validation.issues.map((issue) => issue.message).join('\n'); + assert.match(messages, /at most 1/); + assert.match(messages, /unowned process cannot expose/); + } + }); + + it('requires honest browser ownership without inventing a process id', () => { + const invalid = validHealthResponse(); + if (invalid.response.type !== 'health') assert.fail('expected health response'); + invalid.response.health.browser.pid = 43; + + const validation = validateDaemonResponseEnvelope(invalid); + assert.equal(validation.ok, false); + if (!validation.ok) { + assert.ok(validation.issues.some((issue) => issue.message.includes('must not invent a PID'))); + } + }); +}); + +describe('verification outcome invariants', () => { + it('accepts a complete current zero-model passing result', () => { + assert.equal(validateDaemonResponseEnvelope(validResponse()).ok, true); + }); + + it('does not allow stale, cancelled, incomplete, or failing evidence to claim passed', () => { + const result = validResult(); + result.stale = true; + result.selection.complete = false; + result.cancellation = { state: 'requested', requested_at: later, reason: 'user requested' }; + result.observations = [ + { + id: 'obs-1', + scenario_id: 'portfolio-funded', + kind: 'page_error', + disposition: 'regression', + policy_id: 'runtime-errors', + message: 'Unhandled exception', + occurred_at: later, + }, + ]; + result.limitations = [ + { + code: 'source_stale', + message: 'Source changed during execution', + affects_confidence: true, + }, + ]; + + const validation = validateDaemonResponseEnvelope(validResponse(result)); + assert.equal(validation.ok, false); + if (!validation.ok) { + const messages = validation.issues.map((issue) => issue.message).join('\n'); + assert.match(messages, /stale result cannot pass/); + assert.match(messages, /incomplete selection cannot pass/); + assert.match(messages, /cancelled result cannot pass/); + assert.match(messages, /failing observations/); + assert.match(messages, /confidence-blocking limitations/); + } + }); + + it('maps public outcomes to stable distinct exit codes', () => { + assert.equal(exitCodeForOutcome('passed'), 0); + assert.equal(exitCodeForOutcome('regression'), 2); + assert.equal(exitCodeForOutcome('no_confidence'), 3); + }); + + it('rejects unsafe nested artifact and evidence records', () => { + const result = validResult() as unknown as Record; + result.outcome = 'regression'; + result.artifacts = [ + { + id: 'artifact-1', + kind: 'screenshot', + relative_path: '../../cookies.json', + sha256, + bytes: 100, + redacted: false, + created_at: now, + retained_until: later, + }, + ]; + result.observations = [ + { + id: 'obs-1', + scenario_id: 'portfolio-funded', + kind: 'made_up_kind', + disposition: 'regression', + policy_id: 'runtime-errors', + message: 'failure', + occurred_at: later, + }, + ]; + const response = validResponse() as unknown as Record; + response.response = { type: 'verify_result', result }; + const validation = validateDaemonResponseEnvelope(response); + assert.equal(validation.ok, false); + if (!validation.ok) { + const messages = validation.issues.map((issue) => issue.message).join('\n'); + assert.match(messages, /non-traversing relative path/); + assert.match(messages, /retained artifacts must be redacted/); + assert.match(messages, /invalid observation kind/); + } + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/contracts.ts b/apps/desktop/src/lib/warm-verification/contracts.ts new file mode 100644 index 0000000..148a79f --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/contracts.ts @@ -0,0 +1,999 @@ +export const VERIFY_PROTOCOL_VERSION = 1 as const; +export const VERIFY_RESULT_SCHEMA_VERSION = 1 as const; + +export const VERIFY_CONTRACT_LIMITS = { + maxFrameBytes: 1_048_576, + maxStringBytes: 16_384, + maxArrayItems: 1_000, + maxObjectKeys: 128, + maxNestingDepth: 12, + maxChangedPaths: 2_000, + maxSelectedScenarios: 500, + maxTimings: 2_000, + maxObservations: 2_000, + maxLimitations: 100, + maxArtifacts: 100, + maxActiveRuns: 32, +} as const; + +export type VerifyOutcome = 'passed' | 'regression' | 'no_confidence'; +export type VerifyExitCode = 0 | 2 | 3; + +export const VERIFY_EXIT_CODES: Readonly> = { + passed: 0, + regression: 2, + no_confidence: 3, +}; + +export const VERIFY_USAGE_EXIT_CODE = 64 as const; + +export type VerifyChangeSetKind = 'worktree' | 'staged' | 'commit' | 'range'; + +export interface VerifyChangeSetIdentity { + kind: VerifyChangeSetKind; + target_sha: string; + identity: string; + changed_paths: string[]; + revision?: string; +} + +export interface VerifyChangedOptions { + detailed_capture: boolean; + batch_timeout_ms: number; +} + +export type DaemonRequest = + | { type: 'health' } + | { + type: 'verify_changed'; + run_id: string; + change_set: VerifyChangeSetIdentity; + options: VerifyChangedOptions; + } + | { type: 'cancel'; run_id: string; reason?: string } + | { type: 'shutdown'; grace_ms: number }; + +export interface DaemonRequestEnvelope { + protocol_version: typeof VERIFY_PROTOCOL_VERSION; + request_id: string; + sent_at: string; + request: DaemonRequest; +} + +export interface OwnedRuntimeHealth { + kind: 'process' | 'browser'; + state: 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'recovering' | 'locked'; + owned: boolean; + pid: number | null; + start_identity: string | null; + restart_attempts: number; + last_exit: { code: number | null; signal: string | null; at: string } | null; +} + +export interface DaemonResourceUsage { + rss_bytes: number; + heap_used_bytes: number; + active_contexts: number; + retained_artifact_bytes: number; +} + +export interface DaemonHealth { + schema_version: 1; + daemon_pid: number; + daemon_start_identity: string; + target_root: string; + target_sha: string; + config_hash: string; + chromium_revision: string; + warm: boolean; + server: OwnedRuntimeHealth; + browser: OwnedRuntimeHealth; + active_run_ids: string[]; + resources: DaemonResourceUsage; + checked_at: string; +} + +export type VerifyTimingStage = + | 'diff' + | 'selection' + | 'context' + | 'auth' + | 'state' + | 'navigation' + | 'actions' + | 'observation' + | 'screenshots' + | 'reporting' + | 'teardown' + | 'total'; + +export interface VerifyTiming { + stage: VerifyTimingStage; + duration_ms: number; + scenario_id?: string; +} + +export type VerifyLimitationCode = + | 'cancelled' + | 'config_invalid' + | 'daemon_unavailable' + | 'manifest_invalid' + | 'selection_incomplete' + | 'source_stale' + | 'state_unavailable' + | 'target_unavailable' + | 'browser_unavailable' + | 'timeout' + | 'unsupported_version' + | 'artifact_limit' + | 'other'; + +export interface VerifyLimitation { + code: VerifyLimitationCode; + message: string; + affects_confidence: boolean; + remediation?: string; + scenario_id?: string; +} + +export type VerifyObservationKind = + | 'page_error' + | 'console_error' + | 'request_failed' + | 'http_failure' + | 'unexpected_request' + | 'mutation' + | 'duplicate_mutation' + | 'route' + | 'interaction_timing' + | 'accessibility_smoke' + | 'accessibility_audit' + | 'screenshot'; + +export type VerifyObservationDisposition = + | 'passed' + | 'regression' + | 'no_confidence' + | 'informational'; + +export interface VerifyObservation { + id: string; + scenario_id: string; + kind: VerifyObservationKind; + disposition: VerifyObservationDisposition; + policy_id: string; + message: string; + checkpoint?: string; + occurred_at: string; + evidence?: Record; +} + +export type VerifyArtifactKind = 'screenshot' | 'trace' | 'network' | 'console' | 'report'; + +export interface VerifyArtifact { + id: string; + kind: VerifyArtifactKind; + relative_path: string; + sha256: string; + bytes: number; + redacted: true; + created_at: string; + retained_until: string; + scenario_id?: string; +} + +export type VerifyCancellation = + | { state: 'not_requested' } + | { state: 'requested'; requested_at: string; reason?: string } + | { + state: 'completed'; + requested_at: string; + completed_at: string; + reason?: string; + }; + +export interface VerifySelectionSummary { + changed_paths: string[]; + selected_scenario_ids: string[]; + mandatory_smoke_ids: string[]; + fallback_scenario_ids: string[]; + complete: boolean; + explanation: string; +} + +export interface VerifySourceIdentity { + target_sha: string; + change_set_kind: VerifyChangeSetKind; + change_set_identity: string; + change_set_revision?: string; + config_hash: string; + manifest_hash: string; + source_hash_before: string; + source_hash_after: string; +} + +export interface VerifyObservationPolicyIdentity { + schema_version: 1; + profile_id: string; +} + +export interface ScenarioOutcomeSummary { + scenario_id: string; + outcome: VerifyOutcome; + duration_ms: number; +} + +export interface VerifyResult { + schema_version: typeof VERIFY_RESULT_SCHEMA_VERSION; + protocol_version: typeof VERIFY_PROTOCOL_VERSION; + run_id: string; + outcome: VerifyOutcome; + started_at: string; + finished_at: string; + warm: boolean; + stale: boolean; + model_call_count: 0; + source: VerifySourceIdentity; + observation_policy: VerifyObservationPolicyIdentity; + selection: VerifySelectionSummary; + scenarios: ScenarioOutcomeSummary[]; + timings: VerifyTiming[]; + observations: VerifyObservation[]; + limitations: VerifyLimitation[]; + artifacts: VerifyArtifact[]; + cancellation: VerifyCancellation; +} + +export interface DaemonError { + code: string; + message: string; + remediation?: string; + retryable: boolean; +} + +export type DaemonResponse = + | { type: 'health'; health: DaemonHealth } + | { type: 'verify_result'; result: VerifyResult } + | { type: 'cancel_ack'; run_id: string; accepted: boolean } + | { type: 'shutdown_ack'; active_run_ids: string[] } + | { type: 'error'; error: DaemonError }; + +export interface DaemonResponseEnvelope { + protocol_version: typeof VERIFY_PROTOCOL_VERSION; + request_id: string; + sent_at: string; + response: DaemonResponse; +} + +export interface ContractIssue { + path: string; + message: string; +} + +export type ContractValidation = + | { ok: true; value: T; bytes: number } + | { ok: false; issues: ContractIssue[]; bytes: number | null }; + +type JsonObject = Record; + +const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_SHA_PATTERN = /^[a-f0-9]{40,64}$/; +const TIMING_STAGES: readonly VerifyTimingStage[] = [ + 'diff', + 'selection', + 'context', + 'auth', + 'state', + 'navigation', + 'actions', + 'observation', + 'screenshots', + 'reporting', + 'teardown', + 'total', +]; +const OBSERVATION_KINDS: readonly VerifyObservationKind[] = [ + 'page_error', + 'console_error', + 'request_failed', + 'http_failure', + 'unexpected_request', + 'mutation', + 'duplicate_mutation', + 'route', + 'interaction_timing', + 'accessibility_smoke', + 'accessibility_audit', + 'screenshot', +]; +const OBSERVATION_DISPOSITIONS: readonly VerifyObservationDisposition[] = [ + 'passed', + 'regression', + 'no_confidence', + 'informational', +]; +const LIMITATION_CODES: readonly VerifyLimitationCode[] = [ + 'cancelled', + 'config_invalid', + 'daemon_unavailable', + 'manifest_invalid', + 'selection_incomplete', + 'source_stale', + 'state_unavailable', + 'target_unavailable', + 'browser_unavailable', + 'timeout', + 'unsupported_version', + 'artifact_limit', + 'other', +]; +const ARTIFACT_KINDS: readonly VerifyArtifactKind[] = [ + 'screenshot', + 'trace', + 'network', + 'console', + 'report', +]; +const PROCESS_STATES: readonly OwnedRuntimeHealth['state'][] = [ + 'stopped', + 'starting', + 'ready', + 'unhealthy', + 'recovering', + 'locked', +]; + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function jsonBytes(value: unknown): number | null { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? null : new TextEncoder().encode(serialized).byteLength; + } catch { + return null; + } +} + +function validateBoundedValue( + value: unknown, + path: string, + depth: number, + issues: ContractIssue[] +): void { + if (depth > VERIFY_CONTRACT_LIMITS.maxNestingDepth) { + issues.push({ + path, + message: `exceeds maximum nesting depth ${VERIFY_CONTRACT_LIMITS.maxNestingDepth}`, + }); + return; + } + if (typeof value === 'string') { + const bytes = new TextEncoder().encode(value).byteLength; + if (bytes > VERIFY_CONTRACT_LIMITS.maxStringBytes) { + issues.push({ + path, + message: `string exceeds ${VERIFY_CONTRACT_LIMITS.maxStringBytes} bytes`, + }); + } + return; + } + if (Array.isArray(value)) { + if (value.length > VERIFY_CONTRACT_LIMITS.maxArrayItems) { + issues.push({ path, message: `array exceeds ${VERIFY_CONTRACT_LIMITS.maxArrayItems} items` }); + } + value + .slice(0, VERIFY_CONTRACT_LIMITS.maxArrayItems + 1) + .forEach((item, index) => validateBoundedValue(item, `${path}[${index}]`, depth + 1, issues)); + return; + } + if (isObject(value)) { + const entries = Object.entries(value); + if (entries.length > VERIFY_CONTRACT_LIMITS.maxObjectKeys) { + issues.push({ path, message: `object exceeds ${VERIFY_CONTRACT_LIMITS.maxObjectKeys} keys` }); + } + for (const [key, item] of entries.slice(0, VERIFY_CONTRACT_LIMITS.maxObjectKeys + 1)) { + validateBoundedValue(item, `${path}.${key}`, depth + 1, issues); + } + } +} + +function stringField( + object: JsonObject, + key: string, + path: string, + issues: ContractIssue[], + options: { pattern?: RegExp; optional?: boolean } = {} +): string | undefined { + const value = object[key]; + if (value === undefined && options.optional) return undefined; + if (typeof value !== 'string' || value.length === 0) { + issues.push({ path: `${path}.${key}`, message: 'must be a non-empty string' }); + return undefined; + } + if (options.pattern && !options.pattern.test(value)) { + issues.push({ path: `${path}.${key}`, message: 'has an invalid format' }); + } + return value; +} + +function numberField( + object: JsonObject, + key: string, + path: string, + issues: ContractIssue[], + options: { integer?: boolean; min?: number; max?: number } = {} +): number | undefined { + const value = object[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + issues.push({ path: `${path}.${key}`, message: 'must be a finite number' }); + return undefined; + } + if (options.integer && !Number.isInteger(value)) { + issues.push({ path: `${path}.${key}`, message: 'must be an integer' }); + } + if (options.min !== undefined && value < options.min) { + issues.push({ path: `${path}.${key}`, message: `must be at least ${options.min}` }); + } + if (options.max !== undefined && value > options.max) { + issues.push({ path: `${path}.${key}`, message: `must be at most ${options.max}` }); + } + return value; +} + +function timestampField( + object: JsonObject, + key: string, + path: string, + issues: ContractIssue[] +): void { + const value = stringField(object, key, path, issues); + if (value !== undefined && Number.isNaN(Date.parse(value))) { + issues.push({ path: `${path}.${key}`, message: 'must be an ISO-8601 timestamp' }); + } +} + +function stringArrayField( + object: JsonObject, + key: string, + path: string, + issues: ContractIssue[], + max: number +): string[] | undefined { + const value = object[key]; + if (!Array.isArray(value)) { + issues.push({ path: `${path}.${key}`, message: 'must be an array' }); + return undefined; + } + if (value.length > max) issues.push({ path: `${path}.${key}`, message: `exceeds ${max} items` }); + value.forEach((item, index) => { + if (typeof item !== 'string' || item.length === 0) { + issues.push({ path: `${path}.${key}[${index}]`, message: 'must be a non-empty string' }); + } + }); + return value as string[]; +} + +function validateEnvelopeBase(value: unknown, issues: ContractIssue[]): value is JsonObject { + if (!isObject(value)) { + issues.push({ path: '$', message: 'must be an object' }); + return false; + } + if (value.protocol_version !== VERIFY_PROTOCOL_VERSION) { + issues.push({ + path: '$.protocol_version', + message: `unsupported protocol version; expected ${VERIFY_PROTOCOL_VERSION}`, + }); + } + stringField(value, 'request_id', '$', issues, { pattern: ID_PATTERN }); + timestampField(value, 'sent_at', '$', issues); + return true; +} + +function validateChangeSet(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (!['worktree', 'staged', 'commit', 'range'].includes(String(value.kind))) { + issues.push({ path: `${path}.kind`, message: 'must be worktree, staged, commit, or range' }); + } + stringField(value, 'target_sha', path, issues, { pattern: GIT_SHA_PATTERN }); + stringField(value, 'identity', path, issues, { pattern: SHA256_PATTERN }); + stringArrayField(value, 'changed_paths', path, issues, VERIFY_CONTRACT_LIMITS.maxChangedPaths); + if (value.revision !== undefined) stringField(value, 'revision', path, issues); +} + +function validateOwnedProcess(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (!['process', 'browser'].includes(String(value.kind))) { + issues.push({ path: `${path}.kind`, message: 'must identify a process or browser runtime' }); + } + if (!PROCESS_STATES.includes(value.state as OwnedRuntimeHealth['state'])) { + issues.push({ path: `${path}.state`, message: 'has an invalid process state' }); + } + if (typeof value.owned !== 'boolean') + issues.push({ path: `${path}.owned`, message: 'must be a boolean' }); + if (value.pid !== null) numberField(value, 'pid', path, issues, { integer: true, min: 1 }); + if (value.start_identity !== null) stringField(value, 'start_identity', path, issues); + numberField(value, 'restart_attempts', path, issues, { integer: true, min: 0, max: 1 }); + if (value.last_exit !== null) { + if (!isObject(value.last_exit)) { + issues.push({ path: `${path}.last_exit`, message: 'must be an object or null' }); + } else { + if (value.last_exit.code !== null) { + numberField(value.last_exit, 'code', `${path}.last_exit`, issues, { integer: true }); + } + if (value.last_exit.signal !== null) { + stringField(value.last_exit, 'signal', `${path}.last_exit`, issues); + } + timestampField(value.last_exit, 'at', `${path}.last_exit`, issues); + } + } + if (value.owned === false && (value.pid !== null || value.start_identity !== null)) { + issues.push({ path, message: 'an unowned process cannot expose a PID or start identity' }); + } + if (value.kind === 'browser' && value.pid !== null) { + issues.push({ path: `${path}.pid`, message: 'browser runtime health must not invent a PID' }); + } + if (value.state === 'ready') { + if (value.owned !== true || value.start_identity === null) { + issues.push({ path, message: 'a ready runtime must have ownership and a start identity' }); + } + if (value.kind === 'process' && value.pid === null) { + issues.push({ path, message: 'a ready process must have an owned PID and start identity' }); + } + } +} + +function validateHealth(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (value.schema_version !== 1) { + issues.push({ path: `${path}.schema_version`, message: 'unsupported health schema version' }); + } + numberField(value, 'daemon_pid', path, issues, { integer: true, min: 1 }); + stringField(value, 'daemon_start_identity', path, issues); + stringField(value, 'target_root', path, issues); + stringField(value, 'target_sha', path, issues, { pattern: GIT_SHA_PATTERN }); + stringField(value, 'config_hash', path, issues, { pattern: SHA256_PATTERN }); + stringField(value, 'chromium_revision', path, issues); + if (typeof value.warm !== 'boolean') + issues.push({ path: `${path}.warm`, message: 'must be a boolean' }); + validateOwnedProcess(value.server, `${path}.server`, issues); + validateOwnedProcess(value.browser, `${path}.browser`, issues); + stringArrayField(value, 'active_run_ids', path, issues, VERIFY_CONTRACT_LIMITS.maxActiveRuns); + if (!isObject(value.resources)) { + issues.push({ path: `${path}.resources`, message: 'must be an object' }); + } else { + for (const key of [ + 'rss_bytes', + 'heap_used_bytes', + 'active_contexts', + 'retained_artifact_bytes', + ]) { + numberField(value.resources, key, `${path}.resources`, issues, { integer: true, min: 0 }); + } + } + timestampField(value, 'checked_at', path, issues); +} + +function validateTiming(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (!TIMING_STAGES.includes(value.stage as VerifyTimingStage)) { + issues.push({ path: `${path}.stage`, message: 'has an invalid timing stage' }); + } + numberField(value, 'duration_ms', path, issues, { min: 0, max: 300_000 }); + if (value.scenario_id !== undefined) + stringField(value, 'scenario_id', path, issues, { pattern: ID_PATTERN }); +} + +function validateObservation(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + stringField(value, 'id', path, issues, { pattern: ID_PATTERN }); + stringField(value, 'scenario_id', path, issues, { pattern: ID_PATTERN }); + if (!OBSERVATION_KINDS.includes(value.kind as VerifyObservationKind)) { + issues.push({ path: `${path}.kind`, message: 'has an invalid observation kind' }); + } + if (!OBSERVATION_DISPOSITIONS.includes(value.disposition as VerifyObservationDisposition)) { + issues.push({ path: `${path}.disposition`, message: 'has an invalid observation disposition' }); + } + stringField(value, 'policy_id', path, issues, { pattern: ID_PATTERN }); + stringField(value, 'message', path, issues); + if (value.checkpoint !== undefined) stringField(value, 'checkpoint', path, issues); + timestampField(value, 'occurred_at', path, issues); + if (value.evidence !== undefined && !isObject(value.evidence)) { + issues.push({ path: `${path}.evidence`, message: 'must be a bounded metadata object' }); + } +} + +function validateLimitation(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (!LIMITATION_CODES.includes(value.code as VerifyLimitationCode)) { + issues.push({ path: `${path}.code`, message: 'has an invalid limitation code' }); + } + stringField(value, 'message', path, issues); + if (typeof value.affects_confidence !== 'boolean') { + issues.push({ path: `${path}.affects_confidence`, message: 'must be a boolean' }); + } + if (value.remediation !== undefined) stringField(value, 'remediation', path, issues); + if (value.scenario_id !== undefined) + stringField(value, 'scenario_id', path, issues, { pattern: ID_PATTERN }); +} + +function validateArtifact(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + stringField(value, 'id', path, issues, { pattern: ID_PATTERN }); + if (!ARTIFACT_KINDS.includes(value.kind as VerifyArtifactKind)) { + issues.push({ path: `${path}.kind`, message: 'has an invalid artifact kind' }); + } + const relativePath = stringField(value, 'relative_path', path, issues); + if ( + relativePath !== undefined && + (relativePath.startsWith('/') || relativePath.split('/').includes('..')) + ) { + issues.push({ + path: `${path}.relative_path`, + message: 'must be a non-traversing relative path', + }); + } + stringField(value, 'sha256', path, issues, { pattern: SHA256_PATTERN }); + numberField(value, 'bytes', path, issues, { integer: true, min: 0 }); + if (value.redacted !== true) { + issues.push({ path: `${path}.redacted`, message: 'retained artifacts must be redacted' }); + } + timestampField(value, 'created_at', path, issues); + timestampField(value, 'retained_until', path, issues); + if (value.scenario_id !== undefined) + stringField(value, 'scenario_id', path, issues, { pattern: ID_PATTERN }); +} + +function validateCancellation(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (!['not_requested', 'requested', 'completed'].includes(String(value.state))) { + issues.push({ path: `${path}.state`, message: 'has an invalid state' }); + return; + } + if (value.state === 'requested' || value.state === 'completed') { + timestampField(value, 'requested_at', path, issues); + if (value.reason !== undefined) stringField(value, 'reason', path, issues); + } + if (value.state === 'completed') timestampField(value, 'completed_at', path, issues); +} + +function validateScenarioSummary(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + stringField(value, 'scenario_id', path, issues, { pattern: ID_PATTERN }); + if (!['passed', 'regression', 'no_confidence'].includes(String(value.outcome))) { + issues.push({ path: `${path}.outcome`, message: 'has an invalid outcome' }); + } + numberField(value, 'duration_ms', path, issues, { min: 0, max: 300_000 }); +} + +function validateRequest(value: unknown, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path: '$.request', message: 'must be an object' }); + return; + } + switch (value.type) { + case 'health': + return; + case 'verify_changed': { + stringField(value, 'run_id', '$.request', issues, { pattern: ID_PATTERN }); + validateChangeSet(value.change_set, '$.request.change_set', issues); + if (!isObject(value.options)) { + issues.push({ path: '$.request.options', message: 'must be an object' }); + } else { + if (typeof value.options.detailed_capture !== 'boolean') { + issues.push({ path: '$.request.options.detailed_capture', message: 'must be a boolean' }); + } + numberField(value.options, 'batch_timeout_ms', '$.request.options', issues, { + integer: true, + min: 1, + max: 300_000, + }); + } + return; + } + case 'cancel': + stringField(value, 'run_id', '$.request', issues, { pattern: ID_PATTERN }); + if (value.reason !== undefined) stringField(value, 'reason', '$.request', issues); + return; + case 'shutdown': + numberField(value, 'grace_ms', '$.request', issues, { + integer: true, + min: 0, + max: 30_000, + }); + return; + default: + issues.push({ path: '$.request.type', message: 'unsupported request type' }); + } +} + +function validateResult(value: unknown, path: string, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return; + } + if (value.schema_version !== VERIFY_RESULT_SCHEMA_VERSION) { + issues.push({ path: `${path}.schema_version`, message: 'unsupported result schema version' }); + } + if (value.protocol_version !== VERIFY_PROTOCOL_VERSION) { + issues.push({ path: `${path}.protocol_version`, message: 'unsupported protocol version' }); + } + stringField(value, 'run_id', path, issues, { pattern: ID_PATTERN }); + if (!['passed', 'regression', 'no_confidence'].includes(String(value.outcome))) { + issues.push({ + path: `${path}.outcome`, + message: 'must be passed, regression, or no_confidence', + }); + } + timestampField(value, 'started_at', path, issues); + timestampField(value, 'finished_at', path, issues); + if (typeof value.warm !== 'boolean') + issues.push({ path: `${path}.warm`, message: 'must be a boolean' }); + if (typeof value.stale !== 'boolean') + issues.push({ path: `${path}.stale`, message: 'must be a boolean' }); + if (value.model_call_count !== 0) { + issues.push({ + path: `${path}.model_call_count`, + message: 'normal verification must record zero model calls', + }); + } + + if (!isObject(value.source)) { + issues.push({ path: `${path}.source`, message: 'must be an object' }); + } else { + stringField(value.source, 'target_sha', `${path}.source`, issues, { pattern: GIT_SHA_PATTERN }); + if (!['worktree', 'staged', 'commit', 'range'].includes(String(value.source.change_set_kind))) { + issues.push({ + path: `${path}.source.change_set_kind`, + message: 'must be worktree, staged, commit, or range', + }); + } + if (value.source.change_set_revision !== undefined) { + stringField(value.source, 'change_set_revision', `${path}.source`, issues); + } + for (const key of [ + 'change_set_identity', + 'config_hash', + 'manifest_hash', + 'source_hash_before', + 'source_hash_after', + ]) { + stringField(value.source, key, `${path}.source`, issues, { pattern: SHA256_PATTERN }); + } + } + + if (!isObject(value.observation_policy)) { + issues.push({ path: `${path}.observation_policy`, message: 'must be an object' }); + } else { + if (value.observation_policy.schema_version !== 1) { + issues.push({ + path: `${path}.observation_policy.schema_version`, + message: 'unsupported observation policy version', + }); + } + stringField(value.observation_policy, 'profile_id', `${path}.observation_policy`, issues, { + pattern: ID_PATTERN, + }); + } + + if (!isObject(value.selection)) { + issues.push({ path: `${path}.selection`, message: 'must be an object' }); + } else { + stringArrayField( + value.selection, + 'changed_paths', + `${path}.selection`, + issues, + VERIFY_CONTRACT_LIMITS.maxChangedPaths + ); + for (const key of ['selected_scenario_ids', 'mandatory_smoke_ids', 'fallback_scenario_ids']) { + stringArrayField( + value.selection, + key, + `${path}.selection`, + issues, + VERIFY_CONTRACT_LIMITS.maxSelectedScenarios + ); + } + if (typeof value.selection.complete !== 'boolean') { + issues.push({ path: `${path}.selection.complete`, message: 'must be a boolean' }); + } + stringField(value.selection, 'explanation', `${path}.selection`, issues); + } + + const arrays: Array< + [string, number, (item: unknown, itemPath: string, itemIssues: ContractIssue[]) => void] + > = [ + ['scenarios', VERIFY_CONTRACT_LIMITS.maxSelectedScenarios, validateScenarioSummary], + ['timings', VERIFY_CONTRACT_LIMITS.maxTimings, validateTiming], + ['observations', VERIFY_CONTRACT_LIMITS.maxObservations, validateObservation], + ['limitations', VERIFY_CONTRACT_LIMITS.maxLimitations, validateLimitation], + ['artifacts', VERIFY_CONTRACT_LIMITS.maxArtifacts, validateArtifact], + ]; + for (const [key, max, validator] of arrays) { + const items = value[key]; + if (!Array.isArray(items)) issues.push({ path: `${path}.${key}`, message: 'must be an array' }); + else if (items.length > max) + issues.push({ path: `${path}.${key}`, message: `exceeds ${max} items` }); + if (Array.isArray(items)) { + items + .slice(0, max) + .forEach((item, index) => validator(item, `${path}.${key}[${index}]`, issues)); + } + } + + validateCancellation(value.cancellation, `${path}.cancellation`, issues); + + const sourceChanged = + isObject(value.source) && value.source.source_hash_before !== value.source.source_hash_after; + const cancelled = isObject(value.cancellation) && value.cancellation.state !== 'not_requested'; + const selectionIncomplete = isObject(value.selection) && value.selection.complete !== true; + if ( + (value.stale === true || sourceChanged || cancelled || selectionIncomplete) && + value.outcome !== 'no_confidence' + ) { + issues.push({ + path: `${path}.outcome`, + message: 'stale, changed-source, cancelled, or incomplete execution must be no_confidence', + }); + } + + if (value.outcome === 'passed') { + if (value.stale === true) + issues.push({ path: `${path}.stale`, message: 'a stale result cannot pass' }); + if (isObject(value.selection) && value.selection.complete !== true) { + issues.push({ + path: `${path}.selection.complete`, + message: 'an incomplete selection cannot pass', + }); + } + if (isObject(value.cancellation) && value.cancellation.state !== 'not_requested') { + issues.push({ path: `${path}.cancellation`, message: 'a cancelled result cannot pass' }); + } + if ( + Array.isArray(value.scenarios) && + value.scenarios.some((scenario) => isObject(scenario) && scenario.outcome !== 'passed') + ) { + issues.push({ + path: `${path}.scenarios`, + message: 'a passing result cannot contain a non-passing scenario', + }); + } + if ( + Array.isArray(value.observations) && + value.observations.some( + (observation) => + isObject(observation) && + (observation.disposition === 'regression' || observation.disposition === 'no_confidence') + ) + ) { + issues.push({ + path: `${path}.observations`, + message: 'a passing result cannot contain failing observations', + }); + } + if ( + Array.isArray(value.limitations) && + value.limitations.some( + (limitation) => isObject(limitation) && limitation.affects_confidence === true + ) + ) { + issues.push({ + path: `${path}.limitations`, + message: 'a passing result cannot contain confidence-blocking limitations', + }); + } + } +} + +function validateResponse(value: unknown, issues: ContractIssue[]): void { + if (!isObject(value)) { + issues.push({ path: '$.response', message: 'must be an object' }); + return; + } + switch (value.type) { + case 'health': + validateHealth(value.health, '$.response.health', issues); + return; + case 'verify_result': + validateResult(value.result, '$.response.result', issues); + return; + case 'cancel_ack': + stringField(value, 'run_id', '$.response', issues, { pattern: ID_PATTERN }); + if (typeof value.accepted !== 'boolean') { + issues.push({ path: '$.response.accepted', message: 'must be a boolean' }); + } + return; + case 'shutdown_ack': + stringArrayField( + value, + 'active_run_ids', + '$.response', + issues, + VERIFY_CONTRACT_LIMITS.maxActiveRuns + ); + return; + case 'error': + if (!isObject(value.error)) + issues.push({ path: '$.response.error', message: 'must be an object' }); + else { + stringField(value.error, 'code', '$.response.error', issues, { pattern: ID_PATTERN }); + stringField(value.error, 'message', '$.response.error', issues); + if (value.error.remediation !== undefined) { + stringField(value.error, 'remediation', '$.response.error', issues); + } + if (typeof value.error.retryable !== 'boolean') { + issues.push({ path: '$.response.error.retryable', message: 'must be a boolean' }); + } + } + return; + default: + issues.push({ path: '$.response.type', message: 'unsupported response type' }); + } +} + +function validateEnvelope( + value: unknown, + payloadKey: 'request' | 'response', + validatePayload: (value: unknown, issues: ContractIssue[]) => void +): ContractValidation { + const issues: ContractIssue[] = []; + const bytes = jsonBytes(value); + if (bytes === null) issues.push({ path: '$', message: 'must be JSON serializable' }); + else if (bytes > VERIFY_CONTRACT_LIMITS.maxFrameBytes) { + issues.push({ + path: '$', + message: `frame exceeds ${VERIFY_CONTRACT_LIMITS.maxFrameBytes} bytes`, + }); + } + validateBoundedValue(value, '$', 0, issues); + if (validateEnvelopeBase(value, issues)) validatePayload(value[payloadKey], issues); + return issues.length === 0 + ? { ok: true, value: value as T, bytes: bytes as number } + : { ok: false, issues, bytes }; +} + +export function validateDaemonRequestEnvelope( + value: unknown +): ContractValidation { + return validateEnvelope(value, 'request', validateRequest); +} + +export function validateDaemonResponseEnvelope( + value: unknown +): ContractValidation { + return validateEnvelope(value, 'response', validateResponse); +} + +export function exitCodeForOutcome(outcome: VerifyOutcome): VerifyExitCode { + return VERIFY_EXIT_CODES[outcome]; +} From 457ba978756547f00610d994014748c573829f5b Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:21:55 +0530 Subject: [PATCH 002/141] feat(verify): validate local target configuration --- apps/desktop/package.json | 3 +- .../warm-verification/config-loader.test.ts | 130 ++++ .../lib/warm-verification/config-loader.ts | 162 +++++ .../src/lib/warm-verification/config.test.ts | 159 +++++ .../src/lib/warm-verification/config.ts | 598 ++++++++++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 1054 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/warm-verification/config-loader.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/config-loader.ts create mode 100644 apps/desktop/src/lib/warm-verification/config.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/config.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 429739a..9f3c117 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -65,6 +65,7 @@ "tailwindcss-animate": "^1.0.7", "tsx": "^4.19.0", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "yaml": "2.8.3" } } diff --git a/apps/desktop/src/lib/warm-verification/config-loader.test.ts b/apps/desktop/src/lib/warm-verification/config-loader.test.ts new file mode 100644 index 0000000..f70f480 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/config-loader.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { + MAX_VERIFY_CONFIG_BYTES, + VerifyConfigLoadError, + VerifyConfigLoader, +} from './config-loader'; + +const VALID_YAML = ` +version: 1 +target: + command: [pnpm, exec, vite, --strictPort] + cwd: . + readinessUrl: http://127.0.0.1:4173/health + baseUrl: http://127.0.0.1:4173 + allowedEnv: [NODE_ENV] + hmrSettleMs: 250 + shutdownGraceMs: 2000 +scenarioModules: [verify/scenarios.ts] +authProfiles: + verified-investor: + storageState: .codevetter/auth/verified-investor.json +capabilities: + - id: portfolio + paths: [src/features/portfolio/**] + scenarios: [portfolio-empty] +mandatorySmoke: [app-shell] +sharedInfrastructure: + paths: [src/router/**] + fallbackScenarios: [app-shell, portfolio-empty] +network: + firstPartyOrigins: [http://127.0.0.1:4173] + allowedFirstPartyRequests: [GET /**, POST /api/portfolio/**] + blockThirdParty: true + allowedThirdPartyOrigins: [] +retention: + directory: .codevetter/verify-artifacts + maxRuns: 20 + maxBytes: 104857600 + maxAgeDays: 14 +budgets: + parallelism: 4 + actionMs: 5000 + scenarioMs: 15000 + batchMs: 30000 + slowInteractionMs: 500 +`; + +async function createRepo(source = VALID_YAML): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'codevetter-config-')); + await mkdir(path.join(root, '.codevetter'), { recursive: true }); + await writeFile(path.join(root, '.codevetter', 'verify.yaml'), source); + return root; +} + +describe('VerifyConfigLoader', () => { + it('parses strict YAML and reuses the immutable snapshot by content hash', async () => { + const root = await createRepo(); + const loader = await VerifyConfigLoader.create(root); + + const first = await loader.load(); + const second = await loader.load(); + + assert.strictEqual(second, first); + assert.match(first.hash, /^[a-f0-9]{64}$/); + assert.ok(Object.isFrozen(first.config)); + assert.ok(Object.isFrozen(first.config.capabilities)); + }); + + it('atomically replaces the cache after valid content changes', async () => { + const root = await createRepo(); + const loader = await VerifyConfigLoader.create(root); + const first = await loader.load(); + await writeFile( + path.join(root, '.codevetter', 'verify.yaml'), + VALID_YAML.replace('parallelism: 4', 'parallelism: 2') + ); + + const second = await loader.load(); + + assert.notStrictEqual(second, first); + assert.notEqual(second.hash, first.hash); + assert.equal(second.config.budgets.parallelism, 2); + }); + + it('preserves the last valid cache when a reload is invalid', async () => { + const root = await createRepo(); + const loader = await VerifyConfigLoader.create(root); + const first = await loader.load(); + await writeFile(path.join(root, '.codevetter', 'verify.yaml'), 'version: 99\n'); + + await assert.rejects(loader.load(), (error) => { + assert.ok(error instanceof VerifyConfigLoadError); + assert.equal(error.code, 'schema'); + return true; + }); + await writeFile(path.join(root, '.codevetter', 'verify.yaml'), VALID_YAML); + assert.strictEqual(await loader.load(), first); + }); + + it('rejects duplicate YAML keys, aliases, and oversized input', async () => { + const duplicateRoot = await createRepo(`${VALID_YAML}\nversion: 1\n`); + const duplicateLoader = await VerifyConfigLoader.create(duplicateRoot); + await assert.rejects(duplicateLoader.load(), (error) => { + assert.ok(error instanceof VerifyConfigLoadError); + assert.equal(error.code, 'yaml'); + return true; + }); + + const aliasRoot = await createRepo( + VALID_YAML.replace( + 'mandatorySmoke: [app-shell]', + 'mandatorySmoke: &smoke [app-shell]\nextra: *smoke' + ) + ); + const aliasLoader = await VerifyConfigLoader.create(aliasRoot); + await assert.rejects(aliasLoader.load(), VerifyConfigLoadError); + + const oversizedRoot = await createRepo(`# ${'x'.repeat(MAX_VERIFY_CONFIG_BYTES)}\n`); + const oversizedLoader = await VerifyConfigLoader.create(oversizedRoot); + await assert.rejects(oversizedLoader.load(), (error) => { + assert.ok(error instanceof VerifyConfigLoadError); + assert.equal(error.code, 'oversized'); + return true; + }); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/config-loader.ts b/apps/desktop/src/lib/warm-verification/config-loader.ts new file mode 100644 index 0000000..f6cf12d --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/config-loader.ts @@ -0,0 +1,162 @@ +import { createHash } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { parseDocument } from 'yaml'; +import { parseVerifyConfig, type VerifyConfig, VerifyConfigValidationError } from './config'; + +export const VERIFY_CONFIG_RELATIVE_PATH = '.codevetter/verify.yaml'; +export const MAX_VERIFY_CONFIG_BYTES = 262_144; + +export interface VerifyConfigSnapshot { + config: VerifyConfig; + configPath: string; + hash: string; + sourceBytes: number; +} + +export class VerifyConfigLoadError extends Error { + readonly code: 'missing' | 'oversized' | 'yaml' | 'schema' | 'unsafe_path'; + readonly details: string[]; + + constructor( + code: VerifyConfigLoadError['code'], + message: string, + details: string[] = [], + options?: ErrorOptions + ) { + super(message, options); + this.name = 'VerifyConfigLoadError'; + this.code = code; + this.details = details; + } +} + +export class VerifyConfigLoader { + readonly #repoRoot: string; + #cached: VerifyConfigSnapshot | undefined; + + private constructor(repoRoot: string) { + this.#repoRoot = repoRoot; + } + + static async create(repoRoot: string): Promise { + return new VerifyConfigLoader(await realpath(repoRoot)); + } + + async load(): Promise { + const configPath = path.join(this.#repoRoot, VERIFY_CONFIG_RELATIVE_PATH); + let source: string; + try { + source = await readFile(configPath, 'utf8'); + } catch (error) { + throw new VerifyConfigLoadError( + 'missing', + `Verification config not found at ${VERIFY_CONFIG_RELATIVE_PATH}`, + [], + { cause: error } + ); + } + + const sourceBytes = Buffer.byteLength(source); + if (sourceBytes > MAX_VERIFY_CONFIG_BYTES) { + throw new VerifyConfigLoadError( + 'oversized', + `Verification config is ${sourceBytes} bytes; maximum is ${MAX_VERIFY_CONFIG_BYTES}` + ); + } + + const hash = createHash('sha256').update(source).digest('hex'); + if (this.#cached?.hash === hash) { + return this.#cached; + } + + const document = parseDocument(source, { + merge: false, + prettyErrors: false, + strict: true, + uniqueKeys: true, + }); + const yamlErrors = document.errors.map((entry) => entry.message); + if (yamlErrors.length > 0) { + throw new VerifyConfigLoadError( + 'yaml', + 'Verification config is not valid strict YAML', + yamlErrors + ); + } + if (document.warnings.length > 0) { + throw new VerifyConfigLoadError( + 'yaml', + 'Verification config uses unsupported ambiguous YAML', + document.warnings.map((entry) => entry.message) + ); + } + + let value: unknown; + try { + value = document.toJS({ maxAliasCount: 0 }); + } catch (error) { + throw new VerifyConfigLoadError('yaml', 'Verification config aliases are not supported', [], { + cause: error, + }); + } + + let config: VerifyConfig; + try { + config = parseVerifyConfig(value); + } catch (error) { + if (error instanceof VerifyConfigValidationError) { + throw new VerifyConfigLoadError( + 'schema', + error.message, + error.issues.map((entry) => `${entry.path}: ${entry.message}`), + { cause: error } + ); + } + throw error; + } + + await this.#assertConfiguredPathsStayWithinRepo(config); + this.#cached = Object.freeze({ + config: deepFreeze(config), + configPath, + hash, + sourceBytes, + }); + return this.#cached; + } + + invalidate(): void { + this.#cached = undefined; + } + + async #assertConfiguredPathsStayWithinRepo(config: VerifyConfig): Promise { + const candidates = [ + config.target.cwd, + config.retention.directory, + ...config.scenarioModules, + ...Object.values(config.authProfiles).map((profile) => profile.storageState), + ]; + const escaped = candidates.filter((candidate) => { + const resolved = path.resolve(this.#repoRoot, candidate); + return resolved !== this.#repoRoot && !resolved.startsWith(`${this.#repoRoot}${path.sep}`); + }); + if (escaped.length > 0) { + throw new VerifyConfigLoadError( + 'unsafe_path', + 'Verification config contains paths outside the target repository', + escaped + ); + } + } +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + } + return value; +} diff --git a/apps/desktop/src/lib/warm-verification/config.test.ts b/apps/desktop/src/lib/warm-verification/config.test.ts new file mode 100644 index 0000000..6469b12 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/config.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseVerifyConfig, VerifyConfigValidationError } from './config'; + +function validConfig(): Record { + return { + version: 1, + target: { + command: ['pnpm', 'dev'], + cwd: '.', + readinessUrl: 'http://127.0.0.1:4173/health', + baseUrl: 'http://127.0.0.1:4173', + allowedEnv: ['NODE_ENV'], + hmrSettleMs: 250, + shutdownGraceMs: 2_000, + }, + scenarioModules: ['verify/scenarios.ts'], + authProfiles: { + 'verified-investor': { storageState: '.codevetter/auth/verified-investor.json' }, + }, + capabilities: [ + { + id: 'portfolio', + paths: ['src/features/portfolio/**', 'src/routes/portfolio/**'], + scenarios: ['portfolio-empty', 'portfolio-funded'], + }, + ], + mandatorySmoke: ['app-shell'], + sharedInfrastructure: { + paths: ['src/router/**', 'src/app.tsx'], + fallbackScenarios: ['app-shell', 'portfolio-empty'], + }, + network: { + firstPartyOrigins: ['http://127.0.0.1:4173'], + allowedFirstPartyRequests: ['GET /**', 'POST /api/portfolio/**'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }, + retention: { + directory: '.codevetter/verify-artifacts', + maxRuns: 20, + maxBytes: 104_857_600, + maxAgeDays: 14, + }, + budgets: { + parallelism: 4, + actionMs: 5_000, + scenarioMs: 15_000, + batchMs: 30_000, + slowInteractionMs: 500, + }, + }; +} + +function issuesFor(config: Record): Array<{ path: string; message: string }> { + try { + parseVerifyConfig(config); + assert.fail('expected verification config to be rejected'); + } catch (error) { + assert.ok(error instanceof VerifyConfigValidationError); + return error.issues; + } +} + +describe('parseVerifyConfig', () => { + it('accepts one explicit bounded local target', () => { + const parsed = parseVerifyConfig(validConfig()); + + assert.equal(parsed.version, 1); + assert.deepEqual(parsed.target.command, ['pnpm', 'dev']); + assert.equal(parsed.budgets.parallelism, 4); + assert.equal(parsed.capabilities[0]?.id, 'portfolio'); + }); + + it('rejects remote targets, inline environment values, and path escapes', () => { + const config = validConfig(); + config.target = { + ...(config.target as Record), + readinessUrl: 'https://example.com/health', + allowedEnv: ['API_KEY=secret'], + cwd: '../other-repo', + }; + config.authProfiles = { + admin: { storageState: '/tmp/admin.json' }, + }; + + const issues = issuesFor(config); + assert.ok(issues.some((entry) => entry.path === '$.target.readinessUrl')); + assert.ok(issues.some((entry) => entry.path === '$.target.allowedEnv[0]')); + assert.ok(issues.some((entry) => entry.path === '$.target.cwd')); + assert.ok(issues.some((entry) => entry.path === '$.authProfiles.admin.storageState')); + }); + + it('rejects duplicate capabilities, scenarios, and unsupported keys', () => { + const config = validConfig(); + config.capabilities = [ + ...(config.capabilities as unknown[]), + { + id: 'portfolio', + paths: ['src/portfolio/**'], + scenarios: ['portfolio-empty', 'portfolio-empty'], + inferred: true, + }, + ]; + + const issues = issuesFor(config); + assert.ok(issues.some((entry) => entry.message.includes('duplicates capability'))); + assert.ok(issues.some((entry) => entry.message.includes('duplicates "portfolio-empty"'))); + assert.ok(issues.some((entry) => entry.path.endsWith('.inferred'))); + }); + + it('rejects unbounded resources and incoherent timeouts', () => { + const config = validConfig(); + config.budgets = { + parallelism: 20, + actionMs: 20_000, + scenarioMs: 10_000, + batchMs: 5_000, + slowInteractionMs: 0, + }; + config.retention = { + directory: '.codevetter/verify-artifacts', + maxRuns: 1_000_000, + maxBytes: Number.MAX_SAFE_INTEGER, + maxAgeDays: 10_000, + }; + + const issues = issuesFor(config); + assert.ok(issues.some((entry) => entry.path === '$.budgets.parallelism')); + assert.ok(issues.some((entry) => entry.message === 'must not exceed scenarioMs')); + assert.ok(issues.some((entry) => entry.message === 'must not exceed batchMs')); + assert.ok(issues.some((entry) => entry.path === '$.retention.maxBytes')); + }); + + it('requires the app origin and safe explicit globs', () => { + const config = validConfig(); + config.capabilities = [ + { + id: 'portfolio', + paths: ['../src/**', '!src/secret/**', 'src/{one,two}/**'], + scenarios: ['portfolio-empty'], + }, + ]; + config.network = { + firstPartyOrigins: ['http://localhost:4173'], + allowedFirstPartyRequests: ['TRACE /api/**'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }; + + const issues = issuesFor(config); + assert.equal( + issues.filter((entry) => entry.path.startsWith('$.capabilities[0].paths')).length, + 3 + ); + assert.ok(issues.some((entry) => entry.message.includes('must include target base origin'))); + assert.ok(issues.some((entry) => entry.path === '$.network.allowedFirstPartyRequests[0]')); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/config.ts b/apps/desktop/src/lib/warm-verification/config.ts new file mode 100644 index 0000000..65ffcf1 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/config.ts @@ -0,0 +1,598 @@ +export const VERIFY_CONFIG_VERSION = 1 as const; + +export interface VerifyServerConfig { + command: [string, ...string[]]; + cwd: string; + readinessUrl: string; + baseUrl: string; + allowedEnv: string[]; + hmrSettleMs: number; + shutdownGraceMs: number; +} + +export interface VerifyAuthProfileConfig { + storageState: string; +} + +export interface VerifyCapabilityConfig { + id: string; + paths: string[]; + scenarios: string[]; +} + +export interface VerifySharedInfrastructureConfig { + paths: string[]; + fallbackScenarios: string[]; +} + +export interface VerifyNetworkConfig { + firstPartyOrigins: string[]; + allowedFirstPartyRequests: string[]; + blockThirdParty: boolean; + allowedThirdPartyOrigins: string[]; +} + +export interface VerifyRetentionConfig { + directory: string; + maxRuns: number; + maxBytes: number; + maxAgeDays: number; +} + +export interface VerifyBudgetConfig { + parallelism: 1 | 2 | 3 | 4; + actionMs: number; + scenarioMs: number; + batchMs: number; + slowInteractionMs: number; +} + +export interface VerifyConfig { + version: typeof VERIFY_CONFIG_VERSION; + target: VerifyServerConfig; + scenarioModules: string[]; + authProfiles: Record; + capabilities: VerifyCapabilityConfig[]; + mandatorySmoke: string[]; + sharedInfrastructure: VerifySharedInfrastructureConfig; + network: VerifyNetworkConfig; + retention: VerifyRetentionConfig; + budgets: VerifyBudgetConfig; +} + +export interface VerifyConfigIssue { + path: string; + message: string; +} + +const LIMITS = { + actionMs: [50, 60_000], + scenarioMs: [100, 300_000], + batchMs: [100, 600_000], + slowInteractionMs: [10, 60_000], + hmrSettleMs: [0, 30_000], + shutdownGraceMs: [100, 30_000], + maxRuns: [1, 1_000], + maxBytes: [1_048_576, 10_737_418_240], + maxAgeDays: [1, 365], +} as const; + +const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; +const MODULE_PATTERN = /\.(?:[cm]?[jt]s)$/; + +export class VerifyConfigValidationError extends Error { + readonly issues: VerifyConfigIssue[]; + + constructor(issues: VerifyConfigIssue[]) { + super( + `Invalid CodeVetter verification config (${issues.length} issue${issues.length === 1 ? '' : 's'})` + ); + this.name = 'VerifyConfigValidationError'; + this.issues = issues; + } +} + +export function parseVerifyConfig(value: unknown): VerifyConfig { + const issues: VerifyConfigIssue[] = []; + const root = objectAt(value, '$', issues); + + rejectUnknownKeys( + root, + [ + 'version', + 'target', + 'scenarioModules', + 'authProfiles', + 'capabilities', + 'mandatorySmoke', + 'sharedInfrastructure', + 'network', + 'retention', + 'budgets', + ], + '$', + issues + ); + + const version = integerAt(root.version, '$.version', issues); + if (version !== VERIFY_CONFIG_VERSION) { + issue(issues, '$.version', `must equal ${VERIFY_CONFIG_VERSION}`); + } + + const target = parseTarget(root.target, issues); + const scenarioModules = uniqueStrings(root.scenarioModules, '$.scenarioModules', issues, { + min: 1, + validate: (entry) => + isSafeRelativePath(entry) && MODULE_PATTERN.test(entry) + ? undefined + : 'must be a repository-relative JavaScript or TypeScript module path', + }); + const authProfiles = parseAuthProfiles(root.authProfiles, issues); + const capabilities = parseCapabilities(root.capabilities, issues); + const mandatorySmoke = uniqueIds(root.mandatorySmoke, '$.mandatorySmoke', issues, 1); + const sharedInfrastructure = parseSharedInfrastructure(root.sharedInfrastructure, issues); + const network = parseNetwork(root.network, target.baseUrl, issues); + const retention = parseRetention(root.retention, issues); + const budgets = parseBudgets(root.budgets, issues); + + const scenarioIds = new Set( + capabilities + .flatMap((capability) => capability.scenarios) + .concat(mandatorySmoke, sharedInfrastructure.fallbackScenarios) + ); + if (scenarioIds.size === 0) { + issue(issues, '$.capabilities', 'must reference at least one scenario'); + } + + if (issues.length > 0) { + throw new VerifyConfigValidationError(issues); + } + + return { + version: VERIFY_CONFIG_VERSION, + target, + scenarioModules, + authProfiles, + capabilities, + mandatorySmoke, + sharedInfrastructure, + network, + retention, + budgets, + }; +} + +function parseTarget(value: unknown, issues: VerifyConfigIssue[]): VerifyServerConfig { + const path = '$.target'; + const target = objectAt(value, path, issues); + rejectUnknownKeys( + target, + ['command', 'cwd', 'readinessUrl', 'baseUrl', 'allowedEnv', 'hmrSettleMs', 'shutdownGraceMs'], + path, + issues + ); + const command = uniqueStrings(target.command, `${path}.command`, issues, { + min: 1, + unique: false, + }); + if (command.some((part) => part.includes('\0'))) { + issue(issues, `${path}.command`, 'must not contain null bytes'); + } + const cwd = stringAt(target.cwd, `${path}.cwd`, issues); + if (!isSafeRelativePath(cwd)) { + issue(issues, `${path}.cwd`, 'must be a repository-relative path without parent traversal'); + } + const readinessUrl = loopbackUrlAt(target.readinessUrl, `${path}.readinessUrl`, issues); + const baseUrl = loopbackUrlAt(target.baseUrl, `${path}.baseUrl`, issues); + const allowedEnv = uniqueStrings(target.allowedEnv, `${path}.allowedEnv`, issues, { + validate: (entry) => + ENV_NAME_PATTERN.test(entry) ? undefined : 'must be an uppercase environment variable name', + }); + + return { + command: (command.length > 0 ? command : ['false']) as [string, ...string[]], + cwd, + readinessUrl, + baseUrl, + allowedEnv, + hmrSettleMs: boundedInteger( + target.hmrSettleMs, + `${path}.hmrSettleMs`, + LIMITS.hmrSettleMs, + issues + ), + shutdownGraceMs: boundedInteger( + target.shutdownGraceMs, + `${path}.shutdownGraceMs`, + LIMITS.shutdownGraceMs, + issues + ), + }; +} + +function parseAuthProfiles( + value: unknown, + issues: VerifyConfigIssue[] +): Record { + const path = '$.authProfiles'; + const profiles = objectAt(value, path, issues); + const parsed: Record = {}; + for (const [id, profileValue] of Object.entries(profiles)) { + if (!ID_PATTERN.test(id)) { + issue( + issues, + `${path}.${id}`, + 'profile ID must be lowercase kebab, dot, or underscore syntax' + ); + } + const profilePath = `${path}.${id}`; + const profile = objectAt(profileValue, profilePath, issues); + rejectUnknownKeys(profile, ['storageState'], profilePath, issues); + const storageState = stringAt(profile.storageState, `${profilePath}.storageState`, issues); + if (!isSafeRelativePath(storageState)) { + issue( + issues, + `${profilePath}.storageState`, + 'must be a repository-relative path without parent traversal' + ); + } + parsed[id] = { storageState }; + } + if (Object.keys(parsed).length === 0) { + issue(issues, path, 'must define at least one authentication profile'); + } + return parsed; +} + +function parseCapabilities(value: unknown, issues: VerifyConfigIssue[]): VerifyCapabilityConfig[] { + const path = '$.capabilities'; + const items = arrayAt(value, path, issues); + const ids = new Set(); + const parsed = items.map((item, index) => { + const itemPath = `${path}[${index}]`; + const capability = objectAt(item, itemPath, issues); + rejectUnknownKeys(capability, ['id', 'paths', 'scenarios'], itemPath, issues); + const id = idAt(capability.id, `${itemPath}.id`, issues); + if (ids.has(id)) { + issue(issues, `${itemPath}.id`, `duplicates capability ${JSON.stringify(id)}`); + } + ids.add(id); + return { + id, + paths: uniqueStrings(capability.paths, `${itemPath}.paths`, issues, { + min: 1, + validate: validateGlob, + }), + scenarios: uniqueIds(capability.scenarios, `${itemPath}.scenarios`, issues, 1), + }; + }); + if (parsed.length === 0) { + issue(issues, path, 'must define at least one capability'); + } + return parsed; +} + +function parseSharedInfrastructure( + value: unknown, + issues: VerifyConfigIssue[] +): VerifySharedInfrastructureConfig { + const path = '$.sharedInfrastructure'; + const shared = objectAt(value, path, issues); + rejectUnknownKeys(shared, ['paths', 'fallbackScenarios'], path, issues); + return { + paths: uniqueStrings(shared.paths, `${path}.paths`, issues, { min: 1, validate: validateGlob }), + fallbackScenarios: uniqueIds(shared.fallbackScenarios, `${path}.fallbackScenarios`, issues, 1), + }; +} + +function parseNetwork( + value: unknown, + baseUrl: string, + issues: VerifyConfigIssue[] +): VerifyNetworkConfig { + const path = '$.network'; + const network = objectAt(value, path, issues); + rejectUnknownKeys( + network, + [ + 'firstPartyOrigins', + 'allowedFirstPartyRequests', + 'blockThirdParty', + 'allowedThirdPartyOrigins', + ], + path, + issues + ); + const firstPartyOrigins = uniqueStrings( + network.firstPartyOrigins, + `${path}.firstPartyOrigins`, + issues, + { + min: 1, + validate: validateOrigin, + } + ); + const baseOrigin = safeOrigin(baseUrl); + if (baseOrigin && !firstPartyOrigins.includes(baseOrigin)) { + issue( + issues, + `${path}.firstPartyOrigins`, + `must include target base origin ${JSON.stringify(baseOrigin)}` + ); + } + return { + firstPartyOrigins, + allowedFirstPartyRequests: uniqueStrings( + network.allowedFirstPartyRequests, + `${path}.allowedFirstPartyRequests`, + issues, + { min: 1, validate: validateRequestRule } + ), + blockThirdParty: booleanAt(network.blockThirdParty, `${path}.blockThirdParty`, issues), + allowedThirdPartyOrigins: uniqueStrings( + network.allowedThirdPartyOrigins, + `${path}.allowedThirdPartyOrigins`, + issues, + { validate: validateOrigin } + ), + }; +} + +function parseRetention(value: unknown, issues: VerifyConfigIssue[]): VerifyRetentionConfig { + const path = '$.retention'; + const retention = objectAt(value, path, issues); + rejectUnknownKeys(retention, ['directory', 'maxRuns', 'maxBytes', 'maxAgeDays'], path, issues); + const directory = stringAt(retention.directory, `${path}.directory`, issues); + if (!isSafeRelativePath(directory)) { + issue( + issues, + `${path}.directory`, + 'must be a repository-relative path without parent traversal' + ); + } + return { + directory, + maxRuns: boundedInteger(retention.maxRuns, `${path}.maxRuns`, LIMITS.maxRuns, issues), + maxBytes: boundedInteger(retention.maxBytes, `${path}.maxBytes`, LIMITS.maxBytes, issues), + maxAgeDays: boundedInteger( + retention.maxAgeDays, + `${path}.maxAgeDays`, + LIMITS.maxAgeDays, + issues + ), + }; +} + +function parseBudgets(value: unknown, issues: VerifyConfigIssue[]): VerifyBudgetConfig { + const path = '$.budgets'; + const budgets = objectAt(value, path, issues); + rejectUnknownKeys( + budgets, + ['parallelism', 'actionMs', 'scenarioMs', 'batchMs', 'slowInteractionMs'], + path, + issues + ); + const parallelism = boundedInteger(budgets.parallelism, `${path}.parallelism`, [1, 4], issues); + const actionMs = boundedInteger(budgets.actionMs, `${path}.actionMs`, LIMITS.actionMs, issues); + const scenarioMs = boundedInteger( + budgets.scenarioMs, + `${path}.scenarioMs`, + LIMITS.scenarioMs, + issues + ); + const batchMs = boundedInteger(budgets.batchMs, `${path}.batchMs`, LIMITS.batchMs, issues); + if (actionMs > scenarioMs) { + issue(issues, `${path}.actionMs`, 'must not exceed scenarioMs'); + } + if (scenarioMs > batchMs) { + issue(issues, `${path}.scenarioMs`, 'must not exceed batchMs'); + } + return { + parallelism: parallelism as 1 | 2 | 3 | 4, + actionMs, + scenarioMs, + batchMs, + slowInteractionMs: boundedInteger( + budgets.slowInteractionMs, + `${path}.slowInteractionMs`, + LIMITS.slowInteractionMs, + issues + ), + }; +} + +function objectAt( + value: unknown, + path: string, + issues: VerifyConfigIssue[] +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + issue(issues, path, 'must be an object'); + return {}; + } + return value as Record; +} + +function arrayAt(value: unknown, path: string, issues: VerifyConfigIssue[]): unknown[] { + if (!Array.isArray(value)) { + issue(issues, path, 'must be an array'); + return []; + } + return value; +} + +function stringAt(value: unknown, path: string, issues: VerifyConfigIssue[]): string { + if (typeof value !== 'string' || value.trim() === '') { + issue(issues, path, 'must be a non-empty string'); + return ''; + } + return value; +} + +function booleanAt(value: unknown, path: string, issues: VerifyConfigIssue[]): boolean { + if (typeof value !== 'boolean') { + issue(issues, path, 'must be a boolean'); + return false; + } + return value; +} + +function integerAt(value: unknown, path: string, issues: VerifyConfigIssue[]): number { + if (!Number.isSafeInteger(value)) { + issue(issues, path, 'must be a safe integer'); + return 0; + } + return value as number; +} + +function boundedInteger( + value: unknown, + path: string, + bounds: readonly [number, number], + issues: VerifyConfigIssue[] +): number { + const parsed = integerAt(value, path, issues); + if (parsed < bounds[0] || parsed > bounds[1]) { + issue(issues, path, `must be between ${bounds[0]} and ${bounds[1]}`); + } + return parsed; +} + +function idAt(value: unknown, path: string, issues: VerifyConfigIssue[]): string { + const parsed = stringAt(value, path, issues); + if (!ID_PATTERN.test(parsed)) { + issue(issues, path, 'must use lowercase kebab, dot, or underscore syntax'); + } + return parsed; +} + +function uniqueIds(value: unknown, path: string, issues: VerifyConfigIssue[], min = 0): string[] { + return uniqueStrings(value, path, issues, { + min, + validate: (entry) => + ID_PATTERN.test(entry) ? undefined : 'must use lowercase kebab, dot, or underscore syntax', + }); +} + +function uniqueStrings( + value: unknown, + path: string, + issues: VerifyConfigIssue[], + options: { + min?: number; + unique?: boolean; + validate?: (value: string) => string | undefined; + } = {} +): string[] { + const values = arrayAt(value, path, issues); + const parsed: string[] = []; + const seen = new Set(); + for (const [index, item] of values.entries()) { + const itemPath = `${path}[${index}]`; + const entry = stringAt(item, itemPath, issues); + const validation = entry ? options.validate?.(entry) : undefined; + if (validation) { + issue(issues, itemPath, validation); + } + if (options.unique !== false && seen.has(entry)) { + issue(issues, itemPath, `duplicates ${JSON.stringify(entry)}`); + } + seen.add(entry); + parsed.push(entry); + } + if (parsed.length < (options.min ?? 0)) { + issue(issues, path, `must contain at least ${options.min} item${options.min === 1 ? '' : 's'}`); + } + return parsed; +} + +function rejectUnknownKeys( + value: Record, + allowed: readonly string[], + path: string, + issues: VerifyConfigIssue[] +): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(value)) { + if (!allowedSet.has(key)) { + issue(issues, `${path}.${key}`, 'is not supported'); + } + } +} + +function loopbackUrlAt(value: unknown, path: string, issues: VerifyConfigIssue[]): string { + const raw = stringAt(value, path, issues); + try { + const url = new URL(raw); + const loopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + if (!loopback || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + issue(issues, path, 'must be an unauthenticated HTTP(S) loopback URL'); + } + } catch { + issue(issues, path, 'must be a valid URL'); + } + return raw; +} + +function safeOrigin(value: string): string | undefined { + try { + return new URL(value).origin; + } catch { + return undefined; + } +} + +function validateOrigin(value: string): string | undefined { + try { + const url = new URL(value); + if ( + url.origin !== value || + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password + ) { + return 'must be an unauthenticated HTTP(S) origin without a path'; + } + } catch { + return 'must be a valid HTTP(S) origin'; + } + return undefined; +} + +function validateRequestRule(value: string): string | undefined { + const match = /^([A-Z]+) (\/[^\s]*)$/.exec(value); + if (!match) return 'must use METHOD /repository-relative-path syntax'; + if (!['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(match[1] ?? '')) { + return 'uses an unsupported HTTP method'; + } + return validateGlob((match[2] ?? '').slice(1)); +} + +function validateGlob(value: string): string | undefined { + if (!isSafeRelativePath(value) || value.startsWith('!')) { + return 'must be a non-negated repository-relative glob without parent traversal'; + } + if (value.includes('[') || value.includes(']') || value.includes('{') || value.includes('}')) { + return 'must use only literal segments plus *, **, and ? wildcards'; + } + return undefined; +} + +function isSafeRelativePath(value: string): boolean { + if ( + !value || + value.startsWith('/') || + value.startsWith('~') || + value.includes('\\') || + value.includes('\0') + ) { + return false; + } + return !value.split('/').some((segment) => segment === '..'); +} + +function issue(issues: VerifyConfigIssue[], path: string, message: string): void { + issues.push({ path, message }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6dc4a3..7b83a40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: vite: specifier: ^6.0.0 version: 6.4.2(@types/node@22.19.17)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + yaml: + specifier: 2.8.3 + version: 2.8.3 apps/landing-page-astro: dependencies: From 17b528499b40bb6b12e6e5e1a858b02b3ac77de1 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:22:14 +0530 Subject: [PATCH 003/141] feat(verify): publish deterministic scenario selection --- .../warm-verification/manifest-loader.test.ts | 170 +++++ .../lib/warm-verification/manifest-loader.ts | 237 ++++++ .../lib/warm-verification/scenario.test.ts | 144 ++++ .../src/lib/warm-verification/scenario.ts | 711 ++++++++++++++++++ .../lib/warm-verification/selection.test.ts | 161 ++++ .../src/lib/warm-verification/selection.ts | 285 +++++++ 6 files changed, 1708 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/manifest-loader.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/manifest-loader.ts create mode 100644 apps/desktop/src/lib/warm-verification/scenario.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/scenario.ts create mode 100644 apps/desktop/src/lib/warm-verification/selection.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/selection.ts diff --git a/apps/desktop/src/lib/warm-verification/manifest-loader.test.ts b/apps/desktop/src/lib/warm-verification/manifest-loader.test.ts new file mode 100644 index 0000000..6e2706b --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/manifest-loader.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { parseVerifyConfig, type VerifyConfig } from './config'; +import type { VerifyConfigSnapshot } from './config-loader'; +import { ScenarioManifestLoadError, ScenarioManifestLoader } from './manifest-loader'; + +function config(scenarioModules = ['verify/scenarios.mjs']): VerifyConfig { + return parseVerifyConfig({ + version: 1, + target: { + command: ['pnpm', 'exec', 'vite'], + cwd: '.', + readinessUrl: 'http://127.0.0.1:4173/health', + baseUrl: 'http://127.0.0.1:4173', + allowedEnv: [], + hmrSettleMs: 250, + shutdownGraceMs: 2_000, + }, + scenarioModules, + authProfiles: { developer: { storageState: '.codevetter/auth/developer.json' } }, + capabilities: [ + { id: 'portfolio', paths: ['src/portfolio/**'], scenarios: ['portfolio-empty'] }, + ], + mandatorySmoke: ['portfolio-empty'], + sharedInfrastructure: { + paths: ['src/router/**'], + fallbackScenarios: ['portfolio-empty'], + }, + network: { + firstPartyOrigins: ['http://127.0.0.1:4173'], + allowedFirstPartyRequests: ['GET /**'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }, + retention: { + directory: '.codevetter/artifacts', + maxRuns: 20, + maxBytes: 104_857_600, + maxAgeDays: 14, + }, + budgets: { + parallelism: 4, + actionMs: 5_000, + scenarioMs: 15_000, + batchMs: 30_000, + slowInteractionMs: 500, + }, + }); +} + +const MODULE_SOURCE = ` +export const scenarioModule = { + id: 'portfolio-module', + scenarios: [{ + schemaVersion: 1, + id: 'portfolio-empty', + capabilityIds: ['portfolio'], + route: '/portfolio', + authProfileId: 'developer', + stateName: 'empty', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: { portfolio: true }, + timeouts: { actionMs: 1000, scenarioMs: 5000 }, + actions: [{ id: 'open', kind: 'click', description: 'Open portfolio' }], + assertions: [{ id: 'visible', kind: 'visible', description: 'Portfolio is visible' }], + async run() {} + }] +}; +`; + +async function fixtureRepo(source = MODULE_SOURCE): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'codevetter-manifest-')); + await mkdir(path.join(root, 'verify'), { recursive: true }); + await writeFile(path.join(root, 'verify', 'scenarios.mjs'), source); + return root; +} + +function snapshot(root: string, candidate = config()): VerifyConfigSnapshot { + return { + config: candidate, + configPath: path.join(root, '.codevetter', 'verify.yaml'), + hash: 'a'.repeat(64), + sourceBytes: 1, + }; +} + +describe('ScenarioManifestLoader', () => { + it('hashes loaded source and reuses an atomic immutable manifest', async () => { + const root = await fixtureRepo(); + const loader = await ScenarioManifestLoader.create(root); + + const first = await loader.load(snapshot(root), '2026-07-15T10:00:00.000Z'); + const second = await loader.load(snapshot(root), '2026-07-15T10:01:00.000Z'); + + assert.strictEqual(second, first); + assert.match(first.manifestHash, /^[a-f0-9]{64}$/); + assert.match(first.scenarios[0]?.sourceHash ?? '', /^[a-f0-9]{64}$/); + assert.ok(Object.isFrozen(first)); + }); + + it('publishes a new manifest when source bytes change', async () => { + const root = await fixtureRepo(); + const loader = await ScenarioManifestLoader.create(root); + const first = await loader.load(snapshot(root), '2026-07-15T10:00:00.000Z'); + await writeFile( + path.join(root, 'verify', 'scenarios.mjs'), + MODULE_SOURCE.replace("description: 'Open portfolio'", "description: 'Open empty portfolio'") + ); + + const second = await loader.load(snapshot(root), '2026-07-15T10:01:00.000Z'); + + assert.notStrictEqual(second, first); + assert.notEqual(second.manifestHash, first.manifestHash); + assert.notEqual(second.scenarios[0]?.sourceHash, first.scenarios[0]?.sourceHash); + }); + + it('preserves the prior current manifest when a reload is invalid', async () => { + const root = await fixtureRepo(); + const loader = await ScenarioManifestLoader.create(root); + const first = await loader.load(snapshot(root)); + await writeFile(path.join(root, 'verify', 'scenarios.mjs'), 'throw new Error("broken module")'); + + await assert.rejects(loader.load(snapshot(root)), ScenarioManifestLoadError); + assert.strictEqual(loader.current, first); + }); + + it('rejects configuration and manifest mismatches before publication', async () => { + const root = await fixtureRepo(); + const loader = await ScenarioManifestLoader.create(root); + const candidate = config(); + candidate.mandatorySmoke = ['unknown-scenario']; + + await assert.rejects(loader.load(snapshot(root, candidate)), (error) => { + assert.ok(error instanceof ScenarioManifestLoadError); + assert.equal(error.code, 'config_mismatch'); + assert.ok(error.details.some((entry) => entry.includes('unknown-scenario'))); + return true; + }); + assert.equal(loader.current, undefined); + }); + + it('rejects relative helper imports whose runtime bytes would escape the source hash', async () => { + const root = await fixtureRepo(`import './helper.mjs';\n${MODULE_SOURCE}`); + await writeFile(path.join(root, 'verify', 'helper.mjs'), 'export const helper = true;\n'); + const loader = await ScenarioManifestLoader.create(root); + + await assert.rejects(loader.load(snapshot(root)), (error) => { + assert.ok(error instanceof ScenarioManifestLoadError); + assert.equal(error.code, 'contract'); + assert.match(error.message, /bundle every helper/); + return true; + }); + }); + + it('rejects package imports that could escape source hashing or reach model providers', async () => { + const root = await fixtureRepo(`import OpenAI from 'openai';\n${MODULE_SOURCE}`); + const loader = await ScenarioManifestLoader.create(root); + + await assert.rejects(loader.load(snapshot(root)), (error) => { + assert.ok(error instanceof ScenarioManifestLoadError); + assert.equal(error.code, 'contract'); + assert.match(error.message, /imports "openai"/); + assert.match(error.message, /zero-model boundary/); + return true; + }); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/manifest-loader.ts b/apps/desktop/src/lib/warm-verification/manifest-loader.ts new file mode 100644 index 0000000..7a7d2bb --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/manifest-loader.ts @@ -0,0 +1,237 @@ +import { createHash } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { VerifyConfigSnapshot } from './config-loader'; +import { + publishScenarioManifest, + type DeterministicScenario, + type ScenarioManifest, + type ScenarioModuleSource, +} from './scenario'; +import { validateConfigAgainstScenarios } from './selection'; + +export const MAX_SCENARIO_MODULE_BYTES = 1_048_576; +export const MAX_SCENARIO_SOURCE_BYTES = 8_388_608; + +export class ScenarioManifestLoadError extends Error { + readonly code: + | 'missing' + | 'unsafe_path' + | 'oversized' + | 'import' + | 'contract' + | 'config_mismatch'; + readonly details: readonly string[]; + + constructor( + code: ScenarioManifestLoadError['code'], + message: string, + details: readonly string[] = [], + options?: ErrorOptions + ) { + super(message, options); + this.name = 'ScenarioManifestLoadError'; + this.code = code; + this.details = details; + } +} + +interface ImportedScenarioModule { + id: string; + scenarios: readonly DeterministicScenario[]; +} + +interface LoadedSource { + path: string; + source: Uint8Array; + sourceHash: string; +} + +export class ScenarioManifestLoader { + readonly #repoRoot: string; + #cached: Readonly | undefined; + #cacheKey: string | undefined; + + private constructor(repoRoot: string) { + this.#repoRoot = repoRoot; + } + + static async create(repoRoot: string): Promise { + return new ScenarioManifestLoader(await realpath(repoRoot)); + } + + get current(): Readonly | undefined { + return this.#cached; + } + + async load( + configSnapshot: VerifyConfigSnapshot, + generatedAt = new Date().toISOString() + ): Promise> { + const sources = await Promise.all( + configSnapshot.config.scenarioModules.map((modulePath) => this.#loadSource(modulePath)) + ); + const totalBytes = sources.reduce((total, entry) => total + entry.source.byteLength, 0); + if (totalBytes > MAX_SCENARIO_SOURCE_BYTES) { + throw new ScenarioManifestLoadError( + 'oversized', + `Scenario sources total ${totalBytes} bytes; maximum is ${MAX_SCENARIO_SOURCE_BYTES}` + ); + } + + const cacheKey = createHash('sha256') + .update(configSnapshot.hash) + .update('\0') + .update(sources.map((entry) => `${entry.path}\0${entry.sourceHash}`).join('\0')) + .digest('hex'); + if (this.#cached && this.#cacheKey === cacheKey) return this.#cached; + + const modules: ScenarioModuleSource[] = []; + try { + for (const source of sources) { + const imported = await importScenarioModule(source); + modules.push({ id: imported.id, source: source.source, scenarios: imported.scenarios }); + } + } catch (error) { + if (error instanceof ScenarioManifestLoadError) throw error; + throw new ScenarioManifestLoadError( + 'import', + 'Could not import deterministic scenario module', + [], + { + cause: error, + } + ); + } + + let candidate: Readonly; + try { + candidate = publishScenarioManifest({ + generatedAt, + batchTimeoutMs: configSnapshot.config.budgets.batchMs, + parallelism: configSnapshot.config.budgets.parallelism, + modules, + }); + } catch (error) { + throw new ScenarioManifestLoadError( + 'contract', + 'Scenario modules do not satisfy the deterministic contract', + error instanceof Error ? [error.message] : [], + { cause: error } + ); + } + + const configIssues = validateConfigAgainstScenarios( + configSnapshot.config, + candidate.scenarios.map((scenario) => ({ + id: scenario.id, + capabilityIds: scenario.capabilityIds, + authProfileId: scenario.authProfileId, + })) + ); + if (configIssues.length > 0) { + throw new ScenarioManifestLoadError( + 'config_mismatch', + 'Verification config and scenario manifest do not agree', + configIssues.map((entry) => `${entry.path}: ${entry.message}`) + ); + } + + this.#cached = candidate; + this.#cacheKey = cacheKey; + return candidate; + } + + invalidate(): void { + this.#cached = undefined; + this.#cacheKey = undefined; + } + + async #loadSource(configuredPath: string): Promise { + const expectedPath = path.resolve(this.#repoRoot, configuredPath); + let sourcePath: string; + let source: Uint8Array; + try { + sourcePath = await realpath(expectedPath); + source = await readFile(sourcePath); + } catch (error) { + throw new ScenarioManifestLoadError( + 'missing', + `Scenario module is not readable: ${configuredPath}`, + [], + { cause: error } + ); + } + if (sourcePath !== this.#repoRoot && !sourcePath.startsWith(`${this.#repoRoot}${path.sep}`)) { + throw new ScenarioManifestLoadError( + 'unsafe_path', + `Scenario module resolves outside the target repository: ${configuredPath}` + ); + } + if (source.byteLength > MAX_SCENARIO_MODULE_BYTES) { + throw new ScenarioManifestLoadError( + 'oversized', + `Scenario module ${configuredPath} is ${source.byteLength} bytes; maximum is ${MAX_SCENARIO_MODULE_BYTES}` + ); + } + const dependencyImport = scenarioDependencyImport(new TextDecoder().decode(source)); + if (dependencyImport) { + throw new ScenarioManifestLoadError( + 'contract', + `Scenario module ${configuredPath} imports ${dependencyImport}; bundle every helper into the configured module so its source hash and zero-model boundary are complete` + ); + } + return { + path: sourcePath, + source, + sourceHash: createHash('sha256').update(source).digest('hex'), + }; + } +} + +function scenarioDependencyImport(source: string): string | undefined { + const patterns = [ + /^\s*(?:import|export)\b[^'"\n]*\bfrom\s*['"]([^'"]+)['"]/gm, + /^\s*import\s*['"]([^'"]+)['"]/gm, + /\b(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ]; + for (const pattern of patterns) { + const match = pattern.exec(source); + if (match?.[1]) return JSON.stringify(match[1]); + } + return undefined; +} + +async function importScenarioModule(source: LoadedSource): Promise { + const moduleUrl = pathToFileURL(source.path); + moduleUrl.searchParams.set('codevetter_source', source.sourceHash); + let namespace: Record; + try { + namespace = (await import(moduleUrl.href)) as Record; + } catch (error) { + throw new ScenarioManifestLoadError( + 'import', + `Scenario module could not be evaluated: ${path.basename(source.path)}`, + [], + { cause: error } + ); + } + const value = namespace.scenarioModule ?? namespace.default; + if (!isImportedScenarioModule(value)) { + throw new ScenarioManifestLoadError( + 'contract', + `Scenario module ${path.basename(source.path)} must export scenarioModule or default with id and scenarios` + ); + } + return value; +} + +function isImportedScenarioModule(value: unknown): value is ImportedScenarioModule { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + Array.isArray((value as { scenarios?: unknown }).scenarios) + ); +} diff --git a/apps/desktop/src/lib/warm-verification/scenario.test.ts b/apps/desktop/src/lib/warm-verification/scenario.test.ts new file mode 100644 index 0000000..59644c6 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/scenario.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + publishScenarioManifest, + ScenarioContractError, + validateScenarioManifest, + type DeterministicScenario, + type ScenarioManifest, +} from './scenario'; + +function scenario(overrides: Partial = {}): DeterministicScenario { + return { + schemaVersion: 1, + id: 'portfolio-funded', + capabilityIds: ['portfolio'], + route: '/portfolio', + authProfileId: 'verified-investor', + stateName: 'funded-empty-portfolio', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: { 'recurring-investments': true }, + timeouts: { actionMs: 2_000, scenarioMs: 15_000 }, + tags: ['smoke'], + actions: [ + { id: 'open-create', kind: 'click', description: 'Open the investment form' }, + { id: 'confirm', kind: 'click', description: 'Confirm the investment' }, + ], + assertions: [ + { + id: 'single-mutation', + kind: 'mutation_count', + description: 'Exactly one schedule is created', + }, + { id: 'success-visible', kind: 'visible', description: 'The success message is visible' }, + ], + async run({ page, observe, step }) { + await step('open-create', () => + page.getByRole('button', { name: 'Create investment' }).click() + ); + await step('confirm', () => page.getByRole('button', { name: 'Confirm' }).click()); + await observe.expectMutationCount('/recurring-investments', 1); + await observe.expectVisible('Investment scheduled'); + }, + ...overrides, + }; +} + +function manifest( + scenarios: DeterministicScenario[], + source = 'export const portfolioScenarios = true;' +): Readonly { + return publishScenarioManifest({ + generatedAt: '2026-07-15T10:00:00.000Z', + batchTimeoutMs: 30_000, + parallelism: 4, + modules: [{ id: 'portfolio-scenarios', source, scenarios }], + }); +} + +describe('validateScenarioManifest', () => { + it('rejects unsafe routes and missing assertions during publication', () => { + assert.throws( + () => + manifest([ + scenario({ + route: 'https://example.com/portfolio', + assertions: [], + }), + ]), + (error: unknown) => { + assert.ok(error instanceof ScenarioContractError); + const paths = error.issues.map((issue) => issue.path); + assert.ok(paths.includes('$.modules[0].scenarios[0].route')); + assert.ok(paths.includes('$.modules[0].scenarios[0].assertions')); + return true; + } + ); + }); + + it('computes immutable source and manifest hashes instead of trusting scenario authors', () => { + const candidate = manifest([scenario()]); + const validation = validateScenarioManifest(candidate); + assert.equal(validation.ok, true); + if (validation.ok) { + assert.match(validation.manifest.manifestHash, /^[a-f0-9]{64}$/); + assert.match(validation.manifest.modules[0]?.sourceHash ?? '', /^[a-f0-9]{64}$/); + assert.equal( + validation.manifest.scenarios[0]?.sourceHash, + validation.manifest.modules[0]?.sourceHash + ); + assert.ok(Object.isFrozen(validation.manifest)); + } + }); + + it('changes source and manifest identity whenever loaded module source changes', () => { + const first = manifest([scenario()], 'source version one'); + const second = manifest([scenario()], 'source version two'); + assert.notEqual(first.modules[0]?.sourceHash, second.modules[0]?.sourceHash); + assert.notEqual(first.manifestHash, second.manifestHash); + }); + + it('rejects duplicate scenario IDs before publishing any partial manifest', () => { + const first = scenario(); + const duplicate = scenario({ route: '/portfolio/duplicate' }); + assert.throws( + () => manifest([first, duplicate]), + (error: unknown) => { + assert.ok(error instanceof ScenarioContractError); + assert.ok(error.issues.some((issue) => issue.message.includes('duplicates scenario'))); + return true; + } + ); + }); + + it('rejects unsupported schemas and tampered manifest hashes', () => { + const published = manifest([scenario()]); + const unsupported = validateScenarioManifest({ ...published, schemaVersion: 2 }); + const tampered = validateScenarioManifest({ ...published, manifestHash: 'c'.repeat(64) }); + assert.equal(unsupported.ok, false); + assert.equal(tampered.ok, false); + if (!unsupported.ok) { + assert.ok(unsupported.issues.some((issue) => issue.path === '$.schemaVersion')); + } + if (!tampered.ok) { + assert.ok(tampered.issues.some((issue) => issue.path === '$.manifestHash')); + } + }); + + it('rejects scenario timeouts above the batch budget before publication', () => { + const first = scenario({ timeouts: { actionMs: 2_000, scenarioMs: 40_000 } }); + assert.throws( + () => manifest([first]), + (error: unknown) => { + assert.ok(error instanceof ScenarioContractError); + assert.ok( + error.issues.some((issue) => + issue.message.includes('cannot exceed the manifest batchTimeoutMs') + ) + ); + return true; + } + ); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/scenario.ts b/apps/desktop/src/lib/warm-verification/scenario.ts new file mode 100644 index 0000000..2ca714f --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/scenario.ts @@ -0,0 +1,711 @@ +import { createHash } from 'node:crypto'; + +import type { Page } from '@playwright/test'; + +export const VERIFY_SCENARIO_SCHEMA_VERSION = 1 as const; +export const VERIFY_MANIFEST_SCHEMA_VERSION = 1 as const; + +export const SCENARIO_CONTRACT_LIMITS = { + maxScenarios: 500, + maxModules: 100, + maxActionsPerScenario: 100, + maxAssertionsPerScenario: 100, + maxCapabilitiesPerScenario: 32, + maxTagsPerScenario: 20, + maxRouteLength: 2_048, + minActionTimeoutMs: 50, + maxActionTimeoutMs: 30_000, + maxScenarioTimeoutMs: 120_000, + maxBatchTimeoutMs: 300_000, +} as const; + +export type ScenarioActionKind = + | 'click' + | 'fill' + | 'press' + | 'select' + | 'check' + | 'uncheck' + | 'navigate' + | 'wait'; + +export type ScenarioAssertionKind = + | 'visible' + | 'hidden' + | 'text' + | 'route' + | 'mutation_count' + | 'runtime_errors' + | 'accessibility' + | 'visual' + | 'custom'; + +export interface ScenarioActionDeclaration { + id: string; + kind: ScenarioActionKind; + description: string; +} + +export interface ScenarioAssertionDeclaration { + id: string; + kind: ScenarioAssertionKind; + description: string; +} + +export interface ScenarioTimeoutBudgets { + actionMs: number; + scenarioMs: number; +} + +export type ScenarioFlagValue = string | number | boolean; + +export interface ScenarioObserve { + expectNoRuntimeErrors(): Promise; + expectMutationCount(routePattern: string, expected: number): Promise; + expectVisible(name: string): Promise; + expectRoute(route: string): Promise; + checkpoint(name: string): Promise; +} + +export interface ScenarioExecutionContext { + page: Page; + observe: ScenarioObserve; + signal: AbortSignal; + step(actionId: string, operation: () => Promise): Promise; +} + +export interface DeterministicScenario { + schemaVersion: typeof VERIFY_SCENARIO_SCHEMA_VERSION; + id: string; + capabilityIds: readonly string[]; + route: string; + authProfileId: string; + stateName: string; + frozenTime: string; + flags: Readonly>; + timeouts: Readonly; + tags?: readonly string[]; + actions: readonly ScenarioActionDeclaration[]; + assertions: readonly ScenarioAssertionDeclaration[]; + run(context: ScenarioExecutionContext): Promise; +} + +export interface PublishedScenario extends DeterministicScenario { + sourceHash: string; +} + +export interface ScenarioModuleContract { + id: string; + sourceHash: string; + scenarios: readonly PublishedScenario[]; +} + +export interface ScenarioModuleSource { + id: string; + source: string | Uint8Array; + scenarios: readonly DeterministicScenario[]; +} + +export interface ScenarioManifest { + schemaVersion: typeof VERIFY_MANIFEST_SCHEMA_VERSION; + manifestHash: string; + generatedAt: string; + batchTimeoutMs: number; + parallelism: 1 | 2 | 3 | 4; + modules: readonly ScenarioModuleContract[]; + scenarios: readonly PublishedScenario[]; +} + +export interface PublishScenarioManifestInput { + generatedAt: string; + batchTimeoutMs: number; + parallelism: 1 | 2 | 3 | 4; + modules: readonly ScenarioModuleSource[]; +} + +export interface ScenarioContractIssue { + path: string; + message: string; +} + +export class ScenarioContractError extends Error { + readonly issues: readonly ScenarioContractIssue[]; + + constructor(message: string, issues: readonly ScenarioContractIssue[]) { + super(message); + this.name = 'ScenarioContractError'; + this.issues = issues; + } +} + +export type ScenarioManifestValidation = + | { ok: true; manifest: Readonly } + | { ok: false; issues: readonly ScenarioContractIssue[] }; + +const STABLE_ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const ACTION_KINDS: readonly ScenarioActionKind[] = [ + 'click', + 'fill', + 'press', + 'select', + 'check', + 'uncheck', + 'navigate', + 'wait', +]; +const ASSERTION_KINDS: readonly ScenarioAssertionKind[] = [ + 'visible', + 'hidden', + 'text', + 'route', + 'mutation_count', + 'runtime_errors', + 'accessibility', + 'visual', + 'custom', +]; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => character.charCodeAt(0) < 32); +} + +function requireStableId(value: unknown, path: string, issues: ScenarioContractIssue[]): void { + if (typeof value !== 'string' || value.length > 128 || !STABLE_ID_PATTERN.test(value)) { + issues.push({ + path, + message: 'must be a lowercase stable ID using letters, numbers, dot, underscore, or hyphen', + }); + } +} + +function requireHash(value: unknown, path: string, issues: ScenarioContractIssue[]): void { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + issues.push({ path, message: 'must be a lowercase SHA-256 hash' }); + } +} + +function requirePositiveInteger( + value: unknown, + path: string, + min: number, + max: number, + issues: ScenarioContractIssue[] +): value is number { + if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { + issues.push({ path, message: `must be an integer from ${min} through ${max}` }); + return false; + } + return true; +} + +function validateUniqueStrings( + value: unknown, + path: string, + min: number, + max: number, + issues: ScenarioContractIssue[] +): void { + if (!Array.isArray(value) || value.length < min || value.length > max) { + issues.push({ path, message: `must contain from ${min} through ${max} items` }); + return; + } + const seen = new Set(); + value.forEach((item, index) => { + requireStableId(item, `${path}[${index}]`, issues); + if (typeof item === 'string' && seen.has(item)) { + issues.push({ path: `${path}[${index}]`, message: `duplicates ${JSON.stringify(item)}` }); + } + if (typeof item === 'string') seen.add(item); + }); +} + +function validateDeclarations( + value: unknown, + path: string, + kinds: readonly string[], + max: number, + issues: ScenarioContractIssue[] +): void { + if (!Array.isArray(value) || value.length < 1 || value.length > max) { + issues.push({ path, message: `must contain from 1 through ${max} declarations` }); + return; + } + const ids = new Set(); + value.forEach((declaration, index) => { + const declarationPath = `${path}[${index}]`; + if (!isObject(declaration)) { + issues.push({ path: declarationPath, message: 'must be an object' }); + return; + } + requireStableId(declaration.id, `${declarationPath}.id`, issues); + if (typeof declaration.id === 'string' && ids.has(declaration.id)) { + issues.push({ + path: `${declarationPath}.id`, + message: `duplicates ${JSON.stringify(declaration.id)}`, + }); + } + if (typeof declaration.id === 'string') ids.add(declaration.id); + if (!kinds.includes(String(declaration.kind))) { + issues.push({ + path: `${declarationPath}.kind`, + message: 'is not a supported deterministic kind', + }); + } + if ( + typeof declaration.description !== 'string' || + declaration.description.length < 1 || + declaration.description.length > 500 + ) { + issues.push({ + path: `${declarationPath}.description`, + message: 'must contain from 1 through 500 characters', + }); + } + }); +} + +function validateScenario( + value: unknown, + path: string, + issues: ScenarioContractIssue[] +): value is DeterministicScenario { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }); + return false; + } + if (value.schemaVersion !== VERIFY_SCENARIO_SCHEMA_VERSION) { + issues.push({ + path: `${path}.schemaVersion`, + message: `must equal ${VERIFY_SCENARIO_SCHEMA_VERSION}`, + }); + } + requireStableId(value.id, `${path}.id`, issues); + validateUniqueStrings( + value.capabilityIds, + `${path}.capabilityIds`, + 1, + SCENARIO_CONTRACT_LIMITS.maxCapabilitiesPerScenario, + issues + ); + if ( + typeof value.route !== 'string' || + !value.route.startsWith('/') || + value.route.startsWith('//') || + value.route.length > SCENARIO_CONTRACT_LIMITS.maxRouteLength || + hasControlCharacter(value.route) + ) { + issues.push({ path: `${path}.route`, message: 'must be a bounded direct application route' }); + } + requireStableId(value.authProfileId, `${path}.authProfileId`, issues); + requireStableId(value.stateName, `${path}.stateName`, issues); + if (typeof value.frozenTime !== 'string' || Number.isNaN(Date.parse(value.frozenTime))) { + issues.push({ path: `${path}.frozenTime`, message: 'must be an ISO-8601 timestamp' }); + } + if (!isObject(value.flags) || Object.keys(value.flags).length > 50) { + issues.push({ path: `${path}.flags`, message: 'must be an object with at most 50 flags' }); + } else { + for (const [key, flagValue] of Object.entries(value.flags)) { + requireStableId(key, `${path}.flags.${key}`, issues); + if (!['string', 'number', 'boolean'].includes(typeof flagValue)) { + issues.push({ + path: `${path}.flags.${key}`, + message: 'must be a string, number, or boolean', + }); + } + if (typeof flagValue === 'number' && !Number.isFinite(flagValue)) { + issues.push({ path: `${path}.flags.${key}`, message: 'must be finite' }); + } + } + } + if (!isObject(value.timeouts)) { + issues.push({ path: `${path}.timeouts`, message: 'must be an object' }); + } else { + const actionMs = value.timeouts.actionMs; + const scenarioMs = value.timeouts.scenarioMs; + const actionValid = requirePositiveInteger( + actionMs, + `${path}.timeouts.actionMs`, + SCENARIO_CONTRACT_LIMITS.minActionTimeoutMs, + SCENARIO_CONTRACT_LIMITS.maxActionTimeoutMs, + issues + ); + const scenarioValid = requirePositiveInteger( + scenarioMs, + `${path}.timeouts.scenarioMs`, + SCENARIO_CONTRACT_LIMITS.minActionTimeoutMs, + SCENARIO_CONTRACT_LIMITS.maxScenarioTimeoutMs, + issues + ); + if (actionValid && scenarioValid && actionMs > scenarioMs) { + issues.push({ path: `${path}.timeouts`, message: 'actionMs cannot exceed scenarioMs' }); + } + } + if (value.tags !== undefined) { + validateUniqueStrings( + value.tags, + `${path}.tags`, + 0, + SCENARIO_CONTRACT_LIMITS.maxTagsPerScenario, + issues + ); + } + validateDeclarations( + value.actions, + `${path}.actions`, + ACTION_KINDS, + SCENARIO_CONTRACT_LIMITS.maxActionsPerScenario, + issues + ); + validateDeclarations( + value.assertions, + `${path}.assertions`, + ASSERTION_KINDS, + SCENARIO_CONTRACT_LIMITS.maxAssertionsPerScenario, + issues + ); + if (typeof value.run !== 'function') { + issues.push({ + path: `${path}.run`, + message: 'must be a deterministic async scenario function', + }); + } + return true; +} + +function validatePublishedScenario( + value: unknown, + path: string, + issues: ScenarioContractIssue[] +): value is PublishedScenario { + const valid = validateScenario(value, path, issues); + if (isObject(value)) requireHash(value.sourceHash, `${path}.sourceHash`, issues); + return valid; +} + +function freezeScenario(scenario: T): Readonly { + return Object.freeze({ + ...scenario, + capabilityIds: Object.freeze([...scenario.capabilityIds]), + tags: scenario.tags === undefined ? undefined : Object.freeze([...scenario.tags]), + flags: Object.freeze({ ...scenario.flags }), + timeouts: Object.freeze({ ...scenario.timeouts }), + actions: Object.freeze(scenario.actions.map((action) => Object.freeze({ ...action }))), + assertions: Object.freeze( + scenario.assertions.map((assertion) => Object.freeze({ ...assertion })) + ), + }) as Readonly; +} + +function sha256(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function manifestIdentity( + manifest: Omit +): object { + return { + schemaVersion: manifest.schemaVersion, + batchTimeoutMs: manifest.batchTimeoutMs, + parallelism: manifest.parallelism, + modules: [...manifest.modules] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((module) => ({ + id: module.id, + sourceHash: module.sourceHash, + scenarioIds: module.scenarios.map((scenario) => scenario.id).sort(), + })), + scenarios: [...manifest.scenarios] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((scenario) => ({ + schemaVersion: scenario.schemaVersion, + id: scenario.id, + sourceHash: scenario.sourceHash, + capabilityIds: [...scenario.capabilityIds], + route: scenario.route, + authProfileId: scenario.authProfileId, + stateName: scenario.stateName, + frozenTime: scenario.frozenTime, + flags: Object.fromEntries( + Object.entries(scenario.flags).sort(([left], [right]) => left.localeCompare(right)) + ), + timeouts: { + actionMs: scenario.timeouts.actionMs, + scenarioMs: scenario.timeouts.scenarioMs, + }, + tags: scenario.tags === undefined ? undefined : [...scenario.tags], + actions: scenario.actions.map((action) => ({ + id: action.id, + kind: action.kind, + description: action.description, + })), + assertions: scenario.assertions.map((assertion) => ({ + id: assertion.id, + kind: assertion.kind, + description: assertion.description, + })), + })), + }; +} + +function computeManifestHash( + manifest: Omit +): string { + return sha256(JSON.stringify(manifestIdentity(manifest))); +} + +export function publishScenarioManifest( + input: PublishScenarioManifestInput +): Readonly { + const issues: ScenarioContractIssue[] = []; + if (input.modules.length < 1 || input.modules.length > SCENARIO_CONTRACT_LIMITS.maxModules) { + issues.push({ + path: '$.modules', + message: `must contain from 1 through ${SCENARIO_CONTRACT_LIMITS.maxModules} modules`, + }); + } + + const seenModuleIds = new Set(); + const seenScenarioIds = new Set(); + input.modules.forEach((module, moduleIndex) => { + const modulePath = `$.modules[${moduleIndex}]`; + requireStableId(module.id, `${modulePath}.id`, issues); + if (seenModuleIds.has(module.id)) { + issues.push({ + path: `${modulePath}.id`, + message: `duplicates module ${JSON.stringify(module.id)}`, + }); + } + seenModuleIds.add(module.id); + const sourceBytes = + typeof module.source === 'string' + ? new TextEncoder().encode(module.source).byteLength + : module.source.byteLength; + if (sourceBytes === 0) + issues.push({ path: `${modulePath}.source`, message: 'must not be empty' }); + if (module.scenarios.length < 1) { + issues.push({ + path: `${modulePath}.scenarios`, + message: 'must contain at least one scenario', + }); + } + module.scenarios.forEach((scenario, scenarioIndex) => { + validateScenario(scenario, `${modulePath}.scenarios[${scenarioIndex}]`, issues); + if (seenScenarioIds.has(scenario.id)) { + issues.push({ + path: `${modulePath}.scenarios[${scenarioIndex}].id`, + message: `duplicates scenario ${JSON.stringify(scenario.id)}`, + }); + } + seenScenarioIds.add(scenario.id); + }); + }); + if (seenScenarioIds.size > SCENARIO_CONTRACT_LIMITS.maxScenarios) { + issues.push({ + path: '$.modules', + message: `publishes more than ${SCENARIO_CONTRACT_LIMITS.maxScenarios} scenarios`, + }); + } + if (issues.length > 0) throw new ScenarioContractError('Invalid scenario modules', issues); + + const modules: ScenarioModuleContract[] = input.modules.map((module) => { + const sourceHash = sha256(module.source); + return { + id: module.id, + sourceHash, + scenarios: module.scenarios.map((scenario) => freezeScenario({ ...scenario, sourceHash })), + }; + }); + const scenarios = modules.flatMap((module) => module.scenarios); + const identityInput = { + schemaVersion: VERIFY_MANIFEST_SCHEMA_VERSION, + batchTimeoutMs: input.batchTimeoutMs, + parallelism: input.parallelism, + modules, + scenarios, + } satisfies Omit; + const candidate: ScenarioManifest = { + ...identityInput, + generatedAt: input.generatedAt, + manifestHash: computeManifestHash(identityInput), + }; + const validation = validateScenarioManifest(candidate); + if (!validation.ok) + throw new ScenarioContractError('Invalid published scenario manifest', validation.issues); + return validation.manifest; +} + +export function validateScenarioManifest(value: unknown): ScenarioManifestValidation { + const issues: ScenarioContractIssue[] = []; + if (!isObject(value)) return { ok: false, issues: [{ path: '$', message: 'must be an object' }] }; + + if (value.schemaVersion !== VERIFY_MANIFEST_SCHEMA_VERSION) { + issues.push({ + path: '$.schemaVersion', + message: `must equal ${VERIFY_MANIFEST_SCHEMA_VERSION}`, + }); + } + requireHash(value.manifestHash, '$.manifestHash', issues); + if (typeof value.generatedAt !== 'string' || Number.isNaN(Date.parse(value.generatedAt))) { + issues.push({ path: '$.generatedAt', message: 'must be an ISO-8601 timestamp' }); + } + const batchTimeoutMs = value.batchTimeoutMs; + const batchTimeoutValid = requirePositiveInteger( + batchTimeoutMs, + '$.batchTimeoutMs', + SCENARIO_CONTRACT_LIMITS.minActionTimeoutMs, + SCENARIO_CONTRACT_LIMITS.maxBatchTimeoutMs, + issues + ); + requirePositiveInteger(value.parallelism, '$.parallelism', 1, 4, issues); + + if ( + !Array.isArray(value.modules) || + value.modules.length < 1 || + value.modules.length > SCENARIO_CONTRACT_LIMITS.maxModules + ) { + issues.push({ + path: '$.modules', + message: `must contain from 1 through ${SCENARIO_CONTRACT_LIMITS.maxModules} modules`, + }); + } + if ( + !Array.isArray(value.scenarios) || + value.scenarios.length < 1 || + value.scenarios.length > SCENARIO_CONTRACT_LIMITS.maxScenarios + ) { + issues.push({ + path: '$.scenarios', + message: `must contain from 1 through ${SCENARIO_CONTRACT_LIMITS.maxScenarios} scenarios`, + }); + } + + const scenarioIds = new Set(); + if (Array.isArray(value.scenarios)) { + value.scenarios.forEach((scenario, index) => { + validatePublishedScenario(scenario, `$.scenarios[${index}]`, issues); + if (isObject(scenario) && typeof scenario.id === 'string') { + if (scenarioIds.has(scenario.id)) { + issues.push({ + path: `$.scenarios[${index}].id`, + message: `duplicates scenario ${JSON.stringify(scenario.id)}`, + }); + } + scenarioIds.add(scenario.id); + } + if ( + batchTimeoutValid && + isObject(scenario) && + isObject(scenario.timeouts) && + typeof scenario.timeouts.scenarioMs === 'number' && + scenario.timeouts.scenarioMs > batchTimeoutMs + ) { + issues.push({ + path: `$.scenarios[${index}].timeouts.scenarioMs`, + message: 'cannot exceed the manifest batchTimeoutMs', + }); + } + }); + } + + const moduleIds = new Set(); + const declaredScenarioIds = new Set(); + if (Array.isArray(value.modules)) { + value.modules.forEach((module, moduleIndex) => { + const path = `$.modules[${moduleIndex}]`; + if (!isObject(module)) { + issues.push({ path, message: 'must be an object' }); + return; + } + requireStableId(module.id, `${path}.id`, issues); + requireHash(module.sourceHash, `${path}.sourceHash`, issues); + if (typeof module.id === 'string' && moduleIds.has(module.id)) { + issues.push({ + path: `${path}.id`, + message: `duplicates module ${JSON.stringify(module.id)}`, + }); + } + if (typeof module.id === 'string') moduleIds.add(module.id); + if (!Array.isArray(module.scenarios) || module.scenarios.length < 1) { + issues.push({ path: `${path}.scenarios`, message: 'must contain at least one scenario' }); + return; + } + module.scenarios.forEach((scenario, scenarioIndex) => { + if (!isObject(scenario) || typeof scenario.id !== 'string') return; + if (!scenarioIds.has(scenario.id)) { + issues.push({ + path: `${path}.scenarios[${scenarioIndex}]`, + message: `references unpublished scenario ${JSON.stringify(scenario.id)}`, + }); + } + if (declaredScenarioIds.has(scenario.id)) { + issues.push({ + path: `${path}.scenarios[${scenarioIndex}]`, + message: `scenario ${JSON.stringify(scenario.id)} is declared by more than one module`, + }); + } + declaredScenarioIds.add(scenario.id); + if (typeof scenario.sourceHash === 'string' && scenario.sourceHash !== module.sourceHash) { + issues.push({ + path: `${path}.scenarios[${scenarioIndex}].sourceHash`, + message: 'must match its module sourceHash', + }); + } + }); + }); + } + + for (const id of scenarioIds) { + if (!declaredScenarioIds.has(id)) { + issues.push({ + path: '$.scenarios', + message: `scenario ${JSON.stringify(id)} is not declared by a module`, + }); + } + } + + if (issues.length === 0) { + const candidate = value as unknown as ScenarioManifest; + const expectedManifestHash = computeManifestHash({ + schemaVersion: candidate.schemaVersion, + batchTimeoutMs: candidate.batchTimeoutMs, + parallelism: candidate.parallelism, + modules: candidate.modules, + scenarios: candidate.scenarios, + }); + if (candidate.manifestHash !== expectedManifestHash) { + issues.push({ + path: '$.manifestHash', + message: 'does not match the published module sources and scenario metadata', + }); + } + } + + if (issues.length > 0) return { ok: false, issues }; + const manifest = value as unknown as ScenarioManifest; + const frozenScenarios = Object.freeze( + manifest.scenarios.map((scenario) => freezeScenario(scenario)) + ); + const frozenById = new Map(frozenScenarios.map((scenario) => [scenario.id, scenario])); + return { + ok: true, + manifest: Object.freeze({ + ...manifest, + scenarios: frozenScenarios, + modules: Object.freeze( + manifest.modules.map((module) => + Object.freeze({ + ...module, + scenarios: Object.freeze( + module.scenarios.map((scenario) => frozenById.get(scenario.id) as PublishedScenario) + ), + }) + ) + ), + }), + }; +} diff --git a/apps/desktop/src/lib/warm-verification/selection.test.ts b/apps/desktop/src/lib/warm-verification/selection.test.ts new file mode 100644 index 0000000..09aa2df --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/selection.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { VerifyConfig } from './config'; +import { + matchesPathGlob, + selectChangedCapabilities, + validateConfigAgainstScenarios, +} from './selection'; + +function config(): VerifyConfig { + return { + version: 1, + target: { + command: ['pnpm', 'exec', 'vite'], + cwd: '.', + readinessUrl: 'http://127.0.0.1:4173/health', + baseUrl: 'http://127.0.0.1:4173', + allowedEnv: [], + hmrSettleMs: 250, + shutdownGraceMs: 2_000, + }, + scenarioModules: ['verify/scenarios.ts'], + authProfiles: { developer: { storageState: '.codevetter/auth/developer.json' } }, + capabilities: [ + { + id: 'portfolio', + paths: ['src/features/portfolio/**', 'src/routes/portfolio/**'], + scenarios: ['portfolio-empty', 'shared-detail'], + }, + { + id: 'activity', + paths: ['src/features/activity/**'], + scenarios: ['activity-list', 'shared-detail'], + }, + ], + mandatorySmoke: ['app-shell'], + sharedInfrastructure: { + paths: ['src/router/**', 'src/app.tsx'], + fallbackScenarios: ['app-shell', 'portfolio-empty', 'activity-list'], + }, + network: { + firstPartyOrigins: ['http://127.0.0.1:4173'], + allowedFirstPartyRequests: ['GET /**'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }, + retention: { + directory: '.codevetter/verify-artifacts', + maxRuns: 20, + maxBytes: 104_857_600, + maxAgeDays: 14, + }, + budgets: { + parallelism: 4, + actionMs: 5_000, + scenarioMs: 15_000, + batchMs: 30_000, + slowInteractionMs: 500, + }, + }; +} + +const available = new Set(['app-shell', 'portfolio-empty', 'activity-list', 'shared-detail']); + +describe('path glob matching', () => { + it('supports explicit *, **, and ? without crossing unintended segments', () => { + assert.equal( + matchesPathGlob('src/features/portfolio/**', 'src/features/portfolio/index.ts'), + true + ); + assert.equal(matchesPathGlob('src/**/portfolio/*.tsx', 'src/a/b/portfolio/Card.tsx'), true); + assert.equal(matchesPathGlob('src/routes/?.tsx', 'src/routes/a.tsx'), true); + assert.equal(matchesPathGlob('src/routes/?.tsx', 'src/routes/ab.tsx'), false); + assert.equal(matchesPathGlob('src/*.tsx', 'src/nested/App.tsx'), false); + assert.equal(matchesPathGlob('src/**', '../src/App.tsx'), false); + }); +}); + +describe('changed capability selection', () => { + it('selects exact explicit scenarios plus mandatory smoke', () => { + const result = selectChangedCapabilities(config(), available, [ + 'src/features/portfolio/Card.tsx', + ]); + + assert.equal(result.focused, true); + assert.equal(result.complete, true); + assert.deepEqual(result.selectedScenarioIds, ['app-shell', 'portfolio-empty', 'shared-detail']); + assert.deepEqual(result.matchedCapabilityIds, ['portfolio']); + assert.equal(result.fallbackScenarioIds.length, 0); + }); + + it('deduplicates a scenario shared by overlapping changed capabilities', () => { + const result = selectChangedCapabilities(config(), available, [ + 'src/features/activity/List.tsx', + 'src/features/portfolio/Card.tsx', + ]); + + assert.equal(result.selectedScenarioIds.filter((id) => id === 'shared-detail').length, 1); + assert.equal( + result.reasons.filter((reason) => reason.scenarioId === 'shared-detail').length, + 2 + ); + }); + + it('forces broad fallback for shared infrastructure and unmatched files', () => { + const result = selectChangedCapabilities(config(), available, [ + 'src/router/routes.ts', + 'src/unknown/Thing.tsx', + ]); + + assert.equal(result.focused, false); + assert.equal(result.complete, true); + assert.deepEqual(result.fallbackScenarioIds, ['activity-list', 'app-shell', 'portfolio-empty']); + assert.ok(result.limitations.some((entry) => entry.code === 'shared_infrastructure')); + assert.ok(result.limitations.some((entry) => entry.code === 'unmatched_changed_path')); + }); + + it('cannot claim complete selection when configured scenarios are unavailable', () => { + const result = selectChangedCapabilities(config(), new Set(['app-shell']), [ + 'src/features/portfolio/Card.tsx', + ]); + + assert.equal(result.complete, false); + assert.ok(result.limitations.some((entry) => entry.detail.includes('portfolio-empty'))); + }); +}); + +describe('configuration and manifest cross-validation', () => { + it('accepts matching capabilities and auth profiles', () => { + const issues = validateConfigAgainstScenarios(config(), [ + { id: 'app-shell', capabilityIds: ['shell'], authProfileId: 'developer' }, + { id: 'portfolio-empty', capabilityIds: ['portfolio'], authProfileId: 'developer' }, + { id: 'activity-list', capabilityIds: ['activity'], authProfileId: 'developer' }, + { + id: 'shared-detail', + capabilityIds: ['portfolio', 'activity'], + authProfileId: 'developer', + }, + ]); + + assert.deepEqual(issues, []); + }); + + it('rejects unknown scenarios, wrong capabilities, and unknown auth profiles together', () => { + const candidate = config(); + candidate.mandatorySmoke = ['unknown-smoke']; + const issues = validateConfigAgainstScenarios(candidate, [ + { id: 'portfolio-empty', capabilityIds: ['wrong'], authProfileId: 'missing-auth' }, + { id: 'activity-list', capabilityIds: ['activity'], authProfileId: 'developer' }, + { + id: 'shared-detail', + capabilityIds: ['portfolio', 'activity'], + authProfileId: 'developer', + }, + ]); + + assert.ok(issues.some((entry) => entry.message.includes('unknown scenario'))); + assert.ok(issues.some((entry) => entry.message.includes('does not declare capability'))); + assert.ok(issues.some((entry) => entry.message.includes('unknown auth profile'))); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/selection.ts b/apps/desktop/src/lib/warm-verification/selection.ts new file mode 100644 index 0000000..bd02b03 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/selection.ts @@ -0,0 +1,285 @@ +import type { VerifyConfig } from './config'; + +export type SelectionReasonKind = + | 'explicit_capability' + | 'mandatory_smoke' + | 'shared_infrastructure_fallback' + | 'unmatched_path_fallback'; + +export interface SelectionReason { + kind: SelectionReasonKind; + scenarioId: string; + changedPath?: string; + capabilityId?: string; + pattern?: string; + detail: string; +} + +export interface SelectionLimitation { + code: + | 'invalid_changed_path' + | 'unmatched_changed_path' + | 'shared_infrastructure' + | 'unknown_scenario'; + changedPath?: string; + detail: string; +} + +export interface ChangedCapabilitySelection { + changedPaths: string[]; + matchedCapabilityIds: string[]; + selectedScenarioIds: string[]; + mandatorySmokeIds: string[]; + fallbackScenarioIds: string[]; + focused: boolean; + complete: boolean; + reasons: SelectionReason[]; + limitations: SelectionLimitation[]; +} + +export interface ManifestScenarioIdentity { + id: string; + capabilityIds: readonly string[]; + authProfileId: string; +} + +export interface ConfigManifestIssue { + path: string; + message: string; +} + +export function validateConfigAgainstScenarios( + config: VerifyConfig, + scenarios: readonly ManifestScenarioIdentity[] +): ConfigManifestIssue[] { + const issues: ConfigManifestIssue[] = []; + const scenarioById = new Map(scenarios.map((scenario) => [scenario.id, scenario])); + const references: Array<{ path: string; scenarioId: string; capabilityId?: string }> = []; + + config.capabilities.forEach((capability, capabilityIndex) => { + capability.scenarios.forEach((scenarioId, scenarioIndex) => { + references.push({ + path: `$.capabilities[${capabilityIndex}].scenarios[${scenarioIndex}]`, + scenarioId, + capabilityId: capability.id, + }); + }); + }); + config.mandatorySmoke.forEach((scenarioId, index) => { + references.push({ path: `$.mandatorySmoke[${index}]`, scenarioId }); + }); + config.sharedInfrastructure.fallbackScenarios.forEach((scenarioId, index) => { + references.push({ path: `$.sharedInfrastructure.fallbackScenarios[${index}]`, scenarioId }); + }); + + for (const reference of references) { + const scenario = scenarioById.get(reference.scenarioId); + if (!scenario) { + issues.push({ + path: reference.path, + message: `references unknown scenario ${JSON.stringify(reference.scenarioId)}`, + }); + continue; + } + if (reference.capabilityId && !scenario.capabilityIds.includes(reference.capabilityId)) { + issues.push({ + path: reference.path, + message: `scenario ${JSON.stringify(reference.scenarioId)} does not declare capability ${JSON.stringify(reference.capabilityId)}`, + }); + } + } + + scenarios.forEach((scenario, index) => { + if (!(scenario.authProfileId in config.authProfiles)) { + issues.push({ + path: `$.scenarioManifest.scenarios[${index}].authProfileId`, + message: `references unknown auth profile ${JSON.stringify(scenario.authProfileId)}`, + }); + } + }); + + return issues; +} + +export function selectChangedCapabilities( + config: VerifyConfig, + availableScenarioIds: ReadonlySet, + changedPaths: readonly string[] +): ChangedCapabilitySelection { + const normalizedPaths = [...new Set(changedPaths)].sort(); + const selected = new Set(); + const fallback = new Set(); + const matchedCapabilities = new Set(); + const reasons: SelectionReason[] = []; + const limitations: SelectionLimitation[] = []; + let needsFallback = false; + + for (const scenarioId of config.mandatorySmoke) { + selected.add(scenarioId); + reasons.push({ + kind: 'mandatory_smoke', + scenarioId, + detail: 'Configured mandatory smoke scenario', + }); + } + + for (const changedPath of normalizedPaths) { + if (!isSafeChangedPath(changedPath)) { + needsFallback = true; + limitations.push({ + code: 'invalid_changed_path', + changedPath, + detail: 'Changed path is not a normalized repository-relative path', + }); + continue; + } + + const sharedPattern = config.sharedInfrastructure.paths.find((pattern) => + matchesPathGlob(pattern, changedPath) + ); + if (sharedPattern) { + needsFallback = true; + limitations.push({ + code: 'shared_infrastructure', + changedPath, + detail: `Matches shared-infrastructure pattern ${JSON.stringify(sharedPattern)}`, + }); + } + + let matched = false; + for (const capability of config.capabilities) { + const pattern = capability.paths.find((candidate) => matchesPathGlob(candidate, changedPath)); + if (!pattern) continue; + matched = true; + matchedCapabilities.add(capability.id); + for (const scenarioId of capability.scenarios) { + selected.add(scenarioId); + reasons.push({ + kind: 'explicit_capability', + scenarioId, + changedPath, + capabilityId: capability.id, + pattern, + detail: `Explicit capability ${JSON.stringify(capability.id)} matched ${JSON.stringify(pattern)}`, + }); + } + } + if (!matched) { + needsFallback = true; + limitations.push({ + code: 'unmatched_changed_path', + changedPath, + detail: 'No explicit capability pattern matched this changed path', + }); + } + } + + if (needsFallback) { + for (const scenarioId of config.sharedInfrastructure.fallbackScenarios) { + fallback.add(scenarioId); + selected.add(scenarioId); + const fallbackKind = limitations.some((entry) => entry.code === 'shared_infrastructure') + ? 'shared_infrastructure_fallback' + : 'unmatched_path_fallback'; + reasons.push({ + kind: fallbackKind, + scenarioId, + detail: 'Configured broad fallback because focused confidence is incomplete', + }); + } + } + + const missingScenarios = [...selected].filter( + (scenarioId) => !availableScenarioIds.has(scenarioId) + ); + for (const scenarioId of missingScenarios) { + limitations.push({ + code: 'unknown_scenario', + detail: `Selected configuration references unavailable scenario ${JSON.stringify(scenarioId)}`, + }); + } + + return { + changedPaths: normalizedPaths, + matchedCapabilityIds: [...matchedCapabilities].sort(), + selectedScenarioIds: [...selected].sort(), + mandatorySmokeIds: [...new Set(config.mandatorySmoke)].sort(), + fallbackScenarioIds: [...fallback].sort(), + focused: !needsFallback, + complete: normalizedPaths.length > 0 && missingScenarios.length === 0, + reasons: stableUniqueReasons(reasons), + limitations, + }; +} + +export function matchesPathGlob(pattern: string, changedPath: string): boolean { + if (!isSafeChangedPath(changedPath)) return false; + return compilePathGlob(pattern).test(changedPath); +} + +function compilePathGlob(pattern: string): RegExp { + let expression = '^'; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === '*') { + const globstar = pattern[index + 1] === '*'; + if (globstar) { + index += 1; + if (pattern[index + 1] === '/') { + index += 1; + expression += '(?:.*/)?'; + } else { + expression += '.*'; + } + } else { + expression += '[^/]*'; + } + } else if (character === '?') { + expression += '[^/]'; + } else { + expression += escapeRegExp(character ?? ''); + } + } + return new RegExp(`${expression}$`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isSafeChangedPath(value: string): boolean { + return ( + value.length > 0 && + !value.startsWith('/') && + !value.startsWith('./') && + !value.includes('\\') && + !value.includes('\0') && + !value.split('/').includes('..') + ); +} + +function stableUniqueReasons(reasons: SelectionReason[]): SelectionReason[] { + const seen = new Set(); + return reasons + .sort((left, right) => + [left.scenarioId, left.kind, left.changedPath ?? '', left.capabilityId ?? ''] + .join('\0') + .localeCompare( + [right.scenarioId, right.kind, right.changedPath ?? '', right.capabilityId ?? ''].join( + '\0' + ) + ) + ) + .filter((reason) => { + const key = [ + reason.kind, + reason.scenarioId, + reason.changedPath, + reason.capabilityId, + reason.pattern, + ].join('\0'); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} From 6f360b4b62176c4f0c59a81f7e6859b6e1c376fa Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:23:21 +0530 Subject: [PATCH 004/141] feat(verify): identify exact worktree changes --- .../lib/warm-verification/change-set.test.ts | 144 +++++++++ .../src/lib/warm-verification/change-set.ts | 296 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/change-set.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/change-set.ts diff --git a/apps/desktop/src/lib/warm-verification/change-set.test.ts b/apps/desktop/src/lib/warm-verification/change-set.test.ts new file mode 100644 index 0000000..cbaa857 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/change-set.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { VERIFY_CONTRACT_LIMITS } from './contracts'; +import { + collectWorktreeChangeSet, + computeWorktreeChangeSetIdentity, + GitChangeSetError, + parsePorcelainV2Paths, + type GitExecFile, +} from './change-set'; + +const gitSha = 'a'.repeat(40); +const ordinaryPrefix = '1 .M N... 100644 100644 100644 abcdef1 abcdef2 '; +const renamePrefix = '2 R. N... 100644 100644 100644 abcdef1 abcdef2 R100 '; + +function statusBuffer(records: readonly string[]): Buffer { + return Buffer.from(`${records.join('\0')}\0`); +} + +describe('parsePorcelainV2Paths', () => { + it('covers tracked, staged, deleted, renamed, conflicted, and untracked paths', () => { + const output = statusBuffer([ + `${ordinaryPrefix}src/modified.ts`, + '1 D. N... 100644 000000 000000 abcdef1 0000000 src/deleted.ts', + `${renamePrefix}src/new-name.ts`, + 'src/old-name.ts', + 'u UU N... 100644 100644 100644 100644 abcdef1 abcdef2 abcdef3 src/conflict.ts', + '? src/untracked.ts', + `${ordinaryPrefix}src/modified.ts`, + ]); + + assert.deepEqual(parsePorcelainV2Paths(output), [ + 'src/conflict.ts', + 'src/deleted.ts', + 'src/modified.ts', + 'src/new-name.ts', + 'src/old-name.ts', + 'src/untracked.ts', + ]); + }); + + it('fails closed for traversal, control characters, and incomplete rename records', () => { + for (const output of [ + statusBuffer(['? ../outside']), + statusBuffer(['? src/bad\nname.ts']), + statusBuffer([`${renamePrefix}src/new.ts`]), + ]) { + assert.throws(() => parsePorcelainV2Paths(output), GitChangeSetError); + } + }); + + it('rejects an oversized change set rather than truncating it', () => { + const records = Array.from( + { length: VERIFY_CONTRACT_LIMITS.maxChangedPaths + 1 }, + (_, index) => `? src/generated/file-${String(index).padStart(4, '0')}.ts` + ); + assert.throws( + () => parsePorcelainV2Paths(statusBuffer(records)), + (error: unknown) => + error instanceof GitChangeSetError && error.code === 'too_many_changed_paths' + ); + }); +}); + +describe('collectWorktreeChangeSet', () => { + it('uses execFile without a shell and returns a canonical deterministic identity', async () => { + const calls: Array<{ file: string; args: readonly string[]; shell: boolean; cwd: string }> = []; + const execute: GitExecFile = async (file, args, options) => { + calls.push({ file, args, shell: options.shell, cwd: options.cwd }); + if (args.includes('--show-toplevel')) { + return { stdout: Buffer.from('/canonical/repo\n'), stderr: Buffer.alloc(0) }; + } + if (args.includes('HEAD^{commit}')) { + return { stdout: Buffer.from(`${gitSha}\n`), stderr: Buffer.alloc(0) }; + } + return { + stdout: statusBuffer(['? src/z.ts', `${ordinaryPrefix}src/a.ts`]), + stderr: Buffer.alloc(0), + }; + }; + const realpath = async (candidate: string) => + candidate === './repo' ? '/requested/repo' : candidate; + + const collected = await collectWorktreeChangeSet('./repo', { execFile: execute, realpath }); + assert.equal(collected.repositoryRoot, '/canonical/repo'); + assert.deepEqual(collected.changeSet.changed_paths, ['src/a.ts', 'src/z.ts']); + assert.equal( + collected.changeSet.identity, + computeWorktreeChangeSetIdentity( + gitSha, + 'HEAD+index+worktree+untracked', + collected.changeSet.changed_paths + ) + ); + assert.equal(calls.length, 3); + assert.ok(calls.every((call) => call.file === 'git' && call.shell === false)); + assert.deepEqual(calls[0]?.args, [ + '--no-optional-locks', + '-C', + '/requested/repo', + 'rev-parse', + '--show-toplevel', + ]); + assert.ok(calls.slice(1).every((call) => call.cwd === '/canonical/repo')); + }); + + it('produces the same identity regardless of Git status record order', async () => { + async function collect(records: string[]) { + const execute: GitExecFile = async (_file, args) => { + if (args.includes('--show-toplevel')) { + return { stdout: Buffer.from('/repo\n'), stderr: Buffer.alloc(0) }; + } + if (args.includes('HEAD^{commit}')) { + return { stdout: Buffer.from(`${gitSha}\n`), stderr: Buffer.alloc(0) }; + } + return { stdout: statusBuffer(records), stderr: Buffer.alloc(0) }; + }; + return collectWorktreeChangeSet('/repo', { + execFile: execute, + realpath: async (value) => value, + }); + } + + const first = await collect(['? src/b.ts', '? src/a.ts']); + const second = await collect(['? src/a.ts', '? src/b.ts']); + assert.equal(first.changeSet.identity, second.changeSet.identity); + }); + + it('does not expose Git stderr when collection fails', async () => { + const execute: GitExecFile = async () => { + throw new Error('secret-bearing stderr must not escape'); + }; + await assert.rejects( + collectWorktreeChangeSet('/repo', { execFile: execute, realpath: async (value) => value }), + (error: unknown) => { + assert.ok(error instanceof GitChangeSetError); + assert.equal(error.code, 'git_failed'); + assert.doesNotMatch(error.message, /secret-bearing/); + return true; + } + ); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/change-set.ts b/apps/desktop/src/lib/warm-verification/change-set.ts new file mode 100644 index 0000000..fa6c042 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/change-set.ts @@ -0,0 +1,296 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { realpath as nodeRealpath } from 'node:fs/promises'; +import path from 'node:path'; + +import { VERIFY_CONTRACT_LIMITS, type VerifyChangeSetIdentity } from './contracts'; + +const GIT_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; +const GIT_TIMEOUT_MS = 10_000; +const MAX_PATH_BYTES = 4_096; +const WORKTREE_REVISION = 'HEAD+index+worktree+untracked'; +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }); + +export type GitChangeSetErrorCode = + | 'git_failed' + | 'invalid_git_output' + | 'output_limit' + | 'too_many_changed_paths' + | 'unsafe_path'; + +export class GitChangeSetError extends Error { + readonly code: GitChangeSetErrorCode; + + constructor(code: GitChangeSetErrorCode, message: string) { + super(message); + this.name = 'GitChangeSetError'; + this.code = code; + } +} + +export interface GitExecFileOptions { + cwd: string; + encoding: 'buffer'; + maxBuffer: number; + timeout: number; + windowsHide: true; + shell: false; +} + +export interface GitExecFileResult { + stdout: Buffer; + stderr: Buffer; +} + +export type GitExecFile = ( + file: 'git', + args: readonly string[], + options: GitExecFileOptions +) => Promise; + +export interface GitChangeSetDependencies { + execFile?: GitExecFile; + realpath?: (candidate: string) => Promise; +} + +export interface CollectedGitChangeSet { + repositoryRoot: string; + changeSet: VerifyChangeSetIdentity & { + kind: 'worktree'; + revision: typeof WORKTREE_REVISION; + }; +} + +function defaultExecFile( + file: 'git', + args: readonly string[], + options: GitExecFileOptions +): Promise { + return new Promise((resolve, reject) => { + execFile(file, [...args], options, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +async function runGit( + root: string, + args: readonly string[], + execute: GitExecFile +): Promise { + let result: GitExecFileResult; + try { + result = await execute('git', ['--no-optional-locks', '-C', root, ...args], { + cwd: root, + encoding: 'buffer', + maxBuffer: GIT_OUTPUT_LIMIT_BYTES, + timeout: GIT_TIMEOUT_MS, + windowsHide: true, + shell: false, + }); + } catch { + throw new GitChangeSetError('git_failed', `Git command failed: ${args[0] ?? 'unknown'}`); + } + if (result.stdout.byteLength > GIT_OUTPUT_LIMIT_BYTES) { + throw new GitChangeSetError('output_limit', 'Git output exceeded the verifier byte limit'); + } + return result.stdout; +} + +function decodeUtf8(value: Uint8Array, label: string): string { + try { + return UTF8_DECODER.decode(value); + } catch { + throw new GitChangeSetError('invalid_git_output', `${label} was not valid UTF-8`); + } +} + +function decodeSingleLine(value: Buffer, label: string): string { + const decoded = decodeUtf8(value, label).replace(/[\r\n]+$/, ''); + if (decoded.length === 0 || decoded.includes('\n') || decoded.includes('\r')) { + throw new GitChangeSetError('invalid_git_output', `${label} was not one non-empty line`); + } + return decoded; +} + +function pathAfterFields(record: string, fieldsBeforePath: number): string { + let cursor = 0; + for (let field = 0; field < fieldsBeforePath; field += 1) { + cursor = record.indexOf(' ', cursor); + if (cursor === -1) { + throw new GitChangeSetError('invalid_git_output', 'Git status record was incomplete'); + } + cursor += 1; + } + return record.slice(cursor); +} + +function normalizeGitPath(rawPath: string): string { + const bytes = new TextEncoder().encode(rawPath).byteLength; + if (bytes === 0 || bytes > MAX_PATH_BYTES) { + throw new GitChangeSetError('unsafe_path', 'Changed path had an invalid byte length'); + } + if (Array.from(rawPath).some((character) => character.charCodeAt(0) < 32)) { + throw new GitChangeSetError('unsafe_path', 'Changed path contained a control character'); + } + if (rawPath.includes('\\')) { + throw new GitChangeSetError('unsafe_path', 'Changed path was not Git-normalized'); + } + const normalized = path.posix.normalize(rawPath); + if ( + normalized === '.' || + path.posix.isAbsolute(normalized) || + normalized === '..' || + normalized.startsWith('../') || + normalized.split('/').includes('..') + ) { + throw new GitChangeSetError('unsafe_path', 'Changed path escaped the repository root'); + } + if (normalized !== rawPath) { + throw new GitChangeSetError('unsafe_path', 'Changed path was not normalized'); + } + return normalized; +} + +function splitNullRecords(output: Buffer): Buffer[] { + const records: Buffer[] = []; + let start = 0; + for (let index = 0; index < output.length; index += 1) { + if (output[index] !== 0) continue; + records.push(output.subarray(start, index)); + start = index + 1; + } + if (start !== output.length) { + throw new GitChangeSetError('invalid_git_output', 'Git status output was not NUL-terminated'); + } + return records; +} + +export function parsePorcelainV2Paths(output: Buffer): string[] { + const records = splitNullRecords(output); + const changedPaths: string[] = []; + + for (let index = 0; index < records.length; index += 1) { + const recordBytes = records[index]; + if (recordBytes.byteLength === 0) continue; + const record = decodeUtf8(recordBytes, 'Git status record'); + switch (record[0]) { + case '1': + changedPaths.push(normalizeGitPath(pathAfterFields(record, 8))); + break; + case '2': { + changedPaths.push(normalizeGitPath(pathAfterFields(record, 9))); + const originalRecord = records[index + 1]; + if (originalRecord === undefined || originalRecord.byteLength === 0) { + throw new GitChangeSetError( + 'invalid_git_output', + 'Git rename record omitted its original path' + ); + } + changedPaths.push(normalizeGitPath(decodeUtf8(originalRecord, 'Git rename source path'))); + index += 1; + break; + } + case 'u': + changedPaths.push(normalizeGitPath(pathAfterFields(record, 10))); + break; + case '?': + if (!record.startsWith('? ')) { + throw new GitChangeSetError('invalid_git_output', 'Git untracked record was malformed'); + } + changedPaths.push(normalizeGitPath(record.slice(2))); + break; + case '!': + throw new GitChangeSetError( + 'invalid_git_output', + 'Git unexpectedly returned an ignored path' + ); + case '#': + break; + default: + throw new GitChangeSetError('invalid_git_output', 'Git returned an unknown status record'); + } + } + + const sorted = [...new Set(changedPaths)].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + ); + if (sorted.length > VERIFY_CONTRACT_LIMITS.maxChangedPaths) { + throw new GitChangeSetError( + 'too_many_changed_paths', + `Change set contains more than ${VERIFY_CONTRACT_LIMITS.maxChangedPaths} paths` + ); + } + return sorted; +} + +export function computeWorktreeChangeSetIdentity( + targetSha: string, + revision: string, + paths: readonly string[] +): string { + const canonicalPaths = [...new Set(paths)].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + ); + return createHash('sha256') + .update( + JSON.stringify({ + kind: 'worktree', + target_sha: targetSha, + revision, + paths: canonicalPaths, + }) + ) + .digest('hex'); +} + +export async function collectWorktreeChangeSet( + repositoryPath: string, + dependencies: GitChangeSetDependencies = {} +): Promise { + const execute = dependencies.execFile ?? defaultExecFile; + const repositoryRoot = await resolveGitRepositoryRoot(repositoryPath, dependencies); + const targetSha = decodeSingleLine( + await runGit(repositoryRoot, ['rev-parse', '--verify', 'HEAD^{commit}'], execute), + 'Git HEAD SHA' + ); + if (!/^[a-f0-9]{40,64}$/.test(targetSha)) { + throw new GitChangeSetError('invalid_git_output', 'Git HEAD SHA had an invalid format'); + } + const status = await runGit( + repositoryRoot, + ['status', '--porcelain=v2', '-z', '--untracked-files=all'], + execute + ); + const changedPaths = parsePorcelainV2Paths(status); + const identity = computeWorktreeChangeSetIdentity(targetSha, WORKTREE_REVISION, changedPaths); + + return { + repositoryRoot, + changeSet: { + kind: 'worktree', + target_sha: targetSha, + identity, + revision: WORKTREE_REVISION, + changed_paths: changedPaths, + }, + }; +} + +export async function resolveGitRepositoryRoot( + repositoryPath: string, + dependencies: GitChangeSetDependencies = {} +): Promise { + const execute = dependencies.execFile ?? defaultExecFile; + const resolveRealpath = dependencies.realpath ?? nodeRealpath; + const requestedRoot = await resolveRealpath(repositoryPath); + const reportedRoot = decodeSingleLine( + await runGit(requestedRoot, ['rev-parse', '--show-toplevel'], execute), + 'Git repository root' + ); + return resolveRealpath(reportedRoot); +} From ca6cc0a2cff3aa803b00c38e6200422f452f80cf Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:24:04 +0530 Subject: [PATCH 005/141] feat(verify): own private daemon ipc --- .../src/lib/warm-verification/ipc.test.ts | 173 +++++++++ apps/desktop/src/lib/warm-verification/ipc.ts | 346 ++++++++++++++++++ .../warm-verification/runtime-paths.test.ts | 63 ++++ .../lib/warm-verification/runtime-paths.ts | 131 +++++++ .../lib/warm-verification/singleton.test.ts | 134 +++++++ .../src/lib/warm-verification/singleton.ts | 317 ++++++++++++++++ 6 files changed, 1164 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/ipc.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/ipc.ts create mode 100644 apps/desktop/src/lib/warm-verification/runtime-paths.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/runtime-paths.ts create mode 100644 apps/desktop/src/lib/warm-verification/singleton.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/singleton.ts diff --git a/apps/desktop/src/lib/warm-verification/ipc.test.ts b/apps/desktop/src/lib/warm-verification/ipc.test.ts new file mode 100644 index 0000000..9c6670a --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/ipc.test.ts @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdtemp, rm } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import { + type DaemonRequestEnvelope, + VERIFY_CONTRACT_LIMITS, + VERIFY_PROTOCOL_VERSION, +} from './contracts'; +import { + closeServer, + closeServerWithin, + listenVerifyIpcServer, + readJsonFrame, + requestDaemon, + VerifyIpcError, +} from './ipc'; +import { ensurePrivateRuntimeDirectory, resolveVerifyRuntimePaths } from './runtime-paths'; + +function healthRequest(requestId = 'health-1'): DaemonRequestEnvelope { + return { + protocol_version: VERIFY_PROTOCOL_VERSION, + request_id: requestId, + sent_at: new Date().toISOString(), + request: { type: 'health' }, + }; +} + +describe('verifyd NDJSON IPC', () => { + it('serves one validated request per owner-only Unix socket connection', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'cv-ipc-test-')); + const paths = await resolveVerifyRuntimePaths(fixture, { + runtimeRoot: path.join(fixture, 'r'), + }); + await ensurePrivateRuntimeDirectory(paths); + const server = await listenVerifyIpcServer(paths.socketPath, () => ({ + type: 'cancel_ack', + run_id: 'run-1', + accepted: true, + })); + + try { + assert.equal((await lstat(paths.socketPath)).mode & 0o777, 0o600); + const response = await requestDaemon(paths.socketPath, healthRequest()); + assert.equal(response.request_id, 'health-1'); + assert.deepEqual(response.response, { + type: 'cancel_ack', + run_id: 'run-1', + accepted: true, + }); + } finally { + await closeServer(server); + await rm(fixture, { recursive: true, force: true }); + } + }); + + it('rejects trailing frames and oversized data before parsing', async () => { + const trailing = await socketFrame('1\n2\n'); + await assert.rejects( + readJsonFrame(trailing.socket), + (error) => error instanceof VerifyIpcError && error.code === 'frame_trailing_data' + ); + await trailing.close(); + + const oversized = await socketFrame( + `${'x'.repeat(VERIFY_CONTRACT_LIMITS.maxFrameBytes + 1)}\n` + ); + await assert.rejects( + readJsonFrame(oversized.socket), + (error) => error instanceof VerifyIpcError && error.code === 'frame_oversized' + ); + await oversized.close(); + }); + + it('bounds response waits and validates requests before connecting', async () => { + const invalid = healthRequest() as unknown as Record; + invalid.protocol_version = 99; + await assert.rejects( + requestDaemon('/does/not/matter.sock', invalid as unknown as DaemonRequestEnvelope), + (error) => error instanceof VerifyIpcError && error.code === 'protocol_invalid' + ); + + const fixture = await mkdtemp(path.join(os.tmpdir(), 'cv-ipc-test-')); + const socketPath = path.join(fixture, 'h.sock'); + let acceptedSocket: net.Socket | undefined; + const server = net.createServer((socket) => { + acceptedSocket = socket; + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + await assert.rejects( + requestDaemon(socketPath, healthRequest(), { responseTimeoutMs: 20 }), + (error) => error instanceof VerifyIpcError && error.code === 'timeout' + ); + } finally { + acceptedSocket?.destroy(); + await closeServer(server); + await rm(fixture, { recursive: true, force: true }); + } + }); + + it('propagates client disconnect and labels handler faults as internal', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'cv-ipc-test-')); + const paths = await resolveVerifyRuntimePaths(fixture, { + runtimeRoot: path.join(fixture, 'r'), + }); + await ensurePrivateRuntimeDirectory(paths); + let disconnected: (() => void) | undefined; + const disconnectObserved = new Promise((resolve) => { + disconnected = resolve; + }); + let calls = 0; + const server = await listenVerifyIpcServer(paths.socketPath, async (_request, signal) => { + calls += 1; + if (calls === 1) { + await new Promise((resolve) => { + const observe = () => { + disconnected?.(); + resolve(); + }; + if (signal.aborted) observe(); + else signal.addEventListener('abort', observe, { once: true }); + }); + return { type: 'cancel_ack', run_id: 'run-1', accepted: false }; + } + throw new Error('handler exploded'); + }); + + try { + const client = net.createConnection({ path: paths.socketPath }); + await new Promise((resolve) => client.once('connect', resolve)); + client.write(`${JSON.stringify(healthRequest('disconnect-1'))}\n`); + client.destroy(); + await disconnectObserved; + + const response = await requestDaemon(paths.socketPath, healthRequest('internal-1')); + assert.equal(response.response.type, 'error'); + if (response.response.type === 'error') { + assert.equal(response.response.error.code, 'internal_error'); + assert.equal(response.response.error.retryable, false); + } + } finally { + await closeServerWithin(server, 50); + await rm(fixture, { recursive: true, force: true }); + } + }); +}); + +async function socketFrame(source: string): Promise<{ + socket: net.Socket; + close: () => Promise; +}> { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'cv-frame-test-')); + const socketPath = path.join(fixture, 'f.sock'); + const server = net.createServer((socket) => socket.end(source)); + await new Promise((resolve) => server.listen(socketPath, resolve)); + const socket = net.createConnection({ path: socketPath }); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }); + return { + socket, + close: async () => { + socket.destroy(); + await closeServer(server); + await rm(fixture, { recursive: true, force: true }); + }, + }; +} diff --git a/apps/desktop/src/lib/warm-verification/ipc.ts b/apps/desktop/src/lib/warm-verification/ipc.ts new file mode 100644 index 0000000..e3994c2 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/ipc.ts @@ -0,0 +1,346 @@ +import net, { type Server, type Socket } from 'node:net'; + +import { + type DaemonRequestEnvelope, + type DaemonResponse, + type DaemonResponseEnvelope, + VERIFY_CONTRACT_LIMITS, + VERIFY_PROTOCOL_VERSION, + validateDaemonRequestEnvelope, + validateDaemonResponseEnvelope, +} from './contracts'; +import { secureRuntimeSocket } from './runtime-paths'; + +const DEFAULT_FRAME_TIMEOUT_MS = 5_000; +const DEFAULT_RESPONSE_TIMEOUT_MS = 305_000; +const serverSockets = new WeakMap>(); + +export class VerifyIpcError extends Error { + readonly code: + | 'aborted' + | 'connection' + | 'frame_oversized' + | 'frame_trailing_data' + | 'internal_error' + | 'invalid_json' + | 'protocol_invalid' + | 'timeout' + | 'unexpected_eof'; + + constructor(code: VerifyIpcError['code'], message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'VerifyIpcError'; + this.code = code; + } +} + +export interface VerifyIpcClientOptions { + responseTimeoutMs?: number; + signal?: AbortSignal; +} + +export interface VerifyIpcServerOptions { + frameTimeoutMs?: number; +} + +export type VerifyIpcHandler = ( + request: DaemonRequestEnvelope, + signal: AbortSignal +) => DaemonResponse | Promise; + +export async function requestDaemon( + socketPath: string, + request: DaemonRequestEnvelope, + options: VerifyIpcClientOptions = {} +): Promise { + const validation = validateDaemonRequestEnvelope(request); + if (!validation.ok) { + throw protocolError('Outbound daemon request is invalid', validation.issues); + } + + if (options.signal?.aborted) { + throw new VerifyIpcError('aborted', 'Daemon request was aborted'); + } + const socket = net.createConnection({ path: socketPath }); + const responseTimeoutMs = options.responseTimeoutMs ?? DEFAULT_RESPONSE_TIMEOUT_MS; + const deadline = Date.now() + responseTimeoutMs; + const abort = () => socket.destroy(new VerifyIpcError('aborted', 'Daemon request was aborted')); + options.signal?.addEventListener('abort', abort, { once: true }); + + try { + await waitForConnect(socket, remaining(deadline)); + socket.write(encodeFrame(request)); + const value = await readJsonFrame(socket, remaining(deadline)); + const response = validateDaemonResponseEnvelope(value); + if (!response.ok) { + throw protocolError('Daemon response is invalid', response.issues); + } + if (response.value.request_id !== request.request_id) { + throw new VerifyIpcError( + 'protocol_invalid', + `Daemon response request ID ${JSON.stringify(response.value.request_id)} does not match ${JSON.stringify(request.request_id)}` + ); + } + return response.value; + } finally { + options.signal?.removeEventListener('abort', abort); + socket.destroy(); + } +} + +export async function listenVerifyIpcServer( + socketPath: string, + handler: VerifyIpcHandler, + options: VerifyIpcServerOptions = {} +): Promise { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + void handleConnection(socket, handler, options.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS); + }); + serverSockets.set(server, sockets); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(socketPath); + }); + + try { + await secureRuntimeSocket(socketPath); + } catch (error) { + await closeServer(server); + throw error; + } + return server; +} + +export async function closeServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +export async function closeServerWithin(server: Server, graceMs: number): Promise { + if (!server.listening) return; + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + serverSockets.delete(server); + if (error) reject(error); + else resolve(); + }; + const timer = setTimeout(() => { + for (const socket of serverSockets.get(server) ?? []) socket.destroy(); + finish(); + }, graceMs); + server.close((error) => finish(error ?? undefined)); + }); +} + +export function encodeFrame(value: unknown): Buffer { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new VerifyIpcError('protocol_invalid', 'IPC frame must be JSON serializable'); + } + const frame = Buffer.from(`${serialized}\n`, 'utf8'); + if (frame.byteLength - 1 > VERIFY_CONTRACT_LIMITS.maxFrameBytes) { + throw new VerifyIpcError( + 'frame_oversized', + `IPC frame exceeds ${VERIFY_CONTRACT_LIMITS.maxFrameBytes} bytes` + ); + } + return frame; +} + +export async function readJsonFrame( + socket: Socket, + timeoutMs = DEFAULT_FRAME_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let receivedBytes = 0; + let frameBytes = 0; + const chunks: Buffer[] = []; + const timer = setTimeout( + () => finish(new VerifyIpcError('timeout', `IPC frame timed out after ${timeoutMs}ms`)), + timeoutMs + ); + timer.unref(); + + const cleanup = () => { + clearTimeout(timer); + socket.pause(); + socket.off('data', onData); + socket.off('end', onEnd); + socket.off('error', onError); + }; + const finish = (error?: Error, value?: unknown) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(value); + }; + const parse = (frame: Buffer) => { + try { + finish(undefined, JSON.parse(frame.toString('utf8'))); + } catch (error) { + finish(new VerifyIpcError('invalid_json', 'IPC frame is not valid JSON', { cause: error })); + } + }; + const onData = (chunk: Buffer) => { + receivedBytes += chunk.byteLength; + if (receivedBytes > VERIFY_CONTRACT_LIMITS.maxFrameBytes + 1) { + finish( + new VerifyIpcError( + 'frame_oversized', + `IPC frame exceeds ${VERIFY_CONTRACT_LIMITS.maxFrameBytes} bytes` + ) + ); + return; + } + const newline = chunk.indexOf(0x0a); + if (newline === -1) { + chunks.push(chunk); + frameBytes += chunk.byteLength; + return; + } + const frameTail = chunk.subarray(0, newline); + chunks.push(frameTail); + frameBytes += frameTail.byteLength; + const trailing = chunk.subarray(newline + 1); + if (trailing.some((byte) => byte !== 0x20 && byte !== 0x09 && byte !== 0x0d)) { + finish( + new VerifyIpcError('frame_trailing_data', 'IPC connection may contain only one frame') + ); + return; + } + parse(Buffer.concat(chunks, frameBytes)); + }; + const onEnd = () => + finish(new VerifyIpcError('unexpected_eof', 'IPC connection ended before a complete frame')); + const onError = (error: Error) => + finish(new VerifyIpcError('connection', 'IPC connection failed', { cause: error })); + + socket.on('data', onData); + socket.once('end', onEnd); + socket.once('error', onError); + }); +} + +async function handleConnection( + socket: Socket, + handler: VerifyIpcHandler, + frameTimeoutMs: number +): Promise { + let requestId = 'invalid-request'; + const connection = new AbortController(); + socket.once('close', () => + connection.abort(new DOMException('Verification client disconnected', 'AbortError')) + ); + try { + const value = await readJsonFrame(socket, frameTimeoutMs); + const validation = validateDaemonRequestEnvelope(value); + if (!validation.ok) { + throw protocolError('Daemon request is invalid', validation.issues); + } + requestId = validation.value.request_id; + const response = await handler(validation.value, connection.signal); + const envelope: DaemonResponseEnvelope = { + protocol_version: VERIFY_PROTOCOL_VERSION, + request_id: requestId, + sent_at: new Date().toISOString(), + response, + }; + const responseValidation = validateDaemonResponseEnvelope(envelope); + if (!responseValidation.ok) { + throw protocolError('Daemon handler produced an invalid response', responseValidation.issues); + } + socket.end(encodeFrame(envelope)); + } catch (error) { + const ipcError = normalizeIpcError(error); + const envelope: DaemonResponseEnvelope = { + protocol_version: VERIFY_PROTOCOL_VERSION, + request_id: requestId, + sent_at: new Date().toISOString(), + response: { + type: 'error', + error: { + code: ipcError.code, + message: boundedErrorMessage(ipcError.message), + retryable: ipcError.code === 'timeout' || ipcError.code === 'connection', + }, + }, + }; + socket.end(encodeFrame(envelope)); + } +} + +function waitForConnect(socket: Socket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + socket.destroy(); + reject(new VerifyIpcError('timeout', `Daemon connection timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref(); + const cleanup = () => { + clearTimeout(timer); + socket.off('connect', onConnect); + socket.off('error', onError); + }; + const onConnect = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + reject(new VerifyIpcError('connection', 'Could not connect to verifyd', { cause: error })); + }; + socket.once('connect', onConnect); + socket.once('error', onError); + }); +} + +function protocolError( + message: string, + issues: ReadonlyArray<{ path: string; message: string }> +): VerifyIpcError { + return new VerifyIpcError( + 'protocol_invalid', + `${message}: ${issues.map((issue) => `${issue.path} ${issue.message}`).join('; ')}` + ); +} + +function normalizeIpcError(error: unknown): VerifyIpcError { + if (error instanceof VerifyIpcError) return error; + return new VerifyIpcError( + 'internal_error', + error instanceof Error ? error.message : 'Unknown IPC error', + { cause: error } + ); +} + +function remaining(deadline: number): number { + return Math.max(1, deadline - Date.now()); +} + +function boundedErrorMessage(message: string): string { + const normalized = message.trim() || 'Unknown IPC error'; + if (Buffer.byteLength(normalized) <= VERIFY_CONTRACT_LIMITS.maxStringBytes) return normalized; + return `${normalized.slice(0, 4_000)}...`; +} diff --git a/apps/desktop/src/lib/warm-verification/runtime-paths.test.ts b/apps/desktop/src/lib/warm-verification/runtime-paths.test.ts new file mode 100644 index 0000000..e2070df --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/runtime-paths.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { chmod, lstat, mkdtemp, rm, symlink } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import { + ensurePrivateRuntimeDirectory, + resolveRepositoryRuntimeIdentity, + resolveVerifyRuntimePaths, + VerifyRuntimePathError, +} from './runtime-paths'; + +describe('verification runtime paths', () => { + it('uses canonical repository identity and short owner-private paths', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'cv-runtime-test-')); + const repo = path.join(fixture, 'repo'); + const alias = path.join(fixture, 'repo-alias'); + const runtimeRoot = path.join(fixture, 'r'); + await symlink(fixture, repo); + await symlink(repo, alias); + + try { + const direct = await resolveRepositoryRuntimeIdentity(repo); + const viaAlias = await resolveRepositoryRuntimeIdentity(alias); + assert.deepEqual(viaAlias, direct); + + const paths = await resolveVerifyRuntimePaths(repo, { runtimeRoot }); + await ensurePrivateRuntimeDirectory(paths); + assert.equal((await lstat(paths.runtimeRoot)).mode & 0o777, 0o700); + assert.equal((await lstat(paths.runtimeDir)).mode & 0o777, 0o700); + assert.ok(Buffer.byteLength(paths.socketPath) <= 100); + assert.match(paths.id, /^[a-f0-9]{64}$/); + } finally { + await rm(fixture, { recursive: true, force: true }); + } + }); + + it('repairs owned directory permissions and rejects excessive socket paths', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'cv-runtime-test-')); + const runtimeRoot = path.join('/tmp', path.basename(fixture)); + try { + const paths = await resolveVerifyRuntimePaths(fixture, { + runtimeRoot, + }); + await ensurePrivateRuntimeDirectory(paths); + await chmod(paths.runtimeDir, 0o755); + await ensurePrivateRuntimeDirectory(paths); + assert.equal((await lstat(paths.runtimeDir)).mode & 0o777, 0o700); + + await assert.rejects( + resolveVerifyRuntimePaths(fixture, { + runtimeRoot: path.join(fixture, 'too-long'), + maxSocketPathBytes: 8, + }), + (error) => error instanceof VerifyRuntimePathError && error.code === 'too_long' + ); + } finally { + await rm(runtimeRoot, { recursive: true, force: true }); + await rm(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/runtime-paths.ts b/apps/desktop/src/lib/warm-verification/runtime-paths.ts new file mode 100644 index 0000000..67170cf --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/runtime-paths.ts @@ -0,0 +1,131 @@ +import { createHash } from 'node:crypto'; +import { chmod, lstat, mkdir, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +const RUNTIME_LAYOUT_VERSION = 1; +const SOCKET_NAME = 'd.sock'; +const DEFAULT_MAX_SOCKET_PATH_BYTES = 100; + +export interface RepositoryRuntimeIdentity { + canonicalRoot: string; + id: string; +} + +export interface VerifyRuntimePaths extends RepositoryRuntimeIdentity { + runtimeRoot: string; + runtimeDir: string; + socketPath: string; + leasePath: string; +} + +export interface RuntimePathOptions { + runtimeRoot?: string; + maxSocketPathBytes?: number; +} + +export class VerifyRuntimePathError extends Error { + readonly code: 'unsupported' | 'unsafe' | 'too_long'; + + constructor(code: VerifyRuntimePathError['code'], message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'VerifyRuntimePathError'; + this.code = code; + } +} + +export async function resolveRepositoryRuntimeIdentity( + repoRoot: string +): Promise { + const canonicalRoot = await realpath(repoRoot); + const uid = effectiveUid(); + const id = createHash('sha256') + .update(`codevetter-verify-runtime-v${RUNTIME_LAYOUT_VERSION}\0${uid}\0${canonicalRoot}`) + .digest('hex'); + return { canonicalRoot, id }; +} + +export async function resolveVerifyRuntimePaths( + repoRoot: string, + options: RuntimePathOptions = {} +): Promise { + if (process.platform === 'win32') { + throw new VerifyRuntimePathError( + 'unsupported', + 'Warm verification currently requires Unix-domain sockets' + ); + } + + const identity = await resolveRepositoryRuntimeIdentity(repoRoot); + const runtimeRoot = path.resolve( + options.runtimeRoot ?? path.join('/tmp', `cv-verify-${effectiveUid()}`) + ); + const runtimeDir = path.join(runtimeRoot, identity.id.slice(0, 16)); + const socketPath = path.join(runtimeDir, SOCKET_NAME); + const maxSocketPathBytes = options.maxSocketPathBytes ?? DEFAULT_MAX_SOCKET_PATH_BYTES; + const socketPathBytes = Buffer.byteLength(socketPath); + if (socketPathBytes > maxSocketPathBytes) { + throw new VerifyRuntimePathError( + 'too_long', + `Verification socket path is ${socketPathBytes} bytes; maximum is ${maxSocketPathBytes}` + ); + } + + return { + ...identity, + runtimeRoot, + runtimeDir, + socketPath, + leasePath: path.join(runtimeDir, 'owner.lease'), + }; +} + +export async function ensurePrivateRuntimeDirectory(paths: VerifyRuntimePaths): Promise { + await ensureOwnedDirectory(paths.runtimeRoot); + await ensureOwnedDirectory(paths.runtimeDir); +} + +export async function secureRuntimeSocket(socketPath: string): Promise { + const stats = await lstat(socketPath); + if (!stats.isSocket()) { + throw new VerifyRuntimePathError('unsafe', `Runtime endpoint is not a socket: ${socketPath}`); + } + assertOwned(stats.uid, socketPath); + await chmod(socketPath, 0o600); + const secured = await lstat(socketPath); + if ((secured.mode & 0o777) !== 0o600) { + throw new VerifyRuntimePathError('unsafe', `Could not secure runtime socket: ${socketPath}`); + } +} + +async function ensureOwnedDirectory(directory: string): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const stats = await lstat(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new VerifyRuntimePathError( + 'unsafe', + `Runtime path is not a real directory: ${directory}` + ); + } + assertOwned(stats.uid, directory); + if ((stats.mode & 0o777) !== 0o700) { + await chmod(directory, 0o700); + } +} + +function assertOwned(ownerUid: number, target: string): void { + const uid = effectiveUid(); + if (ownerUid !== uid) { + throw new VerifyRuntimePathError( + 'unsafe', + `Runtime path is owned by uid ${ownerUid}, not the current uid ${uid}: ${target}` + ); + } +} + +function effectiveUid(): number { + const uid = process.getuid?.(); + if (uid === undefined) { + throw new VerifyRuntimePathError('unsupported', 'Cannot determine the current Unix user'); + } + return uid; +} diff --git a/apps/desktop/src/lib/warm-verification/singleton.test.ts b/apps/desktop/src/lib/warm-verification/singleton.test.ts new file mode 100644 index 0000000..8453b8d --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/singleton.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdtemp, readFile, rm } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import { closeServer } from './ipc'; +import { resolveVerifyRuntimePaths } from './runtime-paths'; +import { acquireVerifySingleton, releaseVerifySingleton, VerifySingletonError } from './singleton'; + +describe('verifyd singleton ownership', () => { + it('publishes one private atomic lease and rejects a concurrent owner', async () => { + const fixture = await singletonFixture(); + try { + const handle = await acquireVerifySingleton(fixture.paths, { + pid: 101, + ownerToken: () => 'owner-token-one', + currentProcessStartIdentity: async () => 'start-one', + }); + assert.equal((await lstat(fixture.paths.leasePath)).mode & 0o777, 0o600); + assert.deepEqual(JSON.parse(await readFile(fixture.paths.leasePath, 'utf8')), handle.lease); + + await assert.rejects( + acquireVerifySingleton(fixture.paths, { + pid: 202, + ownerToken: () => 'owner-token-two', + currentProcessStartIdentity: async () => 'start-two', + processStartIdentity: async () => 'start-one', + processAlive: () => true, + }), + (error) => error instanceof VerifySingletonError && error.code === 'already_running' + ); + assert.equal(await releaseVerifySingleton(handle), true); + } finally { + await fixture.cleanup(); + } + }); + + it('recovers a stale PID identity without ever signaling or killing it', async () => { + const fixture = await singletonFixture(); + try { + const stale = await acquireVerifySingleton(fixture.paths, { + pid: 303, + ownerToken: () => 'stale-owner-token', + currentProcessStartIdentity: async () => 'old-start', + }); + let probes = 0; + const replacement = await acquireVerifySingleton(fixture.paths, { + pid: 404, + ownerToken: () => 'fresh-owner-token', + currentProcessStartIdentity: async () => 'fresh-start', + processStartIdentity: async () => 'reused-pid-start', + processAlive: () => { + probes += 1; + return true; + }, + socketResponsive: async () => false, + }); + + assert.equal(probes, 1); + assert.equal(replacement.lease.owner_token, 'fresh-owner-token'); + assert.equal(await releaseVerifySingleton(stale), false); + assert.match(await readFile(fixture.paths.leasePath, 'utf8'), /fresh-owner-token/); + assert.equal(await releaseVerifySingleton(replacement), true); + } finally { + await fixture.cleanup(); + } + }); + + it('refuses to recover while an unknown process responds on the socket', async () => { + const fixture = await singletonFixture(); + try { + await acquireVerifySingleton(fixture.paths, { + pid: 505, + ownerToken: () => 'stale-owner-token', + currentProcessStartIdentity: async () => 'old-start', + }); + const foreignServer = net.createServer(() => undefined); + await new Promise((resolve) => foreignServer.listen(fixture.paths.socketPath, resolve)); + try { + await assert.rejects( + acquireVerifySingleton(fixture.paths, { + pid: 606, + ownerToken: () => 'new-owner-token', + currentProcessStartIdentity: async () => 'new-start', + processStartIdentity: async () => 'different-start', + processAlive: () => false, + }), + (error) => error instanceof VerifySingletonError && error.code === 'busy' + ); + assert.match(await readFile(fixture.paths.leasePath, 'utf8'), /stale-owner-token/); + } finally { + await closeServer(foreignServer); + } + } finally { + await fixture.cleanup(); + } + }); + + it('does not let an obsolete owner remove a replacement lease', async () => { + const fixture = await singletonFixture(); + try { + const old = await acquireVerifySingleton(fixture.paths, { + pid: 707, + ownerToken: () => 'old-owner-token', + currentProcessStartIdentity: async () => 'old-start', + }); + const replacement = await acquireVerifySingleton(fixture.paths, { + pid: 808, + ownerToken: () => 'replacement-token', + currentProcessStartIdentity: async () => 'replacement-start', + processStartIdentity: async () => undefined, + processAlive: () => false, + socketResponsive: async () => false, + }); + assert.equal(await releaseVerifySingleton(old), false); + const leaseSource = await readFile(fixture.paths.leasePath, 'utf8'); + assert.match(leaseSource, /replacement-token/); + await releaseVerifySingleton(replacement); + } finally { + await fixture.cleanup(); + } + }); +}); + +async function singletonFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'cv-singleton-test-')); + const paths = await resolveVerifyRuntimePaths(root, { runtimeRoot: path.join(root, 'r') }); + return { + paths, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} diff --git a/apps/desktop/src/lib/warm-verification/singleton.ts b/apps/desktop/src/lib/warm-verification/singleton.ts new file mode 100644 index 0000000..2311b81 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/singleton.ts @@ -0,0 +1,317 @@ +import { randomUUID } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { constants } from 'node:fs'; +import { lstat, open, readFile, stat, unlink } from 'node:fs/promises'; +import net from 'node:net'; +import { promisify } from 'node:util'; + +import { ensurePrivateRuntimeDirectory, type VerifyRuntimePaths } from './runtime-paths'; + +const execFileAsync = promisify(execFile); +const SINGLETON_SCHEMA_VERSION = 1 as const; +const PARTIAL_LEASE_GRACE_MS = 2_000; + +export interface VerifyDaemonLease { + schema_version: typeof SINGLETON_SCHEMA_VERSION; + repo_id: string; + canonical_root: string; + owner_token: string; + pid: number; + process_start_identity: string; + socket_path: string; + acquired_at: string; +} + +export interface VerifySingletonHandle { + paths: VerifyRuntimePaths; + lease: VerifyDaemonLease; +} + +export interface SingletonDependencies { + pid?: number; + now?: () => Date; + ownerToken?: () => string; + currentProcessStartIdentity?: () => Promise; + processStartIdentity?: (pid: number) => Promise; + processAlive?: (pid: number) => boolean; + socketResponsive?: (socketPath: string) => Promise; +} + +export class VerifySingletonError extends Error { + readonly code: 'already_running' | 'busy' | 'invalid_lease' | 'not_owner' | 'unsafe'; + readonly lease?: VerifyDaemonLease; + + constructor( + code: VerifySingletonError['code'], + message: string, + lease?: VerifyDaemonLease, + options?: ErrorOptions + ) { + super(message, options); + this.name = 'VerifySingletonError'; + this.code = code; + this.lease = lease; + } +} + +export async function acquireVerifySingleton( + paths: VerifyRuntimePaths, + dependencies: SingletonDependencies = {} +): Promise { + await ensurePrivateRuntimeDirectory(paths); + const pid = dependencies.pid ?? process.pid; + const processStartIdentity = await ( + dependencies.currentProcessStartIdentity ?? (() => readProcessStartIdentity(pid)) + )(); + if (!processStartIdentity) { + throw new VerifySingletonError('unsafe', `Could not identify daemon process ${pid}`); + } + + const lease: VerifyDaemonLease = { + schema_version: SINGLETON_SCHEMA_VERSION, + repo_id: paths.id, + canonical_root: paths.canonicalRoot, + owner_token: (dependencies.ownerToken ?? randomUUID)(), + pid, + process_start_identity: processStartIdentity, + socket_path: paths.socketPath, + acquired_at: (dependencies.now ?? (() => new Date()))().toISOString(), + }; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await createLease(paths.leasePath, lease); + return { paths, lease }; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readExistingLease(paths.leasePath); + if (!existing) { + const age = await fileAge(paths.leasePath, dependencies.now?.() ?? new Date()); + if (age < PARTIAL_LEASE_GRACE_MS) { + throw new VerifySingletonError( + 'busy', + 'Another verifyd process is currently acquiring the singleton lease' + ); + } + await recoverMalformedLease(paths); + continue; + } + assertLeaseMatchesPaths(existing, paths); + const inspect = dependencies.processStartIdentity ?? readProcessStartIdentity; + const recordedPidIdentity = await inspect(existing.pid); + const alive = (dependencies.processAlive ?? isProcessAlive)(existing.pid); + if ( + recordedPidIdentity === existing.process_start_identity || + (!recordedPidIdentity && alive) + ) { + throw new VerifySingletonError( + 'already_running', + `verifyd already owns this repository as pid ${existing.pid}`, + existing + ); + } + if (await (dependencies.socketResponsive ?? isSocketResponsive)(paths.socketPath)) { + throw new VerifySingletonError( + 'busy', + 'The verification socket is owned by a responsive process; refusing stale recovery', + existing + ); + } + await recoverOwnedStaleLease(paths, existing); + } + } + + throw new VerifySingletonError( + 'busy', + 'Could not acquire verifyd singleton after stale recovery' + ); +} + +export async function releaseVerifySingleton(handle: VerifySingletonHandle): Promise { + const current = await readExistingLease(handle.paths.leasePath); + if (!current || current.owner_token !== handle.lease.owner_token) return false; + + await removeOwnedSocket(handle.paths, current); + return removeIfOwned(handle.paths.leasePath, current.owner_token); +} + +export async function readProcessStartIdentity(pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined; + if (process.platform === 'linux') { + try { + const source = await readFile(`/proc/${pid}/stat`, 'utf8'); + const closingParen = source.lastIndexOf(')'); + const fieldsAfterCommand = source + .slice(closingParen + 2) + .trim() + .split(/\s+/); + const startTicks = fieldsAfterCommand[19]; + return startTicks ? `linux:${startTicks}` : undefined; + } catch { + return undefined; + } + } + if (process.platform === 'darwin') { + try { + const { stdout } = await execFileAsync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { + encoding: 'utf8', + timeout: 1_000, + }); + const started = stdout.trim().replace(/\s+/g, ' '); + return started ? `darwin:${started}` : undefined; + } catch { + return undefined; + } + } + return isProcessAlive(pid) ? `unverified:${pid}` : undefined; +} + +async function createLease(path: string, lease: VerifyDaemonLease): Promise { + const file = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + try { + await file.writeFile(`${JSON.stringify(lease)}\n`, 'utf8'); + await file.sync(); + } finally { + await file.close(); + } +} + +async function readExistingLease(path: string): Promise { + try { + const stats = await lstat(path); + if (!stats.isFile() || stats.isSymbolicLink() || stats.uid !== process.getuid?.()) { + throw new VerifySingletonError( + 'unsafe', + `Singleton metadata is not an owned regular file: ${path}` + ); + } + if ((stats.mode & 0o077) !== 0) { + throw new VerifySingletonError('unsafe', `Singleton metadata is not private: ${path}`); + } + const value: unknown = JSON.parse(await readFile(path, 'utf8')); + return parseLease(value); + } catch (error) { + if (isNotFound(error) || error instanceof SyntaxError) return undefined; + throw error; + } +} + +function parseLease(value: unknown): VerifyDaemonLease | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const lease = value as Partial; + if ( + lease.schema_version !== SINGLETON_SCHEMA_VERSION || + typeof lease.repo_id !== 'string' || + !/^[a-f0-9]{64}$/.test(lease.repo_id) || + typeof lease.canonical_root !== 'string' || + typeof lease.owner_token !== 'string' || + lease.owner_token.length < 8 || + !Number.isSafeInteger(lease.pid) || + (lease.pid ?? 0) <= 0 || + typeof lease.process_start_identity !== 'string' || + typeof lease.socket_path !== 'string' || + typeof lease.acquired_at !== 'string' || + Number.isNaN(Date.parse(lease.acquired_at)) + ) { + return undefined; + } + return lease as VerifyDaemonLease; +} + +function assertLeaseMatchesPaths(lease: VerifyDaemonLease, paths: VerifyRuntimePaths): void { + if ( + lease.repo_id !== paths.id || + lease.canonical_root !== paths.canonicalRoot || + lease.socket_path !== paths.socketPath + ) { + throw new VerifySingletonError( + 'invalid_lease', + 'Singleton metadata does not belong to this repository runtime' + ); + } +} + +async function recoverMalformedLease(paths: VerifyRuntimePaths): Promise { + await removeOwnedSocket(paths); + await unlink(paths.leasePath).catch(ignoreNotFound); +} + +async function recoverOwnedStaleLease( + paths: VerifyRuntimePaths, + staleLease: VerifyDaemonLease +): Promise { + const current = await readExistingLease(paths.leasePath); + if (!current || current.owner_token !== staleLease.owner_token) return; + await removeOwnedSocket(paths, current); + await removeIfOwned(paths.leasePath, current.owner_token); +} + +async function removeOwnedSocket( + paths: VerifyRuntimePaths, + lease?: VerifyDaemonLease +): Promise { + if (lease && lease.socket_path !== paths.socketPath) { + throw new VerifySingletonError('not_owner', 'Lease does not own the configured socket path'); + } + try { + const stats = await lstat(paths.socketPath); + if (!stats.isSocket() || stats.uid !== process.getuid?.()) { + throw new VerifySingletonError('unsafe', 'Refusing to remove a foreign runtime endpoint'); + } + await unlink(paths.socketPath); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +async function removeIfOwned(path: string, ownerToken: string): Promise { + const lease = await readExistingLease(path); + if (!lease || lease.owner_token !== ownerToken) return false; + await unlink(path).catch(ignoreNotFound); + return true; +} + +async function fileAge(path: string, now: Date): Promise { + try { + return Math.max(0, now.getTime() - (await stat(path)).mtimeMs); + } catch { + return 0; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function isSocketResponsive(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ path: socketPath }); + const timer = setTimeout(() => finish(false), 250); + timer.unref(); + const finish = (responsive: boolean) => { + clearTimeout(timer); + socket.removeAllListeners(); + socket.destroy(); + resolve(responsive); + }; + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + }); +} + +function ignoreNotFound(error: unknown): void { + if (!isNotFound(error)) throw error; +} + +function isNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; +} + +function isAlreadyExists(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'EEXIST'; +} From e400561122e50c9d96b49eef7424c9b761b4f563 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:24:21 +0530 Subject: [PATCH 006/141] feat(verify): supervise warm browser runtime --- .../lib/warm-verification/supervision.test.ts | 491 +++++++++ .../src/lib/warm-verification/supervision.ts | 928 ++++++++++++++++++ 2 files changed, 1419 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/supervision.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/supervision.ts diff --git a/apps/desktop/src/lib/warm-verification/supervision.test.ts b/apps/desktop/src/lib/warm-verification/supervision.test.ts new file mode 100644 index 0000000..9eec680 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/supervision.test.ts @@ -0,0 +1,491 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, it } from 'node:test'; +import type { VerifyServerConfig } from './config'; +import { + AppServerSupervisor, + buildServerEnvironment, + chromiumRevisionFromExecutablePath, + SupervisionError, + WarmChromiumSupervisor, + WarmRuntimeSupervisor, + type Clock, + type OwnedChildProcess, + type SpawnOptions, + type WarmBrowser, +} from './supervision'; + +class FakeClock implements Clock { + current = 0; + + now(): number { + return this.current; + } + + async sleep(milliseconds: number): Promise { + this.current += milliseconds; + } +} + +class FakeChild extends EventEmitter implements OwnedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + + constructor(readonly pid: number) { + super(); + } + + exit(code: number | null, signal: NodeJS.Signals | null = null): void { + if (this.exitCode !== null || this.signalCode !== null) { + return; + } + this.exitCode = code; + this.signalCode = signal; + this.emit('exit', code, signal); + } +} + +class FakeBrowser extends EventEmitter implements WarmBrowser { + connected = true; + closeCalls = 0; + + constructor(private readonly browserVersion = '135.0.1') { + super(); + } + + version(): string { + return this.browserVersion; + } + + isConnected(): boolean { + return this.connected; + } + + newContext(): never { + throw new Error('FakeBrowser.newContext is not used by supervision tests'); + } + + disconnect(): void { + if (!this.connected) { + return; + } + this.connected = false; + this.emit('disconnected'); + } + + async close(): Promise { + this.closeCalls += 1; + this.disconnect(); + } +} + +function serverConfig(overrides: Partial = {}): VerifyServerConfig { + return { + command: ['pnpm', 'dev'], + cwd: '.', + readinessUrl: 'http://127.0.0.1:4173/health', + baseUrl: 'http://127.0.0.1:4173', + allowedEnv: ['NODE_ENV'], + hmrSettleMs: 20, + shutdownGraceMs: 10, + ...overrides, + }; +} + +async function expectSupervisionError( + operation: Promise, + code: SupervisionError['code'] +): Promise { + await assert.rejects(operation, (error: unknown) => { + assert.ok(error instanceof SupervisionError); + assert.equal(error.code, code); + return true; + }); +} + +describe('AppServerSupervisor', () => { + it('spawns argv without a shell, passes only allowlisted environment, and waits for settled HMR', async () => { + const child = new FakeChild(4211); + const clock = new FakeClock(); + const spawnCalls: Array<{ + executable: string; + args: readonly string[]; + options: SpawnOptions; + }> = []; + const readiness = [false, true, true, true]; + let readinessCalls = 0; + const signals: Array<{ processGroupId: number; signal: NodeJS.Signals }> = []; + const supervisor = new AppServerSupervisor( + '/repo', + serverConfig(), + { + clock, + createIdentity: () => 'stable-nonce', + sourceEnvironment: { + PATH: '/bin', + NODE_ENV: 'test', + SECRET_TOKEN: 'must-not-leak', + }, + probeListener: async () => false, + probeReadiness: async () => { + const result = readiness[Math.min(readinessCalls, readiness.length - 1)] ?? false; + readinessCalls += 1; + return result; + }, + spawnProcess: (executable, args, options) => { + spawnCalls.push({ executable, args, options }); + return child; + }, + signalProcessGroup: (processGroupId, signal) => { + signals.push({ processGroupId, signal }); + child.exit(null, signal); + }, + }, + { readinessPollMs: 10, startupTimeoutMs: 100, maxLogBytes: 32 } + ); + + const health = await supervisor.start(); + + assert.equal(health.state, 'ready'); + assert.equal(health.pid, 4211); + assert.equal(health.processGroupId, 4211); + assert.equal(health.startIdentity, '4211:1:stable-nonce'); + assert.equal(health.generation, 1); + assert.equal(readinessCalls, 4); + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0]?.executable, 'pnpm'); + assert.deepEqual(spawnCalls[0]?.args, ['dev']); + assert.equal(spawnCalls[0]?.options.shell, false); + assert.equal(spawnCalls[0]?.options.detached, true); + assert.equal(spawnCalls[0]?.options.cwd, '/repo'); + assert.deepEqual(spawnCalls[0]?.options.env, { PATH: '/bin', NODE_ENV: 'test' }); + + child.stdout.write('a'.repeat(100)); + child.stderr.write('last-error'); + await new Promise((resolveImmediate) => setImmediate(resolveImmediate)); + const logs = supervisor.health().logs; + assert.ok(logs.bytes <= 32); + assert.ok(logs.droppedBytes > 0); + assert.match(logs.text, /last-error/); + + await supervisor.stop(); + assert.deepEqual(signals, [{ processGroupId: 4211, signal: 'SIGTERM' }]); + assert.equal(supervisor.health().state, 'stopped'); + assert.equal(supervisor.health().owned, false); + }); + + it('refuses a foreign listener without spawning or signaling anything', async () => { + let spawnCalls = 0; + let signalCalls = 0; + const supervisor = new AppServerSupervisor('/repo', serverConfig(), { + probeListener: async () => true, + spawnProcess: () => { + spawnCalls += 1; + return new FakeChild(42); + }, + signalProcessGroup: () => { + signalCalls += 1; + }, + }); + + await expectSupervisionError(supervisor.start(), 'foreign_listener'); + + assert.equal(spawnCalls, 0); + assert.equal(signalCalls, 0); + assert.equal(supervisor.health().owned, false); + }); + + it('uses the owned process group and escalates after the graceful timeout', async () => { + const child = new FakeChild(91); + const signals: NodeJS.Signals[] = []; + const supervisor = new AppServerSupervisor( + '/repo', + serverConfig({ hmrSettleMs: 0, shutdownGraceMs: 1 }), + { + probeListener: async () => false, + probeReadiness: async () => true, + spawnProcess: () => child, + signalProcessGroup: (processGroupId, signal) => { + assert.equal(processGroupId, 91); + signals.push(signal); + if (signal === 'SIGKILL') { + child.exit(null, signal); + } + }, + } + ); + + await supervisor.start(); + await supervisor.stop(); + + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); + assert.equal(supervisor.health().owned, false); + }); + + it('retains ownership when a process group does not report exit after escalation', async () => { + const child = new FakeChild(92); + const signals: NodeJS.Signals[] = []; + const supervisor = new AppServerSupervisor( + '/repo', + serverConfig({ hmrSettleMs: 0, shutdownGraceMs: 1 }), + { + probeListener: async () => false, + probeReadiness: async () => true, + spawnProcess: () => child, + signalProcessGroup: (_processGroupId, signal) => signals.push(signal), + } + ); + + await supervisor.start(); + await expectSupervisionError(supervisor.stop(), 'shutdown_timeout'); + + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); + assert.equal(supervisor.health().state, 'unhealthy'); + assert.equal(supervisor.health().owned, true); + assert.equal(supervisor.health().startIdentity?.startsWith('92:1:'), true); + }); + + it('allows one recovery generation and then locks out repeated crashes', async () => { + const children = [new FakeChild(101), new FakeChild(102)]; + let spawnIndex = 0; + const supervisor = new AppServerSupervisor( + '/repo', + serverConfig({ hmrSettleMs: 0 }), + { + clock: new FakeClock(), + probeListener: async () => false, + probeReadiness: async () => true, + spawnProcess: () => children[spawnIndex++]!, + }, + { maxRecoveryAttempts: 1 } + ); + + await supervisor.start(); + children[0]?.exit(1); + assert.equal(supervisor.health().state, 'unhealthy'); + + const recovered = await supervisor.ensureReady(); + assert.equal(recovered.state, 'ready'); + assert.equal(recovered.generation, 2); + assert.equal(recovered.recoveryAttempts, 1); + + children[1]?.exit(1); + assert.equal(supervisor.health().state, 'locked'); + await expectSupervisionError(supervisor.ensureReady(), 'recovery_locked'); + assert.equal(spawnIndex, 2); + }); + + it('re-probes a ready server and stops its owned group before recovery', async () => { + const children = [new FakeChild(201), new FakeChild(202)]; + let ready = true; + let spawnIndex = 0; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const supervisor = new AppServerSupervisor('/repo', serverConfig({ hmrSettleMs: 0 }), { + probeListener: async () => false, + probeReadiness: async () => ready, + spawnProcess: () => children[spawnIndex++]!, + signalProcessGroup: (pid, signal) => { + signals.push({ pid, signal }); + children.find((child) => child.pid === pid)?.exit(null, signal); + ready = true; + }, + }); + + await supervisor.start(); + ready = false; + const recovered = await supervisor.ensureReady(); + + assert.equal(recovered.generation, 2); + assert.equal(recovered.pid, 202); + assert.deepEqual(signals, [{ pid: 201, signal: 'SIGTERM' }]); + }); + + it('coalesces concurrent start calls into one owned server generation', async () => { + const child = new FakeChild(801); + let spawnCalls = 0; + let releaseReadiness: (() => void) | undefined; + const readinessGate = new Promise((resolveGate) => { + releaseReadiness = resolveGate; + }); + const supervisor = new AppServerSupervisor('/repo', serverConfig({ hmrSettleMs: 0 }), { + probeListener: async () => false, + probeReadiness: async () => { + await readinessGate; + return true; + }, + spawnProcess: () => { + spawnCalls += 1; + return child; + }, + }); + + const first = supervisor.start(); + const second = supervisor.start(); + releaseReadiness?.(); + const [firstHealth, secondHealth] = await Promise.all([first, second]); + + assert.equal(spawnCalls, 1); + assert.equal(firstHealth.startIdentity, secondHealth.startIdentity); + assert.equal(firstHealth.generation, 1); + }); + + it('rejects non-loopback targets and repository escapes before spawn', () => { + assert.throws( + () => + new AppServerSupervisor( + '/repo', + serverConfig({ readinessUrl: 'https://example.com/health' }) + ), + (error: unknown) => error instanceof SupervisionError && error.code === 'invalid_target' + ); + assert.throws( + () => new AppServerSupervisor('/repo', serverConfig({ cwd: '../other' })), + (error: unknown) => error instanceof SupervisionError && error.code === 'invalid_target' + ); + }); +}); + +describe('WarmChromiumSupervisor', () => { + it('reuses one browser and reports its pinned revision and generation', async () => { + const browser = new FakeBrowser(); + let launches = 0; + const supervisor = new WarmChromiumSupervisor({ + executablePath: () => '/cache/ms-playwright/chromium-1217/chrome', + launchBrowser: async () => { + launches += 1; + return browser; + }, + }); + + const first = await supervisor.start(); + const second = await supervisor.start(); + + assert.equal(launches, 1); + assert.equal(first.generation, 1); + assert.equal(second.generation, 1); + assert.equal(second.revision, '1217'); + assert.equal(second.version, '135.0.1'); + assert.equal(supervisor.currentBrowser(), browser); + + await supervisor.stop(); + assert.equal(browser.closeCalls, 1); + assert.equal(supervisor.health().state, 'stopped'); + }); + + it('recovers one disconnect and locks out the next one until explicit restart', async () => { + const browsers = [new FakeBrowser('1'), new FakeBrowser('2'), new FakeBrowser('3')]; + let launchIndex = 0; + const supervisor = new WarmChromiumSupervisor( + { + clock: new FakeClock(), + executablePath: () => '/cache/chrome-headless-shell-1217/chrome', + launchBrowser: async () => browsers[launchIndex++]!, + }, + { maxRecoveryAttempts: 1 } + ); + + await supervisor.start(); + browsers[0]?.disconnect(); + assert.equal(supervisor.health().state, 'unhealthy'); + + const recovered = await supervisor.ensureReady(); + assert.equal(recovered.generation, 2); + assert.equal(recovered.recoveryAttempts, 1); + assert.equal(recovered.version, '2'); + + browsers[1]?.disconnect(); + assert.equal(supervisor.health().state, 'locked'); + await expectSupervisionError(supervisor.ensureReady(), 'recovery_locked'); + + const restarted = await supervisor.restart(); + assert.equal(restarted.state, 'ready'); + assert.equal(restarted.generation, 3); + assert.equal(restarted.recoveryAttempts, 0); + assert.equal(launchIndex, 3); + }); + + it('rejects a browser that is already disconnected', async () => { + const browser = new FakeBrowser(); + browser.disconnect(); + const supervisor = new WarmChromiumSupervisor({ launchBrowser: async () => browser }); + + await expectSupervisionError(supervisor.start(), 'browser_unavailable'); + assert.equal(browser.closeCalls, 1); + assert.equal(supervisor.health().state, 'unhealthy'); + }); + + it('coalesces concurrent starts into one warm Chromium generation', async () => { + const browser = new FakeBrowser(); + let launches = 0; + let releaseLaunch: (() => void) | undefined; + const launchGate = new Promise((resolveGate) => { + releaseLaunch = resolveGate; + }); + const supervisor = new WarmChromiumSupervisor({ + launchBrowser: async () => { + launches += 1; + await launchGate; + return browser; + }, + }); + + const first = supervisor.start(); + const second = supervisor.ensureReady(); + releaseLaunch?.(); + const [firstHealth, secondHealth] = await Promise.all([first, second]); + + assert.equal(launches, 1); + assert.equal(firstHealth.generation, 1); + assert.equal(secondHealth.generation, 1); + }); +}); + +describe('WarmRuntimeSupervisor', () => { + it('starts server and browser together and stops both owned resources', async () => { + const child = new FakeChild(701); + const browser = new FakeBrowser(); + const server = new AppServerSupervisor('/repo', serverConfig({ hmrSettleMs: 0 }), { + probeListener: async () => false, + probeReadiness: async () => true, + spawnProcess: () => child, + signalProcessGroup: (_processGroupId, signal) => child.exit(null, signal), + }); + const chromium = new WarmChromiumSupervisor({ launchBrowser: async () => browser }); + const runtime = new WarmRuntimeSupervisor(server, chromium); + + const health = await runtime.start(); + assert.equal(health.warm, true); + assert.equal(health.generation, 1); + + await runtime.stop(); + assert.equal(runtime.health().warm, false); + assert.equal(server.health().owned, false); + assert.equal(browser.closeCalls, 1); + }); +}); + +describe('supervision helpers', () => { + it('selects only runtime and configured environment names', () => { + assert.deepEqual( + buildServerEnvironment(['NODE_ENV'], { + PATH: '/bin', + TMPDIR: '/tmp', + NODE_ENV: 'test', + ACCESS_TOKEN: 'secret', + }), + { PATH: '/bin', TMPDIR: '/tmp', NODE_ENV: 'test' } + ); + }); + + it('extracts known Playwright browser revisions without guessing', () => { + assert.equal(chromiumRevisionFromExecutablePath('/cache/chromium-1217/chrome'), '1217'); + assert.equal( + chromiumRevisionFromExecutablePath('/cache/chrome-headless-shell-1217/chrome'), + '1217' + ); + assert.equal(chromiumRevisionFromExecutablePath('/Applications/Chromium'), 'unknown'); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/supervision.ts b/apps/desktop/src/lib/warm-verification/supervision.ts new file mode 100644 index 0000000..69b4b5f --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/supervision.ts @@ -0,0 +1,928 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { connect } from 'node:net'; +import { isAbsolute, relative, resolve } from 'node:path'; +import { chromium, type Browser } from '@playwright/test'; +import type { VerifyServerConfig } from './config'; + +export const DEFAULT_STARTUP_TIMEOUT_MS = 60_000; +export const DEFAULT_READINESS_POLL_MS = 50; +export const DEFAULT_LOG_BYTES = 256 * 1024; +export const DEFAULT_RECOVERY_ATTEMPTS = 1; + +const RUNTIME_ENV_ALLOWLIST = ['PATH', 'TMPDIR', 'TMP', 'TEMP'] as const; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']); + +export type SupervisionState = + | 'stopped' + | 'starting' + | 'ready' + | 'unhealthy' + | 'recovering' + | 'locked'; + +export type SupervisionErrorCode = + | 'foreign_listener' + | 'invalid_target' + | 'launch_failed' + | 'readiness_timeout' + | 'child_exited' + | 'shutdown_timeout' + | 'browser_unavailable' + | 'recovery_locked'; + +export class SupervisionError extends Error { + readonly code: SupervisionErrorCode; + readonly retryable: boolean; + + constructor(code: SupervisionErrorCode, message: string, retryable: boolean, cause?: unknown) { + super(message, { cause }); + this.name = 'SupervisionError'; + this.code = code; + this.retryable = retryable; + } +} + +export interface Clock { + now(): number; + sleep(milliseconds: number): Promise; +} + +const systemClock: Clock = { + now: () => Date.now(), + sleep: (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)), +}; + +export interface ProcessOutput { + on(event: 'data', listener: (chunk: Buffer | string) => void): this; +} + +export interface OwnedChildProcess { + readonly pid?: number; + readonly stdout: ProcessOutput | null; + readonly stderr: ProcessOutput | null; + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + on(event: 'error', listener: (error: Error) => void): this; +} + +export interface SpawnOptions { + cwd: string; + env: NodeJS.ProcessEnv; + shell: false; + detached: true; + stdio: ['ignore', 'pipe', 'pipe']; +} + +export type SpawnOwnedProcess = ( + executable: string, + args: readonly string[], + options: SpawnOptions +) => OwnedChildProcess; + +export type ProcessGroupSignal = (processGroupId: number, signal: NodeJS.Signals) => void; +export type ListenerProbe = (url: URL) => Promise; +export type ReadinessProbe = (url: URL) => Promise; + +export interface ProcessExit { + code: number | null; + signal: NodeJS.Signals | null; + at: string; +} + +export interface BoundedLogSnapshot { + text: string; + bytes: number; + droppedBytes: number; +} + +export interface ServerSupervisionHealth { + state: SupervisionState; + owned: boolean; + pid: number | null; + processGroupId: number | null; + startIdentity: string | null; + generation: number; + recoveryAttempts: number; + lastExit: ProcessExit | null; + logs: BoundedLogSnapshot; +} + +export interface ServerSupervisorDependencies { + spawnProcess?: SpawnOwnedProcess; + signalProcessGroup?: ProcessGroupSignal; + probeListener?: ListenerProbe; + probeReadiness?: ReadinessProbe; + clock?: Clock; + sourceEnvironment?: NodeJS.ProcessEnv; + createIdentity?: () => string; +} + +export interface ServerSupervisorOptions { + startupTimeoutMs?: number; + readinessPollMs?: number; + maxLogBytes?: number; + maxRecoveryAttempts?: number; +} + +class BoundedLog { + private bytes = Buffer.alloc(0); + private droppedBytes = 0; + + constructor(private readonly maxBytes: number) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError('maxLogBytes must be a positive safe integer'); + } + } + + append(stream: 'stdout' | 'stderr', value: Buffer | string): void { + const payload = Buffer.isBuffer(value) ? value : Buffer.from(value); + const framed = Buffer.concat([Buffer.from(`[${stream}] `), payload]); + const overflow = Math.max(0, this.bytes.length + framed.length - this.maxBytes); + if (overflow > 0) { + this.droppedBytes += overflow; + this.bytes = this.bytes.subarray(Math.min(overflow, this.bytes.length)); + } + const remaining = Math.max(0, this.maxBytes - this.bytes.length); + this.bytes = Buffer.concat([ + this.bytes, + framed.length > remaining ? framed.subarray(framed.length - remaining) : framed, + ]); + } + + snapshot(): BoundedLogSnapshot { + return { + text: this.bytes.toString('utf8'), + bytes: this.bytes.length, + droppedBytes: this.droppedBytes, + }; + } + + clear(): void { + this.bytes = Buffer.alloc(0); + this.droppedBytes = 0; + } +} + +function defaultSpawnProcess( + executable: string, + args: readonly string[], + options: SpawnOptions +): OwnedChildProcess { + return spawn(executable, [...args], options); +} + +function defaultSignalProcessGroup(processGroupId: number, signal: NodeJS.Signals): void { + process.kill(-processGroupId, signal); +} + +function defaultProbeListener(url: URL): Promise { + const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80)); + return new Promise((resolveProbe) => { + const socket = connect({ host: normalizedHostname(url), port }); + const finish = (listening: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolveProbe(listening); + }; + socket.setTimeout(500); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.once('timeout', () => finish(false)); + }); +} + +async function defaultProbeReadiness(url: URL): Promise { + try { + const response = await fetch(url, { + redirect: 'manual', + signal: AbortSignal.timeout(1_000), + }); + const ready = response.ok; + await response.body?.cancel(); + return ready; + } catch { + return false; + } +} + +function normalizedHostname(url: URL): string { + return url.hostname === '[::1]' ? '::1' : url.hostname; +} + +function checkedLoopbackUrl(value: string, field: string): URL { + let parsed: URL; + try { + parsed = new URL(value); + } catch (error) { + throw new SupervisionError('invalid_target', `${field} must be a valid URL`, false, error); + } + if (!['http:', 'https:'].includes(parsed.protocol) || !LOOPBACK_HOSTS.has(parsed.hostname)) { + throw new SupervisionError( + 'invalid_target', + `${field} must use HTTP(S) on localhost, 127.0.0.1, or ::1`, + false + ); + } + return parsed; +} + +function checkedTargetCwd(targetRoot: string, targetCwd: string): string { + if (isAbsolute(targetCwd)) { + throw new SupervisionError('invalid_target', 'target cwd must be repository-relative', false); + } + const root = resolve(targetRoot); + const cwd = resolve(root, targetCwd); + const fromRoot = relative(root, cwd); + if (fromRoot === '..' || fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new SupervisionError('invalid_target', 'target cwd escapes the repository', false); + } + return cwd; +} + +export function buildServerEnvironment( + allowedNames: readonly string[], + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const names = new Set([...RUNTIME_ENV_ALLOWLIST, ...allowedNames]); + const selected: NodeJS.ProcessEnv = {}; + for (const name of names) { + const value = source[name]; + if (value !== undefined) { + selected[name] = value; + } + } + return selected; +} + +function createStartIdentity(pid: number, generation: number, nonce: string): string { + return `${pid}:${generation}:${nonce}`; +} + +export class AppServerSupervisor { + private readonly startupTimeoutMs: number; + private readonly readinessPollMs: number; + private readonly maxRecoveryAttempts: number; + private readonly spawnProcess: SpawnOwnedProcess; + private readonly signalProcessGroup: ProcessGroupSignal; + private readonly probeListener: ListenerProbe; + private readonly probeReadiness: ReadinessProbe; + private readonly clock: Clock; + private readonly sourceEnvironment: NodeJS.ProcessEnv; + private readonly createIdentity: () => string; + private readonly logs: BoundedLog; + private readonly readinessUrl: URL; + private readonly targetCwd: string; + + private state: SupervisionState = 'stopped'; + private child: OwnedChildProcess | null = null; + private ownedIdentity: string | null = null; + private generation = 0; + private recoveryAttempts = 0; + private lastExit: ProcessExit | null = null; + private intentionallyStopping = false; + private everStarted = false; + private transitionInFlight: Promise | null = null; + + constructor( + targetRoot: string, + private readonly config: VerifyServerConfig, + dependencies: ServerSupervisorDependencies = {}, + options: ServerSupervisorOptions = {} + ) { + this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; + this.readinessPollMs = options.readinessPollMs ?? DEFAULT_READINESS_POLL_MS; + this.maxRecoveryAttempts = options.maxRecoveryAttempts ?? DEFAULT_RECOVERY_ATTEMPTS; + this.spawnProcess = dependencies.spawnProcess ?? defaultSpawnProcess; + this.signalProcessGroup = dependencies.signalProcessGroup ?? defaultSignalProcessGroup; + this.probeListener = dependencies.probeListener ?? defaultProbeListener; + this.probeReadiness = dependencies.probeReadiness ?? defaultProbeReadiness; + this.clock = dependencies.clock ?? systemClock; + this.sourceEnvironment = dependencies.sourceEnvironment ?? process.env; + this.createIdentity = dependencies.createIdentity ?? randomUUID; + this.logs = new BoundedLog(options.maxLogBytes ?? DEFAULT_LOG_BYTES); + this.readinessUrl = checkedLoopbackUrl(config.readinessUrl, 'readinessUrl'); + checkedLoopbackUrl(config.baseUrl, 'baseUrl'); + this.targetCwd = checkedTargetCwd(targetRoot, config.cwd); + + if (this.startupTimeoutMs < 1 || this.readinessPollMs < 1) { + throw new RangeError('startupTimeoutMs and readinessPollMs must be positive'); + } + if (!Number.isSafeInteger(this.maxRecoveryAttempts) || this.maxRecoveryAttempts < 0) { + throw new RangeError('maxRecoveryAttempts must be either zero or one'); + } + if (this.maxRecoveryAttempts > 1) { + throw new RangeError('maxRecoveryAttempts must be either zero or one'); + } + } + + health(): ServerSupervisionHealth { + const pid = this.child?.pid ?? null; + return { + state: this.state, + owned: this.child !== null && this.ownedIdentity !== null, + pid, + processGroupId: pid, + startIdentity: this.ownedIdentity, + generation: this.generation, + recoveryAttempts: this.recoveryAttempts, + lastExit: this.lastExit, + logs: this.logs.snapshot(), + }; + } + + async start(): Promise { + return this.runTransition(() => this.startTransition()); + } + + async ensureReady(): Promise { + return this.runTransition(() => this.ensureReadyTransition()); + } + + private async startTransition(): Promise { + if (this.state === 'ready' && this.child !== null) { + return this.health(); + } + if (this.state === 'locked') { + throw recoveryLocked('app server'); + } + if (this.everStarted && this.state !== 'stopped') { + return this.recover(); + } + await this.launch('starting'); + this.everStarted = true; + return this.health(); + } + + private async ensureReadyTransition(): Promise { + if (this.state === 'ready' && this.child !== null) { + if (await this.probeReadiness(this.readinessUrl)) return this.health(); + this.state = 'unhealthy'; + } + if (!this.everStarted && this.state === 'stopped') { + return this.startTransition(); + } + return this.recover(); + } + + private runTransition( + operation: () => Promise + ): Promise { + if (this.transitionInFlight !== null) { + return this.transitionInFlight; + } + const transition = operation().finally(() => { + if (this.transitionInFlight === transition) { + this.transitionInFlight = null; + } + }); + this.transitionInFlight = transition; + return transition; + } + + async restart(): Promise { + await this.stop(); + this.recoveryAttempts = 0; + this.state = 'stopped'; + this.everStarted = false; + this.logs.clear(); + return this.start(); + } + + async stop(): Promise { + const child = this.child; + const identity = this.ownedIdentity; + if (child === null || identity === null) { + this.state = 'stopped'; + return; + } + const pid = child.pid; + if (pid === undefined || pid < 1) { + this.clearOwnedChild(child, identity, 'stopped'); + return; + } + + this.intentionallyStopping = true; + let exited = child.exitCode !== null || child.signalCode !== null; + try { + if (!exited) { + this.sendOwnedSignal(child, identity, pid, 'SIGTERM'); + exited = await this.waitForExit(child, this.config.shutdownGraceMs); + if (!exited) { + this.sendOwnedSignal(child, identity, pid, 'SIGKILL'); + exited = await this.waitForExit(child, Math.min(1_000, this.config.shutdownGraceMs)); + } + } + if (!exited) { + this.state = 'unhealthy'; + throw new SupervisionError( + 'shutdown_timeout', + `Owned app server process group ${pid} did not report exit after SIGKILL`, + true + ); + } + } finally { + if (exited) { + this.clearOwnedChild(child, identity, 'stopped'); + } + this.intentionallyStopping = false; + } + } + + private async recover(): Promise { + if (this.state === 'locked' || this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.state = 'locked'; + throw recoveryLocked('app server'); + } + this.recoveryAttempts += 1; + try { + if (this.child !== null) await this.stop(); + await this.launch('recovering'); + return this.health(); + } catch (error) { + this.state = 'locked'; + throw error; + } + } + + private async launch(initialState: 'starting' | 'recovering'): Promise { + if (await this.probeListener(this.readinessUrl)) { + this.state = 'unhealthy'; + throw new SupervisionError( + 'foreign_listener', + `Refusing to start the app server: ${this.readinessUrl.origin} already has a listener not owned by verifyd`, + false + ); + } + + this.state = initialState; + const [executable, ...args] = this.config.command; + let child: OwnedChildProcess; + try { + child = this.spawnProcess(executable, args, { + cwd: this.targetCwd, + env: buildServerEnvironment(this.config.allowedEnv, this.sourceEnvironment), + shell: false, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + this.state = 'unhealthy'; + throw new SupervisionError( + 'launch_failed', + `Could not launch configured app server command ${JSON.stringify(executable)}`, + true, + error + ); + } + + if (child.pid === undefined || child.pid < 1) { + this.state = 'unhealthy'; + throw new SupervisionError('launch_failed', 'App server did not expose a valid PID', true); + } + + this.generation += 1; + const identity = createStartIdentity(child.pid, this.generation, this.createIdentity()); + this.child = child; + this.ownedIdentity = identity; + this.attachChild(child, identity); + + try { + await this.waitUntilSettled(child, identity); + if (this.child !== child || this.ownedIdentity !== identity) { + throw new SupervisionError( + 'child_exited', + 'App server ownership changed during startup', + true + ); + } + this.state = 'ready'; + } catch (error) { + await this.stop(); + if (error instanceof SupervisionError) { + throw error; + } + throw new SupervisionError( + 'readiness_timeout', + `App server did not remain ready for ${this.config.hmrSettleMs}ms`, + true, + error + ); + } + } + + private attachChild(child: OwnedChildProcess, identity: string): void { + child.stdout?.on('data', (chunk) => this.logs.append('stdout', chunk)); + child.stderr?.on('data', (chunk) => this.logs.append('stderr', chunk)); + child.on('error', (error) => this.logs.append('stderr', `${error.message}\n`)); + child.once('exit', (code, signal) => { + if (this.child !== child || this.ownedIdentity !== identity) { + return; + } + this.lastExit = { code, signal, at: new Date(this.clock.now()).toISOString() }; + this.child = null; + this.ownedIdentity = null; + if (this.intentionallyStopping) { + this.state = 'stopped'; + } else if (this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.state = 'locked'; + } else { + this.state = 'unhealthy'; + } + }); + } + + private async waitUntilSettled(child: OwnedChildProcess, identity: string): Promise { + const deadline = this.clock.now() + this.startupTimeoutMs; + let readySince: number | null = null; + while (this.clock.now() <= deadline) { + if (this.child !== child || this.ownedIdentity !== identity) { + throw new SupervisionError( + 'child_exited', + `App server exited before readiness (${this.describeLastExit()})`, + true + ); + } + const ready = await this.probeReadiness(this.readinessUrl); + const now = this.clock.now(); + if (ready) { + readySince ??= now; + if (now - readySince >= this.config.hmrSettleMs) { + return; + } + } else { + readySince = null; + } + await this.clock.sleep(this.readinessPollMs); + } + throw new SupervisionError( + 'readiness_timeout', + `App server readiness did not settle within ${this.startupTimeoutMs}ms`, + true + ); + } + + private describeLastExit(): string { + if (this.lastExit === null) { + return 'exit details unavailable'; + } + return `code=${this.lastExit.code ?? 'null'}, signal=${this.lastExit.signal ?? 'null'}`; + } + + private sendOwnedSignal( + child: OwnedChildProcess, + identity: string, + pid: number, + signal: NodeJS.Signals + ): void { + if (this.child !== child || this.ownedIdentity !== identity || child.pid !== pid) { + throw new SupervisionError( + 'invalid_target', + 'Refusing to signal a process whose ownership identity changed', + false + ); + } + try { + this.signalProcessGroup(pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + } + } + + private waitForExit(child: OwnedChildProcess, milliseconds: number): Promise { + if (child.exitCode !== null || child.signalCode !== null || this.child !== child) { + return Promise.resolve(true); + } + return new Promise((resolveWait) => { + let complete = false; + const finish = (exited: boolean) => { + if (!complete) { + complete = true; + resolveWait(exited); + } + }; + child.once('exit', () => finish(true)); + setTimeout(() => finish(false), milliseconds).unref(); + }); + } + + private clearOwnedChild( + child: OwnedChildProcess, + identity: string, + state: SupervisionState + ): void { + if (this.child === child && this.ownedIdentity === identity) { + this.child = null; + this.ownedIdentity = null; + } + this.state = state; + } +} + +export interface WarmBrowser { + version(): string; + isConnected(): boolean; + newContext(...args: Parameters): ReturnType; + on(event: 'disconnected', listener: () => void): unknown; + close(): Promise; +} + +export type LaunchBrowser = () => Promise; + +export interface BrowserSupervisionHealth { + state: SupervisionState; + owned: boolean; + connected: boolean; + generation: number; + recoveryAttempts: number; + revision: string; + version: string; + lastDisconnectedAt: string | null; +} + +export interface BrowserSupervisorDependencies { + launchBrowser?: LaunchBrowser; + executablePath?: () => string; + clock?: Clock; +} + +export interface BrowserSupervisorOptions { + maxRecoveryAttempts?: number; +} + +async function launchPinnedChromium(): Promise { + return chromium.launch(); +} + +export function chromiumRevisionFromExecutablePath(executablePath: string): string { + return executablePath.match(/(?:chromium|chrome-headless-shell)-(\d+)/)?.[1] ?? 'unknown'; +} + +export class WarmChromiumSupervisor { + private readonly launchBrowser: LaunchBrowser; + private readonly executablePath: () => string; + private readonly clock: Clock; + private readonly maxRecoveryAttempts: number; + + private state: SupervisionState = 'stopped'; + private browser: WarmBrowser | null = null; + private generation = 0; + private recoveryAttempts = 0; + private lastDisconnectedAt: string | null = null; + private intentionallyStopping = false; + private everStarted = false; + private transitionInFlight: Promise | null = null; + + constructor( + dependencies: BrowserSupervisorDependencies = {}, + options: BrowserSupervisorOptions = {} + ) { + this.launchBrowser = dependencies.launchBrowser ?? launchPinnedChromium; + this.executablePath = dependencies.executablePath ?? (() => chromium.executablePath()); + this.clock = dependencies.clock ?? systemClock; + this.maxRecoveryAttempts = options.maxRecoveryAttempts ?? DEFAULT_RECOVERY_ATTEMPTS; + if ( + !Number.isSafeInteger(this.maxRecoveryAttempts) || + this.maxRecoveryAttempts < 0 || + this.maxRecoveryAttempts > 1 + ) { + throw new RangeError('maxRecoveryAttempts must be either zero or one'); + } + } + + health(): BrowserSupervisionHealth { + const connected = this.browser?.isConnected() ?? false; + return { + state: this.state, + owned: this.browser !== null, + connected, + generation: this.generation, + recoveryAttempts: this.recoveryAttempts, + revision: chromiumRevisionFromExecutablePath(this.executablePath()), + version: connected ? (this.browser?.version() ?? 'unknown') : 'unknown', + lastDisconnectedAt: this.lastDisconnectedAt, + }; + } + + async start(): Promise { + return this.runTransition(() => this.startTransition()); + } + + async ensureReady(): Promise { + return this.runTransition(() => this.ensureReadyTransition()); + } + + private async startTransition(): Promise { + if (this.state === 'ready' && this.browser?.isConnected()) { + return this.health(); + } + if (this.state === 'locked') { + throw recoveryLocked('Chromium'); + } + if (this.everStarted && this.state !== 'stopped') { + return this.recover(); + } + await this.launch('starting'); + this.everStarted = true; + return this.health(); + } + + private async ensureReadyTransition(): Promise { + if (this.state === 'ready' && this.browser?.isConnected()) { + return this.health(); + } + if (!this.everStarted && this.state === 'stopped') { + return this.startTransition(); + } + return this.recover(); + } + + private runTransition( + operation: () => Promise + ): Promise { + if (this.transitionInFlight !== null) { + return this.transitionInFlight; + } + const transition = operation().finally(() => { + if (this.transitionInFlight === transition) { + this.transitionInFlight = null; + } + }); + this.transitionInFlight = transition; + return transition; + } + + async restart(): Promise { + await this.stop(); + this.recoveryAttempts = 0; + this.state = 'stopped'; + this.everStarted = false; + return this.start(); + } + + async stop(): Promise { + const browser = this.browser; + if (browser === null) { + this.state = 'stopped'; + return; + } + this.intentionallyStopping = true; + try { + await browser.close(); + } finally { + if (this.browser === browser) { + this.browser = null; + } + this.state = 'stopped'; + this.intentionallyStopping = false; + } + } + + currentBrowser(): WarmBrowser { + if (this.state !== 'ready' || this.browser === null || !this.browser.isConnected()) { + throw new SupervisionError( + 'browser_unavailable', + 'Warm Chromium is not connected; call ensureReady before creating a context', + true + ); + } + return this.browser; + } + + private async recover(): Promise { + if (this.state === 'locked' || this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.state = 'locked'; + throw recoveryLocked('Chromium'); + } + this.recoveryAttempts += 1; + try { + await this.launch('recovering'); + return this.health(); + } catch (error) { + this.state = 'locked'; + throw error; + } + } + + private async launch(initialState: 'starting' | 'recovering'): Promise { + this.state = initialState; + let browser: WarmBrowser; + try { + browser = await this.launchBrowser(); + } catch (error) { + this.state = 'unhealthy'; + throw new SupervisionError( + 'browser_unavailable', + 'Could not launch the lockfile-pinned Playwright Chromium', + true, + error + ); + } + if (!browser.isConnected()) { + await browser.close().catch(() => undefined); + this.state = 'unhealthy'; + throw new SupervisionError( + 'browser_unavailable', + 'Playwright returned a disconnected Chromium instance', + true + ); + } + if (typeof browser.newContext !== 'function') { + await browser.close().catch(() => undefined); + this.state = 'unhealthy'; + throw new SupervisionError( + 'browser_unavailable', + 'Playwright Chromium does not expose isolated browser contexts', + false + ); + } + + this.browser = browser; + this.generation += 1; + const generation = this.generation; + browser.on('disconnected', () => { + if (this.browser !== browser || this.generation !== generation) { + return; + } + this.lastDisconnectedAt = new Date(this.clock.now()).toISOString(); + this.browser = null; + if (this.intentionallyStopping) { + this.state = 'stopped'; + } else if (this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.state = 'locked'; + } else { + this.state = 'unhealthy'; + } + }); + this.state = 'ready'; + } +} + +export interface WarmRuntimeHealth { + warm: boolean; + generation: number; + server: ServerSupervisionHealth; + browser: BrowserSupervisionHealth; +} + +export class WarmRuntimeSupervisor { + private generation = 0; + + constructor( + readonly server: AppServerSupervisor, + readonly browser: WarmChromiumSupervisor + ) {} + + health(): WarmRuntimeHealth { + const server = this.server.health(); + const browser = this.browser.health(); + return { + warm: server.state === 'ready' && browser.state === 'ready' && browser.connected, + generation: this.generation, + server, + browser, + }; + } + + async start(): Promise { + const outcomes = await Promise.allSettled([this.server.start(), this.browser.start()]); + const failure = outcomes.find( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + ); + if (failure) { + await Promise.allSettled([this.browser.stop(), this.server.stop()]); + throw failure.reason; + } + this.generation += 1; + return this.health(); + } + + async ensureReady(): Promise { + await Promise.all([this.server.ensureReady(), this.browser.ensureReady()]); + const health = this.health(); + if (!health.warm) { + throw new SupervisionError( + 'launch_failed', + 'App server and Chromium did not reach a warm state', + true + ); + } + return health; + } + + async stop(): Promise { + await this.browser.stop(); + await this.server.stop(); + } +} + +function recoveryLocked(name: string): SupervisionError { + return new SupervisionError( + 'recovery_locked', + `${name} exhausted its bounded recovery attempt; explicitly restart verifyd before retrying`, + false + ); +} From 2e47c12ef1879b088269bf1e1dd99c40fbc5e18e Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:25:10 +0530 Subject: [PATCH 007/141] feat(verify): observe and redact browser regressions --- apps/desktop/package.json | 1 + .../lib/warm-verification/observer.test.ts | 236 +++++++++ .../src/lib/warm-verification/observer.ts | 449 ++++++++++++++++++ .../lib/warm-verification/redaction.test.ts | 87 ++++ .../src/lib/warm-verification/redaction.ts | 64 +++ pnpm-lock.yaml | 19 + 6 files changed, 856 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/observer.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/observer.ts create mode 100644 apps/desktop/src/lib/warm-verification/redaction.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/redaction.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9f3c117..933a40b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -52,6 +52,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@axe-core/playwright": "4.12.1", "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2.2.0", "@types/node": "^22.19.17", diff --git a/apps/desktop/src/lib/warm-verification/observer.test.ts b/apps/desktop/src/lib/warm-verification/observer.test.ts new file mode 100644 index 0000000..1da710c --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/observer.test.ts @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { chromium, type Browser, type Page } from '@playwright/test'; +import { AutomaticObserver } from './observer'; + +let browser: Browser; + +before(async () => { + browser = await chromium.launch({ headless: true }); +}); + +after(async () => { + await browser.close(); +}); + +async function pageWithObserver( + slowInteractionMs = 1_000 +): Promise<{ page: Page; observer: AutomaticObserver }> { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.route('http://app.local/**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === '/api/network-failure') { + await route.abort('connectionfailed'); + return; + } + if (url.pathname === '/') { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: '
Investment scheduled
', + }); + } else if (url.pathname === '/portfolio') { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: '', + }); + } else if (url.pathname === '/login') { + await route.fulfill({ status: 200, contentType: 'text/html', body: 'Sign in' }); + } else if (url.pathname === '/api/failure') { + await route.fulfill({ status: 500, body: 'failure' }); + } else { + await route.fulfill({ status: 200, body: '{}' }); + } + }); + const observer = new AutomaticObserver({ + scenarioId: 'portfolio-create', + firstPartyOrigins: ['http://app.local'], + allowedFirstPartyRequests: ['GET /**', 'POST /api/create'], + slowInteractionMs, + now: () => new Date('2026-07-15T10:00:00.000Z'), + }); + observer.attach(page); + await page.goto('http://app.local/'); + return { page, observer }; +} + +describe('AutomaticObserver', () => { + it('detects a failed request from the browser network event even when fetch catches it', async () => { + const { page, observer } = await pageWithObserver(); + await page.evaluate(() => fetch('/api/network-failure').catch(() => undefined)); + + const result = observer.finish(); + assert.equal(result.hasRegression, true); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'request_failed' && + entry.policy_id === 'network.no-failed-requests' && + entry.message.includes('/api/network-failure') + ) + ); + await page.context().close(); + }); + + it('detects an unexpected first-party 5xx response', async () => { + const { page, observer } = await pageWithObserver(); + await page.evaluate(() => fetch('/api/failure')); + + const result = observer.finish(); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'http_failure' && + entry.disposition === 'regression' && + entry.evidence?.status === 500 + ) + ); + await page.context().close(); + }); + + it('detects a first-party request outside the explicit method and path allowlist', async () => { + const { page, observer } = await pageWithObserver(); + await page.evaluate(() => fetch('/api/unexpected', { method: 'POST' })); + + const result = observer.finish(); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'unexpected_request' && + entry.disposition === 'regression' && + entry.evidence?.method === 'POST' && + entry.evidence?.normalized_url === '/api/unexpected' + ) + ); + await page.context().close(); + }); + + it('detects an uncaught page exception independently of console text', async () => { + const { page, observer } = await pageWithObserver(); + await page.evaluate(() => setTimeout(() => Promise.reject(new Error('uncaught fixture')), 0)); + await page.waitForTimeout(20); + + const result = observer.finish(); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'page_error' && + entry.policy_id === 'runtime.no-uncaught-exceptions' && + entry.message.includes('uncaught fixture') + ) + ); + await page.context().close(); + }); + + it('detects equivalent duplicate mutations without retaining request bodies', async () => { + const { page, observer } = await pageWithObserver(); + await page.evaluate(async () => { + const options = { method: 'POST', body: JSON.stringify({ amount: 500 }) }; + await fetch('/api/create', options); + await fetch('/api/create', options); + }); + + await assert.rejects(observer.expectMutationCount('/api/create', 1), /observed 2/); + const result = observer.finish(); + assert.equal(JSON.stringify(result).includes('"amount":500'), false); + const duplicate = result.observations.find((entry) => entry.kind === 'duplicate_mutation'); + assert.equal(duplicate?.evidence?.count, 2); + assert.match(String(duplicate?.evidence?.body_hash ?? ''), /^[a-f0-9]{64}$/); + await page.context().close(); + }); + + it('never retains cookies, authorization headers, query secrets, or secret-like console text', async () => { + const { page, observer } = await pageWithObserver(); + const secret = 'sk-fixture-observer-secret'; + await page.context().addCookies([{ name: 'session', value: secret, url: 'http://app.local' }]); + await page.evaluate(async (credential) => { + console.error(`Authorization: Bearer ${credential}`); + await fetch(`/api/create?access_token=${credential}`, { + method: 'POST', + headers: { Authorization: `Bearer ${credential}` }, + body: JSON.stringify({ password: credential, payload: 'x'.repeat(10_000) }), + }); + }, secret); + + const serialized = JSON.stringify(observer.finish()); + assert.equal(serialized.includes(secret), false); + assert.equal(serialized.includes('x'.repeat(1_000)), false); + assert.match(serialized, /REDACTED/); + await page.context().close(); + }); + + it('records routes and bounded interaction timings on a clean flow', async () => { + const { page, observer } = await pageWithObserver(); + await observer.step('create', () => page.locator('#create').click()); + await observer.expectVisible('Investment scheduled'); + await observer.expectRoute('/'); + + const result = observer.finish(); + assert.equal(result.hasRegression, false, JSON.stringify(result.observations, null, 2)); + assert.deepEqual(result.routes, ['/']); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'interaction_timing' && + entry.evidence?.action_id === 'create' && + entry.disposition === 'passed' + ) + ); + await page.context().close(); + }); + + it('detects an authentication redirect as an expected-route regression', async () => { + const { page, observer } = await pageWithObserver(); + await page.goto('http://app.local/portfolio'); + + await assert.rejects(observer.expectRoute('/portfolio'), /observed \/login/); + const result = observer.finish(); + assert.deepEqual(result.routes, ['/', '/portfolio', '/login']); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'route' && + entry.policy_id === 'navigation.expected-route' && + entry.disposition === 'regression' && + entry.evidence?.actual_route === '/login' + ) + ); + await page.context().close(); + }); + + it('detects an interaction that exceeds its configured local budget', async () => { + const { page, observer } = await pageWithObserver(1); + await observer.step('slow-create', () => page.waitForTimeout(10)); + + const result = observer.finish(); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'interaction_timing' && + entry.policy_id === 'performance.interaction-budget' && + entry.disposition === 'regression' + ) + ); + await page.context().close(); + }); + + it('uses the full axe rules engine and blocks serious accessibility violations', async () => { + const { page, observer } = await pageWithObserver(); + await page.setContent('
'); + + await observer.auditAccessibility('broken-button'); + const result = observer.finish(); + assert.ok( + result.observations.some( + (entry) => + entry.kind === 'accessibility_audit' && + entry.policy_id === 'accessibility.axe.button-name' && + entry.disposition === 'regression' && + entry.evidence?.checkpoint === 'broken-button' + ) + ); + await page.context().close(); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/observer.ts b/apps/desktop/src/lib/warm-verification/observer.ts new file mode 100644 index 0000000..e0399ec --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/observer.ts @@ -0,0 +1,449 @@ +import { createHash } from 'node:crypto'; +import AxeBuilder from '@axe-core/playwright'; +import type { ConsoleMessage, Page, Request, Response } from '@playwright/test'; +import type { VerifyObservation, VerifyObservationDisposition } from './contracts'; +import { isSensitiveEvidenceKey, redactEvidenceText } from './redaction'; +import type { ScenarioObserve } from './scenario'; +import { matchesPathGlob } from './selection'; + +export interface AutomaticObserverOptions { + scenarioId: string; + firstPartyOrigins: readonly string[]; + allowedFirstPartyRequests: readonly string[]; + slowInteractionMs: number; + now?: () => Date; +} + +export interface MutationLedgerEntry { + method: string; + normalizedUrl: string; + bodyHash: string; + count: number; +} + +export interface AutomaticObserverResult { + observations: VerifyObservation[]; + routes: string[]; + hasRegression: boolean; + hasNoConfidence: boolean; +} + +interface MutationExpectation { + pathPattern: string; + expected: number; +} + +const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const BLOCKING_ACCESSIBILITY_IMPACTS = new Set(['serious', 'critical']); +const MAX_ACCESSIBILITY_VIOLATIONS = 100; + +export class AutomaticObserver implements ScenarioObserve { + readonly #options: AutomaticObserverOptions; + readonly #observations: VerifyObservation[] = []; + readonly #mutations = new Map(); + readonly #routes: string[] = []; + readonly #mutationExpectations: MutationExpectation[] = []; + readonly #blockedThirdPartyUrls = new Set(); + readonly #accessibilityCheckpoints = new Set(); + #page: Page | undefined; + #nextObservation = 1; + #detachers: Array<() => void> = []; + + constructor(options: AutomaticObserverOptions) { + this.#options = options; + } + + attach(page: Page): void { + if (this.#page) throw new Error('Automatic observer is already attached'); + this.#page = page; + + const onPageError = (error: Error) => { + this.#record('page_error', 'regression', 'runtime.no-uncaught-exceptions', error.message); + }; + const onConsole = (message: ConsoleMessage) => this.#onConsole(message); + const onRequest = (request: Request) => this.#onRequest(request); + const onRequestFailed = (request: Request) => this.#onRequestFailed(request); + const onResponse = (response: Response) => this.#onResponse(response); + const onFrameNavigated = (frame: ReturnType) => { + if (frame === page.mainFrame()) this.#recordRoute(frame.url()); + }; + + page.on('pageerror', onPageError); + page.on('console', onConsole); + page.on('request', onRequest); + page.on('requestfailed', onRequestFailed); + page.on('response', onResponse); + page.on('framenavigated', onFrameNavigated); + this.#detachers = [ + () => page.off('pageerror', onPageError), + () => page.off('console', onConsole), + () => page.off('request', onRequest), + () => page.off('requestfailed', onRequestFailed), + () => page.off('response', onResponse), + () => page.off('framenavigated', onFrameNavigated), + ]; + } + + detach(): void { + for (const detach of this.#detachers.splice(0)) detach(); + this.#page = undefined; + } + + noteBlockedThirdParty(url: string): void { + this.#blockedThirdPartyUrls.add(url); + this.#record( + 'request_failed', + 'informational', + 'network.block-third-party', + `Blocked configured third-party request to ${safeUrlLabel(url)}` + ); + } + + async step(actionId: string, operation: () => Promise): Promise { + const started = performance.now(); + try { + return await operation(); + } finally { + const durationMs = performance.now() - started; + const slow = durationMs > this.#options.slowInteractionMs; + this.#record( + 'interaction_timing', + slow ? 'regression' : 'passed', + 'performance.interaction-budget', + `${actionId} completed in ${durationMs.toFixed(1)} ms`, + { action_id: actionId, duration_ms: roundDuration(durationMs) } + ); + } + } + + async expectNoRuntimeErrors(): Promise { + const errors = this.#observations.filter( + (entry) => + (entry.kind === 'page_error' || entry.kind === 'console_error') && + entry.disposition === 'regression' + ); + this.#record( + 'page_error', + errors.length === 0 ? 'passed' : 'regression', + 'runtime.no-errors-assertion', + errors.length === 0 + ? 'No runtime errors observed' + : `${errors.length} runtime error(s) observed`, + { error_count: errors.length } + ); + if (errors.length > 0) throw new Error(`${errors.length} runtime error(s) observed`); + } + + async expectMutationCount(routePattern: string, expected: number): Promise { + if (!Number.isSafeInteger(expected) || expected < 0) { + throw new Error('Expected mutation count must be a non-negative safe integer'); + } + this.#mutationExpectations.push({ pathPattern: routePattern, expected }); + const actual = this.#mutationCount(routePattern); + const disposition = actual === expected ? 'passed' : 'regression'; + this.#record( + 'mutation', + disposition, + 'network.expected-mutation-count', + `Expected ${expected} mutation(s) for ${routePattern}; observed ${actual}`, + { route_pattern: routePattern, expected, actual } + ); + if (disposition === 'regression') { + throw new Error(`Expected ${expected} mutation(s) for ${routePattern}; observed ${actual}`); + } + } + + async expectVisible(name: string): Promise { + const page = this.#requirePage(); + await page.getByText(name, { exact: false }).first().waitFor({ state: 'visible' }); + this.#record('route', 'passed', 'ui.expected-visible', `${JSON.stringify(name)} is visible`, { + name, + }); + } + + async expectRoute(route: string): Promise { + const page = this.#requirePage(); + const actual = new URL(page.url()).pathname; + const disposition = actual === route ? 'passed' : 'regression'; + this.#record( + 'route', + disposition, + 'navigation.expected-route', + `Expected route ${route}; observed ${actual}`, + { expected_route: route, actual_route: actual } + ); + if (disposition === 'regression') + throw new Error(`Expected route ${route}; observed ${actual}`); + } + + async checkpoint(name: string): Promise { + this.#record( + 'screenshot', + 'informational', + 'visual.checkpoint-declared', + `Checkpoint ${name}`, + { + checkpoint: name, + } + ); + await this.auditAccessibility(name); + } + + async auditAccessibility(checkpoint = 'final'): Promise { + if (this.#accessibilityCheckpoints.has(checkpoint)) return; + this.#accessibilityCheckpoints.add(checkpoint); + const page = this.#requirePage(); + let results: Awaited>; + try { + results = await new AxeBuilder({ page }).analyze(); + } catch (error) { + this.#record( + 'accessibility_audit', + 'no_confidence', + 'accessibility.axe-unavailable', + `Accessibility audit could not run: ${error instanceof Error ? error.message : String(error)}`, + { checkpoint } + ); + return; + } + + const violations = results.violations.slice(0, MAX_ACCESSIBILITY_VIOLATIONS); + for (const violation of violations) { + const blocking = BLOCKING_ACCESSIBILITY_IMPACTS.has(violation.impact ?? ''); + this.#record( + 'accessibility_audit', + blocking ? 'regression' : 'informational', + `accessibility.axe.${violation.id}`, + `${violation.help} (${violation.impact ?? 'unknown'} impact)`, + { + checkpoint, + rule_id: violation.id, + impact: violation.impact ?? 'unknown', + affected_nodes: violation.nodes.length, + first_target: violation.nodes[0]?.target.join(' ') ?? '', + } + ); + } + if (results.violations.length > MAX_ACCESSIBILITY_VIOLATIONS) { + this.#record( + 'accessibility_audit', + 'no_confidence', + 'accessibility.axe-result-limit', + `Accessibility audit returned ${results.violations.length} violations; only ${MAX_ACCESSIBILITY_VIOLATIONS} were retained`, + { checkpoint, total_violations: results.violations.length } + ); + } else if (violations.length === 0) { + this.#record( + 'accessibility_audit', + 'passed', + 'accessibility.axe-clean', + 'Accessibility audit found no violations', + { checkpoint } + ); + } + } + + finish(): AutomaticObserverResult { + for (const mutation of this.#mutations.values()) { + if (mutation.count < 2) continue; + const permitted = this.#mutationExpectations.some( + (expectation) => + expectation.expected >= mutation.count && + matchesRequestPath(expectation.pathPattern, mutation.normalizedUrl) + ); + if (!permitted) { + this.#record( + 'duplicate_mutation', + 'regression', + 'network.no-duplicate-mutations', + `${mutation.method} ${mutation.normalizedUrl} repeated ${mutation.count} times with the same body`, + { + method: mutation.method, + normalized_url: mutation.normalizedUrl, + body_hash: mutation.bodyHash, + count: mutation.count, + } + ); + } + } + this.detach(); + return { + observations: [...this.#observations], + routes: [...this.#routes], + hasRegression: this.#observations.some((entry) => entry.disposition === 'regression'), + hasNoConfidence: this.#observations.some((entry) => entry.disposition === 'no_confidence'), + }; + } + + #onConsole(message: ConsoleMessage): void { + if (message.type() !== 'error') return; + const text = message.text(); + if (text.includes('net::ERR_BLOCKED_BY_CLIENT') && this.#blockedThirdPartyUrls.size > 0) { + this.#record('console_error', 'informational', 'network.block-third-party-console', text, { + blocked_request_count: this.#blockedThirdPartyUrls.size, + }); + return; + } + this.#record('console_error', 'regression', 'console.no-errors', text); + } + + #onRequest(request: Request): void { + const method = request.method().toUpperCase(); + const normalizedUrl = normalizedRequestUrl(request.url()); + if (MUTATION_METHODS.has(method)) { + const bodyHash = createHash('sha256') + .update(request.postData() ?? '') + .digest('hex'); + const key = `${method}\0${normalizedUrl}\0${bodyHash}`; + const current = this.#mutations.get(key); + if (current) current.count += 1; + else this.#mutations.set(key, { method, normalizedUrl, bodyHash, count: 1 }); + } + + if (!this.#isFirstParty(request.url())) return; + if (!this.#isAllowedFirstParty(method, normalizedUrl)) { + this.#record( + 'unexpected_request', + 'regression', + 'network.first-party-allowlist', + `Unexpected first-party request: ${method} ${normalizedUrl}`, + { method, normalized_url: normalizedUrl } + ); + } + } + + #onRequestFailed(request: Request): void { + if (this.#blockedThirdPartyUrls.has(request.url())) return; + const method = request.method().toUpperCase(); + const normalizedUrl = normalizedRequestUrl(request.url()); + this.#record( + 'request_failed', + 'regression', + 'network.no-failed-requests', + `${method} ${normalizedUrl} failed: ${request.failure()?.errorText ?? 'unknown failure'}`, + { method, normalized_url: normalizedUrl } + ); + } + + #onResponse(response: Response): void { + const status = response.status(); + if (status < 400 || !this.#isFirstParty(response.url())) return; + const method = response.request().method().toUpperCase(); + const normalizedUrl = normalizedRequestUrl(response.url()); + this.#record( + 'http_failure', + 'regression', + 'network.no-unexpected-http-failures', + `${method} ${normalizedUrl} returned ${status}`, + { method, normalized_url: normalizedUrl, status } + ); + } + + #recordRoute(rawUrl: string): void { + const route = normalizedRequestUrl(rawUrl); + if (this.#routes.at(-1) === route) return; + this.#routes.push(route); + this.#record('route', 'informational', 'navigation.route-ledger', `Route changed to ${route}`, { + route, + }); + } + + #mutationCount(routePattern: string): number { + return [...this.#mutations.values()] + .filter((entry) => matchesRequestPath(routePattern, entry.normalizedUrl)) + .reduce((total, entry) => total + entry.count, 0); + } + + #isFirstParty(rawUrl: string): boolean { + try { + return this.#options.firstPartyOrigins.includes(new URL(rawUrl).origin); + } catch { + return false; + } + } + + #isAllowedFirstParty(method: string, normalizedUrl: string): boolean { + return this.#options.allowedFirstPartyRequests.some((rule) => { + const separator = rule.indexOf(' '); + return ( + separator > 0 && + rule.slice(0, separator) === method && + matchesRequestPath(rule.slice(separator + 1), normalizedUrl) + ); + }); + } + + #record( + kind: VerifyObservation['kind'], + disposition: VerifyObservationDisposition, + policyId: string, + message: string, + evidence?: Record + ): void { + this.#observations.push({ + id: `observation-${this.#nextObservation++}`, + scenario_id: this.#options.scenarioId, + kind, + disposition, + policy_id: policyId, + message: redactEvidenceText(message), + occurred_at: (this.#options.now?.() ?? new Date()).toISOString(), + ...(evidence + ? { + evidence: Object.fromEntries( + Object.entries(evidence).map(([key, value]) => [ + key, + typeof value === 'string' ? redactEvidenceText(value) : value, + ]) + ), + } + : {}), + }); + } + + #requirePage(): Page { + if (!this.#page) throw new Error('Automatic observer is not attached to a page'); + return this.#page; + } +} + +function normalizedRequestUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + const search = [...url.searchParams.entries()] + .sort(([leftKey, leftValue], [rightKey, rightValue]) => + `${leftKey}\0${leftValue}`.localeCompare(`${rightKey}\0${rightValue}`) + ) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(isSensitiveEvidenceKey(key) ? '[REDACTED]' : value)}` + ) + .join('&'); + return `${url.pathname}${search ? `?${search}` : ''}`; + } catch { + return '/invalid-url'; + } +} + +function matchesRequestPath(pattern: string, normalizedUrl: string): boolean { + const pathOnly = normalizedUrl.split('?')[0] ?? normalizedUrl; + const normalizedPattern = pattern.startsWith('/') ? pattern.slice(1) : pattern; + const normalizedPath = pathOnly.startsWith('/') ? pathOnly.slice(1) : pathOnly; + if (normalizedPath === '') { + return normalizedPattern === '' || normalizedPattern === '*' || normalizedPattern === '**'; + } + return matchesPathGlob(normalizedPattern || '**', normalizedPath); +} + +function safeUrlLabel(rawUrl: string): string { + try { + const url = new URL(rawUrl); + return `${url.origin}${url.pathname}`; + } catch { + return 'invalid URL'; + } +} + +function roundDuration(value: number): number { + return Math.round(value * 1_000) / 1_000; +} diff --git a/apps/desktop/src/lib/warm-verification/redaction.test.ts b/apps/desktop/src/lib/warm-verification/redaction.test.ts new file mode 100644 index 0000000..24fb64b --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/redaction.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { VerifyResult } from './contracts'; +import { redactEvidenceText, redactVerifyResult } from './redaction'; + +describe('warm verification evidence redaction', () => { + it('redacts common credentials and bounds arbitrary evidence text', () => { + const secret = 'sk-fixture-super-secret-token'; + const bearer = 'plaincredentialwithoutprefix12345'; + const text = redactEvidenceText( + `Authorization: Bearer ${bearer} https://app.local/?access_token=${secret} ` + + `{"password":"${secret}"} ${'x'.repeat(3_000)}` + ); + + assert.equal(text.includes(secret), false); + assert.equal(text.includes(bearer), false); + assert.match(text, /\[REDACTED\]/); + assert.ok(text.length <= 2_000); + }); + + it('sanitizes every free-text result boundary without changing exact identities', () => { + const secret = 'sk-fixture-result-secret'; + const result = redactVerifyResult({ + schema_version: 1, + protocol_version: 1, + run_id: 'run-1', + outcome: 'no_confidence', + started_at: '2026-07-15T10:00:00.000Z', + finished_at: '2026-07-15T10:00:01.000Z', + warm: true, + stale: false, + model_call_count: 0, + source: { + target_sha: 'a'.repeat(40), + change_set_kind: 'worktree', + change_set_identity: 'b'.repeat(64), + config_hash: 'c'.repeat(64), + manifest_hash: 'd'.repeat(64), + source_hash_before: 'e'.repeat(64), + source_hash_after: 'e'.repeat(64), + }, + observation_policy: { schema_version: 1, profile_id: 'strict-default-v1' }, + selection: { + changed_paths: ['src/app.ts'], + selected_scenario_ids: [], + mandatory_smoke_ids: [], + fallback_scenario_ids: [], + complete: false, + explanation: `token=${secret}`, + }, + scenarios: [], + timings: [], + observations: [ + { + id: 'observation-1', + scenario_id: 'scenario-1', + kind: 'console_error', + disposition: 'no_confidence', + policy_id: 'console.no-errors', + message: `cookie=${secret}`, + occurred_at: '2026-07-15T10:00:00.000Z', + evidence: { authorization: `Bearer ${secret}` }, + }, + ], + limitations: [ + { + code: 'other', + message: `password=${secret}`, + affects_confidence: true, + remediation: `api_key=${secret}`, + }, + ], + artifacts: [], + cancellation: { + state: 'completed', + requested_at: '2026-07-15T10:00:00.000Z', + completed_at: '2026-07-15T10:00:01.000Z', + reason: `session=${secret}`, + }, + } satisfies VerifyResult); + + assert.equal(JSON.stringify(result).includes(secret), false); + assert.equal(result.source.target_sha, 'a'.repeat(40)); + assert.equal(result.selection.changed_paths[0], 'src/app.ts'); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/redaction.ts b/apps/desktop/src/lib/warm-verification/redaction.ts new file mode 100644 index 0000000..c894607 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/redaction.ts @@ -0,0 +1,64 @@ +import type { VerifyResult } from './contracts'; + +const MAX_EVIDENCE_TEXT_LENGTH = 2_000; +const SENSITIVE_KEY = + '(?:access[_-]?token|api[_-]?key|authorization|cookie|password|secret|session|token)'; +const SENSITIVE_KEY_PATTERN = new RegExp(`^${SENSITIVE_KEY}$`, 'i'); + +export function isSensitiveEvidenceKey(value: string): boolean { + return SENSITIVE_KEY_PATTERN.test(value); +} + +export function redactEvidenceText(value: string): string { + const redacted = value + .replace(new RegExp(`([?&]${SENSITIVE_KEY}=)[^&#\\s]*`, 'gi'), '$1[REDACTED]') + .replace( + new RegExp(`(["']${SENSITIVE_KEY}["']\\s*:\\s*["'])[^"']*(["'])`, 'gi'), + '$1[REDACTED]$2' + ) + .replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]{8,}/gi, 'Bearer [REDACTED]') + .replace(new RegExp(`\\b(${SENSITIVE_KEY})\\s*[:=]\\s*[^\\s,;]+`, 'gi'), '$1=[REDACTED]') + .replace(/\b(?:sk|pk)-[a-z0-9_-]{8,}/gi, '[REDACTED]') + .replace(/\b[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\b/gi, '[REDACTED]'); + return redacted.length <= MAX_EVIDENCE_TEXT_LENGTH + ? redacted + : `${redacted.slice(0, MAX_EVIDENCE_TEXT_LENGTH - 3)}...`; +} + +export function redactVerifyResult(result: VerifyResult): VerifyResult { + return { + ...result, + selection: { + ...result.selection, + explanation: redactEvidenceText(result.selection.explanation), + }, + observations: result.observations.map((observation) => ({ + ...observation, + message: redactEvidenceText(observation.message), + ...(observation.evidence + ? { + evidence: Object.fromEntries( + Object.entries(observation.evidence).map(([key, value]) => [ + key, + typeof value === 'string' ? redactEvidenceText(value) : value, + ]) + ), + } + : {}), + })), + limitations: result.limitations.map((limitation) => ({ + ...limitation, + message: redactEvidenceText(limitation.message), + ...(limitation.remediation + ? { remediation: redactEvidenceText(limitation.remediation) } + : {}), + })), + cancellation: + result.cancellation.state === 'not_requested' || !result.cancellation.reason + ? result.cancellation + : { + ...result.cancellation, + reason: redactEvidenceText(result.cancellation.reason), + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b83a40..0fbe62e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: specifier: ^3.5.0 version: 3.5.0 devDependencies: + '@axe-core/playwright': + specifier: 4.12.1 + version: 4.12.1(playwright-core@1.59.1) '@playwright/test': specifier: ^1.58.2 version: 1.59.1 @@ -204,6 +207,11 @@ packages: resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1916,6 +1924,10 @@ packages: peerDependencies: postcss: ^8.1.0 + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3728,6 +3740,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@axe-core/playwright@4.12.1(playwright-core@1.59.1)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.59.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5081,6 +5098,8 @@ snapshots: postcss: 8.5.10 postcss-value-parser: 4.2.0 + axe-core@4.12.1: {} + axobject-query@4.1.0: {} bail@2.0.2: {} From 27cb506ce2e5dfede0341a9885da6ff62d237372 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:25:29 +0530 Subject: [PATCH 008/141] feat(verify): isolate deterministic browser scenarios --- .../src/lib/warm-verification/runner.test.ts | 346 +++++++++++++++++ .../src/lib/warm-verification/runner.ts | 356 ++++++++++++++++++ .../lib/warm-verification/runtime-utils.ts | 26 ++ .../src/lib/warm-verification/state.test.ts | 184 +++++++++ .../src/lib/warm-verification/state.ts | 272 +++++++++++++ 5 files changed, 1184 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/runner.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/runner.ts create mode 100644 apps/desktop/src/lib/warm-verification/runtime-utils.ts create mode 100644 apps/desktop/src/lib/warm-verification/state.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/state.ts diff --git a/apps/desktop/src/lib/warm-verification/runner.test.ts b/apps/desktop/src/lib/warm-verification/runner.test.ts new file mode 100644 index 0000000..091b625 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/runner.test.ts @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict'; +import { createServer, type Server } from 'node:http'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { chromium, type Browser } from '@playwright/test'; +import type { VerifyConfig } from './config'; +import { publishScenarioManifest, type DeterministicScenario } from './scenario'; +import { ScenarioRunner } from './runner'; + +let browser: Browser; +let server: Server; +let baseUrl: string; +let repoRoot: string; + +before(async () => { + browser = await chromium.launch({ headless: true }); + server = createServer((request, response) => { + if (request.url === '/sw.js') { + response.writeHead(200, { 'content-type': 'text/javascript' }); + response.end("self.addEventListener('fetch', () => {});"); + return; + } + if (request.url?.startsWith('/api/create')) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"ok":true}'); + return; + } + response.writeHead(200, { 'content-type': 'text/html' }); + response.end(`Verifier fixture +
Ready
`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('fixture server did not expose a port'); + baseUrl = `http://127.0.0.1:${address.port}`; + repoRoot = await mkdtemp(path.join(os.tmpdir(), 'codevetter-runner-')); + await mkdir(path.join(repoRoot, '.codevetter', 'auth'), { recursive: true }); + await writeFile( + path.join(repoRoot, '.codevetter', 'auth', 'developer.json'), + JSON.stringify({ cookies: [], origins: [] }) + ); +}); + +after(async () => { + await browser.close(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +}); + +function config(): VerifyConfig { + return { + version: 1, + target: { + command: ['fixture'], + cwd: '.', + readinessUrl: `${baseUrl}/health`, + baseUrl, + allowedEnv: [], + hmrSettleMs: 0, + shutdownGraceMs: 1_000, + }, + scenarioModules: ['verify/scenarios.ts'], + authProfiles: { developer: { storageState: '.codevetter/auth/developer.json' } }, + capabilities: [{ id: 'portfolio', paths: ['src/**'], scenarios: ['scenario-1'] }], + mandatorySmoke: ['scenario-1'], + sharedInfrastructure: { paths: ['src/router/**'], fallbackScenarios: ['scenario-1'] }, + network: { + firstPartyOrigins: [baseUrl], + allowedFirstPartyRequests: ['GET /**', 'POST /api/create'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }, + retention: { + directory: '.codevetter/artifacts', + maxRuns: 20, + maxBytes: 104_857_600, + maxAgeDays: 14, + }, + budgets: { + parallelism: 4, + actionMs: 2_000, + scenarioMs: 5_000, + batchMs: 10_000, + slowInteractionMs: 1_000, + }, + }; +} + +function scenario(id: string, run?: DeterministicScenario['run']): DeterministicScenario { + return { + schemaVersion: 1, + id, + capabilityIds: ['portfolio'], + route: `/portfolio?scenario=${id}`, + authProfileId: 'developer', + stateName: 'empty', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: { portfolio: true }, + timeouts: { actionMs: 2_000, scenarioMs: 5_000 }, + actions: [{ id: 'create', kind: 'click', description: 'Create investment' }], + assertions: [{ id: 'visible', kind: 'visible', description: 'Result is visible' }], + run: + run ?? + (async ({ page, observe, step }) => { + await step('create', () => page.locator('#create').click()); + await page.evaluate(async () => { + localStorage.setItem('scenario', window.location.search); + await fetch('/api/create', { method: 'POST', body: '{"amount":500}' }); + document.querySelector('#result')?.replaceChildren('Created'); + }); + await observe.expectVisible('Created'); + await observe.expectMutationCount('/api/create', 1); + }), + }; +} + +function manifest(scenarios: DeterministicScenario[]) { + return publishScenarioManifest({ + generatedAt: '2026-07-15T10:00:00.000Z', + batchTimeoutMs: 10_000, + parallelism: 4, + modules: [{ id: 'fixture-module', source: 'fixture-source', scenarios }], + }); +} + +describe('ScenarioRunner', () => { + it('runs fresh isolated contexts in bounded parallel order', async () => { + const runner = await ScenarioRunner.create(browser, repoRoot, config()); + const scenarios = [1, 2, 3, 4].map((index) => scenario(`scenario-${index}`)); + + const result = await runner.run(manifest(scenarios), { + runId: 'parallel-run', + scenarioIds: scenarios.map((entry) => entry.id).reverse(), + }); + + assert.equal(result.outcome, 'passed'); + assert.ok( + result.scenarios.every((entry) => + entry.timings.some((timing) => timing.stage === 'observation') + ) + ); + assert.deepEqual( + result.scenarios.map((entry) => entry.scenario_id), + ['scenario-1', 'scenario-2', 'scenario-3', 'scenario-4'] + ); + assert.equal(browser.contexts().length, 0); + }); + + it('isolates mutable browser state across serial runs and four parallel scenarios', async () => { + const isolated = (id: string) => + scenario(id, async ({ page, observe }) => { + const initial = await page.evaluate( + async ({ expectedId, expectedPath, frozenTime }) => ({ + cookie: document.cookie, + stored: localStorage.getItem('verification-owner'), + serviceWorkers: (await navigator.serviceWorker.getRegistrations()).length, + flag: ( + window as typeof window & { + __CODEVETTER_VERIFY__?: { flags: Record }; + } + ).__CODEVETTER_VERIFY__?.flags.portfolio, + now: Date.now(), + path: window.location.pathname, + expectedId, + expectedPath, + frozenTime, + }), + { + expectedId: id, + expectedPath: '/portfolio', + frozenTime: Date.parse('2026-07-15T10:00:00.000Z'), + } + ); + assert.deepEqual( + { + cookie: initial.cookie, + stored: initial.stored, + serviceWorkers: initial.serviceWorkers, + flag: initial.flag, + now: initial.now, + path: initial.path, + }, + { + cookie: '', + stored: null, + serviceWorkers: 0, + flag: true, + now: initial.frozenTime, + path: initial.expectedPath, + }, + initial.expectedId + ); + await page.context().addCookies([{ name: 'verification-owner', value: id, url: baseUrl }]); + await page.evaluate(async (owner) => { + localStorage.setItem('verification-owner', owner); + await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + }, id); + await observe.expectRoute('/portfolio'); + }); + const runner = await ScenarioRunner.create(browser, repoRoot, config()); + + const first = isolated('serial-first'); + const second = isolated('serial-second'); + assert.equal( + ( + await runner.run(manifest([first]), { + runId: 'serial-first-run', + scenarioIds: [first.id], + }) + ).outcome, + 'passed' + ); + assert.equal( + ( + await runner.run(manifest([second]), { + runId: 'serial-second-run', + scenarioIds: [second.id], + }) + ).outcome, + 'passed' + ); + + const parallel = [1, 2, 3, 4].map((index) => isolated(`parallel-isolated-${index}`)); + const result = await runner.run(manifest(parallel), { + runId: 'parallel-isolation-run', + scenarioIds: parallel.map((entry) => entry.id), + }); + assert.equal(result.outcome, 'passed'); + assert.ok( + result.scenarios.every((entry) => + entry.routes.includes(`/portfolio?scenario=${entry.scenario_id}`) + ) + ); + assert.equal(browser.contexts().length, 0); + }); + + it('classifies deterministic assertion failures as regressions', async () => { + const failing = scenario('scenario-failing', async ({ observe }) => { + await observe.expectVisible('Missing content'); + }); + failing.timeouts = { actionMs: 100, scenarioMs: 1_000 }; + const runner = await ScenarioRunner.create(browser, repoRoot, config()); + + const result = await runner.run(manifest([failing]), { + runId: 'regression-run', + scenarioIds: [failing.id], + }); + + assert.equal(result.outcome, 'regression'); + assert.equal(result.scenarios[0]?.limitations[0]?.affects_confidence, false); + assert.equal(browser.contexts().length, 0); + }); + + it('propagates cancellation, prevents a pass, and closes active contexts', async () => { + const slow = scenario('scenario-slow', async ({ page, step }) => { + await step('create', () => page.waitForTimeout(5_000)); + }); + const runner = await ScenarioRunner.create(browser, repoRoot, config()); + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('user cancelled', 'AbortError')), 25); + + const result = await runner.run(manifest([slow]), { + runId: 'cancelled-run', + scenarioIds: [slow.id], + signal: controller.signal, + }); + + assert.equal(result.outcome, 'no_confidence'); + assert.equal(result.scenarios[0]?.limitations[0]?.code, 'cancelled'); + assert.equal(browser.contexts().length, 0); + }); + + it('classifies browser disconnects as operational no-confidence outcomes', async () => { + const disconnected = scenario('scenario-disconnected', async () => { + throw new Error('Target page, context or browser has been closed'); + }); + const runner = await ScenarioRunner.create(browser, repoRoot, config()); + + const result = await runner.run(manifest([disconnected]), { + runId: 'disconnected-run', + scenarioIds: [disconnected.id], + }); + + assert.equal(result.outcome, 'no_confidence'); + assert.equal(result.scenarios[0]?.limitations[0]?.code, 'browser_unavailable'); + }); + + it('reports scenario deadlines as timeouts rather than user cancellation', async () => { + const timedOut = scenario('scenario-timeout', async ({ page }) => { + await page.waitForTimeout(1_000); + }); + timedOut.timeouts = { actionMs: 100, scenarioMs: 100 }; + const runner = await ScenarioRunner.create(browser, repoRoot, config()); + + const result = await runner.run(manifest([timedOut]), { + runId: 'timeout-run', + scenarioIds: [timedOut.id], + }); + + assert.equal(result.outcome, 'no_confidence'); + assert.equal(result.scenarios[0]?.limitations[0]?.code, 'timeout'); + }); + + it('invalidates an otherwise passing scenario when context teardown fails', async () => { + const browserWithFailingTeardown = { + newContext: async (options: Parameters[0]) => { + const context = await browser.newContext(options); + return new Proxy(context, { + get(target, property, receiver) { + if (property === 'close') { + return async () => { + await target.close(); + throw new Error('fixture teardown failure'); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }, + }; + const runner = await ScenarioRunner.create(browserWithFailingTeardown, repoRoot, config()); + + const result = await runner.run(manifest([scenario('scenario-teardown')]), { + runId: 'teardown-run', + scenarioIds: ['scenario-teardown'], + }); + + assert.equal(result.outcome, 'no_confidence'); + assert.match(result.scenarios[0]?.limitations.at(-1)?.message ?? '', /teardown failed/i); + assert.equal(browser.contexts().length, 0); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/runner.ts b/apps/desktop/src/lib/warm-verification/runner.ts new file mode 100644 index 0000000..7d630c5 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/runner.ts @@ -0,0 +1,356 @@ +import type { Browser, BrowserContext } from '@playwright/test'; +import type { + ScenarioOutcomeSummary, + VerifyLimitation, + VerifyObservation, + VerifyOutcome, + VerifyTiming, +} from './contracts'; +import type { VerifyConfig } from './config'; +import { AutomaticObserver, type AutomaticObserverResult } from './observer'; +import { elapsed, raceAbort, safeErrorMessage, throwIfAborted } from './runtime-utils'; +import type { PublishedScenario, ScenarioManifest } from './scenario'; +import { + AuthStateCache, + BrowserStateError, + installDeterministicContextState, + stateRequestForScenario, + waitForStateBridge, +} from './state'; + +export interface ScenarioExecutionResult extends ScenarioOutcomeSummary { + observations: VerifyObservation[]; + limitations: VerifyLimitation[]; + timings: VerifyTiming[]; + routes: string[]; +} + +export interface ScenarioBatchResult { + outcome: VerifyOutcome; + scenarios: ScenarioExecutionResult[]; + observations: VerifyObservation[]; + limitations: VerifyLimitation[]; + timings: VerifyTiming[]; +} + +export interface ScenarioBatchRequest { + runId: string; + scenarioIds: readonly string[]; + signal?: AbortSignal; +} + +export interface ScenarioRunnerDependencies { + now?: () => Date; + monotonicNow?: () => number; +} + +export class ScenarioRunner { + readonly #browser: Pick; + readonly #config: VerifyConfig; + readonly #authStateCache: AuthStateCache; + readonly #now: () => Date; + readonly #monotonicNow: () => number; + #activeContextCount = 0; + + private constructor( + browser: Pick, + config: VerifyConfig, + authStateCache: AuthStateCache, + dependencies: ScenarioRunnerDependencies + ) { + this.#browser = browser; + this.#config = config; + this.#authStateCache = authStateCache; + this.#now = dependencies.now ?? (() => new Date()); + this.#monotonicNow = dependencies.monotonicNow ?? (() => performance.now()); + } + + static async create( + browser: Pick, + repoRoot: string, + config: VerifyConfig, + dependencies: ScenarioRunnerDependencies = {} + ): Promise { + return new ScenarioRunner(browser, config, await AuthStateCache.create(repoRoot), dependencies); + } + + get activeContextCount(): number { + return this.#activeContextCount; + } + + async run( + manifest: Readonly, + request: ScenarioBatchRequest + ): Promise { + const batchStarted = this.#monotonicNow(); + const byId = new Map(manifest.scenarios.map((scenario) => [scenario.id, scenario])); + const selected = request.scenarioIds.map((id) => { + const scenario = byId.get(id); + if (!scenario) throw new Error(`Selected scenario is unavailable: ${id}`); + return scenario; + }); + const batchTimeout = AbortSignal.timeout( + Math.min(manifest.batchTimeoutMs, this.#config.budgets.batchMs) + ); + const batchSignal = request.signal + ? AbortSignal.any([request.signal, batchTimeout]) + : batchTimeout; + const results = await runBounded( + selected, + Math.min(manifest.parallelism, this.#config.budgets.parallelism), + (scenario) => this.#runScenario(request.runId, scenario, batchSignal) + ); + + const scenarios = results.sort((left, right) => + left.scenario_id.localeCompare(right.scenario_id) + ); + const outcome = aggregateOutcome(scenarios.map((scenario) => scenario.outcome)); + const totalTiming: VerifyTiming = { + stage: 'total', + duration_ms: elapsed(this.#monotonicNow, batchStarted), + }; + return { + outcome, + scenarios, + observations: scenarios.flatMap((scenario) => scenario.observations), + limitations: scenarios.flatMap((scenario) => scenario.limitations), + timings: [...scenarios.flatMap((scenario) => scenario.timings), totalTiming], + }; + } + + async #runScenario( + runId: string, + scenario: PublishedScenario, + batchSignal: AbortSignal + ): Promise { + const started = this.#monotonicNow(); + const timings: VerifyTiming[] = []; + const limitations: VerifyLimitation[] = []; + let context: BrowserContext | undefined; + let observer: AutomaticObserver | undefined; + let observerResult: AutomaticObserverResult | undefined; + let outcome: VerifyOutcome = 'no_confidence'; + let executionStarted = false; + + const scenarioTimeout = AbortSignal.timeout( + Math.min(scenario.timeouts.scenarioMs, this.#config.budgets.scenarioMs) + ); + const signal = AbortSignal.any([batchSignal, scenarioTimeout]); + + try { + throwIfAborted(signal); + let stageStarted = this.#monotonicNow(); + const profile = this.#config.authProfiles[scenario.authProfileId]; + if (!profile) + throw new BrowserStateError( + 'auth_missing', + `Unknown auth profile ${scenario.authProfileId}` + ); + const authState = await this.#authStateCache.load( + scenario.authProfileId, + profile.storageState + ); + timings.push(timing('auth', scenario.id, elapsed(this.#monotonicNow, stageStarted))); + + throwIfAborted(signal); + stageStarted = this.#monotonicNow(); + context = await this.#browser.newContext({ + storageState: this.#authStateCache.copy(authState), + viewport: { width: 1280, height: 800 }, + colorScheme: 'dark', + reducedMotion: 'reduce', + locale: 'en-US', + timezoneId: 'UTC', + }); + this.#activeContextCount += 1; + timings.push(timing('context', scenario.id, elapsed(this.#monotonicNow, stageStarted))); + + observer = new AutomaticObserver({ + scenarioId: scenario.id, + firstPartyOrigins: this.#config.network.firstPartyOrigins, + allowedFirstPartyRequests: this.#config.network.allowedFirstPartyRequests, + slowInteractionMs: this.#config.budgets.slowInteractionMs, + now: this.#now, + }); + const stateRequest = stateRequestForScenario(runId, scenario); + stageStarted = this.#monotonicNow(); + await installDeterministicContextState(context, stateRequest, this.#config, observer); + const page = await context.newPage(); + page.setDefaultTimeout(Math.min(scenario.timeouts.actionMs, this.#config.budgets.actionMs)); + observer.attach(page); + timings.push(timing('state', scenario.id, elapsed(this.#monotonicNow, stageStarted))); + + throwIfAborted(signal); + stageStarted = this.#monotonicNow(); + const targetUrl = new URL(scenario.route, this.#config.target.baseUrl).href; + await raceAbort( + page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: Math.min(scenario.timeouts.actionMs, this.#config.budgets.actionMs), + }), + signal + ); + await raceAbort( + waitForStateBridge( + page, + stateRequest, + Math.min(scenario.timeouts.actionMs, this.#config.budgets.actionMs) + ), + signal + ); + timings.push(timing('navigation', scenario.id, elapsed(this.#monotonicNow, stageStarted))); + + executionStarted = true; + stageStarted = this.#monotonicNow(); + const activeObserver = observer; + await raceAbort( + scenario.run({ + page, + observe: activeObserver, + signal, + step: (actionId, operation) => + activeObserver.step(actionId, () => raceAbort(operation(), signal)), + }), + signal + ); + timings.push(timing('actions', scenario.id, elapsed(this.#monotonicNow, stageStarted))); + + stageStarted = this.#monotonicNow(); + await raceAbort(observer.auditAccessibility('final'), signal); + observerResult = observer.finish(); + observer = undefined; + timings.push(timing('observation', scenario.id, elapsed(this.#monotonicNow, stageStarted))); + outcome = observerResult.hasNoConfidence + ? 'no_confidence' + : observerResult.hasRegression + ? 'regression' + : 'passed'; + } catch (error) { + if (!observerResult && observer) { + const observationStarted = this.#monotonicNow(); + observerResult = observer.finish(); + timings.push( + timing('observation', scenario.id, elapsed(this.#monotonicNow, observationStarted)) + ); + } + observer = undefined; + outcome = classifyScenarioError(error, executionStarted); + limitations.push(limitationForError(error, scenario.id, outcome)); + } finally { + observer?.detach(); + const teardownStarted = this.#monotonicNow(); + if (context) { + try { + await context.close(); + } catch (error) { + outcome = 'no_confidence'; + limitations.push({ + code: 'other', + message: `Scenario context teardown failed: ${safeErrorMessage(error)}`, + affects_confidence: true, + scenario_id: scenario.id, + }); + } finally { + this.#activeContextCount -= 1; + } + } + timings.push(timing('teardown', scenario.id, elapsed(this.#monotonicNow, teardownStarted))); + } + + return { + scenario_id: scenario.id, + outcome, + duration_ms: elapsed(this.#monotonicNow, started), + observations: observerResult?.observations ?? [], + limitations, + timings, + routes: observerResult?.routes ?? [], + }; + } +} + +async function runBounded( + items: readonly T[], + parallelism: number, + operation: (item: T) => Promise +): Promise { + const results: R[] = []; + let nextIndex = 0; + const workers = Array.from({ length: Math.min(parallelism, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await operation(items[index] as T); + } + }); + await Promise.all(workers); + return results; +} + +function classifyScenarioError(error: unknown, executionStarted: boolean): VerifyOutcome { + if (error instanceof BrowserStateError || isAbortError(error) || isBrowserUnavailable(error)) { + return 'no_confidence'; + } + return executionStarted ? 'regression' : 'no_confidence'; +} + +function limitationForError( + error: unknown, + scenarioId: string, + outcome: VerifyOutcome +): VerifyLimitation { + const code = + error instanceof BrowserStateError + ? error.code.startsWith('auth_') || error.code.startsWith('bridge_') + ? 'state_unavailable' + : 'other' + : isTimeoutError(error) + ? 'timeout' + : isAbortError(error) + ? 'cancelled' + : isBrowserUnavailable(error) + ? 'browser_unavailable' + : outcome === 'regression' + ? 'other' + : 'browser_unavailable'; + return { + code, + message: safeErrorMessage(error), + affects_confidence: outcome === 'no_confidence', + scenario_id: scenarioId, + }; +} + +function timing( + stage: VerifyTiming['stage'], + scenarioId: string, + durationMs: number +): VerifyTiming { + return { stage, scenario_id: scenarioId, duration_ms: durationMs }; +} + +function aggregateOutcome(outcomes: readonly VerifyOutcome[]): VerifyOutcome { + if (outcomes.includes('no_confidence')) return 'no_confidence'; + if (outcomes.includes('regression')) return 'regression'; + return 'passed'; +} + +function isAbortError(error: unknown): boolean { + return ( + error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError') + ); +} + +function isTimeoutError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'TimeoutError'; +} + +function isBrowserUnavailable(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.name === 'TargetClosedError' || + /target (?:page, context or browser)|browser has been closed|browser.*disconnected/i.test( + error.message + ) + ); +} diff --git a/apps/desktop/src/lib/warm-verification/runtime-utils.ts b/apps/desktop/src/lib/warm-verification/runtime-utils.ts new file mode 100644 index 0000000..22b1d13 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/runtime-utils.ts @@ -0,0 +1,26 @@ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException('Operation aborted', 'AbortError'); + } +} + +export function raceAbort(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason ?? new DOMException('Operation aborted', 'AbortError')); + } + return new Promise((resolve, reject) => { + const onAbort = () => + reject(signal.reason ?? new DOMException('Operation aborted', 'AbortError')); + signal.addEventListener('abort', onAbort, { once: true }); + operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +export function elapsed(now: () => number, started: number): number { + return Math.round((now() - started) * 1_000) / 1_000; +} + +export function safeErrorMessage(error: unknown, maxLength = 1_000): string { + const message = error instanceof Error ? error.message : String(error); + return message.length <= maxLength ? message : `${message.slice(0, maxLength - 3)}...`; +} diff --git a/apps/desktop/src/lib/warm-verification/state.test.ts b/apps/desktop/src/lib/warm-verification/state.test.ts new file mode 100644 index 0000000..178f394 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/state.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { chromium, type Browser } from '@playwright/test'; +import type { VerifyConfig } from './config'; +import { AutomaticObserver } from './observer'; +import type { DeterministicScenario } from './scenario'; +import { + AuthStateCache, + BrowserStateError, + installDeterministicContextState, + stateRequestForScenario, + type VerificationStateRequest, + waitForStateBridge, +} from './state'; + +let browser: Browser; + +before(async () => { + browser = await chromium.launch({ headless: true }); +}); + +after(async () => { + await browser.close(); +}); + +function scenario(): DeterministicScenario { + return { + schemaVersion: 1, + id: 'portfolio-empty', + capabilityIds: ['portfolio'], + route: '/portfolio', + authProfileId: 'developer', + stateName: 'funded-empty', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: { portfolio: true }, + timeouts: { actionMs: 1_000, scenarioMs: 5_000 }, + actions: [{ id: 'open', kind: 'click', description: 'Open portfolio' }], + assertions: [{ id: 'visible', kind: 'visible', description: 'Portfolio is visible' }], + async run() {}, + }; +} + +function config(): VerifyConfig { + return { + version: 1, + target: { + command: ['pnpm', 'exec', 'vite'], + cwd: '.', + readinessUrl: 'http://app.local/health', + baseUrl: 'http://app.local', + allowedEnv: [], + hmrSettleMs: 0, + shutdownGraceMs: 1_000, + }, + scenarioModules: ['verify/scenarios.ts'], + authProfiles: { developer: { storageState: '.codevetter/auth/developer.json' } }, + capabilities: [{ id: 'portfolio', paths: ['src/**'], scenarios: ['portfolio-empty'] }], + mandatorySmoke: ['portfolio-empty'], + sharedInfrastructure: { paths: ['src/router/**'], fallbackScenarios: ['portfolio-empty'] }, + network: { + firstPartyOrigins: ['http://app.local'], + allowedFirstPartyRequests: ['GET /**'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }, + retention: { + directory: '.codevetter/artifacts', + maxRuns: 20, + maxBytes: 104_857_600, + maxAgeDays: 14, + }, + budgets: { + parallelism: 4, + actionMs: 1_000, + scenarioMs: 5_000, + batchMs: 30_000, + slowInteractionMs: 500, + }, + }; +} + +describe('AuthStateCache', () => { + it('caches immutable auth data and returns isolated copies', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'codevetter-auth-')); + await mkdir(path.join(root, '.codevetter', 'auth'), { recursive: true }); + await writeFile( + path.join(root, '.codevetter', 'auth', 'developer.json'), + JSON.stringify({ cookies: [], origins: [] }) + ); + const cache = await AuthStateCache.create(root); + + const first = await cache.load('developer', '.codevetter/auth/developer.json'); + const second = await cache.load('developer', '.codevetter/auth/developer.json'); + const copy = cache.copy(first); + + assert.strictEqual(second, first); + assert.notStrictEqual(copy, first.storageState); + assert.ok(Object.isFrozen(first.storageState)); + }); + + it('rejects malformed storage state without exposing its contents', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'codevetter-auth-')); + await mkdir(path.join(root, '.codevetter', 'auth'), { recursive: true }); + await writeFile(path.join(root, '.codevetter', 'auth', 'developer.json'), '{"token":"secret"}'); + const cache = await AuthStateCache.create(root); + + await assert.rejects(cache.load('developer', '.codevetter/auth/developer.json'), (error) => { + assert.ok(error instanceof BrowserStateError); + assert.equal(error.code, 'auth_invalid'); + assert.equal(error.message.includes('secret'), false); + return true; + }); + }); +}); + +describe('deterministic state bridge', () => { + it('installs state, flags, frozen time, motion policy, and blocks third parties before app code', async () => { + const context = await browser.newContext({ reducedMotion: 'reduce' }); + const observer = new AutomaticObserver({ + scenarioId: 'portfolio-empty', + firstPartyOrigins: ['http://app.local'], + allowedFirstPartyRequests: ['GET /**'], + slowInteractionMs: 500, + }); + const request = stateRequestForScenario('run-1', scenario()); + await installDeterministicContextState(context, request, config(), observer); + await context.route('http://app.local/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: `
`, + }); + }); + const page = await context.newPage(); + observer.attach(page); + await page.goto('http://app.local/portfolio'); + await waitForStateBridge(page, request, 1_000); + + const state = await page.evaluate(() => ({ + now: Date.now(), + request: (window as typeof window & { __CODEVETTER_VERIFY__?: VerificationStateRequest }) + .__CODEVETTER_VERIFY__, + motionStyle: Boolean(document.getElementById('codevetter-verify-motion')), + })); + const result = observer.finish(); + assert.equal(result.hasRegression, false, JSON.stringify(result.observations, null, 2)); + assert.equal(state.now, Date.parse('2026-07-15T10:00:00.000Z')); + assert.equal(state.request?.flags.portfolio, true); + assert.equal(state.motionStyle, true); + assert.ok( + result.observations.some( + (entry) => + entry.policy_id === 'network.block-third-party' && entry.disposition === 'informational' + ) + ); + await context.close(); + }); + + it('classifies a missing state acknowledgement as an operational state error', async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + const request = stateRequestForScenario('run-timeout', scenario()); + await page.goto('data:text/html,
no bridge
'); + + await assert.rejects(waitForStateBridge(page, request, 50), (error) => { + assert.ok(error instanceof BrowserStateError); + assert.equal(error.code, 'bridge_timeout'); + return true; + }); + await context.close(); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/state.ts b/apps/desktop/src/lib/warm-verification/state.ts new file mode 100644 index 0000000..5e345b0 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/state.ts @@ -0,0 +1,272 @@ +import { createHash } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import type { BrowserContext, Page } from '@playwright/test'; +import type { VerifyConfig } from './config'; +import type { AutomaticObserver } from './observer'; +import type { DeterministicScenario, ScenarioFlagValue } from './scenario'; + +export const MAX_AUTH_STATE_BYTES = 1_048_576; + +export interface CachedAuthState { + profileId: string; + sourceHash: string; + storageState: Awaited>; +} + +export interface VerificationStateRequest { + protocolVersion: 1; + runId: string; + scenarioId: string; + stateName: string; + frozenTime: string; + flags: Readonly>; +} + +export interface VerificationStateStatus { + protocolVersion: 1; + runId: string; + scenarioId: string; + status: 'requested' | 'ready' | 'error'; + message?: string; +} + +export class BrowserStateError extends Error { + readonly code: + | 'auth_missing' + | 'auth_invalid' + | 'auth_unsafe' + | 'bridge_timeout' + | 'bridge_error'; + + constructor(code: BrowserStateError['code'], message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'BrowserStateError'; + this.code = code; + } +} + +export class AuthStateCache { + readonly #repoRoot: string; + readonly #cache = new Map(); + + private constructor(repoRoot: string) { + this.#repoRoot = repoRoot; + } + + static async create(repoRoot: string): Promise { + return new AuthStateCache(await realpath(repoRoot)); + } + + async load(profileId: string, configuredPath: string): Promise { + const expectedPath = path.resolve(this.#repoRoot, configuredPath); + let sourcePath: string; + let source: Uint8Array; + try { + sourcePath = await realpath(expectedPath); + source = await readFile(sourcePath); + } catch (error) { + throw new BrowserStateError( + 'auth_missing', + `Authentication profile ${profileId} is not readable`, + { + cause: error, + } + ); + } + if (sourcePath !== this.#repoRoot && !sourcePath.startsWith(`${this.#repoRoot}${path.sep}`)) { + throw new BrowserStateError( + 'auth_unsafe', + `Authentication profile ${profileId} resolves outside the target repository` + ); + } + if (source.byteLength > MAX_AUTH_STATE_BYTES) { + throw new BrowserStateError( + 'auth_invalid', + `Authentication profile ${profileId} exceeds ${MAX_AUTH_STATE_BYTES} bytes` + ); + } + + const sourceHash = createHash('sha256').update(source).digest('hex'); + const cached = this.#cache.get(profileId); + if (cached?.sourceHash === sourceHash) return cached; + + let value: unknown; + try { + value = JSON.parse(new TextDecoder().decode(source)); + } catch (error) { + throw new BrowserStateError( + 'auth_invalid', + `Authentication profile ${profileId} is not valid JSON`, + { + cause: error, + } + ); + } + if (!isStorageState(value)) { + throw new BrowserStateError( + 'auth_invalid', + `Authentication profile ${profileId} does not match Playwright storageState` + ); + } + + const entry = Object.freeze({ + profileId, + sourceHash, + storageState: deepFreeze(structuredClone(value)), + }); + this.#cache.set(profileId, entry); + return entry; + } + + copy(entry: CachedAuthState): CachedAuthState['storageState'] { + return structuredClone(entry.storageState); + } + + invalidate(profileId?: string): void { + if (profileId) this.#cache.delete(profileId); + else this.#cache.clear(); + } +} + +export async function installDeterministicContextState( + context: BrowserContext, + request: VerificationStateRequest, + config: VerifyConfig, + observer: AutomaticObserver +): Promise { + await context.addInitScript({ content: deterministicPreludeSource(request) }); + + await context.route('**/*', async (route) => { + const rawUrl = route.request().url(); + let url: URL; + try { + url = new URL(rawUrl); + } catch { + await route.continue(); + return; + } + if (['about:', 'blob:', 'data:'].includes(url.protocol)) { + await route.continue(); + return; + } + if ( + config.network.firstPartyOrigins.includes(url.origin) || + config.network.allowedThirdPartyOrigins.includes(url.origin) || + !config.network.blockThirdParty + ) { + await route.continue(); + return; + } + observer.noteBlockedThirdParty(rawUrl); + await route.abort('blockedbyclient'); + }); +} + +export function deterministicPreludeSource(request: VerificationStateRequest): string { + const serialized = JSON.stringify(request).replaceAll('<', '\\u003c'); + return `(() => { + const stateRequest = ${serialized}; + globalThis.__CODEVETTER_VERIFY__ = Object.freeze({ + ...stateRequest, + flags: Object.freeze({ ...stateRequest.flags }) + }); + globalThis.__CODEVETTER_VERIFY_STATE__ = { + protocolVersion: 1, + runId: stateRequest.runId, + scenarioId: stateRequest.scenarioId, + status: 'requested' + }; + const frozenEpoch = Date.parse(stateRequest.frozenTime); + const NativeDate = Date; + function FrozenDate(...args) { + if (new.target) { + return Reflect.construct(NativeDate, args.length === 0 ? [frozenEpoch] : args, new.target); + } + return new NativeDate(frozenEpoch).toString(); + } + Object.setPrototypeOf(FrozenDate, NativeDate); + FrozenDate.prototype = NativeDate.prototype; + FrozenDate.now = () => frozenEpoch; + FrozenDate.parse = NativeDate.parse; + FrozenDate.UTC = NativeDate.UTC; + globalThis.Date = FrozenDate; + const disableMotion = () => { + if (!document.documentElement || document.getElementById('codevetter-verify-motion')) return; + const style = document.createElement('style'); + style.id = 'codevetter-verify-motion'; + style.textContent = '*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;transition-duration:0s!important;scroll-behavior:auto!important}'; + document.documentElement.append(style); + }; + document.addEventListener('DOMContentLoaded', disableMotion, { once: true }); + })();`; +} + +export async function waitForStateBridge( + page: Page, + request: VerificationStateRequest, + timeoutMs: number +): Promise { + try { + await page.waitForFunction( + ({ runId, scenarioId }) => { + const state = ( + window as typeof window & { __CODEVETTER_VERIFY_STATE__?: VerificationStateStatus } + ).__CODEVETTER_VERIFY_STATE__; + return ( + state?.protocolVersion === 1 && + state.runId === runId && + state.scenarioId === scenarioId && + (state.status === 'ready' || state.status === 'error') + ); + }, + { runId: request.runId, scenarioId: request.scenarioId }, + { timeout: timeoutMs } + ); + } catch (error) { + throw new BrowserStateError( + 'bridge_timeout', + `Target state bridge did not acknowledge ${request.stateName}`, + { cause: error } + ); + } + + const state = await page.evaluate(() => { + return (window as typeof window & { __CODEVETTER_VERIFY_STATE__?: VerificationStateStatus }) + .__CODEVETTER_VERIFY_STATE__; + }); + if (state?.status === 'error') { + throw new BrowserStateError( + 'bridge_error', + state.message || `Target state bridge rejected ${request.stateName}` + ); + } +} + +export function stateRequestForScenario( + runId: string, + scenario: DeterministicScenario +): VerificationStateRequest { + return Object.freeze({ + protocolVersion: 1, + runId, + scenarioId: scenario.id, + stateName: scenario.stateName, + frozenTime: scenario.frozenTime, + flags: Object.freeze({ ...scenario.flags }), + }); +} + +function isStorageState(value: unknown): value is CachedAuthState['storageState'] { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as { cookies?: unknown; origins?: unknown }; + return Array.isArray(candidate.cookies) && Array.isArray(candidate.origins); +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value)) deepFreeze(nested); + } + return value; +} From c66f5becfc53ae60cc66c8246df1422b38d6afd5 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:26:21 +0530 Subject: [PATCH 009/141] feat(verify): run a persistent local daemon --- apps/desktop/package.json | 3 + apps/desktop/src/lib/warm-verification/cli.ts | 271 ++++++++ .../src/lib/warm-verification/daemon-entry.ts | 38 ++ .../src/lib/warm-verification/daemon-host.ts | 89 +++ .../src/lib/warm-verification/daemon.test.ts | 318 +++++++++ .../src/lib/warm-verification/daemon.ts | 625 ++++++++++++++++++ .../lifecycle.integration.test.ts | 340 ++++++++++ package.json | 1 + 8 files changed, 1685 insertions(+) create mode 100644 apps/desktop/src/lib/warm-verification/cli.ts create mode 100644 apps/desktop/src/lib/warm-verification/daemon-entry.ts create mode 100644 apps/desktop/src/lib/warm-verification/daemon-host.ts create mode 100644 apps/desktop/src/lib/warm-verification/daemon.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/daemon.ts create mode 100644 apps/desktop/src/lib/warm-verification/lifecycle.integration.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 933a40b..f10718b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,9 @@ "intent-debugger": "node --import tsx src/lib/intent-debugger/run-intent-cli.ts", "synthetic-qa:run": "node scripts/run-synthetic-qa.mjs", "synthetic-qa:replay": "node --import tsx src/lib/synthetic-qa/run-fixture-cli.ts", + "verify": "node --import tsx src/lib/warm-verification/cli.ts", + "verifyd": "node --import tsx src/lib/warm-verification/daemon-entry.ts", + "test:verify": "node --import tsx --test \"src/lib/warm-verification/*.test.ts\"", "lint": "biome check .", "bench:bundle": "node scripts/bundle-budget.mjs", "bench:rust": "cargo test --release --manifest-path src-tauri/Cargo.toml perf_bench -- --ignored --nocapture", diff --git a/apps/desktop/src/lib/warm-verification/cli.ts b/apps/desktop/src/lib/warm-verification/cli.ts new file mode 100644 index 0000000..6e422d3 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/cli.ts @@ -0,0 +1,271 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + collectWorktreeChangeSet, + type CollectedGitChangeSet, + resolveGitRepositoryRoot, +} from './change-set'; +import type { DaemonRequest, DaemonResponse, VerifyResult } from './contracts'; +import { exitCodeForOutcome, VERIFY_PROTOCOL_VERSION, VERIFY_USAGE_EXIT_CODE } from './contracts'; +import { requestDaemon, VerifyIpcError } from './ipc'; +import { resolveVerifyRuntimePaths, type VerifyRuntimePaths } from './runtime-paths'; + +interface CliOptions { + command: 'start' | 'status' | 'stop' | 'changed'; + repo: string; + json: boolean; + detailed: boolean; + timeoutMs: number; +} + +class CliUsageError extends Error {} + +export async function runVerifyCli(argv: readonly string[]): Promise { + let options: CliOptions; + try { + options = parseCli(argv); + } catch (error) { + process.stderr.write(`${safeMessage(error)}\n${usage()}\n`); + return VERIFY_USAGE_EXIT_CODE; + } + + try { + const collected = + options.command === 'changed' ? await collectWorktreeChangeSet(options.repo) : undefined; + options = { + ...options, + repo: collected?.repositoryRoot ?? (await resolveGitRepositoryRoot(options.repo)), + }; + const paths = await resolveVerifyRuntimePaths(options.repo); + if (options.command === 'start') { + const health = await ensureDaemon(paths); + print(options, health); + return 0; + } + if (options.command === 'status') { + const response = await daemonRequest(paths, { type: 'health' }, 1_000); + print(options, response); + return response.type === 'health' ? 0 : 3; + } + if (options.command === 'stop') { + const response = await daemonRequest(paths, { type: 'shutdown', grace_ms: 5_000 }, 10_000); + print(options, response); + if (response.type !== 'shutdown_ack') return 3; + await waitForDaemonStop(paths, 10_000); + return 0; + } + if (!collected) throw new Error('Changed verification did not collect a Git change set'); + return runChanged(options, paths, collected); + } catch (error) { + process.stderr.write(`verify ${options.command} failed: ${safeMessage(error)}\n`); + return 3; + } +} + +async function runChanged( + options: CliOptions, + paths: VerifyRuntimePaths, + collected: CollectedGitChangeSet +): Promise { + await ensureDaemon(paths); + const runId = `run-${randomUUID()}`; + const controller = new AbortController(); + let cancelling = false; + const cancel = () => { + if (cancelling) { + controller.abort(new DOMException('Verification interrupted', 'AbortError')); + return; + } + cancelling = true; + void daemonRequest( + paths, + { type: 'cancel', run_id: runId, reason: 'CLI interrupted' }, + 5_000 + ).finally(() => controller.abort(new DOMException('Verification interrupted', 'AbortError'))); + }; + process.once('SIGINT', cancel); + process.once('SIGTERM', cancel); + try { + const response = await daemonRequest( + paths, + { + type: 'verify_changed', + run_id: runId, + change_set: collected.changeSet, + options: { + detailed_capture: options.detailed, + batch_timeout_ms: options.timeoutMs, + }, + }, + options.timeoutMs + 5_000, + controller.signal + ); + print(options, response); + return response.type === 'verify_result' ? exitCodeForOutcome(response.result.outcome) : 3; + } finally { + process.off('SIGINT', cancel); + process.off('SIGTERM', cancel); + } +} + +async function ensureDaemon(paths: VerifyRuntimePaths): Promise { + const current = await tryHealth(paths); + if (current?.type === 'health') { + if (current.health.warm) return current; + throw new Error('verifyd is running but not warm; stop it before restarting'); + } + + const desktopRoot = fileURLToPath(new URL('../../../', import.meta.url)); + const entry = fileURLToPath(new URL('./daemon-entry.ts', import.meta.url)); + const child = spawn(process.execPath, ['--import', 'tsx', entry, '--repo', paths.canonicalRoot], { + cwd: desktopRoot, + detached: true, + shell: false, + stdio: 'ignore', + }); + child.once('error', () => undefined); + child.unref(); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const response = await tryHealth(paths); + if (response?.type === 'health' && response.health.warm) return response; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('verifyd did not become warm within 30 seconds'); +} + +async function waitForDaemonStop(paths: VerifyRuntimePaths, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await tryHealth(paths)) === undefined) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('verifyd acknowledged shutdown but remained reachable'); +} + +async function tryHealth(paths: VerifyRuntimePaths): Promise { + try { + return await daemonRequest(paths, { type: 'health' }, 500); + } catch (error) { + if (error instanceof VerifyIpcError && ['connection', 'timeout'].includes(error.code)) { + return undefined; + } + throw error; + } +} + +async function daemonRequest( + paths: VerifyRuntimePaths, + request: DaemonRequest, + timeoutMs: number, + signal?: AbortSignal +): Promise { + const envelope = await requestDaemon( + paths.socketPath, + { + protocol_version: VERIFY_PROTOCOL_VERSION, + request_id: `request-${randomUUID()}`, + sent_at: new Date().toISOString(), + request, + }, + { responseTimeoutMs: timeoutMs, signal } + ); + return envelope.response; +} + +function parseCli(argv: readonly string[]): CliOptions { + const daemonCommand = argv[0] === 'daemon'; + const command = daemonCommand ? argv[1] : argv[0]; + if (!['start', 'status', 'stop', 'changed'].includes(command ?? '')) { + throw new CliUsageError('Expected daemon start, daemon status, daemon stop, or changed'); + } + if (daemonCommand && command === 'changed') { + throw new CliUsageError('changed is not a daemon lifecycle command'); + } + let repo = process.cwd(); + let json = false; + let detailed = false; + let timeoutMs = 30_000; + for (let index = daemonCommand ? 2 : 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--json') json = true; + else if (argument === '--detailed') detailed = true; + else if (argument === '--repo') { + const value = argv[++index]; + if (!value) throw new CliUsageError('--repo requires a path'); + repo = path.resolve(value); + } else if (argument === '--timeout-ms') { + const value = Number(argv[++index]); + if (!Number.isSafeInteger(value) || value < 100 || value > 300_000) { + throw new CliUsageError('--timeout-ms must be an integer between 100 and 300000'); + } + timeoutMs = value; + } else { + throw new CliUsageError(`Unknown argument: ${argument}`); + } + } + if (command !== 'changed' && (detailed || timeoutMs !== 30_000)) { + throw new CliUsageError('--detailed and --timeout-ms are only valid with changed'); + } + return { + command: command as CliOptions['command'], + repo, + json, + detailed, + timeoutMs, + }; +} + +function print(options: CliOptions, response: DaemonResponse): void { + if (options.json) { + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); + return; + } + if (response.type === 'health') { + process.stdout.write( + `verifyd ${response.health.warm ? 'warm' : 'not warm'} · ${response.health.active_run_ids.length} active · ${response.health.chromium_revision}\n` + ); + } else if (response.type === 'verify_result') { + printResult(response.result); + } else if (response.type === 'shutdown_ack') { + process.stdout.write( + `verifyd stopping · ${response.active_run_ids.length} active run(s) cancelled\n` + ); + } else if (response.type === 'cancel_ack') { + process.stdout.write(`${response.accepted ? 'cancelling' : 'not active'} ${response.run_id}\n`); + } else { + process.stderr.write(`${response.error.code}: ${response.error.message}\n`); + } +} + +function printResult(result: VerifyResult): void { + const duration = + result.timings.filter((timing) => timing.stage === 'total' && !timing.scenario_id).at(-1) + ?.duration_ms ?? + new Date(result.finished_at).getTime() - new Date(result.started_at).getTime(); + process.stdout.write( + `${result.outcome.replace('_', ' ')} · ${result.scenarios.length} scenario(s) · ${duration}ms · warm=${result.warm}\n` + ); + for (const limitation of result.limitations.slice(0, 5)) { + process.stdout.write(`- ${limitation.code}: ${limitation.message}\n`); + } +} + +function usage(): string { + return 'Usage: verify daemon [--repo PATH] [--json] | verify changed [--repo PATH] [--json] [--detailed] [--timeout-ms N]'; +} + +function safeMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/[\r\n]+/g, ' ').slice(0, 1_000); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void runVerifyCli(process.argv.slice(2)).then((exitCode) => { + process.exitCode = exitCode; + }); +} diff --git a/apps/desktop/src/lib/warm-verification/daemon-entry.ts b/apps/desktop/src/lib/warm-verification/daemon-entry.ts new file mode 100644 index 0000000..62b55e4 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/daemon-entry.ts @@ -0,0 +1,38 @@ +import { VerificationDaemonHost } from './daemon-host'; + +function repositoryArgument(argv: readonly string[]): string { + if (argv.length !== 2 || argv[0] !== '--repo' || !argv[1]) { + throw new Error('Usage: verifyd --repo '); + } + return argv[1]; +} + +async function main(): Promise { + const startup = new AbortController(); + let host: VerificationDaemonHost | undefined; + const stop = () => { + startup.abort(new DOMException('verifyd interrupted', 'AbortError')); + if (host) { + void host + .stop() + .catch((error) => process.stderr.write(`verifyd shutdown failed: ${safeMessage(error)}\n`)); + } + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + host = await VerificationDaemonHost.start( + repositoryArgument(process.argv.slice(2)), + startup.signal + ); + if (startup.signal.aborted) await host.stop(); +} + +function safeMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/[\r\n]+/g, ' ').slice(0, 1_000); +} + +void main().catch((error) => { + process.stderr.write(`verifyd failed: ${safeMessage(error)}\n`); + process.exitCode = 3; +}); diff --git a/apps/desktop/src/lib/warm-verification/daemon-host.ts b/apps/desktop/src/lib/warm-verification/daemon-host.ts new file mode 100644 index 0000000..84938ad --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/daemon-host.ts @@ -0,0 +1,89 @@ +import type { Server } from 'node:net'; + +import { collectWorktreeChangeSet } from './change-set'; +import { VerificationDaemon } from './daemon'; +import { closeServer, closeServerWithin, listenVerifyIpcServer } from './ipc'; +import { resolveVerifyRuntimePaths } from './runtime-paths'; +import { throwIfAborted } from './runtime-utils'; +import { + acquireVerifySingleton, + releaseVerifySingleton, + type VerifySingletonHandle, +} from './singleton'; +import { AppServerSupervisor, WarmChromiumSupervisor, WarmRuntimeSupervisor } from './supervision'; + +export class VerificationDaemonHost { + readonly #daemon: VerificationDaemon; + readonly #server: Server; + readonly #singleton: VerifySingletonHandle; + #stopPromise: Promise | undefined; + + private constructor( + daemon: VerificationDaemon, + server: Server, + singleton: VerifySingletonHandle + ) { + this.#daemon = daemon; + this.#server = server; + this.#singleton = singleton; + } + + static async start(repoPath: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const collected = await collectWorktreeChangeSet(repoPath); + throwIfAborted(signal); + const paths = await resolveVerifyRuntimePaths(collected.repositoryRoot); + const singleton = await acquireVerifySingleton(paths); + let daemon: VerificationDaemon | undefined; + let server: Server | undefined; + + try { + let host: VerificationDaemonHost | undefined; + daemon = await VerificationDaemon.create( + collected.repositoryRoot, + collected.changeSet.target_sha, + singleton.lease, + (repoRoot, config) => + new WarmRuntimeSupervisor( + new AppServerSupervisor(repoRoot, config.config.target), + new WarmChromiumSupervisor() + ), + { onShutdown: (graceMs) => void host?.stop(graceMs) } + ); + await daemon.start(); + throwIfAborted(signal); + const readyDaemon = daemon; + server = await listenVerifyIpcServer(paths.socketPath, (request, connectionSignal) => + readyDaemon.handle(request, connectionSignal) + ); + host = new VerificationDaemonHost(daemon, server, singleton); + return host; + } catch (error) { + if (server) await closeServer(server).catch(() => undefined); + let cleaned = true; + if (daemon) { + try { + await daemon.stop(); + } catch { + cleaned = false; + } + } + if (cleaned) await releaseVerifySingleton(singleton).catch(() => undefined); + throw error; + } + } + + stop(graceMs = 5_000): Promise { + this.#stopPromise ??= this.#stop(graceMs).catch((error) => { + this.#stopPromise = undefined; + throw error; + }); + return this.#stopPromise; + } + + async #stop(graceMs: number): Promise { + await this.#daemon.stop(graceMs); + await closeServerWithin(this.#server, graceMs); + await releaseVerifySingleton(this.#singleton); + } +} diff --git a/apps/desktop/src/lib/warm-verification/daemon.test.ts b/apps/desktop/src/lib/warm-verification/daemon.test.ts new file mode 100644 index 0000000..721ab79 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/daemon.test.ts @@ -0,0 +1,318 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import type { DaemonRequestEnvelope, DaemonResponseEnvelope } from './contracts'; +import { validateDaemonResponseEnvelope } from './contracts'; +import { VerificationDaemon } from './daemon'; +import type { VerifyDaemonLease } from './singleton'; +import type { WarmRuntimeSupervisor } from './supervision'; + +const gitSha = 'a'.repeat(40); +const identity = 'b'.repeat(64); + +const scenarioSource = ` +export const scenarioModule = { + id: 'shell-module', + scenarios: [{ + schemaVersion: 1, + id: 'shell-smoke', + capabilityIds: ['shell'], + route: '/', + authProfileId: 'developer', + stateName: 'ready', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: {}, + timeouts: { actionMs: 1000, scenarioMs: 5000 }, + actions: [{ id: 'open', kind: 'navigate', description: 'Open shell' }], + assertions: [{ id: 'visible', kind: 'visible', description: 'Shell visible' }], + async run() {} + }] +}; +`; + +const configSource = ` +version: 1 +target: + command: [pnpm, exec, vite, --strictPort] + cwd: . + readinessUrl: http://127.0.0.1:4173 + baseUrl: http://127.0.0.1:4173 + allowedEnv: [] + hmrSettleMs: 0 + shutdownGraceMs: 100 +scenarioModules: [verify/scenarios.mjs] +authProfiles: + developer: + storageState: .codevetter/auth/developer.json +capabilities: + - id: shell + paths: [src/**] + scenarios: [shell-smoke] +mandatorySmoke: [shell-smoke] +sharedInfrastructure: + paths: [package.json] + fallbackScenarios: [shell-smoke] +network: + firstPartyOrigins: [http://127.0.0.1:4173] + allowedFirstPartyRequests: [GET /**] + blockThirdParty: true + allowedThirdPartyOrigins: [] +retention: + directory: .codevetter/artifacts + maxRuns: 10 + maxBytes: 1048576 + maxAgeDays: 1 +budgets: + parallelism: 2 + actionMs: 1000 + scenarioMs: 5000 + batchMs: 10000 + slowInteractionMs: 500 +`; + +async function fixtureRepo(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'codevetter-daemon-')); + await mkdir(path.join(root, '.codevetter', 'auth'), { recursive: true }); + await mkdir(path.join(root, 'verify'), { recursive: true }); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, '.codevetter', 'verify.yaml'), configSource); + await writeFile(path.join(root, '.codevetter', 'auth', 'developer.json'), '{}\n'); + await writeFile(path.join(root, 'verify', 'scenarios.mjs'), scenarioSource); + await writeFile(path.join(root, 'src', 'app.ts'), 'export const app = true;\n'); + return root; +} + +function lease(root: string): VerifyDaemonLease { + return { + schema_version: 1, + repo_id: 'c'.repeat(64), + canonical_root: root, + owner_token: 'owner-token', + pid: process.pid, + process_start_identity: 'test-process-start', + socket_path: '/tmp/test.sock', + acquired_at: '2026-07-15T10:00:00.000Z', + }; +} + +function fakeRuntime(): WarmRuntimeSupervisor { + return { + health: () => ({ + warm: true, + generation: 1, + server: { + state: 'ready', + owned: true, + pid: 42, + processGroupId: 42, + startIdentity: '42:1:test', + generation: 1, + recoveryAttempts: 0, + lastExit: null, + logs: { text: '', bytes: 0, droppedBytes: 0 }, + }, + browser: { + state: 'ready', + owned: true, + connected: true, + generation: 1, + recoveryAttempts: 0, + revision: '1217', + version: 'Chromium 136', + lastDisconnectedAt: null, + }, + }), + ensureReady: async () => { + throw new Error('runner should not start in this test'); + }, + stop: async () => undefined, + browser: { currentBrowser: () => assert.fail('browser should not be requested') }, + } as unknown as WarmRuntimeSupervisor; +} + +function request( + type: DaemonRequestEnvelope['request']['type'], + requestId: string +): DaemonRequestEnvelope { + const base = { + protocol_version: 1 as const, + request_id: requestId, + sent_at: '2026-07-15T10:00:00.000Z', + }; + if (type === 'health') return { ...base, request: { type } }; + if (type === 'cancel') return { ...base, request: { type, run_id: 'run-1', reason: 'test' } }; + assert.fail(`unsupported test request ${type}`); +} + +function verifyRequest(changedPaths: string[]): DaemonRequestEnvelope { + return { + protocol_version: 1, + request_id: 'verify-1', + sent_at: '2026-07-15T10:00:00.000Z', + request: { + type: 'verify_changed', + run_id: 'run-1', + change_set: { + kind: 'worktree', + target_sha: gitSha, + identity, + changed_paths: changedPaths, + }, + options: { detailed_capture: false, batch_timeout_ms: 10_000 }, + }, + }; +} + +function matchingChangeSet(root: string, changedPaths: string[]) { + return async () => ({ + repositoryRoot: root, + changeSet: { + kind: 'worktree' as const, + target_sha: gitSha, + identity, + revision: 'HEAD+index+worktree+untracked' as const, + changed_paths: changedPaths, + }, + }); +} + +describe('VerificationDaemon', () => { + it('reports honest process and browser ownership health', async () => { + const root = await fixtureRepo(); + const daemon = await VerificationDaemon.create(root, gitSha, lease(root), fakeRuntime(), { + collectChangeSet: matchingChangeSet(root, []), + }); + const response = await daemon.handle(request('health', 'health-1')); + + assert.equal(response.type, 'health'); + if (response.type !== 'health') return; + assert.equal(response.health.server.pid, 42); + assert.equal(response.health.browser.pid, null); + assert.equal(response.health.browser.start_identity, '1217:generation-1'); + const envelope: DaemonResponseEnvelope = { + protocol_version: 1, + request_id: 'health-1', + sent_at: '2026-07-15T10:00:00.000Z', + response, + }; + assert.equal(validateDaemonResponseEnvelope(envelope).ok, true); + }); + + it('returns no confidence instead of passing an empty change selection', async () => { + const root = await fixtureRepo(); + const daemon = await VerificationDaemon.create(root, gitSha, lease(root), fakeRuntime(), { + collectChangeSet: matchingChangeSet(root, []), + }); + const response = await daemon.handle(verifyRequest([])); + + assert.equal(response.type, 'verify_result'); + if (response.type !== 'verify_result') return; + assert.equal(response.result.outcome, 'no_confidence'); + assert.equal(response.result.selection.complete, false); + assert.ok(response.result.limitations.some((entry) => entry.code === 'selection_incomplete')); + }); + + it('cancels an active run and never converts it into a pass', async () => { + const root = await fixtureRepo(); + let releaseHash: (() => void) | undefined; + let notifyHashStarted: (() => void) | undefined; + const hashStarted = new Promise((resolve) => { + notifyHashStarted = resolve; + }); + const hashGate = new Promise((resolve) => { + releaseHash = resolve; + }); + let calls = 0; + const daemon = await VerificationDaemon.create(root, gitSha, lease(root), fakeRuntime(), { + sourceHash: async () => { + calls += 1; + if (calls === 1) { + notifyHashStarted?.(); + await hashGate; + } + return identity; + }, + collectChangeSet: matchingChangeSet(root, ['src/app.ts']), + }); + + const run = daemon.handle(verifyRequest(['src/app.ts'])); + await hashStarted; + const cancellation = await daemon.handle(request('cancel', 'cancel-1')); + releaseHash?.(); + const response = await run; + + assert.deepEqual(cancellation, { type: 'cancel_ack', run_id: 'run-1', accepted: true }); + assert.equal(response.type, 'verify_result'); + if (response.type !== 'verify_result') return; + assert.equal(response.result.outcome, 'no_confidence'); + assert.equal(response.result.cancellation.state, 'completed'); + assert.ok(response.result.limitations.some((entry) => entry.code === 'cancelled')); + }); + + it('applies the batch deadline to source loading before a browser run starts', async () => { + const root = await fixtureRepo(); + const daemon = await VerificationDaemon.create(root, gitSha, lease(root), fakeRuntime(), { + sourceHash: () => new Promise(() => undefined), + collectChangeSet: matchingChangeSet(root, ['src/app.ts']), + }); + const timedRequest = verifyRequest(['src/app.ts']); + if (timedRequest.request.type !== 'verify_changed') assert.fail('expected verify request'); + timedRequest.request.options.batch_timeout_ms = 100; + + const started = performance.now(); + const response = await daemon.handle(timedRequest); + + assert.ok(performance.now() - started < 500); + assert.equal(response.type, 'verify_result'); + if (response.type !== 'verify_result') return; + assert.equal(response.result.outcome, 'no_confidence'); + assert.ok(response.result.limitations.some((entry) => entry.code === 'timeout')); + }); + + it('invalidates evidence when Git HEAD or changed paths drift during execution', async () => { + const root = await fixtureRepo(); + let collections = 0; + const daemon = await VerificationDaemon.create(root, gitSha, lease(root), fakeRuntime(), { + sourceHash: async () => identity, + collectChangeSet: async () => { + collections += 1; + const current = await matchingChangeSet(root, ['src/app.ts'])(); + if (collections > 1) current.changeSet.identity = 'd'.repeat(64); + return current; + }, + }); + + const response = await daemon.handle(verifyRequest(['src/app.ts'])); + + assert.equal(response.type, 'verify_result'); + if (response.type !== 'verify_result') return; + assert.equal(response.result.stale, true); + assert.equal(response.result.outcome, 'no_confidence'); + assert.ok(response.result.limitations.some((entry) => entry.code === 'source_stale')); + }); + + it('reports diff, selection, reporting, and whole-invocation timings on failed runs', async () => { + const root = await fixtureRepo(); + let tick = 0; + const daemon = await VerificationDaemon.create(root, gitSha, lease(root), fakeRuntime(), { + monotonicNow: () => { + tick += 1; + return tick; + }, + collectChangeSet: matchingChangeSet(root, []), + }); + + const response = await daemon.handle(verifyRequest([])); + + assert.equal(response.type, 'verify_result'); + if (response.type !== 'verify_result') return; + assert.deepEqual( + response.result.timings.map((timing) => timing.stage), + ['diff', 'selection', 'reporting', 'total'] + ); + assert.ok(response.result.timings.every((timing) => timing.duration_ms >= 0)); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/daemon.ts b/apps/desktop/src/lib/warm-verification/daemon.ts new file mode 100644 index 0000000..78f3ee0 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/daemon.ts @@ -0,0 +1,625 @@ +import { createHash } from 'node:crypto'; +import { readFile, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { collectWorktreeChangeSet } from './change-set'; +import type { + DaemonHealth, + DaemonRequestEnvelope, + DaemonResponse, + VerifyCancellation, + VerifyChangeSetIdentity, + VerifyLimitation, + VerifyOutcome, + VerifyResult, +} from './contracts'; +import { VERIFY_CONTRACT_LIMITS, VERIFY_PROTOCOL_VERSION } from './contracts'; +import type { VerifyConfigSnapshot } from './config-loader'; +import { VerifyConfigLoader } from './config-loader'; +import { ScenarioManifestLoader } from './manifest-loader'; +import { redactVerifyResult } from './redaction'; +import { ScenarioRunner, type ScenarioBatchResult } from './runner'; +import { elapsed, raceAbort, safeErrorMessage, throwIfAborted } from './runtime-utils'; +import type { ScenarioManifest } from './scenario'; +import { selectChangedCapabilities, type ChangedCapabilitySelection } from './selection'; +import type { VerifyDaemonLease } from './singleton'; +import { SupervisionError, type WarmRuntimeSupervisor } from './supervision'; + +const MAX_HASHED_FILE_BYTES = 64 * 1024 * 1024; +const MAX_HASHED_RUN_BYTES = 256 * 1024 * 1024; + +interface ActiveRun { + controller: AbortController; + requestedAt?: string; + reason?: string; +} + +export interface VerificationDaemonDependencies { + now?: () => Date; + monotonicNow?: () => number; + sourceHash?: ( + repoRoot: string, + config: VerifyConfigSnapshot, + manifest: Readonly, + changedPaths: readonly string[] + ) => Promise; + onShutdown?: (graceMs: number) => void; + collectChangeSet?: typeof collectWorktreeChangeSet; +} + +export type WarmRuntimeFactory = ( + repoRoot: string, + config: VerifyConfigSnapshot +) => WarmRuntimeSupervisor; + +export class VerificationDaemon { + readonly #repoRoot: string; + readonly #lease: VerifyDaemonLease; + readonly #runtime: WarmRuntimeSupervisor; + readonly #configLoader: VerifyConfigLoader; + readonly #manifestLoader: ScenarioManifestLoader; + readonly #startupConfig: VerifyConfigSnapshot; + readonly #now: () => Date; + readonly #monotonicNow: () => number; + readonly #sourceHash: NonNullable; + readonly #onShutdown: (graceMs: number) => void; + readonly #collectChangeSet: typeof collectWorktreeChangeSet; + readonly #activeRuns = new Map(); + + #targetSha: string; + #runner: ScenarioRunner | undefined; + #runnerGeneration = -1; + #shuttingDown = false; + + private constructor( + repoRoot: string, + targetSha: string, + lease: VerifyDaemonLease, + runtime: WarmRuntimeSupervisor, + configLoader: VerifyConfigLoader, + manifestLoader: ScenarioManifestLoader, + startupConfig: VerifyConfigSnapshot, + dependencies: VerificationDaemonDependencies + ) { + this.#repoRoot = repoRoot; + this.#targetSha = targetSha; + this.#lease = lease; + this.#runtime = runtime; + this.#configLoader = configLoader; + this.#manifestLoader = manifestLoader; + this.#startupConfig = startupConfig; + this.#now = dependencies.now ?? (() => new Date()); + this.#monotonicNow = dependencies.monotonicNow ?? (() => performance.now()); + this.#sourceHash = dependencies.sourceHash ?? hashVerificationSources; + this.#onShutdown = dependencies.onShutdown ?? (() => undefined); + this.#collectChangeSet = dependencies.collectChangeSet ?? collectWorktreeChangeSet; + } + + static async create( + repoRoot: string, + targetSha: string, + lease: VerifyDaemonLease, + runtimeOrFactory: WarmRuntimeSupervisor | WarmRuntimeFactory, + dependencies: VerificationDaemonDependencies = {} + ): Promise { + const canonicalRoot = await realpath(repoRoot); + const configLoader = await VerifyConfigLoader.create(canonicalRoot); + const manifestLoader = await ScenarioManifestLoader.create(canonicalRoot); + const startupConfig = await configLoader.load(); + await manifestLoader.load(startupConfig); + const runtime = + typeof runtimeOrFactory === 'function' + ? runtimeOrFactory(canonicalRoot, startupConfig) + : runtimeOrFactory; + return new VerificationDaemon( + canonicalRoot, + targetSha, + lease, + runtime, + configLoader, + manifestLoader, + startupConfig, + dependencies + ); + } + + async start(): Promise { + await this.#runtime.start(); + } + + health(): DaemonHealth { + const runtime = this.#runtime.health(); + const memory = process.memoryUsage(); + return { + schema_version: 1, + daemon_pid: this.#lease.pid, + daemon_start_identity: this.#lease.process_start_identity, + target_root: this.#repoRoot, + target_sha: this.#targetSha, + config_hash: this.#startupConfig.hash, + chromium_revision: runtime.browser.revision, + warm: runtime.warm && !this.#shuttingDown, + server: { + kind: 'process', + state: runtime.server.state, + owned: runtime.server.owned, + pid: runtime.server.pid, + start_identity: runtime.server.startIdentity, + restart_attempts: runtime.server.recoveryAttempts, + last_exit: runtime.server.lastExit, + }, + browser: { + kind: 'browser', + state: runtime.browser.state, + owned: runtime.browser.owned, + pid: null, + start_identity: runtime.browser.owned + ? `${runtime.browser.revision}:generation-${runtime.browser.generation}` + : null, + restart_attempts: runtime.browser.recoveryAttempts, + last_exit: runtime.browser.lastDisconnectedAt + ? { code: null, signal: 'disconnected', at: runtime.browser.lastDisconnectedAt } + : null, + }, + active_run_ids: [...this.#activeRuns.keys()].sort(), + resources: { + rss_bytes: memory.rss, + heap_used_bytes: memory.heapUsed, + active_contexts: this.#runner?.activeContextCount ?? 0, + retained_artifact_bytes: 0, + }, + checked_at: this.#now().toISOString(), + }; + } + + async handle( + envelope: DaemonRequestEnvelope, + connectionSignal?: AbortSignal + ): Promise { + const request = envelope.request; + if (request.type === 'health') return { type: 'health', health: this.health() }; + if (request.type === 'cancel') { + const active = this.#activeRuns.get(request.run_id); + if (!active) return { type: 'cancel_ack', run_id: request.run_id, accepted: false }; + active.requestedAt ??= this.#now().toISOString(); + active.reason ??= request.reason; + active.controller.abort( + new DOMException(request.reason ?? 'Verification cancelled', 'AbortError') + ); + return { type: 'cancel_ack', run_id: request.run_id, accepted: true }; + } + if (request.type === 'shutdown') { + this.#shuttingDown = true; + const activeRunIds = [...this.#activeRuns.keys()].sort(); + for (const active of this.#activeRuns.values()) { + active.requestedAt ??= this.#now().toISOString(); + active.reason ??= 'verifyd shutdown'; + active.controller.abort(new DOMException(active.reason, 'AbortError')); + } + queueMicrotask(() => this.#onShutdown(request.grace_ms)); + return { type: 'shutdown_ack', active_run_ids: activeRunIds }; + } + + if (this.#shuttingDown) { + return daemonError('daemon_unavailable', 'verifyd is shutting down', false); + } + if (this.#activeRuns.has(request.run_id)) { + return daemonError('duplicate_run', `Run ${request.run_id} is already active`, false); + } + if (this.#activeRuns.size >= VERIFY_CONTRACT_LIMITS.maxActiveRuns) { + return daemonError('capacity', 'verifyd has reached its bounded active-run capacity', true); + } + + const active: ActiveRun = { controller: new AbortController() }; + const disconnect = () => + active.controller.abort( + connectionSignal?.reason ?? + new DOMException('Verification client disconnected', 'AbortError') + ); + if (connectionSignal?.aborted) disconnect(); + else connectionSignal?.addEventListener('abort', disconnect, { once: true }); + this.#activeRuns.set(request.run_id, active); + try { + const result = await this.#verifyChanged( + request.run_id, + request.change_set, + request.options.batch_timeout_ms, + active + ); + return { type: 'verify_result', result }; + } finally { + connectionSignal?.removeEventListener('abort', disconnect); + this.#activeRuns.delete(request.run_id); + } + } + + async stop(graceMs = 5_000): Promise { + this.#shuttingDown = true; + for (const active of this.#activeRuns.values()) { + active.requestedAt ??= this.#now().toISOString(); + active.reason ??= 'verifyd stopped'; + active.controller.abort(new DOMException('verifyd stopped', 'AbortError')); + } + const deadline = Date.now() + graceMs; + while (this.#activeRuns.size > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (this.#activeRuns.size > 0) { + throw new Error(`Timed out waiting for ${this.#activeRuns.size} active verification run(s)`); + } + const remaining = Math.max(1, deadline - Date.now()); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + this.#runtime.stop(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Timed out stopping owned warm runtime')), + remaining + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + async #verifyChanged( + runId: string, + changeSet: VerifyChangeSetIdentity, + requestedBatchTimeoutMs: number, + active: ActiveRun + ): Promise { + const started = this.#now(); + const invocationStarted = this.#monotonicNow(); + const runSignal = AbortSignal.any([ + active.controller.signal, + AbortSignal.timeout( + Math.min(requestedBatchTimeoutMs, this.#startupConfig.config.budgets.batchMs) + ), + ]); + let config = this.#startupConfig; + let manifest = this.#manifestLoader.current as Readonly; + let beforeHash = fallbackHash('before', changeSet.identity, config.hash, manifest.manifestHash); + let selection: ChangedCapabilitySelection | undefined; + let batch: ScenarioBatchResult | undefined; + const limitations: VerifyLimitation[] = []; + const daemonTimings: VerifyResult['timings'] = []; + let warm = false; + + try { + const currentChangeSet = await this.#timed('diff', daemonTimings, () => + raceAbort(this.#collectChangeSet(this.#repoRoot), runSignal) + ); + if ( + currentChangeSet.changeSet.target_sha !== changeSet.target_sha || + currentChangeSet.changeSet.identity !== changeSet.identity + ) { + throw new VerificationRunError( + 'source_stale', + 'Requested Git change set no longer matches the repository' + ); + } + config = await raceAbort(this.#configLoader.load(), runSignal); + manifest = await raceAbort(this.#manifestLoader.load(config), runSignal); + if (config.hash !== this.#startupConfig.hash) { + throw new VerificationRunError( + 'config_invalid', + 'Verification config changed after verifyd started; restart verifyd to apply server settings' + ); + } + beforeHash = await raceAbort( + this.#sourceHash(this.#repoRoot, config, manifest, changeSet.changed_paths), + runSignal + ); + throwIfAborted(runSignal); + selection = await this.#timed('selection', daemonTimings, async () => + selectChangedCapabilities( + config.config, + new Set(manifest.scenarios.map((scenario) => scenario.id)), + changeSet.changed_paths + ) + ); + if (!selection.complete || selection.selectedScenarioIds.length === 0) { + throw new VerificationRunError( + 'selection_incomplete', + selection.limitations.map((entry) => entry.detail).join('; ') || + 'No complete scenario selection was available' + ); + } + const runtimeHealth = await raceAbort(this.#runtime.ensureReady(), runSignal); + warm = runtimeHealth.warm; + const runner = await this.#runnerForGeneration(runtimeHealth.browser.generation, config); + batch = await runner.run(manifest, { + runId, + scenarioIds: selection.selectedScenarioIds, + signal: runSignal, + }); + const afterRuntime = await raceAbort(this.#runtime.ensureReady(), runSignal); + if ( + !afterRuntime.warm || + afterRuntime.server.generation !== runtimeHealth.server.generation || + afterRuntime.browser.generation !== runtimeHealth.browser.generation + ) { + throw new VerificationRunError( + afterRuntime.browser.generation !== runtimeHealth.browser.generation + ? 'browser_unavailable' + : 'target_unavailable', + 'Warm runtime changed generation while verification was executing' + ); + } + this.#targetSha = changeSet.target_sha; + } catch (error) { + limitations.push(limitationForRunError(error)); + } + + const reportingStarted = this.#monotonicNow(); + let afterHash = beforeHash; + try { + this.#configLoader.invalidate(); + this.#manifestLoader.invalidate(); + const afterConfig = await raceAbort(this.#configLoader.load(), runSignal); + const afterManifest = await raceAbort(this.#manifestLoader.load(afterConfig), runSignal); + afterHash = await raceAbort( + this.#sourceHash(this.#repoRoot, afterConfig, afterManifest, changeSet.changed_paths), + runSignal + ); + const afterChangeSet = await raceAbort(this.#collectChangeSet(this.#repoRoot), runSignal); + if ( + afterChangeSet.changeSet.target_sha !== changeSet.target_sha || + afterChangeSet.changeSet.identity !== changeSet.identity + ) { + afterHash = fallbackHash('change-set-drift', afterHash, afterChangeSet.changeSet.identity); + limitations.push({ + code: 'source_stale', + message: 'Git HEAD or changed paths drifted while verification was executing', + affects_confidence: true, + }); + } + } catch (error) { + afterHash = fallbackHash('after-unavailable', changeSet.identity, safeErrorMessage(error)); + const limitation = limitationForRunError(error); + limitations.push( + limitation.code === 'cancelled' || limitation.code === 'timeout' + ? limitation + : { + code: 'source_stale', + message: `Could not revalidate source identity: ${safeErrorMessage(error)}`, + affects_confidence: true, + } + ); + } + + const stale = beforeHash !== afterHash; + if (stale && !limitations.some((entry) => entry.code === 'source_stale')) { + limitations.push({ + code: 'source_stale', + message: 'Verification inputs changed while the run was executing', + affects_confidence: true, + }); + } + const cancellation = cancellationFor(active, this.#now()); + const confidenceBlocked = + stale || + cancellation.state !== 'not_requested' || + limitations.some((entry) => entry.affects_confidence); + const outcome: VerifyOutcome = confidenceBlocked + ? 'no_confidence' + : (batch?.outcome ?? 'no_confidence'); + const finished = this.#now(); + + const result: VerifyResult = { + schema_version: 1, + protocol_version: VERIFY_PROTOCOL_VERSION, + run_id: runId, + outcome, + started_at: started.toISOString(), + finished_at: finished.toISOString(), + warm, + stale, + model_call_count: 0, + source: { + target_sha: changeSet.target_sha, + change_set_kind: changeSet.kind, + change_set_identity: changeSet.identity, + ...(changeSet.revision ? { change_set_revision: changeSet.revision } : {}), + config_hash: config.hash, + manifest_hash: manifest.manifestHash, + source_hash_before: beforeHash, + source_hash_after: afterHash, + }, + observation_policy: { schema_version: 1, profile_id: 'strict-default-v1' }, + selection: selectionSummary(selection, changeSet.changed_paths), + scenarios: (batch?.scenarios ?? []).map( + ({ scenario_id, outcome: scenarioOutcome, duration_ms }) => ({ + scenario_id, + outcome: scenarioOutcome, + duration_ms, + }) + ), + timings: [], + observations: batch?.observations ?? [], + limitations: [...(batch?.limitations ?? []), ...limitations], + artifacts: [], + cancellation, + }; + daemonTimings.push({ + stage: 'reporting', + duration_ms: elapsed(this.#monotonicNow, reportingStarted), + }); + result.timings = [ + ...daemonTimings, + ...(batch?.timings.filter((timing) => timing.stage !== 'total') ?? []), + { stage: 'total', duration_ms: elapsed(this.#monotonicNow, invocationStarted) }, + ]; + return redactVerifyResult(result); + } + + async #timed( + stage: 'diff' | 'selection', + timings: VerifyResult['timings'], + operation: () => Promise + ): Promise { + const started = this.#monotonicNow(); + try { + return await operation(); + } finally { + timings.push({ stage, duration_ms: elapsed(this.#monotonicNow, started) }); + } + } + + async #runnerForGeneration( + generation: number, + config: VerifyConfigSnapshot + ): Promise { + if (this.#runner && this.#runnerGeneration === generation) return this.#runner; + const browser = this.#runtime.browser.currentBrowser(); + this.#runner = await ScenarioRunner.create( + { newContext: browser.newContext.bind(browser) }, + this.#repoRoot, + config.config + ); + this.#runnerGeneration = generation; + return this.#runner; + } +} + +class VerificationRunError extends Error { + constructor( + readonly code: VerifyLimitation['code'], + message: string + ) { + super(message); + this.name = 'VerificationRunError'; + } +} + +function selectionSummary( + selection: ChangedCapabilitySelection | undefined, + changedPaths: readonly string[] +): VerifyResult['selection'] { + if (!selection) { + return { + changed_paths: [...changedPaths], + selected_scenario_ids: [], + mandatory_smoke_ids: [], + fallback_scenario_ids: [], + complete: false, + explanation: 'Selection did not complete', + }; + } + const explanation = selection.reasons.map((entry) => entry.detail).join('; '); + return { + changed_paths: selection.changedPaths, + selected_scenario_ids: selection.selectedScenarioIds, + mandatory_smoke_ids: selection.mandatorySmokeIds, + fallback_scenario_ids: selection.fallbackScenarioIds, + complete: selection.complete, + explanation: explanation || 'Explicit changed-capability selection completed', + }; +} + +function limitationForRunError(error: unknown): VerifyLimitation { + if (error instanceof VerificationRunError) { + return { code: error.code, message: error.message, affects_confidence: true }; + } + if (error instanceof SupervisionError) { + return { + code: error.code === 'browser_unavailable' ? 'browser_unavailable' : 'target_unavailable', + message: error.message, + affects_confidence: true, + }; + } + if ( + error instanceof DOMException && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) { + return { + code: error.name === 'TimeoutError' ? 'timeout' : 'cancelled', + message: safeErrorMessage(error), + affects_confidence: true, + }; + } + return { code: 'other', message: safeErrorMessage(error), affects_confidence: true }; +} + +function cancellationFor(active: ActiveRun, completedAt: Date): VerifyCancellation { + return active.requestedAt + ? { + state: 'completed', + requested_at: active.requestedAt, + completed_at: completedAt.toISOString(), + ...(active.reason ? { reason: active.reason } : {}), + } + : { state: 'not_requested' }; +} + +function daemonError(code: string, message: string, retryable: boolean): DaemonResponse { + return { type: 'error', error: { code, message, retryable } }; +} + +function fallbackHash(...parts: readonly string[]): string { + return createHash('sha256').update(parts.join('\0')).digest('hex'); +} + +export async function hashVerificationSources( + repoRoot: string, + config: VerifyConfigSnapshot, + manifest: Readonly, + changedPaths: readonly string[] +): Promise { + const candidates = [ + path.relative(repoRoot, config.configPath), + ...config.config.scenarioModules, + ...Object.values(config.config.authProfiles).map((profile) => profile.storageState), + ...changedPaths, + ]; + const normalized = [...new Set(candidates)].sort(); + const digest = createHash('sha256'); + digest.update(config.hash).update('\0').update(manifest.manifestHash).update('\0'); + let totalBytes = 0; + + for (const relativePath of normalized) { + const absolutePath = path.resolve(repoRoot, relativePath); + if (absolutePath !== repoRoot && !absolutePath.startsWith(`${repoRoot}${path.sep}`)) { + throw new VerificationRunError( + 'source_stale', + `Source path escapes repository: ${relativePath}` + ); + } + digest.update(relativePath).update('\0'); + try { + const resolved = await realpath(absolutePath); + if (resolved !== repoRoot && !resolved.startsWith(`${repoRoot}${path.sep}`)) { + throw new VerificationRunError( + 'source_stale', + `Source path resolves outside repository: ${relativePath}` + ); + } + const metadata = await stat(resolved); + if (!metadata.isFile()) { + digest.update(`non-file:${metadata.mode}`).update('\0'); + continue; + } + if ( + metadata.size > MAX_HASHED_FILE_BYTES || + totalBytes + metadata.size > MAX_HASHED_RUN_BYTES + ) { + throw new VerificationRunError( + 'source_stale', + `Source hashing budget exceeded at ${relativePath}` + ); + } + const bytes = await readFile(resolved); + totalBytes += bytes.byteLength; + digest.update(String(bytes.byteLength)).update('\0').update(bytes).update('\0'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + digest.update('missing\0'); + continue; + } + throw error; + } + } + return digest.digest('hex'); +} diff --git a/apps/desktop/src/lib/warm-verification/lifecycle.integration.test.ts b/apps/desktop/src/lib/warm-verification/lifecycle.integration.test.ts new file mode 100644 index 0000000..604d8ed --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/lifecycle.integration.test.ts @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { after, describe, it } from 'node:test'; + +import { collectWorktreeChangeSet } from './change-set'; +import { runVerifyCli } from './cli'; +import { type DaemonRequest, type DaemonResponse, VERIFY_PROTOCOL_VERSION } from './contracts'; +import { VerificationDaemonHost } from './daemon-host'; +import { requestDaemon } from './ipc'; +import { resolveVerifyRuntimePaths, type VerifyRuntimePaths } from './runtime-paths'; +import { VerifySingletonError } from './singleton'; + +const execFileAsync = promisify(execFile); +const fixtures = new Set(); + +describe('warm verification lifecycle integration', { timeout: 45_000 }, () => { + it('owns one daemon, cancels an active run on CLI shutdown, and leaves no orphan', async () => { + const fixture = await createLifecycleFixture(); + fixtures.add(fixture); + + try { + assert.equal(await runVerifyCli(['daemon', 'start', '--repo', fixture.root, '--json']), 0); + + const started = await request(fixture.paths, { type: 'health' }); + assert.equal(started.type, 'health'); + if (started.type !== 'health') assert.fail('expected warm daemon health'); + assert.equal(started.health.warm, true); + assert.ok(started.health.daemon_pid > 0); + assert.ok((started.health.server.pid ?? 0) > 0); + fixture.daemonPid = started.health.daemon_pid; + fixture.serverPid = started.health.server.pid ?? undefined; + + assert.equal(await runVerifyCli(['daemon', 'status', '--repo', fixture.root, '--json']), 0); + + await assert.rejects( + VerificationDaemonHost.start(fixture.root), + (error) => error instanceof VerifySingletonError && error.code === 'already_running' + ); + + const collected = await collectWorktreeChangeSet(fixture.root); + assert.deepEqual(collected.changeSet.changed_paths, ['src/app.ts']); + const activeRun = request( + fixture.paths, + { + type: 'verify_changed', + run_id: 'lifecycle-active-run', + change_set: collected.changeSet, + options: { detailed_capture: false, batch_timeout_ms: 20_000 }, + }, + 25_000 + ); + await waitForActiveRun(fixture.paths, 'lifecycle-active-run'); + + assert.equal(await runVerifyCli(['daemon', 'stop', '--repo', fixture.root, '--json']), 0); + + const runResponse = await activeRun; + assert.equal(runResponse.type, 'verify_result'); + if (runResponse.type !== 'verify_result') assert.fail('expected cancelled verify result'); + assert.equal(runResponse.result.outcome, 'no_confidence'); + assert.equal(runResponse.result.cancellation.state, 'completed'); + assert.ok(runResponse.result.limitations.some((entry) => entry.code === 'cancelled')); + + await waitForProcessExit(started.health.daemon_pid); + await waitForProcessExit(started.health.server.pid ?? 0); + await assert.rejects(access(fixture.paths.socketPath), isNotFound); + await assert.rejects(access(fixture.paths.leasePath), isNotFound); + assert.equal(await listenerReachable(fixture.port), false); + assert.equal(await runVerifyCli(['daemon', 'status', '--repo', fixture.root, '--json']), 3); + + fixture.daemonPid = undefined; + fixture.serverPid = undefined; + } finally { + await fixture.cleanup(); + fixtures.delete(fixture); + } + }); +}); + +after(async () => { + await Promise.all([...fixtures].map((fixture) => fixture.cleanup())); +}); + +interface LifecycleFixture { + root: string; + paths: VerifyRuntimePaths; + port: number; + daemonPid?: number; + serverPid?: number; + cleanup(): Promise; +} + +async function createLifecycleFixture(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'cv-lifecycle-integration-')); + const port = await reservePort(); + const paths = await resolveVerifyRuntimePaths(root); + const fixture: LifecycleFixture = { + root, + paths, + port, + cleanup: async () => { + await bestEffortStop(paths); + await stopOwnedProcess(fixture.serverPid); + await stopOwnedProcess(fixture.daemonPid); + await rm(root, { recursive: true, force: true }); + }, + }; + + await mkdir(path.join(root, '.codevetter', 'auth'), { recursive: true }); + await mkdir(path.join(root, 'verify'), { recursive: true }); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, '.codevetter', 'verify.yaml'), configSource(port)); + await writeFile( + path.join(root, '.codevetter', 'auth', 'developer.json'), + `${JSON.stringify({ cookies: [], origins: [] })}\n` + ); + await writeFile(path.join(root, 'verify', 'server.mjs'), serverSource(port)); + await writeFile(path.join(root, 'verify', 'scenarios.mjs'), scenarioSource); + await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 1;\n'); + await git(root, ['init', '--quiet']); + await git(root, ['config', 'user.email', 'verify@example.invalid']); + await git(root, ['config', 'user.name', 'Warm Verify Test']); + await git(root, ['add', '.']); + await git(root, ['commit', '--quiet', '-m', 'fixture baseline']); + await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 2;\n'); + return fixture; +} + +function configSource(port: number): string { + return `${JSON.stringify( + { + version: 1, + target: { + command: [process.execPath, 'verify/server.mjs'], + cwd: '.', + readinessUrl: `http://127.0.0.1:${port}/health`, + baseUrl: `http://127.0.0.1:${port}`, + allowedEnv: [], + hmrSettleMs: 0, + shutdownGraceMs: 500, + }, + scenarioModules: ['verify/scenarios.mjs'], + authProfiles: { developer: { storageState: '.codevetter/auth/developer.json' } }, + capabilities: [{ id: 'shell', paths: ['src/**'], scenarios: ['hang-until-stop'] }], + mandatorySmoke: ['hang-until-stop'], + sharedInfrastructure: { + paths: ['package.json'], + fallbackScenarios: ['hang-until-stop'], + }, + network: { + firstPartyOrigins: [`http://127.0.0.1:${port}`], + allowedFirstPartyRequests: ['GET /**'], + blockThirdParty: true, + allowedThirdPartyOrigins: [], + }, + retention: { + directory: '.codevetter/artifacts', + maxRuns: 10, + maxBytes: 1_048_576, + maxAgeDays: 1, + }, + budgets: { + parallelism: 1, + actionMs: 2_000, + scenarioMs: 30_000, + batchMs: 30_000, + slowInteractionMs: 500, + }, + }, + null, + 2 + )}\n`; +} + +function serverSource(port: number): string { + return `import http from 'node:http'; +const html = \`ready\`; +const server = http.createServer((request, response) => { + response.writeHead(200, { 'content-type': request.url === '/health' ? 'text/plain' : 'text/html' }); + response.end(request.url === '/health' ? 'ok' : html); +}); +server.listen(${port}, '127.0.0.1'); +const stop = () => server.close(() => process.exit(0)); +process.once('SIGINT', stop); +process.once('SIGTERM', stop); +`; +} + +const scenarioSource = `export const scenarioModule = { + id: 'lifecycle-module', + scenarios: [{ + schemaVersion: 1, + id: 'hang-until-stop', + capabilityIds: ['shell'], + route: '/', + authProfileId: 'developer', + stateName: 'ready', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: {}, + timeouts: { actionMs: 2000, scenarioMs: 30000 }, + actions: [{ id: 'wait', kind: 'wait', description: 'Wait for daemon shutdown' }], + assertions: [{ id: 'cancelled', kind: 'custom', description: 'Shutdown cancels the run' }], + async run({ signal }) { + await new Promise((resolve, reject) => { + const abort = () => reject(signal.reason ?? new DOMException('cancelled', 'AbortError')); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }); + } + }] +}; +`; + +async function request( + paths: VerifyRuntimePaths, + requestBody: DaemonRequest, + responseTimeoutMs = 2_000 +): Promise { + const envelope = await requestDaemon( + paths.socketPath, + { + protocol_version: VERIFY_PROTOCOL_VERSION, + request_id: `integration-${crypto.randomUUID()}`, + sent_at: new Date().toISOString(), + request: requestBody, + }, + { responseTimeoutMs } + ); + return envelope.response; +} + +async function waitForActiveRun(paths: VerifyRuntimePaths, runId: string): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const health = await request(paths, { type: 'health' }); + if (health.type === 'health' && health.health.active_run_ids.includes(runId)) return; + await delay(25); + } + assert.fail(`run ${runId} did not become active`); +} + +async function waitForProcessExit(pid: number): Promise { + if (pid < 1) assert.fail('expected an owned process PID'); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return; + await delay(25); + } + assert.fail(`owned process ${pid} remained alive after daemon shutdown`); +} + +function listenerReachable(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + const finish = (reachable: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(reachable); + }; + socket.setTimeout(250); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.once('timeout', () => finish(false)); + }); +} + +async function reservePort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') assert.fail('could not reserve a TCP port'); + const port = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + return port; +} + +async function git(root: string, args: string[]): Promise { + await execFileAsync('git', ['-C', root, ...args], { timeout: 5_000 }); +} + +async function bestEffortStop(paths: VerifyRuntimePaths): Promise { + try { + await request(paths, { type: 'shutdown', grace_ms: 500 }, 1_000); + await delay(100); + } catch { + // The daemon may already be gone; owned-PID cleanup below is the final safety net. + } +} + +async function stopOwnedProcess(pid?: number): Promise { + if (!pid || !isProcessAlive(pid)) return; + try { + process.kill(pid, 'SIGTERM'); + } catch { + return; + } + const deadline = Date.now() + 1_000; + while (Date.now() < deadline && isProcessAlive(pid)) await delay(20); + if (isProcessAlive(pid)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Process exited between the liveness check and signal. + } + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function isNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/package.json b/package.json index 6abd114..2d41b0f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "bench:new-case": "node scripts/create-benchmark-case.mjs", "test:benchmark": "node --test scripts/run-catch-rate-benchmark.test.mjs", "test:coverage": "pnpm --filter @code-reviewer/desktop test:coverage", + "verify": "pnpm --filter @code-reviewer/desktop verify", "prepare": "husky || true", "lint": "biome check .", "format": "biome format --write .", From f1b7101095f7029a473779390680c901ef3d9040 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:27:48 +0530 Subject: [PATCH 010/141] feat(verify): qualify target-owned browser state --- .codevetter/verify.yaml | 47 +++ .gitignore | 3 + apps/desktop/package.json | 1 + .../src/lib/verification-state-bridge.test.ts | 55 +++ .../src/lib/verification-state-bridge.ts | 87 ++++ .../warm-verification/msw-app-fixture.test.ts | 139 +++++++ .../qualification-contract.test.ts | 60 +++ apps/desktop/src/main.tsx | 3 + .../fixtures/warm-verification/README.md | 55 +++ .../auth/local-developer.json | 4 + .../baseline-2026-07-15.json | 74 ++++ .../warm-verification/benchmark-manifest.json | 385 ++++++++++++++++++ .../warm-verification/msw-app/bridge.ts | 97 +++++ .../warm-verification/msw-app/handlers.ts | 41 ++ .../warm-verification/msw-app/index.ts | 11 + .../warm-verification/msw-app/states.ts | 95 +++++ apps/desktop/verify/scenarios.mjs | 35 ++ pnpm-lock.yaml | 264 ++++++++++++ 18 files changed, 1456 insertions(+) create mode 100644 .codevetter/verify.yaml create mode 100644 apps/desktop/src/lib/verification-state-bridge.test.ts create mode 100644 apps/desktop/src/lib/verification-state-bridge.ts create mode 100644 apps/desktop/src/lib/warm-verification/msw-app-fixture.test.ts create mode 100644 apps/desktop/src/lib/warm-verification/qualification-contract.test.ts create mode 100644 apps/desktop/tests/fixtures/warm-verification/README.md create mode 100644 apps/desktop/tests/fixtures/warm-verification/auth/local-developer.json create mode 100644 apps/desktop/tests/fixtures/warm-verification/baseline-2026-07-15.json create mode 100644 apps/desktop/tests/fixtures/warm-verification/benchmark-manifest.json create mode 100644 apps/desktop/tests/fixtures/warm-verification/msw-app/bridge.ts create mode 100644 apps/desktop/tests/fixtures/warm-verification/msw-app/handlers.ts create mode 100644 apps/desktop/tests/fixtures/warm-verification/msw-app/index.ts create mode 100644 apps/desktop/tests/fixtures/warm-verification/msw-app/states.ts create mode 100644 apps/desktop/verify/scenarios.mjs diff --git a/.codevetter/verify.yaml b/.codevetter/verify.yaml new file mode 100644 index 0000000..8240beb --- /dev/null +++ b/.codevetter/verify.yaml @@ -0,0 +1,47 @@ +version: 1 +target: + command: [pnpm, --dir, apps/desktop, exec, vite, --host, 127.0.0.1, --port, "1420", --strictPort] + cwd: . + readinessUrl: http://127.0.0.1:1420/ + baseUrl: http://127.0.0.1:1420 + allowedEnv: [] + hmrSettleMs: 150 + shutdownGraceMs: 2000 +scenarioModules: + - apps/desktop/verify/scenarios.mjs +authProfiles: + local-developer: + storageState: apps/desktop/tests/fixtures/warm-verification/auth/local-developer.json +capabilities: + - id: app-shell + paths: + - apps/desktop/src/App.tsx + - apps/desktop/src/main.tsx + - apps/desktop/src/components/sidebar.tsx + - apps/desktop/src/components/persistent-routes.tsx + scenarios: [shell-navigation] +mandatorySmoke: [shell-navigation] +sharedInfrastructure: + paths: + - apps/desktop/src/** + - apps/desktop/vite.config.ts + - apps/desktop/package.json + - package.json + - pnpm-lock.yaml + fallbackScenarios: [shell-navigation] +network: + firstPartyOrigins: [http://127.0.0.1:1420] + allowedFirstPartyRequests: [GET /**] + blockThirdParty: true + allowedThirdPartyOrigins: [] +retention: + directory: .codevetter/verify-artifacts + maxRuns: 20 + maxBytes: 104857600 + maxAgeDays: 14 +budgets: + parallelism: 4 + actionMs: 3000 + scenarioMs: 10000 + batchMs: 30000 + slowInteractionMs: 750 diff --git a/.gitignore b/.gitignore index 6cd5d26..b6c4a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -61,5 +61,8 @@ apps/desktop/coverage/ .fallow/ cache.bin +# Warm local verification artifacts (redacted but intentionally ephemeral) +.codevetter/verify-artifacts/ + # codex CLI project dir .codex/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f10718b..ff6be2e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -64,6 +64,7 @@ "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.20", "c8": "^11.0.0", + "msw": "2.15.0", "postcss": "^8.5.0", "tailwindcss": "^3.4.0", "tailwindcss-animate": "^1.0.7", diff --git a/apps/desktop/src/lib/verification-state-bridge.test.ts b/apps/desktop/src/lib/verification-state-bridge.test.ts new file mode 100644 index 0000000..7ad7c85 --- /dev/null +++ b/apps/desktop/src/lib/verification-state-bridge.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + initializeVerificationStateBridge, + type VerificationWindow, +} from './verification-state-bridge'; + +function host(stateName = 'shell-navigation-ready'): VerificationWindow { + return { + __CODEVETTER_VERIFY__: { + protocolVersion: 1 as const, + runId: 'run-1', + scenarioId: 'shell-smoke', + stateName, + frozenTime: '2026-07-15T10:00:00.000Z', + flags: {}, + }, + __CODEVETTER_VERIFY_STATE__: undefined, + }; +} + +describe('CodeVetter verification state bridge', () => { + it('does nothing during normal application startup', async () => { + const target = {}; + assert.equal(await initializeVerificationStateBridge(target), false); + assert.deepEqual(target, {}); + }); + + it('acknowledges only an installed named state with exact run identity', async () => { + const target = host(); + assert.equal(await initializeVerificationStateBridge(target), true); + assert.deepEqual(target.__CODEVETTER_VERIFY_STATE__, { + protocolVersion: 1, + runId: 'run-1', + scenarioId: 'shell-smoke', + status: 'ready', + }); + }); + + it('fails closed for unknown states and installer errors', async () => { + const unknown = host('unknown-state'); + await initializeVerificationStateBridge(unknown); + assert.equal(unknown.__CODEVETTER_VERIFY_STATE__?.status, 'error'); + + const failed = host('fixture-error'); + await initializeVerificationStateBridge(failed, { + 'fixture-error': () => { + throw new Error('secret backend detail'); + }, + }); + assert.equal(failed.__CODEVETTER_VERIFY_STATE__?.status, 'error'); + assert.doesNotMatch(failed.__CODEVETTER_VERIFY_STATE__?.message ?? '', /secret backend detail/); + }); +}); diff --git a/apps/desktop/src/lib/verification-state-bridge.ts b/apps/desktop/src/lib/verification-state-bridge.ts new file mode 100644 index 0000000..7efa19a --- /dev/null +++ b/apps/desktop/src/lib/verification-state-bridge.ts @@ -0,0 +1,87 @@ +export interface VerificationRequest { + protocolVersion: 1; + runId: string; + scenarioId: string; + stateName: string; + frozenTime: string; + flags: Readonly>; +} + +export interface VerificationStatus { + protocolVersion: 1; + runId: string; + scenarioId: string; + status: 'requested' | 'ready' | 'error'; + message?: string; +} + +export interface VerificationWindow { + __CODEVETTER_VERIFY__?: VerificationRequest; + __CODEVETTER_VERIFY_STATE__?: VerificationStatus; +} + +type StateInstaller = (request: VerificationRequest) => void | Promise; + +const codevetterStates: Readonly> = Object.freeze({ + 'shell-navigation-ready': () => undefined, +}); + +export async function initializeVerificationStateBridge( + host: VerificationWindow = window as unknown as VerificationWindow, + installers: Readonly> = codevetterStates +): Promise { + const request = host.__CODEVETTER_VERIFY__; + if (!request) return false; + + const base: Omit = { + protocolVersion: 1, + runId: request.runId, + scenarioId: request.scenarioId, + }; + if (!validRequest(request)) { + host.__CODEVETTER_VERIFY_STATE__ = { + ...base, + status: 'error', + message: 'Verification request is invalid', + }; + return true; + } + + const install = installers[request.stateName]; + if (!install) { + host.__CODEVETTER_VERIFY_STATE__ = { + ...base, + status: 'error', + message: `Unsupported CodeVetter verification state: ${request.stateName}`, + }; + return true; + } + + try { + await install(request); + host.__CODEVETTER_VERIFY_STATE__ = { ...base, status: 'ready' }; + } catch { + host.__CODEVETTER_VERIFY_STATE__ = { + ...base, + status: 'error', + message: `Could not install CodeVetter verification state: ${request.stateName}`, + }; + } + return true; +} + +function validRequest(request: VerificationRequest): boolean { + return ( + request.protocolVersion === 1 && + stableId(request.runId) && + stableId(request.scenarioId) && + stableId(request.stateName) && + !Number.isNaN(Date.parse(request.frozenTime)) && + typeof request.flags === 'object' && + request.flags !== null + ); +} + +function stableId(value: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value); +} diff --git a/apps/desktop/src/lib/warm-verification/msw-app-fixture.test.ts b/apps/desktop/src/lib/warm-verification/msw-app-fixture.test.ts new file mode 100644 index 0000000..3f7db2a --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/msw-app-fixture.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { setupServer } from 'msw/node'; + +import { + installTargetOwnedBridge, + type VerificationTarget, +} from '../../../tests/fixtures/warm-verification/msw-app/bridge'; +import { createFixtureHandlers } from '../../../tests/fixtures/warm-verification/msw-app/handlers'; +import { + FixtureStateRegistry, + namedStateNames, + verificationHeadersFor, + type VerificationStateRequest, +} from '../../../tests/fixtures/warm-verification/msw-app/states'; + +function request( + runId: string, + scenarioId: string, + stateName = 'funded-empty-portfolio' +): VerificationStateRequest { + return { + protocolVersion: 1, + runId, + scenarioId, + stateName, + frozenTime: '2026-07-15T10:00:00.000Z', + flags: { recurringInvestments: true }, + }; +} + +describe('target-owned state bridge', () => { + it('reads the injected request and publishes the exact ready identity after MSW starts', async () => { + const target: VerificationTarget = { __CODEVETTER_VERIFY__: request('run-a', 'scenario-a') }; + let starts = 0; + const installed = await installTargetOwnedBridge(target, new FixtureStateRegistry(), { + async start() { + starts += 1; + return {} as ServiceWorkerRegistration; + }, + }); + + assert.equal(starts, 1); + assert.equal(installed?.clientId, 'run-a/scenario-a'); + assert.deepEqual(target.__CODEVETTER_VERIFY_STATE__, { + protocolVersion: 1, + runId: 'run-a', + scenarioId: 'scenario-a', + status: 'ready', + }); + }); + + it('rejects an unknown named state without starting MSW', async () => { + const target: VerificationTarget = { + __CODEVETTER_VERIFY__: request('run-unknown', 'scenario-unknown', 'not-a-state'), + }; + let starts = 0; + const installed = await installTargetOwnedBridge(target, new FixtureStateRegistry(), { + async start() { + starts += 1; + return {} as ServiceWorkerRegistration; + }, + }); + + assert.equal(installed, null); + assert.equal(starts, 0); + assert.deepEqual(target.__CODEVETTER_VERIFY_STATE__, { + protocolVersion: 1, + runId: 'run-unknown', + scenarioId: 'scenario-unknown', + status: 'error', + message: 'Unknown verification state: not-a-state', + }); + }); +}); + +describe('client-scoped MSW named state', () => { + const registry = new FixtureStateRegistry(); + let server: ReturnType; + + before(() => { + server = setupServer(...createFixtureHandlers(registry)); + server.listen({ onUnhandledRequest: 'error' }); + }); + + after(() => server.close()); + + it('provides at least two deterministic named states', async () => { + assert.deepEqual(namedStateNames, ['funded-empty-portfolio', 'funded-existing-portfolio']); + const empty = request('run-empty', 'scenario-empty'); + const existing = request('run-existing', 'scenario-existing', 'funded-existing-portfolio'); + registry.install(empty); + registry.install(existing); + + const [emptyResponse, existingResponse] = await Promise.all([ + fetch('http://fixture.local/api/portfolio', { headers: verificationHeadersFor(empty) }), + fetch('http://fixture.local/api/portfolio', { headers: verificationHeadersFor(existing) }), + ]); + const emptyState = (await emptyResponse.json()) as { investments: unknown[] }; + const existingState = (await existingResponse.json()) as { investments: unknown[] }; + assert.equal(emptyState.investments.length, 0); + assert.equal(existingState.investments.length, 1); + }); + + it('isolates mutations for two clients installed from the same named state', async () => { + const first = request('run-first', 'scenario-shared'); + const second = request('run-second', 'scenario-shared'); + registry.install(first); + registry.install(second); + + const mutation = await fetch('http://fixture.local/api/recurring-investments', { + method: 'POST', + headers: verificationHeadersFor(first), + body: JSON.stringify({ amountCents: 50_000 }), + }); + assert.equal(mutation.status, 201); + + const [firstResponse, secondResponse] = await Promise.all([ + fetch('http://fixture.local/api/portfolio', { headers: verificationHeadersFor(first) }), + fetch('http://fixture.local/api/portfolio', { headers: verificationHeadersFor(second) }), + ]); + const firstState = (await firstResponse.json()) as { + investments: unknown[]; + mutationCount: number; + }; + const secondState = (await secondResponse.json()) as { + investments: unknown[]; + mutationCount: number; + }; + assert.deepEqual( + { investments: firstState.investments.length, mutations: firstState.mutationCount }, + { investments: 1, mutations: 1 } + ); + assert.deepEqual( + { investments: secondState.investments.length, mutations: secondState.mutationCount }, + { investments: 0, mutations: 0 } + ); + }); +}); diff --git a/apps/desktop/src/lib/warm-verification/qualification-contract.test.ts b/apps/desktop/src/lib/warm-verification/qualification-contract.test.ts new file mode 100644 index 0000000..b139c13 --- /dev/null +++ b/apps/desktop/src/lib/warm-verification/qualification-contract.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +interface BenchmarkScenario { + id: string; + route: string; + mockState: string; + interactions: string[]; + assertions: string[]; + observationProfile: string; + screenshotCheckpoints: string[]; +} + +describe('warm verification qualification boundary', () => { + it('keeps the checked-in benchmark at exactly 20 meaningful deterministic scenarios', async () => { + const manifestPath = path.resolve( + process.cwd(), + 'tests/fixtures/warm-verification/benchmark-manifest.json' + ); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + scenarios: BenchmarkScenario[]; + }; + const ids = manifest.scenarios.map((scenario) => scenario.id); + + assert.equal(manifest.scenarios.length, 20); + assert.equal(new Set(ids).size, ids.length); + for (const scenario of manifest.scenarios) { + assert.match(scenario.id, /^[a-z0-9]+(?:-[a-z0-9]+)+$/); + assert.ok(scenario.route.startsWith('/'), `${scenario.id} must use direct route entry`); + assert.ok(scenario.mockState.length > 0, `${scenario.id} must name deterministic state`); + assert.ok(scenario.interactions.length >= 2, `${scenario.id} needs multiple interactions`); + assert.ok(scenario.assertions.length > 0, `${scenario.id} needs scenario assertions`); + assert.equal(scenario.observationProfile, 'strict-ui'); + assert.ok( + scenario.screenshotCheckpoints.length > 0, + `${scenario.id} needs a visual checkpoint` + ); + } + }); + + it('keeps production warm execution disconnected from model and browser-agent modules', async () => { + const directory = path.resolve(process.cwd(), 'src/lib/warm-verification'); + const productionFiles = (await readdir(directory)) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts')) + .sort(); + const forbidden = /(?:anthropic|openai|openrouter|review-service|browser-agent|agent\/)/i; + + for (const file of productionFiles) { + const source = await readFile(path.join(directory, file), 'utf8'); + const specifiers = [...source.matchAll(/\bfrom\s+['"]([^'"]+)['"]/g)].map( + (match) => match[1] ?? '' + ); + for (const specifier of specifiers) { + assert.doesNotMatch(specifier, forbidden, `${file} imports a model-capable boundary`); + } + } + }); +}); diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 7c8b8a2..f22a5f0 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -5,6 +5,9 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; +import { initializeVerificationStateBridge } from './lib/verification-state-bridge'; + +void initializeVerificationStateBridge(); class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { state = { error: null as Error | null }; diff --git a/apps/desktop/tests/fixtures/warm-verification/README.md b/apps/desktop/tests/fixtures/warm-verification/README.md new file mode 100644 index 0000000..3569061 --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/README.md @@ -0,0 +1,55 @@ +# Warm-verification benchmark corpus + +`benchmark-manifest.json` is the representative 20-scenario corpus for qualifying +CodeVetter's warm local verifier. It is deliberately checked in before the final +runtime contract: this file fixes the workload and its quality bar, while the +versioned executable TypeScript schema is specified and tested separately. + +`baseline-2026-07-15.json` records the before-state on the benchmark Mac. It +separates Vite readiness, the first preoptimization one-shot, and five runs with +the server and OS caches warm while proving that the old runner still launches a +new browser each time. It is evidence for prioritization, not a release result. + +The corpus targets CodeVetter's current React routes so the benchmark exercises a +real application shape rather than twenty copies of a toy counter. It remains a +qualification fixture: named mocked states and interactions describe contracts +that the later target-owned state bridge and scenario modules must implement. + +## What makes a scenario meaningful + +Every scenario must have all four properties below. Adding twenty page loads does +not satisfy this benchmark. + +1. **Direct route:** `route` starts at the capability under test. Login and shell + navigation are not repeated as setup. Route navigation is itself the subject + only in `shell-command-palette-navigation`. +2. **Deterministic mocked state:** `mockState` names a client-scoped state owned by + the qualification app. Time, locale, timezone, authentication state, feature + flags, responses, and mutation counters are fixed before application code runs. +3. **Multiple interactions:** `interactions` contains at least two user actions + that change selection, reveal detail, edit state, navigate, or submit a + mutation. Assertions verify the resulting capability, not merely page load. +4. **Automatic observation:** every scenario selects `strict-ui`, which attaches + before navigation and checks runtime and console errors, request failures, + unexpected first-party calls, duplicate mutations, route transitions, + interaction timing, accessibility smoke, and deterministic screenshot hashes. + Scenario assertions complement these observers; they do not replace them. + +The fixture includes read, filter, navigation, validation, and mutation flows +across Home, Review, Unpack, Agents, T-Rex, Settings, and the application shell. +Mutation scenarios explicitly require exactly one mutation so the corpus can +catch double-submit regressions. Screenshot checkpoints are named stable states, +not arbitrary captures after every action. + +## Benchmark use + +The release qualification runner must load exactly these 20 stable IDs, install +their named state, and execute them in real Playwright Chromium with zero model or +browser-agent calls. Two warm-up batches are excluded; at least 20 complete warm +batches are recorded, and the p95 of the whole invocation must remain below 30 +seconds on the documented benchmark Mac. Intentional observer-negative fixtures +run separately and must not inflate or weaken this performance sample. + +The manifest is invalid for qualification if an ID is duplicated, a route is not +direct, a state is missing, fewer than two interactions are declared, the strict +observation profile is absent, or the corpus contains other than 20 scenarios. diff --git a/apps/desktop/tests/fixtures/warm-verification/auth/local-developer.json b/apps/desktop/tests/fixtures/warm-verification/auth/local-developer.json new file mode 100644 index 0000000..914e926 --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/auth/local-developer.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} diff --git a/apps/desktop/tests/fixtures/warm-verification/baseline-2026-07-15.json b/apps/desktop/tests/fixtures/warm-verification/baseline-2026-07-15.json new file mode 100644 index 0000000..ce1791c --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/baseline-2026-07-15.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": "1.0.0", + "capturedAt": "2026-07-15", + "purpose": "Before-state for replacing CodeVetter's spawn-per-run Synthetic QA path with verifyd. This is not the release qualification result.", + "machine": { + "model": "MacBook Pro Mac17,8", + "chip": "Apple M5 Pro", + "cpuCores": 18, + "memoryGiB": 48, + "os": "macOS 27.0 (26A5378j)" + }, + "target": { + "repositoryHead": "f6ec5bd314963ea492877cb07503fe19fc61b2bc", + "baseUrl": "http://127.0.0.1:1420", + "route": "/", + "viteVersion": "6.4.2", + "viteCommand": [ + "pnpm", + "exec", + "vite", + "--host", + "127.0.0.1", + "--port", + "1420", + "--strictPort" + ], + "viteReadyMs": 94, + "hmrSettled": true, + "viteRssBytesAfterWarmup": 560398336 + }, + "browser": { + "playwrightVersion": "1.59.1", + "engine": "chromium", + "revision": "1217", + "headless": true + }, + "coldOneShot": { + "sampleCount": 1, + "serverWarm": true, + "dependencyPreoptimizationOccurred": true, + "runnerDurationMs": 1012, + "wallDurationMs": 1170, + "runnerMaxRssBytes": 92192768, + "passingArtifactBytes": 0, + "notes": "The original one-shot runner did not expose per-stage timings for this first preoptimization sample. The additive timing fields were installed before the recorded warm samples." + }, + "warmServerOneShotRunner": { + "sampleCount": 5, + "browserPersistentAcrossRuns": false, + "serverPersistentAcrossRuns": true, + "runnerDurationsMs": [189, 198, 201, 196, 199], + "wallDurationsMs": [300, 310, 320, 310, 310], + "medianRunnerDurationMs": 198, + "maxRunnerDurationMs": 201, + "medianWallDurationMs": 310, + "maxWallDurationMs": 320, + "medianStageTimingsMs": { + "browserLaunch": 60.701, + "contextCreate": 3.372, + "pageCreate": 30.408, + "navigation": 60.397, + "assertion": 41.56 + }, + "runnerRssBytes": [91815936, 92995584, 92684288, 92864512, 93650944], + "maxRunnerRssBytes": 93650944, + "passingArtifactBytes": 0 + }, + "interpretation": [ + "The existing path launches and closes Chromium for every invocation even when the frontend server is warm.", + "Fresh context creation is approximately three milliseconds and should remain the isolation boundary.", + "Browser persistence removes about sixty milliseconds in this warmed-cache smoke, but deterministic direct state and bounded interactions remain the larger product-level wins.", + "The current smoke does not represent the required 20 meaningful scenarios and cannot be used to claim the under-30-second release gate." + ] +} diff --git a/apps/desktop/tests/fixtures/warm-verification/benchmark-manifest.json b/apps/desktop/tests/fixtures/warm-verification/benchmark-manifest.json new file mode 100644 index 0000000..100a30e --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/benchmark-manifest.json @@ -0,0 +1,385 @@ +{ + "benchmarkFixtureVersion": "1.0.0", + "name": "codevetter-warm-local-qualification", + "description": "Representative qualification corpus for a warm local verifier. This fixture describes coverage and is not the final runtime scenario contract.", + "target": { + "kind": "react-web-app", + "browser": "chromium", + "baseUrl": "http://127.0.0.1:1420", + "authProfile": "local-developer", + "frozenTime": "2026-07-15T09:30:00.000Z", + "locale": "en-IN", + "timezone": "Asia/Kolkata" + }, + "observationProfiles": { + "strict-ui": { + "runtimeErrors": "fail", + "consoleErrors": "fail", + "failedRequests": "fail", + "unexpectedFirstPartyCalls": "fail", + "duplicateMutations": "fail", + "routeTransitions": "record-and-validate", + "interactionTiming": "record-and-budget", + "accessibility": "smoke", + "screenshots": "hash-checkpoints" + } + }, + "scenarios": [ + { + "id": "home-usage-range-and-provider", + "capability": "home-usage", + "route": "/", + "mockState": "home-usage-multi-provider", + "intent": "Usage totals and charts respond coherently to range and provider filters.", + "interactions": [ + "select the seven-day range", + "toggle the OpenAI provider series", + "open one daily usage detail" + ], + "assertions": [ + "the displayed total matches the deterministic seven-day fixture", + "the provider series and daily detail agree" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["filtered-usage"] + }, + { + "id": "home-session-filter-and-detail", + "capability": "home-sessions", + "route": "/", + "mockState": "home-sessions-mixed-status", + "intent": "A developer can narrow recent sessions and inspect the selected result.", + "interactions": [ + "filter sessions to failed runs", + "select the newest failed session", + "return to the complete session list" + ], + "assertions": [ + "only deterministic failed sessions appear while filtered", + "the selected session detail preserves its provider and timestamp" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["failed-session-detail"] + }, + { + "id": "home-source-health-refresh", + "capability": "home-source-health", + "route": "/", + "mockState": "home-source-health-degraded", + "intent": "A degraded source can be refreshed without corrupting the other source summaries.", + "interactions": [ + "expand the degraded source", + "request a source refresh", + "collapse the refreshed source" + ], + "assertions": [ + "exactly one refresh mutation is recorded", + "the refreshed source becomes healthy while unrelated counts stay stable" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["source-health-refreshed"] + }, + { + "id": "review-change-source-and-file", + "capability": "review-change-selection", + "route": "/review", + "mockState": "review-worktree-three-files", + "intent": "The Review workspace preserves change-source and file selection correctly.", + "interactions": [ + "switch from worktree changes to staged changes", + "select the settings file", + "switch back to the first changed file" + ], + "assertions": [ + "the changed-file list reflects the selected source", + "the code pane and file metadata refer to the same file" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["selected-review-file"] + }, + { + "id": "review-finding-filter-and-resolution", + "capability": "review-findings", + "route": "/review", + "mockState": "review-findings-mixed-severity", + "intent": "Finding filters and resolution state remain synchronized with the active finding.", + "interactions": [ + "filter findings to high severity", + "open the first high-severity finding", + "mark the finding resolved" + ], + "assertions": [ + "the active finding has high severity", + "exactly one resolution mutation occurs and the queue count decrements" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["resolved-finding"] + }, + { + "id": "review-verification-target-edit", + "capability": "review-verification", + "route": "/review", + "mockState": "review-verification-ready", + "intent": "A verification target can be edited and restored without starting a model-driven run.", + "interactions": [ + "open the verification controls", + "change the target route to /settings", + "restore the target route to /review" + ], + "assertions": [ + "the route field preserves each deterministic edit", + "no verification or model-provider mutation is made" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["verification-target"] + }, + { + "id": "review-evidence-expand-and-copy", + "capability": "review-evidence", + "route": "/review", + "mockState": "review-evidence-complete", + "intent": "Stored verification evidence can be inspected and copied from Review.", + "interactions": [ + "expand the latest verification evidence", + "open its runtime observation section", + "copy the evidence summary" + ], + "assertions": [ + "the expanded evidence belongs to the selected review", + "the copied summary contains the fixture outcome and provenance" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["expanded-evidence"] + }, + { + "id": "unpack-inventory-language-filter", + "capability": "unpack-inventory", + "route": "/unpack?section=inventory", + "mockState": "unpack-inventory-polyglot", + "intent": "Repository inventory filters expose the right deterministic files and summaries.", + "interactions": [ + "expand the language summary", + "filter inventory to TypeScript", + "open one TypeScript source file" + ], + "assertions": [ + "only TypeScript fixture files remain after filtering", + "the opened source belongs to the selected language group" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["typescript-inventory"] + }, + { + "id": "unpack-activity-commit-selection", + "capability": "unpack-activity", + "route": "/unpack?section=activity", + "mockState": "unpack-activity-linear-history", + "intent": "Commit history selection updates its file and verification evidence consistently.", + "interactions": [ + "select the second commit in history", + "expand its changed-file list", + "open the associated verification evidence" + ], + "assertions": [ + "commit metadata and changed files come from the same fixture commit", + "the evidence provenance points to that commit" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["commit-evidence"] + }, + { + "id": "unpack-graph-node-neighborhood", + "capability": "unpack-structural-graph", + "route": "/unpack?section=structure", + "mockState": "unpack-graph-connected-components", + "intent": "Selecting and filtering a graph node presents a consistent local neighborhood.", + "interactions": [ + "filter graph entities to components", + "select the Review workspace node", + "expand its incoming relationships" + ], + "assertions": [ + "the details panel identifies the selected node", + "every displayed relationship is present in the deterministic graph fixture" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["graph-neighborhood"] + }, + { + "id": "unpack-graph-history-slider", + "capability": "unpack-graph-history", + "route": "/unpack?section=structure", + "mockState": "unpack-graph-three-releases", + "intent": "Walking repository history reconstructs the graph at stable release boundaries.", + "interactions": [ + "move the history position from latest to the middle release", + "select a node added in the middle release", + "move back to the latest release" + ], + "assertions": [ + "the middle release graph contains its added node and exact edge count", + "returning to latest restores the latest graph identity" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["middle-release-graph", "latest-release-graph"] + }, + { + "id": "unpack-quality-risk-drilldown", + "capability": "unpack-quality", + "route": "/unpack?section=quality", + "mockState": "unpack-quality-ranked-risks", + "intent": "Quality-risk ranking can be narrowed and traced back to source evidence.", + "interactions": [ + "filter risks to high confidence", + "open the highest-ranked risk", + "open its first source-evidence link" + ], + "assertions": [ + "the selected risk is the highest-ranked qualifying fixture", + "the source evidence path and risk explanation remain visible" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["quality-risk-evidence"] + }, + { + "id": "agents-status-filter-and-run", + "capability": "agents-runs", + "route": "/agents", + "mockState": "agents-runs-mixed-status", + "intent": "Agent run status filters lead to the correct deterministic run detail.", + "interactions": [ + "filter runs to completed", + "select the newest completed run", + "expand its output summary" + ], + "assertions": [ + "the selected run is completed and has the expected adapter", + "the output summary belongs to the selected run" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["agent-run-detail"] + }, + { + "id": "agents-run-cancel-confirmation", + "capability": "agents-run-control", + "route": "/agents", + "mockState": "agents-one-running-job", + "intent": "Cancelling an active local run requires confirmation and emits only one mutation.", + "interactions": ["open the running job", "request cancellation", "confirm cancellation"], + "assertions": [ + "exactly one cancellation mutation is recorded", + "the run moves from running to cancelled" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["cancelled-agent-run"] + }, + { + "id": "trex-watcher-configuration", + "capability": "trex-configuration", + "route": "/trex", + "mockState": "trex-idle-valid-repository", + "intent": "Local watcher configuration accepts valid polling and branch inputs.", + "interactions": [ + "set the poll interval to five seconds", + "set the base branch to main", + "enable changed-file watching" + ], + "assertions": [ + "the normalized configuration matches the deterministic inputs", + "no watcher starts before explicit confirmation" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["trex-configured"] + }, + { + "id": "trex-event-filter-and-detail", + "capability": "trex-events", + "route": "/trex", + "mockState": "trex-events-mixed-outcomes", + "intent": "A developer can filter watcher events and inspect a failed event safely.", + "interactions": [ + "filter events to failed outcomes", + "select the newest failed event", + "expand its diagnostic detail" + ], + "assertions": [ + "only failed fixture events appear while filtered", + "the diagnostic detail preserves the event identity and changed paths" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["trex-failed-event"] + }, + { + "id": "settings-provider-form-validation", + "capability": "settings-providers", + "route": "/settings?section=providers", + "mockState": "settings-provider-empty-safe", + "intent": "Provider settings validate editable metadata without persisting credentials.", + "interactions": [ + "select the custom provider", + "enter an invalid base URL", + "replace it with the deterministic valid URL" + ], + "assertions": [ + "invalid input produces an inline validation message", + "valid input clears the message without issuing a save mutation" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["provider-validation"] + }, + { + "id": "settings-rubric-filter-and-preview", + "capability": "settings-rubrics", + "route": "/settings?section=rubrics", + "mockState": "settings-rubrics-mixed-language", + "intent": "Rubric filtering and preview preserve the selected rubric's content.", + "interactions": [ + "filter rubrics to TypeScript", + "select the security rubric", + "expand its rule preview" + ], + "assertions": [ + "the selected rubric belongs to the TypeScript fixture set", + "the preview contains its deterministic rule count" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["rubric-preview"] + }, + { + "id": "settings-memory-search-and-detail", + "capability": "settings-memories", + "route": "/settings?section=memories", + "mockState": "settings-memories-searchable", + "intent": "Local memory search returns the correct evidence-backed detail.", + "interactions": [ + "search memories for graph history", + "select the matching observation", + "expand its source metadata" + ], + "assertions": [ + "the deterministic graph-history observation is returned", + "its source metadata and timestamp match the fixture" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["memory-source-detail"] + }, + { + "id": "shell-command-palette-navigation", + "capability": "app-shell-navigation", + "route": "/", + "mockState": "shell-navigation-ready", + "intent": "Global keyboard navigation reaches a direct destination without stale page state.", + "interactions": [ + "open the command palette with the platform shortcut", + "filter commands to Settings", + "activate the Settings destination" + ], + "assertions": [ + "the final route is /settings", + "the Settings page is visible and the command palette is closed" + ], + "observationProfile": "strict-ui", + "screenshotCheckpoints": ["settings-from-command-palette"] + } + ] +} diff --git a/apps/desktop/tests/fixtures/warm-verification/msw-app/bridge.ts b/apps/desktop/tests/fixtures/warm-verification/msw-app/bridge.ts new file mode 100644 index 0000000..88bc032 --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/msw-app/bridge.ts @@ -0,0 +1,97 @@ +import type { SetupWorker } from 'msw/browser'; + +import { FixtureStateRegistry, type VerificationStateRequest } from './states'; + +export interface VerificationStateStatus { + protocolVersion: 1; + runId: string; + scenarioId: string; + status: 'requested' | 'ready' | 'error'; + message?: string; +} + +export interface VerificationTarget { + __CODEVETTER_VERIFY__?: VerificationStateRequest; + __CODEVETTER_VERIFY_STATE__?: VerificationStateStatus; +} + +export type FixtureWorker = Pick; + +export interface InstalledVerificationState { + clientId: string; + request: VerificationStateRequest; +} + +function exactStatus( + request: Pick, + status: 'ready' | 'error', + message?: string +): VerificationStateStatus { + return message === undefined + ? { + protocolVersion: 1, + runId: request.runId, + scenarioId: request.scenarioId, + status, + } + : { + protocolVersion: 1, + runId: request.runId, + scenarioId: request.scenarioId, + status, + message, + }; +} + +function isRequest(value: unknown): value is VerificationStateRequest { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + candidate.protocolVersion === 1 && + typeof candidate.runId === 'string' && + candidate.runId.length > 0 && + typeof candidate.scenarioId === 'string' && + candidate.scenarioId.length > 0 && + typeof candidate.stateName === 'string' && + candidate.stateName.length > 0 && + typeof candidate.frozenTime === 'string' && + typeof candidate.flags === 'object' && + candidate.flags !== null + ); +} + +export async function installTargetOwnedBridge( + target: VerificationTarget, + registry: FixtureStateRegistry, + worker: FixtureWorker +): Promise { + const request = target.__CODEVETTER_VERIFY__; + if (!isRequest(request)) return null; + + let clientId: string; + try { + clientId = registry.install(request); + } catch (error) { + target.__CODEVETTER_VERIFY_STATE__ = exactStatus( + request, + 'error', + error instanceof Error ? error.message : 'Unknown verification state' + ); + return null; + } + + try { + await worker.start({ onUnhandledRequest: 'error', quiet: true }); + } catch { + registry.remove(clientId); + target.__CODEVETTER_VERIFY_STATE__ = exactStatus( + request, + 'error', + 'MSW verification worker failed to start' + ); + return null; + } + + target.__CODEVETTER_VERIFY_STATE__ = exactStatus(request, 'ready'); + return { clientId, request }; +} diff --git a/apps/desktop/tests/fixtures/warm-verification/msw-app/handlers.ts b/apps/desktop/tests/fixtures/warm-verification/msw-app/handlers.ts new file mode 100644 index 0000000..6f9b32e --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/msw-app/handlers.ts @@ -0,0 +1,41 @@ +import { http, HttpResponse, type RequestHandler } from 'msw'; + +import { FixtureStateRegistry, VERIFY_CLIENT_HEADER } from './states'; + +function clientId(request: Request): string | null { + return request.headers.get(VERIFY_CLIENT_HEADER); +} + +function missingClientResponse() { + return HttpResponse.json( + { error: 'Verification client state is not installed' }, + { status: 428 } + ); +} + +export function createFixtureHandlers(registry: FixtureStateRegistry): RequestHandler[] { + return [ + http.get('*/api/portfolio', ({ request }) => { + const id = clientId(request); + const state = id ? registry.read(id) : null; + return state ? HttpResponse.json(state) : missingClientResponse(); + }), + http.post('*/api/recurring-investments', async ({ request }) => { + const id = clientId(request); + if (!id || !registry.read(id)) return missingClientResponse(); + const body = (await request.json().catch(() => null)) as { amountCents?: unknown } | null; + if ( + typeof body?.amountCents !== 'number' || + !Number.isSafeInteger(body.amountCents) || + body.amountCents <= 0 + ) { + return HttpResponse.json( + { error: 'amountCents must be a positive integer' }, + { status: 400 } + ); + } + const state = registry.createInvestment(id, body.amountCents); + return state ? HttpResponse.json(state, { status: 201 }) : missingClientResponse(); + }), + ]; +} diff --git a/apps/desktop/tests/fixtures/warm-verification/msw-app/index.ts b/apps/desktop/tests/fixtures/warm-verification/msw-app/index.ts new file mode 100644 index 0000000..d8e0740 --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/msw-app/index.ts @@ -0,0 +1,11 @@ +import { setupWorker } from 'msw/browser'; + +import { installTargetOwnedBridge } from './bridge'; +import { createFixtureHandlers } from './handlers'; +import { FixtureStateRegistry } from './states'; + +export async function startQualificationBridge(target: Window = window) { + const registry = new FixtureStateRegistry(); + const worker = setupWorker(...createFixtureHandlers(registry)); + return installTargetOwnedBridge(target, registry, worker); +} diff --git a/apps/desktop/tests/fixtures/warm-verification/msw-app/states.ts b/apps/desktop/tests/fixtures/warm-verification/msw-app/states.ts new file mode 100644 index 0000000..0a414ff --- /dev/null +++ b/apps/desktop/tests/fixtures/warm-verification/msw-app/states.ts @@ -0,0 +1,95 @@ +export interface VerificationStateRequest { + protocolVersion: 1; + runId: string; + scenarioId: string; + stateName: string; + frozenTime: string; + flags: Readonly>; +} + +export interface FixtureInvestment { + id: string; + amountCents: number; +} + +export interface FixtureClientState { + cashCents: number; + investments: FixtureInvestment[]; + mutationCount: number; +} + +interface FixtureStateTemplate { + readonly cashCents: number; + readonly investments: readonly Readonly[]; + readonly mutationCount: number; +} + +const NAMED_STATES: Readonly> = { + 'funded-empty-portfolio': { + cashCents: 1_000_000, + investments: [], + mutationCount: 0, + }, + 'funded-existing-portfolio': { + cashCents: 950_000, + investments: [{ id: 'investment-existing', amountCents: 50_000 }], + mutationCount: 0, + }, +}; + +export const VERIFY_CLIENT_HEADER = 'x-codevetter-verify-client'; +export const namedStateNames = Object.freeze(Object.keys(NAMED_STATES).sort()); + +export class UnknownFixtureStateError extends Error { + constructor(stateName: string) { + super(`Unknown verification state: ${stateName}`); + this.name = 'UnknownFixtureStateError'; + } +} + +export function clientIdForRequest( + request: Pick +): string { + return `${encodeURIComponent(request.runId)}/${encodeURIComponent(request.scenarioId)}`; +} + +export class FixtureStateRegistry { + readonly #clients = new Map(); + + install(request: VerificationStateRequest): string { + const template = NAMED_STATES[request.stateName]; + if (!template) throw new UnknownFixtureStateError(request.stateName); + const clientId = clientIdForRequest(request); + this.#clients.set(clientId, { + cashCents: template.cashCents, + investments: template.investments.map((investment) => ({ ...investment })), + mutationCount: template.mutationCount, + }); + return clientId; + } + + remove(clientId: string): void { + this.#clients.delete(clientId); + } + + read(clientId: string): FixtureClientState | null { + const state = this.#clients.get(clientId); + return state ? structuredClone(state) : null; + } + + createInvestment(clientId: string, amountCents: number): FixtureClientState | null { + const state = this.#clients.get(clientId); + if (!state) return null; + state.mutationCount += 1; + state.cashCents -= amountCents; + state.investments.push({ + id: `investment-${state.mutationCount}`, + amountCents, + }); + return structuredClone(state); + } +} + +export function verificationHeadersFor(request: VerificationStateRequest): Headers { + return new Headers({ [VERIFY_CLIENT_HEADER]: clientIdForRequest(request) }); +} diff --git a/apps/desktop/verify/scenarios.mjs b/apps/desktop/verify/scenarios.mjs new file mode 100644 index 0000000..ff27e0a --- /dev/null +++ b/apps/desktop/verify/scenarios.mjs @@ -0,0 +1,35 @@ +export const scenarioModule = { + id: 'codevetter-shell', + scenarios: [ + { + schemaVersion: 1, + id: 'shell-navigation', + capabilityIds: ['app-shell'], + route: '/', + authProfileId: 'local-developer', + stateName: 'shell-navigation-ready', + frozenTime: '2026-07-15T10:00:00.000Z', + flags: {}, + timeouts: { actionMs: 3000, scenarioMs: 10000 }, + actions: [ + { id: 'open-trex', kind: 'click', description: 'Open T-Rex from the shell' }, + { id: 'verify-trex', kind: 'wait', description: 'Wait for the T-Rex route' }, + { id: 'return-home', kind: 'click', description: 'Return to Home' }, + ], + assertions: [ + { id: 'trex-route', kind: 'route', description: 'T-Rex opens directly' }, + { id: 'shell-visible', kind: 'visible', description: 'The CodeVetter shell remains visible' }, + { id: 'runtime-clean', kind: 'runtime_errors', description: 'No runtime error occurs' }, + ], + async run({ page, observe, step }) { + await step('open-trex', () => page.getByRole('link', { name: /T-Rex/i }).click()); + await step('verify-trex', () => observe.expectRoute('/trex')); + await observe.expectVisible('T-Rex watcher'); + await step('return-home', () => page.getByRole('link', { name: /Home/i }).click()); + await observe.expectRoute('/'); + await observe.expectVisible('CodeVetter'); + await observe.expectNoRuntimeErrors(); + }, + }, + ], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fbe62e..b73f31a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: c8: specifier: ^11.0.0 version: 11.0.0 + msw: + specifier: 2.15.0 + version: 2.15.0(@types/node@22.19.17)(typescript@5.9.3) postcss: specifier: ^8.5.0 version: 8.5.10 @@ -1072,6 +1075,41 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@istanbuljs/schema@0.1.6': resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} @@ -1095,6 +1133,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1107,6 +1149,18 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -1828,6 +1882,12 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2038,6 +2098,10 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2258,6 +2322,15 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2346,6 +2419,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} @@ -2387,6 +2464,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2444,6 +2524,9 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2791,6 +2874,20 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.15.0: + resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -2846,6 +2943,9 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3138,6 +3238,9 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3172,6 +3275,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3218,9 +3324,16 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -3270,6 +3383,10 @@ packages: engines: {node: '>=16'} hasBin: true + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -3315,10 +3432,21 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3351,6 +3479,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3478,6 +3610,9 @@ packages: uploadthing: optional: true + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4296,6 +4431,33 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@inquirer/ansi@2.0.7': {} + + '@inquirer/confirm@6.1.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/core@11.2.1(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.17) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/type@4.0.7(@types/node@22.19.17)': + optionalDependencies: + '@types/node': 22.19.17 + '@istanbuljs/schema@0.1.6': {} '@jridgewell/gen-mapping@0.3.13': @@ -4322,6 +4484,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4334,6 +4505,17 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@oslojs/encoding@1.1.0': {} '@playwright/test@1.59.1': @@ -4918,6 +5100,12 @@ snapshots: dependencies: '@types/node': 24.13.0 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 22.19.17 + + '@types/statuses@2.0.6': {} + '@types/unist@3.0.3': {} '@ungap/structured-clone@1.3.0': {} @@ -5206,6 +5394,8 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.0 + cli-width@4.1.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5462,6 +5652,16 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5534,6 +5734,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.14.2: {} + h3@1.15.11: dependencies: cookie-es: 1.2.3 @@ -5639,6 +5841,11 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + html-escaper@2.0.2: {} html-escaper@3.0.3: {} @@ -5679,6 +5886,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -6177,6 +6386,33 @@ snapshots: ms@2.1.3: {} + msw@2.15.0(@types/node@22.19.17)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@22.19.17) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -6227,6 +6463,8 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + outvariant@1.4.3: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -6535,6 +6773,8 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + rettime@0.11.11: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -6584,6 +6824,8 @@ snapshots: set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.2: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -6659,8 +6901,12 @@ snapshots: space-separated-tokens@2.0.2: {} + statuses@2.0.2: {} + stream-replace-string@2.0.0: {} + strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} string-width@4.2.3: @@ -6721,6 +6967,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)): @@ -6784,10 +7032,20 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -6809,6 +7067,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} typescript@6.0.3: {} @@ -6898,6 +7160,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 + until-async@3.0.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 From 539502b08b8ad2cb1ac5f6111eddc3251ae8ac56 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:28:26 +0530 Subject: [PATCH 011/141] spec(verify): define warm local verification --- .../.openspec.yaml | 2 + .../design.md | 135 ++++++++++++++++++ .../proposal.md | 38 +++++ .../spec.md | 47 ++++++ .../changed-capability-verification/spec.md | 44 ++++++ .../specs/deterministic-browser-state/spec.md | 44 ++++++ .../spec.md | 44 ++++++ .../specs/staged-change-verification/spec.md | 38 +++++ .../warm-local-verification-runtime/spec.md | 63 ++++++++ .../tasks.md | 83 +++++++++++ 10 files changed, 538 insertions(+) create mode 100644 openspec/changes/add-warm-local-verification-daemon/.openspec.yaml create mode 100644 openspec/changes/add-warm-local-verification-daemon/design.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/proposal.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/specs/automatic-verification-observation/spec.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/specs/changed-capability-verification/spec.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/specs/deterministic-browser-state/spec.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/specs/deterministic-verification-scenarios/spec.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/specs/staged-change-verification/spec.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/specs/warm-local-verification-runtime/spec.md create mode 100644 openspec/changes/add-warm-local-verification-daemon/tasks.md diff --git a/openspec/changes/add-warm-local-verification-daemon/.openspec.yaml b/openspec/changes/add-warm-local-verification-daemon/.openspec.yaml new file mode 100644 index 0000000..64105fc --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-14 diff --git a/openspec/changes/add-warm-local-verification-daemon/design.md b/openspec/changes/add-warm-local-verification-daemon/design.md new file mode 100644 index 0000000..14d689f --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/design.md @@ -0,0 +1,135 @@ +## Context + +CodeVetter already orchestrates Playwright-based Synthetic QA, persists normalized QA evidence, selects Git changes, and presents proof in Review. Its current built-in runner starts Node and Chromium for each invocation, performs broad console filtering, and tears the browser down after the run. Repository Playwright mode starts a full test process. These boundaries preserve useful evidence contracts but cannot deliver a sub-30-second changed-capability loop reliably. + +This change establishes a narrower product wedge: one trusted developer, one explicitly configured React web app, one Mac, and one Playwright Chromium. The normal hot path is deterministic and performs zero model calls. It does not generalize app discovery, backend orchestration, browser engines, operating systems, users, or cloud execution. + +The runtime must also avoid worsening CodeVetter's local storage footprint. The existing Rust build cache is tens of gigabytes and the shared Playwright cache retains multiple browser revisions, so verification must not invoke Tauri/Cargo builds and must bound its own artifacts. + +## Goals / Non-Goals + +**Goals:** + +- Keep the configured frontend server and one Playwright Chromium process warm across invocations. +- Run each scenario in a fresh isolated context created from cached immutable authentication state. +- Install deterministic target-owned state before app code, then navigate directly to the affected route. +- Execute versioned TypeScript scenarios with zero model calls during normal runs. +- Collect runtime, console, network, mutation, route, accessibility, visual, and timing evidence automatically. +- Select scenarios from an authoritative checked-in capability map and a Git diff, with mandatory smoke and safe broad fallbacks. +- Make `verify changed` useful from a shell and preserve its result in existing Synthetic QA and staged-verification surfaces. +- Gate the warm 20-scenario batch at less than 30 seconds p95 on a recorded benchmark Mac. +- Bound memory, process, browser-cache, and artifact growth across long-lived use. + +**Non-Goals:** + +- CI, team or tenant isolation, hosted execution, cloud browsers, artifact services, or dashboards. +- Mobile/native Expo, Safari, Firefox, Windows/Linux qualification, or arbitrary repository support. +- A new browser engine, Chromiumoxide expansion, Stagehand, Browser Use, or an LLM controlling the browser. +- Automatic framework/dev-server discovery or full backend/database orchestration. +- Model-generated scenarios or main-versus-working-tree differential testing in this change. +- Replacing repository unit tests, Playwright suites, or the existing one-shot QA runners. + +## Decisions + +### 1. Use a long-lived Node daemon with direct Playwright imports + +`verifyd` will be a repository-owned Node/TypeScript process. It will import the pinned `playwright` package directly, supervise one configured frontend child process, launch one Chromium process, retain parsed configuration and auth profiles in memory, and expose a versioned request/response protocol over an owner-only Unix-domain socket. The CLI will expose `verify daemon start|status|stop` and `verify changed [--json]`. + +The first implementation requires the repository's Node runtime and installed workspace dependencies. It does not claim that the daemon is embedded in, or independently packaged with, the Tauri binary. Rust/Tauri will supervise and call the same local protocol when desktop integration is added. + +Alternatives considered: + +- A fresh Playwright process per invocation retains the dominant cold boundaries and cannot satisfy the product goal consistently. +- Go would still delegate browser automation to Playwright or duplicate its semantics across IPC, so it adds complexity without improving the measured hot path. +- Chromiumoxide lacks Playwright contexts, locators, routing, storage-state, tracing, and observer ergonomics and is not the verifier engine. +- Reusing `npx playwright` risks version drift and process startup; the daemon imports the lockfile-pinned package directly. + +### 2. Give the daemon explicit process ownership + +The config declares exactly one server command, working directory, readiness URL, base URL, environment-name allowlist, and shutdown grace period. The daemon records the PID and process start identity it owns, waits for readiness and settled HMR, and kills only its own child process on an explicit stop. It never kills a process merely because a port is occupied. A conflicting listener is an operational/no-confidence outcome. + +The daemon publishes a health contract containing protocol version, PID, server/browser status, target repository and SHA, config hash, Chromium revision, warm/cold state, active runs, and resource usage. Unexpected server/browser exit invalidates warm state. One bounded automatic restart is allowed; repeated failure requires an explicit restart and never becomes a product regression. + +### 3. Cache auth data but isolate mutable execution state + +Authentication profiles are validated, serialized storage-state inputs cached immutably in memory, and copied into a fresh `BrowserContext` for every scenario. Contexts run with bounded parallelism, initially configurable from one to four. The runtime never pools a context after it has executed a scenario. + +Before navigation, the runtime installs init scripts for scenario identity, frozen time, feature flags, reduced motion, and animation disabling; registers request policies and target state; and waits for an explicit target-state readiness handshake. It then navigates directly to the configured route. Parallel scenarios carry a unique run/scenario identity so mutation ledgers and mocked state cannot leak between contexts. + +Reusing authenticated contexts was rejected because cookies, storage, service workers, mutation counters, and MSW state can leak. Fresh context creation is already cheap; optimization beyond immutable storage-state caching requires a profile proving it is safe and material. + +### 4. Use a target-owned state bridge with an MSW adapter + +The verifier defines a small browser-side state-bridge protocol, not application business fixtures. The target app owns named states such as `funded-empty-portfolio` and acknowledges installation before React behavior is exercised. The first adapter supports MSW and must keep scenario state client-scoped rather than process-global. + +Playwright routing enforces first-party request allowlists, mutation counting, unexpected-call failure, and third-party blocking even when an app does not use MSW. MSW is therefore a target-app development dependency, not a CodeVetter production dependency. A checked-in deterministic bridge fixture qualifies the protocol. + +### 5. Define versioned deterministic scenario and result contracts + +Scenario modules export stable IDs, capability IDs, route, auth profile, state name, timeout budgets, optional tags, and deterministic Playwright actions/assertions. The runtime API exposes `page`, `observe`, and cancellation. A normal execution cannot reach any model/provider adapter; a test instruments provider boundaries to prove zero calls. + +Every result distinguishes: + +- `passed`: selected scenarios ran and all required invariants passed; +- `regression`: application behavior violated an invariant; +- `no_confidence`: selection, configuration, daemon, server, browser, state installation, cancellation, or execution infrastructure prevented meaningful verification. + +Results include schema/protocol/config/scenario/source versions, exact target and change-set identities, selection explanation, per-stage timings, observation records, redacted artifacts, and limitations. A source/config hash is checked before and after execution; drift makes the result stale and prevents a pass claim. + +An additive adapter projects versioned results into the existing `SyntheticQaRunResult` and Review proof model. Older QA rows remain readable without rewriting. + +T-Rex owns the operational experience because it already represents watched local changes and verification activity. It shows daemon/server/browser health, target and config identity, changed-capability selection, run/cancel controls, live timings, failures, artifacts, retention, and cleanup. Review and staged verification consume completed evidence through existing proof adapters and do not duplicate daemon or browser controls. + +### 6. Make automatic observation strict, policy-driven, and redacted + +Observers attach before navigation and collect page errors, non-allowlisted console errors, request failures, first-party HTTP failures, unexpected API calls, mutation ledgers, duplicate mutations, route transitions, interaction duration, accessibility violations, and screenshot hashes/differences under explicit policy. + +The existing broad ignores for `Failed to fetch`, `NetworkError`, and `net::ERR_` are not inherited. Allowlisting requires a narrow checked-in matcher and explanation. Authorization/cookie headers, storage state, secret-like values, and unbounded bodies are never persisted. Passing runs keep summary records by default; screenshots, traces, and bounded network/log detail are retained only on failure or explicit request. + +Playwright supplies the core observer surfaces. The first implementation pins development-only `@axe-core/playwright` 4.12.1 (MPL-2.0) and runs its full rules engine at the final state and declared checkpoints. Serious and critical violations block the scenario, lower impacts remain visible context, audit failure produces `no_confidence`, and retained violations are capped at 100. The dependency does not enter the production frontend bundle. Tolerant pixel comparison remains outside this change; the first visual invariant uses deterministic screenshot bytes/hashes and explicit baselines. + +### 7. Make the checked-in capability map authoritative + +The first config is `.codevetter/verify.yaml`, validated against a versioned schema. It declares server settings, auth profiles, scenario modules, capability-to-path mappings, mandatory smoke scenarios, shared-infrastructure paths, request policies, and budgets. A direct `yaml` development dependency is acceptable because importing undeclared transitive parsers would make the contract unstable; production code gains no dependency. + +Selection uses the exact Git diff and deterministic glob matching. Explicit mappings are authoritative. Existing CodeVetter graph/import/coverage/impacted-test data may add ranked hints or explanations but cannot remove explicitly selected scenarios, override smoke rules, or turn incomplete selection into confidence. + +Unmatched paths, invalid mappings, shared-infrastructure changes, truncated/untrusted graph data, or an uncovered changed entity force configured broad smoke/full fallback. The JSON result includes every changed path, matched capability, selected scenario, smoke addition, fallback, and limitation. + +### 8. Benchmark the whole warm invocation and control resource growth + +The release performance fixture contains 20 meaningful real-Chromium scenarios with deterministic mocked backend state, direct routes, multiple interactions, and automatic observers. After two warm-up batches, at least 20 recorded batches measure the p95 of the entire invocation. The gate is under 30 seconds. Cold startup is measured and reported separately. + +The record captures Mac model/CPU/RAM/OS, target SHA, config and scenario-manifest hashes, Chromium revision, parallelism, HMR readiness, and timings for diff, selection, context/state, navigation, actions, observation, screenshots, reporting, and teardown. Intentional negative observer fixtures run outside the performance sample. A second changed-capability hot-path budget is recorded and tightened only after measurement. + +The daemon never invokes Cargo, Tauri, or production builds. It reuses the package-manager store and pinned browser revision. CodeVetter reports shared Playwright revisions but only removes cache entries it can prove it owns. Run storage is capped by count and bytes; passing runs retain summaries, failed artifacts expire under policy, and cleanup is visible and safe. A 100-run stability test gates browser/context/process leakage and bounded RSS growth. + +## Risks / Trade-offs + +- [Target-owned state bridge requires app changes] -> Ship a minimal protocol and fixture, fail clearly when handshake support is absent, and keep Playwright routing useful independently. +- [Mocked state can diverge from the real backend] -> Label mock provenance, require explicit request contracts, and retain repository/full-stack tests as separate evidence. +- [Parallel contexts can race through shared app state] -> Use per-context scenario identities, client-scoped MSW state, isolated mutation ledgers, and concurrency leakage tests. +- [Warm processes can become stale or leak resources] -> Hash source/config, expose health, invalidate on exits, cap restart, test 100-run stability, and provide explicit stop/restart. +- [Unix sockets initially exclude Windows] -> This change qualifies one Mac only; a transport abstraction keeps later platform work possible. +- [Strict network observation can expose existing noise] -> Require narrow documented allowlists and distinguish application regressions from operational failures. +- [Screenshot hashes are sensitive to rendering drift] -> Freeze deterministic inputs and keep tolerant differential visual testing for the later differential-verification change. +- [Fast focused selection can miss effects] -> Keep explicit config authoritative and force mandatory smoke/broad fallback whenever coverage is incomplete. +- [A new dev dependency adds supply-chain surface] -> Pin and audit only the YAML parser and, if approved for full accessibility, `@axe-core/playwright`; add no production dependency. + +## Migration Plan + +1. Add benchmark fixtures and versioned config/scenario/result schemas without changing existing QA execution. +2. Add daemon lifecycle, IPC, server/browser supervision, and health/recovery tests behind an opt-in command. +3. Add isolated state injection, deterministic scenario execution, and automatic observers. +4. Add diff selection, smoke/fallback rules, CLI exit codes, and JSON output. +5. Add the existing-QA persistence/Review adapter and staged-verification qualification. +6. Run correctness, isolation, recovery, 100-run stability, artifact-retention, and repeated 20-scenario performance gates. +7. Enable the warm path for opted-in repositories while preserving one-shot Synthetic QA and repository Playwright runners as fallback. + +Rollback disables the warm verifier command/feature flag and leaves existing QA records and runners untouched. Additive versioned records remain readable; daemon-owned processes and bounded artifacts are stopped/cleaned through ownership-aware controls. + +## Open Questions + +- The measured default context parallelism for the benchmark Mac; begin at four but select from profiling rather than assumption. +- The first measured hot changed-capability budget below the mandatory 30-second 20-scenario gate. +- The measured information density and default expansion state for the T-Rex verification panel; the runtime and evidence contracts remain independent of this presentation choice. diff --git a/openspec/changes/add-warm-local-verification-daemon/proposal.md b/openspec/changes/add-warm-local-verification-daemon/proposal.md new file mode 100644 index 0000000..48db4b6 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/proposal.md @@ -0,0 +1,38 @@ +## Why + +CodeVetter's built-in browser QA pays process, browser, app-startup, login, and navigation costs on each run, so it cannot provide a fast feedback loop after an AI agent changes a frontend. The first product wedge is a local-only verifier for one developer, one configured React web app, one Mac, and Chromium that turns changed code into meaningful deterministic browser evidence in under 30 seconds while warm. + +## What Changes + +- Add a local `verifyd` runtime that keeps one configured app server and one Playwright Chromium process warm, caches immutable authentication state, watches the target repository, and exposes private local IPC. +- Add `verify daemon start|status|stop` and `verify changed [--json]`; normal warm execution performs zero model calls and has distinct pass, regression, and operational/no-confidence outcomes. +- Add a target-owned state bridge, initially supporting MSW, so a scenario can restore authentication, install deterministic backend handlers, freeze time, set flags, disable animation, block third parties, and navigate directly to a route before actions run. +- Add a versioned deterministic TypeScript scenario and result contract. Each scenario runs in a fresh isolated browser context while sharing the warm browser process; contexts may run with bounded parallelism. +- Automatically observe uncaught exceptions, console errors, failed and unexpected requests, mutation counts and duplicates, route changes, accessibility violations, screenshot differences, and interaction timings under explicit policies and budgets. +- Add an explicit repository capability map from changed path globs to scenario IDs, plus mandatory smoke scenarios and a safe broad fallback for unmatched/shared-infrastructure changes. +- Make T-Rex the operational home for daemon health, browser verification, selection, run/cancel, artifacts, and cleanup, while preserving completed warm runs as read-only evidence in Synthetic QA, Review timelines, and staged verification without rewriting older records. +- Establish the first release gate: 20 deterministic real-Chromium scenarios with mocked backend state complete in under 30 seconds at p95 on the recorded benchmark Mac. Cold startup is measured separately. +- Keep model-generated scenarios and main-vs-working-tree differential verification as explicit follow-up changes after the warm execution and selection abstraction is proven. + +## Capabilities + +### New Capabilities + +- `warm-local-verification-runtime`: Local daemon, app-server/browser supervision, IPC, lifecycle recovery, CLI, and warm performance/resource budgets. +- `deterministic-browser-state`: Cached auth, target-owned MSW state, frozen time/flags, direct route entry, third-party blocking, animation control, and isolated parallel contexts. +- `deterministic-verification-scenarios`: Versioned zero-model scenario execution, cancellation/timeouts, stale-source detection, deterministic results, and existing-QA adaptation. +- `automatic-verification-observation`: Runtime, console, network, mutation, routing, accessibility, visual, and interaction-timing evidence with explicit policies and redaction. +- `changed-capability-verification`: Explicit capability mapping, Git-diff selection, mandatory smoke/fallback behavior, selection explanations, and stable CLI/JSON outcomes. + +### Modified Capabilities + +- `staged-change-verification`: Treat a warm local verification run as executable-test evidence while preventing selection gaps or operational failures from satisfying the executable stage. + +## Impact + +- Affects the T-Rex desktop surface, Rust command/process supervision, local Node/Playwright scripts, a new local CLI/daemon boundary, QA persistence/adapters, Review/staged-verification evidence, testing fixtures, and performance gates. +- Reuses the installed Playwright stack and existing Synthetic QA contracts where safe. Accessibility may require one justified dev-only `@axe-core/playwright` dependency because Playwright does not provide an accessibility rules engine. +- The target app is configured with one explicit server command (for example Vite or `expo start --web`); CodeVetter does not add framework discovery or backend orchestration. +- Local-only trust assumptions are explicit: one user, one trusted repository, fixed ports, cached local state, no tenant isolation, and no cloud artifact service. +- Out of scope: CI, teams, cloud browsers, hosted dashboards, arbitrary repository discovery, mobile/native Expo, Safari/Firefox, a new browser engine, autonomous browsing, Stagehand/Browser Use, Chromiumoxide expansion, and model calls during normal execution. +- Depends only optionally on changed-file/entity hints from `complete-local-codebase-intelligence`; explicit capability YAML remains authoritative and the two OpenSpec changes do not share implementation files or schemas. diff --git a/openspec/changes/add-warm-local-verification-daemon/specs/automatic-verification-observation/spec.md b/openspec/changes/add-warm-local-verification-daemon/specs/automatic-verification-observation/spec.md new file mode 100644 index 0000000..d37cd16 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/specs/automatic-verification-observation/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Automatic runtime and console observation +Every scenario MUST attach observers before navigation for uncaught page exceptions and non-allowlisted error-level console activity. Broad network-failure strings such as `Failed to fetch`, `NetworkError`, and `net::ERR_` MUST NOT be silently ignored. + +#### Scenario: Uncaught exception without a handwritten assertion +- **WHEN** the application throws an uncaught exception during a scenario +- **THEN** the result records the exception with redacted source context and the scenario cannot pass + +### Requirement: Automatic network and mutation observation +Every scenario MUST record failed requests, first-party HTTP failures, unexpected API calls, and a mutation ledger keyed by method, normalized URL, and bounded body hash. It SHALL evaluate declared mutation counts and duplicate-submission policy automatically. + +#### Scenario: Double submit creates two schedules +- **WHEN** one user interaction sends two equivalent schedule-creation mutations but the scenario permits one +- **THEN** the observer reports a duplicate mutation regression even without a handwritten duplicate assertion + +#### Scenario: API returns server error +- **WHEN** a first-party request returns a configured failure status outside an expected-error scenario +- **THEN** the request and policy violation appear in evidence and the scenario cannot pass + +### Requirement: Route and interaction observation +Every scenario SHALL record starting, expected, intermediate, and final routes and interaction durations, and MUST evaluate unexpected route transitions and configured slow-interaction budgets. + +#### Scenario: Confirmation redirects to login +- **WHEN** an authenticated confirmation action unexpectedly changes the route to `/login` +- **THEN** the observer reports the route regression with the triggering interaction and timing + +### Requirement: Accessibility observation with honest scope +The verifier SHALL run the configured accessibility policy on the final and declared checkpoint states. Full rules-engine results MUST be labelled an accessibility audit only when the approved accessibility engine is installed; otherwise results MUST be labelled accessibility smoke checks. + +#### Scenario: Blocking accessibility violation +- **WHEN** a checkpoint contains a violation exceeding the configured severity threshold +- **THEN** the result records rule, affected locator, severity, and checkpoint and the scenario cannot pass + +### Requirement: Deterministic visual observation +The verifier SHALL capture declared screenshot checkpoints under frozen deterministic inputs and compare them with exact versioned baselines using explicit policies. Missing, stale, or environment-incompatible baselines MUST produce `no_confidence`, not automatic acceptance. + +#### Scenario: Screenshot changes unexpectedly +- **WHEN** a checkpoint screenshot differs from its compatible exact baseline +- **THEN** the result reports a visual regression and retains bounded failure artifacts + +### Requirement: Policy, evidence, and retention boundaries +Every automatic observation SHALL name the policy that classified it, distinguish regression from operational failure, redact sensitive data, and obey artifact count, size, and age limits. Passing runs MUST retain summaries only unless detailed capture is explicitly requested. + +#### Scenario: Passing batch under default retention +- **WHEN** all selected scenarios pass without detailed capture enabled +- **THEN** CodeVetter retains bounded result and timing summaries and discards screenshots, traces, raw bodies, and verbose logs diff --git a/openspec/changes/add-warm-local-verification-daemon/specs/changed-capability-verification/spec.md b/openspec/changes/add-warm-local-verification-daemon/specs/changed-capability-verification/spec.md new file mode 100644 index 0000000..4a8f25f --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/specs/changed-capability-verification/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Authoritative explicit capability configuration +The verifier SHALL validate a versioned checked-in `.codevetter/verify.yaml` that maps changed path globs to capability IDs and deterministic scenario IDs and declares mandatory smoke and shared-infrastructure rules. Explicit mappings MUST remain authoritative over inferred graph, import, coverage, history, or AI hints. + +#### Scenario: Inference disagrees with explicit mapping +- **WHEN** inferred evidence ranks a different scenario than the explicit mapping for a changed path +- **THEN** the explicitly mapped scenarios still run and the inferred scenario can only be added or shown as a hint + +#### Scenario: Invalid capability configuration +- **WHEN** the capability file contains an unknown scenario, duplicate ID, invalid glob, or unsupported version +- **THEN** `verify changed` returns `no_confidence` with every validation error and does not silently select a subset + +### Requirement: Exact Git changed-file selection +`verify changed` MUST derive the requested worktree, staged, commit, or range change set from Git, preserve exact changed paths and target identity, and deterministically map them to capabilities and scenarios. + +#### Scenario: Portfolio files changed +- **WHEN** the diff contains a path matching the configured portfolio capability +- **THEN** the result selects its configured scenarios and explains the changed path, matching rule, capability, and scenario chain + +### Requirement: Mandatory smoke and broad fallback +The verifier MUST add configured mandatory smoke scenarios for every changed run and SHALL force the configured broad fallback when a path is unmatched, shared infrastructure changes, selected coverage is incomplete, or supporting graph/config evidence is missing, stale, truncated, or untrusted. + +#### Scenario: Shared router changes +- **WHEN** a changed path matches a configured shared-infrastructure rule +- **THEN** the verifier runs the broad fallback plus mandatory smoke scenarios and identifies the rule that widened selection + +#### Scenario: Changed file has no mapping +- **WHEN** a changed path matches no capability rule +- **THEN** the verifier does not claim focused confidence and runs the configured fallback or returns `no_confidence` if no safe fallback exists + +### Requirement: Minimal selected set with complete explanation +The verifier SHALL deduplicate selected scenarios, apply deterministic ordering and bounded parallel scheduling, and emit every selected, added, skipped, and fallback decision with its evidence and limitation. + +#### Scenario: Two capabilities share a scenario +- **WHEN** two changed capabilities map to the same scenario ID +- **THEN** the scenario runs once and its selection explanation cites both capabilities and changed-path reasons + +### Requirement: Selection cannot fabricate verification +Selection hints, historical outcomes, and static graph evidence MUST NOT count as executed scenario evidence or turn a partial, stale, cancelled, or operationally failed run into a pass. + +#### Scenario: Selected scenario fails to start +- **WHEN** selection is complete but a required scenario cannot create its browser context +- **THEN** the overall result is `no_confidence` and the selection explanation remains context rather than executed proof diff --git a/openspec/changes/add-warm-local-verification-daemon/specs/deterministic-browser-state/spec.md b/openspec/changes/add-warm-local-verification-daemon/specs/deterministic-browser-state/spec.md new file mode 100644 index 0000000..6579467 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/specs/deterministic-browser-state/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Isolated authentication restoration +The verifier SHALL cache validated authentication storage state immutably and MUST create a fresh browser context from a copy of that state for each scenario, including scenarios running in parallel. + +#### Scenario: Parallel authenticated scenarios +- **WHEN** multiple scenarios use the same authenticated profile concurrently +- **THEN** each receives equivalent initial authentication but changes to cookies, storage, service workers, or application state cannot appear in another scenario + +### Requirement: Deterministic pre-navigation state +Before target application code executes, the verifier MUST install the scenario identity, named backend state, frozen time, feature flags, reduced motion, animation policy, request policy, and unique run identity, and SHALL navigate directly to the configured route only after the state bridge acknowledges readiness. + +#### Scenario: Open a funded empty portfolio +- **WHEN** a scenario opens `/portfolio` with a verified-investor profile and `funded-empty-portfolio` state +- **THEN** the first application render observes that auth, state, time, flags, and motion policy without executing login or setup navigation + +#### Scenario: State installation does not acknowledge +- **WHEN** the target state bridge fails to acknowledge the requested state before its timeout +- **THEN** the scenario returns `no_confidence` and no application pass or regression is claimed + +### Requirement: Target-owned MSW state adapter +The first state-bridge adapter SHALL support target-owned named MSW scenarios, MUST scope handler state and mutation counters to one browser context, and MUST expose an explicit installation handshake. + +#### Scenario: Concurrent mocked mutations +- **WHEN** two contexts execute the same mutation against different named states +- **THEN** each context observes only its own response state and mutation count + +### Requirement: Strict network boundary and third-party blocking +The verifier MUST enforce explicit first-party request policies and SHALL block configured third-party traffic without treating blocked third-party calls as successful application requests. + +#### Scenario: Unhandled first-party request +- **WHEN** a scenario issues a first-party API call not handled or allowlisted by its deterministic state +- **THEN** the observer records an unexpected request and the scenario cannot pass + +#### Scenario: Configured analytics endpoint is reached +- **WHEN** application code attempts to call a configured third-party analytics origin +- **THEN** the request is blocked deterministically and recorded under the declared third-party policy without leaving the local machine + +### Requirement: No persisted sensitive browser state +CodeVetter MUST NOT persist raw storage-state files, cookies, authorization headers, secret-like values, or unbounded request and response bodies in verification evidence. + +#### Scenario: Failure includes authenticated requests +- **WHEN** a failing scenario sends cookies or authorization headers +- **THEN** retained logs and network evidence contain redacted metadata and no reusable credential material diff --git a/openspec/changes/add-warm-local-verification-daemon/specs/deterministic-verification-scenarios/spec.md b/openspec/changes/add-warm-local-verification-daemon/specs/deterministic-verification-scenarios/spec.md new file mode 100644 index 0000000..bc96437 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/specs/deterministic-verification-scenarios/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Versioned deterministic scenario contract +The verifier SHALL load versioned TypeScript scenarios with stable scenario and capability IDs, direct route, auth profile, state name, budgets, actions, and assertions, and MUST reject invalid or duplicate definitions before browser execution. + +#### Scenario: Load a valid scenario manifest +- **WHEN** a configured scenario module exports a valid supported contract +- **THEN** the daemon registers its stable identity and source hash in the in-memory manifest + +#### Scenario: Unsupported scenario schema +- **WHEN** a scenario uses an unsupported schema version or duplicates an existing stable ID +- **THEN** selection returns `no_confidence` with the exact validation failure and does not execute a partial manifest + +### Requirement: Zero-model normal execution +Normal scenario execution MUST invoke deterministic Playwright actions and assertions without calling any LLM, provider adapter, browser agent, or model-driven action planner. + +#### Scenario: Prove no model calls +- **WHEN** the qualification suite runs all 20 benchmark scenarios with provider boundaries instrumented +- **THEN** every scenario completes with a zero provider/model call count + +### Requirement: Isolated bounded execution +Each scenario MUST run in a fresh context with explicit action, scenario, and batch timeouts, bounded parallelism, cancellation propagation, and guaranteed teardown that leaves the shared browser and server warm. + +#### Scenario: Cancel an active batch +- **WHEN** a user cancels a batch containing active and queued scenarios +- **THEN** queued work does not start, active actions receive cancellation, every affected context closes, and the result is `no_confidence` rather than passed + +### Requirement: Stable source and configuration identity +The verifier MUST record exact target SHA/change-set identity plus config, manifest, scenario, and source hashes, and SHALL invalidate a pass result when relevant source or configuration changes during execution. + +#### Scenario: Source changes during verification +- **WHEN** the watcher observes a relevant source or scenario change after selection and before result finalization +- **THEN** the result is marked stale and cannot satisfy verification for the newer worktree state + +### Requirement: Versioned evidence adaptation +The verifier SHALL emit a versioned result with outcome, selection, timings, observations, limitations, and artifacts, and SHALL adapt it additively into existing Synthetic QA and Review evidence without rewriting older records. + +#### Scenario: Save a warm verification regression +- **WHEN** a deterministic scenario detects a regression +- **THEN** CodeVetter persists its versioned evidence, exposes it through existing QA/Review proof surfaces, and retains the richer warm-result provenance + +#### Scenario: Read an older QA row +- **WHEN** CodeVetter loads a Synthetic QA record created before warm verification exists +- **THEN** the record remains readable and unavailable warm fields are labelled not recorded rather than failed diff --git a/openspec/changes/add-warm-local-verification-daemon/specs/staged-change-verification/spec.md b/openspec/changes/add-warm-local-verification-daemon/specs/staged-change-verification/spec.md new file mode 100644 index 0000000..ece11b4 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/specs/staged-change-verification/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: One staged verification outcome +CodeVetter SHALL represent verification of a code change as an ordered sequence of code review, executable testing, and audience validation, and SHALL expose one aggregate outcome with evidence from every completed stage. A completed warm local verification run MAY supply executable-testing evidence only when its exact change-set identity is current, its required selection completed, and its outcome is passed or regression rather than operational or selection `no_confidence`. + +#### Scenario: Full user-facing verification +- **WHEN** a user-facing change completes review, executable testing, and audience validation +- **THEN** CodeVetter shows the result of each stage and an aggregate outcome linked to the underlying findings, test artifacts, and audience evidence + +#### Scenario: Backend-only change does not need audience validation +- **WHEN** the operator marks audience validation not applicable and records a reason +- **THEN** CodeVetter preserves the waiver and can complete the aggregate outcome from review and executable-test evidence without claiming audience validation occurred + +#### Scenario: Warm verification has incomplete selection +- **WHEN** a warm local run skips a required scenario, uses a stale source identity, is cancelled, or ends with operational or selection `no_confidence` +- **THEN** the executable-testing stage remains not verified and identifies the missing or invalid evidence + +### Requirement: Stage provenance and status +Each stage SHALL have an explicit status, timestamp, provenance, and evidence references. A stage MUST NOT be shown as passed solely because an earlier stage passed. Warm local verification provenance MUST include daemon/result schema, exact target and change-set identities, configuration and scenario-manifest hashes, selected and fallback scenarios, observation policy, warm/cold state, and limitations. + +#### Scenario: Review passes but browser QA fails +- **WHEN** review completes without blocking findings and executable browser QA fails +- **THEN** the aggregate outcome remains unverified or blocked and identifies the failed QA evidence + +#### Scenario: Warm verification supplies executable evidence +- **WHEN** every required scenario for the exact current change set executes and the warm result passes +- **THEN** the executable-testing stage links the run, selection explanation, automatic observations, timings, and artifacts as executable provenance + +### Requirement: Backward compatibility +Existing CodeVetter reviews and synthetic-QA records SHALL remain readable when they have no staged-verification or warm-verification metadata, and the system MUST NOT rewrite those records merely to adopt the new result schema. + +#### Scenario: Open an older review +- **WHEN** CodeVetter loads a review created before staged verification exists +- **THEN** it renders the existing review normally and labels unavailable later stages as not run rather than failed + +#### Scenario: Open a one-shot Synthetic QA record +- **WHEN** CodeVetter loads an existing built-in or repository Playwright QA record without warm daemon provenance +- **THEN** it preserves the original outcome and artifacts and labels warm-only fields as not recorded diff --git a/openspec/changes/add-warm-local-verification-daemon/specs/warm-local-verification-runtime/spec.md b/openspec/changes/add-warm-local-verification-daemon/specs/warm-local-verification-runtime/spec.md new file mode 100644 index 0000000..73bebb1 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/specs/warm-local-verification-runtime/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Private persistent local runtime +CodeVetter SHALL provide a local `verifyd` runtime for one trusted repository that keeps one explicitly configured frontend server and one Playwright Chromium process warm and accepts versioned requests over owner-only local IPC. + +#### Scenario: Reuse warm processes +- **WHEN** two verification requests run after the daemon and target app are ready +- **THEN** both requests use the same owned server and Chromium processes without spawning a new browser or frontend server per scenario + +#### Scenario: Reject another local user +- **WHEN** a client that does not own the daemon socket attempts to submit a request +- **THEN** the daemon rejects the request without exposing configuration, authentication state, or prior evidence + +### Requirement: Explicit process ownership and health +The runtime MUST start, identify, monitor, and stop only the server and browser processes it owns, and SHALL expose protocol version, process health, target identity, configuration hash, Chromium revision, active runs, warm state, and resource usage. + +#### Scenario: Configured port is already occupied +- **WHEN** readiness detects a listener that the daemon did not start +- **THEN** verification returns `no_confidence` with the port conflict and does not kill the existing process + +#### Scenario: Warm child exits unexpectedly +- **WHEN** the owned app server or Chromium process exits during a run +- **THEN** the daemon invalidates warm state, returns `no_confidence`, and performs at most the configured bounded recovery attempt + +### Requirement: Stable local CLI outcomes +CodeVetter SHALL expose `verify daemon start|status|stop` and `verify changed [--json]`; `verify changed` MUST distinguish passed verification, detected regression, and operational or selection `no_confidence` through documented output and stable exit codes. + +#### Scenario: Machine-readable changed verification +- **WHEN** the user runs `verify changed --json` +- **THEN** stdout contains one versioned bounded JSON result and the exit code matches its passed, regression, or no-confidence outcome + +#### Scenario: Required local runtime is absent +- **WHEN** Node, installed Playwright dependencies, the configured target, or Chromium is unavailable +- **THEN** the CLI returns `no_confidence` with a remediation and does not classify the application as regressed + +### Requirement: Warm performance gate +The release benchmark MUST complete a whole warm batch of 20 meaningful deterministic real-Chromium scenarios with mocked backend state in less than 30 seconds at p95 on the recorded benchmark Mac after two warm-up batches and at least 20 measured batches. Cold startup MUST be reported separately. + +#### Scenario: Qualify warm execution +- **WHEN** the release performance fixture runs under its recorded machine, Chromium, target, manifest, parallelism, and settled-HMR conditions +- **THEN** the report gates the p95 duration of each complete 20-scenario invocation and records per-stage timings without mixing cold startup or negative observer fixtures into the sample + +### Requirement: Bounded long-lived resources +The daemon SHALL avoid Cargo, Tauri, and production builds during normal verification; MUST close every scenario context; MUST retain only bounded redacted artifacts; and MUST provide ownership-aware reporting and cleanup for daemon artifacts and browser cache. + +#### Scenario: One hundred warm runs +- **WHEN** the stability qualification executes 100 warm verification runs including failures and cancellations +- **THEN** no browser contexts or owned child processes leak, RSS stays within the recorded budget, and retained artifacts remain within configured count and byte caps + +#### Scenario: Shared Playwright cache contains old revisions +- **WHEN** storage inspection finds browser revisions not provably owned by CodeVetter +- **THEN** CodeVetter reports their footprint but does not delete them automatically + +### Requirement: T-Rex owns browser verification operations +T-Rex SHALL be the desktop home for local verification daemon, server, browser, changed-capability selection, run/cancel, timing, failure, artifact-retention, and cleanup operations. Review and staged-verification surfaces MUST consume completed verification evidence without duplicating those operational controls. + +#### Scenario: Run changed verification from T-Rex +- **WHEN** a developer opens T-Rex for a configured repository +- **THEN** T-Rex shows exact daemon/server/browser health and selection provenance and provides the run or cancel action for `verify changed` + +#### Scenario: Inspect the result from Review +- **WHEN** a completed warm run is linked to a review +- **THEN** Review shows its outcome and evidence references while directing operational restart, rerun, retention, and cleanup actions to T-Rex diff --git a/openspec/changes/add-warm-local-verification-daemon/tasks.md b/openspec/changes/add-warm-local-verification-daemon/tasks.md new file mode 100644 index 0000000..a530dd5 --- /dev/null +++ b/openspec/changes/add-warm-local-verification-daemon/tasks.md @@ -0,0 +1,83 @@ +## 1. Baseline and Versioned Contracts + +- [x] 1.1 Check in the representative 20-scenario benchmark manifest and document what makes each scenario meaningful: direct route, deterministic mocked state, multiple interactions, and automatic observation. +- [x] 1.2 Capture the current cold one-shot and warm-server baseline with machine, OS, Chromium, target SHA, HMR readiness, per-stage timings, artifact bytes, and process memory. +- [x] 1.3 Define and test versioned daemon IPC, health, request, outcome, timing, limitation, observation, artifact, and cancellation contracts with bounded payload sizes. +- [x] 1.4 Define and test the versioned `.codevetter/verify.yaml` schema for one target server, auth profiles, scenario modules, capabilities, smoke/fallback rules, request policies, retention, and budgets. +- [x] 1.5 Define and test the deterministic TypeScript scenario and manifest contracts, including stable IDs, source hashes, route/state/auth metadata, actions, assertions, and timeout budgets. + +## 2. Daemon Lifecycle and Process Supervision + +- [x] 2.1 Add the repository-owned Node/TypeScript `verifyd` entrypoint using direct lockfile-pinned Playwright imports and an owner-only Unix-domain socket; do not use `npx`, Go, or Chromiumoxide. +- [x] 2.2 Implement daemon start/status/stop, singleton locking, stale-socket recovery, protocol negotiation, PID/start identity, and cleanup that affects only daemon-owned resources. +- [x] 2.3 Implement explicit configured server supervision with working directory, allowlisted environment names, readiness URL, settled-HMR gate, log bounds, graceful stop, and port-conflict refusal. +- [x] 2.4 Keep one pinned Playwright Chromium warm, report its revision and health, and invalidate warm state when it exits. +- [x] 2.5 Add bounded one-attempt server/browser recovery, repeated-failure lockout, request cancellation, and daemon shutdown while runs are active. +- [x] 2.6 Add lifecycle tests for concurrent starts, foreign port owners, stale sockets, crash recovery, cancellation, graceful stop, and no orphan processes. +- [x] 2.7 Run the first cleanup gate: remove redundant lifecycle abstractions, consolidate process/IPC errors and ownership types, report file/LOC growth, and rerun focused tests plus full typechecking. + +## 3. Deterministic Browser State + +- [x] 3.1 Implement validated immutable in-memory auth-profile loading and create a fresh context from copied storage state for every scenario. +- [x] 3.2 Install run/scenario identity, frozen time, feature flags, reduced motion, animation controls, and request policy before target application code runs. +- [x] 3.3 Define the target-owned state-bridge handshake and build a checked-in React/MSW qualification fixture with client-scoped named states. +- [x] 3.4 Implement strict first-party request routing, configurable third-party blocking, direct route entry, and state-ready timeout classification. +- [x] 3.5 Add isolation tests for cookies, storage, service workers, MSW state, mutation counters, flags, time, and routes across serial and four-way parallel scenarios. +- [x] 3.6 Add redaction tests proving storage state, cookies, authorization headers, secret-like values, and unbounded bodies never enter persisted evidence. + +## 4. Scenario Runtime and Scheduling + +- [x] 4.1 Load, validate, hash, and atomically publish the scenario manifest; reject unsupported versions, duplicates, unknown capabilities, and partial reloads. +- [x] 4.2 Implement the deterministic `scenario({ page, observe })` runtime with step/action records and scenario-specific assertions. +- [x] 4.3 Implement bounded one-to-four-context scheduling, per-action/scenario/batch timeouts, cancellation propagation, deterministic result ordering, and guaranteed context teardown. +- [ ] 4.4 Watch relevant target/config/scenario sources and invalidate any result whose source, config, manifest, or change-set identity drifts during execution. +- [ ] 4.5 Instrument all model/provider/browser-agent boundaries and add a qualification test proving normal benchmark execution performs zero model calls. +- [x] 4.6 Add deterministic runtime tests for pass, assertion regression, timeout, cancellation, stale source, invalid manifest, and teardown failure outcomes. +- [x] 4.7 Run the second cleanup gate: remove duplicated state/scheduling/observer helpers, simplify public contracts, report file/LOC growth, and rerun focused browser tests plus full typechecking. + +## 5. Automatic Observation + +- [x] 5.1 Attach pre-navigation page-error and console observers with narrow explained allowlists; remove broad suppression of fetch, network, and `net::ERR_` failures from the warm path. +- [x] 5.2 Implement failed-request, HTTP failure, unexpected first-party call, normalized mutation ledger, expected mutation count, and duplicate-mutation policies. +- [x] 5.3 Implement starting/intermediate/final route records, unexpected-transition policies, interaction timing, and slow-interaction budgets. +- [x] 5.4 Decide and document the accessibility scope; if full rules-engine auditing is accepted, add pinned dev-only `@axe-core/playwright`, otherwise ship and label the bounded smoke contract. +- [ ] 5.5 Implement deterministic screenshot checkpoint hashing, exact versioned baseline compatibility, bounded failure artifacts, and no-confidence handling for missing/stale baselines. +- [ ] 5.6 Add observer negative fixtures for uncaught exceptions, hidden network errors, 5xx responses, unexpected calls, double submit, auth redirect, slow interaction, accessibility failure, and visual change. + +## 6. Changed-Capability Selection + +- [x] 6.1 Add the direct `yaml` development dependency with lockfile update and license/security review; do not import an undeclared transitive parser or add a production dependency. +- [x] 6.2 Validate and cache explicit capability path globs, scenario IDs, mandatory smoke rules, shared-infrastructure rules, fallback sets, and budgets with actionable diagnostics. +- [ ] 6.3 Reuse CodeVetter's exact worktree/staged/commit/range Git change collection and preserve target/change-set identities in daemon requests and results. +- [x] 6.4 Implement deterministic path-to-capability-to-scenario selection, deduplication, stable ordering, and complete selection explanations. +- [x] 6.5 Force mandatory smoke and configured broad fallback for unmatched/shared paths, incomplete mappings, absent commands, or stale/truncated/untrusted supporting evidence. +- [ ] 6.6 Integrate impacted-test and graph/import/coverage evidence as additive ranked hints only, proving it cannot remove explicit scenarios, override fallback, or create pass evidence. +- [x] 6.7 Add selection fixtures for exact mapping, overlapping capabilities, shared scenario dedupe, shared infrastructure, unmatched files, invalid config, incomplete graph, and no safe fallback. + +## 7. CLI, Persistence, and Review Integration + +- [x] 7.1 Add `verify daemon start|status|stop` and `verify changed [--json]` with stable passed, regression, and no-confidence exit codes and bounded stdout/stderr. +- [ ] 7.2 Persist additive versioned warm-run summaries, selection, timings, observations, limitations, and artifact metadata without rewriting existing `synthetic_qa_runs` rows. +- [ ] 7.3 Adapt warm results into `SyntheticQaRunResult`, Review evidence/findings, timeline proof, and same-flow comparisons while preserving richer provenance. +- [ ] 7.4 Update staged verification so only exact, current, complete warm runs satisfy executable evidence; stale, skipped, cancelled, or operational runs remain unverified. +- [ ] 7.5 Add T-Rex daemon/server/browser health, selection explanation, run/cancel, timing, failure, artifact, retention, cleanup, and no-confidence states; keep Review and staged verification as read-only evidence consumers rather than duplicate control surfaces. +- [ ] 7.6 Add migration/rollback, legacy-row, CLI contract, persistence, Review proof, staged outcome, and mocked-browser UI tests. +- [ ] 7.7 Run the third cleanup gate across CLI, persistence, T-Rex, and Review adapters; delete superseded code paths, report file/LOC growth, and rerun the complete warm-verification and UI checks. + +## 8. Performance, Storage, and Reliability Gates + +- [ ] 8.1 Implement timing instrumentation for diff, selection, context/auth/state, navigation, actions, observation, screenshots, reporting, and teardown, with cold startup reported separately. +- [ ] 8.2 Run two warm-up batches plus at least 20 recorded 20-scenario batches and enforce whole-invocation p95 below 30 seconds on the recorded Mac. +- [ ] 8.3 Measure and publish the normal small changed-capability hot path, then set a regression budget from evidence without weakening the mandatory 20-scenario gate. +- [ ] 8.4 Run observer negative fixtures outside performance samples and prove all required automatic regressions are detected without handwritten assertions. +- [ ] 8.5 Implement passing-summary-only retention, failure/explicit artifact capture, count/byte/age caps, redacted cleanup controls, and shared Playwright-cache report-only behavior. +- [ ] 8.6 Run 100 warm batches with failures and cancellations; gate context/process cleanup, bounded RSS, stable browser/server reuse, artifact caps, and no Cargo/Tauri/production build invocation. +- [ ] 8.7 Profile parallelism one through four on the benchmark Mac and select the fastest stable default while retaining deterministic isolation. + +## 9. Documentation and Follow-Up Boundaries + +- [ ] 9.1 Document target setup, state-bridge/MSW integration, auth profiles, capability mapping, scenario authoring, CLI outcomes, troubleshooting, redaction, and cleanup. +- [ ] 9.2 Update architecture, Synthetic QA, testing, performance, storage, and `PROJECT_STATUS.md` with measured claims and explicit one-developer/one-app/one-Mac/one-Chromium limits. +- [ ] 9.3 Create separate follow-up OpenSpec changes for model-assisted spec-to-scenario compilation and bounded main-versus-working-tree differential verification; do not implement either in this change. +- [ ] 9.4 Run formatting, typecheck, lint, unit/integration/browser tests, strict Clippy, migration tests, OpenSpec strict validation, dependency/license audit, and production builds required for release qualification. +- [ ] 9.5 Sync and archive the completed OpenSpec change only after every correctness, performance, storage, security, and compatibility gate passes; release remains a separate explicitly authorized action. From 2e003b6981d9b8a708c08c0f8866fa45482482b2 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:47:14 +0530 Subject: [PATCH 012/141] feat(graph): define structural parser foundation --- apps/desktop/src-tauri/Cargo.lock | 191 ++++++- apps/desktop/src-tauri/Cargo.toml | 14 + apps/desktop/src-tauri/src/commands/mod.rs | 1 + .../src/commands/structural_graph/language.rs | 145 ++++++ .../src/commands/structural_graph/mod.rs | 2 + .../src/commands/structural_graph/types.rs | 482 ++++++++++++++++++ 6 files changed, 833 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/language.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/types.rs diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0619f63..cd78d08 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -494,9 +494,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.106" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066fce287b1d4eafef758e89e09d724a24808a9196fe9756b8ca90e86d0719a2" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cesu8" @@ -649,6 +653,20 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tokio", + "tree-sitter", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin-sg", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", "uuid", ] @@ -1279,6 +1297,12 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -4130,6 +4154,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -4299,6 +4324,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4403,6 +4434,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "string_cache" version = "0.8.9" @@ -5331,6 +5368,156 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-sg" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index d071363..1a96614 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -27,6 +27,20 @@ jwalk = "0.8" rayon = "1.10" libc = "0.2" portable-pty = "0.9.0" +tree-sitter = "0.26.3" +tree-sitter-c = "0.24.0" +tree-sitter-c-sharp = "0.23.0" +tree-sitter-cpp = "0.23.0" +tree-sitter-go = "0.25.0" +tree-sitter-java = "0.23.0" +tree-sitter-javascript = "0.25.0" +tree-sitter-kotlin = { package = "tree-sitter-kotlin-sg", version = "0.4.1" } +tree-sitter-php = "0.24.0" +tree-sitter-python = "0.25.0" +tree-sitter-ruby = "0.23.0" +tree-sitter-rust = "0.24.0" +tree-sitter-swift = "0.7.0" +tree-sitter-typescript = "0.23.2" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index eef9e4e..e560070 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -27,6 +27,7 @@ pub mod sandbox; pub mod session_adapters; pub mod sessions; pub mod setup; +pub mod structural_graph; pub mod synthetic_qa; pub mod taste; pub mod trex_watcher; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/language.rs b/apps/desktop/src-tauri/src/commands/structural_graph/language.rs new file mode 100644 index 0000000..014d2ec --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/language.rs @@ -0,0 +1,145 @@ +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SupportedLanguage { + TypeScript, + Tsx, + JavaScript, + Jsx, + Rust, + Python, + Go, + Java, + C, + Cpp, + CSharp, + Ruby, + Php, + Kotlin, + Swift, +} + +impl SupportedLanguage { + pub const ALL: [Self; 15] = [ + Self::TypeScript, + Self::Tsx, + Self::JavaScript, + Self::Jsx, + Self::Rust, + Self::Python, + Self::Go, + Self::Java, + Self::C, + Self::Cpp, + Self::CSharp, + Self::Ruby, + Self::Php, + Self::Kotlin, + Self::Swift, + ]; + + pub fn from_path(path: &Path) -> Option { + let file_name = path.file_name()?.to_str()?.to_ascii_lowercase(); + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + match extension.as_str() { + "ts" | "mts" | "cts" => Some(Self::TypeScript), + "tsx" => Some(Self::Tsx), + "js" | "mjs" | "cjs" => Some(Self::JavaScript), + "jsx" => Some(Self::Jsx), + "rs" => Some(Self::Rust), + "py" | "pyi" => Some(Self::Python), + "go" => Some(Self::Go), + "java" => Some(Self::Java), + "c" | "h" if !file_name.ends_with(".cs") => Some(Self::C), + "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => Some(Self::Cpp), + "cs" => Some(Self::CSharp), + "rb" | "rake" => Some(Self::Ruby), + "php" | "php3" | "php4" | "php5" | "phtml" => Some(Self::Php), + "kt" | "kts" => Some(Self::Kotlin), + "swift" => Some(Self::Swift), + _ if file_name == "rakefile" || file_name == "gemfile" => Some(Self::Ruby), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::TypeScript => "typescript", + Self::Tsx => "tsx", + Self::JavaScript => "javascript", + Self::Jsx => "jsx", + Self::Rust => "rust", + Self::Python => "python", + Self::Go => "go", + Self::Java => "java", + Self::C => "c", + Self::Cpp => "cpp", + Self::CSharp => "csharp", + Self::Ruby => "ruby", + Self::Php => "php", + Self::Kotlin => "kotlin", + Self::Swift => "swift", + } + } + + pub fn tree_sitter_language(self) -> tree_sitter::Language { + match self { + Self::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Self::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Self::JavaScript | Self::Jsx => tree_sitter_javascript::LANGUAGE.into(), + Self::Rust => tree_sitter_rust::LANGUAGE.into(), + Self::Python => tree_sitter_python::LANGUAGE.into(), + Self::Go => tree_sitter_go::LANGUAGE.into(), + Self::Java => tree_sitter_java::LANGUAGE.into(), + Self::C => tree_sitter_c::LANGUAGE.into(), + Self::Cpp => tree_sitter_cpp::LANGUAGE.into(), + Self::CSharp => tree_sitter_c_sharp::LANGUAGE.into(), + Self::Ruby => tree_sitter_ruby::LANGUAGE.into(), + Self::Php => tree_sitter_php::LANGUAGE_PHP.into(), + Self::Kotlin => tree_sitter_kotlin::LANGUAGE.into(), + Self::Swift => tree_sitter_swift::LANGUAGE.into(), + } + } +} + +pub fn supported_language_names() -> Vec { + let mut names = SupportedLanguage::ALL + .iter() + .map(|language| language.name().to_string()) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn promised_language_matrix_is_path_detectable() { + for (path, expected) in [ + ("app.ts", "typescript"), + ("view.tsx", "tsx"), + ("app.js", "javascript"), + ("view.jsx", "jsx"), + ("lib.rs", "rust"), + ("app.py", "python"), + ("main.go", "go"), + ("Main.java", "java"), + ("main.c", "c"), + ("main.cpp", "cpp"), + ("Main.cs", "csharp"), + ("app.rb", "ruby"), + ("index.php", "php"), + ("Main.kt", "kotlin"), + ("Main.swift", "swift"), + ] { + assert_eq!( + SupportedLanguage::from_path(Path::new(path)).map(SupportedLanguage::name), + Some(expected), + "{path}" + ); + } + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs new file mode 100644 index 0000000..4e59c2d --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -0,0 +1,2 @@ +pub mod language; +pub mod types; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/types.rs b/apps/desktop/src-tauri/src/commands/structural_graph/types.rs new file mode 100644 index 0000000..a55f451 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/types.rs @@ -0,0 +1,482 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +pub const STRUCTURAL_GRAPH_SCHEMA_VERSION: i64 = 3; +pub const BUNDLED_ENGINE_ID: &str = "codevetter-tree-sitter"; +pub const BUNDLED_ENGINE_VERSION: &str = "1"; +pub const STRUCTURAL_METRIC_SCHEMA_VERSION: i64 = 1; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum GraphTrust { + Extracted, + Inferred, + Ambiguous, + #[default] + Legacy, +} + +impl GraphTrust { + pub fn as_str(self) -> &'static str { + match self { + Self::Extracted => "extracted", + Self::Inferred => "inferred", + Self::Ambiguous => "ambiguous", + Self::Legacy => "legacy", + } + } + + pub fn from_storage(value: &str) -> Self { + match value { + "extracted" => Self::Extracted, + "inferred" => Self::Inferred, + "ambiguous" => Self::Ambiguous, + _ => Self::Legacy, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum GraphOrigin { + Syntax, + Resolution, + Analysis, + Metadata, + ImportedNodeLink, + UserAnnotation, + #[default] + LegacyMetadata, +} + +impl GraphOrigin { + pub fn as_str(&self) -> &'static str { + match self { + Self::Syntax => "syntax", + Self::Resolution => "resolution", + Self::Analysis => "analysis", + Self::Metadata => "metadata", + Self::ImportedNodeLink => "imported_node_link", + Self::UserAnnotation => "user_annotation", + Self::LegacyMetadata => "legacy_metadata", + } + } + + pub fn from_storage(value: &str) -> Self { + match value { + "syntax" => Self::Syntax, + "resolution" => Self::Resolution, + "analysis" => Self::Analysis, + "metadata" => Self::Metadata, + "imported_node_link" => Self::ImportedNodeLink, + "user_annotation" => Self::UserAnnotation, + _ => Self::LegacyMetadata, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GraphSourceAnchor { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_column: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_column: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub excerpt: Option, +} + +impl GraphSourceAnchor { + pub fn path(path: impl Into) -> Self { + Self { + path: path.into(), + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphNode { + pub id: String, + pub kind: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub qualified_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub community_id: Option, + #[serde(default)] + pub trust: GraphTrust, + #[serde(default)] + pub origin: GraphOrigin, + #[serde(default)] + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphEdge { + pub id: String, + pub from: String, + pub to: String, + pub kind: String, + pub evidence: String, + #[serde(default)] + pub trust: GraphTrust, + #[serde(default)] + pub origin: GraphOrigin, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub candidates: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralGraphCommunity { + pub id: String, + pub label: String, + pub member_count: usize, + #[serde(default)] + pub hub_node_ids: Vec, + #[serde(default)] + pub bridge_node_ids: Vec, + #[serde(default)] + pub score: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LanguageCoverage { + pub language: String, + pub supported: bool, + pub discovered_files: usize, + pub indexed_files: usize, + pub skipped_files: usize, + pub error_files: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct StructuralGraphCoverage { + pub discovered_files: usize, + pub indexed_files: usize, + pub skipped_files: usize, + pub error_files: usize, + pub generated_files: usize, + pub sensitive_files: usize, + pub binary_files: usize, + #[serde(default)] + pub languages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphDiagnostic { + pub severity: String, + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphEngineInfo { + pub id: String, + pub version: String, + pub bundled: bool, + pub syntax_aware: bool, + #[serde(default)] + pub supported_languages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphFileRecord { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_hash: Option, + pub disposition: String, + pub byte_size: u64, + pub node_count: usize, + pub edge_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralControlFlowFact { + pub id: String, + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + pub nesting: usize, + pub source: GraphSourceAnchor, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralBoundaryFact { + pub kind: String, + pub target: String, + pub source: GraphSourceAnchor, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct StructuralCodeMetrics { + pub line_count: usize, + pub statement_count: usize, + pub parameter_count: usize, + pub cyclomatic_complexity: usize, + pub cognitive_complexity: usize, + pub max_nesting: usize, + pub fan_in: usize, + pub fan_out: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cohesion: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralGraphMetricFact { + pub schema_version: i64, + pub id: String, + pub node_id: String, + pub path: String, + pub scope_kind: String, + pub language: String, + pub public_surface: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_surface_reason: Option, + pub syntax_fingerprint: String, + pub normalized_token_count: usize, + pub normalization_method: String, + pub metrics: StructuralCodeMetrics, + #[serde(default)] + pub control_flow: Vec, + #[serde(default)] + pub definitions: Vec, + #[serde(default)] + pub uses: Vec, + #[serde(default)] + pub boundaries: Vec, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralCloneRegion { + pub metric_id: String, + pub node_id: String, + pub path: String, + pub source: GraphSourceAnchor, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralCloneGroup { + pub id: String, + pub syntax_fingerprint: String, + pub normalization_method: String, + pub normalized_token_count: usize, + pub similarity: f64, + pub regions: Vec, + pub exclusions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralGraphSnapshot { + pub schema_version: i64, + pub id: String, + pub repo_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_head: Option, + pub created_at: String, + pub engine: StructuralGraphEngineInfo, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ignore_fingerprint: Option, + pub coverage: StructuralGraphCoverage, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub communities: Vec, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(default)] + pub metrics: Vec, + #[serde(default)] + pub clone_groups: Vec, + pub truncated: bool, +} + +pub fn namespaced_graph_id(repository_id: &str, local_id: &str) -> String { + stable_graph_id("workspace-node", &format!("{repository_id}\0{local_id}")) +} + +#[derive(Debug, Clone)] +pub struct StructuralGraphBuildInput { + pub repo_root: PathBuf, + pub repo_head: Option, + pub changed_files: Vec, + pub deleted_files: Vec, + pub previous_cursor: Option, + pub previous_snapshot: Option>, + pub max_files: usize, + pub max_bytes_per_file: u64, +} + +impl StructuralGraphBuildInput { + pub fn full(repo_root: PathBuf, repo_head: Option) -> Self { + Self { + repo_root, + repo_head, + changed_files: Vec::new(), + deleted_files: Vec::new(), + previous_cursor: None, + previous_snapshot: None, + max_files: 25_000, + max_bytes_per_file: 2 * 1024 * 1024, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphProgress { + pub phase: String, + pub completed: usize, + pub total: usize, + pub detail: String, +} + +#[derive(Debug, Clone, Default)] +pub struct StructuralGraphCancellation { + cancelled: Arc, +} + +impl StructuralGraphCancellation { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StructuralGraphError { + Cancelled, + InvalidRepository(String), + Io(String), + Parse(String), + Storage(String), + UnsupportedSchema(i64), +} + +impl std::fmt::Display for StructuralGraphError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cancelled => write!(formatter, "Structural graph build cancelled"), + Self::InvalidRepository(message) + | Self::Io(message) + | Self::Parse(message) + | Self::Storage(message) => formatter.write_str(message), + Self::UnsupportedSchema(version) => { + write!( + formatter, + "Unsupported structural graph schema version {version}" + ) + } + } + } +} + +impl std::error::Error for StructuralGraphError {} + +pub trait StructuralGraphProgressSink: Send + Sync { + fn report(&self, progress: StructuralGraphProgress); +} + +impl StructuralGraphProgressSink for F +where + F: Fn(StructuralGraphProgress) + Send + Sync, +{ + fn report(&self, progress: StructuralGraphProgress) { + self(progress); + } +} + +pub trait StructuralGraphEngine: Send + Sync { + fn info(&self) -> StructuralGraphEngineInfo; + + fn build( + &self, + input: &StructuralGraphBuildInput, + cancellation: &StructuralGraphCancellation, + progress: &dyn StructuralGraphProgressSink, + ) -> Result; +} + +pub fn stable_graph_id(kind: &str, identity: &str) -> String { + // FNV-1a is deliberately implemented here instead of DefaultHasher, whose + // output is not a stable persistence contract across Rust releases. + let mut hash = 0xcbf29ce484222325_u64; + for byte in kind.bytes().chain([0]).chain(identity.bytes()) { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{kind}:{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_ids_are_deterministic_and_kind_scoped() { + assert_eq!( + stable_graph_id("function", "src/main.rs::run"), + stable_graph_id("function", "src/main.rs::run") + ); + assert_ne!( + stable_graph_id("function", "src/main.rs::run"), + stable_graph_id("method", "src/main.rs::run") + ); + } + + #[test] + fn cancellation_is_shared_between_clones() { + let first = StructuralGraphCancellation::default(); + let second = first.clone(); + second.cancel(); + assert!(first.is_cancelled()); + } + + #[test] + fn workspace_ids_namespace_matching_local_symbols_by_repository() { + let local = "function:shared"; + let first = namespaced_graph_id("repo:first", local); + let second = namespaced_graph_id("repo:second", local); + assert_ne!(first, second); + assert_eq!(first, namespaced_graph_id("repo:first", local)); + assert!(first.starts_with("workspace-node:")); + } +} From 77c0831f7513002bd77ff0de55edb20151f9d2d1 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:47:54 +0530 Subject: [PATCH 013/141] feat(graph): enforce repository extraction contracts --- .../commands/structural_graph/contracts.rs | 1061 +++++++++++++++++ .../src/commands/structural_graph/mod.rs | 1 + 2 files changed, 1062 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/contracts.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/contracts.rs b/apps/desktop/src-tauri/src/commands/structural_graph/contracts.rs new file mode 100644 index 0000000..d1a1334 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/contracts.rs @@ -0,0 +1,1061 @@ +//! Dependency-free extraction of framework, API-contract, and data-lineage facts. +//! +//! These scanners intentionally recognize only explicit, source-backed forms. A +//! later resolution pass may connect reference facts to declarations; collisions +//! remain ambiguous instead of being guessed here. + +use super::types::GraphTrust; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractFact { + pub key: String, + pub line: usize, + pub kind: String, + pub label: String, + pub edge_kind: String, + pub detail: String, + pub trust: GraphTrust, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractLink { + pub from_key: String, + pub to_key: String, + pub edge_kind: String, + pub detail: String, + pub trust: GraphTrust, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ContractExtraction { + pub facts: Vec, + pub links: Vec, +} + +impl ContractExtraction { + fn fact( + &mut self, + line: usize, + kind: &str, + label: impl Into, + edge_kind: &str, + detail: &str, + ) -> String { + let label = clean_label(&label.into()); + if label.is_empty() || label.len() > 240 { + return String::new(); + } + let key = format!("{kind}:{line}:{label}:{}", self.facts.len()); + self.facts.push(ContractFact { + key: key.clone(), + line, + kind: kind.to_string(), + label, + edge_kind: edge_kind.to_string(), + detail: detail.to_string(), + trust: GraphTrust::Extracted, + }); + key + } + + fn reference( + &mut self, + line: usize, + kind: &str, + label: impl Into, + edge_kind: &str, + detail: &str, + ) -> String { + self.fact(line, kind, label, edge_kind, detail) + } + + fn ambiguous_reference(&mut self, line: usize, label: impl Into, detail: &str) { + let key = self.fact(line, "dynamic_reference", label, "may_reference", detail); + if let Some(fact) = self.facts.iter_mut().find(|fact| fact.key == key) { + fact.trust = GraphTrust::Ambiguous; + } + } + + fn link(&mut self, from_key: &str, to_key: &str, edge_kind: &str, detail: &str) { + if from_key.is_empty() || to_key.is_empty() { + return; + } + self.links.push(ContractLink { + from_key: from_key.to_string(), + to_key: to_key.to_string(), + edge_kind: edge_kind.to_string(), + detail: detail.to_string(), + trust: GraphTrust::Extracted, + }); + } +} + +pub fn extract_contracts(path: &str, source: &str) -> ContractExtraction { + let lower_path = path.to_ascii_lowercase(); + let lines = source.lines().collect::>(); + let mut output = ContractExtraction::default(); + let mut openapi_route: Option<(usize, String, String)> = None; + let mut openapi_operation: Option = None; + let mut openapi_schema_indent: Option = None; + let mut proto_service: Option = None; + + for (index, raw_line) in lines.iter().enumerate() { + let line_number = index + 1; + let line = raw_line.trim(); + let lower = line.to_ascii_lowercase(); + if line.is_empty() || line.starts_with("//") || line.starts_with('#') { + continue; + } + + extract_sql(line_number, line, &lower, &mut output); + extract_route_and_handler(line_number, line, &lower, &lines, index, &mut output); + extract_jobs_events_and_bindings(line_number, line, &lower, &lines, index, &mut output); + extract_config_reference(line_number, line, &lower, &mut output); + extract_dynamic_reference(line_number, line, &lower, &mut output); + + if is_openapi_path(&lower_path, source) { + extract_openapi( + line_number, + raw_line, + &lower, + &mut openapi_route, + &mut openapi_operation, + &mut openapi_schema_indent, + &mut output, + ); + } + if lower_path.ends_with(".graphql") || lower_path.ends_with(".gql") { + extract_graphql(line_number, line, &lower, &mut output); + } + if lower_path.ends_with(".proto") { + extract_protobuf(line_number, line, &lower, &mut proto_service, &mut output); + } + if is_dbt_path(&lower_path) { + extract_dbt(line_number, line, &lower, &mut output); + } + } + output +} + +fn extract_sql(line_number: usize, line: &str, lower: &str, output: &mut ContractExtraction) { + for (marker, kind, detail) in [ + ("table", "db_table", "SQL table declaration"), + ("view", "db_view", "SQL view declaration"), + ("index", "db_index", "SQL index declaration"), + ] { + if lower.contains(&format!("create {marker}")) + || lower.contains(&format!("create or replace {marker}")) + { + if let Some(label) = sql_object_after(line, marker) { + output.fact(line_number, kind, label, "declares", detail); + } + } + } + for (marker, edge_kind) in [ + ("from", "reads_from"), + ("join", "reads_from"), + ("update", "writes_to"), + ("into", "writes_to"), + ("delete from", "writes_to"), + ] { + for label in sql_references_after(line, marker) { + output.reference( + line_number, + "db_object_reference", + label, + edge_kind, + "explicit SQL object reference", + ); + } + } +} + +fn extract_route_and_handler( + line_number: usize, + line: &str, + lower: &str, + lines: &[&str], + index: usize, + output: &mut ContractExtraction, +) { + let route_marker = [ + "router.get(", + "router.post(", + "router.put(", + "router.patch(", + "router.delete(", + "app.get(", + "app.post(", + "app.put(", + "app.patch(", + "app.delete(", + "@get(", + "@post(", + "@put(", + "@patch(", + "@delete(", + "#[get(", + "#[post(", + "#[put(", + "#[patch(", + "#[delete(", + "@getmapping(", + "@postmapping(", + "@putmapping(", + "@deletemapping(", + "@requestmapping(", + "handlefunc(", + "route::get(", + "route::post(", + "get \"/", + "get '/", + "post \"/", + "post '/", + "= 2 { + let binding = output.fact( + line_number, + "dependency_binding", + format!("{} -> {}", identifiers[0], identifiers[1]), + "binds", + "explicit dependency-injection binding", + ); + let contract = output.reference( + line_number, + "type_reference", + &identifiers[0], + "references", + "dependency contract", + ); + let implementation = output.reference( + line_number, + "type_reference", + &identifiers[1], + "references", + "dependency implementation", + ); + output.link(&binding, &contract, "binds_contract", "binding contract"); + output.link( + &binding, + &implementation, + "binds_to", + "binding implementation", + ); + } + } + if lower.contains("addscoped<") + || lower.contains("addsingleton<") + || lower.contains("addtransient<") + { + let identifiers = generic_identifiers(line); + if identifiers.len() >= 2 { + let binding = output.fact( + line_number, + "dependency_binding", + format!("{} -> {}", identifiers[0], identifiers[1]), + "binds", + "explicit dependency-injection registration", + ); + let implementation = output.reference( + line_number, + "type_reference", + &identifiers[1], + "references", + "dependency implementation", + ); + output.link( + &binding, + &implementation, + "binds_to", + "binding implementation", + ); + } + } +} + +fn extract_config_reference( + line_number: usize, + line: &str, + lower: &str, + output: &mut ContractExtraction, +) { + let markers = ["process.env.", "import.meta.env."]; + for marker in markers { + if let Some(position) = lower.find(marker) { + let start = position + marker.len(); + let label = line[start..] + .chars() + .take_while(|character| character.is_ascii_alphanumeric() || *character == '_') + .collect::(); + output.reference( + line_number, + "configuration_reference", + label, + "reads_config", + "explicit environment configuration reference", + ); + } + } + for marker in ["std::env::var(", "env::var(", "os.getenv("] { + if lower.contains(marker) { + if let Some(label) = first_quoted(line) { + output.reference( + line_number, + "configuration_reference", + label, + "reads_config", + "explicit environment configuration reference", + ); + } + } + } +} + +fn extract_dynamic_reference( + line_number: usize, + line: &str, + lower: &str, + output: &mut ContractExtraction, +) { + let marker = [ + "getattr(", + "setattr(", + "import_module(", + "class.forname(", + "type.gettype(", + "activator.createinstance(", + "method.invoke(", + "container.resolve(", + "dlsym(", + "libloading", + "send(", + "const_get(", + ] + .iter() + .find(|marker| lower.contains(**marker)); + let Some(marker) = marker else { + return; + }; + let label = quoted_values(line) + .into_iter() + .next_back() + .unwrap_or_else(|| format!("{marker} at line {line_number}")); + output.ambiguous_reference( + line_number, + label, + "reflection or runtime lookup may reference a symbol dynamically", + ); +} + +fn extract_openapi( + line_number: usize, + line: &str, + lower: &str, + current_route: &mut Option<(usize, String, String)>, + current_operation: &mut Option, + schema_indent: &mut Option, + output: &mut ContractExtraction, +) { + let indent = leading_indent(line); + let key = mapping_key(line); + if key + .as_deref() + .is_some_and(|key| key.eq_ignore_ascii_case("schemas")) + { + *schema_indent = Some(indent); + return; + } + if let Some(root_indent) = *schema_indent { + if indent <= root_indent { + *schema_indent = None; + } else if indent == root_indent + 2 && key.is_some() { + output.fact( + line_number, + "openapi_schema", + key.as_deref().unwrap_or_default(), + "declares", + "OpenAPI component schema declaration", + ); + } + } + if key.as_deref().is_some_and(|key| key.starts_with('/')) { + let label = key.as_deref().unwrap_or_default(); + let key = output.fact( + line_number, + "openapi_path", + label, + "declares", + "OpenAPI path declaration", + ); + *current_route = Some((leading_indent(line), label.to_string(), key)); + *current_operation = None; + return; + } + if current_route + .as_ref() + .is_some_and(|(route_indent, _, _)| indent <= *route_indent) + { + *current_route = None; + *current_operation = None; + } + let operation = ["get", "post", "put", "patch", "delete", "options", "head"] + .iter() + .find(|method| { + key.as_deref() + .is_some_and(|key| key.eq_ignore_ascii_case(method)) + }); + if let (Some(method), Some((_, path, path_key))) = (operation, current_route.as_ref()) { + let operation_key = output.fact( + line_number, + "openapi_operation", + format!("{} {path}", method.to_ascii_uppercase()), + "declares", + "OpenAPI operation declaration", + ); + output.link( + path_key, + &operation_key, + "exposes", + "OpenAPI path exposes this operation", + ); + *current_operation = Some(operation_key); + } + if key.as_deref() == Some("$ref") || lower.contains("$ref") { + if let Some(reference) = mapping_value(line) { + let reference_key = output.reference( + line_number, + "schema_reference", + reference, + "references_schema", + "OpenAPI schema reference", + ); + if let Some(operation) = current_operation.as_ref() { + output.link( + operation, + &reference_key, + "uses_schema", + "OpenAPI operation references this schema", + ); + } + } + } + if key + .as_deref() + .is_some_and(|key| key.eq_ignore_ascii_case("operationId")) + { + if let Some(label) = line.split(':').nth(1) { + let handler_key = output.reference( + line_number, + "handler_reference", + clean_label(label), + "implemented_by", + "OpenAPI operationId handler reference", + ); + if let Some(operation) = current_operation.as_ref() { + output.link( + operation, + &handler_key, + "implemented_by", + "OpenAPI operationId names this handler", + ); + } + } + } +} + +fn extract_graphql(line_number: usize, line: &str, lower: &str, output: &mut ContractExtraction) { + for (keyword, kind) in [ + ("type ", "graphql_type"), + ("input ", "graphql_input"), + ("interface ", "graphql_interface"), + ("enum ", "graphql_enum"), + ("scalar ", "graphql_scalar"), + ("directive ", "graphql_directive"), + ] { + if lower.starts_with(keyword) { + if let Some(label) = identifier_after(line, keyword.len()) { + output.fact( + line_number, + kind, + label, + "declares", + "GraphQL schema declaration", + ); + } + } + } + if lower.starts_with("query ") + || lower.starts_with("mutation ") + || lower.starts_with("subscription ") + { + if let Some(label) = line.split_whitespace().nth(1) { + output.fact( + line_number, + "graphql_operation", + clean_label(label), + "declares", + "GraphQL operation declaration", + ); + } + } +} + +fn extract_protobuf( + line_number: usize, + line: &str, + lower: &str, + current_service: &mut Option, + output: &mut ContractExtraction, +) { + for (keyword, kind) in [ + ("message ", "protobuf_message"), + ("enum ", "protobuf_enum"), + ("service ", "protobuf_service"), + ] { + if lower.starts_with(keyword) { + if let Some(label) = identifier_after(line, keyword.len()) { + let key = output.fact( + line_number, + kind, + &label, + "declares", + "protobuf contract declaration", + ); + if kind == "protobuf_service" { + *current_service = Some(key); + } + } + } + } + if lower.starts_with("rpc ") { + if let Some(label) = identifier_after(line, 4) { + let rpc_key = output.fact( + line_number, + "protobuf_rpc", + label, + "declares", + "protobuf RPC declaration", + ); + if let Some(service) = current_service.as_ref() { + output.link(service, &rpc_key, "exposes", "service exposes this RPC"); + } + let identifiers = parenthesized_identifiers(line); + for (position, contract) in identifiers.into_iter().take(2).enumerate() { + let reference = output.reference( + line_number, + "protobuf_message_reference", + contract, + "references", + "protobuf RPC message contract", + ); + output.link( + &rpc_key, + &reference, + if position == 0 { "accepts" } else { "returns" }, + "RPC request/response contract", + ); + } + } + } +} + +fn extract_dbt(line_number: usize, line: &str, lower: &str, output: &mut ContractExtraction) { + for marker in ["ref(", "source("] { + if lower.contains(marker) { + let values = quoted_values(line); + if let Some(label) = values.last() { + output.reference( + line_number, + "dbt_model_reference", + label, + "depends_on", + "explicit dbt model/source reference", + ); + } + } + } + if lower.starts_with("- name:") || lower.starts_with("name:") { + if let Some(label) = line.split(':').nth(1) { + output.fact( + line_number, + "dbt_model", + clean_label(label), + "declares", + "dbt model/schema declaration", + ); + } + } +} + +fn is_openapi_path(path: &str, source: &str) -> bool { + path.contains("openapi") + || path.contains("swagger") + || source.lines().take(20).any(|line| { + let lower = line.to_ascii_lowercase(); + lower.contains("openapi:") || lower.contains("\"openapi\"") + }) +} + +fn is_dbt_path(path: &str) -> bool { + path.starts_with("models/") + || path.contains("/models/") + || path.ends_with("schema.yml") + || path.ends_with("schema.yaml") +} + +fn mapping_key(line: &str) -> Option { + let trimmed = line.trim().trim_end_matches(','); + if let Some(quote) = trimmed + .chars() + .next() + .filter(|value| matches!(value, '"' | '\'')) + { + let remainder = &trimmed[quote.len_utf8()..]; + let end = remainder.find(quote)?; + if remainder[end + quote.len_utf8()..] + .trim_start() + .starts_with(':') + { + return Some(remainder[..end].trim().to_string()); + } + } + let (key, _) = trimmed.split_once(':')?; + let key = clean_label(key); + (!key.is_empty()).then_some(key) +} + +fn mapping_value(line: &str) -> Option { + let (_, value) = line.split_once(':')?; + let value = clean_label(value.trim_end_matches(',')); + (!value.is_empty()).then_some(value) +} + +fn sql_object_after(line: &str, object_kind: &str) -> Option { + let tokens = sql_tokens(line); + let position = tokens + .iter() + .position(|token| token.eq_ignore_ascii_case(object_kind))?; + tokens + .iter() + .skip(position + 1) + .find(|token| { + !matches!( + token.to_ascii_lowercase().as_str(), + "if" | "not" | "exists" | "unique" | "concurrently" + ) + }) + .map(|value| clean_sql_identifier(value)) +} + +fn sql_references_after(line: &str, marker: &str) -> Vec { + let tokens = sql_tokens(line); + let marker_tokens = marker.split_whitespace().collect::>(); + let mut results = Vec::new(); + for window_start in 0..tokens.len() { + if window_start + marker_tokens.len() >= tokens.len() { + break; + } + let matches = marker_tokens + .iter() + .enumerate() + .all(|(offset, expected)| tokens[window_start + offset].eq_ignore_ascii_case(expected)); + if matches { + let candidate = clean_sql_identifier(tokens[window_start + marker_tokens.len()]); + if !candidate.is_empty() && !candidate.starts_with('(') { + results.push(candidate); + } + } + } + results +} + +fn sql_tokens(line: &str) -> Vec<&str> { + line.split(|character: char| { + character.is_whitespace() || matches!(character, '(' | ')' | ',' | ';' | '=') + }) + .filter(|token| !token.is_empty()) + .collect() +} + +fn clean_sql_identifier(value: &str) -> String { + value + .trim_matches(['`', '"', '\'', '[', ']']) + .trim_end_matches(|character: char| { + !character.is_alphanumeric() && character != '_' && character != '.' + }) + .to_string() +} + +fn quoted_values(line: &str) -> Vec { + let mut output = Vec::new(); + let mut quote = None; + let mut start = 0; + for (index, character) in line.char_indices() { + if let Some(active) = quote { + if character == active { + let value = line[start..index].trim(); + if !value.is_empty() { + output.push(value.to_string()); + } + quote = None; + } + } else if matches!(character, '"' | '\'' | '`') { + quote = Some(character); + start = index + character.len_utf8(); + } + } + output +} + +fn first_quoted(line: &str) -> Option { + quoted_values(line).into_iter().next() +} + +fn handler_identifier(line: &str) -> Option { + let after_comma = line.rsplit_once(',')?.1; + let candidate = after_comma + .trim() + .trim_matches([')', ']', '}', ';', ' ', '<', '>', '/']) + .split(|character: char| { + !(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | ':')) + }) + .find(|token| !token.is_empty())?; + let terminal = candidate.split(['.', ':']).rfind(|part| !part.is_empty())?; + is_identifier(terminal).then(|| terminal.to_string()) +} + +fn function_identifier(line: &str) -> Option { + for marker in ["fn ", "function ", "def ", "func ", "fun "] { + if let Some(position) = line.find(marker) { + return identifier_after(line, position + marker.len()); + } + } + None +} + +fn identifier_after(line: &str, start: usize) -> Option { + let value = line.get(start..)?.trim_start(); + let identifier = value + .chars() + .take_while(|character| character.is_ascii_alphanumeric() || *character == '_') + .collect::(); + is_identifier(&identifier).then_some(identifier) +} + +fn parenthesized_identifiers(line: &str) -> Vec { + let mut output = Vec::new(); + let mut remainder = line; + while let Some(start) = remainder.find('(') { + let after = &remainder[start + 1..]; + let Some(end) = after.find(')') else { + break; + }; + for candidate in after[..end].split(',') { + let value = clean_label(candidate); + if is_identifier(&value) { + output.push(value); + } + } + remainder = &after[end + 1..]; + } + output +} + +fn generic_identifiers(line: &str) -> Vec { + let Some(start) = line.find('<') else { + return Vec::new(); + }; + let Some(end) = line[start + 1..].find('>') else { + return Vec::new(); + }; + line[start + 1..start + 1 + end] + .split(',') + .map(clean_label) + .filter(|value| is_identifier(value)) + .collect() +} + +fn is_identifier(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') +} + +fn clean_label(value: &str) -> String { + value + .trim() + .trim_matches(['`', '"', '\'', ';', ',', '(', ')', '{', '}', '[', ']']) + .trim() + .to_string() +} + +fn leading_indent(line: &str) -> usize { + line.chars() + .take_while(|character| character.is_whitespace()) + .count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_route_handler_jobs_events_bindings_and_config() { + let extraction = extract_contracts( + "src/app.ts", + r#" +router.get('/users', listUsers); +queue.add('refresh-users', payload); +bus.emit('user.updated', user); +bus.on('user.created', handleCreated); +container.bind(UserStore).to(SqlUserStore); +const key = process.env.ANALYTICS_KEY; +const field = getattr(user, 'display_name'); +"#, + ); + for (kind, label) in [ + ("route", "/users"), + ("handler_reference", "listUsers"), + ("job_reference", "refresh-users"), + ("event_reference", "user.updated"), + ("event_subscription", "user.created"), + ("dependency_binding", "UserStore -> SqlUserStore"), + ("configuration_reference", "ANALYTICS_KEY"), + ("dynamic_reference", "display_name"), + ] { + assert!( + extraction + .facts + .iter() + .any(|fact| fact.kind == kind && fact.label == label), + "missing {kind} {label}: {:?}", + extraction.facts + ); + } + assert!(extraction + .links + .iter() + .any(|link| link.edge_kind == "routes_to")); + assert!(extraction + .links + .iter() + .any(|link| link.edge_kind == "binds_to")); + assert!(extraction.facts.iter().any(|fact| { + fact.kind == "dynamic_reference" && fact.trust == GraphTrust::Ambiguous + })); + } + + #[test] + fn extracts_sql_openapi_graphql_protobuf_and_dbt_contracts() { + let sql = extract_contracts( + "models/orders.sql", + "CREATE VIEW order_summary AS SELECT * FROM orders JOIN users ON users.id = orders.user_id;", + ); + assert!(sql.facts.iter().any(|fact| fact.kind == "db_view")); + assert!( + sql.facts + .iter() + .filter(|fact| fact.kind == "db_object_reference") + .count() + >= 2 + ); + + let openapi = extract_contracts( + "openapi.yaml", + "openapi: 3.1.0\npaths:\n /users:\n get:\n operationId: listUsers\n $ref: '#/components/schemas/User'\ncomponents:\n schemas:\n User:\n type: object\n", + ); + assert!(openapi + .facts + .iter() + .any(|fact| fact.kind == "openapi_operation" && fact.label == "GET /users")); + assert!(openapi + .facts + .iter() + .any(|fact| fact.kind == "schema_reference")); + assert!(openapi + .facts + .iter() + .any(|fact| fact.kind == "openapi_schema" && fact.label == "User")); + assert!(openapi + .links + .iter() + .any(|link| link.edge_kind == "implemented_by")); + let openapi_json = extract_contracts( + "swagger.json", + "{\n \"openapi\": \"3.1.0\",\n \"paths\": {\n \"/users\": {\n \"post\": {\n \"operationId\": \"createUser\",\n \"$ref\": \"#/components/schemas/User\"\n }\n }\n }\n}", + ); + assert!(openapi_json + .facts + .iter() + .any(|fact| { fact.kind == "openapi_operation" && fact.label == "POST /users" })); + assert!(openapi_json + .facts + .iter() + .any(|fact| fact.kind == "handler_reference" && fact.label == "createUser")); + assert!(openapi_json.facts.iter().any(|fact| { + fact.kind == "schema_reference" && fact.label == "#/components/schemas/User" + })); + + let graphql = extract_contracts( + "schema.graphql", + "type User { id: ID! }\nquery UserById($id: ID!) { user(id: $id) { id } }\n", + ); + assert!(graphql + .facts + .iter() + .any(|fact| fact.kind == "graphql_type" && fact.label == "User")); + + let protobuf = extract_contracts( + "user.proto", + "service Users {\n rpc GetUser (GetUserRequest) returns (User);\n}\nmessage GetUserRequest {}\nmessage User {}\n", + ); + assert!(protobuf + .facts + .iter() + .any(|fact| fact.kind == "protobuf_rpc" && fact.label == "GetUser")); + assert!(protobuf + .links + .iter() + .any(|link| link.edge_kind == "accepts")); + + let dbt = extract_contracts("models/orders.sql", "select * from {{ ref('users') }}"); + assert!(dbt + .facts + .iter() + .any(|fact| fact.kind == "dbt_model_reference" && fact.label == "users")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index 4e59c2d..74a66eb 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,2 +1,3 @@ +mod contracts; pub mod language; pub mod types; From a372dfa72d52e1c41431afbd7c42b34602d2ebf4 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:48:28 +0530 Subject: [PATCH 014/141] feat(graph): analyze canonical topology --- .../src/commands/structural_graph/analysis.rs | 1220 +++++++++++++++++ .../src/commands/structural_graph/mod.rs | 1 + 2 files changed, 1221 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/analysis.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/analysis.rs b/apps/desktop/src-tauri/src/commands/structural_graph/analysis.rs new file mode 100644 index 0000000..41fd26b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/analysis.rs @@ -0,0 +1,1220 @@ +use super::types::{ + stable_graph_id, GraphTrust, StructuralGraphCommunity, StructuralGraphCoverage, + StructuralGraphEdge, StructuralGraphNode, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::Path; + +pub const STRUCTURAL_GRAPH_ANALYSIS_VERSION: &str = "2"; +const MAX_RANKED_METRICS: usize = 500; +const MAX_COMPONENTS: usize = 500; +const MAX_EXECUTION_FLOWS: usize = 100; +const MAX_EXECUTION_FLOW_DEPTH: usize = 8; +const PAGERANK_ITERATIONS: usize = 40; +const PAGERANK_DAMPING: f64 = 0.85; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphAnalysisPolicy { + pub algorithm_version: String, + pub included_edge_kinds: Vec, + pub execution_edge_kinds: Vec, + pub included_trust: Vec, + pub direction: String, + pub max_ranked_metrics: usize, + pub max_components: usize, + pub max_execution_flows: usize, + pub max_execution_flow_depth: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct StructuralGraphAnalysisCoverage { + pub complete: bool, + pub reachability_complete: bool, + pub trusted_edge_count: usize, + pub excluded_edge_count: usize, + pub unresolved_endpoint_count: usize, + pub gaps: Vec, + pub output_truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralGraphNodeMetric { + pub node_id: String, + pub in_degree: usize, + pub out_degree: usize, + pub total_degree: usize, + pub degree_centrality: f64, + pub pagerank: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphComponent { + pub id: String, + pub node_ids: Vec, + pub edge_ids: Vec, + pub cyclic: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphExecutionFlow { + pub entrypoint_node_id: String, + pub node_ids: Vec, + pub edge_ids: Vec, + pub terminal_reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct StructuralGraphAlgorithmResults { + pub node_metrics: Vec, + pub strongly_connected_components: Vec, + pub cycles: Vec, + pub articulation_node_ids: Vec, + pub entrypoint_node_ids: Vec, + pub reachable_node_ids: Vec, + pub unreachable_node_ids: Vec, + pub execution_flows: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralGraphNodeRank { + pub node_id: String, + pub label: String, + pub kind: String, + pub path: Option, + pub degree: usize, + pub score: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StructuralGraphConnectionInsight { + pub edge_id: String, + pub from_community_id: String, + pub to_community_id: String, + pub score: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphSuggestedQuestion { + pub question: String, + pub node_ids: Vec, + pub source_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct StructuralGraphAnalysisSummary { + #[serde(default = "default_analysis_policy")] + pub policy: StructuralGraphAnalysisPolicy, + #[serde(default)] + pub coverage: StructuralGraphAnalysisCoverage, + #[serde(default)] + pub algorithms: StructuralGraphAlgorithmResults, + pub communities: Vec, + pub hubs: Vec, + pub super_hubs: Vec, + pub bridges: Vec, + pub cross_community_edges: Vec, + pub surprising_connections: Vec, + pub suggested_questions: Vec, +} + +fn default_analysis_policy() -> StructuralGraphAnalysisPolicy { + StructuralGraphAnalysisPolicy { + algorithm_version: STRUCTURAL_GRAPH_ANALYSIS_VERSION.to_string(), + included_edge_kinds: Vec::new(), + execution_edge_kinds: Vec::new(), + included_trust: vec![GraphTrust::Extracted, GraphTrust::Inferred], + direction: "from_to".to_string(), + max_ranked_metrics: MAX_RANKED_METRICS, + max_components: MAX_COMPONENTS, + max_execution_flows: MAX_EXECUTION_FLOWS, + max_execution_flow_depth: MAX_EXECUTION_FLOW_DEPTH, + } +} + +impl Default for StructuralGraphAnalysisPolicy { + fn default() -> Self { + default_analysis_policy() + } +} + +pub fn analyze_graph( + nodes: &mut [StructuralGraphNode], + edges: &[StructuralGraphEdge], +) -> Vec { + let community_key_by_node = assign_community_keys( + nodes, + edges.iter().filter(|edge| is_algorithm_trusted(edge.trust)), + ); + let mut degree: HashMap<&str, usize> = HashMap::new(); + let mut bridge_nodes: HashMap> = HashMap::new(); + for edge in edges.iter().filter(|edge| is_algorithm_trusted(edge.trust)) { + *degree.entry(edge.from.as_str()).or_default() += 1; + *degree.entry(edge.to.as_str()).or_default() += 1; + let Some(from_community) = community_key_by_node.get(&edge.from) else { + continue; + }; + let Some(to_community) = community_key_by_node.get(&edge.to) else { + continue; + }; + if from_community != to_community { + bridge_nodes + .entry(from_community.clone()) + .or_default() + .insert(edge.from.clone()); + bridge_nodes + .entry(to_community.clone()) + .or_default() + .insert(edge.to.clone()); + } + } + + let mut members: BTreeMap> = BTreeMap::new(); + for node in nodes.iter_mut() { + let key = community_key_by_node + .get(&node.id) + .cloned() + .unwrap_or_else(|| "root".to_string()); + let community_id = stable_graph_id("community", &key); + node.community_id = Some(community_id); + members.entry(key).or_default().push(node.id.clone()); + } + + members + .into_iter() + .map(|(key, mut member_ids)| { + member_ids.sort(); + let mut ranked = member_ids.clone(); + ranked.sort_by(|left, right| { + degree + .get(right.as_str()) + .copied() + .unwrap_or(0) + .cmp(°ree.get(left.as_str()).copied().unwrap_or(0)) + .then_with(|| left.cmp(right)) + }); + let hub_node_ids = ranked + .into_iter() + .filter(|node_id| degree.get(node_id.as_str()).copied().unwrap_or(0) > 0) + .take(5) + .collect::>(); + let mut bridges = bridge_nodes + .remove(&key) + .unwrap_or_default() + .into_iter() + .collect::>(); + bridges.sort(); + let score = member_ids + .iter() + .map(|node_id| degree.get(node_id.as_str()).copied().unwrap_or(0)) + .sum::() as f64; + StructuralGraphCommunity { + id: stable_graph_id("community", &key), + label: key, + member_count: member_ids.len(), + hub_node_ids, + bridge_node_ids: bridges, + score, + } + }) + .collect() +} + +fn assign_community_keys<'a>( + nodes: &[StructuralGraphNode], + edges: impl IntoIterator, +) -> HashMap { + let node_ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let path_seed = nodes + .iter() + .map(|node| (node.id.clone(), community_key(node))) + .collect::>(); + let mut labels = path_seed.clone(); + let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new(); + for edge in edges { + if !node_ids.contains(edge.from.as_str()) || !node_ids.contains(edge.to.as_str()) { + continue; + } + adjacency.entry(&edge.from).or_default().push(&edge.to); + adjacency.entry(&edge.to).or_default().push(&edge.from); + } + for neighbors in adjacency.values_mut() { + neighbors.sort_unstable(); + neighbors.dedup(); + } + let mut ordered_ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + ordered_ids.sort_unstable(); + for _ in 0..8 { + let mut next = labels.clone(); + let mut changed = false; + for node_id in &ordered_ids { + let Some(current) = labels.get(*node_id) else { + continue; + }; + let mut scores = BTreeMap::::new(); + *scores.entry(current.clone()).or_default() += 2; + if let Some(seed) = path_seed.get(*node_id) { + *scores.entry(seed.clone()).or_default() += 1; + } + for neighbor in adjacency.get(*node_id).into_iter().flatten() { + if let Some(label) = labels.get(*neighbor) { + *scores.entry(label.clone()).or_default() += 1; + } + } + let selected = scores + .into_iter() + .max_by(|(left_label, left_score), (right_label, right_score)| { + left_score + .cmp(right_score) + .then_with(|| right_label.cmp(left_label)) + }) + .map(|(label, _)| label) + .unwrap_or_else(|| current.clone()); + if selected != *current { + next.insert((*node_id).to_string(), selected); + changed = true; + } + } + labels = next; + if !changed { + break; + } + } + labels +} + +pub fn summarize_graph_analysis( + nodes: &[StructuralGraphNode], + edges: &[StructuralGraphEdge], + communities: &[StructuralGraphCommunity], +) -> StructuralGraphAnalysisSummary { + summarize_graph_analysis_with_context( + nodes, + edges, + communities, + &StructuralGraphCoverage::default(), + false, + ) +} + +pub fn summarize_graph_analysis_with_context( + nodes: &[StructuralGraphNode], + edges: &[StructuralGraphEdge], + communities: &[StructuralGraphCommunity], + snapshot_coverage: &StructuralGraphCoverage, + snapshot_truncated: bool, +) -> StructuralGraphAnalysisSummary { + let node_by_id = nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let community_by_node = nodes + .iter() + .filter_map(|node| { + node.community_id + .as_deref() + .map(|community| (node.id.as_str(), community)) + }) + .collect::>(); + let trusted_summary_edges = edges + .iter() + .filter(|edge| { + is_algorithm_trusted(edge.trust) + && node_by_id.contains_key(edge.from.as_str()) + && node_by_id.contains_key(edge.to.as_str()) + }) + .collect::>(); + let mut degree = HashMap::<&str, usize>::new(); + for edge in &trusted_summary_edges { + *degree.entry(edge.from.as_str()).or_default() += 1; + *degree.entry(edge.to.as_str()).or_default() += 1; + } + let super_hub_threshold = ((trusted_summary_edges.len() as f64).sqrt().ceil() as usize).max(12); + let mut ranked = nodes + .iter() + .filter_map(|node| { + let node_degree = degree.get(node.id.as_str()).copied().unwrap_or_default(); + (node_degree > 0).then(|| StructuralGraphNodeRank { + node_id: node.id.clone(), + label: node.label.clone(), + kind: node.kind.clone(), + path: node.path.clone(), + degree: node_degree, + score: node_degree as f64, + reason: "deterministic total degree".to_string(), + }) + }) + .collect::>(); + ranked.sort_by(|left, right| { + right + .degree + .cmp(&left.degree) + .then_with(|| left.node_id.cmp(&right.node_id)) + }); + let super_hubs = ranked + .iter() + .filter(|rank| rank.degree >= super_hub_threshold) + .take(20) + .cloned() + .collect::>(); + let super_hub_ids = super_hubs + .iter() + .map(|rank| rank.node_id.as_str()) + .collect::>(); + let hubs = ranked + .iter() + .filter(|rank| !super_hub_ids.contains(rank.node_id.as_str())) + .take(20) + .cloned() + .collect::>(); + + let bridge_ids = communities + .iter() + .flat_map(|community| community.bridge_node_ids.iter()) + .collect::>(); + let mut bridges = ranked + .iter() + .filter(|rank| bridge_ids.contains(&rank.node_id)) + .cloned() + .collect::>(); + for bridge in &mut bridges { + bridge.reason = "connects nodes assigned to different navigation communities".to_string(); + } + bridges.truncate(30); + + let mut cross_community_edges = trusted_summary_edges + .iter() + .filter_map(|edge| { + let from_community = community_by_node.get(edge.from.as_str())?; + let to_community = community_by_node.get(edge.to.as_str())?; + if from_community == to_community { + return None; + } + let endpoint_degree = degree.get(edge.from.as_str()).copied().unwrap_or_default() + + degree.get(edge.to.as_str()).copied().unwrap_or_default(); + Some(StructuralGraphConnectionInsight { + edge_id: edge.id.clone(), + from_community_id: (*from_community).to_string(), + to_community_id: (*to_community).to_string(), + score: 1.0 / (endpoint_degree.max(1) as f64), + reason: format!("{} crosses navigation communities", edge.kind), + }) + }) + .collect::>(); + cross_community_edges.sort_by(|left, right| left.edge_id.cmp(&right.edge_id)); + let mut surprising_connections = cross_community_edges.clone(); + surprising_connections.sort_by(|left, right| { + right + .score + .total_cmp(&left.score) + .then_with(|| left.edge_id.cmp(&right.edge_id)) + }); + surprising_connections.truncate(20); + cross_community_edges.truncate(100); + + let mut suggested_questions = Vec::new(); + for bridge in bridges.iter().take(5) { + if let Some(node) = node_by_id.get(bridge.node_id.as_str()) { + suggested_questions.push(StructuralGraphSuggestedQuestion { + question: format!( + "Why does {} connect multiple repository communities?", + node.label + ), + node_ids: vec![node.id.clone()], + source_paths: node + .sources + .iter() + .map(|source| source.path.clone()) + .collect(), + }); + } + } + for hub in hubs + .iter() + .take(5_usize.saturating_sub(suggested_questions.len())) + { + if let Some(node) = node_by_id.get(hub.node_id.as_str()) { + suggested_questions.push(StructuralGraphSuggestedQuestion { + question: format!("What depends on {}, and how is it verified?", node.label), + node_ids: vec![node.id.clone()], + source_paths: node + .sources + .iter() + .map(|source| source.path.clone()) + .collect(), + }); + } + } + + let (policy, coverage, algorithms) = + analyze_trusted_algorithms(nodes, edges, snapshot_coverage, snapshot_truncated); + + StructuralGraphAnalysisSummary { + policy, + coverage, + algorithms, + communities: communities.to_vec(), + hubs, + super_hubs, + bridges, + cross_community_edges, + surprising_connections, + suggested_questions, + } +} + +fn analyze_trusted_algorithms( + nodes: &[StructuralGraphNode], + edges: &[StructuralGraphEdge], + snapshot_coverage: &StructuralGraphCoverage, + snapshot_truncated: bool, +) -> ( + StructuralGraphAnalysisPolicy, + StructuralGraphAnalysisCoverage, + StructuralGraphAlgorithmResults, +) { + let mut ordered_nodes = nodes.iter().collect::>(); + ordered_nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let node_index = ordered_nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.as_str(), index)) + .collect::>(); + let mut included_kinds = BTreeSet::new(); + let mut execution_kinds = BTreeSet::new(); + let mut trusted_edges = Vec::new(); + let mut excluded_edge_count = 0; + let mut unresolved_endpoint_count = 0; + for edge in edges { + if !is_algorithm_trusted(edge.trust) { + excluded_edge_count += 1; + continue; + } + let (Some(&from), Some(&to)) = ( + node_index.get(edge.from.as_str()), + node_index.get(edge.to.as_str()), + ) else { + unresolved_endpoint_count += 1; + continue; + }; + included_kinds.insert(edge.kind.clone()); + if is_execution_edge_kind(&edge.kind) { + execution_kinds.insert(edge.kind.clone()); + } + trusted_edges.push((from, to, edge)); + } + trusted_edges.sort_by(|left, right| left.2.id.cmp(&right.2.id)); + + let node_count = ordered_nodes.len(); + let mut outgoing = vec![Vec::::new(); node_count]; + let mut incoming = vec![Vec::::new(); node_count]; + let mut undirected = vec![Vec::::new(); node_count]; + let mut execution = vec![Vec::<(usize, &str)>::new(); node_count]; + for (from, to, edge) in &trusted_edges { + outgoing[*from].push(*to); + incoming[*to].push(*from); + undirected[*from].push(*to); + undirected[*to].push(*from); + if is_execution_edge_kind(&edge.kind) { + execution[*from].push((*to, edge.id.as_str())); + } + } + for neighbors in outgoing + .iter_mut() + .chain(incoming.iter_mut()) + .chain(undirected.iter_mut()) + { + neighbors.sort_unstable(); + neighbors.dedup(); + } + for neighbors in &mut execution { + neighbors.sort_by(|left, right| { + ordered_nodes[left.0] + .id + .cmp(&ordered_nodes[right.0].id) + .then_with(|| left.1.cmp(right.1)) + }); + } + + let pagerank = calculate_pagerank(&outgoing, &incoming); + let degree_denominator = node_count.saturating_sub(1).saturating_mul(2).max(1) as f64; + let mut node_metrics = ordered_nodes + .iter() + .enumerate() + .map(|(index, node)| { + let in_degree = incoming[index].len(); + let out_degree = outgoing[index].len(); + StructuralGraphNodeMetric { + node_id: node.id.clone(), + in_degree, + out_degree, + total_degree: in_degree + out_degree, + degree_centrality: (in_degree + out_degree) as f64 / degree_denominator, + pagerank: pagerank.get(index).copied().unwrap_or_default(), + } + }) + .collect::>(); + node_metrics.sort_by(|left, right| { + right + .pagerank + .total_cmp(&left.pagerank) + .then_with(|| right.total_degree.cmp(&left.total_degree)) + .then_with(|| left.node_id.cmp(&right.node_id)) + }); + + let component_indices = strongly_connected_components(&outgoing, &incoming); + let mut component_by_node = vec![0_usize; node_count]; + for (component_index, component) in component_indices.iter().enumerate() { + for &node_index in component { + component_by_node[node_index] = component_index; + } + } + let mut component_edge_ids = vec![Vec::::new(); component_indices.len()]; + for (from, to, edge) in &trusted_edges { + let component_index = component_by_node[*from]; + if component_index == component_by_node[*to] { + component_edge_ids[component_index].push(edge.id.clone()); + } + } + for edge_ids in &mut component_edge_ids { + edge_ids.sort(); + } + let mut components = component_indices + .iter() + .zip(component_edge_ids) + .map(|(component, edge_ids)| build_component(component, &ordered_nodes, edge_ids)) + .collect::>(); + components.sort_by(|left, right| left.node_ids[0].cmp(&right.node_ids[0])); + let mut cycles = components + .iter() + .filter(|component| component.cyclic) + .cloned() + .collect::>(); + + let articulation_node_ids = articulation_points(&undirected) + .into_iter() + .map(|index| ordered_nodes[index].id.clone()) + .collect::>(); + let entrypoint_indices = ordered_nodes + .iter() + .enumerate() + .filter_map(|(index, node)| is_entrypoint(node).then_some(index)) + .collect::>(); + let mut reachable = vec![false; node_count]; + let mut pending = entrypoint_indices.clone(); + while let Some(index) = pending.pop() { + if reachable[index] { + continue; + } + reachable[index] = true; + pending.extend(execution[index].iter().map(|(target, _)| *target)); + } + let mut reachable_node_ids = Vec::new(); + let mut unreachable_node_ids = Vec::new(); + for (index, node) in ordered_nodes.iter().enumerate() { + if reachable[index] { + reachable_node_ids.push(node.id.clone()); + } else { + unreachable_node_ids.push(node.id.clone()); + } + } + let (execution_flows, flows_truncated) = + bounded_execution_flows(&ordered_nodes, &execution, &entrypoint_indices); + + let mut gaps = Vec::new(); + if snapshot_truncated { + gaps.push("snapshot_truncated".to_string()); + } + if snapshot_coverage.discovered_files > snapshot_coverage.indexed_files { + gaps.push(format!( + "files_not_indexed:{}", + snapshot_coverage.discovered_files - snapshot_coverage.indexed_files + )); + } + if snapshot_coverage.skipped_files > 0 { + gaps.push(format!("skipped_files:{}", snapshot_coverage.skipped_files)); + } + if snapshot_coverage.error_files > 0 { + gaps.push(format!("error_files:{}", snapshot_coverage.error_files)); + } + let unsupported_files = snapshot_coverage + .languages + .iter() + .filter(|language| !language.supported) + .map(|language| language.discovered_files) + .sum::(); + if unsupported_files > 0 { + gaps.push(format!("unsupported_language_files:{unsupported_files}")); + } + if unresolved_endpoint_count > 0 { + gaps.push(format!("unresolved_endpoints:{unresolved_endpoint_count}")); + } + if excluded_edge_count > 0 { + gaps.push(format!("untrusted_edges_excluded:{excluded_edge_count}")); + } + let dynamic_reference_count = nodes + .iter() + .filter(|node| node.kind == "dynamic_reference") + .count(); + if dynamic_reference_count > 0 { + gaps.push(format!("dynamic_references:{dynamic_reference_count}")); + } + if entrypoint_indices.is_empty() { + gaps.push("no_qualified_entrypoints".to_string()); + } + let output_truncated = node_metrics.len() > MAX_RANKED_METRICS + || components.len() > MAX_COMPONENTS + || cycles.len() > MAX_COMPONENTS + || reachable_node_ids.len() > MAX_RANKED_METRICS + || unreachable_node_ids.len() > MAX_RANKED_METRICS + || flows_truncated; + if output_truncated { + gaps.push("analysis_output_limited".to_string()); + } + node_metrics.truncate(MAX_RANKED_METRICS); + components.truncate(MAX_COMPONENTS); + cycles.truncate(MAX_COMPONENTS); + reachable_node_ids.truncate(MAX_RANKED_METRICS); + unreachable_node_ids.truncate(MAX_RANKED_METRICS); + gaps.sort(); + gaps.dedup(); + let reachability_complete = gaps.is_empty(); + let coverage = StructuralGraphAnalysisCoverage { + complete: gaps.is_empty(), + reachability_complete, + trusted_edge_count: trusted_edges.len(), + excluded_edge_count, + unresolved_endpoint_count, + gaps, + output_truncated, + }; + let policy = StructuralGraphAnalysisPolicy { + included_edge_kinds: included_kinds.into_iter().collect(), + execution_edge_kinds: execution_kinds.into_iter().collect(), + ..default_analysis_policy() + }; + let algorithms = StructuralGraphAlgorithmResults { + node_metrics, + strongly_connected_components: components, + cycles, + articulation_node_ids, + entrypoint_node_ids: entrypoint_indices + .iter() + .map(|index| ordered_nodes[*index].id.clone()) + .collect(), + reachable_node_ids, + unreachable_node_ids, + execution_flows, + }; + (policy, coverage, algorithms) +} + +fn is_algorithm_trusted(trust: GraphTrust) -> bool { + matches!(trust, GraphTrust::Extracted | GraphTrust::Inferred) +} + +pub(crate) fn is_execution_edge_kind(kind: &str) -> bool { + matches!( + kind, + "calls" + | "invokes" + | "invokes_command" + | "routes_to" + | "handles" + | "dispatches" + | "emits" + | "subscribes" + | "schedules" + | "executes" + | "queries" + | "reads" + | "reads_from" + | "writes" + | "writes_to" + | "implemented_by" + | "binds_to" + | "depends_on" + | "test_covers" + ) +} + +pub(crate) fn is_entrypoint(node: &StructuralGraphNode) -> bool { + if matches!( + node.kind.as_str(), + "entrypoint" + | "route" + | "command" + | "tauri_command" + | "job" + | "event" + | "event_subscription" + | "test" + | "resolver" + | "openapi_operation" + | "graphql_operation" + | "protobuf_rpc" + ) { + return true; + } + if matches!(node.label.as_str(), "main" | "__main__") { + return true; + } + let Some(path) = node.path.as_deref() else { + return false; + }; + let normalized = path.replace('\\', "/").to_ascii_lowercase(); + let file_name = normalized.rsplit('/').next().unwrap_or(&normalized); + node.kind == "file" + && (matches!( + file_name, + "main.rs" | "main.go" | "main.py" | "__main__.py" | "program.cs" + ) || normalized.contains("/bin/")) +} + +fn calculate_pagerank(outgoing: &[Vec], incoming: &[Vec]) -> Vec { + let count = outgoing.len(); + if count == 0 { + return Vec::new(); + } + let base = (1.0 - PAGERANK_DAMPING) / count as f64; + let mut ranks = vec![1.0 / count as f64; count]; + for _ in 0..PAGERANK_ITERATIONS { + let dangling = outgoing + .iter() + .enumerate() + .filter(|(_, targets)| targets.is_empty()) + .map(|(index, _)| ranks[index]) + .sum::() + / count as f64; + let mut next = vec![base + PAGERANK_DAMPING * dangling; count]; + for (target, sources) in incoming.iter().enumerate() { + next[target] += PAGERANK_DAMPING + * sources + .iter() + .map(|source| ranks[*source] / outgoing[*source].len() as f64) + .sum::(); + } + let delta = ranks + .iter() + .zip(&next) + .map(|(before, after)| (before - after).abs()) + .sum::(); + ranks = next; + if delta < 1e-10 { + break; + } + } + ranks +} + +fn strongly_connected_components( + outgoing: &[Vec], + incoming: &[Vec], +) -> Vec> { + fn finish_order(start: usize, graph: &[Vec], seen: &mut [bool], order: &mut Vec) { + seen[start] = true; + let mut stack = vec![(start, 0_usize)]; + while let Some((node, next_neighbor)) = stack.last_mut() { + if *next_neighbor < graph[*node].len() { + let target = graph[*node][*next_neighbor]; + *next_neighbor += 1; + if !seen[target] { + seen[target] = true; + stack.push((target, 0)); + } + } else { + order.push(*node); + stack.pop(); + } + } + } + + let mut seen = vec![false; outgoing.len()]; + let mut order = Vec::with_capacity(outgoing.len()); + for start in 0..outgoing.len() { + if !seen[start] { + finish_order(start, outgoing, &mut seen, &mut order); + } + } + seen.fill(false); + let mut components = Vec::new(); + for &start in order.iter().rev() { + if seen[start] { + continue; + } + seen[start] = true; + let mut component = Vec::new(); + let mut stack = vec![start]; + while let Some(node) = stack.pop() { + component.push(node); + for &target in incoming[node].iter().rev() { + if !seen[target] { + seen[target] = true; + stack.push(target); + } + } + } + component.sort_unstable(); + components.push(component); + } + components +} + +fn build_component( + component: &[usize], + nodes: &[&StructuralGraphNode], + edge_ids: Vec, +) -> StructuralGraphComponent { + let node_ids = component + .iter() + .map(|index| nodes[*index].id.clone()) + .collect::>(); + let cyclic = node_ids.len() > 1 || !edge_ids.is_empty(); + StructuralGraphComponent { + id: stable_graph_id("scc", &node_ids.join("\u{1f}")), + node_ids, + edge_ids, + cyclic, + } +} + +fn articulation_points(graph: &[Vec]) -> Vec { + let count = graph.len(); + let mut discovered = vec![0_usize; count]; + let mut low = vec![0_usize; count]; + let mut parent = vec![None; count]; + let mut child_count = vec![0_usize; count]; + let mut articulation = vec![false; count]; + let mut time = 0_usize; + for root in 0..count { + if discovered[root] != 0 { + continue; + } + time += 1; + discovered[root] = time; + low[root] = time; + let mut stack = vec![(root, 0_usize)]; + while let Some((node, next_neighbor)) = stack.last_mut() { + if *next_neighbor < graph[*node].len() { + let target = graph[*node][*next_neighbor]; + *next_neighbor += 1; + if discovered[target] == 0 { + parent[target] = Some(*node); + child_count[*node] += 1; + time += 1; + discovered[target] = time; + low[target] = time; + stack.push((target, 0)); + } else if parent[*node] != Some(target) { + low[*node] = low[*node].min(discovered[target]); + } + } else { + let (finished, _) = stack.pop().expect("DFS frame exists"); + if let Some(parent_index) = parent[finished] { + low[parent_index] = low[parent_index].min(low[finished]); + if parent[parent_index].is_some() && low[finished] >= discovered[parent_index] { + articulation[parent_index] = true; + } + } else if child_count[finished] > 1 { + articulation[finished] = true; + } + } + } + } + articulation + .into_iter() + .enumerate() + .filter_map(|(index, value)| value.then_some(index)) + .collect() +} + +fn bounded_execution_flows( + nodes: &[&StructuralGraphNode], + execution: &[Vec<(usize, &str)>], + entrypoints: &[usize], +) -> (Vec, bool) { + let mut flows = Vec::new(); + let mut truncated = false; + for &entrypoint in entrypoints { + let mut pending = vec![(vec![entrypoint], Vec::::new())]; + while let Some((node_path, edge_path)) = pending.pop() { + if flows.len() >= MAX_EXECUTION_FLOWS { + truncated = true; + break; + } + let current = *node_path.last().expect("execution path has a node"); + let at_depth_limit = edge_path.len() >= MAX_EXECUTION_FLOW_DEPTH; + let mut next = execution[current] + .iter() + .filter(|(target, _)| !node_path.contains(target)) + .collect::>(); + next.reverse(); + if at_depth_limit || next.is_empty() { + let terminal_reason = if at_depth_limit { + "depth_limit" + } else if execution[current].is_empty() { + "terminal" + } else { + "cycle_avoided" + }; + flows.push(StructuralGraphExecutionFlow { + entrypoint_node_id: nodes[entrypoint].id.clone(), + node_ids: node_path + .iter() + .map(|index| nodes[*index].id.clone()) + .collect(), + edge_ids: edge_path, + terminal_reason: terminal_reason.to_string(), + }); + continue; + } + for (target, edge_id) in next { + let mut next_nodes = node_path.clone(); + next_nodes.push(*target); + let mut next_edges = edge_path.clone(); + next_edges.push((*edge_id).to_string()); + pending.push((next_nodes, next_edges)); + } + } + if truncated { + break; + } + } + (flows, truncated) +} + +fn community_key(node: &StructuralGraphNode) -> String { + let Some(path) = node.path.as_deref() else { + return node.kind.clone(); + }; + let components = Path::new(path) + .components() + .filter_map(|component| component.as_os_str().to_str()) + .filter(|component| !component.is_empty() && *component != ".") + .take(2) + .collect::>(); + match components.as_slice() { + [] => "root".to_string(), + [first] => (*first).to_string(), + [first, second] if matches!(*first, "src" | "lib" | "app" | "pages" | "tests") => { + (*first).to_string() + } + [first, second] => format!("{first}/{second}"), + _ => unreachable!(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::structural_graph::types::{GraphOrigin, GraphTrust}; + + #[test] + fn communities_hubs_and_bridges_are_deterministic() { + let mut nodes = [ + node("a", "apps/api/a.rs"), + node("b", "apps/api/b.rs"), + node("c", "apps/web/c.ts"), + ]; + let edges = [edge("a", "b"), edge("b", "c")]; + let first = analyze_graph(&mut nodes, &edges); + let second = analyze_graph(&mut nodes, &edges); + assert_eq!(first, second); + assert_eq!(first.len(), 2); + assert!(first + .iter() + .any(|community| !community.bridge_node_ids.is_empty())); + let first_summary = summarize_graph_analysis(&nodes, &edges, &first); + let second_summary = summarize_graph_analysis(&nodes, &edges, &second); + assert_eq!(first_summary, second_summary); + assert!(!first_summary.cross_community_edges.is_empty()); + assert!(!first_summary.surprising_connections.is_empty()); + assert!(!first_summary.suggested_questions.is_empty()); + } + + #[test] + fn trusted_algorithms_compute_cycles_centrality_articulation_and_reachability() { + let mut nodes = [ + node_with_kind("entry", "apps/api/route.ts", "route"), + node("a", "apps/api/a.ts"), + node("b", "apps/api/b.ts"), + node("c", "apps/api/c.ts"), + node("isolated", "apps/api/unused.ts"), + ]; + let edges = [ + edge("entry", "a"), + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + ]; + let communities = analyze_graph(&mut nodes, &edges); + let summary = summarize_graph_analysis_with_context( + &nodes, + &edges, + &communities, + &complete_coverage(5), + false, + ); + + assert_eq!(summary.policy.algorithm_version, "2"); + assert_eq!( + summary.policy.included_trust, + [GraphTrust::Extracted, GraphTrust::Inferred] + ); + assert_eq!(summary.policy.included_edge_kinds, ["calls"]); + assert_eq!(summary.algorithms.entrypoint_node_ids, ["entry"]); + assert_eq!( + summary.algorithms.reachable_node_ids, + ["a", "b", "c", "entry"] + ); + assert_eq!(summary.algorithms.unreachable_node_ids, ["isolated"]); + assert!(summary.coverage.reachability_complete); + assert!(summary + .algorithms + .cycles + .iter() + .any(|cycle| cycle.node_ids == ["a", "b", "c"])); + assert!(summary + .algorithms + .articulation_node_ids + .contains(&"a".to_string())); + let ranked_ids = summary + .algorithms + .node_metrics + .iter() + .map(|metric| metric.node_id.as_str()) + .collect::>(); + assert!( + ranked_ids.iter().position(|id| *id == "a") + < ranked_ids.iter().position(|id| *id == "isolated") + ); + assert!(summary + .algorithms + .execution_flows + .iter() + .all(|flow| flow.node_ids.len() <= MAX_EXECUTION_FLOW_DEPTH + 1)); + } + + #[test] + fn ambiguous_edges_and_partial_snapshots_prevent_global_claims() { + let mut nodes = [ + node_with_kind("entry", "src/main.rs", "entrypoint"), + node("trusted", "src/trusted.rs"), + node("candidate", "src/candidate.rs"), + ]; + let mut ambiguous = edge("trusted", "candidate"); + ambiguous.trust = GraphTrust::Ambiguous; + let edges = [edge("entry", "trusted"), ambiguous]; + let communities = analyze_graph(&mut nodes, &edges); + let coverage = StructuralGraphCoverage { + discovered_files: 4, + indexed_files: 3, + skipped_files: 1, + error_files: 0, + generated_files: 0, + sensitive_files: 0, + binary_files: 0, + languages: Vec::new(), + }; + let summary = + summarize_graph_analysis_with_context(&nodes, &edges, &communities, &coverage, true); + + assert!(!summary.coverage.complete); + assert!(!summary.coverage.reachability_complete); + assert_eq!(summary.coverage.trusted_edge_count, 1); + assert_eq!(summary.coverage.excluded_edge_count, 1); + assert!(summary + .coverage + .gaps + .contains(&"snapshot_truncated".to_string())); + assert!(summary + .coverage + .gaps + .contains(&"untrusted_edges_excluded:1".to_string())); + assert!(summary + .algorithms + .unreachable_node_ids + .contains(&"candidate".to_string())); + assert!(!summary + .algorithms + .strongly_connected_components + .iter() + .flat_map(|component| &component.edge_ids) + .any(|edge_id| edge_id == "trusted:candidate")); + } + + #[test] + fn bounded_execution_flows_are_deterministic_and_report_limits() { + let mut nodes = vec![node_with_kind("entry", "src/main.rs", "entrypoint")]; + let mut edges = Vec::new(); + for index in 0..(MAX_EXECUTION_FLOWS + 10) { + let id = format!("leaf-{index:03}"); + nodes.push(node(&id, &format!("src/{id}.rs"))); + edges.push(edge("entry", &id)); + } + let communities = analyze_graph(&mut nodes, &edges); + let coverage = complete_coverage(nodes.len()); + let first = + summarize_graph_analysis_with_context(&nodes, &edges, &communities, &coverage, false); + let second = + summarize_graph_analysis_with_context(&nodes, &edges, &communities, &coverage, false); + + assert_eq!(first, second); + assert_eq!(first.algorithms.execution_flows.len(), MAX_EXECUTION_FLOWS); + assert!(first.coverage.output_truncated); + assert!(first + .coverage + .gaps + .contains(&"analysis_output_limited".to_string())); + } + + fn node(id: &str, path: &str) -> StructuralGraphNode { + node_with_kind(id, path, "function") + } + + fn node_with_kind(id: &str, path: &str, kind: &str) -> StructuralGraphNode { + StructuralGraphNode { + id: id.to_string(), + kind: kind.to_string(), + label: id.to_string(), + qualified_name: None, + path: Some(path.to_string()), + detail: None, + language: None, + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + } + } + + fn complete_coverage(files: usize) -> StructuralGraphCoverage { + StructuralGraphCoverage { + discovered_files: files, + indexed_files: files, + ..StructuralGraphCoverage::default() + } + } + + fn edge(from: &str, to: &str) -> StructuralGraphEdge { + StructuralGraphEdge { + id: format!("{from}:{to}"), + from: from.to_string(), + to: to.to_string(), + kind: "calls".to_string(), + evidence: "fixture".to_string(), + trust: GraphTrust::Inferred, + origin: GraphOrigin::Resolution, + sources: Vec::new(), + candidates: Vec::new(), + } + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index 74a66eb..a614321 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,3 +1,4 @@ +pub mod analysis; mod contracts; pub mod language; pub mod types; From 937d5068597aa874a1af80f9984d408c7ec1a063 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:49:21 +0530 Subject: [PATCH 015/141] feat(graph): resolve qualified cross-file links --- .../src/commands/structural_graph/mod.rs | 1 + .../src/commands/structural_graph/resolve.rs | 845 ++++++++++++++++++ 2 files changed, 846 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/resolve.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index a614321..c241c56 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,4 +1,5 @@ pub mod analysis; mod contracts; pub mod language; +pub mod resolve; pub mod types; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/resolve.rs b/apps/desktop/src-tauri/src/commands/structural_graph/resolve.rs new file mode 100644 index 0000000..765e8a6 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/resolve.rs @@ -0,0 +1,845 @@ +use super::types::{ + stable_graph_id, GraphOrigin, GraphTrust, StructuralGraphEdge, StructuralGraphNode, +}; +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path}; + +#[derive(Debug, Default)] +struct ImportContext { + candidate_paths: HashSet, + bindings: HashMap, +} + +#[derive(Debug, Default)] +struct FileIndex<'a> { + by_normalized_path: HashMap>, + by_stem: HashMap>, +} + +impl<'a> FileIndex<'a> { + fn new(files: &[&'a StructuralGraphNode]) -> Self { + let mut index = Self::default(); + for file in files { + let Some(path) = file.path.as_deref() else { + continue; + }; + let normalized = normalize_path(Path::new(path)); + index + .by_normalized_path + .entry(normalized.trim_matches('/').to_string()) + .or_default() + .push(*file); + if let Some(stem) = Path::new(&normalized) + .file_stem() + .and_then(|stem| stem.to_str()) + { + index + .by_stem + .entry(stem.to_string()) + .or_default() + .push(*file); + } + } + for candidates in index + .by_normalized_path + .values_mut() + .chain(index.by_stem.values_mut()) + { + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + } + index + } +} + +pub fn resolve_cross_file(nodes: &[StructuralGraphNode], edges: &mut Vec) { + let node_by_id = nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let mut symbols_by_label: HashMap> = HashMap::new(); + let mut files = Vec::new(); + for node in nodes { + if node.kind == "file" { + files.push(node); + } else if !node.kind.ends_with("_reference") { + symbols_by_label + .entry(node.label.clone()) + .or_default() + .push(node); + } + } + for candidates in symbols_by_label.values_mut() { + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + } + files.sort_by(|left, right| left.id.cmp(&right.id)); + let file_index = FileIndex::new(&files); + + let reference_edges = edges + .iter() + .filter(|edge| matches!(edge.origin, GraphOrigin::Syntax | GraphOrigin::Metadata)) + .cloned() + .collect::>(); + let mut imports_by_source_path: HashMap> = HashMap::new(); + for edge in reference_edges.iter().filter(|edge| edge.kind == "imports") { + let Some(reference) = node_by_id.get(edge.to.as_str()).copied() else { + continue; + }; + let Some(source_path) = reference.path.as_ref() else { + continue; + }; + let candidate_paths = resolve_module_candidates(reference, &file_index) + .into_iter() + .filter_map(|candidate| candidate.path.clone()) + .collect::>(); + imports_by_source_path + .entry(source_path.clone()) + .or_default() + .push(ImportContext { + candidate_paths, + bindings: parse_import_bindings(reference.detail.as_deref().unwrap_or_default()), + }); + } + let mut additions = Vec::new(); + for edge in reference_edges { + let Some(reference) = node_by_id.get(edge.to.as_str()).copied() else { + continue; + }; + if !reference.kind.ends_with("_reference") { + continue; + } + let mut candidates = if reference.kind == "module_reference" { + resolve_module_candidates(reference, &file_index) + } else { + resolve_symbol_candidates(reference, &symbols_by_label, &imports_by_source_path) + }; + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + candidates.dedup_by(|left, right| left.id == right.id); + + if reference.kind == "dynamic_reference" && !candidates.is_empty() { + additions.push(StructuralGraphEdge { + id: stable_graph_id( + "edge", + &format!("dynamic_candidate_for\0{}\0{}", edge.from, reference.id), + ), + from: edge.from.clone(), + to: reference.id.clone(), + kind: "candidate_for".to_string(), + evidence: format!( + "Runtime lookup `{}` may resolve to {} source-backed candidate(s); none was promoted to a verified edge", + reference.label, + candidates.len() + ), + trust: GraphTrust::Ambiguous, + origin: GraphOrigin::Resolution, + sources: edge.sources.clone(), + candidates: candidates + .into_iter() + .map(|candidate| candidate.id.clone()) + .collect(), + }); + continue; + } + + if candidates.len() == 1 { + let target = candidates[0]; + additions.push(StructuralGraphEdge { + id: stable_graph_id( + "edge", + &format!("{}\0{}\0{}", edge.kind, edge.from, target.id), + ), + from: edge.from.clone(), + to: target.id.clone(), + kind: edge.kind.clone(), + evidence: format!( + "Resolved `{}` to `{}` using deterministic path/name and source-context rules", + reference.label, + target.qualified_name.as_deref().unwrap_or(&target.label) + ), + trust: GraphTrust::Inferred, + origin: GraphOrigin::Resolution, + sources: edge.sources.clone(), + candidates: Vec::new(), + }); + } else if !candidates.is_empty() { + let candidate_ids = candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect::>(); + additions.push(StructuralGraphEdge { + id: stable_graph_id( + "edge", + &format!("candidate_for\0{}\0{}", edge.from, reference.id), + ), + from: edge.from.clone(), + to: reference.id.clone(), + kind: "candidate_for".to_string(), + evidence: format!( + "`{}` has {} plausible targets; no target was selected silently", + reference.label, + candidate_ids.len() + ), + trust: GraphTrust::Ambiguous, + origin: GraphOrigin::Resolution, + sources: edge.sources.clone(), + candidates: candidate_ids, + }); + } + } + resolve_document_links(nodes, &files, &mut additions); + resolve_test_targets(nodes, &files, &mut additions); + relate_analytics_events(nodes, &mut additions); + edges.extend(additions); +} + +fn resolve_document_links( + nodes: &[StructuralGraphNode], + files: &[&StructuralGraphNode], + additions: &mut Vec, +) { + let files_by_path = files + .iter() + .filter_map(|file| { + file.path + .as_deref() + .map(|path| (normalize_path(Path::new(path)), *file)) + }) + .collect::>(); + for link in nodes + .iter() + .filter(|node| node.kind == "documentation_link") + { + let Some(source_path) = link.path.as_deref() else { + continue; + }; + let target = link.label.split('#').next().unwrap_or_default(); + if target.starts_with("http://") || target.starts_with("https://") || target.is_empty() { + continue; + } + let source_directory = Path::new(source_path) + .parent() + .unwrap_or_else(|| Path::new("")); + let resolved = normalize_path(&source_directory.join(target)); + let Some(target_file) = files_by_path.get(&resolved) else { + continue; + }; + let source_file_id = stable_graph_id("file", source_path); + additions.push(StructuralGraphEdge { + id: stable_graph_id( + "edge", + &format!("documents\0{source_file_id}\0{}", target_file.id), + ), + from: source_file_id, + to: target_file.id.clone(), + kind: "documents".to_string(), + evidence: format!( + "Documentation link resolves `{}` to `{resolved}`", + link.label + ), + trust: GraphTrust::Inferred, + origin: GraphOrigin::Resolution, + sources: link.sources.clone(), + candidates: Vec::new(), + }); + } +} + +fn resolve_test_targets( + nodes: &[StructuralGraphNode], + files: &[&StructuralGraphNode], + additions: &mut Vec, +) { + for test in nodes.iter().filter(|node| node.kind == "test") { + let Some(test_path) = test.path.as_deref() else { + continue; + }; + let test_stem = normalized_test_stem(test_path); + if test_stem.is_empty() { + continue; + } + let mut candidates = files + .iter() + .copied() + .filter(|file| file.path.as_deref() != Some(test_path)) + .filter(|file| { + file.path.as_deref().map(normalized_test_stem).as_deref() + == Some(test_stem.as_str()) + }) + .collect::>(); + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + if candidates.len() == 1 { + let target = candidates[0]; + additions.push(StructuralGraphEdge { + id: stable_graph_id("edge", &format!("tests\0{}\0{}", test.id, target.id)), + from: test.id.clone(), + to: target.id.clone(), + kind: "tests".to_string(), + evidence: "Unique production file matched by conventional test filename" + .to_string(), + trust: GraphTrust::Inferred, + origin: GraphOrigin::Resolution, + sources: test.sources.clone(), + candidates: Vec::new(), + }); + } else if !candidates.is_empty() { + additions.push(StructuralGraphEdge { + id: stable_graph_id("edge", &format!("candidate_test_target\0{}", test.id)), + from: test.id.clone(), + to: test.id.clone(), + kind: "candidate_for".to_string(), + evidence: "Test filename matches multiple possible production files".to_string(), + trust: GraphTrust::Ambiguous, + origin: GraphOrigin::Resolution, + sources: test.sources.clone(), + candidates: candidates + .into_iter() + .map(|candidate| candidate.id.clone()) + .collect(), + }); + } + } +} + +fn normalized_test_stem(path: &str) -> String { + let stem = Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + stem.trim_start_matches("test_") + .trim_end_matches(".test") + .trim_end_matches(".spec") + .trim_end_matches("_test") + .to_string() +} + +fn relate_analytics_events( + nodes: &[StructuralGraphNode], + additions: &mut Vec, +) { + let mut by_label: HashMap<&str, Vec<&StructuralGraphNode>> = HashMap::new(); + for node in nodes.iter().filter(|node| node.kind == "analytics_event") { + by_label.entry(node.label.as_str()).or_default().push(node); + } + for occurrences in by_label.values_mut() { + occurrences.sort_by(|left, right| left.id.cmp(&right.id)); + for pair in occurrences.windows(2) { + additions.push(StructuralGraphEdge { + id: stable_graph_id( + "edge", + &format!("same_event\0{}\0{}", pair[0].id, pair[1].id), + ), + from: pair[0].id.clone(), + to: pair[1].id.clone(), + kind: "same_event".to_string(), + evidence: format!( + "Source-backed analytics callsites share exact event label `{}`", + pair[0].label + ), + trust: GraphTrust::Inferred, + origin: GraphOrigin::Resolution, + sources: pair + .iter() + .flat_map(|node| node.sources.iter().cloned()) + .collect(), + candidates: Vec::new(), + }); + } + } +} + +fn resolve_symbol_candidates<'a>( + reference: &StructuralGraphNode, + symbols_by_label: &'a HashMap>, + imports_by_source_path: &HashMap>, +) -> Vec<&'a StructuralGraphNode> { + let terminal = terminal_reference_name(&reference.label); + let source_path = reference.path.as_deref().unwrap_or_default(); + let import_contexts = imports_by_source_path + .get(source_path) + .map(Vec::as_slice) + .unwrap_or_default(); + let imported_binding = import_contexts + .iter() + .find_map(|context| context.bindings.get(&terminal)) + .cloned(); + let lookup = imported_binding.as_deref().unwrap_or(&terminal); + let Some(all) = symbols_by_label.get(lookup) else { + return Vec::new(); + }; + if all.len() <= 1 { + return all.clone(); + } + let same_file = all + .iter() + .copied() + .filter(|candidate| candidate.path.as_deref() == Some(source_path)) + .collect::>(); + if same_file.len() == 1 { + return same_file; + } + + let imported = all + .iter() + .copied() + .filter(|candidate| { + candidate.path.as_ref().is_some_and(|candidate_path| { + import_contexts.iter().any(|context| { + context.candidate_paths.contains(candidate_path) + && (context.bindings.is_empty() + || context + .bindings + .get(&terminal) + .is_some_and(|name| name == lookup) + || context.bindings.values().any(|name| name == lookup)) + }) + }) + }) + .collect::>(); + if imported.len() == 1 { + return imported; + } + + let source_directory = Path::new(source_path).parent(); + let same_directory = all + .iter() + .copied() + .filter(|candidate| { + candidate + .path + .as_deref() + .and_then(|path| Path::new(path).parent()) + == source_directory + }) + .collect::>(); + if same_directory.len() == 1 { + return same_directory; + } + + all.clone() +} + +fn parse_import_bindings(statement: &str) -> HashMap { + let normalized = statement + .replace(['{', '}', '(', ')', ';', ','], " ") + .replace("::", " ") + .replace('.', " "); + let words = normalized + .split_whitespace() + .map(|word| word.trim_matches(['\'', '"', '`'])) + .collect::>(); + let mut bindings = HashMap::new(); + for window in words.windows(3) { + if window[1].eq_ignore_ascii_case("as") + && is_binding_identifier(window[0]) + && is_binding_identifier(window[2]) + { + bindings.insert(window[2].to_string(), window[0].to_string()); + } + } + if let Some(import_position) = words + .iter() + .position(|word| word.eq_ignore_ascii_case("import")) + { + for word in words.iter().skip(import_position + 1) { + if word.eq_ignore_ascii_case("from") || word.eq_ignore_ascii_case("as") { + break; + } + if is_binding_identifier(word) { + bindings + .entry((*word).to_string()) + .or_insert_with(|| (*word).to_string()); + } + } + } + bindings +} + +fn is_binding_identifier(value: &str) -> bool { + !value.is_empty() + && !matches!( + value.to_ascii_lowercase().as_str(), + "import" | "from" | "use" | "export" | "type" | "pub" | "crate" | "self" | "super" + ) + && value + .chars() + .all(|character| character.is_alphanumeric() || character == '_' || character == '$') +} + +fn resolve_module_candidates<'a>( + reference: &StructuralGraphNode, + files: &FileIndex<'a>, +) -> Vec<&'a StructuralGraphNode> { + let target = clean_module_reference(&reference.label); + if target.is_empty() { + return Vec::new(); + } + let source_path = reference.path.as_deref().unwrap_or_default(); + let source_directory = Path::new(source_path) + .parent() + .unwrap_or_else(|| Path::new("")); + let relative_target = if target.starts_with('.') { + normalize_path(&source_directory.join(&target)) + } else { + target.replace("::", "/").replace('.', "/") + }; + let mut expected = HashSet::new(); + let trimmed = relative_target.trim_matches('/').to_string(); + let mut bases = vec![trimmed.clone()]; + if let Some(rest) = trimmed.strip_prefix("crate/") { + bases.push(format!("src/{rest}")); + } + if let Some(rest) = trimmed.strip_prefix("self/") { + bases.push(normalize_path(&source_directory.join(rest))); + } + if let Some((parent, _symbol)) = trimmed.rsplit_once('/') { + if !parent.is_empty() { + bases.push(parent.to_string()); + if let Some(rest) = parent.strip_prefix("crate/") { + bases.push(format!("src/{rest}")); + } + } + } + bases.sort(); + bases.dedup(); + for base in &bases { + expected.insert(base.to_string()); + for extension in [ + "ts", "tsx", "js", "jsx", "rs", "py", "go", "java", "c", "h", "cpp", "hpp", "cs", "rb", + "php", "kt", "swift", + ] { + expected.insert(format!("{base}.{extension}")); + expected.insert(format!("{base}/index.{extension}")); + expected.insert(format!("{base}/mod.{extension}")); + } + } + + let terminal = terminal_reference_name(&target); + let mut candidates = expected + .iter() + .filter_map(|path| files.by_normalized_path.get(path)) + .flatten() + .copied() + .chain(files.by_stem.get(&terminal).into_iter().flatten().copied()) + .collect::>(); + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + candidates.dedup_by(|left, right| left.id == right.id); + candidates +} + +fn clean_module_reference(value: &str) -> String { + let value = value.trim().trim_end_matches(';').trim(); + let value = value + .strip_prefix("use ") + .or_else(|| value.strip_prefix("import ")) + .or_else(|| value.strip_prefix("from ")) + .or_else(|| value.strip_prefix("#include")) + .unwrap_or(value) + .trim(); + value + .split_whitespace() + .last() + .unwrap_or(value) + .trim_matches(|character| matches!(character, '"' | '\'' | '`' | '<' | '>' | '{' | '}')) + .to_string() +} + +fn terminal_reference_name(value: &str) -> String { + clean_module_reference(value) + .rsplit(['.', ':', '/', '\\']) + .find(|part| !part.is_empty()) + .unwrap_or(value) + .trim_end_matches(['(', ')', '!', '?']) + .to_string() +} + +fn normalize_path(path: &Path) -> String { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::ParentDir => { + components.pop(); + } + Component::CurDir => {} + Component::Normal(value) => components.push(value.to_string_lossy().to_string()), + Component::RootDir | Component::Prefix(_) => {} + } + } + components.join("/") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::structural_graph::types::GraphSourceAnchor; + + fn node(id: &str, kind: &str, label: &str, path: &str) -> StructuralGraphNode { + StructuralGraphNode { + id: id.to_string(), + kind: kind.to_string(), + label: label.to_string(), + qualified_name: Some(format!("{path}::{label}")), + path: Some(path.to_string()), + detail: None, + language: Some("typescript".to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![GraphSourceAnchor::path(path)], + } + } + + #[test] + fn unique_same_file_symbol_is_preferred() { + let nodes = vec![ + node("file:a", "file", "a.ts", "a.ts"), + node("function:local", "function", "run", "a.ts"), + node("function:other", "function", "run", "b.ts"), + node("ref:run", "symbol_reference", "run", "a.ts"), + ]; + let mut edges = vec![StructuralGraphEdge { + id: "syntax".to_string(), + from: "function:local".to_string(), + to: "ref:run".to_string(), + kind: "calls".to_string(), + evidence: "call".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![GraphSourceAnchor::path("a.ts")], + candidates: Vec::new(), + }]; + resolve_cross_file(&nodes, &mut edges); + assert!(edges.iter().any(|edge| { + edge.origin == GraphOrigin::Resolution + && edge.to == "function:local" + && edge.trust == GraphTrust::Inferred + })); + } + + #[test] + fn ambiguous_symbols_retain_all_candidates() { + let nodes = vec![ + node("function:a", "function", "run", "a.ts"), + node("function:b", "function", "run", "b.ts"), + node("ref:run", "symbol_reference", "run", "c.ts"), + ]; + let mut edges = vec![StructuralGraphEdge { + id: "syntax".to_string(), + from: "file:c".to_string(), + to: "ref:run".to_string(), + kind: "calls".to_string(), + evidence: "call".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![GraphSourceAnchor::path("c.ts")], + candidates: Vec::new(), + }]; + resolve_cross_file(&nodes, &mut edges); + let ambiguous = edges + .iter() + .find(|edge| edge.trust == GraphTrust::Ambiguous) + .expect("ambiguous edge"); + assert_eq!(ambiguous.candidates, vec!["function:a", "function:b"]); + } + + #[test] + fn import_alias_and_module_path_disambiguate_cross_file_calls() { + let mut module_reference = node("ref:module", "module_reference", "./a", "src/caller.ts"); + module_reference.detail = Some("import { run as importedRun } from './a';".to_string()); + let nodes = vec![ + node("file:a", "file", "src/a.ts", "src/a.ts"), + node("file:b", "file", "src/b.ts", "src/b.ts"), + node("file:caller", "file", "src/caller.ts", "src/caller.ts"), + node("function:a", "function", "run", "src/a.ts"), + node("function:b", "function", "run", "src/b.ts"), + module_reference, + node( + "ref:call", + "symbol_reference", + "importedRun", + "src/caller.ts", + ), + ]; + let mut edges = vec![ + StructuralGraphEdge { + id: "import".to_string(), + from: "file:caller".to_string(), + to: "ref:module".to_string(), + kind: "imports".to_string(), + evidence: "import".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![GraphSourceAnchor::path("src/caller.ts")], + candidates: Vec::new(), + }, + StructuralGraphEdge { + id: "call".to_string(), + from: "file:caller".to_string(), + to: "ref:call".to_string(), + kind: "calls".to_string(), + evidence: "call".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![GraphSourceAnchor::path("src/caller.ts")], + candidates: Vec::new(), + }, + ]; + resolve_cross_file(&nodes, &mut edges); + assert!(edges.iter().any(|edge| { + edge.origin == GraphOrigin::Resolution + && edge.kind == "calls" + && edge.to == "function:a" + })); + assert!(!edges.iter().any(|edge| { + edge.origin == GraphOrigin::Resolution + && edge.kind == "calls" + && edge.to == "function:b" + })); + } + + #[test] + fn docs_tests_and_events_gain_qualified_cross_file_relationships() { + let nodes = vec![ + node("file:readme", "file", "README.md", "README.md"), + node("file:guide", "file", "guide.md", "docs/guide.md"), + node("file:user", "file", "user.ts", "src/user.ts"), + node( + "file:user-test", + "file", + "user.test.ts", + "tests/user.test.ts", + ), + node( + "doc:guide", + "documentation_link", + "docs/guide.md", + "README.md", + ), + node("test:user", "test", "loads user", "tests/user.test.ts"), + node("event:a", "analytics_event", "user_loaded", "src/user.ts"), + node( + "event:b", + "analytics_event", + "user_loaded", + "tests/user.test.ts", + ), + ]; + let mut edges = Vec::new(); + resolve_cross_file(&nodes, &mut edges); + assert!(edges + .iter() + .any(|edge| edge.kind == "documents" && edge.to == "file:guide")); + assert!(edges + .iter() + .any(|edge| edge.kind == "tests" && edge.to == "file:user")); + assert!(edges.iter().any(|edge| { + edge.kind == "same_event" + && edge.trust == GraphTrust::Inferred + && edge.evidence.contains("user_loaded") + })); + } + + #[test] + fn multi_language_cycles_cross_packages_and_unresolved_calls_remain_honest() { + let mut rust_entry = node( + "function:rust", + "function", + "rust_entry", + "crates/core/src/lib.rs", + ); + rust_entry.language = Some("rust".to_string()); + let mut python_entry = node( + "function:python", + "function", + "python_entry", + "packages/api/main.py", + ); + python_entry.language = Some("python".to_string()); + let mut rust_reference = node( + "ref:rust", + "symbol_reference", + "rust_entry", + "packages/api/main.py", + ); + rust_reference.language = Some("python".to_string()); + let mut python_reference = node( + "ref:python", + "symbol_reference", + "python_entry", + "crates/core/src/lib.rs", + ); + python_reference.language = Some("rust".to_string()); + let unresolved = node( + "ref:missing", + "symbol_reference", + "not_declared", + "packages/api/main.py", + ); + let nodes = vec![ + rust_entry, + python_entry, + rust_reference, + python_reference, + unresolved, + node( + "function:dup-a", + "function", + "duplicate", + "packages/a/mod.ts", + ), + node( + "function:dup-b", + "function", + "duplicate", + "packages/b/mod.ts", + ), + node( + "ref:duplicate", + "symbol_reference", + "duplicate", + "packages/c/mod.ts", + ), + ]; + let syntax_edge = |id: &str, from: &str, to: &str| StructuralGraphEdge { + id: id.to_string(), + from: from.to_string(), + to: to.to_string(), + kind: "calls".to_string(), + evidence: "source-located call fixture".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + candidates: Vec::new(), + }; + let mut edges = vec![ + syntax_edge("call:rust", "function:python", "ref:rust"), + syntax_edge("call:python", "function:rust", "ref:python"), + syntax_edge("call:missing", "function:python", "ref:missing"), + syntax_edge("call:ambiguous", "function:python", "ref:duplicate"), + ]; + + resolve_cross_file(&nodes, &mut edges); + + assert!(edges.iter().any(|edge| { + edge.from == "function:python" + && edge.to == "function:rust" + && edge.trust == GraphTrust::Inferred + })); + assert!(edges.iter().any(|edge| { + edge.from == "function:rust" + && edge.to == "function:python" + && edge.trust == GraphTrust::Inferred + })); + let ambiguous = edges + .iter() + .find(|edge| edge.to == "ref:duplicate" && edge.trust == GraphTrust::Ambiguous) + .expect("ambiguous relationship"); + assert_eq!( + ambiguous.candidates, + vec!["function:dup-a", "function:dup-b"] + ); + assert!(!edges.iter().any(|edge| { + edge.origin == GraphOrigin::Resolution + && (edge.to == "ref:missing" || edge.evidence.contains("not_declared")) + })); + } +} From a980a97b21a3c54ca808e989330f32c6e57fae4d Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:52:01 +0530 Subject: [PATCH 016/141] feat(graph): derive bounded source metrics --- .../src/commands/structural_graph/metrics.rs | 847 ++++++++++++++++++ .../src/commands/structural_graph/mod.rs | 1 + 2 files changed, 848 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/metrics.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/metrics.rs b/apps/desktop/src-tauri/src/commands/structural_graph/metrics.rs new file mode 100644 index 0000000..153245b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/metrics.rs @@ -0,0 +1,847 @@ +//! Source-scoped structural metrics extracted from Tree-sitter scopes. +//! +//! The facts are deliberately descriptive. They are inputs to later calibrated +//! health/risk services, not findings or severity claims by themselves. + +use super::language::SupportedLanguage; +use super::types::{ + stable_graph_id, GraphSourceAnchor, GraphTrust, StructuralBoundaryFact, StructuralCloneGroup, + StructuralCloneRegion, StructuralCodeMetrics, StructuralControlFlowFact, StructuralGraphEdge, + StructuralGraphMetricFact, STRUCTURAL_METRIC_SCHEMA_VERSION, +}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use tree_sitter::Node; + +const MAX_NAMES_PER_SCOPE: usize = 200; +const MAX_CONTROL_FLOW_FACTS: usize = 500; +const MAX_BOUNDARY_FACTS: usize = 200; +const MAX_NORMALIZED_TOKENS: usize = 50_000; +const MIN_CLONE_TOKENS: usize = 20; +const NORMALIZATION_METHOD: &str = "tree-sitter-leaf-kinds-v1"; + +pub fn extract_scope_metrics( + path: &str, + language: SupportedLanguage, + source: &str, + scope: Node<'_>, + node_id: &str, + scope_kind: &str, + public_surface: bool, + public_surface_reason: Option, +) -> StructuralGraphMetricFact { + let mut metrics = StructuralCodeMetrics { + line_count: scope + .end_position() + .row + .saturating_sub(scope.start_position().row) + + 1, + cyclomatic_complexity: 1, + ..StructuralCodeMetrics::default() + }; + let mut control_flow = Vec::new(); + let mut definitions = BTreeSet::new(); + let mut uses = BTreeSet::new(); + let mut boundaries = Vec::new(); + let mut stack = vec![(scope, 0_usize, None::)]; + while let Some((node, nesting, parent_control_id)) = stack.pop() { + let is_root = same_node(node, scope); + if !is_root && is_nested_scope(node.kind()) { + continue; + } + + let kind = node.kind(); + if is_statement(kind) { + metrics.statement_count += 1; + } + let nesting_increment = usize::from(is_nesting_construct(kind)); + let effective_nesting = nesting + nesting_increment; + metrics.max_nesting = metrics.max_nesting.max(effective_nesting); + let mut child_control_id = parent_control_id.clone(); + if is_decision(kind) { + metrics.cyclomatic_complexity += 1; + metrics.cognitive_complexity += 1 + nesting; + if control_flow.len() < MAX_CONTROL_FLOW_FACTS { + let id = control_flow_id(path, node, kind); + control_flow.push(StructuralControlFlowFact { + id: id.clone(), + kind: normalized_control_kind(kind).to_string(), + parent_id: parent_control_id.clone(), + nesting, + source: source_anchor(path, node, source), + }); + child_control_id = Some(id); + } + } else if is_terminal_control(kind) && control_flow.len() < MAX_CONTROL_FLOW_FACTS { + control_flow.push(StructuralControlFlowFact { + id: control_flow_id(path, node, kind), + kind: normalized_control_kind(kind).to_string(), + parent_id: parent_control_id.clone(), + nesting, + source: source_anchor(path, node, source), + }); + } + if is_parameter(kind) { + metrics.parameter_count += 1; + } + if is_identifier(kind) { + if let Some(name) = node_text(node, source, 120) { + if is_definition_identifier(node) { + definitions.insert(name); + } else { + uses.insert(name); + } + } + } + if is_call(kind) && boundaries.len() < MAX_BOUNDARY_FACTS { + if let Some(target) = call_target(node, source) { + if let Some(boundary_kind) = classify_boundary(&target) { + boundaries.push(StructuralBoundaryFact { + kind: boundary_kind.to_string(), + target, + source: source_anchor(path, node, source), + }); + } + } + } + + let mut children = Vec::new(); + let mut cursor = node.walk(); + children.extend(node.children(&mut cursor)); + for child in children.into_iter().rev() { + stack.push((child, effective_nesting, child_control_id.clone())); + } + } + + metrics.cohesion = calculate_cohesion(scope, source); + let (syntax_fingerprint, normalized_token_count, fingerprint_limited) = + syntax_fingerprint(scope, source); + let mut limitations = Vec::new(); + let definitions_truncated = definitions.len() > MAX_NAMES_PER_SCOPE; + let uses_truncated = uses.len() > MAX_NAMES_PER_SCOPE; + if definitions_truncated { + limitations.push("definitions_limited".to_string()); + } + if uses_truncated { + limitations.push("uses_limited".to_string()); + } + if control_flow.len() >= MAX_CONTROL_FLOW_FACTS { + limitations.push("control_flow_limited".to_string()); + } + if boundaries.len() >= MAX_BOUNDARY_FACTS { + limitations.push("boundaries_limited".to_string()); + } + if !public_surface { + limitations.push("public_surface_not_proven".to_string()); + } + if fingerprint_limited { + limitations.push("syntax_fingerprint_limited".to_string()); + } + let definitions = definitions + .into_iter() + .take(MAX_NAMES_PER_SCOPE) + .collect::>(); + let uses = uses + .into_iter() + .take(MAX_NAMES_PER_SCOPE) + .collect::>(); + limitations.sort(); + + StructuralGraphMetricFact { + schema_version: STRUCTURAL_METRIC_SCHEMA_VERSION, + id: stable_graph_id("metric", node_id), + node_id: node_id.to_string(), + path: path.to_string(), + scope_kind: scope_kind.to_string(), + language: language.name().to_string(), + public_surface, + public_surface_reason, + syntax_fingerprint, + normalized_token_count, + normalization_method: NORMALIZATION_METHOD.to_string(), + metrics, + control_flow, + definitions, + uses, + boundaries, + sources: vec![source_anchor(path, scope, source)], + limitations, + } +} + +pub fn detect_clone_groups(facts: &[StructuralGraphMetricFact]) -> Vec { + let mut by_fingerprint = BTreeMap::<&str, Vec<&StructuralGraphMetricFact>>::new(); + for fact in facts.iter().filter(|fact| { + fact.normalized_token_count >= MIN_CLONE_TOKENS + && matches!( + fact.scope_kind.as_str(), + "function" | "method" | "class" | "struct" | "impl" + ) + && !fact + .limitations + .iter() + .any(|value| value == "syntax_fingerprint_limited") + }) { + by_fingerprint + .entry(fact.syntax_fingerprint.as_str()) + .or_default() + .push(fact); + } + let mut groups = by_fingerprint + .into_iter() + .filter_map(|(fingerprint, mut facts)| { + if facts.len() < 2 { + return None; + } + facts.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.node_id.cmp(&right.node_id)) + }); + let regions = facts + .iter() + .filter_map(|fact| { + fact.sources + .first() + .cloned() + .map(|source| StructuralCloneRegion { + metric_id: fact.id.clone(), + node_id: fact.node_id.clone(), + path: fact.path.clone(), + source, + }) + }) + .collect::>(); + (regions.len() >= 2).then(|| StructuralCloneGroup { + id: stable_graph_id("clone-group", fingerprint), + syntax_fingerprint: fingerprint.to_string(), + normalization_method: NORMALIZATION_METHOD.to_string(), + normalized_token_count: facts[0].normalized_token_count, + similarity: 1.0, + regions, + exclusions: vec![ + "comments".to_string(), + "identifier_names".to_string(), + "literal_values".to_string(), + format!("scopes_under_{MIN_CLONE_TOKENS}_tokens"), + ], + }) + }) + .collect::>(); + groups.sort_by(|left, right| left.id.cmp(&right.id)); + groups +} + +pub fn finalize_metric_degrees( + facts: &mut [StructuralGraphMetricFact], + edges: &[StructuralGraphEdge], +) { + let index = facts + .iter() + .enumerate() + .map(|(index, fact)| (fact.node_id.as_str(), index)) + .collect::>(); + let mut incoming = vec![HashSet::<&str>::new(); facts.len()]; + let mut outgoing = vec![HashSet::<&str>::new(); facts.len()]; + for edge in edges.iter().filter(|edge| { + matches!(edge.trust, GraphTrust::Extracted | GraphTrust::Inferred) + && is_dependency_edge(&edge.kind) + }) { + if let Some(&from) = index.get(edge.from.as_str()) { + outgoing[from].insert(edge.to.as_str()); + } + if let Some(&to) = index.get(edge.to.as_str()) { + incoming[to].insert(edge.from.as_str()); + } + } + for (index, fact) in facts.iter_mut().enumerate() { + fact.metrics.fan_in = incoming[index].len(); + fact.metrics.fan_out = outgoing[index].len(); + } +} + +fn is_dependency_edge(kind: &str) -> bool { + !matches!( + kind, + "defines" + | "contains" + | "contains_test" + | "declares" + | "exports" + | "exposes" + | "documents" + | "candidate_for" + | "same_event" + | "records_decision" + | "configures" + | "references" + ) +} + +fn syntax_fingerprint(scope: Node<'_>, source: &str) -> (String, usize, bool) { + let mut tokens = Vec::new(); + let mut total = 0_usize; + let mut stack = vec![scope]; + while let Some(node) = stack.pop() { + if node.child_count() > 0 { + let mut cursor = node.walk(); + let mut children = node.children(&mut cursor).collect::>(); + children.reverse(); + stack.extend(children); + continue; + } + if node.kind().contains("comment") { + continue; + } + let token = normalized_token(node, source); + if token.is_empty() { + continue; + } + total += 1; + if tokens.len() < MAX_NORMALIZED_TOKENS { + tokens.push(token); + } + } + ( + stable_graph_id("syntax-fingerprint", &tokens.join("\u{1f}")), + total, + total > MAX_NORMALIZED_TOKENS, + ) +} + +fn normalized_token(node: Node<'_>, source: &str) -> String { + let kind = node.kind(); + if is_identifier(kind) || kind.contains("identifier") { + return "$id".to_string(); + } + if kind.contains("string") || kind.contains("char_literal") { + return "$literal:string".to_string(); + } + if kind.contains("number") + || kind.contains("integer") + || kind.contains("float") + || kind.contains("decimal") + { + return "$literal:number".to_string(); + } + node_text(node, source, 80) + .map(|text| format!("{kind}:{text}")) + .unwrap_or_else(|| kind.to_string()) +} + +fn same_node(left: Node<'_>, right: Node<'_>) -> bool { + left.kind_id() == right.kind_id() + && left.start_byte() == right.start_byte() + && left.end_byte() == right.end_byte() +} + +fn control_flow_id(path: &str, node: Node<'_>, kind: &str) -> String { + stable_graph_id( + "control-flow", + &format!("{path}\0{kind}\0{}\0{}", node.start_byte(), node.end_byte()), + ) +} + +fn is_nested_scope(kind: &str) -> bool { + matches!( + kind, + "function_declaration" + | "function_definition" + | "function_item" + | "function_expression" + | "arrow_function" + | "method_definition" + | "method_declaration" + | "constructor_declaration" + | "class_declaration" + | "class_definition" + | "class_specifier" + | "impl_item" + ) +} + +fn is_statement(kind: &str) -> bool { + kind.ends_with("_statement") + || matches!( + kind, + "expression_statement" + | "let_declaration" + | "const_declaration" + | "variable_declaration" + | "local_variable_declaration" + | "defer_statement" + ) +} + +fn is_decision(kind: &str) -> bool { + matches!( + kind, + "if_statement" + | "if_expression" + | "elif_clause" + | "else_if_clause" + | "for_statement" + | "for_expression" + | "for_in_statement" + | "while_statement" + | "while_expression" + | "do_statement" + | "case_statement" + | "case_clause" + | "switch_case" + | "when_entry" + | "catch_clause" + | "except_clause" + | "conditional_expression" + | "ternary_expression" + | "match_arm" + ) || matches!(kind, "&&" | "||") +} + +fn is_nesting_construct(kind: &str) -> bool { + is_decision(kind) + || matches!( + kind, + "try_statement" + | "try_expression" + | "switch_statement" + | "switch_expression" + | "match_expression" + | "with_statement" + ) +} + +fn is_terminal_control(kind: &str) -> bool { + matches!( + kind, + "return_statement" + | "break_statement" + | "continue_statement" + | "throw_statement" + | "raise_statement" + | "yield_expression" + ) +} + +fn normalized_control_kind(kind: &str) -> &'static str { + if kind.contains("if") || kind.contains("conditional") || kind.contains("ternary") { + "branch" + } else if kind.contains("for") || kind.contains("while") || kind.contains("do_statement") { + "loop" + } else if kind.contains("case") || kind.contains("when") || kind.contains("match_arm") { + "case" + } else if kind.contains("catch") || kind.contains("except") { + "exception_branch" + } else if kind.contains("return") { + "return" + } else if kind.contains("break") { + "break" + } else if kind.contains("continue") { + "continue" + } else if kind.contains("throw") || kind.contains("raise") { + "throw" + } else if kind.contains("yield") { + "yield" + } else { + "decision" + } +} + +fn is_parameter(kind: &str) -> bool { + matches!( + kind, + "parameter" + | "formal_parameter" + | "required_parameter" + | "optional_parameter" + | "typed_parameter" + | "default_parameter" + | "variadic_parameter" + ) +} + +fn is_identifier(kind: &str) -> bool { + matches!( + kind, + "identifier" | "field_identifier" | "property_identifier" | "type_identifier" | "constant" + ) +} + +fn is_definition_identifier(node: Node<'_>) -> bool { + let Some(parent) = node.parent() else { + return false; + }; + if parent + .child_by_field_name("name") + .is_some_and(|name| same_node(name, node)) + && (is_parameter(parent.kind()) + || parent.kind().contains("declarator") + || parent.kind().contains("declaration") + || parent.kind().contains("pattern")) + { + return true; + } + is_parameter(parent.kind()) || parent.kind().contains("pattern") +} + +fn is_call(kind: &str) -> bool { + matches!( + kind, + "call_expression" + | "call" + | "method_invocation" + | "invocation_expression" + | "function_call_expression" + ) +} + +fn call_target(node: Node<'_>, source: &str) -> Option { + for field in ["function", "name", "method"] { + if let Some(target) = node.child_by_field_name(field) { + return node_text(target, source, 200); + } + } + let mut cursor = node.walk(); + let target = node + .named_children(&mut cursor) + .next() + .and_then(|target| node_text(target, source, 200)); + target +} + +fn classify_boundary(target: &str) -> Option<&'static str> { + let lower = target.to_ascii_lowercase(); + if [ + "fetch", + "axios", + "http", + "request", + "client.get", + "client.post", + "urlsession", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + Some("network") + } else if [ + "query", + "execute", + "select", + "insert", + "update", + "delete", + "repository", + "prisma", + "sqlx", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + Some("database") + } else if [ + "read_to_string", + "read_file", + "write_file", + "fs.", + "file.", + "open(", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + Some("filesystem") + } else if ["command", "spawn", "exec", "processbuilder", "subprocess"] + .iter() + .any(|marker| lower.contains(marker)) + { + Some("process") + } else if ["env::var", "getenv", "process.env", "import.meta.env"] + .iter() + .any(|marker| lower.contains(marker)) + { + Some("configuration") + } else { + None + } +} + +fn calculate_cohesion(scope: Node<'_>, source: &str) -> Option { + if !matches!( + scope.kind(), + "class_declaration" | "class_definition" | "class_specifier" | "struct_item" | "impl_item" + ) { + return None; + } + let mut fields = BTreeSet::new(); + let mut methods = Vec::new(); + let mut stack = vec![scope]; + while let Some(node) = stack.pop() { + if !same_node(node, scope) + && matches!( + node.kind(), + "method_definition" | "method_declaration" | "function_item" + ) + { + if let Some(text) = node_text(node, source, 20_000) { + methods.push(text); + } + continue; + } + if matches!( + node.kind(), + "field_definition" + | "public_field_definition" + | "field_declaration" + | "property_declaration" + ) { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|name| node_text(name, source, 120)) + { + fields.insert(name); + } + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + if methods.len() < 2 || fields.is_empty() { + return None; + } + let field_sets = methods + .iter() + .map(|method| { + fields + .iter() + .filter(|field| contains_identifier(method, field)) + .collect::>() + }) + .collect::>(); + let mut connected = 0_usize; + let mut pairs = 0_usize; + for left in 0..field_sets.len() { + for right in left + 1..field_sets.len() { + pairs += 1; + if !field_sets[left].is_disjoint(&field_sets[right]) { + connected += 1; + } + } + } + (pairs > 0).then(|| connected as f64 / pairs as f64) +} + +fn contains_identifier(source: &str, identifier: &str) -> bool { + source.match_indices(identifier).any(|(start, _)| { + let before = source[..start].chars().next_back(); + let end = start + identifier.len(); + let after = source[end..].chars().next(); + before.is_none_or(|value| !value.is_alphanumeric() && value != '_') + && after.is_none_or(|value| !value.is_alphanumeric() && value != '_') + }) +} + +fn source_anchor(path: &str, node: Node<'_>, source: &str) -> GraphSourceAnchor { + GraphSourceAnchor { + path: path.to_string(), + start_line: Some((node.start_position().row + 1) as u32), + start_column: Some((node.start_position().column + 1) as u32), + end_line: Some((node.end_position().row + 1) as u32), + end_column: Some((node.end_position().column + 1) as u32), + excerpt: node_text(node, source, 240), + } +} + +fn node_text(node: Node<'_>, source: &str, limit: usize) -> Option { + let text = source.get(node.byte_range())?.trim(); + (!text.is_empty()).then(|| text.chars().take(limit).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn function_fact( + path: &str, + language: SupportedLanguage, + source: &str, + ) -> StructuralGraphMetricFact { + let mut parser = Parser::new(); + parser + .set_language(&language.tree_sitter_language()) + .expect("language"); + let tree = parser.parse(source, None).expect("tree"); + let mut stack = vec![tree.root_node()]; + let scope = loop { + let node = stack.pop().expect("function scope"); + if is_nested_scope(node.kind()) { + break node; + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + }; + extract_scope_metrics( + path, + language, + source, + scope, + "function:fixture", + "function", + true, + Some("explicit export".to_string()), + ) + } + + #[test] + fn complexity_def_use_control_flow_and_boundaries_are_source_scoped() { + let fact = function_fact( + "src/load.ts", + SupportedLanguage::TypeScript, + "export async function load(userId: string) { let result = 0; if (userId && result === 0) { for (const row of await db.query('users')) { result += row.id; } } return result; }", + ); + assert!(fact.metrics.cyclomatic_complexity >= 4); + assert!(fact.metrics.cognitive_complexity >= 3); + assert!(fact.metrics.max_nesting >= 2); + assert!(fact.definitions.contains(&"userId".to_string())); + assert!(fact.uses.contains(&"result".to_string())); + assert!(fact.control_flow.iter().any(|flow| flow.kind == "branch")); + assert!(fact.control_flow.iter().any(|flow| flow.kind == "loop")); + assert!(fact + .boundaries + .iter() + .any(|boundary| boundary.kind == "database")); + assert_eq!(fact.sources[0].path, "src/load.ts"); + assert!(fact.public_surface); + } + + #[test] + fn generic_metrics_cover_python_and_rust_tiers() { + for (path, language, source) in [ + ( + "load.py", + SupportedLanguage::Python, + "def load(items):\n for item in items:\n if item:\n return item\n", + ), + ( + "load.rs", + SupportedLanguage::Rust, + "pub fn load(items: Vec) -> u8 { for item in items { if item > 0 { return item; } } 0 }", + ), + ] { + let fact = function_fact(path, language, source); + assert!(fact.metrics.cyclomatic_complexity >= 3, "{path}"); + assert!(fact.metrics.line_count >= 1, "{path}"); + assert!(!fact.control_flow.is_empty(), "{path}"); + } + } + + #[test] + fn semantic_fan_in_and_out_ignore_containment_edges() { + let mut facts = [ + function_fact( + "a.ts", + SupportedLanguage::TypeScript, + "function a() { b(); }", + ), + function_fact("b.ts", SupportedLanguage::TypeScript, "function b() {}"), + ]; + facts[0].node_id = "a".to_string(); + facts[1].node_id = "b".to_string(); + let edges = [ + StructuralGraphEdge { + id: "call".to_string(), + from: "a".to_string(), + to: "b".to_string(), + kind: "calls".to_string(), + evidence: "fixture".to_string(), + trust: GraphTrust::Inferred, + origin: super::super::types::GraphOrigin::Resolution, + sources: Vec::new(), + candidates: Vec::new(), + }, + StructuralGraphEdge { + id: "contains".to_string(), + from: "file".to_string(), + to: "a".to_string(), + kind: "defines".to_string(), + evidence: "fixture".to_string(), + trust: GraphTrust::Extracted, + origin: super::super::types::GraphOrigin::Syntax, + sources: Vec::new(), + candidates: Vec::new(), + }, + ]; + finalize_metric_degrees(&mut facts, &edges); + assert_eq!(facts[0].metrics.fan_out, 1); + assert_eq!(facts[0].metrics.fan_in, 0); + assert_eq!(facts[1].metrics.fan_in, 1); + } + + #[test] + fn class_cohesion_is_a_separate_explainable_metric() { + let source = "class Counter { value = 0; increment() { this.value += 1; } reset() { this.value = 0; } }"; + let language = SupportedLanguage::TypeScript; + let mut parser = Parser::new(); + parser + .set_language(&language.tree_sitter_language()) + .expect("language"); + let tree = parser.parse(source, None).expect("tree"); + let mut stack = vec![tree.root_node()]; + let class = loop { + let node = stack.pop().expect("class scope"); + if node.kind() == "class_declaration" { + break node; + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + }; + let fact = extract_scope_metrics( + "counter.ts", + language, + source, + class, + "class:counter", + "class", + false, + None, + ); + assert_eq!(fact.metrics.cohesion, Some(1.0)); + assert!(fact + .limitations + .contains(&"public_surface_not_proven".to_string())); + } + + #[test] + fn normalized_clone_groups_cross_files_without_identifier_or_literal_bias() { + let mut first = function_fact( + "src/alpha.ts", + SupportedLanguage::TypeScript, + "function alpha(items: number[]) { let total = 0; for (const item of items) { if (item > 1) { total += item; } } return total; }", + ); + first.node_id = "function:alpha".to_string(); + first.id = stable_graph_id("metric", &first.node_id); + let mut second = function_fact( + "src/beta.ts", + SupportedLanguage::TypeScript, + "function beta(values: number[]) { let sum = 9; for (const value of values) { if (value > 4) { sum += value; } } return sum; }", + ); + second.node_id = "function:beta".to_string(); + second.id = stable_graph_id("metric", &second.node_id); + + assert_eq!(first.syntax_fingerprint, second.syntax_fingerprint); + let groups = detect_clone_groups(&[first, second]); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].similarity, 1.0); + assert_eq!(groups[0].regions.len(), 2); + assert_eq!( + groups[0] + .regions + .iter() + .map(|region| region.path.as_str()) + .collect::>(), + ["src/alpha.ts", "src/beta.ts"] + ); + assert!(groups[0] + .exclusions + .contains(&"identifier_names".to_string())); + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index c241c56..264d704 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,5 +1,6 @@ pub mod analysis; mod contracts; pub mod language; +pub(crate) mod metrics; pub mod resolve; pub mod types; From e497c3515f385195315e96cf6bd9386e2bd1d353 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:52:37 +0530 Subject: [PATCH 017/141] feat(graph): qualify imported and legacy graphs --- .../commands/structural_graph/interchange.rs | 537 ++++++++++++++++++ .../src/commands/structural_graph/legacy.rs | 155 +++++ .../src/commands/structural_graph/mod.rs | 2 + 3 files changed, 694 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/interchange.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/legacy.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/interchange.rs b/apps/desktop/src-tauri/src/commands/structural_graph/interchange.rs new file mode 100644 index 0000000..1e69d65 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/interchange.rs @@ -0,0 +1,537 @@ +use super::analysis::analyze_graph; +use super::types::{ + stable_graph_id, GraphOrigin, GraphSourceAnchor, GraphTrust, StructuralGraphCommunity, + StructuralGraphCoverage, StructuralGraphDiagnostic, StructuralGraphEdge, + StructuralGraphEngineInfo, StructuralGraphFileRecord, StructuralGraphNode, + StructuralGraphSnapshot, STRUCTURAL_GRAPH_SCHEMA_VERSION, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +const MAX_IMPORT_BYTES: usize = 32 * 1024 * 1024; +const MAX_IMPORT_NODES: usize = 100_000; +const MAX_IMPORT_EDGES: usize = 250_000; +const EXTENSION_PREFIX: &str = "interchange_extensions:"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StructuralGraphAdapterDescriptor { + pub id: String, + pub label: String, + pub mode: String, + pub bundled: bool, + pub mutates_repository: bool, + pub requires_explicit_action: bool, + pub runtime_behavior: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructuralGraphInterchangePreview { + pub snapshot: StructuralGraphSnapshot, + pub warnings: Vec, +} + +pub fn adapter_descriptors() -> Vec { + vec![ + StructuralGraphAdapterDescriptor { + id: "node_link-json".to_string(), + label: "Generic node-link JSON".to_string(), + mode: "local_import".to_string(), + bundled: true, + mutates_repository: false, + requires_explicit_action: true, + runtime_behavior: "Parses a user-supplied local JSON document; no Python, process, network, or repository write".to_string(), + }, + ] +} + +pub fn import_node_link_json( + repo_path: &str, + json_text: &str, +) -> Result { + if json_text.len() > MAX_IMPORT_BYTES { + return Err(format!( + "node-link import exceeds the {} MiB local safety limit", + MAX_IMPORT_BYTES / 1024 / 1024 + )); + } + let document: Value = serde_json::from_str(json_text) + .map_err(|error| format!("node-link JSON is invalid: {error}"))?; + let object = document + .as_object() + .ok_or_else(|| "node-link JSON must be an object".to_string())?; + let raw_nodes = object + .get("nodes") + .and_then(Value::as_array) + .ok_or_else(|| "node-link JSON requires a nodes array".to_string())?; + let raw_edges = object + .get("links") + .or_else(|| object.get("edges")) + .and_then(Value::as_array) + .ok_or_else(|| "node-link JSON requires a links or edges array".to_string())?; + if raw_nodes.len() > MAX_IMPORT_NODES || raw_edges.len() > MAX_IMPORT_EDGES { + return Err(format!( + "node-link import exceeds bounded graph limits ({MAX_IMPORT_NODES} nodes, {MAX_IMPORT_EDGES} edges)" + )); + } + + let mut id_map = HashMap::new(); + let mut nodes = Vec::with_capacity(raw_nodes.len()); + for raw in raw_nodes { + let fields = raw + .as_object() + .ok_or_else(|| "Every node-link node must be an object".to_string())?; + let upstream_id = required_string(fields, "id", "node-link node")?; + if id_map.contains_key(upstream_id) { + return Err(format!("node-link node id is duplicated: {upstream_id}")); + } + let id = stable_graph_id("node_link-node", upstream_id); + id_map.insert(upstream_id.to_string(), id.clone()); + let label = fields + .get("label") + .and_then(Value::as_str) + .unwrap_or(upstream_id) + .to_string(); + let path = fields + .get("source_file") + .and_then(Value::as_str) + .map(normalize_path); + let source = source_anchor(fields); + let community_id = fields + .get("community") + .filter(|value| !value.is_null()) + .map(|value| stable_graph_id("node_link-community", &value.to_string())); + nodes.push(StructuralGraphNode { + id, + kind: infer_node_kind(&label, path.as_deref()), + label, + qualified_name: Some(upstream_id.to_string()), + path, + detail: extension_detail( + fields, + &[ + "id", + "label", + "file_type", + "source_file", + "source_location", + "community", + ], + ), + language: None, + community_id, + trust: GraphTrust::Inferred, + origin: GraphOrigin::ImportedNodeLink, + sources: source.into_iter().collect(), + }); + } + + let mut edges = Vec::with_capacity(raw_edges.len()); + for (ordinal, raw) in raw_edges.iter().enumerate() { + let fields = raw + .as_object() + .ok_or_else(|| "Every node-link link must be an object".to_string())?; + let source_id = endpoint_string(fields, "source", "_src")?; + let target_id = endpoint_string(fields, "target", "_tgt")?; + let from = id_map.get(source_id).ok_or_else(|| { + format!("node-link link {ordinal} references missing source node {source_id}") + })?; + let to = id_map.get(target_id).ok_or_else(|| { + format!("node-link link {ordinal} references missing target node {target_id}") + })?; + let kind = fields + .get("relation") + .or_else(|| fields.get("kind")) + .and_then(Value::as_str) + .unwrap_or("related_to") + .to_ascii_lowercase(); + let trust = node_link_trust(fields.get("confidence").and_then(Value::as_str)); + let sources = source_anchor(fields).into_iter().collect::>(); + let extensions = extension_detail( + fields, + &[ + "source", + "target", + "_src", + "_tgt", + "relation", + "kind", + "confidence", + "source_file", + "source_location", + ], + ); + edges.push(StructuralGraphEdge { + id: stable_graph_id( + "node_link-edge", + &format!("{ordinal}\0{from}\0{to}\0{kind}"), + ), + from: from.clone(), + to: to.clone(), + kind, + evidence: extensions + .unwrap_or_else(|| "Imported from generic node-link JSON".to_string()), + trust, + origin: GraphOrigin::ImportedNodeLink, + sources, + candidates: Vec::new(), + }); + } + + let explicit_communities = imported_communities(&nodes); + let communities = if explicit_communities.is_empty() { + analyze_graph(&mut nodes, &edges) + } else { + explicit_communities + }; + let files = imported_files(&nodes); + let mut top_level_extensions = object.clone(); + for key in ["nodes", "links", "edges", "directed", "multigraph", "graph"] { + top_level_extensions.remove(key); + } + let diagnostics = (!top_level_extensions.is_empty()) + .then(|| StructuralGraphDiagnostic { + severity: "info".to_string(), + code: "node_link_top_level_extensions".to_string(), + message: format!("{EXTENSION_PREFIX}{}", Value::Object(top_level_extensions)), + path: None, + language: None, + }) + .into_iter() + .collect(); + let snapshot_id = stable_graph_id("node_link-snapshot", json_text); + Ok(StructuralGraphInterchangePreview { + snapshot: StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: snapshot_id, + repo_path: repo_path.to_string(), + repo_head: None, + created_at: Utc::now().to_rfc3339(), + engine: StructuralGraphEngineInfo { + id: "node_link-json-import".to_string(), + version: "node-link".to_string(), + bundled: true, + syntax_aware: true, + supported_languages: Vec::new(), + }, + cursor: None, + ignore_fingerprint: None, + coverage: StructuralGraphCoverage { + discovered_files: files.len(), + indexed_files: files.len(), + ..StructuralGraphCoverage::default() + }, + diagnostics, + communities, + files, + nodes, + edges, + metrics: Vec::new(), + clone_groups: Vec::new(), + truncated: false, + }, + warnings: vec![ + "Preview only: importing node-link JSON does not replace the canonical CodeVetter index" + .to_string(), + ], + }) +} + +pub fn export_json(snapshot: &StructuralGraphSnapshot) -> Result { + #[derive(Serialize)] + struct Export<'a> { + format: &'static str, + schema_version: i64, + exported_at: String, + snapshot: &'a StructuralGraphSnapshot, + } + serde_json::to_string_pretty(&Export { + format: "codevetter-structural-graph", + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + exported_at: Utc::now().to_rfc3339(), + snapshot, + }) + .map_err(|error| format!("Could not export structural graph JSON: {error}")) +} + +pub fn import_codevetter_json(json_text: &str) -> Result { + if json_text.len() > MAX_IMPORT_BYTES { + return Err(format!( + "CodeVetter import exceeds the {} MiB local safety limit", + MAX_IMPORT_BYTES / 1024 / 1024 + )); + } + #[derive(Deserialize)] + struct Import { + format: String, + schema_version: i64, + snapshot: StructuralGraphSnapshot, + } + let imported: Import = serde_json::from_str(json_text) + .map_err(|error| format!("CodeVetter graph JSON is invalid: {error}"))?; + if imported.format != "codevetter-structural-graph" + || imported.schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION + || imported.snapshot.schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION + { + return Err("CodeVetter graph export uses an unsupported format or schema".to_string()); + } + if imported.snapshot.nodes.len() > MAX_IMPORT_NODES + || imported.snapshot.edges.len() > MAX_IMPORT_EDGES + { + return Err("CodeVetter graph export exceeds bounded graph limits".to_string()); + } + let node_ids = imported + .snapshot + .nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + if let Some(edge) = + imported.snapshot.edges.iter().find(|edge| { + !node_ids.contains(edge.from.as_str()) || !node_ids.contains(edge.to.as_str()) + }) + { + return Err(format!( + "CodeVetter graph edge {} has a dangling endpoint", + edge.id + )); + } + Ok(imported.snapshot) +} + +pub fn export_markdown(snapshot: &StructuralGraphSnapshot) -> String { + let mut markdown = format!( + "# CodeVetter structural graph\n\n- Snapshot: `{}`\n- Engine: `{}@{}`\n- Nodes: {}\n- Edges: {}\n- Indexed files: {}\n\n## Important communities\n", + snapshot.id, + snapshot.engine.id, + snapshot.engine.version, + snapshot.nodes.len(), + snapshot.edges.len(), + snapshot.coverage.indexed_files + ); + let mut communities = snapshot.communities.iter().collect::>(); + communities.sort_by(|left, right| { + right + .score + .total_cmp(&left.score) + .then_with(|| left.label.cmp(&right.label)) + }); + for community in communities.into_iter().take(20) { + markdown.push_str(&format!( + "- **{}**: {} nodes, {} bridges\n", + community.label, + community.member_count, + community.bridge_node_ids.len() + )); + } + markdown.push_str("\n## Source-backed nodes\n"); + for node in snapshot + .nodes + .iter() + .filter(|node| !node.sources.is_empty()) + .take(100) + { + let source = &node.sources[0]; + markdown.push_str(&format!( + "- `{}` {} - `{}`{}\n", + node.kind, + node.label, + source.path, + source + .start_line + .map(|line| format!(":{line}")) + .unwrap_or_default() + )); + } + markdown +} + +fn required_string<'a>( + fields: &'a Map, + key: &str, + label: &str, +) -> Result<&'a str, String> { + fields + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("{label} requires a non-empty string {key}")) +} + +fn endpoint_string<'a>( + fields: &'a Map, + primary: &str, + fallback: &str, +) -> Result<&'a str, String> { + fields + .get(primary) + .or_else(|| fields.get(fallback)) + .and_then(Value::as_str) + .ok_or_else(|| format!("node-link link requires {primary}/{fallback} string endpoints")) +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_string() +} + +fn source_anchor(fields: &Map) -> Option { + let path = fields.get("source_file")?.as_str().map(normalize_path)?; + let location = fields + .get("source_location") + .and_then(Value::as_str) + .unwrap_or_default(); + let start_line = location + .trim_start_matches('L') + .split(['-', ':']) + .next() + .and_then(|line| line.parse().ok()); + Some(GraphSourceAnchor { + path, + start_line, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }) +} + +fn infer_node_kind(label: &str, path: Option<&str>) -> String { + if path.is_some_and(|path| path.rsplit('/').next() == Some(label)) { + "file".to_string() + } else if label.ends_with("()") { + "function".to_string() + } else { + "symbol".to_string() + } +} + +fn node_link_trust(confidence: Option<&str>) -> GraphTrust { + match confidence.unwrap_or_default().to_ascii_uppercase().as_str() { + "EXTRACTED" => GraphTrust::Extracted, + "AMBIGUOUS" => GraphTrust::Ambiguous, + _ => GraphTrust::Inferred, + } +} + +fn extension_detail(fields: &Map, known: &[&str]) -> Option { + let known = known.iter().copied().collect::>(); + let extensions = fields + .iter() + .filter(|(key, _)| !known.contains(key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + (!extensions.is_empty()).then(|| format!("{EXTENSION_PREFIX}{}", Value::Object(extensions))) +} + +fn imported_communities(nodes: &[StructuralGraphNode]) -> Vec { + let mut members = BTreeMap::>::new(); + for node in nodes { + if let Some(community_id) = &node.community_id { + members + .entry(community_id.clone()) + .or_default() + .push(node.id.clone()); + } + } + members + .into_iter() + .map(|(id, mut node_ids)| { + node_ids.sort(); + StructuralGraphCommunity { + label: format!( + "node-link community {}", + id.rsplit(':').next().unwrap_or(&id) + ), + id, + member_count: node_ids.len(), + hub_node_ids: Vec::new(), + bridge_node_ids: Vec::new(), + score: node_ids.len() as f64, + } + }) + .collect() +} + +fn imported_files(nodes: &[StructuralGraphNode]) -> Vec { + let mut paths = nodes + .iter() + .filter_map(|node| node.path.clone()) + .collect::>(); + paths.sort(); + paths.dedup(); + paths + .into_iter() + .map(|path| StructuralGraphFileRecord { + path, + language: None, + content_hash: None, + disposition: "imported".to_string(), + byte_size: 0, + node_count: 0, + edge_count: 0, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = r#"{ + "directed": true, + "vendor_extension": {"kept": true}, + "nodes": [ + {"id":"api","label":"api.py","source_file":"src/api.py","source_location":"L1","community":1,"custom":"node-value"}, + {"id":"run","label":"run()","source_file":"src/api.py","source_location":"L4","community":1} + ], + "links": [ + {"source":"api","target":"run","relation":"contains","confidence":"EXTRACTED","source_file":"src/api.py","source_location":"L4","weight":1.0} + ] + }"#; + + #[test] + fn node_link_import_preserves_trust_locations_communities_and_extensions() { + let preview = import_node_link_json("/repo", FIXTURE).expect("import"); + assert_eq!(preview.snapshot.nodes.len(), 2); + assert_eq!(preview.snapshot.edges[0].trust, GraphTrust::Extracted); + assert_eq!(preview.snapshot.edges[0].sources[0].start_line, Some(4)); + assert_eq!(preview.snapshot.communities.len(), 1); + assert!(preview.snapshot.nodes[0] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("custom"))); + assert!(preview.snapshot.diagnostics[0] + .message + .contains("vendor_extension")); + } + + #[test] + fn node_link_import_rejects_dangling_edges_without_partial_output() { + let invalid = r#"{"nodes":[{"id":"a"}],"links":[{"source":"a","target":"missing"}]}"#; + assert!(import_node_link_json("/repo", invalid) + .unwrap_err() + .contains("missing target")); + } + + #[test] + fn node_link_import_enforces_the_document_byte_cap() { + let oversized = " ".repeat(MAX_IMPORT_BYTES + 1); + assert!(import_node_link_json("/repo", &oversized) + .unwrap_err() + .contains("safety limit")); + } + + #[test] + fn codevetter_json_and_markdown_exports_are_versioned_and_source_backed() { + let preview = import_node_link_json("/repo", FIXTURE).expect("import"); + let json = export_json(&preview.snapshot).expect("json export"); + assert!(json.contains("codevetter-structural-graph")); + assert!(json.contains("interchange_extensions")); + let markdown = export_markdown(&preview.snapshot); + assert!(markdown.contains("# CodeVetter structural graph")); + assert!(markdown.contains("`src/api.py`:1")); + let round_trip = import_codevetter_json(&json).expect("round trip"); + assert_eq!(round_trip, preview.snapshot); + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/legacy.rs b/apps/desktop/src-tauri/src/commands/structural_graph/legacy.rs new file mode 100644 index 0000000..c9e3ca7 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/legacy.rs @@ -0,0 +1,155 @@ +use super::types::{ + stable_graph_id, GraphOrigin, GraphSourceAnchor, GraphTrust, StructuralGraphCoverage, + StructuralGraphEdge, StructuralGraphEngineInfo, StructuralGraphFileRecord, StructuralGraphNode, + StructuralGraphSnapshot, STRUCTURAL_GRAPH_SCHEMA_VERSION, +}; +use crate::commands::unpack_types::RepoGraph; + +pub fn snapshot_from_legacy_map( + repo_path: &str, + repo_head: Option, + graph: &RepoGraph, + created_at: String, +) -> StructuralGraphSnapshot { + let nodes = graph + .nodes + .iter() + .map(|node| StructuralGraphNode { + id: node.id.clone(), + kind: node.kind.clone(), + label: node.label.clone(), + qualified_name: None, + path: node.path.clone(), + detail: node.detail.clone(), + language: None, + community_id: None, + trust: GraphTrust::Legacy, + origin: GraphOrigin::LegacyMetadata, + sources: node + .sources + .iter() + .cloned() + .map(GraphSourceAnchor::path) + .collect(), + }) + .collect(); + let edges = graph + .edges + .iter() + .map(|edge| StructuralGraphEdge { + id: stable_graph_id( + "edge", + &format!("{}\0{}\0{}", edge.kind, edge.from, edge.to), + ), + from: edge.from.clone(), + to: edge.to.clone(), + kind: edge.kind.clone(), + evidence: edge.evidence.clone(), + trust: GraphTrust::Legacy, + origin: GraphOrigin::LegacyMetadata, + sources: edge + .sources + .iter() + .cloned() + .map(GraphSourceAnchor::path) + .collect(), + candidates: Vec::new(), + }) + .collect(); + let mut files = graph + .nodes + .iter() + .filter_map(|node| node.path.as_ref()) + .map(|path| StructuralGraphFileRecord { + path: path.clone(), + language: None, + content_hash: None, + disposition: "legacy_metadata".to_string(), + byte_size: 0, + node_count: 1, + edge_count: 0, + }) + .collect::>(); + files.sort_by(|left, right| left.path.cmp(&right.path)); + files.dedup_by(|left, right| left.path == right.path); + + StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: stable_graph_id( + "snapshot", + &format!( + "legacy\0{repo_path}\0{}", + repo_head.as_deref().unwrap_or("unknown") + ), + ), + repo_path: repo_path.to_string(), + repo_head, + created_at, + engine: StructuralGraphEngineInfo { + id: "codevetter-metadata-map".to_string(), + version: graph.schema_version.to_string(), + bundled: true, + syntax_aware: false, + supported_languages: Vec::new(), + }, + cursor: None, + ignore_fingerprint: None, + coverage: StructuralGraphCoverage { + discovered_files: graph + .nodes + .iter() + .filter(|node| node.kind == "file") + .count(), + indexed_files: 0, + ..StructuralGraphCoverage::default() + }, + diagnostics: Vec::new(), + communities: Vec::new(), + files, + nodes, + edges, + metrics: Vec::new(), + clone_groups: Vec::new(), + truncated: graph.truncated, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::unpack_types::{RepoGraphEdge, RepoGraphNode}; + + #[test] + fn legacy_map_is_never_upgraded_to_extracted_trust() { + let graph = RepoGraph { + schema_version: 1, + nodes: vec![RepoGraphNode { + id: "file:a".to_string(), + kind: "file".to_string(), + label: "a.ts".to_string(), + path: Some("a.ts".to_string()), + detail: None, + sources: vec!["a.ts".to_string()], + source_location: None, + community: None, + }], + edges: vec![RepoGraphEdge { + from: "file:a".to_string(), + to: "route:a".to_string(), + kind: "routes_to".to_string(), + evidence: "path convention".to_string(), + sources: vec!["a.ts".to_string()], + trust: "legacy".to_string(), + origin: "codevetter".to_string(), + confidence_label: None, + }], + truncated: false, + }; + + let snapshot = snapshot_from_legacy_map("/repo", None, &graph, "now".to_string()); + assert_eq!(snapshot.schema_version, STRUCTURAL_GRAPH_SCHEMA_VERSION); + assert_eq!(snapshot.nodes[0].trust, GraphTrust::Legacy); + assert_eq!(snapshot.edges[0].trust, GraphTrust::Legacy); + assert!(!snapshot.engine.syntax_aware); + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index 264d704..5f03c1c 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,6 +1,8 @@ pub mod analysis; mod contracts; +pub mod interchange; pub mod language; +pub mod legacy; pub(crate) mod metrics; pub mod resolve; pub mod types; From 9eee56dc7008895c6864b25b363e2b51927f1308 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 10:58:22 +0530 Subject: [PATCH 018/141] feat(graph): persist normalized structural snapshots --- .../src/commands/structural_graph/mod.rs | 1 + .../src/commands/structural_graph/storage.rs | 1187 +++++++++++++++++ apps/desktop/src-tauri/src/db/mod.rs | 1 + apps/desktop/src-tauri/src/db/schema.rs | 1 + .../src/db/schema/structural_graph.sql | 164 +++ .../src/db/structural_graph_schema.rs | 63 + 6 files changed, 1417 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/storage.rs create mode 100644 apps/desktop/src-tauri/src/db/schema/structural_graph.sql create mode 100644 apps/desktop/src-tauri/src/db/structural_graph_schema.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index 5f03c1c..191b654 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -5,4 +5,5 @@ pub mod language; pub mod legacy; pub(crate) mod metrics; pub mod resolve; +pub mod storage; pub mod types; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs b/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs new file mode 100644 index 0000000..aa85823 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs @@ -0,0 +1,1187 @@ +use super::types::{ + GraphOrigin, GraphSourceAnchor, GraphTrust, StructuralCloneGroup, StructuralGraphCommunity, + StructuralGraphCoverage, StructuralGraphDiagnostic, StructuralGraphEdge, StructuralGraphError, + StructuralGraphFileRecord, StructuralGraphMetricFact, StructuralGraphNode, + StructuralGraphSnapshot, STRUCTURAL_GRAPH_SCHEMA_VERSION, +}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::collections::HashMap; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct StructuralGraphStoredSummary { + pub id: String, + pub repo_path: String, + pub repo_head: Option, + pub schema_version: i64, + pub engine_id: String, + pub engine_version: String, + pub coverage: StructuralGraphCoverage, + pub created_at: String, + pub node_count: usize, + pub edge_count: usize, + pub diagnostic_count: usize, + pub truncated: bool, +} + +pub fn persist_snapshot( + connection: &Connection, + snapshot: &StructuralGraphSnapshot, +) -> Result<(), StructuralGraphError> { + if snapshot.schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION { + return Err(StructuralGraphError::UnsupportedSchema( + snapshot.schema_version, + )); + } + + let transaction = connection + .unchecked_transaction() + .map_err(storage_error("start structural graph transaction"))?; + transaction + .execute( + "INSERT INTO structural_graph_snapshots ( + id, repo_path, repo_head, schema_version, engine_id, engine_version, + engine_json, cursor, ignore_fingerprint, coverage_json, truncated, + status, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'ready', ?12) + ON CONFLICT(id) DO UPDATE SET + repo_path = excluded.repo_path, + repo_head = excluded.repo_head, + schema_version = excluded.schema_version, + engine_id = excluded.engine_id, + engine_version = excluded.engine_version, + engine_json = excluded.engine_json, + cursor = excluded.cursor, + ignore_fingerprint = excluded.ignore_fingerprint, + coverage_json = excluded.coverage_json, + truncated = excluded.truncated, + status = excluded.status, + created_at = excluded.created_at", + params![ + snapshot.id, + snapshot.repo_path, + snapshot.repo_head, + snapshot.schema_version, + snapshot.engine.id, + snapshot.engine.version, + to_json(&snapshot.engine)?, + snapshot.cursor, + snapshot.ignore_fingerprint, + to_json(&snapshot.coverage)?, + i64::from(snapshot.truncated), + snapshot.created_at, + ], + ) + .map_err(storage_error("write structural graph snapshot"))?; + + for table in [ + "structural_graph_sources", + "structural_graph_edges", + "structural_graph_clone_groups", + "structural_graph_metric_facts", + "structural_graph_nodes", + "structural_graph_snapshot_files", + "structural_graph_communities", + "structural_graph_diagnostics", + ] { + transaction + .execute( + &format!("DELETE FROM {table} WHERE snapshot_id = ?1"), + params![snapshot.id], + ) + .map_err(storage_error("replace structural graph projection"))?; + } + + { + let mut statement = transaction + .prepare( + "INSERT INTO structural_graph_snapshot_files ( + snapshot_id, path, language, content_hash, disposition, + byte_size, node_count, edge_count + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .map_err(storage_error("prepare structural graph file records"))?; + for file in &snapshot.files { + statement + .execute(params![ + snapshot.id, + file.path, + file.language, + file.content_hash, + file.disposition, + file.byte_size as i64, + file.node_count as i64, + file.edge_count as i64, + ]) + .map_err(storage_error("write structural graph file record"))?; + } + } + + transaction + .execute( + "DELETE FROM structural_graph_file_cursors WHERE repo_path = ?1", + params![snapshot.repo_path], + ) + .map_err(storage_error("replace structural graph file cursors"))?; + { + let mut statement = transaction + .prepare( + "INSERT INTO structural_graph_file_cursors ( + repo_path, path, content_hash, language, engine_version, indexed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .map_err(storage_error("prepare structural graph file cursors"))?; + for file in snapshot + .files + .iter() + .filter(|file| file.content_hash.is_some()) + { + statement + .execute(params![ + snapshot.repo_path, + file.path, + file.content_hash, + file.language, + snapshot.engine.version, + snapshot.created_at, + ]) + .map_err(storage_error("write structural graph file cursor"))?; + } + } + + { + let mut node_statement = transaction + .prepare( + "INSERT INTO structural_graph_nodes ( + snapshot_id, id, kind, label, qualified_name, path, detail, + language, community_id, trust, origin + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + ) + .map_err(storage_error("prepare structural graph nodes"))?; + let mut source_statement = prepare_source_insert(&transaction)?; + for node in &snapshot.nodes { + node_statement + .execute(params![ + snapshot.id, + node.id, + node.kind, + node.label, + node.qualified_name, + node.path, + node.detail, + node.language, + node.community_id, + node.trust.as_str(), + node.origin.as_str(), + ]) + .map_err(storage_error("write structural graph node"))?; + insert_sources( + &mut source_statement, + &snapshot.id, + "node", + &node.id, + &node.sources, + )?; + } + } + + { + let mut edge_statement = transaction + .prepare( + "INSERT INTO structural_graph_edges ( + snapshot_id, id, from_id, to_id, kind, evidence, trust, + origin, candidates_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .map_err(storage_error("prepare structural graph edges"))?; + let mut source_statement = prepare_source_insert(&transaction)?; + for edge in &snapshot.edges { + edge_statement + .execute(params![ + snapshot.id, + edge.id, + edge.from, + edge.to, + edge.kind, + edge.evidence, + edge.trust.as_str(), + edge.origin.as_str(), + to_json(&edge.candidates)?, + ]) + .map_err(storage_error("write structural graph edge"))?; + insert_sources( + &mut source_statement, + &snapshot.id, + "edge", + &edge.id, + &edge.sources, + )?; + } + } + + { + let mut statement = transaction + .prepare( + "INSERT INTO structural_graph_clone_groups ( + snapshot_id, id, syntax_fingerprint, normalized_tokens, group_json + ) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .map_err(storage_error("prepare structural graph clone groups"))?; + for group in &snapshot.clone_groups { + statement + .execute(params![ + snapshot.id, + group.id, + group.syntax_fingerprint, + group.normalized_token_count as i64, + to_json(group)?, + ]) + .map_err(storage_error("write structural graph clone group"))?; + } + } + + { + let mut statement = transaction + .prepare( + "INSERT INTO structural_graph_metric_facts ( + snapshot_id, id, node_id, path, scope_kind, language, + public_surface, fact_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .map_err(storage_error("prepare structural graph metric facts"))?; + for fact in &snapshot.metrics { + statement + .execute(params![ + snapshot.id, + fact.id, + fact.node_id, + fact.path, + fact.scope_kind, + fact.language, + i64::from(fact.public_surface), + to_json(fact)?, + ]) + .map_err(storage_error("write structural graph metric fact"))?; + } + } + + { + let mut statement = transaction + .prepare( + "INSERT INTO structural_graph_communities ( + snapshot_id, id, label, member_count, hub_node_ids_json, + bridge_ids_json, score + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .map_err(storage_error("prepare structural graph communities"))?; + for community in &snapshot.communities { + statement + .execute(params![ + snapshot.id, + community.id, + community.label, + community.member_count as i64, + to_json(&community.hub_node_ids)?, + to_json(&community.bridge_node_ids)?, + community.score, + ]) + .map_err(storage_error("write structural graph community"))?; + } + } + + { + let mut statement = transaction + .prepare( + "INSERT INTO structural_graph_diagnostics ( + snapshot_id, ordinal, severity, code, message, path, language + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .map_err(storage_error("prepare structural graph diagnostics"))?; + for (ordinal, diagnostic) in snapshot.diagnostics.iter().enumerate() { + statement + .execute(params![ + snapshot.id, + ordinal as i64, + diagnostic.severity, + diagnostic.code, + diagnostic.message, + diagnostic.path, + diagnostic.language, + ]) + .map_err(storage_error("write structural graph diagnostic"))?; + } + } + + transaction + .commit() + .map_err(storage_error("commit structural graph snapshot")) +} + +pub fn prune_present_state_snapshots( + connection: &Connection, + repo_path: &str, + keep: usize, +) -> Result { + if repo_path.starts_with("history:") { + return Ok(0); + } + connection + .execute( + "DELETE FROM structural_graph_snapshots + WHERE id IN ( + SELECT id FROM structural_graph_snapshots + WHERE repo_path = ?1 AND status = 'ready' + ORDER BY created_at DESC, id DESC + LIMIT -1 OFFSET ?2 + )", + params![repo_path, keep.max(1) as i64], + ) + .map_err(storage_error("prune structural graph snapshots")) +} + +pub fn load_latest_snapshot( + connection: &Connection, + repo_path: &str, +) -> Result, StructuralGraphError> { + let metadata = connection + .query_row( + "SELECT id, repo_path, repo_head, schema_version, engine_json, cursor, + ignore_fingerprint, coverage_json, truncated, created_at + FROM structural_graph_snapshots + WHERE repo_path = ?1 AND status = 'ready' + ORDER BY created_at DESC, id DESC + LIMIT 1", + params![repo_path], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, String>(9)?, + )) + }, + ) + .optional() + .map_err(storage_error("load structural graph snapshot"))?; + hydrate_snapshot(connection, metadata) +} + +pub fn load_snapshot_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let metadata = connection + .query_row( + "SELECT id, repo_path, repo_head, schema_version, engine_json, cursor, + ignore_fingerprint, coverage_json, truncated, created_at + FROM structural_graph_snapshots + WHERE repo_path = ?1 AND id = ?2 AND status = 'ready'", + params![repo_path, snapshot_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, String>(9)?, + )) + }, + ) + .optional() + .map_err(storage_error("load structural graph snapshot by id"))?; + hydrate_snapshot(connection, metadata) +} + +type SnapshotMetadata = ( + String, + String, + Option, + i64, + String, + Option, + Option, + String, + i64, + String, +); + +fn hydrate_snapshot( + connection: &Connection, + metadata: Option, +) -> Result, StructuralGraphError> { + let Some(( + id, + stored_repo_path, + repo_head, + schema_version, + engine_json, + cursor, + ignore_fingerprint, + coverage_json, + truncated, + created_at, + )) = metadata + else { + return Ok(None); + }; + if schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION { + return Err(StructuralGraphError::UnsupportedSchema(schema_version)); + } + + let mut sources = load_source_map(connection, &id)?; + Ok(Some(StructuralGraphSnapshot { + schema_version, + nodes: load_nodes(connection, &id, &mut sources)?, + edges: load_edges(connection, &id, &mut sources)?, + metrics: load_metrics(connection, &id)?, + clone_groups: load_clone_groups(connection, &id)?, + communities: load_communities(connection, &id)?, + files: load_snapshot_files(connection, &id)?, + diagnostics: load_diagnostics(connection, &id)?, + id, + repo_path: stored_repo_path, + repo_head, + created_at, + engine: from_json(&engine_json, "engine")?, + cursor, + ignore_fingerprint, + coverage: from_json(&coverage_json, "coverage")?, + truncated: truncated != 0, + })) +} + +pub fn load_latest_snapshot_summary( + connection: &Connection, + repo_path: &str, +) -> Result, StructuralGraphError> { + let summary = connection + .query_row( + "SELECT s.id, s.repo_path, s.repo_head, s.schema_version, s.engine_id, s.engine_version, + s.coverage_json, s.created_at, + (SELECT COUNT(*) FROM structural_graph_nodes n WHERE n.snapshot_id = s.id), + (SELECT COUNT(*) FROM structural_graph_edges e WHERE e.snapshot_id = s.id), + (SELECT COUNT(*) FROM structural_graph_diagnostics d WHERE d.snapshot_id = s.id), + s.truncated + FROM structural_graph_snapshots s + WHERE s.repo_path = ?1 AND s.status = 'ready' + ORDER BY s.created_at DESC, s.id DESC + LIMIT 1", + params![repo_path], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, i64>(10)?, + row.get::<_, i64>(11)?, + )) + }, + ) + .optional() + .map_err(storage_error("load structural graph summary"))?; + let Some(( + id, + repo_path, + repo_head, + schema_version, + engine_id, + engine_version, + coverage_json, + created_at, + node_count, + edge_count, + diagnostic_count, + truncated, + )) = summary + else { + return Ok(None); + }; + Ok(Some(StructuralGraphStoredSummary { + id, + repo_path, + repo_head, + schema_version, + engine_id, + engine_version, + coverage: from_json(&coverage_json, "coverage")?, + created_at, + node_count: node_count.max(0) as usize, + edge_count: edge_count.max(0) as usize, + diagnostic_count: diagnostic_count.max(0) as usize, + truncated: truncated != 0, + })) +} + +pub fn list_snapshot_summaries( + connection: &Connection, + repo_path: &str, + limit: usize, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT s.id, s.repo_path, s.repo_head, s.schema_version, s.engine_id, s.engine_version, + s.coverage_json, s.created_at, + (SELECT COUNT(*) FROM structural_graph_nodes n WHERE n.snapshot_id = s.id), + (SELECT COUNT(*) FROM structural_graph_edges e WHERE e.snapshot_id = s.id), + (SELECT COUNT(*) FROM structural_graph_diagnostics d WHERE d.snapshot_id = s.id), + s.truncated + FROM structural_graph_snapshots s + WHERE s.repo_path = ?1 AND s.status = 'ready' + ORDER BY s.created_at DESC, s.id DESC + LIMIT ?2", + ) + .map_err(storage_error("prepare structural graph snapshot list"))?; + let rows = statement + .query_map(params![repo_path, limit.clamp(1, 100) as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, i64>(10)?, + row.get::<_, i64>(11)?, + )) + }) + .map_err(storage_error("query structural graph snapshot list"))? + .collect::, _>>() + .map_err(storage_error("read structural graph snapshot list"))?; + rows.into_iter() + .map( + |( + id, + repo_path, + repo_head, + schema_version, + engine_id, + engine_version, + coverage_json, + created_at, + node_count, + edge_count, + diagnostic_count, + truncated, + )| { + Ok(StructuralGraphStoredSummary { + id, + repo_path, + repo_head, + schema_version, + engine_id, + engine_version, + coverage: from_json(&coverage_json, "coverage")?, + created_at, + node_count: node_count.max(0) as usize, + edge_count: edge_count.max(0) as usize, + diagnostic_count: diagnostic_count.max(0) as usize, + truncated: truncated != 0, + }) + }, + ) + .collect() +} + +pub fn load_snapshot_files( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT path, language, content_hash, disposition, byte_size, node_count, edge_count + FROM structural_graph_snapshot_files + WHERE snapshot_id = ?1 ORDER BY path", + ) + .map_err(storage_error("prepare structural graph files"))?; + let files = statement + .query_map(params![snapshot_id], |row| { + Ok(StructuralGraphFileRecord { + path: row.get(0)?, + language: row.get(1)?, + content_hash: row.get(2)?, + disposition: row.get(3)?, + byte_size: row.get::<_, i64>(4)?.max(0) as u64, + node_count: row.get::<_, i64>(5)?.max(0) as usize, + edge_count: row.get::<_, i64>(6)?.max(0) as usize, + }) + }) + .map_err(storage_error("query structural graph files"))? + .collect::, _>>() + .map_err(storage_error("read structural graph files"))?; + Ok(files) +} + +fn prepare_source_insert( + connection: &Connection, +) -> Result, StructuralGraphError> { + connection + .prepare( + "INSERT INTO structural_graph_sources ( + snapshot_id, target_kind, target_id, ordinal, path, start_line, + start_column, end_line, end_column, excerpt + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + ) + .map_err(storage_error("prepare structural graph sources")) +} + +fn insert_sources( + statement: &mut rusqlite::Statement<'_>, + snapshot_id: &str, + target_kind: &str, + target_id: &str, + sources: &[GraphSourceAnchor], +) -> Result<(), StructuralGraphError> { + for (ordinal, source) in sources.iter().enumerate() { + statement + .execute(params![ + snapshot_id, + target_kind, + target_id, + ordinal as i64, + source.path, + source.start_line, + source.start_column, + source.end_line, + source.end_column, + source.excerpt, + ]) + .map_err(storage_error("write structural graph source"))?; + } + Ok(()) +} + +fn load_source_map( + connection: &Connection, + snapshot_id: &str, +) -> Result>, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT target_kind, target_id, path, start_line, start_column, + end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 + ORDER BY target_kind, target_id, ordinal", + ) + .map_err(storage_error("prepare structural graph sources"))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + GraphSourceAnchor { + path: row.get(2)?, + start_line: row.get(3)?, + start_column: row.get(4)?, + end_line: row.get(5)?, + end_column: row.get(6)?, + excerpt: row.get(7)?, + }, + )) + }) + .map_err(storage_error("query structural graph sources"))? + .collect::, _>>() + .map_err(storage_error("read structural graph sources"))?; + let mut sources = HashMap::new(); + for (target_kind, target_id, source) in rows { + sources + .entry((target_kind, target_id)) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + +fn load_nodes( + connection: &Connection, + snapshot_id: &str, + sources: &mut HashMap<(String, String), Vec>, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT id, kind, label, qualified_name, path, detail, language, + community_id, trust, origin + FROM structural_graph_nodes WHERE snapshot_id = ?1 + ORDER BY kind, label, id", + ) + .map_err(storage_error("prepare structural graph nodes"))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + )) + }) + .map_err(storage_error("query structural graph nodes"))? + .collect::, _>>() + .map_err(storage_error("read structural graph nodes"))?; + + rows.into_iter() + .map( + |( + id, + kind, + label, + qualified_name, + path, + detail, + language, + community_id, + trust, + origin, + )| { + let node_sources = sources + .remove(&("node".to_string(), id.clone())) + .unwrap_or_default(); + Ok(StructuralGraphNode { + id, + kind, + label, + qualified_name, + path, + detail, + language, + community_id, + trust: GraphTrust::from_storage(&trust), + origin: GraphOrigin::from_storage(&origin), + sources: node_sources, + }) + }, + ) + .collect() +} + +fn load_edges( + connection: &Connection, + snapshot_id: &str, + sources: &mut HashMap<(String, String), Vec>, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT id, from_id, to_id, kind, evidence, trust, origin, candidates_json + FROM structural_graph_edges WHERE snapshot_id = ?1 + ORDER BY kind, from_id, to_id, id", + ) + .map_err(storage_error("prepare structural graph edges"))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + )) + }) + .map_err(storage_error("query structural graph edges"))? + .collect::, _>>() + .map_err(storage_error("read structural graph edges"))?; + + rows.into_iter() + .map( + |(id, from, to, kind, evidence, trust, origin, candidates_json)| { + let edge_sources = sources + .remove(&("edge".to_string(), id.clone())) + .unwrap_or_default(); + Ok(StructuralGraphEdge { + id, + from, + to, + kind, + evidence, + trust: GraphTrust::from_storage(&trust), + origin: GraphOrigin::from_storage(&origin), + sources: edge_sources, + candidates: from_json(&candidates_json, "edge candidates")?, + }) + }, + ) + .collect() +} + +fn load_metrics( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT fact_json FROM structural_graph_metric_facts + WHERE snapshot_id = ?1 ORDER BY path, node_id, id", + ) + .map_err(storage_error("prepare structural graph metric facts"))?; + let facts = statement + .query_map(params![snapshot_id], |row| row.get::<_, String>(0)) + .map_err(storage_error("query structural graph metric facts"))? + .map(|row| { + let json = row.map_err(storage_error("read structural graph metric fact"))?; + from_json(&json, "structural graph metric fact") + }) + .collect(); + facts +} + +fn load_clone_groups( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT group_json FROM structural_graph_clone_groups + WHERE snapshot_id = ?1 ORDER BY id", + ) + .map_err(storage_error("prepare structural graph clone groups"))?; + let groups = statement + .query_map(params![snapshot_id], |row| row.get::<_, String>(0)) + .map_err(storage_error("query structural graph clone groups"))? + .map(|row| { + let json = row.map_err(storage_error("read structural graph clone group"))?; + from_json(&json, "structural graph clone group") + }) + .collect(); + groups +} + +fn load_communities( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT id, label, member_count, hub_node_ids_json, bridge_ids_json, score + FROM structural_graph_communities WHERE snapshot_id = ?1 ORDER BY id", + ) + .map_err(storage_error("prepare structural graph communities"))?; + let communities = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, f64>(5)?, + )) + }) + .map_err(storage_error("query structural graph communities"))? + .collect::, _>>() + .map_err(storage_error("read structural graph communities"))? + .into_iter() + .map(|(id, label, member_count, hubs, bridges, score)| { + Ok(StructuralGraphCommunity { + id, + label, + member_count: member_count.max(0) as usize, + hub_node_ids: from_json(&hubs, "community hubs")?, + bridge_node_ids: from_json(&bridges, "community bridges")?, + score, + }) + }) + .collect::, StructuralGraphError>>()?; + Ok(communities) +} + +fn load_diagnostics( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT severity, code, message, path, language + FROM structural_graph_diagnostics + WHERE snapshot_id = ?1 ORDER BY ordinal", + ) + .map_err(storage_error("prepare structural graph diagnostics"))?; + let diagnostics = statement + .query_map(params![snapshot_id], |row| { + Ok(StructuralGraphDiagnostic { + severity: row.get(0)?, + code: row.get(1)?, + message: row.get(2)?, + path: row.get(3)?, + language: row.get(4)?, + }) + }) + .map_err(storage_error("query structural graph diagnostics"))? + .collect::, _>>() + .map_err(storage_error("read structural graph diagnostics"))?; + Ok(diagnostics) +} + +fn to_json(value: &T) -> Result { + serde_json::to_string(value) + .map_err(|error| StructuralGraphError::Storage(format!("Serialize graph data: {error}"))) +} + +fn from_json( + value: &str, + label: &str, +) -> Result { + serde_json::from_str(value).map_err(|error| { + StructuralGraphError::Storage(format!("Decode structural graph {label}: {error}")) + }) +} + +fn storage_error(action: &'static str) -> impl FnOnce(rusqlite::Error) -> StructuralGraphError { + move |error| StructuralGraphError::Storage(format!("Failed to {action}: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::structural_graph::types::{ + stable_graph_id, LanguageCoverage, StructuralCloneGroup, StructuralCloneRegion, + StructuralCodeMetrics, StructuralGraphCommunity, StructuralGraphCoverage, + StructuralGraphDiagnostic, StructuralGraphEdge, StructuralGraphEngineInfo, + StructuralGraphMetricFact, StructuralGraphNode, STRUCTURAL_METRIC_SCHEMA_VERSION, + }; + + #[test] + fn snapshot_round_trips_through_normalized_storage() { + let connection = Connection::open_in_memory().expect("memory db"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + let source = GraphSourceAnchor { + path: "src/lib.rs".to_string(), + start_line: Some(3), + start_column: Some(1), + end_line: Some(5), + end_column: Some(2), + excerpt: Some("fn run()".to_string()), + }; + let snapshot = StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: "snapshot:test".to_string(), + repo_path: "/repo".to_string(), + repo_head: Some("abc".to_string()), + created_at: "2026-07-13T00:00:00Z".to_string(), + engine: StructuralGraphEngineInfo { + id: "test".to_string(), + version: "1".to_string(), + bundled: true, + syntax_aware: true, + supported_languages: vec!["rust".to_string()], + }, + cursor: Some("cursor".to_string()), + ignore_fingerprint: Some("ignore".to_string()), + coverage: StructuralGraphCoverage { + discovered_files: 1, + indexed_files: 1, + languages: vec![LanguageCoverage { + language: "rust".to_string(), + supported: true, + discovered_files: 1, + indexed_files: 1, + skipped_files: 0, + error_files: 0, + }], + ..StructuralGraphCoverage::default() + }, + diagnostics: vec![StructuralGraphDiagnostic { + severity: "info".to_string(), + code: "fixture".to_string(), + message: "fixture diagnostic".to_string(), + path: None, + language: Some("rust".to_string()), + }], + communities: vec![StructuralGraphCommunity { + id: "community:src".to_string(), + label: "src".to_string(), + member_count: 1, + hub_node_ids: vec!["function:run".to_string()], + bridge_node_ids: Vec::new(), + score: 1.0, + }], + files: vec![StructuralGraphFileRecord { + path: "src/lib.rs".to_string(), + language: Some("rust".to_string()), + content_hash: Some("content:1".to_string()), + disposition: "indexed".to_string(), + byte_size: 8, + node_count: 1, + edge_count: 1, + }], + nodes: vec![StructuralGraphNode { + id: "function:run".to_string(), + kind: "function".to_string(), + label: "run".to_string(), + qualified_name: Some("src/lib.rs::run".to_string()), + path: Some("src/lib.rs".to_string()), + detail: None, + language: Some("rust".to_string()), + community_id: Some("community:src".to_string()), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![source.clone()], + }], + edges: vec![StructuralGraphEdge { + id: stable_graph_id("edge", "defines"), + from: "file:lib".to_string(), + to: "function:run".to_string(), + kind: "defines".to_string(), + evidence: "function declaration".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![source.clone()], + candidates: Vec::new(), + }], + metrics: vec![StructuralGraphMetricFact { + schema_version: STRUCTURAL_METRIC_SCHEMA_VERSION, + id: stable_graph_id("metric", "function:run"), + node_id: "function:run".to_string(), + path: "src/lib.rs".to_string(), + scope_kind: "function".to_string(), + language: "rust".to_string(), + public_surface: true, + public_surface_reason: Some("explicit public visibility".to_string()), + syntax_fingerprint: "syntax:test".to_string(), + normalized_token_count: 8, + normalization_method: "tree-sitter-leaf-kinds-v1".to_string(), + metrics: StructuralCodeMetrics { + line_count: 3, + cyclomatic_complexity: 1, + ..StructuralCodeMetrics::default() + }, + control_flow: Vec::new(), + definitions: Vec::new(), + uses: Vec::new(), + boundaries: Vec::new(), + sources: vec![source.clone()], + limitations: Vec::new(), + }], + clone_groups: vec![StructuralCloneGroup { + id: "clone:test".to_string(), + syntax_fingerprint: "syntax:test".to_string(), + normalization_method: "tree-sitter-leaf-kinds-v1".to_string(), + normalized_token_count: 30, + similarity: 1.0, + regions: vec![ + StructuralCloneRegion { + metric_id: stable_graph_id("metric", "function:run"), + node_id: "function:run".to_string(), + path: "src/lib.rs".to_string(), + source: source.clone(), + }, + StructuralCloneRegion { + metric_id: "metric:other".to_string(), + node_id: "function:other".to_string(), + path: "src/other.rs".to_string(), + source, + }, + ], + exclusions: vec!["comments".to_string()], + }], + truncated: false, + }; + + persist_snapshot(&connection, &snapshot).expect("persist snapshot"); + let cursor_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM structural_graph_file_cursors WHERE repo_path = ?1", + params![snapshot.repo_path], + |row| row.get(0), + ) + .expect("file cursor count"); + assert_eq!(cursor_count, 1); + let loaded = load_latest_snapshot(&connection, "/repo") + .expect("load snapshot") + .expect("snapshot exists"); + assert_eq!(loaded, snapshot); + let summary = load_latest_snapshot_summary(&connection, "/repo") + .expect("load summary") + .expect("summary exists"); + assert_eq!(summary.node_count, 1); + assert_eq!(summary.edge_count, 1); + assert_eq!(summary.coverage.indexed_files, 1); + connection + .execute( + "UPDATE structural_graph_snapshots SET engine_json = '{' WHERE id = ?1", + params![snapshot.id], + ) + .expect("corrupt fixture snapshot"); + assert!(load_latest_snapshot(&connection, "/repo") + .unwrap_err() + .to_string() + .contains("Decode structural graph engine")); + } + + #[test] + fn present_state_retention_keeps_latest_snapshots_and_skips_history_storage() { + let connection = Connection::open_in_memory().expect("memory db"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + for (repo_path, count) in [("/repo", 4), ("history:/repo:abc", 4)] { + for ordinal in 0..count { + connection + .execute( + "INSERT INTO structural_graph_snapshots ( + id, repo_path, schema_version, engine_id, engine_version, + engine_json, coverage_json, status, created_at + ) VALUES (?1, ?2, ?3, 'test', '1', '{}', '{}', 'ready', ?4)", + params![ + format!("{repo_path}:{ordinal}"), + repo_path, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + format!("2026-07-13T00:00:0{ordinal}Z"), + ], + ) + .expect("insert fixture snapshot"); + } + } + + assert_eq!( + prune_present_state_snapshots(&connection, "/repo", 2).expect("prune snapshots"), + 2 + ); + assert_eq!( + prune_present_state_snapshots(&connection, "history:/repo:abc", 2) + .expect("skip history snapshots"), + 0 + ); + let present_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM structural_graph_snapshots WHERE repo_path = '/repo'", + [], + |row| row.get(0), + ) + .expect("present count"); + let history_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM structural_graph_snapshots WHERE repo_path LIKE 'history:%'", + [], + |row| row.get(0), + ) + .expect("history count"); + assert_eq!(present_count, 2); + assert_eq!(history_count, 4); + } +} diff --git a/apps/desktop/src-tauri/src/db/mod.rs b/apps/desktop/src-tauri/src/db/mod.rs index d0ffd47..1024876 100644 --- a/apps/desktop/src-tauri/src/db/mod.rs +++ b/apps/desktop/src-tauri/src/db/mod.rs @@ -1,5 +1,6 @@ pub mod queries; pub mod schema; +pub(crate) mod structural_graph_schema; use rusqlite::Connection; use std::path::PathBuf; diff --git a/apps/desktop/src-tauri/src/db/schema.rs b/apps/desktop/src-tauri/src/db/schema.rs index 582d5f6..20c7a86 100644 --- a/apps/desktop/src-tauri/src/db/schema.rs +++ b/apps/desktop/src-tauri/src/db/schema.rs @@ -4,6 +4,7 @@ use rusqlite::Connection; /// (`IF NOT EXISTS`) so this function is safe to call on every startup. pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch(MIGRATION_SQL)?; + super::structural_graph_schema::run_migration(conn)?; // Incremental migrations — safe to re-run (ignore "duplicate column" errors). let _ = conn.execute("ALTER TABLE agent_tasks ADD COLUMN project_path TEXT", []); diff --git a/apps/desktop/src-tauri/src/db/schema/structural_graph.sql b/apps/desktop/src-tauri/src/db/schema/structural_graph.sql new file mode 100644 index 0000000..2f40275 --- /dev/null +++ b/apps/desktop/src-tauri/src/db/schema/structural_graph.sql @@ -0,0 +1,164 @@ +-- ================================================================ +-- Canonical Structural Repository Graph (schema v3) +-- ================================================================ + +CREATE TABLE IF NOT EXISTS structural_graph_snapshots ( + id TEXT PRIMARY KEY, + repo_path TEXT NOT NULL, + repo_head TEXT, + schema_version INTEGER NOT NULL, + engine_id TEXT NOT NULL, + engine_version TEXT NOT NULL, + engine_json TEXT NOT NULL, + cursor TEXT, + ignore_fingerprint TEXT, + coverage_json TEXT NOT NULL, + truncated INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'ready', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_snapshots_repo_created + ON structural_graph_snapshots(repo_path, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_structural_graph_snapshots_repo_head + ON structural_graph_snapshots(repo_path, repo_head); + +CREATE TABLE IF NOT EXISTS structural_graph_snapshot_files ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + path TEXT NOT NULL, + language TEXT, + content_hash TEXT, + disposition TEXT NOT NULL, + byte_size INTEGER NOT NULL DEFAULT 0, + node_count INTEGER NOT NULL DEFAULT 0, + edge_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (snapshot_id, path) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_snapshot_files_disposition + ON structural_graph_snapshot_files(snapshot_id, disposition, language); + +CREATE TABLE IF NOT EXISTS structural_graph_nodes ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + id TEXT NOT NULL, + kind TEXT NOT NULL, + label TEXT NOT NULL, + qualified_name TEXT, + path TEXT, + detail TEXT, + language TEXT, + community_id TEXT, + trust TEXT NOT NULL, + origin TEXT NOT NULL, + PRIMARY KEY (snapshot_id, id) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_nodes_path + ON structural_graph_nodes(snapshot_id, path); +CREATE INDEX IF NOT EXISTS idx_structural_graph_nodes_qualified + ON structural_graph_nodes(snapshot_id, qualified_name); +CREATE INDEX IF NOT EXISTS idx_structural_graph_nodes_kind + ON structural_graph_nodes(snapshot_id, kind, label); +CREATE INDEX IF NOT EXISTS idx_structural_graph_nodes_community + ON structural_graph_nodes(snapshot_id, community_id); + +CREATE TABLE IF NOT EXISTS structural_graph_edges ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + id TEXT NOT NULL, + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + evidence TEXT NOT NULL, + trust TEXT NOT NULL, + origin TEXT NOT NULL, + candidates_json TEXT NOT NULL DEFAULT '[]', + PRIMARY KEY (snapshot_id, id) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_edges_from + ON structural_graph_edges(snapshot_id, from_id, kind); +CREATE INDEX IF NOT EXISTS idx_structural_graph_edges_to + ON structural_graph_edges(snapshot_id, to_id, kind); +CREATE INDEX IF NOT EXISTS idx_structural_graph_edges_kind + ON structural_graph_edges(snapshot_id, kind); + +CREATE TABLE IF NOT EXISTS structural_graph_sources ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + path TEXT NOT NULL, + start_line INTEGER, + start_column INTEGER, + end_line INTEGER, + end_column INTEGER, + excerpt TEXT, + PRIMARY KEY (snapshot_id, target_kind, target_id, ordinal) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_sources_path + ON structural_graph_sources(snapshot_id, path, start_line); + +CREATE TABLE IF NOT EXISTS structural_graph_metric_facts ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + id TEXT NOT NULL, + node_id TEXT NOT NULL, + path TEXT NOT NULL, + scope_kind TEXT NOT NULL, + language TEXT NOT NULL, + public_surface INTEGER NOT NULL DEFAULT 0, + fact_json TEXT NOT NULL, + PRIMARY KEY (snapshot_id, id) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_metric_facts_node + ON structural_graph_metric_facts(snapshot_id, node_id); +CREATE INDEX IF NOT EXISTS idx_structural_graph_metric_facts_path + ON structural_graph_metric_facts(snapshot_id, path, scope_kind); + +CREATE TABLE IF NOT EXISTS structural_graph_clone_groups ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + id TEXT NOT NULL, + syntax_fingerprint TEXT NOT NULL, + normalized_tokens INTEGER NOT NULL, + group_json TEXT NOT NULL, + PRIMARY KEY (snapshot_id, id) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_clone_groups_fingerprint + ON structural_graph_clone_groups(snapshot_id, syntax_fingerprint); + +CREATE TABLE IF NOT EXISTS structural_graph_communities ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + id TEXT NOT NULL, + label TEXT NOT NULL, + member_count INTEGER NOT NULL, + hub_node_ids_json TEXT NOT NULL DEFAULT '[]', + bridge_ids_json TEXT NOT NULL DEFAULT '[]', + score REAL NOT NULL DEFAULT 0, + PRIMARY KEY (snapshot_id, id) +); + +CREATE TABLE IF NOT EXISTS structural_graph_diagnostics ( + snapshot_id TEXT NOT NULL REFERENCES structural_graph_snapshots(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + severity TEXT NOT NULL, + code TEXT NOT NULL, + message TEXT NOT NULL, + path TEXT, + language TEXT, + PRIMARY KEY (snapshot_id, ordinal) +); + +CREATE TABLE IF NOT EXISTS structural_graph_file_cursors ( + repo_path TEXT NOT NULL, + path TEXT NOT NULL, + content_hash TEXT NOT NULL, + language TEXT, + engine_version TEXT NOT NULL, + indexed_at TEXT NOT NULL, + PRIMARY KEY (repo_path, path) +); + +CREATE INDEX IF NOT EXISTS idx_structural_graph_file_cursors_repo + ON structural_graph_file_cursors(repo_path, indexed_at); diff --git a/apps/desktop/src-tauri/src/db/structural_graph_schema.rs b/apps/desktop/src-tauri/src/db/structural_graph_schema.rs new file mode 100644 index 0000000..eeb8ab1 --- /dev/null +++ b/apps/desktop/src-tauri/src/db/structural_graph_schema.rs @@ -0,0 +1,63 @@ +use rusqlite::Connection; + +const MIGRATION_SQL: &str = include_str!("schema/structural_graph.sql"); + +pub fn run_migration(conn: &Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch(MIGRATION_SQL) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn canonical_structural_graph_schema_is_indexed_and_idempotent() { + let conn = Connection::open_in_memory().expect("database"); + conn.execute_batch("PRAGMA foreign_keys = ON;") + .expect("foreign keys"); + + run_migration(&conn).expect("first migration"); + run_migration(&conn).expect("idempotent migration"); + + let tables = schema_objects(&conn, "table", "structural_graph_%"); + assert_eq!( + tables, + BTreeSet::from([ + "structural_graph_clone_groups".to_string(), + "structural_graph_communities".to_string(), + "structural_graph_diagnostics".to_string(), + "structural_graph_edges".to_string(), + "structural_graph_file_cursors".to_string(), + "structural_graph_metric_facts".to_string(), + "structural_graph_nodes".to_string(), + "structural_graph_snapshot_files".to_string(), + "structural_graph_snapshots".to_string(), + "structural_graph_sources".to_string(), + ]) + ); + + let indexes = schema_objects(&conn, "index", "idx_structural_graph_%"); + for required in [ + "idx_structural_graph_edges_from", + "idx_structural_graph_edges_to", + "idx_structural_graph_nodes_path", + "idx_structural_graph_snapshot_files_disposition", + "idx_structural_graph_snapshots_repo_created", + "idx_structural_graph_sources_path", + ] { + assert!(indexes.contains(required), "missing {required}"); + } + } + + fn schema_objects(conn: &Connection, kind: &str, pattern: &str) -> BTreeSet { + let mut statement = conn + .prepare("SELECT name FROM sqlite_master WHERE type = ?1 AND name LIKE ?2") + .expect("prepare schema lookup"); + statement + .query_map([kind, pattern], |row| row.get(0)) + .expect("query schema") + .collect::>() + .expect("schema objects") + } +} From 3a47f5cb4c94d2da08cfcc87d76b2471f36125fa Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:00:47 +0530 Subject: [PATCH 019/141] feat(core): centralize secret redaction policy --- apps/desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/secret_policy.rs | 169 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/secret_policy.rs diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index e560070..693c716 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod resources; pub mod review; pub mod saas_maker; pub mod sandbox; +pub(crate) mod secret_policy; pub mod session_adapters; pub mod sessions; pub mod setup; diff --git a/apps/desktop/src-tauri/src/commands/secret_policy.rs b/apps/desktop/src-tauri/src/commands/secret_policy.rs new file mode 100644 index 0000000..f2d65fb --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/secret_policy.rs @@ -0,0 +1,169 @@ +const REDACTED: &str = "[redacted]"; + +pub(crate) fn is_sensitive_path(path: &str) -> bool { + let lower = path.replace('\\', "/").to_ascii_lowercase(); + let wrapped = format!("/{}/", lower.trim_matches('/')); + let name = lower.rsplit('/').next().unwrap_or(&lower); + matches!( + name, + ".env" + | ".npmrc" + | ".pypirc" + | ".netrc" + | ".dockercfg" + | "id_rsa" + | "id_ed25519" + | "credentials" + | "credentials.json" + | "secrets.yml" + | "secrets.yaml" + | "secrets.json" + ) || name.starts_with(".env.") + || name.ends_with(".pem") + || name.ends_with(".key") + || name.ends_with(".p12") + || name.ends_with(".pfx") + || name.ends_with(".jks") + || name.ends_with(".keystore") + || name == "terraform.tfstate" + || name.starts_with("terraform.tfstate.") + || name.contains("service-account") + || name.contains("service_account") + || ["/.ssh/", "/.aws/", "/.kube/", "/secrets/"] + .iter() + .any(|segment| wrapped.contains(segment)) +} + +pub(crate) fn contains_sensitive_path(value: &str) -> bool { + is_sensitive_path(value) + || value + .split(|character: char| { + character.is_whitespace() + || matches!(character, '`' | '\'' | '"' | ',' | ';' | '(' | ')') + }) + .map(|token| token.trim_matches([':', '[', ']'])) + .filter(|token| !token.is_empty()) + .any(is_sensitive_path) +} + +pub(crate) fn looks_like_secret(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + if [ + "-----begin private key-----", + "-----begin rsa private key-----", + "sk-ant-", + "sk-proj-", + "github_pat_", + "ghp_", + "xoxb-", + "xoxp-", + "xapp-", + "AIza", + "postgres://", + "postgresql://", + "mongodb://", + "mongodb+srv://", + "mysql://", + "redis://", + ] + .iter() + .any(|marker| lower.contains(&marker.to_ascii_lowercase())) + { + return true; + } + if lower.contains("bearer ") || contains_basic_auth_url(value) { + return true; + } + value + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(is_cloud_access_key) + || contains_credential_assignment(value) +} + +pub(crate) fn redact_secret_text(value: &str) -> (String, bool) { + if looks_like_secret(value) || contains_sensitive_path(value) { + (REDACTED.to_string(), true) + } else { + (value.to_string(), false) + } +} + +fn is_cloud_access_key(token: &str) -> bool { + token.len() == 20 + && (token.starts_with("AKIA") || token.starts_with("ASIA")) + && token + .chars() + .all(|character| character.is_ascii_uppercase() || character.is_ascii_digit()) +} + +fn contains_basic_auth_url(value: &str) -> bool { + value.split_whitespace().any(|token| { + token + .find("://") + .and_then(|scheme| token[scheme + 3..].split('@').next()) + .is_some_and(|authority| authority.contains(':') && token.contains('@')) + }) +} + +fn contains_credential_assignment(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + [ + "password", + "passwd", + "api_key", + "apikey", + "access_token", + "client_secret", + ] + .iter() + .any(|key| { + [format!("{key}="), format!("{key}:"), format!("{key} =")] + .iter() + .filter_map(|needle| lower.find(needle).map(|index| index + needle.len())) + .any(|start| { + lower[start..] + .trim_start_matches([' ', '\'', '"']) + .split(|character: char| { + character.is_whitespace() || matches!(character, '\'' | '"' | ',') + }) + .next() + .is_some_and(|candidate| candidate.len() >= 8 && candidate != "[redacted]") + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sensitive_paths_cover_root_directories_and_credential_files() { + for path in [ + "secrets/token.txt", + ".ssh/config", + ".aws/credentials", + ".kube/config", + ".npmrc", + "infra/terraform.tfstate.backup", + "certs/service.key", + "config/service-account.json", + ] { + assert!(is_sensitive_path(path), "expected sensitive path: {path}"); + } + assert!(!is_sensitive_path("src/key.ts")); + } + + #[test] + fn common_credential_shapes_are_redacted() { + for value in [ + "Authorization: Bearer a-long-runtime-token", + "AWS_ACCESS_KEY_ID=AKIA1234567890ABCDEF", + "password=correct-horse-battery-staple", + "postgres://user:password@localhost/db", + "xoxb-123456789-secret", + ] { + assert!(looks_like_secret(value), "expected secret: {value}"); + assert_eq!(redact_secret_text(value).0, REDACTED); + } + } +} From a15a70791b4401b50f56ecd86da9021083092f43 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:01:54 +0530 Subject: [PATCH 020/141] feat(graph): assemble extracted file contributions --- .../structural_graph/extract/assembly.rs | 294 ++++++++++++++++++ .../commands/structural_graph/extract/mod.rs | 50 +++ .../src/commands/structural_graph/mod.rs | 1 + 3 files changed, 345 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/assembly.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/assembly.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/assembly.rs new file mode 100644 index 0000000..a75903e --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/assembly.rs @@ -0,0 +1,294 @@ +use super::*; + +pub(super) fn metadata_file_contribution( + path: String, + language: Option, + disposition: FileDisposition, +) -> FileContribution { + let language_name = language.map(|language| language.name().to_string()); + let (diagnostic_code, diagnostic_message) = match disposition { + FileDisposition::Unsupported => ( + "unsupported_language", + "File is retained as metadata because no syntax grammar is bundled", + ), + FileDisposition::Generated => ( + "generated_file_skipped", + "Generated file is retained as metadata and excluded from syntax extraction", + ), + FileDisposition::TooLarge => ( + "file_too_large", + "File exceeds the configured syntax extraction byte limit", + ), + _ => ("metadata_only", "File is indexed as metadata only"), + }; + FileContribution { + path: path.clone(), + language: language_name.clone(), + content_hash: None, + byte_size: 0, + nodes: vec![StructuralGraphNode { + id: stable_graph_id("file", &path), + kind: "file".to_string(), + label: path.clone(), + qualified_name: Some(path.clone()), + path: Some(path.clone()), + detail: Some( + match disposition { + FileDisposition::Unsupported => "metadata-only unsupported file", + FileDisposition::Generated => "metadata-only generated file", + FileDisposition::TooLarge => "metadata-only oversized source file", + _ => "metadata-only file", + } + .to_string(), + ), + language: language_name.clone(), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Metadata, + sources: vec![GraphSourceAnchor::path(path.clone())], + }], + edges: Vec::new(), + metrics: Vec::new(), + diagnostics: vec![StructuralGraphDiagnostic { + severity: "info".to_string(), + code: diagnostic_code.to_string(), + message: diagnostic_message.to_string(), + path: Some(path), + language: language_name, + }], + disposition, + } +} + +pub(super) fn skipped_contribution( + path: String, + language: Option, + disposition: FileDisposition, +) -> FileContribution { + let (code, message) = match disposition { + FileDisposition::Sensitive => ( + "sensitive_file_skipped", + "Sensitive file content and original path were excluded from the graph", + ), + FileDisposition::Binary => ( + "binary_file_skipped", + "Binary file content was excluded from the graph", + ), + _ => ( + "file_skipped", + "File was excluded from structural extraction", + ), + }; + FileContribution { + path: path.clone(), + language: language.map(|language| language.name().to_string()), + content_hash: None, + byte_size: 0, + nodes: Vec::new(), + edges: Vec::new(), + metrics: Vec::new(), + diagnostics: vec![StructuralGraphDiagnostic { + severity: "info".to_string(), + code: code.to_string(), + message: message.to_string(), + path: Some(path), + language: language.map(|language| language.name().to_string()), + }], + disposition, + } +} + +pub(super) fn parse_error_contribution( + path: &str, + language: SupportedLanguage, + message: String, +) -> FileContribution { + FileContribution { + path: path.to_string(), + language: Some(language.name().to_string()), + content_hash: None, + byte_size: 0, + nodes: Vec::new(), + edges: Vec::new(), + metrics: Vec::new(), + diagnostics: vec![StructuralGraphDiagnostic { + severity: "error".to_string(), + code: "parser_failed".to_string(), + message, + path: Some(path.to_string()), + language: Some(language.name().to_string()), + }], + disposition: FileDisposition::Error, + } +} + +pub(super) fn file_record_from_contribution( + contribution: &FileContribution, +) -> StructuralGraphFileRecord { + StructuralGraphFileRecord { + path: contribution.path.clone(), + language: contribution.language.clone(), + content_hash: contribution.content_hash.clone(), + disposition: contribution.disposition.as_str().to_string(), + byte_size: contribution.byte_size, + node_count: contribution.nodes.len(), + edge_count: contribution.edges.len(), + } +} + +pub(super) fn node_belongs_to_paths(node: &StructuralGraphNode, paths: &HashSet) -> bool { + node.path.as_ref().is_some_and(|path| paths.contains(path)) + || sources_touch_paths(&node.sources, paths) +} + +pub(super) fn sources_touch_paths(sources: &[GraphSourceAnchor], paths: &HashSet) -> bool { + sources.iter().any(|source| paths.contains(&source.path)) +} + +pub(super) fn coverage_from_file_records( + files: &[StructuralGraphFileRecord], +) -> StructuralGraphCoverage { + let mut coverage = StructuralGraphCoverage { + discovered_files: files.len(), + ..StructuralGraphCoverage::default() + }; + let mut languages: BTreeMap = BTreeMap::new(); + for file in files { + let language = file + .language + .clone() + .unwrap_or_else(|| "unsupported".to_string()); + let entry = languages + .entry(language.clone()) + .or_insert(LanguageCoverage { + language, + supported: file.language.is_some(), + discovered_files: 0, + indexed_files: 0, + skipped_files: 0, + error_files: 0, + }); + entry.discovered_files += 1; + match file.disposition.as_str() { + "indexed" => { + coverage.indexed_files += 1; + entry.indexed_files += 1; + } + "error" => { + coverage.error_files += 1; + entry.error_files += 1; + } + "generated" => { + coverage.generated_files += 1; + coverage.skipped_files += 1; + entry.skipped_files += 1; + } + "sensitive" => { + coverage.sensitive_files += 1; + coverage.skipped_files += 1; + entry.skipped_files += 1; + } + "binary" => { + coverage.binary_files += 1; + coverage.skipped_files += 1; + entry.skipped_files += 1; + } + _ => { + coverage.skipped_files += 1; + entry.skipped_files += 1; + } + } + } + coverage.languages = languages.into_values().collect(); + coverage +} + +pub(super) fn deduplicate_nodes(nodes: &mut Vec) { + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + nodes.dedup_by(|left, right| left.id == right.id); + nodes.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.label.cmp(&right.label)) + .then_with(|| left.id.cmp(&right.id)) + }); +} + +pub(super) fn deduplicate_edges(edges: &mut Vec) { + edges.sort_by(|left, right| left.id.cmp(&right.id)); + edges.dedup_by(|left, right| left.id == right.id); + edges.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.from.cmp(&right.from)) + .then_with(|| left.to.cmp(&right.to)) + }); +} + +pub(super) fn deduplicate_metrics(metrics: &mut Vec) { + metrics.sort_by(|left, right| left.id.cmp(&right.id)); + metrics.dedup_by(|left, right| left.id == right.id); + metrics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.node_id.cmp(&right.node_id)) + }); +} + +pub(crate) fn is_sensitive_path(path: &str) -> bool { + crate::commands::secret_policy::is_sensitive_path(path) +} + +pub(super) fn is_generated_path(path: &str) -> bool { + let lower = format!("/{}/", path.to_ascii_lowercase().trim_matches('/')); + [ + "/node_modules/", + "/target/", + "/dist/", + "/build/", + "/out/", + "/coverage/", + "/vendor/", + "/.next/", + "/.turbo/", + ] + .iter() + .any(|segment| lower.contains(segment)) + || path.ends_with(".min.js") + || path.ends_with(".generated.ts") + || path.ends_with(".g.cs") +} + +pub(super) fn is_binary_path(path: &str) -> bool { + let extension = Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + matches!( + extension.as_str(), + "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "ico" + | "pdf" + | "zip" + | "gz" + | "tar" + | "7z" + | "woff" + | "woff2" + | "ttf" + | "otf" + | "mp3" + | "mp4" + | "mov" + | "wasm" + | "dylib" + | "so" + | "dll" + | "exe" + ) +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs new file mode 100644 index 0000000..8e2b189 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs @@ -0,0 +1,50 @@ +use super::language::SupportedLanguage; +use super::types::{ + stable_graph_id, GraphOrigin, GraphSourceAnchor, GraphTrust, LanguageCoverage, + StructuralGraphCoverage, StructuralGraphDiagnostic, StructuralGraphEdge, + StructuralGraphFileRecord, StructuralGraphMetricFact, StructuralGraphNode, +}; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; + +#[derive(Debug)] +struct FileContribution { + path: String, + language: Option, + content_hash: Option, + byte_size: u64, + nodes: Vec, + edges: Vec, + metrics: Vec, + diagnostics: Vec, + disposition: FileDisposition, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileDisposition { + Indexed, + Unsupported, + Generated, + Sensitive, + Binary, + TooLarge, + Error, +} + +impl FileDisposition { + fn as_str(self) -> &'static str { + match self { + Self::Indexed => "indexed", + Self::Unsupported => "unsupported", + Self::Generated => "generated", + Self::Sensitive => "sensitive", + Self::Binary => "binary", + Self::TooLarge => "too_large", + Self::Error => "error", + } + } +} + +mod assembly; + +pub(crate) use assembly::is_sensitive_path; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index 191b654..65cd20f 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,5 +1,6 @@ pub mod analysis; mod contracts; +pub mod extract; pub mod interchange; pub mod language; pub mod legacy; From c1b072adb59b3c1bcf26a32a646f1e79134375ab Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:02:23 +0530 Subject: [PATCH 021/141] feat(graph): extract syntax and metadata signals --- .../structural_graph/extract/metadata.rs | 537 ++++++++++++++++ .../commands/structural_graph/extract/mod.rs | 11 +- .../structural_graph/extract/syntax.rs | 606 ++++++++++++++++++ 3 files changed, 1153 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/metadata.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/syntax.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/metadata.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/metadata.rs new file mode 100644 index 0000000..c0eaef9 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/metadata.rs @@ -0,0 +1,537 @@ +use super::*; + +pub(super) fn extract_metadata_signals( + path: &str, + source: &str, + file_id: &str, + language: Option<&str>, + nodes: &mut Vec, + edges: &mut Vec, +) { + let lower_path = path.to_ascii_lowercase(); + let file_name = lower_path.rsplit('/').next().unwrap_or(&lower_path); + if is_config_name(file_name) { + push_metadata_signal( + path, + source, + file_id, + language, + 1, + "configuration", + file_name, + "configures", + "repository configuration file", + nodes, + edges, + ); + } + + let lines = source.lines().collect::>(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + let lower = trimmed.to_ascii_lowercase(); + let line_number = index + 1; + + if lower.contains("create table") { + if let Some(label) = sql_object_name(trimmed, "table") { + push_metadata_signal( + path, + source, + file_id, + language, + line_number, + "db_table", + &label, + "declares", + "SQL table declaration", + nodes, + edges, + ); + } + } + if lower.contains("create index") { + if let Some(label) = sql_object_name(trimmed, "index") { + push_metadata_signal( + path, + source, + file_id, + language, + line_number, + "db_index", + &label, + "declares", + "SQL index declaration", + nodes, + edges, + ); + } + } + + if lower.contains("#[tauri::command]") { + if let Some(label) = lines + .iter() + .skip(index + 1) + .take(4) + .find_map(|next| rust_function_name(next)) + { + push_metadata_signal( + path, + source, + file_id, + language, + line_number, + "tauri_command", + &label, + "exposes", + "Tauri command boundary", + nodes, + edges, + ); + } + } + + for marker in [", + nodes: &mut Vec, + edges: &mut Vec, +) { + let extraction = extract_contracts(path, source); + let mut node_id_by_key = HashMap::new(); + for fact in extraction.facts { + let id = stable_graph_id(&fact.kind, &format!("{path}\0{}", fact.label)); + let anchor = GraphSourceAnchor { + path: path.to_string(), + start_line: Some(fact.line as u32), + start_column: Some(1), + end_line: Some(fact.line as u32), + end_column: None, + excerpt: source + .lines() + .nth(fact.line.saturating_sub(1)) + .map(|line| line.trim().chars().take(240).collect()), + }; + if let Some(existing) = nodes.iter_mut().find(|node| node.id == id) { + if !existing.sources.contains(&anchor) { + existing.sources.push(anchor.clone()); + } + } else { + nodes.push(StructuralGraphNode { + id: id.clone(), + kind: fact.kind.clone(), + label: fact.label.clone(), + qualified_name: Some(format!("{path}::{}", fact.label)), + path: Some(path.to_string()), + detail: Some(fact.detail.clone()), + language: language.map(str::to_string), + community_id: None, + trust: fact.trust, + origin: GraphOrigin::Metadata, + sources: vec![anchor.clone()], + }); + } + edges.push(make_edge( + file_id, + &id, + &fact.edge_kind, + fact.trust, + GraphOrigin::Metadata, + fact.detail, + vec![anchor], + Vec::new(), + )); + node_id_by_key.insert(fact.key, id); + } + for link in extraction.links { + let (Some(from), Some(to)) = ( + node_id_by_key.get(&link.from_key), + node_id_by_key.get(&link.to_key), + ) else { + continue; + }; + let sources = nodes + .iter() + .find(|node| node.id == *to) + .map(|node| node.sources.clone()) + .unwrap_or_default(); + edges.push(make_edge( + from, + to, + &link.edge_kind, + link.trust, + GraphOrigin::Metadata, + link.detail, + sources, + Vec::new(), + )); + } +} + +pub(super) fn attach_metadata_to_syntax_owners( + nodes: &[StructuralGraphNode], + edges: &mut Vec, +) { + let syntax_nodes = nodes + .iter() + .filter(|node| node.origin == GraphOrigin::Syntax && node.kind != "file") + .collect::>(); + let metadata_nodes = nodes + .iter() + .filter(|node| node.origin == GraphOrigin::Metadata && node.kind != "configuration") + .collect::>(); + for metadata in metadata_nodes { + if metadata.kind == "tauri_command" { + if let Some(implementation) = syntax_nodes.iter().find(|candidate| { + candidate.label == metadata.label && candidate.path == metadata.path + }) { + edges.push(make_edge( + &metadata.id, + &implementation.id, + "implemented_by", + GraphTrust::Extracted, + GraphOrigin::Metadata, + "command annotation and declaration share an exact source-backed name" + .to_string(), + metadata.sources.clone(), + Vec::new(), + )); + } + } + let Some(source) = metadata.sources.first() else { + continue; + }; + let Some(line) = source.start_line else { + continue; + }; + let owner = syntax_nodes + .iter() + .filter(|candidate| candidate.path == metadata.path) + .filter_map(|candidate| { + let anchor = candidate.sources.first()?; + let start = anchor.start_line?; + let end = anchor.end_line.unwrap_or(start); + (start <= line && line <= end).then_some((*candidate, end - start)) + }) + .min_by_key(|(_, span)| *span) + .map(|(candidate, _)| candidate); + if let Some(owner) = owner { + let file_id = metadata + .path + .as_deref() + .map(|path| stable_graph_id("file", path)); + let source_relation = file_id.as_deref().and_then(|file_id| { + edges + .iter() + .find(|edge| edge.from == file_id && edge.to == metadata.id) + .map(|edge| edge.kind.clone()) + }); + let kind = match metadata.kind.as_str() { + "analytics_event" => "emits", + "db_table" | "db_view" | "db_index" => "persists_to", + "db_object_reference" => source_relation.as_deref().unwrap_or("references_data"), + "dbt_model_reference" => "depends_on", + "job_reference" => "schedules", + "event_reference" => "emits", + "event_subscription" => "subscribes", + "configuration_reference" => "reads_config", + "route" => "routes_to", + "test" => "tests", + _ => "contains", + }; + edges.push(make_edge( + &owner.id, + &metadata.id, + kind, + metadata.trust, + GraphOrigin::Metadata, + "metadata signal is lexically contained by this declaration".to_string(), + metadata.sources.clone(), + Vec::new(), + )); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn push_metadata_signal( + path: &str, + source: &str, + file_id: &str, + language: Option<&str>, + line_number: usize, + kind: &str, + label: &str, + edge_kind: &str, + evidence: &str, + nodes: &mut Vec, + edges: &mut Vec, +) { + let label = label.trim().trim_matches(['`', '"', '\'', ';']); + if label.is_empty() || label.len() > 240 { + return; + } + let id = stable_graph_id(kind, &format!("{path}\0{label}")); + if nodes.iter().any(|node| node.id == id) { + return; + } + let excerpt = source + .lines() + .nth(line_number.saturating_sub(1)) + .map(|line| { + let line = line.trim(); + line.chars().take(240).collect::() + }); + let anchor = GraphSourceAnchor { + path: path.to_string(), + start_line: Some(line_number as u32), + start_column: Some(1), + end_line: Some(line_number as u32), + end_column: None, + excerpt, + }; + nodes.push(StructuralGraphNode { + id: id.clone(), + kind: kind.to_string(), + label: label.to_string(), + qualified_name: Some(format!("{path}::{label}")), + path: Some(path.to_string()), + detail: Some(evidence.to_string()), + language: language.map(str::to_string), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Metadata, + sources: vec![anchor.clone()], + }); + edges.push(make_edge( + file_id, + &id, + edge_kind, + GraphTrust::Extracted, + GraphOrigin::Metadata, + evidence.to_string(), + vec![anchor], + Vec::new(), + )); +} + +fn is_config_name(name: &str) -> bool { + name.ends_with(".config.js") + || name.ends_with(".config.ts") + || matches!( + name, + "package.json" + | "cargo.toml" + | "pyproject.toml" + | "go.mod" + | "dockerfile" + | "docker-compose.yml" + | "docker-compose.yaml" + | "wrangler.toml" + | "wrangler.jsonc" + | "tauri.conf.json" + ) +} + +fn sql_object_name(line: &str, object_kind: &str) -> Option { + let tokens = line + .split(|character: char| character.is_whitespace() || matches!(character, '(' | ';')) + .filter(|token| !token.is_empty()) + .collect::>(); + let position = tokens + .iter() + .position(|token| token.eq_ignore_ascii_case(object_kind))?; + tokens + .iter() + .skip(position + 1) + .find(|token| { + !matches!( + token.to_ascii_lowercase().as_str(), + "if" | "not" | "exists" | "unique" | "concurrently" + ) + }) + .map(|token| token.trim_matches(['`', '"', '\'', '[', ']']).to_string()) +} + +fn rust_function_name(line: &str) -> Option { + let function = line.find("fn ")? + 3; + let rest = &line[function..]; + let name = rest + .split(|character: char| !character.is_alphanumeric() && character != '_') + .next()?; + (!name.is_empty()).then(|| name.to_string()) +} + +fn first_quoted(line: &str) -> Option { + for quote in ['"', '\'', '`'] { + let Some(start) = line.find(quote) else { + continue; + }; + let rest = &line[start + quote.len_utf8()..]; + if let Some(end) = rest.find(quote) { + let value = rest[..end].trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +fn is_analytics_line(lower: &str) -> bool { + [ + "capture(", + ".capture(", + "track(", + "trackevent(", + "track_event(", + "trackcoreaction(", + "track_core_action(", + "analytics.emit(", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn is_test_line(lower: &str, lower_path: &str) -> bool { + lower == "#[test]" + || lower.starts_with("it(") + || lower.starts_with("test(") + || lower.starts_with("describe(") + || ((lower_path.contains("/tests/") || lower_path.contains(".test.")) + && lower.contains("fn test_")) +} + +fn markdown_link_targets(line: &str) -> Vec { + let mut targets = Vec::new(); + let mut remainder = line; + while let Some(start) = remainder.find("](") { + let after = &remainder[start + 2..]; + let Some(end) = after.find(')') else { + break; + }; + let target = after[..end].trim(); + if !target.is_empty() && !target.starts_with('#') { + targets.push(target.to_string()); + } + remainder = &after[end + 1..]; + } + targets +} + +fn rationale_marker(line: &str) -> Option { + let trimmed = line + .trim() + .trim_start_matches(['#', '-', '*', '>', ' ']) + .trim(); + let lower = trimmed.to_ascii_lowercase(); + ["decision:", "rationale:", "why:", "adr:"] + .iter() + .find_map(|marker| lower.starts_with(marker).then(|| trimmed.to_string())) +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs index 8e2b189..3c4c70d 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs @@ -1,11 +1,14 @@ +use super::contracts::extract_contracts; use super::language::SupportedLanguage; +use super::metrics::extract_scope_metrics; use super::types::{ stable_graph_id, GraphOrigin, GraphSourceAnchor, GraphTrust, LanguageCoverage, StructuralGraphCoverage, StructuralGraphDiagnostic, StructuralGraphEdge, StructuralGraphFileRecord, StructuralGraphMetricFact, StructuralGraphNode, }; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; +use tree_sitter::{Node, Parser}; #[derive(Debug)] struct FileContribution { @@ -46,5 +49,11 @@ impl FileDisposition { } mod assembly; +mod metadata; +mod syntax; + +use assembly::parse_error_contribution; +use metadata::{attach_metadata_to_syntax_owners, extract_metadata_signals}; +use syntax::make_edge; pub(crate) use assembly::is_sensitive_path; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/syntax.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/syntax.rs new file mode 100644 index 0000000..7a72689 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/syntax.rs @@ -0,0 +1,606 @@ +use super::*; + +pub(super) fn extract_source( + path: &str, + language: SupportedLanguage, + source: &str, +) -> FileContribution { + let mut parser = Parser::new(); + let ts_language = language.tree_sitter_language(); + if let Err(error) = parser.set_language(&ts_language) { + return parse_error_contribution(path, language, format!("Parser setup failed: {error}")); + } + let Some(tree) = parser.parse(source, None) else { + return parse_error_contribution(path, language, "Parser returned no tree".to_string()); + }; + + let file_id = stable_graph_id("file", path); + let mut nodes = vec![StructuralGraphNode { + id: file_id.clone(), + kind: "file".to_string(), + label: path.to_string(), + qualified_name: Some(path.to_string()), + path: Some(path.to_string()), + detail: Some("syntax-indexed source file".to_string()), + language: Some(language.name().to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![GraphSourceAnchor::path(path)], + }]; + let mut edges = Vec::new(); + let mut metrics = vec![extract_scope_metrics( + path, + language, + source, + tree.root_node(), + &file_id, + "file", + false, + None, + )]; + let mut identity_counts = HashMap::new(); + visit_node( + tree.root_node(), + source, + path, + language, + &file_id, + &[], + &mut identity_counts, + &mut nodes, + &mut edges, + &mut metrics, + ); + extract_metadata_signals( + path, + source, + &file_id, + Some(language.name()), + &mut nodes, + &mut edges, + ); + attach_metadata_to_syntax_owners(&nodes, &mut edges); + let mut diagnostics = Vec::new(); + if tree.root_node().has_error() { + diagnostics.push(StructuralGraphDiagnostic { + severity: "warning".to_string(), + code: "syntax_error".to_string(), + message: "Tree-sitter recovered from one or more syntax errors; extracted nodes remain source-backed but coverage may be partial.".to_string(), + path: Some(path.to_string()), + language: Some(language.name().to_string()), + }); + } + + FileContribution { + path: path.to_string(), + language: Some(language.name().to_string()), + content_hash: Some(stable_graph_id("content", source)), + byte_size: source.len() as u64, + nodes, + edges, + metrics, + diagnostics, + disposition: FileDisposition::Indexed, + } +} + +#[allow(clippy::too_many_arguments)] +fn visit_node( + node: Node<'_>, + source: &str, + path: &str, + language: SupportedLanguage, + owner_id: &str, + containers: &[String], + identity_counts: &mut HashMap, + nodes: &mut Vec, + edges: &mut Vec, + metrics: &mut Vec, +) { + let mut child_owner = owner_id.to_string(); + let mut child_containers = containers.to_vec(); + + if let Some(kind) = declaration_kind(node.kind()) { + if let Some(name) = declaration_name(node, source) { + let qualified_name = if containers.is_empty() { + name.clone() + } else { + format!("{}::{name}", containers.join("::")) + }; + let identity = format!("{path}\0{kind}\0{qualified_name}"); + let ordinal = identity_counts.entry(identity.clone()).or_insert(0); + let node_id = stable_graph_id(kind, &format!("{identity}\0{ordinal}")); + *ordinal += 1; + let anchor = source_anchor(path, node, source); + nodes.push(StructuralGraphNode { + id: node_id.clone(), + kind: kind.to_string(), + label: name.clone(), + qualified_name: Some(format!("{path}::{qualified_name}")), + path: Some(path.to_string()), + detail: Some(node.kind().to_string()), + language: Some(language.name().to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![anchor.clone()], + }); + if is_metric_scope(kind) { + let (public_surface, public_surface_reason) = + public_surface(node, source, language); + metrics.push(extract_scope_metrics( + path, + language, + source, + node, + &node_id, + kind, + public_surface, + public_surface_reason, + )); + } + edges.push(make_edge( + owner_id, + &node_id, + "defines", + GraphTrust::Extracted, + GraphOrigin::Syntax, + format!("{} declaration", node.kind()), + vec![anchor], + Vec::new(), + )); + if is_explicitly_exported(node) { + edges.push(make_edge( + owner_id, + &node_id, + "exports", + GraphTrust::Extracted, + GraphOrigin::Syntax, + "declaration is wrapped by an explicit export syntax node".to_string(), + vec![source_anchor(path, node, source)], + Vec::new(), + )); + } + if kind == "field" { + if let Some(type_node) = declaration_type_node(node) { + if let Some(target) = compact_node_text(type_node, source, 160) { + add_reference_edge( + path, + language, + &node_id, + type_node, + source, + &target, + "type_reference", + "has_type", + None, + nodes, + edges, + ); + } + } + } + child_owner = node_id; + if is_container_kind(kind) { + child_containers.push(name); + } + } + } + + if is_call_node(node.kind()) { + if let Some(target) = call_target(node, source) { + add_reference_edge( + path, + language, + &child_owner, + node, + source, + &target, + "symbol_reference", + "calls", + None, + nodes, + edges, + ); + } + } + if is_import_node(node.kind()) { + if let Some(target) = import_target(node, source) { + add_reference_edge( + path, + language, + owner_id, + node, + source, + &target, + "module_reference", + "imports", + compact_node_text(node, source, 500), + nodes, + edges, + ); + } + } + if is_inheritance_node(node.kind()) { + if let Some(target) = compact_node_text(node, source, 160) { + add_reference_edge( + path, + language, + &child_owner, + node, + source, + &target, + "type_reference", + if node.kind().contains("implement") { + "implements" + } else { + "inherits" + }, + None, + nodes, + edges, + ); + } + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + visit_node( + child, + source, + path, + language, + &child_owner, + &child_containers, + identity_counts, + nodes, + edges, + metrics, + ); + } +} + +fn is_explicitly_exported(node: Node<'_>) -> bool { + let mut parent = node.parent(); + for _ in 0..3 { + let Some(current) = parent else { + return false; + }; + if matches!( + current.kind(), + "export_statement" | "export_declaration" | "exported_declaration" + ) { + return true; + } + parent = current.parent(); + } + false +} + +fn is_metric_scope(kind: &str) -> bool { + matches!( + kind, + "function" + | "method" + | "class" + | "interface" + | "struct" + | "trait" + | "module" + | "impl" + | "enum" + | "object" + ) +} + +fn public_surface( + node: Node<'_>, + source: &str, + language: SupportedLanguage, +) -> (bool, Option) { + if is_explicitly_exported(node) { + return (true, Some("explicit export syntax".to_string())); + } + let declaration = source + .get(node.byte_range()) + .unwrap_or_default() + .trim_start() + .chars() + .take(240) + .collect::(); + let lower = declaration.to_ascii_lowercase(); + if lower.starts_with("pub ") + || lower.starts_with("pub(") + || lower.starts_with("public ") + || lower.starts_with("export ") + || lower.starts_with("open ") + { + return (true, Some("explicit public visibility".to_string())); + } + let name = declaration_name(node, source).unwrap_or_default(); + if language == SupportedLanguage::Go && name.chars().next().is_some_and(char::is_uppercase) { + return (true, Some("Go exported-name convention".to_string())); + } + if matches!( + language, + SupportedLanguage::Python | SupportedLanguage::Ruby + ) && node + .parent() + .is_some_and(|parent| parent.parent().is_none()) + && !name.starts_with('_') + { + return ( + true, + Some("module-level public naming convention".to_string()), + ); + } + (false, None) +} + +fn declaration_type_node(node: Node<'_>) -> Option> { + for field in ["type", "return_type", "type_annotation"] { + if let Some(candidate) = node.child_by_field_name(field) { + return Some(candidate); + } + } + let mut cursor = node.walk(); + let candidate = node.named_children(&mut cursor).find(|child| { + child.kind().contains("type") + && !matches!(child.kind(), "type_identifier" | "predefined_type") + }); + candidate +} + +#[allow(clippy::too_many_arguments)] +fn add_reference_edge( + path: &str, + language: SupportedLanguage, + owner_id: &str, + node: Node<'_>, + source: &str, + target: &str, + reference_kind: &str, + edge_kind: &str, + reference_detail: Option, + nodes: &mut Vec, + edges: &mut Vec, +) { + let normalized_target = normalize_reference(target); + if normalized_target.is_empty() { + return; + } + let reference_id = stable_graph_id( + reference_kind, + &format!("{path}\0{edge_kind}\0{normalized_target}"), + ); + let anchor = source_anchor(path, node, source); + nodes.push(StructuralGraphNode { + id: reference_id.clone(), + kind: reference_kind.to_string(), + label: normalized_target.clone(), + qualified_name: None, + path: Some(path.to_string()), + detail: Some(reference_detail.unwrap_or_else(|| format!("unresolved {edge_kind} target"))), + language: Some(language.name().to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: vec![anchor.clone()], + }); + edges.push(make_edge( + owner_id, + &reference_id, + edge_kind, + GraphTrust::Extracted, + GraphOrigin::Syntax, + format!("{} syntax references `{normalized_target}`", node.kind()), + vec![anchor], + Vec::new(), + )); +} + +fn declaration_kind(node_kind: &str) -> Option<&'static str> { + match node_kind { + "function_declaration" + | "function_definition" + | "function_item" + | "function_signature" + | "local_function_statement" => Some("function"), + "method_definition" + | "method_declaration" + | "method_signature" + | "method" + | "singleton_method" + | "method_declaration_with_body" => Some("method"), + "constructor_declaration" | "init_declaration" => Some("constructor"), + "class_declaration" | "class_definition" | "class_specifier" | "class" => Some("class"), + "interface_declaration" | "protocol_declaration" | "trait_item" | "trait_declaration" => { + Some("interface") + } + "struct_item" | "struct_specifier" | "struct_declaration" => Some("struct"), + "enum_item" | "enum_specifier" | "enum_declaration" => Some("enum"), + "union_item" | "union_specifier" => Some("union"), + "type_alias_declaration" | "type_item" | "type_definition" | "type_declaration" => { + Some("type") + } + "field_declaration" + | "property_declaration" + | "property_signature" + | "public_field_definition" + | "field_definition" + | "struct_field" => Some("field"), + "module" + | "module_declaration" + | "module_definition" + | "mod_item" + | "namespace_definition" => Some("module"), + "object_declaration" => Some("object"), + _ => None, + } +} + +fn declaration_name(node: Node<'_>, source: &str) -> Option { + for field in ["name", "declarator", "type", "identifier"] { + if let Some(candidate) = node.child_by_field_name(field) { + if let Some(name) = first_identifier_text(candidate, source, 0) { + return Some(name); + } + } + } + first_identifier_text(node, source, 0) +} + +fn first_identifier_text(node: Node<'_>, source: &str, depth: usize) -> Option { + if depth > 5 { + return None; + } + if is_identifier_kind(node.kind()) { + return compact_node_text(node, source, 120); + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if let Some(value) = first_identifier_text(child, source, depth + 1) { + return Some(value); + } + } + None +} + +fn is_identifier_kind(kind: &str) -> bool { + matches!( + kind, + "identifier" + | "name" + | "type_identifier" + | "field_identifier" + | "property_identifier" + | "namespace_identifier" + | "constant" + | "simple_identifier" + ) +} + +fn is_container_kind(kind: &str) -> bool { + matches!( + kind, + "class" | "interface" | "struct" | "enum" | "union" | "module" | "object" + ) +} + +fn is_call_node(kind: &str) -> bool { + matches!( + kind, + "call_expression" + | "invocation_expression" + | "method_invocation" + | "function_call_expression" + | "call" + ) +} + +fn call_target(node: Node<'_>, source: &str) -> Option { + for field in ["function", "name", "method", "callee"] { + if let Some(candidate) = node.child_by_field_name(field) { + return compact_node_text(candidate, source, 160); + } + } + node.named_child(0) + .and_then(|candidate| compact_node_text(candidate, source, 160)) +} + +fn is_import_node(kind: &str) -> bool { + matches!( + kind, + "import_statement" + | "import_declaration" + | "import_from_statement" + | "use_declaration" + | "using_directive" + | "namespace_use_declaration" + | "preproc_include" + ) +} + +fn import_target(node: Node<'_>, source: &str) -> Option { + for field in ["source", "path", "module", "argument"] { + if let Some(candidate) = node.child_by_field_name(field) { + return compact_node_text(candidate, source, 240); + } + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if matches!( + child.kind(), + "string" | "string_literal" | "interpreted_string_literal" | "scoped_identifier" + ) { + return compact_node_text(child, source, 240); + } + } + compact_node_text(node, source, 240) +} + +fn is_inheritance_node(kind: &str) -> bool { + matches!( + kind, + "extends_clause" + | "implements_clause" + | "superclass" + | "super_interfaces" + | "base_list" + | "delegation_specifiers" + ) +} + +fn compact_node_text(node: Node<'_>, source: &str, max_chars: usize) -> Option { + let text = node.utf8_text(source.as_bytes()).ok()?.trim(); + if text.is_empty() { + return None; + } + Some(text.chars().take(max_chars).collect()) +} + +fn normalize_reference(value: &str) -> String { + value + .trim() + .trim_matches(|character| matches!(character, '"' | '\'' | '`' | '<' | '>')) + .split_whitespace() + .collect::>() + .join(" ") +} + +fn source_anchor(path: &str, node: Node<'_>, source: &str) -> GraphSourceAnchor { + let start = node.start_position(); + let end = node.end_position(); + GraphSourceAnchor { + path: path.to_string(), + start_line: Some(start.row as u32 + 1), + start_column: Some(start.column as u32 + 1), + end_line: Some(end.row as u32 + 1), + end_column: Some(end.column as u32 + 1), + excerpt: compact_node_text(node, source, 240), + } +} + +pub(super) fn make_edge( + from: &str, + to: &str, + kind: &str, + trust: GraphTrust, + origin: GraphOrigin, + evidence: String, + sources: Vec, + candidates: Vec, +) -> StructuralGraphEdge { + StructuralGraphEdge { + id: stable_graph_id("edge", &format!("{kind}\0{from}\0{to}")), + from: from.to_string(), + to: to.to_string(), + kind: kind.to_string(), + evidence, + trust, + origin, + sources, + candidates, + } +} From a1ae749cb45fbcaeb8eba781075edaeb07ecc71f Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:03:10 +0530 Subject: [PATCH 022/141] feat(graph): orchestrate repository extraction --- .../structural_graph/extract/engine.rs | 269 +++++++ .../structural_graph/extract/files.rs | 298 ++++++++ .../structural_graph/extract/history.rs | 257 +++++++ .../commands/structural_graph/extract/mod.rs | 40 +- .../structural_graph/extract/tests.rs | 672 ++++++++++++++++++ 5 files changed, 1529 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/engine.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/files.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/history.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/extract/tests.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/engine.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/engine.rs new file mode 100644 index 0000000..b1b2c3b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/engine.rs @@ -0,0 +1,269 @@ +use super::*; + +#[derive(Debug, Default)] +pub struct BundledTreeSitterEngine; + +impl StructuralGraphEngine for BundledTreeSitterEngine { + fn info(&self) -> StructuralGraphEngineInfo { + StructuralGraphEngineInfo { + id: BUNDLED_ENGINE_ID.to_string(), + version: BUNDLED_ENGINE_VERSION.to_string(), + bundled: true, + syntax_aware: true, + supported_languages: supported_language_names(), + } + } + + fn build( + &self, + input: &StructuralGraphBuildInput, + cancellation: &StructuralGraphCancellation, + progress: &dyn StructuralGraphProgressSink, + ) -> Result { + let root = input.repo_root.canonicalize().map_err(|error| { + StructuralGraphError::InvalidRepository(format!( + "Cannot resolve repository {}: {error}", + input.repo_root.display() + )) + })?; + if !root.is_dir() { + return Err(StructuralGraphError::InvalidRepository(format!( + "Repository path is not a directory: {}", + root.display() + ))); + } + if let Some(previous) = input.previous_snapshot.as_deref() { + if input.previous_cursor != previous.cursor { + return Err(StructuralGraphError::Parse( + "Incremental graph cursor does not match the previous snapshot; rebuild the index" + .to_string(), + )); + } + } + + progress.report(StructuralGraphProgress { + phase: "discover".to_string(), + completed: 0, + total: 0, + detail: "Discovering repository files from Git".to_string(), + }); + let incremental = input.previous_snapshot.is_some(); + let mut paths = if incremental { + input + .changed_files + .iter() + .map(PathBuf::from) + .collect::>() + } else { + discover_paths(&root)? + }; + paths.sort(); + paths.dedup(); + let truncated = paths.len() > input.max_files; + paths.truncate(input.max_files); + + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + + let completed = AtomicUsize::new(0); + let total = paths.len(); + let contributions = paths + .par_iter() + .map(|path| { + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + let contribution = extract_path(&root, path, input.max_bytes_per_file); + let done = completed.fetch_add(1, Ordering::Relaxed) + 1; + if done == total || done.is_multiple_of(100) { + progress.report(StructuralGraphProgress { + phase: "extract".to_string(), + completed: done, + total, + detail: path.to_string_lossy().replace('\\', "/"), + }); + } + Ok(contribution) + }) + .collect::, StructuralGraphError>>()?; + + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + + progress.report(StructuralGraphProgress { + phase: "assemble".to_string(), + completed: total, + total, + detail: "Assembling deterministic structural graph".to_string(), + }); + + let affected_paths = input + .changed_files + .iter() + .chain(input.deleted_files.iter()) + .map(|path| path.replace('\\', "/")) + .collect::>(); + let (mut files, mut nodes, mut edges, mut metrics, mut diagnostics, inherited_truncation) = + if let Some(previous) = input.previous_snapshot.as_deref() { + let mut nodes = previous + .nodes + .iter() + .filter(|node| !node_belongs_to_paths(node, &affected_paths)) + .cloned() + .collect::>(); + let retained_node_ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut edges = previous + .edges + .iter() + .filter(|edge| { + !matches!(edge.origin, GraphOrigin::Resolution | GraphOrigin::Analysis) + && retained_node_ids.contains(edge.from.as_str()) + && retained_node_ids.contains(edge.to.as_str()) + && !sources_touch_paths(&edge.sources, &affected_paths) + }) + .cloned() + .collect::>(); + let mut diagnostics = previous + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic + .path + .as_ref() + .is_none_or(|path| !affected_paths.contains(path)) + }) + .cloned() + .collect::>(); + let mut metrics = previous + .metrics + .iter() + .filter(|fact| !affected_paths.contains(&fact.path)) + .cloned() + .collect::>(); + let mut files = previous + .files + .iter() + .filter(|file| !affected_paths.contains(&file.path)) + .cloned() + .collect::>(); + nodes.extend( + contributions + .iter() + .flat_map(|contribution| contribution.nodes.iter().cloned()), + ); + edges.extend( + contributions + .iter() + .flat_map(|contribution| contribution.edges.iter().cloned()), + ); + diagnostics.extend( + contributions + .iter() + .flat_map(|contribution| contribution.diagnostics.iter().cloned()), + ); + metrics.extend( + contributions + .iter() + .flat_map(|contribution| contribution.metrics.iter().cloned()), + ); + files.extend(contributions.iter().map(file_record_from_contribution)); + ( + files, + nodes, + edges, + metrics, + diagnostics, + previous.truncated, + ) + } else { + ( + contributions + .iter() + .map(file_record_from_contribution) + .collect(), + contributions + .iter() + .flat_map(|contribution| contribution.nodes.iter().cloned()) + .collect(), + contributions + .iter() + .flat_map(|contribution| contribution.edges.iter().cloned()) + .collect(), + contributions + .iter() + .flat_map(|contribution| contribution.metrics.iter().cloned()) + .collect(), + contributions + .iter() + .flat_map(|contribution| contribution.diagnostics.iter().cloned()) + .collect(), + false, + ) + }; + files.sort_by(|left, right| left.path.cmp(&right.path)); + files.dedup_by(|left, right| left.path == right.path); + let coverage = coverage_from_file_records(&files); + deduplicate_nodes(&mut nodes); + deduplicate_edges(&mut edges); + resolve_cross_file(&nodes, &mut edges); + deduplicate_edges(&mut edges); + deduplicate_metrics(&mut metrics); + finalize_metric_degrees(&mut metrics, &edges); + let clone_groups = detect_clone_groups(&metrics); + let communities = analyze_graph(&mut nodes, &edges); + diagnostics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.message.cmp(&right.message)) + }); + + let cursor_identity = files + .iter() + .map(|file| { + file.content_hash + .as_ref() + .map(|hash| format!("{}\0{hash}", file.path)) + .unwrap_or_else(|| format!("{}\0{}", file.path, file.disposition)) + }) + .collect::>() + .join("\0"); + let cursor = stable_graph_id("cursor", &cursor_identity); + let repo_path = root.to_string_lossy().to_string(); + let snapshot_id = stable_graph_id( + "snapshot", + &format!( + "{}\0{}\0{}\0{}", + repo_path, + input.repo_head.as_deref().unwrap_or("working-tree"), + BUNDLED_ENGINE_VERSION, + cursor + ), + ); + + Ok(StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: snapshot_id, + repo_path, + repo_head: input.repo_head.clone(), + created_at: Utc::now().to_rfc3339(), + engine: self.info(), + cursor: Some(cursor), + ignore_fingerprint: Some(stable_graph_id("ignore", IGNORE_POLICY_VERSION)), + coverage, + diagnostics, + communities, + files, + nodes, + edges, + metrics, + clone_groups, + truncated: truncated || inherited_truncation, + }) + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/files.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/files.rs new file mode 100644 index 0000000..80db2df --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/files.rs @@ -0,0 +1,298 @@ +use super::*; + +pub(super) fn extract_blob(path: &str, bytes: &[u8], max_bytes: usize) -> FileContribution { + let normalized_path = path.replace('\\', "/"); + let relative_path = Path::new(&normalized_path); + let language = SupportedLanguage::from_path(relative_path); + if is_sensitive_path(&normalized_path) { + return skipped_contribution( + stable_graph_id("sensitive_path", &normalized_path), + language, + FileDisposition::Sensitive, + ); + } + if is_binary_path(&normalized_path) { + return skipped_contribution(normalized_path, language, FileDisposition::Binary); + } + if is_generated_path(&normalized_path) { + return metadata_file_contribution(normalized_path, language, FileDisposition::Generated); + } + if bytes.len() > max_bytes { + return metadata_file_contribution(normalized_path, language, FileDisposition::TooLarge); + } + let Ok(source) = std::str::from_utf8(bytes) else { + return skipped_contribution(normalized_path, language, FileDisposition::Binary); + }; + if let Some(language) = language { + return extract_source(&normalized_path, language, source); + } + if !is_metadata_text_path(relative_path) { + return metadata_file_contribution(normalized_path, None, FileDisposition::Unsupported); + } + let file_id = stable_graph_id("file", &normalized_path); + let mut nodes = vec![StructuralGraphNode { + id: file_id.clone(), + kind: "file".to_string(), + label: normalized_path.clone(), + qualified_name: Some(normalized_path.clone()), + path: Some(normalized_path.clone()), + detail: Some("historical metadata-indexed text file".to_string()), + language: None, + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Metadata, + sources: vec![GraphSourceAnchor::path(&normalized_path)], + }]; + let mut edges = Vec::new(); + extract_metadata_signals( + &normalized_path, + source, + &file_id, + None, + &mut nodes, + &mut edges, + ); + attach_metadata_to_syntax_owners(&nodes, &mut edges); + FileContribution { + path: normalized_path, + language: None, + content_hash: Some(stable_graph_id("content", source)), + byte_size: bytes.len() as u64, + nodes, + edges, + metrics: Vec::new(), + diagnostics: Vec::new(), + disposition: FileDisposition::Indexed, + } +} + +pub(super) fn discover_paths(root: &Path) -> Result, StructuralGraphError> { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(["ls-files", "-co", "--exclude-standard", "-z"]) + .output() + .map_err(|error| { + StructuralGraphError::Io(format!("Failed to discover Git files: {error}")) + })?; + if !output.status.success() { + return Err(StructuralGraphError::InvalidRepository(format!( + "Git file discovery failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(output + .stdout + .split(|byte| *byte == 0) + .filter(|bytes| !bytes.is_empty()) + .map(|bytes| PathBuf::from(String::from_utf8_lossy(bytes).into_owned())) + .collect()) +} + +pub(super) fn extract_path(root: &Path, relative_path: &Path, max_bytes: u64) -> FileContribution { + let normalized_path = relative_path.to_string_lossy().replace('\\', "/"); + let language = SupportedLanguage::from_path(relative_path); + if is_sensitive_path(&normalized_path) { + return skipped_contribution( + stable_graph_id("sensitive_path", &normalized_path), + language, + FileDisposition::Sensitive, + ); + } + if is_binary_path(&normalized_path) { + return skipped_contribution(normalized_path, language, FileDisposition::Binary); + } + if is_generated_path(&normalized_path) { + return metadata_file_contribution(normalized_path, language, FileDisposition::Generated); + } + let Some(language) = language else { + return extract_metadata_path(root, relative_path, &normalized_path, max_bytes); + }; + + let absolute_path = root.join(relative_path); + let metadata = match std::fs::metadata(&absolute_path) { + Ok(metadata) => metadata, + Err(error) => { + return FileContribution { + path: normalized_path.clone(), + language: Some(language.name().to_string()), + content_hash: None, + byte_size: 0, + nodes: Vec::new(), + edges: Vec::new(), + metrics: Vec::new(), + diagnostics: vec![StructuralGraphDiagnostic { + severity: "warning".to_string(), + code: "file_metadata_failed".to_string(), + message: error.to_string(), + path: Some(normalized_path), + language: Some(language.name().to_string()), + }], + disposition: FileDisposition::Error, + }; + } + }; + if metadata.len() > max_bytes { + return metadata_file_contribution( + normalized_path, + Some(language), + FileDisposition::TooLarge, + ); + } + let bytes = match std::fs::read(&absolute_path) { + Ok(bytes) => bytes, + Err(error) => { + return FileContribution { + path: normalized_path.clone(), + language: Some(language.name().to_string()), + content_hash: None, + byte_size: metadata.len(), + nodes: Vec::new(), + edges: Vec::new(), + metrics: Vec::new(), + diagnostics: vec![StructuralGraphDiagnostic { + severity: "warning".to_string(), + code: "file_read_failed".to_string(), + message: error.to_string(), + path: Some(normalized_path), + language: Some(language.name().to_string()), + }], + disposition: FileDisposition::Error, + }; + } + }; + let source = match String::from_utf8(bytes) { + Ok(source) => source, + Err(_) => { + return skipped_contribution(normalized_path, Some(language), FileDisposition::Binary); + } + }; + extract_source(&normalized_path, language, &source) +} + +pub(super) fn extract_metadata_path( + root: &Path, + relative_path: &Path, + normalized_path: &str, + max_bytes: u64, +) -> FileContribution { + if !is_metadata_text_path(relative_path) { + return metadata_file_contribution( + normalized_path.to_string(), + None, + FileDisposition::Unsupported, + ); + } + let absolute_path = root.join(relative_path); + let metadata = match std::fs::metadata(&absolute_path) { + Ok(metadata) if metadata.len() <= max_bytes => metadata, + Ok(_) => { + return metadata_file_contribution( + normalized_path.to_string(), + None, + FileDisposition::TooLarge, + ) + } + Err(error) => { + return metadata_read_error(normalized_path, "file_metadata_failed", error.to_string()) + } + }; + let bytes = match std::fs::read(&absolute_path) { + Ok(bytes) => bytes, + Err(error) => { + return metadata_read_error(normalized_path, "file_read_failed", error.to_string()) + } + }; + let source = match String::from_utf8(bytes) { + Ok(source) => source, + Err(_) => { + return skipped_contribution(normalized_path.to_string(), None, FileDisposition::Binary) + } + }; + let file_id = stable_graph_id("file", normalized_path); + let mut nodes = vec![StructuralGraphNode { + id: file_id.clone(), + kind: "file".to_string(), + label: normalized_path.to_string(), + qualified_name: Some(normalized_path.to_string()), + path: Some(normalized_path.to_string()), + detail: Some("metadata-indexed text file".to_string()), + language: None, + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Metadata, + sources: vec![GraphSourceAnchor::path(normalized_path)], + }]; + let mut edges = Vec::new(); + extract_metadata_signals( + normalized_path, + &source, + &file_id, + None, + &mut nodes, + &mut edges, + ); + attach_metadata_to_syntax_owners(&nodes, &mut edges); + FileContribution { + path: normalized_path.to_string(), + language: None, + content_hash: Some(stable_graph_id("content", &source)), + byte_size: metadata.len(), + nodes, + edges, + metrics: Vec::new(), + diagnostics: Vec::new(), + disposition: FileDisposition::Indexed, + } +} + +fn metadata_read_error(path: &str, code: &str, message: String) -> FileContribution { + FileContribution { + path: path.to_string(), + language: None, + content_hash: None, + byte_size: 0, + nodes: Vec::new(), + edges: Vec::new(), + metrics: Vec::new(), + diagnostics: vec![StructuralGraphDiagnostic { + severity: "warning".to_string(), + code: code.to_string(), + message, + path: Some(path.to_string()), + language: None, + }], + disposition: FileDisposition::Error, + } +} + +fn is_metadata_text_path(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + extension.as_str(), + "md" | "mdx" + | "sql" + | "json" + | "jsonc" + | "toml" + | "yaml" + | "yml" + | "ini" + | "sh" + | "proto" + | "graphql" + | "gql" + ) || matches!( + name.as_str(), + "dockerfile" | "makefile" | "justfile" | "procfile" + ) +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/history.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/history.rs new file mode 100644 index 0000000..4b11da8 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/history.rs @@ -0,0 +1,257 @@ +use super::*; + +#[derive(Debug, Clone)] +pub struct HistoricalFileBlob { + pub path: String, + pub bytes: Vec, +} + +pub fn build_snapshot_from_blobs( + storage_repo_path: &str, + revision: &str, + mut blobs: Vec, + cancellation: &StructuralGraphCancellation, + progress: &dyn StructuralGraphProgressSink, +) -> Result { + blobs.sort_by(|left, right| left.path.cmp(&right.path)); + blobs.dedup_by(|left, right| left.path == right.path); + let truncated = blobs.len() > 25_000; + blobs.truncate(25_000); + let total = blobs.len(); + let completed = AtomicUsize::new(0); + let contributions = blobs + .par_iter() + .map(|blob| { + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + let contribution = extract_blob(&blob.path, &blob.bytes, 2 * 1024 * 1024); + let done = completed.fetch_add(1, Ordering::Relaxed) + 1; + if done == total || done.is_multiple_of(100) { + progress.report(StructuralGraphProgress { + phase: "historical_extract".to_string(), + completed: done, + total, + detail: blob.path.clone(), + }); + } + Ok(contribution) + }) + .collect::, StructuralGraphError>>()?; + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + let files = contributions + .iter() + .map(file_record_from_contribution) + .collect::>(); + let nodes = contributions + .iter() + .flat_map(|contribution| contribution.nodes.iter().cloned()) + .collect::>(); + let edges = contributions + .iter() + .flat_map(|contribution| contribution.edges.iter().cloned()) + .collect::>(); + let metrics = contributions + .iter() + .flat_map(|contribution| contribution.metrics.iter().cloned()) + .collect::>(); + let diagnostics = contributions + .iter() + .flat_map(|contribution| contribution.diagnostics.iter().cloned()) + .collect::>(); + finalize_historical_snapshot( + storage_repo_path, + revision, + files, + nodes, + edges, + metrics, + diagnostics, + truncated, + ) +} + +pub fn build_snapshot_from_blob_delta( + storage_repo_path: &str, + revision: &str, + previous: &StructuralGraphSnapshot, + mut changed_blobs: Vec, + deleted_paths: &[String], + cancellation: &StructuralGraphCancellation, + progress: &dyn StructuralGraphProgressSink, +) -> Result { + changed_blobs.sort_by(|left, right| left.path.cmp(&right.path)); + changed_blobs.dedup_by(|left, right| left.path == right.path); + let total = changed_blobs.len(); + let completed = AtomicUsize::new(0); + let contributions = changed_blobs + .par_iter() + .map(|blob| { + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + let contribution = extract_blob(&blob.path, &blob.bytes, 2 * 1024 * 1024); + let done = completed.fetch_add(1, Ordering::Relaxed) + 1; + if done == total || done.is_multiple_of(100) { + progress.report(StructuralGraphProgress { + phase: "historical_delta_extract".to_string(), + completed: done, + total, + detail: blob.path.clone(), + }); + } + Ok(contribution) + }) + .collect::, StructuralGraphError>>()?; + if cancellation.is_cancelled() { + return Err(StructuralGraphError::Cancelled); + } + let affected_paths = changed_blobs + .iter() + .map(|blob| blob.path.replace('\\', "/")) + .chain(deleted_paths.iter().map(|path| path.replace('\\', "/"))) + .collect::>(); + let mut nodes = previous + .nodes + .iter() + .filter(|node| !node_belongs_to_paths(node, &affected_paths)) + .cloned() + .collect::>(); + let retained_node_ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut edges = previous + .edges + .iter() + .filter(|edge| { + !matches!(edge.origin, GraphOrigin::Resolution | GraphOrigin::Analysis) + && retained_node_ids.contains(edge.from.as_str()) + && retained_node_ids.contains(edge.to.as_str()) + && !sources_touch_paths(&edge.sources, &affected_paths) + }) + .cloned() + .collect::>(); + let mut diagnostics = previous + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic + .path + .as_ref() + .is_none_or(|path| !affected_paths.contains(path)) + }) + .cloned() + .collect::>(); + let mut metrics = previous + .metrics + .iter() + .filter(|fact| !affected_paths.contains(&fact.path)) + .cloned() + .collect::>(); + let mut files = previous + .files + .iter() + .filter(|file| !affected_paths.contains(&file.path)) + .cloned() + .collect::>(); + nodes.extend( + contributions + .iter() + .flat_map(|contribution| contribution.nodes.iter().cloned()), + ); + edges.extend( + contributions + .iter() + .flat_map(|contribution| contribution.edges.iter().cloned()), + ); + diagnostics.extend( + contributions + .iter() + .flat_map(|contribution| contribution.diagnostics.iter().cloned()), + ); + metrics.extend( + contributions + .iter() + .flat_map(|contribution| contribution.metrics.iter().cloned()), + ); + files.extend(contributions.iter().map(file_record_from_contribution)); + finalize_historical_snapshot( + storage_repo_path, + revision, + files, + nodes, + edges, + metrics, + diagnostics, + previous.truncated, + ) +} + +fn finalize_historical_snapshot( + storage_repo_path: &str, + revision: &str, + mut files: Vec, + mut nodes: Vec, + mut edges: Vec, + mut metrics: Vec, + mut diagnostics: Vec, + truncated: bool, +) -> Result { + files.sort_by(|left, right| left.path.cmp(&right.path)); + files.dedup_by(|left, right| left.path == right.path); + let coverage = coverage_from_file_records(&files); + deduplicate_nodes(&mut nodes); + deduplicate_edges(&mut edges); + resolve_cross_file(&nodes, &mut edges); + deduplicate_edges(&mut edges); + deduplicate_metrics(&mut metrics); + finalize_metric_degrees(&mut metrics, &edges); + let clone_groups = detect_clone_groups(&metrics); + let communities = analyze_graph(&mut nodes, &edges); + diagnostics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.message.cmp(&right.message)) + }); + let cursor_identity = files + .iter() + .map(|file| { + file.content_hash + .as_ref() + .map(|hash| format!("{}\0{hash}", file.path)) + .unwrap_or_else(|| format!("{}\0{}", file.path, file.disposition)) + }) + .collect::>() + .join("\0"); + let cursor = stable_graph_id("cursor", &cursor_identity); + let snapshot_id = stable_graph_id( + "historical-snapshot", + &format!( + "{storage_repo_path}\0{revision}\0{}\0{cursor}", + BUNDLED_ENGINE_VERSION + ), + ); + Ok(StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: snapshot_id, + repo_path: storage_repo_path.to_string(), + repo_head: Some(revision.to_string()), + created_at: Utc::now().to_rfc3339(), + engine: BundledTreeSitterEngine.info(), + cursor: Some(cursor), + ignore_fingerprint: Some(stable_graph_id("ignore", IGNORE_POLICY_VERSION)), + coverage, + diagnostics, + communities, + files, + nodes, + edges, + metrics, + clone_groups, + truncated, + }) +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs index 3c4c70d..b130e42 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/mod.rs @@ -1,15 +1,26 @@ use super::contracts::extract_contracts; -use super::language::SupportedLanguage; -use super::metrics::extract_scope_metrics; +use super::language::{supported_language_names, SupportedLanguage}; +use super::metrics::{detect_clone_groups, extract_scope_metrics, finalize_metric_degrees}; use super::types::{ stable_graph_id, GraphOrigin, GraphSourceAnchor, GraphTrust, LanguageCoverage, - StructuralGraphCoverage, StructuralGraphDiagnostic, StructuralGraphEdge, - StructuralGraphFileRecord, StructuralGraphMetricFact, StructuralGraphNode, + StructuralGraphBuildInput, StructuralGraphCancellation, StructuralGraphCoverage, + StructuralGraphDiagnostic, StructuralGraphEdge, StructuralGraphEngine, + StructuralGraphEngineInfo, StructuralGraphError, StructuralGraphFileRecord, + StructuralGraphMetricFact, StructuralGraphNode, StructuralGraphProgress, + StructuralGraphProgressSink, StructuralGraphSnapshot, BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, STRUCTURAL_GRAPH_SCHEMA_VERSION, }; +use super::{analysis::analyze_graph, resolve::resolve_cross_file}; +use chrono::Utc; +use rayon::prelude::*; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; use tree_sitter::{Node, Parser}; +const IGNORE_POLICY_VERSION: &str = "structural-ignore-v1"; + #[derive(Debug)] struct FileContribution { path: String, @@ -49,11 +60,26 @@ impl FileDisposition { } mod assembly; +mod engine; +mod files; +mod history; mod metadata; mod syntax; -use assembly::parse_error_contribution; +use assembly::{ + coverage_from_file_records, deduplicate_edges, deduplicate_metrics, deduplicate_nodes, + file_record_from_contribution, is_binary_path, is_generated_path, metadata_file_contribution, + node_belongs_to_paths, parse_error_contribution, skipped_contribution, sources_touch_paths, +}; +#[cfg(test)] +use files::extract_metadata_path; +use files::{discover_paths, extract_blob, extract_path}; use metadata::{attach_metadata_to_syntax_owners, extract_metadata_signals}; -use syntax::make_edge; +use syntax::{extract_source, make_edge}; pub(crate) use assembly::is_sensitive_path; +pub use engine::BundledTreeSitterEngine; +pub use history::{build_snapshot_from_blob_delta, build_snapshot_from_blobs, HistoricalFileBlob}; + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/extract/tests.rs b/apps/desktop/src-tauri/src/commands/structural_graph/extract/tests.rs new file mode 100644 index 0000000..0fa6b21 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/extract/tests.rs @@ -0,0 +1,672 @@ +use super::*; +use std::fs; + +#[test] +fn every_promised_language_extracts_a_named_symbol() { + let fixtures = [ + ("a.ts", "export function alpha() { beta(); }", "alpha"), + ( + "a.tsx", + "export function Alpha() { beta(); return
; }", + "Alpha", + ), + ("a.js", "function alpha() { beta(); }", "alpha"), + ( + "a.jsx", + "function Alpha() { beta(); return
; }", + "Alpha", + ), + ("a.rs", "fn alpha() { beta(); }", "alpha"), + ("a.py", "def alpha():\n beta()\n", "alpha"), + ("a.go", "package a\nfunc alpha() { beta() }", "alpha"), + ("A.java", "class A { void alpha() { beta(); } }", "alpha"), + ("a.c", "void alpha(void) { beta(); }", "alpha"), + ("a.cpp", "class A { void alpha() { beta(); } };", "alpha"), + ("A.cs", "class A { void Alpha() { Beta(); } }", "Alpha"), + ("a.rb", "def alpha\n beta()\nend", "alpha"), + ("a.php", ">(), + contribution.diagnostics + ); + assert!( + contribution.nodes.iter().any(|node| node.kind == "file"), + "{path} should include its file/module anchor" + ); + assert!( + contribution.edges.iter().any(|edge| edge.kind == "defines"), + "{path} should include a direct definition edge" + ); + assert!( + contribution.edges.iter().any(|edge| edge.kind == "calls"), + "{path} should include a direct call edge" + ); + let declaration = contribution + .nodes + .iter() + .find(|node| node.label == symbol) + .expect("declaration"); + assert_eq!(declaration.sources[0].path, path); + assert!(declaration.sources[0].start_line.is_some()); + let metric = contribution + .metrics + .iter() + .find(|fact| fact.node_id == declaration.id) + .unwrap_or_else(|| panic!("{path} should publish metrics for {symbol}")); + assert_eq!(metric.schema_version, 1); + assert_eq!(metric.path, path); + assert_eq!(metric.language, language.name()); + assert!(metric.metrics.cyclomatic_complexity >= 1); + assert!(!metric.sources.is_empty()); + } +} + +#[test] +fn modules_fields_and_nested_qualified_names_are_source_located() { + let rust = extract_source( + "src/model.rs", + SupportedLanguage::Rust, + "mod inner { struct User { name: String } impl User { fn save(&self) {} } }", + ); + assert!(rust + .nodes + .iter() + .any(|node| node.kind == "module" && node.label == "inner")); + assert!(rust.nodes.iter().any(|node| { + node.label == "User" + && node + .qualified_name + .as_deref() + .is_some_and(|name| name.contains("inner::User")) + })); + + let typescript = extract_source( + "src/model.ts", + SupportedLanguage::TypeScript, + "export class User { name: string; save(): void {} }", + ); + assert!(typescript + .nodes + .iter() + .any(|node| node.kind == "field" && node.label == "name")); + assert!(typescript + .edges + .iter() + .any(|edge| edge.kind == "has_type" && edge.trust == GraphTrust::Extracted)); + assert!(typescript.nodes.iter().any(|node| { + node.kind == "method" + && node + .qualified_name + .as_deref() + .is_some_and(|name| name.contains("User::save")) + })); + assert!(typescript + .edges + .iter() + .any(|edge| edge.kind == "exports" && edge.trust == GraphTrust::Extracted)); +} + +#[test] +fn source_locations_are_one_based_and_calls_are_source_backed() { + let contribution = extract_source( + "a.rs", + SupportedLanguage::Rust, + "fn alpha() {\n beta();\n}\n", + ); + let function = contribution + .nodes + .iter() + .find(|node| node.kind == "function") + .expect("function"); + assert_eq!(function.sources[0].start_line, Some(1)); + let call = contribution + .edges + .iter() + .find(|edge| edge.kind == "calls") + .expect("call edge"); + assert_eq!(call.sources[0].start_line, Some(2)); + assert_eq!(call.trust, GraphTrust::Extracted); +} + +#[test] +fn source_metadata_extracts_product_boundaries_and_analytics() { + let contribution = extract_source( + "src/app.tsx", + SupportedLanguage::Tsx, + r#" + } /> + trackCoreAction('settings_opened'); + test("opens settings", () => {}); + "#, + ); + for (kind, label) in [ + ("route", "/settings"), + ("analytics_event", "settings_opened"), + ("test", "opens settings"), + ] { + let node = contribution + .nodes + .iter() + .find(|node| node.kind == kind && node.label == label) + .unwrap_or_else(|| panic!("missing {kind} {label}")); + assert_eq!(node.origin, GraphOrigin::Metadata); + assert_eq!(node.trust, GraphTrust::Extracted); + assert!(node.sources[0].start_line.is_some()); + } +} + +#[test] +fn source_metadata_extracts_tauri_commands_and_sql_objects() { + let contribution = extract_source( + "src/main.rs", + SupportedLanguage::Rust, + r#" + #[tauri::command] + async fn build_graph() {} + const SQL: &str = "CREATE TABLE IF NOT EXISTS graph_nodes (id TEXT);"; + "#, + ); + assert!(contribution + .nodes + .iter() + .any(|node| node.kind == "tauri_command" && node.label == "build_graph")); + assert!(contribution + .nodes + .iter() + .any(|node| node.kind == "db_table" && node.label == "graph_nodes")); +} + +#[test] +fn framework_routes_and_sql_lineage_resolve_to_exact_implementations() { + let route = extract_source( + "src/routes.ts", + SupportedLanguage::TypeScript, + "export function listUsers() { return db.query('SELECT * FROM users'); }\nrouter.get('/users', listUsers);\n", + ); + let schema = extract_blob( + "db/schema.sql", + b"CREATE TABLE users (id INTEGER PRIMARY KEY);", + 1024, + ); + let mut nodes = route + .nodes + .into_iter() + .chain(schema.nodes) + .collect::>(); + let mut edges = route + .edges + .into_iter() + .chain(schema.edges) + .collect::>(); + deduplicate_nodes(&mut nodes); + deduplicate_edges(&mut edges); + resolve_cross_file(&nodes, &mut edges); + + let list_users_id = nodes + .iter() + .find(|node| node.kind == "function" && node.label == "listUsers") + .expect("route handler") + .id + .clone(); + let route_node_id = nodes + .iter() + .find(|node| node.kind == "route" && node.label == "/users") + .expect("route") + .id + .clone(); + assert!(edges.iter().any(|edge| { + edge.from == route_node_id + && edge.to == list_users_id + && edge.kind == "routes_to" + && edge.trust == GraphTrust::Inferred + })); + + let users_table_id = nodes + .iter() + .find(|node| node.kind == "db_table" && node.label == "users") + .expect("users table") + .id + .clone(); + assert!(edges.iter().any(|edge| { + edge.from == list_users_id + && edge.to == users_table_id + && edge.kind == "reads_from" + && edge.trust == GraphTrust::Inferred + })); + + let communities = analyze_graph(&mut nodes, &edges); + let summary = crate::commands::structural_graph::analysis::summarize_graph_analysis( + &nodes, + &edges, + &communities, + ); + assert!(summary.algorithms.execution_flows.iter().any(|flow| { + flow.node_ids + .starts_with(&[route_node_id.clone(), list_users_id.clone()]) + && flow.node_ids.contains(&users_table_id) + })); +} + +#[test] +fn ambiguous_framework_handlers_retain_candidates() { + let route = extract_source( + "src/routes.ts", + SupportedLanguage::TypeScript, + "router.get('/users', listUsers);", + ); + let first = extract_source( + "src/admin.ts", + SupportedLanguage::TypeScript, + "export function listUsers() {}", + ); + let second = extract_source( + "src/public.ts", + SupportedLanguage::TypeScript, + "export function listUsers() {}", + ); + let mut nodes = route + .nodes + .into_iter() + .chain(first.nodes) + .chain(second.nodes) + .collect::>(); + let mut edges = route + .edges + .into_iter() + .chain(first.edges) + .chain(second.edges) + .collect::>(); + deduplicate_nodes(&mut nodes); + deduplicate_edges(&mut edges); + resolve_cross_file(&nodes, &mut edges); + + assert!(edges.iter().any(|edge| { + edge.kind == "candidate_for" + && edge.trust == GraphTrust::Ambiguous + && edge.candidates.len() == 2 + })); + assert!(!edges.iter().any(|edge| { + edge.kind == "routes_to" + && edge.origin == GraphOrigin::Resolution + && edge.trust == GraphTrust::Inferred + })); +} + +#[test] +fn dynamic_references_remain_escape_hatches_even_with_one_named_candidate() { + let contribution = extract_source( + "src/runtime.ts", + SupportedLanguage::TypeScript, + "export function UserService() {}\nexport function resolve() { return container.resolve('UserService'); }", + ); + let mut nodes = contribution.nodes; + let mut edges = contribution.edges; + deduplicate_nodes(&mut nodes); + deduplicate_edges(&mut edges); + resolve_cross_file(&nodes, &mut edges); + + let dynamic = nodes + .iter() + .find(|node| node.kind == "dynamic_reference") + .expect("dynamic reference"); + assert_eq!(dynamic.trust, GraphTrust::Ambiguous); + let candidate = edges + .iter() + .find(|edge| edge.kind == "candidate_for" && edge.to == dynamic.id) + .expect("dynamic candidate edge"); + assert_eq!(candidate.trust, GraphTrust::Ambiguous); + assert_eq!(candidate.candidates.len(), 1); + assert!(!edges.iter().any(|edge| { + edge.origin == GraphOrigin::Resolution + && edge.trust == GraphTrust::Inferred + && edge.kind == "may_reference" + })); + + let communities = analyze_graph(&mut nodes, &edges); + let summary = crate::commands::structural_graph::analysis::summarize_graph_analysis( + &nodes, + &edges, + &communities, + ); + assert!(summary + .coverage + .gaps + .contains(&"dynamic_references:1".to_string())); + assert!(!summary.coverage.reachability_complete); +} + +#[test] +fn contract_file_extensions_are_indexed_with_source_backed_facts() { + for (path, source, kind) in [ + ( + "api/user.proto", + "message User {}\nservice Users { rpc Get (User) returns (User); }", + "protobuf_message", + ), + ( + "api/schema.graphql", + "type User { id: ID! }", + "graphql_type", + ), + ( + "api/schema.gql", + "input UserInput { id: ID! }", + "graphql_input", + ), + ] { + let contribution = extract_blob(path, source.as_bytes(), 4096); + assert_eq!(contribution.disposition, FileDisposition::Indexed, "{path}"); + let fact = contribution + .nodes + .iter() + .find(|node| node.kind == kind) + .unwrap_or_else(|| panic!("missing {kind} in {path}")); + assert_eq!(fact.trust, GraphTrust::Extracted); + assert_eq!(fact.sources[0].path, path); + assert!(fact.sources[0].start_line.is_some()); + } +} + +#[test] +fn metadata_text_files_extract_docs_links_rationale_and_configuration() { + let root = std::env::temp_dir().join(format!( + "codevetter-structural-metadata-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("fixture root"); + fs::write( + root.join("README.md"), + "# Notes\nDecision: keep parsing local\n[Architecture](docs/architecture.md)\n", + ) + .expect("readme"); + fs::write(root.join("package.json"), "{\"name\":\"fixture\"}\n").expect("package config"); + let docs = extract_metadata_path(&root, Path::new("README.md"), "README.md", 1024); + assert_eq!(docs.disposition, FileDisposition::Indexed); + assert!(docs.nodes.iter().any(|node| node.kind == "decision")); + assert!(docs + .nodes + .iter() + .any(|node| { node.kind == "documentation_link" && node.label == "docs/architecture.md" })); + let config = extract_metadata_path(&root, Path::new("package.json"), "package.json", 1024); + assert!(config.nodes.iter().any(|node| node.kind == "configuration")); + fs::remove_dir_all(root).expect("remove fixture root"); +} + +#[test] +fn duplicate_overloads_have_distinct_stable_ids() { + let source = "function parse(value: string): string;\nfunction parse(value: number): number;\nfunction parse(value: string | number) { return value; }\n"; + let first = extract_source("parse.ts", SupportedLanguage::TypeScript, source); + let second = extract_source("parse.ts", SupportedLanguage::TypeScript, source); + let ids = |contribution: &FileContribution| { + contribution + .nodes + .iter() + .filter(|node| node.label == "parse") + .map(|node| node.id.clone()) + .collect::>() + }; + let first_ids = ids(&first); + assert!(first_ids.len() >= 2); + assert_eq!(first_ids, ids(&second)); + assert_eq!( + first_ids.iter().collect::>().len(), + first_ids.len() + ); +} + +#[test] +fn malformed_unicode_and_generated_files_preserve_honest_coverage() { + let malformed = extract_source( + "broken.py", + SupportedLanguage::Python, + "def résumé(:\n pass\n", + ); + assert!(malformed + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "syntax_error")); + + let unicode = extract_source( + "unicode.py", + SupportedLanguage::Python, + "def résumé():\n return 1\n", + ); + assert!(unicode + .nodes + .iter() + .any(|node| node.label == "résumé" && node.sources[0].start_line == Some(1))); + + let generated = extract_path( + Path::new("/repo"), + Path::new("src/client.generated.ts"), + 1_024, + ); + assert_eq!(generated.disposition, FileDisposition::Generated); + assert!(generated.nodes.iter().all(|node| node.kind == "file")); + assert!(generated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "generated_file_skipped")); +} + +#[test] +fn sensitive_files_are_not_named_as_graph_nodes() { + let contribution = extract_path(Path::new("/repo"), Path::new("config/.env.local"), 100); + assert_eq!(contribution.disposition, FileDisposition::Sensitive); + assert!(contribution.nodes.is_empty()); +} + +#[test] +fn incremental_build_reuses_untouched_files_and_removes_deleted_files() { + let root = std::env::temp_dir().join(format!( + "codevetter-structural-graph-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("create fixture repo"); + run_git(&root, &["init"]); + fs::write(root.join("a.rs"), "fn alpha() {}\n").expect("a"); + fs::write(root.join("b.rs"), "fn beta() {}\n").expect("b"); + fs::write(root.join("c.rs"), "fn removed() {}\n").expect("c"); + run_git(&root, &["add", "a.rs", "b.rs", "c.rs"]); + + let engine = BundledTreeSitterEngine; + let cancellation = StructuralGraphCancellation::default(); + let progress = |_: StructuralGraphProgress| {}; + let first = engine + .build( + &StructuralGraphBuildInput::full(root.clone(), None), + &cancellation, + &progress, + ) + .expect("full build"); + let beta_id = first + .nodes + .iter() + .find(|node| node.label == "beta") + .expect("beta") + .id + .clone(); + let beta_metric_id = first + .metrics + .iter() + .find(|fact| fact.node_id == beta_id) + .expect("beta metric") + .id + .clone(); + + fs::write(root.join("a.rs"), "fn gamma() {}\n").expect("change a"); + fs::remove_file(root.join("c.rs")).expect("delete c"); + let second = engine + .build( + &StructuralGraphBuildInput { + repo_root: root.clone(), + repo_head: None, + changed_files: vec!["a.rs".to_string()], + deleted_files: vec!["c.rs".to_string()], + previous_cursor: first.cursor.clone(), + previous_snapshot: Some(Box::new(first)), + max_files: 25_000, + max_bytes_per_file: 2 * 1024 * 1024, + }, + &cancellation, + &progress, + ) + .expect("incremental build"); + + assert!(second.nodes.iter().any(|node| node.label == "gamma")); + assert!(!second.nodes.iter().any(|node| node.label == "alpha")); + assert!(!second.nodes.iter().any(|node| node.label == "removed")); + assert!(second.nodes.iter().any(|node| node.id == beta_id)); + assert!(second + .metrics + .iter() + .any(|fact| fact.id == beta_metric_id && fact.node_id == beta_id)); + assert!(second.metrics.iter().any(|fact| { + second + .nodes + .iter() + .any(|node| node.id == fact.node_id && node.label == "gamma") + })); + assert!(!second.metrics.iter().any(|fact| fact.path == "c.rs")); + assert_eq!(second.coverage.indexed_files, 2); + fs::remove_dir_all(root).expect("remove fixture repo"); +} + +#[test] +fn incremental_build_repairs_a_renamed_file_without_stale_nodes() { + let root = std::env::temp_dir().join(format!( + "codevetter-structural-graph-rename-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(root.join("src")).expect("create fixture repo"); + run_git(&root, &["init"]); + fs::write(root.join("src/old.rs"), "fn carried() {}\n").expect("old"); + run_git(&root, &["add", "src/old.rs"]); + + let engine = BundledTreeSitterEngine; + let cancellation = StructuralGraphCancellation::default(); + let progress = |_: StructuralGraphProgress| {}; + let first = engine + .build( + &StructuralGraphBuildInput::full(root.clone(), None), + &cancellation, + &progress, + ) + .expect("full build"); + fs::rename(root.join("src/old.rs"), root.join("src/new.rs")).expect("rename"); + let second = engine + .build( + &StructuralGraphBuildInput { + repo_root: root.clone(), + repo_head: None, + changed_files: vec!["src/new.rs".to_string()], + deleted_files: vec!["src/old.rs".to_string()], + previous_cursor: first.cursor.clone(), + previous_snapshot: Some(Box::new(first)), + max_files: 25_000, + max_bytes_per_file: 2 * 1024 * 1024, + }, + &cancellation, + &progress, + ) + .expect("rename refresh"); + + assert!(second + .nodes + .iter() + .any(|node| { node.label == "carried" && node.path.as_deref() == Some("src/new.rs") })); + assert!(!second + .nodes + .iter() + .any(|node| node.path.as_deref() == Some("src/old.rs"))); + assert_eq!(second.coverage.indexed_files, 1); + fs::remove_dir_all(root).expect("remove fixture repo"); +} + +#[test] +fn incremental_graph_facts_are_identical_to_a_clean_rebuild() { + let root = std::env::temp_dir().join(format!( + "codevetter-structural-equivalence-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("create fixture repo"); + run_git(&root, &["init"]); + let clone_a = "export function alpha(items: number[]) { let total = 0; for (const item of items) { if (item > 1) { total += item; } } return total; }\n"; + let clone_b = "export function beta(values: number[]) { let sum = 9; for (const value of values) { if (value > 4) { sum += value; } } return sum; }\n"; + fs::write(root.join("a.ts"), clone_a).expect("a"); + fs::write(root.join("b.ts"), clone_b).expect("b"); + run_git(&root, &["add", "a.ts", "b.ts"]); + + let engine = BundledTreeSitterEngine; + let cancellation = StructuralGraphCancellation::default(); + let progress = |_: StructuralGraphProgress| {}; + let first = engine + .build( + &StructuralGraphBuildInput::full(root.clone(), None), + &cancellation, + &progress, + ) + .expect("initial build"); + assert_eq!(first.clone_groups.len(), 1); + + let changed_a = clone_a.replace("item > 1", "item > 2"); + fs::write(root.join("a.ts"), changed_a).expect("change a"); + fs::remove_file(root.join("b.ts")).expect("delete b"); + fs::write(root.join("c.ts"), clone_b.replace("beta", "gamma")).expect("add c"); + run_git(&root, &["add", "-A"]); + let incremental = engine + .build( + &StructuralGraphBuildInput { + repo_root: root.clone(), + repo_head: None, + changed_files: vec!["a.ts".to_string(), "c.ts".to_string()], + deleted_files: vec!["b.ts".to_string()], + previous_cursor: first.cursor.clone(), + previous_snapshot: Some(Box::new(first)), + max_files: 25_000, + max_bytes_per_file: 2 * 1024 * 1024, + }, + &cancellation, + &progress, + ) + .expect("incremental build"); + let clean = engine + .build( + &StructuralGraphBuildInput::full(root.clone(), None), + &cancellation, + &progress, + ) + .expect("clean rebuild"); + + assert_eq!(incremental.files, clean.files); + assert_eq!(incremental.coverage, clean.coverage); + assert_eq!(incremental.nodes, clean.nodes); + assert_eq!(incremental.edges, clean.edges); + assert_eq!(incremental.metrics, clean.metrics); + assert_eq!(incremental.clone_groups, clean.clone_groups); + assert_eq!(incremental.cursor, clean.cursor); + fs::remove_dir_all(root).expect("remove fixture repo"); +} + +fn run_git(root: &Path, arguments: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(root) + .args(arguments) + .status() + .expect("run git"); + assert!(status.success(), "git {arguments:?}"); +} From ed1b0329d0cff1dd3ae6f32cdf880ca45a4b1434 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:05:58 +0530 Subject: [PATCH 023/141] feat(graph): query bounded structural projections --- .../src/commands/structural_graph/mod.rs | 1 + .../commands/structural_graph/query/index.rs | 154 +++++++ .../commands/structural_graph/query/limits.rs | 130 ++++++ .../commands/structural_graph/query/mod.rs | 201 +++++++++ .../structural_graph/query/projection.rs | 420 ++++++++++++++++++ .../commands/structural_graph/query/search.rs | 218 +++++++++ 6 files changed, 1124 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/index.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/limits.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/projection.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/search.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index 65cd20f..f18a815 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -5,6 +5,7 @@ pub mod interchange; pub mod language; pub mod legacy; pub(crate) mod metrics; +pub mod query; pub mod resolve; pub mod storage; pub mod types; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/index.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/index.rs new file mode 100644 index 0000000..383e822 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/index.rs @@ -0,0 +1,154 @@ +use super::*; + +pub(super) fn normalize(value: &str) -> String { + value.trim().replace('\\', "/").to_lowercase() +} + +pub(super) fn node_matches_filter(node: &StructuralGraphNode, filter: &GraphQueryFilter) -> bool { + (filter.node_kinds.is_empty() || filter.node_kinds.iter().any(|kind| kind == &node.kind)) + && (filter.trust.is_empty() || filter.trust.contains(&node.trust)) +} + +pub(super) fn edge_matches_filter(edge: &StructuralGraphEdge, filter: &GraphQueryFilter) -> bool { + (filter.edge_kinds.is_empty() || filter.edge_kinds.iter().any(|kind| kind == &edge.kind)) + && (filter.trust.is_empty() || filter.trust.contains(&edge.trust)) +} + +pub(super) fn rank_node(node: &StructuralGraphNode, needle: &str) -> Option<(u32, String)> { + let id = normalize(&node.id); + let qualified = node.qualified_name.as_deref().map(normalize); + let path = node.path.as_deref().map(normalize); + let label = normalize(&node.label); + if id == needle { + Some((0, "id".to_string())) + } else if qualified.as_deref() == Some(needle) { + Some((1, "qualified_name".to_string())) + } else if path.as_deref() == Some(needle) { + Some((2, "path".to_string())) + } else if label == needle { + Some((3, "label".to_string())) + } else if qualified + .as_deref() + .is_some_and(|value| value.contains(needle)) + { + Some((10, "qualified_name_contains".to_string())) + } else if path.as_deref().is_some_and(|value| value.contains(needle)) { + Some((11, "path_contains".to_string())) + } else if label.contains(needle) { + Some((12, "label_contains".to_string())) + } else { + None + } +} + +pub(super) fn lexical_tokens(query: &str) -> Vec { + const STOP_WORDS: &[&str] = &[ + "a", "an", "and", "are", "does", "for", "from", "how", "in", "is", "of", "on", "or", "the", + "to", "what", "when", "where", "which", "why", "with", + ]; + let mut tokens = query + .split(|character: char| { + !(character.is_alphanumeric() + || matches!(character, '_' | '-' | '.' | '/' | ':' | '\\')) + }) + .map(str::trim) + .filter(|token| token.len() >= 2 && !STOP_WORDS.contains(token)) + .map(str::to_string) + .collect::>(); + tokens.sort(); + tokens.dedup(); + tokens +} + +pub(super) fn rank_question_tokens( + node: &StructuralGraphNode, + tokens: &[String], +) -> Option<(u32, String)> { + if tokens.is_empty() { + return None; + } + let haystack = normalize(&format!( + "{} {} {} {} {}", + node.label, + node.qualified_name.as_deref().unwrap_or_default(), + node.path.as_deref().unwrap_or_default(), + node.kind, + node.detail.as_deref().unwrap_or_default() + )); + let matched = tokens + .iter() + .filter(|token| haystack.contains(token.as_str())) + .count(); + if matched == 0 { + return None; + } + let missing = tokens.len() - matched; + Some((20 + missing as u32 * 5, "lexical_question".to_string())) +} + +pub(super) fn node_map(snapshot: &StructuralGraphSnapshot) -> HashMap<&str, &StructuralGraphNode> { + snapshot + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect() +} + +pub(super) fn query_index(snapshot: &StructuralGraphSnapshot) -> Arc { + let indexes = QUERY_INDEXES.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(cache) = indexes.lock() { + if let Some(index) = cache.get(&snapshot.id) { + return Arc::clone(index); + } + } + let mut index = StructuralGraphQueryIndex::default(); + for (ordinal, node) in snapshot.nodes.iter().enumerate() { + for value in [ + Some(node.id.as_str()), + node.path.as_deref(), + node.qualified_name.as_deref(), + Some(node.label.as_str()), + ] + .into_iter() + .flatten() + { + index + .exact + .entry(normalize(value)) + .or_default() + .push(ordinal); + } + let searchable = normalize(&format!( + "{} {} {} {} {}", + node.label, + node.qualified_name.as_deref().unwrap_or_default(), + node.path.as_deref().unwrap_or_default(), + node.kind, + node.detail.as_deref().unwrap_or_default() + )); + for token in lexical_tokens(&searchable) { + index.tokens.entry(token).or_default().push(ordinal); + } + } + for postings in index.tokens.values_mut() { + postings.sort_unstable(); + postings.dedup(); + } + let index = Arc::new(index); + if let Ok(mut cache) = indexes.lock() { + if cache.len() >= MAX_QUERY_INDEXES { + cache.clear(); + } + cache.insert(snapshot.id.clone(), Arc::clone(&index)); + } + index +} + +pub(super) fn trust_cost(trust: GraphTrust) -> f64 { + match trust { + GraphTrust::Extracted => 1.0, + GraphTrust::Inferred => 1.6, + GraphTrust::Ambiguous => 3.5, + GraphTrust::Legacy => 4.0, + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/limits.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/limits.rs new file mode 100644 index 0000000..11f32af --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/limits.rs @@ -0,0 +1,130 @@ +use super::*; + +pub(super) fn bounded_limit(limit: Option) -> usize { + limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) +} + +fn serialized_bytes(value: &T) -> usize { + serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .unwrap_or(usize::MAX) +} + +fn strip_node_excerpts(node: &mut StructuralGraphNode) { + for source in &mut node.sources { + source.excerpt = None; + } +} + +fn strip_edge_excerpts(edge: &mut StructuralGraphEdge) { + for source in &mut edge.sources { + source.excerpt = None; + } +} + +pub(super) fn enforce_projection_bytes( + projection: &mut GraphProjection, + protected_node_ids: &HashSet, +) { + if serialized_bytes(projection) <= MAX_RESPONSE_BYTES { + return; + } + projection.truncated = true; + for node in &mut projection.nodes { + strip_node_excerpts(node); + } + for edge in &mut projection.edges { + strip_edge_excerpts(edge); + } + while serialized_bytes(projection) > MAX_RESPONSE_BYTES && !projection.edges.is_empty() { + projection.edges.pop(); + } + while serialized_bytes(projection) > MAX_RESPONSE_BYTES { + let Some(index) = projection + .nodes + .iter() + .rposition(|node| !protected_node_ids.contains(&node.id)) + else { + break; + }; + projection.nodes.remove(index); + } + let retained = projection + .nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + projection.edges.retain(|edge| { + retained.contains(edge.from.as_str()) && retained.contains(edge.to.as_str()) + }); +} + +pub(super) fn enforce_search_bytes(result: &mut GraphSearchResult, offset: usize, total: usize) { + if serialized_bytes(result) <= MAX_RESPONSE_BYTES { + return; + } + result.truncated = true; + for hit in &mut result.hits { + strip_node_excerpts(&mut hit.node); + } + while serialized_bytes(result) > MAX_RESPONSE_BYTES && !result.hits.is_empty() { + result.hits.pop(); + } + let next_offset = offset + result.hits.len(); + result.next_cursor = (next_offset < total).then(|| next_offset.to_string()); +} + +pub(super) fn enforce_path_bytes(result: &mut GraphPathResult) -> Result<(), String> { + if serialized_bytes(result) <= MAX_RESPONSE_BYTES { + return Ok(()); + } + for node in &mut result.nodes { + strip_node_excerpts(node); + } + for edge in &mut result.edges { + strip_edge_excerpts(edge); + } + if serialized_bytes(result) > MAX_RESPONSE_BYTES { + return Err(format!( + "Graph path exceeds the {MAX_RESPONSE_BYTES}-byte response limit" + )); + } + result.truncated = true; + Ok(()) +} + +pub(super) fn enforce_impact_bytes(result: &mut GraphImpactResult) { + if serialized_bytes(result) <= MAX_RESPONSE_BYTES { + return; + } + result.truncated = true; + strip_node_excerpts(&mut result.root); + for node in &mut result.affected { + strip_node_excerpts(node); + } + for edge in &mut result.edges { + strip_edge_excerpts(edge); + } + while serialized_bytes(result) > MAX_RESPONSE_BYTES && !result.edges.is_empty() { + result.edges.pop(); + } + while serialized_bytes(result) > MAX_RESPONSE_BYTES && !result.affected.is_empty() { + result.affected.pop(); + } + let retained = result + .affected + .iter() + .map(|node| node.id.as_str()) + .chain(std::iter::once(result.root.id.as_str())) + .collect::>(); + result.edges.retain(|edge| { + retained.contains(edge.from.as_str()) && retained.contains(edge.to.as_str()) + }); +} + +pub(super) fn parse_cursor(cursor: Option<&str>) -> Result { + cursor + .unwrap_or("0") + .parse::() + .map_err(|_| "Graph cursor is invalid or expired".to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs new file mode 100644 index 0000000..e77616a --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs @@ -0,0 +1,201 @@ +use super::analysis::{summarize_graph_analysis_with_context, StructuralGraphAnalysisSummary}; +use super::types::{ + GraphTrust, StructuralGraphCoverage, StructuralGraphEdge, StructuralGraphNode, + StructuralGraphSnapshot, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +const DEFAULT_LIMIT: usize = 50; +const MAX_LIMIT: usize = 500; +const MAX_EDGE_LIMIT: usize = 2_000; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const MAX_PATH_HOPS: usize = 32; +const MAX_DIFF_IDS: usize = 500; +const MAX_QUERY_INDEXES: usize = 16; + +#[derive(Debug, Default)] +struct StructuralGraphQueryIndex { + exact: HashMap>, + tokens: HashMap>, +} + +static QUERY_INDEXES: OnceLock>>> = + OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum GraphDirection { + Incoming, + Outgoing, + #[default] + Both, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GraphQueryFilter { + #[serde(default)] + pub node_kinds: Vec, + #[serde(default)] + pub edge_kinds: Vec, + #[serde(default)] + pub trust: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphSearchHit { + pub node: StructuralGraphNode, + pub score: u32, + pub matched_by: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphSearchResult { + pub hits: Vec, + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphExplanation { + pub node: StructuralGraphNode, + pub incoming_count: usize, + pub outgoing_count: usize, + pub incoming_kinds: Vec, + pub outgoing_kinds: Vec, + pub truncated: bool, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphProjection { + pub nodes: Vec, + pub edges: Vec, + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphPathResult { + pub nodes: Vec, + pub edges: Vec, + pub total_cost: f64, + pub visited: usize, + pub truncated: bool, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphImpactResult { + pub root: StructuralGraphNode, + pub affected: Vec, + pub edges: Vec, + pub depth_reached: usize, + pub truncated: bool, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphSnapshotDiff { + pub before_snapshot_id: String, + pub after_snapshot_id: String, + pub added_node_ids: Vec, + pub removed_node_ids: Vec, + pub changed_node_ids: Vec, + pub added_edge_ids: Vec, + pub removed_edge_ids: Vec, + pub changed_edge_ids: Vec, + pub truncated: bool, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphAnalysisResult { + #[serde(flatten)] + pub analysis: StructuralGraphAnalysisSummary, + pub truncated: bool, + pub context: GraphQueryContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct GraphTrustSummary { + pub extracted: usize, + pub inferred: usize, + pub ambiguous: usize, + pub legacy: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GraphFreshness { + pub indexed_head: Option, + pub current_head: Option, + /// `None` means the caller did not provide a live repository HEAD. + pub stale: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GraphQueryContext { + pub snapshot_id: String, + pub schema_version: i64, + pub engine_id: String, + pub engine_version: String, + pub created_at: String, + pub freshness: GraphFreshness, + pub coverage: StructuralGraphCoverage, + pub trust: GraphTrustSummary, + pub max_results: usize, + pub max_edges: usize, + pub max_hops: usize, + pub max_bytes: usize, +} + +impl GraphQueryContext { + pub fn observe_current_head(&mut self, current_head: Option) { + self.freshness.stale = current_head + .as_ref() + .map(|head| self.freshness.indexed_head.as_ref() != Some(head)); + self.freshness.current_head = current_head; + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructuralGraphMetadata { + pub snapshot_id: String, + pub schema_version: i64, + pub repo_path: String, + pub repo_head: Option, + pub created_at: String, + pub engine_id: String, + pub engine_version: String, + pub indexed_files: usize, + pub node_count: usize, + pub edge_count: usize, + pub diagnostic_count: usize, + pub coverage: StructuralGraphCoverage, + pub trust: Option, + pub freshness: GraphFreshness, + pub truncated: bool, +} + +mod index; +mod limits; +mod projection; +mod search; + +use index::{ + edge_matches_filter, lexical_tokens, node_map, node_matches_filter, normalize, query_index, + rank_node, rank_question_tokens, +}; +use limits::{bounded_limit, enforce_projection_bytes, enforce_search_bytes, parse_cursor}; +use projection::query_context; + +pub use projection::{ + analysis, analysis_summary, community, community_page, diff_snapshots, metadata, overview, + overview_page, subgraph, +}; +pub use search::{explain, neighbors, resolve_node, search, search_page}; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/projection.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/projection.rs new file mode 100644 index 0000000..3189851 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/projection.rs @@ -0,0 +1,420 @@ +use super::*; + +pub fn metadata(snapshot: &StructuralGraphSnapshot) -> StructuralGraphMetadata { + StructuralGraphMetadata { + snapshot_id: snapshot.id.clone(), + schema_version: snapshot.schema_version, + repo_path: snapshot.repo_path.clone(), + repo_head: snapshot.repo_head.clone(), + created_at: snapshot.created_at.clone(), + engine_id: snapshot.engine.id.clone(), + engine_version: snapshot.engine.version.clone(), + indexed_files: snapshot.coverage.indexed_files, + node_count: snapshot.nodes.len(), + edge_count: snapshot.edges.len(), + diagnostic_count: snapshot.diagnostics.len(), + coverage: snapshot.coverage.clone(), + trust: Some(trust_summary(snapshot)), + freshness: GraphFreshness { + indexed_head: snapshot.repo_head.clone(), + current_head: None, + stale: None, + }, + truncated: snapshot.truncated, + } +} + +pub(super) fn query_context(snapshot: &StructuralGraphSnapshot) -> GraphQueryContext { + GraphQueryContext { + snapshot_id: snapshot.id.clone(), + schema_version: snapshot.schema_version, + engine_id: snapshot.engine.id.clone(), + engine_version: snapshot.engine.version.clone(), + created_at: snapshot.created_at.clone(), + freshness: GraphFreshness { + indexed_head: snapshot.repo_head.clone(), + current_head: None, + stale: None, + }, + coverage: snapshot.coverage.clone(), + trust: trust_summary(snapshot), + max_results: MAX_LIMIT, + max_edges: MAX_EDGE_LIMIT, + max_hops: MAX_PATH_HOPS, + max_bytes: MAX_RESPONSE_BYTES, + } +} + +fn trust_summary(snapshot: &StructuralGraphSnapshot) -> GraphTrustSummary { + let mut summary = GraphTrustSummary::default(); + for trust in snapshot + .nodes + .iter() + .map(|node| node.trust) + .chain(snapshot.edges.iter().map(|edge| edge.trust)) + { + match trust { + GraphTrust::Extracted => summary.extracted += 1, + GraphTrust::Inferred => summary.inferred += 1, + GraphTrust::Ambiguous => summary.ambiguous += 1, + GraphTrust::Legacy => summary.legacy += 1, + } + } + summary +} + +pub fn analysis(snapshot: &StructuralGraphSnapshot) -> GraphAnalysisResult { + GraphAnalysisResult { + analysis: analysis_summary(snapshot), + truncated: snapshot.truncated, + context: query_context(snapshot), + } +} + +pub fn analysis_summary(snapshot: &StructuralGraphSnapshot) -> StructuralGraphAnalysisSummary { + summarize_graph_analysis_with_context( + &snapshot.nodes, + &snapshot.edges, + &snapshot.communities, + &snapshot.coverage, + snapshot.truncated, + ) +} + +pub fn overview(snapshot: &StructuralGraphSnapshot, limit: Option) -> GraphProjection { + overview_page(snapshot, limit, None).expect("default graph cursor is valid") +} + +pub fn overview_page( + snapshot: &StructuralGraphSnapshot, + limit: Option, + cursor: Option<&str>, +) -> Result { + let limit = bounded_limit(limit); + let offset = parse_cursor(cursor)?; + let mut degree: HashMap<&str, usize> = HashMap::new(); + for edge in &snapshot.edges { + *degree.entry(&edge.from).or_default() += 1; + *degree.entry(&edge.to).or_default() += 1; + } + let mut ranked = snapshot.nodes.iter().collect::>(); + ranked.sort_by(|left, right| { + degree + .get(right.id.as_str()) + .copied() + .unwrap_or_default() + .cmp(°ree.get(left.id.as_str()).copied().unwrap_or_default()) + .then_with(|| left.id.cmp(&right.id)) + }); + if offset > ranked.len() { + return Err("Graph cursor is invalid or expired".to_string()); + } + let page = ranked + .iter() + .skip(offset) + .take(limit) + .copied() + .collect::>(); + let next_offset = offset + page.len(); + let selected = page + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut nodes = page.into_iter().cloned().collect::>(); + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let mut edges = snapshot + .edges + .iter() + .filter(|edge| selected.contains(edge.from.as_str()) && selected.contains(edge.to.as_str())) + .take(MAX_EDGE_LIMIT) + .cloned() + .collect::>(); + edges.sort_by(|left, right| left.id.cmp(&right.id)); + let edge_truncated = snapshot + .edges + .iter() + .filter(|edge| selected.contains(edge.from.as_str()) && selected.contains(edge.to.as_str())) + .count() + > edges.len(); + let mut projection = GraphProjection { + nodes, + edges, + truncated: next_offset < ranked.len() || edge_truncated, + next_cursor: (next_offset < ranked.len()).then(|| next_offset.to_string()), + context: query_context(snapshot), + }; + enforce_projection_bytes(&mut projection, &HashSet::new()); + Ok(projection) +} + +pub fn community( + snapshot: &StructuralGraphSnapshot, + community_id: &str, + limit: Option, +) -> Result { + community_page(snapshot, community_id, limit, None) +} + +pub fn community_page( + snapshot: &StructuralGraphSnapshot, + community_id: &str, + limit: Option, + cursor: Option<&str>, +) -> Result { + if !snapshot + .communities + .iter() + .any(|community| community.id == community_id) + { + return Err(format!("No graph community matches '{community_id}'")); + } + let limit = bounded_limit(limit); + let offset = parse_cursor(cursor)?; + let mut degree: HashMap<&str, usize> = HashMap::new(); + for edge in &snapshot.edges { + *degree.entry(&edge.from).or_default() += 1; + *degree.entry(&edge.to).or_default() += 1; + } + let mut members = snapshot + .nodes + .iter() + .filter(|node| node.community_id.as_deref() == Some(community_id)) + .collect::>(); + members.sort_by(|left, right| { + degree + .get(right.id.as_str()) + .copied() + .unwrap_or_default() + .cmp(°ree.get(left.id.as_str()).copied().unwrap_or_default()) + .then_with(|| left.id.cmp(&right.id)) + }); + if offset > members.len() { + return Err("Graph cursor is invalid or expired".to_string()); + } + let total_members = members.len(); + let members = members + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + let next_offset = offset + members.len(); + let selected = members + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut nodes = members.into_iter().cloned().collect::>(); + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let mut edges = snapshot + .edges + .iter() + .filter(|edge| selected.contains(edge.from.as_str()) && selected.contains(edge.to.as_str())) + .take(MAX_EDGE_LIMIT) + .cloned() + .collect::>(); + edges.sort_by(|left, right| left.id.cmp(&right.id)); + let edge_truncated = snapshot + .edges + .iter() + .filter(|edge| selected.contains(edge.from.as_str()) && selected.contains(edge.to.as_str())) + .count() + > edges.len(); + let mut projection = GraphProjection { + nodes, + edges, + truncated: next_offset < total_members || edge_truncated, + next_cursor: (next_offset < total_members).then(|| next_offset.to_string()), + context: query_context(snapshot), + }; + enforce_projection_bytes(&mut projection, &HashSet::new()); + Ok(projection) +} + +pub fn subgraph( + snapshot: &StructuralGraphSnapshot, + seeds: &[String], + depth: Option, + filter: &GraphQueryFilter, + limit: Option, +) -> Result { + if seeds.is_empty() { + return Err("At least one graph seed is required".to_string()); + } + let max_depth = depth.unwrap_or(2).clamp(0, 8); + let limit = bounded_limit(limit); + let roots = seeds + .iter() + .map(|seed| resolve_node(snapshot, seed)) + .collect::, _>>()?; + let mut adjacency = HashMap::<&str, Vec<&StructuralGraphEdge>>::new(); + for edge in snapshot + .edges + .iter() + .filter(|edge| edge_matches_filter(edge, filter)) + { + adjacency.entry(edge.from.as_str()).or_default().push(edge); + adjacency.entry(edge.to.as_str()).or_default().push(edge); + } + for edges in adjacency.values_mut() { + edges.sort_by(|left, right| left.id.cmp(&right.id)); + } + let mut queue = roots + .iter() + .map(|node| (node.id.as_str(), 0_usize)) + .collect::>(); + let mut selected = roots + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut selected_edges = HashSet::new(); + let mut truncated = false; + while let Some((node_id, current_depth)) = queue.pop_front() { + if current_depth >= max_depth { + continue; + } + for edge in adjacency.get(node_id).into_iter().flatten() { + let neighbor = if edge.from == node_id { + edge.to.as_str() + } else { + edge.from.as_str() + }; + if selected.len() >= limit && !selected.contains(neighbor) { + truncated = true; + continue; + } + selected_edges.insert(edge.id.as_str()); + if selected.insert(neighbor) { + queue.push_back((neighbor, current_depth + 1)); + } + } + } + let mut nodes = snapshot + .nodes + .iter() + .filter(|node| selected.contains(node.id.as_str())) + .filter(|node| { + node_matches_filter(node, filter) || roots.iter().any(|root| root.id == node.id) + }) + .cloned() + .collect::>(); + let retained = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut edges = snapshot + .edges + .iter() + .filter(|edge| selected_edges.contains(edge.id.as_str())) + .filter(|edge| retained.contains(edge.from.as_str()) && retained.contains(edge.to.as_str())) + .take(MAX_EDGE_LIMIT) + .cloned() + .collect::>(); + if selected_edges.len() > edges.len() { + truncated = true; + } + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + edges.sort_by(|left, right| left.id.cmp(&right.id)); + let protected = roots + .iter() + .map(|root| root.id.clone()) + .collect::>(); + let mut projection = GraphProjection { + nodes, + edges, + truncated, + next_cursor: None, + context: query_context(snapshot), + }; + enforce_projection_bytes(&mut projection, &protected); + Ok(projection) +} + +pub fn diff_snapshots( + before: &StructuralGraphSnapshot, + after: &StructuralGraphSnapshot, +) -> GraphSnapshotDiff { + let before_nodes = before + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let after_nodes = after + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let before_edges = before + .edges + .iter() + .map(|edge| (edge.id.as_str(), edge)) + .collect::>(); + let after_edges = after + .edges + .iter() + .map(|edge| (edge.id.as_str(), edge)) + .collect::>(); + let (mut added_node_ids, mut removed_node_ids, mut changed_node_ids) = + diff_identity_maps(&before_nodes, &after_nodes); + let (mut added_edge_ids, mut removed_edge_ids, mut changed_edge_ids) = + diff_identity_maps(&before_edges, &after_edges); + let truncated = [ + added_node_ids.len(), + removed_node_ids.len(), + changed_node_ids.len(), + added_edge_ids.len(), + removed_edge_ids.len(), + changed_edge_ids.len(), + ] + .into_iter() + .any(|count| count > MAX_DIFF_IDS); + for ids in [ + &mut added_node_ids, + &mut removed_node_ids, + &mut changed_node_ids, + &mut added_edge_ids, + &mut removed_edge_ids, + &mut changed_edge_ids, + ] { + ids.truncate(MAX_DIFF_IDS); + } + GraphSnapshotDiff { + before_snapshot_id: before.id.clone(), + after_snapshot_id: after.id.clone(), + added_node_ids, + removed_node_ids, + changed_node_ids, + added_edge_ids, + removed_edge_ids, + changed_edge_ids, + truncated, + context: query_context(after), + } +} + +fn diff_identity_maps( + before: &HashMap<&str, &T>, + after: &HashMap<&str, &T>, +) -> (Vec, Vec, Vec) { + let mut added = after + .keys() + .filter(|id| !before.contains_key(**id)) + .map(|id| (*id).to_string()) + .collect::>(); + let mut removed = before + .keys() + .filter(|id| !after.contains_key(**id)) + .map(|id| (*id).to_string()) + .collect::>(); + let mut changed = after + .iter() + .filter_map(|(id, value)| { + before + .get(id) + .filter(|before| *before != value) + .map(|_| (*id).to_string()) + }) + .collect::>(); + added.sort(); + removed.sort(); + changed.sort(); + (added, removed, changed) +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/search.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/search.rs new file mode 100644 index 0000000..cfb3455 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/search.rs @@ -0,0 +1,218 @@ +use super::*; + +pub fn search( + snapshot: &StructuralGraphSnapshot, + query: &str, + filter: &GraphQueryFilter, + limit: Option, +) -> GraphSearchResult { + search_page(snapshot, query, filter, limit, None).expect("default graph cursor is valid") +} + +pub fn search_page( + snapshot: &StructuralGraphSnapshot, + query: &str, + filter: &GraphQueryFilter, + limit: Option, + cursor: Option<&str>, +) -> Result { + let needle = normalize(query); + if needle.is_empty() { + return Ok(GraphSearchResult { + hits: Vec::new(), + truncated: false, + next_cursor: None, + context: query_context(snapshot), + }); + } + + let tokens = lexical_tokens(&needle); + let index = query_index(snapshot); + let mut candidate_indices = HashSet::new(); + if let Some(exact) = index.exact.get(&needle) { + candidate_indices.extend(exact.iter().copied()); + } + for token in &tokens { + if let Some(postings) = index.tokens.get(token) { + candidate_indices.extend(postings.iter().copied()); + } + } + let candidates = if candidate_indices.is_empty() { + (0..snapshot.nodes.len()).collect::>() + } else { + let mut candidates = candidate_indices.into_iter().collect::>(); + candidates.sort_unstable(); + candidates + }; + let mut hits = candidates + .into_iter() + .filter_map(|index| snapshot.nodes.get(index)) + .filter(|node| node_matches_filter(node, filter)) + .filter_map(|node| { + rank_node(node, &needle) + .or_else(|| rank_question_tokens(node, &tokens)) + .map(|(score, matched_by)| (node, score, matched_by)) + }) + .collect::>(); + hits.sort_by(|(left_node, left_score, _), (right_node, right_score, _)| { + left_score + .cmp(right_score) + .then_with(|| left_node.label.cmp(&right_node.label)) + .then_with(|| left_node.id.cmp(&right_node.id)) + }); + + let offset = parse_cursor(cursor)?; + if offset > hits.len() { + return Err("Graph cursor is invalid or expired".to_string()); + } + let limit = bounded_limit(limit); + let total = hits.len(); + let page = hits + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + let next_offset = offset + page.len(); + let mut result = GraphSearchResult { + hits: page + .into_iter() + .map(|(node, score, matched_by)| GraphSearchHit { + node: node.clone(), + score, + matched_by, + }) + .collect(), + truncated: next_offset < total, + next_cursor: (next_offset < total).then(|| next_offset.to_string()), + context: query_context(snapshot), + }; + enforce_search_bytes(&mut result, offset, total); + Ok(result) +} + +pub fn resolve_node<'a>( + snapshot: &'a StructuralGraphSnapshot, + reference: &str, +) -> Result<&'a StructuralGraphNode, String> { + let needle = normalize(reference); + if needle.is_empty() { + return Err("A node id, qualified name, path, or label is required".to_string()); + } + + let index = query_index(snapshot); + let candidates = index + .exact + .get(&needle) + .cloned() + .unwrap_or_else(|| (0..snapshot.nodes.len()).collect()); + for exact_score in 0..=3 { + let matches = candidates + .iter() + .filter_map(|index| snapshot.nodes.get(*index)) + .filter(|node| rank_node(node, &needle).is_some_and(|(score, _)| score == exact_score)) + .collect::>(); + match matches.len() { + 0 => continue, + 1 => return Ok(matches[0]), + count => { + return Err(format!( + "Node reference is ambiguous ({count} matches); use the stable node id" + )) + } + } + } + Err(format!("No graph node matches '{reference}'")) +} + +pub fn explain( + snapshot: &StructuralGraphSnapshot, + reference: &str, +) -> Result { + let node = resolve_node(snapshot, reference)?; + let mut incoming_kinds = HashSet::new(); + let mut outgoing_kinds = HashSet::new(); + let mut incoming_count = 0; + let mut outgoing_count = 0; + for edge in &snapshot.edges { + if edge.to == node.id { + incoming_count += 1; + incoming_kinds.insert(edge.kind.clone()); + } + if edge.from == node.id { + outgoing_count += 1; + outgoing_kinds.insert(edge.kind.clone()); + } + } + let mut incoming_kinds = incoming_kinds.into_iter().collect::>(); + let mut outgoing_kinds = outgoing_kinds.into_iter().collect::>(); + incoming_kinds.sort(); + outgoing_kinds.sort(); + Ok(GraphExplanation { + node: node.clone(), + incoming_count, + outgoing_count, + incoming_kinds, + outgoing_kinds, + truncated: false, + context: query_context(snapshot), + }) +} + +pub fn neighbors( + snapshot: &StructuralGraphSnapshot, + reference: &str, + direction: GraphDirection, + filter: &GraphQueryFilter, + limit: Option, + cursor: Option<&str>, +) -> Result { + let root = resolve_node(snapshot, reference)?; + let node_by_id = node_map(snapshot); + let mut edges = snapshot + .edges + .iter() + .filter(|edge| edge_matches_filter(edge, filter)) + .filter(|edge| match direction { + GraphDirection::Incoming => edge.to == root.id, + GraphDirection::Outgoing => edge.from == root.id, + GraphDirection::Both => edge.from == root.id || edge.to == root.id, + }) + .collect::>(); + edges.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.id.cmp(&right.id)) + }); + + let offset = parse_cursor(cursor)?; + let limit = bounded_limit(limit); + let page = edges + .iter() + .skip(offset) + .take(limit) + .copied() + .collect::>(); + let truncated = offset + page.len() < edges.len(); + let mut node_ids = HashSet::from([root.id.as_str()]); + for edge in &page { + node_ids.insert(edge.from.as_str()); + node_ids.insert(edge.to.as_str()); + } + let mut nodes = node_ids + .into_iter() + .filter_map(|id| node_by_id.get(id).copied()) + .filter(|node| node_matches_filter(node, filter) || node.id == root.id) + .cloned() + .collect::>(); + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let protected = HashSet::from([root.id.clone()]); + let mut projection = GraphProjection { + nodes, + edges: page.into_iter().cloned().collect(), + truncated, + next_cursor: truncated.then(|| (offset + limit).to_string()), + context: query_context(snapshot), + }; + enforce_projection_bytes(&mut projection, &protected); + Ok(projection) +} From 655b36920cfa4cc74419d20ae4c1ac6e9c4383d8 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:06:58 +0530 Subject: [PATCH 024/141] feat(graph): traverse structural paths and impact --- .../commands/structural_graph/query/mod.rs | 18 +- .../structural_graph/query/path_visit.rs | 36 +++ .../commands/structural_graph/query/tests.rs | 250 ++++++++++++++++++ .../structural_graph/query/traversal.rs | 235 ++++++++++++++++ 4 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/path_visit.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/tests.rs create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/query/traversal.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs index e77616a..40e3325 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs @@ -4,7 +4,8 @@ use super::types::{ StructuralGraphSnapshot, }; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex, OnceLock}; const DEFAULT_LIMIT: usize = 50; @@ -12,6 +13,7 @@ const MAX_LIMIT: usize = 500; const MAX_EDGE_LIMIT: usize = 2_000; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; const MAX_PATH_HOPS: usize = 32; +const MAX_PATH_VISITS: usize = 25_000; const MAX_DIFF_IDS: usize = 500; const MAX_QUERY_INDEXES: usize = 16; @@ -184,14 +186,20 @@ pub struct StructuralGraphMetadata { mod index; mod limits; +mod path_visit; mod projection; mod search; +mod traversal; use index::{ edge_matches_filter, lexical_tokens, node_map, node_matches_filter, normalize, query_index, - rank_node, rank_question_tokens, + rank_node, rank_question_tokens, trust_cost, }; -use limits::{bounded_limit, enforce_projection_bytes, enforce_search_bytes, parse_cursor}; +use limits::{ + bounded_limit, enforce_impact_bytes, enforce_path_bytes, enforce_projection_bytes, + enforce_search_bytes, parse_cursor, +}; +use path_visit::PathVisit; use projection::query_context; pub use projection::{ @@ -199,3 +207,7 @@ pub use projection::{ overview_page, subgraph, }; pub use search::{explain, neighbors, resolve_node, search, search_page}; +pub use traversal::{impact, shortest_path}; + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/path_visit.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/path_visit.rs new file mode 100644 index 0000000..69442bf --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/path_visit.rs @@ -0,0 +1,36 @@ +use super::*; + +#[derive(Debug)] +pub(super) struct PathVisit { + pub(super) node_id: String, + pub(super) cost: f64, +} + +impl PathVisit { + pub(super) fn new(node_id: String, cost: f64) -> Self { + Self { node_id, cost } + } +} + +impl PartialEq for PathVisit { + fn eq(&self, other: &Self) -> bool { + self.node_id == other.node_id && self.cost == other.cost + } +} + +impl Eq for PathVisit {} + +impl PartialOrd for PathVisit { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PathVisit { + fn cmp(&self, other: &Self) -> Ordering { + other + .cost + .total_cmp(&self.cost) + .then_with(|| other.node_id.cmp(&self.node_id)) + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/tests.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/tests.rs new file mode 100644 index 0000000..f8db2c2 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/tests.rs @@ -0,0 +1,250 @@ +use super::*; +use crate::commands::structural_graph::types::{ + GraphOrigin, StructuralGraphCommunity, StructuralGraphCoverage, StructuralGraphEngineInfo, +}; + +fn node(id: &str, label: &str, path: &str) -> StructuralGraphNode { + StructuralGraphNode { + id: id.to_string(), + kind: "function".to_string(), + label: label.to_string(), + qualified_name: Some(format!("{path}::{label}")), + path: Some(path.to_string()), + detail: None, + language: Some("rust".to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + } +} + +fn snapshot() -> StructuralGraphSnapshot { + let nodes = vec![ + node("node:a", "start", "src/a.rs"), + node("node:b", "middle", "src/b.rs"), + node("node:c", "finish", "src/c.rs"), + node("node:d", "start", "tests/a.rs"), + ]; + let edge = |id: &str, from: &str, to: &str, trust| StructuralGraphEdge { + id: id.to_string(), + from: from.to_string(), + to: to.to_string(), + kind: "calls".to_string(), + evidence: "test".to_string(), + trust, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + candidates: Vec::new(), + }; + StructuralGraphSnapshot { + schema_version: 3, + id: "snapshot".to_string(), + repo_path: "/repo".to_string(), + repo_head: Some("head".to_string()), + created_at: "now".to_string(), + engine: StructuralGraphEngineInfo { + id: "engine".to_string(), + version: "1".to_string(), + bundled: true, + syntax_aware: true, + supported_languages: Vec::new(), + }, + cursor: None, + ignore_fingerprint: None, + coverage: StructuralGraphCoverage::default(), + diagnostics: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + nodes, + edges: vec![ + edge("edge:ab", "node:a", "node:b", GraphTrust::Extracted), + edge("edge:bc", "node:b", "node:c", GraphTrust::Extracted), + edge("edge:ac", "node:a", "node:c", GraphTrust::Ambiguous), + ], + metrics: Vec::new(), + clone_groups: Vec::new(), + truncated: false, + } +} + +#[test] +fn search_prefers_exact_stable_identifier() { + let result = search( + &snapshot(), + "node:a", + &GraphQueryFilter::default(), + Some(10), + ); + assert_eq!(result.hits[0].node.id, "node:a"); + assert_eq!(result.hits[0].matched_by, "id"); +} + +#[test] +fn search_seeds_natural_language_questions_without_stop_words() { + let result = search( + &snapshot(), + "where is the finish function?", + &GraphQueryFilter::default(), + Some(10), + ); + assert_eq!(result.hits[0].node.id, "node:c"); + assert_eq!(result.hits[0].matched_by, "lexical_question"); +} + +#[test] +fn search_pages_are_stable_and_carry_query_context() { + let mut graph = snapshot(); + for index in 0..6 { + graph.nodes.push(node( + &format!("node:page:{index}"), + &format!("paged target {index}"), + &format!("src/page-{index}.rs"), + )); + } + graph.coverage.discovered_files = 10; + graph.coverage.indexed_files = 10; + + let first = search_page( + &graph, + "paged target", + &GraphQueryFilter::default(), + Some(2), + None, + ) + .expect("first page"); + let second = search_page( + &graph, + "paged target", + &GraphQueryFilter::default(), + Some(2), + first.next_cursor.as_deref(), + ) + .expect("second page"); + + assert_eq!(first.hits.len(), 2); + assert_eq!(second.hits.len(), 2); + assert!(first.truncated); + assert!(first.next_cursor.is_some()); + assert!(first + .hits + .iter() + .all(|hit| second.hits.iter().all(|other| other.node.id != hit.node.id))); + assert_eq!(first.context.snapshot_id, "snapshot"); + assert_eq!(first.context.coverage.indexed_files, 10); + assert!(first.context.trust.extracted > 0); + assert_eq!(first.context.freshness.stale, None); +} + +#[test] +fn overview_is_bounded_and_prefers_connected_nodes() { + let result = overview(&snapshot(), Some(2)); + assert_eq!(result.nodes.len(), 2); + assert!(result.nodes.iter().any(|node| node.id == "node:a")); + assert!(result.nodes.iter().any(|node| node.id == "node:b")); + assert!(result.truncated); + assert_eq!(result.next_cursor.as_deref(), Some("2")); + assert_eq!(result.context.max_edges, MAX_EDGE_LIMIT); +} + +#[test] +fn community_projection_is_bounded_and_rejects_unknown_ids() { + let mut snapshot = snapshot(); + snapshot.communities = vec![StructuralGraphCommunity { + id: "community:core".to_string(), + label: "core".to_string(), + member_count: 3, + hub_node_ids: vec!["node:b".to_string()], + bridge_node_ids: Vec::new(), + score: 4.0, + }]; + for node in snapshot.nodes.iter_mut().take(3) { + node.community_id = Some("community:core".to_string()); + } + let result = community(&snapshot, "community:core", Some(2)).unwrap(); + assert_eq!(result.nodes.len(), 2); + assert!(result.truncated); + assert!(community(&snapshot, "community:missing", Some(2)).is_err()); +} + +#[test] +fn filtered_multi_seed_subgraph_and_snapshot_diff_are_deterministic() { + let snapshot = snapshot(); + let projection = subgraph( + &snapshot, + &["node:a".to_string(), "node:c".to_string()], + Some(1), + &GraphQueryFilter { + trust: vec![GraphTrust::Extracted], + ..GraphQueryFilter::default() + }, + Some(10), + ) + .unwrap(); + assert_eq!( + projection + .edges + .iter() + .map(|edge| edge.id.as_str()) + .collect::>(), + vec!["edge:ab", "edge:bc"] + ); + + let mut after = snapshot.clone(); + after.id = "snapshot:after".to_string(); + after.nodes[0].detail = Some("changed".to_string()); + after.nodes.pop(); + after.nodes.push(node("node:new", "new", "src/new.rs")); + after.edges.pop(); + let diff = diff_snapshots(&snapshot, &after); + assert_eq!(diff.added_node_ids, vec!["node:new"]); + assert_eq!(diff.removed_node_ids, vec!["node:d"]); + assert_eq!(diff.changed_node_ids, vec!["node:a"]); + assert_eq!(diff.removed_edge_ids, vec!["edge:ac"]); +} + +#[test] +fn ambiguous_labels_require_a_stable_identifier() { + assert!(resolve_node(&snapshot(), "start") + .unwrap_err() + .contains("ambiguous")); +} + +#[test] +fn path_prefers_extracted_edges_over_ambiguous_shortcuts() { + let result = shortest_path( + &snapshot(), + "node:a", + "node:c", + &GraphQueryFilter::default(), + ) + .unwrap(); + assert_eq!(result.edges.len(), 2); + assert_eq!(result.edges[0].id, "edge:ab"); +} + +#[test] +fn impact_walks_reverse_callers_with_a_bound() { + let result = impact( + &snapshot(), + "node:c", + GraphDirection::Incoming, + Some(3), + &GraphQueryFilter::default(), + Some(1), + ) + .unwrap(); + assert_eq!(result.affected.len(), 1); + assert!(result.truncated); + + let downstream = impact( + &snapshot(), + "node:a", + GraphDirection::Outgoing, + Some(1), + &GraphQueryFilter::default(), + Some(10), + ) + .unwrap(); + assert!(downstream.affected.iter().any(|node| node.id == "node:b")); +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/traversal.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/traversal.rs new file mode 100644 index 0000000..adcb5ba --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/traversal.rs @@ -0,0 +1,235 @@ +use super::*; + +pub fn shortest_path( + snapshot: &StructuralGraphSnapshot, + from: &str, + to: &str, + filter: &GraphQueryFilter, +) -> Result { + let start = resolve_node(snapshot, from)?; + let target = resolve_node(snapshot, to)?; + if start.id == target.id { + return Ok(GraphPathResult { + nodes: vec![start.clone()], + edges: Vec::new(), + total_cost: 0.0, + visited: 1, + truncated: false, + context: query_context(snapshot), + }); + } + + let mut adjacency: HashMap<&str, Vec<&StructuralGraphEdge>> = HashMap::new(); + let mut degree: HashMap<&str, usize> = HashMap::new(); + for edge in snapshot + .edges + .iter() + .filter(|edge| edge_matches_filter(edge, filter)) + { + adjacency.entry(&edge.from).or_default().push(edge); + *degree.entry(&edge.from).or_default() += 1; + *degree.entry(&edge.to).or_default() += 1; + } + for edges in adjacency.values_mut() { + edges.sort_by(|left, right| left.id.cmp(&right.id)); + } + + let mut heap = BinaryHeap::new(); + heap.push(PathVisit::new(start.id.clone(), 0.0)); + let mut distance = HashMap::from([(start.id.clone(), 0.0)]); + let mut previous: HashMap = HashMap::new(); + let mut visited = 0; + while let Some(current) = heap.pop() { + if visited >= MAX_PATH_VISITS { + break; + } + visited += 1; + if current.node_id == target.id { + break; + } + if current.cost > *distance.get(¤t.node_id).unwrap_or(&f64::INFINITY) { + continue; + } + for edge in adjacency + .get(current.node_id.as_str()) + .into_iter() + .flatten() + { + let hub_penalty = degree.get(edge.to.as_str()).copied().unwrap_or(0) as f64 * 0.002; + let next_cost = current.cost + trust_cost(edge.trust) + hub_penalty; + if next_cost < *distance.get(&edge.to).unwrap_or(&f64::INFINITY) { + distance.insert(edge.to.clone(), next_cost); + previous.insert(edge.to.clone(), (current.node_id.clone(), edge.id.clone())); + heap.push(PathVisit::new(edge.to.clone(), next_cost)); + } + } + } + + let total_cost = distance.get(&target.id).copied().ok_or_else(|| { + format!( + "No directed graph path connects '{}' to '{}'", + start.label, target.label + ) + })?; + let edge_by_id = snapshot + .edges + .iter() + .map(|edge| (edge.id.as_str(), edge)) + .collect::>(); + let node_by_id = node_map(snapshot); + let mut node_ids = vec![target.id.clone()]; + let mut edge_ids = Vec::new(); + let mut cursor = target.id.clone(); + while cursor != start.id { + let (parent, edge_id) = previous + .get(&cursor) + .cloned() + .ok_or_else(|| "Path reconstruction failed".to_string())?; + node_ids.push(parent.clone()); + edge_ids.push(edge_id); + cursor = parent; + } + node_ids.reverse(); + edge_ids.reverse(); + if edge_ids.len() > MAX_PATH_HOPS { + return Err(format!( + "No directed graph path within the {MAX_PATH_HOPS}-hop limit connects '{}' to '{}'", + start.label, target.label + )); + } + let mut result = GraphPathResult { + nodes: node_ids + .iter() + .filter_map(|id| node_by_id.get(id.as_str()).copied().cloned()) + .collect(), + edges: edge_ids + .iter() + .filter_map(|id| edge_by_id.get(id.as_str()).copied().cloned()) + .collect(), + total_cost, + visited, + truncated: visited >= MAX_PATH_VISITS, + context: query_context(snapshot), + }; + enforce_path_bytes(&mut result)?; + Ok(result) +} + +pub fn impact( + snapshot: &StructuralGraphSnapshot, + reference: &str, + direction: GraphDirection, + depth: Option, + filter: &GraphQueryFilter, + limit: Option, +) -> Result { + let root = resolve_node(snapshot, reference)?; + let max_depth = depth.unwrap_or(3).clamp(1, 12); + let limit = bounded_limit(limit); + let mut adjacency: HashMap<&str, Vec<&StructuralGraphEdge>> = HashMap::new(); + let mut degree = HashMap::<&str, usize>::new(); + for edge in snapshot + .edges + .iter() + .filter(|edge| edge_matches_filter(edge, filter)) + { + *degree.entry(edge.from.as_str()).or_default() += 1; + *degree.entry(edge.to.as_str()).or_default() += 1; + match direction { + GraphDirection::Incoming => adjacency.entry(&edge.to).or_default().push(edge), + GraphDirection::Outgoing => adjacency.entry(&edge.from).or_default().push(edge), + GraphDirection::Both => { + adjacency.entry(&edge.to).or_default().push(edge); + adjacency.entry(&edge.from).or_default().push(edge); + } + } + } + for (node_id, edges) in &mut adjacency { + edges.sort_by(|left, right| { + let left_neighbor = if left.from == *node_id { + left.to.as_str() + } else { + left.from.as_str() + }; + let right_neighbor = if right.from == *node_id { + right.to.as_str() + } else { + right.from.as_str() + }; + degree + .get(left_neighbor) + .copied() + .unwrap_or_default() + .cmp(°ree.get(right_neighbor).copied().unwrap_or_default()) + .then_with(|| left.id.cmp(&right.id)) + }); + } + + let mut queue = VecDeque::from([(root.id.as_str(), 0_usize)]); + let mut seen = HashSet::from([root.id.as_str()]); + let mut edge_ids = HashSet::new(); + let mut depth_reached = 0; + let mut truncated = false; + while let Some((node_id, current_depth)) = queue.pop_front() { + depth_reached = depth_reached.max(current_depth); + if current_depth >= max_depth { + continue; + } + for edge in adjacency.get(node_id).into_iter().flatten() { + edge_ids.insert(edge.id.as_str()); + let neighbor = if edge.from == node_id { + edge.to.as_str() + } else { + edge.from.as_str() + }; + if seen.insert(neighbor) { + if seen.len() > limit + 1 { + truncated = true; + break; + } + queue.push_back((neighbor, current_depth + 1)); + } + } + if truncated { + break; + } + } + + let node_by_id = node_map(snapshot); + let mut affected = seen + .into_iter() + .filter(|id| *id != root.id) + .filter_map(|id| node_by_id.get(id).copied().cloned()) + .collect::>(); + affected.sort_by(|left, right| left.id.cmp(&right.id)); + affected.truncate(limit); + let retained_ids = affected + .iter() + .map(|node| node.id.as_str()) + .chain(std::iter::once(root.id.as_str())) + .collect::>(); + let mut edges = snapshot + .edges + .iter() + .filter(|edge| edge_ids.contains(edge.id.as_str())) + .filter(|edge| { + retained_ids.contains(edge.from.as_str()) && retained_ids.contains(edge.to.as_str()) + }) + .take(MAX_EDGE_LIMIT) + .cloned() + .collect::>(); + if edge_ids.len() > edges.len() { + truncated = true; + } + edges.sort_by(|left, right| left.id.cmp(&right.id)); + let mut result = GraphImpactResult { + root: root.clone(), + affected, + edges, + depth_reached, + truncated, + context: query_context(snapshot), + }; + enforce_impact_bytes(&mut result); + Ok(result) +} From 300aacd4d79e41c9134f21d6d3c47f6dbd6aebfe Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:07:35 +0530 Subject: [PATCH 025/141] feat(graph): expose canonical read service --- .../src/commands/structural_graph/mod.rs | 1 + .../src/commands/structural_graph/service.rs | 321 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/service.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index f18a815..cb7f9a1 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -7,5 +7,6 @@ pub mod legacy; pub(crate) mod metrics; pub mod query; pub mod resolve; +pub mod service; pub mod storage; pub mod types; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/service.rs b/apps/desktop/src-tauri/src/commands/structural_graph/service.rs new file mode 100644 index 0000000..06883a4 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/service.rs @@ -0,0 +1,321 @@ +//! Read-only canonical structural graph service shared by Tauri and MCP. + +use super::{ + query::{ + self, GraphAnalysisResult, GraphDirection, GraphExplanation, GraphImpactResult, + GraphPathResult, GraphProjection, GraphQueryFilter, GraphSearchResult, + StructuralGraphMetadata, + }, + storage::{ + list_snapshot_summaries, load_latest_snapshot, load_snapshot_by_id, + StructuralGraphStoredSummary, + }, + types::StructuralGraphSnapshot, +}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructuralGraphReadStatus { + pub indexed: bool, + pub stale: bool, + pub current_head: Option, + pub indexed_head: Option, + pub snapshot_id: Option, + pub schema_version: Option, + pub engine_id: Option, + pub engine_version: Option, + pub created_at: Option, + pub indexed_files: usize, + pub node_count: usize, + pub edge_count: usize, + pub truncated: bool, +} + +pub struct StructuralGraphReadService<'a> { + connection: &'a Connection, + repo_path: String, + current_head: Option, +} + +impl<'a> StructuralGraphReadService<'a> { + pub fn new(connection: &'a Connection, repo_path: impl Into) -> Self { + let repo_path = repo_path.into(); + let current_head = git_head(&repo_path); + Self { + connection, + repo_path, + current_head, + } + } + + pub fn new_with_current_head( + connection: &'a Connection, + repo_path: impl Into, + current_head: Option, + ) -> Self { + Self { + connection, + repo_path: repo_path.into(), + current_head, + } + } + + pub fn snapshot(&self) -> Result { + load_latest_snapshot(self.connection, &self.repo_path) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + "Canonical structural graph is not built for this repository".to_string() + }) + } + + pub fn snapshot_by_id(&self, snapshot_id: &str) -> Result { + load_snapshot_by_id(self.connection, &self.repo_path, snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) + } + + pub fn status(&self) -> Result { + self.status_with_current_head(self.current_head.clone()) + } + + pub fn status_with_current_head( + &self, + current_head: Option, + ) -> Result { + let snapshot = load_latest_snapshot(self.connection, &self.repo_path) + .map_err(|error| error.to_string())?; + Ok(match snapshot { + Some(snapshot) => StructuralGraphReadStatus { + indexed: true, + stale: snapshot.repo_head != current_head, + current_head, + indexed_head: snapshot.repo_head.clone(), + snapshot_id: Some(snapshot.id.clone()), + schema_version: Some(snapshot.schema_version), + engine_id: Some(snapshot.engine.id.clone()), + engine_version: Some(snapshot.engine.version.clone()), + created_at: Some(snapshot.created_at.clone()), + indexed_files: snapshot.coverage.indexed_files, + node_count: snapshot.nodes.len(), + edge_count: snapshot.edges.len(), + truncated: snapshot.truncated, + }, + None => StructuralGraphReadStatus { + indexed: false, + stale: false, + current_head, + indexed_head: None, + snapshot_id: None, + schema_version: None, + engine_id: None, + engine_version: None, + created_at: None, + indexed_files: 0, + node_count: 0, + edge_count: 0, + truncated: false, + }, + }) + } + + pub fn metadata(&self) -> Result { + let mut metadata = query::metadata(&self.snapshot()?); + metadata.freshness.stale = self + .current_head + .as_ref() + .map(|head| metadata.freshness.indexed_head.as_ref() != Some(head)); + metadata.freshness.current_head = self.current_head.clone(); + Ok(metadata) + } + + pub fn analysis(&self) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::analysis(&snapshot); + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn overview(&self, limit: usize) -> Result { + self.overview_page(limit, None) + } + + pub fn overview_page( + &self, + limit: usize, + cursor: Option<&str>, + ) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::overview_page(&snapshot, Some(limit), cursor)?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn community(&self, community_id: &str, limit: usize) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::community(&snapshot, community_id, Some(limit))?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn search( + &self, + text: &str, + filter: &GraphQueryFilter, + limit: usize, + ) -> Result { + self.search_page(text, filter, limit, None) + } + + pub fn search_page( + &self, + text: &str, + filter: &GraphQueryFilter, + limit: usize, + cursor: Option<&str>, + ) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::search_page(&snapshot, text, filter, Some(limit), cursor)?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn explain(&self, node: &str) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::explain(&snapshot, node)?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn neighbors( + &self, + node: &str, + direction: GraphDirection, + filter: &GraphQueryFilter, + limit: usize, + cursor: Option<&str>, + ) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::neighbors(&snapshot, node, direction, filter, Some(limit), cursor)?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn path( + &self, + from: &str, + to: &str, + filter: &GraphQueryFilter, + ) -> Result { + let snapshot = self.snapshot()?; + let mut result = query::shortest_path(&snapshot, from, to, filter)?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn impact( + &self, + node: &str, + direction: GraphDirection, + depth: usize, + filter: &GraphQueryFilter, + limit: usize, + ) -> Result { + let snapshot = self.snapshot()?; + let mut result = + query::impact(&snapshot, node, direction, Some(depth), filter, Some(limit))?; + result + .context + .observe_current_head(self.current_head.clone()); + Ok(result) + } + + pub fn snapshots(&self, limit: usize) -> Result, String> { + list_snapshot_summaries(self.connection, &self.repo_path, limit) + .map_err(|error| error.to_string()) + } +} + +fn git_head(repo_path: &str) -> Option { + if !Path::new(repo_path).is_dir() { + return None; + } + std::process::Command::new("git") + .args(["-C", repo_path, "rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|head| !head.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::structural_graph::{ + storage::persist_snapshot, + types::{ + StructuralGraphCoverage, StructuralGraphEngineInfo, StructuralGraphSnapshot, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + }, + }; + + #[test] + fn shared_service_uses_the_persisted_canonical_snapshot() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let snapshot = StructuralGraphSnapshot { + id: "snapshot".to_string(), + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + repo_path: "/fixture".to_string(), + repo_head: Some("head".to_string()), + engine: StructuralGraphEngineInfo { + id: "fixture".to_string(), + version: "1".to_string(), + bundled: true, + syntax_aware: true, + supported_languages: Vec::new(), + }, + created_at: "2026-01-01T00:00:00Z".to_string(), + cursor: None, + ignore_fingerprint: None, + coverage: StructuralGraphCoverage::default(), + files: Vec::new(), + nodes: Vec::new(), + edges: Vec::new(), + metrics: Vec::new(), + clone_groups: Vec::new(), + communities: Vec::new(), + diagnostics: Vec::new(), + truncated: false, + }; + persist_snapshot(&connection, &snapshot).expect("persist"); + let service = StructuralGraphReadService::new_with_current_head( + &connection, + "/fixture", + Some("head".to_string()), + ); + assert_eq!( + service.metadata().expect("metadata").snapshot_id, + "snapshot" + ); + let overview = service.overview(10).expect("overview"); + assert_eq!(overview.nodes.len(), 0); + assert_eq!(overview.context.freshness.stale, Some(false)); + } +} From a48ebac15a1c11ce0469e15395a11e9fd2c8c2c0 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:08:22 +0530 Subject: [PATCH 026/141] feat(graph): expose native structural graph api --- .../src/commands/structural_graph/api.rs | 1081 +++++++++++++++++ .../src/commands/structural_graph/mod.rs | 1 + 2 files changed, 1082 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/structural_graph/api.rs diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/api.rs b/apps/desktop/src-tauri/src/commands/structural_graph/api.rs new file mode 100644 index 0000000..eaa7154 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/structural_graph/api.rs @@ -0,0 +1,1081 @@ +use super::extract::BundledTreeSitterEngine; +use super::interchange::{ + self, StructuralGraphAdapterDescriptor, StructuralGraphInterchangePreview, +}; +use super::query::{ + self, GraphAnalysisResult, GraphDirection, GraphExplanation, GraphImpactResult, + GraphPathResult, GraphProjection, GraphQueryFilter, GraphSearchResult, GraphSnapshotDiff, + StructuralGraphMetadata, +}; +use super::storage::{ + list_snapshot_summaries, load_latest_snapshot, load_latest_snapshot_summary, + load_snapshot_by_id, load_snapshot_files, persist_snapshot, prune_present_state_snapshots, + StructuralGraphStoredSummary, +}; +use super::types::{ + stable_graph_id, StructuralGraphBuildInput, StructuralGraphCancellation, StructuralGraphEngine, + StructuralGraphFileRecord, StructuralGraphProgress, StructuralGraphSnapshot, BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, STRUCTURAL_GRAPH_SCHEMA_VERSION, +}; +use crate::DbState; +use chrono::DateTime; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex, OnceLock}; +use tauri::{Emitter, State}; + +static ACTIVE_BUILDS: OnceLock>> = + OnceLock::new(); +static SNAPSHOT_CACHE: OnceLock>>> = + OnceLock::new(); +const PRESENT_STATE_SNAPSHOT_RETENTION: usize = 20; +const MAX_INDEXED_FILE_BYTES: u64 = 2 * 1024 * 1024; +type FileRefreshPlan = (Vec, Vec); + +#[tauri::command] +pub fn get_structural_graph_adapters() -> Vec { + interchange::adapter_descriptors() +} + +#[tauri::command] +pub fn preview_node_link_structural_graph( + repo_path: String, + json_text: String, +) -> Result { + let canonical = canonical_repo_path(&repo_path)?; + interchange::import_node_link_json(&canonical.to_string_lossy(), &json_text) +} + +#[tauri::command] +pub async fn export_structural_graph_json( + repo_path: String, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot_result(repo_path, db, interchange::export_json).await +} + +#[tauri::command] +pub async fn export_structural_graph_markdown( + repo_path: String, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot(repo_path, db, interchange::export_markdown).await +} + +#[derive(Debug, Clone, Serialize)] +pub struct StructuralGraphStatus { + pub repo_path: String, + pub indexed: bool, + pub building: bool, + pub stale: bool, + pub current_head: Option, + pub indexed_head: Option, + pub snapshot_id: Option, + pub schema_version: Option, + pub engine_id: Option, + pub engine_version: Option, + pub created_at: Option, + pub indexed_files: usize, + pub node_count: usize, + pub edge_count: usize, +} + +#[tauri::command] +pub async fn build_structural_graph( + repo_path: String, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let canonical = canonical_repo_path(&repo_path)?; + let key = canonical.to_string_lossy().to_string(); + let cancellation = StructuralGraphCancellation::default(); + { + let mut builds = active_builds() + .lock() + .map_err(|_| "Structural graph build registry is unavailable".to_string())?; + if builds.contains_key(&key) { + return Err( + "A structural graph build is already running for this repository".to_string(), + ); + } + builds.insert(key.clone(), cancellation.clone()); + } + + let database = Arc::clone(&db.0); + let task_key = key.clone(); + let worker_key = task_key.clone(); + let worker_result = tokio::task::spawn_blocking(move || { + let head = git_head(&canonical); + let engine = BundledTreeSitterEngine; + let previous_state = { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + let summary = load_latest_snapshot_summary(&connection, &worker_key) + .map_err(|error| error.to_string())?; + let files = summary + .as_ref() + .map(|summary| load_snapshot_files(&connection, &summary.id)) + .transpose() + .map_err(|error| error.to_string())? + .unwrap_or_default(); + (summary, files) + }; + let (previous_summary, previous_files) = previous_state; + let input = if let Some(summary) = + previous_summary.filter(summary_is_incremental_compatible) + { + match refresh_plan( + &canonical, + summary.repo_head.as_deref(), + head.as_deref(), + &previous_files, + &summary.created_at, + )? { + Some((changed_files, deleted_files)) => { + if changed_files.is_empty() + && deleted_files.is_empty() + && summary.repo_head == head + { + return Ok::<_, String>(metadata_from_summary(summary)); + } + let previous = if let Some(snapshot) = cached_snapshot(&worker_key) { + Some((*snapshot).clone()) + } else { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + load_latest_snapshot(&connection, &worker_key) + .map_err(|error| error.to_string())? + }; + if let Some(previous) = previous.filter(snapshot_is_incremental_compatible) { + StructuralGraphBuildInput { + repo_root: canonical.clone(), + repo_head: head.clone(), + changed_files, + deleted_files, + previous_cursor: previous.cursor.clone(), + previous_snapshot: Some(Box::new(previous)), + max_files: 25_000, + max_bytes_per_file: 2 * 1024 * 1024, + } + } else { + StructuralGraphBuildInput::full(canonical.clone(), head.clone()) + } + } + None => StructuralGraphBuildInput::full(canonical.clone(), head.clone()), + } + } else { + StructuralGraphBuildInput::full(canonical.clone(), head.clone()) + }; + let progress_app = app.clone(); + let progress = move |event: StructuralGraphProgress| { + let _ = progress_app.emit("structural-graph-progress", &event); + }; + let snapshot = engine + .build(&input, &cancellation, &progress) + .map_err(|error| error.to_string())?; + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + persist_snapshot(&connection, &snapshot).map_err(|error| error.to_string())?; + prune_present_state_snapshots(&connection, &worker_key, PRESENT_STATE_SNAPSHOT_RETENTION) + .map_err(|error| error.to_string())?; + cache_snapshot(&worker_key, snapshot.clone()); + Ok::<_, String>(query::metadata(&snapshot)) + }) + .await; + + if let Ok(mut builds) = active_builds().lock() { + builds.remove(&task_key); + } + worker_result.map_err(|error| format!("Structural graph worker failed: {error}"))? +} + +#[tauri::command] +pub async fn cancel_structural_graph_build(repo_path: String) -> Result { + let key = canonical_repo_path(&repo_path)? + .to_string_lossy() + .to_string(); + let builds = active_builds() + .lock() + .map_err(|_| "Structural graph build registry is unavailable".to_string())?; + if let Some(cancellation) = builds.get(&key) { + cancellation.cancel(); + Ok(true) + } else { + Ok(false) + } +} + +#[tauri::command] +pub async fn get_structural_graph( + repo_path: String, + db: State<'_, DbState>, +) -> Result, String> { + let canonical = canonical_repo_path(&repo_path)?; + let key = canonical.to_string_lossy().to_string(); + if let Some(snapshot) = cached_snapshot(&key) { + return Ok(Some((*snapshot).clone())); + } + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + let snapshot = + load_latest_snapshot(&connection, &key).map_err(|error| error.to_string())?; + if let Some(snapshot) = &snapshot { + cache_snapshot(&key, snapshot.clone()); + } + Ok(snapshot) + }) + .await + .map_err(|error| format!("Structural graph worker failed: {error}"))? +} + +#[tauri::command] +pub async fn get_structural_graph_metadata( + repo_path: String, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot(repo_path, db, query::metadata).await +} + +#[tauri::command] +pub async fn get_structural_graph_analysis( + repo_path: String, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot(repo_path, db, query::analysis).await +} + +#[tauri::command] +pub async fn get_structural_graph_overview( + repo_path: String, + limit: Option, + cursor: Option, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot_result(repo_path, db, move |snapshot| { + query::overview_page(snapshot, limit, cursor.as_deref()) + }) + .await +} + +#[tauri::command] +pub async fn get_structural_graph_community( + repo_path: String, + community_id: String, + limit: Option, + cursor: Option, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot_result(repo_path, db, move |snapshot| { + query::community_page(snapshot, &community_id, limit, cursor.as_deref()) + }) + .await +} + +#[tauri::command] +pub async fn get_structural_graph_subgraph( + repo_path: String, + seeds: Vec, + depth: Option, + filter: Option, + limit: Option, + db: State<'_, DbState>, +) -> Result, String> { + let filter = filter.unwrap_or_default(); + with_snapshot_result(repo_path, db, move |snapshot| { + query::subgraph(snapshot, &seeds, depth, &filter, limit) + }) + .await +} + +#[tauri::command] +pub async fn list_structural_graph_snapshots( + repo_path: String, + limit: Option, + db: State<'_, DbState>, +) -> Result, String> { + let canonical = canonical_repo_path(&repo_path)?; + let key = canonical.to_string_lossy().to_string(); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + list_snapshot_summaries(&connection, &key, limit.unwrap_or(20)) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Structural graph snapshot worker failed: {error}"))? +} + +#[tauri::command] +pub async fn diff_structural_graph_snapshots( + repo_path: String, + before_snapshot_id: String, + after_snapshot_id: String, + db: State<'_, DbState>, +) -> Result { + let canonical = canonical_repo_path(&repo_path)?; + let key = canonical.to_string_lossy().to_string(); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + let before = load_snapshot_by_id(&connection, &key, &before_snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("Structural graph snapshot not found: {before_snapshot_id}"))?; + let after = load_snapshot_by_id(&connection, &key, &after_snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("Structural graph snapshot not found: {after_snapshot_id}"))?; + Ok(query::diff_snapshots(&before, &after)) + }) + .await + .map_err(|error| format!("Structural graph diff worker failed: {error}"))? +} + +#[tauri::command] +pub async fn search_structural_graph( + repo_path: String, + query_text: String, + filter: Option, + limit: Option, + cursor: Option, + db: State<'_, DbState>, +) -> Result, String> { + let filter = filter.unwrap_or_default(); + with_snapshot_result(repo_path, db, move |snapshot| { + query::search_page(snapshot, &query_text, &filter, limit, cursor.as_deref()) + }) + .await +} + +#[tauri::command] +pub async fn explain_structural_graph_node( + repo_path: String, + node: String, + db: State<'_, DbState>, +) -> Result, String> { + with_snapshot_result(repo_path, db, move |snapshot| { + query::explain(snapshot, &node) + }) + .await +} + +#[tauri::command] +pub async fn get_structural_graph_neighbors( + repo_path: String, + node: String, + direction: Option, + filter: Option, + limit: Option, + cursor: Option, + db: State<'_, DbState>, +) -> Result, String> { + let direction = direction.unwrap_or_default(); + let filter = filter.unwrap_or_default(); + with_snapshot_result(repo_path, db, move |snapshot| { + query::neighbors( + snapshot, + &node, + direction, + &filter, + limit, + cursor.as_deref(), + ) + }) + .await +} + +#[tauri::command] +pub async fn find_structural_graph_path( + repo_path: String, + from: String, + to: String, + filter: Option, + db: State<'_, DbState>, +) -> Result, String> { + let filter = filter.unwrap_or_default(); + with_snapshot_result(repo_path, db, move |snapshot| { + query::shortest_path(snapshot, &from, &to, &filter) + }) + .await +} + +#[tauri::command] +pub async fn get_structural_graph_impact( + repo_path: String, + node: String, + direction: Option, + depth: Option, + filter: Option, + limit: Option, + db: State<'_, DbState>, +) -> Result, String> { + let filter = filter.unwrap_or_default(); + let direction = direction.unwrap_or(GraphDirection::Incoming); + with_snapshot_result(repo_path, db, move |snapshot| { + query::impact(snapshot, &node, direction, depth, &filter, limit) + }) + .await +} + +#[tauri::command] +pub async fn get_structural_graph_status( + repo_path: String, + db: State<'_, DbState>, +) -> Result { + let canonical = canonical_repo_path(&repo_path)?; + let key = canonical.to_string_lossy().to_string(); + let current_head = git_head(&canonical); + let building = active_builds() + .lock() + .map(|builds| builds.contains_key(&key)) + .unwrap_or(false); + if let Some(snapshot) = cached_snapshot(&key) { + let stale = snapshot_is_stale(&canonical, &snapshot, current_head.as_deref()); + return Ok(StructuralGraphStatus { + repo_path: key, + indexed: true, + building, + stale, + current_head, + indexed_head: snapshot.repo_head.clone(), + snapshot_id: Some(snapshot.id.clone()), + schema_version: Some(snapshot.schema_version), + engine_id: Some(snapshot.engine.id.clone()), + engine_version: Some(snapshot.engine.version.clone()), + created_at: Some(snapshot.created_at.clone()), + indexed_files: snapshot.coverage.indexed_files, + node_count: snapshot.nodes.len(), + edge_count: snapshot.edges.len(), + }); + } + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + let summary = + load_latest_snapshot_summary(&connection, &key).map_err(|error| error.to_string())?; + Ok(match summary { + Some(summary) => { + let files = load_snapshot_files(&connection, &summary.id) + .map_err(|error| error.to_string())?; + let stale = refresh_plan( + &canonical, + summary.repo_head.as_deref(), + current_head.as_deref(), + &files, + &summary.created_at, + ) + .map(|plan| { + plan.is_none_or(|(changed, deleted)| { + !changed.is_empty() + || !deleted.is_empty() + || summary.repo_head != current_head + }) + }) + .unwrap_or(true); + StructuralGraphStatus { + repo_path: key, + indexed: true, + building, + stale, + current_head, + indexed_head: summary.repo_head, + snapshot_id: Some(summary.id), + schema_version: Some(summary.schema_version), + engine_id: Some(summary.engine_id), + engine_version: Some(summary.engine_version), + created_at: Some(summary.created_at), + indexed_files: summary.coverage.indexed_files, + node_count: summary.node_count, + edge_count: summary.edge_count, + } + } + None => StructuralGraphStatus { + repo_path: key, + indexed: false, + building, + stale: false, + current_head, + indexed_head: None, + snapshot_id: None, + schema_version: None, + engine_id: None, + engine_version: None, + created_at: None, + indexed_files: 0, + node_count: 0, + edge_count: 0, + }, + }) + }) + .await + .map_err(|error| format!("Structural graph worker failed: {error}"))? +} + +fn active_builds() -> &'static Mutex> { + ACTIVE_BUILDS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn snapshot_cache() -> &'static Mutex>> { + SNAPSHOT_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cached_snapshot(repo_path: &str) -> Option> { + snapshot_cache() + .lock() + .ok() + .and_then(|cache| cache.get(repo_path).cloned()) +} + +fn cache_snapshot(repo_path: &str, snapshot: StructuralGraphSnapshot) { + if let Ok(mut cache) = snapshot_cache().lock() { + cache.insert(repo_path.to_string(), Arc::new(snapshot)); + } +} + +async fn load_snapshot_arc( + repo_path: String, + db: State<'_, DbState>, +) -> Result>, String> { + let canonical = canonical_repo_path(&repo_path)?; + let key = canonical.to_string_lossy().to_string(); + if let Some(snapshot) = cached_snapshot(&key) { + return Ok(Some(snapshot)); + } + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "Structural graph database is unavailable".to_string())?; + let snapshot = + load_latest_snapshot(&connection, &key).map_err(|error| error.to_string())?; + Ok(snapshot.map(|snapshot| { + let snapshot = Arc::new(snapshot); + if let Ok(mut cache) = snapshot_cache().lock() { + cache.insert(key, Arc::clone(&snapshot)); + } + snapshot + })) + }) + .await + .map_err(|error| format!("Structural graph worker failed: {error}"))? +} + +async fn with_snapshot( + repo_path: String, + db: State<'_, DbState>, + transform: F, +) -> Result, String> +where + T: Send + 'static, + F: FnOnce(&StructuralGraphSnapshot) -> T + Send + 'static, +{ + let Some(snapshot) = load_snapshot_arc(repo_path, db).await? else { + return Ok(None); + }; + tokio::task::spawn_blocking(move || transform(&snapshot)) + .await + .map(Some) + .map_err(|error| format!("Structural graph query worker failed: {error}")) +} + +async fn with_snapshot_result( + repo_path: String, + db: State<'_, DbState>, + transform: F, +) -> Result, String> +where + T: Send + 'static, + F: FnOnce(&StructuralGraphSnapshot) -> Result + Send + 'static, +{ + let Some(snapshot) = load_snapshot_arc(repo_path, db).await? else { + return Ok(None); + }; + tokio::task::spawn_blocking(move || transform(&snapshot)) + .await + .map_err(|error| format!("Structural graph query worker failed: {error}"))? + .map(Some) +} + +fn canonical_repo_path(repo_path: &str) -> Result { + let trimmed = repo_path.trim(); + if trimmed.is_empty() { + return Err("Repository path is required".to_string()); + } + let path = PathBuf::from(trimmed) + .canonicalize() + .map_err(|error| format!("Cannot resolve repository path: {error}"))?; + if !path.is_dir() { + return Err("Repository path is not a directory".to_string()); + } + Ok(path) +} + +fn git_head(repo_path: &Path) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let head = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!head.is_empty()).then_some(head) +} + +fn snapshot_is_incremental_compatible(snapshot: &StructuralGraphSnapshot) -> bool { + snapshot.schema_version == STRUCTURAL_GRAPH_SCHEMA_VERSION + && snapshot.engine.id == BUNDLED_ENGINE_ID + && snapshot.engine.version == BUNDLED_ENGINE_VERSION + && snapshot.engine.syntax_aware +} + +fn summary_is_incremental_compatible(summary: &StructuralGraphStoredSummary) -> bool { + summary.schema_version == STRUCTURAL_GRAPH_SCHEMA_VERSION + && summary.engine_id == BUNDLED_ENGINE_ID + && summary.engine_version == BUNDLED_ENGINE_VERSION +} + +fn metadata_from_summary(summary: StructuralGraphStoredSummary) -> StructuralGraphMetadata { + StructuralGraphMetadata { + snapshot_id: summary.id, + schema_version: summary.schema_version, + repo_path: summary.repo_path, + repo_head: summary.repo_head.clone(), + created_at: summary.created_at, + engine_id: summary.engine_id, + engine_version: summary.engine_version, + indexed_files: summary.coverage.indexed_files, + node_count: summary.node_count, + edge_count: summary.edge_count, + diagnostic_count: summary.diagnostic_count, + coverage: summary.coverage, + trust: None, + freshness: super::query::GraphFreshness { + indexed_head: summary.repo_head.clone(), + current_head: None, + stale: None, + }, + truncated: summary.truncated, + } +} + +fn refresh_plan( + repo_path: &Path, + previous_head: Option<&str>, + current_head: Option<&str>, + previous_files: &[StructuralGraphFileRecord], + indexed_at: &str, +) -> Result, String> { + let (Some(previous_head), Some(current_head)) = (previous_head, current_head) else { + return Ok(None); + }; + if previous_head != current_head && !git_is_ancestor(repo_path, previous_head, current_head) { + return Ok(None); + } + + let mut changed = Vec::new(); + let mut deleted = Vec::new(); + if previous_head != current_head { + let committed = git_path_changes(repo_path, previous_head, Some(current_head))?; + changed.extend(committed.changed); + deleted.extend(committed.deleted); + } + let working_tree = git_path_changes(repo_path, current_head, None)?; + changed.extend(working_tree.changed); + deleted.extend(working_tree.deleted); + changed.extend(git_null_paths( + repo_path, + &["ls-files", "-o", "--exclude-standard", "-z"], + )?); + + for path in changed.clone() { + if repo_path.join(&path).is_file() { + continue; + } else { + deleted.push(path); + } + } + reconcile_file_cursors(repo_path, changed, deleted, previous_files, indexed_at).map(Some) +} + +fn reconcile_file_cursors( + repo_path: &Path, + changed: Vec, + deleted: Vec, + previous_files: &[StructuralGraphFileRecord], + indexed_at: &str, +) -> Result<(Vec, Vec), String> { + let indexed_millis = DateTime::parse_from_rfc3339(indexed_at) + .ok() + .map(|timestamp| timestamp.timestamp_millis()); + let previous_by_path = previous_files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect::>(); + let mut changed = changed.into_iter().collect::>(); + let mut deleted = deleted.into_iter().collect::>(); + + for file in previous_files { + let absolute = repo_path.join(&file.path); + let Ok(metadata) = absolute.metadata() else { + changed.remove(&file.path); + deleted.insert(file.path.clone()); + continue; + }; + if !metadata.is_file() { + changed.remove(&file.path); + deleted.insert(file.path.clone()); + continue; + } + + let modified_after_index = indexed_millis.is_some_and(|indexed| { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as i64 > indexed) + .unwrap_or(false) + }); + let should_verify = changed.contains(&file.path) + || metadata.len() != file.byte_size + || modified_after_index; + if !should_verify { + continue; + } + if file_content_hash(&absolute, metadata.len()).as_ref() == file.content_hash.as_ref() + && file.content_hash.is_some() + { + changed.remove(&file.path); + deleted.remove(&file.path); + } else { + changed.insert(file.path.clone()); + deleted.remove(&file.path); + } + } + + for path in changed.clone() { + let absolute = repo_path.join(&path); + if !absolute.is_file() { + changed.remove(&path); + if previous_by_path.contains_key(path.as_str()) { + deleted.insert(path); + } + continue; + } + if let Some(previous) = previous_by_path.get(path.as_str()) { + let size = absolute + .metadata() + .map(|metadata| metadata.len()) + .unwrap_or(u64::MAX); + if previous.content_hash.is_some() + && file_content_hash(&absolute, size).as_ref() == previous.content_hash.as_ref() + { + changed.remove(&path); + } + } + } + + let mut changed = changed.into_iter().collect::>(); + let mut deleted = deleted.into_iter().collect::>(); + changed.sort(); + deleted.sort(); + Ok((changed, deleted)) +} + +fn file_content_hash(path: &Path, size: u64) -> Option { + if size > MAX_INDEXED_FILE_BYTES { + return None; + } + std::fs::read_to_string(path) + .ok() + .map(|source| stable_graph_id("content", &source)) +} + +fn snapshot_is_stale( + repo_path: &Path, + snapshot: &StructuralGraphSnapshot, + current_head: Option<&str>, +) -> bool { + refresh_plan( + repo_path, + snapshot.repo_head.as_deref(), + current_head, + &snapshot.files, + &snapshot.created_at, + ) + .map(|plan| { + plan.is_none_or(|(changed, deleted)| { + !changed.is_empty() + || !deleted.is_empty() + || snapshot.repo_head.as_deref() != current_head + }) + }) + .unwrap_or(true) +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct GitPathChanges { + changed: Vec, + deleted: Vec, +} + +fn git_path_changes( + repo_path: &Path, + base: &str, + target: Option<&str>, +) -> Result { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(repo_path) + .args(["diff", "--name-status", "-z", "-M", base]); + if let Some(target) = target { + command.arg(target); + } + let output = command + .output() + .map_err(|error| format!("Failed to inspect repository changes: {error}"))?; + if !output.status.success() { + return Err(format!( + "Git change detection failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + parse_git_name_status(&output.stdout) +} + +fn parse_git_name_status(output: &[u8]) -> Result { + let fields = output + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .map(|field| String::from_utf8_lossy(field).replace('\\', "/")) + .collect::>(); + let mut changes = GitPathChanges::default(); + let mut index = 0; + while index < fields.len() { + let status = &fields[index]; + index += 1; + let Some(first_path) = fields.get(index).cloned() else { + return Err("Git change output ended before a path".to_string()); + }; + index += 1; + match status.chars().next().unwrap_or('M') { + 'R' => { + let Some(new_path) = fields.get(index).cloned() else { + return Err("Git rename output ended before the destination path".to_string()); + }; + index += 1; + changes.deleted.push(first_path); + changes.changed.push(new_path); + } + 'C' => { + let Some(new_path) = fields.get(index).cloned() else { + return Err("Git copy output ended before the destination path".to_string()); + }; + index += 1; + changes.changed.push(new_path); + } + 'D' => changes.deleted.push(first_path), + _ => changes.changed.push(first_path), + } + } + Ok(changes) +} + +fn git_is_ancestor(repo_path: &Path, ancestor: &str, descendant: &str) -> bool { + Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["merge-base", "--is-ancestor", ancestor, descendant]) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn git_null_paths(repo_path: &Path, arguments: &[&str]) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(arguments) + .output() + .map_err(|error| format!("Failed to inspect repository changes: {error}"))?; + if !output.status.success() { + return Err(format!( + "Git change detection failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(output + .stdout + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + .map(|value| String::from_utf8_lossy(value).replace('\\', "/")) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn empty_repo_path_is_rejected() { + assert_eq!( + canonical_repo_path(" ").unwrap_err(), + "Repository path is required" + ); + } + + #[test] + fn git_name_status_parser_repairs_renames_deletes_and_copies() { + let parsed = parse_git_name_status( + b"M\0src/a.rs\0R100\0src/old.rs\0src/new.rs\0D\0src/gone.rs\0C090\0src/a.rs\0src/copy.rs\0", + ) + .unwrap(); + assert_eq!( + parsed.changed, + vec!["src/a.rs", "src/new.rs", "src/copy.rs"] + ); + assert_eq!(parsed.deleted, vec!["src/old.rs", "src/gone.rs"]); + } + + #[test] + fn history_rewrite_forces_a_full_rebuild_plan() { + let root = std::env::temp_dir().join(format!( + "codevetter-structural-history-rewrite-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("fixture directory"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@codevetter.local"]); + run_git(&root, &["config", "user.name", "CodeVetter Fixture"]); + fs::write(root.join("first.rs"), "fn first() {}\n").expect("first"); + run_git(&root, &["add", "first.rs"]); + run_git(&root, &["commit", "-m", "first"]); + fs::write(root.join("first.rs"), "fn second() {}\n").expect("second"); + run_git(&root, &["commit", "-am", "second"]); + let abandoned_head = git_output(&root, &["rev-parse", "HEAD"]); + run_git(&root, &["switch", "--orphan", "rewrite"]); + fs::write(root.join("rewrite.rs"), "fn rewrite() {}\n").expect("rewrite"); + run_git(&root, &["add", "rewrite.rs"]); + run_git(&root, &["commit", "-m", "rewrite"]); + let rewritten_head = git_output(&root, &["rev-parse", "HEAD"]); + + assert!(refresh_plan( + &root, + Some(&abandoned_head), + Some(&rewritten_head), + &[], + "2000-01-01T00:00:00Z", + ) + .expect("refresh plan") + .is_none()); + fs::remove_dir_all(root).expect("remove fixture repo"); + } + + #[test] + fn refresh_plan_compares_live_files_to_the_persisted_cursor() { + let root = std::env::temp_dir().join(format!( + "codevetter-structural-file-cursor-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("fixture directory"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@codevetter.local"]); + run_git(&root, &["config", "user.name", "CodeVetter Fixture"]); + fs::write(root.join("tracked.rs"), "fn committed() {}\n").expect("tracked"); + run_git(&root, &["add", "tracked.rs"]); + run_git(&root, &["commit", "-m", "initial"]); + let head = git_output(&root, &["rev-parse", "HEAD"]); + + let dirty_source = "fn dirty_snapshot() {}\n"; + fs::write(root.join("tracked.rs"), dirty_source).expect("dirty tracked file"); + let tracked = file_record("tracked.rs", dirty_source); + let plan = refresh_plan( + &root, + Some(&head), + Some(&head), + std::slice::from_ref(&tracked), + "2000-01-01T00:00:00Z", + ) + .expect("matching dirty cursor plan") + .expect("incremental plan"); + assert_eq!(plan, (Vec::new(), Vec::new())); + + fs::write(root.join("tracked.rs"), "fn committed() {}\n").expect("revert tracked"); + let plan = refresh_plan( + &root, + Some(&head), + Some(&head), + std::slice::from_ref(&tracked), + "2000-01-01T00:00:00Z", + ) + .expect("reverted cursor plan") + .expect("incremental plan"); + assert_eq!(plan.0, vec!["tracked.rs"]); + + let untracked_source = "fn temporary() {}\n"; + fs::write(root.join("temporary.rs"), untracked_source).expect("untracked"); + let untracked = file_record("temporary.rs", untracked_source); + fs::remove_file(root.join("temporary.rs")).expect("delete untracked"); + let plan = refresh_plan( + &root, + Some(&head), + Some(&head), + &[tracked, untracked], + "2000-01-01T00:00:00Z", + ) + .expect("deleted untracked cursor plan") + .expect("incremental plan"); + assert!(plan.1.contains(&"temporary.rs".to_string())); + + fs::remove_dir_all(root).expect("remove fixture repo"); + } + + fn file_record(path: &str, source: &str) -> StructuralGraphFileRecord { + StructuralGraphFileRecord { + path: path.to_string(), + language: Some("rust".to_string()), + content_hash: Some(stable_graph_id("content", source)), + disposition: "indexed".to_string(), + byte_size: source.len() as u64, + node_count: 1, + edge_count: 0, + } + } + + fn run_git(root: &Path, arguments: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(root) + .args(arguments) + .status() + .expect("run git"); + assert!(status.success(), "git {arguments:?}"); + } + + fn git_output(root: &Path, arguments: &[&str]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(arguments) + .output() + .expect("run git"); + assert!(output.status.success(), "git {arguments:?}"); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } +} diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs index cb7f9a1..3a74935 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/mod.rs @@ -1,4 +1,5 @@ pub mod analysis; +pub mod api; mod contracts; pub mod extract; pub mod interchange; From f2f948b2ca1c12e1f71066314bd0023a76a602a5 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:09:11 +0530 Subject: [PATCH 027/141] feat(graph): register native graph commands --- apps/desktop/src-tauri/src/main.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c8bed1b..ed3e4aa 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -466,6 +466,27 @@ fn main() { commands::graph_trust::import_graphify_preview, commands::graph_trust::trace_repo_graph_path, commands::history_graph::query_repo_history_graph, + // Canonical structural repository graph + commands::structural_graph::api::build_structural_graph, + commands::structural_graph::api::cancel_structural_graph_build, + commands::structural_graph::api::diff_structural_graph_snapshots, + commands::structural_graph::api::explain_structural_graph_node, + commands::structural_graph::api::export_structural_graph_json, + commands::structural_graph::api::export_structural_graph_markdown, + commands::structural_graph::api::find_structural_graph_path, + commands::structural_graph::api::get_structural_graph, + commands::structural_graph::api::get_structural_graph_adapters, + commands::structural_graph::api::get_structural_graph_analysis, + commands::structural_graph::api::get_structural_graph_community, + commands::structural_graph::api::get_structural_graph_impact, + commands::structural_graph::api::get_structural_graph_metadata, + commands::structural_graph::api::get_structural_graph_neighbors, + commands::structural_graph::api::get_structural_graph_overview, + commands::structural_graph::api::get_structural_graph_status, + commands::structural_graph::api::get_structural_graph_subgraph, + commands::structural_graph::api::list_structural_graph_snapshots, + commands::structural_graph::api::preview_node_link_structural_graph, + commands::structural_graph::api::search_structural_graph, // Unpack deep graph (call-graph indexing) commands::unpack_deep_graph::unpack_deep_graph_status, commands::unpack_deep_graph::unpack_deep_graph_analyze, From ab89c0403d26a51a3f1d277aee9f4b5afd37560f Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:11:23 +0530 Subject: [PATCH 028/141] feat(graph): type native graph ipc --- apps/desktop/src/lib/tauri-ipc.ts | 612 ++++++++++++++++++++++++++++++ 1 file changed, 612 insertions(+) diff --git a/apps/desktop/src/lib/tauri-ipc.ts b/apps/desktop/src/lib/tauri-ipc.ts index d12bcde..e52657f 100644 --- a/apps/desktop/src/lib/tauri-ipc.ts +++ b/apps/desktop/src/lib/tauri-ipc.ts @@ -1249,6 +1249,618 @@ export async function analyzeBlastRadius( }); } +// ─── Canonical structural graph (local Tree-sitter index) ────────────────── + +export type StructuralGraphTrust = 'extracted' | 'inferred' | 'ambiguous' | 'legacy'; +export type StructuralGraphOrigin = + | 'syntax' + | 'resolution' + | 'analysis' + | 'metadata' + | 'imported_node_link' + | 'user_annotation' + | 'legacy_metadata'; + +export interface StructuralGraphSourceAnchor { + path: string; + start_line?: number | null; + start_column?: number | null; + end_line?: number | null; + end_column?: number | null; + excerpt?: string | null; +} + +export interface StructuralGraphNode { + id: string; + kind: string; + label: string; + qualified_name?: string | null; + path?: string | null; + detail?: string | null; + language?: string | null; + community_id?: string | null; + trust: StructuralGraphTrust; + origin: StructuralGraphOrigin; + sources: StructuralGraphSourceAnchor[]; +} + +export interface StructuralGraphEdge { + id: string; + from: string; + to: string; + kind: string; + evidence: string; + trust: StructuralGraphTrust; + origin: StructuralGraphOrigin; + sources: StructuralGraphSourceAnchor[]; + candidates: string[]; +} + +export interface StructuralControlFlowFact { + id: string; + kind: string; + parent_id?: string | null; + nesting: number; + source: StructuralGraphSourceAnchor; +} + +export interface StructuralBoundaryFact { + kind: string; + target: string; + source: StructuralGraphSourceAnchor; +} + +export interface StructuralCodeMetrics { + line_count: number; + statement_count: number; + parameter_count: number; + cyclomatic_complexity: number; + cognitive_complexity: number; + max_nesting: number; + fan_in: number; + fan_out: number; + cohesion?: number | null; +} + +export interface StructuralGraphMetricFact { + schema_version: number; + id: string; + node_id: string; + path: string; + scope_kind: string; + language: string; + public_surface: boolean; + public_surface_reason?: string | null; + syntax_fingerprint: string; + normalized_token_count: number; + normalization_method: string; + metrics: StructuralCodeMetrics; + control_flow: StructuralControlFlowFact[]; + definitions: string[]; + uses: string[]; + boundaries: StructuralBoundaryFact[]; + sources: StructuralGraphSourceAnchor[]; + limitations: string[]; +} + +export interface StructuralCloneRegion { + metric_id: string; + node_id: string; + path: string; + source: StructuralGraphSourceAnchor; +} + +export interface StructuralCloneGroup { + id: string; + syntax_fingerprint: string; + normalization_method: string; + normalized_token_count: number; + similarity: number; + regions: StructuralCloneRegion[]; + exclusions: string[]; +} + +export interface StructuralGraphLanguageCoverage { + language: string; + supported: boolean; + discovered_files: number; + indexed_files: number; + skipped_files: number; + error_files: number; +} + +export interface StructuralGraphCoverage { + discovered_files: number; + indexed_files: number; + skipped_files: number; + error_files: number; + generated_files: number; + sensitive_files: number; + binary_files: number; + languages: StructuralGraphLanguageCoverage[]; +} + +export interface TrustedReviewGraphContext { + schema_version: number; + snapshot_id: string; + engine_id: string; + engine_version: string; + indexed_head?: string | null; + current_head?: string | null; + stale: boolean; + coverage: StructuralGraphCoverage; + nodes: StructuralGraphNode[]; + edges: StructuralGraphEdge[]; + truncated: boolean; + qualification: string; +} + +export interface StructuralGraphTrustSummary { + extracted: number; + inferred: number; + ambiguous: number; + legacy: number; +} + +export interface StructuralGraphQueryContext { + snapshot_id: string; + schema_version: number; + engine_id: string; + engine_version: string; + created_at: string; + freshness: { + indexed_head?: string | null; + current_head?: string | null; + stale?: boolean | null; + }; + coverage: StructuralGraphCoverage; + trust: StructuralGraphTrustSummary; + max_results: number; + max_edges: number; + max_hops: number; + max_bytes: number; +} + +export interface StructuralGraphProjection { + nodes: StructuralGraphNode[]; + edges: StructuralGraphEdge[]; + truncated: boolean; + next_cursor?: string | null; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphQueryFilter { + node_kinds?: string[]; + edge_kinds?: string[]; + trust?: StructuralGraphTrust[]; +} + +export interface StructuralGraphMetadata { + snapshot_id: string; + schema_version: number; + repo_path: string; + repo_head?: string | null; + created_at: string; + engine_id: string; + engine_version: string; + indexed_files: number; + node_count: number; + edge_count: number; + diagnostic_count: number; + coverage: StructuralGraphCoverage; + trust?: StructuralGraphTrustSummary | null; + freshness: StructuralGraphQueryContext['freshness']; + truncated: boolean; +} + +export interface StructuralGraphStatus { + repo_path: string; + indexed: boolean; + building: boolean; + stale: boolean; + current_head?: string | null; + indexed_head?: string | null; + snapshot_id?: string | null; + schema_version?: number | null; + engine_id?: string | null; + engine_version?: string | null; + created_at?: string | null; + indexed_files: number; + node_count: number; + edge_count: number; +} + +export interface StructuralGraphSearchResult { + hits: Array<{ + node: StructuralGraphNode; + score: number; + matched_by: string; + }>; + truncated: boolean; + next_cursor?: string | null; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphExplanation { + node: StructuralGraphNode; + incoming_count: number; + outgoing_count: number; + incoming_kinds: string[]; + outgoing_kinds: string[]; + truncated: boolean; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphPathResult { + nodes: StructuralGraphNode[]; + edges: StructuralGraphEdge[]; + total_cost: number; + visited: number; + truncated: boolean; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphImpactResult { + root: StructuralGraphNode; + affected: StructuralGraphNode[]; + edges: StructuralGraphEdge[]; + depth_reached: number; + truncated: boolean; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphStoredSummary { + id: string; + repo_path: string; + repo_head?: string | null; + schema_version: number; + engine_id: string; + engine_version: string; + created_at: string; + node_count: number; + edge_count: number; + diagnostic_count: number; + coverage: StructuralGraphCoverage; + truncated: boolean; +} + +export interface StructuralGraphSnapshotDiff { + before_snapshot_id: string; + after_snapshot_id: string; + added_node_ids: string[]; + removed_node_ids: string[]; + changed_node_ids: string[]; + added_edge_ids: string[]; + removed_edge_ids: string[]; + changed_edge_ids: string[]; + truncated: boolean; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphProgress { + phase: string; + completed: number; + total: number; + detail: string; +} + +export interface StructuralGraphCommunity { + id: string; + label: string; + member_count: number; + hub_node_ids: string[]; + bridge_node_ids: string[]; + score: number; +} + +export interface StructuralGraphNodeRank { + node_id: string; + label: string; + kind: string; + path?: string | null; + degree: number; + score: number; + reason: string; +} + +export interface StructuralGraphConnectionInsight { + edge_id: string; + from_community_id: string; + to_community_id: string; + score: number; + reason: string; +} + +export interface StructuralGraphSuggestedQuestion { + question: string; + node_ids: string[]; + source_paths: string[]; +} + +export interface StructuralGraphAnalysisPolicy { + algorithm_version: string; + included_edge_kinds: string[]; + execution_edge_kinds: string[]; + included_trust: StructuralGraphTrust[]; + direction: 'from_to'; + max_ranked_metrics: number; + max_components: number; + max_execution_flows: number; + max_execution_flow_depth: number; +} + +export interface StructuralGraphAnalysisCoverage { + complete: boolean; + reachability_complete: boolean; + trusted_edge_count: number; + excluded_edge_count: number; + unresolved_endpoint_count: number; + gaps: string[]; + output_truncated: boolean; +} + +export interface StructuralGraphNodeMetric { + node_id: string; + in_degree: number; + out_degree: number; + total_degree: number; + degree_centrality: number; + pagerank: number; +} + +export interface StructuralGraphComponent { + id: string; + node_ids: string[]; + edge_ids: string[]; + cyclic: boolean; +} + +export interface StructuralGraphExecutionFlow { + entrypoint_node_id: string; + node_ids: string[]; + edge_ids: string[]; + terminal_reason: 'terminal' | 'cycle_avoided' | 'depth_limit'; +} + +export interface StructuralGraphAlgorithmResults { + node_metrics: StructuralGraphNodeMetric[]; + strongly_connected_components: StructuralGraphComponent[]; + cycles: StructuralGraphComponent[]; + articulation_node_ids: string[]; + entrypoint_node_ids: string[]; + reachable_node_ids: string[]; + unreachable_node_ids: string[]; + execution_flows: StructuralGraphExecutionFlow[]; +} + +export interface StructuralGraphAnalysisSummary { + policy: StructuralGraphAnalysisPolicy; + coverage: StructuralGraphAnalysisCoverage; + algorithms: StructuralGraphAlgorithmResults; + communities: StructuralGraphCommunity[]; + hubs: StructuralGraphNodeRank[]; + super_hubs: StructuralGraphNodeRank[]; + bridges: StructuralGraphNodeRank[]; + cross_community_edges: StructuralGraphConnectionInsight[]; + surprising_connections: StructuralGraphConnectionInsight[]; + suggested_questions: StructuralGraphSuggestedQuestion[]; + truncated: boolean; + context: StructuralGraphQueryContext; +} + +export interface StructuralGraphAdapterDescriptor { + id: string; + label: string; + mode: string; + bundled: boolean; + mutates_repository: boolean; + requires_explicit_action: boolean; + runtime_behavior: string; +} + +export interface StructuralGraphInterchangePreview { + snapshot: { + schema_version: number; + id: string; + repo_path: string; + engine: { id: string; version: string; bundled: boolean; syntax_aware: boolean }; + nodes: StructuralGraphNode[]; + edges: StructuralGraphEdge[]; + metrics: StructuralGraphMetricFact[]; + clone_groups: StructuralCloneGroup[]; + communities: StructuralGraphCommunity[]; + truncated: boolean; + }; + warnings: string[]; +} + +export async function onStructuralGraphProgress( + handler: (progress: StructuralGraphProgress) => void +): Promise { + return listen('structural-graph-progress', (event) => { + handler(event.payload); + }); +} + +export async function buildStructuralGraph(repoPath: string): Promise { + return safeInvoke('build_structural_graph', { repoPath }); +} + +export async function cancelStructuralGraphBuild(repoPath: string): Promise { + return safeInvoke('cancel_structural_graph_build', { repoPath }); +} + +export async function getStructuralGraphStatus(repoPath: string): Promise { + return safeInvoke('get_structural_graph_status', { repoPath }); +} + +export async function getStructuralGraphMetadata( + repoPath: string +): Promise { + return safeInvoke('get_structural_graph_metadata', { repoPath }); +} + +export async function getStructuralGraphAdapters(): Promise { + return safeInvoke('get_structural_graph_adapters'); +} + +export async function previewNodeLinkStructuralGraph( + repoPath: string, + jsonText: string +): Promise { + return safeInvoke('preview_node_link_structural_graph', { repoPath, jsonText }); +} + +export async function exportStructuralGraphJson(repoPath: string): Promise { + return safeInvoke('export_structural_graph_json', { repoPath }); +} + +export async function exportStructuralGraphMarkdown(repoPath: string): Promise { + return safeInvoke('export_structural_graph_markdown', { repoPath }); +} + +export async function getStructuralGraphAnalysis( + repoPath: string +): Promise { + return safeInvoke('get_structural_graph_analysis', { repoPath }); +} + +export async function getStructuralGraphOverview( + repoPath: string, + limit?: number, + cursor?: string | null +): Promise { + return safeInvoke('get_structural_graph_overview', { + repoPath, + limit: limit ?? null, + cursor: cursor ?? null, + }); +} + +export async function getStructuralGraphCommunity( + repoPath: string, + communityId: string, + limit?: number, + cursor?: string | null +): Promise { + return safeInvoke('get_structural_graph_community', { + repoPath, + communityId, + limit: limit ?? null, + cursor: cursor ?? null, + }); +} + +export async function getStructuralGraphSubgraph( + repoPath: string, + seeds: string[], + options?: { depth?: number; filter?: StructuralGraphQueryFilter; limit?: number } +): Promise { + return safeInvoke('get_structural_graph_subgraph', { + repoPath, + seeds, + depth: options?.depth ?? null, + filter: options?.filter ?? null, + limit: options?.limit ?? null, + }); +} + +export async function listStructuralGraphSnapshots( + repoPath: string, + limit?: number +): Promise { + return safeInvoke('list_structural_graph_snapshots', { repoPath, limit: limit ?? null }); +} + +export async function diffStructuralGraphSnapshots( + repoPath: string, + beforeSnapshotId: string, + afterSnapshotId: string +): Promise { + return safeInvoke('diff_structural_graph_snapshots', { + repoPath, + beforeSnapshotId, + afterSnapshotId, + }); +} + +export async function searchStructuralGraph( + repoPath: string, + queryText: string, + filter?: StructuralGraphQueryFilter, + limit?: number, + cursor?: string | null +): Promise { + return safeInvoke('search_structural_graph', { + repoPath, + queryText, + filter: filter ?? null, + limit: limit ?? null, + cursor: cursor ?? null, + }); +} + +export async function explainStructuralGraphNode( + repoPath: string, + node: string +): Promise { + return safeInvoke('explain_structural_graph_node', { repoPath, node }); +} + +export async function getStructuralGraphNeighbors( + repoPath: string, + node: string, + options?: { + direction?: 'incoming' | 'outgoing' | 'both'; + filter?: StructuralGraphQueryFilter; + limit?: number; + cursor?: string | null; + } +): Promise { + return safeInvoke('get_structural_graph_neighbors', { + repoPath, + node, + direction: options?.direction ?? null, + filter: options?.filter ?? null, + limit: options?.limit ?? null, + cursor: options?.cursor ?? null, + }); +} + +export async function findStructuralGraphPath( + repoPath: string, + from: string, + to: string, + filter?: StructuralGraphQueryFilter +): Promise { + return safeInvoke('find_structural_graph_path', { + repoPath, + from, + to, + filter: filter ?? null, + }); +} + +export async function getStructuralGraphImpact( + repoPath: string, + node: string, + options?: { + direction?: 'incoming' | 'outgoing' | 'both'; + depth?: number; + filter?: StructuralGraphQueryFilter; + limit?: number; + } +): Promise { + return safeInvoke('get_structural_graph_impact', { + repoPath, + node, + direction: options?.direction ?? null, + depth: options?.depth ?? null, + filter: options?.filter ?? null, + limit: options?.limit ?? null, + }); +} + // ─── Unpack deep graph (call-graph indexing) ───────────────────────────────── interface UnpackDeepGraphStats { From e48cb840eda8e244c409453dda9a3d3aabcf2435 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:13:46 +0530 Subject: [PATCH 029/141] feat(graph): add interactive structural workbench --- .../src/components/deep-graph-viewer.tsx | 76 +- .../StructuralGraphWorkbench.tsx | 833 ++++++++++++++++++ apps/desktop/src/pages/RepoUnpacked.tsx | 2 + 3 files changed, 903 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/components/unpack-workspace/StructuralGraphWorkbench.tsx diff --git a/apps/desktop/src/components/deep-graph-viewer.tsx b/apps/desktop/src/components/deep-graph-viewer.tsx index b9918f1..f5ddcaf 100644 --- a/apps/desktop/src/components/deep-graph-viewer.tsx +++ b/apps/desktop/src/components/deep-graph-viewer.tsx @@ -78,10 +78,27 @@ function layoutGraph( graph: UnpackRepoGraph, mode: DeepGraphLookupMode, width: number, - height: number + height: number, + stableLayout: boolean ): LayoutNode[] { if (graph.nodes.length === 0) return []; + if (stableLayout) { + const marginX = Math.min(72, width * 0.12); + const marginY = Math.min(64, height * 0.15); + return graph.nodes.map((node) => { + const primary = stableHash(node.id); + const secondary = stableHash(`${node.id}:y`); + return { + id: node.id, + x: marginX + (primary / 0xffffffff) * Math.max(1, width - marginX * 2), + y: marginY + (secondary / 0xffffffff) * Math.max(1, height - marginY * 2), + node, + ring: 'query', + }; + }); + } + const cx = width / 2; const cy = height / 2; const centerId = findCenterId(graph); @@ -184,14 +201,25 @@ function layoutGraph( return [...placed.values()]; } +function stableHash(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + type Props = { graph: UnpackRepoGraph; mode: DeepGraphLookupMode; hits?: DeepGraphQueryHit[]; summary?: string; repoPath: string; - onSelectSymbol?: (name: string, path?: string | null) => void; + onSelectSymbol?: (name: string, path?: string | null, nodeId?: string) => void; onDrillContext?: (name: string, path?: string | null) => void; + stableLayout?: boolean; + nodeStates?: Record; }; export function DeepGraphViewer({ @@ -202,6 +230,8 @@ export function DeepGraphViewer({ repoPath, onSelectSymbol, onDrillContext, + stableLayout = false, + nodeStates = {}, }: Props) { const containerRef = useRef(null); const [size, setSize] = useState({ width: 720, height: 420 }); @@ -226,8 +256,8 @@ export function DeepGraphViewer({ }, []); const layout = useMemo( - () => layoutGraph(graph, mode, size.width, size.height), - [graph, mode, size.height, size.width] + () => layoutGraph(graph, mode, size.width, size.height, stableLayout), + [graph, mode, size.height, size.width, stableLayout] ); const layoutById = useMemo(() => new Map(layout.map((n) => [n.id, n])), [layout]); @@ -264,6 +294,10 @@ export function DeepGraphViewer({ return (
+
@@ -315,7 +349,7 @@ export function DeepGraphViewer({ )} onClick={() => { setSelectedId(hit.id); - onSelectSymbol?.(hit.name, hit.path); + onSelectSymbol?.(hit.name, hit.path, hit.id); }} onDoubleClick={() => onDrillContext?.(hit.name, hit.path)} > @@ -368,7 +402,12 @@ export function DeepGraphViewer({ - + {graph.edges.map((edge) => { @@ -401,20 +440,41 @@ export function DeepGraphViewer({ const isCenter = item.ring === 'center'; const isSelected = selectedId === item.id; const r = isCenter ? 18 : 12; + const changeState = nodeStates[item.id]; return ( { e.stopPropagation(); setSelectedId(item.id); - onSelectSymbol?.(item.node.label, item.node.path); + onSelectSymbol?.(item.node.label, item.node.path, item.id); }} onDoubleClick={(e) => { e.stopPropagation(); onDrillContext?.(item.node.label, item.node.path); }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + event.stopPropagation(); + setSelectedId(item.id); + onSelectSymbol?.(item.node.label, item.node.path, item.id); + }} > {isSelected && ( diff --git a/apps/desktop/src/components/unpack-workspace/StructuralGraphWorkbench.tsx b/apps/desktop/src/components/unpack-workspace/StructuralGraphWorkbench.tsx new file mode 100644 index 0000000..5406c7b --- /dev/null +++ b/apps/desktop/src/components/unpack-workspace/StructuralGraphWorkbench.tsx @@ -0,0 +1,833 @@ +import { + AlertTriangle, + Boxes, + Crosshair, + GitCompareArrows, + LoaderCircle, + Network, + RefreshCw, + Search, + ShieldCheck, +} from 'lucide-react'; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { DeepGraphViewer } from '@/components/deep-graph-viewer'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { SourceLink } from '@/components/unpack-workspace/SourceLink'; +import { + buildStructuralGraph, + diffStructuralGraphSnapshots, + findStructuralGraphPath, + getStructuralGraphAnalysis, + getStructuralGraphCommunity, + getStructuralGraphImpact, + getStructuralGraphNeighbors, + getStructuralGraphOverview, + getStructuralGraphStatus, + getStructuralGraphSubgraph, + isTauriAvailable, + listStructuralGraphSnapshots, + onStructuralGraphProgress, + searchStructuralGraph, + type StructuralGraphNode, + type StructuralGraphAnalysisSummary, + type StructuralGraphProgress, + type StructuralGraphProjection, + type StructuralGraphSearchResult, + type StructuralGraphSnapshotDiff, + type StructuralGraphStatus, + type StructuralGraphStoredSummary, + type StructuralGraphTrust, + type UnpackRepoGraph, +} from '@/lib/tauri-ipc'; +import { cn } from '@/lib/utils'; + +const TRUST_TONE: Record = { + extracted: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200', + inferred: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-200', + ambiguous: 'border-amber-400/30 bg-amber-400/10 text-amber-200', + legacy: 'border-slate-400/25 bg-slate-400/10 text-slate-300', +}; + +function viewerGraph( + projection: StructuralGraphProjection | null, + hiddenNodes: ReadonlySet +): UnpackRepoGraph { + if (!projection) return { schema_version: 3, nodes: [], edges: [], truncated: false }; + return { + schema_version: 3, + truncated: projection.truncated, + nodes: projection.nodes + .filter((node) => !hiddenNodes.has(node.id)) + .map((node) => ({ + id: node.id, + kind: node.kind, + label: node.label, + path: node.path, + detail: `${node.trust} · ${node.origin}${node.detail ? ` · ${node.detail}` : ''}`, + sources: node.sources.map((source) => source.path), + })), + edges: projection.edges + .filter((edge) => !hiddenNodes.has(edge.from) && !hiddenNodes.has(edge.to)) + .map((edge) => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + evidence: `${edge.trust} · ${edge.evidence}`, + sources: edge.sources.map((source) => source.path), + trust: edge.trust, + origin: edge.origin, + })), + }; +} + +function statusLabel(status: StructuralGraphStatus | null): string { + if (!status?.indexed) return 'Not indexed'; + if (status.building) return 'Indexing'; + if (status.stale) return 'Refresh available'; + return 'Current'; +} + +export function StructuralGraphWorkbench({ repoPath }: { repoPath: string }) { + const [status, setStatus] = useState(null); + const [analysis, setAnalysis] = useState(null); + const [snapshots, setSnapshots] = useState([]); + const [snapshotDiff, setSnapshotDiff] = useState(null); + const [projection, setProjection] = useState(null); + const [progress, setProgress] = useState(null); + const [searchText, setSearchText] = useState(''); + const [searchResult, setSearchResult] = useState(null); + const [selected, setSelected] = useState(null); + const [pathFrom, setPathFrom] = useState(''); + const [pathTo, setPathTo] = useState(''); + const [busy, setBusy] = useState(false); + const [activeCommunity, setActiveCommunity] = useState(null); + const [hideSuperHubs, setHideSuperHubs] = useState(false); + const [activeTrust, setActiveTrust] = useState([]); + const [pathNodeIds, setPathNodeIds] = useState>(() => new Set()); + const [pathSummary, setPathSummary] = useState(null); + const [error, setError] = useState(null); + const repoPathRef = useRef(repoPath); + const loadGeneration = useRef(0); + repoPathRef.current = repoPath; + + const refreshStatus = useCallback(async () => { + if (!isTauriAvailable()) return; + const requestedRepo = repoPath; + const next = await getStructuralGraphStatus(repoPath); + if (repoPathRef.current === requestedRepo) setStatus(next); + return next; + }, [repoPath]); + + const loadOverview = useCallback(async () => { + const generation = ++loadGeneration.current; + const requestedRepo = repoPath; + const [next, nextAnalysis, nextSnapshots] = await Promise.all([ + getStructuralGraphOverview(repoPath, 72), + getStructuralGraphAnalysis(repoPath), + listStructuralGraphSnapshots(repoPath, 20), + ]); + if (generation !== loadGeneration.current || requestedRepo !== repoPathRef.current) return; + setProjection(next); + setAnalysis(nextAnalysis); + setSnapshots(nextSnapshots); + setSnapshotDiff(null); + setActiveCommunity(null); + setSearchResult(null); + setSelected(null); + setPathNodeIds(new Set()); + setPathSummary(null); + }, [repoPath]); + + useEffect(() => { + let alive = true; + const generation = ++loadGeneration.current; + if (!isTauriAvailable()) return undefined; + void refreshStatus() + .then((next) => { + if (alive && generation === loadGeneration.current && next?.indexed) return loadOverview(); + return undefined; + }) + .catch((cause) => alive && setError(String(cause))); + let unlisten: (() => void) | undefined; + let disposed = false; + void onStructuralGraphProgress((next) => alive && setProgress(next)).then((stop) => { + if (disposed) stop(); + else unlisten = stop; + }); + return () => { + alive = false; + disposed = true; + unlisten?.(); + }; + }, [loadOverview, refreshStatus]); + + const hiddenNodes = useMemo( + () => + hideSuperHubs + ? new Set(analysis?.super_hubs.map((node) => node.node_id) ?? []) + : new Set(), + [analysis, hideSuperHubs] + ); + const visibleProjection = useMemo(() => { + if (!projection || activeTrust.length === 0) return projection; + const nodes = projection.nodes.filter((node) => activeTrust.includes(node.trust)); + const nodeIds = new Set(nodes.map((node) => node.id)); + const edges = projection.edges.filter( + (edge) => activeTrust.includes(edge.trust) && nodeIds.has(edge.from) && nodeIds.has(edge.to) + ); + return { ...projection, nodes, edges }; + }, [activeTrust, projection]); + const graph = useMemo( + () => viewerGraph(visibleProjection, hiddenNodes), + [hiddenNodes, visibleProjection] + ); + const pathNodeStates = useMemo( + () => + Object.fromEntries([...pathNodeIds].map((nodeId) => [nodeId, 'changed' as const])) as Record< + string, + 'changed' + >, + [pathNodeIds] + ); + const queryFilter = useMemo( + () => (activeTrust.length ? { trust: activeTrust } : undefined), + [activeTrust] + ); + const selectedSources = selected?.sources.slice(0, 3) ?? []; + const selectedEdges = useMemo( + () => + selected + ? (visibleProjection?.edges.filter( + (edge) => edge.from === selected.id || edge.to === selected.id + ) ?? []) + : [], + [selected, visibleProjection] + ); + + async function run(action: () => Promise) { + setBusy(true); + setError(null); + try { + await action(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + } + + function selectNode(label: string, path?: string | null, nodeId?: string) { + const node = + (nodeId + ? visibleProjection?.nodes.find((candidate) => candidate.id === nodeId) + : undefined) ?? + visibleProjection?.nodes.find( + (candidate) => candidate.label === label && (path == null || candidate.path === path) + ); + if (node) { + setSelected(node); + if (!pathFrom) setPathFrom(node.id); + else if (!pathTo && pathFrom !== node.id) setPathTo(node.id); + } + } + + async function submitSearch(event: FormEvent) { + event.preventDefault(); + const query = searchText.trim(); + if (!query) return; + await run(async () => { + const result = await searchStructuralGraph(repoPath, query, queryFilter, 30); + setSearchResult(result); + setPathNodeIds(new Set()); + setPathSummary(null); + const first = result?.hits[0]?.node; + if (!first) { + setProjection( + result ? { nodes: [], edges: [], truncated: false, context: result.context } : null + ); + setSelected(null); + return; + } + setSelected(first); + const neighborhood = await getStructuralGraphNeighbors(repoPath, first.id, { + direction: 'both', + filter: queryFilter, + limit: 90, + }); + setProjection(neighborhood); + }); + } + + async function inspectNode(node: StructuralGraphNode) { + await run(async () => { + setSelected(node); + setPathNodeIds(new Set()); + setPathSummary(null); + const neighborhood = await getStructuralGraphNeighbors(repoPath, node.id, { + direction: 'both', + filter: queryFilter, + limit: 90, + }); + setProjection(neighborhood); + }); + } + + async function showImpact() { + if (!selected) return; + await run(async () => { + const result = await getStructuralGraphImpact(repoPath, selected.id, { + depth: 4, + filter: queryFilter, + limit: 120, + }); + setProjection( + result + ? { + nodes: [result.root, ...result.affected], + edges: result.edges, + truncated: result.truncated, + context: result.context, + } + : null + ); + setPathNodeIds(new Set()); + setPathSummary(null); + }); + } + + async function showPath() { + if (!pathFrom.trim() || !pathTo.trim()) return; + await run(async () => { + const result = await findStructuralGraphPath(repoPath, pathFrom, pathTo, queryFilter); + setProjection( + result + ? { + nodes: result.nodes, + edges: result.edges, + truncated: result.truncated, + context: result.context, + } + : null + ); + const nodeIds = new Set(result?.nodes.map((node) => node.id) ?? []); + setPathNodeIds(nodeIds); + setPathSummary( + result + ? `${result.nodes.length} nodes · ${result.edges.length} edges · cost ${result.total_cost.toFixed(2)}` + : 'No path found' + ); + }); + } + + async function focusCommunity(communityId: string) { + await run(async () => { + const result = await getStructuralGraphCommunity(repoPath, communityId, 120); + setProjection(result); + setActiveCommunity(communityId); + setSelected(null); + setSearchResult(null); + setPathNodeIds(new Set()); + setPathSummary(null); + }); + } + + async function focusRank(nodeId: string) { + await run(async () => { + const result = await searchStructuralGraph(repoPath, nodeId, undefined, 1); + const node = result?.hits[0]?.node; + if (!node) return; + setSelected(node); + const neighborhood = await getStructuralGraphNeighbors(repoPath, node.id, { + direction: 'both', + filter: queryFilter, + limit: 90, + }); + setProjection(neighborhood); + setActiveCommunity(null); + setPathNodeIds(new Set()); + setPathSummary(null); + }); + } + + async function showContext() { + if (!selected) return; + await run(async () => { + const result = await getStructuralGraphSubgraph(repoPath, [selected.id], { + depth: 2, + filter: queryFilter, + limit: 120, + }); + setProjection(result); + setPathNodeIds(new Set()); + setPathSummary(null); + }); + } + + async function compareLatestSnapshots() { + if (snapshots.length < 2) return; + await run(async () => { + const result = await diffStructuralGraphSnapshots(repoPath, snapshots[1].id, snapshots[0].id); + setSnapshotDiff(result); + }); + } + + function toggleTrust(trust: StructuralGraphTrust) { + setPathNodeIds(new Set()); + setPathSummary(null); + setActiveTrust((current) => + current.includes(trust) ? current.filter((value) => value !== trust) : [...current, trust] + ); + } + + async function buildIndex() { + await run(async () => { + setProgress({ phase: 'starting', completed: 0, total: 1, detail: 'Preparing index' }); + await buildStructuralGraph(repoPath); + await refreshStatus(); + await loadOverview(); + setProgress(null); + }); + } + + if (!isTauriAvailable()) { + return ( +
+
+ + Structural graph runs in the desktop app +
+

+ Open this repository in CodeVetter to build and query the local Tree-sitter index. +

+
+ ); + } + + return ( +
+
+
+
+ +

+ Structural intelligence graph +

+ + schema v{status?.schema_version ?? 3} + + + {statusLabel(status)} + +
+

+ Source-located symbols and relationships with explicit trust. Queries return bounded + projections, so large repositories remain responsive. +

+ {status?.indexed ? ( +
+ {status.indexed_files.toLocaleString()} files + {status.node_count.toLocaleString()} nodes + {status.edge_count.toLocaleString()} edges + + {status.engine_id}@{status.engine_version} + +
+ ) : null} +
+ +
+ + {progress ? ( +
+
+ {progress.detail} + + {progress.completed.toLocaleString()} / {progress.total.toLocaleString()} + +
+
+
+
+
+ ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + {!status?.indexed ? ( +
+ +

+ Build the canonical local index +

+

+ The first build parses supported source files in parallel. Later refreshes parse only + changed files and retain stable node identities. +

+
+ ) : ( +
+
+
void submitSearch(event)}> + setSearchText(event.target.value)} + placeholder="Symbol, qualified name, file path, or stable id" + aria-label="Search structural graph" + /> + +
+ + setPathFrom(event.target.value)} + placeholder="Path from" + aria-label="Path start node" + /> + setPathTo(event.target.value)} + placeholder="Path to" + aria-label="Path target node" + /> + +
+ +
+ + Trust + + {(['extracted', 'inferred', 'ambiguous', 'legacy'] as StructuralGraphTrust[]).map( + (trust) => ( + + ) + )} + {snapshots.length > 1 ? ( + + ) : null} +
+ + {snapshotDiff ? ( +
+ Snapshot change: +{snapshotDiff.added_node_ids.length} / - + {snapshotDiff.removed_node_ids.length} / ~{snapshotDiff.changed_node_ids.length} nodes + · +{snapshotDiff.added_edge_ids.length} / -{snapshotDiff.removed_edge_ids.length} / ~ + {snapshotDiff.changed_edge_ids.length} edges +
+ ) : null} + + {projection?.context.coverage ? ( +
+ + Coverage {projection.context.coverage.indexed_files.toLocaleString()} /{' '} + {projection.context.coverage.discovered_files.toLocaleString()} files indexed + + {projection.context.coverage.skipped_files > 0 ? ( + {projection.context.coverage.skipped_files.toLocaleString()} skipped + ) : null} + {projection.context.coverage.error_files > 0 ? ( + + {projection.context.coverage.error_files.toLocaleString()} errors + + ) : null} + {projection.context.coverage.languages + .filter((language) => !language.supported && language.discovered_files > 0) + .map((language) => ( + + Unsupported: {language.language} ({language.discovered_files.toLocaleString()}{' '} + files) + + ))} +
+ ) : null} + + {pathSummary ? ( +
+ Highlighted trust-weighted path: {pathSummary} +
+ ) : null} + + {searchResult?.hits.length ? ( +
+ {searchResult.hits.map((hit) => ( + + ))} +
+ ) : null} + + {analysis ? ( +
+
+
+
+
+ Navigation communities +
+
+ Deterministic topology clusters seeded by repository paths +
+
+ {analysis.super_hubs.length ? ( + + ) : null} +
+
+ {[...analysis.communities] + .sort( + (left, right) => + right.score - left.score || left.label.localeCompare(right.label) + ) + .slice(0, 16) + .map((community) => ( + + ))} +
+
+
+
+ Hubs and bridges +
+
+ {[ + ...analysis.bridges.slice(0, 3).map((node) => ({ ...node, role: 'bridge' })), + ...analysis.hubs.slice(0, 3).map((node) => ({ ...node, role: 'hub' })), + ] + .slice(0, 6) + .map((node) => ( + + ))} +
+
+
+ ) : null} + +
+ + + +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/pages/RepoUnpacked.tsx b/apps/desktop/src/pages/RepoUnpacked.tsx index 56e5689..73e021c 100644 --- a/apps/desktop/src/pages/RepoUnpacked.tsx +++ b/apps/desktop/src/pages/RepoUnpacked.tsx @@ -53,6 +53,7 @@ import { UnpackHistoryList } from '@/components/unpack-workspace/UnpackHistoryLi import { UnpackMissionControl } from '@/components/unpack-workspace/UnpackMissionControl'; import { RepoMemoryGraphPanel } from '@/components/unpack-workspace/RepoMemoryGraphPanel'; import { RepoMemoryPanel } from '@/components/unpack-workspace/RepoMemoryPanel'; +import { StructuralGraphWorkbench } from '@/components/unpack-workspace/StructuralGraphWorkbench'; import { TasteVerdictCard } from '@/components/unpack-workspace/TasteVerdictCard'; import { DisclosurePanel } from '@/components/unpack-workspace/DisclosurePanel'; import { UnpackSectionNav } from '@/components/unpack-workspace/UnpackSectionNav'; @@ -3701,6 +3702,7 @@ const InventorySummary = memo(function InventorySummary({ {showIntelligence ? ( <> + Date: Wed, 15 Jul 2026 11:22:11 +0530 Subject: [PATCH 030/141] fix(unpack): synchronize section route state --- apps/desktop/src/pages/RepoUnpacked.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/pages/RepoUnpacked.tsx b/apps/desktop/src/pages/RepoUnpacked.tsx index 73e021c..4eb7c84 100644 --- a/apps/desktop/src/pages/RepoUnpacked.tsx +++ b/apps/desktop/src/pages/RepoUnpacked.tsx @@ -1125,7 +1125,11 @@ export function UnpackProjectPanel({ useEffect(() => { const requested = searchParams.get('section'); const normalized = - requested === 'intel' || requested === 'attribution' ? 'activity' : requested; + requested == null + ? 'overview' + : requested === 'intel' || requested === 'attribution' + ? 'activity' + : requested; if (!isUnpackSection(normalized) || normalized === activeSection) return; if (!sections.some((section) => section.id === normalized)) return; setActiveSection(normalized); @@ -1133,7 +1137,6 @@ export function UnpackProjectPanel({ const handleSectionChange = useCallback( (section: UnpackWorkspaceSection) => { - setActiveSection(section); setSearchParams( (prev) => { const next = new URLSearchParams(prev); From fc76d192c1575c80ff466743bdd69b365bac9edd Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:22:11 +0530 Subject: [PATCH 031/141] test(graph): cover structural workbench --- apps/desktop/tests/e2e/repo-unpacked.spec.ts | 46 ++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/desktop/tests/e2e/repo-unpacked.spec.ts b/apps/desktop/tests/e2e/repo-unpacked.spec.ts index baa637d..371d80d 100644 --- a/apps/desktop/tests/e2e/repo-unpacked.spec.ts +++ b/apps/desktop/tests/e2e/repo-unpacked.spec.ts @@ -512,6 +512,25 @@ async function installRepoUnpackedMock(page: import('@playwright/test').Page) { }; } if (cmd === 'get_unpack_outcome_evidence') return outcomeEvidence; + if (cmd === 'get_structural_graph_status') { + return { + repo_path: inventory.repo_path, + indexed: false, + building: false, + stale: false, + current_head: inventory.commit_sha, + indexed_head: null, + snapshot_id: null, + schema_version: null, + engine_id: null, + engine_version: null, + created_at: null, + indexed_files: 0, + node_count: 0, + edge_count: 0, + }; + } + if (cmd.startsWith('plugin:event|')) return 1; throw new Error(`unhandled mocked command: ${cmd}`); }, transformCallback: () => 1, @@ -550,11 +569,11 @@ test.describe('Repo Unpacked page', () => { await page .getByRole('navigation', { name: 'Unpack sections' }) - .getByRole('button', { name: 'Memory' }) + .getByRole('button', { name: 'Handoff' }) .click(); - await expect(page.getByRole('heading', { name: 'Repo memory' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Handoff' })).toBeVisible(); await expect(page.getByText('Start here', { exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Export memory' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); await page .getByRole('navigation', { name: 'Unpack sections' }) @@ -594,4 +613,25 @@ test.describe('Repo Unpacked page', () => { await page.getByRole('button', { name: /Copy packet/i }).click(); await expect(page.getByRole('button', { name: 'Copied' })).toBeVisible(); }); + + test('graph section exposes the local structural workbench', async ({ page }) => { + await installRepoUnpackedMock(page); + await navigateTo(page, '/unpack'); + await waitForNoSpinners(page); + + await page + .locator('aside') + .getByRole('button', { name: /^world-class-repo/i }) + .click(); + await page + .getByRole('navigation', { name: 'Unpack sections' }) + .getByRole('button', { name: 'Graph' }) + .click(); + + await expect( + page.getByRole('heading', { name: 'Structural intelligence graph' }) + ).toBeVisible(); + await expect(page.getByText('Build the canonical local index')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Build index' })).toBeVisible(); + }); }); From 876d4bfbe2869518ba520f9d371ffdcecf4a57a5 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:24:52 +0530 Subject: [PATCH 032/141] refactor(core): share backend services across transports --- apps/desktop/src-tauri/src/lib.rs | 21 +++++++++++ apps/desktop/src-tauri/src/main.rs | 56 ++++++++++++------------------ 2 files changed, 44 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src-tauri/src/lib.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..721e82b --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,21 @@ +// Tauri command handlers intentionally keep their stable, flat IPC argument +// contracts. Bundling those parameters into Rust-only structs would complicate +// serialization and silently change the frontend command ABI. +#![allow(clippy::too_many_arguments)] + +//! Shared CodeVetter backend library. +//! +//! Transport adapters share typed services instead of duplicating SQL or +//! repository interpretation. + +pub mod agent; +pub mod commands; +pub mod db; +pub mod talk; +pub mod timeutil; + +use std::sync::{Arc, Mutex}; + +/// Shared database state accessible from transport command handlers. +#[derive(Clone)] +pub struct DbState(pub Arc>); diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed3e4aa..397e312 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,12 +1,7 @@ // Prevent a console window from popping up on Windows release builds. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod agent; -mod commands; -mod db; -mod talk; -mod timeutil; - +use codevetter_desktop::{commands, db, DbState}; use std::sync::{Arc, Mutex}; use tauri::Manager; @@ -14,11 +9,6 @@ const STARTUP_FULL_INDEX_DELAY_SECS: u64 = 6 * 60 * 60; const PERIODIC_INDEX_INITIAL_DELAY_SECS: u64 = 6 * 60 * 60; const PERIODIC_INDEX_INTERVAL_SECS: u64 = commands::history::FULL_INDEX_RECOVERY_INTERVAL_SECS; -/// Shared database state accessible from every Tauri command via -/// `tauri::State`. -#[derive(Clone)] -pub struct DbState(pub Arc>); - /// Repair `PATH` for GUI launches. /// /// When the app is started from Finder/Dock (not a terminal), macOS gives it a @@ -93,19 +83,19 @@ fn run_usage_maintenance(app_data_dir: std::path::PathBuf) { // Repair Codex token totals corrupted by the old cumulative-add bug // (one-time), then refresh stored per-session $ cost if the price // table changed. - crate::commands::history::fix_codex_token_totals(&conn); + commands::history::fix_codex_token_totals(&conn); // One-time per-model usage backfill (v1.1.100) — must precede the // cost recompute so multi-model sessions reprice from their split. - crate::commands::history::backfill_session_model_usage(&conn); + commands::history::backfill_session_model_usage(&conn); // Relabel o3-defaulted Codex sessions from their turn_context rows // before cost recompute so rev-6+ pricing books corrected models. - crate::commands::history::backfill_codex_session_models(&conn); + commands::history::backfill_codex_session_models(&conn); // One-time Claude usage dedup: re-scan on-disk transcripts counting // each API response's usage once (duplicate content-block lines // inflated Claude numbers ~2.2×). Rewrites totals + cost directly, // so ordering vs the pricing recompute below doesn't matter. - crate::commands::history::fix_claude_usage_dedup(&conn); - crate::commands::history::recompute_all_session_costs(&conn); + commands::history::fix_claude_usage_dedup(&conn); + commands::history::recompute_all_session_costs(&conn); log::info!("Usage maintenance done."); } Err(e) => log::error!("Usage maintenance DB init failed: {e}"), @@ -170,7 +160,7 @@ fn main() { match run_full_index(bg_data_dir.clone()) { Ok(summary) => { log::info!("Full index complete: {}", summary.log_message()); - crate::commands::history::emit_session_archive_updated( + commands::history::emit_session_archive_updated( &bg_handle, &summary, ); } @@ -201,7 +191,7 @@ fn main() { loop { match db::init_db(periodic_data_dir.clone()) { Ok(conn) => { - match crate::commands::history::try_run_full_index_summary_with_conn( + match commands::history::try_run_full_index_summary_with_conn( &conn, ) { Ok(Some(summary)) => { @@ -209,7 +199,7 @@ fn main() { "Periodic re-index complete: {}", summary.log_message() ); - crate::commands::history::emit_session_archive_updated( + commands::history::emit_session_archive_updated( &periodic_handle, &summary, ); @@ -250,7 +240,7 @@ fn main() { .spawn(move || { set_thread_background_qos(); std::thread::sleep(std::time::Duration::from_secs( - crate::commands::history::LIVE_TRANSCRIPT_INITIAL_DELAY_SECS, + commands::history::LIVE_TRANSCRIPT_INITIAL_DELAY_SECS, )); // Grok/Cursor aren't transcript-tailable, so they only // refreshed on the 5-min full index and lagged Claude/Codex. @@ -262,7 +252,7 @@ fn main() { match db::init_db(tail_data_dir.clone()) { Ok(conn) => { let _ = conn.busy_timeout(std::time::Duration::from_millis(250)); - match crate::commands::history::tail_live_transcript_sessions_with_conn( + match commands::history::tail_live_transcript_sessions_with_conn( &conn, ) { Ok(summary) => { @@ -273,7 +263,7 @@ fn main() { summary.sessions_tailed ); let archive_summary = - crate::commands::history::FullIndexSummary { + commands::history::FullIndexSummary { indexed_sessions: summary.sessions_tailed, indexed_messages: summary.messages_indexed, skipped_sessions: 0, @@ -282,7 +272,7 @@ fn main() { as i64, indexed_at: summary.tailed_at, }; - crate::commands::history::emit_session_archive_updated( + commands::history::emit_session_archive_updated( &tail_handle, &archive_summary, ); @@ -294,11 +284,11 @@ fn main() { } if tick - % (crate::commands::history::LIVE_SECONDARY_ADAPTER_INTERVAL_SECS - / crate::commands::history::LIVE_TRANSCRIPT_INTERVAL_SECS) + % (commands::history::LIVE_SECONDARY_ADAPTER_INTERVAL_SECS + / commands::history::LIVE_TRANSCRIPT_INTERVAL_SECS) == 0 { - match crate::commands::history::refresh_secondary_agents_with_conn( + match commands::history::refresh_secondary_agents_with_conn( &conn, ) { Ok(summary) if summary.sessions_tailed > 0 => { @@ -307,14 +297,14 @@ fn main() { summary.sessions_tailed ); let archive_summary = - crate::commands::history::FullIndexSummary { + commands::history::FullIndexSummary { indexed_sessions: summary.sessions_tailed, indexed_messages: summary.messages_indexed, skipped_sessions: 0, archive_search_rows_indexed: 0, indexed_at: summary.tailed_at, }; - crate::commands::history::emit_session_archive_updated( + commands::history::emit_session_archive_updated( &tail_handle, &archive_summary, ); @@ -332,7 +322,7 @@ fn main() { } tick = tick.wrapping_add(1); std::thread::sleep(std::time::Duration::from_secs( - crate::commands::history::LIVE_TRANSCRIPT_INTERVAL_SECS, + commands::history::LIVE_TRANSCRIPT_INTERVAL_SECS, )); } }) @@ -510,7 +500,7 @@ fn main() { /// Run a lightweight startup index using its own database connection. fn run_initial_index(app_data_dir: std::path::PathBuf) -> Result { - use crate::db::queries; + use db::queries; let conn = db::init_db(app_data_dir).map_err(|e| e.to_string())?; @@ -630,8 +620,8 @@ fn run_initial_index(app_data_dir: std::path::PathBuf) -> Result fn run_full_index( app_data_dir: std::path::PathBuf, -) -> Result { - use crate::commands::history; +) -> Result { + use commands::history; let conn = db::init_db(app_data_dir).map_err(|e| e.to_string())?; history::run_full_index_summary_with_conn(&conn) } @@ -646,7 +636,7 @@ struct QuickMeta { } fn quick_parse_session_meta(path: &std::path::Path) -> (String, QuickMeta) { - use crate::commands::session_adapters::{ClaudeCodeAdapter, SessionSourceAdapter}; + use commands::session_adapters::{ClaudeCodeAdapter, SessionSourceAdapter}; use std::io::BufRead; let file = match std::fs::File::open(path) { From f6b3f04be332a8f59c54f92f756f2ce60c746589 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:25:55 +0530 Subject: [PATCH 033/141] refactor(history): reserve canonical graph module --- .../src/commands/{history_graph.rs => history_summary_graph.rs} | 0 apps/desktop/src-tauri/src/commands/mod.rs | 2 +- apps/desktop/src-tauri/src/commands/unpack_analysis.rs | 2 +- apps/desktop/src-tauri/src/main.rs | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename apps/desktop/src-tauri/src/commands/{history_graph.rs => history_summary_graph.rs} (100%) diff --git a/apps/desktop/src-tauri/src/commands/history_graph.rs b/apps/desktop/src-tauri/src/commands/history_summary_graph.rs similarity index 100% rename from apps/desktop/src-tauri/src/commands/history_graph.rs rename to apps/desktop/src-tauri/src/commands/history_summary_graph.rs diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 693c716..145e943 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -12,7 +12,7 @@ pub mod files; pub mod git; pub mod graph_trust; pub mod history; -pub mod history_graph; +pub mod history_summary_graph; pub mod intel; pub mod observability; #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/commands/unpack_analysis.rs b/apps/desktop/src-tauri/src/commands/unpack_analysis.rs index 9b4e625..f2332c2 100644 --- a/apps/desktop/src-tauri/src/commands/unpack_analysis.rs +++ b/apps/desktop/src-tauri/src/commands/unpack_analysis.rs @@ -1,6 +1,6 @@ //! Full/deferred Repo Unpacked analysis: graph, health, and history. -use crate::commands::history_graph::build_history_graph; +use crate::commands::history_summary_graph::build_history_graph; use crate::commands::unpack_qa::{push_unique_limited, suggested_qa_flows}; use crate::commands::unpack_scan::is_binary_path; use crate::commands::unpack_types::{ diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 397e312..c4c8370 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -455,7 +455,7 @@ fn main() { commands::unpack::export_repo_unpack_report, commands::graph_trust::import_graphify_preview, commands::graph_trust::trace_repo_graph_path, - commands::history_graph::query_repo_history_graph, + commands::history_summary_graph::query_repo_history_graph, // Canonical structural repository graph commands::structural_graph::api::build_structural_graph, commands::structural_graph::api::cancel_structural_graph_build, From a5f2822078ce0880be346791cc1f1da075fee456 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:26:28 +0530 Subject: [PATCH 034/141] feat(history): centralize git tag metadata --- .../src-tauri/src/commands/git_metadata.rs | 95 +++++++++++++++++++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + 2 files changed, 96 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/git_metadata.rs diff --git a/apps/desktop/src-tauri/src/commands/git_metadata.rs b/apps/desktop/src-tauri/src/commands/git_metadata.rs new file mode 100644 index 0000000..6992546 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/git_metadata.rs @@ -0,0 +1,95 @@ +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitTagRecord { + pub name: String, + pub object_sha: String, + pub commit_sha: String, + pub created_ts: i64, +} + +/// Read tag identity once for DORA and temporal history without changing either +/// consumer's semantics: DORA keeps the tag object SHA, while history uses the +/// peeled commit SHA for ancestry and checkpoint assignment. +pub fn read_git_tags(repo_path: &Path) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args([ + "for-each-ref", + "--format=%(refname:short)%09%(objectname)%09%(*objectname)%09%(creatordate:unix)", + "refs/tags", + ]) + .output() + .map_err(|error| format!("git for-each-ref: {error}"))?; + if !output.status.success() { + return Err(format!( + "git for-each-ref failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let mut tags = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let fields = line.splitn(4, '\t').collect::>(); + if fields.len() != 4 || fields[0].is_empty() { + continue; + } + let created_ts = fields[3].parse::().unwrap_or_default(); + if created_ts <= 0 { + continue; + } + tags.push(GitTagRecord { + name: fields[0].to_string(), + object_sha: fields[1].to_string(), + commit_sha: if fields[2].is_empty() { + fields[1].to_string() + } else { + fields[2].to_string() + }, + created_ts, + }); + } + tags.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.object_sha.cmp(&right.object_sha)) + }); + Ok(tags) +} + +/// Matches v1.2.3, 1.2.3, v1.2.3-rc.1, v2024.04.05, and 1.2. +pub fn is_release_tag(tag: &str) -> bool { + let normalized = tag.trim_start_matches('v').trim_start_matches('V'); + if normalized.is_empty() { + return false; + } + let head = normalized.split(['-', '+']).next().unwrap_or(normalized); + let mut digits = 0; + let mut dots = 0; + for byte in head.bytes() { + if byte.is_ascii_digit() { + digits += 1; + } else if byte == b'.' { + dots += 1; + } else { + return false; + } + } + digits > 0 && dots >= 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_tag_classifier_keeps_dora_semantics() { + for tag in ["v1.2.3", "1.2.3", "v1.2", "v1.2.3-rc.1", "v2024.04.05"] { + assert!(is_release_tag(tag), "{tag}"); + } + for tag in ["latest", "nightly", "release-candidate", "", "v", "vfoo"] { + assert!(!is_release_tag(tag), "{tag}"); + } + } +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 145e943..30ba035 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod dora; pub mod evidence_pattern; pub mod files; pub mod git; +pub mod git_metadata; pub mod graph_trust; pub mod history; pub mod history_summary_graph; From d50d58ee7723824dcd026bc14e3dbc428c73c838 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:29:39 +0530 Subject: [PATCH 035/141] feat(history): migrate compressed temporal storage --- apps/desktop/src-tauri/Cargo.lock | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/src/db/history_graph_schema.rs | 124 +++++++++++++++ apps/desktop/src-tauri/src/db/mod.rs | 1 + apps/desktop/src-tauri/src/db/schema.rs | 1 + .../src-tauri/src/db/schema/history_graph.sql | 144 ++++++++++++++++++ 6 files changed, 272 insertions(+) create mode 100644 apps/desktop/src-tauri/src/db/history_graph_schema.rs create mode 100644 apps/desktop/src-tauri/src/db/schema/history_graph.sql diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index cd78d08..a9c51c0 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -635,6 +635,7 @@ dependencies = [ "chromiumoxide", "chrono", "env_logger", + "flate2", "futures", "jwalk", "libc", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1a96614..009db5d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature log = "0.4" env_logger = "0.11" base64 = "0.22" +flate2 = "1" chromiumoxide = { version = "0.7", default-features = false, features = ["tokio-runtime"], optional = true } futures = { version = "0.3", optional = true } sysinfo = { version = "0.32", default-features = false, features = ["system"] } diff --git a/apps/desktop/src-tauri/src/db/history_graph_schema.rs b/apps/desktop/src-tauri/src/db/history_graph_schema.rs new file mode 100644 index 0000000..0083cbf --- /dev/null +++ b/apps/desktop/src-tauri/src/db/history_graph_schema.rs @@ -0,0 +1,124 @@ +use rusqlite::Connection; + +const MIGRATION_SQL: &str = include_str!("schema/history_graph.sql"); + +pub fn run_migration(conn: &Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch(MIGRATION_SQL)?; + run_additive_migrations(conn) +} + +fn run_additive_migrations(conn: &Connection) -> Result<(), rusqlite::Error> { + let _ = conn.execute( + "ALTER TABLE history_graph_annotations ADD COLUMN decision TEXT", + [], + ); + let _ = conn.execute( + "ALTER TABLE history_graph_annotations ADD COLUMN related_event_id TEXT", + [], + ); + let _ = conn.execute( + "ALTER TABLE history_graph_annotations ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'", + [], + ); + let _ = conn.execute( + "ALTER TABLE history_graph_events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1", + [], + ); + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_history_graph_annotations_evidence + ON history_graph_annotations(repo_path, related_event_id, created_at)", + [], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn temporal_graph_schema_is_indexed_and_idempotent() { + let conn = Connection::open_in_memory().expect("database"); + conn.execute_batch("PRAGMA foreign_keys = ON;") + .expect("foreign keys"); + + run_migration(&conn).expect("first migration"); + run_migration(&conn).expect("idempotent migration"); + + let tables = schema_objects(&conn, "table", "history_graph_%"); + assert_eq!( + tables, + BTreeSet::from([ + "history_graph_annotations".to_string(), + "history_graph_checkpoints".to_string(), + "history_graph_event_blobs".to_string(), + "history_graph_events".to_string(), + "history_graph_repositories".to_string(), + "history_graph_revision_paths".to_string(), + "history_graph_revisions".to_string(), + "history_graph_snapshot_blobs".to_string(), + ]) + ); + + let indexes = schema_objects(&conn, "index", "idx_history_graph_%"); + for required in [ + "idx_history_graph_annotations_evidence", + "idx_history_graph_events_entity", + "idx_history_graph_events_revision", + "idx_history_graph_paths_path", + "idx_history_graph_revisions_time", + ] { + assert!(indexes.contains(required), "missing {required}"); + } + } + + #[test] + fn legacy_event_and_annotation_tables_gain_additive_columns() { + let conn = Connection::open_in_memory().expect("database"); + conn.execute_batch( + "CREATE TABLE history_graph_events (id TEXT PRIMARY KEY); + CREATE TABLE history_graph_annotations ( + id TEXT PRIMARY KEY, + repo_path TEXT NOT NULL, + created_at TEXT NOT NULL + );", + ) + .expect("legacy schema"); + + run_additive_migrations(&conn).expect("upgrade legacy schema"); + + let event_columns = table_columns(&conn, "history_graph_events"); + assert!(event_columns.contains("schema_version")); + let annotation_columns = table_columns(&conn, "history_graph_annotations"); + for required in ["decision", "related_event_id", "metadata_json"] { + assert!(annotation_columns.contains(required), "missing {required}"); + } + assert!( + schema_objects(&conn, "index", "idx_history_graph_annotations_evidence") + .contains("idx_history_graph_annotations_evidence") + ); + } + + fn table_columns(conn: &Connection, table: &str) -> BTreeSet { + let mut statement = conn + .prepare(&format!("PRAGMA table_info({table})")) + .expect("prepare columns"); + statement + .query_map([], |row| row.get(1)) + .expect("query columns") + .collect::>() + .expect("columns") + } + + fn schema_objects(conn: &Connection, kind: &str, pattern: &str) -> BTreeSet { + let mut statement = conn + .prepare("SELECT name FROM sqlite_master WHERE type = ?1 AND name LIKE ?2") + .expect("prepare schema lookup"); + statement + .query_map([kind, pattern], |row| row.get(0)) + .expect("query schema") + .collect::>() + .expect("schema objects") + } +} diff --git a/apps/desktop/src-tauri/src/db/mod.rs b/apps/desktop/src-tauri/src/db/mod.rs index 1024876..a547c22 100644 --- a/apps/desktop/src-tauri/src/db/mod.rs +++ b/apps/desktop/src-tauri/src/db/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod history_graph_schema; pub mod queries; pub mod schema; pub(crate) mod structural_graph_schema; diff --git a/apps/desktop/src-tauri/src/db/schema.rs b/apps/desktop/src-tauri/src/db/schema.rs index 20c7a86..e3912b8 100644 --- a/apps/desktop/src-tauri/src/db/schema.rs +++ b/apps/desktop/src-tauri/src/db/schema.rs @@ -4,6 +4,7 @@ use rusqlite::Connection; /// (`IF NOT EXISTS`) so this function is safe to call on every startup. pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch(MIGRATION_SQL)?; + super::history_graph_schema::run_migration(conn)?; super::structural_graph_schema::run_migration(conn)?; // Incremental migrations — safe to re-run (ignore "duplicate column" errors). diff --git a/apps/desktop/src-tauri/src/db/schema/history_graph.sql b/apps/desktop/src-tauri/src/db/schema/history_graph.sql new file mode 100644 index 0000000..d738094 --- /dev/null +++ b/apps/desktop/src-tauri/src/db/schema/history_graph.sql @@ -0,0 +1,144 @@ +CREATE TABLE IF NOT EXISTS history_graph_repositories ( + repo_path TEXT PRIMARY KEY, + repository_fingerprint TEXT NOT NULL, + indexed_head TEXT, + indexed_tags_fingerprint TEXT, + status TEXT NOT NULL DEFAULT 'pending', + cursor_json TEXT NOT NULL DEFAULT '{}', + coverage_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_history_graph_repositories_status + ON history_graph_repositories(status, updated_at); + +CREATE TABLE IF NOT EXISTS history_graph_revisions ( + repo_path TEXT NOT NULL REFERENCES history_graph_repositories(repo_path) ON DELETE CASCADE, + sha TEXT NOT NULL, + ordinal INTEGER NOT NULL, + committed_at TEXT NOT NULL, + author_name TEXT NOT NULL, + author_email_hash TEXT, + subject TEXT NOT NULL, + parents_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + is_release INTEGER NOT NULL DEFAULT 0, + is_head INTEGER NOT NULL DEFAULT 0, + coverage_json TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (repo_path, sha) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_history_graph_revisions_ordinal + ON history_graph_revisions(repo_path, ordinal); +CREATE INDEX IF NOT EXISTS idx_history_graph_revisions_time + ON history_graph_revisions(repo_path, committed_at, ordinal); +CREATE INDEX IF NOT EXISTS idx_history_graph_revisions_release + ON history_graph_revisions(repo_path, is_release, ordinal); + +CREATE TABLE IF NOT EXISTS history_graph_revision_paths ( + repo_path TEXT NOT NULL, + revision_sha TEXT NOT NULL, + path TEXT NOT NULL, + change_kind TEXT NOT NULL, + old_path TEXT, + additions INTEGER, + deletions INTEGER, + PRIMARY KEY (repo_path, revision_sha, path), + FOREIGN KEY (repo_path, revision_sha) + REFERENCES history_graph_revisions(repo_path, sha) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_history_graph_paths_path + ON history_graph_revision_paths(repo_path, path, revision_sha); +CREATE INDEX IF NOT EXISTS idx_history_graph_paths_old_path + ON history_graph_revision_paths(repo_path, old_path, revision_sha); + +CREATE TABLE IF NOT EXISTS history_graph_checkpoints ( + repo_path TEXT NOT NULL, + revision_sha TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + engine_id TEXT NOT NULL, + engine_version TEXT NOT NULL, + schema_version INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'ready', + coverage_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + PRIMARY KEY (repo_path, revision_sha, engine_id, engine_version, schema_version), + FOREIGN KEY (repo_path, revision_sha) + REFERENCES history_graph_revisions(repo_path, sha) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_history_graph_checkpoints_snapshot + ON history_graph_checkpoints(snapshot_id); + +CREATE TABLE IF NOT EXISTS history_graph_snapshot_blobs ( + snapshot_id TEXT PRIMARY KEY, + repo_path TEXT NOT NULL, + revision_sha TEXT NOT NULL, + encoding TEXT NOT NULL, + payload BLOB NOT NULL, + uncompressed_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (repo_path, revision_sha) + REFERENCES history_graph_revisions(repo_path, sha) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_history_graph_snapshot_blobs_revision + ON history_graph_snapshot_blobs(repo_path, revision_sha); + +CREATE TABLE IF NOT EXISTS history_graph_events ( + id TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL DEFAULT 1, + repo_path TEXT NOT NULL REFERENCES history_graph_repositories(repo_path) ON DELETE CASCADE, + revision_sha TEXT, + event_kind TEXT NOT NULL, + entity_id TEXT, + related_entity_id TEXT, + relation_kind TEXT, + trust TEXT NOT NULL, + origin TEXT NOT NULL, + source_id TEXT NOT NULL, + source_cursor TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + evidence_json TEXT NOT NULL DEFAULT '[]', + recorded_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_history_graph_events_revision + ON history_graph_events(repo_path, revision_sha, event_kind); +CREATE INDEX IF NOT EXISTS idx_history_graph_events_entity + ON history_graph_events(repo_path, entity_id, event_kind, recorded_at); +CREATE INDEX IF NOT EXISTS idx_history_graph_events_relation + ON history_graph_events(repo_path, related_entity_id, relation_kind, recorded_at); +CREATE INDEX IF NOT EXISTS idx_history_graph_events_source + ON history_graph_events(repo_path, source_id, source_cursor); +CREATE INDEX IF NOT EXISTS idx_history_graph_events_time + ON history_graph_events(repo_path, recorded_at DESC, id DESC); + +CREATE TABLE IF NOT EXISTS history_graph_event_blobs ( + event_id TEXT PRIMARY KEY REFERENCES history_graph_events(id) ON DELETE CASCADE, + encoding TEXT NOT NULL, + payload BLOB NOT NULL, + uncompressed_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS history_graph_annotations ( + id TEXT PRIMARY KEY, + repo_path TEXT NOT NULL REFERENCES history_graph_repositories(repo_path) ON DELETE CASCADE, + revision_sha TEXT, + entity_id TEXT, + author TEXT NOT NULL, + body TEXT NOT NULL, + decision TEXT, + related_event_id TEXT, + source TEXT NOT NULL DEFAULT 'user', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_history_graph_annotations_target + ON history_graph_annotations(repo_path, revision_sha, entity_id, created_at); + + From 1944f8fe8fa9cb0efd0c89658478249a7d0ed767 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:30:24 +0530 Subject: [PATCH 036/141] feat(history): ingest bounded local evidence --- .../src/commands/history_evidence/mod.rs | 9 + .../src/commands/history_evidence/service.rs | 814 ++++++++++++++++++ .../history_evidence/service/tests.rs | 201 +++++ .../src/commands/history_evidence/types.rs | 128 +++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + 5 files changed, 1153 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_evidence/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_evidence/service.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_evidence/service/tests.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_evidence/types.rs diff --git a/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs b/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs new file mode 100644 index 0000000..7c9efcd --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs @@ -0,0 +1,9 @@ +mod service; +mod types; + +pub(crate) use service::refresh_builtin_adapters; +pub use service::{ + deterministic_evidence_id, get_history_evidence_adapters, import_history_evidence_export, + refresh_history_evidence, +}; +pub use types::*; diff --git a/apps/desktop/src-tauri/src/commands/history_evidence/service.rs b/apps/desktop/src-tauri/src/commands/history_evidence/service.rs new file mode 100644 index 0000000..080b0b2 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_evidence/service.rs @@ -0,0 +1,814 @@ +use super::types::*; +use crate::commands::secret_policy::{ + contains_sensitive_path, looks_like_secret, redact_secret_text, +}; +use crate::commands::structural_graph::types::{stable_graph_id, GraphSourceAnchor, GraphTrust}; +use crate::DbState; +use chrono::Utc; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::{fs, io::Read}; +use tauri::State; + +pub fn deterministic_evidence_id( + adapter_id: &str, + source_record_id: &str, + effective_at: Option<&str>, +) -> String { + stable_graph_id( + "history-evidence", + &format!( + "{adapter_id}\0{source_record_id}\0{}", + effective_at.unwrap_or("") + ), + ) +} + +#[tauri::command] +pub async fn get_history_evidence_adapters( + repo_path: String, + db: State<'_, DbState>, +) -> Result, String> { + let repo_path = canonical_repo_path(&repo_path)?; + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + adapter_descriptors(&connection, &repo_path) + }) + .await + .map_err(|error| format!("History adapter status worker failed: {error}"))? +} + +#[tauri::command] +pub async fn refresh_history_evidence( + repo_path: String, + db: State<'_, DbState>, +) -> Result { + let repo_path = canonical_repo_path(&repo_path)?; + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let mut connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + refresh_builtin_adapters(&mut connection, &repo_path) + }) + .await + .map_err(|error| format!("History evidence refresh worker failed: {error}"))? +} + +#[tauri::command] +pub async fn import_history_evidence_export( + repo_path: String, + file_path: String, + db: State<'_, DbState>, +) -> Result { + let repo_path = canonical_repo_path(&repo_path)?; + let file_path = PathBuf::from(file_path.trim()); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let export = read_local_evidence_export(&file_path)?; + let refreshed_at = Utc::now().to_rfc3339(); + let records = normalize_local_export(export)?; + let mut connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + persist_imported_records(&mut connection, &repo_path, &records, &refreshed_at) + }) + .await + .map_err(|error| format!("History evidence import worker failed: {error}"))? +} + +fn read_local_evidence_export(file_path: &Path) -> Result { + if file_path.extension().and_then(|value| value.to_str()) != Some("json") { + return Err("History evidence imports must be JSON files".to_string()); + } + let mut file = fs::File::open(file_path) + .map_err(|error| format!("Open local evidence export: {error}"))?; + let size = file + .metadata() + .map_err(|error| format!("Read local evidence export metadata: {error}"))? + .len(); + if size > 16 * 1024 * 1024 { + return Err("History evidence export exceeds the 16 MiB local import bound".to_string()); + } + let mut json = String::with_capacity(size as usize); + file.read_to_string(&mut json) + .map_err(|error| format!("Read local evidence export: {error}"))?; + let export: HistoryLocalEvidenceExport = + serde_json::from_str(&json).map_err(|error| format!("Parse evidence export: {error}"))?; + if export.schema_version != 1 { + return Err(format!( + "Unsupported history evidence export schema {}", + export.schema_version + )); + } + if export.records.len() > 10_000 { + return Err("History evidence export exceeds the 10,000-record bound".to_string()); + } + Ok(export) +} + +fn normalize_local_export( + export: HistoryLocalEvidenceExport, +) -> Result, String> { + let source = export.source.trim(); + if source.is_empty() || source.len() > 120 { + return Err("Evidence export source must be between 1 and 120 bytes".to_string()); + } + if looks_like_secret(source) || contains_sensitive_path(source) { + return Err("Evidence export source contains credential-like data".to_string()); + } + let cursor_redacted = export + .cursor + .as_deref() + .is_some_and(|cursor| looks_like_secret(cursor) || contains_sensitive_path(cursor)); + let safe_cursor = (!cursor_redacted).then_some(export.cursor).flatten(); + let allowed = [ + "analytics_provider_ingestion", + "analytics_provider_delivery", + "deploy", + "incident", + "observed_outcome", + "log_observation", + "pull_request", + "issue", + ]; + export + .records + .into_iter() + .map(|record| { + if record.id.trim().is_empty() || record.id.len() > 240 { + return Err("Every evidence export record needs a bounded ID".to_string()); + } + if looks_like_secret(&record.id) || contains_sensitive_path(&record.id) { + return Err("Evidence export record ID contains credential-like data".to_string()); + } + if !allowed.contains(&record.event_kind.as_str()) { + return Err(format!( + "Unsupported local evidence event kind: {}", + record.event_kind + )); + } + chrono::DateTime::parse_from_rfc3339(&record.observed_at) + .map_err(|error| format!("Evidence observed_at must be RFC3339: {error}"))?; + if let Some(effective_at) = record.effective_at.as_deref() { + chrono::DateTime::parse_from_rfc3339(effective_at) + .map_err(|error| format!("Evidence effective_at must be RFC3339: {error}"))?; + } + let source_record_id = format!("{source}:{}", record.id); + let summary_was_bounded = record.summary.chars().count() > 1_000; + let (summary, summary_redacted) = redact_secret_text(&record.summary); + let source_count = record.source_paths.len(); + let safe_sources = record + .source_paths + .into_iter() + .filter(|path| !contains_sensitive_path(path) && !looks_like_secret(path)) + .take(50) + .map(|path| GraphSourceAnchor { + path, + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }) + .collect::>(); + let sources_redacted = safe_sources.len() < source_count.min(50); + let safe_identifier = + |value: &String| !looks_like_secret(value) && !contains_sensitive_path(value); + Ok(HistoryEvidenceRecord { + id: deterministic_evidence_id( + "provider-export", + &source_record_id, + record.effective_at.as_deref(), + ), + source_id: "provider-export".to_string(), + source_record_id, + source_cursor: safe_cursor.clone(), + event_kind: record.event_kind, + observed_at: record.observed_at, + effective_at: record.effective_at, + entity_candidates: record + .entity_ids + .into_iter() + .filter(safe_identifier) + .take(100) + .collect(), + release_candidates: record + .release_ids + .into_iter() + .filter(safe_identifier) + .take(100) + .collect(), + episode_keys: record + .episode_keys + .into_iter() + .filter(safe_identifier) + .take(100) + .collect(), + trust: GraphTrust::Extracted, + redacted: summary_was_bounded + || summary_redacted + || cursor_redacted + || sources_redacted + || source_count > 50, + summary: bounded_summary(&summary, 1_000), + sources: safe_sources, + }) + }) + .collect::, _>>() + .map(|mut records| { + records.sort_by(|left, right| left.id.cmp(&right.id)); + records.dedup_by(|left, right| left.id == right.id); + records + }) +} + +fn persist_imported_records( + connection: &mut Connection, + repo_path: &Path, + records: &[HistoryEvidenceRecord], + refreshed_at: &str, +) -> Result { + let canonical = repo_path.to_string_lossy().to_string(); + let transaction = connection + .transaction() + .map_err(|error| format!("Start evidence import transaction: {error}"))?; + transaction + .execute( + "INSERT OR IGNORE INTO history_graph_repositories ( + repo_path, repository_fingerprint, status, created_at, updated_at + ) VALUES (?1, ?2, 'pending', ?3, ?3)", + params![ + canonical, + stable_graph_id("repository", &canonical), + refreshed_at + ], + ) + .map_err(|error| format!("Ensure evidence import repository: {error}"))?; + let mut statement = transaction + .prepare( + "INSERT OR IGNORE INTO history_graph_events ( + id, repo_path, event_kind, entity_id, trust, origin, source_id, + source_cursor, payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, ?3, ?4, ?5, 'metadata', ?6, ?7, ?8, ?9, ?10)", + ) + .map_err(|error| format!("Prepare evidence import: {error}"))?; + let mut imported = 0; + let mut by_adapter = std::collections::BTreeMap::::new(); + for record in records { + let primary_entity = record.entity_candidates.first(); + let changed = statement + .execute(params![ + record.id, + canonical, + record.event_kind, + primary_entity, + record.trust.as_str(), + record.source_id, + record.source_cursor, + serde_json::json!({ + "source_record_id": record.source_record_id, + "effective_at": record.effective_at, + "entity_candidates": record.entity_candidates, + "release_candidates": record.release_candidates, + "episode_keys": record.episode_keys, + "summary": record.summary, + "redacted": record.redacted, + }) + .to_string(), + serde_json::to_string(&record.sources).map_err(|error| error.to_string())?, + record.observed_at, + ]) + .map_err(|error| format!("Persist imported evidence: {error}"))?; + if changed > 0 { + imported += 1; + *by_adapter.entry(record.source_id.clone()).or_default() += 1; + } + } + drop(statement); + transaction + .commit() + .map_err(|error| format!("Commit evidence import: {error}"))?; + Ok(HistoryEvidenceRefreshResult { + repo_path: canonical, + imported, + already_present: records.len().saturating_sub(imported), + adapters: by_adapter.into_iter().collect(), + network_requests: 0, + refreshed_at: refreshed_at.to_string(), + }) +} + +pub(crate) fn refresh_builtin_adapters( + connection: &mut Connection, + repo_path: &Path, +) -> Result { + let canonical = repo_path.to_string_lossy().to_string(); + let refreshed_at = Utc::now().to_rfc3339(); + let mut records = Vec::new(); + records.extend(collect_review_records(connection, &canonical)?); + records.extend(collect_qa_records(connection, &canonical)?); + records.extend(collect_session_records(connection, &canonical)?); + records.extend(collect_decision_file_records(repo_path, &refreshed_at)?); + records.sort_by(|left, right| left.id.cmp(&right.id)); + records.dedup_by(|left, right| left.id == right.id); + + let transaction = connection + .transaction() + .map_err(|error| format!("Start evidence refresh transaction: {error}"))?; + transaction + .execute( + "INSERT OR IGNORE INTO history_graph_repositories ( + repo_path, repository_fingerprint, status, created_at, updated_at + ) VALUES (?1, ?2, 'pending', ?3, ?3)", + params![ + canonical, + stable_graph_id("repository", &canonical), + refreshed_at + ], + ) + .map_err(|error| format!("Ensure evidence repository: {error}"))?; + let mut statement = transaction + .prepare( + "INSERT OR IGNORE INTO history_graph_events ( + id, repo_path, event_kind, trust, origin, source_id, source_cursor, + payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, ?3, ?4, 'metadata', ?5, ?6, ?7, ?8, ?9)", + ) + .map_err(|error| format!("Prepare normalized evidence insert: {error}"))?; + let mut imported = 0; + let mut by_adapter = std::collections::BTreeMap::::new(); + for record in &records { + let changed = statement + .execute(params![ + record.id, + canonical, + record.event_kind, + record.trust.as_str(), + record.source_id, + record.source_cursor, + serde_json::json!({ + "source_record_id": record.source_record_id, + "effective_at": record.effective_at, + "entity_candidates": record.entity_candidates, + "release_candidates": record.release_candidates, + "episode_keys": record.episode_keys, + "summary": record.summary, + "redacted": record.redacted, + }) + .to_string(), + serde_json::to_string(&record.sources).map_err(|error| error.to_string())?, + record.observed_at, + ]) + .map_err(|error| format!("Persist normalized evidence: {error}"))?; + if changed > 0 { + imported += 1; + *by_adapter.entry(record.source_id.clone()).or_default() += 1; + } + } + drop(statement); + transaction + .commit() + .map_err(|error| format!("Commit history evidence refresh: {error}"))?; + Ok(HistoryEvidenceRefreshResult { + repo_path: canonical, + imported, + already_present: records.len().saturating_sub(imported), + adapters: by_adapter.into_iter().collect(), + network_requests: 0, + refreshed_at, + }) +} + +fn collect_review_records( + connection: &Connection, + repo_path: &str, +) -> Result, String> { + let mut records = Vec::new(); + let mut statement = connection + .prepare( + "SELECT id, COALESCE(completed_at, started_at, created_at), status, + COALESCE(summary_markdown, ''), pr_number + FROM local_reviews WHERE repo_path = ?1 + ORDER BY created_at DESC, id LIMIT 500", + ) + .map_err(|error| format!("Prepare local review adapter: {error}"))?; + let rows = statement + .query_map(params![repo_path], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + )) + }) + .map_err(|error| format!("Query local review adapter: {error}"))?; + for row in rows { + let (id, observed_at, status, summary, pr_number) = + row.map_err(|error| format!("Read local review adapter: {error}"))?; + records.push(HistoryEvidenceRecord { + id: deterministic_evidence_id("reviews", &id, Some(&observed_at)), + source_id: "reviews".to_string(), + source_record_id: id.clone(), + source_cursor: Some(format!("{observed_at}:{id}")), + event_kind: if pr_number.is_some() { + "pull_request_review" + } else { + "review" + } + .to_string(), + observed_at, + effective_at: None, + entity_candidates: Vec::new(), + release_candidates: Vec::new(), + episode_keys: std::iter::once(format!("review:{id}")) + .chain(pr_number.map(|number| format!("pr:{number}"))) + .collect(), + trust: GraphTrust::Extracted, + summary: bounded_summary(&format!("{status}: {summary}"), 1_000), + sources: Vec::new(), + redacted: summary.len() > 1_000, + }); + } + drop(statement); + + let mut procedure_statement = connection + .prepare( + "SELECT e.id, e.created_at, e.status, e.step_id, e.summary, e.artifact, + e.review_id + FROM review_procedure_events e + JOIN local_reviews r ON r.id = e.review_id + WHERE r.repo_path = ?1 ORDER BY e.created_at DESC, e.id LIMIT 500", + ) + .map_err(|error| format!("Prepare review procedure adapter: {error}"))?; + let rows = procedure_statement + .query_map(params![repo_path], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + )) + }) + .map_err(|error| format!("Query review procedure adapter: {error}"))?; + for row in rows { + let (id, observed_at, status, step, summary, artifact, review_id) = + row.map_err(|error| format!("Read review procedure adapter: {error}"))?; + records.push(HistoryEvidenceRecord { + id: deterministic_evidence_id("reviews", &id, Some(&observed_at)), + source_id: "reviews".to_string(), + source_record_id: id.clone(), + source_cursor: Some(format!("{observed_at}:{id}")), + event_kind: "verification_attempt".to_string(), + observed_at, + effective_at: None, + entity_candidates: Vec::new(), + release_candidates: Vec::new(), + episode_keys: vec![format!("review:{review_id}")], + trust: GraphTrust::Extracted, + summary: bounded_summary(&format!("{step} {status}: {summary}"), 1_000), + sources: artifact + .map(|path| GraphSourceAnchor { + path, + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }) + .into_iter() + .collect(), + redacted: summary.len() > 1_000, + }); + } + Ok(records) +} + +fn collect_qa_records( + connection: &Connection, + repo_path: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT id, created_at, pass, runner_type, COALESCE(goal, ''), + COALESCE(notes, ''), screenshot_path, review_id, loop_id + FROM synthetic_qa_runs WHERE repo_path = ?1 + ORDER BY created_at DESC, id LIMIT 500", + ) + .map_err(|error| format!("Prepare synthetic QA adapter: {error}"))?; + let rows = statement + .query_map(params![repo_path], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, bool>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, String>(8)?, + )) + }) + .map_err(|error| format!("Query synthetic QA adapter: {error}"))?; + rows.map(|row| { + let (id, observed_at, passed, runner, goal, notes, screenshot, review_id, loop_id) = + row.map_err(|error| format!("Read synthetic QA adapter: {error}"))?; + Ok(HistoryEvidenceRecord { + id: deterministic_evidence_id("synthetic-qa", &id, Some(&observed_at)), + source_id: "synthetic-qa".to_string(), + source_record_id: id.clone(), + source_cursor: Some(format!("{observed_at}:{id}")), + event_kind: "synthetic_qa".to_string(), + observed_at, + effective_at: None, + entity_candidates: Vec::new(), + release_candidates: Vec::new(), + episode_keys: review_id + .map(|id| format!("review:{id}")) + .into_iter() + .chain(std::iter::once(format!("qa-loop:{loop_id}"))) + .collect(), + trust: GraphTrust::Extracted, + summary: bounded_summary( + &format!( + "{runner} {}: {goal}. {notes}", + if passed { "passed" } else { "failed" } + ), + 1_000, + ), + sources: screenshot + .map(|path| GraphSourceAnchor { + path, + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }) + .into_iter() + .collect(), + redacted: notes.len() > 1_000, + }) + }) + .collect() +} + +fn collect_session_records( + connection: &Connection, + repo_path: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT s.id, COALESCE(s.indexed_at, s.last_message, ''), s.agent_type, + s.message_count, s.git_branch + FROM cc_sessions s JOIN cc_projects p ON p.id = s.project_id + WHERE p.dir_path = ?1 OR s.cwd = ?1 + ORDER BY COALESCE(s.last_message, s.indexed_at) DESC, s.id LIMIT 500", + ) + .map_err(|error| format!("Prepare local session adapter: {error}"))?; + let rows = statement + .query_map(params![repo_path], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Option>(4)?, + )) + }) + .map_err(|error| format!("Query local session adapter: {error}"))?; + rows.map(|row| { + let (id, mut observed_at, agent, message_count, branch) = + row.map_err(|error| format!("Read local session adapter: {error}"))?; + if observed_at.is_empty() { + observed_at = Utc::now().to_rfc3339(); + } + Ok(HistoryEvidenceRecord { + id: deterministic_evidence_id("agent-sessions", &id, Some(&observed_at)), + source_id: "agent-sessions".to_string(), + source_record_id: id.clone(), + source_cursor: Some(format!("{observed_at}:{id}")), + event_kind: "agent_session".to_string(), + observed_at, + effective_at: None, + entity_candidates: Vec::new(), + release_candidates: branch.into_iter().collect(), + episode_keys: vec![format!("session:{id}")], + trust: GraphTrust::Extracted, + summary: format!("{agent} session metadata: {message_count} indexed messages"), + sources: Vec::new(), + redacted: true, + }) + }) + .collect() +} + +fn collect_decision_file_records( + repo_path: &Path, + observed_at: &str, +) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["ls-files", "-s", "-z"]) + .output() + .map_err(|error| format!("Read decision-file index: {error}"))?; + if !output.status.success() { + return Err(format!( + "Read decision-file index: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let mut records = Vec::new(); + for entry in output.stdout.split(|byte| *byte == 0) { + let entry = String::from_utf8_lossy(entry); + let Some((metadata, path)) = entry.split_once('\t') else { + continue; + }; + let lower = path.to_ascii_lowercase(); + if !(lower.contains("changelog") + || lower.contains("/adr") + || lower.starts_with("adr") + || lower.contains("decision") + || lower.starts_with(".planning/")) + { + continue; + } + let object_id = metadata.split_whitespace().nth(1).unwrap_or_default(); + let source_record_id = format!("{path}:{object_id}"); + records.push(HistoryEvidenceRecord { + id: deterministic_evidence_id("decision-files", &source_record_id, None), + source_id: "decision-files".to_string(), + source_record_id: source_record_id.clone(), + source_cursor: Some(source_record_id), + event_kind: "decision_marker".to_string(), + observed_at: observed_at.to_string(), + effective_at: None, + entity_candidates: Vec::new(), + release_candidates: Vec::new(), + episode_keys: vec![format!("decision-file:{path}")], + trust: GraphTrust::Extracted, + summary: format!("Tracked decision-bearing file: {path}"), + sources: vec![GraphSourceAnchor { + path: path.to_string(), + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }], + redacted: true, + }); + if records.len() >= 500 { + break; + } + } + Ok(records) +} + +fn bounded_summary(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn adapter_descriptors( + connection: &Connection, + repo_path: &Path, +) -> Result, String> { + let canonical = repo_path.to_string_lossy(); + let definitions = [ + ( + "git", + "Git commits, tags, and releases", + "git", + HistoryAdapterConsent::LocalDefault, + vec!["Git object database"], + ), + ( + "decision-files", + "Changelogs, ADRs, and decision markers", + "local_files", + HistoryAdapterConsent::LocalDefault, + vec!["tracked repository paths and bounded source anchors"], + ), + ( + "agent-sessions", + "Indexed local agent sessions", + "sqlite", + HistoryAdapterConsent::LocalDefault, + vec!["cc_projects and cc_sessions metadata"], + ), + ( + "reviews", + "Reviews and fix attempts", + "sqlite", + HistoryAdapterConsent::LocalDefault, + vec!["local_reviews, findings, and procedure events"], + ), + ( + "synthetic-qa", + "Synthetic QA runs", + "sqlite", + HistoryAdapterConsent::LocalDefault, + vec!["synthetic_qa_runs metadata and artifact paths"], + ), + ( + "tasks", + "Local tasks and follow-ups", + "sqlite", + HistoryAdapterConsent::LocalDefault, + vec!["agent_tasks metadata"], + ), + ( + "provider-export", + "Analytics, logs, incidents, deploys, and PR exports", + "explicit_import", + HistoryAdapterConsent::ExplicitImport, + vec!["only a user-selected local export"], + ), + ( + "hosted-provider", + "Configured hosted provider", + "external_provider", + HistoryAdapterConsent::ExplicitImport, + vec!["nothing until a separate adapter is configured"], + ), + ]; + let mut descriptors = Vec::with_capacity(definitions.len()); + for (id, label, source_kind, consent, reads) in definitions { + let (count, cursor, observed_at) = local_adapter_state(connection, &canonical, id)?; + let configured = consent == HistoryAdapterConsent::LocalDefault || count > 0; + let availability = if consent == HistoryAdapterConsent::ExplicitImport && !configured { + HistoryAdapterAvailability::NeedsConfiguration + } else if (id == "git" && repo_path.join(".git").exists()) || count > 0 { + HistoryAdapterAvailability::Available + } else { + HistoryAdapterAvailability::Empty + }; + descriptors.push(HistoryEvidenceAdapterDescriptor { + id: id.to_string(), + label: label.to_string(), + source_kind: source_kind.to_string(), + availability, + consent, + configured, + local_only: true, + network_access: false, + reads: reads.into_iter().map(str::to_string).collect(), + redaction: "Store normalized bounded metadata and source anchors; omit credentials and unrestricted raw payloads".to_string(), + source_cursor: cursor, + last_observed_at: observed_at, + freshness: if count > 0 { + format!("{count} normalized local records") + } else { + "No normalized records imported".to_string() + }, + }); + } + Ok(descriptors) +} + +fn local_adapter_state( + connection: &Connection, + repo_path: &str, + adapter_id: &str, +) -> Result<(usize, Option, Option), String> { + connection + .query_row( + "SELECT COUNT(*), MAX(source_cursor), MAX(recorded_at) + FROM history_graph_events WHERE repo_path = ?1 AND source_id = ?2", + params![repo_path, adapter_id], + |row| { + Ok(( + row.get::<_, i64>(0)? as usize, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional() + .map_err(|error| format!("Load adapter state: {error}")) + .map(|row| row.unwrap_or((0, None, None))) +} + +fn canonical_repo_path(repo_path: &str) -> Result { + let path = PathBuf::from(repo_path.trim()) + .canonicalize() + .map_err(|error| format!("Cannot resolve repository path: {error}"))?; + if !path.is_dir() { + return Err("Repository path is not a directory".to_string()); + } + Ok(path) +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/commands/history_evidence/service/tests.rs b/apps/desktop/src-tauri/src/commands/history_evidence/service/tests.rs new file mode 100644 index 0000000..522796b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_evidence/service/tests.rs @@ -0,0 +1,201 @@ +use super::*; +use std::fs; + +#[test] +fn adapter_registry_is_local_only_and_external_sources_require_consent() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + let root = std::env::temp_dir(); + let descriptors = adapter_descriptors(&connection, &root).expect("descriptors"); + assert!(descriptors.iter().all(|adapter| adapter.local_only)); + assert!(descriptors.iter().all(|adapter| !adapter.network_access)); + let hosted = descriptors + .iter() + .find(|adapter| adapter.id == "hosted-provider") + .expect("hosted provider"); + assert_eq!(hosted.consent, HistoryAdapterConsent::ExplicitImport); + assert_eq!( + hosted.availability, + HistoryAdapterAvailability::NeedsConfiguration + ); +} + +#[test] +fn evidence_ids_are_stable_and_source_scoped() { + let first = deterministic_evidence_id("reviews", "review-1", Some("2026-01-01")); + assert_eq!( + first, + deterministic_evidence_id("reviews", "review-1", Some("2026-01-01")) + ); + assert_ne!( + first, + deterministic_evidence_id("synthetic-qa", "review-1", Some("2026-01-01")) + ); +} + +#[test] +fn built_in_refresh_normalizes_local_records_without_network_or_duplicates() { + let root = std::env::temp_dir().join(format!("cv-history-evidence-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join(".planning")).expect("fixture"); + assert!(Command::new("git") + .arg("-C") + .arg(&root) + .arg("init") + .status() + .expect("git init") + .success()); + fs::write(root.join(".planning/decision.md"), "Keep evidence local.\n").expect("decision"); + assert!(Command::new("git") + .arg("-C") + .arg(&root) + .args(["add", ".planning/decision.md"]) + .status() + .expect("git add") + .success()); + + let canonical = root.canonicalize().expect("canonical"); + let canonical_text = canonical.to_string_lossy().to_string(); + let mut connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute( + "INSERT INTO local_reviews ( + id, repo_path, status, summary_markdown, created_at + ) VALUES ('review-1', ?1, 'complete', 'Review passed', + '2026-01-01T00:00:00Z')", + params![canonical_text], + ) + .expect("review"); + connection + .execute( + "INSERT INTO synthetic_qa_runs ( + id, repo_path, loop_id, runner_type, goal, pass, created_at + ) VALUES ('qa-1', ?1, 'loop-1', 'playwright', 'open app', 1, + '2026-01-02T00:00:00Z')", + params![canonical_text], + ) + .expect("qa"); + connection + .execute( + "INSERT INTO cc_projects (id, display_name, dir_path, created_at) + VALUES ('project-1', 'fixture', ?1, '2026-01-01T00:00:00Z')", + params![canonical_text], + ) + .expect("project"); + connection + .execute( + "INSERT INTO cc_sessions ( + id, project_id, agent_type, message_count, indexed_at + ) VALUES ('session-1', 'project-1', 'codex', 12, + '2026-01-03T00:00:00Z')", + [], + ) + .expect("session"); + + let first = refresh_builtin_adapters(&mut connection, &canonical).expect("refresh"); + assert_eq!(first.imported, 4); + assert_eq!(first.network_requests, 0); + let second = refresh_builtin_adapters(&mut connection, &canonical).expect("repeat"); + assert_eq!(second.imported, 0); + assert_eq!(second.already_present, 4); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn provider_export_keeps_delivery_separate_and_bounded() { + let root = std::env::temp_dir().join(format!("cv-provider-export-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + let canonical = root.canonicalize().expect("canonical"); + let export = HistoryLocalEvidenceExport { + schema_version: 1, + source: "posthog-export".to_string(), + cursor: Some("cursor-1".to_string()), + records: vec![HistoryLocalEvidenceExportRecord { + id: "delivery-1".to_string(), + event_kind: "analytics_provider_delivery".to_string(), + observed_at: "2026-01-04T00:00:00Z".to_string(), + effective_at: Some("2026-01-03T23:59:00Z".to_string()), + summary: "x".repeat(2_000), + entity_ids: vec!["event:signup".to_string()], + release_ids: vec!["v1.0.0".to_string()], + source_paths: Vec::new(), + episode_keys: vec!["deploy:production-42".to_string()], + }], + }; + let records = normalize_local_export(export).expect("normalize export"); + assert_eq!(records[0].summary.chars().count(), 1_000); + assert!(records[0].redacted); + let mut connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + let result = persist_imported_records( + &mut connection, + &canonical, + &records, + "2026-01-04T00:00:00Z", + ) + .expect("persist export"); + assert_eq!(result.imported, 1); + assert_eq!(result.network_requests, 0); + let stored: (String, String) = connection + .query_row( + "SELECT event_kind, entity_id FROM history_graph_events", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("stored provider event"); + assert_eq!(stored.0, "analytics_provider_delivery"); + assert_eq!(stored.1, "event:signup"); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn provider_export_rejects_unsupported_adapter_events() { + let error = normalize_local_export(HistoryLocalEvidenceExport { + schema_version: 1, + source: "provider-export".to_string(), + cursor: None, + records: vec![HistoryLocalEvidenceExportRecord { + id: "record-1".to_string(), + event_kind: "unconfigured_network_probe".to_string(), + observed_at: "2026-01-04T00:00:00Z".to_string(), + effective_at: None, + summary: "must not run".to_string(), + entity_ids: Vec::new(), + release_ids: Vec::new(), + source_paths: Vec::new(), + episode_keys: Vec::new(), + }], + }) + .expect_err("unsupported adapter event"); + + assert!(error.contains("Unsupported local evidence event kind")); +} + +#[test] +fn provider_export_redacts_credentials_before_persistence() { + let records = normalize_local_export(HistoryLocalEvidenceExport { + schema_version: 1, + source: "provider-export".to_string(), + cursor: Some("password=cursor-secret-value".to_string()), + records: vec![HistoryLocalEvidenceExportRecord { + id: "record-1".to_string(), + event_kind: "incident".to_string(), + observed_at: "2026-01-04T00:00:00Z".to_string(), + effective_at: None, + summary: "Authorization: Bearer imported-secret-token".to_string(), + entity_ids: vec!["service:billing".to_string()], + release_ids: Vec::new(), + source_paths: vec![ + "secrets/provider.json".to_string(), + "src/safe.rs".to_string(), + ], + episode_keys: Vec::new(), + }], + }) + .expect("normalize credential-bearing export"); + assert_eq!(records[0].summary, "[redacted]"); + assert!(records[0].source_cursor.is_none()); + assert!(records[0].redacted); + assert_eq!(records[0].sources.len(), 1); + assert_eq!(records[0].sources[0].path, "src/safe.rs"); +} diff --git a/apps/desktop/src-tauri/src/commands/history_evidence/types.rs b/apps/desktop/src-tauri/src/commands/history_evidence/types.rs new file mode 100644 index 0000000..b951041 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_evidence/types.rs @@ -0,0 +1,128 @@ +use crate::commands::structural_graph::types::{GraphSourceAnchor, GraphTrust}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryAdapterAvailability { + Available, + Empty, + NeedsConfiguration, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryAdapterConsent { + LocalDefault, + ExplicitImport, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryEvidenceAdapterDescriptor { + pub id: String, + pub label: String, + pub source_kind: String, + pub availability: HistoryAdapterAvailability, + pub consent: HistoryAdapterConsent, + pub configured: bool, + pub local_only: bool, + pub network_access: bool, + pub reads: Vec, + pub redaction: String, + pub source_cursor: Option, + pub last_observed_at: Option, + pub freshness: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[allow(dead_code)] +pub struct HistoryEvidenceRecord { + pub id: String, + pub source_id: String, + pub source_record_id: String, + pub source_cursor: Option, + pub event_kind: String, + pub observed_at: String, + pub effective_at: Option, + pub entity_candidates: Vec, + pub release_candidates: Vec, + pub episode_keys: Vec, + pub trust: GraphTrust, + pub summary: String, + pub sources: Vec, + pub redacted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[allow(dead_code)] +pub struct HistoryEvidenceBatch { + pub adapter_id: String, + pub records: Vec, + pub next_cursor: Option, + pub truncated: bool, + pub observed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryEvidenceRefreshResult { + pub repo_path: String, + pub imported: usize, + pub already_present: usize, + pub adapters: Vec<(String, usize)>, + pub network_requests: usize, + pub refreshed_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HistoryLocalEvidenceExport { + pub schema_version: i64, + pub source: String, + pub cursor: Option, + pub records: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HistoryLocalEvidenceExportRecord { + pub id: String, + pub event_kind: String, + pub observed_at: String, + pub effective_at: Option, + pub summary: String, + #[serde(default)] + pub entity_ids: Vec, + #[serde(default)] + pub release_ids: Vec, + #[serde(default)] + pub source_paths: Vec, + #[serde(default)] + pub episode_keys: Vec, +} + +#[allow(dead_code)] +pub struct HistoryEvidenceContext<'a> { + pub repo_path: &'a Path, + pub cursor: Option<&'a str>, + pub limit: usize, +} + +/// Local-first ingestion boundary for immutable historical evidence. +/// +/// Implementations must return deterministic source IDs, never retain credentials, +/// and never perform network I/O unless a future, separately configured adapter is +/// explicitly invoked through a consent-bearing surface. +#[allow(dead_code)] +pub trait HistoryEvidenceAdapter: Send + Sync { + fn descriptor( + &self, + connection: &Connection, + repo_path: &Path, + ) -> Result; + + fn collect( + &self, + connection: &Connection, + context: &HistoryEvidenceContext<'_>, + ) -> Result; +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 30ba035..a07c33a 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod git; pub mod git_metadata; pub mod graph_trust; pub mod history; +pub mod history_evidence; pub mod history_summary_graph; pub mod intel; pub mod observability; From 5f951acecc4f8c936a5c9233db6ab01fe32c2441 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:31:29 +0530 Subject: [PATCH 037/141] feat(history): define temporal graph contracts --- .../src/commands/history_graph/catalog/git.rs | 284 ++++++++++++ .../src/commands/history_graph/catalog/mod.rs | 5 + .../src/commands/history_graph/mod.rs | 410 ++++++++++++++++++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + 4 files changed, 700 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/mod.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs b/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs new file mode 100644 index 0000000..93299cd --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs @@ -0,0 +1,284 @@ +use super::*; + +pub(in crate::commands::history_graph) fn build_topology( + root: &Path, + revision: &str, + max_nodes: Option, +) -> Result { + let revision = resolve_revision(root, revision)?; + let output = git_bytes(root, &["ls-tree", "-r", "--name-only", "-z", &revision])?; + let mut files = output + .split(|byte| *byte == 0) + .filter(|bytes| !bytes.is_empty()) + .map(|bytes| String::from_utf8_lossy(bytes).replace('\\', "/")) + .collect::>(); + files.sort(); + let total_files = files.len(); + let limit = max_nodes + .unwrap_or(DEFAULT_GRAPH_LIMIT) + .clamp(20, MAX_GRAPH_LIMIT); + let path_changes = changed_path_records(root, &revision)?; + let changed_paths = path_changes + .iter() + .map(|change| change.path.clone()) + .collect::>(); + + let mut directory_counts = BTreeMap::::new(); + for path in &files { + let mut current = String::new(); + for component in Path::new(path) + .components() + .take(path.split('/').count().saturating_sub(1)) + { + let component = component.as_os_str().to_string_lossy(); + if !current.is_empty() { + current.push('/'); + } + current.push_str(&component); + *directory_counts.entry(current.clone()).or_default() += 1; + } + } + let mut selected_directories = directory_counts.into_iter().collect::>(); + selected_directories.sort_by(|(left_path, left_count), (right_path, right_count)| { + right_count + .cmp(left_count) + .then_with(|| left_path.cmp(right_path)) + }); + let directory_budget = (limit / 3).max(8); + selected_directories.truncate(directory_budget); + let directory_ids = selected_directories + .iter() + .map(|(path, _)| path.as_str()) + .collect::>(); + + files.sort_by(|left, right| { + changed_paths + .contains(right) + .cmp(&changed_paths.contains(left)) + .then_with(|| left.cmp(right)) + }); + let file_budget = limit.saturating_sub(selected_directories.len()); + files.truncate(file_budget); + let mut nodes = Vec::with_capacity(selected_directories.len() + files.len()); + for (path, count) in &selected_directories { + nodes.push(HistoryTopologyNode { + id: stable_graph_id("directory", path), + kind: "directory".to_string(), + label: path.rsplit('/').next().unwrap_or(path).to_string(), + path: path.clone(), + detail: format!("{count} files at this revision"), + }); + } + for path in &files { + nodes.push(HistoryTopologyNode { + id: stable_graph_id("file", path), + kind: if changed_paths.contains(path) { + "changed_file" + } else { + "file" + } + .to_string(), + label: path.rsplit('/').next().unwrap_or(path).to_string(), + path: path.clone(), + detail: if changed_paths.contains(path) { + "changed in this revision" + } else { + "present at this revision" + } + .to_string(), + }); + } + let node_ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let mut edges = Vec::new(); + for node in &nodes { + let Some(parent) = Path::new(&node.path).parent().and_then(Path::to_str) else { + continue; + }; + if parent.is_empty() || !directory_ids.contains(parent) { + continue; + } + let parent_id = stable_graph_id("directory", parent); + if node_ids.contains(parent_id.as_str()) { + edges.push(HistoryTopologyEdge { + id: stable_graph_id("edge", &format!("contains\0{parent_id}\0{}", node.id)), + from: parent_id, + to: node.id.clone(), + kind: "contains".to_string(), + }); + } + } + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + edges.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(HistoryTopology { + schema_version: 1, + repo_path: root.to_string_lossy().to_string(), + revision, + nodes, + edges, + changed_paths: changed_paths.into_iter().collect(), + path_changes, + total_files, + truncated: total_files > file_budget, + }) +} + +pub(in crate::commands::history_graph) fn changed_path_records( + root: &Path, + revision: &str, +) -> Result, String> { + let revision = resolve_revision(root, revision)?; + let parent_line = git_text(root, &["rev-list", "--parents", "-n", "1", &revision])?; + let parents = parent_line.split_whitespace().skip(1).collect::>(); + if let Some(parent) = parents.first() { + return changed_path_records_between(root, parent, &revision); + } + let output = git_bytes( + root, + &[ + "diff-tree", + "--root", + "--no-commit-id", + "--name-status", + "-M", + "-C", + "--find-copies-harder", + "-r", + "-z", + &revision, + ], + )?; + parse_changed_path_records(&output) +} + +pub(in crate::commands::history_graph) fn changed_path_records_between( + root: &Path, + before_revision: &str, + after_revision: &str, +) -> Result, String> { + let before_revision = resolve_revision(root, before_revision)?; + let after_revision = resolve_revision(root, after_revision)?; + let output = git_bytes( + root, + &[ + "diff", + "--name-status", + "-M", + "-C", + "--find-copies-harder", + "-z", + &before_revision, + &after_revision, + ], + )?; + parse_changed_path_records(&output) +} + +pub(in crate::commands::history_graph) fn parse_changed_path_records( + output: &[u8], +) -> Result, String> { + let fields = output + .split(|byte| *byte == 0) + .filter(|bytes| !bytes.is_empty()) + .map(|bytes| String::from_utf8_lossy(bytes).replace('\\', "/")) + .collect::>(); + let mut changes = Vec::new(); + let mut index = 0; + while index < fields.len() { + let status = fields[index].clone(); + index += 1; + let Some(first_path) = fields.get(index).cloned() else { + return Err("Git history change output ended before a path".to_string()); + }; + index += 1; + let kind = status.chars().next().unwrap_or('M'); + let (path, old_path) = if matches!(kind, 'R' | 'C') { + let Some(new_path) = fields.get(index).cloned() else { + return Err("Git history rename/copy output ended before a destination".to_string()); + }; + index += 1; + (new_path, Some(first_path)) + } else { + (first_path, None) + }; + changes.push(HistoryPathChange { + path, + change_kind: match kind { + 'A' => "added", + 'D' => "deleted", + 'R' => "renamed", + 'C' => "copied", + 'T' => "type_changed", + _ => "modified", + } + .to_string(), + old_path, + additions: None, + deletions: None, + }); + } + changes.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(changes) +} + +pub(in crate::commands::history_graph) fn tags_by_commit( + root: &Path, +) -> Result>, String> { + let mut tags = HashMap::>::new(); + for tag in read_git_tags(root)? { + tags.entry(tag.commit_sha).or_default().push(tag.name); + } + for values in tags.values_mut() { + values.sort(); + } + Ok(tags) +} + +pub(crate) fn resolve_revision(root: &Path, revision: &str) -> Result { + let revision = revision.trim(); + if revision.is_empty() || revision.len() > 128 || revision.starts_with('-') { + return Err("A valid Git revision is required".to_string()); + } + git_text( + root, + &["rev-parse", "--verify", &format!("{revision}^{{commit}}")], + ) +} + +pub(crate) fn canonical_repo_path(repo_path: &str) -> Result { + let path = PathBuf::from(repo_path.trim()) + .canonicalize() + .map_err(|error| format!("Cannot resolve repository path: {error}"))?; + if !path.is_dir() { + return Err("Repository path is not a directory".to_string()); + } + Ok(path) +} + +pub(crate) fn git_text(root: &Path, arguments: &[&str]) -> Result { + String::from_utf8(git_bytes(root, arguments)?) + .map(|value| value.trim().to_string()) + .map_err(|error| format!("Git returned invalid UTF-8: {error}")) +} + +pub(in crate::commands::history_graph) fn git_bytes( + root: &Path, + arguments: &[&str], +) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(arguments) + .output() + .map_err(|error| format!("Failed to run git {}: {error}", arguments.join(" ")))?; + if !output.status.success() { + return Err(format!( + "Git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(output.stdout) +} diff --git a/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs new file mode 100644 index 0000000..ca681a1 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +pub(super) mod git; +pub(crate) use git::canonical_repo_path; +use git::*; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs new file mode 100644 index 0000000..7c6de99 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -0,0 +1,410 @@ +use crate::commands::git_metadata::{is_release_tag, read_git_tags}; +use crate::commands::structural_graph::analysis::StructuralGraphAnalysisSummary; +use crate::commands::structural_graph::extract::{ + build_snapshot_from_blob_delta, build_snapshot_from_blobs, HistoricalFileBlob, +}; +use crate::commands::structural_graph::query::{self, GraphProjection}; +use crate::commands::structural_graph::storage::load_snapshot_by_id; +use crate::commands::structural_graph::types::stable_graph_id; +use crate::commands::structural_graph::types::{ + GraphSourceAnchor, GraphTrust, StructuralCloneGroup, StructuralGraphCancellation, + StructuralGraphCommunity, StructuralGraphCoverage, StructuralGraphDiagnostic, + StructuralGraphEdge, StructuralGraphFileRecord, StructuralGraphMetricFact, StructuralGraphNode, + StructuralGraphProgress, StructuralGraphSnapshot, BUNDLED_ENGINE_ID, BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, +}; +use crate::DbState; +use chrono::Utc; +use flate2::{read::ZlibDecoder, write::ZlibEncoder, Compression}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex, OnceLock}; +use tauri::{Emitter, State}; + +const DEFAULT_HISTORY_LIMIT: usize = 250; +const MAX_HISTORY_LIMIT: usize = 2_000; +const DEFAULT_GRAPH_LIMIT: usize = 360; +const MAX_GRAPH_LIMIT: usize = 1_500; +const MAX_HISTORICAL_FILES: usize = 25_000; +const MAX_HISTORICAL_BLOB_BYTES: usize = 2 * 1024 * 1024; + +static ACTIVE_HISTORY_BACKFILLS: OnceLock>> = + OnceLock::new(); + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; +} + +fn active_history_backfills() -> &'static Mutex> { + ACTIVE_HISTORY_BACKFILLS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn release_history_allocator_pressure() { + #[cfg(target_os = "macos")] + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } + #[cfg(target_os = "linux")] + unsafe { + libc::malloc_trim(0); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryRevision { + pub sha: String, + pub short_sha: String, + pub parents: Vec, + pub committed_at: String, + pub author: String, + pub subject: String, + pub tags: Vec, + pub is_release: bool, + pub is_head: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryTimeline { + pub schema_version: i64, + pub repo_path: String, + pub head: String, + pub generated_at: String, + pub revisions: Vec, + pub total_commits: usize, + pub truncated: bool, + pub is_shallow: bool, + pub coverage_complete: bool, + pub release_ranges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryReleaseRange { + pub id: String, + pub label: String, + pub tag: Option, + pub from_exclusive: Option, + pub to_inclusive: String, + pub commit_shas: Vec, + pub is_unreleased: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistorySearchResult { + pub revisions: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryTopologyNode { + pub id: String, + pub kind: String, + pub label: String, + pub path: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryTopologyEdge { + pub id: String, + pub from: String, + pub to: String, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryTopology { + pub schema_version: i64, + pub repo_path: String, + pub revision: String, + pub nodes: Vec, + pub edges: Vec, + pub changed_paths: Vec, + pub path_changes: Vec, + pub total_files: usize, + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryPathChange { + pub path: String, + pub change_kind: String, + pub old_path: Option, + pub additions: Option, + pub deletions: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryStructuralState { + pub schema_version: i64, + pub repo_path: String, + pub revision: String, + pub snapshot_id: String, + pub cached: bool, + pub projection: GraphProjection, + pub analysis: StructuralGraphAnalysisSummary, + pub changed_paths: Vec, + pub path_changes: Vec, + pub indexed_files: usize, + pub node_count: usize, + pub edge_count: usize, + pub generated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HistoryStructuralDelta { + pub schema_version: i64, + #[serde(default)] + pub materialization_version: i64, + pub repo_path: String, + pub before_revision: String, + pub after_revision: String, + pub before_snapshot_id: String, + pub after_snapshot_id: String, + pub added_node_ids: Vec, + pub removed_node_ids: Vec, + pub changed_node_ids: Vec, + pub added_edge_ids: Vec, + pub removed_edge_ids: Vec, + pub changed_edge_ids: Vec, + pub added_community_ids: Vec, + pub removed_community_ids: Vec, + pub added_hub_ids: Vec, + pub removed_hub_ids: Vec, + pub added_bridge_ids: Vec, + pub removed_bridge_ids: Vec, + pub path_changes: Vec, + pub lineage: Vec, + pub coverage_gap: Option, + pub generated_at: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upsert_nodes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upsert_edges: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upsert_communities: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upsert_files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed_file_paths: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upsert_metrics: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed_metric_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub after_metric_order: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upsert_clone_groups: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed_clone_group_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub after_clone_group_order: Vec, + #[serde(default)] + pub after_coverage: StructuralGraphCoverage, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub after_diagnostics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_ignore_fingerprint: Option, + #[serde(default)] + pub after_truncated: bool, + #[serde(default)] + pub after_created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryLineageEdge { + pub id: String, + pub from_entity_id: String, + pub to_entity_id: String, + pub relation: String, + pub trust: GraphTrust, + pub evidence: String, + pub sources: Vec, + pub candidates: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryEntityMoment { + pub revision_sha: String, + pub committed_at: String, + pub ordinal: i64, + pub entity_id: String, + pub label: String, + pub kind: String, + pub path: Option, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryEntityEvolution { + pub schema_version: i64, + pub repo_path: String, + pub resolved_revision: String, + pub entity_id: String, + pub entity_label: String, + pub entity_kind: String, + pub lineage: Vec, + pub occurrences: Vec, + pub first_seen: Option, + pub last_changed: Option, + pub last_present: Option, + pub indexed_head: String, + pub stale: bool, + pub coverage_gap: Option, + pub truncated: bool, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HistoryTemporalReference { + Revision { revision: String }, + Release { tag: String }, + Date { at: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryAsOfState { + pub requested: HistoryTemporalReference, + pub resolved_revision: String, + pub committed_at: String, + pub exact: bool, + pub state: HistoryStructuralState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryBackfillProgress { + pub phase: String, + pub completed: usize, + pub total: usize, + pub revision: Option, + pub detail: String, + pub eta_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryBackfillResult { + pub repo_path: String, + pub total: usize, + pub completed: usize, + pub built: usize, + pub cache_hits: usize, + pub cancelled: bool, + pub release_checkpoints: usize, + pub coverage_complete: bool, + pub refresh_kind: String, + pub invalidated: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryGraphStatus { + pub repo_path: String, + pub indexed: bool, + pub backfilling: bool, + pub stale: bool, + pub current_head: String, + pub indexed_head: Option, + pub checkpoint_count: usize, + pub event_count: usize, + pub coverage: Value, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryFacetStatus { + Evidenced, + QualifiedLead, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryFacet { + pub name: String, + pub status: HistoryFacetStatus, + pub summary: String, + pub trust: GraphTrust, + pub sources: Vec, + pub event_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryFacetPacket { + pub schema_version: i64, + pub repo_path: String, + pub as_of_revision: String, + pub entity_id: String, + pub entity_label: String, + pub entity_kind: String, + pub facets: Vec, + pub gaps: Vec, + pub contradictions: Vec, + pub trust_summary: BTreeMap, + pub indexed_head: String, + pub stale: bool, + pub truncated: bool, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryAnnotationDecision { + Note, + Confirm, + Reject, + Correction, +} + +impl HistoryAnnotationDecision { + fn as_str(&self) -> &'static str { + match self { + Self::Note => "note", + Self::Confirm => "confirm", + Self::Reject => "reject", + Self::Correction => "correction", + } + } + + pub(crate) fn from_storage(value: &str) -> Self { + match value { + "confirm" => Self::Confirm, + "reject" => Self::Reject, + "correction" => Self::Correction, + _ => Self::Note, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryAnnotation { + pub id: String, + pub repo_path: String, + pub revision_sha: Option, + pub entity_id: Option, + pub author: String, + pub body: String, + pub decision: HistoryAnnotationDecision, + pub related_event_id: Option, + pub source: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryAnnotationPage { + pub annotations: Vec, + pub truncated: bool, + pub next_cursor: Option, +} + +mod catalog; + +pub(crate) use catalog::canonical_repo_path; +use catalog::git::*; +pub(crate) use catalog::git::{git_text, resolve_revision}; diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index a07c33a..94dff8b 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod git_metadata; pub mod graph_trust; pub mod history; pub mod history_evidence; +pub mod history_graph; pub mod history_summary_graph; pub mod intel; pub mod observability; From 671817c202b4fdb744edfd46fc05e709a3996434 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:33:44 +0530 Subject: [PATCH 038/141] feat(history): catalog revisions and releases --- .../src/commands/history_graph/catalog/git.rs | 14 + .../src/commands/history_graph/catalog/mod.rs | 404 ++++++++++++++++++ .../src/commands/history_graph/git_objects.rs | 159 +++++++ .../src/commands/history_graph/mod.rs | 5 + 4 files changed, 582 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/git_objects.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs b/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs index 93299cd..fcc4c47 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/catalog/git.rs @@ -263,6 +263,20 @@ pub(crate) fn git_text(root: &Path, arguments: &[&str]) -> Result bool { + Command::new("git") + .arg("-C") + .arg(root) + .args(["merge-base", "--is-ancestor", ancestor, descendant]) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + pub(in crate::commands::history_graph) fn git_bytes( root: &Path, arguments: &[&str], diff --git a/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs index ca681a1..ddab108 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs @@ -1,5 +1,409 @@ use super::*; +#[tauri::command] +pub async fn history_list_releases( + repo_path: String, + limit: Option, + db: State<'_, DbState>, +) -> Result { + query_history_revisions(repo_path, None, true, limit, db).await +} + +#[tauri::command] +pub async fn history_search( + repo_path: String, + query: String, + limit: Option, + db: State<'_, DbState>, +) -> Result { + query_history_revisions(repo_path, Some(query), false, limit, db).await +} + +pub(super) async fn query_history_revisions( + repo_path: String, + query: Option, + releases_only: bool, + limit: Option, + db: State<'_, DbState>, +) -> Result { + let key = canonical_repo_path(&repo_path)? + .to_string_lossy() + .to_string(); + let database = Arc::clone(&db.0); + let limit = limit.unwrap_or(50).clamp(1, 200); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + load_history_revisions(&connection, &key, query.as_deref(), releases_only, limit) + }) + .await + .map_err(|error| format!("History query worker failed: {error}"))? +} + +pub fn load_history_revisions( + connection: &Connection, + repo_path: &str, + query: Option<&str>, + releases_only: bool, + limit: usize, +) -> Result { + let query = query.unwrap_or_default().trim().to_lowercase(); + let mut statement = connection + .prepare( + "SELECT sha, substr(sha, 1, 8), parents_json, committed_at, author_name, + subject, tags_json, is_release, is_head + FROM history_graph_revisions + WHERE repo_path = ?1 + AND (?2 = 0 OR is_release = 1) + AND (?3 = '' OR lower(subject) LIKE '%' || ?3 || '%' + OR lower(author_name) LIKE '%' || ?3 || '%' + OR lower(tags_json) LIKE '%' || ?3 || '%' + OR lower(sha) LIKE ?3 || '%') + ORDER BY ordinal DESC + LIMIT ?4", + ) + .map_err(|error| format!("Prepare history query: {error}"))?; + let rows = statement + .query_map( + params![ + repo_path, + i64::from(releases_only), + query, + (limit + 1) as i64 + ], + |row| { + let parents_json: String = row.get(2)?; + let tags_json: String = row.get(6)?; + Ok(HistoryRevision { + sha: row.get(0)?, + short_sha: row.get(1)?, + parents: serde_json::from_str(&parents_json).unwrap_or_default(), + committed_at: row.get(3)?, + author: row.get(4)?, + subject: row.get(5)?, + tags: serde_json::from_str(&tags_json).unwrap_or_default(), + is_release: row.get::<_, i64>(7)? != 0, + is_head: row.get::<_, i64>(8)? != 0, + }) + }, + ) + .map_err(|error| format!("Query history revisions: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read history revisions: {error}"))?; + let truncated = rows.len() > limit; + let mut revisions = rows; + revisions.truncate(limit); + Ok(HistorySearchResult { + revisions, + truncated, + }) +} + +pub(super) fn persist_changed_paths( + connection: &Connection, + topology: &HistoryTopology, +) -> Result<(), String> { + let transaction = connection + .unchecked_transaction() + .map_err(|error| format!("Start history path transaction: {error}"))?; + let mut statement = transaction + .prepare( + "INSERT INTO history_graph_revision_paths ( + repo_path, revision_sha, path, change_kind, old_path, additions, deletions + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(repo_path, revision_sha, path) DO UPDATE SET + change_kind = excluded.change_kind, + old_path = excluded.old_path, + additions = excluded.additions, + deletions = excluded.deletions", + ) + .map_err(|error| format!("Prepare history changed paths: {error}"))?; + for path in &topology.changed_paths { + let change = topology + .path_changes + .iter() + .find(|change| change.path == *path); + statement + .execute(params![ + topology.repo_path, + topology.revision, + path, + change + .map(|change| change.change_kind.as_str()) + .unwrap_or("changed"), + change.and_then(|change| change.old_path.as_deref()), + change + .and_then(|change| change.additions) + .map(|value| value as i64), + change + .and_then(|change| change.deletions) + .map(|value| value as i64), + ]) + .map_err(|error| format!("Persist history changed path: {error}"))?; + } + drop(statement); + transaction + .commit() + .map_err(|error| format!("Commit history changed paths: {error}")) +} + +pub(super) fn build_timeline(root: &Path, limit: Option) -> Result { + let limit = limit + .unwrap_or(DEFAULT_HISTORY_LIMIT) + .clamp(1, MAX_HISTORY_LIMIT); + let head = git_text(root, &["rev-parse", "HEAD"])?; + let tags = tags_by_commit(root)?; + let ordinals = revision_ordinals(root)?; + let total_commits = ordinals.len(); + let is_shallow = git_text(root, &["rev-parse", "--is-shallow-repository"])? == "true"; + let format = "%H%x1f%h%x1f%P%x1f%cI%x1f%an%x1f%s%x1e"; + let output = git_bytes( + root, + &[ + "log", + "--topo-order", + &format!("--max-count={limit}"), + &format!("--format={format}"), + ], + )?; + let mut revisions = String::from_utf8_lossy(&output) + .split('\u{1e}') + .filter_map(|record| parse_history_revision_record(record, &tags, &head)) + .collect::>(); + let mut present = revisions + .iter() + .map(|revision| revision.sha.clone()) + .collect::>(); + let missing_releases = tags + .iter() + .filter(|(_, values)| values.iter().any(|tag| is_release_tag(tag))) + .map(|(sha, _)| sha) + .filter(|sha| !present.contains(*sha) && git_is_ancestor(root, sha, "HEAD")) + .cloned() + .collect::>(); + for sha in missing_releases { + let record = git_text(root, &["show", "-s", &format!("--format={format}"), &sha])?; + if let Some(revision) = parse_history_revision_record(&record, &tags, &head) { + present.insert(revision.sha.clone()); + revisions.push(revision); + } + } + revisions.sort_by(|left, right| { + ordinals + .get(&left.sha) + .cmp(&ordinals.get(&right.sha)) + .then_with(|| left.sha.cmp(&right.sha)) + }); + let release_ranges = release_ranges(&revisions, &head); + let truncated = total_commits > revisions.len(); + Ok(HistoryTimeline { + schema_version: 1, + repo_path: root.to_string_lossy().to_string(), + head, + generated_at: Utc::now().to_rfc3339(), + truncated, + is_shallow, + coverage_complete: !is_shallow && !truncated, + release_ranges, + total_commits, + revisions, + }) +} + +pub(super) fn parse_history_revision_record( + record: &str, + tags: &HashMap>, + head: &str, +) -> Option { + let fields = record + .trim() + .trim_end_matches('\u{1e}') + .splitn(6, '\u{1f}') + .collect::>(); + if fields.len() != 6 || fields[0].is_empty() { + return None; + } + let revision_tags = tags.get(fields[0]).cloned().unwrap_or_default(); + Some(HistoryRevision { + sha: fields[0].to_string(), + short_sha: fields[1].to_string(), + parents: fields[2].split_whitespace().map(str::to_string).collect(), + committed_at: fields[3].to_string(), + author: fields[4].to_string(), + subject: fields[5].to_string(), + is_release: revision_tags.iter().any(|tag| is_release_tag(tag)), + is_head: fields[0] == head, + tags: revision_tags, + }) +} + +pub(super) fn revision_ordinals(root: &Path) -> Result, String> { + let output = git_text(root, &["rev-list", "--topo-order", "--reverse", "HEAD"])?; + Ok(output + .lines() + .filter(|sha| !sha.is_empty()) + .enumerate() + .map(|(ordinal, sha)| (sha.to_string(), ordinal as i64)) + .collect()) +} + +pub(super) fn release_ranges( + revisions: &[HistoryRevision], + head: &str, +) -> Vec { + let mut ranges = Vec::new(); + let mut start = 0; + let mut previous_release = None::; + for (index, revision) in revisions.iter().enumerate() { + if !revision.is_release { + continue; + } + let tag = revision + .tags + .iter() + .find(|tag| is_release_tag(tag)) + .cloned(); + let label = tag + .clone() + .unwrap_or_else(|| format!("Release {}", revision.short_sha)); + ranges.push(HistoryReleaseRange { + id: stable_graph_id( + "release-range", + &format!("{}\0{}", revision.sha, tag.as_deref().unwrap_or_default()), + ), + label, + tag, + from_exclusive: previous_release.clone(), + to_inclusive: revision.sha.clone(), + commit_shas: revisions[start..=index] + .iter() + .map(|commit| commit.sha.clone()) + .collect(), + is_unreleased: false, + }); + start = index + 1; + previous_release = Some(revision.sha.clone()); + } + ranges.push(HistoryReleaseRange { + id: stable_graph_id( + "release-range", + &format!( + "unreleased\0{}", + previous_release.as_deref().unwrap_or("root") + ), + ), + label: "Unreleased".to_string(), + tag: None, + from_exclusive: previous_release, + to_inclusive: head.to_string(), + commit_shas: revisions[start..] + .iter() + .map(|commit| commit.sha.clone()) + .collect(), + is_unreleased: true, + }); + ranges +} + +pub(super) fn timeline_tag_fingerprint(timeline: &HistoryTimeline) -> String { + let tag_identity = timeline + .revisions + .iter() + .flat_map(|revision| { + revision + .tags + .iter() + .map(move |tag| format!("{}\0{tag}", revision.sha)) + }) + .collect::>() + .join("\0"); + stable_graph_id("tags", &tag_identity) +} + +pub(crate) fn repository_tag_fingerprint(root: &Path) -> Result { + let mut tag_identity = tags_by_commit(root)? + .into_iter() + .flat_map(|(sha, tags)| { + tags.into_iter() + .filter(|tag| is_release_tag(tag)) + .map(move |tag| format!("{sha}\0{tag}")) + }) + .collect::>(); + tag_identity.sort(); + Ok(stable_graph_id("tags", &tag_identity.join("\0"))) +} + +pub(super) fn classify_history_refresh( + previous_head: Option<&str>, + rewritten: bool, + engine_incompatible: bool, + fast_forward: bool, + tags_changed: bool, +) -> &'static str { + if previous_head.is_none() { + "initial" + } else if rewritten { + "rewritten_history" + } else if engine_incompatible { + "engine_repair" + } else if fast_forward { + "fast_forward" + } else if tags_changed { + "tag_metadata" + } else { + "no_op" + } +} + +pub(super) fn has_incompatible_history_checkpoints( + connection: &Connection, + repo_path: &str, +) -> Result { + connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM history_graph_checkpoints + WHERE repo_path = ?1 + AND (engine_id != ?2 OR engine_version != ?3 OR schema_version != ?4) + )", + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| format!("Inspect history checkpoint compatibility: {error}")) +} + +pub(super) fn compatible_history_checkpoint_exists( + connection: &Connection, + repo_path: &str, + revision: &str, +) -> Result { + connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM history_graph_checkpoints + WHERE repo_path = ?1 AND revision_sha = ?2 + AND engine_id = ?3 AND engine_version = ?4 AND schema_version = ?5 + AND status = 'ready' + )", + params![ + repo_path, + revision, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| format!("Inspect history checkpoint cache: {error}")) +} + pub(super) mod git; + pub(crate) use git::canonical_repo_path; use git::*; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/git_objects.rs b/apps/desktop/src-tauri/src/commands/history_graph/git_objects.rs new file mode 100644 index 0000000..87114d0 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/git_objects.rs @@ -0,0 +1,159 @@ +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct GitTreeEntry { + pub(super) object_id: String, + pub(super) path: String, +} + +pub(super) struct HistoricalBlobBatch { + pub(super) blobs: Vec, + pub(super) discovered_files: usize, + pub(super) truncated: bool, +} + +pub(super) struct GitObjectReader<'a> { + pub(super) root: &'a Path, +} + +impl<'a> GitObjectReader<'a> { + pub(super) fn new(root: &'a Path) -> Self { + Self { root } + } + + #[cfg(test)] + pub(super) fn blobs_at(&self, revision: &str) -> Result, String> { + Ok(self.blobs_at_with_coverage(revision)?.blobs) + } + + pub(super) fn blobs_at_with_coverage( + &self, + revision: &str, + ) -> Result { + let revision = resolve_revision(self.root, revision)?; + let tree = git_bytes(self.root, &["ls-tree", "-r", "-z", &revision])?; + let mut entries = tree + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .filter_map(parse_tree_entry) + .collect::>(); + entries.sort_by(|left, right| left.path.cmp(&right.path)); + let discovered_files = entries.len(); + let truncated = discovered_files > MAX_HISTORICAL_FILES; + entries.truncate(MAX_HISTORICAL_FILES); + Ok(HistoricalBlobBatch { + blobs: self.read_batch(&entries)?, + discovered_files, + truncated, + }) + } + + pub(super) fn blobs_for_paths( + &self, + revision: &str, + paths: &[String], + ) -> Result, String> { + if paths.is_empty() { + return Ok(Vec::new()); + } + let revision = resolve_revision(self.root, revision)?; + let mut arguments = vec!["ls-tree", "-r", "-z", revision.as_str(), "--"]; + arguments.extend(paths.iter().map(String::as_str)); + let tree = git_bytes(self.root, &arguments)?; + let mut entries = tree + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .filter_map(parse_tree_entry) + .collect::>(); + entries.sort_by(|left, right| left.path.cmp(&right.path)); + entries.dedup_by(|left, right| left.path == right.path); + self.read_batch(&entries) + } + + pub(super) fn read_batch( + &self, + entries: &[GitTreeEntry], + ) -> Result, String> { + let mut child = Command::new("git") + .arg("-C") + .arg(self.root) + .args(["cat-file", "--batch"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("Start Git object reader: {error}"))?; + { + let stdin = child + .stdin + .as_mut() + .ok_or_else(|| "Git object reader stdin is unavailable".to_string())?; + for entry in entries { + writeln!(stdin, "{}", entry.object_id) + .map_err(|error| format!("Queue Git object: {error}"))?; + } + } + drop(child.stdin.take()); + let stdout = child + .stdout + .take() + .ok_or_else(|| "Git object reader stdout is unavailable".to_string())?; + let mut reader = BufReader::new(stdout); + let mut blobs = Vec::with_capacity(entries.len()); + for entry in entries { + let mut header = String::new(); + reader + .read_line(&mut header) + .map_err(|error| format!("Read Git object header: {error}"))?; + let fields = header.split_whitespace().collect::>(); + if fields.len() != 3 || fields[1] != "blob" { + return Err(format!( + "Git object {} is unavailable or is not a blob", + entry.object_id + )); + } + let size = fields[2] + .parse::() + .map_err(|error| format!("Invalid Git object size: {error}"))?; + let bytes = if size <= MAX_HISTORICAL_BLOB_BYTES { + let mut bytes = vec![0; size]; + reader + .read_exact(&mut bytes) + .map_err(|error| format!("Read Git object content: {error}"))?; + bytes + } else { + std::io::copy(&mut reader.by_ref().take(size as u64), &mut std::io::sink()) + .map_err(|error| format!("Skip oversized Git object: {error}"))?; + vec![0; MAX_HISTORICAL_BLOB_BYTES + 1] + }; + let mut newline = [0_u8; 1]; + reader + .read_exact(&mut newline) + .map_err(|error| format!("Read Git object delimiter: {error}"))?; + blobs.push(HistoricalFileBlob { + path: entry.path.clone(), + bytes, + }); + } + let status = child + .wait() + .map_err(|error| format!("Wait for Git object reader: {error}"))?; + if !status.success() { + return Err("Git object reader failed".to_string()); + } + Ok(blobs) + } +} + +pub(super) fn parse_tree_entry(record: &[u8]) -> Option { + let tab = record.iter().position(|byte| *byte == b'\t')?; + let header = String::from_utf8_lossy(&record[..tab]); + let fields = header.split_whitespace().collect::>(); + if fields.len() != 3 || fields[1] != "blob" { + return None; + } + Some(GitTreeEntry { + object_id: fields[2].to_string(), + path: String::from_utf8_lossy(&record[tab + 1..]).replace('\\', "/"), + }) +} diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs index 7c6de99..a903803 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -404,7 +404,12 @@ pub struct HistoryAnnotationPage { } mod catalog; +mod git_objects; pub(crate) use catalog::canonical_repo_path; use catalog::git::*; pub(crate) use catalog::git::{git_text, resolve_revision}; +pub(crate) use catalog::repository_tag_fingerprint; +use catalog::*; +pub use catalog::{history_list_releases, history_search, load_history_revisions}; +use git_objects::*; From 80427e6ebb0b8cc45b3a5dd57df77ebe071ea136 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:35:38 +0530 Subject: [PATCH 039/141] feat(history): persist compressed graph checkpoints --- .../src/commands/history_graph/catalog/mod.rs | 1 + .../history_graph/catalog/persistence.rs | 595 ++++++++++++++++++ .../src/commands/history_graph/mod.rs | 4 + .../src/commands/history_graph/storage.rs | 417 ++++++++++++ 4 files changed, 1017 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/catalog/persistence.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/storage.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs index ddab108..dba8fc0 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/catalog/mod.rs @@ -404,6 +404,7 @@ pub(super) fn compatible_history_checkpoint_exists( } pub(super) mod git; +pub(super) mod persistence; pub(crate) use git::canonical_repo_path; use git::*; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/catalog/persistence.rs b/apps/desktop/src-tauri/src/commands/history_graph/catalog/persistence.rs new file mode 100644 index 0000000..8a1908e --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/catalog/persistence.rs @@ -0,0 +1,595 @@ +use super::*; + +#[cfg(test)] +pub(in crate::commands::history_graph) fn repair_derived_history( + connection: &Connection, + repo_path: &str, + rewritten: bool, + engine_incompatible: bool, + recorded_at: &str, +) -> Result { + let transaction = connection + .unchecked_transaction() + .map_err(|error| format!("Start history repair transaction: {error}"))?; + let snapshot_ids = if rewritten { + let mut statement = transaction + .prepare("SELECT snapshot_id FROM history_graph_checkpoints WHERE repo_path = ?1") + .map_err(|error| format!("Prepare rewritten checkpoint repair: {error}"))?; + let snapshot_ids = statement + .query_map(params![repo_path], |row| row.get::<_, String>(0)) + .map_err(|error| format!("Query rewritten checkpoints: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read rewritten checkpoints: {error}"))?; + snapshot_ids + } else { + let mut statement = transaction + .prepare( + "SELECT snapshot_id FROM history_graph_checkpoints + WHERE repo_path = ?1 + AND (engine_id != ?2 OR engine_version != ?3 OR schema_version != ?4)", + ) + .map_err(|error| format!("Prepare engine checkpoint repair: {error}"))?; + let snapshot_ids = statement + .query_map( + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + |row| row.get::<_, String>(0), + ) + .map_err(|error| format!("Query incompatible checkpoints: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read incompatible checkpoints: {error}"))?; + snapshot_ids + }; + let checkpoints_deleted = if rewritten { + transaction + .execute( + "DELETE FROM history_graph_checkpoints WHERE repo_path = ?1", + params![repo_path], + ) + .map_err(|error| format!("Delete rewritten checkpoints: {error}"))? + } else if engine_incompatible { + transaction + .execute( + "DELETE FROM history_graph_checkpoints + WHERE repo_path = ?1 + AND (engine_id != ?2 OR engine_version != ?3 OR schema_version != ?4)", + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + ) + .map_err(|error| format!("Delete incompatible checkpoints: {error}"))? + } else { + 0 + }; + let mut snapshots_deleted = 0; + for snapshot_id in snapshot_ids { + snapshots_deleted += transaction + .execute( + "DELETE FROM structural_graph_snapshots WHERE id = ?1", + params![snapshot_id], + ) + .map_err(|error| format!("Delete invalid structural snapshot: {error}"))?; + snapshots_deleted += transaction + .execute( + "DELETE FROM history_graph_snapshot_blobs WHERE snapshot_id = ?1", + params![snapshot_id], + ) + .map_err(|error| format!("Delete invalid compressed history snapshot: {error}"))?; + } + let events_deleted = transaction + .execute( + if rewritten { + "DELETE FROM history_graph_events + WHERE repo_path = ?1 + AND source_id IN ('git', 'codevetter-structural-history', 'codevetter-lineage')" + } else { + "DELETE FROM history_graph_events + WHERE repo_path = ?1 + AND source_id IN ('codevetter-structural-history', 'codevetter-lineage')" + }, + params![repo_path], + ) + .map_err(|error| format!("Delete derived history events: {error}"))?; + let revisions_deleted = if rewritten { + transaction + .execute( + "DELETE FROM history_graph_revisions WHERE repo_path = ?1", + params![repo_path], + ) + .map_err(|error| format!("Delete rewritten revision index: {error}"))? + } else { + 0 + }; + let reason = if rewritten { + "git_history_rewritten" + } else { + "structural_engine_changed" + }; + transaction + .execute( + "INSERT OR REPLACE INTO history_graph_events ( + id, repo_path, event_kind, trust, origin, source_id, source_cursor, + payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, 'invalidation', 'extracted', 'analysis', + 'codevetter-history-repair', ?3, ?4, '[]', ?5)", + params![ + stable_graph_id( + "history-event", + &format!("repair\0{repo_path}\0{reason}\0{recorded_at}") + ), + repo_path, + reason, + serde_json::json!({ + "reason": reason, + "repair_scope": if rewritten { + "derived_revisions_checkpoints_snapshots_events" + } else { + "incompatible_checkpoints_snapshots_and_structural_events" + }, + "preserved": ["imported_evidence", "user_annotations"], + }) + .to_string(), + recorded_at, + ], + ) + .map_err(|error| format!("Record history repair event: {error}"))?; + transaction + .commit() + .map_err(|error| format!("Commit history repair: {error}"))?; + Ok(checkpoints_deleted + snapshots_deleted + events_deleted + revisions_deleted) +} + +pub(in crate::commands::history_graph) fn prune_unreachable_history( + connection: &Connection, + root: &Path, + repo_path: &str, +) -> Result { + let reachable = revision_ordinals(root)?.into_keys().collect::>(); + let mut statement = connection + .prepare("SELECT sha FROM history_graph_revisions WHERE repo_path = ?1 ORDER BY sha") + .map_err(|error| format!("Prepare unreachable history cleanup: {error}"))?; + let revisions = statement + .query_map(params![repo_path], |row| row.get::<_, String>(0)) + .map_err(|error| format!("Query unreachable history revisions: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read unreachable history revisions: {error}"))?; + drop(statement); + let transaction = connection + .unchecked_transaction() + .map_err(|error| format!("Start unreachable history cleanup: {error}"))?; + let mut removed = 0; + for revision in revisions + .into_iter() + .filter(|revision| !reachable.contains(revision)) + { + let snapshot_ids = { + let mut statement = transaction + .prepare( + "SELECT snapshot_id FROM history_graph_checkpoints + WHERE repo_path = ?1 AND revision_sha = ?2", + ) + .map_err(|error| format!("Prepare unreachable checkpoint cleanup: {error}"))?; + let snapshot_ids = statement + .query_map(params![repo_path, revision], |row| row.get::<_, String>(0)) + .map_err(|error| format!("Query unreachable checkpoints: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read unreachable checkpoints: {error}"))?; + snapshot_ids + }; + removed += transaction + .execute( + "DELETE FROM history_graph_events + WHERE repo_path = ?1 AND revision_sha = ?2 + AND source_id IN ('git', 'codevetter-structural-history', 'codevetter-lineage')", + params![repo_path, revision], + ) + .map_err(|error| format!("Delete unreachable derived events: {error}"))?; + removed += transaction + .execute( + "DELETE FROM history_graph_revisions WHERE repo_path = ?1 AND sha = ?2", + params![repo_path, revision], + ) + .map_err(|error| format!("Delete unreachable history revision: {error}"))?; + for snapshot_id in snapshot_ids { + removed += transaction + .execute( + "DELETE FROM structural_graph_snapshots WHERE id = ?1", + params![snapshot_id], + ) + .map_err(|error| format!("Delete unreachable structural snapshot: {error}"))?; + } + } + transaction + .commit() + .map_err(|error| format!("Commit unreachable history cleanup: {error}"))?; + Ok(removed) +} + +pub(in crate::commands::history_graph) fn prune_incompatible_history_checkpoints( + connection: &Connection, + repo_path: &str, +) -> Result { + let mut statement = connection + .prepare( + "SELECT snapshot_id FROM history_graph_checkpoints + WHERE repo_path = ?1 + AND (engine_id != ?2 OR engine_version != ?3 OR schema_version != ?4)", + ) + .map_err(|error| format!("Prepare incompatible checkpoint cleanup: {error}"))?; + let snapshot_ids = statement + .query_map( + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + |row| row.get::<_, String>(0), + ) + .map_err(|error| format!("Query incompatible checkpoints: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read incompatible checkpoints: {error}"))?; + drop(statement); + let transaction = connection + .unchecked_transaction() + .map_err(|error| format!("Start incompatible checkpoint cleanup: {error}"))?; + let mut removed = transaction + .execute( + "DELETE FROM history_graph_checkpoints + WHERE repo_path = ?1 + AND (engine_id != ?2 OR engine_version != ?3 OR schema_version != ?4)", + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + ) + .map_err(|error| format!("Delete incompatible checkpoints: {error}"))?; + for snapshot_id in snapshot_ids { + removed += transaction + .execute( + "DELETE FROM structural_graph_snapshots WHERE id = ?1", + params![snapshot_id], + ) + .map_err(|error| format!("Delete incompatible structural snapshot: {error}"))?; + removed += transaction + .execute( + "DELETE FROM history_graph_snapshot_blobs WHERE snapshot_id = ?1", + params![snapshot_id], + ) + .map_err(|error| format!("Delete incompatible compressed snapshot: {error}"))?; + } + transaction + .commit() + .map_err(|error| format!("Commit incompatible checkpoint cleanup: {error}"))?; + Ok(removed) +} + +pub(in crate::commands::history_graph) fn history_adapter_cursor_json( + connection: &Connection, + repo_path: &str, + head: &str, +) -> Result { + let mut statement = connection + .prepare( + "SELECT source_id, MAX(source_cursor) + FROM history_graph_events + WHERE repo_path = ?1 AND source_cursor IS NOT NULL + GROUP BY source_id ORDER BY source_id", + ) + .map_err(|error| format!("Prepare history adapter cursors: {error}"))?; + let adapters = statement + .query_map(params![repo_path], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| format!("Query history adapter cursors: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read history adapter cursors: {error}"))?; + Ok(serde_json::json!({ "head": head, "adapters": adapters }).to_string()) +} + +#[cfg(test)] +pub(in crate::commands::history_graph) fn persist_history_adapter_cursors( + connection: &Connection, + repo_path: &str, + head: &str, +) -> Result<(), String> { + let cursor_json = history_adapter_cursor_json(connection, repo_path, head)?; + connection + .execute( + "UPDATE history_graph_repositories SET cursor_json = ?2 WHERE repo_path = ?1", + params![repo_path, cursor_json], + ) + .map_err(|error| format!("Persist history adapter cursors: {error}"))?; + Ok(()) +} + +#[cfg(test)] +pub(in crate::commands::history_graph) fn persist_timeline( + connection: &Connection, + timeline: &HistoryTimeline, +) -> Result<(), String> { + persist_timeline_with_publication(connection, timeline, true) +} + +pub(in crate::commands::history_graph) fn persist_timeline_catalog( + connection: &Connection, + timeline: &HistoryTimeline, +) -> Result<(), String> { + persist_timeline_with_publication(connection, timeline, false) +} + +pub(in crate::commands::history_graph) fn persist_timeline_with_publication( + connection: &Connection, + timeline: &HistoryTimeline, + publish: bool, +) -> Result<(), String> { + let root = Path::new(&timeline.repo_path); + let tag_fingerprint = + repository_tag_fingerprint(root).unwrap_or_else(|_| timeline_tag_fingerprint(timeline)); + let ordinals = revision_ordinals(root).unwrap_or_else(|_| { + timeline + .revisions + .iter() + .enumerate() + .map(|(ordinal, revision)| (revision.sha.clone(), ordinal as i64)) + .collect() + }); + let previous_tag_fingerprint = connection + .query_row( + "SELECT indexed_tags_fingerprint FROM history_graph_repositories + WHERE repo_path = ?1", + params![timeline.repo_path], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(|error| format!("Load prior tag fingerprint: {error}"))? + .flatten(); + let transaction = connection + .unchecked_transaction() + .map_err(|error| format!("Start history transaction: {error}"))?; + if publish { + transaction + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, indexed_tags_fingerprint, + status, cursor_json, coverage_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, 'ready', ?5, ?6, ?7, ?7) + ON CONFLICT(repo_path) DO UPDATE SET + indexed_head = excluded.indexed_head, + indexed_tags_fingerprint = excluded.indexed_tags_fingerprint, + status = excluded.status, + cursor_json = excluded.cursor_json, + coverage_json = excluded.coverage_json, + updated_at = excluded.updated_at", + params![ + timeline.repo_path, + stable_graph_id("repository", &timeline.repo_path), + timeline.head, + tag_fingerprint, + serde_json::json!({ "head": timeline.head }).to_string(), + serde_json::json!({ + "loaded_commits": timeline.revisions.len(), + "total_commits": timeline.total_commits, + "truncated": timeline.truncated, + "is_shallow": timeline.is_shallow, + "coverage_complete": timeline.coverage_complete, + }) + .to_string(), + timeline.generated_at, + ], + ) + .map_err(|error| format!("Persist history repository: {error}"))?; + } else { + transaction + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, indexed_tags_fingerprint, + status, cursor_json, coverage_json, created_at, updated_at + ) VALUES (?1, ?2, NULL, NULL, 'pending', '{}', '{}', ?3, ?3) + ON CONFLICT(repo_path) DO UPDATE SET updated_at = excluded.updated_at", + params![ + timeline.repo_path, + stable_graph_id("repository", &timeline.repo_path), + timeline.generated_at, + ], + ) + .map_err(|error| format!("Persist history repository catalog: {error}"))?; + } + let existing_revisions = { + let mut statement = transaction + .prepare("SELECT sha FROM history_graph_revisions WHERE repo_path = ?1 ORDER BY sha") + .map_err(|error| format!("Prepare existing history revisions: {error}"))?; + let revisions = statement + .query_map(params![timeline.repo_path], |row| row.get::<_, String>(0)) + .map_err(|error| format!("Query existing history revisions: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read existing history revisions: {error}"))?; + revisions + }; + for (index, sha) in existing_revisions.iter().enumerate() { + transaction + .execute( + "UPDATE history_graph_revisions SET ordinal = ?3 + WHERE repo_path = ?1 AND sha = ?2", + params![timeline.repo_path, sha, -1_i64 - index as i64], + ) + .map_err(|error| format!("Stage stable history ordinal: {error}"))?; + } + transaction + .execute( + "UPDATE history_graph_revisions + SET is_head = 0, is_release = 0, tags_json = '[]' WHERE repo_path = ?1", + params![timeline.repo_path], + ) + .map_err(|error| format!("Reset history head: {error}"))?; + let mut statement = transaction + .prepare( + "INSERT INTO history_graph_revisions ( + repo_path, sha, ordinal, committed_at, author_name, subject, + parents_json, tags_json, is_release, is_head, coverage_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '{}') + ON CONFLICT(repo_path, sha) DO UPDATE SET + ordinal = excluded.ordinal, + committed_at = excluded.committed_at, + author_name = excluded.author_name, + subject = excluded.subject, + parents_json = excluded.parents_json, + tags_json = excluded.tags_json, + is_release = excluded.is_release, + is_head = excluded.is_head", + ) + .map_err(|error| format!("Prepare history revisions: {error}"))?; + for revision in &timeline.revisions { + let ordinal = ordinals.get(&revision.sha).copied().unwrap_or(i64::MAX); + statement + .execute(params![ + timeline.repo_path, + revision.sha, + ordinal, + revision.committed_at, + revision.author, + revision.subject, + serde_json::to_string(&revision.parents).map_err(|error| error.to_string())?, + serde_json::to_string(&revision.tags).map_err(|error| error.to_string())?, + i64::from(revision.is_release), + i64::from(revision.is_head), + ]) + .map_err(|error| format!("Persist history revision: {error}"))?; + } + drop(statement); + for sha in existing_revisions { + let Some(ordinal) = ordinals.get(&sha) else { + continue; + }; + transaction + .execute( + "UPDATE history_graph_revisions SET ordinal = ?3 + WHERE repo_path = ?1 AND sha = ?2", + params![timeline.repo_path, sha, ordinal], + ) + .map_err(|error| format!("Restore stable history ordinal: {error}"))?; + } + transaction + .execute( + "DELETE FROM history_graph_events WHERE repo_path = ?1 AND source_id = 'git'", + params![timeline.repo_path], + ) + .map_err(|error| format!("Replace Git timeline events: {error}"))?; + let mut event_statement = transaction + .prepare( + "INSERT OR IGNORE INTO history_graph_events ( + id, repo_path, revision_sha, event_kind, trust, origin, source_id, + source_cursor, payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, ?3, ?4, 'extracted', 'metadata', 'git', ?5, ?6, + '[]', ?7)", + ) + .map_err(|error| format!("Prepare Git timeline events: {error}"))?; + for revision in &timeline.revisions { + event_statement + .execute(params![ + stable_graph_id( + "history-event", + &format!("commit\0{}\0{}", timeline.repo_path, revision.sha) + ), + timeline.repo_path, + revision.sha, + "commit", + revision.sha, + serde_json::json!({ + "sha": revision.sha, + "parents": revision.parents, + "subject": revision.subject, + }) + .to_string(), + revision.committed_at, + ]) + .map_err(|error| format!("Persist Git commit event: {error}"))?; + for tag in &revision.tags { + event_statement + .execute(params![ + stable_graph_id( + "history-event", + &format!("release\0{}\0{}\0{tag}", timeline.repo_path, revision.sha) + ), + timeline.repo_path, + revision.sha, + "release", + format!("{}:{tag}", revision.sha), + serde_json::json!({ + "sha": revision.sha, + "tag": tag, + "subject": revision.subject, + "recognized_release": revision.is_release, + }) + .to_string(), + revision.committed_at, + ]) + .map_err(|error| format!("Persist Git release event: {error}"))?; + } + } + event_statement + .execute(params![ + stable_graph_id( + "history-event", + &format!( + "coverage\0{}\0{}\0{}\0{}", + timeline.repo_path, + timeline.head, + timeline.revisions.len(), + timeline.coverage_complete + ) + ), + timeline.repo_path, + timeline.head, + "coverage", + format!("coverage:{}", timeline.head), + serde_json::json!({ + "loaded_commits": timeline.revisions.len(), + "total_commits": timeline.total_commits, + "truncated": timeline.truncated, + "is_shallow": timeline.is_shallow, + "coverage_complete": timeline.coverage_complete, + }) + .to_string(), + timeline.generated_at, + ]) + .map_err(|error| format!("Persist Git coverage event: {error}"))?; + if let Some(previous) = previous_tag_fingerprint.filter(|value| value != &tag_fingerprint) { + event_statement + .execute(params![ + stable_graph_id( + "history-event", + &format!( + "invalidation\0{}\0{}\0{}", + timeline.repo_path, previous, tag_fingerprint + ) + ), + timeline.repo_path, + timeline.head, + "invalidation", + format!("tags:{tag_fingerprint}"), + serde_json::json!({ + "reason": "tag_fingerprint_changed", + "previous": previous, + "current": tag_fingerprint, + "repair_scope": "release_ranges_and_descendant_deltas", + }) + .to_string(), + timeline.generated_at, + ]) + .map_err(|error| format!("Persist history invalidation event: {error}"))?; + } + drop(event_statement); + transaction + .commit() + .map_err(|error| format!("Commit history timeline: {error}")) +} diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs index a903803..79ba2e2 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -405,11 +405,15 @@ pub struct HistoryAnnotationPage { mod catalog; mod git_objects; +mod storage; pub(crate) use catalog::canonical_repo_path; use catalog::git::*; pub(crate) use catalog::git::{git_text, resolve_revision}; +use catalog::persistence::*; pub(crate) use catalog::repository_tag_fingerprint; use catalog::*; pub use catalog::{history_list_releases, history_search, load_history_revisions}; use git_objects::*; +pub(crate) use storage::history_storage_key; +use storage::*; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/storage.rs b/apps/desktop/src-tauri/src/commands/history_graph/storage.rs new file mode 100644 index 0000000..9128497 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/storage.rs @@ -0,0 +1,417 @@ +use super::*; + +pub(super) fn encode_history_blob(value: &T) -> Result<(Vec, usize), String> { + let json = + serde_json::to_vec(value).map_err(|error| format!("Encode history blob: {error}"))?; + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); + encoder + .write_all(&json) + .map_err(|error| format!("Compress history blob: {error}"))?; + let compressed = encoder + .finish() + .map_err(|error| format!("Finish history compression: {error}"))?; + Ok((compressed, json.len())) +} + +pub(super) fn decode_history_blob(payload: &[u8]) -> Result { + let mut decoder = ZlibDecoder::new(payload); + let mut json = Vec::new(); + decoder + .read_to_end(&mut json) + .map_err(|error| format!("Decompress history blob: {error}"))?; + serde_json::from_slice(&json).map_err(|error| format!("Decode history blob: {error}")) +} + +pub(super) fn persist_history_snapshot_blob( + connection: &Connection, + repo_path: &str, + revision: &str, + snapshot: &StructuralGraphSnapshot, +) -> Result<(), String> { + let (payload, uncompressed_bytes) = encode_history_blob(snapshot)?; + connection + .execute( + "INSERT OR REPLACE INTO history_graph_snapshot_blobs ( + snapshot_id, repo_path, revision_sha, encoding, payload, + uncompressed_bytes, created_at + ) VALUES (?1, ?2, ?3, 'zlib-json-v1', ?4, ?5, ?6)", + params![ + snapshot.id, + repo_path, + revision, + payload, + uncompressed_bytes as i64, + snapshot.created_at, + ], + ) + .map_err(|error| format!("Persist compressed history checkpoint: {error}"))?; + Ok(()) +} + +pub(super) fn load_history_snapshot_blob( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, String> { + let payload = connection + .query_row( + "SELECT payload FROM history_graph_snapshot_blobs + WHERE repo_path = ?1 AND snapshot_id = ?2 AND encoding = 'zlib-json-v1'", + params![repo_path, snapshot_id], + |row| row.get::<_, Vec>(0), + ) + .optional() + .map_err(|error| format!("Load compressed history checkpoint: {error}"))?; + payload.as_deref().map(decode_history_blob).transpose() +} + +pub(super) fn persist_history_delta_blob( + connection: &Connection, + event_id: &str, + delta: &HistoryStructuralDelta, +) -> Result<(), String> { + let (payload, uncompressed_bytes) = encode_history_blob(delta)?; + connection + .execute( + "INSERT OR REPLACE INTO history_graph_event_blobs ( + event_id, encoding, payload, uncompressed_bytes, created_at + ) VALUES (?1, 'zlib-json-v1', ?2, ?3, ?4)", + params![ + event_id, + payload, + uncompressed_bytes as i64, + delta.generated_at, + ], + ) + .map_err(|error| format!("Persist compressed structural delta: {error}"))?; + Ok(()) +} + +pub(super) fn load_history_structural_delta( + connection: &Connection, + repo_path: &str, + before_revision: &str, + after_revision: &str, +) -> Result, String> { + let event_id = structural_delta_event_id(repo_path, before_revision, after_revision); + let blob = connection + .query_row( + "SELECT b.payload FROM history_graph_event_blobs b + JOIN history_graph_events e ON e.id = b.event_id + WHERE b.event_id = ?1 AND e.repo_path = ?2 AND b.encoding = 'zlib-json-v1'", + params![event_id, repo_path], + |row| row.get::<_, Vec>(0), + ) + .optional() + .map_err(|error| format!("Load compressed structural delta: {error}"))?; + if let Some(blob) = blob { + return decode_history_blob(&blob).map(Some); + } + let payload = connection + .query_row( + "SELECT payload_json FROM history_graph_events + WHERE id = ?1 AND repo_path = ?2 AND event_kind = 'structural_delta'", + params![event_id, repo_path], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("Load legacy structural delta: {error}"))?; + payload + .as_deref() + .map(|payload| { + serde_json::from_str(payload) + .map_err(|error| format!("Decode legacy structural delta: {error}")) + }) + .transpose() +} + +pub(super) fn load_or_build_history_snapshot( + root: &Path, + canonical_repo_path: &str, + storage_key: &str, + revision: &str, + app: &tauri::AppHandle, + database: &Arc>, +) -> Result< + ( + crate::commands::structural_graph::types::StructuralGraphSnapshot, + bool, + ), + String, +> { + let existing_snapshot_id = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + connection + .query_row( + "SELECT snapshot_id FROM history_graph_checkpoints + WHERE repo_path = ?1 AND revision_sha = ?2 AND engine_id = ?3 + AND engine_version = ?4 AND schema_version = ?5 AND status = 'ready'", + params![ + canonical_repo_path, + revision, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION + ], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("Load history checkpoint: {error}"))? + }; + if let Some(snapshot_id) = existing_snapshot_id { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + if let Some(snapshot) = + load_history_snapshot_blob(&connection, canonical_repo_path, &snapshot_id)? + { + return Ok((snapshot, true)); + } + if let Some(snapshot) = load_snapshot_by_id(&connection, storage_key, &snapshot_id) + .map_err(|error| error.to_string())? + { + return Ok((snapshot, true)); + } + } + build_history_checkpoint( + root, + canonical_repo_path, + storage_key, + revision, + app, + database, + ) + .map(|snapshot| (snapshot, false)) +} + +pub(super) fn build_history_checkpoint( + root: &Path, + canonical_repo_path: &str, + storage_key: &str, + revision: &str, + app: &tauri::AppHandle, + database: &Arc>, +) -> Result { + let snapshot = build_history_snapshot_unpersisted(root, storage_key, revision, app)?; + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + ensure_history_revision(&connection, root, canonical_repo_path, revision)?; + persist_history_snapshot_blob(&connection, canonical_repo_path, revision, &snapshot)?; + connection + .execute( + "INSERT INTO history_graph_checkpoints ( + repo_path, revision_sha, snapshot_id, engine_id, engine_version, + schema_version, status, coverage_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'ready', ?7, ?8) + ON CONFLICT(repo_path, revision_sha, engine_id, engine_version, schema_version) + DO UPDATE SET snapshot_id = excluded.snapshot_id, status = 'ready', + coverage_json = excluded.coverage_json, created_at = excluded.created_at", + params![ + canonical_repo_path, + revision, + snapshot.id, + snapshot.engine.id, + snapshot.engine.version, + snapshot.schema_version, + serde_json::to_string(&snapshot.coverage).map_err(|error| error.to_string())?, + snapshot.created_at, + ], + ) + .map_err(|error| format!("Persist history checkpoint: {error}"))?; + Ok(snapshot) +} + +pub(super) fn build_history_snapshot_unpersisted( + root: &Path, + storage_key: &str, + revision: &str, + app: &tauri::AppHandle, +) -> Result { + let batch = GitObjectReader::new(root).blobs_at_with_coverage(revision)?; + let cancellation = StructuralGraphCancellation::default(); + let progress_app = app.clone(); + let progress = move |event: StructuralGraphProgress| { + let _ = progress_app.emit("history-graph-progress", &event); + }; + let mut snapshot = + build_snapshot_from_blobs(storage_key, revision, batch.blobs, &cancellation, &progress) + .map_err(|error| error.to_string())?; + apply_historical_file_coverage(&mut snapshot, batch.discovered_files, batch.truncated); + compact_history_snapshot(&mut snapshot); + Ok(snapshot) +} + +pub(super) fn apply_historical_file_coverage( + snapshot: &mut StructuralGraphSnapshot, + discovered_files: usize, + truncated: bool, +) { + if !truncated { + return; + } + let omitted = discovered_files.saturating_sub(snapshot.files.len()); + snapshot.truncated = true; + snapshot.coverage.discovered_files = discovered_files; + snapshot.coverage.skipped_files = snapshot.coverage.skipped_files.saturating_add(omitted); + snapshot.diagnostics.push(StructuralGraphDiagnostic { + severity: "warning".to_string(), + code: "historical_file_limit".to_string(), + message: format!( + "Historical extraction indexed {} of {} Git blobs; {} files were omitted by the local bound", + snapshot.files.len(), discovered_files, omitted + ), + path: None, + language: None, + }); +} + +pub(super) fn build_history_snapshot_from_previous( + root: &Path, + storage_key: &str, + revision: &str, + previous: &StructuralGraphSnapshot, + path_changes: &[HistoryPathChange], + app: &tauri::AppHandle, +) -> Result { + let changed_paths = path_changes + .iter() + .filter(|change| change.change_kind != "deleted") + .map(|change| change.path.clone()) + .collect::>(); + let deleted_paths = path_changes + .iter() + .filter(|change| change.change_kind == "deleted") + .map(|change| change.path.clone()) + .chain( + path_changes + .iter() + .filter(|change| change.change_kind == "renamed") + .filter_map(|change| change.old_path.clone()), + ) + .collect::>(); + let blobs = GitObjectReader::new(root).blobs_for_paths(revision, &changed_paths)?; + let cancellation = StructuralGraphCancellation::default(); + let progress_app = app.clone(); + let progress = move |event: StructuralGraphProgress| { + let _ = progress_app.emit("history-graph-progress", &event); + }; + let mut snapshot = build_snapshot_from_blob_delta( + storage_key, + revision, + previous, + blobs, + &deleted_paths, + &cancellation, + &progress, + ) + .map_err(|error| error.to_string())?; + compact_history_snapshot(&mut snapshot); + Ok(snapshot) +} + +pub(super) fn compact_history_snapshot(snapshot: &mut StructuralGraphSnapshot) { + for source in snapshot + .nodes + .iter_mut() + .flat_map(|node| node.sources.iter_mut()) + .chain( + snapshot + .edges + .iter_mut() + .flat_map(|edge| edge.sources.iter_mut()), + ) + { + source.excerpt = None; + } +} + +pub(super) fn ensure_history_revision( + connection: &Connection, + root: &Path, + canonical_repo_path: &str, + revision: &str, +) -> Result<(), String> { + let exists = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM history_graph_revisions WHERE repo_path = ?1 AND sha = ?2)", + params![canonical_repo_path, revision], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| format!("Check history revision: {error}"))? + != 0; + if exists { + return Ok(()); + } + let head = git_text(root, &["rev-parse", "HEAD"])?; + let now = Utc::now().to_rfc3339(); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, created_at, updated_at + ) VALUES (?1, ?2, ?3, 'partial', ?4, ?4) + ON CONFLICT(repo_path) DO NOTHING", + params![ + canonical_repo_path, + stable_graph_id("repository", canonical_repo_path), + head, + now + ], + ) + .map_err(|error| format!("Ensure history repository: {error}"))?; + let metadata = git_text( + root, + &["show", "-s", "--format=%cI%x1f%an%x1f%s%x1f%P", revision], + )?; + let fields = metadata.splitn(4, '\u{1f}').collect::>(); + if fields.len() != 4 { + return Err("Git revision metadata is incomplete".to_string()); + } + let ordinal = connection + .query_row( + "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM history_graph_revisions WHERE repo_path = ?1", + params![canonical_repo_path], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| format!("Allocate history ordinal: {error}"))?; + let tags = tags_by_commit(root)?.remove(revision).unwrap_or_default(); + connection + .execute( + "INSERT INTO history_graph_revisions ( + repo_path, sha, ordinal, committed_at, author_name, subject, + parents_json, tags_json, is_release, is_head, coverage_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '{}')", + params![ + canonical_repo_path, + revision, + ordinal, + fields[0], + fields[1], + fields[2], + serde_json::to_string(&fields[3].split_whitespace().collect::>()) + .map_err(|error| error.to_string())?, + serde_json::to_string(&tags).map_err(|error| error.to_string())?, + i64::from(tags.iter().any(|tag| is_release_tag(tag))), + i64::from(revision == head), + ], + ) + .map_err(|error| format!("Ensure history revision: {error}"))?; + Ok(()) +} + +pub(super) fn structural_delta_event_id( + repo_path: &str, + before_revision: &str, + after_revision: &str, +) -> String { + stable_graph_id( + "history-event", + &format!("structural_delta\0{repo_path}\0{before_revision}\0{after_revision}"), + ) +} + +pub(crate) fn history_storage_key(canonical_repo_path: &str) -> String { + format!("{canonical_repo_path}::codevetter-history") +} From 70b826aafc1132f88cfa954df63ac46f98c46045 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:36:59 +0530 Subject: [PATCH 040/141] feat(history): derive temporal deltas and lineage --- .../src/commands/history_graph/delta.rs | 631 ++++++++++++++++++ .../src/commands/history_graph/mod.rs | 8 + .../commands/history_graph/query_helpers.rs | 291 ++++++++ 3 files changed, 930 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/delta.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/query_helpers.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/delta.rs b/apps/desktop/src-tauri/src/commands/history_graph/delta.rs new file mode 100644 index 0000000..84b99e4 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/delta.rs @@ -0,0 +1,631 @@ +use super::*; + +pub(super) fn set_delta<'a>( + before: impl Iterator, + after: impl Iterator, +) -> (Vec, Vec) { + let before = before.collect::>(); + let after = after.collect::>(); + let mut added = after + .difference(&before) + .map(|id| (*id).to_string()) + .collect::>(); + let mut removed = before + .difference(&after) + .map(|id| (*id).to_string()) + .collect::>(); + added.sort(); + removed.sort(); + (added, removed) +} + +pub(super) fn compute_and_persist_structural_delta( + connection: &Connection, + root: &Path, + repo_path: &str, + before_revision: &str, + after_revision: &str, + before: &StructuralGraphSnapshot, + after: &StructuralGraphSnapshot, +) -> Result { + let path_changes = changed_path_records_between(root, before_revision, after_revision)?; + compute_and_persist_structural_delta_with_paths( + connection, + repo_path, + before_revision, + after_revision, + before, + after, + path_changes, + ) +} + +pub(super) fn compute_and_persist_structural_delta_with_paths( + connection: &Connection, + repo_path: &str, + before_revision: &str, + after_revision: &str, + before: &StructuralGraphSnapshot, + after: &StructuralGraphSnapshot, + path_changes: Vec, +) -> Result { + let structural = query::diff_snapshots(before, after); + let (added_community_ids, removed_community_ids) = set_delta( + before + .communities + .iter() + .map(|community| community.id.as_str()), + after + .communities + .iter() + .map(|community| community.id.as_str()), + ); + let (added_hub_ids, removed_hub_ids) = set_delta( + before + .communities + .iter() + .flat_map(|community| community.hub_node_ids.iter().map(String::as_str)), + after + .communities + .iter() + .flat_map(|community| community.hub_node_ids.iter().map(String::as_str)), + ); + let (added_bridge_ids, removed_bridge_ids) = set_delta( + before + .communities + .iter() + .flat_map(|community| community.bridge_node_ids.iter().map(String::as_str)), + after + .communities + .iter() + .flat_map(|community| community.bridge_node_ids.iter().map(String::as_str)), + ); + let coverage_gap = (before.truncated || after.truncated) + .then(|| "One or both structural checkpoints were bounded".to_string()); + let mut lineage = derive_lineage(before, after, &path_changes, after_revision); + lineage.extend(derive_reintroductions( + connection, + repo_path, + after, + &structural.added_node_ids, + after_revision, + )?); + lineage.sort_by(|left, right| left.id.cmp(&right.id)); + lineage.dedup_by(|left, right| left.id == right.id); + let upsert_node_ids = structural + .added_node_ids + .iter() + .chain(structural.changed_node_ids.iter()) + .collect::>(); + let upsert_edge_ids = structural + .added_edge_ids + .iter() + .chain(structural.changed_edge_ids.iter()) + .collect::>(); + let upsert_nodes = after + .nodes + .iter() + .filter(|node| upsert_node_ids.contains(&node.id)) + .cloned() + .collect(); + let upsert_edges = after + .edges + .iter() + .filter(|edge| upsert_edge_ids.contains(&edge.id)) + .cloned() + .collect(); + let before_files = before + .files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect::>(); + let after_file_paths = after + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + let upsert_files = after + .files + .iter() + .filter(|file| before_files.get(file.path.as_str()).copied() != Some(*file)) + .cloned() + .collect(); + let mut removed_file_paths = before + .files + .iter() + .filter(|file| !after_file_paths.contains(file.path.as_str())) + .map(|file| file.path.clone()) + .collect::>(); + removed_file_paths.sort(); + let before_metrics = before + .metrics + .iter() + .map(|metric| (metric.id.as_str(), metric)) + .collect::>(); + let after_metric_ids = after + .metrics + .iter() + .map(|metric| metric.id.as_str()) + .collect::>(); + let upsert_metrics = after + .metrics + .iter() + .filter(|metric| before_metrics.get(metric.id.as_str()).copied() != Some(*metric)) + .cloned() + .collect(); + let mut removed_metric_ids = before + .metrics + .iter() + .filter(|metric| !after_metric_ids.contains(metric.id.as_str())) + .map(|metric| metric.id.clone()) + .collect::>(); + removed_metric_ids.sort(); + let before_clone_groups = before + .clone_groups + .iter() + .map(|group| (group.id.as_str(), group)) + .collect::>(); + let after_clone_ids = after + .clone_groups + .iter() + .map(|group| group.id.as_str()) + .collect::>(); + let upsert_clone_groups = after + .clone_groups + .iter() + .filter(|group| before_clone_groups.get(group.id.as_str()).copied() != Some(*group)) + .cloned() + .collect(); + let mut removed_clone_group_ids = before + .clone_groups + .iter() + .filter(|group| !after_clone_ids.contains(group.id.as_str())) + .map(|group| group.id.clone()) + .collect::>(); + removed_clone_group_ids.sort(); + let delta = HistoryStructuralDelta { + schema_version: 1, + materialization_version: 1, + repo_path: repo_path.to_string(), + before_revision: before_revision.to_string(), + after_revision: after_revision.to_string(), + before_snapshot_id: before.id.clone(), + after_snapshot_id: after.id.clone(), + added_node_ids: structural.added_node_ids, + removed_node_ids: structural.removed_node_ids, + changed_node_ids: structural.changed_node_ids, + added_edge_ids: structural.added_edge_ids, + removed_edge_ids: structural.removed_edge_ids, + changed_edge_ids: structural.changed_edge_ids, + added_community_ids, + removed_community_ids, + added_hub_ids, + removed_hub_ids, + added_bridge_ids, + removed_bridge_ids, + path_changes, + lineage, + coverage_gap, + generated_at: Utc::now().to_rfc3339(), + upsert_nodes, + upsert_edges, + upsert_communities: after.communities.clone(), + upsert_files, + removed_file_paths, + upsert_metrics, + removed_metric_ids, + after_metric_order: after + .metrics + .iter() + .map(|metric| metric.id.clone()) + .collect(), + upsert_clone_groups, + removed_clone_group_ids, + after_clone_group_order: after + .clone_groups + .iter() + .map(|group| group.id.clone()) + .collect(), + after_coverage: after.coverage.clone(), + after_diagnostics: after.diagnostics.clone(), + after_cursor: after.cursor.clone(), + after_ignore_fingerprint: after.ignore_fingerprint.clone(), + after_truncated: after.truncated, + after_created_at: after.created_at.clone(), + }; + persist_structural_delta(connection, &delta)?; + Ok(delta) +} + +pub(super) fn persist_structural_delta( + connection: &Connection, + delta: &HistoryStructuralDelta, +) -> Result<(), String> { + let event_id = structural_delta_event_id( + &delta.repo_path, + &delta.before_revision, + &delta.after_revision, + ); + let summary = serde_json::json!({ + "schema_version": delta.schema_version, + "materialization_version": delta.materialization_version, + "repo_path": delta.repo_path, + "before_revision": delta.before_revision, + "after_revision": delta.after_revision, + "before_snapshot_id": delta.before_snapshot_id, + "after_snapshot_id": delta.after_snapshot_id, + "added_node_ids": delta.added_node_ids, + "removed_node_ids": delta.removed_node_ids, + "changed_node_ids": delta.changed_node_ids, + "added_edge_ids": delta.added_edge_ids, + "removed_edge_ids": delta.removed_edge_ids, + "changed_edge_ids": delta.changed_edge_ids, + "added_community_ids": delta.added_community_ids, + "removed_community_ids": delta.removed_community_ids, + "added_hub_ids": delta.added_hub_ids, + "removed_hub_ids": delta.removed_hub_ids, + "added_bridge_ids": delta.added_bridge_ids, + "removed_bridge_ids": delta.removed_bridge_ids, + "path_changes": delta.path_changes, + "lineage": delta.lineage, + "coverage_gap": delta.coverage_gap, + "generated_at": delta.generated_at, + "payload_encoding": "zlib-json-v1", + }) + .to_string(); + connection + .execute( + "INSERT OR REPLACE INTO history_graph_events ( + id, repo_path, revision_sha, event_kind, trust, origin, + source_id, source_cursor, payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, ?3, 'structural_delta', 'extracted', 'analysis', + 'codevetter-structural-history', ?4, ?5, '[]', ?6)", + params![ + event_id, + delta.repo_path, + delta.after_revision, + delta.after_snapshot_id, + summary, + delta.generated_at, + ], + ) + .map_err(|error| format!("Persist structural history delta: {error}"))?; + persist_history_delta_blob(connection, &event_id, delta)?; + for lineage in &delta.lineage { + connection + .execute( + "INSERT OR REPLACE INTO history_graph_events ( + id, repo_path, revision_sha, event_kind, entity_id, related_entity_id, + relation_kind, trust, origin, source_id, source_cursor, + payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, ?3, 'entity_lineage', ?4, ?5, ?6, ?7, + 'analysis', 'codevetter-lineage', ?8, ?9, ?10, ?11)", + params![ + lineage.id, + delta.repo_path, + delta.after_revision, + lineage.from_entity_id, + lineage.to_entity_id, + lineage.relation, + lineage.trust.as_str(), + delta.after_snapshot_id, + serde_json::to_string(lineage).map_err(|error| error.to_string())?, + serde_json::to_string(&lineage.sources).map_err(|error| error.to_string())?, + delta.generated_at, + ], + ) + .map_err(|error| format!("Persist structural lineage: {error}"))?; + } + Ok(()) +} + +pub(super) fn derive_reintroductions( + connection: &Connection, + repo_path: &str, + after: &StructuralGraphSnapshot, + added_node_ids: &[String], + after_revision: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT payload_json FROM history_graph_events + WHERE repo_path = ?1 AND event_kind = 'entity_lineage' + AND entity_id = ?2 AND relation_kind = 'removed_in' + ORDER BY recorded_at DESC, id DESC LIMIT 1", + ) + .map_err(|error| format!("Prepare reintroduction query: {error}"))?; + let added = added_node_ids.iter().collect::>(); + let mut reintroductions = Vec::new(); + for node in after.nodes.iter().filter(|node| added.contains(&node.id)) { + let removed: Option = statement + .query_row(params![repo_path, node.id], |row| row.get(0)) + .optional() + .map_err(|error| format!("Query prior removal: {error}"))?; + let Some(removed) = removed else { + continue; + }; + let removal: HistoryLineageEdge = serde_json::from_str(&removed) + .map_err(|error| format!("Decode prior removal: {error}"))?; + reintroductions.push(HistoryLineageEdge { + id: stable_graph_id( + "lineage", + &format!("reintroduced_in\0{}\0{after_revision}", node.id), + ), + from_entity_id: node.id.clone(), + to_entity_id: node.id.clone(), + relation: "reintroduced_in".to_string(), + trust: GraphTrust::Extracted, + evidence: format!( + "Entity returns after the prior removal event {}", + removal.id + ), + sources: node.sources.clone(), + candidates: Vec::new(), + }); + } + Ok(reintroductions) +} + +pub(super) fn derive_lineage( + before: &StructuralGraphSnapshot, + after: &StructuralGraphSnapshot, + path_changes: &[HistoryPathChange], + after_revision: &str, +) -> Vec { + let mut lineage = Vec::new(); + let after_by_id = after + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + for source in &before.nodes { + let Some(target) = after_by_id.get(source.id.as_str()) else { + continue; + }; + if lineage_relevant_change(source, target) { + lineage.push(lineage_edge( + source, + target, + "same_as", + GraphTrust::Extracted, + "Stable structural identity persists while entity attributes change".to_string(), + Vec::new(), + )); + } + } + let after_by_path = after + .nodes + .iter() + .filter_map(|node| node.path.as_deref().map(|path| (path, node))) + .fold(HashMap::<&str, Vec<_>>::new(), |mut map, (path, node)| { + map.entry(path).or_default().push(node); + map + }); + let mut matched_before = HashSet::new(); + let mut matched_after = HashSet::new(); + for change in path_changes.iter().filter(|change| { + matches!(change.change_kind.as_str(), "renamed" | "copied") && change.old_path.is_some() + }) { + let old_path = change.old_path.as_deref().unwrap_or_default(); + let Some(candidates_at_target) = after_by_path.get(change.path.as_str()) else { + continue; + }; + for source in before + .nodes + .iter() + .filter(|node| node.path.as_deref() == Some(old_path)) + { + let mut candidates = candidates_at_target + .iter() + .copied() + .filter(|target| { + target.kind == source.kind + && (target.label == source.label + || (source.kind == "file" && target.kind == "file")) + }) + .collect::>(); + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + if candidates.is_empty() { + continue; + } + let target = candidates[0]; + let trust = if candidates.len() == 1 { + GraphTrust::Extracted + } else { + GraphTrust::Ambiguous + }; + let relation = if change.change_kind == "renamed" { + "moved_to" + } else { + "evolved_from" + }; + lineage.push(lineage_edge( + source, + target, + relation, + trust, + format!( + "Git {} maps {} to {} and structural kind/label remains compatible", + change.change_kind, old_path, change.path + ), + candidates + .iter() + .skip(1) + .map(|node| node.id.clone()) + .collect(), + )); + matched_before.insert(source.id.as_str()); + matched_after.insert(target.id.as_str()); + } + } + let rename_sources = before + .nodes + .iter() + .filter(|node| { + !after.nodes.iter().any(|target| target.id == node.id) + && !matched_before.contains(node.id.as_str()) + }) + .collect::>(); + let mut merge_targets = HashMap::<&str, Vec<_>>::new(); + for source in &rename_sources { + let source_line = source.sources.first().and_then(|anchor| anchor.start_line); + for target in after + .nodes + .iter() + .filter(|target| !matched_after.contains(target.id.as_str())) + .filter(|target| target.kind == source.kind && target.path == source.path) + .filter(|target| { + source_line.is_some() + && target.sources.first().and_then(|anchor| anchor.start_line) == source_line + }) + { + merge_targets + .entry(target.id.as_str()) + .or_default() + .push(*source); + } + } + for (target_id, mut sources) in merge_targets { + if sources.len() < 2 { + continue; + } + sources.sort_by(|left, right| left.id.cmp(&right.id)); + let Some(target) = after_by_id.get(target_id) else { + continue; + }; + for source in &sources { + lineage.push(lineage_edge( + source, + target, + "merged_from", + GraphTrust::Ambiguous, + "Multiple removed entities share the successor's path, kind, and source line" + .to_string(), + sources + .iter() + .filter(|candidate| candidate.id != source.id) + .map(|candidate| candidate.id.clone()) + .collect(), + )); + matched_before.insert(source.id.as_str()); + } + matched_after.insert(target.id.as_str()); + } + for source in rename_sources { + if matched_before.contains(source.id.as_str()) { + continue; + } + let source_line = source.sources.first().and_then(|anchor| anchor.start_line); + let mut candidates = after + .nodes + .iter() + .filter(|target| !matched_after.contains(target.id.as_str())) + .filter(|target| target.kind == source.kind && target.path == source.path) + .filter(|target| { + source_line.is_some() + && target.sources.first().and_then(|anchor| anchor.start_line) == source_line + }) + .collect::>(); + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + if candidates.len() == 1 { + let target = candidates[0]; + lineage.push(lineage_edge( + source, + target, + if source.label == target.label { + "evolved_from" + } else { + "renamed_to" + }, + GraphTrust::Inferred, + "Same path, structural kind, and source line across adjacent revisions".to_string(), + Vec::new(), + )); + matched_before.insert(source.id.as_str()); + matched_after.insert(target.id.as_str()); + } else if candidates.len() > 1 { + lineage.push(lineage_edge( + source, + candidates[0], + "split_into", + GraphTrust::Ambiguous, + "Multiple same-path structural candidates follow the removed entity".to_string(), + candidates + .iter() + .skip(1) + .map(|node| node.id.clone()) + .collect(), + )); + matched_before.insert(source.id.as_str()); + } + } + let revision_entity = stable_graph_id("revision", after_revision); + for source in before.nodes.iter().filter(|node| { + !after.nodes.iter().any(|target| target.id == node.id) + && !matched_before.contains(node.id.as_str()) + }) { + lineage.push(HistoryLineageEdge { + id: stable_graph_id( + "lineage", + &format!("removed_in\0{}\0{revision_entity}", source.id), + ), + from_entity_id: source.id.clone(), + to_entity_id: revision_entity.clone(), + relation: "removed_in".to_string(), + trust: GraphTrust::Extracted, + evidence: "Entity is absent from the exact next structural checkpoint".to_string(), + sources: source.sources.clone(), + candidates: Vec::new(), + }); + } + lineage.sort_by(|left, right| left.id.cmp(&right.id)); + lineage +} + +pub(super) fn lineage_relevant_change( + source: &crate::commands::structural_graph::types::StructuralGraphNode, + target: &crate::commands::structural_graph::types::StructuralGraphNode, +) -> bool { + source.label != target.label + || source.qualified_name != target.qualified_name + || source.path != target.path + || source.kind != target.kind + || source.detail != target.detail + || source.language != target.language + || source + .sources + .first() + .and_then(|anchor| anchor.excerpt.as_deref()) + != target + .sources + .first() + .and_then(|anchor| anchor.excerpt.as_deref()) +} + +pub(super) fn lineage_edge( + source: &crate::commands::structural_graph::types::StructuralGraphNode, + target: &crate::commands::structural_graph::types::StructuralGraphNode, + relation: &str, + trust: GraphTrust, + evidence: String, + candidates: Vec, +) -> HistoryLineageEdge { + HistoryLineageEdge { + id: stable_graph_id( + "lineage", + &format!("{relation}\0{}\0{}", source.id, target.id), + ), + from_entity_id: source.id.clone(), + to_entity_id: target.id.clone(), + relation: relation.to_string(), + trust, + evidence, + sources: source + .sources + .iter() + .chain(target.sources.iter()) + .cloned() + .collect(), + candidates, + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs index 79ba2e2..626d7ca 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -404,7 +404,9 @@ pub struct HistoryAnnotationPage { } mod catalog; +mod delta; mod git_objects; +mod query_helpers; mod storage; pub(crate) use catalog::canonical_repo_path; @@ -414,6 +416,12 @@ use catalog::persistence::*; pub(crate) use catalog::repository_tag_fingerprint; use catalog::*; pub use catalog::{history_list_releases, history_search, load_history_revisions}; +use delta::*; use git_objects::*; +use query_helpers::*; +pub(crate) use query_helpers::{ + history_index_freshness, load_entity_annotation_contradictions, load_entity_occurrences, + load_lineage_family, load_outcome_events, +}; pub(crate) use storage::history_storage_key; use storage::*; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/query_helpers.rs b/apps/desktop/src-tauri/src/commands/history_graph/query_helpers.rs new file mode 100644 index 0000000..874a38f --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/query_helpers.rs @@ -0,0 +1,291 @@ +use super::*; + +pub(super) fn unknown_facet(name: &str, summary: &str) -> HistoryFacet { + HistoryFacet { + name: name.to_string(), + status: HistoryFacetStatus::Unknown, + summary: summary.to_string(), + trust: GraphTrust::Inferred, + sources: Vec::new(), + event_ids: Vec::new(), + } +} + +pub(super) fn git_path_history( + root: &Path, + revision: &str, + path: &str, +) -> Result, String> { + let output = git_text( + root, + &[ + "log", + "--follow", + "--reverse", + "--format=%H%x1f%cI%x1f%s%x1e", + revision, + "--", + path, + ], + )?; + Ok(output + .split('\u{1e}') + .filter_map(|record| { + let fields = record.trim().splitn(3, '\u{1f}').collect::>(); + (fields.len() == 3).then(|| { + ( + fields[0].to_string(), + fields[1].to_string(), + fields[2].to_string(), + ) + }) + }) + .collect()) +} + +pub(crate) fn load_outcome_events( + connection: &Connection, + repo_path: &str, + entity_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT id, event_kind, trust FROM history_graph_events + WHERE repo_path = ?1 AND entity_id = ?2 + AND event_kind IN ('deploy', 'release', 'incident', 'observed_outcome', + 'analytics_provider_ingestion', 'analytics_provider_delivery') + ORDER BY recorded_at DESC, id LIMIT 100", + ) + .map_err(|error| format!("Prepare outcome evidence query: {error}"))?; + let outcomes = statement + .query_map(params![repo_path, entity_id], |row| { + let trust: String = row.get(2)?; + Ok((row.get(0)?, row.get(1)?, GraphTrust::from_storage(&trust))) + }) + .map_err(|error| format!("Query outcome evidence: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read outcome evidence: {error}"))?; + Ok(outcomes) +} + +pub(crate) fn load_entity_annotation_contradictions( + connection: &Connection, + repo_path: &str, + entity_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT decision, body FROM history_graph_annotations + WHERE repo_path = ?1 AND entity_id = ?2 + AND decision IN ('reject', 'correction') + ORDER BY created_at DESC, id LIMIT 20", + ) + .map_err(|error| format!("Prepare entity contradiction query: {error}"))?; + let contradictions = statement + .query_map(params![repo_path, entity_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| format!("Query entity contradictions: {error}"))? + .map(|row| { + row.map(|(decision, body)| { + format!( + "Local {decision} annotation: {}", + body.chars().take(500).collect::() + ) + }) + .map_err(|error| format!("Read entity contradiction: {error}")) + }) + .collect::, _>>()?; + Ok(contradictions) +} + +pub(crate) fn history_index_freshness( + connection: &Connection, + repo_path: &str, + current_head: &str, +) -> Result<(String, bool, Value), String> { + let row = connection + .query_row( + "SELECT indexed_head, indexed_tags_fingerprint, coverage_json + FROM history_graph_repositories + WHERE repo_path = ?1", + params![repo_path], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional() + .map_err(|error| format!("Load history freshness: {error}"))?; + let Some((indexed_head, indexed_tags_fingerprint, coverage_json)) = row else { + return Ok((String::new(), true, serde_json::json!({}))); + }; + let indexed_head = indexed_head.unwrap_or_default(); + let tags_stale = repository_tag_fingerprint(Path::new(repo_path)) + .ok() + .zip(indexed_tags_fingerprint) + .is_some_and(|(current, indexed)| current != indexed); + let stale = indexed_head.is_empty() || indexed_head != current_head || tags_stale; + let coverage = serde_json::from_str(&coverage_json).unwrap_or_else(|_| serde_json::json!({})); + Ok((indexed_head, stale, coverage)) +} + +pub(crate) fn load_lineage_family( + connection: &Connection, + repo_path: &str, + seed_entity_id: &str, + limit: usize, +) -> Result<(Vec, HashSet, bool), String> { + let mut statement = connection + .prepare( + "SELECT payload_json FROM history_graph_events + WHERE repo_path = ?1 AND event_kind = 'entity_lineage' + AND (entity_id = ?2 OR related_entity_id = ?2) + ORDER BY recorded_at, id LIMIT ?3", + ) + .map_err(|error| format!("Prepare lineage query: {error}"))?; + let mut family = HashSet::from([seed_entity_id.to_string()]); + let mut queue = vec![seed_entity_id.to_string()]; + let mut cursor = 0; + let mut edges = BTreeMap::::new(); + let mut truncated = false; + while cursor < queue.len() { + if edges.len() >= limit || family.len() >= limit { + truncated = true; + break; + } + let entity_id = queue[cursor].clone(); + cursor += 1; + let rows = statement + .query_map(params![repo_path, entity_id, (limit + 1) as i64], |row| { + row.get::<_, String>(0) + }) + .map_err(|error| format!("Query entity lineage: {error}"))?; + for payload in rows { + let payload = payload.map_err(|error| format!("Read entity lineage: {error}"))?; + let edge: HistoryLineageEdge = serde_json::from_str(&payload) + .map_err(|error| format!("Decode entity lineage: {error}"))?; + if edges.contains_key(&edge.id) { + continue; + } + if edges.len() >= limit { + truncated = true; + break; + } + let mut related_ids = vec![edge.from_entity_id.clone()]; + if edge.relation != "removed_in" { + related_ids.push(edge.to_entity_id.clone()); + } + related_ids.extend(edge.candidates.iter().cloned()); + for related_id in related_ids { + if family.len() >= limit { + truncated = true; + break; + } + if family.insert(related_id.clone()) { + queue.push(related_id); + } + } + edges.insert(edge.id.clone(), edge); + } + } + Ok((edges.into_values().collect(), family, truncated)) +} + +pub(crate) fn load_entity_occurrences( + connection: &Connection, + repo_path: &str, + entity_ids: &HashSet, + limit: usize, +) -> Result<(Vec, bool), String> { + let mut statement = connection + .prepare( + "SELECT c.revision_sha, r.committed_at, r.ordinal, n.id, n.label, + n.kind, n.path, n.detail + FROM history_graph_checkpoints c + JOIN history_graph_revisions r + ON r.repo_path = c.repo_path AND r.sha = c.revision_sha + JOIN structural_graph_nodes n ON n.snapshot_id = c.snapshot_id + WHERE c.repo_path = ?1 AND c.status = 'ready' AND c.engine_id = ?2 + AND c.engine_version = ?3 AND c.schema_version = ?4 AND n.id = ?5 + ORDER BY r.ordinal, n.id", + ) + .map_err(|error| format!("Prepare entity occurrence query: {error}"))?; + let mut occurrences = BTreeMap::<(i64, String, String), HistoryEntityMoment>::new(); + let mut ids = entity_ids.iter().collect::>(); + ids.sort(); + let mut truncated = false; + for entity_id in ids { + let rows = statement + .query_map( + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + entity_id + ], + |row| { + Ok(HistoryEntityMoment { + revision_sha: row.get(0)?, + committed_at: row.get(1)?, + ordinal: row.get(2)?, + entity_id: row.get(3)?, + label: row.get(4)?, + kind: row.get(5)?, + path: row.get(6)?, + detail: row.get(7)?, + }) + }, + ) + .map_err(|error| format!("Query entity occurrences: {error}"))?; + for moment in rows { + let moment = moment.map_err(|error| format!("Read entity occurrence: {error}"))?; + if occurrences.len() >= limit { + truncated = true; + break; + } + occurrences.insert( + ( + moment.ordinal, + moment.revision_sha.clone(), + moment.entity_id.clone(), + ), + moment, + ); + } + if truncated { + break; + } + } + Ok((occurrences.into_values().collect(), truncated)) +} + +pub(super) fn estimate_eta_ms( + started: std::time::Instant, + completed: usize, + total: usize, +) -> Option { + if completed == 0 || completed >= total { + return None; + } + let per_item = started.elapsed().as_millis() / completed as u128; + Some((per_item * (total - completed) as u128).min(u64::MAX as u128) as u64) +} + +pub(super) fn reachable_release_revisions(root: &Path) -> Result, String> { + let mut releases = tags_by_commit(root)? + .into_iter() + .filter(|(_, tags)| tags.iter().any(|tag| is_release_tag(tag))) + .filter(|(sha, _)| git_is_ancestor(root, sha, "HEAD")) + .map(|(sha, _)| { + let committed_at = git_text(root, &["show", "-s", "--format=%cI", &sha])?; + Ok((committed_at, sha)) + }) + .collect::, String>>()?; + releases.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + Ok(releases.into_iter().map(|(_, sha)| sha).collect()) +} From 530ffd16c35f13c3bfdca3ef99b4f1a7b185603a Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:41:03 +0530 Subject: [PATCH 041/141] feat(history): reconstruct structural state over time --- .../src/commands/history_graph/mod.rs | 10 + .../src/commands/history_graph/state.rs | 531 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/state.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs index 626d7ca..06e6ed9 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -407,6 +407,7 @@ mod catalog; mod delta; mod git_objects; mod query_helpers; +mod state; mod storage; pub(crate) use catalog::canonical_repo_path; @@ -423,5 +424,14 @@ pub(crate) use query_helpers::{ history_index_freshness, load_entity_annotation_contradictions, load_entity_occurrences, load_lineage_family, load_outcome_events, }; +use state::*; +pub use state::{ + get_history_as_of, get_history_entity_evolution, get_history_structural_delta, + get_history_structural_state, +}; +pub(crate) use state::{reconstruct_history_as_of, resolve_temporal_reference}; pub(crate) use storage::history_storage_key; use storage::*; + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/state.rs b/apps/desktop/src-tauri/src/commands/history_graph/state.rs new file mode 100644 index 0000000..34a3fe6 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/state.rs @@ -0,0 +1,531 @@ +use super::*; + +#[tauri::command] +pub async fn get_history_structural_state( + repo_path: String, + revision: String, + max_nodes: Option, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let revision = resolve_revision(&root, &revision)?; + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let reconstructed = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + reconstruct_history_as_of(&connection, &canonical, &storage_key, &revision)? + }; + let (snapshot, cached) = match reconstructed { + Some(snapshot) => (snapshot, true), + None => load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + &revision, + &app, + &database, + )?, + }; + let path_changes = changed_path_records(&root, &revision)?; + let mut revision_changes = path_changes + .iter() + .map(|change| change.path.clone()) + .collect::>(); + revision_changes.sort(); + Ok(HistoryStructuralState { + schema_version: 1, + repo_path: canonical, + revision, + snapshot_id: snapshot.id.clone(), + cached, + projection: query::overview(&snapshot, max_nodes), + analysis: query::analysis_summary(&snapshot), + changed_paths: revision_changes, + path_changes, + indexed_files: snapshot.coverage.indexed_files, + node_count: snapshot.nodes.len(), + edge_count: snapshot.edges.len(), + generated_at: snapshot.created_at, + }) + }) + .await + .map_err(|error| format!("Historical structural state worker failed: {error}"))? +} + +#[tauri::command] +pub async fn get_history_structural_delta( + repo_path: String, + before_revision: String, + after_revision: String, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let before_revision = resolve_revision(&root, &before_revision)?; + let after_revision = resolve_revision(&root, &after_revision)?; + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let cached_delta = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + load_history_structural_delta( + &connection, + &canonical, + &before_revision, + &after_revision, + )? + }; + if let Some(delta) = cached_delta { + return Ok(delta); + } + let (before, _) = load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + &before_revision, + &app, + &database, + )?; + let (after, _) = load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + &after_revision, + &app, + &database, + )?; + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + let delta = compute_and_persist_structural_delta( + &connection, + &root, + &canonical, + &before_revision, + &after_revision, + &before, + &after, + )?; + Ok(delta) + }) + .await + .map_err(|error| format!("Historical structural delta worker failed: {error}"))? +} + +#[tauri::command] +pub async fn get_history_as_of( + repo_path: String, + reference: HistoryTemporalReference, + max_nodes: Option, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let revision = resolve_temporal_reference(&root, &reference)?; + let committed_at = git_text(&root, &["show", "-s", "--format=%cI", &revision])?; + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let reconstructed = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + reconstruct_history_as_of(&connection, &canonical, &storage_key, &revision)? + }; + let (snapshot, cached) = match reconstructed { + Some(snapshot) => (snapshot, true), + None => load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + &revision, + &app, + &database, + )?, + }; + let path_changes = changed_path_records(&root, &revision)?; + let mut changed_paths = path_changes + .iter() + .map(|change| change.path.clone()) + .collect::>(); + changed_paths.sort(); + Ok(HistoryAsOfState { + requested: reference, + resolved_revision: revision.clone(), + committed_at, + exact: true, + state: HistoryStructuralState { + schema_version: 1, + repo_path: canonical, + revision, + snapshot_id: snapshot.id.clone(), + cached, + projection: query::overview(&snapshot, max_nodes), + analysis: query::analysis_summary(&snapshot), + changed_paths, + path_changes, + indexed_files: snapshot.coverage.indexed_files, + node_count: snapshot.nodes.len(), + edge_count: snapshot.edges.len(), + generated_at: snapshot.created_at, + }, + }) + }) + .await + .map_err(|error| format!("Historical as-of worker failed: {error}"))? +} + +#[tauri::command] +pub async fn get_history_entity_evolution( + repo_path: String, + entity: String, + revision: Option, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let canonical = root.to_string_lossy().to_string(); + let revision = resolve_revision(&root, revision.as_deref().unwrap_or("HEAD"))?; + let current_head = git_text(&root, &["rev-parse", "HEAD"])?; + let storage_key = history_storage_key(&canonical); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let (snapshot, _) = load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + &revision, + &app, + &database, + )?; + let node = query::resolve_node(&snapshot, &entity)?.clone(); + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + let (lineage, family_ids, lineage_truncated) = + load_lineage_family(&connection, &canonical, &node.id, 200)?; + let (occurrences, occurrence_truncated) = + load_entity_occurrences(&connection, &canonical, &family_ids, 500)?; + let first_seen = occurrences.first().cloned(); + let last_present = occurrences.last().cloned(); + let mut last_changed = None; + let mut previous_signature: Option<(&str, &str, Option<&str>, Option<&str>)> = None; + for occurrence in &occurrences { + let signature = ( + occurrence.entity_id.as_str(), + occurrence.label.as_str(), + occurrence.path.as_deref(), + occurrence.detail.as_deref(), + ); + if previous_signature != Some(signature) { + last_changed = Some(occurrence.clone()); + } + previous_signature = Some(signature); + } + let (indexed_head, stale, coverage) = + history_index_freshness(&connection, &canonical, ¤t_head)?; + let coverage_complete = coverage + .get("coverage_complete") + .and_then(Value::as_bool) + .unwrap_or(false); + let truncated = lineage_truncated || occurrence_truncated; + let coverage_gap = if truncated { + Some("Entity evolution exceeded local query bounds".to_string()) + } else if !coverage_complete { + Some("First/last moments are bounded by the indexed history coverage".to_string()) + } else { + None + }; + Ok(HistoryEntityEvolution { + schema_version: 1, + repo_path: canonical, + resolved_revision: revision, + entity_id: node.id, + entity_label: node.label, + entity_kind: node.kind, + lineage, + occurrences, + first_seen, + last_changed, + last_present, + indexed_head, + stale, + coverage_gap, + truncated, + next_cursor: None, + }) + }) + .await + .map_err(|error| format!("History entity evolution worker failed: {error}"))? +} + +pub(crate) fn resolve_temporal_reference( + root: &Path, + reference: &HistoryTemporalReference, +) -> Result { + match reference { + HistoryTemporalReference::Revision { revision } => resolve_revision(root, revision), + HistoryTemporalReference::Release { tag } => resolve_revision(root, tag), + HistoryTemporalReference::Date { at } => { + chrono::DateTime::parse_from_rfc3339(at) + .map_err(|error| format!("History date must be RFC3339: {error}"))?; + let revision = git_text(root, &["rev-list", "-1", &format!("--before={at}"), "HEAD"])?; + if revision.is_empty() { + Err(format!("No reachable commit exists at or before {at}")) + } else { + Ok(revision) + } + } + } +} + +pub(crate) fn reconstruct_history_as_of( + connection: &Connection, + repo_path: &str, + storage_key: &str, + target_revision: &str, +) -> Result, String> { + let target_exists = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM history_graph_revisions + WHERE repo_path = ?1 AND sha = ?2)", + params![repo_path, target_revision], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| format!("Resolve indexed as-of revision: {error}"))?; + if !target_exists { + return Ok(None); + } + let mut checkpoint_statement = connection + .prepare( + "SELECT revision_sha, snapshot_id FROM history_graph_checkpoints + WHERE repo_path = ?1 AND status = 'ready' + AND engine_id = ?2 AND engine_version = ?3 AND schema_version = ?4", + ) + .map_err(|error| format!("Prepare compatible history checkpoints: {error}"))?; + let checkpoints = checkpoint_statement + .query_map( + params![ + repo_path, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .map_err(|error| format!("Query compatible history checkpoints: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read compatible history checkpoints: {error}"))?; + + let mut materialization_chain = vec![target_revision.to_string()]; + while !checkpoints.contains_key( + materialization_chain + .last() + .expect("materialization chain has a target"), + ) { + if materialization_chain.len() > MAX_HISTORY_LIMIT + checkpoints.len() + 1 { + return Ok(None); + } + let current = materialization_chain + .last() + .expect("materialization chain has a current revision"); + let parents_json = connection + .query_row( + "SELECT parents_json FROM history_graph_revisions + WHERE repo_path = ?1 AND sha = ?2", + params![repo_path, current], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("Load materialization parent: {error}"))?; + let Some(parents_json) = parents_json else { + return Ok(None); + }; + let parents: Vec = serde_json::from_str(&parents_json).unwrap_or_default(); + let Some(parent) = parents.first() else { + return Ok(None); + }; + let parent_indexed = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM history_graph_revisions + WHERE repo_path = ?1 AND sha = ?2)", + params![repo_path, parent], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| format!("Check materialization parent coverage: {error}"))?; + if !parent_indexed || materialization_chain.contains(parent) { + return Ok(None); + } + materialization_chain.push(parent.clone()); + } + let checkpoint_revision = materialization_chain + .last() + .expect("checkpoint terminates materialization chain") + .clone(); + let Some(snapshot_id) = checkpoints.get(&checkpoint_revision).cloned() else { + return Ok(None); + }; + let snapshot_blob = load_history_snapshot_blob(connection, repo_path, &snapshot_id)?; + let normalized_snapshot = if snapshot_blob.is_none() { + load_snapshot_by_id(connection, storage_key, &snapshot_id) + .map_err(|error| error.to_string())? + } else { + None + }; + let Some(mut snapshot) = snapshot_blob.or(normalized_snapshot) else { + return Ok(None); + }; + materialization_chain.reverse(); + for pair in materialization_chain.windows(2) { + let Some(delta) = load_history_structural_delta(connection, repo_path, &pair[0], &pair[1])? + else { + return Ok(None); + }; + if delta.before_revision != pair[0] + || delta.after_revision != pair[1] + || delta.before_snapshot_id != snapshot.id + { + return Ok(None); + } + let next_blob = + load_history_snapshot_blob(connection, repo_path, &delta.after_snapshot_id)?; + let next_normalized = if next_blob.is_none() { + load_snapshot_by_id(connection, storage_key, &delta.after_snapshot_id) + .map_err(|error| error.to_string())? + } else { + None + }; + if let Some(next_snapshot) = next_blob.or(next_normalized) { + snapshot = next_snapshot; + } else if delta.materialization_version == 1 { + snapshot = apply_structural_delta(snapshot, &delta)?; + } else { + return Ok(None); + } + } + if snapshot.repo_head.as_deref() == Some(target_revision) { + Ok(Some(snapshot)) + } else { + Ok(None) + } +} + +pub(super) fn apply_structural_delta( + mut snapshot: StructuralGraphSnapshot, + delta: &HistoryStructuralDelta, +) -> Result { + if snapshot.id != delta.before_snapshot_id || delta.materialization_version != 1 { + return Err("Structural delta is incompatible with its base checkpoint".to_string()); + } + let removed_nodes = delta.removed_node_ids.iter().collect::>(); + let upsert_nodes = delta + .upsert_nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + snapshot.nodes.retain(|node| { + !removed_nodes.contains(&node.id) && !upsert_nodes.contains(node.id.as_str()) + }); + snapshot.nodes.extend(delta.upsert_nodes.iter().cloned()); + snapshot.nodes.sort_by(|left, right| left.id.cmp(&right.id)); + + let removed_edges = delta.removed_edge_ids.iter().collect::>(); + let upsert_edges = delta + .upsert_edges + .iter() + .map(|edge| edge.id.as_str()) + .collect::>(); + snapshot.edges.retain(|edge| { + !removed_edges.contains(&edge.id) && !upsert_edges.contains(edge.id.as_str()) + }); + snapshot.edges.extend(delta.upsert_edges.iter().cloned()); + snapshot.edges.sort_by(|left, right| left.id.cmp(&right.id)); + + let removed_files = delta + .removed_file_paths + .iter() + .map(String::as_str) + .collect::>(); + let upsert_files = delta + .upsert_files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + snapshot.files.retain(|file| { + !removed_files.contains(file.path.as_str()) && !upsert_files.contains(file.path.as_str()) + }); + snapshot.files.extend(delta.upsert_files.iter().cloned()); + snapshot + .files + .sort_by(|left, right| left.path.cmp(&right.path)); + + let removed_metrics = delta.removed_metric_ids.iter().collect::>(); + let upsert_metrics = delta + .upsert_metrics + .iter() + .map(|metric| metric.id.as_str()) + .collect::>(); + snapshot.metrics.retain(|metric| { + !removed_metrics.contains(&metric.id) && !upsert_metrics.contains(metric.id.as_str()) + }); + snapshot + .metrics + .extend(delta.upsert_metrics.iter().cloned()); + let metric_order = delta + .after_metric_order + .iter() + .enumerate() + .map(|(index, id)| (id.as_str(), index)) + .collect::>(); + snapshot.metrics.sort_by_key(|metric| { + metric_order + .get(metric.id.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + + let removed_clones = delta.removed_clone_group_ids.iter().collect::>(); + let upsert_clones = delta + .upsert_clone_groups + .iter() + .map(|group| group.id.as_str()) + .collect::>(); + snapshot.clone_groups.retain(|group| { + !removed_clones.contains(&group.id) && !upsert_clones.contains(group.id.as_str()) + }); + snapshot + .clone_groups + .extend(delta.upsert_clone_groups.iter().cloned()); + let clone_order = delta + .after_clone_group_order + .iter() + .enumerate() + .map(|(index, id)| (id.as_str(), index)) + .collect::>(); + snapshot.clone_groups.sort_by_key(|group| { + clone_order + .get(group.id.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + + snapshot.id = delta.after_snapshot_id.clone(); + snapshot.repo_head = Some(delta.after_revision.clone()); + snapshot.created_at = delta.after_created_at.clone(); + snapshot.cursor = delta.after_cursor.clone(); + snapshot.ignore_fingerprint = delta.after_ignore_fingerprint.clone(); + snapshot.coverage = delta.after_coverage.clone(); + snapshot.diagnostics = delta.after_diagnostics.clone(); + snapshot.communities = delta.upsert_communities.clone(); + snapshot.truncated = delta.after_truncated; + Ok(snapshot) +} From 4f84ab9d081d04d64823b4e10504d1cb60d115b3 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:41:03 +0530 Subject: [PATCH 042/141] test(history): cover temporal graph reconstruction --- .../src/commands/history_graph/tests.rs | 1442 +++++++++++++++++ 1 file changed, 1442 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/tests.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/tests.rs b/apps/desktop/src-tauri/src/commands/history_graph/tests.rs new file mode 100644 index 0000000..3d3c76f --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/tests.rs @@ -0,0 +1,1442 @@ +use super::*; +use std::fs; + +#[test] +fn timeline_and_topology_are_stable_and_release_aware() { + let root = std::env::temp_dir().join(format!("cv-history-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("src")).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("src/a.rs"), "fn a() {}\n").expect("a"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "feat: first"]); + run_git(&root, &["tag", "v1.0.0"]); + fs::write(root.join("src/b.rs"), "fn b() {}\n").expect("b"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "feat: second"]); + + let timeline = build_timeline(&root, Some(20)).expect("timeline"); + assert_eq!(timeline.revisions.len(), 2); + assert!(timeline.revisions[0].is_release); + assert!(timeline.revisions[1].is_head); + assert!(!timeline.is_shallow); + assert!(timeline.coverage_complete); + assert_eq!(timeline.release_ranges.len(), 2); + assert_eq!(timeline.release_ranges[0].tag.as_deref(), Some("v1.0.0")); + assert!(timeline.release_ranges[1].is_unreleased); + assert_eq!(timeline.release_ranges[1].commit_shas.len(), 1); + assert_eq!( + resolve_temporal_reference( + &root, + &HistoryTemporalReference::Release { + tag: "v1.0.0".to_string(), + }, + ) + .expect("release reference"), + timeline.revisions[0].sha + ); + let topology = build_topology(&root, &timeline.head, Some(40)).expect("topology"); + let first_topology = + build_topology(&root, &timeline.revisions[0].sha, Some(40)).expect("first topology"); + assert_eq!(topology.total_files, 2); + assert!(topology.nodes.iter().any(|node| node.path == "src/b.rs")); + let first_a = first_topology + .nodes + .iter() + .find(|node| node.path == "src/a.rs") + .expect("first a"); + let current_a = topology + .nodes + .iter() + .find(|node| node.path == "src/a.rs") + .expect("current a"); + assert_eq!(first_a.id, current_a.id, "persistent paths keep stable IDs"); + fs::write(root.join("src/a.rs"), "fn worktree_only() {}\n").expect("dirty worktree"); + let blobs = GitObjectReader::new(&root) + .blobs_at(&timeline.revisions[0].sha) + .expect("historical blobs"); + assert_eq!(blobs.len(), 1); + assert_eq!(blobs[0].path, "src/a.rs"); + assert!(String::from_utf8_lossy(&blobs[0].bytes).contains("fn a")); + assert!(!String::from_utf8_lossy(&blobs[0].bytes).contains("worktree_only")); + let historical_snapshot = build_snapshot_from_blobs( + &history_storage_key(&timeline.repo_path), + &timeline.revisions[0].sha, + blobs, + &StructuralGraphCancellation::default(), + &|_: StructuralGraphProgress| {}, + ) + .expect("historical structural snapshot"); + assert!(historical_snapshot + .nodes + .iter() + .any(|node| node.label == "a")); + assert!(!historical_snapshot + .nodes + .iter() + .any(|node| node.label == "worktree_only" || node.label == "b")); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + persist_timeline(&connection, &timeline).expect("persist timeline"); + persist_changed_paths(&connection, &topology).expect("persist changed paths"); + let revision_count: i64 = connection + .query_row("SELECT COUNT(*) FROM history_graph_revisions", [], |row| { + row.get(0) + }) + .expect("revision count"); + assert_eq!(revision_count, 2); + let event_count: i64 = connection + .query_row("SELECT COUNT(*) FROM history_graph_events", [], |row| { + row.get(0) + }) + .expect("history event count"); + assert_eq!( + event_count, 4, + "commits, release, and coverage are ledger events" + ); + let releases = load_history_revisions(&connection, &timeline.repo_path, None, true, 10) + .expect("release query"); + assert_eq!(releases.revisions.len(), 1); + let search = + load_history_revisions(&connection, &timeline.repo_path, Some("second"), false, 10) + .expect("history search"); + assert_eq!(search.revisions[0].subject, "feat: second"); + let changed_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM history_graph_revision_paths", + [], + |row| row.get(0), + ) + .expect("changed path count"); + assert!(changed_count >= 1); + run_git(&root, &["tag", "v1.1.0"]); + let retagged = build_timeline(&root, Some(20)).expect("retagged timeline"); + persist_timeline(&connection, &retagged).expect("persist retagged timeline"); + let invalidations: i64 = connection + .query_row( + "SELECT COUNT(*) FROM history_graph_events WHERE event_kind = 'invalidation'", + [], + |row| row.get(0), + ) + .expect("invalidation count"); + assert_eq!(invalidations, 1); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn invalid_revision_is_rejected_before_git_option_parsing() { + let root = std::env::temp_dir(); + assert_eq!( + resolve_revision(&root, "--upload-pack=bad").unwrap_err(), + "A valid Git revision is required" + ); +} + +#[test] +fn historical_file_bounds_remain_explicit_in_snapshot_coverage() { + let mut snapshot = build_snapshot_from_blobs( + "history:test", + "revision", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn indexed() {}\n".to_vec(), + }], + &StructuralGraphCancellation::default(), + &|_: StructuralGraphProgress| {}, + ) + .expect("snapshot"); + apply_historical_file_coverage(&mut snapshot, 25_001, true); + assert!(snapshot.truncated); + assert_eq!(snapshot.coverage.discovered_files, 25_001); + assert!(snapshot.coverage.skipped_files >= 25_000); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "historical_file_limit")); +} + +#[test] +fn repository_without_tags_has_one_explicit_unreleased_range() { + let root = std::env::temp_dir().join(format!("cv-history-no-tags-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("main.rs"), "fn main() {}\n").expect("main"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "initial"]); + let timeline = build_timeline(&root, Some(20)).expect("timeline"); + assert_eq!(timeline.release_ranges.len(), 1); + assert!(timeline.release_ranges[0].is_unreleased); + assert_eq!( + timeline.release_ranges[0].commit_shas, + vec![timeline.head.clone()] + ); + assert_eq!( + resolve_temporal_reference( + &root, + &HistoryTemporalReference::Date { + at: timeline.revisions[0].committed_at.clone(), + }, + ) + .expect("date reference"), + timeline.head + ); + assert!(resolve_temporal_reference( + &root, + &HistoryTemporalReference::Date { + at: "not-a-date".to_string(), + }, + ) + .unwrap_err() + .contains("RFC3339")); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn divergent_release_tags_join_only_after_their_branch_is_merged() { + let root = std::env::temp_dir().join(format!("cv-history-divergent-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("base.rs"), "fn base() {}\n").expect("base"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "base"]); + run_git(&root, &["tag", "v1.0.0"]); + let main_branch = git_text(&root, &["branch", "--show-current"]).expect("branch"); + + run_git(&root, &["checkout", "-b", "release-side"]); + fs::write(root.join("side.rs"), "fn side() {}\n").expect("side"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "side release"]); + run_git(&root, &["tag", "v2.0.0-side"]); + let side_sha = git_text(&root, &["rev-parse", "HEAD"]).expect("side sha"); + + run_git(&root, &["checkout", &main_branch]); + fs::write(root.join("main.rs"), "fn main_line() {}\n").expect("main"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "main work"]); + let before_merge = reachable_release_revisions(&root).expect("before merge releases"); + assert!(!before_merge.contains(&side_sha)); + + run_git( + &root, + &[ + "merge", + "--no-ff", + "release-side", + "-m", + "merge release side", + ], + ); + let after_merge = reachable_release_revisions(&root).expect("after merge releases"); + assert!(after_merge.contains(&side_sha)); + let timeline = build_timeline(&root, Some(20)).expect("merged timeline"); + assert_eq!(timeline.revisions.last().expect("head").parents.len(), 2); + assert!(timeline + .release_ranges + .iter() + .any(|range| range.tag.as_deref() == Some("v2.0.0-side"))); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn merge_reconstruction_follows_the_recorded_first_parent_chain() { + let root = std::env::temp_dir().join(format!("cv-history-merge-dag-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("base.rs"), "fn base() {}\n").expect("base"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "base"]); + let main_branch = git_text(&root, &["branch", "--show-current"]).expect("main branch"); + run_git(&root, &["checkout", "-b", "feature"]); + fs::write(root.join("feature.rs"), "fn feature() {}\n").expect("feature"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "feature"]); + run_git(&root, &["checkout", &main_branch]); + fs::write(root.join("main.rs"), "fn main_line() {}\n").expect("main line"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "main line"]); + run_git( + &root, + &["merge", "--no-ff", "feature", "-m", "merge feature"], + ); + + let timeline = build_timeline(&root, Some(20)).expect("timeline"); + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let cancellation = StructuralGraphCancellation::default(); + let mut snapshots = HashMap::new(); + for revision in &timeline.revisions { + let mut snapshot = build_snapshot_from_blobs( + &storage_key, + &revision.sha, + GitObjectReader::new(&root) + .blobs_at(&revision.sha) + .expect("revision blobs"), + &cancellation, + &|_: StructuralGraphProgress| {}, + ) + .expect("revision snapshot"); + compact_history_snapshot(&mut snapshot); + snapshots.insert(revision.sha.clone(), snapshot); + } + + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + persist_timeline(&connection, &timeline).expect("persist timeline"); + let root_revision = timeline + .revisions + .iter() + .find(|revision| revision.parents.is_empty()) + .expect("root revision"); + let root_snapshot = snapshots.get(&root_revision.sha).expect("root snapshot"); + persist_history_snapshot_blob(&connection, &canonical, &root_revision.sha, root_snapshot) + .expect("persist root snapshot"); + connection + .execute( + "INSERT INTO history_graph_checkpoints ( + repo_path, revision_sha, snapshot_id, engine_id, engine_version, + schema_version, status, coverage_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'ready', '{}', ?7)", + params![ + canonical, + root_revision.sha, + root_snapshot.id, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + timeline.generated_at, + ], + ) + .expect("root checkpoint"); + for revision in timeline + .revisions + .iter() + .filter(|revision| !revision.parents.is_empty()) + { + let parent = revision.parents.first().expect("first parent"); + compute_and_persist_structural_delta( + &connection, + &root, + &canonical, + parent, + &revision.sha, + snapshots.get(parent).expect("parent snapshot"), + snapshots.get(&revision.sha).expect("child snapshot"), + ) + .expect("parent-aware delta"); + } + + let reconstructed = + reconstruct_history_as_of(&connection, &canonical, &storage_key, &timeline.head) + .expect("reconstruct merge") + .expect("complete first-parent chain"); + let expected = snapshots.get(&timeline.head).expect("head snapshot"); + let mut reconstructed_files = reconstructed + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + let mut expected_files = expected + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + reconstructed_files.sort(); + expected_files.sort(); + assert_eq!(reconstructed_files, expected_files); + let mut reconstructed_nodes = reconstructed.nodes.clone(); + let mut expected_nodes = expected.nodes.clone(); + reconstructed_nodes.sort_by(|left, right| left.id.cmp(&right.id)); + expected_nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let mut reconstructed_edges = reconstructed.edges.clone(); + let mut expected_edges = expected.edges.clone(); + reconstructed_edges.sort_by(|left, right| left.id.cmp(&right.id)); + expected_edges.sort_by(|left, right| left.id.cmp(&right.id)); + assert_eq!(reconstructed_nodes, expected_nodes); + assert_eq!(reconstructed_edges, expected_edges); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn rolling_timeline_windows_keep_global_ordinals_and_old_releases() { + let root = std::env::temp_dir().join(format!("cv-history-window-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + for index in 0..6 { + fs::write( + root.join("history.rs"), + format!("fn version_{index}() {{}}\n"), + ) + .expect("history"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", &format!("commit {index}")]); + if index == 0 { + run_git(&root, &["tag", "v1.0.0"]); + } + } + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + let first = build_timeline(&root, Some(3)).expect("first window"); + persist_timeline(&connection, &first).expect("persist first window"); + for index in 6..8 { + fs::write( + root.join("history.rs"), + format!("fn version_{index}() {{}}\n"), + ) + .expect("history"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", &format!("commit {index}")]); + } + let second = build_timeline(&root, Some(3)).expect("second window"); + persist_timeline(&connection, &second).expect("persist second window"); + + let global_ordinals = revision_ordinals(&root).expect("global ordinals"); + let mut statement = connection + .prepare("SELECT sha, ordinal FROM history_graph_revisions WHERE repo_path = ?1") + .expect("ordinal query"); + let rows = statement + .query_map([second.repo_path.as_str()], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .expect("ordinal rows") + .collect::, _>>() + .expect("read ordinals"); + assert!(rows.iter().all(|(sha, ordinal)| { + global_ordinals.get(sha).copied() == Some(*ordinal) && *ordinal >= 0 + })); + let releases = load_history_revisions(&connection, &second.repo_path, None, true, 10) + .expect("release query"); + assert_eq!(releases.revisions.len(), 1); + assert_eq!(releases.revisions[0].tags, vec!["v1.0.0"]); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn catalog_staging_does_not_publish_freshness_before_backfill_success() { + let root = std::env::temp_dir().join(format!("cv-history-publish-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("history.rs"), "fn first() {}\n").expect("first"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "first"]); + let first = build_timeline(&root, Some(20)).expect("first timeline"); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + persist_timeline(&connection, &first).expect("publish first timeline"); + + fs::write(root.join("history.rs"), "fn second() {}\n").expect("second"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "second"]); + let second = build_timeline(&root, Some(20)).expect("second timeline"); + persist_timeline_catalog(&connection, &second).expect("stage second catalog"); + let (indexed_head, status): (Option, String) = connection + .query_row( + "SELECT indexed_head, status FROM history_graph_repositories WHERE repo_path = ?1", + [second.repo_path.as_str()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("published freshness"); + assert_eq!(indexed_head.as_deref(), Some(first.head.as_str())); + assert_eq!(status, "ready"); + assert_ne!(indexed_head.as_deref(), Some(second.head.as_str())); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn shallow_history_reports_partial_coverage() { + let origin = std::env::temp_dir().join(format!("cv-history-origin-{}", uuid::Uuid::new_v4())); + let shallow = std::env::temp_dir().join(format!("cv-history-shallow-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&origin).expect("origin"); + run_git(&origin, &["init"]); + run_git(&origin, &["config", "user.email", "fixture@local"]); + run_git(&origin, &["config", "user.name", "Fixture"]); + for index in 0..3 { + fs::write(origin.join("history.txt"), format!("{index}\n")).expect("history"); + run_git(&origin, &["add", "."]); + run_git(&origin, &["commit", "-m", &format!("commit {index}")]); + } + let source = format!("file://{}", origin.display()); + let status = Command::new("git") + .args(["clone", "--depth", "1", &source]) + .arg(&shallow) + .status() + .expect("clone"); + assert!(status.success()); + + let timeline = build_timeline(&shallow, Some(20)).expect("shallow timeline"); + assert!(timeline.is_shallow); + assert!(!timeline.coverage_complete); + assert_eq!(timeline.revisions.len(), 1); + fs::remove_dir_all(origin).expect("remove origin"); + fs::remove_dir_all(shallow).expect("remove shallow"); +} + +#[test] +fn path_history_preserves_rename_copy_and_delete_leads() { + let root = std::env::temp_dir().join(format!("cv-history-paths-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("src")).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("src/old.rs"), "fn carried() {}\n").expect("old"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "add old"]); + run_git(&root, &["mv", "src/old.rs", "src/new.rs"]); + run_git(&root, &["commit", "-m", "rename old"]); + let rename_head = git_text(&root, &["rev-parse", "HEAD"]).expect("rename head"); + let rename = changed_path_records(&root, &rename_head).expect("rename changes"); + assert!(rename.iter().any(|change| { + change.change_kind == "renamed" + && change.old_path.as_deref() == Some("src/old.rs") + && change.path == "src/new.rs" + })); + + fs::copy(root.join("src/new.rs"), root.join("src/copy.rs")).expect("copy"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "copy new"]); + let copy_head = git_text(&root, &["rev-parse", "HEAD"]).expect("copy head"); + let copy = changed_path_records(&root, ©_head).expect("copy changes"); + assert!(copy.iter().any(|change| { + change.change_kind == "copied" + && change.old_path.as_deref() == Some("src/new.rs") + && change.path == "src/copy.rs" + })); + + fs::remove_file(root.join("src/copy.rs")).expect("delete"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "delete copy"]); + let delete_head = git_text(&root, &["rev-parse", "HEAD"]).expect("delete head"); + assert!(changed_path_records(&root, &delete_head) + .expect("delete changes") + .iter() + .any(|change| change.change_kind == "deleted" && change.path == "src/copy.rs")); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn structural_lineage_tracks_renames_and_preserves_split_ambiguity() { + let cancellation = StructuralGraphCancellation::default(); + let progress = |_: StructuralGraphProgress| {}; + let before = build_snapshot_from_blobs( + "history:test", + "before", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn old_name() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("before"); + let renamed = build_snapshot_from_blobs( + "history:test", + "renamed", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn new_name() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("renamed"); + let rename_lineage = derive_lineage(&before, &renamed, &[], "renamed"); + assert!(rename_lineage.iter().any(|edge| { + edge.relation == "renamed_to" + && edge.trust == GraphTrust::Inferred + && renamed + .nodes + .iter() + .any(|node| node.id == edge.to_entity_id && node.label == "new_name") + })); + + let split = build_snapshot_from_blobs( + "history:test", + "split", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn first() {} fn second() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("split"); + let split_lineage = derive_lineage(&before, &split, &[], "split"); + assert!(split_lineage.iter().any(|edge| { + edge.relation == "split_into" + && edge.trust == GraphTrust::Ambiguous + && !edge.candidates.is_empty() + })); + + let merge_before = build_snapshot_from_blobs( + "history:test", + "merge-before", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn first() {} fn second() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("merge before"); + let merge_after = build_snapshot_from_blobs( + "history:test", + "merge-after", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn combined() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("merge after"); + assert!(derive_lineage(&merge_before, &merge_after, &[], "merged") + .iter() + .any(|edge| { + edge.relation == "merged_from" + && edge.trust == GraphTrust::Ambiguous + && !edge.candidates.is_empty() + })); + + let stable_before = build_snapshot_from_blobs( + "history:test", + "stable-before", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn stable(value: i32) {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("stable before"); + let stable_after = build_snapshot_from_blobs( + "history:test", + "stable-after", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn stable(value: i64) {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("stable after"); + assert!(derive_lineage(&stable_before, &stable_after, &[], "stable") + .iter() + .any(|edge| edge.relation == "same_as")); + + let cross_language_before = build_snapshot_from_blobs( + "history:test", + "cross-language-before", + vec![HistoricalFileBlob { + path: "src/handler.rs".to_string(), + bytes: b"fn carried() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("cross-language before"); + let cross_language_after = build_snapshot_from_blobs( + "history:test", + "cross-language-after", + vec![HistoricalFileBlob { + path: "src/handler.ts".to_string(), + bytes: b"function carried() {}\n".to_vec(), + }], + &cancellation, + &progress, + ) + .expect("cross-language after"); + let cross_language = derive_lineage( + &cross_language_before, + &cross_language_after, + &[HistoryPathChange { + path: "src/handler.ts".to_string(), + change_kind: "renamed".to_string(), + old_path: Some("src/handler.rs".to_string()), + additions: None, + deletions: None, + }], + "cross-language-after", + ); + assert!(cross_language.iter().any(|edge| { + edge.relation == "moved_to" + && edge.trust == GraphTrust::Extracted + && cross_language_after.nodes.iter().any(|node| { + node.id == edge.to_entity_id + && node.label == "carried" + && node.language.as_deref() == Some("typescript") + }) + })); +} + +#[test] +fn outcome_evidence_requires_an_explicit_local_observation() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, status, created_at, updated_at + ) VALUES ('/fixture', 'fixture', 'ready', '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z')", + [], + ) + .expect("repository"); + + assert!(load_outcome_events(&connection, "/fixture", "event:signup") + .expect("empty outcomes") + .is_empty()); + + connection + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, event_kind, entity_id, trust, origin, source_id, + payload_json, evidence_json, recorded_at + ) VALUES + ('code-change', '/fixture', 'structural_delta', 'event:signup', + 'extracted', 'syntax', 'git', '{}', '[]', '2026-01-01T00:00:00Z'), + ('provider-delivery', '/fixture', 'analytics_provider_delivery', + 'event:signup', 'extracted', 'metadata', 'provider-export', '{}', '[]', + '2026-01-02T00:00:00Z')", + [], + ) + .expect("events"); + + let outcomes = load_outcome_events(&connection, "/fixture", "event:signup").expect("outcomes"); + assert_eq!(outcomes.len(), 1, "code presence is not provider delivery"); + assert_eq!(outcomes[0].0, "provider-delivery"); + assert_eq!(outcomes[0].1, "analytics_provider_delivery"); + assert_eq!(outcomes[0].2, GraphTrust::Extracted); + + connection + .execute( + "INSERT INTO history_graph_annotations ( + id, repo_path, entity_id, author, body, decision, source, created_at + ) VALUES ('reject-1', '/fixture', 'event:signup', 'owner', + 'Provider export belongs to another environment', 'reject', 'user', + '2026-01-03T00:00:00Z')", + [], + ) + .expect("annotation"); + let contradictions = + load_entity_annotation_contradictions(&connection, "/fixture", "event:signup") + .expect("contradictions"); + assert_eq!(contradictions.len(), 1); + assert!(contradictions[0].contains("another environment")); +} + +#[test] +fn lineage_queries_preserve_candidates_and_report_repository_freshness() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, coverage_json, + created_at, updated_at + ) VALUES ('/fixture', 'fixture', 'head-2', 'ready', + '{\"coverage_complete\":true}', '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z')", + [], + ) + .expect("repository"); + let edge = HistoryLineageEdge { + id: "lineage-1".to_string(), + from_entity_id: "old".to_string(), + to_entity_id: "new-a".to_string(), + relation: "split_into".to_string(), + trust: GraphTrust::Ambiguous, + evidence: "two compatible successors".to_string(), + sources: Vec::new(), + candidates: vec!["new-b".to_string()], + }; + connection + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, event_kind, entity_id, related_entity_id, relation_kind, + trust, origin, source_id, payload_json, evidence_json, recorded_at + ) VALUES (?1, '/fixture', 'entity_lineage', ?2, ?3, ?4, + 'ambiguous', 'analysis', 'fixture', ?5, '[]', '2026-01-01T00:00:00Z')", + params![ + edge.id, + edge.from_entity_id, + edge.to_entity_id, + edge.relation, + serde_json::to_string(&edge).expect("lineage json") + ], + ) + .expect("lineage event"); + + let (lineage, family, truncated) = + load_lineage_family(&connection, "/fixture", "old", 20).expect("lineage family"); + assert!(!truncated); + assert_eq!(lineage, vec![edge]); + assert!(family.contains("old")); + assert!(family.contains("new-a")); + assert!(family.contains("new-b")); + + let (indexed_head, stale, coverage) = + history_index_freshness(&connection, "/fixture", "head-2").expect("freshness"); + assert_eq!(indexed_head, "head-2"); + assert!(!stale); + assert_eq!(coverage["coverage_complete"], true); + assert!( + history_index_freshness(&connection, "/fixture", "head-3") + .expect("stale freshness") + .1 + ); + connection + .execute( + "UPDATE history_graph_repositories + SET status = 'partial', + coverage_json = '{\"coverage_complete\":false,\"cancelled\":true,\"adapter_coverage\":\"partial\"}' + WHERE repo_path = '/fixture'", + [], + ) + .expect("partial coverage"); + let (_, stale, partial) = + history_index_freshness(&connection, "/fixture", "head-2").expect("partial query"); + assert!( + !stale, + "partial adapter coverage is separate from Git freshness" + ); + assert_eq!(partial["coverage_complete"], false); + assert_eq!(partial["cancelled"], true); + assert_eq!(partial["adapter_coverage"], "partial"); +} + +#[test] +fn prior_removal_produces_an_explicit_reintroduction_edge() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, status, created_at, updated_at + ) VALUES ('/fixture', 'fixture', 'ready', '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z')", + [], + ) + .expect("repository"); + let cancellation = StructuralGraphCancellation::default(); + let snapshot = build_snapshot_from_blobs( + "history:test", + "returned", + vec![HistoricalFileBlob { + path: "src/lib.rs".to_string(), + bytes: b"fn returned() {}\n".to_vec(), + }], + &cancellation, + &|_: StructuralGraphProgress| {}, + ) + .expect("snapshot"); + let node = snapshot + .nodes + .iter() + .find(|node| node.label == "returned") + .expect("returned node"); + let removal = HistoryLineageEdge { + id: "removed-1".to_string(), + from_entity_id: node.id.clone(), + to_entity_id: "old-revision".to_string(), + relation: "removed_in".to_string(), + trust: GraphTrust::Extracted, + evidence: "absent".to_string(), + sources: Vec::new(), + candidates: Vec::new(), + }; + connection + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, event_kind, entity_id, related_entity_id, relation_kind, + trust, origin, source_id, payload_json, evidence_json, recorded_at + ) VALUES (?1, '/fixture', 'entity_lineage', ?2, ?3, 'removed_in', + 'extracted', 'analysis', 'fixture', ?4, '[]', + '2026-01-01T00:00:00Z')", + params![ + removal.id, + removal.from_entity_id, + removal.to_entity_id, + serde_json::to_string(&removal).expect("removal json") + ], + ) + .expect("removal event"); + let reintroduced = derive_reintroductions( + &connection, + "/fixture", + &snapshot, + std::slice::from_ref(&node.id), + "new-revision", + ) + .expect("reintroduction"); + assert_eq!(reintroduced.len(), 1); + assert_eq!(reintroduced[0].relation, "reintroduced_in"); + assert_eq!(reintroduced[0].trust, GraphTrust::Extracted); +} + +#[test] +fn refresh_classification_prioritizes_rewrites_and_engine_repairs() { + assert_eq!( + classify_history_refresh(None, false, false, false, false), + "initial" + ); + assert_eq!( + classify_history_refresh(Some("old"), true, true, false, true), + "rewritten_history" + ); + assert_eq!( + classify_history_refresh(Some("head"), false, true, false, true), + "engine_repair" + ); + assert_eq!( + classify_history_refresh(Some("old"), false, false, true, true), + "fast_forward" + ); + assert_eq!( + classify_history_refresh(Some("head"), false, false, false, true), + "tag_metadata" + ); + assert_eq!( + classify_history_refresh(Some("head"), false, false, false, false), + "no_op" + ); +} + +#[test] +fn exact_as_of_reconstructs_from_nearest_checkpoint_and_ordered_deltas() { + let root = std::env::temp_dir().join(format!("cv-as-of-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("src")).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("src/lib.rs"), "fn first() {}\n").expect("first"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "feat: first"]); + fs::write(root.join("src/lib.rs"), "fn first() {}\nfn second() {}\n").expect("second"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "feat: second"]); + let timeline = build_timeline(&root, Some(20)).expect("timeline"); + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let cancellation = StructuralGraphCancellation::default(); + let build = |revision: &str| { + let mut snapshot = build_snapshot_from_blobs( + &storage_key, + revision, + GitObjectReader::new(&root) + .blobs_at(revision) + .expect("historical blobs"), + &cancellation, + &|_: StructuralGraphProgress| {}, + ) + .expect("snapshot"); + compact_history_snapshot(&mut snapshot); + snapshot + }; + let before = build(&timeline.revisions[0].sha); + let after = build(&timeline.revisions[1].sha); + let path_changes = + changed_path_records(&root, &timeline.revisions[1].sha).expect("path changes"); + let changed_paths = path_changes + .iter() + .filter(|change| change.change_kind != "deleted") + .map(|change| change.path.clone()) + .collect::>(); + let mut incremental_after = build_snapshot_from_blob_delta( + &storage_key, + &timeline.revisions[1].sha, + &before, + GitObjectReader::new(&root) + .blobs_for_paths(&timeline.revisions[1].sha, &changed_paths) + .expect("changed blobs"), + &[], + &cancellation, + &|_: StructuralGraphProgress| {}, + ) + .expect("incremental snapshot"); + compact_history_snapshot(&mut incremental_after); + let normalize = |snapshot: &mut StructuralGraphSnapshot| { + snapshot.nodes.sort_by(|left, right| left.id.cmp(&right.id)); + snapshot.edges.sort_by(|left, right| left.id.cmp(&right.id)); + }; + let mut expected_after = after.clone(); + incremental_after.created_at = expected_after.created_at.clone(); + normalize(&mut incremental_after); + normalize(&mut expected_after); + assert_eq!( + incremental_after, expected_after, + "path-scoped historical extraction must equal a full revision build" + ); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + persist_timeline(&connection, &timeline).expect("timeline persistence"); + persist_history_snapshot_blob(&connection, &canonical, &timeline.revisions[0].sha, &before) + .expect("compressed before snapshot"); + connection + .execute( + "INSERT INTO history_graph_checkpoints ( + repo_path, revision_sha, snapshot_id, engine_id, engine_version, + schema_version, status, coverage_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'ready', '{}', ?7)", + params![ + canonical, + timeline.revisions[0].sha, + before.id, + BUNDLED_ENGINE_ID, + BUNDLED_ENGINE_VERSION, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + timeline.generated_at, + ], + ) + .expect("checkpoint"); + connection + .execute( + "INSERT INTO history_graph_checkpoints ( + repo_path, revision_sha, snapshot_id, engine_id, engine_version, + schema_version, status, coverage_json, created_at + ) VALUES (?1, ?2, ?3, 'obsolete-engine', '0', 1, 'ready', '{}', ?4)", + params![ + canonical, + timeline.revisions[1].sha, + after.id, + timeline.generated_at, + ], + ) + .expect("incompatible checkpoint"); + let delta = compute_and_persist_structural_delta( + &connection, + &root, + &canonical, + &timeline.revisions[0].sha, + &timeline.revisions[1].sha, + &before, + &after, + ) + .expect("delta"); + assert!(!delta.added_node_ids.is_empty()); + assert!(delta + .path_changes + .iter() + .any(|change| change.path == "src/lib.rs")); + + let mut reconstructed = reconstruct_history_as_of( + &connection, + &canonical, + &storage_key, + &timeline.revisions[1].sha, + ) + .expect("as-of reconstruction") + .expect("complete delta chain"); + let mut expected = after.clone(); + normalize(&mut reconstructed); + normalize(&mut expected); + assert_eq!( + reconstructed, expected, + "delta application must preserve exact graph content" + ); + assert_eq!( + reconstructed.repo_head.as_deref(), + Some(timeline.revisions[1].sha.as_str()) + ); + assert!(reconstructed + .nodes + .iter() + .any(|node| node.label == "second")); + connection + .execute( + "DELETE FROM history_graph_events WHERE event_kind = 'structural_delta'", + [], + ) + .expect("remove delta"); + assert!(reconstruct_history_as_of( + &connection, + &canonical, + &storage_key, + &timeline.revisions[1].sha, + ) + .expect("bounded missing chain") + .is_none()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rewritten_history_repair_preserves_imports_annotations_and_adapter_cursors() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute_batch( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, + created_at, updated_at + ) VALUES ('/fixture', 'fixture', 'old-head', 'ready', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'); + INSERT INTO history_graph_revisions ( + repo_path, sha, ordinal, committed_at, author_name, subject, + parents_json, tags_json + ) VALUES ('/fixture', 'old-head', 0, '2026-01-01T00:00:00Z', + 'Fixture', 'old commit', '[]', '[]'); + INSERT INTO structural_graph_snapshots ( + id, repo_path, repo_head, schema_version, engine_id, engine_version, + engine_json, coverage_json, created_at + ) VALUES ('old-snapshot', 'history:fixture', 'old-head', 1, + 'old-engine', '0', '{}', '{}', '2026-01-01T00:00:00Z'); + INSERT INTO history_graph_checkpoints ( + repo_path, revision_sha, snapshot_id, engine_id, engine_version, + schema_version, created_at + ) VALUES ('/fixture', 'old-head', 'old-snapshot', 'old-engine', '0', 1, + '2026-01-01T00:00:00Z'); + INSERT INTO history_graph_events ( + id, repo_path, revision_sha, event_kind, trust, origin, source_id, + source_cursor, payload_json, evidence_json, recorded_at + ) VALUES + ('derived', '/fixture', 'old-head', 'structural_delta', 'extracted', + 'analysis', 'codevetter-structural-history', 'old-head', '{}', '[]', + '2026-01-01T00:00:00Z'), + ('imported', '/fixture', NULL, 'analytics_provider_delivery', 'extracted', + 'metadata', 'provider-export', 'provider:42', '{}', '[]', + '2026-01-02T00:00:00Z'); + INSERT INTO history_graph_annotations ( + id, repo_path, author, body, decision, source, created_at + ) VALUES ('annotation', '/fixture', 'owner', 'keep this correction', + 'correct', 'user', '2026-01-03T00:00:00Z');", + ) + .expect("fixture data"); + + let invalidated = + repair_derived_history(&connection, "/fixture", true, true, "2026-01-04T00:00:00Z") + .expect("repair"); + assert!(invalidated >= 4); + for table in [ + "history_graph_checkpoints", + "history_graph_revisions", + "structural_graph_snapshots", + ] { + let count: i64 = connection + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("derived count"); + assert_eq!(count, 0, "{table} should be invalidated"); + } + let imported: i64 = connection + .query_row( + "SELECT COUNT(*) FROM history_graph_events WHERE id = 'imported'", + [], + |row| row.get(0), + ) + .expect("imported evidence"); + assert_eq!(imported, 1); + let annotations: i64 = connection + .query_row( + "SELECT COUNT(*) FROM history_graph_annotations", + [], + |row| row.get(0), + ) + .expect("annotations"); + assert_eq!(annotations, 1); + persist_history_adapter_cursors(&connection, "/fixture", "new-head").expect("adapter cursors"); + let cursor_json: String = connection + .query_row( + "SELECT cursor_json FROM history_graph_repositories WHERE repo_path = '/fixture'", + [], + |row| row.get(0), + ) + .expect("cursor json"); + let cursor: Value = serde_json::from_str(&cursor_json).expect("cursor payload"); + assert_eq!(cursor["head"], "new-head"); + assert_eq!(cursor["adapters"]["provider-export"], "provider:42"); +} + +#[test] +#[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] +fn bench_history_backfill_incremental_and_as_of_real_repo() { + let process_usage = || { + let mut usage = std::mem::MaybeUninit::::uninit(); + let status = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + assert_eq!(status, 0, "getrusage"); + unsafe { usage.assume_init() } + }; + let timeval_seconds = + |value: libc::timeval| value.tv_sec as f64 + value.tv_usec as f64 / 1_000_000.0; + let usage_before = process_usage(); + let root = std::env::var("CV_GRAPH_BENCH_REPO") + .map(PathBuf::from) + .unwrap_or_else(|_| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize() + .expect("repo root") + }); + let limit = std::env::var("CV_HISTORY_BENCH_COMMITS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(24) + .clamp(4, 100); + let total_started = std::time::Instant::now(); + let timeline = build_timeline(&root, Some(limit)).expect("timeline"); + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let db_path = std::env::temp_dir().join(format!( + "codevetter-temporal-bench-{}.sqlite", + uuid::Uuid::new_v4() + )); + let connection = Connection::open(&db_path).expect("benchmark database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + persist_timeline(&connection, &timeline).expect("timeline persistence"); + let cancellation = StructuralGraphCancellation::default(); + let mut build_samples = Vec::with_capacity(timeline.revisions.len()); + let build_snapshot = |revision: &HistoryRevision| { + let started = std::time::Instant::now(); + let mut snapshot = build_snapshot_from_blobs( + &storage_key, + &revision.sha, + GitObjectReader::new(&root) + .blobs_at(&revision.sha) + .expect("historical blobs"), + &cancellation, + &|_: StructuralGraphProgress| {}, + ) + .expect("historical snapshot"); + compact_history_snapshot(&mut snapshot); + (snapshot, started.elapsed().as_secs_f64() * 1000.0) + }; + let persist_benchmark_checkpoint = + |revision: &HistoryRevision, snapshot: &StructuralGraphSnapshot| { + persist_history_snapshot_blob(&connection, &canonical, &revision.sha, snapshot) + .expect("compressed snapshot persistence"); + connection + .execute( + "INSERT INTO history_graph_checkpoints ( + repo_path, revision_sha, snapshot_id, engine_id, engine_version, + schema_version, status, coverage_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'ready', ?7, ?8)", + params![ + canonical, + revision.sha, + snapshot.id, + snapshot.engine.id, + snapshot.engine.version, + snapshot.schema_version, + serde_json::to_string(&snapshot.coverage).expect("coverage"), + snapshot.created_at, + ], + ) + .expect("checkpoint"); + }; + let first_revision = timeline.revisions.first().expect("benchmark revision"); + let (mut previous_snapshot, first_build_ms) = build_snapshot(first_revision); + build_samples.push(first_build_ms); + persist_benchmark_checkpoint(first_revision, &previous_snapshot); + let mut checkpoint_count = 1usize; + let mut delta_samples = Vec::with_capacity(timeline.revisions.len().saturating_sub(1)); + let mut delta_node_changes = 0usize; + let mut delta_edge_changes = 0usize; + for index in 1..timeline.revisions.len() { + let revision = &timeline.revisions[index]; + let path_changes = changed_path_records(&root, &revision.sha).expect("path changes"); + let changed_paths = path_changes + .iter() + .filter(|change| change.change_kind != "deleted") + .map(|change| change.path.clone()) + .collect::>(); + let deleted_paths = path_changes + .iter() + .filter(|change| change.change_kind == "deleted") + .map(|change| change.path.clone()) + .chain( + path_changes + .iter() + .filter(|change| change.change_kind == "renamed") + .filter_map(|change| change.old_path.clone()), + ) + .collect::>(); + let started = std::time::Instant::now(); + let mut after_snapshot = build_snapshot_from_blob_delta( + &storage_key, + &revision.sha, + &previous_snapshot, + GitObjectReader::new(&root) + .blobs_for_paths(&revision.sha, &changed_paths) + .expect("changed blobs"), + &deleted_paths, + &cancellation, + &|_: StructuralGraphProgress| {}, + ) + .expect("incremental historical snapshot"); + compact_history_snapshot(&mut after_snapshot); + let build_ms = started.elapsed().as_secs_f64() * 1000.0; + build_samples.push(build_ms); + if index + 1 == timeline.revisions.len() || revision.is_release { + persist_benchmark_checkpoint(revision, &after_snapshot); + checkpoint_count += 1; + } + let started = std::time::Instant::now(); + let delta = compute_and_persist_structural_delta_with_paths( + &connection, + &canonical, + &timeline.revisions[index - 1].sha, + &revision.sha, + &previous_snapshot, + &after_snapshot, + path_changes, + ) + .expect("structural delta"); + delta_node_changes += delta.added_node_ids.len() + + delta.removed_node_ids.len() + + delta.changed_node_ids.len(); + delta_edge_changes += delta.added_edge_ids.len() + + delta.removed_edge_ids.len() + + delta.changed_edge_ids.len(); + delta_samples.push(started.elapsed().as_secs_f64() * 1000.0); + previous_snapshot = after_snapshot; + if index % 4 == 0 { + release_history_allocator_pressure(); + } + } + release_history_allocator_pressure(); + let backfill_ms = total_started.elapsed().as_secs_f64() * 1000.0; + let target_index = (timeline.revisions.len() * 3 / 4) + .min(timeline.revisions.len().saturating_sub(2)) + .max(1); + let target_revision = &timeline.revisions[target_index].sha; + let mut as_of_samples = Vec::with_capacity(100); + for _ in 0..100 { + let started = std::time::Instant::now(); + std::hint::black_box( + reconstruct_history_as_of(&connection, &canonical, &storage_key, target_revision) + .expect("as-of query") + .expect("complete as-of chain"), + ); + as_of_samples.push(started.elapsed().as_secs_f64() * 1000.0); + } + let mut no_op_samples = Vec::with_capacity(10_000); + for _ in 0..10_000 { + let started = std::time::Instant::now(); + std::hint::black_box(classify_history_refresh( + Some(&timeline.head), + false, + false, + false, + false, + )); + no_op_samples.push(started.elapsed().as_secs_f64() * 1000.0); + } + let one_commit_refresh_ms = build_samples.last().copied().unwrap_or_default() + + delta_samples.last().copied().unwrap_or_default(); + let percentile = |samples: &mut Vec, percentile: usize| { + samples.sort_by(f64::total_cmp); + samples[samples.len() * percentile / 100] + }; + let build_p50 = percentile(&mut build_samples, 50); + let build_p95 = percentile(&mut build_samples, 95); + let delta_p50 = percentile(&mut delta_samples, 50); + let delta_p95 = percentile(&mut delta_samples, 95); + let as_of_p50 = percentile(&mut as_of_samples, 50); + let as_of_p95 = percentile(&mut as_of_samples, 95); + let no_op_p50 = percentile(&mut no_op_samples, 50); + let no_op_p95 = percentile(&mut no_op_samples, 95); + let database_bytes = fs::metadata(&db_path) + .map(|metadata| metadata.len()) + .unwrap_or_default(); + let snapshot_blob_bytes: i64 = connection + .query_row( + "SELECT COALESCE(SUM(LENGTH(payload)), 0) FROM history_graph_snapshot_blobs", + [], + |row| row.get(0), + ) + .expect("snapshot blob bytes"); + let delta_blob_bytes: i64 = connection + .query_row( + "SELECT COALESCE(SUM(LENGTH(payload)), 0) FROM history_graph_event_blobs", + [], + |row| row.get(0), + ) + .expect("delta blob bytes"); + let rss_kib = Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_default(); + let usage_after = process_usage(); + let user_cpu = timeval_seconds(usage_after.ru_utime) - timeval_seconds(usage_before.ru_utime); + let system_cpu = timeval_seconds(usage_after.ru_stime) - timeval_seconds(usage_before.ru_stime); + let input_blocks = usage_after + .ru_inblock + .saturating_sub(usage_before.ru_inblock); + let output_blocks = usage_after + .ru_oublock + .saturating_sub(usage_before.ru_oublock); + + eprintln!("\n=== bench_history_backfill_incremental_and_as_of_real_repo ==="); + eprintln!("repo: {}", root.display()); + eprintln!( + "history: {} commits · {} releases · {checkpoint_count} checkpoints", + timeline.revisions.len(), + timeline + .revisions + .iter() + .filter(|revision| revision.is_release) + .count() + ); + eprintln!( + "graph: {} files · {} nodes · {} edges", + previous_snapshot.coverage.indexed_files, + previous_snapshot.nodes.len(), + previous_snapshot.edges.len() + ); + eprintln!("backfill total: {backfill_ms:.2} ms"); + eprintln!("checkpoint p50/p95: {build_p50:.2} / {build_p95:.2} ms"); + eprintln!("delta p50/p95: {delta_p50:.2} / {delta_p95:.2} ms"); + eprintln!( + "delta avg changes: {:.0} nodes · {:.0} edges", + delta_node_changes as f64 / delta_samples.len().max(1) as f64, + delta_edge_changes as f64 / delta_samples.len().max(1) as f64 + ); + eprintln!("one-commit refresh: {one_commit_refresh_ms:.2} ms"); + eprintln!("as-of p50/p95: {as_of_p50:.3} / {as_of_p95:.3} ms"); + eprintln!("no-op p50/p95: {no_op_p50:.6} / {no_op_p95:.6} ms"); + eprintln!( + "checkpoint hit ratio: {:.1}%", + checkpoint_count as f64 / timeline.revisions.len() as f64 * 100.0 + ); + eprintln!( + "database: {:.2} MiB ({:.1} KiB/commit)", + database_bytes as f64 / 1_048_576.0, + database_bytes as f64 / 1024.0 / timeline.revisions.len() as f64 + ); + eprintln!( + "compressed payloads: {:.2} MiB checkpoints · {:.2} MiB deltas", + snapshot_blob_bytes as f64 / 1_048_576.0, + delta_blob_bytes as f64 / 1_048_576.0 + ); + eprintln!( + "process RSS: {:.1} MiB\n", + rss_kib as f64 / 1024.0 + ); + eprintln!("CPU user/system: {user_cpu:.2} / {system_cpu:.2} s"); + eprintln!("filesystem block ops: {input_blocks} read · {output_blocks} write\n"); + + drop(connection); + let _ = fs::remove_file(db_path); +} + +fn run_git(root: &Path, arguments: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(root) + .args(arguments) + .status() + .expect("git"); + assert!(status.success(), "git {arguments:?}"); +} From dc90703063d7e489254a46592940f01089ad0aea Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:43:14 +0530 Subject: [PATCH 043/141] feat(history): query causal evidence chains --- .../src/commands/history_query/mod.rs | 9 + .../commands/history_query/service/causal.rs | 488 ++++++++++++ .../src/commands/history_query/service/mod.rs | 692 ++++++++++++++++++ .../src/commands/history_query/types.rs | 120 +++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + 5 files changed, 1310 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_query/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_query/service/causal.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_query/service/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_query/types.rs diff --git a/apps/desktop/src-tauri/src/commands/history_query/mod.rs b/apps/desktop/src-tauri/src/commands/history_query/mod.rs new file mode 100644 index 0000000..8ad635e --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_query/mod.rs @@ -0,0 +1,9 @@ +mod service; +mod types; + +pub use service::get_history_causal_trace; +pub(crate) use service::{ + build_review_history_slice, query_causal_trace, render_agent_history_context, + render_review_history_slice, +}; +pub use types::*; diff --git a/apps/desktop/src-tauri/src/commands/history_query/service/causal.rs b/apps/desktop/src-tauri/src/commands/history_query/service/causal.rs new file mode 100644 index 0000000..f82e3f5 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_query/service/causal.rs @@ -0,0 +1,488 @@ +use super::*; + +pub(super) fn assemble_episodes( + events: &[StoredHistoryEvent], + selector: &HistoryCausalSelector, + limit: usize, +) -> (Vec, Vec) { + let seeds = events + .iter() + .enumerate() + .filter(|(_, event)| selector_matches(selector, event)) + .map(|(index, _)| index) + .take(20) + .collect::>(); + if seeds.is_empty() { + return ( + Vec::new(), + vec![ + "No explicit ledger event matches the causal selector within scanned coverage" + .to_string(), + ], + ); + } + let mut claimed = HashSet::new(); + let mut episodes = Vec::new(); + for seed in seeds { + if claimed.contains(&seed) { + continue; + } + let mut member_indexes = BTreeSet::from([seed]); + let mut frontier = vec![seed]; + let mut links = Vec::new(); + let mut truncated = false; + while let Some(current_index) = frontier.pop() { + if member_indexes.len() >= limit { + truncated = true; + break; + } + for (candidate_index, candidate) in events.iter().enumerate() { + if member_indexes.contains(&candidate_index) { + continue; + } + let Some((relation, evidence)) = explicit_link(&events[current_index], candidate) + else { + continue; + }; + if member_indexes.len() >= limit { + truncated = true; + break; + } + member_indexes.insert(candidate_index); + frontier.push(candidate_index); + links.push(causal_link( + &events[current_index].event, + &candidate.event, + &relation, + HistoryCausalLinkStatus::Evidenced, + GraphTrust::Extracted, + evidence, + )); + } + } + claimed.extend(member_indexes.iter().copied()); + let mut episode_events = member_indexes + .iter() + .map(|index| events[*index].event.clone()) + .collect::>(); + episode_events.sort_by(|left, right| { + event_time(left) + .cmp(event_time(right)) + .then_with(|| left.id.cmp(&right.id)) + }); + links.sort_by(|left, right| left.id.cmp(&right.id)); + links.dedup_by(|left, right| left.id == right.id); + let member_ids = episode_events + .iter() + .map(|event| event.id.as_str()) + .collect::>(); + let (qualified_leads, qualified_lead_events) = + qualified_leads(events, &episode_events, &member_ids, 20); + episodes.push(build_episode( + &events[seed].event.id, + episode_events, + links, + qualified_leads, + qualified_lead_events, + truncated, + )); + } + (episodes, Vec::new()) +} + +pub(super) fn explicit_link( + left: &StoredHistoryEvent, + right: &StoredHistoryEvent, +) -> Option<(String, String)> { + if left.explicit_refs.contains(&right.event.id) || right.explicit_refs.contains(&left.event.id) + { + return Some(( + "references_event".to_string(), + "One persisted record explicitly references the other event ID".to_string(), + )); + } + let left_keys = left.event.episode_keys.iter().collect::>(); + let right_keys = right.event.episode_keys.iter().collect::>(); + if let Some(key) = left_keys.intersection(&right_keys).next() { + return Some(( + "shared_episode_key".to_string(), + format!("Both records carry the explicit episode key {key}"), + )); + } + None +} + +pub(super) fn qualified_leads( + all: &[StoredHistoryEvent], + members: &[HistoryCausalEvent], + member_ids: &HashSet<&str>, + limit: usize, +) -> (Vec, Vec) { + let mut leads = Vec::new(); + let mut lead_events = BTreeMap::new(); + for candidate in all + .iter() + .filter(|event| !member_ids.contains(event.event.id.as_str())) + { + for member in members { + if let Some((relation, evidence)) = identifier_association(member, candidate) { + leads.push(causal_link( + member, + &candidate.event, + relation, + HistoryCausalLinkStatus::QualifiedLead, + GraphTrust::Inferred, + evidence, + )); + lead_events.insert(candidate.event.id.clone(), candidate.event.clone()); + break; + } + let shared_paths = member + .sources + .iter() + .map(|source| source.path.as_str()) + .collect::>(); + let Some(path) = candidate + .event + .sources + .iter() + .map(|source| source.path.as_str()) + .find(|path| shared_paths.contains(path)) + else { + continue; + }; + if !within_minutes(event_time(member), event_time(&candidate.event), 30) { + continue; + } + leads.push(causal_link( + member, + &candidate.event, + "path_time_correlation", + HistoryCausalLinkStatus::QualifiedLead, + GraphTrust::Inferred, + format!( + "Both records cite {path} within 30 minutes; no explicit identifier links them" + ), + )); + lead_events.insert(candidate.event.id.clone(), candidate.event.clone()); + break; + } + if leads.len() >= limit { + break; + } + } + leads.sort_by(|left, right| left.id.cmp(&right.id)); + leads.dedup_by(|left, right| left.id == right.id); + (leads, lead_events.into_values().collect()) +} + +pub(super) fn identifier_association( + member: &HistoryCausalEvent, + candidate: &StoredHistoryEvent, +) -> Option<(&'static str, String)> { + if member.revision_sha.is_some() && member.revision_sha == candidate.event.revision_sha { + return Some(( + "same_revision_association", + "Both records identify the same Git revision; this is association evidence, not causation" + .to_string(), + )); + } + let member_entities = [&member.entity_id, &member.related_entity_id] + .into_iter() + .flatten() + .cloned() + .collect::>(); + let candidate_entities = event_entities(candidate); + if let Some(entity) = member_entities.intersection(&candidate_entities).next() { + return Some(( + "same_entity_association", + format!( + "Both records identify entity {entity}; no explicit event reference links them" + ), + )); + } + let member_keys = member.episode_keys.iter().collect::>(); + let candidate_keys = candidate.event.episode_keys.iter().collect::>(); + member_keys.intersection(&candidate_keys).next().map(|key| { + ( + "shared_episode_key_association", + format!("Both records carry episode key {key}; no explicit event reference links them"), + ) + }) +} + +pub(super) fn build_episode( + anchor_event_id: &str, + events: Vec, + links: Vec, + qualified_leads: Vec, + qualified_lead_events: Vec, + truncated: bool, +) -> HistoryChangeEpisode { + let mut episode_keys = events + .iter() + .flat_map(|event| event.episode_keys.iter().cloned()) + .collect::>(); + episode_keys.sort(); + episode_keys.dedup(); + let mut stages_present = events + .iter() + .map(|event| event.stage.clone()) + .collect::>(); + stages_present.sort_by_key(stage_order); + stages_present.dedup(); + let mut gaps = Vec::new(); + for (stage, label) in [ + (HistoryCausalStage::Intent, "intent"), + (HistoryCausalStage::Implementation, "implementation"), + (HistoryCausalStage::Verification, "verification"), + (HistoryCausalStage::Release, "release/deploy"), + (HistoryCausalStage::Outcome, "runtime/provider outcome"), + ] { + if !stages_present.contains(&stage) { + gaps.push(format!("No explicitly linked {label} evidence")); + } + } + let contradictions = episode_contradictions(&events); + let mut trust_summary = BTreeMap::new(); + for event in &events { + *trust_summary + .entry(event.trust.as_str().to_string()) + .or_default() += 1; + } + let started_at = events + .first() + .map(|event| event_time(event).to_string()) + .unwrap_or_default(); + let ended_at = events + .last() + .map(|event| event_time(event).to_string()) + .unwrap_or_default(); + HistoryChangeEpisode { + id: stable_graph_id("history-episode", anchor_event_id), + anchor_event_id: anchor_event_id.to_string(), + episode_keys, + events, + links, + qualified_leads, + qualified_lead_events, + stages_present, + gaps, + contradictions, + trust_summary, + started_at, + ended_at, + truncated, + } +} + +pub(super) fn episode_contradictions(events: &[HistoryCausalEvent]) -> Vec { + let mut contradictions = Vec::new(); + let qa_passed = events.iter().any(|event| { + event.event_kind == "synthetic_qa" && event.summary.to_ascii_lowercase().contains("passed") + }); + let qa_failed = events.iter().any(|event| { + event.event_kind == "synthetic_qa" && event.summary.to_ascii_lowercase().contains("failed") + }); + if qa_passed && qa_failed { + contradictions.push( + "Linked synthetic QA evidence contains both passing and failing observations" + .to_string(), + ); + } + if events.iter().any(|event| { + event.event_kind == "user_annotation" + && event.summary.to_ascii_lowercase().contains("reject") + }) { + contradictions + .push("A local user annotation rejects linked historical evidence".to_string()); + } + contradictions +} + +pub(super) fn selector_matches( + selector: &HistoryCausalSelector, + event: &StoredHistoryEvent, +) -> bool { + match selector { + HistoryCausalSelector::Event { event_id } => &event.event.id == event_id, + HistoryCausalSelector::Entity { entity_id } => { + event_entities(event).contains(entity_id) + || payload_mentions_entity(&event.payload, entity_id) + } + HistoryCausalSelector::Revision { revision } => { + event.event.revision_sha.as_deref() == Some(revision) + } + HistoryCausalSelector::Release { tag } => { + event.payload.get("tag").and_then(Value::as_str) == Some(tag) + || string_array(&event.payload, "release_candidates").contains(tag) + } + HistoryCausalSelector::EpisodeKey { key } => event.event.episode_keys.contains(key), + } +} + +pub(super) fn payload_mentions_entity(payload: &Value, entity_id: &str) -> bool { + [ + "entity_candidates", + "added_node_ids", + "changed_node_ids", + "removed_node_ids", + ] + .iter() + .any(|key| { + string_array(payload, key) + .iter() + .any(|value| value == entity_id) + }) +} + +pub(super) fn event_entities(event: &StoredHistoryEvent) -> HashSet { + event + .event + .entity_id + .iter() + .chain(event.event.related_entity_id.iter()) + .cloned() + .chain(string_array(&event.payload, "entity_candidates")) + .collect() +} + +pub(super) fn causal_link( + left: &HistoryCausalEvent, + right: &HistoryCausalEvent, + relation: &str, + status: HistoryCausalLinkStatus, + trust: GraphTrust, + evidence: String, +) -> HistoryCausalLink { + let mut ids = [left.id.as_str(), right.id.as_str()]; + ids.sort(); + HistoryCausalLink { + id: stable_graph_id( + "history-causal-link", + &format!("{relation}\0{}\0{}", ids[0], ids[1]), + ), + from_event_id: left.id.clone(), + to_event_id: right.id.clone(), + relation: relation.to_string(), + status, + trust, + evidence, + sources: left + .sources + .iter() + .chain(right.sources.iter()) + .take(20) + .cloned() + .collect(), + } +} + +pub(super) fn classify_stage(event_kind: &str) -> HistoryCausalStage { + match event_kind { + "decision_marker" | "agent_session" => HistoryCausalStage::Intent, + "commit" | "structural_delta" | "entity_lineage" => HistoryCausalStage::Implementation, + "review" | "pull_request_review" | "verification_attempt" | "synthetic_qa" => { + HistoryCausalStage::Verification + } + "release" | "deploy" => HistoryCausalStage::Release, + "analytics_provider_ingestion" + | "analytics_provider_delivery" + | "observed_outcome" + | "log_observation" => HistoryCausalStage::Outcome, + "incident" => HistoryCausalStage::Regression, + "issue" | "user_annotation" => HistoryCausalStage::FollowUp, + _ => HistoryCausalStage::Context, + } +} + +pub(super) fn event_summary(payload: &Value, event_kind: &str) -> String { + ["summary", "subject", "body", "decision", "evidence"] + .iter() + .find_map(|key| payload.get(*key).and_then(Value::as_str)) + .map(|value| value.chars().take(1_000).collect()) + .unwrap_or_else(|| event_kind.replace('_', " ")) +} + +pub(super) fn resolve_source_path(repo_root: &Path, source_path: &str) -> PathBuf { + let path = PathBuf::from(source_path); + if path.is_absolute() { + path + } else { + repo_root.join(path) + } +} + +pub(super) fn string_array(payload: &Value, key: &str) -> Vec { + payload + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .take(200) + .map(str::to_string) + .collect() +} + +pub(super) fn event_time(event: &HistoryCausalEvent) -> &str { + event.effective_at.as_deref().unwrap_or(&event.recorded_at) +} + +pub(super) fn within_minutes(left: &str, right: &str, minutes: i64) -> bool { + let Ok(left) = chrono::DateTime::parse_from_rfc3339(left) else { + return false; + }; + let Ok(right) = chrono::DateTime::parse_from_rfc3339(right) else { + return false; + }; + (left - right).num_minutes().abs() <= minutes +} + +pub(super) fn stage_order(stage: &HistoryCausalStage) -> u8 { + match stage { + HistoryCausalStage::Intent => 0, + HistoryCausalStage::Implementation => 1, + HistoryCausalStage::Verification => 2, + HistoryCausalStage::Release => 3, + HistoryCausalStage::Outcome => 4, + HistoryCausalStage::Regression => 5, + HistoryCausalStage::FollowUp => 6, + HistoryCausalStage::Context => 7, + } +} + +pub(super) fn resolve_selector( + root: &PathBuf, + selector: HistoryCausalSelector, +) -> Result { + match selector { + HistoryCausalSelector::Revision { revision } => Ok(HistoryCausalSelector::Revision { + revision: resolve_revision(root, &revision)?, + }), + HistoryCausalSelector::Release { tag } => { + if tag.trim().is_empty() || tag.starts_with('-') || tag.len() > 128 { + return Err("A valid release tag is required".to_string()); + } + Ok(HistoryCausalSelector::Release { tag }) + } + HistoryCausalSelector::Event { event_id } if event_id.trim().is_empty() => { + Err("A causal event ID is required".to_string()) + } + HistoryCausalSelector::Entity { entity_id } if entity_id.trim().is_empty() => { + Err("A causal entity ID is required".to_string()) + } + HistoryCausalSelector::EpisodeKey { key } if key.trim().is_empty() => { + Err("A causal episode key is required".to_string()) + } + selector => Ok(selector), + } +} + +pub(super) fn encode_cursor(recorded_at: &str, id: &str) -> Result { + serde_json::to_string(&(recorded_at, id)).map_err(|error| format!("Encode cursor: {error}")) +} + +pub(super) fn decode_cursor(cursor: &str) -> Result<(String, String), String> { + serde_json::from_str(cursor).map_err(|_| "Invalid causal trace cursor".to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs b/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs new file mode 100644 index 0000000..fdca2ea --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs @@ -0,0 +1,692 @@ +use super::types::*; +use crate::commands::history_graph::{canonical_repo_path, git_text, resolve_revision}; +use crate::commands::structural_graph::types::{ + stable_graph_id, GraphSourceAnchor, GraphTrust, STRUCTURAL_GRAPH_SCHEMA_VERSION, +}; +use crate::DbState; +use rusqlite::{params, Connection, OptionalExtension}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tauri::State; + +const MAX_EVENT_SCAN: usize = 5_000; +const DEFAULT_TRACE_LIMIT: usize = 120; +const MAX_TRACE_LIMIT: usize = 500; + +#[derive(Debug, Clone)] +struct StoredHistoryEvent { + event: HistoryCausalEvent, + payload: Value, + explicit_refs: Vec, +} + +pub(crate) fn build_review_history_slice( + connection: &Connection, + repo_path: &str, + changed_files: &[String], +) -> Result { + let repo_root = canonical_repo_path(repo_path)?; + let canonical = repo_root.to_string_lossy().to_string(); + let current_head = git_text(&repo_root, &["rev-parse", "HEAD"])?; + let (indexed_head, coverage) = connection + .query_row( + "SELECT indexed_head, coverage_json FROM history_graph_repositories + WHERE repo_path = ?1", + params![canonical], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| format!("Load review history freshness: {error}"))? + .map(|(head, coverage)| { + ( + head.unwrap_or_default(), + serde_json::from_str(&coverage).unwrap_or_else(|_| serde_json::json!({})), + ) + }) + .unwrap_or_else(|| (String::new(), serde_json::json!({}))); + let mut files = changed_files + .iter() + .map(|path| path.trim().replace('\\', "/")) + .filter(|path| !path.is_empty()) + .take(100) + .collect::>(); + files.sort(); + files.dedup(); + if indexed_head.is_empty() { + return Ok(HistoryReviewSlice { + schema_version: 1, + repo_path: canonical, + files, + entity_ids: Vec::new(), + episodes: Vec::new(), + constraints: Vec::new(), + verification: Vec::new(), + failures: Vec::new(), + regressions: Vec::new(), + qualified_leads: Vec::new(), + gaps: vec!["Temporal graph is not indexed for this repository".to_string()], + indexed_head, + stale: true, + coverage, + truncated: false, + }); + } + + let entity_ids = review_entity_ids(connection, &canonical, &indexed_head, &files, 100)?; + let revision_ids = review_revision_ids(connection, &canonical, &files, 120)?; + let (events, scan_truncated) = load_event_pool(connection, &canonical, &repo_root, None)?; + let entity_set = entity_ids.iter().cloned().collect::>(); + let file_set = files.iter().cloned().collect::>(); + let seed_ids = events + .iter() + .filter(|event| review_event_matches(event, &entity_set, &revision_ids, &file_set)) + .map(|event| event.event.id.clone()) + .take(30) + .collect::>(); + let mut components = BTreeMap::::new(); + for event_id in seed_ids { + let (episodes, _) = + assemble_episodes(&events, &HistoryCausalSelector::Event { event_id }, 80); + for episode in episodes { + let mut event_ids = episode + .events + .iter() + .map(|event| event.id.as_str()) + .collect::>(); + event_ids.sort(); + components.entry(event_ids.join("\0")).or_insert(episode); + } + } + let mut episodes = components.into_values().collect::>(); + episodes.sort_by(|left, right| { + right + .ended_at + .cmp(&left.ended_at) + .then_with(|| left.id.cmp(&right.id)) + }); + let episode_truncated = episodes.len() > 6 || episodes.iter().any(|episode| episode.truncated); + episodes.truncate(6); + + let mut all_events = episodes + .iter() + .flat_map(|episode| episode.events.iter().cloned()) + .collect::>(); + all_events.sort_by(|left, right| { + event_time(right) + .cmp(event_time(left)) + .then_with(|| left.id.cmp(&right.id)) + }); + all_events.dedup_by(|left, right| left.id == right.id); + let constraints = take_review_events(&all_events, 12, |event| { + matches!( + event.stage, + HistoryCausalStage::Intent | HistoryCausalStage::FollowUp + ) + }); + let verification = take_review_events(&all_events, 12, |event| { + event.stage == HistoryCausalStage::Verification + }); + let regressions = take_review_events(&all_events, 12, |event| { + event.stage == HistoryCausalStage::Regression + }); + let failures = take_review_events(&all_events, 12, |event| { + let summary = event.summary.to_ascii_lowercase(); + event.stage == HistoryCausalStage::Regression + || summary.contains("failed") + || summary.contains("failure") + || summary.contains("error") + || summary.contains("reject") + }); + let mut qualified_leads = episodes + .iter() + .flat_map(|episode| episode.qualified_lead_events.iter().cloned()) + .collect::>(); + qualified_leads.sort_by(|left, right| { + event_time(right) + .cmp(event_time(left)) + .then_with(|| left.id.cmp(&right.id)) + }); + qualified_leads.dedup_by(|left, right| left.id == right.id); + qualified_leads.truncate(12); + let mut gaps = episodes + .iter() + .flat_map(|episode| episode.gaps.iter().cloned()) + .collect::>(); + gaps.sort(); + gaps.dedup(); + if entity_ids.is_empty() { + gaps.push("No indexed structural entities map to the changed files".to_string()); + } + if episodes.is_empty() { + gaps.push("No explicit temporal episodes map to the changed files".to_string()); + } + if scan_truncated { + gaps.push(format!( + "Review history scanned only the newest {MAX_EVENT_SCAN} ledger events" + )); + } + + Ok(HistoryReviewSlice { + schema_version: 1, + repo_path: canonical, + files, + entity_ids, + episodes, + constraints, + verification, + failures, + regressions, + qualified_leads, + gaps, + stale: indexed_head != current_head, + indexed_head, + coverage, + truncated: scan_truncated || episode_truncated, + }) +} + +pub(crate) fn render_review_history_slice(slice: &HistoryReviewSlice) -> String { + if slice.episodes.is_empty() && slice.constraints.is_empty() && slice.verification.is_empty() { + return String::new(); + } + const MAX_BYTES: usize = 3_500; + let mut output = String::from( + "\nTemporal history graph for changed files (cited context; inferred/qualified leads are not findings):\n", + ); + for event in slice + .constraints + .iter() + .chain(slice.failures.iter()) + .chain(slice.verification.iter()) + .take(12) + { + let source = event + .sources + .first() + .map(|source| format!(" source={}", source.path)) + .unwrap_or_default(); + let line = format!( + "- [{}|{}] {}{} event={}\n", + stage_label(&event.stage), + event.trust.as_str(), + event.summary.replace('\n', " "), + source, + event.id + ); + if output.len() + line.len() > MAX_BYTES { + break; + } + output.push_str(&line); + } + if !slice.gaps.is_empty() && output.len() < MAX_BYTES { + let line = format!( + "- Evidence gaps: {}\n", + slice + .gaps + .iter() + .take(5) + .cloned() + .collect::>() + .join("; ") + ); + output.push_str( + &line + .chars() + .take(MAX_BYTES - output.len()) + .collect::(), + ); + } + output +} + +pub(crate) fn render_agent_history_context(slice: &HistoryReviewSlice) -> String { + let mut output = String::new(); + output.push_str("## Temporal Structural History\n\n"); + output.push_str(&format!( + "_Schemas: history_query.v{} / structural_graph.v{} · indexed head `{}` · {}{}_\n\n", + slice.schema_version, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + slice.indexed_head, + if slice.stale { "stale" } else { "current" }, + if slice.truncated { " · truncated" } else { "" } + )); + output.push_str(&format!( + "Scope: {} files · {} stable entities · {} causal episodes.\n\n", + slice.files.len(), + slice.entity_ids.len(), + slice.episodes.len() + )); + for episode in slice.episodes.iter().take(6) { + output.push_str(&format!("### Episode `{}`\n\n", episode.id)); + for event in episode.events.iter().take(24) { + let revision = event + .revision_sha + .as_deref() + .map(|revision| format!(" revision `{}`", &revision[..revision.len().min(12)])) + .unwrap_or_default(); + let source = event + .sources + .first() + .map(|source| format!(" source `{}`", source.path)) + .unwrap_or_default(); + let entities = match (&event.entity_id, &event.related_entity_id) { + (Some(from), Some(to)) => format!(" entities `{from}` -> `{to}`"), + (Some(entity), None) | (None, Some(entity)) => { + format!(" entity `{entity}`") + } + (None, None) => String::new(), + }; + output.push_str(&format!( + "- [{} / {}] {} — event `{}`{}{}{}{}\n", + stage_label(&event.stage), + event.trust.as_str(), + event.summary.replace('\n', " "), + event.id, + revision, + source, + entities, + event + .relation_kind + .as_deref() + .map(|relation| format!(" relation `{relation}`")) + .unwrap_or_default() + )); + } + for contradiction in episode.contradictions.iter().take(5) { + output.push_str(&format!("- Contradiction: {contradiction}\n")); + } + for gap in episode.gaps.iter().take(5) { + output.push_str(&format!("- Gap: {gap}\n")); + } + for lead in episode.qualified_leads.iter().take(5) { + output.push_str(&format!( + "- Qualified lead only: {} -> {} — {}\n", + lead.from_event_id, lead.to_event_id, lead.evidence + )); + } + output.push('\n'); + } + if slice.episodes.is_empty() { + output.push_str("No explicit causal episodes map to the bounded export scope.\n\n"); + } + if !slice.gaps.is_empty() { + output.push_str("### Coverage Gaps\n\n"); + for gap in slice.gaps.iter().take(12) { + output.push_str(&format!("- {gap}\n")); + } + output.push('\n'); + } + output +} + +#[tauri::command] +pub async fn get_history_causal_trace( + repo_path: String, + selector: HistoryCausalSelector, + limit: Option, + cursor: Option, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let selector = resolve_selector(&root, selector)?; + let current_head = git_text(&root, &["rev-parse", "HEAD"])?; + let limit = limit + .unwrap_or(DEFAULT_TRACE_LIMIT) + .clamp(1, MAX_TRACE_LIMIT); + let cursor = cursor.as_deref().map(decode_cursor).transpose()?; + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + query_causal_trace(&connection, &root, ¤t_head, selector, limit, cursor) + }) + .await + .map_err(|error| format!("History causal query worker failed: {error}"))? +} + +pub(crate) fn query_causal_trace( + connection: &Connection, + repo_root: &Path, + current_head: &str, + selector: HistoryCausalSelector, + limit: usize, + cursor: Option<(String, String)>, +) -> Result { + let repo_path = repo_root.to_string_lossy().to_string(); + let total_events = connection + .query_row( + "SELECT COUNT(*) FROM history_graph_events WHERE repo_path = ?1", + params![repo_path], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| format!("Count history events: {error}"))? as usize; + let (events, scan_truncated) = + load_event_pool(connection, &repo_path, repo_root, cursor.as_ref())?; + let scanned_events = events.len(); + let (mut episodes, mut gaps) = assemble_episodes(&events, &selector, limit); + let response_truncated = episodes.iter().any(|episode| episode.truncated) || scan_truncated; + if scan_truncated { + gaps.push(format!( + "Causal assembly scanned the newest {scanned_events} of {total_events} ledger events" + )); + } + let next_cursor = scan_truncated + .then(|| events.last()) + .flatten() + .map(|event| encode_cursor(&event.event.recorded_at, &event.event.id)) + .transpose()?; + let (indexed_head, coverage) = connection + .query_row( + "SELECT indexed_head, coverage_json FROM history_graph_repositories + WHERE repo_path = ?1", + params![repo_path], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| format!("Load causal-query freshness: {error}"))? + .map(|(head, coverage)| { + ( + head.unwrap_or_default(), + serde_json::from_str(&coverage).unwrap_or_else(|_| serde_json::json!({})), + ) + }) + .unwrap_or_else(|| (String::new(), serde_json::json!({}))); + episodes.sort_by(|left, right| { + right + .ended_at + .cmp(&left.ended_at) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(HistoryCausalTrace { + schema_version: 1, + repo_path, + selector, + episodes, + stale: indexed_head.is_empty() || indexed_head != current_head, + indexed_head, + coverage, + gaps, + scanned_events, + total_events, + truncated: response_truncated, + next_cursor, + }) +} + +fn load_event_pool( + connection: &Connection, + repo_path: &str, + repo_root: &Path, + cursor: Option<&(String, String)>, +) -> Result<(Vec, bool), String> { + let (cursor_time, cursor_id) = cursor + .cloned() + .map(|(time, id)| (Some(time), Some(id))) + .unwrap_or_default(); + let mut statement = connection + .prepare( + "SELECT id, revision_sha, event_kind, entity_id, related_entity_id, + relation_kind, trust, origin, source_id, source_cursor, payload_json, + evidence_json, recorded_at + FROM history_graph_events + WHERE repo_path = ?1 + AND (?2 IS NULL OR recorded_at < ?2 OR (recorded_at = ?2 AND id < ?3)) + ORDER BY recorded_at DESC, id DESC LIMIT ?4", + ) + .map_err(|error| format!("Prepare causal event scan: {error}"))?; + let rows = statement + .query_map( + params![ + repo_path, + cursor_time, + cursor_id, + (MAX_EVENT_SCAN + 1) as i64 + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, String>(10)?, + row.get::<_, String>(11)?, + row.get::<_, String>(12)?, + )) + }, + ) + .map_err(|error| format!("Scan causal events: {error}"))?; + let rows = rows + .collect::, _>>() + .map_err(|error| format!("Read causal event: {error}"))?; + let scan_truncated = rows.len() > MAX_EVENT_SCAN; + let mut events = Vec::with_capacity(rows.len().min(MAX_EVENT_SCAN)); + for row in rows.into_iter().take(MAX_EVENT_SCAN) { + let ( + id, + revision_sha, + event_kind, + entity_id, + related_entity_id, + relation_kind, + trust, + origin, + source_id, + source_cursor, + payload_json, + evidence_json, + recorded_at, + ) = row; + let payload: Value = + serde_json::from_str(&payload_json).unwrap_or_else(|_| serde_json::json!({})); + let sources: Vec = + serde_json::from_str(&evidence_json).unwrap_or_default(); + let episode_keys = string_array(&payload, "episode_keys"); + let explicit_refs = payload + .get("related_event_id") + .and_then(Value::as_str) + .map(str::to_string) + .into_iter() + .collect(); + let effective_at = payload + .get("effective_at") + .and_then(Value::as_str) + .map(str::to_string); + let summary = event_summary(&payload, &event_kind); + let source_available = sources + .iter() + .all(|source| resolve_source_path(repo_root, &source.path).exists()); + events.push(StoredHistoryEvent { + event: HistoryCausalEvent { + id, + revision_sha, + event_kind: event_kind.clone(), + stage: classify_stage(&event_kind), + summary, + trust: GraphTrust::from_storage(&trust), + origin, + source_id, + source_cursor, + recorded_at, + effective_at, + entity_id, + related_entity_id, + relation_kind, + episode_keys, + sources, + source_available, + }, + payload, + explicit_refs, + }); + } + Ok((events, scan_truncated)) +} + +fn review_entity_ids( + connection: &Connection, + repo_path: &str, + revision: &str, + files: &[String], + limit: usize, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT n.id + FROM history_graph_checkpoints c + JOIN structural_graph_nodes n ON n.snapshot_id = c.snapshot_id + WHERE c.repo_path = ?1 AND c.revision_sha = ?2 AND c.status = 'ready' + AND n.path = ?3 + ORDER BY n.kind, n.label, n.id LIMIT ?4", + ) + .map_err(|error| format!("Prepare review entity lookup: {error}"))?; + let mut entity_ids = BTreeSet::new(); + for file in files { + let remaining = limit.saturating_sub(entity_ids.len()); + if remaining == 0 { + break; + } + let rows = statement + .query_map( + params![repo_path, revision, file, remaining as i64], + |row| row.get::<_, String>(0), + ) + .map_err(|error| format!("Query review entities: {error}"))?; + for entity_id in rows { + entity_ids.insert(entity_id.map_err(|error| format!("Read review entity: {error}"))?); + } + } + Ok(entity_ids.into_iter().collect()) +} + +fn review_revision_ids( + connection: &Connection, + repo_path: &str, + files: &[String], + limit: usize, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT p.revision_sha + FROM history_graph_revision_paths p + JOIN history_graph_revisions r + ON r.repo_path = p.repo_path AND r.sha = p.revision_sha + WHERE p.repo_path = ?1 AND (p.path = ?2 OR p.old_path = ?2) + ORDER BY r.ordinal DESC LIMIT ?3", + ) + .map_err(|error| format!("Prepare review revision lookup: {error}"))?; + let mut revisions = HashSet::new(); + for file in files { + let remaining = limit.saturating_sub(revisions.len()); + if remaining == 0 { + break; + } + let rows = statement + .query_map(params![repo_path, file, remaining as i64], |row| { + row.get::<_, String>(0) + }) + .map_err(|error| format!("Query review revisions: {error}"))?; + for revision in rows { + revisions.insert(revision.map_err(|error| format!("Read review revision: {error}"))?); + } + } + Ok(revisions) +} + +fn review_event_matches( + event: &StoredHistoryEvent, + entity_ids: &HashSet, + revision_ids: &HashSet, + files: &HashSet, +) -> bool { + if event + .event + .revision_sha + .as_ref() + .is_some_and(|revision| revision_ids.contains(revision)) + { + return true; + } + if event_entities(event) + .iter() + .any(|entity_id| entity_ids.contains(entity_id)) + || entity_ids + .iter() + .any(|entity_id| payload_mentions_entity(&event.payload, entity_id)) + { + return true; + } + event.event.sources.iter().any(|source| { + files + .iter() + .any(|file| history_path_matches(&source.path, file)) + }) || files + .iter() + .any(|file| payload_mentions_path(&event.payload, file)) +} + +fn payload_mentions_path(payload: &Value, file: &str) -> bool { + if ["path", "old_path"] + .iter() + .any(|key| payload.get(*key).and_then(Value::as_str) == Some(file)) + || ["changed_paths", "source_paths"] + .iter() + .any(|key| string_array(payload, key).iter().any(|path| path == file)) + { + return true; + } + payload + .get("path_changes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|change| { + ["path", "old_path"] + .iter() + .any(|key| change.get(*key).and_then(Value::as_str) == Some(file)) + }) +} + +fn history_path_matches(source_path: &str, file: &str) -> bool { + let source_path = source_path.replace('\\', "/"); + let file = file.trim_start_matches("./"); + source_path.trim_start_matches("./") == file || source_path.ends_with(&format!("/{file}")) +} + +fn take_review_events( + events: &[HistoryCausalEvent], + limit: usize, + predicate: impl Fn(&HistoryCausalEvent) -> bool, +) -> Vec { + events + .iter() + .filter(|event| predicate(event)) + .take(limit) + .cloned() + .collect() +} + +fn stage_label(stage: &HistoryCausalStage) -> &'static str { + match stage { + HistoryCausalStage::Intent => "intent", + HistoryCausalStage::Implementation => "implementation", + HistoryCausalStage::Verification => "verification", + HistoryCausalStage::Release => "release", + HistoryCausalStage::Outcome => "outcome", + HistoryCausalStage::Regression => "regression", + HistoryCausalStage::FollowUp => "follow-up", + HistoryCausalStage::Context => "context", + } +} + +mod causal; + +use causal::*; diff --git a/apps/desktop/src-tauri/src/commands/history_query/types.rs b/apps/desktop/src-tauri/src/commands/history_query/types.rs new file mode 100644 index 0000000..62519aa --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_query/types.rs @@ -0,0 +1,120 @@ +use crate::commands::structural_graph::types::{GraphSourceAnchor, GraphTrust}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HistoryCausalSelector { + Event { event_id: String }, + Entity { entity_id: String }, + Revision { revision: String }, + Release { tag: String }, + EpisodeKey { key: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryCausalStage { + Intent, + Implementation, + Verification, + Release, + Outcome, + Regression, + FollowUp, + Context, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryCausalLinkStatus { + Evidenced, + QualifiedLead, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryCausalEvent { + pub id: String, + pub revision_sha: Option, + pub event_kind: String, + pub stage: HistoryCausalStage, + pub summary: String, + pub trust: GraphTrust, + pub origin: String, + pub source_id: String, + pub source_cursor: Option, + pub recorded_at: String, + pub effective_at: Option, + pub entity_id: Option, + pub related_entity_id: Option, + pub relation_kind: Option, + pub episode_keys: Vec, + pub sources: Vec, + pub source_available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryCausalLink { + pub id: String, + pub from_event_id: String, + pub to_event_id: String, + pub relation: String, + pub status: HistoryCausalLinkStatus, + pub trust: GraphTrust, + pub evidence: String, + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryChangeEpisode { + pub id: String, + pub anchor_event_id: String, + pub episode_keys: Vec, + pub events: Vec, + pub links: Vec, + pub qualified_leads: Vec, + pub qualified_lead_events: Vec, + pub stages_present: Vec, + pub gaps: Vec, + pub contradictions: Vec, + pub trust_summary: BTreeMap, + pub started_at: String, + pub ended_at: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryCausalTrace { + pub schema_version: i64, + pub repo_path: String, + pub selector: HistoryCausalSelector, + pub episodes: Vec, + pub indexed_head: String, + pub stale: bool, + pub coverage: Value, + pub gaps: Vec, + pub scanned_events: usize, + pub total_events: usize, + pub truncated: bool, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryReviewSlice { + pub schema_version: i64, + pub repo_path: String, + pub files: Vec, + pub entity_ids: Vec, + pub episodes: Vec, + pub constraints: Vec, + pub verification: Vec, + pub failures: Vec, + pub regressions: Vec, + pub qualified_leads: Vec, + pub gaps: Vec, + pub indexed_head: String, + pub stale: bool, + pub coverage: Value, + pub truncated: bool, +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 94dff8b..7a460cb 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod graph_trust; pub mod history; pub mod history_evidence; pub mod history_graph; +pub mod history_query; pub mod history_summary_graph; pub mod intel; pub mod observability; From b730c1a25dc03fda0c12a524293dba7969d71402 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:45:26 +0530 Subject: [PATCH 044/141] test(history): cover causal evidence queries --- .../src/commands/history_query/service/mod.rs | 3 + .../commands/history_query/service/tests.rs | 481 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_query/service/tests.rs diff --git a/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs b/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs index fdca2ea..50131a4 100644 --- a/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_query/service/mod.rs @@ -689,4 +689,7 @@ fn stage_label(stage: &HistoryCausalStage) -> &'static str { mod causal; +#[cfg(test)] +mod tests; + use causal::*; diff --git a/apps/desktop/src-tauri/src/commands/history_query/service/tests.rs b/apps/desktop/src-tauri/src/commands/history_query/service/tests.rs new file mode 100644 index 0000000..c97b1b5 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_query/service/tests.rs @@ -0,0 +1,481 @@ +use super::*; +use std::collections::HashMap; +use std::fs; + +fn stored_event( + id: &str, + event_kind: &str, + recorded_at: &str, + entity_id: Option<&str>, + revision_sha: Option<&str>, + episode_keys: &[&str], + source_path: Option<&str>, + summary: &str, +) -> StoredHistoryEvent { + let sources = source_path + .map(|path| GraphSourceAnchor { + path: path.to_string(), + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }) + .into_iter() + .collect(); + StoredHistoryEvent { + event: HistoryCausalEvent { + id: id.to_string(), + revision_sha: revision_sha.map(str::to_string), + event_kind: event_kind.to_string(), + stage: classify_stage(event_kind), + summary: summary.to_string(), + trust: GraphTrust::Extracted, + origin: "fixture".to_string(), + source_id: "fixture".to_string(), + source_cursor: None, + recorded_at: recorded_at.to_string(), + effective_at: None, + entity_id: entity_id.map(str::to_string), + related_entity_id: None, + relation_kind: None, + episode_keys: episode_keys.iter().map(|key| (*key).to_string()).collect(), + sources, + source_available: true, + }, + payload: serde_json::json!({ + "summary": summary, + "episode_keys": episode_keys, + }), + explicit_refs: Vec::new(), + } +} + +#[test] +fn explicit_event_references_assemble_a_complete_causal_thread() { + let mut events = vec![ + stored_event( + "intent", + "decision_marker", + "2026-01-01T00:00:00Z", + None, + None, + &["review:7"], + Some("docs/decision.md"), + "instrument signup", + ), + stored_event( + "implementation", + "commit", + "2026-01-01T01:00:00Z", + Some("event:signup"), + Some("abc123"), + &["review:7"], + Some("src/analytics.ts"), + "emit signup event", + ), + stored_event( + "verification", + "synthetic_qa", + "2026-01-01T02:00:00Z", + None, + None, + &["review:7"], + None, + "signup passed", + ), + stored_event( + "release", + "deploy", + "2026-01-01T03:00:00Z", + None, + None, + &["review:7", "deploy:42"], + None, + "deployed production build", + ), + stored_event( + "outcome", + "analytics_provider_delivery", + "2026-01-01T04:00:00Z", + Some("event:signup"), + None, + &["deploy:42"], + None, + "provider received signup", + ), + stored_event( + "regression", + "incident", + "2026-01-01T05:00:00Z", + Some("event:signup"), + None, + &["deploy:42"], + None, + "provider delivery regressed", + ), + stored_event( + "follow-up", + "issue", + "2026-01-01T06:00:00Z", + Some("event:signup"), + None, + &["deploy:42"], + None, + "follow up on dropped delivery", + ), + ]; + for index in 1..events.len() { + let previous_id = events[index - 1].event.id.clone(); + events[index].explicit_refs.push(previous_id); + } + + let (episodes, gaps) = assemble_episodes( + &events, + &HistoryCausalSelector::EpisodeKey { + key: "review:7".to_string(), + }, + 20, + ); + + assert!(gaps.is_empty()); + assert_eq!(episodes.len(), 1); + assert_eq!(episodes[0].events.len(), 7); + assert!(episodes[0].gaps.is_empty()); + assert_eq!( + episodes[0].stages_present, + vec![ + HistoryCausalStage::Intent, + HistoryCausalStage::Implementation, + HistoryCausalStage::Verification, + HistoryCausalStage::Release, + HistoryCausalStage::Outcome, + HistoryCausalStage::Regression, + HistoryCausalStage::FollowUp, + ] + ); +} + +#[test] +fn time_and_path_proximity_stays_a_qualified_lead() { + let events = vec![ + stored_event( + "implementation", + "commit", + "2026-01-01T00:00:00Z", + Some("entity:signup"), + Some("abc123"), + &[], + Some("src/analytics.ts"), + "emit signup", + ), + stored_event( + "nearby-review", + "review", + "2026-01-01T00:10:00Z", + None, + None, + &[], + Some("src/analytics.ts"), + "nearby review", + ), + ]; + + let (episodes, _) = assemble_episodes( + &events, + &HistoryCausalSelector::Entity { + entity_id: "entity:signup".to_string(), + }, + 20, + ); + + assert_eq!(episodes[0].events.len(), 1); + assert_eq!(episodes[0].qualified_leads.len(), 1); + assert_eq!(episodes[0].qualified_lead_events[0].id, "nearby-review"); + assert_eq!( + episodes[0].qualified_leads[0].status, + HistoryCausalLinkStatus::QualifiedLead + ); +} + +#[test] +fn shared_revision_and_entity_are_not_evidenced_as_causation() { + let events = vec![ + stored_event( + "implementation", + "commit", + "2026-01-01T00:00:00Z", + Some("entity:signup"), + Some("abc123"), + &[], + None, + "emit signup", + ), + stored_event( + "review", + "review", + "2026-01-01T00:05:00Z", + Some("entity:signup"), + Some("abc123"), + &[], + None, + "review signup", + ), + ]; + let (episodes, _) = assemble_episodes( + &events, + &HistoryCausalSelector::Entity { + entity_id: "entity:signup".to_string(), + }, + 20, + ); + assert_eq!(episodes[0].events.len(), 1); + assert!(episodes[0].links.is_empty()); + assert_eq!(episodes[0].qualified_leads.len(), 1); + assert_eq!( + episodes[0].qualified_leads[0].status, + HistoryCausalLinkStatus::QualifiedLead + ); + assert_eq!(episodes[0].qualified_leads[0].trust, GraphTrust::Inferred); +} + +#[test] +fn unlinked_evidence_remains_separate_and_missing_outcome_is_a_gap() { + let events = vec![ + stored_event( + "implementation", + "commit", + "2026-01-01T00:00:00Z", + Some("entity:signup"), + Some("abc123"), + &[], + Some("src/analytics.ts"), + "emit signup", + ), + stored_event( + "unrelated", + "observed_outcome", + "2026-01-01T00:05:00Z", + None, + None, + &[], + Some("src/billing.ts"), + "billing succeeded", + ), + ]; + + let (episodes, _) = assemble_episodes( + &events, + &HistoryCausalSelector::Entity { + entity_id: "entity:signup".to_string(), + }, + 20, + ); + + assert_eq!(episodes[0].events.len(), 1); + assert!(episodes[0].qualified_leads.is_empty()); + assert!(episodes[0] + .gaps + .iter() + .any(|gap| gap.contains("runtime/provider outcome"))); +} + +#[test] +fn conflicting_qa_results_are_preserved_as_a_contradiction() { + let events = vec![ + stored_event( + "qa-pass", + "synthetic_qa", + "2026-01-01T00:00:00Z", + None, + None, + &["qa-loop:1"], + None, + "browser passed", + ), + stored_event( + "qa-fail", + "synthetic_qa", + "2026-01-01T00:01:00Z", + None, + None, + &["qa-loop:1"], + None, + "browser failed", + ), + ]; + + let (episodes, _) = assemble_episodes( + &events, + &HistoryCausalSelector::EpisodeKey { + key: "qa-loop:1".to_string(), + }, + 20, + ); + + assert_eq!(episodes[0].contradictions.len(), 1); +} + +#[test] +fn rotated_relative_sources_are_reported_unavailable() { + let root = std::env::temp_dir().join(format!("cv-history-query-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("artifacts")).expect("fixture"); + fs::write(root.join("artifacts/present.json"), b"{}").expect("source"); + let canonical = root.canonicalize().expect("canonical"); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, status, created_at, updated_at + ) VALUES (?1, 'fixture', 'ready', '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z')", + params![canonical.to_string_lossy()], + ) + .expect("repository"); + for (id, path) in [ + ("present", "artifacts/present.json"), + ("rotated", "artifacts/rotated.json"), + ] { + let evidence = serde_json::to_string(&vec![GraphSourceAnchor { + path: path.to_string(), + start_line: None, + start_column: None, + end_line: None, + end_column: None, + excerpt: None, + }]) + .expect("evidence"); + connection + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, event_kind, trust, origin, source_id, + payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, 'verification_attempt', 'extracted', 'fixture', + 'fixture', '{}', ?3, '2026-01-01T00:00:00Z')", + params![id, canonical.to_string_lossy(), evidence], + ) + .expect("event"); + } + + let (events, truncated) = + load_event_pool(&connection, &canonical.to_string_lossy(), &canonical, None) + .expect("event pool"); + + assert!(!truncated); + let availability = events + .iter() + .map(|event| (event.event.id.as_str(), event.event.source_available)) + .collect::>(); + assert!(availability["present"]); + assert!(!availability["rotated"]); + fs::remove_dir_all(root).expect("remove fixture"); +} + +#[test] +fn episode_ids_and_bounded_traversal_are_deterministic() { + let events = (0..4) + .map(|index| { + stored_event( + &format!("event-{index}"), + "commit", + &format!("2026-01-01T00:0{index}:00Z"), + None, + None, + &["episode:bounded"], + None, + "bounded", + ) + }) + .collect::>(); + let selector = HistoryCausalSelector::EpisodeKey { + key: "episode:bounded".to_string(), + }; + + let (first, _) = assemble_episodes(&events, &selector, 2); + let (second, _) = assemble_episodes(&events, &selector, 2); + + assert_eq!(first, second); + assert!(first[0].truncated); + assert_eq!(first[0].events.len(), 2); +} + +#[test] +fn review_slice_is_file_scoped_cited_and_prompt_bounded() { + let root = std::env::temp_dir().join(format!("cv-review-history-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("src")).expect("fixture"); + git_text(&root, &["init"]).expect("init"); + git_text(&root, &["config", "user.email", "fixture@example.com"]).expect("email"); + git_text(&root, &["config", "user.name", "Fixture"]).expect("name"); + fs::write( + root.join("src/analytics.ts"), + b"export const track = () => 'signup';\n", + ) + .expect("source"); + git_text(&root, &["add", "src/analytics.ts"]).expect("add"); + git_text(&root, &["commit", "-m", "emit signup analytics"]).expect("commit"); + let canonical = root.canonicalize().expect("canonical"); + let canonical_text = canonical.to_string_lossy().to_string(); + let head = git_text(&canonical, &["rev-parse", "HEAD"]).expect("head"); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, coverage_json, + created_at, updated_at + ) VALUES (?1, 'fixture', ?2, 'ready', '{\"coverage_complete\":true}', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + params![canonical_text, head], + ) + .expect("repository"); + let evidence = serde_json::to_string(&vec![GraphSourceAnchor { + path: "src/analytics.ts".to_string(), + start_line: Some(1), + start_column: None, + end_line: Some(1), + end_column: None, + excerpt: None, + }]) + .expect("evidence"); + connection + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, event_kind, trust, origin, source_id, payload_json, + evidence_json, recorded_at + ) VALUES + ('decision-1', ?1, 'decision_marker', 'extracted', 'fixture', 'fixture', + '{\"summary\":\"track signup\",\"episode_keys\":[\"review:1\"]}', + ?2, '2026-01-01T00:00:00Z'), + ('qa-1', ?1, 'synthetic_qa', 'extracted', 'fixture', 'fixture', + '{\"summary\":\"signup flow passed\",\"episode_keys\":[\"review:1\"]}', + '[]', '2026-01-01T01:00:00Z')", + params![canonical_text, evidence], + ) + .expect("events"); + + let slice = build_review_history_slice( + &connection, + &canonical_text, + &["src/analytics.ts".to_string()], + ) + .expect("review slice"); + let prompt = render_review_history_slice(&slice); + let agent_context = render_agent_history_context(&slice); + + assert!(!slice.stale); + assert_eq!(slice.episodes.len(), 1); + assert_eq!(slice.constraints[0].id, "decision-1"); + assert_eq!(slice.verification[0].id, "qa-1"); + assert!(slice + .gaps + .iter() + .any(|gap| gap.contains("runtime/provider outcome"))); + assert!(prompt.contains("event=decision-1")); + assert!(prompt.contains("event=qa-1")); + assert!(prompt.len() <= 3_500); + assert!(agent_context.contains("history_query.v1 / structural_graph.v3")); + assert!(agent_context.contains("event `decision-1`")); + assert!(agent_context.contains("runtime/provider outcome")); + fs::remove_dir_all(root).expect("remove fixture"); +} From b824ea35f77eb7702e3b2791232eabe13bcfae20 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:46:27 +0530 Subject: [PATCH 045/141] feat(history): unify temporal graph reads --- .../src/commands/history_read/annotations.rs | 163 +++++++++++++++ .../src/commands/history_read/evidence.rs | 181 ++++++++++++++++ .../src/commands/history_read/explain.rs | 197 ++++++++++++++++++ .../src/commands/history_read/mod.rs | 160 ++++++++++++++ .../src/commands/history_read/search.rs | 124 +++++++++++ .../src/commands/history_read/state.rs | 123 +++++++++++ .../src/commands/history_read/status.rs | 89 ++++++++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + 8 files changed, 1038 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_read/annotations.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_read/evidence.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_read/explain.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_read/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_read/search.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_read/state.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_read/status.rs diff --git a/apps/desktop/src-tauri/src/commands/history_read/annotations.rs b/apps/desktop/src-tauri/src/commands/history_read/annotations.rs new file mode 100644 index 0000000..12d44e0 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/annotations.rs @@ -0,0 +1,163 @@ +use super::*; + +impl<'a> HistoryReadService<'a> { + pub fn annotations( + &self, + revision_sha: Option<&str>, + entity_id: Option<&str>, + limit: usize, + cursor: Option<(String, String)>, + ) -> Result { + let (cursor_time, cursor_id) = cursor + .map(|(time, id)| (Some(time), Some(id))) + .unwrap_or_default(); + let mut statement = self + .connection + .prepare( + "SELECT id, repo_path, revision_sha, entity_id, author, body, + COALESCE(decision, 'note'), related_event_id, source, created_at + FROM history_graph_annotations + WHERE repo_path = ?1 + AND (?2 IS NULL OR revision_sha = ?2) + AND (?3 IS NULL OR entity_id = ?3) + AND (?4 IS NULL OR created_at < ?4 OR (created_at = ?4 AND id < ?5)) + ORDER BY created_at DESC, id DESC LIMIT ?6", + ) + .map_err(|error| format!("Prepare history annotation query: {error}"))?; + let rows = statement + .query_map( + params![ + self.repo_path, + revision_sha, + entity_id, + cursor_time, + cursor_id, + (limit + 1) as i64 + ], + |row| { + let decision: String = row.get(6)?; + Ok(HistoryAnnotation { + id: row.get(0)?, + repo_path: row.get(1)?, + revision_sha: row.get(2)?, + entity_id: row.get(3)?, + author: row.get(4)?, + body: row.get(5)?, + decision: HistoryAnnotationDecision::from_storage(&decision), + related_event_id: row.get(7)?, + source: row.get(8)?, + created_at: row.get(9)?, + }) + }, + ) + .map_err(|error| format!("Query history annotations: {error}"))?; + let mut annotations = rows + .collect::, _>>() + .map_err(|error| format!("Read history annotations: {error}"))?; + let truncated = annotations.len() > limit; + annotations.truncate(limit); + let next_cursor = truncated + .then(|| annotations.last()) + .flatten() + .map(|annotation| { + serde_json::to_string(&(annotation.created_at.as_str(), annotation.id.as_str())) + .map_err(|error| format!("Encode annotation cursor: {error}")) + }) + .transpose()?; + Ok(HistoryAnnotationPage { + annotations, + truncated, + next_cursor, + }) + } + + pub(super) fn persisted_path_changes( + &self, + revision: &str, + ) -> Result, String> { + let mut statement = self + .connection + .prepare( + "SELECT path, change_kind, old_path, additions, deletions + FROM history_graph_revision_paths + WHERE repo_path = ?1 AND revision_sha = ?2 ORDER BY path", + ) + .map_err(|error| format!("Prepare path changes: {error}"))?; + let rows = statement + .query_map(params![self.repo_path, revision], |row| { + Ok(crate::commands::history_graph::HistoryPathChange { + path: row.get(0)?, + change_kind: row.get(1)?, + old_path: row.get(2)?, + additions: row + .get::<_, Option>(3)? + .map(|value| value.max(0) as usize), + deletions: row + .get::<_, Option>(4)? + .map(|value| value.max(0) as usize), + }) + }) + .map_err(|error| format!("Query path changes: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("Read path changes: {error}")) + } + + pub(super) fn latest_path_change( + &self, + path: &str, + ) -> Result, String> { + self.connection + .query_row( + "SELECT r.sha, r.committed_at + FROM history_graph_revision_paths p + JOIN history_graph_revisions r + ON r.repo_path = p.repo_path AND r.sha = p.revision_sha + WHERE p.repo_path = ?1 AND (p.path = ?2 OR p.old_path = ?2) + ORDER BY r.ordinal DESC LIMIT 1", + params![self.repo_path, path], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| format!("Load last path change: {error}")) + } + + pub(super) fn ordinal_range(&self, before: &str, after: &str) -> Result<(i64, i64), String> { + let before_ordinal = self.ordinal(before)?; + let after_ordinal = self.ordinal(after)?; + if before_ordinal > after_ordinal { + return Err("The before selector must precede the after selector".to_string()); + } + Ok((before_ordinal, after_ordinal)) + } + + pub(super) fn ordinal(&self, revision: &str) -> Result { + self.connection + .query_row( + "SELECT ordinal FROM history_graph_revisions WHERE repo_path = ?1 AND sha = ?2", + params![self.repo_path, revision], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("Load history ordinal: {error}"))? + .ok_or_else(|| "Selected revision is outside indexed history coverage".to_string()) + } + + pub(super) fn paths_in_range(&self, before: i64, after: i64) -> Result, String> { + let mut statement = self + .connection + .prepare( + "SELECT DISTINCT p.path + FROM history_graph_revision_paths p + JOIN history_graph_revisions r + ON r.repo_path = p.repo_path AND r.sha = p.revision_sha + WHERE p.repo_path = ?1 AND r.ordinal > ?2 AND r.ordinal <= ?3 + ORDER BY p.path LIMIT 501", + ) + .map_err(|error| format!("Prepare comparison paths: {error}"))?; + let rows = statement + .query_map(params![self.repo_path, before, after], |row| row.get(0)) + .map_err(|error| format!("Query comparison paths: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("Read comparison paths: {error}")) + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_read/evidence.rs b/apps/desktop/src-tauri/src/commands/history_read/evidence.rs new file mode 100644 index 0000000..925c7d9 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/evidence.rs @@ -0,0 +1,181 @@ +use super::*; + +impl<'a> HistoryReadService<'a> { + pub fn trace( + &self, + selector: HistoryCausalSelector, + limit: usize, + cursor: Option<(String, String)>, + ) -> Result { + query_causal_trace( + self.connection, + &self.root, + &self.current_head, + selector, + limit, + cursor, + ) + } + + pub fn compare( + &self, + before: HistoryTemporalReference, + after: HistoryTemporalReference, + ) -> Result { + let before_revision = resolve_temporal_reference(&self.root, &before)?; + let after_revision = resolve_temporal_reference(&self.root, &after)?; + let before_snapshot = reconstruct_history_as_of( + self.connection, + &self.repo_path, + &self.storage_key, + &before_revision, + )? + .ok_or_else(|| { + "The before state is unavailable in the persisted history index".to_string() + })?; + let after_snapshot = reconstruct_history_as_of( + self.connection, + &self.repo_path, + &self.storage_key, + &after_revision, + )? + .ok_or_else(|| { + "The after state is unavailable in the persisted history index".to_string() + })?; + let structural = query::diff_snapshots(&before_snapshot, &after_snapshot); + let (before_ordinal, after_ordinal) = + self.ordinal_range(&before_revision, &after_revision)?; + let mut statement = self + .connection + .prepare( + "SELECT e.event_kind, COUNT(*) + FROM history_graph_events e + LEFT JOIN history_graph_revisions r + ON r.repo_path = e.repo_path AND r.sha = e.revision_sha + WHERE e.repo_path = ?1 AND r.ordinal > ?2 AND r.ordinal <= ?3 + GROUP BY e.event_kind ORDER BY e.event_kind", + ) + .map_err(|error| format!("Prepare comparison evidence: {error}"))?; + let event_kind_counts = statement + .query_map( + params![self.repo_path, before_ordinal, after_ordinal], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?.max(0) as usize, + )) + }, + ) + .map_err(|error| format!("Query comparison evidence: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Read comparison evidence: {error}"))?; + let mut changed_paths = self.paths_in_range(before_ordinal, after_ordinal)?; + let truncated = changed_paths.len() > 500; + changed_paths.truncate(500); + let (indexed_head, stale, coverage) = + history_index_freshness(self.connection, &self.repo_path, &self.current_head)?; + let mut gaps = Vec::new(); + if !coverage + .get("coverage_complete") + .and_then(Value::as_bool) + .unwrap_or(false) + { + gaps.push("Comparison is bounded by partial indexed history coverage".to_string()); + } + gaps.push( + "Event adjacency is a delta inventory, not proof that one event caused another" + .to_string(), + ); + Ok(HistoryComparison { + schema_version: 1, + before, + after, + before_revision, + after_revision, + structural, + changed_paths, + event_kind_counts, + gaps, + stale, + indexed_head: Some(indexed_head), + truncated, + }) + } + + pub fn evidence(&self, ids: &[String]) -> Result, String> { + let mut details = Vec::new(); + for id in ids { + let row = self + .connection + .query_row( + "SELECT event_kind, revision_sha, entity_id, related_entity_id, + relation_kind, trust, origin, source_id, source_cursor, + payload_json, evidence_json, recorded_at + FROM history_graph_events WHERE repo_path = ?1 AND id = ?2", + params![self.repo_path, id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + row.get::<_, String>(11)?, + )) + }, + ) + .optional() + .map_err(|error| format!("Load history evidence: {error}"))?; + let Some(( + event_kind, + revision_sha, + entity_id, + related_entity_id, + relation_kind, + trust, + origin, + source_id, + source_cursor, + payload_json, + evidence_json, + recorded_at, + )) = row + else { + continue; + }; + let payload: Value = serde_json::from_str(&payload_json).unwrap_or(Value::Null); + let summary = ["summary", "subject", "decision", "status", "outcome"] + .iter() + .find_map(|key| payload.get(key).and_then(Value::as_str)) + .map(|value| value.chars().take(800).collect::()); + let mut sources: Vec = + serde_json::from_str(&evidence_json).unwrap_or_default(); + sources.truncate(20); + let available = sources.iter().all(source_is_available); + details.push(HistoryEvidenceDetail { + schema_version: 1, + id: id.clone(), + event_kind, + revision_sha, + entity_id, + related_entity_id, + relation_kind, + trust: GraphTrust::from_storage(&trust), + origin, + source_id, + source_cursor, + summary, + sources, + recorded_at, + available, + }); + } + Ok(details) + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_read/explain.rs b/apps/desktop/src-tauri/src/commands/history_read/explain.rs new file mode 100644 index 0000000..ac8d56b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/explain.rs @@ -0,0 +1,197 @@ +use super::*; + +impl<'a> HistoryReadService<'a> { + pub fn explain( + &self, + entity: &str, + reference: HistoryTemporalReference, + ) -> Result { + let revision = resolve_temporal_reference(&self.root, &reference)?; + let snapshot = reconstruct_history_as_of( + self.connection, + &self.repo_path, + &self.storage_key, + &revision, + )? + .ok_or_else(|| "Historical state is unavailable in the persisted index".to_string())?; + let node = query::resolve_node(&snapshot, entity)?.clone(); + let node_path = node.path.clone().unwrap_or_default(); + let related_edges = snapshot + .edges + .iter() + .filter(|edge| edge.from == node.id || edge.to == node.id) + .collect::>(); + let latest_change = self + .connection + .query_row( + "SELECT r.sha, r.subject, r.committed_at + FROM history_graph_revision_paths p + JOIN history_graph_revisions r + ON r.repo_path = p.repo_path AND r.sha = p.revision_sha + WHERE p.repo_path = ?1 AND (p.path = ?2 OR p.old_path = ?2) + ORDER BY r.ordinal DESC LIMIT 1", + params![self.repo_path, node_path], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional() + .map_err(|error| format!("Load entity intent evidence: {error}"))?; + let first_change = self + .connection + .query_row( + "SELECT r.sha, r.committed_at + FROM history_graph_revision_paths p + JOIN history_graph_revisions r + ON r.repo_path = p.repo_path AND r.sha = p.revision_sha + WHERE p.repo_path = ?1 AND (p.path = ?2 OR p.old_path = ?2) + ORDER BY r.ordinal ASC LIMIT 1", + params![self.repo_path, node_path], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| format!("Load first entity change: {error}"))?; + let mut facets = Vec::new(); + facets.push(HistoryFacet { + name: "what".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!( + "{} '{}' is present at this historical state", + node.kind, node.label + ), + trust: node.trust, + sources: node.sources.clone(), + event_ids: Vec::new(), + }); + facets.push(match latest_change { + Some((sha, subject, _)) => HistoryFacet { + name: "why".to_string(), + status: HistoryFacetStatus::QualifiedLead, + summary: format!("Latest path-changing commit says: {subject}"), + trust: GraphTrust::Inferred, + sources: node.sources.clone(), + event_ids: vec![sha], + }, + None => unknown_facet("why", "No local intent evidence is linked to this entity"), + }); + facets.push(match (first_change, self.latest_path_change(&node_path)?) { + (Some((first_sha, first_at)), Some((last_sha, last_at))) => HistoryFacet { + name: "when".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!("First observed at {first_at}; last changed at {last_at}"), + trust: GraphTrust::Extracted, + sources: node.sources.clone(), + event_ids: vec![first_sha, last_sha], + }, + _ => unknown_facet("when", "No bounded path history is indexed for this entity"), + }); + let mut relation_kinds = related_edges + .iter() + .map(|edge| edge.kind.clone()) + .collect::>(); + relation_kinds.sort(); + relation_kinds.dedup(); + facets.push(if relation_kinds.is_empty() { + unknown_facet("how", "No structural relationships explain this entity") + } else { + HistoryFacet { + name: "how".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!("Structural relationships: {}", relation_kinds.join(", ")), + trust: weakest_trust(related_edges.iter().map(|edge| edge.trust)), + sources: related_edges + .iter() + .flat_map(|edge| edge.sources.iter().cloned()) + .take(20) + .collect(), + event_ids: Vec::new(), + } + }); + let verification = related_edges + .iter() + .filter(|edge| { + matches!( + edge.kind.as_str(), + "tests" | "tested_by" | "verifies" | "covered_by" + ) + }) + .collect::>(); + facets.push(if verification.is_empty() { + unknown_facet( + "verification", + "No source-backed verification relationship is linked", + ) + } else { + HistoryFacet { + name: "verification".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!( + "{} verification relationship(s) are linked", + verification.len() + ), + trust: weakest_trust(verification.iter().map(|edge| edge.trust)), + sources: verification + .iter() + .flat_map(|edge| edge.sources.iter().cloned()) + .take(20) + .collect(), + event_ids: Vec::new(), + } + }); + let outcomes = load_outcome_events(self.connection, &self.repo_path, &node.id)?; + facets.push(if outcomes.is_empty() { + unknown_facet( + "outcome", + if node.kind == "analytics_event" { + "Code emission is evidenced, but provider ingestion/delivery is unknown without configured provider evidence" + } else { + "No local runtime, deploy, incident, analytics, or observed outcome is linked" + }, + ) + } else { + HistoryFacet { + name: "outcome".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!("{} observed outcome event(s) are linked", outcomes.len()), + trust: weakest_trust(outcomes.iter().map(|(_, _, trust)| *trust)), + sources: Vec::new(), + event_ids: outcomes.into_iter().map(|(id, _, _)| id).collect(), + } + }); + let gaps = facets + .iter() + .filter(|facet| facet.status == HistoryFacetStatus::Unknown) + .map(|facet| format!("{}: {}", facet.name, facet.summary)) + .collect::>(); + let contradictions = + load_entity_annotation_contradictions(self.connection, &self.repo_path, &node.id)?; + let mut trust_summary = BTreeMap::new(); + for facet in &facets { + *trust_summary + .entry(facet.trust.as_str().to_string()) + .or_insert(0usize) += 1; + } + let (indexed_head, stale, _) = + history_index_freshness(self.connection, &self.repo_path, &self.current_head)?; + Ok(HistoryFacetPacket { + schema_version: 1, + repo_path: self.repo_path.clone(), + as_of_revision: revision, + entity_id: node.id, + entity_label: node.label, + entity_kind: node.kind, + facets, + gaps, + contradictions, + trust_summary, + stale, + indexed_head, + truncated: false, + next_cursor: None, + }) + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_read/mod.rs b/apps/desktop/src-tauri/src/commands/history_read/mod.rs new file mode 100644 index 0000000..ce98c7d --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/mod.rs @@ -0,0 +1,160 @@ +//! Read-only release-history query service shared by Tauri and MCP. +//! +//! This is the only layer in the MCP path allowed to understand graph/history +//! persistence. The protocol adapter maps typed inputs and outputs only. + +use crate::commands::{ + history_graph::{ + canonical_repo_path, git_text, history_index_freshness, history_storage_key, + load_entity_annotation_contradictions, load_entity_occurrences, load_history_revisions, + load_lineage_family, load_outcome_events, reconstruct_history_as_of, + repository_tag_fingerprint, resolve_temporal_reference, HistoryAnnotation, + HistoryAnnotationDecision, HistoryAnnotationPage, HistoryAsOfState, HistoryEntityEvolution, + HistoryFacet, HistoryFacetPacket, HistoryFacetStatus, HistoryGraphStatus, + HistorySearchResult, HistoryStructuralState, HistoryTemporalReference, + }, + history_query::{query_causal_trace, HistoryCausalSelector, HistoryCausalTrace}, + structural_graph::{ + query::{self, GraphSnapshotDiff}, + types::{GraphSourceAnchor, GraphTrust}, + }, +}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistorySearchKind { + Release, + Commit, + Entity, + Event, + Annotation, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistorySearchItem { + pub kind: HistorySearchKind, + pub id: String, + pub label: String, + pub summary: String, + pub revision: Option, + pub recorded_at: Option, + pub trust: GraphTrust, + pub source_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryUnifiedSearch { + pub schema_version: i64, + pub items: Vec, + pub truncated: bool, + pub next_offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryComparison { + pub schema_version: i64, + pub before: HistoryTemporalReference, + pub after: HistoryTemporalReference, + pub before_revision: String, + pub after_revision: String, + pub structural: GraphSnapshotDiff, + pub changed_paths: Vec, + pub event_kind_counts: BTreeMap, + pub gaps: Vec, + pub stale: bool, + pub indexed_head: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryEvidenceDetail { + pub schema_version: i64, + pub id: String, + pub event_kind: String, + pub revision_sha: Option, + pub entity_id: Option, + pub related_entity_id: Option, + pub relation_kind: Option, + pub trust: GraphTrust, + pub origin: String, + pub source_id: String, + pub source_cursor: Option, + pub summary: Option, + pub sources: Vec, + pub recorded_at: String, + pub available: bool, +} + +pub struct HistoryReadService<'a> { + connection: &'a Connection, + root: PathBuf, + repo_path: String, + storage_key: String, + current_head: String, +} + +impl<'a> HistoryReadService<'a> { + pub fn new(connection: &'a Connection, repo_path: &str) -> Result { + let root = canonical_repo_path(repo_path)?; + let current_head = git_text(&root, &["rev-parse", "HEAD"])?; + Self::new_with_current_head(connection, root, current_head) + } + + pub fn new_with_current_head( + connection: &'a Connection, + root: PathBuf, + current_head: String, + ) -> Result { + let repo_path = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&repo_path); + Ok(Self { + connection, + root, + repo_path, + storage_key, + current_head, + }) + } +} + +mod annotations; +mod evidence; +mod explain; +mod search; +mod state; +mod status; + +pub(super) fn unknown_facet(name: &str, summary: &str) -> HistoryFacet { + HistoryFacet { + name: name.to_string(), + status: HistoryFacetStatus::Unknown, + summary: summary.to_string(), + trust: GraphTrust::Inferred, + sources: Vec::new(), + event_ids: Vec::new(), + } +} + +pub(super) fn weakest_trust(values: impl Iterator) -> GraphTrust { + values + .max_by_key(|trust| match trust { + GraphTrust::Extracted => 0, + GraphTrust::Inferred => 1, + GraphTrust::Ambiguous => 2, + GraphTrust::Legacy => 3, + }) + .unwrap_or(GraphTrust::Inferred) +} + +pub(super) fn source_is_available(source: &GraphSourceAnchor) -> bool { + if source.path.is_empty() { + true + } else { + PathBuf::from(&source.path).exists() + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_read/search.rs b/apps/desktop/src-tauri/src/commands/history_read/search.rs new file mode 100644 index 0000000..8db9285 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/search.rs @@ -0,0 +1,124 @@ +use super::*; + +impl<'a> HistoryReadService<'a> { + pub fn search( + &self, + text: &str, + limit: usize, + offset: usize, + ) -> Result { + let needle = text.trim().to_lowercase(); + if needle.is_empty() { + return Err("A non-empty history search query is required".to_string()); + } + let fetch_limit = limit.saturating_add(offset).saturating_add(1).clamp(1, 501); + let mut items = Vec::new(); + for revision in load_history_revisions( + self.connection, + &self.repo_path, + Some(&needle), + false, + fetch_limit, + )? + .revisions + { + items.push(HistorySearchItem { + kind: if revision.is_release { + HistorySearchKind::Release + } else { + HistorySearchKind::Commit + }, + id: revision.sha.clone(), + label: revision + .tags + .first() + .cloned() + .unwrap_or_else(|| revision.short_sha.clone()), + summary: revision.subject, + revision: Some(revision.sha), + recorded_at: Some(revision.committed_at), + trust: GraphTrust::Extracted, + source_ids: vec!["git".to_string()], + }); + } + if let Some(snapshot) = reconstruct_history_as_of( + self.connection, + &self.repo_path, + &self.storage_key, + &self.current_head, + )? { + for hit in + query::search(&snapshot, &needle, &Default::default(), Some(fetch_limit)).hits + { + items.push(HistorySearchItem { + kind: HistorySearchKind::Entity, + id: hit.node.id, + label: hit.node.label, + summary: format!("{} · {}", hit.node.kind, hit.matched_by), + revision: snapshot.repo_head.clone(), + recorded_at: Some(snapshot.created_at.clone()), + trust: hit.node.trust, + source_ids: hit + .node + .sources + .iter() + .map(|source| source.path.clone()) + .collect(), + }); + } + } + let like = format!("%{needle}%"); + let mut statement = self + .connection + .prepare( + "SELECT id, event_kind, revision_sha, entity_id, trust, source_id, recorded_at + FROM history_graph_events + WHERE repo_path = ?1 AND ( + lower(event_kind) LIKE ?2 OR lower(COALESCE(entity_id, '')) LIKE ?2 OR + lower(COALESCE(related_entity_id, '')) LIKE ?2 OR lower(source_id) LIKE ?2 + ) + ORDER BY recorded_at DESC, id DESC LIMIT ?3", + ) + .map_err(|error| format!("Prepare evidence search: {error}"))?; + let rows = statement + .query_map(params![self.repo_path, like, fetch_limit as i64], |row| { + Ok(HistorySearchItem { + kind: HistorySearchKind::Event, + id: row.get(0)?, + label: row.get(1)?, + summary: row + .get::<_, Option>(3)? + .unwrap_or_else(|| "Historical evidence".to_string()), + revision: row.get(2)?, + trust: GraphTrust::from_storage(&row.get::<_, String>(4)?), + source_ids: vec![row.get(5)?], + recorded_at: Some(row.get(6)?), + }) + }) + .map_err(|error| format!("Query evidence search: {error}"))?; + items.extend( + rows.collect::, _>>() + .map_err(|error| format!("Read evidence search: {error}"))?, + ); + items.sort_by(|left, right| { + right + .recorded_at + .cmp(&left.recorded_at) + .then_with(|| left.id.cmp(&right.id)) + }); + items.dedup_by(|left, right| left.kind == right.kind && left.id == right.id); + let available = items.len().saturating_sub(offset); + let truncated = available > limit; + let items = items + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + Ok(HistoryUnifiedSearch { + schema_version: 1, + next_offset: truncated.then(|| offset + items.len()), + items, + truncated, + }) + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_read/state.rs b/apps/desktop/src-tauri/src/commands/history_read/state.rs new file mode 100644 index 0000000..1385711 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/state.rs @@ -0,0 +1,123 @@ +use super::*; + +impl<'a> HistoryReadService<'a> { + pub fn state( + &self, + reference: HistoryTemporalReference, + max_nodes: usize, + ) -> Result { + let revision = resolve_temporal_reference(&self.root, &reference)?; + let committed_at = git_text(&self.root, &["show", "-s", "--format=%cI", &revision])?; + let snapshot = reconstruct_history_as_of( + self.connection, + &self.repo_path, + &self.storage_key, + &revision, + )? + .ok_or_else(|| { + "Historical state is unavailable in the persisted index; build or refresh it in CodeVetter" + .to_string() + })?; + let path_changes = self.persisted_path_changes(&revision)?; + let mut changed_paths = path_changes + .iter() + .map(|change| change.path.clone()) + .collect::>(); + changed_paths.sort(); + Ok(HistoryAsOfState { + requested: reference, + resolved_revision: revision.clone(), + committed_at, + exact: true, + state: HistoryStructuralState { + schema_version: 1, + repo_path: self.repo_path.clone(), + revision, + snapshot_id: snapshot.id.clone(), + cached: true, + projection: query::overview(&snapshot, Some(max_nodes)), + analysis: query::analysis_summary(&snapshot), + changed_paths, + path_changes, + indexed_files: snapshot.coverage.indexed_files, + node_count: snapshot.nodes.len(), + edge_count: snapshot.edges.len(), + generated_at: snapshot.created_at, + }, + }) + } + + pub fn lineage( + &self, + entity: &str, + reference: HistoryTemporalReference, + limit: usize, + ) -> Result { + let revision = resolve_temporal_reference(&self.root, &reference)?; + let snapshot = reconstruct_history_as_of( + self.connection, + &self.repo_path, + &self.storage_key, + &revision, + )? + .ok_or_else(|| "Historical state is unavailable in the persisted index".to_string())?; + let node = query::resolve_node(&snapshot, entity)?.clone(); + let (mut lineage, family_ids, lineage_truncated) = + load_lineage_family(self.connection, &self.repo_path, &node.id, limit)?; + if lineage.len() > limit { + lineage.truncate(limit); + } + let (mut occurrences, occurrence_truncated) = + load_entity_occurrences(self.connection, &self.repo_path, &family_ids, limit * 4)?; + if occurrences.len() > limit * 4 { + occurrences.truncate(limit * 4); + } + let first_seen = occurrences.first().cloned(); + let last_present = occurrences.last().cloned(); + let mut last_changed = None; + let mut previous_signature = None; + for occurrence in &occurrences { + let signature = ( + occurrence.entity_id.as_str(), + occurrence.label.as_str(), + occurrence.path.as_deref(), + occurrence.detail.as_deref(), + ); + if previous_signature != Some(signature) { + last_changed = Some(occurrence.clone()); + } + previous_signature = Some(signature); + } + let (indexed_head, stale, coverage) = + history_index_freshness(self.connection, &self.repo_path, &self.current_head)?; + let coverage_complete = coverage + .get("coverage_complete") + .and_then(Value::as_bool) + .unwrap_or(false); + let truncated = lineage_truncated || occurrence_truncated; + Ok(HistoryEntityEvolution { + schema_version: 1, + repo_path: self.repo_path.clone(), + resolved_revision: revision, + entity_id: node.id, + entity_label: node.label, + entity_kind: node.kind, + lineage, + occurrences, + first_seen, + last_changed, + last_present, + indexed_head, + stale, + coverage_gap: if truncated { + Some("Entity evolution exceeded the requested bound".to_string()) + } else if !coverage_complete { + Some("First/last moments are bounded by indexed history coverage".to_string()) + } else { + None + }, + truncated, + next_cursor: None, + }) + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_read/status.rs b/apps/desktop/src-tauri/src/commands/history_read/status.rs new file mode 100644 index 0000000..c82c411 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/status.rs @@ -0,0 +1,89 @@ +use super::*; + +impl<'a> HistoryReadService<'a> { + pub fn status(&self) -> Result { + let current_tags = repository_tag_fingerprint(&self.root).ok(); + self.status_with_tag_fingerprint(current_tags.as_deref()) + } + + pub fn status_with_tag_fingerprint( + &self, + current_tags: Option<&str>, + ) -> Result { + let stored = self + .connection + .query_row( + "SELECT indexed_head, indexed_tags_fingerprint, coverage_json, updated_at, + (SELECT COUNT(*) FROM history_graph_checkpoints c WHERE c.repo_path = r.repo_path), + (SELECT COUNT(*) FROM history_graph_events e WHERE e.repo_path = r.repo_path) + FROM history_graph_repositories r WHERE repo_path = ?1", + [&self.repo_path], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + }, + ) + .optional() + .map_err(|error| format!("Load history status: {error}"))?; + let (indexed_head, indexed_tags, coverage, updated_at, checkpoints, events) = stored + .map(|(head, tags, coverage, updated, checkpoints, events)| { + ( + head, + tags, + serde_json::from_str(&coverage).unwrap_or(Value::Object(Default::default())), + updated, + checkpoints.max(0) as usize, + events.max(0) as usize, + ) + }) + .unwrap_or((None, None, Value::Object(Default::default()), None, 0, 0)); + let tags_stale = current_tags + .zip(indexed_tags.as_deref()) + .is_some_and(|(current, indexed)| current != indexed); + Ok(HistoryGraphStatus { + repo_path: self.repo_path.clone(), + indexed: indexed_head.is_some(), + backfilling: false, + stale: indexed_head.as_deref() != Some(self.current_head.as_str()) || tags_stale, + current_head: self.current_head.clone(), + indexed_head, + checkpoint_count: checkpoints, + event_count: events, + coverage, + updated_at, + }) + } + + pub fn current_head(&self) -> &str { + &self.current_head + } + + pub fn list_releases(&self, limit: usize) -> Result { + self.list_releases_page(limit, 0) + } + + pub fn list_releases_page( + &self, + limit: usize, + offset: usize, + ) -> Result { + let fetch_limit = limit.saturating_add(offset).saturating_add(1).min(501); + let mut result = + load_history_revisions(self.connection, &self.repo_path, None, true, fetch_limit)?; + let available = result.revisions.len(); + result.revisions = result + .revisions + .into_iter() + .skip(offset) + .take(limit) + .collect(); + result.truncated = available > offset.saturating_add(result.revisions.len()); + Ok(result) + } +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 7a460cb..b6d5717 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod history; pub mod history_evidence; pub mod history_graph; pub mod history_query; +pub mod history_read; pub mod history_summary_graph; pub mod intel; pub mod observability; From 3cc559e5cc3465af2bc51e02406da22777d4578c Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:46:53 +0530 Subject: [PATCH 046/141] test(history): cover unified temporal reads --- .../src/commands/history_read/mod.rs | 3 + .../src/commands/history_read/tests.rs | 58 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/history_read/tests.rs diff --git a/apps/desktop/src-tauri/src/commands/history_read/mod.rs b/apps/desktop/src-tauri/src/commands/history_read/mod.rs index ce98c7d..7e91838 100644 --- a/apps/desktop/src-tauri/src/commands/history_read/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_read/mod.rs @@ -158,3 +158,6 @@ pub(super) fn source_is_available(source: &GraphSourceAnchor) -> bool { PathBuf::from(&source.path).exists() } } + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/commands/history_read/tests.rs b/apps/desktop/src-tauri/src/commands/history_read/tests.rs new file mode 100644 index 0000000..80f0c5e --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_read/tests.rs @@ -0,0 +1,58 @@ +use super::*; +use std::fs; + +#[test] +fn evidence_hydration_returns_only_selected_bounded_fields() { + let root = std::env::temp_dir().join(format!("cv-history-read-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture"); + run_git(&root, &["init"]); + run_git(&root, &["config", "user.email", "fixture@local"]); + run_git(&root, &["config", "user.name", "Fixture"]); + fs::write(root.join("main.rs"), "fn main() {}\n").expect("file"); + run_git(&root, &["add", "."]); + run_git(&root, &["commit", "-m", "initial"]); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let canonical = root + .canonicalize() + .expect("canonical") + .to_string_lossy() + .to_string(); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, + created_at, updated_at + ) VALUES (?1, 'fixture', 'head', 'ready', '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z')", + [&canonical], + ) + .expect("repo"); + connection + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, event_kind, trust, origin, source_id, + payload_json, evidence_json, recorded_at + ) VALUES ('event', ?1, 'verification', 'extracted', 'metadata', 'test', + '{\"summary\":\"passed\",\"secret\":\"must-not-return\"}', '[]', + '2026-01-01T00:00:00Z')", + [&canonical], + ) + .expect("event"); + let service = HistoryReadService::new(&connection, &canonical).expect("service"); + let details = service.evidence(&["event".to_string()]).expect("evidence"); + let encoded = serde_json::to_string(&details).expect("json"); + assert!(encoded.contains("passed")); + assert!(!encoded.contains("must-not-return")); + let _ = fs::remove_dir_all(root); +} + +fn run_git(root: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .status() + .expect("git"); + assert!(status.success()); +} From d5a588ff2c807cc92e6110837c42152ccfe093f1 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:48:01 +0530 Subject: [PATCH 047/141] feat(history): expose temporal graph service --- .../src/commands/history_graph/api.rs | 799 ++++++++++++++++++ .../src/commands/history_graph/mod.rs | 36 +- 2 files changed, 821 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/history_graph/api.rs diff --git a/apps/desktop/src-tauri/src/commands/history_graph/api.rs b/apps/desktop/src-tauri/src/commands/history_graph/api.rs new file mode 100644 index 0000000..262c303 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_graph/api.rs @@ -0,0 +1,799 @@ +use super::*; +use crate::commands::history_read::HistoryReadService; + +#[tauri::command] +pub async fn get_history_timeline( + repo_path: String, + limit: Option, + _db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + tokio::task::spawn_blocking(move || build_timeline(&root, limit)) + .await + .map_err(|error| format!("History timeline worker failed: {error}"))? +} + +#[tauri::command] +pub async fn backfill_history_graph( + repo_path: String, + recent_commit_limit: Option, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let canonical = root.to_string_lossy().to_string(); + let storage_key = history_storage_key(&canonical); + let cancellation = StructuralGraphCancellation::default(); + { + let mut active = active_history_backfills() + .lock() + .map_err(|_| "History backfill registry is unavailable".to_string())?; + if active.contains_key(&canonical) { + return Err("A history backfill is already running for this repository".to_string()); + } + active.insert(canonical.clone(), cancellation.clone()); + } + let database = Arc::clone(&db.0); + let cleanup_key = canonical.clone(); + let worker = tokio::task::spawn_blocking(move || { + let recent_limit = recent_commit_limit + .unwrap_or(500) + .clamp(1, MAX_HISTORY_LIMIT); + let timeline = build_timeline(&root, Some(recent_limit))?; + let tag_fingerprint = repository_tag_fingerprint(&root)?; + let (previous_head, previous_tag_fingerprint) = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + connection + .query_row( + "SELECT indexed_head, indexed_tags_fingerprint + FROM history_graph_repositories WHERE repo_path = ?1", + params![canonical], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) + .optional() + .map_err(|error| format!("Load prior history cursor: {error}"))? + .unwrap_or_default() + }; + let rewritten = previous_head.as_deref().is_some_and(|head| { + head != timeline.head && !git_is_ancestor(&root, head, &timeline.head) + }); + let engine_incompatible = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + has_incompatible_history_checkpoints(&connection, &canonical)? + }; + let tags_changed = previous_tag_fingerprint + .as_deref() + .is_some_and(|fingerprint| fingerprint != tag_fingerprint.as_str()); + let fast_forward = previous_head.as_deref().is_some_and(|head| { + head != timeline.head && git_is_ancestor(&root, head, &timeline.head) + }); + let refresh_kind = classify_history_refresh( + previous_head.as_deref(), + rewritten, + engine_incompatible, + fast_forward, + tags_changed, + ) + .to_string(); + let mut invalidated = 0; + { + let mut connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + refresh_builtin_adapters(&mut connection, &root)?; + } + let mut targets = Vec::new(); + let mut seen = HashSet::new(); + if refresh_kind != "no_op" && seen.insert(timeline.head.clone()) { + targets.push(timeline.head.clone()); + } + let releases = reachable_release_revisions(&root)?; + let release_checkpoints = releases.len(); + for revision in releases { + let should_schedule = refresh_kind != "no_op" + && (refresh_kind != "tag_metadata" || { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + !compatible_history_checkpoint_exists(&connection, &canonical, &revision)? + }); + if should_schedule && seen.insert(revision.clone()) { + targets.push(revision); + } + } + let indexed_revisions = timeline + .revisions + .iter() + .map(|revision| revision.sha.as_str()) + .collect::>(); + if refresh_kind != "no_op" { + for revision in &timeline.revisions { + let materialization_parent = revision.parents.first(); + if materialization_parent + .is_none_or(|parent| !indexed_revisions.contains(parent.as_str())) + && seen.insert(revision.sha.clone()) + { + targets.push(revision.sha.clone()); + } + } + } + let checkpoint_total = targets.len(); + let delta_pairs = if matches!( + refresh_kind.as_str(), + "initial" | "rewritten_history" | "engine_repair" | "fast_forward" + ) { + timeline + .revisions + .iter() + .filter_map(|revision| { + revision.parents.first().and_then(|parent| { + indexed_revisions + .contains(parent.as_str()) + .then(|| (parent.clone(), revision.sha.clone())) + }) + }) + .collect::>() + } else { + Vec::new() + }; + let delta_total = delta_pairs.len(); + let total = checkpoint_total + delta_total; + let started = std::time::Instant::now(); + let mut completed = 0; + let mut checkpoint_completed = 0; + let mut delta_completed = 0; + let mut built = 0; + let mut cache_hits = 0; + let checkpoint_targets = targets.iter().cloned().collect::>(); + for revision in &targets { + if cancellation.is_cancelled() { + break; + } + let _ = app.emit( + "history-backfill-progress", + HistoryBackfillProgress { + phase: "checkpoint".to_string(), + completed, + total, + revision: Some(revision.clone()), + detail: "Building exact structural checkpoint from Git objects".to_string(), + eta_ms: estimate_eta_ms(started, completed, total), + }, + ); + let (_, cached) = load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + revision, + &app, + &database, + )?; + if cached { + cache_hits += 1; + } else { + built += 1; + } + completed += 1; + checkpoint_completed += 1; + } + if !cancellation.is_cancelled() { + let mut previous_snapshot: Option<(String, StructuralGraphSnapshot)> = None; + for (before_revision, after_revision) in &delta_pairs { + if cancellation.is_cancelled() { + break; + } + let _ = app.emit( + "history-backfill-progress", + HistoryBackfillProgress { + phase: "delta".to_string(), + completed, + total, + revision: Some(after_revision.clone()), + detail: "Computing structural delta and conservative entity lineage" + .to_string(), + eta_ms: estimate_eta_ms(started, completed, total), + }, + ); + let before = if previous_snapshot + .as_ref() + .is_some_and(|(revision, _)| revision == before_revision) + { + previous_snapshot + .take() + .map(|(_, snapshot)| snapshot) + .expect("checked previous history snapshot") + } else { + load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + before_revision, + &app, + &database, + )? + .0 + }; + let cached_delta = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + load_history_structural_delta( + &connection, + &canonical, + before_revision, + after_revision, + )? + }; + if let Some(delta) = cached_delta.filter(|delta| { + delta.materialization_version == 1 && delta.before_snapshot_id == before.id + }) { + let after = apply_structural_delta(before, &delta)?; + previous_snapshot = Some((after_revision.clone(), after)); + completed += 1; + delta_completed += 1; + cache_hits += 1; + continue; + } + let path_changes = + changed_path_records_between(&root, before_revision, after_revision)?; + let after = if checkpoint_targets.contains(after_revision) { + load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + after_revision, + &app, + &database, + )? + .0 + } else { + build_history_snapshot_from_previous( + &root, + &storage_key, + after_revision, + &before, + &path_changes, + &app, + )? + }; + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + ensure_history_revision(&connection, &root, &canonical, after_revision)?; + compute_and_persist_structural_delta_with_paths( + &connection, + &canonical, + before_revision, + after_revision, + &before, + &after, + path_changes, + )?; + drop(connection); + previous_snapshot = Some((after_revision.clone(), after)); + completed += 1; + delta_completed += 1; + if delta_completed % 4 == 0 { + release_history_allocator_pressure(); + } + } + release_history_allocator_pressure(); + } + let cancelled = cancellation.is_cancelled(); + let coverage_complete = !cancelled && timeline.coverage_complete && completed == total; + if !cancelled { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + persist_timeline_catalog(&connection, &timeline)?; + let publication = connection + .unchecked_transaction() + .map_err(|error| format!("Start history publication transaction: {error}"))?; + invalidated += prune_unreachable_history(&publication, &root, &canonical)?; + invalidated += prune_incompatible_history_checkpoints(&publication, &canonical)?; + let cursor_json = + history_adapter_cursor_json(&publication, &canonical, &timeline.head)?; + publication + .execute( + "UPDATE history_graph_repositories + SET indexed_head = ?2, indexed_tags_fingerprint = ?3, + status = 'ready', cursor_json = ?4, coverage_json = ?5, updated_at = ?6 + WHERE repo_path = ?1", + params![ + canonical, + timeline.head, + tag_fingerprint, + cursor_json, + serde_json::json!({ + "checkpoint_total": checkpoint_total, + "checkpoint_completed": checkpoint_completed, + "checkpoint_cache_hits": cache_hits, + "delta_total": delta_total, + "delta_completed": delta_completed, + "recent_commit_limit": recent_limit, + "is_shallow": timeline.is_shallow, + "history_truncated": timeline.truncated, + "coverage_complete": coverage_complete, + "refresh_kind": refresh_kind.clone(), + "invalidated": invalidated, + }) + .to_string(), + Utc::now().to_rfc3339(), + ], + ) + .map_err(|error| format!("Update history backfill coverage: {error}"))?; + publication + .commit() + .map_err(|error| format!("Publish history backfill: {error}"))?; + } + let _ = app.emit( + "history-backfill-progress", + HistoryBackfillProgress { + phase: if cancelled { "cancelled" } else { "complete" }.to_string(), + completed, + total, + revision: None, + detail: if cancelled { + "Backfill stopped after the current checkpoint" + } else { + "History checkpoints and structural deltas are ready" + } + .to_string(), + eta_ms: Some(0), + }, + ); + Ok(HistoryBackfillResult { + repo_path: canonical, + total, + completed, + built, + cache_hits, + cancelled, + release_checkpoints, + coverage_complete, + refresh_kind, + invalidated, + }) + }) + .await; + if let Ok(mut active) = active_history_backfills().lock() { + active.remove(&cleanup_key); + } + worker.map_err(|error| format!("History backfill worker failed: {error}"))? +} + +#[tauri::command] +pub fn cancel_history_backfill(repo_path: String) -> Result { + let canonical = canonical_repo_path(&repo_path)? + .to_string_lossy() + .to_string(); + let active = active_history_backfills() + .lock() + .map_err(|_| "History backfill registry is unavailable".to_string())?; + if let Some(cancellation) = active.get(&canonical) { + cancellation.cancel(); + Ok(true) + } else { + Ok(false) + } +} + +#[tauri::command] +pub async fn get_history_graph_status( + repo_path: String, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let canonical = root.to_string_lossy().to_string(); + let current_head = git_text(&root, &["rev-parse", "HEAD"])?; + let backfilling = active_history_backfills() + .lock() + .map(|active| active.contains_key(&canonical)) + .unwrap_or(false); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + let service = HistoryReadService::new_with_current_head(&connection, root, current_head)?; + let mut status = service.status()?; + status.backfilling = backfilling; + Ok(status) + }) + .await + .map_err(|error| format!("History status worker failed: {error}"))? +} + +#[tauri::command] +pub async fn explain_history_entity( + repo_path: String, + entity: String, + revision: Option, + app: tauri::AppHandle, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let canonical = root.to_string_lossy().to_string(); + let revision = resolve_revision(&root, revision.as_deref().unwrap_or("HEAD"))?; + let current_head = git_text(&root, &["rev-parse", "HEAD"])?; + let storage_key = history_storage_key(&canonical); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let (snapshot, _) = load_or_build_history_snapshot( + &root, + &canonical, + &storage_key, + &revision, + &app, + &database, + )?; + let node = query::resolve_node(&snapshot, &entity)?.clone(); + let related_edges = snapshot + .edges + .iter() + .filter(|edge| edge.from == node.id || edge.to == node.id) + .collect::>(); + let relation_kinds = { + let mut kinds = related_edges + .iter() + .map(|edge| edge.kind.clone()) + .collect::>(); + kinds.sort(); + kinds.dedup(); + kinds + }; + let path_history = node + .path + .as_deref() + .map(|path| git_path_history(&root, &revision, path)) + .transpose()? + .unwrap_or_default(); + let mut facets = Vec::new(); + facets.push(HistoryFacet { + name: "what".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!( + "{} `{}` is present in the exact structural checkpoint with {} local relationship kinds{}", + node.kind, + node.label, + relation_kinds.len(), + if !relation_kinds.is_empty() { format!(": {}", relation_kinds.join(", ")) } else { Default::default() } + ), + trust: node.trust, + sources: node.sources.clone(), + event_ids: Vec::new(), + }); + if let Some((sha, _, subject)) = path_history.last() { + facets.push(HistoryFacet { + name: "why".to_string(), + status: HistoryFacetStatus::QualifiedLead, + summary: format!( + "Latest path-changing commit {} says: {}. The subject is intent evidence, not proof of runtime behavior.", + &sha[..sha.len().min(8)], subject + ), + trust: GraphTrust::Inferred, + sources: node.sources.clone(), + event_ids: Vec::new(), + }); + } else { + facets.push(unknown_facet( + "why", + "No local intent evidence is linked to this entity", + )); + } + if let (Some(first), Some(last)) = (path_history.first(), path_history.last()) { + facets.push(HistoryFacet { + name: "when".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!( + "The current path first appears in local Git history at {} and was last changed at {}", + first.1, last.1 + ), + trust: GraphTrust::Extracted, + sources: node.sources.clone(), + event_ids: Vec::new(), + }); + } else { + facets.push(unknown_facet( + "when", + "No bounded Git path history is available for this entity", + )); + } + facets.push(if related_edges.is_empty() { + unknown_facet("how", "No structural relationships explain how this entity participates") + } else { + HistoryFacet { + name: "how".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!( + "The local graph connects this entity through: {}", + relation_kinds.join(", ") + ), + trust: if related_edges + .iter() + .all(|edge| edge.trust == GraphTrust::Extracted) + { + GraphTrust::Extracted + } else { + GraphTrust::Inferred + }, + sources: related_edges + .iter() + .flat_map(|edge| edge.sources.iter().cloned()) + .take(20) + .collect(), + event_ids: Vec::new(), + } + }); + let verification_edges = related_edges + .iter() + .filter(|edge| { + matches!( + edge.kind.as_str(), + "tests" | "tested_by" | "verifies" | "covered_by" + ) + }) + .collect::>(); + facets.push(if verification_edges.is_empty() { + unknown_facet( + "verification", + "No source-backed test or verification relationship is linked locally", + ) + } else { + HistoryFacet { + name: "verification".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!( + "{} local verification relationship(s) are linked", + verification_edges.len() + ), + trust: GraphTrust::Inferred, + sources: verification_edges + .iter() + .flat_map(|edge| edge.sources.iter().cloned()) + .collect(), + event_ids: Vec::new(), + } + }); + let (outcomes, contradictions, indexed_head, stale, _) = { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + let outcomes = load_outcome_events(&connection, &canonical, &node.id)?; + let contradictions = + load_entity_annotation_contradictions(&connection, &canonical, &node.id)?; + let (indexed_head, stale, coverage) = + history_index_freshness(&connection, &canonical, ¤t_head)?; + (outcomes, contradictions, indexed_head, stale, coverage) + }; + facets.push(if outcomes.is_empty() { + unknown_facet( + "outcome", + if node.kind == "analytics_event" { + "Code emission is evidenced, but provider ingestion/delivery is unknown without a configured local provider export" + } else { + "No local deploy, runtime, incident, analytics, or observed-outcome evidence is linked" + }, + ) + } else { + HistoryFacet { + name: "outcome".to_string(), + status: HistoryFacetStatus::Evidenced, + summary: format!("{} local observed outcome event(s) are linked", outcomes.len()), + trust: outcomes + .iter() + .map(|(_, _, trust)| *trust) + .min_by_key(|trust| match trust { + GraphTrust::Extracted => 0, + GraphTrust::Inferred => 1, + GraphTrust::Ambiguous => 2, + GraphTrust::Legacy => 3, + }) + .unwrap_or(GraphTrust::Inferred), + sources: Vec::new(), + event_ids: outcomes.into_iter().map(|(id, _, _)| id).collect(), + } + }); + let gaps = facets + .iter() + .filter(|facet| facet.status == HistoryFacetStatus::Unknown) + .map(|facet| format!("{}: {}", facet.name, facet.summary)) + .collect(); + let mut trust_summary = BTreeMap::new(); + for facet in &facets { + *trust_summary + .entry(facet.trust.as_str().to_string()) + .or_default() += 1; + } + Ok(HistoryFacetPacket { + schema_version: 1, + repo_path: canonical, + as_of_revision: revision, + entity_id: node.id, + entity_label: node.label, + entity_kind: node.kind, + facets, + gaps, + contradictions, + trust_summary, + stale, + indexed_head, + truncated: false, + next_cursor: None, + }) + }) + .await + .map_err(|error| format!("History entity explanation worker failed: {error}"))? +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn add_history_annotation( + repo_path: String, + revision_sha: Option, + entity_id: Option, + author: String, + body: String, + decision: HistoryAnnotationDecision, + related_event_id: Option, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let canonical = root.to_string_lossy().to_string(); + let revision_sha = revision_sha + .as_deref() + .map(|revision| resolve_revision(&root, revision)) + .transpose()?; + let author = author.trim().to_string(); + let body = body.trim().to_string(); + if author.is_empty() || author.len() > 120 { + return Err("Annotation author must be between 1 and 120 bytes".to_string()); + } + if body.is_empty() || body.len() > 20_000 { + return Err("Annotation body must be between 1 and 20,000 bytes".to_string()); + } + let entity_id = entity_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let related_event_id = related_event_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let id = format!("annotation:{}", uuid::Uuid::new_v4()); + let event_id = stable_graph_id("history-annotation-event", &id); + let now = Utc::now().to_rfc3339(); + let source = "local_user".to_string(); + let mut connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + let transaction = connection + .transaction() + .map_err(|error| format!("Start annotation transaction: {error}"))?; + transaction + .execute( + "INSERT OR IGNORE INTO history_graph_repositories ( + repo_path, repository_fingerprint, status, created_at, updated_at + ) VALUES (?1, ?2, 'pending', ?3, ?3)", + params![canonical, stable_graph_id("repository", &canonical), now], + ) + .map_err(|error| format!("Ensure annotation repository: {error}"))?; + if let Some(target_event_id) = related_event_id.as_deref() { + let exists = transaction + .query_row( + "SELECT 1 FROM history_graph_events WHERE repo_path = ?1 AND id = ?2", + params![canonical, target_event_id], + |_| Ok(()), + ) + .optional() + .map_err(|error| format!("Validate annotation evidence target: {error}"))? + .is_some(); + if !exists { + return Err( + "The annotation evidence target does not exist in this repository".to_string(), + ); + } + } + transaction + .execute( + "INSERT INTO history_graph_annotations ( + id, repo_path, revision_sha, entity_id, author, body, decision, + related_event_id, source, metadata_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, '{}', ?10)", + params![ + id, + canonical, + revision_sha, + entity_id, + author, + body, + decision.as_str(), + related_event_id, + source, + now, + ], + ) + .map_err(|error| format!("Persist history annotation: {error}"))?; + transaction + .execute( + "INSERT INTO history_graph_events ( + id, repo_path, revision_sha, event_kind, entity_id, trust, origin, + source_id, source_cursor, payload_json, evidence_json, recorded_at + ) VALUES (?1, ?2, ?3, 'user_annotation', ?4, 'extracted', + 'user_annotation', ?5, ?5, ?6, '[]', ?7)", + params![ + event_id, + canonical, + revision_sha, + entity_id, + id, + serde_json::json!({ + "annotation_id": id, + "decision": decision.as_str(), + "summary": body, + "related_event_id": related_event_id, + }) + .to_string(), + now, + ], + ) + .map_err(|error| format!("Append annotation evidence event: {error}"))?; + transaction + .commit() + .map_err(|error| format!("Commit history annotation: {error}"))?; + Ok(HistoryAnnotation { + id, + repo_path: canonical, + revision_sha, + entity_id, + author, + body, + decision, + related_event_id, + source, + created_at: now, + }) + }) + .await + .map_err(|error| format!("History annotation worker failed: {error}"))? +} + +#[tauri::command] +pub async fn list_history_annotations( + repo_path: String, + revision_sha: Option, + entity_id: Option, + limit: Option, + cursor: Option, + db: State<'_, DbState>, +) -> Result { + let root = canonical_repo_path(&repo_path)?; + let limit = limit.unwrap_or(25).clamp(1, 100); + let cursor = cursor + .as_deref() + .map(decode_annotation_cursor) + .transpose()?; + let database = Arc::clone(&db.0); + tokio::task::spawn_blocking(move || { + let connection = database + .lock() + .map_err(|_| "History database is unavailable".to_string())?; + let service = HistoryReadService::new_with_current_head(&connection, root, String::new())?; + service.annotations(revision_sha.as_deref(), entity_id.as_deref(), limit, cursor) + }) + .await + .map_err(|error| format!("History annotation query worker failed: {error}"))? +} + +pub(super) fn decode_annotation_cursor(cursor: &str) -> Result<(String, String), String> { + serde_json::from_str(cursor).map_err(|_| "Invalid history annotation cursor".to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs index 06e6ed9..6cc736f 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -1,4 +1,5 @@ use crate::commands::git_metadata::{is_release_tag, read_git_tags}; +use crate::commands::history_evidence::refresh_builtin_adapters; use crate::commands::structural_graph::analysis::StructuralGraphAnalysisSummary; use crate::commands::structural_graph::extract::{ build_snapshot_from_blob_delta, build_snapshot_from_blobs, HistoricalFileBlob, @@ -403,6 +404,7 @@ pub struct HistoryAnnotationPage { pub next_cursor: Option, } +mod api; mod catalog; mod delta; mod git_objects; @@ -410,27 +412,33 @@ mod query_helpers; mod state; mod storage; -pub(crate) use catalog::canonical_repo_path; -use catalog::git::*; -pub(crate) use catalog::git::{git_text, resolve_revision}; -use catalog::persistence::*; -pub(crate) use catalog::repository_tag_fingerprint; -use catalog::*; -pub use catalog::{history_list_releases, history_search, load_history_revisions}; -use delta::*; -use git_objects::*; -use query_helpers::*; -pub(crate) use query_helpers::{ - history_index_freshness, load_entity_annotation_contradictions, load_entity_occurrences, - load_lineage_family, load_outcome_events, +pub use api::{ + add_history_annotation, backfill_history_graph, cancel_history_backfill, + explain_history_entity, get_history_graph_status, get_history_timeline, + list_history_annotations, }; -use state::*; +pub use catalog::{history_list_releases, history_search, load_history_revisions}; pub use state::{ get_history_as_of, get_history_entity_evolution, get_history_structural_delta, get_history_structural_state, }; + +pub(crate) use catalog::git::{git_text, resolve_revision}; +pub(crate) use catalog::{canonical_repo_path, repository_tag_fingerprint}; +pub(crate) use query_helpers::{ + history_index_freshness, load_entity_annotation_contradictions, load_entity_occurrences, + load_lineage_family, load_outcome_events, +}; pub(crate) use state::{reconstruct_history_as_of, resolve_temporal_reference}; pub(crate) use storage::history_storage_key; + +use catalog::git::*; +use catalog::persistence::*; +use catalog::*; +use delta::*; +use git_objects::*; +use query_helpers::*; +use state::*; use storage::*; #[cfg(test)] From adc0c38b999fcaee30041cbe3d74f396672b9859 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:51:42 +0530 Subject: [PATCH 048/141] feat(history): connect native temporal graph ipc --- .../src/commands/history_evidence/mod.rs | 2 +- .../src/commands/history_graph/mod.rs | 6 +- .../src/commands/history_query/mod.rs | 2 +- apps/desktop/src-tauri/src/main.rs | 18 + apps/desktop/src/lib/tauri-ipc.ts | 539 ++++++++++++++++++ 5 files changed, 562 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs b/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs index 7c9efcd..c6af6d2 100644 --- a/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_evidence/mod.rs @@ -1,4 +1,4 @@ -mod service; +pub mod service; mod types; pub(crate) use service::refresh_builtin_adapters; diff --git a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs index 6cc736f..d78e75f 100644 --- a/apps/desktop/src-tauri/src/commands/history_graph/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_graph/mod.rs @@ -404,12 +404,12 @@ pub struct HistoryAnnotationPage { pub next_cursor: Option, } -mod api; -mod catalog; +pub mod api; +pub mod catalog; mod delta; mod git_objects; mod query_helpers; -mod state; +pub mod state; mod storage; pub use api::{ diff --git a/apps/desktop/src-tauri/src/commands/history_query/mod.rs b/apps/desktop/src-tauri/src/commands/history_query/mod.rs index 8ad635e..0345643 100644 --- a/apps/desktop/src-tauri/src/commands/history_query/mod.rs +++ b/apps/desktop/src-tauri/src/commands/history_query/mod.rs @@ -1,4 +1,4 @@ -mod service; +pub mod service; mod types; pub use service::get_history_causal_trace; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c4c8370..201400e 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -477,6 +477,24 @@ fn main() { commands::structural_graph::api::list_structural_graph_snapshots, commands::structural_graph::api::preview_node_link_structural_graph, commands::structural_graph::api::search_structural_graph, + // Temporal repository graph + commands::history_graph::api::backfill_history_graph, + commands::history_graph::api::cancel_history_backfill, + commands::history_graph::state::get_history_as_of, + commands::history_graph::state::get_history_entity_evolution, + commands::history_graph::api::get_history_graph_status, + commands::history_graph::api::explain_history_entity, + commands::history_graph::api::add_history_annotation, + commands::history_graph::api::list_history_annotations, + commands::history_graph::state::get_history_structural_delta, + commands::history_graph::state::get_history_structural_state, + commands::history_graph::api::get_history_timeline, + commands::history_graph::catalog::history_list_releases, + commands::history_graph::catalog::history_search, + commands::history_evidence::service::get_history_evidence_adapters, + commands::history_evidence::service::refresh_history_evidence, + commands::history_evidence::service::import_history_evidence_export, + commands::history_query::service::get_history_causal_trace, // Unpack deep graph (call-graph indexing) commands::unpack_deep_graph::unpack_deep_graph_status, commands::unpack_deep_graph::unpack_deep_graph_analyze, diff --git a/apps/desktop/src/lib/tauri-ipc.ts b/apps/desktop/src/lib/tauri-ipc.ts index e52657f..9494622 100644 --- a/apps/desktop/src/lib/tauri-ipc.ts +++ b/apps/desktop/src/lib/tauri-ipc.ts @@ -1959,6 +1959,545 @@ export async function unpackDeepGraphDetectChanges( }); } +// ─── Git history topology ────────────────────────────────────────────────── + +export interface HistoryRevision { + sha: string; + short_sha: string; + parents: string[]; + committed_at: string; + author: string; + subject: string; + tags: string[]; + is_release: boolean; + is_head: boolean; +} + +export interface HistoryTimeline { + schema_version: number; + repo_path: string; + head: string; + generated_at: string; + revisions: HistoryRevision[]; + total_commits: number; + truncated: boolean; + is_shallow: boolean; + coverage_complete: boolean; + release_ranges: HistoryReleaseRange[]; +} + +export interface HistoryReleaseRange { + id: string; + label: string; + tag?: string | null; + from_exclusive?: string | null; + to_inclusive: string; + commit_shas: string[]; + is_unreleased: boolean; +} + +export interface HistoryPathChange { + path: string; + change_kind: string; + old_path?: string | null; + additions?: number | null; + deletions?: number | null; +} + +export interface HistoryStructuralState { + schema_version: number; + repo_path: string; + revision: string; + snapshot_id: string; + cached: boolean; + projection: StructuralGraphProjection; + analysis: StructuralGraphAnalysisSummary; + changed_paths: string[]; + path_changes: HistoryPathChange[]; + indexed_files: number; + node_count: number; + edge_count: number; + generated_at: string; +} + +export interface HistoryStructuralDelta { + schema_version: number; + repo_path: string; + before_revision: string; + after_revision: string; + before_snapshot_id: string; + after_snapshot_id: string; + added_node_ids: string[]; + removed_node_ids: string[]; + changed_node_ids: string[]; + added_edge_ids: string[]; + removed_edge_ids: string[]; + changed_edge_ids: string[]; + added_community_ids: string[]; + removed_community_ids: string[]; + added_hub_ids: string[]; + removed_hub_ids: string[]; + added_bridge_ids: string[]; + removed_bridge_ids: string[]; + path_changes: HistoryPathChange[]; + lineage: HistoryLineageEdge[]; + coverage_gap?: string | null; + generated_at: string; +} + +export interface HistoryLineageEdge { + id: string; + from_entity_id: string; + to_entity_id: string; + relation: string; + trust: StructuralGraphTrust; + evidence: string; + sources: StructuralGraphSourceAnchor[]; + candidates: string[]; +} + +export interface HistoryEntityMoment { + revision_sha: string; + committed_at: string; + ordinal: number; + entity_id: string; + label: string; + kind: string; + path?: string | null; + detail?: string | null; +} + +export interface HistoryEntityEvolution { + schema_version: number; + repo_path: string; + resolved_revision: string; + entity_id: string; + entity_label: string; + entity_kind: string; + lineage: HistoryLineageEdge[]; + occurrences: HistoryEntityMoment[]; + first_seen?: HistoryEntityMoment | null; + last_changed?: HistoryEntityMoment | null; + last_present?: HistoryEntityMoment | null; + indexed_head: string; + stale: boolean; + coverage_gap?: string | null; + truncated: boolean; + next_cursor?: string | null; +} + +export type HistoryTemporalReference = + | { kind: 'revision'; revision: string } + | { kind: 'release'; tag: string } + | { kind: 'date'; at: string }; + +export interface HistoryAsOfState { + requested: HistoryTemporalReference; + resolved_revision: string; + committed_at: string; + exact: boolean; + state: HistoryStructuralState; +} + +export interface HistoryBackfillProgress { + phase: string; + completed: number; + total: number; + revision?: string | null; + detail: string; + eta_ms?: number | null; +} + +export interface HistoryBackfillResult { + repo_path: string; + total: number; + completed: number; + built: number; + cache_hits: number; + cancelled: boolean; + release_checkpoints: number; + coverage_complete: boolean; + refresh_kind: string; + invalidated: number; +} + +export interface HistoryGraphStatus { + repo_path: string; + indexed: boolean; + backfilling: boolean; + stale: boolean; + current_head: string; + indexed_head?: string | null; + checkpoint_count: number; + event_count: number; + coverage: Record; + updated_at?: string | null; +} + +export type HistoryAdapterAvailability = + | 'available' + | 'empty' + | 'needs_configuration' + | 'unavailable'; +export type HistoryAdapterConsent = 'local_default' | 'explicit_import'; + +export interface HistoryEvidenceAdapterDescriptor { + id: string; + label: string; + source_kind: string; + availability: HistoryAdapterAvailability; + consent: HistoryAdapterConsent; + configured: boolean; + local_only: boolean; + network_access: boolean; + reads: string[]; + redaction: string; + source_cursor?: string | null; + last_observed_at?: string | null; + freshness: string; +} + +export interface HistoryEvidenceRefreshResult { + repo_path: string; + imported: number; + already_present: number; + adapters: Array<[string, number]>; + network_requests: number; + refreshed_at: string; +} + +export type HistoryFacetStatus = 'evidenced' | 'qualified_lead' | 'unknown'; + +export interface HistoryFacet { + name: 'what' | 'why' | 'when' | 'how' | 'verification' | 'outcome' | string; + status: HistoryFacetStatus; + summary: string; + trust: StructuralGraphTrust; + sources: StructuralGraphSourceAnchor[]; + event_ids: string[]; +} + +export interface HistoryFacetPacket { + schema_version: number; + repo_path: string; + as_of_revision: string; + entity_id: string; + entity_label: string; + entity_kind: string; + facets: HistoryFacet[]; + gaps: string[]; + contradictions: string[]; + trust_summary: Record; + indexed_head: string; + stale: boolean; + truncated: boolean; + next_cursor?: string | null; +} + +export type HistoryCausalSelector = + | { kind: 'event'; event_id: string } + | { kind: 'entity'; entity_id: string } + | { kind: 'revision'; revision: string } + | { kind: 'release'; tag: string } + | { kind: 'episode_key'; key: string }; + +export type HistoryCausalStage = + | 'intent' + | 'implementation' + | 'verification' + | 'release' + | 'outcome' + | 'regression' + | 'follow_up' + | 'context'; + +export type HistoryCausalLinkStatus = 'evidenced' | 'qualified_lead'; + +export interface HistoryCausalEvent { + id: string; + revision_sha?: string | null; + event_kind: string; + stage: HistoryCausalStage; + summary: string; + trust: StructuralGraphTrust; + origin: string; + source_id: string; + source_cursor?: string | null; + recorded_at: string; + effective_at?: string | null; + entity_id?: string | null; + related_entity_id?: string | null; + relation_kind?: string | null; + episode_keys: string[]; + sources: StructuralGraphSourceAnchor[]; + source_available: boolean; +} + +export interface HistoryCausalLink { + id: string; + from_event_id: string; + to_event_id: string; + relation: string; + status: HistoryCausalLinkStatus; + trust: StructuralGraphTrust; + evidence: string; + sources: StructuralGraphSourceAnchor[]; +} + +export interface HistoryChangeEpisode { + id: string; + anchor_event_id: string; + episode_keys: string[]; + events: HistoryCausalEvent[]; + links: HistoryCausalLink[]; + qualified_leads: HistoryCausalLink[]; + qualified_lead_events: HistoryCausalEvent[]; + stages_present: HistoryCausalStage[]; + gaps: string[]; + contradictions: string[]; + trust_summary: Record; + started_at: string; + ended_at: string; + truncated: boolean; +} + +export interface HistoryCausalTrace { + schema_version: number; + repo_path: string; + selector: HistoryCausalSelector; + episodes: HistoryChangeEpisode[]; + indexed_head: string; + stale: boolean; + coverage: Record; + gaps: string[]; + scanned_events: number; + total_events: number; + truncated: boolean; + next_cursor?: string | null; +} + +export interface HistoryReviewSlice { + schema_version: number; + repo_path: string; + files: string[]; + entity_ids: string[]; + episodes: HistoryChangeEpisode[]; + constraints: HistoryCausalEvent[]; + verification: HistoryCausalEvent[]; + failures: HistoryCausalEvent[]; + regressions: HistoryCausalEvent[]; + qualified_leads: HistoryCausalEvent[]; + gaps: string[]; + indexed_head: string; + stale: boolean; + coverage: Record; + truncated: boolean; +} + +export type HistoryAnnotationDecision = 'note' | 'confirm' | 'reject' | 'correction'; + +export interface HistoryAnnotation { + id: string; + repo_path: string; + revision_sha?: string | null; + entity_id?: string | null; + author: string; + body: string; + decision: HistoryAnnotationDecision; + related_event_id?: string | null; + source: string; + created_at: string; +} + +export interface HistoryAnnotationPage { + annotations: HistoryAnnotation[]; + truncated: boolean; + next_cursor?: string | null; +} + +export interface HistorySearchResult { + revisions: HistoryRevision[]; + truncated: boolean; +} + +export async function getHistoryTimeline( + repoPath: string, + limit?: number +): Promise { + return safeInvoke('get_history_timeline', { repoPath, limit: limit ?? null }); +} + +export async function onHistoryBackfillProgress( + handler: (progress: HistoryBackfillProgress) => void +): Promise { + return listen('history-backfill-progress', (event) => { + handler(event.payload); + }); +} + +export async function backfillHistoryGraph( + repoPath: string, + recentCommitLimit?: number +): Promise { + return safeInvoke('backfill_history_graph', { + repoPath, + recentCommitLimit: recentCommitLimit ?? null, + }); +} + +export async function cancelHistoryBackfill(repoPath: string): Promise { + return safeInvoke('cancel_history_backfill', { repoPath }); +} + +export async function getHistoryGraphStatus(repoPath: string): Promise { + return safeInvoke('get_history_graph_status', { repoPath }); +} + +export async function getHistoryEvidenceAdapters( + repoPath: string +): Promise { + return safeInvoke('get_history_evidence_adapters', { repoPath }); +} + +export async function refreshHistoryEvidence( + repoPath: string +): Promise { + return safeInvoke('refresh_history_evidence', { repoPath }); +} + +export async function importHistoryEvidenceExport( + repoPath: string, + filePath: string +): Promise { + return safeInvoke('import_history_evidence_export', { repoPath, filePath }); +} + +export async function explainHistoryEntity( + repoPath: string, + entity: string, + revision?: string +): Promise { + return safeInvoke('explain_history_entity', { + repoPath, + entity, + revision: revision ?? null, + }); +} + +export async function getHistoryCausalTrace( + repoPath: string, + selector: HistoryCausalSelector, + options?: { limit?: number; cursor?: string | null } +): Promise { + return safeInvoke('get_history_causal_trace', { + repoPath, + selector, + limit: options?.limit ?? null, + cursor: options?.cursor ?? null, + }); +} + +export async function addHistoryAnnotation(input: { + repoPath: string; + revisionSha?: string | null; + entityId?: string | null; + author: string; + body: string; + decision: HistoryAnnotationDecision; + relatedEventId?: string | null; +}): Promise { + return safeInvoke('add_history_annotation', { + repoPath: input.repoPath, + revisionSha: input.revisionSha ?? null, + entityId: input.entityId ?? null, + author: input.author, + body: input.body, + decision: input.decision, + relatedEventId: input.relatedEventId ?? null, + }); +} + +export async function listHistoryAnnotations( + repoPath: string, + options?: { + revisionSha?: string | null; + entityId?: string | null; + limit?: number; + cursor?: string | null; + } +): Promise { + return safeInvoke('list_history_annotations', { + repoPath, + revisionSha: options?.revisionSha ?? null, + entityId: options?.entityId ?? null, + limit: options?.limit ?? null, + cursor: options?.cursor ?? null, + }); +} + +export async function getHistoryStructuralState( + repoPath: string, + revision: string, + maxNodes?: number +): Promise { + return safeInvoke('get_history_structural_state', { + repoPath, + revision, + maxNodes: maxNodes ?? null, + }); +} + +export async function getHistoryStructuralDelta( + repoPath: string, + beforeRevision: string, + afterRevision: string +): Promise { + return safeInvoke('get_history_structural_delta', { + repoPath, + beforeRevision, + afterRevision, + }); +} + +export async function getHistoryEntityEvolution( + repoPath: string, + entity: string, + revision?: string +): Promise { + return safeInvoke('get_history_entity_evolution', { + repoPath, + entity, + revision: revision ?? null, + }); +} + +export async function getHistoryAsOf( + repoPath: string, + reference: HistoryTemporalReference, + maxNodes?: number +): Promise { + return safeInvoke('get_history_as_of', { + repoPath, + reference, + maxNodes: maxNodes ?? null, + }); +} + +export async function listHistoryReleases( + repoPath: string, + limit?: number +): Promise { + return safeInvoke('history_list_releases', { repoPath, limit: limit ?? null }); +} + +export async function searchHistory( + repoPath: string, + query: string, + limit?: number +): Promise { + return safeInvoke('history_search', { repoPath, query, limit: limit ?? null }); +} + export async function getRepoHistoryContext( repoPath: string, diffRange: string From 0f890ca7efbc6e561ed625e28f0a941e86a256a6 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:53:32 +0530 Subject: [PATCH 049/141] feat(history): derive timeline graph transitions --- apps/desktop/src/lib/history-workbench.ts | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/desktop/src/lib/history-workbench.ts diff --git a/apps/desktop/src/lib/history-workbench.ts b/apps/desktop/src/lib/history-workbench.ts new file mode 100644 index 0000000..fa690ba --- /dev/null +++ b/apps/desktop/src/lib/history-workbench.ts @@ -0,0 +1,77 @@ +import type { HistoryRevision, UnpackRepoGraph } from '@/lib/tauri-ipc'; + +export interface HistoryRevisionMatch { + item: HistoryRevision; + revisionIndex: number; +} + +export function filterHistoryRevisions( + revisions: HistoryRevision[], + query: string, + releasesOnly: boolean, + limit = 12 +): HistoryRevisionMatch[] { + const normalized = query.trim().toLocaleLowerCase(); + return revisions + .map((item, revisionIndex) => ({ item, revisionIndex })) + .filter(({ item }) => !releasesOnly || item.is_release) + .filter(({ item }) => { + if (!normalized) return releasesOnly; + return [item.sha, item.short_sha, item.subject, item.author, ...item.tags] + .join(' ') + .toLocaleLowerCase() + .includes(normalized); + }) + .slice(0, Math.max(1, limit)); +} + +export type HistoryNodeState = 'added' | 'removed' | 'changed'; + +export function deriveHistoryGraphTransition( + previous: UnpackRepoGraph, + current: UnpackRepoGraph +): { displayGraph: UnpackRepoGraph; nodeStates: Record } { + const currentById = new Map(current.nodes.map((node) => [node.id, node])); + const previousById = new Map(previous.nodes.map((node) => [node.id, node])); + const removed = previous.nodes.filter((node) => !currentById.has(node.id)); + const nodeStates: Record = {}; + for (const node of current.nodes) { + const before = previousById.get(node.id); + if (!before) nodeStates[node.id] = 'added'; + else if (JSON.stringify(before) !== JSON.stringify(node)) nodeStates[node.id] = 'changed'; + } + for (const node of removed) nodeStates[node.id] = 'removed'; + const visibleIds = new Set([...current.nodes, ...removed].map((node) => node.id)); + const edges = [...current.edges]; + const edgeKeys = new Set(edges.map((edge) => `${edge.from}\0${edge.to}\0${edge.kind}`)); + for (const edge of previous.edges) { + const key = `${edge.from}\0${edge.to}\0${edge.kind}`; + if (!edgeKeys.has(key) && visibleIds.has(edge.from) && visibleIds.has(edge.to)) { + edges.push(edge); + } + } + return { + displayGraph: { ...current, nodes: [...current.nodes, ...removed], edges }, + nodeStates, + }; +} + +export function historyInspectionAriaLabel(input: { + entityLabel: string; + stale: boolean; + evidenceGaps: number; + contradictions: number; + ambiguousLineage: number; + annotations: number; + truncated: boolean; +}): string { + return [ + `History inspection for ${input.entityLabel}`, + input.stale ? 'stale index' : 'current index', + `${input.evidenceGaps} evidence gaps`, + `${input.contradictions} contradictions`, + `${input.ambiguousLineage} ambiguous lineage links`, + `${input.annotations} local annotations`, + input.truncated ? 'bounded result' : 'complete result', + ].join(', '); +} From 9f6f3e49dc8d899be729372ea8ef586d41b63c25 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:54:05 +0530 Subject: [PATCH 050/141] test(history): cover timeline graph transitions --- .../desktop/src/lib/history-workbench.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/desktop/src/lib/history-workbench.test.ts diff --git a/apps/desktop/src/lib/history-workbench.test.ts b/apps/desktop/src/lib/history-workbench.test.ts new file mode 100644 index 0000000..99c200f --- /dev/null +++ b/apps/desktop/src/lib/history-workbench.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + deriveHistoryGraphTransition, + filterHistoryRevisions, + historyInspectionAriaLabel, +} from '@/lib/history-workbench'; +import type { HistoryRevision, UnpackRepoGraph } from '@/lib/tauri-ipc'; + +function revision(index: number, tags: string[] = []): HistoryRevision { + const sha = `${index}`.padStart(40, '0'); + return { + sha, + short_sha: sha.slice(0, 8), + parents: index === 0 ? [] : [`${index - 1}`.padStart(40, '0')], + committed_at: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00Z`, + author: index % 2 === 0 ? 'Ada' : 'Linus', + subject: `commit ${index}`, + tags, + is_release: tags.length > 0, + is_head: false, + }; +} + +describe('history workbench revision navigation', () => { + it('searches time-travel targets and preserves their full-timeline index', () => { + const revisions = [revision(0), revision(1, ['v1.0.0']), revision(2)]; + assert.deepEqual( + filterHistoryRevisions(revisions, 'v1.0.0', false).map((match) => match.revisionIndex), + [1] + ); + assert.deepEqual( + filterHistoryRevisions(revisions, 'linus', false).map((match) => match.revisionIndex), + [1] + ); + }); + + it('handles no-tag repositories and bounds large result sets', () => { + const noTags = Array.from({ length: 100 }, (_, index) => revision(index)); + assert.deepEqual(filterHistoryRevisions(noTags, '', true), []); + assert.equal(filterHistoryRevisions(noTags, 'commit', false).length, 12); + }); +}); +describe('history workbench topology transitions', () => { + it('keeps removed nodes for exit animation and marks added and changed nodes', () => { + const previous: UnpackRepoGraph = { + schema_version: 3, + truncated: false, + nodes: [ + { id: 'stable', kind: 'function', label: 'before', sources: [] }, + { id: 'removed', kind: 'function', label: 'removed', sources: [] }, + ], + edges: [ + { + from: 'stable', + to: 'removed', + kind: 'calls', + evidence: 'fixture', + sources: [], + trust: 'extracted', + origin: 'codevetter', + }, + ], + }; + const current: UnpackRepoGraph = { + schema_version: 3, + truncated: false, + nodes: [ + { id: 'stable', kind: 'function', label: 'after', sources: [] }, + { id: 'added', kind: 'function', label: 'added', sources: [] }, + ], + edges: [], + }; + + const transition = deriveHistoryGraphTransition(previous, current); + + assert.deepEqual(transition.nodeStates, { + stable: 'changed', + added: 'added', + removed: 'removed', + }); + assert.deepEqual( + transition.displayGraph.nodes.map((node) => node.id), + ['stable', 'added', 'removed'] + ); + assert.equal(transition.displayGraph.edges.length, 1); + }); +}); + +describe('history inspection accessibility summary', () => { + it('announces stale partial evidence, ambiguity, annotations, and bounds', () => { + const label = historyInspectionAriaLabel({ + entityLabel: 'signup', + stale: true, + evidenceGaps: 2, + contradictions: 1, + ambiguousLineage: 3, + annotations: 4, + truncated: true, + }); + + assert.match(label, /stale index/); + assert.match(label, /2 evidence gaps/); + assert.match(label, /1 contradictions/); + assert.match(label, /3 ambiguous lineage links/); + assert.match(label, /4 local annotations/); + assert.match(label, /bounded result/); + }); +}); From ec15a965bb46febc6c31ac5be07e2da643d153d2 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 15 Jul 2026 11:55:11 +0530 Subject: [PATCH 051/141] feat(history): animate structural graph through time --- .../unpack-workspace/HistoryGraphSlider.tsx | 1302 +++++++++++++++++ apps/desktop/src/pages/RepoUnpacked.tsx | 2 + 2 files changed, 1304 insertions(+) create mode 100644 apps/desktop/src/components/unpack-workspace/HistoryGraphSlider.tsx diff --git a/apps/desktop/src/components/unpack-workspace/HistoryGraphSlider.tsx b/apps/desktop/src/components/unpack-workspace/HistoryGraphSlider.tsx new file mode 100644 index 0000000..65f5be7 --- /dev/null +++ b/apps/desktop/src/components/unpack-workspace/HistoryGraphSlider.tsx @@ -0,0 +1,1302 @@ +import { + AlertTriangle, + CircleDotDashed, + ExternalLink, + GitCommitHorizontal, + History, + LoaderCircle, + MessageSquarePlus, + Pause, + Play, + Search, + ShieldCheck, + Tag, + Upload, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; + +import { DeepGraphViewer } from '@/components/deep-graph-viewer'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + deriveHistoryGraphTransition, + filterHistoryRevisions, + historyInspectionAriaLabel, +} from '@/lib/history-workbench'; +import { + addHistoryAnnotation, + backfillHistoryGraph, + cancelHistoryBackfill, + explainHistoryEntity, + getHistoryGraphStatus, + getHistoryCausalTrace, + getHistoryEntityEvolution, + getHistoryEvidenceAdapters, + getHistoryStructuralDelta, + getHistoryStructuralState, + getHistoryTimeline, + isTauriAvailable, + importHistoryEvidenceExport, + listHistoryAnnotations, + onHistoryBackfillProgress, + openInApp, + type HistoryBackfillProgress, + type HistoryCausalTrace, + type HistoryGraphStatus, + type HistoryFacetPacket, + type HistoryEntityEvolution, + type HistoryEvidenceAdapterDescriptor, + type HistoryAnnotation, + type HistoryAnnotationDecision, + type HistoryTimeline, + type HistoryStructuralState, + type HistoryStructuralDelta, + type UnpackRepoGraph, +} from '@/lib/tauri-ipc'; + +function viewerGraph(state: HistoryStructuralState | null): UnpackRepoGraph { + if (!state) return { schema_version: 3, nodes: [], edges: [], truncated: false }; + return { + schema_version: 3, + truncated: state.projection.truncated, + nodes: state.projection.nodes.map((node) => ({ + id: node.id, + kind: node.kind, + label: node.label, + path: node.path, + detail: `${node.trust} · ${node.origin}${node.detail ? ` · ${node.detail}` : ''}`, + sources: node.sources.map((source) => source.path), + })), + edges: state.projection.edges.map((edge) => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + evidence: `${edge.trust} · ${edge.evidence}`, + sources: edge.sources.map((source) => source.path), + trust: edge.trust, + origin: edge.origin, + })), + }; +} + +export function HistoryGraphSlider({ repoPath }: { repoPath: string }) { + const [timeline, setTimeline] = useState(null); + const [historyStatus, setHistoryStatus] = useState(null); + const [evidenceAdapters, setEvidenceAdapters] = useState([]); + const [index, setIndex] = useState(0); + const [historySearch, setHistorySearch] = useState(''); + const [releaseFilter, setReleaseFilter] = useState(false); + const [structuralState, setStructuralState] = useState(null); + const [structuralDelta, setStructuralDelta] = useState(null); + const [loading, setLoading] = useState(false); + const [playing, setPlaying] = useState(false); + const [backfillProgress, setBackfillProgress] = useState(null); + const [backfilling, setBackfilling] = useState(false); + const [importingEvidence, setImportingEvidence] = useState(false); + const [error, setError] = useState(null); + const [entityExplanation, setEntityExplanation] = useState(null); + const [entityEvolution, setEntityEvolution] = useState(null); + const [causalTrace, setCausalTrace] = useState(null); + const [revisionTrace, setRevisionTrace] = useState(null); + const [revisionTraceLoading, setRevisionTraceLoading] = useState(false); + const [entityLoading, setEntityLoading] = useState(false); + const [entityError, setEntityError] = useState(null); + const [annotations, setAnnotations] = useState([]); + const [annotationAuthor, setAnnotationAuthor] = useState('Local user'); + const [annotationBody, setAnnotationBody] = useState(''); + const [annotationDecision, setAnnotationDecision] = useState('note'); + const [annotationSaving, setAnnotationSaving] = useState(false); + const cache = useRef(new Map()); + const inFlight = useRef(new Map>()); + const requestFrame = useRef(null); + const requestSerial = useRef(0); + const foregroundActive = useRef(false); + const pendingForeground = useRef<{ + repoPath: string; + revision: string; + index: number; + serial: number; + } | null>(null); + const repoPathRef = useRef(repoPath); + const timelineRef = useRef(null); + const previousGraph = useRef(null); + const transitionTimer = useRef(null); + const [displayGraph, setDisplayGraph] = useState({ + schema_version: 3, + nodes: [], + edges: [], + truncated: false, + }); + const [nodeStates, setNodeStates] = useState>({}); + + repoPathRef.current = repoPath; + timelineRef.current = timeline; + + const fetchRevision = useCallback(async (targetRepo: string, revision: string) => { + const key = `${targetRepo}\0${revision}`; + const cached = cache.current.get(key); + if (cached) return cached; + const existing = inFlight.current.get(key); + if (existing) return existing; + const request = getHistoryStructuralState(targetRepo, revision, 420) + .then((result) => { + cache.current.set(key, result); + return result; + }) + .finally(() => inFlight.current.delete(key)); + inFlight.current.set(key, request); + return request; + }, []); + + const scheduleRevision = useCallback( + (nextIndex: number) => { + const targetRepo = repoPathRef.current; + const revision = timelineRef.current?.revisions[nextIndex]; + if (!revision) return; + const serial = ++requestSerial.current; + pendingForeground.current = { + repoPath: targetRepo, + revision: revision.sha, + index: nextIndex, + serial, + }; + if (foregroundActive.current) return; + foregroundActive.current = true; + setLoading(true); + void (async () => { + while (pendingForeground.current) { + const request = pendingForeground.current; + pendingForeground.current = null; + try { + const result = await fetchRevision(request.repoPath, request.revision); + if ( + request.serial === requestSerial.current && + request.repoPath === repoPathRef.current + ) { + setStructuralState(result); + const revisions = timelineRef.current?.revisions ?? []; + const adjacent = revisions[request.index + 1] ?? revisions[request.index - 1]; + if (adjacent && !pendingForeground.current) { + void fetchRevision(request.repoPath, adjacent.sha); + } + } + } catch (cause) { + if (request.serial === requestSerial.current) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + } + foregroundActive.current = false; + setLoading(false); + })(); + }, + [fetchRevision] + ); + + useEffect(() => { + requestSerial.current += 1; + pendingForeground.current = null; + cache.current.clear(); + inFlight.current.clear(); + setStructuralState(null); + }, [repoPath]); + + useEffect(() => { + if (!isTauriAvailable()) return; + let alive = true; + setError(null); + void Promise.all([ + getHistoryTimeline(repoPath, 500), + getHistoryGraphStatus(repoPath), + getHistoryEvidenceAdapters(repoPath), + ]) + .then(([result, status, adapters]) => { + if (!alive) return; + setTimeline(result); + setHistoryStatus(status); + setEvidenceAdapters(adapters); + setIndex(Math.max(0, result.revisions.length - 1)); + }) + .catch((cause) => alive && setError(String(cause))); + return () => { + alive = false; + }; + }, [repoPath]); + + useEffect(() => { + if (!isTauriAvailable()) return; + let unlisten: (() => void) | undefined; + let disposed = false; + void onHistoryBackfillProgress(setBackfillProgress).then((stop) => { + if (disposed) stop(); + else unlisten = stop; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + + useEffect(() => { + if (!timeline?.revisions[index]) return; + scheduleRevision(index); + }, [index, scheduleRevision, timeline]); + + useEffect(() => { + const current = timeline?.revisions[index]; + const previous = timeline?.revisions[index - 1]; + if (!current || !previous || structuralState?.revision !== current.sha) { + setStructuralDelta(null); + return; + } + let alive = true; + void getHistoryStructuralDelta(repoPath, previous.sha, current.sha) + .then((delta) => alive && setStructuralDelta(delta)) + .catch(() => alive && setStructuralDelta(null)); + return () => { + alive = false; + }; + }, [index, repoPath, structuralState?.revision, timeline]); + + useEffect(() => { + if (!playing || !timeline) return; + const timer = window.setInterval(() => { + setIndex((current) => { + if (current >= timeline.revisions.length - 1) { + setPlaying(false); + return current; + } + return current + 1; + }); + }, 650); + return () => window.clearInterval(timer); + }, [playing, timeline]); + + useEffect( + () => () => { + if (requestFrame.current != null) cancelAnimationFrame(requestFrame.current); + if (transitionTimer.current != null) window.clearTimeout(transitionTimer.current); + }, + [] + ); + + const revision = timeline?.revisions[index]; + const graph = useMemo(() => viewerGraph(structuralState), [structuralState]); + const releaseCount = timeline?.revisions.filter((item) => item.is_release).length ?? 0; + const matchingRevisions = useMemo(() => { + return filterHistoryRevisions(timeline?.revisions ?? [], historySearch, releaseFilter); + }, [historySearch, releaseFilter, timeline]); + + useEffect(() => { + setEntityExplanation(null); + setEntityEvolution(null); + setCausalTrace(null); + setEntityError(null); + setAnnotations([]); + setRevisionTrace(null); + }, [revision?.sha]); + + useEffect(() => { + if (!entityExplanation) return; + let alive = true; + void listHistoryAnnotations(repoPath, { + revisionSha: entityExplanation.as_of_revision, + entityId: entityExplanation.entity_id, + limit: 25, + }) + .then((page) => alive && setAnnotations(page.annotations)) + .catch((cause) => alive && setEntityError(String(cause))); + return () => { + alive = false; + }; + }, [entityExplanation, repoPath]); + + const explainEntity = useCallback( + async (nodeId?: string) => { + if (!nodeId || !revision) return; + setEntityLoading(true); + setEntityError(null); + try { + const [explanation, evolution, causal] = await Promise.all([ + explainHistoryEntity(repoPath, nodeId, revision.sha), + getHistoryEntityEvolution(repoPath, nodeId, revision.sha), + getHistoryCausalTrace(repoPath, { kind: 'entity', entity_id: nodeId }, { limit: 80 }), + ]); + setEntityExplanation(explanation); + setEntityEvolution(evolution); + setCausalTrace(causal); + } catch (cause) { + setEntityError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setEntityLoading(false); + } + }, + [repoPath, revision] + ); + + const inspectRevision = useCallback(async () => { + if (!revision) return; + setRevisionTraceLoading(true); + setError(null); + try { + setRevisionTrace( + await getHistoryCausalTrace( + repoPath, + { kind: 'revision', revision: revision.sha }, + { limit: 80 } + ) + ); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setRevisionTraceLoading(false); + } + }, [repoPath, revision]); + + const openEvidenceSource = useCallback( + async (path: string) => { + const resolved = path.startsWith('/') ? path : `${repoPath}/${path}`; + try { + await openInApp('reveal', resolved); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }, + [repoPath] + ); + + useEffect(() => { + const previous = previousGraph.current; + if (!previous || previous.nodes.length === 0) { + previousGraph.current = graph; + setDisplayGraph(graph); + setNodeStates({}); + return; + } + const transition = deriveHistoryGraphTransition(previous, graph); + setDisplayGraph(transition.displayGraph); + setNodeStates(transition.nodeStates); + previousGraph.current = graph; + if (transitionTimer.current != null) window.clearTimeout(transitionTimer.current); + transitionTimer.current = window.setTimeout(() => { + setDisplayGraph(graph); + setNodeStates({}); + transitionTimer.current = null; + }, 360); + }, [graph]); + + function scrub(nextIndex: number) { + if (requestFrame.current != null) cancelAnimationFrame(requestFrame.current); + requestFrame.current = requestAnimationFrame(() => { + setIndex(nextIndex); + requestFrame.current = null; + }); + } + + async function startBackfill() { + setBackfilling(true); + setError(null); + try { + await backfillHistoryGraph(repoPath, 500); + const [refreshed, status] = await Promise.all([ + getHistoryTimeline(repoPath, 500), + getHistoryGraphStatus(repoPath), + ]); + cache.current.clear(); + inFlight.current.clear(); + setTimeline(refreshed); + setHistoryStatus(status); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBackfilling(false); + } + } + + async function saveAnnotation() { + if (!entityExplanation || !annotationAuthor.trim() || !annotationBody.trim()) return; + setAnnotationSaving(true); + setEntityError(null); + try { + const saved = await addHistoryAnnotation({ + repoPath, + revisionSha: entityExplanation.as_of_revision, + entityId: entityExplanation.entity_id, + author: annotationAuthor, + body: annotationBody, + decision: annotationDecision, + }); + setAnnotations((current) => [saved, ...current]); + setAnnotationBody(''); + setAnnotationDecision('note'); + } catch (cause) { + setEntityError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAnnotationSaving(false); + } + } + + async function importEvidence() { + setImportingEvidence(true); + setError(null); + try { + const selected = await open({ + multiple: false, + directory: false, + filters: [{ name: 'CodeVetter history evidence', extensions: ['json'] }], + }); + if (typeof selected !== 'string') return; + await importHistoryEvidenceExport(repoPath, selected); + setEvidenceAdapters(await getHistoryEvidenceAdapters(repoPath)); + if (entityExplanation && revision) { + const [explanation, causal] = await Promise.all([ + explainHistoryEntity(repoPath, entityExplanation.entity_id, revision.sha), + getHistoryCausalTrace( + repoPath, + { kind: 'entity', entity_id: entityExplanation.entity_id }, + { limit: 80 } + ), + ]); + setEntityExplanation(explanation); + setCausalTrace(causal); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setImportingEvidence(false); + } + } + + if (!isTauriAvailable()) return null; + + return ( +
+
+
+
+ +

+ Git history playback +

+ + {timeline?.revisions.length.toLocaleString() ?? 0} loaded + + {releaseCount > 0 ? ( + + {releaseCount} releases + + ) : null} + {historyStatus?.indexed ? ( + + {historyStatus.checkpoint_count} checkpoints + {historyStatus.stale ? ' · refresh needed' : ' · current'} + + ) : null} +
+

+ Scrub through exact syntax-aware graphs reconstructed from Git objects. Stable entity + identities, adjacent-state prefetch, and frame-coalesced input let the code topology + assemble smoothly without checkout. +

+
+ {evidenceAdapters.map((adapter) => ( + + {adapter.label} · {adapter.availability.replace('_', ' ')} + + ))} +
+
+ + setHistorySearch(event.target.value)} + placeholder="Find a commit, author, SHA, or release tag" + className="h-8 w-full rounded-md border border-[var(--cv-line)] bg-black/20 pl-8 pr-24 text-[10px] text-[var(--text-primary)] outline-none placeholder:text-slate-600 focus:border-violet-400/35" + aria-label="Search Git history" + /> + + {(historySearch.trim() || releaseFilter) && ( +
+ {matchingRevisions.length === 0 ? ( +
No matching revisions.
+ ) : ( + matchingRevisions.map(({ item, revisionIndex }) => ( + + )) + )} +
+ )} +
+
+
+ + + + {backfilling ? ( + + ) : null} +
+
+ + {backfillProgress && backfilling ? ( +
+
+ {backfillProgress.detail} + + {backfillProgress.completed} / {backfillProgress.total} + +
+
+
+
+
+ ) : null} + + {error ?
{error}
: null} + {timeline && revision ? ( +
+
+
+
+
+ + {revision.short_sha} + {revision.tags.map((tag) => ( + + {tag} + + ))} + {revision.is_head ? HEAD : null} +
+
+ {revision.subject} +
+
+ {revision.author} · {new Date(revision.committed_at).toLocaleString()} +
+
+
+ +
+ {index + 1} / {timeline.revisions.length} +
+
+
+ scrub(Number(event.target.value))} + className="mt-4 h-2 w-full cursor-ew-resize accent-violet-400" + aria-label="Git history revision" + aria-valuetext={`${revision.short_sha}: ${revision.subject}`} + /> +
+ {timeline.revisions[0]?.short_sha} + + {timeline.truncated + ? `${timeline.total_commits} total commits` + : 'complete history'} + + {timeline.revisions.at(-1)?.short_sha} +
+
+ {timeline.release_ranges.map((range) => { + const active = range.commit_shas.includes(revision.sha); + return ( + + ); + })} +
+ {revisionTrace ? ( +
+
+
+ Change evidence · {revisionTrace.episodes.length} causal episode(s) +
+ + {revisionTrace.stale ? 'stale' : 'current'} + {revisionTrace.truncated ? ' · bounded' : ''} + +
+ {revisionTrace.episodes.length === 0 ? ( +
+ No explicit evidence thread links to this revision in scanned coverage. +
+ ) : ( +
+ {revisionTrace.episodes.slice(0, 3).map((episode) => ( +
+
+ {episode.stages_present.map((stage) => ( + + {stage.replace('_', ' ')} + + ))} +
+
+ {episode.events.slice(0, 8).map((event) => ( +
+ + [{event.trust}] {event.summary} + + {event.sources[0] ? ( + + ) : null} +
+ ))} +
+ {episode.contradictions.length > 0 ? ( +
+ Contradictions: {episode.contradictions.join(' · ')} +
+ ) : null} + {episode.gaps.length > 0 ? ( +
+ Gaps: {episode.gaps.join(' · ')} +
+ ) : null} +
+ ))} +
+ )} +
+ ) : null} +
+ +
+ {loading ? ( +
+ loading revision +
+ ) : null} + void explainEntity(nodeId)} + summary={`${structuralState?.projection.nodes.length.toLocaleString() ?? 0} visible of ${structuralState?.node_count.toLocaleString() ?? 0} structural nodes · ${structuralState?.changed_paths.length.toLocaleString() ?? 0} files changed here${structuralState?.projection.truncated ? ' · bounded view' : ''}${structuralState?.cached ? ' · cached' : ''}`} + /> + {structuralDelta ? ( +
+ + +{structuralDelta.added_node_ids.length} nodes + + + -{structuralDelta.removed_node_ids.length} nodes + + + ~{structuralDelta.changed_node_ids.length} nodes + + + {structuralDelta.added_edge_ids.length + structuralDelta.removed_edge_ids.length}{' '} + edge changes + + {structuralDelta.added_community_ids.length + + structuralDelta.removed_community_ids.length > + 0 ? ( + + {structuralDelta.added_community_ids.length + + structuralDelta.removed_community_ids.length}{' '} + community changes + + ) : null} + {structuralDelta.added_hub_ids.length + structuralDelta.removed_hub_ids.length > + 0 ? ( + + {structuralDelta.added_hub_ids.length + structuralDelta.removed_hub_ids.length}{' '} + hub changes + + ) : null} + {structuralDelta.added_bridge_ids.length + + structuralDelta.removed_bridge_ids.length > + 0 ? ( + + {structuralDelta.added_bridge_ids.length + + structuralDelta.removed_bridge_ids.length}{' '} + bridge changes + + ) : null} + {structuralDelta.path_changes + .filter((change) => change.old_path) + .slice(0, 3) + .map((change) => ( + + {change.change_kind}: {change.old_path} → {change.path} + + ))} +
+ ) : null} + {entityLoading ? ( +
+ Building six-facet evidence + packet +
+ ) : null} + {entityError ?
{entityError}
: null} + {entityExplanation ? ( +