flagrm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -0
  4. package/dist/adapters/angular/discover.js +100 -0
  5. package/dist/adapters/angular/index.js +34 -0
  6. package/dist/adapters/angular/unused.js +74 -0
  7. package/dist/adapters/dotnet/index.js +29 -0
  8. package/dist/adapters/dotnet/unused.js +43 -0
  9. package/dist/adapters/generic/discover.js +84 -0
  10. package/dist/adapters/generic/index.js +50 -0
  11. package/dist/adapters/generic/text.js +93 -0
  12. package/dist/cli.js +216 -0
  13. package/dist/core/adapter.js +18 -0
  14. package/dist/core/baseline.js +255 -0
  15. package/dist/core/config.js +254 -0
  16. package/dist/core/detect.js +64 -0
  17. package/dist/core/discover.js +310 -0
  18. package/dist/core/doctor.js +141 -0
  19. package/dist/core/git.js +101 -0
  20. package/dist/core/hook.js +83 -0
  21. package/dist/core/install.js +257 -0
  22. package/dist/core/inventory.js +118 -0
  23. package/dist/core/markdown.js +55 -0
  24. package/dist/core/mentions.js +153 -0
  25. package/dist/core/package.js +17 -0
  26. package/dist/core/registry.js +29 -0
  27. package/dist/core/report.js +135 -0
  28. package/dist/core/scan.js +201 -0
  29. package/dist/core/source-text.js +36 -0
  30. package/dist/core/text-utils.js +27 -0
  31. package/dist/core/types.js +22 -0
  32. package/dist/core/util.js +18 -0
  33. package/dist/core/verify/commands.js +166 -0
  34. package/dist/core/verify/context.js +13 -0
  35. package/dist/core/verify/dead-code.js +92 -0
  36. package/dist/core/verify/index.js +84 -0
  37. package/dist/core/verify/leftovers.js +74 -0
  38. package/dist/core/verify/test-results.js +153 -0
  39. package/package.json +66 -0
  40. package/skills/flagrm-remove/SKILL.md +88 -0
  41. package/skills/flagrm-remove/patterns.md +96 -0
  42. package/skills/flagrm-verify/SKILL.md +48 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The `leftovers` check: no recorded name of the flag (`baseline.names`) is
