Skip to content

Commit e02545f

Browse files
release: @levnikolaevich/hex-graph-mcp v0.17.0
- Gitignore-aware file discovery via new lib/file-discovery.mjs: Git repos use `git ls-files -co --exclude-standard`; non-Git dirs fall back to a deterministic walker honoring root .gitignore and built-in generated-directory excludes - indexer.mjs: Pass 0 purges previously-indexed files that become ignored; reindexFile drops a file from the graph if new rules ignore it - workspace.mjs, benchmark/helpers.mjs: switch off custom walkers to the shared listProjectFiles() helper - server.mjs: index_project description updated to reflect the new default behavior - README: parsing section documents Git-vs-fallback discovery; quality snapshot bumps to 103/103 - test/smoke.mjs: new regression guard for .gitignore exclusion and purge-on-new-rule behavior Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60f47bb commit e02545f

12 files changed

Lines changed: 248 additions & 120 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Bundled MCP servers extend agent capabilities — hash-verified editing, code in
116116
Deterministic scope rule: `hex-line` and `hex-graph` keep `path` as the project anchor. In normal use the agent fills it automatically from the active file or project root, so users usually do not need to type it manually. `hex-ssh` runs on Windows/macOS/Linux hosts; remote shell tools stay POSIX-oriented, while SFTP transfers support platform-aware remote paths.
117117

118118
<!-- GENERATED:HEX_GRAPH_MCP_STATUS:START -->
119-
`hex-graph-mcp` quality snapshot: `102/102` tests passing, `1` curated corpus, `1` pinned external corpora, parser-first `green`.
119+
`hex-graph-mcp` quality snapshot: `103/103` tests passing, `1` curated corpus, `1` pinned external corpora, parser-first `green`.
120120
<!-- GENERATED:HEX_GRAPH_MCP_STATUS:END -->
121121

122122
### External servers

mcp/hex-graph-mcp/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Errors use the same grammar, not a JSON error envelope:
123123

124124
| Tool | What it returns |
125125
|------|-----------------|
126-
| `index_project` | Index summary, languages, providers, framework overlays, warnings, and next actions |
126+
| `index_project` | Gitignore-aware index summary, languages, providers, framework overlays, warnings, and next actions |
127127
| `install_graph_providers` | Detected stack, provider status, SCIP exporter status, install plan, remediation steps, and agent-ready instructions |
128128

129129
### Symbol Navigation
@@ -206,6 +206,7 @@ hex-graph-mcp/
206206

207207
### Parsing
208208

209+
- File discovery honors Git excludes by default. Git repositories use `git ls-files -co --exclude-standard`; non-Git directories use the deterministic fallback walker with root `.gitignore` rules and generated-directory exclusions.
209210
- **tree-sitter WASM** via `web-tree-sitter` and repo-owned grammar artifacts from `hex-common/artifacts/tree-sitter`
210211
- Extracts definitions, imports, exports, calls, references, and explicit inheritance syntax
211212
- Feeds a shared pipeline used by both full indexing and watcher-driven reindexing
@@ -256,7 +257,7 @@ Inline `quality` metadata is currently surfaced by:
256257
### Generated Snapshot
257258

258259
- MCP tools registered in server contract: `14`
259-
- Semantic suite: `102/102` passing
260+
- Semantic suite: `103/103` passing
260261
- Corpora: `1` curated, `1` pinned external
261262
- Lanes: parser-first `green`, precise overlay `provider_conditional`
262263

mcp/hex-graph-mcp/benchmark/helpers.mjs

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
*/
44

55
import { execFileSync, execSync } from "node:child_process";
6-
import { readdirSync, statSync } from "node:fs";
6+
import { statSync } from "node:fs";
77
import { resolve, extname } from "node:path";
8+
import { listProjectFiles } from "../lib/file-discovery.mjs";
89

