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.
Files changed (38) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +56 -29
  3. package/dist/adapters/angular/component-files.js +41 -0
  4. package/dist/adapters/angular/flow.js +121 -0
  5. package/dist/adapters/angular/holders.js +194 -0
  6. package/dist/adapters/angular/index.js +20 -1
  7. package/dist/adapters/angular/inputs.js +181 -0
  8. package/dist/cli.js +41 -21
  9. package/dist/core/baseline.js +60 -31
  10. package/dist/core/change-filter.js +3 -2
  11. package/dist/core/config-snapshot.js +26 -3
  12. package/dist/core/config.js +5 -1
  13. package/dist/core/detect.js +4 -4
  14. package/dist/core/doctor.js +26 -6
  15. package/dist/core/environment.js +25 -3
  16. package/dist/core/git.js +50 -3
  17. package/dist/core/hook.js +13 -2
  18. package/dist/core/install.js +26 -11
  19. package/dist/core/inventory.js +3 -1
  20. package/dist/core/markdown.js +4 -0
  21. package/dist/core/name-owner.js +179 -0
  22. package/dist/core/report.js +7 -0
  23. package/dist/core/run-cache.js +147 -0
  24. package/dist/core/scan.js +30 -3
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +3 -3
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +160 -22
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/index.js +32 -5
  32. package/dist/core/verify/log-results.js +127 -0
  33. package/dist/core/verify/removed-tests.js +90 -7
  34. package/dist/core/verify/test-results.js +58 -10
  35. package/package.json +4 -4
  36. package/skills/flagrm-remove/SKILL.md +28 -8
  37. package/skills/flagrm-remove/batch.md +38 -0
  38. 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 unused
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.3. Until 1.0, a minor release may contain
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
- When the agent reports back, review the result:
52
-
53
- ```
54
- /flagrm-verify NewCheckout
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), or the last verify used `--skip`.
106
- 4. **`/flagrm-verify <flag>`** runs `flagrm verify <flag> --md` and adds notes
107
- and a commit message.
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 test commands (marking adapter defaults; `--run` runs them and fails when tests write no readable `testResults`), the .NET SDK `global.json` asks for, `cargo` and Docker where projects need them, git, `.gitignore`, installed skills and hook, and abandoned baselines | 0 ok, 1 problems |
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 test results and the flag's names in `.flagrm/<flag>/baseline.json`. In .NET code the names include the locals, fields, parameters and methods the flag's value travels through when they name the flag (a local, parameter or field is checked only in its own file); generic or ambiguous ones are listed as `suggestedNames`, with `parameterizedTests` that pass the value as a literal. `--file` limits `--name` entries to one file (and records them as wrappers). `--name X --kind alias\|wrapper` adds names to an existing baseline; `--json` prints a summary (test counts, at most 20 failed test names; the file keeps everything); `--force` starts over, but only while the code is unedited since the baseline commit; `--accept config,failures` acknowledges, also mid-removal, a config change or the baseline's failing tests so verify stops warning about them; `--no-checks` skips build and tests | 0, 2 usage error |
119
- | `verify <flag>` | Gate the removal with `leftovers`, `dead-code`, `build` and `tests`. Writes `.flagrm/<flag>/verify.json`. `--md` prints the overview, `--json` the full result, `--skip` skips checks, `--strict` fails on warnings | 0 pass, 1 fail, 2 usage error |
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. With `testResults`, tests that already failed at the baseline are known failures and only warn. A test that no longer runs though the diff neither deletes nor renames it and the test config is unchanged, and a changed test file none of whose tests ran, are warnings for the agent to account for |
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 test commands, the files they name (a `.slnf`, a
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 TRX) lets `verify` compare individual tests
180
- against the baseline, so a test that already failed before the removal only
181
- warns. Only files written during the run are read. The default .NET test
182
- command writes TRX to `.flagrm/test-results/<project>/` and reads it; a `test`
183
- command of your own needs its own `testResults`. `timeout` (seconds) fails a project's build or test command that runs longer, for example
184
- a test runner left in watch mode. `tools` lists the AI coding tools whose files
185
- `init` and `update` manage (`claude-code`, `copilot`, `codex`); dropping one
186
- leaves its files in place, and `init` names them so you can delete them.
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
+ }