@rightkit/release 0.3.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.
Files changed (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // right-release ownership-mandatory: Table 1 mandatory-owner-set check (plan task 5.1).
3
3
  //
4
+ // This evaluates declarations only; it does not establish active target/features, lock resolution or production adoption.
4
5
  // A registered app must declare every Table 1 owner (rows M1-M23) at or above the required version,
5
6
  // from the registry. Report mode (default) prints per-app findings and exits 0; --enforce exits 1
6
7
  // on any missing owner, below-required version, or path/git/patch override ("not-on-registry").
@@ -11,7 +12,14 @@
11
12
  // [--amendment v2.1] [--enforce] [--json]
12
13
  //
13
14
  // App-side declaration (optional): <app>/rightkit-mandatory.json
14
- // { "profile": "desktop", "notApplicable": { "M16": "reason text" } }
15
+ // { "profile": "desktop", "notApplicable": { "M16": "reason text" },
16
+ // "M22": { "role": "client|host|both|absent", "entrypoints": ["src-tauri/src/agent.rs#register_tools"] } }
17
+ //
18
+ // PK17.scanner (report only, never enforcement): rows M21 and M23 stay `ownerPending` and never fail, but each lists its
19
+ // `pendingOwners` (M21: rightkit-fs, rightkit-daemon, rightkit-qa; M23: @rightkit/release, rightkit-daemon) and the report shows
20
+ // how each app's declarations measure up (`pending[].candidateOwners`, `enforced: false`). Moving those owners into `rows` and
21
+ // raising minimums is the separate activation step (PK17.activation). The M22 applicability record (PK15-2) is validated and
22
+ // reported under `applicability.M22`; an invalid or contradictory record is a `problem`, which never changes the exit code.
15
23
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
16
24
  import path from "node:path";
17
25
  import { fileURLToPath } from "node:url";
@@ -25,14 +33,51 @@ const SKIP_DIRS = new Set(["node_modules", "target", ".git", "rk-snapshot", "ven
25
33
  const MAX_MANIFESTS = 2000;
26
34
 
27
35
  // ---- versions -------------------------------------------------------------------------------
28
- /** Lower bound of the first comparator in a Cargo/npm requirement, or null when none can be proven. */
36
+ /**
37
+ * Proven minimum of a Cargo/npm requirement: a version every satisfying version is guaranteed >=, or null when
38
+ * none can be proven (upper-only `<9`, `*`, `latest`, malformed, or a `||` branch with no proven minimum).
39
+ * Space/comma comparators AND (proven minimum = max of lower contributions; `<`/`<=` contribute none); `||`
40
+ * branches union (proven minimum = min of branch minima, null if any branch is unprovable). Hyphen ranges
41
+ * contribute their lower end. `>`, `>=`, `=`, `^`, `~`, bare & wildcard partials contribute their version
42
+ * (missing/`x`/`*` components read as 0), so `>1.2.3` proves 1.2.3 (sound, never above the true floor).
43
+ */
29
44
  export function lowerBound(spec) {
30
45
  if (typeof spec !== "string") return null;
31
- let s = spec.trim().replace(/^npm:(?:@[^@/]+\/)?[^@]+@/, "");
32
- const m = /(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?/.exec(s);
33
- if (!m || /^(\*|latest|x|)$/i.test(s)) return null;
34
- const n = (v) => (v === undefined || /[xX*]/.test(v) ? 0 : Number(v));
35
- return { major: Number(m[1]), minor: n(m[2]), patch: n(m[3]), pre: m[4] ?? null };
46
+ const s = spec.trim().replace(/^npm:(?:@[^@/]+\/)?[^@]+@/, "").trim();
47
+ if (!s || /^(\*|latest|x)$/i.test(s)) return null;
48
+ const part = (v) => (v === undefined || /[xX*]/.test(v) ? 0 : Number(v));
49
+ const versionOf = (text) => {
50
+ const m = /^v?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
51
+ if (m?.[4] && m[4].split(".").some((id) => !id || (/^\d+$/.test(id) && id.length > 1 && id.startsWith("0")))) return null;
52
+ return m ? { major: Number(m[1]), minor: part(m[2]), patch: part(m[3]), pre: m[4] ?? null } : null;
53
+ };
54
+ const branchBound = (branch) => {
55
+ const raw = branch.split(/[\s,]+/).filter(Boolean);
56
+ const tokens = [];
57
+ for (let i = 0; i < raw.length; i += 1) {
58
+ if (/^(>=|<=|>|<|=|\^|~)$/.test(raw[i]) && i + 1 < raw.length) { tokens.push(raw[i] + raw[i + 1]); i += 1; }
59
+ else tokens.push(raw[i]);
60
+ }
61
+ if (!tokens.length) return null;
62
+ const lowers = [];
63
+ let skipUpperEnd = false;
64
+ for (const token of tokens) {
65
+ if (token === "-") { skipUpperEnd = true; continue; }
66
+ if (skipUpperEnd) { skipUpperEnd = false; continue; }
67
+ if (/^(\*|x|latest)$/i.test(token)) continue;
68
+ const m = /^(>=|<=|>|<|=|\^|~)?(.+)$/.exec(token);
69
+ const version = m && versionOf(m[2]);
70
+ if (!version) return null;
71
+ if (m[1] === "<" || m[1] === "<=") continue;
72
+ lowers.push(version);
73
+ }
74
+ return lowers.length ? lowers.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best)) : null;
75
+ };
76
+ const branches = s.split("||").map((b) => b.trim());
77
+ if (branches.some((b) => !b)) return null;
78
+ const bounds = branches.map(branchBound);
79
+ if (bounds.some((b) => b === null)) return null;
80
+ return bounds.reduce((best, v) => (compareVersions(v, best) < 0 ? v : best));
36
81
  }
37
82
  export const parseVersion = lowerBound;
38
83
  export function compareVersions(a, b) {
@@ -40,7 +85,19 @@ export function compareVersions(a, b) {
40
85
  if (a.pre === b.pre) return 0;
41
86
  if (a.pre === null) return 1;
42
87
  if (b.pre === null) return -1;
43
- return a.pre < b.pre ? -1 : 1;
88
+ const left = a.pre.split("."), right = b.pre.split(".");
89
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
90
+ if (left[i] === undefined) return -1;
91
+ if (right[i] === undefined) return 1;
92
+ if (left[i] === right[i]) continue;
93
+ const an = /^\d+$/.test(left[i]), bn = /^\d+$/.test(right[i]);
94
+ if (an && bn) {
95
+ const av = BigInt(left[i]), bv = BigInt(right[i]);
96
+ if (av !== bv) return av < bv ? -1 : 1;
97
+ } else if (an !== bn) return an ? -1 : 1;
98
+ else return left[i] < right[i] ? -1 : 1;
99
+ }
100
+ return 0;
44
101
  }
45
102
  const show = (v) => `${v.major}.${v.minor}.${v.patch}${v.pre ? `-${v.pre}` : ""}`;
46
103
 
@@ -75,21 +132,21 @@ export function readManifest(file, root) {
75
132
  try { doc = parseToml(text); } catch (error) { return { rel, facts, error: `unparseable Cargo.toml: ${error.message}` }; }
76
133
  workspaceDeps = doc.workspace?.dependencies;
77
134
  const wsTable = workspaceDeps ?? {};
78
- const sections = (table, dev) => {
135
+ const sections = (table, dev, target = null) => {
79
136
  for (const [section, isDev] of [["dependencies", dev], ["build-dependencies", dev], ["dev-dependencies", true]]) {
80
- for (const [key, raw] of Object.entries(table?.[section] ?? {})) add(key, raw, isDev);
137
+ for (const [key, raw] of Object.entries(table?.[section] ?? {})) add(key, raw, isDev, target);
81
138
  }
82
139
  };
83
- const add = (key, raw, dev) => {
140
+ const add = (key, raw, dev, target) => {
84
141
  let value = raw;
85
142
  if (value && typeof value === "object" && value.workspace === true && wsTable[key] !== undefined) value = { ...(typeof wsTable[key] === "object" ? wsTable[key] : { version: wsTable[key] }), ...value };
86
143
  const name = typeof value === "object" && typeof value.package === "string" ? value.package : key;
87
144
  const spec = typeof value === "string" ? value : value?.version;
88
145
  const unresolved = raw && typeof raw === "object" && raw.workspace === true && wsTable[key] === undefined;
89
- facts.push({ owner: name, ecosystem: "cargo", kind: isNonRegistry(value) ? "override" : "registry", dev, spec: spec ?? null, via: isNonRegistry(value) ? ("path" in value ? "path dependency" : "git dependency") : "dependency", file: rel, ...(unresolved ? { inheritsWorkspace: key } : {}) });
146
+ facts.push({ target, optional: Boolean(value?.optional), owner: name, ecosystem: "cargo", kind: isNonRegistry(value) ? "override" : "registry", dev, spec: spec ?? null, via: isNonRegistry(value) ? ("path" in value ? "path dependency" : "git dependency") : "dependency", file: rel, ...(unresolved ? { inheritsWorkspace: key } : {}) });
90
147
  };
91
148
  sections(doc, false);
92
- for (const target of Object.values(doc.target ?? {})) sections(target, false);
149
+ for (const [target, table] of Object.entries(doc.target ?? {})) sections(table, false, target);
93
150
  for (const [key, raw] of Object.entries(wsTable)) {
94
151
  // workspace.dependencies only counts through members that opt in (above); record path/git overrides here.
95
152
  if (isNonRegistry(raw)) facts.push({ owner: typeof raw.package === "string" ? raw.package : key, ecosystem: "cargo", kind: "override", dev: false, spec: raw.version ?? null, via: "workspace.dependencies path/git", file: rel, declarationOnly: true });
@@ -107,7 +164,7 @@ export function readManifest(file, root) {
107
164
  const alias = typeof spec === "string" ? /^npm:((?:@[^@/]+\/)?[^@]+)@/.exec(spec) : null;
108
165
  const owner = alias ? alias[1] : name;
109
166
  const local = typeof spec === "string" && /^(workspace:|file:|link:|portal:|git\+|git:|github:|https?:)/.test(spec);
110
- facts.push({ owner, ecosystem: "npm", kind: local ? "override" : "registry", dev, spec: typeof spec === "string" ? spec : null, via: local ? `${spec.split(":")[0]}: specifier` : "dependency", file: rel });
167
+ facts.push({ declarationSection: section, optional: section === "optionalDependencies", peer: section === "peerDependencies", owner, ecosystem: "npm", kind: local ? "override" : "registry", dev, spec: typeof spec === "string" ? spec : null, via: local ? `${spec.split(":")[0]}: specifier` : "dependency", file: rel });
111
168
  }
112
169
  }
113
170
  for (const table of [doc.pnpm?.overrides, doc.overrides, doc.resolutions]) {
@@ -164,7 +221,7 @@ function judge(req, facts) {
164
221
  const counted = mine.filter((f) => f.kind === "registry" && (req.allowDev || !f.dev));
165
222
  const devOnly = !counted.length && mine.some((f) => f.kind === "registry" && f.dev);
166
223
  const min = lowerBound(req.min);
167
- const base = { owner: req.name, ecosystem: req.ecosystem, rows: [...new Set(req.rows)], required: req.min };
224
+ const base = { evidenceKind: "declaration", qualification: "unverified", activeTarget: null, lockResolution: null, productionUse: null, declarations: mine.map((f) => ({ file: f.file, spec: f.spec, dev: f.dev, target: f.target ?? null, optional: f.optional ?? false, peer: f.peer ?? false })), owner: req.name, ecosystem: req.ecosystem, rows: [...new Set(req.rows)], required: req.min };
168
225
  const bounds = counted.map((f) => ({ f, v: lowerBound(f.spec) }));
169
226
  const best = bounds.filter((b) => b.v).sort((a, b) => compareVersions(b.v, a.v))[0];
170
227
  const declaredAt = best ? show(best.v) : null;
@@ -181,9 +238,50 @@ function judge(req, facts) {
181
238
  return { ...base, status: "ok", declared: declaredAt, evidence: [] };
182
239
  }
183
240
 
241
+ // An entrypoint is `<relative file>`, optionally `#<symbol>` or `::<symbol>`; a bare token with no path separator or extension
242
+ // is a symbol or command name and is recorded unverified. A file part must stay inside the app root and exist.
243
+ const entrypointFile = (entry) => entry.split(/#|::/)[0];
244
+ const looksLikePath = (text) => /[\\/]/.test(text) || /\.[A-Za-z0-9]+$/.test(text);
245
+
246
+ /** Validate one applicability record (`M22: { role, entrypoints }`); report-only, never throws. */
247
+ export function validateApplicabilityRecord(id, raw, { spec, root }) {
248
+ const result = { row: id, status: "unrecorded", role: null, entrypoints: [], unverifiedEntrypoints: 0, problems: [] };
249
+ if (raw === undefined) return result;
250
+ const bad = (reason) => result.problems.push(`${id}: ${reason}`);
251
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) { bad("applicability record must be an object { role, entrypoints }"); result.status = "invalid"; return result; }
252
+ const extra = Object.keys(raw).filter((key) => key !== "role" && key !== "entrypoints");
253
+ if (extra.length) bad(`unknown applicability record key(s): ${extra.join(", ")}`);
254
+ const roles = spec?.roles ?? [];
255
+ if (!roles.includes(raw.role)) bad(`role must be one of ${roles.join("|")}, got ${JSON.stringify(raw.role)}`);
256
+ else result.role = raw.role;
257
+ if (!Array.isArray(raw.entrypoints)) bad("entrypoints must be an array");
258
+ else {
259
+ const seen = new Set();
260
+ for (const entry of raw.entrypoints) {
261
+ if (typeof entry !== "string" || !entry.trim()) { bad("every entrypoint must be a non-empty string"); continue; }
262
+ const text = entry.trim();
263
+ if (seen.has(text)) { bad(`entrypoint ${JSON.stringify(text)} is listed twice`); continue; }
264
+ seen.add(text);
265
+ result.entrypoints.push(text);
266
+ const file = entrypointFile(text);
267
+ if (!file) { bad(`entrypoint ${JSON.stringify(text)} names no file or symbol`); continue; }
268
+ if (!looksLikePath(file)) { result.unverifiedEntrypoints += 1; continue; }
269
+ const normalized = file.replace(/\\/g, "/");
270
+ if (path.isAbsolute(file) || /^[A-Za-z]:/.test(file) || normalized.split("/").includes("..")) { bad(`entrypoint ${JSON.stringify(text)} must be a relative path inside the app`); continue; }
271
+ let isFile = false;
272
+ try { isFile = statSync(path.join(root, normalized)).isFile(); } catch { /* missing */ }
273
+ if (!isFile) bad(`entrypoint ${JSON.stringify(text)}: file not found under the app root`);
274
+ }
275
+ if (result.role === "absent" && raw.entrypoints.length) bad("role absent must list no entrypoints");
276
+ if (result.role && result.role !== "absent" && !raw.entrypoints.length) bad(`role ${result.role} needs at least one real entrypoint`);
277
+ }
278
+ result.status = result.problems.length ? "invalid" : "recorded";
279
+ return result;
280
+ }
281
+
184
282
  export function loadAppDeclaration(root, data) {
185
283
  const file = path.join(root, data.appDeclarationFile);
186
- if (!existsSync(file)) return { profile: data.defaultProfile, notApplicable: {}, problems: [] };
284
+ if (!existsSync(file)) return { profile: data.defaultProfile, notApplicable: {}, records: {}, problems: [] };
187
285
  const problems = [];
188
286
  let doc = {};
189
287
  try { doc = JSON.parse(readFileSync(file, "utf8")); } catch (error) { problems.push(`${data.appDeclarationFile} unparseable: ${error.message}`); }
@@ -192,7 +290,9 @@ export function loadAppDeclaration(root, data) {
192
290
  if (typeof reason === "string" && reason.trim().length >= 10) notApplicable[id] = reason.trim();
193
291
  else problems.push(`${id}: N/A declaration needs a reason (>= 10 chars); ignored`);
194
292
  }
195
- return { profile: doc.profile ?? data.defaultProfile, notApplicable, problems };
293
+ const records = {};
294
+ for (const id of Object.keys(data.applicabilityRecords ?? {})) if (doc && typeof doc === "object" && Object.hasOwn(doc, id)) records[id] = doc[id];
295
+ return { profile: doc.profile ?? data.defaultProfile, notApplicable, records, problems };
196
296
  }
197
297
 
198
298
  export function checkApp(root, { data = loadData(), profile, amendments = [] } = {}) {
@@ -213,12 +313,30 @@ export function checkApp(root, { data = loadData(), profile, amendments = [] } =
213
313
  }
214
314
  const notApplicable = Object.entries(declaration.notApplicable).map(([id, reason]) => ({ row: id, reason }));
215
315
  const applicable = rows.filter((r) => !(r.id in declaration.notApplicable));
216
- const pending = data.pendingRows.filter((r) => r.profile === active).map((r) => ({ row: r.id, name: r.name, status: "owner-pending", note: r.ownerPending }));
217
316
  const manifests = findManifests(root).map((f) => readManifest(f, root));
218
317
  resolveWorkspaceInheritance(manifests);
219
318
  for (const m of manifests) if (m.error) problems.push(`${m.rel}: ${m.error}`);
220
319
  const facts = manifests.flatMap((m) => m.facts);
221
- const owners = [...requirements(applicable).values()].map((req) => judge(req, facts));
320
+ // Pending rows never fail. Their `pendingOwners` are judged like any owner and listed as `candidateOwners` (`enforced: false`)
321
+ // so the report shows what activation would find; the result is never counted in `violations`.
322
+ const pending = data.pendingRows.filter((r) => r.profile === active).map((r) => ({
323
+ row: r.id, name: r.name, status: "owner-pending", note: r.ownerPending,
324
+ candidateOwners: r.id in declaration.notApplicable ? [] : (r.pendingOwners ?? []).map((o) => {
325
+ const judged = judge({ ...o, rows: [r.id] }, facts);
326
+ return { owner: judged.owner, ecosystem: judged.ecosystem, rows: judged.rows, required: judged.required, declared: judged.declared, declarationStatus: judged.status, evidence: judged.evidence, enforced: false };
327
+ }),
328
+ }));
329
+ // Applicability records (PK15-2): validated and reported, never enforced.
330
+ const applicability = {};
331
+ for (const [id, spec] of Object.entries(data.applicabilityRecords ?? {})) {
332
+ const record = validateApplicabilityRecord(id, declaration.records[id], { spec, root });
333
+ applicability[id] = record;
334
+ problems.push(...record.problems);
335
+ const declaredNa = id in declaration.notApplicable;
336
+ if (record.role === "absent" && !declaredNa) problems.push(`${id}: applicability role is absent but notApplicable.${id} is not declared; the ${id} row still applies until the app declares it not applicable with a reason`);
337
+ if (record.role && record.role !== "absent" && declaredNa) problems.push(`${id}: notApplicable.${id} is declared but the applicability record says role ${record.role}`);
338
+ }
339
+ const owners = [...requirements(applicable).values()].map((req) => { const result = judge(req, facts); return { ...result, declarationStatus: result.status }; });
222
340
  // A profile that is unknown, marked unratified, or has no rows checks nothing; an app may not select it, so it fails rather than passing empty.
223
341
  const profileStatus = !data.profiles[active] ? "unknown-profile" : (data.profiles[active].ratified === false || !data.rows.some((r) => r.profile === active)) ? "unratified" : null;
224
342
  if (profileStatus) {
@@ -226,7 +344,7 @@ export function checkApp(root, { data = loadData(), profile, amendments = [] } =
226
344
  owners.push({ owner: `profile:${active}`, ecosystem: "profile", rows: [], required: "ratified profile", status: profileStatus, declared: null, evidence: ["an app may not select a profile that is not ratified"] });
227
345
  }
228
346
  const violations = owners.filter((o) => o.status !== "ok").length;
229
- return { app: name, root, scanned: true, profile: active, manifests: manifests.length, owners, pending, notApplicable, problems, violations };
347
+ return { app: name, root, scanned: true, profile: active, manifests: manifests.length, owners, pending, applicability, notApplicable, problems, violations };
230
348
  }
231
349
 
232
350
  export function checkApps(roots, options = {}) {
@@ -236,12 +354,14 @@ export function checkApps(roots, options = {}) {
236
354
  }
237
355
 
238
356
  export function renderText(report) {
239
- const out = [`ownership-mandatory (${report.mode}) — Table 1 mandatory owner set`];
357
+ const out = [`ownership-mandatory (${report.mode}) — Table 1 owner declarations (active-target, lock & production qualification unverified)`];
240
358
  for (const app of report.apps) {
241
359
  const count = (s) => app.owners.filter((o) => o.status === s).length;
242
360
  out.push("", `${app.app} [${app.profile}] ${app.scanned ? `ok=${count("ok")} missing=${count("missing")} below-required=${count("below-required")} not-on-registry=${count("not-on-registry")}` : "UNSCANNED"}`
243
361
  + (app.pending.length ? ` pending=${app.pending.map((p) => p.row).join(",")}` : "") + (app.notApplicable.length ? ` n/a=${app.notApplicable.map((n) => n.row).join(",")}` : ""));
244
362
  for (const o of app.owners.filter((x) => x.status !== "ok")) out.push(` ${o.status.padEnd(15)} ${o.owner} (${o.rows.join(",")}) requires ${o.required}${o.declared ? `, declared ${o.declared}` : ""}${o.evidence.length ? ` — ${o.evidence.join("; ")}` : ""}`);
363
+ for (const p of app.pending) for (const c of p.candidateOwners ?? []) if (c.declarationStatus !== "ok") out.push(` not-enforced ${p.row} ${c.declarationStatus} ${c.owner} requires ${c.required}${c.declared ? `, declared ${c.declared}` : ""} (owner-pending: report only)`);
364
+ for (const record of Object.values(app.applicability ?? {})) if (record.status !== "unrecorded") out.push(` applicability ${record.row} ${record.status}${record.role ? ` role=${record.role}` : ""} entrypoints=${record.entrypoints.length}${record.unverifiedEntrypoints ? ` (${record.unverifiedEntrypoints} unverified symbol)` : ""}`);
245
365
  for (const n of app.notApplicable) out.push(` n/a ${n.row}: ${n.reason}`);
246
366
  for (const p of app.problems) out.push(` problem ${p}`);
247
367
  }
@@ -15,7 +15,7 @@
15
15
  { "repositoryIdentity": "https://github.com/orthic-labs/legion", "roots": ["."], "visibility": "public" },
16
16
  { "repositoryIdentity": "https://github.com/bogusyogi/workright", "roots": ["."], "visibility": "private" },
17
17
  { "repositoryIdentity": "https://github.com/orthic-labs/sellright", "roots": ["."], "visibility": "public" },
18
- { "repositoryIdentity": "https://github.com/orthic-labs/pulse", "roots": ["."], "visibility": "public" },
18
+ { "repositoryIdentity": "https://github.com/bogusyogi/pulse", "roots": ["."], "visibility": "private" },
19
19
  { "repositoryIdentity": "https://github.com/bogusyogi/swiftright", "roots": ["."], "visibility": "private" }
20
20
  ]
21
21
  }
@@ -0,0 +1,94 @@
1
+ // Ownership scan scope for a target that builds from only part of its repository (iOS).
2
+ // A scope is a sorted, de-duplicated list of repository-relative directory roots. Only committed files under those roots
3
+ // are inventoried; the exceptions file under `.rightkit` is always read. Sealed ownership evidence records the scope and
4
+ // upload-time verification re-derives it from the config, so the scope cannot be narrowed after the build.
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export const OWNERSHIP_SCOPE_ALWAYS = ".rightkit";
9
+
10
+ /** Normalize and validate repository-relative scope roots; throws a clear error for absolute, escaping, or missing roots. */
11
+ export function normalizeScopeRoots(roots, { repoRoot, label = "ownershipScope" } = {}) {
12
+ if (!Array.isArray(roots)) throw new Error(`${label} must be an array of repository-relative directories`);
13
+ const out = new Set();
14
+ for (const raw of roots) {
15
+ if (typeof raw !== "string" || !raw.trim()) throw new Error(`${label} entries must be non-empty strings`);
16
+ const slashed = raw.replaceAll("\\", "/");
17
+ if (path.posix.isAbsolute(slashed) || path.win32.isAbsolute(raw) || /^[A-Za-z]:/.test(slashed)) throw new Error(`${label} entry ${raw} must be repository-relative, not absolute`);
18
+ const normal = path.posix.normalize(slashed).replace(/\/+$/, "");
19
+ if (normal === ".." || normal.startsWith("../")) throw new Error(`${label} entry ${raw} escapes the repository`);
20
+ if (normal === "." || normal === "") throw new Error(`${label} entry ${raw} is the whole repository; omit the scope instead`);
21
+ if (repoRoot) {
22
+ const absolute = path.join(repoRoot, ...normal.split("/"));
23
+ let directory = false;
24
+ try { directory = existsSync(absolute) && statSync(absolute).isDirectory(); } catch { directory = false; }
25
+ if (!directory) throw new Error(`${label} entry ${raw} is not an existing directory in the repository`);
26
+ }
27
+ out.add(normal);
28
+ }
29
+ return [...out];
30
+ }
31
+
32
+ /** Local SwiftPM package directories named by `path:` inside the `packages:` section of an XcodeGen project.yml. */
33
+ export function projectYmlLocalPackagePaths(text) {
34
+ const lines = String(text).split(/\r?\n/);
35
+ const paths = [];
36
+ let inPackages = false;
37
+ for (const line of lines) {
38
+ if (/^\s*(#.*)?$/.test(line)) continue;
39
+ if (/^\S/.test(line)) { inPackages = /^packages:\s*(#.*)?$/.test(line); continue; }
40
+ if (!inPackages) continue;
41
+ const match = /(?:^|[\s{,])path:\s*(?:"([^"]*)"|'([^']*)'|([^\s,}#]+))/.exec(line);
42
+ if (match) paths.push(match[1] ?? match[2] ?? match[3]);
43
+ }
44
+ return paths;
45
+ }
46
+
47
+ /**
48
+ * Scan scope of an iOS target: the Xcode project directory, each local SwiftPM package its project.yml references,
49
+ * `.rightkit` (exceptions), the native Rust assembly (`nativeAssembly.workspaceRoot` and the directory of its cargo manifest,
50
+ * which is compiled into the IPA), and `targets.ios.ownershipScope` additions. Accepts the raw or the expanded target.
51
+ * The scan itself closes this scope under local path dependencies of the in-scope manifests (see ownership.mjs).
52
+ * A native assembly rooted at the repository root is ignored (it would make the scope the whole repository).
53
+ * Returns null when the target has no iosPackage (nothing to scope from).
54
+ */
55
+ export function iosOwnershipScope({ repoRoot, appRoot, target, nativeAssembly }) {
56
+ const spec = target?.recipe?.kind === "iosPackage" ? target.recipe.spec : target?.iosPackage;
57
+ if (!spec || typeof spec !== "object") return null;
58
+ const toRepo = (absolute) => {
59
+ const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
60
+ if (relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) throw new Error(`ios ownership scope: ${absolute} is outside the repository`);
61
+ return relative || ".";
62
+ };
63
+ const specFile = spec.projectSpec ?? spec.project;
64
+ if (typeof specFile !== "string") throw new Error("ios ownership scope: iosPackage.project or projectSpec is required");
65
+ const specDir = path.resolve(appRoot, path.posix.dirname(specFile.replaceAll("\\", "/")));
66
+ const roots = [];
67
+ const projectDir = toRepo(specDir);
68
+ if (projectDir === ".") throw new Error("ios ownership scope: the Xcode project sits at the repository root, so there is no narrower scope; declare the project in a subdirectory");
69
+ roots.push(projectDir);
70
+ if (spec.projectSpec) {
71
+ const ymlPath = path.resolve(appRoot, spec.projectSpec);
72
+ if (existsSync(ymlPath)) {
73
+ for (const local of projectYmlLocalPackagePaths(readFileSync(ymlPath, "utf8"))) roots.push(toRepo(path.resolve(specDir, local)));
74
+ }
75
+ }
76
+ const native = nativeAssembly && typeof nativeAssembly === "object" ? nativeAssembly : null;
77
+ const nativeDirs = [native?.workspaceRoot ?? native?.cargoWorkspaceRoot, (native?.cargoManifest ?? native?.manifest) ? path.dirname(native.cargoManifest ?? native.manifest) : undefined].filter((value) => typeof value === "string" && value);
78
+ for (const dir of nativeDirs) {
79
+ const nativeRoot = toRepo(path.resolve(appRoot, dir));
80
+ // A native assembly at the repository root is the app's own (usually desktop) workspace; folding it in would widen the
81
+ // scope to the whole repository, so it adds nothing. The scan's path-dependency closure still follows the in-scope manifests.
82
+ if (nativeRoot !== ".") roots.push(nativeRoot);
83
+ }
84
+ const extra = target.ownershipScope;
85
+ if (extra !== undefined) roots.push(...normalizeScopeRoots(extra, { repoRoot, label: "targets.ios.ownershipScope" }));
86
+ const checked = normalizeScopeRoots(roots.filter((root) => root !== OWNERSHIP_SCOPE_ALWAYS), { repoRoot, label: "ios ownership scope" });
87
+ return [...new Set([...checked, OWNERSHIP_SCOPE_ALWAYS])].sort();
88
+ }
89
+
90
+ /** Repository-relative file or directory under any scope root (null scope = everything). */
91
+ export function inOwnershipScope(scope, rel) {
92
+ if (!scope) return true;
93
+ return scope.some((root) => rel === root || rel.startsWith(`${root}/`));
94
+ }
package/ownership.mjs CHANGED
@@ -12,6 +12,7 @@ import { createHash } from "node:crypto";
12
12
  import { acquireCommittedSource, committedSourceDataForInternalUse } from "./committed-source.mjs";
13
13
  import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
14
14
  import { gitListedInventory } from "./ownership-git-inventory.mjs";
15
+ import { inOwnershipScope } from "./ownership-scope.mjs";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
17
18
 
@@ -240,14 +241,40 @@ function fileInventory(appRoot, trust, pathPrefix = "", repositoryRoot = appRoot
240
241
  return listedCoverage(inventory);
241
242
  }
242
243
 
243
- function committedFileInventory(appRoot, trust, pathPrefix, committed) {
244
+ // A scoped scan also reads the workspace/lockfile authorities that sit in an ancestor directory of a scope root
245
+ // (a root Cargo.toml / Cargo.lock / lockfile resolves a member crate's inherited fields and lock graph), so a member
246
+ // of a larger workspace is judged against the same lockfile as in a whole-repository scan.
247
+ function inScopeOrAuthority(scope, rel) {
248
+ if (inOwnershipScope(scope, rel)) return true;
249
+ const dir = path.posix.dirname(rel);
250
+ return ["Cargo.toml", "Cargo.lock", "package.json", "pnpm-workspace.yaml", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel))
251
+ && scope.some((root) => dir === "." || root === dir || root.startsWith(`${dir}/`));
252
+ }
253
+
254
+ // Directories named by local path dependencies of in-scope manifests that are not yet in scope. A target that is not a
255
+ // directory of the committed tree is left to resolveLocalPath, which records a blocking coverage gap for it.
256
+ function localDependencyRoots(deps, scope, entries) {
257
+ const roots = new Set();
258
+ for (const dep of deps) {
259
+ if (!dep.local || typeof dep.source !== "string" || !dep.source.startsWith("path:")) continue;
260
+ const value = dep.source.slice(5);
261
+ if (!value || value.startsWith("workspace:") || path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || value.includes("\\")) continue;
262
+ const relative = path.posix.normalize(path.posix.join(path.posix.dirname(dep.file), value)).replace(/\/$/, "");
263
+ if (relative === "." || relative === ".." || relative.startsWith("../") || inOwnershipScope(scope, relative)) continue;
264
+ if (entries.some((entry) => entry.rel.startsWith(`${relative}/`))) roots.add(relative);
265
+ }
266
+ return [...roots];
267
+ }
268
+
269
+ function committedFileInventory(appRoot, trust, pathPrefix, committed, scope = null) {
244
270
  const prefix = pathPrefix ? `${pathPrefix.replace(/\/$/, "")}/` : "";
245
271
  const repositoryFiles = committed.entries.filter((entry) => entry.type === "blob").map((entry) => ({ rel: entry.rel, size: entry.size, bytes: entry.bytes, mode: entry.mode }));
246
- const files = [], coverage = committed.coverage.filter((entry) => !prefix || entry.path.startsWith(prefix)).map((entry) => ({ ...entry, path: prefix ? entry.path.slice(prefix.length) : entry.path }));
272
+ const files = [], coverage = committed.coverage.filter((entry) => (!prefix || entry.path.startsWith(prefix)) && inScopeOrAuthority(scope, entry.path)).map((entry) => ({ ...entry, path: prefix ? entry.path.slice(prefix.length) : entry.path }));
247
273
  const protectedPaths = new Set((trust?.provenance ?? []).flatMap((record) => record.paths ?? []));
248
274
  const reportedOpaque = new Set(), reportedNodeModules = new Set();
249
275
  for (const entry of committed.entries) {
250
276
  if (prefix && !entry.rel.startsWith(prefix)) continue;
277
+ if (!inScopeOrAuthority(scope, entry.rel)) continue;
251
278
  const rel = prefix ? entry.rel.slice(prefix.length) : entry.rel;
252
279
  if (!rel) continue;
253
280
  if (entry.mode === "120000") { coverage.push({ path: rel, status: "unscanned", reason: "committed-symlink" }); continue; }
@@ -834,14 +861,28 @@ function checkCommitted(appRoot, manifest, options, acquire, readAuthority = rea
834
861
  keyCheckpoint = resolved.checkpoint;
835
862
  }
836
863
  const location = { root: committed.repositoryRoot, prefix: pathPrefix, pathBase: "repository" };
837
- const inventory = committedFileInventory(appRoot, trust, location.prefix, committed);
838
- if (location.prefix && !committed.entries.some((entry) => entry.rel.startsWith(location.prefix))) {
839
- inventory.coverage.push({ path: ".", status: "unscanned", reason: "registered-root-missing-from-committed-tree" });
840
- }
864
+ const declaredScope = options.scope ?? null;
841
865
  const committedEnrollment = trust?.rootFingerprint
842
866
  ? { enrolled: trust.enrollmentComplete === true, pending: trust.enrollmentComplete !== true }
843
867
  : trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined;
844
- const scanned = scanInternal(appRoot, manifest, { inventory, trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix, allowlistPolicy: dependencyAllowlistPolicy(trust, repositoryIdentity, { pending: enrollmentPending(trust, committedEnrollment), shipped: options.shippedAllowlist }) });
868
+ // The scope is closed under local path dependencies: a path dependency of an in-scope manifest that lands in an
869
+ // out-of-scope directory adds that directory to the scope and the scan repeats, so compiled-in code is never skipped.
870
+ let scope = declaredScope ? [...declaredScope] : null;
871
+ let inventory, scanned;
872
+ for (let round = 0; ; round += 1) {
873
+ inventory = committedFileInventory(appRoot, trust, location.prefix, committed, scope);
874
+ if (location.prefix && !committed.entries.some((entry) => entry.rel.startsWith(location.prefix))) {
875
+ inventory.coverage.push({ path: ".", status: "unscanned", reason: "registered-root-missing-from-committed-tree" });
876
+ }
877
+ scanned = scanInternal(appRoot, manifest, { inventory, trust, repositoryIdentity, repositoryRoot: location.root, pathPrefix: location.prefix, allowlistPolicy: dependencyAllowlistPolicy(trust, repositoryIdentity, { pending: enrollmentPending(trust, committedEnrollment), shipped: options.shippedAllowlist }) });
878
+ if (!scope) break;
879
+ const added = localDependencyRoots(scanned.deps, scope, committed.entries);
880
+ if (!added.length) break;
881
+ if (round >= 64) throw new Error("committed ownership: scan scope did not converge under local path dependencies");
882
+ scope = [...new Set([...scope, ...added])].sort();
883
+ }
884
+ // Provenance and lockfile diagnostics are computed beside the inventory; a scoped scan reports only gaps under its scope.
885
+ if (scope) scanned.coverage = scanned.coverage.filter((entry) => typeof entry.path !== "string" || entry.path === "." || inScopeOrAuthority(scope, entry.path));
845
886
  const exceptionPath = [location.prefix.replace(/\/$/, ""), EXCEPTIONS_FILE.split(path.sep).join("/")].filter(Boolean).join("/");
846
887
  const exceptionObject = committed.entries.find((entry) => entry.rel === exceptionPath);
847
888
  const exceptionEntry = exceptionObject?.bytes;
@@ -856,6 +897,7 @@ function checkCommitted(appRoot, manifest, options, acquire, readAuthority = rea
856
897
  scannedAt: new Date().toISOString(), catalogDigest: catalogDigest(manifest), catalogVersion: manifest.version ?? String(manifest.schema ?? "1"),
857
898
  scannerVersion: SCANNER_VERSION, contentDigests: scanned.contentDigests, coverage: scanned.coverage,
858
899
  pathBase: location.pathBase, pathPrefix: location.prefix, provenance: scanned.provenance, exceptionDigest,
900
+ ...(scope ? { ownershipScopeClosed: scope } : {}),
859
901
  source: "committed-git-tree", ...(keyLogDigest ? { keyLogDigest, keyCheckpoint, rootFingerprint: trust.rootFingerprint, ...(keyAuthorityBinding ? { keyAuthority: keyAuthorityBinding } : {}) } : {}),
860
902
  };
861
903
  const result = evaluate(scanned.findings, exceptions, { ...options, manifest, trust, trustEnrollment: trust?.rootFingerprint
@@ -9,6 +9,8 @@ import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
11
11
  import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
12
+ import { IOS_SIGNING_CONTRACT, IosPackageError, validateIosPackageConfig } from "./ios-package-recipe.mjs";
13
+ import { IosUploadError, validateIosDistribution } from "./ios-upload.mjs";
12
14
 
13
15
  export class PackageRecipeTargetError extends Error {
14
16
  constructor(message) { super(message); this.name = "PackageRecipeTargetError"; this.code = "RK_PACKAGE_RECIPE_TARGET"; }
@@ -249,11 +251,49 @@ export function expandMacPackageTarget(target, { appRoot, env = process.env, res
249
251
  };
250
252
  }
251
253
 
254
+ const IOS_TARGET_KEYS = new Set(["signed", "signingContract", "iosPackage", "artifacts", "hardening", "hardeningAllowances", "qaControlScan", "publishBlocked", "distribution", "ownershipScope", "cargoManifest", "cargoTarget", "recipe"]);
255
+
256
+ /**
257
+ * `targets.ios.iosPackage` (bogusyogi/rightkit#349) is validated strictly and kept as `recipe: { kind: "iosPackage", spec }`.
258
+ * Unlike macPackage there is no hook to expand into: xcodebuild archive and export run in ios-package-recipe.mjs (macOS only),
259
+ * which release.mjs calls like the Android lane. The target may carry only the keys in IOS_TARGET_KEYS: a hand-written
260
+ * prePackage / package / sign / notarize / gradle script, or an App Store Connect credential, is refused.
261
+ * `artifacts` must list the .ipa so the IPA is checked and sealed. `distribution` (the App Store Connect target of an app that
262
+ * also ships desktop) is validated here exactly as the upload validates it, so a credential in it fails the build.
263
+ */
264
+ export function expandIosPackageTarget(target, { appRoot: _appRoot } = {}) {
265
+ if (!isObject(target) || target.iosPackage === undefined) return withoutRecipe(target);
266
+ const unknown = Object.keys(target).filter((key) => !IOS_TARGET_KEYS.has(key));
267
+ if (unknown.length) fail(`targets.ios has unknown key(s): ${unknown.join(", ")} (allowed: ${[...IOS_TARGET_KEYS].filter((key) => key !== "recipe").join(", ")}); the iOS recipe replaces prePackage, package, sign and notarize scripts, and App Store Connect credentials never belong in a target`);
268
+ if (target.distribution !== undefined) {
269
+ try { validateIosDistribution(target.distribution); } catch (error) {
270
+ if (error instanceof IosUploadError) fail(`targets.ios.${error.message.replace(/^\[[A-Z0-9_]+\] /, "")}`);
271
+ throw error;
272
+ }
273
+ }
274
+ if (target.ownershipScope !== undefined && (!Array.isArray(target.ownershipScope) || target.ownershipScope.some((entry) => typeof entry !== "string" || !entry.trim()))) fail("targets.ios.ownershipScope must be an array of repository-relative directory strings");
275
+ // The Rust the app links (for the feature-closure scan): an app-relative Cargo.toml and its iPhone triple.
276
+ if (target.cargoManifest !== undefined && (typeof target.cargoManifest !== "string" || !target.cargoManifest.endsWith("Cargo.toml"))) fail("targets.ios.cargoManifest must be an app-relative path to a Cargo.toml");
277
+ if (target.cargoTarget !== undefined && !/^aarch64-apple-ios(-sim)?$/.test(target.cargoTarget)) fail("targets.ios.cargoTarget must be aarch64-apple-ios or aarch64-apple-ios-sim");
278
+ if (target.signed !== true) fail("targets.ios must declare signed: true");
279
+ if (target.signingContract !== undefined && target.signingContract !== IOS_SIGNING_CONTRACT) fail(`targets.ios.iosPackage signs with Xcode automatic signing; signingContract must be ${IOS_SIGNING_CONTRACT}, got ${target.signingContract}`);
280
+ let spec;
281
+ try { spec = validateIosPackageConfig(target.iosPackage); } catch (error) {
282
+ if (error instanceof IosPackageError) fail(`targets.ios.${error.message}`);
283
+ throw error;
284
+ }
285
+ const declared = (target.artifacts ?? []).map((artifact) => String(typeof artifact === "string" ? artifact : artifact?.file).replaceAll("\\", "/").replace(/^\.\//, ""));
286
+ if (!declared.includes(spec.ipa)) fail("targets.ios.artifacts must list iosPackage.ipa so the IPA is checked and sealed");
287
+ const { iosPackage, recipe: _ignored, ...rest } = target;
288
+ return { ...rest, signingContract: IOS_SIGNING_CONTRACT, recipe: { kind: "iosPackage", spec } };
289
+ }
290
+
252
291
  /** Expand whichever declarative package key the target carries. */
253
292
  export function expandPackageRecipeTarget(target, { platform, appRoot, config, env, resolveTargetRoot }) {
254
293
  const injected = { ...(env ? { env } : {}), ...(resolveTargetRoot ? { resolveTargetRoot } : {}) };
255
294
  if (platform === "win") return expandWinPackageTarget(target, { appRoot, config, ...injected });
256
295
  if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config, ...injected });
257
- if (target?.winPackage || target?.macPackage) fail(`winPackage / macPackage are not valid on ${platform}`);
296
+ if (platform === "ios") return expandIosPackageTarget(target, { appRoot });
297
+ if (target?.winPackage || target?.macPackage || target?.iosPackage) fail(`winPackage / macPackage / iosPackage are not valid on ${platform}`);
258
298
  return target;
259
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",