flagrm 0.4.0 → 0.5.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.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `flagrm stale`: which flags a flag service says are ready to remove from
3
+ * code. Pure: it takes what the source read, the code names from `flagrm
4
+ * list`'s inventory, the conditions and the time to count days to, and does
5
+ * no I/O, so every verdict is reproducible.
6
+ */
7
+ import { JSON_SCHEMA_VERSION, } from "./types.js";
8
+ import { plural } from "./util.js";
9
+ const DAY_MS = 86_400_000;
10
+ /** Case and separators (`-`, `_`, `.`, spaces) don't matter: `new-checkout` and `NewCheckout` are the same name. */
11
+ export function normalizeName(name) {
12
+ return name.toLowerCase().replace(/[-_.\s]/g, "");
13
+ }
14
+ /**
15
+ * The code name each LaunchDarkly key matches: a `map` entry first, then the
16
+ * exact name, then the normalized one. A normalized name shared by several
17
+ * code names, or by several keys, matches none of them: it would be a guess.
18
+ */
19
+ export function matchKeys(keys, codeNames, map) {
20
+ const result = new Map();
21
+ const code = new Set(codeNames);
22
+ const claimed = new Set();
23
+ for (const key of keys) {
24
+ const mapped = map[key];
25
+ if (mapped === undefined)
26
+ continue;
27
+ if (code.has(mapped)) {
28
+ result.set(key, { kind: "matched", codeName: mapped });
29
+ claimed.add(mapped);
30
+ }
31
+ else
32
+ result.set(key, { kind: "none", reason: `map names "${mapped}", which the code doesn't define or use` });
33
+ }
34
+ for (const key of keys) {
35
+ if (!result.has(key) && code.has(key) && !claimed.has(key)) {
36
+ result.set(key, { kind: "matched", codeName: key });
37
+ claimed.add(key);
38
+ }
39
+ }
40
+ const group = (names) => {
41
+ const groups = new Map();
42
+ for (const n of names)
43
+ groups.set(normalizeName(n), [...(groups.get(normalizeName(n)) ?? []), n]);
44
+ return groups;
45
+ };
46
+ const restKeys = keys.filter((k) => !result.has(k));
47
+ const codeByName = group(codeNames.filter((n) => !claimed.has(n)));
48
+ const keysByName = group(restKeys);
49
+ const quoted = (names) => names.map((n) => `"${n}"`).join(", ");
50
+ for (const key of restKeys) {
51
+ const name = normalizeName(key);
52
+ const candidates = codeByName.get(name) ?? [];
53
+ const rivals = (keysByName.get(name) ?? []).filter((k) => k !== key);
54
+ if (candidates.length === 0)
55
+ result.set(key, { kind: "none" });
56
+ else if (candidates.length > 1) {
57
+ result.set(key, {
58
+ kind: "ambiguous",
59
+ reason: `ambiguous: code names ${quoted(candidates)} all match; add a launchdarkly.map entry`,
60
+ });
61
+ }
62
+ else if (rivals.length) {
63
+ result.set(key, {
64
+ kind: "ambiguous",
65
+ reason: `ambiguous: LaunchDarkly keys ${quoted([key, ...rivals])} all match code name "${candidates[0]}"; add a launchdarkly.map entry`,
66
+ });
67
+ }
68
+ else
69
+ result.set(key, { kind: "matched", codeName: candidates[0] });
70
+ }
71
+ return result;
72
+ }
73
+ export function conditionsOf(config, environments) {
74
+ return { environments, minDaysOn: config.minDaysOn, temporaryOnly: config.temporaryOnly, tags: config.tags };
75
+ }
76
+ /** Why the flag's own settings (kind, temporary, tags) rule it out, whatever its environments serve. */
77
+ function settingReasons(flag, conditions) {
78
+ const reasons = [];
79
+ if (flag.kind !== "boolean")
80
+ reasons.push(`not a boolean flag (${flag.kind})`);
81
+ if (conditions.temporaryOnly && !flag.temporary)
82
+ reasons.push("permanent: not marked temporary in LaunchDarkly");
83
+ for (const tag of conditions.tags.include)
84
+ if (!flag.tags.includes(tag))
85
+ reasons.push(`lacks tag "${tag}"`);
86
+ for (const tag of conditions.tags.exclude)
87
+ if (flag.tags.includes(tag))
88
+ reasons.push(`has excluded tag "${tag}"`);
89
+ return reasons;
90
+ }
91
+ /**
92
+ * Whether the flag's change history can decide its verdict, so it is worth
93
+ * the audit-log requests: it passes every condition but the days, and a code
94
+ * name matches it.
95
+ */
96
+ export function needsHistory(flag, config, codeNames) {
97
+ if (flag.unrecognized || settingReasons(flag, config).length)
98
+ return false;
99
+ // A recognized flag has every checked environment.
100
+ const served = Object.values(flag.environments).map((e) => e.serves);
101
+ if (!served.length || !served.every((s) => s.kind === "fixed" && s.value === true))
102
+ return false;
103
+ const name = normalizeName(config.map[flag.key] ?? flag.key);
104
+ return codeNames.some((n) => normalizeName(n) === name);
105
+ }
106
+ /** Each checked environment as reported, with whole days since `since`. */
107
+ function environmentsOf(flag, environments, now) {
108
+ return environments.flatMap((environment) => {
109
+ const state = flag.environments[environment];
110
+ if (!state)
111
+ return [];
112
+ if (state.serves.kind === "targeted")
113
+ return [{ environment, serves: "targeted", detail: state.serves.detail }];
114
+ const days = state.since ? Math.floor((now.getTime() - Date.parse(state.since)) / DAY_MS) : undefined;
115
+ return [
116
+ {
117
+ environment,
118
+ serves: "fixed",
119
+ value: state.serves.value,
120
+ ...(state.since ? { since: state.since, sinceSource: state.sinceSource, days } : {}),
121
+ },
122
+ ];
123
+ });
124
+ }
125
+ function evaluateFlag(flag, match, conditions, now) {
126
+ const environments = environmentsOf(flag, conditions.environments, now);
127
+ const base = { key: flag.key, ...(match.kind === "matched" ? { codeName: match.codeName } : {}), environments };
128
+ if (flag.unrecognized)
129
+ return { ...base, verdict: "keep", reasons: [flag.unrecognized] };
130
+ if (match.kind === "none")
131
+ return { ...base, verdict: "unreferenced", reasons: match.reason ? [match.reason] : [] };
132
+ if (match.kind === "ambiguous")
133
+ return { ...base, verdict: "keep", reasons: [match.reason] };
134
+ const reasons = settingReasons(flag, conditions);
135
+ for (const env of environments) {
136
+ if (env.serves === "targeted")
137
+ reasons.push(`${env.environment}: targeted (${env.detail})`);
138
+ else if (env.value !== true)
139
+ reasons.push(`${env.environment}: serves ${JSON.stringify(env.value)}`);
140
+ else if ((env.days ?? 0) < conditions.minDaysOn) {
141
+ reasons.push(`${env.environment}: true for only ${plural(env.days ?? 0, "day")} (needs ${conditions.minDaysOn})`);
142
+ }
143
+ }
144
+ return { ...base, verdict: reasons.length ? "keep" : "remove", reasons };
145
+ }
146
+ /** Up to three names closest to `name` (normalized edit distance), for a "did you mean". */
147
+ export function closestNames(name, names) {
148
+ const target = normalizeName(name);
149
+ const distance = (a, b) => {
150
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
151
+ for (let i = 1; i <= a.length; i++) {
152
+ const next = [i];
153
+ for (let j = 1; j <= b.length; j++) {
154
+ next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
155
+ }
156
+ row = next;
157
+ }
158
+ return row[b.length];
159
+ };
160
+ return [...new Set(names)]
161
+ .map((n) => ({ n, d: distance(target, normalizeName(n)) }))
162
+ .sort((a, b) => a.d - b.d || a.n.localeCompare(b.n))
163
+ .slice(0, 3)
164
+ .map((x) => x.n);
165
+ }
166
+ /** A name in `only` that neither a LaunchDarkly flag nor a code name matches: a usage error. */
167
+ export class UnknownFlagError extends Error {
168
+ }
169
+ export function evaluateStale(input) {
170
+ const { read, codeNames, config, now } = input;
171
+ const conditions = conditionsOf(config, read.environments);
172
+ const keys = read.flags.map((f) => f.key);
173
+ const matches = matchKeys(keys, codeNames, config.map);
174
+ const byKey = new Map(read.flags.map((f) => [f.key, f]));
175
+ const flags = [];
176
+ const evaluate = (key) => {
177
+ if (!flags.some((f) => f.key === key)) {
178
+ flags.push(evaluateFlag(byKey.get(key), matches.get(key), conditions, now));
179
+ }
180
+ };
181
+ if (!input.only?.length)
182
+ keys.forEach(evaluate);
183
+ for (const arg of input.only ?? []) {
184
+ const name = normalizeName(arg);
185
+ const hits = keys.filter((k) => {
186
+ const m = matches.get(k);
187
+ return k === arg || (m?.kind === "matched" && m.codeName === arg);
188
+ });
189
+ const loose = hits.length ? [] : keys.filter((k) => normalizeName(k) === name);
190
+ if (hits.length || loose.length) {
191
+ [...hits, ...loose].forEach(evaluate);
192
+ continue;
193
+ }
194
+ const codeName = codeNames.find((n) => n === arg) ?? codeNames.find((n) => normalizeName(n) === name);
195
+ if (!codeName) {
196
+ throw new UnknownFlagError(`No LaunchDarkly flag or code name matches "${arg}". Closest: ${closestNames(arg, [...keys, ...codeNames]).join(", ") || "none"}.`);
197
+ }
198
+ if (flags.some((f) => f.codeName === codeName))
199
+ continue;
200
+ flags.push({
201
+ codeName,
202
+ verdict: "keep",
203
+ reasons: [
204
+ `no LaunchDarkly flag matches "${codeName}": if it was archived or deleted, the SDK serves the code's fallback value, which may be OFF`,
205
+ ],
206
+ environments: [],
207
+ });
208
+ }
209
+ const sortKey = (f) => f.key ?? f.codeName ?? "";
210
+ flags.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
211
+ return {
212
+ schemaVersion: JSON_SCHEMA_VERSION,
213
+ project: input.project,
214
+ evaluatedAt: now.toISOString(),
215
+ conditions,
216
+ flags,
217
+ };
218
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import fg from "fast-glob";
9
+ import { globSync } from "tinyglobby";
10
10
  import { readJson, relativePath } from "./util.js";
11
11
  /** The test target of the first `angular.json` project that has one. */
12
12
  function angularTestTarget(root) {
@@ -22,7 +22,7 @@ function dependencies(root) {
22
22
  const pkg = readJson(path.join(root, "package.json"));
23
23
  return new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
24
24
  }
25
- const hasFile = (root, pattern) => fg.sync(pattern, { cwd: root, deep: 1 }).length > 0;
25
+ const hasFile = (root, pattern) => globSync(pattern, { expandDirectories: false, cwd: root, deep: 1 }).length > 0;
26
26
  function karma(root, options) {
27
27
  const conf = path.resolve(root, typeof options.karmaConfig === "string" ? options.karmaConfig : "karma.conf.js");
28
28
  let text;
@@ -21,6 +21,8 @@
21
21
  * - 8: `lint` runs (`check: "lint"`) and the `lint` and `coverage` checks;
22
22
  * `baseline.json` gains the config's `lint` commands and `git.dirtyHashes`.
23
23
  * Both are optional, so version 6 and 7 files are still read.
24
+ *
25
+ * `stale --json` ({@link StaleReport}) was added at 8: a new payload, so no bump.
24
26
  */
25
27
  export const JSON_SCHEMA_VERSION = 8;
26
28
  /** `baseline.json` versions this flagrm reads: 7 and 8 only added optional fields to 6. */
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import fg from "fast-glob";
10
+ import { globAbsolute } from "../scan.js";
11
11
  function attributes(tag) {
12
12
  const out = {};
13
13
  for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
@@ -200,8 +200,7 @@ export function hasTestNames(results) {
200
200
  export function collectTestResults(pattern, since, claimed = new Map()) {
201
201
  // Filesystem timestamps can be coarser than Date.now().
202
202
  const cutoff = since - 2000;
203
- const files = fg
204
- .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
203
+ const files = globAbsolute(pattern)
205
204
  .map((file) => ({ file, mtime: fs.statSync(file).mtimeMs }))
206
205
  .filter(({ file, mtime }) => mtime >= cutoff && claimed.get(file) !== mtime)
207
206
  .sort((a, b) => a.file.localeCompare(b.file));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagrm",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -53,17 +53,17 @@
53
53
  ],
54
54
  "dependencies": {
55
55
  "commander": "^13.1.0",
56
- "fast-glob": "^3.3.3",
57
56
  "picocolors": "^1.1.1",
58
57
  "picomatch": "^4.0.7",
58
+ "tinyglobby": "^0.2.17",
59
59
  "typescript": "^5.7.0",
60
60
  "yaml": "^2.7.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@biomejs/biome": "2.5.10",
63
+ "@biomejs/biome": "2.5.15",
64
64
  "@types/node": "^22.13.0",
65
65
  "@types/picomatch": "^4.0.3",
66
66
  "tsx": "^4.19.0",
67
- "vitest": "^3.0.0"
67
+ "vitest": "^4.1.11"
68
68
  }
69
69
  }
@@ -25,7 +25,7 @@ Several flags (`/flagrm-remove A B C`): read [batch.md](batch.md) first; it runs
25
25
  Its doctor reports a missing SDK, `cargo` or Docker: show it. For `cargo`/Docker, offer `flagrm scope`; `--write` only on a yes.
26
26
  - `flagrm list --json`: find the flag in `flags[]`. Not there: show the closest names and ask.
27
27
  - `state` is `off`, `mixed` or `conditional`: name the environments in `config[]` that change and get a yes, or stop.
28
- `unconfigured` (set in a flag service): ask if it is ON everywhere. The removal request is not that answer.
28
+ `unconfigured` (set in a flag service): with `launchdarkly:` in the config, `flagrm stale <flag> --json`; verdict `remove` confirms ON, another verdict: stop and show its `reasons`. No section, or `stale` exits non-zero (show its message): ask if it is ON everywhere. The removal request is not that answer.
29
29
  - Read `CLAUDE.md` or `AGENTS.md` if present, for conventions and build commands.
30
30
 
31
31
  ## 2. Baseline
@@ -80,7 +80,7 @@ Exit 2 is a usage or config error: read the message. `--skip build,tests,lint,co
80
80
  ## 6. Hand back
81
81
 
82
82
  1. `flagrm verify <flag> --md` on the final tree (it reuses the passing build, test and lint runs; edit nothing after it): show its output exactly as printed; don't reword it or change its numbers. It must pass: the Stop hook checks it.
83
- 2. **Notes**, 3–5 lines: what you kept on purpose, each remaining warning with its reason, follow-ups in the flag service. If `state` wasn't `on` and nobody confirmed ON, start with "Assumed ON everywhere".
83
+ 2. **Notes**, 3–5 lines: what you kept on purpose, each remaining warning with its reason, follow-ups in the flag service. If `state` wasn't `on` and nobody confirmed ON, start with "Assumed ON everywhere". End with: optional, for an independent review, run `/flagrm-review <flag>` in a new session.
84
84
  3. The commit message:
85
85
 
86
86
  <!-- commit-message:start -->
@@ -7,7 +7,7 @@ Each baseline reuses the previous flag's passing verify (seconds) only if the co
7
7
 
8
8
  1. SKILL.md step 1 preconditions. Find every flag in `flagrm list --json`; for one that's missing, show the closest names and ask.
9
9
  2. One table: flag, `state`, and for any not `on` the environments in `config[]` that change.
10
- Ask once: "Remove all of these as ON everywhere?" On a no, drop the flags the user names.
10
+ `unconfigured` flags follow SKILL.md's `flagrm stale` rule. Ask once about the rest: "Remove all of these as ON everywhere?" On a no, drop the flags the user names.
11
11
  3. Ask once: "Commit each flag once its verify passes?"
12
12
 
13
13
  ## Per flag
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: flagrm-review
3
+ description: >
4
+ Use when a feature flag removal needs an independent review, when the user asks
5
+ whether a flag removal kept the right behavior, or runs /flagrm-review <flag>,
6
+ in a repository with a flagrm.config.yaml.
7
+ ---
8
+
9
+ # Review a flag removal
10
+
11
+ `flagrm verify` decides whether the removal is done. You judge what it can't: whether
12
+ the code still behaves as it did with the flag ON. You report; you never edit, and your
13
+ findings never change the verify result. Run flagrm as `npx --no-install flagrm`.
14
+
15
+ ## 1. Fresh session only
16
+
17
+ If you made this removal earlier in this conversation, don't review it: you would only
18
+ check your own reasoning. Tell the user to run `/flagrm-review <flag>` in a new session, and stop.
19
+
20
+ ## 2. Verify
21
+
22
+ `flagrm verify <flag> --json`
23
+
24
+ - Exit 0: go to step 3.
25
+ - Exit 1: list each check with `status: "fail"` and its findings, then stop. Don't review, don't fix.
26
+ - Exit 2: show the message and stop. A missing baseline has to come from the code before
27
+ the removal: never create one on the edited code. Tell the user to start over with
28
+ `/flagrm-remove` from the pre-removal commit.
29
+
30
+ ## 3. Review the diff
31
+
32
+ The removal is every file in `changedFiles`, compared with the baseline commit:
33
+ `git diff <baseline.git.sha> -- <file>` (it may already be committed, so not `HEAD`).
34
+ Check each change:
35
+
36
+ 1. **ON path kept**: where the flag was read, the code that stays is what ran with the flag ON.
37
+ 2. **Deleted tests**: each one tested only the OFF path or the flag switch, never ON behavior.
38
+ 3. **Nothing lifted**: no code from the OFF path moved into live code.
39
+ 4. **Nothing unrelated**: no edits beyond the flag and the code only the OFF path used.
40
+ 5. **No lost side effects**: unwrapping a check kept every `await`, log, return value and call.
41
+
42
+ ## 4. Report
43
+
44
+ 1. One line: `flagrm verify <flag>: pass`, with the warning count if any.
45
+ 2. **Review**: one bullet per finding: `file:line`, what is wrong, and why. No findings:
46
+ "No problems found."
47
+
48
+ With findings, ask the user whether to fix them; edit nothing before they answer.
49
+ When nobody can answer (a headless run), only report.
@@ -1,54 +0,0 @@
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. The commit message:
39
-
40
- <!-- commit-message:start -->
41
- **Proposed commit message**, in a code block:
42
-
43
- ```
44
- chore: remove <flag> feature flag
45
-
46
- <one or two sentences: which path stays and what was deleted>
47
- ```
48
-
49
- - Follow the repository's convention instead when `git log --oneline -10` shows a different one.
50
- - A ticket in the branch name (`feature/feat-24586`): add it the way the log does (`#24586`, `FEAT-24586`), else `(#24586)` after the subject.
51
- - Wrap the body at 72 columns.
52
- <!-- commit-message:end -->
53
-
54
- Never commit, push or open a PR unless asked. Write a PR description only on request, from the same output.