@rightkit/release 0.4.0 → 0.4.1

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.
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env node
2
+ // right-release adoption-evidence: PK17-6 adoption-evidence validation (RV11, Q8).
3
+ //
4
+ // Two checks, both fail-closed, neither ever satisfied by prose (CHANGELOG, deletion lists, summaries):
5
+ //
6
+ // 1. The delivery manifest (`published-release-manifest.json`, packet 01 section 6): schema, derived fields recomputed from
7
+ // their sources (capability status from packages and qualification rows, contract rollups, task `ready`, `readyForAdoption`),
8
+ // and the AP id set equal to the ids extracted from the frozen app packets (an omission, an extra id or a duplicate fails).
9
+ // 2. An app's adoption record (`<app>/rightkit-adoption.json`): for every adopted capability, the manifest package must be
10
+ // pinned by the app's lockfile at the exact version and integrity, the active target and feature closure (a saved
11
+ // `cargo tree -e features` text, hashed) must be one the manifest qualified, and a machine-readable exercised-entrypoint
12
+ // receipt (kind `test` or `run`, exit code 0, bound to the pinned versions, target, entrypoint and lockfile hashes) must exist.
13
+ // A task that is not `ready` in the manifest, or is N/A, is never adopted.
14
+ //
15
+ // right-release adoption-evidence --manifest <published-release-manifest.json> [--packets <dir>] [--json]
16
+ // [--app <dir> [--adoption <file>] [--target <win32-x64|darwin-arm64|...>]]
17
+ //
18
+ // Without --app only the manifest is validated. --packets defaults to the directory above the manifest's directory (the layout
19
+ // docs/plans/<set>/handoff/published-release-manifest.json beside 02-*.md .. 07-*.md); no packets found is a finding, never a skip.
20
+ // Exit codes: 0 no findings, 1 findings, 2 usage error.
21
+ //
22
+ // Adoption record (schema 1):
23
+ // { "schema": 1, "app": "heardright", "manifestSha256": "<sha256 of the manifest file>", "target": "win32-x64",
24
+ // "locks": { "cargo": "src-tauri/Cargo.lock", "pnpm": "pnpm-lock.yaml", "swift": "Package.resolved" }, // optional; defaults below
25
+ // "cargoTree": { "file": "evidence/cargo-tree-win32-x64.txt", "sha256": "<hex>" }, // required with any cargo package
26
+ // "tasks": { "AP-HR-03": { "adopted": [ {
27
+ // "capability": "PK03.helper-trust",
28
+ // "packages": [ { "id": "cargo:rightkit-credentials", "features": ["helper-trust"] } ], // or a bare id; features are the ones the app requests
29
+ // "entrypoints": ["src-tauri/src/vault.rs#open_vault"],
30
+ // "receipt": { "file": "evidence/AP-HR-03-helper-trust.json", "sha256": "<hex>" } } ] } } }
31
+ // Receipt (schema 1, JSON only): { "schema": 1, "kind": "test|run", "app", "capability", "entrypoint", "packages": ["<id>@<version>"],
32
+ // "target", "command": ["..."], "exitCode": 0, "finishedAt": "<iso>", "lockfileSha256": { "cargo": "<hex>" } }
33
+ import { createHash } from "node:crypto";
34
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { parse as parseToml } from "smol-toml";
38
+
39
+ export const APP_PREFIX = Object.freeze({ pulse: "PU", heardright: "HR", viewright: "VR", mailright: "MR", scraperight: "SR", coderight: "CR" });
40
+ export const PK_IDS = Object.freeze(Array.from({ length: 17 }, (_, i) => `PK${String(i + 1).padStart(2, "0")}`));
41
+ export const DEFERRED_CAPABILITY = "PK14.fixture-capture";
42
+ const ECOSYSTEMS = new Set(["cargo", "npm", "swift"]);
43
+ const HOST_FOR_TARGET = [[/^win32-/, "win32"], [/^darwin-/, "darwin"], [/^linux-/, "linux-pr"], [/^android-/, "android-compile"]];
44
+ const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
45
+ const isHex = (value, length) => typeof value === "string" && new RegExp(`^[0-9a-f]{${length}}$`).test(value);
46
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
47
+ const finding = (rule, reason, extra = {}) => ({ rule, reason, ...extra });
48
+
49
+ // ---- strict JSON (duplicate keys are an error, not last-wins) ---------------------------------------------------------------
50
+ export function parseJsonStrict(text, label = "JSON") {
51
+ const source = String(text).replace(/^/, "");
52
+ let i = 0;
53
+ const fail = (message) => { throw new Error(`${label}: ${message} at offset ${i}`); };
54
+ const skip = () => { while (i < source.length && (source[i] === " " || source[i] === "\t" || source[i] === "\r" || source[i] === "\n")) i += 1; };
55
+ const STRING = /"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/y;
56
+ const LITERAL = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/y;
57
+ const readString = () => {
58
+ STRING.lastIndex = i;
59
+ const m = STRING.exec(source);
60
+ if (!m) fail("malformed string");
61
+ i = STRING.lastIndex;
62
+ return JSON.parse(m[0]);
63
+ };
64
+ const readValue = () => {
65
+ skip();
66
+ const c = source[i];
67
+ if (c === "{") {
68
+ i += 1;
69
+ const object = {}; const keys = new Set();
70
+ skip();
71
+ if (source[i] === "}") { i += 1; return object; }
72
+ for (;;) {
73
+ skip();
74
+ if (source[i] !== '"') fail("expected a string key");
75
+ const key = readString();
76
+ if (keys.has(key)) fail(`duplicate key ${JSON.stringify(key)}`);
77
+ keys.add(key);
78
+ skip();
79
+ if (source[i] !== ":") fail("expected ':'");
80
+ i += 1;
81
+ Object.defineProperty(object, key, { value: readValue(), enumerable: true, writable: true, configurable: true });
82
+ skip();
83
+ if (source[i] === ",") { i += 1; continue; }
84
+ if (source[i] === "}") { i += 1; return object; }
85
+ fail("expected ',' or '}'");
86
+ }
87
+ }
88
+ if (c === "[") {
89
+ i += 1;
90
+ const list = [];
91
+ skip();
92
+ if (source[i] === "]") { i += 1; return list; }
93
+ for (;;) {
94
+ list.push(readValue());
95
+ skip();
96
+ if (source[i] === ",") { i += 1; continue; }
97
+ if (source[i] === "]") { i += 1; return list; }
98
+ fail("expected ',' or ']'");
99
+ }
100
+ }
101
+ if (c === '"') return readString();
102
+ LITERAL.lastIndex = i;
103
+ const m = LITERAL.exec(source);
104
+ if (!m) fail("unexpected token");
105
+ i = LITERAL.lastIndex;
106
+ return JSON.parse(m[0]);
107
+ };
108
+ const value = readValue();
109
+ skip();
110
+ if (i !== source.length) fail("trailing characters");
111
+ return value;
112
+ }
113
+
114
+ export function loadManifest(file) {
115
+ const bytes = readFileSync(file);
116
+ return { manifest: parseJsonStrict(bytes.toString("utf8"), path.basename(file)), sha256: sha256Of(bytes), bytes };
117
+ }
118
+
119
+ // ---- frozen packet task ids ---------------------------------------------------------------------------------------------
120
+ const TASK_DEFINITION = /^- \[[ xX]\] \*{0,2}(AP-[A-Z]{2}-\d{2})\b/;
121
+
122
+ /** Task ids defined by the frozen app packets 02..07 (`- [ ] AP-XX-nn` lines): { ids, duplicates, files }. */
123
+ export function extractPacketTaskIds(packetDir) {
124
+ const files = readdirSync(packetDir).filter((name) => /^0[2-7]-.+\.md$/.test(name)).sort();
125
+ const counts = new Map();
126
+ for (const name of files) {
127
+ for (const line of readFileSync(path.join(packetDir, name), "utf8").split(/\r?\n/)) {
128
+ const m = TASK_DEFINITION.exec(line);
129
+ if (m) counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
130
+ }
131
+ }
132
+ return { ids: [...counts.keys()].sort(), duplicates: [...counts].filter(([, n]) => n > 1).map(([id]) => id).sort(), files };
133
+ }
134
+
135
+ // ---- manifest validation --------------------------------------------------------------------------------------------------
136
+ const INTEGRITY = { cargo: (v) => isHex(v, 64), npm: (v) => typeof v === "string" && /^sha512-[A-Za-z0-9+/]+={0,2}$/.test(v), swift: (v) => isHex(v, 40) };
137
+ const nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
138
+ const packageKey = (p) => `${p.id}@${p.version}`;
139
+
140
+ function validatePackage(p, at, out) {
141
+ const bad = (rule, reason) => out.push(finding(rule, `${at}: ${reason}`));
142
+ if (!isObject(p)) { bad("package-invalid", "must be an object"); return; }
143
+ const m = /^(cargo|npm|swift):(.+)$/.exec(String(p.id));
144
+ if (!m) bad("package-invalid", `id must be <cargo|npm|swift>:<name>, got ${JSON.stringify(p.id)}`);
145
+ else {
146
+ if (p.ecosystem !== m[1]) bad("package-invalid", `ecosystem ${JSON.stringify(p.ecosystem)} does not match id ${p.id}`);
147
+ if (p.name !== m[2]) bad("package-invalid", `name ${JSON.stringify(p.name)} does not match id ${p.id}`);
148
+ }
149
+ if (!ECOSYSTEMS.has(p.ecosystem)) bad("package-invalid", "ecosystem must be cargo, npm or swift");
150
+ if (!nonEmptyString(p.version) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(p.version)) bad("package-invalid", `version must be exact semver, got ${JSON.stringify(p.version)}`);
151
+ if (ECOSYSTEMS.has(p.ecosystem) && !INTEGRITY[p.ecosystem](p.integrity)) bad("package-invalid", `integrity is not a valid ${p.ecosystem} integrity value`);
152
+ if (!isHex(p.archiveSha256, 64)) bad("package-invalid", "archiveSha256 must be 64 hex characters");
153
+ if (!nonEmptyString(p.archiveUrl)) bad("package-invalid", "archiveUrl is required");
154
+ if (p.sourceCommit === null) { if (!nonEmptyString(p.sourceCommitNullReason)) bad("package-invalid", "sourceCommitNullReason is required exactly when sourceCommit is null"); }
155
+ else {
156
+ if (!isHex(p.sourceCommit, 40)) bad("package-invalid", "sourceCommit must be a 40-hex commit or null");
157
+ if (p.sourceCommitNullReason !== undefined && p.sourceCommitNullReason !== null) bad("package-invalid", "sourceCommitNullReason must be null unless sourceCommit is null");
158
+ }
159
+ if (!Array.isArray(p.features) || p.features.some((f) => typeof f !== "string")) bad("package-invalid", "features must be an array of strings");
160
+ if (!Array.isArray(p.targets) || !p.targets.length || p.targets.some((t) => typeof t !== "string")) bad("package-invalid", "targets must be a non-empty array of strings");
161
+ if (!Array.isArray(p.qualification)) bad("package-invalid", "qualification must be an array");
162
+ else {
163
+ for (const row of p.qualification) {
164
+ if (!isObject(row) || !nonEmptyString(row.host) || typeof row.features !== "string" || !["pass", "fail", "not-applicable"].includes(row.result)) bad("package-invalid", `qualification row is malformed: ${JSON.stringify(row)}`);
165
+ else if (!nonEmptyString(row.gateEvidence)) bad("package-invalid", `qualification row for ${row.host} has no gateEvidence`);
166
+ }
167
+ }
168
+ if (!isObject(p.fetchedConsumer) || typeof p.fetchedConsumer.ok !== "boolean") bad("package-invalid", "fetchedConsumer.ok (boolean) is required");
169
+ if (p.event === "publish") {
170
+ if (!isObject(p.publisherTool)) bad("package-invalid", "a publish event needs publisherTool");
171
+ if (p.originalPublication != null || p.qualifier != null) bad("package-invalid", "a publish event has null originalPublication and qualifier");
172
+ if (!isObject(p.packedConsumer) || typeof p.packedConsumer.ok !== "boolean") bad("package-invalid", "a publish event needs packedConsumer.ok");
173
+ } else if (p.event === "qualify-existing") {
174
+ if (p.publisherTool !== null) bad("package-invalid", "a qualify-existing event has publisherTool null");
175
+ if (!isObject(p.originalPublication) || !isObject(p.qualifier)) bad("package-invalid", "a qualify-existing event needs originalPublication and qualifier");
176
+ if (!isObject(p.packedConsumer) || p.packedConsumer.ok !== null || p.packedConsumer.notApplicable !== "unchanged published bytes") bad("package-invalid", "a qualify-existing event has packedConsumer { ok: null, notApplicable: \"unchanged published bytes\" }");
177
+ } else bad("package-invalid", `event must be publish or qualify-existing, got ${JSON.stringify(p.event)}`);
178
+ }
179
+
180
+ /** Capability status that follows from its packages: [] when satisfiable, else the reasons it is not. */
181
+ function satisfactionProblems(capability, packagesByKey, packagesById) {
182
+ const problems = [];
183
+ if (!Array.isArray(capability.evidence) || !capability.evidence.length || capability.evidence.some((e) => !nonEmptyString(e))) problems.push("no evidence paths");
184
+ for (const ref of Array.isArray(capability.packageIds) ? capability.packageIds : []) {
185
+ const p = packagesByKey.get(ref) ?? (packagesById.get(ref)?.length === 1 ? packagesById.get(ref)[0] : null);
186
+ if (!p) { problems.push(`package ${ref} is not in the manifest (or is ambiguous without a version)`); continue; }
187
+ if (p.fetchedConsumer?.ok !== true) problems.push(`package ${ref} has no passing fetchedConsumer`);
188
+ const rows = Array.isArray(p.qualification) ? p.qualification : [];
189
+ if (rows.some((r) => r.result === "fail")) problems.push(`package ${ref} has a failing qualification row`);
190
+ if (!rows.some((r) => r.result === "pass")) problems.push(`package ${ref} has no passing qualification row`);
191
+ }
192
+ return problems;
193
+ }
194
+
195
+ function contractStatus(slices, caps) {
196
+ const live = slices.filter((id) => caps[id]?.status !== "deferred-separate-dispatch");
197
+ const satisfied = live.filter((id) => caps[id]?.status === "satisfied");
198
+ if (satisfied.length === live.length) return "satisfied";
199
+ return satisfied.length ? "partial" : "pending";
200
+ }
201
+
202
+ /** Readiness derived from capability statuses alone: { <AP-id>: { app, ready, reasons } }. Never reads the stored `ready`. */
203
+ export function deriveReadiness(manifest) {
204
+ const caps = isObject(manifest?.capabilities) ? manifest.capabilities : {};
205
+ const result = {};
206
+ for (const [app, entry] of Object.entries(isObject(manifest?.apps) ? manifest.apps : {})) {
207
+ for (const [id, task] of Object.entries(isObject(entry?.tasks) ? entry.tasks : {})) {
208
+ const reasons = [];
209
+ if (task?.applicable !== true) reasons.push("not applicable");
210
+ if (task?.applicabilityResolved !== true) reasons.push("applicability unresolved");
211
+ if (!Array.isArray(task?.requiresCapabilities)) reasons.push("requiresCapabilities missing");
212
+ else for (const cap of task.requiresCapabilities) if (caps[cap]?.status !== "satisfied") reasons.push(`capability ${cap} is ${caps[cap]?.status ?? "unknown"}`);
213
+ result[id] = { app, ready: reasons.length === 0, reasons };
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+
219
+ /**
220
+ * Validate a parsed manifest. `packetIds` ({ ids, duplicates }) is the frozen packet task set; without it the AP-set check is a
221
+ * finding. `baseDir` (the manifest's directory) enables the selected-snapshot hash and the evidence file checks.
222
+ */
223
+ export function validateManifest(manifest, { packetIds, baseDir } = {}) {
224
+ const out = [];
225
+ const add = (rule, reason) => out.push(finding(rule, reason));
226
+ if (!isObject(manifest)) return [finding("manifest-invalid", "manifest must be a JSON object")];
227
+ if (manifest.schemaVersion !== 1) add("manifest-invalid", `schemaVersion must be 1, got ${JSON.stringify(manifest.schemaVersion)}`);
228
+ if (!isObject(manifest.spec) || !isHex(manifest.spec.commit, 40) || !isHex(manifest.spec.sha256, 64)) add("manifest-invalid", "spec { commit (40 hex), sha256 (64 hex) } is required");
229
+ const selected = manifest.selectedSnapshot;
230
+ if (!isObject(selected) || !nonEmptyString(selected.path) || !isHex(selected.sha256, 64)) add("manifest-invalid", "selectedSnapshot { path, sha256 } is required");
231
+ else if (baseDir) {
232
+ const read = readInside(baseDir, selected.path);
233
+ if (read.error) add("snapshot-unreadable", `selectedSnapshot ${selected.path}: ${read.error}`);
234
+ else if (sha256Of(read.bytes) !== selected.sha256) add("snapshot-hash-mismatch", `selectedSnapshot ${selected.path} has sha256 ${sha256Of(read.bytes)}, the manifest says ${selected.sha256}`);
235
+ }
236
+
237
+ // packages
238
+ const packagesByKey = new Map(); const packagesById = new Map();
239
+ if (!Array.isArray(manifest.packages)) add("manifest-invalid", "packages must be an array");
240
+ else {
241
+ manifest.packages.forEach((p, index) => {
242
+ validatePackage(p, `packages[${index}]${isObject(p) && p.id ? ` ${p.id}` : ""}`, out);
243
+ if (!isObject(p) || !p.id) return;
244
+ const key = packageKey(p);
245
+ if (packagesByKey.has(key)) add("package-duplicate", `package ${key} is listed twice`);
246
+ packagesByKey.set(key, p);
247
+ packagesById.set(p.id, [...(packagesById.get(p.id) ?? []), p]);
248
+ });
249
+ }
250
+
251
+ // contracts and capabilities
252
+ const caps = isObject(manifest.capabilities) ? manifest.capabilities : null;
253
+ const contracts = isObject(manifest.contracts) ? manifest.contracts : null;
254
+ if (!caps) add("manifest-invalid", "capabilities must be an object");
255
+ if (!contracts) add("manifest-invalid", "contracts must be an object");
256
+ if (caps && contracts) {
257
+ const contractIds = Object.keys(contracts);
258
+ for (const id of PK_IDS) if (!contractIds.includes(id)) add("contract-missing", `contracts has no ${id}`);
259
+ for (const id of contractIds) if (!PK_IDS.includes(id)) add("contract-unknown", `contracts lists ${id}, which is not PK01..PK17`);
260
+ const listed = new Map();
261
+ for (const [id, contract] of Object.entries(contracts)) {
262
+ if (!isObject(contract) || !Array.isArray(contract.capabilities)) { add("contract-invalid", `${id}: capabilities must be an array`); continue; }
263
+ for (const cap of contract.capabilities) {
264
+ if (listed.has(cap)) add("capability-duplicate", `capability ${cap} is listed by ${listed.get(cap)} and ${id}`);
265
+ listed.set(cap, id);
266
+ if (!cap.startsWith(`${id}.`)) add("capability-misplaced", `capability ${cap} is listed under ${id}`);
267
+ if (!Object.hasOwn(caps, cap)) add("capability-missing", `contracts.${id} lists ${cap}, which capabilities does not define`);
268
+ }
269
+ const expected = contractStatus(contract.capabilities, caps);
270
+ if (contract.status !== expected) add("contract-not-derived", `contracts.${id}.status is ${JSON.stringify(contract.status)} but its capabilities derive ${expected}`);
271
+ }
272
+ for (const [id, capability] of Object.entries(caps)) {
273
+ if (!listed.has(id)) add("capability-orphan", `capability ${id} is not listed by any contract`);
274
+ if (!isObject(capability)) { add("capability-invalid", `${id}: must be an object`); continue; }
275
+ if (!["satisfied", "pending", "deferred-separate-dispatch"].includes(capability.status)) add("capability-invalid", `${id}: status ${JSON.stringify(capability.status)} is not satisfied, pending or deferred-separate-dispatch`);
276
+ if (capability.status === "deferred-separate-dispatch" && id !== DEFERRED_CAPABILITY) add("capability-invalid", `${id}: deferred-separate-dispatch is allowed only for ${DEFERRED_CAPABILITY}`);
277
+ if (!Array.isArray(capability.packageIds)) add("capability-invalid", `${id}: packageIds must be an array`);
278
+ if (capability.status === "satisfied") {
279
+ for (const problem of satisfactionProblems(capability, packagesByKey, packagesById)) add("capability-not-satisfied", `${id} is marked satisfied but: ${problem}`);
280
+ if (baseDir) for (const evidence of Array.isArray(capability.evidence) ? capability.evidence : []) {
281
+ if (typeof evidence !== "string") continue;
282
+ const read = readInside(baseDir, evidence);
283
+ if (read.error) { add("evidence-unreadable", `${id}: evidence ${evidence}: ${read.error}`); continue; }
284
+ let snapshot = null;
285
+ try { snapshot = parseJsonStrict(read.bytes.toString("utf8"), evidence); } catch { snapshot = null; }
286
+ const claimed = snapshot?.package;
287
+ if (isObject(claimed)) {
288
+ const match = packagesByKey.get(packageKey(claimed));
289
+ if (!match || match.integrity !== claimed.integrity || match.archiveSha256 !== claimed.archiveSha256) add("snapshot-mismatch", `${id}: evidence ${evidence} records ${packageKey(claimed)} with an integrity the manifest does not carry`);
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ // apps
297
+ const derived = deriveReadiness(manifest);
298
+ const apps = isObject(manifest.apps) ? manifest.apps : null;
299
+ if (!apps) add("manifest-invalid", "apps must be an object");
300
+ else {
301
+ for (const app of Object.keys(APP_PREFIX)) if (!Object.hasOwn(apps, app)) add("app-missing", `apps has no ${app}`);
302
+ const manifestIds = [];
303
+ for (const [app, entry] of Object.entries(apps)) {
304
+ if (!Object.hasOwn(APP_PREFIX, app)) { add("app-unknown", `apps lists ${app}, which is not one of ${Object.keys(APP_PREFIX).join(", ")}`); continue; }
305
+ if (!isObject(entry) || !isObject(entry.tasks) || !Array.isArray(entry.readyForAdoption)) { add("app-invalid", `${app}: tasks (object) and readyForAdoption (array) are required`); continue; }
306
+ for (const [id, task] of Object.entries(entry.tasks)) {
307
+ manifestIds.push(id);
308
+ if (!new RegExp(`^AP-${APP_PREFIX[app]}-\\d{2}$`).test(id)) { add("task-misplaced", `${id} is listed under ${app}`); continue; }
309
+ if (!isObject(task)) { add("task-invalid", `${id}: must be an object`); continue; }
310
+ if (typeof task.applicable !== "boolean" || typeof task.applicabilityResolved !== "boolean" || typeof task.ready !== "boolean") add("task-invalid", `${id}: applicable, applicabilityResolved and ready must be booleans`);
311
+ if (!Array.isArray(task.requiresCapabilities)) add("task-invalid", `${id}: requiresCapabilities must be an array`);
312
+ else {
313
+ if (new Set(task.requiresCapabilities).size !== task.requiresCapabilities.length) add("task-invalid", `${id}: requiresCapabilities lists a capability twice`);
314
+ for (const cap of task.requiresCapabilities) if (caps && !Object.hasOwn(caps, cap)) add("requires-unknown-capability", `${id} requires ${cap}, which capabilities does not define`);
315
+ }
316
+ if (task.applicable === false && !nonEmptyString(task.naReason)) add("task-invalid", `${id}: an N/A task needs a naReason`);
317
+ if (task.applicable === true && task.naReason != null) add("task-invalid", `${id}: naReason must be null for an applicable task`);
318
+ if (!Array.isArray(task.evidence)) add("task-invalid", `${id}: evidence must be an array`);
319
+ if (typeof task.ready === "boolean" && task.ready !== derived[id]?.ready) add("ready-not-derived", `${id}: ready is ${task.ready} but derives ${derived[id]?.ready} (${derived[id]?.reasons.join("; ") || "all requirements satisfied"})`);
320
+ }
321
+ const expectedReady = Object.keys(entry.tasks).filter((id) => derived[id]?.ready).sort();
322
+ const stored = [...entry.readyForAdoption].sort();
323
+ if (new Set(entry.readyForAdoption).size !== entry.readyForAdoption.length) add("ready-list-duplicate", `${app}: readyForAdoption lists an id twice`);
324
+ if (JSON.stringify(stored) !== JSON.stringify(expectedReady)) add("ready-list-not-derived", `${app}: readyForAdoption is [${stored.join(", ")}] but the derived ready list is [${expectedReady.join(", ")}]`);
325
+ }
326
+ // The AP id set must equal the frozen packet task set exactly.
327
+ if (!packetIds) add("packets-missing", "no frozen packet task ids were supplied: the AP id set cannot be verified");
328
+ else {
329
+ for (const id of packetIds.duplicates ?? []) add("packet-duplicate-task", `the frozen packets define ${id} more than once`);
330
+ const frozen = new Set(packetIds.ids);
331
+ if (!frozen.size) add("packets-missing", "the frozen packets define no AP task ids");
332
+ const present = new Set(manifestIds);
333
+ for (const id of frozen) if (!present.has(id)) add("ap-missing", `${id} is in the frozen packets but not in the manifest`);
334
+ for (const id of present) if (!frozen.has(id)) add("ap-extra", `${id} is in the manifest but not in the frozen packets`);
335
+ for (const [app, prefix] of Object.entries(APP_PREFIX)) if (!packetIds.ids.some((id) => id.startsWith(`AP-${prefix}-`))) add("packets-missing", `the frozen packets define no ${app} task`);
336
+ }
337
+ }
338
+ return out;
339
+ }
340
+
341
+ // ---- files inside an app / manifest directory ---------------------------------------------------------------------------
342
+ /** Read a relative path that must stay inside `root` (no absolute, drive, `..`, or symlink escape): { bytes } or { error }. */
343
+ export function readInside(root, relative) {
344
+ if (typeof relative !== "string" || !relative.trim()) return { error: "path is empty" };
345
+ const normalized = relative.replace(/\\/g, "/");
346
+ if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized) || normalized.split("/").includes("..")) return { error: "path must be relative and stay inside the directory" };
347
+ const file = path.join(root, normalized);
348
+ let real; let base;
349
+ try { base = realpathSync(root); } catch { return { error: "directory not found" }; }
350
+ try { real = realpathSync(file); } catch { return { error: "file not found" }; }
351
+ if (real !== base && !real.startsWith(base + path.sep)) return { error: "path resolves outside the directory" };
352
+ try { if (!statSync(real).isFile()) return { error: "not a file" }; } catch { return { error: "file not found" }; }
353
+ return { bytes: readFileSync(real), file };
354
+ }
355
+
356
+ // ---- lockfiles -------------------------------------------------------------------------------------------------------------
357
+ const CARGO_REGISTRY_SOURCES = ["registry+https://github.com/rust-lang/crates.io-index", "sparse+https://index.crates.io/"];
358
+
359
+ /** Cargo.lock packages: [{ name, version, source, checksum }]. */
360
+ export function parseCargoLock(text) {
361
+ const doc = parseToml(text);
362
+ return (Array.isArray(doc.package) ? doc.package : []).map((p) => ({ name: p.name, version: p.version, source: p.source ?? null, checksum: p.checksum ?? null }));
363
+ }
364
+
365
+ /** pnpm-lock.yaml (lockfileVersion 9) `packages:` entries: [{ name, version, integrity, registry }]. Throws on any other lockfile version. */
366
+ export function parsePnpmLock(text) {
367
+ const lines = String(text).split(/\r?\n/);
368
+ const version = /^lockfileVersion:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(text)?.[1];
369
+ if (!version || !/^9(?:\.|$)/.test(version)) throw new Error(`unsupported pnpm lockfileVersion ${JSON.stringify(version)} (only 9.x is read)`);
370
+ const entries = [];
371
+ let inPackages = false; let current = null;
372
+ const flush = () => {
373
+ if (!current) return;
374
+ const body = current.lines.join("\n");
375
+ const integrity = /\bintegrity:\s*(sha\d+-[A-Za-z0-9+/=]+)/.exec(body)?.[1] ?? null;
376
+ const registry = !/\b(?:directory|repo|commit)\s*:/.test(body) && !/\btype:\s*git\b/.test(body) && !/\btarball:\s*['"]?(?:file|link):/.test(body);
377
+ entries.push({ name: current.name, version: current.version, integrity, registry });
378
+ current = null;
379
+ };
380
+ for (const line of lines) {
381
+ if (/^\S/.test(line)) { flush(); inPackages = /^packages:\s*$/.test(line); continue; }
382
+ if (!inPackages) continue;
383
+ const key = /^ {2}'?((?:@[^/@'\s]+\/)?[^@'\s]+)@([^'\s:(]+)'?:\s*$/.exec(line);
384
+ if (key) { flush(); current = { name: key[1], version: key[2], lines: [] }; continue; }
385
+ if (current && /^ {3,}\S/.test(line)) current.lines.push(line);
386
+ else if (current && /^ {0,2}\S/.test(line)) flush();
387
+ }
388
+ flush();
389
+ return entries;
390
+ }
391
+
392
+ /** Package.resolved pins (v1 `object.pins`, v2/v3 `pins`): [{ identity, version, revision }]. */
393
+ export function parsePackageResolved(text) {
394
+ const doc = parseJsonStrict(text, "Package.resolved");
395
+ const pins = Array.isArray(doc?.pins) ? doc.pins : Array.isArray(doc?.object?.pins) ? doc.object.pins : [];
396
+ return pins.map((pin) => ({ identity: String(pin.identity ?? pin.package ?? "").toLowerCase(), version: pin.state?.version ?? null, revision: pin.state?.revision ?? null }));
397
+ }
398
+
399
+ // ---- cargo tree closure ----------------------------------------------------------------------------------------------------
400
+ const TREE_FEATURE = /^[\s│├└─|`+\\-]*([A-Za-z0-9_][A-Za-z0-9_-]*)(?: v(\S+))? feature "([^"]+)"/;
401
+ const TREE_NODE = /^[\s│├└─|`+\\-]*([A-Za-z0-9_][A-Za-z0-9_-]*) v(\d+\.\d+\.\d+\S*)/;
402
+
403
+ /** Crate versions and enabled features in a saved `cargo tree -e features` text. */
404
+ export function cargoTreeFacts(text) {
405
+ const versions = new Map(); const features = new Map();
406
+ const note = (map, name, value) => map.set(name, new Set([...(map.get(name) ?? []), value]));
407
+ for (const line of String(text).split(/\r?\n/)) {
408
+ const feature = TREE_FEATURE.exec(line);
409
+ if (feature) { note(features, feature[1], feature[3]); if (feature[2]) note(versions, feature[1], feature[2]); continue; }
410
+ const node = TREE_NODE.exec(line);
411
+ if (node) note(versions, node[1], node[2]);
412
+ }
413
+ return { versions, features };
414
+ }
415
+
416
+ const normalizeFeatures = (list) => [...new Set(list.map((f) => String(f).trim()).filter((f) => f && f !== "default" && f !== "[]"))].sort().join(",");
417
+ const featureRowSet = (text) => normalizeFeatures(String(text).split(/[,\s]+/));
418
+ const hostForTarget = (target) => HOST_FOR_TARGET.find(([pattern]) => pattern.test(target))?.[1] ?? null;
419
+
420
+ // ---- adoption --------------------------------------------------------------------------------------------------------------
421
+ const entrypointParts = (entry) => { const [file, ...symbol] = entry.split(/#|::/); return { file, symbol: symbol.join("::") }; };
422
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
423
+
424
+ function lockFor(root, ecosystem, adoption, cache, out, at) {
425
+ if (cache.has(ecosystem)) return cache.get(ecosystem);
426
+ const kind = ecosystem === "cargo" ? "cargo" : ecosystem === "npm" ? "pnpm" : "swift";
427
+ const candidates = adoption.locks?.[kind] ? [adoption.locks[kind]] : kind === "cargo" ? ["Cargo.lock", "src-tauri/Cargo.lock"] : kind === "pnpm" ? ["pnpm-lock.yaml"] : ["Package.resolved"];
428
+ let result = null;
429
+ for (const candidate of candidates) {
430
+ const read = readInside(root, candidate);
431
+ if (read.error) continue;
432
+ try {
433
+ const text = read.bytes.toString("utf8");
434
+ result = { kind, file: candidate, sha256: sha256Of(read.bytes), entries: kind === "cargo" ? parseCargoLock(text) : kind === "pnpm" ? parsePnpmLock(text) : parsePackageResolved(text) };
435
+ } catch (error) { out.push(finding("lockfile-unsupported", `${at}: ${candidate}: ${error.message}`)); result = false; }
436
+ break;
437
+ }
438
+ if (result === null) out.push(finding("lockfile-missing", `${at}: no ${kind} lockfile found (tried ${candidates.join(", ")})`));
439
+ cache.set(ecosystem, result);
440
+ return result;
441
+ }
442
+
443
+ function checkLockPin(lock, p, at, out) {
444
+ const rows = lock.kind === "cargo" ? lock.entries.filter((e) => e.name === p.name)
445
+ : lock.kind === "pnpm" ? lock.entries.filter((e) => e.name === p.name)
446
+ : lock.entries.filter((e) => e.identity === p.name.toLowerCase());
447
+ if (!rows.length) { out.push(finding("lock-missing", `${at}: ${p.id} is not in ${lock.file}`)); return; }
448
+ const versions = [...new Set(rows.map((r) => r.version))];
449
+ if (versions.length !== 1 || versions[0] !== p.version) out.push(finding("lock-version-mismatch", `${at}: ${lock.file} resolves ${p.id} to [${versions.join(", ")}], the manifest pins exactly ${p.version}`));
450
+ for (const row of rows) {
451
+ if (row.version !== p.version) continue;
452
+ if (lock.kind === "cargo") {
453
+ if (!CARGO_REGISTRY_SOURCES.includes(row.source)) out.push(finding("lock-not-registry", `${at}: ${p.id} in ${lock.file} has source ${JSON.stringify(row.source)}, not the crates.io registry`));
454
+ else if (row.checksum !== p.integrity) out.push(finding("lock-integrity-mismatch", `${at}: ${p.id} checksum in ${lock.file} is ${row.checksum}, the manifest integrity is ${p.integrity}`));
455
+ } else if (lock.kind === "pnpm") {
456
+ if (!row.registry) out.push(finding("lock-not-registry", `${at}: ${p.id} in ${lock.file} resolves from a directory, git or file source`));
457
+ else if (row.integrity !== p.integrity) out.push(finding("lock-integrity-mismatch", `${at}: ${p.id} integrity in ${lock.file} is ${row.integrity}, the manifest integrity is ${p.integrity}`));
458
+ } else if (row.revision !== p.integrity) out.push(finding("lock-integrity-mismatch", `${at}: ${p.id} revision in ${lock.file} is ${row.revision}, the manifest tag commit is ${p.integrity}`));
459
+ }
460
+ }
461
+
462
+ // `declared` is the feature list the adoption record says the app requests for this crate. The cargo tree evidence must show every
463
+ // declared feature active, the declared set must equal a passing qualification row for the host (a tree lists implied features
464
+ // too, so it cannot be compared to a row directly), and no active feature may be outside the published feature list.
465
+ function checkClosure(p, capability, declared, adoption, tree, at, out) {
466
+ const host = hostForTarget(adoption.target);
467
+ if (!p.targets.includes(adoption.target)) out.push(finding("target-not-published", `${at}: ${p.id}@${p.version} does not list the active target ${adoption.target} (published: ${p.targets.join(", ")})`));
468
+ const rows = (p.qualification ?? []).filter((r) => r.result === "pass" && r.host === host);
469
+ if (!rows.length) out.push(finding("target-not-qualified", `${at}: ${p.id}@${p.version} has no passing qualification row for host ${host ?? adoption.target}`));
470
+ if (p.ecosystem !== "cargo") return;
471
+ const versions = tree.versions.get(p.name);
472
+ if (!versions?.size) { out.push(finding("closure-missing-crate", `${at}: ${p.name} is not in the cargo tree evidence, so its active features cannot be read`)); return; }
473
+ if (versions.size !== 1 || !versions.has(p.version)) out.push(finding("closure-version-mismatch", `${at}: the cargo tree evidence has ${p.name} at [${[...versions].join(", ")}], the manifest pins ${p.version}`));
474
+ const active = new Set(tree.features.get(p.name) ?? []);
475
+ const published = new Set([...p.features, "default"]);
476
+ for (const feature of active) if (!published.has(feature)) out.push(finding("feature-unpublished", `${at}: active feature ${feature} of ${p.name} is not in the published feature list [${p.features.join(", ")}]`));
477
+ for (const feature of declared) {
478
+ if (!published.has(feature)) out.push(finding("feature-unpublished", `${at}: declared feature ${feature} of ${p.name} is not in the published feature list [${p.features.join(", ")}]`));
479
+ if (!active.has(feature)) out.push(finding("feature-not-active", `${at}: declared feature ${feature} of ${p.name} is not active in the cargo tree evidence`));
480
+ }
481
+ for (const feature of Array.isArray(capability.features) ? capability.features : []) if (!declared.includes(feature) || !active.has(feature)) out.push(finding("feature-required-missing", `${at}: ${capability.id ?? "capability"} requires feature ${feature} of ${p.name}, which is not both declared and active`));
482
+ const declaredSet = normalizeFeatures(declared);
483
+ if (rows.length && !rows.some((r) => featureRowSet(r.features) === declaredSet)) out.push(finding("feature-set-unqualified", `${at}: declared features of ${p.name} are [${declaredSet || "default only"}], which matches no passing qualification row (${rows.map((r) => `"${r.features}"`).join(", ")})`));
484
+ }
485
+
486
+ function checkReceipt({ root, adoption, entry, packages, locks, at, out }) {
487
+ const ref = entry.receipt;
488
+ if (!isObject(ref) || !nonEmptyString(ref.file) || !isHex(ref.sha256, 64)) { out.push(finding("receipt-missing", `${at}: an exercised-entrypoint receipt { file, sha256 } is required for ${entry.capability}`)); return; }
489
+ const read = readInside(root, ref.file);
490
+ if (read.error) { out.push(finding("receipt-unreadable", `${at}: receipt ${ref.file}: ${read.error}`)); return; }
491
+ if (sha256Of(read.bytes) !== ref.sha256) { out.push(finding("receipt-hash-mismatch", `${at}: receipt ${ref.file} has sha256 ${sha256Of(read.bytes)}, the adoption record says ${ref.sha256}`)); return; }
492
+ let receipt;
493
+ try { receipt = parseJsonStrict(read.bytes.toString("utf8"), ref.file); } catch (error) { out.push(finding("receipt-invalid", `${at}: receipt ${ref.file} is not machine-readable JSON (${error.message}); prose never satisfies a receipt`)); return; }
494
+ const bad = (reason) => out.push(finding("receipt-invalid", `${at}: receipt ${ref.file}: ${reason}`));
495
+ if (!isObject(receipt) || receipt.schema !== 1) { bad("schema must be 1"); return; }
496
+ if (!["test", "run"].includes(receipt.kind)) bad("kind must be test or run");
497
+ if (receipt.app !== adoption.app) bad(`app is ${JSON.stringify(receipt.app)}, expected ${JSON.stringify(adoption.app)}`);
498
+ if (receipt.capability !== entry.capability) bad(`capability is ${JSON.stringify(receipt.capability)}, expected ${entry.capability}`);
499
+ if (receipt.target !== adoption.target) bad(`target is ${JSON.stringify(receipt.target)}, expected ${adoption.target}`);
500
+ if (!Array.isArray(entry.entrypoints) || !entry.entrypoints.includes(receipt.entrypoint)) bad(`entrypoint ${JSON.stringify(receipt.entrypoint)} is not one of the adopted entrypoints`);
501
+ if (!Array.isArray(receipt.command) || !receipt.command.length || receipt.command.some((c) => !nonEmptyString(c))) bad("command must be a non-empty array of strings");
502
+ if (receipt.exitCode !== 0) bad(`exitCode must be 0, got ${JSON.stringify(receipt.exitCode)}`);
503
+ if (!nonEmptyString(receipt.finishedAt) || Number.isNaN(Date.parse(receipt.finishedAt))) bad("finishedAt must be an ISO date");
504
+ const listed = Array.isArray(receipt.packages) ? receipt.packages : [];
505
+ for (const p of packages) if (!listed.includes(`${p.id}@${p.version}`)) bad(`it does not name ${p.id}@${p.version}, so it does not prove the pinned version was exercised`);
506
+ const hashes = isObject(receipt.lockfileSha256) ? receipt.lockfileSha256 : {};
507
+ for (const [kind, lock] of locks) if (hashes[kind] !== lock.sha256) bad(`lockfileSha256.${kind} is ${hashes[kind] ?? "absent"}, the lockfile ${lock.file} is ${lock.sha256}: the receipt predates the current lock`);
508
+ }
509
+
510
+ function checkEntrypoints(root, entrypoints, at, out) {
511
+ if (!Array.isArray(entrypoints) || !entrypoints.length) { out.push(finding("entrypoint-missing", `${at}: at least one real entrypoint is required`)); return; }
512
+ for (const entry of entrypoints) {
513
+ if (!nonEmptyString(entry)) { out.push(finding("entrypoint-invalid", `${at}: an entrypoint must be a non-empty string`)); continue; }
514
+ const { file, symbol } = entrypointParts(entry);
515
+ const read = readInside(root, file);
516
+ if (read.error) { out.push(finding("entrypoint-not-a-file", `${at}: entrypoint ${entry}: ${read.error} (an entrypoint is <relative file>[#symbol])`)); continue; }
517
+ if (symbol && !new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(symbol)}(?![A-Za-z0-9_])`).test(read.bytes.toString("utf8"))) out.push(finding("entrypoint-symbol-missing", `${at}: entrypoint ${entry}: ${file} does not contain ${symbol}`));
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Check one app's adoption record against a manifest that already validated. Returns { findings, tasks } where each task lists
523
+ * its adopted capabilities and findings. Never mutates anything; reads only files inside `appRoot`.
524
+ */
525
+ export function checkAdoption({ manifest, manifestSha256, appRoot, adoption, target }) {
526
+ const findings = [];
527
+ const tasks = {};
528
+ const add = (rule, reason) => findings.push(finding(rule, reason));
529
+ if (!isObject(adoption) || adoption.schema !== 1) { add("adoption-invalid", "adoption record must be an object with schema 1"); return { findings, tasks }; }
530
+ if (!Object.hasOwn(APP_PREFIX, adoption.app)) add("adoption-invalid", `app must be one of ${Object.keys(APP_PREFIX).join(", ")}, got ${JSON.stringify(adoption.app)}`);
531
+ if (adoption.manifestSha256 !== manifestSha256) add("adoption-stale", `the adoption record was made against manifest ${adoption.manifestSha256 ?? "(none)"}, the manifest is ${manifestSha256}`);
532
+ if (target) adoption = { ...adoption, target };
533
+ if (!nonEmptyString(adoption.target)) { add("adoption-invalid", "target is required (adoption record or --target)"); return { findings, tasks }; }
534
+ if (!isObject(adoption.tasks) || !Object.keys(adoption.tasks).length) { add("adoption-invalid", "tasks must list at least one adopted task"); return { findings, tasks }; }
535
+
536
+ const readiness = deriveReadiness(manifest);
537
+ const caps = manifest.capabilities ?? {};
538
+ const packagesByKey = new Map((manifest.packages ?? []).map((p) => [packageKey(p), p]));
539
+ const packagesById = new Map();
540
+ for (const p of manifest.packages ?? []) packagesById.set(p.id, [...(packagesById.get(p.id) ?? []), p]);
541
+ const locks = new Map();
542
+ let tree = null; let treeChecked = false;
543
+ const loadTree = (out, at) => {
544
+ if (treeChecked) return tree;
545
+ treeChecked = true;
546
+ const ref = adoption.cargoTree;
547
+ if (!isObject(ref) || !nonEmptyString(ref.file) || !isHex(ref.sha256, 64)) { out.push(finding("closure-evidence-missing", `${at}: a cargoTree { file, sha256 } (a saved \`cargo tree -e features\` for ${adoption.target}) is required with any cargo package`)); return null; }
548
+ const read = readInside(appRoot, ref.file);
549
+ if (read.error) { out.push(finding("closure-evidence-unreadable", `${at}: cargoTree ${ref.file}: ${read.error}`)); return null; }
550
+ if (sha256Of(read.bytes) !== ref.sha256) { out.push(finding("closure-evidence-hash-mismatch", `${at}: cargoTree ${ref.file} has sha256 ${sha256Of(read.bytes)}, the adoption record says ${ref.sha256}`)); return null; }
551
+ tree = cargoTreeFacts(read.bytes.toString("utf8"));
552
+ return tree;
553
+ };
554
+
555
+ for (const [taskId, claim] of Object.entries(adoption.tasks)) {
556
+ const out = [];
557
+ const at = taskId;
558
+ const report = { ready: false, adopted: [], findings: out };
559
+ tasks[taskId] = report;
560
+ const state = readiness[taskId];
561
+ const task = Object.values(manifest.apps ?? {}).map((a) => a?.tasks?.[taskId]).find(Boolean);
562
+ if (!state || !task) { out.push(finding("task-unknown", `${at}: not a task in the manifest`)); continue; }
563
+ if (state.app !== adoption.app) out.push(finding("task-wrong-app", `${at}: belongs to ${state.app}, the adoption record is for ${adoption.app}`));
564
+ report.ready = state.ready;
565
+ if (task.applicable !== true) out.push(finding("task-not-applicable", `${at}: is N/A in the manifest (${task.naReason ?? "no reason"}); an N/A task is never ready and is never adopted`));
566
+ else if (!state.ready) out.push(finding("task-not-ready", `${at}: is not ready in the manifest (${state.reasons.join("; ")})`));
567
+ const adopted = Array.isArray(claim?.adopted) ? claim.adopted : null;
568
+ if (!adopted) { out.push(finding("adoption-invalid", `${at}: adopted must be an array`)); continue; }
569
+ const adoptedIds = adopted.map((a) => a?.capability);
570
+ if (task.applicable === true) for (const required of Array.isArray(task.requiresCapabilities) ? task.requiresCapabilities : []) if (!adoptedIds.includes(required)) out.push(finding("required-capability-not-adopted", `${at}: requires ${required}, which the adoption record does not adopt`));
571
+ for (const entry of adopted) {
572
+ const where = `${at} ${entry?.capability}`;
573
+ const capability = isObject(entry) ? caps[entry.capability] : null;
574
+ if (!capability) { out.push(finding("capability-unknown", `${where}: not a capability in the manifest`)); continue; }
575
+ if (capability.status !== "satisfied") { out.push(finding("capability-not-satisfied", `${where}: is ${capability.status} in the manifest, so it cannot be adopted`)); continue; }
576
+ const refs = Array.isArray(entry.packages) ? entry.packages : [];
577
+ if (!refs.length) out.push(finding("adoption-invalid", `${where}: packages must list at least one package id from the capability`));
578
+ const resolved = [];
579
+ const lockUse = new Map();
580
+ for (const raw of refs) {
581
+ // A package is named by id (`cargo:rightkit-credentials`) or by { id, features: [...] } for the features the app requests.
582
+ const ref = isObject(raw) ? raw.id : raw;
583
+ const declared = isObject(raw) && Array.isArray(raw.features) ? raw.features.filter((f) => typeof f === "string") : [];
584
+ const candidates = (capability.packageIds ?? []).map((id) => packagesByKey.get(id) ?? (packagesById.get(id)?.length === 1 ? packagesById.get(id)[0] : null)).filter(Boolean);
585
+ const p = candidates.find((c) => c.id === ref || packageKey(c) === ref);
586
+ if (!p) { out.push(finding("package-not-in-capability", `${where}: ${JSON.stringify(ref)} is not a package of this capability (${(capability.packageIds ?? []).join(", ")})`)); continue; }
587
+ resolved.push(p);
588
+ const lock = lockFor(appRoot, p.ecosystem, adoption, locks, out, where);
589
+ if (lock) { checkLockPin(lock, p, where, out); lockUse.set(lock.kind, lock); }
590
+ const closure = p.ecosystem === "cargo" ? loadTree(out, where) : { versions: new Map(), features: new Map() };
591
+ if (closure) checkClosure(p, { ...capability, id: entry.capability }, p.ecosystem === "cargo" ? declared : [], adoption, closure, where, out);
592
+ }
593
+ checkEntrypoints(appRoot, entry.entrypoints, where, out);
594
+ checkReceipt({ root: appRoot, adoption, entry, packages: resolved, locks: lockUse, at: where, out });
595
+ report.adopted.push({ capability: entry.capability, packages: resolved.map((p) => `${p.id}@${p.version}`), receipt: entry.receipt?.file ?? null });
596
+ }
597
+ }
598
+ for (const report of Object.values(tasks)) findings.push(...report.findings);
599
+ return { findings, tasks };
600
+ }
601
+
602
+ // ---- command ---------------------------------------------------------------------------------------------------------------
603
+ const flag = (args, name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
604
+
605
+ export function renderText(report) {
606
+ const out = [`adoption-evidence — manifest ${report.manifest.sha256}${report.app ? `, app ${report.app}` : ", manifest only"}`];
607
+ for (const f of report.findings) out.push(` ${f.rule.padEnd(30)} ${f.reason}`);
608
+ for (const [id, task] of Object.entries(report.tasks ?? {})) out.push(` ${id}: ${task.findings.length ? `${task.findings.length} finding(s)` : "ok"}${task.adopted.length ? ` (${task.adopted.map((a) => a.capability).join(", ")})` : ""}`);
609
+ out.push(report.ok ? "ok: no findings" : `FAILED: ${report.findings.length} finding(s)`);
610
+ return `${out.join("\n")}\n`;
611
+ }
612
+
613
+ export async function main(args, io = {}) {
614
+ const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr;
615
+ const usage = (message) => { stderr.write(`[right-release] adoption-evidence: ${message}\n`); return 2; };
616
+ const manifestPath = flag(args, "--manifest");
617
+ if (!manifestPath) return usage("--manifest <published-release-manifest.json> is required");
618
+ let loaded;
619
+ try { loaded = loadManifest(path.resolve(manifestPath)); } catch (error) { return usage(`cannot read the manifest: ${error.message}`); }
620
+ const baseDir = path.dirname(path.resolve(manifestPath));
621
+ const packetsDir = path.resolve(flag(args, "--packets") ?? path.join(baseDir, ".."));
622
+ let packetIds = null;
623
+ try { const extracted = extractPacketTaskIds(packetsDir); if (extracted.files.length) packetIds = extracted; } catch { packetIds = null; }
624
+ const findings = validateManifest(loaded.manifest, { packetIds, baseDir });
625
+ const report = { schema: 1, ok: false, manifest: { path: path.resolve(manifestPath), sha256: loaded.sha256 }, app: null, tasks: {}, findings };
626
+ const appArg = flag(args, "--app");
627
+ if (appArg) {
628
+ const appRoot = path.resolve(appArg);
629
+ if (!existsSync(appRoot) || !statSync(appRoot).isDirectory()) return usage(`--app ${appArg} is not a directory`);
630
+ const adoptionPath = path.resolve(flag(args, "--adoption") ?? path.join(appRoot, "rightkit-adoption.json"));
631
+ let adoption;
632
+ try { adoption = parseJsonStrict(readFileSync(adoptionPath, "utf8"), path.basename(adoptionPath)); } catch (error) { return usage(`cannot read the adoption record: ${error.message}`); }
633
+ report.app = adoption?.app ?? null;
634
+ // A manifest that fails its own checks is not a basis for adoption: report its findings and stop.
635
+ if (!findings.length) {
636
+ const checked = checkAdoption({ manifest: loaded.manifest, manifestSha256: loaded.sha256, appRoot, adoption, target: flag(args, "--target") });
637
+ report.tasks = checked.tasks;
638
+ findings.push(...checked.findings);
639
+ }
640
+ }
641
+ report.ok = findings.length === 0;
642
+ stdout.write(args.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
643
+ return report.ok ? 0 : 1;
644
+ }
645
+
646
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
647
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
648
+ try { process.exitCode = await main(process.argv.slice(2)); }
649
+ catch (error) { console.error(`[right-release] adoption-evidence: ${error.message}`); process.exitCode = 1; }
650
+ }