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.
package/dist/cli.js CHANGED
@@ -3,8 +3,9 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Command } from "commander";
5
5
  import pc from "picocolors";
6
- import { addNames, baselinePath, discoverNames, flagDir, mergeNames, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
7
- import { loadConfig } from "./core/config.js";
6
+ import { realProbes } from "./adapters/dotnet/setup.js";
7
+ import { addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, flagDir, mergeNames, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
8
+ import { loadConfig, parseTools, TOOL_IDS } from "./core/config.js";
8
9
  import { runDoctor } from "./core/doctor.js";
9
10
  import { gitState } from "./core/git.js";
10
11
  import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
@@ -13,9 +14,11 @@ import { buildInventory } from "./core/inventory.js";
13
14
  import { verifyMarkdown } from "./core/markdown.js";
14
15
  import { packageVersion } from "./core/package.js";
15
16
  import { resolveProjects } from "./core/registry.js";
16
- import { printBaseline, printDoctor, printInventory, printSteps, printVerifyReport } from "./core/report.js";
17
+ import { printBaseline, printDoctor, printInventory, printScope, printSteps, printVerifyReport, } from "./core/report.js";
18
+ import { setupReport, writeScope } from "./core/scope.js";
19
+ import { askTools, detectTools, savedTools, TOOL_LABELS } from "./core/tools.js";
17
20
  import { CHECK_IDS } from "./core/types.js";
18
- import { relativePath } from "./core/util.js";
21
+ import { onPath, relativePath } from "./core/util.js";
19
22
  import { parseSkip, verifyFlag } from "./core/verify/index.js";
20
23
  const program = new Command();
21
24
  program
@@ -63,22 +66,38 @@ addCommonOptions(program
63
66
  "existing baseline, only adds names"))
64
67
  .option("--no-checks", "record only the git state, without running build and test commands")
65
68
  .option("--name <name>", "add a name the flag is read through (repeatable)", collect, [])
66
- .option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included)", "alias")
67
- .option("--force", "overwrite an existing baseline")
69
+ .option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included); default alias, wrapper with --file")
70
+ .option("--file <path>", "check the --name entries only in this file (a local, parameter or field; implies --kind wrapper)")
71
+ .option("--force", "start over: replace the baseline, only while the code is unedited since it")
68
72
  .action(async (flag, opts) => {
69
73
  const { root, projects } = loadWorkspace(opts);
70
- if (opts.kind !== "alias" && opts.kind !== "wrapper") {
71
- fail(new Error(`--kind must be alias or wrapper, not "${opts.kind}".`));
74
+ // Only wrappers are matched per file: a name scoped with --file is a local, parameter or field.
75
+ const kind = opts.kind ?? (opts.file ? "wrapper" : "alias");
76
+ if (kind !== "alias" && kind !== "wrapper") {
77
+ fail(new Error(`--kind must be alias or wrapper, not "${kind}".`));
72
78
  }
79
+ if (opts.file && opts.name.length === 0)
80
+ fail(new Error("--file scopes --name entries; pass --name too."));
81
+ if (opts.file && kind !== "wrapper")
82
+ fail(new Error("--file scopes wrapper names; drop --kind alias."));
83
+ const scope = opts.file && relativePath(root, path.resolve(opts.file));
84
+ if (scope?.startsWith(".."))
85
+ fail(new Error(`--file ${opts.file} is outside ${root}.`));
73
86
  const added = opts.name.map((name) => ({
74
87
  name,
75
- kind: opts.kind,
88
+ kind: kind,
76
89
  source: "agent",
90
+ ...(scope ? { file: scope } : {}),
77
91
  }));
78
92
  let result;
79
93
  try {
80
94
  validateAgentNames(projects, added);
81
- if (fs.existsSync(baselinePath(root, flag)) && !opts.force) {
95
+ const exists = fs.existsSync(baselinePath(root, flag));
96
+ if (exists && opts.force) {
97
+ const testResults = projects.flatMap(({ ctx }) => (ctx.project.testResults ? [ctx.project.testResults] : []));
98
+ assertNoEditsSinceBaseline(root, flag, testResults);
99
+ }
100
+ if (exists && !opts.force) {
82
101
  if (added.length === 0) {
83
102
  throw new Error(`Baseline for ${flag} already exists; pass --name to add names, or --force to start over.`);
84
103
  }
@@ -87,14 +106,15 @@ addCommonOptions(program
87
106
  else {
88
107
  const git = gitState(root);
89
108
  const checks = opts.checks ? await runChecks(projects, flagDir(root, flag)) : undefined;
90
- result = writeBaseline(root, flag, projects, git, checks, mergeNames(await discoverNames(projects, flag), added));
109
+ const { names, ...flow } = await discoverFlag(projects, flag);
110
+ result = writeBaseline(root, flag, projects, git, checks, mergeNames(names, added), flow);
91
111
  }
92
112
  }
93
113
  catch (err) {
94
114
  fail(err);
95
115
  }
96
116
  if (opts.json) {
97
- console.log(JSON.stringify({ ...result.baseline, baselineFile: result.file }, null, 2));
117
+ console.log(JSON.stringify(baselineSummary(result.baseline, result.file), null, 2));
98
118
  }
99
119
  else {
100
120
  printBaseline(result.baseline, result.file);
@@ -165,25 +185,63 @@ hook
165
185
  // --- init --------------------------------------------------------------------
166
186
  program
167
187
  .command("init")
168
- .description("set up flagrm in this repository: flagrm.config.yaml, the flagrm-remove and flagrm-verify skills for Claude Code, " +
169
- "GitHub Copilot and Codex, the Claude Code Stop hook and the .flagrm/ gitignore entry; never overwrites")
170
- .action(async () => {
188
+ .description("set up flagrm in this repository: flagrm.config.yaml, the .flagrm/ gitignore entry, and for each AI coding " +
189
+ "tool its files (Claude Code: skills and Stop hook; GitHub Copilot: skills and prompt files; Codex: skills " +
190
+ "and the AGENTS.md block); asks which tools on a terminal and saves the choice as `tools:`; never overwrites")
191
+ .option("--tool <tools>", `comma-separated tools to set up, without asking: ${TOOL_IDS.join(", ")}`)
192
+ .action(async (opts) => {
171
193
  const root = installRoot(process.cwd());
172
194
  printInstallRoot(root);
173
- printSteps(initProject(root));
195
+ let tools;
196
+ try {
197
+ tools = await chooseTools(root, opts.tool);
198
+ }
199
+ catch (err) {
200
+ fail(err);
201
+ }
202
+ printSteps(initProject(root, tools));
174
203
  console.log(pc.bold("\nflagrm doctor"));
175
204
  printDoctor(await runDoctor(root));
176
205
  console.log(pc.dim("\nNext: fill in flagrm.config.yaml, then ask your agent: /flagrm-remove <FlagName>"));
177
206
  });
207
+ /**
208
+ * The tools `init` sets up: `--tool`, else the config's saved `tools:`, else
209
+ * the user's answer with the tools the repository already uses pre-selected.
210
+ * Without a terminal to ask on: those detected tools, or all when there are none.
211
+ */
212
+ async function chooseTools(root, flag) {
213
+ if (flag !== undefined) {
214
+ const tools = parseTools(flag, "--tool") ?? [];
215
+ if (tools.length === 0)
216
+ throw new Error(`--tool needs at least one of ${TOOL_IDS.join(", ")}.`);
217
+ return tools;
218
+ }
219
+ const saved = savedTools(root);
220
+ if (saved?.length) {
221
+ console.log(pc.dim(`Tools: ${saved.map((t) => TOOL_LABELS[t]).join(", ")} (from tools: in the config; --tool changes it)`));
222
+ return saved;
223
+ }
224
+ const detected = detectTools(root);
225
+ if (process.stdin.isTTY && process.stdout.isTTY)
226
+ return askTools(detected);
227
+ return detected.length ? detected : [...TOOL_IDS];
228
+ }
178
229
  // --- update ------------------------------------------------------------------
179
230
  program
180
231
  .command("update")
181
- .description("refresh the installed skills, the AGENTS.md block and the Stop hook from this flagrm version " +
182
- "(only where flagrm init installed them)")
232
+ .description("refresh the installed skills, prompt files, AGENTS.md block and Stop hook from this flagrm version " +
233
+ "(only where flagrm init installed them, and only for the config's tools: when it lists them)")
183
234
  .action(() => {
184
235
  const root = installRoot(process.cwd());
185
236
  printInstallRoot(root);
186
- printSteps(updateProject(root));
237
+ let tools;
238
+ try {
239
+ tools = savedTools(root);
240
+ }
241
+ catch (err) {
242
+ fail(err);
243
+ }
244
+ printSteps(updateProject(root, tools));
187
245
  });
188
246
  /** Say where init/update install when it isn't the working directory. */
189
247
  function printInstallRoot(root) {
@@ -207,6 +265,26 @@ program
207
265
  printDoctor(report);
208
266
  process.exitCode = report.status === "ok" ? 0 : 1;
209
267
  });
268
+ // --- scope -------------------------------------------------------------------
269
+ addCommonOptions(program
270
+ .command("scope")
271
+ .description("check that this machine can build and test each .NET project (the SDK global.json asks for, `cargo`, " +
272
+ "Docker for Testcontainers) and show which projects a solution filter (flagrm.slnf) would leave out; " +
273
+ "exit 0 nothing missing, 1 something missing"))
274
+ .option("--write", "create flagrm.slnf, keep it out of git and point the config's build and test at it")
275
+ .action((opts) => {
276
+ const { root, projects } = loadWorkspace(opts);
277
+ const setups = setupReport(projects, root, realProbes((exe) => onPath(exe, root)));
278
+ const steps = opts.write ? writeScope(setups, projects, root, projects[0]?.ctx.config.file) : [];
279
+ if (opts.json)
280
+ console.log(JSON.stringify({ projects: setups, ...(opts.write ? { steps } : {}) }, null, 2));
281
+ else {
282
+ printScope(setups, root, Boolean(opts.write));
283
+ printSteps(steps);
284
+ }
285
+ const missing = setups.some((s) => s.sdk || s.setup?.problems.length);
286
+ process.exitCode = missing ? 1 : 0;
287
+ });
210
288
  // exitCode, not exit(): exit() can cut off output still being flushed to a pipe.
211
289
  program.parseAsync().catch((err) => {
212
290
  if (!(err instanceof CliError))
@@ -2,9 +2,15 @@ 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
+ import { CONFIG_FILENAMES } from "./config.js";
7
+ import { configSnapshot, projectCommands } from "./config-snapshot.js";
8
+ import { environmentProblem } from "./environment.js";
9
+ import { changedFilesSince } from "./git.js";
5
10
  import { buildInventory } from "./inventory.js";
6
- import { JSON_SCHEMA_VERSION } from "./types.js";
7
- import { collectTestResults } from "./verify/test-results.js";
11
+ import { JSON_SCHEMA_VERSION, READABLE_BASELINE_VERSIONS, } from "./types.js";
12
+ import { readJson, relativePath } from "./util.js";
13
+ import { collectTestResults, shortTestNames } from "./verify/test-results.js";
8
14
  /**
9
15
  * `.flagrm/<flag>/` under the config root. A name that isn't safe as a directory
10
16
  * (`team/new`, `..`) is sanitized and suffixed with a hash of the original, so
@@ -24,6 +30,40 @@ export function baselinePath(root, flag) {
24
30
  export function verifyPath(root, flag) {
25
31
  return path.join(flagDir(root, flag), "verify.json");
26
32
  }
33
+ /** At most this many failed test names in `baseline --json`. */
34
+ const MAX_FAILED_NAMES = 20;
35
+ /**
36
+ * What `baseline --json` prints: the baseline without the per-test name lists
37
+ * (every test of a large suite, with its data-row arguments: megabytes) and
38
+ * the config snapshot. Failed tests are listed by short name, at most 20;
39
+ * `baseline.json` keeps everything.
40
+ */
41
+ export function baselineSummary(baseline, file) {
42
+ const { config: _config, checks, ...rest } = baseline;
43
+ return {
44
+ ...rest,
45
+ ...(checks
46
+ ? {
47
+ checks: checks.map(({ results, ...run }) => {
48
+ if (!results)
49
+ return run;
50
+ const { names: _names, failedNames, files, ...counts } = results;
51
+ const failed = shortTestNames(failedNames);
52
+ return {
53
+ ...run,
54
+ results: {
55
+ ...counts,
56
+ files: files.length,
57
+ failedTests: failed.slice(0, MAX_FAILED_NAMES),
58
+ ...(failed.length > MAX_FAILED_NAMES ? { moreFailedTests: failed.length - MAX_FAILED_NAMES } : {}),
59
+ },
60
+ };
61
+ }),
62
+ }
63
+ : {}),
64
+ baselineFile: file,
65
+ };
66
+ }
27
67
  /** A problem with how flagrm was run (missing baseline, stale schema) rather than with the code under check. */
28
68
  export class UsageError extends Error {
29
69
  }
@@ -34,7 +74,7 @@ export function readBaseline(root, flag) {
34
74
  throw new UsageError(`No baseline for ${flag} at ${file}. Run \`flagrm baseline ${flag}\` before changing the code.`);
35
75
  }
36
76
  const baseline = JSON.parse(fs.readFileSync(file, "utf8"));
37
- if (baseline.schemaVersion !== JSON_SCHEMA_VERSION) {
77
+ if (!READABLE_BASELINE_VERSIONS.includes(baseline.schemaVersion)) {
38
78
  throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
39
79
  `Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
40
80
  }
@@ -47,7 +87,7 @@ export function readBaseline(root, flag) {
47
87
  * reason at the end of the log. A test run also reads the project's
48
88
  * `testResults` files written during it.
49
89
  */
50
- export async function runCheck(ctx, check, command, logFile) {
90
+ export async function runCheck(ctx, check, command, logFile, claimed) {
51
91
  const started = Date.now();
52
92
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
53
93
  fs.writeFileSync(logFile, `$ ${command}\n`, "utf8");
@@ -70,7 +110,14 @@ export async function runCheck(ctx, check, command, logFile) {
70
110
  durationMs: Date.now() - started,
71
111
  log: logFile,
72
112
  };
73
- const results = check === "test" && ctx.project.testResults ? collectTestResults(ctx.project.testResults, started) : undefined;
113
+ if (run.exitCode !== 0) {
114
+ const problem = environmentProblem(fs.readFileSync(logFile, "utf8"), ctx.root);
115
+ if (problem)
116
+ run.failure = { kind: "environment", message: problem };
117
+ }
118
+ const results = check === "test" && ctx.project.testResults
119
+ ? collectTestResults(ctx.project.testResults, started, claimed)
120
+ : undefined;
74
121
  if (results)
75
122
  run.results = results;
76
123
  return run;
@@ -118,11 +165,6 @@ function failureReason(exit, timeout) {
118
165
  return `killed by ${exit.signal ?? "a signal"}`;
119
166
  return undefined;
120
167
  }
121
- /** A project's build/test commands: the configured ones, or the adapter's defaults when it configures neither. */
122
- export function projectCommands(adapter, ctx) {
123
- const configured = ctx.project.build !== undefined || ctx.project.test !== undefined;
124
- return configured ? { build: ctx.project.build, test: ctx.project.test } : (adapter.defaultCommands?.(ctx) ?? {});
125
- }
126
168
  /**
127
169
  * Why an Angular project's build can't catch template errors, if it can't:
128
170
  * `tsc` never reads templates, so a member removed or renamed in a component
@@ -155,12 +197,15 @@ export function incrementalBuildGap(adapter, build) {
155
197
  */
156
198
  export async function runChecks(projects, logDir, only = ["build", "test"]) {
157
199
  const runs = [];
200
+ // Projects whose `testResults` patterns overlap must not count each other's result files.
201
+ const claimed = new Map();
158
202
  for (const { adapter, ctx } of projects) {
159
203
  const commands = projectCommands(adapter, ctx);
160
204
  for (const check of only) {
161
205
  const command = commands[check];
162
- if (command)
163
- runs.push(await runCheck(ctx, check, command, path.join(logDir, `${ctx.name}.${check}.log`)));
206
+ if (!command)
207
+ continue;
208
+ runs.push(await runCheck(ctx, check, command, path.join(logDir, `${ctx.name}.${check}.log`), claimed));
164
209
  }
165
210
  }
166
211
  return runs;
@@ -170,7 +215,7 @@ export async function runChecks(projects, logDir, only = ["build", "test"]) {
170
215
  * `git` before running checks, whose logs land under `.flagrm/` and would
171
216
  * otherwise make the tree look dirty.
172
217
  */
173
- export function writeBaseline(root, flag, projects, git, checks, names = []) {
218
+ export function writeBaseline(root, flag, projects, git, checks, names = [], flow) {
174
219
  const baseline = {
175
220
  schemaVersion: JSON_SCHEMA_VERSION,
176
221
  flag,
@@ -178,9 +223,14 @@ export function writeBaseline(root, flag, projects, git, checks, names = []) {
178
223
  projects: projects.map(({ ctx }) => ({ name: ctx.name, adapter: ctx.project.adapter })),
179
224
  git,
180
225
  names,
226
+ config: configSnapshot(projects),
181
227
  };
182
228
  if (checks)
183
229
  baseline.checks = checks;
230
+ if (flow?.suggestedNames.length)
231
+ baseline.suggestedNames = flow.suggestedNames;
232
+ if (flow?.parameterizedTests.length)
233
+ baseline.parameterizedTests = flow.parameterizedTests;
184
234
  return saveBaseline(root, baseline);
185
235
  }
186
236
  function saveBaseline(root, baseline) {
@@ -189,23 +239,93 @@ function saveBaseline(root, baseline) {
189
239
  fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
190
240
  return { baseline, file };
191
241
  }
192
- /** The flag's names from discovery: the literal, then each definition's qualified name. */
193
- export async function discoverNames(projects, flag) {
242
+ /**
243
+ * The flag's names from discovery: the literal, then each definition's
244
+ * qualified name, then the names its value travels through that contain one
245
+ * of the flag's words, as wrappers.
246
+ */
247
+ export async function discoverFlag(projects, flag) {
194
248
  const entry = (await buildInventory(projects)).flags.find((f) => f.flag === flag);
195
- return mergeNames([{ name: flag, kind: "literal", source: "discovery" }], (entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })));
249
+ const flow = entry?.flow ?? [];
250
+ const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
251
+ ...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
252
+ ...flow.filter((n) => n.record).map((n) => flowName(projects, n)),
253
+ ]);
254
+ const key = (n) => `${n.name}\0${n.file ?? ""}`;
255
+ const recorded = new Set(names.map(key));
256
+ const suggestedNames = flow
257
+ .filter((n) => !n.record && !recorded.has(key(flowName(projects, n))))
258
+ .map(({ record: _record, ...name }) => name)
259
+ .filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
260
+ return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
261
+ }
262
+ /** A flow name as a wrapper; a local, parameter or field only in its own file. */
263
+ function flowName(projects, n) {
264
+ const name = { name: n.name, kind: "wrapper", source: "discovery" };
265
+ const root = projects[0]?.ctx.config.root;
266
+ if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field"))
267
+ name.file = relativePath(root, n.file);
268
+ return name;
196
269
  }
197
- /** `existing` followed by the names in `added` it doesn't already have. */
270
+ /** The names {@link discoverFlag} records. */
271
+ export async function discoverNames(projects, flag) {
272
+ return (await discoverFlag(projects, flag)).names;
273
+ }
274
+ /** `existing` followed by the names in `added` it doesn't already cover. */
198
275
  export function mergeNames(existing, added) {
199
- const seen = new Set(existing.map((n) => n.name));
276
+ // A name recorded for every file already covers the same name in one file.
277
+ const key = (n) => `${n.name}\0${n.file ?? ""}`;
278
+ const seen = new Set(existing.map(key));
279
+ const everywhere = new Set(existing.filter((n) => !n.file).map((n) => n.name));
200
280
  const merged = [...existing];
201
281
  for (const n of added) {
202
- if (seen.has(n.name))
282
+ if (seen.has(key(n)) || everywhere.has(n.name))
203
283
  continue;
204
- seen.add(n.name);
284
+ seen.add(key(n));
285
+ if (!n.file)
286
+ everywhere.add(n.name);
205
287
  merged.push(n);
206
288
  }
207
289
  return merged;
208
290
  }
291
+ /**
292
+ * Files changed since `sha` that count as edits to the code: everything but
293
+ * `.flagrm/`, the flagrm config, which may change to fix the setup, and the
294
+ * files matching `testResults` patterns (absolute), which the baseline's own
295
+ * test run writes. Undefined when git can't tell (not a repository, or `sha`
296
+ * isn't in it).
297
+ */
298
+ export function editsSince(root, sha, testResults = []) {
299
+ const results = new Set(testResults.flatMap((pattern) => fg
300
+ .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
301
+ .map((file) => relativePath(root, file))));
302
+ return changedFilesSince(root, sha)?.filter((rel) => !CONFIG_FILENAMES.includes(rel) && !results.has(rel));
303
+ }
304
+ /**
305
+ * `baseline --force` starts over only before the removal began: the working
306
+ * tree must still match the baseline commit. Otherwise the new baseline would
307
+ * record half-removed code, and verify would no longer see those edits.
308
+ * Without git there is nothing to compare, so it is allowed. Files matching
309
+ * `testResults` (absolute patterns) are not edits.
310
+ */
311
+ export function assertNoEditsSinceBaseline(root, flag, testResults = []) {
312
+ const sha = readJson(baselinePath(root, flag))?.git?.sha;
313
+ if (!sha)
314
+ return;
315
+ const edits = editsSince(root, sha, testResults);
316
+ if (edits === undefined) {
317
+ throw new UsageError(`The baseline commit ${sha.slice(0, 7)} of ${flag} is not in this repository, so flagrm can't tell whether the ` +
318
+ `code was edited since. If it wasn't, delete ${relativePath(root, flagDir(root, flag))}/ and take a new baseline.`);
319
+ }
320
+ if (edits.length === 0)
321
+ return;
322
+ const shown = edits.slice(0, 5).join(", ") + (edits.length > 5 ? `, and ${edits.length - 5} more` : "");
323
+ const uncommitted = new Set(changedFilesSince(root, "HEAD") ?? []);
324
+ const fix = edits.every((rel) => uncommitted.has(rel))
325
+ ? `Set the edits aside with \`git stash -u\`, run \`flagrm baseline ${flag} --force\` again, then \`git stash pop\`.`
326
+ : `Some are committed since the baseline commit ${sha.slice(0, 7)}: check out code without the removal first.`;
327
+ throw new UsageError(`The code changed since the baseline of ${flag} (${shown}), so --force would record a half-done removal. ${fix}`);
328
+ }
209
329
  /** Append names to an existing baseline; its git state and checks stay as they are. */
210
330
  export function addNames(root, flag, added) {
211
331
  const { baseline } = readBaseline(root, flag);
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The configuration a baseline was taken with, so `verify` can tell when it
3
+ * changed since: a narrower test command or filter runs fewer tests, and the
4
+ * comparison against the baseline no longer means what it did.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import YAML from "yaml";
10
+ import { relativePath } from "./util.js";
11
+ /** A project's build/test commands: the configured ones, or the adapter's defaults when it configures neither. */
12
+ export function projectCommands(adapter, ctx) {
13
+ const configured = ctx.project.build !== undefined || ctx.project.test !== undefined;
14
+ return configured ? { build: ctx.project.build, test: ctx.project.test } : (adapter.defaultCommands?.(ctx) ?? {});
15
+ }
16
+ /** Solution and project files: a removal may edit them, so they don't count as a command's configuration. */
17
+ const PROJECT_FILE = /\.(sln|slnx|\w*proj)$/i;
18
+ /** The resolved build/test commands of every project, the files they name, and the config file's text. */
19
+ export function configSnapshot(projects) {
20
+ const configFile = projects[0]?.ctx.config.file;
21
+ const root = projects[0]?.ctx.config.root ?? process.cwd();
22
+ const snapshot = { projects: [] };
23
+ if (configFile) {
24
+ try {
25
+ snapshot.file = { path: relativePath(root, configFile), text: fs.readFileSync(configFile, "utf8") };
26
+ }
27
+ catch {
28
+ // Unreadable: nothing to compare later.
29
+ }
30
+ }
31
+ for (const { adapter, ctx } of projects) {
32
+ const commands = projectCommands(adapter, ctx);
33
+ const command = (text) => ({ command: text, files: namedFiles(text, ctx.root, root) });
34
+ const entry = { name: ctx.name };
35
+ if (commands.build)
36
+ entry.build = command(commands.build);
37
+ if (commands.test)
38
+ entry.test = command(commands.test);
39
+ if (ctx.project.testResults)
40
+ entry.testResults = relativePath(root, ctx.project.testResults);
41
+ snapshot.projects.push(entry);
42
+ }
43
+ return snapshot;
44
+ }
45
+ /**
46
+ * Files a command names (`flagrm.slnf`, `--settings ci.runsettings`,
47
+ * `--filter-file=filters.txt`), with a hash of their content. Words are
48
+ * resolved against the directory the command runs in.
49
+ */
50
+ function namedFiles(command, cwd, root) {
51
+ const words = command
52
+ .split(/\s+/)
53
+ .flatMap((word) => [word, word.slice(word.indexOf("=") + 1), word.slice(word.indexOf(":") + 1)])
54
+ .map((word) => word.replace(/^["']|["']$/g, ""))
55
+ .filter((word) => word && !word.startsWith("-") && !PROJECT_FILE.test(word));
56
+ const files = new Map();
57
+ for (const word of new Set(words)) {
58
+ const file = path.resolve(cwd, word);
59
+ try {
60
+ if (!fs.statSync(file).isFile())
61
+ continue;
62
+ files.set(relativePath(root, file), createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
63
+ }
64
+ catch {
65
+ // Not a file.
66
+ }
67
+ }
68
+ return [...files].sort(([a], [b]) => a.localeCompare(b)).map(([p, sha256]) => ({ path: p, sha256 }));
69
+ }
70
+ /** What changed between the configuration at the baseline and now. None when the baseline predates snapshots. */
71
+ export function configChanges(before, now) {
72
+ if (!before)
73
+ return [];
74
+ const changes = [];
75
+ const names = [...new Set([...before.projects, ...now.projects].map((p) => p.name))];
76
+ for (const name of names) {
77
+ const was = before.projects.find((p) => p.name === name);
78
+ const is = now.projects.find((p) => p.name === name);
79
+ for (const [kind, check] of [
80
+ ["build", "build"],
81
+ ["test", "tests"],
82
+ ]) {
83
+ const a = was?.[kind];
84
+ const b = is?.[kind];
85
+ if (a?.command !== b?.command) {
86
+ changes.push({
87
+ check,
88
+ project: name,
89
+ message: `${kind} command changed since the baseline: ${quote(a?.command)} → ${quote(b?.command)}`,
90
+ });
91
+ }
92
+ const changed = changedFiles(a?.files ?? [], b?.files ?? []);
93
+ if (changed.length) {
94
+ changes.push({
95
+ check,
96
+ project: name,
97
+ message: `${changed.join(", ")}, named by the ${kind} command, changed since the baseline`,
98
+ });
99
+ }
100
+ }
101
+ if (was?.testResults !== is?.testResults) {
102
+ changes.push({
103
+ check: "tests",
104
+ project: name,
105
+ message: `testResults changed since the baseline: ${quote(was?.testResults)} → ${quote(is?.testResults)}`,
106
+ });
107
+ }
108
+ }
109
+ if (before.file && now.file && withoutCommands(before.file.text) !== withoutCommands(now.file.text)) {
110
+ changes.push({
111
+ check: "leftovers",
112
+ message: `${now.file.path} changed since the baseline, which changes what is scanned`,
113
+ details: lineDiff(before.file.text, now.file.text),
114
+ });
115
+ }
116
+ return changes;
117
+ }
118
+ const quote = (text) => (text === undefined ? "(none)" : `\`${text}\``);
119
+ function changedFiles(a, b) {
120
+ const hashes = new Map(a.map((f) => [f.path, f.sha256]));
121
+ const out = new Set();
122
+ for (const f of b)
123
+ if (hashes.get(f.path) !== f.sha256)
124
+ out.add(f.path);
125
+ for (const f of a)
126
+ if (!b.some((g) => g.path === f.path))
127
+ out.add(f.path);
128
+ return [...out].sort();
129
+ }
130
+ /** The config without what the command comparison already covers, normalized for comparison. */
131
+ function withoutCommands(text) {
132
+ let parsed;
133
+ try {
134
+ parsed = YAML.parse(text);
135
+ }
136
+ catch {
137
+ return text;
138
+ }
139
+ const strip = (project) => {
140
+ if (!project || typeof project !== "object")
141
+ return project;
142
+ const { build: _b, test: _t, testResults: _r, timeout: _o, ...rest } = project;
143
+ return rest;
144
+ };
145
+ if (parsed && typeof parsed === "object") {
146
+ const config = { ...parsed };
147
+ if (Array.isArray(config.projects))
148
+ config.projects = config.projects.map(strip);
149
+ for (const legacy of ["frontend", "backend"]) {
150
+ if (config[legacy] && typeof config[legacy] === "object")
151
+ config[legacy] = strip(config[legacy]);
152
+ }
153
+ return JSON.stringify(config);
154
+ }
155
+ return JSON.stringify(parsed);
156
+ }
157
+ /** Lines removed (`- `) and added (`+ `) from `a` to `b`, by longest common subsequence; at most 20. */
158
+ export function lineDiff(a, b, max = 20) {
159
+ const x = a.split(/\r?\n/);
160
+ const y = b.split(/\r?\n/);
161
+ const lcs = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0));
162
+ for (let i = x.length - 1; i >= 0; i--) {
163
+ for (let j = y.length - 1; j >= 0; j--) {
164
+ lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
165
+ }
166
+ }
167
+ const out = [];
168
+ let i = 0;
169
+ let j = 0;
170
+ while (i < x.length || j < y.length) {
171
+ if (i < x.length && j < y.length && x[i] === y[j]) {
172
+ i++;
173
+ j++;
174
+ }
175
+ else if (i < x.length && (j === y.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
176
+ out.push(`- ${x[i++]}`);
177
+ }
178
+ else {
179
+ out.push(`+ ${y[j++]}`);
180
+ }
181
+ }
182
+ return out.length > max ? [...out.slice(0, max), `… ${out.length - max} more changed lines`] : out;
183
+ }
@@ -2,6 +2,23 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  export const ADAPTER_IDS = ["angular", "dotnet", "generic"];
5
+ /** The AI coding tools `init` can set up: Claude Code, GitHub Copilot, Codex. */
6
+ export const TOOL_IDS = ["claude-code", "copilot", "codex"];
7
+ /** `tools:` as configured, checked; undefined when the key is absent. */
8
+ export function parseTools(value, where = "tools") {
9
+ if (value === undefined || value === null)
10
+ return undefined;
11
+ const list = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : undefined;
12
+ if (!list)
13
+ throw new Error(`${where} must be a list of ${TOOL_IDS.join(", ")}.`);
14
+ const tools = list.map((t) => String(t).trim()).filter(Boolean);
15
+ for (const t of tools) {
16
+ if (!TOOL_IDS.includes(t)) {
17
+ throw new Error(`${where}: "${t}" is not one of ${TOOL_IDS.join(", ")}.`);
18
+ }
19
+ }
20
+ return TOOL_IDS.filter((t) => tools.includes(t));
21
+ }
5
22
  export const DEFAULT_FRONTEND_METHODS = ["isEnabled", "isFeatureEnabled", "hasFeature", "getFlag", "isOn"];
6
23
  export const DEFAULT_BACKEND_METHODS = [
7
24
  "IsEnabledAsync",
@@ -12,10 +29,11 @@ export const DEFAULT_BACKEND_METHODS = [
12
29
  "GetValue<bool>",
13
30
  ];
14
31
  export const DEFAULT_BACKEND_ATTRIBUTES = ["FeatureGate", "Feature"];
15
- const CONFIG_FILENAMES = ["flagrm.config.yaml", "flagrm.config.yml", "flagrm.config.json"];
32
+ export const CONFIG_FILENAMES = ["flagrm.config.yaml", "flagrm.config.yml", "flagrm.config.json"];
16
33
  /** Known top-level `flagrm.config.yaml` keys — anything else is likely a typo. */
17
34
  const KNOWN_CONFIG_KEYS = [
18
35
  "projects",
36
+ "tools",
19
37
  "frontend",
20
38
  "backend",
21
39
  "frontendMethods",
@@ -245,8 +263,11 @@ export function loadConfig(cli, cwd = process.cwd()) {
245
263
  if (projects.length === 0) {
246
264
  throw new Error("No projects configured. Add a `projects:` list to flagrm.config.yaml, or pass --frontend and/or --backend.");
247
265
  }
266
+ const tools = parseTools(file.tools);
248
267
  return {
249
268
  root: fileDir,
269
+ file: configPath && fs.existsSync(configPath) ? configPath : undefined,
270
+ ...(tools ? { tools } : {}),
250
271
  projects,
251
272
  exclude: [...(file.exclude ?? []), ...(cli.exclude ?? [])],
252
273
  include: [...(file.include ?? []), ...(cli.include ?? [])],