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.
@@ -1,6 +1,6 @@
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";
@@ -15,8 +15,20 @@ import { installedSkillVersion, SKILLS, stopHookState, TOOL_SKILL_DIRS } from ".
15
15
  import { packageVersion } from "./package.js";
16
16
  import { resolveProjects } from "./registry.js";
17
17
  import { setupReport } from "./scope.js";
18
+ import { detectTestRunner, testResultsHint } from "./test-runner.js";
18
19
  import { savedTools } from "./tools.js";
19
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
+ }
20
32
  /** Check the setup in `cwd`: config and commands, git, .gitignore, skills, hook and abandoned baselines. */
21
33
  export async function runDoctor(cwd, options = {}) {
22
34
  const checks = [];
@@ -93,10 +105,13 @@ async function configChecks(cwd, configFile, options, add) {
93
105
  setupChecks(projects, root, options.probes ?? realProbes((exe) => onPath(exe, root)), add);
94
106
  for (const { adapter, ctx } of projects) {
95
107
  const commands = projectCommands(adapter, ctx);
96
- for (const check of ["build", "test"]) {
108
+ for (const check of ["build", "test", "lint"]) {
97
109
  const command = commands[check];
98
110
  if (!command) {
99
- add("warn", `${ctx.name}: no ${check} command — verify can't ${check === "build" ? "check the build" : "compare tests"}`);
111
+ // Lint is optional: verify skips it without a command, which is no problem.
112
+ if (check !== "lint") {
113
+ add("warn", `${ctx.name}: no ${check} command — verify can't ${check === "build" ? "check the build" : "compare tests"}`);
114
+ }
100
115
  continue;
101
116
  }
102
117
  const missing = simpleCommandProgram(command);
@@ -111,7 +126,7 @@ async function configChecks(cwd, configFile, options, add) {
111
126
  }
112
127
  }
113
128
  if (check === "test" && !commands.testResults) {
114
- add("warn", `${ctx.name}: test command has no testResults — verify can't compare tests by name, so a test that fails before the removal fails verify too`);
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)}`]);
115
130
  }
116
131
  if (!options.run) {
117
132
  const source = commands.defaults.includes(check) ? " (adapter default)" : "";
@@ -121,16 +136,21 @@ async function configChecks(cwd, configFile, options, add) {
121
136
  const log = path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`);
122
137
  const run = await runCheck(ctx, check, command, log, undefined, commands.testResults);
123
138
  // Proven here rather than halfway through a removal: without readable results, verify can't compare tests.
124
- if (check === "test" && commands.testResults && !run.results) {
139
+ if (check === "test" && commands.testResults && !hasTestNames(run.results)) {
125
140
  add("fail", `${ctx.name}: \`${command}\` wrote no test results matching ${relativePath(cwd, commands.testResults)}`, run.exitCode === 0 ? undefined : errorExcerpt(run.log, 3));
126
141
  continue;
127
142
  }
128
143
  // A suite that is already red is no setup problem: the baseline records those tests as known failures.
129
- if (run.exitCode !== 0 && run.results?.failed) {
144
+ if (run.exitCode !== 0 && run.results?.failed && run.results.failedNames.length >= run.results.failed) {
130
145
  const failed = run.results.failed;
131
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`);
132
147
  continue;
133
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));
152
+ continue;
153
+ }
134
154
  if (run.exitCode === 0) {
135
155
  add("ok", `${ctx.name}: \`${command}\` passed`);
136
156
  continue;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tell a machine setup problem apart from broken code in a failed build or
3
3
  * test log: a missing .NET SDK, a tool a build step calls that isn't
4
- * installed, Docker not running for Testcontainers. Each becomes one line
5
- * that says what to fix.
4
+ * installed, Docker not running for Testcontainers, a package script or
5
+ * ESLint config that doesn't exist. Each becomes one line that says what to fix.
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
@@ -12,7 +12,13 @@ import { relativePath } from "./util.js";
12
12
  * it shows none. `root` is the project root: paths are reported relative to it.
13
13
  */
14
14
  export function environmentProblem(log, root) {
15
- const problems = [missingSdk(log, root), ...missingCommands(log, root), dockerUnavailable(log)].filter(Boolean);
15
+ const problems = [
16
+ missingSdk(log, root),
17
+ ...missingCommands(log, root),
18
+ dockerUnavailable(log),
19
+ missingScript(log),
20
+ eslintBroken(log),
21
+ ].filter(Boolean);
16
22
  return problems.length ? problems.join("; ") : undefined;
17
23
  }
18
24
  /**
@@ -77,3 +83,19 @@ function dockerUnavailable(log) {
77
83
  return undefined;
78
84
  return "Docker is not running (Testcontainers tests need it): start Docker or leave those tests out of `test`";
79
85
  }
86
+ /** npm and pnpm print `Missing script: "lint"`, Yarn 1 `Command "lint" not found`, Yarn 2+ `Couldn't find a script named "lint"`. */
87
+ function missingScript(log) {
88
+ const name = /\bMissing script: "?([^"\s]+)"?/.exec(log)?.[1] ??
89
+ /\berror Command "([^"]+)" not found/.exec(log)?.[1] ??
90
+ /Couldn't find a script named "([^"]+)"/.exec(log)?.[1];
91
+ if (!name)
92
+ return undefined;
93
+ return `package.json has no "${name}" script: add it or change the command in the config`;
94
+ }
95
+ /** ESLint that can't find its config or crashes exits 2 before it lints anything. */
96
+ function eslintBroken(log) {
97
+ if (!/ESLint couldn't find (?:a configuration file|an eslint\.config)|Oops! Something went wrong! :\(/.test(log)) {
98
+ return undefined;
99
+ }
100
+ return "ESLint could not run (no config found, or it crashed): fix its setup or change the command in the config";
101
+ }
package/dist/core/git.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * to the config root (`cwd`), and flagrm's own `.flagrm/` directory is ignored.
4
4
  */
