Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions repository-artifact-prune-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Repository Artifact Prune Guard

This is a self-contained Project Repository & Version Control slice for issue #10.

The guard checks whether repository artifacts can be pruned before a tagged scientific repository release or export bundle is published. It protects citation evidence, DOI version manifests, export bundle manifests, and reproducibility run packets from accidental deletion.

## Scope

- Synthetic data only.
- No network calls, credentials, DOI provider, storage provider, Git provider, or SCIBASE production service integration.
- Focused on artifact garbage-collection readiness, not broad repository ledgers, external reference pinning, retention legal holds, embargo release checks, component-owner approvals, branch protection, or release signatures.

## Validation

```sh
node repository-artifact-prune-guard/test.js
node repository-artifact-prune-guard/demo.js
```

The demo writes deterministic reviewer artifacts under `repository-artifact-prune-guard/reports/`.

Reviewer artifacts:

- `reports/artifact-prune-packet.json`
- `reports/artifact-prune-report.md`
- `reports/summary.svg`
- `reports/demo.mp4`
147 changes: 147 additions & 0 deletions repository-artifact-prune-guard/artifactPruneGuard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"use strict";

const DAY_MS = 24 * 60 * 60 * 1000;

function daysBetween(olderIso, newerIso) {
return Math.floor((Date.parse(newerIso) - Date.parse(olderIso)) / DAY_MS);
}

function indexReferences(references) {
const byArtifact = new Map();

for (const group of references) {
for (const item of group.artifacts) {
const entries = byArtifact.get(item.id) || [];
entries.push({
source: group.source,
sourceType: group.sourceType,
requiredHash: item.hash || null,
severity: group.severity || "blocker",
});
byArtifact.set(item.id, entries);
}
}

return byArtifact;
}

function artifactLabel(artifact) {
return `${artifact.id} (${artifact.path})`;
}

function analyzeArtifactPrunePlan(input) {
const now = input.now || new Date().toISOString();
const retentionDays = input.policy?.minimumUnreferencedAgeDays ?? 30;
const candidateIds = new Set(input.pruneCandidates || []);
const referencesByArtifact = indexReferences(input.references || []);

const artifactsById = new Map(input.artifacts.map((artifact) => [artifact.id, artifact]));
const missingCandidates = [...candidateIds].filter((id) => !artifactsById.has(id));

const approvedPrunes = [];
const blockedPrunes = [];
const warnings = [];

for (const artifact of input.artifacts) {
if (!candidateIds.has(artifact.id)) {
continue;
}

const ageDays = daysBetween(artifact.lastReferencedAt || artifact.createdAt, now);
const references = referencesByArtifact.get(artifact.id) || [];
const blockers = [];

for (const reference of references) {
if (reference.requiredHash && reference.requiredHash !== artifact.hash) {
blockers.push({
code: "REFERENCE_HASH_DRIFT",
message: `${reference.source} expects ${reference.requiredHash} but ${artifact.id} is ${artifact.hash}`,
source: reference.source,
});
continue;
}

blockers.push({
code: referenceCode(reference.sourceType),
message: `${artifactLabel(artifact)} is still required by ${reference.source}`,
source: reference.source,
});
}

if (artifact.retentionHold) {
blockers.push({
code: "RETENTION_HOLD",
message: `${artifactLabel(artifact)} has an active retention hold: ${artifact.retentionHold}`,
source: "repository policy",
});
}

if (ageDays < retentionDays) {
blockers.push({
code: "MINIMUM_AGE_NOT_MET",
message: `${artifactLabel(artifact)} is ${ageDays} days old; policy requires ${retentionDays}`,
source: "repository policy",
});
}

if (blockers.length > 0) {
blockedPrunes.push({
artifactId: artifact.id,
path: artifact.path,
blockers,
});
} else {
approvedPrunes.push({
artifactId: artifact.id,
path: artifact.path,
reason: `unreferenced for ${ageDays} days and no release evidence depends on it`,
});
}
}

for (const id of missingCandidates) {
warnings.push({
code: "UNKNOWN_CANDIDATE",
message: `Prune candidate ${id} is not present in the repository artifact inventory`,
});
}

const status = blockedPrunes.length === 0 && missingCandidates.length === 0 ? "pass" : "blocked";

return {
status,
checkedAt: now,
policy: {
minimumUnreferencedAgeDays: retentionDays,
},
summary: {
candidates: candidateIds.size,
approved: approvedPrunes.length,
blocked: blockedPrunes.length,
warnings: warnings.length,
},
approvedPrunes,
blockedPrunes,
warnings,
};
}

