@rightkit/release 0.2.135 → 0.2.137

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 (81) hide show
  1. package/README.md +62 -0
  2. package/addon-command.mjs +53 -34
  3. package/apple-profile-renew.mjs +30 -12
  4. package/artifact-storage.mjs +47 -10
  5. package/background-process.mjs +18 -0
  6. package/build-release.mjs +1 -1
  7. package/cache-command.mjs +4 -3
  8. package/cargo-contract.mjs +1 -1
  9. package/cargo-guard.mjs +4 -3
  10. package/cargo-registry-readback.mjs +51 -0
  11. package/cargo-target.mjs +3 -1
  12. package/cli/right-release.mjs +26 -0
  13. package/codesign-environment.mjs +26 -2
  14. package/codesign-shim.mjs +2 -1
  15. package/committed-source.mjs +3 -3
  16. package/development.mjs +7 -7
  17. package/direct-bootstrap.mjs +19 -9
  18. package/dmg-build.mjs +117 -0
  19. package/dmg-layout.mjs +123 -0
  20. package/github-release.mjs +74 -37
  21. package/heavy-command.mjs +3 -2
  22. package/install-app.mjs +358 -0
  23. package/inventory-check.mjs +980 -0
  24. package/macos-signing-session.mjs +3 -0
  25. package/managed-cargo.mjs +23 -19
  26. package/managed-host-secrets.mjs +30 -0
  27. package/managed-roots.mjs +39 -0
  28. package/model-promote.mjs +31 -37
  29. package/notary-auth.mjs +4 -0
  30. package/osascript-guard.mjs +24 -0
  31. package/ownership-approval-command.mjs +15 -4
  32. package/ownership-approval.mjs +114 -49
  33. package/ownership-availability.mjs +25 -0
  34. package/ownership-catalog-config.json +18 -0
  35. package/ownership-catalog.mjs +94 -0
  36. package/ownership-command.mjs +20 -10
  37. package/ownership-declaration.json +15 -0
  38. package/ownership-default-policy.json +1 -11
  39. package/ownership-evidence.mjs +10 -8
  40. package/ownership-git-inventory.mjs +29 -1
  41. package/ownership-helper-paths.json +2 -2
  42. package/ownership-hooks.mjs +107 -0
  43. package/ownership-internal-owners.json +44 -0
  44. package/ownership-key-authority.mjs +112 -7
  45. package/ownership-key-command.mjs +76 -19
  46. package/ownership-key-trust.mjs +97 -9
  47. package/ownership-lockfiles.mjs +311 -0
  48. package/ownership-report-command.mjs +12 -3
  49. package/ownership-report.mjs +20 -17
  50. package/ownership-repositories.json +19 -0
  51. package/ownership-scan-worker.mjs +96 -11
  52. package/ownership-source.mjs +39 -0
  53. package/ownership-staged.mjs +193 -0
  54. package/ownership-verdict.mjs +74 -0
  55. package/ownership.mjs +215 -107
  56. package/package.json +1 -1
  57. package/protected-credentials.mjs +111 -0
  58. package/prune-r2.mjs +4 -3
  59. package/pub-upload.mjs +3 -85
  60. package/publication-artifacts.mjs +108 -0
  61. package/publication-gate.mjs +28 -7
  62. package/publish-cargo.mjs +47 -16
  63. package/publish-swift.mjs +46 -17
  64. package/publish-update.mjs +61 -39
  65. package/r2-verified-upload.mjs +41 -0
  66. package/registry-parity.mjs +5 -3
  67. package/release-state.mjs +1 -0
  68. package/release-token.mjs +2 -0
  69. package/rightkit-drift.mjs +3 -2
  70. package/rightkit-ownership.json +2028 -139
  71. package/rightkit-signing-agent.mjs +5 -3
  72. package/rightkit-sync.mjs +6 -2
  73. package/rightkit-versions.json +215 -103
  74. package/runtime-artifact-manifest.mjs +20 -6
  75. package/sign-updater.mjs +5 -0
  76. package/signing-agent-client.mjs +2 -1
  77. package/signing-agent-install.mjs +3 -2
  78. package/standalone-clone-verify.mjs +3 -2
  79. package/tauri-bundle-marker.mjs +3 -2
  80. package/upload-large.mjs +36 -82
  81. package/verifier.mjs +5 -1
