flagrm 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +56 -29
  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/cli.js +41 -21
  9. package/dist/core/baseline.js +60 -31
  10. package/dist/core/change-filter.js +3 -2
  11. package/dist/core/config-snapshot.js +26 -3
  12. package/dist/core/config.js +5 -1
  13. package/dist/core/detect.js +4 -4
  14. package/dist/core/doctor.js +26 -6
  15. package/dist/core/environment.js +25 -3
  16. package/dist/core/git.js +50 -3
  17. package/dist/core/hook.js +13 -2
  18. package/dist/core/install.js +26 -11
  19. package/dist/core/inventory.js +3 -1
  20. package/dist/core/markdown.js +4 -0
  21. package/dist/core/name-owner.js +179 -0
  22. package/dist/core/report.js +7 -0
  23. package/dist/core/run-cache.js +147 -0
  24. package/dist/core/scan.js +30 -3
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +3 -3
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +160 -22
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/index.js +32 -5
  32. package/dist/core/verify/log-results.js +127 -0
  33. package/dist/core/verify/removed-tests.js +90 -7
  34. package/dist/core/verify/test-results.js +58 -10
  35. package/package.json +4 -4
  36. package/skills/flagrm-remove/SKILL.md +28 -8
  37. package/skills/flagrm-remove/batch.md +38 -0
  38. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -1,7 +1,11 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { discoverInText } from "../../core/discover.js";
3
4
  import { FRONTEND_GLOBS } from "../../core/scan.js";
5
+ import { detectTestRunner, runnerTestCommands } from "../../core/test-runner.js";
6
+ import { componentScopeFiles } from "./component-files.js";
4
7
  import { discoverTypeScript } from "./discover.js";
8
+ import { angularFlow } from "./flow.js";
5
9
  import { angularUnusedDiagnostics } from "./unused.js";
6
10
  /**
7
11
  * Discover candidate flag names: string literals passed to configured eval
@@ -25,10 +29,25 @@ export function discoverAngularFlags(ctx) {
25
29
  }
26
30
  return out;
27
31
  }
32
+ /** The runner per project directory: projectCommands asks for the default commands many times per command. */
33
+ const runners = new Map();
34
+ function testRunner(root) {
35
+ let runner = runners.get(root);
36
+ if (!runner)
37
+ runners.set(root, (runner = detectTestRunner(root)));
38
+ return runner;
39
+ }
28
40
  export const angularAdapter = {
29
41
  id: "angular",
30
42
  defaultGlobs: FRONTEND_GLOBS,
31
43
  discover: async (ctx) => discoverAngularFlags(ctx),
32
- defaultCommands: () => ({ build: "npx ng build", test: "npx ng test --watch=false" }),
44
+ flow: (ctx, flags) => angularFlow(ctx, flags, ctx.project.methods),
45
+ scopeFiles: (_ctx, file) => componentScopeFiles(file),
46
+ // Like the .NET adapter: when the runner can write results without setup, the default test command does, under .flagrm/.
47
+ defaultCommands: (ctx) => {
48
+ const dir = path.join(ctx.config.root, ".flagrm", "test-results", ctx.name);
49
+ const tests = runnerTestCommands(testRunner(ctx.root), ctx.root, dir);
50
+ return { build: "npx ng build", ...(tests ?? { test: "npx ng test --watch=false" }) };
51
+ },
33
52
  unusedDiagnostics: (_ctx, input) => angularUnusedDiagnostics(input),
34
53
  };
@@ -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 });
@@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import fg from "fast-glob";
6
5
  import { changeFilter } from "./change-filter.js";
7
6
  import { configSnapshot, projectCommands } from "./config-snapshot.js";
8
7
  import { environmentProblem } from "./environment.js";
9
8
  import { changedFilesSince } from "./git.js";
10
9
  import { buildInventory } from "./inventory.js";
10
+ import { globAbsolute } from "./scan.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
@@ -78,14 +80,22 @@ export function readBaseline(root, flag) {
78
80
  throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
79
81
  `Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
80
82
  }
83
+ const sha = baseline.git?.sha;
84
+ if (sha != null && !COMMIT_ID.test(sha)) {
85
+ throw new UsageError(`${file} has git.sha "${sha}", which is not a git commit id. ` +
86
+ `Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
87
+ }
81
88
  return { baseline, file };
82
89
  }
