flagrm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -0
  4. package/dist/adapters/angular/discover.js +100 -0
  5. package/dist/adapters/angular/index.js +34 -0
  6. package/dist/adapters/angular/unused.js +74 -0
  7. package/dist/adapters/dotnet/index.js +29 -0
  8. package/dist/adapters/dotnet/unused.js +43 -0
  9. package/dist/adapters/generic/discover.js +84 -0
  10. package/dist/adapters/generic/index.js +50 -0
  11. package/dist/adapters/generic/text.js +93 -0
  12. package/dist/cli.js +216 -0
  13. package/dist/core/adapter.js +18 -0
  14. package/dist/core/baseline.js +255 -0
  15. package/dist/core/config.js +254 -0
  16. package/dist/core/detect.js +64 -0
  17. package/dist/core/discover.js +310 -0
  18. package/dist/core/doctor.js +141 -0
  19. package/dist/core/git.js +101 -0
  20. package/dist/core/hook.js +83 -0
  21. package/dist/core/install.js +257 -0
  22. package/dist/core/inventory.js +118 -0
  23. package/dist/core/markdown.js +55 -0
  24. package/dist/core/mentions.js +153 -0
  25. package/dist/core/package.js +17 -0
  26. package/dist/core/registry.js +29 -0
  27. package/dist/core/report.js +135 -0
  28. package/dist/core/scan.js +201 -0
  29. package/dist/core/source-text.js +36 -0
  30. package/dist/core/text-utils.js +27 -0
  31. package/dist/core/types.js +22 -0
  32. package/dist/core/util.js +18 -0
  33. package/dist/core/verify/commands.js +166 -0
  34. package/dist/core/verify/context.js +13 -0
  35. package/dist/core/verify/dead-code.js +92 -0
  36. package/dist/core/verify/index.js +84 -0
  37. package/dist/core/verify/leftovers.js +74 -0
  38. package/dist/core/verify/test-results.js +153 -0
  39. package/package.json +66 -0
  40. package/skills/flagrm-remove/SKILL.md +88 -0
  41. package/skills/flagrm-remove/patterns.md +96 -0
  42. package/skills/flagrm-verify/SKILL.md +48 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/). Before 1.0, a minor version may
