flagrm 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +70 -31
  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/adapters/dotnet/index.js +11 -1
  9. package/dist/cli.js +62 -21
  10. package/dist/core/baseline.js +98 -45
  11. package/dist/core/change-filter.js +68 -0
  12. package/dist/core/config-snapshot.js +36 -6
  13. package/dist/core/config.js +23 -7
  14. package/dist/core/detect.js +2 -2
  15. package/dist/core/doctor.js +51 -7
  16. package/dist/core/environment.js +25 -3
  17. package/dist/core/git.js +80 -16
  18. package/dist/core/hook.js +9 -6
  19. package/dist/core/install.js +26 -11
  20. package/dist/core/inventory.js +3 -1
  21. package/dist/core/markdown.js +4 -0
  22. package/dist/core/name-owner.js +179 -0
  23. package/dist/core/report.js +13 -0
  24. package/dist/core/run-cache.js +137 -0
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +11 -2
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +170 -29
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/dead-code.js +9 -2
  32. package/dist/core/verify/index.js +42 -8
  33. package/dist/core/verify/log-results.js +127 -0
  34. package/dist/core/verify/orphans.js +175 -0
  35. package/dist/core/verify/removed-tests.js +90 -7
  36. package/dist/core/verify/test-results.js +56 -7
  37. package/package.json +3 -1
  38. package/skills/flagrm-remove/SKILL.md +31 -11
  39. package/skills/flagrm-remove/batch.md +38 -0
  40. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -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,32 +27,70 @@ 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
  }
