diff --git a/repository-component-owner-approval-guard/README.md b/repository-component-owner-approval-guard/README.md new file mode 100644 index 00000000..154239f0 --- /dev/null +++ b/repository-component-owner-approval-guard/README.md @@ -0,0 +1,34 @@ +# Repository Component Owner Approval Guard + +Self-contained guard for SCIBASE issue #10. It validates whether merge requests and tagged scientific repository releases have current approval coverage from the required component owners before protected-branch merge. + +The guard focuses on component-owner approval quorum, not merge queue execution, repository release generation, provenance attestation, access review, DOI tombstones, sensitive-artifact scanning, dependency licensing, branch hypothesis lineage, or legal holds. + +## What It Checks + +- Component ownership for `manuscript/`, `data/`, `code/`, `notebooks/`, `protocols/`, `results/`, and `metadata.json` +- Required owner roles per changed component +- Escalation owners for restricted data or protocol changes +- Stale approvals after the latest file change +- Conflicted self-approvals by merge request authors +- Unmapped repository paths without owner policy coverage + +## Commands + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +## Reviewer Artifacts + +`npm run demo` and `npm run demo:video` generate: + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.webm` + +All data is synthetic and local. The module does not call Git providers, repository hosting APIs, identity systems, storage systems, or external services. diff --git a/repository-component-owner-approval-guard/demo-video.js b/repository-component-owner-approval-guard/demo-video.js new file mode 100644 index 00000000..1f4b7f72 --- /dev/null +++ b/repository-component-owner-approval-guard/demo-video.js @@ -0,0 +1,186 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { componentPolicy, mergeRequests } = require("./sample-data"); +const { evaluateRepositoryChanges } = require("./index"); + +const result = evaluateRepositoryChanges({ + mergeRequests, + policy: componentPolicy +}); + +const reportDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportDir, "demo.webm"); + +const browserCandidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +].filter(Boolean); + +function findBrowser() { + const found = browserCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm."); + } + return found; +} + +function fileUrl(filePath) { + return `file:///${filePath.replace(/\\/g, "/")}`; +} + +const html = String.raw` + + + + Repository component owner approval guard demo + + + + +
recording
+ + +`; + +fs.mkdirSync(reportDir, { recursive: true }); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "repository-owner-demo-")); +const htmlPath = path.join(tempDir, "demo.html"); +const profileDir = path.join(tempDir, "profile"); +fs.writeFileSync(htmlPath, html, "utf8"); + +const stdout = execFileSync( + findBrowser(), + [ + "--headless=new", + "--disable-gpu", + "--disable-dev-shm-usage", + "--autoplay-policy=no-user-gesture-required", + "--run-all-compositor-stages-before-draw", + "--virtual-time-budget=7500", + `--user-data-dir=${profileDir}`, + "--dump-dom", + fileUrl(htmlPath) + ], + { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 } +); + +const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/); +if (!match) { + throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`); +} + +fs.writeFileSync(outputPath, Buffer.from(match[1], "base64")); +console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); \ No newline at end of file diff --git a/repository-component-owner-approval-guard/demo.js b/repository-component-owner-approval-guard/demo.js new file mode 100644 index 00000000..c11e80cc --- /dev/null +++ b/repository-component-owner-approval-guard/demo.js @@ -0,0 +1,12 @@ +const path = require("path"); +const { componentPolicy, mergeRequests } = require("./sample-data"); +const { evaluateRepositoryChanges, writeReports } = require("./index"); + +const result = evaluateRepositoryChanges({ + mergeRequests, + policy: componentPolicy +}); + +writeReports(result, path.join(__dirname, "reports")); + +console.log(JSON.stringify(result.summary, null, 2)); diff --git a/repository-component-owner-approval-guard/index.js b/repository-component-owner-approval-guard/index.js new file mode 100644 index 00000000..958d405d --- /dev/null +++ b/repository-component-owner-approval-guard/index.js @@ -0,0 +1,338 @@ +const fs = require("fs"); +const path = require("path"); + +function normalizePath(filePath) { + return String(filePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); +} + +function matchComponent(filePath, policy) { + const normalized = normalizePath(filePath); + return Object.entries(policy).find(([, rule]) => + rule.paths.some((prefix) => { + const normalizedPrefix = normalizePath(prefix); + return normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix); + }) + ); +} + +function groupChangedComponents(files, policy) { + const groups = new Map(); + + for (const file of files) { + const match = matchComponent(file.path, policy); + const component = match ? match[0] : "unmapped"; + if (!groups.has(component)) { + groups.set(component, []); + } + groups.get(component).push({ + ...file, + path: normalizePath(file.path) + }); + } + + return groups; +} + +function approvalIsFresh(approval, changedAt) { + return new Date(approval.approvedAt).getTime() >= new Date(changedAt).getTime(); +} + +function approvalsForComponent(approvals, component) { + return approvals.filter((approval) => approval.components.includes(component)); +} + +function requiredRolesForComponent(component, files, policy) { + const rule = policy[component]; + if (!rule) { + return ["repository-curator"]; + } + + const roles = new Set(rule.owners); + if (files.some((file) => file.restricted) && rule.restrictedOwners) { + for (const owner of rule.restrictedOwners) { + roles.add(owner); + } + } + + return [...roles]; +} + +function requiredEscalationRolesForComponent(component, files, policy) { + const rule = policy[component]; + if (!rule || !rule.restrictedOwners) { + return []; + } + + if (!files.some((file) => file.restricted)) { + return []; + } + + return rule.restrictedOwners; +} + +function evaluateComponent({ component, files, request, policy }) { + const rule = policy[component] || { quorum: 1 }; + const requiredRoles = requiredRolesForComponent(component, files, policy); + const requiredEscalationRoles = requiredEscalationRolesForComponent(component, files, policy); + const scopedApprovals = approvalsForComponent(request.approvals, component); + const freshApprovals = scopedApprovals.filter((approval) => + approvalIsFresh(approval, request.changedAt) + ); + const eligibleApprovals = freshApprovals.filter((approval) => + requiredRoles.includes(approval.role) && !request.authors.includes(approval.user) + ); + + const findings = []; + const approvedRoles = new Set(eligibleApprovals.map((approval) => approval.role)); + const missingEscalationRoles = requiredEscalationRoles.filter((role) => !approvedRoles.has(role)); + const staleApprovals = scopedApprovals.filter( + (approval) => !approvalIsFresh(approval, request.changedAt) + ); + const selfApprovals = scopedApprovals.filter((approval) => + request.authors.includes(approval.user) + ); + const outOfScopeApprovals = scopedApprovals.filter( + (approval) => !requiredRoles.includes(approval.role) + ); + + if (component === "unmapped") { + findings.push({ + severity: "critical", + code: "unmapped-component-change", + message: "Changed files do not map to an owned repository component.", + files: files.map((file) => file.path) + }); + } + + if (eligibleApprovals.length < rule.quorum) { + findings.push({ + severity: "critical", + code: "approval-quorum-missing", + message: `${component} requires ${rule.quorum} eligible owner approval(s).`, + requiredRoles, + eligibleRoles: [...approvedRoles] + }); + } + + if (missingEscalationRoles.length > 0) { + findings.push({ + severity: "critical", + code: "required-owner-role-missing", + message: `${component} is missing restricted-change escalation owner approval(s).`, + missingRoles: missingEscalationRoles + }); + } + + if (staleApprovals.length > 0) { + findings.push({ + severity: "critical", + code: "stale-approval-after-change", + message: `${component} has approval(s) older than the latest changed file timestamp.`, + approvals: staleApprovals.map((approval) => ({ + user: approval.user, + role: approval.role, + approvedAt: approval.approvedAt + })) + }); + } + + if (selfApprovals.length > 0) { + findings.push({ + severity: "critical", + code: "conflicted-self-approval", + message: `${component} includes approval from a merge request author.`, + approvals: selfApprovals.map((approval) => ({ + user: approval.user, + role: approval.role + })) + }); + } + + if (outOfScopeApprovals.length > 0) { + findings.push({ + severity: "warning", + code: "owner-role-out-of-scope", + message: `${component} includes approval role(s) that do not satisfy this component policy.`, + approvals: outOfScopeApprovals.map((approval) => ({ + user: approval.user, + role: approval.role + })) + }); + } + + return { + component, + files: files.map((file) => file.path), + restricted: files.some((file) => file.restricted), + requiredRoles, + eligibleApprovals: eligibleApprovals.map((approval) => ({ + user: approval.user, + role: approval.role, + approvedAt: approval.approvedAt + })), + findings + }; +} + +function decisionForFindings(findings) { + if (findings.some((finding) => finding.severity === "critical")) { + return "block-merge"; + } + if (findings.some((finding) => finding.severity === "warning")) { + return "require-owner-review"; + } + return "approve-merge"; +} + +function scoreFindings(findings) { + const penalties = findings.reduce((total, finding) => { + if (finding.severity === "critical") { + return total + 30; + } + if (finding.severity === "warning") { + return total + 12; + } + return total + 4; + }, 0); + return Math.max(0, 100 - penalties); +} + +function evaluateMergeRequest(request, policy) { + const groups = groupChangedComponents(request.files, policy); + const components = [...groups.entries()].map(([component, files]) => + evaluateComponent({ component, files, request, policy }) + ); + const findings = components.flatMap((component) => component.findings); + + return { + id: request.id, + title: request.title, + authors: request.authors, + changedAt: request.changedAt, + touchedComponents: components.map((component) => component.component), + decision: decisionForFindings(findings), + score: scoreFindings(findings), + components, + findings + }; +} + +function summarizeEvaluations(evaluations) { + const decisions = evaluations.reduce((summary, evaluation) => { + summary[evaluation.decision] = (summary[evaluation.decision] || 0) + 1; + return summary; + }, {}); + + const findingCounts = evaluations + .flatMap((evaluation) => evaluation.findings) + .reduce((counts, finding) => { + counts[finding.code] = (counts[finding.code] || 0) + 1; + return counts; + }, {}); + + return { + totalMergeRequests: evaluations.length, + decisions, + findingCounts, + blocked: evaluations + .filter((evaluation) => evaluation.decision === "block-merge") + .map((evaluation) => evaluation.id), + approved: evaluations + .filter((evaluation) => evaluation.decision === "approve-merge") + .map((evaluation) => evaluation.id) + }; +} + +function evaluateRepositoryChanges({ mergeRequests, policy }) { + const evaluations = mergeRequests.map((request) => evaluateMergeRequest(request, policy)); + return { + generatedAt: new Date("2026-05-22T19:30:00Z").toISOString(), + guard: "repository-component-owner-approval-guard", + summary: summarizeEvaluations(evaluations), + evaluations + }; +} + +function renderMarkdownReport(result) { + const lines = [ + "# Repository Component Owner Approval Guard", + "", + `Generated: ${result.generatedAt}`, + "", + "## Summary", + "", + `- Total merge requests: ${result.summary.totalMergeRequests}`, + `- Approved: ${result.summary.decisions["approve-merge"] || 0}`, + `- Require owner review: ${result.summary.decisions["require-owner-review"] || 0}`, + `- Blocked: ${result.summary.decisions["block-merge"] || 0}`, + "", + "## Merge Request Decisions", + "" + ]; + + for (const evaluation of result.evaluations) { + lines.push(`### ${evaluation.id}: ${evaluation.title}`); + lines.push(""); + lines.push(`- Decision: ${evaluation.decision}`); + lines.push(`- Score: ${evaluation.score}`); + lines.push(`- Components: ${evaluation.touchedComponents.join(", ")}`); + + if (evaluation.findings.length === 0) { + lines.push("- Findings: none"); + } else { + lines.push("- Findings:"); + for (const finding of evaluation.findings) { + lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`); + } + } + lines.push(""); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderSvgSummary(result) { + const approved = result.summary.decisions["approve-merge"] || 0; + const review = result.summary.decisions["require-owner-review"] || 0; + const blocked = result.summary.decisions["block-merge"] || 0; + + return [ + '', + '', + '', + 'Repository Component Owner Approval Guard', + 'Protected-branch owner quorum check for scientific repository changes', + metricBlock(70, 175, "Approved", approved, "#1f7a4d"), + metricBlock(350, 175, "Needs Review", review, "#a15c00"), + metricBlock(630, 175, "Blocked", blocked, "#b42318"), + 'Findings: stale approvals, conflicted self-approvals, missing restricted-data owners, and unmapped component changes.', + "" + ].join(""); +} + +function metricBlock(x, y, label, value, color) { + return [ + ``, + `${label}`, + `${value}` + ].join(""); +} + +function writeReports(result, outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, "summary.json"), `${JSON.stringify(result, null, 2)}\n`); + fs.writeFileSync(path.join(outputDir, "reviewer-packet.md"), renderMarkdownReport(result)); + fs.writeFileSync(path.join(outputDir, "summary.svg"), renderSvgSummary(result)); +} + +module.exports = { + evaluateMergeRequest, + evaluateRepositoryChanges, + groupChangedComponents, + matchComponent, + normalizePath, + renderMarkdownReport, + renderSvgSummary, + requiredRolesForComponent, + writeReports +}; diff --git a/repository-component-owner-approval-guard/package.json b/repository-component-owner-approval-guard/package.json new file mode 100644 index 00000000..673d771c --- /dev/null +++ b/repository-component-owner-approval-guard/package.json @@ -0,0 +1,14 @@ +{ + "name": "repository-component-owner-approval-guard", + "version": "1.0.0", + "private": true, + "description": "Component-owner approval quorum guard for scientific project repository changes.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node demo-video.js" + }, + "license": "MIT" +} diff --git a/repository-component-owner-approval-guard/reports/demo.webm b/repository-component-owner-approval-guard/reports/demo.webm new file mode 100644 index 00000000..bcf2b5f7 Binary files /dev/null and b/repository-component-owner-approval-guard/reports/demo.webm differ diff --git a/repository-component-owner-approval-guard/reports/reviewer-packet.md b/repository-component-owner-approval-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..88e5ef56 --- /dev/null +++ b/repository-component-owner-approval-guard/reports/reviewer-packet.md @@ -0,0 +1,48 @@ +# Repository Component Owner Approval Guard + +Generated: 2026-05-22T19:30:00.000Z + +## Summary + +- Total merge requests: 4 +- Approved: 2 +- Require owner review: 0 +- Blocked: 2 + +## Merge Request Decisions + +### MR-2401: Refresh methods text and analysis notebook + +- Decision: approve-merge +- Score: 100 +- Components: manuscript, notebooks, results +- Findings: none + +### MR-2417: Publish restricted participant table with updated protocol + +- Decision: block-merge +- Score: 0 +- Components: data, protocols, metadata +- Findings: + - critical: required-owner-role-missing - data is missing restricted-change escalation owner approval(s). + - critical: approval-quorum-missing - protocols requires 1 eligible owner approval(s). + - critical: required-owner-role-missing - protocols is missing restricted-change escalation owner approval(s). + - critical: conflicted-self-approval - protocols includes approval from a merge request author. + +### MR-2422: Retag code package after model output changes + +- Decision: block-merge +- Score: 0 +- Components: code, results, metadata +- Findings: + - critical: approval-quorum-missing - code requires 1 eligible owner approval(s). + - critical: stale-approval-after-change - code has approval(s) older than the latest changed file timestamp. + - critical: approval-quorum-missing - results requires 1 eligible owner approval(s). + - critical: stale-approval-after-change - results has approval(s) older than the latest changed file timestamp. + +### MR-2430: Release curated dataset and citation metadata + +- Decision: approve-merge +- Score: 100 +- Components: data, metadata, manuscript +- Findings: none diff --git a/repository-component-owner-approval-guard/reports/summary.json b/repository-component-owner-approval-guard/reports/summary.json new file mode 100644 index 00000000..69c9c302 --- /dev/null +++ b/repository-component-owner-approval-guard/reports/summary.json @@ -0,0 +1,490 @@ +{ + "generatedAt": "2026-05-22T19:30:00.000Z", + "guard": "repository-component-owner-approval-guard", + "summary": { + "totalMergeRequests": 4, + "decisions": { + "approve-merge": 2, + "block-merge": 2 + }, + "findingCounts": { + "required-owner-role-missing": 2, + "approval-quorum-missing": 3, + "conflicted-self-approval": 1, + "stale-approval-after-change": 2 + }, + "blocked": [ + "MR-2417", + "MR-2422" + ], + "approved": [ + "MR-2401", + "MR-2430" + ] + }, + "evaluations": [ + { + "id": "MR-2401", + "title": "Refresh methods text and analysis notebook", + "authors": [ + "lee" + ], + "changedAt": "2026-05-22T09:20:00Z", + "touchedComponents": [ + "manuscript", + "notebooks", + "results" + ], + "decision": "approve-merge", + "score": 100, + "components": [ + { + "component": "manuscript", + "files": [ + "manuscript/methods.md" + ], + "restricted": false, + "requiredRoles": [ + "scientific-editor", + "corresponding-author" + ], + "eligibleApprovals": [ + { + "user": "mira", + "role": "scientific-editor", + "approvedAt": "2026-05-22T09:52:00Z" + } + ], + "findings": [] + }, + { + "component": "notebooks", + "files": [ + "notebooks/analysis.ipynb" + ], + "restricted": false, + "requiredRoles": [ + "analysis-lead", + "reproducibility-engineer" + ], + "eligibleApprovals": [ + { + "user": "arun", + "role": "analysis-lead", + "approvedAt": "2026-05-22T10:04:00Z" + } + ], + "findings": [] + }, + { + "component": "results", + "files": [ + "results/figure-2.svg" + ], + "restricted": false, + "requiredRoles": [ + "analysis-lead", + "scientific-editor" + ], + "eligibleApprovals": [ + { + "user": "mira", + "role": "scientific-editor", + "approvedAt": "2026-05-22T09:52:00Z" + }, + { + "user": "arun", + "role": "analysis-lead", + "approvedAt": "2026-05-22T10:04:00Z" + } + ], + "findings": [] + } + ], + "findings": [] + }, + { + "id": "MR-2417", + "title": "Publish restricted participant table with updated protocol", + "authors": [ + "tess" + ], + "changedAt": "2026-05-22T11:45:00Z", + "touchedComponents": [ + "data", + "protocols", + "metadata" + ], + "decision": "block-merge", + "score": 0, + "components": [ + { + "component": "data", + "files": [ + "data/participant_export.csv" + ], + "restricted": true, + "requiredRoles": [ + "data-steward", + "privacy-reviewer", + "irb-liaison" + ], + "eligibleApprovals": [ + { + "user": "noor", + "role": "data-steward", + "approvedAt": "2026-05-22T12:01:00Z" + } + ], + "findings": [ + { + "severity": "critical", + "code": "required-owner-role-missing", + "message": "data is missing restricted-change escalation owner approval(s).", + "missingRoles": [ + "privacy-reviewer", + "irb-liaison" + ] + } + ] + }, + { + "component": "protocols", + "files": [ + "protocols/collection-plan.md" + ], + "restricted": true, + "requiredRoles": [ + "protocol-owner", + "lab-manager", + "irb-liaison" + ], + "eligibleApprovals": [], + "findings": [ + { + "severity": "critical", + "code": "approval-quorum-missing", + "message": "protocols requires 1 eligible owner approval(s).", + "requiredRoles": [ + "protocol-owner", + "lab-manager", + "irb-liaison" + ], + "eligibleRoles": [] + }, + { + "severity": "critical", + "code": "required-owner-role-missing", + "message": "protocols is missing restricted-change escalation owner approval(s).", + "missingRoles": [ + "protocol-owner", + "irb-liaison" + ] + }, + { + "severity": "critical", + "code": "conflicted-self-approval", + "message": "protocols includes approval from a merge request author.", + "approvals": [ + { + "user": "tess", + "role": "protocol-owner" + } + ] + } + ] + }, + { + "component": "metadata", + "files": [ + "metadata.json" + ], + "restricted": false, + "requiredRoles": [ + "repository-curator", + "corresponding-author" + ], + "eligibleApprovals": [ + { + "user": "ivy", + "role": "repository-curator", + "approvedAt": "2026-05-22T12:08:00Z" + } + ], + "findings": [] + } + ], + "findings": [ + { + "severity": "critical", + "code": "required-owner-role-missing", + "message": "data is missing restricted-change escalation owner approval(s).", + "missingRoles": [ + "privacy-reviewer", + "irb-liaison" + ] + }, + { + "severity": "critical", + "code": "approval-quorum-missing", + "message": "protocols requires 1 eligible owner approval(s).", + "requiredRoles": [ + "protocol-owner", + "lab-manager", + "irb-liaison" + ], + "eligibleRoles": [] + }, + { + "severity": "critical", + "code": "required-owner-role-missing", + "message": "protocols is missing restricted-change escalation owner approval(s).", + "missingRoles": [ + "protocol-owner", + "irb-liaison" + ] + }, + { + "severity": "critical", + "code": "conflicted-self-approval", + "message": "protocols includes approval from a merge request author.", + "approvals": [ + { + "user": "tess", + "role": "protocol-owner" + } + ] + } + ] + }, + { + "id": "MR-2422", + "title": "Retag code package after model output changes", + "authors": [ + "omar" + ], + "changedAt": "2026-05-22T15:35:00Z", + "touchedComponents": [ + "code", + "results", + "metadata" + ], + "decision": "block-merge", + "score": 0, + "components": [ + { + "component": "code", + "files": [ + "code/model/train.py" + ], + "restricted": false, + "requiredRoles": [ + "analysis-lead", + "reproducibility-engineer" + ], + "eligibleApprovals": [], + "findings": [ + { + "severity": "critical", + "code": "approval-quorum-missing", + "message": "code requires 1 eligible owner approval(s).", + "requiredRoles": [ + "analysis-lead", + "reproducibility-engineer" + ], + "eligibleRoles": [] + }, + { + "severity": "critical", + "code": "stale-approval-after-change", + "message": "code has approval(s) older than the latest changed file timestamp.", + "approvals": [ + { + "user": "arun", + "role": "analysis-lead", + "approvedAt": "2026-05-22T14:58:00Z" + } + ] + } + ] + }, + { + "component": "results", + "files": [ + "results/model-card.md" + ], + "restricted": false, + "requiredRoles": [ + "analysis-lead", + "scientific-editor" + ], + "eligibleApprovals": [], + "findings": [ + { + "severity": "critical", + "code": "approval-quorum-missing", + "message": "results requires 1 eligible owner approval(s).", + "requiredRoles": [ + "analysis-lead", + "scientific-editor" + ], + "eligibleRoles": [] + }, + { + "severity": "critical", + "code": "stale-approval-after-change", + "message": "results has approval(s) older than the latest changed file timestamp.", + "approvals": [ + { + "user": "arun", + "role": "analysis-lead", + "approvedAt": "2026-05-22T14:58:00Z" + } + ] + } + ] + }, + { + "component": "metadata", + "files": [ + "metadata.json" + ], + "restricted": false, + "requiredRoles": [ + "repository-curator", + "corresponding-author" + ], + "eligibleApprovals": [ + { + "user": "ivy", + "role": "repository-curator", + "approvedAt": "2026-05-22T16:00:00Z" + } + ], + "findings": [] + } + ], + "findings": [ + { + "severity": "critical", + "code": "approval-quorum-missing", + "message": "code requires 1 eligible owner approval(s).", + "requiredRoles": [ + "analysis-lead", + "reproducibility-engineer" + ], + "eligibleRoles": [] + }, + { + "severity": "critical", + "code": "stale-approval-after-change", + "message": "code has approval(s) older than the latest changed file timestamp.", + "approvals": [ + { + "user": "arun", + "role": "analysis-lead", + "approvedAt": "2026-05-22T14:58:00Z" + } + ] + }, + { + "severity": "critical", + "code": "approval-quorum-missing", + "message": "results requires 1 eligible owner approval(s).", + "requiredRoles": [ + "analysis-lead", + "scientific-editor" + ], + "eligibleRoles": [] + }, + { + "severity": "critical", + "code": "stale-approval-after-change", + "message": "results has approval(s) older than the latest changed file timestamp.", + "approvals": [ + { + "user": "arun", + "role": "analysis-lead", + "approvedAt": "2026-05-22T14:58:00Z" + } + ] + } + ] + }, + { + "id": "MR-2430", + "title": "Release curated dataset and citation metadata", + "authors": [ + "nina" + ], + "changedAt": "2026-05-22T17:00:00Z", + "touchedComponents": [ + "data", + "metadata", + "manuscript" + ], + "decision": "approve-merge", + "score": 100, + "components": [ + { + "component": "data", + "files": [ + "data/curated-measurements.parquet" + ], + "restricted": false, + "requiredRoles": [ + "data-steward", + "privacy-reviewer" + ], + "eligibleApprovals": [ + { + "user": "noor", + "role": "data-steward", + "approvedAt": "2026-05-22T17:10:00Z" + } + ], + "findings": [] + }, + { + "component": "metadata", + "files": [ + "metadata.json" + ], + "restricted": false, + "requiredRoles": [ + "repository-curator", + "corresponding-author" + ], + "eligibleApprovals": [ + { + "user": "ivy", + "role": "repository-curator", + "approvedAt": "2026-05-22T17:15:00Z" + } + ], + "findings": [] + }, + { + "component": "manuscript", + "files": [ + "manuscript/data-availability.md" + ], + "restricted": false, + "requiredRoles": [ + "scientific-editor", + "corresponding-author" + ], + "eligibleApprovals": [ + { + "user": "mira", + "role": "scientific-editor", + "approvedAt": "2026-05-22T17:18:00Z" + } + ], + "findings": [] + } + ], + "findings": [] + } + ] +} diff --git a/repository-component-owner-approval-guard/reports/summary.svg b/repository-component-owner-approval-guard/reports/summary.svg new file mode 100644 index 00000000..e8482cbf --- /dev/null +++ b/repository-component-owner-approval-guard/reports/summary.svg @@ -0,0 +1 @@ +Repository Component Owner Approval GuardProtected-branch owner quorum check for scientific repository changesApproved2Needs Review0Blocked2Findings: stale approvals, conflicted self-approvals, missing restricted-data owners, and unmapped component changes. \ No newline at end of file diff --git a/repository-component-owner-approval-guard/requirements-map.md b/repository-component-owner-approval-guard/requirements-map.md new file mode 100644 index 00000000..14e05f31 --- /dev/null +++ b/repository-component-owner-approval-guard/requirements-map.md @@ -0,0 +1,15 @@ +# Requirements Map + +## Issue #10 Requirement Coverage + +- Repository structure and components: maps component owners across `manuscript/`, `data/`, `code/`, `notebooks/`, `protocols/`, `results/`, and `metadata.json`. +- File and metadata versioning: blocks stale approvals when changed files move after review. +- Collaboration and merge requests: evaluates merge request approval quorum before protected-branch merge. +- Provenance tracking: records which owner roles approved each component and why a merge is blocked. +- In-browser editors and diffs: treats changed paths as editor/diff outputs that need owner coverage. +- Computation-aware reproducibility: checks code, notebook, and result owners for reproducibility-sensitive changes. +- Repository identifiers and citation: requires metadata owner approval for `metadata.json` citation/version changes. + +## Non-Overlap Statement + +This slice is limited to component-owner approval quorum and owner freshness. It does not implement the existing repository ledger, release engine, structured diff/rollback, provenance attestation, release embargo, notebook replay, schema migration, citation impact, API/export verifier, merge queue, environment drift, access review, DOI tombstone, metadata readiness, branch hypothesis lineage, sensitive-artifact, dependency-license, or legal-hold slices. diff --git a/repository-component-owner-approval-guard/sample-data.js b/repository-component-owner-approval-guard/sample-data.js new file mode 100644 index 00000000..b022570b --- /dev/null +++ b/repository-component-owner-approval-guard/sample-data.js @@ -0,0 +1,159 @@ +const componentPolicy = { + manuscript: { + paths: ["manuscript/"], + owners: ["scientific-editor", "corresponding-author"], + quorum: 1 + }, + data: { + paths: ["data/"], + owners: ["data-steward", "privacy-reviewer"], + quorum: 1, + restrictedOwners: ["privacy-reviewer", "irb-liaison"] + }, + code: { + paths: ["code/"], + owners: ["analysis-lead", "reproducibility-engineer"], + quorum: 1 + }, + notebooks: { + paths: ["notebooks/"], + owners: ["analysis-lead", "reproducibility-engineer"], + quorum: 1 + }, + protocols: { + paths: ["protocols/"], + owners: ["protocol-owner", "lab-manager"], + quorum: 1, + restrictedOwners: ["protocol-owner", "irb-liaison"] + }, + results: { + paths: ["results/"], + owners: ["analysis-lead", "scientific-editor"], + quorum: 1 + }, + metadata: { + paths: ["metadata.json"], + owners: ["repository-curator", "corresponding-author"], + quorum: 1 + } +}; + +const mergeRequests = [ + { + id: "MR-2401", + title: "Refresh methods text and analysis notebook", + authors: ["lee"], + changedAt: "2026-05-22T09:20:00Z", + files: [ + { path: "manuscript/methods.md", changeType: "modify" }, + { path: "notebooks/analysis.ipynb", changeType: "modify" }, + { path: "results/figure-2.svg", changeType: "add" } + ], + approvals: [ + { + user: "mira", + role: "scientific-editor", + components: ["manuscript", "results"], + approvedAt: "2026-05-22T09:52:00Z" + }, + { + user: "arun", + role: "analysis-lead", + components: ["notebooks", "results"], + approvedAt: "2026-05-22T10:04:00Z" + } + ] + }, + { + id: "MR-2417", + title: "Publish restricted participant table with updated protocol", + authors: ["tess"], + changedAt: "2026-05-22T11:45:00Z", + files: [ + { path: "data/participant_export.csv", changeType: "add", restricted: true }, + { path: "protocols/collection-plan.md", changeType: "modify", restricted: true }, + { path: "metadata.json", changeType: "modify" } + ], + approvals: [ + { + user: "noor", + role: "data-steward", + components: ["data"], + approvedAt: "2026-05-22T12:01:00Z" + }, + { + user: "tess", + role: "protocol-owner", + components: ["protocols"], + approvedAt: "2026-05-22T12:04:00Z" + }, + { + user: "ivy", + role: "repository-curator", + components: ["metadata"], + approvedAt: "2026-05-22T12:08:00Z" + } + ] + }, + { + id: "MR-2422", + title: "Retag code package after model output changes", + authors: ["omar"], + changedAt: "2026-05-22T15:35:00Z", + files: [ + { path: "code/model/train.py", changeType: "modify" }, + { path: "results/model-card.md", changeType: "modify" }, + { path: "metadata.json", changeType: "modify" } + ], + approvals: [ + { + user: "arun", + role: "analysis-lead", + components: ["code", "results"], + approvedAt: "2026-05-22T14:58:00Z" + }, + { + user: "ivy", + role: "repository-curator", + components: ["metadata"], + approvedAt: "2026-05-22T16:00:00Z" + } + ] + }, + { + id: "MR-2430", + title: "Release curated dataset and citation metadata", + authors: ["nina"], + changedAt: "2026-05-22T17:00:00Z", + files: [ + { path: "data/curated-measurements.parquet", changeType: "add" }, + { path: "metadata.json", changeType: "modify" }, + { path: "manuscript/data-availability.md", changeType: "modify" } + ], + approvals: [ + { + user: "noor", + role: "data-steward", + components: ["data"], + approvedAt: "2026-05-22T17:10:00Z" + }, + { + user: "ivy", + role: "repository-curator", + components: ["metadata"], + approvedAt: "2026-05-22T17:15:00Z" + }, + { + user: "mira", + role: "scientific-editor", + components: ["manuscript"], + approvedAt: "2026-05-22T17:18:00Z" + } + ] + } +]; + +module.exports = { + componentPolicy, + mergeRequests +}; diff --git a/repository-component-owner-approval-guard/test.js b/repository-component-owner-approval-guard/test.js new file mode 100644 index 00000000..4ec5a7b0 --- /dev/null +++ b/repository-component-owner-approval-guard/test.js @@ -0,0 +1,50 @@ +const assert = require("assert"); +const { componentPolicy, mergeRequests } = require("./sample-data"); +const { + evaluateMergeRequest, + evaluateRepositoryChanges, + matchComponent, + requiredRolesForComponent +} = require("./index"); + +function codes(evaluation) { + return evaluation.findings.map((finding) => finding.code); +} + +assert.strictEqual(matchComponent("metadata.json", componentPolicy)[0], "metadata"); +assert.strictEqual(matchComponent("notebooks/run.ipynb", componentPolicy)[0], "notebooks"); + +assert.deepStrictEqual( + requiredRolesForComponent( + "data", + [{ path: "data/raw.csv", restricted: true }], + componentPolicy + ), + ["data-steward", "privacy-reviewer", "irb-liaison"] +); + +const clean = evaluateMergeRequest(mergeRequests[0], componentPolicy); +assert.strictEqual(clean.decision, "approve-merge"); +assert.strictEqual(clean.findings.length, 0); + +const restricted = evaluateMergeRequest(mergeRequests[1], componentPolicy); +assert.strictEqual(restricted.decision, "block-merge"); +assert.ok(codes(restricted).includes("required-owner-role-missing")); +assert.ok(codes(restricted).includes("conflicted-self-approval")); + +const stale = evaluateMergeRequest(mergeRequests[2], componentPolicy); +assert.strictEqual(stale.decision, "block-merge"); +assert.ok(codes(stale).includes("stale-approval-after-change")); + +const release = evaluateMergeRequest(mergeRequests[3], componentPolicy); +assert.strictEqual(release.decision, "approve-merge"); + +const result = evaluateRepositoryChanges({ + mergeRequests, + policy: componentPolicy +}); +assert.strictEqual(result.summary.totalMergeRequests, 4); +assert.deepStrictEqual(result.summary.blocked.sort(), ["MR-2417", "MR-2422"]); +assert.deepStrictEqual(result.summary.approved.sort(), ["MR-2401", "MR-2430"]); + +console.log("repository-component-owner-approval-guard tests passed");