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.
- package/CHANGELOG.md +48 -0
- package/README.md +320 -103
- package/dist/cli.js +58 -3
- package/dist/core/baseline.js +9 -4
- package/dist/core/change-filter.js +7 -4
- package/dist/core/config-snapshot.js +17 -0
- package/dist/core/config.js +94 -0
- package/dist/core/detect.js +2 -2
- package/dist/core/doctor.js +14 -1
- package/dist/core/git.js +11 -1
- package/dist/core/hook.js +13 -2
- package/dist/core/install.js +70 -15
- package/dist/core/markdown.js +57 -0
- package/dist/core/report.js +30 -0
- package/dist/core/run-cache.js +13 -3
- package/dist/core/scan.js +30 -3
- package/dist/core/scope.js +3 -3
- 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/test-runner.js +2 -2
- package/dist/core/types.js +2 -0
- package/dist/core/verify/test-results.js +2 -3
- package/package.json +4 -4
- 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
|
@@ -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,7 +32,8 @@ 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)
|
|
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).")
|
|
33
37
|
.version(packageVersion());
|
|
34
38
|
/** Accumulate repeatable options (`--exclude a --exclude b`) into an array. */
|
|
35
39
|
function collect(value, previous) {
|
|
@@ -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
|
package/dist/core/baseline.js
CHANGED
|
@@ -2,12 +2,12 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import fg from "fast-glob";
|
|
6
5
|
import { changeFilter } from "./change-filter.js";
|
|
7
6
|
import { configSnapshot, projectCommands } from "./config-snapshot.js";
|
|
8
7
|
import { environmentProblem } from "./environment.js";
|
|
9
8
|
import { changedFilesSince } from "./git.js";
|
|
10
9
|
import { buildInventory } from "./inventory.js";
|
|
10
|
+
import { globAbsolute } from "./scan.js";
|
|
11
11
|
import { withScopeFiles } from "./scope-files.js";
|
|
12
12
|
import { JSON_SCHEMA_VERSION, READABLE_BASELINE_VERSIONS, } from "./types.js";
|
|
13
13
|
import { readJson, relativePath } from "./util.js";
|
|
@@ -80,8 +80,15 @@ export function readBaseline(root, flag) {
|
|
|
80
80
|
throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
|
|
81
81
|
`Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
|
|
82
82
|
}
|
|
83
|
+
const sha = baseline.git?.sha;
|
|
84
|
+
if (sha != null && !COMMIT_ID.test(sha)) {
|
|
85
|
+
throw new UsageError(`${file} has git.sha "${sha}", which is not a git commit id. ` +
|
|
86
|
+
`Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
|
|
87
|
+
}
|
|
83
88
|
return { baseline, file };
|
|
84
89
|
}
|
|
90
|
+
/** A full SHA-1 or SHA-256 commit id, as `git rev-parse HEAD` prints it. */
|
|
91
|
+
const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
85
92
|
/**
|
|
86
93
|
* Run one shell command inside a project, streaming its output to `logFile`
|
|
87
94
|
* (no buffer limit, stdout and stderr interleaved as printed). A command that
|
|
@@ -312,9 +319,7 @@ export function editsSince(root, sha, testResults = [], ignore = changeFilter())
|
|
|
312
319
|
}
|
|
313
320
|
/** The files (relative to `root`) matching absolute paths or globs, such as `testResults` patterns. */
|
|
314
321
|
export function matchingFiles(root, patterns) {
|
|
315
|
-
return new Set(patterns.flatMap((pattern) =>
|
|
316
|
-
.sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
|
|
317
|
-
.map((file) => relativePath(root, file))));
|
|
322
|
+
return new Set(patterns.flatMap((pattern) => globAbsolute(pattern).map((file) => relativePath(root, file))));
|
|
318
323
|
}
|
|
319
324
|
/**
|
|
320
325
|
* `baseline --force` starts over only before the removal began: the working
|
|
@@ -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
|
-
/**
|
|
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",
|
|
@@ -42,7 +45,7 @@ export function changeFilter(config) {
|
|
|
42
45
|
};
|
|
43
46
|
}
|
|
44
47
|
/**
|
|
45
|
-
* Matches `exclude` the way scanning's
|
|
48
|
+
* Matches `exclude` the way scanning's tinyglobby `ignore` does: a pattern
|
|
46
49
|
* that matches a directory (`graphify-out`, `graphify-out/`) excludes
|
|
47
50
|
* everything under it, so test the path and each of its parent directories.
|
|
48
51
|
* `covers` reads the same way.
|
|
@@ -142,6 +142,23 @@ export function configChanges(before, now) {
|
|
|
142
142
|
}
|
|
143
143
|
return changes;
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether the config file under `root` changed since `before` in what is
|
|
147
|
+
* scanned or ignored (`exclude`, globs, paths), not just in its commands.
|
|
148
|
+
* False when `before` recorded no config file.
|
|
149
|
+
*/
|
|
150
|
+
export function scanConfigChanged(before, root) {
|
|
151
|
+
if (!before?.file)
|
|
152
|
+
return false;
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = fs.readFileSync(path.join(root, before.file.path), "utf8");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return withoutCommands(before.file.text) !== withoutCommands(text);
|
|
161
|
+
}
|
|
145
162
|
const quote = (text) => (text === undefined ? "(none)" : `\`${text}\``);
|
|
146
163
|
function changedFiles(a, b) {
|
|
147
164
|
const hashes = new Map(a.map((f) => [f.path, f.sha256]));
|
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",
|
|
@@ -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
|
}
|
package/dist/core/detect.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* saves the user from editing a template, and they check the result.
|
|
6
6
|
*/
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import
|
|
8
|
+
import { globSync } from "tinyglobby";
|
|
9
9
|
import { detectTestRunner } from "./test-runner.js";
|
|
10
10
|
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/bin/**", "**/obj/**", "**/dist/**"];
|
|
11
11
|
const depth = (file) => file.split("/").length;
|
|
@@ -32,7 +32,7 @@ function commonDir(files) {
|
|
|
32
32
|
return common.join("/") || ".";
|
|
33
33
|
}
|
|
34
34
|
export function detectProjects(cwd) {
|
|
35
|
-
const find = (pattern) =>
|
|
35
|
+
const find = (pattern) => globSync(pattern, { expandDirectories: false, cwd, deep: 3, ignore: IGNORE });
|
|
36
36
|
const found = [];
|
|
37
37
|
for (const marker of topmost(find("**/angular.json"))) {
|
|
38
38
|
const dir = path.posix.dirname(marker);
|
package/dist/core/doctor.js
CHANGED
|
@@ -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)}`);
|
package/dist/core/git.js
CHANGED
|
@@ -13,6 +13,8 @@ function git(cwd, ...args) {
|
|
|
13
13
|
const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
14
14
|
return { ok: result.status === 0, stdout: result.stdout ?? "" };
|
|
15
15
|
}
|
|
16
|
+
/** Whether git reads `rev` as a revision: one starting with `-` (`--output=<file>`) would be an option. */
|
|
17
|
+
const isRevision = (rev) => rev !== "" && !rev.startsWith("-");
|
|
16
18
|
/** NUL-separated paths (`-z`), which git never quotes or escapes, whatever the file name. */
|
|
17
19
|
const paths = (text) => text.split("\0").filter(Boolean);
|
|
18
20
|
/** Untracked (and not ignored) files under `cwd`. */
|
|
@@ -60,6 +62,8 @@ export function blobIds(cwd, files) {
|
|
|
60
62
|
* diff plus untracked files), without the ones `ignore` names.
|
|
61
63
|
*/
|
|
62
64
|
export function changedFilesSince(cwd, sha, ignore) {
|
|
65
|
+
if (!isRevision(sha))
|
|
66
|
+
return undefined;
|
|
63
67
|
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
64
68
|
if (!diff.ok)
|
|
65
69
|
return undefined;
|
|
@@ -72,6 +76,8 @@ export function changedFilesSince(cwd, sha, ignore) {
|
|
|
72
76
|
* Undefined when git can't tell.
|
|
73
77
|
*/
|
|
74
78
|
export function trackedChangesSince(cwd, sha, ignore) {
|
|
79
|
+
if (!isRevision(sha))
|
|
80
|
+
return undefined;
|
|
75
81
|
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
76
82
|
if (!diff.ok)
|
|
77
83
|
return undefined;
|
|
@@ -80,6 +86,8 @@ export function trackedChangesSince(cwd, sha, ignore) {
|
|
|
80
86
|
}
|
|
81
87
|
/** A file's content at `sha`, or undefined when it did not exist there. */
|
|
82
88
|
export function fileAt(cwd, sha, relativePath) {
|
|
89
|
+
if (!isRevision(sha))
|
|
90
|
+
return undefined;
|
|
83
91
|
const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
|
|
84
92
|
return result.ok ? result.stdout : undefined;
|
|
85
93
|
}
|
|
@@ -156,6 +164,8 @@ function dropIgnored(cwd, index, ignore) {
|
|
|
156
164
|
}
|
|
157
165
|
/** The tree id of `cwd` at commit `sha`. */
|
|
158
166
|
export function treeAt(cwd, sha) {
|
|
167
|
+
if (!isRevision(sha))
|
|
168
|
+
return undefined;
|
|
159
169
|
const prefix = repoPrefix(cwd);
|
|
160
170
|
if (prefix === undefined)
|
|
161
171
|
return undefined;
|
|
@@ -169,5 +179,5 @@ export function headSha(cwd) {
|
|
|
169
179
|
}
|
|
170
180
|
/** Whether `sha` is HEAD or one of its ancestors. */
|
|
171
181
|
export function isAncestor(cwd, sha) {
|
|
172
|
-
return git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
|
|
182
|
+
return isRevision(sha) && git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
|
|
173
183
|
}
|
package/dist/core/hook.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { changeFilterAt } from "./change-filter.js";
|
|
10
10
|
import { findConfigRoot } from "./config.js";
|
|
11
|
+
import { scanConfigChanged } from "./config-snapshot.js";
|
|
11
12
|
import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
|
|
12
13
|
import { readJson } from "./util.js";
|
|
13
14
|
/** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
|
|
@@ -38,7 +39,8 @@ export function hookRoot(cwd) {
|
|
|
38
39
|
* matches the current tree. In progress means HEAD is still the baseline
|
|
39
40
|
* commit and a file git tracks changed, other than flagrm's setup files and
|
|
40
41
|
* paths matching `exclude` (a removal paused on a question to the user, or a
|
|
41
|
-
* stray test result file, doesn't count),
|
|
42
|
+
* stray test result file, doesn't count), the config changed in what it
|
|
43
|
+
* scans or ignores, or the last verify skipped checks.
|
|
42
44
|
* The fingerprint leaves out the same paths, as verify's does. Once HEAD moves (a
|
|
43
45
|
* commit, a branch switch, a merge), that removal is no longer guarded.
|
|
44
46
|
*/
|
|
@@ -61,7 +63,7 @@ export function stopDecision(root, input) {
|
|
|
61
63
|
return { block: false };
|
|
62
64
|
const ignore = changeFilterAt(root);
|
|
63
65
|
const edited = (trackedChangesSince(root, head, ignore) ?? []).length > 0;
|
|
64
|
-
const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
|
|
66
|
+
const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir) || configChanged(flagDir, root));
|
|
65
67
|
if (started.length === 0)
|
|
66
68
|
return { block: false };
|
|
67
69
|
const current = treeFingerprint(root, ignore);
|
|
@@ -74,6 +76,15 @@ export function stopDecision(root, input) {
|
|
|
74
76
|
return { block: false };
|
|
75
77
|
}
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether the config changed since the baseline (or `--accept config`) in what
|
|
81
|
+
* is scanned or ignored: widening `exclude` would otherwise hide the removal's
|
|
82
|
+
* edits from the hook. A verify passing on this tree shows the change and allows.
|
|
83
|
+
*/
|
|
84
|
+
function configChanged(flagDir, root) {
|
|
85
|
+
const baseline = readJson(path.join(flagDir, "baseline.json"));
|
|
86
|
+
return scanConfigChanged(baseline?.acceptedConfig ?? baseline?.config, root);
|
|
87
|
+
}
|
|
77
88
|
function lastVerifySkipped(flagDir) {
|
|
78
89
|
const skipped = readJson(path.join(flagDir, "verify.json"))?.skipped;
|
|
79
90
|
return Array.isArray(skipped) && skipped.length > 0;
|
package/dist/core/install.js
CHANGED
|
@@ -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
|
|
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-
|
|
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-
|
|
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-
|
|
45
|
+
"flagrm-review": [
|
|
39
46
|
"---",
|
|
40
|
-
"description:
|
|
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-
|
|
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:
|
|
52
|
-
*
|
|
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
|
|
66
|
-
"
|
|
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-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
}
|