29
- /** Files added, modified or deleted in the working tree since `sha` (tracked diff plus untracked files). */
30
- export function changedFilesSince(cwd, sha) {
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
+ }
58
+ /**
59
+ * Files added, modified or deleted in the working tree since `sha` (tracked
60
+ * diff plus untracked files), without the ones `ignore` names.
61
+ */
62
+ export function changedFilesSince(cwd, sha, ignore) {
31
63
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
32
64
  if (!diff.ok)
33
65
  return undefined;
34
- return [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
66
+ const all = [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
67
+ return ignore ? all.filter((rel) => !ignore(rel)) : all;
35
68
  }
36
69
  /**
37
70
  * Tracked files modified or deleted in the working tree since `sha` (staged
38
- * or not); untracked files don't count. Undefined when git can't tell.
71
+ * or not), without the ones `ignore` names; untracked files don't count.
72
+ * Undefined when git can't tell.
39
73
  */
40
- export function trackedChangesSince(cwd, sha) {
74
+ export function trackedChangesSince(cwd, sha, ignore) {
41
75
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
42
- return diff.ok ? paths(diff.stdout).sort() : undefined;
76
+ if (!diff.ok)
77
+ return undefined;
78
+ const all = paths(diff.stdout).sort();
79
+ return ignore ? all.filter((rel) => !ignore(rel)) : all;
43
80
  }
44
81
  /** A file's content at `sha`, or undefined when it did not exist there. */
45
82
  export function fileAt(cwd, sha, relativePath) {
46
83
  const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
47
84
  return result.ok ? result.stdout : undefined;
48
85
  }
49
- function gitWithIndex(cwd, index, ...args) {
86
+ function gitWithIndex(cwd, index, args, input) {
50
87
  const result = spawnSync("git", args, {
51
88
  cwd,
52
89
  encoding: "utf8",
53
90
  maxBuffer: 256 * 1024 * 1024,
54
- env: { ...process.env, GIT_INDEX_FILE: index },
91
+ input,
92
+ // Literal: a file named `[ab].txt` must not match `a.txt` when its path is passed back to git.
93
+ env: { ...process.env, GIT_INDEX_FILE: index, GIT_LITERAL_PATHSPECS: input === undefined ? undefined : "1" },
55
94
  });
56
95
  return { ok: result.status === 0, stdout: result.stdout ?? "" };
57
96
  }
@@ -62,12 +101,13 @@ function repoPrefix(cwd) {
62
101
  }
63
102
  /**
64
103
  * The git tree id of the working tree under `cwd` as it would be committed:
65
- * tracked and untracked files, not ignored ones, without `.flagrm/`. Built in a
66
- * temporary copy of the index, so the user's index is untouched. Committing
67
- * the tree doesn't change it; any edit does. Before any edit it equals
68
- * {@link treeAt} of HEAD.
104
+ * tracked and untracked files, not ignored ones, without `.flagrm/` and the
105
+ * paths `ignore` names. Built in a temporary copy of the index, so the user's
106
+ * index is untouched. Committing the tree doesn't change it; any edit does.
107
+ * Before any edit it equals {@link treeAt} of HEAD, as long as the ignored
108
+ * paths are unchanged since.
69
109
  */
70
- export function treeFingerprint(cwd) {
110
+ export function treeFingerprint(cwd, ignore) {
71
111
  const prefix = repoPrefix(cwd);
72
112
  if (prefix === undefined)
73
113
  return undefined;
@@ -79,17 +119,41 @@ export function treeFingerprint(cwd) {
79
119
  if (realIndex && fs.existsSync(realIndex))
80
120
  fs.cpSync(realIndex, index, { preserveTimestamps: true });
81
121
  // Not PATHSPEC: `git add` exits 1 when an exclude pathspec names an ignored path (`.flagrm/` usually is).
82
- if (!gitWithIndex(cwd, index, "add", "-A", "--", ".").ok)
122
+ if (!gitWithIndex(cwd, index, ["add", "-A", "--", "."]).ok)
83
123
  return undefined;
84
- if (!gitWithIndex(cwd, index, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm").ok)
124
+ if (!gitWithIndex(cwd, index, ["rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm"]).ok)
85
125
  return undefined;
86
- const tree = gitWithIndex(cwd, index, "write-tree", ...(prefix ? [`--prefix=${prefix}`] : []));
126
+ if (ignore && !dropIgnored(cwd, index, ignore))
127
+ return undefined;
128
+ const tree = gitWithIndex(cwd, index, ["write-tree", ...(prefix ? [`--prefix=${prefix}`] : [])]);
87
129
  return tree.ok ? tree.stdout.trim() : undefined;
88
130
  }
89
131
  finally {
90
132
  fs.rmSync(dir, { recursive: true, force: true });
91
133
  }
92
134
  }
135
+ /**
136
+ * Put back each path `ignore` names as it is at HEAD (or drop it, if HEAD
137
+ * doesn't have it), so the fingerprint doesn't see changes to it.
138
+ */
139
+ function dropIgnored(cwd, index, 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);
149
+ if (listed.length === 0)
150
+ return true;
151
+ const input = `${listed.join("\0")}\0`;
152
+ const fromFile = ["--pathspec-from-file=-", "--pathspec-file-nul"];
153
+ // `reset` also drops a path HEAD doesn't have; before the first commit there is no HEAD to reset to.
154
+ const args = head ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
155
+ return gitWithIndex(cwd, index, args, input).ok;
156
+ }
93
157
  /** The tree id of `cwd` at commit `sha`. */
94
158
  export function treeAt(cwd, sha) {
95
159
  const prefix = repoPrefix(cwd);
package/dist/core/hook.js CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { CONFIG_FILENAMES, findConfigRoot } from "./config.js";
9
+ import { changeFilterAt } from "./change-filter.js";
10
+ import { findConfigRoot } from "./config.js";
10
11
  import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
11
12
  import { readJson } from "./util.js";
12
13
  /** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
@@ -35,9 +36,10 @@ export function hookRoot(cwd) {
35
36
  /**
36
37
  * Whether to block the stop: a removal is in progress and no passing verify
37
38
  * matches the current tree. In progress means HEAD is still the baseline
38
- * commit and a file git tracks changed, other than the flagrm config (a
39
- * removal paused on a question to the user, or a stray test result file,
40
- * doesn't count), or the last verify skipped checks. Once HEAD moves (a
39
+ * commit and a file git tracks changed, other than flagrm's setup files and
40
+ * 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
+ * The fingerprint leaves out the same paths, as verify's does. Once HEAD moves (a
41
43
  * commit, a branch switch, a merge), that removal is no longer guarded.
42
44
  */
43
45
  export function stopDecision(root, input) {
@@ -57,11 +59,12 @@ export function stopDecision(root, input) {
57
59
  .filter((flagDir) => readJson(path.join(flagDir, "baseline.json"))?.git?.sha === head);
58
60
  if (active.length === 0)
59
61
  return { block: false };
60
- const edited = (trackedChangesSince(root, head) ?? []).some((rel) => !CONFIG_FILENAMES.includes(rel));
62
+ const ignore = changeFilterAt(root);
63
+ const edited = (trackedChangesSince(root, head, ignore) ?? []).length > 0;
61
64
  const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
62
65
  if (started.length === 0)
63
66
  return { block: false };
64
- const current = treeFingerprint(root);
67
+ const current = treeFingerprint(root, ignore);
65
68
  if (!current)
66
69
  return { block: false };
67
70
  const reasons = started.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
@@ -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,10 +71,19 @@ 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(", "))}`);
76
80
  }
81
+ const accepted = [
82
+ baseline.acceptedConfig && "the config as of now",
83
+ baseline.acceptedFailures && "the baseline's failing tests",
84
+ ].filter(Boolean);
85
+ if (accepted.length)
86
+ console.log(` accepted: ${pc.dim(accepted.join(", "))}`);
77
87
  for (const n of baseline.suggestedNames ?? []) {
78
88
  console.log(` ${pc.yellow("suggested")} ${n.name} ${pc.dim(`(${n.kind})`)} ${pc.cyan(`${rel(n.file)}:${n.line}`)} ` +
79
89
  pc.dim(`if it only carries this flag: --name ${n.name} --kind wrapper` +
@@ -116,6 +126,9 @@ export function printVerifyReport(report, file) {
116
126
  }
117
127
  if (report.strict && warned)
118
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}`));
119
132
  console.log(pc.dim(`\nResult written to ${rel(file)}.`));
120
133
  }
121
134
  const STEP_STYLE = {