From 60a770b37551da1ee7c6ff92eb2d66a1052b4150 Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 23:11:45 +0700 Subject: [PATCH] Add collaborative reference merge guard --- collaborative-reference-merge-guard/README.md | 37 ++ collaborative-reference-merge-guard/demo.js | 148 +++++++ collaborative-reference-merge-guard/index.js | 361 ++++++++++++++++++ .../reports/demo.mp4 | Bin 0 -> 6503 bytes .../reports/reference-merge-packet.json | 175 +++++++++ .../reports/reference-merge-review.md | 73 ++++ .../reports/summary.svg | 23 ++ .../requirements-map.md | 33 ++ .../sample-data.js | 110 ++++++ collaborative-reference-merge-guard/test.js | 151 ++++++++ 10 files changed, 1111 insertions(+) create mode 100644 collaborative-reference-merge-guard/README.md create mode 100644 collaborative-reference-merge-guard/demo.js create mode 100644 collaborative-reference-merge-guard/index.js create mode 100644 collaborative-reference-merge-guard/reports/demo.mp4 create mode 100644 collaborative-reference-merge-guard/reports/reference-merge-packet.json create mode 100644 collaborative-reference-merge-guard/reports/reference-merge-review.md create mode 100644 collaborative-reference-merge-guard/reports/summary.svg create mode 100644 collaborative-reference-merge-guard/requirements-map.md create mode 100644 collaborative-reference-merge-guard/sample-data.js create mode 100644 collaborative-reference-merge-guard/test.js diff --git a/collaborative-reference-merge-guard/README.md b/collaborative-reference-merge-guard/README.md new file mode 100644 index 00000000..df04753f --- /dev/null +++ b/collaborative-reference-merge-guard/README.md @@ -0,0 +1,37 @@ +# Collaborative Reference Merge Guard + +This module adds a self-contained reference library merge guard for issue #12, Real-time collaborative research editor & interface. + +It validates concurrent Zotero, BibTeX, and EndNote-style library merges before a collaborative manuscript is exported. It detects DOI/PMID duplicate records, citation-key collisions, stale in-text citation anchors, locked-reference edits, incomplete source metadata, and unresolved merge conflicts. + +The module is dependency-free, uses only synthetic sample data, and performs no calls to Zotero, EndNote, Crossref, PubMed, browser sessions, credentials, or external services. + +## Files + +- `index.js`: merge evaluation logic and reviewer packet formatter. +- `sample-data.js`: three synthetic collaborative editor scenarios. +- `test.js`: Node test coverage for hold, review, and export-ready decisions. +- `demo.js`: writes JSON, Markdown, SVG, and MP4 reviewer artifacts under `reports/`. +- `requirements-map.md`: maps the implementation to issue #12 requirements. + +## Run + +```bash +node --test collaborative-reference-merge-guard/test.js +node collaborative-reference-merge-guard/demo.js +``` + +To generate `reports/demo.mp4`, set `FFMPEG_PATH` to a local ffmpeg binary before running `demo.js`. + +## Outputs + +- `reports/reference-merge-packet.json` +- `reports/reference-merge-review.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +## Decision Model + +- `hold-export`: blocking duplicate identifiers, citation-key collisions, stale anchors, or locked-reference edits are present. +- `merge-review`: no blockers, but incomplete metadata or unresolved import conflicts need human review. +- `export-ready`: merged references and manuscript citations are safe to sync and export. diff --git a/collaborative-reference-merge-guard/demo.js b/collaborative-reference-merge-guard/demo.js new file mode 100644 index 00000000..4dc5e4fe --- /dev/null +++ b/collaborative-reference-merge-guard/demo.js @@ -0,0 +1,148 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +const { + evaluateReferenceLibraryMerge, + buildReferenceMergePacket, +} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +const framesDir = path.join(reportsDir, 'frames'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateReferenceLibraryMerge(scenario), +})); + +const decisionCounts = evaluations.reduce((counts, item) => { + counts[item.decision] = (counts[item.decision] || 0) + 1; + return counts; +}, {}); + +const totals = evaluations.reduce( + (sum, item) => { + sum.findings += item.summary.findingCount; + sum.blocking += item.summary.blockingFindingCount; + sum.review += item.summary.reviewFindingCount; + sum.gated += item.gatedActions.length; + return sum; + }, + {findings: 0, blocking: 0, review: 0, gated: 0} +); + +const packetJson = JSON.stringify(evaluations, null, 2); +const reviewerPacket = evaluations.map(buildReferenceMergePacket).join('\n---\n'); +const svg = ` + + Collaborative Reference Merge Guard + Checks Zotero, BibTeX, and EndNote merges before collaborative manuscript export + + + Hold Export + ${decisionCounts['hold-export'] || 0} + + + + Merge Review + ${decisionCounts['merge-review'] || 0} + + + + Export Ready + ${decisionCounts['export-ready'] || 0} + + Findings: ${totals.findings} | Blocking: ${totals.blocking} | Review: ${totals.review} | Gated actions: ${totals.gated} + Checks: DOI/PMID duplicates, key collisions, stale citation anchors, locked edits, source metadata, unresolved merge conflicts + Synthetic reference data only. No Zotero, EndNote, Crossref, PubMed, browser, credential, or network calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'reference-merge-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'reference-merge-review.md'), reviewerPacket); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) { + const [r, g, b] = color; + const height = Math.floor(buffer.length / (width * 3)); + const x1 = Math.min(width, x0 + rectWidth); + const y1 = Math.min(height, y0 + rectHeight); + for (let y = Math.max(0, y0); y < y1; y += 1) { + for (let x = Math.max(0, x0); x < x1; x += 1) { + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; + } + } +} + +function writePpmFrame(filePath, frameIndex, frameCount) { + const width = 640; + const height = 360; + const buffer = Buffer.alloc(width * height * 3); + for (let i = 0; i < width * height; i += 1) { + buffer[i * 3] = 24; + buffer[i * 3 + 1] = 37; + buffer[i * 3 + 2] = 31; + } + + const progress = frameIndex / Math.max(1, frameCount - 1); + fillRect(buffer, width, 42, 42, 556, 42, [248, 250, 252]); + fillRect(buffer, width, 42, 112, 150, 118, [153, 27, 27]); + fillRect(buffer, width, 245, 112, 150, 118, [161, 98, 7]); + fillRect(buffer, width, 448, 112, 150, 118, [4, 120, 87]); + fillRect(buffer, width, 78, 260, 112, 30, [248, 113, 113]); + fillRect(buffer, width, 264, 260, 112, 30, [251, 191, 36]); + fillRect(buffer, width, 450, 260, 112, 30, [52, 211, 153]); + fillRect(buffer, width, 42, 318, Math.round(556 * progress), 18, [187, 247, 208]); + fillRect(buffer, width, 42 + Math.round(508 * progress), 309, 48, 36, [240, 253, 244]); + + const header = Buffer.from(`P6\n${width} ${height}\n255\n`); + fs.writeFileSync(filePath, Buffer.concat([header, buffer])); +} + +function createDemoVideo() { + const ffmpegPath = process.env.FFMPEG_PATH; + if (!ffmpegPath) { + console.log('FFMPEG_PATH not set; skipped MP4 generation.'); + return; + } + + fs.rmSync(framesDir, {recursive: true, force: true}); + fs.mkdirSync(framesDir, {recursive: true}); + const frameCount = 72; + for (let index = 0; index < frameCount; index += 1) { + writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount); + } + + const output = path.join(reportsDir, 'demo.mp4'); + const result = spawnSync(ffmpegPath, [ + '-y', + '-framerate', + '24', + '-i', + path.join(framesDir, 'frame-%03d.ppm'), + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + output, + ], {encoding: 'utf8'}); + + fs.rmSync(framesDir, {recursive: true, force: true}); + if (result.status !== 0) { + throw new Error(result.stderr || 'ffmpeg failed'); + } +} + +createDemoVideo(); + +console.log(JSON.stringify({ + scenarios: evaluations.length, + reportsDir, + decisions: decisionCounts, + totals, +}, null, 2)); diff --git a/collaborative-reference-merge-guard/index.js b/collaborative-reference-merge-guard/index.js new file mode 100644 index 00000000..f5fd954b --- /dev/null +++ b/collaborative-reference-merge-guard/index.js @@ -0,0 +1,361 @@ +function list(value) { + return Array.isArray(value) ? value : []; +} + +function clean(value) { + return String(value || '').trim(); +} + +function normalize(value) { + return clean(value).toLowerCase(); +} + +function normalizeDoi(value) { + return normalize(value) + .replace(/^https?:\/\/(dx\.)?doi\.org\//, '') + .replace(/^doi:/, ''); +} + +function normalizePmid(value) { + return normalize(value).replace(/^pmid:/, ''); +} + +function finding(type, severity, target, message) { + return {type, severity, target, message}; +} + +function remediation(type, target, reason) { + return {type, target, reason}; +} + +function referenceLabel(reference) { + return reference.id || reference.citationKey || 'unknown-reference'; +} + +function referenceIdentifiers(reference) { + const identifiers = []; + const doi = normalizeDoi(reference.doi); + const pmid = normalizePmid(reference.pmid); + if (doi) { + identifiers.push({type: 'doi', value: doi, label: reference.doi}); + } + if (pmid) { + identifiers.push({type: 'pmid', value: pmid, label: reference.pmid}); + } + return identifiers; +} + +function collectBy(items, keyFn) { + const buckets = new Map(); + for (const item of items) { + const key = keyFn(item); + if (!key) { + continue; + } + if (!buckets.has(key)) { + buckets.set(key, []); + } + buckets.get(key).push(item); + } + return buckets; +} + +function validateDuplicateIdentifiers(references, findings, remediationPlan) { + const identifierRefs = new Map(); + for (const reference of references) { + for (const identifier of referenceIdentifiers(reference)) { + const key = `${identifier.type}:${identifier.value}`; + if (!identifierRefs.has(key)) { + identifierRefs.set(key, []); + } + identifierRefs.get(key).push({reference, identifier}); + } + } + + for (const [key, entries] of identifierRefs.entries()) { + const uniqueIds = new Set(entries.map((entry) => referenceLabel(entry.reference))); + if (uniqueIds.size <= 1) { + continue; + } + const labels = [...uniqueIds].join(', '); + const identifier = entries[0].identifier.label || key.split(':').slice(1).join(':'); + findings.push( + finding( + 'duplicate-source-identifier', + 'block', + key, + `References ${labels} share source identifier ${identifier}.` + ) + ); + remediationPlan.push( + remediation( + 'deduplicate-source-record', + key, + `Merge or remove duplicate reference records for ${identifier} before syncing the collaborative library.` + ) + ); + } +} + +function validateCitationKeyCollisions(references, findings, remediationPlan) { + const byKey = collectBy(references, (reference) => normalize(reference.citationKey)); + for (const [key, refs] of byKey.entries()) { + const uniqueIds = new Set(refs.map(referenceLabel)); + if (uniqueIds.size <= 1) { + continue; + } + const labels = [...uniqueIds].join(', '); + findings.push( + finding( + 'citation-key-collision', + 'block', + key, + `Citation key ${refs[0].citationKey} points to multiple references: ${labels}.` + ) + ); + remediationPlan.push( + remediation( + 'rename-citation-key', + key, + `Assign unique citation keys for ${labels} and update manuscript anchors before export.` + ) + ); + } +} + +function validateCitationAnchors(references, citations, findings, remediationPlan) { + const keys = new Set(references.map((reference) => normalize(reference.citationKey)).filter(Boolean)); + for (const citation of citations) { + const key = normalize(citation.citationKey); + if (!key || keys.has(key)) { + continue; + } + const target = citation.anchorId || citation.citationKey || 'unknown-anchor'; + findings.push( + finding( + 'stale-citation-anchor', + 'block', + target, + `Citation anchor ${target} references ${citation.citationKey || 'missing key'}, which is absent from the merged library.` + ) + ); + remediationPlan.push( + remediation( + 'repair-citation-anchor', + target, + `Reconnect ${target} to an active reference key or remove the stale in-text citation before export.` + ) + ); + } +} + +function validateLockedEdits(references, proposedEdits, findings, remediationPlan) { + const byId = new Map(references.map((reference) => [reference.id, reference])); + for (const edit of proposedEdits) { + const reference = byId.get(edit.referenceId); + if (!reference?.locked || list(edit.changedFields).length === 0) { + continue; + } + const target = edit.referenceId || 'unknown-reference'; + findings.push( + finding( + 'locked-reference-edit', + 'block', + target, + `Locked reference ${target} has proposed changes to ${list(edit.changedFields).join(', ')}.` + ) + ); + remediationPlan.push( + remediation( + 'review-locked-reference-edit', + target, + `Require reference owner approval before applying locked ${target} edits from ${edit.editor || 'unknown editor'}.` + ) + ); + } +} + +function hasBibliographicCore(reference) { + return Boolean(clean(reference.title)) && list(reference.authors).length > 0 && Boolean(reference.year); +} + +function validateSourceMetadata(references, findings, remediationPlan) { + for (const reference of references) { + const identifiers = referenceIdentifiers(reference); + if (identifiers.length > 0 && hasBibliographicCore(reference)) { + continue; + } + const target = referenceLabel(reference); + findings.push( + finding( + 'missing-source-metadata', + 'review', + target, + `Reference ${target} is missing DOI/PMID or core title, author, and year metadata.` + ) + ); + remediationPlan.push( + remediation( + 'complete-source-metadata', + target, + `Complete DOI/PMID plus title, author, and year metadata for ${target} before final manuscript export.` + ) + ); + } +} + +function validateReferenceConflicts(references, findings, remediationPlan) { + for (const reference of references) { + if (!reference.conflict) { + continue; + } + const target = referenceLabel(reference); + findings.push( + finding( + 'unresolved-reference-conflict', + 'review', + target, + `Reference ${target} still has an unresolved merge conflict from a collaborator library import.` + ) + ); + remediationPlan.push( + remediation( + 'resolve-reference-conflict', + target, + `Pick the winning source metadata for ${target} and record the merge decision before export.` + ) + ); + } +} + +function summarize(findings) { + const blockingFindingCount = findings.filter((item) => item.severity === 'block').length; + const reviewFindingCount = findings.filter((item) => item.severity !== 'block').length; + return { + findingCount: findings.length, + blockingFindingCount, + reviewFindingCount, + }; +} + +function decide(summary) { + if (summary.blockingFindingCount > 0) { + return 'hold-export'; + } + if (summary.reviewFindingCount > 0) { + return 'merge-review'; + } + return 'export-ready'; +} + +function visibleAndGated(actionsRequested, decision) { + const requested = list(actionsRequested); + if (decision === 'export-ready') { + return {visibleActions: requested, gatedActions: []}; + } + if (decision === 'merge-review') { + return { + visibleActions: requested.filter((action) => action !== 'export-manuscript'), + gatedActions: requested.filter((action) => action === 'export-manuscript'), + }; + } + return { + visibleActions: requested.filter((action) => action === 'notify-collaborators'), + gatedActions: requested.filter((action) => action !== 'notify-collaborators'), + }; +} + +function evaluateReferenceLibraryMerge(input = {}) { + const generatedAt = input.generatedAt || new Date(0).toISOString(); + const references = list(input.references); + const findings = []; + const remediationPlan = []; + + validateDuplicateIdentifiers(references, findings, remediationPlan); + validateCitationKeyCollisions(references, findings, remediationPlan); + validateCitationAnchors(references, list(input.citations), findings, remediationPlan); + validateLockedEdits(references, list(input.proposedEdits), findings, remediationPlan); + validateSourceMetadata(references, findings, remediationPlan); + validateReferenceConflicts(references, findings, remediationPlan); + + const summary = summarize(findings); + const decision = decide(summary); + const {visibleActions, gatedActions} = visibleAndGated(input.actionsRequested, decision); + + return { + generatedAt, + manuscriptId: input.manuscriptId || 'unknown-manuscript', + libraryRevision: input.libraryRevision || 'unknown-revision', + decision, + summary, + findings, + remediationPlan, + visibleActions, + gatedActions, + reviewerPacket: { + manuscriptId: input.manuscriptId || 'unknown-manuscript', + libraryRevision: input.libraryRevision || 'unknown-revision', + referenceCount: references.length, + citationCount: list(input.citations).length, + decision, + gatedActions, + visibleActions, + }, + }; +} + +function buildReferenceMergePacket(result) { + const lines = [ + `# Collaborative Reference Merge Guard: ${result.manuscriptId}`, + '', + `Library revision: ${result.libraryRevision}`, + `Decision: ${result.decision}`, + `Generated: ${result.generatedAt}`, + `References: ${result.reviewerPacket.referenceCount}`, + `Citation anchors: ${result.reviewerPacket.citationCount}`, + '', + '## Visible Actions', + ]; + + if (result.visibleActions.length === 0) { + lines.push('- None'); + } else { + for (const action of result.visibleActions) { + lines.push(`- ${action}`); + } + } + + lines.push('', '## Gated Actions'); + if (result.gatedActions.length === 0) { + lines.push('- None'); + } else { + for (const action of result.gatedActions) { + lines.push(`- ${action}`); + } + } + + lines.push('', '## Findings'); + if (result.findings.length === 0) { + lines.push('- None'); + } else { + for (const item of result.findings) { + lines.push(`- ${item.type}: ${item.target} - ${item.message}`); + } + } + + lines.push('', '## Remediation Plan'); + if (result.remediationPlan.length === 0) { + lines.push('- No remediation required'); + } else { + for (const item of result.remediationPlan) { + lines.push(`- ${item.type}: ${item.target} - ${item.reason}`); + } + } + + return `${lines.join('\n')}\n`; +} + +module.exports = { + evaluateReferenceLibraryMerge, + buildReferenceMergePacket, +}; diff --git a/collaborative-reference-merge-guard/reports/demo.mp4 b/collaborative-reference-merge-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..575e7d74c6c830e5adc75926198db748982724b5 GIT binary patch literal 6503 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 K1IY~{1M%O2uRA3G literal 0 HcmV?d00001 diff --git a/collaborative-reference-merge-guard/reports/reference-merge-packet.json b/collaborative-reference-merge-guard/reports/reference-merge-packet.json new file mode 100644 index 00000000..f3aec5bd --- /dev/null +++ b/collaborative-reference-merge-guard/reports/reference-merge-packet.json @@ -0,0 +1,175 @@ +[ + { + "scenario": "duplicate-key-and-locked-edit", + "generatedAt": "2026-05-22T16:10:00Z", + "manuscriptId": "collab-editor-neuro-042", + "libraryRevision": "refs-r17", + "decision": "hold-export", + "summary": { + "findingCount": 4, + "blockingFindingCount": 4, + "reviewFindingCount": 0 + }, + "findings": [ + { + "type": "duplicate-source-identifier", + "severity": "block", + "target": "doi:10.1101/2026.04.01.123456", + "message": "References zotero-a1, bibtex-b9 share source identifier 10.1101/2026.04.01.123456." + }, + { + "type": "citation-key-collision", + "severity": "block", + "target": "nguyen2026atlas", + "message": "Citation key Nguyen2026Atlas points to multiple references: zotero-a1, bibtex-b9." + }, + { + "type": "stale-citation-anchor", + "severity": "block", + "target": "sec-discussion-p2-c1", + "message": "Citation anchor sec-discussion-p2-c1 references Missing2023Trial, which is absent from the merged library." + }, + { + "type": "locked-reference-edit", + "severity": "block", + "target": "endnote-locked", + "message": "Locked reference endnote-locked has proposed changes to title, doi." + } + ], + "remediationPlan": [ + { + "type": "deduplicate-source-record", + "target": "doi:10.1101/2026.04.01.123456", + "reason": "Merge or remove duplicate reference records for 10.1101/2026.04.01.123456 before syncing the collaborative library." + }, + { + "type": "rename-citation-key", + "target": "nguyen2026atlas", + "reason": "Assign unique citation keys for zotero-a1, bibtex-b9 and update manuscript anchors before export." + }, + { + "type": "repair-citation-anchor", + "target": "sec-discussion-p2-c1", + "reason": "Reconnect sec-discussion-p2-c1 to an active reference key or remove the stale in-text citation before export." + }, + { + "type": "review-locked-reference-edit", + "target": "endnote-locked", + "reason": "Require reference owner approval before applying locked endnote-locked edits from orcid:0000-0002-2222-3333." + } + ], + "visibleActions": [ + "notify-collaborators" + ], + "gatedActions": [ + "sync-reference-library", + "export-manuscript" + ], + "reviewerPacket": { + "manuscriptId": "collab-editor-neuro-042", + "libraryRevision": "refs-r17", + "referenceCount": 3, + "citationCount": 2, + "decision": "hold-export", + "gatedActions": [ + "sync-reference-library", + "export-manuscript" + ], + "visibleActions": [ + "notify-collaborators" + ] + } + }, + { + "scenario": "review-incomplete-field-note", + "generatedAt": "2026-05-22T16:20:00Z", + "manuscriptId": "collab-editor-ecology-019", + "libraryRevision": "refs-r08", + "decision": "merge-review", + "summary": { + "findingCount": 2, + "blockingFindingCount": 0, + "reviewFindingCount": 2 + }, + "findings": [ + { + "type": "missing-source-metadata", + "severity": "review", + "target": "zotero-fieldnote", + "message": "Reference zotero-fieldnote is missing DOI/PMID or core title, author, and year metadata." + }, + { + "type": "unresolved-reference-conflict", + "severity": "review", + "target": "zotero-fieldnote", + "message": "Reference zotero-fieldnote still has an unresolved merge conflict from a collaborator library import." + } + ], + "remediationPlan": [ + { + "type": "complete-source-metadata", + "target": "zotero-fieldnote", + "reason": "Complete DOI/PMID plus title, author, and year metadata for zotero-fieldnote before final manuscript export." + }, + { + "type": "resolve-reference-conflict", + "target": "zotero-fieldnote", + "reason": "Pick the winning source metadata for zotero-fieldnote and record the merge decision before export." + } + ], + "visibleActions": [ + "sync-reference-library", + "notify-collaborators" + ], + "gatedActions": [ + "export-manuscript" + ], + "reviewerPacket": { + "manuscriptId": "collab-editor-ecology-019", + "libraryRevision": "refs-r08", + "referenceCount": 2, + "citationCount": 2, + "decision": "merge-review", + "gatedActions": [ + "export-manuscript" + ], + "visibleActions": [ + "sync-reference-library", + "notify-collaborators" + ] + } + }, + { + "scenario": "clean-multi-manager-merge", + "generatedAt": "2026-05-22T16:30:00Z", + "manuscriptId": "collab-editor-physics-007", + "libraryRevision": "refs-r22", + "decision": "export-ready", + "summary": { + "findingCount": 0, + "blockingFindingCount": 0, + "reviewFindingCount": 0 + }, + "findings": [], + "remediationPlan": [], + "visibleActions": [ + "sync-reference-library", + "export-manuscript", + "notify-collaborators" + ], + "gatedActions": [], + "reviewerPacket": { + "manuscriptId": "collab-editor-physics-007", + "libraryRevision": "refs-r22", + "referenceCount": 2, + "citationCount": 2, + "decision": "export-ready", + "gatedActions": [], + "visibleActions": [ + "sync-reference-library", + "export-manuscript", + "notify-collaborators" + ] + } + } +] diff --git a/collaborative-reference-merge-guard/reports/reference-merge-review.md b/collaborative-reference-merge-guard/reports/reference-merge-review.md new file mode 100644 index 00000000..c85a50de --- /dev/null +++ b/collaborative-reference-merge-guard/reports/reference-merge-review.md @@ -0,0 +1,73 @@ +# Collaborative Reference Merge Guard: collab-editor-neuro-042 + +Library revision: refs-r17 +Decision: hold-export +Generated: 2026-05-22T16:10:00Z +References: 3 +Citation anchors: 2 + +## Visible Actions +- notify-collaborators + +## Gated Actions +- sync-reference-library +- export-manuscript + +## Findings +- duplicate-source-identifier: doi:10.1101/2026.04.01.123456 - References zotero-a1, bibtex-b9 share source identifier 10.1101/2026.04.01.123456. +- citation-key-collision: nguyen2026atlas - Citation key Nguyen2026Atlas points to multiple references: zotero-a1, bibtex-b9. +- stale-citation-anchor: sec-discussion-p2-c1 - Citation anchor sec-discussion-p2-c1 references Missing2023Trial, which is absent from the merged library. +- locked-reference-edit: endnote-locked - Locked reference endnote-locked has proposed changes to title, doi. + +## Remediation Plan +- deduplicate-source-record: doi:10.1101/2026.04.01.123456 - Merge or remove duplicate reference records for 10.1101/2026.04.01.123456 before syncing the collaborative library. +- rename-citation-key: nguyen2026atlas - Assign unique citation keys for zotero-a1, bibtex-b9 and update manuscript anchors before export. +- repair-citation-anchor: sec-discussion-p2-c1 - Reconnect sec-discussion-p2-c1 to an active reference key or remove the stale in-text citation before export. +- review-locked-reference-edit: endnote-locked - Require reference owner approval before applying locked endnote-locked edits from orcid:0000-0002-2222-3333. + +--- +# Collaborative Reference Merge Guard: collab-editor-ecology-019 + +Library revision: refs-r08 +Decision: merge-review +Generated: 2026-05-22T16:20:00Z +References: 2 +Citation anchors: 2 + +## Visible Actions +- sync-reference-library +- notify-collaborators + +## Gated Actions +- export-manuscript + +## Findings +- missing-source-metadata: zotero-fieldnote - Reference zotero-fieldnote is missing DOI/PMID or core title, author, and year metadata. +- unresolved-reference-conflict: zotero-fieldnote - Reference zotero-fieldnote still has an unresolved merge conflict from a collaborator library import. + +## Remediation Plan +- complete-source-metadata: zotero-fieldnote - Complete DOI/PMID plus title, author, and year metadata for zotero-fieldnote before final manuscript export. +- resolve-reference-conflict: zotero-fieldnote - Pick the winning source metadata for zotero-fieldnote and record the merge decision before export. + +--- +# Collaborative Reference Merge Guard: collab-editor-physics-007 + +Library revision: refs-r22 +Decision: export-ready +Generated: 2026-05-22T16:30:00Z +References: 2 +Citation anchors: 2 + +## Visible Actions +- sync-reference-library +- export-manuscript +- notify-collaborators + +## Gated Actions +- None + +## Findings +- None + +## Remediation Plan +- No remediation required diff --git a/collaborative-reference-merge-guard/reports/summary.svg b/collaborative-reference-merge-guard/reports/summary.svg new file mode 100644 index 00000000..2c3ea640 --- /dev/null +++ b/collaborative-reference-merge-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + Collaborative Reference Merge Guard + Checks Zotero, BibTeX, and EndNote merges before collaborative manuscript export + + + Hold Export + 1 + + + + Merge Review + 1 + + + + Export Ready + 1 + + Findings: 6 | Blocking: 4 | Review: 2 | Gated actions: 3 + Checks: DOI/PMID duplicates, key collisions, stale citation anchors, locked edits, source metadata, unresolved merge conflicts + Synthetic reference data only. No Zotero, EndNote, Crossref, PubMed, browser, credential, or network calls. + diff --git a/collaborative-reference-merge-guard/requirements-map.md b/collaborative-reference-merge-guard/requirements-map.md new file mode 100644 index 00000000..cff51adc --- /dev/null +++ b/collaborative-reference-merge-guard/requirements-map.md @@ -0,0 +1,33 @@ +# Requirements Map + +Issue #12 asks for a real-time collaborative editor that supports scientific formatting, reference manager integration, version history, comments, and export-ready publication workflows. This slice covers the reference manager merge-safety path. + +## Reference Manager Integration + +- Supports synthetic Zotero, BibTeX, and EndNote-style reference records. +- Normalizes DOI, DOI URL, and PMID identifiers before comparing imported references. +- Detects duplicate source records that would create ambiguous collaborative library state. + +## Rich Scientific Formatting + +- Checks citation keys used by manuscript sections before export. +- Detects citation-key collisions that would make Markdown, LaTeX, or publication-format output point to the wrong source. +- Detects stale in-text citation anchors after a concurrent library merge. + +## Collaborative Editing Safety + +- Blocks edits to locked reference records until the reference owner approves them. +- Routes unresolved collaborator import conflicts to merge review rather than final export. +- Produces deterministic visible and gated actions for sync, export, and collaborator notification. + +## Version History And Auditability + +- Records `manuscriptId`, `libraryRevision`, generation timestamp, reference count, citation count, findings, and remediation actions. +- Emits JSON and Markdown reviewer packets for pull request review. +- Emits an SVG summary and MP4 demo artifact for Algora reviewer evidence. + +## Safety Boundaries + +- Synthetic fixtures only. +- No private manuscripts, credentials, browser cookies, payment data, Zotero/EndNote accounts, DOI registry calls, PubMed calls, Crossref calls, or external network access. +- Dependency-free Node implementation. diff --git a/collaborative-reference-merge-guard/sample-data.js b/collaborative-reference-merge-guard/sample-data.js new file mode 100644 index 00000000..3c2315e0 --- /dev/null +++ b/collaborative-reference-merge-guard/sample-data.js @@ -0,0 +1,110 @@ +const scenarios = [ + { + name: 'duplicate-key-and-locked-edit', + generatedAt: '2026-05-22T16:10:00Z', + manuscriptId: 'collab-editor-neuro-042', + libraryRevision: 'refs-r17', + references: [ + { + id: 'zotero-a1', + source: 'zotero', + citationKey: 'Nguyen2026Atlas', + doi: '10.1101/2026.04.01.123456', + title: 'Single-cell atlas for glial repair', + authors: ['Nguyen T', 'Patel R'], + year: 2026, + }, + { + id: 'bibtex-b9', + source: 'bibtex', + citationKey: 'Nguyen2026Atlas', + doi: 'https://doi.org/10.1101/2026.04.01.123456', + title: 'Duplicated preprint import', + authors: ['Nguyen T'], + year: 2026, + }, + { + id: 'endnote-locked', + source: 'endnote', + citationKey: 'Smith2024Methods', + doi: '10.7554/elife.12345', + title: 'Locked method source', + authors: ['Smith A'], + year: 2024, + locked: true, + }, + ], + citations: [ + {anchorId: 'sec-results-p4-c2', citationKey: 'Nguyen2026Atlas', sectionId: 'results'}, + {anchorId: 'sec-discussion-p2-c1', citationKey: 'Missing2023Trial', sectionId: 'discussion'}, + ], + proposedEdits: [ + {referenceId: 'endnote-locked', changedFields: ['title', 'doi'], editor: 'orcid:0000-0002-2222-3333'}, + ], + actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'], + }, + { + name: 'review-incomplete-field-note', + generatedAt: '2026-05-22T16:20:00Z', + manuscriptId: 'collab-editor-ecology-019', + libraryRevision: 'refs-r08', + references: [ + { + id: 'zotero-fieldnote', + source: 'zotero', + citationKey: 'Lopez2025FieldNote', + title: 'Longitudinal field notebook appendix', + authors: ['Lopez M'], + year: 2025, + conflict: true, + }, + { + id: 'bibtex-dataset', + source: 'bibtex', + citationKey: 'RiverData2024', + doi: '10.5281/zenodo.424242', + title: 'River sensor dataset', + authors: ['Data Consortium'], + year: 2024, + }, + ], + citations: [ + {anchorId: 'sec-methods-p1-c1', citationKey: 'Lopez2025FieldNote', sectionId: 'methods'}, + {anchorId: 'sec-data-p3-c1', citationKey: 'RiverData2024', sectionId: 'data'}, + ], + actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'], + }, + { + name: 'clean-multi-manager-merge', + generatedAt: '2026-05-22T16:30:00Z', + manuscriptId: 'collab-editor-physics-007', + libraryRevision: 'refs-r22', + references: [ + { + id: 'zotero-quantum', + source: 'zotero', + citationKey: 'Chen2026Quantum', + doi: '10.1038/s41586-026-11111-1', + title: 'Quantum sensor calibration', + authors: ['Chen L', 'Ibrahim N'], + year: 2026, + }, + { + id: 'bibtex-control', + source: 'bibtex', + citationKey: 'Patel2024Controls', + pmid: '39555123', + title: 'Control experiment checklist', + authors: ['Patel R'], + year: 2024, + }, + ], + citations: [ + {anchorId: 'sec-intro-p2-c1', citationKey: 'Chen2026Quantum', sectionId: 'intro'}, + {anchorId: 'sec-methods-p4-c1', citationKey: 'Patel2024Controls', sectionId: 'methods'}, + ], + actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'], + }, +]; + +module.exports = {scenarios}; diff --git a/collaborative-reference-merge-guard/test.js b/collaborative-reference-merge-guard/test.js new file mode 100644 index 00000000..0a2cb333 --- /dev/null +++ b/collaborative-reference-merge-guard/test.js @@ -0,0 +1,151 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateReferenceLibraryMerge, + buildReferenceMergePacket, +} = require('./index'); + +test('holds manuscript export when merged reference libraries contain duplicate identifiers, key collisions, stale citations, and locked edits', () => { + const result = evaluateReferenceLibraryMerge({ + generatedAt: '2026-05-22T16:10:00Z', + manuscriptId: 'collab-editor-neuro-042', + libraryRevision: 'refs-r17', + references: [ + { + id: 'zotero-a1', + source: 'zotero', + citationKey: 'Nguyen2026Atlas', + doi: '10.1101/2026.04.01.123456', + title: 'Single-cell atlas for glial repair', + authors: ['Nguyen T', 'Patel R'], + year: 2026, + }, + { + id: 'bibtex-b9', + source: 'bibtex', + citationKey: 'Nguyen2026Atlas', + doi: 'https://doi.org/10.1101/2026.04.01.123456', + title: 'Duplicated preprint import', + authors: ['Nguyen T'], + year: 2026, + }, + { + id: 'endnote-locked', + source: 'endnote', + citationKey: 'Smith2024Methods', + doi: '10.7554/elife.12345', + title: 'Locked method source', + authors: ['Smith A'], + year: 2024, + locked: true, + }, + ], + citations: [ + {anchorId: 'sec-results-p4-c2', citationKey: 'Nguyen2026Atlas', sectionId: 'results'}, + {anchorId: 'sec-discussion-p2-c1', citationKey: 'Missing2023Trial', sectionId: 'discussion'}, + ], + proposedEdits: [ + {referenceId: 'endnote-locked', changedFields: ['title', 'doi'], editor: 'orcid:0000-0002-2222-3333'}, + ], + actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'], + }); + + assert.equal(result.decision, 'hold-export'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + [ + 'duplicate-source-identifier', + 'citation-key-collision', + 'stale-citation-anchor', + 'locked-reference-edit', + ] + ); + assert.equal(result.summary.blockingFindingCount, 4); + assert.deepEqual(result.gatedActions.sort(), ['export-manuscript', 'sync-reference-library']); + assert.deepEqual(result.visibleActions, ['notify-collaborators']); + assert.match(result.remediationPlan[0].reason, /10.1101\/2026.04.01.123456/); +}); + +test('routes unresolved reference conflicts and incomplete source metadata to merge review', () => { + const result = evaluateReferenceLibraryMerge({ + generatedAt: '2026-05-22T16:20:00Z', + manuscriptId: 'collab-editor-ecology-019', + libraryRevision: 'refs-r08', + references: [ + { + id: 'zotero-fieldnote', + source: 'zotero', + citationKey: 'Lopez2025FieldNote', + title: 'Longitudinal field notebook appendix', + authors: ['Lopez M'], + year: 2025, + conflict: true, + }, + { + id: 'bibtex-dataset', + source: 'bibtex', + citationKey: 'RiverData2024', + doi: '10.5281/zenodo.424242', + title: 'River sensor dataset', + authors: ['Data Consortium'], + year: 2024, + }, + ], + citations: [ + {anchorId: 'sec-methods-p1-c1', citationKey: 'Lopez2025FieldNote', sectionId: 'methods'}, + {anchorId: 'sec-data-p3-c1', citationKey: 'RiverData2024', sectionId: 'data'}, + ], + actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'], + }); + + assert.equal(result.decision, 'merge-review'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['missing-source-metadata', 'unresolved-reference-conflict'] + ); + assert.equal(result.summary.reviewFindingCount, 2); + assert.deepEqual(result.gatedActions, ['export-manuscript']); + assert.deepEqual(result.visibleActions.sort(), ['notify-collaborators', 'sync-reference-library']); +}); + +test('approves clean reference merges and builds a reviewer packet', () => { + const result = evaluateReferenceLibraryMerge({ + generatedAt: '2026-05-22T16:30:00Z', + manuscriptId: 'collab-editor-physics-007', + libraryRevision: 'refs-r22', + references: [ + { + id: 'zotero-quantum', + source: 'zotero', + citationKey: 'Chen2026Quantum', + doi: '10.1038/s41586-026-11111-1', + title: 'Quantum sensor calibration', + authors: ['Chen L', 'Ibrahim N'], + year: 2026, + }, + { + id: 'bibtex-control', + source: 'bibtex', + citationKey: 'Patel2024Controls', + pmid: '39555123', + title: 'Control experiment checklist', + authors: ['Patel R'], + year: 2024, + }, + ], + citations: [ + {anchorId: 'sec-intro-p2-c1', citationKey: 'Chen2026Quantum', sectionId: 'intro'}, + {anchorId: 'sec-methods-p4-c1', citationKey: 'Patel2024Controls', sectionId: 'methods'}, + ], + actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'], + }); + const packet = buildReferenceMergePacket(result); + + assert.equal(result.decision, 'export-ready'); + assert.equal(result.summary.findingCount, 0); + assert.deepEqual(result.gatedActions, []); + assert.deepEqual(result.visibleActions.sort(), ['export-manuscript', 'notify-collaborators', 'sync-reference-library']); + assert.match(packet, /collab-editor-physics-007/); + assert.match(packet, /No remediation required/); +});