flagrm 0.3.0 → 0.4.1
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/CHANGELOG.md +100 -0
- package/README.md +56 -29
- package/dist/adapters/angular/component-files.js +41 -0
- package/dist/adapters/angular/flow.js +121 -0
- package/dist/adapters/angular/holders.js +194 -0
- package/dist/adapters/angular/index.js +20 -1
- package/dist/adapters/angular/inputs.js +181 -0
- package/dist/cli.js +41 -21
- package/dist/core/baseline.js +60 -31
- package/dist/core/change-filter.js +3 -2
- package/dist/core/config-snapshot.js +26 -3
- package/dist/core/config.js +5 -1
- package/dist/core/detect.js +4 -4
- package/dist/core/doctor.js +26 -6
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +50 -3
- package/dist/core/hook.js +13 -2
- package/dist/core/install.js +26 -11
- package/dist/core/inventory.js +3 -1
- package/dist/core/markdown.js +4 -0
- package/dist/core/name-owner.js +179 -0
- package/dist/core/report.js +7 -0
- package/dist/core/run-cache.js +147 -0
- package/dist/core/scan.js +30 -3
- package/dist/core/scope-files.js +14 -0
- package/dist/core/scope.js +3 -3
- package/dist/core/test-runner.js +136 -0
- package/dist/core/types.js +9 -4
- package/dist/core/verify/commands.js +160 -22
- package/dist/core/verify/coverage.js +92 -0
- package/dist/core/verify/index.js +32 -5
- package/dist/core/verify/log-results.js +127 -0
- package/dist/core/verify/removed-tests.js +90 -7
- package/dist/core/verify/test-results.js +58 -10
- package/package.json +4 -4
- package/skills/flagrm-remove/SKILL.md +28 -8
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
|
@@ -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,
|
|
39
|
-
const
|
|
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
|
|
74
|
-
after: after
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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, "
|
|
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
|
|
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
|
-
|
|
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 {
|
|
13
|
-
import {
|
|
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 = [
|
|
27
|
-
|
|
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 = {
|
|
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");
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test results from a test command's log, for a run without result files:
|
|
3
|
+
* the failing tests' names and the summary counts the common runners print
|
|
4
|
+
* (Karma, Jest, Vitest, `dotnet test`; Go and pytest failures without
|
|
5
|
+
* counts). Passing tests aren't in these logs, so only failing ones are named.
|
|
6
|
+
*/
|
|
7
|
+
import { JUNIT_SEPARATOR } from "./test-results.js";
|
|
8
|
+
const vitestName = (name) => name.split(" > ").join(JUNIT_SEPARATOR);
|
|
9
|
+
/** A failing test's name in one log line, per runner. */
|
|
10
|
+
const FAILED = [
|
|
11
|
+
// Karma: `Chrome Headless 120.0.0.0 (Mac OS 10.15.7) CheckoutComponent places the order FAILED`
|
|
12
|
+
[/^\S.*? \d[\d.]* \((?:Mac OS|Linux|Windows|Ubuntu)[^)]*\) (.+?) FAILED$/, (m) => m[1]],
|
|
13
|
+
// Jest: `● CheckoutComponent › places the order`; its notices (`● Console`, `● Multiple configurations found:`) aren't tests
|
|
14
|
+
[/^● (.+)$/, (m) => (/^(?:Test suite failed to run|Console)$|:$/.test(m[1]) ? undefined : m[1])],
|
|
15
|
+
// Vitest: `FAIL src/a.spec.ts > Suite > title` (optionally after a `|project|` tag) and `× Suite > title 12ms`
|
|
16
|
+
[/^FAIL\s+(?:\|[^|]+\|\s+)?\S+ > (.+)$/, (m) => vitestName(m[1])],
|
|
17
|
+
[/^[×✗] (.+ > .+?)(?: \d+ms)?$/, (m) => vitestName(m[1])],
|
|
18
|
+
// dotnet test: `Failed Shop.Tests.CheckoutTests.Quote(express: False) [12 ms]`
|
|
19
|
+
[/^Failed (\S.*?) \[(?:< ?)?[\d.,]+ (?:ms|s|m)\]$/, (m) => m[1]],
|
|
20
|
+
// go test: `--- FAIL: TestX (0.00s)`
|
|
21
|
+
[/^--- FAIL: (\S+)/, (m) => m[1]],
|
|
22
|
+
// pytest: `FAILED tests/test_a.py::test_x - AssertionError`
|
|
23
|
+
[/^FAILED (\S+::\S+)/, (m) => m[1]],
|
|
24
|
+
];
|
|
25
|
+
function count(parts, word) {
|
|
26
|
+
return Number(new RegExp(`(\\d+) ${word}`).exec(parts)?.[1] ?? 0);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The summary counts in one log line; `sum` for runners that print one
|
|
30
|
+
* summary per test project, `browser` for Karma's one summary per browser.
|
|
31
|
+
*/
|
|
32
|
+
function summary(line) {
|
|
33
|
+
// Karma, possibly several `\r`-separated progress updates: the last one counts.
|
|
34
|
+
const karma = [
|
|
35
|
+
...line.matchAll(/(?:^|\r)\s*(?:(\S[^\r]*?):\s+)?Executed (\d+) of (\d+)(?: \((\d+) FAILED\))?(?: \(skipped (\d+)\))?/g),
|
|
36
|
+
].pop();
|
|
37
|
+
if (karma) {
|
|
38
|
+
const failed = Number(karma[4] ?? 0);
|
|
39
|
+
return {
|
|
40
|
+
counts: { total: Number(karma[3]), passed: Number(karma[2]) - failed, failed, skipped: Number(karma[5] ?? 0) },
|
|
41
|
+
browser: karma[1] ?? "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const jest = /^Tests:\s+(.*?)(\d+) total/.exec(line);
|
|
45
|
+
if (jest) {
|
|
46
|
+
return {
|
|
47
|
+
counts: {
|
|
48
|
+
total: Number(jest[2]),
|
|
49
|
+
passed: count(jest[1], "passed"),
|
|
50
|
+
failed: count(jest[1], "failed"),
|
|
51
|
+
skipped: count(jest[1], "skipped") + count(jest[1], "todo"),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const vitest = /^Tests\s+(.+)\((\d+)\)$/.exec(line);
|
|
56
|
+
if (vitest) {
|
|
57
|
+
return {
|
|
58
|
+
counts: {
|
|
59
|
+
total: Number(vitest[2]),
|
|
60
|
+
passed: count(vitest[1], "passed"),
|
|
61
|
+
failed: count(vitest[1], "failed"),
|
|
62
|
+
skipped: count(vitest[1], "skipped") + count(vitest[1], "todo"),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const dotnet = /^(?:Failed|Passed)!\s+-\s+Failed:\s+(\d+), Passed:\s+(\d+), Skipped:\s+(\d+), Total:\s+(\d+)/.exec(line);
|
|
67
|
+
if (dotnet) {
|
|
68
|
+
return {
|
|
69
|
+
counts: {
|
|
70
|
+
failed: Number(dotnet[1]),
|
|
71
|
+
passed: Number(dotnet[2]),
|
|
72
|
+
skipped: Number(dotnet[3]),
|
|
73
|
+
total: Number(dotnet[4]),
|
|
74
|
+
},
|
|
75
|
+
sum: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const add = (a, b) => ({
|
|
81
|
+
total: a.total + b.total,
|
|
82
|
+
passed: a.passed + b.passed,
|
|
83
|
+
failed: a.failed + b.failed,
|
|
84
|
+
skipped: a.skipped + b.skipped,
|
|
85
|
+
});
|
|
86
|
+
export function parseTestLog(text) {
|
|
87
|
+
const failed = new Set();
|
|
88
|
+
let counts;
|
|
89
|
+
const browsers = new Map();
|
|
90
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors
|
|
91
|
+
for (const raw of text.replace(/\u001b\[[0-9;]*m/g, "").split(/\r?\n/)) {
|
|
92
|
+
const line = raw.trim();
|
|
93
|
+
const found = summary(line);
|
|
94
|
+
if (found?.browser !== undefined) {
|
|
95
|
+
// Several browsers each run every test: the last summary per browser counts, summed over browsers.
|
|
96
|
+
browsers.set(found.browser, found.counts);
|
|
97
|
+
counts = [...browsers.values()].reduce(add);
|
|
98
|
+
}
|
|
99
|
+
else if (found) {
|
|
100
|
+
counts = found.sum && counts ? add(counts, found.counts) : found.counts;
|
|
101
|
+
}
|
|
102
|
+
// Karma's progress updates share a line through `\r`: names are looked for in each part.
|
|
103
|
+
for (const part of line.split("\r")) {
|
|
104
|
+
for (const [pattern, name] of FAILED) {
|
|
105
|
+
const m = pattern.exec(part.trim());
|
|
106
|
+
const found = m && name(m);
|
|
107
|
+
if (found) {
|
|
108
|
+
failed.add(found);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!counts && failed.size === 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const names = [...failed];
|
|
117
|
+
return {
|
|
118
|
+
total: counts?.total ?? 0,
|
|
119
|
+
passed: counts?.passed ?? 0,
|
|
120
|
+
failed: counts?.failed ?? names.length,
|
|
121
|
+
skipped: counts?.skipped ?? 0,
|
|
122
|
+
names,
|
|
123
|
+
failedNames: [...names],
|
|
124
|
+
files: [],
|
|
125
|
+
source: "log",
|
|
126
|
+
};
|
|
127
|
+
}
|