From 7693539291c1aa9937c5ea4a13322e2973377d9e Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 19:27:55 +0700 Subject: [PATCH] Add project contribution credit gate --- project-contribution-credit-gate/README.md | 34 +++++ project-contribution-credit-gate/demo.js | 49 ++++++++ project-contribution-credit-gate/index.js | 118 ++++++++++++++++++ .../reports/credit-packet.json | 66 ++++++++++ .../reports/credit-report.md | 66 ++++++++++ .../reports/demo.mp4 | Bin 0 -> 20049 bytes .../reports/summary.svg | 23 ++++ .../sample-data.js | 60 +++++++++ project-contribution-credit-gate/test.js | 92 ++++++++++++++ 9 files changed, 508 insertions(+) create mode 100644 project-contribution-credit-gate/README.md create mode 100644 project-contribution-credit-gate/demo.js create mode 100644 project-contribution-credit-gate/index.js create mode 100644 project-contribution-credit-gate/reports/credit-packet.json create mode 100644 project-contribution-credit-gate/reports/credit-report.md create mode 100644 project-contribution-credit-gate/reports/demo.mp4 create mode 100644 project-contribution-credit-gate/reports/summary.svg create mode 100644 project-contribution-credit-gate/sample-data.js create mode 100644 project-contribution-credit-gate/test.js diff --git a/project-contribution-credit-gate/README.md b/project-contribution-credit-gate/README.md new file mode 100644 index 00000000..a550b844 --- /dev/null +++ b/project-contribution-credit-gate/README.md @@ -0,0 +1,34 @@ +# Project Contribution Credit Gate + +Self-contained review slice for issue #11, User & Project Management. + +This module validates contribution credit packets before a scientific project is published. It focuses on authorship consent, CRediT role coverage, and artifact contributor omissions rather than broad RBAC, invitation expiry, service-account governance, break-glass access, deletion/erasure, or funding attribution. + +## What it checks + +- Listed contributors have explicitly consented to the credit statement. +- Credited contributors have at least one CRediT taxonomy role. +- Contributors who touched manuscript, code, data, or result artifacts are credited or routed for review. +- Deterministic reviewer actions are emitted for publication blockers. + +## Files + +- `index.js` - contribution credit evaluator and report builder. +- `sample-data.js` - synthetic project/contributor scenarios. +- `test.js` - Node.js built-in test suite. +- `demo.js` - generates reviewer artifacts in `reports/`. +- `reports/credit-packet.json` - generated machine-readable decisions. +- `reports/credit-report.md` - generated reviewer packet. +- `reports/summary.svg` - generated visual summary. +- `reports/demo.mp4` - short demo video artifact for bounty review. + +## Run + +```bash +node project-contribution-credit-gate/test.js +node project-contribution-credit-gate/demo.js +``` + +## Safety + +The sample data is synthetic. The module performs no network calls, touches no live identity providers, and contains no secrets, tokens, private dashboard data, payout information, ORCID credentials, SAML/OAuth data, or private project records. diff --git a/project-contribution-credit-gate/demo.js b/project-contribution-credit-gate/demo.js new file mode 100644 index 00000000..ea3772c5 --- /dev/null +++ b/project-contribution-credit-gate/demo.js @@ -0,0 +1,49 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateContributionCredit, buildCreditReport} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateContributionCredit(scenario), +})); + +const approved = evaluations.filter((item) => item.decision === 'approved').length; +const remediation = evaluations.filter((item) => item.decision === 'needs-remediation').length; +const blocked = evaluations.filter((item) => item.decision === 'blocked').length; + +const svg = ` + + Project Contribution Credit Gate + Synthetic authorship and CRediT statement review packet + + + Approved + ${approved} + + + + Needs Remediation + ${remediation} + + + + Blocked + ${blocked} + + Checks: consent, CRediT roles, artifact contributors, credit omissions + Outputs: credit-packet.json, credit-report.md, summary.svg, demo video artifact + Synthetic data only. No live ORCID, SAML, OAuth, profile, or private project calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'credit-packet.json'), `${JSON.stringify(evaluations, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, 'credit-report.md'), evaluations.map(buildCreditReport).join('\n---\n')); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +console.log(`Wrote ${evaluations.length} contribution credit evaluations to ${reportsDir}`); +console.log(`Decision counts: approved=${approved}, remediation=${remediation}, blocked=${blocked}`); diff --git a/project-contribution-credit-gate/index.js b/project-contribution-credit-gate/index.js new file mode 100644 index 00000000..14d436d6 --- /dev/null +++ b/project-contribution-credit-gate/index.js @@ -0,0 +1,118 @@ +function list(value) { + return Array.isArray(value) ? value : []; +} + +function action(type, target, reason) { + return {type, target, reason}; +} + +function unique(values) { + return [...new Set(values)]; +} + +function contributorName(contributorsById, contributorId) { + return contributorsById.get(contributorId)?.name || contributorId; +} + +function evaluateContributionCredit(input) { + const contributors = list(input.contributors); + const statements = list(input.creditStatements); + const artifacts = list(input.projectArtifacts); + const contributorsById = new Map(contributors.map((contributor) => [contributor.id, contributor])); + const statementsById = new Map(statements.map((statement) => [statement.contributorId, statement])); + const blockers = []; + const requiredActions = []; + + const artifactContributorIds = unique(artifacts.flatMap((artifact) => list(artifact.touchedBy))); + + for (const statement of statements) { + const name = contributorName(contributorsById, statement.contributorId); + if (!statement.consented) { + blockers.push(`${name} is listed in the credit statement without consent`); + requiredActions.push(action( + 'collect_credit_consent', + statement.contributorId, + 'publication credit requires explicit contributor consent' + )); + } + if (list(statement.creditRoles).length === 0) { + blockers.push(`${name} has no CRediT roles assigned`); + requiredActions.push(action( + 'assign_credit_roles', + statement.contributorId, + 'credited contributors need at least one CRediT taxonomy role' + )); + } + } + + for (const contributorId of artifactContributorIds) { + if (!statementsById.has(contributorId)) { + const name = contributorName(contributorsById, contributorId); + blockers.push(`${name} contributed to project artifacts but is missing from credit statement`); + requiredActions.push(action( + 'review_omitted_artifact_contributor', + contributorId, + 'artifact contributions must be credited or explicitly excluded with review evidence' + )); + } + } + + const hasConsentBlocker = blockers.some((item) => /without consent/i.test(item)); + const consentedCount = statements.filter((statement) => statement.consented).length; + const decision = hasConsentBlocker + ? 'blocked' + : blockers.length > 0 + ? 'needs-remediation' + : 'approved'; + + return { + projectId: input.projectId, + action: input.action, + generatedAt: input.generatedAt, + decision, + blockers, + requiredActions, + coverage: { + artifactContributorCount: artifactContributorIds.length, + creditedContributorCount: statements.length, + consentCoverage: statements.length === 0 ? 1 : consentedCount / statements.length, + }, + }; +} + +function percent(value) { + return `${Math.round(value * 100)}%`; +} + +function buildCreditReport(result) { + return [ + '# Project Contribution Credit Gate Report', + '', + `Project: ${result.projectId}`, + `Action: ${result.action}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + '', + '## Coverage', + '', + `Artifact contributors: ${result.coverage.artifactContributorCount}`, + `Credited contributors: ${result.coverage.creditedContributorCount}`, + `Consent coverage: ${percent(result.coverage.consentCoverage)}`, + '', + '## Blockers', + '', + ...(result.blockers.length ? result.blockers.map((item) => `- ${item}`) : ['- None']), + '', + '## Required Actions', + '', + ...(result.requiredActions.length + ? result.requiredActions.map((item) => `- ${item.type}: ${item.target} (${item.reason})`) + : ['- None']), + '', + ].join('\n'); +} + +module.exports = { + evaluateContributionCredit, + buildCreditReport, +}; diff --git a/project-contribution-credit-gate/reports/credit-packet.json b/project-contribution-credit-gate/reports/credit-packet.json new file mode 100644 index 00000000..55d5e8f6 --- /dev/null +++ b/project-contribution-credit-gate/reports/credit-packet.json @@ -0,0 +1,66 @@ +[ + { + "scenario": "consent-block", + "projectId": "project-neuro-imaging", + "action": "publish-preprint", + "generatedAt": "2026-05-22T12:30:00Z", + "decision": "blocked", + "blockers": [ + "Mika Rao is listed in the credit statement without consent" + ], + "requiredActions": [ + { + "type": "collect_credit_consent", + "target": "u2", + "reason": "publication credit requires explicit contributor consent" + } + ], + "coverage": { + "artifactContributorCount": 2, + "creditedContributorCount": 2, + "consentCoverage": 0.5 + } + }, + { + "scenario": "omitted-contributor-remediation", + "projectId": "project-climate-model", + "action": "publish-preprint", + "generatedAt": "2026-05-22T12:30:00Z", + "decision": "needs-remediation", + "blockers": [ + "Jon Bell has no CRediT roles assigned", + "Ren Ito contributed to project artifacts but is missing from credit statement" + ], + "requiredActions": [ + { + "type": "assign_credit_roles", + "target": "u2", + "reason": "credited contributors need at least one CRediT taxonomy role" + }, + { + "type": "review_omitted_artifact_contributor", + "target": "u3", + "reason": "artifact contributions must be credited or explicitly excluded with review evidence" + } + ], + "coverage": { + "artifactContributorCount": 2, + "creditedContributorCount": 2, + "consentCoverage": 1 + } + }, + { + "scenario": "approved-credit-packet", + "projectId": "project-approved-credit", + "action": "publish-preprint", + "generatedAt": "2026-05-22T12:30:00Z", + "decision": "approved", + "blockers": [], + "requiredActions": [], + "coverage": { + "artifactContributorCount": 2, + "creditedContributorCount": 2, + "consentCoverage": 1 + } + } +] diff --git a/project-contribution-credit-gate/reports/credit-report.md b/project-contribution-credit-gate/reports/credit-report.md new file mode 100644 index 00000000..4db81d35 --- /dev/null +++ b/project-contribution-credit-gate/reports/credit-report.md @@ -0,0 +1,66 @@ +# Project Contribution Credit Gate Report + +Project: project-neuro-imaging +Action: publish-preprint +Generated: 2026-05-22T12:30:00Z +Decision: blocked + +## Coverage + +Artifact contributors: 2 +Credited contributors: 2 +Consent coverage: 50% + +## Blockers + +- Mika Rao is listed in the credit statement without consent + +## Required Actions + +- collect_credit_consent: u2 (publication credit requires explicit contributor consent) + +--- +# Project Contribution Credit Gate Report + +Project: project-climate-model +Action: publish-preprint +Generated: 2026-05-22T12:30:00Z +Decision: needs-remediation + +## Coverage + +Artifact contributors: 2 +Credited contributors: 2 +Consent coverage: 100% + +## Blockers + +- Jon Bell has no CRediT roles assigned +- Ren Ito contributed to project artifacts but is missing from credit statement + +## Required Actions + +- assign_credit_roles: u2 (credited contributors need at least one CRediT taxonomy role) +- review_omitted_artifact_contributor: u3 (artifact contributions must be credited or explicitly excluded with review evidence) + +--- +# Project Contribution Credit Gate Report + +Project: project-approved-credit +Action: publish-preprint +Generated: 2026-05-22T12:30:00Z +Decision: approved + +## Coverage + +Artifact contributors: 2 +Credited contributors: 2 +Consent coverage: 100% + +## Blockers + +- None + +## Required Actions + +- None diff --git a/project-contribution-credit-gate/reports/demo.mp4 b/project-contribution-credit-gate/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..080604309567fcc1481df9ed3f6a00f0d0e77d51 GIT binary patch literal 20049 zcmeIaWmsHGwm;msySqcs;O-VYNC?5T@x~p3gy0(7-Q6L$L$Kg(!Civ;zd7g3y)$?2 z%%}T)d7r6=y=zr1`PEugYuE0ln*sm;g-%z*|LSsdvnr+3FV=zO)*3R7;XzE1H&dSQc%+AWr z0q!(+a!Y1Dfc+T4V!j=sOtNm;r^@xXF#p9c)2{`e0Qyawi9% zwY7yKnBsNgH8FMq6UO!+Ay)7Z3{5<2ZGb}TY)ouyPWpC^Ru*=z5`P-7x6`*Z zHFX3!2{E&iJDEFxHNb{=$gOQ{tqje7-?ZDhWJMF-# zRzP>KZ6S6p*1t%7kcADnN$zM2v;i7BI|*^Jz7EsD@HM6mKu2@1x`VO)f4Te0cQ6(* zb}%Ie8G!@)>K3>m#LmION^bw^j1VgeH<+}0HT*|y=qAL?2kvlm0@?|2l3Un;X9>I_ zz>^5JWoQpx0I$;t@C5*PSb)J_0Rk`2o29qd^Mg8eKA++yB18mLb!!I|mK{27NiJu= zVo-nmLCB7*nja4v*Q)%{iNFeo@ui-!=X+~H83PCk^;GNsP^(b*!uFLnqc<_V-XV55 zQn$l)<}F_RujEpIjaBH#gGvIv^>;MY$p9*zdo4WvCj-3P`}+3xIwW1$tD3I(XRC1> zm0xhLG&qf7U1Jj3;-#vLYUx!l9Yc=lDzv+}CJWV7=7UKP3%g8(JjV-Q2Kc;9-WYN)&Z+NI&Y?MkQ2VmVGhtlW~ zxIZsU?M}yXP$;+Wk!%H>I4~MSe{S_qT8vT6q3!dS{q3vkGt@L7x4Esv_Os9SB4S6d z8T106(c)X6Z|3qpFTC!-r$G?AfsR-^>UVoD%5hsR1FXv%_nDZiWXL()w_;aORSSg= zx^APlF>5bk7@s-JrtgdX(d`>asRvWc_7>6VdDyP#GSXECYY$n_=y#zG^%D9A?cS-h z?Gf+Jn^YHvIH!DuEI1f2!sId1q4DKM~q8y(&KQW&#z~Y$)i3|el$c1 z(a0-TjdA7Bg%Gg{Sc(dF$Y|IO)1#f5ctkGpLCGvC$KH_-K~C-QJGL4jc~9Lp>Df^7 zcxw=}$x|*@2d^W*NrEcY^`4#I`zQT(GR(O69D?FU*>e(`7k){S&E7|aNOtyq8HAQD zhTR0N29?LWce^H^C$sZgBIe+}R8i_Xa<9!r z>``UyKJBkodBs2Xxr*>b&e(e!t=#GhAh&xd=g`T-bYq;@uu$v^GRuP63xvqXSvbub zMi463?i}3YCU2npKYz2m%2*d5l5Z-eG}O@NFpU@pB+(Ip#Jed{%mBud@*y5eqyWzc zzI~20q?10PSyAm)t44;$kOr3d9AktwMxes2dBiK_l@o{TXY)2Sed;Sc-}fpBloKP~ zMdI`XNvEUNN9;Dr+6%(NEE|Re;z&QvPJco7d}(e&c@X@K?5G&`n{d6{K+?IofchJK zct8r#8dPthW*r1EeaHbZ!l+?Wo$^7@#Bt+$TC-~G1H>9f(+d5eFK0)-Gx8~7?1TA& z>a&%`HY+k;i*BU(NP45w*aWX|n0r@Hv$tLZS0#l%pH<&EWEF7Ax4ivO=x!O1z8ft; zn^?T6v&1^nMt%`!Nkz%G0{fi~De6pxRrVYFo5dx1T@-{PSSv%(gwq?GI@8XZ7zQ7ShwQ5;!KJ^B&;NspNi=wGH8GyxGsXGEXFlIxzX)^DU(t2 zNFSt)(jLKgnWZ0C-SM4*W0iU7DBCB1u zJo`m6QyQBc=VV9ojh(n|PRT3@Qb|Ia7m|pBg@*`CNlqEPUD|qhlTvaq+ALY}(!@68 zVIbzp0aDalg;=V(lgRDqg%7pbDuyREL?JOKo1g)XX$iR=PMsiv zrRfIGSwSs>y%QO9;B$q`!vq z4JDCx8pAOlgM^Khl?6}VPpMx{n?{WGy8M*Hc=vX%n*a+QlCAx#ZBVJqg+*ZjEN2hQ zFRyw8j7`nBU6)AEgVurW`KqPlWT8;Ajdf?NPM0t0QG41^r-KB(%UGQJHbaR=N+$V2 zEu%@Teg{dsm-VlUEX+x&G1`F*#;T_-l`lN0s_9`7%N}G3Ftf*7T_>84F!^>v_?(vo zwZ)+h>;g1&6C&>j-rkP~whliu?@#7c%;V(vKxWJoQyX@;KSX4v0RW^mhPG2foB0xBYr)sYDh~JXfiMx{DpddS8lr=9!ISzpIBBv{;P#cJJIo z@dKP>+-$06=E?G>$<)q+A$GM-hvCq+-Xn^I~l-nK`ScgTFtW? zuj_&U<4x3^6H%<(tXj$5d=SGlnNNr&N5x9e=tn;Pje07(NWK%o9?1_-II=N5b#g)B zO8u9ej)hO*FguB{h4}%f>|{qh#RZUvaYaBM221Md%W3@tSh6XiNw&hHLf;KE= z&E@sb`P%fH${y*Imr`>#Uu!k&fHw6eY{C)_-Ca3|1h>H3+ws(UHBs5nWi#UP) zLJYJgYLEpQ?dN1NUG)k&IyHV(l6G2MSKi953LDa4h`RG#ckZBVD}rdQh|5HZP70s)MHHEAG+2Sr%t8qI=i^>}y1F?-+bc;+?0|<{qt@5H4JsBRnENFhZ){|%1EDlLmk zAFB;gatmvyl2$9GckR4#^JgA=!_MSn!mKB+YH&93A{ACt91~treAppRq=lEHD!04v z!j@A#Z7V3giRf;F*tG9Z+Z(7 zYOOnQpYc?9+|?ho+4TuUaFkHruZw>%mm(9F!|blhM@zHzKu71e!jDUIVGTK(XU_{?{*l#;Bt;T{ecR_1cf6@NN;K7p zo3|oDuDh2sh&qZ22!6JXMBG01bxR47H}g1hDmGWos3N5oJvN42uP{)sF5tvu%If+p zTcy|+A>hzI@WxYi9)B*z9u!$C^fPymZpO<4B{TAa%kaq1Qd1Dl+j>1IvfjuHj|K?O zx+|!NHzE1T3Ktlij_IR%_Pv_ShGuyc33k z#M0T0i=$UBI)X0M-LNgV>~4)TS3ARwJNB_rM_DDV-@SEgTHM~3O<>S8i61bgjX(_AFq*bBpg zHW-=dzQ5N3$R#^yp?q3rpEvO=I{r07I@N@(yw00ki3>f=xL#WJTbOQ~jmSan8zTJ1 z5P*H^#qjw6V!T!8I%5-3Y!S&d0~lvfx_g_HyWH}9L*_IJj&pz@mMSsYgN|$|a2S#p zcaYHIh~;oNV0E1^N4I{d8Wvk2v|BScuTPwznGQ=M)hWP(%=ejh3Z7*q$uQhH8^vhe z$m69Hnj$|nIXjpZy^I}O^8=JB?Y8hrvNoL&G{98)y4ATh>rURYdyK?d=hyoVJmv6b zBEiBFU?gST*PB^iiv**zU*Dumf?yw8l;B(5FtCpal_h_W)fIz7qtGs(W(-SrJ-xR! zhJk{LaCdgGfo_6i-Gym-v+rKU^T62{5ql~soq@Wuzz93d&eu-zlFuM=h;IO;Noyf# zk-pP%Y1DC-hxajs6j24uvhbaz#@VVe?XggE9QO_a4Dj;(%sO9+DDyF|RMRq{jvEn^ z;dcWY1-p;ZjFXVQ_{78|jON94x1DPzB*{r}qAL_J>-&pn;^ zOP^`0{RB2zFyh0Z6Sx9D^Fgc}g<7j04KhhIKQ{%wB;#T0yE+%@Y)g%b zIkwbtpf>mt=4GBahSV7k-}($&N)r7rk~8-_2qbO24Gi*I3Q&nlV|**3uqbY1$2XiA z`s7D>W@_A>`vrGyZ(tUqHZ^`(ZOLZz#|XneKIPWMldJNNb#txPk?&Qig!_~wG| zjFQ|fV-eJzipqavoLI$$pZi~ISzmDo0H1viIWhGFmrm}tHuTaG9A=|6VRXK+H^m%& zX5ulKhi&j|XguxToFzOY4(8_8E8UVIv0p#XF*3OmmLSMjQHjq`e>pRqpDM7*R2}+*z@w#RcP5p{7BJ5Wx+>F6}**x_KMDTsM-)1wp>xgV@3ZMvf4a6s6 ziU{e_GG=dklixNkQVe{UoNz{gO_;HSyFjUb-W%{l9}XbFs;A|kJox0bDGp^d`@3|g zYBFFS=e-ZLyf)Na;F~+0qmQg_IkkU>$(IYV9y#4`YFRQN$meQpJ@O%p^AtsU+ok7z z6VodJlt$RJy1TJp{Ztvy3+5afl*D)vi^;!0jM2??iZvH497vX9!;Vak7yh8|1aNT; z{7HDg`KB37%XN=zKbBD}ZECk`GoOVD=#3ikUe|phmSs|s81F(Wcc3xYIGstdBR8B4 zCNRrgz-33eBIrGF+c6AwAsfi_Nu+?;sNTCVspANF$AbalH`m9(HKu6?JG^Y=6wlF& z0Mm4l=Ck?FQFA{=+e~XL-=ezW&f4^!Y&|{4mm^xwT&VIu*DEl@dVh&FMiG#SDu*19 zkyE5Bf)kcyup2iz`NkQm;KBgPF&?qpZo94#R5x)@Q{#6%O;+nkNej_+J}x^UQn(Z4 z^AzLGi@@TeTeu?5hz;)w-2bs^bpC|l`_;mzkRXMWwcw?+X{yFtdlnekeeTZf%;b3D z*EJyGhi3GdA7fK^)Eib2i%%4SYY$^FZeVR$w)36diR6gi_V@gYjOeekw+UrJh#z9N z>)X(>eLgY_9ir;3i4gM1e#uB)j&SePS|uF_4R; zF8PtzawD|x^N^kS;uw9T2|<01DfukiEbW@D^37GORqZ$a+sE(ZZI?${kqjXPRbJJ? zyQf!*M-J4@9hL_xR1}C^2y*4T`T1fYI$JRb*41-14_& zw>;FZBI0JnKD#4?SU&@&Lr*)R$;n^@obu>$A{FY7oDe;{@lia5Wcl{L?<@<2F)n*Y zotbb9uC3z~uR5~CD*@-=s?TV-p}!IGq{O2;?>V$r6IH_eQvL9?MLz!!&9$s-83pa_ z%9?(*Rpe%J$?vZ^*gYur@;>gxz?ohrb$vdp8xNl@v^#N?QJ>8M(OzU5TG)0Z_!<81 zgHIMU(Jl|#!N}Xj8%)V}!lp_2s&4G(3J3QskJFi}IY|~;gVgIOwj5$-#_3p` zDJxs~q%MYBP}P#)NmA*5=ha#6LV;ajz56p)w6CLq)evrqyltNHpP9iCqL zgz?y)h0n{Ed9WIgBi+zn{DSLq3~OgdZKiR#Q0`?TF^mvWgoM8+p#ShHUEa7u*@ zW}decn#bORcG#{lj4P#C(Cy7xn{k6Df12mE>GUyME4`(4(vNwxT!L*KL4 z`*D=>`*m}D%-O`VpvjiJGUVB8!b#5ZD>(>zLGFjh3Ra9OzrM47o{E1Y?dtZs)7bfD zP2(@1a*wzH%NXN}#84lKAVib7phPOi&Tupti-mCod|yh7aTnfmR^xIJXR+o!$fEou zLS7UdcNEu|#fN$X{mCB=#_fF{2QAX3(n5Y@i?UkC?9K+y@Qs*sT{79X_(FP1h5*AA6>;-%2_EA{?2o zu6BH=g}nJR%kIN&>=kx(Qve~A`n>p20lvIdR5IInP0WOnW*>E)de*caWHDAPe2&f+ z3SZM>k&fj2GuKvTpykbFy3_QCc`YJIot%#+$P*`*QMTuZa7n|Vm@fk*#(fVJV}j55 z?8N)Tsukgz2$Yk^$aY`Zrv$3Bn!c~D4~|`Gv%}-cUT3>2K}hSL-)Q{&e$#}wNx|yJ zSa1tE{ZS7^mYxcmD}feY#$>kjbJmD?(0ta`OXIatu3ptnDbr_0#zyfi-dIE#pjD3N*zKR zHDg~|05STN`KJ}7!6xS7M7dPw2&EukU#NMDMUuh9vghdVBqBZ^F^^YQcBu_@Tol#7 zRBc2x$iybc2+W^dGyT zi4vfxOl@1zgdZ^00pzPN2v3iIcQN052oc#=osqoLd`%#QN@-29=>t%ZUM#k=EzR6z zWdrJ}JZY|FIFcHzZ7Jj&Y2}YBsU5=Ml@|PMHRCku?JYOuM`LLmqGle8A!{E$r7>^i zPoHv;EkejN2Wb>DmC7w)>9N>F_?6fwrN)m!+d&WAvJ{=yJmA-0xn2i-7C>@jidsMv z@0~i4UPoe$|EMm$)cM8)!P~xcF#UJw+o&U_8Vps1ygf9)au{sJ(#Gm)orBui3^v2A zNieQuM?6FG5qC)g=EC@p>Cw8uXg7P*?6heE4JWY0#wm&{6?}6*6!(9 z3`dBSBtPaYPo2gLsf^g(jJ${{vR9ho4~#RENEinWk4$V%Z?kBJbRaCa;Ei)ouQcSk zKFNAzykII}kie)0%{JGywwaoxVzvozY9oSPJfJEs5NbqNe$Ix{i$iSkcJ^o!sD6%f zpwL4K`%)OFej9VzFrwDoP7#6^Y8!^(GjRNi3@?c55@D088FIwiu0l+L8t`(m&>8AJb)DS!D{4XUtz+85y=QxC;i{i+ zPy?0TE}@*5D(wsLmf=E)BVQ)LF?xw?Du*9O$deLd>G(#_Lkr+hUFRc&uyxIRwB^@7 z?@raSS}Xv?Wgozu&KvG1We;z3YUwZd+1fu?di9A$bJe-T2wOFH(reY>5~jY3E%ZLc^Q*i=Jqhdf-bp76 zOQO0t=1)*aNOi!pP^LCOSNgy!IvN=^n^P4}3~MK=*j( zU1=A}>q>#Q7ca<#?=W_58uvxDu0c<7e~6)RlW|J2OV@#Z9t8>+HYUqP`$!7Q^0=by zOkGW%?%jR*A^KNH1^!+LzIX4oTzH$=6MQrJlEz2(cYk7v)qi=@C8g%&sHer+Qq?H$ z){petW<8N;>|u{M8sKIe?-p__iLTIGKK`6?FxpvPp}Si`Z)LVS62Aev{!w_bfV=_p zjnrII33czpS8%ZABg)yRGbTpONKWDhEI=)~ z4uxNyaA@^Jb$Y^lW0*}gHu$+((v1PpiR&4ycF9{RbGFtT%>&xy5UK_j8=1yCOgO7}>k_a^yEC%9FX* z&BKz7W^6lNDimgWVf8KXD|mE=#+yf5Kdmy_G6*6kpr< z*}u3i*JypM9Auf8h_b88$2>tz9V))zHmvb`Aj0N5!J=QBua|;x$wQ5=mZrgeYOi+! z+?l%KQ5>X0-)aRVV1VLxgqB;WciO@WPvxkk{l3);wYZ->Wl^kDZzrc=d60}vaO)Qg zPB{#8ix>U==dKo}?M$mqv1R;OKR>ZzMdgDezxAp;$5x#@!9!rw+qr|ecDdy ziAihnlS#X^Yp{p=YP}Mvs3vjn11U4coz3Y+EPEyl+d9b=_meq$9*X8DcSbv&r9A>{r)klMt$*20>UGtrIif?m3?McpNhsuR&%G27&qGBygJG3 z;!&AQ8MX8xBaO%1F~a=yUV!FIGjsDk3FxbF*JpI^Ob;HU(8ceE0>hS9Q@@@{tEi76 zBFy+q_uF@gH1DLXH>sH1HjIhfu9{G~Pi%PSt0;bG!6NWK<%;h;`1Ve4yi4jJ2s^H5 zQu5ho0Ys3P`%4r+l zDQC>9A_VfJK1gNQNOcd4d?u@dWm9cc4|sk->6C+VpACv{m@p!R$Vo6?ldugIHKl8i z89iA&cKMhrYQL(8-w)y0@%d-EW>37Kha&RFSfc3zmR^Q(Cnm&)qtxHA-QBnrS9130X*4*PzI>rwKA?twhIX> z0&J^tbg}w zJqT@$u0v>|;q0l-e&J!3{Hz4m)QjrR>_B968k{`#)KetdPKVpzacMKa)u~6n)}shO zN1C(%pkI8x_w+4rTAe)Y#k1KMD4VX&?e@KV|Jbs^wHKg*AcRr0o~ARdP0PGN{k=T% z*F{(o!lxpd3I$~iR)2@OE=ptBl@3YPTE>b4SsGx(73H^2oPyy`3|(qbD4Kx4tK zb!b)EPRSE+i(;~0Ri;C4f#`l3jL3KVXy@DqI?J4uxH|GPh;y zA|!gn`ERkJG-Fk&z^}`DQiOf8$YUrci_x0?QNf7`acz;37Nk(aoT$|3jW*o2P)F|P zQw%Yz)ga?6au^+qwvCS6zyW~9+gbC4t_8?_VtGs~d-0G^g%+-zcf(>< z!;@oo!#Ax^bIy1Z53pDfv|o|<2w+48T-tK^J~c8Zh1(6{?0FO@Sdi@+Y$Ql=*=gP8 z3=q3wz&y40Y_%rkawvv#OQLg9HOF>0ZKCfkX2nfI@+RsCDD90xC7jm!Kjx{RgyR{0 z$>ro3Y@7A|eeC7ke4#5utBat~JCG}eOkSWkl;ax=&r~vhpCY(#HZ4;3XxRf|I3;snJrBs#pvbf z2<>~}GK+-Pbx=2m$@nhCO#}rr@RZk&Y+as5((BGsxU7*HINy~>Zoqd78=+a>=hK%J zcp?!(qFI;It?Lq`UXI!*U2a*W^@O2^Z99ahq2bDR$O_WJvS|^*HV+9+R1H*g3K?JhJH?Rh-~8sWoW&1pk#y1CbBb!bw6ov~ur4Y|{dQ zpvD4=2c+G5;|yuHCZyBHC_b)X(5}pIeGt(cn)tWjGN30A-eh_ z

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 + + Project Contribution Credit Gate + Synthetic authorship and CRediT statement review packet + + + Approved + 1 + + + + Needs Remediation + 1 + + + + Blocked + 1 + + Checks: consent, CRediT roles, artifact contributors, credit omissions + Outputs: credit-packet.json, credit-report.md, summary.svg, demo video artifact + Synthetic data only. No live ORCID, SAML, OAuth, profile, or private project calls. + diff --git a/project-contribution-credit-gate/sample-data.js b/project-contribution-credit-gate/sample-data.js new file mode 100644 index 00000000..27c3e1f2 --- /dev/null +++ b/project-contribution-credit-gate/sample-data.js @@ -0,0 +1,60 @@ +const scenarios = [ + { + name: 'consent-block', + projectId: 'project-neuro-imaging', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Ava Chen', role: 'Owner', orcid: '0000-0001-1111-1111', activeMember: true}, + {id: 'u2', name: 'Mika Rao', role: 'Contributor', orcid: '0000-0002-2222-2222', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Writing - original draft'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Data curation'], consented: false, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'data/participants.csv', touchedBy: ['u2']}, + ], + }, + { + name: 'omitted-contributor-remediation', + projectId: 'project-climate-model', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Noor Silva', role: 'Owner', orcid: '0000-0001-3333-3333', activeMember: true}, + {id: 'u2', name: 'Jon Bell', role: 'Contributor', orcid: '0000-0002-4444-4444', activeMember: true}, + {id: 'u3', name: 'Ren Ito', role: 'Reviewer', orcid: '0000-0003-5555-5555', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Supervision'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: [], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'code/model.py', touchedBy: ['u2', 'u3']}, + {path: 'results/forecast.svg', touchedBy: ['u3']}, + ], + }, + { + name: 'approved-credit-packet', + projectId: 'project-approved-credit', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Lina Park', role: 'Owner', orcid: '0000-0001-6666-6666', activeMember: true}, + {id: 'u2', name: 'Samir Okafor', role: 'Contributor', orcid: '0000-0002-7777-7777', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Funding acquisition'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Software', 'Formal analysis'], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'code/analysis.py', touchedBy: ['u2']}, + {path: 'results/table.csv', touchedBy: ['u2']}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/project-contribution-credit-gate/test.js b/project-contribution-credit-gate/test.js new file mode 100644 index 00000000..c69ba75f --- /dev/null +++ b/project-contribution-credit-gate/test.js @@ -0,0 +1,92 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateContributionCredit, + buildCreditReport, +} = require('./index'); + +test('blocks publication when listed author has not consented to credit statement', () => { + const result = evaluateContributionCredit({ + projectId: 'project-neuro-imaging', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Ava Chen', role: 'Owner', orcid: '0000-0001-1111-1111', activeMember: true}, + {id: 'u2', name: 'Mika Rao', role: 'Contributor', orcid: '0000-0002-2222-2222', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Writing - original draft'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Data curation'], consented: false, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'data/participants.csv', touchedBy: ['u2']}, + ], + }); + + assert.equal(result.decision, 'blocked'); + assert.deepEqual(result.blockers, [ + 'Mika Rao is listed in the credit statement without consent', + ]); + assert.equal(result.requiredActions[0].type, 'collect_credit_consent'); +}); + +test('requires remediation when active artifact contributor is omitted from credit statement', () => { + const result = evaluateContributionCredit({ + projectId: 'project-climate-model', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Noor Silva', role: 'Owner', orcid: '0000-0001-3333-3333', activeMember: true}, + {id: 'u2', name: 'Jon Bell', role: 'Contributor', orcid: '0000-0002-4444-4444', activeMember: true}, + {id: 'u3', name: 'Ren Ito', role: 'Reviewer', orcid: '0000-0003-5555-5555', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Supervision'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: [], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'code/model.py', touchedBy: ['u2', 'u3']}, + {path: 'results/forecast.svg', touchedBy: ['u3']}, + ], + }); + + assert.equal(result.decision, 'needs-remediation'); + assert.deepEqual(result.blockers, [ + 'Jon Bell has no CRediT roles assigned', + 'Ren Ito contributed to project artifacts but is missing from credit statement', + ]); + assert.equal(result.coverage.artifactContributorCount, 2); + assert.equal(result.coverage.creditedContributorCount, 2); +}); + +test('builds deterministic report for approved contribution credit packet', () => { + const result = evaluateContributionCredit({ + projectId: 'project-approved-credit', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Lina Park', role: 'Owner', orcid: '0000-0001-6666-6666', activeMember: true}, + {id: 'u2', name: 'Samir Okafor', role: 'Contributor', orcid: '0000-0002-7777-7777', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Funding acquisition'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Software', 'Formal analysis'], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'code/analysis.py', touchedBy: ['u2']}, + {path: 'results/table.csv', touchedBy: ['u2']}, + ], + }); + + assert.equal(result.decision, 'approved'); + assert.equal(result.blockers.length, 0); + assert.equal(result.coverage.consentCoverage, 1); + + const report = buildCreditReport(result); + assert.match(report, /project-approved-credit/); + assert.match(report, /Decision: approved/); + assert.match(report, /Consent coverage: 100%/); +});