@rightkit/release 0.6.4 → 0.6.6
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.
- package/README.md +109 -7
- package/admission-files.mjs +279 -0
- package/admission-host.mjs +403 -0
- package/admission.mjs +146 -0
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +1326 -0
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +495 -69
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +87 -26
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +45 -5
- package/command-spec.mjs +4 -5
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +301 -37
- package/development.mjs +5 -5
- package/github-release.mjs +5 -3
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +134 -8
- package/inventory-check.mjs +12 -1
- package/ios-upload.mjs +4 -4
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-dependency-allowlist.json +2 -0
- package/package.json +41 -3
- package/pipeline-admission.mjs +12 -0
- package/prebuild-gates.mjs +7 -3
- package/publish-cargo.mjs +215 -3
- package/publish-update.mjs +5 -3
- package/release-config.mjs +36 -5
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +117 -62
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +18 -13
- package/stage-sidecar-command.mjs +5 -5
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/app-check.mjs
ADDED
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
// The app check: step 1 of docs/PIPELINE-ARCHITECTURE.md for any repository that uses RightKit, shipped in @rightkit/release so an
|
|
2
|
+
// app runs it without a RightKit checkout (`rightkit-gate check`, `right-release check`, the pre-push hook, and the start of every
|
|
3
|
+
// right-release command that builds or ships; see CHECKED_COMMANDS). RightKit's own scripts/preflight.mjs imports these rules and
|
|
4
|
+
// adds the ones that police RightKit itself.
|
|
5
|
+
//
|
|
6
|
+
// It reads the committed files of one commit (or, for a compile, the tree on disk: `--worktree`), exported by git alone (ls-tree and
|
|
7
|
+
// cat-file, no tar and no archive, so Windows depends on no tar on PATH), and reports every finding in one run, each with rule,
|
|
8
|
+
// file, reason and fix. No network, no cargo, no pnpm, no compile, no broker, no environment, no repository code. A rule that throws
|
|
9
|
+
// is a FAIL, never a pass. Nothing is written to the repository.
|
|
10
|
+
//
|
|
11
|
+
// eol-policy G04 a committed blob has CRLF where .gitattributes says eol=lf (every checkout shows it modified;
|
|
12
|
+
// byte-compared and hashed copies differ on Windows)
|
|
13
|
+
// file-url G12 the .pathname of a URL built from import.meta.url used as a path: wrong on Windows; use fileURLToPath
|
|
14
|
+
// retired-alias P11 an invocation of `rightkit`, the retired name of the RustRight client (rustright#91), outside
|
|
15
|
+
// history; scripts/retire-rightkit-alias.mjs rewrites them (in a RightKit checkout)
|
|
16
|
+
// qa-platform G13 a Rust `"platform:<x>"` requirement that is not darwin or windows, the words rightkit-qa emits (it skips forever)
|
|
17
|
+
// test-harness G14 a Cargo.toml [[test]] whose file defines `fn main(` but lacks `harness = false` (libtest ignores it: 0 tests)
|
|
18
|
+
// scenario-skip G15 a `.scenario(` test that prints SKIP and returns: a skip that reports green
|
|
19
|
+
// release-config G16 right-release.config.json (or a legacy .mjs): a provider publish-update.mjs refuses, an updater keyFile, an
|
|
20
|
+
// undefined Cargo profile, JSON right-release cannot load
|
|
21
|
+
// skip-checks G17 a package.json script that runs right-release with --skip-checks
|
|
22
|
+
// qa-dev-server G18 a package.json qa* script that runs `tauri dev` (QA would test the dev server, not the app)
|
|
23
|
+
// ffmpeg-release-escape G19 `allow_in_release_builds` in non-test Rust outside crates/rightkit-ffmpeg
|
|
24
|
+
// switches-inventory G20 a right-release.config.json (or .mjs) directory without a switches.json (or its releaseSwitches.inventory)
|
|
25
|
+
// speech-link G21 `import Speech` anywhere under swift/RightKitSwift/Sources or Tests (Apple Speech is removed, rightkit#978;
|
|
26
|
+
// the LinkProbes control fixture is the one sanctioned import)
|
|
27
|
+
// e2e-under-cargo-test G22 `cargo test|run|nextest` (also as `rustright cargo`, or the retired `rightkit`) that runs an end-to-end
|
|
28
|
+
// crate (rightkit-qa driver modules) in the broker without --no-run: it holds the compile slot for the whole run
|
|
29
|
+
//
|
|
30
|
+
// Ratchet: findings that exist today may be listed so they print as KNOWN and do not fail; an unlisted finding fails; a listed
|
|
31
|
+
// identity (`${rule}|${file}|${subject}`, no line numbers) that is gone fails as STALE, so the list only shrinks. The baseline is
|
|
32
|
+
// 1. --baseline <file>, which must exist; else
|
|
33
|
+
// 2. the app's own .rightkit/app-check-baseline.json, read from the checked tree: { schema: 1, known: [{ id, issue, added, sunset }] }.
|
|
34
|
+
// The rule for an entry (APP_BASELINE_RULE; a baseline that breaks it is exit 2, naming the entry): `issue` names the issue that
|
|
35
|
+
// tracks it and is not empty; `added` and `sunset` are real dates, YYYY-MM-DD, UTC; `added` is not later than today; `sunset`
|
|
36
|
+
// is not before `added` and at most 90 days (APP_BASELINE_MAX_DAYS) after it. So an entry silences its finding for at most 90
|
|
37
|
+
// days, the date it counts from cannot be pushed into the future, and renewing one means editing it in a commit. An entry past
|
|
38
|
+
// its sunset date is no longer tolerated and fails as EXPIRED, with its finding; else
|
|
39
|
+
// 3. RightKit's central ci/app-check-baseline.json ({ known: { "<repo>": ["<identity>", ...] } }), only when this module is loaded
|
|
40
|
+
// from a RightKit checkout; an installed package has none (its entries keep their own contract: identities only, no dates).
|
|
41
|
+
// --print-baseline prints the current identities for a person to paste; the check never writes any baseline.
|
|
42
|
+
//
|
|
43
|
+
// Exit 0: every rule passed. 1: at least one FAIL, STALE or EXPIRED. 2: the check itself could not run.
|
|
44
|
+
import fs from "node:fs";
|
|
45
|
+
import os from "node:os";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
import { spawnSync } from "./background-process.mjs";
|
|
49
|
+
|
|
50
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
export const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8"); } catch { return null; } };
|
|
52
|
+
export const readJson = (root, rel) => { const text = readText(root, rel); try { return text === null ? null : JSON.parse(text); } catch { return null; } };
|
|
53
|
+
|
|
54
|
+
// git's repository-location variables are dropped from every git call: a hook runs with some of them set, and `--repo` must
|
|
55
|
+
// mean the repository it names (the worktree export sets its own GIT_INDEX_FILE).
|
|
56
|
+
const GIT_LOCATION = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|PREFIX|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|NAMESPACE)$/i;
|
|
57
|
+
const gitEnv = (extra = {}) => ({ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !GIT_LOCATION.test(key))), ...extra });
|
|
58
|
+
|
|
59
|
+
function git(args, { cwd, input, env = gitEnv(), encoding = "utf8", maxBuffer = 512 * 1024 * 1024 } = {}) {
|
|
60
|
+
const r = spawnSync("git", args, { cwd, input, env, encoding, maxBuffer });
|
|
61
|
+
if (r.error || r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.error?.message ?? String(r.stderr).trim()}`);
|
|
62
|
+
return r.stdout;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The git top-level directory of `dir`, or null when it is not inside a work tree. */
|
|
66
|
+
export function repoRoot(dir) {
|
|
67
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, env: gitEnv(), encoding: "utf8" });
|
|
68
|
+
return r.error || r.status !== 0 || !String(r.stdout).trim() ? null : path.resolve(String(r.stdout).trim());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- rules: each returns [{ problem, fix? }] -----------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function eolPolicy({ root, commit, repo = process.cwd() }) {
|
|
74
|
+
// CR-bearing text blobs of the commit, then the commit's own attributes (the exported tree holds its .gitattributes).
|
|
75
|
+
const grep = spawnSync("git", ["grep", "-z", "-l", "-I", "-e", "\r", commit, "--"], { cwd: repo, env: gitEnv(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
76
|
+
if (grep.status === 1) return []; // git grep: 1 = no match
|
|
77
|
+
if (grep.error || grep.status !== 0) throw new Error(`git grep failed: ${grep.error?.message ?? grep.stderr.trim()}`);
|
|
78
|
+
const files = grep.stdout.split("\0").filter(Boolean).map((f) => f.slice(commit.length + 1));
|
|
79
|
+
// `-text` (or binary) turns conversion off whatever `eol` says: those bytes are kept as committed (pinned evidence, licence texts).
|
|
80
|
+
const attrs = git(["--work-tree", root, "check-attr", "-z", "--stdin", "text", "eol"], { cwd: repo, input: files.join("\0") }).split("\0");
|
|
81
|
+
const seen = new Map();
|
|
82
|
+
for (let i = 0; i + 2 < attrs.length; i += 3) seen.set(attrs[i], { ...seen.get(attrs[i]), [attrs[i + 1]]: attrs[i + 2] });
|
|
83
|
+
const bad = [...seen].filter(([, a]) => a.eol === "lf" && a.text !== "unset").map(([file]) => file);
|
|
84
|
+
return bad.length ? [{
|
|
85
|
+
problem: `${bad.length} file(s) committed with CRLF although .gitattributes says eol=lf (a fresh checkout shows them modified; hashed and byte-compared copies differ on Windows): ${bad.join(", ")}`,
|
|
86
|
+
fix: `git add --renormalize -- ${bad.join(" ")} && git commit`,
|
|
87
|
+
}] : [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The RustRight client's subcommands as the retired `rightkit` alias took them (rustright crates/rustright-cli/src/cli.rs).
|
|
91
|
+
const ALIAS_SUBCOMMANDS = "cargo|rustc|rustdoc|run|build|owns|status|attach|cancel|list|quiet|queue|prune|deps|profiles|forecast-report|resume-admission|pause-admission|remote|cutover|storage|verify-receipt";
|
|
92
|
+
// Each form is [pattern, replacement] or [pattern, replacement, { unlessRetired, lineMust }]: `unlessRetired` spares a line that
|
|
93
|
+
// says "retired", where code names the old client on purpose to refuse it (`if (name === "rightkit") refuse("R1-retired-client")`);
|
|
94
|
+
// `lineMust` limits a form to lines that also match it (a ternary needs its `?`, so JSON's `"kind": "rightkit"` is data).
|
|
95
|
+
// Forms match across lines (a multi-line argv array), so the rule reads whole files, not lines.
|
|
96
|
+
const ALIAS_FORMS = [
|
|
97
|
+
// rightkit cargo ..., `rightkit status <id>` (never rightkit-qa, @rightkit/..., .../rightkit, or "rightkit build-control")
|
|
98
|
+
[new RegExp(`(^|[^-./@A-Za-z0-9_$])rightkit(?= (?:${ALIAS_SUBCOMMANDS})(?![-\\w]))`, "gm"), "$1rustright"],
|
|
99
|
+
// ["rightkit", "cargo", ...], its quoting variants, one element per line, and the [command, [args]] pair ["rightkit", ["cargo", ...]]
|
|
100
|
+
[new RegExp(`([\\[(]\\s*)(["'\`])rightkit\\2(?=\\s*,\\s*(?:\\/\\/[^\\n]*\\n\\s*)*(?:\\[\\s*)?["'\`](?:${ALIAS_SUBCOMMANDS})["'\`])`, "g"), "$1$2rustright$2"],
|
|
101
|
+
// the same inside an escaped string (a TOML fixture in a Rust or JS literal): [\"rightkit\", \"cargo\", ...]
|
|
102
|
+
[new RegExp(`([\\[(]\\s*)\\\\(["'])rightkit\\\\\\2(?=\\s*,\\s*\\\\["'](?:${ALIAS_SUBCOMMANDS})\\\\["'])`, "g"), "$1\\$2rustright\\$2"],
|
|
103
|
+
// vec!["rightkit".into(), "cargo".into(), ...] and .to_string() / .to_owned()
|
|
104
|
+
[new RegExp(`([\\[(]\\s*)(["'])rightkit\\2(\\.(?:into|to_string|to_owned)\\(\\))(?=\\s*,\\s*["'](?:${ALIAS_SUBCOMMANDS})["'])`, "g"), "$1$2rustright$2$3"],
|
|
105
|
+
// run("rightkit", ...), spawn("rightkit", ...), execFileSync("rightkit", ...), which("rightkit"), Command::new("rightkit")
|
|
106
|
+
[/\b(run|spawn|spawnSync|execFile|execFileSync|execa|resolveCommand|which|Command::new)\((["'])rightkit\2/g, "$1($2rustright$2"],
|
|
107
|
+
// the Windows shim by name: "rightkit.cmd", `...\agent-bin\rightkit.cmd cargo ...`
|
|
108
|
+
[/(["'])rightkit\.(cmd|exe)\1/g, "$1rustright.$2$1"],
|
|
109
|
+
[new RegExp(`(^|[^-./@A-Za-z0-9_$])rightkit\\.(cmd|exe)(?= (?:${ALIAS_SUBCOMMANDS})(?![-\\w]))`, "gm"), "$1rustright.$2"],
|
|
110
|
+
// isWindows ? "rustright.cmd" : "rightkit" (the POSIX half of the same shim choice)
|
|
111
|
+
[/(["'])rustright\.cmd\1(\s*:\s*)(["'])rightkit\3/g, "$1rustright.cmd$1$2$3rustright$3"],
|
|
112
|
+
// { cmd: "rightkit", args: [...] } in a release config
|
|
113
|
+
[/\bcmd:(\s*)(["'])rightkit\2/g, "cmd:$1$2rustright$2"],
|
|
114
|
+
// a command compared by name: `command === "rightkit"` (a Windows .cmd mapping), `argv0 == "rightkit"`
|
|
115
|
+
[/\b(command|cmd|program|bin|binary|exe|executable|tool|base|first|client|argv0)(\s*(?:===?|!==?)\s*)(["'])rightkit\3/g, "$1$2$3rustright$3", { unlessRetired: true }],
|
|
116
|
+
// the POSIX half of a platform choice: `win32 ? (env.ComSpec ?? "cmd.exe") : "rightkit"`, `isWin ? "x" : "rightkit"`
|
|
117
|
+
[/(\)|["'])(\s*:\s*)(["'])rightkit\3(?=\s*[;,)\n])/g, "$1$2$3rustright$3", { lineMust: /\?/ }],
|
|
118
|
+
// a named command constant: `const CARGO_COMMAND = "rightkit"`, `const BROKER: &str = "rightkit"` (RETIRED_* names are refusals)
|
|
119
|
+
[/\b((?![A-Z0-9_]*RETIRED)[A-Z][A-Z0-9_]*(?:COMMAND|CMD|BIN|BINARY|PROGRAM|EXE|CLI|BROKER|CLIENT))(\s*(?::\s*&(?:'static\s+)?str\s*)?=\s*)(["'])rightkit\3/g, "$1$2$3rustright$3", { unlessRetired: true }],
|
|
120
|
+
// PowerShell and shell lookups and calls: `Get-Command rightkit`, `& rightkit @args`, `which rightkit`, `command -v rightkit`
|
|
121
|
+
[/\b(Get-Command|which|where(?:\.exe)?|command -v)(\s+)rightkit\b(?![-.\w])(?=\s*(?:$|[)|;&>"'`\]]|-ErrorAction))/gm, "$1$2rustright"],
|
|
122
|
+
[/(^|[\s(;{`])&(\s+)rightkit\b(?![-.\w])/gm, "$1&$2rustright"],
|
|
123
|
+
// a per-tool table keyed by the client: `{ rightkit: ["cargo", "--version"] }`
|
|
124
|
+
[/(^|[{,\s])rightkit(\s*:\s*\[)/gm, "$1rustright$2"],
|
|
125
|
+
];
|
|
126
|
+
/** History and byte-pinned content the alias may stay in: what was run then, or bytes a hash or review binds. */
|
|
127
|
+
export const RETIRED_ALIAS_EXEMPT = /(^|\/)CHANGELOG\.md$|^docs\/(audits|plans|archive)\/|(^|\/)(evidence|release-evidence|receipts?|fixtures?|vendor)\/|adr-\d+-evidence\/|\.evidence\/|\d{4}-\d{2}-\d{2}|(^|\/)(handoffs?|handovers?|reports?|results|experiments|reviews|research|mockups|archive|incidents?|spikes)\/|[^/]*evidence[^/]*\/|\.(jsonl|log|patch|diff)$|\.receipt\.json$|(^|\/)SHA256SUMS$|^ci\/consumers\/|^packages\/ax\/plugin\/schemas\/|^scripts\/embed-export\/|msix-repair-guard\.ps1$|\.lock$|^scripts\/(preflight|preflight\.test|retire-rightkit-alias)\.mjs$|^packages\/release\/app-check(\.test)?\.mjs$/;
|
|
128
|
+
|
|
129
|
+
/** A file generated from other sources (AGENTS.md from docs/agent-rules/): its sources are what is checked and edited. */
|
|
130
|
+
export const isGeneratedFile = (text) => /^\s*(?:<!--|\/\/|#)\s*GENERATED FILE\. Do not hand-edit\./.test(String(text).slice(0, 300));
|
|
131
|
+
|
|
132
|
+
const lineAt = (text, offset) => text.slice(text.lastIndexOf("\n", offset - 1) + 1, (text.indexOf("\n", offset) + 1 || text.length + 1) - 1);
|
|
133
|
+
|
|
134
|
+
/** `text` with every invocation of the retired alias renamed to `rustright`, how many there were, and the 1-based line of each. */
|
|
135
|
+
export function retireAliasText(text) {
|
|
136
|
+
let count = 0;
|
|
137
|
+
let out = text;
|
|
138
|
+
const lines = new Set();
|
|
139
|
+
for (const [pattern, replacement, { unlessRetired = false, lineMust = null } = {}] of ALIAS_FORMS) {
|
|
140
|
+
const hits = [];
|
|
141
|
+
out = out.replace(pattern, (...m) => {
|
|
142
|
+
const offset = m.at(-2);
|
|
143
|
+
const whole = m.at(-1);
|
|
144
|
+
const line = lineAt(whole, offset);
|
|
145
|
+
if ((unlessRetired && /retired/i.test(line)) || (lineMust && !lineMust.test(line))) return m[0];
|
|
146
|
+
count += 1;
|
|
147
|
+
hits.push(offset + m[0].search(/rightkit/));
|
|
148
|
+
return replacement.replace(/\$(\d)/g, (_, i) => m[Number(i)] ?? "");
|
|
149
|
+
});
|
|
150
|
+
// Offsets are positions in the text before this form's replacements; every replacement keeps its line count.
|
|
151
|
+
for (const at of hits) lines.add(text.slice(0, Math.max(0, at)).split("\n").length);
|
|
152
|
+
}
|
|
153
|
+
return { text: out, count, lines: [...lines].sort((a, b) => a - b) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function retiredAlias({ root, files }) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (RETIRED_ALIAS_EXEMPT.test(file)) continue;
|
|
160
|
+
let bytes;
|
|
161
|
+
try { bytes = fs.readFileSync(path.join(root, file)); } catch { continue; }
|
|
162
|
+
if (bytes.includes(0) || !bytes.includes("rightkit") || isGeneratedFile(bytes.toString("utf8"))) continue;
|
|
163
|
+
const hits = retireAliasText(bytes.toString("utf8")).lines;
|
|
164
|
+
if (hits.length) out.push({ problem: `${file}:${hits.slice(0, 8).join(",")}${hits.length > 8 ? ",…" : ""} invokes \`rightkit\`, the retired name of the RustRight client`, fix: "use rustright (node <rightkit>/scripts/retire-rightkit-alias.mjs --repo <this repository> rewrites them all)" });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const FILE_URL_PATHNAME = /new URL\((?:[^()]|\([^()]*\))*import\.meta\.url\s*\)\s*\.pathname/g;
|
|
170
|
+
function fileUrls({ root, files }) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
if (!/\.(?:mjs|cjs|js|ts|mts|cts|tsx)$/.test(file) || file.startsWith("vendor/") || file.includes("/node_modules/") || file === "scripts/preflight.mjs") continue;
|
|
174
|
+
const text = readText(root, file);
|
|
175
|
+
if (!text || !text.includes("import.meta.url")) continue;
|
|
176
|
+
for (const m of text.matchAll(FILE_URL_PATHNAME)) {
|
|
177
|
+
const line = text.slice(0, m.index).split("\n").length;
|
|
178
|
+
out.push({ problem: `${file}:${line} turns a file URL into a path with .pathname (C:\\ becomes /C:/ and spaces become %20 on Windows)`, fix: `use fileURLToPath(new URL(..., import.meta.url)) from node:url` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- G13-G22: app defects found from files alone ----------------------------------------------------------------------------
|
|
185
|
+
// Each finding is { problem, fix, file, subject }; `${rule}|${file}|${subject}` (no line numbers, so an edit above it does not
|
|
186
|
+
// change it) is its identity in ci/app-check-baseline.json.
|
|
187
|
+
const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
|
|
188
|
+
const scanned = (file) => !SKIPPED_TREE.test(file);
|
|
189
|
+
const lineOf = (text, offset) => text.slice(0, offset).split("\n").length;
|
|
190
|
+
/** `text` with every whole-line `//` comment blanked, offsets and line numbers kept (a URL inside a string is untouched). */
|
|
191
|
+
const blankComments = (text) => text.replace(/^[ \t]*\/\/.*$/gm, (m) => " ".repeat(m.length));
|
|
192
|
+
const rustFiles = (files) => files.filter((f) => f.endsWith(".rs") && scanned(f));
|
|
193
|
+
// The release config: the data config (right-release.config.json, M24) or a legacy module (right-release.config.mjs) not yet moved.
|
|
194
|
+
const configFiles = (files) => files.filter((f) => /(^|\/)right-release\.config\.(?:mjs|json)$/.test(f) && scanned(f));
|
|
195
|
+
const isJsonConfig = (file) => file.endsWith(".json");
|
|
196
|
+
/** Every `[key, value, parentKey]` of a parsed JSON value, depth first (the walk the JSON release config rules read). */
|
|
197
|
+
function* jsonEntries(node, parentKey = null) {
|
|
198
|
+
if (Array.isArray(node)) { for (const item of node) yield* jsonEntries(item, parentKey); return; }
|
|
199
|
+
if (!node || typeof node !== "object") return;
|
|
200
|
+
for (const [key, value] of Object.entries(node)) { yield [key, value, parentKey]; yield* jsonEntries(value, key); }
|
|
201
|
+
}
|
|
202
|
+
/** The offset of the first `"key"` in JSON text that is followed by `:` (a key, not a string value), else 0. */
|
|
203
|
+
const jsonKeyOffset = (text, key) => Math.max(0, text.search(new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`)));
|
|
204
|
+
const dirOf = (file) => path.posix.dirname(file);
|
|
205
|
+
|
|
206
|
+
// G13: rightkit-qa's util::platform() emits "darwin" or "windows"; a requirement spelled any other way never matches, so the
|
|
207
|
+
// test skips on every host and nobody sees it (HeardRight tauri-app-next/qa-e2e/tests/installed_app_ax.rs "platform:macos").
|
|
208
|
+
function qaPlatform({ root, files }) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const file of rustFiles(files)) {
|
|
211
|
+
const text = readText(root, file);
|
|
212
|
+
if (!text || !text.includes("platform:")) continue;
|
|
213
|
+
// A requirement is an element of a requires list (after `[` or `,`), never a JSON value (`"item": "platform:x"`).
|
|
214
|
+
for (const m of blankComments(text).matchAll(/([\[,]\s*)"platform:([A-Za-z0-9_-]+)"/g)) {
|
|
215
|
+
m.index += m[1].length; m[0] = m[0].slice(m[1].length); m[1] = m[2];
|
|
216
|
+
if (m[1] === "darwin" || m[1] === "windows") continue;
|
|
217
|
+
out.push({ file, subject: `platform:${m[1]}`, problem: `${file}:${lineOf(text, m.index)} requirement ${m[0]} never matches (rightkit-qa platform() is "darwin" or "windows"), so the test skips forever`, fix: `spell it "platform:darwin" or "platform:windows"` });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The [[test]] tables of a Cargo manifest: { name, path, harness, line } (literal values only). */
|
|
224
|
+
export function cargoTestTargets(text) {
|
|
225
|
+
const targets = [];
|
|
226
|
+
let current = null;
|
|
227
|
+
String(text ?? "").split("\n").forEach((raw, i) => {
|
|
228
|
+
const line = raw.replace(/\s+#.*$/, "");
|
|
229
|
+
if (/^\s*\[/.test(line)) {
|
|
230
|
+
current = /^\s*\[\[test\]\]\s*$/.test(line) ? { name: undefined, path: undefined, harness: true, line: i + 1 } : null;
|
|
231
|
+
if (current) targets.push(current);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!current) return;
|
|
235
|
+
const kv = /^\s*([A-Za-z_-]+)\s*=\s*(.*?)\s*$/.exec(line);
|
|
236
|
+
if (!kv) return;
|
|
237
|
+
if (kv[1] === "name" || kv[1] === "path") current[kv[1]] = /^"([^"]*)"$/.exec(kv[2])?.[1];
|
|
238
|
+
else if (kv[1] === "harness") current.harness = kv[2] !== "false";
|
|
239
|
+
});
|
|
240
|
+
return targets;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// G14: libtest runs #[test] functions and ignores a `fn main`, so a [[test]] target written as a program but left on the default
|
|
244
|
+
// harness passes with 0 tests (CodeRight engine/Cargo.toml installed_matrix_*). An ordinary #[test] file has no top-level main.
|
|
245
|
+
const TOP_LEVEL_MAIN = /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+main\s*\(/m;
|
|
246
|
+
function testHarness({ root, files }) {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const file of files.filter((f) => /(^|\/)Cargo\.toml$/.test(f) && scanned(f))) {
|
|
249
|
+
const text = readText(root, file);
|
|
250
|
+
if (!text || !text.includes("[[test]]")) continue;
|
|
251
|
+
for (const t of cargoTestTargets(text)) {
|
|
252
|
+
if (!t.harness || (!t.name && !t.path)) continue;
|
|
253
|
+
const base = dirOf(file);
|
|
254
|
+
const candidates = t.path ? [path.posix.join(base, t.path)] : [path.posix.join(base, "tests", `${t.name}.rs`), path.posix.join(base, "tests", t.name ?? "", "main.rs")];
|
|
255
|
+
const source = candidates.map((c) => readText(root, c)).find((s) => s !== null);
|
|
256
|
+
if (source === undefined || source === null || !TOP_LEVEL_MAIN.test(blankComments(source))) continue;
|
|
257
|
+
out.push({ file, subject: t.name ?? path.posix.basename(t.path, ".rs"), problem: `${file}:${t.line} [[test]] ${t.name ?? t.path} defines fn main but does not set harness = false: cargo test runs it as a libtest target, ignores main and passes with 0 tests`, fix: "add harness = false to the [[test]] table, or turn main into #[test] functions" });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// G15: in a `.scenario(` test, `println!("SKIP ...")` then `return;` ends the test green. A scenario that cannot run is a
|
|
264
|
+
// failure or an explicit, reviewed ignore (CutRight crates/cutright-ui-e2e/tests/*). The ordinal counts every SKIP print in the
|
|
265
|
+
// file, so fixing one does not renumber the others.
|
|
266
|
+
function scenarioSkip({ root, files }) {
|
|
267
|
+
const out = [];
|
|
268
|
+
for (const file of rustFiles(files)) {
|
|
269
|
+
const text = readText(root, file);
|
|
270
|
+
if (!text || !text.includes(".scenario(") || !text.includes("SKIP")) continue;
|
|
271
|
+
const lines = text.split("\n");
|
|
272
|
+
let ordinal = 0;
|
|
273
|
+
lines.forEach((line, i) => {
|
|
274
|
+
if (!/\be?println!\s*\(\s*"SKIP/.test(line) || /^\s*\/\//.test(line)) return;
|
|
275
|
+
ordinal += 1;
|
|
276
|
+
if (!lines.slice(i, i + 4).some((l) => /\breturn\s*;/.test(l))) return;
|
|
277
|
+
out.push({ file, subject: `${file}#${ordinal}`, problem: `${file}:${i + 1} prints SKIP and returns inside a scenario test: the skip reports green`, fix: "fail the test (panic or assert) when the scenario cannot run, or mark it #[ignore = \"why\"] so the skip is counted" });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The text of the `{...}` block that opens at `open` (braces in strings are not special-cased), or "" when unbalanced. */
|
|
284
|
+
function braceBlock(text, open) {
|
|
285
|
+
let depth = 0;
|
|
286
|
+
for (let i = open; i < text.length; i++) {
|
|
287
|
+
if (text[i] === "{") depth += 1;
|
|
288
|
+
else if (text[i] === "}" && --depth === 0) return text.slice(open, i + 1);
|
|
289
|
+
}
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Profile names a Cargo manifest defines ([profile.<name>] and [profile.<name>.package...]). */
|
|
294
|
+
const cargoProfiles = (text) => new Set([...String(text ?? "").matchAll(/^\s*\[profile\.("[^"]+"|[A-Za-z0-9_-]+)/gm)].map((m) => m[1].replaceAll('"', "")));
|
|
295
|
+
const BUILTIN_PROFILES = new Set(["dev", "release", "test", "bench"]);
|
|
296
|
+
// The providers packages/release accepts: publish-update.mjs takes the first two for win and mac; the other two are the iOS
|
|
297
|
+
// (app-store-connect) and Android (google-play) lanes, whose configs sit in the same file.
|
|
298
|
+
const RELEASE_PROVIDERS = new Set(["r2-rightapps", "github-releases", "app-store-connect", "google-play"]);
|
|
299
|
+
|
|
300
|
+
/** Profiles the nearest workspace Cargo.toml above `dir` defines; with no manifest on the way up to the root, every manifest's. */
|
|
301
|
+
function profilesFor(root, files, dir) {
|
|
302
|
+
let first = null;
|
|
303
|
+
for (let d = dir; ; d = dirOf(d)) {
|
|
304
|
+
const text = readText(root, d === "." ? "Cargo.toml" : `${d}/Cargo.toml`);
|
|
305
|
+
if (text !== null) { first ??= text; if (/^\s*\[workspace\]/m.test(text)) return cargoProfiles(text); }
|
|
306
|
+
if (d === ".") break;
|
|
307
|
+
}
|
|
308
|
+
if (first !== null) return cargoProfiles(first);
|
|
309
|
+
return new Set(files.filter((f) => /(^|\/)Cargo\.toml$/.test(f) && scanned(f)).flatMap((f) => [...cargoProfiles(readText(root, f))]));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// G16: what a release config says that packages/release refuses later, minutes into a build: a provider publish-update.mjs does not
|
|
313
|
+
// know, an updater keyFile (sign-updater.mjs: "--key-file is removed"), a Cargo profile nothing defines (ScrapeRight release-iterate).
|
|
314
|
+
function releaseConfig({ root, files }) {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const file of configFiles(files)) {
|
|
317
|
+
const raw = readText(root, file);
|
|
318
|
+
if (raw === null) continue;
|
|
319
|
+
const text = isJsonConfig(file) ? raw : blankComments(raw);
|
|
320
|
+
const add = (kind, value, offset, why, fix) => out.push({ file, subject: `${kind}:${value}`, problem: `${file}:${lineOf(text, offset)} ${why}`, fix });
|
|
321
|
+
const badProvider = (provider, offset) => add("provider", provider, offset, `distribution provider "${provider}" is refused by packages/release/publish-update.mjs (it takes r2-rightapps or github-releases)`, 'set provider: "r2-rightapps" (private) or "github-releases" (public)');
|
|
322
|
+
const keyFile = (value, offset) => add("keyFile", value.slice(0, 60), offset, "updater keyFile: sign-updater.mjs refuses --key-file; updater keys come from the OS credential provider", "remove keyFile; the updater key is read from the credential store");
|
|
323
|
+
const profiles = [];
|
|
324
|
+
if (isJsonConfig(file)) {
|
|
325
|
+
let config;
|
|
326
|
+
try { config = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch (error) {
|
|
327
|
+
add("json", "invalid", 0, `is not valid JSON (${error.message}): right-release refuses to load it`, "fix the JSON; the release config is strict JSON (no comments or trailing commas)");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
for (const [key, value, parent] of jsonEntries(config)) {
|
|
331
|
+
if (key === "provider" && parent === "distribution" && typeof value === "string" && !RELEASE_PROVIDERS.has(value)) badProvider(value, text.indexOf(JSON.stringify(value)));
|
|
332
|
+
if (key === "keyFile" && parent === "updater") keyFile(JSON.stringify(value), jsonKeyOffset(text, "keyFile"));
|
|
333
|
+
if (key === "profile" && (parent === "winPackage" || parent === "macPackage") && typeof value === "string") profiles.push([value, text.indexOf(JSON.stringify(value))]);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
for (const m of text.matchAll(/\bprovider\s*:\s*(["'`])([^"'`]+)\1/g)) if (!RELEASE_PROVIDERS.has(m[2])) badProvider(m[2], m.index);
|
|
337
|
+
for (const m of text.matchAll(/\bupdater\s*:\s*\{/g)) {
|
|
338
|
+
const block = braceBlock(text, m.index + m[0].length - 1);
|
|
339
|
+
const key = /\bkeyFile\s*:\s*([^,\n}]+)/.exec(block);
|
|
340
|
+
if (key) keyFile(key[1].trim(), m.index + key.index);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
profiles.push(...[...text.matchAll(/(["'])--profile\1\s*,\s*(["'])([^"']+)\2/g)].map((m) => [m[3], m.index])
|
|
344
|
+
.concat([...text.matchAll(/(["'])--profile=([^"']+)\1/g)].map((m) => [m[2], m.index]))
|
|
345
|
+
.concat([...text.matchAll(/\bPROFILE\s*(?:\?\?|\|\|)\s*(["'])([^"']+)\1/g)].map((m) => [m[2], m.index])));
|
|
346
|
+
if (profiles.length) {
|
|
347
|
+
const defined = profilesFor(root, files, dirOf(file));
|
|
348
|
+
for (const [name, offset] of profiles) {
|
|
349
|
+
if (!BUILTIN_PROFILES.has(name) && !defined.has(name)) add("profile", name, offset, `Cargo profile "${name}" is not dev, release, test or bench and no [profile.${name}] is in the nearest workspace Cargo.toml (cargo refuses it)`, `define [profile.${name}] in the workspace Cargo.toml, or name an existing profile`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const packageManifests = (root, files) => files.filter((f) => /(^|\/)package\.json$/.test(f) && scanned(f)).map((file) => ({ file, text: readText(root, file), manifest: readJson(root, file) })).filter((p) => p.manifest?.scripts && typeof p.manifest.scripts === "object");
|
|
357
|
+
const scriptLine = (text, name) => lineOf(text, Math.max(0, text.indexOf(JSON.stringify(name))));
|
|
358
|
+
|
|
359
|
+
// G17: right-release build --skip-checks turns off the gates the release exists to run.
|
|
360
|
+
function skipChecks({ root, files }) {
|
|
361
|
+
const out = [];
|
|
362
|
+
for (const { file, text, manifest } of packageManifests(root, files)) {
|
|
363
|
+
for (const [name, command] of Object.entries(manifest.scripts)) {
|
|
364
|
+
if (typeof command === "string" && /\bright-release\b/.test(command) && /(^|\s)--skip-checks(\s|=|$)/.test(command)) out.push({ file, subject: name, problem: `${file}:${scriptLine(text, name)} script "${name}" runs right-release with --skip-checks`, fix: "drop --skip-checks; fix the finding it hides, or record a reviewed exception in the release config" });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// G18: a debug build without the custom-protocol feature loads the dev server, so `tauri dev` under a qa script tests the dev
|
|
371
|
+
// server, not the app that ships. QA runs the built app.
|
|
372
|
+
function qaDevServer({ root, files }) {
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const { file, text, manifest } of packageManifests(root, files)) {
|
|
375
|
+
for (const [name, command] of Object.entries(manifest.scripts)) {
|
|
376
|
+
if (/^qa/.test(name) && typeof command === "string" && /\btauri\s+dev\b/.test(command)) out.push({ file, subject: name, problem: `${file}:${scriptLine(text, name)} script "${name}" runs \`tauri dev\`: a debug build without custom-protocol loads the dev server, so QA tests the dev server, not the app`, fix: "run QA against the built app (tauri build --debug with custom-protocol, or the installed app)" });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// G19: ffmpeg's release gate is the point of crates/rightkit-ffmpeg; allow_in_release_builds outside it and its tests opens a
|
|
383
|
+
// path that ships a build the gate would have refused.
|
|
384
|
+
function ffmpegReleaseEscape({ root, files }) {
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const file of rustFiles(files)) {
|
|
387
|
+
if (/(^|\/)tests\//.test(file) || /(^|\/)(?:tests|[^/]*_tests?)\.rs$/.test(file) || /(^|\/)crates\/rightkit-ffmpeg\//.test(file)) continue;
|
|
388
|
+
const text = readText(root, file);
|
|
389
|
+
if (!text || !text.includes("allow_in_release_builds") || /^\s*#!\[cfg\(test\)\]/m.test(text)) continue;
|
|
390
|
+
const testModule = /^#\[cfg\(test\)\]/m.exec(text)?.index ?? Infinity;
|
|
391
|
+
const hit = [...blankComments(text).matchAll(/allow_in_release_builds/g)].find((m) => m.index < testModule);
|
|
392
|
+
if (hit) out.push({ file, subject: file, problem: `${file}:${lineOf(text, hit.index)} uses allow_in_release_builds outside test code: a release build could ship what the ffmpeg gate refuses`, fix: "remove it from non-test code; only tests and crates/rightkit-ffmpeg itself may name it" });
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// G20: the release-switches scan proves a release binary carries no QA or dev switch, from the app's inventory of them. A
|
|
398
|
+
// config without one has nothing to prove against.
|
|
399
|
+
function switchesInventory({ root, files }) {
|
|
400
|
+
const out = [];
|
|
401
|
+
for (const file of configFiles(files)) {
|
|
402
|
+
const raw = readText(root, file);
|
|
403
|
+
if (raw === null) continue;
|
|
404
|
+
const text = isJsonConfig(file) ? raw : blankComments(raw);
|
|
405
|
+
const dir = dirOf(file);
|
|
406
|
+
let block;
|
|
407
|
+
let named;
|
|
408
|
+
if (isJsonConfig(file)) {
|
|
409
|
+
let config;
|
|
410
|
+
try { config = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch { config = null; }
|
|
411
|
+
const switches = config?.releaseSwitches;
|
|
412
|
+
if (switches && typeof switches === "object") block = { index: jsonKeyOffset(text, "releaseSwitches") };
|
|
413
|
+
if (typeof switches?.inventory === "string") named = switches.inventory;
|
|
414
|
+
} else {
|
|
415
|
+
block = /\breleaseSwitches\s*:\s*\{/.exec(text);
|
|
416
|
+
named = block ? /\binventory\s*:\s*(["'`])([^"'`]+)\1/.exec(braceBlock(text, block.index + block[0].length - 1))?.[2] : undefined;
|
|
417
|
+
}
|
|
418
|
+
const inventory = path.posix.normalize(path.posix.join(dir, named ?? "switches.json"));
|
|
419
|
+
if (fs.existsSync(path.join(root, inventory))) continue;
|
|
420
|
+
out.push({ file, subject: dir, problem: `${file}:${block ? lineOf(text, block.index) : 1} has no switches inventory (${inventory} does not exist)`, fix: named ? `commit ${inventory} or point releaseSwitches.inventory at the app's inventory` : `add ${path.posix.join(dir, "switches.json")} listing the app's QA and dev switches (see packages/release/fixtures/release-switches/app/switches.json)` });
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// G21: Speech.framework must stay out of RightKitSwift entirely (rightkit#978: Apple Speech is removed; Parakeet and Whisper are the
|
|
426
|
+
// only engines). swiftc writes the framework into the object file of every module that imports it, so one `import Speech` in any
|
|
427
|
+
// RightKitSwift source or test links it into the apps that take that module (HeardRight's App Store policy). The link probe
|
|
428
|
+
// (swift/RightKitSwift/LinkProbes/SpeechNoApple, exempt here: its SpeechControl target is the one sanctioned import) is the proof;
|
|
429
|
+
// this is the seconds-fast early signal.
|
|
430
|
+
const SPEECH_FREE = /^swift\/RightKitSwift\/(?:Sources|Tests)\/.+\.swift$/;
|
|
431
|
+
const IMPORT_SPEECH = /^[ \t]*(?:@\w+(?:\([^)]*\))?[ \t]+)*(?:(?:public|internal|package|private|fileprivate|open)[ \t]+)?import[ \t]+(?:(?:struct|class|enum|protocol|typealias|func|var|let)[ \t]+)?Speech\b/m;
|
|
432
|
+
function speechLink({ root, files }) {
|
|
433
|
+
const out = [];
|
|
434
|
+
for (const file of files.filter((f) => SPEECH_FREE.test(f))) {
|
|
435
|
+
const text = readText(root, file);
|
|
436
|
+
if (text === null) continue;
|
|
437
|
+
const hit = IMPORT_SPEECH.exec(blankComments(text));
|
|
438
|
+
if (hit) out.push({ file, subject: file, problem: `${file}:${lineOf(text, hit.index)} imports Speech: RightKitSwift must not link Speech.framework (Apple Speech was removed, rightkit#978)`, fix: "delete the import; Parakeet and Whisper are the only speech engines" });
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// G22: the broker compiles; it does not run end-to-end tests. `cargo test`, `cargo run` and `cargo nextest` (plain, through the
|
|
444
|
+
// RustRight client as `rustright cargo`, or as `rightkit` while the retired alias lasts) run the test binary or the program inside the
|
|
445
|
+
// broker job, which holds the compile slot for the whole run (Pulse, 2026-10-23: 55 minutes with 15 builds queued) and has no GUI
|
|
446
|
+
// session or unscrubbed environment a real-app test needs. The fix is `--no-run` to compile and `rightkit-qa run-tests` to run
|
|
447
|
+
// outside the broker (crates/rightkit-qa/docs/run-tests.md). Found from files alone, in three steps:
|
|
448
|
+
// 1. the end-to-end crates: a Cargo package that depends on rightkit-qa not optionally (the optional dependency is the in-app control
|
|
449
|
+
// server behind a qa-native / dev-control feature) and has a .rs file, outside examples/, that names a driver module of it;
|
|
450
|
+
// 2. the commands: `cargo test|run|nextest` in a package.json script, a shell, PowerShell, batch, Node or Python script, a Makefile
|
|
451
|
+
// or justfile recipe, a GitHub workflow `run:` step, a git hook, or a code span of a runbook document;
|
|
452
|
+
// 3. the selection: a command that is not `--no-run` or a rightkit-qa run-tests / journeys run, and names an end-to-end crate by
|
|
453
|
+
// -p / --package, --manifest-path, --workspace / --all (a member of the workspace in the working directory), --test or --bin.
|
|
454
|
+
// Known limits, deliberate for an offline check: no cargo binary (a path dependency that cargo adds to a workspace implicitly is not
|
|
455
|
+
// a member here), a command assembled from variables is matched on its literal text only, a package.json script that calls another
|
|
456
|
+
// script is judged where the cargo text is, a cargo alias (`cargo t`) is not matched, and a bare `cargo test` selects nothing.
|
|
457
|
+
const E2E_DRIVER_MODULES = new Set(["harness", "control", "suite", "scenario", "engine", "in_app", "interactive", "page", "browser", "android"]);
|
|
458
|
+
const E2E_GUARD_TARGET = "qa_surface_guard"; // the one test target of an end-to-end crate that only runs the static guard
|
|
459
|
+
const E2E_NAME_ONLY = /-e2e$|^qa-/; // a name is not evidence: crates that match only this are listed for a reviewer by e2eCrates()
|
|
460
|
+
|
|
461
|
+
const unquote = (text) => String(text).replace(/^(["'])(.*)\1$/, "$2");
|
|
462
|
+
const escapeRegExp = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
463
|
+
/** A dotted TOML key split at the dots outside quotes: `target.'cfg(unix)'.dependencies` is three parts. */
|
|
464
|
+
const tomlPath = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'|([^\s.'"]+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
465
|
+
const tomlStrings = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
|
|
466
|
+
const uncomment = (line) => line.replace(/^\s*#.*$/, "").replace(/\s+#.*$/, "");
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* What the end-to-end check reads from a Cargo manifest, literal values only: the package name, whether it holds a [workspace] and its
|
|
470
|
+
* members and exclude, the dependencies of [dependencies], [dev-dependencies] and their [target.*] tables (inline, dotted-key and
|
|
471
|
+
* `[dependencies.name]` forms, each with optional, package and workspace), [workspace.dependencies] as key -> package, and the
|
|
472
|
+
* [[test]] and [[bin]] tables. Build dependencies are not dependencies of the tests.
|
|
473
|
+
*/
|
|
474
|
+
function parseCargoManifest(text) {
|
|
475
|
+
const manifest = { name: null, workspace: false, members: [], exclude: [], dependencies: [], workspaceDependencies: new Map(), tests: [], bins: [] };
|
|
476
|
+
const entries = new Map();
|
|
477
|
+
const dependency = (table, key) => {
|
|
478
|
+
const id = `${table}\0${key}`;
|
|
479
|
+
if (!entries.has(id)) { const entry = { key, optional: false, package: undefined, workspace: false }; entries.set(id, entry); manifest.dependencies.push(entry); }
|
|
480
|
+
return entries.get(id);
|
|
481
|
+
};
|
|
482
|
+
const setAttribute = (entry, attribute, value) => {
|
|
483
|
+
if (attribute === "optional") entry.optional = entry.optional || value === "true";
|
|
484
|
+
else if (attribute === "package") entry.package = tomlStrings(value)[0] ?? entry.package;
|
|
485
|
+
else if (attribute === "workspace") entry.workspace = entry.workspace || value === "true";
|
|
486
|
+
};
|
|
487
|
+
const setInline = (entry, value) => {
|
|
488
|
+
if (/\boptional\s*=\s*true\b/.test(value)) entry.optional = true;
|
|
489
|
+
const renamed = /\bpackage\s*=\s*(?:"([^"]+)"|'([^']+)')/.exec(value);
|
|
490
|
+
if (renamed) entry.package = renamed[1] ?? renamed[2];
|
|
491
|
+
if (/\bworkspace\s*=\s*true\b/.test(value)) entry.workspace = true;
|
|
492
|
+
};
|
|
493
|
+
const lines = String(text ?? "").split("\n");
|
|
494
|
+
let section = { kind: "other" };
|
|
495
|
+
for (let i = 0; i < lines.length; i++) {
|
|
496
|
+
const line = uncomment(lines[i]);
|
|
497
|
+
const header = /^\s*(\[\[?)\s*(.+?)\s*\]\]?\s*$/.exec(line);
|
|
498
|
+
if (header) {
|
|
499
|
+
const parts = tomlPath(header[2]);
|
|
500
|
+
if (header[1] === "[[") {
|
|
501
|
+
const item = parts[0] === "test" || parts[0] === "bin" ? { name: undefined, path: undefined } : null;
|
|
502
|
+
if (item) manifest[parts[0] === "test" ? "tests" : "bins"].push(item);
|
|
503
|
+
section = item ? { kind: "target", item } : { kind: "other" };
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const at = parts[0] === "dependencies" || parts[0] === "dev-dependencies" ? 0 : parts[0] === "target" && (parts[2] === "dependencies" || parts[2] === "dev-dependencies") ? 2 : -1;
|
|
507
|
+
if (parts[0] === "package" && parts.length === 1) section = { kind: "package" };
|
|
508
|
+
else if (parts[0] === "workspace") {
|
|
509
|
+
manifest.workspace = true;
|
|
510
|
+
section = parts.length === 1 ? { kind: "workspace" } : parts[1] === "dependencies" ? (parts.length === 2 ? { kind: "workspace-dependencies" } : { kind: "workspace-dependency", key: parts[2] }) : { kind: "other" };
|
|
511
|
+
} else if (at >= 0) section = parts.length === at + 1 ? { kind: "dependencies", table: parts.join(".") } : { kind: "dependency", table: parts.slice(0, at + 1).join("."), key: parts[at + 1] };
|
|
512
|
+
else section = { kind: "other" };
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const kv = /^\s*("[^"]+"|'[^']+'|[A-Za-z0-9_-]+)(?:\s*\.\s*("[^"]+"|'[^']+'|[A-Za-z0-9_-]+))?\s*=\s*(.*?)\s*$/.exec(line);
|
|
516
|
+
if (!kv) continue;
|
|
517
|
+
const key = unquote(kv[1]);
|
|
518
|
+
const attribute = kv[2] === undefined ? undefined : unquote(kv[2]);
|
|
519
|
+
const value = kv[3];
|
|
520
|
+
switch (section.kind) {
|
|
521
|
+
case "package":
|
|
522
|
+
if (key === "name" && attribute === undefined) manifest.name = tomlStrings(value)[0] ?? null;
|
|
523
|
+
break;
|
|
524
|
+
case "workspace":
|
|
525
|
+
if ((key === "members" || key === "exclude") && attribute === undefined) {
|
|
526
|
+
let list = value;
|
|
527
|
+
if (list.startsWith("[")) while (!list.includes("]") && i + 1 < lines.length) list += ` ${uncomment(lines[++i])}`;
|
|
528
|
+
manifest[key].push(...tomlStrings(list));
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
case "target":
|
|
532
|
+
if ((key === "name" || key === "path") && attribute === undefined) section.item[key] = tomlStrings(value)[0];
|
|
533
|
+
break;
|
|
534
|
+
case "dependencies":
|
|
535
|
+
if (attribute === undefined) setInline(dependency(section.table, key), value); else setAttribute(dependency(section.table, key), attribute, value);
|
|
536
|
+
break;
|
|
537
|
+
case "dependency":
|
|
538
|
+
if (attribute === undefined) setAttribute(dependency(section.table, section.key), key, value);
|
|
539
|
+
break;
|
|
540
|
+
case "workspace-dependencies":
|
|
541
|
+
if (attribute === undefined) manifest.workspaceDependencies.set(key, /\bpackage\s*=\s*(?:"([^"]+)"|'([^']+)')/.exec(value)?.slice(1).find(Boolean) ?? key);
|
|
542
|
+
else if (attribute === "package") manifest.workspaceDependencies.set(key, tomlStrings(value)[0] ?? key);
|
|
543
|
+
break;
|
|
544
|
+
case "workspace-dependency":
|
|
545
|
+
if (key === "package" && attribute === undefined) manifest.workspaceDependencies.set(section.key, tomlStrings(value)[0] ?? section.key);
|
|
546
|
+
else if (!manifest.workspaceDependencies.has(section.key)) manifest.workspaceDependencies.set(section.key, section.key);
|
|
547
|
+
break;
|
|
548
|
+
default:
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return manifest;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** A `[workspace] members` pattern (`crates/*`, `apps/**`, `./x`) as a regular expression over a posix path; a pattern that is no
|
|
555
|
+
* valid glob matches nothing (the rule must not throw on a manifest cargo would refuse). */
|
|
556
|
+
function memberPattern(glob) {
|
|
557
|
+
const source = String(glob).replace(/^\.\//, "").replace(/\/+$/, "").split("**").map((part) => part.replace(/[.+^$(){}|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
|
|
558
|
+
try { return new RegExp(`^${source}$`); } catch { return /$^/; }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** True when the Rust source names a driver module of rightkit-qa through any of `names` (its crate identifier(s) in this package):
|
|
562
|
+
* `rightkit_qa::harness`, or a `use rightkit_qa::{...}` list with a driver module among its top-level items. Comments do not count. */
|
|
563
|
+
function namesDriverModule(source, names) {
|
|
564
|
+
const text = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/\/\/[^\n]*/g, "");
|
|
565
|
+
for (const name of names) {
|
|
566
|
+
for (const m of text.matchAll(new RegExp(`(?<!\\w)${escapeRegExp(name)}\\s*::\\s*(\\{|[A-Za-z_]\\w*)`, "g"))) {
|
|
567
|
+
if (m[1] !== "{") { if (E2E_DRIVER_MODULES.has(m[1])) return true; continue; }
|
|
568
|
+
const list = braceBlock(text, m.index + m[0].length - 1).slice(1, -1);
|
|
569
|
+
let depth = 0, item = "";
|
|
570
|
+
const items = [];
|
|
571
|
+
for (const ch of list) {
|
|
572
|
+
if (ch === "{") depth += 1; else if (ch === "}") depth -= 1;
|
|
573
|
+
if (ch === "," && depth === 0) { items.push(item); item = ""; } else item += ch;
|
|
574
|
+
}
|
|
575
|
+
items.push(item);
|
|
576
|
+
if (items.some((it) => E2E_DRIVER_MODULES.has(/^\s*([A-Za-z_]\w*)/.exec(it)?.[1]))) return true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The Cargo layout of a tree and the end-to-end crates in it. `crates` are the end-to-end ones as { name, dir, tests, bins } (a
|
|
584
|
+
* package that depends on rightkit-qa not optionally and has a non-example .rs file naming one of its driver modules); `nameOnly`
|
|
585
|
+
* lists packages named like one (`*-e2e`, `qa-*`) that are not, for a reviewer: a name is not evidence.
|
|
586
|
+
*/
|
|
587
|
+
export function e2eCrates(root, files) {
|
|
588
|
+
const manifests = new Map();
|
|
589
|
+
for (const file of files.filter((f) => /(^|\/)Cargo\.toml$/.test(f) && scanned(f))) {
|
|
590
|
+
const text = readText(root, file);
|
|
591
|
+
if (text !== null) manifests.set(dirOf(file), parseCargoManifest(text));
|
|
592
|
+
}
|
|
593
|
+
/** The package directory a file belongs to: the nearest directory above it with a Cargo.toml, when that holds a [package]. */
|
|
594
|
+
const ownerOf = (file) => {
|
|
595
|
+
for (let dir = dirOf(file); ; dir = dirOf(dir)) {
|
|
596
|
+
const manifest = manifests.get(dir);
|
|
597
|
+
if (manifest) return manifest.name === null ? null : dir;
|
|
598
|
+
if (dir === ".") return null;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
const packageOf = (dep, dir) => {
|
|
602
|
+
if (dep.package) return dep.package;
|
|
603
|
+
if (dep.workspace) {
|
|
604
|
+
for (let d = dir; ; d = dirOf(d)) {
|
|
605
|
+
const inherited = manifests.get(d)?.workspaceDependencies.get(dep.key);
|
|
606
|
+
if (inherited) return inherited;
|
|
607
|
+
if (d === ".") break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return dep.key;
|
|
611
|
+
};
|
|
612
|
+
const identifiers = new Map(); // package dir -> how Rust code names rightkit-qa there (the key, with - as _)
|
|
613
|
+
for (const [dir, manifest] of manifests) {
|
|
614
|
+
if (manifest.name === null) continue;
|
|
615
|
+
const names = manifest.dependencies.filter((dep) => !dep.optional && packageOf(dep, dir) === "rightkit-qa").map((dep) => dep.key.replaceAll("-", "_"));
|
|
616
|
+
if (names.length) identifiers.set(dir, [...new Set(names)]);
|
|
617
|
+
}
|
|
618
|
+
const driving = new Set();
|
|
619
|
+
if (identifiers.size) {
|
|
620
|
+
for (const file of rustFiles(files)) {
|
|
621
|
+
const dir = ownerOf(file);
|
|
622
|
+
if (dir === null || !identifiers.has(dir) || driving.has(dir) || (dir === "." ? file : file.slice(dir.length + 1)).startsWith("examples/")) continue;
|
|
623
|
+
const text = readText(root, file);
|
|
624
|
+
if (text !== null && identifiers.get(dir).some((name) => text.includes(name)) && namesDriverModule(text, identifiers.get(dir))) driving.add(dir);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const crates = [...driving].sort().map((dir) => {
|
|
628
|
+
const manifest = manifests.get(dir);
|
|
629
|
+
const within = (file) => (dir === "." ? file : file.startsWith(`${dir}/`) ? file.slice(dir.length + 1) : null);
|
|
630
|
+
const tests = new Set(manifest.tests.map((t) => t.name).filter(Boolean));
|
|
631
|
+
const bins = new Set(manifest.bins.map((b) => b.name).filter(Boolean));
|
|
632
|
+
for (const file of files) {
|
|
633
|
+
const rel = within(file);
|
|
634
|
+
if (rel === null || !scanned(file) || ownerOf(file) !== dir) continue;
|
|
635
|
+
const test = /^tests\/([^/]+)(?:\.rs|\/main\.rs)$/.exec(rel);
|
|
636
|
+
if (test) tests.add(test[1]);
|
|
637
|
+
const bin = /^src\/bin\/([^/]+)(?:\.rs|\/main\.rs)$/.exec(rel);
|
|
638
|
+
if (bin) bins.add(bin[1]);
|
|
639
|
+
if (rel === "src/main.rs") bins.add(manifest.name);
|
|
640
|
+
}
|
|
641
|
+
return { name: manifest.name, dir, tests, bins };
|
|
642
|
+
});
|
|
643
|
+
const nameOnly = [...manifests].filter(([dir, m]) => m.name !== null && E2E_NAME_ONLY.test(m.name) && !driving.has(dir)).map(([, m]) => m.name).sort();
|
|
644
|
+
/** The end-to-end crates that are members of the workspace rooted at `wsDir`: its root package, and every crate that matches a
|
|
645
|
+
* `members` glob, is not under `exclude` and is not a workspace root itself (a nested [workspace] makes a crate its own root). */
|
|
646
|
+
const membersOf = (wsDir) => {
|
|
647
|
+
const ws = manifests.get(wsDir);
|
|
648
|
+
if (!ws?.workspace) return [];
|
|
649
|
+
const included = ws.members.map(memberPattern);
|
|
650
|
+
const excluded = ws.exclude.map((x) => String(x).replace(/^\.\//, "").replace(/\/+$/, ""));
|
|
651
|
+
return crates.filter((c) => {
|
|
652
|
+
if (c.dir === wsDir) return true;
|
|
653
|
+
const rel = wsDir === "." ? c.dir : c.dir.startsWith(`${wsDir}/`) ? c.dir.slice(wsDir.length + 1) : null;
|
|
654
|
+
return rel !== null && !manifests.get(c.dir).workspace && included.some((re) => re.test(rel)) && !excluded.some((x) => rel === x || rel.startsWith(`${x}/`));
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
return { crates, nameOnly, manifests, membersOf };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const CARGO_COMMAND = /(?<![\w-])((?:(?:rustright|rightkit)\s+)?cargo)\s+(?:\+\S+\s+)?(test|run|nextest)\b([^&;|`\n]*)/g;
|
|
661
|
+
const CD_COMMAND = /(?<![\w./-])(?:cd|Set-Location)\s+(?:-(?:Path|LiteralPath)\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi;
|
|
662
|
+
const COMMENT_LINE = /^\s*(?:#|\/\/|\/?\*|;|::|REM\b)/i;
|
|
663
|
+
const QA_RUNNER = /\brightkit-qa(?:\.exe)?\s+(?:run-tests|journeys\s+run)\b/;
|
|
664
|
+
const NEXTEST_NO_TESTS = new Set("list archive show-config self help".split(" ")); // nextest subcommands that run no test
|
|
665
|
+
const HISTORY_DOC = /^docs\/(?:archive|plans|provenance|handoff[^/]*)\/|(?:^|\/)(?:tasks|evidence)\/|(?:^|\/)(?:changelog|pending|status|state)\.md$/i;
|
|
666
|
+
const HOOK_FILE = /(?:^|\/)(?:\.githooks|\.husky|githooks)\/(?!_\/)[^/]+/;
|
|
667
|
+
const WORKFLOW_FILE = /^\.github\/(?:workflows\/[^/]+|actions\/.+\/action)\.ya?ml$/;
|
|
668
|
+
const BUILD_FILE = /(?:^|\/)(?:Makefile|makefile|GNUmakefile|[Jj]ustfile|\.justfile)$|\.mk$/;
|
|
669
|
+
const SCRIPT_FILE = /\.(?:sh|bash|zsh|ps1|psm1|bat|cmd|mjs|cjs|js|py)$/i;
|
|
670
|
+
|
|
671
|
+
/** A script's `cd` / `Set-Location` target applied to `cwd` (a posix path from the repository root). A target that is not a plain
|
|
672
|
+
* relative path (a variable, a substitution, an absolute path, one that leaves the repository) leaves the directory as it was: the
|
|
673
|
+
* `cd "$(dirname "$0")/.."` of a script under scripts/ is the repository root, where a script starts. */
|
|
674
|
+
function changeDirectory(cwd, target) {
|
|
675
|
+
const t = unquote(target);
|
|
676
|
+
if (!t || /[$`(%~]/.test(t) || /^([\\/]|[A-Za-z]:)/.test(t) || t === "-") return cwd;
|
|
677
|
+
const next = path.posix.normalize(path.posix.join(cwd, t.replaceAll("\\", "/")));
|
|
678
|
+
return next === ".." || next.startsWith("../") ? cwd : next;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The logical lines of physical `lines` ({ no, text }): comment lines dropped, and a line that ends with `continuation` joined to the
|
|
682
|
+
* next, numbered by the line the command starts on. */
|
|
683
|
+
function logicalLines(lines, continuation) {
|
|
684
|
+
const out = [];
|
|
685
|
+
let pending = null;
|
|
686
|
+
for (const line of lines) {
|
|
687
|
+
if (!pending && COMMENT_LINE.test(line.text)) continue;
|
|
688
|
+
const text = line.text.replace(/\s+$/, "");
|
|
689
|
+
const joined = pending ? `${pending.text} ${text.trim()}` : text;
|
|
690
|
+
const no = pending?.no ?? line.no;
|
|
691
|
+
if (continuation && text.endsWith(continuation)) { pending = { no, text: joined.slice(0, -continuation.length) }; continue; }
|
|
692
|
+
pending = null;
|
|
693
|
+
out.push({ no, text: joined });
|
|
694
|
+
}
|
|
695
|
+
if (pending) out.push(pending);
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** The `run:` steps of a GitHub workflow or composite action: { lines, cwd } where cwd is the step's working-directory (the
|
|
700
|
+
* repository root when it has none, or one that is an expression). */
|
|
701
|
+
function workflowSteps(text) {
|
|
702
|
+
const lines = text.split("\n");
|
|
703
|
+
const indent = (l) => l.length - l.trimStart().length;
|
|
704
|
+
const steps = [];
|
|
705
|
+
lines.forEach((raw, i) => {
|
|
706
|
+
const m = /^(\s*)(-\s+)?run:\s*(.*?)\s*$/.exec(raw);
|
|
707
|
+
if (!m) return;
|
|
708
|
+
const keyColumn = m[1].length + (m[2]?.length ?? 0);
|
|
709
|
+
const value = m[3];
|
|
710
|
+
let block;
|
|
711
|
+
if (/^[|>][+-]?\d*(?:\s+#.*)?$/.test(value)) {
|
|
712
|
+
block = [];
|
|
713
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
714
|
+
if (lines[j].trim() === "") continue;
|
|
715
|
+
if (indent(lines[j]) <= keyColumn) break;
|
|
716
|
+
block.push({ no: j + 1, text: lines[j].trim() });
|
|
717
|
+
}
|
|
718
|
+
// A folded scalar (`>`) is one line to the shell.
|
|
719
|
+
if (value.startsWith(">") && block.length) block = [{ no: block[0].no, text: block.map((l) => l.text).join(" ") }];
|
|
720
|
+
} else {
|
|
721
|
+
const plain = /^(["'])(.*)\1$/.test(value) ? unquote(value) : value.replace(/\s+#.*$/, "");
|
|
722
|
+
block = plain ? [{ no: i + 1, text: plain }] : [];
|
|
723
|
+
}
|
|
724
|
+
// The step's own working-directory sits beside `run:` (same column), on either side of it.
|
|
725
|
+
const sibling = (j) => { const k = /^(\s*)(-\s+)?working-directory:\s*(.*?)\s*$/.exec(lines[j]); return k && k[1].length + (k[2]?.length ?? 0) === keyColumn ? unquote(k[3].replace(/\s+#.*$/, "")) : null; };
|
|
726
|
+
let directory = null;
|
|
727
|
+
for (let j = i - 1; j >= 0 && directory === null; j--) {
|
|
728
|
+
if (lines[j].trim() === "") continue;
|
|
729
|
+
directory = sibling(j);
|
|
730
|
+
if (indent(lines[j]) < keyColumn) break;
|
|
731
|
+
}
|
|
732
|
+
for (let j = i + 1; j < lines.length && directory === null; j++) {
|
|
733
|
+
if (lines[j].trim() === "") continue;
|
|
734
|
+
if (indent(lines[j]) < keyColumn) break;
|
|
735
|
+
directory = sibling(j);
|
|
736
|
+
}
|
|
737
|
+
steps.push({ lines: block, cwd: directory === null ? "." : changeDirectory(".", directory) });
|
|
738
|
+
});
|
|
739
|
+
return steps;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** The command-bearing units of a runbook document: each fenced code block (its lines) and each inline code span (one line). Prose
|
|
743
|
+
* outside code is never a command. */
|
|
744
|
+
function markdownUnits(text) {
|
|
745
|
+
const units = [];
|
|
746
|
+
let fence = null;
|
|
747
|
+
text.split("\n").forEach((raw, i) => {
|
|
748
|
+
if (fence) {
|
|
749
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(raw);
|
|
750
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) { units.push(fence.lines); fence = null; } else fence.lines.push({ no: i + 1, text: raw });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const open = /^\s*(`{3,}|~{3,})/.exec(raw);
|
|
754
|
+
if (open) { fence = { char: open[1][0], length: open[1].length, lines: [] }; return; }
|
|
755
|
+
for (const span of raw.matchAll(/(`+)(.+?)\1(?!`)/g)) units.push([{ no: i + 1, text: span[2].trim() }]);
|
|
756
|
+
});
|
|
757
|
+
if (fence) units.push(fence.lines);
|
|
758
|
+
return units;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// G22: see the block comment above.
|
|
762
|
+
function e2eUnderCargoTest({ root, files }) {
|
|
763
|
+
const layout = e2eCrates(root, files);
|
|
764
|
+
if (!layout.crates.length) return [];
|
|
765
|
+
const out = [];
|
|
766
|
+
const seen = new Set();
|
|
767
|
+
const flagValues = (tokens, ...names) => tokens.flatMap((t, i) => names.flatMap((name) => (t === name ? (i + 1 < tokens.length ? [tokens[i + 1]] : []) : name.startsWith("--") && t.startsWith(`${name}=`) ? [t.slice(name.length + 1)] : [])));
|
|
768
|
+
/** The directory of the manifest a --manifest-path names (relative to the cwd, then to the repository root), when it holds one. */
|
|
769
|
+
const manifestDirectory = (value, cwd) => {
|
|
770
|
+
const base = value.replaceAll("\\", "/").replace(/(^|\/)Cargo\.toml$/, "") || ".";
|
|
771
|
+
if (path.posix.isAbsolute(base) || /^[A-Za-z]:/.test(base)) return null;
|
|
772
|
+
for (const from of [cwd, "."]) {
|
|
773
|
+
const dir = path.posix.normalize(path.posix.join(from, base));
|
|
774
|
+
if (dir !== ".." && !dir.startsWith("../") && layout.manifests.has(dir)) return dir;
|
|
775
|
+
}
|
|
776
|
+
return null;
|
|
777
|
+
};
|
|
778
|
+
/** The end-to-end crates a cargo command selects, each with the flag that did it: [{ crate, selector }], first rule first. */
|
|
779
|
+
const selected = (tokens, isRun, cwd) => {
|
|
780
|
+
const picks = new Map();
|
|
781
|
+
const pick = (crate, selector) => { if (!picks.has(crate)) picks.set(crate, selector); };
|
|
782
|
+
const packages = flagValues(tokens, "--package", "-p");
|
|
783
|
+
const manifestPaths = flagValues(tokens, "--manifest-path");
|
|
784
|
+
const tests = flagValues(tokens, "--test");
|
|
785
|
+
const wholeWorkspace = tokens.includes("--workspace") || tokens.includes("--all");
|
|
786
|
+
const excluded = new Set(flagValues(tokens, "--exclude"));
|
|
787
|
+
const guardOnly = tests.length > 0 && tests.every((t) => t === E2E_GUARD_TARGET); // `--test qa_surface_guard` runs the static guard only
|
|
788
|
+
const members = (dir) => layout.membersOf(dir).filter((c) => !excluded.has(c.name));
|
|
789
|
+
if (!guardOnly) {
|
|
790
|
+
for (const p of packages) for (const c of layout.crates.filter((crate) => crate.name === p)) pick(c, `-p ${p}`);
|
|
791
|
+
for (const mp of manifestPaths) {
|
|
792
|
+
const dir = manifestDirectory(mp, cwd);
|
|
793
|
+
if (dir === null) continue;
|
|
794
|
+
for (const c of layout.crates.filter((crate) => crate.dir === dir)) pick(c, `--manifest-path ${mp}`);
|
|
795
|
+
if (wholeWorkspace) for (const c of members(dir)) pick(c, `--manifest-path ${mp} ${tokens.includes("--workspace") ? "--workspace" : "--all"}`);
|
|
796
|
+
}
|
|
797
|
+
if (wholeWorkspace && !manifestPaths.length) for (const c of members(cwd)) pick(c, tokens.includes("--workspace") ? "--workspace" : "--all");
|
|
798
|
+
}
|
|
799
|
+
if (!packages.length && !manifestPaths.length) {
|
|
800
|
+
for (const t of tests) {
|
|
801
|
+
const owners = t === E2E_GUARD_TARGET ? [] : layout.crates.filter((c) => c.tests.has(t));
|
|
802
|
+
if (owners.length === 1) pick(owners[0], `--test ${t}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (isRun) for (const b of flagValues(tokens, "--bin")) for (const c of layout.crates.filter((crate) => crate.bins.has(b))) pick(c, `--bin ${b}`);
|
|
806
|
+
return [...picks].map(([crate, selector]) => ({ crate, selector }));
|
|
807
|
+
};
|
|
808
|
+
/** Scan logical lines ({ no, text }) that run in order from `cwd`; `what` names the place and `subject` overrides the crate name in the identity. */
|
|
809
|
+
const scan = (file, lines, cwd, what, subject) => {
|
|
810
|
+
for (const { no, text } of lines) {
|
|
811
|
+
if (!/cargo|\bcd\b|Set-Location/i.test(text)) continue; // a line with neither a command nor a directory change cannot matter
|
|
812
|
+
const events = [...text.matchAll(CD_COMMAND)].map((m) => ({ at: m.index, cd: m[1] })).concat([...text.matchAll(CARGO_COMMAND)].map((m) => ({ at: m.index, m })));
|
|
813
|
+
for (const event of events.sort((a, b) => a.at - b.at)) {
|
|
814
|
+
if (event.cd !== undefined) { cwd = changeDirectory(cwd, event.cd); continue; }
|
|
815
|
+
const [, client, verb] = event.m;
|
|
816
|
+
// A command inside a string literal ends at the quote that closes it: `run("rustright cargo test -p x")`.
|
|
817
|
+
let rest = event.m[3];
|
|
818
|
+
for (const quote of ['"', "'"]) {
|
|
819
|
+
if ((rest.split(quote).length - 1) % 2 === 1 && (text.slice(0, event.at).split(quote).length - 1) % 2 === 1) rest = rest.slice(0, rest.indexOf(quote));
|
|
820
|
+
}
|
|
821
|
+
const tokens = (rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((t) => t.replace(/["']/g, "").replace(/\)+$/, ""));
|
|
822
|
+
const ownArguments = tokens.indexOf("--") < 0 ? tokens : tokens.slice(0, tokens.indexOf("--"));
|
|
823
|
+
const segment = text.slice(Math.max(text.lastIndexOf("&", event.at), text.lastIndexOf(";", event.at), text.lastIndexOf("|", event.at), text.lastIndexOf("`", event.at)) + 1, event.at + event.m[0].length);
|
|
824
|
+
if (ownArguments.includes("--no-run") || QA_RUNNER.test(segment)) continue;
|
|
825
|
+
if (ownArguments.some((t) => t === "--help" || t === "-h" || t === "--version" || t === "-V")) continue;
|
|
826
|
+
if (verb === "nextest" && NEXTEST_NO_TESTS.has(tokens[0])) continue;
|
|
827
|
+
for (const { crate, selector } of selected(ownArguments, verb === "run", cwd)) {
|
|
828
|
+
const key = `${file}\0${no}\0${crate.name}`;
|
|
829
|
+
if (seen.has(key)) continue;
|
|
830
|
+
seen.add(key);
|
|
831
|
+
const via = `${client.replace(/\s+/g, " ")} ${verb} ${selector}`;
|
|
832
|
+
out.push({ file, subject: subject ?? crate.name, problem: `${file}:${no} ${what} runs the end-to-end crate ${crate.name} under cargo test (${via}): the test binary runs in the broker's context and holds its compile slot for the whole run`, fix: `compile it with --no-run and run it outside the broker: rightkit-qa run-tests -p ${crate.name} [--test <target>] -- <libtest args>` });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
for (const { file, text, manifest } of packageManifests(root, files)) {
|
|
838
|
+
for (const [name, command] of Object.entries(manifest.scripts)) {
|
|
839
|
+
if (typeof command === "string") scan(file, [{ no: scriptLine(text, name), text: command }], dirOf(file), `script "${name}"`, name);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
for (const file of files.filter(scanned)) {
|
|
843
|
+
const base = path.posix.basename(file);
|
|
844
|
+
const runbook = (file === "README.md" || file === "AGENTS.md" || file === "CLAUDE.md" || /^docs\/.+\.md$/.test(file)) && !HISTORY_DOC.test(file) && !/\d{4}-\d{2}-\d{2}/.test(base);
|
|
845
|
+
const workflow = WORKFLOW_FILE.test(file);
|
|
846
|
+
const hook = !workflow && HOOK_FILE.test(file);
|
|
847
|
+
const recipe = !workflow && !hook && BUILD_FILE.test(file);
|
|
848
|
+
const script = !workflow && !hook && !recipe && SCRIPT_FILE.test(file);
|
|
849
|
+
if (!(runbook || workflow || hook || recipe || script) || base === "package.json") continue;
|
|
850
|
+
const content = readText(root, file);
|
|
851
|
+
if (content === null || !/cargo/.test(content)) continue;
|
|
852
|
+
const continuation = /\.(?:ps1|psm1)$/i.test(file) ? "`" : /\.(?:bat|cmd)$/i.test(file) ? "^" : "\\";
|
|
853
|
+
const physical = content.split("\n").map((line, i) => ({ no: i + 1, text: line }));
|
|
854
|
+
if (workflow) for (const step of workflowSteps(content)) scan(file, logicalLines(step.lines, continuation), step.cwd, "workflow step");
|
|
855
|
+
else if (runbook) for (const unit of markdownUnits(content)) scan(file, logicalLines(unit, continuation), ".", "runbook command");
|
|
856
|
+
else scan(file, logicalLines(physical, continuation), ".", hook ? "git hook" : recipe ? "recipe" : "command");
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export const RULES = Object.freeze([
|
|
862
|
+
{ id: "eol-policy", gap: "G04", run: eolPolicy },
|
|
863
|
+
{ id: "file-url", gap: "G12", run: fileUrls },
|
|
864
|
+
{ id: "retired-alias", gap: "P11", run: retiredAlias },
|
|
865
|
+
{ id: "qa-platform", gap: "G13", run: qaPlatform },
|
|
866
|
+
{ id: "test-harness", gap: "G14", run: testHarness },
|
|
867
|
+
{ id: "scenario-skip", gap: "G15", run: scenarioSkip },
|
|
868
|
+
{ id: "release-config", gap: "G16", run: releaseConfig },
|
|
869
|
+
{ id: "skip-checks", gap: "G17", run: skipChecks },
|
|
870
|
+
{ id: "qa-dev-server", gap: "G18", run: qaDevServer },
|
|
871
|
+
{ id: "ffmpeg-release-escape", gap: "G19", run: ffmpegReleaseEscape },
|
|
872
|
+
{ id: "switches-inventory", gap: "G20", run: switchesInventory },
|
|
873
|
+
{ id: "speech-link", gap: "G21", run: speechLink },
|
|
874
|
+
{ id: "e2e-under-cargo-test", gap: "G22", run: e2eUnderCargoTest },
|
|
875
|
+
]);
|
|
876
|
+
|
|
877
|
+
// ---- export: the committed files, by git alone ----------------------------------------------------------------------------------
|
|
878
|
+
|
|
879
|
+
/** The path a tree entry is written to. A tree is untrusted bytes: nothing may land outside `dest` or inside a `.git`. */
|
|
880
|
+
export function exportTarget(dest, rel) {
|
|
881
|
+
const parts = rel.split("/");
|
|
882
|
+
const unsafe = !rel || rel.startsWith("/") || rel.includes("\0") || parts.some((s) => s === "" || s === "." || s === ".." || s.toLowerCase() === ".git")
|
|
883
|
+
|| (process.platform === "win32" && /[\\:]/.test(rel));
|
|
884
|
+
if (unsafe) throw new Error(`refusing to export the unsafe path ${JSON.stringify(rel)}`);
|
|
885
|
+
return path.join(dest, ...parts);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const BATCH_BYTES = 64 * 1024 * 1024;
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Write every file of `commit` (a commit or tree id) under `dest`: `git ls-tree -r -z -l` names them, `git cat-file --batch`
|
|
892
|
+
* returns the bytes (in batches of at most `batchBytes`, 64 MiB), node fs writes them. The bytes are the committed blobs: no smudge filter, no
|
|
893
|
+
* eol conversion and no export-ignore, so every host sees the same tree. A symlink becomes a file holding its target (as
|
|
894
|
+
* core.symlinks=false does) and a submodule an empty directory. Returns the repository paths as `ls-tree` lists them.
|
|
895
|
+
*/
|
|
896
|
+
export function exportCommit(repo, commit, dest, { batchBytes = BATCH_BYTES } = {}) {
|
|
897
|
+
const entries = git(["ls-tree", "-r", "-z", "-l", commit], { cwd: repo }).split("\0").filter(Boolean).map((record) => {
|
|
898
|
+
const tab = record.indexOf("\t");
|
|
899
|
+
const m = /^(\d+) (\w+) ([0-9a-f]{40,64}) +(\d+|-)$/.exec(record.slice(0, Math.max(0, tab)));
|
|
900
|
+
if (!m) throw new Error(`unexpected git ls-tree output: ${JSON.stringify(record.slice(0, 120))}`);
|
|
901
|
+
return { mode: m[1], type: m[2], oid: m[3], size: m[4] === "-" ? 0 : Number(m[4]), path: record.slice(tab + 1) };
|
|
902
|
+
});
|
|
903
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
904
|
+
const blobs = new Map();
|
|
905
|
+
for (const entry of entries) {
|
|
906
|
+
const target = exportTarget(dest, entry.path);
|
|
907
|
+
if (entry.type === "commit") { fs.mkdirSync(target, { recursive: true }); continue; } // a submodule: git archive makes the directory too
|
|
908
|
+
if (entry.type !== "blob") throw new Error(`unexpected ${entry.type} ${JSON.stringify(entry.path)} in the tree`);
|
|
909
|
+
if (!blobs.has(entry.oid)) blobs.set(entry.oid, { size: entry.size, targets: [] });
|
|
910
|
+
blobs.get(entry.oid).targets.push({ target, mode: entry.mode === "100755" && process.platform !== "win32" ? 0o755 : 0o644 });
|
|
911
|
+
}
|
|
912
|
+
const oids = [...blobs.keys()];
|
|
913
|
+
for (let from = 0; from < oids.length;) {
|
|
914
|
+
let to = from, bytes = 0;
|
|
915
|
+
while (to < oids.length && (to === from || bytes + blobs.get(oids[to]).size <= batchBytes)) bytes += blobs.get(oids[to++]).size;
|
|
916
|
+
const batch = oids.slice(from, to);
|
|
917
|
+
from = to;
|
|
918
|
+
const out = git(["cat-file", "--batch"], { cwd: repo, input: Buffer.from(`${batch.join("\n")}\n`), encoding: "buffer", maxBuffer: bytes + batch.length * 128 + 1024 * 1024 });
|
|
919
|
+
let at = 0;
|
|
920
|
+
for (const oid of batch) {
|
|
921
|
+
const eol = out.indexOf(10, at);
|
|
922
|
+
const header = eol < 0 ? "" : out.toString("latin1", at, eol);
|
|
923
|
+
const m = /^([0-9a-f]+) blob (\d+)$/.exec(header);
|
|
924
|
+
if (!m || m[1] !== oid) throw new Error(`git cat-file did not return blob ${oid}: ${JSON.stringify(header.slice(0, 120))}`);
|
|
925
|
+
const end = eol + 1 + Number(m[2]);
|
|
926
|
+
const body = out.subarray(eol + 1, end);
|
|
927
|
+
at = end + 1;
|
|
928
|
+
for (const { target, mode } of blobs.get(oid).targets) {
|
|
929
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
930
|
+
fs.writeFileSync(target, body, { mode });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return entries.map((entry) => entry.path);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The tree a compile of `repo` would see, written under `dest`: HEAD plus every tracked and untracked, non-ignored file as it is
|
|
939
|
+
* on disk, put through a throwaway index (the repository's own index is never touched; the blobs land in its object store).
|
|
940
|
+
* Returns { tree, files }.
|
|
941
|
+
*/
|
|
942
|
+
export function exportWorktree(repo, dest) {
|
|
943
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "rk-check-index-"));
|
|
944
|
+
try {
|
|
945
|
+
const env = gitEnv({ GIT_INDEX_FILE: path.join(scratch, "index") });
|
|
946
|
+
const run = (args) => git(args, { cwd: repo, env, maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
947
|
+
run(["read-tree", "HEAD"]);
|
|
948
|
+
run(["add", "-A"]);
|
|
949
|
+
const tree = run(["write-tree"]);
|
|
950
|
+
return { tree, files: exportCommit(repo, tree, dest) };
|
|
951
|
+
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** True when HEAD of `repo` is a branch that has no commit yet (just after `git init`, or an orphan branch before its first commit). */
|
|
955
|
+
export function hasNoCommit(repo) {
|
|
956
|
+
const head = spawnSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { cwd: repo, env: gitEnv(), encoding: "utf8" });
|
|
957
|
+
if (head.error || head.status === 0) return false;
|
|
958
|
+
const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repo, env: gitEnv(), encoding: "utf8" });
|
|
959
|
+
return !branch.error && branch.status === 0;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** The commit id `rev` names in `repo`. */
|
|
963
|
+
export function resolveCommit(repo, rev) {
|
|
964
|
+
if (String(rev).startsWith("-")) throw new Error(`${JSON.stringify(rev)} is not a revision`);
|
|
965
|
+
return git(["rev-parse", "--verify", `${rev}^{commit}`], { cwd: repo }).trim();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Run the selected rules over an exported tree. Returns { failures: [{ rule, gap, problem, fix, file?, subject?, identity? }], timings }. */
|
|
969
|
+
export function runRules({ root, commit, files, repo = process.cwd(), only = null, rules = RULES }) {
|
|
970
|
+
const failures = [];
|
|
971
|
+
const timings = [];
|
|
972
|
+
for (const rule of rules) {
|
|
973
|
+
if (only && !only.includes(rule.id)) continue;
|
|
974
|
+
const started = Date.now();
|
|
975
|
+
let found;
|
|
976
|
+
try { found = rule.run({ root, commit, files, repo }); }
|
|
977
|
+
catch (error) { found = [{ problem: `the rule itself failed: ${error.message}` }]; }
|
|
978
|
+
timings.push({ rule: rule.id, ms: Date.now() - started, failures: found.length });
|
|
979
|
+
for (const f of found) failures.push({ rule: rule.id, gap: rule.gap, ...f, ...(f.file && f.subject !== undefined ? { identity: `${rule.id}|${f.file}|${f.subject}` } : {}) });
|
|
980
|
+
}
|
|
981
|
+
return { failures, timings };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ---- the ratchet ------------------------------------------------------------------------------------------------------------------
|
|
985
|
+
|
|
986
|
+
export const APP_BASELINE_FILE = ".rightkit/app-check-baseline.json";
|
|
987
|
+
export const CENTRAL_BASELINE_FILE = "ci/app-check-baseline.json";
|
|
988
|
+
|
|
989
|
+
/** The identities RightKit's central baseline lists for `repoKey`; throws on a file that is not { known: { <key>: [string] } }. */
|
|
990
|
+
export function baselineKnown(text, repoKey) {
|
|
991
|
+
if (text === null) return [];
|
|
992
|
+
const known = JSON.parse(text)?.known;
|
|
993
|
+
if (!known || typeof known !== "object" || Array.isArray(known) || !Object.values(known).every((v) => Array.isArray(v) && v.every((id) => typeof id === "string"))) throw new Error('expected { "known": { "<repo>": ["<identity>", ...] } }');
|
|
994
|
+
return known[repoKey] ?? [];
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** The longest an app baseline entry may stay tolerated: its sunset is at most this many days after its own `added` date. */
|
|
998
|
+
export const APP_BASELINE_MAX_DAYS = 90;
|
|
999
|
+
/** The rule for an app baseline entry, as the messages state it. */
|
|
1000
|
+
/** The sentence an expired entry's EXPIRED line and its finding's FAIL line both carry. */
|
|
1001
|
+
const APP_BASELINE_EXPIRY_RULE = `an app baseline entry is tolerated for at most ${APP_BASELINE_MAX_DAYS} days after its added date`;
|
|
1002
|
+
export const APP_BASELINE_RULE = `an app baseline entry needs a non-empty issue, an added date and a sunset date (YYYY-MM-DD, UTC); added is not later than today, and sunset is not before added and at most ${APP_BASELINE_MAX_DAYS} days after it`;
|
|
1003
|
+
|
|
1004
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
1005
|
+
/** `text` (YYYY-MM-DD) when it is a real calendar date, else null. */
|
|
1006
|
+
const realDate = (text) => {
|
|
1007
|
+
if (typeof text !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(text)) return null;
|
|
1008
|
+
const date = new Date(`${text}T00:00:00Z`);
|
|
1009
|
+
return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text ? null : text;
|
|
1010
|
+
};
|
|
1011
|
+
/** The date `days` after `text` (YYYY-MM-DD, UTC), as YYYY-MM-DD. */
|
|
1012
|
+
export const addDays = (text, days) => new Date(Date.parse(`${text}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10);
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* An app's own baseline, { schema: 1, known: [{ id, issue, added, sunset }] }, as its entries; throws on any other shape or on an
|
|
1016
|
+
* entry that breaks APP_BASELINE_RULE. `today` (YYYY-MM-DD, UTC) is the latest an `added` date may be.
|
|
1017
|
+
*/
|
|
1018
|
+
export function appBaselineEntries(text, { today = new Date().toISOString().slice(0, 10) } = {}) {
|
|
1019
|
+
const data = JSON.parse(text);
|
|
1020
|
+
if (data?.schema !== 1 || !Array.isArray(data.known)) throw new Error('expected { "schema": 1, "known": [{ "id": "<identity>", "issue": "<repo#number>", "added": "YYYY-MM-DD", "sunset": "YYYY-MM-DD" }, ...] }');
|
|
1021
|
+
const seen = new Set();
|
|
1022
|
+
return data.known.map((entry, i) => {
|
|
1023
|
+
const at = `known[${i}]`;
|
|
1024
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`${at} must be an object with id, issue, added and sunset`);
|
|
1025
|
+
if (typeof entry.id !== "string" || !entry.id) throw new Error(`${at}.id must be a finding identity`);
|
|
1026
|
+
if (typeof entry.issue !== "string" || !entry.issue.trim()) throw new Error(`${at}.issue must name the issue that tracks ${entry.id}; ${APP_BASELINE_RULE}`);
|
|
1027
|
+
const added = realDate(entry.added);
|
|
1028
|
+
if (!added) throw new Error(`${at}.added must be the date the entry was added, YYYY-MM-DD; ${APP_BASELINE_RULE}`);
|
|
1029
|
+
if (added > today) throw new Error(`${at}.added ${added} is later than today (${today}, UTC); ${APP_BASELINE_RULE}`);
|
|
1030
|
+
const sunset = realDate(entry.sunset);
|
|
1031
|
+
if (!sunset) throw new Error(`${at}.sunset must be a date, YYYY-MM-DD; ${APP_BASELINE_RULE}`);
|
|
1032
|
+
if (sunset < added) throw new Error(`${at}.sunset ${sunset} is before added ${added}; ${APP_BASELINE_RULE}`);
|
|
1033
|
+
const latest = addDays(added, APP_BASELINE_MAX_DAYS);
|
|
1034
|
+
if (sunset > latest) throw new Error(`${at}.sunset ${sunset} is more than ${APP_BASELINE_MAX_DAYS} days after added ${added} (the latest is ${latest}); ${APP_BASELINE_RULE}`);
|
|
1035
|
+
if (seen.has(entry.id)) throw new Error(`${at} repeats ${entry.id}`);
|
|
1036
|
+
seen.add(entry.id);
|
|
1037
|
+
return { id: entry.id, issue: entry.issue, added, sunset };
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** The ratchet: split findings into `fresh` (fail), `known` (listed: reported, not failing) and `stale` (a listed identity that no
|
|
1042
|
+
* finding has any more; fails, so the list only shrinks). With `rules` (--only) only those rules' entries can be stale. */
|
|
1043
|
+
export function applyBaseline(findings, known, rules = null) {
|
|
1044
|
+
const listed = new Set(known);
|
|
1045
|
+
const present = new Set(findings.map((f) => f.identity).filter(Boolean));
|
|
1046
|
+
return {
|
|
1047
|
+
fresh: findings.filter((f) => !(f.identity && listed.has(f.identity))),
|
|
1048
|
+
known: findings.filter((f) => f.identity && listed.has(f.identity)),
|
|
1049
|
+
stale: [...listed].filter((id) => !present.has(id) && (!rules || rules.includes(id.split("|")[0]))).sort(),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const sameDirectory = (a, b) => { try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); } };
|
|
1054
|
+
|
|
1055
|
+
/** RightKit's own checkout when this module is loaded from one (packages/release/app-check.mjs), else null: an installed package has none. */
|
|
1056
|
+
export function rightkitCheckout(here = HERE) {
|
|
1057
|
+
const root = path.resolve(here, "..", "..");
|
|
1058
|
+
const inPackages = path.basename(here) === "release" && path.basename(path.dirname(here)) === "packages";
|
|
1059
|
+
return inPackages && fs.existsSync(path.join(root, "scripts", "preflight.mjs")) && fs.existsSync(path.join(root, CENTRAL_BASELINE_FILE)) ? root : null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** The baseline for one run: { entries: [{ id, issue?, added?, sunset? }], listedIn }. `root` is the exported tree. Throws { file, message }. */
|
|
1063
|
+
function loadBaseline({ explicit, root, central, repoKey, today }) {
|
|
1064
|
+
let file = explicit ? path.resolve(explicit) : null;
|
|
1065
|
+
try {
|
|
1066
|
+
let text = null;
|
|
1067
|
+
let fromApp = false;
|
|
1068
|
+
if (file) text = fs.readFileSync(file, "utf8");
|
|
1069
|
+
else if ((text = readText(root, APP_BASELINE_FILE)) !== null) { file = APP_BASELINE_FILE; fromApp = true; }
|
|
1070
|
+
else if (central) {
|
|
1071
|
+
file = path.join(central, CENTRAL_BASELINE_FILE);
|
|
1072
|
+
try { text = fs.readFileSync(file, "utf8"); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
1073
|
+
}
|
|
1074
|
+
// The app's own file is always read as an app baseline (an issue, an added date and a sunset per entry, appBaselineEntries refuses
|
|
1075
|
+
// any other shape): sniffing its shape would let an app write the central { known: { "<repo>": [...] } } form, which carries
|
|
1076
|
+
// no dates, and silence a finding for good.
|
|
1077
|
+
if (fromApp || (text !== null && Array.isArray(JSON.parse(text)?.known))) return { entries: appBaselineEntries(text, { today }), listedIn: `${APP_BASELINE_FILE} known`, listedFile: APP_BASELINE_FILE };
|
|
1078
|
+
return { entries: baselineKnown(text, repoKey).map((id) => ({ id })), listedIn: `${CENTRAL_BASELINE_FILE} known["${repoKey}"]`, listedFile: CENTRAL_BASELINE_FILE };
|
|
1079
|
+
} catch (error) { throw Object.assign(new Error(error.message), { file }); }
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ---- the check ---------------------------------------------------------------------------------------------------------------------
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Check one repository. `mode` is "commit" (the committed files of `commit`, default HEAD) or "worktree" (what a compile would
|
|
1086
|
+
* see). Returns { findings (failing), known (tolerated by the baseline), stale (identities), expired ([{ id, issue, added, sunset }]),
|
|
1087
|
+
* found (every finding), exitCode, lines, text (stdout), errLines, errText (stderr) }; nothing is printed.
|
|
1088
|
+
* `rules` is the rule list (this module's, or RightKit's preflight adds its own), `label` prefixes the summary, `central` is the
|
|
1089
|
+
* RightKit checkout whose ci/app-check-baseline.json is the last baseline resort (null: none), `now` dates the sunsets.
|
|
1090
|
+
* `refuse` lists rules whose findings the baseline cannot excuse here, because the build refuses them whatever the baseline says
|
|
1091
|
+
* (BUILD_REFUSES); a listed one fails like a new one.
|
|
1092
|
+
*/
|
|
1093
|
+
export function runAppCheck({ repo, mode = "commit", commit, only = null, baseline, printBaseline = false, rules = RULES, label = "check", repoKey, central = rightkitCheckout(), now = new Date(), refuse = [] } = {}) {
|
|
1094
|
+
const lines = [];
|
|
1095
|
+
const errLines = [];
|
|
1096
|
+
const finish = (exitCode, extra = {}) => ({ findings: [], known: [], stale: [], expired: [], found: [], ...extra, exitCode, lines, text: lines.join("\n"), errLines, errText: errLines.join("\n") });
|
|
1097
|
+
const unknown = only?.filter((id) => !rules.some((r) => r.id === id)) ?? [];
|
|
1098
|
+
if (unknown.length) { errLines.push(`${label}: unknown rule(s): ${unknown.join(", ")} (see --list)`); return finish(2); }
|
|
1099
|
+
if (mode !== "commit" && mode !== "worktree") { errLines.push(`${label}: mode must be commit or worktree, not ${JSON.stringify(mode)}`); return finish(2); }
|
|
1100
|
+
if (mode === "worktree" && commit) { errLines.push(`${label}: --worktree and --commit are exclusive`); return finish(2); }
|
|
1101
|
+
repo = path.resolve(repo ?? process.cwd());
|
|
1102
|
+
// The baseline is keyed by the checked repository's name; a RightKit checkout is "rightkit" wherever it was cloned.
|
|
1103
|
+
const key = repoKey ?? (central && sameDirectory(repo, central) ? "rightkit" : path.basename(repo).toLowerCase());
|
|
1104
|
+
const started = Date.now();
|
|
1105
|
+
let checked, files, root;
|
|
1106
|
+
try {
|
|
1107
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "rk-check-"));
|
|
1108
|
+
if (mode === "worktree") ({ tree: checked, files } = exportWorktree(repo, root));
|
|
1109
|
+
else { checked = resolveCommit(repo, commit ?? "HEAD"); files = exportCommit(repo, checked, root); }
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
// HEAD is what both modes start from: a repository that has no commit yet has nothing to read, and "commit first" is the fix.
|
|
1112
|
+
const unborn = (mode === "worktree" || (commit ?? "HEAD") === "HEAD") && hasNoCommit(repo);
|
|
1113
|
+
errLines.push(unborn ? `${label}: the repository has no commit yet; commit first` : `${label}: could not export the commit to check: ${error.message}`);
|
|
1114
|
+
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
1115
|
+
return finish(2);
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
const { failures: found } = runRules({ root, commit: checked, files, repo, only, rules });
|
|
1119
|
+
if (printBaseline) {
|
|
1120
|
+
lines.push(JSON.stringify([...new Set(found.map((f) => f.identity).filter(Boolean))].sort(), null, 2));
|
|
1121
|
+
const unkeyed = found.filter((f) => !f.identity).length;
|
|
1122
|
+
if (unkeyed) errLines.push(`${label}: ${unkeyed} finding(s) of rules without an identity are not baselineable and are not listed`);
|
|
1123
|
+
return finish(0, { found });
|
|
1124
|
+
}
|
|
1125
|
+
// An entry past its sunset is no longer tolerated: its finding fails like any other, and the entry itself is reported.
|
|
1126
|
+
const today = now.toISOString().slice(0, 10);
|
|
1127
|
+
let listed;
|
|
1128
|
+
try { listed = loadBaseline({ explicit: baseline, root, central, repoKey: key, today }); }
|
|
1129
|
+
catch (error) { errLines.push(`${label}: cannot read the baseline ${error.file}: ${error.message}`); return finish(2); }
|
|
1130
|
+
const ran = (id) => !only || only.includes(id.split("|")[0]);
|
|
1131
|
+
const pastSunset = (entry) => entry.sunset !== undefined && entry.sunset < today;
|
|
1132
|
+
const expired = listed.entries.filter((entry) => pastSunset(entry) && ran(entry.id));
|
|
1133
|
+
const applied = applyBaseline(found, listed.entries.filter((entry) => !pastSunset(entry)).map((entry) => entry.id), only);
|
|
1134
|
+
const refused = applied.known.filter((f) => refuse.includes(f.rule)).map((f) => ({ ...f, problem: `${f.problem} (the baseline lists it, but the build refuses it)` }));
|
|
1135
|
+
const lapsed = new Map(expired.map((entry) => [entry.id, entry]));
|
|
1136
|
+
const fresh = [...applied.fresh.map((f) => (f.identity && lapsed.has(f.identity) ? { ...f, problem: `${f.problem} (its baseline entry expired: sunset ${lapsed.get(f.identity).sunset}; ${APP_BASELINE_EXPIRY_RULE})` } : f)), ...refused];
|
|
1137
|
+
const known = applied.known.filter((f) => !refuse.includes(f.rule));
|
|
1138
|
+
const { stale } = applied;
|
|
1139
|
+
for (const f of fresh) {
|
|
1140
|
+
lines.push(`FAIL ${f.rule} (${f.gap}): ${f.problem}`);
|
|
1141
|
+
if (f.fix) lines.push(` fix: ${f.fix}`);
|
|
1142
|
+
}
|
|
1143
|
+
for (const f of known) lines.push(`KNOWN ${f.rule}: ${f.problem}`);
|
|
1144
|
+
for (const id of stale) lines.push(`STALE baseline entry ${id}: no longer found in ${key}; remove it from ${listed.listedIn}`);
|
|
1145
|
+
for (const e of expired) lines.push(`EXPIRED baseline entry ${e.id}: sunset ${e.sunset} has passed (${e.issue}); ${APP_BASELINE_EXPIRY_RULE}${e.added ? ` (${e.added})` : ""}; fix the finding and remove the entry from ${listed.listedIn}`);
|
|
1146
|
+
const ruleCount = only ? only.length : rules.length;
|
|
1147
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
1148
|
+
const problems = fresh.length + stale.length + expired.length;
|
|
1149
|
+
const result = { findings: fresh, known, stale, expired, found };
|
|
1150
|
+
if (problems) {
|
|
1151
|
+
lines.push(`${label}: ${problems} problem(s) in ${checked.slice(0, 12)} (${ruleCount} rules, ${seconds}s${known.length ? `, ${known.length} known` : ""}). Each would refuse later in CI or publication; fix and commit, then push.`);
|
|
1152
|
+
return finish(1, result);
|
|
1153
|
+
}
|
|
1154
|
+
lines.push(`${label}: ${checked.slice(0, 12)} passed ${ruleCount} rules in ${seconds}s${known.length ? ` (${known.length} known finding(s) in ${listed.listedFile}; fix them, then remove their entries)` : ""}`);
|
|
1155
|
+
return finish(0, result);
|
|
1156
|
+
} finally {
|
|
1157
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// ---- which right-release commands run the check ------------------------------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
/** Commands that compile, package, sign, ship or inspect a release run the check first, in-process, and start nothing when it is red.
|
|
1164
|
+
* "commit" checks HEAD (what is built, packaged, signed and uploaded); "worktree" checks what a compile or the doctor would see
|
|
1165
|
+
* (the commands a person runs against the tree on disk: dev, doctor, test, stage-sidecar, sign-dev-artifact). There is no flag that
|
|
1166
|
+
* skips it. Each was found by reading the command's module and its callers (a compile, a package or a signing step that a person or an
|
|
1167
|
+
* agent can start from the CLI): test runs the app's suites (cargo, vitest) so it is dev-like; web-build is `build --web` and
|
|
1168
|
+
* checks as it does; swift-package packages and signs; recipe-build runs the Tauri build of a package recipe; stage-sidecar builds
|
|
1169
|
+
* the sidecar crates through the broker (apps run it from their own scripts, so it is dev-like); sign-candidate signs;
|
|
1170
|
+
* sign-dev-artifact signs a CI dev artifact from the checked-out repository (the dev-mac-sign and sign-existing jobs run it there),
|
|
1171
|
+
* so it checks the tree on disk; `addon build` runs the add-on's own target.build.cmd (a compile), then signs and seals the output of
|
|
1172
|
+
* HEAD, so it checks HEAD. A key with a space names a subcommand: "addon build" guards `right-release addon build` and no other
|
|
1173
|
+
* `addon` form (see UNCHECKED_COMMANDS). */
|
|
1174
|
+
export const CHECKED_COMMANDS = new Map([
|
|
1175
|
+
["release", "commit"], ["build", "commit"], ["package", "commit"], ["dev", "worktree"], ["qa", "commit"], ["upload", "commit"],
|
|
1176
|
+
["publish", "commit"], ["publish-update", "commit"], ["github", "commit"], ["doctor", "worktree"],
|
|
1177
|
+
["sign-windows", "commit"], ["sign-updater", "commit"], ["create-mac-updater", "commit"],
|
|
1178
|
+
["test", "worktree"], ["web-build", "commit"], ["swift-package", "commit"], ["recipe-build", "commit"], ["stage-sidecar", "worktree"], ["sign-candidate", "commit"],
|
|
1179
|
+
["addon build", "commit"], ["sign-dev-artifact", "worktree"],
|
|
1180
|
+
]);
|
|
1181
|
+
|
|
1182
|
+
/** The key of CHECKED_COMMANDS an invocation matches, or null: `<command> <subcommand>` when that pair is listed, else the command. */
|
|
1183
|
+
export function checkedCommandOf(args) {
|
|
1184
|
+
const [first, second] = args;
|
|
1185
|
+
if (!first) return null;
|
|
1186
|
+
if (second && CHECKED_COMMANDS.has(`${first} ${second}`)) return `${first} ${second}`;
|
|
1187
|
+
return CHECKED_COMMANDS.has(first) ? first : null;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Rules whose findings a baseline cannot excuse before a build, doctor or release step: the build refuses them whatever the baseline
|
|
1191
|
+
* says (no switches inventory: the release-switches scan has nothing to prove against), so tolerating one only moves the failure
|
|
1192
|
+
* minutes into the build. */
|
|
1193
|
+
export const BUILD_REFUSES = Object.freeze(["switches-inventory"]);
|
|
1194
|
+
|
|
1195
|
+
/** Every other right-release command, each with why the check does not guard it. A command that is in neither map fails the
|
|
1196
|
+
* classification test in app-check.test.mjs, so a new command cannot appear unclassified. */
|
|
1197
|
+
export const UNCHECKED_COMMANDS = new Map([
|
|
1198
|
+
["check", "is the check"],
|
|
1199
|
+
["update", "runs @rightkit/outdated over the app's RightKit pins (right-update); reads no release inputs and builds nothing"],
|
|
1200
|
+
["deps", "the shared dependency lane: reads and updates dependency pins, not the app's release inputs"],
|
|
1201
|
+
["hardening", "scans artifacts named on the command line; reads no source tree"],
|
|
1202
|
+
["harden", "alias of hardening"],
|
|
1203
|
+
["hardeningscan", "alias of hardening"],
|
|
1204
|
+
["sign-qa-bundle", "QA-only ad hoc signing inside a scratch root; not a release artifact"],
|
|
1205
|
+
["engine-locate", "prints the verified path of an installed engine; reads no app source"],
|
|
1206
|
+
["init", "renames the desktop template in place; there is no committed app to check yet"],
|
|
1207
|
+
["pe", "inspects one PE file named on the command line"],
|
|
1208
|
+
["icons", "packs PNG frames named on the command line"],
|
|
1209
|
+
["signing-agent", "manages the host signing service; no app"],
|
|
1210
|
+
["mirror-root-artifact", "copies artifacts a build already produced"],
|
|
1211
|
+
["license-notices", "writes third-party notices; no release input"],
|
|
1212
|
+
["toolchain", "installs a pinned toolchain on a runner; no app"],
|
|
1213
|
+
["cargo-target", "prints the cargo target directory from an offline `cargo metadata --no-deps`; compiles nothing"],
|
|
1214
|
+
["ownership", "the ownership scan is its own gate (pre-commit, admission)"],
|
|
1215
|
+
["ownership-approval", "retired no-op"],
|
|
1216
|
+
["ownership-report", "reports ownership across a workspace"],
|
|
1217
|
+
["ownership-mandatory", "reports the mandatory owner set"],
|
|
1218
|
+
["adoption-evidence", "validates a published manifest and an adoption record; a report"],
|
|
1219
|
+
["bundle-scan", "scans bundles named on the command line"],
|
|
1220
|
+
["check-release-dependencies", "reads dependency manifests; a report"],
|
|
1221
|
+
["dist-scan", "scans a built web dist named on the command line"],
|
|
1222
|
+
["identity-scan", "asserts the signature of a path named on the command line"],
|
|
1223
|
+
["apple-profile-create", "provisioning-profile credentials; no app source"],
|
|
1224
|
+
["ios-credentials", "stores or checks App Store Connect credentials; no app source"],
|
|
1225
|
+
["hooks", "installs the git hooks, and the pre-push hook runs the check"],
|
|
1226
|
+
["suite-doctor", "verifies the Right Suite contract tests; a report"],
|
|
1227
|
+
["cache", "host build-cache maintenance"],
|
|
1228
|
+
["disk", "host disk maintenance"],
|
|
1229
|
+
["install-app", "installs a signed build made earlier"],
|
|
1230
|
+
["dmg", "takes --app/--out paths, compiles nothing, has no repository"],
|
|
1231
|
+
["apple", "renews a provisioning profile; no app source"],
|
|
1232
|
+
["qualify", "qualifies RightKit crates; preflight and the hosted scans check RightKit"],
|
|
1233
|
+
["inventory", "reports registry drift"],
|
|
1234
|
+
["model", "promotes a runtime model artifact"],
|
|
1235
|
+
["addon", "every form but `addon build` (which is checked) compiles nothing: doctor validates the config, upload ships only what `addon build` sealed at HEAD, adopt verifies a downloaded add-on against a lock"],
|
|
1236
|
+
["lsclean", "host LaunchServices cleanup"],
|
|
1237
|
+
["generate-dmg-background", "generates an image"],
|
|
1238
|
+
]);
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* The places in the source that show the facts an UNCHECKED_COMMANDS reason states, for the commands whose reason is about the
|
|
1242
|
+
* source and not "a report" or "host maintenance": [file under packages/release, text that file must contain]. app-check.test.mjs
|
|
1243
|
+
* reads every place, so a reason cannot outlive the code it names. dmg: it needs --app, --out and --volname on the command line, and
|
|
1244
|
+
* dmg-build.test.mjs pins that `right-release dmg build` dispatches from a directory that is no repository (guarding it would turn
|
|
1245
|
+
* that dispatch into "needs a git repository"). addon: the forms other than build; upload ships only a seal whose manifest commit is
|
|
1246
|
+
* HEAD, which only `addon build` writes, and adopt takes a lock and an output directory.
|
|
1247
|
+
*/
|
|
1248
|
+
export const UNCHECKED_PROOF = new Map([
|
|
1249
|
+
["dmg", [
|
|
1250
|
+
["dmg-build.mjs", '"--app, --out & --volname are required"'],
|
|
1251
|
+
["dmg-build.test.mjs", "installed CLI dispatches dmg build"],
|
|
1252
|
+
]],
|
|
1253
|
+
["addon", [
|
|
1254
|
+
["addon-command.mjs", "manifest.commit !== commit) throw new Error(\"sealed add-on identity mismatch\")"],
|
|
1255
|
+
["addon-command.mjs", '"adopt requires --lock and --output"'],
|
|
1256
|
+
["addon-command.mjs", "run(target.build.cmd, target.build.args, root);"],
|
|
1257
|
+
]],
|
|
1258
|
+
]);
|
|
1259
|
+
|
|
1260
|
+
/** The mode `right-release <args>` must pass the check in, or null for a command the check does not guard. */
|
|
1261
|
+
export function checkModeFor(args) {
|
|
1262
|
+
const [first, second] = args;
|
|
1263
|
+
if (!first) return null; // right-release alone prints help
|
|
1264
|
+
if (first.startsWith("-")) return ["-h", "--help", "-v", "--version"].includes(first) ? null : "commit"; // direct flags run `build <flags>`
|
|
1265
|
+
if (first === "publish" && ["cargo", "npm", "swift"].includes(second)) return null; // publishing a RightKit unit, not an app
|
|
1266
|
+
if (first === "qa" && second === "audit") return null; // a report
|
|
1267
|
+
if (first === "doctor" && args.includes("--all")) return null; // the suite contract test, no app
|
|
1268
|
+
const key = checkedCommandOf(args);
|
|
1269
|
+
return key ? CHECKED_COMMANDS.get(key) : null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** The directory whose git top-level is the app: --config's folder when given, else --app-root (recipe-build runs its plan there),
|
|
1273
|
+
* else `cwd`. */
|
|
1274
|
+
export const appDirectory = (args, cwd) => {
|
|
1275
|
+
const valueOf = (flag) => {
|
|
1276
|
+
const i = args.findIndex((a) => a === flag || a.startsWith(`${flag}=`));
|
|
1277
|
+
return i < 0 ? null : args[i] === flag ? args[i + 1] : args[i].slice(flag.length + 1);
|
|
1278
|
+
};
|
|
1279
|
+
const config = valueOf("--config");
|
|
1280
|
+
if (config) return path.dirname(path.resolve(cwd, config));
|
|
1281
|
+
const appRoot = valueOf("--app-root");
|
|
1282
|
+
return appRoot ? path.resolve(cwd, appRoot) : cwd;
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Run the check a `right-release <args>` invocation owes, in this process. Returns null when the command is not guarded or the
|
|
1287
|
+
* check is green (nothing is printed then), else { exitCode: 1, lines } for the caller to print: the findings and why nothing
|
|
1288
|
+
* was started. A check that cannot run is red too.
|
|
1289
|
+
*/
|
|
1290
|
+
export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck } = {}) {
|
|
1291
|
+
const mode = checkModeFor(args);
|
|
1292
|
+
if (!mode) return null;
|
|
1293
|
+
const name = args[0].startsWith("-") ? "build" : checkedCommandOf(args) ?? args[0];
|
|
1294
|
+
const dir = appDirectory(args, cwd);
|
|
1295
|
+
const repo = repoRoot(dir);
|
|
1296
|
+
if (!repo) return { exitCode: 1, lines: [`right-release ${name}: the app check needs a git repository and ${dir} is not inside one; nothing was started`] };
|
|
1297
|
+
const result = run({ repo, mode, label: "check", refuse: BUILD_REFUSES });
|
|
1298
|
+
if (result.exitCode === 0) return null;
|
|
1299
|
+
const why = result.exitCode === 2 ? "could not run" : "found problems";
|
|
1300
|
+
return { exitCode: 1, lines: [...result.lines, ...result.errLines, `right-release ${name}: the app check ${why} in ${repo}; nothing was started. Fix the above and commit, then run it again.`] };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ---- command line ---------------------------------------------------------------------------------------------------------------
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* `check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`.
|
|
1307
|
+
* `--repo` defaults to the git top-level of `cwd` (or `defaultRepo`). Returns the exit code: 0 pass, 1 findings, 2 could not run.
|
|
1308
|
+
*/
|
|
1309
|
+
export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`), label = "check", rules = RULES, defaultRepo = null, cwd = process.cwd(), central, now } = {}) {
|
|
1310
|
+
const arg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
|
|
1311
|
+
if (argv.includes("--list")) { for (const r of rules) out(`${r.id} (${r.gap})`); return 0; }
|
|
1312
|
+
const repo = arg("--repo") ? path.resolve(arg("--repo")) : defaultRepo ?? repoRoot(cwd);
|
|
1313
|
+
if (!repo) { err(`${label}: not inside a git repository (use --repo <dir>)`); return 2; }
|
|
1314
|
+
const result = runAppCheck({
|
|
1315
|
+
repo,
|
|
1316
|
+
mode: argv.includes("--worktree") ? "worktree" : "commit",
|
|
1317
|
+
commit: arg("--commit"),
|
|
1318
|
+
only: arg("--only")?.split(",").filter(Boolean) ?? null,
|
|
1319
|
+
baseline: arg("--baseline"),
|
|
1320
|
+
printBaseline: argv.includes("--print-baseline"),
|
|
1321
|
+
rules, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}),
|
|
1322
|
+
});
|
|
1323
|
+
for (const line of result.lines) out(line);
|
|
1324
|
+
for (const line of result.errLines) err(line);
|
|
1325
|
+
return result.exitCode;
|
|
1326
|
+
}
|