flagrm 0.1.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 +38 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/adapters/angular/discover.js +100 -0
- package/dist/adapters/angular/index.js +34 -0
- package/dist/adapters/angular/unused.js +74 -0
- package/dist/adapters/dotnet/index.js +29 -0
- package/dist/adapters/dotnet/unused.js +43 -0
- package/dist/adapters/generic/discover.js +84 -0
- package/dist/adapters/generic/index.js +50 -0
- package/dist/adapters/generic/text.js +93 -0
- package/dist/cli.js +216 -0
- package/dist/core/adapter.js +18 -0
- package/dist/core/baseline.js +255 -0
- package/dist/core/config.js +254 -0
- package/dist/core/detect.js +64 -0
- package/dist/core/discover.js +310 -0
- package/dist/core/doctor.js +141 -0
- package/dist/core/git.js +101 -0
- package/dist/core/hook.js +83 -0
- package/dist/core/install.js +257 -0
- package/dist/core/inventory.js +118 -0
- package/dist/core/markdown.js +55 -0
- package/dist/core/mentions.js +153 -0
- package/dist/core/package.js +17 -0
- package/dist/core/registry.js +29 -0
- package/dist/core/report.js +135 -0
- package/dist/core/scan.js +201 -0
- package/dist/core/source-text.js +36 -0
- package/dist/core/text-utils.js +27 -0
- package/dist/core/types.js +22 -0
- package/dist/core/util.js +18 -0
- package/dist/core/verify/commands.js +166 -0
- package/dist/core/verify/context.js +13 -0
- package/dist/core/verify/dead-code.js +92 -0
- package/dist/core/verify/index.js +84 -0
- package/dist/core/verify/leftovers.js +74 -0
- package/dist/core/verify/test-results.js +153 -0
- package/package.json +66 -0
- package/skills/flagrm-remove/SKILL.md +88 -0
- package/skills/flagrm-remove/patterns.md +96 -0
- package/skills/flagrm-verify/SKILL.md +48 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
export const BACKEND_GLOBS = [
|
|
4
|
+
"**/*.cs",
|
|
5
|
+
"**/*.cshtml",
|
|
6
|
+
"**/*.razor",
|
|
7
|
+
"**/appsettings*.json",
|
|
8
|
+
// Generated EF Core migration metadata: often most of a solution's C# by size, and never flag logic.
|
|
9
|
+
"!**/Migrations/*.Designer.cs",
|
|
10
|
+
"!**/Migrations/*ModelSnapshot.cs",
|
|
11
|
+
];
|
|
12
|
+
export const FRONTEND_GLOBS = ["**/*.ts", "**/*.html"];
|
|
13
|
+
export const IGNORE_GLOBS = [
|
|
14
|
+
"**/node_modules/**",
|
|
15
|
+
"**/bin/**",
|
|
16
|
+
"**/obj/**",
|
|
17
|
+
"**/dist/**",
|
|
18
|
+
"**/coverage/**",
|
|
19
|
+
"**/.angular/**",
|
|
20
|
+
"**/.git/**",
|
|
21
|
+
];
|
|
22
|
+
/** Absolute paths with the platform's separators (fast-glob returns `/` on Windows too), so they match `path.join`. */
|
|
23
|
+
export function listProjectFiles(root, globs, overrides) {
|
|
24
|
+
const patterns = [...globs, ...(overrides?.include ?? [])];
|
|
25
|
+
const ignore = [...IGNORE_GLOBS, ...(overrides?.exclude ?? [])];
|
|
26
|
+
return fg.sync(patterns, { cwd: root, ignore, absolute: true, dot: false }).map((file) => path.normalize(file));
|
|
27
|
+
}
|
|
28
|
+
export function escapeRegExp(s) {
|
|
29
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the regex fragment for a configured method-name entry. An entry may
|
|
33
|
+
* pin a specific generic argument (`"GetValue<bool>"`, matching only that
|
|
34
|
+
* exact instantiation) or be a bare name (`"IsEnabled"`, matching an optional
|
|
35
|
+
* generic argument list of any shape — `Foo(...)` or `Foo<T>(...)`).
|
|
36
|
+
*/
|
|
37
|
+
export function methodNamePattern(entry) {
|
|
38
|
+
const m = /^([\w.]+)\s*<\s*([^>]+)\s*>$/.exec(entry.trim());
|
|
39
|
+
if (m) {
|
|
40
|
+
const [, name, generic] = m;
|
|
41
|
+
return `${escapeRegExp(name)}\\s*<\\s*${escapeRegExp(generic.trim())}\\s*>`;
|
|
42
|
+
}
|
|
43
|
+
return `${escapeRegExp(entry)}\\s*(?:<[^>]*>\\s*)?`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a file (relative to its project root) is test code: `*.spec.ts`/
|
|
47
|
+
* `*.test.ts`, `*Tests.cs`, `*_test.go`, `test_*.py`/`*_test.py`,
|
|
48
|
+
* `*Test.java`/`*Tests.kt`, `*_spec.rb`, or anything under a `test`/`tests`
|
|
49
|
+
* directory or a `*.Tests`/`*-tests` project directory.
|
|
50
|
+
*/
|
|
51
|
+
export function isTestPath(relativePath) {
|
|
52
|
+
return (/\.(?:spec|test)\.[cm]?[jt]sx?$/.test(relativePath) ||
|
|
53
|
+
/Tests?\.(?:cs|java|kt)$/.test(relativePath) ||
|
|
54
|
+
/_test\.(?:go|py)$/.test(relativePath) ||
|
|
55
|
+
/(?:^|[\\/])test_[^\\/]*\.py$/.test(relativePath) ||
|
|
56
|
+
/_spec\.rb$/.test(relativePath) ||
|
|
57
|
+
/(?:^|[\\/])(?:[^\\/]*[.\-_])?tests?[\\/]/i.test(relativePath));
|
|
58
|
+
}
|
|
59
|
+
const SLASH_COMMENT_EXTENSIONS = new Set([
|
|
60
|
+
".cs",
|
|
61
|
+
".ts",
|
|
62
|
+
".tsx",
|
|
63
|
+
".js",
|
|
64
|
+
".jsx",
|
|
65
|
+
".mjs",
|
|
66
|
+
".cjs",
|
|
67
|
+
".go",
|
|
68
|
+
".java",
|
|
69
|
+
".kt",
|
|
70
|
+
".kts",
|
|
71
|
+
".scala",
|
|
72
|
+
".swift",
|
|
73
|
+
".rs",
|
|
74
|
+
".c",
|
|
75
|
+
".h",
|
|
76
|
+
".cc",
|
|
77
|
+
".cpp",
|
|
78
|
+
".hpp",
|
|
79
|
+
".dart",
|
|
80
|
+
".php",
|
|
81
|
+
]);
|
|
82
|
+
const HASH_COMMENT_EXTENSIONS = new Set([
|
|
83
|
+
".py",
|
|
84
|
+
".rb",
|
|
85
|
+
".sh",
|
|
86
|
+
".bash",
|
|
87
|
+
".yaml",
|
|
88
|
+
".yml",
|
|
89
|
+
".toml",
|
|
90
|
+
".properties",
|
|
91
|
+
".ini",
|
|
92
|
+
".cfg",
|
|
93
|
+
".conf",
|
|
94
|
+
".tf",
|
|
95
|
+
".ex",
|
|
96
|
+
".exs",
|
|
97
|
+
]);
|
|
98
|
+
/** Key/value config formats: their `key:` / `key=` lines configure things rather than run code. */
|
|
99
|
+
const KEY_VALUE_EXTENSIONS = new Set([".yaml", ".yml", ".toml", ".properties", ".ini", ".cfg", ".conf"]);
|
|
100
|
+
/** A file's lowercased base name and extension, and whether it is a `.env` file. */
|
|
101
|
+
function fileName(file) {
|
|
102
|
+
const base = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1).toLowerCase();
|
|
103
|
+
const dot = base.lastIndexOf(".");
|
|
104
|
+
return { ext: dot === -1 ? "" : base.slice(dot), isEnv: base === ".env" || base.startsWith(".env.") };
|
|
105
|
+
}
|
|
106
|
+
/** The comment syntax of a file, by extension (or `.env` name); undefined when flagrm doesn't know it (HTML, JSON, ...). */
|
|
107
|
+
export function commentSyntax(file) {
|
|
108
|
+
const { ext, isEnv } = fileName(file);
|
|
109
|
+
if (isEnv)
|
|
110
|
+
return "hash";
|
|
111
|
+
if (SLASH_COMMENT_EXTENSIONS.has(ext))
|
|
112
|
+
return "slash";
|
|
113
|
+
if (HASH_COMMENT_EXTENSIONS.has(ext))
|
|
114
|
+
return "hash";
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/** YAML, TOML, properties, ini/cfg/conf and `.env` files: key/value config that the code scan reads as plain text. */
|
|
118
|
+
export function isKeyValueConfig(file) {
|
|
119
|
+
const { ext, isEnv } = fileName(file);
|
|
120
|
+
return isEnv || KEY_VALUE_EXTENSIONS.has(ext);
|
|
121
|
+
}
|
|
122
|
+
/** Character classes of {@link computeLexicalMask}. */
|
|
123
|
+
export const LEX_CODE = 0;
|
|
124
|
+
export const LEX_COMMENT = 1;
|
|
125
|
+
export const LEX_STRING = 2;
|
|
126
|
+
/**
|
|
127
|
+
* Classify every character as code, comment or string (quotes included), for
|
|
128
|
+
* `//`-style or `#`-style languages. `'`, `"` and `` ` `` open strings that end
|
|
129
|
+
* at the line's end (only backtick strings span lines), so a quote the lexer
|
|
130
|
+
* misreads (a regex literal such as `/"/`) mislabels one line, not the rest of
|
|
131
|
+
* the file. Multi-line strings: in `#` languages `"""` and `'''` (Python); in
|
|
132
|
+
* `//` languages C# raw `"""` strings and verbatim `@"..."` strings, where
|
|
133
|
+
* `""` is an escaped quote.
|
|
134
|
+
*/
|
|
135
|
+
export function computeLexicalMask(text, syntax) {
|
|
136
|
+
const mask = new Uint8Array(text.length);
|
|
137
|
+
const fill = (from, to, cls) => mask.fill(cls, from, Math.min(to, text.length));
|
|
138
|
+
let i = 0;
|
|
139
|
+
while (i < text.length) {
|
|
140
|
+
const c = text[i];
|
|
141
|
+
const next = text[i + 1];
|
|
142
|
+
if ((syntax === "slash" && c === "/" && next === "/") || (syntax === "hash" && c === "#")) {
|
|
143
|
+
const end = text.indexOf("\n", i);
|
|
144
|
+
const stop = end === -1 ? text.length : end;
|
|
145
|
+
fill(i, stop, LEX_COMMENT);
|
|
146
|
+
i = stop;
|
|
147
|
+
}
|
|
148
|
+
else if (syntax === "slash" && c === "/" && next === "*") {
|
|
149
|
+
const end = text.indexOf("*/", i + 2);
|
|
150
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
151
|
+
fill(i, stop, LEX_COMMENT);
|
|
152
|
+
i = stop;
|
|
153
|
+
}
|
|
154
|
+
else if (text.startsWith('"""', i) || (syntax === "hash" && text.startsWith("'''", i))) {
|
|
155
|
+
const end = text.indexOf(text.slice(i, i + 3), i + 3);
|
|
156
|
+
const stop = end === -1 ? text.length : end + 3;
|
|
157
|
+
fill(i, stop, LEX_STRING);
|
|
158
|
+
i = stop;
|
|
159
|
+
}
|
|
160
|
+
else if (syntax === "slash" &&
|
|
161
|
+
(text.startsWith('@"', i) || text.startsWith('$@"', i) || text.startsWith('@$"', i))) {
|
|
162
|
+
const start = i;
|
|
163
|
+
i = text.indexOf('"', i) + 1;
|
|
164
|
+
while (i < text.length && !(text[i] === '"' && text[i + 1] !== '"'))
|
|
165
|
+
i += text[i] === '"' ? 2 : 1;
|
|
166
|
+
fill(start, i + 1, LEX_STRING);
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
else if (c === '"' || c === "'" || c === "`") {
|
|
170
|
+
mask[i++] = LEX_STRING;
|
|
171
|
+
while (i < text.length && text[i] !== c && (c === "`" || text[i] !== "\n")) {
|
|
172
|
+
if (text[i] === "\\")
|
|
173
|
+
mask[i++] = LEX_STRING;
|
|
174
|
+
if (i < text.length)
|
|
175
|
+
mask[i++] = LEX_STRING;
|
|
176
|
+
}
|
|
177
|
+
if (i < text.length && text[i] === c)
|
|
178
|
+
mask[i++] = LEX_STRING;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
i++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return mask;
|
|
185
|
+
}
|
|
186
|
+
/** 1 where {@link computeLexicalMask} finds code, 0 in comments and strings. */
|
|
187
|
+
export function computeCodeMask(text, syntax = "slash") {
|
|
188
|
+
return computeLexicalMask(text, syntax).map((c) => (c === LEX_CODE ? 1 : 0));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Comment mask (0 = inside a comment, 1 = anything else, strings included) for
|
|
192
|
+
* a file whose comment syntax flagrm knows, otherwise null.
|
|
193
|
+
*/
|
|
194
|
+
export function commentMaskFor(file, text) {
|
|
195
|
+
const syntax = commentSyntax(file);
|
|
196
|
+
return syntax ? computeCommentMask(text, syntax) : null;
|
|
197
|
+
}
|
|
198
|
+
/** 0 where {@link computeLexicalMask} finds a comment, 1 elsewhere. */
|
|
199
|
+
export function computeCommentMask(text, syntax = "slash") {
|
|
200
|
+
return computeLexicalMask(text, syntax).map((c) => (c === LEX_COMMENT ? 0 : 1));
|
|
201
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A file's LF-normalized text with offset → line mapping. */
|
|
2
|
+
export class SourceText {
|
|
3
|
+
file;
|
|
4
|
+
text;
|
|
5
|
+
lineStarts = [0];
|
|
6
|
+
constructor(file, text) {
|
|
7
|
+
this.file = file;
|
|
8
|
+
this.text = text;
|
|
9
|
+
for (let i = 0; i < text.length; i++)
|
|
10
|
+
if (text[i] === "\n")
|
|
11
|
+
this.lineStarts.push(i + 1);
|
|
12
|
+
}
|
|
13
|
+
/** 1-based line and column of an offset. */
|
|
14
|
+
position(offset) {
|
|
15
|
+
let lo = 0;
|
|
16
|
+
let hi = this.lineStarts.length - 1;
|
|
17
|
+
while (lo < hi) {
|
|
18
|
+
const mid = (lo + hi + 1) >> 1;
|
|
19
|
+
if (this.lineStarts[mid] <= offset)
|
|
20
|
+
lo = mid;
|
|
21
|
+
else
|
|
22
|
+
hi = mid - 1;
|
|
23
|
+
}
|
|
24
|
+
return { line: lo + 1, column: offset - this.lineStarts[lo] + 1 };
|
|
25
|
+
}
|
|
26
|
+
/** Trimmed text of the line containing `offset`, capped at 160 characters. */
|
|
27
|
+
snippet(offset) {
|
|
28
|
+
const { line } = this.position(offset);
|
|
29
|
+
const start = this.lineStarts[line - 1];
|
|
30
|
+
const next = this.lineStarts[line];
|
|
31
|
+
return this.text
|
|
32
|
+
.slice(start, next === undefined ? this.text.length : next - 1)
|
|
33
|
+
.trim()
|
|
34
|
+
.slice(0, 160);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text utilities shared by discovery and scanning: line endings and bracket
|
|
3
|
+
* matching. Masks are in scan.ts, offset-to-line lookup in source-text.ts.
|
|
4
|
+
*/
|
|
5
|
+
/** Strip `\r` before `\n` so downstream logic can assume bare `\n`. */
|
|
6
|
+
export function toLf(text) {
|
|
7
|
+
return text.includes("\r\n") ? text.replace(/\r\n/g, "\n") : text;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find the index of the bracket matching the one at `openIndex`.
|
|
11
|
+
* Only characters where mask[i] === 1 are considered. Returns -1 when unbalanced.
|
|
12
|
+
*/
|
|
13
|
+
export function matchBracket(text, mask, openIndex, open, close) {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
16
|
+
if (!mask[i])
|
|
17
|
+
continue;
|
|
18
|
+
if (text[i] === open)
|
|
19
|
+
depth++;
|
|
20
|
+
else if (text[i] === close) {
|
|
21
|
+
depth--;
|
|
22
|
+
if (depth === 0)
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return -1;
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bumped whenever a `--json` payload shape changes in a way that could break
|
|
3
|
+
* a consumer (field renamed/removed, meaning of an existing field changed).
|
|
4
|
+
* Purely additive fields don't require a bump.
|
|
5
|
+
*
|
|
6
|
+
* - 2: `project` on every candidate and result is the configured project name.
|
|
7
|
+
* - 3: agent-first release. `inspect`, `scan`, `fold` and `report` are gone;
|
|
8
|
+
* the baseline is written by `flagrm baseline` and holds only the git state and
|
|
9
|
+
* build/test runs; `verify` runs the `dead-code`, `build` and `tests` checks,
|
|
10
|
+
* and tests that no longer run are listed as `removed`.
|
|
11
|
+
* - 4: the baseline records the flag's `names`; `verify` adds the `leftovers`
|
|
12
|
+
* check and reports `fingerprint` and `changedFiles`.
|
|
13
|
+
* - 5: `verify` reports `runs[].results` as counts only (no `names` or
|
|
14
|
+
* `failedNames`: the comparison is in `tests`, failed tests in the findings),
|
|
15
|
+
* and one `tests` warning for all tests that no longer run.
|
|
16
|
+
* - 6: `verify` reports `skipped`, the checks `--skip` left out.
|
|
17
|
+
*/
|
|
18
|
+
export const JSON_SCHEMA_VERSION = 6;
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// verify
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export const CHECK_IDS = ["leftovers", "dead-code", "build", "tests"];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Small helpers shared across flagrm's modules. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** `1 flag`, `2 flags`; `many` for irregular plurals. */
|
|
5
|
+
export const plural = (n, word, many = `${word}s`) => `${n} ${n === 1 ? word : many}`;
|
|
6
|
+
/** `file` relative to `from`, with `/` separators on every platform. */
|
|
7
|
+
export function relativePath(from, file) {
|
|
8
|
+
return path.relative(from, file).split(path.sep).join("/");
|
|
9
|
+
}
|
|
10
|
+
/** A JSON file's parsed content, or undefined when it is missing or not valid JSON. */
|
|
11
|
+
export function readJson(file) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `build` and `tests` checks: the configured commands must exit 0. Tests
|
|
3
|
+
* that no longer run are listed for the agent to account for: deleting the
|
|
4
|
+
* tests of the OFF path is part of removing a flag, losing any other test is not.
|
|
5
|
+
* A changed test file none of whose tests ran is a warning too: the test
|
|
6
|
+
* command doesn't cover it, so verify didn't check the edit.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { errorExcerpt, templateCheckGap } from "../baseline.js";
|
|
11
|
+
import { escapeRegExp, isTestPath } from "../scan.js";
|
|
12
|
+
import { relativePath } from "../util.js";
|
|
13
|
+
import { checkResult, plural, skippedResult } from "./context.js";
|
|
14
|
+
import { diffTestNames } from "./test-results.js";
|
|
15
|
+
function failedRunFindings(run) {
|
|
16
|
+
const findings = [
|
|
17
|
+
{
|
|
18
|
+
severity: "fail",
|
|
19
|
+
message: `\`${run.command}\` exited with ${run.exitCode} (log: ${run.log})`,
|
|
20
|
+
project: run.project,
|
|
21
|
+
file: run.log,
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
for (const line of errorExcerpt(run.log))
|
|
25
|
+
findings.push({ severity: "info", message: line, project: run.project });
|
|
26
|
+
return findings;
|
|
27
|
+
}
|
|
28
|
+
export function buildCheck(v) {
|
|
29
|
+
const runs = v.runs.filter((r) => r.check === "build");
|
|
30
|
+
if (runs.length === 0)
|
|
31
|
+
return skippedResult("build", "no build command configured");
|
|
32
|
+
const findings = runs.flatMap((r) => (r.exitCode === 0 ? [] : failedRunFindings(r)));
|
|
33
|
+
for (const run of runs) {
|
|
34
|
+
const project = v.projects.find(({ ctx }) => ctx.name === run.project);
|
|
35
|
+
const gap = project && templateCheckGap(project.adapter, run.command);
|
|
36
|
+
if (!gap)
|
|
37
|
+
continue;
|
|
38
|
+
const templates = (v.changedFiles ?? [])
|
|
39
|
+
.filter((rel) => rel.endsWith(".html"))
|
|
40
|
+
.filter((rel) => !path.relative(project.ctx.root, path.join(v.root, rel)).startsWith(".."));
|
|
41
|
+
if (templates.length)
|
|
42
|
+
findings.push({ severity: "warn", message: gap, project: run.project });
|
|
43
|
+
}
|
|
44
|
+
const failed = runs.filter((r) => r.exitCode !== 0).length;
|
|
45
|
+
const summary = failed
|
|
46
|
+
? `${plural(failed, "build")} failed`
|
|
47
|
+
: `${runs.map((r) => r.project).join(", ")} built successfully`;
|
|
48
|
+
return checkResult("build", summary, findings);
|
|
49
|
+
}
|
|
50
|
+
export function compareTests(v) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const run of v.runs.filter((r) => r.check === "test")) {
|
|
53
|
+
const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
|
|
54
|
+
const after = run.results;
|
|
55
|
+
const comparison = {
|
|
56
|
+
project: run.project,
|
|
57
|
+
before: before?.total,
|
|
58
|
+
after: after?.total,
|
|
59
|
+
removed: [],
|
|
60
|
+
added: [],
|
|
61
|
+
};
|
|
62
|
+
if (before && after)
|
|
63
|
+
Object.assign(comparison, diffTestNames(before.names, after.names));
|
|
64
|
+
out.push(comparison);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
export function testsCheck(v, comparisons) {
|
|
69
|
+
const runs = v.runs.filter((r) => r.check === "test");
|
|
70
|
+
if (runs.length === 0)
|
|
71
|
+
return skippedResult("tests", "no test command configured");
|
|
72
|
+
const findings = [];
|
|
73
|
+
for (const run of runs) {
|
|
74
|
+
if (run.exitCode !== 0) {
|
|
75
|
+
findings.push(...failedRunFindings(run).slice(0, 1));
|
|
76
|
+
for (const name of run.results?.failedNames.slice(0, 20) ?? []) {
|
|
77
|
+
findings.push({ severity: "info", message: `failed: ${name}`, project: run.project });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const ctx = v.projects.find(({ ctx }) => ctx.name === run.project)?.ctx;
|
|
81
|
+
const project = ctx?.project;
|
|
82
|
+
const comparison = comparisons.find((c) => c.project === run.project);
|
|
83
|
+
if (project?.testResults && !run.results) {
|
|
84
|
+
findings.push({
|
|
85
|
+
severity: "warn",
|
|
86
|
+
message: `no test results found at ${project.testResults}, so the test count was not compared`,
|
|
87
|
+
project: run.project,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else if (run.results && comparison?.before === undefined) {
|
|
91
|
+
findings.push({
|
|
92
|
+
severity: "info",
|
|
93
|
+
message: "the baseline has no test results to compare with (run `flagrm baseline` before the removal)",
|
|
94
|
+
project: run.project,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const removed = comparison?.removed.length ?? 0;
|
|
98
|
+
if (removed) {
|
|
99
|
+
findings.push({
|
|
100
|
+
severity: "warn",
|
|
101
|
+
message: `${plural(removed, "test no longer runs", "tests no longer run")} (fine if they only covered the OFF path or were renamed; say so in the PR)`,
|
|
102
|
+
project: run.project,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (run.results && ctx) {
|
|
106
|
+
for (const file of changedTestFiles(v, ctx.root)) {
|
|
107
|
+
if (testFileRan(file, ctx.root, run.results.names))
|
|
108
|
+
continue;
|
|
109
|
+
findings.push({
|
|
110
|
+
severity: "warn",
|
|
111
|
+
message: "changed, but none of its tests ran, so verify didn't check it: run them yourself or widen the project's `test` command",
|
|
112
|
+
project: run.project,
|
|
113
|
+
file,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const counts = comparisons
|
|
119
|
+
.filter((c) => c.after !== undefined)
|
|
120
|
+
.map((c) => (c.before === undefined ? `${c.project} ${c.after}` : `${c.project} ${c.before} → ${c.after}`));
|
|
121
|
+
const failed = runs.filter((r) => r.exitCode !== 0).length;
|
|
122
|
+
const summary = [
|
|
123
|
+
failed ? `${plural(failed, "test run")} failed` : "tests passed",
|
|
124
|
+
counts.length ? `(${counts.join(", ")})` : undefined,
|
|
125
|
+
]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.join(" ");
|
|
128
|
+
return checkResult("tests", summary, findings);
|
|
129
|
+
}
|
|
130
|
+
/** Absolute paths of test files under `root` changed since the baseline that still exist. */
|
|
131
|
+
function changedTestFiles(v, root) {
|
|
132
|
+
return (v.changedFiles ?? [])
|
|
133
|
+
.map((rel) => path.join(v.root, rel))
|
|
134
|
+
.filter((file) => !path.relative(root, file).startsWith(".."))
|
|
135
|
+
.filter((file) => isTestPath(path.relative(root, file)) && fs.existsSync(file));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Whether any of a test file's tests are among the names in the test results.
|
|
139
|
+
* Result formats don't reliably record the source file, so this matches what
|
|
140
|
+
* the names do carry: the file path (Vitest), the class named after the file
|
|
141
|
+
* (xUnit/NUnit/JUnit, qualified by the file's namespace or package when it
|
|
142
|
+
* declares one, so a unit and an integration test class of the same name
|
|
143
|
+
* aren't confused), the first `describe` title (Jest, Karma), or a
|
|
144
|
+
* `TestX`/`test_x` function name (Go, pytest).
|
|
145
|
+
*/
|
|
146
|
+
export function testFileRan(file, root, names) {
|
|
147
|
+
const rel = relativePath(root, file);
|
|
148
|
+
const base = path.basename(file).replace(/\.[^.]+$/, "");
|
|
149
|
+
let text;
|
|
150
|
+
try {
|
|
151
|
+
text = fs.readFileSync(file, "utf8");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
const keys = [rel];
|
|
157
|
+
const describe = /\b(?:describe|suite|context)(?:\.\w+)?\s*\(\s*(["'`])((?:(?!\1).)+)\1/.exec(text)?.[2];
|
|
158
|
+
if (describe)
|
|
159
|
+
keys.push(describe);
|
|
160
|
+
for (const m of text.matchAll(/\bfunc (Test\w+)|\bdef (test_\w+)/g))
|
|
161
|
+
keys.push(m[1] ?? m[2]);
|
|
162
|
+
const ns = /^\s*(?:namespace|package)\s+([\w.]+)/m.exec(text)?.[1];
|
|
163
|
+
const qualified = ns ? `${ns}.${base}` : base;
|
|
164
|
+
const className = new RegExp(`(?:^|[./\\\\ ])${escapeRegExp(qualified)}(?:[./\\\\ ]|$)`);
|
|
165
|
+
return names.some((name) => className.test(name) || keys.some((key) => name.includes(key)));
|
|
166
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A check's status is the worst severity among its findings. */
|
|
2
|
+
export function checkResult(id, summary, findings) {
|
|
3
|
+
const status = findings.some((f) => f.severity === "fail")
|
|
4
|
+
? "fail"
|
|
5
|
+
: findings.some((f) => f.severity === "warn")
|
|
6
|
+
? "warn"
|
|
7
|
+
: "pass";
|
|
8
|
+
return { id, status, summary, findings };
|
|
9
|
+
}
|
|
10
|
+
export function skippedResult(id, summary, findings = []) {
|
|
11
|
+
return { id, status: "skipped", summary, findings };
|
|
12
|
+
}
|
|
13
|
+
export { plural } from "../util.js";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dead-code` check: compiler unused-code diagnostics in the changed
|
|
3
|
+
* files that the baseline did not have (fail; warn when the adapter cannot
|
|
4
|
+
* tell which ones are new).
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileAt } from "../git.js";
|
|
9
|
+
import { checkResult, plural, skippedResult } from "./context.js";
|
|
10
|
+
export function deadCodeCheck(v) {
|
|
11
|
+
const { findings, summary, analyzed } = compilerFindings(v);
|
|
12
|
+
// A check that did not look at every changed file must not read as a pass (a found diagnostic still fails it).
|
|
13
|
+
if (!analyzed && !findings.some((f) => f.severity === "fail" || f.severity === "warn")) {
|
|
14
|
+
return skippedResult("dead-code", summary, findings);
|
|
15
|
+
}
|
|
16
|
+
return checkResult("dead-code", summary, findings);
|
|
17
|
+
}
|
|
18
|
+
function compilerFindings(v) {
|
|
19
|
+
const sha = v.baseline.git.sha;
|
|
20
|
+
if (!sha || !v.changedFiles) {
|
|
21
|
+
const reason = sha
|
|
22
|
+
? "the baseline commit is no longer reachable (amended, rebased or garbage-collected)"
|
|
23
|
+
: "the baseline has no git commit to diff against";
|
|
24
|
+
return {
|
|
25
|
+
findings: [{ severity: "info", message: `compiler diagnostics skipped: ${reason}` }],
|
|
26
|
+
summary: "compiler diagnostics skipped",
|
|
27
|
+
analyzed: false,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const findings = [];
|
|
31
|
+
let count = 0;
|
|
32
|
+
let supported = false;
|
|
33
|
+
const unread = [];
|
|
34
|
+
for (const { adapter, ctx } of v.projects) {
|
|
35
|
+
if (!adapter.unusedDiagnostics)
|
|
36
|
+
continue;
|
|
37
|
+
supported = true;
|
|
38
|
+
const inProject = new Set(ctx.files);
|
|
39
|
+
const changedFiles = v.changedFiles
|
|
40
|
+
.map((rel) => path.join(v.root, rel))
|
|
41
|
+
.filter((file) => inProject.has(file) && fs.existsSync(file));
|
|
42
|
+
if (changedFiles.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
const readLog = (log) => {
|
|
45
|
+
try {
|
|
46
|
+
return log ? fs.readFileSync(log, "utf8") : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const buildLog = readLog(v.runs.find((r) => r.project === ctx.name && r.check === "build")?.log);
|
|
53
|
+
const baselineBuildLog = readLog(v.baseline.checks?.find((r) => r.project === ctx.name && r.check === "build")?.log);
|
|
54
|
+
const result = adapter.unusedDiagnostics(ctx, {
|
|
55
|
+
changedFiles,
|
|
56
|
+
baselineText: (file) => fileAt(v.root, sha, path.relative(v.root, file)),
|
|
57
|
+
buildLog,
|
|
58
|
+
baselineBuildLog,
|
|
59
|
+
});
|
|
60
|
+
if (!result) {
|
|
61
|
+
unread.push(ctx.name);
|
|
62
|
+
findings.push({
|
|
63
|
+
severity: "info",
|
|
64
|
+
message: "compiler diagnostics skipped: no build output to read (the build check did not run)",
|
|
65
|
+
project: ctx.name,
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const d of result.diagnostics) {
|
|
70
|
+
count++;
|
|
71
|
+
findings.push({
|
|
72
|
+
severity: result.compared ? "fail" : "warn",
|
|
73
|
+
message: `${d.code} ${d.message}${result.compared ? "" : " (the baseline has no build log, so it may predate the removal)"}`,
|
|
74
|
+
project: ctx.name,
|
|
75
|
+
file: d.file,
|
|
76
|
+
line: d.line,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!supported) {
|
|
81
|
+
return { findings, summary: "compiler diagnostics skipped: no adapter reports unused code here", analyzed: false };
|
|
82
|
+
}
|
|
83
|
+
const found = count ? plural(count, "new unused-code diagnostic") : "no new unused-code diagnostics";
|
|
84
|
+
if (unread.length) {
|
|
85
|
+
return {
|
|
86
|
+
findings,
|
|
87
|
+
summary: `${count ? `${found}; ` : ""}compiler diagnostics skipped for ${unread.join(", ")}: no build output`,
|
|
88
|
+
analyzed: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { findings, summary: found, analyzed: true };
|
|
92
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flagrm verify <flag>`: the pass/fail gate for a removal. It compares the
|
|
3
|
+
* current tree with the flag's baseline (`flagrm baseline`), runs the checks
|
|
4
|
+
* and writes `.flagrm/<flag>/verify.json`.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { flagDir, readBaseline, runChecks, verifyPath } from "../baseline.js";
|
|
9
|
+
import { changedFilesSince, gitState, treeFingerprint } from "../git.js";
|
|
10
|
+
import { CHECK_IDS, JSON_SCHEMA_VERSION, } from "../types.js";
|
|
11
|
+
import { buildCheck, compareTests, testsCheck } from "./commands.js";
|
|
12
|
+
import { skippedResult } from "./context.js";
|
|
13
|
+
import { deadCodeCheck } from "./dead-code.js";
|
|
14
|
+
import { leftoversCheck } from "./leftovers.js";
|
|
15
|
+
export async function verifyFlag(root, projects, flag, options = {}) {
|
|
16
|
+
const { baseline, file: baselineFile } = readBaseline(root, flag);
|
|
17
|
+
const skip = new Set(options.skip ?? []);
|
|
18
|
+
// Take the git state before running commands: their output (bin/, test
|
|
19
|
+
// results) is not part of the change under review.
|
|
20
|
+
const git = gitState(root);
|
|
21
|
+
const changedFiles = baseline.git.sha ? changedFilesSince(root, baseline.git.sha) : undefined;
|
|
22
|
+
const only = [...(skip.has("build") ? [] : ["build"]), ...(skip.has("tests") ? [] : ["test"])];
|
|
23
|
+
const runs = only.length ? await runChecks(projects, path.join(flagDir(root, flag), "verify"), only) : [];
|
|
24
|
+
// The fingerprint, though, is the tree the Stop hook will see next, so take it
|
|
25
|
+
// after the commands: a test report they write outside .gitignore is part of it.
|
|
26
|
+
const fingerprint = treeFingerprint(root);
|
|
27
|
+
const v = { root, flag, projects, baseline, changedFiles, runs };
|
|
28
|
+
const tests = compareTests(v);
|
|
29
|
+
const run = {
|
|
30
|
+
"dead-code": () => deadCodeCheck(v),
|
|
31
|
+
build: () => buildCheck(v),
|
|
32
|
+
tests: () => testsCheck(v, tests),
|
|
33
|
+
};
|
|
34
|
+
const checks = [];
|
|
35
|
+
for (const id of CHECK_IDS) {
|
|
36
|
+
if (skip.has(id))
|
|
37
|
+
checks.push(skippedResult(id, "skipped (--skip)"));
|
|
38
|
+
else
|
|
39
|
+
checks.push(id === "leftovers" ? await leftoversCheck(v) : run[id]());
|
|
40
|
+
}
|
|
41
|
+
const strict = Boolean(options.strict);
|
|
42
|
+
const failed = checks.some((c) => c.status === "fail" || (strict && c.status === "warn"));
|
|
43
|
+
const report = {
|
|
44
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
45
|
+
flag,
|
|
46
|
+
status: failed ? "fail" : "pass",
|
|
47
|
+
strict,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
baseline: { file: baselineFile, createdAt: baseline.createdAt, git: baseline.git },
|
|
50
|
+
git,
|
|
51
|
+
fingerprint,
|
|
52
|
+
changedFiles,
|
|
53
|
+
checks,
|
|
54
|
+
skipped: CHECK_IDS.filter((id) => skip.has(id)),
|
|
55
|
+
runs: runs.map(countsOnly),
|
|
56
|
+
tests,
|
|
57
|
+
};
|
|
58
|
+
const file = verifyPath(root, flag);
|
|
59
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
60
|
+
fs.writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
61
|
+
return { report, file };
|
|
62
|
+
}
|
|
63
|
+
/** Drop the per-test name lists: thousands of names in a large suite, and `tests` already has the comparison. */
|
|
64
|
+
function countsOnly(run) {
|
|
65
|
+
if (!run.results)
|
|
66
|
+
return run;
|
|
67
|
+
const { names: _names, failedNames: _failed, ...counts } = run.results;
|
|
68
|
+
return { ...run, results: counts };
|
|
69
|
+
}
|
|
70
|
+
/** Parse `--skip build,tests`; throws on an unknown check name. */
|
|
71
|
+
export function parseSkip(value) {
|
|
72
|
+
if (!value)
|
|
73
|
+
return [];
|
|
74
|
+
const ids = value
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
for (const id of ids) {
|
|
79
|
+
if (!CHECK_IDS.includes(id)) {
|
|
80
|
+
throw new Error(`Unknown check "${id}" in --skip. Checks: ${CHECK_IDS.join(", ")}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return ids;
|
|
84
|
+
}
|