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.
Files changed (40) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +70 -31
  3. package/dist/adapters/angular/component-files.js +41 -0
  4. package/dist/adapters/angular/flow.js +121 -0
  5. package/dist/adapters/angular/holders.js +194 -0
  6. package/dist/adapters/angular/index.js +20 -1
  7. package/dist/adapters/angular/inputs.js +181 -0
  8. package/dist/adapters/dotnet/index.js +11 -1
  9. package/dist/cli.js +62 -21
  10. package/dist/core/baseline.js +98 -45
  11. package/dist/core/change-filter.js +68 -0
  12. package/dist/core/config-snapshot.js +36 -6
  13. package/dist/core/config.js +23 -7
  14. package/dist/core/detect.js +2 -2
  15. package/dist/core/doctor.js +51 -7
  16. package/dist/core/environment.js +25 -3
  17. package/dist/core/git.js +80 -16
  18. package/dist/core/hook.js +9 -6
  19. package/dist/core/install.js +26 -11
  20. package/dist/core/inventory.js +3 -1
  21. package/dist/core/markdown.js +4 -0
  22. package/dist/core/name-owner.js +179 -0
  23. package/dist/core/report.js +13 -0
  24. package/dist/core/run-cache.js +137 -0
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +11 -2
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +170 -29
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/dead-code.js +9 -2
  32. package/dist/core/verify/index.js +42 -8
  33. package/dist/core/verify/log-results.js +127 -0
  34. package/dist/core/verify/orphans.js +175 -0
  35. package/dist/core/verify/removed-tests.js +90 -7
  36. package/dist/core/verify/test-results.js +56 -7
  37. package/package.json +3 -1
  38. package/skills/flagrm-remove/SKILL.md +31 -11
  39. package/skills/flagrm-remove/batch.md +38 -0
  40. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -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
+ }
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * The `dead-code` check: compiler unused-code diagnostics in the changed
3
3
  * files that the baseline did not have (fail; warn when the adapter cannot
4
- * tell which ones are new).
4
+ * tell which ones are new), and declarations the removal left without
5
+ * references, which compilers don't flag for public members (warn).
5
6
  */
6
7
  import fs from "node:fs";
7
8
  import path from "node:path";
8
9
  import { fileAt } from "../git.js";
9
10
  import { checkResult, plural, skippedResult } from "./context.js";
