From eb8b22de7d7785cb0474cf96d614352de779104c Mon Sep 17 00:00:00 2001 From: Dshuishui Date: Sat, 15 Aug 2026 16:11:28 +0800 Subject: [PATCH] test(extraction): cover the fork's fm_agent work-directory exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork's own patch — `fm_agent` in `DEFAULT_IGNORE_DIRS` — had no test. It is one line in a name set, and that set is a part of the file upstream touches often, which makes it exactly the kind of thing an upstream sync can silently drop or widen. Losing it fails nothing: the build passes, the suite is green, the release ships, and the index quietly grows a second copy of every function FM-Agent extracted. Pins the behaviour down, and records the file in FORK.md's patch table — the list a sync is checked against. --- FORK.md | 11 +- __tests__/fm-agent-workdir-exclusion.test.ts | 154 +++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 __tests__/fm-agent-workdir-exclusion.test.ts diff --git a/FORK.md b/FORK.md index 49d2cb1f3..6d9fee95c 100644 --- a/FORK.md +++ b/FORK.md @@ -15,14 +15,21 @@ Upstream shipped the C macro-attribute extraction fix (issue #1211, PR #1311) in v1.5.0, so the base carries it natively; the fork no longer needs its own patch for it. -**Patches:** one, listed below. Apart from it the tree matches the pinned base, so -the fork stays cheap to re-sync. Every patch lives as a merged pull request here +**Patches:** two, listed below. Apart from them the tree matches the pinned base, +so the fork stays cheap to re-sync. Every patch lives as a merged pull request here and must be **re-applied on each upstream sync** — if a merge drops one, this list is what catches it. | File | Patch | |------|-------| | `src/extraction/index.ts` | `fm_agent` added to `DEFAULT_IGNORE_DIRS`. FM-Agent writes its work directory into the project it analyses, holding one copy of every function it extracts plus the scripts staged to produce them, so indexing it lists each function twice and mixes tool code in with project code. Upstream deliberately keeps names that could be real source out of that list, so this stays fork-only; a project that does own an `fm_agent/` directory opts back in with a `.gitignore` negation (`!fm_agent/`). | +| `__tests__/fm-agent-workdir-exclusion.test.ts` | Regression cover for the patch above, so a sync that drops or widens it fails `npm test` instead of shipping. Pins four things: the exclusion applies at the root and at any depth; it is a whole-name match, so `fm_agent_data/` and `my_fm_agent/` stay indexed; a `.gitignore` negation takes the directory back; and none of it depends on git. | + +A sync brings new upstream test files in on its own — they are separate files, so +git takes them without asking. The case to watch for is upstream *moving* the test +tree or changing how the runner discovers it: our file would stay where it is, quietly +stop being collected, and nothing would fail. Check the suite's file count after a +sync, not just that it is green. **Version marker:** `codegraph --version` → `1.5.0-fmagent.N` identifies a build from this fork. Note this is a SemVer pre-release of `1.5.0`, so it sorts *below* diff --git a/__tests__/fm-agent-workdir-exclusion.test.ts b/__tests__/fm-agent-workdir-exclusion.test.ts new file mode 100644 index 000000000..cbef10edc --- /dev/null +++ b/__tests__/fm-agent-workdir-exclusion.test.ts @@ -0,0 +1,154 @@ +/** + * FORK-SPECIFIC (see FORK.md): FM-Agent's work directory is excluded by default. + * + * FM-Agent writes an `fm_agent/` directory into the project it analyses, holding + * one copy of every function it extracts plus the scripts it stages to produce + * them. Indexing that lists each function twice and mixes tool output in with + * project code, so `fm_agent` sits in DEFAULT_IGNORE_DIRS. + * + * The patch is one line in a name set, which is exactly the kind of change a + * later upstream merge can silently drop or widen. These tests pin down what it + * is supposed to do — and, just as importantly, what it must NOT do: + * + * - It matches a DIRECTORY named exactly `fm_agent`, at any depth. Matching at + * any depth is deliberate: FM-Agent's work directory sits at the root of the + * directory it was pointed at, which is not necessarily the root CodeGraph + * indexes (run FM-Agent on `packages/product` but open an editor session at + * the monorepo root, and the artifacts are nested). + * - It is a whole-name match, not a prefix or substring one, so `fm_agent_data/` + * and `my_fm_agent/` stay indexed. This is what keeps the blast radius small. + * - A project that legitimately owns an `fm_agent/` directory takes it back with + * a `.gitignore` negation, as for any other default-ignored directory. That + * escape hatch is the reason the any-depth match is acceptable, so it is + * covered here rather than left to documentation. + * - The exclusion is independent of git: it holds with no repository at all, and + * with the artifacts committed. The second case is not hypothetical — FM-Agent's + * incremental mode indexes a temporary worktree checked out from a commit that + * can contain them. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +const C_FN = 'int extracted(int n) { return n + 1; }\n'; +const PY_FN = 'def extracted(n):\n return n + 1\n'; + +describe("FM-Agent work directory exclusion (fork-only)", () => { + const open: CodeGraph[] = []; + const dirs: string[] = []; + + const scratch = (tag: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-fmagent-${tag}-`)); + dirs.push(dir); + return dir; + }; + + const write = (dir: string, rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + const indexOf = async (dir: string) => { + const cg = CodeGraph.initSync(dir); + open.push(cg); + await cg.indexAll(); + return new Set(cg.getFiles().map((f) => f.path)); + }; + + const git = (dir: string, ...args: string[]) => + execFileSync('git', ['-C', dir, ...args], { stdio: 'pipe' }); + + afterEach(() => { + while (open.length) open.pop()!.destroy(); + while (dirs.length) { + const d = dirs.pop()!; + if (fs.existsSync(d)) fs.rmSync(d, { recursive: true, force: true }); + } + }); + + it('excludes fm_agent/ at the project root and at any nested depth, in any language', async () => { + const dir = scratch('core'); + write(dir, 'src/app.c', 'int main(void) { return 0; }\n'); + write(dir, 'packages/product/real.c', 'int helper(void) { return 1; }\n'); + + // Root-level artifacts, both languages FM-Agent extracts into. + write(dir, 'fm_agent/extracted_functions/src-c/extracted.c', C_FN); + write(dir, 'fm_agent/extracted_functions/src-py/extracted.py', PY_FN); + + // Nested artifacts: FM-Agent ran on `packages/product`, CodeGraph indexes the + // repository root. + write(dir, 'packages/product/fm_agent/extracted_functions/x-c/extracted.c', C_FN); + + const indexed = await indexOf(dir); + + expect(indexed).not.toContain('fm_agent/extracted_functions/src-c/extracted.c'); + expect(indexed).not.toContain('fm_agent/extracted_functions/src-py/extracted.py'); + expect(indexed).not.toContain('packages/product/fm_agent/extracted_functions/x-c/extracted.c'); + + // Real source on both sides of the excluded directory is untouched. + expect(indexed).toContain('src/app.c'); + expect(indexed).toContain('packages/product/real.c'); + }); + + it('matches the whole directory name only, so lookalike names stay indexed', async () => { + const dir = scratch('lookalike'); + write(dir, 'src/app.c', 'int main(void) { return 0; }\n'); + write(dir, 'fm_agent_data/loader.c', C_FN); // longer name + write(dir, 'my_fm_agent/plugin.c', C_FN); // longer name, other side + write(dir, 'agent/fm_agent.c', C_FN); // a FILE, not a directory + write(dir, 'fm_agent/extracted_functions/x-c/extracted.c', C_FN); // the real thing + + const indexed = await indexOf(dir); + + expect(indexed).toContain('fm_agent_data/loader.c'); + expect(indexed).toContain('my_fm_agent/plugin.c'); + expect(indexed).toContain('agent/fm_agent.c'); + expect(indexed).toContain('src/app.c'); + expect(indexed).not.toContain('fm_agent/extracted_functions/x-c/extracted.c'); + }); + + it('lets a project take fm_agent/ back with a .gitignore negation, nested included', async () => { + const dir = scratch('negation'); + write(dir, '.gitignore', '!fm_agent/\n'); + write(dir, 'src/app.c', 'int main(void) { return 0; }\n'); + write(dir, 'fm_agent/service.c', C_FN); + write(dir, 'packages/product/fm_agent/handler.c', C_FN); + + const indexed = await indexOf(dir); + + expect(indexed).toContain('fm_agent/service.c'); + expect(indexed).toContain('packages/product/fm_agent/handler.c'); + expect(indexed).toContain('src/app.c'); + }); + + it('excludes fm_agent/ whether or not there is a repository, and whether or not it is committed', async () => { + // No repository at all. + const plain = scratch('nogit'); + write(plain, 'src/app.c', 'int main(void) { return 0; }\n'); + write(plain, 'fm_agent/extracted_functions/x-c/extracted.c', C_FN); + const plainIndexed = await indexOf(plain); + expect(plainIndexed).toContain('src/app.c'); + expect(plainIndexed).not.toContain('fm_agent/extracted_functions/x-c/extracted.c'); + + // A repository with the artifacts tracked and committed — the shape + // FM-Agent's incremental mode checks out into a temporary worktree. + const repo = scratch('tracked'); + write(repo, 'src/app.c', 'int main(void) { return 0; }\n'); + write(repo, 'fm_agent/extracted_functions/x-c/extracted.c', C_FN); + git(repo, 'init', '-q'); + git(repo, 'config', 'user.email', 'test@example.com'); + git(repo, 'config', 'user.name', 'test'); + git(repo, 'add', '-A'); + git(repo, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'with artifacts'); + // Precondition: the artifacts really are tracked, so this is not a no-op. + expect(git(repo, 'ls-files').toString()).toContain('fm_agent/extracted_functions/x-c/extracted.c'); + + const repoIndexed = await indexOf(repo); + expect(repoIndexed).toContain('src/app.c'); + expect(repoIndexed).not.toContain('fm_agent/extracted_functions/x-c/extracted.c'); + }); +});