@rightkit/release 0.6.6 → 0.6.8

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 (46) hide show
  1. package/README.md +132 -10
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +524 -345
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/door.mjs +23 -0
  26. package/gate-executor.mjs +30 -0
  27. package/gate-stages.mjs +110 -0
  28. package/github-updater-release.mjs +48 -8
  29. package/init-command.mjs +41 -0
  30. package/legion-semantic-contracts.mjs +428 -0
  31. package/ownership-class-rules.mjs +277 -0
  32. package/ownership-key-authority.mjs +3 -1
  33. package/ownership-mandatory-set.mjs +3 -2
  34. package/package.json +11 -3
  35. package/publish-cargo.mjs +5 -2
  36. package/publish-update.mjs +14 -11
  37. package/release-config.mjs +51 -6
  38. package/rightkit-versions.json +172 -167
  39. package/shell-contract-bridge.json +251 -0
  40. package/shell-contract.mjs +1069 -0
  41. package/sign-updater.mjs +24 -4
  42. package/source-closure-rules.mjs +204 -0
  43. package/stage-sidecar-command.mjs +2 -1
  44. package/stage-sidecar.mjs +437 -114
  45. package/suite-api.generated.mjs +96 -6
  46. package/suite-trust-roots.mjs +198 -0
@@ -0,0 +1,422 @@
1
+ // Consumer input contracts: three app-check rules (CONSUMER_INPUT_RULES, appended to the app check) for inputs a committed tree
2
+ // declares but a later step cannot reach. Each reads committed files only (workflow YAML through the strict workflow-yaml.mjs
3
+ // parser, JSON and Markdown as data), never runs repository code, never touches the network, and never throws on odd input.
4
+ //
5
+ // checkout-step-input CA-07 a workflow step names a tracked repository path (a script, a source file, a working-directory)
6
+ // that the job's own sparse checkout (cone mode) does not materialize. SwiftRight factory-pairs.yml at
7
+ // 80c9be45d compiled factory/native/windows/wgc-record/wgc-record.cpp on a windows job whose sparse
8
+ // checkout listed packages, probes/sr0-s/fresh-runtime, factory/pipeline and factory/tools only; the
9
+ // recorder source was absent on the runner until 2a67fc182 added its directory to the cone.
10
+ // source-relation CA-09 a value in one committed file names a row of another committed file that does not exist. The
11
+ // relations are data: SOURCE_RELATIONS below (the ones RightKit knows) plus an app's own
12
+ // .rightkit/source-relations.json of the same shape. SwiftRight commit 7655d1d87 rewrote
13
+ // docs/sr1-status.md as one line with literal "\n" escapes, so its status table has no rows and
14
+ // factory/tools/proven-controls/hand-entries.json's statusDocBlock "I2" names nothing (the
15
+ // proven-controls drift check failed on every push after it, e.g. run 39701428805).
16
+ // checkout-longpaths CA-11 a job on a GitHub-hosted Windows runner checks out tracked paths that exceed the Win32 path limit
17
+ // under the runner workspace (D:\a\<repo>\<repo>\) without enabling Git long paths before the checkout.
18
+ // SwiftRight run 38363145934 (windows-2025, plain actions/checkout): "Filename too long", 829 tracked paths
19
+ // over the limit. Long paths are legitimate: a job passes when a step before its checkout provably runs
20
+ // `git config --system|--global core.longpaths true` (or GIT_CONFIG_COUNT covers a KEY_n/VALUE_n pair), or when its
21
+ // sparse checkout leaves every long path out, or when it runs on a non-Windows runner.
22
+ //
23
+ // Known limits, deliberate for an offline check: a non-cone sparse checkout (sparse-checkout-cone-mode: false) is not modelled, a
24
+ // job with more than one checkout of its own repository is skipped, a path assembled from variables is matched on its literal
25
+ // text only, and a self-hosted Windows runner (label self-hosted) is skipped because its system Git configuration is not in the
26
+ // tree. The workspace name is the GitHub repository name from the checked repository's remote origin (git config, no network),
27
+ // never the checker's own directory; with no GitHub origin, paths that fail under any name fail and the rest are UNSUPPORTED.
28
+ // A pre-checkout step counts only when it provably runs and its whole script is the git config line; any other step or
29
+ // environment that touches core.longpaths is reported as UNSUPPORTED, never passed. A workflow the strict parser refuses is a finding only
30
+ // when it mentions a sparse checkout or a Windows runner (the two things these rules read); any other is left alone.
31
+ import { spawnSync } from "./background-process.mjs";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ import { parseYaml } from "./workflow-yaml.mjs";
35
+
36
+ const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8").replace(/^\uFEFF/, ""); } catch { return null; } };
37
+ const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
38
+ const CHECKOUT = /^actions\/checkout@/;
39
+
40
+ // ---- workflow model ------------------------------------------------------------------------------------------------------------
41
+
42
+ const asList = (value) => (Array.isArray(value) ? value : value === undefined || value === null || value === "" ? [] : [value]);
43
+ const lines = (value) => (typeof value === "string" ? value.split("\n").map((l) => l.trim()).filter(Boolean) : asList(value).map(String));
44
+
45
+ /** The parsed workflows of the tree: [{ file, doc }] and [{ file, error }] for one the strict parser refuses. */
46
+ function workflows(root, files) {
47
+ return files.filter((f) => WORKFLOW.test(f)).sort().map((file) => {
48
+ const text = readText(root, file);
49
+ if (text === null) return { file, error: "unreadable", text: "" };
50
+ try { return { file, doc: parseYaml(text), text }; } catch (error) { return { file, error: error.message, text }; }
51
+ });
52
+ }
53
+
54
+ /** The single self checkout of a job: { index, step, path, sparse: string[] | null, cone } or null when there is none or more
55
+ * than one (a checkout of another repository, `with.repository`, is not the job's own tree). */
56
+ function selfCheckout(job) {
57
+ const steps = Array.isArray(job?.steps) ? job.steps : [];
58
+ const found = [];
59
+ steps.forEach((step, index) => {
60
+ if (!step || typeof step !== "object" || typeof step.uses !== "string" || !CHECKOUT.test(step.uses)) return;
61
+ const w = step.with && typeof step.with === "object" ? step.with : {};
62
+ if (w.repository && !/^\$\{\{\s*github\.repository\s*\}\}$/.test(String(w.repository))) return;
63
+ const sparse = w["sparse-checkout"] === undefined ? null : lines(w["sparse-checkout"]).map((d) => d.replace(/^\/+|\/+$/g, ""));
64
+ found.push({ index, step, path: typeof w.path === "string" ? w.path.replace(/^\.\/|\/+$/g, "") : "", sparse, cone: String(w["sparse-checkout-cone-mode"] ?? "true") !== "false" });
65
+ });
66
+ return found.length === 1 ? found[0] : null;
67
+ }
68
+
69
+ /** The runner labels a job can run on: its runs-on labels, with `${{ matrix.<key> }}` expanded from strategy.matrix. */
70
+ function runnerLabels(job) {
71
+ const matrix = job?.strategy?.matrix && typeof job.strategy.matrix === "object" ? job.strategy.matrix : {};
72
+ const expand = (label) => {
73
+ const m = /^\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}$/.exec(String(label).trim());
74
+ if (!m) return [String(label)];
75
+ const values = asList(matrix[m[1]]).map(String);
76
+ for (const entry of asList(matrix.include)) if (entry && typeof entry === "object" && entry[m[1]] !== undefined) values.push(String(entry[m[1]]));
77
+ return values.length ? values : [String(label)];
78
+ };
79
+ const runsOn = job?.["runs-on"];
80
+ if (runsOn && typeof runsOn === "object" && !Array.isArray(runsOn)) return asList(runsOn.labels).flatMap(expand); // { group, labels }
81
+ return asList(runsOn).flatMap(expand);
82
+ }
83
+
84
+ // ---- cone-mode sparse checkout -------------------------------------------------------------------------------------------------
85
+
86
+ /** Whether a cone-mode sparse checkout of directories `dirs` materializes the tracked file `file`: a root file, a file under a
87
+ * listed directory, or a file directly in a parent directory of one (git's cone rules). */
88
+ export function coneIncludesFile(dirs, file) {
89
+ const parent = path.posix.dirname(file);
90
+ if (parent === ".") return true;
91
+ return dirs.some((d) => file.startsWith(`${d}/`) || d === parent || d.startsWith(`${parent}/`));
92
+ }
93
+ /** Whether the cone materializes anything of the tracked directory `dir` that a step would read as that directory. */
94
+ const coneIncludesDir = (dirs, dir) => dirs.some((d) => dir === d || dir.startsWith(`${d}/`) || d.startsWith(`${dir}/`));
95
+
96
+ // ---- CA-07 checkout-step-input ---------------------------------------------------------------------------------------------------
97
+
98
+ const WORKSPACE_PREFIX = /^(?:\$\{\{\s*github\.workspace\s*\}\}|\$\{?GITHUB_WORKSPACE\}?|\$env:GITHUB_WORKSPACE|%GITHUB_WORKSPACE%)[\\/]+/i;
99
+ const PATH_TOKEN = /[A-Za-z0-9_.@${}:%-][A-Za-z0-9_.@${}:%\\/-]*[\\/][A-Za-z0-9_.@\\/-]*[A-Za-z0-9_-]/g;
100
+ const SCRIPT_COMMENT = /^\s*(?:#|REM\b|::)/i;
101
+ // git reads its object database, not the checkout: `git diff -- dir`, `git show rev:path` name no file of the working tree.
102
+ const GIT_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:if\s+!?\s*)?git\s/;
103
+
104
+ /** The literal repository paths a step's script names: quoted strings and bare words that contain a separator, with a
105
+ * workspace prefix or a leading ./ removed and backslashes read as separators. Comment lines, git command lines and words with a
106
+ * glob character are skipped. */
107
+ export function scriptPaths(script) {
108
+ const out = new Set();
109
+ for (const line of String(script ?? "").split("\n")) {
110
+ if (SCRIPT_COMMENT.test(line) || GIT_COMMAND.test(line)) continue;
111
+ const pieces = [...line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
112
+ for (const piece of pieces) {
113
+ if (/[*?]/.test(piece)) continue; // a glob or a case pattern names no one path
114
+ for (const raw of piece.match(PATH_TOKEN) ?? []) {
115
+ const token = raw.trim().replace(WORKSPACE_PREFIX, "").replace(/\\+/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
116
+ if (!token || token.startsWith("/") || token.startsWith("../") || /[${}%]|\s/.test(token)) continue;
117
+ out.add(token);
118
+ }
119
+ }
120
+ }
121
+ return [...out];
122
+ }
123
+
124
+ function checkoutStepInput({ root, files }) {
125
+ const out = [];
126
+ const tracked = new Set(files);
127
+ const trackedDirs = new Set();
128
+ for (const f of files) for (let d = path.posix.dirname(f); d !== "."; d = path.posix.dirname(d)) trackedDirs.add(d);
129
+ for (const wf of workflows(root, files)) {
130
+ if (wf.error) {
131
+ if (/sparse-checkout\s*:/.test(wf.text)) out.push({ file: wf.file, subject: "parse", problem: `${wf.file} uses a sparse checkout but cannot be read by the strict workflow parser (${wf.error}), so its step inputs cannot be checked`, fix: "write the workflow in the YAML subset packages/release/workflow-yaml.mjs reads" });
132
+ continue;
133
+ }
134
+ for (const [jobId, job] of Object.entries(wf.doc?.jobs ?? {})) {
135
+ const checkout = selfCheckout(job);
136
+ if (!checkout || checkout.sparse === null || !checkout.cone || checkout.sparse.length === 0) continue;
137
+ const base = checkout.path;
138
+ const relative = (p) => (base && p.startsWith(`${base}/`) ? p.slice(base.length + 1) : base ? null : p);
139
+ const missing = new Map(); // repo path -> step name
140
+ job.steps.slice(checkout.index + 1).forEach((step, i) => {
141
+ if (!step || typeof step !== "object") return;
142
+ const name = String(step.name ?? step.uses ?? `step ${checkout.index + 2 + i}`);
143
+ const candidates = [];
144
+ const wd = step["working-directory"] ?? job.defaults?.run?.["working-directory"];
145
+ if (typeof wd === "string" && step.run !== undefined) candidates.push(wd.replace(WORKSPACE_PREFIX, "").replace(/\\+/g, "/").replace(/^\.\/|\/+$/g, ""));
146
+ if (typeof step.run === "string") candidates.push(...scriptPaths(step.run));
147
+ for (const candidate of candidates) {
148
+ const rel = relative(candidate);
149
+ if (rel === null || rel === "" || rel === ".") continue;
150
+ const isFile = tracked.has(rel);
151
+ const isDir = !isFile && trackedDirs.has(rel);
152
+ if (!isFile && !isDir) continue;
153
+ const present = isFile ? coneIncludesFile(checkout.sparse, rel) : coneIncludesDir(checkout.sparse, rel);
154
+ if (!present && !missing.has(rel)) missing.set(rel, name);
155
+ }
156
+ });
157
+ for (const [rel, stepName] of missing) {
158
+ out.push({
159
+ file: wf.file, subject: `${jobId}:${rel}`,
160
+ problem: `${wf.file} job ${jobId}: step "${stepName}" reads ${rel}, which its sparse checkout (${checkout.sparse.join(", ")}) does not materialize`,
161
+ fix: `add ${path.posix.dirname(rel) === "." ? rel : tracked.has(rel) ? path.posix.dirname(rel) : rel} to the job's sparse-checkout list, or stop reading it`,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // ---- CA-11 checkout-longpaths ----------------------------------------------------------------------------------------------------
170
+
171
+ // Win32 without long-path support: a full file path is at most MAX_PATH - 1 = 259 characters, and CreateDirectoryW refuses a
172
+ // directory path of 248 (MAX_PATH - 12) or more. Git for Windows applies these unless core.longpaths is true.
173
+ export const WIN_MAX_FILE = 259;
174
+ export const WIN_MAX_DIR = 247;
175
+ const HOSTED_WINDOWS = /^windows-(?:latest|\d{4}|11-arm|\d+[a-z0-9-]*)$/i;
176
+ /** A git boolean: true, false, or "unknown" for anything git would not read as one plainly. */
177
+ const gitBool = (value) => (/^(?:true|1|yes|on)$/i.test(String(value).trim()) ? true : /^(?:false|0|no|off)$/i.test(String(value).trim()) ? false : "unknown");
178
+ // The one script line that sets long paths: the whole line is the command (no echo, no condition, no substitution).
179
+ const SET_LINE = /^git\s+config\s+(--system|--global)\s+(?:--bool\s+)?(?:--(?:add|replace-all)\s+)?core\.longpaths\s+([A-Za-z0-9]+)\s*;?$/i;
180
+ // Lines a step may carry beside it without changing whether it runs: blank lines, comments, strict-mode preambles.
181
+ const NEUTRAL_LINE = /^(?:$|#|set\s+-[euxo]+(?:\s+pipefail)?\s*$|\$ErrorActionPreference\s*=\s*['"]Stop['"]\s*;?$)/i;
182
+ const STANDARD_SHELLS = new Set(["bash", "pwsh", "powershell", "sh", "cmd"]);
183
+ const MENTIONS_LONGPATHS = /core\.longpaths/i;
184
+
185
+ /** The core.longpaths value git takes from the config environment (GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n):
186
+ * null when the environment sets none, true or false for the LAST pair below COUNT that names it (git applies pairs in index
187
+ * order and the last value wins), or "unknown" for an expression, a COUNT git would refuse, or a value that is not a plain
188
+ * boolean, or "error" when a pair below COUNT lacks its KEY_n or VALUE_n (git refuses to run then). Pair n counts only when
189
+ * COUNT is a positive integer above n; an empty COUNT is 0. */
190
+ export function envLongpaths(env) {
191
+ if (!env || typeof env !== "object") return null;
192
+ const values = Object.entries(env).filter(([k]) => /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(k));
193
+ if (values.some(([, v]) => /\$\{\{/.test(String(v)))) return "unknown";
194
+ if (env.GIT_CONFIG_COUNT === undefined) return null;
195
+ const count = String(env.GIT_CONFIG_COUNT).trim();
196
+ if (count === "" || count === "0") return null;
197
+ if (!/^[1-9]\d*$/.test(count)) return "unknown";
198
+ for (let i = 0; i < Number(count); i += 1) if (env[`GIT_CONFIG_KEY_${i}`] === undefined || env[`GIT_CONFIG_VALUE_${i}`] === undefined) return "error";
199
+ let value = null;
200
+ for (let i = 0; i < Number(count); i += 1) {
201
+ if (String(env[`GIT_CONFIG_KEY_${i}`] ?? "").trim().toLowerCase() === "core.longpaths") value = gitBool(env[`GIT_CONFIG_VALUE_${i}`] ?? "");
202
+ }
203
+ return value;
204
+ }
205
+
206
+ /** Whether a step's `if:` provably lets it run on the Windows legs this rule judges: none, `true`, `always()`, `success()`, or
207
+ * `runner.os == 'Windows'` (true on exactly those legs). Anything else cannot be judged from the file. */
208
+ function alwaysRuns(condition) {
209
+ if (condition === undefined) return true;
210
+ const text = String(condition).trim().replace(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/, "$1").trim();
211
+ if (/^(?:true|always\(\)|success\(\))$/.test(text)) return true;
212
+ if (/^runner\.os\s*==\s*['"]Windows['"]$/.test(text)) return true;
213
+ return false;
214
+ }
215
+
216
+ /** A pre-checkout step judged for long paths: "none" (it does not touch them), "unsupported" (it touches them in a way this
217
+ * check cannot evaluate: a condition, echo or other shell text, a custom shell, an action, a value git would not read plainly),
218
+ * or { system?, global? } with the value its git config lines leave in each file (the last line for a file wins). */
219
+ function judgeStep(step) {
220
+ if (!step || typeof step !== "object") return "none";
221
+ if (typeof step.uses === "string") return MENTIONS_LONGPATHS.test(JSON.stringify(step)) ? "unsupported" : "none";
222
+ if (typeof step.run !== "string" || !MENTIONS_LONGPATHS.test(step.run)) return "none";
223
+ const shell = step.shell === undefined ? "bash" : String(step.shell).trim();
224
+ if (!alwaysRuns(step.if) || !STANDARD_SHELLS.has(shell) || step["continue-on-error"] !== undefined) return "unsupported";
225
+ const sets = {};
226
+ for (const line of step.run.split("\n").map((l) => l.trim())) {
227
+ if (NEUTRAL_LINE.test(line)) continue;
228
+ const m = SET_LINE.exec(line);
229
+ if (!m) return "unsupported";
230
+ const value = gitBool(m[2]);
231
+ if (value === "unknown") return "unsupported";
232
+ sets[m[1].slice(2).toLowerCase()] = value;
233
+ }
234
+ return Object.keys(sets).length ? sets : "unsupported";
235
+ }
236
+
237
+ /** core.longpaths as git reads it at the checkout step: the config environment overrides the files, the global file overrides
238
+ * the system file, and within a source the last value wins; Git for Windows on a hosted runner defaults to false. Returns
239
+ * true, false, "unknown", or "error" (an incomplete GIT_CONFIG pair set: git refuses to run). */
240
+ export function effectiveLongpaths(steps, env) {
241
+ const files = { system: null, global: null };
242
+ let unknown = false;
243
+ for (const step of steps) {
244
+ const judged = judgeStep(step);
245
+ if (judged === "unsupported") unknown = true;
246
+ else if (judged !== "none") Object.assign(files, judged);
247
+ }
248
+ const fromEnv = envLongpaths(env);
249
+ if (fromEnv === "unknown" || fromEnv === "error") return fromEnv;
250
+ if (fromEnv !== null) return fromEnv;
251
+ if (unknown) return "unknown";
252
+ return files.global ?? files.system ?? false;
253
+ }
254
+
255
+ /** The workspace prefix a GitHub-hosted Windows runner checks a repository out under: D:\a\<name>\<name>\ (plus with.path). */
256
+ export const windowsWorkspace = (name, checkoutPath = "") => `D:\\a\\${name}\\${name}\\${checkoutPath ? `${checkoutPath.replace(/\//g, "\\")}\\` : ""}`;
257
+
258
+ /** The GitHub repository name the runner names its workspace after, from the checked repository's remote `origin` URL (read with
259
+ * git config, no network); null when there is none or it is not a GitHub URL. Never the checker's own directory name. */
260
+ export function repositoryName(repo) {
261
+ if (!repo) return null;
262
+ const r = spawnSync("git", ["-C", String(repo), "config", "--get", "remote.origin.url"], { encoding: "utf8", windowsHide: true });
263
+ const m = /github\.com[:/]+[^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i.exec(String(r.stdout ?? "").trim());
264
+ return r.status === 0 && m ? m[1] : null;
265
+ }
266
+
267
+ /** The tracked paths a checkout materializes that are too long for Win32 under `prefix`: { files, dirs, longest }. */
268
+ export function overLongPaths(files, prefix, sparse = null) {
269
+ const kept = sparse ? files.filter((f) => coneIncludesFile(sparse, f)) : files;
270
+ const tooLong = [];
271
+ const dirs = new Set();
272
+ for (const f of kept) {
273
+ if (prefix.length + f.length > WIN_MAX_FILE) tooLong.push(f);
274
+ for (let d = path.posix.dirname(f); d !== "."; d = path.posix.dirname(d)) if (prefix.length + d.length > WIN_MAX_DIR) dirs.add(d);
275
+ }
276
+ const longest = [...tooLong, ...dirs].sort((a, b) => b.length - a.length || (a < b ? -1 : 1))[0] ?? null;
277
+ return { files: tooLong, dirs: [...dirs], longest };
278
+ }
279
+
280
+ function checkoutLongpaths({ root, files, repo, repositoryName: given }) {
281
+ const out = [];
282
+ const name = given !== undefined ? given : repositoryName(repo);
283
+ for (const wf of workflows(root, files)) {
284
+ if (wf.error) {
285
+ if (/windows/i.test(wf.text) && /actions\/checkout@/.test(wf.text)) out.push({ file: wf.file, subject: "parse", problem: `UNSUPPORTED: ${wf.file} checks out on a Windows runner but cannot be read by the strict workflow parser (${wf.error}), so its long-path capability cannot be checked`, fix: "write the workflow in the YAML subset packages/release/workflow-yaml.mjs reads" });
286
+ continue;
287
+ }
288
+ for (const [jobId, job] of Object.entries(wf.doc?.jobs ?? {})) {
289
+ const labels = runnerLabels(job);
290
+ if (labels.some((l) => /^self-hosted$/i.test(l))) continue;
291
+ const windows = labels.filter((l) => HOSTED_WINDOWS.test(l.trim()));
292
+ if (!windows.length) continue;
293
+ const checkout = selfCheckout(job);
294
+ if (!checkout || (checkout.sparse !== null && !checkout.cone)) continue;
295
+ // Unknown repository: judge with the shortest possible name (one character); what fails there fails under any name, and
296
+ // what fails only under a longer one is reported as UNSUPPORTED rather than passed.
297
+ const prefix = windowsWorkspace(name ?? "x", checkout.path);
298
+ const long = overLongPaths(files, prefix, checkout.sparse);
299
+ const unknownWide = name ? null : overLongPaths(files, windowsWorkspace("x".repeat(100), checkout.path), checkout.sparse);
300
+ if (!long.longest && !unknownWide?.longest) continue;
301
+ const env = { ...(wf.doc.env ?? {}), ...(job.env ?? {}), ...(checkout.step.env ?? {}) };
302
+ const effective = effectiveLongpaths(job.steps.slice(0, checkout.index), env);
303
+ if (effective === true) continue;
304
+ const unsupported = effective === "unknown";
305
+ if (effective === "error") {
306
+ out.push({ file: wf.file, subject: jobId, problem: `${wf.file} job ${jobId} (${windows.join(", ")}) sets GIT_CONFIG_COUNT with a pair below it missing its GIT_CONFIG_KEY_n or GIT_CONFIG_VALUE_n: git refuses to run, so the checkout of ${long.longest ? long.files.length : unknownWide.files.length} over-long tracked path(s) has no Git long paths`, fix: "give every index below GIT_CONFIG_COUNT both a GIT_CONFIG_KEY_n and a GIT_CONFIG_VALUE_n, or lower the count" });
307
+ continue;
308
+ }
309
+ const after = job.steps.slice(checkout.index + 1).some((s) => s && typeof s.run === "string" && MENTIONS_LONGPATHS.test(s.run));
310
+ if (!long.longest) {
311
+ out.push({ file: wf.file, subject: jobId, problem: `UNSUPPORTED: ${wf.file} job ${jobId} (${windows.join(", ")}) checks out ${unknownWide.files.length} tracked path(s) that exceed the Win32 limit under D:\\a\\<repo>\\<repo>\\ for a long enough repository name, and the repository name is unknown (no GitHub remote origin), without proven Git long paths`, fix: "enable core.longpaths before the checkout, or check a clone whose origin names the GitHub repository" });
312
+ continue;
313
+ }
314
+ out.push({
315
+ file: wf.file, subject: jobId,
316
+ problem: `${unsupported ? "UNSUPPORTED: " : ""}${wf.file} job ${jobId} (${windows.join(", ")}) checks out ${long.files.length} tracked file path(s) longer than ${WIN_MAX_FILE} characters under ${prefix}${name ? "" : " (repository name unknown; shortest possible)"} (longest: ${prefix.length + long.longest.length} characters, ${long.longest}) without ${unsupported ? "provable " : ""}Git long paths${unsupported ? " (a step or environment before the checkout touches core.longpaths in a way this check cannot prove runs: a condition, echo or other shell text, a custom shell, an action, an expression, or a GIT_CONFIG_COUNT git would refuse)" : ""}${after ? " (core.longpaths is enabled only after the checkout)" : ""}: git fails with "Filename too long"`,
317
+ fix: "add an unconditional step before the checkout whose whole script is `git config --system core.longpaths true`, or leave the long paths out of the job's sparse checkout",
318
+ });
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+
324
+ // ---- CA-09 source-relation -------------------------------------------------------------------------------------------------------
325
+
326
+ /** The relations RightKit knows: each names a source file, a selector of string values in it, and the target file whose rows they
327
+ * must name. An app adds its own in .rightkit/source-relations.json ({ "schema": 1, "relations": [ ... ] }). */
328
+ export const SOURCE_RELATIONS = Object.freeze([
329
+ Object.freeze({
330
+ id: "proven-controls-status-row",
331
+ source: "factory/tools/proven-controls/hand-entries.json",
332
+ select: "[*].statusDocBlock",
333
+ target: "docs/sr1-status.md",
334
+ adapter: "markdown-table-first-cell",
335
+ about: "SwiftRight: each hand entry's statusDocBlock names a row of docs/sr1-status.md (factory/tools/proven-controls/generate.py status_doc_rows)",
336
+ }),
337
+ ]);
338
+ export const APP_RELATIONS_FILE = ".rightkit/source-relations.json";
339
+ const ADAPTERS = new Set(["markdown-table-first-cell"]);
340
+
341
+ /** The string values `select` picks from parsed JSON: dot-separated keys, `[*]` for every element of an array. Returns
342
+ * [{ at, value }] with `at` the concrete path; a missing key yields { at, value: undefined }. */
343
+ export function selectValues(doc, select) {
344
+ const steps = String(select).match(/\[\*\]|[^.[\]]+/g) ?? [];
345
+ let current = [{ at: "", value: doc }];
346
+ for (const step of steps) {
347
+ const next = [];
348
+ for (const { at, value } of current) {
349
+ if (step === "[*]") { if (Array.isArray(value)) value.forEach((v, i) => next.push({ at: `${at}[${i}]`, value: v })); else next.push({ at: `${at}[*]`, value: undefined }); }
350
+ else next.push({ at: at ? `${at}.${step}` : step, value: value && typeof value === "object" && !Array.isArray(value) ? value[step] : undefined });
351
+ }
352
+ current = next;
353
+ }
354
+ return current;
355
+ }
356
+
357
+ /** The row ids of a Markdown status document: the first word of the first cell of each table row before the first `---` line
358
+ * (the same reading as SwiftRight's status_doc_rows). A document whose lines are escaped into one line has no rows. */
359
+ export function markdownFirstCells(text) {
360
+ const ids = new Set();
361
+ for (const line of String(text).replace(/\r\n/g, "\n").split("\n---\n")[0].split("\n")) {
362
+ if (!line.startsWith("|")) continue;
363
+ const cell = line.trim().replace(/^\|+|\|+$/g, "").split("|")[0].trim();
364
+ const m = /^([A-Za-z0-9][\w.-]*)\s/.exec(`${cell} `);
365
+ if (m) ids.add(m[1]);
366
+ }
367
+ return ids;
368
+ }
369
+
370
+ function relationsOf(root, files) {
371
+ const relations = SOURCE_RELATIONS.map((r) => ({ ...r, origin: "RightKit" }));
372
+ const problems = [];
373
+ if (files.includes(APP_RELATIONS_FILE)) {
374
+ let doc = null;
375
+ try { doc = JSON.parse(readText(root, APP_RELATIONS_FILE) ?? ""); } catch (error) { problems.push(`${APP_RELATIONS_FILE} is not JSON: ${error.message}`); }
376
+ if (doc) {
377
+ if (doc.schema !== 1 || !Array.isArray(doc.relations)) problems.push(`${APP_RELATIONS_FILE} is not { "schema": 1, "relations": [...] }`);
378
+ else doc.relations.forEach((r, i) => {
379
+ const ok = r && typeof r === "object" && ["id", "source", "select", "target", "adapter"].every((k) => typeof r[k] === "string" && r[k]);
380
+ if (!ok) problems.push(`${APP_RELATIONS_FILE} relations[${i}] needs id, source, select, target and adapter strings`);
381
+ else if (!ADAPTERS.has(r.adapter)) problems.push(`${APP_RELATIONS_FILE} relations[${i}] adapter "${r.adapter}" is not one of ${[...ADAPTERS].join(", ")}`);
382
+ else relations.push({ ...r, origin: APP_RELATIONS_FILE });
383
+ });
384
+ }
385
+ }
386
+ return { relations, problems };
387
+ }
388
+
389
+ function sourceRelation({ root, files }) {
390
+ const out = [];
391
+ const { relations, problems } = relationsOf(root, files);
392
+ for (const problem of problems) out.push({ file: APP_RELATIONS_FILE, subject: "declaration", problem, fix: "fix the declaration" });
393
+ const present = new Set(files);
394
+ for (const rel of relations) {
395
+ if (!present.has(rel.source)) continue; // the relation applies only to a tree that has its source
396
+ let doc;
397
+ try { doc = JSON.parse(readText(root, rel.source) ?? ""); } catch (error) {
398
+ out.push({ file: rel.source, subject: `${rel.id}:parse`, problem: `${rel.source} is not JSON (${error.message}), so its ${rel.select} references to ${rel.target} cannot be checked`, fix: "commit valid JSON" });
399
+ continue;
400
+ }
401
+ const text = present.has(rel.target) ? readText(root, rel.target) : null;
402
+ if (text === null) {
403
+ out.push({ file: rel.source, subject: `${rel.id}:target`, problem: `${rel.source} references rows of ${rel.target} (${rel.id}), which is not in the tree`, fix: `commit ${rel.target} or remove the references` });
404
+ continue;
405
+ }
406
+ const rows = markdownFirstCells(text);
407
+ for (const { at, value } of selectValues(doc, rel.select)) {
408
+ if (typeof value !== "string" || !value) {
409
+ out.push({ file: rel.source, subject: `${rel.id}:${at}`, problem: `${rel.source} ${at} is ${value === undefined ? "missing" : JSON.stringify(value)}; it must name a row of ${rel.target}`, fix: `set it to the id in the first cell of a ${rel.target} table row` });
410
+ } else if (!rows.has(value)) {
411
+ out.push({ file: rel.source, subject: `${rel.id}:${value}`, problem: `${rel.source} ${at} names row "${value}", which ${rel.target} does not have (${rows.size} table row id(s) before its first --- line${rows.size === 0 ? "; a document escaped into one line has none" : ""})`, fix: `restore the "${value}" row in ${rel.target}, or point ${at} at a row that exists` });
412
+ }
413
+ }
414
+ }
415
+ return out;
416
+ }
417
+
418
+ export const CONSUMER_INPUT_RULES = Object.freeze([
419
+ { id: "checkout-step-input", gap: "CA-07", run: checkoutStepInput },
420
+ { id: "source-relation", gap: "CA-09", run: sourceRelation },
421
+ { id: "checkout-longpaths", gap: "CA-11", run: checkoutLongpaths },
422
+ ]);