What happened?
Ran the skill tests (pnpm test, or file-scoped: ./node_modules/.bin/vitest run tests/skill/understand/test_scan_project.test.mjs) on a development machine whose global gitignore contains .env and .env.local. One test fails, and it fails with a TypeError rather than an assertion:
FAIL tests/skill/understand/test_scan_project.test.mjs > scan-project.mjs — category assignment (project-scanner.md Step 4) > dotfile configs (.env, .env.local, .env.production) map to config + env language
TypeError: Cannot read properties of undefined (reading 'fileCategory')
❯ tests/skill/understand/test_scan_project.test.mjs:401:33
Expected: the suite passes regardless of the contributor's personal git configuration.
CI never sees this because ubuntu-latest runners ship with no global gitignore. It only bites contributor machines, and .env is one of the most common global-ignore entries in the wild.
Root cause
scan-project.mjs prefers git enumeration: enumerateViaGit() spawns git ls-files -z -co --exclude-standard. Per gitignore(5), --exclude-standard honors three ignore sources: the repo's .gitignore, .git/info/exclude, and the host's core.excludesFile. The test helper runScript() spawns the scanner with a fully inherited environment, so the host's global ignore applies inside every fixture repo. With .env globally ignored, the fixture's .env/.env.local never reach the scanner output, byPath(r.output, '.env') returns undefined, and the test dies on undefined.fileCategory.
The walker fallback never consults gitignore, which makes this look spooky during diagnosis: the identical tree scans fine with gitInit: false.
Minimal reproduction
No need to touch your real config; simulate the global ignore via XDG_CONFIG_HOME:
export XDG_CONFIG_HOME="$(mktemp -d)"
mkdir -p "$XDG_CONFIG_HOME/git"
printf '.env\n.env.local\n' > "$XDG_CONFIG_HOME/git/ignore"
repo="$(mktemp -d)"; git -C "$repo" init -q
touch "$repo/.env" "$repo/.env.local" "$repo/.env.production"
git -C "$repo" ls-files -co --exclude-standard
# prints only: .env.production
Run the vitest file under those same two exports and the dotfile-configs test fails exactly as above.
The trap: nulling the config files is NOT enough
The obvious hermeticity fix, GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null, does not work:
$ GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git -C "$repo" ls-files -co --exclude-standard
.env.production # still broken
$ GIT_CONFIG_GLOBAL=/dev/null git config --get core.excludesFile; echo "exit=$?"
exit=1 # the config setting is gone...
$ GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git -C "$repo" check-ignore -v .env
~/.config/git/ignore:7:.env .env # ...yet the ignore file still applies
That is because core.excludesFile has a built-in default independent of any config file. git-config(1):
Defaults to $XDG_CONFIG_HOME/git/ignore. If $XDG_CONFIG_HOME is either not set or empty, $HOME/.config/git/ignore is used instead.
So the knob itself must be overridden. Git's environment-config mechanism does that without touching the scanner's command line, keeping the fix entirely inside the tests.
Suggested fix (verified)
diff --git a/tests/skill/understand/test_scan_project.test.mjs b/tests/skill/understand/test_scan_project.test.mjs
index 65d96c8..fe56a5c 100644
--- a/tests/skill/understand/test_scan_project.test.mjs
+++ b/tests/skill/understand/test_scan_project.test.mjs
@@ -19,6 +19,26 @@ const SCRIPT = resolve(
'../../../understand-anything-plugin/skills/understand/scan-project.mjs',
);
+/**
+ * Hermetic git environment for fixture work: host-level git config must not
+ * leak into tests. Developers commonly have `.env` in their global gitignore;
+ * scan-project.mjs enumerates via `git ls-files -co --exclude-standard`,
+ * which honors core.excludesFile, so fixture files silently vanish from the
+ * scan and byPath() lookups explode. Nulling the config files alone is NOT
+ * enough: core.excludesFile *defaults* to `$XDG_CONFIG_HOME/git/ignore`
+ * (falling back to `~/.config/git/ignore`) even when no config sets it, so
+ * the knob itself is overridden via git's environment-config mechanism
+ * (GIT_CONFIG_COUNT: git >= 2.31; GIT_CONFIG_GLOBAL/SYSTEM: git >= 2.32).
+ */
+const HERMETIC_GIT_ENV = {
+ ...process.env,
+ GIT_CONFIG_GLOBAL: '/dev/null',
+ GIT_CONFIG_SYSTEM: '/dev/null',
+ GIT_CONFIG_COUNT: '1',
+ GIT_CONFIG_KEY_0: 'core.excludesFile',
+ GIT_CONFIG_VALUE_0: '/dev/null',
+};
+
/**
* Build a project tree from a `{ relPath: contents }` object. Creates parent
* directories as needed. Initializes a real git repo so the script's preferred
@@ -36,7 +56,11 @@ function setupTree(files, { gitInit = true } = {}) {
// `git ls-files -co --exclude-standard` returns BOTH cached and others
// (modulo gitignore), so an `add` is unnecessary for our tests — the
// bare repo init is enough for ls-files to enumerate.
- const init = spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf-8' });
+ const init = spawnSync('git', ['init', '-q'], {
+ cwd: root,
+ encoding: 'utf-8',
+ env: HERMETIC_GIT_ENV,
+ });
if (init.status !== 0) {
// CI without git: continue without it; the walker fallback will fire.
}
@@ -66,6 +90,7 @@ function runScript(projectRoot) {
const outputPath = join(outputDir, 'scan-output.json');
const result = spawnSync('node', [SCRIPT, projectRoot, outputPath], {
encoding: 'utf-8',
+ env: HERMETIC_GIT_ENV,
});
let output = null;
try {
Verification on the affected machine: without the patch the file runs 31 passed / 1 failed (the TypeError above); with the patch, 32/32 pass (vitest exit code 0). The fixture git init gets the same env for completeness: a host init.templateDir could otherwise plant .git/info/exclude entries, which is the same class of leak.
Version floors, from the git release notes: the GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n mechanism landed in git 2.31.0 and GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM in 2.32.0 (June 2021), comfortably below anything that can run this repo's Node >= 22 toolchain.
Alternative considered: passing -c core.excludesFile=/dev/null inside enumerateViaGit() also fixes the test (verified), but that changes production behavior to solve a test-hermeticity problem, so the env route in the test helper seems strictly better. Scope note: of the three skill scripts under test, only scan-project.mjs shells out to git, so this one helper is the entire blast radius.
Two small adjacent observations
byPath()'s docstring says callers should expect(byPath(...)).toBeDefined() first; the dotfile test indexes the result directly, which is why this surfaced as a TypeError instead of a readable assertion failure. Adding the guard there would make the next config-leak diagnosis faster.
- Deliberately out of scope here: whether production scans should let a user's personal global gitignore hide files like
.env from analysis (the walker fallback and git path currently disagree about that). This issue is only about making the tests independent of contributor machine config.
Environment
- Plugin version: 2.7.6, repo at
fb1356a
- Platform: Claude Code (CLI), plugin marketplace checkout
- OS + Node: macOS 26.2 (arm64), Node v24.15.0, vitest 3.2.4, git 2.50.1 (Apple Git-155)
What happened?
Ran the skill tests (
pnpm test, or file-scoped:./node_modules/.bin/vitest run tests/skill/understand/test_scan_project.test.mjs) on a development machine whose global gitignore contains.envand.env.local. One test fails, and it fails with a TypeError rather than an assertion:Expected: the suite passes regardless of the contributor's personal git configuration.
CI never sees this because
ubuntu-latestrunners ship with no global gitignore. It only bites contributor machines, and.envis one of the most common global-ignore entries in the wild.Root cause
scan-project.mjsprefers git enumeration:enumerateViaGit()spawnsgit ls-files -z -co --exclude-standard. Per gitignore(5),--exclude-standardhonors three ignore sources: the repo's.gitignore,.git/info/exclude, and the host'score.excludesFile. The test helperrunScript()spawns the scanner with a fully inherited environment, so the host's global ignore applies inside every fixture repo. With.envglobally ignored, the fixture's.env/.env.localnever reach the scanner output,byPath(r.output, '.env')returnsundefined, and the test dies onundefined.fileCategory.The walker fallback never consults gitignore, which makes this look spooky during diagnosis: the identical tree scans fine with
gitInit: false.Minimal reproduction
No need to touch your real config; simulate the global ignore via
XDG_CONFIG_HOME:Run the vitest file under those same two exports and the dotfile-configs test fails exactly as above.
The trap: nulling the config files is NOT enough
The obvious hermeticity fix,
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null, does not work:That is because
core.excludesFilehas a built-in default independent of any config file. git-config(1):So the knob itself must be overridden. Git's environment-config mechanism does that without touching the scanner's command line, keeping the fix entirely inside the tests.
Suggested fix (verified)
Verification on the affected machine: without the patch the file runs 31 passed / 1 failed (the TypeError above); with the patch, 32/32 pass (vitest exit code 0). The fixture
git initgets the same env for completeness: a hostinit.templateDircould otherwise plant.git/info/excludeentries, which is the same class of leak.Version floors, from the git release notes: the
GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_nmechanism landed in git 2.31.0 andGIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEMin 2.32.0 (June 2021), comfortably below anything that can run this repo's Node >= 22 toolchain.Alternative considered: passing
-c core.excludesFile=/dev/nullinsideenumerateViaGit()also fixes the test (verified), but that changes production behavior to solve a test-hermeticity problem, so the env route in the test helper seems strictly better. Scope note: of the three skill scripts under test, onlyscan-project.mjsshells out to git, so this one helper is the entire blast radius.Two small adjacent observations
byPath()'s docstring says callers shouldexpect(byPath(...)).toBeDefined()first; the dotfile test indexes the result directly, which is why this surfaced as a TypeError instead of a readable assertion failure. Adding the guard there would make the next config-leak diagnosis faster..envfrom analysis (the walker fallback and git path currently disagree about that). This issue is only about making the tests independent of contributor machine config.Environment
fb1356a