From 285030974bcaa81bb64ea38de2422d9bf7ad0107 Mon Sep 17 00:00:00 2001 From: wbo68410-lgtm Date: Thu, 28 May 2026 16:43:01 +0800 Subject: [PATCH] Add comment anchor drift gate --- .../comment-anchor-drift-gate/README.md | 37 +++++ .../artifacts/comment-anchor-drift-flow.svg | 17 +++ .../comment-anchor-drift-report.json | 108 +++++++++++++++ .../artifacts/comment-anchor-drift-report.md | 45 ++++++ .../data/anchor-packets.json | 103 ++++++++++++++ .../comment-anchor-drift-gate/demo.js | 69 ++++++++++ .../comment-anchor-drift-gate/gate.js | 130 ++++++++++++++++++ .../comment-anchor-drift-gate/test.js | 58 ++++++++ 8 files changed, 567 insertions(+) create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/README.md create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/demo.js create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/gate.js create mode 100644 real-time-collaborative-editor/comment-anchor-drift-gate/test.js diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/README.md b/real-time-collaborative-editor/comment-anchor-drift-gate/README.md new file mode 100644 index 00000000..72f99c65 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/README.md @@ -0,0 +1,37 @@ +# Comment Anchor Drift Gate + +This slice supports issue #12, Real-time collaborative research editor & +interface. It reviews inline comments and annotations after document edits so +collaborative reviewers do not leave comments attached to the wrong sentence, +cell output, figure, table, or protocol step. + +The module uses synthetic edit packets only. It does not connect to a live +editor, read private documents, or store collaborator identities. + +## Checks + +- Anchors whose quoted text no longer exists are blocked unless a new exact + anchor is supplied. +- Anchors that moved outside the expected section are blocked. +- Anchors attached to stale notebook output are blocked when execution evidence + changed. +- Large anchor movement requires reviewer confirmation. +- Clean anchors produce deterministic reviewer receipts. + +## Local Verification + +```bash +node real-time-collaborative-editor/comment-anchor-drift-gate/test.js +node real-time-collaborative-editor/comment-anchor-drift-gate/demo.js +git diff --check +``` + +The demo writes deterministic artifacts under +`real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/`. + +## Files + +- `gate.js` - anchor drift checks and reviewer receipt generation. +- `data/anchor-packets.json` - synthetic collaborative edit packets. +- `test.js` - Node built-in tests. +- `demo.js` - deterministic JSON, Markdown, and SVG artifact generation. diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg new file mode 100644 index 00000000..39958762 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg @@ -0,0 +1,17 @@ + + + Comment Anchor Drift Gate + + + Edit packet + comments + sections + + + Quote, section, output, + movement checks + + + Ready 2 / Blocked 3 + Findings 4 + + diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json new file mode 100644 index 00000000..48091461 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json @@ -0,0 +1,108 @@ +{ + "summary": { + "total": 5, + "ready": 2, + "blocked": 3, + "findings": 4 + }, + "reports": [ + { + "documentId": "doc-clean-methods", + "revisionId": "rev-001", + "decision": "ready", + "findings": [], + "receipt": { + "commentsReviewed": 1, + "blockedFindings": 0, + "heldFindings": 0, + "nextActions": [] + } + }, + { + "documentId": "doc-missing-anchor", + "revisionId": "rev-002", + "decision": "block", + "findings": [ + { + "code": "missing_anchor_quote", + "severity": "block", + "target": "comment-2", + "message": "comment-2 quoted text no longer exists and no replacement anchor was provided.", + "action": "Re-anchor the comment to exact current text or mark it resolved." + } + ], + "receipt": { + "commentsReviewed": 1, + "blockedFindings": 1, + "heldFindings": 0, + "nextActions": [ + "Re-anchor the comment to exact current text or mark it resolved." + ] + } + }, + { + "documentId": "doc-section-drift", + "revisionId": "rev-003", + "decision": "block", + "findings": [ + { + "code": "section_drift", + "severity": "block", + "target": "comment-3", + "message": "comment-3 moved from Limitations to Discussion.", + "action": "Confirm the comment still applies or re-anchor it in the expected section." + }, + { + "code": "large_anchor_movement", + "severity": "hold", + "target": "comment-3", + "message": "comment-3 moved 220 characters, above the trusted movement threshold.", + "action": "Ask the reviewer to confirm or manually re-anchor the comment." + } + ], + "receipt": { + "commentsReviewed": 1, + "blockedFindings": 1, + "heldFindings": 1, + "nextActions": [ + "Confirm the comment still applies or re-anchor it in the expected section.", + "Ask the reviewer to confirm or manually re-anchor the comment." + ] + } + }, + { + "documentId": "doc-notebook-output", + "revisionId": "rev-004", + "decision": "block", + "findings": [ + { + "code": "stale_notebook_output_anchor", + "severity": "block", + "target": "comment-4", + "message": "comment-4 targets notebook output whose execution record changed.", + "action": "Re-run output review and confirm the comment still applies to the new output." + } + ], + "receipt": { + "commentsReviewed": 1, + "blockedFindings": 1, + "heldFindings": 0, + "nextActions": [ + "Re-run output review and confirm the comment still applies to the new output." + ] + } + }, + { + "documentId": "doc-manual-confirmation", + "revisionId": "rev-005", + "decision": "ready", + "findings": [], + "receipt": { + "commentsReviewed": 1, + "blockedFindings": 0, + "heldFindings": 0, + "nextActions": [] + } + } + ] +} diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md new file mode 100644 index 00000000..ddc93293 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md @@ -0,0 +1,45 @@ +# Comment Anchor Drift Gate + +## Summary + +- Anchor packets reviewed: 5 +- Ready: 2 +- Blocked: 3 +- Findings: 4 + +## Reports + +### doc-clean-methods + +- Revision: rev-001 +- Decision: ready +- Comments reviewed: 1 + +### doc-missing-anchor + +- Revision: rev-002 +- Decision: block +- Comments reviewed: 1 + - block: missing_anchor_quote - comment-2 quoted text no longer exists and no replacement anchor was provided. + +### doc-section-drift + +- Revision: rev-003 +- Decision: block +- Comments reviewed: 1 + - block: section_drift - comment-3 moved from Limitations to Discussion. + - hold: large_anchor_movement - comment-3 moved 220 characters, above the trusted movement threshold. + +### doc-notebook-output + +- Revision: rev-004 +- Decision: block +- Comments reviewed: 1 + - block: stale_notebook_output_anchor - comment-4 targets notebook output whose execution record changed. + +### doc-manual-confirmation + +- Revision: rev-005 +- Decision: ready +- Comments reviewed: 1 + diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json b/real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json new file mode 100644 index 00000000..a2745087 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json @@ -0,0 +1,103 @@ +[ + { + "documentId": "doc-clean-methods", + "revisionId": "rev-001", + "policy": { "maxTrustedOffsetDelta": 120 }, + "sections": [ + { "id": "methods", "label": "Methods", "text": "Cells were seeded at 20,000 cells per well before treatment." } + ], + "comments": [ + { + "id": "comment-1", + "anchorType": "text", + "expectedSectionId": "methods", + "currentSectionId": "methods", + "quotedText": "20,000 cells per well", + "offsetDelta": 24, + "executionRecordChanged": false, + "reviewerConfirmed": false + } + ] + }, + { + "documentId": "doc-missing-anchor", + "revisionId": "rev-002", + "policy": { "maxTrustedOffsetDelta": 120 }, + "sections": [ + { "id": "results", "label": "Results", "text": "The revised figure reports normalized response values." } + ], + "comments": [ + { + "id": "comment-2", + "anchorType": "text", + "expectedSectionId": "results", + "currentSectionId": "results", + "quotedText": "raw fluorescence increased threefold", + "offsetDelta": 18, + "executionRecordChanged": false, + "reviewerConfirmed": false + } + ] + }, + { + "documentId": "doc-section-drift", + "revisionId": "rev-003", + "policy": { "maxTrustedOffsetDelta": 120 }, + "sections": [ + { "id": "discussion", "label": "Discussion", "text": "The limitation now appears in discussion text." }, + { "id": "limitations", "label": "Limitations", "text": "Sample size limitations remain important." } + ], + "comments": [ + { + "id": "comment-3", + "anchorType": "text", + "expectedSectionId": "limitations", + "currentSectionId": "discussion", + "quotedText": "limitation", + "offsetDelta": 220, + "executionRecordChanged": false, + "reviewerConfirmed": false + } + ] + }, + { + "documentId": "doc-notebook-output", + "revisionId": "rev-004", + "policy": { "maxTrustedOffsetDelta": 120 }, + "sections": [ + { "id": "notebook-cell-7", "label": "Notebook cell 7", "text": "Mean response table output: 1.8, 2.1, 2.4." } + ], + "comments": [ + { + "id": "comment-4", + "anchorType": "notebook-output", + "expectedSectionId": "notebook-cell-7", + "currentSectionId": "notebook-cell-7", + "quotedText": "Mean response table output", + "offsetDelta": 42, + "executionRecordChanged": true, + "reviewerConfirmed": false + } + ] + }, + { + "documentId": "doc-manual-confirmation", + "revisionId": "rev-005", + "policy": { "maxTrustedOffsetDelta": 120 }, + "sections": [ + { "id": "protocol", "label": "Protocol", "text": "The revised protocol includes the centrifuge speed and duration." } + ], + "comments": [ + { + "id": "comment-5", + "anchorType": "text", + "expectedSectionId": "protocol", + "currentSectionId": "protocol", + "quotedText": "centrifuge speed and duration", + "offsetDelta": 360, + "executionRecordChanged": false, + "reviewerConfirmed": true + } + ] + } +] diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/demo.js b/real-time-collaborative-editor/comment-anchor-drift-gate/demo.js new file mode 100644 index 00000000..8dbd4c99 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/demo.js @@ -0,0 +1,69 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const packets = require("./data/anchor-packets.json"); +const { reviewAnchorPackets, summarizeReports } = require("./gate"); + +const artifactDir = path.join(__dirname, "artifacts"); +const reports = reviewAnchorPackets(packets); +const summary = summarizeReports(reports); + +fs.mkdirSync(artifactDir, { recursive: true }); +fs.writeFileSync(path.join(artifactDir, "comment-anchor-drift-report.json"), `${JSON.stringify({ summary, reports }, null, 2)}\n`); +fs.writeFileSync(path.join(artifactDir, "comment-anchor-drift-report.md"), renderMarkdown(summary, reports)); +fs.writeFileSync(path.join(artifactDir, "comment-anchor-drift-flow.svg"), renderSvg(summary)); + +console.log(`reviewed ${summary.total} anchor packets`); +console.log(`ready=${summary.ready} blocked=${summary.blocked} findings=${summary.findings}`); + +function renderMarkdown(reportSummary, reportRows) { + const lines = [ + "# Comment Anchor Drift Gate", + "", + "## Summary", + "", + `- Anchor packets reviewed: ${reportSummary.total}`, + `- Ready: ${reportSummary.ready}`, + `- Blocked: ${reportSummary.blocked}`, + `- Findings: ${reportSummary.findings}`, + "", + "## Reports", + "", + ]; + + for (const report of reportRows) { + lines.push(`### ${report.documentId}`); + lines.push(""); + lines.push(`- Revision: ${report.revisionId}`); + lines.push(`- Decision: ${report.decision}`); + lines.push(`- Comments reviewed: ${report.receipt.commentsReviewed}`); + for (const finding of report.findings) { + lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvg(reportSummary) { + return ` + + Comment Anchor Drift Gate + + + Edit packet + comments + sections + + + Quote, section, output, + movement checks + + + Ready ${reportSummary.ready} / Blocked ${reportSummary.blocked} + Findings ${reportSummary.findings} + + +`; +} diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/gate.js b/real-time-collaborative-editor/comment-anchor-drift-gate/gate.js new file mode 100644 index 00000000..33853bb2 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/gate.js @@ -0,0 +1,130 @@ +"use strict"; + +function reviewAnchorPackets(packets) { + return packets.map(reviewAnchorPacket); +} + +function reviewAnchorPacket(packet) { + validatePacket(packet); + + const findings = packet.comments.flatMap((comment) => reviewComment(packet, comment)); + + return { + documentId: packet.documentId, + revisionId: packet.revisionId, + decision: findings.some((item) => item.severity === "block") ? "block" : "ready", + findings, + receipt: buildReceipt(packet, findings), + }; +} + +function validatePacket(packet) { + if (!packet.documentId || !packet.revisionId) { + throw new Error("Anchor packet must include documentId and revisionId."); + } + if (!Array.isArray(packet.comments)) { + throw new Error(`${packet.documentId} must include comments.`); + } +} + +function reviewComment(packet, comment) { + const findings = []; + const currentSection = packet.sections.find((section) => section.id === comment.currentSectionId); + const expectedSection = packet.sections.find((section) => section.id === comment.expectedSectionId); + const currentText = currentSection?.text ?? ""; + const quotePresent = currentText.includes(comment.quotedText); + + if (!quotePresent && !comment.reanchor?.quotedText) { + findings.push( + finding( + "missing_anchor_quote", + "block", + comment.id, + `${comment.id} quoted text no longer exists and no replacement anchor was provided.`, + "Re-anchor the comment to exact current text or mark it resolved.", + ), + ); + } + + if (comment.currentSectionId !== comment.expectedSectionId) { + findings.push( + finding( + "section_drift", + "block", + comment.id, + `${comment.id} moved from ${expectedSection?.label ?? comment.expectedSectionId} to ${currentSection?.label ?? comment.currentSectionId}.`, + "Confirm the comment still applies or re-anchor it in the expected section.", + ), + ); + } + + if (comment.anchorType === "notebook-output" && comment.executionRecordChanged && !comment.reviewerConfirmed) { + findings.push( + finding( + "stale_notebook_output_anchor", + "block", + comment.id, + `${comment.id} targets notebook output whose execution record changed.`, + "Re-run output review and confirm the comment still applies to the new output.", + ), + ); + } + + if (Math.abs(comment.offsetDelta ?? 0) > packet.policy.maxTrustedOffsetDelta && !comment.reviewerConfirmed) { + findings.push( + finding( + "large_anchor_movement", + "hold", + comment.id, + `${comment.id} moved ${comment.offsetDelta} characters, above the trusted movement threshold.`, + "Ask the reviewer to confirm or manually re-anchor the comment.", + ), + ); + } + + if (comment.reanchor?.quotedText && !currentText.includes(comment.reanchor.quotedText)) { + findings.push( + finding( + "invalid_reanchor_quote", + "block", + comment.id, + `${comment.id} supplied a replacement anchor that does not exist in the current section.`, + "Use an exact quote from the current document revision.", + ), + ); + } + + return findings; +} + +function finding(code, severity, target, message, action) { + return { code, severity, target, message, action }; +} + +function buildReceipt(packet, findings) { + return { + commentsReviewed: packet.comments.length, + blockedFindings: findings.filter((item) => item.severity === "block").length, + heldFindings: findings.filter((item) => item.severity === "hold").length, + nextActions: Array.from(new Set(findings.map((item) => item.action))), + }; +} + +function summarizeReports(reports) { + return reports.reduce( + (summary, report) => { + summary.total += 1; + summary.ready += report.decision === "ready" ? 1 : 0; + summary.blocked += report.decision === "block" ? 1 : 0; + summary.findings += report.findings.length; + return summary; + }, + { total: 0, ready: 0, blocked: 0, findings: 0 }, + ); +} + +module.exports = { + reviewAnchorPacket, + reviewAnchorPackets, + summarizeReports, +}; diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/test.js b/real-time-collaborative-editor/comment-anchor-drift-gate/test.js new file mode 100644 index 00000000..d0c1b5b3 --- /dev/null +++ b/real-time-collaborative-editor/comment-anchor-drift-gate/test.js @@ -0,0 +1,58 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const packets = require("./data/anchor-packets.json"); +const { reviewAnchorPacket, reviewAnchorPackets, summarizeReports } = require("./gate"); + +test("marks stable anchors as ready", () => { + const report = reviewAnchorPacket(packets[0]); + + assert.equal(report.decision, "ready"); + assert.deepEqual(report.findings, []); + assert.equal(report.receipt.commentsReviewed, 1); +}); + +test("blocks comments whose original quote disappeared", () => { + const report = reviewAnchorPacket(packets[1]); + const codes = report.findings.map((item) => item.code); + + assert.equal(report.decision, "block"); + assert.ok(codes.includes("missing_anchor_quote")); +}); + +test("blocks section drift and holds large unconfirmed movement", () => { + const report = reviewAnchorPacket(packets[2]); + const codes = report.findings.map((item) => item.code); + + assert.equal(report.decision, "block"); + assert.ok(codes.includes("section_drift")); + assert.ok(codes.includes("large_anchor_movement")); +}); + +test("blocks notebook output anchors after execution record changes", () => { + const report = reviewAnchorPacket(packets[3]); + const codes = report.findings.map((item) => item.code); + + assert.equal(report.decision, "block"); + assert.ok(codes.includes("stale_notebook_output_anchor")); +}); + +test("accepts large movement when reviewer confirmed the re-anchor", () => { + const report = reviewAnchorPacket(packets[4]); + + assert.equal(report.decision, "ready"); + assert.deepEqual(report.findings, []); +}); + +test("summarizes anchor readiness", () => { + const reports = reviewAnchorPackets(packets); + const summary = summarizeReports(reports); + + assert.deepEqual(summary, { + total: 5, + ready: 2, + blocked: 3, + findings: 4, + }); +});