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
@@ -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
  }
@@ -5,7 +5,8 @@
5
5
  * saves the user from editing a template, and they check the result.
6
6
  */
7
7
  import path from "node:path";
8
- import fg from "fast-glob";
8
+ import { globSync } from "tinyglobby";
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. */
@@ -31,12 +32,11 @@ function commonDir(files) {
31
32
  return common.join("/") || ".";
32
33
  }
33
34
  export function detectProjects(cwd) {
34
- const find = (pattern) => fg.sync(pattern, { cwd, deep: 4, ignore: IGNORE });
35
+ const find = (pattern) => globSync(pattern, { expandDirectories: false, cwd, deep: 3, ignore: IGNORE });
35
36
  const found = [];
36
37
  for (const marker of topmost(find("**/angular.json"))) {
37
38
  const dir = path.posix.dirname(marker);
38
- const jest = fg.sync("jest.config.{js,ts,mjs,cjs}", { cwd: path.join(cwd, dir) }).length > 0;
39
- found.push({ adapter: "angular", path: dir, marker, jest });
39
+ found.push({ adapter: "angular", path: dir, marker, testRunner: detectTestRunner(path.join(cwd, dir)) });
40
40
  }
41
41
  const solutions = topmost(find("**/*.{sln,slnx}"));
42
42
  if (solutions.length) {
@@ -1,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";
@@ -12,6 +13,8 @@ function git(cwd, ...args) {
12
13
  const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
13
14
  return { ok: result.status === 0, stdout: result.stdout ?? "" };
14
15
  }
16
+ /** Whether git reads `rev` as a revision: one starting with `-` (`--output=<file>`) would be an option. */
17
+ const isRevision = (rev) => rev !== "" && !rev.startsWith("-");
15
18
  /** NUL-separated paths (`-z`), which git never quotes or escapes, whatever the file name. */
16
19
  const paths = (text) => text.split("\0").filter(Boolean);
17
20
  /** Untracked (and not ignored) files under `cwd`. */
@@ -26,11 +29,41 @@ export function gitState(cwd) {
26
29
  const dirtyFiles = changedFilesSince(cwd, "HEAD") ?? [];
27
30
  return { sha: head.stdout.trim(), dirty: dirtyFiles.length > 0, dirtyFiles };
28
31
  }
32
+ function hashObjects(cwd, files) {
33
+ const result = spawnSync("git", ["hash-object", "--stdin-paths"], {
34
+ cwd,
35
+ encoding: "utf8",
36
+ input: `${files.join("\n")}\n`,
37
+ });
38
+ return result.status === 0 ? (result.stdout ?? "").split("\n") : undefined;
39
+ }
40
+ /**
41
+ * Each file's git blob id as it is in the working tree (null when it doesn't
42
+ * exist), so a later run can tell whether it changed since. A file git can't
43
+ * hash (a submodule directory) gets an id of its own each time, so it always
44
+ * counts as changed. Paths are relative to `cwd`.
45
+ */
46
+ export function blobIds(cwd, files) {
47
+ const ids = {};
48
+ const existing = files.filter((rel) => fs.existsSync(path.join(cwd, rel)));
49
+ for (const rel of files)
50
+ ids[rel] = null;
51
+ if (existing.length === 0)
52
+ return ids;
53
+ // One git call for all files; when it fails, one per file, so a single unhashable path doesn't spoil the rest.
54
+ const out = hashObjects(cwd, existing);
55
+ existing.forEach((rel, i) => {
56
+ ids[rel] = (out ? out[i] : hashObjects(cwd, [rel])?.[0]) || `unhashable:${randomUUID()}`;
57
+ });
58
+ return ids;
59
+ }
29
60
  /**
30
61
  * Files added, modified or deleted in the working tree since `sha` (tracked
31
62
  * diff plus untracked files), without the ones `ignore` names.
32
63
  */
33
64
  export function changedFilesSince(cwd, sha, ignore) {
65
+ if (!isRevision(sha))
66
+ return undefined;
34
67
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
35
68
  if (!diff.ok)
36
69
  return undefined;
@@ -43,6 +76,8 @@ export function changedFilesSince(cwd, sha, ignore) {
43
76
  * Undefined when git can't tell.
44
77
  */
45
78
  export function trackedChangesSince(cwd, sha, ignore) {
79
+ if (!isRevision(sha))
80
+ return undefined;
46
81
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
47
82
  if (!diff.ok)
48
83
  return undefined;
@@ -51,6 +86,8 @@ export function trackedChangesSince(cwd, sha, ignore) {
51
86
  }
52
87
  /** A file's content at `sha`, or undefined when it did not exist there. */
53
88
  export function fileAt(cwd, sha, relativePath) {
89
+ if (!isRevision(sha))
90
+ return undefined;
54
91
  const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
55
92
  return result.ok ? result.stdout : undefined;
56
93
  }
@@ -108,17 +145,27 @@ export function treeFingerprint(cwd, ignore) {
108
145
  * doesn't have it), so the fingerprint doesn't see changes to it.
109
146
  */
110
147
  function dropIgnored(cwd, index, ignore) {
111
- const listed = paths(gitWithIndex(cwd, index, ["ls-files", "-z"]).stdout).filter(ignore);
148
+ const head = headSha(cwd);
149
+ // The paths that differ from HEAD, deleted ones included: `ls-files` no longer lists a deleted file.
150
+ // Before the first commit there is no HEAD to compare with, so every path in the index.
151
+ const listing = head
152
+ ? gitWithIndex(cwd, index, ["diff-index", "--cached", "--name-only", "--no-renames", "--relative", "-z", "HEAD"])
153
+ : gitWithIndex(cwd, index, ["ls-files", "-z"]);
154
+ if (!listing.ok)
155
+ return false;
156
+ const listed = paths(listing.stdout).filter(ignore);
112
157
  if (listed.length === 0)
113
158
  return true;
114
159
  const input = `${listed.join("\0")}\0`;
115
160
  const fromFile = ["--pathspec-from-file=-", "--pathspec-file-nul"];
116
161
  // `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];
162
+ const args = head ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
118
163
  return gitWithIndex(cwd, index, args, input).ok;
119
164
  }
120
165
  /** The tree id of `cwd` at commit `sha`. */
121
166
  export function treeAt(cwd, sha) {
167
+ if (!isRevision(sha))
168
+ return undefined;
122
169
  const prefix = repoPrefix(cwd);
123
170
  if (prefix === undefined)
124
171
  return undefined;
@@ -132,5 +179,5 @@ export function headSha(cwd) {
132
179
  }
133
180
  /** Whether `sha` is HEAD or one of its ancestors. */
134
181
  export function isAncestor(cwd, sha) {
135
- return git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
182
+ return isRevision(sha) && git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
136
183
  }
package/dist/core/hook.js CHANGED
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { changeFilterAt } from "./change-filter.js";
10
10
  import { findConfigRoot } from "./config.js";
11
+ import { scanConfigChanged } from "./config-snapshot.js";
11
12
  import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
12
13
  import { readJson } from "./util.js";
13
14
  /** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
@@ -38,7 +39,8 @@ export function hookRoot(cwd) {
38
39
  * matches the current tree. In progress means HEAD is still the baseline
39
40
  * commit and a file git tracks changed, other than flagrm's setup files and
40
41
  * paths matching `exclude` (a removal paused on a question to the user, or a
41
- * stray test result file, doesn't count), or the last verify skipped checks.
42
+ * stray test result file, doesn't count), the config changed in what it
43
+ * scans or ignores, or the last verify skipped checks.
42
44
  * The fingerprint leaves out the same paths, as verify's does. Once HEAD moves (a
43
45
  * commit, a branch switch, a merge), that removal is no longer guarded.
44
46
  */
@@ -61,7 +63,7 @@ export function stopDecision(root, input) {
61
63
  return { block: false };
62
64
  const ignore = changeFilterAt(root);
63
65
  const edited = (trackedChangesSince(root, head, ignore) ?? []).length > 0;
64
- const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
66
+ const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir) || configChanged(flagDir, root));
65
67
  if (started.length === 0)
66
68
  return { block: false };
67
69
  const current = treeFingerprint(root, ignore);
@@ -74,6 +76,15 @@ export function stopDecision(root, input) {
74
76
  return { block: false };
75
77
  }
76
78
  }
79
+ /**
80
+ * Whether the config changed since the baseline (or `--accept config`) in what
81
+ * is scanned or ignored: widening `exclude` would otherwise hide the removal's
82
+ * edits from the hook. A verify passing on this tree shows the change and allows.
83
+ */
84
+ function configChanged(flagDir, root) {
85
+ const baseline = readJson(path.join(flagDir, "baseline.json"));
86
+ return scanConfigChanged(baseline?.acceptedConfig ?? baseline?.config, root);
87
+ }
77
88
  function lastVerifySkipped(flagDir) {
78
89
  const skipped = readJson(path.join(flagDir, "verify.json"))?.skipped;
79
90
  return Array.isArray(skipped) && skipped.length > 0;
@@ -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
+ }