910
export const CODE_EXTS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".py", ".cs", ".php"]);
1011
export const RUNS = 3;
@@ -19,24 +20,16 @@ export function pctSavings(without, withG) {
1920
return pct >= 0 ? `${pct.toFixed(0)}%` : `-${Math.abs(pct).toFixed(0)}%`;
2021
}
2122

22-
export function walkDir(dir, depth = 0) {
23-
if (depth > 10) return [];
23+
export function walkDir(dir) {
24+
const root = resolve(dir);
2425
const results = [];
25-
let entries;
26-
try { entries = readdirSync(dir, { withFileTypes: true }); }
27-
catch { return results; }
28-
for (const e of entries) {
29-
const full = resolve(dir, e.name);
30-
if (e.isDirectory()) {
31-
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "vendor"
32-
|| e.name === "dist" || e.name === "__pycache__" || e.name === "target") continue;
33-
results.push(...walkDir(full, depth + 1));
34-
} else if (e.isFile() && CODE_EXTS.has(extname(e.name).toLowerCase())) {
35-
try {
36-
const st = statSync(full);
37-
if (st.size > 0 && st.size < 1_000_000) results.push(full);
38-
} catch { /* skip */ }
39-
}
26+
for (const relPath of listProjectFiles(root)) {
27+
if (!CODE_EXTS.has(extname(relPath).toLowerCase())) continue;
28+
const full = resolve(root, relPath);
29+
try {
30+
const st = statSync(full);
31+
if (st.size > 0 && st.size < 1_000_000) results.push(full);
32+
} catch { /* skip */ }
4033
}
4134
return results;
4235
}

mcp/hex-graph-mcp/benchmark/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ async function main() {
197197
if (amort) {
198198
out.push("### Latency Diagnostics");
199199
out.push("");
200-
out.push(`Index time: ${fmt(Math.round(amort.indexTimeMs))}ms for ${fmt(stats.files)} files`);
200+
out.push(`Index time: ${fmt(Math.round(amort.indexTimeMs))}ms for ${fmt(allFiles.length)} source files`);
201201
out.push(`Average query: ${amort.avgQueryMs.toFixed(1)}ms`);
202202
out.push(`Built-in comparison query: ${amort.avgBuiltinMs.toFixed(1)}ms`);
203203
out.push("");

mcp/hex-graph-mcp/evals/artifacts/quality-report.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"summary": {
44
"semantic_suite": {
55
"runner": "node --test test/*.mjs",
6-
"passed": 102,
6+
"passed": 103,
77
"skipped": 0,
8-
"total": 102,
8+
"total": 103,
99
"failed": 0,
1010
"status": "pass"
1111
},
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Project file inventory for graph indexing.
3+
*
4+
* Git repositories use Git's own exclude engine so .gitignore, info/exclude,
5+
* and global excludes are interpreted exactly as the working tree sees them.
6+
* Non-Git fixtures fall back to a deterministic walker with common generated
7+
* directories and root .gitignore rules.
8+
*/
9+
10+
import { execFileSync } from "node:child_process";
11+
import { existsSync, readFileSync, readdirSync } from "node:fs";
12+
import { join, relative, resolve } from "node:path";
13+
import picomatch from "picomatch";
14+
import { CODEGRAPH_DIR } from "./store.mjs";
15+
16+
const WALK_IGNORE_DIRS = new Set([
17+
"node_modules", ".git", "dist", "build", "out", ".next",
18+
"__pycache__", ".venv", "venv", "vendor", "target",
19+
CODEGRAPH_DIR, ".vs", "bin", "obj",
20+
]);
21+
22+
function normalizeRelPath(path) {
23+
return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
24+
}
25+
26+
function isInsideProject(relPath) {
27+
return relPath && relPath !== "." && !relPath.startsWith("../") && !relPath.startsWith("..\\");
28+
}
29+
30+
function gitInventory(projectPath) {
31+
try {
32+
const output = execFileSync("git", ["ls-files", "-co", "--exclude-standard", "--", "."], {
33+
cwd: projectPath,
34+
encoding: "utf8",
35+
stdio: ["ignore", "pipe", "ignore"],
36+
timeout: 10_000,
37+
maxBuffer: 50 * 1024 * 1024,
38+
});
39+
return output
40+
.split(/\r?\n/)
41+
.map(normalizeRelPath)
42+
.filter(isInsideProject)
43+
.filter(relPath => existsSync(join(projectPath, relPath)));
44+
} catch {
45+
return null;
46+
}
47+
}
48+
49+
function gitignorePatternVariants(pattern, { anchored, directoryOnly }) {
50+
const variants = [];
51+
const pathPattern = pattern.replace(/^\/+/, "").replace(/\/+$/, "");
52+
if (!pathPattern) return variants;
53+
54+
const hasSlash = pathPattern.includes("/");
55+
const bases = anchored || hasSlash
56+
? [pathPattern]
57+
: [pathPattern, `**/${pathPattern}`];
58+
59+
for (const base of bases) {
60+
variants.push(base);
61+
if (directoryOnly) variants.push(`${base}/**`);
62+
}
63+
return variants;
64+
}
65+
66+
function loadRootGitignoreRules(projectPath) {
67+
const gitignorePath = join(projectPath, ".gitignore");
68+
if (!existsSync(gitignorePath)) return [];
69+
70+
let lines;
71+
try {
72+
lines = readFileSync(gitignorePath, "utf8").split(/\r?\n/);
73+
} catch {
74+
return [];
75+
}
76+
77+
const rules = [];
78+
for (let raw of lines) {
79+
raw = raw.trimEnd();
80+
if (!raw || raw.startsWith("#")) continue;
81+
82+
const negated = raw.startsWith("!");
83+
if (negated) raw = raw.slice(1);
84+
if (!raw || raw.startsWith("#")) continue;
85+
86+
const anchored = raw.startsWith("/");
87+
const directoryOnly = raw.endsWith("/");
88+
const variants = gitignorePatternVariants(raw, { anchored, directoryOnly });
89+
if (variants.length === 0) continue;
90+
91+
rules.push({
92+
negated,
93+
isMatch: picomatch(variants, { dot: true }),
94+
});
95+
}
96+
return rules;
97+
}
98+
99+
function isIgnoredByRules(relPath, rules) {
100+
let ignored = false;
101+
const normalized = normalizeRelPath(relPath);
102+
for (const rule of rules) {
103+
if (rule.isMatch(normalized)) ignored = !rule.negated;
104+
}
105+
return ignored;
106+
}
107+
108+
function fallbackInventory(projectPath) {
109+
const results = [];
110+
const rules = loadRootGitignoreRules(projectPath);
111+
112+
function walk(dir, depth = 0) {
113+
if (depth > 12) return;
114+
let entries;
115+
try {
116+
entries = readdirSync(dir, { withFileTypes: true });
117+
} catch {
118+
return;
119+
}
120+
121+
for (const entry of entries) {
122+
const fullPath = resolve(dir, entry.name);
123+
const relPath = normalizeRelPath(relative(projectPath, fullPath));
124+
if (!isInsideProject(relPath)) continue;
125+
126+
if (entry.isDirectory()) {
127+
if (WALK_IGNORE_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
128+
if (isIgnoredByRules(relPath, rules)) continue;
129+
walk(fullPath, depth + 1);
130+
continue;
131+
}
132+
133+
if (!entry.isFile()) continue;
134+
if (isIgnoredByRules(relPath, rules)) continue;
135+
results.push(relPath);
136+
}
137+
}
138+
139+
walk(projectPath);
140+
return results;
141+
}
142+
143+
export function listProjectFiles(projectPath) {
144+
const absPath = resolve(projectPath);
145+
return gitInventory(absPath) ?? fallbackInventory(absPath);
146+
}

0 commit comments

Comments
 (0)