diff --git a/project-funding-attribution-guard/README.md b/project-funding-attribution-guard/README.md new file mode 100644 index 00000000..d64e3fa2 --- /dev/null +++ b/project-funding-attribution-guard/README.md @@ -0,0 +1,36 @@ +# Project Funding Attribution Guard + +A focused User & Project Management slice for checking whether a research project has valid funding-source, institution, collaborator, and citation attribution before publication or public profile exposure. + +The module is deterministic, dependency-free, and uses synthetic data only. It does not handle payout routing, billing, payment rails, or private financial details. + +## What It Checks + +- Funding sources linked to project metadata and required acknowledgements. +- Institution ownership and collaborator affiliation consistency. +- Grant IDs and sponsor terms required for public project pages. +- Public/private profile exposure against grant and institution requirements. +- Citation metadata readiness for DOI/project exports. +- Audit evidence for project-level attribution decisions. +- Reviewer-facing remediation actions before publication. + +## Usage + +```bash +node project-funding-attribution-guard/test.js +node project-funding-attribution-guard/demo.js +``` + +The demo writes: + +- `reports/attribution-audit.json` +- `reports/reviewer-packet.md` +- `reports/attribution-summary.svg` + +The PR includes a short demo video at `reports/demo.mp4`. + +## Maintainer-Friendly Notes + +- No external services, credentials, network calls, or package installs. +- All fixtures are synthetic and intentionally small. +- The guard complements identity/access modules by focusing specifically on project-level funding and institutional attribution readiness. diff --git a/project-funding-attribution-guard/demo.js b/project-funding-attribution-guard/demo.js new file mode 100644 index 00000000..a46e6a7b --- /dev/null +++ b/project-funding-attribution-guard/demo.js @@ -0,0 +1,23 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { + evaluateProjectFundingAttribution, + renderReviewerMarkdown, + renderSvgSummary +} = require("./index"); +const { project } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const audit = evaluateProjectFundingAttribution(project); + +fs.writeFileSync(path.join(reportsDir, "attribution-audit.json"), `${JSON.stringify(audit, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "reviewer-packet.md"), renderReviewerMarkdown(audit)); +fs.writeFileSync(path.join(reportsDir, "attribution-summary.svg"), renderSvgSummary(audit)); + +console.log(`Readiness: ${audit.readiness} (${audit.readinessScore}/100)`); +console.log(`Findings: ${audit.findings.map((finding) => finding.code).join(", ")}`); +console.log(`Reports written to ${reportsDir}`); diff --git a/project-funding-attribution-guard/index.js b/project-funding-attribution-guard/index.js new file mode 100644 index 00000000..9067e52d --- /dev/null +++ b/project-funding-attribution-guard/index.js @@ -0,0 +1,247 @@ +"use strict"; + +function byId(items) { + return Object.fromEntries(items.map((item) => [item.id, item])); +} + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +function hasText(haystack, needle) { + return String(haystack || "").toLowerCase().includes(String(needle || "").toLowerCase()); +} + +function evaluateFunding(project) { + const acknowledgement = project.citationMetadata.fundingAcknowledgement || ""; + return project.fundingSources.map((source) => { + const missingGrantId = !source.grantId; + const missingAcknowledgement = source.publicAcknowledgementRequired && !hasText(acknowledgement, source.requiredText); + const missingDoi = source.requiresDoiBeforePublication && !project.citationMetadata.doi; + const evidence = project.auditEvents.filter((event) => event.targetId === source.id); + const blockers = []; + + if (missingGrantId) blockers.push("grant id missing"); + if (missingAcknowledgement) blockers.push("required acknowledgement missing"); + if (missingDoi) blockers.push("DOI required before publication"); + if (!evidence.length) blockers.push("funding link lacks audit evidence"); + + return { + fundingSourceId: source.id, + funder: source.funder, + grantId: source.grantId || "missing", + readiness: blockers.length ? "blocked" : "ready", + blockers, + auditEventIds: evidence.map((event) => event.id) + }; + }); +} + +function evaluateInstitutions(project) { + const institutionMap = byId(project.linkedInstitutions); + return project.collaborators.map((collaborator) => { + const institution = institutionMap[collaborator.institutionId]; + const blockers = []; + if (!institution) { + blockers.push("linked institution missing"); + } else if (collaborator.emailDomain !== institution.verifiedDomain) { + blockers.push("email domain does not match institution domain"); + } + if (!collaborator.orcidLinked && ["owner", "contributor"].includes(collaborator.role)) { + blockers.push("ORCID link missing for attributed contributor"); + } + return { + collaboratorId: collaborator.id, + name: collaborator.name, + role: collaborator.role, + institution: institution ? institution.name : "missing", + readiness: blockers.length ? "needs-review" : "ready", + blockers + }; + }); +} + +function evaluateCitationMetadata(project) { + const blockers = []; + const warnings = []; + const institutions = project.linkedInstitutions.map((institution) => institution.name); + const authors = project.collaborators + .filter((collaborator) => ["owner", "contributor"].includes(collaborator.role)) + .map((collaborator) => collaborator.name); + + if (!project.citationMetadata.title) blockers.push("citation title missing"); + if (!project.citationMetadata.doi) warnings.push("DOI missing for export-ready citation metadata"); + const missingAuthors = authors.filter((author) => !(project.citationMetadata.authors || []).includes(author)); + if (missingAuthors.length) blockers.push(`citation authors missing: ${missingAuthors.join(", ")}`); + const missingInstitutions = institutions.filter((institution) => !(project.citationMetadata.institutions || []).includes(institution)); + if (missingInstitutions.length) warnings.push(`citation institutions missing: ${missingInstitutions.join(", ")}`); + + return { + readiness: blockers.length ? "blocked" : warnings.length ? "ready-with-warnings" : "ready", + blockers, + warnings + }; +} + +function evaluateProfileExposure(project, institutionEvaluations, fundingEvaluations) { + const findings = []; + const publicProject = project.visibility === "public"; + const blockedFunding = fundingEvaluations.filter((item) => item.readiness === "blocked"); + const exposedReviewers = project.collaborators.filter((collaborator) => collaborator.role === "reviewer" && collaborator.publicProfile); + const affiliationIssues = institutionEvaluations.filter((item) => item.blockers.length); + + if (publicProject && blockedFunding.length) { + findings.push({ + severity: "high", + code: "public-project-funding-blocker", + message: "Public project visibility is blocked by incomplete funding attribution.", + evidence: blockedFunding.map((item) => `${item.fundingSourceId}: ${item.blockers.join("; ")}`), + action: "Complete funding acknowledgements, grant IDs, DOI requirements, and audit evidence before public release." + }); + } + if (exposedReviewers.length) { + findings.push({ + severity: "medium", + code: "reviewer-profile-exposure", + message: "Reviewer profile exposure should be checked before public project display.", + evidence: exposedReviewers.map((collaborator) => collaborator.name), + action: "Confirm reviewer profile visibility matches project policy before publication." + }); + } + if (affiliationIssues.length) { + findings.push({ + severity: "medium", + code: "affiliation-mismatch", + message: "Some collaborators have institution attribution issues.", + evidence: affiliationIssues.map((item) => `${item.name}: ${item.blockers.join("; ")}`), + action: "Resolve ORCID and institution-domain mismatches before final attribution." + }); + } + + return findings; +} + +function evaluateProjectFundingAttribution(project) { + const funding = evaluateFunding(project); + const institutions = evaluateInstitutions(project); + const citation = evaluateCitationMetadata(project); + const profileFindings = evaluateProfileExposure(project, institutions, funding); + const findings = [...profileFindings]; + + if (citation.readiness === "blocked") { + findings.push({ + severity: "high", + code: "citation-metadata-blocked", + message: "Project citation metadata is missing required attribution fields.", + evidence: citation.blockers, + action: "Complete author and citation metadata before DOI or public export." + }); + } + if (citation.warnings.length) { + findings.push({ + severity: "low", + code: "citation-metadata-warning", + message: "Citation metadata is usable but not export-ready.", + evidence: citation.warnings, + action: "Resolve citation warnings before repository export or DOI registration." + }); + } + + const blockedFunding = funding.filter((item) => item.readiness === "blocked").length; + const institutionIssues = institutions.filter((item) => item.readiness !== "ready").length; + const readinessScore = Math.max( + 0, + Math.min( + 100, + 100 - + blockedFunding * 18 - + institutionIssues * 12 - + (citation.readiness === "blocked" ? 20 : 0) - + citation.warnings.length * 6 - + findings.filter((finding) => finding.severity === "high").length * 8 + ) + ); + + return { + projectId: project.id, + generatedBy: "project-funding-attribution-guard", + readiness: readinessScore >= 78 ? "ready-with-review" : "needs-attribution-fixes", + readinessScore, + metrics: { + fundingSources: funding.length, + blockedFundingSources: blockedFunding, + collaborators: institutions.length, + collaboratorAttributionIssues: institutionIssues, + findings: findings.length + }, + funding, + institutions, + citation, + findings, + reviewerActions: unique(findings.map((finding) => finding.action)), + auditDigest: [ + `${funding.length} funding sources evaluated`, + `${blockedFunding} blocked funding sources`, + `${institutions.length} collaborator affiliations evaluated`, + `${institutionIssues} collaborator attribution issues`, + `${findings.length} reviewer findings` + ] + }; +} + +function renderReviewerMarkdown(audit) { + const lines = [ + "# Project Funding Attribution Guard", + "", + `Project: ${audit.projectId}`, + `Readiness: ${audit.readiness} (${audit.readinessScore}/100)`, + "", + "## Metrics", + "", + `- Funding sources: ${audit.metrics.fundingSources}`, + `- Blocked funding sources: ${audit.metrics.blockedFundingSources}`, + `- Collaborators: ${audit.metrics.collaborators}`, + `- Attribution issues: ${audit.metrics.collaboratorAttributionIssues}`, + "", + "## Findings", + "" + ]; + audit.findings.forEach((finding) => { + lines.push(`- [${finding.severity}] ${finding.code}: ${finding.message}`); + lines.push(` Action: ${finding.action}`); + }); + lines.push("", "## Funding Sources", ""); + audit.funding.forEach((source) => { + lines.push(`- ${source.fundingSourceId}: ${source.readiness} (${source.blockers.join("; ") || "no blockers"})`); + }); + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(audit) { + const barWidth = Math.round((audit.readinessScore / 100) * 760); + const findingLines = audit.findings.slice(0, 5).map((finding, index) => { + const y = 318 + index * 34; + return `${finding.severity.toUpperCase()} ${finding.code}`; + }).join("\n"); + + return ` + + Project Funding Attribution Guard + Readiness: ${audit.readiness} (${audit.readinessScore}/100) + + + Blocked funding ${audit.metrics.blockedFundingSources} | Attribution issues ${audit.metrics.collaboratorAttributionIssues} + Reviewer findings + ${findingLines} + +`; +} + +module.exports = { + evaluateProjectFundingAttribution, + renderReviewerMarkdown, + renderSvgSummary, + evaluateFunding, + evaluateInstitutions, + evaluateCitationMetadata +}; diff --git a/project-funding-attribution-guard/reports/attribution-audit.json b/project-funding-attribution-guard/reports/attribution-audit.json new file mode 100644 index 00000000..936d852c --- /dev/null +++ b/project-funding-attribution-guard/reports/attribution-audit.json @@ -0,0 +1,121 @@ +{ + "projectId": "proj-neuro-air-001", + "generatedBy": "project-funding-attribution-guard", + "readiness": "needs-attribution-fixes", + "readinessScore": 20, + "metrics": { + "fundingSources": 2, + "blockedFundingSources": 2, + "collaborators": 3, + "collaboratorAttributionIssues": 2, + "findings": 3 + }, + "funding": [ + { + "fundingSourceId": "grant-clean-cities", + "funder": "Clean Cities Health Initiative", + "grantId": "CCHI-2026-118", + "readiness": "blocked", + "blockers": [ + "DOI required before publication" + ], + "auditEventIds": [ + "ae-1" + ] + }, + { + "fundingSourceId": "grant-pediatric-data", + "funder": "Pediatric Data Commons", + "grantId": "missing", + "readiness": "blocked", + "blockers": [ + "grant id missing", + "required acknowledgement missing", + "funding link lacks audit evidence" + ], + "auditEventIds": [] + } + ], + "institutions": [ + { + "collaboratorId": "u-ada", + "name": "Ada Chen", + "role": "owner", + "institution": "North Valley University", + "readiness": "ready", + "blockers": [] + }, + { + "collaboratorId": "u-luis", + "name": "Luis Ortega", + "role": "contributor", + "institution": "Bridge Children's Hospital", + "readiness": "needs-review", + "blockers": [ + "ORCID link missing for attributed contributor" + ] + }, + { + "collaboratorId": "u-mina", + "name": "Mina Patel", + "role": "reviewer", + "institution": "North Valley University", + "readiness": "needs-review", + "blockers": [ + "email domain does not match institution domain" + ] + } + ], + "citation": { + "readiness": "ready-with-warnings", + "blockers": [], + "warnings": [ + "DOI missing for export-ready citation metadata", + "citation institutions missing: Bridge Children's Hospital" + ] + }, + "findings": [ + { + "severity": "high", + "code": "public-project-funding-blocker", + "message": "Public project visibility is blocked by incomplete funding attribution.", + "evidence": [ + "grant-clean-cities: DOI required before publication", + "grant-pediatric-data: grant id missing; required acknowledgement missing; funding link lacks audit evidence" + ], + "action": "Complete funding acknowledgements, grant IDs, DOI requirements, and audit evidence before public release." + }, + { + "severity": "medium", + "code": "affiliation-mismatch", + "message": "Some collaborators have institution attribution issues.", + "evidence": [ + "Luis Ortega: ORCID link missing for attributed contributor", + "Mina Patel: email domain does not match institution domain" + ], + "action": "Resolve ORCID and institution-domain mismatches before final attribution." + }, + { + "severity": "low", + "code": "citation-metadata-warning", + "message": "Citation metadata is usable but not export-ready.", + "evidence": [ + "DOI missing for export-ready citation metadata", + "citation institutions missing: Bridge Children's Hospital" + ], + "action": "Resolve citation warnings before repository export or DOI registration." + } + ], + "reviewerActions": [ + "Complete funding acknowledgements, grant IDs, DOI requirements, and audit evidence before public release.", + "Resolve ORCID and institution-domain mismatches before final attribution.", + "Resolve citation warnings before repository export or DOI registration." + ], + "auditDigest": [ + "2 funding sources evaluated", + "2 blocked funding sources", + "3 collaborator affiliations evaluated", + "2 collaborator attribution issues", + "3 reviewer findings" + ] +} diff --git a/project-funding-attribution-guard/reports/attribution-summary.svg b/project-funding-attribution-guard/reports/attribution-summary.svg new file mode 100644 index 00000000..23d9a8cc --- /dev/null +++ b/project-funding-attribution-guard/reports/attribution-summary.svg @@ -0,0 +1,12 @@ + + + Project Funding Attribution Guard + Readiness: needs-attribution-fixes (20/100) + + + Blocked funding 2 | Attribution issues 2 + Reviewer findings + HIGH public-project-funding-blocker +MEDIUM affiliation-mismatch +LOW citation-metadata-warning + diff --git a/project-funding-attribution-guard/reports/demo.mp4 b/project-funding-attribution-guard/reports/demo.mp4 new file mode 100644 index 00000000..92af3cd0 Binary files /dev/null and b/project-funding-attribution-guard/reports/demo.mp4 differ diff --git a/project-funding-attribution-guard/reports/reviewer-packet.md b/project-funding-attribution-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..c83942b7 --- /dev/null +++ b/project-funding-attribution-guard/reports/reviewer-packet.md @@ -0,0 +1,25 @@ +# Project Funding Attribution Guard + +Project: proj-neuro-air-001 +Readiness: needs-attribution-fixes (20/100) + +## Metrics + +- Funding sources: 2 +- Blocked funding sources: 2 +- Collaborators: 3 +- Attribution issues: 2 + +## Findings + +- [high] public-project-funding-blocker: Public project visibility is blocked by incomplete funding attribution. + Action: Complete funding acknowledgements, grant IDs, DOI requirements, and audit evidence before public release. +- [medium] affiliation-mismatch: Some collaborators have institution attribution issues. + Action: Resolve ORCID and institution-domain mismatches before final attribution. +- [low] citation-metadata-warning: Citation metadata is usable but not export-ready. + Action: Resolve citation warnings before repository export or DOI registration. + +## Funding Sources + +- grant-clean-cities: blocked (DOI required before publication) +- grant-pediatric-data: blocked (grant id missing; required acknowledgement missing; funding link lacks audit evidence) diff --git a/project-funding-attribution-guard/sample-data.js b/project-funding-attribution-guard/sample-data.js new file mode 100644 index 00000000..a22e675f --- /dev/null +++ b/project-funding-attribution-guard/sample-data.js @@ -0,0 +1,104 @@ +"use strict"; + +const project = { + id: "proj-neuro-air-001", + title: "Urban air exposure and pediatric neuroinflammation", + visibility: "public", + citationMetadata: { + doi: "", + title: "Urban air exposure and pediatric neuroinflammation", + authors: ["Ada Chen", "Luis Ortega", "Mina Patel"], + institutions: ["North Valley University"], + fundingAcknowledgement: "Supported by Clean Cities Health Initiative." + }, + linkedInstitutions: [ + { + id: "inst-nvu", + name: "North Valley University", + role: "lead-institution", + verifiedDomain: "nvu.example", + requiresOpenAccessAcknowledgement: true + }, + { + id: "inst-bridge", + name: "Bridge Children's Hospital", + role: "clinical-partner", + verifiedDomain: "bridge.example", + requiresOpenAccessAcknowledgement: false + } + ], + fundingSources: [ + { + id: "grant-clean-cities", + funder: "Clean Cities Health Initiative", + grantId: "CCHI-2026-118", + requiredText: "Clean Cities Health Initiative", + requiresDoiBeforePublication: true, + publicAcknowledgementRequired: true + }, + { + id: "grant-pediatric-data", + funder: "Pediatric Data Commons", + grantId: "", + requiredText: "Pediatric Data Commons", + requiresDoiBeforePublication: false, + publicAcknowledgementRequired: true + } + ], + collaborators: [ + { + id: "u-ada", + name: "Ada Chen", + role: "owner", + institutionId: "inst-nvu", + emailDomain: "nvu.example", + orcidLinked: true, + publicProfile: true + }, + { + id: "u-luis", + name: "Luis Ortega", + role: "contributor", + institutionId: "inst-bridge", + emailDomain: "bridge.example", + orcidLinked: false, + publicProfile: true + }, + { + id: "u-mina", + name: "Mina Patel", + role: "reviewer", + institutionId: "inst-nvu", + emailDomain: "personal.example", + orcidLinked: true, + publicProfile: false + } + ], + auditEvents: [ + { + id: "ae-1", + actorId: "u-ada", + action: "linked-funder", + targetId: "grant-clean-cities", + createdAt: "2026-05-01T12:00:00Z" + }, + { + id: "ae-2", + actorId: "u-ada", + action: "added-institution", + targetId: "inst-nvu", + createdAt: "2026-05-02T12:00:00Z" + }, + { + id: "ae-3", + actorId: "u-luis", + action: "updated-profile", + targetId: "u-luis", + createdAt: "2026-05-03T12:00:00Z" + } + ] +}; + +module.exports = { + project +}; diff --git a/project-funding-attribution-guard/test.js b/project-funding-attribution-guard/test.js new file mode 100644 index 00000000..fdfe9ca7 --- /dev/null +++ b/project-funding-attribution-guard/test.js @@ -0,0 +1,34 @@ +"use strict"; + +const assert = require("assert"); +const { + evaluateProjectFundingAttribution, + evaluateFunding, + evaluateInstitutions, + evaluateCitationMetadata +} = require("./index"); +const { project } = require("./sample-data"); + +const audit = evaluateProjectFundingAttribution(project); + +assert.strictEqual(audit.generatedBy, "project-funding-attribution-guard"); +assert.strictEqual(audit.projectId, project.id); +assert.strictEqual(audit.metrics.fundingSources, 2); +assert.ok(audit.metrics.blockedFundingSources >= 1, "fixture should expose funding blockers"); +assert.ok(audit.findings.some((finding) => finding.code === "public-project-funding-blocker")); +assert.ok(audit.findings.some((finding) => finding.code === "affiliation-mismatch")); +assert.ok(audit.findings.some((finding) => finding.code === "citation-metadata-warning")); +assert.ok(audit.reviewerActions.every((action) => action.length > 20)); + +const funding = evaluateFunding(project); +assert.ok(funding.find((source) => source.fundingSourceId === "grant-pediatric-data").blockers.includes("grant id missing")); +assert.ok(funding.find((source) => source.fundingSourceId === "grant-clean-cities").blockers.includes("DOI required before publication")); + +const institutions = evaluateInstitutions(project); +assert.ok(institutions.find((item) => item.collaboratorId === "u-luis").blockers.includes("ORCID link missing for attributed contributor")); +assert.ok(institutions.find((item) => item.collaboratorId === "u-mina").blockers.includes("email domain does not match institution domain")); + +const citation = evaluateCitationMetadata(project); +assert.strictEqual(citation.readiness, "ready-with-warnings"); + +console.log("project-funding-attribution-guard tests passed");