flagrm 0.3.0 → 0.4.1
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 +100 -0
- package/README.md +56 -29
- package/dist/adapters/angular/component-files.js +41 -0
- package/dist/adapters/angular/flow.js +121 -0
- package/dist/adapters/angular/holders.js +194 -0
- package/dist/adapters/angular/index.js +20 -1
- package/dist/adapters/angular/inputs.js +181 -0
- package/dist/cli.js +41 -21
- package/dist/core/baseline.js +60 -31
- package/dist/core/change-filter.js +3 -2
- package/dist/core/config-snapshot.js +26 -3
- package/dist/core/config.js +5 -1
- package/dist/core/detect.js +4 -4
- package/dist/core/doctor.js +26 -6
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +50 -3
- package/dist/core/hook.js +13 -2
- package/dist/core/install.js +26 -11
- package/dist/core/inventory.js +3 -1
- package/dist/core/markdown.js +4 -0
- package/dist/core/name-owner.js +179 -0
- package/dist/core/report.js +7 -0
- package/dist/core/run-cache.js +147 -0
- package/dist/core/scan.js +30 -3
- package/dist/core/scope-files.js +14 -0
- package/dist/core/scope.js +3 -3
- package/dist/core/test-runner.js +136 -0
- package/dist/core/types.js +9 -4
- package/dist/core/verify/commands.js +160 -22
- package/dist/core/verify/coverage.js +92 -0
- package/dist/core/verify/index.js +32 -5
- package/dist/core/verify/log-results.js +127 -0
- package/dist/core/verify/removed-tests.js +90 -7
- package/dist/core/verify/test-results.js +58 -10
- package/package.json +4 -4
- package/skills/flagrm-remove/SKILL.md +28 -8
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,104 @@ contain breaking changes.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] - 2026-10-12
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- Replaced `fast-glob` with `tinyglobby`, which drops the vulnerable `braces`
|
|
15
|
+
dependency (GHSA-vfj7-8cjw-p6xm) from installs.
|
|
16
|
+
- The Stop hook no longer lets a removal finish unverified when the config's
|
|
17
|
+
`exclude` (or anything else it scans) changed since the baseline: widening
|
|
18
|
+
`exclude` used to hide the removal's edits from the hook.
|
|
19
|
+
- A `git.sha` in `baseline.json` that isn't a commit id is refused, and no
|
|
20
|
+
revision starting with `-` reaches git, where `--output=<file>` would have
|
|
21
|
+
written a file.
|
|
22
|
+
- SECURITY.md says what flagrm guarantees and what the Stop hook doesn't:
|
|
23
|
+
it guards against finishing a removal by accident, not an agent working
|
|
24
|
+
around it.
|
|
25
|
+
|
|
26
|
+
## [0.4.0] - 2026-10-11
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `testResults` reads Jest JSON (`jest --json`, Vitest's `json` reporter); a
|
|
31
|
+
result file that doesn't parse, or an XML file that isn't complete, is skipped.
|
|
32
|
+
- Without `testResults`, `verify` reads the changed test files to list
|
|
33
|
+
deleted and renamed tests, and warns when the log's test count dropped by
|
|
34
|
+
more than the diff deletes.
|
|
35
|
+
- Without `testResults`, `verify` reads failing test names and counts from
|
|
36
|
+
the test command's log (Karma, Jest, Vitest, `dotnet test`; Go and pytest
|
|
37
|
+
names), so a test that already failed at the baseline is a known failure
|
|
38
|
+
there too.
|
|
39
|
+
- `doctor`'s warning about a test command without `testResults` says how
|
|
40
|
+
to get results for the detected runner (Vitest, Jest, Karma, `dotnet test`).
|
|
41
|
+
- An optional per-project `lint` command (lint or type-check), run by
|
|
42
|
+
`baseline`, `verify` and `doctor --run`. `verify`'s new `lint` check fails
|
|
43
|
+
on lint errors the removal caused; a lint that already failed at the
|
|
44
|
+
baseline only warns, unless its log has error lines the baseline's didn't
|
|
45
|
+
(line and column numbers ignored).
|
|
46
|
+
- `verify`'s new `coverage` check warns about changed files that no build,
|
|
47
|
+
test or lint command checks; a project's optional `covers` globs say which
|
|
48
|
+
files its commands check (default: everything under its path). Files
|
|
49
|
+
already dirty at the baseline and unchanged since don't count, nor do
|
|
50
|
+
documentation and deleted files outside every project. With
|
|
51
|
+
`--strict`, such a file now fails verify.
|
|
52
|
+
- `/flagrm-remove` takes several flags (`/flagrm-remove A B C`): it asks
|
|
53
|
+
once whether they are all ON and whether to commit each one, then removes
|
|
54
|
+
them one after another, one commit per flag.
|
|
55
|
+
- `/flagrm-remove` ends with the verify overview and a proposed commit
|
|
56
|
+
message, so `/flagrm-verify` is only needed to check a removal later. The
|
|
57
|
+
commit message takes the ticket from the branch name and wraps its body.
|
|
58
|
+
- A passing full verify saves its build and test results; `baseline` and
|
|
59
|
+
`verify` on the same tree with the same commands reuse them instead of
|
|
60
|
+
running them again, and say so. Removing flags one after another, each
|
|
61
|
+
next baseline takes seconds, and so does `/flagrm-verify` after a
|
|
62
|
+
removal. `--no-reuse` runs them anyway.
|
|
63
|
+
- Angular: a renamed import of a flag registry
|
|
64
|
+
(`import { FeatureFlags as AppFeatureFlags }`) is followed, so
|
|
65
|
+
`AppFeatureFlags.X` is recorded and checked like `FeatureFlags.X`.
|
|
66
|
+
- Angular: a name checked only in a component's `.ts` file is checked in
|
|
67
|
+
its `templateUrl` template too, whether discovery or `--file` scoped it.
|
|
68
|
+
- Angular: `list` and `baseline` follow a flag's value through the fields,
|
|
69
|
+
getters, parameterless methods and locals holding an evaluation of it
|
|
70
|
+
(`isPaginationEnabled = this.ff.isFeatureFlagEnabled(…)`), names derived
|
|
71
|
+
from those, and one hop into the child component input a template binds
|
|
72
|
+
one of them to. Names that mention the flag are recorded; generic ones are
|
|
73
|
+
listed as `suggestedNames`.
|
|
74
|
+
- `baseline --file` repeats: `--name x --file a.ts --file b.ts` records the
|
|
75
|
+
name for every file in one call.
|
|
76
|
+
- `baseline --name` refuses a name whose code reads only other flags (a
|
|
77
|
+
helper named like this flag that evaluates a different one), since
|
|
78
|
+
`verify` would then ask to delete the other flag's code.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **Breaking:** JSON schema version 8: `--json`, `baseline.json` and `verify.json` gain
|
|
83
|
+
the `lint` and `coverage` checks, `lint` runs and `git.dirtyHashes`.
|
|
84
|
+
Version 6 and 7 baselines are still read.
|
|
85
|
+
- The Angular adapter's default test command now writes test results when
|
|
86
|
+
the workspace's runner can without setup: JUnit for Angular's Vitest
|
|
87
|
+
builder (`ng test --reporters=junit --output-file`) and plain Vitest, Jest
|
|
88
|
+
JSON for Jest, and JUnit for Karma once karma.conf configures
|
|
89
|
+
`karma-junit-reporter`. Results go to `.flagrm/test-results/<project>/`.
|
|
90
|
+
A baseline taken before upgrading reports the test command as changed
|
|
91
|
+
once; `flagrm baseline <flag> --accept config` or a new baseline clears it.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- A failed test run without test results showed only "exited with 1":
|
|
96
|
+
`verify` now lists the log's error lines, as for a failed build.
|
|
97
|
+
- A lint, build or test command that fails because its package script
|
|
98
|
+
(`npm run lint` without a `lint` script) or ESLint config is missing is a
|
|
99
|
+
setup problem: `verify` and `doctor --run` fail on it instead of treating
|
|
100
|
+
it as lint debt.
|
|
101
|
+
- Deleting a committed file that matches `exclude` (say, a generator removing
|
|
102
|
+
`graphify-out/graph.html`) no longer invalidates a passing verify: the
|
|
103
|
+
Stop hook's tree fingerprint now leaves out deletions under `exclude`, as
|
|
104
|
+
it already did for edits and new files.
|
|
105
|
+
- `/flagrm-remove` tells the agent not to run code generators or formatters
|
|
106
|
+
after the passing verify, or to verify again if it must.
|
|
107
|
+
|
|
10
108
|
## [0.3.0] - 2026-10-07
|
|
11
109
|
|
|
12
110
|
### Changed
|
|
@@ -189,6 +287,8 @@ removes the flag through the `flagrm-remove` skill, and flagrm checks the result
|
|
|
189
287
|
`doctor` warns about an incremental `dotnet build`, which hides existing
|
|
190
288
|
warnings from the dead-code comparison.
|
|
191
289
|
|
|
290
|
+
[0.4.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.4.1
|
|
291
|
+
[0.4.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.4.0
|
|
192
292
|
[0.3.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.3.0
|
|
193
293
|
[0.2.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.2.1
|
|
194
294
|
[0.2.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.2.0
|
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ Remove feature flags with your AI coding agent, safely.
|
|
|
8
8
|
flagrm gives Claude Code, GitHub Copilot and Codex a flag-removal skill and the
|
|
9
9
|
guardrails to check the result. It records a baseline before the edit, then
|
|
10
10
|
gates the removal with `flagrm verify`, which fails while the flag is still
|
|
11
|
-
referenced, the build breaks, a test regresses, or the removal leaves
|
|
12
|
-
code behind.
|
|
11
|
+
referenced, the build breaks, a test or lint regresses, or the removal leaves
|
|
12
|
+
unused code behind.
|
|
13
13
|
|
|
14
14
|
The agent does the reasoning and the edits: finding the flag, its aliases and
|
|
15
15
|
wrappers, removing the OFF path, and refactoring methods the flag was passed
|
|
@@ -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.4. Until 1.0, a minor release may contain
|
|
22
22
|
> breaking changes.
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
@@ -48,14 +48,11 @@ agent:
|
|
|
48
48
|
/flagrm-remove NewCheckout
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
You get a short overview (checks, changed files, tests deleted or renamed) and
|
|
58
|
-
a proposed commit message. The agent never commits for you. The overview looks
|
|
51
|
+
The agent ends with a short overview (checks, changed files, tests deleted or
|
|
52
|
+
renamed) and a proposed commit message; it commits only when you ask. Several
|
|
53
|
+
flags at once: `/flagrm-remove NewCheckout DarkMode` asks the ON question and
|
|
54
|
+
whether to commit each flag once, then removes them one after another. To
|
|
55
|
+
check a removal later, run `/flagrm-verify NewCheckout`. The overview looks
|
|
59
56
|
like this (abridged):
|
|
60
57
|
|
|
61
58
|
```md
|
|
@@ -67,6 +64,8 @@ like this (abridged):
|
|
|
67
64
|
| dead-code | ✅ pass | no new unused-code diagnostics |
|
|
68
65
|
| build | ✅ pass | web, api built successfully |
|
|
69
66
|
| tests | ✅ pass | tests passed (web 24 → 22: 2 deleted, 1 renamed; api 18 → 16: 2 deleted) |
|
|
67
|
+
| lint | ✅ pass | web linted cleanly |
|
|
68
|
+
| coverage | ✅ pass | every changed file is built, tested or linted |
|
|
70
69
|
|
|
71
70
|
**Files changed since baseline (26):** `backend/src/Shop.Api/Services/CheckoutService.cs`, ...
|
|
72
71
|
|
|
@@ -97,26 +96,32 @@ of these reasons is a warning, for the agent to explain in its notes.
|
|
|
97
96
|
`flagrm baseline <flag>`, which records the git commit, the build and test
|
|
98
97
|
results, and the flag's names. It finds every usage with its own search,
|
|
99
98
|
records wrappers and aliases with `flagrm baseline <flag> --name`, removes
|
|
100
|
-
the flag, and repeats `flagrm verify <flag>` until it passes.
|
|
99
|
+
the flag, and repeats `flagrm verify <flag>` until it passes. It ends with
|
|
100
|
+
the overview and a commit message. With several flags it asks once whether
|
|
101
|
+
they are all ON and whether to commit each, then removes them in order,
|
|
102
|
+
one commit each.
|
|
101
103
|
3. **The Stop hook** (Claude Code) keeps the agent from finishing while a
|
|
102
104
|
removal is in progress and not verified. In progress means a file git
|
|
103
105
|
tracks changed on top of the baseline commit (untracked files and
|
|
104
106
|
flagrm's own setup files don't count, so a removal paused on a question to
|
|
105
|
-
the user doesn't block),
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
the user doesn't block), the config changed in what it scans or ignores
|
|
108
|
+
(such as `exclude`), or the last verify used `--skip`. It guards against
|
|
109
|
+
finishing by accident, not an agent working around it: see
|
|
110
|
+
[SECURITY.md](SECURITY.md#what-it-doesnt-guarantee).
|
|
111
|
+
4. **`/flagrm-verify <flag>`** checks a removal later: runs
|
|
112
|
+
`flagrm verify <flag> --md` and adds notes and a commit message.
|
|
108
113
|
|
|
109
114
|
## Commands
|
|
110
115
|
|
|
111
116
|
| Command | What it does | Exit codes |
|
|
112
117
|
|---|---|---|
|
|
113
118
|
| `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, the build and
|
|
119
|
+
| `doctor` | Check the config, the build, test and lint 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
120
|
| `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
121
|
| `update` | Refresh the installed skills, prompt files, `AGENTS.md` block and hook from this flagrm version, for the config's `tools:` | 0 |
|
|
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
|
|
119
|
-
| `verify <flag>` | Gate the removal with `leftovers`, `dead-code`, `build` and `
|
|
122
|
+
| `list` | List every feature flag in the project: definitions, configured state per environment, reference counts; flow names include renamed imports of a flag registry (`import { FeatureFlags as AppFeatureFlags }`) and, in Angular, the fields, getters, methods and locals holding a flag and a child component's input bound to one | 0 |
|
|
123
|
+
| `baseline <flag>` | Before any edit, record the git commit, build, test and lint results and the flag's names in `.flagrm/<flag>/baseline.json`. In .NET and Angular code the names include the locals, fields, getters, 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, and an Angular component's template); generic or ambiguous ones are listed as `suggestedNames`, with `parameterizedTests` that pass the value as a literal. `--file` (repeatable) limits `--name` entries to those files (and records them as wrappers). `--name X --kind alias\|wrapper` adds names to an existing baseline (a name whose declaration reads only other flags is refused); `--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, tests and lint; build, test and lint results of a passing verify on the same tree and commands are reused (`--no-reuse` runs them) | 0, 2 usage error |
|
|
124
|
+
| `verify <flag>` | Gate the removal with `leftovers`, `dead-code`, `build`, `tests`, `lint` and `coverage`. Writes `.flagrm/<flag>/verify.json`. `--md` prints the overview, `--json` the full result, `--skip` skips checks, `--strict` fails on warnings; reuses its own or another flag's passing runs on the same tree and commands (`--no-reuse` runs them) | 0 pass, 1 fail, 2 usage error |
|
|
120
125
|
| `hook stop` | The Claude Code Stop hook installed by `init` | always 0 |
|
|
121
126
|
|
|
122
127
|
`list`, `baseline` and `verify` accept `--json`, `--config <path>`, and
|
|
@@ -129,10 +134,12 @@ of these reasons is a warning, for the agent to explain in its notes.
|
|
|
129
134
|
| `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
135
|
| `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
136
|
| `build` | A project's build command fails |
|
|
132
|
-
| `tests` | A test that passed at the baseline fails.
|
|
137
|
+
| `tests` | A test that passed at the baseline fails. Tests that already failed at the baseline are known failures and only warn; without `testResults` they are read from the test log (Karma, Jest, Vitest, `dotnet test`), and count as known only when the log names every failing test. 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. Without `testResults`, deleted and renamed tests come from the changed test files, and a test count that dropped by more than the diff deletes is a warning (info when the changed test files use data rows such as `[InlineData]`, `TestCase`, `DataRow` or `.each`, whose cases count separately, or when the test config changed since the baseline) |
|
|
138
|
+
| `lint` | The project's `lint` command fails where it passed at the baseline. A lint that already failed at the baseline only warns, unless its log has error lines the baseline's didn't (line and column numbers ignored); a missing package script or ESLint config is a setup problem and fails |
|
|
139
|
+
| `coverage` | A changed file that no project's build, test or lint command checks (outside every project with a command, or outside its `covers` globs) is a warning, so it fails only with `--strict`. Files already dirty at the baseline and unchanged since don't count, nor do projects whose commands `--skip` left out |
|
|
133
140
|
|
|
134
141
|
The baseline also records the configuration it was taken with: each project's
|
|
135
|
-
resolved build and
|
|
142
|
+
resolved build, test and lint commands, the files they name (a `.slnf`, a
|
|
136
143
|
`.runsettings`, a filter list) and the text of `flagrm.config.yaml`. When any
|
|
137
144
|
of it changed since, the check it affects warns and shows the difference: a
|
|
138
145
|
narrower test command makes "no new failures" mean less.
|
|
@@ -152,6 +159,8 @@ projects:
|
|
|
152
159
|
build: npm run build
|
|
153
160
|
test: npx ng test --watch=false
|
|
154
161
|
testResults: ./frontend/test-results/junit.xml
|
|
162
|
+
lint: npx eslint e2e && npx tsc --noEmit -p e2e # optional lint / type-check
|
|
163
|
+
covers: ['src/**', 'e2e/**'] # what the commands check (default: everything under path)
|
|
155
164
|
methods: [isEnabled, watch] # flag evaluation methods
|
|
156
165
|
- name: api
|
|
157
166
|
adapter: dotnet
|
|
@@ -176,14 +185,32 @@ path. `build` and `test` are resolved one at a time:
|
|
|
176
185
|
| a command | that command |
|
|
177
186
|
| `false` | none, not even the default |
|
|
178
187
|
|
|
179
|
-
`testResults` (JUnit XML or
|
|
180
|
-
against the baseline
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
188
|
+
`testResults` (JUnit XML, TRX or Jest JSON; a path or a glob) lets `verify`
|
|
189
|
+
compare individual tests against the baseline. Only files written during
|
|
190
|
+
the run are read, and every matching file is merged, so a sharded run can
|
|
191
|
+
use `./test-results/*.xml`. The default .NET test command writes TRX to
|
|
192
|
+
`.flagrm/test-results/<project>/`. So does the default Angular test command,
|
|
193
|
+
when the workspace's runner can write results without setup: JUnit for
|
|
194
|
+
Angular's Vitest builder and Vitest, Jest JSON for Jest, and JUnit for Karma
|
|
195
|
+
once karma.conf configures `karma-junit-reporter`. A `test` command of your
|
|
196
|
+
own needs its own `testResults`, and `flagrm doctor` says what to add.
|
|
197
|
+
Without result files, `verify` reads failing test names and counts from the
|
|
198
|
+
test log and deleted tests from the diff, which is coarser: passing tests
|
|
199
|
+
aren't named. `timeout` (seconds) fails a project's build or test command
|
|
200
|
+
that runs longer, for example a test runner left in watch mode. `tools` lists
|
|
201
|
+
the AI coding tools whose files `init` and `update` manage (`claude-code`,
|
|
202
|
+
`copilot`, `codex`); dropping one leaves its files in place, and `init` names
|
|
203
|
+
them so you can delete them.
|
|
204
|
+
|
|
205
|
+
`lint` is an optional lint or type-check command (no adapter default) that
|
|
206
|
+
`baseline` and `verify` run like `build` and `test`; use it for code the build
|
|
207
|
+
and tests don't reach, such as end-to-end specs. A lint that already fails at
|
|
208
|
+
the baseline only warns, unless the removal adds error lines. flagrm doesn't
|
|
209
|
+
check formatting: put a formatter's check (`npx prettier --check .`) in
|
|
210
|
+
`lint` if you want it. `covers` lists the files (globs relative to `path`) the
|
|
211
|
+
project's commands check; the `coverage` check warns about a changed file no
|
|
212
|
+
project with a command covers. Without `covers`, a project covers everything
|
|
213
|
+
under its path.
|
|
187
214
|
|
|
188
215
|
Paths matching `exclude` (relative to the config file or a project's path; a
|
|
189
216
|
directory excludes everything under it) and flagrm's own setup files don't
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The files that share a component's scope: its `templateUrl` template. Parsed, never type-checked. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
export function componentScopeFiles(file) {
|
|
6
|
+
if (!file.endsWith(".ts"))
|
|
7
|
+
return [];
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = fs.readFileSync(file, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
if (!text.includes("templateUrl"))
|
|
16
|
+
return [];
|
|
17
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const statement of sf.statements) {
|
|
20
|
+
if (!ts.isClassDeclaration(statement))
|
|
21
|
+
continue;
|
|
22
|
+
for (const decorator of ts.getDecorators(statement) ?? []) {
|
|
23
|
+
const call = decorator.expression;
|
|
24
|
+
if (!ts.isCallExpression(call) || call.expression.getText(sf) !== "Component")
|
|
25
|
+
continue;
|
|
26
|
+
const options = call.arguments[0];
|
|
27
|
+
if (!options || !ts.isObjectLiteralExpression(options))
|
|
28
|
+
continue;
|
|
29
|
+
for (const prop of options.properties) {
|
|
30
|
+
if (!ts.isPropertyAssignment(prop) || prop.name.getText(sf) !== "templateUrl")
|
|
31
|
+
continue;
|
|
32
|
+
if (!ts.isStringLiteralLike(prop.initializer))
|
|
33
|
+
continue;
|
|
34
|
+
const template = path.resolve(path.dirname(file), prop.initializer.text);
|
|
35
|
+
if (fs.existsSync(template))
|
|
36
|
+
out.push(template);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a flag's value goes in TypeScript code: renamed imports of a flag
|
|
3
|
+
* registry (`import { FeatureFlags as AppFeatureFlags }`), so `AppFeatureFlags.X`
|
|
4
|
+
* is checked like `FeatureFlags.X`, and the fields, getters, methods and
|
|
5
|
+
* locals holding an evaluation of the flag (`holders.ts`), and a child
|
|
6
|
+
* component's input a template binds one of them to (`inputs.ts`). Each file
|
|
7
|
+
* is parsed at most once, never type-checked.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import ts from "typescript";
|
|
12
|
+
import { isTestPath } from "../../core/scan.js";
|
|
13
|
+
import { collectSites, deriveHolders, evaluationMatcher, skipParens } from "./holders.js";
|
|
14
|
+
import { childInputs } from "./inputs.js";
|
|
15
|
+
/** A renamed named import: `import { A as B }`, `import type { A as B }`, `import X, { A as B }`. */
|
|
16
|
+
const RENAMED_IMPORT = /\bimport\s*(?:type\s+)?(?:[\w$]+\s*,\s*)?\{[^}]*\bas\s/;
|
|
17
|
+
export function angularFlow(ctx, flags, evalMethods) {
|
|
18
|
+
const files = parseFiles(ctx, evalMethods);
|
|
19
|
+
const names = files.flatMap((f) => importAliases(ctx, f, flags));
|
|
20
|
+
if (evalMethods.length === 0)
|
|
21
|
+
return { names, tests: [] };
|
|
22
|
+
const code = files.filter((f) => !f.isTest);
|
|
23
|
+
const evaluates = evaluationMatcher(evalMethods, flagOfArgument(flags, names));
|
|
24
|
+
const sites = collectSites(code);
|
|
25
|
+
let holders = deriveHolders(sites, evaluates);
|
|
26
|
+
// One hop: a child's inputs bound to a holder, then what the child derives from them.
|
|
27
|
+
const inputs = childInputs(code, holders);
|
|
28
|
+
if (inputs.length > 0)
|
|
29
|
+
holders = deriveHolders(sites, evaluates, inputs);
|
|
30
|
+
for (const h of holders) {
|
|
31
|
+
if (evalMethods.includes(h.name))
|
|
32
|
+
continue;
|
|
33
|
+
const same = (n) => n.flag === h.flag && n.name === h.name && n.kind === h.kind && n.file === h.file;
|
|
34
|
+
if (names.some(same))
|
|
35
|
+
continue;
|
|
36
|
+
names.push({
|
|
37
|
+
flag: h.flag,
|
|
38
|
+
name: h.name,
|
|
39
|
+
kind: h.kind,
|
|
40
|
+
...(h.ambiguous ? { ambiguous: true } : {}),
|
|
41
|
+
project: ctx.name,
|
|
42
|
+
file: h.file,
|
|
43
|
+
line: h.line,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { names, tests: [] };
|
|
47
|
+
}
|
|
48
|
+
/** The `.ts` files any pass needs, each read and parsed once. */
|
|
49
|
+
function parseFiles(ctx, evalMethods) {
|
|
50
|
+
const files = [];
|
|
51
|
+
for (const file of ctx.files) {
|
|
52
|
+
if (!file.endsWith(".ts"))
|
|
53
|
+
continue;
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = fs.readFileSync(file, "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const wanted = RENAMED_IMPORT.test(text) ||
|
|
62
|
+
(evalMethods.length > 0 && (text.includes("@Component") || evalMethods.some((m) => text.includes(m))));
|
|
63
|
+
if (!wanted)
|
|
64
|
+
continue;
|
|
65
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
66
|
+
files.push({ file, text, sf, isTest: isTestPath(path.relative(ctx.root, file)) });
|
|
67
|
+
}
|
|
68
|
+
return files;
|
|
69
|
+
}
|
|
70
|
+
/** The flag names a renamed import in `f` gives: `AppFeatureFlags.X` for `FeatureFlags.X`. */
|
|
71
|
+
function importAliases(ctx, f, flags) {
|
|
72
|
+
const names = [];
|
|
73
|
+
for (const statement of f.sf.statements) {
|
|
74
|
+
const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : undefined;
|
|
75
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
76
|
+
continue;
|
|
77
|
+
for (const element of bindings.elements) {
|
|
78
|
+
if (!element.propertyName)
|
|
79
|
+
continue;
|
|
80
|
+
const original = element.propertyName.text;
|
|
81
|
+
const renamed = element.name.text;
|
|
82
|
+
const line = f.sf.getLineAndCharacterOfPosition(element.getStart(f.sf)).line + 1;
|
|
83
|
+
for (const { flag, aliases } of flags) {
|
|
84
|
+
for (const alias of aliases) {
|
|
85
|
+
if (!alias.startsWith(`${original}.`))
|
|
86
|
+
continue;
|
|
87
|
+
const name = `${renamed}${alias.slice(original.length)}`;
|
|
88
|
+
names.push({ flag, name, kind: "alias", project: ctx.name, file: f.file, line });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return names;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The flag an eval call's first argument names: its string literal, one of
|
|
97
|
+
* its qualified names (`FeatureFlags.X`), or an import alias of one declared
|
|
98
|
+
* in the same file (`AppFeatureFlags.X`).
|
|
99
|
+
*/
|
|
100
|
+
function flagOfArgument(flags, aliases) {
|
|
101
|
+
const literals = new Set(flags.map((f) => f.flag));
|
|
102
|
+
const qualified = new Map();
|
|
103
|
+
for (const { flag, aliases: names } of flags) {
|
|
104
|
+
for (const name of names)
|
|
105
|
+
if (!qualified.has(name))
|
|
106
|
+
qualified.set(name, flag);
|
|
107
|
+
}
|
|
108
|
+
const imported = new Map();
|
|
109
|
+
for (const a of aliases) {
|
|
110
|
+
const inFile = imported.get(a.file) ?? new Map();
|
|
111
|
+
inFile.set(a.name, a.flag);
|
|
112
|
+
imported.set(a.file, inFile);
|
|
113
|
+
}
|
|
114
|
+
return (file, arg) => {
|
|
115
|
+
const value = skipParens(arg);
|
|
116
|
+
if (ts.isStringLiteralLike(value))
|
|
117
|
+
return literals.has(value.text) ? value.text : undefined;
|
|
118
|
+
const text = value.getText(file.sf).replace(/\s+/g, "");
|
|
119
|
+
return qualified.get(text) ?? imported.get(file.file)?.get(text);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names a flag's value is held in: fields, getters, parameterless
|
|
3
|
+
* methods and locals whose value is an evaluation of the flag (or its
|
|
4
|
+
* negation), and those whose value is such a name of the same class or
|
|
5
|
+
* function. A getter or method returning a ternary on an evaluation is
|
|
6
|
+
* recorded but not followed: it holds the ternary's value, not the flag's.
|
|
7
|
+
* Parsed, never type-checked.
|
|
8
|
+
*/
|
|
9
|
+
import ts from "typescript";
|
|
10
|
+
export function skipParens(expr) {
|
|
11
|
+
let e = expr;
|
|
12
|
+
while (ts.isParenthesizedExpression(e))
|
|
13
|
+
e = e.expression;
|
|
14
|
+
return e;
|
|
15
|
+
}
|
|
16
|
+
/** `expr` without parentheses and leading `!`s. */
|
|
17
|
+
export function skipNot(expr) {
|
|
18
|
+
let e = expr;
|
|
19
|
+
for (;;) {
|
|
20
|
+
if (ts.isParenthesizedExpression(e))
|
|
21
|
+
e = e.expression;
|
|
22
|
+
else if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken)
|
|
23
|
+
e = e.operand;
|
|
24
|
+
else
|
|
25
|
+
return e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* An evaluation: a call of a configured eval method, by the last name of its
|
|
30
|
+
* callee (`a.b.isFeatureFlagEnabled`), whose first argument names the flag;
|
|
31
|
+
* parentheses and `!` around it are allowed.
|
|
32
|
+
*/
|
|
33
|
+
export function evaluationMatcher(evalMethods, flagOfArgument) {
|
|
34
|
+
return (file, expr) => {
|
|
35
|
+
const e = skipNot(expr);
|
|
36
|
+
if (!ts.isCallExpression(e) || e.arguments.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
const callee = skipParens(e.expression);
|
|
39
|
+
const name = ts.isPropertyAccessExpression(callee)
|
|
40
|
+
? callee.name.text
|
|
41
|
+
: ts.isIdentifier(callee)
|
|
42
|
+
? callee.text
|
|
43
|
+
: undefined;
|
|
44
|
+
if (!name || !evalMethods.includes(name))
|
|
45
|
+
return undefined;
|
|
46
|
+
return flagOfArgument(file, e.arguments[0]);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every place a holder can be declared: a property with an initializer, a
|
|
51
|
+
* `this.x = …` statement, a getter or parameterless method whose body is a
|
|
52
|
+
* single `return`, a `const`/`let` in a function.
|
|
53
|
+
*/
|
|
54
|
+
export function collectSites(files) {
|
|
55
|
+
const sites = [];
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const { sf } = file;
|
|
58
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
59
|
+
const visit = (node, cls, fn) => {
|
|
60
|
+
if (cls && ts.isPropertyDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
61
|
+
sites.push({
|
|
62
|
+
file,
|
|
63
|
+
name: node.name.text,
|
|
64
|
+
kind: "field",
|
|
65
|
+
line: lineOf(node.name),
|
|
66
|
+
expr: node.initializer,
|
|
67
|
+
ternary: false,
|
|
68
|
+
cls,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else if (cls &&
|
|
72
|
+
ts.isExpressionStatement(node) &&
|
|
73
|
+
ts.isBinaryExpression(node.expression) &&
|
|
74
|
+
node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
75
|
+
ts.isPropertyAccessExpression(node.expression.left) &&
|
|
76
|
+
node.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
77
|
+
ts.isIdentifier(node.expression.left.name)) {
|
|
78
|
+
const { left, right } = node.expression;
|
|
79
|
+
sites.push({ file, name: left.name.text, kind: "field", line: lineOf(node), expr: right, ternary: false, cls });
|
|
80
|
+
}
|
|
81
|
+
else if (cls &&
|
|
82
|
+
(ts.isGetAccessorDeclaration(node) || (ts.isMethodDeclaration(node) && node.parameters.length === 0)) &&
|
|
83
|
+
ts.isIdentifier(node.name) &&
|
|
84
|
+
node.body?.statements.length === 1) {
|
|
85
|
+
const only = node.body.statements[0];
|
|
86
|
+
if (ts.isReturnStatement(only) && only.expression) {
|
|
87
|
+
const kind = ts.isGetAccessorDeclaration(node) ? "property" : "method";
|
|
88
|
+
const value = skipParens(only.expression);
|
|
89
|
+
const base = { file, name: node.name.text, kind, line: lineOf(node.name), cls };
|
|
90
|
+
if (ts.isConditionalExpression(value))
|
|
91
|
+
sites.push({ ...base, expr: value.condition, ternary: true });
|
|
92
|
+
else
|
|
93
|
+
sites.push({ ...base, expr: only.expression, ternary: false });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (fn &&
|
|
97
|
+
ts.isVariableDeclaration(node) &&
|
|
98
|
+
ts.isIdentifier(node.name) &&
|
|
99
|
+
node.initializer &&
|
|
100
|
+
ts.isVariableDeclarationList(node.parent) &&
|
|
101
|
+
node.parent.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) {
|
|
102
|
+
sites.push({
|
|
103
|
+
file,
|
|
104
|
+
name: node.name.text,
|
|
105
|
+
kind: "local",
|
|
106
|
+
line: lineOf(node.name),
|
|
107
|
+
expr: node.initializer,
|
|
108
|
+
ternary: false,
|
|
109
|
+
cls,
|
|
110
|
+
fn,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const nextCls = ts.isClassLike(node) ? node : cls;
|
|
114
|
+
const nextFn = ts.isFunctionLike(node) ? node : fn;
|
|
115
|
+
ts.forEachChild(node, (child) => visit(child, nextCls, nextFn));
|
|
116
|
+
};
|
|
117
|
+
visit(sf, undefined, undefined);
|
|
118
|
+
}
|
|
119
|
+
return sites;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The holders among `sites`, repeated until nothing new: a site whose value
|
|
123
|
+
* is an evaluation, or a read of a holder of its class (`this.x`, `!this.x`,
|
|
124
|
+
* `this.x()`) or, for a local, of a local of its function (`x`, `!x`, `x()`).
|
|
125
|
+
* `seeds` (a child component's inputs) are holders from the start.
|
|
126
|
+
*/
|
|
127
|
+
export function deriveHolders(sites, evaluates, seeds = []) {
|
|
128
|
+
const holders = [];
|
|
129
|
+
const members = new Map();
|
|
130
|
+
const locals = new Map();
|
|
131
|
+
const index = (scopes, scope, name, flag) => {
|
|
132
|
+
const names = scopes.get(scope) ?? new Map();
|
|
133
|
+
scopes.set(scope, names);
|
|
134
|
+
const flags = names.get(name) ?? new Set();
|
|
135
|
+
names.set(name, flags);
|
|
136
|
+
flags.add(flag);
|
|
137
|
+
};
|
|
138
|
+
const add = (h) => {
|
|
139
|
+
const same = (o) => o.flag === h.flag && o.name === h.name && o.kind === h.kind && o.cls === h.cls && o.fn === h.fn;
|
|
140
|
+
if (holders.some(same))
|
|
141
|
+
return false;
|
|
142
|
+
holders.push(h);
|
|
143
|
+
if (h.ternary || h.ambiguous)
|
|
144
|
+
return true;
|
|
145
|
+
if (h.kind === "local") {
|
|
146
|
+
if (h.fn)
|
|
147
|
+
index(locals, h.fn, h.name, h.flag);
|
|
148
|
+
}
|
|
149
|
+
else if (h.cls) {
|
|
150
|
+
index(members, h.cls, h.name, h.flag);
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
const flagsOf = (site) => {
|
|
155
|
+
const flag = evaluates(site.file, site.expr);
|
|
156
|
+
if (flag)
|
|
157
|
+
return [flag];
|
|
158
|
+
if (site.ternary)
|
|
159
|
+
return [];
|
|
160
|
+
let read = skipNot(site.expr);
|
|
161
|
+
if (ts.isCallExpression(read) && read.arguments.length === 0)
|
|
162
|
+
read = skipParens(read.expression);
|
|
163
|
+
if (ts.isPropertyAccessExpression(read) && read.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
164
|
+
return [...((site.cls && members.get(site.cls)?.get(read.name.text)) ?? [])];
|
|
165
|
+
}
|
|
166
|
+
if (ts.isIdentifier(read))
|
|
167
|
+
return [...((site.fn && locals.get(site.fn)?.get(read.text)) ?? [])];
|
|
168
|
+
return [];
|
|
169
|
+
};
|
|
170
|
+
for (const seed of seeds)
|
|
171
|
+
add(seed);
|
|
172
|
+
for (let changed = true; changed;) {
|
|
173
|
+
changed = false;
|
|
174
|
+
for (const site of sites) {
|
|
175
|
+
for (const flag of flagsOf(site)) {
|
|
176
|
+
const holder = {
|
|
177
|
+
flag,
|
|
178
|
+
name: site.name,
|
|
179
|
+
kind: site.kind,
|
|
180
|
+
file: site.file.file,
|
|
181
|
+
line: site.line,
|
|
182
|
+
cls: site.cls,
|
|
183
|
+
};
|
|
184
|
+
if (site.kind === "local")
|
|
185
|
+
holder.fn = site.fn;
|
|
186
|
+
if (site.ternary)
|
|
187
|
+
holder.ternary = true;
|
|
188
|
+
if (add(holder))
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return holders;
|
|
194
|
+
}
|