flagrm 0.2.1 → 0.3.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 CHANGED
@@ -5,6 +5,51 @@ All notable changes to this project are documented here. The format follows
5
5
  [Semantic Versioning](https://semver.org/). Before 1.0, a minor version may
6
6
  contain breaking changes.
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - **Breaking:** `build` and `test` each fall back to the adapter's default on
15
+ their own. Setting only `test` no longer drops the default build, so the
16
+ dead-code check has a build log to read. `build: false` / `test: false`
17
+ turn one off. The default .NET test command writes TRX to
18
+ `.flagrm/test-results/<project>/` and sets `testResults`, so tests are
19
+ compared by name and tests that already failed before the removal only
20
+ warn. `flagrm scope --write` writes that `testResults` out along with the
21
+ test command, and no longer folds long commands over two lines.
22
+ - flagrm's own setup files (`flagrm.config.yaml`, `.gitignore`,
23
+ `.claude/settings.json`, the installed `flagrm-*` skills and prompt files,
24
+ `AGENTS.md`, `flagrm.slnf`) and paths matching `exclude` no longer count as
25
+ part of a removal: verify's changed files, `baseline --force` and the Stop
26
+ hook ignore them, so generated output such as `graphify-out/` no longer
27
+ invalidates a passing verify. An `exclude` entry that names a directory
28
+ covers everything under it, as it already did for scanning.
29
+ - `flagrm doctor` and the `/flagrm-remove` precondition accept a tree whose
30
+ only uncommitted files are flagrm's setup files or paths matching `exclude`,
31
+ so a freshly run `flagrm init` no longer blocks the first removal.
32
+
33
+ ### Added
34
+
35
+ - `flagrm doctor` marks the build and test commands that are adapter
36
+ defaults, and warns about a test command without `testResults`. With
37
+ `--run` it fails when the test command writes no results matching
38
+ `testResults`, so a broken setup shows up before a removal instead of
39
+ halfway through, and only warns when tests already fail: the baseline
40
+ records those as known failures.
41
+ - `flagrm baseline <flag> --accept config,failures` acknowledges a change to
42
+ the flagrm config or the baseline's failing tests, also mid-removal, when
43
+ `--force` is no longer allowed. verify stops warning about the accepted
44
+ config (tests it leaves out still count as excluded by config) and lists
45
+ accepted known failures as info, so `--strict` can pass. A changed `tools:`
46
+ no longer warns at all.
47
+ - The `dead-code` check warns about a method, property, field or type the
48
+ removal left referenced only where it is declared. Compilers don't report
49
+ that for public or internal members. The check is text-based: strings,
50
+ templates, Razor views and config files count as references, comments
51
+ don't, and language keywords and the flag's recorded names are left out.
52
+
8
53
  ## [0.2.1] - 2026-10-04
9
54
 
10
55
  ### Fixed
@@ -144,6 +189,7 @@ removes the flag through the `flagrm-remove` skill, and flagrm checks the result
144
189
  `doctor` warns about an incremental `dotnet build`, which hides existing
145
190
  warnings from the dead-code comparison.
146
191
 
