flagrm 0.3.0 → 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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * One hop of a flag's value into a child component: a parent's template
3
+ * binds a holder (`[express]="expressShipping()"`) on an element whose tag is
4
+ * another component's selector, and the child's input of that binding name
5
+ * holds the flag too. Templates are scanned lexically; components are parsed,
6
+ * never type-checked. The child's own bindings are not followed.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import ts from "typescript";
11
+ const ELEMENT_SELECTOR = /^[a-z][\w-]*$/i;
12
+ /** A start tag and its attributes; quoted values may contain `>`. */
13
+ const START_TAG = /<([a-zA-Z][\w-]*)((?:\s+[^\s"'=<>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
14
+ /** `[prop]="expr"` or `[prop]='expr'`; `[attr.x]`, `[class.x]`, `[style.x]` don't match. */
15
+ const PROPERTY_BINDING = /\[([\w$]+)\]\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
16
+ /** `h`, `!h`, `h()` or `!h()`. */
17
+ const HOLDER_READ = /^!?\s*([A-Za-z_$][\w$]*)(?:\s*\(\s*\))?$/;
18
+ const SIGNAL_INPUT = /^(?:input|model)(?:\.required)?$/;
19
+ /**
20
+ * The inputs of child components that parents' templates bind one of
21
+ * `holders` to. An input is `ambiguous` (suggested, never recorded) when its
22
+ * tag maps to several components, or when another binding passes it
23
+ * something else: a reusable component keeps that input after the removal.
24
+ */
25
+ export function childInputs(files, holders) {
26
+ const all = components(files);
27
+ const bySelector = new Map();
28
+ for (const c of all)
29
+ for (const s of c.selectors)
30
+ bySelector.set(s, [...(bySelector.get(s) ?? []), c]);
31
+ const inputs = new Map();
32
+ for (const parent of all) {
33
+ const bound = new Map();
34
+ for (const h of holders) {
35
+ if (h.cls !== parent.cls || h.kind === "local" || h.ternary || h.ambiguous)
36
+ continue;
37
+ bound.set(h.name, (bound.get(h.name) ?? new Set()).add(h.flag));
38
+ }
39
+ const template = templateOf(parent)?.replace(/<!--[\s\S]*?-->/g, "");
40
+ if (!template)
41
+ continue;
42
+ for (const tag of template.matchAll(START_TAG)) {
43
+ const children = bySelector.get(tag[1].toLowerCase());
44
+ if (!children)
45
+ continue;
46
+ for (const binding of tag[2].matchAll(PROPERTY_BINDING)) {
47
+ const read = HOLDER_READ.exec((binding[2] ?? binding[3]).trim());
48
+ const flags = read ? bound.get(read[1]) : undefined;
49
+ for (const child of children) {
50
+ const input = findInput(child, binding[1]);
51
+ if (!input)
52
+ continue;
53
+ const key = `${child.file.file}\0${child.cls.pos}\0${input.name}`;
54
+ const entry = inputs.get(key) ?? { child, input, shared: false, reads: [] };
55
+ inputs.set(key, entry);
56
+ entry.shared ||= children.length > 1;
57
+ entry.reads.push(flags);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ const out = [];
63
+ for (const { child, input, shared, reads } of inputs.values()) {
64
+ const flags = new Set(reads.flatMap((r) => [...(r ?? [])]));
65
+ for (const flag of flags) {
66
+ const ambiguous = shared || reads.some((r) => !r?.has(flag));
67
+ out.push({
68
+ flag,
69
+ name: input.name,
70
+ kind: "field",
71
+ file: child.file.file,
72
+ line: input.line,
73
+ cls: child.cls,
74
+ ...(ambiguous ? { ambiguous: true } : {}),
75
+ });
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /** The `@Component` classes of `files`, with their selectors and template. */
81
+ function components(files) {
82
+ const out = [];
83
+ for (const file of files) {
84
+ if (!file.text.includes("@Component"))
85
+ continue;
86
+ for (const statement of file.sf.statements) {
87
+ if (!ts.isClassDeclaration(statement))
88
+ continue;
89
+ for (const decorator of ts.getDecorators(statement) ?? []) {
90
+ const call = decorator.expression;
91
+ if (!ts.isCallExpression(call) || call.expression.getText(file.sf) !== "Component")
92
+ continue;
93
+ const options = call.arguments[0];
94
+ if (!options || !ts.isObjectLiteralExpression(options))
95
+ continue;
96
+ const info = { file, cls: statement, selectors: [] };
97
+ for (const prop of options.properties) {
98
+ if (!ts.isPropertyAssignment(prop) || !ts.isStringLiteralLike(prop.initializer))
99
+ continue;
100
+ const key = prop.name.getText(file.sf);
101
+ const value = prop.initializer.text;
102
+ if (key === "selector") {
103
+ info.selectors = value
104
+ .split(",")
105
+ .map((s) => s.trim())
106
+ .filter((s) => ELEMENT_SELECTOR.test(s))
107
+ .map((s) => s.toLowerCase());
108
+ }
109
+ else if (key === "template") {
110
+ info.template = value;
111
+ }
112
+ else if (key === "templateUrl") {
113
+ info.templateUrl = path.resolve(path.dirname(file.file), value);
114
+ }
115
+ }
116
+ out.push(info);
117
+ }
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ function templateOf(c) {
123
+ if (c.template !== undefined)
124
+ return c.template;
125
+ if (!c.templateUrl)
126
+ return undefined;
127
+ try {
128
+ return fs.readFileSync(c.templateUrl, "utf8");
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ /** The child's member whose binding name (its `alias`, else its own name) is `binding`. */
135
+ function findInput(child, binding) {
136
+ const { sf } = child.file;
137
+ for (const member of child.cls.members) {
138
+ if (!(ts.isPropertyDeclaration(member) || ts.isSetAccessorDeclaration(member)) || !ts.isIdentifier(member.name)) {
139
+ continue;
140
+ }
141
+ const name = member.name.text;
142
+ const line = sf.getLineAndCharacterOfPosition(member.name.getStart(sf)).line + 1;
143
+ const decorator = (ts.getDecorators(member) ?? [])
144
+ .map((d) => d.expression)
145
+ .find((e) => ts.isCallExpression(e) && e.expression.getText(sf) === "Input");
146
+ if (decorator) {
147
+ if ((aliasOf(decorator.arguments, true) ?? name) === binding)
148
+ return { name, line };
149
+ continue;
150
+ }
151
+ const init = ts.isPropertyDeclaration(member) ? member.initializer : undefined;
152
+ if (init &&
153
+ ts.isCallExpression(init) &&
154
+ SIGNAL_INPUT.test(init.expression.getText(sf)) &&
155
+ (aliasOf(init.arguments, false) ?? name) === binding) {
156
+ return { name, line };
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+ /**
162
+ * An input's alias: `{ alias: 'x' }` among the arguments, or (for `@Input`
163
+ * only, where a signal input's first argument is its initial value) `'x'`.
164
+ */
165
+ function aliasOf(args, stringAlias) {
166
+ for (const arg of args) {
167
+ if (stringAlias && ts.isStringLiteralLike(arg))
168
+ return arg.text;
169
+ if (!ts.isObjectLiteralExpression(arg))
170
+ continue;
171
+ for (const p of arg.properties) {
172
+ if (ts.isPropertyAssignment(p) &&
173
+ ts.isIdentifier(p.name) &&
174
+ p.name.text === "alias" &&
175
+ ts.isStringLiteralLike(p.initializer)) {
176
+ return p.initializer.text;
177
+ }
178
+ }
179
+ }
180
+ return undefined;
181
+ }
package/dist/cli.js CHANGED
@@ -4,20 +4,23 @@ import path from "node:path";
4
4
  import { Command } from "commander";
5
5
  import pc from "picocolors";
6
6
  import { realProbes } from "./adapters/dotnet/setup.js";
7
- import { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, flagDir, mergeNames, parseAccept, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
7
+ import { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, mergeNames, parseAccept, validateAgentNames, writeBaseline, } from "./core/baseline.js";
8
8
  import { changeFilter } from "./core/change-filter.js";
9
9
  import { loadConfig, parseTools, TOOL_IDS } from "./core/config.js";
10
10
  import { projectCommands } from "./core/config-snapshot.js";
11
11
  import { runDoctor } from "./core/doctor.js";
12
- import { gitState } from "./core/git.js";
12
+ import { blobIds, gitState } from "./core/git.js";
13
13
  import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
14
14
  import { initProject, installRoot, updateProject } from "./core/install.js";
15
15
  import { buildInventory } from "./core/inventory.js";
16
16
  import { verifyMarkdown } from "./core/markdown.js";
17
+ import { assertOwnNames } from "./core/name-owner.js";
17
18
  import { packageVersion } from "./core/package.js";
18
19
  import { resolveProjects } from "./core/registry.js";
19
20
  import { printBaseline, printDoctor, printInventory, printScope, printSteps, printVerifyReport, } from "./core/report.js";
21
+ import { baselineRuns } from "./core/run-cache.js";
20
22
  import { setupReport, writeScope } from "./core/scope.js";
23
+ import { withScopeFiles } from "./core/scope-files.js";
21
24
  import { askTools, detectTools, savedTools, TOOL_LABELS } from "./core/tools.js";
22
25
  import { CHECK_IDS } from "./core/types.js";
23
26
  import { onPath, relativePath } from "./core/util.js";
@@ -63,21 +66,23 @@ function loadWorkspace(opts) {
63
66
  // --- baseline ------------------------------------------------------------------
64
67
  addCommonOptions(program
65
68
  .command("baseline <flag>")
66
- .description("record the state before removing a flag: the git commit, each project's build and test results, and the " +
69
+ .description("record the state before removing a flag: the git commit, each project's build, test and lint results, and the " +
67
70
  "flag's names for the leftovers check; writes .flagrm/<flag>/baseline.json for verify. With --name on an " +
68
71
  "existing baseline, only adds names"))
69
- .option("--no-checks", "record only the git state, without running build and test commands")
72
+ .option("--no-checks", "record only the git state, without running build, test and lint commands")
73
+ .option("--no-reuse", "run build, tests and lint even when a passing verify on the same tree and commands saved their results")
70
74
  .option("--name <name>", "add a name the flag is read through (repeatable)", collect, [])
71
75
  .option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included); default alias, wrapper with --file")
72
- .option("--file <path>", "check the --name entries only in this file (a local, parameter or field; implies --kind wrapper)")
76
+ .option("--file <path>", "check the --name entries only in these files and the files sharing their scope, such as a component's template " +
77
+ "(repeatable; a local, parameter or field; implies --kind wrapper)", collect, [])
73
78
  .option("--force", "start over: replace the baseline, only while the code is unedited since it")
74
79
  .option("--accept <what>", "acknowledge without starting over, also mid-removal: config (verify stops warning about changes to the flagrm " +
75
80
  "config so far), failures (the baseline's failing tests); comma-separated")
76
81
  .action(async (flag, opts) => {
77
82
  const { root, projects } = loadWorkspace(opts);
78
83
  if (opts.accept !== undefined) {
79
- if (opts.force || opts.name.length || opts.file || opts.kind || !opts.checks) {
80
- fail(new Error("--accept only records an acknowledgement; run it without --force, --name, --file, --kind and --no-checks."));
84
+ if (opts.force || opts.name.length || opts.file.length || opts.kind || !opts.checks || !opts.reuse) {
85
+ fail(new Error("--accept only records an acknowledgement; run it without --force, --name, --file, --kind, --no-checks and --no-reuse."));
81
86
  }
82
87
  let accepted;
83
88
  try {
@@ -93,26 +98,34 @@ addCommonOptions(program
93
98
  return;
94
99
  }
95
100
  // Only wrappers are matched per file: a name scoped with --file is a local, parameter or field.
96
- const kind = opts.kind ?? (opts.file ? "wrapper" : "alias");
101
+ const kind = opts.kind ?? (opts.file.length ? "wrapper" : "alias");
97
102
  if (kind !== "alias" && kind !== "wrapper") {
98
103
  fail(new Error(`--kind must be alias or wrapper, not "${kind}".`));
99
104
  }
100
- if (opts.file && opts.name.length === 0)
105
+ if (!opts.checks && !opts.reuse)
106
+ fail(new Error("--no-reuse has nothing to reuse with --no-checks."));
107
+ if (opts.file.length && opts.name.length === 0)
101
108
  fail(new Error("--file scopes --name entries; pass --name too."));
102
- if (opts.file && kind !== "wrapper")
109
+ if (opts.file.length && kind !== "wrapper")
103
110
  fail(new Error("--file scopes wrapper names; drop --kind alias."));
104
- const scope = opts.file && relativePath(root, path.resolve(opts.file));
105
- if (scope?.startsWith(".."))
106
- fail(new Error(`--file ${opts.file} is outside ${root}.`));
107
- const added = opts.name.map((name) => ({
111
+ const scopes = [
112
+ ...new Set(opts.file.flatMap((file) => {
113
+ const rel = relativePath(root, path.resolve(file));
114
+ if (rel.startsWith(".."))
115
+ fail(new Error(`--file ${file} is outside ${root}.`));
116
+ return withScopeFiles(projects, root, rel);
117
+ })),
118
+ ];
119
+ const added = opts.name.flatMap((name) => (scopes.length ? scopes : [undefined]).map((file) => ({
108
120
  name,
109
121
  kind: kind,
110
122
  source: "agent",
111
- ...(scope ? { file: scope } : {}),
112
- }));
123
+ ...(file ? { file } : {}),
124
+ })));
113
125
  let result;
114
126
  try {
115
127
  validateAgentNames(projects, added);
128
+ await assertOwnNames(root, projects, flag, added);
116
129
  const exists = fs.existsSync(baselinePath(root, flag));
117
130
  if (exists && opts.force) {
118
131
  const testResults = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
@@ -126,7 +139,9 @@ addCommonOptions(program
126
139
  }
127
140
  else {
128
141
  const git = gitState(root);
129
- const checks = opts.checks ? await runChecks(projects, flagDir(root, flag)) : undefined;
142
+ if (git.dirtyFiles?.length)
143
+ git.dirtyHashes = blobIds(root, git.dirtyFiles);
144
+ const checks = opts.checks ? await baselineRuns(root, flag, projects, opts.reuse) : undefined;
130
145
  const { names, ...flow } = await discoverFlag(projects, flag);
131
146
  result = writeBaseline(root, flag, projects, git, checks, mergeNames(names, added), flow);
132
147
  }
@@ -144,18 +159,23 @@ addCommonOptions(program
144
159
  // --- verify ------------------------------------------------------------------
145
160
  addCommonOptions(program
146
161
  .command("verify <flag>")
147
- .description("gate a flag removal against its baseline (`flagrm baseline <flag>`): build, tests and new unused code; " +
162
+ .description("gate a flag removal against its baseline (`flagrm baseline <flag>`): leftovers, new unused code, build, tests, lint and coverage; " +
148
163
  "writes .flagrm/<flag>/verify.json; exit 0 pass, 1 fail, 2 usage error"))
149
164
  .option("--skip <checks>", `comma-separated checks to skip (${CHECK_IDS.join(", ")})`)
150
165
  .option("--strict", "treat warnings as failures")
151
166
  .option("--md", "print a markdown overview (for the user or a PR) instead of text")
167
+ .option("--no-reuse", "run build, tests and lint even when a passing verify on the same tree and commands saved their results")
152
168
  .action(async (flag, opts) => {
153
169
  if (opts.md && opts.json)
154
170
  fail(new Error("--md and --json can't be combined."));
155
171
  const { root, projects } = loadWorkspace(opts);
156
172
  let result;
157
173
  try {
158
- result = await verifyFlag(root, projects, flag, { skip: parseSkip(opts.skip), strict: opts.strict });
174
+ result = await verifyFlag(root, projects, flag, {
175
+ skip: parseSkip(opts.skip),
176
+ strict: opts.strict,
177
+ reuse: opts.reuse,
178
+ });
159
179
  }
160
180
  catch (err) {
161
181
  fail(err);
@@ -273,10 +293,10 @@ function printInstallRoot(root) {
273
293
  // --- doctor ------------------------------------------------------------------
274
294
  program
275
295
  .command("doctor")
276
- .description("check that this repository is ready for flag removal: config, build/test commands, git, .gitignore, " +
296
+ .description("check that this repository is ready for flag removal: config, build/test/lint commands, git, .gitignore, " +
277
297
  "installed skills and Stop hook, abandoned baselines; exit 0 ok, 1 problems")
278
298
  .option("--config <path>", "path to a config file (default: the nearest flagrm.config.yaml up to the git repository root)")
279
- .option("--run", "also run each build and test command")
299
+ .option("--run", "also run each build, test and lint command")
280
300
  .option("--json", "machine-readable JSON output")
281
301
  .action(async (opts) => {
282
302
  const report = await runDoctor(process.cwd(), { config: opts.config, run: opts.run });
@@ -8,8 +8,10 @@ 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
@@ -85,7 +87,8 @@ export function readBaseline(root, flag) {
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
89
  * reason at the end of the log. A test run also reads the `testResults`
88
- * files (absolute path or glob) written during it.
90
+ * files (absolute path or glob) written during it, or without any, what its
91
+ * log says (`log-results.ts`).
89
92
  */
90
93
  export async function runCheck(ctx, check, command, logFile, claimed, testResults) {
91
94
  const started = Date.now();
@@ -110,12 +113,17 @@ export async function runCheck(ctx, check, command, logFile, claimed, testResult
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" && testResults ? collectTestResults(testResults, started, claimed) : 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"));
119
127
  if (results)
120
128
  run.results = results;
121
129
  return run;
@@ -189,11 +197,12 @@ export function incrementalBuildGap(adapter, build) {
189
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`";
190
198
  }
191
199
  /**
192
- * Build then test every project, each command resolved on its own (see
193
- * `projectCommands`): the configured one, none for `false`, else the
194
- * adapter's default (`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).
195
204
  */
196
- export async function runChecks(projects, logDir, only = ["build", "test"]) {
205
+ export async function runChecks(projects, logDir, only = ["build", "test", "lint"]) {
197
206
  const runs = [];
198
207
  // Projects whose `testResults` patterns overlap must not count each other's result files.
199
208
  const claimed = new Map();
@@ -248,23 +257,26 @@ export async function discoverFlag(projects, flag) {
248
257
  const flow = entry?.flow ?? [];
249
258
  const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
250
259
  ...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
251
- ...flow.filter((n) => n.record).map((n) => flowName(projects, n)),
260
+ ...flow.filter((n) => n.record).flatMap((n) => flowNames(projects, n)),
252
261
  ]);
253
262
  const key = (n) => `${n.name}\0${n.file ?? ""}`;
254
263
  const recorded = new Set(names.map(key));
255
264
  const suggestedNames = flow
256
- .filter((n) => !n.record && !recorded.has(key(flowName(projects, n))))
265
+ .filter((n) => !n.record && !recorded.has(key(flowNames(projects, n)[0])))
257
266
  .map(({ record: _record, ...name }) => name)
258
267
  .filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
259
268
  return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
260
269
  }
261
- /** A flow name as a wrapper; a local, parameter or field only in its own file. */
262
- 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" }];
263
274
  const name = { name: n.name, kind: "wrapper", source: "discovery" };
264
275
  const root = projects[0]?.ctx.config.root;
265
- if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field"))
266
- name.file = relativePath(root, n.file);
267
- 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];
268
280
  }
269
281
  /** The names {@link discoverFlag} records. */
270
282
  export async function discoverNames(projects, flag) {
@@ -295,10 +307,14 @@ export function mergeNames(existing, added) {
295
307
  * when git can't tell (not a repository, or `sha` isn't in it).
296
308
  */
297
309
  export function editsSince(root, sha, testResults = [], ignore = changeFilter()) {
298
- const results = new Set(testResults.flatMap((pattern) => fg
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
299
316
  .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
300
317
  .map((file) => relativePath(root, file))));
301
- return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
302
318
  }
303
319
  /**
304
320
  * `baseline --force` starts over only before the removal began: the working
@@ -373,30 +389,38 @@ export function validateAgentNames(projects, names) {
373
389
  }
374
390
  }
375
391
  }
376
- /**
377
- * Up to `max` distinct lines of a log that look like errors, so the reader sees
378
- * why a command failed without opening the log. Compiler warnings and summary
379
- * lines such as `0 Error(s)` are skipped: in a large build they bury the one
380
- * real error. When no line looks like an error, the last `max` lines instead.
381
- */
382
- 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) {
383
394
  if (!log)
384
- return [];
395
+ return undefined;
385
396
  let text;
386
397
  try {
387
398
  text = fs.readFileSync(log, "utf8");
388
399
  }
389
400
  catch {
390
- return [];
401
+ return undefined;
391
402
  }
392
- const all = text
403
+ return (text
393
404
  .split(/\r?\n/)
394
405
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors (ngc colors even when piped)
395
406
  .map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").trim())
396
- .filter((line) => line && !line.startsWith("$ "));
397
- const lines = all
398
- .filter((line) => /\berror\b|\bfailed\b|✗|×/i.test(line))
399
- .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);
400
424
  // No line says error (e.g. "A compatible .NET SDK was not found"): the end of the log usually does.
401
425
  if (!lines.length)
402
426
  return all.slice(-max);
@@ -45,8 +45,9 @@ export function changeFilter(config) {
45
45
  * Matches `exclude` the way scanning's fast-glob `ignore` does: a pattern
46
46
  * that matches a directory (`graphify-out`, `graphify-out/`) excludes
47
47
  * everything under it, so test the path and each of its parent directories.
48
+ * `covers` reads the same way.
48
49
  */
49
- function excludeMatcher(patterns) {
50
+ export function excludeMatcher(patterns) {
50
51
  const isMatch = picomatch(patterns.map((p) => p.replace(/\/+$/, "")), { dot: true });
51
52
  return (rel) => {
52
53
  for (let end = rel.length; end > 0; end = rel.lastIndexOf("/", end - 1)) {
@@ -9,8 +9,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 commands, each resolved on its own: the configured
13
- * one, none for `false`, else the adapter's default. `testResults` defaults
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
14
  * only along with the default test command, whose output location it names.
15
15
  */
16
16
  export function projectCommands(adapter, ctx) {
@@ -27,6 +27,8 @@ export function projectCommands(adapter, ctx) {
27
27
  out.defaults.push(key);
28
28
  }
29
29
  }
30
+ if (typeof ctx.project.lint === "string")
31
+ out.lint = ctx.project.lint;
30
32
  if (ctx.project.testResults)
31
33
  out.testResults = ctx.project.testResults;
32
34
  else if (out.defaults.includes("test") && fallback.testResults) {
@@ -58,6 +60,8 @@ export function configSnapshot(projects) {
58
60
  entry.build = command(commands.build);
59
61
  if (commands.test)
60
62
  entry.test = command(commands.test);
63
+ if (commands.lint)
64
+ entry.lint = command(commands.lint);
61
65
  if (commands.testResults)
62
66
  entry.testResults = relativePath(root, commands.testResults);
63
67
  snapshot.projects.push(entry);
@@ -101,6 +105,7 @@ export function configChanges(before, now) {
101
105
  for (const [kind, check] of [
102
106
  ["build", "build"],
103
107
  ["test", "tests"],
108
+ ["lint", "lint"],
104
109
  ]) {
105
110
  const a = was?.[kind];
106
111
  const b = is?.[kind];
@@ -161,7 +166,8 @@ function withoutCommands(text) {
161
166
  const strip = (project) => {
162
167
  if (!project || typeof project !== "object")
163
168
  return project;
164
- 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;
165
171
  return rest;
166
172
  };
167
173
  if (parsed && typeof parsed === "object") {
@@ -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
  ];
@@ -200,14 +202,16 @@ function parseProject(raw, index, file, fileDir, quiet = false) {
200
202
  path: resolveProjectDir(fileDir, p.path, `Project "${name}"`),
201
203
  build: optionalCommand(p.build, `${where}.build`),
202
204
  test: optionalCommand(p.test, `${where}.test`),
205
+ lint: optionalCommand(p.lint, `${where}.lint`),
203
206
  testResults: testResults && path.resolve(fileDir, testResults),
204
207
  timeout: optionalPositiveNumber(p.timeout, `${where}.timeout`),
205
208
  globs: optionalStringList(p.globs, `${where}.globs`),
209
+ covers: optionalStringList(p.covers, `${where}.covers`),
206
210
  methods: optionalStringList(p.methods, `${where}.methods`) ?? defaults.methods,
207
211
  attributes: optionalStringList(p.attributes, `${where}.attributes`) ?? defaults.attributes,
208
212
  };
209
213
  // Keep the resolved shape free of absent optional keys.
210
- for (const key of ["build", "test", "testResults", "timeout", "globs"]) {
214
+ for (const key of ["build", "test", "lint", "testResults", "timeout", "globs", "covers"]) {
211
215
  if (project[key] === undefined)
212
216
  delete project[key];
213
217
  }
@@ -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) {