3
+ * left anywhere in the projects. Code and config fail; comments warn.
4
+ */
5
+ import { FEATURE_MANAGEMENT_SECTION } from "../discover.js";
6
+ import { discoverCandidates } from "../inventory.js";
7
+ import { findIdentifiers, findMentions } from "../mentions.js";
8
+ import { checkResult, plural } from "./context.js";
9
+ const STILL = {
10
+ config: "still configured",
11
+ attribute: "still gated by attribute",
12
+ eval: "still evaluated",
13
+ constant: "still defined",
14
+ "object-key": "still defined",
15
+ };
16
+ export async function leftoversCheck(v) {
17
+ const byKind = (kind) => v.baseline.names.filter((n) => n.kind === kind).map((n) => n.name);
18
+ const literals = byKind("literal");
19
+ const contexts = v.projects.map((p) => p.ctx);
20
+ const findings = [];
21
+ const seen = new Set();
22
+ const add = (f) => {
23
+ const key = `${f.file}:${f.line}:${f.severity}`;
24
+ if (seen.has(key))
25
+ return;
26
+ seen.add(key);
27
+ findings.push(f);
28
+ };
29
+ for (const m of findMentions(contexts, literals, byKind("alias"), { comments: true })) {
30
+ add({
31
+ severity: m.inComment ? "warn" : "fail",
32
+ project: m.project,
33
+ file: m.file,
34
+ line: m.line,
35
+ message: `${m.inComment ? "comment mentions" : "still references"} ${m.token}`,
36
+ });
37
+ }
38
+ const hits = findIdentifiers(contexts, [...byKind("wrapper"), ...literals],
39
+ // `FeatureManagement__Flag=true` configures the flag through an environment variable (.env, docker-compose);
40
+ // `GetValue<bool>("FeatureManagement:Flag")` reads it by its section-qualified key.
41
+ literals.flatMap((name) => [`${FEATURE_MANAGEMENT_SECTION}__${name}`, `${FEATURE_MANAGEMENT_SECTION}:${name}`]));
42
+ for (const hit of hits) {
43
+ const isLiteral = literals.includes(hit.token);
44
+ // A bare literal name in code is an identifier that merely shares the name; only its comments and
45
+ // config keys (`new-checkout: true`) count.
46
+ if (isLiteral && !hit.inComment && !hit.isConfigKey)
47
+ continue;
48
+ add({
49
+ severity: hit.inComment ? "warn" : "fail",
50
+ project: hit.project,
51
+ file: hit.file,
52
+ line: hit.line,
53
+ message: `${hit.inComment ? "comment mentions" : "still references"} ${hit.token}`,
54
+ });
55
+ }
56
+ // Discovery only: the inventory's reference counts would scan every file again for every flag.
57
+ for (const c of (await discoverCandidates(v.projects)).filter((c) => c.flag === v.flag)) {
58
+ add({
59
+ severity: "fail",
60
+ project: c.project,
61
+ file: c.file,
62
+ line: c.line,
63
+ message: `${STILL[c.source]} ${c.symbol ?? c.flag}`,
64
+ });
65
+ }
66
+ const fails = findings.filter((f) => f.severity === "fail").length;
67
+ const warns = findings.length - fails;
68
+ const summary = findings.length === 0
69
+ ? "no recorded name left in code or config"
70
+ : [fails && `${plural(fails, "reference")} left`, warns && `${plural(warns, "comment")} naming the flag`]
71
+ .filter(Boolean)
72
+ .join(", ");
73
+ return checkResult("leftovers", summary, findings);
74
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Read test counts and names from the result files a test command writes:
3
+ * TRX (`dotnet test --logger trx`) and JUnit XML (Karma, Jest, Vitest, NUnit
4
+ * and most CI reporters). Both are parsed with regexes over tags and
5
+ * attributes, which is enough for these machine-written formats.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import fg from "fast-glob";
10
+ function attributes(tag) {
11
+ const out = {};
12
+ for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
13
+ out[m[1]] = decode(m[2] ?? m[3]);
14
+ return out;
15
+ }
16
+ function decode(text) {
17
+ return text
18
+ .replace(/&lt;/g, "<")
19
+ .replace(/&gt;/g, ">")
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&apos;/g, "'")
22
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
23
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(Number.parseInt(n, 16)))
24
+ .replace(/&amp;/g, "&");
25
+ }
26
+ function empty() {
27
+ return { total: 0, passed: 0, failed: 0, skipped: 0, names: [], failedNames: [], files: [] };
28
+ }
29
+ function record(results, name, outcome) {
30
+ results.total++;
31
+ results[outcome]++;
32
+ results.names.push(name);
33
+ if (outcome === "failed")
34
+ results.failedNames.push(name);
35
+ }
36
+ /** One `<UnitTestResult>` per executed test or data row. */
37
+ export function parseTrx(xml) {
38
+ const results = empty();
39
+ for (const m of xml.matchAll(/<UnitTestResult\b[^>]*>/g)) {
40
+ const attrs = attributes(m[0]);
41
+ const outcome = (attrs.outcome ?? "").toLowerCase();
42
+ const kind = outcome === "passed" ? "passed" : outcome === "failed" || outcome === "error" ? "failed" : "skipped";
43
+ record(results, attrs.testName ?? "", kind);
44
+ }
45
+ return results;
46
+ }
47
+ /** Joins a JUnit `classname` and test `name` (Jest's separator; test titles may contain dots). */
48
+ export const JUNIT_SEPARATOR = " › ";
49
+ /**
50
+ * A test name short enough to read in an overview. TRX names lose their
51
+ * namespace and data-row arguments (`Ns.JobTests.Run(sutProvider: …)` becomes
52
+ * `JobTests.Run(…)`); a JUnit name whose class repeats the title (Jest's
53
+ * default) is shown once. Names are only shortened for display, never compared.
54
+ */
55
+ export function shortTestName(name) {
56
+ const sep = name.indexOf(JUNIT_SEPARATOR);
57
+ if (sep >= 0) {
58
+ const cls = name.slice(0, sep);
59
+ const title = name.slice(sep + JUNIT_SEPARATOR.length);
60
+ if (cls.endsWith(title))
61
+ return cls;
62
+ if (title.startsWith(cls))
63
+ return title;
64
+ return name;
65
+ }
66
+ const paren = name.indexOf("(");
67
+ const qualified = paren >= 0 ? name.slice(0, paren) : name;
68
+ return qualified.split(".").slice(-2).join(".") + (paren >= 0 ? "(…)" : "");
69
+ }
70
+ /** Short names, each once, with a count when data rows collapse into one (`Run(…) ×3`). */
71
+ export function shortTestNames(names) {
72
+ const counts = new Map();
73
+ for (const name of names) {
74
+ const short = shortTestName(name);
75
+ counts.set(short, (counts.get(short) ?? 0) + 1);
76
+ }
77
+ return [...counts].map(([short, n]) => (n > 1 ? `${short} ×${n}` : short));
78
+ }
79
+ /**
80
+ * The name a test is compared by: a TRX data row without its arguments, since
81
+ * AutoFixture-style attributes put random values in them on every run. JUnit
82
+ * names are kept whole (a test title may contain parentheses).
83
+ */
84
+ function testKey(name) {
85
+ if (name.includes(JUNIT_SEPARATOR))
86
+ return name;
87
+ const m = /^([\w.`+<>,[\]]+)\(.*\)$/s.exec(name);
88
+ return m ? m[1] : name;
89
+ }
90
+ /**
91
+ * The tests in `before` that no longer run and the ones in `after` that are new.
92
+ * Data rows are counted per test, so dropping the OFF row of a theory still
93
+ * shows up; among a test's rows, the ones whose exact name is gone are reported.
94
+ */
95
+ export function diffTestNames(before, after) {
96
+ const onlyIn = (from, other) => {
97
+ const exact = new Set(other);
98
+ const left = new Map();
99
+ for (const name of exact)
100
+ left.set(testKey(name), (left.get(testKey(name)) ?? 0) + 1);
101
+ const rows = new Map();
102
+ for (const name of new Set(from))
103
+ rows.set(testKey(name), [...(rows.get(testKey(name)) ?? []), name]);
104
+ const out = [];
105
+ for (const [key, names] of rows) {
106
+ const extra = names.length - (left.get(key) ?? 0);
107
+ if (extra > 0)
108
+ out.push(...[...names].sort((a, b) => Number(exact.has(a)) - Number(exact.has(b))).slice(0, extra));
109
+ }
110
+ return out;
111
+ };
112
+ return { removed: onlyIn(before, after), added: onlyIn(after, before) };
113
+ }
114
+ /** One `<testcase>` per test; a `<failure>`/`<error>` child fails it, `<skipped>` skips it. */
115
+ export function parseJUnit(xml) {
116
+ const results = empty();
117
+ for (const m of xml.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
118
+ const attrs = attributes(m[1]);
119
+ const name = [attrs.classname, attrs.name].filter(Boolean).join(JUNIT_SEPARATOR);
120
+ const body = m[2] ?? "";
121
+ const kind = /<(?:failure|error)\b/.test(body) ? "failed" : /<skipped\b/.test(body) ? "skipped" : "passed";
122
+ record(results, name, kind);
123
+ }
124
+ return results;
125
+ }
126
+ /**
127
+ * Merge every result file matching `pattern` (an absolute path or glob)
128
+ * modified at or after `since` — test runners keep older result files around.
129
+ * Undefined when no such file exists.
130
+ */
131
+ export function collectTestResults(pattern, since) {
132
+ // Filesystem timestamps can be coarser than Date.now().
133
+ const cutoff = since - 2000;
134
+ const files = fg
135
+ .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
136
+ .filter((file) => fs.statSync(file).mtimeMs >= cutoff)
137
+ .sort();
138
+ if (files.length === 0)
139
+ return undefined;
140
+ const merged = empty();
141
+ for (const file of files) {
142
+ const xml = fs.readFileSync(file, "utf8");
143
+ const parsed = /<TestRun\b/.test(xml) ? parseTrx(xml) : parseJUnit(xml);
144
+ merged.total += parsed.total;
145
+ merged.passed += parsed.passed;
146
+ merged.failed += parsed.failed;
147
+ merged.skipped += parsed.skipped;
148
+ merged.names.push(...parsed.names);
149
+ merged.failedNames.push(...parsed.failedNames);
150
+ merged.files.push(file);
151
+ }
152
+ return merged;
153
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "flagrm",
3
+ "version": "0.1.0",
4
+ "description": "Guardrails for removing feature flags with AI coding agents (Claude Code, Copilot, Codex): skills, a baseline, a verify gate and a Stop hook.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dino-cosic/flagrm.git"
10
+ },
11
+ "homepage": "https://github.com/dino-cosic/flagrm#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/dino-cosic/flagrm/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "bin": {
19
+ "flagrm": "dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "skills",
24
+ "README.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
30
+ "build": "npm run clean && tsc -p tsconfig.json",
31
+ "dev": "tsx src/cli.ts",
32
+ "test": "vitest run",
33
+ "typecheck": "tsc -p tsconfig.json --noEmit",
34
+ "lint": "biome check .",
35
+ "lint:fix": "biome check --write .",
36
+ "format": "biome format --write .",
37
+ "ci": "npm run typecheck && npm run lint && npm run build && npm test",
38
+ "prepublishOnly": "npm run ci"
39
+ },
40
+ "keywords": [
41
+ "feature-flags",
42
+ "feature-toggles",
43
+ "flag-removal",
44
+ "ai-agent",
45
+ "claude-code",
46
+ "copilot",
47
+ "codex",
48
+ "agent-skills",
49
+ "angular",
50
+ "dotnet",
51
+ "cli"
52
+ ],
53
+ "dependencies": {
54
+ "commander": "^13.1.0",
55
+ "fast-glob": "^3.3.3",
56
+ "picocolors": "^1.1.1",
57
+ "typescript": "^5.7.0",
58
+ "yaml": "^2.7.0"
59
+ },
60
+ "devDependencies": {
61
+ "@biomejs/biome": "2.5.10",
62
+ "@types/node": "^22.13.0",
63
+ "tsx": "^4.19.0",
64
+ "vitest": "^3.0.0"
65
+ }
66
+ }
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: flagrm-remove
3
+ description: >
4
+ Use when the user asks to remove, retire, deprecate or clean up a feature flag
5
+ or feature toggle, says a flag is fully rolled out, or runs /flagrm-remove <flag>,
6
+ in a repository with a flagrm.config.yaml. Works for Angular, .NET and other
7
+ stacks: IFeatureManager, [FeatureGate], appsettings FeatureManagement,
8
+ LaunchDarkly-style calls, flags objects, enums, signals, *ngIf/@if templates.
9
+ ---
10
+
11
+ # Remove a feature flag
12
+
13
+ **The flag is permanently ON.** Keep the ON path, delete the OFF path and
14
+ everything only it used. Behavior with the flag ON must not change.
15
+
16
+ You find and edit the code. `flagrm` records the state before you start and
17
+ decides when you are done: **done means `flagrm verify <flag> --json` exited 0.**
18
+ Run it as `npx --no-install flagrm`, one command per call, no `||` (plain `npx flagrm` is another package).
19
+
20
+ ## 1. Preconditions
21
+
22
+ - `git status --porcelain` must print nothing. Otherwise ask the user to commit or stash.
23
+ - No `flagrm.config.yaml`: run `flagrm init` and fill in project paths and build/test commands with the user.
24
+ - `flagrm list --json`: find the flag in `flags[]`. Not there: show the closest names and ask.
25
+ - `state` is `off`, `mixed` or `conditional`: name the environments in `config[]` that change and get a yes, or stop.
26
+ `unconfigured` (set in a flag service): ask if it is ON everywhere. The removal request is not that answer.
27
+ - Read `CLAUDE.md` or `AGENTS.md` if present, for conventions and build commands.
28
+
29
+ ## 2. Baseline
30
+
31
+ `flagrm baseline <flag> --json` records the git commit, build and test results and the flag's
32
+ `names[]`. A `checks[]` entry with a non-zero `exitCode` means the code is already broken: stop.
33
+
34
+ ## 3. Find every usage
35
+
36
+ - Grep for each name in `names[]`: code, templates, config and tests.
37
+ - Follow how the value travels: a method or property returning the flag, a field
38
+ or local holding it, a parameter it's passed as, a DI-registered bool. Record
39
+ each so `verify` checks it too, then grep for it:
40
+ `flagrm baseline <flag> --name IsNewCheckoutEnabledAsync --kind wrapper`
41
+ (`--kind alias` for a qualified constant like `Flags.NewCheckout`).
42
+
43
+ ## 4. Remove
44
+
45
+ Read `patterns.md` in this skill's directory when a case is unclear.
46
+
47
+ - Condition: keep the ON branch, delete the OFF branch, drop the check.
48
+ `a && flag` → `a`, `a || !flag` → `a`. Never touch other flags.
49
+ - Wrapper: replace each call with `true` and simplify, then delete the wrapper, its interface member and DI registration.
50
+ - Passed as a parameter: pass `true` and simplify the callee. Once no caller
51
+ passes anything else, remove the parameter and the argument at every caller.
52
+ - Code only the OFF path used (methods, classes, fields, constructor parameters,
53
+ DI registrations, imports/usings, files): delete it. Keep what anything else uses.
54
+ - Definition and config: remove the constant, enum member or flags-object key,
55
+ and the key in every environment's config file. Remove a section left empty.
56
+ Keep a key the service sends to other apps (a known-flags list behind a config endpoint): they'd read OFF.
57
+ - Tests: delete tests of the OFF path. In the others, remove the flag setup
58
+ (mocks, spies, overrides) and drop the flag from the test name. A test
59
+ parameterized over ON/OFF keeps only the ON case.
60
+ - Keep the flag system itself (the flag service or client and its generic methods), public API
61
+ of a library package, and anything that also defines other flags. Mention them in your report.
62
+
63
+ ## 5. Verify until it passes
64
+
65
+ `flagrm verify <flag> --json`. Fix every check with `status: "fail"`, run it again.
66
+
67
+ | Check | Fix |
68
+ |---|---|
69
+ | `leftovers` | Remove each reference listed. A comment is a warning: reword or delete it |
70
+ | `dead-code` | Delete what the removal left unused |
71
+ | `build` | Fix the error your edit caused; don't touch unrelated code. `tsc` skips templates: grep `.html` for each member you removed or renamed |
72
+ | `tests` | A failure means ON behavior changed: fix the edit, not the test. Tests that no longer run must be ones you deleted on purpose. A changed test file whose tests didn't run: run them yourself and report it |
73
+
74
+ Exit 2 is a usage or config error: read the message. `--skip build,tests` is fine for a quick check
75
+ in between; only a full run counts, and the Stop hook won't let you finish on one with `--skip`. After three failed attempts on one check, show the user the findings.
76
+
77
+ ## 6. Report
78
+
79
+ In 3–5 lines: what was removed, what you kept on purpose, and each remaining warning with its
80
+ reason. If `state` wasn't `on` and nobody confirmed ON, start with "Assumed ON everywhere".
81
+ Suggest `/flagrm-verify <flag>` for the overview and commit message. Don't commit.
82
+
83
+ ## Rules
84
+
85
+ - Never lift code out of the OFF path into live code.
86
+ - Match the file's formatting; don't refactor unrelated code.
87
+ - Never edit `.flagrm/`. Never run `flagrm baseline <flag>` again without `--name`.
88
+ - Never `--skip` a failing check, and never change a test's expected value to pass.
@@ -0,0 +1,96 @@
1
+ # Flag removal patterns
2
+
3
+ The flag is always ON: keep the ON path, delete the OFF path.
4
+
5
+ ## C# / .NET
6
+
7
+ **Fall-through return.** The code after an `if` that returns is the OFF path.
8
+
9
+ ```csharp
10
+ // before
11
+ if (await _featureManager.IsEnabledAsync(FeatureFlags.NewCheckout))
12
+ return await PlaceOrderAsync(cart);
13
+ return PlaceOrderLegacy(cart);
14
+ // after
15
+ return await PlaceOrderAsync(cart);
16
+ ```
17
+
18
+ **Negated guard.** The block only runs when OFF: delete it.
19
+
20
+ ```csharp
21
+ if (!await checkoutFeatures.IsNewCheckoutEnabledAsync()) { return Legacy(); } // delete
22
+ ```
23
+
24
+ **Wrapper.** `Task<bool> IsNewCheckoutEnabledAsync()` is the flag under another
25
+ name: record it with `--kind wrapper`, replace each call with `true`, then
26
+ delete the member, its interface member, the interface and class when nothing
27
+ is left, and their DI registration. Test mocks of it (`Setup(x => x.IsNewCheckoutEnabledAsync())`) go too.
28
+
29
+ **DI toggle.** Keep the ON registration.
30
+
31
+ ```csharp
32
+ // before
33
+ if (config.GetValue<bool>("FeatureManagement:NewPayments"))
34
+ services.AddScoped<IPaymentService, NewPaymentService>();
35
+ else
36
+ services.AddScoped<IPaymentService, LegacyPaymentService>();
37
+ // after
38
+ services.AddScoped<IPaymentService, NewPaymentService>();
39
+ ```
40
+
41
+ **Switch.** `flag switch { true => A(), false => B() }` → `A()`.
42
+
43
+ **Middleware.** `app.UseWhen(ctx => flag, b => b.UseMiddleware<M>())` →
44
+ `app.UseMiddleware<M>()` at the same position in the pipeline.
45
+
46
+ **`[FeatureGate]`.** Remove the attribute. If another action only served the
47
+ disabled case (404, redirect), delete it too.
48
+
49
+ **Config.** Remove the key from `appsettings*.json` in every environment, and a
50
+ `FeatureManagement` section left empty. Keys outside the repo (Key Vault, App
51
+ Service settings, Helm values) go in your report as follow-ups.
52
+
53
+ ## Angular / TypeScript
54
+
55
+ **Templates.** `@if (newCheckout()) { A } @else { B }` → `A`. An element with
56
+ `*ngIf="!newCheckout()"` is deleted; `*ngIf="newCheckout()"` loses the directive
57
+ (an `<ng-container>` left with no directive is unwrapped). A binding such as
58
+ `[disabled]="!newCheckout()"` is `false` when ON: remove it.
59
+
60
+ **Signals and Observables.** `newCheckout = this.flags.watch(FLAGS.newCheckout)`
61
+ is a wrapper: remove the field and every read. `flag$ | async` and
62
+ `toSignal(getFlag$())` are the same: remove the stream, `AsyncPipe` and `NgIf`
63
+ when the template no longer needs them.
64
+
65
+ **Standalone imports.** Remove `imports: [...]` entries (components, `NgIf`,
66
+ `AsyncPipe`) the template no longer uses.
67
+
68
+ **Routes.** A route guarded with `canMatch: [() => !isEnabled(...)]` only
69
+ served the OFF case: delete it. A positive guard loses the guard.
70
+
71
+ **Tests.** `spyOn(flags, 'isEnabled').withArgs(FLAGS.newCheckout).and.returnValue(true)`
72
+ is setup: delete it and keep the test. A test returning `false`, or calling
73
+ `flags.override(FLAGS.newCheckout, false)`, tests the OFF path: delete it.
74
+ `TestBed` providers only the OFF path needed go too.
75
+
76
+ ## Any stack
77
+
78
+ **Flag passed as a parameter.**
79
+
80
+ ```ts
81
+ // before
82
+ function price(cart: Cart, useEngine: boolean) { return useEngine ? engine(cart) : legacy(cart); }
83
+ price(cart, flags.isEnabled('NewCheckout'));
84
+ // after
85
+ function price(cart: Cart) { return engine(cart); }
86
+ price(cart);
87
+ ```
88
+
89
+ Pass `true` first, simplify, then remove the parameter only when every caller
90
+ passes `true`. A caller that passes something else keeps it.
91
+
92
+ **Stored in a field or local.** `const useNew = isEnabled(...)` is a wrapper:
93
+ record it, replace each read with `true`, then delete it.
94
+
95
+ **Two flags.** `if (flagA && flagB)` → `if (flagB)` when removing `flagA`.
96
+ Never remove or change the other flag.
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: flagrm-verify
3
+ description: >
4
+ Use when a feature flag removal needs checking, when the user asks whether a
5
+ flag is fully removed or wants the summary or commit message for a flag
6
+ removal, or runs /flagrm-verify <flag>, in a repository with a flagrm.config.yaml.
7
+ ---
8
+
9
+ # Verify a flag removal
10
+
11
+ `flagrm verify` decides whether the removal passed. You show its overview, add what the CLI
12
+ can't know, and propose a commit message. Run it as `npx --no-install flagrm`.
13
+
14
+ ## 1. Verify
15
+
16
+ `flagrm verify <flag> --md`
17
+
18
+ - Exit 0: go to step 2.
19
+ - Exit 1: show the failing checks. If you are in the middle of `/flagrm-remove`,
20
+ fix them with its rules and verify again; otherwise ask the user whether to fix them now.
21
+ - Exit 2: read the message. A missing baseline has to come from the code before
22
+ the removal. Never create one on the edited code: that compares the removal
23
+ with itself. Tell the user to start over with `/flagrm-remove` from the
24
+ pre-removal commit.
25
+
26
+ ## 2. Hand back
27
+
28
+ Output, in this order:
29
+
30
+ 1. The markdown from `flagrm verify` exactly as printed. Don't reword it, change
31
+ its numbers or drop rows.
32
+ 2. **Notes** (skip when empty), with no counts of your own:
33
+ - why each remaining warning is acceptable;
34
+ - projects whose tests didn't run because the config has no `test` command;
35
+ - follow-ups outside the repo: archive the flag in the flag service
36
+ (LaunchDarkly, Azure App Configuration, ...), remove keys from deployment
37
+ settings and secret stores.
38
+ 3. **Proposed commit message**, in a code block:
39
+
40
+ ```
41
+ chore: remove <flag> feature flag
42
+
43
+ <one or two sentences: which path stays and what was deleted>
44
+ ```
45
+
46
+ Follow the repository's convention instead when `git log --oneline -10` shows a different one.
47
+
48
+ Never commit, push or open a PR unless asked. Write a PR description only on request, from the same output.