@@ -0,0 +1,980 @@
1
+ #!/usr/bin/env node
2
+ // RightKit inventory check: registry parity, account parity, outdated third-party
3
+ // requirements, app adoption, and new-package hygiene. Public endpoints only; no
4
+ // credentials or tokens are read. Invoked as `right-release inventory check`.
5
+ import { createHash } from "node:crypto";
6
+ import { realpathSync } from "node:fs";
7
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { parse as parseToml } from "smol-toml";
12
+ import {
13
+ APP_REPOS,
14
+ DEFAULT_WORKSPACE,
15
+ driftOf,
16
+ generateAdoption,
17
+ generateLocal,
18
+ } from "../../scripts/generate-index.mjs";
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ export const DEFAULT_REPO_ROOT = resolve(HERE, "..", "..");
22
+ export const DEFAULT_CACHE_DIR = join(tmpdir(), "rightkit-inventory-cache");
23
+ const CACHE_TTL_MS = 60 * 60 * 1000;
24
+ const CONCURRENCY = 8;
25
+ const REQUEST_TIMEOUT_MS = 15_000;
26
+ const CRATES_API_INTERVAL_MS = 1000;
27
+ const USER_AGENT = "rightkit-inventory-check/1 (+https://github.com/bogusyogi/claude)";
28
+ const EXCLUDED_PREFIX = "build-control-";
29
+ const OWNER_PROBE_CRATE = "rightkit-fs";
30
+ const EXTRA_ACCOUNT_CRATES = Object.freeze(["agent-room-core"]);
31
+ const SWIFT_REPO = "bogusyogi/rightkit-swift";
32
+ const SWIFT_KEY = "rightkit-swift";
33
+ const SWIFT_NAME = "RightKitSwift";
34
+ const SKIP_DIRS = new Set(["node_modules", ".git", "target", "dist", "build", ".cache"]);
35
+ const NON_REGISTRY = /^(workspace:|file:|link:|portal:|git\+|git:|github:|https?:|npm:)|\//;
36
+
37
+ export const ACTION = "action";
38
+ export const INFO = "info";
39
+ export const SEVERITY = Object.freeze({
40
+ "never-published": ACTION,
41
+ "source-unpublished": ACTION,
42
+ "staged-not-published": ACTION,
43
+ yanked: ACTION,
44
+ deprecated: ACTION,
45
+ "registry-newer-than-source": ACTION,
46
+ "feed-out-of-date": ACTION,
47
+ "published-but-private": ACTION,
48
+ "orphan-registry-package": ACTION,
49
+ "missing-from-registry": ACTION,
50
+ "outdated-requirement": ACTION,
51
+ "outdated-pin": ACTION,
52
+ "unpublished-pin": ACTION,
53
+ "split-app-pins": ACTION,
54
+ "duplicate-app-copies": ACTION,
55
+ "generated-doc-drift": ACTION,
56
+ "missing-ownership-file": ACTION,
57
+ "missing-readme": ACTION,
58
+ "missing-from-catalog": ACTION,
59
+ unverified: ACTION,
60
+ "private-crate": INFO,
61
+ "private-package": INFO,
62
+ "duplicate-lock-versions": INFO,
63
+ "unsupported-requirement": INFO,
64
+ "app-missing": INFO,
65
+ });
66
+ export const SECTIONS = Object.freeze({
67
+ registry: "1. Registry parity",
68
+ account: "2. Account parity",
69
+ thirdParty: "3. Third-party requirements",
70
+ apps: "4. App adoption",
71
+ generated: "5. Generated docs",
72
+ hygiene: "6. New-package hygiene",
73
+ });
74
+
75
+ export function issue(section, subject, kind, detail, severity = SEVERITY[kind] ?? ACTION) {
76
+ return { section, subject, kind, severity, detail };
77
+ }
78
+
79
+ // ---------- versions and requirements ----------
80
+
81
+ export function parseVersion(text) {
82
+ const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(text ?? "").trim());
83
+ return match ? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), pre: match[4] ?? null } : null;
84
+ }
85
+
86
+ function comparePair(a, b) {
87
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
88
+ return 0;
89
+ }
90
+ const partsOfVersion = (v) => [v.major, v.minor, v.patch];
91
+
92
+ // Compares two version strings or parsed versions; throws when either is not semver.
93
+ export function compareVersions(a, b) {
94
+ const x = typeof a === "string" ? parseVersion(a) : a;
95
+ const y = typeof b === "string" ? parseVersion(b) : b;
96
+ if (!x || !y) throw new TypeError(`not a semver version: ${!x ? a : b}`);
97
+ const c = comparePair(partsOfVersion(x), partsOfVersion(y));
98
+ if (c) return c;
99
+ if (x.pre === y.pre) return 0;
100
+ if (x.pre === null) return 1;
101
+ if (y.pre === null) return -1;
102
+ return x.pre < y.pre ? -1 : x.pre > y.pre ? 1 : 0;
103
+ }
104
+
105
+ // Highest version that is not yanked, deprecated, or a prerelease.
106
+ export function latestStable(versions) {
107
+ let best = null;
108
+ for (const entry of versions) {
109
+ if (entry.yanked || entry.deprecated) continue;
110
+ const parsed = parseVersion(entry.version);
111
+ if (!parsed || parsed.pre) continue;
112
+ if (!best || compareVersions(parsed, best.parsed) > 0) best = { version: entry.version, parsed };
113
+ }
114
+ return best?.version ?? null;
115
+ }
116
+
117
+ function normalizeRange(text) {
118
+ return String(text ?? "").trim().replace(/(\^|~|>=|<=|>|<|=)\s+/g, "$1");
119
+ }
120
+
121
+ // One comparator: op, numeric parts (n = count of leading numeric parts before a wildcard).
122
+ function parseComparator(token, ecosystem) {
123
+ const match = /^(\^|~|>=|<=|>|<|=)?v?([0-9xX*]+(?:\.[0-9xX*]+){0,2})(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]*)?$/.exec(token);
124
+ if (!match) return null;
125
+ const numeric = [];
126
+ for (const part of match[2].split(".")) {
127
+ if (!/^\d+$/.test(part)) break;
128
+ numeric.push(Number(part));
129
+ }
130
+ const [M = 0, m = 0, p = 0] = numeric;
131
+ const op = match[1] ?? (ecosystem === "cargo" ? "^" : "");
132
+ return { op, n: numeric.length, M, m, p };
133
+ }
134
+
135
+ function matchComparator(c, v) {
136
+ const lo = [c.M, c.m, c.p];
137
+ const vv = partsOfVersion(v);
138
+ switch (c.op) {
139
+ case "":
140
+ case "=":
141
+ if (c.n === 0) return true;
142
+ if (c.n === 3) return comparePair(vv, lo) === 0;
143
+ return vv[0] === c.M && (c.n < 2 || vv[1] === c.m);
144
+ case "^": {
145
+ if (c.n === 0) return true;
146
+ const upper = c.n === 1 || c.M > 0 ? [c.M + 1, 0, 0]
147
+ : c.n === 2 || c.m > 0 ? [0, c.m + 1, 0]
148
+ : [0, 0, c.p + 1];
149
+ return comparePair(vv, lo) >= 0 && comparePair(vv, upper) < 0;
150
+ }
151
+ case "~": {
152
+ if (c.n === 0) return true;
153
+ const upper = c.n === 1 ? [c.M + 1, 0, 0] : [c.M, c.m + 1, 0];
154
+ return comparePair(vv, lo) >= 0 && comparePair(vv, upper) < 0;
155
+ }
156
+ case ">=": return comparePair(vv, lo) >= 0;
157
+ case ">": return comparePair(vv, lo) > 0;
158
+ case "<=": return comparePair(vv, lo) <= 0;
159
+ case "<": return comparePair(vv, lo) < 0;
160
+ default: return false;
161
+ }
162
+ }
163
+
164
+ // True when `version` lies inside the requirement; false when it is outside; null when the
165
+ // requirement uses syntax this check does not model (tags, git, local specifiers).
166
+ export function satisfies(range, version, ecosystem = "cargo") {
167
+ const v = parseVersion(version);
168
+ if (!v) return null;
169
+ const text = normalizeRange(range);
170
+ if (text === "") return true;
171
+ let matched = false;
172
+ for (const group of text.split("||")) {
173
+ const tokens = group.trim().split(/\s*,\s*|\s+/).filter(Boolean);
174
+ let comparators;
175
+ if (tokens.length === 3 && tokens[1] === "-") {
176
+ const lo = parseComparator(tokens[0], ecosystem);
177
+ const hi = parseComparator(tokens[2], ecosystem);
178
+ if (!lo || !hi) return null;
179
+ comparators = [{ op: ">=", n: 3, M: lo.M, m: lo.m, p: lo.p }];
180
+ if (hi.n === 3) comparators.push({ op: "<=", n: 3, M: hi.M, m: hi.m, p: hi.p });
181
+ else if (hi.n === 2) comparators.push({ op: "<", n: 3, M: hi.M, m: hi.m + 1, p: 0 });
182
+ else if (hi.n === 1) comparators.push({ op: "<", n: 3, M: hi.M + 1, m: 0, p: 0 });
183
+ } else {
184
+ comparators = tokens.map((token) => parseComparator(token, ecosystem));
185
+ }
186
+ if (comparators.some((c) => !c)) return null;
187
+ if (comparators.every((c) => matchComparator(c, v))) matched = true;
188
+ }
189
+ return matched;
190
+ }
191
+
192
+ // The lower bound a requirement pins to, as "M.m.p"; null for upper-bound-only or unsupported syntax.
193
+ export function baseVersion(range, ecosystem = "cargo") {
194
+ const first = normalizeRange(range).split("||")[0].trim().split(/\s*,\s*|\s+/).filter(Boolean)[0];
195
+ const c = first ? parseComparator(first, ecosystem) : null;
196
+ if (!c || c.op === "<" || c.op === "<=") return null;
197
+ return `${c.M}.${c.m}.${c.p}`;
198
+ }
199
+
200
+ export function behindKind(range, latest, ecosystem = "cargo") {
201
+ const base = baseVersion(range, ecosystem);
202
+ const b = base ? parseVersion(base) : null;
203
+ const l = parseVersion(latest);
204
+ if (!b || !l) return "unknown";
205
+ if (l.major > b.major) return "major";
206
+ if (l.minor > b.minor) return "minor";
207
+ return "patch";
208
+ }
209
+
210
+ // ---------- bounded concurrency ----------
211
+
212
+ export async function mapLimit(items, limit, fn) {
213
+ const results = new Array(items.length);
214
+ let next = 0;
215
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
216
+ while (next < items.length) {
217
+ const index = next++;
218
+ results[index] = await fn(items[index], index);
219
+ }
220
+ });
221
+ await Promise.all(workers);
222
+ return results;
223
+ }
224
+
225
+ // ---------- HTTP with a one-hour on-disk cache ----------
226
+
227
+ export function crateIndexPath(name) {
228
+ const n = name.toLowerCase();
229
+ if (n.length === 1) return `1/${n}`;
230
+ if (n.length === 2) return `2/${n}`;
231
+ if (n.length === 3) return `3/${n[0]}/${n}`;
232
+ return `${n.slice(0, 2)}/${n.slice(2, 4)}/${n}`;
233
+ }
234
+
235
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
236
+
237
+ async function readCache(file, nowMs, ttlMs) {
238
+ try {
239
+ const entry = JSON.parse(await readFile(file, "utf8"));
240
+ return nowMs - entry.at < ttlMs && typeof entry.text === "string" ? { status: entry.status, text: entry.text } : null;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ async function writeCache(file, entry) {
247
+ try {
248
+ await mkdir(dirname(file), { recursive: true });
249
+ await writeFile(file, JSON.stringify(entry));
250
+ } catch {
251
+ // The cache is best-effort; a failed write only costs a refetch.
252
+ }
253
+ }
254
+
255
+ // Returns get(url) -> { status, text }. Resolves 200 and 404; throws on anything else.
256
+ // Requests to crates.io are spaced to one per second, per the crates.io crawler policy.
257
+ export function createFetcher(options = {}) {
258
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
259
+ const cacheDir = "cacheDir" in options ? options.cacheDir : DEFAULT_CACHE_DIR;
260
+ const now = options.now ?? Date.now;
261
+ const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
262
+ const intervalMs = options.crateApiIntervalMs ?? CRATES_API_INTERVAL_MS;
263
+ let crateSlot = Promise.resolve();
264
+ const get = async (url, { headers = {} } = {}) => {
265
+ const file = cacheDir ? join(cacheDir, `${createHash("sha256").update(url).digest("hex")}.json`) : null;
266
+ if (file) {
267
+ const hit = await readCache(file, now(), ttlMs);
268
+ if (hit) return hit;
269
+ }
270
+ if (new URL(url).hostname === "crates.io") {
271
+ const slot = crateSlot.then(() => sleep(intervalMs));
272
+ crateSlot = slot;
273
+ await slot;
274
+ }
275
+ const response = await fetchImpl(url, {
276
+ headers: { "user-agent": USER_AGENT, accept: "application/json", ...headers },
277
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
278
+ });
279
+ if (response.status !== 200 && response.status !== 404) throw new Error(`HTTP ${response.status} from ${new URL(url).host}`);
280
+ const result = { status: response.status, text: await response.text() };
281
+ if (file) await writeCache(file, { at: now(), ...result });
282
+ return result;
283
+ };
284
+ return get;
285
+ }
286
+
287
+ async function jsonOrNull(get, url, options) {
288
+ const response = await get(url, options);
289
+ return response.status === 404 ? null : JSON.parse(response.text);
290
+ }
291
+
292
+ export async function fetchCrateVersions(get, name) {
293
+ const response = await get(`https://index.crates.io/${crateIndexPath(name)}`);
294
+ if (response.status === 404) return { versions: [] };
295
+ const versions = response.text.split("\n").filter(Boolean).map((line) => {
296
+ const entry = JSON.parse(line);
297
+ return { version: entry.vers, yanked: entry.yanked === true, deprecated: null };
298
+ });
299
+ return { versions };
300
+ }
301
+
302
+ export async function fetchNpmPackage(get, name) {
303
+ const doc = await jsonOrNull(get, `https://registry.npmjs.org/${encodeURIComponent(name)}`, {
304
+ headers: { accept: "application/vnd.npm.install-v1+json, application/json;q=0.5" },
305
+ });
306
+ if (!doc) return { versions: [], latest: null };
307
+ const versions = Object.entries(doc.versions ?? {}).map(([version, meta]) => ({
308
+ version,
309
+ yanked: false,
310
+ deprecated: typeof meta?.deprecated === "string" && meta.deprecated ? meta.deprecated : null,
311
+ }));
312
+ return { versions, latest: latestStable(versions) };
313
+ }
314
+
315
+ export async function fetchSwiftTags(get) {
316
+ const response = await get(`https://api.github.com/repos/${SWIFT_REPO}/tags?per_page=100`, {
317
+ headers: { accept: "application/vnd.github+json" },
318
+ });
319
+ if (response.status !== 200) throw new Error(`GitHub tags unavailable without credentials (HTTP ${response.status})`);
320
+ const versions = JSON.parse(response.text).map((tag) => ({ version: String(tag.name).replace(/^v/, ""), yanked: false, deprecated: null }))
321
+ .filter((entry) => parseVersion(entry.version));
322
+ return { versions, latest: latestStable(versions) };
323
+ }
324
+
325
+ // One memoized lookup per (ecosystem, name) so sections share registry reads.
326
+ export function createLiveIndex(get) {
327
+ const memo = new Map();
328
+ return (ecosystem, name) => {
329
+ const key = `${ecosystem}:${name}`;
330
+ if (!memo.has(key)) {
331
+ const lookup = ecosystem === "cargo"
332
+ ? fetchCrateVersions(get, name).then(({ versions }) => ({ versions, latest: latestStable(versions) }))
333
+ : ecosystem === "npm" ? fetchNpmPackage(get, name)
334
+ : Promise.reject(new Error(`no registry for ${ecosystem}`));
335
+ memo.set(key, lookup);
336
+ }
337
+ return memo.get(key);
338
+ };
339
+ }
340
+
341
+ // ---------- source inventory ----------
342
+
343
+ async function childDirs(dir) {
344
+ try {
345
+ return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
346
+ } catch {
347
+ return [];
348
+ }
349
+ }
350
+
351
+ async function exists(path) {
352
+ try {
353
+ await stat(path);
354
+ return true;
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ async function readText(path) {
361
+ try {
362
+ return await readFile(path, "utf8");
363
+ } catch (error) {
364
+ if (error.code === "ENOENT") return null;
365
+ throw error;
366
+ }
367
+ }
368
+
369
+ async function readToml(path) {
370
+ const text = await readText(path);
371
+ return text === null ? null : parseToml(text);
372
+ }
373
+
374
+ async function readJson(path) {
375
+ const text = await readText(path);
376
+ return text === null ? null : JSON.parse(text);
377
+ }
378
+
379
+ // Depth-limited file walk for the named files; skips dependency and build output.
380
+ async function walkNamed(root, names, maxDepth, depth = 0, out = []) {
381
+ for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
382
+ if (entry.isDirectory()) {
383
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".") && depth < maxDepth) {
384
+ await walkNamed(join(root, entry.name), names, maxDepth, depth + 1, out);
385
+ }
386
+ } else if (entry.isFile() && names.includes(entry.name)) {
387
+ out.push(join(root, entry.name));
388
+ }
389
+ }
390
+ return out;
391
+ }
392
+
393
+ export function cargoDeclarations(manifest) {
394
+ const tables = [];
395
+ if (manifest.workspace?.dependencies) tables.push(manifest.workspace.dependencies);
396
+ const kinds = ["dependencies", "dev-dependencies", "build-dependencies"];
397
+ for (const kind of kinds) if (manifest[kind]) tables.push(manifest[kind]);
398
+ for (const target of Object.values(manifest.target ?? {})) {
399
+ for (const kind of kinds) if (target[kind]) tables.push(target[kind]);
400
+ }
401
+ const out = [];
402
+ for (const table of tables) {
403
+ for (const [alias, spec] of Object.entries(table)) {
404
+ if (typeof spec === "string") out.push({ name: alias, req: spec });
405
+ else if (spec && typeof spec === "object" && typeof spec.version === "string") out.push({ name: spec.package ?? alias, req: spec.version });
406
+ // path, git, and workspace-inherited entries carry no registry requirement here.
407
+ }
408
+ }
409
+ return out;
410
+ }
411
+
412
+ export function npmDeclarations(pkg) {
413
+ const out = [];
414
+ for (const kind of ["dependencies", "devDependencies", "optionalDependencies"]) {
415
+ for (const [name, req] of Object.entries(pkg[kind] ?? {})) {
416
+ if (typeof req === "string" && !NON_REGISTRY.test(req.trim())) out.push({ name, req });
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+
422
+ export async function loadSource(repoRoot) {
423
+ const releaseDir = join(repoRoot, "packages", "release");
424
+ const versions = await readJson(join(releaseDir, "rightkit-versions.json"));
425
+ const catalog = await readJson(join(releaseDir, "rightkit-ownership.json"));
426
+ const catalogNames = new Set((catalog?.capabilities ?? []).map((c) => c.package));
427
+ const records = [];
428
+
429
+ for (const dir of await childDirs(join(repoRoot, "crates"))) {
430
+ if (dir.startsWith(EXCLUDED_PREFIX)) continue;
431
+ const base = join(repoRoot, "crates", dir);
432
+ const manifest = await readToml(join(base, "Cargo.toml"));
433
+ if (!manifest?.package) continue;
434
+ const pkg = manifest.package;
435
+ const publish = pkg.publish;
436
+ records.push({
437
+ ecosystem: "cargo",
438
+ name: pkg.name,
439
+ dir: base,
440
+ version: typeof pkg.version === "string" ? pkg.version : null,
441
+ isPublic: !(publish === false || (Array.isArray(publish) && publish.length === 0)),
442
+ manifest,
443
+ });
444
+ }
445
+
446
+ for (const dir of await childDirs(join(repoRoot, "packages"))) {
447
+ if (dir.startsWith(EXCLUDED_PREFIX)) continue;
448
+ const base = join(repoRoot, "packages", dir);
449
+ const pkg = await readJson(join(base, "package.json"));
450
+ if (!pkg?.name) continue;
451
+ records.push({ ecosystem: "npm", name: pkg.name, dir: base, version: pkg.version ?? null, isPublic: pkg.private !== true, manifest: pkg });
452
+ }
453
+
454
+ const swiftDir = join(repoRoot, "swift", SWIFT_NAME);
455
+ const swiftVersion = (await readText(join(swiftDir, "VERSION")))?.trim() ?? null;
456
+ if (swiftVersion !== null) {
457
+ records.push({ ecosystem: "swift", name: SWIFT_NAME, dir: swiftDir, version: swiftVersion, isPublic: true, manifest: null });
458
+ }
459
+
460
+ for (const record of records) {
461
+ record.hygiene = {
462
+ hasOwnership: await exists(join(record.dir, "rightkit-ownership.json")),
463
+ hasReadme: await exists(join(record.dir, "README.md")),
464
+ inCatalog: catalogNames.has(record.name),
465
+ };
466
+ Object.assign(record, feedFor(record, versions ?? {}));
467
+ }
468
+ return { repoRoot, versions: versions ?? {}, records };
469
+ }
470
+
471
+ // rightkit-versions.json: recorded (last published) and staged versions per package.
472
+ export function feedFor(record, versions) {
473
+ if (record.ecosystem === "swift") {
474
+ return { recorded: versions.swift?.[SWIFT_KEY]?.version ?? null, staged: versions.stagedSwift?.[SWIFT_KEY]?.version ?? null };
475
+ }
476
+ const bucket = record.ecosystem === "cargo" ? "cargo" : "npm";
477
+ const staged = record.ecosystem === "cargo" ? "stagedCargo" : "stagedNpm";
478
+ return {
479
+ recorded: versions.registry?.[bucket]?.[record.name] ?? versions[bucket]?.[record.name] ?? null,
480
+ staged: versions[staged]?.[record.name] ?? null,
481
+ };
482
+ }
483
+
484
+ const subjectOf = (record) => `${record.ecosystem === "cargo" ? "crate" : record.ecosystem} ${record.name}`;
485
+
486
+ // ---------- section 1: registry parity (pure) ----------
487
+
488
+ export function assessParity(record) {
489
+ const subject = subjectOf(record);
490
+ const row = {
491
+ ecosystem: record.ecosystem, name: record.name, source: record.version ?? null, public: record.isPublic,
492
+ latest: null, recorded: record.recorded ?? null, staged: record.staged ?? null, status: "ok",
493
+ };
494
+ const findings = [];
495
+ const add = (kind, detail, severity) => findings.push(issue("registry", subject, kind, detail, severity));
496
+
497
+ if (record.liveError) {
498
+ add("unverified", `registry lookup failed: ${record.liveError}`, record.ecosystem === "swift" ? INFO : ACTION);
499
+ row.status = "unverified";
500
+ return { row, findings };
501
+ }
502
+ const live = record.live ?? { versions: [], latest: null };
503
+ const versions = live.versions;
504
+ row.latest = live.latest ?? null;
505
+
506
+ if (!record.isPublic) {
507
+ if (versions.length > 0) add("published-but-private", `${versions.length} version(s) on the registry; source is private`);
508
+ else add(record.ecosystem === "cargo" ? "private-crate" : "private-package", "private source, not published (informational)");
509
+ } else if (versions.length === 0) {
510
+ add("never-published", `no versions on the registry; source ${record.version ?? "unknown"}`);
511
+ } else {
512
+ const src = record.version;
513
+ if (!src || !parseVersion(src)) add("unverified", "source version could not be read");
514
+ const entry = src ? versions.find((v) => v.version === src) : undefined;
515
+ if (src && !entry) add("source-unpublished", `source ${src} is not on the registry${record.staged ? ` (feed stages ${record.staged})` : ""}`);
516
+ if (entry?.yanked) add("yanked", `source ${src} is yanked on the registry`);
517
+ if (entry?.deprecated) add("deprecated", `source ${src} is deprecated: ${entry.deprecated}`);
518
+ if (record.staged && record.staged !== src && !versions.some((v) => v.version === record.staged)) {
519
+ add("staged-not-published", `feed stages ${record.staged}; it is not on the registry`);
520
+ }
521
+ if (!live.latest) add("yanked", "every published version is yanked or deprecated");
522
+ else if (src && parseVersion(src) && compareVersions(live.latest, src) > 0) {
523
+ add("registry-newer-than-source", `registry latest ${live.latest} is newer than source ${src}`);
524
+ }
525
+ if (!record.recorded) {
526
+ if (live.latest) add("feed-out-of-date", `rightkit-versions.json has no entry; registry latest ${live.latest}`);
527
+ } else if (!versions.some((v) => v.version === record.recorded)) {
528
+ add("feed-out-of-date", `feed records ${record.recorded}, which is not on the registry`);
529
+ } else if (live.latest && compareVersions(live.latest, record.recorded) > 0) {
530
+ add("feed-out-of-date", `feed records ${record.recorded}; registry latest is ${live.latest}`);
531
+ }
532
+ }
533
+ row.status = findings.length ? findings.map((f) => f.kind).join(", ") : "ok";
534
+ return { row, findings };
535
+ }
536
+
537
+ // ---------- section 2: account parity ----------
538
+
539
+ async function searchCrateNames(get) {
540
+ const names = [];
541
+ for (let page = 1; page <= 10; page++) {
542
+ const body = await jsonOrNull(get, `https://crates.io/api/v1/crates?q=rightkit&per_page=100&page=${page}`);
543
+ const crates = body?.crates ?? [];
544
+ names.push(...crates.map((c) => c.name));
545
+ if (crates.length < 100 || names.length >= (body?.meta?.total ?? 0)) break;
546
+ }
547
+ return [...new Set(names)];
548
+ }
549
+
550
+ // Lists @rightkit/* via the scope query. That query has returned no results on some days, so an
551
+ // empty answer falls back to a plain "@rightkit" text search and the report says so.
552
+ async function searchNpmNames(get) {
553
+ for (const text of ["scope:rightkit", "%40rightkit"]) {
554
+ const names = [];
555
+ for (let from = 0; from < 2500; from += 250) {
556
+ const body = await jsonOrNull(get, `https://registry.npmjs.org/-/v1/search?text=${text}&size=250&from=${from}`);
557
+ const objects = body?.objects ?? [];
558
+ names.push(...objects.map((o) => o.package?.name).filter((n) => typeof n === "string" && n.startsWith("@rightkit/")));
559
+ if (objects.length < 250 || from + 250 >= (body?.total ?? 0)) break;
560
+ }
561
+ if (names.length) return { names: [...new Set(names)], query: text };
562
+ }
563
+ return { names: [], query: null };
564
+ }
565
+
566
+ async function checkAccount({ records, get, findings }) {
567
+ const section = { crates: { verified: false, owned: [] }, npm: { verified: false, owned: [] } };
568
+ try {
569
+ const probe = await jsonOrNull(get, `https://crates.io/api/v1/crates/${OWNER_PROBE_CRATE}/owner_user`);
570
+ const ownerIds = new Set((probe?.users ?? []).map((u) => u.id));
571
+ if (ownerIds.size === 0) throw new Error(`no owner found for ${OWNER_PROBE_CRATE}`);
572
+ const names = await searchCrateNames(get);
573
+ const candidates = [...new Set([...names.filter((n) => n.startsWith("rightkit-")), ...EXTRA_ACCOUNT_CRATES])];
574
+ const owned = await mapLimit(candidates, CONCURRENCY, async (name) => {
575
+ const body = await jsonOrNull(get, `https://crates.io/api/v1/crates/${name}/owner_user`);
576
+ return (body?.users ?? []).some((u) => ownerIds.has(u.id)) ? name : null;
577
+ });
578
+ section.crates = { verified: true, owned: owned.filter(Boolean).sort(), candidates: candidates.length };
579
+ } catch (error) {
580
+ findings.push(issue("account", "crates.io", "unverified", `crate account listing failed: ${error.message}`));
581
+ }
582
+ try {
583
+ const listing = await searchNpmNames(get);
584
+ section.npm = { verified: true, owned: listing.names.sort(), query: listing.query };
585
+ if (listing.query !== "scope:rightkit") {
586
+ findings.push(issue("account", "npm", "unverified", `scope:rightkit search returned nothing; listed via ${listing.query ?? "no query"} instead`, INFO));
587
+ }
588
+ } catch (error) {
589
+ findings.push(issue("account", "npm", "unverified", `npm scope listing failed: ${error.message}`));
590
+ }
591
+
592
+ for (const [ecosystem, key] of [["cargo", "crates"], ["npm", "npm"]]) {
593
+ const listing = section[key];
594
+ if (!listing.verified) continue;
595
+ const known = new Set(records.filter((r) => r.ecosystem === ecosystem).map((r) => r.name));
596
+ const owned = new Set(listing.owned);
597
+ for (const name of listing.owned) {
598
+ if (!known.has(name)) findings.push(issue("account", `${ecosystem === "cargo" ? "crate" : "npm"} ${name}`, "orphan-registry-package", "published under our account with no source in the repo"));
599
+ }
600
+ for (const record of records.filter((r) => r.ecosystem === ecosystem && r.isPublic)) {
601
+ // npm's listing cannot prove ownership; a published @rightkit name is ours by scope, so
602
+ // only never-published npm packages are reported here (section 1 covers them).
603
+ if (owned.has(record.name) || (ecosystem === "npm" && record.live?.versions?.length)) continue;
604
+ const hasVersions = (record.live?.versions?.length ?? 0) > 0;
605
+ findings.push(issue("account", subjectOf(record), "missing-from-registry",
606
+ `not listed under our account${hasVersions ? "; registry has versions under another owner or the listing lags" : "; never published"}`));
607
+ }
608
+ }
609
+ return section;
610
+ }
611
+
612
+ // ---------- section 3: third-party requirements ----------
613
+
614
+ async function checkThirdParty({ repoRoot, records, live, findings }) {
615
+ const internalCargo = new Set(records.filter((r) => r.ecosystem === "cargo").map((r) => r.name));
616
+ const declarations = new Map();
617
+ const add = (ecosystem, name, req, where) => {
618
+ const key = `${ecosystem}:${name}:${req}`;
619
+ if (!declarations.has(key)) declarations.set(key, { ecosystem, name, req, wheres: new Set() });
620
+ declarations.get(key).wheres.add(where);
621
+ };
622
+
623
+ const rootManifest = await readToml(join(repoRoot, "Cargo.toml"));
624
+ const cargoSources = [{ where: "Cargo.toml", manifest: rootManifest }];
625
+ for (const record of records.filter((r) => r.ecosystem === "cargo")) {
626
+ cargoSources.push({ where: relative(repoRoot, join(record.dir, "Cargo.toml")), manifest: record.manifest });
627
+ }
628
+ for (const { where, manifest } of cargoSources) {
629
+ if (!manifest) continue;
630
+ for (const dep of cargoDeclarations(manifest)) {
631
+ if (internalCargo.has(dep.name) || dep.name.startsWith("rightkit-") || dep.name.startsWith("agent-room")) continue;
632
+ add("cargo", dep.name, dep.req, where);
633
+ }
634
+ }
635
+ for (const record of records.filter((r) => r.ecosystem === "npm" && r.isPublic)) {
636
+ for (const dep of npmDeclarations(record.manifest)) {
637
+ if (dep.name.startsWith("@rightkit/")) continue;
638
+ add("npm", dep.name, dep.req, relative(repoRoot, join(record.dir, "package.json")));
639
+ }
640
+ }
641
+
642
+ const grouped = new Map();
643
+ for (const decl of declarations.values()) {
644
+ const key = `${decl.ecosystem}:${decl.name}`;
645
+ if (!grouped.has(key)) grouped.set(key, []);
646
+ grouped.get(key).push(decl);
647
+ }
648
+ let unique = 0;
649
+ await mapLimit([...grouped.values()], CONCURRENCY, async (decls) => {
650
+ unique++;
651
+ const { ecosystem, name } = decls[0];
652
+ let info;
653
+ try {
654
+ info = await live(ecosystem, name);
655
+ } catch (error) {
656
+ findings.push(issue("thirdParty", `${ecosystem} ${name}`, "unverified", `registry lookup failed: ${error.message}`));
657
+ return;
658
+ }
659
+ if (!info.latest) {
660
+ findings.push(issue("thirdParty", `${ecosystem} ${name}`, "unverified", "no stable version found on the registry (prerelease or empty)", INFO));
661
+ return;
662
+ }
663
+ for (const decl of decls) {
664
+ const ok = satisfies(decl.req, info.latest, ecosystem);
665
+ const wheres = [...decl.wheres].sort().join(", ");
666
+ if (ok === null) findings.push(issue("thirdParty", `${ecosystem} ${name}`, "unsupported-requirement", `requirement "${decl.req}" in ${wheres} is not modelled`));
667
+ else if (ok === false) {
668
+ findings.push(issue("thirdParty", `${ecosystem} ${name}`, "outdated-requirement",
669
+ `"${decl.req}" in ${wheres} excludes latest ${info.latest} (${behindKind(decl.req, info.latest, ecosystem)} behind)`));
670
+ }
671
+ }
672
+ });
673
+
674
+ const lockDuplicates = [];
675
+ const lockText = await readText(join(repoRoot, "Cargo.lock"));
676
+ if (lockText !== null) {
677
+ for (const dup of duplicateCargoVersions(lockText)) {
678
+ lockDuplicates.push(dup);
679
+ findings.push(issue("thirdParty", `crate ${dup.name}`, "duplicate-lock-versions", `Cargo.lock resolves ${dup.versions.join(", ")}`));
680
+ }
681
+ }
682
+ return { declarations: declarations.size, uniquePackages: unique, lockDuplicates };
683
+ }
684
+
685
+ // ---------- lock parsing ----------
686
+
687
+ export function duplicateCargoVersions(lockText, filter = () => true) {
688
+ const byName = new Map();
689
+ for (const pkg of parseToml(lockText).package ?? []) {
690
+ if (!filter(pkg.name)) continue;
691
+ if (!byName.has(pkg.name)) byName.set(pkg.name, new Set());
692
+ byName.get(pkg.name).add(pkg.version);
693
+ }
694
+ return [...byName].filter(([, versions]) => versions.size > 1)
695
+ .map(([name, versions]) => ({ name, versions: [...versions].sort() }))
696
+ .sort((a, b) => a.name.localeCompare(b.name));
697
+ }
698
+
699
+ // Versions per package in a pnpm lock's `packages:` block (v6 and v9 key forms).
700
+ export function pnpmPackageVersions(lockText, filter = () => true) {
701
+ const byName = new Map();
702
+ let inPackages = false;
703
+ for (const line of lockText.split("\n")) {
704
+ if (/^\S/.test(line)) {
705
+ inPackages = line.trimEnd() === "packages:";
706
+ continue;
707
+ }
708
+ if (!inPackages) continue;
709
+ const match = /^ {2}'?\/?(@?[^@'\s]+(?:\/[^@'\s]+)?)@([^'\s:(]+)(?:\([^']*\))?'?:\s*$/.exec(line);
710
+ if (!match || !filter(match[1])) continue;
711
+ if (!byName.has(match[1])) byName.set(match[1], new Set());
712
+ byName.get(match[1]).add(match[2]);
713
+ }
714
+ return [...byName].filter(([, versions]) => versions.size > 1)
715
+ .map(([name, versions]) => ({ name, versions: [...versions].sort() }));
716
+ }
717
+
718
+ // ---------- section 4: app adoption ----------
719
+
720
+ // Reuses the adoption generator's own output (pin statuses and split pins) instead of a second
721
+ // manifest scanner. Lock files are the one extra read; the generator does not read them.
722
+ async function checkApps({ appsRoot, apps, generatedAdoption, findings }) {
723
+ const rows = [];
724
+ const lines = generatedAdoption.split("\n");
725
+ const section = (title) => {
726
+ const start = lines.indexOf(title);
727
+ if (start < 0) return [];
728
+ const body = [];
729
+ for (let i = start + 1; i < lines.length && !lines[i].startsWith("## "); i++) body.push(lines[i]);
730
+ return body.filter((line) => line.startsWith("| "));
731
+ };
732
+ const cells = (line) => line.slice(1, line.lastIndexOf("|")).split("|").map((c) => c.trim().replace(/^`|`$/g, ""));
733
+ const pinRows = section("## Pins").slice(2).map(cells);
734
+ for (const [name, app, manifest, , requirement, latest, status] of pinRows) {
735
+ if (status === "stale") {
736
+ findings.push(issue("apps", `${name} (${app})`, "outdated-pin", `${manifest} pins "${requirement}"; latest published ${latest}`));
737
+ } else if (status === "unpublished") {
738
+ findings.push(issue("apps", `${name} (${app})`, "unpublished-pin", `${manifest} pins "${requirement}" but no published version is recorded`));
739
+ } else if (status === "unparsed") {
740
+ findings.push(issue("apps", `${name} (${app})`, "unsupported-requirement", `${manifest} requirement "${requirement}" is not understood`));
741
+ }
742
+ }
743
+ for (const [name, ecosystem, app, requirements] of section("## Duplicate copies and split pins").slice(2).map(cells)) {
744
+ findings.push(issue("apps", `${name} (${app})`, "split-app-pins", `${ecosystem} pins differ within one app: ${requirements}`));
745
+ }
746
+
747
+ for (const app of apps) {
748
+ const appDir = join(appsRoot, app);
749
+ if (!(await exists(appDir))) {
750
+ findings.push(issue("apps", app, "app-missing", "app repository not found under the workspace root"));
751
+ rows.push({ app, present: false, duplicates: 0 });
752
+ continue;
753
+ }
754
+ let duplicates = 0;
755
+ for (const lockPath of await walkNamed(appDir, ["Cargo.lock"], 5)) {
756
+ for (const dup of duplicateCargoVersions(await readFile(lockPath, "utf8"), (n) => n.startsWith("rightkit-"))) {
757
+ duplicates++;
758
+ findings.push(issue("apps", `${dup.name} (${app})`, "duplicate-app-copies", `${relative(appDir, lockPath)} resolves ${dup.versions.join(", ")}`));
759
+ }
760
+ }
761
+ for (const lockPath of await walkNamed(appDir, ["pnpm-lock.yaml"], 4)) {
762
+ const text = await readFile(lockPath, "utf8");
763
+ for (const dup of pnpmPackageVersions(text, (n) => n.startsWith("@rightkit/"))) {
764
+ duplicates++;
765
+ findings.push(issue("apps", `${dup.name} (${app})`, "duplicate-app-copies", `${relative(appDir, lockPath)} resolves ${dup.versions.join(", ")}`));
766
+ }
767
+ }
768
+ rows.push({ app, present: true, duplicates });
769
+ }
770
+ return rows;
771
+ }
772
+
773
+ // ---------- section 5: generated docs ----------
774
+
775
+ function checkGenerated({ repoRoot, appsRoot, apps, findings }) {
776
+ const local = generateLocal({ root: repoRoot });
777
+ for (const rel of driftOf(repoRoot, local)) {
778
+ findings.push(issue("generated", rel, "generated-doc-drift", "committed file differs from the generator output; run generate-index.mjs"));
779
+ }
780
+ const adoption = generateAdoption({ root: repoRoot, workspace: appsRoot, apps });
781
+ for (const rel of driftOf(repoRoot, adoption)) {
782
+ findings.push(issue("generated", rel, "generated-doc-drift", "committed adoption report differs from the app repos; run generate-index.mjs"));
783
+ }
784
+ return { local, adoption: Object.values(adoption)[0] };
785
+ }
786
+
787
+ // ---------- section 6: hygiene ----------
788
+
789
+ function checkHygiene(records, findings) {
790
+ for (const record of records) {
791
+ if (!record.hygiene.hasOwnership) findings.push(issue("hygiene", subjectOf(record), "missing-ownership-file", "no rightkit-ownership.json"));
792
+ if (!record.hygiene.hasReadme) findings.push(issue("hygiene", subjectOf(record), "missing-readme", "no README.md"));
793
+ if (!record.hygiene.inCatalog) findings.push(issue("hygiene", subjectOf(record), "missing-from-catalog", "not listed in packages/release/rightkit-ownership.json"));
794
+ }
795
+ }
796
+
797
+ // ---------- orchestration ----------
798
+
799
+ export async function runInventoryCheck(options = {}) {
800
+ const repoRoot = resolve(options.repoRoot ?? DEFAULT_REPO_ROOT);
801
+ const appsRoot = resolve(options.appsRoot ?? DEFAULT_WORKSPACE);
802
+ const apps = options.apps ?? APP_REPOS;
803
+ const get = options.get ?? createFetcher(options.http ?? {});
804
+ const liveIndex = createLiveIndex(get);
805
+ const findings = [];
806
+ const source = await loadSource(repoRoot);
807
+ const { records } = source;
808
+
809
+ // Registry lookups for every in-scope package; Swift uses GitHub tags.
810
+ await mapLimit(records, CONCURRENCY, async (record) => {
811
+ try {
812
+ record.live = record.ecosystem === "swift" ? await fetchSwiftTags(get) : await liveIndex(record.ecosystem, record.name);
813
+ } catch (error) {
814
+ record.liveError = error.message;
815
+ }
816
+ });
817
+
818
+ const registryRows = [];
819
+ for (const record of records) {
820
+ const { row, findings: found } = assessParity(record);
821
+ registryRows.push(row);
822
+ findings.push(...found);
823
+ }
824
+
825
+ const account = await checkAccount({ records, get, findings });
826
+ const thirdParty = await checkThirdParty({ repoRoot, records, live: liveIndex, findings });
827
+ const generated = checkGenerated({ repoRoot, appsRoot, apps, findings });
828
+ const appRows = await checkApps({ appsRoot, apps, generatedAdoption: generated.adoption, findings });
829
+ checkHygiene(records, findings);
830
+
831
+ const counts = { action: 0, info: 0 };
832
+ for (const f of findings) counts[f.severity] += 1;
833
+ return {
834
+ schema: 1,
835
+ generatedAt: new Date().toISOString(),
836
+ repoRoot,
837
+ appsRoot,
838
+ counts,
839
+ findings,
840
+ sections: {
841
+ registry: { rows: registryRows },
842
+ account: { crates: account.crates, npm: account.npm },
843
+ thirdParty: { declarations: thirdParty.declarations, uniquePackages: thirdParty.uniquePackages, lockDuplicates: thirdParty.lockDuplicates },
844
+ apps: { rows: appRows },
845
+ generated: { checked: ["INDEX.md", "docs/CAPABILITY-MAP.md", "docs/ADOPTION.md"] },
846
+ hygiene: { checked: records.length },
847
+ },
848
+ };
849
+ }
850
+
851
+ export function decideExit(report, warnOnly = false) {
852
+ return warnOnly ? 0 : report.counts.action > 0 ? 1 : 0;
853
+ }
854
+
855
+ // ---------- rendering ----------
856
+
857
+ const esc = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
858
+
859
+ export function renderSummary(report, markdownPath) {
860
+ const out = [];
861
+ out.push(`RightKit inventory check (${report.generatedAt})`);
862
+ for (const [key, title] of Object.entries(SECTIONS)) {
863
+ const list = report.findings.filter((f) => f.section === key);
864
+ const action = list.filter((f) => f.severity === ACTION).length;
865
+ out.push(` ${title.padEnd(30)} ${action} action, ${list.length - action} info`);
866
+ }
867
+ const actions = report.findings.filter((f) => f.severity === ACTION);
868
+ if (actions.length) {
869
+ out.push("", `Action items (${actions.length}):`);
870
+ for (const f of actions) out.push(` [${f.section}] ${f.subject}: ${f.kind}, ${f.detail}`);
871
+ }
872
+ out.push("", `Result: ${report.counts.action} action, ${report.counts.info} info. Report: ${markdownPath}`);
873
+ return `${out.join("\n")}\n`;
874
+ }
875
+
876
+ export function renderMarkdown(report) {
877
+ const lines = [];
878
+ lines.push("# RightKit inventory status", "");
879
+ lines.push(`Generated: ${report.generatedAt}. Command: \`right-release inventory check\`.`, "");
880
+ lines.push(`Result: ${report.counts.action} action item(s), ${report.counts.info} info item(s).`, "");
881
+ lines.push("Sources: crates.io sparse index and search API, npm registry, GitHub tags for RightKitSwift, and the local manifests. No credentials are read. Registry responses are cached for one hour under the OS temp directory.", "");
882
+ lines.push("## Summary", "", "| Section | Action | Info |", "| --- | ---: | ---: |");
883
+ for (const [key, title] of Object.entries(SECTIONS)) {
884
+ const list = report.findings.filter((f) => f.section === key);
885
+ const action = list.filter((f) => f.severity === ACTION).length;
886
+ lines.push(`| ${title} | ${action} | ${list.length - action} |`);
887
+ }
888
+ lines.push("", `## ${SECTIONS.registry}`, "", "| Ecosystem | Package | Public | Source | Registry latest | Feed recorded | Staged | Status |", "| --- | --- | --- | --- | --- | --- | --- | --- |");
889
+ for (const r of report.sections.registry.rows) {
890
+ lines.push(`| ${r.ecosystem} | \`${esc(r.name)}\` | ${r.public ? "yes" : "no"} | ${esc(r.source)} | ${esc(r.latest)} | ${esc(r.recorded)} | ${esc(r.staged)} | ${esc(r.status)} |`);
891
+ }
892
+ lines.push("", `## ${SECTIONS.account}`, "", `Crates owned by the \`${OWNER_PROBE_CRATE}\` owner: ${report.sections.account.crates.owned.length}. npm @rightkit packages listed: ${report.sections.account.npm.owned.length}.`, "");
893
+ const section = (key) => {
894
+ const list = report.findings.filter((f) => f.section === key);
895
+ lines.push(`## ${SECTIONS[key]}`, "");
896
+ if (!list.length) lines.push("No findings.", "");
897
+ else {
898
+ lines.push("| Severity | Subject | Kind | Detail |", "| --- | --- | --- | --- |");
899
+ for (const f of list) lines.push(`| ${f.severity} | ${esc(f.subject)} | ${f.kind} | ${esc(f.detail)} |`);
900
+ lines.push("");
901
+ }
902
+ };
903
+ section("account");
904
+ lines.push(`## ${SECTIONS.thirdParty}`, "", `Declarations checked: ${report.sections.thirdParty.declarations}. Unique external packages: ${report.sections.thirdParty.uniquePackages}.`, "");
905
+ section("thirdParty");
906
+ lines.push("Multiple versions in the root Cargo.lock:", "");
907
+ for (const dup of report.sections.thirdParty.lockDuplicates) lines.push(`- \`${dup.name}\`: ${dup.versions.join(", ")}`);
908
+ if (!report.sections.thirdParty.lockDuplicates.length) lines.push("- none");
909
+ lines.push("");
910
+ lines.push(`## ${SECTIONS.apps}`, "", "| App | Present | Duplicate copies in lock files |", "| --- | --- | ---: |");
911
+ for (const row of report.sections.apps.rows) lines.push(`| ${row.app} | ${row.present ? "yes" : "**missing**"} | ${row.duplicates} |`);
912
+ lines.push("");
913
+ section("apps");
914
+ section("generated");
915
+ section("hygiene");
916
+ return `${lines.join("\n")}\n`;
917
+ }
918
+
919
+ // ---------- CLI ----------
920
+
921
+ const USAGE = `right-release inventory check [--json] [--warn-only] [--md <file>] [--no-cache] [--apps-root <dir>]
922
+
923
+ Checks RightKit registry parity, account parity, third-party requirements,
924
+ app adoption, generated docs, and package hygiene. Exit 1 when any action item
925
+ exists (0 with --warn-only). Writes docs/INVENTORY-STATUS.md unless --md is given.
926
+ `;
927
+
928
+ export function parseArgs(argv) {
929
+ const options = { json: false, warnOnly: false, noCache: false, md: null, appsRoot: null, help: false };
930
+ for (let i = 0; i < argv.length; i++) {
931
+ const arg = argv[i];
932
+ if (arg === "--json") options.json = true;
933
+ else if (arg === "--warn-only") options.warnOnly = true;
934
+ else if (arg === "--no-cache") options.noCache = true;
935
+ else if (arg === "--md") options.md = resolve(argv[++i] ?? "");
936
+ else if (arg === "--apps-root") options.appsRoot = resolve(argv[++i] ?? "");
937
+ else if (arg === "-h" || arg === "--help") options.help = true;
938
+ else throw new Error(`unknown argument: ${arg}`);
939
+ }
940
+ return options;
941
+ }
942
+
943
+ export async function main(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
944
+ let options;
945
+ try {
946
+ options = parseArgs(argv);
947
+ } catch (error) {
948
+ io.stderr.write(`inventory check: ${error.message}\n${USAGE}`);
949
+ return 2;
950
+ }
951
+ if (options.help) {
952
+ io.stdout.write(USAGE);
953
+ return 0;
954
+ }
955
+ const report = await runInventoryCheck({
956
+ appsRoot: options.appsRoot ?? undefined,
957
+ http: options.noCache ? { cacheDir: null } : {},
958
+ });
959
+ const mdPath = options.md ?? join(report.repoRoot, "docs", "INVENTORY-STATUS.md");
960
+ await mkdir(dirname(mdPath), { recursive: true });
961
+ await writeFile(mdPath, renderMarkdown(report));
962
+ io.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report, mdPath));
963
+ return decideExit(report, options.warnOnly);
964
+ }
965
+
966
+ const realOrSelf = (file) => {
967
+ try {
968
+ return realpathSync(file);
969
+ } catch {
970
+ return resolve(file);
971
+ }
972
+ };
973
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
974
+ main().then((code) => {
975
+ process.exitCode = code;
976
+ }, (error) => {
977
+ process.stderr.write(`inventory check failed: ${error.stack ?? error.message}\n`);
978
+ process.exitCode = 2;
979
+ });
980
+ }