flagrm 0.1.1 → 0.2.0

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.
@@ -1,6 +1,5 @@
1
1
  import pc from "picocolors";
2
2
  import { plural, relativePath } from "./util.js";
3
- import { shortTestNames } from "./verify/test-results.js";
4
3
  /** A path relative to the working directory, with `/` separators on every platform. */
5
4
  function rel(file) {
6
5
  return relativePath(process.cwd(), file) || file;
@@ -28,12 +27,21 @@ export function printInventory(report) {
28
27
  (refs.tests ? ` (${refs.tests} in tests)` : "") +
29
28
  pc.dim(` · ${byProject}`);
30
29
  console.log(`${pc.bold(entry.flag)} ${pc.dim(`${entry.type} · ${stateLabel(entry.state)} ·`)} ${usage}`);
31
- const project = pad([...entry.definitions, ...entry.config].map((x) => x.project));
30
+ const project = pad([...entry.definitions, ...entry.config, ...entry.flow].map((x) => x.project));
32
31
  const kind = pad(entry.definitions.map((d) => d.kind));
33
32
  const name = pad(entry.definitions.map((d) => d.name));
34
33
  for (const d of entry.definitions) {
35
34
  console.log(` ${pc.dim("defined")} ${pc.dim(project(d.project))} ${pc.dim(kind(d.kind))} ${name(d.name)} ${pc.cyan(`${rel(d.file)}:${d.line}`)}`);
36
35
  }
36
+ const flowKind = pad(entry.flow.map((n) => n.kind));
37
+ const flowName = pad(entry.flow.map((n) => n.name));
38
+ for (const n of entry.flow) {
39
+ console.log(` ${pc.dim("flows ")} ${pc.dim(project(n.project))} ${pc.dim(flowKind(n.kind))} ${flowName(n.name)} ${pc.cyan(`${rel(n.file)}:${n.line}`)}` +
40
+ (n.record ? "" : pc.dim(" (suggested)")));
41
+ }
42
+ if (entry.parameterizedTests.length) {
43
+ console.log(` ${pc.dim("tests ")} ${plural(entry.parameterizedTests.length, "test call")} pass the flag's value as a literal`);
44
+ }
37
45
  const env = pad(entry.config.map((c) => c.environment));
38
46
  const state = pad(entry.config.map((c) => configLabel(c.state, c.filters)));
39
47
  for (const c of entry.config) {
@@ -41,7 +49,8 @@ export function printInventory(report) {
41
49
  }
42
50
  console.log("");
43
51
  }
44
- console.log(pc.dim("Reference counts are direct uses of the flag's literal and definitions, not reads through wrappers."));
52
+ console.log(pc.dim("Reference counts are direct uses of the flag's literal and definitions, not reads through wrappers. " +
53
+ "`flows` are the names its value travels through; `baseline` records the ones not marked suggested."));
45
54
  }
46
55
  function stateLabel(state) {
47
56
  return state === "unconfigured" ? "not configured" : state;
@@ -58,11 +67,21 @@ export function printBaseline(baseline, file) {
58
67
  for (const c of checks ?? []) {
59
68
  const status = c.exitCode === 0 ? pc.green("pass") : pc.red(`fail (exit ${c.exitCode})`);
60
69
  console.log(` ${c.project} ${c.check}: ${status} ${pc.dim(`${c.command} — ${Math.round(c.durationMs / 100) / 10}s`)}`);
70
+ if (c.failure)
71
+ console.log(` ${pc.yellow("setup problem:")} ${c.failure.message}`);
61
72
  }
62
73
  if (baseline.names.length) {
63
- const names = baseline.names.map((n) => (n.kind === "literal" ? n.name : `${n.name} (${n.kind})`));
74
+ const names = baseline.names.map((n) => n.kind === "literal" ? n.name : `${n.name} (${n.kind}${n.file ? ` in ${n.file}` : ""})`);
64
75
  console.log(` names: ${pc.dim(names.join(", "))}`);
65
76
  }
77
+ for (const n of baseline.suggestedNames ?? []) {
78
+ console.log(` ${pc.yellow("suggested")} ${n.name} ${pc.dim(`(${n.kind})`)} ${pc.cyan(`${rel(n.file)}:${n.line}`)} ` +
79
+ pc.dim(`if it only carries this flag: --name ${n.name} --kind wrapper` +
80
+ (n.kind === "method" || n.kind === "property" ? "" : ` --file ${rel(n.file)}`)));
81
+ }
82
+ for (const t of baseline.parameterizedTests ?? []) {
83
+ console.log(` ${pc.dim("test passes")} ${t.parameter}: ${t.value} ${pc.dim(`to ${t.method}`)} ${pc.cyan(`${rel(t.file)}:${t.line}`)}`);
84
+ }
66
85
  }
67
86
  const CHECK_STYLE = {
68
87
  pass: { icon: "✓", color: pc.green },
@@ -94,13 +113,6 @@ export function printVerifyReport(report, file) {
94
113
  if (check.findings.length > shown.length) {
95
114
  console.log(pc.dim(` … ${check.findings.length - shown.length} more (see --json)`));
96
115
  }
97
- if (check.id === "tests") {
98
- const removed = shortTestNames(report.tests.flatMap((t) => t.removed));
99
- for (const name of removed.slice(0, MAX_FINDINGS))
100
- console.log(pc.dim(` no longer runs: ${name}`));
101
- if (removed.length > MAX_FINDINGS)
102
- console.log(pc.dim(` … ${removed.length - MAX_FINDINGS} more (see --md)`));
103
- }
104
116
  }
105
117
  if (report.strict && warned)
106
118
  console.log(pc.dim("\n --strict: warnings count as failures."));
@@ -133,3 +145,34 @@ export function printDoctor(report) {
133
145
  }
134
146
  console.log(report.status === "ok" ? pc.green("\nflagrm doctor: ok") : pc.red("\nflagrm doctor: problems found"));
135
147
  }
148
+ /** `flagrm scope`: each .NET project's setup problems and what flagrm.slnf leaves out. */
149
+ export function printScope(setups, root, written) {
150
+ if (setups.length === 0) {
151
+ console.log(pc.dim("No .NET projects in the config."));
152
+ return;
153
+ }
154
+ for (const s of setups) {
155
+ const solution = s.setup ? relativePath(root, s.setup.solution) : s.note;
156
+ console.log(`${pc.bold(s.project)} ${pc.dim(solution ?? "")}`);
157
+ if (s.sdk)
158
+ console.log(` ${pc.red("✗")} ${s.sdk}`);
159
+ for (const p of s.setup?.problems ?? [])
160
+ console.log(` ${pc.yellow("!")} ${p}`);
161
+ const out = s.setup?.leaveOut ?? [];
162
+ if (!s.sdk && !s.setup?.problems.length)
163
+ console.log(` ${pc.green("✓")} this machine has what it needs`);
164
+ if (out.length) {
165
+ const total = s.setup?.projects.length ?? 0;
166
+ console.log(` ${written ? "flagrm.slnf leaves out" : "flagrm.slnf would leave out"} ${out.length} of ${total} projects:`);
167
+ for (const l of out.slice(0, 30))
168
+ console.log(` ${l.entry} ${pc.dim(`— ${l.reason}`)}`);
169
+ if (out.length > 30)
170
+ console.log(pc.dim(` … ${out.length - 30} more`));
171
+ console.log(pc.dim(written
172
+ ? " Their builds and tests won't run: verify can't check edits to them, so say so in the PR."
173
+ : " Run `flagrm scope --write` to create it and point the build and test at it."));
174
+ }
175
+ if (s.sdk)
176
+ console.log(pc.dim(" A solution filter can't help here: install the SDK or change global.json."));
177
+ }
178
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `flagrm scope`: check that the machine can build and test each .NET
3
+ * project, and when it lacks something only some projects need (`cargo`,
4
+ * Docker), narrow the build and test to a solution filter, `flagrm.slnf`,
5
+ * that leaves those projects out. A smaller scope that runs beats a full one
6
+ * that can't: the baseline's results are then real.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import fg from "fast-glob";
12
+ import YAML from "yaml";
13
+ import { sdkProblem, solutionSetup } from "../adapters/dotnet/setup.js";
14
+ import { projectCommands } from "./config-snapshot.js";
15
+ import { relativePath } from "./util.js";
16
+ export const SLNF = "flagrm.slnf";
17
+ /** Check each .NET project of the workspace. */
18
+ export function setupReport(projects, root, probes) {
19
+ const out = [];
20
+ for (const { adapter, ctx } of projects) {
21
+ if (adapter.id !== "dotnet")
22
+ continue;
23
+ const entry = { project: ctx.name };
24
+ const sdk = sdkProblem(ctx.root, probes, root);
25
+ if (sdk)
26
+ entry.sdk = sdk;
27
+ const solution = solutionFor(ctx, projectCommands(adapter, ctx));
28
+ if ("note" in solution)
29
+ entry.note = solution.note;
30
+ else {
31
+ try {
32
+ entry.setup = solutionSetup(solution.solution, probes, root, solution.only);
33
+ }
34
+ catch (err) {
35
+ entry.note = `can't read ${relativePath(root, solution.solution)}: ${err instanceof Error ? err.message : String(err)}`;
36
+ }
37
+ }
38
+ out.push(entry);
39
+ }
40
+ return out;
41
+ }
42
+ /** A `.sln`, `.slnx` or `.slnf` named by a command. */
43
+ const SOLUTION_ARG = /(?<=^|\s)(?:(["'])([^"']+\.sln[xf]?)\1|([^\s"']+\.sln[xf]?))(?=\s|$)/;
44
+ /** The solution path in a SOLUTION_ARG match, quoted or not. */
45
+ const solutionArg = (m) => m?.[2] ?? m?.[3];
46
+ /** A project file (`.csproj`, `.fsproj`, ...) named by a command. */
47
+ const PROJECT_ARG = /(?<=^|\s)["']?[^\s"']+\.\w*proj["']?(?=\s|$)/;
48
+ /**
49
+ * The solution a project builds: the one its build or test command names, or
50
+ * the only one in its directory. When the command names a solution filter
51
+ * (a user's own or flagrm.slnf), the solution it filters, with `only` its
52
+ * projects: that is the scope, which flagrm only narrows further.
53
+ */
54
+ function solutionFor(ctx, commands) {
55
+ for (const command of [commands.build, commands.test]) {
56
+ if (command && PROJECT_ARG.test(command) && !SOLUTION_ARG.test(command)) {
57
+ return { note: `\`${command}\` builds a project, not a solution: nothing to narrow` };
58
+ }
59
+ }
60
+ for (const command of [commands.build, commands.test]) {
61
+ const arg = command && solutionArg(SOLUTION_ARG.exec(command));
62
+ if (!arg)
63
+ continue;
64
+ const file = path.resolve(ctx.root, arg);
65
+ if (!fs.existsSync(file))
66
+ return { note: `${arg} (from \`${command}\`) not found` };
67
+ if (!file.endsWith(".slnf"))
68
+ return { solution: file };
69
+ try {
70
+ const { path: inner, projects } = JSON.parse(fs.readFileSync(file, "utf8"))?.solution ?? {};
71
+ if (typeof inner === "string") {
72
+ const solution = path.resolve(path.dirname(file), inner.replace(/\\/g, "/"));
73
+ const dir = path.dirname(solution);
74
+ const only = Array.isArray(projects)
75
+ ? projects.filter((p) => typeof p === "string").map((p) => path.resolve(dir, p.replace(/\\/g, "/")))
76
+ : undefined;
77
+ return { solution, only };
78
+ }
79
+ }
80
+ catch {
81
+ return { note: `can't read ${arg}` };
82
+ }
83
+ }
84
+ const found = fg.sync("*.{sln,slnx}", { cwd: ctx.root, absolute: true });
85
+ if (found.length === 1)
86
+ return { solution: found[0] };
87
+ return { note: found.length ? "several solutions: name one in build/test" : "no solution file found" };
88
+ }
89
+ /**
90
+ * Create each solution's flagrm.slnf without the projects to leave out, keep
91
+ * it out of git through `.git/info/exclude` (it describes this machine, not
92
+ * the repository), and point the project's build and test at it in the config.
93
+ */
94
+ export function writeScope(setups, projects, root, configFile) {
95
+ const steps = [];
96
+ const commandUpdates = [];
97
+ for (const s of setups) {
98
+ if (!s.setup?.leaveOut.length)
99
+ continue;
100
+ const { solution, leaveOut, projects: all } = s.setup;
101
+ const slnf = path.join(path.dirname(solution), SLNF);
102
+ const out = new Set(leaveOut.map((l) => l.entry));
103
+ const content = `${JSON.stringify({ solution: { path: path.basename(solution), projects: all.map((p) => p.entry).filter((e) => !out.has(e)) } }, null, 2)}\n`;
104
+ const before = fs.existsSync(slnf) ? fs.readFileSync(slnf, "utf8") : undefined;
105
+ if (before !== content)
106
+ fs.writeFileSync(slnf, content, "utf8");
107
+ steps.push({
108
+ path: relativePath(root, slnf),
109
+ status: before === undefined ? "created" : before === content ? "unchanged" : "updated",
110
+ note: `${all.length - out.size} of ${all.length} projects`,
111
+ });
112
+ steps.push(excludeFromGit(root, slnf));
113
+ const target = projects.find((p) => p.ctx.name === s.project);
114
+ if (target) {
115
+ const commands = projectCommands(target.adapter, target.ctx);
116
+ const relative = relativePath(target.ctx.root, slnf) || SLNF;
117
+ const rel = /\s/.test(relative) ? `"${relative}"` : relative;
118
+ commandUpdates.push({
119
+ project: s.project,
120
+ // The resolved commands: the configured ones, or the adapter's defaults when none are.
121
+ build: commands.build && pointAt(commands.build, rel),
122
+ test: commands.test && pointAt(commands.test, rel),
123
+ });
124
+ }
125
+ }
126
+ if (commandUpdates.length)
127
+ steps.push(updateConfig(configFile, root, commandUpdates));
128
+ return steps;
129
+ }
130
+ /**
131
+ * `command` building `slnf` instead of the solution it names, or the solution
132
+ * `dotnet` would find. A command naming a project file is left as it is:
133
+ * `dotnet` takes one project or solution, not both.
134
+ */
135
+ export function pointAt(command, slnf) {
136
+ if (SOLUTION_ARG.test(command))
137
+ return command.replace(SOLUTION_ARG, slnf);
138
+ if (PROJECT_ARG.test(command))
139
+ return command;
140
+ return command.replace(/\bdotnet\s+(build|test)\b/, `dotnet $1 ${slnf}`);
141
+ }
142
+ function excludeFromGit(root, file) {
143
+ const git = (...args) => spawnSync("git", args, { cwd: root, encoding: "utf8" });
144
+ const top = git("rev-parse", "--show-toplevel");
145
+ const exclude = git("rev-parse", "--path-format=absolute", "--git-path", "info/exclude");
146
+ if (top.status !== 0 || exclude.status !== 0)
147
+ return { path: ".git/info/exclude", status: "skipped", note: "not a git repository" };
148
+ const excludeFile = exclude.stdout.trim();
149
+ const line = `/${relativePath(fs.realpathSync(top.stdout.trim()), fs.realpathSync(file))}`;
150
+ const text = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
151
+ const shown = relativePath(root, excludeFile);
152
+ if (text.split(/\r?\n/).includes(line))
153
+ return { path: shown, status: "unchanged" };
154
+ fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
155
+ fs.appendFileSync(excludeFile, `${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`, "utf8");
156
+ return { path: shown, status: "updated", note: line };
157
+ }
158
+ /** Set the build and test commands of `projects:` entries in place, keeping the file's comments and layout. */
159
+ function updateConfig(configFile, root, updates) {
160
+ const paste = updates.map((u) => `${u.project}: build: ${u.build}; test: ${u.test}`).join("; ");
161
+ if (!configFile || configFile.endsWith(".json")) {
162
+ return {
163
+ path: configFile ? relativePath(root, configFile) : "flagrm.config.yaml",
164
+ status: "skipped",
165
+ note: `set ${paste}`,
166
+ };
167
+ }
168
+ const text = fs.readFileSync(configFile, "utf8");
169
+ const doc = YAML.parseDocument(text);
170
+ const list = doc.get("projects");
171
+ const missing = [];
172
+ for (const u of updates) {
173
+ const index = YAML.isSeq(list)
174
+ ? list.items.findIndex((item) => YAML.isMap(item) && item.get("name") === u.project)
175
+ : -1;
176
+ if (index === -1) {
177
+ missing.push(u.project);
178
+ continue;
179
+ }
180
+ if (u.build)
181
+ doc.setIn(["projects", index, "build"], u.build);
182
+ if (u.test)
183
+ doc.setIn(["projects", index, "test"], u.test);
184
+ }
185
+ const next = doc.toString();
186
+ if (next !== text)
187
+ fs.writeFileSync(configFile, next, "utf8");
188
+ const note = missing.length
189
+ ? `not in projects: (${missing.join(", ")}): set ${paste}`
190
+ : updates.map((u) => `${u.project}: \`${u.build}\`, \`${u.test}\``).join("; ");
191
+ return { path: relativePath(root, configFile), status: next === text ? "unchanged" : "updated", note };
192
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Which AI coding tools `flagrm init` sets up, and how the choice is made:
3
+ * `--tool`, the `tools:` saved in flagrm.config.yaml, or a question with the
4
+ * tools the repository already uses pre-selected.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import readline from "node:readline/promises";
9
+ import YAML from "yaml";
10
+ import { findDefaultConfigFile, parseTools, TOOL_IDS } from "./config.js";
11
+ import { relativePath } from "./util.js";
12
+ export const TOOL_LABELS = {
13
+ "claude-code": "Claude Code",
14
+ copilot: "GitHub Copilot",
15
+ codex: "Codex",
16
+ };
17
+ /** The tools a repository already uses: `.claude/`, Copilot instructions or skills, AGENTS.md or `.agents/`. */
18
+ export function detectTools(root) {
19
+ const has = (rel) => fs.existsSync(path.join(root, rel));
20
+ const found = {
21
+ "claude-code": has(".claude"),
22
+ copilot: has(".github/copilot-instructions.md") || has(".github/skills") || has(".github/prompts"),
23
+ codex: has("AGENTS.md") || has(".agents"),
24
+ };
25
+ return TOOL_IDS.filter((t) => found[t]);
26
+ }
27
+ /** The `tools:` saved in the config file in `root`, if any. Throws on an invalid value. */
28
+ export function savedTools(root) {
29
+ const file = findDefaultConfigFile(root);
30
+ if (!file)
31
+ return undefined;
32
+ const text = fs.readFileSync(file, "utf8");
33
+ const raw = file.endsWith(".json") ? JSON.parse(text) : YAML.parse(text);
34
+ return parseTools(raw?.tools, `${path.basename(file)}: tools`);
35
+ }
36
+ /** Save `tools` in the config file in `root`, in place (comments and layout kept). */
37
+ export function saveTools(root, tools) {
38
+ const file = findDefaultConfigFile(root);
39
+ if (!file)
40
+ return { path: "flagrm.config.yaml", status: "skipped" };
41
+ const rel = relativePath(root, file);
42
+ const text = fs.readFileSync(file, "utf8");
43
+ let current;
44
+ try {
45
+ current = savedTools(root)?.join();
46
+ }
47
+ catch {
48
+ // An invalid `tools:` (say `[cursor]`) is what `init --tool` replaces.
49
+ }
50
+ if (current === tools.join())
51
+ return { path: rel, status: "unchanged" };
52
+ let next;
53
+ if (file.endsWith(".json")) {
54
+ next = `${JSON.stringify({ ...JSON.parse(text), tools }, null, 2)}\n`;
55
+ }
56
+ else {
57
+ const doc = YAML.parseDocument(text);
58
+ const seq = doc.createNode(tools);
59
+ seq.flow = true;
60
+ if (doc.has("tools"))
61
+ doc.set("tools", seq);
62
+ // First, above `projects:`, as the template has it.
63
+ else if (YAML.isMap(doc.contents))
64
+ doc.contents.items.unshift(doc.createPair("tools", seq));
65
+ else
66
+ return { path: rel, status: "skipped", note: `add \`tools: [${tools.join(", ")}]\`` };
67
+ next = doc.toString();
68
+ }
69
+ if (next === text)
70
+ return { path: rel, status: "unchanged" };
71
+ fs.writeFileSync(file, next, "utf8");
72
+ return { path: rel, status: "updated", note: `tools: [${tools.join(", ")}]` };
73
+ }
74
+ /**
75
+ * Ask which tools to set up, with `preselected` checked: the user types the
76
+ * numbers to set up (`1,3`), or presses Enter to keep the pre-selection.
77
+ */
78
+ export async function askTools(preselected, input = process.stdin, output = process.stdout) {
79
+ const rl = readline.createInterface({ input, output });
80
+ try {
81
+ output.write("Which AI coding tools should flagrm set up?\n");
82
+ TOOL_IDS.forEach((t, i) => {
83
+ output.write(` ${i + 1}. [${preselected.includes(t) ? "x" : " "}] ${TOOL_LABELS[t]}\n`);
84
+ });
85
+ const fallback = preselected.length ? preselected : [...TOOL_IDS];
86
+ const defaults = fallback.map((t) => TOOL_IDS.indexOf(t) + 1).join(",");
87
+ for (;;) {
88
+ let answer;
89
+ try {
90
+ answer = (await rl.question(`Numbers, comma-separated [${defaults}]: `)).trim();
91
+ }
92
+ catch {
93
+ // Ctrl+D or Ctrl+C: nothing was chosen.
94
+ throw new Error(`Cancelled: no tools chosen. Run flagrm init again, or pass --tool ${TOOL_IDS.join(",")}.`);
95
+ }
96
+ if (!answer)
97
+ return fallback;
98
+ const picks = answer.split(/[\s,]+/).map(Number);
99
+ if (picks.every((n) => Number.isInteger(n) && n >= 1 && n <= TOOL_IDS.length)) {
100
+ return TOOL_IDS.filter((_, i) => picks.includes(i + 1));
101
+ }
102
+ output.write(`Enter numbers from 1 to ${TOOL_IDS.length}, e.g. 1,3.\n`);
103
+ }
104
+ }
105
+ finally {
106
+ rl.close();
107
+ }
108
+ }
@@ -14,8 +14,14 @@
14
14
  * `failedNames`: the comparison is in `tests`, failed tests in the findings),
15
15
  * and one `tests` warning for all tests that no longer run.
16
16
  * - 6: `verify` reports `skipped`, the checks `--skip` left out.
17
+ * - 7: `baseline --json` prints a summary of `baseline.json`: test results as
18
+ * counts and at most 20 short failed test names (`failedTests`), no
19
+ * `config`. `baseline.json` itself is unchanged, so version 6 files are
20
+ * still read.
17
21
  */
18
- export const JSON_SCHEMA_VERSION = 6;
22
+ export const JSON_SCHEMA_VERSION = 7;
23
+ /** `baseline.json` versions this flagrm reads: 7 only added optional fields to 6. */
24
+ export const READABLE_BASELINE_VERSIONS = [6, 7];
19
25
  // ---------------------------------------------------------------------------
20
26
  // verify
21
27
  // ---------------------------------------------------------------------------
package/dist/core/util.js CHANGED
@@ -16,3 +16,13 @@ export function readJson(file) {
16
16
  return undefined;
17
17
  }
18
18
  }
19
+ /** Whether `exe` can be run from `cwd`: a path that exists, or a program on PATH (with PATHEXT on Windows). */
20
+ export function onPath(exe, cwd) {
21
+ if (exe.includes("/") || exe.includes("\\"))
22
+ return fs.existsSync(path.resolve(cwd, exe));
23
+ const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
24
+ return (process.env.PATH ?? "")
25
+ .split(path.delimiter)
26
+ .filter(Boolean)
27
+ .some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, exe + ext))));
28
+ }
@@ -10,8 +10,9 @@ import path from "node:path";
10
10
  import { errorExcerpt, templateCheckGap } from "../baseline.js";
