codebase-brain-mcp 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/PKG-INFO +54 -12
  2. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/README.md +50 -11
  3. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/pyproject.toml +4 -1
  4. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/pyproject.toml.orig +4 -1
  5. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/analysis.py +18 -6
  6. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/chunking.py +46 -18
  7. codebase_brain_mcp-0.2.0/src/codebase_brain_mcp/chunking_js.py +255 -0
  8. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/evals.py +5 -2
  9. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/server.py +2 -2
  10. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/store.py +13 -9
  11. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/LICENSE +0 -0
  12. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/__init__.py +0 -0
  13. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/__main__.py +0 -0
  14. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/brain.py +0 -0
  15. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/config.py +0 -0
  16. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/embeddings.py +0 -0
  17. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/history.py +0 -0
  18. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/py.typed +0 -0
  19. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/remote.py +0 -0
  20. {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/sources.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-brain-mcp
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: An MCP server that gives AI agents a cited, searchable memory of a codebase.
5
5
  Keywords: mcp,model-context-protocol,rag,code-search,ai-agents,claude,git
6
6
  Author: Noman Nazir
@@ -18,6 +18,9 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
18
  Requires-Dist: fastembed>=0.9.1
19
19
  Requires-Dist: mcp>=2.3.0
20
20
  Requires-Dist: numpy>=2.5.4
21
+ Requires-Dist: tree-sitter>=0.25.2,<0.26
22
+ Requires-Dist: tree-sitter-javascript>=0.25
23
+ Requires-Dist: tree-sitter-typescript>=0.23.2
21
24
  Requires-Python: >=3.12
22
25
  Project-URL: Homepage, https://github.com/Nomannazir/codebase-brain-mcp
23
26
  Project-URL: Repository, https://github.com/Nomannazir/codebase-brain-mcp
@@ -73,8 +76,10 @@ A plug-in for any AI assistant that turns a codebase into something it can actua
73
76
 
74
77
  ## Why it's different (technical)
75
78
 
76
- - **Code-aware chunking.** Python is split by function, class and method using the real syntax tree, Markdown by
77
- heading. Each chunk knows its symbol (`TimestampSigner.unsign`) and exact lines, so citations point at real code.
79
+ - **Code-aware chunking.** Python (with `ast`) and JavaScript / TypeScript / JSX / TSX (with tree-sitter) are split
80
+ by function, class, method, arrow function, interface, type and enum, with their doc comments. Markdown is split
81
+ by heading. Each chunk knows its symbol (`TimestampSigner.unsign`, `Ky.#retry`) and exact lines, so citations
82
+ point at real code. Other languages are indexed in overlapping line windows.
78
83
  - **Hybrid retrieval.** SQLite FTS5 BM25 (with `camelCase` / `snake_case` identifier splitting) plus local
79
84
  embeddings, merged with reciprocal-rank fusion. Exact names and fuzzy questions both work.
80
85
  - **Local and free.** Embeddings run on your machine (`fastembed`, ONNX). No API keys, and no code leaves your
@@ -85,7 +90,8 @@ A plug-in for any AI assistant that turns a codebase into something it can actua
85
90
  the current lines, and links the pull requests behind each change. So agents can answer *"why is it built this
86
91
  way?"*, not just *"what does it do?"*.
87
92
  - **Impact analysis.** `impact_of_change` lists everything that uses a function, directly and transitively, plus the
88
- tests that exercise it, before an agent changes anything.
93
+ tests that exercise it, before an agent changes anything. It works across Python, JavaScript and TypeScript files,
94
+ and follows a constructor to the code that builds its class.
89
95
  - **Doc-drift detection.** `find_doc_drift` checks Markdown and reStructuredText docs against the code. It finds
90
96
  files, functions, settings and flags that don't exist; examples passing parameters a function doesn't take; imports
91
97
  that would fail; and sections last edited long before the code they describe changed. Each finding cites the doc
@@ -209,10 +215,14 @@ because they're supposed to mention removed things.
209
215
  ## Evaluation
210
216
 
211
217
  Does search actually put the right code in front of the model? `codebase-brain eval` answers that with a benchmark.
212
- The benchmark has 32 questions about [pallets/itsdangerous](https://github.com/pallets/itsdangerous), pinned to
213
- commit `672971d`, each with the code or docs a good answer must come from. The questions are in
214
- [`evals/datasets/itsdangerous.toml`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/datasets/itsdangerous.toml). They were written by reading the code
215
- before any results were measured, and haven't been changed since.
218
+ There are two, each with 32 questions pinned to one commit, and each question lists the code or docs a good answer
219
+ must come from. The questions were written by reading the code before any results were measured, and haven't been
220
+ changed since.
221
+
222
+ ### Python: pallets/itsdangerous
223
+
224
+ 32 questions about [pallets/itsdangerous](https://github.com/pallets/itsdangerous) at commit `672971d`, in
225
+ [`evals/datasets/itsdangerous.toml`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/datasets/itsdangerous.toml).
216
226
 
217
227
  | Mode | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@10 (95% CI) | nDCG@10 | p50 latency |
218
228
  |---|---|---|---|---|---|---|---|
@@ -236,10 +246,40 @@ before any results were measured, and haven't been changed since.
236
246
  the top 5. They're listed in [`evals/results/itsdangerous.md`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/results/itsdangerous.md) and are the next
237
247
  thing to improve. A larger, held-out question set will come first, so any tuning isn't fitted to these 32.
238
248
 
239
- Reproduce it (the first run downloads the repo and the embedding model):
249
+ ### TypeScript: sindresorhus/ky
250
+
251
+ 32 questions about [sindresorhus/ky](https://github.com/sindresorhus/ky) at commit `3541888`, in
252
+ [`evals/datasets/ky.toml`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/datasets/ky.toml).
253
+ The readme is one 2,000-line file, so docs questions must find the right *section*, not just the file.
254
+
255
+ | Mode | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@10 (95% CI) | nDCG@10 | p50 latency |
256
+ |---|---|---|---|---|---|---|---|
257
+ | **hybrid** (default) | 47% | 66% | 69% | 78% | 0.563 (0.41–0.71) | 0.576 | 6 ms |
258
+ | keyword only (BM25) | 38% | 44% | 53% | 62% | 0.436 (0.29–0.59) | 0.455 | 1 ms |
259
+ | vector only (bge-small) | 47% | **69%** | **75%** | 78% | **0.594** (0.46–0.74) | **0.604** | 5 ms |
260
+
261
+ | Hit@5 by question type | describes behaviour (14) | about the docs (7) | names the code (11) |
262
+ |---|---|---|---|
263
+ | hybrid | 36% | **100%** | 91% |
264
+ | keyword only | 7% | 86% | 91% |
265
+ | vector only | **43%** | **100%** | **100%** |
266
+
267
+ **What this shows:**
268
+ - **Naming the code or asking about the docs works well:** 91–100% in the top 5.
269
+ - **Describing behaviour in plain words is the weak spot (36%).** ky has about 25,000 lines of tests next to
270
+ 5,000 lines of source, and the tests describe the same behaviour in plain words, so they outrank the source.
271
+ Every miss is listed in [`evals/results/ky.md`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/results/ky.md).
272
+ - **Hybrid is not better than vector-only here.** The intervals overlap heavily (hybrid beats vector on 5
273
+ questions and loses on 9), so neither is proven better. Keyword-only is clearly worst: hybrid beats it on 12
274
+ questions and loses on none.
275
+ - These results are published as measured. Tuning (for example, ranking source above tests) will be checked
276
+ against a new held-out set, so it isn't fitted to these questions.
277
+
278
+ Reproduce either benchmark (the first run downloads the repo and the embedding model):
240
279
 
241
280
  ```bash
242
281
  uv run codebase-brain eval evals/datasets/itsdangerous.toml --out evals/results
282
+ uv run codebase-brain eval evals/datasets/ky.toml --out evals/results
243
283
  ```
244
284
 
245
285
  How it's measured:
@@ -255,7 +295,7 @@ How it's measured:
255
295
 
256
296
  ```
257
297
  source ─► resolve (local dir / shallow https clone) ─► list files (.gitignore-aware, skips binaries and lockfiles)
258
- ─► chunk (ast / headings / line windows) ─► embed (fastembed) ─► SQLite: chunks + vectors + FTS5
298
+ ─► chunk (ast / tree-sitter / headings / windows) ─► embed (fastembed) ─► SQLite: chunks + vectors + FTS5
259
299
  query ─► BM25 top 50 ┐
260
300
  ─► vector top 50 ┴─► reciprocal-rank fusion ─► filters ─► cited sources
261
301
  ```
@@ -285,9 +325,11 @@ uv run pyright # types
285
325
  - [x] Git history and PR discussions: `explain_history`, `search_history`
286
326
  - [x] Symbol references and transitive dependents: `impact_of_change`
287
327
  - [x] `find_doc_drift`: Markdown and reStructuredText, signatures, imports, settings, flags, staleness
288
- - [ ] tree-sitter chunking for more languages
328
+ - [x] JavaScript and TypeScript (tree-sitter): chunking, references, impact analysis
329
+ - [ ] More languages (Go, Rust, Java) with tree-sitter
289
330
  - [x] Retrieval evals with confidence intervals (`codebase-brain eval`)
290
331
  - [ ] Larger held-out eval set, then retrieval tuning (reranking, query expansion)
291
332
  - [x] Streamable HTTP with bearer-token auth, Docker image
292
- - [ ] Hosted demo, PyPI package, MCP Registry listing
333
+ - [x] PyPI package, MCP Registry listing
334
+ - [ ] Hosted demo
293
335
  - [ ] OpenTelemetry tracing
@@ -47,8 +47,10 @@ A plug-in for any AI assistant that turns a codebase into something it can actua
47
47
 
48
48
  ## Why it's different (technical)
49
49
 
50
- - **Code-aware chunking.** Python is split by function, class and method using the real syntax tree, Markdown by
51
- heading. Each chunk knows its symbol (`TimestampSigner.unsign`) and exact lines, so citations point at real code.
50
+ - **Code-aware chunking.** Python (with `ast`) and JavaScript / TypeScript / JSX / TSX (with tree-sitter) are split
51
+ by function, class, method, arrow function, interface, type and enum, with their doc comments. Markdown is split
52
+ by heading. Each chunk knows its symbol (`TimestampSigner.unsign`, `Ky.#retry`) and exact lines, so citations
53
+ point at real code. Other languages are indexed in overlapping line windows.
52
54
  - **Hybrid retrieval.** SQLite FTS5 BM25 (with `camelCase` / `snake_case` identifier splitting) plus local
53
55
  embeddings, merged with reciprocal-rank fusion. Exact names and fuzzy questions both work.
54
56
  - **Local and free.** Embeddings run on your machine (`fastembed`, ONNX). No API keys, and no code leaves your
@@ -59,7 +61,8 @@ A plug-in for any AI assistant that turns a codebase into something it can actua
59
61
  the current lines, and links the pull requests behind each change. So agents can answer *"why is it built this
60
62
  way?"*, not just *"what does it do?"*.
61
63
  - **Impact analysis.** `impact_of_change` lists everything that uses a function, directly and transitively, plus the
62
- tests that exercise it, before an agent changes anything.
64
+ tests that exercise it, before an agent changes anything. It works across Python, JavaScript and TypeScript files,
65
+ and follows a constructor to the code that builds its class.
63
66
  - **Doc-drift detection.** `find_doc_drift` checks Markdown and reStructuredText docs against the code. It finds
64
67
  files, functions, settings and flags that don't exist; examples passing parameters a function doesn't take; imports
65
68
  that would fail; and sections last edited long before the code they describe changed. Each finding cites the doc
@@ -183,10 +186,14 @@ because they're supposed to mention removed things.
183
186
  ## Evaluation
184
187
 
185
188
  Does search actually put the right code in front of the model? `codebase-brain eval` answers that with a benchmark.
186
- The benchmark has 32 questions about [pallets/itsdangerous](https://github.com/pallets/itsdangerous), pinned to
187
- commit `672971d`, each with the code or docs a good answer must come from. The questions are in
188
- [`evals/datasets/itsdangerous.toml`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/datasets/itsdangerous.toml). They were written by reading the code
189
- before any results were measured, and haven't been changed since.
189
+ There are two, each with 32 questions pinned to one commit, and each question lists the code or docs a good answer
190
+ must come from. The questions were written by reading the code before any results were measured, and haven't been
191
+ changed since.
192
+
193
+ ### Python: pallets/itsdangerous
194
+
195
+ 32 questions about [pallets/itsdangerous](https://github.com/pallets/itsdangerous) at commit `672971d`, in
196
+ [`evals/datasets/itsdangerous.toml`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/datasets/itsdangerous.toml).
190
197
 
191
198
  | Mode | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@10 (95% CI) | nDCG@10 | p50 latency |
192
199
  |---|---|---|---|---|---|---|---|
@@ -210,10 +217,40 @@ before any results were measured, and haven't been changed since.
210
217
  the top 5. They're listed in [`evals/results/itsdangerous.md`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/results/itsdangerous.md) and are the next
211
218
  thing to improve. A larger, held-out question set will come first, so any tuning isn't fitted to these 32.
212
219
 
213
- Reproduce it (the first run downloads the repo and the embedding model):
220
+ ### TypeScript: sindresorhus/ky
221
+
222
+ 32 questions about [sindresorhus/ky](https://github.com/sindresorhus/ky) at commit `3541888`, in
223
+ [`evals/datasets/ky.toml`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/datasets/ky.toml).
224
+ The readme is one 2,000-line file, so docs questions must find the right *section*, not just the file.
225
+
226
+ | Mode | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@10 (95% CI) | nDCG@10 | p50 latency |
227
+ |---|---|---|---|---|---|---|---|
228
+ | **hybrid** (default) | 47% | 66% | 69% | 78% | 0.563 (0.41–0.71) | 0.576 | 6 ms |
229
+ | keyword only (BM25) | 38% | 44% | 53% | 62% | 0.436 (0.29–0.59) | 0.455 | 1 ms |
230
+ | vector only (bge-small) | 47% | **69%** | **75%** | 78% | **0.594** (0.46–0.74) | **0.604** | 5 ms |
231
+
232
+ | Hit@5 by question type | describes behaviour (14) | about the docs (7) | names the code (11) |
233
+ |---|---|---|---|
234
+ | hybrid | 36% | **100%** | 91% |
235
+ | keyword only | 7% | 86% | 91% |
236
+ | vector only | **43%** | **100%** | **100%** |
237
+
238
+ **What this shows:**
239
+ - **Naming the code or asking about the docs works well:** 91–100% in the top 5.
240
+ - **Describing behaviour in plain words is the weak spot (36%).** ky has about 25,000 lines of tests next to
241
+ 5,000 lines of source, and the tests describe the same behaviour in plain words, so they outrank the source.
242
+ Every miss is listed in [`evals/results/ky.md`](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/evals/results/ky.md).
243
+ - **Hybrid is not better than vector-only here.** The intervals overlap heavily (hybrid beats vector on 5
244
+ questions and loses on 9), so neither is proven better. Keyword-only is clearly worst: hybrid beats it on 12
245
+ questions and loses on none.
246
+ - These results are published as measured. Tuning (for example, ranking source above tests) will be checked
247
+ against a new held-out set, so it isn't fitted to these questions.
248
+
249
+ Reproduce either benchmark (the first run downloads the repo and the embedding model):
214
250
 
215
251
  ```bash
216
252
  uv run codebase-brain eval evals/datasets/itsdangerous.toml --out evals/results
253
+ uv run codebase-brain eval evals/datasets/ky.toml --out evals/results
217
254
  ```
218
255
 
219
256
  How it's measured:
@@ -229,7 +266,7 @@ How it's measured:
229
266
 
230
267
  ```
231
268
  source ─► resolve (local dir / shallow https clone) ─► list files (.gitignore-aware, skips binaries and lockfiles)
232
- ─► chunk (ast / headings / line windows) ─► embed (fastembed) ─► SQLite: chunks + vectors + FTS5
269
+ ─► chunk (ast / tree-sitter / headings / windows) ─► embed (fastembed) ─► SQLite: chunks + vectors + FTS5
233
270
  query ─► BM25 top 50 ┐
234
271
  ─► vector top 50 ┴─► reciprocal-rank fusion ─► filters ─► cited sources
235
272
  ```
@@ -259,9 +296,11 @@ uv run pyright # types
259
296
  - [x] Git history and PR discussions: `explain_history`, `search_history`
260
297
  - [x] Symbol references and transitive dependents: `impact_of_change`
261
298
  - [x] `find_doc_drift`: Markdown and reStructuredText, signatures, imports, settings, flags, staleness
262
- - [ ] tree-sitter chunking for more languages
299
+ - [x] JavaScript and TypeScript (tree-sitter): chunking, references, impact analysis
300
+ - [ ] More languages (Go, Rust, Java) with tree-sitter
263
301
  - [x] Retrieval evals with confidence intervals (`codebase-brain eval`)
264
302
  - [ ] Larger held-out eval set, then retrieval tuning (reranking, query expansion)
265
303
  - [x] Streamable HTTP with bearer-token auth, Docker image
266
- - [ ] Hosted demo, PyPI package, MCP Registry listing
304
+ - [x] PyPI package, MCP Registry listing
305
+ - [ ] Hosted demo
267
306
  - [ ] OpenTelemetry tracing
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebase-brain-mcp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An MCP server that gives AI agents a cited, searchable memory of a codebase."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -29,6 +29,9 @@ dependencies = [
29
29
  "fastembed>=0.9.1",
30
30
  "mcp>=2.3.0",
31
31
  "numpy>=2.5.4",
32
+ "tree-sitter>=0.25.2,<0.26",
33
+ "tree-sitter-javascript>=0.25",
34
+ "tree-sitter-typescript>=0.23.2",
32
35
  ]
33
36
 
34
37
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebase-brain-mcp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An MCP server that gives AI agents a cited, searchable memory of a codebase."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -24,6 +24,9 @@ dependencies = [
24
24
  "fastembed>=0.9.1",
25
25
  "mcp>=2.3.0",
26
26
  "numpy>=2.5.4",
27
+ "tree-sitter>=0.25.2,<0.26", # 0.26.0 crashes the process (SIGBUS) while walking big files
28
+ "tree-sitter-javascript>=0.25",
29
+ "tree-sitter-typescript>=0.23.2",
27
30
  ]
28
31
 
29
32
  [project.urls]
@@ -13,10 +13,12 @@ from datetime import datetime, timedelta
13
13
  from pathlib import Path
14
14
 
15
15
  from . import history
16
+ from .chunking import DEFINITION_KINDS
16
17
  from .sources import SourceError
17
18
  from .store import Store, StoredChunk
18
19
 
19
20
  MAX_PER_LEVEL = 60
21
+ STRUCTURED_SUFFIXES = (".py", ".pyi", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx")
20
22
  STALE_AFTER = timedelta(days=180)
21
23
  MAX_STALENESS_CHECKS = 40
22
24
  _FENCE = re.compile(r"^(`{3,}|~{3,})\s*([\w+-]*)[^\n]*\n(.*?)^\1\s*$", re.MULTILINE | re.DOTALL)
@@ -31,7 +33,7 @@ _PATH = re.compile(r"^\.?/?[\w.-]+(/[\w.-]+)+/?$")
31
33
  _ENV = re.compile(r"^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$")
32
34
  _FLAG = re.compile(r"^(--[a-z][a-z0-9-]+)(=\S*)?$")
33
35
  _SYMBOL = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*(\(\))?$")
34
- _TEST_PATH = re.compile(r"(^|/)(tests?|testing)/|(^|/)test_[^/]+$|_test\.\w+$")
36
+ _TEST_PATH = re.compile(r"(^|/)(tests?|testing|__tests__)/|(^|/)test_[^/]+$|_test\.\w+$|\.(test|spec)\.[jt]sx?$")
35
37
  _COMMON = set(dir(builtins)) | set(keyword.kwlist) | {"true", "false", "null", "none", "self", "cls"}
36
38
  _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
37
39
  _CHANGELOG = re.compile(
@@ -57,6 +59,8 @@ def definition_span(matches: list[StoredChunk]) -> tuple[str, int, int, str | No
57
59
 
58
60
  # ---- impact_of_change -------------------------------------------------------------------------
59
61
 
62
+ _CONSTRUCTORS = {"__init__", "__new__", "constructor"}
63
+
60
64
 
61
65
  @dataclass(frozen=True)
62
66
  class Dependent:
@@ -79,13 +83,21 @@ class ImpactReport:
79
83
  notes: list[str] = field(default_factory=list)
80
84
 
81
85
 
86
+ def _used_as(symbol: str) -> str:
87
+ """The name other code uses to reach ``symbol``: a constructor is reached through its class."""
88
+ owner, _, short = symbol.rpartition(".")
89
+ if short in _CONSTRUCTORS and owner:
90
+ return owner.rsplit(".", 1)[-1]
91
+ return short
92
+
93
+
82
94
  def impact_of_change(store: Store, target: StoredChunk, span: tuple[int, int], depth: int = 2) -> ImpactReport:
83
95
  """Everything that uses ``target`` by name, directly and (up to ``depth``) transitively."""
84
96
  symbol = target.symbol or ""
85
97
  short = symbol.rsplit(".", 1)[-1]
86
98
  excluded = {c.id for c in store.find_symbol(symbol, target.path)}
87
99
  dependents: list[Dependent] = []
88
- frontier = [short]
100
+ frontier = [_used_as(symbol)]
89
101
  for level in range(1, max(1, min(depth, 3)) + 1):
90
102
  next_frontier: list[str] = []
91
103
  for name in frontier:
@@ -97,8 +109,8 @@ def impact_of_change(store: Store, target: StoredChunk, span: tuple[int, int], d
97
109
  dependents.append(
98
110
  Dependent(chunk=chunk, via=name, level=level, is_test=bool(_TEST_PATH.search(chunk.path)))
99
111
  )
100
- if chunk.symbol and chunk.kind in ("function", "method", "class"):
101
- next_frontier.append(chunk.symbol.rsplit(".", 1)[-1])
112
+ if chunk.symbol and chunk.kind in DEFINITION_KINDS:
113
+ next_frontier.append(_used_as(chunk.symbol))
102
114
  frontier = list(dict.fromkeys(next_frontier))
103
115
  if not frontier:
104
116
  break
@@ -114,8 +126,8 @@ def impact_of_change(store: Store, target: StoredChunk, span: tuple[int, int], d
114
126
  f"{len(twins)} other definition(s) share the name {short!r}; some dependents may use those "
115
127
  "instead. Check the import or receiver before changing anything."
116
128
  )
117
- if not target.path.endswith((".py", ".pyi")):
118
- notes.append("Only Python is analysed structurally; for other languages, also run search for the name.")
129
+ if not target.path.endswith(STRUCTURED_SUFFIXES):
130
+ notes.append("Only Python, JS and TS are analysed structurally; for other languages, also search the name.")
119
131
  notes.append("Found by name, so dynamic calls (getattr, string dispatch, callbacks) aren't included.")
120
132
  return ImpactReport(
121
133
  symbol=symbol,
@@ -1,7 +1,8 @@
1
1
  """Split files into chunks that follow the code's own structure.
2
2
 
3
- Python is split by function and class (with exact line numbers, from ``ast``), Markdown by heading,
4
- and everything else into overlapping line windows. Chunks that are still too long are windowed
3
+ Python is split by function and class (with exact line numbers, from ``ast``), JavaScript and
4
+ TypeScript the same way (with tree-sitter), Markdown by heading, and everything else into overlapping
5
+ line windows. Chunks that are still too long are windowed
5
6
  again but keep their symbol, so a citation always says which function it came from.
6
7
  """
7
8
 
@@ -12,11 +13,16 @@ import builtins
12
13
  import re
13
14
  from dataclasses import dataclass, replace
14
15
 
16
+ from . import chunking_js
17
+
15
18
  WINDOW_LINES = 60
16
19
  WINDOW_OVERLAP = 10
17
20
  MAX_CHUNK_LINES = 120
18
21
  _HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
19
22
  _IGNORED_NAMES = set(dir(builtins)) | {"self", "cls", "super", "typing", "t", "_"}
23
+ DEFINITION_KINDS = ("function", "method", "class", "interface", "type", "enum")
24
+ """Chunk kinds that define a named thing other code can use."""
25
+ Signatures = dict[int, tuple[tuple[str, ...], bool]]
20
26
 
21
27
 
22
28
  @dataclass(frozen=True)
@@ -27,11 +33,11 @@ class Chunk:
27
33
  end_line: int
28
34
  """1-based, inclusive."""
29
35
  kind: str
30
- """function, class, method, module, section, or code."""
36
+ """function, class, method, interface, type, enum, module, section, or code."""
31
37
  symbol: str | None
32
38
  text: str
33
39
  refs: tuple[str, ...] = ()
34
- """Names this chunk uses: calls, attributes and imports (Python only). Feeds impact_of_change."""
40
+ """Names this chunk uses: calls, attributes and imports (Python, JS and TS). Feeds impact_of_change."""
35
41
  params: tuple[str, ...] | None = None
36
42
  """Parameter names when the chunk is a function or method; None otherwise. Feeds find_doc_drift."""
37
43
  accepts_any_keyword: bool = False
@@ -52,7 +58,9 @@ def chunk_file(path: str, language: str, text: str) -> list[Chunk]:
52
58
  except (SyntaxError, ValueError):
53
59
  return _windows(path, lines, 1, len(lines), "code", None)
54
60
  chunks = [piece for chunk in _python_chunks(path, lines, tree) for piece in _split_long(chunk, lines)]
55
- return _with_python_facts(chunks, tree)
61
+ return _with_facts(chunks, *_python_facts(tree))
62
+ if language in ("javascript", "typescript"):
63
+ return _js_chunks(path, language, lines, text)
56
64
  if language == "markdown":
57
65
  chunks = _markdown_chunks(path, lines)
58
66
  else:
@@ -72,7 +80,22 @@ def _python_chunks(path: str, lines: list[str], tree: ast.Module) -> list[Chunk]
72
80
  chunks.extend(_class_chunks(path, lines, node, start, end))
73
81
  else:
74
82
  chunks.append(_make(path, lines, start, end, "function", node.name))
75
- # Whatever sits between definitions (imports, constants, module docstring) is one "module" chunk each.
83
+ return _with_module_gaps(path, lines, chunks, covered)
84
+
85
+
86
+ def _js_chunks(path: str, language: str, lines: list[str], text: str) -> list[Chunk]:
87
+ found = chunking_js.outline(path, language, text, MAX_CHUNK_LINES)
88
+ if found is None:
89
+ return _windows(path, lines, 1, len(lines), "code", None)
90
+ chunks = [_make(path, lines, d.start_line, d.end_line, d.kind, d.symbol) for d in found.definitions]
91
+ chunks = _with_module_gaps(path, lines, chunks, found.covered)
92
+ pieces = [piece for chunk in chunks for piece in _split_long(chunk, lines)]
93
+ return _with_facts(pieces, found.uses, found.signatures, chunking_js.IGNORED_NAMES)
94
+
95
+
96
+ def _with_module_gaps(path: str, lines: list[str], chunks: list[Chunk], covered: list[tuple[int, int]]) -> list[Chunk]:
97
+ """Whatever sits between definitions (imports, constants, module docstring) is one "module" chunk each."""
98
+ result = list(chunks)
76
99
  cursor = 1
77
100
  for start, end in [*sorted(covered), (len(lines) + 1, len(lines) + 1)]:
78
101
  gap_start, gap_end = cursor, start - 1
@@ -81,15 +104,15 @@ def _python_chunks(path: str, lines: list[str], tree: ast.Module) -> list[Chunk]
81
104
  while gap_end >= gap_start and not lines[gap_end - 1].strip():
82
105
  gap_end -= 1
83
106
  if gap_start <= gap_end:
84
- chunks.append(_make(path, lines, gap_start, gap_end, "module", None))
107
+ result.append(_make(path, lines, gap_start, gap_end, "module", None))
85
108
  cursor = max(cursor, end + 1)
86
- return sorted(chunks, key=lambda c: c.start_line)
109
+ return sorted(result, key=lambda c: c.start_line)
87
110
 
88
111
 
89
- def _with_python_facts(chunks: list[Chunk], tree: ast.Module) -> list[Chunk]:
90
- """Attach the names each chunk uses, and the parameters of each function chunk."""
112
+ def _python_facts(tree: ast.Module) -> tuple[list[tuple[int, str]], Signatures, set[str]]:
113
+ """The names used on each line, and each function's parameters keyed by its first line."""
91
114
  uses: list[tuple[int, str]] = []
92
- signatures: dict[int, tuple[tuple[str, ...], bool]] = {}
115
+ signatures: Signatures = {}
93
116
  for node in ast.walk(tree):
94
117
  if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
95
118
  uses.append((node.lineno, node.id))
@@ -105,6 +128,13 @@ def _with_python_facts(chunks: list[Chunk], tree: ast.Module) -> list[Chunk]:
105
128
  args = node.args
106
129
  names = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)]
107
130
  signatures[_span(node)[0]] = (tuple(n for n in names if n not in ("self", "cls")), args.kwarg is not None)
131
+ return uses, signatures, _IGNORED_NAMES
132
+
133
+
134
+ def _with_facts(
135
+ chunks: list[Chunk], uses: list[tuple[int, str]], signatures: Signatures, ignored: set[str]
136
+ ) -> list[Chunk]:
137
+ """Attach the names each chunk uses, and the parameters of each function chunk."""
108
138
  result = []
109
139
  for chunk in chunks:
110
140
  own = chunk.symbol.rsplit(".", 1)[-1] if chunk.symbol else None
@@ -112,10 +142,7 @@ def _with_python_facts(chunks: list[Chunk], tree: ast.Module) -> list[Chunk]:
112
142
  {
113
143
  name
114
144
  for line, name in uses
115
- if chunk.start_line <= line <= chunk.end_line
116
- and name not in _IGNORED_NAMES
117
- and name != own
118
- and len(name) > 2
145
+ if chunk.start_line <= line <= chunk.end_line and name not in ignored and name != own and len(name) > 1
119
146
  }
120
147
  )
121
148
  signature = signatures.get(chunk.start_line) if chunk.kind in ("function", "method") else None
@@ -151,7 +178,8 @@ def _span(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> tuple[
151
178
 
152
179
  def _markdown_chunks(path: str, lines: list[str]) -> list[Chunk]:
153
180
  chunks: list[Chunk] = []
154
- trail: list[str] = []
181
+ trail: list[tuple[int, str]] = []
182
+ """(level, heading) of each open heading; a new heading closes every one at its level or deeper."""
155
183
  section_start, section_symbol = 1, None
156
184
  in_fence = False
157
185
  for number, line in enumerate(lines, start=1):
@@ -163,8 +191,8 @@ def _markdown_chunks(path: str, lines: list[str]) -> list[Chunk]:
163
191
  if number > section_start:
164
192
  chunks.append(_make(path, lines, section_start, number - 1, "section", section_symbol))
165
193
  level = len(match.group(1))
166
- trail = [*trail[: level - 1], match.group(2)]
167
- section_start, section_symbol = number, " > ".join(trail)
194
+ trail = [*(open_ for open_ in trail if open_[0] < level), (level, match.group(2))]
195
+ section_start, section_symbol = number, " > ".join(heading for _, heading in trail)
168
196
  chunks.append(_make(path, lines, section_start, len(lines), "section", section_symbol))
169
197
  return [c for c in chunks if c.text.strip()]
170
198
 
@@ -0,0 +1,255 @@
1
+ """Find the definitions in JavaScript and TypeScript files with tree-sitter.
2
+
3
+ Returns plain line spans plus the facts ``chunking`` attaches to chunks: which names each line uses
4
+ (calls, properties, types, imports) and each function's parameters. Comment blocks directly above a
5
+ definition belong to it, so a JSDoc comment is searched together with its function.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from functools import cache
12
+ from typing import Literal
13
+
14
+ import tree_sitter_javascript
15
+ import tree_sitter_typescript
16
+ from tree_sitter import Language, Node, Parser
17
+
18
+ Grammar = Literal["javascript", "typescript", "tsx"]
19
+
20
+ FUNCTION_VALUES = {"arrow_function", "function_expression", "function", "generator_function"}
21
+ CLASS_VALUES = {"class", "class_expression"}
22
+ CLASS_DECLARATIONS = {"class_declaration", "abstract_class_declaration"}
23
+ TYPE_DECLARATIONS = {"interface_declaration": "interface", "type_alias_declaration": "type", "enum_declaration": "enum"}
24
+ FUNCTION_DECLARATIONS = {"function_declaration", "generator_function_declaration", "function_signature"}
25
+ METHOD_NODES = {"method_definition", "public_field_definition", "field_definition", "abstract_method_signature"}
26
+ NAME_NODES = {
27
+ "identifier", "type_identifier", "property_identifier", "private_property_identifier",
28
+ "shorthand_property_identifier",
29
+ } # fmt: skip
30
+ DECLARING_PARENTS = {
31
+ *FUNCTION_DECLARATIONS, *CLASS_DECLARATIONS, *TYPE_DECLARATIONS, *METHOD_NODES,
32
+ "variable_declarator", "formal_parameters", "required_parameter", "optional_parameter", "property_signature",
33
+ } # fmt: skip
34
+ """A name directly under one of these (as its name or parameter) is being declared, not used."""
35
+ IGNORED_NAMES = {
36
+ "Array", "Boolean", "Date", "Error", "JSON", "Map", "Math", "Number", "Object", "Promise", "RegExp", "Set",
37
+ "String", "Symbol", "console", "exports", "length", "module", "require", "undefined", "window", "document",
38
+ } # fmt: skip
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Definition:
43
+ start_line: int
44
+ """1-based, inclusive."""
45
+ end_line: int
46
+ """1-based, inclusive."""
47
+ kind: str
48
+ symbol: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Outline:
53
+ definitions: list[Definition]
54
+ covered: list[tuple[int, int]]
55
+ """Line spans owned by top-level definitions; the rest of the file becomes module chunks."""
56
+ uses: list[tuple[int, str]]
57
+ signatures: dict[int, tuple[tuple[str, ...], bool]]
58
+ """Definition start line -> (parameter names, takes a rest parameter)."""
59
+
60
+
61
+ def grammar_for(path: str, language: str) -> Grammar:
62
+ if language == "typescript":
63
+ return "tsx" if path.endswith(".tsx") else "typescript"
64
+ return "javascript" # this grammar also understands JSX
65
+
66
+
67
+ @cache
68
+ def _parser(grammar: Grammar) -> Parser:
69
+ if grammar == "javascript":
70
+ handle = tree_sitter_javascript.language()
71
+ elif grammar == "tsx":
72
+ handle = tree_sitter_typescript.language_tsx()
73
+ else:
74
+ handle = tree_sitter_typescript.language_typescript()
75
+ return Parser(Language(handle))
76
+
77
+
78
+ def outline(path: str, language: str, text: str, max_class_lines: int) -> Outline | None:
79
+ """The file's definitions, or None when it doesn't parse (the caller then falls back to windows)."""
80
+ tree = _parser(grammar_for(path, language)).parse(text.encode())
81
+ root = tree.root_node
82
+ if root.has_error:
83
+ return None
84
+ collector = _Collector(max_class_lines)
85
+ for statement in root.named_children:
86
+ collector.top_level(statement)
87
+ return Outline(
88
+ definitions=collector.definitions,
89
+ covered=collector.covered,
90
+ uses=_uses(root),
91
+ signatures=collector.signatures,
92
+ )
93
+
94
+
95
+ class _Collector:
96
+ def __init__(self, max_class_lines: int) -> None:
97
+ self.max_class_lines = max_class_lines
98
+ self.definitions: list[Definition] = []
99
+ self.covered: list[tuple[int, int]] = []
100
+ self.signatures: dict[int, tuple[tuple[str, ...], bool]] = {}
101
+
102
+ def top_level(self, statement: Node) -> None:
103
+ found = _declared(statement)
104
+ if found is None:
105
+ return
106
+ kind, name, node = found
107
+ start, end = _with_leading_comments(statement), statement.end_point.row + 1
108
+ self.covered.append((start, end))
109
+ if kind == "class":
110
+ self._class(node, name, start, end)
111
+ return
112
+ self.definitions.append(Definition(start, end, kind, name))
113
+ if kind == "function":
114
+ self.signatures[start] = _parameters(node)
115
+
116
+ def _class(self, node: Node, name: str, start: int, end: int) -> None:
117
+ body = node.child_by_field_name("body")
118
+ methods = [m for m in body.named_children if m.type in METHOD_NODES and _is_function(m)] if body else []
119
+ if end - start + 1 <= self.max_class_lines or not methods:
120
+ self.definitions.append(Definition(start, end, "class", name))
121
+ return
122
+ # A big class: its header (up to the first method) plus one chunk per method, like Python.
123
+ first = _with_leading_comments(methods[0])
124
+ self.definitions.append(Definition(start, max(start, first - 1), "class", name))
125
+ for method in methods:
126
+ m_start = _with_leading_comments(method)
127
+ symbol = f"{name}.{_text(method.child_by_field_name('name')) or 'anonymous'}"
128
+ self.definitions.append(Definition(m_start, method.end_point.row + 1, "method", symbol))
129
+ self.signatures[m_start] = _parameters(_function_of(method))
130
+
131
+
132
+ def _declared(statement: Node) -> tuple[str, str, Node] | None:
133
+ """(kind, name, node) when a top-level statement defines one named thing."""
134
+ if statement.type == "export_statement":
135
+ inner = statement.child_by_field_name("declaration") or statement.child_by_field_name("value")
136
+ if inner is None:
137
+ return None
138
+ if inner.type in FUNCTION_VALUES:
139
+ return "function", "default", inner # export default function () {}
140
+ if inner.type in CLASS_VALUES:
141
+ return "class", "default", inner
142
+ statement = inner
143
+ kind = statement.type
144
+ name = _text(statement.child_by_field_name("name"))
145
+ if kind in FUNCTION_DECLARATIONS and name:
146
+ return "function", name, statement
147
+ if kind in CLASS_DECLARATIONS and name:
148
+ return "class", name, statement
149
+ if kind in TYPE_DECLARATIONS and name:
150
+ return TYPE_DECLARATIONS[kind], name, statement
151
+ if kind in ("lexical_declaration", "variable_declaration"):
152
+ return _bound_value(statement)
153
+ if kind == "expression_statement":
154
+ return _assigned_value(statement)
155
+ return None
156
+
157
+
158
+ def _bound_value(statement: Node) -> tuple[str, str, Node] | None:
159
+ """``const name = () => ...`` or ``const Name = class {...}``, when it is the only binding."""
160
+ declarators = [d for d in statement.named_children if d.type == "variable_declarator"]
161
+ if len(declarators) != 1:
162
+ return None
163
+ return _named_value(declarators[0].child_by_field_name("name"), declarators[0].child_by_field_name("value"))
164
+
165
+
166
+ def _assigned_value(statement: Node) -> tuple[str, str, Node] | None:
167
+ """CommonJS and prototype style: ``module.exports.name = function () {...}``."""
168
+ expression = statement.named_children[0] if statement.named_children else None
169
+ if expression is None or expression.type != "assignment_expression":
170
+ return None
171
+ left = expression.child_by_field_name("left")
172
+ if left is not None and left.type == "member_expression":
173
+ left = left.child_by_field_name("property")
174
+ return _named_value(left, expression.child_by_field_name("right"))
175
+
176
+
177
+ def _named_value(name_node: Node | None, value: Node | None) -> tuple[str, str, Node] | None:
178
+ name = _text(name_node)
179
+ if not name or value is None or name_node is None or name_node.type not in NAME_NODES:
180
+ return None
181
+ if value.type in FUNCTION_VALUES:
182
+ return "function", name, value
183
+ if value.type in CLASS_VALUES:
184
+ return "class", name, value
185
+ return None
186
+
187
+
188
+ def _is_function(member: Node) -> bool:
189
+ return member.type in ("method_definition", "abstract_method_signature") or _function_of(member) is not member
190
+
191
+
192
+ def _function_of(member: Node) -> Node:
193
+ """The function node holding a class member's parameters (a field may hold an arrow function)."""
194
+ value = member.child_by_field_name("value")
195
+ return value if value is not None and value.type in FUNCTION_VALUES else member
196
+
197
+
198
+ def _parameters(function: Node) -> tuple[tuple[str, ...], bool]:
199
+ single = function.child_by_field_name("parameter") # x => x
200
+ if single is not None:
201
+ return (_text(single),), False
202
+ params = function.child_by_field_name("parameters")
203
+ names: list[str] = []
204
+ takes_rest = False
205
+ for param in params.named_children if params else []:
206
+ pattern = param.child_by_field_name("pattern") or param
207
+ if pattern.type == "assignment_pattern":
208
+ pattern = pattern.child_by_field_name("left") or pattern
209
+ if pattern.type == "rest_pattern":
210
+ takes_rest = True
211
+ pattern = pattern.named_children[0] if pattern.named_children else pattern
212
+ if pattern.type == "identifier":
213
+ names.append(_text(pattern))
214
+ return tuple(names), takes_rest
215
+
216
+
217
+ def _with_leading_comments(node: Node) -> int:
218
+ """The 1-based start line, moved up over comments that sit directly above the node."""
219
+ start = node.start_point.row
220
+ previous = node.prev_named_sibling
221
+ while previous is not None and previous.type == "comment" and previous.end_point.row >= start - 1:
222
+ start = previous.start_point.row
223
+ previous = previous.prev_named_sibling
224
+ return start + 1
225
+
226
+
227
+ def _uses(root: Node) -> list[tuple[int, str]]:
228
+ """Every name the file mentions, by line: identifiers, properties, types and imported names.
229
+
230
+ Walks with a cursor, so a big file doesn't create a Python object for every node at once.
231
+ """
232
+ uses: list[tuple[int, str]] = []
233
+ cursor = root.walk()
234
+ while True:
235
+ node = cursor.node
236
+ if node is not None and node.type in NAME_NODES and not _is_declared(node, cursor.field_name):
237
+ name = _text(node)
238
+ if name not in IGNORED_NAMES:
239
+ uses.append((node.start_point.row + 1, name))
240
+ if cursor.goto_first_child():
241
+ continue
242
+ while not cursor.goto_next_sibling():
243
+ if not cursor.goto_parent():
244
+ return uses
245
+
246
+
247
+ def _is_declared(node: Node, field: str | None) -> bool:
248
+ parent = node.parent
249
+ if parent is None or parent.type not in DECLARING_PARENTS:
250
+ return False
251
+ return field in ("name", "pattern") or parent.type == "formal_parameters"
252
+
253
+
254
+ def _text(node: Node | None) -> str:
255
+ return node.text.decode(errors="replace") if node is not None and node.text is not None else ""
@@ -19,6 +19,7 @@ from pathlib import Path
19
19
 
20
20
  from . import sources
21
21
  from .brain import SEARCH_MODES, Brain, SearchMode
22
+ from .chunking import DEFINITION_KINDS
22
23
  from .store import StoredChunk
23
24
 
24
25
  CUTOFFS = (1, 3, 5, 10)
@@ -42,7 +43,9 @@ class Target:
42
43
  return False
43
44
  if self.symbol is None:
44
45
  return True
45
- return chunk.symbol == self.symbol or (chunk.symbol or "").startswith(f"{self.symbol}.")
46
+ # A method belongs to its class ("Signer.sign"), a subsection to its section ("API > retry").
47
+ symbol = chunk.symbol or ""
48
+ return symbol == self.symbol or symbol.startswith((f"{self.symbol}.", f"{self.symbol} > "))
46
49
 
47
50
 
48
51
  @dataclass(frozen=True)
@@ -151,7 +154,7 @@ def validate_targets(brain: Brain, repo: str, dataset: Dataset) -> None:
151
154
  """Fail loudly if the dataset names files or symbols that aren't in the index (dataset rot)."""
152
155
  store = brain.store_for(repo)
153
156
  files = store.file_paths()
154
- symbols = set(store.symbols())
157
+ symbols = set(store.symbols((*DEFINITION_KINDS, "section")))
155
158
  missing = []
156
159
  for query in dataset.queries:
157
160
  for target in query.targets:
@@ -187,7 +187,7 @@ def build_server(brain: Brain | None = None) -> MCPServer:
187
187
  query: str, repo: str | None = None, k: int = 8, path_prefix: str | None = None, kinds: list[str] | None = None
188
188
  ) -> SearchResult:
189
189
  """Hybrid search (BM25 keywords + embeddings, fused by rank). Filter by `path_prefix`
190
- (e.g. "src/auth/") or `kinds` (function, class, method, module, section, code)."""
190
+ (e.g. "src/auth/") or `kinds` (function, class, method, interface, type, enum, module, section, code)."""
191
191
  hits = await _search(brain, query, repo, k, path_prefix, kinds)
192
192
  return SearchResult(repo=brain.resolve_repo(repo), query=query, results=[_source(h) for h in hits])
193
193
 
@@ -232,7 +232,7 @@ def build_server(brain: Brain | None = None) -> MCPServer:
232
232
  symbol: str, repo: str | None = None, path: str | None = None, depth: int = 2
233
233
  ) -> ImpactResult:
234
234
  """What breaks if `symbol` changes: code that uses it directly, code that uses that code
235
- (up to `depth`, max 3), and the tests involved. Python is analysed structurally."""
235
+ (up to `depth`, max 3), and the tests involved. Python, JavaScript and TypeScript are analysed structurally."""
236
236
  report = await _call(lambda: brain.impact_of_change(repo, symbol, path, depth))
237
237
  return _impact(report)
238
238
 
@@ -6,12 +6,13 @@ import json
6
6
  import re
7
7
  import sqlite3
8
8
  import threading
9
+ from collections.abc import Sequence
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
 
12
13
  import numpy as np
13
14
 
14
- from .chunking import Chunk
15
+ from .chunking import DEFINITION_KINDS, Chunk
15
16
  from .history import Commit, PullRequest
16
17
 
17
18
  SCHEMA = """
@@ -46,7 +47,7 @@ CREATE TABLE IF NOT EXISTS commits (
46
47
  CREATE VIRTUAL TABLE IF NOT EXISTS commits_fts USING fts5 (subject, body, sha UNINDEXED, tokenize = 'porter unicode61');
47
48
  CREATE TABLE IF NOT EXISTS pull_requests (number INTEGER PRIMARY KEY, data TEXT NOT NULL);
48
49
  """
49
- SCHEMA_VERSION = "3"
50
+ SCHEMA_VERSION = "4"
50
51
  """Bump when the index layout changes; older indexes are rebuilt automatically."""
51
52
  _WORD = re.compile(r"[A-Za-z0-9_]+")
52
53
  _CAMEL = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+")
@@ -221,19 +222,22 @@ class Store:
221
222
  )
222
223
  return [StoredChunk(**dict(row)) for row in rows]
223
224
 
224
- def symbols(self) -> list[tuple[str, str]]:
225
- """Every defined (symbol, path) pair."""
225
+ def symbols(self, kinds: Sequence[str] = DEFINITION_KINDS) -> list[tuple[str, str]]:
226
+ """Every (symbol, path) pair of the given chunk kinds; by default, everything code defines."""
227
+ marks = ", ".join("?" * len(kinds))
226
228
  rows = self._db.execute(
227
- "SELECT DISTINCT symbol, path FROM chunks WHERE symbol IS NOT NULL"
228
- " AND kind IN ('function', 'class', 'method')"
229
+ f"SELECT DISTINCT symbol, path FROM chunks WHERE symbol IS NOT NULL AND kind IN ({marks})", # noqa: S608 (only "?" placeholders)
230
+ tuple(kinds),
229
231
  )
230
232
  return [(row["symbol"], row["path"]) for row in rows]
231
233
 
232
234
  def signatures(self, short_name: str) -> list[tuple[str, str, list[str], bool]]:
233
- """(symbol, path, params, accepts_any_keyword) for functions named ``short_name``."""
235
+ """(symbol, path, params, accepts_any_keyword) for Python functions named ``short_name``.
236
+
237
+ Python only, because the docs' code examples that drift checks against them are Python."""
234
238
  rows = self._db.execute(
235
- "SELECT symbol, path, params, accepts_any_keyword FROM chunks WHERE params IS NOT NULL"
236
- " AND (symbol = ? OR symbol GLOB ?)",
239
+ "SELECT c.symbol, c.path, c.params, c.accepts_any_keyword FROM chunks c JOIN files f ON f.path = c.path"
240
+ " WHERE c.params IS NOT NULL AND f.language = 'python' AND (c.symbol = ? OR c.symbol GLOB ?)",
237
241
  (short_name, f"*.{_glob_literal(short_name)}"),
238
242
  )
239
243
  return [(r["symbol"], r["path"], json.loads(r["params"]), bool(r["accepts_any_keyword"])) for r in rows]