From 0ef145ad537d45569a9760392fbde901cb496c93 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Sat, 14 Nov 2026 19:25:14 +0800 Subject: [PATCH] fix: deliver reviewed audit repairs --- apps/server/src/claims/claim-http.test.ts | 80 ++++++++++ apps/server/src/claims/claim-service.test.ts | 159 +++++++++++++++++++ apps/server/src/claims/claim-service.ts | 42 +++-- 3 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/claims/claim-http.test.ts create mode 100644 apps/server/src/claims/claim-service.test.ts diff --git a/apps/server/src/claims/claim-http.test.ts b/apps/server/src/claims/claim-http.test.ts new file mode 100644 index 0000000..8d31275 --- /dev/null +++ b/apps/server/src/claims/claim-http.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ClaimEvidenceLink, FileContentResponse, PaperClaim, PaperProject } from "@fastwrite/shared"; +import { createApplication } from "../app"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "fastwrite-claim-http-")); + roots.push(root); + const app = await createApplication(root); + const request = async (path: string, method = "GET", body?: unknown, status = 200): Promise => { + const response = await app(new Request(`http://fastwrite.test/api${path}`, { + method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) + })); + expect(response.status).toBe(status); + return status === 204 ? undefined as T : response.json() as Promise; + }; + const save = async (projectId: string, content: string) => { + const path = `/projects/${projectId}/file?path=main.tex`; + const opened = await request(path); + await request(path, "PUT", { content, baseVersion: opened.file.version }); + }; + return { request, save }; +} + +test("HTTP file rename preserves another project's supported workspace evidence", async () => { + const { request, save } = await fixture(); + const projects: PaperProject[] = []; + for (const name of ["A", "B"]) { + const project = await request("/projects", "POST", { name }, 201); + projects.push(project); + await save(project.id, "The evaluated result improves accuracy by 10%."); + const [claim] = await request(`/projects/${project.id}/claim-scans`, "POST", undefined, 201); + await request(`/projects/${project.id}/claims/${claim!.id}/links`, "POST", { kind: "workspace", path: "main.tex", anchor: claim!.anchor }, 201); + await request(`/projects/${project.id}/claims/${claim!.id}`, "PATCH", { reviewStatus: "supported" }); + } + const [a, b] = projects; + const before = await request(`/projects/${b!.id}/claims`); + const links = await request(`/projects/${b!.id}/claim-links`); + await request(`/projects/${a!.id}/files`, "PATCH", { from: "main.tex", to: "renamed.tex" }, 204); + expect(await request(`/projects/${b!.id}/claim-links`)).toEqual(links); + expect(await request(`/projects/${b!.id}/claims`)).toEqual(before); + expect(await request(`/projects/${a!.id}/claims`)).toMatchObject([ + { anchor: { path: "renamed.tex" }, reviewStatus: "supported" } + ]); +}); + +test("HTTP scan and save keep decimal anchors and current reanchored semantics", async () => { + const { request, save } = await fixture(); + const project = await request("/projects", "POST", { name: "Claim semantics" }, 201); + const base = `/projects/${project.id}`; + const prefix = "\\section{Results}\nContext before the measured statement.\n"; + const suffix = "\nContext after the measured statement.\n\\end{document}\n"; + const original = "The evaluated result improves accuracy by 10.5%."; + await save(project.id, prefix + original + suffix); + const [claim] = await request(`${base}/claim-scans`, "POST", undefined, 201); + expect(claim).toMatchObject({ anchor: { exactText: original }, numbers: [{ normalized: 10.5, unit: "%" }] }); + const link = await request(`${base}/claims/${claim!.id}/links`, "POST", { + kind: "review-waiver", approvedByUser: true, reason: "Checked original measurements" + }, 201); + await request(`${base}/claims/${claim!.id}`, "PATCH", { reviewStatus: "supported" }); + const text = "This limitation shows no accuracy benefit \\cite{paper}."; + const content = prefix + text + suffix; + await save(project.id, content); + const updated = await request(`${base}/claims`); + expect(updated).toHaveLength(1); + expect(updated[0]).toMatchObject({ id: claim!.id, createdAt: claim!.createdAt, createdBy: claim!.createdBy, + anchor: { exactText: text }, normalizedText: text, type: "limitation", surface: "citation", + anchorStatus: "reanchored", reviewStatus: "needs-review" }); + expect(updated[0]!.numbers).toBeUndefined(); + expect(content.slice(updated[0]!.anchor.startOffset, updated[0]!.anchor.endOffset)).toBe(text); + expect(await request(`${base}/claims`)).toEqual(updated); + const rescanned = await request(`${base}/claim-scans`, "POST", undefined, 201); + expect(rescanned.map(({ updatedAt, ...item }) => item)).toEqual(updated.map(({ updatedAt, ...item }) => item)); + expect(await request(`${base}/claim-links`)).toEqual([link]); +}); diff --git a/apps/server/src/claims/claim-service.test.ts b/apps/server/src/claims/claim-service.test.ts new file mode 100644 index 0000000..bd668c1 --- /dev/null +++ b/apps/server/src/claims/claim-service.test.ts @@ -0,0 +1,159 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rename, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { JsonDatabase } from "../storage/database"; +import { WorkspaceService } from "../workspace/workspace-service"; +import { ClaimService } from "./claim-service"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "fastwrite-claims-")); + roots.push(root); + const database = new JsonDatabase(root); + await database.initialize(); + const workspaces = new WorkspaceService(root, database); + await workspaces.initialize(); + const claims = new ClaimService(database, workspaces); + const save = async (projectId: string, path: string, content: string) => { + const opened = await workspaces.readTextFile(projectId, path); + await workspaces.saveTextFile(projectId, path, { content, baseVersion: opened.file.version }); + }; + const persisted = async () => { + const reopened = new JsonDatabase(root); + await reopened.initialize(); + return reopened.snapshot(); + }; + return { database, workspaces, claims, save, persisted }; +} + +for (const source of ["results.tex", "results"]) { + test(`rename ${source} scopes evidence by all project claims and preserves the other project`, async () => { + const { database, workspaces, claims, save, persisted } = await fixture(); + const a = await workspaces.createEmpty("A"); + const b = await workspaces.createEmpty("B"); + const evidencePath = source === "results" ? "results/data.tex" : source; + const untouchedPath = source === "results" ? "results.bak/data.tex" : "results.tex.bak.tex"; + for (const project of [a, b]) { + await save(project.id, "main.tex", "The evaluated result improves accuracy by 10%."); + await workspaces.createFile(project.id, evidencePath, "The experiment shows reliable evidence."); + await workspaces.createFile(project.id, untouchedPath, "Unchanged evidence outside the renamed path."); + const scanned = await claims.scan(project.id); + const main = scanned.find((claim) => claim.anchor.path === "main.tex")!; + const evidence = scanned.find((claim) => claim.anchor.path === evidencePath)!; + await claims.link(project.id, main.id, { kind: "workspace", path: evidencePath, anchor: evidence.anchor }); + await claims.update(project.id, main.id, { reviewStatus: "supported" }); + const opened = await workspaces.readTextFile(project.id, untouchedPath); + await claims.link(project.id, evidence.id, { kind: "workspace", path: untouchedPath, anchor: { + path: untouchedPath, fileVersion: opened.file.version, startOffset: 0, endOffset: opened.content.length, + exactText: opened.content, prefix: "", suffix: "" + } }); + } + const before = database.snapshot(); + const bClaims = before.paperClaims.filter((claim) => claim.projectId === b.id); + const bLinks = claims.links(b.id); + const aLinks = claims.links(a.id); + // WorkspaceService currently accepts only file renames; exercise the claims + // directory-prefix contract against a real disk move without changing it. + if (source === "results") await rename(join(workspaces.workspaceRoot(a.id), source), join(workspaces.workspaceRoot(a.id), "renamed.tex")); + else await workspaces.renamePath(a.id, source, "renamed.tex"); + await claims.renamePath(a.id, source, "renamed.tex"); + expect(claims.links(b.id)).toEqual(bLinks); + expect(await claims.list(b.id)).toEqual(bClaims); + const after = await persisted(); + expect(after.paperClaims.filter((claim) => claim.projectId === b.id)).toEqual(bClaims); + expect(after.claimEvidenceLinks.filter((link) => bLinks.some((previous) => previous.id === link.id))).toEqual(bLinks); + const own = after.paperClaims.filter((claim) => claim.projectId === a.id); + expect(own.find((claim) => claim.anchor.exactText === "The experiment shows reliable evidence.")!.anchor.path) + .toBe(`renamed.tex${evidencePath.slice(source.length)}`); + expect(own.find((claim) => claim.anchor.path === "main.tex")!.reviewStatus).toBe("supported"); + expect(claims.links(a.id)).toEqual(aLinks.map((link) => link.kind === "workspace" && link.path === evidencePath + ? { ...link, path: `renamed.tex${evidencePath.slice(source.length)}`, anchor: { ...link.anchor, path: `renamed.tex${evidencePath.slice(source.length)}` } } + : link)); + }); +} + +test("scan keeps decimal sentences, original offsets, and linked identity on rescan", async () => { + const { workspaces, claims, save, persisted } = await fixture(); + const project = await workspaces.createEmpty("Decimals"); + const sentences = [ + "The evaluated result improves accuracy by 10.5%.", + "The evaluated result reduces latency by -2.75 ms!", + "The evaluated result improves accuracy by 3.25\\%?" + ]; + const content = `\\section{Results}\n ${sentences[0]} ${sentences[1]}\n\t${sentences[2]}\nThis result improves without terminal punctuation`; + await save(project.id, "main.tex", content); + const scanned = await claims.scan(project.id); + expect(scanned.map((claim) => claim.anchor.exactText)).toEqual(sentences); + expect(scanned.map((claim) => claim.numbers)).toEqual([ + [{ raw: "10.5%", normalized: 10.5, unit: "%" }], + [{ raw: "-2.75 ms", normalized: -2.75, unit: "ms" }], + [{ raw: "3.25", normalized: 3.25 }] + ]); + for (const claim of scanned) { + expect(content.slice(claim.anchor.startOffset, claim.anchor.endOffset)).toBe(claim.anchor.exactText); + expect(claim.anchor.startOffset).toBe(content.indexOf(claim.anchor.exactText)); + } + const link = await claims.link(project.id, scanned[0]!.id, { kind: "review-waiver", reason: "Checked original measurements", approvedByUser: true }); + expect((await claims.scan(project.id)).map((claim) => claim.id)).toEqual(scanned.map((claim) => claim.id)); + expect(claims.links(project.id)).toEqual([link]); + expect((await persisted()).paperClaims).toEqual(await claims.list(project.id)); +}); + +const original = "The evaluated result improves accuracy by 10%."; +const prefix = "\\section{Results}\nContext before the measured statement.\n"; +const suffix = "\nContext after the measured statement.\n\\end{document}\n"; +for (const scenario of [ + { text: "The evaluated result improves accuracy by 20%.", type: "result", surface: "number", numbers: [{ raw: "20%", normalized: 20, unit: "%" }] }, + { text: "The evaluated result improves accuracy without a percentage.", type: "result", surface: "text", numbers: undefined }, + { text: "This limitation shows no accuracy benefit.", type: "limitation", surface: "text", numbers: undefined }, + { text: "This limitation shows no accuracy benefit \\cite{paper}.", type: "limitation", surface: "citation", numbers: undefined }, + { text: "This limitation shows no accuracy benefit.", path: "caption.tex", type: "limitation", surface: "caption", numbers: undefined } +]) { + test(`context reanchor synchronizes ${scenario.surface}/${scenario.type}: ${scenario.text}`, async () => { + const { workspaces, claims, save, persisted } = await fixture(); + const path = scenario.path ?? "main.tex"; + const project = await workspaces.createEmpty("Reanchor", path); + await save(project.id, path, prefix + original + suffix); + const [before] = await claims.scan(project.id); + expect(before!.anchor.exactText).toBe(original); + const link = await claims.link(project.id, before!.id, { kind: "review-waiver", reason: "Checked original measurements", approvedByUser: true }); + await claims.update(project.id, before!.id, { reviewStatus: "supported" }); + const content = prefix + scenario.text + suffix; + await save(project.id, path, content); + const [updated] = await claims.list(project.id); + expect(updated).toMatchObject({ + id: before!.id, createdAt: before!.createdAt, createdBy: before!.createdBy, + anchor: { exactText: scenario.text, path }, anchorStatus: "reanchored", reviewStatus: "needs-review", + type: scenario.type, surface: scenario.surface, normalizedText: scenario.text + }); + expect(updated!.numbers).toEqual(scenario.numbers); + expect(content.slice(updated!.anchor.startOffset, updated!.anchor.endOffset)).toBe(scenario.text); + expect(await claims.list(project.id)).toEqual([updated!]); + const rescanned = await claims.scan(project.id); + expect(rescanned.map(({ updatedAt, ...claim }) => claim)).toEqual([updated!].map(({ updatedAt, ...claim }) => claim)); + expect(claims.links(project.id)).toEqual([link]); + expect((await persisted()).paperClaims).toEqual(rescanned); + }); +} + +test("moving exact text preserves supported review with valid waiver evidence", async () => { + const { workspaces, claims, save } = await fixture(); + const project = await workspaces.createEmpty("Moved claim"); + await save(project.id, "main.tex", prefix + original + suffix); + const [before] = await claims.scan(project.id); + const link = await claims.link(project.id, before!.id, { kind: "review-waiver", reason: "Checked original measurements", approvedByUser: true }); + const supported = await claims.update(project.id, before!.id, { reviewStatus: "supported" }); + const content = "Additional context.\n" + prefix + original + suffix; + await save(project.id, "main.tex", content); + const [moved] = await claims.list(project.id); + expect(moved).toEqual({ ...supported, anchor: { + ...supported.anchor, fileVersion: supported.anchor.fileVersion + 1, + startOffset: content.indexOf(original), endOffset: content.indexOf(original) + original.length, + prefix: content.slice(content.indexOf(original) - 40, content.indexOf(original)) + }, anchorStatus: "reanchored", updatedAt: moved!.updatedAt }); + expect(claims.links(project.id)).toEqual([link]); + expect(await claims.list(project.id)).toEqual([moved!]); +}); diff --git a/apps/server/src/claims/claim-service.ts b/apps/server/src/claims/claim-service.ts index 7513cfe..24e7391 100644 --- a/apps/server/src/claims/claim-service.ts +++ b/apps/server/src/claims/claim-service.ts @@ -23,13 +23,14 @@ export class ClaimService { for (const path of [project.mainDocument, ...paths.filter((candidate) => candidate !== project.mainDocument)]) { const file = await this.workspaces.readTextFile(projectId, path); const occurrences = new Map(); - for (const match of file.content.matchAll(/[^.!?\n]+[.!?]/g)) { - const exactText = match[0].trim(); + for (const sentence of claimSentences(file.content)) { + const exactText = sentence.text.trim(); if (exactText.length < 20 || !isClaimCandidate(exactText)) continue; - const normalizedText = exactText.replace(/\s+/g, " ").trim(); + const semantics = claimSemantics(exactText, path); + const { normalizedText } = semantics; const occurrence = occurrences.get(normalizedText) ?? 0; occurrences.set(normalizedText, occurrence + 1); - const startOffset = (match.index ?? 0) + match[0].indexOf(exactText); + const startOffset = sentence.startOffset + sentence.text.indexOf(exactText); const anchor: ClaimAnchor = { path, fileVersion: file.file.version, @@ -39,16 +40,12 @@ export class ClaimService { prefix: file.content.slice(Math.max(0, startOffset - 40), startOffset), suffix: file.content.slice(startOffset + exactText.length, startOffset + exactText.length + 40) }; - const numbers = [...exactText.matchAll(/(-?\d+(?:\.\d+)?)\s*(%|ms|s|×|x|points?)?/gi)].map((item) => ({ raw: item[0], normalized: Number(item[1]), ...(item[2] ? { unit: item[2] } : {}) })); const timestamp = now(); found.push({ id: stableClaimId(projectId, path, normalizedText, occurrence), projectId, anchor, - type: claimType(exactText), - surface: hasCitation(exactText) ? "citation" : hasNumericClaim(exactText) ? "number" : /caption/i.test(path) ? "caption" : "text", - normalizedText, - ...(numbers.length ? { numbers } : {}), + ...semantics, reviewStatus: "detected", anchorStatus: "current", createdBy: "scanner", @@ -120,8 +117,9 @@ export class ClaimService { const source = normalizeWorkspacePath(from); const target = normalizeWorkspacePath(to); await this.database.mutate((state) => { + const claimIds = new Set(state.paperClaims.filter((claim) => claim.projectId === projectId).map((claim) => claim.id)); for (const claim of state.paperClaims.filter((item) => item.projectId === projectId && (item.anchor.path === source || item.anchor.path.startsWith(`${source}/`)))) { claim.anchor.path = `${target}${claim.anchor.path.slice(source.length)}`; claim.anchorStatus = "stale"; claim.updatedAt = now(); } - for (const link of state.claimEvidenceLinks) if (link.kind === "workspace" && (link.path === source || link.path.startsWith(`${source}/`))) { link.path = `${target}${link.path.slice(source.length)}`; link.anchor.path = `${target}${link.anchor.path.slice(source.length)}`; } + for (const link of state.claimEvidenceLinks) if (claimIds.has(link.claimId) && link.kind === "workspace" && (link.path === source || link.path.startsWith(`${source}/`))) { link.path = `${target}${link.path.slice(source.length)}`; link.anchor.path = `${target}${link.anchor.path.slice(source.length)}`; } }); await this.refresh(projectId); } @@ -227,6 +225,8 @@ function refreshClaimAnchor(claim: PaperClaim, file: OpenedClaimFile): void { const contextual = contextCandidate(file.content, previous); if (contextual) { claim.anchor = anchorAt(previous.path, file.content, file.version, contextual.startOffset, contextual.exactText); + Object.assign(claim, claimSemantics(contextual.exactText, previous.path)); + if (!claim.numbers) delete claim.numbers; claim.anchorStatus = "reanchored"; if (claim.reviewStatus === "supported") claim.reviewStatus = "needs-review"; claim.updatedAt = now(); @@ -235,6 +235,28 @@ function refreshClaimAnchor(claim: PaperClaim, file: OpenedClaimFile): void { markClaimOrphaned(claim); } +function* claimSentences(content: string): Generator<{ text: string; startOffset: number }> { + let startOffset = 0; + for (let index = 0; index < content.length; index++) { + const char = content[index]; + if (char === "\n") { startOffset = index + 1; continue; } + if (char !== "." && char !== "!" && char !== "?") continue; + if (char === "." && /\d/.test(content[index - 1] ?? "") && /\d/.test(content[index + 1] ?? "")) continue; + if (index > startOffset) yield { text: content.slice(startOffset, index + 1), startOffset }; + startOffset = index + 1; + } +} + +function claimSemantics(text: string, path: string): Pick & { normalizedText: string } { + const numbers = [...text.matchAll(/(-?\d+(?:\.\d+)?)\s*(%|ms|s|×|x|points?)?/gi)].map((item) => ({ raw: item[0], normalized: Number(item[1]), ...(item[2] ? { unit: item[2] } : {}) })); + return { + type: claimType(text), + surface: hasCitation(text) ? "citation" : hasNumericClaim(text) ? "number" : /caption/i.test(path) ? "caption" : "text", + normalizedText: text.replace(/\s+/g, " ").trim(), + numbers: numbers.length ? numbers : undefined + }; +} + function markClaimOrphaned(claim: PaperClaim): void { if (claim.anchorStatus !== "orphaned") claim.updatedAt = now(); claim.anchorStatus = "orphaned"; if (claim.reviewStatus === "supported") claim.reviewStatus = "needs-review"; } function anchorAt(path: string, content: string, fileVersion: number, startOffset: number, exactText: string): ClaimAnchor { return { path, fileVersion, startOffset, endOffset: startOffset + exactText.length, exactText, prefix: content.slice(Math.max(0, startOffset - 40), startOffset), suffix: content.slice(startOffset + exactText.length, startOffset + exactText.length + 40) }; }