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.
@@ -7,14 +7,17 @@
7
7
  */
8
8
  import picomatch from "picomatch";
9
9
  import { CONFIG_FILENAMES, loadConfig } from "./config.js";
10
- import { AGENT_SKILL_DIRS, SKILLS } from "./install.js";
10
+ import { AGENT_SKILL_DIRS, LEGACY_SKILLS, SKILLS } from "./install.js";
11
11
  import { relativePath } from "./util.js";
12
- /** The files `flagrm init`, `update` and `scope --write` write, relative to the config root. */
12
+ /**
13
+ * The files `flagrm init`, `update` and `scope --write` write, relative to the
14
+ * config root. Renamed skills count too: `update` deletes them, possibly mid-removal.
15
+ */
13
16
  export const SETUP_PATTERNS = [
14
17
  ...CONFIG_FILENAMES,
15
18
  ".gitignore",
16
19
  ".claude/settings.json",
17
- ...AGENT_SKILL_DIRS.flatMap((dir) => SKILLS.map((skill) => `${dir}/${skill}/**`)),
20
+ ...AGENT_SKILL_DIRS.flatMap((dir) => [...SKILLS, ...Object.keys(LEGACY_SKILLS)].map((skill) => `${dir}/${skill}/**`)),
18
21
  ".github/prompts/flagrm-*.prompt.md",
19
22
  "AGENTS.md",
20
23
  "**/flagrm.slnf",
@@ -19,6 +19,8 @@ export function parseTools(value, where = "tools") {
19
19
  }
20
20
  return TOOL_IDS.filter((t) => tools.includes(t));
21
21
  }
22
+ export const DEFAULT_LAUNCHDARKLY_BASE_URL = "https://app.launchdarkly.com";
23
+ export const DEFAULT_LAUNCHDARKLY_TOKEN_ENV = "LD_API_TOKEN";
22
24
  export const DEFAULT_FRONTEND_METHODS = ["isEnabled", "isFeatureEnabled", "hasFeature", "getFlag", "isOn"];
23
25
  export const DEFAULT_BACKEND_METHODS = [
24
26
  "IsEnabledAsync",
@@ -41,6 +43,17 @@ const KNOWN_CONFIG_KEYS = [
41
43
  "backendAttributes",
42
44
  "exclude",
43
45
  "include",
46
+ "launchdarkly",
47
+ ];
48
+ const KNOWN_LAUNCHDARKLY_KEYS = [
49
+ "project",
50
+ "baseUrl",
51
+ "tokenEnv",
52
+ "environments",
53
+ "minDaysOn",
54
+ "temporaryOnly",
55
+ "tags",
56
+ "map",
44
57
  ];
45
58
  const KNOWN_PROJECT_KEYS = [
46
59
  "name",
@@ -155,6 +168,32 @@ function optionalPositiveNumber(value, where) {
155
168
  }
156
169
  return value;
157
170
  }
171
+ function optionalBoolean(value, where) {
172
+ if (value === undefined)
173
+ return undefined;
174
+ if (typeof value !== "boolean")
175
+ throw new Error(`${where} must be true or false.`);
176
+ return value;
177
+ }
178
+ function optionalPositiveDays(value, where) {
179
+ if (value === undefined)
180
+ return undefined;
181
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
182
+ throw new Error(`${where} must be a positive whole number of days.`);
183
+ }
184
+ return value;
185
+ }
186
+ function isObject(value) {
187
+ return value !== null && typeof value === "object" && !Array.isArray(value);
188
+ }
189
+ function optionalStringMap(value, where) {
190
+ if (value === undefined)
191
+ return undefined;
192
+ if (!isObject(value) || !Object.values(value).every((v) => typeof v === "string" && v.trim())) {
193
+ throw new Error(`${where} must map LaunchDarkly keys to code names (strings).`);
194
+ }
195
+ return value;
196
+ }
158
197
  function optionalStringList(value, where) {
159
198
  if (value === undefined)
160
199
  return undefined;
@@ -163,6 +202,59 @@ function optionalStringList(value, where) {
163
202
  }
164
203
  return value;
165
204
  }
