diff --git a/collaborative-research-editor/find-replace-safety-guard/README.md b/collaborative-research-editor/find-replace-safety-guard/README.md new file mode 100644 index 00000000..f0301d6e --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/README.md @@ -0,0 +1,21 @@ +# Collaborative Find/Replace Safety Guard + +Focused slice for SCIBASE issue #12: a real-time collaborative editor guard that previews global find/replace operations before they mutate shared manuscript state. + +## What it checks + +- Protected section locks and final-review freeze state. +- Citation keys, cross-reference anchors, and LaTeX command names. +- Notebook/code cells where semantic replacements need explicit review. +- Inline comment quote anchors that would no longer resolve after replacement. +- WYSIWYG/source-mode replacement scope so batch edits remain reviewable. + +The module is deterministic and dependency-free. It uses synthetic document packets only; it does not connect to live editors, private projects, external services, or credentials. + +## Validation + +```bash +node collaborative-research-editor/find-replace-safety-guard/test.js +node collaborative-research-editor/find-replace-safety-guard/demo.js +node collaborative-research-editor/find-replace-safety-guard/make-demo-video.js +``` diff --git a/collaborative-research-editor/find-replace-safety-guard/demo.js b/collaborative-research-editor/find-replace-safety-guard/demo.js new file mode 100644 index 00000000..2fd621f2 --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/demo.js @@ -0,0 +1,15 @@ +const fs = require("fs"); +const path = require("path"); +const { replacementPreview, renderMarkdownReport, renderSvgSummary } = require("./index"); +const { riskyDocument, riskyOperation } = require("./sample-data"); + +const outputDir = path.join(__dirname, "reports"); +fs.mkdirSync(outputDir, { recursive: true }); + +const result = replacementPreview(riskyDocument, riskyOperation); +fs.writeFileSync(path.join(outputDir, "find-replace-review.json"), `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(path.join(outputDir, "find-replace-review.md"), renderMarkdownReport(result)); +fs.writeFileSync(path.join(outputDir, "find-replace-summary.svg"), renderSvgSummary(result)); + +console.log(`decision=${result.decision} riskScore=${result.riskScore} findings=${result.findings.length}`); +console.log(`reports=${outputDir}`); diff --git a/collaborative-research-editor/find-replace-safety-guard/index.js b/collaborative-research-editor/find-replace-safety-guard/index.js new file mode 100644 index 00000000..527afeeb --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/index.js @@ -0,0 +1,248 @@ +const crypto = require("crypto"); + +const SEVERITY_WEIGHT = { + blocker: 30, + high: 18, + medium: 9, + low: 4, +}; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildMatcher(operation) { + const flags = operation.caseSensitive ? "g" : "gi"; + const source = operation.wholeWord ? `\\b${escapeRegExp(operation.find)}\\b` : escapeRegExp(operation.find); + return new RegExp(source, flags); +} + +function blockText(block) { + return [block.content, block.key, block.anchor, block.caption].filter(Boolean).join("\n"); +} + +function countMatches(text, matcher) { + const matches = String(text).match(matcher); + return matches ? matches.length : 0; +} + +function classifyBlockRisk(block, section, operation, matcher) { + const findings = []; + const text = blockText(block); + const matches = countMatches(text, matcher); + if (!matches) { + return findings; + } + + const identity = `${section.id}/${block.id}`; + if (section.locked || section.freeze === "final-review") { + findings.push({ + severity: "blocker", + code: "LOCKED_SECTION_MATCH", + title: "Batch replacement touches a locked or final-review section", + evidence: `${identity} has ${matches} match(es) while section state is ${section.locked ? "locked" : section.freeze}.`, + action: "Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions.", + }); + } + + if (block.type === "citation" || /@[A-Za-z0-9:_-]+/.test(text)) { + findings.push({ + severity: "high", + code: "CITATION_KEY_MUTATION", + title: "Replacement would alter citation keys or reference anchors", + evidence: `${identity} contains citation/reference text matching "${operation.find}".`, + action: "Route citation-key changes through the reference manager merge workflow instead of direct replacement.", + }); + } + + if (block.type === "latex" && /\\[A-Za-z]+/.test(text)) { + findings.push({ + severity: "high", + code: "LATEX_COMMAND_MUTATION", + title: "Replacement would alter LaTeX command or equation source", + evidence: `${identity} is a LaTeX block with ${matches} match(es).`, + action: "Preview equation output and require formula-owner approval before applying source-mode replacements.", + }); + } + + if (block.type === "code" || block.type === "notebook") { + findings.push({ + severity: "high", + code: "CODE_CELL_MUTATION", + title: "Replacement would mutate notebook or code cell source", + evidence: `${identity} is ${block.type} source with ${matches} match(es).`, + action: "Create a code-review task and rerun the notebook before sharing updated outputs.", + }); + } + + if (block.type === "cross-reference" || /fig:|tbl:|eq:/i.test(text)) { + findings.push({ + severity: "medium", + code: "CROSS_REFERENCE_TOUCH", + title: "Replacement touches cross-reference anchors", + evidence: `${identity} contains figure, table, or equation anchors.`, + action: "Regenerate cross-reference maps and hold export until anchors resolve.", + }); + } + + return findings; +} + +function replacementPreview(document, operation) { + const matcher = buildMatcher(operation); + const scope = new Set(operation.scopeSections || []); + const changedBlocks = []; + const findings = []; + + document.sections.forEach((section) => { + if (scope.size && !scope.has(section.id)) { + return; + } + section.blocks.forEach((block) => { + const text = blockText(block); + const matches = countMatches(text, matcher); + if (!matches) { + return; + } + const before = text; + const after = text.replace(matcher, operation.replace); + changedBlocks.push({ + sectionId: section.id, + blockId: block.id, + type: block.type, + matches, + beforeDigest: digest(before), + afterDigest: digest(after), + }); + findings.push(...classifyBlockRisk(block, section, operation, matcher)); + }); + }); + + (document.comments || []).forEach((comment) => { + const changed = changedBlocks.some((block) => block.blockId === comment.blockId); + if (changed && comment.quote && countMatches(comment.quote, matcher)) { + findings.push({ + severity: "medium", + code: "COMMENT_ANCHOR_DRIFT", + title: "Replacement would invalidate an inline comment quote anchor", + evidence: `Comment ${comment.id} anchors to text that would be replaced in block ${comment.blockId}.`, + action: "Convert the edit to a suggestion and re-anchor or resolve the comment before applying.", + }); + } + }); + + if (operation.mode === "wysiwyg" && findings.some((finding) => /LATEX|CODE|CITATION/.test(finding.code))) { + findings.push({ + severity: "medium", + code: "WYSIWYG_SCOPE_MISMATCH", + title: "WYSIWYG replacement crosses source-only content", + evidence: "The operation starts in WYSIWYG mode but reaches LaTeX, citation, or code source blocks.", + action: "Split the operation by block type so source-mode changes receive explicit review.", + }); + } + + const riskScore = Math.min(100, findings.reduce((sum, finding) => sum + SEVERITY_WEIGHT[finding.severity], 0)); + const blockers = findings.filter((finding) => finding.severity === "blocker").length; + const high = findings.filter((finding) => finding.severity === "high").length; + const decision = blockers || high ? "hold-batch-edit" : findings.length ? "suggestion-preview-required" : "safe-to-apply"; + const packet = { + documentId: document.id, + operation: { + find: operation.find, + replace: operation.replace, + mode: operation.mode, + wholeWord: Boolean(operation.wholeWord), + scopeSections: operation.scopeSections || [], + }, + decision, + riskScore, + changedBlocks, + findings, + actions: findings.map((finding) => ({ code: finding.code, action: finding.action })), + generatedFrom: "synthetic-data-only", + }; + + return { + ...packet, + auditDigest: digest(packet), + }; +} + +function renderMarkdownReport(result) { + const lines = [ + `# Collaborative Find/Replace Safety Review`, + "", + `Decision: ${result.decision}`, + `Risk score: ${result.riskScore}`, + `Changed blocks: ${result.changedBlocks.length}`, + `Audit digest: ${result.auditDigest}`, + "", + "## Findings", + ]; + if (!result.findings.length) { + lines.push("- No blocking find/replace risks."); + } else { + result.findings.forEach((finding) => { + lines.push(`- [${finding.severity}] ${finding.title}: ${finding.evidence}`); + lines.push(` Action: ${finding.action}`); + }); + } + lines.push("", "## Safety", "- Synthetic document packet only; no live editor or private manuscript data."); + return `${lines.join("\n")}\n`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvgSummary(result) { + const color = result.decision === "hold-batch-edit" ? "#b42318" : result.decision === "safe-to-apply" ? "#067647" : "#b54708"; + const rows = result.findings + .slice(0, 5) + .map((finding, index) => { + const y = 215 + index * 54; + return `${escapeXml(finding.severity.toUpperCase())}: ${escapeXml(finding.title).slice(0, 72)} +${escapeXml(finding.code)}`; + }) + .join("\n"); + return ` + + +Collaborative Find/Replace Safety Guard +Preview batch edits before shared manuscript mutation + +${escapeXml(result.decision.toUpperCase())} +Risk score: ${result.riskScore} +${rows} +Audit digest: ${escapeXml(result.auditDigest)} | Synthetic data only | No external services +`; +} + +module.exports = { + replacementPreview, + renderMarkdownReport, + renderSvgSummary, + stableStringify, + digest, +}; diff --git a/collaborative-research-editor/find-replace-safety-guard/make-demo-video.js b/collaborative-research-editor/find-replace-safety-guard/make-demo-video.js new file mode 100644 index 00000000..89244b77 --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/make-demo-video.js @@ -0,0 +1,157 @@ +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { replacementPreview } = require("./index"); +const { riskyDocument, riskyOperation } = require("./sample-data"); + +const WIDTH = 1280; +const HEIGHT = 720; +const outputDir = path.join(__dirname, "reports"); +const frameDir = path.join(outputDir, "ppm-frames"); +fs.mkdirSync(frameDir, { recursive: true }); + +const GLYPHS = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"], + Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], + Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], + "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], + "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], + "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], + "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], + "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], + "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"], + "6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"], + "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], + "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], + "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"], + "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"], + "#": ["01010", "11111", "01010", "01010", "11111", "01010", "01010"], + " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"], +}; + +function rgb(hex) { + const clean = hex.replace("#", ""); + return [Number.parseInt(clean.slice(0, 2), 16), Number.parseInt(clean.slice(2, 4), 16), Number.parseInt(clean.slice(4, 6), 16)]; +} + +function canvas(color) { + const data = Buffer.alloc(WIDTH * HEIGHT * 3); + const [r, g, b] = rgb(color); + for (let offset = 0; offset < data.length; offset += 3) { + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + } + return data; +} + +function rect(data, x, y, width, height, color) { + const [r, g, b] = rgb(color); + for (let row = Math.max(0, y); row < Math.min(HEIGHT, y + height); row += 1) { + for (let col = Math.max(0, x); col < Math.min(WIDTH, x + width); col += 1) { + const offset = (row * WIDTH + col) * 3; + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + } + } +} + +function text(data, value, x, y, scale, color) { + const [r, g, b] = rgb(color); + let cursor = x; + String(value).toUpperCase().split("").forEach((char) => { + const glyph = GLYPHS[char] || GLYPHS[" "]; + glyph.forEach((line, gy) => { + line.split("").forEach((pixel, gx) => { + if (pixel !== "1") return; + for (let sy = 0; sy < scale; sy += 1) { + for (let sx = 0; sx < scale; sx += 1) { + const px = cursor + gx * scale + sx; + const py = y + gy * scale + sy; + if (px < 0 || px >= WIDTH || py < 0 || py >= HEIGHT) continue; + const offset = (py * WIDTH + px) * 3; + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + } + } + }); + }); + cursor += 6 * scale; + }); +} + +function writePpm(filePath, data) { + fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`), data])); +} + +function renderFrame(filePath, subtitle, lines, result) { + const data = canvas("#f7f8fa"); + rect(data, 36, 36, 1208, 648, "#ffffff"); + rect(data, 36, 36, 1208, 3, "#d0d5dd"); + rect(data, 36, 681, 1208, 3, "#d0d5dd"); + rect(data, 36, 36, 3, 648, "#d0d5dd"); + rect(data, 1241, 36, 3, 648, "#d0d5dd"); + text(data, "COLLAB FIND REPLACE SAFETY GUARD", 56, 74, 5, "#101828"); + text(data, subtitle, 56, 126, 3, "#475467"); + rect(data, 56, 166, 390, 54, "#b42318"); + text(data, "HOLD BATCH EDIT", 76, 184, 4, "#ffffff"); + text(data, `RISK SCORE ${result.riskScore}`, 476, 184, 4, "#101828"); + lines.forEach((line, index) => text(data, line, 56, 260 + index * 62, 4, "#111827")); + text(data, "JSON MARKDOWN SVG MP4 REVIEWER ARTIFACTS", 56, 592, 3, "#344054"); + text(data, `AUDIT DIGEST ${result.auditDigest.toUpperCase()}`, 56, 638, 3, "#667085"); + writePpm(filePath, data); +} + +const result = replacementPreview(riskyDocument, riskyOperation); +const frames = [ + ["SCIBASE ISSUE #12 BATCH EDIT PREVIEW", ["BLOCKER LOCKED SECTION MATCH", "HIGH CITATION KEY MUTATION", "HIGH LATEX COMMAND MUTATION"]], + ["SOURCE AND WYSIWYG SCOPE PROTECTION", ["HIGH CODE CELL MUTATION", "MEDIUM WYSIWYG SCOPE MISMATCH", "MEDIUM CROSS REFERENCE TOUCH"]], + ["COMMENT ANCHORS STAY REVIEWABLE", ["MEDIUM COMMENT ANCHOR DRIFT", "ACTION CONVERT TO SUGGESTION", "NO LIVE EDITOR OR PRIVATE DATA"]], + ["DETERMINISTIC REVIEW PACKET", ["SYNTHETIC DOCUMENT ONLY", "PREVIEW BEFORE MUTATION", "READY FOR MAINTAINER REVIEW"]], +]; + +frames.forEach(([subtitle, lines], index) => { + renderFrame(path.join(frameDir, `frame-${String(index + 1).padStart(3, "0")}.ppm`), subtitle, lines, result); +}); + +const outputPath = path.join(outputDir, "demo.mp4"); +execFileSync("ffmpeg", [ + "-y", + "-framerate", + "1", + "-i", + path.join(frameDir, "frame-%03d.ppm"), + "-vf", + "fps=12,format=yuv420p", + "-movflags", + "+faststart", + outputPath, +], { stdio: "inherit" }); + +fs.rmSync(frameDir, { recursive: true, force: true }); +console.log(`demo video=${outputPath}`); diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/demo.mp4 b/collaborative-research-editor/find-replace-safety-guard/reports/demo.mp4 new file mode 100644 index 00000000..dcd94d59 Binary files /dev/null and b/collaborative-research-editor/find-replace-safety-guard/reports/demo.mp4 differ diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.json b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.json new file mode 100644 index 00000000..77d8051c --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.json @@ -0,0 +1,141 @@ +{ + "documentId": "collab-doc-12-find-replace", + "operation": { + "find": "alpha", + "replace": "omega", + "mode": "wysiwyg", + "wholeWord": false, + "scopeSections": [ + "methods", + "references", + "final-figures" + ] + }, + "decision": "hold-batch-edit", + "riskScore": 100, + "changedBlocks": [ + { + "sectionId": "methods", + "blockId": "methods-p1", + "type": "paragraph", + "matches": 2, + "beforeDigest": "a7c02b6adacc531c", + "afterDigest": "b604f6421a609783" + }, + { + "sectionId": "methods", + "blockId": "methods-eq1", + "type": "latex", + "matches": 2, + "beforeDigest": "f32017986cd1ba0f", + "afterDigest": "89c44468bc1034ed" + }, + { + "sectionId": "methods", + "blockId": "methods-code1", + "type": "notebook", + "matches": 1, + "beforeDigest": "8391629e63709c74", + "afterDigest": "c8645a8d6004fe0e" + }, + { + "sectionId": "references", + "blockId": "ref-key", + "type": "citation", + "matches": 2, + "beforeDigest": "473fe626bbfe0c64", + "afterDigest": "94126d50ed228c79" + }, + { + "sectionId": "final-figures", + "blockId": "fig1-caption", + "type": "cross-reference", + "matches": 2, + "beforeDigest": "0853df185fc30260", + "afterDigest": "40b70db10eaf56eb" + } + ], + "findings": [ + { + "severity": "high", + "code": "LATEX_COMMAND_MUTATION", + "title": "Replacement would alter LaTeX command or equation source", + "evidence": "methods/methods-eq1 is a LaTeX block with 2 match(es).", + "action": "Preview equation output and require formula-owner approval before applying source-mode replacements." + }, + { + "severity": "high", + "code": "CODE_CELL_MUTATION", + "title": "Replacement would mutate notebook or code cell source", + "evidence": "methods/methods-code1 is notebook source with 1 match(es).", + "action": "Create a code-review task and rerun the notebook before sharing updated outputs." + }, + { + "severity": "high", + "code": "CITATION_KEY_MUTATION", + "title": "Replacement would alter citation keys or reference anchors", + "evidence": "references/ref-key contains citation/reference text matching \"alpha\".", + "action": "Route citation-key changes through the reference manager merge workflow instead of direct replacement." + }, + { + "severity": "blocker", + "code": "LOCKED_SECTION_MATCH", + "title": "Batch replacement touches a locked or final-review section", + "evidence": "final-figures/fig1-caption has 2 match(es) while section state is locked.", + "action": "Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions." + }, + { + "severity": "medium", + "code": "CROSS_REFERENCE_TOUCH", + "title": "Replacement touches cross-reference anchors", + "evidence": "final-figures/fig1-caption contains figure, table, or equation anchors.", + "action": "Regenerate cross-reference maps and hold export until anchors resolve." + }, + { + "severity": "medium", + "code": "COMMENT_ANCHOR_DRIFT", + "title": "Replacement would invalidate an inline comment quote anchor", + "evidence": "Comment cmt-17 anchors to text that would be replaced in block methods-p1.", + "action": "Convert the edit to a suggestion and re-anchor or resolve the comment before applying." + }, + { + "severity": "medium", + "code": "WYSIWYG_SCOPE_MISMATCH", + "title": "WYSIWYG replacement crosses source-only content", + "evidence": "The operation starts in WYSIWYG mode but reaches LaTeX, citation, or code source blocks.", + "action": "Split the operation by block type so source-mode changes receive explicit review." + } + ], + "actions": [ + { + "code": "LATEX_COMMAND_MUTATION", + "action": "Preview equation output and require formula-owner approval before applying source-mode replacements." + }, + { + "code": "CODE_CELL_MUTATION", + "action": "Create a code-review task and rerun the notebook before sharing updated outputs." + }, + { + "code": "CITATION_KEY_MUTATION", + "action": "Route citation-key changes through the reference manager merge workflow instead of direct replacement." + }, + { + "code": "LOCKED_SECTION_MATCH", + "action": "Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions." + }, + { + "code": "CROSS_REFERENCE_TOUCH", + "action": "Regenerate cross-reference maps and hold export until anchors resolve." + }, + { + "code": "COMMENT_ANCHOR_DRIFT", + "action": "Convert the edit to a suggestion and re-anchor or resolve the comment before applying." + }, + { + "code": "WYSIWYG_SCOPE_MISMATCH", + "action": "Split the operation by block type so source-mode changes receive explicit review." + } + ], + "generatedFrom": "synthetic-data-only", + "auditDigest": "855bf874ddecf9aa" +} diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.md b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.md new file mode 100644 index 00000000..044961f1 --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.md @@ -0,0 +1,25 @@ +# Collaborative Find/Replace Safety Review + +Decision: hold-batch-edit +Risk score: 100 +Changed blocks: 5 +Audit digest: 855bf874ddecf9aa + +## Findings +- [high] Replacement would alter LaTeX command or equation source: methods/methods-eq1 is a LaTeX block with 2 match(es). + Action: Preview equation output and require formula-owner approval before applying source-mode replacements. +- [high] Replacement would mutate notebook or code cell source: methods/methods-code1 is notebook source with 1 match(es). + Action: Create a code-review task and rerun the notebook before sharing updated outputs. +- [high] Replacement would alter citation keys or reference anchors: references/ref-key contains citation/reference text matching "alpha". + Action: Route citation-key changes through the reference manager merge workflow instead of direct replacement. +- [blocker] Batch replacement touches a locked or final-review section: final-figures/fig1-caption has 2 match(es) while section state is locked. + Action: Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions. +- [medium] Replacement touches cross-reference anchors: final-figures/fig1-caption contains figure, table, or equation anchors. + Action: Regenerate cross-reference maps and hold export until anchors resolve. +- [medium] Replacement would invalidate an inline comment quote anchor: Comment cmt-17 anchors to text that would be replaced in block methods-p1. + Action: Convert the edit to a suggestion and re-anchor or resolve the comment before applying. +- [medium] WYSIWYG replacement crosses source-only content: The operation starts in WYSIWYG mode but reaches LaTeX, citation, or code source blocks. + Action: Split the operation by block type so source-mode changes receive explicit review. + +## Safety +- Synthetic document packet only; no live editor or private manuscript data. diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-summary.svg b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-summary.svg new file mode 100644 index 00000000..8e6b6b4f --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-summary.svg @@ -0,0 +1,20 @@ + + + +Collaborative Find/Replace Safety Guard +Preview batch edits before shared manuscript mutation + +HOLD-BATCH-EDIT +Risk score: 100 +HIGH: Replacement would alter LaTeX command or equation source +LATEX_COMMAND_MUTATION +HIGH: Replacement would mutate notebook or code cell source +CODE_CELL_MUTATION +HIGH: Replacement would alter citation keys or reference anchors +CITATION_KEY_MUTATION +BLOCKER: Batch replacement touches a locked or final-review section +LOCKED_SECTION_MATCH +MEDIUM: Replacement touches cross-reference anchors +CROSS_REFERENCE_TOUCH +Audit digest: 855bf874ddecf9aa | Synthetic data only | No external services + \ No newline at end of file diff --git a/collaborative-research-editor/find-replace-safety-guard/sample-data.js b/collaborative-research-editor/find-replace-safety-guard/sample-data.js new file mode 100644 index 00000000..48197fad --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/sample-data.js @@ -0,0 +1,102 @@ +const riskyDocument = { + id: "collab-doc-12-find-replace", + sections: [ + { + id: "methods", + locked: false, + freeze: "draft", + blocks: [ + { + id: "methods-p1", + type: "paragraph", + content: "The alpha assay was selected for the pilot manuscript because alpha response is reproducible.", + }, + { + id: "methods-eq1", + type: "latex", + content: "\\alpha = 0.05; \\frac{signal_{alpha}}{baseline}", + }, + { + id: "methods-code1", + type: "notebook", + content: "model <- lm(response ~ alpha + baseline, data = notebook_frame)", + }, + ], + }, + { + id: "references", + locked: false, + freeze: "draft", + blocks: [ + { + id: "ref-key", + type: "citation", + key: "@alpha2024", + content: "Alpha response benchmark, DOI 10.0000/example.", + }, + ], + }, + { + id: "final-figures", + locked: true, + freeze: "final-review", + blocks: [ + { + id: "fig1-caption", + type: "cross-reference", + anchor: "fig:alpha-response", + caption: "Figure 1. Alpha response across cohorts.", + }, + ], + }, + ], + comments: [ + { + id: "cmt-17", + blockId: "methods-p1", + quote: "alpha response is reproducible", + }, + ], +}; + +const safeDocument = { + id: "collab-doc-12-safe-preview", + sections: [ + { + id: "intro", + locked: false, + freeze: "draft", + blocks: [ + { + id: "intro-p1", + type: "paragraph", + content: "This pilot manuscript uses an exploratory workflow for reader feedback.", + }, + ], + }, + ], + comments: [], +}; + +const riskyOperation = { + find: "alpha", + replace: "omega", + mode: "wysiwyg", + wholeWord: false, + scopeSections: ["methods", "references", "final-figures"], +}; + +const safeOperation = { + find: "pilot", + replace: "feasibility", + mode: "wysiwyg", + wholeWord: true, + scopeSections: ["intro"], +}; + +module.exports = { + riskyDocument, + safeDocument, + riskyOperation, + safeOperation, +}; diff --git a/collaborative-research-editor/find-replace-safety-guard/test.js b/collaborative-research-editor/find-replace-safety-guard/test.js new file mode 100644 index 00000000..e192270e --- /dev/null +++ b/collaborative-research-editor/find-replace-safety-guard/test.js @@ -0,0 +1,33 @@ +const assert = require("assert"); +const { replacementPreview, renderMarkdownReport, renderSvgSummary, digest } = require("./index"); +const { riskyDocument, safeDocument, riskyOperation, safeOperation } = require("./sample-data"); + +const risky = replacementPreview(riskyDocument, riskyOperation); +assert.strictEqual(risky.decision, "hold-batch-edit"); +assert.ok(risky.riskScore >= 90, "risky batch edit should produce a strong hold score"); +assert.ok(risky.findings.some((finding) => finding.code === "LOCKED_SECTION_MATCH")); +assert.ok(risky.findings.some((finding) => finding.code === "LATEX_COMMAND_MUTATION")); +assert.ok(risky.findings.some((finding) => finding.code === "CODE_CELL_MUTATION")); +assert.ok(risky.findings.some((finding) => finding.code === "CITATION_KEY_MUTATION")); +assert.ok(risky.findings.some((finding) => finding.code === "COMMENT_ANCHOR_DRIFT")); +assert.ok(risky.findings.some((finding) => finding.code === "WYSIWYG_SCOPE_MISMATCH")); + +const repeat = replacementPreview(riskyDocument, riskyOperation); +assert.strictEqual(risky.auditDigest, repeat.auditDigest, "audit digest must be deterministic"); + +const safe = replacementPreview(safeDocument, safeOperation); +assert.strictEqual(safe.decision, "safe-to-apply"); +assert.strictEqual(safe.findings.length, 0); +assert.strictEqual(safe.changedBlocks.length, 1); + +const markdown = renderMarkdownReport(risky); +assert.ok(markdown.includes("Collaborative Find/Replace Safety Review")); +assert.ok(markdown.includes("Synthetic document packet only")); + +const svg = renderSvgSummary(risky); +assert.ok(svg.includes("