11
+ import { orphanFindings } from "./orphans.js";
10
12
  export function deadCodeCheck(v) {
11
- const { findings, summary, analyzed } = compilerFindings(v);
13
+ const { findings, summary: compiler, analyzed } = compilerFindings(v);
14
+ const orphans = orphanFindings(v);
15
+ findings.push(...orphans);
16
+ const summary = orphans.length
17
+ ? `${compiler}; ${plural(orphans.length, "declaration")} left without references`
18
+ : compiler;
12
19
  // A check that did not look at every changed file must not read as a pass (a found diagnostic still fails it).
13
20
  if (!analyzed && !findings.some((f) => f.severity === "fail" || f.severity === "warn")) {
14
21
  return skippedResult("dead-code", summary, findings);
@@ -6,11 +6,14 @@
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { flagDir, readBaseline, runChecks, verifyPath } from "../baseline.js";
9
+ import { changeFilter, changeFilterAt } from "../change-filter.js";
9
10
  import { configChanges as configChangesSince, configSnapshot } from "../config-snapshot.js";
10
11
  import { changedFilesSince, gitState, treeFingerprint } from "../git.js";
11
- import { CHECK_IDS, JSON_SCHEMA_VERSION, } from "../types.js";
12
- 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";
13
15
  import { skippedResult } from "./context.js";
16
+ import { coverageCheck } from "./coverage.js";
14
17
  import { deadCodeCheck } from "./dead-code.js";
15
18
  import { leftoversCheck } from "./leftovers.js";
16
19
  export async function verifyFlag(root, projects, flag, options = {}) {
@@ -19,19 +22,41 @@ export async function verifyFlag(root, projects, flag, options = {}) {
19
22
  // Take the git state before running commands: their output (bin/, test
20
23
  // results) is not part of the change under review.
21
24
  const git = gitState(root);
22
- const changedFiles = baseline.git.sha ? changedFilesSince(root, baseline.git.sha) : undefined;
23
- const only = [...(skip.has("build") ? [] : ["build"]), ...(skip.has("tests") ? [] : ["test"])];
24
- const runs = only.length ? await runChecks(projects, path.join(flagDir(root, flag), "verify"), only) : [];
25
+ // flagrm's setup files and `exclude`d paths (the config's and --exclude) aren't part of the removal.
26
+ const ignore = changeFilter(projects[0]?.ctx.config);
27
+ const changedFiles = baseline.git.sha ? changedFilesSince(root, baseline.git.sha, ignore) : undefined;
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) : [];
25
34
  // The fingerprint, though, is the tree the Stop hook will see next, so take it
26
35
  // after the commands: a test report they write outside .gitignore is part of it.
27
- const fingerprint = treeFingerprint(root);
28
- const configChanges = configChangesSince(baseline.config, configSnapshot(projects));
29
- const v = { root, flag, projects, baseline, changedFiles, runs, configChanges };
36
+ // The Stop hook compares it with its own, so take it exactly as the hook does: from the config file
37
+ // alone, without --exclude, which the hook can't know.
38
+ const fingerprint = treeFingerprint(root, changeFilterAt(root));
39
+ const now = configSnapshot(projects);
40
+ const configChanges = configChangesSince(baseline.acceptedConfig ?? baseline.config, now);
41
+ const originalConfigChanges = baseline.acceptedConfig ? configChangesSince(baseline.config, now) : configChanges;
42
+ const v = {
43
+ root,
44
+ flag,
45
+ projects,
46
+ baseline,
47
+ changedFiles,
48
+ runs,
49
+ skip,
50
+ configChanges,
51
+ originalConfigChanges,
52
+ };
30
53
  const tests = compareTests(v);
31
54
  const run = {
32
55
  "dead-code": () => deadCodeCheck(v),
33
56
  build: () => buildCheck(v),
34
57
  tests: () => testsCheck(v, tests),
58
+ lint: () => lintCheck(v),
59
+ coverage: () => coverageCheck(v),
35
60
  };
36
61
  const checks = [];
37
62
  for (const id of CHECK_IDS) {
@@ -57,6 +82,15 @@ export async function verifyFlag(root, projects, flag, options = {}) {
57
82
  runs: runs.map(countsOnly),
58
83
  tests,
59
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
+ }
60
94
  const file = verifyPath(root, flag);
61
95
  fs.mkdirSync(path.dirname(file), { recursive: true });
62
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
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Code the removal orphaned without a compiler noticing: a method, property
3
+ * or class whose last reference was in the deleted OFF path is still
4
+ * declared, and a public or internal member gets no "unused" diagnostic.
5
+ *
6
+ * Lexical: an identifier whose count went down in a changed code file, now
7
+ * found exactly once across the projects' files (its declaration) and more
8
+ * than once at the baseline. Comments don't count as references; strings,
9
+ * templates, Razor views and config files do (DI by name, interpolation,
10
+ * Angular bindings, options bound from appsettings), so a hit is a warning.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileAt } from "../git.js";
15
+ import { commentSyntax, computeLexicalMask, escapeRegExp, LEX_COMMENT } from "../scan.js";
16
+ import { SourceText } from "../source-text.js";
17
+ /** Files whose identifiers can be declarations the removal orphaned. */
18
+ const CODE_FILE = /\.(cs|ts|tsx|js|jsx|mjs|cjs|go|java|kt)$/;
19
+ /** Identifiers of 4+ characters: shorter ones (`id`, `Run`) are too common to mean anything here. */
20
+ const IDENT = /[A-Za-z_]\w{3,}/g;
21
+ /**
22
+ * Keywords and built-in type names (C#, TypeScript/JavaScript, Go, Java,
23
+ * Kotlin) of 4+ characters: removing one can leave a single one in the
24
+ * project, which is no declaration.
25
+ */
26
+ const KEYWORDS = new Set(("true false null this base void self super class struct record interface enum namespace package import " +
27
+ "export from default extends implements public private protected internal static readonly const final " +
28
+ "abstract virtual override sealed async await yield return throw throws break continue switch case else " +
29
+ "while foreach using lock fixed unsafe checked unchecked params where when select func defer chan range " +
30
+ "type typeof instanceof keyof infer declare module require function constructor delete void never unknown " +
31
+ "string bool boolean char byte short long float double decimal object dynamic number bigint symbol " +
32
+ "Task Void Boolean String Object Unit init operator implicit explicit event delegate goto sizeof stackalloc " +
33
+ "volatile extern partial global nameof value get set add remove inline data sealed open lateinit companion").split(" "));
34
+ const MODIFIERS = "public|private|protected|internal|static|readonly|virtual|override|abstract|sealed|async|partial|const|extern|" +
35
+ "unsafe|required|new|final|open|suspend|inline|lateinit|declare|export|default|get|set|accessor";
36
+ /** Words that start a statement, not a declaration's type (`return Foo;`, `await Bar();`). */
37
+ const STATEMENT = new Set([
38
+ "return",
39
+ "await",
40
+ "throw",
41
+ "yield",
42
+ "else",
43
+ "case",
44
+ "goto",
45
+ "using",
46
+ "import",
47
+ "package",
48
+ "namespace",
49
+ ]);
50
+ /**
51
+ * Whether `line` declares `name`, by the shape of declarations in the file's
52
+ * language: what is left of an orphan is its declaration, while the last use
53
+ * of a library member (`mock.Setup(...)`), an object key or an import is not.
54
+ */
55
+ export function declares(file, line, name) {
56
+ const n = escapeRegExp(name);
57
+ if (/\.(cs|java|kt)$/.test(file)) {
58
+ if (new RegExp(`\\b(class|interface|record|struct|enum|object|fun|val|var)\\s+${n}\\b`).test(line))
59
+ return true;
60
+ const member = new RegExp(`^\\s*(?:\\[[^\\]]*\\]\\s*)*(?:(?:${MODIFIERS})\\s+)*([\\w.?<>\\[\\],]+)\\s+${n}\\s*(?:<[^>]*>)?\\s*(?:\\(|\\{|=>|=|;)`).exec(line);
61
+ return Boolean(member && !STATEMENT.has(member[1]));
62
+ }
63
+ if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file)) {
64
+ const mods = `(?:(?:${MODIFIERS})\\s+)`;
65
+ return [
66
+ new RegExp(`\\b(?:function\\*?|class|interface|enum|type|const|let|var|namespace)\\s+${n}\\b`),
67
+ new RegExp(`^\\s*${mods}+${n}\\s*[?!]?\\s*(?:\\(|=|:|<)`),
68
+ new RegExp(`^\\s*${n}\\s*[?!]?\\s*=(?![=>])`),
69
+ new RegExp(`^\\s*${n}\\s*[?!]?\\s*:[^,]*;\\s*$`),
70
+ new RegExp(`^\\s*${n}\\s*(?:<[^>]*>)?\\s*\\([^)]*\\)\\s*(?::\\s*[^{;=]+)?\\{\\s*$`),
71
+ ].some((re) => re.test(line));
72
+ }
73
+ if (file.endsWith(".go")) {
74
+ return new RegExp(`\\bfunc\\s+(?:\\([^)]*\\)\\s*)?${n}\\s*[([]|\\b(?:type|var|const)\\s+${n}\\b`).test(line);
75
+ }
76
+ return false;
77
+ }
78
+ /** At most this many findings: the first ones show the pattern. */
79
+ const MAX = 20;
80
+ /** Each identifier's occurrences outside comments (`only`: just these identifiers). */
81
+ function occurrences(file, text, only) {
82
+ const syntax = commentSyntax(file);
83
+ const mask = syntax ? computeLexicalMask(text, syntax) : undefined;
84
+ const out = new Map();
85
+ for (const m of text.matchAll(IDENT)) {
86
+ if (only && !only.has(m[0]))
87
+ continue;
88
+ if (mask && mask[m.index] === LEX_COMMENT)
89
+ continue;
90
+ const seen = out.get(m[0]);
91
+ if (seen)
92
+ seen.count++;
93
+ else
94
+ out.set(m[0], { count: 1, first: m.index });
95
+ }
96
+ return out;
97
+ }
98
+ function read(file) {
99
+ try {
100
+ return fs.readFileSync(file, "utf8");
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ function add(totals, counts) {
107
+ for (const [name, { count }] of counts)
108
+ totals.set(name, (totals.get(name) ?? 0) + count);
109
+ }
110
+ export function orphanFindings(v) {
111
+ const sha = v.baseline.git.sha;
112
+ if (!sha || !v.changedFiles)
113
+ return [];
114
+ const projectFiles = new Map();
115
+ for (const { ctx } of v.projects)
116
+ for (const file of ctx.files)
117
+ if (!projectFiles.has(file))
118
+ projectFiles.set(file, ctx.name);
119
+ const roots = v.projects.map(({ ctx }) => ctx.root);
120
+ const inProject = (file) => projectFiles.has(file) || (!fs.existsSync(file) && roots.some((r) => !path.relative(r, file).startsWith("..")));
121
+ const changed = v.changedFiles.map((rel) => path.join(v.root, rel)).filter(inProject);
122
+ const recorded = new Set(v.baseline.names.map((n) => n.name));
123
+ // Counts in the changed files then and now; unchanged files count the same both times.
124
+ const before = new Map();
125
+ const nowInChanged = new Map();
126
+ const candidates = new Set();
127
+ for (const file of changed) {
128
+ const was = occurrences(file, fileAt(v.root, sha, path.relative(v.root, file)) ?? "");
129
+ const text = read(file);
130
+ const is = text === undefined ? new Map() : occurrences(file, text);
131
+ add(before, was);
132
+ add(nowInChanged, is);
133
+ if (!CODE_FILE.test(file))
134
+ continue;
135
+ for (const [name, { count }] of was) {
136
+ if (count > (is.get(name)?.count ?? 0) && !recorded.has(name) && !KEYWORDS.has(name))
137
+ candidates.add(name);
138
+ }
139
+ }
140
+ if (candidates.size === 0)
141
+ return [];
142
+ const now = new Map();
143
+ const where = new Map();
144
+ for (const [file, project] of projectFiles) {
145
+ const text = read(file);
146
+ if (text === undefined)
147
+ continue;
148
+ const counts = occurrences(file, text, candidates);
149
+ add(now, counts);
150
+ for (const [name, { first }] of counts)
151
+ if (!where.has(name))
152
+ where.set(name, { file, offset: first, text, project });
153
+ }
154
+ const findings = [];
155
+ for (const name of [...candidates].sort()) {
156
+ const total = now.get(name) ?? 0;
157
+ const wasTotal = total - (nowInChanged.get(name) ?? 0) + (before.get(name) ?? 0);
158
+ const at = where.get(name);
159
+ if (total !== 1 || wasTotal < 2 || !at)
160
+ continue;
161
+ const source = new SourceText(at.file, at.text);
162
+ if (!declares(at.file, source.snippet(at.offset), name))
163
+ continue;
164
+ findings.push({
165
+ severity: "warn",
166
+ message: `${name} is now referenced only where it is declared: delete it if only the OFF path used it`,
167
+ project: at.project,
168
+ file: at.file,
169
+ line: source.position(at.offset).line,
170
+ });
171
+ if (findings.length === MAX)
172
+ break;
173
+ }
174
+ return findings;
175
+ }
@@ -2,19 +2,23 @@
2
2
  * Why each baseline test no longer runs. Deleting the OFF path's tests and
3
3
  * dropping the flag from test names is part of a removal, and a narrower test
4
4
  * command leaves tests out on purpose; only the rest needs explaining.
5
+ * Without result files, the changed test files' declarations stand in for the
6
+ * names (`declaredTestDiff`).
5
7
  */
6
8
  import fs from "node:fs";
7
9
  import path from "node:path";
8
10
  import { flagWords, words } from "../flag-words.js";
9
11
  import { fileAt } from "../git.js";
10
- import { escapeRegExp, isTestPath } from "../scan.js";
12
+ import { computeCodeMask, escapeRegExp, isTestPath } from "../scan.js";
11
13
  import { JUNIT_SEPARATOR, testKey } from "./test-results.js";
12
- /** Sort the tests that no longer run (`removed`) using the new ones (`added`) and the diff. */
13
- export function classifyRemovedTests(removed, added, afterNames, ctx) {
14
+ /**
15
+ * Sort the tests that no longer run (`removed`) using the new ones (`added`)
16
+ * and the diff. `files`: the changed test files when the caller already read them.
17
+ */
18
+ export function classifyRemovedTests(removed, added, afterNames, ctx, files = changedTestFiles(ctx)) {
14
19
  const out = { deleted: [], renamed: [], excludedByConfig: [], unexplained: [] };
15
20
  const renamedTo = matchRenames(removed, added, flagWords(ctx.names));
16
21
  const stillRuns = new Set(afterNames.map(testKey));
17
- const files = changedTestFiles(ctx);
18
22
  for (const name of removed) {
19
23
  const to = renamedTo.get(testKey(name));
20
24
  if (to)
@@ -83,7 +87,7 @@ function matchRenames(removed, added, flag) {
83
87
  }
84
88
  return out;
85
89
  }
86
- function changedTestFiles(ctx) {
90
+ function changedTestFiles(ctx, includeNew = false) {
87
91
  if (!ctx.sha)
88
92
  return [];
89
93
  const out = [];
@@ -91,7 +95,7 @@ function changedTestFiles(ctx) {
91
95
  if (!isTestPath(rel))
92
96
  continue;
93
97
  const before = fileAt(ctx.root, ctx.sha, rel);
94
- if (before === undefined)
98
+ if (before === undefined && !includeNew)
95
99
  continue;
96
100
  let now;
97
101
  try {
@@ -100,7 +104,7 @@ function changedTestFiles(ctx) {
100
104
  catch {
101
105
  now = undefined;
102
106
  }
103
- out.push({ before, now });
107
+ out.push({ rel, before: before ?? "", now });
104
108
  }
105
109
  return out;
106
110
  }
@@ -123,6 +127,10 @@ function isDeleted(name, keyStillRuns, files) {
123
127
  }
124
128
  /** xUnit (`InlineData`, `[Bit]AutoData(...)` with values), NUnit and MSTest attributes that each add a data row. */
125
129
  const DATA_ROW = /\b(?:\w*InlineData|\w*AutoData|TestCase|DataRow)\s*\(/g;
130
+ /** Whether a test file has data-row attributes or `.each` cases. */
131
+ function hasDataRows(text) {
132
+ return new RegExp(DATA_ROW.source).test(text) || /\.each\s*[(`]/.test(text);
133
+ }
126
134
  /**
127
135
  * How many data rows `own` has in `text`: the row attributes between the end
128
136
  * of the previous member and its declaration. For JUnit names (`it.each`
@@ -139,3 +147,78 @@ function dataRows(text, own, junit) {
139
147
  }
140
148
  return rows;
141
149
  }
150
+ // --- declarations ----------------------------------------------------------------
151
+ const JS_TEST = /(?<![\w$.])(describe|fdescribe|xdescribe|context|suite|it|fit|xit|test)(?:\.(?:only|skip|todo))?(?:\.each\s*(?:\([\s\S]*?\)|`[\s\S]*?`))?\s*\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/g;
152
+ /** `it`/`test` titles prefixed with the titles of the `describe` blocks around them (by brace depth over code). */
153
+ function jsTests(text) {
154
+ const code = computeCodeMask(text);
155
+ const out = [];
156
+ const open = [];
157
+ let depth = 0;
158
+ let pos = 0;
159
+ for (const m of text.matchAll(JS_TEST)) {
160
+ if (!code[m.index])
161
+ continue;
162
+ for (; pos < m.index; pos++)
163
+ if (code[pos])
164
+ depth += text[pos] === "{" ? 1 : text[pos] === "}" ? -1 : 0;
165
+ while (open.length && open[open.length - 1].depth >= depth)
166
+ open.pop();
167
+ if (/describe|context|suite/.test(m[1]))
168
+ open.push({ title: m[3], depth });
169
+ else
170
+ out.push([...open.map((d) => d.title), m[3]].join(JUNIT_SEPARATOR));
171
+ }
172
+ return out;
173
+ }
174
+ const CS_TEST = /\[(?:Fact|Theory|Test|TestCase|TestMethod)\b[^\]]*\][\s\S]*?\b(?:void|Task|ValueTask)(?:<[^>]*>)?\s+(\w+)\s*\(/g;
175
+ /** Test methods as TRX names them without data-row arguments: `Namespace.Class.Method`. */
176
+ function csTests(text) {
177
+ const ns = /^\s*namespace\s+([\w.]+)/m.exec(text)?.[1];
178
+ return [...text.matchAll(CS_TEST)].map((m) => {
179
+ const cls = [...text.slice(0, m.index).matchAll(/\bclass\s+(\w+)/g)].pop()?.[1];
180
+ return [ns, cls, m[1]].filter(Boolean).join(".");
181
+ });
182
+ }
183
+ /** The tests a test file declares, named as result files name them where the syntax allows. */
184
+ export function declaredTests(text, file) {
185
+ if (/\.[cm]?[jt]sx?$/.test(file))
186
+ return jsTests(text);
187
+ if (file.endsWith(".cs"))
188
+ return csTests(text);
189
+ if (file.endsWith(".go"))
190
+ return [...text.matchAll(/\bfunc (Test\w+)\s*\(/g)].map((m) => m[1]);
191
+ if (file.endsWith(".py"))
192
+ return [...text.matchAll(/^\s*def (test_\w+)\s*\(/gm)].map((m) => m[1]);
193
+ return [];
194
+ }
195
+ /** `from` without the names in `other`, counting duplicates. */
196
+ function minus(from, other) {
197
+ const left = new Map();
198
+ for (const name of other)
199
+ left.set(name, (left.get(name) ?? 0) + 1);
200
+ return from.filter((name) => {
201
+ const n = left.get(name) ?? 0;
202
+ left.set(name, n - 1);
203
+ return n <= 0;
204
+ });
205
+ }
206
+ /**
207
+ * What the changed test files declared at the baseline and don't now, the
208
+ * new declarations, and all declared now: the stand-in for result files'
209
+ * names when there are none.
210
+ */
211
+ export function declaredTestDiff(ctx) {
212
+ const before = [];
213
+ const now = [];
214
+ let rows = false;
215
+ // A file new since the baseline declared nothing then, so a moved spec's tests cancel out.
216
+ const files = changedTestFiles(ctx, true);
217
+ for (const file of files) {
218
+ rows ||= hasDataRows(file.before) || (file.now !== undefined && hasDataRows(file.now));
219
+ before.push(...declaredTests(file.before, file.rel));
220
+ if (file.now !== undefined)
221
+ now.push(...declaredTests(file.now, file.rel));
222
+ }
223
+ return { removed: minus(before, now), added: minus(now, before), now, rows, files };
224
+ }