@rightkit/release 0.4.0 → 0.5.0

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.
Files changed (50) hide show
  1. package/README.md +59 -7
  2. package/adhoc-entitlements.mjs +8 -0
  3. package/adoption-evidence.mjs +650 -0
  4. package/advisory-exceptions.mjs +371 -0
  5. package/apple-profile-create.mjs +113 -10
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +1079 -0
  8. package/binary-license-notices.mjs +161 -0
  9. package/cargo-license-notices.mjs +111 -0
  10. package/cli/right-release.mjs +39 -0
  11. package/deterministic-scans.mjs +966 -0
  12. package/development-signing.mjs +2 -1
  13. package/host-upload-refusal.mjs +90 -0
  14. package/local-publish.mjs +142 -19
  15. package/local-qualify.mjs +728 -0
  16. package/local-runtime.mjs +319 -0
  17. package/lock-graph.mjs +51 -0
  18. package/managed-cargo.mjs +4 -1
  19. package/ownership-approval.mjs +23 -6
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/ownership-mandatory-set.json +158 -68
  22. package/ownership-mandatory-set.mjs +82 -6
  23. package/package.json +6 -2
  24. package/pipeline-admission.mjs +375 -0
  25. package/pipeline-config.mjs +100 -0
  26. package/pipeline-generators.json +4 -0
  27. package/pipeline-generators.mjs +137 -0
  28. package/pipeline-receipts.mjs +302 -0
  29. package/pipeline-scan.mjs +285 -0
  30. package/pipeline-tools.mjs +171 -0
  31. package/production-mode.mjs +42 -0
  32. package/publication-gate.mjs +94 -32
  33. package/publish-cargo.mjs +24 -7
  34. package/publish-swift.mjs +12 -3
  35. package/qa-control-scan.mjs +4 -0
  36. package/release-config.mjs +330 -0
  37. package/release-switch-scan.mjs +44 -5
  38. package/release.mjs +14 -4
  39. package/rightkit-ownership.json +57 -0
  40. package/rightkit-sync.mjs +8 -0
  41. package/rightkit-versions.json +191 -131
  42. package/runtime-budget-classes.json +8 -0
  43. package/runtime-budget-crates.mjs +14 -0
  44. package/runtime-budgets.mjs +128 -31
  45. package/scan-digest.mjs +107 -0
  46. package/sign-qa-bundle.mjs +210 -0
  47. package/stage-sidecar.mjs +99 -15
  48. package/swap-helper-bundle.mjs +7 -3
  49. package/workflow-yaml.mjs +177 -0
  50. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,302 @@
