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.
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/PKG-INFO +54 -12
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/README.md +50 -11
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/pyproject.toml +4 -1
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/pyproject.toml.orig +4 -1
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/analysis.py +18 -6
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/chunking.py +46 -18
- codebase_brain_mcp-0.2.0/src/codebase_brain_mcp/chunking_js.py +255 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/evals.py +5 -2
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/server.py +2 -2
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/store.py +13 -9
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/LICENSE +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/__init__.py +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/__main__.py +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/brain.py +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/config.py +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/embeddings.py +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/history.py +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/py.typed +0 -0
- {codebase_brain_mcp-0.1.0 → codebase_brain_mcp-0.2.0}/src/codebase_brain_mcp/remote.py +0 -0
- {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.
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
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.
|
|
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.
|
|
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 = [
|
|
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
|
|
101
|
-
next_frontier.append(chunk.symbol
|
|
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(
|
|
118
|
-
notes.append("Only Python
|
|
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``),
|
|
4
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
result.append(_make(path, lines, gap_start, gap_end, "module", None))
|
|
85
108
|
cursor = max(cursor, end + 1)
|
|
86
|
-
return sorted(
|
|
109
|
+
return sorted(result, key=lambda c: c.start_line)
|
|
87
110
|
|
|
88
111
|
|
|
89
|
-
def
|
|
90
|
-
"""
|
|
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:
|
|
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[
|
|
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
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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
|
-
|
|
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
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|