90
+ /** A full SHA-1 or SHA-256 commit id, as `git rev-parse HEAD` prints it. */
91
+ const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
83
92
  /**
84
93
  * Run one shell command inside a project, streaming its output to `logFile`
85
94
  * (no buffer limit, stdout and stderr interleaved as printed). A command that
86
95
  * can't start, is killed or outlives the project's `timeout` fails with the
87
96
  * reason at the end of the log. A test run also reads the `testResults`
88
- * files (absolute path or glob) written during it.
97
+ * files (absolute path or glob) written during it, or without any, what its
98
+ * log says (`log-results.ts`).
89
99
  */
90
100
  export async function runCheck(ctx, check, command, logFile, claimed, testResults) {
91
101
  const started = Date.now();
@@ -110,12 +120,17 @@ export async function runCheck(ctx, check, command, logFile, claimed, testResult
110
120
  durationMs: Date.now() - started,
111
121
  log: logFile,
112
122
  };
123
+ if (reason)
124
+ run.interrupted = reason;
113
125
  if (run.exitCode !== 0) {
114
126
  const problem = environmentProblem(fs.readFileSync(logFile, "utf8"), ctx.root);
115
127
  if (problem)
116
128
  run.failure = { kind: "environment", message: problem };
117
129
  }
118
- const results = check === "test" && testResults ? collectTestResults(testResults, started, claimed) : undefined;
130
+ let results = check === "test" && testResults ? collectTestResults(testResults, started, claimed) : undefined;
131
+ // No result files: the runner's log still names failing tests and prints counts.
132
+ if (check === "test" && !results)
133
+ results = parseTestLog(fs.readFileSync(logFile, "utf8"));
119
134
  if (results)
120
135
  run.results = results;
121
136
  return run;
@@ -189,11 +204,12 @@ export function incrementalBuildGap(adapter, build) {
189
204
  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
205
  }
191
206
  /**
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`, ...).
207
+ * Build, test, then lint every project, each command resolved on its own
208
+ * (see `projectCommands`): the configured one, none for `false`, else the
209
+ * adapter's default (`dotnet build --no-incremental`, `npx ng test`, ...;
210
+ * lint has none).
195
211
  */
196
- export async function runChecks(projects, logDir, only = ["build", "test"]) {
212
+ export async function runChecks(projects, logDir, only = ["build", "test", "lint"]) {
197
213
  const runs = [];
198
214
  // Projects whose `testResults` patterns overlap must not count each other's result files.
199
215
  const claimed = new Map();
@@ -248,23 +264,26 @@ export async function discoverFlag(projects, flag) {
248
264
  const flow = entry?.flow ?? [];
249
265
  const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
250
266
  ...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
251
- ...flow.filter((n) => n.record).map((n) => flowName(projects, n)),
267
+ ...flow.filter((n) => n.record).flatMap((n) => flowNames(projects, n)),
252
268
  ]);
253
269
  const key = (n) => `${n.name}\0${n.file ?? ""}`;
254
270
  const recorded = new Set(names.map(key));
255
271
  const suggestedNames = flow
256
- .filter((n) => !n.record && !recorded.has(key(flowName(projects, n))))
272
+ .filter((n) => !n.record && !recorded.has(key(flowNames(projects, n)[0])))
257
273
  .map(({ record: _record, ...name }) => name)
258
274
  .filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
259
275
  return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
260
276
  }
261
- /** A flow name as a wrapper; a local, parameter or field only in its own file. */
262
- function flowName(projects, n) {
277
+ /** A flow name as recorded: an alias everywhere; a wrapper, a local, parameter or field only in its own file. */
278
+ function flowNames(projects, n) {
279
+ if (n.kind === "alias")
280
+ return [{ name: n.name, kind: "alias", source: "discovery" }];
263
281
  const name = { name: n.name, kind: "wrapper", source: "discovery" };
264
282
  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;
283
+ if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field")) {
284
+ return withScopeFiles(projects, root, relativePath(root, n.file)).map((file) => ({ ...name, file }));
285
+ }
286
+ return [name];
268
287
  }
269
288
  /** The names {@link discoverFlag} records. */
270
289
  export async function discoverNames(projects, flag) {
@@ -295,11 +314,13 @@ export function mergeNames(existing, added) {
295
314
  * when git can't tell (not a repository, or `sha` isn't in it).
296
315
  */
297
316
  export function editsSince(root, sha, testResults = [], ignore = changeFilter()) {
298
- const results = new Set(testResults.flatMap((pattern) => fg
299
- .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
300
- .map((file) => relativePath(root, file))));
317
+ const results = matchingFiles(root, testResults);
301
318
  return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
302
319
  }
320
+ /** The files (relative to `root`) matching absolute paths or globs, such as `testResults` patterns. */
321
+ export function matchingFiles(root, patterns) {
322
+ return new Set(patterns.flatMap((pattern) => globAbsolute(pattern).map((file) => relativePath(root, file))));
323
+ }
303
324
  /**
304
325
  * `baseline --force` starts over only before the removal began: the working
305
326
  * tree must still match the baseline commit. Otherwise the new baseline would
@@ -373,30 +394,38 @@ export function validateAgentNames(projects, names) {
373
394
  }
374
395
  }
375
396
  }
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) {
397
+ /** A log's non-blank lines, trimmed, without ANSI colors or the `$ <command>` header; undefined when unreadable. */
398
+ export function logLines(log) {
383
399
  if (!log)
384
- return [];
400
+ return undefined;
385
401
  let text;
386
402
  try {
387
403
  text = fs.readFileSync(log, "utf8");
388
404
  }
389
405
  catch {
390
- return [];
406
+ return undefined;
391
407
  }
392
- const all = text
408
+ return (text
393
409
  .split(/\r?\n/)
394
410
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors (ngc colors even when piped)
395
411
  .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));
412
+ .filter((line) => line && !line.startsWith("$ ")));
413
+ }
414
+ /** A log line that reports an error: not a compiler warning, nor a summary such as `0 Error(s)`. */
415
+ export function looksLikeError(line) {
416
+ return /\berror\b|\bfailed\b|✗|×/i.test(line) && !/:\s*warning\b/i.test(line) && !/^\d+ Error\(s\)$/i.test(line);
417
+ }
418
+ /**
419
+ * Up to `max` distinct lines of a log that look like errors, so the reader sees
420
+ * why a command failed without opening the log. Compiler warnings and summary
421
+ * lines such as `0 Error(s)` are skipped: in a large build they bury the one
422
+ * real error. When no line looks like an error, the last `max` lines instead.
423
+ */
424
+ export function errorExcerpt(log, max = 10) {
425
+ const all = logLines(log);
426
+ if (!all)
427
+ return [];
428
+ const lines = all.filter(looksLikeError);
400
429
  // No line says error (e.g. "A compatible .NET SDK was not found"): the end of the log usually does.
401
430
  if (!lines.length)
402
431
  return all.slice(-max);
@@ -42,11 +42,12 @@ export function changeFilter(config) {
42
42
  };
43
43
  }