function referenceCode(sourceType) {
switch (sourceType) {
case "citation":
return "CITATION_EVIDENCE_BOUND";
case "doi":
return "DOI_VERSION_BOUND";
case "export":
return "EXPORT_MANIFEST_BOUND";
case "reproducibility":
return "REPRODUCIBILITY_PACKET_BOUND";
default:
return "REPOSITORY_REFERENCE_BOUND";
}
}

module.exports = {
analyzeArtifactPrunePlan,
daysBetween,
};
60 changes: 60 additions & 0 deletions repository-artifact-prune-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use strict";

const fs = require("fs");
const path = require("path");
const { analyzeArtifactPrunePlan } = require("./artifactPruneGuard");
const { sampleRepository } = require("./sampleRepository");

const report = analyzeArtifactPrunePlan(sampleRepository);
const reportsDir = path.join(__dirname, "reports");

fs.mkdirSync(reportsDir, { recursive: true });
fs.writeFileSync(
path.join(reportsDir, "artifact-prune-packet.json"),
`${JSON.stringify(report, null, 2)}\n`,
);
fs.writeFileSync(path.join(reportsDir, "artifact-prune-report.md"), renderMarkdown(report));
fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvg(report));

console.log(`Artifact prune guard status: ${report.status}`);
console.log(`Approved: ${report.summary.approved}; blocked: ${report.summary.blocked}`);

function renderMarkdown(packet) {
const lines = [
"# Artifact Prune Guard Report",
"",
`Status: ${packet.status}`,
`Checked at: ${packet.checkedAt}`,
"",
"## Approved Prunes",
"",
...packet.approvedPrunes.map((item) => `- ${item.artifactId}: ${item.reason}`),
"",
"## Blocked Prunes",
"",
];

for (const item of packet.blockedPrunes) {
lines.push(`- ${item.artifactId} (${item.path})`);
for (const blocker of item.blockers) {
lines.push(` - ${blocker.code}: ${blocker.message}`);
}
}

return `${lines.join("\n")}\n`;
}

function renderSvg(packet) {
const approvedWidth = 70 + packet.summary.approved * 70;
const blockedWidth = 70 + packet.summary.blocked * 70;
return `<svg xmlns="http://www.w3.org/2000/svg" width="520" height="160" role="img" aria-label="Artifact prune guard summary">
<rect width="520" height="160" fill="#f8fafc"/>
<text x="24" y="36" font-family="Arial" font-size="20" fill="#0f172a">Artifact Prune Guard</text>
<text x="24" y="64" font-family="Arial" font-size="13" fill="#475569">Status: ${packet.status}</text>
<rect x="24" y="88" width="${approvedWidth}" height="28" fill="#16a34a"/>
<text x="34" y="107" font-family="Arial" font-size="13" fill="#ffffff">Approved ${packet.summary.approved}</text>
<rect x="24" y="120" width="${blockedWidth}" height="28" fill="#dc2626"/>
<text x="34" y="139" font-family="Arial" font-size="13" fill="#ffffff">Blocked ${packet.summary.blocked}</text>
</svg>
`;
}
77 changes: 77 additions & 0 deletions repository-artifact-prune-guard/reports/artifact-prune-packet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"status": "blocked",
"checkedAt": "2026-05-31T04:45:00.000Z",
"policy": {
"minimumUnreferencedAgeDays": 30
},
"summary": {
"candidates": 5,
"approved": 1,
"blocked": 4,
"warnings": 0
},
"approvedPrunes": [
{
"artifactId": "orphan-scratch-plot",
"path": "results/tmp/orphan-scratch-plot.png",
"reason": "unreferenced for 118 days and no release evidence depends on it"
}
],
"blockedPrunes": [
{
"artifactId": "fig-survival-curve",
"path": "results/figures/survival-curve.svg",
"blockers": [
{
"code": "CITATION_EVIDENCE_BOUND",
"message": "fig-survival-curve (results/figures/survival-curve.svg) is still required by manuscript/citations.json#figure-2",
"source": "manuscript/citations.json#figure-2"
},
{
"code": "DOI_VERSION_BOUND",
"message": "fig-survival-curve (results/figures/survival-curve.svg) is still required by metadata.json#doi:10.5555/scibase.demo.v1",
"source": "metadata.json#doi:10.5555/scibase.demo.v1"
}
]
},
{
"artifactId": "raw-screening-table",
"path": "data/raw/screening-table.csv",
"blockers": [
{
"code": "EXPORT_MANIFEST_BOUND",
"message": "raw-screening-table (data/raw/screening-table.csv) is still required by exports/release-v1/manifest.json",
"source": "exports/release-v1/manifest.json"
}
]
},
{
"artifactId": "notebook-run-packet",
"path": "notebooks/run-2026-05-01/replay-packet.json",
"blockers": [
{
"code": "REPRODUCIBILITY_PACKET_BOUND",
"message": "notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is still required by results/reproducibility/run-2026-05-01.json",
"source": "results/reproducibility/run-2026-05-01.json"
},
{
"code": "MINIMUM_AGE_NOT_MET",
"message": "notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is 29 days old; policy requires 30",
"source": "repository policy"
}
]
},
{
"artifactId": "protocol-appendix",
"path": "protocols/appendix-a.md",
"blockers": [
{
"code": "RETENTION_HOLD",
"message": "protocol-appendix (protocols/appendix-a.md) has an active retention hold: IRB audit window closes 2026-12-31",
"source": "repository policy"
}
]
}
],
"warnings": []
}
21 changes: 21 additions & 0 deletions repository-artifact-prune-guard/reports/artifact-prune-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Artifact Prune Guard Report