5
5
  import { spawnSync } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
6
7
  import fs from "node:fs";
7
8
  import os from "node:os";
8
9
  import path from "node:path";
@@ -26,6 +27,34 @@ export function gitState(cwd) {
26
27
  const dirtyFiles = changedFilesSince(cwd, "HEAD") ?? [];
27
28
  return { sha: head.stdout.trim(), dirty: dirtyFiles.length > 0, dirtyFiles };
28
29
  }
30
+ function hashObjects(cwd, files) {
31
+ const result = spawnSync("git", ["hash-object", "--stdin-paths"], {
32
+ cwd,
33
+ encoding: "utf8",
34
+ input: `${files.join("\n")}\n`,
35
+ });
36
+ return result.status === 0 ? (result.stdout ?? "").split("\n") : undefined;
37
+ }
38
+ /**
39
+ * Each file's git blob id as it is in the working tree (null when it doesn't
40
+ * exist), so a later run can tell whether it changed since. A file git can't
41
+ * hash (a submodule directory) gets an id of its own each time, so it always
42
+ * counts as changed. Paths are relative to `cwd`.
43
+ */
44
+ export function blobIds(cwd, files) {
45
+ const ids = {};
46
+ const existing = files.filter((rel) => fs.existsSync(path.join(cwd, rel)));
47
+ for (const rel of files)
48
+ ids[rel] = null;
49
+ if (existing.length === 0)
50
+ return ids;
51
+ // One git call for all files; when it fails, one per file, so a single unhashable path doesn't spoil the rest.
52
+ const out = hashObjects(cwd, existing);
53
+ existing.forEach((rel, i) => {
54
+ ids[rel] = (out ? out[i] : hashObjects(cwd, [rel])?.[0]) || `unhashable:${randomUUID()}`;
55
+ });
56
+ return ids;
57
+ }
29
58
  /**
30
59
  * Files added, modified or deleted in the working tree since `sha` (tracked
31
60
  * diff plus untracked files), without the ones `ignore` names.
@@ -108,13 +137,21 @@ export function treeFingerprint(cwd, ignore) {
108
137
  * doesn't have it), so the fingerprint doesn't see changes to it.
109
138
  */
