From 39f37e847d8db43b1c43ac89f4bc35e6d663517c Mon Sep 17 00:00:00 2001 From: Divyansh Date: Wed, 8 Jul 2026 20:16:23 +0530 Subject: [PATCH 1/2] feat: surface stale code_verified claims in graph context Quarantine proven stale claims in graph context packets using stored anchor fingerprints, without changing ranking or writing to memory. Co-authored-by: Cursor --- .../knowledge-graph/code-anchors/freshness.ts | 33 ++++ .../graph-context/claim-freshness.ts | 49 ++++++ .../graph-context/context-builder.ts | 23 ++- libs/knowledge-graph/graph-context/render.ts | 61 ++++++- libs/knowledge-graph/graph-context/types.ts | 2 + package.json | 2 +- scripts/check-freshness-foreground.js | 152 ++++++++++++++++++ 7 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 libs/knowledge-graph/code-anchors/freshness.ts create mode 100644 libs/knowledge-graph/graph-context/claim-freshness.ts create mode 100644 scripts/check-freshness-foreground.js diff --git a/libs/knowledge-graph/code-anchors/freshness.ts b/libs/knowledge-graph/code-anchors/freshness.ts new file mode 100644 index 0000000..d724ea7 --- /dev/null +++ b/libs/knowledge-graph/code-anchors/freshness.ts @@ -0,0 +1,33 @@ +import type { ResolvedCodeAnchor, ResolvedCodeAnchorStatus } from "./types.js"; + +export type StaleReason = "structural" | "content"; + +export interface AnchorCheck { + anchor: ResolvedCodeAnchor; + storedHash: string | undefined; + currentHash: string | undefined; +} + +const structuralStatuses: ReadonlySet = new Set([ + "missing_file", + "missing_symbol", + "ambiguous_symbol", +]); + +/** Return why a claim is proven stale, or undefined when staleness is not proven. */ +export function classifyStale(checks: AnchorCheck[]): StaleReason | undefined { + if (checks.length === 0) return undefined; + if (checks.every((check) => isStructurallyBroken(check.anchor))) return "structural"; + if (checks.some(hasContentDrift)) return "content"; + return undefined; +} + +export function isStructurallyBroken(anchor: ResolvedCodeAnchor): boolean { + return structuralStatuses.has(anchor.status); +} + +function hasContentDrift(check: AnchorCheck): boolean { + if (isStructurallyBroken(check.anchor)) return false; + if (check.storedHash === undefined || check.currentHash === undefined) return false; + return check.currentHash !== check.storedHash; +} diff --git a/libs/knowledge-graph/graph-context/claim-freshness.ts b/libs/knowledge-graph/graph-context/claim-freshness.ts new file mode 100644 index 0000000..e0c2ac4 --- /dev/null +++ b/libs/knowledge-graph/graph-context/claim-freshness.ts @@ -0,0 +1,49 @@ +import type { SqliteRepository } from "../../storage/sqlite/repository.js"; +import { anchorFingerprintKey, fingerprintAnchor } from "../code-anchors/fingerprint.js"; +import { classifyStale, isStructurallyBroken, type AnchorCheck } from "../code-anchors/freshness.js"; +import { CodeAnchorResolver } from "../code-anchors/resolver.js"; +import type { ResolvedCodeAnchor } from "../code-anchors/types.js"; +import type { ClaimContextResult } from "./types.js"; + +type FingerprintReader = Pick; + +export async function attachStaleClaims( + claims: ClaimContextResult[], + repository: FingerprintReader, + repoId: string, + repoRoot: string | undefined, + resolver: CodeAnchorResolver, +): Promise { + const candidates = claims.filter((claim) => claim.object.truth === "code_verified" && claim.code_anchors.length > 0); + const storedByClaim = repository.readClaimAnchorFingerprints(repoId, candidates.map((claim) => claim.object.id)); + + return Promise.all(claims.map(async (claim) => { + const stored = storedByClaim.get(claim.object.id); + if (stored === undefined || Object.keys(stored).length === 0) return claim; + + const checks = await staleChecks(claim.code_anchors, stored, repoRoot, resolver); + const reason = classifyStale(checks); + return reason === undefined ? claim : { ...claim, freshness: { reason } }; + })); +} + +async function staleChecks( + anchors: ResolvedCodeAnchor[], + stored: Record, + repoRoot: string | undefined, + resolver: CodeAnchorResolver, +): Promise { + if (anchors.every(isStructurallyBroken)) { + return anchors.map((anchor) => ({ anchor, storedHash: stored[anchorFingerprintKey(anchor)], currentHash: undefined })); + } + + return Promise.all(anchors.map(async (anchor) => { + const storedHash = stored[anchorFingerprintKey(anchor)]; + const shouldHash = storedHash !== undefined && (anchor.status === "resolved" || anchor.status === "file_only"); + return { + anchor, + storedHash, + currentHash: shouldHash ? await fingerprintAnchor(repoRoot, anchor, resolver) : undefined, + }; + })); +} diff --git a/libs/knowledge-graph/graph-context/context-builder.ts b/libs/knowledge-graph/graph-context/context-builder.ts index 0a43b02..e8c8879 100644 --- a/libs/knowledge-graph/graph-context/context-builder.ts +++ b/libs/knowledge-graph/graph-context/context-builder.ts @@ -19,6 +19,7 @@ import type { ClaimContextResult, ClaimEvidenceResult, ComponentContextResult, E import { rankPacketResults, roundRankedSignals, selectGraphObjects } from "./packet-rank.js"; import { CodeAnchorResolver } from "../code-anchors/resolver.js"; import type { ResolvedCodeAnchor } from "../code-anchors/types.js"; +import { attachStaleClaims } from "./claim-freshness.js"; export interface BuildGraphContextOptions { warnOnCreatedEmbeddings?: boolean; @@ -52,7 +53,7 @@ export class GraphContextBuilder { const queryEmbedding = await embedder.embed(query); const embeddings = this.loadEmbeddings(repoId, config); - return this.buildFromVectors(graph, query, queryEmbedding, embeddings, { + return this.buildFromVectors(repoId, graph, query, queryEmbedding, embeddings, { ...options, config, embeddingStatus, @@ -60,6 +61,7 @@ export class GraphContextBuilder { } async buildFromVectors( + repoId: string, graph: GraphReadResult, query: string, queryEmbedding: number[], @@ -85,29 +87,38 @@ export class GraphContextBuilder { options.repoRoot, options.resolveCodeAnchors ?? true, ); + const claimsWithFreshness = this.repository === undefined + ? selectedClaims + : await attachStaleClaims( + selectedClaims, + this.repository, + repoId, + options.repoRoot, + this.codeAnchorResolver, + ); const selectedComponents = selectGraphObjects( ranked.components, - selectedClaims, + claimsWithFreshness, "component", config, ) as ComponentContextResult[]; const selectedFlows = selectGraphObjects( ranked.flows, - selectedClaims, + claimsWithFreshness, "flow", config, ) as FlowContextResult[]; - const rankedResults = rankPacketResults(selectedClaims, selectedComponents, selectedFlows, graph, config); + const rankedResults = rankPacketResults(claimsWithFreshness, selectedComponents, selectedFlows, graph, config); return { query, search_config_version: config.version, embedding_status: options.embeddingStatus, - claims: selectedClaims, + claims: claimsWithFreshness, components: selectedComponents, flows: selectedFlows, ranked_results: rankedResults, - sources: selectedEvidenceSources(selectedClaims), + sources: selectedEvidenceSources(claimsWithFreshness), debug: { ranked_results: rankedResults, base_ranked_claims: baseRanked.claims.map((document, index) => toRankedDebugResult(document, index) as RankedContextDebugResult), diff --git a/libs/knowledge-graph/graph-context/render.ts b/libs/knowledge-graph/graph-context/render.ts index 16db6be..aca5fb9 100644 --- a/libs/knowledge-graph/graph-context/render.ts +++ b/libs/knowledge-graph/graph-context/render.ts @@ -8,46 +8,66 @@ export function renderGraphContextMarkdown(result: GraphContextResult): string { const rankedComponents = result.ranked_results.filter((item) => item.type === "component"); const rankedFlows = result.ranked_results.filter((item) => item.type === "flow"); const rankedClaims = result.ranked_results.filter((item) => item.type === "claim"); + const staleClaims = rankedClaims.filter((claim) => claim.freshness !== undefined); + const liveClaims = rankedClaims.filter((claim) => claim.freshness === undefined); + const staleClaimIds = new Set(staleClaims.map((claim) => claim.object.id)); const componentsById = new Map(result.components.map((component) => [component.object.id, component.object.name])); const flowsById = new Map(result.flows.map((flow) => [flow.object.id, flow.object.name])); const content = [ "# Graph Context", "", - "## Best Claims", - "", - ...renderRankedClaims(rankedClaims, componentsById, flowsById), + ...renderClaimSections(liveClaims, staleClaims, componentsById, flowsById), "", "## Related Components", "", - ...renderRankedComponents(rankedComponents), + ...renderRankedComponents(rankedComponents, staleClaimIds), "", "## Related Flows", "", - ...renderRankedFlows(rankedFlows), + ...renderRankedFlows(rankedFlows, staleClaimIds), ]; return lines(...content); } +function renderClaimSections( + liveClaims: Array>, + staleClaims: Array>, + componentsById: Map, + flowsById: Map, +): string[] { + if (liveClaims.length === 0 && staleClaims.length > 0) { + return renderStaleClaims(staleClaims, componentsById, flowsById); + } + return [ + "## Best Claims", + "", + ...renderRankedClaims(liveClaims, componentsById, flowsById), + ...renderStaleClaims(staleClaims, componentsById, flowsById), + ]; +} + function renderRankedComponents( components: Array>, + staleClaimIds: Set, ): string[] { if (components.length === 0) return ["- None."]; return components.map((component, index) => { const relation = component.context_relation === "additional" ? " additional" : ""; const anchor = component.object.code_anchor === undefined ? "" : ` Anchor: \`${component.object.code_anchor}\`.`; - const claims = component.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${component.matched_claim_ids.map((id) => `\`${id}\``).join(", ")}.`; + const claims = component.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${component.matched_claim_ids.map((id) => claimReference(id, staleClaimIds)).join(", ")}.`; return `- ${index + 1}. ${component.object.name}${relation}. ID: \`${component.object.id}\`.${anchor}${claims}${provenanceLabel(component.object)}`; }); } function renderRankedFlows( flows: Array>, + staleClaimIds: Set, ): string[] { if (flows.length === 0) return ["- None."]; return flows.map((flow, index) => { const relation = flow.context_relation === "additional" ? " additional" : ""; - const claims = flow.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${flow.matched_claim_ids.map((id) => `\`${id}\``).join(", ")}.`; + const claims = flow.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${flow.matched_claim_ids.map((id) => claimReference(id, staleClaimIds)).join(", ")}.`; return `- ${index + 1}. ${flow.object.name}${relation}. ID: \`${flow.object.id}\`.${claims}${provenanceLabel(flow.object)}`; }); } @@ -61,12 +81,13 @@ function renderRankedClaims( return claims.flatMap((claim, index) => { const anchors = claim.code_anchors.length === 0 ? "" : ` Anchor: ${claim.code_anchors.map(anchorLabel).join("; ")}.`; const about = aboutLabel(claim.about, componentsById, flowsById); + const freshness = freshnessLabel(claim); return [ `### ${index + 1}. ${claim.object.id}`, "", claim.object.text, "", - `${anchors}${about}${provenanceLabel(claim.object)}`.trim(), + `${freshness}${anchors}${about}${provenanceLabel(claim.object)}`.trim(), "", ]; }); @@ -124,6 +145,26 @@ function provenanceValues(provenance: ManagedObjectProvenance | ManagedObjectOri return values; } +function freshnessLabel(claim: Extract): string { + return claim.freshness === undefined + ? "" + : `[STALE: ${claim.freshness.reason} drift - re-verify against current code].`; +} + +function renderStaleClaims( + claims: Array>, + componentsById: Map, + flowsById: Map, +): string[] { + if (claims.length === 0) return []; + return [ + "", + "## Needs re-verification", + "", + ...renderRankedClaims(claims, componentsById, flowsById), + ]; +} + function anchorLabel(anchor: Extract["code_anchors"][number]): string { const base = anchor.symbol === undefined ? anchor.file : `${anchor.file}#${anchor.symbol}`; if (anchor.status === "resolved" && anchor.start_line !== undefined) { @@ -149,6 +190,10 @@ function aboutLabel( return ` About: ${labels.join("; ")}.`; } +function claimReference(id: string, staleClaimIds: Set): string { + return staleClaimIds.has(id) ? `\`${id}\` (stale)` : `\`${id}\``; +} + function lines(...values: string[]): string { return `${values.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } diff --git a/libs/knowledge-graph/graph-context/types.ts b/libs/knowledge-graph/graph-context/types.ts index 0d7e7b3..b964ff8 100644 --- a/libs/knowledge-graph/graph-context/types.ts +++ b/libs/knowledge-graph/graph-context/types.ts @@ -1,6 +1,7 @@ import type { Claim } from "../claim.js"; import type { ResolvedCodeAnchor } from "../code-anchors/types.js"; import type { Component, Flow, Source } from "../schema.js"; +import type { StaleReason } from "../code-anchors/freshness.js"; export interface EmbeddingStatus { checked_objects: number; @@ -47,6 +48,7 @@ export interface ClaimContextResult { about: Array<{ type: "component" | "flow"; id: string }>; evidence: ClaimEvidenceResult[]; code_anchors: ResolvedCodeAnchor[]; + freshness?: { reason: StaleReason }; } export interface GraphObjectContextResult { diff --git a/package.json b/package.json index d6dd889..dbeeac2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js && node scripts/check-freshness-foreground.js", "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js", "test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", diff --git a/scripts/check-freshness-foreground.js b/scripts/check-freshness-foreground.js new file mode 100644 index 0000000..2a3e1be --- /dev/null +++ b/scripts/check-freshness-foreground.js @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { CodeAnchorResolver } = await import(new URL("dist/libs/knowledge-graph/code-anchors/resolver.js", root)); +const { fingerprintClaimAnchors } = await import(new URL("dist/libs/knowledge-graph/code-anchors/fingerprint.js", root)); +const { classifyStale } = await import(new URL("dist/libs/knowledge-graph/code-anchors/freshness.js", root)); +const { attachStaleClaims } = await import(new URL("dist/libs/knowledge-graph/graph-context/claim-freshness.js", root)); +const { renderGraphContextMarkdown } = await import(new URL("dist/libs/knowledge-graph/graph-context/render.js", root)); + +const repo = mkdtempSync(join(tmpdir(), "greplica-freshness-foreground-test-")); +const file = join(repo, "mod.py"); +const anchor = { file: "mod.py", symbol: "foo" }; +const codeVerifiedClaim = { + id: "claim.foo", + kind: "fact", + text: "foo returns 3", + truth: "code_verified", + intent: "intended", + code_anchors: [anchor], +}; +const sourceVerifiedClaim = { ...codeVerifiedClaim, id: "claim.source", truth: "source_verified" }; +const resolver = new CodeAnchorResolver(); + +writeFileSync(file, "def foo():\n return 3\n"); +const baseline = new Map([["claim.foo", await fingerprintClaimAnchors(repo, [anchor], resolver)]]); + +assert.equal(classifyStale([ + { anchor: { ...anchor, status: "missing_symbol" }, storedHash: "a", currentHash: undefined }, +]), "structural"); +assert.equal(classifyStale([ + { anchor: { ...anchor, status: "resolved" }, storedHash: "a", currentHash: "b" }, +]), "content"); +assert.equal(classifyStale([ + { anchor: { ...anchor, status: "resolved" }, storedHash: "a", currentHash: "a" }, +]), undefined); + +writeFileSync(file, "def foo():\n return 3\n"); +const freshResolver = new CodeAnchorResolver(); +const fresh = await attachStaleClaims([await claimResult(codeVerifiedClaim, freshResolver)], fakeRepository(baseline), "repo", repo, freshResolver); +assert.equal(fresh[0].freshness, undefined); +assert.doesNotMatch(renderResult(fresh), /Needs re-verification/); + +writeFileSync(file, "def foo():\n return 8\n"); +const contentResolver = new CodeAnchorResolver(); +const contentDrift = await attachStaleClaims([await claimResult(codeVerifiedClaim, contentResolver)], fakeRepository(baseline), "repo", repo, contentResolver); +assert.deepEqual(contentDrift[0].freshness, { reason: "content" }); +assert.match(renderResult(contentDrift, ["claim.foo"]), /## Needs re-verification/); +assert.match(renderResult(contentDrift, ["claim.foo"]), /\[STALE: content drift - re-verify against current code\]/); +assert.match(renderResult(contentDrift, ["claim.foo"]), /`claim.foo` \(stale\)/); +assert.doesNotMatch(renderResult(contentDrift), /## Best Claims\n\n- None\./); + +writeFileSync(file, "def bar():\n return 3\n"); +const structuralResolver = new CodeAnchorResolver(); +const structuralDrift = await attachStaleClaims([await claimResult(codeVerifiedClaim, structuralResolver)], fakeRepository(baseline), "repo", repo, structuralResolver); +assert.deepEqual(structuralDrift[0].freshness, { reason: "structural" }); +assert.match(renderResult(structuralDrift), /\[STALE: structural drift - re-verify against current code\]/); + +writeFileSync(file, "def foo():\n return 8\n"); +const noBaselineResolver = new CodeAnchorResolver(); +const noBaseline = await attachStaleClaims([await claimResult(codeVerifiedClaim, noBaselineResolver)], fakeRepository(new Map()), "repo", repo, noBaselineResolver); +assert.equal(noBaseline[0].freshness, undefined); + +const sourceResolver = new CodeAnchorResolver(); +const sourceVerified = await attachStaleClaims([await claimResult(sourceVerifiedClaim, sourceResolver)], fakeRepository(baseline), "repo", repo, sourceResolver); +assert.equal(sourceVerified[0].freshness, undefined); + +console.log("check-freshness-foreground: ok"); + +async function claimResult(claim, claimResolver) { + return { + rank: 1, + score: 1, + signals: signals(), + object: claim, + about: [{ type: "component", id: "component.mod" }], + evidence: [], + code_anchors: await claimResolver.resolveMany(repo, claim.code_anchors), + }; +} + +function fakeRepository(fingerprints) { + return { + readClaimAnchorFingerprints(_repoId, ids) { + return new Map(ids.flatMap((id) => { + const value = fingerprints.get(id); + return value === undefined ? [] : [[id, value]]; + })); + }, + }; +} + +function renderResult(claims, matchedClaimIds = []) { + return renderGraphContextMarkdown({ + query: "foo", + search_config_version: "test", + embedding_status: { checked_objects: 0, created: 0, reused: 0 }, + claims, + components: [{ + rank: 1, + score: 1, + context_relation: "additional", + direct_score: 0, + direct_raw_score: 0, + claim_support_score: 1, + claim_support_raw_score: 1, + signals: signals(), + object: { id: "component.mod", name: "Module", code_anchor: "mod.py" }, + matched_claim_ids: matchedClaimIds, + }], + flows: [], + ranked_results: [ + ...claims.map((claim) => ({ ...claim, type: "claim" })), + { + rank: 2, + score: 1, + context_relation: "additional", + direct_score: 0, + direct_raw_score: 0, + claim_support_score: 1, + claim_support_raw_score: 1, + signals: signals(), + object: { id: "component.mod", name: "Module", code_anchor: "mod.py" }, + matched_claim_ids: matchedClaimIds, + type: "component", + }, + ], + sources: [], + }); +} + +function signals() { + return { + semantic_score: 1, + semantic_raw_score: 1, + semantic_rank: 1, + bm25_score: 1, + bm25_raw_score: 1, + bm25_rank: 1, + weighted_score: 1, + weighted_raw_score: 1, + pre_coherence_score: 1, + graph_score: 1, + graph_raw_score: 1, + graph_sources: [], + coherence_score: 1, + coherence_raw_score: 1, + coherence_sources: [], + }; +} From 6cbc9d4bb004904ec62249fce5fb4396a827fc37 Mon Sep 17 00:00:00 2001 From: Divyansh Date: Wed, 8 Jul 2026 20:48:51 +0530 Subject: [PATCH 2/2] refactor: share anchor content-drift hash compare in freshness.ts Unify audit and graph-context stale detection on one pure helper so both paths use the same baseline comparison rule. Co-authored-by: Cursor --- libs/knowledge-graph/code-anchors/audit.ts | 3 ++- libs/knowledge-graph/code-anchors/freshness.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/libs/knowledge-graph/code-anchors/audit.ts b/libs/knowledge-graph/code-anchors/audit.ts index 424d20a..e52a329 100644 --- a/libs/knowledge-graph/code-anchors/audit.ts +++ b/libs/knowledge-graph/code-anchors/audit.ts @@ -1,4 +1,5 @@ import type { Claim, ClaimCodeAnchor } from "../claim.js"; +import { anchorContentDrift } from "./freshness.js"; import { anchorFingerprintKey, fingerprintAnchor } from "./fingerprint.js"; import { CodeAnchorResolver } from "./resolver.js"; import type { ClaimAnchorAuditResult } from "./types.js"; @@ -73,5 +74,5 @@ async function hasDrifted( const stored = baseline[anchorFingerprintKey(anchor)]; if (stored === undefined) return false; const current = await fingerprintAnchor(repoRoot, anchor, resolver); - return current !== undefined && current !== stored; + return anchorContentDrift(stored, current); } diff --git a/libs/knowledge-graph/code-anchors/freshness.ts b/libs/knowledge-graph/code-anchors/freshness.ts index d724ea7..52a3b42 100644 --- a/libs/knowledge-graph/code-anchors/freshness.ts +++ b/libs/knowledge-graph/code-anchors/freshness.ts @@ -26,8 +26,16 @@ export function isStructurallyBroken(anchor: ResolvedCodeAnchor): boolean { return structuralStatuses.has(anchor.status); } +/** True when both hashes are known and the current anchor code no longer matches the baseline. */ +export function anchorContentDrift( + storedHash: string | undefined, + currentHash: string | undefined, +): boolean { + if (storedHash === undefined || currentHash === undefined) return false; + return currentHash !== storedHash; +} + function hasContentDrift(check: AnchorCheck): boolean { if (isStructurallyBroken(check.anchor)) return false; - if (check.storedHash === undefined || check.currentHash === undefined) return false; - return check.currentHash !== check.storedHash; + return anchorContentDrift(check.storedHash, check.currentHash); }