Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions tests/skill/understand/test_scan_project.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
chmodSync,
existsSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { tmpdir, devNull } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
Expand All @@ -19,6 +19,29 @@ const SCRIPT = resolve(
'../../../understand-anything-plugin/skills/understand/scan-project.mjs',
);

/**
* scan-project.mjs enumerates files via `git ls-files -co --exclude-standard`,
* which honors the host's global gitignore (core.excludesFile). Many
* contributors keep `.env`/`.env.local` in their global gitignore, which would
* then hide the fixture dotfiles from the scanner and break tests that assert
* on them (e.g. the `.env` dotfile-config test) — see #427. Nulling
* GIT_CONFIG_GLOBAL/SYSTEM is NOT enough: core.excludesFile defaults to
* $XDG_CONFIG_HOME/git/ignore (or ~/.config/git/ignore) even with no config
* file, so the knob itself must be overridden. Passing this env to every git
* invocation makes the fixtures hermetic against any host config.
*
* `os.devNull` is used instead of a literal '/dev/null' so the null sink is
* valid on Windows too (`NUL`), where contributors also run these tests.
*/
const HERMETIC_GIT_ENV = {
...process.env,
GIT_CONFIG_GLOBAL: devNull,
GIT_CONFIG_SYSTEM: devNull,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.excludesFile',
GIT_CONFIG_VALUE_0: devNull,
};

/**
* Build a project tree from a `{ relPath: contents }` object. Creates parent
* directories as needed. Initializes a real git repo so the script's preferred
Expand All @@ -36,7 +59,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.
}
Expand Down Expand Up @@ -66,6 +93,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 {
Expand Down Expand Up @@ -391,17 +419,29 @@ describe('scan-project.mjs — category assignment (project-scanner.md Step 4)',
// review on PR #204.
it('dotfile configs (.env, .env.local, .env.production) map to config + env language', () => {
projectRoot = setupTree({
// A non-dotfile sibling keeps `git ls-files` output non-empty so the
// git enumeration path is actually exercised. Without it, a host global
// gitignore that hides every dotfile would make `git ls-files` return
// nothing, silently tripping the walker fallback (which is unaware of
// gitignore) and masking the #427 leak. With a sibling present, the git
// path is taken and the dotfiles must survive on their own merits.
'keep.ts': 'export const x = 1;\n',
'.env': 'API_KEY=abc\n',
'.env.local': 'LOCAL=1\n',
'.env.production': 'PROD=1\n',
});
const r = runScript(projectRoot);
expect(r.status).toBe(0);
for (const p of ['.env', '.env.local', '.env.production']) {
expect(byPath(r.output, p).fileCategory).toBe('config');
const entry = byPath(r.output, p);
// Guard for a clearer failure than "Cannot read properties of
// undefined" if a host global gitignore ever hides these dotfiles
// (see HERMETIC_GIT_ENV above / #427).
expect(entry).toBeDefined();
expect(entry.fileCategory).toBe('config');
// LANGUAGE_BY_EXT['.env'] -> 'config' (the language id itself; not
// a typo — the language for env files is the 'config' bucket).
expect(byPath(r.output, p).language).toBe('config');
expect(entry.language).toBe('config');
}
});
});
Expand Down
Loading