205
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
206
+ /** The `launchdarkly:` section with defaults applied; undefined when absent. */
207
+ function parseLaunchDarkly(raw, quiet = false) {
208
+ if (raw === undefined || raw === null)
209
+ return undefined;
210
+ const where = "launchdarkly";
211
+ if (!isObject(raw))
212
+ throw new Error(`${where} must be an object with at least a "project".`);
213
+ for (const key of ["token", "apiKey"]) {
214
+ if (key in raw) {
215
+ throw new Error(`${where}.${key}: never put the API token in the config file. Remove it and set the environment ` +
216
+ `variable named by ${where}.tokenEnv (default ${DEFAULT_LAUNCHDARKLY_TOKEN_ENV}) instead.`);
217
+ }
218
+ }
219
+ if (!quiet)
220
+ warnOnUnknownKeys(raw, KNOWN_LAUNCHDARKLY_KEYS, `"${where}"`);
221
+ const project = optionalString(raw.project, `${where}.project`);
222
+ if (!project)
223
+ throw new Error(`${where}.project is required: the LaunchDarkly project key.`);
224
+ const baseUrl = (optionalString(raw.baseUrl, `${where}.baseUrl`) ?? DEFAULT_LAUNCHDARKLY_BASE_URL).replace(/\/+$/, "");
225
+ let url;
226
+ try {
227
+ url = new URL(baseUrl);
228
+ }
229
+ catch {
230
+ throw new Error(`${where}.baseUrl is not a URL: ${baseUrl}`);
231
+ }
232
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname))) {
233
+ throw new Error(`${where}.baseUrl must use https: the API token is sent to it.`);
234
+ }
235
+ if (raw.tags !== undefined && !isObject(raw.tags)) {
236
+ throw new Error(`${where}.tags must be an object with "include" and/or "exclude" lists.`);
237
+ }
238
+ const tags = (raw.tags ?? {});
239
+ if (!quiet)
240
+ warnOnUnknownKeys(tags, ["include", "exclude"], `"${where}.tags"`);
241
+ const environments = optionalStringList(raw.environments, `${where}.environments`);
242
+ if (environments?.length === 0)
243
+ throw new Error(`${where}.environments must name at least one environment.`);
244
+ return {
245
+ project,
246
+ baseUrl,
247
+ tokenEnv: optionalString(raw.tokenEnv, `${where}.tokenEnv`) ?? DEFAULT_LAUNCHDARKLY_TOKEN_ENV,
248
+ ...(environments ? { environments } : {}),
249
+ minDaysOn: optionalPositiveDays(raw.minDaysOn, `${where}.minDaysOn`) ?? 30,
250
+ temporaryOnly: optionalBoolean(raw.temporaryOnly, `${where}.temporaryOnly`) ?? true,
251
+ tags: {
252
+ include: optionalStringList(tags.include, `${where}.tags.include`) ?? [],
253
+ exclude: optionalStringList(tags.exclude, `${where}.tags.exclude`) ?? [],
254
+ },
255
+ map: optionalStringMap(raw.map, `${where}.map`) ?? {},
256
+ };
257
+ }
166
258
  /** Per-adapter defaults for `methods`/`attributes`; the legacy top-level keys override the built-ins. */
167
259
  function adapterDefaults(adapter, file) {
168
260
  const frontend = file.frontendMethods?.length ? file.frontendMethods : DEFAULT_FRONTEND_METHODS;
@@ -280,6 +372,7 @@ export function loadConfig(cli, cwd = process.cwd()) {
280
372
  throw new Error("No projects configured. Add a `projects:` list to flagrm.config.yaml, or pass --frontend and/or --backend.");
281
373
  }
282
374
  const tools = parseTools(file.tools);
375
+ const launchdarkly = parseLaunchDarkly(file.launchdarkly, cli.quiet);
283
376
  return {
284
377
  root: fileDir,
285
378
  file: configPath && fs.existsSync(configPath) ? configPath : undefined,
@@ -287,5 +380,6 @@ export function loadConfig(cli, cwd = process.cwd()) {
287
380
  projects,
288
381
  exclude: [...(file.exclude ?? []), ...(cli.exclude ?? [])],
289
382
  include: [...(file.include ?? []), ...(cli.include ?? [])],
383
+ ...(launchdarkly ? { launchdarkly } : {}),
290
384
  };
291
385
  }
@@ -11,7 +11,7 @@ import { changeFilter, changeFilterAt } from "./change-filter.js";
11
11
  import { findConfigFile, loadConfig, TOOL_IDS } from "./config.js";
12
12
  import { projectCommands } from "./config-snapshot.js";
13
13
  import { gitState, isAncestor } from "./git.js";
14
- import { installedSkillVersion, SKILLS, stopHookState, TOOL_SKILL_DIRS } from "./install.js";
14
+ import { installedSkillVersion, LEGACY_SKILLS, SKILLS, stopHookState, TOOL_SKILL_DIRS } from "./install.js";
15
15
  import { packageVersion } from "./package.js";
16
16
  import { resolveProjects } from "./registry.js";
17
17
  import { setupReport } from "./scope.js";
@@ -73,6 +73,11 @@ export async function runDoctor(cwd, options = {}) {
73
73
  }
74
74
  else
75
75
  add("ok", `${dir}: skills ${version}`);
76
+ for (const [legacy, skill] of Object.entries(LEGACY_SKILLS)) {
77
+ if (fs.existsSync(path.join(root, dir, legacy))) {
78
+ add("warn", `${dir}/${legacy}: replaced by ${skill} (run flagrm update)`);
79
+ }
80
+ }
76
81
  }