1
+ // Pipeline stage receipts and their verifier (architecture-v3 sections 4-5, "Receipts").
2
+ //
3
+ // A receipt records a fact about one stage of one request. It never authorizes execution alone: the
4
+ // broker (RustRight) signs receipts and issues short-lived capabilities; this module only checks that
5
+ // a chain of receipts is well formed, linked, fresh and complete. Signature checking is an injected
6
+ // interface (`verifySignature`); this module does no cryptography beyond SHA-256 hashing and invents none.
7
+ //
8
+ // Chain: ADMISSION -> SCAN_A -> ROUTE -> SCAN_B -> BUILD -> SIGN -> QUALIFIED -> PUBLISH_INTENT -> PUBLISHED.
9
+ // Each stage requires exactly its predecessor; `parents` carries that predecessor's receipt hash.
10
+ import crypto from "node:crypto";
11
+ import { canonicalJson, digestScanSet, scanClassOf } from "./deterministic-scans.mjs";
12
+
13
+ export const RECEIPT_SCHEMA = "rightkit.pipeline-receipt/1";
14
+ export const STAGES = Object.freeze(["ADMISSION", "SCAN_A", "ROUTE", "SCAN_B", "BUILD", "SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
15
+ export const OPERATIONS = Object.freeze(["dev", "build", "qualify", "sign", "publish"]);
16
+ /** The last stage each operation may reach. */
17
+ export const OPERATION_LAST_STAGE = Object.freeze({ dev: "BUILD", build: "BUILD", sign: "SIGN", qualify: "QUALIFIED", publish: "PUBLISHED" });
18
+ /** Each stage requires only these predecessors. */
19
+ export const PREREQUISITES = Object.freeze({
20
+ ADMISSION: [], SCAN_A: ["ADMISSION"], ROUTE: ["SCAN_A"], SCAN_B: ["ROUTE"], BUILD: ["SCAN_B"],
21
+ SIGN: ["BUILD"], QUALIFIED: ["SIGN"], PUBLISH_INTENT: ["QUALIFIED"], PUBLISHED: ["PUBLISH_INTENT"],
22
+ });
23
+ const RELEASE_ID_STAGES = new Set(["SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
24
+ /** Stages only a publishable request may reach: signing and everything after it. A dev or non-publishable chain stops at BUILD. */
25
+ const PUBLISHABLE_ONLY_STAGES = new Set(RELEASE_ID_STAGES);
26
+
27
+ /**
28
+ * What ADMISSION's evidence allows for `stage`, as [{ code, message }]. ADMISSION is the sole authority:
29
+ * a missing or malformed ADMISSION authorizes nothing, and `publishable` must be exactly true to reach SIGN or later.
30
+ */
31
+ function admissionProblems(admission, stage) {
32
+ const out = [];
33
+ if (!admission) return [{ code: "STAGE_NOT_AUTHORIZED", message: `no valid ADMISSION evidence authorizes ${stage}` }];
34
+ if (!Array.isArray(admission.authorizedStages) || !admission.authorizedStages.includes(stage)) out.push({ code: "STAGE_NOT_AUTHORIZED", message: `ADMISSION does not authorize ${stage}` });
35
+ if (admission.publishable !== true && PUBLISHABLE_ONLY_STAGES.has(stage)) out.push({ code: "NOT_PUBLISHABLE", message: `ADMISSION does not mark this request publishable:true; it cannot reach ${stage}` });
36
+ return out;
37
+ }
38
+
39
+ const ENVELOPE_STRINGS = ["receiptId", "jobId", "requestId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "runtimeDigest", "executor", "issuedAt", "expiresAt", "keyId", "signature"];
40
+ const HEX64 = /^[0-9a-f]{64}$/;
41
+ const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
42
+
43
+ /** Domain-separated bytes a signature covers: receipt type bound in, signature field excluded. */
44
+ export function signingBytes(receipt) {
45
+ const { signature, ...rest } = receipt;
46
+ return Buffer.from(`${RECEIPT_SCHEMA}\0${receipt.type}\0${canonicalJson(rest)}`, "utf8");
47
+ }
48
+
49
+ /** The hash a child receipt records in `parents`: covers the whole receipt including its signature. */
50
+ export function receiptHash(receipt) {
51
+ return sha256(Buffer.concat([Buffer.from(`${RECEIPT_SCHEMA}.hash\0`, "utf8"), Buffer.from(canonicalJson(receipt), "utf8")]));
52
+ }
53
+
54
+ /** In-memory request ledger for tests and single-process brokers: records which job owns a requestId. */
55
+ export function createMemoryLedger() {
56
+ const owners = new Map(); const consumed = new Set();
57
+ return {
58
+ requestOwner: (requestId) => owners.get(requestId),
59
+ isConsumed: (receiptId) => consumed.has(receiptId),
60
+ record(chain) { for (const r of chain) { if (!owners.has(r.requestId)) owners.set(r.requestId, r.jobId); consumed.add(r.receiptId); } },
61
+ };
62
+ }
63
+
64
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
65
+ const nonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
66
+ const isHex = (v) => typeof v === "string" && HEX64.test(v);
67
+ const nonEmptyArray = (v) => Array.isArray(v) && v.length > 0;
68
+
69
+ /** Stage-specific evidence checks. Each returns a list of problem messages. */
70
+ const STAGE_EVIDENCE = {
71
+ ADMISSION(e) {
72
+ const p = [];
73
+ for (const f of ["inputManifestDigest", "dependencyManifestDigest", "toolchainManifestDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
74
+ if (!nonEmptyArray(e.authorizedStages) || e.authorizedStages.some((s) => !STAGES.includes(s))) p.push("authorizedStages must list known stages");
75
+ if (typeof e.publishable !== "boolean") p.push("publishable must be a boolean");
76
+ return p;
77
+ },
78
+ SCAN_A: (e, ctx) => scanEvidence(e, "A", ctx),
79
+ ROUTE(e, ctx) {
80
+ const p = [];
81
+ const d = e.decision;
82
+ if (!isObj(d)) return ["decision is required"];
83
+ if (!["local", "ci"].includes(d.executor)) p.push("decision.executor must be local or ci");
84
+ if (!["public", "private"].includes(d.visibility)) p.push("decision.visibility must be public or private (unknown visibility is rejected)");
85
+ if (d.visibility === "private" && d.executor !== "local") p.push("a private repository never routes to CI");
86
+ if (typeof d.localGrant !== "boolean") p.push("decision.localGrant must be a boolean");
87
+ if (d.executor === "local" && !nonEmptyString(String(e.leaseFence ?? ""))) p.push("a local route needs a leaseFence");
88
+ if (d.executor === "ci" && !isObj(e.dispatchBinding)) p.push("a CI route needs a dispatchBinding");
89
+ if (!nonEmptyString(d.policyVersion)) p.push("decision.policyVersion is required");
90
+ return p;
91
+ },
92
+ SCAN_B: (e, ctx) => scanEvidence(e, "B", ctx),
93
+ BUILD(e) {
94
+ const p = [];
95
+ if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
96
+ if (!nonEmptyArray(e.artifacts) || e.artifacts.some((a) => !nonEmptyString(a?.path) || !isHex(a?.sha256))) p.push("artifacts must list path and sha256 for every output");
97
+ for (const f of ["generationEvidenceDigest", "provenanceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
98
+ if (!nonEmptyArray(e.targetMatrix)) p.push("targetMatrix must list the built targets");
99
+ return p;
100
+ },
101
+ SIGN(e, ctx) {
102
+ const p = [];
103
+ if (!["SIGN", "SEAL"].includes(e.mode)) p.push("mode must be SIGN or SEAL");
104
+ for (const f of ["inputManifestDigest", "outputManifestDigest", "signingEvidenceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
105
+ if (!nonEmptyString(e.signingProfile)) p.push("signingProfile is required");
106
+ const build = ctx.byStage.BUILD;
107
+ if (build && e.inputManifestDigest !== build.evidence?.artifactManifestDigest) p.push("inputManifestDigest does not equal the BUILD artifactManifestDigest");
108
+ return p;
109
+ },
110
+ QUALIFIED(e, ctx) {
111
+ const p = [];
112
+ if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
113
+ if (!nonEmptyArray(e.testMatrix) || e.testMatrix.some((t) => !nonEmptyString(t?.name) || t?.status !== "pass")) p.push("testMatrix must be non-empty and every row must pass");
114
+ const sign = ctx.byStage.SIGN;
115
+ if (sign && e.artifactManifestDigest !== sign.evidence?.outputManifestDigest) p.push("artifactManifestDigest does not equal the SIGN outputManifestDigest (qualification covers the final signed bytes)");
116
+ return p;
117
+ },
118
+ PUBLISH_INTENT(e, ctx) {
119
+ const p = [];
120
+ if (!isHex(e.releaseManifestDigest)) p.push("releaseManifestDigest must be a sha256 hex digest");
121
+ if (!nonEmptyArray(e.destinations) || e.destinations.some((d) => !nonEmptyString(d?.id) || !nonEmptyString(d?.identity) || !nonEmptyString(d?.version) || !isHex(d?.expectedDigest) || !nonEmptyString(d?.idempotencyKey))) {
122
+ p.push("destinations must name id, identity, version, expectedDigest and idempotencyKey");
123
+ }
124
+ const q = ctx.byStage.QUALIFIED;
125
+ if (q && e.releaseManifestDigest !== q.evidence?.artifactManifestDigest) p.push("releaseManifestDigest does not equal the QUALIFIED artifactManifestDigest");
126
+ return p;
127
+ },
128
+ PUBLISHED(e, ctx) {
129
+ const p = [];
130
+ const intent = ctx.byStage.PUBLISH_INTENT;
131
+ if (!nonEmptyArray(e.entries)) return ["entries must list every destination outcome"];
132
+ for (const d of intent?.evidence?.destinations ?? []) {
133
+ const entry = e.entries.find((x) => x?.destinationId === d.id);
134
+ if (!entry) p.push(`no entry for destination ${d.id}`);
135
+ else if (entry.outcome !== "verified") p.push(`destination ${d.id} is ${entry.outcome}, not verified`);
136
+ else if (entry.remoteDigest !== d.expectedDigest) p.push(`destination ${d.id} readback digest differs from the intent`);
137
+ }
138
+ return p;
139
+ },
140
+ };
141
+
142
+ function scanEvidence(e, cls, ctx) {
143
+ const p = [];
144
+ const other = cls === "A" ? "B" : "A";
145
+ if (e.scanClass !== cls) p.push(`scanClass must be ${cls}`);
146
+ if (!nonEmptyArray(e.scans)) return [...p, "scans must list every required scan result"];
147
+ if (cls === "A" && !isHex(e.advisorySnapshotDigest)) p.push("advisorySnapshotDigest must be a sha256 hex digest");
148
+ if (cls === "A" && !nonEmptyString(e.advisorySnapshotTime)) p.push("advisorySnapshotTime is required");
149
+ for (const s of e.scans) {
150
+ const derived = scanClassOf(s?.id);
151
+ if (s?.class === other || derived === other) p.push(`${cls === "A" ? "SCAN_A claims" : "SCAN_B carries"} class ${other} scan ${s?.id}`);
152
+ else if (s?.class !== cls) p.push(`scan ${s?.id} has class ${s?.class}, expected ${cls}`);
153
+ if (s?.status !== "pass") p.push(`scan ${s?.id} is ${s?.status}, not pass`);
154
+ }
155
+ const ids = new Set(e.scans.map((s) => s?.id));
156
+ const required = ctx.requiredScans?.[cls];
157
+ const declared = Array.isArray(e.requiredScanIds) ? e.requiredScanIds : null;
158
+ if (!declared) p.push("requiredScanIds is required");
159
+ // The required set comes only from the injected policy matrix. A receipt's own declaration is never trusted:
160
+ // without the policy, verification fails closed.
161
+ if (!Array.isArray(required) || required.some((id) => typeof id !== "string")) {
162
+ p.push(`trusted required scan set for class ${cls} is not supplied by policy; verification fails closed`);
163
+ return p;
164
+ }
165
+ for (const id of required) if (!ids.has(id)) p.push(`required class ${cls} scan ${id} is missing`);
166
+ if (declared && canonicalJson([...required].sort()) !== canonicalJson([...declared].sort())) p.push(`requiredScanIds differ from the trusted class ${cls} set`);
167
+ if (e.scanSetDigest !== digestScanSet(e.scans)) p.push("scanSetDigest does not match the listed scans");
168
+ if (cls === "B" && !nonEmptyArray(required)) p.push("SCAN_B must require at least one class B scan");
169
+ return p;
170
+ }
171
+
172
+ /**
173
+ * Verify a chain of receipts (ADMISSION first, ending at the stage being authorized).
174
+ *
175
+ * options:
176
+ * verifySignature({ receipt, keyId, signingBytes, signature }) -> boolean REQUIRED (the broker supplies keys)
177
+ * now ISO string or Date, default current time
178
+ * expected { requestId, operation, inputDigest, stage, repositoryId, commit, jobId, releaseId, targetSet } (each optional but checked when given)
179
+ * requiredScans { A: [ids], B: [ids] } trusted required sets from the policy matrix: REQUIRED; absence rejects (NO_SCAN_POLICY)
180
+ * ledger { requestOwner(requestId), isConsumed(receiptId) } REQUIRED: without it verification fails (NO_LEDGER)
181
+ * keyAllowed (keyId, receipt) -> boolean, for revocations
182
+ * Returns { ok, problems: [{ code, stage, message }], head }. Never throws on a bad receipt.
183
+ */
184
+ export function verifyReceiptChain(chain, options = {}) {
185
+ const problems = [];
186
+ const bad = (code, stage, message) => problems.push({ code, stage: stage ?? null, message });
187
+ if (typeof options.verifySignature !== "function") return { ok: false, problems: [{ code: "NO_SIGNATURE_VERIFIER", stage: null, message: "a signature verifier must be supplied by the broker" }], head: null };
188
+ if (!Array.isArray(chain) || chain.length === 0) return { ok: false, problems: [{ code: "EMPTY_CHAIN", stage: null, message: "no receipts" }], head: null };
189
+ const now = new Date(options.now ?? Date.now()).getTime();
190
+ const expected = options.expected ?? {};
191
+ const byStage = {};
192
+ let previous = null;
193
+ // Replay protection is mandatory: without a ledger the chain cannot be proven fresh, so it fails closed.
194
+ if (typeof options.ledger?.requestOwner !== "function" || typeof options.ledger?.isConsumed !== "function") {
195
+ bad("NO_LEDGER", null, "a request ledger with requestOwner and isConsumed must be supplied; replay cannot be ruled out without it");
196
+ }
197
+ // One release id for the whole chain: the ADMISSION one when it has it, else the first release-stage one.
198
+ const releaseRef = chain.find((r) => isObj(r) && nonEmptyString(r.releaseId))?.releaseId;
199
+ // ADMISSION fixes which stages may run and whether the request may publish at all.
200
+ const admission = isObj(chain[0]) && chain[0].type === "ADMISSION" && isObj(chain[0].evidence) ? chain[0].evidence : null;
201
+
202
+ chain.forEach((receipt, index) => {
203
+ const stage = isObj(receipt) ? receipt.type : null;
204
+ if (!isObj(receipt)) { bad("MALFORMED", null, `receipt ${index} is not an object`); return; }
205
+ if (receipt.schema !== RECEIPT_SCHEMA) bad("SCHEMA", stage, `schema must be ${RECEIPT_SCHEMA}`);
206
+ if (!STAGES.includes(stage)) { bad("UNKNOWN_STAGE", stage, `unknown receipt type ${JSON.stringify(stage)}`); return; }
207
+ const wantStage = STAGES[index];
208
+ if (stage !== wantStage) bad("WRONG_STAGE", stage, `position ${index} requires ${wantStage}, got ${stage}`);
209
+ for (const f of ENVELOPE_STRINGS) if (!nonEmptyString(receipt[f])) bad("ENVELOPE", stage, `${f} is required`);
210
+ if (!Number.isInteger(receipt.attempt) || receipt.attempt < 1) bad("ENVELOPE", stage, "attempt must be an integer >= 1");
211
+ if (!Array.isArray(receipt.targetSet)) bad("ENVELOPE", stage, "targetSet must be an array");
212
+ if (!OPERATIONS.includes(receipt.operation)) bad("WRONG_OPERATION", stage, `unknown operation ${JSON.stringify(receipt.operation)}`);
213
+ if (RELEASE_ID_STAGES.has(stage) && !nonEmptyString(receipt.releaseId)) bad("ENVELOPE", stage, "releaseId is required");
214
+ if (!isObj(receipt.evidence)) bad("EVIDENCE", stage, "evidence is required");
215
+
216
+ // The operation bounds how far a request may go.
217
+ const last = OPERATION_LAST_STAGE[receipt.operation];
218
+ if (last && STAGES.indexOf(stage) > STAGES.indexOf(last)) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} does not authorize ${stage}`);
219
+
220
+ // ADMISSION bounds the chain: only its authorized stages, and nothing from SIGN on unless publishable:true.
221
+ for (const x of admissionProblems(admission, stage)) bad(x.code, stage, x.message);
222
+
223
+ // Binding to the caller's expectations.
224
+ if (expected.requestId !== undefined && receipt.requestId !== expected.requestId) bad("WRONG_REQUEST_ID", stage, "requestId differs from the expected request");
225
+ if (expected.operation !== undefined && receipt.operation !== expected.operation) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} differs from expected ${expected.operation}`);
226
+ if (expected.inputDigest !== undefined && receipt.inputDigest !== expected.inputDigest) bad("WRONG_INPUT_DIGEST", stage, "inputDigest differs from the expected input");
227
+ for (const f of ["repositoryId", "commit", "jobId", "releaseId"]) if (expected[f] !== undefined && receipt[f] !== expected[f]) bad("BINDING", stage, `${f} differs from the expected value`);
228
+ if (expected.targetSet !== undefined && canonicalJson(receipt.targetSet ?? null) !== canonicalJson(expected.targetSet)) bad("BINDING", stage, "targetSet differs from the expected value");
229
+ if (releaseRef !== undefined && receipt.releaseId !== undefined && receipt.releaseId !== releaseRef) bad("BINDING", stage, "releaseId differs from the chain's release");
230
+
231
+ // Consistency across the chain.
232
+ const first = chain[0];
233
+ if (index > 0 && isObj(first)) {
234
+ for (const f of ["requestId", "jobId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "operation", "targetSet"]) {
235
+ if (f === "targetSet" ? canonicalJson(receipt[f] ?? null) !== canonicalJson(first[f] ?? null) : receipt[f] !== first[f]) bad(f === "requestId" ? "WRONG_REQUEST_ID" : f === "inputDigest" ? "WRONG_INPUT_DIGEST" : f === "operation" ? "WRONG_OPERATION" : "BINDING", stage, `${f} differs from the ADMISSION receipt`);
236
+ }
237
+ }
238
+
239
+ // Freshness.
240
+ const issued = Date.parse(receipt.issuedAt); const expires = Date.parse(receipt.expiresAt);
241
+ if (Number.isNaN(issued) || Number.isNaN(expires)) bad("ENVELOPE", stage, "issuedAt and expiresAt must be ISO timestamps");
242
+ else {
243
+ if (expires <= issued) bad("ENVELOPE", stage, "expiresAt must be after issuedAt");
244
+ if (expires <= now) bad("EXPIRED", stage, `expired at ${receipt.expiresAt}`);
245
+ if (issued > now + 60_000) bad("ENVELOPE", stage, "issuedAt is in the future");
246
+ if (previous && issued < Date.parse(previous.issuedAt)) bad("ORDER", stage, "issued before its parent receipt");
247
+ }
248
+
249
+ // Hash chain.
250
+ const wantParents = previous ? [receiptHash(previous)] : [];
251
+ if (!Array.isArray(receipt.parents) || canonicalJson(receipt.parents) !== canonicalJson(wantParents)) bad("BROKEN_CHAIN", stage, previous ? `parents must be exactly [hash of ${previous.type}]` : "ADMISSION has no parents");
252
+
253
+ // Replay (the ledger is required; see NO_LEDGER above).
254
+ if (typeof options.ledger?.requestOwner === "function" && typeof options.ledger?.isConsumed === "function") {
255
+ const owner = options.ledger.requestOwner(receipt.requestId);
256
+ if (owner !== undefined && owner !== receipt.jobId) bad("REPLAYED_REQUEST_ID", stage, "requestId already belongs to another job");
257
+ if (options.ledger.isConsumed(receipt.receiptId)) bad("REPLAYED_RECEIPT", stage, "receipt already consumed");
258
+ }
259
+
260
+ // Signature (injected).
261
+ if (options.keyAllowed && !options.keyAllowed(receipt.keyId, receipt)) bad("KEY_REVOKED", stage, `key ${receipt.keyId} is not allowed for this receipt`);
262
+ let signatureOk = false;
263
+ try { signatureOk = options.verifySignature({ receipt, keyId: receipt.keyId, signingBytes: signingBytes(receipt), signature: receipt.signature }) === true; } catch { signatureOk = false; }
264
+ if (!signatureOk) bad("BAD_SIGNATURE", stage, "signature did not verify");
265
+
266
+ byStage[stage] = receipt;
267
+ // Stage evidence last: it can look at the earlier stages collected so far.
268
+ if (isObj(receipt.evidence) && STAGE_EVIDENCE[stage]) {
269
+ for (const message of STAGE_EVIDENCE[stage](receipt.evidence, { byStage, requiredScans: options.requiredScans })) {
270
+ const code = /trusted required scan set/.test(message) ? "NO_SCAN_POLICY" : /missing|claims|carries/.test(message) && (stage === "SCAN_A" || stage === "SCAN_B") ? (/claims|carries/.test(message) ? "SCAN_CLASS_MIX" : "SCAN_INCOMPLETE") : "EVIDENCE";
271
+ bad(code, stage, message);
272
+ }
273
+ }
274
+ previous = receipt;
275
+ });
276
+
277
+ const head = chain[chain.length - 1];
278
+ if (expected.stage !== undefined) {
279
+ if (!STAGES.includes(expected.stage)) bad("WRONG_STAGE", null, `unknown expected stage ${expected.stage}`);
280
+ else if (head?.type !== expected.stage) bad("WRONG_STAGE", head?.type, `chain ends at ${head?.type}, expected ${expected.stage}`);
281
+ }
282
+ return { ok: problems.length === 0, problems, head: isObj(head) ? { type: head.type, hash: receiptHash(head) } : null };
283
+ }
284
+
285
+ /**
286
+ * Verify that a new stage may start: the chain so far must verify and end at the stage `stage` requires.
287
+ * Returns the same result shape; `authorizes` is the stage the chain authorizes next (or null).
288
+ */
289
+ export function authorizeNextStage(chain, stage, options = {}) {
290
+ const need = PREREQUISITES[stage];
291
+ if (!need) return { ok: false, problems: [{ code: "UNKNOWN_STAGE", stage, message: `unknown stage ${stage}` }], head: null, authorizes: null };
292
+ if (need.length === 0) return { ok: !chain?.length, problems: chain?.length ? [{ code: "WRONG_STAGE", stage, message: "ADMISSION starts a chain" }] : [], head: null, authorizes: chain?.length ? null : stage };
293
+ const result = verifyReceiptChain(chain, { ...options, expected: { ...(options.expected ?? {}), stage: need[0] } });
294
+ // The requested stage itself must sit inside the operation's boundary and ADMISSION's authorized stages.
295
+ const first = Array.isArray(chain) ? chain[0] : null;
296
+ const last = OPERATION_LAST_STAGE[first?.operation];
297
+ if (!last || STAGES.indexOf(stage) > STAGES.indexOf(last)) result.problems.push({ code: "WRONG_OPERATION", stage, message: `operation ${first?.operation} does not authorize ${stage}` });
298
+ const admission = first?.type === "ADMISSION" && isObj(first.evidence) ? first.evidence : null;
299
+ for (const x of admissionProblems(admission, stage)) result.problems.push({ code: x.code, stage, message: x.message });
300
+ result.ok = result.problems.length === 0;
301
+ return { ...result, authorizes: result.ok ? stage : null };
302
+ }
@@ -0,0 +1,285 @@
1
+ // M24 `--enforce` inventory: find every way an app can own, or indirectly start, a build, sign, notarize, package or upload step.
2
+ //
3
+ // Sources read (R2 framework hooks, R3 tool inventory, R1 alias traversal):
4
+ // - the release config (`right-release.config.json`: every command admitted by R1; a legacy `.mjs` config is a finding and is never opened),
5
+ // - package.json scripts and npm lifecycle hooks, with aliases followed to the helper files they reach,
6
+ // - helper and pipeline scripts under scripts/, tools/, ci/, build/, bin/, packaging/ and the app root,
7
+ // - GitHub workflows (a `# Managed by right-git` workflow is RightKit-owned and only inventoried),
8
+ // - Tauri before-dev / before-build / before-bundle hooks and signing keys in tauri.conf.json,
9
+ // - Cargo `build.rs` (signing or upload is forbidden; spawning an interpreter or RightKit is a generator that must be admitted),
10
+ // - Xcode script phases (`project.pbxproj`, XcodeGen `project.yml`).
11
+ // A finding names its file, line, stage and ownership (`app-owned` when the app wrote the pipeline step, `unknown` when ownership could
12
+ // not be established, which blocks exactly like an app-owned step). Every finding blocks `--enforce`. This is a static scan: it cannot
13
+ // prove native behaviour, and ordinary product runtime code (src/, crates) is never read except build.rs.
14
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { admitHookString, admitTauriHook } from "./pipeline-admission.mjs";
17
+ import { loadGeneratorRegistry, makeGeneratorAdmitter } from "./pipeline-generators.mjs";
18
+ import { ownershipOfCode, ruleOfCode, validateConfigPipeline } from "./pipeline-config.mjs";
19
+ import { classifyAction, dispatchesRightKit, scanTextForPipelineTools, segmentTokens, splitSegments } from "./pipeline-tools.mjs";
20
+ import { LEGACY_RELEASE_CONFIG_FILE, RELEASE_CONFIG_FILE, loadReleaseConfigSync } from "./release-config.mjs";
21
+ import { PIPELINE_DISPATCH_SUBCOMMANDS } from "./pipeline-admission.mjs";
22
+
23
+ export const PIPELINE_SCAN_SCHEMA = 1;
24
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
25
+ const MAX_FILES = 5000;
26
+ const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".git", ".cache", ".right-release", ".pnpm-store", "coverage", "Pods", "DerivedData", ".build", ".swiftpm"]);
27
+ const SCRIPT_DIRS = ["scripts", "script", "tools", "tool", "ci", ".ci", "build", "bin", "packaging", "installer", "installers", "release", "deploy", ".husky", ".githooks"];
28
+ const SHELL_EXT = /\.(?:sh|ps1|bat|cmd|command|bash|zsh)$/i;
29
+ const CODE_EXT = /\.(?:mjs|cjs|js|ts|mts|py|rb)$/i;
30
+ const HELPER_REF = /\.(?:sh|ps1|bat|cmd|command|bash|zsh|mjs|cjs|js|py)$/i;
31
+ const MANAGED_MARKER = /#\s*Managed by right-git/i;
32
+ const AUTO_LIFECYCLE = new Set(["preinstall", "install", "postinstall", "prepublish", "preprepare", "prepare", "postprepare", "prepack", "postpack", "prepublishOnly", "publish", "postpublish"]);
33
+ const posix = (value) => value.split(path.sep).join("/");
34
+
35
+ const defaultFs = {
36
+ readText: (file) => readFileSync(file, "utf8"),
37
+ exists: (file) => existsSync(file),
38
+ readdir: (dir) => readdirSync(dir, { withFileTypes: true }).map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink(), isFile: entry.isFile() })),
39
+ size: (file) => lstatSync(file).size,
40
+ };
41
+
42
+ const lineAt = (text, index) => { let line = 1; for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === "\n") line += 1; return line; };
43
+
44
+ /** Run the inventory for one app. `fs` is injectable. Returns `{ schema, app, root, findings, inventory, scanned }`. */
45
+ export function scanApp({ appRoot, registry, fs = defaultFs } = {}) {
46
+ const root = path.resolve(appRoot);
47
+ const findings = [];
48
+ const inventory = [];
49
+ const visited = new Set();
50
+ let budget = MAX_FILES;
51
+ const rel = (file) => posix(path.relative(root, file));
52
+ const add = (finding) => findings.push({ severity: "block", ownership: ownershipOfCode(finding.code), rule: ruleOfCode(finding.code), line: 1, ...finding });
53
+ const read = (file, label = rel(file)) => {
54
+ if (budget <= 0) { add({ code: "R0-too-many-files", file: label, stage: "scan", message: `more than ${MAX_FILES} files reached; the inventory is incomplete and unknown ownership blocks` }); return null; }
55
+ budget -= 1;
56
+ try {
57
+ if (fs.size && fs.size(file) > MAX_FILE_BYTES) { add({ code: "R0-unreadable", file: label, stage: "scan", message: `${label} is larger than ${MAX_FILE_BYTES} bytes and cannot be inspected` }); return null; }
58
+ return fs.readText(file);
59
+ } catch (error) {
60
+ add({ code: "R0-unreadable", file: label, stage: "scan", message: `${label} cannot be read (${error.message})` });
61
+ return null;
62
+ }
63
+ };
64
+
65
+ // ---- package.json ------------------------------------------------------------------------------------------------------------
66
+ let scripts;
67
+ let packageText = "";
68
+ const packageFile = path.join(root, "package.json");
69
+ if (fs.exists(packageFile)) {
70
+ packageText = read(packageFile) ?? "";
71
+ try {
72
+ const parsed = JSON.parse(packageText || "{}");
73
+ if (parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts)) scripts = parsed.scripts;
74
+ } catch (error) { add({ code: "R0-package-json", file: "package.json", stage: "scan", message: `package.json is not valid JSON (${error.message}); script ownership is unknown` }); }
75
+ }
76
+
77
+ // ---- release config (R1) -----------------------------------------------------------------------------------------------------
78
+ const configFile = path.join(root, RELEASE_CONFIG_FILE);
79
+ const legacyFile = path.join(root, LEGACY_RELEASE_CONFIG_FILE);
80
+ let config;
81
+ if (fs.exists(legacyFile)) {
82
+ add({ code: "R0-legacy-config", file: LEGACY_RELEASE_CONFIG_FILE, stage: "config", ownership: "app-owned", message: `${LEGACY_RELEASE_CONFIG_FILE} is an executable release config; it is never imported, evaluated or converted. Replace it with ${RELEASE_CONFIG_FILE}.` });
83
+ inventory.push({ kind: "config", file: LEGACY_RELEASE_CONFIG_FILE, ownership: "app-owned-executable" });
84
+ }
85
+ let admitGenerator;
86
+ const useRegistry = registry ?? (() => { try { return loadGeneratorRegistry(); } catch { return undefined; } })();
87
+ if (fs.exists(configFile)) {
88
+ inventory.push({ kind: "config", file: RELEASE_CONFIG_FILE, ownership: "rightkit-schema" });
89
+ try {
90
+ const loaded = loadReleaseConfigSync(configFile, { validatePipeline: false, readText: (target) => fs.readText(target) });
91
+ config = loaded.config;
92
+ for (const finding of validateConfigPipeline(config, { scripts, registry: useRegistry, appRoot: root, lineOf: loaded.lineOf })) {
93
+ add({ code: finding.code, file: RELEASE_CONFIG_FILE, line: finding.line, stage: finding.stage, ownership: finding.ownership, message: `${finding.pointer}: ${finding.message}` });
94
+ }
95
+ } catch (error) {
96
+ add({ code: `R0-${String(error.code ?? "config-invalid").toLowerCase().replace(/^release_config_/, "config-").replaceAll("_", "-")}`, file: RELEASE_CONFIG_FILE, line: error.line ?? 1, stage: "config", ownership: "unknown", message: error.message });
97
+ }
98
+ } else if (!fs.exists(legacyFile)) {
99
+ add({ code: "R0-config-missing", file: RELEASE_CONFIG_FILE, stage: "config", ownership: "unknown", message: `${RELEASE_CONFIG_FILE} is missing: the app's pipeline ownership cannot be established` });
100
+ }
101
+ if (useRegistry) admitGenerator = makeGeneratorAdmitter({ registry: useRegistry, app: config?.app ?? path.basename(root), appRoot: root, readBytes: (file) => Buffer.from(fs.readText(file), "utf8") });
102
+ const aliasOptions = { scripts, admitGenerator };
103
+
104
+ // ---- helper queue (scripts reachable from pipeline entries, scripts directories) ----------------------------------------------
105
+ const queue = [];
106
+ const enqueue = (file, via) => { const key = path.resolve(file); if (!visited.has(key)) { visited.add(key); queue.push({ file: key, via }); } };
107
+ const refsIn = (text) => {
108
+ const refs = new Set();
109
+ for (const line of String(text).split(/\r?\n/)) {
110
+ for (const segment of splitSegments(line)) {
111
+ for (const token of segmentTokens(segment)) {
112
+ if (!HELPER_REF.test(token) || token.startsWith("-") || /^[a-z]+:\/\//i.test(token)) continue;
113
+ const normalized = token.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
114
+ if (path.posix.isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized) || normalized.split("/").includes("..") || normalized.includes("$") || normalized.includes("*")) continue;
115
+ refs.add(normalized);
116
+ }
117
+ }
118
+ }
119
+ return [...refs];
120
+ };
121
+
122
+ if (scripts) {
123
+ const scriptNames = Object.keys(scripts);
124
+ for (const name of scriptNames) {
125
+ const body = scripts[name];
126
+ const lineOfScript = () => { const at = packageText.search(new RegExp(`"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`)); return at >= 0 ? lineAt(packageText, at) : 1; };
127
+ if (typeof body !== "string") { add({ code: "R1-alias-unknown", file: "package.json", line: lineOfScript(), stage: "script", message: `package script "${name}" is not a string` }); continue; }
128
+ inventory.push({ kind: "package-script", file: "package.json", name, ownership: "app-script" });
129
+ for (const hit of scanTextForPipelineTools(body, { kind: "shell" })) {
130
+ add({ code: `R3-${hit.id}`, file: "package.json", line: lineOfScript(), stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `script "${name}" runs ${hit.tool} (${hit.stage}); build, sign, notarize, package and upload run only inside RightKit (right-release)` });
131
+ }
132
+ const lifecycle = AUTO_LIFECYCLE.has(name) || (/^(?:pre|post)(.+)$/.test(name) && scriptNames.includes(name.replace(/^(?:pre|post)/, "")));
133
+ if (lifecycle) {
134
+ const admitted = admitHookString(body, aliasOptions, [name]);
135
+ if (!admitted.admitted) add({ code: admitted.code.replace(/^R\d/, "R2"), file: "package.json", line: lineOfScript(), stage: "hook", message: `npm lifecycle hook "${name}": ${admitted.reason}` });
136
+ }
137
+ for (const ref of refsIn(body)) enqueue(path.join(root, ref), `package.json scripts.${name}`);
138
+ }
139
+ }
140
+
141
+ // ---- script directories and root-level scripts -------------------------------------------------------------------------------
142
+ const walkDir = (dir, depth) => {
143
+ let entries;
144
+ try { entries = fs.readdir(dir); } catch { return; }
145
+ for (const entry of entries) {
146
+ const full = path.join(dir, entry.name);
147
+ if (entry.isDirectory) { if (depth > 0 && !SKIP_DIRS.has(entry.name)) walkDir(full, depth - 1); continue; }
148
+ if (!(SHELL_EXT.test(entry.name) || CODE_EXT.test(entry.name) || /^(?:Makefile|justfile)$/i.test(entry.name))) continue;
149
+ if (entry.isSymlink) { add({ code: "R0-symlink", file: rel(full), stage: "scan", message: `${rel(full)} is a symlink; a linked script's owner cannot be established` }); continue; }
150
+ enqueue(full, `pipeline directory ${rel(dir) || "."}`);
151
+ }
152
+ };
153
+ for (const dir of SCRIPT_DIRS) if (fs.exists(path.join(root, dir))) walkDir(path.join(root, dir), 4);
154
+ try {
155
+ for (const entry of fs.readdir(root)) {
156
+ if (entry.isDirectory || entry.isSymlink) continue;
157
+ if (SHELL_EXT.test(entry.name) || /^(?:Makefile|justfile)$/i.test(entry.name) || /^(?:package|build|release|sign|notarize|install|deploy)[-_.a-z0-9]*\.(?:mjs|cjs|js|py)$/i.test(entry.name)) enqueue(path.join(root, entry.name), "app root");
158
+ }
159
+ } catch { /* an unreadable root is reported by the reads above */ }
160
+
161
+ // The queue grows while it drains: a helper's own references join it.
162
+ for (let index = 0; index < queue.length; index += 1) {
163
+ const { file, via } = queue[index];
164
+ const label = rel(file);
165
+ if (!fs.exists(file)) { add({ code: "R1-helper-missing", file: label, stage: "script", ownership: "unknown", message: `${label} is reached from ${via} but does not exist; an unknown reachable script blocks` }); continue; }
166
+ const text = read(file, label);
167
+ if (text === null) continue;
168
+ const isShell = SHELL_EXT.test(file) || /(?:Makefile|justfile)$/i.test(file);
169
+ inventory.push({ kind: "helper-script", file: label, via, ownership: "app-owned" });
170
+ for (const hit of scanTextForPipelineTools(text, { kind: isShell ? "shell" : "code" })) {
171
+ add({ code: `R3-${hit.id}`, file: label, line: hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `${label} (from ${via}) runs ${hit.tool} (${hit.stage}); this is an app-owned pipeline script` });
172
+ }
173
+ if (isShell) for (const ref of refsIn(text)) enqueue(path.resolve(path.dirname(file), ref).startsWith(root) && fs.exists(path.resolve(path.dirname(file), ref)) ? path.resolve(path.dirname(file), ref) : path.join(root, ref), `${label}`);
174
+ }
175
+
176
+ // ---- GitHub workflows --------------------------------------------------------------------------------------------------------
177
+ const workflowDir = path.join(root, ".github", "workflows");
178
+ if (fs.exists(workflowDir)) {
179
+ for (const entry of fs.readdir(workflowDir)) {
180
+ if (entry.isDirectory || !/\.ya?ml$/i.test(entry.name)) continue;
181
+ const file = path.join(workflowDir, entry.name);
182
+ const label = rel(file);
183
+ const text = read(file, label);
184
+ if (text === null) continue;
185
+ const managed = MANAGED_MARKER.test(text.split(/\r?\n/).slice(0, 10).join("\n"));
186
+ inventory.push({ kind: "workflow", file: label, ownership: managed ? "rightkit-managed" : "app-owned" });
187
+ if (managed) continue;
188
+ for (const hit of scanTextForPipelineTools(text, { kind: "shell" })) {
189
+ add({ code: `R3-${hit.id}`, file: label, line: hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `workflow ${label} runs ${hit.tool} (${hit.stage}); pipeline workflows are generated and owned by right-git (carry the "Managed by right-git" marker)` });
190
+ }
191
+ text.split(/\r?\n/).forEach((lineText, i) => {
192
+ const uses = /^\s*(?:-\s+)?uses\s*:\s*(\S+)/.exec(lineText);
193
+ const hit = uses ? classifyAction(uses[1]) : null;
194
+ if (hit) add({ code: `R3-${hit.id}`, file: label, line: i + 1, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `workflow ${label} uses ${hit.tool}, an action that ${hit.stage}s on its own` });
195
+ });
196
+ }
197
+ }
198
+
199
+ // ---- framework hooks: Tauri, build.rs, Xcode --------------------------------------------------------------------------------
200
+ const projectFiles = [];
201
+ const find = (dir, depth) => {
202
+ let entries;
203
+ try { entries = fs.readdir(dir); } catch { return; }
204
+ for (const entry of entries) {
205
+ const full = path.join(dir, entry.name);
206
+ if (entry.isDirectory) {
207
+ if (/\.xcodeproj$/i.test(entry.name)) { projectFiles.push({ kind: "xcode", file: path.join(full, "project.pbxproj") }); continue; }
208
+ if (depth > 0 && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) find(full, depth - 1);
209
+ continue;
210
+ }
211
+ if (/^tauri(?:\.[A-Za-z0-9_-]+)?\.conf\.json$/i.test(entry.name)) projectFiles.push({ kind: "tauri", file: full });
212
+ else if (entry.name === "build.rs") projectFiles.push({ kind: "build-rs", file: full });
213
+ else if (entry.name === "project.yml" || entry.name === "project.yaml") projectFiles.push({ kind: "xcodegen", file: full });
214
+ }
215
+ };
216
+ find(root, 4);
217
+ for (const item of projectFiles) {
218
+ if (!fs.exists(item.file)) continue;
219
+ const label = rel(item.file);
220
+ const text = read(item.file, label);
221
+ if (text === null) continue;
222
+ if (item.kind === "tauri") scanTauriConf(text, label);
223
+ else if (item.kind === "build-rs") scanBuildRs(text, label);
224
+ else scanXcode(text, label, item.kind);
225
+ }
226
+
227
+ function scanTauriConf(text, label) {
228
+ inventory.push({ kind: "tauri-config", file: label, ownership: "framework-config" });
229
+ let parsed;
230
+ try { parsed = JSON.parse(text); } catch (error) { add({ code: "R0-unreadable", file: label, stage: "hook", message: `${label} is not valid JSON (${error.message}); its hooks are unknown` }); return; }
231
+ const keyLine = (key) => { const at = text.search(new RegExp(`"${key}"\\s*:`)); return at >= 0 ? lineAt(text, at) : 1; };
232
+ for (const key of ["beforeDevCommand", "beforeBuildCommand", "beforeBundleCommand"]) {
233
+ const entry = parsed?.build?.[key];
234
+ if (entry === undefined || entry === null || entry === "") continue;
235
+ const problem = admitTauriHook(entry, aliasOptions, `${label} build.${key}`);
236
+ if (problem) add({ code: problem.code.replace(/^R\d/, "R2"), file: label, line: keyLine(key), stage: "hook", message: problem.reason });
237
+ }
238
+ const windows = parsed?.bundle?.windows ?? {};
239
+ if (windows.signCommand !== undefined && windows.signCommand !== null) add({ code: "R3-tauri-sign-hook", file: label, line: keyLine("signCommand"), stage: "sign", ownership: "app-owned", message: `${label} bundle.windows.signCommand is a sign hook; signing is RightKit's` });
240
+ if (typeof windows.certificateThumbprint === "string" && windows.certificateThumbprint) add({ code: "R3-tauri-sign-config", file: label, line: keyLine("certificateThumbprint"), stage: "sign", ownership: "app-owned", message: `${label} bundle.windows.certificateThumbprint selects a signing identity; signing is RightKit's` });
241
+ const identity = parsed?.bundle?.macOS?.signingIdentity;
242
+ if (typeof identity === "string" && identity !== "-" && identity !== "") add({ code: "R3-tauri-sign-config", file: label, line: keyLine("signingIdentity"), stage: "sign", ownership: "app-owned", message: `${label} bundle.macOS.signingIdentity selects a signing identity; signing is RightKit's` });
243
+ }
244
+
245
+ function scanBuildRs(text, label) {
246
+ inventory.push({ kind: "build-rs", file: label, ownership: "product-compile" });
247
+ for (const hit of scanTextForPipelineTools(text, { kind: "code" })) {
248
+ add({ code: `R2-build-rs-${hit.id}`, file: label, line: hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `${label} runs ${hit.tool} (${hit.stage}); signing, packaging or upload from build.rs is forbidden` });
249
+ }
250
+ text.split(/\r?\n/).forEach((lineText, i) => {
251
+ if (/^\s*\/\//.test(lineText)) return;
252
+ const spawn = /Command::new\(\s*"([^"]+)"/.exec(lineText);
253
+ if (!spawn) return;
254
+ const name = path.basename(spawn[1]).toLowerCase().replace(/\.(?:exe|cmd|bat)$/, "");
255
+ if (name === "rightkit" || name === "right-release") add({ code: "R2-build-rs-dispatch", file: label, line: i + 1, stage: "hook", ownership: "app-owned", message: `${label} starts ${name} from build.rs; a generator cannot dispatch another pipeline` });
256
+ else if (["node", "python", "python3", "py", "bash", "sh", "zsh", "pwsh", "powershell", "cmd", "pnpm", "npm", "npx", "yarn", "deno", "bun", "ruby", "perl"].includes(name)) add({ code: "R4-build-rs-generator", file: label, line: i + 1, stage: "generator", ownership: "unknown", message: `${label} runs ${name} from build.rs; a pipeline generator must be an admitted shared step (pipeline-generators.json), not app code` });
257
+ });
258
+ }
259
+
260
+ function scanXcode(text, label, kind) {
261
+ inventory.push({ kind: kind === "xcode" ? "xcode-project" : "xcodegen-spec", file: label, ownership: "framework-config" });
262
+ let bodies;
263
+ if (kind === "xcode") {
264
+ bodies = [...text.matchAll(/shellScript\s*=\s*"((?:[^"\\]|\\.)*)"\s*;/g)].map((match) => ({ line: lineAt(text, match.index), script: match[1].replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\") }));
265
+ } else bodies = [{ line: 1, script: text }];
266
+ for (const body of bodies) {
267
+ for (const hit of scanTextForPipelineTools(body.script, { kind: "shell" })) {
268
+ add({ code: `R2-xcode-script-${hit.id}`, file: label, line: kind === "xcode" ? body.line : hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `${label} script phase runs ${hit.tool} (${hit.stage}); archive, export, signing and upload belong to RightKit` });
269
+ }
270
+ for (const segment of body.script.split(/\r?\n/).flatMap(splitSegments)) {
271
+ const tokens = segmentTokens(segment);
272
+ if (dispatchesRightKit(tokens) && (tokens[0] === "rightkit" || PIPELINE_DISPATCH_SUBCOMMANDS.has(tokens[1]))) add({ code: "R2-xcode-script-dispatch", file: label, line: kind === "xcode" ? body.line : 1, stage: "hook", ownership: "app-owned", message: `${label} script phase starts a RightKit pipeline run; a hook cannot dispatch another pipeline` });
273
+ }
274
+ }
275
+ }
276
+
277
+ findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
278
+ return { schema: PIPELINE_SCAN_SCHEMA, app: config?.app ?? path.basename(root), root, findings, inventory, scanned: visited.size };
279
+ }
280
+
281
+ export function formatScan(report) {
282
+ const lines = [`M24 pipeline scan: ${report.app} (${report.root}) - ${report.findings.length} blocking finding(s), ${report.inventory.length} inventoried entrypoint(s)`];
283
+ for (const finding of report.findings) lines.push(` ${finding.file}:${finding.line} [${finding.code}] ${finding.stage} (${finding.ownership}) ${finding.message}`);
284
+ return `${lines.join("\n")}\n`;
285
+ }