flagrm 0.3.0 → 0.4.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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A file-scoped name (a local, parameter or field) is also used in the files
3
+ * that share its file's scope, such as an Angular component's template. The
4
+ * adapter whose project contains the file says which (`Adapter.scopeFiles`).
5
+ */
6
+ import path from "node:path";
7
+ import { relativePath } from "./util.js";
8
+ /** `rel` (relative to the config `root`) followed by the files sharing its scope, relative too, each once. */
9
+ export function withScopeFiles(projects, root, rel) {
10
+ const file = path.resolve(root, rel);
11
+ const owner = projects.find(({ ctx }) => !path.relative(ctx.root, file).startsWith(".."));
12
+ const more = owner?.adapter.scopeFiles?.(owner.ctx, file) ?? [];
13
+ return [...new Set([rel, ...more.map((f) => relativePath(root, f))])];
14
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Which JavaScript test runner a project uses, read from `angular.json`'s
3
+ * test builder, config files and `package.json`, so the Angular adapter can
4
+ * default to a test command that writes machine-readable results, and
5
+ * doctor can say how to get them. Reads only; anything unreadable is `unknown`.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import fg from "fast-glob";
10
+ import { readJson, relativePath } from "./util.js";
11
+ /** The test target of the first `angular.json` project that has one. */
12
+ function angularTestTarget(root) {
13
+ const json = readJson(path.join(root, "angular.json"));
14
+ for (const project of Object.values(json?.projects ?? {})) {
15
+ const test = (project?.architect ?? project?.targets)?.test;
16
+ if (test?.builder)
17
+ return test;
18
+ }
19
+ return undefined;
20
+ }
21
+ function dependencies(root) {
22
+ const pkg = readJson(path.join(root, "package.json"));
23
+ return new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
24
+ }
25
+ const hasFile = (root, pattern) => fg.sync(pattern, { cwd: root, deep: 1 }).length > 0;
26
+ function karma(root, options) {
27
+ const conf = path.resolve(root, typeof options.karmaConfig === "string" ? options.karmaConfig : "karma.conf.js");
28
+ let text;
29
+ try {
30
+ text = fs.readFileSync(conf, "utf8");
31
+ }
32
+ catch {
33
+ return { kind: "karma" };
34
+ }
35
+ const outputDir = /junitReporter\s*:\s*\{[^}]*?\boutputDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text)?.[2];
36
+ // Without a `plugins` key Karma loads every installed karma-* package.
37
+ const loaded = text.includes("karma-junit-reporter") ||
38
+ (!/\bplugins\s*:/.test(text) && dependencies(root).has("karma-junit-reporter"));
39
+ return loaded && outputDir
40
+ ? { kind: "karma", junit: { outputDir: path.resolve(path.dirname(conf), outputDir) } }
41
+ : { kind: "karma" };
42
+ }
43
+ export function detectTestRunner(root) {
44
+ const target = angularTestTarget(root);
45
+ const builder = target?.builder ?? "";
46
+ const options = target?.options ?? {};
47
+ if (builder === "@angular/build:unit-test")
48
+ return options.runner === "karma" ? karma(root, options) : { kind: "vitest-ng" };
49
+ if (builder.endsWith(":karma"))
50
+ return karma(root, options);
51
+ if (builder.startsWith("@angular-builders/jest"))
52
+ return { kind: "jest", viaNg: true };
53
+ if (hasFile(root, "jest.config.{js,ts,mjs,cjs,json}"))
54
+ return { kind: "jest", viaNg: false };
55
+ if (hasFile(root, "vitest.config.{js,ts,mjs,cjs,mts}"))
56
+ return { kind: "vitest" };
57
+ const deps = dependencies(root);
58
+ if (deps.has("jest"))
59
+ return { kind: "jest", viaNg: false };
60
+ if (deps.has("vitest"))
61
+ return { kind: "vitest" };
62
+ if (hasFile(root, "karma.conf.{js,cjs,ts}"))
63
+ return karma(root, {});
64
+ return { kind: "unknown" };
65
+ }
66
+ /** The flags that make each runner write results to `file` (quoted, relative to the project). */
67
+ function resultFlags(runner, file) {
68
+ switch (runner.kind) {
69
+ case "vitest-ng":
70
+ // The unit-test builder's --output-file applies to the first reporter only.
71
+ return `--reporters=junit --output-file="${file("junit.xml")}"`;
72
+ case "jest":
73
+ return runner.viaNg ? undefined : `--json --outputFile="${file("jest.json")}"`;
74
+ case "vitest":
75
+ return `--reporter=default --reporter=junit --outputFile.junit="${file("junit.xml")}"`;
76
+ default:
77
+ return undefined;
78
+ }
79
+ }
80
+ const RESULT_FILE = {
81
+ "vitest-ng": "junit.xml",
82
+ jest: "jest.json",
83
+ vitest: "junit.xml",
84
+ };
85
+ /**
86
+ * A test command for the runner that writes results into `resultsDir`, and
87
+ * where to read them. Undefined when the runner can't without a setup change
88
+ * (Karma without a JUnit reporter, Jest through an Angular builder, unknown).
89
+ */
90
+ export function runnerTestCommands(runner, projectRoot, resultsDir) {
91
+ if (runner.kind === "karma") {
92
+ return runner.junit
93
+ ? {
94
+ test: "npx ng test --watch=false --reporters=progress --reporters=junit",
95
+ testResults: path.join(runner.junit.outputDir, "**", "*.xml"),
96
+ }
97
+ : undefined;
98
+ }
99
+ const flags = resultFlags(runner, (name) => relativePath(projectRoot, path.join(resultsDir, name)));
100
+ const name = RESULT_FILE[runner.kind];
101
+ if (!flags || !name)
102
+ return undefined;
103
+ const base = runner.kind === "vitest-ng"
104
+ ? "npx ng test --watch=false"
105
+ : runner.kind === "jest"
106
+ ? "npx jest --ci"
107
+ : "npx vitest run";
108
+ return { test: `${base} ${flags}`, testResults: path.join(resultsDir, name) };
109
+ }
110
+ /**
111
+ * How to get test results for a project whose test command has none: one
112
+ * line for doctor and init. `dirFromProject` is a results directory relative
113
+ * to the project (for the command), `dirFromConfig` the same relative to the
114
+ * config file (for `testResults`). `adapterDefault`: the adapter's default
115
+ * test command writes results for this runner (Angular), so removing `test` is a fix too.
116
+ * `configRoot`: the config file's directory, for a results directory karma.conf.js names.
117
+ */
118
+ export function testResultsHint(runner, dirFromProject, dirFromConfig, adapterDefault, configRoot) {
119
+ const flags = resultFlags(runner, (name) => `${dirFromProject}/${name}`);
120
+ const name = RESULT_FILE[runner.kind];
121
+ const fallback = adapterDefault ? " (or remove `test` to use the adapter default)" : "";
122
+ if (flags && name) {
123
+ return `add \`${flags}\` to the test command and set testResults: ${dirFromConfig}/${name}${fallback}`;
124
+ }
125
+ if (runner.kind === "karma" && runner.junit) {
126
+ const results = `./${relativePath(configRoot, runner.junit.outputDir)}/**/*.xml`;
127
+ return `add \`--reporters=progress --reporters=junit\` to the test command and set testResults: ${results}${fallback}`;
128
+ }
129
+ if (runner.kind === "karma") {
130
+ return "npm i -D karma-junit-reporter, add require('karma-junit-reporter') to plugins and junitReporter: { outputDir: 'test-results' } to karma.conf.js; the angular adapter's default test command then writes JUnit";
131
+ }
132
+ if (runner.kind === "jest") {
133
+ return `add \`--json --outputFile="${dirFromProject}/jest.json"\` to the test command if @angular-builders/jest passes them on, and set testResults: ${dirFromConfig}/jest.json`;
134
+ }
135
+ return "make the test runner write JUnit XML, TRX or Jest JSON and point testResults at the file";
136
+ }
@@ -18,11 +18,16 @@
18
18
  * counts and at most 20 short failed test names (`failedTests`), no