Status: blocked
Checked at: 2026-05-31T04:45:00.000Z

## Approved Prunes

- orphan-scratch-plot: unreferenced for 118 days and no release evidence depends on it

## Blocked Prunes

- fig-survival-curve (results/figures/survival-curve.svg)
- CITATION_EVIDENCE_BOUND: fig-survival-curve (results/figures/survival-curve.svg) is still required by manuscript/citations.json#figure-2
- DOI_VERSION_BOUND: fig-survival-curve (results/figures/survival-curve.svg) is still required by metadata.json#doi:10.5555/scibase.demo.v1
- raw-screening-table (data/raw/screening-table.csv)
- EXPORT_MANIFEST_BOUND: raw-screening-table (data/raw/screening-table.csv) is still required by exports/release-v1/manifest.json
- notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json)
- REPRODUCIBILITY_PACKET_BOUND: notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is still required by results/reproducibility/run-2026-05-01.json
- MINIMUM_AGE_NOT_MET: notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is 29 days old; policy requires 30
- protocol-appendix (protocols/appendix-a.md)
- RETENTION_HOLD: protocol-appendix (protocols/appendix-a.md) has an active retention hold: IRB audit window closes 2026-12-31
17 changes: 17 additions & 0 deletions repository-artifact-prune-guard/reports/demo.ass
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[Script Info]
Title: Artifact Prune Guard Demo
ScriptType: v4.00+
PlayResX: 1280
PlayResY: 720

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Title,Arial,54,&H00FFFFFF,&H000000FF,&H00202830,&H99000000,1,0,0,0,100,100,0,0,1,3,1,8,60,60,60,1
Style: Body,Arial,36,&H00F8FAFC,&H000000FF,&H00202830,&H99000000,0,0,0,0,100,100,0,0,1,2,1,5,90,90,80,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:02.20,Title,,0,0,0,,Repository Artifact Prune Guard
Dialogue: 0,0:00:02.20,0:00:04.80,Body,,0,0,0,,Checks prune candidates before scientific release or export.
Dialogue: 0,0:00:04.80,0:00:07.60,Body,,0,0,0,,Blocks artifacts still bound to citations, DOI manifests, exports, or reproducibility packets.
Dialogue: 0,0:00:07.60,0:00:10.00,Body,,0,0,0,,Demo result: 1 approved prune, 4 blocked prunes, deterministic reviewer packet generated.
Binary file not shown.
9 changes: 9 additions & 0 deletions repository-artifact-prune-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading