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 +46 -0
- package/README.md +26 -11
- package/dist/adapters/dotnet/index.js +11 -1
- package/dist/cli.js +24 -3
- package/dist/core/baseline.js +51 -22
- package/dist/core/change-filter.js +67 -0
- package/dist/core/config-snapshot.js +29 -5
- package/dist/core/config.js +18 -6
- package/dist/core/doctor.js +28 -4
- package/dist/core/git.js +43 -16
- package/dist/core/hook.js +9 -6
- package/dist/core/report.js +6 -0
- package/dist/core/scope.js +11 -2
- package/dist/core/verify/commands.js +13 -10
- package/dist/core/verify/dead-code.js +9 -2
- package/dist/core/verify/index.js +11 -4
- package/dist/core/verify/orphans.js +175 -0
- package/package.json +3 -1
- package/skills/flagrm-remove/SKILL.md +4 -4
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.
|
|
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 (
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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 }) => (
|
|
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) {
|
package/dist/core/baseline.js
CHANGED
|
@@ -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 {
|
|
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
|
|
88
|
-
*
|
|
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" &&
|
|
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
|
|
195
|
-
* `
|
|
196
|
-
*
|
|
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
|
-
|
|
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/`,
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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) => !
|
|
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
|
-
/**
|
|
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
|
|
14
|
-
|
|
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 (
|
|
40
|
-
entry.testResults = relativePath(root,
|
|
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"]) {
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
191
|
-
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
|
-
|
|
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,
|
package/dist/core/doctor.js
CHANGED
|
@@ -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 (
|
|
31
|
-
add("warn", `${
|
|
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
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
66
|
-
* temporary copy of the index, so the user's
|
|
67
|
-
* the tree doesn't change it; any edit does.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
39
|
-
* removal paused on a question to the user, or a
|
|
40
|
-
* doesn't count), or the last verify skipped checks.
|
|
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
|
|
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);
|
package/dist/core/report.js
CHANGED
|
@@ -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` +
|
package/dist/core/scope.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
176
|
-
const
|
|
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 (
|
|
181
|
+
if (testResults && !run.results) {
|
|
179
182
|
findings.push({
|
|
180
183
|
severity: "warn",
|
|
181
|
-
message: `no test results found at ${
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
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.
|
|
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
|
-
- `
|
|
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.
|