diff --git a/apps/cli/main.ts b/apps/cli/main.ts index e18df82..b10719c 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -287,7 +287,7 @@ function runGraphExportCommand(args: string[]): void { console.log(`Files: ${files.length}`); } -function runGraphViewCommand(args: string[]): void { +async function runGraphViewCommand(args: string[]): Promise { const options = parseGraphViewArgs(args); const { repo, service } = createCommandContext(); const graph = service.readGraph(repo); @@ -299,7 +299,7 @@ function runGraphViewCommand(args: string[]): void { const outputPath = options.outputPath ?? defaultGraphViewOutputPath(repo.repo_name); mkdirSync(dirname(outputPath), { recursive: true }); - const html = service.buildGraphView(repo); + const html = await service.buildGraphView(repo); writeFileSync(outputPath, html, "utf8"); console.log(`Wrote graph view to ${outputPath}`); @@ -350,6 +350,7 @@ function printAnchorAudit(result: ClaimAnchorAuditResult): void { printAuditSection("Missing symbols", result.missing_symbols, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`); printAuditSection("Ambiguous symbols", result.ambiguous_symbols, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`); printAuditSection("Unsupported languages", result.unsupported_languages, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`); + printAuditSection("Stale content", result.stale_content, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`); } function anchorAuditIssueCount(result: ClaimAnchorAuditResult): number { @@ -357,7 +358,8 @@ function anchorAuditIssueCount(result: ClaimAnchorAuditResult): number { result.missing_files.length + result.missing_symbols.length + result.ambiguous_symbols.length + - result.unsupported_languages.length; + result.unsupported_languages.length + + result.stale_content.length; } function printAuditSection(title: string, items: T[], render: (item: T) => string): void { diff --git a/evals/lib/code-anchor-quality.ts b/evals/lib/code-anchor-quality.ts index 841430d..d319e92 100644 --- a/evals/lib/code-anchor-quality.ts +++ b/evals/lib/code-anchor-quality.ts @@ -215,6 +215,8 @@ function anchorStatusMessage(anchor: ResolvedCodeAnchor): string { return "Anchor symbol matches multiple declarations; use the fully qualified symbol path."; case "unsupported_language": return "Anchor language cannot currently be parsed; use a file-only anchor only for non-code artifacts."; + case "stale_content": + return "Anchor content has changed since the claim was recorded."; case "resolved": case "file_only": return "Anchor resolved."; diff --git a/libs/install/platforms/opencode.ts b/libs/install/platforms/opencode.ts index 85c0b8d..3623029 100644 --- a/libs/install/platforms/opencode.ts +++ b/libs/install/platforms/opencode.ts @@ -5,7 +5,8 @@ import Database from "better-sqlite3"; import { hookCommand, hookEvents, mergeHookConfig, readJsonObject, writeJson } from "../hook-config.js"; import { copyBundledSkills } from "../skills.js"; import { runOpenCodeAgent } from "../../agent-runner/opencode.js"; -import { hookSessionId, type HookInput } from "../../hooks/hook-input.js"; +import { hookSessionId } from "../../hooks/hook-input.js"; +import type { HookInput } from "../../hooks/types.js"; import { isRecord, parseJsonLine, diff --git a/libs/knowledge-graph/claim.ts b/libs/knowledge-graph/claim.ts index a4aff6c..2ef91f4 100644 --- a/libs/knowledge-graph/claim.ts +++ b/libs/knowledge-graph/claim.ts @@ -15,6 +15,7 @@ export type ClaimIntent = "intended" | "accidental" | "unknown"; export interface ClaimCodeAnchor { file: string; symbol?: string; + content_hash?: string; } export interface Claim { diff --git a/libs/knowledge-graph/code-anchors/audit.ts b/libs/knowledge-graph/code-anchors/audit.ts index 5335f0c..3783716 100644 --- a/libs/knowledge-graph/code-anchors/audit.ts +++ b/libs/knowledge-graph/code-anchors/audit.ts @@ -13,6 +13,7 @@ export async function auditClaimCodeAnchors( missing_symbols: [], ambiguous_symbols: [], unsupported_languages: [], + stale_content: [], }; for (const claim of claims) { @@ -37,6 +38,9 @@ export async function auditClaimCodeAnchors( case "unsupported_language": result.unsupported_languages.push({ claim_id: claim.id, anchor, status: "unsupported_language" }); break; + case "stale_content": + result.stale_content.push({ claim_id: claim.id, anchor, status: "stale_content" }); + break; case "resolved": case "file_only": break; diff --git a/libs/knowledge-graph/code-anchors/resolver.ts b/libs/knowledge-graph/code-anchors/resolver.ts index 8466814..2e1d461 100644 --- a/libs/knowledge-graph/code-anchors/resolver.ts +++ b/libs/knowledge-graph/code-anchors/resolver.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { extname, join, normalize, relative } from "node:path"; import Parser from "web-tree-sitter"; @@ -10,6 +11,7 @@ interface SymbolCandidate { symbol: string; start_line: number; end_line: number; + content_hash: string; } const require = createRequire(import.meta.url); @@ -113,18 +115,21 @@ export class CodeAnchorResolver { } if (anchor.symbol === undefined) { - return { ...anchor, status: "file_only" }; + const currentContentHash = contentHash(readFileSync(filePath, "utf8")); + return resolvedAnchor({ ...anchor, current_content_hash: currentContentHash, status: "file_only" }); } const symbols = await this.symbolsForFile(filePath); if (symbols === undefined) { const fallback = fallbackSymbolForFile(filePath, anchor.symbol); if (fallback !== undefined) { - return { + return resolvedAnchor({ ...anchor, start_line: fallback.start_line, + end_line: fallback.end_line, + current_content_hash: fallback.content_hash, status: "resolved", - }; + }); } return { ...anchor, status: "unsupported_language" }; } @@ -133,11 +138,13 @@ export class CodeAnchorResolver { if (matches.length === 0) { const fallback = fallbackSymbolForFile(filePath, anchor.symbol); if (fallback !== undefined) { - return { + return resolvedAnchor({ ...anchor, start_line: fallback.start_line, + end_line: fallback.end_line, + current_content_hash: fallback.content_hash, status: "resolved", - }; + }); } return { ...anchor, status: "missing_symbol" }; } @@ -146,12 +153,13 @@ export class CodeAnchorResolver { } const match = matches[0]; - return { + return resolvedAnchor({ ...anchor, start_line: match.start_line, end_line: match.end_line, + current_content_hash: match.content_hash, status: "resolved", - }; + }); } async resolveMany(repoRoot: string | undefined, anchors: ClaimCodeAnchor[] | undefined): Promise { @@ -209,14 +217,17 @@ export class CodeAnchorResolver { function fallbackSymbolForFile(filePath: string, symbol: string): SymbolCandidate | undefined { if (!cFamilyExtensions.has(extname(filePath).toLowerCase())) return undefined; - const line = findCFamilySymbolLine(readFileSync(filePath, "utf8"), symbol); + const source = readFileSync(filePath, "utf8"); + const line = findCFamilySymbolLine(source, symbol); if (line === undefined) return undefined; + const sourceLine = source.split(/\r?\n/)[line - 1] ?? ""; return { name: symbol, symbol, start_line: line, end_line: line, + content_hash: contentHash(sourceLine), }; } @@ -343,6 +354,7 @@ function walk(node: Parser.SyntaxNode, containers: string[], symbols: SymbolCand symbol, start_line: node.startPosition.row + 1, end_line: Math.max(node.startPosition.row + 1, node.endPosition.row + 1), + content_hash: contentHash(node.text), }); } @@ -387,3 +399,18 @@ function isRepoRelative(repoRoot: string, filePath: string): boolean { const relativePath = normalize(relative(repoRoot, filePath)); return relativePath.length === 0 || (!relativePath.startsWith("..") && !relativePath.startsWith("/")); } + +function resolvedAnchor(anchor: ResolvedCodeAnchor): ResolvedCodeAnchor { + if ( + anchor.content_hash !== undefined && + anchor.current_content_hash !== undefined && + anchor.content_hash !== anchor.current_content_hash + ) { + return { ...anchor, status: "stale_content" }; + } + return anchor; +} + +function contentHash(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} diff --git a/libs/knowledge-graph/code-anchors/types.ts b/libs/knowledge-graph/code-anchors/types.ts index ef5b200..413241b 100644 --- a/libs/knowledge-graph/code-anchors/types.ts +++ b/libs/knowledge-graph/code-anchors/types.ts @@ -4,6 +4,7 @@ import type { ClaimId } from "../schema.js"; export type ResolvedCodeAnchorStatus = | "resolved" | "file_only" + | "stale_content" | "missing_file" | "missing_symbol" | "ambiguous_symbol" @@ -12,13 +13,14 @@ export type ResolvedCodeAnchorStatus = export interface ResolvedCodeAnchor extends ClaimCodeAnchor { start_line?: number; end_line?: number; + current_content_hash?: string; status: ResolvedCodeAnchorStatus; } export interface ClaimAnchorAuditIssue { claim_id: ClaimId; anchor?: ClaimCodeAnchor; - status: "missing_anchors" | "missing_file" | "missing_symbol" | "ambiguous_symbol" | "unsupported_language"; + status: "missing_anchors" | "missing_file" | "missing_symbol" | "ambiguous_symbol" | "unsupported_language" | "stale_content"; } export interface ClaimAnchorAuditResult { @@ -27,4 +29,5 @@ export interface ClaimAnchorAuditResult { missing_symbols: ClaimAnchorAuditIssue[]; ambiguous_symbols: ClaimAnchorAuditIssue[]; unsupported_languages: ClaimAnchorAuditIssue[]; + stale_content: ClaimAnchorAuditIssue[]; } diff --git a/libs/knowledge-graph/graph-view/build-graph-view.ts b/libs/knowledge-graph/graph-view/build-graph-view.ts index 145e060..2fe2cb0 100644 --- a/libs/knowledge-graph/graph-view/build-graph-view.ts +++ b/libs/knowledge-graph/graph-view/build-graph-view.ts @@ -28,7 +28,7 @@ export interface GraphViewClaimRow { kind: string; session: string; source: "code" | "session"; - freshness: "active" | "superseded"; + freshness: "active" | "stale_content" | "superseded"; componentIds: string[]; flowIds: string[]; createdAt: string | null; @@ -49,6 +49,7 @@ export interface GraphViewData { components: number; flows: number; claims: number; + stale_content: number; superseded: number; }; components: GraphViewComponentRow[]; @@ -63,6 +64,7 @@ export interface GraphViewData { export interface BuildGraphViewOptions { repoName?: string; + staleClaimIds?: ReadonlySet; } const CLAIM_KIND_ORDER = ["fact", "decision", "requirement", "task", "risk", "question"]; @@ -80,7 +82,9 @@ export function buildGraphViewData( graph: GraphReadResult, provenance: ClaimProvenanceRecord[], supersededClaims: Claim[], + options: BuildGraphViewOptions = {}, ): GraphViewData { + const staleClaimIds = options.staleClaimIds ?? new Set(); const provenanceByClaimId = new Map(provenance.map((row) => [row.claim_id, row])); const sourceById = new Map(graph.sources.map((source) => [source.id, source])); const topLevelComponents = selectTopLevelComponents(graph.components, graph.edges); @@ -108,7 +112,7 @@ export function buildGraphViewData( }; }); - const toClaimRow = (claim: Claim, freshness: "active" | "superseded"): GraphViewClaimRow => { + const toClaimRow = (claim: Claim, freshness: GraphViewClaimRow["freshness"]): GraphViewClaimRow => { const record = provenanceByClaimId.get(claim.id); const session = sessionLabelForClaim(claim.id, graph.edges, sourceById); return { @@ -131,7 +135,9 @@ export function buildGraphViewData( return rightTime - leftTime; }; - const claims = graph.claims.map((claim) => toClaimRow(claim, "active")).sort(byCreatedDesc); + const claims = graph.claims + .map((claim) => toClaimRow(claim, staleClaimIds.has(claim.id) ? "stale_content" : "active")) + .sort(byCreatedDesc); const superseded = supersededClaims.map((claim) => toClaimRow(claim, "superseded")).sort(byCreatedDesc); return { @@ -140,6 +146,7 @@ export function buildGraphViewData( components: components.length, flows: flows.length, claims: claims.length, + stale_content: claims.filter((claim) => claim.freshness === "stale_content").length, superseded: superseded.length, }, components, @@ -156,7 +163,7 @@ export function buildGraphViewHtml( supersededClaims: Claim[], options: BuildGraphViewOptions = {}, ): string { - const data = buildGraphViewData(graph, provenance, supersededClaims); + const data = buildGraphViewData(graph, provenance, supersededClaims, options); const title = options.repoName ? `Greplica graph view — ${options.repoName}` : "Greplica graph view"; return renderHtml(data, title); } @@ -412,7 +419,8 @@ function renderHtml(data: GraphViewData, title: string): string { }) .join("\n"); - const defaultClaimsMeta = `${data.claims.length} active claims · session from evidenced_by source, otherwise from code`; + const activeClaimCount = data.counts.claims - data.counts.stale_content; + const defaultClaimsMeta = `${data.claims.length} current claims · ${activeClaimCount} active${data.counts.stale_content > 0 ? ` · ${data.counts.stale_content} stale content` : ""} · session from evidenced_by source, otherwise from code`; const graphDataJson = jsonForScriptTag(data); return ` @@ -834,7 +842,7 @@ ${timelineEvents}

Claims - Overview

-

Summary of ${data.counts.claims} active claims · click to see claims

+

Summary of ${data.counts.claims} current claims · click to see claims

By Type

@@ -879,7 +887,7 @@ ${timelineEvents} const CLAIM_KIND_ORDER = ${JSON.stringify(CLAIM_KIND_ORDER)}; const CLAIM_KIND_COLORS = ${JSON.stringify(CLAIM_KIND_COLORS)}; const SOURCE_COLORS = { code: "#4e79a7", session: "#f28e2b" }; - const FRESHNESS_COLORS = { active: "#59a14f", superseded: "#bab0ac" }; + const FRESHNESS_COLORS = { active: "#59a14f", stale_content: "#e15759", superseded: "#bab0ac" }; const allClaims = graphData.claims.concat(graphData.supersededClaims); const claimTextById = new Map(allClaims.map((claim) => [claim.id, claim.text])); @@ -1069,7 +1077,8 @@ ${timelineEvents} ]); renderOverviewChart("chart-freshness", "legend-freshness", [ - { label: "active", count: graphData.counts.claims, color: FRESHNESS_COLORS.active, href: "#claims?freshness=active" }, + { label: "active", count: graphData.counts.claims - graphData.counts.stale_content, color: FRESHNESS_COLORS.active, href: "#claims?freshness=active" }, + { label: "stale content", count: graphData.counts.stale_content, color: FRESHNESS_COLORS.stale_content, href: "#claims?freshness=stale_content" }, { label: "superseded", count: graphData.counts.superseded, color: FRESHNESS_COLORS.superseded, href: "#claims?freshness=superseded" }, ]); } @@ -1114,7 +1123,9 @@ ${timelineEvents} case "source": return filter.value === "session" ? "from session" : "from code"; case "freshness": - return filter.value === "superseded" ? "superseded" : "active"; + if (filter.value === "superseded") return "superseded"; + if (filter.value === "stale_content") return "with stale anchor content"; + return "active"; case "commit": { const event = graphData.claimsTimeline.events.find((item) => item.memoryCommitId === filter.value); if (event && event.createdAt) return "from commit on " + formatDateTimeClient(event.createdAt); @@ -1127,9 +1138,9 @@ ${timelineEvents} function rowMatchesFilter(row, filter) { const freshness = row.dataset.freshness; - if (!filter) return freshness === "active"; + if (!filter) return freshness !== "superseded"; if (filter.type === "freshness") return freshness === filter.value; - if (freshness !== "active") return false; + if (freshness === "superseded") return false; if (filter.type === "kind") return row.dataset.kind === filter.value; if (filter.type === "source") return row.dataset.source === filter.value; if (filter.type === "commit") return row.dataset.memoryCommitId === filter.value; diff --git a/libs/knowledge-graph/service.ts b/libs/knowledge-graph/service.ts index e805969..28c4e9b 100644 --- a/libs/knowledge-graph/service.ts +++ b/libs/knowledge-graph/service.ts @@ -8,6 +8,7 @@ import { graphContextConfig, type GraphContextConfig } from "./graph-context/con import type { EmbeddingStatus, GraphContextResult } from "./graph-context/types.js"; import { buildGraphViewHtml } from "./graph-view/build-graph-view.js"; import { auditClaimCodeAnchors } from "./code-anchors/audit.js"; +import { CodeAnchorResolver } from "./code-anchors/resolver.js"; import type { ClaimAnchorAuditResult } from "./code-anchors/types.js"; import { defaultDatabasePath, openDatabase } from "../storage/sqlite/db.js"; import type { SqliteRepository } from "../storage/sqlite/repository.js"; @@ -103,12 +104,16 @@ export class KnowledgeGraphService { return this.repository.readGraphView(initialized.repo_id); } - buildGraphView(input: RepoRef): string { + async buildGraphView(input: RepoRef): Promise { const initialized = this.requireRepo(input); const graph = this.repository.readGraphView(initialized.repo_id); const provenance = this.repository.readClaimProvenance(initialized.repo_id); const supersededClaims = this.repository.readSupersededClaims(initialized.repo_id); - return buildGraphViewHtml(graph, provenance, supersededClaims, { repoName: input.repo_name }); + const audit = await auditClaimCodeAnchors(input.repo_root, graph.claims); + return buildGraphViewHtml(graph, provenance, supersededClaims, { + repoName: input.repo_name, + staleClaimIds: new Set(audit.stale_content.map((issue) => issue.claim_id)), + }); } async contextGraph(input: RepoRef, query: string): Promise { @@ -150,15 +155,16 @@ export class KnowledgeGraphService { if (!validation.valid) { throw new Error(`Proposal is invalid:\n${validation.errors.map((error) => `- ${error}`).join("\n")}`); } + const fingerprintedProposal = await fingerprintProposalCodeAnchors(input.repo_root, normalizedProposal); const working = this.repository.requireWorkingScope(initialized.repo_id); const memoryCommit = this.repository.createMemoryCommit({ scope_id: working.id, - title: normalizedProposal.title, - summary: normalizedProposal.summary, + title: fingerprintedProposal.title, + summary: fingerprintedProposal.summary, }); - this.repository.createProposalRecords(working.id, memoryCommit.id, normalizedProposal); + this.repository.createProposalRecords(working.id, memoryCommit.id, fingerprintedProposal); const embeddingStatus = await this.contextBuilder.ensureForGraph( initialized.repo_id, this.repository.readGraphView(initialized.repo_id), @@ -170,11 +176,11 @@ export class KnowledgeGraphService { scope_id: working.id, embedding_status: embeddingStatus, created: { - components: normalizedProposal.creates.components?.length ?? 0, - flows: normalizedProposal.creates.flows?.length ?? 0, - claims: normalizedProposal.creates.claims?.length ?? 0, - sources: normalizedProposal.creates.sources?.length ?? 0, - edges: normalizedProposal.creates.edges?.length ?? 0, + components: fingerprintedProposal.creates.components?.length ?? 0, + flows: fingerprintedProposal.creates.flows?.length ?? 0, + claims: fingerprintedProposal.creates.claims?.length ?? 0, + sources: fingerprintedProposal.creates.sources?.length ?? 0, + edges: fingerprintedProposal.creates.edges?.length ?? 0, }, }; } @@ -197,9 +203,44 @@ function anchorAuditErrors(result: ClaimAnchorAuditResult): string[] { ...result.missing_symbols.map((issue) => `${issue.claim_id} -> ${formatAnchor(issue.anchor)} symbol was not found`), ...result.ambiguous_symbols.map((issue) => `${issue.claim_id} -> ${formatAnchor(issue.anchor)} symbol is ambiguous`), ...result.unsupported_languages.map((issue) => `${issue.claim_id} -> ${formatAnchor(issue.anchor)} language is unsupported for symbol anchors`), + ...result.stale_content.map((issue) => `${issue.claim_id} -> ${formatAnchor(issue.anchor)} content changed since the claim was recorded`), ]; } +async function fingerprintProposalCodeAnchors( + repoRoot: string | undefined, + proposal: ReturnType, +): Promise> { + if (repoRoot === undefined) return proposal; + + const resolver = new CodeAnchorResolver(); + const claims: Claim[] = []; + for (const claim of proposal.creates.claims ?? []) { + if (claim.code_anchors === undefined || claim.code_anchors.length === 0) { + claims.push(claim); + continue; + } + + const resolved = await resolver.resolveMany(repoRoot, claim.code_anchors); + claims.push({ + ...claim, + code_anchors: resolved.map((anchor) => ({ + file: anchor.file, + symbol: anchor.symbol, + content_hash: anchor.current_content_hash ?? anchor.content_hash, + })), + }); + } + + return { + ...proposal, + creates: { + ...proposal.creates, + claims, + }, + }; +} + function formatAnchor(anchor: { file: string; symbol?: string } | undefined): string { if (anchor === undefined) return ""; return anchor.symbol === undefined ? anchor.file : `${anchor.file}#${anchor.symbol}`; diff --git a/libs/knowledge-graph/validate-proposal.ts b/libs/knowledge-graph/validate-proposal.ts index 7cdad63..1082dc8 100644 --- a/libs/knowledge-graph/validate-proposal.ts +++ b/libs/knowledge-graph/validate-proposal.ts @@ -204,6 +204,12 @@ function validateClaimCodeAnchors(claim: Record, errors: string if (typeof anchor.symbol === "string" && anchor.symbol.trim().length === 0) { errors.push(`Claim ${claimId} code_anchors[${index}].symbol must not be empty when present.`); } + if (anchor.content_hash !== undefined && typeof anchor.content_hash !== "string") { + errors.push(`Claim ${claimId} code_anchors[${index}].content_hash must be a string when present.`); + } + if (typeof anchor.content_hash === "string" && anchor.content_hash.trim().length === 0) { + errors.push(`Claim ${claimId} code_anchors[${index}].content_hash must not be empty when present.`); + } const key = `${anchor.file}#${typeof anchor.symbol === "string" ? anchor.symbol : ""}`; if (seen.has(key)) { diff --git a/scripts/check-graph-view.js b/scripts/check-graph-view.js index 11661ed..766d54a 100644 --- a/scripts/check-graph-view.js +++ b/scripts/check-graph-view.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,18 +7,27 @@ const root = new URL("..", import.meta.url); const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); const { KnowledgeGraphService } = await import(new URL("dist/libs/knowledge-graph/service.js", root)); +const { renderGraphContextMarkdown } = await import(new URL("dist/libs/knowledge-graph/graph-context/render.js", root)); const tmp = mkdtempSync(join(tmpdir(), "greplica-graph-view-test-")); const db = openDatabase(join(tmp, "graph.db")); try { const repository = new SqliteRepository(db); - const service = new KnowledgeGraphService(repository); + const service = new KnowledgeGraphService(repository, undefined, { + ensureForGraph: async () => ({ checked_objects: 0, created: 0, reused: 0 }), + }); const repo = { repo_root: join(tmp, "repo"), repo_name: "graph-view-null-anchor", default_branch: "main", }; + mkdirSync(join(repo.repo_root, "src"), { recursive: true }); + writeFileSync( + join(repo.repo_root, "src/foo.ts"), + "export function computeTotal(amount: number): number {\n return amount * 0.95;\n}\n", + "utf8", + ); const initialized = service.initRepo(repo); const memoryCommit = repository.createMemoryCommit({ @@ -38,9 +47,68 @@ try { }, }); - const html = service.buildGraphView(repo); + await service.applyProposal(repo, { + title: "Seed anchored claim", + creates: { + claims: [ + { + id: "claim.compute_total_discount", + kind: "fact", + text: "computeTotal applies a flat 5% discount.", + truth: "code_verified", + intent: "intended", + code_anchors: [{ file: "src/foo.ts", symbol: "computeTotal" }], + }, + ], + }, + }); + + const storedClaim = service.readGraph(repo).claims.find((claim) => claim.id === "claim.compute_total_discount"); + const storedHash = storedClaim?.code_anchors?.[0]?.content_hash; + assert.equal(typeof storedHash, "string", "applyProposal must persist an anchor content hash"); + assert.match(storedHash, /^sha256:/); + + const beforeAudit = await service.auditCodeAnchors(repo); + assert.equal(beforeAudit.stale_content.length, 0, "unchanged anchored code must not audit as stale"); + + writeFileSync( + join(repo.repo_root, "src/foo.ts"), + "export function computeTotal(amount: number): number {\n return amount;\n}\n", + "utf8", + ); + + const afterAudit = await service.auditCodeAnchors(repo); + assert.deepEqual(afterAudit.stale_content.map((issue) => issue.claim_id), ["claim.compute_total_discount"]); + + const html = await service.buildGraphView(repo); assert.match(html, /Component Without Anchor/); assert.match(html, /Greplica graph view/); + assert.match(html, /stale content/); + assert.match(html, /"stale_content":1/); + + const markdown = renderGraphContextMarkdown({ + query: "computeTotal", + search_config_version: "test", + embedding_status: { checked_objects: 0, created: 0, reused: 0 }, + claims: [], + components: [], + flows: [], + sources: [], + ranked_results: [ + { + type: "claim", + rank: 1, + score: 1, + signals: {}, + object: storedClaim, + about: [], + evidence: [], + code_anchors: [{ file: "src/foo.ts", symbol: "computeTotal", status: "stale_content" }], + }, + ], + debug: {}, + }); + assert.match(markdown, /stale content/); } finally { db.close(); }