@volter/twin-catalog 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/browse.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // Read-only discovery over a published index. Never resolve or import candidate packages.
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { digest, loadIndex, read, requireThat, selection, stable, submissionId, validateIndex } from './model.mjs';
5
+
6
+ const textOrder = (a, b) => a < b ? -1 : a > b ? 1 : 0;
7
+ const releaseKey = (v) => `${v.package}@${v.version}`;
8
+
9
+ function repositoryOf(pkg) {
10
+ const value = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
11
+ const match = /^git\+https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(value ?? '') ??
12
+ /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(value ?? '');
13
+ requireThat(match, 'catalog package must name its GitHub repository');
14
+ return `https://github.com/${match[1]}`;
15
+ }
16
+
17
+ function reportFor(root, version) {
18
+ const evidence = version.evidence;
19
+ if (!evidence?.reportSha256) return null;
20
+ requireThat(/^[a-f0-9]{64}$/.test(evidence.submission) && /^[a-f0-9]{64}$/.test(evidence.reportSha256), 'invalid report identity');
21
+ const path = `evidence/${evidence.submission}.json`;
22
+ requireThat(evidence.reportPath === path && existsSync(join(root, path)), `${releaseKey(version)}: bound report is missing`);
23
+ const envelope = read(join(root, path));
24
+ requireThat(digest(envelope) === evidence.reportSha256, `${releaseKey(version)}: report checksum differs`);
25
+ const input = envelope.input;
26
+ const submission = input?.submission;
27
+ requireThat(envelope.inputSha256 === digest(input) && submission && submissionId(submission) === evidence.submission,
28
+ `${releaseKey(version)}: report input identity differs`);
29
+ requireThat(submission.vendor === version.vendor && submission.package === version.package && submission.version === version.version &&
30
+ submission.source === version.source && submission.integrity === version.integrity, `${releaseKey(version)}: report release differs`);
31
+ const report = envelope.report;
32
+ requireThat(envelope.status === 'ready' && report?.ready === true && report.vendor === version.vendor &&
33
+ report.package === version.package && report.version === version.version, `${releaseKey(version)}: report is not release readiness`);
34
+ return { envelope, report };
35
+ }
36
+
37
+ /** Load and project an installed index. integrity is caller-retained install evidence, not computed here. */
38
+ export function browse(root, { vendor, package: packageName, integrity = null } = {}) {
39
+ const pkg = read(join(root, 'package.json'));
40
+ const catalog = read(join(root, 'catalog.json'));
41
+ const index = validateIndex(loadIndex(root));
42
+ requireThat(catalog.schemaVersion === 1 && Array.isArray(catalog.versions), 'unsupported catalog snapshot');
43
+ requireThat(/^[a-f0-9]{40}$/.test(catalog.sourceCommit) && /^[a-f0-9]{64}$/.test(catalog.digest), 'invalid snapshot identity');
44
+ requireThat(pkg.catalog?.sourceCommit === catalog.sourceCommit && pkg.catalog?.digest === catalog.digest, 'package/catalog identity differs');
45
+ requireThat(integrity === null || /^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity), 'invalid retained install integrity');
46
+ const records = new Map();
47
+ const references = {};
48
+ for (const v of catalog.versions) {
49
+ requireThat(!records.has(releaseKey(v)), 'repeated catalog release');
50
+ records.set(releaseKey(v), v);
51
+ if (v.evidence) {
52
+ const { submission, admissionCommit, ...receipt } = v.evidence;
53
+ requireThat(submission === (v.submission ?? v.assessment) && admissionCommit === (v.assessmentCommit ?? v.commit), 'evidence admission identity differs');
54
+ if (Object.keys(receipt).length) {
55
+ requireThat(!references[submission] || stable(references[submission]) === stable(receipt), 'conflicting evidence references');
56
+ references[submission] = receipt;
57
+ }
58
+ }
59
+ }
60
+ const expected = index.vendors.flatMap((e) => e.packages.flatMap((p) => p.versions.map((v) => {
61
+ const id = v.submission ?? v.assessment;
62
+ return { vendor: e.vendor, package: p.name, source: p.source, ...v,
63
+ evidence: id ? { submission: id, admissionCommit: v.assessmentCommit ?? v.commit, ...(references[id] ?? {}) } : null,
64
+ assessed: Boolean(id) };
65
+ })));
66
+ requireThat(expected.length === records.size && expected.every((v) => stable(v) === stable(records.get(releaseKey(v)))),
67
+ 'catalog records differ from index data');
68
+ const snapshotDigest = (vendors) => digest({ evidence: references, sources: index.sources, vendors,
69
+ recommendations: index.recommendations, revocations: index.revocations });
70
+ // Publication can append newly admitted vendors after the legacy vendors. Directory enumeration is alphabetical;
71
+ // catalog.json retains the publication order. Accept either only when its complete digest matches the snapshot.
72
+ const publicationOrder = new Map([...new Set(catalog.versions.map((v) => v.vendor))].map((name, at) => [name, at]));
73
+ const orderedVendors = [...index.vendors].sort((a, b) =>
74
+ (publicationOrder.get(a.vendor) ?? index.vendors.length) - (publicationOrder.get(b.vendor) ?? index.vendors.length));
75
+ requireThat(snapshotDigest(index.vendors) === catalog.digest || snapshotDigest(orderedVendors) === catalog.digest,
76
+ 'catalog snapshot digest differs');
77
+ const repository = repositoryOf(pkg);
78
+ if (vendor !== undefined) requireThat(index.vendors.some((e) => e.vendor === vendor), `unknown catalog vendor: ${vendor}`);
79
+ if (packageName !== undefined) requireThat(index.vendors.some((e) => (vendor === undefined || e.vendor === vendor) &&
80
+ e.packages.some((p) => p.name === packageName)), `unknown catalog package: ${packageName}`);
81
+
82
+ const vendors = index.vendors.filter((e) => vendor === undefined || e.vendor === vendor)
83
+ .filter((e) => packageName === undefined || e.packages.some((p) => p.name === packageName))
84
+ .sort((a, b) => textOrder(a.vendor, b.vendor)).map((entry) => {
85
+ // Compute the choice before filtering: a package filter cannot silently change the default.
86
+ const { candidates, recommended, choice } = selection(index, entry);
87
+ const defaultRelease = choice ? { package: choice.p.name, version: choice.v.version, source: choice.p.source } : null;
88
+ const state = choice ? 'selected' : recommended ? 'unavailable-recommendation' : candidates.length > 1 ? 'choice-required' : 'unavailable';
89
+ return { vendor: entry.vendor, recommendation: recommended ?? null, selection: { state, default: defaultRelease },
90
+ implementations: entry.packages.filter((p) => packageName === undefined || p.name === packageName)
91
+ .sort((a, b) => textOrder(a.name, b.name)).map((p) => {
92
+ const source = index.sources.find((s) => s.name === p.source);
93
+ const sourceUrl = `https://github.com/${source.repository}`;
94
+ return { package: p.name, source: { ...source, url: sourceUrl,
95
+ workflowUrl: `${sourceUrl}/blob/HEAD/.github/workflows/${source.workflow ?? 'release.yml'}` },
96
+ versions: p.versions.map((v) => {
97
+ const record = records.get(`${p.name}@${v.version}`);
98
+ const bound = reportFor(root, record);
99
+ const revoked = index.revocations.find((r) => r.package === p.name && r.version === v.version);
100
+ const selectable = v.status === 'live' && !v.version.includes('-') && !revoked;
101
+ const quick = bound?.report.quick, browser = bound?.report.browser;
102
+ return { ...v, revoked: revoked ? { reason: revoked.reason } : null, selectable: Boolean(selectable),
103
+ default: choice?.p.name === p.name && choice.v.version === v.version,
104
+ assessment: { recorded: record.assessed, available: Boolean(bound), evidence: record.evidence,
105
+ input: bound ? { repository: bound.envelope.input.repository, pullRequest: bound.envelope.input.pullRequest,
106
+ head: bound.envelope.input.head, base: bound.envelope.input.base, image: bound.envelope.image,
107
+ workflow: bound.envelope.workflow, tools: bound.envelope.input.policy?.tools ?? null } : null },
108
+ measurements: bound ? {
109
+ scope: quick?.scope ?? null, surface: quick?.surface ?? null,
110
+ operationCoverage: quick?.operationCoverage ?? null, servedOperations: quick?.servedOperations ?? null,
111
+ steps: quick?.steps ?? null, answered: quick?.answered ?? null, failures: quick?.failures ?? null,
112
+ replay: quick?.replay ?? null, conformance: bound.report.conformance ?? null,
113
+ browser: browser?.measured === true ? browser : null,
114
+ codeCoverage: quick?.codeCoverage ?? null, stateTransitionCoverage: quick?.stateTransitionCoverage ?? null,
115
+ domCoverage: browser?.domCoverage ?? null, comparison: bound.report.comparison ?? null } : null };
116
+ }) };
117
+ }) };
118
+ });
119
+ return { schemaVersion: 1, snapshot: { package: pkg.name, version: pkg.version, integrity,
120
+ sourceCommit: catalog.sourceCommit, digest: catalog.digest },
121
+ contribution: { repository, registrationUrl: `${repository}/edit/main/sources.json`,
122
+ pullRequestsUrl: `${repository}/pulls`, guideUrl: `${repository}/blob/${catalog.sourceCommit}/docs/contributing.md` }, vendors };
123
+ }
@@ -0,0 +1,74 @@
1
+ // Trusted controller receipts outlive workflow-artifact retention. No candidate code is imported here.
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { digest, requireThat, stable, submissionId } from './model.mjs';
7
+
8
+ export function validateEvidence(envelope, proof, repository) {
9
+ requireThat(digest(envelope) === proof.reportSha256, 'evidence digest differs from successful readiness receipt');
10
+ requireThat(envelope.status === 'ready' && envelope.report?.ready === true, 'evidence is not successful package readiness');
11
+ requireThat(envelope.input?.repository === repository && envelope.input?.pullRequest === proof.pullRequest && envelope.input?.head === proof.head && envelope.input?.base === proof.base, 'evidence PR identity differs');
12
+ requireThat(stable(envelope.input.submission) === stable(proof.submission), 'evidence artifact identity differs');
13
+ requireThat(envelope.inputSha256 === digest(envelope.input) && envelope.dependencyLock?.packages && envelope.workflow?.repository === repository && envelope.workflow.file === 'check.yml', 'evidence input or workflow identity differs');
14
+ requireThat(!proof.workflow || (envelope.workflow.run === proof.workflow.run && envelope.workflow.attempt === proof.workflow.attempt), 'evidence workflow differs from bound receipt');
15
+ return envelope;
16
+ }
17
+ export async function readinessRun(proof, github) {
18
+ if (proof.workflow) {
19
+ const { run: id, attempt } = proof.workflow;
20
+ requireThat(Number.isSafeInteger(id) && id > 0 && Number.isSafeInteger(attempt) && attempt > 0, 'invalid readiness workflow run or attempt');
21
+ const run = await github.call(`actions/runs/${id}/attempts/${attempt}`);
22
+ requireThat(run.id === id && run.run_attempt === attempt && run.repository?.full_name === github.repository, 'readiness workflow identity differs from receipt');
23
+ requireThat(run.path === '.github/workflows/check.yml' && ['pull_request_target', 'workflow_dispatch'].includes(run.event), 'evidence did not come from the trusted readiness workflow');
24
+ requireThat(run.status === 'completed' && run.conclusion === 'success', 'readiness workflow attempt did not succeed');
25
+ requireThat(run.head_sha === (run.event === 'workflow_dispatch' ? proof.base : proof.head), 'readiness workflow source commit differs from bound input');
26
+ return run;
27
+ }
28
+ const url = new URL(proof.check.details_url);
29
+ requireThat(url.origin === 'https://github.com', 'readiness evidence is not a GitHub run');
30
+ const prefix = `/${github.repository}/actions/runs/`;
31
+ const runId = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : '';
32
+ let run;
33
+ if (/^[0-9]+$/.test(runId)) run = await github.call(`actions/runs/${runId}`);
34
+ else {
35
+ requireThat(url.pathname === `/${github.repository}/runs/${proof.check.id}` && Number.isSafeInteger(proof.check.check_suite?.id), 'readiness evidence run belongs to a different repository');
36
+ const suite = proof.check.check_suite.id;
37
+ const result = await github.call(`actions/runs?check_suite_id=${suite}&per_page=100`);
38
+ const matches = result.workflow_runs.filter((r) => r.check_suite_id === suite && r.head_sha === proof.head && r.path === '.github/workflows/check.yml');
39
+ requireThat(matches.length === 1, 'readiness check has no unique matching workflow run');
40
+ run = matches[0];
41
+ }
42
+ requireThat(run.path === '.github/workflows/check.yml' && ['pull_request_target', 'workflow_dispatch'].includes(run.event), 'evidence did not come from the trusted readiness workflow');
43
+ return run;
44
+ }
45
+ export async function persistEvidence(proofs, github) {
46
+ const receipts = {};
47
+ for (const proof of proofs) {
48
+ const id = submissionId(proof.submission), tag = `evidence-${id}-${proof.reportSha256}`;
49
+ let release = await github.optional(`releases/tags/${tag}`);
50
+ let asset = release?.assets.find((a) => a.name === 'report.json');
51
+ let envelope;
52
+ if (asset) envelope = JSON.parse((await github.download(`releases/assets/${asset.id}`)).toString('utf8'));
53
+ else {
54
+ const run = await readinessRun(proof, github);
55
+ const artifacts = await github.call(`actions/runs/${run.id}/artifacts?per_page=100`);
56
+ const artifact = artifacts.artifacts.find((a) => a.name === `catalog-assessment-${run.id}-${run.run_attempt}` && !a.expired);
57
+ requireThat(artifact, 'readiness report expired or missing; reassess the current PR before admission');
58
+ const temp = mkdtempSync(join(tmpdir(), 'catalog-evidence-')), file = join(temp, 'evidence.zip');
59
+ writeFileSync(file, await github.download(`actions/artifacts/${artifact.id}/zip`));
60
+ // Read one member to stdout; never extract paths supplied in an archive.
61
+ const data = spawnSync('unzip', ['-p', file, 'report.json'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
62
+ requireThat(data.status === 0, 'readiness artifact contains no readable report');
63
+ envelope = JSON.parse(data.stdout);
64
+ requireThat(envelope.workflow?.run === run.id && envelope.workflow?.attempt === run.run_attempt, 'evidence workflow differs from artifact source');
65
+ validateEvidence(envelope, proof, github.repository);
66
+ if (!release) release = await github.call('releases', 'POST', { tag_name: tag, target_commitish: proof.commit, name: `Assessment ${proof.submission.package}@${proof.submission.version}`, body: `Readiness report SHA-256: ${proof.reportSha256}\nAdmission PR: #${proof.pullRequest}`, draft: false, prerelease: true });
67
+ asset = await github.upload(release.upload_url, 'report.json', Buffer.from(`${JSON.stringify(envelope, null, 2)}\n`));
68
+ }
69
+ validateEvidence(envelope, proof, github.repository);
70
+ // The resolved dependency lock is retained in the report identity; workflow diagnostics are supplementary.
71
+ receipts[id] = { pullRequest: proof.pullRequest, url: release.html_url, reportSha256: proof.reportSha256, reportPath: `evidence/${id}.json`, ...(proof.moderation ? { moderation: proof.moderation } : {}), envelope };
72
+ }
73
+ return receipts;
74
+ }
package/lib/github.mjs ADDED
@@ -0,0 +1,88 @@
1
+ import { requireThat } from './model.mjs';
2
+
3
+ export class GitHub {
4
+ constructor(repository, token, fetcher = fetch) {
5
+ requireThat(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository), 'invalid repository');
6
+ requireThat(token, 'GitHub token required for repository automation');
7
+ this.repository = repository; this.token = token; this.fetcher = fetcher;
8
+ }
9
+ async call(path, method = 'GET', body) {
10
+ const url = `https://api.github.com/repos/${this.repository}/${path}`;
11
+ const options = { method, headers: { authorization: `Bearer ${this.token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) };
12
+ let r;
13
+ for (let attempt = 0; ; attempt++) {
14
+ try { r = await this.fetcher(url, options); break; }
15
+ catch (error) {
16
+ // Both sandbox attempts retained ready evidence but failed on the post-evaluation read
17
+ // (Actions 37313751170, attempts 1 and 2). Retry the read, never a potentially applied write.
18
+ if (method === 'GET' && attempt === 0) continue;
19
+ const code = error.cause?.code ?? error.code ?? 'unknown';
20
+ throw new Error(`GitHub ${method} ${path}: transport failed (${code})`, { cause: error });
21
+ }
22
+ }
23
+ if (!r.ok) { const error = new Error(`GitHub ${method} ${path}: HTTP ${r.status}`); error.status = r.status; throw error; }
24
+ return r.status === 204 ? null : r.json();
25
+ }
26
+ async optional(path) {
27
+ try { return await this.call(path); } catch (error) { if (error.status === 404) return null; throw error; }
28
+ }
29
+ async download(path) {
30
+ const accept = /^releases\/assets\/\d+$/.test(path) ? 'application/octet-stream' : 'application/vnd.github+json';
31
+ const response = await this.fetcher(`https://api.github.com/repos/${this.repository}/${path}`, { headers: { authorization: `Bearer ${this.token}`, accept, 'x-github-api-version': '2022-11-28' } });
32
+ requireThat(response.ok, `GitHub download ${path}: HTTP ${response.status}`);
33
+ return Buffer.from(await response.arrayBuffer());
34
+ }
35
+ async upload(url, name, bytes) {
36
+ const target = new URL(url.replace(/\{.*$/, ''));
37
+ requireThat(target.origin === 'https://uploads.github.com', 'unexpected evidence upload host');
38
+ target.searchParams.set('name', name);
39
+ const response = await this.fetcher(target, { method: 'POST', headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, body: bytes });
40
+ requireThat(response.ok, `GitHub evidence upload: HTTP ${response.status}`);
41
+ return response.json();
42
+ }
43
+ async pages(path) {
44
+ const result = [];
45
+ for (let page = 1; ; page++) {
46
+ const rows = await this.call(`${path}${path.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
47
+ requireThat(Array.isArray(rows), 'GitHub pagination expected an array');
48
+ result.push(...rows);
49
+ if (rows.length < 100) return result;
50
+ }
51
+ }
52
+ async content(path, ref) {
53
+ requireThat(/^[a-f0-9]{40}$/.test(ref), 'content must be read at an immutable commit');
54
+ const doc = await this.call(`contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${ref}`);
55
+ requireThat(doc.type === 'file' && doc.encoding === 'base64', 'expected a data file');
56
+ return Buffer.from(doc.content, 'base64').toString('utf8');
57
+ }
58
+ }
59
+
60
+ const escape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/@/g, '@\u200b').replace(/`/g, '\\`');
61
+ export function summary(envelope) {
62
+ const s = envelope.input?.submission;
63
+ const lines = [`Catalog: **${escape(envelope.status)}**`, '', `Head: \`${escape(envelope.input?.head ?? 'unknown')}\``, `Policy and inputs: \`${envelope.inputSha256 ?? 'not assessed'}\``];
64
+ if (s) lines.push(`Artifact: \`${escape(s.package)}@${escape(s.version)}\``, `Integrity: \`${s.integrity}\``);
65
+ if (envelope.error) lines.push('', escape(envelope.error));
66
+ const report = envelope.report;
67
+ if (report) {
68
+ lines.push('', `Journey: ${escape(report.quick?.answered ?? 'not measured')} answered steps; ${escape(report.quick?.failures?.length ?? 'not measured')} recorded failures.`, `Replay: ${report.quick?.replay?.equal ? 'matches' : 'failed'}.`);
69
+ const surface = report.quick?.surface;
70
+ lines.push(surface ? `Declared API surface: ${escape(surface.served)}/${escape(surface.total)} served; ${escape(surface.gap)} gaps.` : 'Declared API surface: not measured.');
71
+ const coverage = report.quick?.operationCoverage;
72
+ lines.push(coverage ? `Journey operation coverage: ${escape(coverage.exercised)}/${escape(coverage.total)} (${escape(coverage.percent.toFixed(1))}%).` : 'Journey operation coverage: not measured.');
73
+ lines.push(report.browser?.measured ? `Chromium: ${escape(report.browser.version)}; ${escape(report.browser.answered)} answered steps; ${escape(report.browser.failures.length)} failures; replay ${report.browser.replay.equal ? 'matches' : 'failed'}.` : `Chromium: not measured (${escape(report.browser?.reason ?? 'unknown target')}).`);
74
+ lines.push('Browser DOM, code-line and state-transition coverage: not measured by this quick run.');
75
+ for (const group of [...(report.gates ?? []), ...(report.conformance ?? [])]) for (const failure of group.failures ?? []) lines.push(`- ${escape(group.id ?? group.unit)}: ${escape(failure)}`);
76
+ for (const failure of report.quick?.failures ?? []) lines.push(`- ${escape(failure.step)} / ${escape(failure.beat)}: ${escape(failure.detail)}`);
77
+ lines.push('', escape(report.comparison?.note ?? 'Baseline comparison is in the JSON report.'));
78
+ }
79
+ lines.push('', 'An authorized maintainer merges the current head after readiness. Direct trusted accounts need no separate review; untrusted accounts also need current-head moderator approval.', 'The workflow artifacts contain the complete JSON report and diagnostics.');
80
+ return lines.join('\n').slice(0, 60000);
81
+ }
82
+
83
+ export async function feedback(github, number, body) {
84
+ const marker = '<!-- twin-catalog-readiness -->';
85
+ const comments = await github.pages(`issues/${number}/comments`);
86
+ const old = comments.find((c) => c.user?.login === 'github-actions[bot]' && c.body?.startsWith(marker));
87
+ return github.call(old ? `issues/comments/${old.id}` : `issues/${number}/comments`, old ? 'PATCH' : 'POST', { body: `${marker}\n${body}` });
88
+ }
package/lib/model.mjs ADDED
@@ -0,0 +1,172 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const canonical = (v) => v && typeof v === 'object' ? Array.isArray(v) ? v.map(canonical) : Object.fromEntries(Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => [k, canonical(v[k])])) : v;
6
+ export const stable = (v) => JSON.stringify(canonical(v));
7
+ export const digest = (v) => createHash('sha256').update(typeof v === 'string' ? v : stable(v)).digest('hex');
8
+ export const read = (file) => JSON.parse(readFileSync(file, 'utf8'));
9
+ export const write = (file, value) => { mkdirSync(join(file, '..'), { recursive: true }); writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); };
10
+ export const requireThat = (condition, message) => { if (!condition) throw new Error(message); };
11
+ export const idPattern = /^[a-z][a-z0-9-]*$/;
12
+ export const packagePattern = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
13
+ export const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
14
+ export function exactKeys(value, keys, label) {
15
+ requireThat(value && typeof value === 'object' && !Array.isArray(value), `${label}: expected an object`);
16
+ requireThat(Object.keys(value).every((k) => keys.includes(k)), `${label}: unknown field`);
17
+ }
18
+ export function validateSources(sources) {
19
+ requireThat(Array.isArray(sources), 'sources must be an array');
20
+ const names = new Set();
21
+ for (const s of sources) {
22
+ exactKeys(s, ['name', 'repository', 'scope', 'official', 'protocol', 'workflow'], 'source');
23
+ requireThat(idPattern.test(s.name) && !names.has(s.name), 'source name must be unique');
24
+ requireThat(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(s.repository), 'source repository must be owner/repo');
25
+ requireThat(/^@[a-z0-9][a-z0-9._-]*$/.test(s.scope), 'source scope must be an npm scope');
26
+ requireThat(s.protocol === '3' && typeof s.official === 'boolean', 'source must declare Protocol 3 and maintenance identity');
27
+ requireThat(/^[A-Za-z0-9_-]+\.ya?ml$/.test(s.workflow ?? 'release.yml'), 'source workflow must be a filename');
28
+ names.add(s.name);
29
+ }
30
+ return sources;
31
+ }
32
+ export function validateSubmission(s, sources) {
33
+ exactKeys(s, ['schemaVersion', 'source', 'vendor', 'package', 'version', 'integrity'], 'submission');
34
+ requireThat(s.schemaVersion === 1, 'unsupported submission schema');
35
+ requireThat(typeof s.vendor === 'string' && idPattern.test(s.vendor), 'invalid vendor');
36
+ requireThat(packagePattern.test(s.package), 'package must have a publisher scope');
37
+ requireThat(versionPattern.test(s.version), 'version must be exact semver without build metadata');
38
+ if (s.version.includes('-')) requireThat(s.version.split('-').slice(1).join('-').split('.').every((x) => !/^0\d+$/.test(x)), 'numeric prerelease identifiers cannot have leading zeros');
39
+ requireThat(/^sha512-[A-Za-z0-9+/]{86}==$/.test(s.integrity), 'integrity must be a single SHA-512 SRI');
40
+ const source = sources.find((x) => x.name === s.source);
41
+ requireThat(source && s.package.startsWith(`${source.scope}/`), 'package is outside its registered source scope');
42
+ return source;
43
+ }
44
+ export const submissionId = (s) => digest(s);
45
+ export function loadIndex(root) {
46
+ const sources = validateSources(read(join(root, 'sources.json')));
47
+ const vendors = existsSync(join(root, 'vendors')) ? readdirSync(join(root, 'vendors')).filter((f) => f.endsWith('.json')).sort().map((f) => read(join(root, 'vendors', f))) : [];
48
+ const recommendations = existsSync(join(root, 'recommendations.json')) ? read(join(root, 'recommendations.json')) : {};
49
+ const revocations = existsSync(join(root, 'revocations.json')) ? read(join(root, 'revocations.json')) : [];
50
+ return { sources, vendors, recommendations, revocations };
51
+ }
52
+ export function validateIndex(index) {
53
+ validateSources(index.sources);
54
+ const identities = new Map();
55
+ const packageOwners = new Map();
56
+ const vendors = new Set();
57
+ for (const e of index.vendors) {
58
+ requireThat(idPattern.test(e.vendor) && !vendors.has(e.vendor) && Array.isArray(e.packages), 'invalid or repeated vendor');
59
+ vendors.add(e.vendor);
60
+ const packages = new Set();
61
+ for (const p of e.packages) {
62
+ const source = index.sources.find((s) => s.name === p.source);
63
+ requireThat(packagePattern.test(p.name) && source && p.name.startsWith(`${source.scope}/`) && !packages.has(p.name), 'invalid or repeated package/source');
64
+ packages.add(p.name);
65
+ const owner = packageOwners.get(p.name);
66
+ requireThat(!owner || (owner.vendor === e.vendor && owner.source === p.source), 'package vendor and source are immutable across versions');
67
+ packageOwners.set(p.name, { vendor: e.vendor, source: p.source });
68
+ const versions = new Set();
69
+ for (const v of p.versions) {
70
+ requireThat(versionPattern.test(v.version) && !versions.has(v.version), 'invalid or repeated version');
71
+ versions.add(v.version);
72
+ requireThat(['live', 'pending', 'rejected'].includes(v.status), 'invalid version status');
73
+ const key = `${p.name}@${v.version}`;
74
+ requireThat(!identities.has(key), `${key}: repeated package identity across vendors`);
75
+ identities.set(key, { vendor: e.vendor, source: p.source, ...v });
76
+ }
77
+ }
78
+ }
79
+ requireThat(index.recommendations && typeof index.recommendations === 'object' && !Array.isArray(index.recommendations), 'recommendations must be an object');
80
+ for (const [vendor, name] of Object.entries(index.recommendations)) requireThat(index.vendors.some((e) => e.vendor === vendor && e.packages.some((p) => p.name === name && p.versions.some((v) => v.status === 'live'))), `${vendor}: recommendation has no live package`);
81
+ requireThat(Array.isArray(index.revocations), 'revocations must be an array');
82
+ const revoked = new Set();
83
+ for (const r of index.revocations) {
84
+ exactKeys(r, ['package', 'version', 'reason'], 'revocation');
85
+ const key = `${r.package}@${r.version}`;
86
+ requireThat(identities.has(key) && !revoked.has(key) && typeof r.reason === 'string' && r.reason.trim(), 'revocation needs an existing unique release and reason');
87
+ revoked.add(key);
88
+ }
89
+ return index;
90
+ }
91
+ export const revoked = (index, name, version) => index.revocations.some((r) => r.package === name && r.version === version);
92
+ export const compareVersions = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; };
93
+ export function selection(index, entry) {
94
+ const candidates = entry.packages.map((p) => ({ p, v: p.versions.filter((v) => v.status === 'live' && !v.version.includes('-') && !revoked(index, p.name, v.version)).sort((a, b) => compareVersions(b.version, a.version))[0] })).filter((c) => c.v);
95
+ const recommended = index.recommendations[entry.vendor];
96
+ const choice = recommended ? candidates.find((c) => c.p.name === recommended) : candidates.length === 1 ? candidates[0] : undefined;
97
+ return { candidates, recommended, choice };
98
+ }
99
+ export function defaults(index) {
100
+ validateIndex(index);
101
+ const out = {};
102
+ for (const e of index.vendors) {
103
+ const { candidates, recommended, choice } = selection(index, e);
104
+ if (!choice && candidates.length) throw new Error(`${e.vendor}: select a live recommended package; competing publishers are not ordered by version`);
105
+ if (recommended && !choice) throw new Error(`${e.vendor}: recommended package has no selectable release`);
106
+ if (choice) out[e.vendor] = { name: choice.p.name, version: choice.v.version, source: choice.p.source, ...(choice.v.integrity ? { integrity: choice.v.integrity } : {}) };
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** A PR contributes one data change. Nothing from its tree is executed. */
112
+ export function classify(files, base, contents) {
113
+ requireThat(files.length > 0, 'empty change');
114
+ if (files.length === 1 && /^(submissions|reassessments)\/[a-f0-9]{64}\.json$/.test(files[0].filename)) {
115
+ const f = files[0];
116
+ requireThat(f.status === 'added', 'admitted submissions are immutable');
117
+ const submission = JSON.parse(contents[f.filename]);
118
+ const source = validateSubmission(submission, base.sources);
119
+ const reassessment = f.filename.startsWith('reassessments/');
120
+ requireThat(f.filename === `${reassessment ? 'reassessments' : 'submissions'}/${submissionId(submission)}.json`, 'submission filename does not match its identity');
121
+ const previous = base.vendors.find((e) => e.vendor === submission.vendor)?.packages.find((p) => p.name === submission.package && p.source === submission.source)?.versions.find((v) => v.version === submission.version);
122
+ if (reassessment) requireThat(previous?.status === 'live' && !previous.submission && !previous.assessment && (!previous.integrity || previous.integrity === submission.integrity), 'reassessment needs an unassessed historical release with identical identity');
123
+ else requireThat(!base.vendors.some((e) => e.packages.some((p) => p.name === submission.package && p.versions.some((v) => v.version === submission.version))), 'release already in catalog');
124
+ return { kind: reassessment ? 'reassessment' : 'submission', submission, source };
125
+ }
126
+ requireThat(!files.some((f) => f.filename.startsWith('submissions/') || f.filename.startsWith('reassessments/') || f.filename.startsWith('vendors/')), 'submissions cannot be mixed with policy, executable, or index edits');
127
+ if (files.length === 1 && files[0].filename === 'sources.json') {
128
+ const sources = validateSources(JSON.parse(contents['sources.json']));
129
+ requireThat(sources.length === base.sources.length + 1 && base.sources.every((s) => sources.some((x) => stable(x) === stable(s))), 'source registration adds one source; existing registrations are immutable');
130
+ return { kind: 'registration', source: sources.find((s) => !base.sources.some((x) => x.name === s.name)) };
131
+ }
132
+ requireThat(!files.some((f) => f.filename === 'sources.json'), 'source registration must be a separate data-only PR');
133
+ if (files.every((f) => ['recommendations.json', 'revocations.json'].includes(f.filename))) {
134
+ const next = { ...base };
135
+ for (const f of files) { requireThat(f.status !== 'removed', 'policy data cannot be removed'); next[f.filename.split('.')[0]] = JSON.parse(contents[f.filename]); }
136
+ validateIndex(next);
137
+ requireThat(base.revocations.every((r) => next.revocations.some((x) => stable(x) === stable(r))), 'revocations are append-only');
138
+ defaults(next);
139
+ return { kind: 'selection' };
140
+ }
141
+ requireThat(!files.some((f) => ['recommendations.json', 'revocations.json'].includes(f.filename)), 'selection changes must be a separate data-only PR');
142
+ return { kind: 'maintenance' };
143
+ }
144
+
145
+ export function admit(index, submission, commit, at, reassessment = false) {
146
+ validateSubmission(submission, index.sources);
147
+ requireThat(/^[a-f0-9]{40}$/.test(commit) && Number.isFinite(Date.parse(at)), 'admission needs a merge commit and timestamp');
148
+ const existing = index.vendors.flatMap((e) => e.packages.flatMap((p) => p.name === submission.package ? p.versions.map((v) => ({ e, p, v })) : [])).find(({ v }) => v.version === submission.version);
149
+ if (existing) {
150
+ if (reassessment) {
151
+ requireThat(existing.e.vendor === submission.vendor && existing.p.source === submission.source && existing.v.status === 'live' && (!existing.v.integrity || existing.v.integrity === submission.integrity) && !existing.v.submission && (!existing.v.assessment || existing.v.assessment === submissionId(submission)), 'historical reassessment identity conflict');
152
+ Object.assign(existing.v, { integrity: submission.integrity, assessment: submissionId(submission), assessmentCommit: commit, assessedAt: at });
153
+ return validateIndex(index);
154
+ }
155
+ requireThat(existing.e.vendor === submission.vendor && existing.p.source === submission.source && existing.v.integrity === submission.integrity && existing.v.submission === submissionId(submission), 'immutable release identity conflict');
156
+ return index;
157
+ }
158
+ let e = index.vendors.find((v) => v.vendor === submission.vendor);
159
+ if (!e) { e = { vendor: submission.vendor, packages: [] }; index.vendors.push(e); }
160
+ let p = e.packages.find((p) => p.name === submission.package);
161
+ if (!p) { p = { name: submission.package, source: submission.source, versions: [] }; e.packages.push(p); }
162
+ requireThat(p.source === submission.source, 'package source is immutable');
163
+ p.versions.push({ version: submission.version, status: 'live', integrity: submission.integrity, submission: submissionId(submission), commit, at, by: 'moderated-merge' });
164
+ return validateIndex(index);
165
+ }
166
+
167
+ export function compareReports(current, previous) {
168
+ if (!previous) return { baseline: null, note: 'No independently measured baseline; regressions are not assessed.' };
169
+ requireThat(current.package === previous.package, 'cannot compare different publishers');
170
+ const old = new Set(previous.quick?.servedOperations ?? []), now = new Set(current.quick?.servedOperations ?? []);
171
+ return { baseline: previous.version, added: [...now].filter((x) => !old.has(x)), removed: [...old].filter((x) => !now.has(x)), previousDenominator: previous.quick?.surface?.total ?? null, denominator: current.quick?.surface?.total ?? null, previousFailures: previous.quick?.failures?.length ?? null, failures: current.quick?.failures?.length ?? null };
172
+ }
@@ -0,0 +1,29 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { read, requireThat, stable, submissionId, validateSubmission } from './model.mjs';
4
+
5
+ /** Publisher-side automation opens a data PR; it never approves, merges or edits the index. */
6
+ export async function propose(github, directory, reassess = false) {
7
+ const base = await github.call('git/ref/heads/main');
8
+ const sources = JSON.parse(await github.content('sources.json', base.object.sha));
9
+ const results = [];
10
+ for (const filename of readdirSync(directory).filter((f) => /^[a-f0-9]{64}\.json$/.test(f)).sort()) {
11
+ const submission = read(join(directory, filename));
12
+ validateSubmission(submission, sources);
13
+ const id = submissionId(submission), kind = reassess ? 'reassessment' : 'submission', path = `${reassess ? 'reassessments' : 'submissions'}/${id}.json`, branch = `${kind}/${id}`;
14
+ requireThat(filename === `${id}.json`, 'submission filename mismatch');
15
+ const existing = await github.call(`pulls?state=all&head=${encodeURIComponent(`${github.repository.split('/')[0]}:${branch}`)}&base=main`);
16
+ if (existing.length) { results.push({ id, url: existing[0].html_url, state: existing[0].state }); continue; }
17
+ let ref;
18
+ try { ref = await github.call(`git/ref/heads/${branch}`); }
19
+ catch (e) { if (!e.message.endsWith('HTTP 404')) throw e; ref = await github.call('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: base.object.sha }); }
20
+ let held;
21
+ try { held = JSON.parse(await github.content(path, ref.object.sha)); }
22
+ catch (e) { if (!e.message.endsWith('HTTP 404')) throw e; }
23
+ if (held) requireThat(stable(held) === stable(submission), 'existing submission branch has different data');
24
+ else await github.call(`contents/${path}`, 'PUT', { message: `Submit ${submission.package}@${submission.version}`, branch, content: Buffer.from(`${JSON.stringify(submission, null, 2)}\n`).toString('base64') });
25
+ const pr = await github.call('pulls', 'POST', { title: `Submit ${submission.package}@${submission.version}`, head: branch, base: 'main', body: `Published artifact submission for ${submission.vendor}.\n\nThe catalog prepares evidence; moderators decide admission.\n\nIntegrity: \`${submission.integrity}\`` });
26
+ results.push({ id, url: pr.html_url, state: pr.state });
27
+ }
28
+ return results;
29
+ }