flagrm 0.4.1 → 1.0.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 +71 -2
- package/README.md +336 -104
- package/dist/cli.js +76 -12
- package/dist/core/change-filter.js +4 -1
- package/dist/core/config-snapshot.js +0 -4
- package/dist/core/config.js +119 -56
- package/dist/core/discover.js +1 -1
- package/dist/core/doctor.js +8 -0
- package/dist/core/install.js +17 -13
- package/dist/core/markdown.js +57 -0
- package/dist/core/report.js +29 -0
- package/dist/core/sources/launchdarkly.js +331 -0
- package/dist/core/sources/types.js +13 -0
- package/dist/core/stale.js +218 -0
- package/dist/core/types.js +2 -0
- package/package.json +2 -2
- package/skills/flagrm-remove/SKILL.md +2 -2
- package/skills/flagrm-remove/batch.md +1 -1
- package/skills/flagrm-review/SKILL.md +49 -0
- package/skills/flagrm-verify/SKILL.md +0 -54
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { Command } from "commander";
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
5
|
import pc from "picocolors";
|
|
6
6
|
import { realProbes } from "./adapters/dotnet/setup.js";
|
|
7
7
|
import { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, mergeNames, parseAccept, validateAgentNames, writeBaseline, } from "./core/baseline.js";
|
|
@@ -13,14 +13,17 @@ import { blobIds, gitState } from "./core/git.js";
|
|
|
13
13
|
import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
|
|
14
14
|
import { initProject, installRoot, updateProject } from "./core/install.js";
|
|
15
15
|
import { buildInventory } from "./core/inventory.js";
|
|
16
|
-
import { verifyMarkdown } from "./core/markdown.js";
|
|
16
|
+
import { staleMarkdown, verifyMarkdown } from "./core/markdown.js";
|
|
17
17
|
import { assertOwnNames } from "./core/name-owner.js";
|
|
18
18
|
import { packageVersion } from "./core/package.js";
|
|
19
19
|
import { resolveProjects } from "./core/registry.js";
|
|
20
|
-
import { printBaseline, printDoctor, printInventory, printScope, printSteps, printVerifyReport, } from "./core/report.js";
|
|
20
|
+
import { printBaseline, printDoctor, printInventory, printScope, printStale, printSteps, printVerifyReport, } from "./core/report.js";
|
|
21
21
|
import { baselineRuns } from "./core/run-cache.js";
|
|
22
22
|
import { setupReport, writeScope } from "./core/scope.js";
|
|
23
23
|
import { withScopeFiles } from "./core/scope-files.js";
|
|
24
|
+
import { LaunchDarklySource } from "./core/sources/launchdarkly.js";
|
|
25
|
+
import { SourceError } from "./core/sources/types.js";
|
|
26
|
+
import { evaluateStale, needsHistory } from "./core/stale.js";
|
|
24
27
|
import { askTools, detectTools, savedTools, TOOL_LABELS } from "./core/tools.js";
|
|
25
28
|
import { CHECK_IDS } from "./core/types.js";
|
|
26
29
|
import { onPath, relativePath } from "./core/util.js";
|
|
@@ -29,8 +32,11 @@ const program = new Command();
|
|
|
29
32
|
program
|
|
30
33
|
.name("flagrm")
|
|
31
34
|
.description("Guardrails for removing feature flags with an AI coding agent: set up the skills and hook (init, update, " +
|
|
32
|
-
"doctor), find flags (list)
|
|
33
|
-
|
|
35
|
+
"doctor), find flags (list) and those LaunchDarkly says are ready to remove (stale), record the state before a " +
|
|
36
|
+
"removal (baseline) and gate the result (verify).")
|
|
37
|
+
.version(packageVersion())
|
|
38
|
+
// Throw instead of exiting, so a usage error commander finds (unknown option, missing argument) exits 2 too.
|
|
39
|
+
.exitOverride();
|
|
34
40
|
/** Accumulate repeatable options (`--exclude a --exclude b`) into an array. */
|
|
35
41
|
function collect(value, previous) {
|
|
36
42
|
return previous.concat([value]);
|
|
@@ -42,8 +48,6 @@ function addGlobOptions(cmd) {
|
|
|
42
48
|
}
|
|
43
49
|
function addCommonOptions(cmd) {
|
|
44
50
|
return addGlobOptions(cmd
|
|
45
|
-
.option("--frontend <path>", "path to an Angular project (adds or replaces the project named 'frontend')")
|
|
46
|
-
.option("--backend <path>", "path to a .NET project (adds or replaces the project named 'backend')")
|
|
47
51
|
.option("--config <path>", "path to a config file (default: the nearest flagrm.config.yaml up to the git repository root)")
|
|
48
52
|
.option("--json", "machine-readable JSON output"));
|
|
49
53
|
}
|
|
@@ -206,6 +210,57 @@ addCommonOptions(program
|
|
|
206
210
|
printInventory(report);
|
|
207
211
|
}
|
|
208
212
|
});
|
|
213
|
+
// --- stale -------------------------------------------------------------------
|
|
214
|
+
addCommonOptions(program
|
|
215
|
+
.command("stale [flags...]")
|
|
216
|
+
.description("ask LaunchDarkly (the config's launchdarkly: section, read-only) which flags are ready to remove from code: " +
|
|
217
|
+
"remove (meets the conditions, in this repository's code), unreferenced (not in this repository) or keep " +
|
|
218
|
+
"(with reasons); flags are LaunchDarkly keys or code names, default all; " +
|
|
219
|
+
"exit 0 evaluated, 2 usage error, 3 LaunchDarkly unreachable or refused the request (token, access, project key)"))
|
|
220
|
+
.option("--md", "print a markdown summary (for a pull request or pipeline summary) instead of text")
|
|
221
|
+
.action(async (flags, opts) => {
|
|
222
|
+
if (opts.md && opts.json)
|
|
223
|
+
fail(new Error("--md and --json can't be combined."));
|
|
224
|
+
let config;
|
|
225
|
+
let projects;
|
|
226
|
+
try {
|
|
227
|
+
config = loadConfig(opts);
|
|
228
|
+
projects = resolveProjects(config);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
fail(err);
|
|
232
|
+
}
|
|
233
|
+
const ld = config.launchdarkly;
|
|
234
|
+
if (!ld) {
|
|
235
|
+
fail(new Error(`No launchdarkly: section in ${config.file ?? "the config"}. Add one with your LaunchDarkly project key:\n` +
|
|
236
|
+
" launchdarkly:\n project: <project key>\n" +
|
|
237
|
+
"and set LD_API_TOKEN to an API token (reader role is enough). See the README's LaunchDarkly section."));
|
|
238
|
+
}
|
|
239
|
+
// Checked before any request: nothing goes out without a token.
|
|
240
|
+
const token = process.env[ld.tokenEnv]?.trim();
|
|
241
|
+
if (!token)
|
|
242
|
+
fail(new Error(`${ld.tokenEnv} is not set: set it to a LaunchDarkly API token (reader role is enough).`));
|
|
243
|
+
const codeNames = (await buildInventory(projects)).flags.map((f) => f.flag);
|
|
244
|
+
let report;
|
|
245
|
+
try {
|
|
246
|
+
const read = await new LaunchDarklySource(ld, { token }).read((f) => needsHistory(f, ld, codeNames));
|
|
247
|
+
report = evaluateStale({ project: ld.project, read, codeNames, config: ld, now: new Date(), only: flags });
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
if (!(err instanceof SourceError))
|
|
251
|
+
fail(err);
|
|
252
|
+
console.error(pc.red(err.message));
|
|
253
|
+
process.exitCode = 3;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (opts.json)
|
|
257
|
+
console.log(JSON.stringify(report, null, 2));
|
|
258
|
+
else if (opts.md)
|
|
259
|
+
process.stdout.write(`${staleMarkdown(report)}\n`);
|
|
260
|
+
else
|
|
261
|
+
printStale(report);
|
|
262
|
+
process.exitCode = 0;
|
|
263
|
+
});
|
|
209
264
|
// --- hook --------------------------------------------------------------------
|
|
210
265
|
const hook = program.command("hook").description("entry points for agent hooks (installed by flagrm init)");
|
|
211
266
|
hook
|
|
@@ -327,9 +382,18 @@ addCommonOptions(program
|
|
|
327
382
|
process.exitCode = missing ? 1 : 0;
|
|
328
383
|
});
|
|
329
384
|
// exitCode, not exit(): exit() can cut off output still being flushed to a pipe.
|
|
330
|
-
|
|
331
|
-
|
|
385
|
+
try {
|
|
386
|
+
await program.parseAsync();
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
if (err instanceof CommanderError) {
|
|
390
|
+
// Commander printed the message already; --help and --version end with 0.
|
|
391
|
+
process.exitCode = err.exitCode === 0 ? 0 : 2;
|
|
392
|
+
}
|
|
393
|
+
else if (err instanceof CliError) {
|
|
394
|
+
console.error(pc.red(err.message));
|
|
395
|
+
process.exitCode = 2;
|
|
396
|
+
}
|
|
397
|
+
else
|
|
332
398
|
throw err;
|
|
333
|
-
|
|
334
|
-
process.exitCode = 2;
|
|
335
|
-
});
|
|
399
|
+
}
|
|
@@ -9,7 +9,10 @@ import picomatch from "picomatch";
|
|
|
9
9
|
import { CONFIG_FILENAMES, loadConfig } from "./config.js";
|
|
10
10
|
import { AGENT_SKILL_DIRS, SKILLS } from "./install.js";
|
|
11
11
|
import { relativePath } from "./util.js";
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The files `flagrm init`, `update` and `scope --write` write, relative to the
|
|
14
|
+
* config root.
|
|
15
|
+
*/
|
|
13
16
|
export const SETUP_PATTERNS = [
|
|
14
17
|
...CONFIG_FILENAMES,
|
|
15
18
|
".gitignore",
|
|
@@ -193,10 +193,6 @@ function withoutCommands(text) {
|
|
|
193
193
|
delete config.tools;
|
|
194
194
|
if (Array.isArray(config.projects))
|
|
195
195
|
config.projects = config.projects.map(strip);
|
|
196
|
-
for (const legacy of ["frontend", "backend"]) {
|
|
197
|
-
if (config[legacy] && typeof config[legacy] === "object")
|
|
198
|
-
config[legacy] = strip(config[legacy]);
|
|
199
|
-
}
|
|
200
196
|
return JSON.stringify(config);
|
|
201
197
|
}
|
|
202
198
|
return JSON.stringify(parsed);
|
package/dist/core/config.js
CHANGED
|
@@ -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",
|
|
@@ -31,16 +33,16 @@ export const DEFAULT_BACKEND_METHODS = [
|
|
|
31
33
|
export const DEFAULT_BACKEND_ATTRIBUTES = ["FeatureGate", "Feature"];
|
|
32
34
|
export const CONFIG_FILENAMES = ["flagrm.config.yaml", "flagrm.config.yml", "flagrm.config.json"];
|
|
33
35
|
/** Known top-level `flagrm.config.yaml` keys — anything else is likely a typo. */
|
|
34
|
-
const KNOWN_CONFIG_KEYS = [
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
36
|
+
const KNOWN_CONFIG_KEYS = ["projects", "tools", "exclude", "include", "launchdarkly"];
|
|
37
|
+
const KNOWN_LAUNCHDARKLY_KEYS = [
|
|
38
|
+
"project",
|
|
39
|
+
"baseUrl",
|
|
40
|
+
"tokenEnv",
|
|
41
|
+
"environments",
|
|
42
|
+
"minDaysOn",
|
|
43
|
+
"temporaryOnly",
|
|
44
|
+
"tags",
|
|
45
|
+
"map",
|
|
44
46
|
];
|
|
45
47
|
const KNOWN_PROJECT_KEYS = [
|
|
46
48
|
"name",
|
|
@@ -56,6 +58,14 @@ const KNOWN_PROJECT_KEYS = [
|
|
|
56
58
|
"methods",
|
|
57
59
|
"attributes",
|
|
58
60
|
];
|
|
61
|
+
/** Top-level keys removed in 1.0, with what replaces them. */
|
|
62
|
+
const REMOVED_CONFIG_KEYS = {
|
|
63
|
+
frontend: "a `projects:` entry with `adapter: angular`",
|
|
64
|
+
backend: "a `projects:` entry with `adapter: dotnet`",
|
|
65
|
+
frontendMethods: "`methods:` on the angular project",
|
|
66
|
+
backendMethods: "`methods:` on the dotnet project",
|
|
67
|
+
backendAttributes: "`attributes:` on the dotnet project",
|
|
68
|
+
};
|
|
59
69
|
const PROJECT_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
|
60
70
|
function readConfigFile(configPath) {
|
|
61
71
|
const raw = fs.readFileSync(configPath, "utf8");
|
|
@@ -64,10 +74,12 @@ function readConfigFile(configPath) {
|
|
|
64
74
|
throw new Error(`Config file ${configPath} does not contain an object.`);
|
|
65
75
|
}
|
|
66
76
|
const file = parsed;
|
|
77
|
+
for (const [key, replacement] of Object.entries(REMOVED_CONFIG_KEYS)) {
|
|
78
|
+
if (key in file)
|
|
79
|
+
throw new Error(`${configPath}: "${key}" was removed in flagrm 1.0; use ${replacement}.`);
|
|
80
|
+
}
|
|
67
81
|
// Checked here so a YAML string (`exclude: "**/gen/**"`) fails instead of being spread into characters.
|
|
68
|
-
for (const key of ["
|
|
69
|
-
optionalString(file[key], key);
|
|
70
|
-
for (const key of ["frontendMethods", "backendMethods", "backendAttributes", "exclude", "include"]) {
|
|
82
|
+
for (const key of ["exclude", "include"]) {
|
|
71
83
|
optionalStringList(file[key], key);
|
|
72
84
|
}
|
|
73
85
|
return file;
|
|
@@ -155,6 +167,32 @@ function optionalPositiveNumber(value, where) {
|
|
|
155
167
|
}
|
|
156
168
|
return value;
|
|
157
169
|
}
|
|
170
|
+
function optionalBoolean(value, where) {
|
|
171
|
+
if (value === undefined)
|
|
172
|
+
return undefined;
|
|
173
|
+
if (typeof value !== "boolean")
|
|
174
|
+
throw new Error(`${where} must be true or false.`);
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
function optionalPositiveDays(value, where) {
|
|
178
|
+
if (value === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
181
|
+
throw new Error(`${where} must be a positive whole number of days.`);
|
|
182
|
+
}
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
function isObject(value) {
|
|
186
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
187
|
+
}
|
|
188
|
+
function optionalStringMap(value, where) {
|
|
189
|
+
if (value === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (!isObject(value) || !Object.values(value).every((v) => typeof v === "string" && v.trim())) {
|
|
192
|
+
throw new Error(`${where} must map LaunchDarkly keys to code names (strings).`);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
158
196
|
function optionalStringList(value, where) {
|
|
159
197
|
if (value === undefined)
|
|
160
198
|
return undefined;
|
|
@@ -163,18 +201,71 @@ function optionalStringList(value, where) {
|
|
|
163
201
|
}
|
|
164
202
|
return value;
|
|
165
203
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
204
|
+
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
205
|
+
/** The `launchdarkly:` section with defaults applied; undefined when absent. */
|
|
206
|
+
function parseLaunchDarkly(raw, quiet = false) {
|
|
207
|
+
if (raw === undefined || raw === null)
|
|
208
|
+
return undefined;
|
|
209
|
+
const where = "launchdarkly";
|
|
210
|
+
if (!isObject(raw))
|
|
211
|
+
throw new Error(`${where} must be an object with at least a "project".`);
|
|
212
|
+
for (const key of ["token", "apiKey"]) {
|
|
213
|
+
if (key in raw) {
|
|
214
|
+
throw new Error(`${where}.${key}: never put the API token in the config file. Remove it and set the environment ` +
|
|
215
|
+
`variable named by ${where}.tokenEnv (default ${DEFAULT_LAUNCHDARKLY_TOKEN_ENV}) instead.`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!quiet)
|
|
219
|
+
warnOnUnknownKeys(raw, KNOWN_LAUNCHDARKLY_KEYS, `"${where}"`);
|
|
220
|
+
const project = optionalString(raw.project, `${where}.project`);
|
|
221
|
+
if (!project)
|
|
222
|
+
throw new Error(`${where}.project is required: the LaunchDarkly project key.`);
|
|
223
|
+
const baseUrl = (optionalString(raw.baseUrl, `${where}.baseUrl`) ?? DEFAULT_LAUNCHDARKLY_BASE_URL).replace(/\/+$/, "");
|
|
224
|
+
let url;
|
|
225
|
+
try {
|
|
226
|
+
url = new URL(baseUrl);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new Error(`${where}.baseUrl is not a URL: ${baseUrl}`);
|
|
230
|
+
}
|
|
231
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname))) {
|
|
232
|
+
throw new Error(`${where}.baseUrl must use https: the API token is sent to it.`);
|
|
233
|
+
}
|
|
234
|
+
if (raw.tags !== undefined && !isObject(raw.tags)) {
|
|
235
|
+
throw new Error(`${where}.tags must be an object with "include" and/or "exclude" lists.`);
|
|
236
|
+
}
|
|
237
|
+
const tags = (raw.tags ?? {});
|
|
238
|
+
if (!quiet)
|
|
239
|
+
warnOnUnknownKeys(tags, ["include", "exclude"], `"${where}.tags"`);
|
|
240
|
+
const environments = optionalStringList(raw.environments, `${where}.environments`);
|
|
241
|
+
if (environments?.length === 0)
|
|
242
|
+
throw new Error(`${where}.environments must name at least one environment.`);
|
|
243
|
+
return {
|
|
244
|
+
project,
|
|
245
|
+
baseUrl,
|
|
246
|
+
tokenEnv: optionalString(raw.tokenEnv, `${where}.tokenEnv`) ?? DEFAULT_LAUNCHDARKLY_TOKEN_ENV,
|
|
247
|
+
...(environments ? { environments } : {}),
|
|
248
|
+
minDaysOn: optionalPositiveDays(raw.minDaysOn, `${where}.minDaysOn`) ?? 30,
|
|
249
|
+
temporaryOnly: optionalBoolean(raw.temporaryOnly, `${where}.temporaryOnly`) ?? true,
|
|
250
|
+
tags: {
|
|
251
|
+
include: optionalStringList(tags.include, `${where}.tags.include`) ?? [],
|
|
252
|
+
exclude: optionalStringList(tags.exclude, `${where}.tags.exclude`) ?? [],
|
|
253
|
+
},
|
|
254
|
+
map: optionalStringMap(raw.map, `${where}.map`) ?? {},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Per-adapter defaults for `methods`/`attributes`. */
|
|
258
|
+
function adapterDefaults(adapter) {
|
|
171
259
|
if (adapter === "angular")
|
|
172
|
-
return { methods:
|
|
260
|
+
return { methods: DEFAULT_FRONTEND_METHODS, attributes: [] };
|
|
173
261
|
if (adapter === "dotnet")
|
|
174
|
-
return { methods:
|
|
175
|
-
return {
|
|
262
|
+
return { methods: DEFAULT_BACKEND_METHODS, attributes: DEFAULT_BACKEND_ATTRIBUTES };
|
|
263
|
+
return {
|
|
264
|
+
methods: [...new Set([...DEFAULT_FRONTEND_METHODS, ...DEFAULT_BACKEND_METHODS])],
|
|
265
|
+
attributes: DEFAULT_BACKEND_ATTRIBUTES,
|
|
266
|
+
};
|
|
176
267
|
}
|
|
177
|
-
function parseProject(raw, index,
|
|
268
|
+
function parseProject(raw, index, fileDir, quiet = false) {
|
|
178
269
|
const where = `projects[${index}]`;
|
|
179
270
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
180
271
|
throw new Error(`${where} must be an object with name, adapter and path.`);
|
|
@@ -194,7 +285,7 @@ function parseProject(raw, index, file, fileDir, quiet = false) {
|
|
|
194
285
|
const adapter = p.adapter;
|
|
195
286
|
if (typeof p.path !== "string" || !p.path)
|
|
196
287
|
throw new Error(`${where} ("${name}") is missing a "path".`);
|
|
197
|
-
const defaults = adapterDefaults(adapter
|
|
288
|
+
const defaults = adapterDefaults(adapter);
|
|
198
289
|
const testResults = optionalString(p.testResults, `${where}.testResults`);
|
|
199
290
|
const project = {
|
|
200
291
|
name,
|
|
@@ -220,10 +311,7 @@ function parseProject(raw, index, file, fileDir, quiet = false) {
|
|
|
220
311
|
/**
|
|
221
312
|
* Merge CLI options with an optional config file and validate everything.
|
|
222
313
|
*
|
|
223
|
-
* Projects come from
|
|
224
|
-
* which become an `angular` project named `frontend` and a `dotnet` project
|
|
225
|
-
* named `backend`. `--frontend`/`--backend` on the CLI replace the project of
|
|
226
|
-
* that name (or add it).
|
|
314
|
+
* Projects come from the config file's `projects:` list.
|
|
227
315
|
*/
|
|
228
316
|
export function loadConfig(cli, cwd = process.cwd()) {
|
|
229
317
|
let file = {};
|
|
@@ -242,44 +330,18 @@ export function loadConfig(cli, cwd = process.cwd()) {
|
|
|
242
330
|
if (file.projects !== undefined && !Array.isArray(file.projects)) {
|
|
243
331
|
throw new Error(`"projects" must be a list of { name, adapter, path } entries.`);
|
|
244
332
|
}
|
|
245
|
-
const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i,
|
|
246
|
-
const legacyProject = (name, adapter, dir) => ({
|
|
247
|
-
name,
|
|
248
|
-
adapter,
|
|
249
|
-
path: dir,
|
|
250
|
-
...adapterDefaults(adapter, file),
|
|
251
|
-
});
|
|
252
|
-
if (file.frontend) {
|
|
253
|
-
projects.push(legacyProject("frontend", "angular", resolveProjectDir(fileDir, file.frontend, "Frontend")));
|
|
254
|
-
}
|
|
255
|
-
if (file.backend) {
|
|
256
|
-
projects.push(legacyProject("backend", "dotnet", resolveProjectDir(fileDir, file.backend, "Backend")));
|
|
257
|
-
}
|
|
333
|
+
const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i, fileDir, cli.quiet));
|
|
258
334
|
const seen = new Set();
|
|
259
335
|
for (const p of projects) {
|
|
260
336
|
if (seen.has(p.name))
|
|
261
337
|
throw new Error(`Duplicate project name "${p.name}" in ${configPath ?? "config"}.`);
|
|
262
338
|
seen.add(p.name);
|
|
263
339
|
}
|
|
264
|
-
// CLI shortcuts replace a same-named project (keeping its position) or append one.
|
|
265
|
-
for (const [name, adapter, raw] of [
|
|
266
|
-
["frontend", "angular", cli.frontend],
|
|
267
|
-
["backend", "dotnet", cli.backend],
|
|
268
|
-
]) {
|
|
269
|
-
if (!raw)
|
|
270
|
-
continue;
|
|
271
|
-
const label = name === "frontend" ? "Frontend" : "Backend";
|
|
272
|
-
const project = legacyProject(name, adapter, resolveProjectDir(cwd, raw, label));
|
|
273
|
-
const existing = projects.findIndex((p) => p.name === name);
|
|
274
|
-
if (existing === -1)
|
|
275
|
-
projects.push(project);
|
|
276
|
-
else
|
|
277
|
-
projects[existing] = project;
|
|
278
|
-
}
|
|
279
340
|
if (projects.length === 0) {
|
|
280
|
-
throw new Error("No projects configured. Add a `projects:` list to flagrm.config.yaml
|
|
341
|
+
throw new Error("No projects configured. Add a `projects:` list to flagrm.config.yaml (`flagrm init` writes one).");
|
|
281
342
|
}
|
|
282
343
|
const tools = parseTools(file.tools);
|
|
344
|
+
const launchdarkly = parseLaunchDarkly(file.launchdarkly, cli.quiet);
|
|
283
345
|
return {
|
|
284
346
|
root: fileDir,
|
|
285
347
|
file: configPath && fs.existsSync(configPath) ? configPath : undefined,
|
|
@@ -287,5 +349,6 @@ export function loadConfig(cli, cwd = process.cwd()) {
|
|
|
287
349
|
projects,
|
|
288
350
|
exclude: [...(file.exclude ?? []), ...(cli.exclude ?? [])],
|
|
289
351
|
include: [...(file.include ?? []), ...(cli.include ?? [])],
|
|
352
|
+
...(launchdarkly ? { launchdarkly } : {}),
|
|
290
353
|
};
|
|
291
354
|
}
|
package/dist/core/discover.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* This is a UX aid: false negatives (a flag it misses) are fine — the agent
|
|
5
5
|
* can still remove a flag by name. False positives are mildly annoying but
|
|
6
6
|
* harmless since nothing is written. So the heuristics here are intentionally
|
|
7
|
-
*
|
|
7
|
+
* loose.
|
|
8
8
|
*/
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { computeCodeMask, computeCommentMask, computeLexicalMask, escapeRegExp, LEX_COMMENT, LEX_STRING, methodNamePattern, } from "./scan.js";
|
package/dist/core/doctor.js
CHANGED
|
@@ -97,6 +97,14 @@ async function configChecks(cwd, configFile, options, add) {
|
|
|
97
97
|
const config = loadConfig({ config: configFile }, cwd);
|
|
98
98
|
root = config.root;
|
|
99
99
|
projects = resolveProjects(config);
|
|
100
|
+
// Only `flagrm stale` needs the token, so a missing one warns: removals without LaunchDarkly still work.
|
|
101
|
+
// No request is made: doctor stays offline.
|
|
102
|
+
const ld = config.launchdarkly;
|
|
103
|
+
if (ld && !(options.env ?? process.env)[ld.tokenEnv]?.trim()) {
|
|
104
|
+
add("warn", `launchdarkly: ${ld.tokenEnv} is not set — flagrm stale can't read project "${ld.project}"`);
|
|
105
|
+
}
|
|
106
|
+
else if (ld)
|
|
107
|
+
add("ok", `launchdarkly: project "${ld.project}", token from ${ld.tokenEnv}`);
|
|
100
108
|
}
|
|
101
109
|
catch (err) {
|
|
102
110
|
add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
|
package/dist/core/install.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
|
7
|
+
* overwritten or deleted.
|
|
8
8
|
*/
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
@@ -14,7 +14,7 @@ import { packageRoot, packageVersion } from "./package.js";
|
|
|
14
14
|
import { runnerTestCommands, testResultsHint } from "./test-runner.js";
|
|
15
15
|
import { saveTools } from "./tools.js";
|
|
16
16
|
import { relativePath } from "./util.js";
|
|
17
|
-
export const SKILLS = ["flagrm-remove", "flagrm-
|
|
17
|
+
export const SKILLS = ["flagrm-remove", "flagrm-review"];
|
|
18
18
|
/** Where each supported tool discovers repository skills. */
|
|
19
19
|
export const TOOL_SKILL_DIRS = {
|
|
20
20
|
"claude-code": ".claude/skills",
|
|
@@ -23,7 +23,7 @@ export const TOOL_SKILL_DIRS = {
|
|
|
23
23
|
};
|
|
24
24
|
/** Every tool's skills directory: Claude Code, GitHub Copilot, Codex. */
|
|
25
25
|
export const AGENT_SKILL_DIRS = TOOL_IDS.map((t) => TOOL_SKILL_DIRS[t]);
|
|
26
|
-
/** GitHub Copilot prompt files: `/flagrm-remove` and `/flagrm-
|
|
26
|
+
/** GitHub Copilot prompt files: `/flagrm-remove` and `/flagrm-review` in Copilot Chat, each pointing at its skill. */
|
|
27
27
|
export const PROMPT_FILES = {
|
|
28
28
|
"flagrm-remove": [
|
|
29
29
|
"---",
|
|
@@ -35,21 +35,21 @@ export const PROMPT_FILES = {
|
|
|
35
35
|
"Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flags named after this command.",
|
|
36
36
|
"",
|
|
37
37
|
].join("\n"),
|
|
38
|
-
"flagrm-
|
|
38
|
+
"flagrm-review": [
|
|
39
39
|
"---",
|
|
40
|
-
"description:
|
|
40
|
+
"description: Review a feature flag removal in a fresh session (flagrm verify, then the ON path and deleted tests)",
|
|
41
41
|
"argument-hint: <flag>",
|
|
42
42
|
"agent: agent",
|
|
43
43
|
"---",
|
|
44
44
|
"",
|
|
45
|
-
"Follow `.github/skills/flagrm-
|
|
45
|
+
"Follow `.github/skills/flagrm-review/SKILL.md` for the feature flag named after this command.",
|
|
46
46
|
"",
|
|
47
47
|
].join("\n"),
|
|
48
48
|
};
|
|
49
49
|
/**
|
|
50
50
|
* 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:
|
|
52
|
-
*
|
|
51
|
+
* and falls back to a global install. `--no-install` matters: a hook must never
|
|
52
|
+
* download code, so without a local or global flagrm it does nothing.
|
|
53
53
|
* No redirects or `||`: Claude Code runs hooks in PowerShell on Windows
|
|
54
54
|
* without Git Bash, so the command must parse in both shells.
|
|
55
55
|
*/
|
|
@@ -62,8 +62,8 @@ export const AGENTS_BLOCK = [
|
|
|
62
62
|
AGENTS_START,
|
|
63
63
|
"## Feature flag removal (flagrm)",
|
|
64
64
|
"",
|
|
65
|
-
"To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To
|
|
66
|
-
"
|
|
65
|
+
"To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To review a removal",
|
|
66
|
+
"in a new session, follow `.agents/skills/flagrm-review/SKILL.md`.",
|
|
67
67
|
AGENTS_END,
|
|
68
68
|
].join("\n");
|
|
69
69
|
const CONFIG_HEADER = [
|
|
@@ -193,11 +193,12 @@ export function installedSkillVersion(skillDir) {
|
|
|
193
193
|
return undefined;
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
-
/** GitHub Copilot's `/flagrm-remove` and `/flagrm-
|
|
196
|
+
/** GitHub Copilot's `/flagrm-remove` and `/flagrm-review` prompt files in `.github/prompts/`. */
|
|
197
197
|
export function installPrompts(cwd, mode) {
|
|
198
198
|
const results = [];
|
|
199
|
+
const prompt = (skill) => path.join(cwd, ".github", "prompts", `${skill}.prompt.md`);
|
|
199
200
|
for (const skill of SKILLS) {
|
|
200
|
-
const file =
|
|
201
|
+
const file = prompt(skill);
|
|
201
202
|
const step = relativePath(cwd, file);
|
|
202
203
|
const exists = fs.existsSync(file);
|
|
203
204
|
if (mode === "init" && exists) {
|
|
@@ -372,7 +373,10 @@ export function initProject(root, tools = TOOL_IDS) {
|
|
|
372
373
|
...deselectedSteps(root, tools),
|
|
373
374
|
];
|
|
374
375
|
}
|
|
375
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* `flagrm update`: refresh what `init` installed in `root` for `tools` from this
|
|
378
|
+
* flagrm version. Adds nothing new, except the replacement of a renamed skill it deletes.
|
|
379
|
+
*/
|
|
376
380
|
export function updateProject(root, tools = TOOL_IDS) {
|
|
377
381
|
return [...tools.flatMap((t) => toolSteps(root, t, "update")), ...deselectedSteps(root, tools)];
|
|
378
382
|
}
|
package/dist/core/markdown.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -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. */
|
|
@@ -189,3 +190,31 @@ export function printScope(setups, root, written) {
|
|
|
189
190
|
console.log(pc.dim(" A solution filter can't help here: install the SDK or change global.json."));
|
|
190
191
|
}
|
|
191
192
|
}
|
|
193
|
+
const STALE_STYLE = {
|
|
194
|
+
remove: { title: "Ready to remove", color: pc.green },
|
|
195
|
+
keep: { title: "Keep", color: pc.yellow },
|
|
196
|
+
unreferenced: { title: "Not referenced in this repository (another may still read them)", color: pc.dim },
|
|
197
|
+
};
|
|
198
|
+
/** `flagrm stale`: the flags grouped by verdict, each with its environments and reasons. */
|
|
199
|
+
export function printStale(report) {
|
|
200
|
+
console.log(pc.bold(`LaunchDarkly project ${report.project}`) + pc.dim(` — ${staleConditionsText(report)}`));
|
|
201
|
+
for (const verdict of ["remove", "keep", "unreferenced"]) {
|
|
202
|
+
const flags = report.flags.filter((f) => f.verdict === verdict);
|
|
203
|
+
if (!flags.length)
|
|
204
|
+
continue;
|
|
205
|
+
const { title, color } = STALE_STYLE[verdict];
|
|
206
|
+
console.log(`\n${color(pc.bold(`${title} (${flags.length})`))}`);
|
|
207
|
+
for (const f of flags) {
|
|
208
|
+
const name = f.key && f.codeName && f.key !== f.codeName ? `${f.key} → ${f.codeName}` : (f.key ?? f.codeName);
|
|
209
|
+
const envs = f.environments.map(staleEnvironmentText).join(", ");
|
|
210
|
+
console.log(` ${name}${envs ? pc.dim(` ${envs}`) : ""}`);
|
|
211
|
+
for (const reason of verdict === "unreferenced" ? [] : f.reasons)
|
|
212
|
+
console.log(pc.dim(` ${reason}`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (report.flags.length === 0)
|
|
216
|
+
console.log(pc.dim("\nNo flags."));
|
|
217
|
+
if (report.flags.some((f) => f.environments.some((e) => e.sinceSource === "lastModified"))) {
|
|
218
|
+
console.log(pc.dim("\n* days since the environment last changed: the audit log had no targeting change to date."));
|
|
219
|
+
}
|
|
220
|
+
}
|