flagrm 0.2.1 → 0.4.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +70 -31
  3. package/dist/adapters/angular/component-files.js +41 -0
  4. package/dist/adapters/angular/flow.js +121 -0
  5. package/dist/adapters/angular/holders.js +194 -0
  6. package/dist/adapters/angular/index.js +20 -1
  7. package/dist/adapters/angular/inputs.js +181 -0
  8. package/dist/adapters/dotnet/index.js +11 -1
  9. package/dist/cli.js +62 -21
  10. package/dist/core/baseline.js +98 -45
  11. package/dist/core/change-filter.js +68 -0
  12. package/dist/core/config-snapshot.js +36 -6
  13. package/dist/core/config.js +23 -7
  14. package/dist/core/detect.js +2 -2
  15. package/dist/core/doctor.js +51 -7
  16. package/dist/core/environment.js +25 -3
  17. package/dist/core/git.js +80 -16
  18. package/dist/core/hook.js +9 -6
  19. package/dist/core/install.js +26 -11
  20. package/dist/core/inventory.js +3 -1
  21. package/dist/core/markdown.js +4 -0
  22. package/dist/core/name-owner.js +179 -0
  23. package/dist/core/report.js +13 -0
  24. package/dist/core/run-cache.js +137 -0
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +11 -2
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +170 -29
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/dead-code.js +9 -2
  32. package/dist/core/verify/index.js +42 -8
  33. package/dist/core/verify/log-results.js +127 -0
  34. package/dist/core/verify/orphans.js +175 -0
  35. package/dist/core/verify/removed-tests.js +90 -7
  36. package/dist/core/verify/test-results.js +56 -7
  37. package/package.json +3 -1
  38. package/skills/flagrm-remove/SKILL.md +31 -11
  39. package/skills/flagrm-remove/batch.md +38 -0
  40. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -3,13 +3,15 @@ import { createHash } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import fg from "fast-glob";
6
- import { CONFIG_FILENAMES } from "./config.js";
6
+ import { changeFilter } from "./change-filter.js";
7
7
  import { configSnapshot, projectCommands } from "./config-snapshot.js";
8
8
  import { environmentProblem } from "./environment.js";
9
9
  import { changedFilesSince } from "./git.js";
10
10
  import { buildInventory } from "./inventory.js";
11
+ import { withScopeFiles } from "./scope-files.js";
11
12
  import { JSON_SCHEMA_VERSION, READABLE_BASELINE_VERSIONS, } from "./types.js";
12
13
  import { readJson, relativePath } from "./util.js";
14
+ import { parseTestLog } from "./verify/log-results.js";
13
15
  import { collectTestResults, shortTestNames } from "./verify/test-results.js";
14
16
  /**
15
17
  * `.flagrm/<flag>/` under the config root. A name that isn't safe as a directory
@@ -39,7 +41,7 @@ const MAX_FAILED_NAMES = 20;
39
41
  * `baseline.json` keeps everything.
40
42
  */