11
11
  import { escapeRegExp, isTestPath } from "../scan.js";
12
12
  import { relativePath } from "../util.js";
13
- import { checkResult, plural, skippedResult } from "./context.js";
14
- import { diffTestNames } from "./test-results.js";
13
+ import { checkResult, configFindings, plural, skippedResult } from "./context.js";
14
+ import { classifyRemovedTests } from "./removed-tests.js";
15
+ import { diffTestNames, shortTestNames, splitFailures } from "./test-results.js";
15
16
  function failedRunFindings(run) {
16
17
  const findings = [
17
18
  {
@@ -21,15 +22,29 @@ function failedRunFindings(run) {
21
22
  file: run.log,
22
23
  },
23
24
  ];
24
- for (const line of errorExcerpt(run.log))
25
- findings.push({ severity: "info", message: line, project: run.project });
25
+ if (run.failure)
26
+ findings.push(setupFinding(run));
27
+ else
28
+ for (const line of errorExcerpt(run.log))
29
+ findings.push({ severity: "info", message: line, project: run.project });
26
30
  return findings;
27
31
  }
32
+ /** A failed run's setup problem: the machine, not the edit, is what needs fixing. */
33
+ function setupFinding(run) {
34
+ return { severity: "info", message: `setup problem: ${run.failure?.message}`, project: run.project };
35
+ }
36
+ /** No command to run: skipped, unless the baseline had one, which is worth a warning. */
37
+ function noCommandResult(v, id) {
38
+ const summary = `no ${id === "build" ? "build" : "test"} command configured`;
39
+ const findings = configFindings(v, id);
40
+ return findings.length ? checkResult(id, summary, findings) : skippedResult(id, summary);
41
+ }
28
42
  export function buildCheck(v) {
29
43
  const runs = v.runs.filter((r) => r.check === "build");
30
44
  if (runs.length === 0)
31
- return skippedResult("build", "no build command configured");
45
+ return noCommandResult(v, "build");
32
46
  const findings = runs.flatMap((r) => (r.exitCode === 0 ? [] : failedRunFindings(r)));
47
+ findings.push(...configFindings(v, "build"));
33
48
  for (const run of runs) {
34
49
  const project = v.projects.find(({ ctx }) => ctx.name === run.project);
35
50
  const gap = project && templateCheckGap(project.adapter, run.command);
@@ -58,24 +73,104 @@ export function compareTests(v) {
58
73
  after: after?.total,
59
74
  removed: [],
60
75
  added: [],
76
+ deleted: [],
77
+ renamed: [],
78
+ excludedByConfig: [],
79
+ unexplained: [],
80
+ newFailures: [],
81
+ knownFailures: [],
61
82
  };
62
- if (before && after)
63
- Object.assign(comparison, diffTestNames(before.names, after.names));
83
+ if (after)
84
+ comparison.failed = after.failed;
85
+ const failures = splitBaselineFailures(v, run);
86
+ if (failures) {
87
+ comparison.newFailures = shortTestNames(failures.new);
88
+ comparison.knownFailures = shortTestNames(failures.known);
89
+ }
90
+ if (before && after) {
91
+ const { removed, added } = diffTestNames(before.names, after.names);
92
+ Object.assign(comparison, { removed, added });
93
+ Object.assign(comparison, classifyRemovedTests(removed, added, after.names, {
94
+ root: v.root,
95
+ sha: v.baseline.git.sha,
96
+ changedFiles: v.changedFiles,
97
+ names: v.baseline.names,
98
+ testConfigChanged: v.configChanges.some((c) => c.check === "tests" && c.project === run.project),
99
+ }));
100
+ }
64
101
  out.push(comparison);
65
102
  }
66
103
  return out;
67
104
  }
105
+ /**
106
+ * For a test run that exited non-zero: its failed tests split into the ones
107
+ * that already failed at the baseline and new ones. Undefined when that can't
108
+ * be told (no result files now or at the baseline, or none of them failed, so
109
+ * the command broke some other way).
110
+ */
111
+ function splitBaselineFailures(v, run) {
112
+ const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
113
+ if (!before || !run.results?.failed)
114
+ return undefined;
115
+ return splitFailures(before.failedNames, run.results.failedNames);
116
+ }
117
+ /**
118
+ * A failed test run fails the check, unless every failed test already failed
119
+ * at the baseline: those are known failures, and only warn.
120
+ */
121
+ function failedTestFindings(run, failures) {
122
+ const list = (names, prefix) => shortTestNames(names)
123
+ .slice(0, 20)
124
+ .map((name) => ({ severity: "info", message: `${prefix}: ${name}`, project: run.project }));
125
+ if (!failures) {
126
+ return [...failedRunFindings(run).slice(0, 1), ...list(run.results?.failedNames ?? [], "failed")];
127
+ }
128
+ if (failures.new.length === 0) {
129
+ return [
130
+ {
131
+ severity: "warn",
132
+ message: `${plural(failures.known.length, "test fails", "tests fail")}, as at the baseline (known failures; log: ${run.log})`,
133
+ project: run.project,
134
+ file: run.log,
135
+ },
136
+ ...list(failures.known, "known failure"),
137
+ ];
138
+ }
139
+ return [
140
+ {
141
+ severity: "fail",
142
+ message: `${plural(failures.new.length, "test fails that", "tests fail that")} passed or didn't run at the baseline (log: ${run.log})`,
143
+ project: run.project,
144
+ file: run.log,
145
+ },
146
+ ...list(failures.new, "new failure"),
147
+ ...(failures.known.length
148
+ ? [
149
+ {
150
+ severity: "info",
151
+ message: `plus ${plural(failures.known.length, "known failure")} from the baseline`,
152
+ project: run.project,
153
+ },
154
+ ]
155
+ : []),
156
+ ];
157
+ }
68
158
  export function testsCheck(v, comparisons) {
69
159
  const runs = v.runs.filter((r) => r.check === "test");
70
160
  if (runs.length === 0)
71
- return skippedResult("tests", "no test command configured");
72
- const findings = [];
161
+ return noCommandResult(v, "tests");
162
+ const findings = configFindings(v, "tests");
163
+ let failed = 0;
164
+ let known = 0;
73
165
  for (const run of runs) {
74
166
  if (run.exitCode !== 0) {
75
- findings.push(...failedRunFindings(run).slice(0, 1));
76
- for (const name of run.results?.failedNames.slice(0, 20) ?? []) {
77
- findings.push({ severity: "info", message: `failed: ${name}`, project: run.project });
78
- }
167
+ const failures = splitBaselineFailures(v, run);
168
+ if (failures?.new.length !== 0)
169
+ failed++;
170
+ known += failures?.known.length ?? 0;
171
+ findings.push(...failedTestFindings(run, failures));
172
+ if (run.failure)
173
+ findings.push(setupFinding(run));
79
174
  }
80
175
  const ctx = v.projects.find(({ ctx }) => ctx.name === run.project)?.ctx;
81
176
  const project = ctx?.project;
@@ -94,13 +189,16 @@ export function testsCheck(v, comparisons) {
94
189
  project: run.project,
95
190
  });
96
191
  }
97
- const removed = comparison?.removed.length ?? 0;
98
- if (removed) {
192
+ const unexplained = comparison?.unexplained ?? [];
193
+ if (unexplained.length) {
99
194
  findings.push({
100
195
  severity: "warn",
101
- message: `${plural(removed, "test no longer runs", "tests no longer run")} (fine if they only covered the OFF path or were renamed; say so in the PR)`,
196
+ message: `${plural(unexplained.length, "test no longer runs", "tests no longer run")} though the diff doesn't delete or rename it and the test config is unchanged: account for ${unexplained.length === 1 ? "it" : "them"} in the PR`,
102
197
  project: run.project,
103
198
  });
199
+ for (const name of shortTestNames(unexplained).slice(0, 20)) {
200
+ findings.push({ severity: "info", message: `no longer runs: ${name}`, project: run.project });
201
+ }
104
202
  }
105
203
  if (run.results && ctx) {
106
204
  for (const file of changedTestFiles(v, ctx.root)) {
@@ -117,11 +215,21 @@ export function testsCheck(v, comparisons) {
117
215
  }
118
216
  const counts = comparisons
119
217
  .filter((c) => c.after !== undefined)
120
- .map((c) => (c.before === undefined ? `${c.project} ${c.after}` : `${c.project} ${c.before} → ${c.after}`));
121
- const failed = runs.filter((r) => r.exitCode !== 0).length;
218
+ .map((c) => {
219
+ if (c.before === undefined)
220
+ return `${c.project} ${c.after}`;
221
+ const why = [
222
+ c.deleted.length && `${c.deleted.length} deleted`,
223
+ c.renamed.length && `${c.renamed.length} renamed`,
224
+ c.excludedByConfig.length && `${c.excludedByConfig.length} excluded by config`,
225
+ c.unexplained.length && `${c.unexplained.length} unexplained`,
226
+ ].filter(Boolean);
227
+ return `${c.project} ${c.before} → ${c.after}${why.length ? `: ${why.join(", ")}` : ""}`;
228
+ });
122
229
  const summary = [
123
230
  failed ? `${plural(failed, "test run")} failed` : "tests passed",
124
- counts.length ? `(${counts.join(", ")})` : undefined,
231
+ known ? `apart from ${plural(known, "known failure")}` : undefined,
232
+ counts.length ? `(${counts.join("; ")})` : undefined,
125
233
  ]
126
234
  .filter(Boolean)
127
235
  .join(" ");
@@ -1,3 +1,12 @@
1
+ /** Warnings about the configuration changes that affect check `id`. */
2
+ export function configFindings(v, id) {
3
+ return v.configChanges
4
+ .filter((c) => c.check === id)
5
+ .flatMap((c) => [
6
+ { severity: "warn", message: c.message, project: c.project },
7
+ ...(c.details ?? []).map((line) => ({ severity: "info", message: line, project: c.project })),
8
+ ]);
9
+ }
1
10
  /** A check's status is the worst severity among its findings. */
2
11
  export function checkResult(id, summary, findings) {
3
12
  const status = findings.some((f) => f.severity === "fail")