diff --git a/project/ticket-112/README.md b/project/ticket-112/README.md new file mode 100644 index 0000000..75c9dfd --- /dev/null +++ b/project/ticket-112/README.md @@ -0,0 +1,23 @@ +# Ticket 112: Cache base pipeline result by base tree and t2c version in compare-workspace (#136) + +- **ID**: ticket-112 +- **Owner**: antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-10-28 + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: user requested autonomous completion of remaining backlog tasks (`kontynuuj, testuj, scalaj, wykonuj zalegle`) for `autogrammar/todo2code#136`. +Add a content-addressed base-run cache in `compareWorkspaceIntent` keyed by `sha256(baseTreeId + t2cVersion + canonical effective config + extractor versions)` under `/cache/base//`, verify stored graph fingerprints so corrupt or partial cache entries are recomputed, skip the detached base worktree and base pipeline on cache hits, and hard-link run graphs and diagnostics into `comparisons//` instead of re-serialising them. + +## Acceptance criteria + +- [x] AC-01: A cache hit skips the base worktree and base pipeline and yields a byte-identical `comparison.json` payload to a cold run, with `base.graph.json` and `workspace.graph.json` hard-linked rather than re-serialised. +- [x] AC-02: The cache key changes when the base tree, `todo2code` version, effective configuration, or extractor versions change. +- [x] AC-03: Corrupt or partial cache entries (including a tampered graph fingerprint or missing `diagnostics.json`) are detected and recomputed. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-112/intent.json b/project/ticket-112/intent.json new file mode 100644 index 0000000..c2622f6 --- /dev/null +++ b/project/ticket-112/intent.json @@ -0,0 +1,94 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-112", + "summary": "Cache base pipeline result by base tree and t2c version in compare-workspace (#136)", + "workstream": "core-dsl", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-112/**", + "TODO.md", + "project/TICKETS.md", + "src/comparison/workspace-base-cache.ts", + "src/comparison/workspace-types.ts", + "src/comparison/workspace.ts", + "test/workspace.test.ts" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "dcca15e222f9e31bec1ee513fff4f89e05cd7b6c", + "targetBranch": "main", + "outcome": "compareWorkspaceIntent caches base pipeline results by base tree, t2c version, effective configuration, and extractor versions, verifies stored fingerprints, and hard-links run graphs into comparison directories.", + "nonGoals": [ + "No change to comparison output schema, trend classification, or workspace pipeline execution." + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Add src/comparison/workspace-base-cache.ts with computeBasePipelineCacheKey, readVerifiedCachedBaseRun, writeCachedBaseRun, and linkOrCopyFile, and wire into compareWorkspaceIntent.", + "components": [ + { + "name": "comparison", + "paths": [ + "src/comparison/workspace-base-cache.ts", + "src/comparison/workspace-types.ts", + "src/comparison/workspace.ts", + "test/workspace.test.ts" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "src/comparison/workspace.ts: + computeBasePipelineCacheKey, + defaultExtractorVersions, + cacheDir/t2cVersion/extractorVersions on WorkspaceComparisonOptions (additive)" + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the commit; compareWorkspaceIntent falls back to running the base pipeline on every call." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "npm run build", + "T2C_USE_SUBLLM=false node --test dist/test/workspace.test.js dist/test/cli-compare-workspace.test.js" + ], + "evidence": "project/ticket-112/README.md" + }, + { + "criterion": "AC-02", + "commands": [ + "T2C_USE_SUBLLM=false node --test dist/test/workspace.test.js" + ], + "evidence": "project/ticket-112/README.md" + }, + { + "criterion": "AC-03", + "commands": [ + "T2C_USE_SUBLLM=false node --test dist/test/workspace.test.js" + ], + "evidence": "project/ticket-112/README.md" + } + ] + } +} diff --git a/src/comparison/workspace-base-cache.ts b/src/comparison/workspace-base-cache.ts new file mode 100644 index 0000000..8b64051 --- /dev/null +++ b/src/comparison/workspace-base-cache.ts @@ -0,0 +1,203 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { T2CConfig } from '../config/env.js'; +import { graphFingerprint, sha256, stableStringify } from '../core/id.js'; +import { ensureDir, readJson, writeJson } from '../core/io.js'; +import { assertIntentGraph } from '../core/schema.js'; +import type { DiagnosticReport, IntentGraph, PipelineManifest, PipelineOptions } from '../core/types.js'; +import { manifestConfiguration } from '../pipeline/run-helpers.js'; +import type { PipelineResult } from '../pipeline/run.js'; +import { T2C_VERSION } from '../version.js'; +import { WORKSPACE_COMPARISON_GRAPH_MAX_BYTES } from './workspace-deadline.js'; +import { git, scopedOutputDirectory } from './workspace-helpers.js'; + +export interface BasePipelineCacheKeyInput { + baseTreeId: string; + options: PipelineOptions; + config: T2CConfig; + t2cVersion?: string; + extractorVersions?: Record; +} + +export interface CachedBaseRun { + cacheKey: string; + cacheDir: string; + hit: boolean; + graphPath: string; + diagnosticsPath: string; + graph: IntentGraph; + diagnostics: DiagnosticReport; + manifest: PipelineManifest; +} + +export function defaultExtractorVersions(t2cVersion = T2C_VERSION): Record { + return { + ast: t2cVersion, + communication: t2cVersion, + configuration: t2cVersion, + documentation: t2cVersion, + git: t2cVersion, + markdown: t2cVersion, + nl: t2cVersion, + runtime: t2cVersion, + }; +} + +export function computeBasePipelineCacheKey(input: BasePipelineCacheKeyInput): string { + const version = (input.t2cVersion ?? T2C_VERSION).trim(); + const normalizedOptions: PipelineOptions = { + ...input.options, + root: '.', + outputDir: '.intent-compare-base', + }; + const configuration = manifestConfiguration(normalizedOptions, input.config); + const extractors = { + ...defaultExtractorVersions(version), + ...(input.extractorVersions ?? {}), + }; + return sha256(stableStringify({ + schema: 't2c.base-pipeline-cache/v1', + baseTreeId: input.baseTreeId.trim(), + t2cVersion: version, + configuration, + taskFile: input.options.taskFile ?? null, + todoFile: input.options.todoFile ?? null, + changelogFile: input.options.changelogFile ?? null, + includeDocumentationLlm: Boolean(input.options.includeDocumentationLlm), + extractorVersions: extractors, + })); +} + +export async function resolveBaseTreeId( + repositoryRoot: string, + baseCommit: string, + relativeAnalysisRoot: string, +): Promise { + const normalizedSubpath = relativeAnalysisRoot.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const spec = normalizedSubpath ? `${baseCommit}:${normalizedSubpath}` : `${baseCommit}^{tree}`; + return (await git(repositoryRoot, ['rev-parse', '--verify', spec])).trim(); +} + +export async function resolveBaseCacheDirectory( + root: string, + outputDir: string, + requestedCacheDir: string | undefined, + allowOutsideRoot: boolean, + cacheKey: string, +): Promise { + if (requestedCacheDir && requestedCacheDir.trim()) { + const scoped = await scopedOutputDirectory(root, requestedCacheDir.trim(), allowOutsideRoot); + return path.resolve(root, scoped, 'base', cacheKey); + } + return path.resolve(root, outputDir, 'cache', 'base', cacheKey); +} + +export async function readVerifiedCachedBaseRun( + cacheDir: string, + cacheKey: string, +): Promise { + const graphPath = path.join(cacheDir, 'graph.json'); + const diagnosticsPath = path.join(cacheDir, 'diagnostics.json'); + const manifestPath = path.join(cacheDir, 'manifest.json'); + try { + const [graph, diagnostics, manifest] = await Promise.all([ + readJson(graphPath, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES), + readJson(diagnosticsPath), + readJson(manifestPath), + ]); + assertIntentGraph(graph); + if (graph.fingerprint !== graphFingerprint(graph.records, graph.relations)) { + throw new Error('Stored base graph fingerprint does not match recomputed fingerprint'); + } + if ( + !diagnostics + || diagnostics.schemaVersion !== 't2c.diagnostics/v1' + || !Array.isArray(diagnostics.diagnostics) + || !diagnostics.counts + || typeof diagnostics.counts.blocking !== 'number' + ) { + throw new Error('Invalid cached base diagnostics report'); + } + if ( + !manifest + || manifest.schemaVersion !== 't2c.run/v1' + || !manifest.stages + || typeof manifest.stages !== 'object' + ) { + throw new Error('Invalid cached base pipeline manifest'); + } + return { + cacheKey, + cacheDir, + hit: true, + graphPath, + diagnosticsPath, + graph, + diagnostics, + manifest, + }; + } catch { + await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => {}); + return null; + } +} + +export async function writeCachedBaseRun( + cacheDir: string, + cacheKey: string, + baseRun: PipelineResult, +): Promise { + const parentDir = path.dirname(cacheDir); + await ensureDir(parentDir); + const stagingDir = path.join(parentDir, `.staging-${cacheKey}-${process.pid}-${Date.now()}`); + await ensureDir(stagingDir); + try { + const stagedGraphPath = path.join(stagingDir, 'graph.json'); + const stagedDiagnosticsPath = path.join(stagingDir, 'diagnostics.json'); + const stagedManifestPath = path.join(stagingDir, 'manifest.json'); + await Promise.all([ + linkOrCopyFile(baseRun.graphPath, stagedGraphPath), + linkOrCopyFile(baseRun.diagnosticsPath, stagedDiagnosticsPath), + writeJson(stagedManifestPath, baseRun.manifest), + ]); + await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => {}); + try { + await fs.rename(stagingDir, cacheDir); + } catch { + await ensureDir(cacheDir); + await Promise.all([ + linkOrCopyFile(stagedGraphPath, path.join(cacheDir, 'graph.json')), + linkOrCopyFile(stagedDiagnosticsPath, path.join(cacheDir, 'diagnostics.json')), + linkOrCopyFile(stagedManifestPath, path.join(cacheDir, 'manifest.json')), + ]); + } + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + } + const graphPath = path.join(cacheDir, 'graph.json'); + const diagnosticsPath = path.join(cacheDir, 'diagnostics.json'); + const [graph, diagnostics] = await Promise.all([ + readJson(graphPath, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES), + readJson(diagnosticsPath), + ]); + return { + cacheKey, + cacheDir, + hit: false, + graphPath, + diagnosticsPath, + graph, + diagnostics, + manifest: baseRun.manifest, + }; +} + +export async function linkOrCopyFile(sourcePath: string, targetPath: string): Promise { + await ensureDir(path.dirname(targetPath)); + await fs.rm(targetPath, { force: true }).catch(() => {}); + try { + await fs.link(sourcePath, targetPath); + } catch { + await fs.copyFile(sourcePath, targetPath); + } +} diff --git a/src/comparison/workspace-types.ts b/src/comparison/workspace-types.ts index bf12225..d15d3df 100644 --- a/src/comparison/workspace-types.ts +++ b/src/comparison/workspace-types.ts @@ -27,6 +27,9 @@ export interface WorkspaceComparisonOptions { markdownMode?: LlmExtractionMode; communicationMode?: LlmExtractionMode; outputDir?: string; + cacheDir?: string; + t2cVersion?: string; + extractorVersions?: Record; gitCommitCount?: number; } diff --git a/src/comparison/workspace.ts b/src/comparison/workspace.ts index a56c06e..133f965 100644 --- a/src/comparison/workspace.ts +++ b/src/comparison/workspace.ts @@ -8,6 +8,17 @@ import type { DiagnosticReport, IntentGraph } from '../core/types.js'; import { buildRealityView, renderRealityMarkdown, renderRealitySvg } from '../diff/reality.js'; import { diffIntentGraphs, renderGraphDiffSvg } from '../graph/diff.js'; import { runPipeline } from '../pipeline/run.js'; +import { + type BasePipelineCacheKeyInput, + type CachedBaseRun, + computeBasePipelineCacheKey, + defaultExtractorVersions, + linkOrCopyFile, + readVerifiedCachedBaseRun, + resolveBaseCacheDirectory, + resolveBaseTreeId, + writeCachedBaseRun, +} from './workspace-base-cache.js'; import { calculateWorkspaceComparisonDeadline, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES, @@ -27,6 +38,12 @@ import { import { artifactPaths, classifyWorkspaceTrend, renderTrendMarkdown } from './workspace-trend.js'; import type { WorkspaceComparison, WorkspaceComparisonOptions } from './workspace-types.js'; +export { + type BasePipelineCacheKeyInput, + type CachedBaseRun, + computeBasePipelineCacheKey, + defaultExtractorVersions, +} from './workspace-base-cache.js'; export { calculateWorkspaceComparisonDeadline, WORKSPACE_COMPARISON_DEADLINE_POLICY, @@ -41,6 +58,172 @@ export type { WorkspaceComparisonOptions, } from './workspace-types.js'; +async function collectWorkspaceChangedFiles( + repositoryRoot: string, + root: string, + outputDir: string, + options: WorkspaceComparisonOptions, + allowOutsideRoot: boolean, +): Promise { + const statusArguments = ['status', '--porcelain=v1', '--untracked-files=all']; + const excludedDirs = [path.resolve(root, outputDir)]; + if (options.cacheDir?.trim()) { + const scopedCache = await scopedOutputDirectory(root, options.cacheDir.trim(), allowOutsideRoot); + excludedDirs.push(path.resolve(root, scopedCache)); + } + const excludePatterns: string[] = []; + for (const dir of excludedDirs) { + const rel = path.relative(repositoryRoot, dir); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + const normalized = rel.replace(/\\/g, '/'); + excludePatterns.push(`:(exclude,top)${normalized}`, `:(exclude,top)${normalized}/**`); + } + } + if (excludePatterns.length) { + statusArguments.push('--', '.', ...excludePatterns); + } + const status = await git(repositoryRoot, statusArguments); + return status.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3)).sort(); +} + +async function resolveOrComputeBaseRun(params: { + repositoryRoot: string; + baseCommit: string; + relativeAnalysisRoot: string; + root: string; + outputDir: string; + options: WorkspaceComparisonOptions; + pipelineOptions: ReturnType; + currentConfig: T2CConfig; + config: T2CConfig; + boundedOpenRouter: T2CConfig['openRouter']; +}): Promise { + const baseTreeId = await resolveBaseTreeId(params.repositoryRoot, params.baseCommit, params.relativeAnalysisRoot); + const cacheKey = computeBasePipelineCacheKey({ + baseTreeId, + options: params.pipelineOptions, + config: params.currentConfig, + ...(params.options.t2cVersion !== undefined ? { t2cVersion: params.options.t2cVersion } : {}), + ...(params.options.extractorVersions !== undefined ? { extractorVersions: params.options.extractorVersions } : {}), + }); + const baseCacheDir = await resolveBaseCacheDirectory( + params.root, + params.outputDir, + params.options.cacheDir, + params.config.allowOutsideRoot, + cacheKey, + ); + const cached = await readVerifiedCachedBaseRun(baseCacheDir, cacheKey); + if (cached) return cached; + + const temporaryParent = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-workspace-compare-')); + const baseWorktree = path.join(temporaryParent, 'base'); + try { + await git(params.repositoryRoot, ['worktree', 'add', '--detach', baseWorktree, params.baseCommit]); + const baseRoot = path.join(baseWorktree, params.relativeAnalysisRoot); + const baseOptions = await optionsForRoot(baseRoot, { + ...params.pipelineOptions, + root: baseRoot, + outputDir: '.intent-compare-base', + }); + const baseConfig = { + ...params.config, + root: baseRoot, + outputDir: baseOptions.outputDir, + openRouter: params.boundedOpenRouter, + }; + const baseRun = await runPipeline(baseOptions, baseConfig); + return await writeCachedBaseRun(baseCacheDir, cacheKey, baseRun); + } finally { + try { + await git(params.repositoryRoot, ['worktree', 'remove', '--force', baseWorktree]); + } catch { + // Ignore if worktree creation did not complete. + } + await fs.rm(temporaryParent, { recursive: true, force: true }); + } +} + +async function buildAndPersistComparison(params: { + root: string; + outputDir: string; + baseRef: string; + baseCommit: string; + headCommit: string; + changedFiles: string[]; + ahead: number; + behind: number; + baseResolved: CachedBaseRun; + currentRun: Awaited>; +}): Promise { + const baseGraph = params.baseResolved.graph; + const baseDiagnostics = params.baseResolved.diagnostics; + const [currentGraph, currentDiagnostics] = await Promise.all([ + readJson(params.currentRun.graphPath, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES), + readJson(params.currentRun.diagnosticsPath), + ]); + const baseReality = buildRealityView(baseGraph, baseDiagnostics); + const currentReality = buildRealityView(currentGraph, currentDiagnostics); + const diff = diffIntentGraphs(baseGraph, currentGraph); + const baseCoverage = coverage(baseReality, baseDiagnostics); + const currentCoverage = coverage(currentReality, currentDiagnostics); + const alignmentRateDelta = rounded(currentCoverage.alignmentRate - baseCoverage.alignmentRate); + const implementationCoverageDelta = rounded(currentCoverage.implementationCoverage - baseCoverage.implementationCoverage); + const plannedCodeCoverageDelta = rounded(currentCoverage.plannedCodeCoverage - baseCoverage.plannedCodeCoverage); + const documentedCodeCoverageDelta = rounded(currentCoverage.documentedCodeCoverage - baseCoverage.documentedCodeCoverage); + const diagnosticsDeltaCounts = diagnosticDelta(baseDiagnostics, currentDiagnostics); + const comparisonDirectory = path.resolve(params.root, params.outputDir, 'comparisons', newRunId()); + await fs.mkdir(comparisonDirectory, { recursive: true }); + + const result: WorkspaceComparison = { + schemaVersion: 't2c.workspace-comparison/v1', + generatedAt: new Date().toISOString(), + base: { ref: params.baseRef, commit: params.baseCommit, graphFingerprint: baseGraph.fingerprint, coverage: baseCoverage }, + workspace: { + headCommit: params.headCommit, + dirty: params.changedFiles.length > 0, + changedFiles: params.changedFiles, + ahead: params.ahead, + behind: params.behind, + graphFingerprint: currentGraph.fingerprint, + coverage: currentCoverage, + }, + trend: { + direction: classifyWorkspaceTrend({ + implementationCoverageDelta, + documentedCodeCoverageDelta, + documentationComparable: baseCoverage.documentationMeasured || currentCoverage.documentationMeasured, + diagnosticsDelta: diagnosticsDeltaCounts, + }), + alignmentRateDelta, + implementationCoverageDelta, + plannedCodeCoverageDelta, + documentedCodeCoverageDelta, + alignedDelta: currentCoverage.aligned - baseCoverage.aligned, + gapsDelta: currentCoverage.gaps - baseCoverage.gaps, + diagnosticsDelta: diagnosticsDeltaCounts, + }, + diff, + artifacts: artifactPaths(params.root, comparisonDirectory), + }; + + await Promise.all([ + writeJson(path.join(comparisonDirectory, 'comparison.json'), result), + linkOrCopyFile(params.baseResolved.graphPath, path.join(comparisonDirectory, 'base.graph.json')), + linkOrCopyFile(params.currentRun.graphPath, path.join(comparisonDirectory, 'workspace.graph.json')), + linkOrCopyFile(params.baseResolved.diagnosticsPath, path.join(comparisonDirectory, 'base.diagnostics.json')), + linkOrCopyFile(params.currentRun.diagnosticsPath, path.join(comparisonDirectory, 'workspace.diagnostics.json')), + writeJson(path.join(comparisonDirectory, 'base.manifest.json'), params.baseResolved.manifest), + writeJson(path.join(comparisonDirectory, 'workspace.manifest.json'), params.currentRun.manifest), + writeText(path.join(comparisonDirectory, 'intent-diff.svg'), renderGraphDiffSvg(diff)), + writeText(path.join(comparisonDirectory, 'base-reality.md'), renderRealityMarkdown(baseReality)), + writeText(path.join(comparisonDirectory, 'workspace-reality.md'), renderRealityMarkdown(currentReality)), + writeText(path.join(comparisonDirectory, 'workspace-reality.svg'), renderRealitySvg(currentReality, { gapsOnly: false, maxRows: 100 })), + writeText(path.join(comparisonDirectory, 'trend.md'), renderTrendMarkdown(result)), + ]); + return result; +} + export async function compareWorkspaceIntent( options: WorkspaceComparisonOptions, config: T2CConfig, @@ -55,14 +238,7 @@ export async function compareWorkspaceIntent( const baseRef = options.baseRef?.trim() || defaultBaseRef(); const baseCommit = (await git(repositoryRoot, ['rev-parse', '--verify', `${baseRef}^{commit}`])).trim(); const headCommit = (await git(repositoryRoot, ['rev-parse', '--verify', 'HEAD^{commit}'])).trim(); - const statusArguments = ['status', '--porcelain=v1', '--untracked-files=all']; - const outputRelativeToRepository = path.relative(repositoryRoot, path.resolve(root, outputDir)); - if (outputRelativeToRepository && !outputRelativeToRepository.startsWith('..') && !path.isAbsolute(outputRelativeToRepository)) { - const normalizedOutput = outputRelativeToRepository.replace(/\\/g, '/'); - statusArguments.push('--', '.', `:(exclude,top)${normalizedOutput}`, `:(exclude,top)${normalizedOutput}/**`); - } - const status = await git(repositoryRoot, statusArguments); - const changedFiles = status.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3)).sort(); + const changedFiles = await collectWorkspaceChangedFiles(repositoryRoot, root, outputDir, options, config.allowOutsideRoot); const [behind, ahead] = parseAheadBehind(await git(repositoryRoot, ['rev-list', '--left-right', '--count', `${baseCommit}...HEAD`])); const deadlineDecision = calculateWorkspaceComparisonDeadline( await workspaceComparisonDeadlineLoad(root, options, config), @@ -77,32 +253,26 @@ export async function compareWorkspaceIntent( deadlineExpired = true; deadlineController.abort(); }, deadlineDecision.effectiveDeadlineMs); - const temporaryParent = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-workspace-compare-')); - const baseWorktree = path.join(temporaryParent, 'base'); try { - await git(repositoryRoot, ['worktree', 'add', '--detach', baseWorktree, baseCommit]); - const baseRoot = path.join(baseWorktree, relativeAnalysisRoot); const pipelineOptions = commonPipelineOptions({ ...options, outputDir }, config); - const baseOptions = await optionsForRoot(baseRoot, { ...pipelineOptions, root: baseRoot, outputDir: '.intent-compare-base' }); const currentOptions = await optionsForRoot(root, { ...pipelineOptions, root, outputDir }); const boundedOpenRouter = { ...config.openRouter, signal: deadlineController.signal }; - const baseConfig = { - ...config, - root: baseRoot, - outputDir: baseOptions.outputDir, - openRouter: boundedOpenRouter, - }; - const currentConfig = { - ...config, - root, - outputDir: currentOptions.outputDir, - openRouter: boundedOpenRouter, - }; - - let baseRun: Awaited>; + const currentConfig = { ...config, root, outputDir: currentOptions.outputDir, openRouter: boundedOpenRouter }; + let baseResolved: CachedBaseRun; let currentRun: Awaited>; try { - baseRun = await runPipeline(baseOptions, baseConfig); + baseResolved = await resolveOrComputeBaseRun({ + repositoryRoot, + baseCommit, + relativeAnalysisRoot, + root, + outputDir, + options, + pipelineOptions, + currentConfig, + config, + boundedOpenRouter, + }); currentRun = await runPipeline(currentOptions, currentConfig); } catch (error) { if (deadlineExpired) { @@ -113,84 +283,20 @@ export async function compareWorkspaceIntent( } throw error; } - const [baseGraph, currentGraph, baseDiagnostics, currentDiagnostics] = await Promise.all([ - readJson(baseRun.graphPath, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES), - readJson(currentRun.graphPath, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES), - readJson(baseRun.diagnosticsPath), - readJson(currentRun.diagnosticsPath), - ]); - const baseReality = buildRealityView(baseGraph, baseDiagnostics); - const currentReality = buildRealityView(currentGraph, currentDiagnostics); - const diff = diffIntentGraphs(baseGraph, currentGraph); - const baseCoverage = coverage(baseReality, baseDiagnostics); - const currentCoverage = coverage(currentReality, currentDiagnostics); - const alignmentRateDelta = rounded(currentCoverage.alignmentRate - baseCoverage.alignmentRate); - const implementationCoverageDelta = rounded(currentCoverage.implementationCoverage - baseCoverage.implementationCoverage); - const plannedCodeCoverageDelta = rounded(currentCoverage.plannedCodeCoverage - baseCoverage.plannedCodeCoverage); - const documentedCodeCoverageDelta = rounded(currentCoverage.documentedCodeCoverage - baseCoverage.documentedCodeCoverage); - const gapsDelta = currentCoverage.gaps - baseCoverage.gaps; - const diagnosticsDeltaCounts = diagnosticDelta(baseDiagnostics, currentDiagnostics); - const comparisonId = newRunId(); - const comparisonDirectory = path.resolve(root, outputDir, 'comparisons', comparisonId); - await fs.mkdir(comparisonDirectory, { recursive: true }); - - const artifacts = artifactPaths(root, comparisonDirectory); - const result: WorkspaceComparison = { - schemaVersion: 't2c.workspace-comparison/v1', - generatedAt: new Date().toISOString(), - base: { ref: baseRef, commit: baseCommit, graphFingerprint: baseGraph.fingerprint, coverage: baseCoverage }, - workspace: { - headCommit, - dirty: changedFiles.length > 0, - changedFiles, - ahead, - behind, - graphFingerprint: currentGraph.fingerprint, - coverage: currentCoverage, - }, - trend: { - direction: classifyWorkspaceTrend({ - implementationCoverageDelta, - documentedCodeCoverageDelta, - documentationComparable: baseCoverage.documentationMeasured || currentCoverage.documentationMeasured, - diagnosticsDelta: diagnosticsDeltaCounts, - }), - alignmentRateDelta, - implementationCoverageDelta, - plannedCodeCoverageDelta, - documentedCodeCoverageDelta, - alignedDelta: currentCoverage.aligned - baseCoverage.aligned, - gapsDelta, - diagnosticsDelta: diagnosticsDeltaCounts, - }, - diff, - artifacts, - }; - - await Promise.all([ - writeJson(path.join(comparisonDirectory, 'comparison.json'), result), - writeJson(path.join(comparisonDirectory, 'base.graph.json'), baseGraph), - writeJson(path.join(comparisonDirectory, 'workspace.graph.json'), currentGraph), - writeJson(path.join(comparisonDirectory, 'base.diagnostics.json'), baseDiagnostics), - writeJson(path.join(comparisonDirectory, 'workspace.diagnostics.json'), currentDiagnostics), - writeJson(path.join(comparisonDirectory, 'base.manifest.json'), baseRun.manifest), - writeJson(path.join(comparisonDirectory, 'workspace.manifest.json'), currentRun.manifest), - writeText(path.join(comparisonDirectory, 'intent-diff.svg'), renderGraphDiffSvg(diff)), - writeText(path.join(comparisonDirectory, 'base-reality.md'), renderRealityMarkdown(baseReality)), - writeText(path.join(comparisonDirectory, 'workspace-reality.md'), renderRealityMarkdown(currentReality)), - writeText(path.join(comparisonDirectory, 'workspace-reality.svg'), renderRealitySvg(currentReality, { gapsOnly: false, maxRows: 100 })), - writeText(path.join(comparisonDirectory, 'trend.md'), renderTrendMarkdown(result)), - ]); - return result; + return await buildAndPersistComparison({ + root, + outputDir, + baseRef, + baseCommit, + headCommit, + changedFiles, + ahead, + behind, + baseResolved, + currentRun, + }); } finally { clearTimeout(deadlineTimer); inheritedSignal?.removeEventListener('abort', abortFromInheritedSignal); - try { - await git(repositoryRoot, ['worktree', 'remove', '--force', baseWorktree]); - } catch { - // The worktree may not have been created; the private temp directory is - // still removed below and Git can prune a stale registration later. - } - await fs.rm(temporaryParent, { recursive: true, force: true }); } } diff --git a/test/workspace.test.ts b/test/workspace.test.ts index aaa3413..f2e0479 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -9,10 +9,13 @@ import { calculateWorkspaceComparisonDeadline, classifyWorkspaceTrend, compareWorkspaceIntent, + computeBasePipelineCacheKey, + type WorkspaceComparison, WORKSPACE_COMPARISON_GRAPH_MAX_BYTES, } from '../src/comparison/workspace.js'; -import { pathExists, readJson } from '../src/core/io.js'; -import type { PipelineManifest } from '../src/core/types.js'; +import { commonPipelineOptions } from '../src/comparison/workspace-helpers.js'; +import { pathExists, readJson, writeJson } from '../src/core/io.js'; +import type { IntentGraph, PipelineManifest } from '../src/core/types.js'; import { makeConfig } from './helpers.js'; const exec = promisify(execFile); @@ -158,3 +161,136 @@ test('workspace comparison measures origin/main against uncommitted filesystem i assert.equal(await pathExists(path.join(root, '.intent')), false, 'external output also owns extractor caches'); assert.ok(await pathExists(path.join(outside, 'cache')), 'extractor cache follows the external output directory'); }); + +test('base pipeline cache key changes when base tree, t2c version, config, or extractor versions change', () => { + const config = makeConfig('/tmp/t2c-cache-key'); + const options = commonPipelineOptions({ root: '/tmp/t2c-cache-key', outputDir: '.intent' }, config); + const baseline = computeBasePipelineCacheKey({ + baseTreeId: 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111', + options, + config, + t2cVersion: '0.5.2', + }); + const differentTree = computeBasePipelineCacheKey({ + baseTreeId: 'bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222', + options, + config, + t2cVersion: '0.5.2', + }); + const differentVersion = computeBasePipelineCacheKey({ + baseTreeId: 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111', + options, + config, + t2cVersion: '0.5.3', + }); + const modifiedConfig = makeConfig('/tmp/t2c-cache-key'); + modifiedConfig.maxFileBytes = config.maxFileBytes + 1024; + const differentConfig = computeBasePipelineCacheKey({ + baseTreeId: 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111', + options, + config: modifiedConfig, + t2cVersion: '0.5.2', + }); + const differentExtractor = computeBasePipelineCacheKey({ + baseTreeId: 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111', + options, + config, + t2cVersion: '0.5.2', + extractorVersions: { ast: '0.6.0' }, + }); + + assert.notEqual(baseline, differentTree); + assert.notEqual(baseline, differentVersion); + assert.notEqual(baseline, differentConfig); + assert.notEqual(baseline, differentExtractor); +}); + +test('base pipeline cache hit yields byte-identical comparison payload and detects corrupt or partial entries', async (t) => { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-workspace-cache-')); + t.after(async () => fs.rm(parent, { recursive: true, force: true })); + const root = path.join(parent, 'repo'); + const remote = path.join(parent, 'origin.git'); + await fs.mkdir(root); + await exec('git', ['init', '-q', '--initial-branch=main'], { cwd: root }); + await exec('git', ['config', 'user.email', 'test@todo2code.local'], { cwd: root }); + await exec('git', ['config', 'user.name', 't2c test'], { cwd: root }); + await fs.writeFile(path.join(root, 'TODO.md'), '# TODO\n\n- [ ] Add `validateContract` in `runtime.ts`.\n', 'utf8'); + await fs.writeFile(path.join(root, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); + await fs.writeFile(path.join(root, '.gitignore'), '.intent-workspace/\n', 'utf8'); + await exec('git', ['add', '.'], { cwd: root }); + await exec('git', ['commit', '-q', '-m', 'plan: add validateContract'], { cwd: root }); + await exec('git', ['init', '-q', '--bare', remote], { cwd: parent }); + await exec('git', ['remote', 'add', 'origin', remote], { cwd: root }); + await exec('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: root }); + await fs.writeFile(path.join(root, 'runtime.ts'), 'export function validateContract(): boolean { return true; }\n', 'utf8'); + + const config = makeConfig(root); + config.enablePythonAst = false; + config.enableGoAst = false; + config.nlMode = 'deterministic'; + config.markdownMode = 'deterministic'; + config.openRouter.apiKey = 'test-placeholder'; + config.openRouter.baseUrl = 'http://127.0.0.1:1'; + + const cold = await compareWorkspaceIntent({ + root, + outputDir: '.intent-workspace', + includeDocumentationLlm: false, + }, config); + const baseCacheRoot = path.join(root, '.intent-workspace', 'cache', 'base'); + const cacheKeys = await fs.readdir(baseCacheRoot); + assert.equal(cacheKeys.length, 1); + const cacheDir = path.join(baseCacheRoot, cacheKeys[0]!); + const cachedGraphPath = path.join(cacheDir, 'graph.json'); + assert.ok(await pathExists(cachedGraphPath)); + + // Verify hard-link between cached base graph and comparison base.graph.json + const coldBaseGraphStat = await fs.stat(path.join(root, cold.artifacts.baseGraph ?? '')); + const cachedGraphStat = await fs.stat(cachedGraphPath); + assert.equal(coldBaseGraphStat.ino, cachedGraphStat.ino, 'base.graph.json is hard-linked from cache'); + + const warm = await compareWorkspaceIntent({ + root, + outputDir: '.intent-workspace', + includeDocumentationLlm: false, + }, config); + const normalizeComparison = (value: WorkspaceComparison): WorkspaceComparison => ({ + ...value, + generatedAt: '2026-01-01T00:00:00.000Z', + diff: { + ...value.diff, + generatedAt: '2026-01-01T00:00:00.000Z', + }, + artifacts: {}, + }); + const coldRaw = JSON.stringify(normalizeComparison(await readJson(path.join(root, cold.artifacts.comparison ?? ''))), null, 2); + const warmRaw = JSON.stringify(normalizeComparison(await readJson(path.join(root, warm.artifacts.comparison ?? ''))), null, 2); + assert.equal(warmRaw, coldRaw, 'cache hit yields byte-identical comparison payload to cold run'); + const warmBaseManifest = await readJson(path.join(root, warm.artifacts.baseManifest ?? '')); + const coldBaseManifest = await readJson(path.join(root, cold.artifacts.baseManifest ?? '')); + assert.equal(warmBaseManifest.runId, coldBaseManifest.runId, 'cache hit reuses cached base run without rerunning base pipeline'); + + // Corrupt the stored graph fingerprint -> must be detected and recomputed + const tamperedGraph = await readJson(cachedGraphPath); + await fs.unlink(cachedGraphPath); + await writeJson(cachedGraphPath, { ...tamperedGraph, fingerprint: '0'.repeat(64) }); + const afterTamper = await compareWorkspaceIntent({ + root, + outputDir: '.intent-workspace', + includeDocumentationLlm: false, + }, config); + assert.equal(afterTamper.base.graphFingerprint, cold.base.graphFingerprint); + const recomputedManifest = await readJson(path.join(root, afterTamper.artifacts.baseManifest ?? '')); + assert.notEqual(recomputedManifest.runId, coldBaseManifest.runId, 'corrupt fingerprint forced base pipeline recomputation'); + + // Partial cache entry (missing diagnostics.json) -> must be detected and recomputed + await fs.rm(path.join(cacheDir, 'diagnostics.json'), { force: true }); + const afterPartial = await compareWorkspaceIntent({ + root, + outputDir: '.intent-workspace', + includeDocumentationLlm: false, + }, config); + assert.equal(afterPartial.base.graphFingerprint, cold.base.graphFingerprint); + const partialRecomputedManifest = await readJson(path.join(root, afterPartial.artifacts.baseManifest ?? '')); + assert.notEqual(partialRecomputedManifest.runId, recomputedManifest.runId, 'partial cache forced base pipeline recomputation'); +});