flagrm 0.2.1 → 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.
- package/CHANGELOG.md +129 -0
- package/README.md +70 -31
- 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/adapters/dotnet/index.js +11 -1
- package/dist/cli.js +62 -21
- package/dist/core/baseline.js +98 -45
- package/dist/core/change-filter.js +68 -0
- package/dist/core/config-snapshot.js +36 -6
- package/dist/core/config.js +23 -7
- package/dist/core/detect.js +2 -2
- package/dist/core/doctor.js +51 -7
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +80 -16
- package/dist/core/hook.js +9 -6
- 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 +13 -0
- package/dist/core/run-cache.js +137 -0
- package/dist/core/scope-files.js +14 -0
- package/dist/core/scope.js +11 -2
- package/dist/core/test-runner.js +136 -0
- package/dist/core/types.js +9 -4
- package/dist/core/verify/commands.js +170 -29
- package/dist/core/verify/coverage.js +92 -0
- package/dist/core/verify/dead-code.js +9 -2
- package/dist/core/verify/index.js +42 -8
- package/dist/core/verify/log-results.js +127 -0
- package/dist/core/verify/orphans.js +175 -0
- package/dist/core/verify/removed-tests.js +90 -7
- package/dist/core/verify/test-results.js +56 -7
- package/package.json +3 -1
- package/skills/flagrm-remove/SKILL.md +31 -11
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build and test runs a passing full verify saves, so the next baseline or
|
|
3
|
+
* verify on the same tree with the same commands reuses them instead of
|
|
4
|
+
* running them again: in a sequential removal, the next flag's baseline
|
|
5
|
+
* starts on the commit the previous flag's verify passed on.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fg from "fast-glob";
|
|
10
|
+
import { flagDir, matchingFiles, runChecks } from "./baseline.js";
|
|
11
|
+
import { changeFilter } from "./change-filter.js";
|
|
12
|
+
import { configSnapshot, projectCommands } from "./config-snapshot.js";
|
|
13
|
+
import { treeFingerprint } from "./git.js";
|
|
14
|
+
import { packageVersion } from "./package.js";
|
|
15
|
+
import { JSON_SCHEMA_VERSION, RUN_CHECK_ID } from "./types.js";
|
|
16
|
+
import { readJson } from "./util.js";
|
|
17
|
+
/** `.flagrm/<flag>/verify/runs.json`. */
|
|
18
|
+
export function runsPath(root, flag) {
|
|
19
|
+
return path.join(flagDir(root, flag), "verify", "runs.json");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The tree the commands will run on: its fingerprint before they run, without
|
|
23
|
+
* flagrm's setup files and the test result files the runs themselves rewrite.
|
|
24
|
+
* Commands are compared on their own, so leaving out the config file is safe.
|
|
25
|
+
* Unlike the Stop hook's fingerprint, `exclude`d paths count: a build or test
|
|
26
|
+
* may read them (a lockfile, generated sources), so a change there must rerun.
|
|
27
|
+
*/
|
|
28
|
+
export function runsKey(root, projects) {
|
|
29
|
+
const ignore = changeFilter();
|
|
30
|
+
const patterns = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
|
|
31
|
+
const results = matchingFiles(root, patterns);
|
|
32
|
+
return treeFingerprint(root, (rel) => ignore(rel) || results.has(rel));
|
|
33
|
+
}
|
|
34
|
+
/** The (project, check) pairs a run of `only` would run: those with a command. */
|
|
35
|
+
export function plannedRuns(projects, only) {
|
|
36
|
+
return projects.flatMap(({ adapter, ctx }) => {
|
|
37
|
+
const commands = projectCommands(adapter, ctx);
|
|
38
|
+
return only.filter((check) => commands[check]).map((check) => ({ project: ctx.name, check }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Save a passing full verify's runs; never a set with a setup problem, which would come back on every rerun,
|
|
43
|
+
* nor runs that were themselves reused: the set they came from keeps its own flag and time.
|
|
44
|
+
* The logs are copied next to `runs.json`, so a later verify overwriting its own logs leaves the saved set whole.
|
|
45
|
+
*/
|
|
46
|
+
export function saveRuns(root, flag, projects, key, runs, createdAt = new Date().toISOString()) {
|
|
47
|
+
if (runs.some((r) => r.failure || r.reused))
|
|
48
|
+
return;
|
|
49
|
+
const file = runsPath(root, flag);
|
|
50
|
+
const logDir = path.join(path.dirname(file), "saved");
|
|
51
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
52
|
+
const kept = runs.map(({ reused: _reused, ...run }) => {
|
|
53
|
+
if (!run.log)
|
|
54
|
+
return run;
|
|
55
|
+
const log = path.join(logDir, `${run.project}.${run.check}.log`);
|
|
56
|
+
fs.copyFileSync(run.log, log);
|
|
57
|
+
return { ...run, log };
|
|
58
|
+
});
|
|
59
|
+
const saved = {
|
|
60
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
61
|
+
flagrmVersion: packageVersion(),
|
|
62
|
+
flag,
|
|
63
|
+
createdAt,
|
|
64
|
+
key,
|
|
65
|
+
commands: configSnapshot(projects).projects,
|
|
66
|
+
runs: kept,
|
|
67
|
+
};
|
|
68
|
+
fs.writeFileSync(file, `${JSON.stringify(saved, null, 2)}\n`, "utf8");
|
|
69
|
+
}
|
|
70
|
+
/** Forget every saved set, of any flag, for this tree: a fresh run on it failed, so they may hide a flaky test or a changed machine. */
|
|
71
|
+
export function dropRuns(root, key) {
|
|
72
|
+
for (const file of fg.sync(".flagrm/*/verify/runs.json", { cwd: root, absolute: true, dot: true })) {
|
|
73
|
+
if (readJson(file)?.key === key)
|
|
74
|
+
fs.rmSync(file, { force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The newest saved set, of any flag, for this tree and these commands that has every `needed` run and its log. */
|
|
78
|
+
export function findRuns(root, projects, key, needed) {
|
|
79
|
+
if (needed.length === 0)
|
|
80
|
+
return undefined;
|
|
81
|
+
const commands = JSON.stringify(configSnapshot(projects).projects);
|
|
82
|
+
const version = packageVersion();
|
|
83
|
+
let best;
|
|
84
|
+
for (const file of fg.sync(".flagrm/*/verify/runs.json", { cwd: root, absolute: true, dot: true })) {
|
|
85
|
+
const saved = readJson(file);
|
|
86
|
+
if (!saved || !Array.isArray(saved.runs) || typeof saved.createdAt !== "string")
|
|
87
|
+
continue;
|
|
88
|
+
if (saved.key !== key || saved.schemaVersion !== JSON_SCHEMA_VERSION)
|
|
89
|
+
continue;
|
|
90
|
+
if (saved.flagrmVersion !== version || JSON.stringify(saved.commands) !== commands)
|
|
91
|
+
continue;
|
|
92
|
+
const runs = needed.map((n) => saved.runs.find((r) => r.project === n.project && r.check === n.check));
|
|
93
|
+
if (runs.some((r) => !r || (r.log !== undefined && !fs.existsSync(r.log))))
|
|
94
|
+
continue;
|
|
95
|
+
if (!best || saved.createdAt > best.saved.createdAt)
|
|
96
|
+
best = { saved, runs: runs };
|
|
97
|
+
}
|
|
98
|
+
return best;
|
|
99
|
+
}
|
|
100
|
+
/** The found runs as this flag's own: logs copied into `logDir` under their usual names, marked `reused`. */
|
|
101
|
+
export function reuseRuns(found, logDir) {
|
|
102
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
103
|
+
const reused = { flag: found.saved.flag, createdAt: found.saved.createdAt };
|
|
104
|
+
return found.runs.map((run) => {
|
|
105
|
+
if (!run.log)
|
|
106
|
+
return { ...run, reused };
|
|
107
|
+
const log = path.join(logDir, `${run.project}.${run.check}.log`);
|
|
108
|
+
if (path.resolve(run.log) !== path.resolve(log))
|
|
109
|
+
fs.copyFileSync(run.log, log);
|
|
110
|
+
return { ...run, log, reused };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** A baseline's build, test and lint runs: reused from a passing verify on the same tree and commands, else run. */
|
|
114
|
+
export async function baselineRuns(root, flag, projects, reuse = true) {
|
|
115
|
+
const logDir = flagDir(root, flag);
|
|
116
|
+
const key = reuse ? runsKey(root, projects) : undefined;
|
|
117
|
+
const found = key ? findRuns(root, projects, key, plannedRuns(projects, ["build", "test", "lint"])) : undefined;
|
|
118
|
+
return found ? reuseRuns(found, logDir) : runChecks(projects, logDir);
|
|
119
|
+
}
|
|
120
|
+
/** One line saying which runs were reused and from where, or undefined when none were. `at`: when they were reused. */
|
|
121
|
+
export function reusedNote(runs, at) {
|
|
122
|
+
const reused = runs.find((r) => r.reused)?.reused;
|
|
123
|
+
if (!reused)
|
|
124
|
+
return undefined;
|
|
125
|
+
const checks = [...new Set(runs.filter((r) => r.reused).map((r) => RUN_CHECK_ID[r.check]))];
|
|
126
|
+
const what = checks.length > 1 ? `${checks.slice(0, -1).join(", ")} and ${checks.at(-1)}` : checks[0];
|
|
127
|
+
return `${what} reused from ${reused.flag}'s verify (same tree and commands, ${age(Date.parse(at) - Date.parse(reused.createdAt))} ago; --no-reuse to run them)`;
|
|
128
|
+
}
|
|
129
|
+
function age(ms) {
|
|
130
|
+
const min = Math.round(ms / 60_000);
|
|
131
|
+
if (min < 1)
|
|
132
|
+
return "under a minute";
|
|
133
|
+
if (min < 60)
|
|
134
|
+
return `${min} min`;
|
|
135
|
+
const hours = Math.round(min / 60);
|
|
136
|
+
return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`;
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/core/scope.js
CHANGED
|
@@ -121,6 +121,10 @@ export function writeScope(setups, projects, root, configFile) {
|
|
|
121
121
|
// The resolved commands: the configured ones, or the adapter's defaults when none are.
|
|
122
122
|
build: commands.build && pointAt(commands.build, rel),
|
|
123
123
|
test: commands.test && pointAt(commands.test, rel),
|
|
124
|
+
// Written out, the test command is no longer the default, so its default results location must be too.
|
|
125
|
+
testResults: commands.testResults && commands.defaults.includes("testResults")
|
|
126
|
+
? `./${relativePath(root, commands.testResults)}`
|
|
127
|
+
: undefined,
|
|
124
128
|
});
|
|
125
129
|
}
|
|
126
130
|
}
|
|
@@ -172,7 +176,9 @@ function excludeFromGit(root, file) {
|
|
|
172
176
|
}
|
|
173
177
|
/** Set the build and test commands of `projects:` entries in place, keeping the file's comments and layout. */
|
|
174
178
|
function updateConfig(configFile, root, updates) {
|
|
175
|
-
const paste = updates
|
|
179
|
+
const paste = updates
|
|
180
|
+
.map((u) => `${u.project}: build: ${u.build}; test: ${u.test}${u.testResults ? `; testResults: ${u.testResults}` : ""}`)
|
|
181
|
+
.join("; ");
|
|
176
182
|
if (!configFile || configFile.endsWith(".json")) {
|
|
177
183
|
return {
|
|
178
184
|
path: configFile ? relativePath(root, configFile) : "flagrm.config.yaml",
|
|
@@ -196,8 +202,11 @@ function updateConfig(configFile, root, updates) {
|
|
|
196
202
|
doc.setIn(["projects", index, "build"], u.build);
|
|
197
203
|
if (u.test)
|
|
198
204
|
doc.setIn(["projects", index, "test"], u.test);
|
|
205
|
+
if (u.testResults)
|
|
206
|
+
doc.setIn(["projects", index, "testResults"], u.testResults);
|
|
199
207
|
}
|
|
200
|
-
|
|
208
|
+
// lineWidth 0: never fold a long command over two lines.
|
|
209
|
+
const next = doc.toString({ lineWidth: 0 });
|
|
201
210
|
if (next !== text)
|
|
202
211
|
fs.writeFileSync(configFile, next, "utf8");
|
|
203
212
|
const note = missing.length
|
|
@@ -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
|
+
}
|
package/dist/core/types.js
CHANGED
|
@@ -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 =
|
|
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,15 +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";
|
|
15
|
+
import { projectCommands } from "../config-snapshot.js";
|
|
11
16
|
import { escapeRegExp, isTestPath } from "../scan.js";
|
|
17
|
+
import { RUN_CHECK_ID, } from "../types.js";
|
|
12
18
|
import { relativePath } from "../util.js";
|
|
13
19
|
import { checkResult, configFindings, plural, skippedResult } from "./context.js";
|
|
14
|
-
import { classifyRemovedTests } from "./removed-tests.js";
|
|
15
|
-
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";
|
|
16
22
|
function failedRunFindings(run) {
|
|
17
23
|
const findings = [
|
|
18
24
|
{
|
|
@@ -34,8 +40,9 @@ function setupFinding(run) {
|
|
|
34
40
|
return { severity: "info", message: `setup problem: ${run.failure?.message}`, project: run.project };
|
|
35
41
|
}
|
|
36
42
|
/** No command to run: skipped, unless the baseline had one, which is worth a warning. */
|
|
37
|
-
function noCommandResult(v,
|
|
38
|
-
const
|
|
43
|
+
function noCommandResult(v, kind) {
|
|
44
|
+
const id = RUN_CHECK_ID[kind];
|
|
45
|
+
const summary = `no ${kind} command configured`;
|
|
39
46
|
const findings = configFindings(v, id);
|
|
40
47
|
return findings.length ? checkResult(id, summary, findings) : skippedResult(id, summary);
|
|
41
48
|
}
|
|
@@ -62,15 +69,104 @@ export function buildCheck(v) {
|
|
|
62
69
|
: `${runs.map((r) => r.project).join(", ")} built successfully`;
|
|
63
70
|
return checkResult("build", summary, findings);
|
|
64
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
|
+
}
|
|
65
160
|
export function compareTests(v) {
|
|
66
161
|
const out = [];
|
|
67
162
|
for (const run of v.runs.filter((r) => r.check === "test")) {
|
|
68
163
|
const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
|
|
69
164
|
const after = run.results;
|
|
165
|
+
const counted = (r) => (r && (r.total > 0 || hasTestNames(r)) ? r.total : undefined);
|
|
70
166
|
const comparison = {
|
|
71
167
|
project: run.project,
|
|
72
|
-
before: before
|
|
73
|
-
after: after
|
|
168
|
+
before: counted(before),
|
|
169
|
+
after: counted(after),
|
|
74
170
|
removed: [],
|
|
75
171
|
added: [],
|
|
76
172
|
deleted: [],
|
|
@@ -87,16 +183,40 @@ export function compareTests(v) {
|
|
|
87
183
|
comparison.newFailures = shortTestNames(failures.new);
|
|
88
184
|
comparison.knownFailures = shortTestNames(failures.known);
|
|
89
185
|
}
|
|
90
|
-
|
|
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)) {
|
|
91
197
|
const { removed, added } = diffTestNames(before.names, after.names);
|
|
92
198
|
Object.assign(comparison, { removed, added });
|
|
93
|
-
Object.assign(comparison, classifyRemovedTests(removed, added, after.names,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
100
220
|
}
|
|
101
221
|
out.push(comparison);
|
|
102
222
|
}
|
|
@@ -105,31 +225,41 @@ export function compareTests(v) {
|
|
|
105
225
|
/**
|
|
106
226
|
* For a test run that exited non-zero: its failed tests split into the ones
|
|
107
227
|
* that already failed at the baseline and new ones. Undefined when that can't
|
|
108
|
-
* be told (no
|
|
228
|
+
* be told (no results now or at the baseline, from different sources, or none of them failed, so
|
|
109
229
|
* the command broke some other way).
|
|
110
230
|
*/
|
|
111
231
|
function splitBaselineFailures(v, run) {
|
|
112
232
|
const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
|
|
113
233
|
if (!before || !run.results?.failed)
|
|
114
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;
|
|
115
241
|
return splitFailures(before.failedNames, run.results.failedNames);
|
|
116
242
|
}
|
|
117
243
|
/**
|
|
118
244
|
* A failed test run fails the check, unless every failed test already failed
|
|
119
|
-
* at the baseline: those are known failures, and only warn
|
|
245
|
+
* at the baseline: those are known failures, and only warn, or are only
|
|
246
|
+
* listed once the user accepted them (`baseline --accept failures`).
|
|
120
247
|
*/
|
|
121
|
-
function failedTestFindings(run, failures) {
|
|
248
|
+
function failedTestFindings(run, failures, accepted = false) {
|
|
122
249
|
const list = (names, prefix) => shortTestNames(names)
|
|
123
250
|
.slice(0, 20)
|
|
124
251
|
.map((name) => ({ severity: "info", message: `${prefix}: ${name}`, project: run.project }));
|
|
125
252
|
if (!failures) {
|
|
126
|
-
|
|
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")];
|
|
127
257
|
}
|
|
128
258
|
if (failures.new.length === 0) {
|
|
129
259
|
return [
|
|
130
260
|
{
|
|
131
|
-
severity: "warn",
|
|
132
|
-
message: `${plural(failures.known.length, "test fails", "tests fail")}, as at the baseline (known failures; log: ${run.log})`,
|
|
261
|
+
severity: accepted ? "info" : "warn",
|
|
262
|
+
message: `${plural(failures.known.length, "test fails", "tests fail")}, as at the baseline (known failures${accepted ? ", accepted" : ""}; log: ${run.log})`,
|
|
133
263
|
project: run.project,
|
|
134
264
|
file: run.log,
|
|
135
265
|
},
|
|
@@ -158,7 +288,7 @@ function failedTestFindings(run, failures) {
|
|
|
158
288
|
export function testsCheck(v, comparisons) {
|
|
159
289
|
const runs = v.runs.filter((r) => r.check === "test");
|
|
160
290
|
if (runs.length === 0)
|
|
161
|
-
return noCommandResult(v, "
|
|
291
|
+
return noCommandResult(v, "test");
|
|
162
292
|
const findings = configFindings(v, "tests");
|
|
163
293
|
let failed = 0;
|
|
164
294
|
let known = 0;
|
|
@@ -168,21 +298,22 @@ export function testsCheck(v, comparisons) {
|
|
|
168
298
|
if (failures?.new.length !== 0)
|
|
169
299
|
failed++;
|
|
170
300
|
known += failures?.known.length ?? 0;
|
|
171
|
-
findings.push(...failedTestFindings(run, failures));
|
|
301
|
+
findings.push(...failedTestFindings(run, failures, v.baseline.acceptedFailures));
|
|
172
302
|
if (run.failure)
|
|
173
303
|
findings.push(setupFinding(run));
|
|
174
304
|
}
|
|
175
|
-
const
|
|
176
|
-
const
|
|
305
|
+
const entry = v.projects.find(({ ctx }) => ctx.name === run.project);
|
|
306
|
+
const ctx = entry?.ctx;
|
|
307
|
+
const testResults = entry && projectCommands(entry.adapter, entry.ctx).testResults;
|
|
177
308
|
const comparison = comparisons.find((c) => c.project === run.project);
|
|
178
|
-
if (
|
|
309
|
+
if (testResults && !hasTestNames(run.results)) {
|
|
179
310
|
findings.push({
|
|
180
311
|
severity: "warn",
|
|
181
|
-
message: `no test results found at ${
|
|
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)" : ""}`,
|
|
182
313
|
project: run.project,
|
|
183
314
|
});
|
|
184
315
|
}
|
|
185
|
-
else if (run.results && comparison?.before === undefined) {
|
|
316
|
+
else if (hasTestNames(run.results) && comparison?.before === undefined) {
|
|
186
317
|
findings.push({
|
|
187
318
|
severity: "info",
|
|
188
319
|
message: "the baseline has no test results to compare with (run `flagrm baseline` before the removal)",
|
|
@@ -200,7 +331,16 @@ export function testsCheck(v, comparisons) {
|
|
|
200
331
|
findings.push({ severity: "info", message: `no longer runs: ${name}`, project: run.project });
|
|
201
332
|
}
|
|
202
333
|
}
|
|
203
|
-
|
|
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) {
|
|
204
344
|
for (const file of changedTestFiles(v, ctx.root)) {
|
|
205
345
|
if (testFileRan(file, ctx.root, run.results.names))
|
|
206
346
|
continue;
|
|
@@ -223,6 +363,7 @@ export function testsCheck(v, comparisons) {
|
|
|
223
363
|
c.renamed.length && `${c.renamed.length} renamed`,
|
|
224
364
|
c.excludedByConfig.length && `${c.excludedByConfig.length} excluded by config`,
|
|
225
365
|
c.unexplained.length && `${c.unexplained.length} unexplained`,
|
|
366
|
+
c.missing && `${c.missing} unaccounted for`,
|
|
226
367
|
].filter(Boolean);
|
|
227
368
|
return `${c.project} ${c.before} → ${c.after}${why.length ? `: ${why.join(", ")}` : ""}`;
|
|
228
369
|
});
|