flagrm 0.1.1 → 0.2.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 +92 -0
- package/README.md +62 -21
- package/dist/adapters/dotnet/flow.js +429 -0
- package/dist/adapters/dotnet/index.js +2 -0
- package/dist/adapters/dotnet/setup.js +163 -0
- package/dist/cli.js +97 -19
- package/dist/core/baseline.js +140 -20
- package/dist/core/config-snapshot.js +183 -0
- package/dist/core/config.js +22 -1
- package/dist/core/doctor.js +37 -17
- package/dist/core/environment.js +79 -0
- package/dist/core/flag-words.js +36 -0
- package/dist/core/git.js +8 -0
- package/dist/core/hook.js +18 -8
- package/dist/core/install.js +128 -22
- package/dist/core/inventory.js +39 -0
- package/dist/core/markdown.js +16 -5
- package/dist/core/report.js +54 -11
- package/dist/core/scope.js +192 -0
- package/dist/core/tools.js +108 -0
- package/dist/core/types.js +7 -1
- package/dist/core/util.js +10 -0
- package/dist/core/verify/commands.js +127 -19
- package/dist/core/verify/context.js +9 -0
- package/dist/core/verify/deleted-text.js +180 -0
- package/dist/core/verify/index.js +3 -1
- package/dist/core/verify/leftovers.js +19 -2
- package/dist/core/verify/removed-tests.js +141 -0
- package/dist/core/verify/test-results.js +31 -6
- package/package.json +1 -1
- package/skills/flagrm-remove/SKILL.md +14 -14
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests of the OFF path that never name the flag: a flag mock returns false
|
|
3
|
+
* by default, so a test can check OFF behavior without mentioning the flag at
|
|
4
|
+
* all. What gives it away is the text it expects. When the removal deletes a
|
|
5
|
+
* string literal from the code (`"Single organization policy"`), a test still
|
|
6
|
+
* checking that text most likely tests the OFF path. And when no test checks
|
|
7
|
+
* the text that replaced it on the same line, the ON behavior lost coverage.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileAt } from "../git.js";
|
|
12
|
+
import { computeLexicalMask, isTestPath, LEX_STRING } from "../scan.js";
|
|
13
|
+
import { SourceText } from "../source-text.js";
|
|
14
|
+
import { toLf } from "../text-utils.js";
|
|
15
|
+
/** Extensions whose string literals are read: the `//`-comment languages flagrm's adapters cover. */
|
|
16
|
+
const CODE_FILE = /\.(cs|ts|tsx|js|jsx|mjs|cjs|go|java|kt)$/;
|
|
17
|
+
/** At most this many findings: the first ones show the pattern. */
|
|
18
|
+
const MAX = 20;
|
|
19
|
+
/** String literals worth looking for in tests: text (a space or 8+ characters, mostly letters), not interpolated. */
|
|
20
|
+
export function stringLiterals(text) {
|
|
21
|
+
const mask = computeLexicalMask(text, "slash");
|
|
22
|
+
const source = new SourceText("", text);
|
|
23
|
+
const out = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < text.length) {
|
|
26
|
+
if (mask[i] !== LEX_STRING) {
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const start = i;
|
|
31
|
+
while (i < text.length && mask[i] === LEX_STRING)
|
|
32
|
+
i++;
|
|
33
|
+
const raw = text.slice(start, i);
|
|
34
|
+
const m = /^([$@]*)(["'`]{1,3})([\s\S]*?)\2$/.exec(raw);
|
|
35
|
+
if (!m)
|
|
36
|
+
continue;
|
|
37
|
+
// The lexer leaves a lone `$` (C# `$"..."`) outside the string.
|
|
38
|
+
const interpolated = m[1].includes("$") || text[start - 1] === "$" || m[2] === "`";
|
|
39
|
+
const value = m[3];
|
|
40
|
+
if (interpolated && /\{|\$\{/.test(value))
|
|
41
|
+
continue;
|
|
42
|
+
if (!isText(value))
|
|
43
|
+
continue;
|
|
44
|
+
out.push({ value, line: source.position(start).line });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function isText(value) {
|
|
49
|
+
const letters = value.replace(/[^\p{L}]/gu, "").length;
|
|
50
|
+
if (letters < 4 || letters < value.length / 2)
|
|
51
|
+
return false;
|
|
52
|
+
return /\s/.test(value.trim()) || value.length >= 8;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Literals the removal deleted from non-test code: in a changed file at the
|
|
56
|
+
* baseline, and in no non-test file now (a string moved elsewhere wasn't deleted).
|
|
57
|
+
*/
|
|
58
|
+
function deletedTexts(v, codeNow, skip) {
|
|
59
|
+
const sha = v.baseline.git.sha;
|
|
60
|
+
if (!sha || !v.changedFiles)
|
|
61
|
+
return [];
|
|
62
|
+
const out = new Map();
|
|
63
|
+
for (const rel of v.changedFiles) {
|
|
64
|
+
if (!CODE_FILE.test(rel) || isTestPath(rel))
|
|
65
|
+
continue;
|
|
66
|
+
const before = fileAt(v.root, sha, rel);
|
|
67
|
+
if (before === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
const now = read(path.join(v.root, rel)) ?? "";
|
|
70
|
+
const remaining = new Set(stringLiterals(now).map((l) => l.value));
|
|
71
|
+
const literals = stringLiterals(toLf(before));
|
|
72
|
+
for (const l of literals) {
|
|
73
|
+
if (remaining.has(l.value) || skip.has(l.value) || out.has(l.value))
|
|
74
|
+
continue;
|
|
75
|
+
const counterparts = literals
|
|
76
|
+
.filter((o) => o.line === l.line && o.value !== l.value && remaining.has(o.value))
|
|
77
|
+
.map((o) => o.value);
|
|
78
|
+
out.set(l.value, { value: l.value, file: rel, counterparts });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...out.values()].filter((d) => !codeNow.some((text) => text.includes(d.value)));
|
|
82
|
+
}
|
|
83
|
+
function read(file) {
|
|
84
|
+
try {
|
|
85
|
+
return toLf(fs.readFileSync(file, "utf8"));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The test enclosing `index`: the last `it('title'` / `test(` or C#/Java test method before it. */
|
|
92
|
+
function testName(text, index) {
|
|
93
|
+
const before = text.slice(0, index);
|
|
94
|
+
let name;
|
|
95
|
+
for (const m of before.matchAll(/\b(?:it|test)\s*\(\s*(['"`])((?:(?!\1).)*)\1|\b(?:public|internal)\s+(?:async\s+)?(?:Task|void)\s+(\w+)\s*\(|\bfunc\s+(Test\w+)\s*\(/g)) {
|
|
96
|
+
name = m[2] ?? m[3] ?? m[4];
|
|
97
|
+
}
|
|
98
|
+
return name;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Warnings for tests that still check text the removal deleted from the
|
|
102
|
+
* code, and for deleted text whose replacement no test checks.
|
|
103
|
+
*/
|
|
104
|
+
export function deletedTextFindings(v) {
|
|
105
|
+
const files = new Map();
|
|
106
|
+
for (const { ctx } of v.projects) {
|
|
107
|
+
for (const file of ctx.files) {
|
|
108
|
+
if (CODE_FILE.test(file) && !files.has(file)) {
|
|
109
|
+
files.set(file, { project: ctx.name, isTest: isTestPath(path.relative(ctx.root, file)) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const tests = [];
|
|
114
|
+
const code = [];
|
|
115
|
+
for (const [file, { project, isTest }] of files) {
|
|
116
|
+
const text = read(file);
|
|
117
|
+
if (text === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
if (isTest)
|
|
120
|
+
tests.push({ file, project, text });
|
|
121
|
+
else
|
|
122
|
+
code.push(text);
|
|
123
|
+
}
|
|
124
|
+
const skip = new Set(v.baseline.names.map((n) => n.name));
|
|
125
|
+
const findings = [];
|
|
126
|
+
const deletedList = deletedTexts(v, code, skip);
|
|
127
|
+
const before = deletedList.length ? baselineTests(v) : [];
|
|
128
|
+
for (const deleted of deletedList) {
|
|
129
|
+
const quoted = JSON.stringify(deleted.value);
|
|
130
|
+
let checked = false;
|
|
131
|
+
for (const t of tests) {
|
|
132
|
+
// One finding per test that checks the text, at its first check.
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (let at = t.text.indexOf(deleted.value); at !== -1; at = t.text.indexOf(deleted.value, at + 1)) {
|
|
135
|
+
checked = true;
|
|
136
|
+
const name = testName(t.text, at);
|
|
137
|
+
if (seen.has(name))
|
|
138
|
+
continue;
|
|
139
|
+
seen.add(name);
|
|
140
|
+
findings.push({
|
|
141
|
+
severity: "warn",
|
|
142
|
+
project: t.project,
|
|
143
|
+
file: t.file,
|
|
144
|
+
line: new SourceText(t.file, t.text).position(at).line,
|
|
145
|
+
message: `${name ? `\`${name}\` ` : ""}checks ${quoted}, which the removal deleted from ${deleted.file}: ` +
|
|
146
|
+
"likely a test of the OFF path (the flag mock returns false by default); delete it",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const coveredBefore = checked || before.some((text) => text.includes(deleted.value));
|
|
151
|
+
if (coveredBefore && deleted.counterparts.length) {
|
|
152
|
+
// `"Single organization"` inside the OFF test's `"Single organization policy"` isn't a test of it.
|
|
153
|
+
const onTested = deleted.counterparts.some((c) => tests.some((t) => t.text.split(deleted.value).join("\0").includes(c)));
|
|
154
|
+
if (!onTested) {
|
|
155
|
+
findings.push({
|
|
156
|
+
severity: "warn",
|
|
157
|
+
message: `a test checked ${quoted} (deleted from ${deleted.file}), but none checks ` +
|
|
158
|
+
`${deleted.counterparts.map((c) => JSON.stringify(c)).join(" or ")}, the text kept on its line: ` +
|
|
159
|
+
"the ON path lost its test coverage",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (findings.length <= MAX)
|
|
165
|
+
return findings;
|
|
166
|
+
return [
|
|
167
|
+
...findings.slice(0, MAX),
|
|
168
|
+
{ severity: "info", message: `and ${findings.length - MAX} more tests checking deleted text` },
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
/** The baseline text of the test files changed since the baseline: a deleted OFF test is in one of them. */
|
|
172
|
+
function baselineTests(v) {
|
|
173
|
+
const sha = v.baseline.git.sha;
|
|
174
|
+
if (!sha)
|
|
175
|
+
return [];
|
|
176
|
+
return (v.changedFiles ?? [])
|
|
177
|
+
.filter((rel) => CODE_FILE.test(rel) && isTestPath(rel))
|
|
178
|
+
.map((rel) => fileAt(v.root, sha, rel))
|
|
179
|
+
.filter((text) => text !== undefined);
|
|
180
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
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 { configChanges as configChangesSince, configSnapshot } from "../config-snapshot.js";
|
|
9
10
|
import { changedFilesSince, gitState, treeFingerprint } from "../git.js";
|
|
10
11
|
import { CHECK_IDS, JSON_SCHEMA_VERSION, } from "../types.js";
|
|
11
12
|
import { buildCheck, compareTests, testsCheck } from "./commands.js";
|
|
@@ -24,7 +25,8 @@ export async function verifyFlag(root, projects, flag, options = {}) {
|
|
|
24
25
|
// The fingerprint, though, is the tree the Stop hook will see next, so take it
|
|
25
26
|
// after the commands: a test report they write outside .gitignore is part of it.
|
|
26
27
|
const fingerprint = treeFingerprint(root);
|
|
27
|
-
const
|
|
28
|
+
const configChanges = configChangesSince(baseline.config, configSnapshot(projects));
|
|
29
|
+
const v = { root, flag, projects, baseline, changedFiles, runs, configChanges };
|
|
28
30
|
const tests = compareTests(v);
|
|
29
31
|
const run = {
|
|
30
32
|
"dead-code": () => deadCodeCheck(v),
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { FEATURE_MANAGEMENT_SECTION } from "../discover.js";
|
|
6
6
|
import { discoverCandidates } from "../inventory.js";
|
|
7
7
|
import { findIdentifiers, findMentions } from "../mentions.js";
|
|
8
|
-
import {
|
|
8
|
+
import { relativePath } from "../util.js";
|
|
9
|
+
import { checkResult, configFindings, plural } from "./context.js";
|
|
10
|
+
import { deletedTextFindings } from "./deleted-text.js";
|
|
9
11
|
const STILL = {
|
|
10
12
|
config: "still configured",
|
|
11
13
|
attribute: "still gated by attribute",
|
|
@@ -39,8 +41,20 @@ export async function leftoversCheck(v) {
|
|
|
39
41
|
// `FeatureManagement__Flag=true` configures the flag through an environment variable (.env, docker-compose);
|
|
40
42
|
// `GetValue<bool>("FeatureManagement:Flag")` reads it by its section-qualified key.
|
|
41
43
|
literals.flatMap((name) => [`${FEATURE_MANAGEMENT_SECTION}__${name}`, `${FEATURE_MANAGEMENT_SECTION}:${name}`]));
|
|
44
|
+
// A local, parameter or field the flag's value travelled through is only checked in its own file.
|
|
45
|
+
const scopes = new Map();
|
|
46
|
+
for (const n of v.baseline.names.filter((x) => x.kind === "wrapper")) {
|
|
47
|
+
const current = scopes.get(n.name);
|
|
48
|
+
if (!n.file || current === "everywhere")
|
|
49
|
+
scopes.set(n.name, "everywhere");
|
|
50
|
+
else
|
|
51
|
+
scopes.set(n.name, new Set([...(current ?? []), n.file]));
|
|
52
|
+
}
|
|
42
53
|
for (const hit of hits) {
|
|
43
54
|
const isLiteral = literals.includes(hit.token);
|
|
55
|
+
const scope = isLiteral ? "everywhere" : scopes.get(hit.token);
|
|
56
|
+
if (scope instanceof Set && !scope.has(relativePath(v.root, hit.file)))
|
|
57
|
+
continue;
|
|
44
58
|
// A bare literal name in code is an identifier that merely shares the name; only its comments and
|
|
45
59
|
// config keys (`new-checkout: true`) count.
|
|
46
60
|
if (isLiteral && !hit.inComment && !hit.isConfigKey)
|
|
@@ -70,5 +84,8 @@ export async function leftoversCheck(v) {
|
|
|
70
84
|
: [fails && `${plural(fails, "reference")} left`, warns && `${plural(warns, "comment")} naming the flag`]
|
|
71
85
|
.filter(Boolean)
|
|
72
86
|
.join(", ");
|
|
73
|
-
|
|
87
|
+
const offTests = deletedTextFindings(v);
|
|
88
|
+
const offCount = offTests.filter((f) => f.severity === "warn").length;
|
|
89
|
+
const withOff = offCount ? `${summary}; ${plural(offCount, "warning")} about tests of deleted text` : summary;
|
|
90
|
+
return checkResult("leftovers", withOff, [...findings, ...offTests, ...configFindings(v, "leftovers")]);
|
|
74
91
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why each baseline test no longer runs. Deleting the OFF path's tests and
|
|
3
|
+
* dropping the flag from test names is part of a removal, and a narrower test
|
|
4
|
+
* command leaves tests out on purpose; only the rest needs explaining.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { flagWords, words } from "../flag-words.js";
|
|
9
|
+
import { fileAt } from "../git.js";
|
|
10
|
+
import { escapeRegExp, isTestPath } from "../scan.js";
|
|
11
|
+
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
|
+
const out = { deleted: [], renamed: [], excludedByConfig: [], unexplained: [] };
|
|
15
|
+
const renamedTo = matchRenames(removed, added, flagWords(ctx.names));
|
|
16
|
+
const stillRuns = new Set(afterNames.map(testKey));
|
|
17
|
+
const files = changedTestFiles(ctx);
|
|
18
|
+
for (const name of removed) {
|
|
19
|
+
const to = renamedTo.get(testKey(name));
|
|
20
|
+
if (to)
|
|
21
|
+
out.renamed.push({ from: name, to });
|
|
22
|
+
else if (isDeleted(name, stillRuns.has(testKey(name)), files))
|
|
23
|
+
out.deleted.push(name);
|
|
24
|
+
else if (ctx.testConfigChanged)
|
|
25
|
+
out.excludedByConfig.push(name);
|
|
26
|
+
else
|
|
27
|
+
out.unexplained.push(name);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
// --- renames -------------------------------------------------------------------
|
|
32
|
+
/** A test name's class (`Ns.ClassTests`, JUnit classname) and its own name (method or title). */
|
|
33
|
+
function split(key) {
|
|
34
|
+
const sep = key.indexOf(JUNIT_SEPARATOR);
|
|
35
|
+
if (sep >= 0)
|
|
36
|
+
return { owner: key.slice(0, sep), own: key.slice(sep + JUNIT_SEPARATOR.length) };
|
|
37
|
+
const dot = key.lastIndexOf(".");
|
|
38
|
+
return { owner: key.slice(0, Math.max(dot, 0)), own: key.slice(dot + 1) };
|
|
39
|
+
}
|
|
40
|
+
function isSubsequence(short, long) {
|
|
41
|
+
let i = 0;
|
|
42
|
+
for (const w of long)
|
|
43
|
+
if (i < short.length && w === short[i])
|
|
44
|
+
i++;
|
|
45
|
+
return i === short.length;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Removed test keys mapped to the new test they were renamed to: same class,
|
|
49
|
+
* the new name's words in order within the old one's, and the dropped words
|
|
50
|
+
* include one of the flag's. Each new test explains one old one; the closest
|
|
51
|
+
* (fewest dropped words) wins.
|
|
52
|
+
*/
|
|
53
|
+
function matchRenames(removed, added, flag) {
|
|
54
|
+
const addedByKey = new Map();
|
|
55
|
+
for (const name of added)
|
|
56
|
+
if (!addedByKey.has(testKey(name)))
|
|
57
|
+
addedByKey.set(testKey(name), name);
|
|
58
|
+
const candidates = [];
|
|
59
|
+
for (const from of new Set(removed.map(testKey))) {
|
|
60
|
+
const old = split(from);
|
|
61
|
+
const oldWords = words(old.own);
|
|
62
|
+
for (const to of addedByKey.keys()) {
|
|
63
|
+
const neu = split(to);
|
|
64
|
+
if (neu.owner !== old.owner)
|
|
65
|
+
continue;
|
|
66
|
+
const newWords = words(neu.own);
|
|
67
|
+
if (newWords.length === 0 || newWords.length >= oldWords.length || !isSubsequence(newWords, oldWords))
|
|
68
|
+
continue;
|
|
69
|
+
const dropped = [...oldWords];
|
|
70
|
+
for (const w of newWords)
|
|
71
|
+
dropped.splice(dropped.indexOf(w), 1);
|
|
72
|
+
if (dropped.some((w) => flag.has(w)))
|
|
73
|
+
candidates.push({ from, to, dropped: dropped.length });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const out = new Map();
|
|
77
|
+
const used = new Set();
|
|
78
|
+
for (const c of candidates.sort((a, b) => a.dropped - b.dropped)) {
|
|
79
|
+
if (out.has(c.from) || used.has(c.to))
|
|
80
|
+
continue;
|
|
81
|
+
out.set(c.from, addedByKey.get(c.to) ?? c.to);
|
|
82
|
+
used.add(c.to);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function changedTestFiles(ctx) {
|
|
87
|
+
if (!ctx.sha)
|
|
88
|
+
return [];
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const rel of ctx.changedFiles ?? []) {
|
|
91
|
+
if (!isTestPath(rel))
|
|
92
|
+
continue;
|
|
93
|
+
const before = fileAt(ctx.root, ctx.sha, rel);
|
|
94
|
+
if (before === undefined)
|
|
95
|
+
continue;
|
|
96
|
+
let now;
|
|
97
|
+
try {
|
|
98
|
+
now = fs.readFileSync(path.join(ctx.root, rel), "utf8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
now = undefined;
|
|
102
|
+
}
|
|
103
|
+
out.push({ before, now });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a changed test file had this test at the baseline and the edit
|
|
109
|
+
* removed it: the method or title is gone, or (a data row whose test still
|
|
110
|
+
* runs) the file still has it but with fewer rows.
|
|
111
|
+
*/
|
|
112
|
+
function isDeleted(name, keyStillRuns, files) {
|
|
113
|
+
const { owner, own } = split(testKey(name));
|
|
114
|
+
const ownerName = owner.split(/[.+/\\ ]/).pop() ?? "";
|
|
115
|
+
const junit = name.includes(JUNIT_SEPARATOR);
|
|
116
|
+
const has = (text) => junit
|
|
117
|
+
? text.includes(own)
|
|
118
|
+
: new RegExp(`\\b${escapeRegExp(own)}\\b`).test(text) && (!ownerName || text.includes(ownerName));
|
|
119
|
+
return files.some((f) => has(f.before) &&
|
|
120
|
+
(f.now === undefined ||
|
|
121
|
+
!has(f.now) ||
|
|
122
|
+
(keyStillRuns && dataRows(f.now, own, junit) < dataRows(f.before, own, junit))));
|
|
123
|
+
}
|
|
124
|
+
/** xUnit (`InlineData`, `[Bit]AutoData(...)` with values), NUnit and MSTest attributes that each add a data row. */
|
|
125
|
+
const DATA_ROW = /\b(?:\w*InlineData|\w*AutoData|TestCase|DataRow)\s*\(/g;
|
|
126
|
+
/**
|
|
127
|
+
* How many data rows `own` has in `text`: the row attributes between the end
|
|
128
|
+
* of the previous member and its declaration. For JUnit names (`it.each`
|
|
129
|
+
* titles), how often the title appears.
|
|
130
|
+
*/
|
|
131
|
+
function dataRows(text, own, junit) {
|
|
132
|
+
if (junit)
|
|
133
|
+
return text.split(own).length - 1;
|
|
134
|
+
let rows = 0;
|
|
135
|
+
for (const m of text.matchAll(new RegExp(`\\b${escapeRegExp(own)}\\s*\\(`, "g"))) {
|
|
136
|
+
const before = text.slice(0, m.index);
|
|
137
|
+
const start = Math.max(before.lastIndexOf("}"), before.lastIndexOf(";"), before.lastIndexOf("{")) + 1;
|
|
138
|
+
rows += before.slice(start).match(DATA_ROW)?.length ?? 0;
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
@@ -81,7 +81,7 @@ export function shortTestNames(names) {
|
|
|
81
81
|
* AutoFixture-style attributes put random values in them on every run. JUnit
|
|
82
82
|
* names are kept whole (a test title may contain parentheses).
|
|
83
83
|
*/
|
|
84
|
-
function testKey(name) {
|
|
84
|
+
export function testKey(name) {
|
|
85
85
|
if (name.includes(JUNIT_SEPARATOR))
|
|
86
86
|
return name;
|
|
87
87
|
const m = /^([\w.`+<>,[\]]+)\(.*\)$/s.exec(name);
|
|
@@ -111,6 +111,25 @@ export function diffTestNames(before, after) {
|
|
|
111
111
|
};
|
|
112
112
|
return { removed: onlyIn(before, after), added: onlyIn(after, before) };
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Split the tests failing now into the ones that already failed at the
|
|
116
|
+
* baseline (`known`) and the rest (`new`). Names are compared without TRX
|
|
117
|
+
* data-row arguments, counting rows per test: a theory whose OFF row failed at
|
|
118
|
+
* the baseline and whose ON row fails now has a new failure.
|
|
119
|
+
*/
|
|
120
|
+
export function splitFailures(baseline, now) {
|
|
121
|
+
const left = new Map();
|
|
122
|
+
for (const name of baseline)
|
|
123
|
+
left.set(testKey(name), (left.get(testKey(name)) ?? 0) + 1);
|
|
124
|
+
const out = { known: [], new: [] };
|
|
125
|
+
for (const name of now) {
|
|
126
|
+
const n = left.get(testKey(name)) ?? 0;
|
|
127
|
+
if (n > 0)
|
|
128
|
+
left.set(testKey(name), n - 1);
|
|
129
|
+
(n > 0 ? out.known : out.new).push(name);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
114
133
|
/** One `<testcase>` per test; a `<failure>`/`<error>` child fails it, `<skipped>` skips it. */
|
|
115
134
|
export function parseJUnit(xml) {
|
|
116
135
|
const results = empty();
|
|
@@ -126,19 +145,25 @@ export function parseJUnit(xml) {
|
|
|
126
145
|
/**
|
|
127
146
|
* Merge every result file matching `pattern` (an absolute path or glob)
|
|
128
147
|
* modified at or after `since` — test runners keep older result files around.
|
|
129
|
-
*
|
|
148
|
+
* Files in `claimed` that haven't changed since are skipped: another
|
|
149
|
+
* project's run just wrote them, and an overlapping pattern
|
|
150
|
+
* (`./**\/TestResults/*.trx`) would count them twice. The files read are
|
|
151
|
+
* added to `claimed`. Undefined when no such file exists.
|
|
130
152
|
*/
|
|
131
|
-
export function collectTestResults(pattern, since) {
|
|
153
|
+
export function collectTestResults(pattern, since, claimed = new Map()) {
|
|
132
154
|
// Filesystem timestamps can be coarser than Date.now().
|
|
133
155
|
const cutoff = since - 2000;
|
|
134
156
|
const files = fg
|
|
135
157
|
.sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
|
|
136
|
-
.
|
|
137
|
-
.
|
|
158
|
+
.map((file) => ({ file, mtime: fs.statSync(file).mtimeMs }))
|
|
159
|
+
.filter(({ file, mtime }) => mtime >= cutoff && claimed.get(file) !== mtime)
|
|
160
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
161
|
+
for (const { file, mtime } of files)
|
|
162
|
+
claimed.set(file, mtime);
|
|
138
163
|
if (files.length === 0)
|
|
139
164
|
return undefined;
|
|
140
165
|
const merged = empty();
|
|
141
|
-
for (const file of files) {
|
|
166
|
+
for (const { file } of files) {
|
|
142
167
|
const xml = fs.readFileSync(file, "utf8");
|
|
143
168
|
const parsed = /<TestRun\b/.test(xml) ? parseTrx(xml) : parseJUnit(xml);
|
|
144
169
|
merged.total += parsed.total;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flagrm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Guardrails for removing feature flags with AI coding agents (Claude Code, Copilot, Codex): skills, a baseline, a verify gate and a Stop hook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -10,8 +10,7 @@ description: >
|
|
|
10
10
|
|
|
11
11
|
# Remove a feature flag
|
|
12
12
|
|
|
13
|
-
**The flag is permanently ON.** Keep the ON path, delete the OFF path and
|
|
14
|
-
everything only it used. Behavior with the flag ON must not change.
|
|
13
|
+
**The flag is permanently ON.** Keep the ON path, delete the OFF path and everything only it used. Behavior with the flag ON must not change.
|
|
15
14
|
|
|
16
15
|
You find and edit the code. `flagrm` records the state before you start and
|
|
17
16
|
decides when you are done: **done means `flagrm verify <flag> --json` exited 0.**
|
|
@@ -21,6 +20,7 @@ Run it as `npx --no-install flagrm`, one command per call, no `||` (plain `npx f
|
|
|
21
20
|
|
|
22
21
|
- `git status --porcelain` must print nothing. Otherwise ask the user to commit or stash.
|
|
23
22
|
- No `flagrm.config.yaml`: run `flagrm init` and fill in project paths and build/test commands with the user.
|
|
23
|
+
Its doctor reports a missing SDK, `cargo` or Docker: show it. For `cargo`/Docker, offer `flagrm scope`; `--write` only on a yes.
|
|
24
24
|
- `flagrm list --json`: find the flag in `flags[]`. Not there: show the closest names and ask.
|
|
25
25
|
- `state` is `off`, `mixed` or `conditional`: name the environments in `config[]` that change and get a yes, or stop.
|
|
26
26
|
`unconfigured` (set in a flag service): ask if it is ON everywhere. The removal request is not that answer.
|
|
@@ -28,17 +28,17 @@ Run it as `npx --no-install flagrm`, one command per call, no `||` (plain `npx f
|
|
|
28
28
|
|
|
29
29
|
## 2. Baseline
|
|
30
30
|
|
|
31
|
-
`flagrm baseline <flag> --json` records the git commit, build and test results and the flag's
|
|
32
|
-
|
|
31
|
+
`flagrm baseline <flag> --json` records the git commit, build and test results and the flag's `names[]`.
|
|
32
|
+
A `checks[]` entry with a non-zero `exitCode` and a `failure`: a setup problem. Show the user `failure.message` (and `flagrm scope`); once fixed, rerun with `--force`.
|
|
33
|
+
Without `failure`: a build, stop. Tests: stop if the failures (`results.failedTests`) could cover the flag's code; else ask the user to accept them as known.
|
|
33
34
|
|
|
34
35
|
## 3. Find every usage
|
|
35
36
|
|
|
36
|
-
- Grep for each name in `names[]`: code, templates, config and tests.
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
each so `verify` checks it
|
|
40
|
-
`flagrm baseline <flag> --name IsNewCheckoutEnabledAsync --kind wrapper`
|
|
41
|
-
(`--kind alias` for a qualified constant like `Flags.NewCheckout`).
|
|
37
|
+
- Grep for each name in `names[]`: code, templates, config and tests. `parameterizedTests[]` are test calls passing ON/OFF.
|
|
38
|
+
- `suggestedNames[]`: values of the flag with generic names. Record each that carries only this flag, a local, parameter or field with `--file <its file>`.
|
|
39
|
+
- Follow the value further: a method or property returning it, a field or local holding it, a parameter
|
|
40
|
+
it's passed as, a DI-registered bool. Record each so `verify` checks it, then grep for it:
|
|
41
|
+
`flagrm baseline <flag> --name IsNewCheckoutEnabledAsync --kind wrapper` (`--kind alias` for a constant like `Flags.NewCheckout`).
|
|
42
42
|
|
|
43
43
|
## 4. Remove
|
|
44
44
|
|
|
@@ -66,10 +66,10 @@ Read `patterns.md` in this skill's directory when a case is unclear.
|
|
|
66
66
|
|
|
67
67
|
| Check | Fix |
|
|
68
68
|
|---|---|
|
|
69
|
-
| `leftovers` | Remove each reference listed. A comment is a warning: reword or delete it |
|
|
69
|
+
| `leftovers` | Remove each reference listed. A comment is a warning: reword or delete it. A test checking text you deleted tests the OFF path: delete it. ON text no test checks: say so in your report |
|
|
70
70
|
| `dead-code` | Delete what the removal left unused |
|
|
71
71
|
| `build` | Fix the error your edit caused; don't touch unrelated code. `tsc` skips templates: grep `.html` for each member you removed or renamed |
|
|
72
|
-
| `tests` | A failure means ON behavior changed: fix the edit, not the test.
|
|
72
|
+
| `tests` | A new failure means ON behavior changed: fix the edit, not the test. Known failures (failed at the baseline) only warn. A test listed as no longer running (`unexplained`) must be one you removed on purpose: say why. A changed test file whose tests didn't run: run them yourself and report it |
|
|
73
73
|
|
|
74
74
|
Exit 2 is a usage or config error: read the message. `--skip build,tests` is fine for a quick check
|
|
75
75
|
in between; only a full run counts, and the Stop hook won't let you finish on one with `--skip`. After three failed attempts on one check, show the user the findings.
|
|
@@ -84,5 +84,5 @@ Suggest `/flagrm-verify <flag>` for the overview and commit message. Don't commi
|
|
|
84
84
|
|
|
85
85
|
- Never lift code out of the OFF path into live code.
|
|
86
86
|
- Match the file's formatting; don't refactor unrelated code.
|
|
87
|
-
- Never edit `.flagrm/`.
|
|
88
|
-
- Never `--skip` a failing check, and never change a test's expected value to pass.
|
|
87
|
+
- Never edit `.flagrm/`. Rerun `flagrm baseline <flag>` only with `--name`, or with `--force` before your first edit (it refuses after).
|
|
88
|
+
- Never `--skip` a failing check, and never change a test's expected value or the build/test commands to pass.
|