@rightkit/release 0.6.6 → 0.6.7

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 (45) hide show
  1. package/README.md +122 -9
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +495 -344
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/gate-executor.mjs +30 -0
  26. package/gate-stages.mjs +110 -0
  27. package/github-updater-release.mjs +48 -8
  28. package/init-command.mjs +41 -0
  29. package/legion-semantic-contracts.mjs +428 -0
  30. package/ownership-class-rules.mjs +277 -0
  31. package/ownership-key-authority.mjs +3 -1
  32. package/ownership-mandatory-set.mjs +3 -2
  33. package/package.json +10 -3
  34. package/publish-cargo.mjs +5 -2
  35. package/publish-update.mjs +14 -11
  36. package/release-config.mjs +51 -6
  37. package/rightkit-versions.json +172 -167
  38. package/shell-contract-bridge.json +251 -0
  39. package/shell-contract.mjs +1069 -0
  40. package/sign-updater.mjs +24 -4
  41. package/source-closure-rules.mjs +204 -0
  42. package/stage-sidecar-command.mjs +2 -1
  43. package/stage-sidecar.mjs +437 -114
  44. package/suite-api.generated.mjs +96 -6
  45. package/suite-trust-roots.mjs +198 -0
@@ -0,0 +1,627 @@
1
+ // Consumer contracts: source contracts that broke consumer builds late (minutes into CI, after install or compile), checked from
2
+ // the committed files before anything runs. Each rule is { id, gap, run({ root, files, now }) } like the app-check rules and returns
3
+ // findings [{ file, subject, problem, fix }]; `problem` starts with "<file>:<line>" where a line is known. Builtins only: no
4
+ // node_modules, no network, no cargo, no pnpm, and no repository code is executed (a right-release.config.mjs is read as text).
5
+ //
6
+ // release-config-identity CA-01 a right-release config with no top-level `app` or `version` (build-release.mjs: "release config
7
+ // must expose app and version"; Ember run 38858187099)
8
+ // pnpm-node-floor CA-05 a CI job that installs pnpm on a Node below what the pinned pnpm requires (pnpm 11 needs Node
9
+ // 22.13: "No such built-in module: node:sqlite"; CutRight run 30718913302)
10
+ // rust-dependency-msrv CA-05 a Cargo.lock that resolves a crate whose published rust-version is above the pinned
11
+ // rust-toolchain (cargo: "rustc 1.90.0 is not supported by the following packages"; Pulse run 38026724922)
12
+ // release-age CA-06 a pnpm-lock.yaml entry younger than the workspace's minimumReleaseAge at the check's clock
13
+ // (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION; Pulse run 37683011855)
14
+ // notice-lock-digest CA-14 a third-party notice whose recorded lockfile sha256 is not the committed lockfile's (HeardRight)
15
+ // doc-comment-bare-cr CA-15 a carriage return not followed by a line feed inside a Rust doc comment (rustc: "bare CR not
16
+ // allowed in doc-comment"; Pulse run 38110627257)
17
+ //
18
+ // Pinned inputs. The pnpm Node floor and default release age, the crates.io rust_version of RightKit's crates and the npm publish
19
+ // times of RightKit's packages come from consumer-contracts.json, a snapshot with its source and fetch time; a rule never asks a
20
+ // registry. A crate or package version the snapshot does not list is not judged (the snapshot's limit, not a pass). The clock is
21
+ // the check's `now` (runRules passes it; tests pin it).
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import { createRequire } from "node:module";
25
+ import path from "node:path";
26
+
27
+ const DATA_FILE = new URL("./consumer-contracts.json", import.meta.url);
28
+ let cachedData = null;
29
+ /** The pinned registry and tool metadata the rules read. */
30
+ export function contractData() {
31
+ cachedData ??= JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
32
+ return cachedData;
33
+ }
34
+
35
+ const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
36
+ // Recorded history is not a live notice: evidence, fixtures, plans and archives keep the digests of their day.
37
+ const HISTORY_TREE = /(^|\/)(evidence|release-evidence|receipts?|fixtures?|archive|audits|plans|handoffs?|reports?|provenance)\//i;
38
+ const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8").replace(/^/, ""); } catch { return null; } };
39
+ const readBytes = (root, rel) => { try { return fs.readFileSync(path.join(root, rel)); } catch { return null; } };
40
+ const lineOf = (text, offset) => { let line = 1; for (let i = text.indexOf("\n"); i !== -1 && i < offset; i = text.indexOf("\n", i + 1)) line += 1; return line; };
41
+ const dirOf = (file) => (file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "");
42
+ const join = (dir, rel) => (dir ? `${dir}/${rel}` : rel);
43
+ /** `dir` and each directory above it, nearest first, ending with the root (""). */
44
+ const upward = (dir) => { const out = []; for (let d = dir; ; d = dirOf(d)) { out.push(d); if (d === "") return out; } };
45
+
46
+ // ---- versions ---------------------------------------------------------------------------------------------------------------------
47
+
48
+ /** "1.90", "v22.13.0", "20.x" -> [major, minor|null, patch|null]; null when it is not a plain version (lts/*, latest, an expression). */
49
+ export function versionParts(text) {
50
+ const m = /^v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/i.exec(String(text).trim());
51
+ if (!m) return null;
52
+ const n = (s) => (s === undefined || /^[x*]$/i.test(s) ? null : Number(s));
53
+ return [Number(m[1]), n(m[2]), n(m[3])];
54
+ }
55
+ /** Compare two full versions a.b.c; -1, 0 or 1. */
56
+ export function compareVersions(a, b) {
57
+ const pa = versionParts(a) ?? [0, 0, 0], pb = versionParts(b) ?? [0, 0, 0];
58
+ for (let i = 0; i < 3; i += 1) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return Math.sign(d); }
59
+ return 0;
60
+ }
61
+ /**
62
+ * Whether a selected version (possibly partial: "20", "22.x") can be below `floor`. A missing part resolves to the newest release of
63
+ * that line (actions/setup-node and rustup both do), so "22" satisfies 22.13.0 and "20" does not.
64
+ */
65
+ export function selectionBelow(selected, floor) {
66
+ const s = versionParts(selected), f = versionParts(floor);
67
+ if (!s || !f) return false;
68
+ for (let i = 0; i < 3; i += 1) {
69
+ if (s[i] === null) return false;
70
+ if (s[i] !== (f[i] ?? 0)) return s[i] < (f[i] ?? 0);
71
+ }
72
+ return false;
73
+ }
74
+ /** The row of `table` ([{ pnpm, ... }], ascending) that applies to pnpm `version`, or null. */
75
+ const pnpmRow = (table, version) => [...table].reverse().find((row) => compareVersions(version, row.pnpm) >= 0) ?? null;
76
+
77
+ /** The pnpm version a package.json pins in packageManager ("pnpm@11.16.0+sha512..." -> "11.16.0"), or null. */
78
+ function pnpmPin(root, file) {
79
+ try { return /^pnpm@(\d+\.\d+\.\d+)/.exec(JSON.parse(readText(root, file) ?? "null")?.packageManager ?? "")?.[1] ?? null; } catch { return null; }
80
+ }
81
+
82
+ // ---- CA-01 release-config-identity ------------------------------------------------------------------------------------------------
83
+
84
+ const CONFIG_FILE = /(^|\/)right-release\.config\.(?:mjs|json)$/;
85
+ const SAFE_APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
86
+ const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
87
+
88
+ /** `text` with comments, strings and template literals blanked to spaces (same length, newlines kept), so braces can be counted. */
89
+ export function blankJs(text) {
90
+ let out = "";
91
+ for (let i = 0; i < text.length;) {
92
+ const c = text[i], next = text[i + 1];
93
+ const keep = (s) => s.replace(/[^\n]/g, " ");
94
+ if (c === "/" && next === "/") { const end = text.indexOf("\n", i); const stop = end < 0 ? text.length : end; out += keep(text.slice(i, stop)); i = stop; continue; }
95
+ if (c === "/" && next === "*") { const end = text.indexOf("*/", i + 2); const stop = end < 0 ? text.length : end + 2; out += keep(text.slice(i, stop)); i = stop; continue; }
96
+ if (c === "'" || c === '"' || c === "`") {
97
+ let j = i + 1;
98
+ while (j < text.length && text[j] !== c) { if (text[j] === "\\") j += 1; else if (c !== "`" && text[j] === "\n") break; j += 1; }
99
+ out += c + keep(text.slice(i + 1, j)) + (j < text.length ? c : ""); i = j + 1; continue;
100
+ }
101
+ out += c; i += 1;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** The top-level keys of the object literal that opens at `open` in blanked `skel`: { keys: Map(name -> offset), spread }. */
107
+ function objectKeys(skel, text, open) {
108
+ const keys = new Map();
109
+ let spread = false, depth = 0, expectKey = true;
110
+ for (let i = open; i < skel.length; i += 1) {
111
+ const c = skel[i];
112
+ if (c === "{" || c === "[" || c === "(") { depth += 1; if (depth === 1) expectKey = true; continue; }
113
+ if (c === "}" || c === "]" || c === ")") { depth -= 1; if (depth === 0) return { keys, spread, close: i }; continue; }
114
+ if (depth !== 1) continue;
115
+ if (c === ",") { expectKey = true; continue; }
116
+ if (!expectKey || /\s/.test(c)) continue;
117
+ expectKey = false;
118
+ if (skel.startsWith("...", i)) { spread = true; continue; }
119
+ // A key: an identifier, or a quoted name (blanked in skel; read it from text). Shorthand `version,` counts.
120
+ const quoted = /^(['"])([^'"\n]*)\1\s*:/.exec(text.slice(i));
121
+ if (quoted) { keys.set(quoted[2], i); continue; }
122
+ const ident = /^([A-Za-z_$][\w$]*)\s*(?=[:,}(])/.exec(skel.slice(i));
123
+ if (ident) keys.set(ident[1], i);
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** The object literal a .mjs config exports by default: { open } or null when it is not a literal this reader can follow. */
129
+ function defaultExportObject(skel) {
130
+ const m = /\bexport\s+default\s+/.exec(skel);
131
+ if (!m) return null;
132
+ let at = m.index + m[0].length;
133
+ const call = /^[A-Za-z_$][\w$.]*\s*\(\s*/.exec(skel.slice(at)); // defineConfig({ ... })
134
+ if (call) at += call[0].length;
135
+ if (skel[at] === "{") return { open: at };
136
+ const name = /^([A-Za-z_$][\w$]*)\s*;?/.exec(skel.slice(at))?.[1];
137
+ if (!name) return null;
138
+ const decl = new RegExp(`\\b(?:const|let|var)\\s+${name.replace(/\$/g, "\\$")}\\s*=\\s*`).exec(skel);
139
+ if (!decl) return null;
140
+ const open = decl.index + decl[0].length;
141
+ return skel[open] === "{" ? { open } : null;
142
+ }
143
+
144
+ /** The source text of the value of the property whose key starts at `offset`: { shorthand: name } or { expr, at } (offset of the value). */
145
+ function propertyValue(skel, text, offset) {
146
+ const key = /^(['"]?)([A-Za-z_$][\w$-]*)\1\s*/.exec(text.slice(offset));
147
+ if (!key) return null;
148
+ let i = offset + key[0].length;
149
+ if (skel[i] !== ":") return { shorthand: key[2] };
150
+ i += 1;
151
+ let depth = 0, j = i;
152
+ for (; j < skel.length; j += 1) {
153
+ const c = skel[j];
154
+ if (c === "{" || c === "[" || c === "(") depth += 1;
155
+ else if (c === "}" || c === "]" || c === ")") { if (depth === 0) break; depth -= 1; }
156
+ else if (c === "," && depth === 0) break;
157
+ }
158
+ const raw = text.slice(i, j), lead = raw.length - raw.trimStart().length;
159
+ return { expr: raw.trim(), at: i + lead };
160
+ }
161
+
162
+ const NON_STRING = /^(undefined|null|true|false|void\s+0|NaN|-?\d[\d_]*(\.\d+)?([eE][+-]?\d+)?|\[[^]*\]|\{[^]*\})$/;
163
+ /**
164
+ * What an expression in the config is, statically: { value } for a string literal (or a top-level `const` bound to one, followed
165
+ * through at most four names), { bad } for an absent or non-string value, { unsupported } for anything this reader cannot prove
166
+ * (a call, a member, an interpolated template, an import, a `let`/`var`, a name with no top-level declaration).
167
+ */
168
+ export function staticString(skel, text, expr, seen = []) {
169
+ const str = /^(['"])((?:[^'"\\\n]|\\.)*)\1$/.exec(expr) ?? /^(`)([^`$\\]*)`$/.exec(expr);
170
+ if (str) return { value: str[2] };
171
+ if (NON_STRING.test(expr)) return { bad: `${expr.length > 40 ? `${expr.slice(0, 40)}…` : expr} (not a string)` };
172
+ const name = /^[A-Za-z_$][\w$]*$/.exec(expr)?.[0];
173
+ if (!name) return { unsupported: `the expression \`${expr.length > 60 ? `${expr.slice(0, 60)}…` : expr}\`` };
174
+ if (seen.includes(name) || seen.length >= 4) return { unsupported: `the name chain ${[...seen, name].join(" -> ")}` };
175
+ if (new RegExp(`\\bimport\\b[^;]*\\b${name.replace(/\$/g, "\\$")}\\b[^;]*\\bfrom\\b`).test(skel)) return { unsupported: `\`${name}\`, an imported binding` };
176
+ const decl = new RegExp(`\\b(const|let|var)\\s+${name.replace(/\$/g, "\\$")}\\s*=\\s*`, "g");
177
+ for (let m; (m = decl.exec(skel));) {
178
+ const before = skel.slice(0, m.index);
179
+ const depth = (before.match(/[{([]/g) ?? []).length - (before.match(/[})\]]/g) ?? []).length;
180
+ if (depth !== 0) continue; // only top-level declarations
181
+ if (m[1] !== "const") return { unsupported: `\`${name}\`, declared with ${m[1]} (it can be reassigned)` };
182
+ const at = m.index + m[0].length;
183
+ let d = 0, j = at;
184
+ for (; j < skel.length; j += 1) {
185
+ const c = skel[j];
186
+ if (c === "{" || c === "[" || c === "(") d += 1;
187
+ else if (c === "}" || c === "]" || c === ")") d -= 1;
188
+ else if ((c === ";" || c === "\n") && d === 0) break;
189
+ }
190
+ const resolved = staticString(skel, text, text.slice(at, j).trim(), [...seen, name]);
191
+ return resolved.unsupported && !seen.length ? { unsupported: `\`${name}\` = ${resolved.unsupported}` } : resolved;
192
+ }
193
+ return { unsupported: `\`${name}\`, which has no top-level declaration in this file` };
194
+ }
195
+
196
+ let tomlParse;
197
+ /** smol-toml's parse (the TOML parser release-config.mjs uses), loaded synchronously from this package's own dependencies, or null. */
198
+ function tomlParser() {
199
+ if (tomlParse === undefined) { try { tomlParse = createRequire(import.meta.url)("smol-toml").parse; } catch { tomlParse = null; } }
200
+ return tomlParse;
201
+ }
202
+
203
+ /**
204
+ * The string a { $fromJson: { file, pointer } } or { $fromToml: { file, path } } node selects, resolved as release-config.mjs does
205
+ * (readInside :173-184, jsonPointerGet :186-196, the $fromToml walk :226-239) over the committed file: { value } for a non-empty string,
206
+ * { bad } for a missing file, pointer or path, or a non-string value, { unsupported } when no TOML parser is installed.
207
+ */
208
+ export function resolveTyped(root, set, appDir, node) {
209
+ const json = "$fromJson" in node;
210
+ const spec = json ? node.$fromJson : node.$fromToml;
211
+ const kind = json ? "$fromJson" : "$fromToml";
212
+ if (!spec || typeof spec !== "object" || typeof spec.file !== "string" || (!json && typeof spec.path !== "string")) return { bad: `has a ${kind} that is not { file, ${json ? "pointer" : "path"} }` };
213
+ const rel = spec.file.replaceAll("\\", "/");
214
+ if (!rel.trim() || rel.startsWith("/") || /^[A-Za-z]:/.test(rel) || rel.split("/").includes("..")) return { bad: `reads ${JSON.stringify(spec.file)}, which is not a relative path inside the app` };
215
+ const file = path.posix.normalize(join(appDir, rel));
216
+ if (!set.has(file)) return { bad: `reads ${JSON.stringify(spec.file)}, which is not a committed file` };
217
+ const text = readText(root, file);
218
+ let current;
219
+ const where = json ? (spec.pointer ?? "") : spec.path;
220
+ if (json) {
221
+ try { current = JSON.parse(text); } catch { return { bad: `reads ${spec.file}, which is not valid JSON` }; }
222
+ if (where !== "") {
223
+ if (typeof where !== "string" || !where.startsWith("/")) return { bad: `has a pointer ${JSON.stringify(where)} that is not a JSON pointer such as /version` };
224
+ for (const raw of where.slice(1).split("/")) {
225
+ const k = raw.replaceAll("~1", "/").replaceAll("~0", "~");
226
+ if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, k)) return { bad: `selects ${where}, which is not in ${spec.file}` };
227
+ current = current[k];
228
+ }
229
+ }
230
+ } else {
231
+ const parse = tomlParser();
232
+ if (!parse) return { unsupported: `a $fromToml node, and no TOML parser (smol-toml) is installed beside this package` };
233
+ try { current = parse(text); } catch { return { bad: `reads ${spec.file}, which is not valid TOML` }; }
234
+ for (const k of where.split(".")) {
235
+ if (!current || typeof current !== "object" || Array.isArray(current) || !Object.prototype.hasOwnProperty.call(current, k)) return { bad: `selects ${where}, which is not in ${spec.file}` };
236
+ current = current[k];
237
+ }
238
+ }
239
+ if (typeof current !== "string") return { bad: `selects ${spec.file} ${where || "(the whole file)"}, which is ${current === null ? "null" : Array.isArray(current) ? "an array" : typeof current} (not a string)` };
240
+ if (!current) return { bad: `selects ${spec.file} ${where}, which is an empty string` };
241
+ return { value: current };
242
+ }
243
+
244
+ const UNSUPPORTED_BOUND = "release-config-identity proves a key only from a string literal or a top-level const bound to one in the same file";
245
+
246
+ function releaseConfigIdentity({ root, files }) {
247
+ const out = [];
248
+ const set = new Set(files);
249
+ for (const file of files.filter((f) => CONFIG_FILE.test(f) && !SKIPPED_TREE.test(f))) {
250
+ const text = readText(root, file);
251
+ if (text === null) continue;
252
+ const add = (key, line, why) => out.push({
253
+ file, subject: key, problem: `${file}:${line} ${why}: right-release build refuses it ("release config must expose app and version", build-release.mjs) after install, before it compiles`,
254
+ fix: `export a top-level ${key} string (a literal, a top-level const string, or in right-release.config.json a $fromJson/$fromToml node)`,
255
+ });
256
+ const unsupported = (key, line, what) => out.push({
257
+ file, subject: `${key}:unsupported`, problem: `${file}:${line} UNSUPPORTED: \`${key}\` is ${what}; ${UNSUPPORTED_BOUND}, so it is not proven present (right-release build refuses a missing one)`,
258
+ fix: `make ${key} a string literal or a top-level const string, or move the config to right-release.config.json with { "$fromToml": { "file": ..., "path": ... } }`,
259
+ });
260
+ if (file.endsWith(".json")) {
261
+ let config;
262
+ try { config = JSON.parse(text); } catch { continue; } // invalid JSON is release-config's (G16) finding
263
+ if (!config || typeof config !== "object" || Array.isArray(config) || !("schema" in config)) continue;
264
+ for (const key of ["app", "version"]) {
265
+ const value = config[key];
266
+ // release-config.mjs expands a { $fromJson } / { $fromToml } node at load (release-config.mjs:217-239); the value it selects
267
+ // is resolved here the same way, from the committed bytes, and must be a non-empty string.
268
+ if (value && typeof value === "object" && !Array.isArray(value) && ("$fromJson" in value || "$fromToml" in value)) {
269
+ const r = resolveTyped(root, set, dirOf(file), value);
270
+ if (r.unsupported) unsupported(key, 1, r.unsupported);
271
+ else if (r.bad) add(key, 1, `"${key}" ${r.bad}`);
272
+ else if (!(key === "app" ? SAFE_APP : SAFE_VERSION).test(r.value)) add(key, 1, `"${key}" resolves to ${JSON.stringify(r.value)}, which is not a safe release-id component`);
273
+ continue;
274
+ }
275
+ if (typeof value === "string" && value.includes("{{")) continue; // a {{token}} expands from the other values at load
276
+ if (typeof value !== "string" || !value) add(key, 1, `has no top-level "${key}" string (found ${JSON.stringify(value ?? null)})`);
277
+ else if (!(key === "app" ? SAFE_APP : SAFE_VERSION).test(value)) add(key, 1, `"${key}": ${JSON.stringify(value)} is not a safe release-id component`);
278
+ }
279
+ continue;
280
+ }
281
+ const skel = blankJs(text);
282
+ const target = defaultExportObject(skel);
283
+ const declaresSchema = /\bschema\s*:/.test(skel);
284
+ if (!target) { if (declaresSchema) unsupported("default", 1, "exported in a form this reader cannot follow (not an object literal, a wrapped one, or a const bound to one)"); continue; }
285
+ const literal = objectKeys(skel, text, target.open);
286
+ if (!literal) continue;
287
+ // Only a config that declares itself a release config (schema) is judged: partial stubs (`export default {}`) are other tools'.
288
+ if (!literal.keys.has("schema")) continue;
289
+ for (const key of ["app", "version"]) {
290
+ if (!literal.keys.has(key)) {
291
+ if (literal.spread) unsupported(key, lineOf(text, target.open), "absent from the literal, which spreads another object that may or may not supply it");
292
+ else add(key, lineOf(text, target.open), `the default export has no top-level \`${key}\``);
293
+ continue;
294
+ }
295
+ const at = literal.keys.get(key);
296
+ const prop = propertyValue(skel, text, at);
297
+ const resolved = !prop ? { unsupported: "an unreadable property" } : staticString(skel, text, prop.shorthand ?? prop.expr);
298
+ if (resolved.unsupported) unsupported(key, lineOf(text, at), resolved.unsupported);
299
+ else if (resolved.bad) add(key, lineOf(text, at), `\`${key}\` is ${resolved.bad}`);
300
+ else if (!resolved.value) add(key, lineOf(text, at), `\`${key}\` is an empty string`);
301
+ else if (!(key === "app" ? SAFE_APP : SAFE_VERSION).test(resolved.value)) add(key, lineOf(text, at), `\`${key}: ${JSON.stringify(resolved.value)}\` is not a safe release-id component`);
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ // ---- CA-05 pnpm-node-floor --------------------------------------------------------------------------------------------------------
308
+
309
+ const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
310
+ const unquote = (s) => s.replace(/\s+#.*$/, "").trim().replace(/^(['"])(.*)\1$/, "$2");
311
+
312
+ /** The jobs of a workflow as { id, line, steps: [{ line, lines: [{ no, text }] }] }, read by indentation (no YAML library). */
313
+ export function workflowJobs(text) {
314
+ const lines = text.split(/\r?\n/).map((t, i) => ({ no: i + 1, text: t }));
315
+ const jobsAt = lines.findIndex((l) => /^jobs:\s*(#.*)?$/.test(l.text));
316
+ if (jobsAt < 0) return [];
317
+ const indent = (t) => /^ */.exec(t)[0].length;
318
+ const body = [];
319
+ for (const l of lines.slice(jobsAt + 1)) { if (/^\S/.test(l.text)) break; body.push(l); }
320
+ const content = body.filter((l) => l.text.trim() && !/^\s*#/.test(l.text));
321
+ if (!content.length) return [];
322
+ const jobIndent = indent(content[0].text);
323
+ const jobs = [];
324
+ for (const l of content) {
325
+ if (indent(l.text) === jobIndent && /^\s*[\w-]+:\s*(#.*)?$/.test(l.text)) jobs.push({ id: l.text.trim().replace(/:.*$/, ""), line: l.no, lines: [] });
326
+ else jobs.at(-1)?.lines.push(l);
327
+ }
328
+ for (const job of jobs) {
329
+ job.steps = [];
330
+ let dash = null;
331
+ for (const l of job.lines) {
332
+ const m = /^(\s*)-\s/.exec(l.text);
333
+ if (m && (dash === null || m[1].length === dash)) { dash = m[1].length; job.steps.push({ line: l.no, lines: [{ no: l.no, text: l.text.slice(dash + 2) }] }); continue; }
334
+ if (dash !== null && indent(l.text) <= dash) { dash = null; continue; } // left the steps list
335
+ job.steps.at(-1)?.lines.push(l);
336
+ }
337
+ }
338
+ return jobs;
339
+ }
340
+ const stepValue = (step, key) => {
341
+ for (const l of step.lines) { const m = new RegExp(`^\\s*${key}:\\s*(.*)$`).exec(l.text); if (m) return { value: unquote(m[1]), line: l.no }; }
342
+ return null;
343
+ };
344
+
345
+ function pnpmNodeFloor({ root, files }) {
346
+ const data = contractData().pnpm;
347
+ const pins = files.filter((f) => /(^|\/)package\.json$/.test(f) && !SKIPPED_TREE.test(f)).map((f) => ({ file: f, pnpm: pnpmPin(root, f) })).filter((p) => p.pnpm);
348
+ const floors = pins.map((p) => ({ ...p, node: pnpmRow(data.nodeFloor, p.pnpm)?.node })).filter((p) => p.node);
349
+ if (!floors.length) return [];
350
+ const need = floors.reduce((a, b) => (compareVersions(b.node, a.node) > 0 ? b : a));
351
+ const why = `${need.file} pins pnpm ${need.pnpm} (packageManager), which requires Node ${need.node}; on an older Node pnpm stops before installing ("No such built-in module: node:sqlite")`;
352
+ const out = [];
353
+ for (const file of files.filter((f) => WORKFLOW.test(f))) {
354
+ const text = readText(root, file);
355
+ if (text === null) continue;
356
+ for (const job of workflowJobs(text)) {
357
+ const usesPnpm = job.steps.some((s) => /pnpm\/action-setup@/.test(stepValue(s, "uses")?.value ?? "") || /(^|[\s;&|(])pnpm(\s|$)/m.test(s.lines.map((l) => l.text).join("\n")));
358
+ if (!usesPnpm) continue;
359
+ for (const step of job.steps) {
360
+ if (!/^actions\/setup-node@/.test(stepValue(step, "uses")?.value ?? "")) continue;
361
+ let selected = stepValue(step, "node-version");
362
+ const versionFile = stepValue(step, "node-version-file");
363
+ if (!selected && versionFile && files.includes(versionFile.value)) selected = { value: (readText(root, versionFile.value) ?? "").trim().split(/\s+/)[0], line: versionFile.line };
364
+ if (!selected || !selectionBelow(selected.value, need.node)) continue;
365
+ out.push({ file, subject: `${job.id}:node-${selected.value}`, problem: `${file}:${selected.line} job ${job.id} sets up Node ${selected.value} and runs pnpm: ${why}`, fix: `select Node ${need.node} or later in that setup-node step (or pin a pnpm whose engines accept the Node it uses)` });
366
+ }
367
+ }
368
+ }
369
+ for (const file of files.filter((f) => /(^|\/)\.(node-version|nvmrc)$/.test(f) && !SKIPPED_TREE.test(f))) {
370
+ const selected = (readText(root, file) ?? "").trim().split(/\s+/)[0];
371
+ if (selectionBelow(selected, need.node)) out.push({ file, subject: `node-${selected}`, problem: `${file}:1 selects Node ${selected}: ${why}`, fix: `select Node ${need.node} or later` });
372
+ }
373
+ return out;
374
+ }
375
+
376
+ // ---- CA-05 rust-dependency-msrv ---------------------------------------------------------------------------------------------------
377
+
378
+ const CRATES_IO = /^(registry\+https:\/\/github\.com\/rust-lang\/crates\.io-index|sparse\+https:\/\/index\.crates\.io\/)$/;
379
+
380
+ /** The [[package]] entries of a Cargo.lock: [{ name, version, source, line }]. */
381
+ export function lockPackages(text) {
382
+ const out = [];
383
+ const re = /^\[\[package\]\]\s*$/gm;
384
+ let m;
385
+ while ((m = re.exec(text))) {
386
+ const end = text.indexOf("\n[", m.index + 1);
387
+ const block = text.slice(m.index, end < 0 ? text.length : end);
388
+ const field = (k) => new RegExp(`^${k}\\s*=\\s*"([^"]*)"`, "m").exec(block)?.[1] ?? null;
389
+ out.push({ name: field("name"), version: field("version"), source: field("source"), line: lineOf(text, m.index) });
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /** The toolchain a rust-toolchain(.toml) selects: { channel } or null. */
395
+ export function toolchainChannel(text) {
396
+ const toml = /^\s*channel\s*=\s*"([^"]+)"/m.exec(text);
397
+ if (toml) return toml[1].trim();
398
+ const legacy = text.trim();
399
+ return /^[\w.-]+$/.test(legacy) ? legacy : null;
400
+ }
401
+
402
+ function rustDependencyMsrv({ root, files }) {
403
+ const table = contractData().cratesRustVersion.crates;
404
+ const set = new Set(files);
405
+ const out = [];
406
+ for (const lock of files.filter((f) => /(^|\/)Cargo\.lock$/.test(f) && !SKIPPED_TREE.test(f))) {
407
+ const toolchainFile = upward(dirOf(lock)).flatMap((d) => [join(d, "rust-toolchain.toml"), join(d, "rust-toolchain")]).find((f) => set.has(f));
408
+ if (!toolchainFile) continue;
409
+ const channel = toolchainChannel(readText(root, toolchainFile) ?? "");
410
+ if (!channel || !versionParts(channel)) continue; // stable/nightly: not a pinned toolchain, nothing to compare
411
+ const text = readText(root, lock);
412
+ if (text === null) continue;
413
+ for (const pkg of lockPackages(text)) {
414
+ if (!pkg.source || !CRATES_IO.test(pkg.source)) continue;
415
+ const required = table[pkg.name]?.[pkg.version];
416
+ if (!required || !selectionBelow(channel, required)) continue;
417
+ out.push({
418
+ file: lock, subject: `${pkg.name}@${pkg.version}`,
419
+ problem: `${lock}:${pkg.line} resolves ${pkg.name} ${pkg.version}, which requires rustc ${required} (crates.io rust_version), but ${toolchainFile} selects ${channel}: cargo refuses it ("rustc ${channel} is not supported by the following packages")`,
420
+ fix: `raise ${toolchainFile} to ${required} or later, or lock a ${pkg.name} version whose rust-version ${channel} meets`,
421
+ });
422
+ }
423
+ }
424
+ return out;
425
+ }
426
+
427
+ // ---- CA-06 release-age ------------------------------------------------------------------------------------------------------------
428
+
429
+ /** The top-level scalar and list settings of a pnpm-workspace.yaml that release age reads. */
430
+ export function workspaceReleaseAge(text) {
431
+ const lines = (text ?? "").split(/\r?\n/);
432
+ let minutes = null;
433
+ const exclude = [];
434
+ for (let i = 0; i < lines.length; i += 1) {
435
+ const age = /^minimumReleaseAge:\s*(.+)$/.exec(lines[i]);
436
+ if (age) { const n = Number(unquote(age[1])); if (Number.isFinite(n)) minutes = n; continue; }
437
+ const ex = /^minimumReleaseAgeExclude:\s*(.*)$/.exec(lines[i]);
438
+ if (!ex) continue;
439
+ const inline = unquote(ex[1]);
440
+ if (inline.startsWith("[")) { for (const item of inline.replace(/^\[|\]$/g, "").split(",")) if (item.trim()) exclude.push(unquote(item)); continue; }
441
+ if (inline) { exclude.push(inline); continue; }
442
+ for (let j = i + 1; j < lines.length && (/^\s+-\s/.test(lines[j]) || !lines[j].trim() || /^\s+#/.test(lines[j])); j += 1) {
443
+ const item = /^\s+-\s+(.+)$/.exec(lines[j]);
444
+ if (item) exclude.push(unquote(item[1]));
445
+ }
446
+ }
447
+ return { minutes, exclude };
448
+ }
449
+
450
+ /** Whether an exclude entry (exact `name@version`, `name@v1 || v2`, a bare name, or a `*` pattern) covers `name`@`version`. */
451
+ export function excluded(entries, name, version) {
452
+ return entries.some((entry) => {
453
+ const at = entry.lastIndexOf("@");
454
+ const pattern = at > 0 ? entry.slice(0, at) : entry;
455
+ const versions = at > 0 ? entry.slice(at + 1).split("||").map((v) => v.trim()) : null;
456
+ const re = new RegExp(`^${pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
457
+ return re.test(name) && (!versions || versions.includes(version));
458
+ });
459
+ }
460
+
461
+ /** The name@version keys under `packages:` of a pnpm-lock.yaml: [{ name, version, line }]. */
462
+ export function pnpmLockPackages(text) {
463
+ const out = [];
464
+ const lines = text.split(/\r?\n/);
465
+ const start = lines.findIndex((l) => /^packages:\s*$/.test(l));
466
+ if (start < 0) return out;
467
+ for (let i = start + 1; i < lines.length && !/^\S/.test(lines[i]); i += 1) {
468
+ const m = /^ {2}(['"]?)((?:@[^@/'"\s]+\/)?[^@'"\s]+)@([^('"\s:]+)(?:\([^'"]*\))?\1:\s*$/.exec(lines[i]);
469
+ if (m) out.push({ name: m[2], version: m[3], line: i + 1 });
470
+ }
471
+ return out;
472
+ }
473
+
474
+ function releaseAge({ root, files, now = new Date(), registry = contractData().npmPublishTimes.packages }) {
475
+ const out = [];
476
+ const set = new Set(files);
477
+ for (const lock of files.filter((f) => /(^|\/)pnpm-lock\.yaml$/.test(f) && !SKIPPED_TREE.test(f))) {
478
+ const dir = dirOf(lock);
479
+ const workspaceFile = join(dir, "pnpm-workspace.yaml");
480
+ const policy = workspaceReleaseAge(set.has(workspaceFile) ? readText(root, workspaceFile) : "");
481
+ let minutes = policy.minutes, from = `${workspaceFile} minimumReleaseAge`;
482
+ if (minutes === null) {
483
+ const pinFile = upward(dir).map((d) => join(d, "package.json")).find((f) => set.has(f) && pnpmPin(root, f));
484
+ const row = pinFile ? pnpmRow(contractData().pnpm.defaultMinimumReleaseAge, pnpmPin(root, pinFile)) : null;
485
+ if (!row) continue;
486
+ minutes = row.minutes; from = `the pnpm ${pnpmPin(root, pinFile)} default (${pinFile} packageManager)`;
487
+ }
488
+ if (!(minutes > 0)) continue;
489
+ const cutoff = new Date(now.getTime() - minutes * 60 * 1000);
490
+ const text = readText(root, lock);
491
+ if (text === null) continue;
492
+ for (const pkg of pnpmLockPackages(text)) {
493
+ const published = registry[pkg.name]?.[pkg.version];
494
+ if (!published || new Date(published) <= cutoff || excluded(policy.exclude, pkg.name, pkg.version)) continue;
495
+ out.push({
496
+ file: lock, subject: `${pkg.name}@${pkg.version}`,
497
+ problem: `${lock}:${pkg.line} ${pkg.name}@${pkg.version} was published at ${published}, within the minimumReleaseAge cutoff (${cutoff.toISOString()}; ${minutes} minutes from ${from}): pnpm install --frozen-lockfile refuses it (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION)`,
498
+ fix: `wait until ${new Date(new Date(published).getTime() + minutes * 60 * 1000).toISOString()}, or add the reviewed exact version '${pkg.name}@${pkg.version}' to minimumReleaseAgeExclude in ${workspaceFile}`,
499
+ });
500
+ }
501
+ }
502
+ return out;
503
+ }
504
+
505
+ // ---- CA-14 notice-lock-digest -----------------------------------------------------------------------------------------------------
506
+
507
+ const NOTICE_FILE = /(^|\/)[^/]*(notice|licen[cs]e|third[-_ ]?party)[^/]*\.(html?|md|txt)$/i;
508
+ /** A notice CA-14 judges (repository-relative, `/`-separated): a notice or licence file outside vendored and history trees. */
509
+ export const isJudgedNotice = (file) => NOTICE_FILE.test(file) && !SKIPPED_TREE.test(file) && !HISTORY_TREE.test(file);
510
+ const LOCK_NAME = String.raw`(?:[\w.-]+\/)*(?:pnpm-lock\.yaml|Cargo\.lock|package-lock\.json|yarn\.lock|bun\.lock)`;
511
+ // The two formats the generators write, both read here:
512
+ // `<lock> sha256=<hex>` packages/release/npm-license-notices.mjs:74 and packages/legal/src/tooling.ts:263 (npm appendix); HeardRight's
513
+ // own generator quotes it as `pnpm-lock.yaml` sha256=`<hex>`
514
+ // `<lock> sha256 <hex>` packages/legal/src/tooling.ts:142-149 (legal-snapshot fills {{key}} of a notices template with the sha256 of
515
+ // notices.hashes[key]); desktop-app-template/legal/NOTICES.template.md:4 `pnpm-lock.yaml sha256 {{lock}}`
516
+ export const LOCK_DIGEST = new RegExp(String.raw`` + "`?(" + LOCK_NAME + ")`?" + String.raw`\s+sha256(?:=|[ \t]+)` + "`?" + String.raw`([0-9a-f]{64})\b`, "g");
517
+ const LOCK_LINE = new RegExp(`(${LOCK_NAME})`);
518
+ const HEX64 = /\b[0-9a-fA-F]{64}\b/g;
519
+
520
+ /** The lock files a legal-snapshot config hashes into `notice` (packages/legal/src/tooling.ts: notices.hashes, relative to the app root). */
521
+ function snapshotHashFiles(root, set, notice) {
522
+ for (const dir of upward(dirOf(notice))) {
523
+ const configFile = join(dir, "legal-snapshot.config.json");
524
+ if (!set.has(configFile)) continue;
525
+ try {
526
+ const config = JSON.parse(readText(root, configFile));
527
+ const output = config?.notices?.path && path.posix.normalize(join(dir, join(config.legalDir ?? "legal", config.notices.path)));
528
+ if (output !== notice) return [];
529
+ return Object.values(config.notices.hashes ?? {}).filter((f) => typeof f === "string").map((f) => path.posix.normalize(join(dir, f)));
530
+ } catch { return []; }
531
+ }
532
+ return [];
533
+ }
534
+
535
+ function noticeLockDigest({ root, files }) {
536
+ const set = new Set(files);
537
+ const out = [];
538
+ const digests = new Map();
539
+ const digest = (file) => {
540
+ if (!digests.has(file)) { const bytes = readBytes(root, file); digests.set(file, bytes && createHash("sha256").update(bytes).digest("hex")); }
541
+ return digests.get(file);
542
+ };
543
+ for (const file of files.filter(isJudgedNotice)) {
544
+ const text = readText(root, file);
545
+ if (text === null) continue;
546
+ const declared = snapshotHashFiles(root, set, file);
547
+ const seen = new Set();
548
+ const consumed = new Set();
549
+ for (const m of text.matchAll(LOCK_DIGEST)) {
550
+ const [, ref, recorded] = m;
551
+ consumed.add(m.index + m[0].lastIndexOf(recorded));
552
+ const line = lineOf(text, m.index);
553
+ const lock = declared.find((f) => f === ref || f.endsWith(`/${ref}`)) ?? upward(dirOf(file)).map((d) => join(d, ref)).find((f) => set.has(f));
554
+ if (!lock || !set.has(lock)) {
555
+ out.push({ file, subject: `${ref}:unresolved`, problem: `${file}:${line} UNSUPPORTED: records ${ref} sha256 ${recorded}, but no committed ${ref} at or above ${dirOf(file) || "."} (nor in a legal-snapshot notices.hashes entry) can be compared, so its freshness is not proven`, fix: `commit the lock the notice was generated from beside the app, or regenerate the notice from the lock that is committed` });
556
+ continue;
557
+ }
558
+ if (seen.has(`${lock}|${recorded}`)) continue;
559
+ seen.add(`${lock}|${recorded}`);
560
+ const actual = digest(lock);
561
+ if (actual === recorded) continue;
562
+ out.push({
563
+ file, subject: lock,
564
+ problem: `${file}:${line} records ${ref} sha256 ${recorded}, but the committed ${lock} hashes to ${actual}: the notice was generated from another lockfile`,
565
+ fix: `regenerate the notice from the committed ${lock} (the app's notice generator) and commit both together`,
566
+ });
567
+ }
568
+ // A digest beside a lockfile name in a format neither generator writes is not silently passed.
569
+ const lines = text.split("\n");
570
+ let offset = 0;
571
+ for (let i = 0; i < lines.length; offset += lines[i].length + 1, i += 1) {
572
+ if (!LOCK_LINE.test(lines[i])) continue;
573
+ let previous = 0;
574
+ for (const hex of lines[i].matchAll(HEX64)) {
575
+ const segment = lines[i].slice(previous, hex.index);
576
+ previous = hex.index + hex[0].length;
577
+ if (consumed.has(offset + hex.index)) continue;
578
+ // The digest belongs to the last file named before it: judge it only when that file is a lockfile.
579
+ const owner = [...segment.matchAll(/[\w./-]+\.[A-Za-z]+/g)].at(-1)?.[0] ?? "";
580
+ if (!LOCK_LINE.test(owner)) continue;
581
+ out.push({ file, subject: `${owner}:format:${i + 1}`, problem: `${file}:${i + 1} UNSUPPORTED: a sha256-length digest after ${owner} in a form this rule does not read (it reads the generated \`<lock> sha256=<hex>\` and \`<lock> sha256 <hex>\`), so its freshness is not proven`, fix: "write the digest as `<lock> sha256=<hex>` or `<lock> sha256 <hex>` (what @rightkit/legal legal-snapshot and right-release license-notices write), or regenerate the notice with them" });
582
+ break;
583
+ }
584
+ }
585
+ }
586
+ return out;
587
+ }
588
+
589
+ // ---- CA-15 doc-comment-bare-cr ----------------------------------------------------------------------------------------------------
590
+
591
+ /** Each bare CR (a \r not followed by \n) inside a Rust doc comment: [{ line, column }]. */
592
+ export function docCommentBareCrs(text) {
593
+ const found = [];
594
+ const lines = text.split("\n");
595
+ let block = false; // inside /** or /*! (a doc block); plain /* */ blocks are not doc comments
596
+ for (let i = 0; i < lines.length; i += 1) {
597
+ const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
598
+ const lineDoc = /^\s*\/\/(\/(?!\/)|!)/.test(line);
599
+ let doc = block || lineDoc;
600
+ if (!block) { const open = /\/\*(\*(?![*/])|!)/.exec(line); if (open && !lineDoc) { doc = true; block = !line.slice(open.index + 3).includes("*/"); } }
601
+ else if (line.includes("*/")) block = false;
602
+ if (!doc) continue;
603
+ for (let at = line.indexOf("\r"); at !== -1; at = line.indexOf("\r", at + 1)) found.push({ line: i + 1, column: at + 1 });
604
+ }
605
+ return found;
606
+ }
607
+
608
+ function docCommentBareCr({ root, files }) {
609
+ const out = [];
610
+ for (const file of files.filter((f) => f.endsWith(".rs") && !SKIPPED_TREE.test(f))) {
611
+ const bytes = readBytes(root, file);
612
+ if (!bytes || !bytes.includes(13)) continue;
613
+ for (const hit of docCommentBareCrs(bytes.toString("latin1"))) {
614
+ out.push({ file, subject: `line-${hit.line}`, problem: `${file}:${hit.line}:${hit.column} a carriage return that ends no line sits inside a doc comment: rustc refuses the file ("bare CR not allowed in doc-comment")`, fix: "remove the stray \\r byte (a backslash path such as bridge\\relay.json written through a tool that read \\r as an escape); write a literal backslash instead" });
615
+ }
616
+ }
617
+ return out;
618
+ }
619
+
620
+ export const CONSUMER_CONTRACT_RULES = Object.freeze([
621
+ { id: "release-config-identity", gap: "CA-01", run: releaseConfigIdentity },
622
+ { id: "pnpm-node-floor", gap: "CA-05", run: pnpmNodeFloor },
623
+ { id: "rust-dependency-msrv", gap: "CA-05", run: rustDependencyMsrv },
624
+ { id: "release-age", gap: "CA-06", run: releaseAge },
625
+ { id: "notice-lock-digest", gap: "CA-14", run: noticeLockDigest },
626
+ { id: "doc-comment-bare-cr", gap: "CA-15", run: docCommentBareCr },
627
+ ]);