@rightkit/release 0.6.5 → 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 (73) hide show
  1. package/README.md +196 -11
  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/android-play-upload.mjs +4 -4
  7. package/app-check.mjs +702 -26
  8. package/apple-profile-create.mjs +4 -3
  9. package/apple-profile-renew.mjs +5 -4
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/rightkit-agent-hook.mjs +9 -0
  12. package/bin/rightkit-gate.mjs +671 -133
  13. package/binary-license-notices.mjs +2 -1
  14. package/build-release.mjs +20 -12
  15. package/cache-command.mjs +4 -3
  16. package/cargo-license-notices.mjs +2 -1
  17. package/cargo-manifest-text.mjs +25 -0
  18. package/cargo-profile-contract.mjs +26 -13
  19. package/cli/right-release.mjs +37 -7
  20. package/command-spec.mjs +4 -5
  21. package/compile-inputs.mjs +66 -0
  22. package/consumer-contracts.json +1044 -0
  23. package/consumer-contracts.mjs +627 -0
  24. package/consumer-input-contracts.mjs +422 -0
  25. package/consumer-policy.mjs +588 -0
  26. package/consumer-source-contracts.mjs +469 -0
  27. package/deny-default.toml +42 -0
  28. package/deps.mjs +5 -4
  29. package/deterministic-scans.mjs +513 -54
  30. package/development.mjs +5 -5
  31. package/disk-command.mjs +270 -30
  32. package/disk-policy.json +50 -10
  33. package/disk-policy.mjs +482 -35
  34. package/disk-report.mjs +129 -0
  35. package/door-policy.mjs +228 -0
  36. package/gate-executor.mjs +30 -0
  37. package/gate-stages.mjs +110 -0
  38. package/github-release.mjs +5 -3
  39. package/github-updater-release.mjs +48 -8
  40. package/github-upload-gate.mjs +6 -6
  41. package/init-command.mjs +175 -8
  42. package/ios-upload.mjs +4 -4
  43. package/legion-semantic-contracts.mjs +428 -0
  44. package/mirror-root-artifact.mjs +3 -4
  45. package/npm-license-notices.mjs +2 -1
  46. package/outdated-command.mjs +91 -0
  47. package/ownership-class-rules.mjs +277 -0
  48. package/ownership-key-authority.mjs +3 -1
  49. package/ownership-mandatory-set.mjs +3 -2
  50. package/package.json +46 -3
  51. package/pipeline-admission.mjs +12 -0
  52. package/publish-cargo.mjs +219 -4
  53. package/publish-update.mjs +19 -14
  54. package/release-config.mjs +87 -11
  55. package/release-dependencies-command.mjs +4 -3
  56. package/release-qa-audit.mjs +12 -10
  57. package/release-qa-command.mjs +42 -7
  58. package/release-qa-hardening.mjs +10 -3
  59. package/release-qa-policy.mjs +214 -47
  60. package/release.mjs +4 -4
  61. package/rightkit-registry-resolver.mjs +5 -2
  62. package/rightkit-versions.json +178 -170
  63. package/shell-contract-bridge.json +251 -0
  64. package/shell-contract.mjs +1069 -0
  65. package/sign-updater.mjs +24 -4
  66. package/source-closure-rules.mjs +204 -0
  67. package/stage-sidecar-command.mjs +7 -6
  68. package/stage-sidecar.mjs +437 -114
  69. package/suite-api.generated.mjs +96 -6
  70. package/suite-trust-roots.mjs +198 -0
  71. package/test-command.mjs +3 -2
  72. package/upload-release.mjs +3 -2
  73. package/web-build.mjs +2 -1
