flagrm 0.4.1 → 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,331 @@
1
+ /**
2
+ * Reads a LaunchDarkly project through the REST API (GET only): its
3
+ * environments, every non-archived flag with full targeting, and, for the
4
+ * flags that could be removed, the audit log to date the last targeting change.
5
+ * `fetch` and `sleep` are injectable so tests never touch the network or wait.
6
+ */
7
+ import { SourceError } from "./types.js";
8
+ export const LD_API_VERSION = "20240415";
9
+ const TIMEOUT_MS = 30_000;
10
+ const MAX_RETRIES = 5;
11
+ const MAX_WAIT_MS = 60_000;
12
+ const PAGE_SIZE = 100;
13
+ /** Environments requested per flags listing: kept small, since each adds full targeting to every flag. */
14
+ const ENVIRONMENTS_PER_LISTING = 3;
15
+ /** The audit log returns at most 20 entries per page. */
16
+ const AUDIT_PAGE_SIZE = 20;
17
+ const AUDIT_PAGES = 5;
18
+ /**
19
+ * Audit-log actions that don't change what an environment serves. Any other
20
+ * action, including one LaunchDarkly adds later, resets `since`: an unknown
21
+ * action can then only shorten the time a flag counts as ON, never lengthen it.
22
+ */
23
+ export const NON_TARGETING_ACTIONS = new Set([
24
+ "updateName",
25
+ "updateDescription",
26
+ "updateTags",
27
+ "updateTemporary",
28
+ "updateMaintainer",
29
+ "updateDeprecated",
30
+ "updateIncludeInSnippet",
31
+ "updateClientSideFlagAvailability",
32
+ "updateFlagCustomProperties",
33
+ "updateFlagLinks",
34
+ "updateTrackEvents",
35
+ "updateTrackEventsFallthrough",
36
+ "updateFlagSalt",
37
+ "createApprovalRequest",
38
+ "reviewApprovalRequest",
39
+ "deleteApprovalRequest",
40
+ "updateApprovalRequest",
41
+ "createTriggers",
42
+ "updateTriggers",
43
+ "deleteTriggers",
44
+ ]);
45
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+ const iso = (ms) => new Date(ms).toISOString();
47
+ export class LaunchDarklySource {
48
+ config;
49
+ options;
50
+ fetch;
51
+ sleep;
52
+ now;
53
+ constructor(config, options) {
54
+ this.config = config;
55
+ this.options = options;
56
+ this.fetch = options.fetch ?? globalThis.fetch;
57
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
58
+ this.now = options.now ?? Date.now;
59
+ }
60
+ async read(wantsHistory) {
61
+ const environments = await this.environments();
62
+ const flags = await this.flags(environments);
63
+ for (const { flag, raw } of flags) {
64
+ if (flag.unrecognized)
65
+ continue;
66
+ const history = wantsHistory(flag);
67
+ for (const [env, state] of Object.entries(flag.environments)) {
68
+ if (state.serves.kind !== "fixed")
69
+ continue;
70
+ const lastModified = raw[env].lastModified;
71
+ const audit = history ? await this.lastTargetingChange(flag.key, env) : undefined;
72
+ state.since = iso(audit ?? lastModified);
73
+ state.sinceSource = audit === undefined ? "lastModified" : "audit";
74
+ }
75
+ }
76
+ return { environments, flags: flags.map((f) => f.flag) };
77
+ }
78
+ /** The checked environments: the configured ones, which must exist, or all of the project's. */
79
+ async environments() {
80
+ const project = encodeURIComponent(this.config.project);
81
+ const keys = [];
82
+ const environmentsUrl = `/api/v2/projects/${project}/environments?limit=${PAGE_SIZE}`;
83
+ for await (const item of this.pages(environmentsUrl, this.projectNotFound())) {
84
+ if (typeof item.key === "string")
85
+ keys.push(item.key);
86
+ }
87
+ const wanted = this.config.environments;
88
+ if (!wanted)
89
+ return keys;
90
+ const unknown = wanted.filter((e) => !keys.includes(e));
91
+ if (unknown.length) {
92
+ throw new Error(`launchdarkly.environments: ${unknown.map((e) => `"${e}"`).join(", ")} not in LaunchDarkly project ` +
93
+ `"${this.config.project}". Its environments: ${keys.join(", ") || "none"}.`);
94
+ }
95
+ return wanted;
96
+ }
97
+ /**
98
+ * Every non-archived flag with what each environment serves; `raw` keeps each environment's JSON.
99
+ * The listing leaves archived flags out unless asked for them (`filter=state:archived`), so no filter is sent.
100
+ */
101
+ async flags(environments) {
102
+ const project = encodeURIComponent(this.config.project);
103
+ const byKey = new Map();
104
+ for (let i = 0; i < environments.length; i += ENVIRONMENTS_PER_LISTING) {
105
+ const batch = environments.slice(i, i + ENVIRONMENTS_PER_LISTING);
106
+ const envParams = batch.map((e) => `&env=${encodeURIComponent(e)}`).join("");
107
+ const flagsUrl = `/api/v2/flags/${project}?summary=0&limit=${PAGE_SIZE}${envParams}`;
108
+ for await (const item of this.pages(flagsUrl, this.projectNotFound())) {
109
+ const key = typeof item.key === "string" ? item.key : undefined;
110
+ if (!key)
111
+ continue;
112
+ const entry = byKey.get(key) ?? { item, envs: {} };
113
+ if (isObject(item.environments))
114
+ Object.assign(entry.envs, item.environments);
115
+ byKey.set(key, entry);
116
+ }
117
+ }
118
+ return [...byKey.values()].map(({ item, envs }) => ({ flag: parseFlag(item, envs, environments), raw: envs }));
119
+ }
120
+ /** Epoch ms of the latest audit entry for the flag in `env` that may have changed targeting; undefined when none. */
121
+ async lastTargetingChange(flagKey, env) {
122
+ const spec = `proj/${this.config.project}:env/${env}:flag/${flagKey}`;
123
+ let before;
124
+ for (let page = 0; page < AUDIT_PAGES; page++) {
125
+ const query = `spec=${encodeURIComponent(spec)}&limit=${AUDIT_PAGE_SIZE}${before ? `&before=${before}` : ""}`;
126
+ const body = await this.get(`/api/v2/auditlog?${query}`);
127
+ const items = Array.isArray(body.items) ? body.items.filter(isObject) : [];
128
+ const dated = items.filter((i) => typeof i.date === "number");
129
+ // The log lists newest first, but take the latest targeting entry of the page rather than rely on it.
130
+ const changes = dated.filter(changesTargeting).map((i) => i.date);
131
+ if (changes.length)
132
+ return Math.max(...changes);
133
+ if (dated.length < AUDIT_PAGE_SIZE)
134
+ return undefined;
135
+ before = Math.min(...dated.map((i) => i.date));
136
+ }
137
+ return undefined;
138
+ }
139
+ /** Each item of a paginated listing, following `_links.next`. */
140
+ async *pages(first, notFound) {
141
+ for (let next = first; next;) {
142
+ const body = await this.get(next, notFound);
143
+ if (!Array.isArray(body.items))
144
+ throw new SourceError(`LaunchDarkly returned no items list for GET ${next}`);
145
+ yield* body.items.filter(isObject);
146
+ const links = isObject(body._links) && isObject(body._links.next) ? body._links.next : undefined;
147
+ next = typeof links?.href === "string" ? links.href : undefined;
148
+ }
149
+ }
150
+ /**
151
+ * GET `target` as JSON, retrying rate limits; every failure is a {@link SourceError}, a 404 with `notFound`
152
+ * as its message when given. `target` is a path rooted at `baseUrl`, or a `_links` URL on the same origin.
153
+ */
154
+ async get(target, notFound) {
155
+ const url = this.resolve(target);
156
+ const endpoint = new URL(url).pathname;
157
+ for (let attempt = 0;; attempt++) {
158
+ let res;
159
+ try {
160
+ res = await this.fetch(url, {
161
+ method: "GET",
162
+ headers: { Authorization: this.options.token, "LD-API-Version": LD_API_VERSION, Accept: "application/json" },
163
+ signal: AbortSignal.timeout(TIMEOUT_MS),
164
+ });
165
+ }
166
+ catch (err) {
167
+ throw new SourceError(this.redact(networkMessage(err, endpoint)));
168
+ }
169
+ if (res.status === 429 && attempt < MAX_RETRIES) {
170
+ await this.sleep(this.retryWait(res.headers));
171
+ continue;
172
+ }
173
+ if (res.ok) {
174
+ try {
175
+ const body = await res.json();
176
+ if (isObject(body))
177
+ return body;
178
+ }
179
+ catch {
180
+ // reported below
181
+ }
182
+ throw new SourceError(`LaunchDarkly returned a response that isn't a JSON object for GET ${endpoint}`);
183
+ }
184
+ const message = res.status === 404 && notFound ? `${notFound} (404) at GET ${endpoint}` : undefined;
185
+ throw new SourceError(this.redact(message ?? this.statusMessage(res.status, endpoint)));
186
+ }
187
+ }
188
+ /**
189
+ * The URL for a path (`/api/v2/...`, appended to `baseUrl`) or an absolute `_links` URL. An absolute URL
190
+ * must be on `baseUrl`'s origin: the token is sent with the request, so it never goes to another host.
191
+ */
192
+ resolve(target) {
193
+ if (target.startsWith("/"))
194
+ return `${this.config.baseUrl}${target}`;
195
+ let url;
196
+ try {
197
+ url = new URL(target);
198
+ }
199
+ catch {
200
+ throw new SourceError(`LaunchDarkly returned a link flagrm can't follow: ${target}`);
201
+ }
202
+ if (url.origin !== new URL(this.config.baseUrl).origin) {
203
+ throw new SourceError(`LaunchDarkly returned a link to another host (${url.origin}); not following it`);
204
+ }
205
+ return url.href;
206
+ }
207
+ projectNotFound() {
208
+ return `LaunchDarkly project "${this.config.project}" not found`;
209
+ }
210
+ statusMessage(status, endpoint) {
211
+ const { project, tokenEnv } = this.config;
212
+ if (status === 401)
213
+ return `LaunchDarkly rejected the token in ${tokenEnv} (401) for GET ${endpoint}`;
214
+ if (status === 403) {
215
+ return `The token in ${tokenEnv} may not read LaunchDarkly project "${project}" (403) for GET ${endpoint}`;
216
+ }
217
+ if (status === 404)
218
+ return `LaunchDarkly found nothing (404) at GET ${endpoint}`;
219
+ if (status === 429)
220
+ return `LaunchDarkly kept rate-limiting (429) after ${MAX_RETRIES} retries for GET ${endpoint}`;
221
+ return `LaunchDarkly answered ${status} for GET ${endpoint}`;
222
+ }
223
+ /** Milliseconds to wait before retrying a 429: the longest the headers ask for, at most a minute. */
224
+ retryWait(headers) {
225
+ const waits = [];
226
+ for (const name of ["X-Ratelimit-Reset", "X-Ratelimit-Auth-Token-Reset"]) {
227
+ const reset = Number(headers.get(name));
228
+ if (headers.get(name) && Number.isFinite(reset))
229
+ waits.push(reset - this.now());
230
+ }
231
+ const retryAfter = Number(headers.get("Retry-After"));
232
+ if (headers.get("Retry-After") && Number.isFinite(retryAfter))
233
+ waits.push(retryAfter * 1000);
234
+ const wait = waits.length ? Math.max(...waits) : 1000;
235
+ return Math.min(MAX_WAIT_MS, Math.max(0, wait));
236
+ }
237
+ /** Belt and braces: no message may carry the token, whatever an error echoes back. */
238
+ redact(message) {
239
+ const { token } = this.options;
240
+ return token ? message.split(token).join("***") : message;
241
+ }
242
+ }
243
+ function networkMessage(err, endpoint) {
244
+ const e = err;
245
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
246
+ return `LaunchDarkly didn't answer within ${TIMEOUT_MS / 1000} s for GET ${endpoint}`;
247
+ }
248
+ const cause = e?.cause?.code ?? e?.cause?.message ?? e?.message ?? String(err);
249
+ return `Couldn't reach LaunchDarkly (${cause}) for GET ${endpoint}`;
250
+ }
251
+ /** Whether an audit entry may have changed what the environment serves. */
252
+ function changesTargeting(entry) {
253
+ const accesses = Array.isArray(entry.accesses) ? entry.accesses.filter(isObject) : [];
254
+ // An entry without readable actions counts as a change: unknown can only shorten the ON time.
255
+ if (accesses.length === 0)
256
+ return true;
257
+ return accesses.some((a) => typeof a.action !== "string" || !NON_TARGETING_ACTIONS.has(a.action));
258
+ }
259
+ const nonEmpty = (v) => Array.isArray(v) && v.length > 0;
260
+ /**
261
+ * What one environment serves, from its targeting: `fixed` only when every
262
+ * context gets one variation. Undefined when the data isn't what flagrm expects.
263
+ */
264
+ export function servedValue(env, variations) {
265
+ const variation = (index) => typeof index === "number" && Number.isInteger(index) && index >= 0 && index < variations.length
266
+ ? { kind: "fixed", value: variations[index].value }
267
+ : undefined;
268
+ for (const field of ["targets", "contextTargets", "rules", "prerequisites"]) {
269
+ if (env[field] !== undefined && env[field] !== null && !Array.isArray(env[field]))
270
+ return undefined;
271
+ }
272
+ if (typeof env.on !== "boolean")
273
+ return undefined;
274
+ if (!env.on) {
275
+ if (env.offVariation === undefined || env.offVariation === null) {
276
+ return { kind: "targeted", detail: "targeting off without an off variation (the code's fallback is served)" };
277
+ }
278
+ return variation(env.offVariation);
279
+ }
280
+ const count = (field) => (Array.isArray(env[field]) ? env[field].length : 0);
281
+ if (nonEmpty(env.prerequisites))
282
+ return { kind: "targeted", detail: `${count("prerequisites")} prerequisite(s)` };
283
+ if (nonEmpty(env.rules))
284
+ return { kind: "targeted", detail: `${count("rules")} rule(s)` };
285
+ if (nonEmpty(env.targets) || nonEmpty(env.contextTargets))
286
+ return { kind: "targeted", detail: "individual targets" };
287
+ if (!isObject(env.fallthrough))
288
+ return undefined;
289
+ if (env.fallthrough.rollout !== undefined && env.fallthrough.rollout !== null) {
290
+ return { kind: "targeted", detail: "a percentage rollout" };
291
+ }
292
+ return variation(env.fallthrough.variation);
293
+ }
294
+ /** A {@link SourceFlag} from one flags-listing item; `unrecognized` says what didn't parse. */
295
+ export function parseFlag(item, envs, environments) {
296
+ const key = String(item.key);
297
+ const flag = {
298
+ key,
299
+ name: typeof item.name === "string" ? item.name : key,
300
+ kind: typeof item.kind === "string" ? item.kind : "unknown",
301
+ temporary: item.temporary === true,
302
+ tags: Array.isArray(item.tags) ? item.tags.filter((t) => typeof t === "string") : [],
303
+ creationDate: typeof item.creationDate === "number" ? iso(item.creationDate) : "",
304
+ environments: {},
305
+ };
306
+ const problems = [];
307
+ if (typeof item.kind !== "string")
308
+ problems.push("no kind");
309
+ if (typeof item.temporary !== "boolean")
310
+ problems.push("no temporary marker");
311
+ if (!Array.isArray(item.variations) || !item.variations.every(isObject))
312
+ problems.push("no variations");
313
+ for (const env of environments) {
314
+ const raw = envs[env];
315
+ if (!isObject(raw)) {
316
+ problems.push(`no targeting for environment "${env}"`);
317
+ continue;
318
+ }
319
+ const serves = Array.isArray(item.variations) ? servedValue(raw, item.variations) : undefined;
320
+ if (!serves)
321
+ problems.push(`unreadable targeting in "${env}"`);
322
+ else if (serves.kind === "fixed" && typeof raw.lastModified !== "number") {
323
+ problems.push(`no lastModified in "${env}"`);
324
+ }
325
+ else
326
+ flag.environments[env] = { serves };
327
+ }
328
+ if (problems.length)
329
+ flag.unrecognized = `unrecognized LaunchDarkly data: ${problems.join(", ")}`;
330
+ return flag;
331
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A flag service flagrm reads flags from, for `flagrm stale`. Read-only: a
3
+ * source never writes to the service. LaunchDarkly is the only one today;
4
+ * another service is a new file implementing {@link FlagSource}.
5
+ */
6
+ /**
7
+ * The service couldn't be asked: unreachable, timed out, rejected the token,
8
+ * unknown project, rate limit retries used up. `flagrm stale` exits 3 on it.
9
+ * Messages name the endpoint, never the token.
10
+ */
11
+ export class SourceError extends Error {
12
+ name = "SourceError";
13
+ }
@@ -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
+ }
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagrm",
3
- "version": "0.4.1",
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",
@@ -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.