77
82
  const hook = tools.includes("claude-code") ? stopHookState(root) : undefined;
78
83
  if (hook === "current")
@@ -97,6 +102,14 @@ async function configChecks(cwd, configFile, options, add) {
97
102
  const config = loadConfig({ config: configFile }, cwd);
98
103
  root = config.root;
99
104
  projects = resolveProjects(config);
105
+ // Only `flagrm stale` needs the token, so a missing one warns: removals without LaunchDarkly still work.
106
+ // No request is made: doctor stays offline.
107
+ const ld = config.launchdarkly;
108
+ if (ld && !(options.env ?? process.env)[ld.tokenEnv]?.trim()) {
109
+ add("warn", `launchdarkly: ${ld.tokenEnv} is not set — flagrm stale can't read project "${ld.project}"`);
110
+ }
111
+ else if (ld)
112
+ add("ok", `launchdarkly: project "${ld.project}", token from ${ld.tokenEnv}`);
100
113
  }
101
114
  catch (err) {
102
115
  add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
@@ -4,7 +4,8 @@
4
4
  * files: for Claude Code the skills and the Stop hook, for GitHub Copilot the
5
5
  * skills and `/flagrm-*` prompt files, for Codex the skills and the AGENTS.md
6
6
  * pointer. Every step reports what it did; nothing the user wrote is
7
- * overwritten, and nothing is deleted.
7
+ * overwritten. The only deletion: `update` replaces a renamed skill that flagrm
8
+ * installed (see {@link LEGACY_SKILLS}).
8
9
  */
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
@@ -14,7 +15,13 @@ import { packageRoot, packageVersion } from "./package.js";
14
15
  import { runnerTestCommands, testResultsHint } from "./test-runner.js";
15
16
  import { saveTools } from "./tools.js";
16
17
  import { relativePath } from "./util.js";
17
- export const SKILLS = ["flagrm-remove", "flagrm-verify"];
18
+ export const SKILLS = ["flagrm-remove", "flagrm-review"];
19
+ /**
20
+ * Skills an earlier flagrm installed under another name, and the skill that
21
+ * replaces each. `update` deletes the old one (only when flagrm's stamp shows
22
+ * flagrm wrote it) and installs its replacement; `init` deletes nothing.
23
+ */
24
+ export const LEGACY_SKILLS = { "flagrm-verify": "flagrm-review" };
18
25
  /** Where each supported tool discovers repository skills. */
19
26
  export const TOOL_SKILL_DIRS = {
20
27
  "claude-code": ".claude/skills",
@@ -23,7 +30,7 @@ export const TOOL_SKILL_DIRS = {
23
30
  };
24
31
  /** Every tool's skills directory: Claude Code, GitHub Copilot, Codex. */
25
32
  export const AGENT_SKILL_DIRS = TOOL_IDS.map((t) => TOOL_SKILL_DIRS[t]);
26
- /** GitHub Copilot prompt files: `/flagrm-remove` and `/flagrm-verify` in Copilot Chat, each pointing at its skill. */
33
+ /** GitHub Copilot prompt files: `/flagrm-remove` and `/flagrm-review` in Copilot Chat, each pointing at its skill. */
27
34
  export const PROMPT_FILES = {
28
35
  "flagrm-remove": [
29
36
  "---",
@@ -35,21 +42,21 @@ export const PROMPT_FILES = {
35
42
  "Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flags named after this command.",
36
43
  "",
37
44
  ].join("\n"),
38
- "flagrm-verify": [
45
+ "flagrm-review": [
39
46
  "---",
40
- "description: Check a feature flag removal with flagrm and propose its commit message",
47
+ "description: Review a feature flag removal in a fresh session (flagrm verify, then the ON path and deleted tests)",
41
48
  "argument-hint: <flag>",
42
49
  "agent: agent",
43
50
  "---",
44
51
  "",
45
- "Follow `.github/skills/flagrm-verify/SKILL.md` for the feature flag named after this command.",
52
+ "Follow `.github/skills/flagrm-review/SKILL.md` for the feature flag named after this command.",
46
53
  "",
47
54
  ].join("\n"),
48
55
  };
49
56
  /**
50
57
  * The Stop hook command. npx runs the project's own flagrm (a dev dependency)
51
- * and falls back to a global install. `--no-install` matters: the `flagrm` name
52
- * on npm belongs to an unrelated package, and a hook must never download code.
58
+ * and falls back to a global install. `--no-install` matters: a hook must never
59
+ * download code, so without a local or global flagrm it does nothing.
53
60
  * No redirects or `||`: Claude Code runs hooks in PowerShell on Windows
54
61
  * without Git Bash, so the command must parse in both shells.
55
62
  */
@@ -62,8 +69,8 @@ export const AGENTS_BLOCK = [
62
69
  AGENTS_START,
63
70
  "## Feature flag removal (flagrm)",
64
71
  "",
65
- "To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To check a removal",
66
- "later, follow `.agents/skills/flagrm-verify/SKILL.md`.",
72
+ "To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To review a removal",
73
+ "in a new session, follow `.agents/skills/flagrm-review/SKILL.md`.",
67
74
  AGENTS_END,
68
75
  ].join("\n");
69
76
  const CONFIG_HEADER = [
@@ -144,6 +151,28 @@ export function writeConfigTemplate(cwd, tools) {
144
151
  export function installSkills(cwd, mode, version = packageVersion(), source = path.join(packageRoot(), "skills"), tools = TOOL_IDS) {
145
152
  const results = [];
146
153
  for (const dir of tools.map((t) => TOOL_SKILL_DIRS[t])) {
154
+ const replaced = new Set();
155
+ for (const [legacy, skill] of Object.entries(LEGACY_SKILLS)) {
156
+ const target = path.join(cwd, dir, legacy);
157
+ if (!fs.existsSync(target))
158
+ continue;
159
+ const step = relativePath(cwd, target);
160
+ if (mode === "init") {
161
+ results.push({ path: step, status: "skipped", note: `replaced by ${skill}: run flagrm update to delete it` });
162
+ }
163
+ else if (installedSkillVersion(target) === undefined) {
164
+ results.push({
165
+ path: step,
166
+ status: "skipped",
167
+ note: "no flagrm-version stamp, so not deleted: delete it by hand",
168
+ });
169
+ }
170
+ else {
171
+ fs.rmSync(target, { recursive: true, force: true });
172
+ results.push({ path: step, status: "deleted", note: `replaced by ${skill}` });
173
+ replaced.add(skill);
174
+ }
175
+ }
147
176
  for (const skill of SKILLS) {
148
177
  const target = path.join(cwd, dir, skill);
149
178
  const exists = fs.existsSync(target);
@@ -151,7 +180,7 @@ export function installSkills(cwd, mode, version = packageVersion(), source = pa
151
180
  results.push({ path: relativePath(cwd, target), status: "skipped" });
152
181
  continue;
153
182
  }
154
- if (mode === "update" && !exists)
183
+ if (mode === "update" && !exists && !replaced.has(skill))
155
184
  continue;
156
185
  const before = exists ? snapshot(target) : undefined;
157
186
  fs.rmSync(target, { recursive: true, force: true });
@@ -193,18 +222,41 @@ export function installedSkillVersion(skillDir) {
193
222
  return undefined;
194
223
  }
195
224
  }
196
- /** GitHub Copilot's `/flagrm-remove` and `/flagrm-verify` prompt files in `.github/prompts/`. */
225
+ /** GitHub Copilot's `/flagrm-remove` and `/flagrm-review` prompt files in `.github/prompts/`. */
197
226
  export function installPrompts(cwd, mode) {
198
227
  const results = [];
228
+ const prompt = (skill) => path.join(cwd, ".github", "prompts", `${skill}.prompt.md`);
229
+ const replaced = new Set();
230
+ for (const [legacy, skill] of Object.entries(LEGACY_SKILLS)) {
231
+ const file = prompt(legacy);
232
+ if (!fs.existsSync(file))
233
+ continue;
234
+ const step = relativePath(cwd, file);
235
+ if (mode === "init") {
236
+ results.push({ path: step, status: "skipped", note: `replaced by ${skill}: run flagrm update to delete it` });
237
+ }
238
+ else if (!fs.readFileSync(file, "utf8").includes(`.github/skills/${legacy}/SKILL.md`)) {
239
+ results.push({
240
+ path: step,
241
+ status: "skipped",
242
+ note: `doesn't point at flagrm's ${legacy} skill, so not deleted`,
243
+ });
244
+ }
245
+ else {
246
+ fs.rmSync(file);
247
+ results.push({ path: step, status: "deleted", note: `replaced by ${skill}` });
248
+ replaced.add(skill);
249
+ }
250
+ }
199
251
  for (const skill of SKILLS) {
200
- const file = path.join(cwd, ".github", "prompts", `${skill}.prompt.md`);
252
+ const file = prompt(skill);
201
253
  const step = relativePath(cwd, file);
202
254
  const exists = fs.existsSync(file);
203
255
  if (mode === "init" && exists) {
204
256
  results.push({ path: step, status: "skipped" });
205
257
  continue;
206
258
  }
207
- if (mode === "update" && !exists)
259
+ if (mode === "update" && !exists && !replaced.has(skill))
208
260
  continue;
209
261
  const before = exists ? fs.readFileSync(file, "utf8") : undefined;
210
262
  if (before === PROMPT_FILES[skill]) {
@@ -372,7 +424,10 @@ export function initProject(root, tools = TOOL_IDS) {
372
424
  ...deselectedSteps(root, tools),
373
425
  ];
374
426
  }
375
- /** `flagrm update`: refresh what `init` installed in `root` for `tools` from this flagrm version; adds nothing new. */
427
+ /**
428
+ * `flagrm update`: refresh what `init` installed in `root` for `tools` from this
429
+ * flagrm version. Adds nothing new, except the replacement of a renamed skill it deletes.
430
+ */
376
431
  export function updateProject(root, tools = TOOL_IDS) {
377
432
  return [...tools.flatMap((t) => toolSteps(root, t, "update")), ...deselectedSteps(root, tools)];
378
433
  }
@@ -68,3 +68,60 @@ export function verifyMarkdown(report, root) {
68
68
  }
69
69
  return `${out.join("\n")}\n`;
70
70
  }
71
+ /** One environment in a few words: `production true 45d`, `staging targeted (1 rule(s))`. */
72
+ export function staleEnvironmentText(e) {
73
+ if (e.serves === "targeted")
74
+ return `${e.environment} targeted (${e.detail})`;
75
+ const days = e.days === undefined ? "" : ` ${e.days}d${e.sinceSource === "lastModified" ? "*" : ""}`;
76
+ return `${e.environment} ${JSON.stringify(e.value)}${days}`;
77
+ }
78
+ /** The conditions a report applied, in one line. */
79
+ export function staleConditionsText(report) {
80
+ const c = report.conditions;
81
+ const parts = [
82
+ `boolean, true in ${c.environments.join(", ") || "no environment"} for ${c.minDaysOn}+ days`,
83
+ ...(c.temporaryOnly ? ["temporary"] : []),
84
+ ...(c.tags.include.length ? [`tags ${c.tags.include.join(", ")}`] : []),
85
+ ...(c.tags.exclude.length ? [`not tagged ${c.tags.exclude.join(", ")}`] : []),
86
+ ];
87
+ return parts.join("; ");
88
+ }
89
+ const STALE_TITLES = {
90
+ remove: "Ready to remove",
91
+ keep: "Keep",
92
+ unreferenced: "Not referenced in this repository",
93
+ };
94
+ /** `flagrm stale --md`: the flags per verdict, for a pull request or a pipeline summary. */
95
+ export function staleMarkdown(report) {
96
+ const count = (v) => report.flags.filter((f) => f.verdict === v).length;
97
+ const out = [
98
+ `### flagrm stale: ${report.project} — ${count("remove")} ready to remove`,
99
+ "",
100
+ `_Conditions: ${staleConditionsText(report)}. Evaluated ${report.evaluatedAt}._`,
101
+ ];
102
+ const cell = (text) => text.replace(/\|/g, "\\|");
103
+ for (const verdict of ["remove", "keep"]) {
104
+ const flags = report.flags.filter((f) => f.verdict === verdict);
105
+ if (!flags.length)
106
+ continue;
107
+ const columns = ["Flag", "Code name", "Environments", ...(verdict === "keep" ? ["Reasons"] : [])];
108
+ out.push("", `**${STALE_TITLES[verdict]} (${flags.length})**`, "", `| ${columns.join(" | ")} |`, `|${"---|".repeat(columns.length)}`);
109
+ for (const f of flags) {
110
+ const cells = [
111
+ f.key ? `\`${f.key}\`` : "—",
112
+ f.codeName ? `\`${f.codeName}\`` : "—",
113
+ cell(f.environments.map(staleEnvironmentText).join(", ") || "—"),
114
+ ...(verdict === "keep" ? [cell(f.reasons.join("; "))] : []),
115
+ ];
116
+ out.push(`| ${cells.join(" | ")} |`);
117
+ }
118
+ }
119
+ const unreferenced = report.flags.filter((f) => f.verdict === "unreferenced").map((f) => f.key ?? "");
120
+ if (unreferenced.length) {
121
+ out.push("", `**${STALE_TITLES.unreferenced} (${unreferenced.length}):** ${codeList(unreferenced)}`, "", "_Another repository may still read these; flagrm doesn't recommend archiving them._");
122
+ }
123
+ if (report.flags.some((f) => f.environments.some((e) => e.sinceSource === "lastModified"))) {
124
+ out.push("", "_\\* days since the environment last changed: the audit log had no targeting change to date._");
125
+ }
126
+ return out.join("\n");
127
+ }
@@ -1,4 +1,5 @@
1
1
  import pc from "picocolors";
2
+ import { staleConditionsText, staleEnvironmentText } from "./markdown.js";
2
3
  import { reusedNote } from "./run-cache.js";
3
4
  import { plural, relativePath } from "./util.js";
4
5
  /** A path relative to the working directory, with `/` separators on every platform. */
@@ -136,6 +137,7 @@ const STEP_STYLE = {
136
137
  updated: pc.green,
137
138
  unchanged: pc.dim,
138
139
  skipped: pc.dim,
140
+ deleted: pc.yellow,
139
141
  };
140
142
  /** One line per `init`/`update` step: `created .claude/skills/flagrm-remove`. */
141
143
  export function printSteps(steps) {
@@ -189,3 +191,31 @@ export function printScope(setups, root, written) {
189
191
  console.log(pc.dim(" A solution filter can't help here: install the SDK or change global.json."));
190
192
  }
191
193
  }
194
+ const STALE_STYLE = {
195
+ remove: { title: "Ready to remove", color: pc.green },
196
+ keep: { title: "Keep", color: pc.yellow },
197
+ unreferenced: { title: "Not referenced in this repository (another may still read them)", color: pc.dim },
198
+ };
199
+ /** `flagrm stale`: the flags grouped by verdict, each with its environments and reasons. */
200
+ export function printStale(report) {
201
+ console.log(pc.bold(`LaunchDarkly project ${report.project}`) + pc.dim(` — ${staleConditionsText(report)}`));
202
+ for (const verdict of ["remove", "keep", "unreferenced"]) {
203
+ const flags = report.flags.filter((f) => f.verdict === verdict);
204
+ if (!flags.length)
205
+ continue;
206
+ const { title, color } = STALE_STYLE[verdict];
207
+ console.log(`\n${color(pc.bold(`${title} (${flags.length})`))}`);
208
+ for (const f of flags) {
209
+ const name = f.key && f.codeName && f.key !== f.codeName ? `${f.key} → ${f.codeName}` : (f.key ?? f.codeName);
210
+ const envs = f.environments.map(staleEnvironmentText).join(", ");
211
+ console.log(` ${name}${envs ? pc.dim(` ${envs}`) : ""}`);
212
+ for (const reason of verdict === "unreferenced" ? [] : f.reasons)
213
+ console.log(pc.dim(` ${reason}`));
214
+ }
215
+ }
216
+ if (report.flags.length === 0)
217
+ console.log(pc.dim("\nNo flags."));
218
+ if (report.flags.some((f) => f.environments.some((e) => e.sinceSource === "lastModified"))) {
219
+ console.log(pc.dim("\n* days since the environment last changed: the audit log had no targeting change to date."));
220
+ }
221
+ }