flagrm 0.3.0 → 0.4.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 CHANGED
@@ -7,6 +7,88 @@ contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - `testResults` reads Jest JSON (`jest --json`, Vitest's `json` reporter); a
15
+ result file that doesn't parse, or an XML file that isn't complete, is skipped.
16
+ - Without `testResults`, `verify` reads the changed test files to list
17
+ deleted and renamed tests, and warns when the log's test count dropped by
18
+ more than the diff deletes.
19
+ - Without `testResults`, `verify` reads failing test names and counts from
20
+ the test command's log (Karma, Jest, Vitest, `dotnet test`; Go and pytest
21
+ names), so a test that already failed at the baseline is a known failure
22
+ there too.
23
+ - `doctor`'s warning about a test command without `testResults` says how
24
+ to get results for the detected runner (Vitest, Jest, Karma, `dotnet test`).
25
+ - An optional per-project `lint` command (lint or type-check), run by
26
+ `baseline`, `verify` and `doctor --run`. `verify`'s new `lint` check fails
27
+ on lint errors the removal caused; a lint that already failed at the
28
+ baseline only warns, unless its log has error lines the baseline's didn't
29
+ (line and column numbers ignored).
30
+ - `verify`'s new `coverage` check warns about changed files that no build,
31
+ test or lint command checks; a project's optional `covers` globs say which
32
+ files its commands check (default: everything under its path). Files
33
+ already dirty at the baseline and unchanged since don't count, nor do
34
+ documentation and deleted files outside every project. With
35
+ `--strict`, such a file now fails verify.
36
+ - `/flagrm-remove` takes several flags (`/flagrm-remove A B C`): it asks
37
+ once whether they are all ON and whether to commit each one, then removes
38
+ them one after another, one commit per flag.
39
+ - `/flagrm-remove` ends with the verify overview and a proposed commit
40
+ message, so `/flagrm-verify` is only needed to check a removal later. The
41
+ commit message takes the ticket from the branch name and wraps its body.
42
+ - A passing full verify saves its build and test results; `baseline` and
43
+ `verify` on the same tree with the same commands reuse them instead of
44
+ running them again, and say so. Removing flags one after another, each
45
+ next baseline takes seconds, and so does `/flagrm-verify` after a
46
+ removal. `--no-reuse` runs them anyway.
47
+ - Angular: a renamed import of a flag registry
48
+ (`import { FeatureFlags as AppFeatureFlags }`) is followed, so
49
+ `AppFeatureFlags.X` is recorded and checked like `FeatureFlags.X`.
50
+ - Angular: a name checked only in a component's `.ts` file is checked in
51
+ its `templateUrl` template too, whether discovery or `--file` scoped it.
52
+ - Angular: `list` and `baseline` follow a flag's value through the fields,
53
+ getters, parameterless methods and locals holding an evaluation of it
54
+ (`isPaginationEnabled = this.ff.isFeatureFlagEnabled(…)`), names derived
55
+ from those, and one hop into the child component input a template binds
56
+ one of them to. Names that mention the flag are recorded; generic ones are
57
+ listed as `suggestedNames`.
58
+ - `baseline --file` repeats: `--name x --file a.ts --file b.ts` records the
59
+ name for every file in one call.
60
+ - `baseline --name` refuses a name whose code reads only other flags (a
61
+ helper named like this flag that evaluates a different one), since
62
+ `verify` would then ask to delete the other flag's code.
63
+
64
+ ### Changed
65
+
66
+ - **Breaking:** JSON schema version 8: `--json`, `baseline.json` and `verify.json` gain
67
+ the `lint` and `coverage` checks, `lint` runs and `git.dirtyHashes`.
68
+ Version 6 and 7 baselines are still read.
69
+ - The Angular adapter's default test command now writes test results when
70
+ the workspace's runner can without setup: JUnit for Angular's Vitest
71
+ builder (`ng test --reporters=junit --output-file`) and plain Vitest, Jest
72
+ JSON for Jest, and JUnit for Karma once karma.conf configures
73
+ `karma-junit-reporter`. Results go to `.flagrm/test-results/<project>/`.
74
+ A baseline taken before upgrading reports the test command as changed
75
+ once; `flagrm baseline <flag> --accept config` or a new baseline clears it.
76
+
77
+ ### Fixed
78
+
79
+ - A failed test run without test results showed only "exited with 1":
80
+ `verify` now lists the log's error lines, as for a failed build.
81
+ - A lint, build or test command that fails because its package script
82
+ (`npm run lint` without a `lint` script) or ESLint config is missing is a
83
+ setup problem: `verify` and `doctor --run` fail on it instead of treating
84
+ it as lint debt.
85
+ - Deleting a committed file that matches `exclude` (say, a generator removing
86
+ `graphify-out/graph.html`) no longer invalidates a passing verify: the
87
+ Stop hook's tree fingerprint now leaves out deletions under `exclude`, as
88
+ it already did for edits and new files.
89
+ - `/flagrm-remove` tells the agent not to run code generators or formatters
90
+ after the passing verify, or to verify again if it must.
91
+
10
92
  ## [0.3.0] - 2026-10-07
