@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,1069 @@
1
+ // Shell contract: the files-only check of how an app wires rightkit-tauri-shell (rules SC-R1..SC-R9; scan gaps C1-01, C1-02, C1-03,
2
+ // C1-04 and C1-10). It reads tracked files and nothing else: no cargo, no pnpm, no network, no compile. It imports node builtins
3
+ // only, because preflight runs with no node_modules; the one piece of devtools-policy.mjs it needs, the content-devtools Cargo
4
+ // test, is a copy below (importing that module pulls in the build and managed-target code through qa-build-features.mjs).
5
+ //
6
+ // Each rule is { id, gap, run({ root, files }) } and returns findings [{ file, subject, problem, fix }] the way the G13-G21 app
7
+ // rules of scripts/preflight.mjs do: `root` is the directory that holds the exported tree and `files` the posix path of every
8
+ // tracked file in it. `problem` starts with "<file>:<line> ..." and `fix` says the exact change. The identity of a finding is
9
+ // rule|file|subject, so `subject` never carries a line number. A rule never throws on repository content: JSON that does not
10
+ // parse, Rust that is odd and files that are missing become a finding or a skip.
11
+ //
12
+ // shell-window-hidden SC-R1 a window created at startup that is not visible:false on some target
13
+ // shell-plugin-order SC-R2 the shell plugin is installed late or from setup
14
+ // shell-handler SC-R3 a rk_shell_* command the renderer calls that the Rust side does not register
15
+ // shell-csp SC-R4 a CSP below the baseline
16
+ // shell-grants SC-R5 core:window:allow-* granted while the shell's window command is registered
17
+ // qa-window-label SC-R6 QA Control binds a window label the shell does not use
18
+ // devtools-declaration SC-R7 the five sites of the content-only DevTools declaration disagree
19
+ // shell-lifecycle-external SC-R8 lifecycle_only next to an external-URL window (no hardening, no navigation policy)
20
+ // legal-receipt-native SC-R9 legal acceptance stamped with the webview clock instead of the native receipt service
21
+ //
22
+ // An app is every tracked tauri.conf.json outside node_modules/, vendor/ and target/. Targets are `base` plus each tauri.<platform>.conf.json:
23
+ // Tauri merges a platform file into the base by JSON Merge Patch (RFC 7396: objects merge, arrays replace, null deletes), and a platform
24
+ // target is reported only when its own patch restates the key the rule checks (otherwise it inherits the base result).
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
30
+ const PLATFORMS = ["macos", "windows", "linux", "ios", "android"];
31
+ // The shell is a desktop plugin: a mobile window is created by the OS and shown by it, so R1 judges desktop targets only.
32
+ const DESKTOP = new Set(["base", "macos", "windows", "linux"]);
33
+ const SHELL_CRATES = ["rightkit-tauri-shell", "rightkit-shell"];
34
+ const ALLOW_LIST_FILE = "packages/release/devtools-policy.json";
35
+ // The npm bridge's command map (R3 maps what an app imports from @rightkit/tauri-shell to rk_shell_* commands). It ships inside this
36
+ // package as shell-contract-bridge.json, generated from packages/tauri-shell/src/index.ts by scripts/generate-shell-contract-bridge.mjs
37
+ // and held to it by a test, because an installed copy of this package has no tauri-shell source beside it.
38
+ const BRIDGE_FILE = fileURLToPath(new URL("./shell-contract-bridge.json", import.meta.url));
39
+ // The source in this checkout, only as a fallback when the JSON is absent (a damaged copy); an installed package has neither and
40
+ // maps nothing, so every @rightkit/tauri-shell import then needs at least one registered command.
41
+ const TAURI_SHELL_INDEX = fileURLToPath(new URL("../tauri-shell/src/index.ts", import.meta.url));
42
+ const POLICY_FILE = fileURLToPath(new URL("./devtools-policy.json", import.meta.url));
43
+
44
+ // ---- small helpers ---------------------------------------------------------------------------------------------------------
45
+ const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8").replace(/^\uFEFF/, ""); } catch { return null; } };
46
+ const parseJson = (text) => { try { return { json: JSON.parse(text) }; } catch (error) { return { error: String(error?.message ?? error) }; } };
47
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
48
+ const blank = (s) => s.replace(/[^\n]/g, " ");
49
+ const join = (dir, rel) => (dir ? `${dir}/${rel}` : rel);
50
+ const under = (dir, file) => dir === "" || file.startsWith(`${dir}/`);
51
+ const relTo = (dir, file) => (dir === "" ? file : file.slice(dir.length + 1));
52
+ 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; };
53
+ const lineOfPattern = (text, pattern) => { const m = pattern.exec(text ?? ""); return m ? lineOf(text, m.index) : 1; };
54
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ const uniqueBy = (items, key) => { const seen = new Set(); return items.filter((item) => { const k = key(item); if (seen.has(k)) return false; seen.add(k); return true; }); };
56
+ const dedupe = (findings) => uniqueBy(findings, (f) => `${f.file}|${f.subject}`);
57
+
58
+ /** RFC 7396 JSON Merge Patch: objects merge recursively, any other patch value replaces, null deletes the key. */
59
+ export function mergePatch(target, patch) {
60
+ if (!isObject(patch)) return patch;
61
+ const out = isObject(target) ? { ...target } : {};
62
+ for (const [key, value] of Object.entries(patch)) {
63
+ if (key === "__proto__") continue;
64
+ if (value === null) delete out[key];
65
+ else out[key] = mergePatch(out[key], value);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const hasPath = (value, keys) => keys.every((key) => { if (!isObject(value) || !Object.hasOwn(value, key)) return false; value = value[key]; return true; });
71
+ const getPath = (value, keys) => keys.reduce((v, key) => (isObject(v) ? v[key] : undefined), value);
72
+
73
+ // ---- Rust, read without a parser --------------------------------------------------------------------------------------------
74
+ // scanRust returns the text twice with every comment blanked (offsets and line numbers kept): `code` keeps string and char literals,
75
+ // `skel` blanks their contents too, so brackets and keywords found in `skel` are real code. `#[cfg(test)]` items are blanked in both.
76
+ const isIdentChar = (ch) => ch !== undefined && /[A-Za-z0-9_]/.test(ch);
77
+
78
+ /** The index of the bracket that closes the one at `open` in `skel`, or -1. */
79
+ export function matchClose(skel, open) {
80
+ const o = skel[open];
81
+ const c = o === "(" ? ")" : o === "[" ? "]" : o === "{" ? "}" : null;
82
+ if (c === null) return -1;
83
+ let depth = 0;
84
+ for (let i = open; i < skel.length; i++) {
85
+ const ch = skel[i];
86
+ if (ch === o) depth += 1;
87
+ else if (ch === c && --depth === 0) return i;
88
+ }
89
+ return -1;
90
+ }
91
+
92
+ /** The top-level arguments of the bracket group opened at `open`, as trimmed [start, end) ranges (a trailing comma adds none). */
93
+ export function callArgs(skel, open) {
94
+ const close = matchClose(skel, open);
95
+ if (close < 0) return [];
96
+ const ranges = [];
97
+ let depth = 0;
98
+ let start = open + 1;
99
+ const push = (end) => {
100
+ let s = start; let e = end;
101
+ while (s < e && /\s/.test(skel[s])) s += 1;
102
+ while (e > s && /\s/.test(skel[e - 1])) e -= 1;
103
+ if (e > s) ranges.push([s, e]);
104
+ };
105
+ for (let i = open + 1; i < close; i++) {
106
+ const ch = skel[i];
107
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
108
+ else if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
109
+ else if (ch === "," && depth === 0) { push(i); start = i + 1; }
110
+ }
111
+ push(close);
112
+ return ranges;
113
+ }
114
+
115
+ export function scanRust(text) {
116
+ const n = text.length;
117
+ const code = [];
118
+ const skel = [];
119
+ let run = 0;
120
+ const flush = (to) => { if (to > run) { const s = text.slice(run, to); code.push(s); skel.push(s); } };
121
+ const comment = (from, to) => { flush(from); const b = blank(text.slice(from, to)); code.push(b); skel.push(b); run = to; };
122
+ const literal = (from, bodyFrom, bodyTo, to) => { flush(from); code.push(text.slice(from, to)); skel.push(text.slice(from, bodyFrom) + blank(text.slice(bodyFrom, bodyTo)) + text.slice(bodyTo, to)); run = to; };
123
+ let i = 0;
124
+ while (i < n) {
125
+ const ch = text[i];
126
+ if (ch === "/" && text[i + 1] === "/") { const j = text.indexOf("\n", i); const end = j < 0 ? n : j; comment(i, end); i = end; continue; }
127
+ if (ch === "/" && text[i + 1] === "*") {
128
+ let depth = 1; let j = i + 2;
129
+ while (j < n && depth > 0) { if (text[j] === "/" && text[j + 1] === "*") { depth += 1; j += 2; } else if (text[j] === "*" && text[j + 1] === "/") { depth -= 1; j += 2; } else j += 1; }
130
+ comment(i, j); i = j; continue;
131
+ }
132
+ if (ch === '"') {
133
+ let j = i + 1;
134
+ while (j < n && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
135
+ const end = Math.min(j + 1, n);
136
+ literal(i, i + 1, Math.min(j, n), end); i = end; continue;
137
+ }
138
+ if (ch === "r" && (!isIdentChar(text[i - 1]) || (text[i - 1] === "b" && !isIdentChar(text[i - 2])))) {
139
+ let j = i + 1; let hashes = 0;
140
+ while (text[j] === "#") { hashes += 1; j += 1; }
141
+ if (text[j] === '"') {
142
+ const close = `"${"#".repeat(hashes)}`;
143
+ const at = text.indexOf(close, j + 1);
144
+ const bodyTo = at < 0 ? n : at;
145
+ const end = at < 0 ? n : at + close.length;
146
+ literal(i, j + 1, bodyTo, end); i = end; continue;
147
+ }
148
+ }
149
+ if (ch === "'") {
150
+ let end = -1;
151
+ if (text[i + 1] === "\\") { const j = text.indexOf("'", i + 3); if (j > 0 && j - i <= 12) end = j + 1; }
152
+ else if (text[i + 1] !== undefined && text[i + 1] !== "'") { const width = text.codePointAt(i + 1) > 0xffff ? 2 : 1; if (text[i + 1 + width] === "'") end = i + 2 + width; }
153
+ if (end > 0) { literal(i, i + 1, end - 1, end); i = end; continue; }
154
+ }
155
+ i += 1;
156
+ }
157
+ flush(n);
158
+ let c = code.join("");
159
+ let s = skel.join("");
160
+ // `#![cfg(test)]` makes the whole file test code; `#[cfg(test)] item` is blanked item by item.
161
+ const cfgTest = /#\s*(!?)\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]/g;
162
+ const ranges = [];
163
+ for (let m = cfgTest.exec(s); m; m = cfgTest.exec(s)) {
164
+ if (m[1] === "!") return { code: blank(c), skel: blank(s) };
165
+ let j = m.index + m[0].length;
166
+ for (;;) {
167
+ while (/\s/.test(s[j] ?? "")) j += 1;
168
+ if (s[j] === "#" && /^#\s*\[/.test(s.slice(j, j + 8))) { const open = s.indexOf("[", j); const close = matchClose(s, open); if (close < 0) break; j = close + 1; } else break;
169
+ }
170
+ // Only an item is blanked; a test-only match arm, field or statement stays (it holds no registration worth reading).
171
+ if (!/^(?:pub(?:\([^)]*\))?\s+)?(?:(?:async|unsafe|const|extern(?:\s+"[^"]*")?)\s+)*(?:mod|fn|use|impl|struct|enum|trait|type|static|const|macro_rules\s*!)\b/.test(s.slice(j, j + 120))) continue;
172
+ let depth = 0; let end = s.length;
173
+ for (let k = j; k < s.length; k++) {
174
+ const ck = s[k];
175
+ if (ck === "(" || ck === "[") depth += 1;
176
+ else if (ck === ")" || ck === "]") depth -= 1;
177
+ else if (depth === 0 && ck === ";") { end = k + 1; break; }
178
+ else if (depth === 0 && ck === "{") { const close = matchClose(s, k); end = close < 0 ? s.length : close + 1; break; }
179
+ }
180
+ ranges.push([m.index, end]);
181
+ cfgTest.lastIndex = Math.max(cfgTest.lastIndex, end);
182
+ }
183
+ for (const [from, to] of ranges.reverse()) { c = c.slice(0, from) + blank(c.slice(from, to)) + c.slice(to); s = s.slice(0, from) + blank(s.slice(from, to)) + s.slice(to); }
184
+ return { code: c, skel: s };
185
+ }
186
+
187
+ const RUST_CACHE = new Map();
188
+ const parsedRust = (text) => {
189
+ let parsed = RUST_CACHE.get(text);
190
+ if (!parsed) { if (RUST_CACHE.size > 4000) RUST_CACHE.clear(); parsed = scanRust(text); RUST_CACHE.set(text, parsed); }
191
+ return parsed;
192
+ };
193
+
194
+ /** Every `fn name(..) .. { body }` of the skeleton: { name, start, bodyStart, bodyEnd } (declarations without a body are skipped). */
195
+ export function rustFns(skel) {
196
+ const fns = [];
197
+ const FN = /\bfn\s+([A-Za-z_]\w*)/g;
198
+ for (let m = FN.exec(skel); m; m = FN.exec(skel)) {
199
+ let i = m.index + m[0].length;
200
+ while (/\s/.test(skel[i] ?? "")) i += 1;
201
+ if (skel[i] === "<") {
202
+ let depth = 0;
203
+ for (; i < skel.length; i++) { if (skel[i] === "<") depth += 1; else if (skel[i] === ">" && skel[i - 1] !== "-" && skel[i - 1] !== "=" && --depth === 0) { i += 1; break; } }
204
+ }
205
+ while (/\s/.test(skel[i] ?? "")) i += 1;
206
+ if (skel[i] !== "(") continue;
207
+ const paramsEnd = matchClose(skel, i);
208
+ if (paramsEnd < 0) continue;
209
+ let depth = 0; let bodyStart = -1;
210
+ for (let k = paramsEnd + 1; k < skel.length; k++) {
211
+ const ck = skel[k];
212
+ if (ck === "(" || ck === "[") depth += 1;
213
+ else if (ck === ")" || ck === "]") depth -= 1;
214
+ else if (depth === 0 && ck === ";") break;
215
+ else if (depth === 0 && ck === "{") { bodyStart = k; break; }
216
+ }
217
+ if (bodyStart < 0) continue;
218
+ const bodyEnd = matchClose(skel, bodyStart);
219
+ if (bodyEnd > 0) fns.push({ name: m[1], start: m.index, bodyStart, bodyEnd });
220
+ }
221
+ return fns;
222
+ }
223
+ const innermostFn = (fns, offset) => fns.filter((f) => f.bodyStart <= offset && offset <= f.bodyEnd).sort((a, b) => b.bodyStart - a.bodyStart)[0];
224
+
225
+ const SHELL_CONSTRUCT = /\b(?:Shell|ShellPlugin)\s*::\s*builder\s*\(/;
226
+ const squash = (s) => s.replace(/\s+/g, " ").trim();
227
+ /** `const NAME: &str = "value";` (or static) literals of a Rust text: Map name -> value. */
228
+ function stringConsts(parsedFiles) {
229
+ const consts = new Map();
230
+ for (const { code } of parsedFiles) for (const m of code.matchAll(/\b(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:\s*&(?:'static\s+)?str\s*=\s*"([^"\\]*)"\s*;/g)) consts.set(m[1], m[2]);
231
+ return consts;
232
+ }
233
+ /** The label a Rust argument names: a "literal", or an identifier that is a string const; undefined when it is neither. */
234
+ function labelOf(argCode, consts) {
235
+ const lit = /^&?\s*"([^"\\]*)"$/.exec(argCode);
236
+ if (lit) return lit[1];
237
+ const name = /^(?:[A-Za-z_]\w*::)*([A-Z][A-Z0-9_]*)$/.exec(argCode.trim());
238
+ return name ? consts.get(name[1]) : undefined;
239
+ }
240
+
241
+ // ---- JavaScript and JSX, read without a parser -------------------------------------------------------------------------------
242
+ function skipQuoted(text, i, quote) {
243
+ let j = i + 1;
244
+ while (j < text.length && text[j] !== quote && text[j] !== "\n") j += text[j] === "\\" ? 2 : 1;
245
+ return Math.min(j + 1, text.length);
246
+ }
247
+ // A `/` starts a regular expression, not a division, after one of these tokens (`<` is left out: `</div>` is JSX).
248
+ const REGEX_AFTER = /[(,=:[!&|?{;+\-*%~^]$|\b(?:return|typeof|case|in|of|delete|void|throw|new|else|do)$/;
249
+ // A quote starts a string in expression position; after JSX text (`Don't`) it is only an apostrophe.
250
+ const QUOTE_AFTER = /[(,=:[!&|?{};+\-*%<>~^]$|\b(?:return|typeof|case|in|of|delete|void|throw|new|else|do|from|import|export|default|extends|yield|await|as)$/;
251
+ const quoteAllowed = (text, i) => { const before = text.slice(Math.max(0, i - 12), i).trimEnd(); return before === "" || QUOTE_AFTER.test(before); };
252
+ const regexAllowed = (text, i) => { const before = text.slice(Math.max(0, i - 12), i).trimEnd(); return before === "" || REGEX_AFTER.test(before); };
253
+ function skipRegex(text, i) {
254
+ let j = i + 1; let inClass = false;
255
+ while (j < text.length && text[j] !== "\n") {
256
+ if (text[j] === "\\") { j += 2; continue; }
257
+ if (text[j] === "[") inClass = true; else if (text[j] === "]") inClass = false; else if (text[j] === "/" && !inClass) { j += 1; while (/[a-z]/i.test(text[j] ?? "")) j += 1; return j; }
258
+ j += 1;
259
+ }
260
+ return i + 1;
261
+ }
262
+ function skipTemplate(text, i) {
263
+ let j = i + 1;
264
+ while (j < text.length && text[j] !== "`") {
265
+ if (text[j] === "\\") { j += 2; continue; }
266
+ if (text[j] === "$" && text[j + 1] === "{") {
267
+ let depth = 1; j += 2;
268
+ while (j < text.length && depth > 0) {
269
+ if (text[j] === "`") j = skipTemplate(text, j);
270
+ else if (text[j] === '"' || text[j] === "'") j = skipQuoted(text, j, text[j]);
271
+ else { if (text[j] === "{") depth += 1; else if (text[j] === "}") depth -= 1; j += 1; }
272
+ }
273
+ continue;
274
+ }
275
+ j += 1;
276
+ }
277
+ return Math.min(j + 1, text.length);
278
+ }
279
+
280
+ /** `code`: comments blanked. `skel`: comments blanked and string and template contents blanked too (delimiters kept). */
281
+ export function scanJs(text) {
282
+ const code = []; const skel = [];
283
+ let run = 0;
284
+ const flush = (to) => { if (to > run) { const s = text.slice(run, to); code.push(s); skel.push(s); } };
285
+ let i = 0;
286
+ while (i < text.length) {
287
+ const ch = text[i];
288
+ if (ch === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
289
+ flush(i);
290
+ let end;
291
+ if (text[i + 1] === "/") { const j = text.indexOf("\n", i); end = j < 0 ? text.length : j; } else { const j = text.indexOf("*/", i + 2); end = j < 0 ? text.length : j + 2; }
292
+ const b = blank(text.slice(i, end)); code.push(b); skel.push(b); run = i = end; continue;
293
+ }
294
+ if (ch === "`" || ((ch === '"' || ch === "'") && quoteAllowed(text, i)) || (ch === "/" && regexAllowed(text, i))) {
295
+ flush(i);
296
+ const end = ch === "`" ? skipTemplate(text, i) : ch === "/" ? skipRegex(text, i) : skipQuoted(text, i, ch);
297
+ code.push(text.slice(i, end));
298
+ skel.push(ch + blank(text.slice(i + 1, Math.max(i + 1, end - 1))) + (end - i >= 2 ? text[end - 1] : ""));
299
+ run = i = end; continue;
300
+ }
301
+ i += 1;
302
+ }
303
+ flush(text.length);
304
+ return { code: code.join(""), skel: skel.join("") };
305
+ }
306
+ const JS_CACHE = new Map();
307
+ const parsedJs = (text) => {
308
+ let parsed = JS_CACHE.get(text);
309
+ if (!parsed) { if (JS_CACHE.size > 4000) JS_CACHE.clear(); parsed = scanJs(text); JS_CACHE.set(text, parsed); }
310
+ return parsed;
311
+ };
312
+
313
+ /** The attributes of the JSX element that opens at `lt` (a `<` in `skel`): { attrs: Map name -> { kind, text, offset }, spread, end } or null. */
314
+ export function readJsxTag(skel, code, lt) {
315
+ let i = lt + 1;
316
+ while (/[\w.$]/.test(skel[i] ?? "")) i += 1;
317
+ const attrs = new Map();
318
+ let spread = false;
319
+ const ATTR = /[A-Za-z_$][\w$:-]*/y;
320
+ for (;;) {
321
+ while (/\s/.test(skel[i] ?? "")) i += 1;
322
+ if (i >= skel.length) return null;
323
+ if (skel[i] === "/" && skel[i + 1] === ">") return { attrs, spread, end: i + 2 };
324
+ if (skel[i] === ">") return { attrs, spread, end: i + 1 };
325
+ if (skel[i] === "{") { const close = matchClose(skel, i); if (close < 0) return null; spread = true; i = close + 1; continue; }
326
+ ATTR.lastIndex = i;
327
+ const name = ATTR.exec(skel)?.[0];
328
+ if (!name) return null;
329
+ i += name.length;
330
+ while (/\s/.test(skel[i] ?? "")) i += 1;
331
+ if (skel[i] !== "=") { attrs.set(name, { kind: "true", text: "true", offset: i }); continue; }
332
+ i += 1;
333
+ while (/\s/.test(skel[i] ?? "")) i += 1;
334
+ if (skel[i] === "{") { const close = matchClose(skel, i); if (close < 0) return null; attrs.set(name, { kind: "expr", text: code.slice(i + 1, close).trim(), offset: i }); i = close + 1; }
335
+ else if (skel[i] === '"' || skel[i] === "'") { const close = skel.indexOf(skel[i], i + 1); if (close < 0) return null; attrs.set(name, { kind: "string", text: code.slice(i + 1, close), offset: i }); i = close + 1; }
336
+ else return null;
337
+ }
338
+ }
339
+
340
+ // ---- apps -------------------------------------------------------------------------------------------------------------------
341
+ const RUST_SKIP = /(^|\/)(target|gen|tests|benches)\//;
342
+ const RUST_TEST_FILE = /(^|\/)(tests?|[^/]*_tests?)\.rs$/;
343
+ const RENDERER_SKIP = /(^|\/)(node_modules|dist|build|src-tauri|target|__tests__|tests|e2e|scripts)\/|\.(test|spec)\.[^/]*$|\.d\.ts$/;
344
+ const RENDERER_FILE = /\.(ts|tsx|js|jsx|mjs)$/;
345
+
346
+ /**
347
+ * Every app under `files`: one per tracked tauri.conf.json outside node_modules/, vendor/ and target/.
348
+ * { confDir, appRoot, conf, platforms, targets(keyPath), capabilityFiles, rustFiles, rendererFiles, releaseConfig, cargoToml, buildRs }
349
+ * (directories are posix paths relative to the tree, "" for the root; a file that does not parse carries `error`).
350
+ */
351
+ export function discoverApps({ root, files }) {
352
+ const tracked = new Set(files);
353
+ const confFiles = files.filter((f) => (f === "tauri.conf.json" || f.endsWith("/tauri.conf.json")) && !SKIPPED_TREE.test(f)).sort();
354
+ const confDirs = confFiles.map((f) => f.slice(0, -"tauri.conf.json".length).replace(/\/$/, ""));
355
+ const rootOf = (confDir) => (path.posix.basename(confDir) === "src-tauri" ? (path.posix.dirname(confDir) === "." ? "" : path.posix.dirname(confDir)) : confDir);
356
+ const appRoots = confDirs.map(rootOf);
357
+ const apps = [];
358
+ confFiles.forEach((confFile, index) => {
359
+ const confDir = confDirs[index];
360
+ const appRoot = rootOf(confDir);
361
+ const read = (file, target) => { const text = readText(root, file); return { file, target, text, ...(text === null ? { error: "cannot be read" } : parseJson(text)) }; };
362
+ const conf = read(confFile, "base");
363
+ const platforms = PLATFORMS.map((p) => join(confDir, `tauri.${p}.conf.json`)).filter((f) => tracked.has(f)).map((f) => read(f, path.posix.basename(f).split(".")[1]));
364
+ // Files of a nested app belong to that app: a file under a deeper app's directory is not this app's.
365
+ const owned = (dir, file, dirs) => !dirs.some((other) => other.length > dir.length && under(other, file) && under(dir, other));
366
+ const rustFiles = files.filter((f) => f.endsWith(".rs") && under(confDir, f) && !SKIPPED_TREE.test(f) && !RUST_SKIP.test(relTo(confDir, f)) && !RUST_TEST_FILE.test(f) && owned(confDir, f, confDirs));
367
+ const rendererFiles = files.filter((f) => RENDERER_FILE.test(f) && under(appRoot, f) && !SKIPPED_TREE.test(f) && !RENDERER_SKIP.test(relTo(appRoot, f)) && owned(appRoot, f, appRoots));
368
+ const capabilityFiles = files.filter((f) => f.endsWith(".json") && under(join(confDir, "capabilities"), f) && !SKIPPED_TREE.test(f));
369
+ let releaseConfig = null;
370
+ for (let dir = appRoot; releaseConfig === null; dir = path.posix.dirname(dir) === "." ? "" : path.posix.dirname(dir)) {
371
+ releaseConfig = ["right-release.config.json", "right-release.config.mjs"].map((n) => join(dir, n)).find((f) => tracked.has(f)) ?? null;
372
+ if (dir === "") break;
373
+ }
374
+ apps.push({
375
+ confDir, appRoot, conf, platforms, rustFiles, rendererFiles, capabilityFiles, releaseConfig,
376
+ cargoToml: tracked.has(join(confDir, "Cargo.toml")) ? join(confDir, "Cargo.toml") : null,
377
+ buildRs: tracked.has(join(confDir, "build.rs")) ? join(confDir, "build.rs") : null,
378
+ /** The merged config of `base` and of each platform whose own patch restates `keyPath` (all of them when keyPath is empty). */
379
+ targets(keyPath = []) {
380
+ if (conf.error || !isObject(conf.json)) return [];
381
+ const out = [{ name: "base", file: confFile, merged: conf.json }];
382
+ for (const p of platforms) if (!p.error && isObject(p.json) && (keyPath.length === 0 || hasPath(p.json, keyPath))) out.push({ name: p.target, file: p.file, merged: mergePatch(conf.json, p.json) });
383
+ return out;
384
+ },
385
+ });
386
+ });
387
+ return apps;
388
+ }
389
+
390
+ const readRust = (root, app, needle) => app.rustFiles.map((file) => ({ file, text: readText(root, file) })).filter((f) => f.text !== null && (needle === undefined || needle.test(f.text))).map((f) => ({ ...f, ...parsedRust(f.text) }));
391
+ const readRenderer = (root, app, needle) => app.rendererFiles.map((file) => ({ file, text: readText(root, file) })).filter((f) => f.text !== null && (needle === undefined || needle.test(f.text))).map((f) => ({ ...f, ...parsedJs(f.text) }));
392
+
393
+ // Every rule judges one app at a time; a rule that fails on one app reports that as a finding with an identity (never a throw, which
394
+ // preflight can only show as an unkeyed FAIL that no baseline can list) and still judges the rest.
395
+ const ruleError = (app, error) => ({ file: app.conf.file,
396
+ subject: "rule-error",
397
+ problem: `${app.conf.file}:1 the shell-contract rule failed on this app (${String(error?.message ?? error).slice(0, 120)})`,
398
+ fix: "report it to rightkit with this tauri.conf.json: a rule must not fail on repository content",
399
+ });
400
+ const perApp = (check) => (input) => {
401
+ const out = [];
402
+ for (const app of discoverApps(input)) { try { out.push(...check({ ...input, app })); } catch (error) { out.push(ruleError(app, error)); } }
403
+ return dedupe(out);
404
+ };
405
+
406
+ // ---- R1 shell-window-hidden --------------------------------------------------------------------------------------------------
407
+ function conflictFindings(app) {
408
+ return [app.conf, ...app.platforms].filter((c) => c.error).map((c) => ({ file: c.file, subject: `${c.target}:unparseable`, problem: `${c.file}:1 is not valid JSON (${c.error}), so no shell-contract rule can read this ${c.target} config`, fix: "fix the JSON syntax; Tauri refuses the file too" }));
409
+ }
410
+
411
+ const shellWindowHidden = perApp(({ app, root }) => {
412
+ const out = conflictFindings(app);
413
+ for (const target of app.targets(["app", "windows"])) {
414
+ if (!DESKTOP.has(target.name)) continue;
415
+ const windows = getPath(target.merged, ["app", "windows"]);
416
+ if (!Array.isArray(windows)) continue;
417
+ const text = readText(root, target.file) ?? "";
418
+ windows.forEach((w, i) => {
419
+ if (!isObject(w) || w.create === false || w.visible === false) return;
420
+ const label = typeof w.label === "string" ? w.label : "main";
421
+ const line = lineOfPattern(text, new RegExp(`"label"\\s*:\\s*"${escapeRegExp(label)}"`));
422
+ const restated = target.name === "base" ? "" : ` This ${target.name} file restates app.windows, and Tauri replaces arrays on merge, so it has to say visible: false itself.`;
423
+ out.push({
424
+ file: target.file,
425
+ subject: `${target.name}:${label}${windows.filter((x, j) => j < i && (x?.label ?? "main") === label).length ? `#${i}` : ""}`,
426
+ problem: `${target.file}:${line} window "${label}" is created at startup on ${target.name} without "visible": false: the shell shows the window when its webview is ready, so a window that starts visible flashes and races show-on-ready.${restated}`,
427
+ fix: `set "visible": false on window "${label}" in ${target.file} (an app that owns its windows, with host_windows or lifecycle_only, shows it itself once its webview is ready)`,
428
+ });
429
+ });
430
+ }
431
+ return out;
432
+ });
433
+
434
+ // ---- R4 shell-csp ------------------------------------------------------------------------------------------------------------
435
+ /** The directives of a Tauri `app.security.csp` (a string or a directive object): Map name -> [source...], or null when it has none. */
436
+ export function parseCsp(csp) {
437
+ const directives = new Map();
438
+ if (typeof csp === "string") {
439
+ for (const part of csp.split(";")) { const tokens = part.trim().split(/\s+/).filter(Boolean); if (tokens.length) { const name = tokens.shift().toLowerCase(); if (!directives.has(name)) directives.set(name, tokens); } }
440
+ } else if (isObject(csp)) {
441
+ for (const [name, value] of Object.entries(csp)) directives.set(name.toLowerCase(), (Array.isArray(value) ? value : [value]).flatMap((v) => String(v).split(/\s+/)).filter(Boolean));
442
+ }
443
+ return directives.size ? directives : null;
444
+ }
445
+ const hasToken = (tokens, token) => (tokens ?? []).some((t) => t.toLowerCase() === token);
446
+
447
+ const shellCsp = perApp(({ app, root }) => {
448
+ const out = [];
449
+ for (const target of app.targets(["app", "security", "csp"])) {
450
+ const text = readText(root, target.file) ?? "";
451
+ const line = lineOfPattern(text, /"csp"\s*:/);
452
+ const csp = parseCsp(getPath(target.merged, ["app", "security", "csp"]));
453
+ const add = (what, why, fix) => out.push({ file: target.file, subject: `${target.name}:${what}`, problem: `${target.file}:${line} ${target.name} CSP ${why}`, fix });
454
+ if (!csp) { add("csp-missing", "is missing: the webview runs with no content security policy at all", `set app.security.csp in ${target.file} (default-src 'self'; frame-ancestors 'none'; connect-src with the hosts the app calls)`); continue; }
455
+ if (!hasToken(csp.get("default-src"), "'self'")) add("default-src", "has no default-src 'self'", "add default-src 'self' to the policy");
456
+ if (!hasToken(csp.get("frame-ancestors"), "'none'")) add("frame-ancestors", "has no frame-ancestors 'none': the page can be framed by another origin", "add frame-ancestors 'none' to the policy");
457
+ const scripts = csp.get("script-src") ?? csp.get("default-src");
458
+ const scriptName = csp.has("script-src") ? "script-src" : "default-src (no script-src)";
459
+ if (hasToken(scripts, "'unsafe-inline'")) add("script-src-unsafe-inline", `allows 'unsafe-inline' in ${scriptName}: any injected markup can run script`, "remove 'unsafe-inline' from script-src (style-src may keep it)");
460
+ if (hasToken(scripts, "'unsafe-eval'")) add("script-src-unsafe-eval", `allows 'unsafe-eval' in ${scriptName}: strings can be run as code`, "remove 'unsafe-eval' from script-src");
461
+ if (!csp.has("connect-src")) add("connect-src", "has no connect-src: network access falls back to default-src and the hosts the app calls are not declared", "add connect-src 'self' plus the hosts the app calls (ipc: is advisory)");
462
+ }
463
+ return out;
464
+ });
465
+
466
+ // ---- shell registration in Rust (R2, R3, R5, R6, R8) --------------------------------------------------------------------------
467
+ /** The shell handler of an app's Rust: whether handler![..] is used, each hand-listed rk_shell_* command, and where generate_handler! sits. */
468
+ function handlerInfo(parsedFiles) {
469
+ const info = { macro: null, listed: new Map(), sites: [] };
470
+ for (const { file, text, code, skel } of parsedFiles) {
471
+ const macro = /\b(?:rightkit_tauri_shell|rightkit_shell)\s*::\s*handler\s*!\s*[[({]/.exec(skel);
472
+ if (macro && !info.macro) info.macro = { file, line: lineOf(text, macro.index) };
473
+ for (const m of skel.matchAll(/\bgenerate_handler\s*!\s*([[({])/g)) {
474
+ const open = m.index + m[0].length - 1;
475
+ info.sites.push({ file, line: lineOf(text, m.index) });
476
+ for (const [s, e] of callArgs(skel, open)) {
477
+ const name = /([A-Za-z_]\w*)\s*$/.exec(code.slice(s, e))?.[1];
478
+ if (name?.startsWith("rk_shell_") && !info.listed.has(name)) info.listed.set(name, { file, line: lineOf(text, s) });
479
+ }
480
+ }
481
+ }
482
+ return info;
483
+ }
484
+ const registers = (info, command) => info.macro !== null || info.listed.has(command);
485
+
486
+ // ---- R2 shell-plugin-order ---------------------------------------------------------------------------------------------------
487
+ const QA_PLUGIN = /rightkit_qa|control|(^|[^a-z])qa([^a-z]|$)|webdriver|wdio/i;
488
+
489
+ const shellPluginOrder = perApp(({ app, root }) => {
490
+ const out = [];
491
+ const parsed = readRust(root, app, /\.\s*plugin\s*\(|\b(?:Shell|ShellPlugin)\s*::\s*builder/).map((f) => ({ ...f, fns: rustFns(f.skel) }));
492
+ // Functions that build a shell: directly, or by calling one that does.
493
+ const shellFns = [];
494
+ for (const f of parsed) for (const fn of f.fns) if (SHELL_CONSTRUCT.test(f.skel.slice(fn.bodyStart, fn.bodyEnd))) shellFns.push({ ...fn, file: f.file });
495
+ for (let round = 0; round < 2; round++) {
496
+ for (const f of parsed) for (const fn of f.fns) {
497
+ if (shellFns.some((s) => s.file === f.file && s.start === fn.start)) continue;
498
+ const body = f.skel.slice(fn.bodyStart, fn.bodyEnd);
499
+ if (shellFns.some((s) => new RegExp(`\\b${s.name}\\s*\\(`).test(body))) shellFns.push({ ...fn, file: f.file });
500
+ }
501
+ }
502
+ const stem = (file) => { const base = path.posix.basename(file, ".rs"); return base === "mod" ? path.posix.basename(path.posix.dirname(file)) : base; };
503
+ const callsShellFn = (arg, file) => {
504
+ const m = /^\s*&?\s*((?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*)\s*\(/.exec(arg);
505
+ if (!m) return false;
506
+ const parts = m[1].split(/\s*::\s*/);
507
+ const name = parts.at(-1);
508
+ const qualifier = parts.length > 1 ? parts.at(-2) : null;
509
+ let candidates = shellFns.filter((s) => s.name === name);
510
+ if (qualifier && !["crate", "self", "super"].includes(qualifier)) candidates = candidates.filter((s) => stem(s.file) === qualifier);
511
+ else if (!qualifier && candidates.some((s) => s.file === file)) candidates = candidates.filter((s) => s.file === file);
512
+ return candidates.length > 0;
513
+ };
514
+ for (const f of parsed) {
515
+ const fns = f.fns;
516
+ const setups = [...f.skel.matchAll(/\.\s*setup\s*\(/g)].map((m) => callArgs(f.skel, m.index + m[0].length - 1)[0]).filter(Boolean);
517
+ const regs = [];
518
+ for (const m of f.skel.matchAll(/\.\s*plugin\s*\(/g)) {
519
+ const [arg] = callArgs(f.skel, m.index + m[0].length - 1);
520
+ if (!arg) continue;
521
+ const argCode = f.code.slice(arg[0], arg[1]);
522
+ const fn = innermostFn(fns, m.index);
523
+ const scope = fn ? f.skel.slice(fn.bodyStart, m.index) : f.skel.slice(0, m.index);
524
+ const scopeCode = fn ? f.code.slice(fn.bodyStart, m.index) : f.code.slice(0, m.index);
525
+ let shell = SHELL_CONSTRUCT.test(argCode) || callsShellFn(argCode, f.file);
526
+ const variable = /^\s*&?\s*([A-Za-z_]\w*)\s*\./.exec(argCode)?.[1];
527
+ if (!shell && variable) {
528
+ for (const b of scope.matchAll(new RegExp(`\\blet\\s+(?:mut\\s+)?${variable}\\b[^;=]*=`, "g"))) {
529
+ const end = scope.indexOf(";", b.index);
530
+ if (SHELL_CONSTRUCT.test(scope.slice(b.index, end < 0 ? scope.length : end))) shell = true;
531
+ }
532
+ }
533
+ // An identifier argument is QA when what it was bound from is (`if let Some(plugin) = control.build_if_enabled()`).
534
+ let qa = QA_PLUGIN.test(argCode);
535
+ if (!qa && /^[A-Za-z_]\w*$/.test(argCode)) {
536
+ for (const b of scopeCode.matchAll(new RegExp(`(?:\\blet\\s+(?:mut\\s+)?${argCode}\\b[^;=]*|Some\\(\\s*${argCode}\\s*\\))\\s*=\\s*([^;{]*)`, "g"))) if (QA_PLUGIN.test(b[1])) qa = true;
537
+ }
538
+ regs.push({ at: m.index, argCode, fn, shell, qa, single: /single_instance_plugin/.test(argCode), inSetup: setups.some(([s, e]) => m.index >= s && m.index < e) });
539
+ }
540
+ for (const s of regs.filter((r) => r.shell)) {
541
+ if (s.inSetup) {
542
+ out.push({
543
+ file: f.file,
544
+ subject: "setup-install",
545
+ problem: `${f.file}:${lineOf(f.text, s.at)} installs the shell plugin from setup (.plugin(..) inside .setup(..)): the window, the single-instance claim and the other plugins have already started by then`,
546
+ fix: "register it on the builder, tauri::Builder::default().plugin(shell.plugin()), before every non-QA plugin, and leave setup for work that needs the running app",
547
+ });
548
+ continue;
549
+ }
550
+ // A shell built with single_instance(false) leaves arbitration to another plugin, which has to come first (CodeRight's
551
+ // desktop_shell::plugin); the order then guards nothing the shell does, so only the setup-time install is a finding.
552
+ if (parsed.some((p) => (p.file === f.file || shellFns.some((fn) => fn.file === p.file)) && /\.\s*single_instance\s*\(\s*false\s*\)/.test(p.skel))) continue;
553
+ for (const r of regs.filter((x) => !x.shell && !x.inSetup && x.at < s.at && x.fn === s.fn && !x.qa && !x.single)) {
554
+ const head = squash(/^[^(]*/.exec(r.argCode)[0]) || r.argCode.slice(0, 40);
555
+ out.push({
556
+ file: f.file,
557
+ subject: `before:${head}`,
558
+ problem: `${f.file}:${lineOf(f.text, r.at)} registers ${head} before the shell plugin (${f.file}:${lineOf(f.text, s.at)}): the shell's single-instance claim, hardening and window handling must run before any other plugin does work`,
559
+ fix: `move the shell plugin ahead of ${head} (only the shell's single_instance_plugin and QA-only plugins may come first)`,
560
+ });
561
+ }
562
+ }
563
+ }
564
+ return out;
565
+ });
566
+
567
+ // ---- R3 shell-handler --------------------------------------------------------------------------------------------------------
568
+ // A command-by-command map of the npm bridge: exported name -> the rk_shell_* literals in its body. createShell wraps every command, so
569
+ // its commands are the members an app reaches (shellMembers, shellUsage); any other export that maps to more than this many, or that the
570
+ // map does not know, counts as "needs at least one".
571
+ const FACADE_COMMANDS = 8;
572
+ /** The end of the statement that starts at `from` in a JS skeleton: the `;` (or, with no semicolon, the line break) that is not inside a bracket. */
573
+ function statementEnd(skel, from) {
574
+ let depth = 0;
575
+ for (let k = from; k < skel.length; k++) {
576
+ const c = skel[k];
577
+ if (c === "(" || c === "[" || c === "{") depth += 1;
578
+ else if (c === ")" || c === "]" || c === "}") { depth -= 1; if (depth < 0) return k; }
579
+ else if (depth === 0 && c === ";") return k;
580
+ else if (depth === 0 && c === "\n" && skel.slice(from, k).trim() && !/^\s*[.?:+\-*/|&,=(\[]/.test(skel.slice(k + 1, k + 40))) return k;
581
+ }
582
+ return skel.length;
583
+ }
584
+
585
+ /** Exported function and const names of @rightkit/tauri-shell's index.ts with the rk_shell_* literals in each body. */
586
+ export function shellExports(indexText) {
587
+ const map = new Map();
588
+ if (typeof indexText !== "string") return map;
589
+ const { code, skel } = parsedJs(indexText);
590
+ for (const m of skel.matchAll(/\bexport\s+(?:async\s+)?(?:function\s*\*?\s+([A-Za-z_$][\w$]*)|const\s+([A-Za-z_$][\w$]*))/g)) {
591
+ let end;
592
+ if (m[1]) {
593
+ let i = m.index + m[0].length;
594
+ if (skel[i] === "<") { for (let depth = 0; i < skel.length; i++) { if (skel[i] === "<") depth += 1; else if (skel[i] === ">" && skel[i - 1] !== "=" && --depth === 0) { i += 1; break; } } }
595
+ const params = skel.indexOf("(", i) >= 0 ? matchClose(skel, skel.indexOf("(", i)) : -1;
596
+ const open = params < 0 ? -1 : skel.indexOf("{", params);
597
+ end = open < 0 ? m.index + m[0].length : (matchClose(skel, open) + 1) || skel.length;
598
+ } else end = statementEnd(skel, m.index + m[0].length);
599
+ map.set(m[1] ?? m[2], [...new Set([...code.slice(m.index, end).matchAll(/['"`](rk_shell_[a-z0-9_]+)['"`]/g)].map((x) => x[1]))]);
600
+ }
601
+ return map;
602
+ }
603
+ /**
604
+ * createShell's API as member paths with the rk_shell_* literals in each member: "window.minimize" -> ["rk_shell_window"], "quit" ->
605
+ * ["rk_shell_quit"], "engine.status" -> ["rk_shell_engine_status"]. Read from the `const api = { .. }` of createShell in index.ts.
606
+ */
607
+ export function shellMembers(indexText) {
608
+ const members = new Map();
609
+ if (typeof indexText !== "string") return members;
610
+ const { code, skel } = parsedJs(indexText);
611
+ const fn = /\bfunction\s+createShell\b/.exec(skel);
612
+ const params = fn ? skel.indexOf("(", fn.index) : -1;
613
+ const open = params < 0 ? -1 : skel.indexOf("{", matchClose(skel, params));
614
+ const end = open < 0 ? -1 : matchClose(skel, open);
615
+ const api = end < 0 ? null : /\bconst\s+api\s*=\s*\{/.exec(skel.slice(open, end));
616
+ if (!api) return members;
617
+ // `invoke(SURFACE_COMMAND, ..)`: a command named through an exported string constant.
618
+ const consts = new Map([...code.matchAll(/\bexport\s+const\s+([A-Z][A-Z0-9_]*)\s*=\s*(['"`])(rk_shell_[a-z0-9_]+)\2/g)].map((m) => [m[1], m[3]]));
619
+ const commandsIn = (text) => [...new Set([...[...text.matchAll(/['"`](rk_shell_[a-z0-9_]+)['"`]/g)].map((x) => x[1]), ...(text.match(/\b[A-Z][A-Z0-9_]*\b/g) ?? []).filter((id) => consts.has(id)).map((id) => consts.get(id))])];
620
+ const walk = (objectOpen, prefix) => {
621
+ for (const [s, e] of callArgs(skel, objectOpen)) {
622
+ const entry = /^(?:async\s+)?([A-Za-z_$][\w$]*)\s*(:|\(|$)/.exec(skel.slice(s, e));
623
+ if (!entry) continue;
624
+ const path = prefix ? `${prefix}.${entry[1]}` : entry[1];
625
+ const value = entry[2] === ":" ? s + skel.slice(s, e).indexOf(":") + 1 : -1;
626
+ const valueAt = value < 0 ? -1 : value + skel.slice(value, e).search(/\S/);
627
+ if (valueAt > 0 && skel[valueAt] === "{") walk(valueAt, path);
628
+ else members.set(path, commandsIn(code.slice(s, e)));
629
+ }
630
+ };
631
+ walk(open + api.index + api[0].length - 1, "");
632
+ // shell.bridge() hands the host the commands its window, link and theme members reach, some of them through api.<member>.
633
+ const bridge = /\bconst\s+bridge\s*=/.exec(skel.slice(open, end));
634
+ if (bridge) {
635
+ const from = open + bridge.index;
636
+ const text = code.slice(from, from + statementEnd(skel.slice(from), 0));
637
+ const commands = new Set(commandsIn(text));
638
+ for (const ref of text.matchAll(/\bapi\.([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?/g)) for (const c of members.get([ref[1], ref[2]].filter(Boolean).join(".")) ?? members.get(ref[1]) ?? []) commands.add(c);
639
+ members.set("bridge", [...commands]);
640
+ }
641
+ return members;
642
+ }
643
+
644
+ /** The bridge map as the plain object the package ships: { schema, exports: { name: [command] }, members: { "window.close": [command] } }. */
645
+ export function bridgeMap(indexText) {
646
+ return { schema: 1, exports: Object.fromEntries(shellExports(indexText)), members: Object.fromEntries(shellMembers(indexText)) };
647
+ }
648
+ const readIndex = () => { try { return fs.readFileSync(TAURI_SHELL_INDEX, "utf8"); } catch { return null; } };
649
+ const mapOf = (value) => new Map(Object.entries(isObject(value) ? value : {}).filter(([, commands]) => Array.isArray(commands) && commands.every((c) => typeof c === "string")));
650
+ let shellBridgeCache;
651
+ const defaultShellBridge = () => {
652
+ if (shellBridgeCache) return shellBridgeCache;
653
+ try {
654
+ const shipped = JSON.parse(fs.readFileSync(BRIDGE_FILE, "utf8"));
655
+ if (isObject(shipped) && shipped.schema === 1) return (shellBridgeCache = { exports: mapOf(shipped.exports), members: mapOf(shipped.members) });
656
+ } catch { /* fall through to the source */ }
657
+ const index = readIndex();
658
+ return (shellBridgeCache = { exports: shellExports(index), members: shellMembers(index) });
659
+ };
660
+
661
+ /**
662
+ * What an app's renderer calls on the shells it makes with createShell(): the commands of each member it reaches (`shell.window.close()`),
663
+ * and where it uses a shell in a way that cannot be followed (passed on, returned, or a member the bridge map does not know).
664
+ */
665
+ function shellUsage(root, app, members) {
666
+ const out = { commands: new Map(), unresolved: null, bound: false };
667
+ const names = new Set();
668
+ const chainOf = (skel, at) => /^\s*\??\.\s*([A-Za-z_$][\w$]*)(?:\s*\??\.\s*([A-Za-z_$][\w$]*))?/.exec(skel.slice(at, at + 160));
669
+ const use = (f, offset, chain) => {
670
+ const where = { file: f.file, line: lineOf(f.text, offset) };
671
+ const segments = chain ? [chain[1], chain[2]].filter(Boolean) : [];
672
+ for (let k = segments.length; k > 0; k--) {
673
+ const commands = members.get(segments.slice(0, k).join("."));
674
+ if (commands) { for (const c of commands) if (!out.commands.has(c)) out.commands.set(c, where); return; }
675
+ }
676
+ out.unresolved ??= where;
677
+ };
678
+ for (const f of readRenderer(root, app, /\bcreateShell\b/)) {
679
+ for (const m of f.skel.matchAll(/\bcreateShell\s*\(/g)) {
680
+ const open = m.index + m[0].length - 1;
681
+ const close = matchClose(f.skel, open);
682
+ const chain = close < 0 ? null : chainOf(f.skel, close + 1);
683
+ if (chain) { use(f, m.index, chain); continue; }
684
+ const binding = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+?)?=\s*(?:await\s+)?$/.exec(f.skel.slice(Math.max(0, m.index - 120), m.index));
685
+ if (binding) { names.add(binding[1]); out.bound = true; } else out.unresolved ??= { file: f.file, line: lineOf(f.text, m.index) };
686
+ }
687
+ }
688
+ for (const name of names) {
689
+ const word = new RegExp(`(?<![.\\w$])${escapeRegExp(name)}(?![\\w$])`, "g");
690
+ for (const f of readRenderer(root, app, new RegExp(word.source))) {
691
+ // Import and re-export lists name a shell without using it.
692
+ const skipped = [...f.code.matchAll(/\bimport\b[^;'"`]*?\bfrom\s*(['"])[^'"\n]*\1|\bexport\s*\{[^}]*\}/g)].map((x) => [x.index, x.index + x[0].length]);
693
+ for (const m of f.skel.matchAll(word)) {
694
+ if (skipped.some(([s, e]) => m.index >= s && m.index < e)) continue;
695
+ const before = f.skel.slice(Math.max(0, m.index - 24), m.index);
696
+ const after = f.skel.slice(m.index + m[0].length, m.index + m[0].length + 4);
697
+ if (/\b(?:const|let|var|typeof)\s+$/.test(before) || (/[{,]\s*$/.test(before) && /^\s*:(?!:)/.test(after))) continue;
698
+ use(f, m.index, chainOf(f.skel, m.index + m[0].length));
699
+ }
700
+ }
701
+ }
702
+ return out;
703
+ }
704
+
705
+ const shellHandler = perApp(({ app, root, shellIndex }) => {
706
+ const bridge = shellIndex === undefined ? defaultShellBridge() : { exports: shellExports(shellIndex), members: shellMembers(shellIndex) };
707
+ const out = [];
708
+ const renderer = readRenderer(root, app, /rk_shell_|@rightkit\/tauri-shell/);
709
+ const needed = new Map();
710
+ let needsAny = null;
711
+ for (const f of renderer) {
712
+ for (const m of f.code.matchAll(/(['"`])(rk_shell_[a-z0-9_]+)\1/g)) if (!needed.has(m[2])) needed.set(m[2], { file: f.file, line: lineOf(f.text, m.index) });
713
+ for (const m of f.code.matchAll(/\bimport\s+(type\s+)?([^'"`;]*?)\s*from\s*(['"])@rightkit\/tauri-shell\3/g)) {
714
+ if (m[1]) continue;
715
+ const where = { file: f.file, line: lineOf(f.text, m.index) };
716
+ const names = /\{([^}]*)\}/.exec(m[2])?.[1].split(",").map((part) => part.trim()).filter((part) => part && !/^type\s/.test(part)).map((part) => part.split(/\s+as\s+/)[0].trim()) ?? [];
717
+ // A default or namespace import (`import shell from`, `import * as shell from`) reaches every command.
718
+ if (m[2].replace(/\{[^}]*\}/, "").replace(/,/g, "").trim()) needsAny ??= where;
719
+ for (const name of names) {
720
+ const commands = bridge.exports.get(name);
721
+ if (name === "createShell" && bridge.members.size > 0) {
722
+ // The shell client wraps every command; the commands this app needs are the members it reaches.
723
+ const usage = shellUsage(root, app, bridge.members);
724
+ for (const [c, at] of usage.commands) if (!needed.has(c)) needed.set(c, at);
725
+ if (usage.unresolved || (!usage.bound && usage.commands.size === 0)) needsAny ??= usage.unresolved ?? where;
726
+ } else if (commands === undefined || commands.length > FACADE_COMMANDS) needsAny ??= where;
727
+ else for (const c of commands) if (!needed.has(c)) needed.set(c, where);
728
+ }
729
+ }
730
+ }
731
+ if (needed.size === 0 && needsAny === null) return out;
732
+ const handler = handlerInfo(readRust(root, app, /handler\s*!/));
733
+ if (handler.macro) return out;
734
+ const site = handler.sites[0] ?? { file: app.rustFiles.find((f) => /(^|\/)(lib|main)\.rs$/.test(f)) ?? join(app.confDir, "src/lib.rs"), line: 1 };
735
+ for (const [command, where] of needed) {
736
+ if (handler.listed.has(command)) continue;
737
+ out.push({
738
+ file: site.file,
739
+ subject: `missing:${command}`,
740
+ problem: `${site.file}:${site.line} does not register ${command}, which the renderer invokes (${where.file}:${where.line}): the call is refused at run time`,
741
+ fix: `use rightkit_tauri_shell::handler![..] in the invoke handler, or add rightkit_tauri_shell::commands::${command} to tauri::generate_handler![..]`,
742
+ });
743
+ }
744
+ if (needsAny && needed.size === 0 && handler.listed.size === 0) {
745
+ out.push({
746
+ file: site.file,
747
+ subject: "missing:any",
748
+ problem: `${site.file}:${site.line} registers no rk_shell_* command, but the renderer imports @rightkit/tauri-shell (${needsAny.file}:${needsAny.line}), whose calls are rk_shell_* commands`,
749
+ fix: "use rightkit_tauri_shell::handler![..] in the invoke handler, or list the shell commands the renderer calls in tauri::generate_handler![..]",
750
+ });
751
+ }
752
+ return out;
753
+ });
754
+
755
+ // ---- R5 shell-grants ---------------------------------------------------------------------------------------------------------
756
+ const shellGrants = perApp(({ app, root }) => {
757
+ const out = [];
758
+ const handler = handlerInfo(readRust(root, app, /handler\s*!|rk_shell_window/));
759
+ if (!registers(handler, "rk_shell_window")) return out;
760
+ const sources = app.capabilityFiles.map((file) => ({ file, text: readText(root, file) })).map((c) => ({ ...c, ...(c.text === null ? { error: "" } : parseJson(c.text)) }));
761
+ const inline = [app.conf, ...app.platforms].filter((c) => !c.error).map((c) => ({ file: c.file, text: c.text, json: { capabilities: getPath(c.json, ["app", "security", "capabilities"]) } }));
762
+ for (const source of sources) {
763
+ if (source.error !== undefined) continue;
764
+ const list = Array.isArray(source.json) ? source.json : [source.json];
765
+ for (const cap of list) for (const id of permissionIds(cap)) out.push(grantFinding(source, id));
766
+ }
767
+ for (const source of inline) for (const cap of Array.isArray(source.json.capabilities) ? source.json.capabilities : []) for (const id of permissionIds(cap)) out.push(grantFinding(source, id));
768
+ return out;
769
+ });
770
+ // The window grants rk_shell_window mirrors (crates/rightkit-tauri-shell/src/commands.rs: minimize, maximize, unmaximize, toggle-maximize,
771
+ // fullscreen, hide, show, close). A grant with no shell verb (set-size, center, set-focus, ...) has no sanctioned replacement, and
772
+ // allow-start-dragging stays: Tauri's own `data-tauri-drag-region` handler (the shell title bar) calls plugin:window|start_dragging.
773
+ const SHELL_WINDOW_GRANTS = new Set(["minimize", "maximize", "unmaximize", "toggle-maximize", "set-fullscreen", "close", "hide", "show"].map((verb) => `core:window:allow-${verb}`));
774
+ const permissionIds = (cap) => (isObject(cap) && Array.isArray(cap.permissions) ? cap.permissions : []).map((p) => (typeof p === "string" ? p : isObject(p) ? p.identifier : undefined)).filter((id) => typeof id === "string" && SHELL_WINDOW_GRANTS.has(id));
775
+ const grantFinding = (source, id) => ({ file: source.file,
776
+ subject: id,
777
+ problem: `${source.file}:${lineOfPattern(source.text, new RegExp(`"${escapeRegExp(id)}"`))} grants ${id} while the shell's rk_shell_window command is registered: the shell's window command already does this and runs the shell's caller checks, which a webview-level grant bypasses`,
778
+ fix: `remove "${id}" from this capability and call the shell's window command instead (shell.window.* of @rightkit/tauri-shell)`,
779
+ });
780
+
781
+ // ---- R6 qa-window-label ------------------------------------------------------------------------------------------------------
782
+ const qaWindowLabel = perApp(({ app, root }) => {
783
+ const out = [];
784
+ const parsed = readRust(root, app);
785
+ const shellSites = parsed.filter((f) => SHELL_CONSTRUCT.test(f.skel));
786
+ if (shellSites.length === 0) return out;
787
+ const consts = stringConsts(parsed);
788
+ // S: the shell's main window label.
789
+ const labels = [];
790
+ let mainSite = null;
791
+ for (const f of parsed) for (const m of f.skel.matchAll(/\.\s*main_label\s*\(/g)) {
792
+ const [arg] = callArgs(f.skel, m.index + m[0].length - 1);
793
+ const label = arg ? labelOf(f.code.slice(arg[0], arg[1]), consts) : undefined;
794
+ labels.push(label);
795
+ mainSite ??= { file: f.file, line: lineOf(f.text, m.index), label };
796
+ }
797
+ // A label that is not a literal or a string const, or two different ones, cannot be compared: nothing is guessed.
798
+ if (labels.some((l) => l === undefined) || new Set(labels).size > 1) return out;
799
+ const shellLabel = labels[0] ?? "main";
800
+ const shellAt = mainSite ?? { file: shellSites[0].file, line: lineOf(shellSites[0].text, SHELL_CONSTRUCT.exec(shellSites[0].skel).index) };
801
+ // Control registrations and the label each binds.
802
+ for (const f of parsed.filter((p) => /\brightkit_qa\b/.test(p.skel))) {
803
+ const fns = rustFns(f.skel);
804
+ for (const m of f.skel.matchAll(/\bControl\s*(?:::\s*<[^>]*>\s*)?::\s*(?:new|default)\s*\(\s*\)/g)) {
805
+ const fn = innermostFn(fns, m.index);
806
+ // The statement around the registration: back to the previous `;` or block start, forward to its `;` or block.
807
+ let start = m.index;
808
+ for (let depth = 0; start > 0; start--) {
809
+ const c = f.skel[start - 1];
810
+ if (c === ")" || c === "]" || c === "}") depth += 1;
811
+ else if (c === "(" || c === "[") { if (depth > 0) depth -= 1; }
812
+ else if (c === "{") { if (depth === 0) break; depth -= 1; }
813
+ else if (c === ";" && depth === 0) break;
814
+ }
815
+ let end = f.skel.length;
816
+ for (let depth = 0, k = start; k < f.skel.length; k++) {
817
+ const c = f.skel[k];
818
+ if (c === "(" || c === "[") depth += 1;
819
+ else if (c === ")" || c === "]") depth = Math.max(0, depth - 1);
820
+ else if (depth === 0 && (c === ";" || c === "{")) { end = k; break; }
821
+ }
822
+ const statement = f.code.slice(start, end);
823
+ let label = /\.\s*window\s*\(\s*"([^"]*)"\s*\)/.exec(f.code.slice(m.index, end))?.[1];
824
+ const bound = /^\s*let\s+(?:mut\s+)?([A-Za-z_]\w*)\b/.exec(statement)?.[1];
825
+ if (label === undefined && bound && fn) label = new RegExp(`\\b${bound}\\s*\\.\\s*window\\s*\\(\\s*"([^"]*)"\\s*\\)`).exec(f.code.slice(m.index, fn.bodyEnd))?.[1];
826
+ const mismatch = label === undefined ? shellLabel !== "main" : label !== shellLabel;
827
+ if (!mismatch) continue;
828
+ const bind = label === undefined ? 'no .window(..), so it binds "main"' : `.window("${label}")`;
829
+ out.push({
830
+ file: f.file,
831
+ subject: "control-label",
832
+ problem: `${f.file}:${lineOf(f.text, m.index)} registers the QA Control with ${bind}, but ${shellAt.file}:${shellAt.line} sets the shell's main window to "${shellLabel}": every QA order that names the main window answers window_not_found`,
833
+ fix: `add .window("${shellLabel}") to the Control registration at ${f.file}:${lineOf(f.text, m.index)}`,
834
+ });
835
+ }
836
+ }
837
+ // The main label must name a window the app creates.
838
+ const created = new Set();
839
+ let unknownBuilder = false;
840
+ for (const target of app.targets()) for (const w of Array.isArray(getPath(target.merged, ["app", "windows"])) ? getPath(target.merged, ["app", "windows"]) : []) if (isObject(w) && w.create !== false) created.add(typeof w.label === "string" ? w.label : "main");
841
+ for (const f of parsed) for (const m of f.skel.matchAll(/\b(?:WebviewWindowBuilder|WindowBuilder)\s*::\s*new\s*\(|\bwindows\s*::\s*builder\s*\(/g)) {
842
+ const arg = callArgs(f.skel, m.index + m[0].length - 1)[1];
843
+ const label = arg ? labelOf(f.code.slice(arg[0], arg[1]), consts) : undefined;
844
+ if (label === undefined) unknownBuilder = true; else created.add(label);
845
+ }
846
+ if (created.size > 0 && !created.has(shellLabel) && !unknownBuilder) {
847
+ out.push({
848
+ file: shellAt.file,
849
+ subject: "main-label-not-created",
850
+ problem: `${shellAt.file}:${shellAt.line} makes "${shellLabel}" the shell's main window, but no window with that label is created (windows labelled ${[...created].map((l) => `"${l}"`).join(", ")}): show-on-ready, the close policy and QA find nothing`,
851
+ fix: `create a window labelled "${shellLabel}" (tauri.conf.json app.windows or rightkit_tauri_shell::windows::builder), or point main_label at one of the created labels`,
852
+ });
853
+ }
854
+ return out;
855
+ });
856
+
857
+ // ---- R7 devtools-declaration -------------------------------------------------------------------------------------------------
858
+ const stripTomlComments = (text) => text.replace(/^\s*#.*$/gm, "").replace(/(\s)#[^"'\n]*$/gm, "$1");
859
+ const quotedStrings = (text) => [...text.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
860
+ function bracketText(text, open, o, c) { let depth = 0; for (let i = open; i < text.length; i++) { if (text[i] === o) depth += 1; else if (text[i] === c && --depth === 0) return text.slice(open + 1, i); } return text.slice(open + 1); }
861
+
862
+ /**
863
+ * The shell crate whose `content-devtools` feature a Cargo manifest selects, directly in the dependency or through an app feature
864
+ * (`content-devtools = ["rightkit-tauri-shell/content-devtools"]`), or null. The same test as selectedContentDevtoolsCrate in devtools-policy.mjs.
865
+ */
866
+ export function contentDevtoolsCrate(manifestText) {
867
+ const text = stripTomlComments(String(manifestText ?? ""));
868
+ const sections = [];
869
+ const HEADER = /^\s*\[([^\]\n]+)\]\s*$/gm;
870
+ const marks = [...text.matchAll(HEADER)];
871
+ marks.forEach((m, i) => sections.push({ name: m[1].trim(), body: text.slice(m.index + m[0].length, marks[i + 1]?.index ?? text.length) }));
872
+ const features = new Map();
873
+ const dependencies = new Map();
874
+ const info = (spec) => { const list = /features\s*=\s*\[/.exec(spec); return { features: list ? quotedStrings(bracketText(spec, list.index + list[0].length - 1, "[", "]")) : [] }; };
875
+ for (const { name, body } of sections) {
876
+ if (name === "features") {
877
+ for (const m of body.matchAll(/^\s*([A-Za-z0-9_\-"']+)\s*=\s*\[/gm)) features.set(m[1].replace(/^["']|["']$/g, ""), quotedStrings(bracketText(body, m.index + m[0].length - 1, "[", "]")));
878
+ } else if (/^(?:target\.[^\n]+\.)?dependencies$/.test(name)) {
879
+ for (const m of body.matchAll(/^\s*([A-Za-z0-9_\-"']+)\s*=\s*(\{)?/gm)) dependencies.set(m[1].replace(/^["']|["']$/g, ""), info(m[2] ? bracketText(body, m.index + m[0].length - 1, "{", "}") : ""));
880
+ } else {
881
+ const table = /^(?:target\.[^\n]+\.)?dependencies\.([A-Za-z0-9_\-]+)$/.exec(name);
882
+ if (table) dependencies.set(table[1], info(body));
883
+ }
884
+ }
885
+ for (const crate of SHELL_CRATES) {
886
+ const shell = dependencies.get(crate);
887
+ if (!shell) continue;
888
+ if (shell.features.includes("content-devtools")) return crate;
889
+ for (const items of features.values()) if (items.some((item) => item === `${crate}/content-devtools` || item === `${crate}?/content-devtools`)) return crate;
890
+ }
891
+ return null;
892
+ }
893
+
894
+ function readAllowList() {
895
+ try { const parsed = JSON.parse(fs.readFileSync(POLICY_FILE, "utf8")); return Array.isArray(parsed.contentOnlyApps) ? parsed.contentOnlyApps.filter((a) => typeof a === "string") : []; } catch { return []; }
896
+ }
897
+
898
+ const devtoolsDeclaration = perApp(({ app, root, contentOnlyApps }) => {
899
+ const allowed = contentOnlyApps ?? readAllowList();
900
+ const out = [];
901
+ const release = app.releaseConfig;
902
+ const releaseText = release ? readText(root, release) : null;
903
+ let declared = false;
904
+ let appId;
905
+ if (releaseText !== null) {
906
+ if (release.endsWith(".json")) { const cfg = parseJson(releaseText).json; declared = cfg?.devtools === "content-only"; appId = typeof cfg?.app === "string" ? cfg.app : undefined; }
907
+ else { const code = parsedJs(releaseText).code; declared = /\bdevtools\s*:\s*(["'`])content-only\1/.test(code); appId = /(?:^|[\s,{])["']?app["']?\s*:\s*(["'`])([^"'`]+)\1/.exec(code)?.[2]; }
908
+ }
909
+ const cargoText = app.cargoToml ? readText(root, app.cargoToml) : null;
910
+ const feature = cargoText !== null && contentDevtoolsCrate(cargoText) !== null;
911
+ const parsed = readRust(root, app);
912
+ const consts = new Map();
913
+ for (const { code } of parsed) for (const m of code.matchAll(/\b(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:[^=]*=\s*([^;]*);/g)) consts.set(m[1], m[2]);
914
+ let runtime = false;
915
+ for (const f of parsed) for (const m of f.skel.matchAll(/\.\s*devtools_policy\s*\(/g)) {
916
+ const [arg] = callArgs(f.skel, m.index + m[0].length - 1);
917
+ const argCode = arg ? f.code.slice(arg[0], arg[1]) : "";
918
+ if (/\bContentOnly\b/.test(argCode) || (/^[A-Z][A-Z0-9_]*$/.test(argCode) && /\bContentOnly\b/.test(consts.get(argCode) ?? ""))) runtime = true;
919
+ }
920
+ if (!declared && !feature && !runtime) return out;
921
+ const buildRs = app.buildRs ? readText(root, app.buildRs) : null;
922
+ const buildCode = buildRs === null ? "" : parsedRust(buildRs).code;
923
+ const acl = /\bAppManifest\b/.test(buildCode) && /\.commands\s*\(/.test(buildCode);
924
+ const shellFile = parsed.find((f) => SHELL_CONSTRUCT.test(f.skel))?.file ?? app.rustFiles.find((f) => /(^|\/)(lib|main)\.rs$/.test(f)) ?? join(app.confDir, "src/lib.rs");
925
+ const have = [declared && "the release config declares devtools: \"content-only\"", feature && "Cargo.toml selects content-devtools", runtime && "the Rust calls .devtools_policy(ContentOnly)"].filter(Boolean).join(", ");
926
+ const add = (subject, file, why, fix) => out.push({ file, subject, problem: `${file}:1 ${why} (${have})`, fix });
927
+ const releaseFile = release ?? join(app.appRoot, "right-release.config.json");
928
+ if (!declared) add("release-config", releaseFile, `${release ? "the release config does not declare" : "there is no release config declaring"} devtools: "content-only", and the pre-build DevTools gate refuses a build without it`, `add devtools: "content-only" to ${releaseFile}`);
929
+ if (appId === undefined && releaseText !== null) add("allow-list", release, "the release config names no app, so it cannot be matched against the content-only allow-list", `add app: "<id>" to ${release} and list that id in rightkit's ${ALLOW_LIST_FILE} contentOnlyApps`);
930
+ else if (appId !== undefined && !allowed.includes(appId)) add("allow-list", ALLOW_LIST_FILE, `app "${appId}" is not in rightkit's ${ALLOW_LIST_FILE} contentOnlyApps (${allowed.join(", ") || "empty"}): only an app on that list may declare content-only DevTools`, `list "${appId}" in ${ALLOW_LIST_FILE} contentOnlyApps on the owner's instruction, or drop the declaration, the cargo feature and the runtime policy`);
931
+ if (!feature) add("cargo-feature", app.cargoToml ?? join(app.confDir, "Cargo.toml"), "Cargo.toml does not enable the rightkit-tauri-shell content-devtools feature, so DevToolsPolicy::ContentOnly cannot be selected", 'add "content-devtools" to the rightkit-tauri-shell dependency features');
932
+ if (!acl) add("acl-manifest", app.buildRs ?? join(app.confDir, "build.rs"), "build.rs declares no application ACL manifest (tauri_build::AppManifest with .commands(..)): without one, local content can call every app command", "declare tauri_build::AppManifest::new().commands(..) in build.rs and grant each command in a capability");
933
+ if (!runtime) add("runtime-policy", shellFile, "the Rust never calls .devtools_policy(DevToolsPolicy::ContentOnly) on the shell builder, so the webview keeps the default DevTools denial", "call .devtools_policy(DevToolsPolicy::ContentOnly) on Shell::builder(..)");
934
+ return out;
935
+ });
936
+
937
+ // ---- R8 shell-lifecycle-external ---------------------------------------------------------------------------------------------
938
+ const shellLifecycleExternal = perApp(({ app, root }) => {
939
+ const out = [];
940
+ const parsed = readRust(root, app, /lifecycle_only|lifecycle_plugin|WebviewUrl/);
941
+ const external = parsed.find((f) => /\bWebviewUrl\s*::\s*External\b/.test(f.skel));
942
+ if (!external) return out;
943
+ const at = /\bWebviewUrl\s*::\s*External\b/.exec(external.skel);
944
+ for (const f of parsed) {
945
+ const call = /(?<!\bfn\s+)\blifecycle_(?:only|plugin)\s*\(/.exec(f.skel);
946
+ if (!call) continue;
947
+ out.push({
948
+ file: f.file,
949
+ subject: "lifecycle-only+external",
950
+ problem: `${f.file}:${lineOf(f.text, call.index)} installs the shell with ${call[0].replace(/\s*\($/, "")}() while the app opens an external-URL window (${external.file}:${lineOf(external.text, at.index)}): lifecycle_only drops the JS hardening and the navigation policy, so that window navigates unfiltered`,
951
+ fix: "use ShellPlugin::builder().shell(..).host_windows(): it keeps hardening and the navigation policy while the app owns its windows",
952
+ });
953
+ }
954
+ return out;
955
+ });
956
+
957
+ // ---- R9 legal-receipt-native -------------------------------------------------------------------------------------------------
958
+ const NATIVE_PORT = /\bcreateNativeLegalReceiptPort\s*\(/;
959
+ const JS_KEYWORDS = new Set(["const", "let", "var", "function", "return", "async", "await", "new", "typeof", "void", "null", "undefined", "true", "false", "this", "if", "else", "import", "export", "from", "as", "type", "interface", "default"]);
960
+
961
+ /** The local name `name` is imported under from `pkg` in `code` (a type-only import does not count), or null. */
962
+ function importedAs(code, pkg, name) {
963
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s*\\{([^}]*)\\}\\s*from\\s*(['"])${escapeRegExp(pkg)}\\2`, "g"))) {
964
+ for (const part of m[1].split(",").map((x) => x.trim())) {
965
+ const [from, to] = part.split(/\s+as\s+/);
966
+ if (from === name) return to ?? from;
967
+ }
968
+ }
969
+ return null;
970
+ }
971
+
972
+ /** Bindings of the renderer: Map name -> [{ file, line, text }] (a const/let/var initialiser, or a function with its body). */
973
+ function definitionIndex(renderer) {
974
+ const index = new Map();
975
+ const add = (name, f, at, text) => { if (!index.has(name)) index.set(name, []); index.get(name).push({ file: f.file, line: lineOf(f.text, at), text }); };
976
+ for (const f of renderer) {
977
+ for (const m of f.skel.matchAll(/\b(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)\s*[(<]/g)) {
978
+ const params = f.skel.indexOf("(", m.index);
979
+ const open = params < 0 ? -1 : f.skel.indexOf("{", matchClose(f.skel, params));
980
+ const close = open < 0 ? -1 : matchClose(f.skel, open);
981
+ add(m[1], f, m.index, f.code.slice(m.index, close < 0 ? m.index + 400 : close + 1));
982
+ }
983
+ for (const m of f.skel.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+?)?=(?!=)/g)) add(m[1], f, m.index, f.code.slice(m.index + m[0].length, statementEnd(f.skel, m.index + m[0].length)));
984
+ }
985
+ return index;
986
+ }
987
+
988
+ /** Whether `text` is createNativeLegalReceiptPort(..) or built from a binding that is; `walked` collects the bindings it followed. */
989
+ function nativeExpression(text, index, walked = [], seen = new Set(), depth = 0) {
990
+ if (NATIVE_PORT.test(text)) return true;
991
+ if (depth >= 4) return false;
992
+ for (const id of new Set(text.match(/[A-Za-z_$][\w$]*/g) ?? [])) {
993
+ if (JS_KEYWORDS.has(id) || seen.has(id) || !index.has(id)) continue;
994
+ seen.add(id);
995
+ for (const def of index.get(id)) { walked.push({ id, ...def }); if (nativeExpression(def.text, index, walked, seen, depth + 1)) return true; }
996
+ }
997
+ return false;
998
+ }
999
+
1000
+ const legalReceiptNative = perApp(({ app, root }) => {
1001
+ const out = [];
1002
+ const renderer = readRenderer(root, app, /@rightkit\/(?:onboarding|legal-ui)|createNativeLegalReceiptPort/);
1003
+ const anyNative = renderer.some((f) => NATIVE_PORT.test(f.code));
1004
+ let index = null;
1005
+ for (const f of renderer.filter((r) => /\.[jt]sx$/.test(r.file))) {
1006
+ const onboarding = importedAs(f.code, "@rightkit/onboarding", "Onboarding");
1007
+ for (const m of onboarding ? f.skel.matchAll(new RegExp(`<${escapeRegExp(onboarding)}(?=[\\s/>])`, "g")) : []) {
1008
+ const tag = readJsxTag(f.skel, f.code, m.index);
1009
+ if (!tag || tag.spread) continue;
1010
+ // legalMode="none" and "notice" have no acceptance to receipt; any other computed value cannot be judged from the file.
1011
+ const mode = tag.attrs.get("legalMode");
1012
+ if (mode) {
1013
+ const value = mode.text.replace(/^(["'`])(.*)\1$/, "$2");
1014
+ if (value === "none" || value === "notice" || (value !== "gate" && mode.kind === "expr")) continue;
1015
+ }
1016
+ const receipt = tag.attrs.get("legalReceipt");
1017
+ const at = `${f.file}:${lineOf(f.text, m.index)}`;
1018
+ if (!receipt) {
1019
+ out.push({
1020
+ file: f.file,
1021
+ subject: "settings-copy",
1022
+ problem: `${at} <Onboarding> has no legalReceipt: the acceptance is stamped with the webview clock and kept in settings, with no native receipt`,
1023
+ fix: 'pass legalReceipt={createNativeLegalReceiptPort({ invoke })} (@rightkit/onboarding) and install the shell\'s legal receipt service, or pass legalMode="none" when the app has no legal gate',
1024
+ });
1025
+ continue;
1026
+ }
1027
+ index ??= definitionIndex(readRenderer(root, app));
1028
+ const walked = [];
1029
+ if (nativeExpression(receipt.text, index, walked)) continue;
1030
+ if (/^[A-Za-z_$][\w$.]*$/.test(receipt.text) && !index.has(receipt.text.split(".")[0])) continue; // a name this app does not define: not judgeable
1031
+ const port = walked.find((d) => /\bread\b/.test(d.text) && /\b(?:write|create)\b/.test(d.text)) ?? walked[0];
1032
+ const where = port ? `${port.file}:${port.line}` : `${f.file}:${lineOf(f.text, receipt.offset)}`;
1033
+ out.push({
1034
+ file: f.file,
1035
+ subject: "hand-written-port",
1036
+ problem: `${at} <Onboarding> takes legalReceipt={${receipt.text.replace(/\s+/g, " ").slice(0, 60)}}, a hand-written port (${where}) that is not createNativeLegalReceiptPort: acceptedAt comes from the webview clock and the app stores the receipt itself`,
1037
+ fix: `replace the port at ${where} with createNativeLegalReceiptPort({ invoke }) (@rightkit/onboarding); the native service stamps acceptedAt and stores the receipt`,
1038
+ });
1039
+ }
1040
+ const gate = anyNative ? null : importedAs(f.code, "@rightkit/legal-ui", "LegalGate");
1041
+ for (const m of gate ? f.skel.matchAll(new RegExp(`<${escapeRegExp(gate)}(?=[\\s/>])`, "g")) : []) {
1042
+ const agree = readJsxTag(f.skel, f.code, m.index)?.attrs.get("onAgree");
1043
+ if (!agree) continue;
1044
+ // The handler's text: inline, or the function it names in this file.
1045
+ const name = /^[A-Za-z_$][\w$]*$/.test(agree.text) ? escapeRegExp(agree.text) : null;
1046
+ const named = name ? new RegExp(`\\b(?:function\\s+${name}\\b|(?:const|let)\\s+${name}\\b)[\\s\\S]{0,1500}`).exec(f.code)?.[0] ?? "" : "";
1047
+ if (!/\bnew\s+Date\s*\(|\bDate\s*\.\s*now\s*\(|toISOString|accepted_?at|acceptedAt/i.test(`${agree.text}\n${named}`)) continue;
1048
+ out.push({
1049
+ file: f.file,
1050
+ subject: "legal-gate-without-native-receipt",
1051
+ problem: `${f.file}:${lineOf(f.text, m.index)} <LegalGate> records the acceptance in the app's own store with a webview-clock timestamp, and no createNativeLegalReceiptPort exists anywhere in this app's renderer`,
1052
+ fix: "build the receipt with createNativeLegalReceiptPort({ invoke }) (@rightkit/onboarding) and create it from onAgree: the native service stamps acceptedAt and stores it",
1053
+ });
1054
+ }
1055
+ }
1056
+ return out;
1057
+ });
1058
+
1059
+ export const SHELL_CONTRACT_RULES = Object.freeze([
1060
+ { id: "shell-window-hidden", gap: "SC-R1", run: shellWindowHidden },
1061
+ { id: "shell-plugin-order", gap: "SC-R2", run: shellPluginOrder },
1062
+ { id: "shell-handler", gap: "SC-R3", run: shellHandler },
1063
+ { id: "shell-csp", gap: "SC-R4", run: shellCsp },
1064
+ { id: "shell-grants", gap: "SC-R5", run: shellGrants },
1065
+ { id: "qa-window-label", gap: "SC-R6", run: qaWindowLabel },
1066
+ { id: "devtools-declaration", gap: "SC-R7", run: devtoolsDeclaration },
1067
+ { id: "shell-lifecycle-external", gap: "SC-R8", run: shellLifecycleExternal },
1068
+ { id: "legal-receipt-native", gap: "SC-R9", run: legalReceiptNative },
1069
+ ]);