6
+ contain breaking changes.
7
+
8
+ ## [0.1.0] - Unreleased
9
+
10
+ Initial release.
11
+
12
+ flagrm is agent-first: an AI coding agent (Claude Code, GitHub Copilot, Codex)
13
+ removes the flag through the `flagrm-remove` skill, and flagrm checks the result.
14
+
15
+ ### Added
16
+
17
+ - `init`: writes `flagrm.config.yaml` for the Angular workspaces and .NET
18
+ solutions it detects, installs the `flagrm-remove` and `flagrm-verify` skills for
19
+ Claude Code, Copilot and Codex, adds an `AGENTS.md` block, the Claude Code
20
+ Stop hook and the `.flagrm/` `.gitignore` entry. Never overwrites.
21
+ - `doctor`: checks the config, build and test commands (`--run`), git,
22
+ `.gitignore`, installed skills and hook, and abandoned baselines.
23
+ - `update`: refreshes the installed skills, `AGENTS.md` block and hook.
24
+ - `list`: inventories every feature flag, its configured state per environment
25
+ and its reference counts.
26
+ - `baseline <flag>`: records the git commit, build and test results and the
27
+ flag's names before the removal. `--name` adds wrappers and aliases.
28
+ - `verify <flag>`: `leftovers`, `dead-code`, `build` and `tests` checks against
29
+ the baseline, with `--json` and `--md` output.
30
+ - `hook stop`: keeps Claude Code from finishing while a removal is in progress
31
+ and not verified.
32
+ - Angular and .NET adapters, and a generic adapter for other stacks.
33
+ - Per-project `timeout` (seconds) for build and test commands.
34
+ - The .NET adapter builds with `dotnet build --no-incremental` by default, and
35
+ `doctor` warns about an incremental `dotnet build`, which hides existing
36
+ warnings from the dead-code comparison.
37
+
38
+ [0.1.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dino Cosic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # flagrm
2
+
3
+ Remove feature flags with your AI coding agent, safely. flagrm gives Claude Code,
4
+ GitHub Copilot and Codex a flag-removal skill and the guardrails to check the
5
+ result: a baseline taken before the edit, and a `verify` gate that fails while
6
+ the flag is still referenced, the build breaks, a test regresses, or the
7
+ removal left unused code behind.
8
+
9
+ The agent does the reasoning and the edits (finding the flag, its aliases and
10
+ wrappers, removing the OFF path, refactoring methods the flag was passed
11
+ into). flagrm decides when it's done.
12
+
13
+ Works with Angular and .NET projects out of the box, and any other stack
14
+ through the generic adapter.
15
+
16
+ In a [benchmark on bitwarden/server and bitwarden/clients](docs/BENCHMARK.md),
17
+ Claude Code removed a live flag from each repository, including the dead OFF
18
+ paths and the tests that covered only them, in under 5 minutes and for about
19
+ $1.50 per flag, with `flagrm verify` passing on an independent rerun.
20
+
21
+ ## Requirements
22
+
23
+ - Node.js 22 or later
24
+ - git: `baseline` and `verify` compare against the commit the removal started from
25
+ - An AI coding agent that reads skills: Claude Code, GitHub Copilot or Codex
26
+ (the Stop hook is Claude Code only)
27
+
28
+ On Windows, the Stop hook runs in Git Bash, or in PowerShell when Git Bash
29
+ isn't installed. In PowerShell, `npx` needs an execution policy that allows
30
+ scripts (`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`); otherwise the
31
+ hook fails and doesn't guard the removal. `flagrm verify` still works.
32
+
33
+ ## Quick start
34
+
35
+ ```sh
36
+ npm install -D flagrm # or run everything with npx flagrm
37
+ npx flagrm init # config, skills, Stop hook, .gitignore entry
38
+ ```
39
+
40
+ Fill in the project paths and build/test commands in `flagrm.config.yaml`, check
41
+ the setup with `npx flagrm doctor`, then ask your agent:
42
+
43
+ ```
44
+ /flagrm-remove NewCheckout
45
+ ```
46
+
47
+ When it reports back:
48
+
49
+ ```
50
+ /flagrm-verify NewCheckout
51
+ ```
52
+
53
+ You get a short overview (checks, changed files, tests that no longer run) and
54
+ a proposed commit message. The agent never commits for you. The overview looks
55
+ like this (abridged):
56
+
57
+ ```md
58
+ ### flagrm verify: NewCheckout ✅ pass
59
+
60
+ | Check | Status | Summary |
61
+ |---|---|---|
62
+ | leftovers | ✅ pass | no recorded name left in code or config |
63
+ | dead-code | ✅ pass | no new unused-code diagnostics |
64
+ | build | ✅ pass | web, api built successfully |
65
+ | tests | ⚠️ warn | tests passed (web 24 → 21, api 18 → 16) |
66
+
67
+ **Files changed since baseline (26):** `backend/src/Shop.Api/Services/CheckoutService.cs`, ...
68
+
69
+ **Tests that no longer run (5):** `LegacyPricingCalculatorTests.AppliesBulkDiscount`, ...
70
+ ```
71
+
72
+ A warning is not a failure: here the agent deleted the OFF path's tests on
73
+ purpose, and says so in its notes.
74
+
75
+ ## How it works
76
+
77
+ 1. **`flagrm init`** writes `flagrm.config.yaml` for the Angular and .NET projects
78
+ it finds, installs the `flagrm-remove` and `flagrm-verify` skills into
79
+ `.claude/skills/`, `.github/skills/` and `.agents/skills/`, adds a pointer to
80
+ `AGENTS.md` for Codex, adds a Claude Code Stop hook to
81
+ `.claude/settings.json` and `.flagrm/` to `.gitignore`.
82
+ 2. **`/flagrm-remove <flag>`**: the agent checks the tree is clean, runs
83
+ `flagrm baseline <flag>` (git commit, build and test results, the flag's
84
+ names), finds every usage with its own search, records wrappers and
85
+ aliases with `flagrm baseline <flag> --name`, removes the flag, and loops
86
+ `flagrm verify <flag>` until it passes.
87
+ 3. **The Stop hook** (Claude Code) keeps the agent from finishing while a
88
+ removal is in progress (uncommitted on top of its baseline commit) and not
89
+ verified.
90
+ 4. **`/flagrm-verify <flag>`** runs `flagrm verify <flag> --md` and adds notes and a
91
+ commit message.
92
+
93
+ ## Commands
94
+
95
+ | Command | What it does | Exit codes |
96
+ |---|---|---|
97
+ | `init` | Set up the config, skills, `AGENTS.md` block, Stop hook and `.gitignore` entry. Never overwrites | 0 |
98
+ | `doctor` | Check config, build/test commands (`--run` runs them), git, `.gitignore`, installed skills and hook, abandoned baselines | 0 ok, 1 problems |
99
+ | `update` | Refresh installed skills, the `AGENTS.md` block and the hook from this flagrm version | 0 |
100
+ | `list` | Every feature flag in the project: definitions, configured state per environment, reference counts | 0 |
101
+ | `baseline <flag>` | Before any edit: record the git commit, build and test results and the flag's names in `.flagrm/<flag>/baseline.json`. `--name X --kind alias\|wrapper` adds names to an existing baseline; `--force` starts over; `--no-checks` skips build and tests | 0, 2 usage error |
102
+ | `verify <flag>` | Gate the removal: `leftovers`, `dead-code`, `build`, `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 |
103
+ | `hook stop` | The Claude Code Stop hook installed by `init` | always 0 |
104
+
105
+ `list`, `baseline` and `verify` take `--json`, `--config <path>`, and
106
+ `--include`/`--exclude <glob>`.
107
+
108
+ ## What verify checks
109
+
110
+ | Check | Fails when |
111
+ |---|---|
112
+ | `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 |
113
+ | `dead-code` | The compiler reports new unused code (locals, imports, private members) in changed files |
114
+ | `build` | A project's build command fails |
115
+ | `tests` | A test that passed at the baseline fails. Tests that no longer run, and changed test files none of whose tests ran, are warnings for the agent to account for |
116
+
117
+ In typed code, deleting the flag's definition makes every missed reference a
118
+ compile error, so the build does most of the work. `leftovers` covers what the
119
+ compiler can't see: config files, templates, comments and wrappers.
120
+
121
+ ## Configuration
122
+
123
+ ```yaml
124
+ projects:
125
+ - name: web
126
+ adapter: angular
127
+ path: ./frontend
128
+ build: npm run build
129
+ test: npx ng test --watch=false
130
+ testResults: ./frontend/test-results/junit.xml
131
+ methods: [isEnabled, watch] # flag evaluation methods
132
+ - name: api
133
+ adapter: dotnet
134
+ path: ./backend
135
+ build: dotnet build --no-incremental
136
+ test: dotnet test --logger trx --results-directory TestResults
137
+ testResults: ./backend/TestResults/*.trx
138
+ - name: service
139
+ adapter: generic # any other stack
140
+ path: ./service
141
+ globs: ['**/*.go', 'config/**/*.yaml']
142
+ methods: [IsEnabled]
143
+ exclude: ['**/Migrations/**']
144
+ ```
145
+
146
+ Paths are relative to the config file, and commands run inside each project's
147
+ path. Without `build`/`test`, the adapter's defaults are used (`dotnet build
148
+ --no-incremental`, `npx ng build`, ...). `testResults` (JUnit XML or TRX) lets
149
+ `verify` compare individual tests against the baseline. `timeout` (seconds)
150
+ fails a project's build or test command that runs longer, such as a test
151
+ runner left in watch mode.
152
+
153
+ For .NET, keep `--no-incremental` in `build`: an incremental build skips
154
+ up-to-date projects and prints none of their warnings, so the dead-code check
155
+ would have no baseline warnings to compare against.
156
+
157
+ ## Development
158
+
159
+ ```sh
160
+ npm install
161
+ npm run ci # typecheck, lint, build, tests
162
+ npm run dev -- list --config test/fixtures/realistic/before/flagrm.config.yaml
163
+ ```
164
+
165
+ `test/fixtures/realistic` holds a before/after pair of a real removal (Angular
166
+ and .NET) that the `verify` tests run against.
167
+
168
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to report a bug or send a pull
169
+ request, and [CHANGELOG.md](CHANGELOG.md) for what changed between versions.
170
+
171
+ ## License
172
+
173
+ [MIT](LICENSE)
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `flagrm list` discovery for TypeScript sources: flag registries (a flags
3
+ * object or an enum named like `*Flag*`/`*Feature*`/`*Toggle*`) and the
4
+ * boolean flags of Angular `environment*.ts` files.
5
+ *
6
+ * Each file is only parsed, never type-checked, so discovery stays cheap on
7
+ * large workspaces.
8
+ */
9
+ import path from "node:path";
10
+ import ts from "typescript";
11
+ import { configEnvironment } from "../../core/discover.js";
12
+ /** Names of flag registries and of the `features` object in an environment file. */
13
+ const REGISTRY_NAME = /flag|feature|toggle/i;
14
+ const ENVIRONMENT_FILE = /^environment(?:\..+)?\.ts$/;
15
+ /** Strip `as const`, `satisfies T`, parentheses and `Object.freeze(...)` around an initializer. */
16
+ function unwrap(node) {
17
+ if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) {
18
+ return unwrap(node.expression);
19
+ }
20
+ if (ts.isCallExpression(node) && node.expression.getText() === "Object.freeze" && node.arguments.length === 1) {
21
+ return unwrap(node.arguments[0]);
22
+ }
23
+ return node;
24
+ }
25
+ function propertyName(name) {
26
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
27
+ return name.text;
28
+ return undefined;
29
+ }
30
+ function stringValue(node) {
31
+ if (!node)
32
+ return undefined;
33
+ const value = unwrap(node);
34
+ return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value) ? value.text : undefined;
35
+ }
36
+ /** Candidate flags declared in one TypeScript file. */
37
+ export function discoverTypeScript(text, file, project) {
38
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
39
+ const out = [];
40
+ const line = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
41
+ for (const statement of sf.statements) {
42
+ if (ts.isEnumDeclaration(statement) && REGISTRY_NAME.test(statement.name.text)) {
43
+ for (const member of statement.members) {
44
+ const name = propertyName(member.name);
45
+ if (name === undefined)
46
+ continue;
47
+ const flag = member.initializer ? stringValue(member.initializer) : name;
48
+ if (flag === undefined)
49
+ continue;
50
+ const symbol = `${statement.name.text}.${name}`;
51
+ out.push({ flag, project, source: "constant", file, line: line(member), symbol, symbolKind: "enum-member" });
52
+ }
53
+ }
54
+ else if (ts.isVariableStatement(statement)) {
55
+ for (const decl of statement.declarationList.declarations) {
56
+ if (!ts.isIdentifier(decl.name) || !decl.initializer || !REGISTRY_NAME.test(decl.name.text))
57
+ continue;
58
+ const obj = unwrap(decl.initializer);
59
+ if (!ts.isObjectLiteralExpression(obj))
60
+ continue;
61
+ for (const prop of obj.properties) {
62
+ if (!ts.isPropertyAssignment(prop))
63
+ continue;
64
+ const name = propertyName(prop.name);
65
+ const flag = stringValue(prop.initializer);
66
+ if (name === undefined || flag === undefined)
67
+ continue;
68
+ const symbol = `${decl.name.text}.${name}`;
69
+ out.push({ flag, project, source: "object-key", file, line: line(prop), symbol, symbolKind: "object-key" });
70
+ }
71
+ }
72
+ }
73
+ }
74
+ if (ENVIRONMENT_FILE.test(path.basename(file))) {
75
+ const environment = configEnvironment(file);
76
+ const visit = (node) => {
77
+ if (ts.isPropertyAssignment(node) && REGISTRY_NAME.test(propertyName(node.name) ?? "")) {
78
+ const obj = unwrap(node.initializer);
79
+ if (ts.isObjectLiteralExpression(obj)) {
80
+ for (const prop of obj.properties) {
81
+ if (!ts.isPropertyAssignment(prop))
82
+ continue;
83
+ const flag = propertyName(prop.name);
84
+ const value = unwrap(prop.initializer).kind;
85
+ if (flag === undefined)
86
+ continue;
87
+ if (value !== ts.SyntaxKind.TrueKeyword && value !== ts.SyntaxKind.FalseKeyword)
88
+ continue;
89
+ const state = value === ts.SyntaxKind.TrueKeyword ? "on" : "off";
90
+ out.push({ flag, project, source: "config", file, line: line(prop), environment, state });
91
+ }
92
+ return;
93
+ }
94
+ }
95
+ ts.forEachChild(node, visit);
96
+ };
97
+ visit(sf);
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import { discoverInText } from "../../core/discover.js";
3
+ import { FRONTEND_GLOBS } from "../../core/scan.js";
4
+ import { discoverTypeScript } from "./discover.js";
5
+ import { angularUnusedDiagnostics } from "./unused.js";
6
+ /**
7
+ * Discover candidate flag names: string literals passed to configured eval
8
+ * methods (`.ts`/`.html`), flag registries (flags objects, enums) and
9
+ * `environment*.ts` feature flags.
10
+ */
11
+ export function discoverAngularFlags(ctx) {
12
+ const out = [];
13
+ const classify = { evalMethods: ctx.project.methods, attributes: [] };
14
+ for (const file of ctx.files) {
15
+ let text;
16
+ try {
17
+ text = fs.readFileSync(file, "utf8");
18
+ }
19
+ catch {
20
+ continue;
21
+ }
22
+ out.push(...discoverInText(text, file, ctx.name, classify));
23
+ if (file.endsWith(".ts"))
24
+ out.push(...discoverTypeScript(text, file, ctx.name));
25
+ }
26
+ return out;
27
+ }
28
+ export const angularAdapter = {
29
+ id: "angular",
30
+ defaultGlobs: FRONTEND_GLOBS,
31
+ discover: async (ctx) => discoverAngularFlags(ctx),
32
+ defaultCommands: () => ({ build: "npx ng build", test: "npx ng test --watch=false" }),
33
+ unusedDiagnostics: (_ctx, input) => angularUnusedDiagnostics(input),
34
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * TypeScript "unused" diagnostics for `verify`'s dead-code check: the
3
+ * compiler's own `noUnusedLocals`/`noUnusedParameters` analysis, run in
4
+ * memory on the changed files only, then on their baseline versions so
5
+ * pre-existing findings can be subtracted.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import ts from "typescript";
10
+ import { newDiagnostics, } from "../../core/adapter.js";
11
+ /** "declared but never read/used" (locals, imports, private members, type parameters, destructuring). */
12
+ const UNUSED_CODES = new Set([6133, 6138, 6192, 6196, 6198, 6199, 6205]);
13
+ const OPTIONS = {
14
+ noUnusedLocals: true,
15
+ noUnusedParameters: true,
16
+ experimentalDecorators: true,
17
+ noLib: true,
18
+ noResolve: true,
19
+ types: [],
20
+ target: ts.ScriptTarget.ES2022,
21
+ };
22
+ function diagnose(files) {
23
+ // Unused-ness is decided per file by the binder, so neither lib files nor the
24
+ // rest of the program are needed; unresolved imports do not matter here.
25
+ // Keyed by the name TypeScript asks for: it turns `\` into `/` (Windows paths).
26
+ const originals = new Map(files.map(({ file }) => [file.replace(/\\/g, "/"), file]));
27
+ const sources = new Map(files.map(({ file, text }) => {
28
+ const name = file.replace(/\\/g, "/");
29
+ return [name, ts.createSourceFile(name, text, OPTIONS.target, true)];
30
+ }));
31
+ const host = {
32
+ getSourceFile: (file) => sources.get(file),
33
+ getDefaultLibFileName: () => "lib.d.ts",
34
+ writeFile: () => undefined,
35
+ getCurrentDirectory: () => "/",
36
+ getCanonicalFileName: (file) => file,
37
+ useCaseSensitiveFileNames: () => true,
38
+ getNewLine: () => "\n",
39
+ fileExists: (file) => sources.has(file),
40
+ readFile: () => undefined,
41
+ };
42
+ const program = ts.createProgram([...sources.keys()], OPTIONS, host);
43
+ const out = [];
44
+ for (const [name, sf] of sources) {
45
+ const file = originals.get(name) ?? name;
46
+ for (const d of program.getSemanticDiagnostics(sf)) {
47
+ if (!UNUSED_CODES.has(d.code) || d.start === undefined)
48
+ continue;
49
+ out.push({
50
+ file,
51
+ line: sf.getLineAndCharacterOfPosition(d.start).line + 1,
52
+ code: `TS${d.code}`,
53
+ message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
54
+ });
55
+ }
56
+ }
57
+ return out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
58
+ }
59
+ /** Diagnostics in each file's current text that its baseline text (if any) did not have. */
60
+ export function diagnoseUnusedTs(files) {
61
+ const current = diagnose(files);
62
+ const before = diagnose(files.flatMap(({ file, baseline }) => (baseline === undefined ? [] : [{ file, text: baseline }])));
63
+ return newDiagnostics(current, before);
64
+ }
65
+ export function angularUnusedDiagnostics(input) {
66
+ const files = input.changedFiles
67
+ .filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts"))
68
+ .map((file) => ({
69
+ file: path.resolve(file),
70
+ text: fs.readFileSync(file, "utf8"),
71
+ baseline: input.baselineText(file),
72
+ }));
73
+ return { diagnostics: diagnoseUnusedTs(files), compared: true };
74
+ }
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import { discoverInText } from "../../core/discover.js";
3
+ import { BACKEND_GLOBS } from "../../core/scan.js";
4
+ import { dotnetUnusedDiagnostics } from "./unused.js";
5
+ /** Discover candidate flag names (appsettings config keys, [FeatureGate] args, eval-call literals, *Flags constants/enums). */
6
+ export function discoverDotnetFlags(ctx) {
7
+ const out = [];
8
+ const classify = { evalMethods: ctx.project.methods, attributes: ctx.project.attributes };
9
+ for (const file of ctx.files) {
10
+ let text;
11
+ try {
12
+ text = fs.readFileSync(file, "utf8");
13
+ }
14
+ catch {
15
+ continue;
16
+ }
17
+ out.push(...discoverInText(text, file, ctx.name, classify));
18
+ }
19
+ return out;
20
+ }
21
+ export const dotnetAdapter = {
22
+ id: "dotnet",
23
+ defaultGlobs: BACKEND_GLOBS,
24
+ discover: async (ctx) => discoverDotnetFlags(ctx),
25
+ // --no-incremental: an up-to-date project skips compiling and prints none of its
26
+ // warnings, so the baseline's dead-code comparison would have nothing to subtract.
27
+ defaultCommands: () => ({ build: "dotnet build --no-incremental", test: "dotnet test" }),
28
+ unusedDiagnostics: (_ctx, input) => dotnetUnusedDiagnostics(input),
29
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * .NET "unused" diagnostics for `verify`'s dead-code check, read from the
3
+ * build output: compiler warnings that are on by default (CS0169 unused
4
+ * field, CS0414 assigned but never read, CS0168/CS0219 unused locals, CS8321
5
+ * unused local function, CS9113 unread primary-constructor parameter) and the IDE analyzers when the build enforces code
6
+ * style (IDE0051/IDE0052 unused private members, IDE0060 unused parameter,
7
+ * IDE0005 unnecessary using).
8
+ */
9
+ import path from "node:path";
10
+ import { newDiagnostics, } from "../../core/adapter.js";
11
+ const UNUSED_CODES = "CS0169|CS0414|CS0168|CS0219|CS8321|CS9113|IDE0051|IDE0052|IDE0060|IDE0005";
12
+ const WARNING = new RegExp(`^\\s*(.+?\\.cs)\\((\\d+),\\d+\\): (?:warning|error) (${UNUSED_CODES}): (.*?)(?: \\[[^\\]]*\\])?\\s*$`);
13
+ /** Unused-code warnings in `files`, each reported once (the build summary repeats them). */
14
+ export function parseDotnetUnused(log, files) {
15
+ const wanted = new Set(files.map((f) => path.resolve(f)));
16
+ const seen = new Set();
17
+ const out = [];
18
+ for (const line of log.split(/\r?\n/)) {
19
+ const m = WARNING.exec(line);
20
+ if (!m)
21
+ continue;
22
+ const file = path.resolve(m[1]);
23
+ if (!wanted.has(file))
24
+ continue;
25
+ const d = { file, line: Number(m[2]), code: m[3], message: m[4] };
26
+ const key = `${d.file}:${d.line}:${d.code}:${d.message}`;
27
+ if (seen.has(key))
28
+ continue;
29
+ seen.add(key);
30
+ out.push(d);
31
+ }
32
+ return out;
33
+ }
34
+ export function dotnetUnusedDiagnostics(input) {
35
+ if (input.buildLog === undefined)
36
+ return undefined;
37
+ const files = input.changedFiles.filter((f) => f.endsWith(".cs"));
38
+ const current = parseDotnetUnused(input.buildLog, files);
39
+ if (input.baselineBuildLog === undefined)
40
+ return { diagnostics: current, compared: false };
41
+ const diagnostics = newDiagnostics(current, parseDotnetUnused(input.baselineBuildLog, files));
42
+ return { diagnostics, compared: true };
43
+ }
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs";
2
+ import { discoverInText } from "../../core/discover.js";
3
+ import { LEX_CODE, LEX_STRING } from "../../core/scan.js";
4
+ import { SourceText } from "../../core/source-text.js";
5
+ import { toLf } from "../../core/text-utils.js";
6
+ import { closingParen, declaredName, evalCallPattern, isConfigFile, lexicalMask } from "./text.js";
7
+ /** A string literal that could hold a flag name. */
8
+ const FLAG_LIKE_LITERAL = /(['"`])([\w.:/-]{1,100})\1/g;
9
+ /**
10
+ * Candidate flags for `flagrm list` in any language:
11
+ * - `eval`: the first string literal passed to a configured eval method;
12
+ * - `constant`: a `NAME = "flag"` declaration whose name is passed to one
13
+ * (`IsEnabled(ctx, flags.NewCheckout)`);
14
+ * - `config`: `FeatureManagement` keys in `.json` files.
15
+ */
16
+ export function discoverGeneric(ctx) {
17
+ const out = [];
18
+ const pattern = evalCallPattern(ctx.project.methods);
19
+ const declared = new Map();
20
+ const passed = new Set();
21
+ for (const file of ctx.files) {
22
+ let text;
23
+ try {
24
+ text = toLf(fs.readFileSync(file, "utf8"));
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ if (file.endsWith(".json")) {
30
+ out.push(...discoverInText(text, file, ctx.name, { evalMethods: [], attributes: [] }));
31
+ continue;
32
+ }
33
+ if (isConfigFile(file))
34
+ continue;
35
+ const mask = lexicalMask(file, text);
36
+ const source = new SourceText(file, text);
37
+ for (const m of text.matchAll(FLAG_LIKE_LITERAL)) {
38
+ if (mask && mask[m.index] !== LEX_STRING)
39
+ continue;
40
+ const decl = declaredName(text, m.index, file);
41
+ if (!decl)
42
+ continue;
43
+ const list = declared.get(decl.name) ?? [];
44
+ list.push({ value: m[2], file, line: source.position(decl.start).line });
45
+ declared.set(decl.name, list);
46
+ }
47
+ if (!pattern)
48
+ continue;
49
+ for (const m of text.matchAll(pattern)) {
50
+ if (mask && mask[m.index] !== LEX_CODE)
51
+ continue;
52
+ const open = m.index + m[0].length - 1;
53
+ const close = closingParen(text, mask, open);
54
+ const lineEnd = text.indexOf("\n", open);
55
+ const args = text.slice(open + 1, close !== -1 ? close : lineEnd !== -1 ? lineEnd : text.length);
56
+ const literal = /(['"`])((?:\\.|(?!\1).)*)\1/.exec(args);
57
+ if (literal) {
58
+ out.push({
59
+ flag: literal[2],
60
+ project: ctx.name,
61
+ source: "eval",
62
+ file,
63
+ line: source.position(open + 1 + literal.index).line,
64
+ });
65
+ }
66
+ for (const id of args.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, "").matchAll(/[A-Za-z_$][\w$]*/g))
67
+ passed.add(id[0]);
68
+ }
69
+ }
70
+ for (const name of passed) {
71
+ for (const d of declared.get(name) ?? []) {
72
+ out.push({
73
+ flag: d.value,
74
+ project: ctx.name,
75
+ source: "constant",
76
+ file: d.file,
77
+ line: d.line,
78
+ symbol: name,
79
+ symbolKind: "const",
80
+ });
81
+ }
82
+ }
83
+ return out;
84
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The generic adapter: discovery and verification for any stack, configured
3
+ * by `globs` and `methods` (eval method names).
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { discoverGeneric } from "./discover.js";
8
+ /**
9
+ * Common source and configuration files, minus vendored dependencies, build
10
+ * output and lock files (on top of the global ignore list). Set `globs` on the
11
+ * project to replace them.
12
+ */
13
+ export const GENERIC_GLOBS = [
14
+ "**/*.{go,py,rb,java,kt,kts,scala,swift,rs,php,dart,c,h,cc,cpp,hpp,cs,js,jsx,mjs,cjs,ts,tsx,html,vue,svelte}",
15
+ "**/*.{json,yaml,yml,toml,properties,ini}",
16
+ "**/.env",
17
+ "**/.env.*",
18
+ "!**/vendor/**",
19
+ "!**/target/**",
20
+ "!**/venv/**",
21
+ "!**/__pycache__/**",
22
+ "!**/package-lock.json",
23
+ ];
24
+ /** Build and test commands inferred from the project's manifest: Go modules, Cargo, npm scripts. */
25
+ function defaultCommands(ctx) {
26
+ const has = (file) => fs.existsSync(path.join(ctx.root, file));
27
+ if (has("go.mod"))
28
+ return { build: "go build ./...", test: "go test ./..." };
29
+ if (has("Cargo.toml"))
30
+ return { build: "cargo build", test: "cargo test" };
31
+ if (has("package.json")) {
32
+ try {
33
+ const scripts = JSON.parse(fs.readFileSync(path.join(ctx.root, "package.json"), "utf8")).scripts ?? {};
34
+ return {
35
+ ...(scripts.build ? { build: "npm run build" } : {}),
36
+ ...(scripts.test ? { test: "npm test" } : {}),
37
+ };
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ return {};
44
+ }
45
+ export const genericAdapter = {
46
+ id: "generic",
47
+ defaultGlobs: GENERIC_GLOBS,
48
+ discover: async (ctx) => discoverGeneric(ctx),
49
+ defaultCommands,
50
+ };