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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/adapters/angular/discover.js +100 -0
- package/dist/adapters/angular/index.js +34 -0
- package/dist/adapters/angular/unused.js +74 -0
- package/dist/adapters/dotnet/index.js +29 -0
- package/dist/adapters/dotnet/unused.js +43 -0
- package/dist/adapters/generic/discover.js +84 -0
- package/dist/adapters/generic/index.js +50 -0
- package/dist/adapters/generic/text.js +93 -0
- package/dist/cli.js +216 -0
- package/dist/core/adapter.js +18 -0
- package/dist/core/baseline.js +255 -0
- package/dist/core/config.js +254 -0
- package/dist/core/detect.js +64 -0
- package/dist/core/discover.js +310 -0
- package/dist/core/doctor.js +141 -0
- package/dist/core/git.js +101 -0
- package/dist/core/hook.js +83 -0
- package/dist/core/install.js +257 -0
- package/dist/core/inventory.js +118 -0
- package/dist/core/markdown.js +55 -0
- package/dist/core/mentions.js +153 -0
- package/dist/core/package.js +17 -0
- package/dist/core/registry.js +29 -0
- package/dist/core/report.js +135 -0
- package/dist/core/scan.js +201 -0
- package/dist/core/source-text.js +36 -0
- package/dist/core/text-utils.js +27 -0
- package/dist/core/types.js +22 -0
- package/dist/core/util.js +18 -0
- package/dist/core/verify/commands.js +166 -0
- package/dist/core/verify/context.js +13 -0
- package/dist/core/verify/dead-code.js +92 -0
- package/dist/core/verify/index.js +84 -0
- package/dist/core/verify/leftovers.js +74 -0
- package/dist/core/verify/test-results.js +153 -0
- package/package.json +66 -0
- package/skills/flagrm-remove/SKILL.md +88 -0
- package/skills/flagrm-remove/patterns.md +96 -0
- 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
|
+
};
|