110
139
  function dropIgnored(cwd, index, ignore) {
111
- const listed = paths(gitWithIndex(cwd, index, ["ls-files", "-z"]).stdout).filter(ignore);
140
+ const head = headSha(cwd);
141
+ // The paths that differ from HEAD, deleted ones included: `ls-files` no longer lists a deleted file.
142
+ // Before the first commit there is no HEAD to compare with, so every path in the index.
143
+ const listing = head
144
+ ? gitWithIndex(cwd, index, ["diff-index", "--cached", "--name-only", "--no-renames", "--relative", "-z", "HEAD"])
145
+ : gitWithIndex(cwd, index, ["ls-files", "-z"]);
146
+ if (!listing.ok)
147
+ return false;
148
+ const listed = paths(listing.stdout).filter(ignore);
112
149
  if (listed.length === 0)
113
150
  return true;
114
151
  const input = `${listed.join("\0")}\0`;
115
152
  const fromFile = ["--pathspec-from-file=-", "--pathspec-file-nul"];
116
153
  // `reset` also drops a path HEAD doesn't have; before the first commit there is no HEAD to reset to.
117
- const args = headSha(cwd) ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
154
+ const args = head ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
118
155
  return gitWithIndex(cwd, index, args, input).ok;
119
156
  }
120
157
  /** The tree id of `cwd` at commit `sha`. */
@@ -11,6 +11,7 @@ import path from "node:path";
11
11
  import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, TOOL_IDS, } from "./config.js";
12
12
  import { detectProjects } from "./detect.js";
13
13
  import { packageRoot, packageVersion } from "./package.js";
14
+ import { runnerTestCommands, testResultsHint } from "./test-runner.js";
14
15
  import { saveTools } from "./tools.js";
15
16
  import { relativePath } from "./util.js";
16
17
  export const SKILLS = ["flagrm-remove", "flagrm-verify"];
