mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into feat/private-repo-token-auth
This commit is contained in:
commit
a4f1030c6d
68 changed files with 3146 additions and 607 deletions
|
|
@ -15,7 +15,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
|||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
|
||||
|
||||
### DAG runner
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
|||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` forces sequential parsing.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
|
||||
|
||||
### How to add a new phase
|
||||
|
|
@ -202,7 +202,7 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
|
|||
```
|
||||
|
||||
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
|
|
@ -251,7 +251,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
|
||||
### Performance notes
|
||||
|
||||
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Cross-phase Tree cache**: the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) lets a scope-resolution per-language hook (`emitScopeCaptures`) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's `ParsedFile` (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
|
||||
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
|
||||
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
|
||||
|
|
@ -314,7 +314,7 @@ Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSe
|
|||
### Chunked parse-and-resolve
|
||||
|
||||
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
|
||||
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
|
||||
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
4. Resolve imports
|
||||
|
|
@ -324,6 +324,8 @@ Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInhe
|
|||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
|
||||
|
||||
### Inheritance and MRO
|
||||
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, be
|
|||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16, auto-sized to the repo). 0 is rejected — there is no sequential mode.
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
|
|
@ -314,7 +314,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
|
|
|
|||
|
|
@ -4,6 +4,71 @@ All notable changes to GitNexus will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.6] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Scope-resolution (RFC #909) migrations completed across the language matrix** — Rust (#1639), JavaScript (#1640), Ruby (#1831), Swift (#937, #1948), Vue SFC (#940, #1950), Dart (#939, #1970), COBOL (#941, #1835, #1842), and Kotlin (#1727, #1746, #1782) now run on the registry-primary path; Java reached 100% scope-resolution parity and joined `MIGRATED_LANGUAGES` (#1805); per-language progress reporting added to the scope-resolution phase (#1813)
|
||||
- **HTTP route & consumer contract extraction (group mode)** — Spring interface routes attributed to controllers (#1743); named/positional Java Spring route args (#1834); Kotlin Spring HTTP route, consumer, and WebClient long-form extraction (#1849, #1855, #1884); Java HTTP consumer contracts (#1872); OpenFeign `@RequestLine` consumer contracts incl. plain interfaces without `@FeignClient` (#1904, #1917); FastAPI `include_router(prefix=...)` cross-file routes (#1877); indirect call patterns via FastAPI `Depends()` and frontend HTTP consumers (#1852); gRPC consumer FQN derivation from Java imports for client-jar consumers (#1889)
|
||||
- **C++ overload & template resolution** — operator-call resolution (#1754), template partial ordering (#1885), user-defined conversion ranking (#1829), nullptr/ellipsis pointer conversion ranks (#1708), SFINAE filter (#1623), expanded `type_traits` constraint registry (#1648), structured resolver-suppression outcomes (#1785), function-type ADL entities (#1822), and a parameter-type class sidecar (#1642)
|
||||
- **Go enhancements** — structural interface implementation inference (#1966) and a `builtInNames` set for the Go language provider (#1886)
|
||||
- **Self-healing worker pool** — automatic worker replacement plus deferred-resolution observability and verbose progress logging (#1741, #1773, #1947)
|
||||
- **`.gitnexusrc` config file and `gitnexus analyze --default-branch`** (#243, #1996)
|
||||
- **CLI / MCP impact ergonomics** — `--uid/--file/--kind` disambiguation flags (#1907, #1914), `limit/offset/summaryOnly` pagination on the impact tool (#1818), and a per-symbol `processes` field on `byDepth` items (#1867)
|
||||
- **`gitnexus analyze --repair-fts`** — enforces FTS verification with hardened repair safeguards (#1720)
|
||||
- **Web viewer** — Tree View and Circles View (#1799), GitLab repository URLs (#1565), `GITNEXUS_BACKEND_URL` env var for Docker deployments (#1286), and web + CLI internationalization (#1748)
|
||||
- **Wiki** — local Claude/Codex providers (#1769), an opencode local provider (#2039), and `gitnexus wiki --lang <lang>` for multilanguage wiki generation (#1613)
|
||||
- **`detect-changes` git-worktree support** (#1654)
|
||||
- **DeepSeek V4 API support** (#1594)
|
||||
- **Devcontainer for the Claude / Codex / Cursor CLIs** (#1875) and antigravity integration setup + hook adapter (#1730)
|
||||
- **Object-literal methods linked to exported bindings** (#1718)
|
||||
- **`eval-server --host`** for a user-configured bind IP (#1667)
|
||||
- **PR reviewer swarm agents** (#1851)
|
||||
- **tree-sitter node-type/field validation gate** — validates against the grammar and removes dead literal handling (#1937)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Parsing-layer coverage gaps closed across the language matrix** (umbrella #1919) — remaining open gaps (#2072) plus Java F35/F38/F41 (#1928, #2045), PHP F53/F54/F55 (#1931, #1989), COBOL F17–F23 (#1925, #1959), Rust F66/F68/F71/F72 (#1934, #1974), Python F57/F58/F61 (#1932, #1964), JS/TS F44/F83/F85/F86/F87 (#1929, #1968), and Ruby F62 (#1933, #1972)
|
||||
- **Fully-qualified nested-type identity for C++ and Ruby** — distinct nodes for union-, anonymous-namespace-, and same-tail-nested types (#1978, #1981, #2004, #2005); cross-namespace same-tail inheritance bases resolved (#1993, #2005); Ruby same-tail nested mixin modules qualified with `IMPLEMENTS` routed by scope (#1991, #2006); shared codec for `__heritage__`/`__property__` markers (#1994, #2007); graph nodes materialized for scoped class/module/impl declarations (#1975, #1977); generic Rust inherent-impl methods owned through the mod-qualified `Impl` node (#1992, #2003)
|
||||
- **C# resolution & memory** — global-namespace `typeBindings` O(files²) OOM eliminated (#1871, #1954) and namespace-siblings OOM with worker-path re-parse removed (#1905); qualified/alias constructor names, `:base`/`:this` initializers, and generic type-arg stripping (#2046); primary-base receiver type normalization (#2036); spurious `IMPORTS` edges from ungated `using` resolution stopped (#1881, #1908)
|
||||
- **C++ dependent-base and member lookup** — resolution across nested/inline namespaces (#1634, #1814), base-specifier qualifier threading (#1815, #1819), call-site types threaded into qualified member lookup (#1632, #1810), variadic pack dependent lookup (#1909), uninitialized multi-declarators (#1965), and typedef-enum / anonymous-struct declarations (#1941)
|
||||
- **Kotlin type resolution** — smart-cast refinement for `when/is` and `if/is` (#1758, #1774), overload target-id by parameter types (#1761, #1777), cross-file iterable return propagation (#1759, #1775), method-chain fixpoint receiver types (#1760, #1776), virtual dispatch via constructor type override (#1762, #1778), interface default-method dispatch via implements-split MRO (#1763, #1779), and default-parameter arity detection (#2034)
|
||||
- **Go declarations** — multi-name declaration capture (#2032), fixed-array parameter binding normalization (#1988), and generic composite-literal constructor inference F33 (#1976)
|
||||
- **Rust / PHP / Vue / Java parsing** — Rust `struct_expression` name pattern split (#2051); PHP import decomposition, namespace-less `.phtml` module scopes, and Blade-template exclusion (#1801, #1790, #1989); Vue JSDoc, dual-script merge, and lang plumbing F89/F90/F92 (#1936, #2050); Java inherited `RequestMapping` prefix deduplication (#2057) and same-module type resolution for duplicate FQNs (#1712)
|
||||
- **TypeScript** — HOC pattern false positives fixed with `export default` HOC support (#1943) and suffix-index reuse in the scope resolver (#1840)
|
||||
- **Inheritance on the worker path** — all languages' inheritance migrated to scope-resolution in worker mode (#1951, #1956); centralized heritage supertype matching (#1921, #1922, #1940); `File->Member` `DEFINES` edges skipped for class members (#1949); phantom `Function` defs for array-method callbacks no longer emitted (#1906)
|
||||
- **MCP** — sibling-clone repo-ID collisions prevented and generated MCP tool names corrected (#2067); orphan processes avoided by handling stdin close/end and the startup race (#2049); duplicate-name repo resolution disambiguated for worktrees (#1753); Windows setup fallback when global `gitnexus` resolves to a non-spawnable shim (#1694)
|
||||
- **Worker pool** — resilient zero-copy ingestion worker pool prevents analyze hangs on TS-root-scale loads (#1693); cache-hit native workers no longer abort (#1751, #1833); worker-pool docs drift corrected and worker-side stack surfaced on crash (#2068, #2070)
|
||||
- **LadybugDB** — FTS loaded in the Windows read pool (#2040) and probed-then-loaded on Windows (#1690, #1692); non-ASCII KuzuDB paths resolved on Windows (#1811, #1817); WAL corruption detected in schema init with recovery surfaced (#1647, #1650); WAL checkpoint-threshold control (#1772); init lock skipped for read-only opens (#1783, #1784); `serve` kept stable when sidecars are missing (#1747)
|
||||
- **Server / API** — `gitnexus serve` startup restored under Express 5 (#1749); `/api/graph`, `/api/search`, `/api/grep` opened read-only (#1686); native read-only enforcement and prepared statements for Cypher query paths (#1655); `eval-server` localhost binding left to the OS (#1722)
|
||||
- **Embeddings** — local ONNX runtime guarded on macOS Intel before the transformers.js import (#1987)
|
||||
- **Web agent** — Nexus AI agent system prompt aligned with registered tools (#1984) and the agent stopped cleanly on user Stop (#1820)
|
||||
- **Group / contracts** — HTTP graph and source contracts unioned (#1709); `httpx` `AsyncClient` alias imports detected (#1687); Node gRPC `loadPackageDefinition` gate no longer matches every member call (#1916); manifest/workspace extraction moved before `closeLbug` (#1802, #1807)
|
||||
- **Hooks / install** — `gitnexus` resolved on `PATH` via a pure-Node, all-OS scan (#1938, #1980); offline-first extension installs (#1161); actionable error and docs for the `pnpm dlx`/`pnpx` native-load crash (#307, #1967); `onnxruntime-common` declared as a runtime dependency (#2074); vendored grammars materialized to fix Windows EPERM (#1728, #1729)
|
||||
- **CLI** — missing LadybugDB native binary detected at startup with actionable guidance (#835, #1837); `--no-stats` applied to the keep-marker stats line (#1706, #1765); skipped large-file paths surfaced by default (#1659, #1661); build.js skipped when running outside the monorepo (#1795, #1816); auto-heap raised to 16 GB with tightened cross-platform OOM guidance for UE5-scale repos (#1652)
|
||||
- **Wiki** — hidden 60s default timeout removed with timeout/retry flag validation and surfaced timeout errors (#1651); budget-aware grouping to prevent context overflow on large repos (#627, #1832)
|
||||
- **`detect-changes`** — `resolveWorktreeCwd` guarded against overriding a separately-indexed worktree (#1691)
|
||||
- **Windows reliability** — `windowsHide:true` passed to every `child_process` spawn-family call (#1794)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Legacy resolution deletion (Ring 4)** — removed the legacy call-resolution DAG + heritage processor (RING4-1, #942, #2023), the legacy resolution-context + tiered-lookup plumbing (RING4-2, #943, #2033), and the shadow-mode parity harness (RING4-3, #944, #2071)
|
||||
- **CONTRIBUTING** — clarified local development setup (#2024)
|
||||
- **Tests / CI** — cli-e2e made read-only and eval-server tests hardened under load (#2000, #1786, #1838, #1688); parity shards consolidated and the cross-platform matrix narrowed (#1798); devcontainer smoke build hardened against Docker Hub flakes (#1969); gitleaks stabilized (#2027)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Linux-kernel-scale analysis overhaul** — worker-pool parse, finalize O(n²), and the scope-resolution memory wall (#1983, #2038)
|
||||
- **Scope-capture linearized across all languages (O(n²)→O(n))** plus Python import-resolution linearization (#1918), the Go-specific re-walk fix (#1848, #1915), and owner-keyed lookup for Step 2 member resolution (#1657)
|
||||
- **C++ ADL candidates indexed once instead of per-site rescans** (#1990)
|
||||
- **Inert local value symbols pruned** during ingestion (#2065)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- `@ladybugdb/core` bump in /gitnexus (#2056)
|
||||
- Routine dependency bumps across /gitnexus, /gitnexus-web, /eval, and GitHub Actions — incl. `hono`, `vitest`, `@vitest/coverage-v8`, `tsx`, `lru-cache`, `express`/`@types/express`, `express-rate-limit`, `qs`, `node-addon-api`, `brace-expansion`, `langchain`, `i18next`, `dompurify`, `lucide-react`, `axios`, `zod`, `@langchain/langgraph`, `@vercel/node`, `langsmith`, `aiohttp`, `idna`, and the `docker/*` / `github/codeql-action` / `release-drafter` / `dependency-review-action` actions (#2056, #2044, #2043, #2042, #2016, #2015, #2013, #2012, #2011, #2010, #2009, #2008, #2018, #2019, #2017, #2020, #1986, #1911, #1864, #1863, #1861, #1860, #1866, #1844, #1845, #1826, #1825, #1824, #1791, #1789, #1768, #1767, #1739, #1740, #1738, #1736, #1735, #1734, #1731, #1713, #1698, #1697, #1696, #1689, #1604, #1552, #1464, #872)
|
||||
- **Security** — `@vercel/node` upgraded in /gitnexus-web with transitive advisories remediated (#1705)
|
||||
|
||||
## [1.6.5] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -70,10 +70,17 @@ this doc, run it under instrumentation:
|
|||
```bash
|
||||
# From the gitnexus/ subdir:
|
||||
cd gitnexus
|
||||
# Single-threaded baseline (sequential fallback):
|
||||
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
|
||||
# The worker pool is the sole parse path, so every run needs the dist worker
|
||||
# (`npm run build`) and a pool size pinned via GITNEXUS_WORKER_POOL_SIZE.
|
||||
|
||||
# Worker-pool path (requires built dist/ — pre-built by `npm run build`):
|
||||
# Single-worker-pool baseline (closest analog to the old single-threaded run —
|
||||
# sequential parsing was removed, so a 1-worker pool is the floor):
|
||||
npm run build && \
|
||||
GITNEXUS_WORKER_POOL_SIZE=1 \
|
||||
GITNEXUS_VERBOSE=1 \
|
||||
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
|
||||
|
||||
# Multi-worker path:
|
||||
npm run build && \
|
||||
GITNEXUS_WORKER_POOL_SIZE=4 \
|
||||
GITNEXUS_PARSE_CHUNK_CONCURRENCY=2 \
|
||||
|
|
@ -97,22 +104,26 @@ node --inspect=0 \
|
|||
## Latest measurement
|
||||
|
||||
> _No measurement data has been collected yet — this file is the
|
||||
> methodology + harness scaffold. The single recorded data point is the
|
||||
> U6 wall-clock smoke baseline below; the worker-pool rows are
|
||||
> placeholders for future bench-pass output._
|
||||
> methodology + harness scaffold. The U6 smoke test confirms the
|
||||
> worker-pool path stays well within its wall-clock budget, but every
|
||||
> throughput/heap cell below is a `_TBD_` placeholder for a future
|
||||
> bench-pass._
|
||||
|
||||
The U6 integration test (`gitnexus/test/integration/parse-impl-large-fixture.test.ts`)
|
||||
was observed completing the synthetic fixture in **~6 seconds** under
|
||||
the sequential path (`skipWorkers: true`) on the development machine,
|
||||
well under the 30 s `Promise.race` wall-clock budget. That number is a
|
||||
smoke baseline only — recorded here for reference, not as a regression
|
||||
target.
|
||||
runs the worker pool — the sole parse path now that sequential parsing
|
||||
has been removed (disabling the pool on a repo with parseable files
|
||||
raises a hard `WorkerPoolDisabledError`). It completes the synthetic
|
||||
fixture well within the 30 s `Promise.race` wall-clock budget on the
|
||||
development machine, but no worker-pool throughput/heap numbers have been
|
||||
captured yet, so the rows below are all `_TBD_`. (An earlier ~6 s figure
|
||||
recorded here was measured on the now-removed sequential path; it has
|
||||
been dropped rather than relabelled as a worker-pool baseline, since the
|
||||
two paths are not comparable.)
|
||||
|
||||
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
|
||||
| ------------------------------------------ | ------- | -------------------- | --------- | ------ | ----------- |
|
||||
| Sequential (`skipWorkers: true`, U6 smoke) | _TBD_ | ~6 s _(observation)_ | _TBD_ | 17 | 0 |
|
||||
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Worker pool, `--workers 1`, concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
|
||||
| ------------------------------------------------------------------------- | ------- | ---------- | --------- | ------ | ----------- |
|
||||
| Worker pool, `--workers 1` (`GITNEXUS_WORKER_POOL_SIZE=1`), concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
|
||||
**Hardware:** _TBD — record OS, CPU, RAM, Node version, gitnexus SHA at
|
||||
the time of the bench-pass that populates the table above._
|
||||
|
|
|
|||
|
|
@ -11,17 +11,18 @@
|
|||
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959."
|
||||
},
|
||||
"c": {
|
||||
"fingerprint": "39f3a8346bb58159e9d79e2db6e1dd34ec6d70028e507ffd39548667dc658aa2",
|
||||
"fingerprint": "12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83."
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "6d6207ae1df3943c5fae28983e0c294e55225456e7cf39af1d46fda21b6787c4",
|
||||
"fingerprint": "fd3d3768cdebbb4767d7cf18b8d2df19d61de969c816d7f4d6b599f947811356",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
|
|
@ -32,8 +33,8 @@
|
|||
"rust": {
|
||||
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
|
||||
|
|
@ -45,18 +46,18 @@
|
|||
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
|
||||
"fingerprint": "180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956: swift-qualified-base fixture + heritage-bearing scale source (class: Base, Serviceable \u2014 extends + protocol conformance); linear (~1.03)."
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "a9e882b537765e8fd0ddfcd33b38b253dd86fc5ddffa6e4bf5a85ed8ee615eaa",
|
||||
"fingerprint": "94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",
|
||||
"_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade."
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
|
||||
|
|
@ -67,8 +68,8 @@
|
|||
"typescript": {
|
||||
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 — fingerprint drift expected."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
|
||||
|
|
@ -77,9 +78,9 @@
|
|||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "a16400622892183581b8f5f8fa01f07842d19b8cf49ed021df52bd17009d749f",
|
||||
"fingerprint": "90aa832978d9744e50058e77a04748390a7e34e36b309f6c1d178eb07280b7ea",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1930 F45: default parameters now emit optional-arity metadata; capture fingerprint changes, scaling remains linear."
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
gitnexus/package-lock.json
generated
6
gitnexus/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
@ -26,8 +26,8 @@
|
|||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"onnxruntime-common": "^1.26.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
|
|
@ -70,8 +70,8 @@
|
|||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"onnxruntime-common": "^1.26.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ const writeFatalToStderr = (label: string, err: unknown): void => {
|
|||
const message = isErr ? err.message : String(err);
|
||||
realStderrWrite(`\n ${label}: ${message}\n`);
|
||||
if (isErr && err.stack) realStderrWrite(`${err.stack}\n`);
|
||||
// Walk and print the `cause` chain. The phase runner wraps the underlying
|
||||
// failure as `new Error("Phase 'X' failed: …", { cause })`, so the original
|
||||
// error (e.g. a WorkerPoolDispatchError carrying the worker-side stack from
|
||||
// #2068) is only reachable via `.cause`. Without this the user sees the
|
||||
// wrapper's main-thread stack and never the real frame. `cause.stack` already
|
||||
// begins with the cause's message, so we print the stack alone (not message +
|
||||
// stack) to avoid repeating it. Depth-bounded so a cyclic `cause` can't loop
|
||||
// (the phase runner wraps one level; the bound leaves headroom for future
|
||||
// nesting); uses realStderrWrite so the redirected console.error's ANSI
|
||||
// clear-line wrapping can't erase it (#1169).
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
let cause: unknown = isErr ? (err as { cause?: unknown }).cause : undefined;
|
||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
|
||||
realStderrWrite(`\n Caused by: ${cause.stack ?? cause.message}\n`);
|
||||
cause = (cause as { cause?: unknown }).cause;
|
||||
}
|
||||
};
|
||||
|
||||
let fatalHandlersInstalled = false;
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { LRUCache } from 'lru-cache';
|
||||
import Parser from 'tree-sitter';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
/**
|
||||
* Minimal structural shape consumers need when reading Trees back
|
||||
* through a phase-dependency boundary. Declared here so phases that
|
||||
* receive ASTCache via `getPhaseOutput<...>` don't hand-roll their
|
||||
* own inline structural types that silently drift when ASTCache's
|
||||
* contract changes.
|
||||
*
|
||||
* Typed as `unknown` at the Tree boundary because consumers on the
|
||||
* other side of the phase-output map don't share tree-sitter's type
|
||||
* graph (e.g. COBOL's standalone processor).
|
||||
*/
|
||||
export interface ASTCacheReader {
|
||||
get(filePath: string): unknown;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
// Define the interface for the Cache
|
||||
export interface ASTCache extends ASTCacheReader {
|
||||
get: (filePath: string) => Parser.Tree | undefined;
|
||||
set: (filePath: string, tree: Parser.Tree) => void;
|
||||
clear: () => void;
|
||||
stats: () => { size: number; maxSize: number };
|
||||
}
|
||||
|
||||
export const createASTCache = (maxSize: number = 50): ASTCache => {
|
||||
const effectiveMax = Math.max(maxSize, 1);
|
||||
// Initialize the cache with a 'dispose' handler
|
||||
// This is the magic: When an item is evicted (dropped), this runs automatically.
|
||||
const cache = new LRUCache<string, Parser.Tree>({
|
||||
max: effectiveMax,
|
||||
dispose: (tree) => {
|
||||
try {
|
||||
// NOTE: web-tree-sitter has tree.delete(); native tree-sitter
|
||||
// trees are GC-managed and .delete is absent (no-op here).
|
||||
//
|
||||
// Single-owner invariant (load-bearing under WASM): a given
|
||||
// Parser.Tree reference must live in AT MOST ONE ASTCache
|
||||
// that disposes. The parse-phase chunk-local cache clears
|
||||
// between chunks; the cross-phase `scopeTreeCache` (also an
|
||||
// ASTCache today) holds the same Tree by reference. Under
|
||||
// native tree-sitter this is benign (dispose is a no-op).
|
||||
// If/when GitNexus adopts web-tree-sitter for sequential
|
||||
// parsing, the cross-phase cache must either (a) skip
|
||||
// writing Trees that are already owned by a disposing cache,
|
||||
// or (b) use tree.copy() per entry. Failing to pick one
|
||||
// will hand freed memory to scope-resolution.
|
||||
(tree as unknown as { delete?: () => void }).delete?.();
|
||||
} catch (e) {
|
||||
logger.warn({ e }, 'Failed to delete tree from WASM memory');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
get: (filePath: string) => {
|
||||
const tree = cache.get(filePath);
|
||||
return tree; // Returns undefined if not found
|
||||
},
|
||||
|
||||
set: (filePath: string, tree: Parser.Tree) => {
|
||||
cache.set(filePath, tree);
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
cache.clear();
|
||||
},
|
||||
|
||||
stats: () => ({
|
||||
size: cache.size,
|
||||
maxSize: effectiveMax,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -9,25 +9,17 @@
|
|||
*
|
||||
* - `processRoutesFromExtracted` — CALLS edges from framework routes
|
||||
* (e.g. Laravel) to their controller methods.
|
||||
* - `processNextjsFetchRoutes` / `extractFetchCallsFromFiles` /
|
||||
* `extractConsumerAccessedKeys` — FETCHES edges from `fetch()` calls to
|
||||
* Next.js Route nodes.
|
||||
* - `processNextjsFetchRoutes` / `extractConsumerAccessedKeys` — FETCHES edges
|
||||
* from `fetch()` calls to Next.js Route nodes.
|
||||
* - `buildExportedTypeMapFromGraph` — exported symbol → return/declared type
|
||||
* map, consumed by the cross-file enrichment pass.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import type { SemanticModel, SymbolTableReader } from './model/index.js';
|
||||
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
||||
import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
|
||||
import { getTreeSitterBufferSize } from './constants.js';
|
||||
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
|
||||
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
|
|
@ -454,79 +446,3 @@ export const processNextjsFetchRoutes = (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract fetch() calls from source files (sequential path).
|
||||
* Workers handle this via tree-sitter captures in parse-worker; this function
|
||||
* provides the same extraction for the sequential fallback path.
|
||||
*/
|
||||
export const extractFetchCallsFromFiles = async (
|
||||
files: { path: string; content: string }[],
|
||||
astCache: ASTCache,
|
||||
): Promise<ExtractedFetchCall[]> => {
|
||||
const parser = await loadParser();
|
||||
const result: ExtractedFetchCall[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language) continue;
|
||||
if (!isLanguageAvailable(language)) continue;
|
||||
|
||||
const provider = getProvider(language);
|
||||
const queryStr = provider.treeSitterQueries;
|
||||
if (!queryStr) continue;
|
||||
|
||||
await loadLanguage(language, file.path);
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parseSourceSafe(parser, parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
astCache.set(file.path, tree);
|
||||
}
|
||||
|
||||
let matches;
|
||||
try {
|
||||
const lang = parser.getLanguage();
|
||||
const query = new Parser.Query(lang, queryStr);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach((c) => (captureMap[c.name] = c.node));
|
||||
|
||||
if (captureMap['route.fetch']) {
|
||||
const urlNode = captureMap['route.url'] ?? captureMap['route.template_url'];
|
||||
if (urlNode) {
|
||||
result.push({
|
||||
filePath: file.path,
|
||||
fetchURL: urlNode.text,
|
||||
lineNumber: captureMap['route.fetch'].startPosition.row,
|
||||
});
|
||||
}
|
||||
} else if (captureMap['http_client'] && captureMap['http_client.url']) {
|
||||
const method = captureMap['http_client.method']?.text;
|
||||
const url = captureMap['http_client.url'].text;
|
||||
const HTTP_CLIENT_ONLY = new Set(['head', 'options', 'request', 'ajax']);
|
||||
if (method && HTTP_CLIENT_ONLY.has(method) && url.startsWith('/')) {
|
||||
result.push({
|
||||
filePath: file.path,
|
||||
fetchURL: url,
|
||||
lineNumber: captureMap['http_client'].startPosition.row,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* Determines whether a symbol (function, class, etc.) is exported/public
|
||||
* in its language. This is a pure function — safe for use in worker threads.
|
||||
*
|
||||
* Shared between parse-worker.ts (worker pool) and parsing-processor.ts (sequential fallback).
|
||||
* Used by the language providers during worker parsing (parse-worker.ts) — the
|
||||
* sole parse path. (Sequential parsing was removed.)
|
||||
*/
|
||||
|
||||
import { findSiblingChild, type SyntaxNode } from './utils/ast-helpers.js';
|
||||
|
|
|
|||
|
|
@ -2,15 +2,59 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { FieldExtractionConfig } from '../generic.js';
|
||||
import type { FieldVisibility } from '../../field-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { hasKeyword } from './helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
|
||||
/**
|
||||
* Dart field extraction config.
|
||||
*
|
||||
* Dart class fields appear as declaration nodes inside class_body.
|
||||
* Dart class fields appear as `declaration` nodes inside `class_body`.
|
||||
* Two shapes carry the field name(s):
|
||||
* - instance / plain fields → `initialized_identifier_list`
|
||||
* (`int z = 0;`, `int a = 1, b = 2;`)
|
||||
* - `static const` / `static final` / `const` fields → `static_final_declaration_list`
|
||||
* (`static const a = 1;`, `static final String b = 'x', c = 'y';`)
|
||||
* Both shapes may declare SEVERAL fields in one declaration, so name extraction
|
||||
* is multi-name (`extractNames`). The structure query (`DART_QUERIES`) emits one
|
||||
* `@definition.property` per name for both shapes; this config enriches each.
|
||||
*
|
||||
* Visibility is convention-based: underscore prefix = private.
|
||||
*/
|
||||
|
||||
/** All field names declared by a `declaration` node, across both Dart shapes. */
|
||||
function extractDartFieldNames(node: SyntaxNode): string[] {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
// instance / plain fields: initialized_identifier_list > initialized_identifier > identifier
|
||||
if (child.type === 'initialized_identifier_list') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const init = child.namedChild(j);
|
||||
if (init?.type === 'initialized_identifier') {
|
||||
const ident = init.firstNamedChild;
|
||||
if (ident?.type === 'identifier') names.push(ident.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// static const / final fields: static_final_declaration_list > static_final_declaration > identifier
|
||||
if (child.type === 'static_final_declaration_list') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const decl = child.namedChild(j);
|
||||
if (decl?.type === 'static_final_declaration') {
|
||||
const ident = decl.firstNamedChild;
|
||||
if (ident?.type === 'identifier') names.push(ident.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export const dartConfig: FieldExtractionConfig = {
|
||||
language: SupportedLanguages.Dart,
|
||||
typeDeclarationNodes: ['class_definition'],
|
||||
|
|
@ -18,31 +62,20 @@ export const dartConfig: FieldExtractionConfig = {
|
|||
bodyNodeTypes: ['class_body'],
|
||||
defaultVisibility: 'public',
|
||||
|
||||
// One AST `declaration` node may declare several fields (`int a, b;`,
|
||||
// `static final String b = 'x', c = 'y';`), so use the multi-name path.
|
||||
extractName(node) {
|
||||
// declaration > initialized_identifier_list > initialized_identifier > identifier
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'initialized_identifier_list') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const init = child.namedChild(j);
|
||||
if (init?.type === 'initialized_identifier') {
|
||||
const ident = init.firstNamedChild;
|
||||
if (ident?.type === 'identifier') return ident.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child?.type === 'initialized_identifier') {
|
||||
const ident = child.firstNamedChild;
|
||||
if (ident?.type === 'identifier') return ident.text;
|
||||
}
|
||||
}
|
||||
// fallback: look for direct identifier
|
||||
const name = node.childForFieldName('name');
|
||||
return name?.text;
|
||||
return extractDartFieldNames(node)[0];
|
||||
},
|
||||
|
||||
extractNames(node) {
|
||||
return extractDartFieldNames(node);
|
||||
},
|
||||
|
||||
extractType(node) {
|
||||
// declaration > type_identifier (first named child usually)
|
||||
// declaration > type_identifier (the type annotation, present for both the
|
||||
// instance-field shape and `static final String b = …`). `static const a = 1;`
|
||||
// has no annotation → undefined (untyped).
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && (child.type === 'type_identifier' || child.type === 'function_type')) {
|
||||
|
|
@ -52,22 +85,16 @@ export const dartConfig: FieldExtractionConfig = {
|
|||
return undefined;
|
||||
},
|
||||
|
||||
extractVisibility(node) {
|
||||
// Dart uses _ prefix for private
|
||||
// Walk to find the identifier name
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'initialized_identifier_list') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const init = child.namedChild(j);
|
||||
if (init?.type === 'initialized_identifier') {
|
||||
const ident = init.firstNamedChild;
|
||||
if (ident?.text?.startsWith('_')) return 'private';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'public';
|
||||
// Per-name: Dart convention is underscore-prefixed = private. A single
|
||||
// declaration can mix visibilities (`static const _p = 1, q = 2;`), so the
|
||||
// decision is keyed on the individual field name.
|
||||
extractVisibilityForName(_node, name): FieldVisibility {
|
||||
return name.startsWith('_') ? 'private' : 'public';
|
||||
},
|
||||
|
||||
extractVisibility(node): FieldVisibility {
|
||||
const first = extractDartFieldNames(node)[0];
|
||||
return first?.startsWith('_') ? 'private' : 'public';
|
||||
},
|
||||
|
||||
isStatic(node) {
|
||||
|
|
@ -75,6 +102,8 @@ export const dartConfig: FieldExtractionConfig = {
|
|||
},
|
||||
|
||||
isReadonly(node) {
|
||||
// `final` / `const` (both `final_builtin`/`const_builtin` nodes whose text
|
||||
// is `final`/`const`) are read-only.
|
||||
return hasKeyword(node, 'final') || hasKeyword(node, 'const');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { FieldExtractionConfig } from '../generic.js';
|
|||
import { findVisibility, hasKeyword, hasModifier, typeFromField } from './helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { FieldVisibility } from '../../field-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java
|
||||
|
|
@ -73,13 +74,49 @@ export const javaConfig: FieldExtractionConfig = {
|
|||
|
||||
const KOTLIN_VIS = new Set<FieldVisibility>(['public', 'private', 'protected', 'internal']);
|
||||
|
||||
/** A property_declaration is a companion-object member when its nearest
|
||||
* class-body ancestor is the body of a companion_object (F52, issue #1919).
|
||||
* Companion members are addressed statically through the enclosing class
|
||||
* (`C.TAG`), so they are marked static. */
|
||||
function isInsideKotlinCompanion(node: SyntaxNode): boolean {
|
||||
for (let cur = node.parent; cur !== null; cur = cur.parent) {
|
||||
if (cur.type === 'class_body') return cur.parent?.type === 'companion_object';
|
||||
if (cur.type === 'companion_object') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const kotlinConfig: FieldExtractionConfig = {
|
||||
language: SupportedLanguages.Kotlin,
|
||||
typeDeclarationNodes: ['class_declaration', 'object_declaration'],
|
||||
// F52: include companion_object so a companion property's innermost
|
||||
// class-container owner (findEnclosingClassNode returns the companion_object)
|
||||
// is recognized as a type declaration and its nested class_body is walked.
|
||||
// The structure query already creates the Property node and owns it on the
|
||||
// ENCLOSING class for anonymous companions / on the named companion Class —
|
||||
// this entry only drives field-metadata enrichment, so it does NOT change
|
||||
// ownership or emit a second node (no double-count).
|
||||
typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'],
|
||||
fieldNodeTypes: ['property_declaration'],
|
||||
bodyNodeTypes: ['class_body'],
|
||||
defaultVisibility: 'public',
|
||||
|
||||
// F52: an anonymous `companion object { ... }` has no name child, so the
|
||||
// generic factory's `childForFieldName('name')` owner lookup is empty and
|
||||
// `extract()` would bail before walking the body. Supply a stable owner
|
||||
// name (the named companion's identifier, else "Companion") so the body IS
|
||||
// walked; the resulting FieldInfo map is keyed by field NAME only, so the
|
||||
// owner name does not affect which Property node gets enriched.
|
||||
extractOwnerName(node) {
|
||||
const typeIdentifierText = node.namedChildren.find((c) => c.type === 'type_identifier')?.text;
|
||||
if (node.type === 'companion_object') {
|
||||
// Anonymous companions have no type_identifier — fall back to "Companion".
|
||||
return typeIdentifierText ?? 'Companion';
|
||||
}
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) return name.text;
|
||||
return typeIdentifierText;
|
||||
},
|
||||
|
||||
extractName(node) {
|
||||
// property_declaration > variable_declaration > simple_identifier
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
|
|
@ -124,9 +161,11 @@ export const kotlinConfig: FieldExtractionConfig = {
|
|||
return findVisibility(node, KOTLIN_VIS, 'public', 'modifiers');
|
||||
},
|
||||
|
||||
isStatic(_node) {
|
||||
// Kotlin doesn't have static; companion object members are handled separately
|
||||
return false;
|
||||
isStatic(node) {
|
||||
// Kotlin has no `static`, but companion-object members are accessed
|
||||
// statically through the enclosing class (`C.TAG`) — mark them static
|
||||
// so the field metadata reflects that (F52).
|
||||
return isInsideKotlinCompanion(node);
|
||||
},
|
||||
|
||||
isReadonly(node) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { FieldExtractionConfig } from '../generic.js';
|
||||
import { hasKeyword, findVisibility } from './helpers.js';
|
||||
import { hasKeyword, hasModifier, findVisibility } from './helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { FieldVisibility } from '../../field-types.js';
|
||||
|
||||
|
|
@ -17,18 +17,33 @@ const SWIFT_VIS = new Set<FieldVisibility>([
|
|||
/**
|
||||
* Swift field extraction config.
|
||||
*
|
||||
* Handles property_declaration inside class_body / protocol_body.
|
||||
* Handles property_declaration inside class_body / protocol_body and
|
||||
* protocol_property_declaration inside protocol_body (F75 — protocol property
|
||||
* requirements like "var title: String { get }").
|
||||
*
|
||||
* tree-sitter-swift uses property_declaration for stored/computed properties.
|
||||
* A protocol property requirement parses to its own node type,
|
||||
* protocol_property_declaration, whose name lives in a "name:" pattern field
|
||||
* (pattern > value_binding_pattern + simple_identifier(bound_identifier)), its
|
||||
* type in a sibling type_annotation, and its "{ get }" / "{ get set }" in a
|
||||
* protocol_property_requirements child. Note: Swift reuses the "name:" field
|
||||
* across many positions (func name, every parameter label, parameter/return
|
||||
* type), so the name is synthesized from the simple_identifier inside the
|
||||
* pattern rather than read blindly off "name:".
|
||||
*/
|
||||
export const swiftConfig: FieldExtractionConfig = {
|
||||
language: SupportedLanguages.Swift,
|
||||
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
|
||||
fieldNodeTypes: ['property_declaration'],
|
||||
fieldNodeTypes: ['property_declaration', 'protocol_property_declaration'],
|
||||
bodyNodeTypes: ['class_body', 'protocol_body'],
|
||||
defaultVisibility: 'internal',
|
||||
|
||||
extractName(node) {
|
||||
// property_declaration > pattern > simple_identifier
|
||||
// property_declaration > pattern > simple_identifier, and
|
||||
// protocol_property_declaration > name: (pattern ... simple_identifier).
|
||||
// For protocol_property_declaration the pattern wraps a leading
|
||||
// value_binding_pattern ("var") plus the simple_identifier — the loop
|
||||
// below skips the binding keyword and returns the identifier.
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'pattern') {
|
||||
|
|
@ -62,7 +77,19 @@ export const swiftConfig: FieldExtractionConfig = {
|
|||
},
|
||||
|
||||
isStatic(node) {
|
||||
return hasKeyword(node, 'static') || hasKeyword(node, 'class');
|
||||
// `static`/`class` (type-level) modifiers live inside a `modifiers`
|
||||
// wrapper for both property_declaration and protocol_property_declaration
|
||||
// (e.g. `static var shared: P { get }`), so check the wrapper too.
|
||||
// `hasKeyword` compares each direct child by `.text` equality: it matches a
|
||||
// single-modifier wrapper (`modifiers.text === 'static'`) but fails for a
|
||||
// multi-modifier wrapper (`private static` → `modifiers.text === 'private static'`),
|
||||
// which `hasModifier` handles by descending into the wrapper's children.
|
||||
return (
|
||||
hasKeyword(node, 'static') ||
|
||||
hasKeyword(node, 'class') ||
|
||||
hasModifier(node, 'modifiers', 'static') ||
|
||||
hasModifier(node, 'modifiers', 'class')
|
||||
);
|
||||
},
|
||||
|
||||
isReadonly(node) {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ import { glob } from 'glob';
|
|||
import { createIgnoreFilter } from '../../config/ignore-service.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Lightweight entry — path + size from stat, no content in memory */
|
||||
export interface ScannedFile {
|
||||
|
|
@ -153,21 +149,3 @@ export const readFileContents = async (
|
|||
|
||||
return contents;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy API — scans and reads everything into memory.
|
||||
* Used by sequential fallback path only.
|
||||
*/
|
||||
export const walkRepository = async (
|
||||
repoPath: string,
|
||||
onProgress?: (current: number, total: number, filePath: string) => void,
|
||||
): Promise<FileEntry[]> => {
|
||||
const scanned = await walkRepositoryPaths(repoPath, onProgress);
|
||||
const contents = await readFileContents(
|
||||
repoPath,
|
||||
scanned.map((f) => f.path),
|
||||
);
|
||||
return scanned
|
||||
.filter((f) => contents.has(f.path))
|
||||
.map((f) => ({ path: f.path, content: contents.get(f.path)! }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,10 +5,20 @@ import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/as
|
|||
* Decompose a `preproc_include` node into a CaptureMatch with structured
|
||||
* import captures. C #include maps to a wildcard import (all symbols
|
||||
* from the header are visible).
|
||||
*
|
||||
* Only literal include paths are emitted as import sources:
|
||||
* #include <stdio.h> → system_lib_string
|
||||
* #include "local.h" → string_literal
|
||||
* A computed include like `#include HEADER_MACRO` carries an `identifier`
|
||||
* path node (the macro name, not a header path). Emitting it as an import
|
||||
* source produces a garbage literal edge, so we skip it entirely — matching
|
||||
* the convention in interpretCImport, which drops imports with no resolvable
|
||||
* source (issue #1919 F5).
|
||||
*/
|
||||
export function splitCInclude(node: SyntaxNode): CaptureMatch | null {
|
||||
// node.type === 'preproc_include'
|
||||
// path field: (string_literal (string_content)) | (system_lib_string)
|
||||
// | (identifier) ← computed macro include, NOT a header path
|
||||
const pathNode = node.childForFieldName?.('path') ?? null;
|
||||
if (pathNode === null) {
|
||||
// Fallback: scan children
|
||||
|
|
@ -24,7 +34,13 @@ export function splitCInclude(node: SyntaxNode): CaptureMatch | null {
|
|||
return buildIncludeCapture(node, pathNode);
|
||||
}
|
||||
|
||||
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
|
||||
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch | null {
|
||||
// Skip computed includes (`#include MACRO`) — the path is an `identifier`,
|
||||
// not a literal header path. Emitting it would create a garbage import.
|
||||
if (pathNode.type !== 'string_literal' && pathNode.type !== 'system_lib_string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
if (pathNode.type === 'string_literal') {
|
||||
// string_literal has children: `"`, string_content, `"`
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/as
|
|||
* Decompose a `preproc_include` node into a CaptureMatch with structured
|
||||
* import captures. C++ #include maps to a wildcard import (all symbols
|
||||
* from the header are visible). Identical to C's splitCInclude.
|
||||
*
|
||||
* Only literal include paths are emitted as import sources:
|
||||
* #include <map> → system_lib_string
|
||||
* #include "User.h" → string_literal
|
||||
* A computed include like `#include HEADER_MACRO` carries an `identifier`
|
||||
* path node (the macro name, not a header path); we skip it so it never
|
||||
* becomes a garbage literal import source (issue #1919 F5).
|
||||
*/
|
||||
export function splitCppInclude(node: SyntaxNode): CaptureMatch | null {
|
||||
const pathNode = node.childForFieldName?.('path') ?? null;
|
||||
|
|
@ -21,7 +28,13 @@ export function splitCppInclude(node: SyntaxNode): CaptureMatch | null {
|
|||
return buildIncludeCapture(node, pathNode);
|
||||
}
|
||||
|
||||
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
|
||||
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch | null {
|
||||
// Skip computed includes (`#include MACRO`) — the path is an `identifier`,
|
||||
// not a literal header path. Emitting it would create a garbage import.
|
||||
if (pathNode.type !== 'string_literal' && pathNode.type !== 'system_lib_string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
if (pathNode.type === 'string_literal') {
|
||||
const content = pathNode.namedChildren.find((c) => c.type === 'string_content');
|
||||
|
|
|
|||
|
|
@ -39,6 +39,46 @@ const DART_SCOPE_QUERY = `
|
|||
(extension_declaration name: (identifier) @declaration.name) @declaration.class
|
||||
(enum_declaration name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
; ── Declarations — type aliases (old-style + new-style function typedefs) ────
|
||||
; Both forms parse as type_alias; the name position differs, and a generic
|
||||
; <T> parameter list intervenes for the generic variants. Per #1919 review CF2,
|
||||
; a generic type_parameters node sits between the name and the next anchor, so
|
||||
; the non-generic adjacency patterns silently drop the generic forms. Four
|
||||
; standalone patterns (NOT one alternation — the tree-sitter 0.21 hazard drops
|
||||
; sibling branches) keep the name capture unambiguous and single-match per form:
|
||||
; non-generic old-style typedef int Cmp(int a, int b);
|
||||
; children: return-type, NAME, formal_parameter_list
|
||||
; generic old-style typedef int Cmp<T>(T a, T b); (CF2)
|
||||
; children: return-type, NAME, type_parameters, formal_parameter_list
|
||||
; non-generic new-style typedef Pred = bool Function(int);
|
||||
; children: NAME, "=", function_type
|
||||
; generic new-style typedef Mapper<T> = T Function(T);
|
||||
; children: NAME, type_parameters, "=", function_type
|
||||
; The alias name is the type_identifier immediately before the param list (old)
|
||||
; or before "=" (new); for the generic forms it is the one immediately before
|
||||
; the intervening type_parameters. Mirrors Kotlin's @declaration.type_alias
|
||||
; rule; the generic scope-extractor maps "type_alias" → TypeAlias.
|
||||
(type_alias
|
||||
(type_identifier) @declaration.name
|
||||
.
|
||||
(formal_parameter_list)) @declaration.type_alias
|
||||
(type_alias
|
||||
(type_identifier) @declaration.name
|
||||
.
|
||||
(type_parameters)
|
||||
.
|
||||
(formal_parameter_list)) @declaration.type_alias
|
||||
(type_alias
|
||||
(type_identifier) @declaration.name
|
||||
.
|
||||
"=") @declaration.type_alias
|
||||
(type_alias
|
||||
(type_identifier) @declaration.name
|
||||
.
|
||||
(type_parameters)
|
||||
.
|
||||
"=") @declaration.type_alias
|
||||
|
||||
; ── Declarations — top-level functions (parent is program, not method) ───────
|
||||
(program
|
||||
(function_signature
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export function emitKotlinScopeCaptures(
|
|||
out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
|
||||
out.push(...synthesizeKotlinLambdaBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinInheritanceReferences(tree.rootNode));
|
||||
out.push(...synthesizeKotlinSecondaryConstructorDeclarations(tree.rootNode));
|
||||
|
||||
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
|
|
@ -87,6 +88,40 @@ export function emitKotlinScopeCaptures(
|
|||
}
|
||||
}
|
||||
|
||||
// Callable references (`::method`, `Type::new`, `obj::m`) — F47 (#1919).
|
||||
// The query captures the referenced member as `@reference.name`, an
|
||||
// optional receiver type as `@reference.receiver`, and the whole node as
|
||||
// `@reference.callable`. Rewrite into a call reference so it participates
|
||||
// in call-graph resolution: a bare `::member` resolves as a free call;
|
||||
// a `Receiver::member` resolves as a member call against the receiver
|
||||
// type. The function/constructor is referenced (not invoked), so no
|
||||
// arity/argument metadata is attached.
|
||||
if (grouped['@reference.callable'] !== undefined) {
|
||||
const nameCap = grouped['@reference.name'];
|
||||
const callableNode = groupedNodes['@reference.callable'];
|
||||
if (nameCap !== undefined && callableNode !== undefined) {
|
||||
const receiverCap = grouped['@reference.receiver'];
|
||||
// The anchor Capture must carry the call-form tag as its `name` —
|
||||
// the scope-extractor reads `Capture.name` (not the map key) to
|
||||
// classify the reference kind, so re-wrap via nodeToCapture rather
|
||||
// than reusing the `@reference.callable`-named Capture (whose head
|
||||
// `callable` resolves to no ReferenceKind and silently drops it).
|
||||
if (receiverCap !== undefined) {
|
||||
out.push({
|
||||
'@reference.call.member': nodeToCapture('@reference.call.member', callableNode),
|
||||
'@reference.name': nameCap,
|
||||
'@reference.receiver': receiverCap,
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
'@reference.call.free': nodeToCapture('@reference.call.free', callableNode),
|
||||
'@reference.name': nameCap,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
grouped['@reference.call.free'] !== undefined &&
|
||||
grouped['@reference.receiver'] !== undefined
|
||||
|
|
@ -253,6 +288,100 @@ function synthesizeKotlinInheritanceReferences(rootNode: SyntaxNode): CaptureMat
|
|||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The enclosing type name for a node nested in a class/object/companion body.
|
||||
* Walks up to the first `class_declaration` / `object_declaration` /
|
||||
* `companion_object` ancestor and returns its `type_identifier` name node.
|
||||
* Used to qualify a secondary-constructor declaration as `<ClassName>.constructor`.
|
||||
*/
|
||||
function kotlinEnclosingTypeNameNode(node: SyntaxNode): SyntaxNode | null {
|
||||
for (let cur: SyntaxNode | null = node.parent; cur !== null; cur = cur.parent) {
|
||||
if (
|
||||
cur.type === 'class_declaration' ||
|
||||
cur.type === 'object_declaration' ||
|
||||
cur.type === 'companion_object'
|
||||
) {
|
||||
const nameNode = cur.namedChildren.find((c) => c.type === 'type_identifier');
|
||||
return nameNode ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a `@declaration.constructor` capture for each Kotlin
|
||||
* `secondary_constructor` (issue #1919 review CF1). The structure phase already
|
||||
* materializes a `Constructor` graph node (`Constructor:file:Class.constructor#<arity>`),
|
||||
* but the registry-primary scope-resolution path had no Constructor *def* in the
|
||||
* scope tree — so a call inside the constructor body resolved its caller anchor
|
||||
* up to the enclosing Class def, mis-attributing the CALLS edge to the class.
|
||||
*
|
||||
* Paired with `(secondary_constructor) @scope.function` in query.ts: that rule
|
||||
* makes the constructor body its own Function scope; this declaration places a
|
||||
* Constructor def in that scope so `pickCallerCallableDef` anchors calls on the
|
||||
* Constructor. The def is keyed to match the structure-phase node id:
|
||||
* - `@declaration.qualified_name` = `<ClassName>.constructor` so the bridge's
|
||||
* qualified key (`<q>:file::Constructor::Class.constructor`) hits the node.
|
||||
* - `@declaration.parameter-types` so two same-name secondary constructors are
|
||||
* disambiguated by the bridge's parameter-types key (`~Int,Int`), matching
|
||||
* the `#<arity>`-suffixed structure node for the overload with the same
|
||||
* parameter shape. (The zero-arg overload carries no parameter types and
|
||||
* resolves via the qualified/simple key to the `#0` node.)
|
||||
*
|
||||
* The anchor spans the whole `secondary_constructor` node — same range as the
|
||||
* `@scope.function` it pairs with — so the def is owned by that Function scope
|
||||
* and the constructor name auto-hoists to the enclosing class scope (exactly the
|
||||
* binding shape a normal method declaration produces).
|
||||
*/
|
||||
function synthesizeKotlinSecondaryConstructorDeclarations(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const ctorNode of descendantsOfType(rootNode, 'secondary_constructor')) {
|
||||
const keyword = ctorNode.namedChildren.find((c) => c.type === 'constructor');
|
||||
// The `constructor` keyword is an anonymous token; fall back to the node
|
||||
// itself for the name capture position when the named-child lookup misses.
|
||||
const nameAnchor = keyword ?? ctorNode;
|
||||
const classNameNode = kotlinEnclosingTypeNameNode(ctorNode);
|
||||
const qualifiedName =
|
||||
classNameNode !== null ? `${classNameNode.text}.constructor` : 'constructor';
|
||||
|
||||
const match: Record<string, Capture> = {
|
||||
'@declaration.constructor': nodeToCapture('@declaration.constructor', ctorNode),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', nameAnchor, 'constructor'),
|
||||
'@declaration.qualified_name': syntheticCapture(
|
||||
'@declaration.qualified_name',
|
||||
ctorNode,
|
||||
qualifiedName,
|
||||
),
|
||||
};
|
||||
|
||||
const arity = computeKotlinArityMetadata(ctorNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
match['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
ctorNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
match['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
ctorNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
match['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
ctorNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
|
||||
out.push(match);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bare simple-name `type_identifier` of a `user_type`. Strips generic
|
||||
* type arguments (`Base<T>` → `Base`) and qualifier tails (`pkg.Base` → `Base`)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ const KOTLIN_SCOPE_QUERY = `
|
|||
(companion_object) @scope.class
|
||||
(function_declaration) @scope.function
|
||||
|
||||
;; Secondary-constructor body scope (issue #1919 review CF1). A
|
||||
;; secondary constructor's "constructor(...) { ... }" body executes statements
|
||||
;; just like a method body, so it must be its OWN Function scope — otherwise a
|
||||
;; call inside the body resolves its caller anchor up to the enclosing Class
|
||||
;; scope (the class's Class def), mis-attributing the CALLS edge to the class
|
||||
;; rather than the Constructor. The matching @declaration.constructor is
|
||||
;; synthesized in captures.ts (synthesizeKotlinSecondaryConstructorDeclarations)
|
||||
;; so this scope owns a Constructor def keyed to the structure-phase node id.
|
||||
(secondary_constructor) @scope.function
|
||||
|
||||
;; Companion-object marker (issue #1756 / U4). Side-channel capture that
|
||||
;; lets populateCompanionMembersOnEnclosingClass distinguish a companion
|
||||
;; Class scope from a regular Class scope without inspecting ownedDefs.
|
||||
|
|
@ -117,6 +127,26 @@ const KOTLIN_SCOPE_QUERY = `
|
|||
(function_value_parameters)
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.return
|
||||
|
||||
;; References — callable references ("::method", "Type::new", "obj::m") — F47.
|
||||
;; A "callable_reference" references a function/constructor as a value (no
|
||||
;; call_suffix), so the registry-primary call path never saw it. Real-parse
|
||||
;; (issue #1919) shows the canonical shape inside a function body is:
|
||||
;; "::topLevelFn" -> (callable_reference :: (simple_identifier)) member only
|
||||
;; "String::length" -> (callable_reference (type_identifier) :: (simple_identifier))
|
||||
;; "obj::method" -> (callable_reference (type_identifier) :: (simple_identifier))
|
||||
;; "Type::new" -> (callable_reference (type_identifier) :: (simple_identifier))
|
||||
;; The receiver (real type OR object) is always a "type_identifier"; the
|
||||
;; referenced member is the LAST "simple_identifier". One rule with an
|
||||
;; optional receiver and an end-anchored member covers all four forms with
|
||||
;; exactly one match per callable_reference (no sibling-branch double-match).
|
||||
;; (NOTE: a qualified "A.B::m" parses as a nested navigation_expression, not a
|
||||
;; callable_reference, and is already captured by the read.member rule below.)
|
||||
;; emitKotlinScopeCaptures rewrites this into a free/member call reference.
|
||||
(callable_reference
|
||||
(type_identifier)? @reference.receiver
|
||||
(simple_identifier) @reference.name
|
||||
.) @reference.callable
|
||||
|
||||
;; References — direct calls / constructor syntax
|
||||
(call_expression
|
||||
(simple_identifier) @reference.name) @reference.call.free
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ export function emitPythonScopeCaptures(
|
|||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
// Skip the parse when the caller (parse phase's ASTCache) already
|
||||
// produced a Tree for this source. Cache miss = re-parse, same as
|
||||
// before. The cachedTree parameter is typed as `unknown` at the
|
||||
// Skip the parse when the caller (the scope-resolution orchestrator's
|
||||
// `treeCache`) already produced a Tree for this source — empty under
|
||||
// worker-pool runs, so cache miss = re-parse. The cachedTree parameter
|
||||
// is typed as `unknown` at the
|
||||
// contract layer (see `LanguageProvider.emitScopeCaptures`); cast
|
||||
// here at the use site.
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
|
||||
|
|
|
|||
|
|
@ -186,9 +186,30 @@ function kotlinParameterHasDefaultValue(param: SyntaxNode): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member name for a Kotlin method node. A `secondary_constructor`
|
||||
* (`constructor(...) { }`) has no name child — its only identity token is
|
||||
* the anonymous `constructor` keyword — so it is named "constructor" (F48,
|
||||
* issue #1919), matching the @name the KOTLIN_QUERIES structure rule captures
|
||||
* off that keyword so method-extractor enrichment keys (`name:line`) align.
|
||||
* Multiple secondary constructors collide on this name but are disambiguated
|
||||
* downstream by the `#<arity>` ID suffix the worker appends to Constructors.
|
||||
*/
|
||||
function extractKotlinMethodName(node: SyntaxNode): string | undefined {
|
||||
if (node.type === 'secondary_constructor') return 'constructor';
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'simple_identifier') return child.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractKotlinParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = [];
|
||||
// Kotlin: function_declaration > function_value_parameters > parameter
|
||||
// Kotlin: function_declaration / secondary_constructor >
|
||||
// function_value_parameters > parameter. Both node types nest the
|
||||
// parameter list the same way, so the same walk extracts a secondary
|
||||
// constructor's parameters (F48).
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === 'function_value_parameters') {
|
||||
|
|
@ -271,16 +292,10 @@ function extractKotlinReturnType(node: SyntaxNode): string | undefined {
|
|||
export const kotlinMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Kotlin,
|
||||
typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'],
|
||||
methodNodeTypes: ['function_declaration'],
|
||||
methodNodeTypes: ['function_declaration', 'secondary_constructor'],
|
||||
bodyNodeTypes: ['class_body'],
|
||||
staticOwnerTypes: new Set(['companion_object', 'object_declaration']),
|
||||
extractName(node) {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'simple_identifier') return child.text;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
extractName: extractKotlinMethodName,
|
||||
|
||||
extractReturnType: extractKotlinReturnType,
|
||||
|
||||
|
|
|
|||
|
|
@ -264,16 +264,23 @@ function extractSwiftAnnotations(node: SyntaxNode): string[] {
|
|||
export const swiftMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Swift,
|
||||
|
||||
// Keep this conservative until Swift type-shape coverage is expanded.
|
||||
// TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration
|
||||
// node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct.
|
||||
// tree-sitter-swift collapses class / struct / enum / extension / actor into a
|
||||
// single `class_declaration` node (distinguished by the `declaration_kind`
|
||||
// field) — verified by real parse. There is NO separate `enum_declaration`
|
||||
// node type, so it must NOT be listed here (it would fail the grammar-drift
|
||||
// gate). The enum's owner node is therefore already covered by
|
||||
// `class_declaration`; F79 only needed the enum BODY node added below.
|
||||
// protocol_declaration is a separate, confirmed node type.
|
||||
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
|
||||
|
||||
// function_declaration for class/struct methods, protocol_function_declaration for protocol methods
|
||||
methodNodeTypes: ['function_declaration', 'protocol_function_declaration'],
|
||||
|
||||
bodyNodeTypes: ['class_body', 'protocol_body'],
|
||||
// class_body for class/struct/extension/actor, protocol_body for protocols,
|
||||
// enum_class_body for enums (F79). Without enum_class_body the factory only
|
||||
// reached enum methods via the generic findBodies fallback, which logs a
|
||||
// dev-mode "body field type not in bodyNodeTypes" warning.
|
||||
bodyNodeTypes: ['class_body', 'protocol_body', 'enum_class_body'],
|
||||
|
||||
extractName: extractSwiftName,
|
||||
extractReturnType: extractSwiftReturnType,
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* Inline ORM query extraction (sequential fallback path).
|
||||
*
|
||||
* Extracts Prisma and Supabase query calls from source content using
|
||||
* regex patterns. Used by the sequential parse path when workers are
|
||||
* not available — the worker path extracts ORM queries via tree-sitter
|
||||
* queries instead.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ExtractedORMQuery } from '../workers/parse-worker.js';
|
||||
|
||||
// ── Regex patterns ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Matches Prisma client method calls: `prisma.user.findMany(...)` */
|
||||
const PRISMA_QUERY_RE =
|
||||
/\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g;
|
||||
|
||||
/** Matches Supabase client method calls: `supabase.from('users').select(...)` */
|
||||
const SUPABASE_QUERY_RE =
|
||||
/\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g;
|
||||
|
||||
// ── Extraction function ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract ORM query calls from file content using regex.
|
||||
*
|
||||
* Fast-path: skips files that don't contain `prisma.` or `supabase.from`.
|
||||
* Results are appended to the `out` array (push pattern avoids allocation).
|
||||
*
|
||||
* @param filePath Relative path of the source file
|
||||
* @param content File content string
|
||||
* @param out Output array to append extracted queries to
|
||||
*/
|
||||
export function extractORMQueriesInline(
|
||||
filePath: string,
|
||||
content: string,
|
||||
out: ExtractedORMQuery[],
|
||||
): void {
|
||||
const hasPrisma = content.includes('prisma.');
|
||||
const hasSupabase = content.includes('supabase.from');
|
||||
if (!hasPrisma && !hasSupabase) return;
|
||||
|
||||
// Pre-compute line number offsets to avoid O(n²) substring+split per match
|
||||
const lineOffsets = buildLineOffsets(content);
|
||||
|
||||
if (hasPrisma) {
|
||||
PRISMA_QUERY_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = PRISMA_QUERY_RE.exec(content)) !== null) {
|
||||
const model = m[1];
|
||||
if (model.startsWith('$')) continue;
|
||||
out.push({
|
||||
filePath,
|
||||
orm: 'prisma',
|
||||
model,
|
||||
method: m[2],
|
||||
lineNumber: lineNumberAtOffset(lineOffsets, m.index),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSupabase) {
|
||||
SUPABASE_QUERY_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) {
|
||||
out.push({
|
||||
filePath,
|
||||
orm: 'supabase',
|
||||
model: m[1],
|
||||
method: m[2],
|
||||
lineNumber: lineNumberAtOffset(lineOffsets, m.index),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Line offset helpers ───────────────────────────────────────────────────
|
||||
|
||||
/** Build an array of byte offsets where each newline occurs (O(n) once). */
|
||||
function buildLineOffsets(content: string): number[] {
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === '\n') offsets.push(i);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary search for 0-based line number at a given character offset.
|
||||
*
|
||||
* Returns the number of newlines that occur before `offset` in the content,
|
||||
* which is the 0-based line number. When `offset` is beyond the last newline,
|
||||
* returns `lineOffsets.length` (i.e., the last line index).
|
||||
*/
|
||||
function lineNumberAtOffset(lineOffsets: number[], offset: number): number {
|
||||
let lo = 0;
|
||||
let hi = lineOffsets.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (lineOffsets[mid] < offset) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ import {
|
|||
type ExportedTypeMap,
|
||||
} from '../call-processor.js';
|
||||
import { createSemanticModel, type MutableSemanticModel } from '../model/index.js';
|
||||
import { createASTCache } from '../ast-cache.js';
|
||||
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
|
||||
|
|
@ -466,14 +465,6 @@ export async function runChunkedParseAndResolve(
|
|||
|
||||
let filesParsedSoFar = 0;
|
||||
|
||||
// Chunk-local tree-sitter cache, cleared between chunks — call / heritage /
|
||||
// import processors read it during parse to avoid re-parsing within the same
|
||||
// chunk. (The former cross-phase `scopeTreeCache` was only ever populated by
|
||||
// the sequential parser, which has been removed; workers can't return native
|
||||
// Trees across the MessageChannel, so scope-resolution re-parses as needed.)
|
||||
const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0);
|
||||
const astCache = createASTCache(maxChunkFiles);
|
||||
|
||||
const exportedTypeMap: ExportedTypeMap = new Map();
|
||||
const bindingAccumulator = new BindingAccumulator();
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
|
|
@ -649,7 +640,6 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
|
||||
filesParsedSoFar += chunkFiles.length;
|
||||
astCache.clear();
|
||||
|
||||
if (verboseThroughputLog && chunkStartMs !== null) {
|
||||
const elapsedMs = Date.now() - chunkStartMs;
|
||||
|
|
@ -943,7 +933,6 @@ export async function runChunkedParseAndResolve(
|
|||
// Finalize the accumulator and propagate any fixpoint-inferred exports before
|
||||
// `crossFile` disposes it downstream. Wrapped in try/catch so a cleanup
|
||||
// failure never masks a real parse error; disposal stays with `crossFile`.
|
||||
astCache.clear();
|
||||
try {
|
||||
bindingAccumulator.finalize();
|
||||
const enriched = enrichExportedTypeMap(bindingAccumulator, graph, exportedTypeMap);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function resolveDefGraphId(
|
|||
type?: NodeLabel;
|
||||
parameterTypes?: readonly string[];
|
||||
parameterTypeClasses?: readonly ParameterTypeClass[];
|
||||
parameterCount?: number;
|
||||
templateArguments?: readonly string[];
|
||||
templateConstraints?: unknown;
|
||||
/** #1982 bridge-held namespace path; see `SymbolDefinition.namespacePrefix`. */
|
||||
|
|
@ -171,6 +172,15 @@ export function resolveDefGraphId(
|
|||
const pHit = nodeLookup.get(pKey);
|
||||
if (pHit !== undefined) return pHit;
|
||||
}
|
||||
// Arity-disambiguating key (see node-lookup.ts): route a same-name overload
|
||||
// to the structure node with the matching parameter count. Critical for a
|
||||
// zero-arg overload (no parameterTypes) that would otherwise collapse onto a
|
||||
// sibling overload via the source-order-dependent qualified key.
|
||||
if (isOverloadableCallable(def.type) && def.parameterCount !== undefined) {
|
||||
const aKey = qualifiedKey(filePath, def.type, `${qn}#${def.parameterCount}`);
|
||||
const aHit = nodeLookup.get(aKey);
|
||||
if (aHit !== undefined) return aHit;
|
||||
}
|
||||
if (
|
||||
(def.type === 'Class' ||
|
||||
def.type === 'Struct' ||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,20 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
|||
// Each overload is unique — set unconditionally.
|
||||
if (!lookup.has(pKey)) lookup.set(pKey, node.id);
|
||||
}
|
||||
// Arity-disambiguating key: include the parameter count so two same-name
|
||||
// overloads of DIFFERENT arity route to distinct graph nodes even when the
|
||||
// shorter overload carries no parameter types (e.g. a Kotlin zero-arg
|
||||
// secondary constructor vs a 2-arg one — both share the qualified key, whose
|
||||
// first-write-wins assignment is source-order-dependent). The structure-phase
|
||||
// node id encodes `#<arity>`; this mirrors it in the lookup keyspace so
|
||||
// resolveDefGraphId can match by the def's own parameterCount. Same-arity
|
||||
// overloads collapse onto one arity key (first-write-wins) — identical to the
|
||||
// pre-existing qualified-key behavior, so no regression there.
|
||||
const pCount = (props as { parameterCount?: number }).parameterCount;
|
||||
if (pCount !== undefined && isOverloadableCallable(node.label)) {
|
||||
const aKey = qualifiedKey(props.filePath, node.label, `${keyQualified}#${pCount}`);
|
||||
if (!lookup.has(aKey)) lookup.set(aKey, node.id);
|
||||
}
|
||||
const pClasses = (props as { parameterTypeClasses?: readonly ParameterTypeClass[] })
|
||||
.parameterTypeClasses;
|
||||
const shapeTag = parameterShapeIdTag(pTypes, pClasses);
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,19 @@ export const CPP_QUERIES = `
|
|||
declarator: (init_declarator
|
||||
declarator: (identifier) @name)) @definition.variable
|
||||
|
||||
; Structured bindings: auto [a, b] = makePair(); (one @name per bound identifier)
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (structured_binding_declarator
|
||||
(identifier) @name))) @definition.variable
|
||||
|
||||
; Structured bindings, reference form: auto& [x, y] = tup;
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (reference_declarator
|
||||
(structured_binding_declarator
|
||||
(identifier) @name)))) @definition.variable
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
|
|
@ -1326,11 +1339,47 @@ export const KOTLIN_QUERIES = `
|
|||
(function_declaration
|
||||
(simple_identifier) @name) @definition.function
|
||||
|
||||
; ── Secondary constructors (F49 sibling F48, issue #1919) ────────────────
|
||||
; "constructor(...) { }" inside a class body is a secondary_constructor with
|
||||
; no name child — its only identity token is the anonymous "constructor"
|
||||
; keyword, captured here as @name so the node is named "constructor"
|
||||
; (matching kotlinMethodConfig.extractName). Multiple secondary constructors
|
||||
; share that name but get distinct ids via the worker's #<arity> suffix.
|
||||
(secondary_constructor
|
||||
"constructor" @name) @definition.constructor
|
||||
|
||||
; ── Properties ───────────────────────────────────────────────────────────
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @name)) @definition.property
|
||||
|
||||
; ── Destructuring declarations (F51, issue #1919) ────────────────────────
|
||||
; "val (a, b) = pair" binds several names through a multi_variable_declaration
|
||||
; (NOT a variable_declaration), which the property rule above misses. Emit one
|
||||
; @definition.property per bound name — the SAME label every other Kotlin val/var
|
||||
; gets (KOTLIN_QUERIES has no @definition.variable rule, so a single "val x"
|
||||
; is already a Property; matching that keeps destructured names consistent and
|
||||
; out of the block-scope local-symbol pruner that drops Variable/Const/Static).
|
||||
; The Kotlin "_" discard placeholder is filtered out here via (#not-eq? @name "_")
|
||||
; — these locals have no enclosing class, so the field-extractor enrichment path
|
||||
; never runs and cannot do the filtering itself. Each rule is a standalone
|
||||
; pattern (NOT a top-level [...] alternation), so the predicate is safe under
|
||||
; tree-sitter 0.21.1 (no sibling-branch drop). Loop destructuring
|
||||
; "for ((k, v) in m)" nests the SAME multi_variable_declaration directly under the
|
||||
; for_statement (no property_declaration wrapper); the scope-path loop binding only
|
||||
; handles the single variable_declaration form, so this rule does not double-emit.
|
||||
((property_declaration
|
||||
(multi_variable_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @name))) @definition.property
|
||||
(#not-eq? @name "_"))
|
||||
|
||||
((for_statement
|
||||
(multi_variable_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @name))) @definition.property
|
||||
(#not-eq? @name "_"))
|
||||
|
||||
; Primary constructor val/var parameters (data class, value class, regular class)
|
||||
; binding_pattern_kind contains "val" or "var" — without it, the param is not a property
|
||||
(class_parameter
|
||||
|
|
@ -1365,8 +1414,23 @@ export const KOTLIN_QUERIES = `
|
|||
(type_identifier) @call.name)) @call
|
||||
|
||||
; ── Infix function calls (e.g., a to b, x until y) ──────────────────────
|
||||
; tree-sitter-kotlin models infix_expression as three UNNAMED-FIELD children:
|
||||
; (operand) (operator) (operand) — all three are simple_identifier for
|
||||
; "a to b". The old rule "(infix_expression (simple_identifier) @call.name)"
|
||||
; matched EVERY simple_identifier child, so it captured the operands a/b as
|
||||
; spurious @call.name calls (F49, issue #1919). There is no operator: field to
|
||||
; anchor on, so anchor positionally: the operator is the middle child, flanked
|
||||
; by an operand on each side. End-anchored on both sides so only the lone
|
||||
; middle simple_identifier (the infix function) is captured; chained
|
||||
; "a to b to c" still matches each nested infix_expression's own operator.
|
||||
(infix_expression
|
||||
(simple_identifier) @call.name) @call
|
||||
.
|
||||
(_)
|
||||
.
|
||||
(simple_identifier) @call.name
|
||||
.
|
||||
(_)
|
||||
.) @call
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
|
|
@ -1413,6 +1477,13 @@ export const SWIFT_QUERIES = `
|
|||
; Properties (stored and computed)
|
||||
(property_declaration (pattern (simple_identifier) @name)) @definition.property
|
||||
|
||||
; Protocol property requirements (F75): "var title: String { get }" parses to a
|
||||
; protocol_property_declaration (NOT property_declaration). Its name is a
|
||||
; "name:" pattern field wrapping a value_binding_pattern + the bound
|
||||
; simple_identifier; match the inner identifier so the requirement is emitted
|
||||
; as a property symbol of the protocol.
|
||||
(protocol_property_declaration (pattern (simple_identifier) @name)) @definition.property
|
||||
|
||||
; Enum cases
|
||||
(enum_entry (simple_identifier) @name) @definition.property
|
||||
|
||||
|
|
@ -1459,12 +1530,36 @@ export const DART_QUERIES = `
|
|||
(enum_declaration
|
||||
name: (identifier) @name) @definition.enum
|
||||
|
||||
; ── Type aliases ─────────────────────────────────────────────────────────────
|
||||
; Anchor "=" after the name to avoid capturing the RHS type
|
||||
; ── Type aliases — new-style (typedef Pred = bool Function(int);) ────────────
|
||||
; Anchor "=" after the name to avoid capturing the RHS type. The name is the
|
||||
; first type_identifier (the alias), the RHS function_type follows the "=".
|
||||
(type_alias
|
||||
(type_identifier) @name
|
||||
"=") @definition.type
|
||||
|
||||
; ── Type aliases — old-style (typedef int Cmp(int a, int b);) ────────────────
|
||||
; The old-style function typedef has NO "=" — it parses as a type_alias whose
|
||||
; children are: return type_identifier, NAME type_identifier, formal_parameter_list.
|
||||
; Anchor @name as the type_identifier immediately before the parameter list so we
|
||||
; capture the alias name (Cmp), not the leading return type (int).
|
||||
(type_alias
|
||||
(type_identifier) @name
|
||||
.
|
||||
(formal_parameter_list)) @definition.type
|
||||
|
||||
; ── Type aliases — generic old-style (typedef int Cmp<T>(T a, T b);) ─────────
|
||||
; #1919 review CF2: a generic <T> inserts a type_parameters node between the
|
||||
; NAME and the parameter list, so the non-generic adjacency above misses it.
|
||||
; Standalone pattern (NOT an alternation arm) anchoring @name immediately before
|
||||
; type_parameters, which is immediately before the parameter list. The new-style
|
||||
; "=" rule above is unanchored and already covers generic new-style (Mapper<T>).
|
||||
(type_alias
|
||||
(type_identifier) @name
|
||||
.
|
||||
(type_parameters)
|
||||
.
|
||||
(formal_parameter_list)) @definition.type
|
||||
|
||||
; ── Top-level functions (parent is program, not method_signature) ────────────
|
||||
(program
|
||||
(function_signature
|
||||
|
|
@ -1503,6 +1598,19 @@ export const DART_QUERIES = `
|
|||
(initialized_identifier
|
||||
(identifier) @name))) @definition.property
|
||||
|
||||
; ── static const / static final / const class fields ────────────────────────
|
||||
; A "static const a = 1;" / "static final String b = ..., c = ...;" field parses
|
||||
; with a static_final_declaration_list (NOT an initialized_identifier_list), so
|
||||
; the field rules above miss them. One @name per static_final_declaration, so a
|
||||
; multi-name declaration yields a Property per name. Anchored on declaration (not
|
||||
; class_body) so top-level final/const variables — whose
|
||||
; static_final_declaration_list is a direct child of program, not wrapped in a
|
||||
; declaration — never match here.
|
||||
(declaration
|
||||
(static_final_declaration_list
|
||||
(static_final_declaration
|
||||
(identifier) @name))) @definition.property
|
||||
|
||||
; ── Getters ──────────────────────────────────────────────────────────────────
|
||||
(method_signature
|
||||
(getter_signature
|
||||
|
|
@ -1513,11 +1621,22 @@ export const DART_QUERIES = `
|
|||
(setter_signature
|
||||
name: (identifier) @name)) @definition.property
|
||||
|
||||
; ── Top-level variable declarations (const maxSize = 100, final x = 5, var y = 0) ──
|
||||
(declaration
|
||||
; ── Top-level variable declarations ──────────────────────────────────────────
|
||||
; Top-level Dart variables are NOT wrapped in a declaration node (that wrapper
|
||||
; only occurs for class-body members). They sit as loose siblings under program:
|
||||
; var name = 'x'; int x = 5; → initialized_identifier_list
|
||||
; final int count = 3; const a = 1, b = 2; → static_final_declaration_list
|
||||
; Anchor both rules under (program) so class-body fields (which reuse the same
|
||||
; inner node types) are never matched here. One @name per declared name so
|
||||
; multi-name forms (const a = 1, b = 2;) yield a Variable per name.
|
||||
(program
|
||||
(initialized_identifier_list
|
||||
(initialized_identifier
|
||||
(identifier) @name))) @definition.variable
|
||||
(identifier) @name)) @definition.variable)
|
||||
(program
|
||||
(static_final_declaration_list
|
||||
(static_final_declaration
|
||||
(identifier) @name)) @definition.variable)
|
||||
|
||||
; ── Imports ──────────────────────────────────────────────────────────────────
|
||||
(import_or_export
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ export const FUNCTION_NODE_TYPES = new Set([
|
|||
'anonymous_function',
|
||||
// Kotlin
|
||||
'lambda_literal',
|
||||
'secondary_constructor', // F48: methodNodeTypes superset invariant
|
||||
// Swift
|
||||
'init_declaration',
|
||||
'deinit_declaration',
|
||||
|
|
@ -583,6 +584,19 @@ export const findEnclosingClassInfo = (
|
|||
) {
|
||||
label = 'Interface';
|
||||
}
|
||||
// class_declaration with a `declaration_kind` field collapses several
|
||||
// type kinds onto one node (tree-sitter-swift: class / struct / enum /
|
||||
// extension / actor). The structure query labels struct → Struct and
|
||||
// enum → Enum; refine the owner label to match so a member edge
|
||||
// (HAS_METHOD / HAS_PROPERTY) anchors on the real Enum/Struct node id
|
||||
// rather than a non-existent `Class:` id (F79). Gated on the field
|
||||
// being present, so it is a no-op for grammars whose class_declaration
|
||||
// has no `declaration_kind` field (e.g. Kotlin).
|
||||
if (current.type === 'class_declaration' && label === 'Class') {
|
||||
const declKind = current.childForFieldName?.('declaration_kind')?.text;
|
||||
if (declKind === 'struct') label = 'Struct';
|
||||
else if (declKind === 'enum') label = 'Enum';
|
||||
}
|
||||
const templateArguments = extractTemplateArguments(nameNode.text);
|
||||
const classIdName =
|
||||
templateArguments !== undefined
|
||||
|
|
@ -819,6 +833,62 @@ export const inferFunctionLabel = (nodeType: string): NodeLabel =>
|
|||
/** Argument list node types shared between countCallArguments and call-resolution helpers. */
|
||||
export const CALL_ARGUMENT_LIST_TYPES = new Set(['arguments', 'argument_list', 'value_arguments']);
|
||||
|
||||
/**
|
||||
* Function/method parameter-list node types across grammars. Used to tell a
|
||||
* PARAMETER-property (a constructor parameter that is also a class field, e.g.
|
||||
* TypeScript `constructor(public name: string)`) apart from a function-BODY
|
||||
* local: a property reached through one of these — rather than through the
|
||||
* function's executable body — is a genuine class member, so the
|
||||
* function-local-property guard must NOT strip its owner edge.
|
||||
*/
|
||||
export const PARAMETER_LIST_NODE_TYPES = new Set([
|
||||
'formal_parameters', // TypeScript / JavaScript
|
||||
'parameters', // Python / C#
|
||||
'parameter_list', // Java / Go / C / Swift
|
||||
'function_value_parameters', // Kotlin
|
||||
'class_parameters', // Scala-like / future grammars
|
||||
]);
|
||||
|
||||
/**
|
||||
* Executable local-scope boundaries for the property-ownership guard
|
||||
* (`isFunctionLocalProperty` in parse-worker.ts). A `Property` capture whose
|
||||
* nearest enclosing scope — walking up before any class container — is one of
|
||||
* these executable bodies is a function-local binding, NOT a class member, so it
|
||||
* must not receive a class `HAS_PROPERTY` owner edge.
|
||||
*
|
||||
* Derived from FUNCTION_NODE_TYPES, with two deliberate adjustments found by the
|
||||
* #1919 review of the original guard:
|
||||
* - EXCLUDES Dart's bare signature wrappers (`function_signature` /
|
||||
* `method_signature`). A Dart getter/setter NAME lives under `method_signature`,
|
||||
* yet it is a class-member declaration, not a local inside an executable body;
|
||||
* treating the signature as a scope boundary OVER-stripped every Dart class
|
||||
* accessor's owner edge. (Signatures are Dart-only; no language emits a
|
||||
* legitimately-function-local Property under one.)
|
||||
* - INCLUDES accessor + initializer bodies (Kotlin `anonymous_initializer` /
|
||||
* `getter` / `setter`, Swift `computed_property` / `computed_getter` /
|
||||
* `computed_setter` / `computed_modify`). Destructuring/locals inside these ARE
|
||||
* function-local, yet they are absent from FUNCTION_NODE_TYPES; omitting them
|
||||
* UNDER-stripped and emitted spurious class `HAS_PROPERTY` edges for
|
||||
* `init {}` / accessor-body destructuring bindings.
|
||||
*
|
||||
* Kept separate from FUNCTION_NODE_TYPES because that set has many other consumers
|
||||
* (e.g. enclosing-callable resolution) where signatures must remain function nodes
|
||||
* and accessor bodies must not.
|
||||
*/
|
||||
export const LOCAL_SCOPE_BODY_NODE_TYPES: ReadonlySet<string> = new Set(
|
||||
[...FUNCTION_NODE_TYPES]
|
||||
.filter((t) => t !== 'function_signature' && t !== 'method_signature')
|
||||
.concat([
|
||||
'anonymous_initializer', // Kotlin: init { }
|
||||
'getter', // Kotlin: val x get() { }
|
||||
'setter', // Kotlin: var x set(v) { }
|
||||
'computed_property', // Swift: var x: T { get set }
|
||||
'computed_getter', // Swift: get { }
|
||||
'computed_setter', // Swift: set { }
|
||||
'computed_modify', // Swift: _modify { }
|
||||
]),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Generic AST traversal helpers (shared by parse-worker + php-helpers)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -37,6 +37,55 @@ function extractCVarName(node: SyntaxNode): string | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the `structured_binding_declarator` inside a `declaration` node, if any.
|
||||
*
|
||||
* C++ structured bindings (`auto [a, b] = pair;`) parse as:
|
||||
* declaration → init_declarator → structured_binding_declarator → identifier+
|
||||
* The reference form (`auto& [x, y] = tup;`) wraps it one level deeper:
|
||||
* declaration → init_declarator → reference_declarator → structured_binding_declarator
|
||||
*/
|
||||
function findStructuredBindingDeclarator(node: SyntaxNode): SyntaxNode | undefined {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type !== 'init_declarator') continue;
|
||||
const declarator = child.childForFieldName('declarator');
|
||||
if (declarator?.type === 'structured_binding_declarator') return declarator;
|
||||
// `auto& [x, y]` → reference_declarator wraps the structured_binding_declarator.
|
||||
if (declarator?.type === 'reference_declarator') {
|
||||
const inner = declarator.namedChildren.find(
|
||||
(c: SyntaxNode) => c.type === 'structured_binding_declarator',
|
||||
);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every bound name from a C/C++ declaration.
|
||||
*
|
||||
* For a structured binding `auto [a, b] = ...;` this returns each bound
|
||||
* identifier (`['a', 'b']`); the binding's `structured_binding_declarator`
|
||||
* lists one `identifier` per name. For an ordinary single-name declaration
|
||||
* (`int n = 0;`) it falls back to the single name extractor so behaviour is
|
||||
* unchanged (issue #1919 F9).
|
||||
*/
|
||||
function extractCVarNames(node: SyntaxNode): string[] {
|
||||
const binding = findStructuredBindingDeclarator(node);
|
||||
if (binding !== undefined) {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < binding.namedChildCount; i++) {
|
||||
const child = binding.namedChild(i);
|
||||
if (child?.type === 'identifier') names.push(child.text);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
const single = extractCVarName(node);
|
||||
return single !== undefined ? [single] : [];
|
||||
}
|
||||
|
||||
function extractCVarType(node: SyntaxNode): string | undefined {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
|
|
@ -60,6 +109,7 @@ const shared: Omit<VariableExtractionConfig, 'language'> = {
|
|||
variableNodeTypes: ['declaration'],
|
||||
|
||||
extractName: extractCVarName,
|
||||
extractNames: extractCVarNames,
|
||||
extractType: extractCVarType,
|
||||
|
||||
extractVisibility(node): VariableVisibility {
|
||||
|
|
|
|||
|
|
@ -8,56 +8,79 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
|||
/**
|
||||
* Dart variable extraction config.
|
||||
*
|
||||
* Dart has top-level variable and constant declarations:
|
||||
* - `const maxSize = 100;`
|
||||
* - `final String name = "dart";`
|
||||
* - `var counter = 0;`
|
||||
* - `int x = 5;`
|
||||
* Top-level Dart variables are NOT wrapped in a `declaration` node (that wrapper
|
||||
* only occurs for class-body members). The structure query (`DART_QUERIES`)
|
||||
* captures them as `@definition.variable` on the loose container node, which is
|
||||
* one of two real shapes:
|
||||
*
|
||||
* tree-sitter-dart uses:
|
||||
* - declaration (with initialized_identifier_list) for file-scope variables
|
||||
* - `var name = 'x';` / `int x = 5;`
|
||||
* → initialized_identifier_list > initialized_identifier > identifier
|
||||
* - `final int count = 3;` / `const a = 1, b = 2;`
|
||||
* → static_final_declaration_list > static_final_declaration > identifier
|
||||
*
|
||||
* The variable extractor is invoked on that captured container node to enrich
|
||||
* the Variable symbol with name(s)/type/const/mutable metadata.
|
||||
*
|
||||
* NOTE: the const/final modifier (`const_builtin` / `final_builtin`) and the
|
||||
* type annotation (`type_identifier`) are siblings of the captured container —
|
||||
* they live on the parent (program), NOT inside it — so const-ness and the type
|
||||
* are read from the captured node's parent. (This is why the previous
|
||||
* `type_identifier`-as-direct-child read found nothing.)
|
||||
*/
|
||||
|
||||
function extractDartVarName(node: SyntaxNode): string | undefined {
|
||||
// declaration → initialized_variable_definition → identifier
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'initialized_variable_definition') {
|
||||
const name = child.childForFieldName('name');
|
||||
if (name) return name.text;
|
||||
// Fallback: first identifier
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const gc = child.namedChild(j);
|
||||
if (gc?.type === 'identifier') return gc.text;
|
||||
}
|
||||
}
|
||||
// declaration → initialized_identifier_list → initialized_identifier → identifier
|
||||
if (child?.type === 'initialized_identifier_list') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const gc = child.namedChild(j);
|
||||
if (gc?.type === 'initialized_identifier') {
|
||||
const ident = gc.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
|
||||
if (ident) return ident.text;
|
||||
}
|
||||
}
|
||||
/** The `initialized_identifier` / `static_final_declaration` name children. */
|
||||
function nameNodes(container: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let i = 0; i < container.namedChildCount; i++) {
|
||||
const entry = container.namedChild(i);
|
||||
if (!entry) continue;
|
||||
if (entry.type === 'initialized_identifier' || entry.type === 'static_final_declaration') {
|
||||
const ident = entry.firstNamedChild;
|
||||
if (ident?.type === 'identifier') out.push(ident);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractDartVarNames(node: SyntaxNode): string[] {
|
||||
return nameNodes(node).map((n) => n.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the container's immediately-preceding siblings (the modifier / type
|
||||
* nodes of THIS declaration), stopping at the previous statement's `;` so a
|
||||
* neighbouring declaration's modifiers/type never bleed in. Top-level Dart
|
||||
* declarations sit as loose siblings under `program` separated by `;`:
|
||||
* final int count = 3; var name = 'x'; const a = 1, b = 2;
|
||||
* so the leading `type_identifier` / `const_builtin` / `final_builtin` of a
|
||||
* declaration are the siblings between the prior `;` and the captured container.
|
||||
*/
|
||||
function scanLeadingSiblings(node: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
let sib = node.previousSibling;
|
||||
while (sib !== null && sib.type !== ';') {
|
||||
out.push(sib);
|
||||
sib = sib.previousSibling;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declared type annotation, read from the captured container's leading
|
||||
* sibling `type_identifier`. Returns undefined for inferred (`var`)
|
||||
* declarations, which have an `inferred_type` sibling instead.
|
||||
*/
|
||||
function extractDartVarType(node: SyntaxNode): string | undefined {
|
||||
// Look for type_identifier directly on the node
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'type_identifier') return child.text;
|
||||
for (const sib of scanLeadingSiblings(node)) {
|
||||
if (sib.type === 'type_identifier') return sib.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasDartKeyword(node: SyntaxNode, keyword: string): boolean {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child?.text === keyword) return true;
|
||||
/** Whether a `const_builtin` / `final_builtin` leads this declaration. */
|
||||
function hasReadonlyModifier(node: SyntaxNode): boolean {
|
||||
for (const sib of scanLeadingSiblings(node)) {
|
||||
if (sib.type === 'const_builtin' || sib.type === 'final_builtin') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -66,28 +89,32 @@ export const dartVariableConfig: VariableExtractionConfig = {
|
|||
language: SupportedLanguages.Dart,
|
||||
constNodeTypes: [],
|
||||
staticNodeTypes: [],
|
||||
variableNodeTypes: ['declaration'],
|
||||
// The two real top-level container shapes captured as @definition.variable.
|
||||
variableNodeTypes: ['initialized_identifier_list', 'static_final_declaration_list'],
|
||||
|
||||
extractName: extractDartVarName,
|
||||
extractName: (node) => extractDartVarNames(node)[0],
|
||||
extractNames: extractDartVarNames,
|
||||
extractType: extractDartVarType,
|
||||
|
||||
extractVisibility(node): VariableVisibility {
|
||||
const name = extractDartVarName(node);
|
||||
if (!name) return 'public';
|
||||
// Dart convention: underscore prefix = library-private
|
||||
extractVisibilityForName(_node, name): VariableVisibility {
|
||||
// Dart convention: underscore prefix = library-private.
|
||||
return name.startsWith('_') ? 'private' : 'public';
|
||||
},
|
||||
|
||||
isConst(node) {
|
||||
return hasDartKeyword(node, 'const') || hasDartKeyword(node, 'final');
|
||||
extractVisibility(node): VariableVisibility {
|
||||
const first = extractDartVarNames(node)[0];
|
||||
if (!first) return 'public';
|
||||
return first.startsWith('_') ? 'private' : 'public';
|
||||
},
|
||||
|
||||
isConst: hasReadonlyModifier,
|
||||
|
||||
isStatic(_node) {
|
||||
// Top-level Dart variables are not static
|
||||
// Top-level Dart variables are not static.
|
||||
return false;
|
||||
},
|
||||
|
||||
isMutable(node) {
|
||||
return !hasDartKeyword(node, 'const') && !hasDartKeyword(node, 'final');
|
||||
return !hasReadonlyModifier(node);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ export const javaVariableConfig: VariableExtractionConfig = {
|
|||
},
|
||||
};
|
||||
|
||||
/** Single-binding name of a Kotlin property_declaration:
|
||||
* property_declaration → variable_declaration → simple_identifier. */
|
||||
function kotlinSingleVarName(node: SyntaxNode): string | undefined {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'variable_declaration') {
|
||||
const ident = child.namedChildren.find((c: SyntaxNode) => c.type === 'simple_identifier');
|
||||
return ident?.text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin variable extraction config.
|
||||
*
|
||||
|
|
@ -66,16 +79,32 @@ export const kotlinVariableConfig: VariableExtractionConfig = {
|
|||
staticNodeTypes: [],
|
||||
variableNodeTypes: ['property_declaration'],
|
||||
|
||||
extractName(node) {
|
||||
// property_declaration → variable_declaration → simple_identifier
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'variable_declaration') {
|
||||
const ident = child.namedChildren.find((c: SyntaxNode) => c.type === 'simple_identifier');
|
||||
return ident?.text;
|
||||
extractName: kotlinSingleVarName,
|
||||
|
||||
// F51 (issue #1919): destructuring declarations bind several names at one
|
||||
// node. `val (a, b) = pair` parses as
|
||||
// property_declaration → multi_variable_declaration
|
||||
// → variable_declaration → simple_identifier (one per name)
|
||||
// (real-parse-verified). The single-name `extractName` above misses these
|
||||
// entirely. When a multi_variable_declaration is present we return EACH
|
||||
// bound name; the Kotlin `_` placeholder is a discard and is skipped. A
|
||||
// plain single declaration falls through to the existing variable_declaration
|
||||
// shape so `val x = 1` still yields exactly one name (no double-count).
|
||||
extractNames(node) {
|
||||
const multi = node.namedChildren.find(
|
||||
(c: SyntaxNode) => c.type === 'multi_variable_declaration',
|
||||
);
|
||||
if (multi !== undefined) {
|
||||
const names: string[] = [];
|
||||
for (const decl of multi.namedChildren) {
|
||||
if (decl.type !== 'variable_declaration') continue;
|
||||
const ident = decl.namedChildren.find((c: SyntaxNode) => c.type === 'simple_identifier');
|
||||
if (ident !== undefined && ident.text !== '_') names.push(ident.text);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
return undefined;
|
||||
const single = kotlinSingleVarName(node);
|
||||
return single !== undefined ? [single] : [];
|
||||
},
|
||||
|
||||
extractType(node) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { getLanguageFromFilename } from 'gitnexus-shared';
|
|||
import {
|
||||
buildConcreteTypedefDefinitionRanges,
|
||||
FUNCTION_NODE_TYPES,
|
||||
findAncestorBeforeBoundary,
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassInfo,
|
||||
findObjectLiteralBindingInfo,
|
||||
|
|
@ -69,6 +70,8 @@ import {
|
|||
isQualifiableScopeLabel,
|
||||
qualifyRustImplTargetByModScope,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
PARAMETER_LIST_NODE_TYPES,
|
||||
LOCAL_SCOPE_BODY_NODE_TYPES,
|
||||
type SyntaxNode,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import { extractCallArgTypes, type MixedChainStep } from '../utils/call-analysis.js';
|
||||
|
|
@ -1779,14 +1782,52 @@ const processFileGroup = (
|
|||
provider.classExtractor!.qualifyScopeName?.(node, simpleName) ??
|
||||
null
|
||||
: undefined;
|
||||
const enclosingClassInfo = needsOwner
|
||||
? cachedFindEnclosingClassInfo(
|
||||
nameNode || definitionNode,
|
||||
file.path,
|
||||
provider.resolveEnclosingOwner,
|
||||
getQualifiedOwnerName,
|
||||
)
|
||||
: null;
|
||||
// A Property declared inside a function/lambda BODY is a function-LOCAL
|
||||
// binding (e.g. Kotlin `val (a,b) = pair` or a `for ((k,v) in m)` loop
|
||||
// destructuring emitted as `@definition.property` to dodge the local-symbol
|
||||
// pruner), NOT a class member. Such locals must not get a HAS_PROPERTY owner
|
||||
// edge from the enclosing class. Detect them by walking from the def node:
|
||||
// if a function-like ancestor is reached BEFORE any class container, the
|
||||
// property is enclosed by a function. Language-agnostic — genuine class
|
||||
// fields sit directly in the class body with no intervening function, so
|
||||
// they are unaffected (#1919 review CF3).
|
||||
//
|
||||
// EXCEPTION: a constructor PARAMETER property (TypeScript
|
||||
// `constructor(public name: string)`) is also enclosed by a function, but
|
||||
// it IS a class member — it is reached through the parameter list, not the
|
||||
// executable body. So only strip the owner when the property is NOT inside
|
||||
// a parameter list of that function (i.e. it's a body local).
|
||||
const propOwnerNode = nameNode || definitionNode;
|
||||
// A Property is function-local (and must NOT get a class HAS_PROPERTY owner)
|
||||
// when its nearest enclosing executable body — reached before any class
|
||||
// container — is a function/accessor/initializer body, AND it is not a
|
||||
// constructor parameter-property (rescued by the param-list carve-out).
|
||||
// Uses LOCAL_SCOPE_BODY_NODE_TYPES (not FUNCTION_NODE_TYPES): the latter
|
||||
// mis-includes Dart bare signatures (over-stripping accessors) and omits
|
||||
// Kotlin/Swift init+accessor bodies (under-stripping their locals) — see
|
||||
// the #1919 review of this guard.
|
||||
const isFunctionLocalProperty =
|
||||
nodeLabel === 'Property' &&
|
||||
propOwnerNode !== undefined &&
|
||||
findAncestorBeforeBoundary(
|
||||
propOwnerNode,
|
||||
LOCAL_SCOPE_BODY_NODE_TYPES,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
) !== null &&
|
||||
findAncestorBeforeBoundary(
|
||||
propOwnerNode,
|
||||
PARAMETER_LIST_NODE_TYPES,
|
||||
LOCAL_SCOPE_BODY_NODE_TYPES,
|
||||
) === null;
|
||||
const enclosingClassInfo =
|
||||
needsOwner && !isFunctionLocalProperty
|
||||
? cachedFindEnclosingClassInfo(
|
||||
nameNode || definitionNode,
|
||||
file.path,
|
||||
provider.resolveEnclosingOwner,
|
||||
getQualifiedOwnerName,
|
||||
)
|
||||
: null;
|
||||
const enclosingClassId =
|
||||
enclosingClassInfo?.qualifiedClassId ?? enclosingClassInfo?.classId ?? null;
|
||||
const objectLiteralOwnerInfo =
|
||||
|
|
@ -2434,7 +2475,21 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
|
|||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
parentPort!.postMessage({ type: 'error', error: message });
|
||||
// Carry the worker-side stack across the MessageChannel, not just the
|
||||
// message. Without this, an unexpected worker throw (e.g. the minified
|
||||
// `this.#<x> is not a function` family) reaches the operator as a bare
|
||||
// one-liner with no file:line — exactly what made #2068 undebuggable. The
|
||||
// pool embeds `errorStack` into its death/circuit-breaker reason so the
|
||||
// surfaced "Phase 'parse' failed" message points at the real frame (the
|
||||
// stack's first line already carries the error's type + message). We send
|
||||
// primitive fields (not the raw Error) so a non-cloneable `cause` payload
|
||||
// can never turn the report itself into a `messageerror`. `errorStack` is
|
||||
// optional on the wire, so an older pool ignores it.
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
parentPort!.postMessage({
|
||||
type: 'error',
|
||||
error: e.message,
|
||||
errorStack: e.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -310,7 +310,15 @@ type WorkerOutgoingMessage =
|
|||
| { type: 'progress'; filesProcessed: number }
|
||||
| { type: 'warning'; message: string }
|
||||
| { type: 'sub-batch-done' }
|
||||
| { type: 'error'; error: string }
|
||||
/**
|
||||
* Worker-side caught error. `error` is the message; `errorStack` carries the
|
||||
* worker thread's stack so the pool can embed a real file:line into its
|
||||
* death / circuit-breaker reason instead of surfacing a bare one-liner (the
|
||||
* #2068 diagnosability gap). `errorStack` is optional so an older worker
|
||||
* build that only sends `error` still validates and degrades to message-only
|
||||
* — and a newer pool reading it just gets no stack.
|
||||
*/
|
||||
| { type: 'error'; error: string; errorStack?: string }
|
||||
| { type: 'result'; data: unknown }
|
||||
/**
|
||||
* Authoritative in-flight signal: worker is about to process this file.
|
||||
|
|
@ -656,6 +664,25 @@ function withStderr(worker: Worker, message: string): string {
|
|||
return tail ? `${message}. Worker stderr:\n${tail}` : message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a worker-death reason string that carries the worker-side stack when one
|
||||
* is available (#2068). The stack is appended AFTER the `Worker N error: <msg>`
|
||||
* prefix so every prefix/substring consumer downstream — recoverAndResume →
|
||||
* handleWorkerDeath → the circuit-breaker `WorkerPoolDispatchError` message, and
|
||||
* the tests that regex-match those — keeps working unchanged, while the operator
|
||||
* now gets the real frame instead of a bare one-liner. The stack's first line is
|
||||
* normally the message itself; keeping both is harmless and the indented block
|
||||
* scans cleanly in a log. The stack is capped at WORKER_STDERR_TAIL_LIMIT,
|
||||
* mirroring the sibling stderr-tail bound, so a pathological error type (or a
|
||||
* raised `Error.stackTraceLimit`) can't bloat the death reason. `stack` is
|
||||
* `undefined` for an older worker build (or a thrown non-Error), in which case
|
||||
* the reason is exactly the prior message-only form.
|
||||
*/
|
||||
function workerErrorReason(workerIndex: number, message: string, stack?: string): string {
|
||||
const base = `Worker ${workerIndex} error: ${message}`;
|
||||
return stack ? `${base}\n worker stack:\n${stack.slice(0, WORKER_STDERR_TAIL_LIMIT)}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a freshly-spawned replacement worker to emit the
|
||||
* `{type:'ready'}` handshake from `parse-worker.ts` before treating its
|
||||
|
|
@ -1832,7 +1859,7 @@ export const createWorkerPool = (
|
|||
settled = true;
|
||||
cleanup();
|
||||
void recoverAndResume(
|
||||
`Worker ${workerIndex} error: ${msg.error}`,
|
||||
workerErrorReason(workerIndex, msg.error, msg.errorStack),
|
||||
resolveExcludePaths(),
|
||||
);
|
||||
} else if (msg.type === 'result') {
|
||||
|
|
@ -1877,8 +1904,13 @@ export const createWorkerPool = (
|
|||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
// The Node 'error' event fires on an UNCAUGHT worker throw (one that
|
||||
// escaped the worker's own try/catch, or an async rejection). Unlike
|
||||
// the `{type:'error'}` message, the event delivers a real Error whose
|
||||
// `.stack` is the worker-side frame — carry it so the surfaced reason
|
||||
// points at the actual failure site, not just `err.message` (#2068).
|
||||
void recoverAndResume(
|
||||
`Worker ${workerIndex} error: ${err.message}`,
|
||||
workerErrorReason(workerIndex, err.message, err.stack),
|
||||
resolveExcludePaths(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import path from 'path';
|
|||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { closeQueryResults } from './query-result-utils.js';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import {
|
||||
NODE_TABLES,
|
||||
|
|
@ -213,8 +214,18 @@ const DB_LOCK_RETRY_DELAY_MS = 500;
|
|||
* analyze` and either already happened or will happen on the next run.
|
||||
*/
|
||||
export const isReadOnlyDbError = (err: unknown): boolean => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /read-only database/i.test(msg);
|
||||
// Walk the `cause` chain (bounded) so a wrapped read-only error — e.g. the
|
||||
// pool adapter's `new Error('…read-only.', { cause: nativeReadOnlyErr })` —
|
||||
// is still detected by callers that only see the wrapper (#2068 follow-up).
|
||||
// The same strict regex is re-applied at each level, so a non-read-only
|
||||
// chain stays false; the depth bound guards a cyclic `cause`.
|
||||
let cur: unknown = err;
|
||||
for (let depth = 0; depth < 5 && cur != null; depth++) {
|
||||
const msg = cur instanceof Error ? cur.message : String(cur);
|
||||
if (/read-only database/i.test(msg)) return true;
|
||||
cur = cur instanceof Error ? (cur as { cause?: unknown }).cause : undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isMissingFileError = (err: unknown): boolean => {
|
||||
|
|
@ -392,12 +403,10 @@ const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> =>
|
|||
const normalizeCopyPath = (filePath: string): string =>
|
||||
toNativeSafePath(filePath).replace(/\\/g, '/');
|
||||
|
||||
// Single-result convenience wrapper over the shared best-effort closer
|
||||
// (drainQueryResult / readQueryRows close one cursor at a time).
|
||||
const closeQueryResult = async (result: lbug.QueryResult): Promise<void> => {
|
||||
try {
|
||||
await result.close();
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
await closeQueryResults(result);
|
||||
};
|
||||
|
||||
const drainQueryResult = async (
|
||||
|
|
@ -1758,8 +1767,9 @@ export const queryImporters = async (targetFilePath: string): Promise<string[]>
|
|||
WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}'
|
||||
RETURN DISTINCT a.filePath AS importer
|
||||
`;
|
||||
let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
try {
|
||||
const queryResult = await conn.query(cypher);
|
||||
queryResult = await conn.query(cypher);
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const rows = await result.getAll();
|
||||
const out: string[] = [];
|
||||
|
|
@ -1770,6 +1780,8 @@ export const queryImporters = async (targetFilePath: string): Promise<string[]>
|
|||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
if (queryResult) await closeQueryResults(queryResult);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1788,8 +1800,9 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
|||
}
|
||||
let nodesDeleted = 0;
|
||||
for (const label of ['Community', 'Process']) {
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
try {
|
||||
const countResult = await conn.query(`MATCH (n:${label}) RETURN count(n) AS cnt`);
|
||||
countResult = await conn.query(`MATCH (n:${label}) RETURN count(n) AS cnt`);
|
||||
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||
const rows = await result.getAll();
|
||||
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||
|
|
@ -1799,6 +1812,8 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
|||
}
|
||||
} catch {
|
||||
// Table may not exist yet on a freshly-initialized DB — fine.
|
||||
} finally {
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
}
|
||||
}
|
||||
return { nodesDeleted };
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
||||
import { closeQueryResults } from './query-result-utils.js';
|
||||
import {
|
||||
createLbugDatabase,
|
||||
isWalCorruptionError,
|
||||
|
|
@ -41,8 +42,14 @@ interface PoolEntry {
|
|||
available: lbug.Connection[];
|
||||
/** Number of connections currently checked out */
|
||||
checkedOut: number;
|
||||
/** Queued waiters for when all connections are busy */
|
||||
waiters: Array<(conn: lbug.Connection) => void>;
|
||||
/** Queued waiters for when all connections are busy. Each carries `resolve`
|
||||
* (hand off a freed connection) and `reject` (fail fast when the pool is
|
||||
* closed before a connection frees, instead of hanging until the waiter
|
||||
* timeout — #2068 follow-up). */
|
||||
waiters: Array<{
|
||||
resolve: (conn: lbug.Connection) => void;
|
||||
reject: (err: Error) => void;
|
||||
}>;
|
||||
lastUsed: number;
|
||||
dbPath: string;
|
||||
/** Set to true when the pool entry is closed — checkin will close orphaned connections */
|
||||
|
|
@ -176,6 +183,20 @@ function closeOne(repoId: string): void {
|
|||
|
||||
entry.closed = true;
|
||||
|
||||
// Reject any callers still queued for a connection: the pool is going away
|
||||
// (re-init / teardown / LRU eviction), so they must fail fast with an
|
||||
// actionable error instead of hanging until WAITER_TIMEOUT_MS and then
|
||||
// surfacing a misleading "pool exhausted" (#2068 follow-up). Draining the
|
||||
// queue also guarantees checkin() below finds no waiter expecting a
|
||||
// connection, so a connection returned after close is simply closed.
|
||||
if (entry.waiters.length > 0) {
|
||||
const closedErr = new Error(
|
||||
`LadybugDB connection pool closed for repo "${repoId}" (re-init/teardown); retry the query.`,
|
||||
);
|
||||
for (const waiter of entry.waiters) waiter.reject(closedErr);
|
||||
entry.waiters.length = 0;
|
||||
}
|
||||
|
||||
// Close available connections — fire-and-forget with .catch() to prevent
|
||||
// unhandled rejections. Native close() returns Promise<void> but can crash
|
||||
// the N-API destructor on macOS/Windows; deferring to process exit lets
|
||||
|
|
@ -680,14 +701,20 @@ function checkout(entry: PoolEntry): Promise<lbug.Connection> {
|
|||
|
||||
// At capacity — queue the caller with a timeout.
|
||||
return new Promise<lbug.Connection>((resolve, reject) => {
|
||||
const waiter = (conn: lbug.Connection) => {
|
||||
clearTimeout(timer);
|
||||
resolve(conn);
|
||||
const waiter = {
|
||||
resolve: (conn: lbug.Connection) => {
|
||||
clearTimeout(timer);
|
||||
resolve(conn);
|
||||
},
|
||||
reject: (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
const idx = entry.waiters.indexOf(waiter);
|
||||
if (idx !== -1) entry.waiters.splice(idx, 1);
|
||||
reject(
|
||||
waiter.reject(
|
||||
new Error(
|
||||
`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`,
|
||||
),
|
||||
|
|
@ -713,7 +740,7 @@ function checkin(entry: PoolEntry, conn: lbug.Connection): void {
|
|||
if (entry.waiters.length > 0) {
|
||||
// Hand directly to the next waiter — no intermediate available state
|
||||
const waiter = entry.waiters.shift()!;
|
||||
waiter(conn);
|
||||
waiter.resolve(conn);
|
||||
} else {
|
||||
entry.checkedOut--;
|
||||
entry.available.push(conn);
|
||||
|
|
@ -756,22 +783,33 @@ export const executeParameterized = async (
|
|||
const conn = await checkout(entry);
|
||||
silenceStdout();
|
||||
activeQueryCount++;
|
||||
let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
try {
|
||||
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Prepare failed: ${errMsg}`);
|
||||
}
|
||||
const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
|
||||
queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const rows = await result.getAll();
|
||||
return rows;
|
||||
} catch (err) {
|
||||
if (isReadOnlyDbError(err)) {
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
||||
// Preserve the native error as `cause` so the original frame/message is
|
||||
// not lost behind the friendly read-only message (#2068 follow-up).
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
// Close the native QueryResult cursor(s) before returning the connection —
|
||||
// getAll() drains rows but does not release the native cursor, so without
|
||||
// this the cursor leaks for the connection's lifetime (#2068 follow-up).
|
||||
// Best-effort via the shared helper; never masks the query result or a real
|
||||
// error.
|
||||
if (queryResult) await closeQueryResults(queryResult);
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
checkin(entry, conn);
|
||||
|
|
|
|||
31
gitnexus/src/core/lbug/query-result-utils.ts
Normal file
31
gitnexus/src/core/lbug/query-result-utils.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import lbug from '@ladybugdb/core';
|
||||
|
||||
/**
|
||||
* Best-effort close of one or more native `QueryResult` cursors.
|
||||
*
|
||||
* `result.getAll()` materializes rows into a JS array but does not release the
|
||||
* native cursor — leaving it open holds native resources for the connection's
|
||||
* lifetime. Both the pooled adapter (`pool-adapter.ts`) and the direct adapter
|
||||
* (`lbug-adapter.ts`) must release cursors after reading; this is the single
|
||||
* shared implementation so neither re-rolls the array-normalize + swallow loop
|
||||
* (a #2068 follow-up de-dup). Lives in its own leaf module so the two adapters
|
||||
* depend on it rather than on each other.
|
||||
*
|
||||
* `conn.execute()` can return either a single `QueryResult` or an array
|
||||
* (multi-statement); this normalizes both. Each close is independent and
|
||||
* best-effort: a failing or absent `close()` on one cursor never throws and
|
||||
* never prevents the others from closing — a cleanup failure must not mask the
|
||||
* query result or a real error at the call site.
|
||||
*/
|
||||
export async function closeQueryResults(
|
||||
queryResult: lbug.QueryResult | lbug.QueryResult[],
|
||||
): Promise<void> {
|
||||
const results = Array.isArray(queryResult) ? queryResult : [queryResult];
|
||||
for (const r of results) {
|
||||
try {
|
||||
await r?.close();
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -177,8 +177,19 @@ function logQueryError(context: string, err: unknown): void {
|
|||
logger.error({ context, err: msg }, 'GitNexus query failed');
|
||||
}
|
||||
|
||||
const isReadOnlyDbError = (err: unknown): boolean =>
|
||||
/read-only database/i.test(err instanceof Error ? err.message : String(err));
|
||||
const isReadOnlyDbError = (err: unknown): boolean => {
|
||||
// Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
|
||||
// pool adapter's `{ cause }` wrapper) is still detected here — this is the
|
||||
// copy the MCP cypher handler uses to surface its curated read-only message
|
||||
// (#2068 follow-up). Mirrors lbug-adapter's isReadOnlyDbError.
|
||||
let cur: unknown = err;
|
||||
for (let depth = 0; depth < 5 && cur != null; depth++) {
|
||||
const msg = cur instanceof Error ? cur.message : String(cur);
|
||||
if (/read-only database/i.test(msg)) return true;
|
||||
cur = cur instanceof Error ? (cur as { cause?: unknown }).cause : undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-query latency telemetry for production aggregation (#553).
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ export interface PipelineResult {
|
|||
*/
|
||||
resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/**
|
||||
* True if the parse phase spawned a worker pool for this run. False means
|
||||
* the sequential fallback handled every chunk. Primarily a test affordance
|
||||
* so regression suites can prove which path executed.
|
||||
* True if a worker pool was actually constructed for this run. The worker
|
||||
* pool is the sole parse path (sequential parsing was removed). False means
|
||||
* no pool was needed: either there were no parseable files, or every chunk
|
||||
* was a parse-cache hit and the cached worker output was replayed without
|
||||
* spawning workers (a warm all-cache-hit run, #2038). Primarily a test
|
||||
* affordance so regression suites can prove the pool engaged.
|
||||
*/
|
||||
usedWorkerPool: boolean;
|
||||
}
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/lang-resolution/c-coverage/main.c
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/c-coverage/main.c
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#include <stdio.h>
|
||||
#include "local.h"
|
||||
|
||||
#define HDR "computed.h"
|
||||
#include HDR
|
||||
|
||||
int main(void) {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -15,3 +15,8 @@ void processRepoMap(std::map<std::string, Repo> repoMap) {
|
|||
repo.save();
|
||||
}
|
||||
}
|
||||
|
||||
// F9 — plain (non-for-loop) structured binding declaration. Each bound name
|
||||
// must emit its own Variable node.
|
||||
std::pair<int, int> makePair();
|
||||
auto [firstId, secondId] = makePair();
|
||||
|
|
|
|||
12
gitnexus/test/fixtures/lang-resolution/dart-accessor-owner/main.dart
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/dart-accessor-owner/main.dart
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// CF3 review (#1919): a Dart class getter/setter is a class-member declaration
|
||||
// (its name lives under method_signature), NOT a function-local — it must keep
|
||||
// its HAS_PROPERTY owner edge from the class.
|
||||
class Box {
|
||||
int normalField = 1;
|
||||
|
||||
int get answer => 42;
|
||||
|
||||
set answer(int v) {
|
||||
normalField = v;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/dart-coverage/typedefs.dart
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/dart-coverage/typedefs.dart
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
typedef int Cmp(int a, int b);
|
||||
typedef int Cmp2<T>(T a, T b);
|
||||
typedef Pred = bool Function(int);
|
||||
typedef Mapper<T> = T Function(T);
|
||||
typedef int _Internal(int);
|
||||
6
gitnexus/test/fixtures/lang-resolution/dart-static-fields/config.dart
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/dart-static-fields/config.dart
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class Config {
|
||||
static const int maxRetries = 3;
|
||||
static final String host = 'localhost', scheme = 'https';
|
||||
int port = 8080;
|
||||
static const _secret = 'hidden';
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/dart-toplevel-vars/globals.dart
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/dart-toplevel-vars/globals.dart
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
final int count = 3;
|
||||
var name = 'x';
|
||||
const a = 1, b = 2;
|
||||
|
||||
class Holder {
|
||||
int z = 0;
|
||||
}
|
||||
15
gitnexus/test/fixtures/lang-resolution/kotlin-companion-fields/Companions.kt
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/kotlin-companion-fields/Companions.kt
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package coverage
|
||||
|
||||
class C {
|
||||
companion object {
|
||||
const val TAG = "c"
|
||||
val instances = 0
|
||||
fun create() {}
|
||||
}
|
||||
}
|
||||
|
||||
class NamedComp {
|
||||
companion object Factory {
|
||||
val cfgX = 1
|
||||
}
|
||||
}
|
||||
15
gitnexus/test/fixtures/lang-resolution/kotlin-coverage/callable_refs.kt
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/kotlin-coverage/callable_refs.kt
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package coverage
|
||||
|
||||
fun topLevelFn(): Int = 1
|
||||
|
||||
class Obj {
|
||||
fun method(): String = "m"
|
||||
}
|
||||
|
||||
fun useCallableRefs() {
|
||||
val a = ::topLevelFn
|
||||
val b = String::length
|
||||
val obj = Obj()
|
||||
val c = obj::method
|
||||
val d = Type::new
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/kotlin-destructuring/Destructuring.kt
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/kotlin-destructuring/Destructuring.kt
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package coverage
|
||||
|
||||
fun useDestructuring(pair: Pair<Int, String>, map: Map<String, Int>) {
|
||||
val (a, b) = pair
|
||||
val (_, second) = pair
|
||||
for ((k, v) in map) { }
|
||||
val x = 1
|
||||
}
|
||||
31
gitnexus/test/fixtures/lang-resolution/kotlin-local-property-owner/Locals.kt
vendored
Normal file
31
gitnexus/test/fixtures/lang-resolution/kotlin-local-property-owner/Locals.kt
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package coverage
|
||||
|
||||
class C(val field: Int) {
|
||||
val classProp: Int = field
|
||||
|
||||
// CF3 review (#1919): destructuring inside an init {} block is a
|
||||
// function-local binding (anonymous_initializer is an executable body),
|
||||
// NOT a class member — it must not be owned by C.
|
||||
init {
|
||||
val (ix, iy) = field to field
|
||||
println(ix)
|
||||
println(iy)
|
||||
}
|
||||
|
||||
// ...and the same for locals inside a property accessor (getter) body.
|
||||
// `derived` is a genuine class property (owned); `gx`/`gy` are not.
|
||||
val derived: Int
|
||||
get() {
|
||||
val (gx, gy) = field to field
|
||||
return gx + gy
|
||||
}
|
||||
|
||||
fun process(map: Map<String, Int>) {
|
||||
for ((k, v) in map) {
|
||||
println(k)
|
||||
println(v)
|
||||
}
|
||||
val pair = Pair(1, 2)
|
||||
val (a, b) = pair
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/kotlin-secondary-ctor/Constructors.kt
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/kotlin-secondary-ctor/Constructors.kt
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package coverage
|
||||
|
||||
class Point(val x: Int) {
|
||||
constructor(a: Int, b: String) : this(a) { helper() }
|
||||
constructor() : this(0) { helper(); other() }
|
||||
fun describe(): String = "p"
|
||||
}
|
||||
|
||||
class OnlyPrimary(val v: Int) {
|
||||
fun method(): Int = v
|
||||
}
|
||||
|
||||
fun helper() {}
|
||||
fun other() {}
|
||||
22
gitnexus/test/fixtures/lang-resolution/swift-enum-members/Direction.swift
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/swift-enum-members/Direction.swift
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
enum Direction {
|
||||
case north
|
||||
case south
|
||||
|
||||
func describe() -> String {
|
||||
return "direction"
|
||||
}
|
||||
|
||||
var label: String {
|
||||
return "dir"
|
||||
}
|
||||
|
||||
static func make() -> Direction {
|
||||
return .north
|
||||
}
|
||||
}
|
||||
|
||||
class Compass {
|
||||
func heading() -> String {
|
||||
return "n"
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/swift-protocol-property/Repository.swift
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/swift-protocol-property/Repository.swift
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
protocol Repository {
|
||||
var title: String { get }
|
||||
var count: Int { get set }
|
||||
static var shared: Repository { get }
|
||||
}
|
||||
|
||||
class FileRepository {
|
||||
var name: String = ""
|
||||
}
|
||||
|
|
@ -59,6 +59,10 @@
|
|||
"captureGroups": 12,
|
||||
"digest": "a9c40a2dcb51c6e6a9adbaa869183eed90a81302729d4d7a0c300084ea2978e1"
|
||||
},
|
||||
"swift-enum-members/Direction.swift": {
|
||||
"captureGroups": 18,
|
||||
"digest": "6378c8860113e9d0331f4da4d05e64e6933a995493131d2f2165f64f82978b0b"
|
||||
},
|
||||
"swift-export-visibility/App.swift": {
|
||||
"captureGroups": 10,
|
||||
"digest": "6be49190e8a120ed1c3c3812afaa717f60fc12dae82d4b1b7158a358c53ea75a"
|
||||
|
|
@ -199,6 +203,10 @@
|
|||
"captureGroups": 10,
|
||||
"digest": "bd01b5adcd523ceee95772cc74ded0dbc1d70449fc9e9d17e975299dcbac783f"
|
||||
},
|
||||
"swift-protocol-property/Repository.swift": {
|
||||
"captureGroups": 7,
|
||||
"digest": "28e5a3c93f0dc2c0f940a6def1491d4a339e86fdc71858ecd61f4aea372afd6a"
|
||||
},
|
||||
"swift-qualified-base/Sources/Derived.swift": {
|
||||
"captureGroups": 4,
|
||||
"digest": "39e6ba35775ce624d1fb982204d2e2f0fcbbaf3046e317242602d9f245eb5a9d"
|
||||
|
|
|
|||
|
|
@ -94,6 +94,86 @@ withTestLbugDB(
|
|||
});
|
||||
});
|
||||
|
||||
// ─── closeLbug rejects pending waiters (#2068 follow-up) ─────────────
|
||||
//
|
||||
// Before the fix, closeOne() never rejected queued waiters: a caller
|
||||
// waiting for a free connection when the pool was closed (e.g. a staleness
|
||||
// reinit under concurrent query load) hung for WAITER_TIMEOUT_MS (15s) and
|
||||
// then surfaced a misleading "pool exhausted" error. Now they reject
|
||||
// immediately with an actionable "pool closed" message. The pool caps at
|
||||
// MAX_CONNS_PER_REPO (8); firing a synchronous burst larger than that queues
|
||||
// the surplus as waiters, and closing synchronously (before any query
|
||||
// settles) must reject every queued waiter at once. The default 5s test
|
||||
// timeout also guards promptness — a regression would block ~15s and time
|
||||
// out rather than reject.
|
||||
describe('closeLbug waiter handling (#2068)', () => {
|
||||
it('rejects queued waiters promptly with a pool-closed error on close', async () => {
|
||||
await initLbug('test-repo', handle.dbPath);
|
||||
|
||||
// Fire a burst larger than the 8-connection cap WITHOUT awaiting: the
|
||||
// first 8 check out connections synchronously, the surplus queue as
|
||||
// waiters — all before the synchronous closeLbug below runs.
|
||||
const BURST = 24;
|
||||
const MAX_CONNS = 8;
|
||||
const inflight = Array.from({ length: BURST }, () =>
|
||||
executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name'),
|
||||
);
|
||||
// Close in the same synchronous tick — no microtask has served a waiter.
|
||||
const closing = closeLbug('test-repo');
|
||||
|
||||
const settled = await Promise.allSettled(inflight);
|
||||
await closing;
|
||||
|
||||
const reasons = settled
|
||||
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
|
||||
.map((r) => String(r.reason?.message ?? r.reason));
|
||||
|
||||
// The surplus (BURST - MAX_CONNS) waiters must reject with "pool closed".
|
||||
const poolClosed = reasons.filter((m) => /pool closed/i.test(m));
|
||||
expect(poolClosed.length).toBeGreaterThanOrEqual(BURST - MAX_CONNS);
|
||||
// And none should have hit the 15s "exhausted" waiter-timeout path.
|
||||
expect(reasons.some((m) => /waiting for a free connection/i.test(m))).toBe(false);
|
||||
|
||||
expect(isLbugReady('test-repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('settles in-flight queries and fully tears down when closed mid-flight', async () => {
|
||||
// closeOne-vs-checkin interleave (F4b): with 8 connections in-flight and
|
||||
// surplus callers queued, a synchronous close must (a) let every promise
|
||||
// settle — no hang — and (b) fully delete the pool entry so checked-in
|
||||
// connections are closed as orphans rather than handed to a rejected
|
||||
// waiter. We assert the observable contract; the "orphan not handed to a
|
||||
// rejected waiter" invariant is single-threaded-by-construction (closeOne
|
||||
// drains waiters with no await before any checkin can run).
|
||||
await initLbug('test-repo', handle.dbPath);
|
||||
|
||||
const inflight = Array.from({ length: 16 }, () =>
|
||||
executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name'),
|
||||
);
|
||||
const closing = closeLbug('test-repo');
|
||||
|
||||
// allSettled only resolves once EVERY query settled — proving none hangs
|
||||
// (a 15s waiter-timeout regression would blow the default test timeout).
|
||||
const settled = await Promise.allSettled(inflight);
|
||||
await closing;
|
||||
expect(settled).toHaveLength(16);
|
||||
expect(
|
||||
settled.some(
|
||||
(r) =>
|
||||
r.status === 'rejected' &&
|
||||
/waiting for a free connection/i.test(String(r.reason?.message ?? r.reason)),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Pool entry fully gone — a subsequent query fails fast with the
|
||||
// not-initialized error, not a hang or a stale connection.
|
||||
expect(isLbugReady('test-repo')).toBe(false);
|
||||
await expect(executeQuery('test-repo', 'MATCH (n) RETURN n LIMIT 1')).rejects.toThrow(
|
||||
/not initialized/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('executeParameterized', () => {
|
||||
|
|
|
|||
87
gitnexus/test/integration/resolvers/c-coverage.test.ts
Normal file
87
gitnexus/test/integration/resolvers/c-coverage.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Regression tests for C/C++ scope-resolution coverage gaps (issue #1919).
|
||||
*
|
||||
* F5 — a computed `#include MACRO` must NOT become a literal import source.
|
||||
* The macro name is an `identifier` path node (not a header path), so emitting
|
||||
* it would create a garbage import edge. Literal `<stdio.h>` (system_lib_string)
|
||||
* and `"local.h"` (string_literal) includes must keep emitting correct sources.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { emitCScopeCaptures } from '../../../src/core/ingestion/languages/c/index.js';
|
||||
import { emitCppScopeCaptures } from '../../../src/core/ingestion/languages/cpp/index.js';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = path.resolve(
|
||||
here,
|
||||
'..',
|
||||
'..',
|
||||
'fixtures',
|
||||
'lang-resolution',
|
||||
'c-coverage',
|
||||
'main.c',
|
||||
);
|
||||
|
||||
function importSources(matches: readonly CaptureMatch[]): string[] {
|
||||
return matches
|
||||
.filter((m) => m['@import.source'] !== undefined)
|
||||
.map((m) => m['@import.source'].text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F5 — computed #include MACRO is not emitted as a literal import source (C)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('F5 — computed #include MACRO (C)', () => {
|
||||
const src = fs.readFileSync(FIXTURE, 'utf8');
|
||||
const matches = emitCScopeCaptures(src, 'main.c') as CaptureMatch[];
|
||||
const sources = importSources(matches);
|
||||
|
||||
it('emits import sources for literal <stdio.h> and "local.h"', () => {
|
||||
expect(sources).toContain('stdio.h');
|
||||
expect(sources).toContain('local.h');
|
||||
});
|
||||
|
||||
it('does NOT emit a garbage import source for #include HDR', () => {
|
||||
// The macro name and any expansion text must never surface as a source.
|
||||
expect(sources).not.toContain('HDR');
|
||||
expect(sources).not.toContain('computed.h');
|
||||
// Exactly the two literal includes — no spurious third source.
|
||||
expect(sources).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks the system header <stdio.h> as a system include', () => {
|
||||
const systemSources = matches
|
||||
.filter((m) => m['@import.system'] !== undefined)
|
||||
.map((m) => m['@import.source']?.text);
|
||||
expect(systemSources).toContain('stdio.h');
|
||||
expect(systemSources).not.toContain('local.h');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F5 — computed #include MACRO is not emitted as a literal import source (C++)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('F5 — computed #include MACRO (C++)', () => {
|
||||
// Inline C++ source mixing literal + computed includes — the cpp decomposer
|
||||
// path (splitCppInclude) is independently exercised here.
|
||||
const src =
|
||||
'#include <map>\n#include "User.h"\n#define HDR "computed.h"\n#include HDR\n\nint main() { return 0; }\n';
|
||||
const matches = emitCppScopeCaptures(src, 'main.cpp') as CaptureMatch[];
|
||||
const sources = importSources(matches);
|
||||
|
||||
it('emits import sources for literal <map> and "User.h"', () => {
|
||||
expect(sources).toContain('map');
|
||||
expect(sources).toContain('User.h');
|
||||
});
|
||||
|
||||
it('does NOT emit a garbage import source for #include HDR', () => {
|
||||
expect(sources).not.toContain('HDR');
|
||||
expect(sources).not.toContain('computed.h');
|
||||
expect(sources).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -893,6 +893,21 @@ describe('C++ structured binding in range-for', () => {
|
|||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
// F9 — a plain structured-binding declaration emits one Variable per bound name.
|
||||
it('emits a Variable for each name in `auto [firstId, secondId] = makePair();`', () => {
|
||||
const vars = getNodesByLabelFull(result, 'Variable').map((v) => v.name);
|
||||
expect(vars).toContain('firstId');
|
||||
expect(vars).toContain('secondId');
|
||||
});
|
||||
|
||||
it('classifies top-level structured-binding names as module scope', () => {
|
||||
const bound = getNodesByLabelFull(result, 'Variable').filter(
|
||||
(v) => v.name === 'firstId' || v.name === 'secondId',
|
||||
);
|
||||
expect(bound).toHaveLength(2);
|
||||
for (const v of bound) expect(v.properties.scope).toBe('module');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
118
gitnexus/test/integration/resolvers/dart-coverage.test.ts
Normal file
118
gitnexus/test/integration/resolvers/dart-coverage.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Regression tests for Dart scope-resolution / structure coverage gaps
|
||||
* (issue #1919). Mirrors python-parsing-coverage.test.ts: the F28 scope-capture
|
||||
* assertions exercise emitDartScopeCaptures directly, and a pipeline check
|
||||
* verifies the TypeAlias symbol exists end-to-end.
|
||||
*
|
||||
* F28 — old-style function typedef (`typedef int Cmp(int a, int b);`) was never
|
||||
* captured: DART_SCOPE_QUERY had no type_alias rule, and DART_QUERIES only
|
||||
* captured the new-style (`=`-anchored) form. Both forms must now surface as a
|
||||
* type-alias declaration / TypeAlias symbol.
|
||||
*
|
||||
* #1919 review CF2 — the GENERIC forms (`typedef int Cmp2<T>(T a, T b);` and
|
||||
* `typedef Mapper<T> = T Function(T);`) were still dropped: a generic
|
||||
* type_parameters node sits between the alias name and the next anchor, so the
|
||||
* non-generic adjacency patterns never matched. Standalone generic patterns now
|
||||
* capture them too.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { emitDartScopeCaptures } from '../../../src/core/ingestion/languages/dart/captures.js';
|
||||
import { FIXTURES, getNodesByLabel, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
import {
|
||||
isLanguageAvailable,
|
||||
loadParser,
|
||||
loadLanguage,
|
||||
} from '../../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
||||
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
|
||||
if (dartAvailable) {
|
||||
try {
|
||||
await loadParser();
|
||||
await loadLanguage(SupportedLanguages.Dart);
|
||||
} catch {
|
||||
dartAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPEDEFS = `typedef int Cmp(int a, int b);
|
||||
typedef int Cmp2<T>(T a, T b);
|
||||
typedef Pred = bool Function(int);
|
||||
typedef Mapper<T> = T Function(T);
|
||||
typedef int _Internal(int);`;
|
||||
|
||||
/** All @declaration.type_alias matches, as (name) tuples. */
|
||||
function typeAliasNames(src: string): string[] {
|
||||
const matches = emitDartScopeCaptures(src, 'test.dart') as CaptureMatch[];
|
||||
return matches
|
||||
.filter((m) => m['@declaration.type_alias'] !== undefined)
|
||||
.map((m) => m['@declaration.name']?.text)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F28 — typedef capture (scope layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!dartAvailable)('F28 — Dart typedef capture (scope layer)', () => {
|
||||
it('captures the old-style function typedef as a type-alias declaration', () => {
|
||||
const names = typeAliasNames(TYPEDEFS);
|
||||
expect(names).toContain('Cmp');
|
||||
});
|
||||
|
||||
it('still captures the new-style typedef (regression)', () => {
|
||||
const names = typeAliasNames(TYPEDEFS);
|
||||
expect(names).toContain('Pred');
|
||||
});
|
||||
|
||||
it('captures a private old-style typedef', () => {
|
||||
const names = typeAliasNames(TYPEDEFS);
|
||||
expect(names).toContain('_Internal');
|
||||
});
|
||||
|
||||
it('captures the generic old-style typedef (CF2)', () => {
|
||||
const names = typeAliasNames(TYPEDEFS);
|
||||
expect(names).toContain('Cmp2');
|
||||
});
|
||||
|
||||
it('captures the generic new-style typedef (CF2)', () => {
|
||||
const names = typeAliasNames(TYPEDEFS);
|
||||
expect(names).toContain('Mapper');
|
||||
});
|
||||
|
||||
it('emits exactly one declaration per typedef (no double-match)', () => {
|
||||
const names = typeAliasNames(TYPEDEFS);
|
||||
expect(names.sort()).toEqual(['Cmp', 'Cmp2', 'Pred', 'Mapper', '_Internal'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F28 — typedef symbols exist end-to-end (structure phase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!dartAvailable)('F28 — Dart typedef symbols (end-to-end)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-coverage'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('creates TypeAlias nodes for old-style, new-style, generic, and private typedefs', () => {
|
||||
const aliases = getNodesByLabel(result, 'TypeAlias');
|
||||
expect(aliases).toContain('Cmp'); // old-style (covers F28)
|
||||
expect(aliases).toContain('Cmp2'); // generic old-style (covers CF2)
|
||||
expect(aliases).toContain('Pred'); // new-style (regression)
|
||||
expect(aliases).toContain('Mapper'); // generic new-style (covers CF2)
|
||||
expect(aliases).toContain('_Internal'); // private old-style
|
||||
});
|
||||
|
||||
it('emits exactly one TypeAlias per typedef (no duplicates)', () => {
|
||||
const aliases = getNodesByLabel(result, 'TypeAlias');
|
||||
const fromFixture = aliases.filter((n) =>
|
||||
['Cmp', 'Cmp2', 'Pred', 'Mapper', '_Internal'].includes(n),
|
||||
);
|
||||
expect(fromFixture.sort()).toEqual(['Cmp', 'Cmp2', 'Mapper', 'Pred', '_Internal'].sort());
|
||||
});
|
||||
});
|
||||
|
|
@ -707,6 +707,118 @@ describe.skipIf(!dartAvailable)('Dart named-constructor body (no file drop)', ()
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F26 (issue #1919): static const / static final class fields.
|
||||
// `static const`/`static final` fields parse with a static_final_declaration_list
|
||||
// (not initialized_identifier_list), so the legacy field rules missed them and
|
||||
// no Property node was created end-to-end. They must surface as Property nodes
|
||||
// marked static + readonly, one per name in a multi-name declaration.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!dartAvailable)('Dart static const/final fields (F26)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-static-fields'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('captures static const and static final fields as Properties', () => {
|
||||
const properties = getNodesByLabel(result, 'Property');
|
||||
expect(properties).toContain('maxRetries'); // static const
|
||||
expect(properties).toContain('host'); // static final, name 1 of 2
|
||||
expect(properties).toContain('scheme'); // static final, name 2 of 2
|
||||
expect(properties).toContain('port'); // instance field (regression)
|
||||
expect(properties).toContain('_secret'); // private static const
|
||||
});
|
||||
|
||||
it('emits HAS_PROPERTY edges for static fields', () => {
|
||||
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
||||
expect(edgeSet(propEdges)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Config → maxRetries',
|
||||
'Config → host',
|
||||
'Config → scheme',
|
||||
'Config → port',
|
||||
'Config → _secret',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks static const/final fields as static + readonly', () => {
|
||||
const props = getNodesByLabelFull(result, 'Property');
|
||||
for (const name of ['maxRetries', 'host', 'scheme', '_secret']) {
|
||||
const p = props.find((n) => n.name === name);
|
||||
expect(p, name).toBeDefined();
|
||||
expect(p!.properties.isStatic, name).toBe(true);
|
||||
expect(p!.properties.isReadonly, name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the instance field non-static, non-readonly (regression)', () => {
|
||||
const props = getNodesByLabelFull(result, 'Property');
|
||||
const port = props.find((n) => n.name === 'port');
|
||||
expect(port).toBeDefined();
|
||||
expect(port!.properties.isStatic).toBe(false);
|
||||
expect(port!.properties.isReadonly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F29 (issue #1919): top-level Dart variables. Top-level vars are loose
|
||||
// siblings under `program` (no `declaration` wrapper), so the structure query
|
||||
// never captured them and no Variable node existed end-to-end. They must now
|
||||
// surface as Variable nodes with the real type/const metadata read from the
|
||||
// captured container's leading siblings (not a phantom `type` field).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!dartAvailable)('Dart top-level variables (F29)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-toplevel-vars'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('captures top-level variables (typed final, inferred var, multi-name const)', () => {
|
||||
const vars = getNodesByLabel(result, 'Variable');
|
||||
expect(vars).toContain('count'); // final int count = 3; (covers F29)
|
||||
expect(vars).toContain('name'); // var name = 'x';
|
||||
expect(vars).toContain('a'); // const a = 1, b = 2;
|
||||
expect(vars).toContain('b');
|
||||
});
|
||||
|
||||
it('reads the real type for a typed final and leaves an inferred var untyped', () => {
|
||||
const vars = getNodesByLabelFull(result, 'Variable');
|
||||
const count = vars.find((n) => n.name === 'count');
|
||||
expect(count).toBeDefined();
|
||||
expect(count!.properties.declaredType).toBe('int');
|
||||
expect(count!.properties.isConst).toBe(true);
|
||||
|
||||
const name = vars.find((n) => n.name === 'name');
|
||||
expect(name).toBeDefined();
|
||||
// inferred `var` → no declaredType from a phantom field; mutable.
|
||||
expect(name!.properties.declaredType).toBeUndefined();
|
||||
expect(name!.properties.isMutable).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the class instance field as a Property, not a Variable (regression)', () => {
|
||||
const vars = getNodesByLabel(result, 'Variable');
|
||||
expect(vars).not.toContain('z');
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
expect(props).toContain('z');
|
||||
});
|
||||
|
||||
it('does NOT emit top-level vars as Property nodes (#1919 review CF4)', () => {
|
||||
// Guards the `(program …)` vs `(declaration …)` anchor split: top-level
|
||||
// siblings under `program` must surface as Variable, never Property. If the
|
||||
// top-level anchor regressed to the class-field `(declaration …)` rule, these
|
||||
// names would mis-classify as class Properties.
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
for (const name of ['count', 'a', 'b', 'name']) {
|
||||
expect(props).not.toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage cross-file simple-name collision (PR #1970 tri-review P2).
|
||||
// console_logger.dart and file_logger.dart each declare `class Logger`; each
|
||||
|
|
@ -736,3 +848,26 @@ describe.skipIf(!dartAvailable)('Dart heritage cross-file name collision', () =>
|
|||
expect(fileEdge!.targetFilePath).toContain('file_logger.dart');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CF3 (#1919 review): Dart class getters/setters keep their class owner edge.
|
||||
// ---------------------------------------------------------------------------
|
||||
// A Dart accessor's name lives under `method_signature`; the CF3 owner-strip
|
||||
// guard must NOT treat that signature as an executable body and strip the
|
||||
// HAS_PROPERTY owner (the over-strip regression this guards against).
|
||||
describe('CF3 — Dart class accessors keep their class owner', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-accessor-owner'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('owns the getter/setter property `answer` and the stored field under Box', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY')
|
||||
.filter((e) => e.source === 'Box')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(owned).toContain('answer');
|
||||
expect(owned).toContain('normalField');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
176
gitnexus/test/integration/resolvers/kotlin-coverage.test.ts
Normal file
176
gitnexus/test/integration/resolvers/kotlin-coverage.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Regression tests for Kotlin parsing-layer coverage gaps (issue #1919).
|
||||
*
|
||||
* Mirrors dart-coverage.test.ts / python-parsing-coverage.test.ts: the scope-layer
|
||||
* assertions exercise emitKotlinScopeCaptures directly; F49 also exercises the
|
||||
* legacy KOTLIN_QUERIES structure-query bank (the live spurious-edge source).
|
||||
*
|
||||
* F47 — callable references (`::method`, `Type::new`, `obj::method`) were never
|
||||
* captured: KOTLIN_SCOPE_QUERY had no callable_reference rule, so they never
|
||||
* participated in call-graph resolution.
|
||||
*
|
||||
* F49 — the legacy KOTLIN_QUERIES infix rule captured ALL three simple_identifier
|
||||
* children of an infix_expression (`a to b` → `a`, `to`, `b`) as @call.name,
|
||||
* emitting spurious call references for the operands. The fix anchors the
|
||||
* capture to the operator (the middle child) only.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import Parser from 'tree-sitter';
|
||||
import Kotlin from 'tree-sitter-kotlin';
|
||||
import { emitKotlinScopeCaptures } from '../../../src/core/ingestion/languages/kotlin/captures.js';
|
||||
import { KOTLIN_QUERIES } from '../../../src/core/ingestion/tree-sitter-queries.js';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F47 — callable references (scope layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CALLABLE_REFS = `fun useCallableRefs() {
|
||||
val a = ::topLevelFn
|
||||
val b = String::length
|
||||
val c = obj::method
|
||||
val d = Type::new
|
||||
}`;
|
||||
|
||||
/** All call references emitted for the source, as { name, receiver, form }. */
|
||||
function callReferences(
|
||||
src: string,
|
||||
): Array<{ name: string; receiver?: string; form: 'free' | 'member' }> {
|
||||
const matches = emitKotlinScopeCaptures(src, 'test.kt') as CaptureMatch[];
|
||||
const out: Array<{ name: string; receiver?: string; form: 'free' | 'member' }> = [];
|
||||
for (const m of matches) {
|
||||
if (m['@reference.call.free'] !== undefined && m['@reference.name'] !== undefined) {
|
||||
out.push({ name: m['@reference.name'].text, form: 'free' });
|
||||
} else if (m['@reference.call.member'] !== undefined && m['@reference.name'] !== undefined) {
|
||||
out.push({
|
||||
name: m['@reference.name'].text,
|
||||
receiver: m['@reference.receiver']?.text,
|
||||
form: 'member',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('F47 — Kotlin callable references (scope layer)', () => {
|
||||
it('captures a bare `::topLevelFn` reference as a free call', () => {
|
||||
const refs = callReferences(CALLABLE_REFS);
|
||||
const free = refs.find((r) => r.name === 'topLevelFn');
|
||||
expect(free).toBeDefined();
|
||||
expect(free!.form).toBe('free');
|
||||
});
|
||||
|
||||
it('captures `String::length` as a member call with receiver String', () => {
|
||||
const refs = callReferences(CALLABLE_REFS);
|
||||
const ref = refs.find((r) => r.name === 'length');
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref!.form).toBe('member');
|
||||
expect(ref!.receiver).toBe('String');
|
||||
});
|
||||
|
||||
it('captures `obj::method` as a member call with receiver obj', () => {
|
||||
const refs = callReferences(CALLABLE_REFS);
|
||||
const ref = refs.find((r) => r.name === 'method');
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref!.form).toBe('member');
|
||||
expect(ref!.receiver).toBe('obj');
|
||||
});
|
||||
|
||||
it('captures `Type::new` (constructor reference) as a member call with receiver Type', () => {
|
||||
const refs = callReferences(CALLABLE_REFS);
|
||||
const ref = refs.find((r) => r.name === 'new');
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref!.form).toBe('member');
|
||||
expect(ref!.receiver).toBe('Type');
|
||||
});
|
||||
|
||||
it('emits exactly one call reference per callable_reference (no double-match)', () => {
|
||||
const refs = callReferences(CALLABLE_REFS).filter((r) =>
|
||||
['topLevelFn', 'length', 'method', 'new'].includes(r.name),
|
||||
);
|
||||
expect(refs.map((r) => r.name).sort()).toEqual(['length', 'method', 'new', 'topLevelFn']);
|
||||
});
|
||||
|
||||
it('does not capture the receiver type as its own free call', () => {
|
||||
const refs = callReferences(CALLABLE_REFS);
|
||||
// String / Type / obj are receivers, never standalone call targets.
|
||||
expect(refs.some((r) => r.form === 'free' && r.name === 'String')).toBe(false);
|
||||
expect(refs.some((r) => r.form === 'free' && r.name === 'Type')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F47 — callable references resolve to CALLS edges end-to-end (worker path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('F47 — Kotlin callable references (end-to-end)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-coverage'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves a bare `::topLevelFn` reference to a CALLS edge on the local function', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const ref = calls.find((c) => c.source === 'useCallableRefs' && c.target === 'topLevelFn');
|
||||
expect(ref).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves an `obj::method` member reference to a CALLS edge on Obj.method', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const ref = calls.find((c) => c.source === 'useCallableRefs' && c.target === 'method');
|
||||
expect(ref).toBeDefined();
|
||||
});
|
||||
|
||||
it('runs through the worker pool (parity: capture edits survive the worker boundary)', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F49 — infix-call query captures only the operator (legacy structure bank)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Characterized end-to-end (issue #1919): the live spurious @call.name edges
|
||||
// for `a to b` originate from the legacy KOTLIN_QUERIES bank (still wired as
|
||||
// provider.treeSitterQueries / used by the worker structure phase). The
|
||||
// registry KOTLIN_SCOPE_QUERY has no infix rule, so the fix is in
|
||||
// tree-sitter-queries.ts only. These tests compile that live query and assert
|
||||
// the call captures directly.
|
||||
|
||||
/** @call.name capture texts produced by the live KOTLIN_QUERIES structure bank. */
|
||||
function structureCallNames(src: string): string[] {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Kotlin as Parameters<Parser['setLanguage']>[0]);
|
||||
const query = new Parser.Query(Kotlin as Parameters<Parser['setLanguage']>[0], KOTLIN_QUERIES);
|
||||
const tree = parser.parse(src);
|
||||
const names: string[] = [];
|
||||
for (const match of query.matches(tree.rootNode)) {
|
||||
for (const c of match.captures) {
|
||||
if (c.name === 'call.name') names.push(c.node.text);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe('F49 — Kotlin infix call captures only the operator', () => {
|
||||
it('`val p = a to b` captures exactly one call (`to`), zero for the operands', () => {
|
||||
const names = structureCallNames(`fun f() {\n val p = a to b\n}`);
|
||||
expect(names).toEqual(['to']);
|
||||
});
|
||||
|
||||
it('`a to b to c` captures only the `to` operators, never the operands', () => {
|
||||
const names = structureCallNames(`fun f() {\n val q = a to b to c\n}`);
|
||||
expect(names.sort()).toEqual(['to', 'to']);
|
||||
expect(names.includes('a')).toBe(false);
|
||||
expect(names.includes('b')).toBe(false);
|
||||
expect(names.includes('c')).toBe(false);
|
||||
});
|
||||
|
||||
it('a normal call `foo(a, b)` still produces exactly one call to `foo`', () => {
|
||||
const names = structureCallNames(`fun f() {\n foo(a, b)\n}`);
|
||||
expect(names).toEqual(['foo']);
|
||||
});
|
||||
});
|
||||
|
|
@ -2672,3 +2672,231 @@ describe('Kotlin isStaticOnly across other receiver cases (#1756 / U3)', () => {
|
|||
expect(createCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F48 (issue #1919): secondary constructors are extracted as members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('F48 — Kotlin secondary constructors', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-secondary-ctor'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('creates a Constructor node for each secondary constructor', () => {
|
||||
// Point declares two secondary constructors; both surface as Constructors.
|
||||
const ctors = getNodesByLabel(result, 'Constructor');
|
||||
expect(ctors).toEqual(['constructor', 'constructor']);
|
||||
});
|
||||
|
||||
it('owns both secondary constructors under the enclosing class Point', () => {
|
||||
const owned = getRelationships(result, 'HAS_METHOD').filter(
|
||||
(e) => e.targetLabel === 'Constructor',
|
||||
);
|
||||
expect(owned.length).toBe(2);
|
||||
expect(owned.every((e) => e.source === 'Point')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not synthesize a constructor for a class with only a primary constructor (no double-count)', () => {
|
||||
// OnlyPrimary has a primary ctor + one method, and must yield no Constructor node.
|
||||
const ctorOwners = getRelationships(result, 'HAS_METHOD')
|
||||
.filter((e) => e.targetLabel === 'Constructor')
|
||||
.map((e) => e.source);
|
||||
expect(ctorOwners).not.toContain('OnlyPrimary');
|
||||
// Its regular method is still extracted.
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('method');
|
||||
});
|
||||
|
||||
// ── CF1 (#1919 review): secondary-ctor body calls attribute to the Constructor ──
|
||||
// The fixture's two secondary constructors call free functions in their bodies:
|
||||
// constructor(a: Int, b: String) : this(a) { helper() } // arity 2
|
||||
// constructor() : this(0) { helper(); other() } // arity 0
|
||||
// Each body call must source from ITS OWN Constructor node (with the correct
|
||||
// arity suffix), NOT from the File node and NOT from the enclosing Class.
|
||||
it('attributes a secondary-constructor body call to the Constructor node, not File or Class', () => {
|
||||
const helperCalls = getRelationships(result, 'CALLS').filter((e) => e.target === 'helper');
|
||||
// helper() is called from both secondary constructors.
|
||||
expect(helperCalls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of helperCalls) {
|
||||
expect(call.sourceLabel).toBe('Constructor');
|
||||
expect(call.sourceLabel).not.toBe('File');
|
||||
expect(call.sourceLabel).not.toBe('Class');
|
||||
}
|
||||
});
|
||||
|
||||
it('disambiguates secondary-ctor body calls by arity (#<arity> Constructor node id)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
// `other()` is only called from the zero-arg `constructor()` body → must
|
||||
// source from the arity-0 Constructor node id, never the arity-2 one.
|
||||
const otherCall = calls.find((e) => e.target === 'other');
|
||||
expect(otherCall).toBeDefined();
|
||||
expect(otherCall!.sourceLabel).toBe('Constructor');
|
||||
expect(otherCall!.rel.sourceId).toBe('Constructor:Constructors.kt:Point.constructor#0');
|
||||
|
||||
// `helper()` is called from BOTH constructors; the set of caller ids must be
|
||||
// exactly the two distinct arity-tagged Constructor nodes (no collapse onto one).
|
||||
const helperSourceIds = new Set(
|
||||
calls.filter((e) => e.target === 'helper').map((e) => e.rel.sourceId),
|
||||
);
|
||||
expect(helperSourceIds).toEqual(
|
||||
new Set([
|
||||
'Constructor:Constructors.kt:Point.constructor#0',
|
||||
'Constructor:Constructors.kt:Point.constructor#2',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('still attributes a normal method body call to the Method (regression guard)', () => {
|
||||
// `describe()` is an expression-body method with no call; add a sibling check
|
||||
// that no secondary-ctor regression mis-routes method-owned calls. The Method
|
||||
// node for `describe` exists and is owned by Point.
|
||||
const describeOwned = getRelationships(result, 'HAS_METHOD').filter(
|
||||
(e) => e.target === 'describe' && e.source === 'Point',
|
||||
);
|
||||
expect(describeOwned.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F51 (issue #1919): destructuring declarations emit one binding per name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('F51 — Kotlin destructuring declarations', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-destructuring'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('emits one binding per destructured name in `val (a, b) = pair`', () => {
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
expect(props).toContain('a');
|
||||
expect(props).toContain('b');
|
||||
});
|
||||
|
||||
it('emits bindings for loop destructuring `for ((k, v) in map)`', () => {
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
expect(props).toContain('k');
|
||||
expect(props).toContain('v');
|
||||
});
|
||||
|
||||
it('skips the `_` discard placeholder but keeps `second`', () => {
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
expect(props).toContain('second');
|
||||
expect(props).not.toContain('_');
|
||||
});
|
||||
|
||||
it('emits exactly the expected binding set (no double-count, plain `val x` once)', () => {
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
expect(props).toEqual(['a', 'b', 'k', 'second', 'v', 'x']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CF3 (#1919 review): function-local property bindings are NOT class members
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kotlin emits destructuring / loop bindings as `@definition.property` to dodge
|
||||
// the block-scope local-symbol pruner. When such a binding sits inside a METHOD
|
||||
// body of a class, it must NOT receive a HAS_PROPERTY owner edge from the class —
|
||||
// it is a function-local, not a class field. Genuine class fields (primary-ctor
|
||||
// `val` params and class-body `val`/`var`) must still be owned by the class.
|
||||
|
||||
describe('CF3 — Kotlin function-local bindings are not class properties', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'kotlin-local-property-owner'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('does NOT own loop-destructuring bindings (k, v) under the enclosing class C', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY')
|
||||
.filter((e) => e.source === 'C')
|
||||
.map((e) => e.target);
|
||||
expect(owned).not.toContain('k');
|
||||
expect(owned).not.toContain('v');
|
||||
});
|
||||
|
||||
it('does NOT own a `val (a, b) = pair` destructuring binding under class C', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY')
|
||||
.filter((e) => e.source === 'C')
|
||||
.map((e) => e.target);
|
||||
expect(owned).not.toContain('a');
|
||||
expect(owned).not.toContain('b');
|
||||
// The intermediate `val pair` local is likewise not a class property.
|
||||
expect(owned).not.toContain('pair');
|
||||
});
|
||||
|
||||
it('does NOT own destructuring inside an init {} block (ix, iy) under class C', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY')
|
||||
.filter((e) => e.source === 'C')
|
||||
.map((e) => e.target);
|
||||
expect(owned).not.toContain('ix');
|
||||
expect(owned).not.toContain('iy');
|
||||
});
|
||||
|
||||
it('does NOT own destructuring inside a property accessor body (gx, gy) under class C', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY')
|
||||
.filter((e) => e.source === 'C')
|
||||
.map((e) => e.target);
|
||||
expect(owned).not.toContain('gx');
|
||||
expect(owned).not.toContain('gy');
|
||||
});
|
||||
|
||||
it('still owns genuine class fields + the computed property under C, nothing else', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY')
|
||||
.filter((e) => e.source === 'C')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
// Exact set: catches both over-strip (a real member dropped) and under-strip
|
||||
// (a function-local wrongly owned).
|
||||
expect(owned).toEqual(['classProp', 'derived', 'field']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F52 (issue #1919): companion-object properties are indexed as fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('F52 — Kotlin companion-object properties', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-companion-fields'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('indexes anonymous-companion `const val TAG` as a static, readonly field', () => {
|
||||
const tag = getNodesByLabelFull(result, 'Property').find((n) => n.name === 'TAG');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag!.properties.isStatic).toBe(true);
|
||||
expect(tag!.properties.isReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('indexes a NAMED-companion property `cfgX` as a field', () => {
|
||||
const x = getNodesByLabelFull(result, 'Property').find((n) => n.name === 'cfgX');
|
||||
expect(x).toBeDefined();
|
||||
expect(x!.properties.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('emits each companion field exactly once (no double emission)', () => {
|
||||
// Exact field set + a one-per-name count guards against the companion-scope
|
||||
// machinery re-emitting the same property.
|
||||
const props = getNodesByLabel(result, 'Property');
|
||||
expect(props).toEqual(['TAG', 'cfgX', 'instances']);
|
||||
expect(props.filter((p) => p === 'TAG')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('owns anonymous-companion fields on the ENCLOSING class C (companion function is not a field)', () => {
|
||||
const owned = getRelationships(result, 'HAS_PROPERTY');
|
||||
const cFields = owned
|
||||
.filter((e) => e.source === 'C')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(cFields).toEqual(['TAG', 'instances']);
|
||||
// The companion's `create` function is a Method, never a Property/field.
|
||||
expect(getNodesByLabel(result, 'Property')).not.toContain('create');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1250,3 +1250,110 @@ describe.skipIf(!swiftAvailable)('Swift nested-type extension (extension Foo.Bar
|
|||
expect(baseCall!.rel.targetId).toBe('Function:Types.swift:Bar.base#0');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F75: protocol property requirements (`var title: String { get }`) are
|
||||
// extracted as Property symbols owned by the protocol. Before the fix these
|
||||
// protocol_property_declaration nodes were dropped (the structure query and
|
||||
// field config only knew property_declaration).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!swiftAvailable)('Swift protocol property requirements (F75)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-protocol-property'), () => {}, {
|
||||
skipGraphPhases: true,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
it('detects the Repository protocol and its property requirements', () => {
|
||||
expect(getNodesByLabel(result, 'Interface')).toContain('Repository');
|
||||
const properties = getNodesByLabel(result, 'Property');
|
||||
expect(properties).toContain('title');
|
||||
expect(properties).toContain('count');
|
||||
expect(properties).toContain('shared');
|
||||
});
|
||||
|
||||
it('emits HAS_PROPERTY edges from the protocol to each requirement', () => {
|
||||
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
||||
expect(edgeSet(propEdges)).toEqual(
|
||||
expect.arrayContaining(['Repository → title', 'Repository → count', 'Repository → shared']),
|
||||
);
|
||||
});
|
||||
|
||||
it('populates type + static metadata on protocol requirement Property nodes', () => {
|
||||
const properties = getNodesByLabelFull(result, 'Property');
|
||||
|
||||
const title = properties.find(
|
||||
(p) => p.name === 'title' && p.properties.filePath === 'Repository.swift',
|
||||
);
|
||||
expect(title).toBeDefined();
|
||||
expect(title!.properties.declaredType).toBe('String');
|
||||
expect(title!.properties.isStatic).toBe(false);
|
||||
|
||||
const count = properties.find(
|
||||
(p) => p.name === 'count' && p.properties.filePath === 'Repository.swift',
|
||||
);
|
||||
expect(count).toBeDefined();
|
||||
expect(count!.properties.declaredType).toBe('Int');
|
||||
|
||||
const shared = properties.find(
|
||||
(p) => p.name === 'shared' && p.properties.filePath === 'Repository.swift',
|
||||
);
|
||||
expect(shared).toBeDefined();
|
||||
expect(shared!.properties.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('still extracts the class stored property exactly once (regression)', () => {
|
||||
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
||||
const nameEdges = propEdges.filter((e) => e.target === 'name' && e.source === 'FileRepository');
|
||||
expect(nameEdges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F79: methods/members declared inside a Swift enum (enum_class_body) are
|
||||
// extracted via the proper body-node path. Before the fix they only resolved
|
||||
// through the generic findBodies fallback, which logs a dev-mode warning.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!swiftAvailable)('Swift enum members (F79)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-enum-members'), () => {}, {
|
||||
skipGraphPhases: true,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
it('extracts enum methods owned by the enum', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const enumMethods = hasMethod
|
||||
.filter((e) => e.source === 'Direction')
|
||||
.map((e) => e.target)
|
||||
.sort();
|
||||
expect(enumMethods).toContain('describe');
|
||||
expect(enumMethods).toContain('make');
|
||||
});
|
||||
|
||||
it('extracts each enum method exactly once (no double-count)', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const describeEdges = hasMethod.filter(
|
||||
(e) => e.target === 'describe' && e.source === 'Direction',
|
||||
);
|
||||
expect(describeEdges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('extracts an enum computed property as a Property of the enum', () => {
|
||||
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
||||
const labelEdge = propEdges.find((e) => e.target === 'label' && e.source === 'Direction');
|
||||
expect(labelEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it('still extracts class methods (no regression / double-count)', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const headingEdges = hasMethod.filter((e) => e.target === 'heading' && e.source === 'Compass');
|
||||
expect(headingEdges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createASTCache, type ASTCache } from '../../src/core/ingestion/ast-cache.js';
|
||||
|
||||
// Create a minimal mock tree object (mimics Parser.Tree interface)
|
||||
function mockTree(id: string): any {
|
||||
return { rootNode: { type: 'program', text: id }, delete: vi.fn() };
|
||||
}
|
||||
|
||||
describe('ASTCache', () => {
|
||||
let cache: ASTCache;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = createASTCache(3);
|
||||
});
|
||||
|
||||
describe('get / set', () => {
|
||||
it('returns undefined for cache miss', () => {
|
||||
expect(cache.get('nonexistent.ts')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns cached tree on hit', () => {
|
||||
const tree = mockTree('test');
|
||||
cache.set('src/index.ts', tree);
|
||||
expect(cache.get('src/index.ts')).toBe(tree);
|
||||
});
|
||||
|
||||
it('overwrites existing entry for same key', () => {
|
||||
const tree1 = mockTree('v1');
|
||||
const tree2 = mockTree('v2');
|
||||
cache.set('src/index.ts', tree1);
|
||||
cache.set('src/index.ts', tree2);
|
||||
expect(cache.get('src/index.ts')).toBe(tree2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LRU eviction', () => {
|
||||
it('evicts least recently used when capacity exceeded', () => {
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
cache.set('c.ts', mockTree('c'));
|
||||
// Cache is full (maxSize=3). Adding one more evicts 'a'
|
||||
cache.set('d.ts', mockTree('d'));
|
||||
expect(cache.get('a.ts')).toBeUndefined();
|
||||
expect(cache.get('b.ts')).toBeDefined();
|
||||
expect(cache.get('d.ts')).toBeDefined();
|
||||
});
|
||||
|
||||
it('accessing an entry makes it recently used', () => {
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
cache.set('c.ts', mockTree('c'));
|
||||
// Touch 'a' to make it recently used
|
||||
cache.get('a.ts');
|
||||
// Now 'b' is LRU
|
||||
cache.set('d.ts', mockTree('d'));
|
||||
expect(cache.get('a.ts')).toBeDefined();
|
||||
expect(cache.get('b.ts')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
cache.clear();
|
||||
expect(cache.get('a.ts')).toBeUndefined();
|
||||
expect(cache.get('b.ts')).toBeUndefined();
|
||||
expect(cache.stats().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('reports size and maxSize', () => {
|
||||
expect(cache.stats()).toEqual({ size: 0, maxSize: 3 });
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
expect(cache.stats()).toEqual({ size: 1, maxSize: 3 });
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
expect(cache.stats()).toEqual({ size: 2, maxSize: 3 });
|
||||
});
|
||||
|
||||
it('uses default maxSize of 50', () => {
|
||||
const defaultCache = createASTCache();
|
||||
expect(defaultCache.stats().maxSize).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps maxSize of 0 to 1 to prevent LRU cache error', () => {
|
||||
const zeroCache = createASTCache(0);
|
||||
expect(zeroCache.stats().maxSize).toBe(1);
|
||||
// Should still function correctly
|
||||
const tree = mockTree('test');
|
||||
zeroCache.set('a.ts', tree);
|
||||
expect(zeroCache.get('a.ts')).toBe(tree);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,9 @@ import { pythonConfig } from '../../src/core/ingestion/field-extractors/configs/
|
|||
import { goConfig } from '../../src/core/ingestion/field-extractors/configs/go.js';
|
||||
import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-cpp.js';
|
||||
import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js';
|
||||
import { dartConfig } from '../../src/core/ingestion/field-extractors/configs/dart.js';
|
||||
import { kotlinConfig } from '../../src/core/ingestion/field-extractors/configs/jvm.js';
|
||||
import { swiftConfig } from '../../src/core/ingestion/field-extractors/configs/swift.js';
|
||||
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
|
||||
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
|
||||
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
|
||||
|
|
@ -16,6 +19,21 @@ import Go from 'tree-sitter-go';
|
|||
import Cpp from 'tree-sitter-cpp';
|
||||
import Ruby from 'tree-sitter-ruby';
|
||||
import CSharp from 'tree-sitter-c-sharp';
|
||||
import Dart from 'tree-sitter-dart';
|
||||
|
||||
let Kotlin: unknown;
|
||||
try {
|
||||
Kotlin = require('tree-sitter-kotlin');
|
||||
} catch {
|
||||
// Kotlin grammar may not be installed
|
||||
}
|
||||
|
||||
let Swift: unknown;
|
||||
try {
|
||||
Swift = require('tree-sitter-swift');
|
||||
} catch {
|
||||
// Swift grammar is an optional dependency; may not be installed
|
||||
}
|
||||
import { csharpConfig as csharpFieldConfig } from '../../src/core/ingestion/field-extractors/configs/csharp.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
|
||||
|
|
@ -1073,3 +1091,279 @@ describe('GenericFieldExtractor — C# primary constructor fields', () => {
|
|||
expect(countField).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dart config — F26: static const / static final class fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GenericFieldExtractor — Dart', () => {
|
||||
const parser = new Parser();
|
||||
const extractor = createFieldExtractor(dartConfig);
|
||||
const mockContext = createMockContext();
|
||||
mockContext.language = SupportedLanguages.Dart;
|
||||
mockContext.filePath = 'test.dart';
|
||||
|
||||
it('extracts a static const field as static + readonly (F26)', () => {
|
||||
parser.setLanguage(Dart);
|
||||
const tree = parser.parse(`class C {
|
||||
static const a = 1;
|
||||
}`);
|
||||
const classNode = tree.rootNode.child(0);
|
||||
expect(classNode).toBeDefined();
|
||||
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
|
||||
|
||||
const result = extractor.extract(classNode!, mockContext);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerFqn).toBe('C');
|
||||
const a = result!.fields.find((f) => f.name === 'a');
|
||||
expect(a).toBeDefined();
|
||||
expect(a!.isStatic).toBe(true);
|
||||
expect(a!.isReadonly).toBe(true);
|
||||
expect(a!.visibility).toBe('public');
|
||||
});
|
||||
|
||||
it('extracts multi-name static final fields, all static + readonly (F26)', () => {
|
||||
parser.setLanguage(Dart);
|
||||
const tree = parser.parse(`class C {
|
||||
static final String b = 'x', c = 'y';
|
||||
}`);
|
||||
const classNode = tree.rootNode.child(0);
|
||||
const result = extractor.extract(classNode!, mockContext);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Exact-count guard (#1919 review CF4): `find()` below passes even on a
|
||||
// double-emit, so assert b/c surface exactly twice total — one field each,
|
||||
// no duplicate from the static_final_declaration_list multi-name path.
|
||||
expect(result!.fields.filter((f) => f.name === 'b' || f.name === 'c')).toHaveLength(2);
|
||||
const b = result!.fields.find((f) => f.name === 'b');
|
||||
const c = result!.fields.find((f) => f.name === 'c');
|
||||
expect(b).toBeDefined();
|
||||
expect(c).toBeDefined();
|
||||
for (const f of [b!, c!]) {
|
||||
expect(f.isStatic).toBe(true);
|
||||
expect(f.isReadonly).toBe(true);
|
||||
expect(f.type).toBe('String');
|
||||
}
|
||||
});
|
||||
|
||||
it('still extracts instance fields (regression)', () => {
|
||||
parser.setLanguage(Dart);
|
||||
const tree = parser.parse(`class C {
|
||||
int z = 0;
|
||||
}`);
|
||||
const classNode = tree.rootNode.child(0);
|
||||
const result = extractor.extract(classNode!, mockContext);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const z = result!.fields.find((f) => f.name === 'z');
|
||||
expect(z).toBeDefined();
|
||||
expect(z!.isStatic).toBe(false);
|
||||
expect(z!.isReadonly).toBe(false);
|
||||
expect(z!.type).toBe('int');
|
||||
});
|
||||
|
||||
it('marks an underscore-prefixed static const as private (F26)', () => {
|
||||
parser.setLanguage(Dart);
|
||||
const tree = parser.parse(`class C {
|
||||
static const _p = 1;
|
||||
}`);
|
||||
const classNode = tree.rootNode.child(0);
|
||||
const result = extractor.extract(classNode!, mockContext);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const p = result!.fields.find((f) => f.name === '_p');
|
||||
expect(p).toBeDefined();
|
||||
expect(p!.visibility).toBe('private');
|
||||
expect(p!.isStatic).toBe(true);
|
||||
expect(p!.isReadonly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kotlin config — F52: companion-object properties indexed as fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const describeKotlin = Kotlin ? describe : describe.skip;
|
||||
|
||||
describeKotlin('GenericFieldExtractor — Kotlin (F52 companion)', () => {
|
||||
const parser = new Parser();
|
||||
const extractor = createFieldExtractor(kotlinConfig);
|
||||
const mockContext = createMockContext();
|
||||
mockContext.language = SupportedLanguages.Kotlin;
|
||||
mockContext.filePath = 'test.kt';
|
||||
|
||||
/** The first companion_object node in `src`. */
|
||||
function companion(src: string): Parser.SyntaxNode {
|
||||
parser.setLanguage(Kotlin as Parser.Language);
|
||||
const tree = parser.parse(src);
|
||||
let found: Parser.SyntaxNode | undefined;
|
||||
const walk = (n: Parser.SyntaxNode) => {
|
||||
if (n.type === 'companion_object') found ??= n;
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
if (!found) throw new Error('no companion_object found');
|
||||
return found;
|
||||
}
|
||||
|
||||
it('extracts a `const val` companion property as a static, readonly field', () => {
|
||||
const node = companion(`class C {
|
||||
companion object {
|
||||
const val TAG = "c"
|
||||
}
|
||||
}`);
|
||||
expect(extractor.isTypeDeclaration(node)).toBe(true);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
expect(result).not.toBeNull();
|
||||
const tag = result!.fields.find((f) => f.name === 'TAG');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag!.isStatic).toBe(true);
|
||||
expect(tag!.isReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts a property from a NAMED companion object', () => {
|
||||
const node = companion(`class C {
|
||||
companion object Factory {
|
||||
val x = 1
|
||||
}
|
||||
}`);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
const x = result!.fields.find((f) => f.name === 'x');
|
||||
expect(x).toBeDefined();
|
||||
expect(x!.isStatic).toBe(true);
|
||||
expect(x!.isReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('indexes only the property, not the function, and emits it exactly once', () => {
|
||||
const node = companion(`class C {
|
||||
companion object {
|
||||
val onlyField = 1
|
||||
fun create() {}
|
||||
}
|
||||
}`);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
const fieldNames = result!.fields.map((f) => f.name);
|
||||
expect(fieldNames).toEqual(['onlyField']); // function excluded, no duplication
|
||||
});
|
||||
|
||||
// CF4 (#1919 review): guard the new `isInsideKotlinCompanion` walk against
|
||||
// false-positives — a plain (non-companion) class property must be isStatic=false.
|
||||
it('reports a plain non-companion class property as isStatic=false (CF4)', () => {
|
||||
const classNode = firstNodeOfType(
|
||||
`class C {
|
||||
val x: Int = 1
|
||||
}`,
|
||||
'class_declaration',
|
||||
);
|
||||
expect(extractor.isTypeDeclaration(classNode)).toBe(true);
|
||||
const result = extractor.extract(classNode, mockContext);
|
||||
expect(result).not.toBeNull();
|
||||
const x = result!.fields.find((f) => f.name === 'x');
|
||||
expect(x).toBeDefined();
|
||||
expect(x!.isStatic).toBe(false);
|
||||
});
|
||||
|
||||
/** Parse `src` and return the first node of the given type (depth-first). */
|
||||
function firstNodeOfType(src: string, type: string): Parser.SyntaxNode {
|
||||
parser.setLanguage(Kotlin as Parser.Language);
|
||||
const tree = parser.parse(src);
|
||||
let found: Parser.SyntaxNode | undefined;
|
||||
const walk = (n: Parser.SyntaxNode) => {
|
||||
if (found) return;
|
||||
if (n.type === type) {
|
||||
found = n;
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
if (!found) throw new Error(`no ${type} found`);
|
||||
return found;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swift config — F75: protocol property requirements extracted as fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const describeSwift = Swift ? describe : describe.skip;
|
||||
|
||||
describeSwift('GenericFieldExtractor — Swift (F75 protocol property requirements)', () => {
|
||||
const parser = new Parser();
|
||||
const extractor = createFieldExtractor(swiftConfig);
|
||||
const mockContext = createMockContext();
|
||||
mockContext.language = SupportedLanguages.Swift;
|
||||
mockContext.filePath = 'test.swift';
|
||||
|
||||
/** Parse `src` and return the first class/protocol declaration node. */
|
||||
function declNode(src: string): Parser.SyntaxNode {
|
||||
parser.setLanguage(Swift as Parser.Language);
|
||||
const tree = parser.parse(src);
|
||||
const node = tree.rootNode.child(0);
|
||||
if (!node) throw new Error('no declaration node');
|
||||
return node;
|
||||
}
|
||||
|
||||
it('extracts a `{ get }` protocol property requirement as a field (F75)', () => {
|
||||
const node = declNode(`protocol P {
|
||||
var title: String { get }
|
||||
}`);
|
||||
expect(extractor.isTypeDeclaration(node)).toBe(true);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerFqn).toBe('P');
|
||||
const title = result!.fields.find((f) => f.name === 'title');
|
||||
expect(title).toBeDefined();
|
||||
expect(title!.type).toBe('String');
|
||||
expect(title!.isStatic).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts a `{ get set }` protocol property requirement (F75)', () => {
|
||||
const node = declNode(`protocol P {
|
||||
var count: Int { get set }
|
||||
}`);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
const count = result!.fields.find((f) => f.name === 'count');
|
||||
expect(count).toBeDefined();
|
||||
expect(count!.type).toBe('Int');
|
||||
});
|
||||
|
||||
it('extracts a static protocol property requirement as static (F75)', () => {
|
||||
const node = declNode(`protocol P {
|
||||
static var shared: P { get }
|
||||
}`);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
const shared = result!.fields.find((f) => f.name === 'shared');
|
||||
expect(shared).toBeDefined();
|
||||
expect(shared!.type).toBe('P');
|
||||
expect(shared!.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts all requirements from a multi-property protocol (F75)', () => {
|
||||
const node = declNode(`protocol P {
|
||||
var title: String { get }
|
||||
var count: Int { get set }
|
||||
static var shared: P { get }
|
||||
}`);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
const names = result!.fields.map((f) => f.name).sort();
|
||||
expect(names).toEqual(['count', 'shared', 'title']);
|
||||
});
|
||||
|
||||
it('still extracts a class stored property exactly once (regression)', () => {
|
||||
const node = declNode(`class C {
|
||||
var name: String = ""
|
||||
}`);
|
||||
expect(extractor.isTypeDeclaration(node)).toBe(true);
|
||||
const result = extractor.extract(node, mockContext);
|
||||
const matches = result!.fields.filter((f) => f.name === 'name');
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe('String');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
gitnexus/test/unit/lbug-query-result-utils.test.ts
Normal file
45
gitnexus/test/unit/lbug-query-result-utils.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Unit tests: closeQueryResults — the shared best-effort cursor-close helper
|
||||
* (#2068 follow-up). Guards the contract the two LadybugDB adapters rely on:
|
||||
* a single result OR an array of results are all closed, and a failing/absent
|
||||
* `close()` never throws and never skips the rest.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js';
|
||||
|
||||
// Minimal QueryResult stand-in — only `close()` matters here.
|
||||
function fakeResult(close: () => unknown = () => undefined) {
|
||||
return { close: vi.fn(close) } as unknown as import('@ladybugdb/core').QueryResult;
|
||||
}
|
||||
|
||||
describe('closeQueryResults', () => {
|
||||
it('closes a single QueryResult exactly once', async () => {
|
||||
const r = fakeResult();
|
||||
await closeQueryResults(r);
|
||||
expect(r.close as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes EVERY element of an array (not just the first)', async () => {
|
||||
const rs = [fakeResult(), fakeResult(), fakeResult()];
|
||||
await closeQueryResults(rs);
|
||||
for (const r of rs) {
|
||||
expect(r.close as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps closing the rest when one close() rejects (best-effort, no throw)', async () => {
|
||||
const ok1 = fakeResult();
|
||||
const bad = fakeResult(() => {
|
||||
throw new Error('native close failed');
|
||||
});
|
||||
const ok2 = fakeResult(() => Promise.reject(new Error('async close failed')));
|
||||
const ok3 = fakeResult();
|
||||
await expect(closeQueryResults([ok1, bad, ok2, ok3])).resolves.toBeUndefined();
|
||||
expect(ok1.close as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
|
||||
expect(ok3.close as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is a no-op on an empty array', async () => {
|
||||
await expect(closeQueryResults([])).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,34 @@ describe('isReadOnlyDbError', () => {
|
|||
expect(isReadOnlyDbError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a read-only error wrapped as a `cause` (#2068 — pool-adapter wrapper)', () => {
|
||||
// The pool adapter rethrows native read-only failures as a friendly
|
||||
// message with the original error preserved on `cause`. Detection must see
|
||||
// through the wrapper so the MCP/HTTP handlers surface their curated message.
|
||||
const wrapped = new Error('Write operations are not allowed. The pool adapter is read-only.', {
|
||||
cause: new Error('Cannot execute write operations in a read-only database!'),
|
||||
});
|
||||
expect(isReadOnlyDbError(wrapped)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT match a wrapper whose cause chain is unrelated', () => {
|
||||
const wrapped = new Error('Query failed', { cause: new Error('Connection refused') });
|
||||
expect(isReadOnlyDbError(wrapped)).toBe(false);
|
||||
});
|
||||
|
||||
it('terminates on a cyclic cause chain without hanging', () => {
|
||||
const a = new Error('boom a') as Error & { cause?: unknown };
|
||||
const b = new Error('boom b') as Error & { cause?: unknown };
|
||||
a.cause = b;
|
||||
b.cause = a;
|
||||
expect(isReadOnlyDbError(a)).toBe(false);
|
||||
});
|
||||
|
||||
it('stops cleanly on a non-Error cause', () => {
|
||||
const wrapped = new Error('outer', { cause: 'just a string' });
|
||||
expect(isReadOnlyDbError(wrapped)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT match unrelated errors that the ensure path must still surface', () => {
|
||||
// Lock contention — handled separately by isDbBusyError; must not be
|
||||
// silenced by the read-only filter.
|
||||
|
|
|
|||
|
|
@ -727,6 +727,69 @@ describeKotlin('Kotlin MethodExtractor', () => {
|
|||
expect(result!.methods[0].isStatic).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// F48 (issue #1919): secondary constructors were dropped — methodNodeTypes
|
||||
// listed only 'function_declaration'. They are now extracted as members
|
||||
// named "constructor" with their function_value_parameters.
|
||||
describe('secondary constructors (F48)', () => {
|
||||
it('extracts a secondary constructor as a member named "constructor" with its params', () => {
|
||||
const tree = parseKotlin(`
|
||||
class C(val x: Int) {
|
||||
constructor(a: Int, b: String) : this(a) { }
|
||||
}
|
||||
`);
|
||||
const classNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(classNode, kotlinCtx);
|
||||
|
||||
const ctor = result!.methods.find((m) => m.name === 'constructor');
|
||||
expect(ctor).toBeDefined();
|
||||
expect(ctor!.parameters.map((p) => p.name)).toEqual(['a', 'b']);
|
||||
expect(ctor!.parameters[0].type).toBe('Int');
|
||||
});
|
||||
|
||||
it('extracts multiple secondary constructors distinctly (by arity)', () => {
|
||||
const tree = parseKotlin(`
|
||||
class C(val x: Int) {
|
||||
constructor(a: Int, b: String) : this(a) { }
|
||||
constructor() { }
|
||||
}
|
||||
`);
|
||||
const classNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(classNode, kotlinCtx);
|
||||
|
||||
const ctors = result!.methods.filter((m) => m.name === 'constructor');
|
||||
expect(ctors).toHaveLength(2);
|
||||
const arities = ctors.map((c) => c.parameters.length).sort();
|
||||
expect(arities).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('still extracts the secondary constructor when it delegates via : this(...)', () => {
|
||||
const tree = parseKotlin(`
|
||||
class C(val x: Int) {
|
||||
constructor(a: Int) : this(a) { }
|
||||
}
|
||||
`);
|
||||
const classNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(classNode, kotlinCtx);
|
||||
|
||||
const ctor = result!.methods.find((m) => m.name === 'constructor');
|
||||
expect(ctor).toBeDefined();
|
||||
expect(ctor!.parameters.map((p) => p.name)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('does not synthesize a constructor member for a class with only a primary constructor + methods', () => {
|
||||
const tree = parseKotlin(`
|
||||
class C(val x: Int) {
|
||||
fun normal(): Int = x
|
||||
}
|
||||
`);
|
||||
const classNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(classNode, kotlinCtx);
|
||||
|
||||
expect(result!.methods.some((m) => m.name === 'constructor')).toBe(false);
|
||||
expect(result!.methods.map((m) => m.name)).toEqual(['normal']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -4535,6 +4598,74 @@ class Child {
|
|||
expect(result!.methods[0].isOverride).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// F79: a Swift `enum { ... }` parses to a class_declaration whose body is an
|
||||
// `enum_class_body` (NOT class_body). With enum_class_body added to
|
||||
// bodyNodeTypes the factory reaches enum methods via the proper body-node
|
||||
// path instead of the generic findBodies fallback.
|
||||
describe('enum members (F79)', () => {
|
||||
it('extracts a method declared inside an enum', () => {
|
||||
const tree = parseSwift(`
|
||||
enum E {
|
||||
case a
|
||||
func describe() -> String {
|
||||
return "x"
|
||||
}
|
||||
}
|
||||
`);
|
||||
const enumNode = tree.rootNode.child(0)!;
|
||||
expect(enumNode.type).toBe('class_declaration');
|
||||
expect(extractor.isTypeDeclaration(enumNode)).toBe(true);
|
||||
|
||||
const result = extractor.extract(enumNode, swiftCtx);
|
||||
expect(result!.ownerName).toBe('E');
|
||||
const describe = result!.methods.find((m) => m.name === 'describe');
|
||||
expect(describe).toBeDefined();
|
||||
expect(describe!.returnType).toBe('String');
|
||||
});
|
||||
|
||||
it('extracts a static method inside an enum as static', () => {
|
||||
const tree = parseSwift(`
|
||||
enum E {
|
||||
case a
|
||||
static func make() -> E {
|
||||
return .a
|
||||
}
|
||||
}
|
||||
`);
|
||||
const enumNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(enumNode, swiftCtx);
|
||||
const make = result!.methods.find((m) => m.name === 'make');
|
||||
expect(make).toBeDefined();
|
||||
expect(make!.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts multiple enum methods, each exactly once', () => {
|
||||
const tree = parseSwift(`
|
||||
enum E {
|
||||
case a
|
||||
func describe() -> String { return "x" }
|
||||
static func make() -> E { return .a }
|
||||
}
|
||||
`);
|
||||
const enumNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(enumNode, swiftCtx);
|
||||
const names = result!.methods.map((m) => m.name).sort();
|
||||
expect(names).toEqual(['describe', 'make']);
|
||||
});
|
||||
|
||||
it('still extracts class methods exactly once (regression)', () => {
|
||||
const tree = parseSwift(`
|
||||
class Compass {
|
||||
func heading() -> String { return "n" }
|
||||
}
|
||||
`);
|
||||
const classNode = tree.rootNode.child(0)!;
|
||||
const result = extractor.extract(classNode, swiftCtx);
|
||||
const heading = result!.methods.filter((m) => m.name === 'heading');
|
||||
expect(heading).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
cppVariableConfig,
|
||||
} from '../../src/core/ingestion/variable-extractors/configs/c-cpp.js';
|
||||
import { rubyVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/ruby.js';
|
||||
import { dartVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/dart.js';
|
||||
import { kotlinVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/jvm.js';
|
||||
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
|
||||
import type { VariableExtractorContext } from '../../src/core/ingestion/variable-types.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
import Parser from 'tree-sitter';
|
||||
|
|
@ -22,6 +25,14 @@ import Rust from 'tree-sitter-rust';
|
|||
import Cpp from 'tree-sitter-cpp';
|
||||
import C from 'tree-sitter-c';
|
||||
import Ruby from 'tree-sitter-ruby';
|
||||
import Dart from 'tree-sitter-dart';
|
||||
|
||||
let Kotlin: unknown;
|
||||
try {
|
||||
Kotlin = require('tree-sitter-kotlin');
|
||||
} catch {
|
||||
// Kotlin grammar may not be installed
|
||||
}
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
|
|
@ -473,6 +484,41 @@ describe('VariableExtractor — C++', () => {
|
|||
expect(info!.name).toBe('SIZE');
|
||||
expect(info!.isConst).toBe(true);
|
||||
});
|
||||
|
||||
// F9 — structured binding declarations emit one Variable per bound name.
|
||||
it('emits a Variable per name for `auto [a, b] = make_pair();`', () => {
|
||||
parser.setLanguage(Cpp);
|
||||
const tree = parser.parse('auto [a, b] = make_pair();');
|
||||
const node = tree.rootNode.child(0)!;
|
||||
expect(extractor.isVariableDeclaration(node)).toBe(true);
|
||||
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos.map((i) => i.name)).toEqual(['a', 'b']);
|
||||
// Top-level declaration → module scope (C++ is in the safe set re: the
|
||||
// determineScope class-body block hazard).
|
||||
for (const info of infos) expect(info.scope).toBe('module');
|
||||
});
|
||||
|
||||
it('emits a Variable per name for the reference form `auto& [x, y, z] = tup;`', () => {
|
||||
parser.setLanguage(Cpp);
|
||||
const tree = parser.parse('auto& [x, y, z] = tup;');
|
||||
const node = tree.rootNode.child(0)!;
|
||||
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos.map((i) => i.name)).toEqual(['x', 'y', 'z']);
|
||||
for (const info of infos) expect(info.scope).toBe('module');
|
||||
});
|
||||
|
||||
it('does not double-emit for an ordinary single-name declaration `int n = 0;`', () => {
|
||||
parser.setLanguage(Cpp);
|
||||
const tree = parser.parse('int n = 0;');
|
||||
const node = tree.rootNode.child(0)!;
|
||||
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos).toHaveLength(1);
|
||||
expect(infos[0]!.name).toBe('n');
|
||||
expect(infos[0]!.scope).toBe('module');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -724,3 +770,159 @@ describe('VariableExtractor — block-scoped declarations', () => {
|
|||
expect(info).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dart config — F29: top-level variable declarations
|
||||
//
|
||||
// Top-level Dart vars are loose siblings under `program` (no `declaration`
|
||||
// wrapper). The structure query captures the container node
|
||||
// (initialized_identifier_list / static_final_declaration_list) as
|
||||
// @definition.variable, so the extractor is fed that container. The previous
|
||||
// config read a `type_identifier` that does not exist as a direct child of the
|
||||
// captured node — a dead read. These tests feed the real captured container.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('VariableExtractor — Dart (F29 top-level)', () => {
|
||||
const extractor = createVariableExtractor(dartVariableConfig);
|
||||
const ctx: VariableExtractorContext = {
|
||||
filePath: 'test.dart',
|
||||
language: SupportedLanguages.Dart,
|
||||
};
|
||||
|
||||
/** The top-level variable container the structure query captures. */
|
||||
function captureContainer(src: string): SyntaxNode {
|
||||
parser.setLanguage(Dart);
|
||||
const tree = parser.parse(src);
|
||||
let found: SyntaxNode | undefined;
|
||||
const walk = (n: SyntaxNode) => {
|
||||
if (
|
||||
(n.type === 'initialized_identifier_list' || n.type === 'static_final_declaration_list') &&
|
||||
n.parent?.type === 'program'
|
||||
) {
|
||||
found ??= n;
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
if (!found) throw new Error('no top-level variable container found');
|
||||
return found;
|
||||
}
|
||||
|
||||
it('extracts a typed final top-level variable with the real type (F29)', () => {
|
||||
const node = captureContainer('final int count = 3;');
|
||||
expect(extractor.isVariableDeclaration(node)).toBe(true);
|
||||
const info = extractor.extract(node, ctx);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.name).toBe('count');
|
||||
expect(info!.type).toBe('int');
|
||||
expect(info!.isConst).toBe(true);
|
||||
expect(info!.isMutable).toBe(false);
|
||||
expect(info!.scope).toBe('module');
|
||||
});
|
||||
|
||||
it('extracts an inferred var as untyped (not from a phantom field) (F29)', () => {
|
||||
const node = captureContainer("var name = 'x';");
|
||||
const info = extractor.extract(node, ctx);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.name).toBe('name');
|
||||
// `var` is inferred → no type annotation, so type is null (not a phantom).
|
||||
expect(info!.type).toBeNull();
|
||||
expect(info!.isMutable).toBe(true);
|
||||
expect(info!.isConst).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts both names from a multi-name top-level const (F29)', () => {
|
||||
const node = captureContainer('const a = 1, b = 2;');
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos.map((i) => i.name).sort()).toEqual(['a', 'b']);
|
||||
for (const i of infos) {
|
||||
expect(i.isConst).toBe(true);
|
||||
expect(i.isMutable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a neighbouring declaration’s type/modifier bleed in', () => {
|
||||
// Three declarations under one program. The `var name` container sits after
|
||||
// `final int count` — its type/modifier must NOT leak onto `name`.
|
||||
parser.setLanguage(Dart);
|
||||
const tree = parser.parse("final int count = 3;\nvar name = 'x';\nconst a = 1, b = 2;");
|
||||
const containers: SyntaxNode[] = [];
|
||||
const walk = (n: SyntaxNode) => {
|
||||
if (
|
||||
(n.type === 'initialized_identifier_list' || n.type === 'static_final_declaration_list') &&
|
||||
n.parent?.type === 'program'
|
||||
) {
|
||||
containers.push(n);
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
|
||||
const byName = new Map<string, ReturnType<typeof extractor.extractAll>[number]>();
|
||||
for (const c of containers)
|
||||
for (const info of extractor.extractAll(c, ctx)) byName.set(info.name, info);
|
||||
|
||||
expect(byName.get('name')!.type).toBeNull(); // not 'int' from count
|
||||
expect(byName.get('name')!.isConst).toBe(false); // not final from count
|
||||
expect(byName.get('count')!.type).toBe('int');
|
||||
expect(byName.get('a')!.isConst).toBe(true);
|
||||
expect(byName.get('b')!.isConst).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kotlin destructuring declarations (F51, issue #1919)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const describeKotlin = Kotlin ? describe : describe.skip;
|
||||
|
||||
describeKotlin('VariableExtractor — Kotlin (F51 destructuring)', () => {
|
||||
const extractor = createVariableExtractor(kotlinVariableConfig);
|
||||
const ctx: VariableExtractorContext = {
|
||||
filePath: 'test.kt',
|
||||
language: SupportedLanguages.Kotlin,
|
||||
};
|
||||
|
||||
/** The first property_declaration whose text starts with `prefix`. */
|
||||
function propertyDecl(src: string, prefix: string): SyntaxNode {
|
||||
parser.setLanguage(Kotlin as Parser.Language);
|
||||
const tree = parser.parse(src);
|
||||
let found: SyntaxNode | undefined;
|
||||
const walk = (n: SyntaxNode) => {
|
||||
if (n.type === 'property_declaration' && n.text.trimStart().startsWith(prefix)) {
|
||||
found ??= n;
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
if (!found) throw new Error(`no property_declaration starting with ${prefix}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
it('emits one Variable per destructured name (`val (a, b) = pair`)', () => {
|
||||
const node = propertyDecl('fun f() { val (a, b) = pair }', 'val (a');
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos.map((i) => i.name)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('skips the `_` discard placeholder (`val (_, second) = pair`)', () => {
|
||||
const node = propertyDecl('fun f() { val (_, second) = pair }', 'val (_');
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos.map((i) => i.name)).toEqual(['second']);
|
||||
});
|
||||
|
||||
it('still emits exactly one name for a plain `val x = 1` (no double-count)', () => {
|
||||
const node = propertyDecl('fun f() { val x = 1 }', 'val x');
|
||||
const infos = extractor.extractAll(node, ctx);
|
||||
expect(infos.map((i) => i.name)).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
176
gitnexus/test/unit/worker-pool-error-stack.test.ts
Normal file
176
gitnexus/test/unit/worker-pool-error-stack.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
createWorkerPool,
|
||||
WorkerPoolDispatchError,
|
||||
} from '../../src/core/ingestion/workers/worker-pool.js';
|
||||
|
||||
/**
|
||||
* #2068 regression: a worker-side crash must carry its stack across the
|
||||
* MessageChannel so the surfaced "Phase 'parse' failed" error points at a real
|
||||
* frame instead of a bare one-liner (the issue's `this.#q is not a function`
|
||||
* reached the operator with no file:line because the worker only sent
|
||||
* `err.message`). These tests assert the worker stack rides through both worker
|
||||
* failure channels — the `{type:'error'}` message (a caught worker throw) and
|
||||
* the Node `'error'` event (an uncaught worker throw) — into the
|
||||
* `WorkerPoolDispatchError` the parse phase rejects with, and that an older
|
||||
* worker build that omits the stack still degrades cleanly.
|
||||
*/
|
||||
|
||||
type NodeWorker = import('node:worker_threads').Worker;
|
||||
|
||||
type FakeAction =
|
||||
| { kind: 'error-message'; error: string; errorStack?: string }
|
||||
| { kind: 'error-event'; message: string; stack: string };
|
||||
|
||||
const nextActions: FakeAction[] = [];
|
||||
|
||||
/**
|
||||
* Minimal worker double: emits the readiness handshake on construction, then
|
||||
* runs one scripted action per dispatched sub-batch. Unlike the resilience
|
||||
* suite's double, this one can emit the `{type:'error', errorStack}` MESSAGE
|
||||
* (the worker's own caught-error path) in addition to the Node `'error'` event.
|
||||
*/
|
||||
class FakeWorker extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
queueMicrotask(() => {
|
||||
this.emit('online');
|
||||
this.emit('message', { type: 'ready' });
|
||||
});
|
||||
}
|
||||
|
||||
postMessage(rawMsg: unknown): void {
|
||||
const m = rawMsg as { type?: string };
|
||||
// Only a real dispatch drives an action; ignore the pool's `flush` reply.
|
||||
if (!m || m.type !== 'sub-batch') return;
|
||||
const action = nextActions.shift();
|
||||
if (!action) return;
|
||||
queueMicrotask(() => {
|
||||
if (action.kind === 'error-message') {
|
||||
this.emit('message', {
|
||||
type: 'error',
|
||||
error: action.error,
|
||||
errorStack: action.errorStack,
|
||||
});
|
||||
} else {
|
||||
const e = new Error(action.message);
|
||||
e.stack = action.stack;
|
||||
this.emit('error', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async terminate(): Promise<number> {
|
||||
this.emit('exit', 0);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
let tempDir: string;
|
||||
let workerUrl: URL;
|
||||
|
||||
beforeEach(() => {
|
||||
nextActions.length = 0;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-error-stack-'));
|
||||
const workerPath = path.join(tempDir, 'fake-worker.js');
|
||||
fs.writeFileSync(workerPath, '// fake');
|
||||
workerUrl = pathToFileURL(workerPath) as URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
// Trip the breaker on the very first death so the reason string is surfaced
|
||||
// verbatim in the rejected WorkerPoolDispatchError.
|
||||
const TRIP_ON_FIRST_DEATH = {
|
||||
consecutiveFailureThreshold: 1,
|
||||
maxRespawnsPerSlot: 0,
|
||||
} as const;
|
||||
|
||||
async function dispatchAndCatch(pool: ReturnType<typeof createWorkerPool>): Promise<unknown> {
|
||||
try {
|
||||
await pool.dispatch<{ path: string; content: string }, unknown>([
|
||||
{ path: 'src/a.ts', content: '' },
|
||||
]);
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
describe('worker-pool error stack propagation (#2068)', () => {
|
||||
it('embeds the worker stack from a {type:error} message into the surfaced error', async () => {
|
||||
const workerStack =
|
||||
'TypeError: this.#q is not a function\n' +
|
||||
' at frobnicate (/dist/core/ingestion/workers/parse-worker.js:1234:56)';
|
||||
const pool = createWorkerPool(workerUrl, 1, {
|
||||
workerFactory: () => new FakeWorker() as unknown as NodeWorker,
|
||||
...TRIP_ON_FIRST_DEATH,
|
||||
});
|
||||
nextActions.push({
|
||||
kind: 'error-message',
|
||||
error: 'this.#q is not a function',
|
||||
errorStack: workerStack,
|
||||
});
|
||||
|
||||
const caught = await dispatchAndCatch(pool);
|
||||
|
||||
expect(caught).toBeInstanceOf(WorkerPoolDispatchError);
|
||||
const msg = (caught as Error).message;
|
||||
expect(msg).toContain('this.#q is not a function');
|
||||
expect(msg).toContain('worker stack:');
|
||||
expect(msg).toContain('frobnicate (/dist/core/ingestion/workers/parse-worker.js:1234:56)');
|
||||
await pool.terminate();
|
||||
});
|
||||
|
||||
// The Node 'error' event fires on an UNCAUGHT JS throw / async rejection (which
|
||||
// carries a real JS stack). A true NATIVE abort (tree-sitter SIGSEGV / OOM kill)
|
||||
// instead fires the 'exit' event and is intentionally stackless — no JS frame
|
||||
// exists — so it is NOT exercised here.
|
||||
it('embeds the worker stack from a Node error event (uncaught throw) into the surfaced error', async () => {
|
||||
const workerStack =
|
||||
'Error: uncaught worker throw\n' +
|
||||
' at processFileGroup (/dist/core/ingestion/workers/parse-worker.js:777:9)';
|
||||
const pool = createWorkerPool(workerUrl, 1, {
|
||||
workerFactory: () => new FakeWorker() as unknown as NodeWorker,
|
||||
...TRIP_ON_FIRST_DEATH,
|
||||
});
|
||||
nextActions.push({ kind: 'error-event', message: 'uncaught worker throw', stack: workerStack });
|
||||
|
||||
const caught = await dispatchAndCatch(pool);
|
||||
|
||||
expect(caught).toBeInstanceOf(WorkerPoolDispatchError);
|
||||
const msg = (caught as Error).message;
|
||||
expect(msg).toContain('worker stack:');
|
||||
expect(msg).toContain('processFileGroup (/dist/core/ingestion/workers/parse-worker.js:777:9)');
|
||||
await pool.terminate();
|
||||
});
|
||||
|
||||
it('degrades to message-only when an older worker build omits errorStack', async () => {
|
||||
const pool = createWorkerPool(workerUrl, 1, {
|
||||
workerFactory: () => new FakeWorker() as unknown as NodeWorker,
|
||||
...TRIP_ON_FIRST_DEATH,
|
||||
});
|
||||
// No errorStack — the wire field is optional for back/forward compat.
|
||||
nextActions.push({ kind: 'error-message', error: 'legacy worker failure' });
|
||||
|
||||
const caught = await dispatchAndCatch(pool);
|
||||
|
||||
expect(caught).toBeInstanceOf(WorkerPoolDispatchError);
|
||||
const msg = (caught as Error).message;
|
||||
expect(msg).toContain('legacy worker failure');
|
||||
expect(msg).not.toContain('worker stack:');
|
||||
await pool.terminate();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue