flagrm 0.1.1 → 0.2.1

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.
@@ -5,13 +5,17 @@
5
5
  */
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
- import { errorExcerpt, incrementalBuildGap, projectCommands, runCheck, templateCheckGap } from "./baseline.js";
9
- import { findConfigFile, loadConfig } from "./config.js";
8
+ import { realProbes } from "../adapters/dotnet/setup.js";
9
+ import { errorExcerpt, incrementalBuildGap, runCheck, templateCheckGap } from "./baseline.js";
10
+ import { findConfigFile, loadConfig, TOOL_IDS } from "./config.js";
11
+ import { projectCommands } from "./config-snapshot.js";
10
12
  import { gitState, isAncestor } from "./git.js";
11
- import { AGENT_SKILL_DIRS, installedSkillVersion, SKILLS, stopHookState } from "./install.js";
13
+ import { installedSkillVersion, SKILLS, stopHookState, TOOL_SKILL_DIRS } from "./install.js";
12
14
  import { packageVersion } from "./package.js";
13
15
  import { resolveProjects } from "./registry.js";
14
- import { readJson, relativePath } from "./util.js";
16
+ import { setupReport } from "./scope.js";
17
+ import { savedTools } from "./tools.js";
18
+ import { onPath, readJson, relativePath } from "./util.js";
15
19
  /** Check the setup in `cwd`: config and commands, git, .gitignore, skills, hook and abandoned baselines. */
