flagrm 0.4.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-10-12
11
+
12
+ ### Security
13
+
14
+ - Replaced `fast-glob` with `tinyglobby`, which drops the vulnerable `braces`
15
+ dependency (GHSA-vfj7-8cjw-p6xm) from installs.
16
+ - The Stop hook no longer lets a removal finish unverified when the config's
17
+ `exclude` (or anything else it scans) changed since the baseline: widening
18
+ `exclude` used to hide the removal's edits from the hook.
19
+ - A `git.sha` in `baseline.json` that isn't a commit id is refused, and no
20
+ revision starting with `-` reaches git, where `--output=<file>` would have
21
+ written a file.
22
+ - SECURITY.md says what flagrm guarantees and what the Stop hook doesn't:
23
+ it guards against finishing a removal by accident, not an agent working
24
+ around it.
25
+
10
26
  ## [0.4.0] - 2026-10-11
11
27
 
12
28
  ### Added
@@ -271,6 +287,7 @@ removes the flag through the `flagrm-remove` skill, and flagrm checks the result
271
287
  `doctor` warns about an incremental `dotnet build`, which hides existing
272
288
  warnings from the dead-code comparison.
273
289
 
290
+ [0.4.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.4.1
274
291
  [0.4.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.4.0
275
292
  [0.3.0]: https://github.com/dino-cosic/flagrm/releases/tag/v0.3.0
276
293
  [0.2.1]: https://github.com/dino-cosic/flagrm/releases/tag/v0.2.1
package/README.md CHANGED
@@ -104,7 +104,10 @@ of these reasons is a warning, for the agent to explain in its notes.
104
104
  removal is in progress and not verified. In progress means a file git
105
105
  tracks changed on top of the baseline commit (untracked files and
106
106
  flagrm's own setup files don't count, so a removal paused on a question to
107
- the user doesn't block), or the last verify used `--skip`.
107
+ the user doesn't block), the config changed in what it scans or ignores
108
+ (such as `exclude`), or the last verify used `--skip`. It guards against
109
+ finishing by accident, not an agent working around it: see
110
+ [SECURITY.md](SECURITY.md#what-it-doesnt-guarantee).
108
111
  4. **`/flagrm-verify <flag>`** checks a removal later: runs
109
112
  `flagrm verify <flag> --md` and adds notes and a commit message.
110
113
 
@@ -2,12 +2,12 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import fg from "fast-glob";
6
5
  import { changeFilter } from "./change-filter.js";
7
6
  import { configSnapshot, projectCommands } from "./config-snapshot.js";
8
7
  import { environmentProblem } from "./environment.js";
9
8
  import { changedFilesSince } from "./git.js";
10
9
  import { buildInventory } from "./inventory.js";
10
+ import { globAbsolute } from "./scan.js";
11
11
  import { withScopeFiles } from "./scope-files.js";
12
12
  import { JSON_SCHEMA_VERSION, READABLE_BASELINE_VERSIONS, } from "./types.js";
13
13
  import { readJson, relativePath } from "./util.js";
@@ -80,8 +80,15 @@ export function readBaseline(root, flag) {
80
80
  throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
81
81
  `Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
82
82
  }
83
+ const sha = baseline.git?.sha;
84
+ if (sha != null && !COMMIT_ID.test(sha)) {
85
+ throw new UsageError(`${file} has git.sha "${sha}", which is not a git commit id. ` +
86
+ `Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
87
+ }
83
88
  return { baseline, file };
84
89
  }
90
+ /** A full SHA-1 or SHA-256 commit id, as `git rev-parse HEAD` prints it. */
91
+ const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
85
92
  /**
86
93
  * Run one shell command inside a project, streaming its output to `logFile`
87
94
  * (no buffer limit, stdout and stderr interleaved as printed). A command that
@@ -312,9 +319,7 @@ export function editsSince(root, sha, testResults = [], ignore = changeFilter())
312
319
  }
313
320
  /** The files (relative to `root`) matching absolute paths or globs, such as `testResults` patterns. */
314
321
  export function matchingFiles(root, patterns) {
315
- return new Set(patterns.flatMap((pattern) => fg
316
- .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
317
- .map((file) => relativePath(root, file))));
322
+ return new Set(patterns.flatMap((pattern) => globAbsolute(pattern).map((file) => relativePath(root, file))));
318
323
  }
319
324
  /**
320
325
  * `baseline --force` starts over only before the removal began: the working
@@ -42,7 +42,7 @@ export function changeFilter(config) {
42
42
  };
43
43
  }
44
44
  /**
45
- * Matches `exclude` the way scanning's fast-glob `ignore` does: a pattern
45
+ * Matches `exclude` the way scanning's tinyglobby `ignore` does: a pattern
46
46
  * that matches a directory (`graphify-out`, `graphify-out/`) excludes
47
47
  * everything under it, so test the path and each of its parent directories.
48
48
  * `covers` reads the same way.
@@ -142,6 +142,23 @@ export function configChanges(before, now) {
142
142
  }
143
143
  return changes;
144
144
  }
145
+ /**
146
+ * Whether the config file under `root` changed since `before` in what is
147
+ * scanned or ignored (`exclude`, globs, paths), not just in its commands.
148
+ * False when `before` recorded no config file.
149
+ */
150
+ export function scanConfigChanged(before, root) {
151
+ if (!before?.file)
152
+ return false;
153
+ let text;
154
+ try {
155
+ text = fs.readFileSync(path.join(root, before.file.path), "utf8");
156
+ }
157
+ catch {
158
+ return true;
159
+ }
160
+ return withoutCommands(before.file.text) !== withoutCommands(text);
161
+ }
145
162
  const quote = (text) => (text === undefined ? "(none)" : `\`${text}\``);
146
163
  function changedFiles(a, b) {
147
164
  const hashes = new Map(a.map((f) => [f.path, f.sha256]));
@@ -5,7 +5,7 @@
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
9
  import { detectTestRunner } from "./test-runner.js";
10
10
  const IGNORE = ["**/node_modules/**", "**/.git/**", "**/bin/**", "**/obj/**", "**/dist/**"];
11
11
  const depth = (file) => file.split("/").length;
@@ -32,7 +32,7 @@ function commonDir(files) {
32
32
  return common.join("/") || ".";
33
33
  }
34
34
  export function detectProjects(cwd) {
35
- const find = (pattern) => fg.sync(pattern, { cwd, deep: 4, ignore: IGNORE });
35
+ const find = (pattern) => globSync(pattern, { expandDirectories: false, cwd, deep: 3, ignore: IGNORE });
36
36
  const found = [];
37
37
  for (const marker of topmost(find("**/angular.json"))) {
38
38
  const dir = path.posix.dirname(marker);
package/dist/core/git.js CHANGED
@@ -13,6 +13,8 @@ function git(cwd, ...args) {
13
13
  const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
14
14
  return { ok: result.status === 0, stdout: result.stdout ?? "" };
15
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("-");
16
18
  /** NUL-separated paths (`-z`), which git never quotes or escapes, whatever the file name. */
17
19
  const paths = (text) => text.split("\0").filter(Boolean);
18
20
  /** Untracked (and not ignored) files under `cwd`. */
@@ -60,6 +62,8 @@ export function blobIds(cwd, files) {
60
62
  * diff plus untracked files), without the ones `ignore` names.
61
63
  */
62
64
  export function changedFilesSince(cwd, sha, ignore) {
65
+ if (!isRevision(sha))
66
+ return undefined;
63
67
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
64
68
  if (!diff.ok)
65
69
  return undefined;
@@ -72,6 +76,8 @@ export function changedFilesSince(cwd, sha, ignore) {
72
76
  * Undefined when git can't tell.
73
77
  */
74
78
  export function trackedChangesSince(cwd, sha, ignore) {
79
+ if (!isRevision(sha))
80
+ return undefined;
75
81
  const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
76
82
  if (!diff.ok)
77
83
  return undefined;
@@ -80,6 +86,8 @@ export function trackedChangesSince(cwd, sha, ignore) {
80
86
  }
81
87
  /** A file's content at `sha`, or undefined when it did not exist there. */
82
88
  export function fileAt(cwd, sha, relativePath) {
89
+ if (!isRevision(sha))
90
+ return undefined;
83
91
  const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
84
92
  return result.ok ? result.stdout : undefined;
85
93
  }
@@ -156,6 +164,8 @@ function dropIgnored(cwd, index, ignore) {
156
164
  }
157
165
  /** The tree id of `cwd` at commit `sha`. */
158
166
  export function treeAt(cwd, sha) {
167
+ if (!isRevision(sha))
168
+ return undefined;
159
169
  const prefix = repoPrefix(cwd);
160
170
  if (prefix === undefined)
161
171
  return undefined;
@@ -169,5 +179,5 @@ export function headSha(cwd) {
169
179
  }
170
180
  /** Whether `sha` is HEAD or one of its ancestors. */
171
181
  export function isAncestor(cwd, sha) {
172
- return git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
182
+ return isRevision(sha) && git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
173
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;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import fg from "fast-glob";
9
+ import { globSync } from "tinyglobby";
10
10
  import { flagDir, matchingFiles, runChecks } from "./baseline.js";
11
11
  import { changeFilter } from "./change-filter.js";
12
12
  import { configSnapshot, projectCommands } from "./config-snapshot.js";
@@ -69,7 +69,12 @@ export function saveRuns(root, flag, projects, key, runs, createdAt = new Date()
69
69
  }
70
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
71
  export function dropRuns(root, key) {
72
- for (const file of fg.sync(".flagrm/*/verify/runs.json", { cwd: root, absolute: true, dot: true })) {
72
+ for (const file of globSync(".flagrm/*/verify/runs.json", {
73
+ expandDirectories: false,
74
+ cwd: root,
75
+ absolute: true,
76
+ dot: true,
77
+ })) {
73
78
  if (readJson(file)?.key === key)
74
79
  fs.rmSync(file, { force: true });
75
80
  }
@@ -81,7 +86,12 @@ export function findRuns(root, projects, key, needed) {
81
86
  const commands = JSON.stringify(configSnapshot(projects).projects);
82
87
  const version = packageVersion();
83
88
  let best;
84
- for (const file of fg.sync(".flagrm/*/verify/runs.json", { cwd: root, absolute: true, dot: true })) {
89
+ for (const file of globSync(".flagrm/*/verify/runs.json", {
90
+ expandDirectories: false,
91
+ cwd: root,
92
+ absolute: true,
93
+ dot: true,
94
+ })) {
85
95
  const saved = readJson(file);
86
96
  if (!saved || !Array.isArray(saved.runs) || typeof saved.createdAt !== "string")
87
97
  continue;
package/dist/core/scan.js CHANGED
@@ -1,5 +1,6 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
- import fg from "fast-glob";
3
+ import { globSync, isDynamicPattern } from "tinyglobby";
3
4
  export const BACKEND_GLOBS = [
4
5
  "**/*.cs",
5
6
  "**/*.cshtml",
@@ -19,11 +20,37 @@ export const IGNORE_GLOBS = [
19
20
  "**/.angular/**",
20
21
  "**/.git/**",
21
22
  ];
22
- /** Absolute paths with the platform's separators (fast-glob returns `/` on Windows too), so they match `path.join`. */
23
+ /**
24
+ * Files matching an absolute path or glob, such as a `testResults` pattern,
25
+ * as absolute paths with `/`. The leading directories that exist are taken
26
+ * literally and become the glob's `cwd`, so a repository path containing
27
+ * glob characters (`/work/p[x]/TestResults/*.trx`) still matches.
28
+ */
29
+ export function globAbsolute(pattern) {
30
+ const segments = (path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern).split("/");
31
+ let base = segments[0] === "" ? "/" : `${segments[0]}/`;
32
+ let i = 1;
33
+ for (; i < segments.length - 1; i++) {
34
+ const dir = path.posix.join(base, segments[i]);
35
+ if (isDynamicPattern(segments[i]) && !isDirectory(dir))
36
+ break;
37
+ base = dir;
38
+ }
39
+ return globSync(segments.slice(i).join("/"), { expandDirectories: false, cwd: base, absolute: true, dot: true });
40
+ }
41
+ function isDirectory(dir) {
42
+ try {
43
+ return fs.statSync(dir).isDirectory();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /** Absolute paths with the platform's separators (tinyglobby returns `/` on Windows too), so they match `path.join`. */
23
50
  export function listProjectFiles(root, globs, overrides) {
24
51
  const patterns = [...globs, ...(overrides?.include ?? [])];
25
52
  const ignore = [...IGNORE_GLOBS, ...(overrides?.exclude ?? [])];
26
- return fg.sync(patterns, { cwd: root, ignore, absolute: true, dot: false }).map((file) => path.normalize(file));
53
+ return globSync(patterns, { expandDirectories: false, cwd: root, ignore, absolute: true, dot: false }).map((file) => path.normalize(file));
27
54
  }
28
55
  export function escapeRegExp(s) {
29
56
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -8,7 +8,7 @@
8
8
  import { spawnSync } from "node:child_process";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import fg from "fast-glob";
11
+ import { globSync } from "tinyglobby";
12
12
  import YAML from "yaml";
13
13
  import { sdkProblem, solutionSetup } from "../adapters/dotnet/setup.js";
14
14
  import { projectCommands } from "./config-snapshot.js";
@@ -81,8 +81,8 @@ function solutionFor(ctx, commands) {
81
81
  return { note: `can't read ${arg}` };
82
82
  }
83
83
  }
84
- const found = fg.sync("*.{sln,slnx}", { cwd: ctx.root, absolute: true });
85
- // fast-glob returns forward slashes, also on Windows.
84
+ const found = globSync("*.{sln,slnx}", { expandDirectories: false, cwd: ctx.root, absolute: true });
85
+ // tinyglobby returns forward slashes, also on Windows.
86
86
  if (found.length === 1)
87
87
  return { solution: path.normalize(found[0]) };
88
88
  return { note: found.length ? "several solutions: name one in build/test" : "no solution file found" };
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import fg from "fast-glob";
9
+ import { globSync } from "tinyglobby";
10
10
  import { readJson, relativePath } from "./util.js";
11
11
  /** The test target of the first `angular.json` project that has one. */
12
12
  function angularTestTarget(root) {
@@ -22,7 +22,7 @@ function dependencies(root) {
22
22
  const pkg = readJson(path.join(root, "package.json"));
23
23
  return new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
24
24
  }
25
- const hasFile = (root, pattern) => fg.sync(pattern, { cwd: root, deep: 1 }).length > 0;
25
+ const hasFile = (root, pattern) => globSync(pattern, { expandDirectories: false, cwd: root, deep: 1 }).length > 0;
26
26
  function karma(root, options) {
27
27
  const conf = path.resolve(root, typeof options.karmaConfig === "string" ? options.karmaConfig : "karma.conf.js");
28
28
  let text;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import fg from "fast-glob";
10
+ import { globAbsolute } from "../scan.js";
11
11
  function attributes(tag) {
12
12
  const out = {};
13
13
  for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
@@ -200,8 +200,7 @@ export function hasTestNames(results) {
200
200
  export function collectTestResults(pattern, since, claimed = new Map()) {
201
201
  // Filesystem timestamps can be coarser than Date.now().
202
202
  const cutoff = since - 2000;
203
- const files = fg
204
- .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
203
+ const files = globAbsolute(pattern)
205
204
  .map((file) => ({ file, mtime: fs.statSync(file).mtimeMs }))
206
205
  .filter(({ file, mtime }) => mtime >= cutoff && claimed.get(file) !== mtime)
207
206
  .sort((a, b) => a.file.localeCompare(b.file));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagrm",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Guardrails for removing feature flags with AI coding agents (Claude Code, Copilot, Codex): skills, a baseline, a verify gate and a Stop hook.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -53,17 +53,17 @@
53
53
  ],
54
54
  "dependencies": {
55
55
  "commander": "^13.1.0",
56
- "fast-glob": "^3.3.3",
57
56
  "picocolors": "^1.1.1",
58
57
  "picomatch": "^4.0.7",
58
+ "tinyglobby": "^0.2.17",
59
59
  "typescript": "^5.7.0",
60
60
  "yaml": "^2.7.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@biomejs/biome": "2.5.10",
63
+ "@biomejs/biome": "2.5.15",
64
64
  "@types/node": "^22.13.0",
65
65
  "@types/picomatch": "^4.0.3",
66
66
  "tsx": "^4.19.0",
67
- "vitest": "^3.0.0"
67
+ "vitest": "^4.1.11"
68
68
  }
69
69
  }