192
+ [0.3.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.3.0
147
193
  [0.2.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.2.1
148
194
  [0.2.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.2.0
149
195
  [0.1.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.1.1
package/README.md CHANGED
@@ -18,7 +18,7 @@ into. flagrm decides when the work is done.
18
18
  Angular and .NET projects work out of the box. Any other stack works through the
19
19
  generic adapter.
20
20
 
21
- > **Status:** flagrm is at version 0.1. Until 1.0, a minor release may contain
21
+ > **Status:** flagrm is at version 0.3. Until 1.0, a minor release may contain
22
22
  > breaking changes.
23
23
 
24
24
  ## Requirements
@@ -100,9 +100,9 @@ of these reasons is a warning, for the agent to explain in its notes.
100
100
  the flag, and repeats `flagrm verify <flag>` until it passes.
101
101
  3. **The Stop hook** (Claude Code) keeps the agent from finishing while a
102
102
  removal is in progress and not verified. In progress means a file git
103
- tracks changed on top of the baseline commit (changes to
104
- `flagrm.config.yaml` and untracked files don't count, so a removal paused
105
- on a question to the user doesn't block), or the last verify used `--skip`.
103
+ tracks changed on top of the baseline commit (untracked files and
104
+ flagrm's own setup files don't count, so a removal paused on a question to
105
+ the user doesn't block), or the last verify used `--skip`.
106
106
  4. **`/flagrm-verify <flag>`** runs `flagrm verify <flag> --md` and adds notes
107
107
  and a commit message.
108
108
 
@@ -111,11 +111,11 @@ of these reasons is a warning, for the agent to explain in its notes.
111
111
  | Command | What it does | Exit codes |
112
112
  |---|---|---|
113
113
  | `init` | Set up the config and `.gitignore` entry, and each chosen AI tool's files (asks, or `--tool claude-code,copilot,codex`; saved as `tools:`). Never overwrites or deletes | 0, 2 usage error |
114
- | `doctor` | Check the config, build and test commands (`--run` runs them), the .NET SDK `global.json` asks for, `cargo` and Docker where projects need them, git, `.gitignore`, installed skills and hook, and abandoned baselines | 0 ok, 1 problems |
114
+ | `doctor` | Check the config, the build and test commands (marking adapter defaults; `--run` runs them and fails when tests write no readable `testResults`), the .NET SDK `global.json` asks for, `cargo` and Docker where projects need them, git, `.gitignore`, installed skills and hook, and abandoned baselines | 0 ok, 1 problems |
115
115
  | `scope` | For each .NET project, show what the machine lacks and which projects a solution filter would leave out; `--write` creates `flagrm.slnf`, adds it to `.git/info/exclude` and points the project's build and test at it | 0 nothing missing, 1 something missing |
116
116
  | `update` | Refresh the installed skills, prompt files, `AGENTS.md` block and hook from this flagrm version, for the config's `tools:` | 0 |
117
117
  | `list` | List every feature flag in the project: definitions, configured state per environment, reference counts | 0 |
118
- | `baseline <flag>` | Before any edit, record the git commit, build and test results and the flag's names in `.flagrm/<flag>/baseline.json`. In .NET code the names include the locals, fields, parameters and methods the flag's value travels through when they name the flag (a local, parameter or field is checked only in its own file); generic or ambiguous ones are listed as `suggestedNames`, with `parameterizedTests` that pass the value as a literal. `--file` limits `--name` entries to one file (and records them as wrappers). `--name X --kind alias\|wrapper` adds names to an existing baseline; `--json` prints a summary (test counts, at most 20 failed test names; the file keeps everything); `--force` starts over, but only while the code is unedited since the baseline commit (changes to `flagrm.config.yaml` and `testResults` files are fine); `--no-checks` skips build and tests | 0, 2 usage error |
118
+ | `baseline <flag>` | Before any edit, record the git commit, build and test results and the flag's names in `.flagrm/<flag>/baseline.json`. In .NET code the names include the locals, fields, parameters and methods the flag's value travels through when they name the flag (a local, parameter or field is checked only in its own file); generic or ambiguous ones are listed as `suggestedNames`, with `parameterizedTests` that pass the value as a literal. `--file` limits `--name` entries to one file (and records them as wrappers). `--name X --kind alias\|wrapper` adds names to an existing baseline; `--json` prints a summary (test counts, at most 20 failed test names; the file keeps everything); `--force` starts over, but only while the code is unedited since the baseline commit; `--accept config,failures` acknowledges, also mid-removal, a config change or the baseline's failing tests so verify stops warning about them; `--no-checks` skips build and tests | 0, 2 usage error |
119
119
  | `verify <flag>` | Gate the removal with `leftovers`, `dead-code`, `build` and `tests`. Writes `.flagrm/<flag>/verify.json`. `--md` prints the overview, `--json` the full result, `--skip` skips checks, `--strict` fails on warnings | 0 pass, 1 fail, 2 usage error |
120
120
  | `hook stop` | The Claude Code Stop hook installed by `init` | always 0 |
121
121
 
@@ -127,7 +127,7 @@ of these reasons is a warning, for the agent to explain in its notes.
127
127
  | Check | Fails when |
128
128
  |---|---|
129
129
  | `leftovers` | A recorded name (the flag literal, its constants, wrappers the agent recorded) is still in code or config. A mention in a comment is a warning, and so is a test that checks text the removal deleted from the code (a test of the OFF path that never names the flag), or deleted text whose replacement on the same line no test checks (lost ON coverage) |
130
- | `dead-code` | The compiler reports new unused code (locals, imports, private members) in changed files |
130
+ | `dead-code` | The compiler reports new unused code (locals, imports, private members) in changed files. A method, property, field or type the removal left named only where it is declared is a warning (compilers don't report public members) |
131
131
  | `build` | A project's build command fails |
132
132
  | `tests` | A test that passed at the baseline fails. With `testResults`, tests that already failed at the baseline are known failures and only warn. A test that no longer runs though the diff neither deletes nor renames it and the test config is unchanged, and a changed test file none of whose tests ran, are warnings for the agent to account for |
133
133
 
@@ -168,14 +168,29 @@ exclude: ['**/Migrations/**']
168
168
  ```
169
169
 
170
170
  Paths are relative to the config file, and commands run inside each project's
171
- path. Without `build` or `test`, the adapter's defaults are used (`dotnet build
172
- --no-incremental`, `npx ng build`, and so on). `testResults` (JUnit XML or TRX)
173
- lets `verify` compare individual tests against the baseline. Only files written during the run are read, so result files left by earlier runs don't count, and projects whose patterns overlap don't count each other's files. `timeout`
174
- (seconds) fails a project's build or test command that runs longer, for example
171
+ path. `build` and `test` are resolved one at a time:
172
+
173
+ | In the config | Command that runs |
174
+ |---|---|
175
+ | absent | the adapter's default (`dotnet build --no-incremental`, `npx ng build`, and so on) |
176
+ | a command | that command |
177
+ | `false` | none, not even the default |
178
+
179
+ `testResults` (JUnit XML or TRX) lets `verify` compare individual tests
180
+ against the baseline, so a test that already failed before the removal only
181
+ warns. Only files written during the run are read. The default .NET test
182
+ command writes TRX to `.flagrm/test-results/<project>/` and reads it; a `test`
183
+ command of your own needs its own `testResults`. `timeout` (seconds) fails a project's build or test command that runs longer, for example
175
184
  a test runner left in watch mode. `tools` lists the AI coding tools whose files
176
185
  `init` and `update` manage (`claude-code`, `copilot`, `codex`); dropping one
177
186
  leaves its files in place, and `init` names them so you can delete them.
178
187
 
188
+ Paths matching `exclude` (relative to the config file or a project's path; a
189
+ directory excludes everything under it) and flagrm's own setup files don't
190
+ count as part of a removal, so generated output such as `graphify-out/` that
191
+ changes during a session doesn't invalidate a passing verify or block the Stop
192
+ hook. `--exclude` on the command line doesn't reach the Stop hook.
193
+
179
194
  ### When the machine can't build everything
180
195
 
181
196
  `flagrm init` and `flagrm doctor` check that the .NET SDK `global.json` asks
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { discoverInText } from "../../core/discover.js";
3
4
  import { BACKEND_GLOBS } from "../../core/scan.js";
5
+ import { relativePath } from "../../core/util.js";
4
6
  import { dotnetFlow } from "./flow.js";
5
7
  import { dotnetUnusedDiagnostics } from "./unused.js";
6
8
  /** Discover candidate flag names (appsettings config keys, [FeatureGate] args, eval-call literals, *Flags constants/enums). */
@@ -26,6 +28,14 @@ export const dotnetAdapter = {
26
28
  flow: (ctx, flags) => dotnetFlow(ctx, flags, ctx.project.methods),
27
29
  // --no-incremental: an up-to-date project skips compiling and prints none of its
28
30
  // warnings, so the baseline's dead-code comparison would have nothing to subtract.
29
- defaultCommands: () => ({ build: "dotnet build --no-incremental", test: "dotnet test" }),
31
+ // TRX under .flagrm/, so verify compares tests by name (known failures) out of the box.
32
+ defaultCommands: (ctx) => {
33
+ const dir = path.join(ctx.config.root, ".flagrm", "test-results", ctx.name);
34
+ return {
35
+ build: "dotnet build --no-incremental",
36
+ test: `dotnet test --logger trx --results-directory "${relativePath(ctx.root, dir) || "."}"`,
37
+ testResults: path.join(dir, "*.trx"),
38
+ };
39
+ },
30
40
  unusedDiagnostics: (_ctx, input) => dotnetUnusedDiagnostics(input),
31
41
  };
package/dist/cli.js CHANGED
@@ -4,8 +4,10 @@ import path from "node:path";
4
4
  import { Command } from "commander";
5
5
  import pc from "picocolors";
6
6
  import { realProbes } from "./adapters/dotnet/setup.js";
7
- import { addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, flagDir, mergeNames, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
7
+ import { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, flagDir, mergeNames, parseAccept, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
8
+ import { changeFilter } from "./core/change-filter.js";
8
9
  import { loadConfig, parseTools, TOOL_IDS } from "./core/config.js";
10
+ import { projectCommands } from "./core/config-snapshot.js";
9
11
  import { runDoctor } from "./core/doctor.js";
10
12
  import { gitState } from "./core/git.js";
11
13
  import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
@@ -69,8 +71,27 @@ addCommonOptions(program
69
71
  .option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included); default alias, wrapper with --file")
70
72
  .option("--file <path>", "check the --name entries only in this file (a local, parameter or field; implies --kind wrapper)")
71
73
  .option("--force", "start over: replace the baseline, only while the code is unedited since it")
74
+ .option("--accept <what>", "acknowledge without starting over, also mid-removal: config (verify stops warning about changes to the flagrm " +
75
+ "config so far), failures (the baseline's failing tests); comma-separated")
72
76
  .action(async (flag, opts) => {
73
77
  const { root, projects } = loadWorkspace(opts);
78
+ if (opts.accept !== undefined) {
79
+ if (opts.force || opts.name.length || opts.file || opts.kind || !opts.checks) {
80
+ fail(new Error("--accept only records an acknowledgement; run it without --force, --name, --file, --kind and --no-checks."));
81
+ }
82
+ let accepted;
83
+ try {
84
+ accepted = acceptBaseline(root, flag, projects, parseAccept(opts.accept));
85
+ }
86
+ catch (err) {
87
+ fail(err);
88
+ }
89
+ if (opts.json)
90
+ console.log(JSON.stringify(baselineSummary(accepted.baseline, accepted.file), null, 2));
91
+ else
92
+ printBaseline(accepted.baseline, accepted.file);
93
+ return;
94
+ }
74
95
  // Only wrappers are matched per file: a name scoped with --file is a local, parameter or field.
75
96
  const kind = opts.kind ?? (opts.file ? "wrapper" : "alias");
76
97
  if (kind !== "alias" && kind !== "wrapper") {
@@ -94,8 +115,8 @@ addCommonOptions(program
94
115
  validateAgentNames(projects, added);
95
116
  const exists = fs.existsSync(baselinePath(root, flag));
96
117
  if (exists && opts.force) {
97
- const testResults = projects.flatMap(({ ctx }) => (ctx.project.testResults ? [ctx.project.testResults] : []));
98
- assertNoEditsSinceBaseline(root, flag, testResults);
118
+ const testResults = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
119
+ assertNoEditsSinceBaseline(root, flag, testResults, changeFilter(projects[0]?.ctx.config));
99
120
  }
100
121
  if (exists && !opts.force) {
101
122
  if (added.length === 0) {
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import fg from "fast-glob";
6
- import { CONFIG_FILENAMES } from "./config.js";
6
+ import { changeFilter } from "./change-filter.js";
7
7
  import { configSnapshot, projectCommands } from "./config-snapshot.js";
8
8
  import { environmentProblem } from "./environment.js";
9
9
  import { changedFilesSince } from "./git.js";
@@ -39,7 +39,7 @@ const MAX_FAILED_NAMES = 20;
39
39
  * `baseline.json` keeps everything.
40
40
  */
41
41
  export function baselineSummary(baseline, file) {
42
- const { config: _config, checks, ...rest } = baseline;
42
+ const { config: _config, acceptedConfig: _accepted, checks, ...rest } = baseline;
43
43
  return {
44
44
  ...rest,
45
45
  ...(checks
@@ -84,10 +84,10 @@ export function readBaseline(root, flag) {
84
84
  * Run one shell command inside a project, streaming its output to `logFile`
85
85
  * (no buffer limit, stdout and stderr interleaved as printed). A command that
86
86
  * can't start, is killed or outlives the project's `timeout` fails with the
87
- * reason at the end of the log. A test run also reads the project's
88
- * `testResults` files written during it.
87
+ * reason at the end of the log. A test run also reads the `testResults`
88
+ * files (absolute path or glob) written during it.
89
89
  */
90
- export async function runCheck(ctx, check, command, logFile, claimed) {
90
+ export async function runCheck(ctx, check, command, logFile, claimed, testResults) {
91
91
  const started = Date.now();
92
92
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
93
93
  fs.writeFileSync(logFile, `$ ${command}\n`, "utf8");
@@ -115,9 +115,7 @@ export async function runCheck(ctx, check, command, logFile, claimed) {
115
115
  if (problem)
116
116
  run.failure = { kind: "environment", message: problem };
117
117
  }
118
- const results = check === "test" && ctx.project.testResults
119
- ? collectTestResults(ctx.project.testResults, started, claimed)
120
- : undefined;
118
+ const results = check === "test" && testResults ? collectTestResults(testResults, started, claimed) : undefined;
121
119
  if (results)
122
120
  run.results = results;
123
121
  return run;
@@ -191,9 +189,9 @@ export function incrementalBuildGap(adapter, build) {
191
189
  return "`dotnet build` without `--no-incremental` skips up-to-date projects and prints none of their warnings, so the dead-code check may report existing unused code as new: add `--no-incremental` to `build`";
192
190
  }
193
191
  /**
194
- * Build then test every project. A project that configures `build` and/or
195
- * `test` runs exactly those; one that configures neither gets the adapter's
196
- * defaults (`dotnet build --no-incremental`, `npx ng test`, ...).
192
+ * Build then test every project, each command resolved on its own (see
193
+ * `projectCommands`): the configured one, none for `false`, else the
194
+ * adapter's default (`dotnet build --no-incremental`, `npx ng test`, ...).
197
195
  */
198
196
  export async function runChecks(projects, logDir, only = ["build", "test"]) {
199
197
  const runs = [];
@@ -205,7 +203,8 @@ export async function runChecks(projects, logDir, only = ["build", "test"]) {
205
203
  const command = commands[check];
206
204
  if (!command)
207
205
  continue;
208
- runs.push(await runCheck(ctx, check, command, path.join(logDir, `${ctx.name}.${check}.log`), claimed));
206
+ const log = path.join(logDir, `${ctx.name}.${check}.log`);
207
+ runs.push(await runCheck(ctx, check, command, log, claimed, commands.testResults));
209
208
  }
210
209
  }
211
210
  return runs;
@@ -290,29 +289,29 @@ export function mergeNames(existing, added) {
290
289
  }
291
290
  /**
292
291
  * Files changed since `sha` that count as edits to the code: everything but
293
- * `.flagrm/`, the flagrm config, which may change to fix the setup, and the
294
- * files matching `testResults` patterns (absolute), which the baseline's own
295
- * test run writes. Undefined when git can't tell (not a repository, or `sha`
296
- * isn't in it).
292
+ * `.flagrm/`, what `ignore` names (flagrm's setup files, which may change to
293
+ * fix the setup, and `exclude`d paths), and the files matching `testResults`
294
+ * patterns (absolute), which the baseline's own test run writes. Undefined
295
+ * when git can't tell (not a repository, or `sha` isn't in it).
297
296
  */
298
- export function editsSince(root, sha, testResults = []) {
297
+ export function editsSince(root, sha, testResults = [], ignore = changeFilter()) {
299
298
  const results = new Set(testResults.flatMap((pattern) => fg
300
299
  .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
301
300
  .map((file) => relativePath(root, file))));
302
- return changedFilesSince(root, sha)?.filter((rel) => !CONFIG_FILENAMES.includes(rel) && !results.has(rel));
301
+ return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
303
302
  }
304
303
  /**
305
304
  * `baseline --force` starts over only before the removal began: the working
306
305
  * tree must still match the baseline commit. Otherwise the new baseline would
307
306
  * record half-removed code, and verify would no longer see those edits.
308
307
  * Without git there is nothing to compare, so it is allowed. Files matching
309
- * `testResults` (absolute patterns) are not edits.
308
+ * `testResults` (absolute patterns) and the ones `ignore` names are not edits.
310
309
  */
311
- export function assertNoEditsSinceBaseline(root, flag, testResults = []) {
310
+ export function assertNoEditsSinceBaseline(root, flag, testResults = [], ignore = changeFilter()) {
312
311
  const sha = readJson(baselinePath(root, flag))?.git?.sha;
313
312
  if (!sha)
314
313
  return;
315
- const edits = editsSince(root, sha, testResults);
314
+ const edits = editsSince(root, sha, testResults, ignore);
316
315
  if (edits === undefined) {
317
316
  throw new UsageError(`The baseline commit ${sha.slice(0, 7)} of ${flag} is not in this repository, so flagrm can't tell whether the ` +
318
317
  `code was edited since. If it wasn't, delete ${relativePath(root, flagDir(root, flag))}/ and take a new baseline.`);
@@ -320,12 +319,42 @@ export function assertNoEditsSinceBaseline(root, flag, testResults = []) {
320
319
  if (edits.length === 0)
321
320
  return;
322
321
  const shown = edits.slice(0, 5).join(", ") + (edits.length > 5 ? `, and ${edits.length - 5} more` : "");
323
- const uncommitted = new Set(changedFilesSince(root, "HEAD") ?? []);
322
+ const uncommitted = new Set(changedFilesSince(root, "HEAD", ignore) ?? []);
324
323
  const fix = edits.every((rel) => uncommitted.has(rel))
325
324
  ? `Set the edits aside with \`git stash -u\`, run \`flagrm baseline ${flag} --force\` again, then \`git stash pop\`.`
326
325
  : `Some are committed since the baseline commit ${sha.slice(0, 7)}: check out code without the removal first.`;
327
326
  throw new UsageError(`The code changed since the baseline of ${flag} (${shown}), so --force would record a half-done removal. ${fix}`);
328
327
  }
328
+ /** Parse `--accept config,failures`; throws on anything else, or nothing. */
329
+ export function parseAccept(value) {
330
+ const items = value
331
+ .split(",")
332
+ .map((s) => s.trim())
333
+ .filter(Boolean);
334
+ if (items.length === 0)
335
+ throw new UsageError("--accept takes config, failures; got nothing.");
336
+ for (const item of items) {
337
+ if (item !== "config" && item !== "failures") {
338
+ throw new UsageError(`--accept takes config, failures; not "${item}".`);
339
+ }
340
+ }
341
+ return [...new Set(items)];
342
+ }
343
+ /**
344
+ * Record the user's acknowledgement: the current configuration (verify then
345
+ * warns only about later changes) and/or the baseline's failing tests (listed
346
+ * as info). The git state, checks and names stay as they are, so this is
347
+ * safe mid-removal, unlike `--force`.
348
+ */
349
+ export function acceptBaseline(root, flag, projects, what) {
350
+ const { baseline } = readBaseline(root, flag);
351
+ const next = { ...baseline };
352
+ if (what.includes("config"))
353
+ next.acceptedConfig = configSnapshot(projects);
354
+ if (what.includes("failures"))
355
+ next.acceptedFailures = true;
356
+ return saveBaseline(root, next);
357
+ }
329
358
  /** Append names to an existing baseline; its git state and checks stay as they are. */
330
359
  export function addNames(root, flag, added) {
331
360
  const { baseline } = readBaseline(root, flag);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What is not part of a flag removal: flagrm's own setup files (`init`,
3
+ * `update` and `scope --write` write them, often while a removal starts) and
4
+ * the config's `exclude` globs (generated output such as `graphify-out/`).
5
+ * verify's changed files, `baseline --force`, the Stop hook and the tree
6
+ * fingerprint all use this one filter, so verify and the hook agree.
7
+ */
8
+ import picomatch from "picomatch";
9
+ import { CONFIG_FILENAMES, loadConfig } from "./config.js";
10
+ import { AGENT_SKILL_DIRS, SKILLS } from "./install.js";
11
+ import { relativePath } from "./util.js";
12
+ /** The files `flagrm init`, `update` and `scope --write` write, relative to the config root. */
13
+ export const SETUP_PATTERNS = [
14
+ ...CONFIG_FILENAMES,
15
+ ".gitignore",
16
+ ".claude/settings.json",
17
+ ...AGENT_SKILL_DIRS.flatMap((dir) => SKILLS.map((skill) => `${dir}/${skill}/**`)),
18
+ ".github/prompts/flagrm-*.prompt.md",
19
+ "AGENTS.md",
20
+ "**/flagrm.slnf",
21
+ ];
22
+ const isSetup = picomatch([...SETUP_PATTERNS], { dot: true });
23
+ /**
24
+ * The filter for a config: setup files, and paths matching an `exclude` glob
25
+ * relative to the config root or to the path of the project they are in
26
+ * (`exclude` is also read per project when scanning). Without a config, only
27
+ * setup files.
28
+ */
29
+ export function changeFilter(config) {
30
+ const exclude = config?.exclude.length ? excludeMatcher(config.exclude) : undefined;
31
+ const projectDirs = config
32
+ ? config.projects.map((p) => relativePath(config.root, p.path)).filter((dir) => dir && !dir.startsWith(".."))
33
+ : [];
34
+ return (rel) => {
35
+ if (isSetup(rel))
36
+ return true;
37
+ if (!exclude)
38
+ return false;
39
+ if (exclude(rel))
40
+ return true;
41
+ return projectDirs.some((dir) => rel.startsWith(`${dir}/`) && exclude(rel.slice(dir.length + 1)));
42
+ };
43
+ }
44
+ /**
45
+ * Matches `exclude` the way scanning's fast-glob `ignore` does: a pattern
46
+ * that matches a directory (`graphify-out`, `graphify-out/`) excludes
47
+ * everything under it, so test the path and each of its parent directories.
48
+ */
49
+ function excludeMatcher(patterns) {
50
+ const isMatch = picomatch(patterns.map((p) => p.replace(/\/+$/, "")), { dot: true });
51
+ return (rel) => {
52
+ for (let end = rel.length; end > 0; end = rel.lastIndexOf("/", end - 1)) {
53
+ if (isMatch(rel.slice(0, end)))
54
+ return true;
55
+ }
56
+ return false;
57
+ };
58
+ }
59
+ /** {@link changeFilter} for the config at `root`; only setup files when it can't be loaded. Never throws. */
60
+ export function changeFilterAt(root) {
61
+ try {
62
+ return changeFilter(loadConfig({ quiet: true }, root));
63
+ }
64
+ catch {
65
+ return changeFilter();
66
+ }
67
+ }
@@ -8,10 +8,32 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import YAML from "yaml";
10
10
  import { relativePath } from "./util.js";
11
- /** A project's build/test commands: the configured ones, or the adapter's defaults when it configures neither. */
11
+ /**
12
+ * A project's build/test commands, each resolved on its own: the configured
13
+ * one, none for `false`, else the adapter's default. `testResults` defaults
14
+ * only along with the default test command, whose output location it names.
15
+ */
12
16
  export function projectCommands(adapter, ctx) {
13
- const configured = ctx.project.build !== undefined || ctx.project.test !== undefined;
14
- return configured ? { build: ctx.project.build, test: ctx.project.test } : (adapter.defaultCommands?.(ctx) ?? {});
17
+ const fallback = adapter.defaultCommands?.(ctx) ?? {};
18
+ const out = { defaults: [] };
19
+ for (const key of ["build", "test"]) {
20
+ const configured = ctx.project[key];
21
+ if (configured === false)
22
+ continue;
23
+ if (configured !== undefined)
24
+ out[key] = configured;
25
+ else if (fallback[key]) {
26
+ out[key] = fallback[key];
27
+ out.defaults.push(key);
28
+ }
29
+ }
30
+ if (ctx.project.testResults)
31
+ out.testResults = ctx.project.testResults;
32
+ else if (out.defaults.includes("test") && fallback.testResults) {
33
+ out.testResults = fallback.testResults;
34
+ out.defaults.push("testResults");
35
+ }
36
+ return out;
15
37
  }
16
38
  /** Solution and project files: a removal may edit them, so they don't count as a command's configuration. */
17
39
  const PROJECT_FILE = /\.(sln|slnx|\w*proj)$/i;
@@ -36,8 +58,8 @@ export function configSnapshot(projects) {
36
58
  entry.build = command(commands.build);
37
59
  if (commands.test)
38
60
  entry.test = command(commands.test);
39
- if (ctx.project.testResults)
40
- entry.testResults = relativePath(root, ctx.project.testResults);
61
+ if (commands.testResults)
62
+ entry.testResults = relativePath(root, commands.testResults);
41
63
  snapshot.projects.push(entry);
42
64
  }
43
65
  return snapshot;
@@ -144,6 +166,8 @@ function withoutCommands(text) {
144
166
  };
145
167
  if (parsed && typeof parsed === "object") {
146
168
  const config = { ...parsed };
169
+ // Only says which agents `init` sets up; never changes what is scanned.
170
+ delete config.tools;
147
171
  if (Array.isArray(config.projects))
148
172
  config.projects = config.projects.map(strip);
149
173
  for (const legacy of ["frontend", "backend"]) {
@@ -135,6 +135,16 @@ function optionalString(value, where) {
135
135
  throw new Error(`${where} must be a non-empty string.`);
136
136
  return value;
137
137
  }
138
+ /** A build/test command: a non-empty string, or `false` to run none (not even the adapter's default). */
139
+ function optionalCommand(value, where) {
140
+ if (value === false)
141
+ return false;
142
+ if (value === undefined)
143
+ return undefined;
144
+ if (typeof value !== "string" || !value.trim())
145
+ throw new Error(`${where} must be a non-empty string or false.`);
146
+ return value;
147
+ }
138
148
  function optionalPositiveNumber(value, where) {
139
149
  if (value === undefined)
140
150
  return undefined;
@@ -162,7 +172,7 @@ function adapterDefaults(adapter, file) {
162
172
  return { methods: backend, attributes };
163
173
  return { methods: [...new Set([...frontend, ...backend])], attributes };
164
174
  }
165
- function parseProject(raw, index, file, fileDir) {
175
+ function parseProject(raw, index, file, fileDir, quiet = false) {
166
176
  const where = `projects[${index}]`;
167
177
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
168
178
  throw new Error(`${where} must be an object with name, adapter and path.`);
@@ -174,7 +184,8 @@ function parseProject(raw, index, file, fileDir) {
174
184
  if (!PROJECT_NAME.test(name)) {
175
185
  throw new Error(`${where}: name "${name}" must start with a letter or digit and contain only letters, digits, _ . -`);
176
186
  }
177
- warnOnUnknownKeys(p, KNOWN_PROJECT_KEYS, `project "${name}"`);
187
+ if (!quiet)
188
+ warnOnUnknownKeys(p, KNOWN_PROJECT_KEYS, `project "${name}"`);
178
189
  if (typeof p.adapter !== "string" || !ADAPTER_IDS.includes(p.adapter)) {
179
190
  throw new Error(`${where}: adapter "${String(p.adapter)}" is not one of ${ADAPTER_IDS.join(", ")}.`);
180
191
  }
@@ -187,8 +198,8 @@ function parseProject(raw, index, file, fileDir) {
187
198
  name,
188
199
  adapter,
189
200
  path: resolveProjectDir(fileDir, p.path, `Project "${name}"`),
190
- build: optionalString(p.build, `${where}.build`),
191
- test: optionalString(p.test, `${where}.test`),
201
+ build: optionalCommand(p.build, `${where}.build`),
202
+ test: optionalCommand(p.test, `${where}.test`),
192
203
  testResults: testResults && path.resolve(fileDir, testResults),
193
204
  timeout: optionalPositiveNumber(p.timeout, `${where}.timeout`),
194
205
  globs: optionalStringList(p.globs, `${where}.globs`),
@@ -221,12 +232,13 @@ export function loadConfig(cli, cwd = process.cwd()) {
221
232
  if (configPath && fs.existsSync(configPath)) {
222
233
  file = readConfigFile(configPath);
223
234
  fileDir = path.dirname(configPath);
224
- warnOnUnknownKeys(file, KNOWN_CONFIG_KEYS, configPath);
235
+ if (!cli.quiet)
236
+ warnOnUnknownKeys(file, KNOWN_CONFIG_KEYS, configPath);
225
237
  }
226
238
  if (file.projects !== undefined && !Array.isArray(file.projects)) {
227
239
  throw new Error(`"projects" must be a list of { name, adapter, path } entries.`);
228
240
  }
229
- const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i, file, fileDir));
241
+ const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i, file, fileDir, cli.quiet));
230
242
  const legacyProject = (name, adapter, dir) => ({
231
243
  name,
232
244
  adapter,
@@ -7,6 +7,7 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { realProbes } from "../adapters/dotnet/setup.js";
9
9
  import { errorExcerpt, incrementalBuildGap, runCheck, templateCheckGap } from "./baseline.js";
10
+ import { changeFilter, changeFilterAt } from "./change-filter.js";
10
11
  import { findConfigFile, loadConfig, TOOL_IDS } from "./config.js";
11
12
  import { projectCommands } from "./config-snapshot.js";
12
13
  import { gitState, isAncestor } from "./git.js";
@@ -25,10 +26,17 @@ export async function runDoctor(cwd, options = {}) {
25
26
  const root = configFile ? path.dirname(configFile) : cwd;
26
27
  await configChecks(cwd, configFile, options, add);
27
28
  const git = gitState(root);
29
+ // flagrm's own setup files and `exclude`d paths aren't part of a removal, so they needn't be committed first.
30
+ const dirty = git.dirtyFiles ?? [];
31
+ const ignore = changeFilterAt(root);
32
+ const other = dirty.filter((rel) => !ignore(rel));
33
+ const setup = dirty.filter(changeFilter());
28
34
  if (git.sha === null)
29
35
  add("warn", "not a git repository: verify can't diff against the baseline");
30
- else if (git.dirty)
31
- add("warn", `${git.dirtyFiles?.length ?? 0} uncommitted files — commit or stash before a removal`);
36
+ else if (other.length)
37
+ add("warn", `${other.length} uncommitted files — commit or stash before a removal`);
38
+ else if (setup.length)
39
+ add("ok", `working tree clean apart from flagrm's setup files (${setup.length}): commit them before or with the removal`);
32
40
  else
33
41
  add("ok", "working tree clean");
34
42
  const gitignore = path.join(root, ".gitignore");
@@ -102,11 +110,27 @@ async function configChecks(cwd, configFile, options, add) {
102
110
  add("warn", `${ctx.name}: ${gap}`);
103
111
  }
104
112
  }
113
+ if (check === "test" && !commands.testResults) {
114
+ add("warn", `${ctx.name}: test command has no testResults — verify can't compare tests by name, so a test that fails before the removal fails verify too`);
115
+ }
105
116
  if (!options.run) {
106
- add("ok", `${ctx.name}: ${check} command \`${command}\``);
117
+ const source = commands.defaults.includes(check) ? " (adapter default)" : "";
118
+ add("ok", `${ctx.name}: ${check} command \`${command}\`${source}`);
119
+ continue;
120
+ }
121
+ const log = path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`);
122
+ const run = await runCheck(ctx, check, command, log, undefined, commands.testResults);
123
+ // Proven here rather than halfway through a removal: without readable results, verify can't compare tests.
124
+ if (check === "test" && commands.testResults && !run.results) {
125
+ add("fail", `${ctx.name}: \`${command}\` wrote no test results matching ${relativePath(cwd, commands.testResults)}`, run.exitCode === 0 ? undefined : errorExcerpt(run.log, 3));
126
+ continue;
127
+ }
128
+ // A suite that is already red is no setup problem: the baseline records those tests as known failures.
129
+ if (run.exitCode !== 0 && run.results?.failed) {
130
+ const failed = run.results.failed;
131
+ add("warn", `${ctx.name}: \`${command}\`: ${failed === 1 ? "1 test fails already — the baseline records it as a known failure" : `${failed} tests fail already — the baseline records them as known failures`}, which verify only warns about`);
107
132
  continue;
108
133
  }
109
- const run = await runCheck(ctx, check, command, path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`));
110
134
  if (run.exitCode === 0) {
111
135
  add("ok", `${ctx.name}: \`${command}\` passed`);
112
136
  continue;
package/dist/core/git.js CHANGED
@@ -26,32 +26,42 @@ export function gitState(cwd) {
26
26
  const dirtyFiles = changedFilesSince(cwd, "HEAD") ?? [];
27
27
  return { sha: head.stdout.trim(), dirty: dirtyFiles.length > 0, dirtyFiles };
28
28
  }
29
- /** Files added, modified or deleted in the working tree since `sha` (tracked diff plus untracked files). */
30
- export function changedFilesSince(cwd, sha) {
29
+ /**
30
+ * Files added, modified or deleted in the working tree since `sha` (tracked
31
+ * diff plus untracked files), without the ones `ignore` names.
32
+ */
33
+ export function changedFilesSince(cwd, sha, ignore) {
31
34
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
32
35
  if (!diff.ok)
33
36
  return undefined;
34
- return [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
37
+ const all = [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
38
+ return ignore ? all.filter((rel) => !ignore(rel)) : all;
35
39
  }
36
40
  /**
37
41
  * Tracked files modified or deleted in the working tree since `sha` (staged
38
- * or not); untracked files don't count. Undefined when git can't tell.
42
+ * or not), without the ones `ignore` names; untracked files don't count.
43
+ * Undefined when git can't tell.
39
44
  */
40
- export function trackedChangesSince(cwd, sha) {
45
+ export function trackedChangesSince(cwd, sha, ignore) {
41
46
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
42
- return diff.ok ? paths(diff.stdout).sort() : undefined;
47
+ if (!diff.ok)
48
+ return undefined;
49
+ const all = paths(diff.stdout).sort();
50
+ return ignore ? all.filter((rel) => !ignore(rel)) : all;
43
51
  }
44
52
  /** A file's content at `sha`, or undefined when it did not exist there. */
45
53
  export function fileAt(cwd, sha, relativePath) {
46
54
  const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
47
55
  return result.ok ? result.stdout : undefined;
48
56
  }
49
- function gitWithIndex(cwd, index, ...args) {
57
+ function gitWithIndex(cwd, index, args, input) {
50
58
  const result = spawnSync("git", args, {
51
59
  cwd,
52
60
  encoding: "utf8",
53
61
  maxBuffer: 256 * 1024 * 1024,
54
- env: { ...process.env, GIT_INDEX_FILE: index },
62
+ input,
63
+ // Literal: a file named `[ab].txt` must not match `a.txt` when its path is passed back to git.
64
+ env: { ...process.env, GIT_INDEX_FILE: index, GIT_LITERAL_PATHSPECS: input === undefined ? undefined : "1" },
55
65
  });
56
66
  return { ok: result.status === 0, stdout: result.stdout ?? "" };
57
67
  }
@@ -62,12 +72,13 @@ function repoPrefix(cwd) {
62
72
  }
63
73
  /**
64
74
  * The git tree id of the working tree under `cwd` as it would be committed:
65
- * tracked and untracked files, not ignored ones, without `.flagrm/`. Built in a
66
- * temporary copy of the index, so the user's index is untouched. Committing
67
- * the tree doesn't change it; any edit does. Before any edit it equals
68
- * {@link treeAt} of HEAD.
75
+ * tracked and untracked files, not ignored ones, without `.flagrm/` and the
76
+ * paths `ignore` names. Built in a temporary copy of the index, so the user's
77
+ * index is untouched. Committing the tree doesn't change it; any edit does.
78
+ * Before any edit it equals {@link treeAt} of HEAD, as long as the ignored
79
+ * paths are unchanged since.
69
80
  */
70
- export function treeFingerprint(cwd) {
81
+ export function treeFingerprint(cwd, ignore) {
71
82
  const prefix = repoPrefix(cwd);
72
83
  if (prefix === undefined)
73
84
  return undefined;
@@ -79,17 +90,33 @@ export function treeFingerprint(cwd) {
79
90
  if (realIndex && fs.existsSync(realIndex))
80
91
  fs.cpSync(realIndex, index, { preserveTimestamps: true });
81
92
  // Not PATHSPEC: `git add` exits 1 when an exclude pathspec names an ignored path (`.flagrm/` usually is).
82
- if (!gitWithIndex(cwd, index, "add", "-A", "--", ".").ok)
93
+ if (!gitWithIndex(cwd, index, ["add", "-A", "--", "."]).ok)
83
94
  return undefined;
84
- if (!gitWithIndex(cwd, index, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm").ok)
95
+ if (!gitWithIndex(cwd, index, ["rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm"]).ok)
85
96
  return undefined;
86
- const tree = gitWithIndex(cwd, index, "write-tree", ...(prefix ? [`--prefix=${prefix}`] : []));
97
+ if (ignore && !dropIgnored(cwd, index, ignore))
98
+ return undefined;
99
+ const tree = gitWithIndex(cwd, index, ["write-tree", ...(prefix ? [`--prefix=${prefix}`] : [])]);
87
100
  return tree.ok ? tree.stdout.trim() : undefined;
88
101
  }
89
102
  finally {
90
103
  fs.rmSync(dir, { recursive: true, force: true });
91
104
  }
92
105
  }
106
+ /**
107
+ * Put back each path `ignore` names as it is at HEAD (or drop it, if HEAD
108
+ * doesn't have it), so the fingerprint doesn't see changes to it.
109
+ */
110
+ function dropIgnored(cwd, index, ignore) {
111
+ const listed = paths(gitWithIndex(cwd, index, ["ls-files", "-z"]).stdout).filter(ignore);
112
+ if (listed.length === 0)
113
+ return true;
114
+ const input = `${listed.join("\0")}\0`;
115
+ const fromFile = ["--pathspec-from-file=-", "--pathspec-file-nul"];
116
+ // `reset` also drops a path HEAD doesn't have; before the first commit there is no HEAD to reset to.
117
+ const args = headSha(cwd) ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
118
+ return gitWithIndex(cwd, index, args, input).ok;
119
+ }
93
120
  /** The tree id of `cwd` at commit `sha`. */
94
121
  export function treeAt(cwd, sha) {
95
122
  const prefix = repoPrefix(cwd);
package/dist/core/hook.js CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { CONFIG_FILENAMES, findConfigRoot } from "./config.js";
9
+ import { changeFilterAt } from "./change-filter.js";
10
+ import { findConfigRoot } from "./config.js";
10
11
  import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
11
12
  import { readJson } from "./util.js";
12
13
  /** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
@@ -35,9 +36,10 @@ export function hookRoot(cwd) {
35
36
  /**
36
37
  * Whether to block the stop: a removal is in progress and no passing verify
37
38
  * matches the current tree. In progress means HEAD is still the baseline
38
- * commit and a file git tracks changed, other than the flagrm config (a
39
- * removal paused on a question to the user, or a stray test result file,
40
- * doesn't count), or the last verify skipped checks. Once HEAD moves (a
39
+ * commit and a file git tracks changed, other than flagrm's setup files and
40
+ * paths matching `exclude` (a removal paused on a question to the user, or a
41
+ * stray test result file, doesn't count), or the last verify skipped checks.
42
+ * The fingerprint leaves out the same paths, as verify's does. Once HEAD moves (a
41
43
  * commit, a branch switch, a merge), that removal is no longer guarded.
42
44
  */
43
45
  export function stopDecision(root, input) {
@@ -57,11 +59,12 @@ export function stopDecision(root, input) {
57
59
  .filter((flagDir) => readJson(path.join(flagDir, "baseline.json"))?.git?.sha === head);
58
60
  if (active.length === 0)
59
61
  return { block: false };
60
- const edited = (trackedChangesSince(root, head) ?? []).some((rel) => !CONFIG_FILENAMES.includes(rel));
62
+ const ignore = changeFilterAt(root);
63
+ const edited = (trackedChangesSince(root, head, ignore) ?? []).length > 0;
61
64
  const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
62
65
  if (started.length === 0)
63
66
  return { block: false };
64
- const current = treeFingerprint(root);
67
+ const current = treeFingerprint(root, ignore);
65
68
  if (!current)
66
69
  return { block: false };
67
70
  const reasons = started.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
@@ -74,6 +74,12 @@ export function printBaseline(baseline, file) {
74
74
  const names = baseline.names.map((n) => n.kind === "literal" ? n.name : `${n.name} (${n.kind}${n.file ? ` in ${n.file}` : ""})`);
75
75
  console.log(` names: ${pc.dim(names.join(", "))}`);
76
76
  }
77
+ const accepted = [
78
+ baseline.acceptedConfig && "the config as of now",
79
+ baseline.acceptedFailures && "the baseline's failing tests",
80
+ ].filter(Boolean);
81
+ if (accepted.length)
82
+ console.log(` accepted: ${pc.dim(accepted.join(", "))}`);
77
83
  for (const n of baseline.suggestedNames ?? []) {
78
84
  console.log(` ${pc.yellow("suggested")} ${n.name} ${pc.dim(`(${n.kind})`)} ${pc.cyan(`${rel(n.file)}:${n.line}`)} ` +
79
85
  pc.dim(`if it only carries this flag: --name ${n.name} --kind wrapper` +
@@ -121,6 +121,10 @@ export function writeScope(setups, projects, root, configFile) {
121
121
  // The resolved commands: the configured ones, or the adapter's defaults when none are.
122
122
  build: commands.build && pointAt(commands.build, rel),
123
123
  test: commands.test && pointAt(commands.test, rel),
124
+ // Written out, the test command is no longer the default, so its default results location must be too.
125
+ testResults: commands.testResults && commands.defaults.includes("testResults")
126
+ ? `./${relativePath(root, commands.testResults)}`
127
+ : undefined,
124
128
  });
125
129
  }
126
130
  }
@@ -172,7 +176,9 @@ function excludeFromGit(root, file) {
172
176
  }
173
177
  /** Set the build and test commands of `projects:` entries in place, keeping the file's comments and layout. */
174
178
  function updateConfig(configFile, root, updates) {
175
- const paste = updates.map((u) => `${u.project}: build: ${u.build}; test: ${u.test}`).join("; ");
179
+ const paste = updates
180
+ .map((u) => `${u.project}: build: ${u.build}; test: ${u.test}${u.testResults ? `; testResults: ${u.testResults}` : ""}`)
181
+ .join("; ");
176
182
  if (!configFile || configFile.endsWith(".json")) {
177
183
  return {
178
184
  path: configFile ? relativePath(root, configFile) : "flagrm.config.yaml",
@@ -196,8 +202,11 @@ function updateConfig(configFile, root, updates) {
196
202
  doc.setIn(["projects", index, "build"], u.build);
197
203
  if (u.test)
198
204
  doc.setIn(["projects", index, "test"], u.test);
205
+ if (u.testResults)
206
+ doc.setIn(["projects", index, "testResults"], u.testResults);
199
207
  }
200
- const next = doc.toString();
208
+ // lineWidth 0: never fold a long command over two lines.
209
+ const next = doc.toString({ lineWidth: 0 });
201
210
  if (next !== text)
202
211
  fs.writeFileSync(configFile, next, "utf8");
203
212
  const note = missing.length
@@ -8,6 +8,7 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { errorExcerpt, templateCheckGap } from "../baseline.js";
11
+ import { projectCommands } from "../config-snapshot.js";
11
12
  import { escapeRegExp, isTestPath } from "../scan.js";
12
13
  import { relativePath } from "../util.js";
13
14
  import { checkResult, configFindings, plural, skippedResult } from "./context.js";
@@ -95,7 +96,7 @@ export function compareTests(v) {
95
96
  sha: v.baseline.git.sha,
96
97
  changedFiles: v.changedFiles,
97
98
  names: v.baseline.names,
98
- testConfigChanged: v.configChanges.some((c) => c.check === "tests" && c.project === run.project),
99
+ testConfigChanged: v.originalConfigChanges.some((c) => c.check === "tests" && c.project === run.project),
99
100
  }));
100
101
  }
101
102
  out.push(comparison);
@@ -116,9 +117,10 @@ function splitBaselineFailures(v, run) {
116
117
  }
117
118
  /**
118
119
  * A failed test run fails the check, unless every failed test already failed
119
- * at the baseline: those are known failures, and only warn.
120
+ * at the baseline: those are known failures, and only warn, or are only
121
+ * listed once the user accepted them (`baseline --accept failures`).
120
122
  */
121
- function failedTestFindings(run, failures) {
123
+ function failedTestFindings(run, failures, accepted = false) {
122
124
  const list = (names, prefix) => shortTestNames(names)
123
125
  .slice(0, 20)
124
126
  .map((name) => ({ severity: "info", message: `${prefix}: ${name}`, project: run.project }));
@@ -128,8 +130,8 @@ function failedTestFindings(run, failures) {
128
130
  if (failures.new.length === 0) {
129
131
  return [
130
132
  {
131
- severity: "warn",
132
- message: `${plural(failures.known.length, "test fails", "tests fail")}, as at the baseline (known failures; log: ${run.log})`,
133
+ severity: accepted ? "info" : "warn",
134
+ message: `${plural(failures.known.length, "test fails", "tests fail")}, as at the baseline (known failures${accepted ? ", accepted" : ""}; log: ${run.log})`,
133
135
  project: run.project,
134
136
  file: run.log,
135
137
  },
@@ -168,17 +170,18 @@ export function testsCheck(v, comparisons) {
168
170
  if (failures?.new.length !== 0)
169
171
  failed++;
170
172
  known += failures?.known.length ?? 0;
171
- findings.push(...failedTestFindings(run, failures));
173
+ findings.push(...failedTestFindings(run, failures, v.baseline.acceptedFailures));
172
174
  if (run.failure)
173
175
  findings.push(setupFinding(run));
174
176
  }
175
- const ctx = v.projects.find(({ ctx }) => ctx.name === run.project)?.ctx;
176
- const project = ctx?.project;
177
+ const entry = v.projects.find(({ ctx }) => ctx.name === run.project);
178
+ const ctx = entry?.ctx;
179
+ const testResults = entry && projectCommands(entry.adapter, entry.ctx).testResults;
177
180
  const comparison = comparisons.find((c) => c.project === run.project);
178
- if (project?.testResults && !run.results) {
181
+ if (testResults && !run.results) {
179
182
  findings.push({
180
183
  severity: "warn",
181
- message: `no test results found at ${project.testResults}, so the test count was not compared`,
184
+ message: `no test results found at ${testResults}, so the test count was not compared`,
182
185
  project: run.project,
183
186
  });
184
187
  }
@@ -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,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 { 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
12
  import { CHECK_IDS, JSON_SCHEMA_VERSION, } from "../types.js";
@@ -19,14 +20,20 @@ export async function verifyFlag(root, projects, flag, options = {}) {
19
20
  // Take the git state before running commands: their output (bin/, test
20
21
  // results) is not part of the change under review.
21
22
  const git = gitState(root);
22
- const changedFiles = baseline.git.sha ? changedFilesSince(root, baseline.git.sha) : undefined;
23
+ // flagrm's setup files and `exclude`d paths (the config's and --exclude) aren't part of the removal.
24
+ const ignore = changeFilter(projects[0]?.ctx.config);
25
+ const changedFiles = baseline.git.sha ? changedFilesSince(root, baseline.git.sha, ignore) : undefined;
23
26
  const only = [...(skip.has("build") ? [] : ["build"]), ...(skip.has("tests") ? [] : ["test"])];
24
27
  const runs = only.length ? await runChecks(projects, path.join(flagDir(root, flag), "verify"), only) : [];
25
28
  // The fingerprint, though, is the tree the Stop hook will see next, so take it
26
29
  // 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 };
30
+ // The Stop hook compares it with its own, so take it exactly as the hook does: from the config file
31
+ // alone, without --exclude, which the hook can't know.
32
+ const fingerprint = treeFingerprint(root, changeFilterAt(root));
33
+ const now = configSnapshot(projects);
34
+ const configChanges = configChangesSince(baseline.acceptedConfig ?? baseline.config, now);
35
+ const originalConfigChanges = baseline.acceptedConfig ? configChangesSince(baseline.config, now) : configChanges;
36
+ const v = { root, flag, projects, baseline, changedFiles, runs, configChanges, originalConfigChanges };
30
37
  const tests = compareTests(v);
31
38
  const run = {
32
39
  "dead-code": () => deadCodeCheck(v),
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagrm",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -55,12 +55,14 @@
55
55
  "commander": "^13.1.0",
56
56
  "fast-glob": "^3.3.3",
57
57
  "picocolors": "^1.1.1",
58
+ "picomatch": "^4.0.7",
58
59
  "typescript": "^5.7.0",
59
60
  "yaml": "^2.7.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@biomejs/biome": "2.5.10",
63
64
  "@types/node": "^22.13.0",
65
+ "@types/picomatch": "^4.0.3",
64
66
  "tsx": "^4.19.0",
65
67
  "vitest": "^3.0.0"
66
68
  }
@@ -18,7 +18,7 @@ Run it as `npx --no-install flagrm`, one command per call, no `||` (plain `npx f
18
18
 
19
19
  ## 1. Preconditions
20
20
 
21
- - `git status --porcelain` must print nothing. Otherwise ask the user to commit or stash.
21
+ - `flagrm doctor` must not warn about uncommitted files (flagrm's own setup files and `exclude`d paths don't count). Otherwise ask the user to commit or stash.
22
22
  - No `flagrm.config.yaml`: run `flagrm init` and fill in project paths and build/test commands with the user.
23
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.
@@ -30,7 +30,7 @@ Run it as `npx --no-install flagrm`, one command per call, no `||` (plain `npx f
30
30
 
31
31
  `flagrm baseline <flag> --json` records the git commit, build and test results and the flag's `names[]`.
32
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
+ 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, and on a yes run `flagrm baseline <flag> --accept failures`.
34
34
 
35
35
  ## 3. Find every usage
36
36
 
@@ -67,7 +67,7 @@ Read `patterns.md` in this skill's directory when a case is unclear.
67
67
  | Check | Fix |
68
68
  |---|---|
69
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
- | `dead-code` | Delete what the removal left unused |
70
+ | `dead-code` | Delete what the removal left unused. A declaration "referenced only where it is declared" (warning): delete it if only the OFF path used it; else say why it stays |
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
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
 
@@ -85,4 +85,4 @@ Suggest `/flagrm-verify <flag>` for the overview and commit message. Don't commi
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
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.
88
+ - Never `--skip` a failing check, and never change a test's expected value or the build/test commands to pass. If the user changes the config mid-removal (say, to leave out tests that need services), run `flagrm baseline <flag> --accept config` once they confirm.