16
20
  export async function runDoctor(cwd, options = {}) {
17
21
  const checks = [];
@@ -30,8 +34,16 @@ export async function runDoctor(cwd, options = {}) {
30
34
  const gitignore = path.join(root, ".gitignore");
31
35
  const ignored = fs.existsSync(gitignore) && /^\/?\.flagrm\/?\s*$/m.test(fs.readFileSync(gitignore, "utf8"));
32
36
  add(ignored ? "ok" : "warn", ignored ? ".flagrm/ is gitignored" : "add .flagrm/ to .gitignore (flagrm init does this)");
37
+ // Only the tools the config lists, when it lists them.
38
+ let tools = TOOL_IDS;
39
+ try {
40
+ tools = savedTools(root) ?? TOOL_IDS;
41
+ }
42
+ catch (err) {
43
+ add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
44
+ }
33
45
  const version = packageVersion();
34
- for (const dir of AGENT_SKILL_DIRS) {
46
+ for (const dir of tools.map((t) => TOOL_SKILL_DIRS[t])) {
35
47
  const versions = SKILLS.map((s) => installedSkillVersion(path.join(root, dir, s)));
36
48
  if (versions.every((v) => v === undefined))
37
49
  add("warn", `${dir}: skills not installed (run flagrm init)`);
@@ -42,12 +54,12 @@ export async function runDoctor(cwd, options = {}) {
42
54
  else
43
55
  add("ok", `${dir}: skills ${version}`);
44
56
  }
45
- const hook = stopHookState(root);
57
+ const hook = tools.includes("claude-code") ? stopHookState(root) : undefined;
46
58
  if (hook === "current")
47
59
  add("ok", "Claude Code Stop hook installed");
48
60
  else if (hook === "outdated")
49
61
  add("warn", "Claude Code Stop hook runs an outdated command (run flagrm update)");
50
- else
62
+ else if (hook === "missing")
51
63
  add("warn", "Claude Code Stop hook missing from .claude/settings.json (run flagrm init)");
52
64
  if (git.sha !== null)
53
65
  abandonedBaselines(root, add);
@@ -70,6 +82,7 @@ async function configChecks(cwd, configFile, options, add) {
70
82
  add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
71
83
  return;
72
84
  }
85
+ setupChecks(projects, root, options.probes ?? realProbes((exe) => onPath(exe, root)), add);
73
86
  for (const { adapter, ctx } of projects) {
74
87
  const commands = projectCommands(adapter, ctx);
75
88
  for (const check of ["build", "test"]) {
@@ -98,7 +111,23 @@ async function configChecks(cwd, configFile, options, add) {
98
111
  add("ok", `${ctx.name}: \`${command}\` passed`);
99
112
  continue;
100
113
  }
101
- add("fail", `${ctx.name}: \`${command}\` failed (exit ${run.exitCode}, log ${relativePath(cwd, run.log ?? "")})`, errorExcerpt(run.log, 3));
114
+ add("fail", `${ctx.name}: \`${command}\` failed (exit ${run.exitCode}, log ${relativePath(cwd, run.log ?? "")})`, run.failure ? [`setup problem: ${run.failure.message}`] : errorExcerpt(run.log, 3));
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Whether the machine can build and test each .NET project: no SDK matching
120
+ * global.json fails (nothing builds), a missing `cargo` or Docker warns (a
121
+ * solution filter can leave out the projects that need them).
122
+ */
123
+ function setupChecks(projects, root, probes, add) {
124
+ for (const s of setupReport(projects, root, probes)) {
125
+ if (s.sdk)
126
+ add("fail", `${s.project}: ${s.sdk}`);
127
+ const problems = s.setup?.problems ?? [];
128
+ if (problems.length) {
129
+ const out = s.setup?.leaveOut.length ?? 0;
130
+ add("warn", `${s.project}: ${problems.join("; ")} — \`flagrm scope\` shows the ${out} projects a solution filter would leave out`);
102
131
  }
103
132
  }
104
133
  }
@@ -116,15 +145,6 @@ export function simpleCommandProgram(command) {
116
145
  .split(/\s+/)
117
146
  .find((word) => !/^\w+=/.test(word));
118
147
  }
119
- function onPath(exe, cwd) {
120
- if (exe.includes("/") || exe.includes("\\"))
121
- return fs.existsSync(path.resolve(cwd, exe));
122
- const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
123
- return (process.env.PATH ?? "")
124
- .split(path.delimiter)
125
- .filter(Boolean)
126
- .some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, exe + ext))));
127
- }
128
148
  /** `.flagrm/<flag>/` entries whose baseline commit is not in this branch's history. */
129
149
  function abandonedBaselines(root, add) {
130
150
  const dir = path.join(root, ".flagrm");
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tell a machine setup problem apart from broken code in a failed build or
3
+ * test log: a missing .NET SDK, a tool a build step calls that isn't
4
+ * installed, Docker not running for Testcontainers. Each becomes one line
5
+ * that says what to fix.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { relativePath } from "./util.js";
10
+ /**
11
+ * The setup problems a log shows, as one actionable line, or undefined when
12
+ * it shows none. `root` is the project root: paths are reported relative to it.
13
+ */
14
+ export function environmentProblem(log, root) {
15
+ const problems = [missingSdk(log, root), ...missingCommands(log, root), dockerUnavailable(log)].filter(Boolean);
16
+ return problems.length ? problems.join("; ") : undefined;
17
+ }
18
+ /**
19
+ * `dotnet` exits 155 with "A compatible .NET SDK was not found" when no
20
+ * installed SDK satisfies global.json. It prints the requested version and the
21
+ * global.json path; the installed SDKs (`10.0.101 [/usr/local/share/dotnet/sdk]`)
22
+ * go to stdout, so in a combined log they may come after the message.
23
+ */
24
+ function missingSdk(log, root) {
25
+ if (!/A compatible \.NET SDK was not found/.test(log))
26
+ return undefined;
27
+ const requested = /^Requested SDK version:\s*(\S+)/m.exec(log)?.[1];
28
+ const globalJson = /^global\.json file:\s*(.+?)\s*$/m.exec(log)?.[1];
29
+ const installed = [...new Set([...log.matchAll(/^(\d+\.\d+\.\d+\S*) \[[^\]]*sdk\]\s*$/gm)].map((m) => m[1]))];
30
+ const rollForward = globalJson ? readRollForward(globalJson) : undefined;
31
+ const wanted = [requested ?? "the version", rollForward && `rollForward: ${rollForward}`].filter(Boolean).join(", ");
32
+ const where = globalJson ? relativePath(root, globalJson) : "global.json";
33
+ return (`.NET SDK not found: ${where} requests ${wanted}; installed: ${installed.join(", ") || "none"}. ` +
34
+ `Install that SDK or change ${where}`);
35
+ }
36
+ function readRollForward(file) {
37
+ try {
38
+ const value = JSON.parse(fs.readFileSync(file, "utf8"))?.sdk?.rollForward;
39
+ return typeof value === "string" ? value : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * Commands the shell couldn't find (exit 127; `9009` and "is not recognized"
47
+ * on Windows), with the MSBuild project whose `Exec` ran them when the log
48
+ * names it (`RustSdk.csproj(12,5): error MSB3073: The command "cargo build" exited with code 127`).
49
+ */
50
+ function missingCommands(log, root) {
51
+ const commands = new Map();
52
+ const add = (command, project) => {
53
+ const name = path.basename(command);
54
+ const projects = commands.get(name) ?? new Set();
55
+ if (project)
56
+ projects.add(relativePath(root, path.resolve(root, project.trim())));
57
+ commands.set(name, projects);
58
+ };
59
+ for (const m of log.matchAll(/\b(?:ba|z|da)?sh: (?:line \d+: |\d+: )?([^\s:]+): (?:command )?not found/g))
60
+ add(m[1]);
61
+ for (const m of log.matchAll(/\bcommand not found: (\S+)/g))
62
+ add(m[1]);
63
+ for (const m of log.matchAll(/'([^']+)' is not recognized as an internal or external command/g))
64
+ add(m[1]);
65
+ for (const m of log.matchAll(/^\s*(.+?\.\w*proj)\(\d+,\d+\): error MSB3073: The command "([^\s"]+)[^"]*" exited with code (?:127|9009)\b/gm)) {
66
+ add(m[2].replace(/^["']|["']$/g, ""), m[1]);
67
+ }
68
+ return [...commands].map(([command, projects]) => {
69
+ const neededBy = projects.size ? ` (needed by ${[...projects].sort().join(", ")})` : "";
70
+ return `\`${command}\` is not installed or not on PATH${neededBy}`;
71
+ });
72
+ }
73
+ /** Testcontainers throws `DockerUnavailableException`; the docker CLI says it can't reach the daemon. */
74
+ function dockerUnavailable(log) {
75
+ const docker = /DockerUnavailableException|Docker is either not running or misconfigured|Cannot connect to the Docker daemon/i;
76
+ if (!docker.test(log))
77
+ return undefined;
78
+ return "Docker is not running (Testcontainers tests need it): start Docker or leave those tests out of `test`";
79
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The words a flag's names are made of, to recognize a name that is about the
3
+ * flag (`useVfo1Terminology`, `Save_Vfo1Enabled`) among ones that aren't.
4
+ */
5
+ /** Words that say nothing about which flag a name is about. */
6
+ const GENERIC = new Set("feature features flag flags toggle key keys enabled disabled enable disable is has use get async on off the when with if and".split(" "));
7
+ /** Lowercase words of a name: `Vfo1FoundationEnabled_Saves` → vfo1, foundation, enabled, saves. */
8
+ export function words(name) {
9
+ return (name.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+[0-9]*|[A-Z]+[0-9]*|[0-9]+/g) ?? []).map((w) => w.toLowerCase());
10
+ }
11
+ /** Words of the flag's recorded names */
12
+ export function flagWords(names) {
13
+ return new Set(names.flatMap((n) => words(n.name)).filter((w) => !GENERIC.has(w)));
14
+ }
15
+ /** Whether `name` contains one of the flag's words. */
16
+ export function mentionsFlag(name, flag) {
17
+ return words(name).some((w) => flag.has(w));
18
+ }
19
+ /**
20
+ * Whether `name` names this flag clearly enough to look for it in every file:
21
+ * it contains all words of the flag's name in order (`IsNewCheckoutEnabledAsync`
22
+ * for `NewCheckout`), or a flag word with a digit (`vfo1`), which ordinary
23
+ * names don't have. One plain word (`express`, `checkout`) is not enough.
24
+ * `flagNames` are the literal and the definitions' last segments.
25
+ */
26
+ export function namesFlag(name, flagNames, flag) {
27
+ const own = words(name);
28
+ if (own.some((w) => flag.has(w) && /\d/.test(w) && /[a-z]/.test(w) && w.length >= 3))
29
+ return true;
30
+ return flagNames.some((flagName) => {
31
+ const seq = words(flagName).filter((w) => !GENERIC.has(w));
32
+ if (seq.length < 2)
33
+ return false;
34
+ return own.some((_, i) => seq.every((w, j) => own[i + j] === w));
35
+ });
36
+ }
package/dist/core/git.js CHANGED
@@ -33,6 +33,14 @@ export function changedFilesSince(cwd, sha) {
33
33
  return undefined;
34
34
  return [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
35
35
  }
36
+ /**
37
+ * Tracked files modified or deleted in the working tree since `sha` (staged
38
+ * or not); untracked files don't count. Undefined when git can't tell.
39
+ */
40
+ export function trackedChangesSince(cwd, sha) {
41
+ const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
42
+ return diff.ok ? paths(diff.stdout).sort() : undefined;
43
+ }
36
44
  /** A file's content at `sha`, or undefined when it did not exist there. */
37
45
  export function fileAt(cwd, sha, relativePath) {
38
46
  const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
package/dist/core/hook.js CHANGED
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { findConfigRoot } from "./config.js";
10
- import { headSha, treeAt, treeFingerprint } from "./git.js";
9
+ import { CONFIG_FILENAMES, findConfigRoot } from "./config.js";
10
+ import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
11
11
  import { readJson } from "./util.js";
12
12
  /** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
13
13
  export function parseHookInput(text) {
@@ -33,10 +33,12 @@ export function hookRoot(cwd) {
33
33
  return findConfigRoot(cwd) ?? cwd;
34
34
  }
35
35
  /**
36
- * Whether to block the stop: a removal is in progress (HEAD is still its
37
- * baseline commit and the tree has changed) and no passing verify matches the
38
- * current tree. Once HEAD moves (a commit, a branch switch, a merge), that
39
- * removal is no longer guarded.
36
+ * Whether to block the stop: a removal is in progress and no passing verify
37
+ * matches the current tree. In progress means HEAD is still the baseline
38
+ * commit and a file git tracks changed, other than the flagrm config (a
39
+ * removal paused on a question to the user, or a stray test result file,
40
+ * doesn't count), or the last verify skipped checks. Once HEAD moves (a
41
+ * commit, a branch switch, a merge), that removal is no longer guarded.
40
42
  */
41
43
  export function stopDecision(root, input) {
42
44
  if (input.stop_hook_active)
@@ -55,16 +57,24 @@ export function stopDecision(root, input) {
55
57
  .filter((flagDir) => readJson(path.join(flagDir, "baseline.json"))?.git?.sha === head);
56
58
  if (active.length === 0)
57
59
  return { block: false };
60
+ const edited = (trackedChangesSince(root, head) ?? []).some((rel) => !CONFIG_FILENAMES.includes(rel));
61
+ const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
62
+ if (started.length === 0)
63
+ return { block: false };
58
64
  const current = treeFingerprint(root);
59
- if (!current || current === treeAt(root, head))
65
+ if (!current)
60
66
  return { block: false };
61
- const reasons = active.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
67
+ const reasons = started.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
62
68
  return reasons.length ? { block: true, reason: reasons.join("\n") } : { block: false };
63
69
  }
64
70
  catch {
65
71
  return { block: false };
66
72
  }
67
73
  }
74
+ function lastVerifySkipped(flagDir) {
75
+ const skipped = readJson(path.join(flagDir, "verify.json"))?.skipped;
76
+ return Array.isArray(skipped) && skipped.length > 0;
77
+ }
68
78
  /** Why the in-progress removal in `flagDir` is not verified, or undefined when a full verify passed on `current`. */
69
79
  function unverified(flagDir, current) {
70
80
  const baseline = readJson(path.join(flagDir, "baseline.json"));
@@ -1,18 +1,50 @@
1
1
  /**
2
2
  * What `flagrm init` and `flagrm update` write into a repository: the config
3
- * template, the agent skills, the AGENTS.md pointer for Codex, the Claude
4
- * Code Stop hook and the `.flagrm/` gitignore entry. Every step reports what
5
- * it did; nothing the user wrote is overwritten.
3
+ * template and the `.flagrm/` gitignore entry, then per AI coding tool its
4
+ * files: for Claude Code the skills and the Stop hook, for GitHub Copilot the
5
+ * skills and `/flagrm-*` prompt files, for Codex the skills and the AGENTS.md
6
+ * pointer. Every step reports what it did; nothing the user wrote is
7
+ * overwritten, and nothing is deleted.
6
8
  */
7
9
  import fs from "node:fs";
8
10
  import path from "node:path";
9
- import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, } from "./config.js";
11
+ import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, TOOL_IDS, } from "./config.js";
10
12
  import { detectProjects } from "./detect.js";
11
13
  import { packageRoot, packageVersion } from "./package.js";
14
+ import { saveTools } from "./tools.js";
12
15
  import { relativePath } from "./util.js";
13
16
  export const SKILLS = ["flagrm-remove", "flagrm-verify"];
14
- /** Where each supported agent discovers repository skills: Claude Code, GitHub Copilot, Codex. */
15
- export const AGENT_SKILL_DIRS = [".claude/skills", ".github/skills", ".agents/skills"];
17
+ /** Where each supported tool discovers repository skills. */
18
+ export const TOOL_SKILL_DIRS = {
19
+ "claude-code": ".claude/skills",
20
+ copilot: ".github/skills",
21
+ codex: ".agents/skills",
22
+ };
23
+ /** Every tool's skills directory: Claude Code, GitHub Copilot, Codex. */
24
+ export const AGENT_SKILL_DIRS = TOOL_IDS.map((t) => TOOL_SKILL_DIRS[t]);
25
+ /** GitHub Copilot prompt files: `/flagrm-remove` and `/flagrm-verify` in Copilot Chat, each pointing at its skill. */
26
+ export const PROMPT_FILES = {
27
+ "flagrm-remove": [
28
+ "---",
29
+ "description: Remove a feature flag with flagrm's guardrails (keep the ON path, verify until it passes)",
30
+ "argument-hint: <flag>",
31
+ "agent: agent",
32
+ "---",
33
+ "",
34
+ "Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flag named after this command.",
35
+ "",
36
+ ].join("\n"),
37
+ "flagrm-verify": [
38
+ "---",
39
+ "description: Check a feature flag removal with flagrm and propose its commit message",
40
+ "argument-hint: <flag>",
41
+ "agent: agent",
42
+ "---",
43
+ "",
44
+ "Follow `.github/skills/flagrm-verify/SKILL.md` for the feature flag named after this command.",
45
+ "",
46
+ ].join("\n"),
47
+ };
16
48
  /**
17
49
  * The Stop hook command. npx runs the project's own flagrm (a dev dependency)
18
50
  * and falls back to a global install. `--no-install` matters: the `flagrm` name
@@ -72,27 +104,31 @@ function projectLines(p) {
72
104
  }
73
105
  return lines;
74
106
  }
75
- export function configTemplate(projects) {
107
+ export function configTemplate(projects, tools) {
76
108
  const listed = projects.length ? projects : EXAMPLE_PROJECTS;
77
- return [...CONFIG_HEADER, ...listed.flatMap(projectLines), ...CONFIG_FOOTER].join("\n");
109
+ const [comment1, comment2, projectsKey] = CONFIG_HEADER;
110
+ const toolLines = tools
111
+ ? ["# The AI coding tools `flagrm init` and `flagrm update` set up.", `tools: [${tools.join(", ")}]`]
112
+ : [];
113
+ return [comment1, comment2, ...toolLines, projectsKey, ...listed.flatMap(projectLines), ...CONFIG_FOOTER].join("\n");
78
114
  }
79
115
  /** Write `flagrm.config.yaml` for the projects found in `cwd` (an example when none are) unless a config file exists. */
80
- export function writeConfigTemplate(cwd) {
116
+ export function writeConfigTemplate(cwd, tools) {
81
117
  const existing = findDefaultConfigFile(cwd);
82
118
  if (existing)
83
119
  return { path: relativePath(cwd, existing), status: "skipped" };
84
120
  const file = path.join(cwd, "flagrm.config.yaml");
85
121
  const projects = detectProjects(cwd);
86
- fs.writeFileSync(file, configTemplate(projects), "utf8");
122
+ fs.writeFileSync(file, configTemplate(projects, tools), "utf8");
87
123
  const note = projects.length
88
124
  ? `detected ${projects.map((p) => `${p.adapter} (${p.marker})`).join(", ")}`
89
125
  : "no Angular or .NET project found: edit the example projects";
90
126
  return { path: relativePath(cwd, file), status: "created", note };
91
127
  }
92
- /** Copy each skill into each agent's skills directory and stamp it with the flagrm version. */
93
- export function installSkills(cwd, mode, version = packageVersion(), source = path.join(packageRoot(), "skills")) {
128
+ /** Copy each skill into each tool's skills directory and stamp it with the flagrm version. */
129
+ export function installSkills(cwd, mode, version = packageVersion(), source = path.join(packageRoot(), "skills"), tools = TOOL_IDS) {
94
130
  const results = [];
95
- for (const dir of AGENT_SKILL_DIRS) {
131
+ for (const dir of tools.map((t) => TOOL_SKILL_DIRS[t])) {
96
132
  for (const skill of SKILLS) {
97
133
  const target = path.join(cwd, dir, skill);
98
134
  const exists = fs.existsSync(target);
@@ -142,6 +178,30 @@ export function installedSkillVersion(skillDir) {
142
178
  return undefined;
143
179
  }
144
180
  }
181
+ /** GitHub Copilot's `/flagrm-remove` and `/flagrm-verify` prompt files in `.github/prompts/`. */
182
+ export function installPrompts(cwd, mode) {
183
+ const results = [];
184
+ for (const skill of SKILLS) {
185
+ const file = path.join(cwd, ".github", "prompts", `${skill}.prompt.md`);
186
+ const step = relativePath(cwd, file);
187
+ const exists = fs.existsSync(file);
188
+ if (mode === "init" && exists) {
189
+ results.push({ path: step, status: "skipped" });
190
+ continue;
191
+ }
192
+ if (mode === "update" && !exists)
193
+ continue;
194
+ const before = exists ? fs.readFileSync(file, "utf8") : undefined;
195
+ if (before === PROMPT_FILES[skill]) {
196
+ results.push({ path: step, status: "unchanged" });
197
+ continue;
198
+ }
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ fs.writeFileSync(file, PROMPT_FILES[skill], "utf8");
201
+ results.push({ path: step, status: exists ? "updated" : "created" });
202
+ }
203
+ return results;
204
+ }
145
205
  /** The marked flagrm block in AGENTS.md, which Codex reads. */
146
206
  export function installAgentsBlock(cwd, mode) {
147
207
  const file = path.join(cwd, "AGENTS.md");
@@ -241,17 +301,63 @@ export function ensureGitignore(cwd) {
241
301
  export function installRoot(cwd) {
242
302
  return findConfigRoot(cwd) ?? repositoryRoot(cwd) ?? cwd;
243
303
  }
244
- /** `flagrm init`: everything the agent needs in `root` (see {@link installRoot}), added where missing. */
245
- export function initProject(root) {
304
+ /** The files `init` installs for one tool. */
305
+ function toolSteps(root, tool, mode) {
306
+ const skills = installSkills(root, mode, undefined, undefined, [tool]);
307
+ if (tool === "claude-code")
308
+ return [...skills, installStopHook(root, mode)];
309
+ if (tool === "copilot")
310
+ return [...skills, ...installPrompts(root, mode)];
311
+ return [...skills, installAgentsBlock(root, mode)];
312
+ }
313
+ /**
314
+ * flagrm's files for tools `tools` leaves out, when they are installed: never
315
+ * deleted, no longer updated, and named so the user can delete them.
316
+ */
317
+ function deselectedSteps(root, tools) {
318
+ const files = {
319
+ "claude-code": [
320
+ ...SKILLS.map((s) => `${TOOL_SKILL_DIRS["claude-code"]}/${s}`),
321
+ stopHookState(root) === "missing" ? "" : ".claude/settings.json",
322
+ ],
323
+ copilot: [
324
+ ...SKILLS.map((s) => `${TOOL_SKILL_DIRS.copilot}/${s}`),
325
+ ...SKILLS.map((s) => `.github/prompts/${s}.prompt.md`),
326
+ ],
327
+ codex: [...SKILLS.map((s) => `${TOOL_SKILL_DIRS.codex}/${s}`), hasAgentsBlock(root) ? "AGENTS.md" : ""],
328
+ };
329
+ return TOOL_IDS.filter((t) => !tools.includes(t)).flatMap((tool) => files[tool]
330
+ .filter((rel) => rel && fs.existsSync(path.join(root, rel)))
331
+ .map((rel) => ({
332
+ path: rel,
333
+ status: "skipped",
334
+ note: `${tool} isn't in tools: no longer updated${rel.endsWith(".json") || rel === "AGENTS.md" ? " (flagrm's entry)" : ""}; delete it if you don't use it`,
335
+ })));
336
+ }
337
+ function hasAgentsBlock(root) {
338
+ try {
339
+ return fs.readFileSync(path.join(root, "AGENTS.md"), "utf8").includes(AGENTS_START);
340
+ }
341
+ catch {
342
+ return false;
343
+ }
344
+ }
345
+ /**
346
+ * `flagrm init`: everything the agent needs in `root` (see {@link installRoot})
347
+ * for `tools`, added where missing, and the choice saved as the config's `tools:`.
348
+ */
349
+ export function initProject(root, tools = TOOL_IDS) {
350
+ const config = writeConfigTemplate(root, tools);
351
+ const saved = config.status === "skipped" ? saveTools(root, [...tools]) : undefined;
246
352
  return [
247
- writeConfigTemplate(root),
248
- ...installSkills(root, "init"),
249
- installAgentsBlock(root, "init"),
250
- installStopHook(root, "init"),
353
+ config,
354
+ ...(saved && saved.status !== "unchanged" ? [saved] : []),
355
+ ...tools.flatMap((t) => toolSteps(root, t, "init")),
251
356
  ensureGitignore(root),
357
+ ...deselectedSteps(root, tools),
252
358
  ];
253
359
  }
254
- /** `flagrm update`: refresh what `init` installed in `root` from this flagrm version; adds nothing new. */
255
- export function updateProject(root) {
256
- return [...installSkills(root, "update"), installAgentsBlock(root, "update"), installStopHook(root, "update")];
360
+ /** `flagrm update`: refresh what `init` installed in `root` for `tools` from this flagrm version; adds nothing new. */
361
+ export function updateProject(root, tools = TOOL_IDS) {
362
+ return [...tools.flatMap((t) => toolSteps(root, t, "update")), ...deselectedSteps(root, tools)];
257
363
  }
@@ -4,6 +4,7 @@
4
4
  * type, direct reference counts and the projects involved.
5
5
  */
6
6
  import path from "node:path";
7
+ import { flagWords, mentionsFlag, namesFlag } from "./flag-words.js";
7
8
  import { findMentions } from "./mentions.js";
8
9
  import { isTestPath } from "./scan.js";
9
10
  import { JSON_SCHEMA_VERSION, } from "./types.js";
@@ -33,6 +34,8 @@ export async function buildInventory(projects) {
33
34
  definitions: [],
34
35
  config: [],
35
36
  references: { total: 0, files: 0, tests: 0, byProject: {} },
37
+ flow: [],
38
+ parameterizedTests: [],
36
39
  };
37
40
  entries.set(c.flag, entry);
38
41
  involved.set(entry, new Set());
@@ -52,6 +55,7 @@ export async function buildInventory(projects) {
52
55
  }
53
56
  }
54
57
  countReferences(projects, [...entries.values()]);
58
+ followFlow(projects, [...entries.values()]);
55
59
  const order = projects.map(({ ctx }) => ctx.name);
56
60
  const byLocation = (a, b) => order.indexOf(a.project) - order.indexOf(b.project) || a.file.localeCompare(b.file) || a.line - b.line;
57
61
  for (const entry of entries.values()) {
@@ -116,3 +120,38 @@ function countReferences(projects, entries) {
116
120
  for (const [entry, seen] of files)
117
121
  entry.references.files = seen.size;
118
122
  }
123
+ /**
124
+ * Where each flag's value goes, from the adapters that can follow it. A name
125
+ * is marked `record` when it says it is about the flag; a generic one
126
+ * (`enabled`, `Plural`) recorded as a wrapper would match unrelated code.
127
+ */
128
+ function followFlow(projects, entries) {
129
+ const byFlag = new Map(entries.map((e) => [e.flag, e]));
130
+ const refs = entries.map((e) => ({ flag: e.flag, aliases: e.definitions.map((d) => d.name) }));
131
+ for (const { adapter, ctx } of projects) {
132
+ if (!adapter.flow)
133
+ continue;
134
+ const { names, tests } = adapter.flow(ctx, refs);
135
+ for (const name of names) {
136
+ const entry = byFlag.get(name.flag);
137
+ if (!entry)
138
+ continue;
139
+ const words = flagWords([{ name: entry.flag }, ...entry.definitions]);
140
+ const flagNames = [entry.flag, ...entry.definitions.map((d) => d.name.split(".").pop() ?? d.name)];
141
+ entry.flow.push({ ...name, record: !name.ambiguous && recordable(name, flagNames, words) });
142
+ }
143
+ for (const test of tests)
144
+ byFlag.get(test.flag)?.parameterizedTests.push(test);
145
+ }
146
+ }
147
+ /**
148
+ * A local, parameter or field is only looked for in its own file, so one of
149
+ * the flag's words in its name is enough (`useVfo1Terminology`). A method or
150
+ * property is looked for everywhere, so its name has to name the flag
151
+ * (`IsNewCheckoutEnabledAsync`); `express` or `GetCheckout` would match
152
+ * unrelated code.
153
+ */
154
+ function recordable(name, flagNames, words) {
155
+ const scoped = name.kind === "local" || name.kind === "parameter" || name.kind === "field";
156
+ return scoped ? mentionsFlag(name.name, words) : namesFlag(name.name, flagNames, words);
157
+ }
@@ -4,7 +4,7 @@
4
4
  * notes and the commit message around it.
5
5
  */
6
6
  import { relativePath } from "./util.js";
7
- import { shortTestNames } from "./verify/test-results.js";
7
+ import { shortTestName, shortTestNames } from "./verify/test-results.js";
8
8
  const ICON = { pass: "✅", fail: "❌", warn: "⚠️", skipped: "➖" };
9
9
  /** How many entries a list shows before summarizing the rest. */
10
10
  const MAX_ITEMS = 20;
@@ -33,11 +33,22 @@ export function verifyMarkdown(report, root) {
33
33
  const changed = report.changedFiles ?? [];
34
34
  if (changed.length)
35
35
  out.push("", `**Files changed since baseline (${changed.length}):** ${codeList(changed)}`);
36
- const removed = report.tests.flatMap((t) => t.removed);
37
- if (removed.length) {
38
- out.push("", `**Tests that no longer run (${removed.length}):** ${codeList(shortTestNames(removed))}`);
36
+ const all = (key) => report.tests.flatMap((t) => t[key]);
37
+ const renamed = report.tests.flatMap((t) => t.renamed);
38
+ for (const [names, title] of [
39
+ [all("deleted"), "Tests deleted"],
40
+ [all("excludedByConfig"), "Tests excluded by a config change"],
41
+ [all("unexplained"), "Tests that no longer run, unexplained"],
42
+ ]) {
43
+ if (names.length)
44
+ out.push("", `**${title} (${names.length}):** ${codeList(shortTestNames(names))}`);
45
+ }
46
+ if (renamed.length) {
47
+ const pairs = renamed.map((r) => `\`${shortTestName(r.from)}\` → \`${shortTestName(r.to)}\``);
48
+ out.push("", `**Tests renamed (${renamed.length}):** ${pairs.slice(0, MAX_ITEMS).join(", ")}`);
39
49
  }
40
- const added = report.tests.flatMap((t) => t.added);
50
+ const renamedTo = new Set(renamed.map((r) => r.to));
51
+ const added = report.tests.flatMap((t) => t.added).filter((name) => !renamedTo.has(name));
41
52
  if (added.length)
42
53
  out.push("", `**New tests (${added.length}):** ${codeList(shortTestNames(added))}`);
43
54
  for (const [severity, title] of [