@@ -0,0 +1,588 @@
1
+ // Consumer semantic policy (Class A row 13). An app declares, as data in its own commit, the semantic contracts that its own
2
+ // scripts used to enforce by running repository code; these rules judge them from committed files alone. Nothing here imports,
3
+ // evaluates or spawns repository code: the policy is JSON, the source files are read as text and import edges come from the
4
+ // lexer below, which only reads module specifiers.
5
+ //
6
+ // The declaration is `.rightkit/consumer-policy.json` (CONSUMER_POLICY_FILE). An app without it has nothing to check. Shape:
7
+ //
8
+ // { "schema": 1,
9
+ // "retiredTokens": [{ "id", "tokens": [..], "caseInsensitive"?, "include": [glob], "exclude"?: [glob], "extensions"?: [".ts", ..],
10
+ // "allowFiles"?: [{ "file", "reason" }], "allowFragments"?: [{ "file", "text", "kind"?: "text" | "identifier" | "string", "reason" }] }],
11
+ // "routeDenyPolicies": [{ "id", "routes": "<route table or OpenAPI json>" | [..], "policy": "<deny policy json>", "prefix": "/v1/admin/" }],
12
+ // "layerBoundaries": [{ "id", "root": "<dir>", "aliases"?: { "~/": "src/" }, "from": { "include", "exclude"? },
13
+ // "forbid": { "include", "exclude"? }, "stop"?: [glob], "extensions"?: [..] }],
14
+ // "specRouteParity": [{ "id", "spec": "<openapi json>", "routes": "<route table json>" }] }
15
+ //
16
+ // route table: { "routes": ["METHOD /path/{param}", ..], "undocumented"?: ["METHOD /path", ..] } (a `:param` is read as `{param}`)
17
+ // deny policy: { "denied": ["METHOD /path", ..] | { "METHOD /path": "<group>" }, "exceptions"?: [..],
18
+ // "irreversible": { "segments": [..], "fragments": [..] } }
19
+ //
20
+ // retired-token CA-13 a declared retired token in a scoped file, outside a whole-file or exact-fragment exception
21
+ // route-deny-policy CA-13 the deny-set invariants over the committed route table: a denied or excepted route that is not a route,
22
+ // a DELETE that is not denied, an irreversible-looking mutation that is neither denied nor excepted, a route
23
+ // both denied and excepted, and a denied non-DELETE route the irreversible marks do not cover (its removal
24
+ // would go unnoticed)
25
+ // layer-reach CA-13 a file reached through value imports from a `from` file that lands on a `forbid` file, or an import in a
26
+ // reached file that cannot be read statically; a `stop` file (a reviewed public API) is accepted and not walked
27
+ // spec-route-parity CA-13 the committed API spec and the committed route table disagree: a documented route missing from the spec,
28
+ // a spec operation that is not a route, or an undocumented route that the spec documents
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ // Not imported from app-check.mjs: app-check imports this module, and a cycle would leave its RULES half-built.
33
+ const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8"); } catch { return null; } };
34
+
35
+ export const CONSUMER_POLICY_FILE = ".rightkit/consumer-policy.json";
36
+ const FIX_POLICY = `fix ${CONSUMER_POLICY_FILE} (see packages/release/consumer-policy.mjs for its shape)`;
37
+ const DEFAULT_CODE_EXT = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
38
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
39
+
40
+ // ---- shared ------------------------------------------------------------------------------------------------------------------
41
+
42
+ /** The policy, or { error } when it is present but unreadable; null when the app declares none. */
43
+ export function loadPolicy(root, files) {
44
+ if (!files.includes(CONSUMER_POLICY_FILE)) return null;
45
+ const text = readText(root, CONSUMER_POLICY_FILE);
46
+ let policy;
47
+ try { policy = JSON.parse(text); } catch (error) { return { error: `${CONSUMER_POLICY_FILE} is not JSON: ${error.message}` }; }
48
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) return { error: `${CONSUMER_POLICY_FILE} is not a JSON object` };
49
+ if (policy.schema !== 1) return { error: `${CONSUMER_POLICY_FILE} has schema ${JSON.stringify(policy.schema)}; this check reads schema 1` };
50
+ return policy;
51
+ }
52
+
53
+ const policyError = (problem, subject = "policy") => ({ file: CONSUMER_POLICY_FILE, subject, problem, fix: FIX_POLICY });
54
+
55
+ /** Entries of one section, each checked for an id; a malformed section is one finding. */
56
+ function sectionOf(policy, key) {
57
+ if (policy[key] === undefined) return { entries: [] };
58
+ if (!Array.isArray(policy[key])) return { error: policyError(`${CONSUMER_POLICY_FILE} ${key} is not an array`, key) };
59
+ const ids = new Set();
60
+ for (const [i, entry] of policy[key].entries()) {
61
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(entry.id)) return { error: policyError(`${CONSUMER_POLICY_FILE} ${key}[${i}] needs an id of lower-case letters, digits, '.', '_' or '-'`, key) };
62
+ if (ids.has(entry.id)) return { error: policyError(`${CONSUMER_POLICY_FILE} ${key} repeats the id ${entry.id}`, key) };
63
+ ids.add(entry.id);
64
+ }
65
+ return { entries: policy[key] };
66
+ }
67
+
68
+ const isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string" && s !== "");
69
+
70
+ /** A repository-relative posix path: no leading '/', no '.', '..' or empty segment, no backslash. */
71
+ const cleanRel = (p) => typeof p === "string" && p !== "" && !p.includes("\\") && !p.includes("\0") && !p.startsWith("/") && p.split("/").every((s) => s !== "" && s !== "." && s !== "..");
72
+
73
+ const segmentRegex = new Map();
74
+ function segmentMatches(pattern, part) {
75
+ let re = segmentRegex.get(pattern);
76
+ if (!re) {
77
+ re = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
78
+ segmentRegex.set(pattern, re);
79
+ }
80
+ return re.test(part);
81
+ }
82
+ function matchParts(pattern, pi, parts, si) {
83
+ if (pi === pattern.length) return si === parts.length;
84
+ if (pattern[pi] === "**") {
85
+ for (let k = si; k <= parts.length; k++) if (matchParts(pattern, pi + 1, parts, k)) return true;
86
+ return false;
87
+ }
88
+ return si < parts.length && segmentMatches(pattern[pi], parts[si]) && matchParts(pattern, pi + 1, parts, si + 1);
89
+ }
90
+ /** Per-segment glob: `*` and `?` stay inside one segment, `**` (a whole segment) is zero or more segments, `[` `]` are literal. */
91
+ export function matchGlob(pattern, rel) {
92
+ return matchParts(pattern.split("/"), 0, rel.split("/"), 0);
93
+ }
94
+ const inSet = (set, rel) => (set.include ?? []).some((g) => matchGlob(g, rel)) && !(set.exclude ?? []).some((g) => matchGlob(g, rel));
95
+ const globsOk = (v) => v === undefined || (isStringList(v) && v.every((g) => cleanRel(g.replace(/\*\*?|\?/g, "x"))));
96
+
97
+ const lineOf = (text, index) => text.slice(0, index).split("\n").length;
98
+
99
+ // ---- retired-token -------------------------------------------------------------------------------------------------------------
100
+
101
+ // An exact-fragment exception: "text" is any occurrence; "identifier" an occurrence with no identifier character on either side;
102
+ // "string" the whole body of a quoted string ('', "" or ``).
103
+ export const FRAGMENT_KINDS = ["text", "identifier", "string"];
104
+ const IDENT_CHAR = /[A-Za-z0-9_$]/;
105
+ export function blankFragment(text, { text: fragment, kind = "text" }) {
106
+ let out = "";
107
+ let from = 0;
108
+ for (let at = text.indexOf(fragment); at >= 0; at = text.indexOf(fragment, at + 1)) {
109
+ if (at < from) continue;
110
+ const before = text[at - 1] ?? "";
111
+ const after = text[at + fragment.length] ?? "";
112
+ const ok = kind === "identifier" ? !IDENT_CHAR.test(before) && !IDENT_CHAR.test(after)
113
+ : kind === "string" ? ["'", '"', "`"].includes(before) && before === after
114
+ : true;
115
+ if (!ok) continue;
116
+ out += text.slice(from, at) + fragment.replace(/[^\n]/g, " ");
117
+ from = at + fragment.length;
118
+ }
119
+ return out + text.slice(from);
120
+ }
121
+
122
+ function retiredTokens({ root, files }) {
123
+ const policy = loadPolicy(root, files);
124
+ if (!policy) return [];
125
+ if (policy.error) return [policyError(policy.error)];
126
+ const { entries, error } = sectionOf(policy, "retiredTokens");
127
+ if (error) return [error];
128
+ const out = [];
129
+ for (const entry of entries) {
130
+ const bad = (why) => out.push(policyError(`${CONSUMER_POLICY_FILE} retiredTokens ${entry.id}: ${why}`, `retiredTokens:${entry.id}`));
131
+ if (!isStringList(entry.tokens) || entry.tokens.length === 0) { bad("tokens must be a non-empty list of strings"); continue; }
132
+ if (!isStringList(entry.include) || entry.include.length === 0 || !globsOk(entry.include) || !globsOk(entry.exclude)) { bad("include (non-empty) and exclude must be lists of relative globs"); continue; }
133
+ const exts = entry.extensions ?? DEFAULT_CODE_EXT;
134
+ if (!isStringList(exts) || !exts.every((e) => /^\.[A-Za-z0-9.]+$/.test(e))) { bad("extensions must be a list like [\".ts\"]"); continue; }
135
+ const allowFiles = entry.allowFiles ?? [];
136
+ const allowFragments = entry.allowFragments ?? [];
137
+ if (!Array.isArray(allowFiles) || !allowFiles.every((a) => a && cleanRel(a.file) && typeof a.reason === "string" && a.reason.trim())) { bad("each allowFiles entry needs a file and a reason"); continue; }
138
+ if (!Array.isArray(allowFragments) || !allowFragments.every((a) => a && cleanRel(a.file) && typeof a.text === "string" && a.text !== "" && typeof a.reason === "string" && a.reason.trim())) { bad("each allowFragments entry needs a file, a non-empty text and a reason"); continue; }
139
+ if (!allowFragments.every((a) => a.kind === undefined || FRAGMENT_KINDS.includes(a.kind))) { bad(`an allowFragments kind is one of ${FRAGMENT_KINDS.join(", ")}`); continue; }
140
+ const fold = entry.caseInsensitive === true;
141
+ const norm = (s) => (fold ? s.toLowerCase() : s);
142
+ const wholeFile = new Set(allowFiles.map((a) => a.file));
143
+ const fragments = new Map();
144
+ for (const a of allowFragments) fragments.set(a.file, [...(fragments.get(a.file) ?? []), a]);
145
+ for (const file of files) {
146
+ if (!inSet(entry, file) || !exts.includes(path.posix.extname(file).toLowerCase()) || wholeFile.has(file)) continue;
147
+ const original = readText(root, file);
148
+ if (original === null || original.includes("\0")) continue; // binary
149
+ // Each excepted fragment is blanked to spaces of its own length (newlines kept), longest first, so lines stay true and two
150
+ // halves of a token on either side of an excepted fragment never join into one.
151
+ let text = original;
152
+ for (const a of [...(fragments.get(file) ?? [])].sort((x, y) => y.text.length - x.text.length)) text = blankFragment(text, a);
153
+ const haystack = norm(text);
154
+ for (const token of entry.tokens) {
155
+ const at = haystack.indexOf(norm(token));
156
+ if (at < 0) continue;
157
+ out.push({ file, subject: `${entry.id}:${token}`, problem: `${file}:${lineOf(text, at)} contains the retired token ${JSON.stringify(token)} (${CONSUMER_POLICY_FILE} retiredTokens ${entry.id})`, fix: `remove it; a reviewed compatibility use is an allowFragments entry (file, exact text, kind, reason) in ${CONSUMER_POLICY_FILE}` });
158
+ }
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // ---- route tables --------------------------------------------------------------------------------------------------------------
165
+
166
+ /** "METHOD /a/:b{[0-9]+}/c" -> "METHOD /a/{b}/c". */
167
+ export function routeKey(raw) {
168
+ const m = /^([A-Z]+) (\/\S*)$/.exec(String(raw).trim());
169
+ if (!m) return null;
170
+ return `${m[1]} ${m[2].split("/").map((s) => s.replace(/^:([A-Za-z0-9_]+)(?:\{[^}]*\})?$/, "{$1}")).join("/")}`;
171
+ }
172
+
173
+ /**
174
+ * The routes of one or more committed files: a route table ({ routes, undocumented? }) or an OpenAPI document ({ paths }), whose
175
+ * operations are routes. Several sources are a union (an API spec lists the documented routes, a table the rest).
176
+ */
177
+ function readRoutes(root, files, sources) {
178
+ const list = typeof sources === "string" ? [sources] : sources;
179
+ if (!isStringList(list) || list.length === 0) return { error: "routes must name a route table or OpenAPI file, or a list of them" };
180
+ const routes = new Set();
181
+ const undocumented = new Set();
182
+ for (const rel of list) {
183
+ if (!cleanRel(rel) || !files.includes(rel)) return { error: `the route source ${JSON.stringify(rel)} is not a file of this commit` };
184
+ let doc;
185
+ try { doc = JSON.parse(readText(root, rel)); } catch (error) { return { error: `the route source ${rel} is not JSON: ${error.message}` }; }
186
+ if (doc && typeof doc.paths === "object" && doc.paths !== null && !Array.isArray(doc.paths)) { for (const k of specOperations(doc)) routes.add(k); continue; }
187
+ const table = readRouteTable(root, files, rel);
188
+ if (table.error) return table;
189
+ for (const k of table.routes) routes.add(k);
190
+ for (const k of table.undocumented) undocumented.add(k);
191
+ }
192
+ return { routes, undocumented };
193
+ }
194
+
195
+ /** "METHOD /path/{param}" for every operation of an OpenAPI document. */
196
+ function specOperations(spec) {
197
+ const ops = new Set();
198
+ for (const [p, item] of Object.entries(spec.paths)) for (const m of Object.keys(item ?? {})) if (HTTP_METHODS.includes(m)) { const k = routeKey(`${m.toUpperCase()} ${p}`); if (k) ops.add(k); }
199
+ return ops;
200
+ }
201
+
202
+ function readRouteTable(root, files, rel) {
203
+ if (!cleanRel(rel)) return { error: `the route table path ${JSON.stringify(rel)} is not a relative path` };
204
+ if (!files.includes(rel)) return { error: `the route table ${rel} is not in this commit` };
205
+ let table;
206
+ try { table = JSON.parse(readText(root, rel)); } catch (error) { return { error: `the route table ${rel} is not JSON: ${error.message}` }; }
207
+ if (!table || !isStringList(table.routes) || (table.undocumented !== undefined && !isStringList(table.undocumented))) return { error: `the route table ${rel} needs "routes" (and optional "undocumented") as lists of "METHOD /path"` };
208
+ const parse = (list) => {
209
+ const keys = [];
210
+ for (const r of list) { const k = routeKey(r); if (!k) return { error: `the route table ${rel} has ${JSON.stringify(r)}, which is not "METHOD /path"` }; keys.push(k); }
211
+ return { keys };
212
+ };
213
+ const routes = parse(table.routes);
214
+ if (routes.error) return routes;
215
+ const undocumented = parse(table.undocumented ?? []);
216
+ if (undocumented.error) return undocumented;
217
+ return { routes: new Set(routes.keys), undocumented: new Set(undocumented.keys) };
218
+ }
219
+
220
+ // ---- route-deny-policy --------------------------------------------------------------------------------------------------------
221
+
222
+ /** True when `routePath` carries an irreversible mark: a segment preceded by '/' or '-' and followed by '/' or the end, or a fragment. */
223
+ export function irreversible(marks, routePath) {
224
+ const p = routePath;
225
+ for (const s of marks.segments ?? []) {
226
+ for (let at = p.indexOf(s); at >= 0; at = p.indexOf(s, at + 1)) {
227
+ const before = p[at - 1];
228
+ const after = p[at + s.length];
229
+ if ((before === "/" || before === "-") && (after === undefined || after === "/")) return true;
230
+ }
231
+ }
232
+ return (marks.fragments ?? []).some((f) => p.includes(f));
233
+ }
234
+
235
+ function routeDenyPolicies({ root, files }) {
236
+ const policy = loadPolicy(root, files);
237
+ if (!policy) return [];
238
+ if (policy.error) return [policyError(policy.error)];
239
+ const { entries, error } = sectionOf(policy, "routeDenyPolicies");
240
+ if (error) return [error];
241
+ const out = [];
242
+ for (const entry of entries) {
243
+ const bad = (why) => out.push(policyError(`${CONSUMER_POLICY_FILE} routeDenyPolicies ${entry.id}: ${why}`, `routeDenyPolicies:${entry.id}`));
244
+ if (typeof entry.prefix !== "string" || !entry.prefix.startsWith("/")) { bad("prefix must be a path such as \"/v1/admin/\""); continue; }
245
+ const table = readRoutes(root, files, entry.routes);
246
+ if (table.error) { bad(table.error); continue; }
247
+ if (!cleanRel(entry.policy) || !files.includes(entry.policy)) { bad(`the deny policy ${JSON.stringify(entry.policy)} is not a file of this commit`); continue; }
248
+ let deny;
249
+ try { deny = JSON.parse(readText(root, entry.policy)); } catch (e) { bad(`the deny policy ${entry.policy} is not JSON: ${e.message}`); continue; }
250
+ const deniedRaw = Array.isArray(deny?.denied) ? deny.denied : deny?.denied && typeof deny.denied === "object" ? Object.keys(deny.denied) : null;
251
+ const marks = deny?.irreversible;
252
+ if (!isStringList(deniedRaw) || (deny.exceptions !== undefined && !isStringList(deny.exceptions)) || !marks || !isStringList(marks.segments ?? []) || !isStringList(marks.fragments ?? [])) { bad(`the deny policy ${entry.policy} needs "denied", optional "exceptions" and "irreversible": { "segments", "fragments" }`); continue; }
253
+ const keyed = (list) => list.map((r) => [r, routeKey(r)]);
254
+ const badKey = [...keyed(deniedRaw), ...keyed(deny.exceptions ?? [])].find(([, k]) => !k);
255
+ if (badKey) { bad(`the deny policy ${entry.policy} has ${JSON.stringify(badKey[0])}, which is not "METHOD /path"`); continue; }
256
+ const denied = new Set(deniedRaw.map(routeKey));
257
+ const excepted = new Set((deny.exceptions ?? []).map(routeKey));
258
+ const real = new Set([...table.routes].filter((k) => { const [m, p] = k.split(" "); return m !== "ALL" && p.startsWith(entry.prefix) && !p.includes("*"); }));
259
+ const file = entry.policy;
260
+ const add = (subject, problem, fix) => out.push({ file, subject: `${entry.id}:${subject}`, problem: `${file}: ${problem} (${CONSUMER_POLICY_FILE} routeDenyPolicies ${entry.id})`, fix });
261
+ for (const k of denied) if (!real.has(k)) add(`not-a-route:${k}`, `the denied route ${k} is not a route in ${[entry.routes].flat().join(" + ")}: a renamed route has left the block`, "deny the route as it is registered now");
262
+ for (const k of excepted) {
263
+ if (!real.has(k)) add(`exception-not-a-route:${k}`, `the exception ${k} is not a route in ${[entry.routes].flat().join(" + ")}`, "remove the stale exception");
264
+ if (denied.has(k)) add(`denied-and-excepted:${k}`, `${k} is both denied and excepted`, "keep it in one list");
265
+ }
266
+ for (const k of real) {
267
+ const [m, p] = k.split(" ");
268
+ if (m === "GET" || m === "HEAD") continue;
269
+ if (m === "DELETE" && !denied.has(k)) add(`delete-not-denied:${k}`, `the DELETE route ${k} is not denied`, "add it to denied: every hard delete needs a human");
270
+ if (irreversible(marks, p) && !denied.has(k) && !excepted.has(k)) add(`irreversible-not-denied:${k}`, `the irreversible-looking route ${k} is neither denied nor excepted`, "deny it, or except it with a reason");
271
+ }
272
+ for (const k of denied) {
273
+ const [m, p] = k.split(" ");
274
+ if (m !== "DELETE" && !irreversible(marks, p)) add(`denied-not-covered:${k}`, `the denied route ${k} is not covered by the irreversible marks: its removal from denied would go unnoticed`, "add the segment or fragment that names it to irreversible");
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ // ---- spec-route-parity ---------------------------------------------------------------------------------------------------------
281
+
282
+ function specRouteParity({ root, files }) {
283
+ const policy = loadPolicy(root, files);
284
+ if (!policy) return [];
285
+ if (policy.error) return [policyError(policy.error)];
286
+ const { entries, error } = sectionOf(policy, "specRouteParity");
287
+ if (error) return [error];
288
+ const out = [];
289
+ for (const entry of entries) {
290
+ const bad = (why) => out.push(policyError(`${CONSUMER_POLICY_FILE} specRouteParity ${entry.id}: ${why}`, `specRouteParity:${entry.id}`));
291
+ const table = readRouteTable(root, files, entry.routes);
292
+ if (table.error) { bad(table.error); continue; }
293
+ if (!cleanRel(entry.spec) || !files.includes(entry.spec)) { bad(`the spec ${JSON.stringify(entry.spec)} is not a file of this commit`); continue; }
294
+ let spec;
295
+ try { spec = JSON.parse(readText(root, entry.spec)); } catch (e) { bad(`the spec ${entry.spec} is not JSON: ${e.message}`); continue; }
296
+ if (!spec || typeof spec.paths !== "object" || spec.paths === null || Array.isArray(spec.paths)) { bad(`the spec ${entry.spec} has no "paths" object`); continue; }
297
+ const ops = specOperations(spec);
298
+ const add = (file, subject, problem, fix) => out.push({ file, subject: `${entry.id}:${subject}`, problem: `${problem} (${CONSUMER_POLICY_FILE} specRouteParity ${entry.id})`, fix });
299
+ const regen = `regenerate ${entry.spec} from the API and commit it with the route change`;
300
+ for (const k of table.routes) {
301
+ if (table.undocumented.has(k)) { if (ops.has(k)) add(entry.routes, `undocumented-in-spec:${k}`, `${entry.routes} lists ${k} as undocumented, but ${entry.spec} documents it`, "remove it from undocumented"); continue; }
302
+ if (!ops.has(k)) add(entry.spec, `missing:${k}`, `${entry.spec} has no operation for the documented route ${k} in ${entry.routes}: the committed spec is older than the routes`, regen);
303
+ }
304
+ for (const k of table.undocumented) if (!table.routes.has(k)) add(entry.routes, `undocumented-not-a-route:${k}`, `${entry.routes} lists ${k} as undocumented, but it is not a route`, "remove the stale entry");
305
+ for (const k of ops) if (!table.routes.has(k)) add(entry.spec, `not-a-route:${k}`, `${entry.spec} documents ${k}, which is not a route in ${entry.routes}`, `${regen}, or add the route to ${entry.routes}`);
306
+ }
307
+ return out;
308
+ }
309
+
310
+ // ---- layer-reach ---------------------------------------------------------------------------------------------------------------
311
+
312
+ const ID_START = /[A-Za-z_$]/;
313
+ const ID_PART = /[A-Za-z0-9_$]/;
314
+ const REGEX_AFTER = new Set([..."(,=:[!&|?{};+-*%<>~^"]);
315
+ const REGEX_AFTER_WORD = new Set(["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"]);
316
+
317
+ /**
318
+ * The module specifiers of a JS/TS source, read lexically: comments, strings, templates and regular expressions are skipped, and
319
+ * `import ... from 'x'`, `import 'x'`, `export ... from 'x'`, `import('x')` and `require('x')` are returned as
320
+ * { spec, typeOnly, via, index }; a dynamic import or require whose argument is not one string literal has spec null. A quote
321
+ * string ends at a newline (JSX text such as Don't cannot swallow the file). The `${...}` holes of a template are code and are
322
+ * read the same way. `import('x').T` and `typeof import('x')` are type-only only in a type position: a `type`, `interface` or
323
+ * `declare` statement, or right after the annotation colon of a `let`, `const`, `var` or `function` before any `=`; anywhere else
324
+ * they load the module and are edges.
325
+ */
326
+ export function moduleSpecifiers(text) {
327
+ const out = [];
328
+ const n = text.length;
329
+ let i = 0;
330
+ let prev = ""; // the last significant character, or a word
331
+ let prev2 = ""; // the one before it
332
+ const setPrev = (t) => { prev2 = prev; prev = t; };
333
+ // the statement being read: its first word (`export` skipped), whether its `=` has passed, and whether an enclosing brace
334
+ // belongs to a type statement
335
+ let stmtWord = "";
336
+ let stmtEq = false;
337
+ const typeStack = [false];
338
+ const TYPE_STMT = new Set(["type", "interface", "declare"]);
339
+ const ANNOTATED = new Set(["let", "const", "var", "function"]);
340
+ const inTypeStatement = () => typeStack[typeStack.length - 1] || TYPE_STMT.has(stmtWord);
341
+ const typePosition = (before) => inTypeStatement() || (before === ":" && ANNOTATED.has(stmtWord) && !stmtEq);
342
+ const skipWs = (j) => {
343
+ for (;;) {
344
+ while (j < n && /\s/.test(text[j])) j++;
345
+ if (text.startsWith("//", j)) { while (j < n && text[j] !== "\n") j++; continue; }
346
+ if (text.startsWith("/*", j)) { const e = text.indexOf("*/", j + 2); j = e < 0 ? n : e + 2; continue; }
347
+ return j;
348
+ }
349
+ };
350
+ const readString = (j) => { // j at a quote; returns { value, end } or null
351
+ const q = text[j];
352
+ let v = "";
353
+ for (let k = j + 1; k < n; k++) {
354
+ const c = text[k];
355
+ if (c === "\\") { v += text[k + 1] ?? ""; k++; continue; }
356
+ if (c === q) return { value: v, end: k + 1 };
357
+ if (c === "\n") return null;
358
+ v += c;
359
+ }
360
+ return null;
361
+ };
362
+ // j after the opening backtick; returns the index after the closing one. `holes` collects the [start, end) of each `${...}`.
363
+ const skipTemplate = (j, holes = null) => {
364
+ let depth = 0;
365
+ let hole = 0;
366
+ for (let k = j; k < n; k++) {
367
+ const c = text[k];
368
+ if (c === "\\") { k++; continue; }
369
+ if (depth === 0 && c === "`") return k + 1;
370
+ if (depth === 0 && c === "$" && text[k + 1] === "{") { depth = 1; k++; hole = k + 1; continue; }
371
+ if (depth > 0) {
372
+ if (c === "{") depth++;
373
+ else if (c === "}") { depth--; if (depth === 0 && holes) holes.push([hole, k]); }
374
+ else if (c === "'" || c === '"') { const s = readString(k); if (s) k = s.end - 1; }
375
+ else if (c === "`") k = skipTemplate(k + 1) - 1;
376
+ }
377
+ }
378
+ return n;
379
+ };
380
+ const word = (j) => { let k = j; while (k < n && ID_PART.test(text[k])) k++; return text.slice(j, k); };
381
+ const quoted = (j) => (text[j] === "'" || text[j] === '"' ? readString(j) : null);
382
+ // a call argument: one string literal (or a template without substitutions) followed by ')' or ','
383
+ const literalArg = (j) => {
384
+ j = skipWs(j);
385
+ let s = quoted(j);
386
+ if (!s && text[j] === "`") {
387
+ const end = skipTemplate(j + 1);
388
+ const body = text.slice(j + 1, end - 1);
389
+ if (!body.includes("${")) s = { value: body, end };
390
+ }
391
+ if (!s) return null;
392
+ const after = skipWs(s.end);
393
+ return text[after] === ")" || text[after] === "," ? s.value : null;
394
+ };
395
+ // from j (after `import` or `export`), the clause up to `from` and the string after it, at depth 0 of braces
396
+ const clauseFrom = (j) => {
397
+ let depth = 0;
398
+ for (let k = j; k < n;) {
399
+ k = skipWs(k);
400
+ const c = text[k];
401
+ if (c === undefined) return null;
402
+ if (c === "{") { depth++; k++; continue; }
403
+ if (c === "}") { depth--; k++; continue; }
404
+ if (depth === 0 && (c === ";" || c === "(" || c === "=" || c === "'" || c === '"' || c === "`")) return null;
405
+ if (ID_START.test(c)) {
406
+ const w = word(k);
407
+ if (depth === 0 && w === "from") {
408
+ const s = quoted(skipWs(k + 4));
409
+ return s ? { clause: text.slice(j, k), spec: s.value, end: s.end } : null;
410
+ }
411
+ k += w.length;
412
+ continue;
413
+ }
414
+ k++;
415
+ }
416
+ return null;
417
+ };
418
+ const typeOnlyClause = (clause) => {
419
+ const c = clause.trim();
420
+ if (/^type\s/.test(c) && !/^type\s*,/.test(c) && !/^type\s+from\b/.test(c)) return true; // import type X / export type { X }
421
+ const m = /^\{([\s\S]*)\}$/.exec(c);
422
+ if (!m) return false;
423
+ const names = m[1].split(",").map((s) => s.trim()).filter(Boolean);
424
+ return names.length > 0 && names.every((s) => /^type\s/.test(s));
425
+ };
426
+ while (i < n) {
427
+ const c = text[i];
428
+ if (c === "/" && text[i + 1] === "/") { while (i < n && text[i] !== "\n") i++; continue; }
429
+ if (c === "/" && text[i + 1] === "*") { const e = text.indexOf("*/", i + 2); i = e < 0 ? n : e + 2; continue; }
430
+ if (c === "'" || c === '"') { const s = readString(i); i = s ? s.end : text.indexOf("\n", i) < 0 ? n : text.indexOf("\n", i); setPrev("x"); continue; }
431
+ if (c === "`") {
432
+ const holes = [];
433
+ i = skipTemplate(i + 1, holes);
434
+ for (const [a, b] of holes) for (const s of moduleSpecifiers(text.slice(a, b))) out.push({ ...s, index: a + s.index });
435
+ setPrev("x");
436
+ continue;
437
+ }
438
+ if (c === "/") {
439
+ if (prev === "" || REGEX_AFTER.has(prev) || REGEX_AFTER_WORD.has(prev)) { // a regular expression literal
440
+ let k = i + 1;
441
+ let cls = false;
442
+ for (; k < n && text[k] !== "\n"; k++) {
443
+ if (text[k] === "\\") { k++; continue; }
444
+ if (text[k] === "[") cls = true;
445
+ else if (text[k] === "]") cls = false;
446
+ else if (text[k] === "/" && !cls) break;
447
+ }
448
+ i = k + 1;
449
+ while (i < n && ID_PART.test(text[i])) i++;
450
+ setPrev("x");
451
+ continue;
452
+ }
453
+ setPrev("/"); i++; continue;
454
+ }
455
+ if (ID_START.test(c)) {
456
+ const w = word(i);
457
+ const start = i;
458
+ i += w.length;
459
+ const dotted = text[start - 1] === "." && text[start - 2] !== ".";
460
+ if (!dotted && (w === "import" || w === "export" || w === "require")) {
461
+ const j = skipWs(i);
462
+ if (w === "require" || (w === "import" && text[j] === "(")) {
463
+ if (text[j] === "(") {
464
+ // import('x').Name (Name not then/catch/finally) and typeof import('x') are types only in a type position
465
+ let typeOnly = false;
466
+ if (w === "import") {
467
+ const close = text.indexOf(")", j);
468
+ const after = close < 0 ? "" : text.slice(close + 1, close + 40);
469
+ const member = /^\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/.exec(after);
470
+ if (prev === "typeof") typeOnly = typePosition(prev2);
471
+ else if (member && !["then", "catch", "finally"].includes(member[1])) typeOnly = typePosition(prev);
472
+ }
473
+ out.push({ spec: literalArg(j + 1), typeOnly, via: w === "require" ? "require" : "dynamic-import", index: start });
474
+ i = j + 1;
475
+ }
476
+ } else if (w === "import" && text[j] !== ".") {
477
+ const s = quoted(j);
478
+ if (s) { out.push({ spec: s.value, typeOnly: false, via: "import", index: start }); i = s.end; }
479
+ else {
480
+ const r = clauseFrom(j);
481
+ if (r) { out.push({ spec: r.spec, typeOnly: typeOnlyClause(r.clause), via: "import", index: start }); i = r.end; }
482
+ }
483
+ } else if (w === "export" && (text[j] === "*" || text[j] === "{" || /^type\s*[{*]/.test(text.slice(j, j + 8)))) {
484
+ const r = clauseFrom(j);
485
+ if (r) { out.push({ spec: r.spec, typeOnly: typeOnlyClause(r.clause), via: "export", index: start }); i = r.end; }
486
+ }
487
+ }
488
+ if (stmtWord === "" && w !== "export") stmtWord = w;
489
+ setPrev(w);
490
+ continue;
491
+ }
492
+ if (c === ";") { stmtWord = ""; stmtEq = false; }
493
+ else if (c === "{") { typeStack.push(inTypeStatement()); stmtWord = ""; stmtEq = false; }
494
+ else if (c === "}") { if (typeStack.length > 1) typeStack.pop(); stmtWord = ""; stmtEq = false; }
495
+ else if (c === "=" && !"=>".includes(text[i + 1] ?? "") && !"=!<>".includes(prev)) stmtEq = true;
496
+ if (!/\s/.test(c)) setPrev(c);
497
+ i++;
498
+ }
499
+ return out;
500
+ }
501
+
502
+ const JS_TO_TS = { ".js": [".ts", ".tsx", ".js"], ".jsx": [".tsx", ".jsx"], ".mjs": [".mts", ".mjs"], ".cjs": [".cts", ".cjs"] };
503
+
504
+ /** The committed file a specifier names, resolved like a bundler (extension and index probing), or null. */
505
+ export function resolveSpecifier(spec, fromRel, { root, aliases, fileSet, extensions }) {
506
+ const clean = spec.replace(/[?#].*$/, "");
507
+ let base;
508
+ if (clean.startsWith("./") || clean.startsWith("../") || clean === "." || clean === "..") base = path.posix.join(path.posix.dirname(fromRel), clean);
509
+ else {
510
+ const alias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((a) => clean.startsWith(a));
511
+ if (alias === undefined) return null; // a package
512
+ base = path.posix.join(root, aliases[alias], clean.slice(alias.length));
513
+ }
514
+ if (base.startsWith("../") || base === "..") return null;
515
+ const candidates = [base];
516
+ const ext = path.posix.extname(base);
517
+ for (const alt of JS_TO_TS[ext] ?? []) candidates.push(base.slice(0, -ext.length) + alt);
518
+ for (const e of extensions) candidates.push(base + e);
519
+ for (const e of extensions) candidates.push(`${base}/index${e}`);
520
+ return candidates.find((c) => fileSet.has(c)) ?? null;
521
+ }
522
+
523
+ function layerBoundaries({ root, files }) {
524
+ const policy = loadPolicy(root, files);
525
+ if (!policy) return [];
526
+ if (policy.error) return [policyError(policy.error)];
527
+ const { entries, error } = sectionOf(policy, "layerBoundaries");
528
+ if (error) return [error];
529
+ const out = [];
530
+ const fileSet = new Set(files);
531
+ for (const entry of entries) {
532
+ const bad = (why) => out.push(policyError(`${CONSUMER_POLICY_FILE} layerBoundaries ${entry.id}: ${why}`, `layerBoundaries:${entry.id}`));
533
+ if (!cleanRel(entry.root)) { bad("root must be a relative directory"); continue; }
534
+ const aliases = entry.aliases ?? {};
535
+ if (typeof aliases !== "object" || Array.isArray(aliases) || !Object.entries(aliases).every(([k, v]) => k !== "" && !k.startsWith(".") && typeof v === "string" && (v === "" || cleanRel(v.replace(/\/$/, ""))))) { bad("aliases map a specifier prefix to a directory under root"); continue; }
536
+ const sets = [entry.from, entry.forbid];
537
+ if (!sets.every((s) => s && isStringList(s.include) && s.include.length > 0 && globsOk(s.include) && globsOk(s.exclude)) || !globsOk(entry.stop)) { bad("from and forbid need include (non-empty) and optional exclude lists of globs relative to root; stop is a list of globs"); continue; }
538
+ const exts = entry.extensions ?? DEFAULT_CODE_EXT;
539
+ if (!isStringList(exts)) { bad("extensions must be a list like [\".ts\"]"); continue; }
540
+ const prefix = `${entry.root}/`;
541
+ const local = (f) => (f.startsWith(prefix) ? f.slice(prefix.length) : null);
542
+ const isCode = (f) => exts.includes(path.posix.extname(f).toLowerCase());
543
+ const ctx = { root: entry.root, aliases, fileSet, extensions: exts };
544
+ const forbidden = (f) => { const l = local(f); return l !== null && inSet(entry.forbid, l); };
545
+ const parsed = new Map();
546
+ const specsOf = (f) => {
547
+ if (!parsed.has(f)) { const text = readText(root, f); parsed.set(f, text === null ? [] : moduleSpecifiers(text).map((s) => ({ ...s, line: lineOf(text, s.index) }))); }
548
+ return parsed.get(f);
549
+ };
550
+ const starts = files.filter((f) => { const l = local(f); return l !== null && isCode(f) && inSet(entry.from, l); });
551
+ const reported = new Set();
552
+ for (const start of starts) {
553
+ const seen = new Set([start]);
554
+ const queue = [[start, [start]]];
555
+ while (queue.length) {
556
+ const [file, chain] = queue.shift();
557
+ for (const s of specsOf(file)) {
558
+ if (s.typeOnly) continue;
559
+ const here = chain.map((c) => c.slice(prefix.length)).join(" -> ");
560
+ if (s.spec === null) {
561
+ const key = `dynamic:${file}:${s.line}`;
562
+ if (!reported.has(key)) { reported.add(key); out.push({ file, subject: `${entry.id}:unreadable:${file.slice(prefix.length)}`, problem: `${file}:${s.line} has a ${s.via} whose argument is not a string literal, reached from ${here}: the layer boundary cannot be checked through it (${CONSUMER_POLICY_FILE} layerBoundaries ${entry.id})`, fix: "use a static import with a literal specifier" }); }
563
+ continue;
564
+ }
565
+ const target = resolveSpecifier(s.spec, file, ctx);
566
+ if (!target || !isCode(target) || local(target) === null) continue;
567
+ if ((entry.stop ?? []).some((g) => matchGlob(g, local(target)))) continue;
568
+ if (forbidden(target)) {
569
+ const key = `${start}\0${target}`;
570
+ if (reported.has(key)) continue;
571
+ reported.add(key);
572
+ out.push({ file: start, subject: `${entry.id}:${start.slice(prefix.length)}->${target.slice(prefix.length)}`, problem: `${file}:${s.line} '${s.spec}': ${here} -> ${target.slice(prefix.length)} reaches a file the layer boundary forbids (${CONSUMER_POLICY_FILE} layerBoundaries ${entry.id})`, fix: "import the value from a permitted layer, or move the file there" });
573
+ continue;
574
+ }
575
+ if (!seen.has(target)) { seen.add(target); queue.push([target, [...chain, target]]); }
576
+ }
577
+ }
578
+ }
579
+ }
580
+ return out;
581
+ }
582
+
583
+ export const CONSUMER_POLICY_RULES = Object.freeze([
584
+ { id: "retired-token", gap: "CA-13", run: retiredTokens },
585
+ { id: "route-deny-policy", gap: "CA-13", run: routeDenyPolicies },
586
+ { id: "layer-reach", gap: "CA-13", run: layerBoundaries },
587
+ { id: "spec-route-parity", gap: "CA-13", run: specRouteParity },
588
+ ]);