11
93
 
12
94
  ### Changed
@@ -189,6 +271,7 @@ removes the flag through the `flagrm-remove` skill, and flagrm checks the result
189
271
  `doctor` warns about an incremental `dotnet build`, which hides existing
190
272
  warnings from the dead-code comparison.
191
273
 
274
+ [0.4.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.4.0
192
275
  [0.3.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.3.0
193
276
  [0.2.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.2.1
194
277
  [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,29 @@ 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
107
  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.
108
+ 4. **`/flagrm-verify <flag>`** checks a removal later: runs
109
+ `flagrm verify <flag> --md` and adds notes and a commit message.
108
110
 
109
111
  ## Commands
110
112
 
111
113
  | Command | What it does | Exit codes |
112
114
  |---|---|---|
113
115
  | `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 |
116
+ | `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
117
  | `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
118
  | `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 |
119
+ | `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 |
120
+ | `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 |
121
+ | `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
122
  | `hook stop` | The Claude Code Stop hook installed by `init` | always 0 |
121
123
 
122
124
  `list`, `baseline` and `verify` accept `--json`, `--config <path>`, and
@@ -129,10 +131,12 @@ of these reasons is a warning, for the agent to explain in its notes.
129
131
  | `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
132
  | `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
133
  | `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 |
134
+ | `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) |
135
+ | `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 |
136
+ | `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
137
 
134
138
  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
139
+ resolved build, test and lint commands, the files they name (a `.slnf`, a
136
140
  `.runsettings`, a filter list) and the text of `flagrm.config.yaml`. When any
137
141
  of it changed since, the check it affects warns and shows the difference: a
138
142
  narrower test command makes "no new failures" mean less.
@@ -152,6 +156,8 @@ projects:
152
156
  build: npm run build
153
157
  test: npx ng test --watch=false
154
158
  testResults: ./frontend/test-results/junit.xml
159
+ lint: npx eslint e2e && npx tsc --noEmit -p e2e # optional lint / type-check
160
+ covers: ['src/**', 'e2e/**'] # what the commands check (default: everything under path)
155
161
  methods: [isEnabled, watch] # flag evaluation methods
156
162
  - name: api
157
163
  adapter: dotnet
@@ -176,14 +182,32 @@ path. `build` and `test` are resolved one at a time:
176
182
  | a command | that command |
177
183
  | `false` | none, not even the default |
178
184
 
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.
185
+ `testResults` (JUnit XML, TRX or Jest JSON; a path or a glob) lets `verify`
186
+ compare individual tests against the baseline. Only files written during
187
+ the run are read, and every matching file is merged, so a sharded run can
188
+ use `./test-results/*.xml`. The default .NET test command writes TRX to
189
+ `.flagrm/test-results/<project>/`. So does the default Angular test command,
190
+ when the workspace's runner can write results without setup: JUnit for
191
+ Angular's Vitest builder and Vitest, Jest JSON for Jest, and JUnit for Karma
192
+ once karma.conf configures `karma-junit-reporter`. A `test` command of your
193
+ own needs its own `testResults`, and `flagrm doctor` says what to add.
194
+ Without result files, `verify` reads failing test names and counts from the
195
+ test log and deleted tests from the diff, which is coarser: passing tests
196
+ aren't named. `timeout` (seconds) fails a project's build or test command
197
+ that runs longer, for example a test runner left in watch mode. `tools` lists
198
+ the AI coding tools whose files `init` and `update` manage (`claude-code`,
199
+ `copilot`, `codex`); dropping one leaves its files in place, and `init` names
200
+ them so you can delete them.
201
+
202
+ `lint` is an optional lint or type-check command (no adapter default) that
203
+ `baseline` and `verify` run like `build` and `test`; use it for code the build
204
+ and tests don't reach, such as end-to-end specs. A lint that already fails at
205
+ the baseline only warns, unless the removal adds error lines. flagrm doesn't
206
+ check formatting: put a formatter's check (`npx prettier --check .`) in
207
+ `lint` if you want it. `covers` lists the files (globs relative to `path`) the
208
+ project's commands check; the `coverage` check warns about a changed file no
209
+ project with a command covers. Without `covers`, a project covers everything
210
+ under its path.
187
211
 
188
212
  Paths matching `exclude` (relative to the config file or a project's path; a
189
213
  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
+ }
@@ -1,7 +1,11 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { discoverInText } from "../../core/discover.js";
3
4
  import { FRONTEND_GLOBS } from "../../core/scan.js";
5
+ import { detectTestRunner, runnerTestCommands } from "../../core/test-runner.js";
6
+ import { componentScopeFiles } from "./component-files.js";
4
7
  import { discoverTypeScript } from "./discover.js";
8
+ import { angularFlow } from "./flow.js";
5
9
  import { angularUnusedDiagnostics } from "./unused.js";
6
10
  /**
7
11
  * Discover candidate flag names: string literals passed to configured eval
@@ -25,10 +29,25 @@ export function discoverAngularFlags(ctx) {
25
29
  }
26
30
  return out;
27
31
  }
32
+ /** The runner per project directory: projectCommands asks for the default commands many times per command. */
33
+ const runners = new Map();
34
+ function testRunner(root) {
35
+ let runner = runners.get(root);
36
+ if (!runner)
37
+ runners.set(root, (runner = detectTestRunner(root)));
38
+ return runner;
39
+ }
28
40
  export const angularAdapter = {
29
41
  id: "angular",
30
42
  defaultGlobs: FRONTEND_GLOBS,
31
43
  discover: async (ctx) => discoverAngularFlags(ctx),
32
- defaultCommands: () => ({ build: "npx ng build", test: "npx ng test --watch=false" }),
44
+ flow: (ctx, flags) => angularFlow(ctx, flags, ctx.project.methods),
45
+ scopeFiles: (_ctx, file) => componentScopeFiles(file),
46
+ // Like the .NET adapter: when the runner can write results without setup, the default test command does, under .flagrm/.
47
+ defaultCommands: (ctx) => {
48
+ const dir = path.join(ctx.config.root, ".flagrm", "test-results", ctx.name);
49
+ const tests = runnerTestCommands(testRunner(ctx.root), ctx.root, dir);
50
+ return { build: "npx ng build", ...(tests ?? { test: "npx ng test --watch=false" }) };
51
+ },
33
52
  unusedDiagnostics: (_ctx, input) => angularUnusedDiagnostics(input),
34
53
  };