flagrm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/adapters/angular/discover.js +100 -0
- package/dist/adapters/angular/index.js +34 -0
- package/dist/adapters/angular/unused.js +74 -0
- package/dist/adapters/dotnet/index.js +29 -0
- package/dist/adapters/dotnet/unused.js +43 -0
- package/dist/adapters/generic/discover.js +84 -0
- package/dist/adapters/generic/index.js +50 -0
- package/dist/adapters/generic/text.js +93 -0
- package/dist/cli.js +216 -0
- package/dist/core/adapter.js +18 -0
- package/dist/core/baseline.js +255 -0
- package/dist/core/config.js +254 -0
- package/dist/core/detect.js +64 -0
- package/dist/core/discover.js +310 -0
- package/dist/core/doctor.js +141 -0
- package/dist/core/git.js +101 -0
- package/dist/core/hook.js +83 -0
- package/dist/core/install.js +257 -0
- package/dist/core/inventory.js +118 -0
- package/dist/core/markdown.js +55 -0
- package/dist/core/mentions.js +153 -0
- package/dist/core/package.js +17 -0
- package/dist/core/registry.js +29 -0
- package/dist/core/report.js +135 -0
- package/dist/core/scan.js +201 -0
- package/dist/core/source-text.js +36 -0
- package/dist/core/text-utils.js +27 -0
- package/dist/core/types.js +22 -0
- package/dist/core/util.js +18 -0
- package/dist/core/verify/commands.js +166 -0
- package/dist/core/verify/context.js +13 -0
- package/dist/core/verify/dead-code.js +92 -0
- package/dist/core/verify/index.js +84 -0
- package/dist/core/verify/leftovers.js +74 -0
- package/dist/core/verify/test-results.js +153 -0
- package/package.json +66 -0
- package/skills/flagrm-remove/SKILL.md +88 -0
- package/skills/flagrm-remove/patterns.md +96 -0
- package/skills/flagrm-verify/SKILL.md +48 -0
|
@@ -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(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/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(/&/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.
|