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.
@@ -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 v = { root, flag, projects, baseline, changedFiles, runs };
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 { checkResult, plural } from "./context.js";
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
- return checkResult("leftovers", summary, findings);
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
- * Undefined when no such file exists.
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
- .filter((file) => fs.statSync(file).mtimeMs >= cutoff)
137
- .sort();
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.1.1",
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
- `names[]`. A `checks[]` entry with a non-zero `exitCode` means the code is already broken: stop.
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
- - Follow how the value travels: a method or property returning the flag, a field
38
- or local holding it, a parameter it's passed as, a DI-registered bool. Record
39
- each so `verify` checks it too, then grep for 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. Tests that no longer run must be ones you deleted on purpose. A changed test file whose tests didn't run: run them yourself and report it |
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/`. Never run `flagrm baseline <flag>` again without `--name`.
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.