diff --git a/README.md b/README.md index d338cf68..13689437 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # deepevents.ai deepevents.ai main codebase + +- [Collaborative LaTeX macro safety guard](collaborative-latex-macro-safety-guard/README.md) diff --git a/collaborative-latex-macro-safety-guard/README.md b/collaborative-latex-macro-safety-guard/README.md new file mode 100644 index 00000000..44c63258 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/README.md @@ -0,0 +1,48 @@ +# Collaborative LaTeX Macro Safety Guard + +This module is a self-contained real-time collaborative research editor slice for SCIBASE.AI issue #12. It validates synchronized Markdown and LaTeX editor packets before live preview or named-version export. + +The guard uses synthetic data only. It does not connect to live users, private manuscripts, browser sessions, credentials, or external services. + +## Scope + +- Detect conflicting collaborator macro definitions before broadcast. +- Block unsafe macro commands such as raw HTML helpers, external links, and image includes unless a trusted render packet is explicitly reviewed. +- Detect recursive macro expansion cycles that can hang live preview or export rendering. +- Block raw active HTML and unapproved external render resources inside Markdown/LaTeX blocks. +- Hold locked sections when unapproved or external-resource macro changes affect the render packet. +- Emit deterministic remediation actions for live preview, trusted KaTeX rendering, and named-version export gates. + +## Requirement Mapping + +| Issue #12 area | Implementation | +| --- | --- | +| Rich scientific formatting | Macro, equation, Markdown, and external render-resource checks | +| Real-time collaboration | Multi-user macro conflict and shared render packet validation | +| Locking/unlock modes | Locked section render holds for risky macro packets | +| Version history and autosave | Named-version export decisions and reviewer remediation packets | + +## Files + +- `index.js` - dependency-free evaluator plus Markdown/SVG report renderers. +- `sample-data.js` - synthetic ready, blocked, and needs-review editor packets. +- `test.js` - Node assertion coverage for guard decisions and report renderers. +- `demo.js` - writes deterministic JSON, Markdown, and SVG reviewer artifacts. +- `scripts/render-demo-video.js` - optional ffmpeg-based MP4 renderer. +- `reports/` - generated reviewer artifacts. + +## Validation + +```bash +npm run check +npm test +npm run demo +``` + +Optional video render when ffmpeg is available: + +```bash +npm run demo:video +``` + +The included demo artifacts are deterministic and based only on the synthetic fixtures in `sample-data.js`. diff --git a/collaborative-latex-macro-safety-guard/demo.js b/collaborative-latex-macro-safety-guard/demo.js new file mode 100644 index 00000000..d6b4fda9 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/demo.js @@ -0,0 +1,27 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + createGuardReport, + renderMarkdown, + renderSvg, +} = require("./index"); +const { samplePackets } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const report = createGuardReport(samplePackets); +const jsonPath = path.join(reportsDir, "demo.json"); +const markdownPath = path.join(reportsDir, "demo.md"); +const svgPath = path.join(reportsDir, "demo.svg"); + +fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(markdownPath, renderMarkdown(report)); +fs.writeFileSync(svgPath, renderSvg(report)); + +console.log(`Wrote ${path.relative(process.cwd(), jsonPath)}`); +console.log(`Wrote ${path.relative(process.cwd(), markdownPath)}`); +console.log(`Wrote ${path.relative(process.cwd(), svgPath)}`); +console.log( + `Ready=${report.totals.ready} NeedsReview=${report.totals.needsReview} Blocked=${report.totals.blocked}`, +); diff --git a/collaborative-latex-macro-safety-guard/index.js b/collaborative-latex-macro-safety-guard/index.js new file mode 100644 index 00000000..4f922c57 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/index.js @@ -0,0 +1,447 @@ +const DEFAULT_POLICY = Object.freeze({ + generatedAt: "2026-05-22T00:00:00.000Z", + allowedExternalHosts: ["doi.org", "api.datacite.org"], + unsafeMacroCommands: [ + "\\href", + "\\includegraphics", + "\\htmlClass", + "\\htmlId", + "\\htmlStyle", + "\\url", + ], + rawHtmlPattern: /<\s*(script|iframe|object|embed|link|meta|style)\b/i, + externalUrlPattern: /https?:\/\/([^)\s"']+)/gi, + maxExpansionDepth: 8, +}); + +const REQUIREMENT_MAP = Object.freeze({ + scientific_formatting: + "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + realtime_collaboration: + "Real-Time Collaboration: multi-user edits with live render safety", + locking: + "Real-Time Collaboration: controlled sections and lock/unlock modes", + version_history: + "Version History & Autosave: safe named-version export", +}); + +function evaluateMacroPacket(packet, policy = DEFAULT_POLICY) { + assertPacket(packet); + + const checks = []; + const issues = []; + const addCheck = (code, passed, detail) => { + checks.push({ code, passed, detail }); + }; + const addIssue = ( + severity, + code, + message, + requirement, + remediation, + target, + ) => { + issues.push({ + severity, + code, + message, + requirement, + remediation, + target, + }); + }; + + const macroGroups = groupMacros(packet.macros); + checkMacroConflicts(macroGroups, addCheck, addIssue); + checkUnsafeMacroCommands(packet, policy, addCheck, addIssue); + checkMacroRecursion(packet, policy, addCheck, addIssue); + checkBlockContent(packet, policy, addCheck, addIssue); + checkLockedSection(packet, addCheck, addIssue); + + const blockingIssues = issues.filter((issue) => issue.severity === "error"); + const warnings = issues.filter((issue) => issue.severity === "warning"); + const status = + blockingIssues.length > 0 + ? "blocked" + : warnings.length > 0 + ? "needs_review" + : "ready"; + + return { + generatedAt: policy.generatedAt, + packetId: packet.packetId, + documentId: packet.documentId, + namedVersion: packet.namedVersion, + sectionId: packet.sectionId, + status, + summary: { + macros: packet.macros.length, + blocks: packet.blocks.length, + checks: checks.length, + passedChecks: checks.filter((check) => check.passed).length, + blockingIssues: blockingIssues.length, + warnings: warnings.length, + }, + checks, + issues: issues.sort(compareIssues), + renderDecision: buildRenderDecision(status, issues), + requirementCoverage: REQUIREMENT_MAP, + }; +} + +function createGuardReport(packets, policy = DEFAULT_POLICY) { + const evaluations = packets + .map((packet) => evaluateMacroPacket(packet, policy)) + .sort((a, b) => a.packetId.localeCompare(b.packetId)); + + return { + generatedAt: policy.generatedAt, + guard: "collaborative-latex-macro-safety-guard", + issue: "SCIBASE-AI/SCIBASE.AI#12", + totals: { + packets: evaluations.length, + ready: evaluations.filter((item) => item.status === "ready").length, + needsReview: evaluations.filter((item) => item.status === "needs_review") + .length, + blocked: evaluations.filter((item) => item.status === "blocked").length, + blockingIssues: evaluations.reduce( + (sum, item) => sum + item.summary.blockingIssues, + 0, + ), + warnings: evaluations.reduce((sum, item) => sum + item.summary.warnings, 0), + }, + evaluations, + }; +} + +function renderMarkdown(report) { + const rows = report.evaluations + .map( + (item) => + `| ${item.packetId} | ${item.namedVersion} | ${item.status} | ${item.summary.blockingIssues} | ${item.summary.warnings} |`, + ) + .join("\n"); + const issueRows = + report.evaluations + .flatMap((item) => + item.issues.map( + (issue) => + `- ${item.packetId} ${issue.code} (${issue.target}): ${issue.remediation}`, + ), + ) + .join("\n") || "- No remediation required."; + + return `# Collaborative LaTeX Macro Safety Guard + +Synthetic reviewer packet for ${report.issue}. + +## Summary + +| Packet | Named version | Status | Blocking issues | Warnings | +| --- | --- | --- | ---: | ---: | +${rows} + +## Gate Totals + +- Ready packets: ${report.totals.ready} +- Needs review: ${report.totals.needsReview} +- Blocked packets: ${report.totals.blocked} +- Blocking issues: ${report.totals.blockingIssues} +- Warnings: ${report.totals.warnings} + +## Remediation Queue + +${issueRows} + +## Requirement Mapping + +- Macro and equation safety: ${REQUIREMENT_MAP.scientific_formatting} +- Multi-user macro edit checks: ${REQUIREMENT_MAP.realtime_collaboration} +- Locked section render holds: ${REQUIREMENT_MAP.locking} +- Named-version export gate: ${REQUIREMENT_MAP.version_history} +`; +} + +function renderSvg(report) { + const width = 960; + const height = 540; + const cardWidth = 280; + const colors = { + ready: "#157f57", + needs_review: "#b7791f", + blocked: "#b42318", + }; + const cards = report.evaluations + .map((item, index) => { + const x = 40 + index * (cardWidth + 20); + const color = colors[item.status]; + return ` + + + ${escapeXml(item.packetId)} + ${escapeXml(item.sectionId)} + ${escapeXml(item.status)} + Checks: ${item.summary.passedChecks}/${item.summary.checks} + Blocks: ${item.summary.blockingIssues} +`; + }) + .join("\n"); + + return ` +Collaborative LaTeX macro safety guard +Deterministic status report for real-time editor macro render packets. + +Collaborative LaTeX Macro Safety Guard +Blocks unsafe macros, raw HTML, recursive edits, and locked-section render risks. + + + Ready ${report.totals.ready} + + Needs review ${report.totals.needsReview} + + Blocked ${report.totals.blocked} + +${cards} +Issue mapping: real-time collaborative editor, Markdown/LaTeX rendering, live collaboration, versioned export. + +`; +} + +function checkMacroConflicts(macroGroups, addCheck, addIssue) { + for (const [name, macros] of [...macroGroups.entries()].sort()) { + const bodies = new Set(macros.map((macro) => macro.body)); + const passed = bodies.size === 1; + addCheck("MACRO_CONFLICT", passed, `${name}; definitions=${bodies.size}`); + if (!passed) { + addIssue( + "error", + "MACRO_DEFINITION_CONFLICT", + "Collaborators submitted conflicting bodies for the same macro.", + REQUIREMENT_MAP.realtime_collaboration, + "Resolve the macro conflict before broadcasting the shared render state.", + name, + ); + } + } +} + +function checkUnsafeMacroCommands(packet, policy, addCheck, addIssue) { + for (const macro of packet.macros) { + const used = policy.unsafeMacroCommands.filter((command) => + macro.body.includes(command), + ); + const trusted = packet.trustedRenderMode && Boolean(macro.trustJustification); + const passed = used.length === 0 || trusted; + addCheck( + "MACRO_TRUST_COMMANDS", + passed, + `${macro.name}; commands=${used.join(",") || "none"}`, + ); + if (used.length > 0) { + addIssue( + trusted ? "warning" : "error", + trusted ? "TRUSTED_MACRO_REVIEW_REQUIRED" : "UNSAFE_MACRO_COMMAND", + "Macro body uses a command that can escape plain equation rendering.", + REQUIREMENT_MAP.scientific_formatting, + trusted + ? "Keep the trusted-render justification with reviewer signoff." + : "Remove the unsafe command or move it behind an approved trusted-render packet.", + macro.name, + ); + } + + if (!macro.approved) { + addIssue( + "error", + "UNAPPROVED_MACRO_EDIT", + "Macro edit has not been approved for the shared named version.", + REQUIREMENT_MAP.version_history, + "Require approval before autosave promotes this macro into the named version.", + macro.name, + ); + } + } +} + +function checkMacroRecursion(packet, policy, addCheck, addIssue) { + const macroMap = new Map(packet.macros.map((macro) => [macro.name, macro])); + for (const macro of packet.macros) { + const cycle = findMacroCycle(macro, macroMap, policy.maxExpansionDepth); + const passed = cycle.length === 0; + addCheck("MACRO_RECURSION", passed, `${macro.name}; cycle=${cycle.join(" -> ") || "none"}`); + if (!passed) { + addIssue( + "error", + "RECURSIVE_MACRO_EXPANSION", + "Macro expansion forms a recursive cycle.", + REQUIREMENT_MAP.scientific_formatting, + "Break the macro cycle before live preview or export rendering.", + macro.name, + ); + } + } +} + +function checkBlockContent(packet, policy, addCheck, addIssue) { + for (const block of packet.blocks) { + const rawHtml = policy.rawHtmlPattern.test(block.text); + addCheck("RAW_HTML_BLOCK", !rawHtml, `${block.blockId}; rawHtml=${rawHtml}`); + if (rawHtml) { + addIssue( + "error", + "RAW_HTML_IN_COLLAB_BLOCK", + "Collaborative block contains raw active HTML.", + REQUIREMENT_MAP.scientific_formatting, + "Strip active HTML before live preview or named-version export.", + block.blockId, + ); + } + + const externalHosts = findExternalHosts(block.text, policy); + const disallowed = externalHosts.filter( + (host) => !policy.allowedExternalHosts.includes(host), + ); + addCheck( + "EXTERNAL_RENDER_RESOURCE", + disallowed.length === 0, + `${block.blockId}; hosts=${externalHosts.join(",") || "none"}`, + ); + if (disallowed.length > 0) { + addIssue( + "error", + "EXTERNAL_RENDER_RESOURCE_BLOCKED", + "Collaborative render block references an unapproved external resource.", + REQUIREMENT_MAP.scientific_formatting, + "Replace external resources with repository-managed figure or citation assets.", + block.blockId, + ); + } + } +} + +function checkLockedSection(packet, addCheck, addIssue) { + const lockedBlocks = packet.blocks.filter((block) => block.locked); + const hasRenderRisk = packet.macros.some( + (macro) => !macro.approved || macro.body.includes("\\href"), + ); + const passed = lockedBlocks.length === 0 || !hasRenderRisk; + addCheck( + "LOCKED_SECTION_RENDER_HOLD", + passed, + `lockedBlocks=${lockedBlocks.length}; renderRisk=${hasRenderRisk}`, + ); + if (!passed) { + addIssue( + "error", + "LOCKED_SECTION_RENDER_HOLD", + "Locked section contains unapproved or external-resource macro changes.", + REQUIREMENT_MAP.locking, + "Hold render/export until the section owner approves the macro packet.", + packet.sectionId, + ); + } +} + +function buildRenderDecision(status, issues) { + const blocking = issues.filter((issue) => issue.severity === "error"); + const warnings = issues.filter((issue) => issue.severity === "warning"); + return { + allowLivePreview: blocking.length === 0, + allowNamedVersionExport: blocking.length === 0, + allowTrustedKatexRender: blocking.length === 0 && warnings.length === 0, + requiresReviewerSignoff: warnings.length > 0, + remediationChecklist: issues.map((issue) => ({ + code: issue.code, + target: issue.target, + action: issue.remediation, + })), + status, + }; +} + +function groupMacros(macros) { + const groups = new Map(); + for (const macro of macros) { + if (!groups.has(macro.name)) { + groups.set(macro.name, []); + } + groups.get(macro.name).push(macro); + } + return groups; +} + +function findMacroCycle(startMacro, macroMap, maxDepth) { + const visited = []; + let current = startMacro; + for (let depth = 0; depth <= maxDepth; depth += 1) { + visited.push(current.name); + const nextName = [...macroMap.keys()].find((name) => + current.body.includes(name), + ); + if (!nextName) { + return []; + } + if (visited.includes(nextName)) { + return [...visited, nextName]; + } + current = macroMap.get(nextName); + } + return visited; +} + +function findExternalHosts(text, policy) { + const hosts = []; + let match = policy.externalUrlPattern.exec(text); + while (match) { + hosts.push(match[1].toLowerCase()); + match = policy.externalUrlPattern.exec(text); + } + policy.externalUrlPattern.lastIndex = 0; + return [...new Set(hosts)].sort(); +} + +function assertPacket(packet) { + if (!packet || typeof packet !== "object") { + throw new TypeError("packet must be an object"); + } + for (const field of ["packetId", "documentId", "namedVersion", "sectionId"]) { + if (!packet[field]) { + throw new TypeError(`packet.${field} is required`); + } + } + if (!Array.isArray(packet.macros)) { + throw new TypeError("packet.macros must be an array"); + } + if (!Array.isArray(packet.blocks)) { + throw new TypeError("packet.blocks must be an array"); + } +} + +function compareIssues(a, b) { + return ( + severityRank(a.severity) - severityRank(b.severity) || + a.code.localeCompare(b.code) || + a.target.localeCompare(b.target) + ); +} + +function severityRank(severity) { + return severity === "error" ? 0 : 1; +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +module.exports = { + DEFAULT_POLICY, + REQUIREMENT_MAP, + createGuardReport, + evaluateMacroPacket, + renderMarkdown, + renderSvg, +}; diff --git a/collaborative-latex-macro-safety-guard/package.json b/collaborative-latex-macro-safety-guard/package.json new file mode 100644 index 00000000..85bdf58e --- /dev/null +++ b/collaborative-latex-macro-safety-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "collaborative-latex-macro-safety-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "description": "Synthetic collaborative LaTeX macro safety guard for SCIBASE real-time editor packets.", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check test.js && node --check scripts/render-demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node scripts/render-demo-video.js" + } +} diff --git a/collaborative-latex-macro-safety-guard/reports/demo.json b/collaborative-latex-macro-safety-guard/reports/demo.json new file mode 100644 index 00000000..92bc4a82 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/reports/demo.json @@ -0,0 +1,451 @@ +{ + "generatedAt": "2026-05-22T00:00:00.000Z", + "guard": "collaborative-latex-macro-safety-guard", + "issue": "SCIBASE-AI/SCIBASE.AI#12", + "totals": { + "packets": 3, + "ready": 1, + "needsReview": 1, + "blocked": 1, + "blockingIssues": 10, + "warnings": 1 + }, + "evaluations": [ + { + "generatedAt": "2026-05-22T00:00:00.000Z", + "packetId": "packet-safe-methods-v4", + "documentId": "shared-manuscript/neuro-methods", + "namedVersion": "Manuscript v4 Methods", + "sectionId": "methods-statistical-model", + "status": "ready", + "summary": { + "macros": 2, + "blocks": 2, + "checks": 11, + "passedChecks": 11, + "blockingIssues": 0, + "warnings": 0 + }, + "checks": [ + { + "code": "MACRO_CONFLICT", + "passed": true, + "detail": "\\ci; definitions=1" + }, + { + "code": "MACRO_CONFLICT", + "passed": true, + "detail": "\\mean; definitions=1" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": true, + "detail": "\\mean; commands=none" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": true, + "detail": "\\ci; commands=none" + }, + { + "code": "MACRO_RECURSION", + "passed": true, + "detail": "\\mean; cycle=none" + }, + { + "code": "MACRO_RECURSION", + "passed": true, + "detail": "\\ci; cycle=none" + }, + { + "code": "RAW_HTML_BLOCK", + "passed": true, + "detail": "eq-primary-outcome; rawHtml=false" + }, + { + "code": "EXTERNAL_RENDER_RESOURCE", + "passed": true, + "detail": "eq-primary-outcome; hosts=none" + }, + { + "code": "RAW_HTML_BLOCK", + "passed": true, + "detail": "md-summary; rawHtml=false" + }, + { + "code": "EXTERNAL_RENDER_RESOURCE", + "passed": true, + "detail": "md-summary; hosts=none" + }, + { + "code": "LOCKED_SECTION_RENDER_HOLD", + "passed": true, + "detail": "lockedBlocks=0; renderRisk=false" + } + ], + "issues": [], + "renderDecision": { + "allowLivePreview": true, + "allowNamedVersionExport": true, + "allowTrustedKatexRender": true, + "requiresReviewerSignoff": false, + "remediationChecklist": [], + "status": "ready" + }, + "requirementCoverage": { + "scientific_formatting": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "realtime_collaboration": "Real-Time Collaboration: multi-user edits with live render safety", + "locking": "Real-Time Collaboration: controlled sections and lock/unlock modes", + "version_history": "Version History & Autosave: safe named-version export" + } + }, + { + "generatedAt": "2026-05-22T00:00:00.000Z", + "packetId": "packet-trusted-supplement-v2", + "documentId": "shared-manuscript/supplement", + "namedVersion": "Supplement Draft", + "sectionId": "appendix-derivations", + "status": "needs_review", + "summary": { + "macros": 1, + "blocks": 1, + "checks": 6, + "passedChecks": 6, + "blockingIssues": 0, + "warnings": 1 + }, + "checks": [ + { + "code": "MACRO_CONFLICT", + "passed": true, + "detail": "\\annotate; definitions=1" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": true, + "detail": "\\annotate; commands=\\htmlId" + }, + { + "code": "MACRO_RECURSION", + "passed": true, + "detail": "\\annotate; cycle=none" + }, + { + "code": "RAW_HTML_BLOCK", + "passed": true, + "detail": "appendix-equation; rawHtml=false" + }, + { + "code": "EXTERNAL_RENDER_RESOURCE", + "passed": true, + "detail": "appendix-equation; hosts=none" + }, + { + "code": "LOCKED_SECTION_RENDER_HOLD", + "passed": true, + "detail": "lockedBlocks=0; renderRisk=false" + } + ], + "issues": [ + { + "severity": "warning", + "code": "TRUSTED_MACRO_REVIEW_REQUIRED", + "message": "Macro body uses a command that can escape plain equation rendering.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Keep the trusted-render justification with reviewer signoff.", + "target": "\\annotate" + } + ], + "renderDecision": { + "allowLivePreview": true, + "allowNamedVersionExport": true, + "allowTrustedKatexRender": false, + "requiresReviewerSignoff": true, + "remediationChecklist": [ + { + "code": "TRUSTED_MACRO_REVIEW_REQUIRED", + "target": "\\annotate", + "action": "Keep the trusted-render justification with reviewer signoff." + } + ], + "status": "needs_review" + }, + "requirementCoverage": { + "scientific_formatting": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "realtime_collaboration": "Real-Time Collaboration: multi-user edits with live render safety", + "locking": "Real-Time Collaboration: controlled sections and lock/unlock modes", + "version_history": "Version History & Autosave: safe named-version export" + } + }, + { + "generatedAt": "2026-05-22T00:00:00.000Z", + "packetId": "packet-unsafe-results-v8", + "documentId": "shared-manuscript/results-live", + "namedVersion": "Final Submission", + "sectionId": "results-figures", + "status": "blocked", + "summary": { + "macros": 5, + "blocks": 3, + "checks": 21, + "passedChecks": 12, + "blockingIssues": 10, + "warnings": 0 + }, + "checks": [ + { + "code": "MACRO_CONFLICT", + "passed": true, + "detail": "\\dataset; definitions=1" + }, + { + "code": "MACRO_CONFLICT", + "passed": false, + "detail": "\\hazard; definitions=2" + }, + { + "code": "MACRO_CONFLICT", + "passed": true, + "detail": "\\loopA; definitions=1" + }, + { + "code": "MACRO_CONFLICT", + "passed": true, + "detail": "\\loopB; definitions=1" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": false, + "detail": "\\dataset; commands=\\href" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": true, + "detail": "\\loopA; commands=none" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": true, + "detail": "\\loopB; commands=none" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": false, + "detail": "\\hazard; commands=\\htmlClass" + }, + { + "code": "MACRO_TRUST_COMMANDS", + "passed": true, + "detail": "\\hazard; commands=none" + }, + { + "code": "MACRO_RECURSION", + "passed": true, + "detail": "\\dataset; cycle=none" + }, + { + "code": "MACRO_RECURSION", + "passed": false, + "detail": "\\loopA; cycle=\\loopA -> \\loopB -> \\loopA" + }, + { + "code": "MACRO_RECURSION", + "passed": false, + "detail": "\\loopB; cycle=\\loopB -> \\loopA -> \\loopB" + }, + { + "code": "MACRO_RECURSION", + "passed": true, + "detail": "\\hazard; cycle=none" + }, + { + "code": "MACRO_RECURSION", + "passed": true, + "detail": "\\hazard; cycle=none" + }, + { + "code": "RAW_HTML_BLOCK", + "passed": true, + "detail": "eq-hazard-model; rawHtml=false" + }, + { + "code": "EXTERNAL_RENDER_RESOURCE", + "passed": true, + "detail": "eq-hazard-model; hosts=none" + }, + { + "code": "RAW_HTML_BLOCK", + "passed": true, + "detail": "figure-caption; rawHtml=false" + }, + { + "code": "EXTERNAL_RENDER_RESOURCE", + "passed": false, + "detail": "figure-caption; hosts=private.example.invalid/figure.png" + }, + { + "code": "RAW_HTML_BLOCK", + "passed": false, + "detail": "unsafe-inline-html; rawHtml=true" + }, + { + "code": "EXTERNAL_RENDER_RESOURCE", + "passed": false, + "detail": "unsafe-inline-html; hosts=private.example.invalid/embed" + }, + { + "code": "LOCKED_SECTION_RENDER_HOLD", + "passed": false, + "detail": "lockedBlocks=2; renderRisk=true" + } + ], + "issues": [ + { + "severity": "error", + "code": "EXTERNAL_RENDER_RESOURCE_BLOCKED", + "message": "Collaborative render block references an unapproved external resource.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Replace external resources with repository-managed figure or citation assets.", + "target": "figure-caption" + }, + { + "severity": "error", + "code": "EXTERNAL_RENDER_RESOURCE_BLOCKED", + "message": "Collaborative render block references an unapproved external resource.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Replace external resources with repository-managed figure or citation assets.", + "target": "unsafe-inline-html" + }, + { + "severity": "error", + "code": "LOCKED_SECTION_RENDER_HOLD", + "message": "Locked section contains unapproved or external-resource macro changes.", + "requirement": "Real-Time Collaboration: controlled sections and lock/unlock modes", + "remediation": "Hold render/export until the section owner approves the macro packet.", + "target": "results-figures" + }, + { + "severity": "error", + "code": "MACRO_DEFINITION_CONFLICT", + "message": "Collaborators submitted conflicting bodies for the same macro.", + "requirement": "Real-Time Collaboration: multi-user edits with live render safety", + "remediation": "Resolve the macro conflict before broadcasting the shared render state.", + "target": "\\hazard" + }, + { + "severity": "error", + "code": "RAW_HTML_IN_COLLAB_BLOCK", + "message": "Collaborative block contains raw active HTML.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Strip active HTML before live preview or named-version export.", + "target": "unsafe-inline-html" + }, + { + "severity": "error", + "code": "RECURSIVE_MACRO_EXPANSION", + "message": "Macro expansion forms a recursive cycle.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Break the macro cycle before live preview or export rendering.", + "target": "\\loopA" + }, + { + "severity": "error", + "code": "RECURSIVE_MACRO_EXPANSION", + "message": "Macro expansion forms a recursive cycle.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Break the macro cycle before live preview or export rendering.", + "target": "\\loopB" + }, + { + "severity": "error", + "code": "UNAPPROVED_MACRO_EDIT", + "message": "Macro edit has not been approved for the shared named version.", + "requirement": "Version History & Autosave: safe named-version export", + "remediation": "Require approval before autosave promotes this macro into the named version.", + "target": "\\dataset" + }, + { + "severity": "error", + "code": "UNSAFE_MACRO_COMMAND", + "message": "Macro body uses a command that can escape plain equation rendering.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Remove the unsafe command or move it behind an approved trusted-render packet.", + "target": "\\dataset" + }, + { + "severity": "error", + "code": "UNSAFE_MACRO_COMMAND", + "message": "Macro body uses a command that can escape plain equation rendering.", + "requirement": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "remediation": "Remove the unsafe command or move it behind an approved trusted-render packet.", + "target": "\\hazard" + } + ], + "renderDecision": { + "allowLivePreview": false, + "allowNamedVersionExport": false, + "allowTrustedKatexRender": false, + "requiresReviewerSignoff": false, + "remediationChecklist": [ + { + "code": "EXTERNAL_RENDER_RESOURCE_BLOCKED", + "target": "figure-caption", + "action": "Replace external resources with repository-managed figure or citation assets." + }, + { + "code": "EXTERNAL_RENDER_RESOURCE_BLOCKED", + "target": "unsafe-inline-html", + "action": "Replace external resources with repository-managed figure or citation assets." + }, + { + "code": "LOCKED_SECTION_RENDER_HOLD", + "target": "results-figures", + "action": "Hold render/export until the section owner approves the macro packet." + }, + { + "code": "MACRO_DEFINITION_CONFLICT", + "target": "\\hazard", + "action": "Resolve the macro conflict before broadcasting the shared render state." + }, + { + "code": "RAW_HTML_IN_COLLAB_BLOCK", + "target": "unsafe-inline-html", + "action": "Strip active HTML before live preview or named-version export." + }, + { + "code": "RECURSIVE_MACRO_EXPANSION", + "target": "\\loopA", + "action": "Break the macro cycle before live preview or export rendering." + }, + { + "code": "RECURSIVE_MACRO_EXPANSION", + "target": "\\loopB", + "action": "Break the macro cycle before live preview or export rendering." + }, + { + "code": "UNAPPROVED_MACRO_EDIT", + "target": "\\dataset", + "action": "Require approval before autosave promotes this macro into the named version." + }, + { + "code": "UNSAFE_MACRO_COMMAND", + "target": "\\dataset", + "action": "Remove the unsafe command or move it behind an approved trusted-render packet." + }, + { + "code": "UNSAFE_MACRO_COMMAND", + "target": "\\hazard", + "action": "Remove the unsafe command or move it behind an approved trusted-render packet." + } + ], + "status": "blocked" + }, + "requirementCoverage": { + "scientific_formatting": "Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references", + "realtime_collaboration": "Real-Time Collaboration: multi-user edits with live render safety", + "locking": "Real-Time Collaboration: controlled sections and lock/unlock modes", + "version_history": "Version History & Autosave: safe named-version export" + } + } + ] +} diff --git a/collaborative-latex-macro-safety-guard/reports/demo.md b/collaborative-latex-macro-safety-guard/reports/demo.md new file mode 100644 index 00000000..543689b8 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/reports/demo.md @@ -0,0 +1,40 @@ +# Collaborative LaTeX Macro Safety Guard + +Synthetic reviewer packet for SCIBASE-AI/SCIBASE.AI#12. + +## Summary + +| Packet | Named version | Status | Blocking issues | Warnings | +| --- | --- | --- | ---: | ---: | +| packet-safe-methods-v4 | Manuscript v4 Methods | ready | 0 | 0 | +| packet-trusted-supplement-v2 | Supplement Draft | needs_review | 0 | 1 | +| packet-unsafe-results-v8 | Final Submission | blocked | 10 | 0 | + +## Gate Totals + +- Ready packets: 1 +- Needs review: 1 +- Blocked packets: 1 +- Blocking issues: 10 +- Warnings: 1 + +## Remediation Queue + +- packet-trusted-supplement-v2 TRUSTED_MACRO_REVIEW_REQUIRED (\annotate): Keep the trusted-render justification with reviewer signoff. +- packet-unsafe-results-v8 EXTERNAL_RENDER_RESOURCE_BLOCKED (figure-caption): Replace external resources with repository-managed figure or citation assets. +- packet-unsafe-results-v8 EXTERNAL_RENDER_RESOURCE_BLOCKED (unsafe-inline-html): Replace external resources with repository-managed figure or citation assets. +- packet-unsafe-results-v8 LOCKED_SECTION_RENDER_HOLD (results-figures): Hold render/export until the section owner approves the macro packet. +- packet-unsafe-results-v8 MACRO_DEFINITION_CONFLICT (\hazard): Resolve the macro conflict before broadcasting the shared render state. +- packet-unsafe-results-v8 RAW_HTML_IN_COLLAB_BLOCK (unsafe-inline-html): Strip active HTML before live preview or named-version export. +- packet-unsafe-results-v8 RECURSIVE_MACRO_EXPANSION (\loopA): Break the macro cycle before live preview or export rendering. +- packet-unsafe-results-v8 RECURSIVE_MACRO_EXPANSION (\loopB): Break the macro cycle before live preview or export rendering. +- packet-unsafe-results-v8 UNAPPROVED_MACRO_EDIT (\dataset): Require approval before autosave promotes this macro into the named version. +- packet-unsafe-results-v8 UNSAFE_MACRO_COMMAND (\dataset): Remove the unsafe command or move it behind an approved trusted-render packet. +- packet-unsafe-results-v8 UNSAFE_MACRO_COMMAND (\hazard): Remove the unsafe command or move it behind an approved trusted-render packet. + +## Requirement Mapping + +- Macro and equation safety: Rich Scientific Formatting: Markdown, LaTeX, equations, and cross references +- Multi-user macro edit checks: Real-Time Collaboration: multi-user edits with live render safety +- Locked section render holds: Real-Time Collaboration: controlled sections and lock/unlock modes +- Named-version export gate: Version History & Autosave: safe named-version export diff --git a/collaborative-latex-macro-safety-guard/reports/demo.mp4 b/collaborative-latex-macro-safety-guard/reports/demo.mp4 new file mode 100644 index 00000000..a60a5e91 Binary files /dev/null and b/collaborative-latex-macro-safety-guard/reports/demo.mp4 differ diff --git a/collaborative-latex-macro-safety-guard/reports/demo.svg b/collaborative-latex-macro-safety-guard/reports/demo.svg new file mode 100644 index 00000000..969d7c49 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/reports/demo.svg @@ -0,0 +1,43 @@ + +Collaborative LaTeX macro safety guard +Deterministic status report for real-time editor macro render packets. + +Collaborative LaTeX Macro Safety Guard +Blocks unsafe macros, raw HTML, recursive edits, and locked-section render risks. + + + Ready 1 + + Needs review 1 + + Blocked 1 + + + + + packet-safe-methods-v4 + methods-statistical-model + ready + Checks: 11/11 + Blocks: 0 + + + + + packet-trusted-supplement-v2 + appendix-derivations + needs_review + Checks: 6/6 + Blocks: 0 + + + + + packet-unsafe-results-v8 + results-figures + blocked + Checks: 12/21 + Blocks: 10 + +Issue mapping: real-time collaborative editor, Markdown/LaTeX rendering, live collaboration, versioned export. + diff --git a/collaborative-latex-macro-safety-guard/sample-data.js b/collaborative-latex-macro-safety-guard/sample-data.js new file mode 100644 index 00000000..69c385a6 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/sample-data.js @@ -0,0 +1,135 @@ +const safePacket = { + packetId: "packet-safe-methods-v4", + documentId: "shared-manuscript/neuro-methods", + namedVersion: "Manuscript v4 Methods", + sectionId: "methods-statistical-model", + collaborators: ["orcid:0000-0002-1825-0097", "orcid:0000-0003-1555-4212"], + trustedRenderMode: false, + macros: [ + { + name: "\\mean", + body: "\\operatorname{mean}", + author: "orcid:0000-0002-1825-0097", + scope: "section", + approved: true, + }, + { + name: "\\ci", + body: "\\mathrm{CI}", + author: "orcid:0000-0003-1555-4212", + scope: "document", + approved: true, + }, + ], + blocks: [ + { + blockId: "eq-primary-outcome", + type: "latex", + text: "The primary endpoint is $\\mean(X) \\pm 95\\%\\ \\ci$.", + locked: false, + }, + { + blockId: "md-summary", + type: "markdown", + text: "The same model is rendered in the methods preview.", + locked: false, + }, + ], +}; + +const blockedPacket = { + packetId: "packet-unsafe-results-v8", + documentId: "shared-manuscript/results-live", + namedVersion: "Final Submission", + sectionId: "results-figures", + collaborators: ["orcid:0000-0001-1111-2222", "orcid:0000-0001-3333-4444"], + trustedRenderMode: false, + macros: [ + { + name: "\\dataset", + body: "\\href{https://private.example.invalid/raw.csv}{dataset}", + author: "orcid:0000-0001-1111-2222", + scope: "document", + approved: false, + }, + { + name: "\\loopA", + body: "\\loopB", + author: "orcid:0000-0001-3333-4444", + scope: "section", + approved: true, + }, + { + name: "\\loopB", + body: "\\loopA", + author: "orcid:0000-0001-3333-4444", + scope: "section", + approved: true, + }, + { + name: "\\hazard", + body: "\\htmlClass{warning}{hazard}", + author: "orcid:0000-0001-1111-2222", + scope: "section", + approved: true, + }, + { + name: "\\hazard", + body: "\\textbf{hazard}", + author: "orcid:0000-0001-3333-4444", + scope: "section", + approved: true, + }, + ], + blocks: [ + { + blockId: "eq-hazard-model", + type: "latex", + text: "$\\hazard = \\loopA + \\dataset$", + locked: true, + }, + { + blockId: "figure-caption", + type: "markdown", + text: "![private cohort](https://private.example.invalid/figure.png)", + locked: true, + }, + { + blockId: "unsafe-inline-html", + type: "markdown", + text: "", + locked: false, + }, + ], +}; + +const reviewPacket = { + packetId: "packet-trusted-supplement-v2", + documentId: "shared-manuscript/supplement", + namedVersion: "Supplement Draft", + sectionId: "appendix-derivations", + collaborators: ["orcid:0000-0002-9999-7777"], + trustedRenderMode: true, + macros: [ + { + name: "\\annotate", + body: "\\htmlId{derivation-note}{note}", + author: "orcid:0000-0002-9999-7777", + scope: "section", + approved: true, + trustJustification: "Maintainer-approved KaTeX trust macro for internal appendix anchor.", + }, + ], + blocks: [ + { + blockId: "appendix-equation", + type: "latex", + text: "$\\annotate = \\sum_i x_i$", + locked: false, + }, + ], +}; + +module.exports = { + samplePackets: [safePacket, blockedPacket, reviewPacket], +}; diff --git a/collaborative-latex-macro-safety-guard/scripts/render-demo-video.js b/collaborative-latex-macro-safety-guard/scripts/render-demo-video.js new file mode 100644 index 00000000..8325dacf --- /dev/null +++ b/collaborative-latex-macro-safety-guard/scripts/render-demo-video.js @@ -0,0 +1,85 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const reportsDir = path.join(__dirname, "..", "reports"); +const reportPath = path.join(reportsDir, "demo.json"); +const outputPath = path.join(reportsDir, "demo.mp4"); + +if (!fs.existsSync(reportPath)) { + throw new Error("Run npm run demo before rendering the video."); +} + +const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg"; +const title = "SCIBASE LaTeX macro safety guard"; +const subtitle = `ready ${report.totals.ready} review ${report.totals.needsReview} blocked ${report.totals.blocked}`; +const detail = `blocking issues ${report.totals.blockingIssues} warnings ${report.totals.warnings}`; +const fontArg = getFontArg(); +const filters = [ + "drawbox=x=0:y=0:w=iw:h=ih:color=0x111827@1:t=fill", + "drawbox=x=72:y=86:w=1136:h=548:color=0xffffff@0.94:t=fill", + "drawbox=x=72:y=86:w=1136:h=18:color=0xb7791f@1:t=fill", + drawText(title, 112, 160, 42, "0x111827", fontArg), + drawText("Real-time collaborative research editor", 112, 220, 24, "0x374151", fontArg), + drawText(subtitle, 112, 305, 34, "0x111827", fontArg), + drawText(detail, 112, 365, 26, "0xb42318", fontArg), + drawText( + "Validates synced Markdown and LaTeX packets before live preview or named-version export.", + 112, + 450, + 22, + "0x374151", + fontArg, + ), + drawText("Synthetic data only. No credentials, users, or external services.", 112, 500, 22, "0x374151", fontArg), +].join(","); + +const result = spawnSync( + ffmpeg, + [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x111827:s=1280x720:d=4:r=25", + "-vf", + filters, + "-pix_fmt", + "yuv420p", + outputPath, + ], + { stdio: "inherit" }, +); + +if (result.status !== 0) { + throw new Error(`ffmpeg exited with status ${result.status}`); +} + +console.log(`Wrote ${path.relative(process.cwd(), outputPath)}`); + +function drawText(text, x, y, size, color, fontArg) { + return `drawtext=${fontArg}:text='${escapeDrawtext(text)}':x=${x}:y=${y}:fontsize=${size}:fontcolor=${color}`; +} + +function escapeDrawtext(value) { + return String(value) + .replaceAll("\\", "\\\\") + .replaceAll(":", "\\:") + .replaceAll("'", "\\'"); +} + +function getFontArg() { + const candidates = [ + "C:/Windows/Fonts/arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + ]; + const font = candidates.find((candidate) => fs.existsSync(candidate)); + if (!font) { + throw new Error( + `No supported TrueType font file found. Checked: ${candidates.join(", ")}`, + ); + } + return `fontfile='${font.replaceAll(":", "\\:")}'`; +} diff --git a/collaborative-latex-macro-safety-guard/test.js b/collaborative-latex-macro-safety-guard/test.js new file mode 100644 index 00000000..4ef742e8 --- /dev/null +++ b/collaborative-latex-macro-safety-guard/test.js @@ -0,0 +1,74 @@ +const assert = require("node:assert/strict"); +const { + createGuardReport, + evaluateMacroPacket, + renderMarkdown, + renderSvg, +} = require("./index"); +const { samplePackets } = require("./sample-data"); + +const ready = evaluateMacroPacket(samplePackets[0]); +assert.equal(ready.status, "ready"); +assert.equal(ready.renderDecision.allowLivePreview, true); +assert.equal(ready.renderDecision.allowNamedVersionExport, true); + +const blocked = evaluateMacroPacket(samplePackets[1]); +assert.equal(blocked.status, "blocked"); +assert.equal(blocked.renderDecision.allowLivePreview, false); +assert.ok( + blocked.issues.some((issue) => issue.code === "UNSAFE_MACRO_COMMAND"), +); +assert.ok( + blocked.issues.some((issue) => issue.code === "RECURSIVE_MACRO_EXPANSION"), +); +assert.ok( + blocked.issues.some((issue) => issue.code === "MACRO_DEFINITION_CONFLICT"), +); +assert.ok( + blocked.issues.some((issue) => issue.code === "RAW_HTML_IN_COLLAB_BLOCK"), +); +assert.ok( + blocked.issues.some( + (issue) => issue.code === "EXTERNAL_RENDER_RESOURCE_BLOCKED", + ), +); +assert.ok( + blocked.issues.some((issue) => issue.code === "LOCKED_SECTION_RENDER_HOLD"), +); + +const review = evaluateMacroPacket(samplePackets[2]); +assert.equal(review.status, "needs_review"); +assert.equal(review.renderDecision.allowLivePreview, true); +assert.equal(review.renderDecision.allowTrustedKatexRender, false); +assert.ok( + review.issues.some( + (issue) => issue.code === "TRUSTED_MACRO_REVIEW_REQUIRED", + ), +); + +const report = createGuardReport(samplePackets); +assert.deepEqual(report.totals, { + packets: 3, + ready: 1, + needsReview: 1, + blocked: 1, + blockingIssues: 10, + warnings: 1, +}); + +const markdown = renderMarkdown(report); +assert.match(markdown, /Collaborative LaTeX Macro Safety Guard/); +assert.match(markdown, /RECURSIVE_MACRO_EXPANSION/); +assert.match(markdown, /Named-version export gate/); + +const svg = renderSvg(report); +assert.match(svg, / evaluateMacroPacket({ packetId: "missing-document" }), + /packet.documentId is required/, +); + +console.log("collaborative-latex-macro-safety-guard tests passed");