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.
- package/CHANGELOG.md +129 -0
- package/README.md +70 -31
- package/dist/adapters/angular/component-files.js +41 -0
- package/dist/adapters/angular/flow.js +121 -0
- package/dist/adapters/angular/holders.js +194 -0
- package/dist/adapters/angular/index.js +20 -1
- package/dist/adapters/angular/inputs.js +181 -0
- package/dist/adapters/dotnet/index.js +11 -1
- package/dist/cli.js +62 -21
- package/dist/core/baseline.js +98 -45
- package/dist/core/change-filter.js +68 -0
- package/dist/core/config-snapshot.js +36 -6
- package/dist/core/config.js +23 -7
- package/dist/core/detect.js +2 -2
- package/dist/core/doctor.js +51 -7
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +80 -16
- package/dist/core/hook.js +9 -6
- package/dist/core/install.js +26 -11
- package/dist/core/inventory.js +3 -1
- package/dist/core/markdown.js +4 -0
- package/dist/core/name-owner.js +179 -0
- package/dist/core/report.js +13 -0
- package/dist/core/run-cache.js +137 -0
- package/dist/core/scope-files.js +14 -0
- package/dist/core/scope.js +11 -2
- package/dist/core/test-runner.js +136 -0
- package/dist/core/types.js +9 -4
- package/dist/core/verify/commands.js +170 -29
- package/dist/core/verify/coverage.js +92 -0
- package/dist/core/verify/dead-code.js +9 -2
- package/dist/core/verify/index.js +42 -8
- package/dist/core/verify/log-results.js +127 -0
- package/dist/core/verify/orphans.js +175 -0
- package/dist/core/verify/removed-tests.js +90 -7
- package/dist/core/verify/test-results.js +56 -7
- package/package.json +3 -1
- package/skills/flagrm-remove/SKILL.md +31 -11
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
package/dist/core/baseline.js
CHANGED
|
@@ -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 {
|
|
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
|
|
88
|
-
*
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
195
|
-
* `
|
|
196
|
-
*
|
|
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
|
-
|
|
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).
|
|
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(
|
|
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
|
|
263
|
-
function
|
|
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
|
-
|
|
268
|
-
|
|
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/`,
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
/**
|
|
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
|
|
14
|
-
|
|
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 (
|
|
40
|
-
entry.
|
|
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
|
-
|
|
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"]) {
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
191
|
-
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
|
-
|
|
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,
|
package/dist/core/detect.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/core/doctor.js
CHANGED
|
@@ -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 (
|
|
31
|
-
add("warn", `${
|
|
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
|
-
|
|
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
|
-
|
|
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;
|