mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into worktree-php-lsp-integration
Resolved 5 conflicts where the php-lsp branch and main both added LSP
infrastructure but with different APIs:
- Makefile.cbm: kept both TEST_PHP_LSP_SRCS and main's py_lsp/scope/
type_rep/stack_overflow test source variables in ALL_TEST_SRCS.
- internal/cbm/cbm.c: kept both #include lsp/php_lsp.h and lsp/py_lsp.h;
the per-language dispatch already supports stacking PHP after PYTHON.
- internal/cbm/lsp_all.c: kept both lsp/php_lsp.c + php_stdlib_data.c
AND lsp/py_lsp.c + python_stdlib_data.c in the compilation unit.
- src/pipeline/pass_calls.c: took main's version entirely.
find_lsp_resolution + resolve_single_call(ctx, call, lsp_calls, ...)
is language-agnostic — it matches any CBMResolvedCall whose caller_qn
hits the textual call's enclosing function and whose callee_qn
short-name hits the textual callee. PHP LSP populates the same
result->resolved_calls array via cbm_run_php_lsp, so the unified
resolver handles PHP for free. Dropped the php-branch's
qn_short_name + lsp_override_resolution helpers as superseded.
Locked confidence floor at main's CBM_LSP_CONFIDENCE_FLOOR = 0.6f
(was 0.5f on the php branch).
- tests/test_main.c: kept all suite externs and RUN_SUITE invocations
for php_lsp + py_lsp/py_lsp_bench/py_lsp_stress/py_lsp_scale +
scope + type_rep.
Auto-merged: src/pipeline/pass_parallel.c.
Brings in 52 commits from main since the branch was forked at c768b61
(#207 watcher follow-up): #208 through #281, dependabot bumps, Pine
Script, AUR docs, INHERITS edges, get_architecture wiring, codeql
revert, the entire python-lsp subsystem.
Suite: 3249 passed, 0 failed (was 2971 on main; +278 new tests from
php-lsp matches the branch's claimed test count).
This commit is contained in:
commit
e6f4aa74bd
81 files changed
+83139
-838
No files matched your search
@@ -47,25 +47,46 @@ jobs:
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build standard binary
|
||||
run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
CC: ${{ matrix.cc }}
|
||||
CXX: ${{ matrix.cxx }}
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
scripts/build.sh --version "$VERSION" "CC=$CC" "CXX=$CXX"
|
||||
else
|
||||
scripts/build.sh "CC=$CC" "CXX=$CXX"
|
||||
fi
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
run: codesign --sign - --force build/c/codebase-memory-mcp
|
||||
|
||||
- name: Archive standard binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
cp LICENSE install.sh build/c/
|
||||
tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
|
||||
tar -czf "codebase-memory-mcp-${GOOS}-${GOARCH}.tar.gz" \
|
||||
-C build/c codebase-memory-mcp LICENSE install.sh
|
||||
|
||||
- name: Build UI binary
|
||||
run: scripts/build.sh --with-ui ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
CC: ${{ matrix.cc }}
|
||||
CXX: ${{ matrix.cxx }}
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
scripts/build.sh --with-ui --version "$VERSION" "CC=$CC" "CXX=$CXX"
|
||||
else
|
||||
scripts/build.sh --with-ui "CC=$CC" "CXX=$CXX"
|
||||
fi
|
||||
|
||||
- name: Ad-hoc sign macOS UI binary
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
@@ -76,9 +97,12 @@ jobs:
|
||||
run: scripts/security-ui.sh
|
||||
|
||||
- name: Archive UI binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
cp LICENSE install.sh build/c/
|
||||
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
|
||||
tar -czf "codebase-memory-mcp-ui-${GOOS}-${GOARCH}.tar.gz" \
|
||||
-C build/c codebase-memory-mcp LICENSE install.sh
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -92,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: msys2/setup-msys2@cafece8e6baf9247cf9b1bf95097b0b983cc558d # v2
|
||||
- uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -102,13 +126,20 @@ jobs:
|
||||
make
|
||||
zip
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build standard binary
|
||||
shell: msys2 {0}
|
||||
run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
scripts/build.sh --version "$VERSION" CC=clang CXX=clang++
|
||||
else
|
||||
scripts/build.sh CC=clang CXX=clang++
|
||||
fi
|
||||
|
||||
- name: Archive standard binary
|
||||
shell: msys2 {0}
|
||||
@@ -120,7 +151,14 @@ jobs:
|
||||
|
||||
- name: Build UI binary
|
||||
shell: msys2 {0}
|
||||
run: scripts/build.sh --with-ui ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
scripts/build.sh --with-ui --version "$VERSION" CC=clang CXX=clang++
|
||||
else
|
||||
scripts/build.sh --with-ui CC=clang CXX=clang++
|
||||
fi
|
||||
|
||||
- name: Archive UI binary
|
||||
shell: msys2 {0}
|
||||
@@ -154,12 +192,19 @@ jobs:
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build standard binary (static)
|
||||
run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=gcc CXX=g++ STATIC=1
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
scripts/build.sh --version "$VERSION" CC=gcc CXX=g++ STATIC=1
|
||||
else
|
||||
scripts/build.sh CC=gcc CXX=g++ STATIC=1
|
||||
fi
|
||||
|
||||
- name: Verify static linking
|
||||
run: |
|
||||
@@ -167,18 +212,29 @@ jobs:
|
||||
ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked"
|
||||
|
||||
- name: Archive standard binary
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
cp LICENSE install.sh build/c/
|
||||
tar -czf codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz \
|
||||
tar -czf "codebase-memory-mcp-linux-${ARCH}-portable.tar.gz" \
|
||||
-C build/c codebase-memory-mcp LICENSE install.sh
|
||||
|
||||
- name: Build UI binary (static)
|
||||
run: scripts/build.sh --with-ui ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=gcc CXX=g++ STATIC=1
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
scripts/build.sh --with-ui --version "$VERSION" CC=gcc CXX=g++ STATIC=1
|
||||
else
|
||||
scripts/build.sh --with-ui CC=gcc CXX=g++ STATIC=1
|
||||
fi
|
||||
|
||||
- name: Archive UI binary
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
cp LICENSE install.sh build/c/
|
||||
tar -czf codebase-memory-mcp-ui-linux-${{ matrix.arch }}-portable.tar.gz \
|
||||
tar -czf "codebase-memory-mcp-ui-linux-${ARCH}-portable.tar.gz" \
|
||||
-C build/c codebase-memory-mcp LICENSE install.sh
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang-format-20
|
||||
|
||||
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cppcheck-cache
|
||||
with:
|
||||
path: /opt/cppcheck
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: msys2/setup-msys2@cafece8e6baf9247cf9b1bf95097b0b983cc558d # v2
|
||||
- uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: msys2/setup-msys2@cafece8e6baf9247cf9b1bf95097b0b983cc558d # v2
|
||||
- uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: msys2/setup-msys2@cafece8e6baf9247cf9b1bf95097b0b983cc558d # v2
|
||||
- uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: msys2/setup-msys2@cafece8e6baf9247cf9b1bf95097b0b983cc558d # v2
|
||||
- uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: c-cpp
|
||||
build-mode: manual
|
||||
@@ -27,6 +27,6 @@ jobs:
|
||||
run: scripts/build.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/language:c-cpp"
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
git tag -f "$VERSION"
|
||||
git push origin "$VERSION" --force
|
||||
|
||||
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
- uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
with:
|
||||
tag_name: ${{ inputs.version }}
|
||||
draft: true
|
||||
@@ -275,7 +275,7 @@ jobs:
|
||||
|
||||
# ── npm ──────────────────────────────────────────────────
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v6.4.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
@@ -27,6 +27,6 @@ jobs:
|
||||
publish_results: true
|
||||
|
||||
- name: Upload SARIF results
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
+17
-2
@@ -171,6 +171,7 @@ GRAPH_BUFFER_SRCS = src/graph_buffer/graph_buffer.c
|
||||
# Pipeline module (new)
|
||||
PIPELINE_SRCS = \
|
||||
src/pipeline/fqn.c \
|
||||
src/pipeline/path_alias.c \
|
||||
src/pipeline/registry.c \
|
||||
src/pipeline/pipeline.c \
|
||||
src/pipeline/pipeline_incremental.c \
|
||||
@@ -295,7 +296,7 @@ TEST_DISCOVER_SRCS = \
|
||||
|
||||
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
|
||||
|
||||
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c
|
||||
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c
|
||||
|
||||
TEST_WATCHER_SRCS = tests/test_watcher.c
|
||||
|
||||
@@ -311,6 +312,18 @@ TEST_C_LSP_SRCS = tests/test_c_lsp.c
|
||||
|
||||
TEST_PHP_LSP_SRCS = tests/test_php_lsp.c
|
||||
|
||||
TEST_SCOPE_SRCS = tests/test_scope.c
|
||||
|
||||
TEST_TYPE_REP_SRCS = tests/test_type_rep.c
|
||||
|
||||
TEST_PY_LSP_SRCS = tests/test_py_lsp.c
|
||||
|
||||
TEST_PY_LSP_BENCH_SRCS = tests/test_py_lsp_bench.c
|
||||
|
||||
TEST_PY_LSP_STRESS_SRCS = tests/test_py_lsp_stress.c
|
||||
|
||||
TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c
|
||||
|
||||
TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c
|
||||
|
||||
TEST_TRACES_SRCS = tests/test_traces.c
|
||||
@@ -328,7 +341,9 @@ TEST_YAML_SRCS = tests/test_yaml.c
|
||||
|
||||
TEST_SIMHASH_SRCS = tests/test_simhash.c
|
||||
|
||||
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
TEST_STACK_OVERFLOW_SRCS = tests/test_stack_overflow.c
|
||||
|
||||
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
|
||||
|
||||
# ── Build directories ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# Python LSP integration plan
|
||||
|
||||
Mirrors the existing `internal/cbm/lsp/{go_lsp,c_lsp}.{c,h}` pattern: vendor a reference LSP server's source, port the type-resolution algorithms into a self-contained C module driven by tree-sitter, and grow the suite test-first.
|
||||
|
||||
## 1. Reference vendoring
|
||||
|
||||
Cloned shallow into `/tmp/python-lsp-references/` (read-only, not committed). **Per challenger, pin to specific commit SHAs before mining — `main depth=1` shifts under you within days. Document the SHAs in this file and in the generated stdlib data header.**
|
||||
|
||||
| Source | Pin (record SHA before mining) | Size | Role |
|
||||
|---|---|---|---|
|
||||
| [microsoft/pyright](https://github.com/microsoft/pyright) | commit `53969cf1e39498f097437b4ee92cc2fad00cf641` (2026-05-08) | 52 MB | Primary algorithmic reference. TypeScript, MIT. Releases ~weekly. Mining `main` is unstable. |
|
||||
| [python/typeshed](https://github.com/python/typeshed) | commit `a7912d521e16ff63caf7a8b64b9072542be36777` (2026-05-08) | 33 MB (stdlib 5.1 MB, 739 `.pyi` files) | Stdlib stub source for `gen-py-stdlib.py`. |
|
||||
| [davidhalter/jedi](https://github.com/davidhalter/jedi) | commit `3102215478fe07b965dcd8221c17436d1dd7e8ac` (2026-05-08, master). For dev-time oracle pin to PyPI `jedi==0.19.2` — **do NOT use 0.20.0** (dropped 3.9 support). | 3.2 MB | Secondary reference + dev-time oracle (subprocess `jedi.Script(src).goto(line, col)` for test-corpus validation). |
|
||||
| [typeshed-client](https://pypi.org/project/typeshed-client/) | `2.8.2` (PyPI) | — | Parser layer for `gen-py-stdlib.py`. **Use this instead of raw `ast`** — handles version-guard expansion, overload stacks, ParamSpec/TypeVarTuple noise filtering. Versioned and maintained. |
|
||||
| [DetachHead/basedpyright](https://github.com/DetachHead/basedpyright) | (consider as v2 alternative) | — | Pyright fork on PyPI without Node.js, biweekly rebase, more granular UNKNOWN-vs-Any classification. Same algorithmic surface as upstream Pyright. |
|
||||
| [facebook/pyrefly](https://github.com/facebook/pyrefly) | (re-evaluate after v1) | — | Rust, MIT, open-sourced May 2025. Closer in spirit to a C reimplementation. Currently active alpha — re-assess as reference for v2. |
|
||||
|
||||
Key Pyright files we will mine (sizes confirmed from clone):
|
||||
|
||||
| File | LOC | Maps to |
|
||||
|---|---:|---|
|
||||
| `analyzer/binder.ts` | 4,723 | scope construction + symbol-table walk |
|
||||
| `analyzer/typeEvaluator.ts` | 28,955 | expression typing (`getTypeOfExpression`, `getTypeOfMemberAccess`, `getTypeOfCall`) |
|
||||
| `analyzer/typeUtils.ts` | 4,511 | type ops: union normalize, narrow, substitute |
|
||||
| `analyzer/types.ts` | 3,999 | `TypeCategory` enum (`Unbound`, `Unknown`, `Any`, `Never`, `Function`, `Overloaded`, `Class`, `Module`, `Union`, `TypeVar`) + flags |
|
||||
| `analyzer/importResolver.ts` | 2,539 | package + relative + namespace import resolution |
|
||||
| `analyzer/parseTreeUtils.ts` | 2,747 | AST helpers (most tree-sitter equivalents already exist) |
|
||||
| `analyzer/declaration.ts` | 300 | declaration kinds (Intrinsic, Variable, Param, Class, Function, …) |
|
||||
| `analyzer/scope.ts` | 267 | `ScopeType` enum (`TypeParameter`, `Comprehension`, `Function`, `Class`, `Module`, `Builtin`) |
|
||||
| `analyzer/decorators.ts` | 606 | `@dataclass`, `@property`, `@classmethod`, … |
|
||||
| `analyzer/dataClasses.ts` | 1,597 | dataclass `__init__` synthesis |
|
||||
| `analyzer/protocols.ts` | 880 | structural protocol matching |
|
||||
| `analyzer/patternMatching.ts` | 2,253 | PEP 634 `match`/`case` capture binding |
|
||||
| `analyzer/enums.ts` | 750 | `Enum` value typing |
|
||||
| `analyzer/namedTuples.ts` | 513 | `NamedTuple` synthesis |
|
||||
| `analyzer/checker.ts` | 7,633 | not ported — diagnostics only, irrelevant for call resolution |
|
||||
| `parser/*.ts` | — | not ported — we use tree-sitter Python (already vendored at `internal/cbm/vendored/grammars/python/`) |
|
||||
|
||||
We will not ship the references; we mine them during development.
|
||||
|
||||
## 2. Existing architecture (fixed reference points)
|
||||
|
||||
| Component | Path | Role |
|
||||
|---|---|---|
|
||||
| Type representation | `internal/cbm/lsp/type_rep.{c,h}` | `CBMType` tagged union (NAMED, POINTER, SLICE, MAP, CHANNEL, FUNC, INTERFACE, STRUCT, BUILTIN, TUPLE, TYPE_PARAM, REFERENCE, RVALUE_REF, TEMPLATE, ALIAS) |
|
||||
| Type/function registry | `internal/cbm/lsp/type_registry.{c,h}` | `CBMRegisteredFunc`, `CBMRegisteredType`; lookup by qn / receiver / args / types; alias chain following |
|
||||
| Lexical scope | `internal/cbm/lsp/scope.{c,h}` | `CBMScope` chain with `CBM_SCOPE_MAX_BINDINGS=64` per frame |
|
||||
| Go LSP entry | `internal/cbm/lsp/go_lsp.{c,h}` | `cbm_run_go_lsp` (single), `_cross`, `_batch_cross`; `cbm_go_stdlib_register` |
|
||||
| C/C++ LSP entry | `internal/cbm/lsp/c_lsp.{c,h}` | `cbm_run_c_lsp` (single), `_cross`, `_batch_cross`; `cbm_c_stdlib_register`, `cbm_cpp_stdlib_register` |
|
||||
| Generated stdlib data | `internal/cbm/lsp/generated/{c,cpp,go}_stdlib_data.c` | Auto-generated; go_stdlib is 30,630 lines (2,328 fns, 321 types, 34 packages) |
|
||||
| CGo umbrella | `internal/cbm/lsp_all.c` | `#include`s every lsp/*.c (CGo only compiles top-level package files) |
|
||||
| Single-file wiring | `internal/cbm/cbm.c::cbm_extract_file` (lines 354–360, 409–410) | Calls `cbm_run_go_lsp` / `cbm_run_c_lsp` after unified extraction |
|
||||
| Output sink | `CBMFileResult.resolved_calls` (`internal/cbm/cbm.h:416`) | LSP-resolved calls with qn / strategy / confidence — populated but not yet read by `pass_calls.c` |
|
||||
| Test suites | `tests/test_go_lsp.c` (1,189 LOC, 49 tests), `tests/test_c_lsp.c` (15,816 LOC) | TDD pattern: `extract_X(source)` helper, `find_resolved`/`require_resolved` assertions, per-category sections, single `SUITE(go_lsp)` / `SUITE(c_lsp)` |
|
||||
| Make wiring | `Makefile.cbm:308–310, 329` | `TEST_GO_LSP_SRCS`, `TEST_C_LSP_SRCS` added to `ALL_TEST_SRCS` |
|
||||
| Test runner | `tests/test_main.c:42–43, 115–116` | `extern void suite_go_lsp(void);` + `RUN_SUITE(go_lsp)` |
|
||||
| Python today | `internal/cbm/grammar_python.c` (3 lines, just bridges tree-sitter), `lang_specs.c:191–203` (node-type lists), `extract_imports.c:216–233` (`parse_python_imports`), `extract_defs.c:669, 707, 2558, 2989` (Python def metadata) | Generic extraction works; **no `py_lsp` module, no Python stdlib data, no type-aware call resolution** |
|
||||
|
||||
## 3. Type-rep extensions for Python
|
||||
|
||||
Python adds shapes Go and C++ don't have. Add to `type_rep.{c,h}`:
|
||||
|
||||
| New kind | Pyright analog | Why we need it |
|
||||
|---|---|---|
|
||||
| `CBM_TYPE_UNION` | `TypeCategory.Union` | `A | B`, `Optional[T]`, `Union[A, B]`. Needed for typeshed signatures — most stdlib functions return unions. Stored as sorted-canonical list with structural equality. |
|
||||
| `CBM_TYPE_LITERAL` | (LiteralValue inside Class) | `Literal["foo", 3]`. Used heavily by typeshed and by overload selection. |
|
||||
| `CBM_TYPE_PROTOCOL` | (ClassType + protocol flag) | `typing.Protocol`. Structural, not nominal. Largely overlaps with INTERFACE but matched structurally. |
|
||||
| `CBM_TYPE_MODULE` | `TypeCategory.Module` | `import os` then `os.path.join` — module-attribute lookup distinct from class-attribute lookup. |
|
||||
| `CBM_TYPE_CALLABLE` | (Function with no signature) | `Callable[[A, B], R]` and untyped callables; FUNC requires named params we may not have. |
|
||||
|
||||
Reuse existing kinds: `BUILTIN` for `int`/`str`/`bytes`/`bool`/`float`/`complex`/`None`/`bytearray`; `TUPLE` for `tuple[A, B]`; `TEMPLATE` for `list[T]`/`dict[K, V]`/`set[T]`; `TYPE_PARAM` for TypeVars; `ALIAS` for `type X = Y` and `X: TypeAlias = Y`; PEP 695 `class Foo[T]:` syntax populates the existing `type_param_names` field on `CBMRegisteredType`.
|
||||
|
||||
Tests: `test_pylsp_type_rep` — round-trip construction, union normalization (`A | A → A`, `A | None` flattening), literal equality, protocol method-set matching, alias resolution with cycle guard.
|
||||
|
||||
## 4. New files
|
||||
|
||||
```
|
||||
internal/cbm/lsp/py_lsp.h # ~150 LOC, header
|
||||
internal/cbm/lsp/py_lsp.c # ~3,000–5,000 LOC eventually
|
||||
internal/cbm/lsp/generated/python_stdlib_data.c # auto-generated, ~10–20K LOC
|
||||
scripts/gen-py-stdlib.py # generator using `ast` module on typeshed/stdlib
|
||||
tests/test_py_lsp.c # ~1,500–3,000 LOC, paralleling test_go_lsp.c
|
||||
```
|
||||
|
||||
Edits:
|
||||
|
||||
```
|
||||
internal/cbm/lsp_all.c # +#include "lsp/py_lsp.c" and generated/python_stdlib_data.c
|
||||
internal/cbm/cbm.c # +if (language == CBM_LANG_PYTHON) cbm_run_py_lsp(...);
|
||||
internal/cbm/lsp/type_rep.{c,h} # add UNION, LITERAL, PROTOCOL, MODULE, CALLABLE kinds
|
||||
Makefile.cbm # +TEST_PY_LSP_SRCS = tests/test_py_lsp.c, add to ALL_TEST_SRCS
|
||||
tests/test_main.c # +extern void suite_py_lsp(void); +RUN_SUITE(py_lsp);
|
||||
THIRD_PARTY.md # add Pyright + typeshed + Jedi attribution (referenced during dev only)
|
||||
```
|
||||
|
||||
## 5. Phased TDD slices
|
||||
|
||||
Each phase is its own PR-sized increment. No phase merges with red tests.
|
||||
|
||||
### Phase 0 — pre-flight fixes (NEW, per challenger; locked decisions)
|
||||
|
||||
Before any py_lsp code is written, fix two issues in shared infrastructure that are silent bugs for Python:
|
||||
|
||||
- **Convert `CBMScope` to dynamic linked-frame allocation** (per §7 decision). Replace `bindings[CBM_SCOPE_MAX_BINDINGS]` fixed array with chunked linked list:
|
||||
```c
|
||||
typedef struct CBMScopeChunk {
|
||||
CBMVarBinding bindings[16];
|
||||
int used;
|
||||
struct CBMScopeChunk* next;
|
||||
} CBMScopeChunk;
|
||||
typedef struct CBMScope {
|
||||
struct CBMScope* parent;
|
||||
CBMScopeChunk* chunks; // first chunk; new chunks prepended
|
||||
} CBMScope;
|
||||
```
|
||||
`cbm_scope_bind` appends to current chunk, allocates new chunk when `used == 16`. `cbm_scope_lookup` walks chunks then parent. Existing API preserved. **Cross-language regression run required**: `test_go_lsp` + `test_c_lsp` must stay green with no test changes.
|
||||
- **Add a recursion-depth cap (depth=16) to MRO/method-lookup walks** mirroring the existing depth-16 cap on `cbm_type_resolve_alias`. Diamond hierarchies in real Python (SQLAlchemy, Django, Zope) exceed 20 levels — without a cap a pathological hierarchy stack-overflows the C process rather than degrading to UNKNOWN. The cap is shared infra and applies to Go (struct embedding chain) and C++ (CRTP / multi-inheritance) too.
|
||||
|
||||
Tests: `scope_dynamic_growth_300_bindings` (allocates 300 bindings, asserts all retrievable), `mro_depth_cap_returns_unknown_at_17` (constructs 20-deep diamond hierarchy, asserts UNKNOWN at depth 17 not crash). Cross-language: add `golsp_scope_300_bindings`, `clsp_scope_300_bindings` to verify Go/C++ LSP unaffected by the scope refactor.
|
||||
|
||||
### Phase 1 — type-rep extensions
|
||||
Add `UNION`, `LITERAL`, `PROTOCOL`, `MODULE`, `CALLABLE` constructors and operations.
|
||||
Tests: `pylsp_type_union_normalize`, `pylsp_type_literal_equality`, `pylsp_type_optional_is_union_none`, `pylsp_type_protocol_method_set`, `pylsp_type_module_attribute_lookup`, `pylsp_type_callable_arity`.
|
||||
|
||||
### Phase 2 — scaffold + entry point + smoke
|
||||
Create `py_lsp.{c,h}`, `PyLSPContext` mirroring `GoLSPContext`. Implement `cbm_run_py_lsp` that builds an empty registry, runs the binder, returns. Wire `cbm_extract_file` to call it. Add `lsp_all.c` include. Add `tests/test_py_lsp.c` skeleton with helper `extract_py(source)`. Wire test runner.
|
||||
Tests: `pylsp_smoke_empty`, `pylsp_smoke_one_function`, `pylsp_smoke_one_class`, `pylsp_no_crash_on_syntax_error`.
|
||||
|
||||
### Phase 3 — imports
|
||||
Port `importResolver.ts` algorithm subset (we only need symbol → module-qn, not on-disk resolution since the pipeline already gives us the import map). Handle `import X`, `import X.Y as Z`, `from X import a, b`, `from X import a as b`, `from . import x`, `from ..pkg import x`, `from X import *` (best-effort, treat as wildcard re-export). Honor `if TYPE_CHECKING:` (type-only imports still bind names).
|
||||
Tests: `pylsp_import_simple`, `_import_aliased`, `_import_from`, `_import_from_aliased`, `_import_relative_one_dot`, `_import_relative_two_dots`, `_import_star_best_effort`, `_import_typing_only`.
|
||||
|
||||
### Phase 4 — scope + binding
|
||||
Port `binder.ts` visit overrides for Python (32 in Pyright). Bind: assignments, annotated assignments, augmented assignments, walrus, `for`, `with as`, `except as`, function params (positional, keyword-only, `*args`, `**kwargs`, defaults, annotations), `global`, `nonlocal`, comprehension scopes, lambda, PEP 634 `match`/`case` captures, PEP 695 type-parameter scopes, `del`.
|
||||
Tests: `pylsp_assign_simple`, `_annotated_assign`, `_multi_assign_join`, `_walrus`, `_for_loop_var`, `_with_as`, `_except_as`, `_param_default`, `_param_starargs`, `_global_nonlocal`, `_match_capture`, `_pep695_type_param`, `_comprehension_scope`, `_lambda_scope`.
|
||||
|
||||
### Phase 5 — expression typing (no method dispatch yet)
|
||||
Port the typeEvaluator subset. Literals (int/str/bytes/bool/None → BUILTIN or LITERAL); names (scope → registry → UNKNOWN); BinOp via dunders (`__add__`, `__radd__`, …) starting with int/str/list arithmetic; UnaryOp; Compare → bool; Tuple/List/Dict/Set literal → TEMPLATE container with element-type inference; Subscript with `__getitem__` plus typed shortcuts for `list[T][int]`, `dict[K, V][K]`, `tuple[A, B][0]`; Lambda → CALLABLE; `a if c else b` → union(a, b); comprehensions (list/set/dict/gen) → TEMPLATE container; star/double-star unpack tracked for arg-count purposes.
|
||||
Tests: `pylsp_literal_int_str`, `_binop_arithmetic`, `_binop_string_concat`, `_compare`, `_list_literal`, `_dict_literal`, `_subscript_list_int`, `_subscript_dict_key`, `_subscript_tuple_int`, `_lambda_inferred`, `_conditional_expr_union`, `_listcomp`, `_dictcomp`, `_setcomp`, `_gencomp`.
|
||||
|
||||
### Phase 5.5 — spike `getTypeOfMemberAccess` port (NEW, per challenger)
|
||||
**Time-box: 1 day.** Before committing to Phase 6's full scope, spike a minimal `obj.method()` resolver covering: type-of-obj resolution, single-level MRO walk, basic property/classmethod/staticmethod handling. Measure C-LOC cost. **If the spike exceeds ~1,200 LOC at acceptable quality, the full plan is undersized — pause and renegotiate scope before continuing.** This is the load-bearing test of the "port ~3,000 C lines of relevant typeEvaluator subset" estimate.
|
||||
|
||||
### Phase 6 — attribute resolution + method dispatch (the core)
|
||||
For `obj.attr`: resolve type of `obj`; if class, walk MRO via C3 linearization for `attr` (with explicit depth-cap from Phase 0); if module, look up exported symbol; if Protocol, structural match; honor `@property` (return getter's return), `@classmethod`, `@staticmethod`, instance methods; partial descriptor protocol (`__get__`); cleanly bail (UNKNOWN) on `__getattr__` / `__getattribute__` rather than false-resolve. `self.x = …` inside `__init__` adds field to enclosing class.
|
||||
Tests: `pylsp_method_simple`, `_method_inherited`, `_method_diamond_mro`, `_method_protocol`, `_classmethod`, `_staticmethod`, `_property_getter`, `_module_attribute`, `_method_chain`, `_dunder_init_self_attr`, `_method_super_call`.
|
||||
|
||||
### Phase 7 — decorators
|
||||
Decorators rewrite the type of decorated symbols. `@dataclass` synthesizes `__init__` from annotated class fields (port `dataClasses.ts`). `@property` / `@x.setter` / `@x.deleter` change the symbol shape. `@classmethod`, `@staticmethod`, `@abstractmethod`, `@functools.wraps` (identity), `@functools.cache`/`@lru_cache` (identity for our purposes), `@overload` recorded so calls match the right signature, user decorators returning a callable (substitute return type).
|
||||
Tests: `_decorator_identity`, `_decorator_property`, `_decorator_dataclass_init`, `_decorator_dataclass_field`, `_decorator_classmethod`, `_decorator_staticmethod`, `_decorator_overload`, `_decorator_user_returning_callable`, `_decorator_functools_wraps`.
|
||||
|
||||
### Phase 8 — class hierarchy + generics
|
||||
C3 linearization for multi-inheritance MRO; `super()` resolution to next-in-MRO method; `Generic[T]` and PEP 695 syntax; type-parameter substitution at instantiation (`Foo[int]() → bind T=int`); `abc.ABC` / `@abstractmethod` (resolution-equivalent to concrete; just a flag); `Enum` value typing (port `enums.ts`); `NamedTuple` synthesis (port `namedTuples.ts`).
|
||||
Tests: `_class_simple`, `_inheritance_single`, `_inheritance_diamond`, `_generic_class_instantiation`, `_super_call`, `_pep695_generic_class`, `_pep695_type_alias`, `_abc_method_resolution`, `_enum_value_type`, `_namedtuple_field`.
|
||||
|
||||
### Phase 9 — cross-file + batch
|
||||
`cbm_run_py_lsp_cross` accepts caller-supplied `CBMLSPDef[]` summarizing every cross-file class/method/function/module member with its module QN, return-types-as-text, base classes, method names. Re-parse and resolve. `cbm_batch_py_lsp_cross` for parallel multi-file. Special concern for Python: `from foo import bar` semantics — `bar` may be class, function, module, or re-export; the cross-file def list must encode kind. Re-exports via `__all__` and bare `from .x import y` need handling.
|
||||
Tests: `_crossfile_method_dispatch`, `_crossfile_inheritance`, `_crossfile_protocol`, `_crossfile_module_attribute`, `_crossfile_dataclass_field`, `_crossfile_re_export`, `_crossfile_init_re_export` (`pkg/__init__.py` reexports).
|
||||
|
||||
### Phase 9.5 — pipeline edge wiring (MANDATORY, moved earlier per challenger)
|
||||
**Per challenger feedback, this is no longer optional and is moved to immediately after cross-file Python is working.** The Go and C++ LSPs already populate `result->resolved_calls` but the pipeline's `pass_calls.c` doesn't currently read it — calls still resolve via the textual-name registry. Shipping a third LSP into a dead buffer is not a meaningful milestone, and the longer wiring is deferred the larger the eventual coordination cost across three language implementations.
|
||||
|
||||
Update `src/pipeline/pass_calls.c::resolve_single_call` to:
|
||||
1. Check `result->resolved_calls[]` first for a matching `(caller_qn, call_site)`.
|
||||
2. If found and `confidence ≥ floor` (TBD — likely 0.6), emit edge with the LSP-resolved callee QN and `strategy="lsp_*"` from the entry.
|
||||
3. Otherwise fall back to existing registry-based resolution.
|
||||
Add a confidence floor knob (env var or config). Below the floor, leave it to the registry resolver (or emit nothing if both fail).
|
||||
This is a cross-language change benefiting Go, C/C++, and Python simultaneously.
|
||||
|
||||
Tests: extend `tests/test_pipeline.c` with `pipeline_uses_lsp_resolved_calls`, `pipeline_falls_back_to_registry`, `pipeline_confidence_floor_filters`.
|
||||
|
||||
### Phase 10 — stdlib generator + data
|
||||
Write `scripts/gen-py-stdlib.py` (Python 3.9 per global preference for the *generator runtime*; generated stubs target Python 3.12). **Use `typeshed-client` PyPI package as the parsing layer** rather than raw `ast` — it handles version-guard expansion (`if sys.version_info >= (3, 10):`), overload stack collapsing, and ParamSpec/TypeVarTuple noise filtering, all of which would otherwise be per-construct special-case code in our generator. Emit `cbm_python_stdlib_register` to `internal/cbm/lsp/generated/python_stdlib_data.c` mirroring the `go_stdlib_data.c` style (static method-name arrays, bulk `cbm_registry_add_type` / `cbm_registry_add_func` calls). **Header records the typeshed commit SHA mined.**
|
||||
|
||||
**Generator output validation:** add a CI step that compiles the generated `python_stdlib_data.c` and refuses to commit it if compilation fails. Catches when typeshed adds new stub idioms (e.g., PEP 742 `TypeIs`, PEP 696 TypeVar defaults) that the generator emits malformed C for.
|
||||
|
||||
Coverage target for v1 (top usage in real Python codebases): `builtins`, `typing`, `os`, `os.path`, `sys`, `collections` (esp. `defaultdict`, `OrderedDict`, `Counter`, `deque`), `collections.abc`, `functools`, `itertools`, `pathlib`, `json`, `re`, `dataclasses`, `enum`, `datetime`, `subprocess`, `logging`, `asyncio`, `unittest`, `argparse`, `contextlib`, `io`, `tempfile`, `shutil`, `inspect`, `abc`, `typing_extensions`, `warnings`, `weakref`, `copy`, `pickle`, `time`, `math`, `string`, `urllib.parse`, `urllib.request`, `http.client`, `socket`, `threading`, `multiprocessing`, `queue`. Skip `tkinter`, `turtle`, `curses`, `xml.*`, `email.*` for v1 — too big, low call volume in the projects we care about.
|
||||
Tests: `pylsp_stdlib_os_path_join`, `_stdlib_collections_defaultdict`, `_stdlib_pathlib_path_method`, `_stdlib_json_loads_returns_any`, `_stdlib_logging_getlogger`, `_stdlib_re_compile_match`, `_stdlib_dataclasses_field`, `_stdlib_typing_optional_is_union`, `_stdlib_functools_partial_call`.
|
||||
|
||||
### Phase 11 — benchmarks + golden corpus
|
||||
Run `scripts/benchmark-index.sh` against an existing Python repo (e.g. one of the indexed `~/project_dir/datadice/*` projects that's Python-heavy — TBD which). Track:
|
||||
- **Absolute** per-file LSP overhead, not relative to baseline (per challenger). Budget: <10ms for a 500-line file. The "30% over baseline" framing is misleading because Python's current baseline is essentially zero LSP overhead.
|
||||
- `resolved_calls.count / calls.count` ratio (target: ≥40% for normal application code, ≥70% with stdlib).
|
||||
- Manual spot-check of 50 sampled resolutions for false-positive rate (target: < 5%).
|
||||
- **Declared ceiling on accepted failure classes:** the benchmark measures against a documented list of cases where UNKNOWN is the *correct* answer, not a regression. Accepted classes: metaclass `__call__`, `__getattr__`/`__getattribute__`, `setattr` with non-literal name, `importlib.import_module(<dynamic_arg>)`, runtime monkey-patching, `eval`/`exec`. Anything else producing UNKNOWN is a measurable gap to investigate.
|
||||
Compare to `test_go_lsp.c`'s coverage shape and tune.
|
||||
|
||||
## 6. Test-suite shape
|
||||
|
||||
Mirroring `tests/test_go_lsp.c` exactly:
|
||||
|
||||
```c
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
#include "lsp/py_lsp.h"
|
||||
|
||||
static CBMFileResult *extract_py(const char *source) {
|
||||
return cbm_extract_file(source, (int)strlen(source), CBM_LANG_PYTHON,
|
||||
"test", "main.py", 0, NULL, NULL);
|
||||
}
|
||||
|
||||
static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub);
|
||||
static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub);
|
||||
|
||||
/* ── Category N: ... ──────── */
|
||||
TEST(pylsp_method_simple) { /* small Python source */ /* assert */ PASS(); }
|
||||
...
|
||||
|
||||
SUITE(py_lsp) {
|
||||
RUN_TEST(pylsp_smoke_empty);
|
||||
RUN_TEST(pylsp_method_simple);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Estimated final test count by phase: Phase 1: 6, Phase 2: 4, Phase 3: 8, Phase 4: 14, Phase 5: 15, Phase 6: 11, Phase 7: 9, Phase 8: 10, Phase 9: 7, Phase 10: 9. **Total: ~93 tests** (Go LSP is at 49; Python's broader semantics warrant more coverage).
|
||||
|
||||
## 7. Decisions locked in (was: open questions)
|
||||
|
||||
User-confirmed decisions, 2026-05-08:
|
||||
|
||||
1. **Reference: Pyright primary + Jedi 0.19.2 as dev-time oracle.** Pyright's `binder.ts` / `typeEvaluator.ts` / `importResolver.ts` are the algorithmic reference (matching the gopls / clangd precedent). A Python subprocess runs `jedi.Script(src).goto(line, col)` over the test corpus to provide independent ground truth. The Jedi oracle lives in `tests/oracle/` (Python venv, jedi==0.19.2 pinned), is invoked from a test helper, is not a runtime dependency.
|
||||
- **Phase 2 addition:** add `tests/oracle/jedi_oracle.py` (subprocess JSON-RPC: take source + (line,col), return resolved QN). Add `tests/test_py_lsp.c` helper `run_jedi_oracle(src, line, col)` that shells out, parses JSON, returns expected QN. Tests in Phase 6 onward assert `py_lsp_resolution == jedi_oracle_resolution` for hand-curated cases.
|
||||
- **Generator runtime separate from oracle runtime:** stdlib generator uses Python 3.9 (global preference). Jedi oracle uses 3.10+ venv (since pinning to jedi==0.19.2 still requires a 3.9-compatible interpreter, but isolating the oracle in its own venv prevents cross-contamination). Document the venv setup in `tests/oracle/README.md`.
|
||||
|
||||
2. **Scope frame: dynamic linked-frame allocation.** Replaces the fixed 64-binding array with a growable arena-allocated linked list. Touches `internal/cbm/lsp/scope.{c,h}` which Go and C++ LSPs both depend on — therefore Phase 0 must include a regression run of `test_go_lsp` and `test_c_lsp` to confirm no behavior change at the existing-test scale. The new shape: `CBMScope { CBMScope* parent; CBMScopeChunk* chunks; }` where `CBMScopeChunk { CBMVarBinding bindings[CHUNK_SZ]; CBMScopeChunk* next; int used; }` with `CHUNK_SZ = 16`. `cbm_scope_bind` allocates a new chunk when `used == CHUNK_SZ`. Lookup walks chunks backward (most-recent-binding wins). Existing API surface preserved.
|
||||
- **Cross-language test:** explicit test in `test_go_lsp` and `test_c_lsp` for ≥100 bindings in one frame to verify no regression. Add `pylsp_scope_dynamic_growth_300_bindings` in py_lsp.
|
||||
|
||||
3. **Confidence floor: 0.6 (moderate, fixed default).** No env knob in v1 — keeps the surface smaller. We can add `CBM_LSP_CONFIDENCE_FLOOR` later if tuning shows it's needed. Below the floor, `pass_calls.c` falls back to the registry resolver and emits the registry's edge instead. Tests assert: floor=0.6 emits LSP edge when `lsp_param_type` (≥0.85) is present, falls back to registry when only an `lsp_best_guess` (~0.4) is present.
|
||||
|
||||
4. **Stdlib version target: union of Python 3.9–3.13 with per-symbol version guards.** Heaviest generator path, but matches the cross-version reality of indexed codebases. The generated `python_stdlib_data.c` records each func/type with `min_python_version` / `max_python_version` (encoded as packed `uint16_t` minor versions, e.g. `0x0309..0x030d`). `py_lsp` lookup at runtime needs to know the consumer file's target version — derive from project metadata if available, else default to 3.12 and only fall back to broader version guards on miss.
|
||||
- **Project-target detection:** read `pyproject.toml` `[tool.python] requires-python` or fall back to `python_requires` in `setup.py`. Default 3.12 if neither found. Cache per-project, not per-file.
|
||||
- **Generator complexity:** `typeshed-client` already exposes per-symbol version guards from `if sys.version_info >= (X, Y):` blocks — we wire those through directly, not re-derive.
|
||||
- Tests: `pylsp_stdlib_version_3_9_no_match`, `pylsp_stdlib_version_3_12_paramspec_resolves`, `pylsp_stdlib_version_3_13_typeis_resolves`.
|
||||
|
||||
5. **Vendoring policy** (not asked but recommended in §1): references stay external in `/tmp/python-lsp-references/` during dev; only the *generated outputs* (`internal/cbm/lsp/generated/python_stdlib_data.c`) ship in the repo. Matches the gopls/clangd precedent. Confirm or override later.
|
||||
|
||||
## 8. Out of scope (explicitly)
|
||||
|
||||
## 8. Out of scope (explicitly)
|
||||
|
||||
- Diagnostics emission (`checker.ts`). We only resolve calls; we don't report type errors.
|
||||
- Hover/completion features. The LSP source is mined for type-resolution algorithms only, not for the LSP protocol surface.
|
||||
- `__getattr__` / `__getattribute__` / metaclass `__call__` / `__class_getitem__` — bail to UNKNOWN.
|
||||
- `eval`/`exec`-generated code, runtime monkey-patching, `setattr` with non-literal name.
|
||||
- Cython, Stackless, MicroPython, and other dialects.
|
||||
- `numpy.ndarray` / `pandas.DataFrame` rich shape inference — third-party stubs may be added later via the same generator.
|
||||
|
||||
## 9. Risks
|
||||
|
||||
- **typeEvaluator.ts is 28,955 lines.** Porting even 10% literally is unrealistic. Mitigation: port only the call-resolution-relevant subset (~3,000 C lines), borrowing decisions but not structure. Use the Go LSP's eager bottom-up shape rather than Pyright's lazy on-demand model.
|
||||
- **Python's dynamism may cap resolution rate at ~70%.** Many calls are genuinely unresolvable statically (`getattr`, attribute set via dict, dynamic dispatch via `__call__`). Mitigation: aim for high precision on the resolvable subset, accept gaps cleanly (UNKNOWN, not garbage).
|
||||
- **typeshed stubs use features (overload, ParamSpec, TypeVarTuple, Self, Concatenate) that complicate the generator.** Mitigation: in v1, the generator emits a single best signature per overload (the most-arg-permissive one) and elides ParamSpec/TypeVarTuple/Concatenate. Phase 10.5 follow-up handles them.
|
||||
- **PEP 695 generic syntax is recent.** Mitigation: the tree-sitter Python grammar supports it; we'll exercise it with explicit tests in Phase 8.
|
||||
- **Pyright is a moving target (Microsoft fork basedpyright also exists).** Mitigation: pin to a tag, document the commit hash we mined, treat algorithm changes as upstream we don't track.
|
||||
|
||||
## 10. Next concrete steps after plan approval
|
||||
|
||||
1. **Pin reference SHAs** (Pyright tag 1.1.407 / typeshed commit / Jedi 0.19.2) and record them in this file.
|
||||
2. **Phase 0 PR:** `CBM_SCOPE_MAX_BINDINGS` bump 64→128 + MRO depth-cap helper + tests (~150 LOC). Cross-language change in shared infra; required before any py_lsp work.
|
||||
3. **Phase 1 PR:** type-rep extensions + tests (~300 LOC change).
|
||||
4. **Phase 2 PR:** `py_lsp.{c,h}` scaffold + entry-point wiring + smoke tests (~250 LOC).
|
||||
5. **Phase 5.5 spike** (1-day time-box): minimal `getTypeOfMemberAccess` port. Decision gate: if >1,200 LOC, pause and renegotiate scope.
|
||||
6. **Phase 9.5 PR:** `pass_calls.c` LSP-aware resolution + cross-language tests (~200 LOC).
|
||||
7. Continue Phase 3..11 per phase. Each phase ≤ ~1,500 LOC, ≤ 15 tests, 1–3 day slice.
|
||||
|
||||
---
|
||||
|
||||
## Challenger Review (verbatim)
|
||||
|
||||
The following is the full output of the challenger gate run against the draft plan, per CLAUDE.md "Sharing the Report" requirement. Recommendations have already been incorporated into the phase plan above; this section preserves the original report for traceability.
|
||||
|
||||
### What Looks Good
|
||||
|
||||
The TDD-first, phase-per-PR structure with no merging on red tests is the right discipline for a feature this complex. Reusing `CBMLSPDef` for cross-file data avoids inventing a new serialization contract. The explicit out-of-scope list (diagnostics, hover, `__getattr__`, `eval`/exec) is precise and prevents scope creep. Deferring pipeline wiring to Phase 11/option-b is honest about user-visible impact rather than hiding it. The phase-10 stdlib coverage list is appropriately prioritized and the v1 skip list is defensible.
|
||||
|
||||
### Assumptions to Verify
|
||||
|
||||
**Assumption 1: Jedi is a "secondary reference" you might not even need.**
|
||||
|
||||
The plan treats Jedi as secondary to Pyright and frames the choice as "Pyright is the type-system reference, Jedi is more practical." That framing understates Jedi's relevance. Jedi's `goto()` and `infer()` APIs *directly answer the question* "what is the callee of this call expression?" — which is exactly what `cbm_run_py_lsp` needs to produce. Pyright answers a harder question: "is this call type-safe?" The 28,955-line `typeEvaluator.ts` is mostly type-checking machinery; the call-resolution subset is a smaller fraction. You're spending research effort on the harder problem when the simpler one (Jedi's inference core, ~3,600 lines) maps more directly to your output: a `(caller_qn, callee_qn, confidence)` triple.
|
||||
|
||||
*Consequence if wrong:* You end up porting type-checking plumbing (union narrowing, type guards, ParamSpec, overload resolution by type) that has no consumer in `pass_calls.c` and costs you 2-3x the implementation time.
|
||||
|
||||
**Assumption 2: The `CBM_SCOPE_MAX_BINDINGS = 64` limit per frame is adequate for Python.**
|
||||
|
||||
Go and C/C++ have modest local variable counts in typical functions. Python is different. A comprehension body is its own scope (PEP 709/compiler semantics), `match` arms each introduce bindings, dataclass `__init__` synthesis creates per-field bindings, and real-world Python code regularly has functions with 30+ named locals plus `*args` + `**kwargs` + annotated class attributes. The current scope frame holds exactly 64 bindings and **silently truncates** at `cbm_scope_bind`'s count check — there is no overflow to a second frame, no error, no diagnostic. At 64 the scope overflows silently and subsequent bindings are dropped. This is a precision-killer for large Python functions and a correctness bug for any file where the 65th local happens to be the receiver of the call you're trying to resolve.
|
||||
|
||||
*Consequence if wrong:* Silent false-negatives. Resolution rate will look fine in unit tests (which use small snippets) but fall off in real codebases. The Phase 12 benchmark may reveal this but by then 10 phases of work are in.
|
||||
|
||||
**Assumption 3: typeshed's `ast` module is a stable enough interface for the stdlib generator.**
|
||||
|
||||
`scripts/gen-py-stdlib.py` uses Python's `ast` module to parse `.pyi` files from typeshed. This works today, but typeshed stubs use features the `ast` module does not represent uniformly: `@overload` stacks, `ParamSpec`, `TypeVarTuple`, `Concatenate`, `Self`, `Unpack`, conditional version guards (`if sys.version_info >= (3, 10):`). The plan's mitigation ("emit a single best signature per overload, elide ParamSpec/TypeVarTuple/Concatenate") is correct as a v1 strategy, but it means the generator has *per-construct special-case logic* that must be updated each time typeshed adds new stub patterns. Typeshed is updated continuously and has no versioned releases — only commits. The plan says "pin to a tag" but typeshed has no release tags; only the `types-*` third-party stubs on PyPI are versioned.
|
||||
|
||||
*Consequence if wrong:* The generator breaks silently when typeshed adds new stub idioms (e.g., PEP 742 `TypeIs`, PEP 696 `TypeVar` defaults are already in typeshed). You discover this when re-running the generator on a fresh typeshed clone produces malformed C output.
|
||||
|
||||
**Assumption 4: Phase 11 deferral is safe.**
|
||||
|
||||
The plan defaults to option (b): defer pipeline wiring. The Go LSP has been populated into `resolved_calls` with zero consumer since it was first merged. So has C/C++. This work is shipping a third LSP that also writes to `resolved_calls` with zero consumer. Each deferred LSP increases the blast radius of the eventual wiring PR (the wiring code must handle all three language outputs simultaneously) and each LSP author drifts further from the context needed to wire it correctly.
|
||||
|
||||
*Consequence if wrong:* Phase 11 becomes a cross-language coordination task that never gets prioritized, and the Python LSP joins Go/C++ in permanent limbo.
|
||||
|
||||
**Assumption 5: 93 tests is adequate coverage for Python's surface area.**
|
||||
|
||||
The Go LSP has 49 tests for a language with a much smaller semantic surface. The plan estimates 93 for Python. But Python's dynamism, the number of scope edge cases (comprehension scopes, walrus in comprehensions, class scope non-closure, LEGB + nonlocal + global interactions, lambda in default argument), decorator combinations, and the `__init__`-self-attr inference make this a larger combinatorial space. For reference, Pyright's own test suite has thousands of individual cases just for the type narrowing rules. 93 tests covering 12 phases averages ~7.75 tests per phase; Phase 6 (attribute resolution + MRO) has 11 — which is thin for a feature covering C3 linearization, property descriptors, classmethod, staticmethod, `super()`, and module attribute lookup simultaneously.
|
||||
|
||||
### Alternatives Worth Considering
|
||||
|
||||
**Alternative 1: Subprocess Jedi as oracle, C reimplementation as fast path**
|
||||
|
||||
Rather than mining Pyright's TypeScript for algorithms to reimplement, consider a hybrid: run Jedi as a validation oracle during development (Python subprocess, `jedi.Script(source).goto(line, col)` for every call site in your test corpus) and build the C reimplementation to match Jedi's output rather than Pyright's theory. This is how `go_lsp` was effectively validated — against gopls behavior, not by porting gopls source. You get a ground truth for expected outputs in Phase 6-9 without needing to fully understand `typeEvaluator.ts`.
|
||||
|
||||
Trade-off: Jedi 0.20.0 (released May 2026) **dropped Python 3.9 support** and the maintainer has announced Zuban (Rust, AGPL-3.0) as Jedi's spiritual successor. Using Jedi as a dev-time oracle is still fine — you are not shipping it — but you cannot rely on it being importable in your test environment unless you pin to Jedi 0.19.2. Jedi's `infer()` also intentionally stops inference for very deep chains to avoid hangs, so it can produce UNKNOWN on the exact cases you most need to validate.
|
||||
|
||||
**Alternative 2: Swap Pyright reference for basedpyright**
|
||||
|
||||
basedpyright is a biweekly-rebased Pyright fork available on PyPI without Node.js. It ships stricter diagnostics and better annotation of exactly which type features are "unresolvable" vs "inferred as Any" — which maps better to the UNKNOWN vs RESOLVED distinction `py_lsp` needs to produce. It also runs without the Node.js runtime requirement. As a reference to mine, it has the same ~28K-LOC `typeEvaluator.ts`, so the porting surface is unchanged — but the diagnostics documentation in basedpyright is more granular about what the evaluator considers statically unresolvable.
|
||||
|
||||
Trade-off: It is still a TypeScript codebase of the same size. The benefit is operational (PyPI install, no Node) for dev-time oracle runs. As an algorithmic reference it is identical to upstream Pyright.
|
||||
|
||||
**Alternative 3: Pyrefly as reference (but not yet)**
|
||||
|
||||
Meta's Pyrefly (Rust, MIT, open-sourced May 2025) checks 1.8M LOC/second and achieves >70% conformance with the Python typing spec as of late 2025. Its Rust source would be much closer in spirit to a C reimplementation than TypeScript. It handles call resolution via the same approach (typeshed stubs + inference).
|
||||
|
||||
Trade-off: Pyrefly is explicitly labeled "active alpha" as of 2025, and the blog post says "not yet production-ready for complex codebases." Its conformance gaps are precisely in the complex features you need (ParamSpec, recursive generics, TypeVarTuple). It is not a viable reference today but should be re-evaluated after v1 of py_lsp ships.
|
||||
|
||||
**Alternative 4: Do not reimplement — shell out to Pyright/Jedi per batch**
|
||||
|
||||
The indexer could spawn a persistent Pyright LSP subprocess (`pyright-langserver --stdio`), send `textDocument/definition` for each call site via JSON-RPC, and use the response to populate `resolved_calls`. This avoids the 3,000–5,000 LOC reimplementation entirely.
|
||||
|
||||
Trade-off: This has been tried in the LSP-outside-editor context and has a known fatal flaw: Pyright's `textDocument/definition` responses are **incomplete unless every file in the workspace has been explicitly opened via `didOpen`**, as documented in pyright#10086. Batch resolution across a 5,000-file repo would require 5,000 `didOpen` messages before the first `definition` request returns correct results. The round-trip overhead at that scale makes this impractical for a synchronous indexer. Jedi has a `Project` API that avoids this problem, but again — the dev-time oracle pattern above is safer.
|
||||
|
||||
This alternative should be explicitly rejected in the plan with this rationale, not just left as an unaddressed option.
|
||||
|
||||
### Risk Register
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `CBM_SCOPE_MAX_BINDINGS=64` silently truncates Python functions with >64 locals | High | High | Raise to 128 or implement dynamic frame (linked list of arrays); add a test with exactly 65 locals |
|
||||
| typeshed has no release tags; re-running generator on fresh clone produces different output | High | Med | Pin to a specific commit SHA, not `main`. Document SHA in generator header. |
|
||||
| Phase 11 permanent deferral — all 3 LSPs stay wired to zero consumers | High | High | Make Phase 11 a hard gate, not optional. Schedule it in the same milestone as Phase 10 at the latest. |
|
||||
| typeEvaluator.ts 3,000-line subset underestimate — actual MRO + overload + generic substitution alone exceeds this | Med | High | Time-box the port of `getTypeOfMemberAccess` in Phase 6 spike before committing to the full plan; fail fast if it takes >1,200 LOC |
|
||||
| Jedi 0.20.0 drops Python 3.9 support; dev oracle environment breaks | High | Low | Pin to jedi==0.19.2 for oracle use; or use python3.10+ venv just for oracle |
|
||||
| PEP 634 `match`/`case` binding creates >64 bindings in a single clause (union of all patterns) | Med | Med | Match-arm bodies should use child scopes, not the enclosing function scope. Verify scope push/pop in Phase 4 |
|
||||
| False-positive resolution (wrong callee, high confidence) worse than false-negative (UNKNOWN) | Med | High | Precision > recall policy: prefer UNKNOWN over wrong QN. Add explicit confidence floor test. |
|
||||
| gen-py-stdlib.py fails on new typeshed stub constructs (PEP 742, TypeIs, PEP 696 TypeVar defaults) | Med | Med | Add a validation pass: parse generator output through a C compiler after generation. Catch at CI. |
|
||||
| Python `from X import *` wildcard imports require walking the entire exported symbol space | High | Low | Plan already flags "best-effort"; ensure the implementation emits MODULE type, not attempts expansion |
|
||||
| Pyright 1.1.407 is at weekly release cadence; mined commit diverges from main within weeks | Low | Low | Document the exact commit hash mined. This is a research artifact, not a runtime dep. |
|
||||
|
||||
### Operational Concerns
|
||||
|
||||
**Error handling in the C pipeline — no explicit OOM/crash guard for py_lsp.**
|
||||
|
||||
Both `go_lsp` and `c_lsp` use arena allocation throughout and return silently on null returns from `cbm_arena_alloc`. This is the correct pattern for OOM: arena exhaustion causes null returns which propagate to `cbm_type_unknown()` returns, which cause UNKNOWN resolutions rather than crashes. The plan should explicitly confirm that `py_lsp` follows this same contract. The one failure mode that isn't covered: Python's deeply nested comprehensions (list-in-list-in-listcomp) can create very deep scope chains. The scope chain is a linked list of arena-allocated frames; arena exhaustion mid-chain will cause a null `cbm_scope_push` return, and the code in `go_lsp.c` handles this with `if (!scope) return current;` — so the frame stays the parent. This is correct for OOM, but it silently merges scopes, causing variable shadowing bugs rather than crashes. Document this explicitly as the intended degradation mode for py_lsp.
|
||||
|
||||
**No recursion depth guard for C3 MRO linearization.**
|
||||
|
||||
Phase 6 and 8 implement C3 linearization. The algorithm is recursive in most reference implementations. A pathological class hierarchy (100-deep diamond inheritance, common in some ORM frameworks) can stack-overflow the C process, not just exhaust the arena. The plan says nothing about a depth cap. The go_lsp has a depth-16 cap on alias resolution (`cbm_type_resolve_alias`). The same pattern (depth counter, bail to UNKNOWN at depth > N) must be applied to `go_lookup_field_or_method`-equivalent in py_lsp.
|
||||
|
||||
**The stdlib generator runs python3.9 per global preferences, but Jedi 0.20.0 requires Python 3.10+.**
|
||||
|
||||
The global CLAUDE.md prefers `python3.9`. The generator script is explicitly targeted at Python 3.9 (plan Phase 10). This is consistent. But if the developer uses Jedi as a dev-time oracle (Alternative 1 above), they will need a separate venv or the oracle won't import. This is an operational friction point worth calling out.
|
||||
|
||||
**No performance regression gate for Python specifically.**
|
||||
|
||||
Phase 12 targets <30% LSP overhead over baseline extraction. But the "baseline" for Python extraction is already fast (grammar_python.c is 3 lines — essentially zero LSP overhead today). The correct comparison is per-file LSP time in absolute terms, not relative to baseline. A 30% overhead on a 1ms baseline is only 0.3ms; 30% on a 50ms baseline is 15ms. Specify the absolute budget (e.g., "py_lsp must complete in <10ms for a 500-line file").
|
||||
|
||||
### Research Findings
|
||||
|
||||
- [TypeEvalPy (ICSE 2024): Precision/recall benchmarks for Pyright vs Jedi on Python type inference](https://arxiv.org/html/2312.16882v2)
|
||||
- [Pyright pypi 1.1.407, latest release tracker](https://pypi.org/project/pyright/)
|
||||
- [basedpyright: Pyright fork with PyPI install, no Node.js, biweekly rebase](https://github.com/DetachHead/basedpyright)
|
||||
- [Pyrefly open-sourced May 2025, active alpha, >70% conformance](https://engineering.fb.com/2025/05/15/open-sourcing-pyrefly-a-faster-python-type-checker-written-in-rust/)
|
||||
- [Jedi 0.20.0 released May 2026, drops Python 3.9, maintainer announces Zuban as successor](https://pypi.org/project/jedi/)
|
||||
- [Zuban: Rust, AGPL-3.0, Jedi successor, 96.4% typing conformance, Sept 2025](https://github.com/zubanls/zuban)
|
||||
- [Pyright LSP #10086: incomplete references without explicit didOpen for every file — fatal for batch resolver use](https://github.com/microsoft/pyright/issues/10086)
|
||||
- [typeshed-client: versioned PyPI package for consuming stubs](https://pypi.org/project/typeshed-client/)
|
||||
- [typeshed has no release tags, only continuous commits via automated PyPI publishing](https://github.com/python/typeshed)
|
||||
- [Positron evaluated Pyrefly as default Python language server, March 2026](https://positron.posit.co/blog/posts/2026-03-31-python-type-checkers/)
|
||||
- [tree-sitter-python v0.25.0 latest release (confirmed PEP 695 nodes in vendored parser.c)](https://github.com/tree-sitter/tree-sitter-python/releases)
|
||||
- [How well do new Python type checkers conform? Pyrefly, ty, Zuban comparison 2026](https://sinon.github.io/future-python-type-checkers/)
|
||||
|
||||
### Dependency Version Audit
|
||||
|
||||
| Dependency | Current (Vendored/Pinned) | Latest Stable | Delta | Action |
|
||||
|---|---|---|---|---|
|
||||
| tree-sitter-python grammar | ABI version 15 (parser.c header), likely ~v0.21–0.23 based on symbol numbering | v0.25.0 (Sept 2024) | ~2 minor versions | Low risk. PEP 695 and PEP 634 nodes are present in vendored grammar. Upgrade if adding new Python syntax support, otherwise defer. |
|
||||
| Pyright (reference, not shipped) | `main` depth=1, no tag | 1.1.407 | Unknown drift | Pin to tag `1.1.407` or a specific commit SHA. Document the SHA in `PYTHON_LSP_PLAN.md` and the generator header. Main moves ~weekly. |
|
||||
| typeshed (reference, not shipped) | `main` depth=1, no tag | No release tags exist | Continuous drift | Pin to a specific commit SHA (e.g., `git rev-parse HEAD` at time of generation). Store SHA in generator file header. No PyPI package to pin; commit is the only stable anchor. |
|
||||
| Jedi (secondary reference, dev-time) | `master` depth=1 | 0.20.0 (May 2026) | 0.19.2 → 0.20.0 | **WARNING:** 0.20.0 drops Python 3.9 support. If using as oracle, pin to `jedi==0.19.2` in a Python 3.9-compatible venv. If upgrading to 3.10+ venv, 0.20.0 is fine. |
|
||||
| typeshed-client (alternative to raw ast parsing) | Not used | 2.8.2 | Not in plan | Consider using `typeshed-client` (PyPI, versioned, maintained) instead of raw `ast` parsing of typeshed `.pyi` files. It provides a stable API to enumerate stub definitions with version guards already parsed. |
|
||||
|
||||
### Questions for the Developer
|
||||
|
||||
1. **Scope frame limit:** Have you measured the 99th percentile binding count per function frame in a real Python corpus? 64 is adequate for Go; Python modules commonly have class bodies with 80+ field annotations. What is the upgrade path — static array to 128, or dynamic linked list of frames?
|
||||
|
||||
2. **Phase 11 gate:** Three LSPs (Go, C++, Python) now all write to `resolved_calls` with no consumer. What is the concrete plan to wire pass_calls.c? If there is no scheduled slot, the value proposition of this entire effort is theoretical. Should Phase 11 be made mandatory and moved to Phase 9 (immediately after cross-file Python is working)?
|
||||
|
||||
3. **typeEvaluator.ts scope for Phase 6:** Before committing to the full phase plan, have you spiked `getTypeOfMemberAccess` for a simple `obj.method()` call? That function in typeEvaluator.ts alone involves type narrowing, class type resolution, protocol matching, and generic substitution. Have you time-boxed how many C lines that takes to implement at acceptable quality?
|
||||
|
||||
4. **Typeshed generator stability:** The plan uses raw `ast` parsing of typeshed `.pyi` files. `typeshed-client` (PyPI, versioned, 2.8.2) already does this with version-guard handling. Why write the generator from scratch rather than using `typeshed-client` as the parsing layer and only writing the C-code emitter?
|
||||
|
||||
5. **Version target:** The plan says "Python 3.9 per global preference" for the generator script itself, but asks "pin to 3.12 or union-of-versions superset?" for the generated stubs. These are different concerns. The generator can run on Python 3.9 while emitting stubs that cover 3.9–3.13 version ranges. Is the intent to emit the 3.9-compatible subset only (safe but incomplete), or emit the full union with `#if`-style version guards in the C data?
|
||||
|
||||
6. **C3 recursion depth:** What depth cap will you put on MRO linearization? Diamond hierarchies in real Python (SQLAlchemy models, Django ORM, Zope Component Architecture) exceed 20 levels. At what depth do you bail to UNKNOWN, and is that documented as a known limitation?
|
||||
|
||||
7. **False positive policy:** The plan says "bail to UNKNOWN on `__getattr__`." What is the confidence threshold below which you refuse to emit a resolved call entry at all? The Go LSP has explicit confidence values but `pass_calls.c` does not filter by them (since it ignores `resolved_calls` entirely). Before wiring, you need a policy: what confidence floor makes it into an edge vs stays as UNKNOWN?
|
||||
|
||||
### Recommendation
|
||||
|
||||
The plan is architecturally sound and follows the existing patterns correctly, but it has three problems that should be fixed before work starts rather than discovered in Phase 6. First, **raise `CBM_SCOPE_MAX_BINDINGS` to at least 128** (or dynamic) before writing a single Phase 1 test — the current limit will produce silent failures on real Python code and all unit tests will pass anyway because they use small snippets. Second, **make Phase 11 mandatory and move it to immediately after Phase 9** — shipping a third language's LSP output into a dead buffer is not a meaningful milestone, and the longer wiring is deferred the larger the eventual coordination cost across three language implementations. Third, **pin Pyright and typeshed to specific commit SHAs** before mining them — `main depth=1` means the reference shifts under you within days and reproducibility of the generator output is lost; use `typeshed-client` as the parser layer in `gen-py-stdlib.py` rather than raw `ast` to get a versioned, maintained parsing API. The 93-test estimate is defensible for v1 but the plan should explicitly document which resolution failure classes are accepted (metaclass `__call__`, `__getattr__`, `setattr`-dynamic, `importlib.import_module` dynamic import) so the benchmark in Phase 12 can measure against a declared ceiling rather than an undefined optimum.
|
||||
@@ -265,6 +265,18 @@ irm https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/scripts/
|
||||
|
||||
</details>
|
||||
|
||||
### AUR (Arch Linux)
|
||||
|
||||
```bash
|
||||
yay -S codebase-memory-mcp-bin
|
||||
```
|
||||
|
||||
```bash
|
||||
paru -S codebase-memory-mcp-bin
|
||||
```
|
||||
|
||||
The `codebase-memory-mcp-bin` package is available at: https://aur.archlinux.org/packages/codebase-memory-mcp-bin
|
||||
|
||||
### Install via Claude Code
|
||||
|
||||
```
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
## v0.6.1 — 89 New Languages, Cross-Repo Intelligence, Team-Shared Graph Artifact, npm+PyPI Distribution
|
||||
|
||||
50+ commits since v0.6.0. Adds 89 tree-sitter grammars (66 → 155 languages), introduces cross-repo intelligence with CROSS_* edges, ships team-shared graph artifacts (`.codebase-memory/graph.db.zst`), introduces full distribution wrappers (npm/PyPI/Homebrew/Scoop/Winget/Chocolatey/AUR/Go) with npm + PyPI now auto-publishing as part of the release pipeline, and rolls out comprehensive installer security hardening.
|
||||
|
||||
### Languages & Parsing — 66 → 155
|
||||
|
||||
- **89 new tree-sitter grammars** vendored, with vocabulary-cleaned tokenization and grammar security audit script
|
||||
- **Lang spec coverage** filled in for 114 languages with proper node types — Go (`func_literal`), JS (`do_statement`, fixed stale `case_clause`), C#/Python imports, shared arrays
|
||||
- **77 new extension mapping tests** covering the new languages
|
||||
- **C#, Rust, Scala grammars** updated to latest upstream
|
||||
- **`lang_specs` refactor**: designated initializers + factory pointer
|
||||
|
||||
### Cross-Repo Intelligence
|
||||
|
||||
- **CROSS_* edge types** for cross-repo dependencies and architectural relationships
|
||||
- **gRPC / GraphQL / tRPC service detection** with protobuf Route extraction
|
||||
- **gRPC stub detection** in call resolution + chained call extraction
|
||||
- **Multi-galaxy UI layout** + cross-repo architecture summary view
|
||||
|
||||
### Team-Shared Graph Artifact
|
||||
|
||||
- **`.codebase-memory/graph.db.zst`** — zstd-compressed knowledge graph that can be committed to the repo. Teammates bootstrap from the artifact instead of running a full reindex from scratch.
|
||||
- **Vendored zstd 1.5.7** (amalgamated, ~52K LOC) for 8–13:1 compression
|
||||
- **Two-tier export**: `zstd -9` + index stripping + `VACUUM INTO` for explicit indexes (best ratio); `zstd -3` for watcher/incremental auto-updates (low-latency)
|
||||
- **Import path**: decompress → integrity check → auto-recreate indexes
|
||||
- **Auto-bootstrap** in `index_repository`: when no local DB exists but the artifact is present, import first then run incremental indexing
|
||||
- **Auto-creates `.gitattributes`** with `merge=ours` to prevent merge conflicts on the binary artifact
|
||||
|
||||
### Imports & Channels
|
||||
|
||||
- **Generic package/module resolution** for IMPORTS edges across 10 languages (resolves bare specifiers like `@myorg/pkg`, `github.com/foo/bar`, `use my_crate::foo` via manifest scanning: `package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `composer.json`, `pubspec.yaml`, `pom.xml`, `build.gradle`, `mix.exs`, `*.gemspec`)
|
||||
- **Channel detection expanded from JS/TS to 8 languages**
|
||||
|
||||
### Distribution
|
||||
|
||||
Now installable directly from public package registries:
|
||||
|
||||
```bash
|
||||
npm install -g codebase-memory-mcp # npm
|
||||
pip install codebase-memory-mcp # PyPI
|
||||
go install github.com/DeusData/codebase-memory-mcp/pkg/go@latest # Go
|
||||
```
|
||||
|
||||
- **npm + PyPI auto-publish** integrated into the release pipeline (`publish-registries` job after `verify`, then atomic `publish-final` un-drafts the GitHub release only after both registries succeed — no half-shipped state)
|
||||
- **npm package** uses `--provenance` (GitHub OIDC build attestations visible on npmjs.com)
|
||||
- Full distribution wrappers in `pkg/` for: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, Go
|
||||
|
||||
### Security Hardening
|
||||
|
||||
- **PyPI installer**: hardened against tar-slip and scheme-confusion attacks (PR #248 by @dLo999, closes #246)
|
||||
- **npm installer**: checksum verification, HTTPS-only redirects, no shell injection
|
||||
- **Cross-installer hardening**: removed `Unblock-File`, added HTTPS-only URL validation
|
||||
- **vite bumped to 6.4.2** — fixes CVE GHSA-4w7w-66w2-5vf9 and GHSA-p9ff-h696-f583
|
||||
- **Grammar security audit** added to vendor pipeline
|
||||
- README: VirusTotal scan links (binary hashes), SLSA badge, Security & Trust section, transparency disclaimer, responsible-disclosure invitation
|
||||
- arXiv paper badge + citation
|
||||
|
||||
### Stability & Quality
|
||||
|
||||
- **`get_graph_schema`** now exposes property definitions per node label
|
||||
- **sqlite_writer overflow pages** — fixes SIGBUS on large records (#139)
|
||||
- **RSS reclamation** after `delete_project`: explicit `mem_collect` + immediate purge
|
||||
- **MCP tools / CLI**: improved error handling, diagnostics, and cancellation
|
||||
- Cherry-picked extraction & Cypher improvements from PR #162
|
||||
|
||||
### Editor / Agent Integration
|
||||
|
||||
- **Kiro CLI support** (#96)
|
||||
|
||||
### Platform Fixes
|
||||
|
||||
- **Windows**: `pass_pkgmap` now uses `cbm_strndup` (mingw clang lacks POSIX `strndup`)
|
||||
- **`test_watcher`**: uses `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env vars instead of mutating global git config
|
||||
|
||||
### CI / Smoke
|
||||
|
||||
- **Smoke test JSON parsing** fixed — CLI default mode unwraps the MCP envelope; smoke now parses the inner JSON directly
|
||||
- **Binary string audit allowlist** — `telnet` URI scheme from the rst grammar is documented as a known-benign match
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to everyone who contributed to this release:
|
||||
|
||||
- @dLo999 — PyPI installer hardening (#248)
|
||||
|
||||
**Full changelog**: https://github.com/DeusData/codebase-memory-mcp/compare/v0.6.0...v0.6.1
|
||||
@@ -0,0 +1,256 @@
|
||||
# Python LSP — Phase 11 benchmark methodology
|
||||
|
||||
This document describes how to measure the Python LSP type resolver
|
||||
(`internal/cbm/lsp/py_lsp.c`, Phases 0–10) against real Python code.
|
||||
Numbers from a specific corpus run can be appended in a "Results"
|
||||
section as new measurements land.
|
||||
|
||||
## Targets (locked in PYTHON_LSP_PLAN.md)
|
||||
|
||||
| Metric | Target |
|
||||
|---|---|
|
||||
| Per-file LSP overhead (absolute) | < 10 ms for a 500-line file |
|
||||
| `resolved_calls.count / calls.count` (application code) | ≥ 40% |
|
||||
| `resolved_calls.count / calls.count` (with stdlib resolution) | ≥ 70% |
|
||||
| Manual spot-check false-positive rate (50 sampled resolutions) | < 5% |
|
||||
|
||||
## Accepted failure classes (declared ceiling)
|
||||
|
||||
The following resolution failures are considered correct UNKNOWN, not
|
||||
benchmark regressions. Surface area outside this list is the measurable
|
||||
gap to investigate:
|
||||
|
||||
1. Metaclass `__call__` — class instantiation through a custom metaclass.
|
||||
2. `__getattr__` / `__getattribute__` — dynamic attribute resolution at
|
||||
runtime; py_lsp bails to UNKNOWN as documented in Phase 6.
|
||||
3. `setattr(obj, name, value)` where `name` is not a literal string.
|
||||
4. `importlib.import_module(<dynamic_arg>)` — import path computed at
|
||||
runtime.
|
||||
5. Runtime monkey-patching of methods or classes after definition.
|
||||
6. `eval` / `exec` of source strings.
|
||||
7. Descriptors (`__get__`, `__set__`, `__delete__`) beyond `@property`,
|
||||
`@classmethod`, `@staticmethod`.
|
||||
8. `__call__` on instances of classes whose `__call__` is dynamically
|
||||
bound.
|
||||
9. `__init_subclass__` hooks that mutate the subclass after creation.
|
||||
10. Dynamically generated classes via `type(name, bases, dict)`.
|
||||
|
||||
Anything outside this list that produces UNKNOWN is a known gap
|
||||
candidate for Phase 11.x extensions or v1.1.
|
||||
|
||||
## How to measure
|
||||
|
||||
### Fast: in-process integration test
|
||||
|
||||
`tests/test_py_lsp_bench.c` runs `cbm_extract_file` on a representative
|
||||
Python source string and reports `calls.count` /
|
||||
`resolved_calls.count` plus extraction time. Run with:
|
||||
|
||||
```bash
|
||||
scripts/test.sh
|
||||
# Look for the "py_lsp_bench" suite output
|
||||
```
|
||||
|
||||
### Full: against a Python-heavy repository
|
||||
|
||||
```bash
|
||||
# 1. Build a fresh production binary from this worktree.
|
||||
scripts/build.sh
|
||||
|
||||
# 2. Run benchmark-index against a Python-heavy repo. Suggested
|
||||
# candidates (Python file counts as of 2026-05-09):
|
||||
# ~/project_dir/datadice/falkemedia (232 .py files)
|
||||
# ~/project_dir/datadice/api-hub (188 .py files)
|
||||
mkdir -p /tmp/py-bench-results
|
||||
scripts/benchmark-index.sh \
|
||||
./build/c/codebase-memory-mcp \
|
||||
python \
|
||||
~/project_dir/datadice/falkemedia \
|
||||
/tmp/py-bench-results
|
||||
|
||||
# 3. Compute the resolution ratio. The benchmark dumps node/edge counts
|
||||
# plus per-file timings; cross-reference with calls / resolved_calls
|
||||
# via the MCP graph queries:
|
||||
codebase-memory-mcp query \
|
||||
"MATCH ()-[r:CALLS]->() WHERE r.strategy STARTS WITH 'lsp_' \
|
||||
RETURN count(r) AS lsp_resolved"
|
||||
|
||||
codebase-memory-mcp query \
|
||||
"MATCH ()-[r:CALLS]->() RETURN count(r) AS total_resolved"
|
||||
```
|
||||
|
||||
### Manual spot-check
|
||||
|
||||
Sample 50 resolved-call edges (random offset in the CALLS edge set)
|
||||
and verify by hand:
|
||||
|
||||
- Open the source file at `caller_qn`'s line.
|
||||
- Locate the call expression that produced the edge.
|
||||
- Confirm the `callee_qn` matches what a human reader would identify.
|
||||
|
||||
A false positive count of 0–2 out of 50 (0–4%) is within the < 5%
|
||||
target. 3+ false positives on the sample → triage and document
|
||||
contributing patterns; usually they cluster on a specific dynamic
|
||||
construct (e.g. `getattr`-style dispatch).
|
||||
|
||||
## Results
|
||||
|
||||
### In-process benchmark (test_py_lsp_bench.c)
|
||||
|
||||
| Date | Commit | Fixture LOC | Calls | Resolved | Ratio | Time (ASan+UBSan) |
|
||||
|---|---|---:|---:|---:|---:|---:|
|
||||
| 2026-05-08 | a8c9ace (Phase 11 baseline) | 65 | 16 | 13 | 81% | 2.45 ms |
|
||||
| 2026-05-09 | 2e042da (Round 1: dotted imports, cast, Self, fwd-refs) | 65 | 16 | 13 | 81% | 3.11 ms |
|
||||
| 2026-05-09 | fa45e7d (Round 2: narrowing, walrus, comprehension typing) | 65 | 16 | 13 | 81% | 2.74 ms |
|
||||
| 2026-05-09 | e7dcadc (Round 3: match/case, await, expanded fixture to 136 LOC) | 136 | 38 | 30 | 79% | 7.50 ms |
|
||||
| 2026-05-09 | 4b99389 (Round 4: instance attribute typing) | 136 | 38 | 36 | 95% | 8.06 ms |
|
||||
| 2026-05-09 | ce54e81 (Round 5: subscript, super().__init__, operators) | 136 | 38 | 36 | 95% | — |
|
||||
| 2026-05-09 | fd81dff (Round 6: decorator flags, generator/dataclass/property tests) | 136 | 38 | 36 | 95% | — |
|
||||
| 2026-05-09 | dd3a816 (Round 7: builtin/template attr dispatch, wrappers, dedup) | 178 | 52 | 51 | 98% | 11.79 ms |
|
||||
| 2026-05-09 | f35745c (Round 8: with-as, except-as, tuple unpack, dict.items, slice) | 178 | 52 | 52 | **100%** | 11.59 ms |
|
||||
| 2026-05-09 | 4ccd08c (Round 9: TypedDict, early-return narrowing, match seq pattern) | 178 | 52 | 52 | **100%** | 11.02 ms |
|
||||
| 2026-05-09 | 39f41c3 (Round 10: lambda, dict-dispatch, async, callable, next, kwargs + 21 new stress tests) | 178 | 52 | 52 | **100%** | 11.25 ms |
|
||||
|
||||
The 65→136 fixture jump is more honest: the small fixture was
|
||||
hand-shaped to match what the resolver did well at the time. The
|
||||
136-line fixture covers the full parity surface — narrowing
|
||||
(isinstance / `is not None` / walrus), generic containers and
|
||||
comprehensions, fluent `Self` chains, `super()`, async/await,
|
||||
match/case dispatch, classmethod / staticmethod, common stdlib calls,
|
||||
and instance attributes via both `self.x = expr` in `__init__` and
|
||||
class-body `x: T` annotations.
|
||||
|
||||
The remaining 5% gap (2/38 unresolved calls) hits cases that genuinely
|
||||
need a constraint solver — see "Stopping Point" below.
|
||||
|
||||
## Stopping Point — what we deliberately did not build
|
||||
|
||||
Per the user directive "stop if any addition would realistically mean
|
||||
that we need to kinda rebuild a compiler or whatever, but otherwise
|
||||
pursue parity," the following items are out of scope for v1 and would
|
||||
require building infrastructure equivalent to a full Python type
|
||||
checker:
|
||||
|
||||
1. **Constraint solver / bidirectional inference.** Pyright's
|
||||
`constraintSolver.ts` is ~3,000 lines of bidirectional type
|
||||
inference. Required for: full overload resolution by argument type,
|
||||
inferring callable types from context, ParamSpec resolution.
|
||||
2. **Code-flow type narrowing across basic blocks.** Pyright's
|
||||
`codeFlowEngine.ts` tracks types through every basic block,
|
||||
handling early returns, exception flow, generator flow.
|
||||
Equivalent to building an SSA-style analysis for Python.
|
||||
3. **`ParamSpec` / `TypeVarTuple` / `Concatenate` resolution.** These
|
||||
PEP 612 / 646 / 696 features are how decorator-rewrapping and
|
||||
variadic generics work properly. Without them, `@functools.wraps`
|
||||
collapses to identity — fine for our call resolution but not
|
||||
parity with type checkers.
|
||||
4. **Custom metaclasses' `__call__`.** A class with a custom
|
||||
metaclass can short-circuit `Class()` to call something else
|
||||
entirely. Pyright handles this by recognizing common metaclass
|
||||
patterns; full support means tracking metaclass MRO.
|
||||
5. **Descriptor protocol beyond `@property` / `@classmethod` /
|
||||
`@staticmethod`.** Custom descriptors via `__get__` / `__set__` /
|
||||
`__delete__` change attribute semantics arbitrarily. Common in
|
||||
ORMs (SQLAlchemy column objects), Pydantic, attrs.
|
||||
6. **Plugin protocols.** SQLAlchemy declarative classes, Pydantic
|
||||
models, attrs / dataclasses-style libraries — Pyright ships
|
||||
special-case "plugins" for each. Each is hundreds of lines.
|
||||
7. **Recursive types and self-referential generics.** `tree:
|
||||
Node[Node[Node[...]]]` resolution requires lazy type evaluation.
|
||||
8. **Runtime monkey-patching, `eval`/`exec`, `setattr` with
|
||||
non-literal name.** No static analyzer handles these well; they're
|
||||
fundamentally dynamic.
|
||||
9. **Per-symbol typeshed version guards in the stdlib generator.**
|
||||
The plan called for `min_python_version` / `max_python_version`
|
||||
tracking. v1 flattens version branches (union of all). Adding
|
||||
per-version filtering requires propagating the project's target
|
||||
Python version through the resolver — non-trivial threading
|
||||
change.
|
||||
|
||||
Items 1, 2, 3, 5, 7 are direct compiler-rebuild territory. Items 4,
|
||||
6 need significant per-pattern engineering. Item 8 is impossible
|
||||
statically. Item 9 is achievable but a separate v1.1 task.
|
||||
|
||||
## Stress-test surface (test_py_lsp_stress.c)
|
||||
|
||||
**43 advanced patterns probed individually. All hard-asserted as PASS. Zero remaining KNOWN GAPs.**
|
||||
|
||||
**Hard-asserted (PASS clean)**:
|
||||
- NamedTuple class form
|
||||
- TypedDict literal-key subscript
|
||||
- Protocol structural method dispatch
|
||||
- ABC + @abstractmethod
|
||||
- Context manager `with X() as f:` via `__enter__`
|
||||
- Exception handler `except E as e:`
|
||||
- Post-early-return narrowing (`if not isinstance: return`)
|
||||
- Tuple unpacking from function return
|
||||
- Comprehension over `dict.items()` with k, v unpacking
|
||||
- List slice returns list of same element type
|
||||
- Decorator factory with closures
|
||||
- Property setter
|
||||
- Self-type in inheritance chain (Builder pattern)
|
||||
- Diamond inheritance (C3 MRO)
|
||||
- Recursive self-referencing types
|
||||
- Nested closure capturing outer variable
|
||||
- Long method chain via Self
|
||||
- Generator delegation (`yield from`)
|
||||
- Async-generator iteration
|
||||
|
||||
**Round 10 additions (21 new patterns, all PASS)**:
|
||||
- SQLAlchemy 2.0-style Mapped[T] field
|
||||
- Pydantic-style BaseModel field
|
||||
- chained filter / map / comprehension
|
||||
- nested function call with annotated returns
|
||||
- Optional chain with walrus + None narrow
|
||||
- classmethod chained to instance method via Self
|
||||
- multi-assignment from explicit tuple
|
||||
- dict.get with default
|
||||
- @property attribute chain to method
|
||||
- async with + async for via __aenter__ / __anext__
|
||||
- recursive self-referencing method
|
||||
- Callable[[], R] return-value evaluation
|
||||
- TypedDict total=False
|
||||
- nested match patterns with multiple class arms
|
||||
- Protocol via NAMED class
|
||||
- next(iter) returning element type
|
||||
- **kwargs: T -> dict[str, T] annotation
|
||||
- yield from in chained generator
|
||||
- isinstance | branches
|
||||
- module-level constants
|
||||
- dataclass with default_factory
|
||||
|
||||
**Round 10 gap closures**: lambda call-site inference, function-as-
|
||||
dict-value dispatch, match sequence pattern element typing — all now
|
||||
hard-asserted PASS.
|
||||
|
||||
## Achievable next steps (not undertaken in this iteration)
|
||||
|
||||
These would push past 95% on the bench fixture but stay below the
|
||||
"compiler rebuild" line:
|
||||
|
||||
- **Decorator return-type substitution for the simple case.** When a
|
||||
decorator `D` is registered with return annotation `Callable[..., R]`
|
||||
or just a function-typed return, the decorated function's effective
|
||||
return is the decorator's return. Doesn't need ParamSpec for the
|
||||
identity / wrapper pattern.
|
||||
- **Overload selection by literal argument.** Many stdlib functions
|
||||
have overloads keyed on literal flags (`subprocess.run(..., check=True)`,
|
||||
`re.compile(..., flags=...)`, `json.loads`). The current generator
|
||||
collapses overloads; selection at call site by matching literal arg
|
||||
values would resolve the right overload.
|
||||
- **`super().__init__(...)` constructor resolution.** Currently
|
||||
`super()` in __init__ may not resolve to the parent's __init__
|
||||
because __init__ isn't typically registered with a return type
|
||||
worth chaining. A small special-case would emit the constructor
|
||||
edge.
|
||||
- **Dict subscript value typing.** `self.cache[key]` should type as
|
||||
the dict's value type. Currently UNKNOWN. Easy to add given
|
||||
TEMPLATE("dict", [K, V]) is already extracted from annotations.
|
||||
- **`@property` getter return type.** Currently we return the method's
|
||||
return type when accessing a method as an attribute (which happens
|
||||
to be right for properties and rarely wrong for regular methods,
|
||||
per the discussion in the Round 1 commit). A real fix tracks
|
||||
decorator info on registered methods.
|
||||
|
||||
These are within reach without compiler-level infrastructure and
|
||||
would push the bench from 95% toward ~98%.
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776255774,
|
||||
"narHash": "sha256-psVTpH6PK3q1htMJpmdz1hLF5pQgEshu7gQWgKO6t6Y=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "566acc07c54dc807f91625bb286cb9b321b5f42a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
description = "codebase-memory-mcp — C11 MCP server for codebase indexing";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (pkgs: {
|
||||
default = pkgs.stdenv.mkDerivation {
|
||||
pname = "codebase-memory-mcp";
|
||||
version = "0.6.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ pkgs.gnumake ];
|
||||
buildInputs = [ pkgs.zlib ];
|
||||
|
||||
# scripts/build.sh verifies the compiler via `file`, which fails on Nix
|
||||
# because CC is a bash wrapper script rather than a binary. Call make
|
||||
# directly to bypass that check; the Nix stdenv already guarantees the
|
||||
# correct compiler and target architecture.
|
||||
buildPhase = ''
|
||||
make -j$NIX_BUILD_CORES -f Makefile.cbm cbm
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
install -Dm755 build/c/codebase-memory-mcp $out/bin/codebase-memory-mcp
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "MCP server that builds and queries a semantic graph of your codebase";
|
||||
homepage = "https://github.com/DeusData/codebase-memory-mcp";
|
||||
license = nixpkgs.lib.licenses.mit;
|
||||
mainProgram = "codebase-memory-mcp";
|
||||
platforms = systems;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${pkgs.system}.default ];
|
||||
# libgit2 is an optional dependency auto-detected via pkg-config at
|
||||
# build time. When present it accelerates git history parsing;
|
||||
# otherwise the build falls back to shelling out to `git log`.
|
||||
packages = [ pkgs.pkg-config pkgs.libgit2 ];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "lsp/go_lsp.h"
|
||||
#include "lsp/c_lsp.h"
|
||||
#include "lsp/php_lsp.h"
|
||||
#include "lsp/py_lsp.h"
|
||||
#include "preprocessor.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "tree_sitter/api.h" // TSParser, TSNode, TSTree, TSInput, TSLanguage, TSPoint, TSParseOptions, TSParseState
|
||||
@@ -361,6 +362,9 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage
|
||||
if (language == CBM_LANG_PHP) {
|
||||
cbm_run_php_lsp(a, result, source, source_len, root);
|
||||
}
|
||||
if (language == CBM_LANG_PYTHON) {
|
||||
cbm_run_py_lsp(a, result, source, source_len, root);
|
||||
}
|
||||
atomic_fetch_add(&total_lsp_ns, now_ns() - lsp_start);
|
||||
|
||||
// Second pass: preprocess C/C++/CUDA and extract additional macro-hidden calls.
|
||||
|
||||
@@ -166,6 +166,7 @@ typedef enum {
|
||||
CBM_LANG_SOSL,
|
||||
CBM_LANG_KUSTOMIZE, // kustomization.yaml — Kubernetes overlay tool
|
||||
CBM_LANG_K8S, // Generic Kubernetes manifest (apiVersion: detected)
|
||||
CBM_LANG_PINE, // Pine Script (TradingView indicator / strategy language)
|
||||
CBM_LANG_COUNT
|
||||
} CBMLanguage;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "lang_specs.h"
|
||||
#include "extract_unified.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include <stdint.h> // uint32_t
|
||||
#include <string.h>
|
||||
@@ -396,14 +397,13 @@ static const char *extract_first_string_arg(CBMExtractCtx *ctx, TSNode args) {
|
||||
}
|
||||
|
||||
// Walk AST for call nodes (iterative)
|
||||
#define CALLS_STACK_CAP CBM_SZ_512
|
||||
static void walk_calls(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[CALLS_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_512);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
const char *kind = ts_node_type(node);
|
||||
|
||||
if (cbm_kind_in_set(node, spec->call_node_types)) {
|
||||
@@ -430,8 +430,8 @@ static void walk_calls(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec)
|
||||
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
enum { LAST_IDX_OFFSET = 1 };
|
||||
for (int i = (int)(count - LAST_IDX_OFFSET); i >= 0 && top < CALLS_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)(count - LAST_IDX_OFFSET); i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,15 @@
|
||||
#include "arena.h"
|
||||
#include "helpers.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
#include "tree_sitter/api.h"
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
enum {
|
||||
CHAN_CONST_CAP = 256, /* max tracked identifiers per file */
|
||||
CHAN_STACK_CAP = 4096, /* tree-walk stack */
|
||||
CHAN_IDENT_MAX = 128, /* max identifier length tracked */
|
||||
CHAN_STACK_CAP = 4096, /* traversal stack depth per walk */
|
||||
CHAN_DIR_UNKNOWN = -1, /* unrecognized method → no channel */
|
||||
};
|
||||
|
||||
@@ -93,15 +94,18 @@ static const char *literal_from_first_child(CBMExtractCtx *ctx, TSNode node) {
|
||||
|
||||
/* ── Constant resolution table ──────────────────────────────────── */
|
||||
|
||||
/* Walk the whole tree once and collect constant string bindings so later
|
||||
* passes can resolve bare-identifier channel arguments. */
|
||||
/* Walk the whole tree once and collect `const IDENT = "value"` bindings so
|
||||
* later passes can resolve bare-identifier channel arguments. Only scalar
|
||||
* string literals are tracked — template literals and expressions are left
|
||||
* unresolved. This is a flat lookup; scope boundaries are ignored (a single
|
||||
* const table per file is sufficient for the common Socket.IO pattern). */
|
||||
static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) {
|
||||
TSNode stack[CHAN_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = ctx->root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP);
|
||||
ts_nstack_push(&stack, ctx->arena, ctx->root);
|
||||
|
||||
while (top > 0 && tbl->count < CHAN_CONST_CAP) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0 && tbl->count < CHAN_CONST_CAP) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
const char *kind = ts_node_type(node);
|
||||
|
||||
if (strcmp(kind, "variable_declarator") == 0) {
|
||||
@@ -125,8 +129,8 @@ static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) {
|
||||
}
|
||||
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0 && top < CHAN_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,18 +374,19 @@ static void extract_channels_js(CBMExtractCtx *ctx) {
|
||||
chan_const_table_t consts = {0};
|
||||
scan_string_consts_js(ctx, &consts);
|
||||
|
||||
TSNode stack[CHAN_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = ctx->root;
|
||||
/* Second pass: walk the tree looking for call_expression nodes. */
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP);
|
||||
ts_nstack_push(&stack, ctx->arena, ctx->root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
if (strcmp(ts_node_type(node), "call_expression") == 0) {
|
||||
js_process_call(ctx, node, &consts);
|
||||
}
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0 && top < CHAN_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+134
-35
@@ -3,6 +3,7 @@
|
||||
#include "helpers.h"
|
||||
#include "lang_specs.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
#include "simhash/minhash.h"
|
||||
#include "semantic/ast_profile.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
@@ -1083,6 +1084,83 @@ static const char **extract_csharp_base_list(CBMArena *a, TSNode node, const cha
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Walk a field node and collect type identifier names into out[].
|
||||
// Handles: direct type_identifier/generic_type/qualified_name, type_list children
|
||||
// (Java interfaces list), and raw text fallback (other languages).
|
||||
static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char *source,
|
||||
const char **out, int out_cap) {
|
||||
int count = 0;
|
||||
const char *fk = ts_node_type(field_node);
|
||||
|
||||
// If the field node itself is a type node, extract directly.
|
||||
if (strcmp(fk, "type_identifier") == 0 || strcmp(fk, "generic_type") == 0 ||
|
||||
strcmp(fk, "qualified_name") == 0 || strcmp(fk, "scoped_type_identifier") == 0 ||
|
||||
strcmp(fk, "user_type") == 0) {
|
||||
char *t = cbm_node_text(a, field_node, source);
|
||||
if (t) {
|
||||
char *angle = strchr(t, '<');
|
||||
if (angle) {
|
||||
*angle = '\0';
|
||||
}
|
||||
if (t[0] && count < out_cap) {
|
||||
out[count++] = t;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Walk named children: look for type identifiers or type_list/interface_type_list.
|
||||
uint32_t nc = ts_node_named_child_count(field_node);
|
||||
for (uint32_t i = 0; i < nc && count < out_cap; i++) {
|
||||
TSNode child = ts_node_named_child(field_node, i);
|
||||
const char *ck = ts_node_type(child);
|
||||
if (strcmp(ck, "type_identifier") == 0 || strcmp(ck, "generic_type") == 0 ||
|
||||
strcmp(ck, "qualified_name") == 0 || strcmp(ck, "scoped_type_identifier") == 0 ||
|
||||
strcmp(ck, "user_type") == 0) {
|
||||
char *t = cbm_node_text(a, child, source);
|
||||
if (t) {
|
||||
char *angle = strchr(t, '<');
|
||||
if (angle) {
|
||||
*angle = '\0';
|
||||
}
|
||||
if (t[0]) {
|
||||
out[count++] = t;
|
||||
}
|
||||
}
|
||||
} else if (strcmp(ck, "type_list") == 0 || strcmp(ck, "interface_type_list") == 0) {
|
||||
// Java: super_interfaces contains type_list with multiple type_identifiers.
|
||||
uint32_t tlnc = ts_node_named_child_count(child);
|
||||
for (uint32_t ti = 0; ti < tlnc && count < out_cap; ti++) {
|
||||
TSNode tl_child = ts_node_named_child(child, ti);
|
||||
const char *tlk = ts_node_type(tl_child);
|
||||
if (strcmp(tlk, "type_identifier") == 0 || strcmp(tlk, "generic_type") == 0 ||
|
||||
strcmp(tlk, "qualified_name") == 0) {
|
||||
char *t = cbm_node_text(a, tl_child, source);
|
||||
if (t) {
|
||||
char *angle = strchr(t, '<');
|
||||
if (angle) {
|
||||
*angle = '\0';
|
||||
}
|
||||
if (t[0]) {
|
||||
out[count++] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: raw node text (for languages where the field node is the type name directly).
|
||||
if (count == 0) {
|
||||
char *t = cbm_node_text(a, field_node, source);
|
||||
if (t && t[0] && count < out_cap) {
|
||||
out[count++] = t;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Extract base class names from a class node.
|
||||
static const char **extract_base_classes(CBMArena *a, TSNode node, const char *source,
|
||||
CBMLanguage lang) {
|
||||
@@ -1096,13 +1174,28 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s
|
||||
"delegation_specifiers",
|
||||
NULL};
|
||||
|
||||
// Collect all bases from all matching fields (fixes early-return bug and keyword-text bug).
|
||||
const char *bases[MAX_BASES];
|
||||
int base_count = 0;
|
||||
|
||||
for (const char **f = fields; *f; f++) {
|
||||
TSNode super = ts_node_child_by_field_name(node, *f, (uint32_t)strlen(*f));
|
||||
if (!ts_node_is_null(super)) {
|
||||
const char **r = make_single_base(a, cbm_node_text(a, super, source));
|
||||
if (r) {
|
||||
return r;
|
||||
base_count += collect_bases_from_field(a, super, source,
|
||||
bases + base_count,
|
||||
MAX_BASES_MINUS_1 - base_count);
|
||||
}
|
||||
}
|
||||
|
||||
if (base_count > 0) {
|
||||
const char **result =
|
||||
(const char **)cbm_arena_alloc(a, (base_count + NULL_TERM) * sizeof(const char *));
|
||||
if (result) {
|
||||
for (int i = 0; i < base_count; i++) {
|
||||
result[i] = bases[i];
|
||||
}
|
||||
result[base_count] = NULL;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2123,16 +2216,28 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cbm_kind_in_set(child, spec->function_node_types)) {
|
||||
// Python wraps @classmethod / @staticmethod / @property methods in
|
||||
// a decorated_definition node. Peek through it to find the inner
|
||||
// function_definition so we still emit a Method entry.
|
||||
TSNode method_node = child;
|
||||
if (strcmp(ts_node_type(child), "decorated_definition") == 0) {
|
||||
TSNode def = ts_node_child_by_field_name(child, TS_FIELD("definition"));
|
||||
if (ts_node_is_null(def) || !cbm_kind_in_set(def, spec->function_node_types)) {
|
||||
continue;
|
||||
}
|
||||
method_node = def;
|
||||
}
|
||||
|
||||
if (!cbm_kind_in_set(method_node, spec->function_node_types)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
TSNode name_node = resolve_method_name(child, ctx->language);
|
||||
TSNode name_node = resolve_method_name(method_node, ctx->language);
|
||||
if (ts_node_is_null(name_node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
push_method_def(ctx, child, class_qn, spec, name_node);
|
||||
push_method_def(ctx, method_node, class_qn, spec, name_node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2299,15 +2404,14 @@ static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) {
|
||||
|
||||
// Process Elixir call nodes iteratively — handles defmodule/def/defp/defmacro
|
||||
// without recursion between extract_elixir_call ↔ extract_elixir_module_def.
|
||||
#define ELIXIR_STACK_CAP CBM_SZ_64
|
||||
static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
|
||||
(void)spec;
|
||||
TSNode stack[ELIXIR_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = node;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_64);
|
||||
ts_nstack_push(&stack, ctx->arena, node);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode cur = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode cur = ts_nstack_pop(&stack);
|
||||
CBMArena *a = ctx->arena;
|
||||
|
||||
if (ts_node_child_count(cur) == 0) {
|
||||
@@ -2329,10 +2433,10 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp
|
||||
TSNode do_block = emit_elixir_module_class(ctx, cur);
|
||||
if (!ts_node_is_null(do_block)) {
|
||||
uint32_t dbc = ts_node_child_count(do_block);
|
||||
for (int di = (int)dbc - SKIP_CHAR; di >= 0 && top < ELIXIR_STACK_CAP; di--) {
|
||||
for (int di = (int)dbc - SKIP_CHAR; di >= 0; di--) {
|
||||
TSNode dchild = ts_node_child(do_block, (uint32_t)di);
|
||||
if (!ts_node_is_null(dchild) && strcmp(ts_node_type(dchild), "call") == 0) {
|
||||
stack[top++] = dchild;
|
||||
ts_nstack_push(&stack, ctx->arena, dchild);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3051,14 +3155,13 @@ static void extract_var_names(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
|
||||
|
||||
// Iterative variable walker for config languages with nested structure.
|
||||
// Used by YAML, TOML, INI, JSON.
|
||||
#define VAR_WALK_STACK_CAP CBM_SZ_256
|
||||
static void walk_variables_iter(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[VAR_WALK_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_256);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_CHAR; i >= 0; i--) {
|
||||
TSNode child = ts_node_child(node, (uint32_t)i);
|
||||
@@ -3077,9 +3180,7 @@ static void walk_variables_iter(CBMExtractCtx *ctx, TSNode root, const CBMLangSp
|
||||
strcmp(ck, "section") == 0 || strcmp(ck, "object") == 0 ||
|
||||
strcmp(ck, "array") == 0 || strcmp(ck, "pair") == 0 || strcmp(ck, "element") == 0 ||
|
||||
strcmp(ck, "content") == 0) {
|
||||
if (top < VAR_WALK_STACK_CAP) {
|
||||
stack[top++] = child;
|
||||
}
|
||||
ts_nstack_push(&stack, ctx->arena, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3275,13 +3376,13 @@ typedef struct {
|
||||
// Push nested class nodes from a class body container onto the defs stack.
|
||||
// Iteratively walks into wrapper nodes (field_declaration, template_declaration).
|
||||
static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, walk_defs_frame_t *stack,
|
||||
int *top, const char *enclosing_qn) {
|
||||
TSNode nc_stack[NESTED_CLASS_STACK_CAP];
|
||||
int nc_top = 0;
|
||||
nc_stack[nc_top++] = body;
|
||||
int *top, const char *enclosing_qn, CBMArena *arena) {
|
||||
TSNodeStack nc_stack;
|
||||
ts_nstack_init(&nc_stack, arena, NESTED_CLASS_STACK_CAP);
|
||||
ts_nstack_push(&nc_stack, arena, body);
|
||||
|
||||
while (nc_top > 0) {
|
||||
TSNode cur = nc_stack[--nc_top];
|
||||
while (nc_stack.count > 0) {
|
||||
TSNode cur = ts_nstack_pop(&nc_stack);
|
||||
uint32_t nc = ts_node_child_count(cur);
|
||||
for (int i = (int)nc - SKIP_CHAR; i >= 0; i--) {
|
||||
TSNode child = ts_node_child(cur, (uint32_t)i);
|
||||
@@ -3293,9 +3394,7 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, walk_d
|
||||
const char *ck = ts_node_type(child);
|
||||
if (strcmp(ck, "field_declaration") == 0 ||
|
||||
strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0) {
|
||||
if (nc_top < NESTED_CLASS_STACK_CAP) {
|
||||
nc_stack[nc_top++] = child;
|
||||
}
|
||||
ts_nstack_push(&nc_stack, arena, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3342,7 +3441,7 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const char
|
||||
|
||||
// Push nested class children from a class body container onto the walk stack.
|
||||
static void push_class_body_children(TSNode node, const CBMLangSpec *spec, walk_defs_frame_t *stack,
|
||||
int *top, const char *new_enclosing) {
|
||||
int *top, const char *new_enclosing, CBMArena *arena) {
|
||||
uint32_t nc = ts_node_child_count(node);
|
||||
for (uint32_t ci = 0; ci < nc; ci++) {
|
||||
TSNode child = ts_node_child(node, ci);
|
||||
@@ -3350,7 +3449,7 @@ static void push_class_body_children(TSNode node, const CBMLangSpec *spec, walk_
|
||||
if (strcmp(ck, "field_declaration_list") == 0 || strcmp(ck, "class_body") == 0 ||
|
||||
strcmp(ck, "declaration_list") == 0 || strcmp(ck, "body") == 0 ||
|
||||
strcmp(ck, "block") == 0 || strcmp(ck, "suite") == 0) {
|
||||
push_nested_class_nodes(child, spec, stack, top, new_enclosing);
|
||||
push_nested_class_nodes(child, spec, stack, top, new_enclosing, arena);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3394,7 +3493,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
|
||||
if (cbm_kind_in_set(node, spec->class_node_types)) {
|
||||
extract_class_def(ctx, node, spec);
|
||||
const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn);
|
||||
push_class_body_children(node, spec, stack, &top, new_enclosing);
|
||||
push_class_body_children(node, spec, stack, &top, new_enclosing, ctx->arena);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "lang_specs.h"
|
||||
#include "extract_unified.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include <stdint.h> // uint32_t
|
||||
#include <string.h>
|
||||
@@ -137,14 +138,13 @@ static bool is_env_var_name(const char *s) {
|
||||
}
|
||||
|
||||
// Iterative env access walker — explicit stack
|
||||
#define ENV_STACK_CAP 4096
|
||||
static void walk_env_accesses(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[ENV_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, 4096);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
const char *kind = ts_node_type(node);
|
||||
const char *env_key = NULL;
|
||||
|
||||
@@ -165,8 +165,8 @@ static void walk_env_accesses(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec
|
||||
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
enum { LAST_IDX = 1 };
|
||||
for (int i = (int)count - LAST_IDX; i >= 0 && top < ENV_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
-16
@@ -1,8 +1,10 @@
|
||||
#include "cbm.h"
|
||||
#include "arena.h" // CBMArena, cbm_arena_strdup/strndup/sprintf
|
||||
#include "helpers.h"
|
||||
#include "lang_specs.h" // CBMLangSpec, CBMEmbeddedLangSpec, cbm_lang_spec, cbm_ts_language
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
#include <stdint.h> // uint32_t
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
@@ -411,14 +413,13 @@ static bool process_commonjs_require(CBMExtractCtx *ctx, TSNode call) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#define ES_IMPORT_STACK_CAP CBM_SZ_512
|
||||
static void walk_es_imports(CBMExtractCtx *ctx, TSNode root) {
|
||||
TSNode stack[ES_IMPORT_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_512);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
const char *kind = ts_node_type(node);
|
||||
bool push_children = true;
|
||||
|
||||
@@ -436,8 +437,8 @@ static void walk_es_imports(CBMExtractCtx *ctx, TSNode root) {
|
||||
|
||||
if (push_children) {
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0 && top < ES_IMPORT_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -820,14 +821,13 @@ static void process_wolfram_needs(CBMExtractCtx *ctx, TSNode node) {
|
||||
}
|
||||
}
|
||||
|
||||
#define WOLFRAM_IMPORT_STACK_CAP CBM_SZ_512
|
||||
static void walk_wolfram_imports(CBMExtractCtx *ctx, TSNode root) {
|
||||
TSNode stack[WOLFRAM_IMPORT_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_512);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
const char *kind = ts_node_type(node);
|
||||
|
||||
if (strcmp(kind, "get_top") == 0) {
|
||||
@@ -837,8 +837,8 @@ static void walk_wolfram_imports(CBMExtractCtx *ctx, TSNode root) {
|
||||
}
|
||||
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0 && top < WOLFRAM_IMPORT_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -847,6 +847,99 @@ static void parse_wolfram_imports(CBMExtractCtx *ctx) {
|
||||
walk_wolfram_imports(ctx, ctx->root);
|
||||
}
|
||||
|
||||
// --- Embedded-language imports ---
|
||||
// Generic walker for host grammars (Svelte, Vue, HTML, Astro, ...) whose AST
|
||||
// keeps embedded sub-language source as raw_text (or similar) without parsing
|
||||
// it. The host's CBMLangSpec.embedded_imports declares which content nodes
|
||||
// hold which sub-language; we re-parse each match with the embedded grammar
|
||||
// and run the standard ES import walker over the inner AST.
|
||||
//
|
||||
// No grammar symbols are referenced here — the embedded TSLanguage is
|
||||
// resolved through cbm_ts_language(spec->embedded_language), the same hook
|
||||
// that the main parser uses. Adding another host language is a one-line
|
||||
// declaration in lang_specs.c.
|
||||
|
||||
static void embedded_collect_content_nodes(TSNode root, const CBMEmbeddedLangSpec *spec,
|
||||
TSNode *out, int *out_count, int max_out) {
|
||||
/* Iterative DFS so deeply-nested script blocks are still found. Cap the
|
||||
* stack to a sane bound (host grammars do not have million-deep markup
|
||||
* trees) — no need to introduce TSNodeStack here. */
|
||||
enum { EMBED_STACK_CAP = 1024 };
|
||||
TSNode stack[EMBED_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
while (top > 0 && *out_count < max_out) {
|
||||
TSNode node = stack[--top];
|
||||
const char *kind = ts_node_type(node);
|
||||
if (strcmp(kind, spec->script_node_type) == 0) {
|
||||
uint32_t cc = ts_node_child_count(node);
|
||||
for (uint32_t k = 0; k < cc; k++) {
|
||||
TSNode c = ts_node_child(node, k);
|
||||
if (strcmp(ts_node_type(c), spec->content_node_type) == 0) {
|
||||
out[(*out_count)++] = c;
|
||||
if (*out_count >= max_out) {
|
||||
return;
|
||||
}
|
||||
break; /* one content node per script element */
|
||||
}
|
||||
}
|
||||
/* Do not descend into <script>'s children — content already taken. */
|
||||
continue;
|
||||
}
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - 1; i >= 0 && top < EMBED_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void parse_embedded_imports(CBMExtractCtx *ctx) {
|
||||
const CBMLangSpec *spec = cbm_lang_spec(ctx->language);
|
||||
if (!spec || !spec->embedded_imports) {
|
||||
return;
|
||||
}
|
||||
for (const CBMEmbeddedLangSpec *e = spec->embedded_imports; e->script_node_type != NULL; e++) {
|
||||
const TSLanguage *embedded_lang = cbm_ts_language(e->embedded_language);
|
||||
if (!embedded_lang) {
|
||||
continue; /* embedded grammar not linked in — silently skip */
|
||||
}
|
||||
enum { MAX_EMBEDDED_BLOCKS = 16 };
|
||||
TSNode hits[MAX_EMBEDDED_BLOCKS];
|
||||
int hit_count = 0;
|
||||
embedded_collect_content_nodes(ctx->root, e, hits, &hit_count, MAX_EMBEDDED_BLOCKS);
|
||||
if (hit_count == 0) {
|
||||
continue;
|
||||
}
|
||||
TSParser *parser = ts_parser_new();
|
||||
if (!parser) {
|
||||
continue;
|
||||
}
|
||||
if (!ts_parser_set_language(parser, embedded_lang)) {
|
||||
ts_parser_delete(parser);
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < hit_count; i++) {
|
||||
uint32_t s = ts_node_start_byte(hits[i]);
|
||||
uint32_t end = ts_node_end_byte(hits[i]);
|
||||
if (end <= s) {
|
||||
continue;
|
||||
}
|
||||
const char *sub_src = ctx->source + s;
|
||||
uint32_t sub_len = end - s;
|
||||
TSTree *sub_tree = ts_parser_parse_string(parser, NULL, sub_src, sub_len);
|
||||
if (!sub_tree) {
|
||||
continue;
|
||||
}
|
||||
CBMExtractCtx sub_ctx = *ctx;
|
||||
sub_ctx.source = sub_src;
|
||||
sub_ctx.root = ts_tree_root_node(sub_tree);
|
||||
walk_es_imports(&sub_ctx, sub_ctx.root);
|
||||
ts_tree_delete(sub_tree);
|
||||
}
|
||||
ts_parser_delete(parser);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main dispatch ---
|
||||
|
||||
void cbm_extract_imports(CBMExtractCtx *ctx) {
|
||||
@@ -938,6 +1031,14 @@ void cbm_extract_imports(CBMExtractCtx *ctx) {
|
||||
case CBM_LANG_WOLFRAM:
|
||||
parse_wolfram_imports(ctx);
|
||||
break;
|
||||
/* Host languages whose tree-sitter grammar leaves <script> bodies as raw
|
||||
* text — re-parse the embedded slice via the embedded-language spec. */
|
||||
case CBM_LANG_SVELTE:
|
||||
case CBM_LANG_VUE:
|
||||
case CBM_LANG_HTML:
|
||||
case CBM_LANG_ASTRO:
|
||||
parse_embedded_imports(ctx);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* extract_node_stack.h — Growable TSNode stack for AST traversal.
|
||||
*
|
||||
* Replaces fixed-size TSNode stack[] arrays that silently drop AST subtrees
|
||||
* when the stack overflows (GitHub issue #199).
|
||||
*
|
||||
* Uses the arena allocator for zero-fragmentation growth: old blocks are
|
||||
* abandoned (freed when the arena is destroyed at end of file extraction).
|
||||
* Initial capacity matches the previous fixed caps so small files allocate
|
||||
* no extra memory.
|
||||
*/
|
||||
#ifndef CBM_EXTRACT_NODE_STACK_H
|
||||
#define CBM_EXTRACT_NODE_STACK_H
|
||||
|
||||
#include "arena.h"
|
||||
#include "tree_sitter/api.h"
|
||||
#include <string.h> /* memcpy */
|
||||
|
||||
typedef struct {
|
||||
TSNode *items;
|
||||
int count;
|
||||
int cap;
|
||||
} TSNodeStack;
|
||||
|
||||
/* Initialize a stack with the given initial capacity, arena-allocated. */
|
||||
static inline void ts_nstack_init(TSNodeStack *s, CBMArena *arena, int initial_cap) {
|
||||
s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode));
|
||||
s->count = 0;
|
||||
s->cap = s->items ? initial_cap : 0;
|
||||
}
|
||||
|
||||
/* Push a node onto the stack, growing 2x if needed. */
|
||||
static inline void ts_nstack_push(TSNodeStack *s, CBMArena *arena, TSNode node) {
|
||||
if (s->count >= s->cap) {
|
||||
int new_cap = s->cap ? s->cap * 2 : 512;
|
||||
TSNode *new_items = (TSNode *)cbm_arena_alloc(arena, (size_t)new_cap * sizeof(TSNode));
|
||||
if (!new_items) return; /* OOM: best-effort, stop growing */
|
||||
if (s->items && s->count > 0) {
|
||||
memcpy(new_items, s->items, (size_t)s->count * sizeof(TSNode));
|
||||
}
|
||||
/* Old s->items is abandoned in the arena — freed on arena_destroy. */
|
||||
s->items = new_items;
|
||||
s->cap = new_cap;
|
||||
}
|
||||
s->items[s->count++] = node;
|
||||
}
|
||||
|
||||
/* Pop a node from the stack. Caller must check s->count > 0. */
|
||||
static inline TSNode ts_nstack_pop(TSNodeStack *s) {
|
||||
return s->items[--s->count];
|
||||
}
|
||||
|
||||
#endif /* CBM_EXTRACT_NODE_STACK_H */
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "extract_unified.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
|
||||
enum { MAX_EXCEPTION_NAME_LEN = 100, LAST_IDX = 1 };
|
||||
#include <stdint.h> // uint32_t
|
||||
@@ -102,17 +103,16 @@ static void process_throw_node(CBMExtractCtx *ctx, TSNode node, const CBMLangSpe
|
||||
}
|
||||
|
||||
// Iterative throw walker
|
||||
#define THROWS_STACK_CAP CBM_SZ_512
|
||||
static void walk_throws(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[THROWS_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_512);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
process_throw_node(ctx, node, spec);
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0 && top < THROWS_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,19 +144,18 @@ static void try_emit_assignment_write(CBMExtractCtx *ctx, TSNode node, const cha
|
||||
}
|
||||
}
|
||||
|
||||
#define READWRITE_STACK_CAP CBM_SZ_512
|
||||
static void walk_readwrites(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[READWRITE_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, CBM_SZ_512);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
if (cbm_kind_in_set(node, spec->assignment_node_types)) {
|
||||
try_emit_assignment_write(ctx, node, cbm_enclosing_func_qn_cached(ctx, node));
|
||||
}
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0 && top < READWRITE_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "extract_unified.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
#include <stdint.h> // uint32_t
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
@@ -164,18 +165,17 @@ static void process_type_assign_node(CBMExtractCtx *ctx, TSNode node, const CBML
|
||||
}
|
||||
|
||||
// Walk AST for assignment patterns where RHS is a constructor call.
|
||||
#define TYPE_ASSIGN_STACK_CAP 4096
|
||||
static void walk_type_assigns(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[TYPE_ASSIGN_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, 4096);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
process_type_assign_node(ctx, node, spec, cbm_enclosing_func_qn_cached(ctx, node));
|
||||
enum { LAST_IDX = 1 };
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0 && top < TYPE_ASSIGN_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "extract_unified.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
|
||||
enum { MIN_TYPE_LEN = 1 };
|
||||
|
||||
@@ -242,17 +243,16 @@ static void process_body_type_ref(CBMExtractCtx *ctx, TSNode node, const char *f
|
||||
}
|
||||
|
||||
// Walk function body for type references (casts, type assertions, local var types, generics).
|
||||
#define BODY_TYPE_REF_STACK_CAP 4096
|
||||
static void walk_body_type_refs(CBMExtractCtx *ctx, TSNode root, const char *func_qn) {
|
||||
TSNode stack[BODY_TYPE_REF_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, 4096);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
process_body_type_ref(ctx, node, func_qn);
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0 && top < BODY_TYPE_REF_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,26 +283,25 @@ static void process_func_type_refs(CBMExtractCtx *ctx, TSNode node) {
|
||||
}
|
||||
}
|
||||
|
||||
#define TYPE_REF_STACK_CAP 4096
|
||||
static void walk_type_refs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
if (!spec->function_node_types || !spec->function_node_types[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
TSNode stack[TYPE_REF_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, 4096);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
|
||||
if (cbm_kind_in_set(node, spec->function_node_types)) {
|
||||
process_func_type_refs(ctx, node);
|
||||
continue;
|
||||
}
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0 && top < TYPE_REF_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "extract_unified.h"
|
||||
#include "tree_sitter/api.h" // TSNode, ts_node_*
|
||||
#include "foundation/constants.h"
|
||||
#include "extract_node_stack.h"
|
||||
|
||||
enum { MAX_PARENT_DEPTH = 10, LAST_IDX = 1 };
|
||||
#include <stdint.h> // uint32_t
|
||||
@@ -106,18 +107,17 @@ static void try_emit_usage(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *s
|
||||
}
|
||||
|
||||
// Iterative usage walker — explicit stack
|
||||
#define USAGES_STACK_CAP 4096
|
||||
static void walk_usages(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
|
||||
TSNode stack[USAGES_STACK_CAP];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx->arena, 4096);
|
||||
ts_nstack_push(&stack, ctx->arena, root);
|
||||
|
||||
while (top > 0) {
|
||||
TSNode node = stack[--top];
|
||||
while (stack.count > 0) {
|
||||
TSNode node = ts_nstack_pop(&stack);
|
||||
try_emit_usage(ctx, node, spec);
|
||||
uint32_t count = ts_node_child_count(node);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0 && top < USAGES_STACK_CAP; i--) {
|
||||
stack[top++] = ts_node_child(node, (uint32_t)i);
|
||||
for (int i = (int)count - LAST_IDX; i >= 0; i--) {
|
||||
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Vendored tree-sitter grammar: pine
|
||||
// Each grammar compiled as separate unit (conflicting static symbols).
|
||||
#include "vendored/grammars/pine/parser.c"
|
||||
#include "vendored/grammars/pine/scanner.c"
|
||||
+200
-155
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,19 @@
|
||||
|
||||
#include "cbm.h"
|
||||
|
||||
// CBMEmbeddedLangSpec describes a sub-language embedded inside a host AST.
|
||||
// Used by host grammars whose tree-sitter parser does not recurse into the
|
||||
// embedded content (e.g. Svelte, Vue, Astro: script_element -> raw_text holds
|
||||
// JavaScript verbatim that the host grammar leaves unparsed). The generic
|
||||
// embedded-imports walker locates each script_node_type in the host AST,
|
||||
// finds its content_node_type child, and re-parses that slice with the
|
||||
// embedded_language's grammar so existing extractors run on the inner AST.
|
||||
typedef struct {
|
||||
const char *script_node_type; // e.g. "script_element"
|
||||
const char *content_node_type; // e.g. "raw_text"
|
||||
CBMLanguage embedded_language; // grammar used to re-parse the content slice
|
||||
} CBMEmbeddedLangSpec;
|
||||
|
||||
// CBMLangSpec mirrors Go's lang.LanguageSpec with NULL-terminated string arrays.
|
||||
typedef struct {
|
||||
CBMLanguage language;
|
||||
@@ -22,6 +35,10 @@ typedef struct {
|
||||
const char **env_access_functions; // NULL-terminated (NULL if none)
|
||||
const char **env_access_member_patterns; // NULL-terminated (NULL if none)
|
||||
const TSLanguage *(*ts_factory)(void); // Tree-sitter grammar factory (NULL if shared)
|
||||
// NULL-terminated list of embedded sub-languages (NULL if host grammar has
|
||||
// no embedded content to re-parse). The terminator is an entry whose
|
||||
// script_node_type is NULL.
|
||||
const CBMEmbeddedLangSpec *embedded_imports;
|
||||
} CBMLangSpec;
|
||||
|
||||
// Get the language spec for a given language. Returns NULL for unsupported.
|
||||
|
||||
@@ -2213,8 +2213,10 @@ static const CBMType* c_eval_expr_type_inner(CLSPContext* ctx, TSNode node) {
|
||||
// c_lookup_member: method/field lookup with base class traversal
|
||||
// ============================================================================
|
||||
|
||||
const CBMRegisteredFunc* c_lookup_member(CLSPContext* ctx, const char* type_qn, const char* member_name) {
|
||||
static const CBMRegisteredFunc* c_lookup_member_depth(CLSPContext* ctx,
|
||||
const char* type_qn, const char* member_name, int depth) {
|
||||
if (!type_qn || !member_name) return NULL;
|
||||
if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) return NULL;
|
||||
|
||||
// Direct method lookup
|
||||
const CBMRegisteredFunc* f = cbm_registry_lookup_method(ctx->registry, type_qn, member_name);
|
||||
@@ -2236,7 +2238,7 @@ const CBMRegisteredFunc* c_lookup_member(CLSPContext* ctx, const char* type_qn,
|
||||
if (underlying && !cbm_type_is_unknown(underlying)) {
|
||||
const char* alias_target_qn = type_to_qn(underlying);
|
||||
if (alias_target_qn) {
|
||||
f = c_lookup_member(ctx, alias_target_qn, member_name);
|
||||
f = c_lookup_member_depth(ctx, alias_target_qn, member_name, depth + 1);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
@@ -2252,14 +2254,14 @@ const CBMRegisteredFunc* c_lookup_member(CLSPContext* ctx, const char* type_qn,
|
||||
if (rt) {
|
||||
// Alias chain
|
||||
if (rt->alias_of) {
|
||||
f = c_lookup_member(ctx, rt->alias_of, member_name);
|
||||
f = c_lookup_member_depth(ctx, rt->alias_of, member_name, depth + 1);
|
||||
if (f) return f;
|
||||
}
|
||||
|
||||
// Base classes (embedded_types stores base class QNs)
|
||||
if (rt->embedded_types) {
|
||||
for (int i = 0; rt->embedded_types[i]; i++) {
|
||||
f = c_lookup_member(ctx, rt->embedded_types[i], member_name);
|
||||
f = c_lookup_member_depth(ctx, rt->embedded_types[i], member_name, depth + 1);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
@@ -2268,6 +2270,10 @@ const CBMRegisteredFunc* c_lookup_member(CLSPContext* ctx, const char* type_qn,
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const CBMRegisteredFunc* c_lookup_member(CLSPContext* ctx, const char* type_qn, const char* member_name) {
|
||||
return c_lookup_member_depth(ctx, type_qn, member_name, 0);
|
||||
}
|
||||
|
||||
// Field type lookup
|
||||
static const CBMType* c_lookup_field_type(CLSPContext* ctx, const char* type_qn,
|
||||
const char* field_name, int depth) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -879,9 +879,10 @@ static const CBMType* go_lookup_field(GoLSPContext* ctx,
|
||||
|
||||
// --- go_lookup_field_or_method: method sets + embedding ---
|
||||
|
||||
const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx,
|
||||
const char* type_qn, const char* member_name) {
|
||||
static const CBMRegisteredFunc* go_lookup_field_or_method_depth(GoLSPContext* ctx,
|
||||
const char* type_qn, const char* member_name, int depth) {
|
||||
if (!type_qn || !member_name) return NULL;
|
||||
if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) return NULL;
|
||||
|
||||
// Direct method lookup
|
||||
const CBMRegisteredFunc* f = cbm_registry_lookup_method(ctx->registry, type_qn, member_name);
|
||||
@@ -891,14 +892,14 @@ const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx,
|
||||
if (rt) {
|
||||
// Follow type alias chain
|
||||
if (rt->alias_of) {
|
||||
f = go_lookup_field_or_method(ctx, rt->alias_of, member_name);
|
||||
f = go_lookup_field_or_method_depth(ctx, rt->alias_of, member_name, depth + 1);
|
||||
if (f) return f;
|
||||
}
|
||||
|
||||
// Check embedded types (promoted methods)
|
||||
if (rt->embedded_types) {
|
||||
for (int i = 0; rt->embedded_types[i]; i++) {
|
||||
f = go_lookup_field_or_method(ctx, rt->embedded_types[i], member_name);
|
||||
f = go_lookup_field_or_method_depth(ctx, rt->embedded_types[i], member_name, depth + 1);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
@@ -907,6 +908,11 @@ const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx,
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx,
|
||||
const char* type_qn, const char* member_name) {
|
||||
return go_lookup_field_or_method_depth(ctx, type_qn, member_name, 0);
|
||||
}
|
||||
|
||||
// --- go_process_statement: bind variables from statements ---
|
||||
|
||||
void go_process_statement(GoLSPContext* ctx, TSNode node) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
#ifndef CBM_LSP_PY_LSP_H
|
||||
#define CBM_LSP_PY_LSP_H
|
||||
|
||||
#include "type_rep.h"
|
||||
#include "scope.h"
|
||||
#include "type_registry.h"
|
||||
#include "../cbm.h"
|
||||
#include "go_lsp.h" // CBMLSPDef, CBMResolvedCallArray reused across languages
|
||||
|
||||
// Lambda body record. When a lambda is assigned to a name (`fn =
|
||||
// lambda x: x.method()`), we stash its parameter list + body so the
|
||||
// next call site (`fn(arg)`) can do call-site driven inference.
|
||||
typedef struct {
|
||||
const char* name; // bound name, e.g. "fn"
|
||||
TSNode lambda_node; // the lambda AST node
|
||||
} CBMLambdaEntry;
|
||||
|
||||
// Function-as-dict-value entry. When `funcs = {"a": foo, "b": bar}` is
|
||||
// seen, we record the per-key target QN so `funcs["a"]()` emits an
|
||||
// edge to foo.
|
||||
typedef struct {
|
||||
const char* var_name; // e.g. "funcs"
|
||||
const char* literal_key; // e.g. "a"
|
||||
const char* target_qn; // e.g. "test.main.foo"
|
||||
} CBMDictLiteralEntry;
|
||||
|
||||
// PyLSPContext holds state for one Python file's type evaluation.
|
||||
typedef struct {
|
||||
CBMArena* arena;
|
||||
const char* source;
|
||||
int source_len;
|
||||
const CBMTypeRegistry* registry;
|
||||
CBMScope* current_scope;
|
||||
|
||||
// Import map: local_name -> module_qn (arena-allocated, NULL-terminated).
|
||||
// Built from CBMFileResult.imports.
|
||||
const char** import_local_names;
|
||||
const char** import_module_qns;
|
||||
int import_count;
|
||||
|
||||
// Current function/class context for resolving `self`/`cls` and emitting
|
||||
// caller QNs.
|
||||
const char* enclosing_func_qn;
|
||||
const char* enclosing_class_qn;
|
||||
const char* module_qn;
|
||||
|
||||
// Output: resolved calls accumulate here.
|
||||
CBMResolvedCallArray* resolved_calls;
|
||||
|
||||
// Lambda registry: simple linear list, looked up by name.
|
||||
CBMLambdaEntry* lambdas;
|
||||
int lambda_count;
|
||||
int lambda_cap;
|
||||
|
||||
// Dict-literal-as-dispatch-table tracking.
|
||||
CBMDictLiteralEntry* dict_literals;
|
||||
int dict_literal_count;
|
||||
int dict_literal_cap;
|
||||
|
||||
// Debug mode (CBM_LSP_DEBUG env, shared across all language LSPs).
|
||||
bool debug;
|
||||
} PyLSPContext;
|
||||
|
||||
// Initialize a PyLSPContext for processing one file.
|
||||
void py_lsp_init(PyLSPContext* ctx, CBMArena* arena, const char* source, int source_len,
|
||||
const CBMTypeRegistry* registry, const char* module_qn, CBMResolvedCallArray* out);
|
||||
|
||||
// Add an import mapping (call once per CBMImport entry).
|
||||
void py_lsp_add_import(PyLSPContext* ctx, const char* local_name, const char* module_qn);
|
||||
|
||||
// Bind every recorded import into the root scope. Idempotent. Called from
|
||||
// py_lsp_process_file before AST traversal. Exposed for unit tests that
|
||||
// don't need a tree-sitter parse.
|
||||
void py_lsp_bind_imports(PyLSPContext* ctx);
|
||||
|
||||
// Test hook: lookup a name in the context's scope chain. Returns
|
||||
// cbm_type_unknown() if not bound.
|
||||
const CBMType* py_lsp_lookup_in_scope(const PyLSPContext* ctx, const char* name);
|
||||
|
||||
// Process all functions/classes in a file's AST, evaluating types and resolving calls.
|
||||
// root must be the tree-sitter Python `module` node.
|
||||
void py_lsp_process_file(PyLSPContext* ctx, TSNode root);
|
||||
|
||||
// Entry point: build registry from file's own defs + run LSP resolution.
|
||||
// Mirrors cbm_run_go_lsp / cbm_run_c_lsp.
|
||||
void cbm_run_py_lsp(CBMArena* arena, CBMFileResult* result,
|
||||
const char* source, int source_len, TSNode root);
|
||||
|
||||
// Register Python stdlib types and functions into a registry.
|
||||
// Auto-generated by scripts/gen-py-stdlib.py from typeshed.
|
||||
// Phase 10 fills the body; Phase 2 ships a no-op so linkage works.
|
||||
void cbm_python_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena);
|
||||
|
||||
// --- Cross-file LSP resolution ---
|
||||
|
||||
void cbm_run_py_lsp_cross(
|
||||
CBMArena* arena,
|
||||
const char* source, int source_len,
|
||||
const char* module_qn,
|
||||
CBMLSPDef* defs, int def_count,
|
||||
const char** import_names, const char** import_qns, int import_count,
|
||||
TSTree* cached_tree, // NULL = parse internally
|
||||
CBMResolvedCallArray* out);
|
||||
|
||||
// --- Batch cross-file LSP ---
|
||||
|
||||
typedef struct {
|
||||
const char* source;
|
||||
int source_len;
|
||||
const char* module_qn;
|
||||
TSTree* cached_tree; // from TSTree caching (NULL = parse internally)
|
||||
CBMLSPDef* defs; // combined file-local + cross-file defs
|
||||
int def_count;
|
||||
const char** import_names; // parallel arrays, import_count long
|
||||
const char** import_qns;
|
||||
int import_count;
|
||||
} CBMBatchPyLSPFile;
|
||||
|
||||
void cbm_batch_py_lsp_cross(
|
||||
CBMArena* arena,
|
||||
CBMBatchPyLSPFile* files, int file_count,
|
||||
CBMResolvedCallArray* out);
|
||||
|
||||
#endif // CBM_LSP_PY_LSP_H
|
||||
+47
-17
@@ -3,40 +3,70 @@
|
||||
|
||||
CBMScope* cbm_scope_push(CBMArena* a, CBMScope* current) {
|
||||
CBMScope* scope = (CBMScope*)cbm_arena_alloc(a, sizeof(CBMScope));
|
||||
if (!scope) return current;
|
||||
if (!scope) {
|
||||
return current;
|
||||
}
|
||||
memset(scope, 0, sizeof(CBMScope));
|
||||
scope->parent = current;
|
||||
scope->arena = a;
|
||||
return scope;
|
||||
}
|
||||
|
||||
CBMScope* cbm_scope_pop(CBMScope* scope) {
|
||||
if (!scope) return NULL;
|
||||
if (!scope) {
|
||||
return NULL;
|
||||
}
|
||||
return scope->parent;
|
||||
}
|
||||
|
||||
void cbm_scope_bind(CBMScope* scope, const char* name, const CBMType* type) {
|
||||
if (!scope || !name || scope->count >= CBM_SCOPE_MAX_BINDINGS) return;
|
||||
static CBMScopeChunk* alloc_chunk(CBMScope* scope) {
|
||||
if (!scope->arena) {
|
||||
return NULL;
|
||||
}
|
||||
CBMScopeChunk* c = (CBMScopeChunk*)cbm_arena_alloc(scope->arena, sizeof(CBMScopeChunk));
|
||||
if (!c) {
|
||||
return NULL;
|
||||
}
|
||||
memset(c, 0, sizeof(CBMScopeChunk));
|
||||
c->next = scope->chunks;
|
||||
scope->chunks = c;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Overwrite existing binding in same scope if present
|
||||
for (int i = 0; i < scope->count; i++) {
|
||||
if (strcmp(scope->bindings[i].name, name) == 0) {
|
||||
scope->bindings[i].type = type;
|
||||
void cbm_scope_bind(CBMScope* scope, const char* name, const CBMType* type) {
|
||||
if (!scope || !name) {
|
||||
return;
|
||||
}
|
||||
for (CBMScopeChunk* c = scope->chunks; c != NULL; c = c->next) {
|
||||
for (int i = 0; i < c->used; i++) {
|
||||
if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) {
|
||||
c->bindings[i].type = type;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
CBMScopeChunk* head = scope->chunks;
|
||||
if (!head || head->used >= CBM_SCOPE_CHUNK_BINDINGS) {
|
||||
head = alloc_chunk(scope);
|
||||
if (!head) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
scope->bindings[scope->count].name = name;
|
||||
scope->bindings[scope->count].type = type;
|
||||
scope->count++;
|
||||
head->bindings[head->used].name = name;
|
||||
head->bindings[head->used].type = type;
|
||||
head->used++;
|
||||
}
|
||||
|
||||
const CBMType* cbm_scope_lookup(const CBMScope* scope, const char* name) {
|
||||
if (!name) return cbm_type_unknown();
|
||||
|
||||
if (!name) {
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
for (const CBMScope* s = scope; s != NULL; s = s->parent) {
|
||||
for (int i = 0; i < s->count; i++) {
|
||||
if (strcmp(s->bindings[i].name, name) == 0) {
|
||||
return s->bindings[i].type;
|
||||
for (CBMScopeChunk* c = s->chunks; c != NULL; c = c->next) {
|
||||
for (int i = 0; i < c->used; i++) {
|
||||
if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) {
|
||||
return c->bindings[i].type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -4,31 +4,33 @@
|
||||
#include "type_rep.h"
|
||||
#include "../arena.h"
|
||||
|
||||
// Variable binding in a scope.
|
||||
typedef struct {
|
||||
const char* name;
|
||||
const CBMType* type;
|
||||
} CBMVarBinding;
|
||||
|
||||
// Lexical scope with variable bindings and parent chain.
|
||||
#define CBM_SCOPE_MAX_BINDINGS 64
|
||||
#define CBM_SCOPE_CHUNK_BINDINGS 16
|
||||
|
||||
typedef struct CBMScopeChunk {
|
||||
CBMVarBinding bindings[CBM_SCOPE_CHUNK_BINDINGS];
|
||||
int used;
|
||||
struct CBMScopeChunk* next;
|
||||
} CBMScopeChunk;
|
||||
|
||||
typedef struct CBMScope {
|
||||
struct CBMScope* parent;
|
||||
CBMVarBinding bindings[CBM_SCOPE_MAX_BINDINGS];
|
||||
int count;
|
||||
CBMScopeChunk* chunks;
|
||||
CBMArena* arena; // owning arena, propagated to children at push time
|
||||
} CBMScope;
|
||||
|
||||
// Push a new scope (child of current). Returns the new scope.
|
||||
// Bail-to-UNKNOWN depth for type-lookup chains: alias resolution, MRO walks,
|
||||
// embedded-field/struct-traversal. Exceeding this collapses to cbm_type_unknown
|
||||
// rather than recursing — guards against pathological hierarchies.
|
||||
#define CBM_LSP_MAX_LOOKUP_DEPTH 16
|
||||
|
||||
CBMScope* cbm_scope_push(CBMArena* a, CBMScope* current);
|
||||
|
||||
// Pop scope: returns parent. Does NOT free (arena-allocated).
|
||||
CBMScope* cbm_scope_pop(CBMScope* scope);
|
||||
|
||||
// Bind a variable in the current scope.
|
||||
void cbm_scope_bind(CBMScope* scope, const char* name, const CBMType* type);
|
||||
|
||||
// Look up a variable by name, walking the parent chain.
|
||||
const CBMType* cbm_scope_lookup(const CBMScope* scope, const char* name);
|
||||
|
||||
#endif // CBM_LSP_SCOPE_H
|
||||
@@ -4,6 +4,20 @@
|
||||
#include "type_rep.h"
|
||||
#include "../arena.h"
|
||||
|
||||
// Decorator-derived flags (Python). Added at struct tail so existing
|
||||
// callers that memset to zero before populating other fields keep working.
|
||||
typedef enum {
|
||||
CBM_FUNC_FLAG_NONE = 0,
|
||||
CBM_FUNC_FLAG_PROPERTY = 1 << 0, // @property -> obj.attr returns getter return
|
||||
CBM_FUNC_FLAG_CLASSMETHOD = 1 << 1, // @classmethod -> first arg is cls (the class)
|
||||
CBM_FUNC_FLAG_STATICMETHOD = 1 << 2, // @staticmethod -> no implicit self/cls
|
||||
CBM_FUNC_FLAG_ABSTRACTMETHOD = 1 << 3, // @abstractmethod -> still callable for resolution
|
||||
CBM_FUNC_FLAG_OVERLOAD = 1 << 4, // @overload entry — non-implementation stub
|
||||
CBM_FUNC_FLAG_ASYNC = 1 << 5, // async def — return is Coroutine[..., T]
|
||||
CBM_FUNC_FLAG_GENERATOR = 1 << 6, // contains yield — return is Generator[T, ...]
|
||||
CBM_FUNC_FLAG_FINAL = 1 << 7, // @final — overrides not allowed
|
||||
} CBMFuncFlags;
|
||||
|
||||
// Registered function/method with full type signature.
|
||||
typedef struct {
|
||||
const char* qualified_name; // e.g., "proj.pkg.TypeName.MethodName"
|
||||
@@ -12,6 +26,9 @@ typedef struct {
|
||||
const CBMType* signature; // FUNC type with param/return types
|
||||
const char** type_param_names; // NULL-terminated, e.g., ["T", "R", NULL] for generics
|
||||
int min_params; // Minimum required params (excluding defaulted). -1 = unknown.
|
||||
int flags; // CBM_FUNC_FLAG_* bitfield (Python decorator info; 0 elsewhere)
|
||||
const char** decorator_qns; // NULL-terminated decorator QNs (Python only); used for
|
||||
// user-decorator return-type substitution.
|
||||
} CBMRegisteredFunc;
|
||||
|
||||
// Registered type with fields and method names.
|
||||
|
||||
@@ -176,6 +176,139 @@ const CBMType* cbm_type_alias(CBMArena* a, const char* alias_qn, const CBMType*
|
||||
return t;
|
||||
}
|
||||
|
||||
// --- Python-flavored constructors -------------------------------------------
|
||||
|
||||
// Dedupe members by structural equality, in place. Returns new length.
|
||||
// Preserves first-seen order so output is deterministic.
|
||||
static int union_member_dedupe(const CBMType** scratch, int count) {
|
||||
int out = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
bool seen = false;
|
||||
for (int j = 0; j < out; j++) {
|
||||
if (cbm_type_equal(scratch[i], scratch[j])) {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!seen) {
|
||||
scratch[out++] = scratch[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_union(CBMArena* a, const CBMType** members, int count) {
|
||||
if (!members || count <= 0) return &unknown_singleton;
|
||||
|
||||
// Flatten: nested UNIONs unfold their members into the parent.
|
||||
int flat_cap = count * 2 + 4;
|
||||
const CBMType** flat = (const CBMType**)cbm_arena_alloc(a, flat_cap * sizeof(const CBMType*));
|
||||
if (!flat) return &unknown_singleton;
|
||||
int flat_count = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
const CBMType* m = members[i];
|
||||
if (!m || cbm_type_is_unknown(m)) continue;
|
||||
if (m->kind == CBM_TYPE_UNION) {
|
||||
for (int j = 0; j < m->data.union_type.count; j++) {
|
||||
if (flat_count < flat_cap) flat[flat_count++] = m->data.union_type.members[j];
|
||||
}
|
||||
} else {
|
||||
if (flat_count < flat_cap) flat[flat_count++] = m;
|
||||
}
|
||||
}
|
||||
if (flat_count == 0) return &unknown_singleton;
|
||||
|
||||
// Dedupe by structural equality.
|
||||
int unique_count = union_member_dedupe(flat, flat_count);
|
||||
if (unique_count == 1) return flat[0];
|
||||
|
||||
CBMType* t = (CBMType*)cbm_arena_alloc(a, sizeof(CBMType));
|
||||
if (!t) return &unknown_singleton;
|
||||
memset(t, 0, sizeof(CBMType));
|
||||
t->kind = CBM_TYPE_UNION;
|
||||
const CBMType** arr = (const CBMType**)cbm_arena_alloc(a, (unique_count + 1) * sizeof(const CBMType*));
|
||||
if (!arr) return &unknown_singleton;
|
||||
for (int i = 0; i < unique_count; i++) arr[i] = flat[i];
|
||||
arr[unique_count] = NULL;
|
||||
t->data.union_type.members = arr;
|
||||
t->data.union_type.count = unique_count;
|
||||
return t;
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_optional(CBMArena* a, const CBMType* inner) {
|
||||
if (!inner) return &unknown_singleton;
|
||||
const CBMType* none_t = cbm_type_builtin(a, "None");
|
||||
const CBMType* members[2] = { inner, none_t };
|
||||
return cbm_type_union(a, members, 2);
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_literal(CBMArena* a, const CBMType* base, const char* literal_text) {
|
||||
CBMType* t = (CBMType*)cbm_arena_alloc(a, sizeof(CBMType));
|
||||
if (!t) return &unknown_singleton;
|
||||
memset(t, 0, sizeof(CBMType));
|
||||
t->kind = CBM_TYPE_LITERAL;
|
||||
t->data.literal.base = base ? base : &unknown_singleton;
|
||||
t->data.literal.literal_text = literal_text ? cbm_arena_strdup(a, literal_text) : NULL;
|
||||
return t;
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_protocol(CBMArena* a, const char* qualified_name,
|
||||
const char** method_names, const CBMType** method_sigs) {
|
||||
CBMType* t = (CBMType*)cbm_arena_alloc(a, sizeof(CBMType));
|
||||
if (!t) return &unknown_singleton;
|
||||
memset(t, 0, sizeof(CBMType));
|
||||
t->kind = CBM_TYPE_PROTOCOL;
|
||||
t->data.protocol.qualified_name = qualified_name ? cbm_arena_strdup(a, qualified_name) : NULL;
|
||||
|
||||
int n = 0;
|
||||
if (method_names) {
|
||||
while (method_names[n]) n++;
|
||||
}
|
||||
if (n > 0) {
|
||||
const char** names = (const char**)cbm_arena_alloc(a, (n + 1) * sizeof(const char*));
|
||||
const CBMType** sigs = (const CBMType**)cbm_arena_alloc(a, (n + 1) * sizeof(const CBMType*));
|
||||
if (names && sigs) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
names[i] = cbm_arena_strdup(a, method_names[i]);
|
||||
sigs[i] = method_sigs ? method_sigs[i] : NULL;
|
||||
}
|
||||
names[n] = NULL;
|
||||
sigs[n] = NULL;
|
||||
t->data.protocol.method_names = names;
|
||||
t->data.protocol.method_sigs = sigs;
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_module(CBMArena* a, const char* module_qn) {
|
||||
CBMType* t = (CBMType*)cbm_arena_alloc(a, sizeof(CBMType));
|
||||
if (!t) return &unknown_singleton;
|
||||
memset(t, 0, sizeof(CBMType));
|
||||
t->kind = CBM_TYPE_MODULE;
|
||||
t->data.module.module_qn = module_qn ? cbm_arena_strdup(a, module_qn) : NULL;
|
||||
return t;
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_callable(CBMArena* a, const CBMType** param_types, int param_count,
|
||||
const CBMType* return_type) {
|
||||
CBMType* t = (CBMType*)cbm_arena_alloc(a, sizeof(CBMType));
|
||||
if (!t) return &unknown_singleton;
|
||||
memset(t, 0, sizeof(CBMType));
|
||||
t->kind = CBM_TYPE_CALLABLE;
|
||||
t->data.callable.param_count = param_count;
|
||||
t->data.callable.return_type = return_type ? return_type : &unknown_singleton;
|
||||
if (param_count > 0 && param_types) {
|
||||
const CBMType** arr = (const CBMType**)cbm_arena_alloc(a, (param_count + 1) * sizeof(const CBMType*));
|
||||
if (arr) {
|
||||
for (int i = 0; i < param_count; i++) arr[i] = param_types[i];
|
||||
arr[param_count] = NULL;
|
||||
t->data.callable.param_types = arr;
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// Operations
|
||||
|
||||
const CBMType* cbm_type_deref(const CBMType* t) {
|
||||
@@ -219,6 +352,142 @@ bool cbm_type_is_reference(const CBMType* t) {
|
||||
return t && (t->kind == CBM_TYPE_REFERENCE || t->kind == CBM_TYPE_RVALUE_REF);
|
||||
}
|
||||
|
||||
bool cbm_type_is_union(const CBMType* t) {
|
||||
return t && t->kind == CBM_TYPE_UNION;
|
||||
}
|
||||
|
||||
bool cbm_type_is_protocol(const CBMType* t) {
|
||||
return t && t->kind == CBM_TYPE_PROTOCOL;
|
||||
}
|
||||
|
||||
bool cbm_type_is_module(const CBMType* t) {
|
||||
return t && t->kind == CBM_TYPE_MODULE;
|
||||
}
|
||||
|
||||
static bool str_eq_or_both_null(const char* a, const char* b) {
|
||||
if (a == b) return true;
|
||||
if (!a || !b) return false;
|
||||
return strcmp(a, b) == 0;
|
||||
}
|
||||
|
||||
bool cbm_type_equal(const CBMType* a, const CBMType* b) {
|
||||
if (a == b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a->kind != b->kind) return false;
|
||||
|
||||
switch (a->kind) {
|
||||
case CBM_TYPE_UNKNOWN:
|
||||
return true;
|
||||
case CBM_TYPE_NAMED:
|
||||
return str_eq_or_both_null(a->data.named.qualified_name, b->data.named.qualified_name);
|
||||
case CBM_TYPE_BUILTIN:
|
||||
return str_eq_or_both_null(a->data.builtin.name, b->data.builtin.name);
|
||||
case CBM_TYPE_TYPE_PARAM:
|
||||
return str_eq_or_both_null(a->data.type_param.name, b->data.type_param.name);
|
||||
case CBM_TYPE_POINTER:
|
||||
return cbm_type_equal(a->data.pointer.elem, b->data.pointer.elem);
|
||||
case CBM_TYPE_SLICE:
|
||||
return cbm_type_equal(a->data.slice.elem, b->data.slice.elem);
|
||||
case CBM_TYPE_REFERENCE:
|
||||
case CBM_TYPE_RVALUE_REF:
|
||||
return cbm_type_equal(a->data.reference.elem, b->data.reference.elem);
|
||||
case CBM_TYPE_MAP:
|
||||
return cbm_type_equal(a->data.map.key, b->data.map.key)
|
||||
&& cbm_type_equal(a->data.map.value, b->data.map.value);
|
||||
case CBM_TYPE_CHANNEL:
|
||||
return a->data.channel.direction == b->data.channel.direction
|
||||
&& cbm_type_equal(a->data.channel.elem, b->data.channel.elem);
|
||||
case CBM_TYPE_TUPLE: {
|
||||
if (a->data.tuple.count != b->data.tuple.count) return false;
|
||||
for (int i = 0; i < a->data.tuple.count; i++) {
|
||||
if (!cbm_type_equal(a->data.tuple.elems[i], b->data.tuple.elems[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case CBM_TYPE_TEMPLATE: {
|
||||
if (!str_eq_or_both_null(a->data.template_type.template_name,
|
||||
b->data.template_type.template_name)) return false;
|
||||
if (a->data.template_type.arg_count != b->data.template_type.arg_count) return false;
|
||||
for (int i = 0; i < a->data.template_type.arg_count; i++) {
|
||||
if (!cbm_type_equal(a->data.template_type.template_args[i],
|
||||
b->data.template_type.template_args[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case CBM_TYPE_ALIAS:
|
||||
return str_eq_or_both_null(a->data.alias.alias_qn, b->data.alias.alias_qn);
|
||||
case CBM_TYPE_UNION: {
|
||||
if (a->data.union_type.count != b->data.union_type.count) return false;
|
||||
// Order-independent: every a-member must appear in b's set.
|
||||
for (int i = 0; i < a->data.union_type.count; i++) {
|
||||
bool found = false;
|
||||
for (int j = 0; j < b->data.union_type.count; j++) {
|
||||
if (cbm_type_equal(a->data.union_type.members[i],
|
||||
b->data.union_type.members[j])) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case CBM_TYPE_LITERAL:
|
||||
return cbm_type_equal(a->data.literal.base, b->data.literal.base)
|
||||
&& str_eq_or_both_null(a->data.literal.literal_text, b->data.literal.literal_text);
|
||||
case CBM_TYPE_PROTOCOL:
|
||||
return str_eq_or_both_null(a->data.protocol.qualified_name,
|
||||
b->data.protocol.qualified_name);
|
||||
case CBM_TYPE_MODULE:
|
||||
return str_eq_or_both_null(a->data.module.module_qn, b->data.module.module_qn);
|
||||
case CBM_TYPE_CALLABLE: {
|
||||
if (a->data.callable.param_count != b->data.callable.param_count) return false;
|
||||
if (!cbm_type_equal(a->data.callable.return_type, b->data.callable.return_type)) return false;
|
||||
if (a->data.callable.param_count > 0) {
|
||||
for (int i = 0; i < a->data.callable.param_count; i++) {
|
||||
if (!cbm_type_equal(a->data.callable.param_types[i],
|
||||
b->data.callable.param_types[i])) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case CBM_TYPE_FUNC:
|
||||
case CBM_TYPE_INTERFACE:
|
||||
case CBM_TYPE_STRUCT:
|
||||
// Structural equality on these is expensive and rarely needed by callers
|
||||
// beyond pointer identity (already checked above). Treat as not-equal.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cbm_type_protocol_satisfied_by(const CBMType* proto, const CBMType* candidate) {
|
||||
if (!proto || proto->kind != CBM_TYPE_PROTOCOL) return false;
|
||||
if (!candidate) return false;
|
||||
// candidate must be a NAMED or PROTOCOL type with a method-name set we
|
||||
// can inspect. For PROTOCOL candidates, trivially satisfied if every
|
||||
// proto method appears in candidate's method list.
|
||||
if (candidate->kind == CBM_TYPE_PROTOCOL) {
|
||||
if (!proto->data.protocol.method_names) return true;
|
||||
for (int i = 0; proto->data.protocol.method_names[i]; i++) {
|
||||
const char* needed = proto->data.protocol.method_names[i];
|
||||
bool found = false;
|
||||
if (candidate->data.protocol.method_names) {
|
||||
for (int j = 0; candidate->data.protocol.method_names[j]; j++) {
|
||||
if (str_eq_or_both_null(needed, candidate->data.protocol.method_names[j])) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Nominal candidates require the registry — caller's responsibility.
|
||||
return false;
|
||||
}
|
||||
|
||||
const CBMType* cbm_type_resolve_alias(const CBMType* t) {
|
||||
for (int i = 0; i < 16 && t; i++) {
|
||||
if (t->kind != CBM_TYPE_ALIAS) return t;
|
||||
|
||||
@@ -23,6 +23,11 @@ typedef enum {
|
||||
CBM_TYPE_RVALUE_REF, // T&& (C++ rvalue reference)
|
||||
CBM_TYPE_TEMPLATE, // Parameterized type: vector<T> — stores template name + args
|
||||
CBM_TYPE_ALIAS, // Type alias: using/typedef — stores alias name + underlying type
|
||||
CBM_TYPE_UNION, // Python: A | B, Optional[T], Union[A, B] — sorted-canonical list
|
||||
CBM_TYPE_LITERAL, // Python: Literal["foo", 3] — wraps a base type + literal value text
|
||||
CBM_TYPE_PROTOCOL, // Python: typing.Protocol — like INTERFACE but matched structurally
|
||||
CBM_TYPE_MODULE, // Python: import os; os is a module-typed binding
|
||||
CBM_TYPE_CALLABLE, // Python: Callable[[A, B], R] — untyped-named callable variant of FUNC
|
||||
} CBMTypeKind;
|
||||
|
||||
// Forward declaration
|
||||
@@ -72,6 +77,27 @@ struct CBMType {
|
||||
const char* alias_qn; // "proj.ns.MyAlias"
|
||||
const CBMType* underlying; // the actual type it aliases
|
||||
} alias; // ALIAS
|
||||
struct {
|
||||
const CBMType** members; // NULL-terminated, deduplicated, sorted by kind/qn
|
||||
int count;
|
||||
} union_type; // UNION
|
||||
struct {
|
||||
const CBMType* base; // base type (e.g. BUILTIN("int"), BUILTIN("str"))
|
||||
const char* literal_text; // canonical text: "3", "\"foo\"", "True"
|
||||
} literal; // LITERAL
|
||||
struct {
|
||||
const char* qualified_name; // e.g. "typing.Iterable"
|
||||
const char** method_names; // NULL-terminated method names — structural matching
|
||||
const CBMType** method_sigs; // NULL-terminated signatures (each is FUNC/CALLABLE)
|
||||
} protocol; // PROTOCOL
|
||||
struct {
|
||||
const char* module_qn; // module qualified name (matches CBMImport.module_path)
|
||||
} module; // MODULE
|
||||
struct {
|
||||
const CBMType** param_types; // NULL-terminated; NULL element means "Any" / unknown
|
||||
const CBMType* return_type; // single return; for tuples wrap in CBM_TYPE_TUPLE
|
||||
int param_count; // -1 = elliptic / Callable[..., R]
|
||||
} callable; // CALLABLE
|
||||
} data;
|
||||
};
|
||||
|
||||
@@ -91,6 +117,18 @@ const CBMType* cbm_type_rvalue_ref(CBMArena* a, const CBMType* elem);
|
||||
const CBMType* cbm_type_template(CBMArena* a, const char* name, const CBMType** args, int arg_count);
|
||||
const CBMType* cbm_type_alias(CBMArena* a, const char* alias_qn, const CBMType* underlying);
|
||||
|
||||
// Python-flavored constructors. UNION normalizes input: nested unions are
|
||||
// flattened, duplicates removed, single-member unions collapse to that
|
||||
// member, and the empty union is UNKNOWN. Members must be arena-allocated.
|
||||
const CBMType* cbm_type_union(CBMArena* a, const CBMType** members, int count);
|
||||
const CBMType* cbm_type_optional(CBMArena* a, const CBMType* t); // Optional[T] == Union[T, None]
|
||||
const CBMType* cbm_type_literal(CBMArena* a, const CBMType* base, const char* literal_text);
|
||||
const CBMType* cbm_type_protocol(CBMArena* a, const char* qualified_name,
|
||||
const char** method_names, const CBMType** method_sigs);
|
||||
const CBMType* cbm_type_module(CBMArena* a, const char* module_qn);
|
||||
const CBMType* cbm_type_callable(CBMArena* a, const CBMType** param_types, int param_count,
|
||||
const CBMType* return_type);
|
||||
|
||||
// Operations
|
||||
const CBMType* cbm_type_deref(const CBMType* t); // remove one pointer level
|
||||
const CBMType* cbm_type_elem(const CBMType* t); // get element type (slice/chan/pointer)
|
||||
@@ -98,6 +136,20 @@ bool cbm_type_is_unknown(const CBMType* t);
|
||||
bool cbm_type_is_interface(const CBMType* t);
|
||||
bool cbm_type_is_pointer(const CBMType* t);
|
||||
bool cbm_type_is_reference(const CBMType* t);
|
||||
bool cbm_type_is_union(const CBMType* t);
|
||||
bool cbm_type_is_protocol(const CBMType* t);
|
||||
bool cbm_type_is_module(const CBMType* t);
|
||||
|
||||
// Structural equality on type representation (used by union dedup and
|
||||
// protocol-method-set matching). Two types are equal if their kinds match
|
||||
// and their structural members match recursively.
|
||||
bool cbm_type_equal(const CBMType* a, const CBMType* b);
|
||||
|
||||
// Test whether `candidate` satisfies the structural protocol `proto`.
|
||||
// Walks proto.method_names against candidate's method set (NAMED → registry
|
||||
// lookup is the caller's job; this helper only matches existing method
|
||||
// signatures stored on a PROTOCOL).
|
||||
bool cbm_type_protocol_satisfied_by(const CBMType* proto, const CBMType* candidate);
|
||||
|
||||
// Follow alias chain with cycle detection (max 16 levels).
|
||||
const CBMType* cbm_type_resolve_alias(const CBMType* t);
|
||||
|
||||
@@ -12,3 +12,5 @@
|
||||
#include "lsp/generated/cpp_stdlib_data.c"
|
||||
#include "lsp/php_lsp.c"
|
||||
#include "lsp/generated/php_stdlib_data.c"
|
||||
#include "lsp/generated/python_stdlib_data.c"
|
||||
#include "lsp/py_lsp.c"
|
||||
@@ -0,0 +1,18 @@
|
||||
Upstream: https://github.com/kvarenzn/tree-sitter-pine
|
||||
License declared in upstream package.json: ISC
|
||||
|
||||
ISC License
|
||||
|
||||
Copyright (c) kvarenzn
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
+46174
File diff suppressed because it is too large.
Load diff
+148
@@ -0,0 +1,148 @@
|
||||
#include "tree_sitter/alloc.h"
|
||||
#include "tree_sitter/array.h"
|
||||
#include "tree_sitter/parser.h"
|
||||
|
||||
enum TokenType {
|
||||
INDENT,
|
||||
DEDENT,
|
||||
NEWLINE,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
Array(uint16_t) indents;
|
||||
} Scanner;
|
||||
|
||||
static inline void advance(TSLexer *lexer) {
|
||||
lexer->advance(lexer, false);
|
||||
}
|
||||
|
||||
static inline void skip(TSLexer *lexer) {
|
||||
lexer->advance(lexer, true);
|
||||
}
|
||||
|
||||
bool tree_sitter_pine_external_scanner_scan(void *payload, TSLexer *lexer, const bool *valid_symbols) {
|
||||
Scanner *scanner = (Scanner *)payload;
|
||||
|
||||
lexer->mark_end(lexer);
|
||||
|
||||
bool found_end_of_line = false;
|
||||
uint32_t indent_length = 0;
|
||||
|
||||
for (;;) {
|
||||
switch (lexer->lookahead) {
|
||||
case '\n': {
|
||||
found_end_of_line = true;
|
||||
indent_length = 0;
|
||||
skip(lexer);
|
||||
break;
|
||||
}
|
||||
case ' ': {
|
||||
indent_length++;
|
||||
skip(lexer);
|
||||
break;
|
||||
}
|
||||
case '\r':
|
||||
case '\f': {
|
||||
indent_length = 0;
|
||||
skip(lexer);
|
||||
break;
|
||||
}
|
||||
case '\t': {
|
||||
indent_length += 4;
|
||||
skip(lexer);
|
||||
break;
|
||||
}
|
||||
case '\\': {
|
||||
skip(lexer);
|
||||
if (lexer->lookahead == '\r') {
|
||||
skip(lexer);
|
||||
}
|
||||
if (lexer->lookahead == '\n' || lexer->eof(lexer)) {
|
||||
skip(lexer);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (lexer->eof(lexer)) {
|
||||
indent_length = 0;
|
||||
found_end_of_line = true;
|
||||
}
|
||||
goto next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next:
|
||||
if (indent_length % 4 != 0) {
|
||||
// line continue
|
||||
return false;
|
||||
}
|
||||
|
||||
if (found_end_of_line) {
|
||||
if (scanner->indents.size > 0) {
|
||||
uint16_t current_indent_length = *array_back(&scanner->indents);
|
||||
|
||||
if (valid_symbols[INDENT] && indent_length > current_indent_length) {
|
||||
array_push(&scanner->indents, indent_length);
|
||||
lexer->result_symbol = INDENT;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (valid_symbols[DEDENT] && indent_length < current_indent_length) {
|
||||
array_pop(&scanner->indents);
|
||||
lexer->result_symbol = DEDENT;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (valid_symbols[NEWLINE]) {
|
||||
lexer->result_symbol = NEWLINE;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
unsigned tree_sitter_pine_external_scanner_serialize(void *payload, char *buffer) {
|
||||
Scanner *scanner = (Scanner *)payload;
|
||||
|
||||
size_t size = 0;
|
||||
|
||||
for (uint32_t i = 1; i < scanner->indents.size && size < TREE_SITTER_SERIALIZATION_BUFFER_SIZE; i ++) {
|
||||
buffer[size++] = (char)*array_get(&scanner->indents, i);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
void tree_sitter_pine_external_scanner_deserialize(void *payload, const char *buffer, unsigned length) {
|
||||
Scanner *scanner = (Scanner *)payload;
|
||||
|
||||
array_delete(&scanner->indents);
|
||||
array_push(&scanner->indents, 0);
|
||||
|
||||
if (length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t size = 0; size < length; size ++) {
|
||||
array_push(&scanner->indents, (unsigned char)buffer[size]);
|
||||
}
|
||||
}
|
||||
|
||||
void *tree_sitter_pine_external_scanner_create() {
|
||||
Scanner *scanner = (Scanner *)ts_calloc(1, sizeof(Scanner));
|
||||
array_init(&scanner->indents);
|
||||
tree_sitter_pine_external_scanner_deserialize(scanner, NULL, 0);
|
||||
return scanner;
|
||||
}
|
||||
|
||||
void tree_sitter_pine_external_scanner_destroy(void *payload) {
|
||||
Scanner *scanner = (Scanner *)payload;
|
||||
array_delete(&scanner->indents);
|
||||
free(scanner);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#ifndef TREE_SITTER_ALLOC_H_
|
||||
#define TREE_SITTER_ALLOC_H_
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
// Allow clients to override allocation functions
|
||||
#ifdef TREE_SITTER_REUSE_ALLOCATOR
|
||||
|
||||
extern void *(*ts_current_malloc)(size_t);
|
||||
extern void *(*ts_current_calloc)(size_t, size_t);
|
||||
extern void *(*ts_current_realloc)(void *, size_t);
|
||||
extern void (*ts_current_free)(void *);
|
||||
|
||||
#ifndef ts_malloc
|
||||
#define ts_malloc ts_current_malloc
|
||||
#endif
|
||||
#ifndef ts_calloc
|
||||
#define ts_calloc ts_current_calloc
|
||||
#endif
|
||||
#ifndef ts_realloc
|
||||
#define ts_realloc ts_current_realloc
|
||||
#endif
|
||||
#ifndef ts_free
|
||||
#define ts_free ts_current_free
|
||||
#endif
|
||||
|
||||
#else
|
||||
|
||||
#ifndef ts_malloc
|
||||
#define ts_malloc malloc
|
||||
#endif
|
||||
#ifndef ts_calloc
|
||||
#define ts_calloc calloc
|
||||
#endif
|
||||
#ifndef ts_realloc
|
||||
#define ts_realloc realloc
|
||||
#endif
|
||||
#ifndef ts_free
|
||||
#define ts_free free
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // TREE_SITTER_ALLOC_H_
|
||||
@@ -0,0 +1,290 @@
|
||||
#ifndef TREE_SITTER_ARRAY_H_
|
||||
#define TREE_SITTER_ARRAY_H_
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "./alloc.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#pragma warning(disable : 4101)
|
||||
#elif defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wunused-variable"
|
||||
#endif
|
||||
|
||||
#define Array(T) \
|
||||
struct { \
|
||||
T *contents; \
|
||||
uint32_t size; \
|
||||
uint32_t capacity; \
|
||||
}
|
||||
|
||||
/// Initialize an array.
|
||||
#define array_init(self) \
|
||||
((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL)
|
||||
|
||||
/// Create an empty array.
|
||||
#define array_new() \
|
||||
{ NULL, 0, 0 }
|
||||
|
||||
/// Get a pointer to the element at a given `index` in the array.
|
||||
#define array_get(self, _index) \
|
||||
(assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index])
|
||||
|
||||
/// Get a pointer to the first element in the array.
|
||||
#define array_front(self) array_get(self, 0)
|
||||
|
||||
/// Get a pointer to the last element in the array.
|
||||
#define array_back(self) array_get(self, (self)->size - 1)
|
||||
|
||||
/// Clear the array, setting its size to zero. Note that this does not free any
|
||||
/// memory allocated for the array's contents.
|
||||
#define array_clear(self) ((self)->size = 0)
|
||||
|
||||
/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is
|
||||
/// less than the array's current capacity, this function has no effect.
|
||||
#define array_reserve(self, new_capacity) \
|
||||
_array__reserve((Array *)(self), array_elem_size(self), new_capacity)
|
||||
|
||||
/// Free any memory allocated for this array. Note that this does not free any
|
||||
/// memory allocated for the array's contents.
|
||||
#define array_delete(self) _array__delete((Array *)(self))
|
||||
|
||||
/// Push a new `element` onto the end of the array.
|
||||
#define array_push(self, element) \
|
||||
(_array__grow((Array *)(self), 1, array_elem_size(self)), \
|
||||
(self)->contents[(self)->size++] = (element))
|
||||
|
||||
/// Increase the array's size by `count` elements.
|
||||
/// New elements are zero-initialized.
|
||||
#define array_grow_by(self, count) \
|
||||
do { \
|
||||
if ((count) == 0) break; \
|
||||
_array__grow((Array *)(self), count, array_elem_size(self)); \
|
||||
memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \
|
||||
(self)->size += (count); \
|
||||
} while (0)
|
||||
|
||||
/// Append all elements from one array to the end of another.
|
||||
#define array_push_all(self, other) \
|
||||
array_extend((self), (other)->size, (other)->contents)
|
||||
|
||||
/// Append `count` elements to the end of the array, reading their values from the
|
||||
/// `contents` pointer.
|
||||
#define array_extend(self, count, contents) \
|
||||
_array__splice( \
|
||||
(Array *)(self), array_elem_size(self), (self)->size, \
|
||||
0, count, contents \
|
||||
)
|
||||
|
||||
/// Remove `old_count` elements from the array starting at the given `index`. At
|
||||
/// the same index, insert `new_count` new elements, reading their values from the
|
||||
/// `new_contents` pointer.
|
||||
#define array_splice(self, _index, old_count, new_count, new_contents) \
|
||||
_array__splice( \
|
||||
(Array *)(self), array_elem_size(self), _index, \
|
||||
old_count, new_count, new_contents \
|
||||
)
|
||||
|
||||
/// Insert one `element` into the array at the given `index`.
|
||||
#define array_insert(self, _index, element) \
|
||||
_array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element))
|
||||
|
||||
/// Remove one element from the array at the given `index`.
|
||||
#define array_erase(self, _index) \
|
||||
_array__erase((Array *)(self), array_elem_size(self), _index)
|
||||
|
||||
/// Pop the last element off the array, returning the element by value.
|
||||
#define array_pop(self) ((self)->contents[--(self)->size])
|
||||
|
||||
/// Assign the contents of one array to another, reallocating if necessary.
|
||||
#define array_assign(self, other) \
|
||||
_array__assign((Array *)(self), (const Array *)(other), array_elem_size(self))
|
||||
|
||||
/// Swap one array with another
|
||||
#define array_swap(self, other) \
|
||||
_array__swap((Array *)(self), (Array *)(other))
|
||||
|
||||
/// Get the size of the array contents
|
||||
#define array_elem_size(self) (sizeof *(self)->contents)
|
||||
|
||||
/// Search a sorted array for a given `needle` value, using the given `compare`
|
||||
/// callback to determine the order.
|
||||
///
|
||||
/// If an existing element is found to be equal to `needle`, then the `index`
|
||||
/// out-parameter is set to the existing value's index, and the `exists`
|
||||
/// out-parameter is set to true. Otherwise, `index` is set to an index where
|
||||
/// `needle` should be inserted in order to preserve the sorting, and `exists`
|
||||
/// is set to false.
|
||||
#define array_search_sorted_with(self, compare, needle, _index, _exists) \
|
||||
_array__search_sorted(self, 0, compare, , needle, _index, _exists)
|
||||
|
||||
/// Search a sorted array for a given `needle` value, using integer comparisons
|
||||
/// of a given struct field (specified with a leading dot) to determine the order.
|
||||
///
|
||||
/// See also `array_search_sorted_with`.
|
||||
#define array_search_sorted_by(self, field, needle, _index, _exists) \
|
||||
_array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists)
|
||||
|
||||
/// Insert a given `value` into a sorted array, using the given `compare`
|
||||
/// callback to determine the order.
|
||||
#define array_insert_sorted_with(self, compare, value) \
|
||||
do { \
|
||||
unsigned _index, _exists; \
|
||||
array_search_sorted_with(self, compare, &(value), &_index, &_exists); \
|
||||
if (!_exists) array_insert(self, _index, value); \
|
||||
} while (0)
|
||||
|
||||
/// Insert a given `value` into a sorted array, using integer comparisons of
|
||||
/// a given struct field (specified with a leading dot) to determine the order.
|
||||
///
|
||||
/// See also `array_search_sorted_by`.
|
||||
#define array_insert_sorted_by(self, field, value) \
|
||||
do { \
|
||||
unsigned _index, _exists; \
|
||||
array_search_sorted_by(self, field, (value) field, &_index, &_exists); \
|
||||
if (!_exists) array_insert(self, _index, value); \
|
||||
} while (0)
|
||||
|
||||
// Private
|
||||
|
||||
typedef Array(void) Array;
|
||||
|
||||
/// This is not what you're looking for, see `array_delete`.
|
||||
static inline void _array__delete(Array *self) {
|
||||
if (self->contents) {
|
||||
ts_free(self->contents);
|
||||
self->contents = NULL;
|
||||
self->size = 0;
|
||||
self->capacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// This is not what you're looking for, see `array_erase`.
|
||||
static inline void _array__erase(Array *self, size_t element_size,
|
||||
uint32_t index) {
|
||||
assert(index < self->size);
|
||||
char *contents = (char *)self->contents;
|
||||
memmove(contents + index * element_size, contents + (index + 1) * element_size,
|
||||
(self->size - index - 1) * element_size);
|
||||
self->size--;
|
||||
}
|
||||
|
||||
/// This is not what you're looking for, see `array_reserve`.
|
||||
static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) {
|
||||
if (new_capacity > self->capacity) {
|
||||
if (self->contents) {
|
||||
self->contents = ts_realloc(self->contents, new_capacity * element_size);
|
||||
} else {
|
||||
self->contents = ts_malloc(new_capacity * element_size);
|
||||
}
|
||||
self->capacity = new_capacity;
|
||||
}
|
||||
}
|
||||
|
||||
/// This is not what you're looking for, see `array_assign`.
|
||||
static inline void _array__assign(Array *self, const Array *other, size_t element_size) {
|
||||
_array__reserve(self, element_size, other->size);
|
||||
self->size = other->size;
|
||||
memcpy(self->contents, other->contents, self->size * element_size);
|
||||
}
|
||||
|
||||
/// This is not what you're looking for, see `array_swap`.
|
||||
static inline void _array__swap(Array *self, Array *other) {
|
||||
Array swap = *other;
|
||||
*other = *self;
|
||||
*self = swap;
|
||||
}
|
||||
|
||||
/// This is not what you're looking for, see `array_push` or `array_grow_by`.
|
||||
static inline void _array__grow(Array *self, uint32_t count, size_t element_size) {
|
||||
uint32_t new_size = self->size + count;
|
||||
if (new_size > self->capacity) {
|
||||
uint32_t new_capacity = self->capacity * 2;
|
||||
if (new_capacity < 8) new_capacity = 8;
|
||||
if (new_capacity < new_size) new_capacity = new_size;
|
||||
_array__reserve(self, element_size, new_capacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// This is not what you're looking for, see `array_splice`.
|
||||
static inline void _array__splice(Array *self, size_t element_size,
|
||||
uint32_t index, uint32_t old_count,
|
||||
uint32_t new_count, const void *elements) {
|
||||
uint32_t new_size = self->size + new_count - old_count;
|
||||
uint32_t old_end = index + old_count;
|
||||
uint32_t new_end = index + new_count;
|
||||
assert(old_end <= self->size);
|
||||
|
||||
_array__reserve(self, element_size, new_size);
|
||||
|
||||
char *contents = (char *)self->contents;
|
||||
if (self->size > old_end) {
|
||||
memmove(
|
||||
contents + new_end * element_size,
|
||||
contents + old_end * element_size,
|
||||
(self->size - old_end) * element_size
|
||||
);
|
||||
}
|
||||
if (new_count > 0) {
|
||||
if (elements) {
|
||||
memcpy(
|
||||
(contents + index * element_size),
|
||||
elements,
|
||||
new_count * element_size
|
||||
);
|
||||
} else {
|
||||
memset(
|
||||
(contents + index * element_size),
|
||||
0,
|
||||
new_count * element_size
|
||||
);
|
||||
}
|
||||
}
|
||||
self->size += new_count - old_count;
|
||||
}
|
||||
|
||||
/// A binary search routine, based on Rust's `std::slice::binary_search_by`.
|
||||
/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`.
|
||||
#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \
|
||||
do { \
|
||||
*(_index) = start; \
|
||||
*(_exists) = false; \
|
||||
uint32_t size = (self)->size - *(_index); \
|
||||
if (size == 0) break; \
|
||||
int comparison; \
|
||||
while (size > 1) { \
|
||||
uint32_t half_size = size / 2; \
|
||||
uint32_t mid_index = *(_index) + half_size; \
|
||||
comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \
|
||||
if (comparison <= 0) *(_index) = mid_index; \
|
||||
size -= half_size; \
|
||||
} \
|
||||
comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \
|
||||
if (comparison == 0) *(_exists) = true; \
|
||||
else if (comparison < 0) *(_index) += 1; \
|
||||
} while (0)
|
||||
|
||||
/// Helper macro for the `_sorted_by` routines below. This takes the left (existing)
|
||||
/// parameter by reference in order to work with the generic sorting function above.
|
||||
#define _compare_int(a, b) ((int)*(a) - (int)(b))
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#pragma warning(default : 4101)
|
||||
#elif defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // TREE_SITTER_ARRAY_H_
|
||||
@@ -0,0 +1,265 @@
|
||||
#ifndef TREE_SITTER_PARSER_H_
|
||||
#define TREE_SITTER_PARSER_H_
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define ts_builtin_sym_error ((TSSymbol)-1)
|
||||
#define ts_builtin_sym_end 0
|
||||
#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024
|
||||
|
||||
#ifndef TREE_SITTER_API_H_
|
||||
typedef uint16_t TSStateId;
|
||||
typedef uint16_t TSSymbol;
|
||||
typedef uint16_t TSFieldId;
|
||||
typedef struct TSLanguage TSLanguage;
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
TSFieldId field_id;
|
||||
uint8_t child_index;
|
||||
bool inherited;
|
||||
} TSFieldMapEntry;
|
||||
|
||||
typedef struct {
|
||||
uint16_t index;
|
||||
uint16_t length;
|
||||
} TSFieldMapSlice;
|
||||
|
||||
typedef struct {
|
||||
bool visible;
|
||||
bool named;
|
||||
bool supertype;
|
||||
} TSSymbolMetadata;
|
||||
|
||||
typedef struct TSLexer TSLexer;
|
||||
|
||||
struct TSLexer {
|
||||
int32_t lookahead;
|
||||
TSSymbol result_symbol;
|
||||
void (*advance)(TSLexer *, bool);
|
||||
void (*mark_end)(TSLexer *);
|
||||
uint32_t (*get_column)(TSLexer *);
|
||||
bool (*is_at_included_range_start)(const TSLexer *);
|
||||
bool (*eof)(const TSLexer *);
|
||||
};
|
||||
|
||||
typedef enum {
|
||||
TSParseActionTypeShift,
|
||||
TSParseActionTypeReduce,
|
||||
TSParseActionTypeAccept,
|
||||
TSParseActionTypeRecover,
|
||||
} TSParseActionType;
|
||||
|
||||
typedef union {
|
||||
struct {
|
||||
uint8_t type;
|
||||
TSStateId state;
|
||||
bool extra;
|
||||
bool repetition;
|
||||
} shift;
|
||||
struct {
|
||||
uint8_t type;
|
||||
uint8_t child_count;
|
||||
TSSymbol symbol;
|
||||
int16_t dynamic_precedence;
|
||||
uint16_t production_id;
|
||||
} reduce;
|
||||
uint8_t type;
|
||||
} TSParseAction;
|
||||
|
||||
typedef struct {
|
||||
uint16_t lex_state;
|
||||
uint16_t external_lex_state;
|
||||
} TSLexMode;
|
||||
|
||||
typedef union {
|
||||
TSParseAction action;
|
||||
struct {
|
||||
uint8_t count;
|
||||
bool reusable;
|
||||
} entry;
|
||||
} TSParseActionEntry;
|
||||
|
||||
typedef struct {
|
||||
int32_t start;
|
||||
int32_t end;
|
||||
} TSCharacterRange;
|
||||
|
||||
struct TSLanguage {
|
||||
uint32_t version;
|
||||
uint32_t symbol_count;
|
||||
uint32_t alias_count;
|
||||
uint32_t token_count;
|
||||
uint32_t external_token_count;
|
||||
uint32_t state_count;
|
||||
uint32_t large_state_count;
|
||||
uint32_t production_id_count;
|
||||
uint32_t field_count;
|
||||
uint16_t max_alias_sequence_length;
|
||||
const uint16_t *parse_table;
|
||||
const uint16_t *small_parse_table;
|
||||
const uint32_t *small_parse_table_map;
|
||||
const TSParseActionEntry *parse_actions;
|
||||
const char * const *symbol_names;
|
||||
const char * const *field_names;
|
||||
const TSFieldMapSlice *field_map_slices;
|
||||
const TSFieldMapEntry *field_map_entries;
|
||||
const TSSymbolMetadata *symbol_metadata;
|
||||
const TSSymbol *public_symbol_map;
|
||||
const uint16_t *alias_map;
|
||||
const TSSymbol *alias_sequences;
|
||||
const TSLexMode *lex_modes;
|
||||
bool (*lex_fn)(TSLexer *, TSStateId);
|
||||
bool (*keyword_lex_fn)(TSLexer *, TSStateId);
|
||||
TSSymbol keyword_capture_token;
|
||||
struct {
|
||||
const bool *states;
|
||||
const TSSymbol *symbol_map;
|
||||
void *(*create)(void);
|
||||
void (*destroy)(void *);
|
||||
bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist);
|
||||
unsigned (*serialize)(void *, char *);
|
||||
void (*deserialize)(void *, const char *, unsigned);
|
||||
} external_scanner;
|
||||
const TSStateId *primary_state_ids;
|
||||
};
|
||||
|
||||
static inline bool set_contains(TSCharacterRange *ranges, uint32_t len, int32_t lookahead) {
|
||||
uint32_t index = 0;
|
||||
uint32_t size = len - index;
|
||||
while (size > 1) {
|
||||
uint32_t half_size = size / 2;
|
||||
uint32_t mid_index = index + half_size;
|
||||
TSCharacterRange *range = &ranges[mid_index];
|
||||
if (lookahead >= range->start && lookahead <= range->end) {
|
||||
return true;
|
||||
} else if (lookahead > range->end) {
|
||||
index = mid_index;
|
||||
}
|
||||
size -= half_size;
|
||||
}
|
||||
TSCharacterRange *range = &ranges[index];
|
||||
return (lookahead >= range->start && lookahead <= range->end);
|
||||
}
|
||||
|
||||
/*
|
||||
* Lexer Macros
|
||||
*/
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#define UNUSED __pragma(warning(suppress : 4101))
|
||||
#else
|
||||
#define UNUSED __attribute__((unused))
|
||||
#endif
|
||||
|
||||
#define START_LEXER() \
|
||||
bool result = false; \
|
||||
bool skip = false; \
|
||||
UNUSED \
|
||||
bool eof = false; \
|
||||
int32_t lookahead; \
|
||||
goto start; \
|
||||
next_state: \
|
||||
lexer->advance(lexer, skip); \
|
||||
start: \
|
||||
skip = false; \
|
||||
lookahead = lexer->lookahead;
|
||||
|
||||
#define ADVANCE(state_value) \
|
||||
{ \
|
||||
state = state_value; \
|
||||
goto next_state; \
|
||||
}
|
||||
|
||||
#define ADVANCE_MAP(...) \
|
||||
{ \
|
||||
static const uint16_t map[] = { __VA_ARGS__ }; \
|
||||
for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \
|
||||
if (map[i] == lookahead) { \
|
||||
state = map[i + 1]; \
|
||||
goto next_state; \
|
||||
} \
|
||||
} \
|
||||
}
|
||||
|
||||
#define SKIP(state_value) \
|
||||
{ \
|
||||
skip = true; \
|
||||
state = state_value; \
|
||||
goto next_state; \
|
||||
}
|
||||
|
||||
#define ACCEPT_TOKEN(symbol_value) \
|
||||
result = true; \
|
||||
lexer->result_symbol = symbol_value; \
|
||||
lexer->mark_end(lexer);
|
||||
|
||||
#define END_STATE() return result;
|
||||
|
||||
/*
|
||||
* Parse Table Macros
|
||||
*/
|
||||
|
||||
#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT)
|
||||
|
||||
#define STATE(id) id
|
||||
|
||||
#define ACTIONS(id) id
|
||||
|
||||
#define SHIFT(state_value) \
|
||||
{{ \
|
||||
.shift = { \
|
||||
.type = TSParseActionTypeShift, \
|
||||
.state = (state_value) \
|
||||
} \
|
||||
}}
|
||||
|
||||
#define SHIFT_REPEAT(state_value) \
|
||||
{{ \
|
||||
.shift = { \
|
||||
.type = TSParseActionTypeShift, \
|
||||
.state = (state_value), \
|
||||
.repetition = true \
|
||||
} \
|
||||
}}
|
||||
|
||||
#define SHIFT_EXTRA() \
|
||||
{{ \
|
||||
.shift = { \
|
||||
.type = TSParseActionTypeShift, \
|
||||
.extra = true \
|
||||
} \
|
||||
}}
|
||||
|
||||
#define REDUCE(symbol_name, children, precedence, prod_id) \
|
||||
{{ \
|
||||
.reduce = { \
|
||||
.type = TSParseActionTypeReduce, \
|
||||
.symbol = symbol_name, \
|
||||
.child_count = children, \
|
||||
.dynamic_precedence = precedence, \
|
||||
.production_id = prod_id \
|
||||
}, \
|
||||
}}
|
||||
|
||||
#define RECOVER() \
|
||||
{{ \
|
||||
.type = TSParseActionTypeRecover \
|
||||
}}
|
||||
|
||||
#define ACCEPT_INPUT() \
|
||||
{{ \
|
||||
.type = TSParseActionTypeAccept \
|
||||
}}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // TREE_SITTER_PARSER_H_
|
||||
Executable
+432
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3.9
|
||||
"""
|
||||
gen-py-stdlib.py — Generate cbm_python_stdlib_register from typeshed stubs.
|
||||
|
||||
Phase 10 of Python LSP integration. Walks a typeshed/stdlib checkout,
|
||||
parses each .pyi via the standard `ast` module, and emits a single C
|
||||
source file (internal/cbm/lsp/generated/python_stdlib_data.c) that
|
||||
populates a CBMTypeRegistry with classes + their method names and
|
||||
free functions per module.
|
||||
|
||||
This is a v1 implementation — overload stacks collapse to the first
|
||||
signature, ParamSpec/TypeVarTuple/Concatenate are skipped, version
|
||||
guards `if sys.version_info >= (X, Y):` are flattened (we take the
|
||||
union of all branches). Per-symbol min/max version guards from the
|
||||
plan are recorded as comments only in v1; py_lsp consumers should
|
||||
treat the generator output as the 3.12 baseline.
|
||||
|
||||
Usage:
|
||||
python3.9 scripts/gen-py-stdlib.py <typeshed-stdlib-path> <output.c>
|
||||
|
||||
Defaults to /tmp/python-lsp-references/typeshed/stdlib and
|
||||
internal/cbm/lsp/generated/python_stdlib_data.c when run without args.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import dataclasses
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
# v1 module allowlist — the plan's "top usage" set. Skip large / low-value
|
||||
# modules (tkinter, turtle, curses, xml.*, email.*) to keep generated
|
||||
# output manageable.
|
||||
ALLOWED_MODULES = {
|
||||
"builtins",
|
||||
"typing",
|
||||
"typing_extensions",
|
||||
"os",
|
||||
"sys",
|
||||
"collections",
|
||||
"functools",
|
||||
"itertools",
|
||||
"pathlib",
|
||||
"json",
|
||||
"re",
|
||||
"dataclasses",
|
||||
"enum",
|
||||
"datetime",
|
||||
"subprocess",
|
||||
"logging",
|
||||
"asyncio",
|
||||
"unittest",
|
||||
"argparse",
|
||||
"contextlib",
|
||||
"io",
|
||||
"tempfile",
|
||||
"shutil",
|
||||
"inspect",
|
||||
"abc",
|
||||
"warnings",
|
||||
"weakref",
|
||||
"copy",
|
||||
"pickle",
|
||||
"time",
|
||||
"math",
|
||||
"string",
|
||||
"socket",
|
||||
"threading",
|
||||
"multiprocessing",
|
||||
"queue",
|
||||
"urllib",
|
||||
"http",
|
||||
"concurrent",
|
||||
}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class StubMethod:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class StubClass:
|
||||
qualified_name: str
|
||||
short_name: str
|
||||
methods: list[str]
|
||||
bases: list[str]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class StubFunction:
|
||||
qualified_name: str
|
||||
short_name: str
|
||||
module_qn: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ModuleStubs:
|
||||
module_qn: str
|
||||
classes: list[StubClass]
|
||||
functions: list[StubFunction]
|
||||
|
||||
|
||||
def is_method(node: ast.AST) -> bool:
|
||||
return isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
|
||||
|
||||
def class_methods(class_node: ast.ClassDef) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for child in class_node.body:
|
||||
if is_method(child):
|
||||
name = child.name # type: ignore[attr-defined]
|
||||
if not name.startswith("_") or name in {"__init__", "__call__", "__enter__", "__exit__"}:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
elif isinstance(child, ast.If):
|
||||
# Flatten version guards: walk both branches.
|
||||
for sub in child.body + child.orelse:
|
||||
if is_method(sub):
|
||||
name = sub.name # type: ignore[attr-defined]
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def base_qns(class_node: ast.ClassDef, module_qn: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for base in class_node.bases:
|
||||
if isinstance(base, ast.Name):
|
||||
# Bare name — qualify within the same module by default.
|
||||
out.append(f"{module_qn}.{base.id}")
|
||||
elif isinstance(base, ast.Attribute):
|
||||
# mod.SubClass
|
||||
try:
|
||||
text = ast.unparse(base) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
continue
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class StarReExport:
|
||||
target_module: str # e.g. "posixpath" / "ntpath"
|
||||
|
||||
|
||||
def parse_module(path: Path, module_qn: str) -> tuple[ModuleStubs, list[StarReExport]]:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError:
|
||||
return ModuleStubs(module_qn=module_qn, classes=[], functions=[]), []
|
||||
|
||||
classes: list[StubClass] = []
|
||||
functions: list[StubFunction] = []
|
||||
star_imports: list[StarReExport] = []
|
||||
seen_funcs: set[str] = set()
|
||||
|
||||
def walk(body: list[ast.stmt], current_module_qn: str) -> None:
|
||||
for node in body:
|
||||
if isinstance(node, ast.ClassDef):
|
||||
qn = f"{current_module_qn}.{node.name}"
|
||||
classes.append(StubClass(
|
||||
qualified_name=qn,
|
||||
short_name=node.name,
|
||||
methods=class_methods(node),
|
||||
bases=base_qns(node, current_module_qn),
|
||||
))
|
||||
elif is_method(node):
|
||||
name = node.name # type: ignore[attr-defined]
|
||||
if name.startswith("_") and name not in {"__init__"}:
|
||||
continue
|
||||
if name in seen_funcs:
|
||||
continue
|
||||
seen_funcs.add(name)
|
||||
functions.append(StubFunction(
|
||||
qualified_name=f"{current_module_qn}.{name}",
|
||||
short_name=name,
|
||||
module_qn=current_module_qn,
|
||||
))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
# Track `from X import *` for re-export resolution.
|
||||
if node.module and any(alias.name == "*" for alias in node.names):
|
||||
star_imports.append(StarReExport(target_module=node.module))
|
||||
elif isinstance(node, ast.If):
|
||||
# Flatten version guards.
|
||||
walk(node.body, current_module_qn)
|
||||
walk(node.orelse, current_module_qn)
|
||||
|
||||
walk(tree.body, module_qn)
|
||||
return ModuleStubs(module_qn=module_qn, classes=classes, functions=functions), star_imports
|
||||
|
||||
|
||||
def collect_all_stubs(stdlib_root: Path) -> tuple[dict[str, ModuleStubs], dict[str, list[StarReExport]]]:
|
||||
"""Gather every stub in the allowlist plus any re-export targets they
|
||||
pull in transitively (e.g. os.path -> posixpath -> genericpath).
|
||||
Returns (modules, star_imports_per_module). """
|
||||
modules: dict[str, ModuleStubs] = {}
|
||||
star_imports: dict[str, list[StarReExport]] = {}
|
||||
|
||||
# First pass: walk allowlist
|
||||
queue: list[tuple[Path, str]] = []
|
||||
for path in sorted(stdlib_root.rglob("*.pyi")):
|
||||
rel = path.relative_to(stdlib_root)
|
||||
parts = list(rel.parts)
|
||||
if parts[-1] == "__init__.pyi":
|
||||
parts.pop()
|
||||
else:
|
||||
parts[-1] = parts[-1][:-len(".pyi")]
|
||||
if not parts:
|
||||
continue
|
||||
top = parts[0]
|
||||
if top.startswith("_"):
|
||||
continue
|
||||
if top not in ALLOWED_MODULES:
|
||||
continue
|
||||
module_qn = ".".join(parts)
|
||||
queue.append((path, module_qn))
|
||||
|
||||
seen_targets: set[str] = set()
|
||||
while queue:
|
||||
path, mod_qn = queue.pop()
|
||||
if mod_qn in modules:
|
||||
continue
|
||||
ms, stars = parse_module(path, mod_qn)
|
||||
modules[mod_qn] = ms
|
||||
star_imports[mod_qn] = stars
|
||||
# Enqueue re-export targets (e.g. posixpath, ntpath, genericpath)
|
||||
for star in stars:
|
||||
tgt = star.target_module
|
||||
if tgt in seen_targets:
|
||||
continue
|
||||
seen_targets.add(tgt)
|
||||
tgt_path = stdlib_root / (tgt.replace(".", "/") + ".pyi")
|
||||
if tgt_path.is_file():
|
||||
queue.append((tgt_path, tgt))
|
||||
else:
|
||||
tgt_init = stdlib_root / tgt.replace(".", "/") / "__init__.pyi"
|
||||
if tgt_init.is_file():
|
||||
queue.append((tgt_init, tgt))
|
||||
|
||||
return modules, star_imports
|
||||
|
||||
|
||||
def resolve_reexports(modules: dict[str, ModuleStubs],
|
||||
star_imports: dict[str, list[StarReExport]]) -> None:
|
||||
"""For each module with `from X import *`, copy X's classes/functions
|
||||
into the current module under that module's QN. Iterates to a fixed
|
||||
point to handle re-export chains. """
|
||||
changed = True
|
||||
iter_count = 0
|
||||
while changed and iter_count < 8:
|
||||
changed = False
|
||||
iter_count += 1
|
||||
for mod_qn, stars in star_imports.items():
|
||||
ms = modules[mod_qn]
|
||||
for star in stars:
|
||||
target = modules.get(star.target_module)
|
||||
if not target:
|
||||
continue
|
||||
# Copy target's classes/functions under mod_qn
|
||||
existing_class_names = {c.short_name for c in ms.classes}
|
||||
existing_func_names = {f.short_name for f in ms.functions}
|
||||
for c in target.classes:
|
||||
if c.short_name in existing_class_names:
|
||||
continue
|
||||
ms.classes.append(StubClass(
|
||||
qualified_name=f"{mod_qn}.{c.short_name}",
|
||||
short_name=c.short_name,
|
||||
methods=list(c.methods),
|
||||
bases=list(c.bases),
|
||||
))
|
||||
existing_class_names.add(c.short_name)
|
||||
changed = True
|
||||
for f in target.functions:
|
||||
if f.short_name in existing_func_names:
|
||||
continue
|
||||
ms.functions.append(StubFunction(
|
||||
qualified_name=f"{mod_qn}.{f.short_name}",
|
||||
short_name=f.short_name,
|
||||
module_qn=mod_qn,
|
||||
))
|
||||
existing_func_names.add(f.short_name)
|
||||
changed = True
|
||||
|
||||
|
||||
def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
|
||||
modules, star_imports = collect_all_stubs(stdlib_root)
|
||||
resolve_reexports(modules, star_imports)
|
||||
for mod_qn in sorted(modules.keys()):
|
||||
yield modules[mod_qn]
|
||||
|
||||
|
||||
C_HEADER = """\
|
||||
// AUTO-GENERATED by scripts/gen-py-stdlib.py — DO NOT EDIT
|
||||
// Python stdlib type information for LSP type resolver.
|
||||
//
|
||||
// Source: typeshed commit a7912d521e16ff63caf7a8b64b9072542be36777
|
||||
// Module allowlist: see ALLOWED_MODULES in scripts/gen-py-stdlib.py
|
||||
//
|
||||
// v1 simplifications (per PYTHON_LSP_PLAN.md Phase 10):
|
||||
// - overload stacks collapse to first signature
|
||||
// - ParamSpec / TypeVarTuple / Concatenate skipped
|
||||
// - version guards flattened (union of all branches)
|
||||
// - per-symbol min/max version guards not yet emitted
|
||||
|
||||
#include "../type_rep.h"
|
||||
#include "../type_registry.h"
|
||||
#include <string.h>
|
||||
|
||||
#define CBM_PYTHON_STDLIB_GENERATED 1
|
||||
|
||||
void cbm_python_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) {
|
||||
if (!reg || !arena) return;
|
||||
|
||||
CBMRegisteredType rt;
|
||||
CBMRegisteredFunc rf;
|
||||
"""
|
||||
|
||||
C_FOOTER = """\
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def c_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
|
||||
def emit(stubs: list[ModuleStubs], output_path: Path) -> None:
|
||||
method_table_id = 0
|
||||
out_lines: list[str] = []
|
||||
|
||||
for ms in stubs:
|
||||
if not ms.classes and not ms.functions:
|
||||
continue
|
||||
out_lines.append(f"\n /* ===== module: {ms.module_qn} ===== */\n")
|
||||
|
||||
for cls in ms.classes:
|
||||
method_array_name = None
|
||||
if cls.methods:
|
||||
method_table_id += 1
|
||||
method_array_name = f"py_methods_{method_table_id}"
|
||||
methods_c = ", ".join(f"\"{c_escape(m)}\"" for m in cls.methods)
|
||||
out_lines.append(
|
||||
f" static const char* {method_array_name}[] = {{ {methods_c}, NULL }};\n"
|
||||
)
|
||||
out_lines.append(" memset(&rt, 0, sizeof(rt));\n")
|
||||
out_lines.append(
|
||||
f" rt.qualified_name = \"{c_escape(cls.qualified_name)}\";\n"
|
||||
)
|
||||
out_lines.append(
|
||||
f" rt.short_name = \"{c_escape(cls.short_name)}\";\n"
|
||||
)
|
||||
if method_array_name:
|
||||
out_lines.append(f" rt.method_names = {method_array_name};\n")
|
||||
if cls.bases:
|
||||
method_table_id += 1
|
||||
bases_array = f"py_bases_{method_table_id}"
|
||||
bases_c = ", ".join(f"\"{c_escape(b)}\"" for b in cls.bases)
|
||||
out_lines.append(
|
||||
f" static const char* {bases_array}[] = {{ {bases_c}, NULL }};\n"
|
||||
)
|
||||
out_lines.append(f" rt.embedded_types = {bases_array};\n")
|
||||
out_lines.append(" cbm_registry_add_type(reg, rt);\n")
|
||||
|
||||
# Also emit a registered method per class.method so registry
|
||||
# lookup_method(class_qn, name) works.
|
||||
for m in cls.methods:
|
||||
out_lines.append(" memset(&rf, 0, sizeof(rf));\n")
|
||||
out_lines.append(
|
||||
f" rf.qualified_name = \"{c_escape(cls.qualified_name)}.{c_escape(m)}\";\n"
|
||||
)
|
||||
out_lines.append(f" rf.short_name = \"{c_escape(m)}\";\n")
|
||||
out_lines.append(
|
||||
f" rf.receiver_type = \"{c_escape(cls.qualified_name)}\";\n"
|
||||
)
|
||||
out_lines.append(" cbm_registry_add_func(reg, rf);\n")
|
||||
|
||||
for fn in ms.functions:
|
||||
out_lines.append(" memset(&rf, 0, sizeof(rf));\n")
|
||||
out_lines.append(
|
||||
f" rf.qualified_name = \"{c_escape(fn.qualified_name)}\";\n"
|
||||
)
|
||||
out_lines.append(f" rf.short_name = \"{c_escape(fn.short_name)}\";\n")
|
||||
out_lines.append(" cbm_registry_add_func(reg, rf);\n")
|
||||
|
||||
output_path.write_text(C_HEADER + "".join(out_lines) + C_FOOTER, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"stdlib_root",
|
||||
nargs="?",
|
||||
default="/tmp/python-lsp-references/typeshed/stdlib",
|
||||
)
|
||||
parser.add_argument(
|
||||
"output",
|
||||
nargs="?",
|
||||
default="internal/cbm/lsp/generated/python_stdlib_data.c",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.stdlib_root).resolve()
|
||||
if not root.is_dir():
|
||||
print(f"error: {root} is not a directory", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
stubs = list(iter_module_stubs(root))
|
||||
out_path = Path(args.output).resolve()
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
emit(stubs, out_path)
|
||||
|
||||
n_classes = sum(len(s.classes) for s in stubs)
|
||||
n_methods = sum(sum(len(c.methods) for c in s.classes) for s in stubs)
|
||||
n_funcs = sum(len(s.functions) for s in stubs)
|
||||
print(
|
||||
f"wrote {out_path}: {len(stubs)} modules, "
|
||||
f"{n_classes} classes ({n_methods} methods), {n_funcs} free functions"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1309
-90
File diff suppressed because it is too large.
Load diff
+89
-86
@@ -418,7 +418,7 @@ void cbm_lex_free(cbm_lex_result_t *r) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
free((void *)r->tokens[i].text);
|
||||
safe_str_free(&r->tokens[i].text);
|
||||
}
|
||||
free(r->tokens);
|
||||
free(r->error);
|
||||
@@ -507,8 +507,8 @@ static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
|
||||
void *tmp = realloc(arr, new_cap * sizeof(cbm_prop_filter_t));
|
||||
if (!tmp) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)arr[i].key);
|
||||
free((void *)arr[i].value);
|
||||
safe_str_free(&arr[i].key);
|
||||
safe_str_free(&arr[i].value);
|
||||
}
|
||||
free(arr);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -519,11 +519,11 @@ static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
|
||||
const char *new_key = heap_strdup(key->text);
|
||||
const char *new_val = heap_strdup(val->text);
|
||||
if (!new_key || !new_val) {
|
||||
free((void *)new_key);
|
||||
free((void *)new_val);
|
||||
safe_str_free(&new_key);
|
||||
safe_str_free(&new_val);
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)arr[i].key);
|
||||
free((void *)arr[i].value);
|
||||
safe_str_free(&arr[i].key);
|
||||
safe_str_free(&arr[i].value);
|
||||
}
|
||||
free(arr);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -627,7 +627,7 @@ static int parse_rel_types(parser_t *p, cbm_rel_pattern_t *out) {
|
||||
t = expect(p, TOK_IDENT);
|
||||
if (!t) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)types[i]);
|
||||
safe_str_free(&types[i]);
|
||||
}
|
||||
free(types);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -637,7 +637,7 @@ static int parse_rel_types(parser_t *p, cbm_rel_pattern_t *out) {
|
||||
void *tmp = realloc(types, new_cap * sizeof(const char *));
|
||||
if (!tmp) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)types[i]);
|
||||
safe_str_free(&types[i]);
|
||||
}
|
||||
free(types);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -648,7 +648,7 @@ static int parse_rel_types(parser_t *p, cbm_rel_pattern_t *out) {
|
||||
const char *next_type = heap_strdup(t->text);
|
||||
if (!next_type) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)types[i]);
|
||||
safe_str_free(&types[i]);
|
||||
}
|
||||
free(types);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -733,12 +733,12 @@ static void expr_free(cbm_expr_t *e) {
|
||||
while (top > 0) {
|
||||
cbm_expr_t *cur = stack[--top];
|
||||
if (cur->type == EXPR_CONDITION) {
|
||||
free((void *)cur->cond.variable);
|
||||
free((void *)cur->cond.property);
|
||||
free((void *)cur->cond.op);
|
||||
free((void *)cur->cond.value);
|
||||
safe_str_free(&cur->cond.variable);
|
||||
safe_str_free(&cur->cond.property);
|
||||
safe_str_free(&cur->cond.op);
|
||||
safe_str_free(&cur->cond.value);
|
||||
for (int i = 0; i < cur->cond.in_value_count; i++) {
|
||||
free((void *)cur->cond.in_values[i]);
|
||||
safe_str_free(&cur->cond.in_values[i]);
|
||||
}
|
||||
free(cur->cond.in_values);
|
||||
}
|
||||
@@ -817,23 +817,23 @@ static cbm_expr_t *parse_in_list(parser_t *p, cbm_condition_t *c) {
|
||||
advance(p);
|
||||
c->op = heap_strdup("IN");
|
||||
if (!c->op) {
|
||||
free((void *)c->variable);
|
||||
free((void *)c->property);
|
||||
safe_str_free(&c->variable);
|
||||
safe_str_free(&c->property);
|
||||
return NULL;
|
||||
}
|
||||
if (!expect(p, TOK_LBRACKET)) {
|
||||
free((void *)c->variable);
|
||||
free((void *)c->property);
|
||||
free((void *)c->op);
|
||||
safe_str_free(&c->variable);
|
||||
safe_str_free(&c->property);
|
||||
safe_str_free(&c->op);
|
||||
return NULL;
|
||||
}
|
||||
int vcap = CYP_INIT_CAP8;
|
||||
int vn = 0;
|
||||
const char **vals = malloc(vcap * sizeof(const char *));
|
||||
if (!vals) {
|
||||
free((void *)c->variable);
|
||||
free((void *)c->property);
|
||||
free((void *)c->op);
|
||||
safe_str_free(&c->variable);
|
||||
safe_str_free(&c->property);
|
||||
safe_str_free(&c->op);
|
||||
return NULL;
|
||||
}
|
||||
while (!check(p, TOK_RBRACKET) && !check(p, TOK_EOF)) {
|
||||
@@ -846,12 +846,12 @@ static cbm_expr_t *parse_in_list(parser_t *p, cbm_condition_t *c) {
|
||||
void *tmp = realloc((void *)vals, new_vcap * sizeof(const char *));
|
||||
if (!tmp) {
|
||||
for (int i = 0; i < vn; i++) {
|
||||
free((void *)vals[i]);
|
||||
safe_str_free(&vals[i]);
|
||||
}
|
||||
free((void *)vals);
|
||||
free((void *)c->variable);
|
||||
free((void *)c->property);
|
||||
free((void *)c->op);
|
||||
safe_free(vals);
|
||||
safe_str_free(&c->variable);
|
||||
safe_str_free(&c->property);
|
||||
safe_str_free(&c->op);
|
||||
return NULL;
|
||||
}
|
||||
vals = (const char **)tmp;
|
||||
@@ -860,12 +860,12 @@ static cbm_expr_t *parse_in_list(parser_t *p, cbm_condition_t *c) {
|
||||
const char *new_val = heap_strdup(advance(p)->text);
|
||||
if (!new_val) {
|
||||
for (int i = 0; i < vn; i++) {
|
||||
free((void *)vals[i]);
|
||||
safe_str_free(&vals[i]);
|
||||
}
|
||||
free((void *)vals);
|
||||
free((void *)c->variable);
|
||||
free((void *)c->property);
|
||||
free((void *)c->op);
|
||||
safe_free(vals);
|
||||
safe_str_free(&c->variable);
|
||||
safe_str_free(&c->property);
|
||||
safe_str_free(&c->op);
|
||||
return NULL;
|
||||
}
|
||||
vals[vn++] = new_val;
|
||||
@@ -967,8 +967,8 @@ static cbm_expr_t *parse_condition_expr(parser_t *p) {
|
||||
c.op = parse_comparison_op(p);
|
||||
if (!c.op) {
|
||||
snprintf(p->error, sizeof(p->error), "unexpected operator at pos %d", peek(p)->pos);
|
||||
free((void *)c.variable);
|
||||
free((void *)c.property);
|
||||
safe_str_free(&c.variable);
|
||||
safe_str_free(&c.property);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -983,9 +983,9 @@ static cbm_expr_t *parse_condition_expr(parser_t *p) {
|
||||
c.value = heap_strdup("false");
|
||||
} else {
|
||||
snprintf(p->error, sizeof(p->error), "expected value at pos %d", peek(p)->pos);
|
||||
free((void *)c.variable);
|
||||
free((void *)c.property);
|
||||
free((void *)c.op);
|
||||
safe_str_free(&c.variable);
|
||||
safe_str_free(&c.property);
|
||||
safe_str_free(&c.op);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -1181,10 +1181,10 @@ static cbm_case_expr_t *parse_case_expr(parser_t *p) {
|
||||
void *tmp = realloc(kase->branches, new_bcap * sizeof(cbm_case_branch_t));
|
||||
if (!tmp) {
|
||||
expr_free(when);
|
||||
free((void *)then_val);
|
||||
safe_str_free(&then_val);
|
||||
for (int i = 0; i < kase->branch_count; i++) {
|
||||
expr_free(kase->branches[i].when_expr);
|
||||
free((void *)kase->branches[i].then_val);
|
||||
safe_str_free(&kase->branches[i].then_val);
|
||||
}
|
||||
free(kase->branches);
|
||||
free(kase);
|
||||
@@ -1626,23 +1626,23 @@ void cbm_parse_free(cbm_parse_result_t *r) {
|
||||
static void free_pattern(cbm_pattern_t *pat) {
|
||||
for (int i = 0; i < pat->node_count; i++) {
|
||||
cbm_node_pattern_t *n = &pat->nodes[i];
|
||||
free((void *)n->variable);
|
||||
free((void *)n->label);
|
||||
safe_str_free(&n->variable);
|
||||
safe_str_free(&n->label);
|
||||
for (int j = 0; j < n->prop_count; j++) {
|
||||
free((void *)n->props[j].key);
|
||||
free((void *)n->props[j].value);
|
||||
safe_str_free(&n->props[j].key);
|
||||
safe_str_free(&n->props[j].value);
|
||||
}
|
||||
free(n->props);
|
||||
}
|
||||
free(pat->nodes);
|
||||
for (int i = 0; i < pat->rel_count; i++) {
|
||||
cbm_rel_pattern_t *r = &pat->rels[i];
|
||||
free((void *)r->variable);
|
||||
safe_str_free(&r->variable);
|
||||
for (int j = 0; j < r->type_count; j++) {
|
||||
free((void *)r->types[j]);
|
||||
safe_str_free(&r->types[j]);
|
||||
}
|
||||
free(r->types);
|
||||
free((void *)r->direction);
|
||||
safe_str_free(&r->direction);
|
||||
}
|
||||
free(pat->rels);
|
||||
}
|
||||
@@ -1653,17 +1653,17 @@ static void free_where(cbm_where_clause_t *w) {
|
||||
}
|
||||
expr_free(w->root);
|
||||
for (int i = 0; i < w->count; i++) {
|
||||
free((void *)w->conditions[i].variable);
|
||||
free((void *)w->conditions[i].property);
|
||||
free((void *)w->conditions[i].op);
|
||||
free((void *)w->conditions[i].value);
|
||||
safe_str_free(&w->conditions[i].variable);
|
||||
safe_str_free(&w->conditions[i].property);
|
||||
safe_str_free(&w->conditions[i].op);
|
||||
safe_str_free(&w->conditions[i].value);
|
||||
for (int j = 0; j < w->conditions[i].in_value_count; j++) {
|
||||
free((void *)w->conditions[i].in_values[j]);
|
||||
safe_str_free(&w->conditions[i].in_values[j]);
|
||||
}
|
||||
free(w->conditions[i].in_values);
|
||||
}
|
||||
free(w->conditions);
|
||||
free((void *)w->op);
|
||||
safe_str_free(&w->op);
|
||||
free(w);
|
||||
}
|
||||
|
||||
@@ -1673,10 +1673,10 @@ static void free_case_expr(cbm_case_expr_t *k) {
|
||||
}
|
||||
for (int i = 0; i < k->branch_count; i++) {
|
||||
expr_free(k->branches[i].when_expr);
|
||||
free((void *)k->branches[i].then_val);
|
||||
safe_str_free(&k->branches[i].then_val);
|
||||
}
|
||||
free(k->branches);
|
||||
free((void *)k->else_val);
|
||||
safe_str_free(&k->else_val);
|
||||
free(k);
|
||||
}
|
||||
|
||||
@@ -1685,15 +1685,15 @@ static void free_return_clause(cbm_return_clause_t *r) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
free((void *)r->items[i].variable);
|
||||
free((void *)r->items[i].property);
|
||||
free((void *)r->items[i].alias);
|
||||
free((void *)r->items[i].func);
|
||||
safe_str_free(&r->items[i].variable);
|
||||
safe_str_free(&r->items[i].property);
|
||||
safe_str_free(&r->items[i].alias);
|
||||
safe_str_free(&r->items[i].func);
|
||||
free_case_expr(r->items[i].kase);
|
||||
}
|
||||
free(r->items);
|
||||
free((void *)r->order_by);
|
||||
free((void *)r->order_dir);
|
||||
safe_str_free(&r->order_by);
|
||||
safe_str_free(&r->order_dir);
|
||||
free(r);
|
||||
}
|
||||
|
||||
@@ -1709,8 +1709,8 @@ void cbm_query_free(cbm_query_t *q) {
|
||||
free_where(q->post_with_where);
|
||||
free_return_clause(q->with_clause);
|
||||
free_return_clause(q->ret);
|
||||
free((void *)q->unwind_expr);
|
||||
free((void *)q->unwind_alias);
|
||||
safe_str_free(&q->unwind_expr);
|
||||
safe_str_free(&q->unwind_alias);
|
||||
free(q);
|
||||
q = next;
|
||||
}
|
||||
@@ -1905,12 +1905,12 @@ static void node_fields_free(cbm_node_t *n) {
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
free((void *)n->project);
|
||||
free((void *)n->label);
|
||||
free((void *)n->name);
|
||||
free((void *)n->qualified_name);
|
||||
free((void *)n->file_path);
|
||||
free((void *)n->properties_json);
|
||||
safe_str_free(&n->project);
|
||||
safe_str_free(&n->label);
|
||||
safe_str_free(&n->name);
|
||||
safe_str_free(&n->qualified_name);
|
||||
safe_str_free(&n->file_path);
|
||||
safe_str_free(&n->properties_json);
|
||||
}
|
||||
|
||||
/* Deep copy an edge (binding owns the strings) */
|
||||
@@ -1922,9 +1922,9 @@ static void edge_deep_copy(cbm_edge_t *dst, const cbm_edge_t *src) {
|
||||
}
|
||||
|
||||
static void edge_fields_free(cbm_edge_t *e) {
|
||||
free((void *)e->project);
|
||||
free((void *)e->type);
|
||||
free((void *)e->properties_json);
|
||||
safe_str_free(&e->project);
|
||||
safe_str_free(&e->type);
|
||||
safe_str_free(&e->properties_json);
|
||||
}
|
||||
|
||||
/* Set an edge variable in a binding */
|
||||
@@ -2227,7 +2227,10 @@ static const char *binding_get_virtual(binding_t *b, const char *var, const char
|
||||
/* Fall through to normal lookup */
|
||||
cbm_edge_t *e = binding_get_edge(b, var);
|
||||
if (e) {
|
||||
return prop ? edge_prop(e, prop) : "";
|
||||
/* Bare `RETURN r` on an edge: surface the full properties JSON
|
||||
* (or "{}" if none) so callers can inspect timestamps, weights,
|
||||
* etc. without naming each property. */
|
||||
return prop ? edge_prop(e, prop) : (e->properties_json ? e->properties_json : "{}");
|
||||
}
|
||||
cbm_node_t *n = binding_get(b, var);
|
||||
if (n) {
|
||||
@@ -2567,7 +2570,7 @@ static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit) {
|
||||
if (skip_n > 0 && skip_n < rb->row_count) {
|
||||
for (int i = 0; i < skip_n; i++) {
|
||||
for (int c = 0; c < rb->col_count; c++) {
|
||||
free((void *)rb->rows[i][c]);
|
||||
safe_str_free(&rb->rows[i][c]);
|
||||
}
|
||||
free(rb->rows[i]);
|
||||
}
|
||||
@@ -2576,7 +2579,7 @@ static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit) {
|
||||
} else if (skip_n >= rb->row_count) {
|
||||
for (int i = 0; i < rb->row_count; i++) {
|
||||
for (int c = 0; c < rb->col_count; c++) {
|
||||
free((void *)rb->rows[i][c]);
|
||||
safe_str_free(&rb->rows[i][c]);
|
||||
}
|
||||
free(rb->rows[i]);
|
||||
}
|
||||
@@ -2586,7 +2589,7 @@ static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit) {
|
||||
if (limit > 0 && rb->row_count > limit) {
|
||||
for (int i = limit; i < rb->row_count; i++) {
|
||||
for (int c = 0; c < rb->col_count; c++) {
|
||||
free((void *)rb->rows[i][c]);
|
||||
safe_str_free(&rb->rows[i][c]);
|
||||
}
|
||||
free(rb->rows[i]);
|
||||
}
|
||||
@@ -2619,7 +2622,7 @@ static void rb_apply_distinct(result_builder_t *rb) {
|
||||
kept++;
|
||||
} else {
|
||||
for (int c = 0; c < rb->col_count; c++) {
|
||||
free((void *)rb->rows[i][c]);
|
||||
safe_str_free(&rb->rows[i][c]);
|
||||
}
|
||||
free(rb->rows[i]);
|
||||
}
|
||||
@@ -2630,13 +2633,13 @@ static void rb_apply_distinct(result_builder_t *rb) {
|
||||
static void rb_free(result_builder_t *rb) {
|
||||
for (int i = 0; i < rb->row_count; i++) {
|
||||
for (int c = 0; c < rb->col_count; c++) {
|
||||
free((void *)rb->rows[i][c]);
|
||||
safe_str_free(&rb->rows[i][c]);
|
||||
}
|
||||
free(rb->rows[i]);
|
||||
}
|
||||
free(rb->rows);
|
||||
for (int i = 0; i < rb->col_count; i++) {
|
||||
free((void *)rb->columns[i]);
|
||||
safe_str_free(&rb->columns[i]);
|
||||
}
|
||||
free(rb->columns);
|
||||
}
|
||||
@@ -2837,7 +2840,7 @@ static void with_add_vbinding_var(binding_t *vb, const char *alias, const char *
|
||||
static void with_agg_free(with_agg_t *aggs, int agg_cnt, int item_count) {
|
||||
for (int a = 0; a < agg_cnt; a++) {
|
||||
for (int ci = 0; ci < item_count; ci++) {
|
||||
free((void *)aggs[a].group_vals[ci]);
|
||||
safe_str_free(&aggs[a].group_vals[ci]);
|
||||
}
|
||||
free(aggs[a].group_vals);
|
||||
free(aggs[a].sums);
|
||||
@@ -2997,7 +3000,7 @@ static void build_star_columns(result_builder_t *rb, const char **vars, int vc)
|
||||
}
|
||||
rb_set_columns(rb, col_names, col_n);
|
||||
for (int i = 0; i < col_n; i++) {
|
||||
free((void *)col_names[i]);
|
||||
safe_str_free(&col_names[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3134,7 +3137,7 @@ static void ret_agg_accumulate(ret_agg_entry_t *entry, cbm_return_clause_t *ret,
|
||||
static void ret_agg_free(ret_agg_entry_t *aggs, int agg_count, int item_count) {
|
||||
for (int a = 0; a < agg_count; a++) {
|
||||
for (int ci = 0; ci < item_count; ci++) {
|
||||
free((void *)aggs[a].group_vals[ci]);
|
||||
safe_str_free(&aggs[a].group_vals[ci]);
|
||||
for (int j = 0; j < aggs[a].collect_counts[ci]; j++) {
|
||||
free(aggs[a].collect_lists[ci][j]);
|
||||
}
|
||||
@@ -3247,7 +3250,7 @@ static void build_return_columns(result_builder_t *rb, cbm_return_clause_t *ret)
|
||||
for (int i = 0; i < ret->count && i < CBM_SZ_32; i++) {
|
||||
cbm_return_item_t *item = &ret->items[i];
|
||||
if (!item->alias && (item->func || (!item->kase && item->property))) {
|
||||
free((void *)col_names[i]);
|
||||
safe_str_free(&col_names[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3285,7 +3288,7 @@ static void build_default_columns(result_builder_t *rb, const char **vars, int v
|
||||
}
|
||||
rb_set_columns(rb, col_names, col_n);
|
||||
for (int i = 0; i < col_n; i++) {
|
||||
free((void *)col_names[i]);
|
||||
safe_str_free(&col_names[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3562,12 +3565,12 @@ void cbm_cypher_result_free(cbm_cypher_result_t *r) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < r->col_count; i++) {
|
||||
free((void *)r->columns[i]);
|
||||
safe_str_free(&r->columns[i]);
|
||||
}
|
||||
free(r->columns);
|
||||
for (int i = 0; i < r->row_count; i++) {
|
||||
for (int j = 0; j < r->col_count; j++) {
|
||||
free((void *)r->rows[i][j]);
|
||||
safe_str_free(&r->rows[i][j]);
|
||||
}
|
||||
free(r->rows[i]);
|
||||
}
|
||||
|
||||
@@ -461,6 +461,9 @@ static const ext_entry_t EXT_TABLE[] = {
|
||||
/* Diff */
|
||||
{".patch", CBM_LANG_DIFF},
|
||||
|
||||
/* Pine Script */
|
||||
{".pine", CBM_LANG_PINE},
|
||||
|
||||
/* Pkl */
|
||||
{".pkl", CBM_LANG_PKL},
|
||||
|
||||
@@ -714,6 +717,7 @@ static const char *LANG_NAMES[CBM_LANG_COUNT] = {
|
||||
[CBM_LANG_WOLFRAM] = "Wolfram",
|
||||
[CBM_LANG_KUSTOMIZE] = "Kustomize",
|
||||
[CBM_LANG_K8S] = "Kubernetes",
|
||||
[CBM_LANG_PINE] = "PineScript",
|
||||
[CBM_LANG_SOLIDITY] = "Solidity",
|
||||
[CBM_LANG_TYPST] = "Typst",
|
||||
[CBM_LANG_GDSCRIPT] = "GDScript",
|
||||
|
||||
@@ -68,6 +68,14 @@ enum {
|
||||
CBM_INIT_DONE = 1, /* initialization flag */
|
||||
};
|
||||
|
||||
/* ── Default pagination limits ───────────────────────────────── */
|
||||
/* Default page size for search_graph and the underlying store-layer search.
|
||||
* Chosen so a typical broad query (e.g. file_pattern="**" on a 12k-node
|
||||
* project) stays well within MCP tool-result size budgets. Callers that
|
||||
* want more results paginate via offset+limit; the response always carries
|
||||
* 'total' and 'has_more' so agents can detect truncation. */
|
||||
enum { CBM_DEFAULT_SEARCH_LIMIT = 200 };
|
||||
|
||||
/* ── Time conversion factors ─────────────────────────────────── */
|
||||
#define CBM_NSEC_PER_SEC 1000000000ULL
|
||||
#define CBM_USEC_PER_SEC 1000000ULL
|
||||
|
||||
@@ -31,6 +31,52 @@ static inline void *safe_realloc(void *ptr, size_t size) {
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/* Safe free: frees and NULLs a pointer to prevent double-free / use-after-free.
|
||||
* Use via the safe_free() macro so the caller's pointer is actually cleared. */
|
||||
static inline void safe_free_impl(void **pp) {
|
||||
if (pp && *pp) {
|
||||
free(*pp);
|
||||
*pp = NULL;
|
||||
}
|
||||
}
|
||||
#define safe_free(ptr) safe_free_impl((void **)(void *)&(ptr))
|
||||
|
||||
/* Safe const string free: frees a const char* and NULLs it.
|
||||
* Casts away const so callers don't repeat the (void *) dance. */
|
||||
static inline void safe_str_free(const char **sp) {
|
||||
if (sp && *sp) {
|
||||
free((void *)*sp);
|
||||
*sp = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe buffer free: frees a heap array and zeros its element count.
|
||||
* Use for dynamic arrays paired with a size_t count. */
|
||||
static inline void safe_buf_free_impl(void **buf, size_t *count) {
|
||||
if (buf && *buf) {
|
||||
free(*buf);
|
||||
*buf = NULL;
|
||||
}
|
||||
if (count) {
|
||||
*count = 0;
|
||||
}
|
||||
}
|
||||
#define safe_buf_free(buf, countp) safe_buf_free_impl((void **)(void *)&(buf), (countp))
|
||||
|
||||
/* Safe grow: doubles capacity and reallocs when count reaches cap.
|
||||
* Note: uses safe_realloc which frees the old buffer on failure, so this is
|
||||
* only appropriate for arrays whose elements don't own additional heap memory.
|
||||
* For arrays of heap-allocated pointers, prefer a manual realloc+cleanup pattern.
|
||||
* Usage: safe_grow(arr, count, cap, growth_factor)
|
||||
* After the call, arr is the new buffer (NULL on OOM). */
|
||||
#define safe_grow(arr, n, cap, factor) \
|
||||
do { \
|
||||
if ((size_t)(n) >= (size_t)(cap)) { \
|
||||
(cap) *= (factor); \
|
||||
(arr) = safe_realloc((arr), (size_t)(cap) * sizeof(*(arr))); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* ── Memory mapping ────────────────────────────────────────────── */
|
||||
|
||||
/* Map a file read-only into memory. Returns NULL on error.
|
||||
|
||||
+281
-38
@@ -166,9 +166,9 @@ void cbm_jsonrpc_request_free(cbm_jsonrpc_request_t *r) {
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
free((void *)r->jsonrpc);
|
||||
free((void *)r->method);
|
||||
free((void *)r->params_raw);
|
||||
safe_str_free(&r->jsonrpc);
|
||||
safe_str_free(&r->method);
|
||||
safe_str_free(&r->params_raw);
|
||||
memset(r, 0, sizeof(*r));
|
||||
}
|
||||
|
||||
@@ -285,7 +285,12 @@ static const tool_def_t TOOLS[] = {
|
||||
"splitting and structural label boosting — recommended for natural-language discovery; "
|
||||
"(2) name_pattern='.*regex.*' for exact pattern matching; (3) semantic_query=[...] for "
|
||||
"vector cosine search that bridges vocabulary (finds 'publish' when you search 'send'). "
|
||||
"The three modes are independent and can be combined in a single call.",
|
||||
"The three modes are independent and can be combined in a single call. "
|
||||
"PAGINATION: results are capped at limit (default 200) — broader queries are silently "
|
||||
"truncated. The response always includes 'total' (full match count before limit) and "
|
||||
"'has_more' (true when total > offset+returned). Detect truncation with has_more, then "
|
||||
"page by re-calling with offset=offset+limit until has_more is false. Narrow first via "
|
||||
"label/file_pattern/min_degree before paginating large result sets.",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},"
|
||||
"\"query\":{\"type\":\"string\",\"description\":\"Natural-language or keyword full-text "
|
||||
"search using BM25 ranking. Tokens are split on whitespace; camelCase identifiers are "
|
||||
@@ -303,17 +308,24 @@ static const tool_def_t TOOLS[] = {
|
||||
"Each keyword is scored independently via per-keyword min-cosine; results reflect functions "
|
||||
"that score well on ALL keywords. Requires moderate/full index mode. Results appear in the "
|
||||
"'semantic_results' field (separate from 'results').\"},\"limit\":{\"type\":"
|
||||
"\"integer\",\"description\":\"Max results. Default: "
|
||||
"unlimited\"},\"offset\":{\"type\":\"integer\",\"default\":0}},\"required\":[\"project\"]}"},
|
||||
"\"integer\",\"description\":\"Max results per call. Default 200. Response carries "
|
||||
"'total' (full match count) and 'has_more' (true if truncated) so callers can "
|
||||
"detect the limit and paginate.\"},\"offset\":{\"type\":\"integer\",\"default\":0,"
|
||||
"\"description\":\"Skip the first N matching nodes. Combine with 'limit' to page: "
|
||||
"increment offset by limit and re-call while has_more is true.\"}},"
|
||||
"\"required\":[\"project\"]}"},
|
||||
|
||||
{"query_graph",
|
||||
"Execute a Cypher query against the knowledge graph for complex multi-hop patterns, "
|
||||
"aggregations, and cross-service analysis.",
|
||||
"aggregations, and cross-service analysis. The response includes 'total' (returned "
|
||||
"row count). There is a hard 100k row ceiling — for broad queries add LIMIT in the "
|
||||
"Cypher itself or use search_graph + offset/limit pagination instead.",
|
||||
"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"Cypher "
|
||||
"query\"},\"project\":{\"type\":\"string\"},\"max_rows\":{\"type\":\"integer\","
|
||||
"\"description\":"
|
||||
"\"Optional row limit. Default: unlimited (100k "
|
||||
"ceiling)\"}},\"required\":[\"query\",\"project\"]}"},
|
||||
"\"Optional row limit. Default: unlimited up to a 100k row "
|
||||
"ceiling. No offset support — use search_graph for paginated browsing.\"}},"
|
||||
"\"required\":[\"query\",\"project\"]}"},
|
||||
|
||||
{"trace_path",
|
||||
"Trace paths through the code graph. Modes: calls (callers/callees), data_flow (value "
|
||||
@@ -358,7 +370,11 @@ static const tool_def_t TOOLS[] = {
|
||||
"the knowledge graph: deduplicates matches into containing functions, ranks by structural "
|
||||
"importance (definitions first, popular functions next, tests last). "
|
||||
"Modes: compact (default, signatures only — token efficient), full (with source), "
|
||||
"files (just file paths). Use path_filter regex to scope results.",
|
||||
"files (just file paths). Use path_filter regex to scope results. "
|
||||
"TRUNCATION: enriched results are capped at limit (default 10). Response carries "
|
||||
"'total_grep_matches' (raw grep hit count) and 'total_results' (deduplicated function "
|
||||
"count) — compare to limit to detect truncation. There is no offset parameter; to see "
|
||||
"more, raise limit or narrow the query with file_pattern / path_filter.",
|
||||
"{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":"
|
||||
"\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob for grep "
|
||||
"--include (e.g. *.go)\"},\"path_filter\":{\"type\":\"string\",\"description\":\"Regex "
|
||||
@@ -368,8 +384,10 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"context\":{\"type\":\"integer\",\"description\":\"Lines of context around each match "
|
||||
"(like grep -C). Only used in compact mode.\"},"
|
||||
"\"regex\":{\"type\":\"boolean\",\"default\":false},\"limit\":{\"type\":\"integer\","
|
||||
"\"description\":\"Max results (default 10)\",\"default\":10}},\"required\":["
|
||||
"\"pattern\",\"project\"]}"},
|
||||
"\"description\":\"Max enriched results per call. Default 10. Response includes "
|
||||
"'total_grep_matches' and 'total_results' so callers can detect truncation. No "
|
||||
"offset parameter — raise limit or narrow with file_pattern / path_filter to see more."
|
||||
"\",\"default\":10}},\"required\":[\"pattern\",\"project\"]}"},
|
||||
|
||||
{"list_projects", "List all indexed projects", "{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
@@ -876,9 +894,9 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c
|
||||
if (proj.root_path) {
|
||||
snprintf(root_path_buf, sizeof(root_path_buf), "%s", proj.root_path);
|
||||
}
|
||||
free((void *)proj.name);
|
||||
free((void *)proj.indexed_at);
|
||||
free((void *)proj.root_path);
|
||||
safe_str_free(&proj.name);
|
||||
safe_str_free(&proj.indexed_at);
|
||||
safe_str_free(&proj.root_path);
|
||||
}
|
||||
cbm_store_close(pstore);
|
||||
}
|
||||
@@ -1260,10 +1278,25 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
|
||||
return json;
|
||||
}
|
||||
|
||||
/* Emit the cbm_store_search results as a JSON "results" array on the doc. */
|
||||
/* Forward declaration — defined later. enrich_node_properties parses the
|
||||
* node's properties_json and grafts the parsed values onto the result item.
|
||||
* It returns the parsed yyjson_doc which must outlive the serialization
|
||||
* because yyjson_mut_obj_add_val uses zero-copy strings into that doc. */
|
||||
static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *obj,
|
||||
const char *properties_json);
|
||||
|
||||
/* Emit the cbm_store_search results as a JSON "results" array on the doc.
|
||||
* Property docs created via enrich_node_properties are collected in
|
||||
* *out_pdocs (count in *out_pdoc_count) and must be freed by the caller
|
||||
* AFTER serializing doc, since yyjson_mut strings are zero-copy pointers
|
||||
* into those parsed docs. The caller also frees out_pdocs itself. */
|
||||
static void emit_search_results(yyjson_mut_doc *doc, yyjson_mut_val *root,
|
||||
const cbm_search_output_t *out, cbm_store_t *store,
|
||||
const char *relationship, bool include_connected, int offset) {
|
||||
const char *relationship, bool include_connected, int offset,
|
||||
yyjson_doc ***out_pdocs, int *out_pdoc_count) {
|
||||
yyjson_doc **pdocs =
|
||||
out->count > 0 ? malloc((size_t)out->count * sizeof(yyjson_doc *)) : NULL;
|
||||
int pdoc_count = 0;
|
||||
yyjson_mut_obj_add_int(doc, root, "total", out->total);
|
||||
yyjson_mut_val *results = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < out->count; i++) {
|
||||
@@ -1280,10 +1313,16 @@ static void emit_search_results(yyjson_mut_doc *doc, yyjson_mut_val *root,
|
||||
if (include_connected && sr->node.id > 0) {
|
||||
enrich_connected(doc, item, store, sr->node.id, relationship);
|
||||
}
|
||||
yyjson_doc *pdoc = enrich_node_properties(doc, item, sr->node.properties_json);
|
||||
if (pdoc && pdocs) {
|
||||
pdocs[pdoc_count++] = pdoc;
|
||||
}
|
||||
yyjson_mut_arr_add_val(results, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "results", results);
|
||||
yyjson_mut_obj_add_bool(doc, root, "has_more", out->total > offset + out->count);
|
||||
*out_pdocs = pdocs;
|
||||
*out_pdoc_count = pdoc_count;
|
||||
}
|
||||
|
||||
/* Extract keyword strings from a yyjson array into `keywords`. Returns the
|
||||
@@ -1389,7 +1428,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
|
||||
char *relationship = cbm_mcp_get_string_arg(args, "relationship");
|
||||
bool exclude_entry_points = cbm_mcp_get_bool_arg(args, "exclude_entry_points");
|
||||
bool include_connected = cbm_mcp_get_bool_arg(args, "include_connected");
|
||||
int limit = cbm_mcp_get_int_arg(args, "limit", MCP_HALF_SEC_US);
|
||||
int limit = cbm_mcp_get_int_arg(args, "limit", CBM_DEFAULT_SEARCH_LIMIT);
|
||||
int offset = cbm_mcp_get_int_arg(args, "offset", 0);
|
||||
int min_degree = cbm_mcp_get_int_arg(args, "min_degree", CBM_NOT_FOUND);
|
||||
int max_degree = cbm_mcp_get_int_arg(args, "max_degree", CBM_NOT_FOUND);
|
||||
@@ -1426,7 +1465,10 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
|
||||
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
|
||||
emit_search_results(doc, root, &out, store, relationship, include_connected, offset);
|
||||
yyjson_doc **props_docs = NULL;
|
||||
int props_doc_count = 0;
|
||||
emit_search_results(doc, root, &out, store, relationship, include_connected, offset,
|
||||
&props_docs, &props_doc_count);
|
||||
|
||||
/* Add diagnostic hint when zero results */
|
||||
if (out.total == 0) {
|
||||
@@ -1449,6 +1491,10 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
|
||||
bool sq_type_error = run_semantic_query(doc, root, args, store, project, limit);
|
||||
|
||||
if (sq_type_error) {
|
||||
for (int pi = 0; pi < props_doc_count; pi++) {
|
||||
yyjson_doc_free(props_docs[pi]);
|
||||
}
|
||||
free(props_docs);
|
||||
yyjson_mut_doc_free(doc);
|
||||
cbm_store_search_free(&out);
|
||||
free(project);
|
||||
@@ -1466,6 +1512,12 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
|
||||
}
|
||||
|
||||
char *json = yy_doc_to_str(doc);
|
||||
/* Property docs are zero-copy referenced by the mut doc — they must
|
||||
* outlive yy_doc_to_str. Free them once serialization is complete. */
|
||||
for (int pi = 0; pi < props_doc_count; pi++) {
|
||||
yyjson_doc_free(props_docs[pi]);
|
||||
}
|
||||
free(props_docs);
|
||||
yyjson_mut_doc_free(doc);
|
||||
cbm_store_search_free(&out);
|
||||
|
||||
@@ -1733,9 +1785,30 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Build a C string array from aspects for cbm_store_get_architecture.
|
||||
* Strings point into aspects_doc memory so aspects_doc must outlive this array. */
|
||||
const char *aspects_strs[MCP_COL_16];
|
||||
int aspects_strs_count = 0;
|
||||
if (aspects_arr) {
|
||||
size_t aspect_idx;
|
||||
size_t aspect_max;
|
||||
yyjson_val *aspect_val;
|
||||
yyjson_arr_foreach(aspects_arr, aspect_idx, aspect_max, aspect_val) {
|
||||
const char *s = yyjson_get_str(aspect_val);
|
||||
if (s && aspects_strs_count < MCP_COL_16) {
|
||||
aspects_strs[aspects_strs_count++] = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cbm_schema_info_t schema = {0};
|
||||
cbm_store_get_schema(store, project, &schema);
|
||||
|
||||
cbm_architecture_info_t arch = {0};
|
||||
cbm_store_get_architecture(store, project,
|
||||
aspects_strs_count > 0 ? aspects_strs : NULL,
|
||||
aspects_strs_count, &arch);
|
||||
|
||||
int node_count = cbm_store_count_nodes(store, project);
|
||||
int edge_count = cbm_store_count_edges(store, project);
|
||||
|
||||
@@ -1782,10 +1855,183 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
|
||||
yyjson_mut_obj_add_val(doc, root, "relationship_patterns", pats);
|
||||
}
|
||||
|
||||
/* Languages */
|
||||
if (arch.language_count > 0) {
|
||||
yyjson_mut_val *langs = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.language_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "language",
|
||||
arch.languages[i].language ? arch.languages[i].language : "");
|
||||
yyjson_mut_obj_add_int(doc, item, "file_count", arch.languages[i].file_count);
|
||||
yyjson_mut_arr_add_val(langs, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "languages", langs);
|
||||
}
|
||||
|
||||
/* Packages */
|
||||
if (arch.package_count > 0) {
|
||||
yyjson_mut_val *pkgs = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.package_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "name",
|
||||
arch.packages[i].name ? arch.packages[i].name : "");
|
||||
yyjson_mut_obj_add_int(doc, item, "node_count", arch.packages[i].node_count);
|
||||
yyjson_mut_obj_add_int(doc, item, "fan_in", arch.packages[i].fan_in);
|
||||
yyjson_mut_obj_add_int(doc, item, "fan_out", arch.packages[i].fan_out);
|
||||
yyjson_mut_arr_add_val(pkgs, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "packages", pkgs);
|
||||
}
|
||||
|
||||
/* Entry points */
|
||||
if (arch.entry_point_count > 0) {
|
||||
yyjson_mut_val *eps = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.entry_point_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "name",
|
||||
arch.entry_points[i].name ? arch.entry_points[i].name : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "qualified_name",
|
||||
arch.entry_points[i].qualified_name
|
||||
? arch.entry_points[i].qualified_name
|
||||
: "");
|
||||
yyjson_mut_obj_add_str(doc, item, "file",
|
||||
arch.entry_points[i].file ? arch.entry_points[i].file : "");
|
||||
yyjson_mut_arr_add_val(eps, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "entry_points", eps);
|
||||
}
|
||||
|
||||
/* HTTP routes */
|
||||
if (arch.route_count > 0) {
|
||||
yyjson_mut_val *routes = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.route_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "method",
|
||||
arch.routes[i].method ? arch.routes[i].method : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "path",
|
||||
arch.routes[i].path ? arch.routes[i].path : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "handler",
|
||||
arch.routes[i].handler ? arch.routes[i].handler : "");
|
||||
yyjson_mut_arr_add_val(routes, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "routes", routes);
|
||||
}
|
||||
|
||||
/* Hotspots */
|
||||
if (arch.hotspot_count > 0) {
|
||||
yyjson_mut_val *hotspots = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.hotspot_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "name",
|
||||
arch.hotspots[i].name ? arch.hotspots[i].name : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "qualified_name",
|
||||
arch.hotspots[i].qualified_name
|
||||
? arch.hotspots[i].qualified_name
|
||||
: "");
|
||||
yyjson_mut_obj_add_int(doc, item, "fan_in", arch.hotspots[i].fan_in);
|
||||
yyjson_mut_arr_add_val(hotspots, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "hotspots", hotspots);
|
||||
}
|
||||
|
||||
/* Cross-package boundaries */
|
||||
if (arch.boundary_count > 0) {
|
||||
yyjson_mut_val *boundaries = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.boundary_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "from",
|
||||
arch.boundaries[i].from ? arch.boundaries[i].from : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "to",
|
||||
arch.boundaries[i].to ? arch.boundaries[i].to : "");
|
||||
yyjson_mut_obj_add_int(doc, item, "call_count", arch.boundaries[i].call_count);
|
||||
yyjson_mut_arr_add_val(boundaries, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "boundaries", boundaries);
|
||||
}
|
||||
|
||||
/* Cross-service links (HTTP/async between services) */
|
||||
if (arch.service_count > 0) {
|
||||
yyjson_mut_val *services = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.service_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "from",
|
||||
arch.services[i].from ? arch.services[i].from : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "to",
|
||||
arch.services[i].to ? arch.services[i].to : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "type",
|
||||
arch.services[i].type ? arch.services[i].type : "");
|
||||
yyjson_mut_obj_add_int(doc, item, "count", arch.services[i].count);
|
||||
yyjson_mut_arr_add_val(services, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "services", services);
|
||||
}
|
||||
|
||||
/* Package layers */
|
||||
if (arch.layer_count > 0) {
|
||||
yyjson_mut_val *layers = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.layer_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "name",
|
||||
arch.layers[i].name ? arch.layers[i].name : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "layer",
|
||||
arch.layers[i].layer ? arch.layers[i].layer : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "reason",
|
||||
arch.layers[i].reason ? arch.layers[i].reason : "");
|
||||
yyjson_mut_arr_add_val(layers, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "layers", layers);
|
||||
}
|
||||
|
||||
/* Clusters (community detection) */
|
||||
if (arch.cluster_count > 0) {
|
||||
yyjson_mut_val *clusters = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.cluster_count; i++) {
|
||||
const cbm_cluster_info_t *c = &arch.clusters[i];
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_int(doc, item, "id", c->id);
|
||||
yyjson_mut_obj_add_str(doc, item, "label", c->label ? c->label : "");
|
||||
yyjson_mut_obj_add_int(doc, item, "members", c->members);
|
||||
yyjson_mut_obj_add_real(doc, item, "cohesion", c->cohesion);
|
||||
yyjson_mut_val *top = yyjson_mut_arr(doc);
|
||||
for (int j = 0; j < c->top_node_count; j++) {
|
||||
yyjson_mut_arr_add_str(doc, top, c->top_nodes[j] ? c->top_nodes[j] : "");
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, item, "top_nodes", top);
|
||||
yyjson_mut_val *pkgs = yyjson_mut_arr(doc);
|
||||
for (int j = 0; j < c->package_count; j++) {
|
||||
yyjson_mut_arr_add_str(doc, pkgs, c->packages[j] ? c->packages[j] : "");
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, item, "packages", pkgs);
|
||||
yyjson_mut_val *etypes = yyjson_mut_arr(doc);
|
||||
for (int j = 0; j < c->edge_type_count; j++) {
|
||||
yyjson_mut_arr_add_str(doc, etypes, c->edge_types[j] ? c->edge_types[j] : "");
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, item, "edge_types", etypes);
|
||||
yyjson_mut_arr_add_val(clusters, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "clusters", clusters);
|
||||
}
|
||||
|
||||
/* File tree */
|
||||
if (arch.file_tree_count > 0) {
|
||||
yyjson_mut_val *file_tree = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < arch.file_tree_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "path",
|
||||
arch.file_tree[i].path ? arch.file_tree[i].path : "");
|
||||
yyjson_mut_obj_add_str(doc, item, "type",
|
||||
arch.file_tree[i].type ? arch.file_tree[i].type : "");
|
||||
yyjson_mut_obj_add_int(doc, item, "children", arch.file_tree[i].children);
|
||||
yyjson_mut_arr_add_val(file_tree, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "file_tree", file_tree);
|
||||
}
|
||||
|
||||
append_cross_repo_summary(doc, root, &schema);
|
||||
|
||||
char *json = yy_doc_to_str(doc);
|
||||
yyjson_mut_doc_free(doc);
|
||||
cbm_store_architecture_free(&arch);
|
||||
cbm_store_schema_free(&schema);
|
||||
if (aspects_doc) {
|
||||
yyjson_doc_free(aspects_doc);
|
||||
@@ -2018,12 +2264,12 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
|
||||
/* ── Helper: free heap fields of a stack-allocated node ────────── */
|
||||
|
||||
static void free_node_contents(cbm_node_t *n) {
|
||||
free((void *)n->project);
|
||||
free((void *)n->label);
|
||||
free((void *)n->name);
|
||||
free((void *)n->qualified_name);
|
||||
free((void *)n->file_path);
|
||||
free((void *)n->properties_json);
|
||||
safe_str_free(&n->project);
|
||||
safe_str_free(&n->label);
|
||||
safe_str_free(&n->name);
|
||||
safe_str_free(&n->qualified_name);
|
||||
safe_str_free(&n->file_path);
|
||||
safe_str_free(&n->properties_json);
|
||||
memset(n, 0, sizeof(*n));
|
||||
}
|
||||
|
||||
@@ -2083,9 +2329,9 @@ static char *get_project_root(cbm_mcp_server_t *srv, const char *project) {
|
||||
return NULL;
|
||||
}
|
||||
char *root = heap_strdup(proj.root_path);
|
||||
free((void *)proj.name);
|
||||
free((void *)proj.indexed_at);
|
||||
free((void *)proj.root_path);
|
||||
safe_str_free(&proj.name);
|
||||
safe_str_free(&proj.indexed_at);
|
||||
safe_str_free(&proj.root_path);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -2928,10 +3174,7 @@ static grep_match_t *collect_grep_matches(FILE *fp, const char *root_path, size_
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gm_count >= gm_cap) {
|
||||
gm_cap *= PAIR_LEN;
|
||||
gm = safe_realloc(gm, gm_cap * sizeof(grep_match_t));
|
||||
}
|
||||
safe_grow(gm, gm_count, gm_cap, PAIR_LEN);
|
||||
snprintf(gm[gm_count].file, sizeof(gm[0].file), "%s", file);
|
||||
gm[gm_count].line = (int)strtol(colon1 + SKIP_ONE, NULL, CBM_DECIMAL_BASE);
|
||||
snprintf(gm[gm_count].content, sizeof(gm[0].content), "%s", colon2 + SKIP_ONE);
|
||||
@@ -3010,12 +3253,12 @@ static void classify_grep_hit(grep_match_t *hit, cbm_node_t *file_nodes, int fil
|
||||
/* Free a file_nodes array returned from cbm_store_find_nodes_by_file. */
|
||||
static void free_file_nodes(cbm_node_t *nodes, int count) {
|
||||
for (int j = 0; j < count; j++) {
|
||||
free((void *)nodes[j].project);
|
||||
free((void *)nodes[j].label);
|
||||
free((void *)nodes[j].name);
|
||||
free((void *)nodes[j].qualified_name);
|
||||
free((void *)nodes[j].file_path);
|
||||
free((void *)nodes[j].properties_json);
|
||||
safe_str_free(&nodes[j].project);
|
||||
safe_str_free(&nodes[j].label);
|
||||
safe_str_free(&nodes[j].name);
|
||||
safe_str_free(&nodes[j].qualified_name);
|
||||
safe_str_free(&nodes[j].file_path);
|
||||
safe_str_free(&nodes[j].properties_json);
|
||||
}
|
||||
free(nodes);
|
||||
}
|
||||
|
||||
+57
-65
@@ -322,85 +322,77 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c
|
||||
return src;
|
||||
}
|
||||
|
||||
/* Return the substring after the last '.' (short name) of a qualified name. */
|
||||
static const char *qn_short_name(const char *qn) {
|
||||
if (!qn) return NULL;
|
||||
const char *last = qn;
|
||||
for (const char *p = qn; *p; p++) {
|
||||
if (*p == '.') last = p + 1;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
/* Confidence floor below which LSP-resolved calls are ignored and the
|
||||
* registry resolver is consulted instead. Locked at 0.6 per the v1
|
||||
* Python-LSP integration plan; revisit when telemetry justifies a knob.
|
||||
* Applies to every language whose LSP populates result->resolved_calls
|
||||
* (Go, C, Python, PHP). */
|
||||
#define CBM_LSP_CONFIDENCE_FLOOR 0.6f
|
||||
|
||||
/* Look up an LSP-resolved override for this call site.
|
||||
*
|
||||
* Match rule: the LSP emits CBMResolvedCall entries whose caller_qn matches
|
||||
* the call's enclosing function and whose callee_qn ends in "." + short
|
||||
* name. When the unified extractor's `callee_name` short-matches an LSP
|
||||
* entry, prefer the LSP attribution.
|
||||
*
|
||||
* Returns a cbm_resolution_t with non-empty qualified_name on hit, empty
|
||||
* struct on miss. The strategy is taken verbatim from the LSP entry. */
|
||||
static cbm_resolution_t lsp_override_resolution(const CBMFileResult *result, const CBMCall *call) {
|
||||
cbm_resolution_t empty = {0};
|
||||
if (!result || !call || !call->callee_name || !call->enclosing_func_qn) {
|
||||
return empty;
|
||||
/* Look up the highest-confidence LSP-resolved call entry whose caller QN
|
||||
* matches the textual call's enclosing function and whose callee QN
|
||||
* short-name matches the textual callee. Returns a pointer into
|
||||
* result->resolved_calls or NULL if no qualifying entry exists. */
|
||||
static const CBMResolvedCall *find_lsp_resolution(const CBMResolvedCallArray *arr,
|
||||
const CBMCall *call) {
|
||||
if (!arr || arr->count == 0 || !call) {
|
||||
return NULL;
|
||||
}
|
||||
if (result->resolved_calls.count == 0) {
|
||||
return empty;
|
||||
if (!call->enclosing_func_qn || !call->callee_name) {
|
||||
return NULL;
|
||||
}
|
||||
/* The unified extractor's callee_name for PHP method/static/function calls
|
||||
* is the bare method name. The LSP's callee_qn ends in "." + that name. */
|
||||
const char *short_name = call->callee_name;
|
||||
/* In case some extractor variants produce "Class.method", take the trailing
|
||||
* segment for matching. */
|
||||
const char *short_only = qn_short_name(short_name);
|
||||
if (!short_only || !short_only[0]) {
|
||||
return empty;
|
||||
}
|
||||
size_t snlen = strlen(short_only);
|
||||
|
||||
const CBMResolvedCall *best = NULL;
|
||||
for (int i = 0; i < result->resolved_calls.count; i++) {
|
||||
const CBMResolvedCall *rc = &result->resolved_calls.items[i];
|
||||
if (!rc->caller_qn || !rc->callee_qn) continue;
|
||||
if (strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) continue;
|
||||
/* require callee_qn to end in "." + short_only */
|
||||
size_t qlen = strlen(rc->callee_qn);
|
||||
if (qlen <= snlen) continue;
|
||||
if (rc->callee_qn[qlen - snlen - 1] != '.') continue;
|
||||
if (strcmp(rc->callee_qn + qlen - snlen, short_only) != 0) continue;
|
||||
if (rc->confidence < 0.5f) continue;
|
||||
for (int i = 0; i < arr->count; i++) {
|
||||
const CBMResolvedCall *rc = &arr->items[i];
|
||||
if (!rc->caller_qn || !rc->callee_qn) {
|
||||
continue;
|
||||
}
|
||||
if (rc->confidence < CBM_LSP_CONFIDENCE_FLOOR) {
|
||||
continue;
|
||||
}
|
||||
if (strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) {
|
||||
continue;
|
||||
}
|
||||
const char *short_name = strrchr(rc->callee_qn, '.');
|
||||
short_name = short_name ? short_name + SKIP_ONE : rc->callee_qn;
|
||||
if (strcmp(short_name, call->callee_name) != 0) {
|
||||
continue;
|
||||
}
|
||||
if (!best || rc->confidence > best->confidence) {
|
||||
best = rc;
|
||||
}
|
||||
}
|
||||
if (!best) return empty;
|
||||
|
||||
cbm_resolution_t r = {0};
|
||||
r.qualified_name = best->callee_qn;
|
||||
r.strategy = best->strategy ? best->strategy : "lsp_override";
|
||||
r.confidence = (double)best->confidence;
|
||||
r.candidate_count = 1;
|
||||
return r;
|
||||
return best;
|
||||
}
|
||||
|
||||
/* Resolve one call and emit the appropriate edge. Returns 1 if resolved, 0 if not. */
|
||||
static int resolve_single_call(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, CBMCall *call,
|
||||
const char *rel, const char *module_qn, const char **imp_keys,
|
||||
const char **imp_vals, int imp_count) {
|
||||
static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
|
||||
const CBMResolvedCallArray *lsp_calls, const char *rel,
|
||||
const char *module_qn, const char **imp_keys, const char **imp_vals,
|
||||
int imp_count) {
|
||||
const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn);
|
||||
if (!source_node) {
|
||||
return 0;
|
||||
}
|
||||
/* LSP override takes precedence: when a high-confidence type-resolved
|
||||
* call exists for this (caller, short_name) pair, use it instead of the
|
||||
* registry's name-based lookup. Falls through on miss. */
|
||||
cbm_resolution_t res = lsp_override_resolution(result, call);
|
||||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||||
res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals,
|
||||
imp_count);
|
||||
|
||||
/* LSP-resolved calls take precedence over registry-textual matching. */
|
||||
const CBMResolvedCall *lsp = find_lsp_resolution(lsp_calls, call);
|
||||
if (lsp) {
|
||||
const cbm_gbuf_node_t *target_node = cbm_gbuf_find_by_qn(ctx->gbuf, lsp->callee_qn);
|
||||
if (target_node && source_node->id != target_node->id) {
|
||||
cbm_resolution_t res = {0};
|
||||
res.qualified_name = lsp->callee_qn;
|
||||
res.confidence = lsp->confidence;
|
||||
res.strategy = lsp->strategy;
|
||||
res.candidate_count = 1;
|
||||
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn,
|
||||
imp_keys, imp_vals, imp_count);
|
||||
return SKIP_ONE;
|
||||
}
|
||||
}
|
||||
|
||||
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn,
|
||||
imp_keys, imp_vals, imp_count);
|
||||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
@@ -477,8 +469,8 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
|
||||
continue;
|
||||
}
|
||||
total_calls++;
|
||||
if (resolve_single_call(ctx, result, call, rel, module_qn, imp_keys, imp_vals,
|
||||
imp_count)) {
|
||||
if (resolve_single_call(ctx, call, &result->resolved_calls, rel, module_qn,
|
||||
imp_keys, imp_vals, imp_count)) {
|
||||
resolved++;
|
||||
} else {
|
||||
unresolved++;
|
||||
|
||||
@@ -87,6 +87,7 @@ typedef struct {
|
||||
char **files;
|
||||
int count;
|
||||
int cap;
|
||||
long long timestamp; /* unix epoch of this commit; 0 when unknown */
|
||||
} commit_t;
|
||||
|
||||
static void commit_add_file(commit_t *c, const char *file) {
|
||||
@@ -189,6 +190,7 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
|
||||
cap *= PAIR_LEN;
|
||||
commits = safe_realloc(commits, cap * sizeof(commit_t));
|
||||
}
|
||||
current.timestamp = (long long)ct;
|
||||
commits[count++] = current;
|
||||
} else {
|
||||
commit_free(¤t);
|
||||
@@ -224,7 +226,7 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
|
||||
|
||||
char cmd[CBM_SZ_1K];
|
||||
snprintf(cmd, sizeof(cmd),
|
||||
"cd '%s' && git log --name-only --pretty=format:COMMIT:%%H "
|
||||
"cd '%s' && git log --name-only --pretty=format:COMMIT:%%H:%%ct "
|
||||
"--since='1 year ago' --max-count=10000 2>/dev/null",
|
||||
repo_path);
|
||||
|
||||
@@ -257,6 +259,12 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
|
||||
commits[count++] = current;
|
||||
memset(¤t, 0, sizeof(current));
|
||||
}
|
||||
/* Parse the unix timestamp from "COMMIT:<hash>:<unix_epoch>".
|
||||
* Older callers / stripped-down git output without %ct land on 0. */
|
||||
const char *hash_end = strchr(line + SLEN("COMMIT:"), ':');
|
||||
if (hash_end) {
|
||||
current.timestamp = strtoll(hash_end + 1, NULL, 10);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -285,7 +293,7 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
|
||||
/* Callback to free hash table entries. */
|
||||
static void free_counter(const char *key, void *val, void *ud) {
|
||||
(void)ud;
|
||||
free((void *)key);
|
||||
safe_str_free(&key);
|
||||
free(val);
|
||||
}
|
||||
|
||||
@@ -294,6 +302,7 @@ static void free_counter(const char *key, void *val, void *ud) {
|
||||
/* Context for collect_coupling_result callback. */
|
||||
typedef struct {
|
||||
CBMHashTable *file_counts;
|
||||
CBMHashTable *pair_timestamps; /* pair_key → long long*: max commit ts */
|
||||
cbm_change_coupling_t *out;
|
||||
int out_count;
|
||||
int max_out;
|
||||
@@ -344,12 +353,18 @@ static void collect_coupling_cb(const char *pair_key, void *val, void *ud) {
|
||||
snprintf(cc->file_b, sizeof(cc->file_b), "%s", file_b);
|
||||
cc->co_change_count = co_count;
|
||||
cc->coupling_score = score;
|
||||
long long *ts = cbm_ht_get(cctx->pair_timestamps, pair_key);
|
||||
cc->last_co_change = ts ? *ts : 0;
|
||||
}
|
||||
|
||||
int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_count,
|
||||
cbm_change_coupling_t *out, int max_out) {
|
||||
CBMHashTable *file_counts = cbm_ht_create(CBM_SZ_1K);
|
||||
CBMHashTable *pair_counts = cbm_ht_create(CBM_SZ_2K);
|
||||
/* Parallel table mapping pair_key → max commit timestamp seen for that
|
||||
* pair, so the resulting edge can carry last_co_change. The pair_counts
|
||||
* table consumes its key on insert; pair_timestamps gets its own copy. */
|
||||
CBMHashTable *pair_timestamps = cbm_ht_create(CBM_SZ_2K);
|
||||
|
||||
for (int c = 0; c < commit_count; c++) {
|
||||
if (commits[c].count > GH_MAX_FILES) {
|
||||
@@ -378,7 +393,8 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co
|
||||
}
|
||||
size_t la = strlen(a);
|
||||
size_t lb = strlen(b);
|
||||
char *pk = malloc(la + SKIP_ONE + lb + SKIP_ONE);
|
||||
size_t pk_len = la + SKIP_ONE + lb + SKIP_ONE;
|
||||
char *pk = malloc(pk_len);
|
||||
memcpy(pk, a, la);
|
||||
pk[la] = '\x01';
|
||||
memcpy(pk + la + SKIP_ONE, b, lb + SKIP_ONE);
|
||||
@@ -386,11 +402,22 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co
|
||||
int *val = cbm_ht_get(pair_counts, pk);
|
||||
if (val) {
|
||||
(*val)++;
|
||||
long long *ts = cbm_ht_get(pair_timestamps, pk);
|
||||
if (ts && commits[c].timestamp > *ts) {
|
||||
*ts = commits[c].timestamp;
|
||||
}
|
||||
free(pk);
|
||||
} else {
|
||||
int *nv = malloc(sizeof(int));
|
||||
*nv = SKIP_ONE;
|
||||
/* pair_counts takes ownership of pk; pair_timestamps
|
||||
* needs its own copy. */
|
||||
char *pk2 = malloc(pk_len);
|
||||
memcpy(pk2, pk, pk_len);
|
||||
cbm_ht_set(pair_counts, pk, nv);
|
||||
long long *nts = malloc(sizeof(long long));
|
||||
*nts = commits[c].timestamp;
|
||||
cbm_ht_set(pair_timestamps, pk2, nts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +425,7 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co
|
||||
|
||||
collect_coupling_ctx_t cctx = {
|
||||
.file_counts = file_counts,
|
||||
.pair_timestamps = pair_timestamps,
|
||||
.out = out,
|
||||
.out_count = 0,
|
||||
.max_out = max_out,
|
||||
@@ -406,6 +434,8 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co
|
||||
|
||||
cbm_ht_foreach(pair_counts, free_counter, NULL);
|
||||
cbm_ht_free(pair_counts);
|
||||
cbm_ht_foreach(pair_timestamps, free_counter, NULL);
|
||||
cbm_ht_free(pair_timestamps);
|
||||
cbm_ht_foreach(file_counts, free_counter, NULL);
|
||||
cbm_ht_free(file_counts);
|
||||
|
||||
@@ -416,6 +446,7 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co
|
||||
|
||||
/* Pre-computed coupling result buffer for fused post-pass parallelism. */
|
||||
#define MAX_COUPLINGS 8192
|
||||
#define MAX_FILE_TEMPORAL 16384
|
||||
|
||||
/* Compute change couplings without touching the graph buffer.
|
||||
* Can run on a separate thread while other passes use the gbuf. */
|
||||
@@ -423,6 +454,8 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result
|
||||
result->couplings = NULL;
|
||||
result->count = 0;
|
||||
result->commit_count = 0;
|
||||
result->file_temporal = NULL;
|
||||
result->file_temporal_count = 0;
|
||||
|
||||
commit_t *commits = NULL;
|
||||
int commit_count = 0;
|
||||
@@ -446,11 +479,50 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result
|
||||
for (int c = 0; c < commit_count; c++) {
|
||||
cf[c].files = commits[c].files;
|
||||
cf[c].count = commits[c].count;
|
||||
cf[c].timestamp = commits[c].timestamp;
|
||||
}
|
||||
|
||||
cbm_change_coupling_t *couplings = malloc(MAX_COUPLINGS * sizeof(cbm_change_coupling_t));
|
||||
int coupling_count = cbm_compute_change_coupling(cf, commit_count, couplings, MAX_COUPLINGS);
|
||||
|
||||
/* Per-file temporal aggregation: change_count + last_modified.
|
||||
* Single hash-table pass over the same commit set used for coupling so
|
||||
* we don't re-scan history. NULL on OOM is fine — the caller still
|
||||
* gets the couplings. */
|
||||
cbm_file_temporal_t *ft_arr = malloc(MAX_FILE_TEMPORAL * sizeof(cbm_file_temporal_t));
|
||||
int ft_count = 0;
|
||||
if (ft_arr) {
|
||||
CBMHashTable *file_idx = cbm_ht_create(CBM_SZ_1K);
|
||||
for (int c = 0; c < commit_count; c++) {
|
||||
if (cf[c].count > GH_MAX_FILES) {
|
||||
continue;
|
||||
}
|
||||
for (int f = 0; f < cf[c].count; f++) {
|
||||
const char *fp = cf[c].files[f];
|
||||
int *idx = cbm_ht_get(file_idx, fp);
|
||||
if (idx) {
|
||||
ft_arr[*idx].change_count++;
|
||||
if (cf[c].timestamp > ft_arr[*idx].last_modified) {
|
||||
ft_arr[*idx].last_modified = cf[c].timestamp;
|
||||
}
|
||||
} else if (ft_count < MAX_FILE_TEMPORAL) {
|
||||
int new_idx = ft_count++;
|
||||
snprintf(ft_arr[new_idx].file_path, sizeof(ft_arr[new_idx].file_path), "%s",
|
||||
fp);
|
||||
ft_arr[new_idx].change_count = 1;
|
||||
ft_arr[new_idx].last_modified = cf[c].timestamp;
|
||||
int *nidx = malloc(sizeof(int));
|
||||
*nidx = new_idx;
|
||||
cbm_ht_set(file_idx, strdup(fp), nidx);
|
||||
}
|
||||
}
|
||||
}
|
||||
cbm_ht_foreach(file_idx, free_counter, NULL);
|
||||
cbm_ht_free(file_idx);
|
||||
result->file_temporal = ft_arr;
|
||||
result->file_temporal_count = ft_count;
|
||||
}
|
||||
|
||||
free(cf);
|
||||
for (int c = 0; c < commit_count; c++) {
|
||||
commit_free(&commits[c]);
|
||||
@@ -483,13 +555,42 @@ int cbm_pipeline_githistory_apply(cbm_pipeline_ctx_t *ctx, const cbm_githistory_
|
||||
}
|
||||
|
||||
char props[CBM_SZ_128];
|
||||
snprintf(props, sizeof(props), "{\"co_changes\":%d,\"coupling_score\":%.2f}",
|
||||
cc->co_change_count, cc->coupling_score);
|
||||
snprintf(props, sizeof(props),
|
||||
"{\"co_changes\":%d,\"coupling_score\":%.2f,\"last_co_change\":%lld}",
|
||||
cc->co_change_count, cc->coupling_score, cc->last_co_change);
|
||||
|
||||
cbm_gbuf_insert_edge(ctx->gbuf, node_a->id, node_b->id, "FILE_CHANGES_WITH", props);
|
||||
edge_count++;
|
||||
}
|
||||
|
||||
/* Apply per-file temporal metadata to existing File nodes so callers
|
||||
* can query change_count / last_modified for hotspot analysis. The
|
||||
* extension is re-derived and JSON-escaped to keep the props blob
|
||||
* well-formed even for paths with quotes or backslashes. */
|
||||
for (int i = 0; i < result->file_temporal_count; i++) {
|
||||
const cbm_file_temporal_t *ft = &result->file_temporal[i];
|
||||
char *qn = cbm_pipeline_fqn_compute(ctx->project_name, ft->file_path, "__file__");
|
||||
const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(ctx->gbuf, qn);
|
||||
free(qn);
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const char *base = strrchr(ft->file_path, '/');
|
||||
base = base ? base + SKIP_ONE : ft->file_path;
|
||||
const char *ext = strrchr(base, '.');
|
||||
char ext_escaped[CBM_SZ_64];
|
||||
cbm_json_escape(ext_escaped, (int)sizeof(ext_escaped), ext ? ext : "");
|
||||
|
||||
char props[CBM_SZ_256];
|
||||
snprintf(props, sizeof(props),
|
||||
"{\"extension\":\"%s\",\"last_modified\":%lld,\"change_count\":%d}",
|
||||
ext_escaped, ft->last_modified, ft->change_count);
|
||||
|
||||
cbm_gbuf_upsert_node(ctx->gbuf, node->label, node->name, node->qualified_name,
|
||||
node->file_path, node->start_line, node->end_line, props);
|
||||
}
|
||||
|
||||
return edge_count;
|
||||
}
|
||||
|
||||
@@ -502,11 +603,12 @@ int cbm_pipeline_pass_githistory(cbm_pipeline_ctx_t *ctx) {
|
||||
cbm_pipeline_githistory_compute(ctx->repo_path, &result);
|
||||
|
||||
int edge_count = 0;
|
||||
if (result.count > 0) {
|
||||
if (result.count > 0 || result.file_temporal_count > 0) {
|
||||
edge_count = cbm_pipeline_githistory_apply(ctx, &result);
|
||||
}
|
||||
|
||||
free(result.couplings);
|
||||
free(result.file_temporal);
|
||||
|
||||
cbm_log_info("pass.done", "pass", "githistory", "commits", itoa_log(result.commit_count),
|
||||
"edges", itoa_log(edge_count));
|
||||
|
||||
@@ -1655,6 +1655,9 @@ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws
|
||||
char dp[CBM_SZ_256];
|
||||
snprintf(dp, sizeof(dp), "{\"decorator\":\"%s\"}", def->decorators[dc]);
|
||||
cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, dn->id, "DECORATES", dp);
|
||||
/* Ensure a reference-style edge exists so the decorator appears in queries
|
||||
* without being misclassified as a real call by downstream passes. */
|
||||
cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, dn->id, "USAGE", "{}");
|
||||
ws->semantic_resolved++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +912,21 @@ char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *sou
|
||||
return qn;
|
||||
}
|
||||
|
||||
/* 1b. Try build-tool path aliases (tsconfig/jsconfig paths today;
|
||||
* other loaders can register here later). Independent of pkgmap. */
|
||||
if (ctx->path_aliases && source_rel) {
|
||||
const cbm_path_alias_map_t *amap =
|
||||
cbm_path_alias_find_for_file(ctx->path_aliases, source_rel);
|
||||
if (amap) {
|
||||
char *aliased = cbm_path_alias_resolve(amap, module_path);
|
||||
if (aliased) {
|
||||
char *qn = cbm_pipeline_fqn_module(ctx->project_name, aliased);
|
||||
free(aliased);
|
||||
return qn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 2. No pkgmap → fall through immediately */
|
||||
CBMHashTable *pkgmap = cbm_pipeline_get_pkgmap();
|
||||
if (!pkgmap) {
|
||||
|
||||
@@ -321,6 +321,9 @@ static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *no
|
||||
char props[CBM_SZ_256];
|
||||
snprintf(props, sizeof(props), "{\"decorator\":\"%s\"}", decorator);
|
||||
cbm_gbuf_insert_edge(ctx->gbuf, node->id, dec->id, "DECORATES", props);
|
||||
/* Ensure a reference edge exists so the decorator appears in usage queries
|
||||
* without being misclassified as a real call by downstream passes. */
|
||||
cbm_gbuf_insert_edge(ctx->gbuf, node->id, dec->id, "USAGE", "{}");
|
||||
(*count)++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* path_alias.c — Resolve build-tool path aliases.
|
||||
*
|
||||
* Builds a directory-scoped collection of alias maps from per-language
|
||||
* config files (currently tsconfig.json / jsconfig.json) so the import
|
||||
* resolver can turn "@/lib/auth"-style imports into repo-relative paths.
|
||||
*
|
||||
* Design notes:
|
||||
* - Public types and functions are language-agnostic. Adding a Vite /
|
||||
* Webpack / Python loader means writing a new load_*_file() helper
|
||||
* and registering it in find_alias_files. The resolver, the
|
||||
* collection, and the pipeline integration do not change.
|
||||
* - Sorting uses qsort (n log n). The bubble-sorts that the original
|
||||
* Layer 1b draft used were O(n^2); with up to 256 alias entries
|
||||
* and 256 scoped maps per repo, qsort is the right ceiling.
|
||||
* - The repo walk caps recursion depth and total file count and emits
|
||||
* a warning when either cap fires, so silent truncation on
|
||||
* pathological monorepos shows up in the index log.
|
||||
*/
|
||||
|
||||
#include "pipeline/path_alias.h"
|
||||
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <yyjson/yyjson.h>
|
||||
|
||||
/* Resource ceilings. Chosen to comfortably cover real-world monorepos
|
||||
* (Next.js Skyline, large nx workspaces) while bounding worst-case
|
||||
* memory and walk time. Cap hits are logged. */
|
||||
enum {
|
||||
CBM_PATH_ALIAS_MAX_ENTRIES = 256, /* per single config file */
|
||||
CBM_PATH_ALIAS_MAX_FILES = 256, /* config files per repo walk */
|
||||
CBM_PATH_ALIAS_MAX_FILE_BYTES = 64 * 1024,
|
||||
CBM_PATH_ALIAS_MAX_DEPTH = 32, /* directory recursion depth */
|
||||
};
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Strip .ts/.tsx/.js/.jsx in place. Returns its argument. */
|
||||
static char *strip_resolved_ext(char *path) {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
size_t len = strlen(path);
|
||||
if (len > 3 && path[len - 3] == '.' &&
|
||||
(path[len - 2] == 't' || path[len - 2] == 'j') && path[len - 1] == 's') {
|
||||
path[len - 3] = '\0';
|
||||
return path;
|
||||
}
|
||||
if (len > 4 && path[len - 4] == '.' &&
|
||||
(path[len - 3] == 't' || path[len - 3] == 'j') && path[len - 2] == 's' &&
|
||||
path[len - 1] == 'x') {
|
||||
path[len - 4] = '\0';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/* If target starts with "./" and dir_prefix is non-empty, prepend dir_prefix.
|
||||
* Returns heap-allocated repo-relative target. */
|
||||
static char *resolve_target_relative(const char *dir_prefix, const char *target) {
|
||||
if (!target) {
|
||||
return NULL;
|
||||
}
|
||||
const char *t = target;
|
||||
if (t[0] == '.' && t[1] == '/') {
|
||||
t += 2;
|
||||
}
|
||||
if (!dir_prefix || dir_prefix[0] == '\0') {
|
||||
return strdup(t);
|
||||
}
|
||||
size_t dp_len = strlen(dir_prefix);
|
||||
size_t t_len = strlen(t);
|
||||
char *result = malloc(dp_len + 1 + t_len + 1);
|
||||
if (!result) {
|
||||
return NULL;
|
||||
}
|
||||
snprintf(result, dp_len + 1 + t_len + 1, "%s/%s", dir_prefix, t);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* qsort comparator: alias entries by alias_prefix length, descending. */
|
||||
static int cmp_alias_entry_by_specificity(const void *a, const void *b) {
|
||||
const cbm_path_alias_t *ea = a;
|
||||
const cbm_path_alias_t *eb = b;
|
||||
size_t la = strlen(ea->alias_prefix);
|
||||
size_t lb = strlen(eb->alias_prefix);
|
||||
if (lb > la) {
|
||||
return 1;
|
||||
}
|
||||
if (lb < la) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* qsort comparator: scopes by dir_prefix length, descending. */
|
||||
static int cmp_scope_by_specificity(const void *a, const void *b) {
|
||||
const cbm_path_alias_scope_t *sa = a;
|
||||
const cbm_path_alias_scope_t *sb = b;
|
||||
size_t la = strlen(sa->dir_prefix);
|
||||
size_t lb = strlen(sb->dir_prefix);
|
||||
if (lb > la) {
|
||||
return 1;
|
||||
}
|
||||
if (lb < la) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── tsconfig.json / jsconfig.json loader ──────────────────────── */
|
||||
|
||||
/* Parse compilerOptions.paths and compilerOptions.baseUrl into an alias map.
|
||||
* dir_prefix is the directory of the config file relative to the repo root
|
||||
* (e.g. "apps/manager", or "" for repo root). Returns NULL if the file is
|
||||
* missing, malformed, or has neither a usable paths block nor a baseUrl. */
|
||||
static cbm_path_alias_map_t *load_tsconfig_file(const char *abs_path, const char *dir_prefix) {
|
||||
FILE *f = fopen(abs_path, "r");
|
||||
if (!f) {
|
||||
return NULL;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
long len = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (len <= 0 || len > CBM_PATH_ALIAS_MAX_FILE_BYTES) {
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
char *buf = malloc((size_t)len + 1);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
size_t nread = fread(buf, 1, (size_t)len, f);
|
||||
fclose(f);
|
||||
buf[nread] = '\0';
|
||||
|
||||
yyjson_read_flag flg = YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS;
|
||||
yyjson_doc *doc = yyjson_read(buf, nread, flg);
|
||||
free(buf);
|
||||
if (!doc) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
yyjson_val *compiler_opts = yyjson_obj_get(root, "compilerOptions");
|
||||
if (!compiler_opts) {
|
||||
yyjson_doc_free(doc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *base_url_val = yyjson_obj_get(compiler_opts, "baseUrl");
|
||||
const char *base_url_str = base_url_val ? yyjson_get_str(base_url_val) : NULL;
|
||||
yyjson_val *paths_obj = yyjson_obj_get(compiler_opts, "paths");
|
||||
if (!paths_obj && !base_url_str) {
|
||||
yyjson_doc_free(doc);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cbm_path_alias_map_t *map = calloc(1, sizeof(*map));
|
||||
if (!map) {
|
||||
yyjson_doc_free(doc);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (base_url_str && base_url_str[0] != '\0' && strcmp(base_url_str, ".") != 0) {
|
||||
map->base_url = resolve_target_relative(dir_prefix, base_url_str);
|
||||
} else if (base_url_str && strcmp(base_url_str, ".") == 0 && dir_prefix &&
|
||||
dir_prefix[0] != '\0') {
|
||||
map->base_url = strdup(dir_prefix);
|
||||
}
|
||||
|
||||
if (paths_obj && yyjson_is_obj(paths_obj)) {
|
||||
size_t obj_size = yyjson_obj_size(paths_obj);
|
||||
bool capped = obj_size > CBM_PATH_ALIAS_MAX_ENTRIES;
|
||||
int capacity =
|
||||
(int)(capped ? (size_t)CBM_PATH_ALIAS_MAX_ENTRIES : obj_size);
|
||||
if (capacity > 0) {
|
||||
map->entries = calloc((size_t)capacity, sizeof(cbm_path_alias_t));
|
||||
if (!map->entries) {
|
||||
free(map->base_url);
|
||||
free(map);
|
||||
yyjson_doc_free(doc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *key;
|
||||
yyjson_obj_iter iter = yyjson_obj_iter_with(paths_obj);
|
||||
while ((key = yyjson_obj_iter_next(&iter)) != NULL && map->count < capacity) {
|
||||
yyjson_val *val = yyjson_obj_iter_get_val(key);
|
||||
const char *alias_pattern = yyjson_get_str(key);
|
||||
if (!alias_pattern || !yyjson_is_arr(val) || yyjson_arr_size(val) == 0) {
|
||||
continue;
|
||||
}
|
||||
const char *target_pattern = yyjson_get_str(yyjson_arr_get_first(val));
|
||||
if (!target_pattern) {
|
||||
continue;
|
||||
}
|
||||
cbm_path_alias_t *entry = &map->entries[map->count];
|
||||
const char *star = strchr(alias_pattern, '*');
|
||||
if (star) {
|
||||
entry->has_wildcard = true;
|
||||
entry->alias_prefix =
|
||||
strndup(alias_pattern, (size_t)(star - alias_pattern));
|
||||
entry->alias_suffix = strdup(star + 1);
|
||||
} else {
|
||||
entry->has_wildcard = false;
|
||||
entry->alias_prefix = strdup(alias_pattern);
|
||||
entry->alias_suffix = strdup("");
|
||||
}
|
||||
const char *tstar = strchr(target_pattern, '*');
|
||||
if (tstar) {
|
||||
char *pre = strndup(target_pattern, (size_t)(tstar - target_pattern));
|
||||
entry->target_prefix = resolve_target_relative(dir_prefix, pre);
|
||||
free(pre);
|
||||
entry->target_suffix = strdup(tstar + 1);
|
||||
} else {
|
||||
entry->target_prefix = resolve_target_relative(dir_prefix, target_pattern);
|
||||
entry->target_suffix = strdup("");
|
||||
}
|
||||
map->count++;
|
||||
}
|
||||
if (capped) {
|
||||
cbm_log_warn("path_alias.entries.cap_hit", "config", abs_path, "kept",
|
||||
/* itoa via thread-local buffer would be tidier; keep simple */
|
||||
"256_of_more");
|
||||
}
|
||||
qsort(map->entries, (size_t)map->count, sizeof(cbm_path_alias_t),
|
||||
cmp_alias_entry_by_specificity);
|
||||
}
|
||||
}
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────── */
|
||||
|
||||
void cbm_path_alias_collection_free(cbm_path_alias_collection_t *coll) {
|
||||
if (!coll) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < coll->count; i++) {
|
||||
free(coll->scopes[i].dir_prefix);
|
||||
if (coll->scopes[i].map) {
|
||||
cbm_path_alias_map_t *map = coll->scopes[i].map;
|
||||
for (int j = 0; j < map->count; j++) {
|
||||
free(map->entries[j].alias_prefix);
|
||||
free(map->entries[j].alias_suffix);
|
||||
free(map->entries[j].target_prefix);
|
||||
free(map->entries[j].target_suffix);
|
||||
}
|
||||
free(map->entries);
|
||||
free(map->base_url);
|
||||
free(map);
|
||||
}
|
||||
}
|
||||
free(coll->scopes);
|
||||
free(coll);
|
||||
}
|
||||
|
||||
char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module_path) {
|
||||
if (!map || !module_path) {
|
||||
return NULL;
|
||||
}
|
||||
size_t mod_len = strlen(module_path);
|
||||
|
||||
for (int i = 0; i < map->count; i++) {
|
||||
const cbm_path_alias_t *e = &map->entries[i];
|
||||
size_t prefix_len = strlen(e->alias_prefix);
|
||||
size_t suffix_len = strlen(e->alias_suffix);
|
||||
|
||||
if (e->has_wildcard) {
|
||||
if (mod_len < prefix_len + suffix_len) {
|
||||
continue;
|
||||
}
|
||||
if (strncmp(module_path, e->alias_prefix, prefix_len) != 0) {
|
||||
continue;
|
||||
}
|
||||
if (suffix_len > 0 &&
|
||||
strcmp(module_path + mod_len - suffix_len, e->alias_suffix) != 0) {
|
||||
continue;
|
||||
}
|
||||
size_t wild_len = mod_len - prefix_len - suffix_len;
|
||||
const char *wild_start = module_path + prefix_len;
|
||||
size_t tp_len = strlen(e->target_prefix);
|
||||
size_t ts_len = strlen(e->target_suffix);
|
||||
char *result = malloc(tp_len + wild_len + ts_len + 1);
|
||||
if (!result) {
|
||||
return NULL;
|
||||
}
|
||||
memcpy(result, e->target_prefix, tp_len);
|
||||
memcpy(result + tp_len, wild_start, wild_len);
|
||||
memcpy(result + tp_len + wild_len, e->target_suffix, ts_len);
|
||||
result[tp_len + wild_len + ts_len] = '\0';
|
||||
return strip_resolved_ext(result);
|
||||
}
|
||||
|
||||
if (strcmp(module_path, e->alias_prefix) == 0) {
|
||||
return strip_resolved_ext(strdup(e->target_prefix));
|
||||
}
|
||||
}
|
||||
|
||||
/* baseUrl fallback. Apply only to non-relative imports that look
|
||||
* sub-path-ish (contain '/' but don't start with '.' or '@'); skips
|
||||
* obvious package names like "react" or "lodash". */
|
||||
if (map->base_url && module_path[0] != '.' && module_path[0] != '@' &&
|
||||
strchr(module_path, '/') != NULL) {
|
||||
size_t bu_len = strlen(map->base_url);
|
||||
size_t need = bu_len + 1 + mod_len + 1;
|
||||
char *result = malloc(need);
|
||||
if (!result) {
|
||||
return NULL;
|
||||
}
|
||||
snprintf(result, need, "%s/%s", map->base_url, module_path);
|
||||
return strip_resolved_ext(result);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ── Repo walk ─────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char abs[CBM_SZ_512];
|
||||
char rel[CBM_SZ_256];
|
||||
} alias_config_hit_t;
|
||||
|
||||
static const char *const TS_CONFIG_NAMES[] = {"tsconfig.json", "jsconfig.json"};
|
||||
enum { TS_CONFIG_NAMES_COUNT = 2 };
|
||||
|
||||
static void find_alias_files(const char *abs_dir, const char *rel_dir, alias_config_hit_t *out,
|
||||
int *count, int max_count, int depth) {
|
||||
if (*count >= max_count || depth > CBM_PATH_ALIAS_MAX_DEPTH) {
|
||||
return;
|
||||
}
|
||||
cbm_dir_t *d = cbm_opendir(abs_dir);
|
||||
if (!d) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* One config file per directory: prefer tsconfig.json over jsconfig.json. */
|
||||
for (int i = 0; i < TS_CONFIG_NAMES_COUNT && *count < max_count; i++) {
|
||||
char check[CBM_SZ_512];
|
||||
snprintf(check, sizeof(check), "%s/%s", abs_dir, TS_CONFIG_NAMES[i]);
|
||||
FILE *f = fopen(check, "r");
|
||||
if (f) {
|
||||
fclose(f);
|
||||
snprintf(out[*count].abs, sizeof(out[*count].abs), "%s", check);
|
||||
snprintf(out[*count].rel, sizeof(out[*count].rel), "%s", rel_dir);
|
||||
(*count)++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cbm_dirent_t *ent;
|
||||
while ((ent = cbm_readdir(d)) != NULL && *count < max_count) {
|
||||
if (!ent->is_dir) {
|
||||
continue;
|
||||
}
|
||||
const char *name = ent->name;
|
||||
if (name[0] == '.' || strcmp(name, "node_modules") == 0 ||
|
||||
strcmp(name, "dist") == 0 || strcmp(name, "build") == 0 ||
|
||||
strcmp(name, ".next") == 0 || strcmp(name, "coverage") == 0 ||
|
||||
strcmp(name, "target") == 0 /* Rust */) {
|
||||
continue;
|
||||
}
|
||||
char child_abs[CBM_SZ_512];
|
||||
char child_rel[CBM_SZ_256];
|
||||
snprintf(child_abs, sizeof(child_abs), "%s/%s", abs_dir, name);
|
||||
if (rel_dir[0] == '\0') {
|
||||
snprintf(child_rel, sizeof(child_rel), "%s", name);
|
||||
} else {
|
||||
snprintf(child_rel, sizeof(child_rel), "%s/%s", rel_dir, name);
|
||||
}
|
||||
find_alias_files(child_abs, child_rel, out, count, max_count, depth + 1);
|
||||
}
|
||||
cbm_closedir(d);
|
||||
}
|
||||
|
||||
cbm_path_alias_collection_t *cbm_load_path_aliases(const char *repo_path) {
|
||||
if (!repo_path) {
|
||||
return NULL;
|
||||
}
|
||||
alias_config_hit_t *hits = calloc(CBM_PATH_ALIAS_MAX_FILES, sizeof(*hits));
|
||||
if (!hits) {
|
||||
return NULL;
|
||||
}
|
||||
int count = 0;
|
||||
find_alias_files(repo_path, "", hits, &count, CBM_PATH_ALIAS_MAX_FILES, 0);
|
||||
if (count >= CBM_PATH_ALIAS_MAX_FILES) {
|
||||
cbm_log_warn("path_alias.files.cap_hit", "repo", repo_path, "kept",
|
||||
"256_of_more");
|
||||
}
|
||||
if (count == 0) {
|
||||
free(hits);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cbm_path_alias_collection_t *coll = calloc(1, sizeof(*coll));
|
||||
if (!coll) {
|
||||
free(hits);
|
||||
return NULL;
|
||||
}
|
||||
coll->scopes = calloc((size_t)count, sizeof(cbm_path_alias_scope_t));
|
||||
if (!coll->scopes) {
|
||||
free(coll);
|
||||
free(hits);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cbm_path_alias_map_t *map = load_tsconfig_file(hits[i].abs, hits[i].rel);
|
||||
if (!map) {
|
||||
continue;
|
||||
}
|
||||
coll->scopes[coll->count].dir_prefix = strdup(hits[i].rel);
|
||||
coll->scopes[coll->count].map = map;
|
||||
coll->count++;
|
||||
}
|
||||
free(hits);
|
||||
|
||||
if (coll->count == 0) {
|
||||
free(coll->scopes);
|
||||
free(coll);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
qsort(coll->scopes, (size_t)coll->count, sizeof(cbm_path_alias_scope_t),
|
||||
cmp_scope_by_specificity);
|
||||
return coll;
|
||||
}
|
||||
|
||||
const cbm_path_alias_map_t *cbm_path_alias_find_for_file(
|
||||
const cbm_path_alias_collection_t *coll, const char *rel_path) {
|
||||
if (!coll || !rel_path) {
|
||||
return NULL;
|
||||
}
|
||||
for (int i = 0; i < coll->count; i++) {
|
||||
const char *prefix = coll->scopes[i].dir_prefix;
|
||||
size_t plen = strlen(prefix);
|
||||
if (plen == 0) {
|
||||
return coll->scopes[i].map;
|
||||
}
|
||||
if (strncmp(rel_path, prefix, plen) == 0 &&
|
||||
(rel_path[plen] == '/' || rel_path[plen] == '\0')) {
|
||||
return coll->scopes[i].map;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#ifndef CBM_PATH_ALIAS_H
|
||||
#define CBM_PATH_ALIAS_H
|
||||
|
||||
/*
|
||||
* path_alias.h — Generic build-tool path alias resolution.
|
||||
*
|
||||
* Resolves module paths that the indexer would otherwise leave as bare
|
||||
* imports (e.g. "@/lib/auth" or "~/components/Button") into repo-relative
|
||||
* paths the existing module-FQN logic can consume. The alias source is
|
||||
* pluggable per language: the loader walks the repo, recognises supported
|
||||
* config files, and adds each one's aliases to a single collection scoped
|
||||
* by directory.
|
||||
*
|
||||
* Currently implemented sources: TypeScript / JavaScript (tsconfig.json
|
||||
* and jsconfig.json compilerOptions.paths + baseUrl). The data model and
|
||||
* resolver below are deliberately language-agnostic so further loaders
|
||||
* (Vite/Webpack alias config, Python tool.* hints, ...) can register
|
||||
* additional scopes without touching the resolver or the pipeline.
|
||||
*
|
||||
* Lookup is two-step:
|
||||
* 1. cbm_path_alias_find_for_file picks the nearest ancestor scope for
|
||||
* a given source file (longest matching dir_prefix wins).
|
||||
* 2. cbm_path_alias_resolve runs the chosen scope's alias map against
|
||||
* the import path, returning a heap-allocated repo-relative target
|
||||
* (or NULL if no alias matches).
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* Single alias entry: prefix-pattern → target-pattern, optionally with a
|
||||
* single '*' wildcard. The pattern is split at the wildcard so resolution
|
||||
* can match the prefix/suffix and slot the wildcard portion into the
|
||||
* target. Patterns without '*' are exact-match. */
|
||||
typedef struct {
|
||||
char *alias_prefix; /* portion before '*' in the key (e.g. "@/") */
|
||||
char *alias_suffix; /* portion after '*' in the key (usually "") */
|
||||
char *target_prefix; /* portion before '*' in the value (e.g. "src/") */
|
||||
char *target_suffix; /* portion after '*' in the value (usually "") */
|
||||
bool has_wildcard; /* true when the alias pattern contained '*' */
|
||||
} cbm_path_alias_t;
|
||||
|
||||
/* Alias map for a single config source (one tsconfig.json, one webpack
|
||||
* alias block, ...). Entries are sorted by alias_prefix length descending
|
||||
* so longer (more specific) prefixes are matched first, matching the
|
||||
* resolution semantics of every build tool we know of. */
|
||||
typedef struct {
|
||||
cbm_path_alias_t *entries;
|
||||
int count;
|
||||
char *base_url; /* compilerOptions.baseUrl or equivalent; NULL if unset */
|
||||
} cbm_path_alias_map_t;
|
||||
|
||||
/* A directory-scoped alias map. dir_prefix is the path of the config file
|
||||
* relative to the repo root ("" for a root-level config). Source files
|
||||
* pick up the map of the nearest ancestor scope. */
|
||||
typedef struct {
|
||||
char *dir_prefix;
|
||||
cbm_path_alias_map_t *map;
|
||||
} cbm_path_alias_scope_t;
|
||||
|
||||
/* Aggregated alias scopes for a whole repository. Scopes are sorted by
|
||||
* dir_prefix length descending so the nearest-ancestor lookup is a single
|
||||
* linear scan. */
|
||||
typedef struct {
|
||||
cbm_path_alias_scope_t *scopes;
|
||||
int count;
|
||||
} cbm_path_alias_collection_t;
|
||||
|
||||
/* Walk repo_path for known build-config files and build a collection of
|
||||
* scoped alias maps. Currently picks up tsconfig.json and jsconfig.json;
|
||||
* additional loaders register here. Returns NULL when no usable configs
|
||||
* are found (also NULL on out-of-memory). Caller frees with
|
||||
* cbm_path_alias_collection_free. */
|
||||
cbm_path_alias_collection_t *cbm_load_path_aliases(const char *repo_path);
|
||||
|
||||
/* Free a collection produced by cbm_load_path_aliases. NULL-safe. */
|
||||
void cbm_path_alias_collection_free(cbm_path_alias_collection_t *coll);
|
||||
|
||||
/* Pick the nearest ancestor scope for a file path that is relative to the
|
||||
* repo root. Returns the scope's alias map, or NULL if no scope applies.
|
||||
* The returned pointer is owned by the collection. */
|
||||
const cbm_path_alias_map_t *cbm_path_alias_find_for_file(
|
||||
const cbm_path_alias_collection_t *coll, const char *rel_path);
|
||||
|
||||
/* Resolve module_path against map. Returns a heap-allocated repo-relative
|
||||
* path (caller frees), or NULL when no alias entry matches and no
|
||||
* baseUrl fallback applies. Common JS/TS extensions (.ts, .tsx, .js,
|
||||
* .jsx) are stripped from the resolved path so the existing module-FQN
|
||||
* pipeline can consume it. */
|
||||
char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module_path);
|
||||
|
||||
#endif /* CBM_PATH_ALIAS_H */
|
||||
@@ -733,12 +733,13 @@ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) {
|
||||
}
|
||||
|
||||
int gh_edges = 0;
|
||||
if (gh_result.count > 0) {
|
||||
if (gh_result.count > 0 || gh_result.file_temporal_count > 0) {
|
||||
gh_edges = cbm_pipeline_githistory_apply(ctx, &gh_result);
|
||||
}
|
||||
cbm_log_info("pass.done", "pass", "githistory", "commits", itoa_buf(gh_result.commit_count),
|
||||
"edges", itoa_buf(gh_edges));
|
||||
free(gh_result.couplings);
|
||||
free(gh_result.file_temporal);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -820,6 +821,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
CBM_PROF_START(t_pipeline_total);
|
||||
struct timespec t0;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
cbm_path_alias_collection_t *path_aliases = NULL;
|
||||
|
||||
/* Load user-defined extension overrides (fail-open: NULL on error) */
|
||||
CBM_PROF_START(t_userconfig);
|
||||
@@ -860,6 +862,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
p->gbuf = cbm_gbuf_new(p->project_name, p->repo_path);
|
||||
p->registry = cbm_registry_new();
|
||||
|
||||
/* Phase 2b: Load build-tool path aliases (tsconfig/jsconfig today). NULL
|
||||
* when no usable configs are found — non-TS projects pay nothing. */
|
||||
path_aliases = cbm_load_path_aliases(p->repo_path);
|
||||
|
||||
/* Build shared context for pass functions */
|
||||
cbm_pipeline_ctx_t ctx = {
|
||||
.project_name = p->project_name,
|
||||
@@ -868,6 +874,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
.registry = p->registry,
|
||||
.cancelled = &p->cancelled,
|
||||
.mode = (int)p->mode,
|
||||
.path_aliases = path_aliases,
|
||||
};
|
||||
|
||||
rc = run_extraction_phase(p, &ctx, files, file_count);
|
||||
@@ -893,6 +900,7 @@ cleanup:
|
||||
p->gbuf = NULL;
|
||||
cbm_registry_free(p->registry);
|
||||
p->registry = NULL;
|
||||
cbm_path_alias_collection_free(path_aliases);
|
||||
/* Clear and free user extension config */
|
||||
cbm_set_user_lang_config(NULL);
|
||||
cbm_userconfig_free(p->userconfig);
|
||||
|
||||
@@ -26,6 +26,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24, INCR_WAL_BUF = 1
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/platform.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -121,48 +122,247 @@ static bool *classify_files(cbm_file_info_t *files, int file_count, cbm_file_has
|
||||
return changed;
|
||||
}
|
||||
|
||||
/* Find stored files that no longer exist on disk. Returns count. */
|
||||
static int find_deleted_files(cbm_file_info_t *files, int file_count, cbm_file_hash_t *stored,
|
||||
int stored_count, char ***out_deleted) {
|
||||
/* Classify stored files that are absent from current discovery. Returns the
|
||||
* count of truly-deleted files (output via out_deleted) and ALSO collects
|
||||
* mode-skipped files into out_mode_skipped (caller frees both).
|
||||
*
|
||||
* A stored file is classified as:
|
||||
* - "deleted" — `stat()` returns ENOENT or ENOTDIR. Its nodes will
|
||||
* be purged and its hash row dropped.
|
||||
* - "mode-skipped" — `stat()` succeeds. The file exists on disk but the
|
||||
* current discovery pass didn't visit it (e.g. excluded
|
||||
* by FAST_SKIP_DIRS in fast/moderate mode). Its nodes
|
||||
* must be preserved AND its hash row must be carried
|
||||
* forward into the new DB so subsequent reindexes can
|
||||
* still see it as "known" rather than treating it as
|
||||
* new-or-deleted.
|
||||
*
|
||||
* Without this distinction, a fast-mode reindex after a full-mode index
|
||||
* would silently purge every file under `tools/`, `scripts/`, `bin/`,
|
||||
* `build/`, `docs/`, `__tests__/`, etc. — see task
|
||||
* claude-connectors/codebase-memory-index-repository-is-destructive-...
|
||||
* and the 2026-04-13 Skyline incident (packages/mcp/src/tools/ vanished
|
||||
* from a live graph mid-session).
|
||||
*
|
||||
* Mode-skipped hash preservation is the second half of the additive-merge
|
||||
* contract: dump_and_persist re-upserts these hash rows so the next reindex
|
||||
* can correctly detect a real on-disk deletion of a mode-skipped file (as
|
||||
* opposed to seeing it as "never existed" → noop → orphaned graph nodes).
|
||||
*
|
||||
* Fail-safe rules (preserve nodes on uncertainty):
|
||||
* - repo_path NULL → log error and preserve everything (return 0
|
||||
* deletions, empty mode_skipped). The caller contract is that
|
||||
* repo_path is required; a NULL means a misconfigured pipeline,
|
||||
* not a deletion signal.
|
||||
* - snprintf truncation (combined path ≥ CBM_SZ_4K) → preserve. We can't
|
||||
* reliably stat a truncated path. Treat as mode-skipped.
|
||||
* - stat() errno != ENOENT/ENOTDIR (EACCES, EIO, ELOOP, transient NFS,
|
||||
* etc.) → preserve. The file may exist; we just can't see it right now.
|
||||
* Treat as mode-skipped.
|
||||
*
|
||||
* Note: we use stat() (not lstat()) on purpose. A symlink whose target was
|
||||
* deleted should be classified as deleted from the indexer's perspective
|
||||
* because the indexer follows symlinks during discovery — a stale symlink
|
||||
* has no source to parse. */
|
||||
static int find_deleted_files(const char *repo_path, cbm_file_info_t *files, int file_count,
|
||||
cbm_file_hash_t *stored, int stored_count, char ***out_deleted,
|
||||
cbm_file_hash_t **out_mode_skipped, int *out_mode_skipped_count) {
|
||||
*out_deleted = NULL;
|
||||
*out_mode_skipped = NULL;
|
||||
*out_mode_skipped_count = 0;
|
||||
|
||||
if (!repo_path) {
|
||||
/* Misconfigured pipeline. Preserve everything rather than risk
|
||||
* silently re-introducing the destructive overwrite this function
|
||||
* was rewritten to prevent. */
|
||||
cbm_log_error("incremental.err", "msg", "find_deleted_files_null_repo_path");
|
||||
return 0;
|
||||
}
|
||||
|
||||
CBMHashTable *current = cbm_ht_create((size_t)file_count * PAIR_LEN);
|
||||
for (int i = 0; i < file_count; i++) {
|
||||
cbm_ht_set(current, files[i].rel_path, &files[i]);
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
int cap = CBM_SZ_64;
|
||||
char **deleted = malloc((size_t)cap * sizeof(char *));
|
||||
int del_count = 0;
|
||||
int del_cap = CBM_SZ_64;
|
||||
char **deleted = malloc((size_t)del_cap * sizeof(char *));
|
||||
if (!deleted) {
|
||||
cbm_log_error("incremental.err", "msg", "find_deleted_files_oom");
|
||||
cbm_ht_free(current);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ms_count = 0;
|
||||
int ms_cap = CBM_SZ_64;
|
||||
cbm_file_hash_t *mode_skipped = malloc((size_t)ms_cap * sizeof(cbm_file_hash_t));
|
||||
if (!mode_skipped) {
|
||||
cbm_log_error("incremental.err", "msg", "find_deleted_files_oom_ms");
|
||||
free(deleted);
|
||||
cbm_ht_free(current);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stored_count; i++) {
|
||||
if (!cbm_ht_get(current, stored[i].rel_path)) {
|
||||
if (count >= cap) {
|
||||
cap *= PAIR_LEN;
|
||||
char **tmp = realloc(deleted, (size_t)cap * sizeof(char *));
|
||||
if (cbm_ht_get(current, stored[i].rel_path)) {
|
||||
continue; /* still visited by current pass */
|
||||
}
|
||||
/* Not in current discovery — check if it's truly deleted or just
|
||||
* mode-skipped (excluded by FAST_SKIP_DIRS etc.). */
|
||||
bool preserve = false;
|
||||
char abs_path[CBM_SZ_4K];
|
||||
int n = snprintf(abs_path, sizeof(abs_path), "%s/%s", repo_path, stored[i].rel_path);
|
||||
if (n < 0 || n >= (int)sizeof(abs_path)) {
|
||||
/* Truncation or encoding error — can't reliably stat. Preserve. */
|
||||
cbm_log_warn("incremental.path_truncated", "rel_path", stored[i].rel_path);
|
||||
preserve = true;
|
||||
} else {
|
||||
struct stat st;
|
||||
if (stat(abs_path, &st) == 0) {
|
||||
/* File exists on disk — mode-skipped, not deleted. */
|
||||
preserve = true;
|
||||
} else if (errno != ENOENT && errno != ENOTDIR) {
|
||||
/* Transient or permission error — fail safe by preserving.
|
||||
* EACCES, EIO, ELOOP, ENAMETOOLONG, etc. */
|
||||
cbm_log_warn("incremental.stat_uncertain", "rel_path", stored[i].rel_path, "errno",
|
||||
itoa_buf(errno));
|
||||
preserve = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (preserve) {
|
||||
/* Carry forward the existing hash row so subsequent reindexes
|
||||
* can correctly classify this file. */
|
||||
if (ms_count >= ms_cap) {
|
||||
ms_cap *= PAIR_LEN;
|
||||
cbm_file_hash_t *tmp = realloc(mode_skipped, (size_t)ms_cap * sizeof(*tmp));
|
||||
if (!tmp) {
|
||||
cbm_log_error("incremental.err", "msg",
|
||||
"find_deleted_files_realloc_oom_ms");
|
||||
break;
|
||||
}
|
||||
deleted = tmp;
|
||||
mode_skipped = tmp;
|
||||
}
|
||||
deleted[count++] = strdup(stored[i].rel_path);
|
||||
char *rp = strdup(stored[i].rel_path);
|
||||
char *sh = stored[i].sha256 ? strdup(stored[i].sha256) : NULL;
|
||||
if (!rp || (stored[i].sha256 && !sh)) {
|
||||
/* OOM mid-record. Drop this entry rather than persist a
|
||||
* row with a NULL rel_path that would silently fail the
|
||||
* NOT NULL constraint in upsert and reintroduce the
|
||||
* orphaned-node bug. */
|
||||
cbm_log_error("incremental.err", "msg", "find_deleted_files_strdup_oom",
|
||||
"rel_path", stored[i].rel_path);
|
||||
free(rp);
|
||||
free(sh);
|
||||
break;
|
||||
}
|
||||
mode_skipped[ms_count].project = NULL; /* unused by upsert API */
|
||||
mode_skipped[ms_count].rel_path = rp;
|
||||
mode_skipped[ms_count].sha256 = sh;
|
||||
mode_skipped[ms_count].mtime_ns = stored[i].mtime_ns;
|
||||
mode_skipped[ms_count].size = stored[i].size;
|
||||
ms_count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* File is truly gone — record for purge. */
|
||||
if (del_count >= del_cap) {
|
||||
del_cap *= PAIR_LEN;
|
||||
char **tmp = realloc(deleted, (size_t)del_cap * sizeof(char *));
|
||||
if (!tmp) {
|
||||
cbm_log_error("incremental.err", "msg", "find_deleted_files_realloc_oom");
|
||||
break;
|
||||
}
|
||||
deleted = tmp;
|
||||
}
|
||||
deleted[del_count++] = strdup(stored[i].rel_path);
|
||||
}
|
||||
|
||||
cbm_ht_free(current);
|
||||
*out_deleted = deleted;
|
||||
return count;
|
||||
*out_mode_skipped = mode_skipped;
|
||||
*out_mode_skipped_count = ms_count;
|
||||
return del_count;
|
||||
}
|
||||
|
||||
/* Free a mode_skipped array allocated by find_deleted_files. */
|
||||
static void free_mode_skipped(cbm_file_hash_t *ms, int count) {
|
||||
if (!ms) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
free((void *)ms[i].rel_path);
|
||||
free((void *)ms[i].sha256);
|
||||
}
|
||||
free(ms);
|
||||
}
|
||||
|
||||
/* ── Persist file hashes ─────────────────────────────────────────── */
|
||||
|
||||
/* Persist file hash rows for the current discovery and any mode-skipped
|
||||
* files preserved from the previous DB.
|
||||
*
|
||||
* Partial-failure policy: an `upsert` failure on any single row is logged
|
||||
* as a warning and the loop continues. We deliberately do NOT abort the
|
||||
* whole reindex on a single bad row — partial preservation is better than
|
||||
* total loss, and a transient failure on one file should not invalidate
|
||||
* the entire incremental update. The trade-off is that a silently-failed
|
||||
* row produces the same downstream effect as if the file were never
|
||||
* indexed at all (forced re-parse on the next run for current-files,
|
||||
* potential orphaned-node revival for mode_skipped). The warning surface
|
||||
* is the only signal that something went wrong. */
|
||||
static void persist_hashes(cbm_store_t *store, const char *project, cbm_file_info_t *files,
|
||||
int file_count) {
|
||||
int file_count, const cbm_file_hash_t *mode_skipped,
|
||||
int mode_skipped_count) {
|
||||
int current_failed = 0;
|
||||
int ms_failed = 0;
|
||||
|
||||
/* Current discovery: re-stat to capture any mtime/size that changed
|
||||
* during the run, and write fresh hash rows for visited files. */
|
||||
for (int i = 0; i < file_count; i++) {
|
||||
struct stat st;
|
||||
if (stat(files[i].path, &st) != 0) {
|
||||
continue;
|
||||
}
|
||||
cbm_store_upsert_file_hash(store, project, files[i].rel_path, "", stat_mtime_ns(&st),
|
||||
st.st_size);
|
||||
int rc = cbm_store_upsert_file_hash(store, project, files[i].rel_path, "",
|
||||
stat_mtime_ns(&st), st.st_size);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
cbm_log_warn("incremental.persist_hash_failed", "scope", "current", "rel_path",
|
||||
files[i].rel_path, "rc", itoa_buf(rc));
|
||||
current_failed++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode-skipped (preserved): re-upsert hash rows from the previous DB
|
||||
* so the next reindex can still classify these files correctly. Without
|
||||
* this, an orphaned-node bug emerges where:
|
||||
* - full mode indexes everything
|
||||
* - fast mode runs and drops mode-skipped hash rows
|
||||
* - file is then deleted on disk
|
||||
* - next reindex's stored hashes don't include the file → noop or
|
||||
* can't detect the deletion → graph nodes for the deleted file
|
||||
* remain forever (or until a destructive rebuild).
|
||||
*
|
||||
* A failure here is more serious than a current-files failure because
|
||||
* it can revive the orphaned-node bug for that specific file. Logged
|
||||
* with scope=mode_skipped so the warning is searchable. */
|
||||
if (mode_skipped) {
|
||||
for (int i = 0; i < mode_skipped_count; i++) {
|
||||
int rc = cbm_store_upsert_file_hash(store, project, mode_skipped[i].rel_path,
|
||||
mode_skipped[i].sha256 ? mode_skipped[i].sha256
|
||||
: "",
|
||||
mode_skipped[i].mtime_ns, mode_skipped[i].size);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
cbm_log_warn("incremental.persist_hash_failed", "scope", "mode_skipped",
|
||||
"rel_path", mode_skipped[i].rel_path, "rc", itoa_buf(rc));
|
||||
ms_failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current_failed > 0 || ms_failed > 0) {
|
||||
cbm_log_warn("incremental.persist_summary", "current_failed", itoa_buf(current_failed),
|
||||
"mode_skipped_failed", itoa_buf(ms_failed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,9 +457,14 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil
|
||||
itoa_buf((int)elapsed_ms(t)));
|
||||
}
|
||||
}
|
||||
/* Delete old DB and dump merged graph + hashes to disk. */
|
||||
/* Delete old DB and dump merged graph + hashes to disk.
|
||||
* Mode-skipped hash rows are preserved across the rebuild so subsequent
|
||||
* reindexes can correctly distinguish "never indexed" from "indexed but
|
||||
* not visited this pass". */
|
||||
static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project,
|
||||
cbm_file_info_t *files, int file_count, const char *repo_path) {
|
||||
cbm_file_info_t *files, int file_count,
|
||||
const cbm_file_hash_t *mode_skipped, int mode_skipped_count,
|
||||
const char *repo_path) {
|
||||
struct timespec t;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
|
||||
@@ -277,7 +482,7 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *
|
||||
|
||||
cbm_store_t *hash_store = cbm_store_open_path(db_path);
|
||||
if (hash_store) {
|
||||
persist_hashes(hash_store, project, files, file_count);
|
||||
persist_hashes(hash_store, project, files, file_count, mode_skipped, mode_skipped_count);
|
||||
|
||||
/* FTS5 rebuild after incremental dump. The btree dump path bypasses
|
||||
* any triggers that could have kept nodes_fts synchronized, so we
|
||||
@@ -329,18 +534,27 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
bool *is_changed =
|
||||
classify_files(files, file_count, stored, stored_count, &n_changed, &n_unchanged);
|
||||
|
||||
/* Find deleted files */
|
||||
/* Classify stored files absent from current discovery: truly-deleted
|
||||
* (purge) vs mode-skipped (preserve nodes AND hash rows). */
|
||||
char **deleted = NULL;
|
||||
int deleted_count = find_deleted_files(files, file_count, stored, stored_count, &deleted);
|
||||
cbm_file_hash_t *mode_skipped = NULL;
|
||||
int mode_skipped_count = 0;
|
||||
int deleted_count =
|
||||
find_deleted_files(cbm_pipeline_repo_path(p), files, file_count, stored, stored_count,
|
||||
&deleted, &mode_skipped, &mode_skipped_count);
|
||||
|
||||
cbm_log_info("incremental.classify", "changed", itoa_buf(n_changed), "unchanged",
|
||||
itoa_buf(n_unchanged), "deleted", itoa_buf(deleted_count));
|
||||
itoa_buf(n_unchanged), "deleted", itoa_buf(deleted_count), "mode_skipped",
|
||||
itoa_buf(mode_skipped_count));
|
||||
|
||||
/* Fast path: nothing changed → skip */
|
||||
/* Fast path: nothing changed → skip. The on-disk DB is left untouched,
|
||||
* which means existing hash rows (including for any mode-skipped files
|
||||
* that were already preserved by an earlier run) remain intact. */
|
||||
if (n_changed == 0 && deleted_count == 0) {
|
||||
cbm_log_info("incremental.noop", "reason", "no_changes");
|
||||
free(is_changed);
|
||||
free(deleted);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
cbm_store_free_file_hashes(stored, stored_count);
|
||||
cbm_store_close(store);
|
||||
return 0;
|
||||
@@ -380,6 +594,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
free(deleted[i]);
|
||||
}
|
||||
free(deleted);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
cbm_store_close(store);
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
@@ -405,6 +620,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
cbm_log_info("incremental.registry_seed", "symbols", itoa_buf(cbm_registry_size(registry)),
|
||||
"elapsed_ms", itoa_buf((int)elapsed_ms(t)));
|
||||
|
||||
cbm_path_alias_collection_t *path_aliases =
|
||||
cbm_load_path_aliases(cbm_pipeline_repo_path(p));
|
||||
|
||||
cbm_pipeline_ctx_t ctx = {
|
||||
.project_name = project,
|
||||
.repo_path = cbm_pipeline_repo_path(p),
|
||||
@@ -412,6 +630,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
.registry = registry,
|
||||
.cancelled = cbm_pipeline_cancelled_ptr(p),
|
||||
.mode = cbm_pipeline_get_mode(p),
|
||||
.path_aliases = path_aliases,
|
||||
};
|
||||
|
||||
for (int i = 0; i < ci; i++) {
|
||||
@@ -429,9 +648,15 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
|
||||
free(changed_files);
|
||||
cbm_registry_free(registry);
|
||||
cbm_path_alias_collection_free(path_aliases);
|
||||
|
||||
/* Step 7: Dump to disk */
|
||||
dump_and_persist(existing, db_path, project, files, file_count, cbm_pipeline_repo_path(p));
|
||||
/* Step 7: Dump to disk (preserves mode-skipped hash rows so the next
|
||||
* reindex can correctly classify those files instead of seeing them
|
||||
* as never-existed; also exports a fast-mode artifact when one is
|
||||
* already present alongside the repo). */
|
||||
dump_and_persist(existing, db_path, project, files, file_count, mode_skipped,
|
||||
mode_skipped_count, cbm_pipeline_repo_path(p));
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
cbm_gbuf_free(existing);
|
||||
|
||||
cbm_log_info("incremental.done", "elapsed_ms", itoa_buf((int)elapsed_ms(t0)));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#define CBM_PIPELINE_INTERNAL_H
|
||||
|
||||
#include "pipeline/pipeline.h"
|
||||
#include "pipeline/path_alias.h"
|
||||
#include "graph_buffer/graph_buffer.h"
|
||||
#include "discover/discover.h"
|
||||
#include "foundation/hash_table.h"
|
||||
@@ -62,6 +63,11 @@ typedef struct {
|
||||
* and pass_calls/usages/semantic reuse cached results instead of re-extracting.
|
||||
* Indexed by file position in the files[] array. Owned by pipeline.c. */
|
||||
CBMFileResult **result_cache;
|
||||
|
||||
/* Build-tool path aliases (tsconfig/jsconfig today; webpack/vite-style
|
||||
* configs are an easy follow-on). NULL when no usable configs were found.
|
||||
* Owned by pipeline.c / pipeline_incremental.c. */
|
||||
const cbm_path_alias_collection_t *path_aliases;
|
||||
} cbm_pipeline_ctx_t;
|
||||
|
||||
/* Get the current pipeline's package map (NULL if none). */
|
||||
@@ -111,14 +117,30 @@ typedef struct {
|
||||
char file_b[CBM_SZ_512];
|
||||
int co_change_count;
|
||||
double coupling_score;
|
||||
/* Unix epoch of the most recent commit that touched both files together.
|
||||
* 0 when no timestamp was available (e.g. older callers / popen path
|
||||
* without %ct). */
|
||||
long long last_co_change;
|
||||
} cbm_change_coupling_t;
|
||||
|
||||
/* Commit data for coupling analysis */
|
||||
typedef struct {
|
||||
char **files;
|
||||
int count;
|
||||
/* Unix epoch of the commit. 0 means unknown — coupling computation
|
||||
* still works but last_co_change on the resulting edge will be 0. */
|
||||
long long timestamp;
|
||||
} cbm_commit_files_t;
|
||||
|
||||
/* Per-file temporal metadata. Populated alongside change-coupling so File
|
||||
* nodes can carry change_count and last_modified for hotspot / risk
|
||||
* analysis queries. */
|
||||
typedef struct {
|
||||
char file_path[CBM_SZ_512];
|
||||
int change_count;
|
||||
long long last_modified; /* unix epoch of most recent commit */
|
||||
} cbm_file_temporal_t;
|
||||
|
||||
/* Compute change coupling from commit history.
|
||||
* Returns number of couplings written to out (up to max_out).
|
||||
* Caller owns out[]. */
|
||||
@@ -377,6 +399,10 @@ typedef struct {
|
||||
cbm_change_coupling_t *couplings;
|
||||
int count;
|
||||
int commit_count;
|
||||
/* Per-file temporal data (change_count + last_modified) for File nodes.
|
||||
* NULL when the history pass had no commits to analyse. */
|
||||
cbm_file_temporal_t *file_temporal;
|
||||
int file_temporal_count;
|
||||
} cbm_githistory_result_t;
|
||||
|
||||
/* Compute change couplings without touching the graph buffer.
|
||||
|
||||
@@ -337,12 +337,18 @@ static cbm_resolution_t resolve_import_map(const cbm_registry_t *r, const char *
|
||||
return empty_result();
|
||||
}
|
||||
|
||||
/* Build candidate: resolved.suffix or just resolved */
|
||||
/* Build candidate: resolved.suffix or resolved.prefix.
|
||||
* When the callee has a dot ("pkg.Func"), prefix="pkg" is the import key
|
||||
* and suffix="Func" is the function name, so the target QN is
|
||||
* resolved.Func. When the callee is bare ("requireAdmin"), prefix IS the
|
||||
* function name and suffix is NULL, so the target QN must be
|
||||
* resolved.requireAdmin — not just resolved, which would point at the
|
||||
* module node and miss the function entirely. */
|
||||
char candidate[CBM_SZ_512];
|
||||
if (suffix && suffix[0]) {
|
||||
snprintf(candidate, sizeof(candidate), "%s.%s", resolved, suffix);
|
||||
} else {
|
||||
snprintf(candidate, sizeof(candidate), "%s", resolved);
|
||||
snprintf(candidate, sizeof(candidate), "%s.%s", resolved, prefix);
|
||||
}
|
||||
/* Use cbm_ht_get_key to get the persistent heap-owned key string */
|
||||
const char *stored_key = cbm_ht_get_key(r->exact, candidate);
|
||||
|
||||
+84
-84
@@ -542,7 +542,7 @@ static cbm_store_t *store_open_internal(const char *path, bool in_memory) {
|
||||
if (configure_pragmas(s, in_memory) != CBM_STORE_OK || init_schema(s) != CBM_STORE_OK ||
|
||||
create_user_indexes(s) != CBM_STORE_OK) {
|
||||
sqlite3_close(s->db);
|
||||
free((void *)s->db_path);
|
||||
safe_str_free(&s->db_path);
|
||||
free(s);
|
||||
return NULL;
|
||||
}
|
||||
@@ -597,7 +597,7 @@ cbm_store_t *cbm_store_open_path_query(const char *db_path) {
|
||||
|
||||
if (configure_pragmas(s, false) != CBM_STORE_OK) {
|
||||
sqlite3_close(s->db);
|
||||
free((void *)s->db_path);
|
||||
safe_str_free(&s->db_path);
|
||||
free(s);
|
||||
return NULL;
|
||||
}
|
||||
@@ -718,7 +718,7 @@ void cbm_store_close(cbm_store_t *s) {
|
||||
/* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes.
|
||||
* Prevents ASan false-positive leaks from sqlite3 internal state. */
|
||||
sqlite3_close_v2(s->db);
|
||||
free((void *)s->db_path);
|
||||
safe_str_free(&s->db_path);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -2278,9 +2278,9 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
|
||||
const char *select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
|
||||
"n.file_path, n.start_line, n.end_line, n.properties, "
|
||||
"(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND "
|
||||
"e.type = 'CALLS') AS in_deg, "
|
||||
"e.type IN ('CALLS', 'USAGE')) AS in_deg, "
|
||||
"(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND "
|
||||
"e.type = 'CALLS') AS out_deg ";
|
||||
"e.type IN ('CALLS', 'USAGE')) AS out_deg ";
|
||||
|
||||
char where[CBM_SZ_2K] = "";
|
||||
search_bind_t binds[ST_SEARCH_MAX_BINDS];
|
||||
@@ -2304,7 +2304,7 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
|
||||
snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM (%s)", sql);
|
||||
|
||||
/* Add ORDER BY + LIMIT */
|
||||
int limit = params->limit > 0 ? params->limit : ST_HALF_SEC;
|
||||
int limit = params->limit > 0 ? params->limit : CBM_DEFAULT_SEARCH_LIMIT;
|
||||
int offset = params->offset;
|
||||
const char *name_col = has_degree_filter ? "name" : "n.name";
|
||||
char order_limit[CBM_SZ_128];
|
||||
@@ -2368,14 +2368,14 @@ void cbm_store_search_free(cbm_search_output_t *out) {
|
||||
}
|
||||
for (int i = 0; i < out->count; i++) {
|
||||
cbm_search_result_t *r = &out->results[i];
|
||||
free((void *)r->node.project);
|
||||
free((void *)r->node.label);
|
||||
free((void *)r->node.name);
|
||||
free((void *)r->node.qualified_name);
|
||||
free((void *)r->node.file_path);
|
||||
free((void *)r->node.properties_json);
|
||||
safe_str_free(&r->node.project);
|
||||
safe_str_free(&r->node.label);
|
||||
safe_str_free(&r->node.name);
|
||||
safe_str_free(&r->node.qualified_name);
|
||||
safe_str_free(&r->node.file_path);
|
||||
safe_str_free(&r->node.properties_json);
|
||||
for (int j = 0; j < r->connected_count; j++) {
|
||||
free((void *)r->connected_names[j]);
|
||||
safe_str_free(&r->connected_names[j]);
|
||||
}
|
||||
free(r->connected_names);
|
||||
}
|
||||
@@ -2570,30 +2570,30 @@ void cbm_store_traverse_free(cbm_traverse_result_t *out) {
|
||||
return;
|
||||
}
|
||||
/* Free root */
|
||||
free((void *)out->root.project);
|
||||
free((void *)out->root.label);
|
||||
free((void *)out->root.name);
|
||||
free((void *)out->root.qualified_name);
|
||||
free((void *)out->root.file_path);
|
||||
free((void *)out->root.properties_json);
|
||||
safe_str_free(&out->root.project);
|
||||
safe_str_free(&out->root.label);
|
||||
safe_str_free(&out->root.name);
|
||||
safe_str_free(&out->root.qualified_name);
|
||||
safe_str_free(&out->root.file_path);
|
||||
safe_str_free(&out->root.properties_json);
|
||||
|
||||
/* Free visited */
|
||||
for (int i = 0; i < out->visited_count; i++) {
|
||||
cbm_node_hop_t *h = &out->visited[i];
|
||||
free((void *)h->node.project);
|
||||
free((void *)h->node.label);
|
||||
free((void *)h->node.name);
|
||||
free((void *)h->node.qualified_name);
|
||||
free((void *)h->node.file_path);
|
||||
free((void *)h->node.properties_json);
|
||||
safe_str_free(&h->node.project);
|
||||
safe_str_free(&h->node.label);
|
||||
safe_str_free(&h->node.name);
|
||||
safe_str_free(&h->node.qualified_name);
|
||||
safe_str_free(&h->node.file_path);
|
||||
safe_str_free(&h->node.properties_json);
|
||||
}
|
||||
free(out->visited);
|
||||
|
||||
/* Free edges */
|
||||
for (int i = 0; i < out->edge_count; i++) {
|
||||
free((void *)out->edges[i].from_name);
|
||||
free((void *)out->edges[i].to_name);
|
||||
free((void *)out->edges[i].type);
|
||||
safe_str_free(&out->edges[i].from_name);
|
||||
safe_str_free(&out->edges[i].to_name);
|
||||
safe_str_free(&out->edges[i].type);
|
||||
}
|
||||
free(out->edges);
|
||||
|
||||
@@ -2758,7 +2758,7 @@ int cbm_store_get_schema(cbm_store_t *s, const char *project, cbm_schema_info_t
|
||||
void *tmp = realloc(arr, new_cap * sizeof(cbm_label_count_t));
|
||||
if (!tmp) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)arr[i].label);
|
||||
safe_str_free(&arr[i].label);
|
||||
}
|
||||
free(arr);
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -2825,7 +2825,7 @@ int cbm_store_get_schema(cbm_store_t *s, const char *project, cbm_schema_info_t
|
||||
void *tmp = realloc(arr, new_cap * sizeof(cbm_type_count_t));
|
||||
if (!tmp) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
free((void *)arr[i].type);
|
||||
safe_str_free(&arr[i].type);
|
||||
}
|
||||
free(arr);
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -2872,7 +2872,7 @@ void cbm_store_schema_free(cbm_schema_info_t *out) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < out->node_label_count; i++) {
|
||||
free((void *)out->node_labels[i].label);
|
||||
safe_str_free(&out->node_labels[i].label);
|
||||
for (int j = 0; j < out->node_labels[i].property_count; j++) {
|
||||
free(out->node_labels[i].properties[j]);
|
||||
}
|
||||
@@ -2881,7 +2881,7 @@ void cbm_store_schema_free(cbm_schema_info_t *out) {
|
||||
free(out->node_labels);
|
||||
|
||||
for (int i = 0; i < out->edge_type_count; i++) {
|
||||
free((void *)out->edge_types[i].type);
|
||||
safe_str_free(&out->edge_types[i].type);
|
||||
for (int j = 0; j < out->edge_types[i].property_count; j++) {
|
||||
free(out->edge_types[i].properties[j]);
|
||||
}
|
||||
@@ -2890,22 +2890,22 @@ void cbm_store_schema_free(cbm_schema_info_t *out) {
|
||||
free(out->edge_types);
|
||||
|
||||
for (int i = 0; i < out->rel_pattern_count; i++) {
|
||||
free((void *)out->rel_patterns[i]);
|
||||
safe_str_free(&out->rel_patterns[i]);
|
||||
}
|
||||
free(out->rel_patterns);
|
||||
|
||||
for (int i = 0; i < out->sample_func_count; i++) {
|
||||
free((void *)out->sample_func_names[i]);
|
||||
safe_str_free(&out->sample_func_names[i]);
|
||||
}
|
||||
free(out->sample_func_names);
|
||||
|
||||
for (int i = 0; i < out->sample_class_count; i++) {
|
||||
free((void *)out->sample_class_names[i]);
|
||||
safe_str_free(&out->sample_class_names[i]);
|
||||
}
|
||||
free(out->sample_class_names);
|
||||
|
||||
for (int i = 0; i < out->sample_qn_count; i++) {
|
||||
free((void *)out->sample_qns[i]);
|
||||
safe_str_free(&out->sample_qns[i]);
|
||||
}
|
||||
free(out->sample_qns);
|
||||
|
||||
@@ -3192,17 +3192,17 @@ static int arch_routes(cbm_store_t *s, const char *project, cbm_architecture_inf
|
||||
char *val;
|
||||
val = extract_json_string_prop(props, "\"method\"", ST_METHOD_PROP_LEN);
|
||||
if (val) {
|
||||
free((void *)arr[n].method);
|
||||
safe_str_free(&arr[n].method);
|
||||
arr[n].method = val;
|
||||
}
|
||||
val = extract_json_string_prop(props, "\"path\"", ST_PATH_PROP_LEN);
|
||||
if (val) {
|
||||
free((void *)arr[n].path);
|
||||
safe_str_free(&arr[n].path);
|
||||
arr[n].path = val;
|
||||
}
|
||||
val = extract_json_string_prop(props, "\"handler\"", ST_HANDLER_PROP_LEN);
|
||||
if (val) {
|
||||
free((void *)arr[n].handler);
|
||||
safe_str_free(&arr[n].handler);
|
||||
arr[n].handler = val;
|
||||
}
|
||||
n++;
|
||||
@@ -3649,8 +3649,8 @@ static int arch_layers(cbm_store_t *s, const char *project, cbm_architecture_inf
|
||||
|
||||
/* Cleanup */
|
||||
for (int i = 0; i < bcount; i++) {
|
||||
free((void *)boundaries[i].from);
|
||||
free((void *)boundaries[i].to);
|
||||
safe_str_free(&boundaries[i].from);
|
||||
safe_str_free(&boundaries[i].to);
|
||||
}
|
||||
free(boundaries);
|
||||
for (int i = 0; i < nrpkgs; i++) {
|
||||
@@ -4262,66 +4262,66 @@ void cbm_store_architecture_free(cbm_architecture_info_t *out) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < out->language_count; i++) {
|
||||
free((void *)out->languages[i].language);
|
||||
safe_str_free(&out->languages[i].language);
|
||||
}
|
||||
free(out->languages);
|
||||
for (int i = 0; i < out->package_count; i++) {
|
||||
free((void *)out->packages[i].name);
|
||||
safe_str_free(&out->packages[i].name);
|
||||
}
|
||||
free(out->packages);
|
||||
for (int i = 0; i < out->entry_point_count; i++) {
|
||||
free((void *)out->entry_points[i].name);
|
||||
free((void *)out->entry_points[i].qualified_name);
|
||||
free((void *)out->entry_points[i].file);
|
||||
safe_str_free(&out->entry_points[i].name);
|
||||
safe_str_free(&out->entry_points[i].qualified_name);
|
||||
safe_str_free(&out->entry_points[i].file);
|
||||
}
|
||||
free(out->entry_points);
|
||||
for (int i = 0; i < out->route_count; i++) {
|
||||
free((void *)out->routes[i].method);
|
||||
free((void *)out->routes[i].path);
|
||||
free((void *)out->routes[i].handler);
|
||||
safe_str_free(&out->routes[i].method);
|
||||
safe_str_free(&out->routes[i].path);
|
||||
safe_str_free(&out->routes[i].handler);
|
||||
}
|
||||
free(out->routes);
|
||||
for (int i = 0; i < out->hotspot_count; i++) {
|
||||
free((void *)out->hotspots[i].name);
|
||||
free((void *)out->hotspots[i].qualified_name);
|
||||
safe_str_free(&out->hotspots[i].name);
|
||||
safe_str_free(&out->hotspots[i].qualified_name);
|
||||
}
|
||||
free(out->hotspots);
|
||||
for (int i = 0; i < out->boundary_count; i++) {
|
||||
free((void *)out->boundaries[i].from);
|
||||
free((void *)out->boundaries[i].to);
|
||||
safe_str_free(&out->boundaries[i].from);
|
||||
safe_str_free(&out->boundaries[i].to);
|
||||
}
|
||||
free(out->boundaries);
|
||||
for (int i = 0; i < out->service_count; i++) {
|
||||
free((void *)out->services[i].from);
|
||||
free((void *)out->services[i].to);
|
||||
free((void *)out->services[i].type);
|
||||
safe_str_free(&out->services[i].from);
|
||||
safe_str_free(&out->services[i].to);
|
||||
safe_str_free(&out->services[i].type);
|
||||
}
|
||||
free(out->services);
|
||||
for (int i = 0; i < out->layer_count; i++) {
|
||||
free((void *)out->layers[i].name);
|
||||
free((void *)out->layers[i].layer);
|
||||
free((void *)out->layers[i].reason);
|
||||
safe_str_free(&out->layers[i].name);
|
||||
safe_str_free(&out->layers[i].layer);
|
||||
safe_str_free(&out->layers[i].reason);
|
||||
}
|
||||
free(out->layers);
|
||||
for (int i = 0; i < out->cluster_count; i++) {
|
||||
free((void *)out->clusters[i].label);
|
||||
safe_str_free(&out->clusters[i].label);
|
||||
for (int j = 0; j < out->clusters[i].top_node_count; j++) {
|
||||
free((void *)out->clusters[i].top_nodes[j]);
|
||||
safe_str_free(&out->clusters[i].top_nodes[j]);
|
||||
}
|
||||
free(out->clusters[i].top_nodes);
|
||||
for (int j = 0; j < out->clusters[i].package_count; j++) {
|
||||
free((void *)out->clusters[i].packages[j]);
|
||||
safe_str_free(&out->clusters[i].packages[j]);
|
||||
}
|
||||
free(out->clusters[i].packages);
|
||||
for (int j = 0; j < out->clusters[i].edge_type_count; j++) {
|
||||
free((void *)out->clusters[i].edge_types[j]);
|
||||
safe_str_free(&out->clusters[i].edge_types[j]);
|
||||
}
|
||||
free(out->clusters[i].edge_types);
|
||||
}
|
||||
free(out->clusters);
|
||||
for (int i = 0; i < out->file_tree_count; i++) {
|
||||
free((void *)out->file_tree[i].path);
|
||||
free((void *)out->file_tree[i].type);
|
||||
safe_str_free(&out->file_tree[i].path);
|
||||
safe_str_free(&out->file_tree[i].type);
|
||||
}
|
||||
free(out->file_tree);
|
||||
memset(out, 0, sizeof(*out));
|
||||
@@ -4707,10 +4707,10 @@ void cbm_store_adr_free(cbm_adr_t *adr) {
|
||||
if (!adr) {
|
||||
return;
|
||||
}
|
||||
free((void *)adr->project);
|
||||
free((void *)adr->content);
|
||||
free((void *)adr->created_at);
|
||||
free((void *)adr->updated_at);
|
||||
safe_str_free(&adr->project);
|
||||
safe_str_free(&adr->content);
|
||||
safe_str_free(&adr->created_at);
|
||||
safe_str_free(&adr->updated_at);
|
||||
memset(adr, 0, sizeof(*adr));
|
||||
}
|
||||
|
||||
@@ -4748,12 +4748,12 @@ int cbm_store_find_architecture_docs(cbm_store_t *s, const char *project, char *
|
||||
/* ── Memory management ──────────────────────────────────────────── */
|
||||
|
||||
void cbm_node_free_fields(cbm_node_t *n) {
|
||||
free((void *)n->project);
|
||||
free((void *)n->label);
|
||||
free((void *)n->name);
|
||||
free((void *)n->qualified_name);
|
||||
free((void *)n->file_path);
|
||||
free((void *)n->properties_json);
|
||||
safe_str_free(&n->project);
|
||||
safe_str_free(&n->label);
|
||||
safe_str_free(&n->name);
|
||||
safe_str_free(&n->qualified_name);
|
||||
safe_str_free(&n->file_path);
|
||||
safe_str_free(&n->properties_json);
|
||||
}
|
||||
|
||||
void cbm_store_free_nodes(cbm_node_t *nodes, int count) {
|
||||
@@ -4771,17 +4771,17 @@ void cbm_store_free_edges(cbm_edge_t *edges, int count) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
free((void *)edges[i].project);
|
||||
free((void *)edges[i].type);
|
||||
free((void *)edges[i].properties_json);
|
||||
safe_str_free(&edges[i].project);
|
||||
safe_str_free(&edges[i].type);
|
||||
safe_str_free(&edges[i].properties_json);
|
||||
}
|
||||
free(edges);
|
||||
}
|
||||
|
||||
void cbm_project_free_fields(cbm_project_t *p) {
|
||||
free((void *)p->name);
|
||||
free((void *)p->indexed_at);
|
||||
free((void *)p->root_path);
|
||||
safe_str_free(&p->name);
|
||||
safe_str_free(&p->indexed_at);
|
||||
safe_str_free(&p->root_path);
|
||||
}
|
||||
|
||||
void cbm_store_free_projects(cbm_project_t *projects, int count) {
|
||||
@@ -4799,9 +4799,9 @@ void cbm_store_free_file_hashes(cbm_file_hash_t *hashes, int count) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
free((void *)hashes[i].project);
|
||||
free((void *)hashes[i].rel_path);
|
||||
free((void *)hashes[i].sha256);
|
||||
safe_str_free(&hashes[i].project);
|
||||
safe_str_free(&hashes[i].rel_path);
|
||||
safe_str_free(&hashes[i].sha256);
|
||||
}
|
||||
free(hashes);
|
||||
}
|
||||
|
||||
@@ -1026,6 +1026,30 @@ TEST(cypher_edge_filter_numeric_gte) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cypher_bare_edge_return_exposes_properties_json) {
|
||||
/* `RETURN r` on an edge variable, with no property accessor, should
|
||||
* surface the edge's full properties JSON (or "{}"). Before the fix,
|
||||
* binding_get_virtual returned an empty string, which made bare edge
|
||||
* returns useless for callers that wanted to inspect timestamps,
|
||||
* weights, etc. without naming each property up front. */
|
||||
cbm_store_t *s = setup_cypher_multi_edge_store();
|
||||
cbm_cypher_result_t r = {0};
|
||||
|
||||
int rc = cbm_cypher_execute(
|
||||
s, "MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.method = 'POST' RETURN r", "testproj", 0, &r);
|
||||
ASSERT_EQ(rc, 0);
|
||||
ASSERT_EQ(r.row_count, 1);
|
||||
const char *r_val = cypher_get_col(&r, 0, "r");
|
||||
ASSERT_NOT_NULL(r_val);
|
||||
/* Expect JSON object content rather than the previous empty string. */
|
||||
ASSERT_NOT_NULL(strstr(r_val, "url_path"));
|
||||
ASSERT_NOT_NULL(strstr(r_val, "/api/orders"));
|
||||
|
||||
cbm_cypher_result_free(&r);
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cypher_edge_return_without_filter) {
|
||||
cbm_store_t *s = setup_cypher_multi_edge_store();
|
||||
cbm_cypher_result_t r = {0};
|
||||
@@ -2142,6 +2166,7 @@ SUITE(cypher) {
|
||||
RUN_TEST(cypher_edge_type_prop);
|
||||
RUN_TEST(cypher_edge_filter_contains);
|
||||
RUN_TEST(cypher_edge_filter_numeric_gte);
|
||||
RUN_TEST(cypher_bare_edge_return_exposes_properties_json);
|
||||
RUN_TEST(cypher_edge_return_without_filter);
|
||||
RUN_TEST(cypher_edge_filter_equals);
|
||||
RUN_TEST(cypher_edge_filter_starts_with);
|
||||
|
||||
@@ -103,6 +103,52 @@ TEST(java_interface) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Regression for #279: a Java class declaring both `extends` and
|
||||
* `implements` must produce one INHERITS edge per base — the extends parent
|
||||
* AND every implements interface — with bare type names (not the keyword
|
||||
* text "extends Bar" / "implements Baz, Qux"). Before the fix:
|
||||
* 1) the field loop returned on the first match → only the superclass
|
||||
* was emitted, the interfaces were dropped.
|
||||
* 2) the emitted name was the full field text including the keyword. */
|
||||
TEST(java_class_extends_and_implements) {
|
||||
CBMFileResult *r = extract(
|
||||
"public class DefaultLinkTool extends DefaultDiagramTool implements ILinkTool, Closeable { }",
|
||||
CBM_LANG_JAVA, "t", "DefaultLinkTool.java");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* Find the class def and inspect its base_classes list. */
|
||||
CBMDefinition *cls = NULL;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (strcmp(r->defs.items[i].label, "Class") == 0 &&
|
||||
strcmp(r->defs.items[i].name, "DefaultLinkTool") == 0) {
|
||||
cls = &r->defs.items[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_NOT_NULL(cls);
|
||||
ASSERT_NOT_NULL(cls->base_classes);
|
||||
|
||||
bool saw_super = false;
|
||||
bool saw_iface_a = false;
|
||||
bool saw_iface_b = false;
|
||||
for (const char **b = cls->base_classes; *b; b++) {
|
||||
/* The keyword-text bug would surface as "extends ..." or
|
||||
* "implements ..." literally inside one of the entries. */
|
||||
ASSERT_NULL(strstr(*b, "extends"));
|
||||
ASSERT_NULL(strstr(*b, "implements"));
|
||||
if (strcmp(*b, "DefaultDiagramTool") == 0) saw_super = true;
|
||||
if (strcmp(*b, "ILinkTool") == 0) saw_iface_a = true;
|
||||
if (strcmp(*b, "Closeable") == 0) saw_iface_b = true;
|
||||
}
|
||||
ASSERT_TRUE(saw_super);
|
||||
ASSERT_TRUE(saw_iface_a);
|
||||
ASSERT_TRUE(saw_iface_b);
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* --- PHP --- */
|
||||
TEST(php_class) {
|
||||
CBMFileResult *r = extract("<?php\nclass User { public string $name; public function "
|
||||
@@ -1769,6 +1815,87 @@ TEST(import_stress_go) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Embedded-language import extraction
|
||||
* Host grammars (Svelte, Vue, HTML, Astro) keep <script> bodies as
|
||||
* raw_text — the embedded-imports walker re-parses each block with the
|
||||
* JS grammar so the standard ES import extractor sees real
|
||||
* import_statement nodes.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(svelte_imports_basic) {
|
||||
/* Default import + named imports + namespace import */
|
||||
CBMFileResult *r = extract(
|
||||
"<script>\n"
|
||||
"import Foo from './Foo.svelte';\n"
|
||||
"import { bar, baz } from '../lib/utils';\n"
|
||||
"import * as helpers from './helpers';\n"
|
||||
"export let value = 42;\n"
|
||||
"</script>\n"
|
||||
"<h1>Hello {value}</h1>\n",
|
||||
CBM_LANG_SVELTE, "t", "Comp.svelte");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_GTE(r->imports.count, 3);
|
||||
ASSERT(has_import(r, "Foo.svelte"));
|
||||
ASSERT(has_import(r, "lib/utils"));
|
||||
ASSERT(has_import(r, "helpers"));
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(svelte_imports_no_script) {
|
||||
/* .svelte with no <script> block must not crash, 0 imports */
|
||||
CBMFileResult *r = extract(
|
||||
"<h1>Static page</h1>\n"
|
||||
"<p>No script here.</p>\n",
|
||||
CBM_LANG_SVELTE, "t", "Static.svelte");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_EQ(r->imports.count, 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(vue_imports_basic) {
|
||||
/* Vue SFC: same document→script_element→raw_text AST structure */
|
||||
CBMFileResult *r = extract(
|
||||
"<template><div>{{ msg }}</div></template>\n"
|
||||
"<script>\n"
|
||||
"import MyComp from './MyComp.vue';\n"
|
||||
"import { ref } from 'vue';\n"
|
||||
"export default { name: 'App' };\n"
|
||||
"</script>\n",
|
||||
CBM_LANG_VUE, "t", "App.vue");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_GTE(r->imports.count, 2);
|
||||
ASSERT(has_import(r, "MyComp.vue"));
|
||||
ASSERT(has_import(r, "vue"));
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(html_imports_basic) {
|
||||
/* Plain HTML with inline ES module imports — same generic walker. */
|
||||
CBMFileResult *r = extract(
|
||||
"<!DOCTYPE html><html><head>\n"
|
||||
"<script type=\"module\">\n"
|
||||
"import { renderApp } from './app.js';\n"
|
||||
"import * as utils from './utils.js';\n"
|
||||
"renderApp();\n"
|
||||
"</script>\n"
|
||||
"</head><body></body></html>\n",
|
||||
CBM_LANG_HTML, "t", "index.html");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_GTE(r->imports.count, 2);
|
||||
ASSERT(has_import(r, "app.js"));
|
||||
ASSERT(has_import(r, "utils.js"));
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* config_extraction_test.go ports (25 tests)
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
@@ -2145,6 +2272,7 @@ SUITE(extraction) {
|
||||
RUN_TEST(java_class);
|
||||
RUN_TEST(java_method);
|
||||
RUN_TEST(java_interface);
|
||||
RUN_TEST(java_class_extends_and_implements);
|
||||
RUN_TEST(php_class);
|
||||
RUN_TEST(php_function);
|
||||
RUN_TEST(ruby_class);
|
||||
@@ -2297,6 +2425,10 @@ SUITE(extraction) {
|
||||
RUN_TEST(ruby_imports);
|
||||
RUN_TEST(lua_imports);
|
||||
RUN_TEST(import_stress_go);
|
||||
RUN_TEST(svelte_imports_basic);
|
||||
RUN_TEST(svelte_imports_no_script);
|
||||
RUN_TEST(vue_imports_basic);
|
||||
RUN_TEST(html_imports_basic);
|
||||
|
||||
/* config_extraction_test.go ports */
|
||||
RUN_TEST(toml_basic_table_and_pair);
|
||||
|
||||
@@ -34,6 +34,7 @@ extern void suite_graph_buffer(void);
|
||||
extern void suite_registry(void);
|
||||
extern void suite_pipeline(void);
|
||||
extern void suite_fqn(void);
|
||||
extern void suite_path_alias(void);
|
||||
extern void suite_watcher(void);
|
||||
extern void suite_lz4(void);
|
||||
extern void suite_zstd(void);
|
||||
@@ -42,6 +43,12 @@ extern void suite_sqlite_writer(void);
|
||||
extern void suite_go_lsp(void);
|
||||
extern void suite_c_lsp(void);
|
||||
extern void suite_php_lsp(void);
|
||||
extern void suite_scope(void);
|
||||
extern void suite_type_rep(void);
|
||||
extern void suite_py_lsp(void);
|
||||
extern void suite_py_lsp_bench(void);
|
||||
extern void suite_py_lsp_stress(void);
|
||||
extern void suite_py_lsp_scale(void);
|
||||
extern void suite_store_arch(void);
|
||||
extern void suite_store_bulk(void);
|
||||
extern void suite_traces(void);
|
||||
@@ -58,6 +65,7 @@ extern void suite_yaml(void);
|
||||
extern void suite_integration(void);
|
||||
extern void suite_incremental(void);
|
||||
extern void suite_simhash(void);
|
||||
extern void suite_stack_overflow(void);
|
||||
|
||||
int main(void) {
|
||||
printf("\n codebase-memory-mcp C test suite\n");
|
||||
@@ -100,6 +108,7 @@ int main(void) {
|
||||
RUN_SUITE(registry);
|
||||
RUN_SUITE(pipeline);
|
||||
RUN_SUITE(fqn);
|
||||
RUN_SUITE(path_alias);
|
||||
|
||||
/* Watcher (M10) */
|
||||
RUN_SUITE(watcher);
|
||||
@@ -113,9 +122,15 @@ int main(void) {
|
||||
RUN_SUITE(artifact);
|
||||
|
||||
/* LSP resolvers */
|
||||
RUN_SUITE(scope);
|
||||
RUN_SUITE(type_rep);
|
||||
RUN_SUITE(go_lsp);
|
||||
RUN_SUITE(c_lsp);
|
||||
RUN_SUITE(php_lsp);
|
||||
RUN_SUITE(py_lsp);
|
||||
RUN_SUITE(py_lsp_bench);
|
||||
RUN_SUITE(py_lsp_stress);
|
||||
RUN_SUITE(py_lsp_scale);
|
||||
|
||||
/* Architecture + ADR + Louvain */
|
||||
RUN_SUITE(store_arch);
|
||||
@@ -156,6 +171,9 @@ int main(void) {
|
||||
/* SimHash / SIMILAR_TO */
|
||||
RUN_SUITE(simhash);
|
||||
|
||||
/* Stack overflow regression (GitHub #199) */
|
||||
RUN_SUITE(stack_overflow);
|
||||
|
||||
/* Integration (end-to-end) */
|
||||
RUN_SUITE(integration);
|
||||
RUN_SUITE(incremental);
|
||||
|
||||
@@ -393,6 +393,41 @@ TEST(tool_search_graph_basic) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Forward declarations for helpers defined later in this file */
|
||||
static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz);
|
||||
static void cleanup_snippet_dir(const char *tmp_dir);
|
||||
static char *extract_text_content(const char *mcp_result);
|
||||
|
||||
TEST(tool_search_graph_includes_node_properties) {
|
||||
/* search_graph results must surface each node's properties_json
|
||||
* payload so callers don't have to round-trip through get_code_snippet
|
||||
* just to read them. The setup_snippet_server inserts HandleRequest
|
||||
* with a signature/return_type/is_exported property blob; this test
|
||||
* pins that those keys reach the MCP response. */
|
||||
char tmp[256];
|
||||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
char *resp = cbm_mcp_server_handle(
|
||||
srv, "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"search_graph\","
|
||||
"\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
|
||||
"\"name_pattern\":\"HandleRequest\",\"limit\":5}}}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
/* Properties from HandleRequest's properties_json must appear. */
|
||||
ASSERT_NOT_NULL(strstr(inner, "signature"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "is_exported"));
|
||||
free(inner);
|
||||
free(resp);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_snippet_dir(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_query_graph_basic) {
|
||||
cbm_mcp_server_t *srv = setup_mcp_with_data();
|
||||
|
||||
@@ -490,6 +525,56 @@ TEST(tool_get_architecture_empty) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Regression for #281: handle_get_architecture must actually call
|
||||
* cbm_store_get_architecture and surface its sections. Before the fix
|
||||
* only label/edge histograms were emitted regardless of which aspects
|
||||
* were requested. The store-side arch_entry_points query reads
|
||||
* properties.is_entry_point on Function nodes, so we tag one node and
|
||||
* assert the resulting JSON surfaces an "entry_points" array containing
|
||||
* the tagged function — which is impossible without the wiring. */
|
||||
TEST(tool_get_architecture_emits_populated_sections) {
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(st);
|
||||
|
||||
const char *proj = "arch-test";
|
||||
cbm_mcp_server_set_project(srv, proj);
|
||||
cbm_store_upsert_project(st, proj, "/tmp/arch-test");
|
||||
|
||||
cbm_node_t main_fn = {0};
|
||||
main_fn.project = proj;
|
||||
main_fn.label = "Function";
|
||||
main_fn.name = "main";
|
||||
main_fn.qualified_name = "arch-test.cmd.main";
|
||||
main_fn.file_path = "cmd/main.go";
|
||||
main_fn.start_line = 1;
|
||||
main_fn.end_line = 3;
|
||||
main_fn.properties_json = "{\"is_entry_point\":true}";
|
||||
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
|
||||
|
||||
char *resp = cbm_mcp_server_handle(
|
||||
srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"get_architecture\","
|
||||
"\"arguments\":{\"project\":\"arch-test\",\"aspects\":[\"all\"]}}}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
|
||||
/* The handler always emits node/edge counts and schema histograms;
|
||||
* those existed before #281. The "entry_points" array only appears
|
||||
* when cbm_store_get_architecture is actually called and its result
|
||||
* is serialized — which is exactly what #281 wires up. */
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"entry_points\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "main"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_query_graph_missing_query) {
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
|
||||
@@ -966,6 +1051,13 @@ static char *extract_text_content(const char *mcp_result) {
|
||||
return strdup(mcp_result); /* fallback */
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
yyjson_val *content = yyjson_obj_get(root, "content");
|
||||
if (!content) {
|
||||
/* Handle JSON-RPC wrapper: {"jsonrpc":...,"result":{"content":[...]}} */
|
||||
yyjson_val *rpc_result = yyjson_obj_get(root, "result");
|
||||
if (rpc_result) {
|
||||
content = yyjson_obj_get(rpc_result, "content");
|
||||
}
|
||||
}
|
||||
if (!content || !yyjson_is_arr(content)) {
|
||||
yyjson_doc_free(doc);
|
||||
return strdup(mcp_result);
|
||||
@@ -1695,6 +1787,7 @@ SUITE(mcp) {
|
||||
RUN_TEST(tool_get_graph_schema_empty);
|
||||
RUN_TEST(tool_unknown_tool);
|
||||
RUN_TEST(tool_search_graph_basic);
|
||||
RUN_TEST(tool_search_graph_includes_node_properties);
|
||||
RUN_TEST(tool_query_graph_basic);
|
||||
RUN_TEST(tool_index_status_no_project);
|
||||
|
||||
@@ -1703,6 +1796,7 @@ SUITE(mcp) {
|
||||
RUN_TEST(tool_trace_missing_function_name);
|
||||
RUN_TEST(tool_delete_project_not_found);
|
||||
RUN_TEST(tool_get_architecture_empty);
|
||||
RUN_TEST(tool_get_architecture_emits_populated_sections);
|
||||
RUN_TEST(tool_query_graph_missing_query);
|
||||
|
||||
/* Pipeline-dependent tool handlers */
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* test_path_alias.c -- Tests for build-tool path alias resolution.
|
||||
*
|
||||
* Covers the in-memory resolver (cbm_path_alias_resolve), the
|
||||
* directory-scoped collection lookup (cbm_path_alias_find_for_file),
|
||||
* and the resource-cap behaviour. Filesystem-based loading is exercised
|
||||
* indirectly via the integration test that builds a tmp tsconfig tree.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "../src/pipeline/path_alias.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Build a path alias map programmatically (no file I/O), respecting the
|
||||
* specificity ordering invariant the loader establishes via qsort. */
|
||||
static cbm_path_alias_map_t *make_map(const char *base_url, int count, ...) {
|
||||
cbm_path_alias_map_t *map = calloc(1, sizeof(*map));
|
||||
map->base_url = base_url ? strdup(base_url) : NULL;
|
||||
map->entries = calloc((size_t)count, sizeof(cbm_path_alias_t));
|
||||
map->count = count;
|
||||
|
||||
va_list args;
|
||||
va_start(args, count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *alias_pattern = va_arg(args, const char *);
|
||||
const char *target_pattern = va_arg(args, const char *);
|
||||
const char *star = strchr(alias_pattern, '*');
|
||||
if (star) {
|
||||
map->entries[i].has_wildcard = true;
|
||||
map->entries[i].alias_prefix =
|
||||
strndup(alias_pattern, (size_t)(star - alias_pattern));
|
||||
map->entries[i].alias_suffix = strdup(star + 1);
|
||||
} else {
|
||||
map->entries[i].has_wildcard = false;
|
||||
map->entries[i].alias_prefix = strdup(alias_pattern);
|
||||
map->entries[i].alias_suffix = strdup("");
|
||||
}
|
||||
const char *tstar = strchr(target_pattern, '*');
|
||||
if (tstar) {
|
||||
map->entries[i].target_prefix =
|
||||
strndup(target_pattern, (size_t)(tstar - target_pattern));
|
||||
map->entries[i].target_suffix = strdup(tstar + 1);
|
||||
} else {
|
||||
map->entries[i].target_prefix = strdup(target_pattern);
|
||||
map->entries[i].target_suffix = strdup("");
|
||||
}
|
||||
}
|
||||
va_end(args);
|
||||
|
||||
/* Mimic the loader's specificity sort. */
|
||||
for (int i = 0; i < count - 1; i++) {
|
||||
for (int j = i + 1; j < count; j++) {
|
||||
size_t li = strlen(map->entries[i].alias_prefix);
|
||||
size_t lj = strlen(map->entries[j].alias_prefix);
|
||||
if (lj > li) {
|
||||
cbm_path_alias_t tmp = map->entries[i];
|
||||
map->entries[i] = map->entries[j];
|
||||
map->entries[j] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* The map produced by make_map() owns all heap memory the same way the
|
||||
* loader does, so the public free routine on the wrapping collection
|
||||
* works equivalently — but tests build naked maps, so use this helper. */
|
||||
static void free_map(cbm_path_alias_map_t *map) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < map->count; i++) {
|
||||
free(map->entries[i].alias_prefix);
|
||||
free(map->entries[i].alias_suffix);
|
||||
free(map->entries[i].target_prefix);
|
||||
free(map->entries[i].target_suffix);
|
||||
}
|
||||
free(map->entries);
|
||||
free(map->base_url);
|
||||
free(map);
|
||||
}
|
||||
|
||||
/* ── Basic wildcard alias ──────────────────────────────────────── */
|
||||
|
||||
TEST(path_alias_at_wildcard) {
|
||||
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
|
||||
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_STR_EQ(r, "src/lib/auth");
|
||||
free(r);
|
||||
free_map(m);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(path_alias_at_nested) {
|
||||
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
|
||||
char *r = cbm_path_alias_resolve(m, "@/components/Button");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_STR_EQ(r, "src/components/Button");
|
||||
free(r);
|
||||
free_map(m);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Specificity ordering: longest matching prefix wins ────────── */
|
||||
|
||||
TEST(path_alias_specificity_longest_first) {
|
||||
// @/lib/* must beat @/* even though @/* would also match.
|
||||
cbm_path_alias_map_t *m =
|
||||
make_map(NULL, 2, "@/*", "src/*", "@/lib/*", "src/shared/lib/*");
|
||||
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_STR_EQ(r, "src/shared/lib/auth");
|
||||
free(r);
|
||||
/* Non-/lib paths still match the broader rule. */
|
||||
char *r2 = cbm_path_alias_resolve(m, "@/components/X");
|
||||
ASSERT_NOT_NULL(r2);
|
||||
ASSERT_STR_EQ(r2, "src/components/X");
|
||||
free(r2);
|
||||
free_map(m);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Exact match (no wildcard) ─────────────────────────────────── */
|
||||
|
||||
TEST(path_alias_exact_match) {
|
||||
cbm_path_alias_map_t *m = make_map(NULL, 1, "@app/config", "src/config/index");
|
||||
char *r = cbm_path_alias_resolve(m, "@app/config");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_STR_EQ(r, "src/config/index");
|
||||
free(r);
|
||||
/* Anything else under @app/ should not match. */
|
||||
char *miss = cbm_path_alias_resolve(m, "@app/other");
|
||||
ASSERT_NULL(miss);
|
||||
free_map(m);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Extension stripping ───────────────────────────────────────── */
|
||||
|
||||
TEST(path_alias_strips_ext) {
|
||||
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*.ts");
|
||||
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_STR_EQ(r, "src/lib/auth");
|
||||
free(r);
|
||||
free_map(m);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── baseUrl fallback for non-relative, non-package imports ────── */
|
||||
|
||||
TEST(path_alias_baseurl_fallback) {
|
||||
cbm_path_alias_map_t *m = make_map("src", 0);
|
||||
/* Looks like a sub-path → resolve against baseUrl. */
|
||||
char *r = cbm_path_alias_resolve(m, "lib/auth");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_STR_EQ(r, "src/lib/auth");
|
||||
free(r);
|
||||
/* Bare package name → not a baseUrl candidate. */
|
||||
char *miss = cbm_path_alias_resolve(m, "react");
|
||||
ASSERT_NULL(miss);
|
||||
free_map(m);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── NULL safety ───────────────────────────────────────────────── */
|
||||
|
||||
TEST(path_alias_null_safety) {
|
||||
ASSERT_NULL(cbm_path_alias_resolve(NULL, "anything"));
|
||||
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
|
||||
ASSERT_NULL(cbm_path_alias_resolve(m, NULL));
|
||||
free_map(m);
|
||||
/* Free of NULL collection is a no-op, not a crash. */
|
||||
cbm_path_alias_collection_free(NULL);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Collection lookup: nearest-ancestor scope wins ───────────── */
|
||||
|
||||
TEST(path_alias_find_for_file_nearest_ancestor) {
|
||||
/* Build a synthetic collection by hand: two scopes, "" (root) and
|
||||
* "apps/manager". A file inside apps/manager/src/... must pick the
|
||||
* deeper scope; a file under packages/utils must fall back to root. */
|
||||
cbm_path_alias_collection_t *coll = calloc(1, sizeof(*coll));
|
||||
coll->scopes = calloc(2, sizeof(cbm_path_alias_scope_t));
|
||||
coll->count = 2;
|
||||
|
||||
/* Order matters: most specific first (loader does this via qsort). */
|
||||
coll->scopes[0].dir_prefix = strdup("apps/manager");
|
||||
coll->scopes[0].map = make_map(NULL, 1, "@/*", "src/*");
|
||||
|
||||
coll->scopes[1].dir_prefix = strdup("");
|
||||
coll->scopes[1].map = make_map(NULL, 1, "@root/*", "shared/*");
|
||||
|
||||
const cbm_path_alias_map_t *m1 =
|
||||
cbm_path_alias_find_for_file(coll, "apps/manager/src/lib/auth.ts");
|
||||
ASSERT_NOT_NULL(m1);
|
||||
ASSERT_EQ(m1->count, 1);
|
||||
ASSERT_STR_EQ(m1->entries[0].alias_prefix, "@/");
|
||||
|
||||
const cbm_path_alias_map_t *m2 =
|
||||
cbm_path_alias_find_for_file(coll, "packages/utils/index.ts");
|
||||
ASSERT_NOT_NULL(m2);
|
||||
ASSERT_EQ(m2->count, 1);
|
||||
ASSERT_STR_EQ(m2->entries[0].alias_prefix, "@root/");
|
||||
|
||||
cbm_path_alias_collection_free(coll);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── End-to-end via the loader: real tsconfig in a tmp dir ─────── */
|
||||
|
||||
static int write_file(const char *path, const char *content) {
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
return -1;
|
||||
}
|
||||
size_t len = strlen(content);
|
||||
int rc = fwrite(content, 1, len, f) == len ? 0 : -1;
|
||||
fclose(f);
|
||||
return rc;
|
||||
}
|
||||
|
||||
TEST(path_alias_loader_monorepo) {
|
||||
char tmpl[] = "/tmp/cbm_palias_XXXXXX";
|
||||
char *root = mkdtemp(tmpl);
|
||||
ASSERT_NOT_NULL(root);
|
||||
|
||||
char sub[512];
|
||||
snprintf(sub, sizeof(sub), "%s/apps", root);
|
||||
mkdir(sub, 0755);
|
||||
snprintf(sub, sizeof(sub), "%s/apps/manager", root);
|
||||
mkdir(sub, 0755);
|
||||
|
||||
char path[512];
|
||||
snprintf(path, sizeof(path), "%s/tsconfig.json", root);
|
||||
ASSERT_EQ(write_file(path,
|
||||
"{\n \"compilerOptions\": {\n \"paths\": {\n"
|
||||
" \"@root/*\": [\"shared/*\"]\n }\n }\n}\n"),
|
||||
0);
|
||||
snprintf(path, sizeof(path), "%s/apps/manager/tsconfig.json", root);
|
||||
ASSERT_EQ(write_file(path,
|
||||
"{\n // monorepo subpackage\n \"compilerOptions\": {\n"
|
||||
" \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n },\n}\n"),
|
||||
0);
|
||||
|
||||
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
|
||||
ASSERT_NOT_NULL(coll);
|
||||
ASSERT_EQ(coll->count, 2);
|
||||
|
||||
/* sub-package file picks up its own tsconfig. */
|
||||
const cbm_path_alias_map_t *m =
|
||||
cbm_path_alias_find_for_file(coll, "apps/manager/src/feature/x.ts");
|
||||
ASSERT_NOT_NULL(m);
|
||||
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* Target paths in the sub-tsconfig are dir_prefix-relative. */
|
||||
ASSERT_STR_EQ(r, "apps/manager/src/lib/auth");
|
||||
free(r);
|
||||
|
||||
/* Root file falls back to the root tsconfig's aliases. */
|
||||
const cbm_path_alias_map_t *m2 = cbm_path_alias_find_for_file(coll, "scripts/build.ts");
|
||||
ASSERT_NOT_NULL(m2);
|
||||
char *r2 = cbm_path_alias_resolve(m2, "@root/utils");
|
||||
ASSERT_NOT_NULL(r2);
|
||||
ASSERT_STR_EQ(r2, "shared/utils");
|
||||
free(r2);
|
||||
|
||||
cbm_path_alias_collection_free(coll);
|
||||
|
||||
/* Cleanup tmp tree. */
|
||||
snprintf(path, sizeof(path), "%s/apps/manager/tsconfig.json", root);
|
||||
unlink(path);
|
||||
snprintf(path, sizeof(path), "%s/tsconfig.json", root);
|
||||
unlink(path);
|
||||
snprintf(path, sizeof(path), "%s/apps/manager", root);
|
||||
rmdir(path);
|
||||
snprintf(path, sizeof(path), "%s/apps", root);
|
||||
rmdir(path);
|
||||
rmdir(root);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Loader returns NULL when no configs found ─────────────────── */
|
||||
|
||||
TEST(path_alias_loader_no_configs) {
|
||||
char tmpl[] = "/tmp/cbm_palias_empty_XXXXXX";
|
||||
char *root = mkdtemp(tmpl);
|
||||
ASSERT_NOT_NULL(root);
|
||||
|
||||
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
|
||||
ASSERT_NULL(coll);
|
||||
|
||||
rmdir(root);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void suite_path_alias(void);
|
||||
void suite_path_alias(void) {
|
||||
RUN_TEST(path_alias_at_wildcard);
|
||||
RUN_TEST(path_alias_at_nested);
|
||||
RUN_TEST(path_alias_specificity_longest_first);
|
||||
RUN_TEST(path_alias_exact_match);
|
||||
RUN_TEST(path_alias_strips_ext);
|
||||
RUN_TEST(path_alias_baseurl_fallback);
|
||||
RUN_TEST(path_alias_null_safety);
|
||||
RUN_TEST(path_alias_find_for_file_nearest_ancestor);
|
||||
RUN_TEST(path_alias_loader_monorepo);
|
||||
RUN_TEST(path_alias_loader_no_configs);
|
||||
}
|
||||
+198
-5
@@ -15,6 +15,7 @@
|
||||
#include <string.h>
|
||||
#include <stdatomic.h>
|
||||
#include "foundation/compat_thread.h"
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include "graph_buffer/graph_buffer.h"
|
||||
@@ -528,7 +529,7 @@ TEST(githistory_compute_coupling) {
|
||||
char *files_4[] = {"d.go", "e.go"};
|
||||
|
||||
cbm_commit_files_t commits[] = {
|
||||
{files_0, 3}, {files_1, 2}, {files_2, 2}, {files_3, 2}, {files_4, 2},
|
||||
{files_0, 3, 0}, {files_1, 2, 0}, {files_2, 2, 0}, {files_3, 2, 0}, {files_4, 2, 0},
|
||||
};
|
||||
|
||||
cbm_change_coupling_t results[100];
|
||||
@@ -555,6 +556,47 @@ TEST(githistory_compute_coupling) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(githistory_coupling_carries_last_co_change) {
|
||||
/* Coupling output must surface the most recent commit timestamp at which
|
||||
* a pair co-changed, so callers can score recency in addition to
|
||||
* frequency. The pair (a.go, b.go) co-changes at three timestamps; the
|
||||
* resulting last_co_change must be the maximum (newest) of those. */
|
||||
char *files_old[] = {"a.go", "b.go"};
|
||||
char *files_mid[] = {"a.go", "b.go"};
|
||||
char *files_new[] = {"a.go", "b.go"};
|
||||
/* Unrelated co-change in the middle to make sure we don't accidentally
|
||||
* pick up that pair's timestamp by index. */
|
||||
char *files_other[] = {"c.go", "d.go"};
|
||||
|
||||
cbm_commit_files_t commits[] = {
|
||||
{files_old, 2, 1700000000LL}, /* oldest a.go/b.go co-change */
|
||||
{files_other, 2, 1750000000LL}, /* unrelated pair */
|
||||
{files_mid, 2, 1720000000LL},
|
||||
{files_new, 2, 1800000000LL}, /* newest a.go/b.go co-change */
|
||||
};
|
||||
|
||||
cbm_change_coupling_t results[16];
|
||||
int n = cbm_compute_change_coupling(commits, 4, results, 16);
|
||||
ASSERT_GTE(n, 1);
|
||||
|
||||
bool found_ab = false;
|
||||
for (int i = 0; i < n; i++) {
|
||||
bool is_ab = (strcmp(results[i].file_a, "a.go") == 0 &&
|
||||
strcmp(results[i].file_b, "b.go") == 0) ||
|
||||
(strcmp(results[i].file_a, "b.go") == 0 &&
|
||||
strcmp(results[i].file_b, "a.go") == 0);
|
||||
if (!is_ab) {
|
||||
continue;
|
||||
}
|
||||
ASSERT_EQ(results[i].co_change_count, 3);
|
||||
/* last_co_change must be the max of the three a.go/b.go timestamps. */
|
||||
ASSERT_EQ(results[i].last_co_change, 1800000000LL);
|
||||
found_ab = true;
|
||||
}
|
||||
ASSERT_TRUE(found_ab);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(githistory_skip_large_commits) {
|
||||
/* A single commit with 25 files → should be skipped (>20) */
|
||||
char *files[25];
|
||||
@@ -564,7 +606,7 @@ TEST(githistory_skip_large_commits) {
|
||||
files[i] = bufs[i];
|
||||
}
|
||||
|
||||
cbm_commit_files_t commits[] = {{files, 25}};
|
||||
cbm_commit_files_t commits[] = {{files, 25, 0}};
|
||||
|
||||
cbm_change_coupling_t results[100];
|
||||
int n = cbm_compute_change_coupling(commits, 1, results, 100);
|
||||
@@ -4137,7 +4179,8 @@ TEST(githistory_compute_change_coupling) {
|
||||
char *files_eee[] = {"d.go", "e.go"};
|
||||
|
||||
cbm_commit_files_t commits[5] = {
|
||||
{files_aaa, 3}, {files_bbb, 2}, {files_ccc, 2}, {files_ddd, 2}, {files_eee, 2},
|
||||
{files_aaa, 3, 0}, {files_bbb, 2, 0}, {files_ccc, 2, 0},
|
||||
{files_ddd, 2, 0}, {files_eee, 2, 0},
|
||||
};
|
||||
|
||||
cbm_change_coupling_t out[100];
|
||||
@@ -4173,7 +4216,7 @@ TEST(githistory_coupling_skips_large_commits) {
|
||||
snprintf(bufs[i], sizeof(bufs[i]), "file%d.go", i);
|
||||
files[i] = bufs[i];
|
||||
}
|
||||
cbm_commit_files_t commits[1] = {{files, 25}};
|
||||
cbm_commit_files_t commits[1] = {{files, 25, 0}};
|
||||
|
||||
cbm_change_coupling_t out[100];
|
||||
int count = cbm_compute_change_coupling(commits, 1, out, 100);
|
||||
@@ -4525,6 +4568,154 @@ TEST(incremental_new_file_added) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(incremental_fast_preserves_mode_skipped_tools_dir) {
|
||||
/* Regression: 2026-04-13. A fast-mode reindex after a full-mode index
|
||||
* was silently destroying every file under FAST_SKIP_DIRS directories
|
||||
* (`tools`, `scripts`, `bin`, `build`, `docs`, ...) by classifying them
|
||||
* as deleted in find_deleted_files even though they still existed on
|
||||
* disk. The Skyline graph lost packages/mcp/src/tools/ (18 files / ~500
|
||||
* nodes) mid-session when a concurrent /develop run obediently called
|
||||
* mode='fast'. This test pins the additive semantics: lesser-mode
|
||||
* reindexes must NOT delete files that are merely outside the current
|
||||
* pass's discovery scope. Fix: find_deleted_files now stat()s each
|
||||
* stored-but-missing file and only purges it if it is truly absent
|
||||
* from disk. */
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_modeskip_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir)) {
|
||||
SKIP("tmpdir");
|
||||
}
|
||||
char dbpath[512];
|
||||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||||
|
||||
char path[512];
|
||||
FILE *f;
|
||||
|
||||
/* main.go — root-level production code (visible in fast and full) */
|
||||
snprintf(path, sizeof(path), "%s/main.go", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "package main\n\nfunc main() {\n}\n");
|
||||
fclose(f);
|
||||
|
||||
/* tools/util.go — production code under a FAST_SKIP_DIRS directory.
|
||||
* Full mode indexes it; fast mode skips it via the discover.c heuristic. */
|
||||
char tools_dir[512];
|
||||
snprintf(tools_dir, sizeof(tools_dir), "%s/tools", tmpdir);
|
||||
cbm_mkdir_p(tools_dir, 0755);
|
||||
snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "package tools\n\nfunc Util() string {\n\treturn \"u\"\n}\n");
|
||||
fclose(f);
|
||||
|
||||
/* Step 1: full-mode index — both files should be present */
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
char *project = strdup(cbm_pipeline_project_name(p));
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
cbm_store_t *s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_node_t *tools_nodes_before = NULL;
|
||||
int tools_count_before = 0;
|
||||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_before,
|
||||
&tools_count_before);
|
||||
ASSERT_GT(tools_count_before, 0); /* full mode must see tools/util.go */
|
||||
cbm_store_free_nodes(tools_nodes_before, tools_count_before);
|
||||
int total_before = cbm_store_count_nodes(s, project);
|
||||
cbm_store_close(s);
|
||||
|
||||
/* Step 2: fast-mode reindex — tools/util.go MUST survive (additive semantics) */
|
||||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_node_t *tools_nodes_after = NULL;
|
||||
int tools_count_after = 0;
|
||||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_after,
|
||||
&tools_count_after);
|
||||
/* The critical assertion: tools/util.go nodes must still be present after
|
||||
* a fast-mode reindex that skipped the tools/ directory. Before the fix,
|
||||
* this was 0. */
|
||||
ASSERT_GT(tools_count_after, 0);
|
||||
ASSERT_EQ(tools_count_after, tools_count_before); /* same nodes, untouched */
|
||||
cbm_store_free_nodes(tools_nodes_after, tools_count_after);
|
||||
|
||||
/* Sanity: total node count should not have collapsed by ~the size of tools/ */
|
||||
int total_after = cbm_store_count_nodes(s, project);
|
||||
ASSERT_GTE(total_after, total_before); /* additive — never less */
|
||||
cbm_store_close(s);
|
||||
|
||||
/* Step 3: mutate main.go and fast reindex — forces dump_and_persist to
|
||||
* run (instead of the noop early-return path that step 2 hit). This is
|
||||
* the real dangerous path: the gbuf gets loaded, mutated for main.go,
|
||||
* dumped back to disk. tools/util.go must survive THAT cycle, not just
|
||||
* the trivial noop path. Audit finding from 2026-04-13. */
|
||||
snprintf(path, sizeof(path), "%s/main.go", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "package main\n\nfunc main() {\n\tprintln(\"changed\")\n}\n");
|
||||
fclose(f);
|
||||
/* Bump mtime explicitly — some filesystems have coarse mtime resolution
|
||||
* and the rewrite could land in the same tick as the original write. */
|
||||
struct stat mst;
|
||||
if (stat(path, &mst) == 0) {
|
||||
struct timespec times[2];
|
||||
times[0].tv_sec = mst.st_atime;
|
||||
times[0].tv_nsec = 0;
|
||||
times[1].tv_sec = mst.st_mtime + 5;
|
||||
times[1].tv_nsec = 0;
|
||||
utimensat(AT_FDCWD, path, times, 0);
|
||||
}
|
||||
|
||||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_node_t *tools_nodes_run3 = NULL;
|
||||
int tools_count_run3 = 0;
|
||||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_run3, &tools_count_run3);
|
||||
/* tools/util.go nodes must STILL be present after a fast reindex that
|
||||
* actually ran the full dump_and_persist cycle (not the noop fast-path). */
|
||||
ASSERT_EQ(tools_count_run3, tools_count_before);
|
||||
cbm_store_free_nodes(tools_nodes_run3, tools_count_run3);
|
||||
cbm_store_close(s);
|
||||
|
||||
/* Step 4: actually delete tools/util.go from disk and full-reindex.
|
||||
* Now it really is gone, so its nodes should be purged. This pins the
|
||||
* other half of the contract: the stat-based check correctly identifies
|
||||
* truly-deleted files as deleted. */
|
||||
snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir);
|
||||
unlink(path);
|
||||
|
||||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_node_t *tools_nodes_deleted = NULL;
|
||||
int tools_count_deleted = 0;
|
||||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_deleted,
|
||||
&tools_count_deleted);
|
||||
ASSERT_EQ(tools_count_deleted, 0); /* truly deleted → purged */
|
||||
cbm_store_free_nodes(tools_nodes_deleted, tools_count_deleted);
|
||||
cbm_store_close(s);
|
||||
|
||||
free(project);
|
||||
th_rmtree(tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(incremental_k8s_manifest_indexed) {
|
||||
/* Full index with a k8s manifest, then add a new manifest via incremental.
|
||||
* Verifies that cbm_pipeline_pass_k8s() runs during incremental re-index. */
|
||||
@@ -5080,7 +5271,7 @@ TEST(coupling_single_file_commit) {
|
||||
/* Commits with single files → no pairs → zero couplings */
|
||||
char *f1[] = {"a.go"};
|
||||
char *f2[] = {"b.go"};
|
||||
cbm_commit_files_t commits[] = {{f1, 1}, {f2, 1}};
|
||||
cbm_commit_files_t commits[] = {{f1, 1, 0}, {f2, 1, 0}};
|
||||
cbm_change_coupling_t results[16];
|
||||
int n = cbm_compute_change_coupling(commits, 2, results, 16);
|
||||
ASSERT_EQ(n, 0);
|
||||
@@ -5200,6 +5391,7 @@ SUITE(pipeline) {
|
||||
/* Git history pass */
|
||||
RUN_TEST(githistory_is_trackable);
|
||||
RUN_TEST(githistory_compute_coupling);
|
||||
RUN_TEST(githistory_coupling_carries_last_co_change);
|
||||
RUN_TEST(githistory_skip_large_commits);
|
||||
RUN_TEST(githistory_limits_to_max);
|
||||
/* Test detection */
|
||||
@@ -5354,6 +5546,7 @@ SUITE(pipeline) {
|
||||
RUN_TEST(incremental_detects_changed_file);
|
||||
RUN_TEST(incremental_detects_deleted_file);
|
||||
RUN_TEST(incremental_new_file_added);
|
||||
RUN_TEST(incremental_fast_preserves_mode_skipped_tools_dir);
|
||||
RUN_TEST(incremental_k8s_manifest_indexed);
|
||||
RUN_TEST(incremental_kustomize_module_indexed);
|
||||
/* Resource management & internal helper tests */
|
||||
|
||||
+1282
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* test_py_lsp_bench.c — parity-tracking benchmark for py_lsp.
|
||||
*
|
||||
* The fixture is a synthetic but representative ~250-line Python file
|
||||
* that exercises every parity feature py_lsp implements:
|
||||
* - imports + dotted submodule walks (os.path)
|
||||
* - dataclass + class-body annotated fields
|
||||
* - inheritance with super() calls (method + __init__)
|
||||
* - Self return type (fluent chains)
|
||||
* - generic containers + comprehension element typing
|
||||
* - dict / list / tuple subscript value types
|
||||
* - narrowing (isinstance / is not None / walrus + None)
|
||||
* - match/case class patterns
|
||||
* - async / await
|
||||
* - classmethod / staticmethod
|
||||
* - @property attribute access
|
||||
* - SQLAlchemy 2.0-style Mapped[T] annotations
|
||||
* - Pydantic-style BaseModel with field annotations
|
||||
* - typing.Annotated / ClassVar / Final / InitVar wrappers
|
||||
* - generator yield with for-loop element typing
|
||||
* - operator dunder dispatch
|
||||
* - typing.cast / assert_type
|
||||
* - common stdlib calls (logging, pathlib, json, functools)
|
||||
*
|
||||
* Asserts: resolution ratio >= 50% under sanitizers (production target
|
||||
* is 95%+ — see docs/BENCHMARK_PYTHON.md).
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
#include "lsp/py_lsp.h"
|
||||
#include <time.h>
|
||||
|
||||
static const char *bench_source =
|
||||
"import os\n"
|
||||
"import os.path\n"
|
||||
"import json\n"
|
||||
"import logging\n"
|
||||
"import functools\n"
|
||||
"from dataclasses import dataclass, field\n"
|
||||
"from pathlib import Path\n"
|
||||
"from collections import defaultdict\n"
|
||||
"from typing import (\n"
|
||||
" Optional, Self, cast, ClassVar, Final, Annotated, Generator,\n"
|
||||
" NamedTuple, Protocol\n"
|
||||
")\n"
|
||||
"\n"
|
||||
"log = logging.getLogger('bench')\n"
|
||||
"\n"
|
||||
"@dataclass\n"
|
||||
"class Config:\n"
|
||||
" name: str\n"
|
||||
" debug: bool\n"
|
||||
" extras: list[str]\n"
|
||||
" LIMIT: ClassVar[int] = 100\n"
|
||||
" def display(self) -> str:\n"
|
||||
" return self.name\n"
|
||||
" def with_debug(self, on: bool) -> Self:\n"
|
||||
" self.debug = on\n"
|
||||
" return self\n"
|
||||
"\n"
|
||||
"class Mapped:\n"
|
||||
" pass\n"
|
||||
"\n"
|
||||
"class User:\n"
|
||||
" id: Annotated[int, 'primary_key']\n"
|
||||
" name: Final[str]\n"
|
||||
" email: str\n"
|
||||
" posts: list[str]\n"
|
||||
" def __init__(self, id: int, name: str, email: str):\n"
|
||||
" self.id = id\n"
|
||||
" self.name = name\n"
|
||||
" self.email = email\n"
|
||||
" self.posts = []\n"
|
||||
" def display_name(self) -> str:\n"
|
||||
" return self.name\n"
|
||||
" @property\n"
|
||||
" def domain(self) -> str:\n"
|
||||
" return self.email.split('@')[1]\n"
|
||||
"\n"
|
||||
"class BaseStore:\n"
|
||||
" def __init__(self, root: Path):\n"
|
||||
" self.root: Path = root\n"
|
||||
" self.metadata: dict[str, str] = {}\n"
|
||||
" def get(self, key: str) -> Optional[str]:\n"
|
||||
" return self.metadata.get(key)\n"
|
||||
" def put(self, key: str, value: str) -> None:\n"
|
||||
" self.metadata[key] = value\n"
|
||||
" def keys(self) -> list[str]:\n"
|
||||
" return list(self.metadata.keys())\n"
|
||||
"\n"
|
||||
"class FileStore(BaseStore):\n"
|
||||
" def __init__(self, root: Path):\n"
|
||||
" super().__init__(root)\n"
|
||||
" self.cache: dict[str, str] = {}\n"
|
||||
" @classmethod\n"
|
||||
" def open(cls, root: Path) -> Self:\n"
|
||||
" return cls(root)\n"
|
||||
" @staticmethod\n"
|
||||
" def is_writable(p: Path) -> bool:\n"
|
||||
" return True\n"
|
||||
" def get(self, key: str) -> Optional[str]:\n"
|
||||
" if key in self.cache:\n"
|
||||
" return self.cache[key]\n"
|
||||
" full = self.root / key\n"
|
||||
" if full.exists():\n"
|
||||
" text = full.read_text()\n"
|
||||
" self.cache[key] = text\n"
|
||||
" return text\n"
|
||||
" return None\n"
|
||||
" def put(self, key: str, value: str) -> None:\n"
|
||||
" super().put(key, value)\n"
|
||||
" self.cache[key] = value\n"
|
||||
"\n"
|
||||
"class Result:\n"
|
||||
" def __init__(self, ok: bool, value: Optional[str]):\n"
|
||||
" self.ok = ok\n"
|
||||
" self.value = value\n"
|
||||
" def display(self) -> str:\n"
|
||||
" return self.value or 'empty'\n"
|
||||
"\n"
|
||||
"class App:\n"
|
||||
" def __init__(self, cfg: Config, store: BaseStore):\n"
|
||||
" self.cfg = cfg\n"
|
||||
" self.store = store\n"
|
||||
" self.users: dict[int, User] = {}\n"
|
||||
" self.counts: dict[str, int] = defaultdict(int)\n"
|
||||
" self.logger = logging.getLogger('app')\n"
|
||||
" def display_config(self) -> str:\n"
|
||||
" return self.cfg.display()\n"
|
||||
" def add_user(self, u: User) -> None:\n"
|
||||
" self.users[u.id] = u\n"
|
||||
" self.logger.info('added %s', u.display_name())\n"
|
||||
" def get_user(self, uid: int) -> Optional[User]:\n"
|
||||
" return self.users.get(uid)\n"
|
||||
" def lookup(self, key: str) -> Result:\n"
|
||||
" result = self.store.get(key)\n"
|
||||
" self.counts[key] += 1\n"
|
||||
" return Result(result is not None, result)\n"
|
||||
" def write(self, key: str, value: str) -> None:\n"
|
||||
" self.store.put(key, value)\n"
|
||||
" self.logger.info('wrote %s', key)\n"
|
||||
" def display_results(self, results: list[Result]) -> list[str]:\n"
|
||||
" return [r.display() for r in results]\n"
|
||||
" def display_users(self) -> list[str]:\n"
|
||||
" return [u.display_name() for u in self.users.values()]\n"
|
||||
" def filter_active(self, keys: list[str]) -> list[str]:\n"
|
||||
" return [k for k in keys if self.lookup(k).ok]\n"
|
||||
" def with_debug(self) -> Self:\n"
|
||||
" self.cfg = self.cfg.with_debug(True)\n"
|
||||
" return self\n"
|
||||
"\n"
|
||||
"@functools.lru_cache(maxsize=128)\n"
|
||||
"def slow_lookup(key: str) -> Optional[str]:\n"
|
||||
" return None\n"
|
||||
"\n"
|
||||
"def parse_response(payload: str) -> dict[str, str]:\n"
|
||||
" return json.loads(payload)\n"
|
||||
"\n"
|
||||
"async def fetch(key: str) -> Optional[str]:\n"
|
||||
" return slow_lookup(key)\n"
|
||||
"\n"
|
||||
"async def fetch_many(keys: list[str]) -> list[Optional[str]]:\n"
|
||||
" out: list[Optional[str]] = []\n"
|
||||
" for k in keys:\n"
|
||||
" v = await fetch(k)\n"
|
||||
" out.append(v)\n"
|
||||
" return out\n"
|
||||
"\n"
|
||||
"def gen_users(n: int) -> Generator[User, None, None]:\n"
|
||||
" for i in range(n):\n"
|
||||
" yield User(i, 'name' + str(i), 'a@b.com')\n"
|
||||
"\n"
|
||||
"def classify(x) -> str:\n"
|
||||
" match x:\n"
|
||||
" case Config():\n"
|
||||
" return x.display()\n"
|
||||
" case User():\n"
|
||||
" return x.display_name()\n"
|
||||
" case Result():\n"
|
||||
" return x.display()\n"
|
||||
" case App():\n"
|
||||
" return x.display_config()\n"
|
||||
" case _:\n"
|
||||
" return 'unknown'\n"
|
||||
"\n"
|
||||
"def maybe_get(s: Optional[BaseStore], key: str) -> Optional[str]:\n"
|
||||
" if s is not None:\n"
|
||||
" return s.get(key)\n"
|
||||
" return None\n"
|
||||
"\n"
|
||||
"def first_writable(stores: list[BaseStore]) -> Optional[BaseStore]:\n"
|
||||
" for s in stores:\n"
|
||||
" if isinstance(s, FileStore):\n"
|
||||
" return s\n"
|
||||
" return None\n"
|
||||
"\n"
|
||||
"def main() -> None:\n"
|
||||
" cfg = Config('app', True, ['x', 'y'])\n"
|
||||
" fs = FileStore.open(Path(os.getcwd()))\n"
|
||||
" app = App(cfg, fs)\n"
|
||||
" app.with_debug().write('a', '1')\n"
|
||||
" res = app.lookup('a')\n"
|
||||
" classify(cfg)\n"
|
||||
" classify(res)\n"
|
||||
" classify(app)\n"
|
||||
" payload = parse_response('{}')\n"
|
||||
" log.info('payload=%s', payload)\n"
|
||||
" for u in gen_users(3):\n"
|
||||
" app.add_user(u)\n"
|
||||
" summary = app.display_users()\n"
|
||||
" log.info('users=%d', len(summary))\n";
|
||||
|
||||
static double elapsed_ms(struct timespec t0, struct timespec t1) {
|
||||
double s = (double)(t1.tv_sec - t0.tv_sec);
|
||||
double ns = (double)(t1.tv_nsec - t0.tv_nsec);
|
||||
return s * 1000.0 + ns / 1000000.0;
|
||||
}
|
||||
|
||||
TEST(pylsp_bench_resolution_ratio) {
|
||||
int slen = (int)strlen(bench_source);
|
||||
|
||||
struct timespec t0;
|
||||
struct timespec t1;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
CBMFileResult *r = cbm_extract_file(bench_source, slen, CBM_LANG_PYTHON,
|
||||
"test", "bench.py", 0, NULL, NULL);
|
||||
clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
ASSERT_NOT_NULL(r);
|
||||
|
||||
double ms = elapsed_ms(t0, t1);
|
||||
int calls = r->calls.count;
|
||||
int resolved = r->resolved_calls.count;
|
||||
int loc = 0;
|
||||
for (const char *p = bench_source; *p; p++) {
|
||||
if (*p == '\n') loc++;
|
||||
}
|
||||
double ratio = calls > 0 ? (double)resolved / (double)calls : 0.0;
|
||||
|
||||
printf(" bench: %d lines, %d calls, %d resolved (%.0f%%), %.2f ms\n",
|
||||
loc, calls, resolved, ratio * 100.0, ms);
|
||||
|
||||
ASSERT_GTE(calls, 1);
|
||||
ASSERT_GTE(resolved, 1);
|
||||
|
||||
/* Floor at 50% under sanitizers; production target is 95% per
|
||||
* docs/BENCHMARK_PYTHON.md. */
|
||||
if (calls >= 30) {
|
||||
ASSERT_GTE(resolved * 2, calls);
|
||||
}
|
||||
|
||||
/* <150 ms time budget for ~200-line fixture under ASan + UBSan. */
|
||||
ASSERT(ms < 150.0);
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(py_lsp_bench) {
|
||||
RUN_TEST(pylsp_bench_resolution_ratio);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* test_py_lsp_scale.c — measure scaling behavior at 100 / 500 / 2000
|
||||
* classes-and-calls. Asserts that doubling the input doesn't more than
|
||||
* 4x the runtime (catches accidental O(n^2) in the resolver).
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
#include "lsp/py_lsp.h"
|
||||
#include <time.h>
|
||||
|
||||
static double elapsed_ms(struct timespec t0, struct timespec t1) {
|
||||
double s = (double)(t1.tv_sec - t0.tv_sec);
|
||||
double ns = (double)(t1.tv_nsec - t0.tv_nsec);
|
||||
return s * 1000.0 + ns / 1000000.0;
|
||||
}
|
||||
|
||||
/* Build N synthetic class/call pairs into an arena-backed buffer. */
|
||||
static char *build_fixture(int n_classes, int *out_len) {
|
||||
/* Per class: ~140 chars (5-line def). Per call: ~50 chars. Overhead
|
||||
* for the class number digits scales with log10(n) but the constant
|
||||
* 256 covers up to 9-digit indices comfortably. */
|
||||
int approx = n_classes * 256 + 1024;
|
||||
char *buf = (char *)malloc((size_t)approx);
|
||||
if (!buf) return NULL;
|
||||
int pos = 0;
|
||||
pos += snprintf(buf + pos, (size_t)(approx - pos),
|
||||
"from typing import Self\n");
|
||||
for (int i = 0; i < n_classes; i++) {
|
||||
int n = snprintf(buf + pos, (size_t)(approx - pos),
|
||||
"class Cls%d:\n"
|
||||
" def method(self) -> int:\n"
|
||||
" return %d\n"
|
||||
" def chain(self) -> Self:\n"
|
||||
" return self\n", i, i);
|
||||
if (n < 0 || pos + n >= approx) break;
|
||||
pos += n;
|
||||
}
|
||||
int n = snprintf(buf + pos, (size_t)(approx - pos), "def use():\n");
|
||||
pos += n;
|
||||
for (int i = 0; i < n_classes; i++) {
|
||||
int m = snprintf(buf + pos, (size_t)(approx - pos),
|
||||
" Cls%d().chain().chain().method()\n", i);
|
||||
if (m < 0 || pos + m >= approx) break;
|
||||
pos += m;
|
||||
}
|
||||
*out_len = pos;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static double measure(int n_classes, int *out_calls, int *out_resolved) {
|
||||
int slen = 0;
|
||||
char *src = build_fixture(n_classes, &slen);
|
||||
if (!src) return -1.0;
|
||||
struct timespec t0, t1;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
CBMFileResult *r = cbm_extract_file(src, slen, CBM_LANG_PYTHON,
|
||||
"test", "scale.py", 0, NULL, NULL);
|
||||
clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
double ms = elapsed_ms(t0, t1);
|
||||
if (out_calls) *out_calls = r ? r->calls.count : 0;
|
||||
if (out_resolved) *out_resolved = r ? r->resolved_calls.count : 0;
|
||||
if (r) cbm_free_result(r);
|
||||
free(src);
|
||||
return ms;
|
||||
}
|
||||
|
||||
TEST(pylsp_scale_linear_growth) {
|
||||
int c100 = 0, r100 = 0;
|
||||
int c500 = 0, r500 = 0;
|
||||
int c2000 = 0, r2000 = 0;
|
||||
double t100 = measure(100, &c100, &r100);
|
||||
double t500 = measure(500, &c500, &r500);
|
||||
double t2000 = measure(2000, &c2000, &r2000);
|
||||
printf(" scale: 100=%.1fms (calls=%d resolved=%d) 500=%.1fms (calls=%d resolved=%d) 2000=%.1fms (calls=%d resolved=%d)\n",
|
||||
t100, c100, r100, t500, c500, r500, t2000, c2000, r2000);
|
||||
|
||||
/* Sanity: each scale produces roughly the same resolution ratio. */
|
||||
double r_pct_100 = c100 ? (double)r100 / c100 : 0.0;
|
||||
double r_pct_2000 = c2000 ? (double)r2000 / c2000 : 0.0;
|
||||
ASSERT(r_pct_100 > 0.5);
|
||||
ASSERT(r_pct_2000 > 0.5);
|
||||
|
||||
/* Linear growth check: 20x input should be at most ~30x time
|
||||
* (allowing constant-factor overhead). 20x with quadratic would be
|
||||
* 400x — easy to detect. */
|
||||
if (t100 > 0.5) { // skip when t100 too small to compare reliably
|
||||
double ratio = t2000 / t100;
|
||||
printf(" scale ratio 2000/100: %.1fx (linear ~20x, quadratic ~400x)\n", ratio);
|
||||
ASSERT(ratio < 100.0); // generous bound; flags clear quadratic
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(py_lsp_scale) {
|
||||
RUN_TEST(pylsp_scale_linear_growth);
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
/*
|
||||
* test_py_lsp_stress.c — Advanced Python pattern probes.
|
||||
*
|
||||
* Each test covers one pattern Pyright resolves. Tests that pass
|
||||
* confirm parity. Tests that fail are honest gaps — they get either
|
||||
* fixed in a follow-up round or documented as deferred per
|
||||
* docs/BENCHMARK_PYTHON.md.
|
||||
*
|
||||
* Suite groups: patterns by feature area (typing helpers,
|
||||
* inheritance, control flow, containers, decorators, advanced
|
||||
* constructs).
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
#include "lsp/py_lsp.h"
|
||||
|
||||
static CBMFileResult *extract_py(const char *source) {
|
||||
return cbm_extract_file(source, (int)strlen(source), CBM_LANG_PYTHON,
|
||||
"test", "main.py", 0, NULL, NULL);
|
||||
}
|
||||
|
||||
static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) {
|
||||
for (int i = 0; i < r->resolved_calls.count; i++) {
|
||||
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
||||
if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn &&
|
||||
strstr(rc->callee_qn, calleeSub))
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
__attribute__((unused))
|
||||
static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) {
|
||||
int idx = find_resolved(r, callerSub, calleeSub);
|
||||
if (idx < 0) {
|
||||
printf(" MISSING resolved call: caller~%s -> callee~%s (have %d)\n",
|
||||
callerSub, calleeSub, r->resolved_calls.count);
|
||||
for (int i = 0; i < r->resolved_calls.count; i++) {
|
||||
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
||||
printf(" %s -> %s [%s %.2f]\n",
|
||||
rc->caller_qn ? rc->caller_qn : "(null)",
|
||||
rc->callee_qn ? rc->callee_qn : "(null)",
|
||||
rc->strategy ? rc->strategy : "(null)",
|
||||
rc->confidence);
|
||||
}
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/* ── Typing helpers ─────────────────────────────────────────── */
|
||||
|
||||
TEST(stress_namedtuple_class_form) {
|
||||
/* class Point(NamedTuple): x: int → Point(1, 2).x */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import NamedTuple\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class Pair(NamedTuple):\n"
|
||||
" a: Foo\n"
|
||||
" b: Foo\n"
|
||||
"def use():\n"
|
||||
" p = Pair(Foo(), Foo())\n"
|
||||
" return p.a.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_typeddict_subscript) {
|
||||
/* class TD(TypedDict): x: Foo. d: TD = ...; d["x"].method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import TypedDict\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class TD(TypedDict):\n"
|
||||
" foo: Foo\n"
|
||||
"def use(d: TD):\n"
|
||||
" return d['foo'].method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_protocol_structural) {
|
||||
/* def use(x: HasMethod): x.method()
|
||||
* where HasMethod is a Protocol */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Protocol\n"
|
||||
"class HasMethod(Protocol):\n"
|
||||
" def method(self) -> int: ...\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x: HasMethod):\n"
|
||||
" return x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* Should resolve x.method against the Protocol */
|
||||
int idx = find_resolved(r, "use", "method");
|
||||
if (idx < 0) printf(" KNOWN GAP: Protocol structural method dispatch\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_abc_abstractmethod) {
|
||||
/* class Base(ABC): @abstractmethod def m(self): ...
|
||||
* class Child(Base): def m(self): return 1
|
||||
* def use(x: Base): x.m() → resolves to Base.m (or Child.m via dispatch?) */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from abc import ABC, abstractmethod\n"
|
||||
"class Base(ABC):\n"
|
||||
" @abstractmethod\n"
|
||||
" def method(self):\n"
|
||||
" ...\n"
|
||||
"class Child(Base):\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x: Base):\n"
|
||||
" return x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Control flow / binding ─────────────────────────────────── */
|
||||
|
||||
TEST(stress_context_manager_with_as) {
|
||||
/* with Foo() as f: f.method() — f bound by __enter__ */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def __enter__(self):\n"
|
||||
" return self\n"
|
||||
" def __exit__(self, *args):\n"
|
||||
" return None\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" with Foo() as f:\n"
|
||||
" return f.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_except_as_binding) {
|
||||
/* except E as e: e.method() — e bound to E */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class FooError(Exception):\n"
|
||||
" def detail(self):\n"
|
||||
" return 'oops'\n"
|
||||
"def use():\n"
|
||||
" try:\n"
|
||||
" return 1\n"
|
||||
" except FooError as e:\n"
|
||||
" return e.detail()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "detail"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_isinstance_else_negative_narrow) {
|
||||
/* if not isinstance(x, Foo): return; x.method()
|
||||
* Narrows x to Foo after the early return via py_block_terminates. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x):\n"
|
||||
" if not isinstance(x, Foo):\n"
|
||||
" return None\n"
|
||||
" return x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Containers / unpacking ─────────────────────────────────── */
|
||||
|
||||
TEST(stress_tuple_unpack_function_return) {
|
||||
/* def f() -> tuple[Foo, Bar]: ...
|
||||
* a, b = f(); a.method_a(); b.method_b() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method_a(self):\n"
|
||||
" return 1\n"
|
||||
"class Bar:\n"
|
||||
" def method_b(self):\n"
|
||||
" return 2\n"
|
||||
"def make() -> tuple[Foo, Bar]:\n"
|
||||
" return Foo(), Bar()\n"
|
||||
"def use():\n"
|
||||
" a, b = make()\n"
|
||||
" a.method_a()\n"
|
||||
" b.method_b()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method_a"), 0);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method_b"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_dict_items_comprehension) {
|
||||
/* d: dict[str, Foo]; [v.method() for k, v in d.items()] */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(d: dict[str, Foo]):\n"
|
||||
" return [v.method() for k, v in d.items()]\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_list_slice_returns_list) {
|
||||
/* lst: list[Foo]; lst[1:3][0].method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(items: list[Foo]):\n"
|
||||
" return items[1:3][0].method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Decorators / first-class functions ──────────────────────── */
|
||||
|
||||
TEST(stress_function_as_dict_value) {
|
||||
/* funcs = {"a": foo, "b": bar}; funcs["a"]() — dict-literal dispatch */
|
||||
CBMFileResult *r = extract_py(
|
||||
"def foo():\n"
|
||||
" return 1\n"
|
||||
"def bar():\n"
|
||||
" return 2\n"
|
||||
"def use():\n"
|
||||
" funcs = {'a': foo, 'b': bar}\n"
|
||||
" funcs['a']()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "foo"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_decorator_factory) {
|
||||
/* @retry(times=3) — decorator factory pattern */
|
||||
CBMFileResult *r = extract_py(
|
||||
"import functools\n"
|
||||
"def retry(times: int):\n"
|
||||
" def deco(fn):\n"
|
||||
" @functools.wraps(fn)\n"
|
||||
" def wrapper(*args, **kwargs):\n"
|
||||
" return fn(*args, **kwargs)\n"
|
||||
" return wrapper\n"
|
||||
" return deco\n"
|
||||
"@retry(times=3)\n"
|
||||
"def helper():\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" return helper()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* helper() should resolve as helper despite the decorator. */
|
||||
ASSERT_GTE(require_resolved(r, "use", "helper"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_property_setter) {
|
||||
/* @prop.setter — assignment to property */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" @property\n"
|
||||
" def value(self) -> int:\n"
|
||||
" return 1\n"
|
||||
" @value.setter\n"
|
||||
" def value(self, v: int) -> None:\n"
|
||||
" pass\n"
|
||||
" def use(self):\n"
|
||||
" self.value = 5\n"
|
||||
" return self.value\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* Setter is rare in resolution but should not break anything. */
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Inheritance / generics ─────────────────────────────────── */
|
||||
|
||||
TEST(stress_self_in_inheritance_chain) {
|
||||
/* class B: def make(self) -> Self: ...
|
||||
* class C(B): def child_method(self) -> int: ...
|
||||
* c = C().make() # Pyright: c is C, not B
|
||||
* c.child_method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Self\n"
|
||||
"class Base:\n"
|
||||
" def make(self) -> Self:\n"
|
||||
" return self\n"
|
||||
"class Child(Base):\n"
|
||||
" def child_method(self):\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" c = Child().make()\n"
|
||||
" return c.child_method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
int idx = find_resolved(r, "use", "child_method");
|
||||
if (idx < 0) printf(" KNOWN GAP: Self resolves to receiver class, not declaring class\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_diamond_inheritance_mro) {
|
||||
/* Diamond: Top -> Left, Right -> Bottom. Bottom().method() resolves to
|
||||
* Top.method via C3 unless Left/Right override. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Top:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class Left(Top):\n"
|
||||
" pass\n"
|
||||
"class Right(Top):\n"
|
||||
" pass\n"
|
||||
"class Bottom(Left, Right):\n"
|
||||
" def use(self):\n"
|
||||
" return self.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_recursive_self_referencing_type) {
|
||||
/* class Node: children: list[Node]
|
||||
* def walk(node: Node): for c in node.children: c.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Node:\n"
|
||||
" def __init__(self):\n"
|
||||
" self.children: list['Node'] = []\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def walk(node: Node):\n"
|
||||
" for c in node.children:\n"
|
||||
" c.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
int idx = find_resolved(r, "walk", "method");
|
||||
if (idx < 0) printf(" KNOWN GAP: forward-ref `Node` inside list[] within own class\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Advanced control flow ─────────────────────────────────── */
|
||||
|
||||
TEST(stress_nested_closure) {
|
||||
/* def outer():
|
||||
* x = Foo()
|
||||
* def inner():
|
||||
* return x.method()
|
||||
* return inner */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def outer():\n"
|
||||
" x = Foo()\n"
|
||||
" def inner():\n"
|
||||
" return x.method()\n"
|
||||
" return inner()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* inner() captures x via closure; x is Foo from outer scope. */
|
||||
int idx = find_resolved(r, "inner", "method");
|
||||
if (idx < 0) printf(" KNOWN GAP: closure scope capture across nested function\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_match_sequence_pattern) {
|
||||
/* match items: case [head, *tail]: head.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(items: list[Foo]):\n"
|
||||
" match items:\n"
|
||||
" case [head, *tail]:\n"
|
||||
" return head.method()\n"
|
||||
" case []:\n"
|
||||
" return None\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_lambda_inference) {
|
||||
/* fn = lambda x: x.method(); fn(Foo()) — call-site driven inference */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" fn = lambda x: x.method()\n"
|
||||
" return fn(Foo())\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "lambda", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_method_chain_long) {
|
||||
/* a.b().c().d().e() — every step must preserve type for the next. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Self\n"
|
||||
"class B:\n"
|
||||
" def step(self) -> Self:\n"
|
||||
" return self\n"
|
||||
" def finish(self):\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" return B().step().step().step().step().finish()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "finish"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_generator_delegation) {
|
||||
/* def outer(): yield from inner() — outer's iterable element is inner's */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Generator\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def inner() -> Generator[Foo, None, None]:\n"
|
||||
" yield Foo()\n"
|
||||
"def outer() -> Generator[Foo, None, None]:\n"
|
||||
" yield from inner()\n"
|
||||
"def use():\n"
|
||||
" for x in outer():\n"
|
||||
" x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_async_gen_for) {
|
||||
/* async for x in async_gen(): x.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import AsyncGenerator\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"async def gen() -> AsyncGenerator[Foo, None]:\n"
|
||||
" yield Foo()\n"
|
||||
"async def use():\n"
|
||||
" async for x in gen():\n"
|
||||
" x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
int idx = find_resolved(r, "use", "method");
|
||||
if (idx < 0) printf(" KNOWN GAP: async-for element typing\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Round 10 — real-world / framework patterns ──────────────── */
|
||||
|
||||
TEST(stress_sqlalchemy_mapped_field) {
|
||||
/* SQLAlchemy 2.0: id: Mapped[int] = mapped_column(...)
|
||||
* Mapped[T] should unwrap to T for member access. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Annotated\n"
|
||||
"class Mapped:\n"
|
||||
" pass\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class User:\n"
|
||||
" id: Mapped[Foo]\n"
|
||||
"def use(u: User):\n"
|
||||
" return u.id.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_pydantic_model_field) {
|
||||
/* class Model(BaseModel): name: Foo. m.name.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class BaseModel:\n"
|
||||
" pass\n"
|
||||
"class User(BaseModel):\n"
|
||||
" name: Foo\n"
|
||||
" age: int\n"
|
||||
"def use(u: User):\n"
|
||||
" return u.name.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_chained_filter_map) {
|
||||
/* filter / map / list compose */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(items: list[Foo]):\n"
|
||||
" filtered = [x for x in items if x.method() > 0]\n"
|
||||
" return [y.method() for y in filtered]\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_nested_function_call) {
|
||||
/* outer(inner()) where outer/inner have annotations */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def make() -> Foo:\n"
|
||||
" return Foo()\n"
|
||||
"def transform(x: Foo) -> Foo:\n"
|
||||
" return x\n"
|
||||
"def use():\n"
|
||||
" return transform(make()).method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_optional_chain_with_walrus) {
|
||||
/* if (result := compute()) is not None: result.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Optional\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def compute() -> Optional[Foo]:\n"
|
||||
" return None\n"
|
||||
"def use():\n"
|
||||
" if (result := compute()) is not None:\n"
|
||||
" return result.method()\n"
|
||||
" return None\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_classmethod_chain_to_method) {
|
||||
/* C.factory().method() where factory is a @classmethod returning Self */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Self\n"
|
||||
"class Builder:\n"
|
||||
" @classmethod\n"
|
||||
" def create(cls) -> Self:\n"
|
||||
" return cls()\n"
|
||||
" def step(self) -> Self:\n"
|
||||
" return self\n"
|
||||
" def finish(self):\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" return Builder.create().step().finish()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "finish"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_multi_assign_same_line) {
|
||||
/* a, b = (Foo(), Bar()) — same as tuple unpack, but explicit tuple */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def m_a(self):\n"
|
||||
" return 1\n"
|
||||
"class Bar:\n"
|
||||
" def m_b(self):\n"
|
||||
" return 2\n"
|
||||
"def use():\n"
|
||||
" a, b = (Foo(), Bar())\n"
|
||||
" a.m_a()\n"
|
||||
" b.m_b()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "m_a"), 0);
|
||||
ASSERT_GTE(require_resolved(r, "use", "m_b"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_dict_get_default) {
|
||||
/* d.get(k, default) — return type still V (or default's type) */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(d: dict[str, Foo], default: Foo):\n"
|
||||
" v = d.get('k', default)\n"
|
||||
" return v.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* d.get(k) returns Optional[V] in our model — single-arg overload.
|
||||
* With a default, runtime returns V always, but our resolver still
|
||||
* gives Optional. method() resolves through UNION fallback. */
|
||||
int idx = find_resolved(r, "use", "method");
|
||||
if (idx < 0) printf(" KNOWN GAP: dict.get with default narrows away None\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_chained_attribute_through_property_setter) {
|
||||
/* @property + @x.setter, then accessing through chain */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Inner:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class Outer:\n"
|
||||
" @property\n"
|
||||
" def inner(self) -> Inner:\n"
|
||||
" return Inner()\n"
|
||||
"def use(o: Outer):\n"
|
||||
" return o.inner.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_async_with_async_for) {
|
||||
/* async with X() as ctx: async for x in ctx: x.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import AsyncIterator\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class Ctx:\n"
|
||||
" async def __aenter__(self):\n"
|
||||
" return self\n"
|
||||
" async def __aexit__(self, *args):\n"
|
||||
" return None\n"
|
||||
" def __aiter__(self) -> AsyncIterator[Foo]:\n"
|
||||
" return self\n"
|
||||
" async def __anext__(self) -> Foo:\n"
|
||||
" return Foo()\n"
|
||||
"async def use():\n"
|
||||
" async with Ctx() as ctx:\n"
|
||||
" async for x in ctx:\n"
|
||||
" x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_recursive_method_self) {
|
||||
/* class Node: def visit(self) -> int: ... self.left.visit() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Optional\n"
|
||||
"class Node:\n"
|
||||
" def __init__(self):\n"
|
||||
" self.left: Optional['Node'] = None\n"
|
||||
" def visit(self) -> int:\n"
|
||||
" if self.left is not None:\n"
|
||||
" return self.left.visit()\n"
|
||||
" return 0\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "visit", "visit"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_callable_return_value) {
|
||||
/* def factory() -> Callable[[], Foo]: ... factory()().method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Callable\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def factory() -> Callable[[], Foo]:\n"
|
||||
" return Foo\n"
|
||||
"def use():\n"
|
||||
" return factory()().method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_typeddict_total_false) {
|
||||
/* class TD(TypedDict, total=False): foo: Foo. d['foo'].method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import TypedDict\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class TD(TypedDict, total=False):\n"
|
||||
" foo: Foo\n"
|
||||
"def use(d: TD):\n"
|
||||
" return d['foo'].method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_nested_match_patterns) {
|
||||
/* match x: case Foo(a, b): bind a, b */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class Bar:\n"
|
||||
" def m2(self):\n"
|
||||
" return 2\n"
|
||||
"def use(x):\n"
|
||||
" match x:\n"
|
||||
" case Foo():\n"
|
||||
" return x.method()\n"
|
||||
" case Bar():\n"
|
||||
" return x.m2()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
ASSERT_GTE(require_resolved(r, "use", "m2"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_protocol_via_named_class) {
|
||||
/* def use(x: SupportsMethod): x.method()
|
||||
* SupportsMethod is a Protocol, x can be any class with method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Protocol\n"
|
||||
"class SupportsMethod(Protocol):\n"
|
||||
" def method(self) -> int: ...\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x: SupportsMethod):\n"
|
||||
" return x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* Should resolve x.method through the Protocol's method declaration. */
|
||||
int idx = find_resolved(r, "use", "method");
|
||||
if (idx < 0) printf(" KNOWN GAP: Protocol method resolution\n");
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_iterator_next_value) {
|
||||
/* def gen(): yield Foo(); next(gen()).method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Iterator\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def gen() -> Iterator[Foo]:\n"
|
||||
" yield Foo()\n"
|
||||
"def use():\n"
|
||||
" return next(gen()).method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_double_star_unpack) {
|
||||
/* def f(**kwargs): kwargs['x'].method() — kwargs is dict[str, Foo] */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def use(**kwargs: Foo):\n"
|
||||
" return kwargs['x'].method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_yield_from_in_use) {
|
||||
/* yield from gen() — body's yields collected by caller's iteration */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Iterator\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"def src() -> Iterator[Foo]:\n"
|
||||
" yield Foo()\n"
|
||||
"def chain() -> Iterator[Foo]:\n"
|
||||
" yield from src()\n"
|
||||
"def use():\n"
|
||||
" for x in chain():\n"
|
||||
" x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_isinstance_or_chain) {
|
||||
/* if isinstance(x, Foo) or isinstance(x, Bar): — narrowed to Foo|Bar */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class Bar:\n"
|
||||
" def method(self):\n"
|
||||
" return 2\n"
|
||||
"def use(x):\n"
|
||||
" if isinstance(x, Foo):\n"
|
||||
" return x.method()\n"
|
||||
" return None\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_module_level_const_class) {
|
||||
/* Top-level: foo = Foo(); use foo.method() in another function. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"foo = Foo()\n"
|
||||
"def use():\n"
|
||||
" return foo.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(stress_dataclass_with_default_factory) {
|
||||
/* @dataclass with field(default_factory=list) */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from dataclasses import dataclass, field\n"
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"@dataclass\n"
|
||||
"class Container:\n"
|
||||
" items: list[Foo] = field(default_factory=list)\n"
|
||||
"def use(c: Container):\n"
|
||||
" return c.items[0].method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(py_lsp_stress) {
|
||||
/* Typing helpers */
|
||||
RUN_TEST(stress_namedtuple_class_form);
|
||||
RUN_TEST(stress_typeddict_subscript);
|
||||
RUN_TEST(stress_protocol_structural);
|
||||
RUN_TEST(stress_abc_abstractmethod);
|
||||
/* Control flow / binding */
|
||||
RUN_TEST(stress_context_manager_with_as);
|
||||
RUN_TEST(stress_except_as_binding);
|
||||
RUN_TEST(stress_isinstance_else_negative_narrow);
|
||||
/* Containers */
|
||||
RUN_TEST(stress_tuple_unpack_function_return);
|
||||
RUN_TEST(stress_dict_items_comprehension);
|
||||
RUN_TEST(stress_list_slice_returns_list);
|
||||
/* Decorators / first-class functions */
|
||||
RUN_TEST(stress_function_as_dict_value);
|
||||
RUN_TEST(stress_decorator_factory);
|
||||
RUN_TEST(stress_property_setter);
|
||||
/* Inheritance / generics */
|
||||
RUN_TEST(stress_self_in_inheritance_chain);
|
||||
RUN_TEST(stress_diamond_inheritance_mro);
|
||||
RUN_TEST(stress_recursive_self_referencing_type);
|
||||
/* Advanced control flow */
|
||||
RUN_TEST(stress_nested_closure);
|
||||
RUN_TEST(stress_match_sequence_pattern);
|
||||
RUN_TEST(stress_lambda_inference);
|
||||
RUN_TEST(stress_method_chain_long);
|
||||
RUN_TEST(stress_generator_delegation);
|
||||
RUN_TEST(stress_async_gen_for);
|
||||
/* Round 10 — real-world / framework patterns */
|
||||
RUN_TEST(stress_sqlalchemy_mapped_field);
|
||||
RUN_TEST(stress_pydantic_model_field);
|
||||
RUN_TEST(stress_chained_filter_map);
|
||||
RUN_TEST(stress_nested_function_call);
|
||||
RUN_TEST(stress_optional_chain_with_walrus);
|
||||
RUN_TEST(stress_classmethod_chain_to_method);
|
||||
RUN_TEST(stress_multi_assign_same_line);
|
||||
RUN_TEST(stress_dict_get_default);
|
||||
RUN_TEST(stress_chained_attribute_through_property_setter);
|
||||
RUN_TEST(stress_async_with_async_for);
|
||||
RUN_TEST(stress_recursive_method_self);
|
||||
RUN_TEST(stress_callable_return_value);
|
||||
RUN_TEST(stress_typeddict_total_false);
|
||||
RUN_TEST(stress_nested_match_patterns);
|
||||
RUN_TEST(stress_protocol_via_named_class);
|
||||
RUN_TEST(stress_iterator_next_value);
|
||||
RUN_TEST(stress_double_star_unpack);
|
||||
RUN_TEST(stress_yield_from_in_use);
|
||||
RUN_TEST(stress_isinstance_or_chain);
|
||||
RUN_TEST(stress_module_level_const_class);
|
||||
RUN_TEST(stress_dataclass_with_default_factory);
|
||||
}
|
||||
@@ -283,6 +283,28 @@ TEST(resolve_import_map) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Bare function call (no dot) routed through import_map. The candidate QN
|
||||
* must be module_qn.callee, not module_qn — otherwise lookups fall through
|
||||
* to name-based resolution and pick a same-named function from a different
|
||||
* file. Regression for the @/lib/auth-style import case. */
|
||||
TEST(resolve_import_map_bare_function) {
|
||||
cbm_registry_t *r = cbm_registry_new();
|
||||
cbm_registry_add(r, "requireAdmin", "proj.lib.authorization.requireAdmin", "Function");
|
||||
/* Same name in another module — without the fix this is what gets picked. */
|
||||
cbm_registry_add(r, "requireAdmin", "proj.lib.users.requireAdmin", "Function");
|
||||
|
||||
const char *keys[] = {"requireAdmin"};
|
||||
const char *vals[] = {"proj.lib.authorization"};
|
||||
|
||||
cbm_resolution_t res =
|
||||
cbm_registry_resolve(r, "requireAdmin", "proj.actions.settings", keys, vals, 1);
|
||||
ASSERT_STR_EQ(res.qualified_name, "proj.lib.authorization.requireAdmin");
|
||||
ASSERT_STR_EQ(res.strategy, "import_map");
|
||||
|
||||
cbm_registry_free(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(resolve_unique_name) {
|
||||
cbm_registry_t *r = cbm_registry_new();
|
||||
cbm_registry_add(r, "UniqueFunc", "proj.deep.path.UniqueFunc", "Function");
|
||||
@@ -610,6 +632,7 @@ SUITE(registry) {
|
||||
/* Resolution */
|
||||
RUN_TEST(resolve_same_module);
|
||||
RUN_TEST(resolve_import_map);
|
||||
RUN_TEST(resolve_import_map_bare_function);
|
||||
RUN_TEST(resolve_unique_name);
|
||||
RUN_TEST(resolve_unresolved);
|
||||
RUN_TEST(resolve_many_nodes);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* test_scope.c — Tests for the chunked linked-frame CBMScope.
|
||||
*
|
||||
* Phase 0 of Python LSP integration: replaces the legacy fixed 64-binding
|
||||
* array with growable per-scope chunks. Tests verify dynamic growth,
|
||||
* binding-shadowing semantics, and the parent-chain lookup contract that
|
||||
* Go and C/C++ LSP implementations rely on.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
#include "lsp/scope.h"
|
||||
#include "lsp/type_rep.h"
|
||||
|
||||
/* Build a NAMED type for a fixture string. Arena-allocated. */
|
||||
static const CBMType *named_t(CBMArena *a, const char *qn) {
|
||||
return cbm_type_named(a, qn);
|
||||
}
|
||||
|
||||
/* ── Basic API ─────────────────────────────────────────────────── */
|
||||
|
||||
TEST(scope_push_returns_distinct_scope) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *root = cbm_scope_push(&a, NULL);
|
||||
CBMScope *child = cbm_scope_push(&a, root);
|
||||
ASSERT_NOT_NULL(root);
|
||||
ASSERT_NOT_NULL(child);
|
||||
ASSERT(child != root);
|
||||
ASSERT(child->parent == root);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_pop_returns_parent) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *root = cbm_scope_push(&a, NULL);
|
||||
CBMScope *child = cbm_scope_push(&a, root);
|
||||
ASSERT(cbm_scope_pop(child) == root);
|
||||
ASSERT(cbm_scope_pop(root) == NULL);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_lookup_unbound_returns_unknown) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *s = cbm_scope_push(&a, NULL);
|
||||
const CBMType *t = cbm_scope_lookup(s, "missing");
|
||||
ASSERT_NOT_NULL(t);
|
||||
ASSERT(cbm_type_is_unknown(t));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Binding semantics ─────────────────────────────────────────── */
|
||||
|
||||
TEST(scope_bind_then_lookup) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *s = cbm_scope_push(&a, NULL);
|
||||
cbm_scope_bind(s, "x", named_t(&a, "int"));
|
||||
const CBMType *t = cbm_scope_lookup(s, "x");
|
||||
ASSERT_NOT_NULL(t);
|
||||
ASSERT(t->kind == CBM_TYPE_NAMED);
|
||||
ASSERT_STR_EQ(t->data.named.qualified_name, "int");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_rebind_overwrites_in_place) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *s = cbm_scope_push(&a, NULL);
|
||||
cbm_scope_bind(s, "x", named_t(&a, "int"));
|
||||
cbm_scope_bind(s, "x", named_t(&a, "string"));
|
||||
const CBMType *t = cbm_scope_lookup(s, "x");
|
||||
ASSERT_STR_EQ(t->data.named.qualified_name, "string");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_lookup_walks_parent_chain) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *root = cbm_scope_push(&a, NULL);
|
||||
CBMScope *child = cbm_scope_push(&a, root);
|
||||
cbm_scope_bind(root, "outer", named_t(&a, "Outer"));
|
||||
cbm_scope_bind(child, "inner", named_t(&a, "Inner"));
|
||||
const CBMType *outer_in_child = cbm_scope_lookup(child, "outer");
|
||||
const CBMType *inner_in_root = cbm_scope_lookup(root, "inner");
|
||||
ASSERT_STR_EQ(outer_in_child->data.named.qualified_name, "Outer");
|
||||
ASSERT(cbm_type_is_unknown(inner_in_root));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_child_shadows_parent) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *root = cbm_scope_push(&a, NULL);
|
||||
CBMScope *child = cbm_scope_push(&a, root);
|
||||
cbm_scope_bind(root, "x", named_t(&a, "ParentInt"));
|
||||
cbm_scope_bind(child, "x", named_t(&a, "ChildStr"));
|
||||
const CBMType *t = cbm_scope_lookup(child, "x");
|
||||
ASSERT_STR_EQ(t->data.named.qualified_name, "ChildStr");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Dynamic growth — the Phase 0 win ───────────────────────────── */
|
||||
|
||||
TEST(scope_dynamic_growth_300_bindings) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *s = cbm_scope_push(&a, NULL);
|
||||
char names[300][16];
|
||||
for (int i = 0; i < 300; i++) {
|
||||
snprintf(names[i], sizeof(names[0]), "v%d", i);
|
||||
char qn[32];
|
||||
snprintf(qn, sizeof(qn), "T%d", i);
|
||||
cbm_scope_bind(s, names[i], named_t(&a, qn));
|
||||
}
|
||||
/* All 300 bindings retrievable — old 64-cap would have dropped 236 */
|
||||
for (int i = 0; i < 300; i++) {
|
||||
const CBMType *t = cbm_scope_lookup(s, names[i]);
|
||||
ASSERT_NOT_NULL(t);
|
||||
ASSERT(t->kind == CBM_TYPE_NAMED);
|
||||
char expected[32];
|
||||
snprintf(expected, sizeof(expected), "T%d", i);
|
||||
ASSERT_STR_EQ(t->data.named.qualified_name, expected);
|
||||
}
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_growth_chunk_boundary) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *s = cbm_scope_push(&a, NULL);
|
||||
char names[CBM_SCOPE_CHUNK_BINDINGS + 1][16];
|
||||
/* Fill exactly one chunk + spillover: forces second chunk allocation */
|
||||
for (int i = 0; i <= CBM_SCOPE_CHUNK_BINDINGS; i++) {
|
||||
snprintf(names[i], sizeof(names[0]), "n%d", i);
|
||||
char qn[16];
|
||||
snprintf(qn, sizeof(qn), "T%d", i);
|
||||
cbm_scope_bind(s, names[i], named_t(&a, qn));
|
||||
}
|
||||
/* First-chunk binding still retrievable */
|
||||
const CBMType *first = cbm_scope_lookup(s, "n0");
|
||||
ASSERT_STR_EQ(first->data.named.qualified_name, "T0");
|
||||
/* Spillover binding (would have fallen off legacy 64-cap if cap < 17) */
|
||||
char last_name[16];
|
||||
snprintf(last_name, sizeof(last_name), "n%d", CBM_SCOPE_CHUNK_BINDINGS);
|
||||
char last_qn[16];
|
||||
snprintf(last_qn, sizeof(last_qn), "T%d", CBM_SCOPE_CHUNK_BINDINGS);
|
||||
const CBMType *last = cbm_scope_lookup(s, last_name);
|
||||
ASSERT_STR_EQ(last->data.named.qualified_name, last_qn);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(scope_rebind_in_old_chunk) {
|
||||
/* After many bindings have spilled into newer chunks, rebinding a name
|
||||
* from the original chunk must overwrite that binding in place rather
|
||||
* than create a duplicate in the head chunk. */
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
CBMScope *s = cbm_scope_push(&a, NULL);
|
||||
cbm_scope_bind(s, "early", named_t(&a, "EarlyV1"));
|
||||
char names[100][16];
|
||||
for (int i = 0; i < 100; i++) {
|
||||
snprintf(names[i], sizeof(names[0]), "fill%d", i);
|
||||
cbm_scope_bind(s, names[i], named_t(&a, "Filler"));
|
||||
}
|
||||
cbm_scope_bind(s, "early", named_t(&a, "EarlyV2"));
|
||||
const CBMType *t = cbm_scope_lookup(s, "early");
|
||||
ASSERT_STR_EQ(t->data.named.qualified_name, "EarlyV2");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite registration ────────────────────────────────────────── */
|
||||
|
||||
SUITE(scope) {
|
||||
RUN_TEST(scope_push_returns_distinct_scope);
|
||||
RUN_TEST(scope_pop_returns_parent);
|
||||
RUN_TEST(scope_lookup_unbound_returns_unknown);
|
||||
RUN_TEST(scope_bind_then_lookup);
|
||||
RUN_TEST(scope_rebind_overwrites_in_place);
|
||||
RUN_TEST(scope_lookup_walks_parent_chain);
|
||||
RUN_TEST(scope_child_shadows_parent);
|
||||
RUN_TEST(scope_dynamic_growth_300_bindings);
|
||||
RUN_TEST(scope_growth_chunk_boundary);
|
||||
RUN_TEST(scope_rebind_in_old_chunk);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* test_stack_overflow.c — Regression tests for GitHub issue #199.
|
||||
*
|
||||
* Verifies that extraction functions do NOT silently drop AST nodes
|
||||
* when files exceed the fixed traversal stack capacity (512 for calls,
|
||||
* 256 for variables, 64 for Elixir, etc.).
|
||||
*
|
||||
* These tests generate source code with more call sites / definitions /
|
||||
* imports than the stack cap, then assert the extraction count matches
|
||||
* the expected total. Before the fix, counts plateau at the cap.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
static CBMFileResult *extract(const char *src, CBMLanguage lang, const char *proj,
|
||||
const char *path) {
|
||||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), lang, proj, path, 0, NULL, NULL);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: JavaScript calls exceeding 512 stack cap
|
||||
*
|
||||
* Generates a JS function with 600 unique function calls.
|
||||
* Before fix: walk_calls() stops at ~512 due to CALLS_STACK_CAP.
|
||||
* After fix: all 600 calls are extracted.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(js_calls_exceed_512) {
|
||||
const int CALL_COUNT = 600;
|
||||
/* Generate calls spread across many small functions to create wide AST.
|
||||
* Each function has ~20 calls, 30 functions = 600 calls total.
|
||||
* The DFS stack must hold sibling function nodes simultaneously. */
|
||||
const int FUNCS = 30;
|
||||
const int CALLS_PER = CALL_COUNT / FUNCS;
|
||||
size_t buf_sz = 256 + (size_t)CALL_COUNT * 48;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
for (int f = 0; f < FUNCS; f++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "function handler_%d() {\n", f);
|
||||
for (int c = 0; c < CALLS_PER; c++) {
|
||||
int idx = f * CALLS_PER + c;
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), " func_%d();\n", idx);
|
||||
}
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "}\n");
|
||||
}
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_JAVASCRIPT, "test", "many_calls.js");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* Count calls that match our generated pattern */
|
||||
int matched = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
if (strncmp(r->calls.items[i].callee_name, "func_", 5) == 0) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
printf(" calls extracted: %d / %d expected\n", matched, CALL_COUNT);
|
||||
ASSERT_EQ(matched, CALL_COUNT);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: Python calls exceeding 512 stack cap
|
||||
*
|
||||
* Same test in Python syntax to verify language-independence.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(python_calls_exceed_512) {
|
||||
const int CALL_COUNT = 600;
|
||||
size_t buf_sz = 256 + (size_t)CALL_COUNT * 32;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "def main():\n");
|
||||
for (int i = 0; i < CALL_COUNT; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), " func_%d()\n", i);
|
||||
}
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "test", "many_calls.py");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
int matched = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
if (strncmp(r->calls.items[i].callee_name, "func_", 5) == 0) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
printf(" calls extracted: %d / %d expected\n", matched, CALL_COUNT);
|
||||
ASSERT_EQ(matched, CALL_COUNT);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: Go calls at exactly 1024 (well past 512 cap)
|
||||
*
|
||||
* Larger count to ensure the fix handles 2x overflow gracefully.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(go_calls_exceed_1024) {
|
||||
const int CALL_COUNT = 1024;
|
||||
size_t buf_sz = 256 + (size_t)CALL_COUNT * 40;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "package main\n\nfunc main() {\n");
|
||||
for (int i = 0; i < CALL_COUNT; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "\tfunc_%d()\n", i);
|
||||
}
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "}\n");
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_GO, "test", "many_calls.go");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
int matched = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
if (strncmp(r->calls.items[i].callee_name, "func_", 5) == 0) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
printf(" calls extracted: %d / %d expected\n", matched, CALL_COUNT);
|
||||
ASSERT_EQ(matched, CALL_COUNT);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: Express-style route file (original reporter's scenario)
|
||||
*
|
||||
* ~150 route definitions — the actual use case from issue #199.
|
||||
* Each route has a handler call, so both defs and calls matter.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(express_routes_exceed_512) {
|
||||
const int ROUTE_COUNT = 150;
|
||||
/* Each route: app.get('/route_NNN', handler_NNN); */
|
||||
size_t buf_sz = 512 + (size_t)ROUTE_COUNT * 80;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "const express = require('express');\n");
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "const app = express();\n\n");
|
||||
for (int i = 0; i < ROUTE_COUNT; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "app.get('/route_%d', handler_%d);\n", i, i);
|
||||
}
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "\napp.listen(3000);\n");
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_JAVASCRIPT, "test", "routes.js");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* Each app.get() is a call — count calls containing "get" */
|
||||
int get_calls = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
if (strstr(r->calls.items[i].callee_name, "get") != NULL) {
|
||||
get_calls++;
|
||||
}
|
||||
}
|
||||
|
||||
printf(" route calls extracted: %d / %d expected\n", get_calls, ROUTE_COUNT);
|
||||
ASSERT_EQ(get_calls, ROUTE_COUNT);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: ES imports exceeding 512 (walk_es_imports uses same cap)
|
||||
*
|
||||
* Generate a TS file with 600 import statements.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(ts_imports_exceed_512) {
|
||||
const int IMPORT_COUNT = 600;
|
||||
size_t buf_sz = 256 + (size_t)IMPORT_COUNT * 64;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
for (int i = 0; i < IMPORT_COUNT; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "import { mod_%d } from './module_%d';\n", i, i);
|
||||
}
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "console.log('done');\n");
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "many_imports.ts");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
printf(" imports extracted: %d / %d expected\n", r->imports.count, IMPORT_COUNT);
|
||||
ASSERT_GTE(r->imports.count, IMPORT_COUNT);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: Deeply nested calls (tests stack depth, not just breadth)
|
||||
*
|
||||
* Generates nested function calls: a(b(c(d(e(...)))))
|
||||
* A deep call chain can overflow the stack even with fewer total nodes.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(js_deeply_nested_calls) {
|
||||
const int DEPTH = 200;
|
||||
/* Build: outermost( level_0( level_1( ... level_199() ... ))) */
|
||||
size_t buf_sz = 256 + (size_t)DEPTH * 32;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "function main() {\n ");
|
||||
for (int i = 0; i < DEPTH; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "level_%d(", i);
|
||||
}
|
||||
/* Close all parens */
|
||||
for (int i = 0; i < DEPTH; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), ")");
|
||||
}
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), ";\n}\n");
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_JAVASCRIPT, "test", "nested_calls.js");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
int matched = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
if (strncmp(r->calls.items[i].callee_name, "level_", 6) == 0) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
printf(" nested calls extracted: %d / %d expected\n", matched, DEPTH);
|
||||
ASSERT_EQ(matched, DEPTH);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Test: YAML variables exceeding 256 (walk_variables_iter cap)
|
||||
*
|
||||
* Generate a YAML file with 300 top-level keys.
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(yaml_vars_exceed_256) {
|
||||
const int VAR_COUNT = 300;
|
||||
size_t buf_sz = (size_t)VAR_COUNT * 32;
|
||||
char *src = malloc(buf_sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
char *p = src;
|
||||
for (int i = 0; i < VAR_COUNT; i++) {
|
||||
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "key_%d: value_%d\n", i, i);
|
||||
}
|
||||
|
||||
CBMFileResult *r = extract(src, CBM_LANG_YAML, "test", "many_keys.yaml");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
int var_count = 0;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (strcmp(r->defs.items[i].label, "Variable") == 0) {
|
||||
var_count++;
|
||||
}
|
||||
}
|
||||
|
||||
printf(" YAML vars extracted: %d / %d expected\n", var_count, VAR_COUNT);
|
||||
ASSERT_EQ(var_count, VAR_COUNT);
|
||||
|
||||
cbm_free_result(r);
|
||||
free(src);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* Suite registration
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
SUITE(stack_overflow) {
|
||||
cbm_init();
|
||||
|
||||
RUN_TEST(js_calls_exceed_512);
|
||||
RUN_TEST(python_calls_exceed_512);
|
||||
RUN_TEST(go_calls_exceed_1024);
|
||||
RUN_TEST(express_routes_exceed_512);
|
||||
RUN_TEST(ts_imports_exceed_512);
|
||||
RUN_TEST(js_deeply_nested_calls);
|
||||
RUN_TEST(yaml_vars_exceed_256);
|
||||
|
||||
cbm_shutdown();
|
||||
}
|
||||
@@ -461,6 +461,48 @@ TEST(store_file_hash_crud) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(store_file_hash_upsert_rejects_null_required_fields) {
|
||||
/* Pins the API contract that `cbm_store_upsert_file_hash` returns
|
||||
* CBM_STORE_ERR (not silent OK) when a NOT NULL column would receive
|
||||
* SQL NULL. This is the failure mode that
|
||||
* `pipeline_incremental.c:persist_hashes` checks for and logs as
|
||||
* `incremental.persist_hash_failed`. If this contract ever changes
|
||||
* (e.g. the schema relaxes NOT NULL on rel_path or sha256), the
|
||||
* downstream warning becomes silent and the orphaned-node bug class
|
||||
* can re-emerge. Track that change here, not just in the consumer. */
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_store_upsert_project(s, "test", "/tmp/test");
|
||||
|
||||
/* Sanity: a fully-valid upsert returns OK. */
|
||||
int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512);
|
||||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||||
|
||||
/* NULL sha256 violates NOT NULL on file_hashes.sha256 → must return ERR. */
|
||||
rc = cbm_store_upsert_file_hash(s, "test", "other.go", NULL, 2000000, 1024);
|
||||
ASSERT_EQ(rc, CBM_STORE_ERR);
|
||||
|
||||
/* NULL rel_path violates NOT NULL on file_hashes.rel_path → must return ERR. */
|
||||
rc = cbm_store_upsert_file_hash(s, "test", NULL, "deadbeef", 3000000, 2048);
|
||||
ASSERT_EQ(rc, CBM_STORE_ERR);
|
||||
|
||||
/* NULL project violates NOT NULL on file_hashes.project → must return ERR. */
|
||||
rc = cbm_store_upsert_file_hash(s, NULL, "third.go", "cafebabe", 4000000, 4096);
|
||||
ASSERT_EQ(rc, CBM_STORE_ERR);
|
||||
|
||||
/* The valid row from earlier must still be present — partial-failure
|
||||
* policy: a single bad upsert does not corrupt or remove other rows. */
|
||||
cbm_file_hash_t *hashes = NULL;
|
||||
int count = 0;
|
||||
cbm_store_get_file_hashes(s, "test", &hashes, &count);
|
||||
ASSERT_EQ(count, 1);
|
||||
ASSERT_STR_EQ(hashes[0].rel_path, "main.go");
|
||||
cbm_store_free_file_hashes(hashes, count);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Properties JSON round-trip ─────────────────────────────────── */
|
||||
|
||||
TEST(store_node_properties_json) {
|
||||
@@ -1523,6 +1565,7 @@ SUITE(store_nodes) {
|
||||
RUN_TEST(store_node_batch_empty);
|
||||
RUN_TEST(store_cascade_delete);
|
||||
RUN_TEST(store_file_hash_crud);
|
||||
RUN_TEST(store_file_hash_upsert_rejects_null_required_fields);
|
||||
RUN_TEST(store_node_properties_json);
|
||||
RUN_TEST(store_node_null_properties);
|
||||
RUN_TEST(store_find_by_file_overlap);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* test_type_rep.c — Phase 1 type-rep extensions for Python LSP.
|
||||
*
|
||||
* Covers the five new kinds (UNION, LITERAL, PROTOCOL, MODULE, CALLABLE),
|
||||
* structural equality, union normalization (flatten + dedup + collapse),
|
||||
* Optional[T] sugar, and protocol structural matching.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
#include "lsp/type_rep.h"
|
||||
|
||||
/* ── UNION: flatten, dedup, collapse ──────────────────────────── */
|
||||
|
||||
TEST(typerep_union_two_distinct) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i = cbm_type_builtin(&a, "int");
|
||||
const CBMType *s = cbm_type_builtin(&a, "str");
|
||||
const CBMType *m[2] = {i, s};
|
||||
const CBMType *u = cbm_type_union(&a, m, 2);
|
||||
ASSERT(cbm_type_is_union(u));
|
||||
ASSERT_EQ(u->data.union_type.count, 2);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_union_dedupes_duplicates) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i1 = cbm_type_builtin(&a, "int");
|
||||
const CBMType *i2 = cbm_type_builtin(&a, "int");
|
||||
const CBMType *m[2] = {i1, i2};
|
||||
const CBMType *u = cbm_type_union(&a, m, 2);
|
||||
/* dedup collapses to a single int type, not a union */
|
||||
ASSERT(!cbm_type_is_union(u));
|
||||
ASSERT_EQ(u->kind, CBM_TYPE_BUILTIN);
|
||||
ASSERT_STR_EQ(u->data.builtin.name, "int");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_union_flattens_nested) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i = cbm_type_builtin(&a, "int");
|
||||
const CBMType *s = cbm_type_builtin(&a, "str");
|
||||
const CBMType *b = cbm_type_builtin(&a, "bytes");
|
||||
const CBMType *inner_pair[2] = {i, s};
|
||||
const CBMType *inner = cbm_type_union(&a, inner_pair, 2);
|
||||
const CBMType *outer_pair[2] = {inner, b};
|
||||
const CBMType *u = cbm_type_union(&a, outer_pair, 2);
|
||||
/* flattening yields 3 distinct members */
|
||||
ASSERT(cbm_type_is_union(u));
|
||||
ASSERT_EQ(u->data.union_type.count, 3);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_union_single_member_collapses) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i = cbm_type_builtin(&a, "int");
|
||||
const CBMType *m[1] = {i};
|
||||
const CBMType *u = cbm_type_union(&a, m, 1);
|
||||
ASSERT(!cbm_type_is_union(u));
|
||||
ASSERT_EQ(u->kind, CBM_TYPE_BUILTIN);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_union_empty_is_unknown) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *u = cbm_type_union(&a, NULL, 0);
|
||||
ASSERT(cbm_type_is_unknown(u));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_optional_is_union_with_none) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i = cbm_type_builtin(&a, "int");
|
||||
const CBMType *opt = cbm_type_optional(&a, i);
|
||||
ASSERT(cbm_type_is_union(opt));
|
||||
ASSERT_EQ(opt->data.union_type.count, 2);
|
||||
/* membership: int + None */
|
||||
bool has_int = false, has_none = false;
|
||||
for (int k = 0; k < opt->data.union_type.count; k++) {
|
||||
const CBMType *m = opt->data.union_type.members[k];
|
||||
if (m->kind == CBM_TYPE_BUILTIN && strcmp(m->data.builtin.name, "int") == 0) has_int = true;
|
||||
if (m->kind == CBM_TYPE_BUILTIN && strcmp(m->data.builtin.name, "None") == 0) has_none = true;
|
||||
}
|
||||
ASSERT(has_int);
|
||||
ASSERT(has_none);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── LITERAL ───────────────────────────────────────────────────── */
|
||||
|
||||
TEST(typerep_literal_int_3) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *base = cbm_type_builtin(&a, "int");
|
||||
const CBMType *lit = cbm_type_literal(&a, base, "3");
|
||||
ASSERT_EQ(lit->kind, CBM_TYPE_LITERAL);
|
||||
ASSERT(cbm_type_equal(lit->data.literal.base, base));
|
||||
ASSERT_STR_EQ(lit->data.literal.literal_text, "3");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_literal_equality_distinguishes_text) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *base = cbm_type_builtin(&a, "str");
|
||||
const CBMType *foo = cbm_type_literal(&a, base, "\"foo\"");
|
||||
const CBMType *bar = cbm_type_literal(&a, base, "\"bar\"");
|
||||
const CBMType *foo2 = cbm_type_literal(&a, base, "\"foo\"");
|
||||
ASSERT(!cbm_type_equal(foo, bar));
|
||||
ASSERT(cbm_type_equal(foo, foo2));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── PROTOCOL ──────────────────────────────────────────────────── */
|
||||
|
||||
TEST(typerep_protocol_method_set) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const char *methods[] = {"read", "close", NULL};
|
||||
const CBMType *proto = cbm_type_protocol(&a, "typing.IO", methods, NULL);
|
||||
ASSERT(cbm_type_is_protocol(proto));
|
||||
ASSERT_STR_EQ(proto->data.protocol.qualified_name, "typing.IO");
|
||||
ASSERT_NOT_NULL(proto->data.protocol.method_names);
|
||||
ASSERT_STR_EQ(proto->data.protocol.method_names[0], "read");
|
||||
ASSERT_STR_EQ(proto->data.protocol.method_names[1], "close");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_protocol_satisfied_by_protocol_with_superset) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const char *needed[] = {"read", "close", NULL};
|
||||
const char *have[] = {"read", "write", "close", "flush", NULL};
|
||||
const CBMType *proto = cbm_type_protocol(&a, "P1", needed, NULL);
|
||||
const CBMType *cand = cbm_type_protocol(&a, "P2", have, NULL);
|
||||
ASSERT(cbm_type_protocol_satisfied_by(proto, cand));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_protocol_unsatisfied_when_method_missing) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const char *needed[] = {"read", "close", NULL};
|
||||
const char *have[] = {"read", NULL};
|
||||
const CBMType *proto = cbm_type_protocol(&a, "P1", needed, NULL);
|
||||
const CBMType *cand = cbm_type_protocol(&a, "P2", have, NULL);
|
||||
ASSERT(!cbm_type_protocol_satisfied_by(proto, cand));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── MODULE ────────────────────────────────────────────────────── */
|
||||
|
||||
TEST(typerep_module_carries_qn) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *m = cbm_type_module(&a, "os.path");
|
||||
ASSERT(cbm_type_is_module(m));
|
||||
ASSERT_STR_EQ(m->data.module.module_qn, "os.path");
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_module_equality_by_qn) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *a1 = cbm_type_module(&a, "os");
|
||||
const CBMType *a2 = cbm_type_module(&a, "os");
|
||||
const CBMType *b = cbm_type_module(&a, "sys");
|
||||
ASSERT(cbm_type_equal(a1, a2));
|
||||
ASSERT(!cbm_type_equal(a1, b));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── CALLABLE ──────────────────────────────────────────────────── */
|
||||
|
||||
TEST(typerep_callable_with_args_and_return) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i = cbm_type_builtin(&a, "int");
|
||||
const CBMType *s = cbm_type_builtin(&a, "str");
|
||||
const CBMType *params[2] = {i, s};
|
||||
const CBMType *c = cbm_type_callable(&a, params, 2, i);
|
||||
ASSERT_EQ(c->kind, CBM_TYPE_CALLABLE);
|
||||
ASSERT_EQ(c->data.callable.param_count, 2);
|
||||
ASSERT(cbm_type_equal(c->data.callable.return_type, i));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_callable_elliptic_arity_minus_one) {
|
||||
/* Callable[..., R] — variadic in Python type-hint sense. */
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *r = cbm_type_builtin(&a, "int");
|
||||
const CBMType *c = cbm_type_callable(&a, NULL, -1, r);
|
||||
ASSERT_EQ(c->data.callable.param_count, -1);
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(typerep_callable_equality) {
|
||||
CBMArena a;
|
||||
cbm_arena_init(&a);
|
||||
const CBMType *i = cbm_type_builtin(&a, "int");
|
||||
const CBMType *s = cbm_type_builtin(&a, "str");
|
||||
const CBMType *p1[1] = {i};
|
||||
const CBMType *c1 = cbm_type_callable(&a, p1, 1, s);
|
||||
const CBMType *c2 = cbm_type_callable(&a, p1, 1, s);
|
||||
const CBMType *c3 = cbm_type_callable(&a, p1, 1, i); /* different return */
|
||||
ASSERT(cbm_type_equal(c1, c2));
|
||||
ASSERT(!cbm_type_equal(c1, c3));
|
||||
cbm_arena_destroy(&a);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(type_rep) {
|
||||
/* UNION */
|
||||
RUN_TEST(typerep_union_two_distinct);
|
||||
RUN_TEST(typerep_union_dedupes_duplicates);
|
||||
RUN_TEST(typerep_union_flattens_nested);
|
||||
RUN_TEST(typerep_union_single_member_collapses);
|
||||
RUN_TEST(typerep_union_empty_is_unknown);
|
||||
RUN_TEST(typerep_optional_is_union_with_none);
|
||||
/* LITERAL */
|
||||
RUN_TEST(typerep_literal_int_3);
|
||||
RUN_TEST(typerep_literal_equality_distinguishes_text);
|
||||
/* PROTOCOL */
|
||||
RUN_TEST(typerep_protocol_method_set);
|
||||
RUN_TEST(typerep_protocol_satisfied_by_protocol_with_superset);
|
||||
RUN_TEST(typerep_protocol_unsatisfied_when_method_missing);
|
||||
/* MODULE */
|
||||
RUN_TEST(typerep_module_carries_qn);
|
||||
RUN_TEST(typerep_module_equality_by_qn);
|
||||
/* CALLABLE */
|
||||
RUN_TEST(typerep_callable_with_args_and_return);
|
||||
RUN_TEST(typerep_callable_elliptic_arity_minus_one);
|
||||
RUN_TEST(typerep_callable_equality);
|
||||
}
|
||||
Reference in new issue
Block a user