@@ -27,11 +28,11 @@ export const PROMPT_FILES = {
27
28
  "flagrm-remove": [
28
29
  "---",
29
30
  "description: Remove a feature flag with flagrm's guardrails (keep the ON path, verify until it passes)",
30
- "argument-hint: <flag>",
31
+ "argument-hint: <flag> [flag...]",
31
32
  "agent: agent",
32
33
  "---",
33
34
  "",
34
- "Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flag named after this command.",
35
+ "Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flags named after this command.",
35
36
  "",
36
37
  ].join("\n"),
37
38
  "flagrm-verify": [
@@ -61,8 +62,8 @@ export const AGENTS_BLOCK = [
61
62
  AGENTS_START,
62
63
  "## Feature flag removal (flagrm)",
63
64
  "",
64
- "To remove a feature flag, follow `.agents/skills/flagrm-remove/SKILL.md`. To check a removal and get its",
65
- "commit message, follow `.agents/skills/flagrm-verify/SKILL.md`.",
65
+ "To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To check a removal",
66
+ "later, follow `.agents/skills/flagrm-verify/SKILL.md`.",
66
67
  AGENTS_END,
67
68
  ].join("\n");
68
69
  const CONFIG_HEADER = [
@@ -89,14 +90,21 @@ const EXAMPLE_PROJECTS = [
89
90
  { name: "backend", adapter: "dotnet", path: "backend", marker: "backend/Backend.sln" },
90
91
  ];
91
92
  /** The config lines for one project, with commented-out commands that fit it. */
92
- function projectLines(p) {
93
+ function projectLines(p, cwd) {
93
94
  const dir = p.path === "." ? "." : `./${p.path}`;
94
95
  const inDir = (rel) => (p.path === "." ? `./${rel}` : `${dir}/${rel}`);
95
96
  const lines = [` - name: ${p.name}`, ` adapter: ${p.adapter}`, ` path: ${dir}`];
96
97
  if (p.adapter === "angular") {
97
- lines.push(" # build: npx ng build", p.jest
98
- ? " # test: JEST_JUNIT_OUTPUT_DIR=test-results npx jest --ci --reporters=default --reporters=jest-junit"
99
- : " # test: npx ng test --watch=false", ` # testResults: ${inDir("test-results/junit.xml")} # JUnit XML, so verify can count tests`, ` # methods: [${DEFAULT_FRONTEND_METHODS.join(", ")}] # flag evaluation methods (defaults shown)`);
98
+ const results = path.join(cwd, ".flagrm", "test-results", p.name);
99
+ const runner = p.testRunner ?? { kind: "unknown" };
100
+ const tests = runnerTestCommands(runner, path.join(cwd, p.path), results);
101
+ lines.push(" # build: npx ng build", ` # test: ${tests?.test ?? "npx ng test --watch=false"}`, tests
102
+ ? ` # testResults: ./${relativePath(cwd, tests.testResults)} # the default test command writes these`
103
+ : ` # testResults: ${inDir("test-results/junit.xml")} # JUnit XML, TRX or Jest JSON, so verify can compare tests`, ...(tests
104
+ ? []
105
+ : [
106
+ ` # to get test results: ${testResultsHint(runner, relativePath(path.join(cwd, p.path), results) || ".", `./${relativePath(cwd, results)}`, true, cwd)}`,
107
+ ]), ` # methods: [${DEFAULT_FRONTEND_METHODS.join(", ")}] # flag evaluation methods (defaults shown)`);
100
108
  }
101
109
  else {
102
110
  const solution = /\.slnx?$/.test(p.marker) ? ` ${path.posix.basename(p.marker)}` : "";
@@ -104,13 +112,20 @@ function projectLines(p) {
104
112
  }
105
113
  return lines;
106
114
  }
107
- export function configTemplate(projects, tools) {
115
+ export function configTemplate(projects, tools, cwd = process.cwd()) {
108
116
  const listed = projects.length ? projects : EXAMPLE_PROJECTS;
109
117
  const [comment1, comment2, projectsKey] = CONFIG_HEADER;
110
118
  const toolLines = tools
111
119
  ? ["# The AI coding tools `flagrm init` and `flagrm update` set up.", `tools: [${tools.join(", ")}]`]
112
120
  : [];
113
- return [comment1, comment2, ...toolLines, projectsKey, ...listed.flatMap(projectLines), ...CONFIG_FOOTER].join("\n");
121
+ return [
122
+ comment1,
123
+ comment2,
124
+ ...toolLines,
125
+ projectsKey,
126
+ ...listed.flatMap((p) => projectLines(p, cwd)),
127
+ ...CONFIG_FOOTER,
128
+ ].join("\n");
114
129
  }
115
130
  /** Write `flagrm.config.yaml` for the projects found in `cwd` (an example when none are) unless a config file exists. */
116
131
  export function writeConfigTemplate(cwd, tools) {
@@ -119,7 +134,7 @@ export function writeConfigTemplate(cwd, tools) {
119
134
  return { path: relativePath(cwd, existing), status: "skipped" };
120
135
  const file = path.join(cwd, "flagrm.config.yaml");
121
136
  const projects = detectProjects(cwd);
122
- fs.writeFileSync(file, configTemplate(projects, tools), "utf8");
137
+ fs.writeFileSync(file, configTemplate(projects, tools, cwd), "utf8");
123
138
  const note = projects.length
124
139
  ? `detected ${projects.map((p) => `${p.adapter} (${p.marker})`).join(", ")}`
125
140
  : "no Angular or .NET project found: edit the example projects";
@@ -138,7 +138,9 @@ function followFlow(projects, entries) {
138
138
  continue;
139
139
  const words = flagWords([{ name: entry.flag }, ...entry.definitions]);
140
140
  const flagNames = [entry.flag, ...entry.definitions.map((d) => d.name.split(".").pop() ?? d.name)];
141
- entry.flow.push({ ...name, record: !name.ambiguous && recordable(name, flagNames, words) });
141
+ // A renamed import of one of the flag's definitions is as specific as the definition.
142
+ const record = name.kind === "alias" || (!name.ambiguous && recordable(name, flagNames, words));
143
+ entry.flow.push({ ...name, record });
142
144
  }
143
145
  for (const test of tests)
144
146
  byFlag.get(test.flag)?.parameterizedTests.push(test);
@@ -3,6 +3,7 @@
3
3
  * for the user or a PR. Every fact comes from the report; the agent adds
4
4
  * notes and the commit message around it.
5
5
  */
6
+ import { reusedNote } from "./run-cache.js";
6
7
  import { relativePath } from "./util.js";
7
8
  import { shortTestName, shortTestNames } from "./verify/test-results.js";
8
9
  const ICON = { pass: "✅", fail: "❌", warn: "⚠️", skipped: "➖" };
@@ -30,6 +31,9 @@ export function verifyMarkdown(report, root) {
30
31
  "|---|---|---|",
31
32
  ...report.checks.map((c) => `| ${c.id} | ${ICON[c.status]} ${c.status} | ${c.summary.replace(/\|/g, "\\|")} |`),
32
33
  ];
34
+ const note = reusedNote(report.runs, report.createdAt);
35
+ if (note)
36
+ out.push("", `_${note}_`);
33
37
  const changed = report.changedFiles ?? [];
34
38
  if (changed.length)
35
39
  out.push("", `**Files changed since baseline (${changed.length}):** ${codeList(changed)}`);
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Which flags a name's code reads: where the name is declared or assigned
3
+ * (`name =`, `name:`, `name(...) {`, `Name =>`, `Name {`), and the flag
4
+ * literals and qualified names in what follows, up to the end of that
5
+ * statement or body. Lexical like the rest of discovery (comments and strings
6
+ * masked), so it works for TypeScript and C# alike and misses unusual shapes.
7
+ *
8
+ * `baseline --name` refuses a name that reads only other flags: once
9
+ * recorded, `leftovers` would make the agent delete another flag's code, and
10
+ * a recorded name can't be removed again.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { UsageError } from "./baseline.js";
15
+ import { buildInventory } from "./inventory.js";
16
+ import { commentSyntax, computeLexicalMask, escapeRegExp, LEX_CODE, LEX_STRING } from "./scan.js";
17
+ import { SourceText } from "./source-text.js";
18
+ import { toLf } from "./text-utils.js";
19
+ import { relativePath } from "./util.js";
20
+ const MAX_LINES = 20;
21
+ const OPEN = "([{";
22
+ const CLOSE = ")]}";
23
+ /** Every declaration or assignment of `name` in `files` (absolute; every project file when undefined). */
24
+ export function nameReads(projects, name, files, flags) {
25
+ const only = files && new Set(files);
26
+ const word = new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`, "g");
27
+ const out = [];
28
+ const seen = new Set();
29
+ for (const { ctx } of projects) {
30
+ for (const file of ctx.files) {
31
+ if (seen.has(file) || (only && !only.has(file)) || commentSyntax(file) !== "slash")
32
+ continue;
33
+ seen.add(file);
34
+ let text;
35
+ try {
36
+ text = toLf(fs.readFileSync(file, "utf8"));
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ if (!text.includes(name))
42
+ continue;
43
+ const mask = computeLexicalMask(text, "slash");
44
+ const source = new SourceText(file, text);
45
+ for (const m of text.matchAll(word)) {
46
+ if (mask[m.index] !== LEX_CODE)
47
+ continue;
48
+ const after = m.index + name.length;
49
+ if (!isSite(text, mask, m.index, after))
50
+ continue;
51
+ const body = text.slice(after, statementEnd(text, mask, after));
52
+ out.push({ file, line: source.position(m.index).line, flags: flagsIn(body, flags) });
53
+ }
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /** A declaration or assignment of the name at `start..after`, not a read, a call, a ternary branch or a case label. */
59
+ function isSite(text, mask, start, after) {
60
+ const before = text.slice(Math.max(0, start - 40), start);
61
+ if (/\?\s*$/.test(before) || /\bcase\s+$/.test(before))
62
+ return false;
63
+ let i = after;
64
+ while (i < text.length && /[ \t]/.test(text[i]))
65
+ i++;
66
+ // `x?:` / `x!:` (optional, definite) and `x ??= y` declare or assign; `x != y`, `x ? a : b` and `x!.y` don't.
67
+ if (text[i] === "?" || text[i] === "!") {
68
+ const op = text.slice(i, i + 3);
69
+ return op[1] === ":" || op === "??=";
70
+ }
71
+ const rest = text.slice(i, i + 3);
72
+ if (rest.startsWith("=>"))
73
+ return true;
74
+ if (rest.startsWith("="))
75
+ return !rest.startsWith("==");
76
+ if (rest.startsWith(":"))
77
+ return true;
78
+ if (rest.startsWith("{"))
79
+ return true;
80
+ if (!rest.startsWith("("))
81
+ return false;
82
+ // A parameter list, then (after an optional return type, which may hold `|`, `.` or `<>`) a body or
83
+ // `=>`: a method. Anything else is a call: `;`/`,`/`}` or a line break first, or a `)` closing an
84
+ // enclosing bracket (`if (isX()) {`). Arrows and braces inside the call's own brackets don't count.
85
+ let depth = 0;
86
+ for (let j = i; j < Math.min(text.length, i + 400); j++) {
87
+ if (mask[j] !== LEX_CODE)
88
+ continue;
89
+ const c = text[j];
90
+ if (c === "(")
91
+ depth++;
92
+ else if (c === ")") {
93
+ if (--depth < 0)
94
+ return false;
95
+ }
96
+ else if (depth > 0)
97
+ continue;
98
+ else if (c === "{")
99
+ return true;
100
+ else if (c === "=" && text[j + 1] === ">")
101
+ return true;
102
+ else if (c === ";" || c === "," || c === "}")
103
+ return false;
104
+ else if (c === "\n") {
105
+ const head = text.slice(j + 1).trimStart();
106
+ return head.startsWith("{") || head.startsWith("=>");
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ /** Where the statement or body starting at `from` ends: `;` or `,` at depth 0, a closing bracket below it, or a `{` body's `}`. */
112
+ function statementEnd(text, mask, from) {
113
+ let depth = 0;
114
+ let lines = 0;
115
+ for (let i = from; i < text.length; i++) {
116
+ if (text[i] === "\n" && ++lines >= MAX_LINES)
117
+ return i;
118
+ if (mask[i] !== LEX_CODE)
119
+ continue;
120
+ const c = text[i];
121
+ if (OPEN.includes(c))
122
+ depth++;
123
+ else if (CLOSE.includes(c)) {
124
+ depth--;
125
+ if (depth < 0)
126
+ return i;
127
+ if (depth === 0 && c === "}")
128
+ return i + 1;
129
+ }
130
+ else if (depth === 0 && (c === ";" || c === ","))
131
+ return i;
132
+ }
133
+ return text.length;
134
+ }
135
+ /** The flags whose literal (quoted) or qualified name (in code) occurs in `body`. */
136
+ function flagsIn(body, flags) {
137
+ const mask = computeLexicalMask(body, "slash");
138
+ return flags
139
+ .filter(({ flag, aliases }) => {
140
+ for (const m of body.matchAll(new RegExp(`(['"\`])${escapeRegExp(flag)}\\1`, "g"))) {
141
+ if (mask[m.index] === LEX_STRING)
142
+ return true;
143
+ }
144
+ return aliases.some((alias) => {
145
+ const pattern = alias.split(".").map(escapeRegExp).join("\\s*\\.\\s*");
146
+ for (const m of body.matchAll(new RegExp(`(?<![\\w$])${pattern}(?![\\w$])`, "g"))) {
147
+ if (mask[m.index] === LEX_CODE)
148
+ return true;
149
+ }
150
+ return false;
151
+ });
152
+ })
153
+ .map((f) => f.flag);
154
+ }
155
+ /** Throw a {@link UsageError} for the first agent name whose code reads only flags other than `flag`. */
156
+ export async function assertOwnNames(root, projects, flag, names) {
157
+ if (names.length === 0)
158
+ return;
159
+ const inventory = (await buildInventory(projects)).flags;
160
+ // A renamed import of the flag's registry (`AppFeatureFlags.X`) reads it as much as the definition does.
161
+ const refs = inventory.map((e) => ({
162
+ flag: e.flag,
163
+ aliases: [...e.definitions.map((d) => d.name), ...e.flow.filter((n) => n.kind === "alias").map((n) => n.name)],
164
+ }));
165
+ if (!refs.some((r) => r.flag === flag))
166
+ refs.push({ flag, aliases: [] });
167
+ for (const name of [...new Set(names.map((n) => n.name))]) {
168
+ const entries = names.filter((n) => n.name === name);
169
+ const files = entries.every((n) => n.file) ? entries.map((n) => path.resolve(root, n.file)) : undefined;
170
+ const sites = nameReads(projects, name, files, refs);
171
+ if (sites.some((s) => s.flags.includes(flag)))
172
+ continue;
173
+ const foreign = sites.find((s) => s.flags.length > 0);
174
+ if (!foreign)
175
+ continue;
176
+ throw new UsageError(`"${name}" reads ${foreign.flags.join(", ")}, not ${flag} (${relativePath(root, foreign.file)}:${foreign.line}); ` +
177
+ "not recorded. Record only names this flag's value goes through.");
178
+ }
179
+ }
@@ -1,4 +1,5 @@
1
1
  import pc from "picocolors";
2
+ import { reusedNote } from "./run-cache.js";
2
3
  import { plural, relativePath } from "./util.js";
3
4
  /** A path relative to the working directory, with `/` separators on every platform. */
4
5
  function rel(file) {
@@ -70,6 +71,9 @@ export function printBaseline(baseline, file) {
70
71
  if (c.failure)
71
72
  console.log(` ${pc.yellow("setup problem:")} ${c.failure.message}`);
72
73
  }
74
+ const note = reusedNote(checks ?? [], baseline.createdAt);
75
+ if (note)
76
+ console.log(` ${pc.dim(note)}`);
73
77
  if (baseline.names.length) {
74
78
  const names = baseline.names.map((n) => n.kind === "literal" ? n.name : `${n.name} (${n.kind}${n.file ? ` in ${n.file}` : ""})`);
75
79
  console.log(` names: ${pc.dim(names.join(", "))}`);
@@ -122,6 +126,9 @@ export function printVerifyReport(report, file) {
122
126
  }
123
127
  if (report.strict && warned)
124
128
  console.log(pc.dim("\n --strict: warnings count as failures."));
129
+ const note = reusedNote(report.runs, report.createdAt);
130
+ if (note)
131
+ console.log(pc.dim(`\n ${note}`));
125
132
  console.log(pc.dim(`\nResult written to ${rel(file)}.`));
126
133
  }
127
134
  const STEP_STYLE = {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Build and test runs a passing full verify saves, so the next baseline or
3
+ * verify on the same tree with the same commands reuses them instead of
4
+ * running them again: in a sequential removal, the next flag's baseline
5
+ * starts on the commit the previous flag's verify passed on.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import fg from "fast-glob";
10
+ import { flagDir, matchingFiles, runChecks } from "./baseline.js";
11
+ import { changeFilter } from "./change-filter.js";
12
+ import { configSnapshot, projectCommands } from "./config-snapshot.js";
13
+ import { treeFingerprint } from "./git.js";
14
+ import { packageVersion } from "./package.js";
15
+ import { JSON_SCHEMA_VERSION, RUN_CHECK_ID } from "./types.js";
16
+ import { readJson } from "./util.js";
17
+ /** `.flagrm/<flag>/verify/runs.json`. */
18
+ export function runsPath(root, flag) {
19
+ return path.join(flagDir(root, flag), "verify", "runs.json");
20
+ }
21
+ /**
22
+ * The tree the commands will run on: its fingerprint before they run, without
23
+ * flagrm's setup files and the test result files the runs themselves rewrite.
24
+ * Commands are compared on their own, so leaving out the config file is safe.
25
+ * Unlike the Stop hook's fingerprint, `exclude`d paths count: a build or test
26
+ * may read them (a lockfile, generated sources), so a change there must rerun.
27
+ */
28
+ export function runsKey(root, projects) {
29
+ const ignore = changeFilter();
30
+ const patterns = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
31
+ const results = matchingFiles(root, patterns);
32
+ return treeFingerprint(root, (rel) => ignore(rel) || results.has(rel));
33
+ }
34
+ /** The (project, check) pairs a run of `only` would run: those with a command. */
35
+ export function plannedRuns(projects, only) {
36
+ return projects.flatMap(({ adapter, ctx }) => {
37
+ const commands = projectCommands(adapter, ctx);
38
+ return only.filter((check) => commands[check]).map((check) => ({ project: ctx.name, check }));
39
+ });
40
+ }
41
+ /**
42
+ * Save a passing full verify's runs; never a set with a setup problem, which would come back on every rerun,
43
+ * nor runs that were themselves reused: the set they came from keeps its own flag and time.
44
+ * The logs are copied next to `runs.json`, so a later verify overwriting its own logs leaves the saved set whole.
45
+ */
46
+ export function saveRuns(root, flag, projects, key, runs, createdAt = new Date().toISOString()) {
47
+ if (runs.some((r) => r.failure || r.reused))
48
+ return;
49
+ const file = runsPath(root, flag);
50
+ const logDir = path.join(path.dirname(file), "saved");
51
+ fs.mkdirSync(logDir, { recursive: true });
52
+ const kept = runs.map(({ reused: _reused, ...run }) => {
53
+ if (!run.log)
54
+ return run;
55
+ const log = path.join(logDir, `${run.project}.${run.check}.log`);
56
+ fs.copyFileSync(run.log, log);
57
+ return { ...run, log };
58
+ });
59
+ const saved = {
60
+ schemaVersion: JSON_SCHEMA_VERSION,
61
+ flagrmVersion: packageVersion(),
62
+ flag,
63
+ createdAt,
64
+ key,
65
+ commands: configSnapshot(projects).projects,
66
+ runs: kept,
67
+ };
68
+ fs.writeFileSync(file, `${JSON.stringify(saved, null, 2)}\n`, "utf8");
69
+ }
70
+ /** Forget every saved set, of any flag, for this tree: a fresh run on it failed, so they may hide a flaky test or a changed machine. */
71
+ export function dropRuns(root, key) {
72
+ for (const file of fg.sync(".flagrm/*/verify/runs.json", { cwd: root, absolute: true, dot: true })) {
73
+ if (readJson(file)?.key === key)
74
+ fs.rmSync(file, { force: true });
75
+ }
76
+ }
77
+ /** The newest saved set, of any flag, for this tree and these commands that has every `needed` run and its log. */
78
+ export function findRuns(root, projects, key, needed) {
79
+ if (needed.length === 0)
80
+ return undefined;
81
+ const commands = JSON.stringify(configSnapshot(projects).projects);
82
+ const version = packageVersion();
83
+ let best;
84
+ for (const file of fg.sync(".flagrm/*/verify/runs.json", { cwd: root, absolute: true, dot: true })) {
85
+ const saved = readJson(file);
86
+ if (!saved || !Array.isArray(saved.runs) || typeof saved.createdAt !== "string")
87
+ continue;
88
+ if (saved.key !== key || saved.schemaVersion !== JSON_SCHEMA_VERSION)
89
+ continue;
90
+ if (saved.flagrmVersion !== version || JSON.stringify(saved.commands) !== commands)
91
+ continue;
92
+ const runs = needed.map((n) => saved.runs.find((r) => r.project === n.project && r.check === n.check));
93
+ if (runs.some((r) => !r || (r.log !== undefined && !fs.existsSync(r.log))))
94
+ continue;
95
+ if (!best || saved.createdAt > best.saved.createdAt)
96
+ best = { saved, runs: runs };
97
+ }
98
+ return best;
99
+ }
100
+ /** The found runs as this flag's own: logs copied into `logDir` under their usual names, marked `reused`. */
101
+ export function reuseRuns(found, logDir) {
102
+ fs.mkdirSync(logDir, { recursive: true });
103
+ const reused = { flag: found.saved.flag, createdAt: found.saved.createdAt };
104
+ return found.runs.map((run) => {
105
+ if (!run.log)
106
+ return { ...run, reused };
107
+ const log = path.join(logDir, `${run.project}.${run.check}.log`);
108
+ if (path.resolve(run.log) !== path.resolve(log))
109
+ fs.copyFileSync(run.log, log);
110
+ return { ...run, log, reused };
111
+ });
112
+ }
113
+ /** A baseline's build, test and lint runs: reused from a passing verify on the same tree and commands, else run. */
114
+ export async function baselineRuns(root, flag, projects, reuse = true) {
115
+ const logDir = flagDir(root, flag);
116
+ const key = reuse ? runsKey(root, projects) : undefined;
117
+ const found = key ? findRuns(root, projects, key, plannedRuns(projects, ["build", "test", "lint"])) : undefined;
118
+ return found ? reuseRuns(found, logDir) : runChecks(projects, logDir);
119
+ }
120
+ /** One line saying which runs were reused and from where, or undefined when none were. `at`: when they were reused. */
121
+ export function reusedNote(runs, at) {
122
+ const reused = runs.find((r) => r.reused)?.reused;
123
+ if (!reused)
124
+ return undefined;
125
+ const checks = [...new Set(runs.filter((r) => r.reused).map((r) => RUN_CHECK_ID[r.check]))];
126
+ const what = checks.length > 1 ? `${checks.slice(0, -1).join(", ")} and ${checks.at(-1)}` : checks[0];
127
+ return `${what} reused from ${reused.flag}'s verify (same tree and commands, ${age(Date.parse(at) - Date.parse(reused.createdAt))} ago; --no-reuse to run them)`;
128
+ }
129
+ function age(ms) {
130
+ const min = Math.round(ms / 60_000);
131
+ if (min < 1)
132
+ return "under a minute";
133
+ if (min < 60)
134
+ return `${min} min`;
135
+ const hours = Math.round(min / 60);
136
+ return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`;
137
+ }