19
19
  * `config`. `baseline.json` itself is unchanged, so version 6 files are
20
20
  * still read.
21
+ * - 8: `lint` runs (`check: "lint"`) and the `lint` and `coverage` checks;
22
+ * `baseline.json` gains the config's `lint` commands and `git.dirtyHashes`.
23
+ * Both are optional, so version 6 and 7 files are still read.
21
24
  */
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];
25
+ export const JSON_SCHEMA_VERSION = 8;
26
+ /** `baseline.json` versions this flagrm reads: 7 and 8 only added optional fields to 6. */
27
+ export const READABLE_BASELINE_VERSIONS = [6, 7, 8];
25
28
  // ---------------------------------------------------------------------------
26
29
  // verify
27
30
  // ---------------------------------------------------------------------------
28
- export const CHECK_IDS = ["leftovers", "dead-code", "build", "tests"];
31
+ export const CHECK_IDS = ["leftovers", "dead-code", "build", "tests", "lint", "coverage"];
32
+ /** The check that judges each kind of command run. */
33
+ export const RUN_CHECK_ID = { build: "build", test: "tests", lint: "lint" };
@@ -4,16 +4,21 @@
4
4
  * tests of the OFF path is part of removing a flag, losing any other test is not.
5
5
  * A changed test file none of whose tests ran is a warning too: the test
