Skip to content

Commit bb2510e

Browse files
authored
Merge pull request #806 from DeusData/distill/560-architecture-overview
fix(mcp): get_architecture overview subset, aspects enum + validation (+ .cbmignore how-to)
2 parents 9fbdac2 + 76e87f4 commit bb2510e

6 files changed

Lines changed: 381 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,8 @@ Anything outside this subset (write/`MERGE`/`CALL` clauses, unsupported function
458458

459459
Layered: hardcoded patterns (`.git`, `node_modules`, etc.) → `.gitignore` hierarchy → `.cbmignore` (project-specific, gitignore syntax). Symlinks are always skipped.
460460

461+
See [docs/cbmignore.md](docs/cbmignore.md) for the full `.cbmignore` how-to: syntax, precedence across the ignore layers, and negation semantics.
462+
461463
## Configuration
462464

463465
```bash

docs/cbmignore.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# `.cbmignore` — Excluding Files from Indexing
2+
3+
`.cbmignore` is a project-specific ignore file that controls which files the
4+
indexer sees. It uses gitignore-style syntax and is read from the **root of
5+
the indexed directory** (`<repo>/.cbmignore`). Nested `.cbmignore` files in
6+
subdirectories are not read.
7+
8+
It applies at **file discovery time** — the directory walk that selects files
9+
for parsing. Every indexing path uses the same discovery: the initial
10+
`index_repository`, manual re-indexing, and background auto-sync. A path
11+
matched by `.cbmignore` never enters the graph. Changes to `.cbmignore` take
12+
effect on the next (re-)index.
13+
14+
Unlike `.gitignore`, it has no effect on git itself — it only shapes what the
15+
indexer sees. Commit it to share indexing excludes with your team, or list it
16+
in `.gitignore` to keep personal excludes untracked.
17+
18+
To verify it works: directory subtrees skipped during discovery are reported
19+
in the `index_repository` response under `excluded`
20+
(`{"dirs": [up to 25 paths], "count": <total>, "truncated": <bool>}`).
21+
22+
## Syntax
23+
24+
One pattern per line. Blank lines are ignored, lines starting with `#` are
25+
comments, and trailing whitespace is trimmed.
26+
27+
| Feature | Meaning |
28+
|---|---|
29+
| `*` | matches any run of characters, except `/` |
30+
| `?` | matches exactly one character, except `/` |
31+
| `**` | matches across directory boundaries (`**/name`, `dir/**`, `a/**/b`) |
32+
| `[abc]`, `[a-z]` | character classes; `[!a-z]` / `[^a-z]` negate the class |
33+
| trailing `/` | pattern matches **directories only** |
34+
| `/` anywhere else | anchors the pattern to the repo root |
35+
| no `/` in pattern | matches the file/directory name at **any depth** |
36+
| leading `!` | negation — re-includes a previously matched path; the **last matching pattern wins** |
37+
38+
Examples:
39+
40+
```gitignore
41+
# Generated protobuf output, anywhere in the tree
42+
*.pb.go
43+
44+
# A specific top-level directory (leading / anchors to the repo root)
45+
/third_party/
46+
47+
# Any directory named "snapshots", at any depth (trailing / = directories only)
48+
snapshots/
49+
50+
# Everything under any fixtures directory
51+
**/fixtures/**
52+
53+
# Anchored glob: generated clients for any single-character API version
54+
/api/v?/generated/
55+
56+
# Character class: yearly log folders 2020-2029
57+
/logs/202[0-9]/
58+
59+
# Ignore all YAML, but keep CI configs (negation — last match wins)
60+
*.yaml
61+
!ci.yaml
62+
```
63+
64+
## Precedence
65+
66+
Discovery applies its filters in a fixed order — the first layer that rejects
67+
a path wins. For directories:
68+
69+
1. **Built-in skip list**`.git`, `node_modules`, `dist`, `target`,
70+
`vendor`, tool caches, etc. (60+ names; the fast/moderate index modes add
71+
more, e.g. `docs`, `examples`, `testdata`). Not overridable from any
72+
ignore file today.
73+
2. **Repo `.gitignore`**`<repo>/.gitignore` merged with
74+
`<git-common-dir>/info/exclude` (worktree-aware); later patterns win on
75+
conflict. Honored even when the indexed directory is not a git repo root.
76+
3. **Nested `.gitignore` files** — picked up during the walk and matched
77+
relative to their own directory.
78+
4. **`.cbmignore`** — a positive match skips the path; a negated match can
79+
only rescue paths from layer 5.
80+
5. **Git global excludes**`core.excludesFile` from `~/.gitconfig` or the
81+
XDG git config (default `$XDG_CONFIG_HOME/git/ignore`); consulted only
82+
when the project is a git repo with a config.
83+
84+
For files, built-in suffix filters (`.png`, `.o`, `.db`, …; fast modes add
85+
archives, media, lockfiles, `.min.js`, …) and fast-mode filename/substring
86+
filters run **before** the ignore files, and a maximum-file-size cap runs
87+
after them; none of these are overridable from `.cbmignore`. Symlinks are
88+
always skipped.
89+
90+
## Negation (`!`) — current behavior
91+
92+
- **Within `.cbmignore`**: standard gitignore semantics. Patterns are
93+
evaluated top to bottom and the last matching pattern wins, so
94+
`!pattern` re-includes something an earlier line excluded.
95+
- **Parent pruning** (same caveat as git): when a directory is excluded, the
96+
walk never descends into it — you cannot re-include a file whose parent
97+
directory is excluded. Negate the directory itself if you need its
98+
contents.
99+
- **Across layers**: a `.cbmignore` negation overrides the **git global
100+
excludes** layer only. Example: your `~/.config/git/ignore` ignores
101+
`*.sql`, but this project's SQL should be indexed — add `!*.sql` to
102+
`.cbmignore`. Negation cannot override the built-in skip lists, the repo
103+
`.gitignore`/`info/exclude`, nested `.gitignore` files, the built-in
104+
suffix/filename filters, or the size cap.
105+
106+
### Planned (not yet implemented)
107+
108+
The negation story is being unified; none of the following works yet:
109+
110+
- `!` in `.cbmignore` will be able to un-skip ordinary built-in skip
111+
directories (`obj/`, `dist/`, `target/`, …) so build-output-like
112+
directories that actually contain source can be indexed.
113+
- A small safety core stays non-negatable by design — `.git`,
114+
`node_modules`, and worktree-internal directories — because indexing them
115+
risks OOM and correctness issues (see issue #489).
116+
- Auxiliary filesystem walkers will honor the same ignore predicate as
117+
discovery, so every code path sees an identical ignore decision
118+
(unification tracked in a follow-up issue).
119+
120+
Until these land, the "Precedence" and "Negation — current behavior" sections
121+
above describe the actual behavior.

src/mcp/mcp.c

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,11 +437,15 @@ static const tool_def_t TOOLS[] = {
437437
"representative top_nodes, and the packages/edge_types that bind it) — use these to grasp "
438438
"the real architectural seams, which often cut across the folder layout. Optional path scopes "
439439
"analysis to nodes under that directory prefix (file_path).",
440+
/* The aspects enum mirrors VALID_ASPECTS (see aspect_is_valid) — update both together. */
440441
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"path\":{\"type\":"
441442
"\"string\",\"description\":\"Optional directory prefix to scope architecture (e.g. "
442443
"apps/hoa)\"},"
443-
"\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
444-
"}"},
444+
"\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"all\","
445+
"\"overview\",\"structure\",\"dependencies\",\"routes\",\"languages\",\"packages\","
446+
"\"entry_points\",\"hotspots\",\"boundaries\",\"layers\",\"file_tree\",\"clusters\"]},"
447+
"\"description\":\"Aspects to include. 'all' = everything; 'overview' = compact summary "
448+
"(all except file_tree); omit = all.\"}},\"required\":[\"project\"]}"},
445449

446450
{"search_code", "Search code",
447451
"Graph-augmented code search. Finds text patterns via grep, then enriches results with "
@@ -2244,7 +2248,29 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
22442248
return result;
22452249
}
22462250

2247-
/* Check if an aspect is requested (NULL aspects = all, or array contains "all" or the name). */
2251+
/* Canonical list of valid aspect tokens for get_architecture. Single source
2252+
* of truth for the server-side validation (authoritative); the JSON-Schema
2253+
* enum in the TOOLS entry above is the advisory client-side mirror — update
2254+
* both together when the aspect set changes. */
2255+
static const char *VALID_ASPECTS[] = {
2256+
"all", "overview", "structure", "dependencies", "routes", "languages", "packages",
2257+
"entry_points", "hotspots", "boundaries", "layers", "file_tree", "clusters", NULL};
2258+
2259+
static bool aspect_is_valid(const char *name) {
2260+
if (!name) {
2261+
return false;
2262+
}
2263+
for (int i = 0; VALID_ASPECTS[i]; i++) {
2264+
if (strcmp(name, VALID_ASPECTS[i]) == 0) {
2265+
return true;
2266+
}
2267+
}
2268+
return false;
2269+
}
2270+
2271+
/* Check if an aspect is requested. NULL aspects = all. The array can contain
2272+
* "all" (everything), "overview" (everything except file_tree — see
2273+
* cbm_store_arch_aspect_in_overview in store.c), or the aspect name itself. */
22482274
static bool aspect_wanted(yyjson_doc *aspects_doc, yyjson_val *aspects_arr, const char *name) {
22492275
if (!aspects_arr) {
22502276
return true; /* no filter = all */
@@ -2254,7 +2280,16 @@ static bool aspect_wanted(yyjson_doc *aspects_doc, yyjson_val *aspects_arr, cons
22542280
yyjson_val *val;
22552281
while ((val = yyjson_arr_iter_next(&iter)) != NULL) {
22562282
const char *s = yyjson_get_str(val);
2257-
if (s && (strcmp(s, "all") == 0 || strcmp(s, name) == 0)) {
2283+
if (!s) {
2284+
continue;
2285+
}
2286+
if (strcmp(s, "all") == 0) {
2287+
return true;
2288+
}
2289+
if (strcmp(s, "overview") == 0 && cbm_store_arch_aspect_in_overview(name)) {
2290+
return true;
2291+
}
2292+
if (strcmp(s, name) == 0) {
22582293
return true;
22592294
}
22602295
}
@@ -2331,6 +2366,35 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
23312366
}
23322367
}
23332368

2369+
/* Server-side validation: reject unknown aspect tokens with an isError
2370+
* result listing the valid values. The JSON-Schema enum is advisory —
2371+
* many MCP clients do not validate arguments against tool schemas — so
2372+
* without this check a typo degraded to a silent near-empty payload. */
2373+
for (int i = 0; i < aspects_strs_count; i++) {
2374+
if (!aspect_is_valid(aspects_strs[i])) {
2375+
char valid_list[CBM_SZ_256];
2376+
size_t off = 0;
2377+
for (int j = 0; VALID_ASPECTS[j] && off < sizeof(valid_list); j++) {
2378+
int n = snprintf(valid_list + off, sizeof(valid_list) - off, "%s%s",
2379+
j > 0 ? ", " : "", VALID_ASPECTS[j]);
2380+
if (n < 0) {
2381+
break;
2382+
}
2383+
off += (size_t)n;
2384+
}
2385+
char msg[CBM_SZ_512];
2386+
snprintf(msg, sizeof(msg), "Unknown aspect '%s'. Valid: %s.", aspects_strs[i],
2387+
valid_list);
2388+
char *err = cbm_mcp_text_result(msg, true);
2389+
free(project);
2390+
free(scope_path);
2391+
if (aspects_doc) {
2392+
yyjson_doc_free(aspects_doc);
2393+
}
2394+
return err;
2395+
}
2396+
}
2397+
23342398
cbm_schema_info_t schema = {0};
23352399
/* Counts-only: this handler renders label/type counts but never property
23362400
* keys, and full key discovery json_each-scans every row (seconds-to-

src/store/store.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5482,6 +5482,15 @@ static int arch_clusters(cbm_store_t *s, const char *project, const char *path,
54825482

54835483
/* ── GetArchitecture dispatch ──────────────────────────────────── */
54845484

5485+
/* "overview" = compact architecture summary: every aspect EXCEPT the large
5486+
* per-file listing (file_tree), which alone dominates the payload on real
5487+
* repos and can push the MCP response past the output cap. Declared in
5488+
* store.h and shared with aspect_wanted in src/mcp/mcp.c so the store-side
5489+
* DB gate and the MCP-side serialization gate cannot drift. */
5490+
bool cbm_store_arch_aspect_in_overview(const char *name) {
5491+
return strcmp(name, "file_tree") != 0;
5492+
}
5493+
54855494
static bool want_aspect(const char **aspects, int aspect_count, const char *name) {
54865495
if (!aspects || aspect_count == 0) {
54875496
return true;
@@ -5490,6 +5499,9 @@ static bool want_aspect(const char **aspects, int aspect_count, const char *name
54905499
if (strcmp(aspects[i], "all") == 0) {
54915500
return true;
54925501
}
5502+
if (strcmp(aspects[i], "overview") == 0 && cbm_store_arch_aspect_in_overview(name)) {
5503+
return true;
5504+
}
54935505
if (strcmp(aspects[i], name) == 0) {
54945506
return true;
54955507
}

src/store/store.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ bool cbm_store_arch_path_scoped(const char *path);
333333
/* When scoped, writes normalized directory prefix into norm_out. Returns false if unscoped. */
334334
bool cbm_store_normalize_arch_path(const char *path, char *norm_out, size_t norm_sz);
335335

336+
/* True when architecture aspect `name` belongs to the "overview" subset:
337+
* every aspect EXCEPT the large per-file listing (file_tree). Shared by both
338+
* aspect gates — want_aspect (store.c) and aspect_wanted (mcp.c) — so the
339+
* two sites cannot drift. */
340+
bool cbm_store_arch_aspect_in_overview(const char *name);
341+
336342
/* Delete all nodes for a project (cascade deletes edges). */
337343
int cbm_store_delete_nodes_by_project(cbm_store_t *s, const char *project);
338344

0 commit comments

Comments
 (0)