41
43
  export function baselineSummary(baseline, file) {
42
- const { config: _config, checks, ...rest } = baseline;
44
+ const { config: _config, acceptedConfig: _accepted, checks, ...rest } = baseline;
43
45
  return {
44
46
  ...rest,
45
47
  ...(checks
@@ -84,10 +86,11 @@ export function readBaseline(root, flag) {
84
86
  * Run one shell command inside a project, streaming its output to `logFile`
85
87
  * (no buffer limit, stdout and stderr interleaved as printed). A command that
86
88
  * can't start, is killed or outlives the project's `timeout` fails with the
87
- * reason at the end of the log. A test run also reads the project's
88
- * `testResults` files written during it.
89
+ * reason at the end of the log. A test run also reads the `testResults`
90
+ * files (absolute path or glob) written during it, or without any, what its
91
+ * log says (`log-results.ts`).
89
92
  */
90
- export async function runCheck(ctx, check, command, logFile, claimed) {
93
+ export async function runCheck(ctx, check, command, logFile, claimed, testResults) {
91
94
  const started = Date.now();
92
95
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
93
96
  fs.writeFileSync(logFile, `$ ${command}\n`, "utf8");
@@ -110,14 +113,17 @@ export async function runCheck(ctx, check, command, logFile, claimed) {
110
113
  durationMs: Date.now() - started,
111
114
  log: logFile,
112
115
  };
116
+ if (reason)
117
+ run.interrupted = reason;
113
118
  if (run.exitCode !== 0) {
114
119
  const problem = environmentProblem(fs.readFileSync(logFile, "utf8"), ctx.root);
115
120
  if (problem)
116
121
  run.failure = { kind: "environment", message: problem };
117
122
  }
118
- const results = check === "test" && ctx.project.testResults
119
- ? collectTestResults(ctx.project.testResults, started, claimed)
120
- : undefined;
123
+ let results = check === "test" && testResults ? collectTestResults(testResults, started, claimed) : undefined;
124
+ // No result files: the runner's log still names failing tests and prints counts.
125
+ if (check === "test" && !results)
126
+ results = parseTestLog(fs.readFileSync(logFile, "utf8"));
121
127
  if (results)
122
128
  run.results = results;
123
129
  return run;
@@ -191,11 +197,12 @@ export function incrementalBuildGap(adapter, build) {
191
197
  return "`dotnet build` without `--no-incremental` skips up-to-date projects and prints none of their warnings, so the dead-code check may report existing unused code as new: add `--no-incremental` to `build`";
192
198
  }
193
199
  /**
194
- * Build then test every project. A project that configures `build` and/or
195
- * `test` runs exactly those; one that configures neither gets the adapter's
196
- * defaults (`dotnet build --no-incremental`, `npx ng test`, ...).
200
+ * Build, test, then lint every project, each command resolved on its own
201
+ * (see `projectCommands`): the configured one, none for `false`, else the
202
+ * adapter's default (`dotnet build --no-incremental`, `npx ng test`, ...;
203
+ * lint has none).
197
204
  */
198
- export async function runChecks(projects, logDir, only = ["build", "test"]) {
205
+ export async function runChecks(projects, logDir, only = ["build", "test", "lint"]) {
199
206
  const runs = [];
200
207
  // Projects whose `testResults` patterns overlap must not count each other's result files.
201
208
  const claimed = new Map();
@@ -205,7 +212,8 @@ export async function runChecks(projects, logDir, only = ["build", "test"]) {
205
212
  const command = commands[check];
206
213
  if (!command)
207
214
  continue;
208
- runs.push(await runCheck(ctx, check, command, path.join(logDir, `${ctx.name}.${check}.log`), claimed));
215
+ const log = path.join(logDir, `${ctx.name}.${check}.log`);
216
+ runs.push(await runCheck(ctx, check, command, log, claimed, commands.testResults));
209
217
  }
210
218
  }
211
219
  return runs;
@@ -249,23 +257,26 @@ export async function discoverFlag(projects, flag) {
249
257
  const flow = entry?.flow ?? [];
250
258
  const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
251
259
  ...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
252
- ...flow.filter((n) => n.record).map((n) => flowName(projects, n)),
260
+ ...flow.filter((n) => n.record).flatMap((n) => flowNames(projects, n)),
253
261
  ]);
254
262
  const key = (n) => `${n.name}\0${n.file ?? ""}`;
255
263
  const recorded = new Set(names.map(key));
256
264
  const suggestedNames = flow
257
- .filter((n) => !n.record && !recorded.has(key(flowName(projects, n))))
265
+ .filter((n) => !n.record && !recorded.has(key(flowNames(projects, n)[0])))
258
266
  .map(({ record: _record, ...name }) => name)
259
267
  .filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
260
268
  return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
261
269
  }
262
- /** A flow name as a wrapper; a local, parameter or field only in its own file. */
263
- function flowName(projects, n) {
270
+ /** A flow name as recorded: an alias everywhere; a wrapper, a local, parameter or field only in its own file. */
271
+ function flowNames(projects, n) {
272
+ if (n.kind === "alias")
273
+ return [{ name: n.name, kind: "alias", source: "discovery" }];
264
274
  const name = { name: n.name, kind: "wrapper", source: "discovery" };
265
275
  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;
276
+ if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field")) {
277
+ return withScopeFiles(projects, root, relativePath(root, n.file)).map((file) => ({ ...name, file }));
278
+ }
279
+ return [name];
269
280
  }
270
281
  /** The names {@link discoverFlag} records. */
271
282
  export async function discoverNames(projects, flag) {
@@ -290,29 +301,33 @@ export function mergeNames(existing, added) {
290
301
  }
291
302
  /**
292
303
  * 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).
304
+ * `.flagrm/`, what `ignore` names (flagrm's setup files, which may change to
305
+ * fix the setup, and `exclude`d paths), and the files matching `testResults`
306
+ * patterns (absolute), which the baseline's own test run writes. Undefined
307
+ * when git can't tell (not a repository, or `sha` isn't in it).
297
308
  */
298
- export function editsSince(root, sha, testResults = []) {
299
- const results = new Set(testResults.flatMap((pattern) => fg
309
+ export function editsSince(root, sha, testResults = [], ignore = changeFilter()) {
310
+ const results = matchingFiles(root, testResults);
311
+ return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
312
+ }
313
+ /** The files (relative to `root`) matching absolute paths or globs, such as `testResults` patterns. */
314
+ export function matchingFiles(root, patterns) {
315
+ return new Set(patterns.flatMap((pattern) => fg
300
316
  .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
301
317
  .map((file) => relativePath(root, file))));
302
- return changedFilesSince(root, sha)?.filter((rel) => !CONFIG_FILENAMES.includes(rel) && !results.has(rel));
303
318
  }
304
319
  /**
305
320
  * `baseline --force` starts over only before the removal began: the working
306
321
  * tree must still match the baseline commit. Otherwise the new baseline would
307
322
  * record half-removed code, and verify would no longer see those edits.
308
323
  * Without git there is nothing to compare, so it is allowed. Files matching
309
- * `testResults` (absolute patterns) are not edits.
324
+ * `testResults` (absolute patterns) and the ones `ignore` names are not edits.
310
325
  */
311
- export function assertNoEditsSinceBaseline(root, flag, testResults = []) {
326
+ export function assertNoEditsSinceBaseline(root, flag, testResults = [], ignore = changeFilter()) {
312
327
  const sha = readJson(baselinePath(root, flag))?.git?.sha;
313
328
  if (!sha)
314
329
  return;
315
- const edits = editsSince(root, sha, testResults);
330
+ const edits = editsSince(root, sha, testResults, ignore);
316
331
  if (edits === undefined) {
317
332
  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
333
  `code was edited since. If it wasn't, delete ${relativePath(root, flagDir(root, flag))}/ and take a new baseline.`);
@@ -320,12 +335,42 @@ export function assertNoEditsSinceBaseline(root, flag, testResults = []) {
320
335
  if (edits.length === 0)
321
336
  return;
322
337
  const shown = edits.slice(0, 5).join(", ") + (edits.length > 5 ? `, and ${edits.length - 5} more` : "");
323
- const uncommitted = new Set(changedFilesSince(root, "HEAD") ?? []);
338
+ const uncommitted = new Set(changedFilesSince(root, "HEAD", ignore) ?? []);
324
339
  const fix = edits.every((rel) => uncommitted.has(rel))
325
340
  ? `Set the edits aside with \`git stash -u\`, run \`flagrm baseline ${flag} --force\` again, then \`git stash pop\`.`
326
341
  : `Some are committed since the baseline commit ${sha.slice(0, 7)}: check out code without the removal first.`;
327
342
  throw new UsageError(`The code changed since the baseline of ${flag} (${shown}), so --force would record a half-done removal. ${fix}`);
328
343
  }
344
+ /** Parse `--accept config,failures`; throws on anything else, or nothing. */
345
+ export function parseAccept(value) {
346
+ const items = value
347
+ .split(",")
348
+ .map((s) => s.trim())
349
+ .filter(Boolean);
350
+ if (items.length === 0)
351
+ throw new UsageError("--accept takes config, failures; got nothing.");
352
+ for (const item of items) {
353
+ if (item !== "config" && item !== "failures") {
354
+ throw new UsageError(`--accept takes config, failures; not "${item}".`);
355
+ }
356
+ }
357
+ return [...new Set(items)];
358
+ }
359
+ /**
360
+ * Record the user's acknowledgement: the current configuration (verify then
361
+ * warns only about later changes) and/or the baseline's failing tests (listed
362
+ * as info). The git state, checks and names stay as they are, so this is
363
+ * safe mid-removal, unlike `--force`.
364
+ */
365
+ export function acceptBaseline(root, flag, projects, what) {
366
+ const { baseline } = readBaseline(root, flag);
367
+ const next = { ...baseline };
368
+ if (what.includes("config"))
369
+ next.acceptedConfig = configSnapshot(projects);
370
+ if (what.includes("failures"))
371
+ next.acceptedFailures = true;
372
+ return saveBaseline(root, next);
373
+ }
329
374
  /** Append names to an existing baseline; its git state and checks stay as they are. */
330
375
  export function addNames(root, flag, added) {
331
376
  const { baseline } = readBaseline(root, flag);
@@ -344,30 +389,38 @@ export function validateAgentNames(projects, names) {
344
389
  }
345
390
  }
346
391
  }
347
- /**
348
- * Up to `max` distinct lines of a log that look like errors, so the reader sees
349
- * why a command failed without opening the log. Compiler warnings and summary
350
- * lines such as `0 Error(s)` are skipped: in a large build they bury the one
351
- * real error. When no line looks like an error, the last `max` lines instead.
352
- */
353
- export function errorExcerpt(log, max = 10) {
392
+ /** A log's non-blank lines, trimmed, without ANSI colors or the `$ <command>` header; undefined when unreadable. */
393
+ export function logLines(log) {
354
394
  if (!log)
355
- return [];
395
+ return undefined;
356
396
  let text;
357
397
  try {
358
398
  text = fs.readFileSync(log, "utf8");
359
399
  }
360
400
  catch {
361
- return [];
401
+ return undefined;
362
402
  }
363
- const all = text
403
+ return (text
364
404
  .split(/\r?\n/)
365
405
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors (ngc colors even when piped)
366
406
  .map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").trim())
367
- .filter((line) => line && !line.startsWith("$ "));
368
- const lines = all
369
- .filter((line) => /\berror\b|\bfailed\b|✗|×/i.test(line))
370
- .filter((line) => !/:\s*warning\b/i.test(line) && !/^\d+ Error\(s\)$/i.test(line));
407
+ .filter((line) => line && !line.startsWith("$ ")));
408
+ }
409
+ /** A log line that reports an error: not a compiler warning, nor a summary such as `0 Error(s)`. */
410
+ export function looksLikeError(line) {
411
+ return /\berror\b|\bfailed\b|✗|×/i.test(line) && !/:\s*warning\b/i.test(line) && !/^\d+ Error\(s\)$/i.test(line);
412
+ }
413
+ /**
414
+ * Up to `max` distinct lines of a log that look like errors, so the reader sees
415
+ * why a command failed without opening the log. Compiler warnings and summary
416
+ * lines such as `0 Error(s)` are skipped: in a large build they bury the one
417
+ * real error. When no line looks like an error, the last `max` lines instead.
418
+ */
419
+ export function errorExcerpt(log, max = 10) {
420
+ const all = logLines(log);
421
+ if (!all)
422
+ return [];
423
+ const lines = all.filter(looksLikeError);
371
424
  // No line says error (e.g. "A compatible .NET SDK was not found"): the end of the log usually does.
372
425
  if (!lines.length)
373
426
  return all.slice(-max);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What is not part of a flag removal: flagrm's own setup files (`init`,
3
+ * `update` and `scope --write` write them, often while a removal starts) and
4
+ * the config's `exclude` globs (generated output such as `graphify-out/`).
5
+ * verify's changed files, `baseline --force`, the Stop hook and the tree
6
+ * fingerprint all use this one filter, so verify and the hook agree.
7
+ */
8
+ import picomatch from "picomatch";
9
+ import { CONFIG_FILENAMES, loadConfig } from "./config.js";
10
+ import { AGENT_SKILL_DIRS, SKILLS } from "./install.js";
11
+ import { relativePath } from "./util.js";
12
+ /** The files `flagrm init`, `update` and `scope --write` write, relative to the config root. */
13
+ export const SETUP_PATTERNS = [
14
+ ...CONFIG_FILENAMES,
15
+ ".gitignore",
16
+ ".claude/settings.json",
17
+ ...AGENT_SKILL_DIRS.flatMap((dir) => SKILLS.map((skill) => `${dir}/${skill}/**`)),
18
+ ".github/prompts/flagrm-*.prompt.md",
19
+ "AGENTS.md",
20
+ "**/flagrm.slnf",
21
+ ];
22
+ const isSetup = picomatch([...SETUP_PATTERNS], { dot: true });
23
+ /**
24
+ * The filter for a config: setup files, and paths matching an `exclude` glob
25
+ * relative to the config root or to the path of the project they are in
26
+ * (`exclude` is also read per project when scanning). Without a config, only
27
+ * setup files.
28
+ */
29
+ export function changeFilter(config) {
30
+ const exclude = config?.exclude.length ? excludeMatcher(config.exclude) : undefined;
31
+ const projectDirs = config
32
+ ? config.projects.map((p) => relativePath(config.root, p.path)).filter((dir) => dir && !dir.startsWith(".."))
33
+ : [];
34
+ return (rel) => {
35
+ if (isSetup(rel))
36
+ return true;
37
+ if (!exclude)
38
+ return false;
39
+ if (exclude(rel))
40
+ return true;
41
+ return projectDirs.some((dir) => rel.startsWith(`${dir}/`) && exclude(rel.slice(dir.length + 1)));
42
+ };
43
+ }
44
+ /**
45
+ * Matches `exclude` the way scanning's fast-glob `ignore` does: a pattern
46
+ * that matches a directory (`graphify-out`, `graphify-out/`) excludes
47
+ * everything under it, so test the path and each of its parent directories.
48
+ * `covers` reads the same way.
49
+ */
50
+ export function excludeMatcher(patterns) {
51
+ const isMatch = picomatch(patterns.map((p) => p.replace(/\/+$/, "")), { dot: true });
52
+ return (rel) => {
53
+ for (let end = rel.length; end > 0; end = rel.lastIndexOf("/", end - 1)) {
54
+ if (isMatch(rel.slice(0, end)))
55
+ return true;
56
+ }
57
+ return false;
58
+ };
59
+ }
60
+ /** {@link changeFilter} for the config at `root`; only setup files when it can't be loaded. Never throws. */
61
+ export function changeFilterAt(root) {
62
+ try {
63
+ return changeFilter(loadConfig({ quiet: true }, root));
64
+ }
65
+ catch {
66
+ return changeFilter();
67
+ }
68
+ }
@@ -8,10 +8,34 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import YAML from "yaml";
10
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. */
11
+ /**
12
+ * A project's build/test/lint commands, each resolved on its own: the
13
+ * configured one, none for `false`, else the adapter's default (lint has none). `testResults` defaults
14
+ * only along with the default test command, whose output location it names.
15
+ */
12
16
  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) ?? {});
17
+ const fallback = adapter.defaultCommands?.(ctx) ?? {};
18
+ const out = { defaults: [] };
19
+ for (const key of ["build", "test"]) {
20
+ const configured = ctx.project[key];
21
+ if (configured === false)
22
+ continue;
23
+ if (configured !== undefined)
24
+ out[key] = configured;
25
+ else if (fallback[key]) {
26
+ out[key] = fallback[key];
27
+ out.defaults.push(key);
28
+ }
29
+ }
30
+ if (typeof ctx.project.lint === "string")
31
+ out.lint = ctx.project.lint;
32
+ if (ctx.project.testResults)
33
+ out.testResults = ctx.project.testResults;
34
+ else if (out.defaults.includes("test") && fallback.testResults) {
35
+ out.testResults = fallback.testResults;
36
+ out.defaults.push("testResults");
37
+ }
38
+ return out;
15
39
  }
16
40
  /** Solution and project files: a removal may edit them, so they don't count as a command's configuration. */
17
41
  const PROJECT_FILE = /\.(sln|slnx|\w*proj)$/i;
@@ -36,8 +60,10 @@ export function configSnapshot(projects) {
36
60
  entry.build = command(commands.build);
37
61
  if (commands.test)
38
62
  entry.test = command(commands.test);
39
- if (ctx.project.testResults)
40
- entry.testResults = relativePath(root, ctx.project.testResults);
63
+ if (commands.lint)
64
+ entry.lint = command(commands.lint);
65
+ if (commands.testResults)
66
+ entry.testResults = relativePath(root, commands.testResults);
41
67
  snapshot.projects.push(entry);
42
68
  }
43
69
  return snapshot;
@@ -79,6 +105,7 @@ export function configChanges(before, now) {
79
105
  for (const [kind, check] of [
80
106
  ["build", "build"],
81
107
  ["test", "tests"],
108
+ ["lint", "lint"],
82
109
  ]) {
83
110
  const a = was?.[kind];
84
111
  const b = is?.[kind];
@@ -139,11 +166,14 @@ function withoutCommands(text) {
139
166
  const strip = (project) => {
140
167
  if (!project || typeof project !== "object")
141
168
  return project;
142
- const { build: _b, test: _t, testResults: _r, timeout: _o, ...rest } = project;
169
+ // `covers` changes no command's result, and `coverage` always reads the current config.
170
+ const { build: _b, test: _t, lint: _l, testResults: _r, timeout: _o, covers: _c, ...rest } = project;
143
171
  return rest;
144
172
  };
145
173
  if (parsed && typeof parsed === "object") {
146
174
  const config = { ...parsed };
175
+ // Only says which agents `init` sets up; never changes what is scanned.
176
+ delete config.tools;
147
177
  if (Array.isArray(config.projects))
148
178
  config.projects = config.projects.map(strip);
149
179
  for (const legacy of ["frontend", "backend"]) {
@@ -48,9 +48,11 @@ const KNOWN_PROJECT_KEYS = [
48
48
  "path",
49
49
  "build",
50
50
  "test",
51
+ "lint",
51
52
  "testResults",
52
53
  "timeout",
53
54
  "globs",
55
+ "covers",
54
56
  "methods",
55
57
  "attributes",
56
58
  ];
@@ -135,6 +137,16 @@ function optionalString(value, where) {
135
137
  throw new Error(`${where} must be a non-empty string.`);
136
138
  return value;
137
139
  }
140
+ /** A build/test command: a non-empty string, or `false` to run none (not even the adapter's default). */
141
+ function optionalCommand(value, where) {
142
+ if (value === false)
143
+ return false;
144
+ if (value === undefined)
145
+ return undefined;
146
+ if (typeof value !== "string" || !value.trim())
147
+ throw new Error(`${where} must be a non-empty string or false.`);
148
+ return value;
149
+ }
138
150
  function optionalPositiveNumber(value, where) {
139
151
  if (value === undefined)
140
152
  return undefined;
@@ -162,7 +174,7 @@ function adapterDefaults(adapter, file) {
162
174
  return { methods: backend, attributes };
163
175
  return { methods: [...new Set([...frontend, ...backend])], attributes };
164
176
  }
165
- function parseProject(raw, index, file, fileDir) {
177
+ function parseProject(raw, index, file, fileDir, quiet = false) {
166
178
  const where = `projects[${index}]`;
167
179
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
168
180
  throw new Error(`${where} must be an object with name, adapter and path.`);
@@ -174,7 +186,8 @@ function parseProject(raw, index, file, fileDir) {
174
186
  if (!PROJECT_NAME.test(name)) {
175
187
  throw new Error(`${where}: name "${name}" must start with a letter or digit and contain only letters, digits, _ . -`);
176
188
  }
177
- warnOnUnknownKeys(p, KNOWN_PROJECT_KEYS, `project "${name}"`);
189
+ if (!quiet)
190
+ warnOnUnknownKeys(p, KNOWN_PROJECT_KEYS, `project "${name}"`);
178
191
  if (typeof p.adapter !== "string" || !ADAPTER_IDS.includes(p.adapter)) {
179
192
  throw new Error(`${where}: adapter "${String(p.adapter)}" is not one of ${ADAPTER_IDS.join(", ")}.`);
180
193
  }
@@ -187,16 +200,18 @@ function parseProject(raw, index, file, fileDir) {
187
200
  name,
188
201
  adapter,
189
202
  path: resolveProjectDir(fileDir, p.path, `Project "${name}"`),
190
- build: optionalString(p.build, `${where}.build`),
191
- test: optionalString(p.test, `${where}.test`),
203
+ build: optionalCommand(p.build, `${where}.build`),
204
+ test: optionalCommand(p.test, `${where}.test`),
205
+ lint: optionalCommand(p.lint, `${where}.lint`),
192
206
  testResults: testResults && path.resolve(fileDir, testResults),
193
207
  timeout: optionalPositiveNumber(p.timeout, `${where}.timeout`),
194
208
  globs: optionalStringList(p.globs, `${where}.globs`),
209
+ covers: optionalStringList(p.covers, `${where}.covers`),
195
210
  methods: optionalStringList(p.methods, `${where}.methods`) ?? defaults.methods,
196
211
  attributes: optionalStringList(p.attributes, `${where}.attributes`) ?? defaults.attributes,
197
212
  };
198
213
  // Keep the resolved shape free of absent optional keys.
199
- for (const key of ["build", "test", "testResults", "timeout", "globs"]) {
214
+ for (const key of ["build", "test", "lint", "testResults", "timeout", "globs", "covers"]) {
200
215
  if (project[key] === undefined)
201
216
  delete project[key];
202
217
  }
@@ -221,12 +236,13 @@ export function loadConfig(cli, cwd = process.cwd()) {
221
236
  if (configPath && fs.existsSync(configPath)) {
222
237
  file = readConfigFile(configPath);
223
238
  fileDir = path.dirname(configPath);
224
- warnOnUnknownKeys(file, KNOWN_CONFIG_KEYS, configPath);
239
+ if (!cli.quiet)
240
+ warnOnUnknownKeys(file, KNOWN_CONFIG_KEYS, configPath);
225
241
  }
226
242
  if (file.projects !== undefined && !Array.isArray(file.projects)) {
227
243
  throw new Error(`"projects" must be a list of { name, adapter, path } entries.`);
228
244
  }
229
- const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i, file, fileDir));
245
+ const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i, file, fileDir, cli.quiet));
230
246
  const legacyProject = (name, adapter, dir) => ({
231
247
  name,
232
248
  adapter,
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import path from "node:path";
8
8
  import fg from "fast-glob";
9
+ import { detectTestRunner } from "./test-runner.js";
9
10
  const IGNORE = ["**/node_modules/**", "**/.git/**", "**/bin/**", "**/obj/**", "**/dist/**"];
10
11
  const depth = (file) => file.split("/").length;
11
12
  /** Markers whose directory isn't inside another marker's directory, one per directory. */
@@ -35,8 +36,7 @@ export function detectProjects(cwd) {
35
36
  const found = [];
36
37
  for (const marker of topmost(find("**/angular.json"))) {
37
38
  const dir = path.posix.dirname(marker);
38
- const jest = fg.sync("jest.config.{js,ts,mjs,cjs}", { cwd: path.join(cwd, dir) }).length > 0;
39
- found.push({ adapter: "angular", path: dir, marker, jest });
39
+ found.push({ adapter: "angular", path: dir, marker, testRunner: detectTestRunner(path.join(cwd, dir)) });
40
40
  }
41
41
  const solutions = topmost(find("**/*.{sln,slnx}"));
42
42
  if (solutions.length) {
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * `flagrm doctor`: is this repository ready for an agent to remove a flag?
3
- * Config, build/test commands, git, .gitignore, the installed skills and
3
+ * Config, build/test/lint commands, git, .gitignore, the installed skills and
4
4
  * hook, and baselines left behind by abandoned removals.
5
5
  */
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { realProbes } from "../adapters/dotnet/setup.js";
9
9
  import { errorExcerpt, incrementalBuildGap, runCheck, templateCheckGap } from "./baseline.js";
10
+ import { changeFilter, changeFilterAt } from "./change-filter.js";
10
11
  import { findConfigFile, loadConfig, TOOL_IDS } from "./config.js";
11
12
  import { projectCommands } from "./config-snapshot.js";
12
13
  import { gitState, isAncestor } from "./git.js";
@@ -14,8 +15,20 @@ import { installedSkillVersion, SKILLS, stopHookState, TOOL_SKILL_DIRS } from ".
14
15
  import { packageVersion } from "./package.js";
15
16
  import { resolveProjects } from "./registry.js";
16
17
  import { setupReport } from "./scope.js";
18
+ import { detectTestRunner, testResultsHint } from "./test-runner.js";
17
19
  import { savedTools } from "./tools.js";
18
20
  import { onPath, readJson, relativePath } from "./util.js";
21
+ import { hasTestNames } from "./verify/test-results.js";
22
+ /** How to get test results for a project without `testResults`: .NET writes TRX, JavaScript runners by detection. */
23
+ function missingResultsFix(adapterId, ctx, root) {
24
+ const dir = path.join(root, ".flagrm", "test-results", ctx.name);
25
+ const fromProject = relativePath(ctx.root, dir) || ".";
26
+ const fromConfig = `./${relativePath(root, dir)}`;
27
+ if (adapterId === "dotnet") {
28
+ return `add \`--logger trx --results-directory "${fromProject}"\` to the test command and set testResults: ${fromConfig}/*.trx`;
29
+ }
30
+ return testResultsHint(detectTestRunner(ctx.root), fromProject, fromConfig, adapterId === "angular", root);
31
+ }
19
32
  /** Check the setup in `cwd`: config and commands, git, .gitignore, skills, hook and abandoned baselines. */
20
33
  export async function runDoctor(cwd, options = {}) {
21
34
  const checks = [];
@@ -25,10 +38,17 @@ export async function runDoctor(cwd, options = {}) {
25
38
  const root = configFile ? path.dirname(configFile) : cwd;
26
39
  await configChecks(cwd, configFile, options, add);
27
40
  const git = gitState(root);
41
+ // flagrm's own setup files and `exclude`d paths aren't part of a removal, so they needn't be committed first.
42
+ const dirty = git.dirtyFiles ?? [];
43
+ const ignore = changeFilterAt(root);
44
+ const other = dirty.filter((rel) => !ignore(rel));
45
+ const setup = dirty.filter(changeFilter());
28
46
  if (git.sha === null)
29
47
  add("warn", "not a git repository: verify can't diff against the baseline");
30
- else if (git.dirty)
31
- add("warn", `${git.dirtyFiles?.length ?? 0} uncommitted files — commit or stash before a removal`);
48
+ else if (other.length)
49
+ add("warn", `${other.length} uncommitted files — commit or stash before a removal`);
50
+ else if (setup.length)
51
+ add("ok", `working tree clean apart from flagrm's setup files (${setup.length}): commit them before or with the removal`);
32
52
  else
33
53
  add("ok", "working tree clean");
34
54
  const gitignore = path.join(root, ".gitignore");
@@ -85,10 +105,13 @@ async function configChecks(cwd, configFile, options, add) {
85
105
  setupChecks(projects, root, options.probes ?? realProbes((exe) => onPath(exe, root)), add);
86
106
  for (const { adapter, ctx } of projects) {
87
107
  const commands = projectCommands(adapter, ctx);
88
- for (const check of ["build", "test"]) {
108
+ for (const check of ["build", "test", "lint"]) {
89
109
  const command = commands[check];
90
110
  if (!command) {
91
- add("warn", `${ctx.name}: no ${check} command — verify can't ${check === "build" ? "check the build" : "compare tests"}`);
111
+ // Lint is optional: verify skips it without a command, which is no problem.
112
+ if (check !== "lint") {
113
+ add("warn", `${ctx.name}: no ${check} command — verify can't ${check === "build" ? "check the build" : "compare tests"}`);
114
+ }
92
115
  continue;
93
116
  }
94
117
  const missing = simpleCommandProgram(command);
@@ -102,11 +125,32 @@ async function configChecks(cwd, configFile, options, add) {
102
125
  add("warn", `${ctx.name}: ${gap}`);
103
126
  }
104
127
  }
128
+ if (check === "test" && !commands.testResults) {
129
+ add("warn", `${ctx.name}: test command has no testResults — verify reads failing tests and counts from the log only and deleted tests from the diff, so it can't compare tests by name`, [`fix: ${missingResultsFix(adapter.id, ctx, root)}`]);
130
+ }
105
131
  if (!options.run) {
106
- add("ok", `${ctx.name}: ${check} command \`${command}\``);
132
+ const source = commands.defaults.includes(check) ? " (adapter default)" : "";
133
+ add("ok", `${ctx.name}: ${check} command \`${command}\`${source}`);
134
+ continue;
135
+ }
136
+ const log = path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`);
137
+ const run = await runCheck(ctx, check, command, log, undefined, commands.testResults);
138
+ // Proven here rather than halfway through a removal: without readable results, verify can't compare tests.
139
+ if (check === "test" && commands.testResults && !hasTestNames(run.results)) {
140
+ add("fail", `${ctx.name}: \`${command}\` wrote no test results matching ${relativePath(cwd, commands.testResults)}`, run.exitCode === 0 ? undefined : errorExcerpt(run.log, 3));
141
+ continue;
142
+ }
143
+ // A suite that is already red is no setup problem: the baseline records those tests as known failures.
144
+ if (run.exitCode !== 0 && run.results?.failed && run.results.failedNames.length >= run.results.failed) {
145
+ const failed = run.results.failed;
146
+ add("warn", `${ctx.name}: \`${command}\`: ${failed === 1 ? "1 test fails already — the baseline records it as a known failure" : `${failed} tests fail already — the baseline records them as known failures`}, which verify only warns about`);
147
+ continue;
148
+ }
149
+ // Lint debt is no setup problem: verify only warns about a lint run that already failed at the baseline.
150
+ if (check === "lint" && run.exitCode !== 0 && !run.failure && !run.interrupted) {
151
+ add("warn", `${ctx.name}: \`${command}\` fails already (exit ${run.exitCode}) — verify only warns about lint errors the baseline had`, errorExcerpt(run.log, 3));
107
152
  continue;
108
153
  }
109
- const run = await runCheck(ctx, check, command, path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`));
110
154
  if (run.exitCode === 0) {
111
155
  add("ok", `${ctx.name}: \`${command}\` passed`);
112
156
  continue;