6
6
  * command doesn't cover it, so verify didn't check the edit.
7
+ *
8
+ * The `lint` check: a failing lint command fails. One that already failed at
9
+ * the baseline (lint debt) only warns, unless its log has error lines the
10
+ * baseline's didn't.
7
11
  */
8
12
  import fs from "node:fs";
9
13
  import path from "node:path";
10
- import { errorExcerpt, templateCheckGap } from "../baseline.js";
14
+ import { errorExcerpt, logLines, looksLikeError, templateCheckGap } from "../baseline.js";
11
15
  import { projectCommands } from "../config-snapshot.js";
12
16
  import { escapeRegExp, isTestPath } from "../scan.js";
17
+ import { RUN_CHECK_ID, } from "../types.js";
13
18
  import { relativePath } from "../util.js";
14
19
  import { checkResult, configFindings, plural, skippedResult } from "./context.js";
15
- import { classifyRemovedTests } from "./removed-tests.js";
16
- import { diffTestNames, shortTestNames, splitFailures } from "./test-results.js";
20
+ import { classifyRemovedTests, declaredTestDiff } from "./removed-tests.js";
21
+ import { diffTestNames, hasTestNames, shortTestNames, splitFailures } from "./test-results.js";
17
22
  function failedRunFindings(run) {
18
23
  const findings = [
19
24
  {
@@ -35,8 +40,9 @@ function setupFinding(run) {
35
40
  return { severity: "info", message: `setup problem: ${run.failure?.message}`, project: run.project };
36
41
  }
37
42
  /** No command to run: skipped, unless the baseline had one, which is worth a warning. */
38
- function noCommandResult(v, id) {
39
- const summary = `no ${id === "build" ? "build" : "test"} command configured`;
43
+ function noCommandResult(v, kind) {
44
+ const id = RUN_CHECK_ID[kind];
45
+ const summary = `no ${kind} command configured`;
40
46
  const findings = configFindings(v, id);
41
47
  return findings.length ? checkResult(id, summary, findings) : skippedResult(id, summary);
42
48
  }
@@ -63,15 +69,104 @@ export function buildCheck(v) {
63
69
  : `${runs.map((r) => r.project).join(", ")} built successfully`;
64
70
  return checkResult("build", summary, findings);
65
71
  }
72
+ export function lintCheck(v) {
73
+ const runs = v.runs.filter((r) => r.check === "lint");
74
+ if (runs.length === 0)
75
+ return noCommandResult(v, "lint");
76
+ const findings = [];
77
+ let failed = 0;
78
+ let known = 0;
79
+ for (const run of runs) {
80
+ if (run.exitCode === 0)
81
+ continue;
82
+ const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "lint");
83
+ // A setup problem, or a run cut short, is no lint debt: it fails even when the baseline failed too.
84
+ if (run.failure || run.interrupted || !before || before.exitCode === 0 || before.failure || before.interrupted) {
85
+ failed++;
86
+ findings.push(...failedRunFindings(run));
87
+ if (!before) {
88
+ findings.push({
89
+ severity: "info",
90
+ message: "the baseline has no lint run to compare with (lint configured after it, or baseline --no-checks), so every lint error fails: show the user",
91
+ project: run.project,
92
+ });
93
+ }
94
+ continue;
95
+ }
96
+ const debt = knownLintFindings(before, run);
97
+ if (debt.some((f) => f.severity === "fail"))
98
+ failed++;
99
+ else
100
+ known++;
101
+ findings.push(...debt);
102
+ }
103
+ findings.push(...configFindings(v, "lint"));
104
+ const summary = failed
105
+ ? `${plural(failed, "lint run")} failed`
106
+ : known
107
+ ? `${plural(known, "lint run fails", "lint runs fail")} as at the baseline`
108
+ : `${runs.map((r) => r.project).join(", ")} linted cleanly`;
109
+ return checkResult("lint", summary, findings);
110
+ }
111
+ /**
112
+ * A lint run that failed at the baseline too: compared line by line with the
113
+ * baseline's log. Numbers are masked first, so an old error whose line moved
114
+ * (`40:5` → `30:5`) or a changed problem count isn't new. Lines are counted,
115
+ * not just compared: ESLint prints an error without its file, so a second
116
+ * identical one is still new. A new line that looks like an error fails; other
117
+ * new lines only warn. At most 20 are listed, errors first.
118
+ */
119
+ function knownLintFindings(before, run) {
120
+ const was = logLines(before.log);
121
+ if (!was) {
122
+ return [
123
+ {
124
+ severity: "warn",
125
+ message: `lint already failed at the baseline; its log wasn't found, so new errors can't be told (log: ${run.log})`,
126
+ project: run.project,
127
+ file: run.log,
128
+ },
129
+ ];
130
+ }
131
+ const mask = (line) => line.replace(/\d+/g, "#");
132
+ const seen = new Map();
133
+ for (const line of was)
134
+ seen.set(mask(line), (seen.get(mask(line)) ?? 0) + 1);
135
+ const added = (logLines(run.log) ?? []).filter((line) => {
136
+ const left = seen.get(mask(line)) ?? 0;
137
+ seen.set(mask(line), left - 1);
138
+ return left <= 0;
139
+ });
140
+ const errors = added.filter(looksLikeError);
141
+ const listed = [...errors, ...added.filter((line) => !looksLikeError(line))].slice(0, 20);
142
+ const head = errors.length
143
+ ? {
144
+ severity: "fail",
145
+ message: `lint already failed at the baseline, but ${plural(errors.length, "error line is", "error lines are")} new: fix ${errors.length === 1 ? "it" : "them"} (log: ${run.log})`,
146
+ project: run.project,
147
+ file: run.log,
148
+ }
149
+ : {
150
+ severity: "warn",
151
+ message: `lint already failed at the baseline: ${added.length ? `${plural(added.length, "new line")} in the log, none an error` : "no new lines in the log"} (log: ${run.log})`,
152
+ project: run.project,
153
+ file: run.log,
154
+ };
155
+ return [
156
+ head,
157
+ ...listed.map((line) => ({ severity: "info", message: `new: ${line}`, project: run.project })),
158
+ ];
159
+ }
66
160
  export function compareTests(v) {
67
161
  const out = [];
68
162
  for (const run of v.runs.filter((r) => r.check === "test")) {
69
163
  const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
70
164
  const after = run.results;
165
+ const counted = (r) => (r && (r.total > 0 || hasTestNames(r)) ? r.total : undefined);
71
166
  const comparison = {
72
167
  project: run.project,
73
- before: before?.total,
74
- after: after?.total,
168
+ before: counted(before),
169
+ after: counted(after),
75
170
  removed: [],
76
171
  added: [],
77
172
  deleted: [],
@@ -88,16 +183,40 @@ export function compareTests(v) {
88
183
  comparison.newFailures = shortTestNames(failures.new);
89
184
  comparison.knownFailures = shortTestNames(failures.known);
90
185
  }
91
- if (before && after) {
186
+ const projectRoot = v.projects.find(({ ctx }) => ctx.name === run.project)?.ctx.root;
187
+ const removedCtx = {
188
+ root: v.root,
189
+ sha: v.baseline.git.sha,
190
+ changedFiles: projectRoot
191
+ ? (v.changedFiles ?? []).filter((rel) => !path.relative(projectRoot, path.join(v.root, rel)).startsWith(".."))
192
+ : v.changedFiles,
193
+ names: v.baseline.names,
194
+ testConfigChanged: v.originalConfigChanges.some((c) => c.check === "tests" && c.project === run.project),
195
+ };
196
+ if (hasTestNames(before) && hasTestNames(after)) {
92
197
  const { removed, added } = diffTestNames(before.names, after.names);
93
198
  Object.assign(comparison, { removed, added });
94
- Object.assign(comparison, classifyRemovedTests(removed, added, after.names, {
95
- root: v.root,
96
- sha: v.baseline.git.sha,
97
- changedFiles: v.changedFiles,
98
- names: v.baseline.names,
99
- testConfigChanged: v.originalConfigChanges.some((c) => c.check === "tests" && c.project === run.project),
100
- }));
199
+ Object.assign(comparison, classifyRemovedTests(removed, added, after.names, removedCtx));
200
+ }
201
+ else {
202
+ // No names from result files on both sides: the test files' declarations show what the diff deleted or renamed.
203
+ const declared = declaredTestDiff(removedCtx);
204
+ const { renamed } = classifyRemovedTests(declared.removed, declared.added, declared.now, removedCtx, declared.files);
205
+ const from = new Set(renamed.map((r) => r.from));
206
+ Object.assign(comparison, {
207
+ removed: declared.removed,
208
+ added: declared.added,
209
+ renamed,
210
+ deleted: declared.removed.filter((name) => !from.has(name)),
211
+ });
212
+ if (comparison.before !== undefined && comparison.after !== undefined) {
213
+ const gap = comparison.before - comparison.after - comparison.deleted.length + (declared.added.length - renamed.length);
214
+ if (gap > 0) {
215
+ comparison.missing = gap;
216
+ if (declared.rows)
217
+ comparison.missingMayBeRows = true;
218
+ }
219
+ }
101
220
  }
102
221
  out.push(comparison);
103
222
  }
@@ -106,13 +225,19 @@ export function compareTests(v) {
106
225
  /**
107
226
  * For a test run that exited non-zero: its failed tests split into the ones
108
227
  * that already failed at the baseline and new ones. Undefined when that can't
109
- * be told (no result files now or at the baseline, or none of them failed, so
228
+ * be told (no results now or at the baseline, from different sources, or none of them failed, so
110
229
  * the command broke some other way).
111
230
  */
112
231
  function splitBaselineFailures(v, run) {
113
232
  const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
114
233
  if (!before || !run.results?.failed)
115
234
  return undefined;
235
+ // Counts without all their names (a log may not name every failing test) can't be split.
236
+ if (run.results.failedNames.length < run.results.failed || before.failedNames.length < before.failed)
237
+ return undefined;
238
+ // Names from a log and from result files aren't spelled alike, so they can't be matched.
239
+ if ((before.source ?? "files") !== (run.results.source ?? "files"))
240
+ return undefined;
116
241
  return splitFailures(before.failedNames, run.results.failedNames);
117
242
  }
118
243
  /**
@@ -125,7 +250,10 @@ function failedTestFindings(run, failures, accepted = false) {
125
250
  .slice(0, 20)
126
251
  .map((name) => ({ severity: "info", message: `${prefix}: ${name}`, project: run.project }));
127
252
  if (!failures) {
128
- return [...failedRunFindings(run).slice(0, 1), ...list(run.results?.failedNames ?? [], "failed")];
253
+ const names = run.results?.failedNames ?? [];
254
+ const [head, ...excerpt] = failedRunFindings(run);
255
+ // Without failing test names, the log's error lines are all there is to go on; the caller adds a setup problem.
256
+ return [head, ...(names.length || run.failure ? [] : excerpt), ...list(names, "failed")];
129
257
  }
130
258
  if (failures.new.length === 0) {
131
259
  return [
@@ -160,7 +288,7 @@ function failedTestFindings(run, failures, accepted = false) {
160
288
  export function testsCheck(v, comparisons) {
161
289
  const runs = v.runs.filter((r) => r.check === "test");
162
290
  if (runs.length === 0)
163
- return noCommandResult(v, "tests");
291
+ return noCommandResult(v, "test");
164
292
  const findings = configFindings(v, "tests");
165
293
  let failed = 0;
166
294
  let known = 0;
@@ -178,14 +306,14 @@ export function testsCheck(v, comparisons) {
178
306
  const ctx = entry?.ctx;
179
307
  const testResults = entry && projectCommands(entry.adapter, entry.ctx).testResults;
180
308
  const comparison = comparisons.find((c) => c.project === run.project);
181
- if (testResults && !run.results) {
309
+ if (testResults && !hasTestNames(run.results)) {
182
310
  findings.push({
183
311
  severity: "warn",
184
- message: `no test results found at ${testResults}, so the test count was not compared`,
312
+ message: `no test results found at ${testResults}, so tests were not compared by name${run.results ? " (failing tests and counts read from the log)" : ""}`,
185
313
  project: run.project,
186
314
  });
187
315
  }
188
- else if (run.results && comparison?.before === undefined) {
316
+ else if (hasTestNames(run.results) && comparison?.before === undefined) {
189
317
  findings.push({
190
318
  severity: "info",
191
319
  message: "the baseline has no test results to compare with (run `flagrm baseline` before the removal)",
@@ -203,7 +331,16 @@ export function testsCheck(v, comparisons) {
203
331
  findings.push({ severity: "info", message: `no longer runs: ${name}`, project: run.project });
204
332
  }
205
333
  }
206
- if (run.results && ctx) {
334
+ const missing = comparison?.missing ?? 0;
335
+ if (missing > 0 && comparison) {
336
+ const configChanged = v.originalConfigChanges.some((c) => c.check === "tests" && c.project === run.project);
337
+ findings.push({
338
+ severity: configChanged || comparison.missingMayBeRows ? "info" : "warn",
339
+ message: `${plural(missing, "more test stopped running", "more tests stopped running")} than the diff deletes (${comparison.before} → ${comparison.after}, ${comparison.deleted.length} deleted): account for ${missing === 1 ? "it" : "them"} in the PR${comparison.missingMayBeRows ? " (data rows and .each cases count separately, so this may be them)" : ""}`,
340
+ project: run.project,
341
+ });
342
+ }
343
+ if (hasTestNames(run.results) && ctx) {
207
344
  for (const file of changedTestFiles(v, ctx.root)) {
208
345
  if (testFileRan(file, ctx.root, run.results.names))
209
346
  continue;
@@ -226,6 +363,7 @@ export function testsCheck(v, comparisons) {
226
363
  c.renamed.length && `${c.renamed.length} renamed`,
227
364
  c.excludedByConfig.length && `${c.excludedByConfig.length} excluded by config`,
228
365
  c.unexplained.length && `${c.unexplained.length} unexplained`,
366
+ c.missing && `${c.missing} unaccounted for`,
229
367
  ].filter(Boolean);
230
368
  return `${c.project} ${c.before} → ${c.after}${why.length ? `: ${why.join(", ")}` : ""}`;
231
369
  });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The `coverage` check: a file the removal changed that no build, test or
3
+ * lint command checks is a warning. A project covers a file inside its path
4
+ * when it ran a command in this verify (or has one `--skip` left out) and one
5
+ * of its `covers` globs (relative to its path; default: everything) matches
6
+ * the file. A file that was already dirty at the baseline and hasn't changed
7
+ * since is no edit of the removal. Outside every project, deleted files and
8
+ * documentation have nothing for a command to check.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { excludeMatcher } from "../change-filter.js";
13
+ import { projectCommands } from "../config-snapshot.js";
14
+ import { blobIds } from "../git.js";
15
+ import { RUN_CHECK_ID } from "../types.js";
16
+ import { relativePath } from "../util.js";
17
+ import { checkResult, plural, skippedResult } from "./context.js";
18
+ /** More uncovered files than this are counted per top-level directory instead. */
19
+ const MAX_FILES = 20;
20
+ const UNCHECKED = "no build, test or lint command checks";
21
+ const DOCS = /\.(?:md|mdx|markdown|txt|rst|adoc)$/i;
22
+ export function coverageCheck(v) {
23
+ if (!v.changedFiles)
24
+ return skippedResult("coverage", "no baseline commit to diff against");
25
+ const skipped = Object.keys(RUN_CHECK_ID).filter((kind) => v.skip?.has(RUN_CHECK_ID[kind]));
26
+ if (skipped.length === 3)
27
+ return skippedResult("coverage", "build, tests and lint skipped (--skip), so no command ran");
28
+ const ran = new Set(v.runs.map((r) => r.project));
29
+ const projects = v.projects.map(({ adapter, ctx }) => {
30
+ const covers = ctx.project.covers;
31
+ const commands = projectCommands(adapter, ctx);
32
+ return {
33
+ name: ctx.name,
34
+ root: ctx.root,
35
+ // A command --skip left out would have checked the file: no reason to warn about the config.
36
+ ran: ran.has(ctx.name) || skipped.some((kind) => commands[kind]),
37
+ covers: covers ? excludeMatcher(covers.map(coversGlob)) : () => true,
38
+ };
39
+ });
40
+ const uncovered = [];
41
+ for (const file of removalFiles(v, v.changedFiles)) {
42
+ const containing = projects
43
+ .map((p) => ({ ...p, rel: relativePath(p.root, path.join(v.root, file)) }))
44
+ .filter((p) => p.rel !== ".." && !p.rel.startsWith("../") && !path.isAbsolute(p.rel));
45
+ if (containing.some((p) => p.ran && p.covers(p.rel)))
46
+ continue;
47
+ // Outside every project, documentation and deleted files have nothing for a command to check.
48
+ if (containing.length === 0 && (DOCS.test(file) || !fs.existsSync(path.join(v.root, file))))
49
+ continue;
50
+ const innermost = containing.sort((a, b) => b.root.length - a.root.length)[0];
51
+ uncovered.push({ file, project: innermost?.name });
52
+ }
53
+ if (uncovered.length === 0)
54
+ return checkResult("coverage", "every changed file is built, tested or linted", []);
55
+ const findings = uncovered.length > MAX_FILES
56
+ ? byDirectory(uncovered)
57
+ : uncovered.map(({ file, project }) => ({
58
+ severity: "warn",
59
+ message: `${file}: ${UNCHECKED} it`,
60
+ ...(project ? { project } : {}),
61
+ }));
62
+ return checkResult("coverage", `${plural(uncovered.length, "changed file")} no command checks`, findings);
63
+ }
64
+ /** The changed files without the ones dirty at the baseline and unchanged since (all of them, for a baseline without their hashes). */
65
+ function removalFiles(v, changed) {
66
+ const { dirtyFiles = [], dirtyHashes } = v.baseline.git ?? {};
67
+ const dirty = new Set(dirtyFiles);
68
+ const candidates = changed.filter((rel) => dirty.has(rel));
69
+ if (candidates.length === 0)
70
+ return changed;
71
+ if (!dirtyHashes)
72
+ return changed.filter((rel) => !dirty.has(rel));
73
+ const now = blobIds(v.root, candidates);
74
+ return changed.filter((rel) => !dirty.has(rel) || !(rel in dirtyHashes) || now[rel] !== dirtyHashes[rel]);
75
+ }
76
+ /** A `covers` entry as a glob: `./src/**` is how the rest of the config writes paths, and `./` or `.` is everything. */
77
+ function coversGlob(glob) {
78
+ const rel = glob.replace(/^\.\//, "").replace(/\/+$/, "");
79
+ return rel === "" || rel === "." ? "**" : rel;
80
+ }
81
+ /** One warning per top-level directory (`.` for files in the root) with its count. */
82
+ function byDirectory(uncovered) {
83
+ const counts = new Map();
84
+ for (const { file } of uncovered) {
85
+ const slash = file.indexOf("/");
86
+ const dir = slash === -1 ? "." : `${file.slice(0, slash)}/`;
87
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
88
+ }
89
+ return [...counts]
90
+ .sort(([a], [b]) => a.localeCompare(b))
91
+ .map(([dir, n]) => ({ severity: "warn", message: `${dir}: ${plural(n, "changed file")} ${UNCHECKED}` }));
92
+ }
@@ -9,9 +9,11 @@ import { flagDir, readBaseline, runChecks, verifyPath } from "../baseline.js";
9
9
  import { changeFilter, changeFilterAt } from "../change-filter.js";
10
10
  import { configChanges as configChangesSince, configSnapshot } from "../config-snapshot.js";
11
11
  import { changedFilesSince, gitState, treeFingerprint } from "../git.js";
12
- import { CHECK_IDS, JSON_SCHEMA_VERSION, } from "../types.js";
13
- import { buildCheck, compareTests, testsCheck } from "./commands.js";
12
+ import { dropRuns, findRuns, plannedRuns, reuseRuns, runsKey, saveRuns } from "../run-cache.js";
13
+ import { CHECK_IDS, JSON_SCHEMA_VERSION, RUN_CHECK_ID, } from "../types.js";
14
+ import { buildCheck, compareTests, lintCheck, testsCheck } from "./commands.js";
14
15
  import { skippedResult } from "./context.js";
16
+ import { coverageCheck } from "./coverage.js";
15
17
  import { deadCodeCheck } from "./dead-code.js";
16
18
  import { leftoversCheck } from "./leftovers.js";
17
19
  export async function verifyFlag(root, projects, flag, options = {}) {
@@ -23,8 +25,12 @@ export async function verifyFlag(root, projects, flag, options = {}) {
23
25
  // flagrm's setup files and `exclude`d paths (the config's and --exclude) aren't part of the removal.
24
26
  const ignore = changeFilter(projects[0]?.ctx.config);
25
27
  const changedFiles = baseline.git.sha ? changedFilesSince(root, baseline.git.sha, ignore) : undefined;
26
- const only = [...(skip.has("build") ? [] : ["build"]), ...(skip.has("tests") ? [] : ["test"])];
27
- const runs = only.length ? await runChecks(projects, path.join(flagDir(root, flag), "verify"), only) : [];
28
+ const only = ["build", "test", "lint"].filter((check) => !skip.has(RUN_CHECK_ID[check]));
29
+ // Before the commands: the key is the tree they run on.
30
+ const key = only.length ? runsKey(root, projects) : undefined;
31
+ const found = key && options.reuse !== false ? findRuns(root, projects, key, plannedRuns(projects, only)) : undefined;
32
+ const logDir = path.join(flagDir(root, flag), "verify");
33
+ const runs = found ? reuseRuns(found, logDir) : only.length ? await runChecks(projects, logDir, only) : [];
28
34
  // The fingerprint, though, is the tree the Stop hook will see next, so take it
29
35
  // after the commands: a test report they write outside .gitignore is part of it.
30
36
  // The Stop hook compares it with its own, so take it exactly as the hook does: from the config file
@@ -33,12 +39,24 @@ export async function verifyFlag(root, projects, flag, options = {}) {
33
39
  const now = configSnapshot(projects);
34
40
  const configChanges = configChangesSince(baseline.acceptedConfig ?? baseline.config, now);
35
41
  const originalConfigChanges = baseline.acceptedConfig ? configChangesSince(baseline.config, now) : configChanges;
36
- const v = { root, flag, projects, baseline, changedFiles, runs, configChanges, originalConfigChanges };
42
+ const v = {
43
+ root,
44
+ flag,
45
+ projects,
46
+ baseline,
47
+ changedFiles,
48
+ runs,
49
+ skip,
50
+ configChanges,
51
+ originalConfigChanges,
52
+ };
37
53
  const tests = compareTests(v);
38
54
  const run = {
39
55
  "dead-code": () => deadCodeCheck(v),
40
56
  build: () => buildCheck(v),
41
57
  tests: () => testsCheck(v, tests),
58
+ lint: () => lintCheck(v),
59
+ coverage: () => coverageCheck(v),
42
60
  };
43
61
  const checks = [];
44
62
  for (const id of CHECK_IDS) {
@@ -64,6 +82,15 @@ export async function verifyFlag(root, projects, flag, options = {}) {
64
82
  runs: runs.map(countsOnly),
65
83
  tests,
66
84
  };
85
+ // Only a full pass is worth reusing: a failure may be flaky or a stopped service.
86
+ if (key && report.status === "pass" && report.skipped.length === 0)
87
+ saveRuns(root, flag, projects, key, runs);
88
+ // A fresh run that fails on this tree outweighs a saved pass for it (`--no-reuse` after a machine change).
89
+ else if (key &&
90
+ !found &&
91
+ checks.some((c) => (c.id === "build" || c.id === "tests" || c.id === "lint") && c.status === "fail")) {
92
+ dropRuns(root, key);
93
+ }
67
94
  const file = verifyPath(root, flag);
68
95
  fs.mkdirSync(path.dirname(file), { recursive: true });
69
96
  fs.writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, "utf8");