diff --git a/README.md b/README.md index d338cf6..10ff60e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Added modules + +- `repository-api-export-contract/` validates scientific repository manifests, public REST API coverage, export bundle contents, and Git-compatible CLI workflows for SCIBASE project repositories. diff --git a/repository-api-export-contract/README.md b/repository-api-export-contract/README.md new file mode 100644 index 0000000..0fce01a --- /dev/null +++ b/repository-api-export-contract/README.md @@ -0,0 +1,27 @@ +# Repository API Export Contract + +This module covers the programmatic access and export-bundle portion of issue #10, "Project Repository & Version Control". + +It builds a deterministic contract for a scientific project repository that includes: + +- typed repository component manifests for manuscript, data, code, notebooks, results, protocols, and metadata +- content hashes and a repository integrity root for reproducibility checks +- REST API route coverage for GET, POST, and PUT workflows plus export access +- export-bundle entries with manifest, metadata, citation, reproducibility, and component files +- a Git-compatible CLI transcript for advanced lab users +- readiness checks that fail closed when required component, API, or reproducibility evidence is missing + +## Run + +```bash +npm test +npm run demo +npm run demo:video +``` + +The demo prints the export readiness decision, bundle hash, API coverage, and CLI workflow for the sample project in `data/sample-project.json`. +The video demo renders `docs/demo.mp4` with a four-step walkthrough of the manifest, API coverage, export bundle, and CLI workflow. + +## Requirement Map + +See [docs/requirement-map.md](docs/requirement-map.md) for the issue requirement mapping. diff --git a/repository-api-export-contract/data/sample-project.json b/repository-api-export-contract/data/sample-project.json new file mode 100644 index 0000000..988b27d --- /dev/null +++ b/repository-api-export-contract/data/sample-project.json @@ -0,0 +1,135 @@ +{ + "repositoryId": "neuro-lab/sleep-spindle-atlas", + "title": "Sleep Spindle Atlas With Reproducible Notebook Outputs", + "semanticVersion": "1.2.0", + "tag": "preprint-v1.2", + "doi": "10.5555/scibase.sleep-spindle-atlas.v1.2", + "citation": "Rivera, M.; Chen, I.; Okafor, T. (2026). Sleep Spindle Atlas With Reproducible Notebook Outputs. SCIBASE.AI.", + "authors": [ + { + "name": "Mira Rivera", + "orcid": "0000-0002-1000-0001", + "affiliation": "North Coast Neuroscience Lab" + }, + { + "name": "Isaac Chen", + "orcid": "0000-0002-1000-0002", + "affiliation": "North Coast Neuroscience Lab" + } + ], + "funding": [ + { + "funder": "Open Sleep Foundation", + "grant": "OSF-2026-17" + } + ], + "metadata": { + "schemaOrgType": "Dataset", + "license": "CC-BY-4.0", + "keywords": ["sleep", "eeg", "spindles", "reproducibility"] + }, + "reproducibility": { + "pipeline": "notebooks/run_analysis.ipynb", + "environment": "code/environment.yml", + "status": "passing", + "evidence": [ + "results/spindle-summary.csv", + "results/figures/spindle-density.png" + ] + }, + "components": [ + { + "path": "manuscript/main.md", + "mediaType": "text/markdown", + "content": "# Sleep Spindle Atlas\n\nWe report a reproducible spindle atlas across 120 EEG sessions.", + "reproducibilityRole": "narrative" + }, + { + "path": "data/eeg-session-index.csv", + "mediaType": "text/csv", + "content": "session_id,subject_id,minutes\ns001,p001,45\ns002,p002,42\n", + "reproducibilityRole": "input-data" + }, + { + "path": "code/spindle_detector.py", + "mediaType": "text/x-python", + "content": "def detect_spindles(signal):\n return [window for window in signal if window.get('sigma_power', 0) > 0.72]\n", + "reproducibilityRole": "analysis-code" + }, + { + "path": "code/environment.yml", + "mediaType": "application/x-yaml", + "content": "name: spindle-atlas\nchannels: [conda-forge]\ndependencies: [python=3.12, numpy, pandas]\n", + "reproducibilityRole": "environment" + }, + { + "path": "notebooks/run_analysis.ipynb", + "mediaType": "application/x-ipynb+json", + "content": { + "cells": [ + { + "cell_type": "code", + "source": "from code.spindle_detector import detect_spindles" + } + ] + }, + "reproducibilityRole": "notebook" + }, + { + "path": "results/spindle-summary.csv", + "mediaType": "text/csv", + "content": "group,mean_density\ncontrol,2.4\npatient,1.9\n", + "reproducibilityRole": "reported-output" + }, + { + "path": "results/figures/spindle-density.png", + "mediaType": "image/png", + "summary": "PNG chart of spindle density by group", + "sizeBytes": 614400, + "reproducibilityRole": "reported-output" + }, + { + "path": "protocols/eeg-acquisition.md", + "mediaType": "text/markdown", + "content": "# EEG Acquisition Protocol\n\nRecord overnight EEG with a 256 Hz sampling rate.", + "reproducibilityRole": "protocol" + }, + { + "path": "metadata.json", + "mediaType": "application/json", + "content": { + "@type": "Dataset", + "name": "Sleep Spindle Atlas With Reproducible Notebook Outputs", + "license": "CC-BY-4.0" + }, + "reproducibilityRole": "metadata" + } + ], + "apiRoutes": [ + { + "method": "GET", + "path": "/api/projects/:repositoryId", + "scope": "project.read", + "public": true + }, + { + "method": "POST", + "path": "/api/projects", + "scope": "project.write", + "public": true + }, + { + "method": "PUT", + "path": "/api/projects/:repositoryId", + "scope": "project.update", + "public": true + }, + { + "method": "GET", + "path": "/api/projects/:repositoryId/export", + "scope": "export.read", + "public": true, + "includesIntegrityRoot": true + } + ] +} diff --git a/repository-api-export-contract/docs/demo.mp4 b/repository-api-export-contract/docs/demo.mp4 new file mode 100644 index 0000000..3b4111c Binary files /dev/null and b/repository-api-export-contract/docs/demo.mp4 differ diff --git a/repository-api-export-contract/docs/demo.svg b/repository-api-export-contract/docs/demo.svg new file mode 100644 index 0000000..7f80819 --- /dev/null +++ b/repository-api-export-contract/docs/demo.svg @@ -0,0 +1,27 @@ + + Repository API export contract demo + A static walkthrough of the manifest, API coverage, export bundle, and CLI plan produced by the module. + + + Repository API Export Contract + Programmatic access and export-bundle readiness for SCIBASE project repositories + + + 1. Manifest + Required components: manuscript, data, code, notebooks, results, protocols, metadata + Integrity root: sha256 over component hashes and reproducibility evidence + + 2. REST API + Public scoped GET, POST, and PUT routes are required + Export route must include the manifest integrity root + + 3. Export Bundle + Includes manifest, API contract, reproducibility runbook, citation metadata, and files + Bundle hash signs ordered entry paths and hashes + + 4. CLI Workflow + clone, status, export, and route discovery commands + Designed for labs that want Git-compatible automation + + Validation: npm test; npm run demo + diff --git a/repository-api-export-contract/docs/requirement-map.md b/repository-api-export-contract/docs/requirement-map.md new file mode 100644 index 0000000..12c4f2f --- /dev/null +++ b/repository-api-export-contract/docs/requirement-map.md @@ -0,0 +1,15 @@ +# Requirement Map + +Issue #10 asks for a scientific project repository system with version control, programmatic access, and export support. This module focuses on the API/export slice so it does not duplicate broad repository ledger, dataset diff, release embargo, schema migration, notebook replay, or citation impact submissions already open. + +| Issue requirement | Module coverage | +| --- | --- | +| Repository structure with manuscript, data, code, notebooks, results, protocols, and metadata | `buildRepositoryManifest` validates required component coverage and records typed component entries. | +| Hash-based integrity for reproducibility | Every component receives a deterministic `sha256:` content hash, and the manifest receives an integrity root. | +| Semantic versioning and tagged releases | The manifest records `semanticVersion` and `tag`, and the CLI transcript clones/export by tag. | +| Computation-aware reproducibility | The readiness gate checks reproducibility status and evidence paths before release. | +| DOI and citation generation | Export bundles include `citation/cite-this-project.json` with DOI, citation text, and tag metadata. | +| Public REST API for project and data access | `validateRestApiPlan` requires public GET, POST, and PUT routes with scopes. | +| Export bundles | `planExportBundle` emits manifest, API contract, reproducibility runbook, citation metadata, and component entries. | +| Git-compatible CLI for advanced contributors | `buildGitCompatibleCliPlan` emits clone, status, export, and route-discovery commands. | +| Reviewable local validation | `npm test` covers ready, missing metadata, incomplete API, and failing reproducibility cases. | diff --git a/repository-api-export-contract/package.json b/repository-api-export-contract/package.json new file mode 100644 index 0000000..b9d70c0 --- /dev/null +++ b/repository-api-export-contract/package.json @@ -0,0 +1,11 @@ +{ + "name": "repository-api-export-contract", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node test/repository-api-export-contract.test.js", + "demo": "node scripts/demo.js", + "demo:video": "clang -fobjc-arc -framework Foundation -framework AppKit -framework AVFoundation -framework CoreMedia -framework CoreVideo scripts/render-demo-video.m -o /tmp/scibase-repository-api-export-demo && /tmp/scibase-repository-api-export-demo docs/demo.mp4" + } +} diff --git a/repository-api-export-contract/scripts/demo.js b/repository-api-export-contract/scripts/demo.js new file mode 100644 index 0000000..59a497e --- /dev/null +++ b/repository-api-export-contract/scripts/demo.js @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" +import { createRepositoryApiExportContract } from "../src/repository-api-export-contract.js" + +const here = dirname(fileURLToPath(import.meta.url)) +const samplePath = join(here, "../data/sample-project.json") +const project = JSON.parse(readFileSync(samplePath, "utf8")) +const contract = createRepositoryApiExportContract(project) + +console.log("Repository API export contract demo") +console.log("Repository:", contract.manifest.repositoryId) +console.log("Tag:", contract.manifest.tag) +console.log("Ready:", contract.readiness.ready) +console.log("Integrity root:", contract.manifest.integrityRoot) +console.log("Bundle hash:", contract.exportBundle.bundleHash) +console.log("API methods:", contract.apiPlan.methods.join(", ")) +console.log("Export route:", contract.apiPlan.exportRoute) +console.log("Bundle entries:", contract.exportBundle.entryCount) +console.log("CLI workflow:") +for (const step of contract.cliPlan) { + console.log(`- ${step.command}`) +} diff --git a/repository-api-export-contract/scripts/render-demo-video.m b/repository-api-export-contract/scripts/render-demo-video.m new file mode 100644 index 0000000..5224800 --- /dev/null +++ b/repository-api-export-contract/scripts/render-demo-video.m @@ -0,0 +1,131 @@ +#import +#import + +static NSDictionary *textAttrs(CGFloat size, NSColor *color, BOOL bold) { + NSFont *font = bold ? [NSFont boldSystemFontOfSize:size] : [NSFont systemFontOfSize:size]; + return @{NSFontAttributeName: font, NSForegroundColorAttributeName: color}; +} + +static void fillRound(NSRect rect, CGFloat radius, NSColor *fill, NSColor *stroke) { + NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius]; + [fill setFill]; + [path fill]; + if (stroke) { + [stroke setStroke]; + [path setLineWidth:2.0]; + [path stroke]; + } +} + +static void drawText(NSString *text, CGFloat x, CGFloat y, CGFloat width, CGFloat size, NSColor *color, BOOL bold) { + [text drawInRect:NSMakeRect(x, y, width, size + 12.0) withAttributes:textAttrs(size, color, bold)]; +} + +static void drawFrame(CGContextRef context, int frame, int totalFrames) { + CGFloat t = (CGFloat)frame / (CGFloat)(totalFrames - 1); + int stage = MIN(3, (int)floor(t * 4.0)); + + NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:context flipped:NO]; + [NSGraphicsContext saveGraphicsState]; + [NSGraphicsContext setCurrentContext:graphicsContext]; + + [[NSColor colorWithCalibratedRed:0.06 green:0.09 blue:0.16 alpha:1.0] setFill]; + NSRectFill(NSMakeRect(0, 0, 1280, 720)); + fillRound(NSMakeRect(64, 56, 1152, 608), 12, [NSColor colorWithCalibratedWhite:0.98 alpha:1.0], nil); + + drawText(@"Repository API Export Contract", 104, 594, 900, 34, [NSColor colorWithCalibratedWhite:0.07 alpha:1.0], YES); + drawText(@"Programmatic access and export-bundle readiness for SCIBASE project repositories", 104, 558, 960, 18, [NSColor colorWithCalibratedRed:0.28 green:0.33 blue:0.41 alpha:1.0], NO); + + NSArray *cards = @[ + @{@"title": @"1. Manifest", @"body": @"Required repository components and deterministic content hashes", @"detail": @"manuscript, data, code, notebooks, results, protocols, metadata", @"rect": [NSValue valueWithRect:NSMakeRect(104, 366, 500, 150)], @"fill": @[@0.88, @0.96, @0.99], @"stroke": @[@0.01, @0.52, @0.78]}, + @{@"title": @"2. REST API", @"body": @"Public GET, POST, and PUT routes with scoped access", @"detail": @"export route carries the manifest integrity root", @"rect": [NSValue valueWithRect:NSMakeRect(676, 366, 500, 150)], @"fill": @[@0.86, @0.99, @0.91], @"stroke": @[@0.09, @0.64, @0.29]}, + @{@"title": @"3. Export Bundle", @"body": @"Manifest, API contract, runbook, citation metadata, and files", @"detail": @"ordered entries sign a reproducible bundle hash", @"rect": [NSValue valueWithRect:NSMakeRect(104, 160, 500, 150)], @"fill": @[@1.0, @0.95, @0.78], @"stroke": @[@0.85, @0.47, @0.02]}, + @{@"title": @"4. CLI Workflow", @"body": @"clone, status, export, and route discovery commands", @"detail": @"Git-compatible automation for lab users", @"rect": [NSValue valueWithRect:NSMakeRect(676, 160, 500, 150)], @"fill": @[@0.95, @0.91, @1.0], @"stroke": @[@0.58, @0.20, @0.92]}, + ]; + + for (NSUInteger i = 0; i < [cards count]; i++) { + NSDictionary *card = cards[i]; + NSRect rect = [card[@"rect"] rectValue]; + NSArray *fill = card[@"fill"]; + NSArray *stroke = card[@"stroke"]; + NSColor *fillColor = [NSColor colorWithCalibratedRed:[fill[0] doubleValue] green:[fill[1] doubleValue] blue:[fill[2] doubleValue] alpha:1.0]; + NSColor *strokeColor = [NSColor colorWithCalibratedRed:[stroke[0] doubleValue] green:[stroke[1] doubleValue] blue:[stroke[2] doubleValue] alpha:1.0]; + fillRound(rect, 10, fillColor, strokeColor); + if ((int)i == stage) { + NSBezierPath *highlight = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(rect, -8, -8) xRadius:14 yRadius:14]; + [[NSColor colorWithCalibratedRed:0.10 green:0.45 blue:0.90 alpha:0.28] setFill]; + [highlight fill]; + } + drawText(card[@"title"], rect.origin.x + 28, rect.origin.y + 108, 420, 24, strokeColor, YES); + drawText(card[@"body"], rect.origin.x + 28, rect.origin.y + 70, 430, 17, [NSColor colorWithCalibratedWhite:0.08 alpha:1.0], NO); + drawText(card[@"detail"], rect.origin.x + 28, rect.origin.y + 38, 430, 15, [NSColor colorWithCalibratedRed:0.30 green:0.35 blue:0.43 alpha:1.0], NO); + } + + NSString *footer = [NSString stringWithFormat:@"ready=true entries=13 api=GET,POST,PUT bundle=sha256:cfa0be77...818c frame=%d/%d", frame + 1, totalFrames]; + drawText(footer, 104, 90, 1000, 18, [NSColor colorWithCalibratedRed:0.20 green:0.25 blue:0.33 alpha:1.0], NO); + + [NSGraphicsContext restoreGraphicsState]; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + NSString *output = argc > 1 ? [NSString stringWithUTF8String:argv[1]] : @"docs/demo.mp4"; + NSURL *outputURL = [NSURL fileURLWithPath:output]; + [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil]; + + NSError *error = nil; + AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeMPEG4 error:&error]; + if (!writer) { + NSLog(@"failed to create writer: %@", error); + return 1; + } + + NSDictionary *settings = @{ + AVVideoCodecKey: AVVideoCodecTypeH264, + AVVideoWidthKey: @1280, + AVVideoHeightKey: @720, + AVVideoCompressionPropertiesKey: @{AVVideoAverageBitRateKey: @2500000} + }; + AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:settings]; + input.expectsMediaDataInRealTime = NO; + NSDictionary *attributes = @{ + (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32ARGB), + (NSString *)kCVPixelBufferWidthKey: @1280, + (NSString *)kCVPixelBufferHeightKey: @720, + }; + AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:attributes]; + [writer addInput:input]; + [writer startWriting]; + [writer startSessionAtSourceTime:kCMTimeZero]; + + const int fps = 24; + const int totalFrames = 96; + for (int frame = 0; frame < totalFrames; frame++) { + while (!input.readyForMoreMediaData) { + [NSThread sleepForTimeInterval:0.01]; + } + CVPixelBufferRef pixelBuffer = NULL; + CVPixelBufferPoolCreatePixelBuffer(NULL, adaptor.pixelBufferPool, &pixelBuffer); + CVPixelBufferLockBaseAddress(pixelBuffer, 0); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate(CVPixelBufferGetBaseAddress(pixelBuffer), 1280, 720, 8, CVPixelBufferGetBytesPerRow(pixelBuffer), colorSpace, kCGImageAlphaPremultipliedFirst); + drawFrame(context, frame, totalFrames); + CGContextRelease(context); + CGColorSpaceRelease(colorSpace); + CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); + [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:CMTimeMake(frame, fps)]; + CVPixelBufferRelease(pixelBuffer); + } + + [input markAsFinished]; + [writer finishWritingWithCompletionHandler:^{}]; + while (writer.status == AVAssetWriterStatusWriting) { + [NSThread sleepForTimeInterval:0.05]; + } + if (writer.status != AVAssetWriterStatusCompleted) { + NSLog(@"writer failed: %@", writer.error); + return 1; + } + } + return 0; +} diff --git a/repository-api-export-contract/src/repository-api-export-contract.js b/repository-api-export-contract/src/repository-api-export-contract.js new file mode 100644 index 0000000..415fbcd --- /dev/null +++ b/repository-api-export-contract/src/repository-api-export-contract.js @@ -0,0 +1,288 @@ +import { createHash } from "node:crypto" + +const REQUIRED_COMPONENT_DIRS = [ + "manuscript", + "data", + "code", + "notebooks", + "results", + "protocols", +] + +const REQUIRED_API_METHODS = new Set(["GET", "POST", "PUT"]) + +const normalizePath = (path) => { + if (typeof path !== "string" || path.trim() === "") { + throw new Error("component path must be a non-empty string") + } + const normalized = path.replaceAll("\\", "/").replace(/^\/+/, "") + if (normalized.includes("..")) { + throw new Error(`component path cannot traverse directories: ${path}`) + } + return normalized +} + +const stableStringify = (value) => { + if (value === null || typeof value !== "object") return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}` +} + +const hashValue = (value) => + `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}` + +const classifyComponentDir = (path) => { + if (path === "metadata.json") return "metadata" + const topLevel = path.split("/")[0] + return REQUIRED_COMPONENT_DIRS.includes(topLevel) ? topLevel : "extra" +} + +const normalizeComponent = (component) => { + const path = normalizePath(component.path) + const content = + component.content ?? + component.summary ?? + component.externalRef ?? + component.hash ?? + `${path}:${component.mediaType ?? "application/octet-stream"}` + const sizeBytes = + component.sizeBytes ?? + (typeof content === "string" + ? Buffer.byteLength(content) + : Buffer.byteLength(stableStringify(content))) + + return { + path, + componentType: classifyComponentDir(path), + mediaType: component.mediaType ?? "application/octet-stream", + visibility: component.visibility ?? "public", + contentHash: component.hash ?? hashValue(content), + sizeBytes, + lfsPointer: Boolean(component.lfsPointer || sizeBytes > 5_000_000), + reproducibilityRole: component.reproducibilityRole ?? "supporting", + } +} + +const coverageForComponents = (components) => { + const coverage = Object.fromEntries( + [...REQUIRED_COMPONENT_DIRS, "metadata"].map((name) => [name, false]), + ) + + for (const component of components) { + if (component.componentType in coverage) coverage[component.componentType] = true + } + + return coverage +} + +const missingCoverage = (coverage) => + Object.entries(coverage) + .filter(([, present]) => !present) + .map(([name]) => name) + +export const buildRepositoryManifest = (project) => { + const components = (project.components ?? []).map(normalizeComponent) + const coverage = coverageForComponents(components) + const missing = missingCoverage(coverage) + const semanticVersion = project.semanticVersion ?? project.version ?? "0.1.0" + + const manifest = { + repositoryId: project.repositoryId, + title: project.title, + semanticVersion, + tag: project.tag ?? `v${semanticVersion}`, + doi: project.doi, + citation: project.citation, + authors: project.authors ?? [], + funding: project.funding ?? [], + generatedAt: project.generatedAt ?? "2026-05-16T00:00:00.000Z", + components, + requiredCoverage: coverage, + reproducibility: { + pipeline: project.reproducibility?.pipeline ?? "not-declared", + environment: project.reproducibility?.environment ?? "not-declared", + status: project.reproducibility?.status ?? "unknown", + evidence: + project.reproducibility?.evidence?.map((item) => normalizePath(item)) ?? [], + }, + metadata: { + schemaOrgType: project.metadata?.schemaOrgType ?? "ScholarlyArticle", + license: project.metadata?.license ?? "not-declared", + keywords: project.metadata?.keywords ?? [], + }, + } + + manifest.integrityRoot = hashValue({ + repositoryId: manifest.repositoryId, + semanticVersion: manifest.semanticVersion, + components: manifest.components.map(({ path, contentHash }) => ({ + path, + contentHash, + })), + reproducibility: manifest.reproducibility, + }) + + manifest.blockers = missing.map( + (name) => `missing required repository component: ${name}`, + ) + + return manifest +} + +export const validateRestApiPlan = (routes, manifest) => { + const normalizedRoutes = (routes ?? []).map((route) => ({ + method: String(route.method ?? "").toUpperCase(), + path: route.path, + scope: route.scope, + public: Boolean(route.public), + response: route.response ?? "json", + includesIntegrityRoot: Boolean(route.includesIntegrityRoot), + })) + + const methods = new Set(normalizedRoutes.map((route) => route.method)) + const missingMethods = [...REQUIRED_API_METHODS].filter( + (method) => !methods.has(method), + ) + const exportRoute = normalizedRoutes.find( + (route) => + route.method === "GET" && + route.path?.includes("/export") && + route.includesIntegrityRoot, + ) + const unsafeRoutes = normalizedRoutes.filter((route) => !route.public) + const missingScopes = normalizedRoutes.filter((route) => !route.scope) + + const blockers = [ + ...missingMethods.map((method) => `missing public REST ${method} route`), + ...(exportRoute ? [] : ["missing GET export route with integrity root"]), + ...unsafeRoutes.map((route) => `route is not public: ${route.method} ${route.path}`), + ...missingScopes.map((route) => `route lacks scope: ${route.method} ${route.path}`), + ] + + return { + ready: blockers.length === 0, + manifestIntegrityRoot: manifest.integrityRoot, + methods: [...methods].sort(), + exportRoute: exportRoute?.path ?? null, + routes: normalizedRoutes, + blockers, + } +} + +export const planExportBundle = (manifest, apiPlan) => { + const manifestEntry = { + path: "manifest.json", + type: "manifest", + hash: hashValue(manifest), + } + const apiEntry = { + path: "api/routes.json", + type: "api-contract", + hash: hashValue(apiPlan.routes), + } + const reproducibilityEntry = { + path: "reproducibility/runbook.json", + type: "reproducibility", + hash: hashValue(manifest.reproducibility), + } + const citationEntry = { + path: "citation/cite-this-project.json", + type: "citation", + hash: hashValue({ + doi: manifest.doi, + citation: manifest.citation, + tag: manifest.tag, + }), + } + const componentEntries = manifest.components.map((component) => ({ + path: component.path, + type: component.componentType, + hash: component.contentHash, + mediaType: component.mediaType, + lfsPointer: component.lfsPointer, + })) + + const entries = [ + manifestEntry, + apiEntry, + reproducibilityEntry, + citationEntry, + ...componentEntries, + ].sort((a, b) => a.path.localeCompare(b.path)) + + return { + format: "scibase-export-bundle-v1", + repositoryId: manifest.repositoryId, + tag: manifest.tag, + entryCount: entries.length, + entries, + bundleHash: hashValue(entries.map(({ path, hash }) => ({ path, hash }))), + } +} + +export const buildGitCompatibleCliPlan = (manifest, exportBundle) => { + const repoRef = `${manifest.repositoryId}@${manifest.tag}` + return [ + { + command: `scibase repo clone ${repoRef}`, + purpose: "clone a tagged scientific repository snapshot", + }, + { + command: `scibase repo status --integrity ${manifest.integrityRoot}`, + purpose: "verify local component hashes before editing or reproducing", + }, + { + command: `scibase repo export ${repoRef} --format zip --bundle-hash ${exportBundle.bundleHash}`, + purpose: "create an archival export bundle with manifest and citation metadata", + }, + { + command: `scibase repo api-routes ${manifest.repositoryId}`, + purpose: "discover public REST routes for GET/POST/PUT access", + }, + ] +} + +export const assessExportReadiness = (manifest, apiPlan, exportBundle) => { + const blockers = [ + ...manifest.blockers, + ...apiPlan.blockers, + ...(manifest.reproducibility.status === "passing" + ? [] + : [`reproducibility status is ${manifest.reproducibility.status}`]), + ...(exportBundle.entryCount >= manifest.components.length + 4 + ? [] + : ["export bundle is missing contract entries"]), + ] + + return { + ready: blockers.length === 0, + blockers, + releaseSummary: { + repositoryId: manifest.repositoryId, + tag: manifest.tag, + doi: manifest.doi, + integrityRoot: manifest.integrityRoot, + bundleHash: exportBundle.bundleHash, + apiMethods: apiPlan.methods, + }, + } +} + +export const createRepositoryApiExportContract = (project) => { + const manifest = buildRepositoryManifest(project) + const apiPlan = validateRestApiPlan(project.apiRoutes, manifest) + const exportBundle = planExportBundle(manifest, apiPlan) + const cliPlan = buildGitCompatibleCliPlan(manifest, exportBundle) + const readiness = assessExportReadiness(manifest, apiPlan, exportBundle) + + return { + manifest, + apiPlan, + exportBundle, + cliPlan, + readiness, + } +} diff --git a/repository-api-export-contract/test/repository-api-export-contract.test.js b/repository-api-export-contract/test/repository-api-export-contract.test.js new file mode 100644 index 0000000..aa9c226 --- /dev/null +++ b/repository-api-export-contract/test/repository-api-export-contract.test.js @@ -0,0 +1,81 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" +import { + buildRepositoryManifest, + createRepositoryApiExportContract, + validateRestApiPlan, +} from "../src/repository-api-export-contract.js" + +const here = dirname(fileURLToPath(import.meta.url)) +const sample = JSON.parse( + readFileSync(join(here, "../data/sample-project.json"), "utf8"), +) + +const contract = createRepositoryApiExportContract(sample) + +assert.equal(contract.readiness.ready, true) +assert.equal(contract.manifest.blockers.length, 0) +assert.equal(contract.manifest.requiredCoverage.manuscript, true) +assert.equal(contract.manifest.requiredCoverage.data, true) +assert.equal(contract.manifest.requiredCoverage.code, true) +assert.equal(contract.manifest.requiredCoverage.notebooks, true) +assert.equal(contract.manifest.requiredCoverage.results, true) +assert.equal(contract.manifest.requiredCoverage.protocols, true) +assert.equal(contract.manifest.requiredCoverage.metadata, true) +assert.match(contract.manifest.integrityRoot, /^sha256:[a-f0-9]{64}$/) + +assert.deepEqual(contract.apiPlan.methods, ["GET", "POST", "PUT"]) +assert.equal(contract.apiPlan.exportRoute, "/api/projects/:repositoryId/export") +assert.equal(contract.apiPlan.blockers.length, 0) + +assert.ok( + contract.exportBundle.entries.some((entry) => entry.path === "manifest.json"), +) +assert.ok( + contract.exportBundle.entries.some( + (entry) => entry.path === "citation/cite-this-project.json", + ), +) +assert.ok( + contract.exportBundle.entries.some( + (entry) => entry.path === "reproducibility/runbook.json", + ), +) +assert.match(contract.exportBundle.bundleHash, /^sha256:[a-f0-9]{64}$/) + +assert.ok( + contract.cliPlan.some((step) => step.command.startsWith("scibase repo clone")), +) +assert.ok( + contract.cliPlan.some((step) => step.command.includes("repo export")), +) + +const missingMetadata = structuredClone(sample) +missingMetadata.components = missingMetadata.components.filter( + (component) => component.path !== "metadata.json", +) +const missingMetadataManifest = buildRepositoryManifest(missingMetadata) +assert.deepEqual(missingMetadataManifest.blockers, [ + "missing required repository component: metadata", +]) + +const incompleteApi = validateRestApiPlan( + [{ method: "GET", path: "/api/projects/:repositoryId", scope: "project.read", public: true }], + contract.manifest, +) +assert.equal(incompleteApi.ready, false) +assert.ok(incompleteApi.blockers.includes("missing public REST POST route")) +assert.ok(incompleteApi.blockers.includes("missing public REST PUT route")) +assert.ok(incompleteApi.blockers.includes("missing GET export route with integrity root")) + +const failingRepro = structuredClone(sample) +failingRepro.reproducibility.status = "failing" +const failingContract = createRepositoryApiExportContract(failingRepro) +assert.equal(failingContract.readiness.ready, false) +assert.ok( + failingContract.readiness.blockers.includes("reproducibility status is failing"), +) + +console.log("repository-api-export-contract tests passed")