44
44
  /**
45
- * Matches `exclude` the way scanning's fast-glob `ignore` does: a pattern
45
+ * Matches `exclude` the way scanning's tinyglobby `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];
@@ -137,6 +142,23 @@ export function configChanges(before, now) {
137
142
  }
138
143
  return changes;
139
144
  }
145
+ /**
146
+ * Whether the config file under `root` changed since `before` in what is
147
+ * scanned or ignored (`exclude`, globs, paths), not just in its commands.
148
+ * False when `before` recorded no config file.
149
+ */
150
+ export function scanConfigChanged(before, root) {
151
+ if (!before?.file)
152
+ return false;
153
+ let text;
154
+ try {
155
+ text = fs.readFileSync(path.join(root, before.file.path), "utf8");
156
+ }
157
+ catch {
158
+ return true;
159
+ }
160
+ return withoutCommands(before.file.text) !== withoutCommands(text);
161
+ }
140
162
  const quote = (text) => (text === undefined ? "(none)" : `\`${text}\``);
141
163
  function changedFiles(a, b) {
142
164
  const hashes = new Map(a.map((f) => [f.path, f.sha256]));
@@ -161,7 +183,8 @@ function withoutCommands(text) {
161
183
  const strip = (project) => {
162
184
  if (!project || typeof project !== "object")
163
185
  return project;
164
- const { build: _b, test: _t, testResults: _r, timeout: _o, ...rest } = project;
186
+ // `covers` changes no command's result, and `coverage` always reads the current config.
187
+ const { build: _b, test: _t, lint: _l, testResults: _r, timeout: _o, covers: _c, ...rest } = project;
165
188
  return rest;
166
189
  };
167
190
  if (parsed && typeof parsed === "object") {