mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'abhigyanpatwari:main' into main
This commit is contained in:
commit
11d159d4f0
275 changed files with 29651 additions and 1328 deletions
|
|
@ -27,9 +27,12 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
|
||||
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
|
|
|||
49
.github/workflows/ci-tests.yml
vendored
49
.github/workflows/ci-tests.yml
vendored
|
|
@ -481,6 +481,20 @@ jobs:
|
|||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Java wildcard-static route constant guards (#3110)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: named-import control vs wildcard materialization;
|
||||
# fingerprints bindings and guards scaling + absolute wall time.
|
||||
run: node --import tsx bench/java-wildcard-route-constants/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin package-star route constant guards (#3110)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: explicit-import control vs package-star folding;
|
||||
# fingerprints route facts and guards scaling + widening overhead.
|
||||
run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language scope-capture fingerprint + scaling guards
|
||||
# Runs even after an earlier guard fails (#2895). Every step here was
|
||||
# fail-fast, so the FIRST failing --check aborted the job and every guard
|
||||
|
|
@ -509,6 +523,31 @@ jobs:
|
|||
run: node --import tsx bench/callable-value-flow/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Java Lombok accessor synthesis guards (#2885)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: no-Lombok vs Lombok-heavy corpora; fingerprint over
|
||||
# synthetic Method ids; scaling + widening overhead budgets.
|
||||
run: node --import tsx bench/java-lombok-synthesis/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin JVM accessor synthesis guards (#2885)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: no-property vs data-class corpora; fingerprint over
|
||||
# synthetic Method ids; scaling + widening overhead budgets.
|
||||
run: node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin Spring config-consumer capture guards (#2412)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: explicit-import control vs wildcard-import feature path;
|
||||
# fingerprints @Value / @ConfigurationProperties facts and guards scaling
|
||||
# + widening overhead. The parity check is the regression gate: each file
|
||||
# declares a sibling nested type named `Value`, which must not suppress
|
||||
# the imported Spring annotation (file-wide shadowing dropped 2 of every
|
||||
# 3 facts on this corpus).
|
||||
run: node --import tsx bench/spring-config-bindings/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Re-export closure scaling guards (#2864)
|
||||
# Build-free: asserts buildReexportClosures stays linear in chain depth
|
||||
# and within an absolute ceiling on a wide package corpus. #2864 changed
|
||||
|
|
@ -711,20 +750,22 @@ jobs:
|
|||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
if: ${{ !cancelled() }}
|
||||
# cpp-adl-benchmark.test.ts is not a `*-pipeline-benchmark.test.ts` but
|
||||
# belongs here for the same reason: it is skipIf-gated on GITNEXUS_BENCH,
|
||||
# so it had never run in CI and the PR #1990 ADL emit-scaling guard it
|
||||
# holds was dead. ~45s of test time.
|
||||
# cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts
|
||||
# are not `*-pipeline-benchmark.test.ts` files but belong here for the
|
||||
# same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling
|
||||
# guards they hold never run in the main coverage job.
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
run: >-
|
||||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-razor-view-components-benchmark.test.ts
|
||||
test/integration/cpp-adl-benchmark.test.ts
|
||||
test/integration/data-route-table-benchmark.test.ts
|
||||
test/integration/instance-ownership-pipeline-benchmark.test.ts
|
||||
test/integration/spring-bean-resource-benchmark.test.ts
|
||||
test/integration/spring-dynamic-lookup-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
|
|
|
|||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -71,6 +71,12 @@ jobs:
|
|||
# deliberately contain use-before-init / unused-variable shapes).
|
||||
- '**/test/fixtures/**'
|
||||
- '**/test/**/fixtures/**'
|
||||
# GET /api/grep intentionally builds RegExp from the query string
|
||||
# (literal=1 escapes). ReDoS is handled by worker terminate() —
|
||||
# see SECURITY.md. Inline codeql[] comments do not clear the
|
||||
# GitHub PR CodeQL gate, so this file is excluded to avoid
|
||||
# re-filing js/regex-injection on every push of the same line.
|
||||
- 'gitnexus/src/server/grep-params.ts'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
# Deleted README placeholder from PR #2458; no credential was present.
|
||||
c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348
|
||||
# Synthetic Kotlin Actuator fixture value from PR #3107; no credential was present.
|
||||
3951079300a18b14e79f5b5f5dd778ae19ced6e3:gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts:generic-api-key:8
|
||||
|
|
|
|||
|
|
@ -121,8 +121,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- Explore with `query({search_query: "concept"})` for process-grouped flows.
|
||||
- Use `context({name: "symbolName"})` for callers, callees, and flows.
|
||||
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
|
|||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes, factory Methods, or AST-captured programmatic lookup callables to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- Explore with `query({search_query: "concept"})` for process-grouped flows.
|
||||
- Use `context({name: "symbolName"})` for callers, callees, and flows.
|
||||
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
|
||||
|
||||
|
|
|
|||
71
README.md
71
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# GitNexus (Akon Labs)
|
||||
# GitNexus (Akon Labs)
|
||||
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
|
|
@ -449,10 +449,13 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
|
|||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
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 analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes
|
||||
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
```
|
||||
|
||||
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
**Embeddings node limit** — `gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories:
|
||||
|
|
@ -500,6 +503,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
|
|||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"springActuator": "./actuator", // optional local runtime snapshot directory or bundle
|
||||
"workerTimeout": 60,
|
||||
}
|
||||
```
|
||||
|
|
@ -514,7 +518,7 @@ Notes:
|
|||
|
||||
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
|
||||
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
|
||||
|
||||
</details>
|
||||
|
|
@ -524,36 +528,39 @@ Notes:
|
|||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `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. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
|
||||
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `GITNEXUS_MCP_AUTH_TOKEN` | unset | Bearer token for the dedicated `gitnexus mcp --http` server, for a **directly reachable** `gitnexus serve` `/api/mcp` route, and for the `docker-server` / web proxy in front of one. A non-loopback dedicated MCP bind requires it; `serve` enables protocol-layer MCP auth when it is set. Behind a proxy, set the **same** value on both services: the proxy spends the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with this token on `/api/mcp` only. | Dedicated MCP, a `serve` the client can reach directly, or a proxied deploy (Render Blueprint) where the backend runs protocol-layer MCP auth — configure it on the proxy too. |
|
||||
| `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. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
|
||||
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -59,11 +59,16 @@ The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnex
|
|||
- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying.
|
||||
- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard.
|
||||
- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories.
|
||||
- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover.
|
||||
- **`POST /api/mcp` rides the same path.** When `GITNEXUS_MCP_AUTH_TOKEN` is set on the backend, `serve` protects `/api/mcp` with the same constant-time Bearer check as the dedicated HTTP MCP server, before parsing the request body. The Render Blueprint does not set a backend MCP token by default. To enable it behind the proxy, set the **same** `GITNEXUS_MCP_AUTH_TOKEN` on both the `gitnexus-web` proxy and the `gitnexus-server` backend: the proxy consumes the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with the MCP token on `/api/mcp` (and its subpaths) only — the edge credential is never forwarded, and other `/api/*` routes stay stripped. Configuring it on the backend alone makes every proxied MCP request `401`.
|
||||
- **A directly reachable `serve` still needs an explicit control.** If neither `GITNEXUS_MCP_AUTH_TOKEN` nor an authenticated edge/private-network boundary is present, `/api/mcp` is unauthenticated. Do not bind that topology to a LAN or public interface: MCP readers can access indexed source and graph context.
|
||||
- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in.
|
||||
|
||||
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
|
||||
|
||||
### `/api/grep` regex semantics and residual ReDoS exposure
|
||||
|
||||
`GET /api/grep` executes caller-supplied patterns as real regular expressions (with an optional path-substring `fileFilter` and `caseSensitive` flag) to honor the web chat's grep tool contract; `literal=1` restores the older escaped-substring mode. Mitigations: a 200-character pattern cap, line-by-line matching, a max-200 result cap, and a 5-second wall-clock budget. Matching runs in a `worker_threads` worker so a catastrophic pattern (e.g. `(a+)+$`) can be killed with `terminate()` when the budget expires — the parent event loop (other routes + SSE) stays responsive. A timed-out scan returns partial results with `timedOut: true`; the web grep tool surfaces that flag so an agent does not treat a cut-off scan as exhaustive. CodeQL still flags constructing a `RegExp` from the query string; that is the advertised contract, not accidental injection. Hosted deploys continue to gate the route behind the edge token.
|
||||
|
||||
## Automated Scans Running in CI
|
||||
|
||||
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
|
|||
// (gitnexus/src/mcp/http-transport.ts).
|
||||
const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null;
|
||||
|
||||
// The protocol-layer credential the upstream `serve` expects on /api/mcp when it
|
||||
// runs with MCP Bearer auth enabled. Set it to the SAME value on both services:
|
||||
// the edge token is spent here and replaced with this one for MCP requests only
|
||||
// (see proxyToUpstream). Unset — the default — means no injection, so a backend
|
||||
// without MCP auth is unaffected. Blank-is-absent follows resolveAuthToken
|
||||
// (gitnexus/src/mcp/http-transport.ts). Never logged.
|
||||
const mcpAuthToken = process.env.GITNEXUS_MCP_AUTH_TOKEN?.trim() || null;
|
||||
|
||||
// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer),
|
||||
// relocated because the trust boundary is here: an unguarded `serve` behind a
|
||||
// private service is legitimate, an unguarded public proxy is not.
|
||||
|
|
@ -341,11 +349,17 @@ async function proxyToUpstream(req, res) {
|
|||
// talks to this same-origin web service.
|
||||
delete headers.origin;
|
||||
delete headers.referer;
|
||||
// The edge token is spent here. `serve` reads no Authorization header
|
||||
// (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding
|
||||
// it would only copy a live credential into another service's logs. Pinned by
|
||||
// test.
|
||||
// The edge token is spent here and must never be forwarded: copying
|
||||
// Authorization would put a live credential into another service's logs. So
|
||||
// drop it unconditionally first, then — for the MCP route alone, and only
|
||||
// when a backend token is configured — replace it with that separate
|
||||
// protocol credential. Unset GITNEXUS_MCP_AUTH_TOKEN (the default) leaves
|
||||
// every request stripped, as before. The scope is the normalized pathname,
|
||||
// so a query string can't widen it and /api/mcpfoo doesn't qualify.
|
||||
delete headers.authorization;
|
||||
const upstreamPath = upstream.pathname;
|
||||
const isMcpRoute = upstreamPath === '/api/mcp' || upstreamPath.startsWith('/api/mcp/');
|
||||
if (isMcpRoute && mcpAuthToken) headers.authorization = `Bearer ${mcpAuthToken}`;
|
||||
headers.host = upstream.host;
|
||||
// Replace, never forward, the inbound chain (see clientAddressFor).
|
||||
const clientAddress = clientAddressFor(req);
|
||||
|
|
|
|||
|
|
@ -271,6 +271,12 @@ it('does not inject config into static assets', async () => {
|
|||
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
|
||||
const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`;
|
||||
|
||||
// The protocol token the upstream expects on /api/mcp. Deliberately unlike the
|
||||
// edge token, so "injected the backend credential" and "forwarded the edge one"
|
||||
// can never both satisfy an assertion.
|
||||
const TEST_MCP_TOKEN = 'backend-mcp-token-0123456789abcdefghij';
|
||||
const TEST_MCP_BEARER = `Bearer ${TEST_MCP_TOKEN}`;
|
||||
|
||||
// rawRequest never sends credentials; apiRequest does. In a file whose subject
|
||||
// is who gets let through, no test should pass because a helper quietly
|
||||
// authenticated for it.
|
||||
|
|
@ -376,6 +382,11 @@ async function withProxy(
|
|||
const proc = spawnServerWithEnv(dir, port, {
|
||||
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
|
||||
// An ambient GITNEXUS_MCP_AUTH_TOKEN in the developer's shell would make the
|
||||
// proxy inject one on /api/mcp, so drop it: spawn omits undefined entries,
|
||||
// which unsets the inherited value. A test that wants injection sets it via
|
||||
// `env` below.
|
||||
GITNEXUS_MCP_AUTH_TOKEN: undefined,
|
||||
...env,
|
||||
});
|
||||
proc.stderr.setEncoding('utf8');
|
||||
|
|
@ -969,8 +980,9 @@ it('forwards an /api/* request that carries the correct token', async () => {
|
|||
});
|
||||
|
||||
it('strips the Authorization header instead of forwarding the edge token', async () => {
|
||||
// The token is spent at this hop. `serve` reads no Authorization header, so
|
||||
// forwarding would only copy a live credential into another service's logs.
|
||||
// The edge credential is spent and stripped at this hop. Forwarding it
|
||||
// would copy a live credential into another service's logs. With no
|
||||
// GITNEXUS_MCP_AUTH_TOKEN configured — the default — nothing replaces it.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, 'the request itself must still be proxied');
|
||||
|
|
@ -978,6 +990,72 @@ it('strips the Authorization header instead of forwarding the edge token', async
|
|||
});
|
||||
});
|
||||
|
||||
// -- Upstream MCP token injection (GITNEXUS_MCP_AUTH_TOKEN) -----------------
|
||||
//
|
||||
// A backend running protocol-layer MCP auth expects its own Bearer on
|
||||
// /api/mcp, and the edge credential can't serve as one. Both services are
|
||||
// configured with the same GITNEXUS_MCP_AUTH_TOKEN; this hop spends the edge
|
||||
// token and substitutes the backend one, for that route only.
|
||||
|
||||
// Stands in for a `serve` with MCP Bearer auth enabled: only the exact backend
|
||||
// credential gets through, so a passing two-hop request proves what was sent.
|
||||
const mcpBackend = (req, res) => {
|
||||
if (req.headers.authorization !== TEST_MCP_BEARER) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end('{"error":"unauthorized"}');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end('{"ok":true}');
|
||||
};
|
||||
|
||||
it('treats a blank GITNEXUS_MCP_AUTH_TOKEN as unset and still strips', async () => {
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: ' ' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.received.headers.authorization, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the edge credential with the upstream MCP token on /api/mcp', async () => {
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
|
||||
await withProxy({ upstream: mcpBackend, env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, 'a backend that demands the MCP token must accept this hop');
|
||||
assert.equal(ctx.received.headers.authorization, TEST_MCP_BEARER);
|
||||
assert.notEqual(
|
||||
ctx.received.headers.authorization,
|
||||
TEST_BEARER,
|
||||
'the edge credential must never be forwarded',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('injects the upstream MCP token on /api/mcp subpaths and ignores the query string', async () => {
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
|
||||
await withProxy({ upstream: mcpBackend, env }, async (port, ctx) => {
|
||||
for (const path of ['/api/mcp/messages', '/api/mcp?session=abc']) {
|
||||
const res = await apiRequest(port, path, { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, `${path} must reach the MCP backend authenticated`);
|
||||
assert.equal(ctx.received.headers.authorization, TEST_MCP_BEARER, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-MCP routes stripped when an upstream MCP token is configured', async () => {
|
||||
// /api/mcpfoo shares a prefix with the MCP route but is not it, and a plain
|
||||
// API route never carries a protocol credential.
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
for (const path of ['/api/mcpfoo', '/api/health']) {
|
||||
const res = await apiRequest(port, path);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.received.headers.authorization, undefined, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('never gates static assets behind the token', async () => {
|
||||
// The UI has to load before it can prompt for a token.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
|
||||
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
|
|
|||
|
|
@ -45,6 +45,21 @@ export type NodeLabel =
|
|||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool'
|
||||
/**
|
||||
* A message-broker destination — a Kafka topic, a Rabbit exchange/routing
|
||||
* key, a JMS queue, a Spring Cloud Stream binding. The framework overlay for
|
||||
* ASYNCHRONOUS entry/exit points, symmetric to `Route` for HTTP.
|
||||
*
|
||||
* Identity is `(broker, resolved ADDRESS)`, so a publisher and a consumer of
|
||||
* the same address on the same broker land on one node and the connection is
|
||||
* a single hop — while a Kafka topic and a Rabbit queue that share a name
|
||||
* stay two nodes, the same way `GET /x` and `POST /x` are two Routes. A
|
||||
* destination whose address could NOT be resolved is keyed by its source
|
||||
* location instead and carries no `address` property at all. See
|
||||
* `pipeline-phases/spring-destinations.ts` for why an unresolved spelling may
|
||||
* not key a node, and `ingestion/destination-key.ts` for why the broker may.
|
||||
*/
|
||||
| 'Destination'
|
||||
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
|
||||
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
|
||||
| 'BasicBlock';
|
||||
|
|
@ -95,6 +110,30 @@ export type NodeProperties = {
|
|||
responseKeys?: string[];
|
||||
errorKeys?: string[];
|
||||
middleware?: string[];
|
||||
/** Route runtime evidence is authoritative only when this is exactly true. */
|
||||
runtimeConfirmed?: boolean;
|
||||
/** Provenance of runtime evidence; presence alone does not imply confirmation. */
|
||||
runtimeSource?: string;
|
||||
/** Runtime result such as runtime-confirmed or handler-conflict. */
|
||||
runtimeStatus?: string;
|
||||
// Destination (async messaging overlay). See the `Destination` label above.
|
||||
/** The RESOLVED broker address. Together with `broker` it is the key a
|
||||
* cross-repository pass joins on. Present only when the address resolved:
|
||||
* absent is the load-bearing state, because an absent property cannot match
|
||||
* another absent property. */
|
||||
address?: string;
|
||||
/** Broker family the syntax attests to (`kafka`, `rabbit`, `jms`, …). Part
|
||||
* of the node's identity alongside `address`, not a label on it. */
|
||||
broker?: string;
|
||||
/** How the address was arrived at (`literal`, `constant`) when it resolved,
|
||||
* or the named reason it did not. */
|
||||
resolution?: string;
|
||||
/** Configuration key named by an unresolvable `${…}` placeholder. The key
|
||||
* only — configuration VALUES are deliberately absent from this graph. */
|
||||
configKey?: string;
|
||||
/** The `${key:default}` default text. Not an address: configuration can
|
||||
* override it and the graph cannot see whether it did. */
|
||||
configDefault?: string;
|
||||
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
|
||||
text?: string;
|
||||
/** BasicBlock: space-joined leaf callee names invoked in the block — the
|
||||
|
|
@ -122,6 +161,19 @@ export type RelationshipType =
|
|||
| 'MEMBER_OF'
|
||||
| 'STEP_IN_PROCESS'
|
||||
| 'HANDLES_ROUTE'
|
||||
/** Outbound async messaging. Source = the callable that performs the publish
|
||||
* (or its File); target = the `Destination` it publishes to. Emitted by
|
||||
* `pipeline-phases/spring-destinations.ts` from Spring messaging-template
|
||||
* calls (`kafkaTemplate.send(...)`, `rabbitTemplate.convertAndSend(...)`).
|
||||
* One edge per address: a publish that names two destinations yields two
|
||||
* edges, and `reason` records which argument each came from. */
|
||||
| 'PUBLISHES_TO'
|
||||
/** Inbound async messaging — the mirror of `PUBLISHES_TO`. Source = the
|
||||
* annotated handler callable (or its File); target = the `Destination` it
|
||||
* subscribes to. Emitted from `@KafkaListener` / `@RabbitListener` /
|
||||
* `@JmsListener` and their siblings. Together the two types make
|
||||
* "who else reads what this service writes" a two-hop traversal. */
|
||||
| 'CONSUMES_FROM'
|
||||
| 'FETCHES'
|
||||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export const NODE_TABLES = [
|
|||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
// Async messaging overlay — the broker-side counterpart of `Route`.
|
||||
'Destination',
|
||||
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
|
||||
'BasicBlock',
|
||||
] as const;
|
||||
|
|
@ -64,6 +66,8 @@ export const REL_TYPES = [
|
|||
'MEMBER_OF',
|
||||
'STEP_IN_PROCESS',
|
||||
'HANDLES_ROUTE',
|
||||
'PUBLISHES_TO',
|
||||
'CONSUMES_FROM',
|
||||
'FETCHES',
|
||||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@
|
|||
import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import { NODE_TABLES, REL_TYPES, scoreImpactRisk, unusedAxesForImpactWalk } from 'gitnexus-shared';
|
||||
import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client';
|
||||
import type {
|
||||
EnrichedSearchResult,
|
||||
GrepOptions,
|
||||
GrepResponse,
|
||||
} from '../../services/backend-client';
|
||||
|
||||
/**
|
||||
* Tool names registered by createGraphRAGTools — kept in sync with each tool's `name`
|
||||
|
|
@ -44,7 +48,7 @@ export interface GraphRAGBackend {
|
|||
query: string,
|
||||
opts?: { limit?: number; mode?: 'hybrid' | 'semantic' | 'bm25'; enrich?: boolean },
|
||||
) => Promise<EnrichedSearchResult[]>;
|
||||
grep: (pattern: string, limit?: number) => Promise<GrepResult[]>;
|
||||
grep: (pattern: string, limit?: number, opts?: GrepOptions) => Promise<GrepResponse>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
}
|
||||
|
||||
|
|
@ -375,20 +379,22 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
}
|
||||
|
||||
const limit = maxResults ?? 100;
|
||||
const fullPattern = fileFilter
|
||||
? `(?=.*${fileFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}).*${pattern}`
|
||||
: pattern;
|
||||
|
||||
const results = await backendGrep(fullPattern, limit);
|
||||
const { results, timedOut } = await backendGrep(pattern, limit, {
|
||||
fileFilter,
|
||||
caseSensitive,
|
||||
});
|
||||
const timeoutMsg = timedOut
|
||||
? '\n\n(Scan timed out after a few seconds — results may be incomplete)'
|
||||
: '';
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}`;
|
||||
return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}${timeoutMsg}`;
|
||||
}
|
||||
|
||||
const formatted = results.map((r) => `${r.filePath}:${r.line}: ${r.text}`).join('\n');
|
||||
const truncatedMsg = results.length >= limit ? `\n\n(Showing first ${limit} results)` : '';
|
||||
|
||||
return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}`;
|
||||
return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}${timeoutMsg}`;
|
||||
} catch (error) {
|
||||
return `Grep error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
|
@ -396,16 +402,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
{
|
||||
name: 'grep',
|
||||
description:
|
||||
'Search for exact text patterns across all files using regex. Use for finding specific strings, error messages, TODOs, variable names, etc.',
|
||||
'Search file contents with a regular expression (server executes it as a real regex — alternation like "sign|Sign" works). Matches are case-insensitive unless caseSensitive is set. fileFilter keeps only files whose path contains the substring. Each call caps at maxResults matches (default 100) and the server stops after a few seconds (the tool will say so if the scan was incomplete), so prefer precise patterns over catch-alls.',
|
||||
schema: z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'),
|
||||
.describe(
|
||||
'Regex pattern to search for (e.g., "TODO|FIXME", "console\\.log", "signOrder")',
|
||||
),
|
||||
fileFilter: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe('Only search files containing this string (e.g., ".ts", "src/api")'),
|
||||
.describe(
|
||||
'Only search files whose path contains this substring (e.g., ".ts", "src/api", "Controller.java")',
|
||||
),
|
||||
caseSensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
|
@ -1219,7 +1229,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
const targetFileName = (targetFilePath || target).split('/').pop() || target;
|
||||
const baseName = targetFileName.replace(/\.[^/.]+$/, '');
|
||||
try {
|
||||
const hints = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
|
||||
const { results: hints } = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
|
||||
const filtered = hints.filter((h) => h.filePath !== targetFilePath);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
repoIdentity as repoIdentityOf,
|
||||
type BackendRepo,
|
||||
type ConnectResult,
|
||||
type GrepOptions,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
|
||||
|
|
@ -671,7 +672,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
const backend = {
|
||||
executeQuery,
|
||||
search: (query: string, opts?: any) => backendSearch(query, { ...opts, repo }),
|
||||
grep: (pattern: string, limit?: number) => backendGrep(pattern, repo, limit),
|
||||
grep: (pattern: string, limit?: number, opts?: GrepOptions) =>
|
||||
backendGrep(pattern, repo, limit, opts),
|
||||
readFile: (filePath: string) =>
|
||||
backendReadFile(filePath, { repo }).then((r) => r.content),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
|
|||
Constructor: '#10b981', // Emerald - like Function
|
||||
Template: '#a78bfa', // Violet light - like Type
|
||||
Route: '#f43f5e', // Rose - like Process
|
||||
Destination: '#fb7185', // Rose light - like Route, the broker-side counterpart
|
||||
Tool: '#a855f7', // Purple - like Project
|
||||
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
|
||||
};
|
||||
|
|
@ -79,6 +80,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
|
|||
Constructor: 4, // Like Function
|
||||
Template: 3, // Like Type
|
||||
Route: 5, // Like Enum
|
||||
Destination: 5, // Like Route - the broker-side counterpart
|
||||
Tool: 5, // Like Enum
|
||||
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ export interface GrepResult {
|
|||
text: string;
|
||||
}
|
||||
|
||||
/** Full `/api/grep` payload — `timedOut` is true when the 5s budget cut the scan short. */
|
||||
export interface GrepResponse {
|
||||
results: GrepResult[];
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export interface JobProgress {
|
||||
phase: string;
|
||||
percent: number;
|
||||
|
|
@ -869,23 +875,37 @@ export const search = async (
|
|||
return (body.results ?? []) as EnrichedSearchResult[];
|
||||
};
|
||||
|
||||
/** Grep across file contents in the indexed repo. */
|
||||
/** Options for {@link grep} beyond pattern/repo/limit. */
|
||||
export interface GrepOptions {
|
||||
/** Only search files whose path contains this substring (case-insensitive). */
|
||||
fileFilter?: string | null;
|
||||
/** Case-sensitive matching (default: insensitive). */
|
||||
caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
/** Grep across file contents in the indexed repo. Regex semantics server-side. */
|
||||
export const grep = async (
|
||||
pattern: string,
|
||||
repo?: string,
|
||||
limit?: number,
|
||||
): Promise<GrepResult[]> => {
|
||||
opts?: GrepOptions,
|
||||
): Promise<GrepResponse> => {
|
||||
const params = [
|
||||
`pattern=${encodeURIComponent(pattern)}`,
|
||||
repoParam(repo),
|
||||
limit ? `limit=${limit}` : '',
|
||||
opts?.fileFilter ? `fileFilter=${encodeURIComponent(opts.fileFilter)}` : '',
|
||||
opts?.caseSensitive ? 'caseSensitive=1' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`);
|
||||
await assertOk(response);
|
||||
const body = await response.json();
|
||||
return (body.results ?? []) as GrepResult[];
|
||||
const body = (await response.json()) as Partial<GrepResponse>;
|
||||
return {
|
||||
results: body.results ?? [],
|
||||
timedOut: body.timedOut === true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Result from reading a file, optionally with line range. */
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const FORBIDDEN_TOOL_NAMES = [
|
|||
const stubBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => [],
|
||||
grep: async () => ({ results: [], timedOut: false }),
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
|
|
|
|||
75
gitnexus-web/test/unit/backend-client-grep.test.ts
Normal file
75
gitnexus-web/test/unit/backend-client-grep.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* `/api/grep` client: query params and `timedOut` must reach callers.
|
||||
* Dropping `timedOut` made a 5s partial scan look like a complete miss.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import { grep, setBackendUrl } from '../../src/services/backend-client';
|
||||
|
||||
const BASE = 'http://grep-client.test:4747';
|
||||
|
||||
const jsonOk = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('backend-client grep', () => {
|
||||
beforeEach(() => {
|
||||
__resetBreakerRegistry__();
|
||||
setBackendUrl(BASE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('forwards fileFilter and caseSensitive and returns timedOut', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
expect(url).toContain('/api/grep?');
|
||||
expect(url).toContain(`pattern=${encodeURIComponent('sign|Sign')}`);
|
||||
expect(url).toContain(`fileFilter=${encodeURIComponent('src/api')}`);
|
||||
expect(url).toContain('caseSensitive=1');
|
||||
expect(url).toContain('limit=12');
|
||||
return jsonOk({
|
||||
results: [{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }],
|
||||
timedOut: true,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const body = await grep('sign|Sign', '/repo', 12, {
|
||||
fileFilter: 'src/api',
|
||||
caseSensitive: true,
|
||||
});
|
||||
expect(body.results).toEqual([{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }]);
|
||||
expect(body.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('reports timedOut false when the server completed the scan', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
return jsonOk({ results: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await grep('TODO');
|
||||
expect(body).toEqual({ results: [], timedOut: false });
|
||||
});
|
||||
|
||||
it('does not send fileFilter when it is null or empty', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
expect(url).not.toContain('fileFilter=');
|
||||
return jsonOk({ results: [] });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
for (const fileFilter of ['', null] as const) {
|
||||
await grep('x', undefined, undefined, { fileFilter });
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
36
gitnexus-web/test/unit/grep-tool.test.ts
Normal file
36
gitnexus-web/test/unit/grep-tool.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools';
|
||||
|
||||
const noOpBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => ({ results: [], timedOut: false }),
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
function grepTool(backend: GraphRAGBackend) {
|
||||
return createGraphRAGTools(backend).find((candidate) => candidate.name === 'grep')!;
|
||||
}
|
||||
|
||||
describe('grep tool timeout contract', () => {
|
||||
it('says the scan was incomplete when the server sets timedOut with no hits', async () => {
|
||||
const grep = vi.fn(async () => ({ results: [], timedOut: true }));
|
||||
const output = await grepTool({ ...noOpBackend, grep }).invoke({ pattern: 'signOrder' });
|
||||
expect(output).toContain('No matches for "signOrder"');
|
||||
expect(output).toContain('results may be incomplete');
|
||||
});
|
||||
|
||||
it('still warns when a timed-out scan returned some hits below the limit', async () => {
|
||||
const grep = vi.fn(async () => ({
|
||||
results: [{ filePath: 'a.ts', line: 1, text: 'signOrder()' }],
|
||||
timedOut: true,
|
||||
}));
|
||||
const output = await grepTool({ ...noOpBackend, grep }).invoke({
|
||||
pattern: 'signOrder',
|
||||
maxResults: 100,
|
||||
});
|
||||
expect(output).toContain('Found 1 matches');
|
||||
expect(output).toContain('results may be incomplete');
|
||||
expect(output).not.toContain('Showing first');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@ import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/to
|
|||
const noOpBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => [],
|
||||
grep: async () => ({ results: [], timedOut: false }),
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -240,10 +240,11 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
|
|||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected)
|
||||
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
|
||||
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
|
|
@ -323,6 +324,8 @@ and root fields. Dynamic decorator names, anonymous operations, and ambiguous or
|
|||
anchors are deliberately omitted. Add common infrastructure fields such as `/health` to
|
||||
`matching.exclude_links_paths` to keep those GraphQL contracts visible without cross-linking them.
|
||||
|
||||
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
## Remote Embeddings
|
||||
|
|
@ -431,7 +434,7 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
|
|||
LadybugDB native binary ships as a prebuild against that floor, so on an older host it cannot
|
||||
load and reinstalling does not help — see
|
||||
[Linux: `GLIBC_2.34' not found`](#linux-glibc_234-not-found).
|
||||
- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) *and*
|
||||
- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) _and_
|
||||
OpenSSL 3 (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) resolvable on `PATH` — see
|
||||
[Windows: full-text search unavailable](#windows-full-text-search-unavailable).
|
||||
|
||||
|
|
@ -708,17 +711,17 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
|
|||
|
||||
Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
|
||||
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
|
||||
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
|
||||
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
|
||||
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
|
||||
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
|
||||
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
|
||||
|
||||
### Graph cleanup tuning
|
||||
|
||||
|
|
@ -732,8 +735,8 @@ Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions`
|
|||
|
||||
### Scope-resolution property-key dispatch cap
|
||||
|
||||
During scope resolution GitNexus synthesizes CALLS edges through *property-key
|
||||
dispatch* — call sites like `hooks.emitScopeCaptures()` where a property key is
|
||||
During scope resolution GitNexus synthesizes CALLS edges through _property-key
|
||||
dispatch_ — call sites like `hooks.emitScopeCaptures()` where a property key is
|
||||
registered by multiple definitions across the codebase. To keep this fan-in
|
||||
bounded, each property key is capped at **32 registrations**: a key registered
|
||||
by more than 32 distinct functions is skipped entirely (no CALLS are synthesized
|
||||
|
|
@ -741,8 +744,8 @@ through it), and the dropped key names are surfaced in the analyze log for
|
|||
operator visibility. The cap is calibrated at 2× this repo's own provider table
|
||||
(16 legitimate registrations, one per language provider).
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Variable | Default | Effect |
|
||||
| --------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` | `32` | Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
|
||||
|
||||
```bash
|
||||
|
|
@ -755,11 +758,11 @@ npx gitnexus analyze --force
|
|||
|
||||
### Scope-resolution dispatch-target cap
|
||||
|
||||
During scope resolution GitNexus resolves calls that flow through *callable
|
||||
values* — function/method references bound to variables, passed as arguments,
|
||||
During scope resolution GitNexus resolves calls that flow through _callable
|
||||
values_ — function/method references bound to variables, passed as arguments,
|
||||
or stored in maps/tables. To keep that inclusion-based resolution finite, each
|
||||
callable site is capped at **32 dispatch targets**. When a site gathers more
|
||||
candidates than the cap it is treated as **overflowed** and *all* of its call
|
||||
candidates than the cap it is treated as **overflowed** and _all_ of its call
|
||||
edges are dropped — a cliff, not a tail, so a repository with a legitimately
|
||||
wide dispatch table (a single callable site resolving to 33+ targets) loses
|
||||
that site's whole call chain. In that case `analyze` logs
|
||||
|
|
@ -769,8 +772,8 @@ candidate count, and the cap (32).
|
|||
|
||||
Raise the cap for such repositories:
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` | `32` | Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e",
|
||||
"fingerprint": "72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_rebaselined_destination_broker_conflict_column_removed": "The `Destination` node table lost its trailing `brokerConflict` STRING column (DESTINATION_SCHEMA in src/core/lbug/schema.ts, the COPY statement in lbug-adapter.ts, and the `destinationWriter` header plus its row cell in csv-generator.ts). The column existed only to say WHY a destination's address had been withdrawn when two brokers claimed it; the address is no longer withdrawn — a resolved `Destination` is now keyed by `(broker, address)` via `ingestion/destination-key.ts`, so two brokers on one name are two ordinary joinable nodes and there is nothing to diagnose. That makes this a header-only shrink, and it was verified as one rather than assumed: dumping every CSV this bench emits with csv-generator.ts at the merge base and again on this branch, then diffing per file by filename, byte length and sha256, shows the file SET identical at 36 CSVs on both sides, 35 of the 36 byte-IDENTICAL (same sha256, not merely same length), and the sole difference `destination.csv` shrinking 112 -> 97 bytes: `id,name,filePath,startLine,endLine,address,broker,resolution,configKey,configDefault,brokerConflict,description` -> the same list without `brokerConflict`. That file is header-only on both sides — the synthetic benchmark graph contains no Destination nodes — so no row moved, was re-routed to another pair file, or reordered, which is the class of change this fingerprint exists to catch. Prior 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf -> 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a, reproduced identically across two consecutive runs. Both timing gates passed while the guard was red (scaling_ratio 0.818 and 0.751 across those two runs against the 1.8 budget; elapsed_ms_large 65.64ms and 59.32ms against the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_3132_destination_node_table": "A new `Destination` node table (async messaging overlay; see DESTINATION_SCHEMA in src/core/lbug/schema.ts) means `streamAllCSVsToDisk` writes one more FILE, not one more column — the first rebaseline here that changes the file SET rather than a header. That makes the usual evidence more important, not less, so it was gathered the same way: dump every CSV this bench emits on the merge base and on this branch, then diff per-file by filename, byte length and sha256. Result: 35 files -> 36, the sole addition is `destination.csv`, and ALL 35 pre-existing files are byte-IDENTICAL — not merely same-length, same sha256. So nothing was re-routed into the new file and nothing reordered, which is exactly what this fingerprint exists to catch. `destination.csv` is 112 bytes, header only: the synthetic benchmark graph has no Destination nodes, so no row exists to move. Prior 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2 -> 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf, reproduced identically across two runs. Both timing gates passed while this was red (scaling_ratio 0.711 vs the 1.8 budget, elapsed_ms_large 58.88ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
|
||||
"_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
}
|
||||
|
|
|
|||
8
gitnexus/bench/java-lombok-synthesis/baselines.json
Normal file
8
gitnexus/bench/java-lombok-synthesis/baselines.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/java-lombok-synthesis/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the lombok_large corpus (800 @Data entities × 4 fields × 2 accessors = 6400 methods). no_lombok arm must emit 0 methods. Budgets are timing gates with CI headroom.",
|
||||
"fingerprint": "b935d6894d32de7594d5887bb62af6ade2b66b19d6846700a05ef3baf1ed1eb1",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) on the lombok arm. Measured ~1.01.",
|
||||
"widening_overhead_budget": 2.5,
|
||||
"_widening_overhead_note": "lombok_large_ms / no_lombok_large_ms using an unannotated, shape-equivalent four-field control. Measured about 1.24; budget guards against a pathological feature-arm regression."
|
||||
}
|
||||
124
gitnexus/bench/java-lombok-synthesis/measure.mjs
Normal file
124
gitnexus/bench/java-lombok-synthesis/measure.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Build-free throughput + identity bench for Java Lombok accessor synthesis.
|
||||
*
|
||||
* Arms:
|
||||
* - no_lombok: unannotated fields (shape-equivalent control) — synthesizer no-ops
|
||||
* - lombok_heavy: @Data classes (feature path)
|
||||
*
|
||||
* Times synthesizeLombokAccessors over N separate files (not one giant buffer).
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/java-lombok-synthesis/measure.mjs
|
||||
* node --import tsx bench/java-lombok-synthesis/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/java/lombok-synthesizer.ts';
|
||||
import {
|
||||
fingerprintIds,
|
||||
minSample,
|
||||
runBaselineCheck,
|
||||
runMethodCountCheck,
|
||||
} from '../lib/identity-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
function entitySource(i, mode) {
|
||||
if (mode === 'lombok') {
|
||||
return `import lombok.Data;
|
||||
@Data
|
||||
public class Entity${i} {
|
||||
private String id;
|
||||
private String name;
|
||||
private boolean active;
|
||||
private Long amount;
|
||||
}
|
||||
`;
|
||||
}
|
||||
return `public class Entity${i} {
|
||||
private String id;
|
||||
private String name;
|
||||
private boolean active;
|
||||
private Long amount;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function ownerMap(tree, filePath) {
|
||||
const map = new Map();
|
||||
const walk = (node) => {
|
||||
if (node.type === 'class_declaration') {
|
||||
const name = node.childForFieldName('name')?.text;
|
||||
if (name) map.set(node.id, `Class:${filePath}:${name}`);
|
||||
}
|
||||
for (const c of node.children) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const files = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Java);
|
||||
const filePath = `bench/${mode}/Entity${i}.java`;
|
||||
const tree = parser.parse(entitySource(i, mode));
|
||||
files.push({ tree, filePath, owners: ownerMap(tree, filePath) });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function runAll(files) {
|
||||
const nodes = [];
|
||||
for (const f of files) {
|
||||
const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners);
|
||||
for (const n of result.nodes) nodes.push(n.id);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const files = prepare(mode, fileCount);
|
||||
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
methods: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
no_lombok_small: measure('bare', SMALL),
|
||||
no_lombok_large: measure('bare', LARGE),
|
||||
lombok_small: measure('lombok', SMALL),
|
||||
lombok_large: measure('lombok', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.lombok_large.ms / report.lombok_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.widening_overhead = Number(
|
||||
(report.lombok_large.ms / Math.max(report.no_lombok_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.fingerprint = report.lombok_large.fingerprint;
|
||||
|
||||
runMethodCountCheck(report, {
|
||||
no_lombok_large: 0,
|
||||
lombok_large: 6400,
|
||||
});
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/java-wildcard-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 materialized route bindings from 800 constant files and must match the named-import control. The benchmark builds the constant import index once per repo pass, matching ingestion and group wiring.",
|
||||
"fingerprint": "8114e613e93ce0ef6220b810850888592e0e822fe04bf2eb5d8fc4ec3dbba5ef",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) while both constant files and wildcard importers scale. Measured about 1.14 with the suffix index; repeated candidate scans are quadratic.",
|
||||
"absolute_ms_budget": 10,
|
||||
"_absolute_ms_note": "Wildcard materialization for 800 controllers. Measured about 1.1 ms; the generous ceiling catches gross regressions without treating the near-zero named-import control as a stable ratio denominator."
|
||||
}
|
||||
158
gitnexus/bench/java-wildcard-route-constants/measure.mjs
Normal file
158
gitnexus/bench/java-wildcard-route-constants/measure.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Build-free throughput + identity benchmark for Java wildcard-static route constants.
|
||||
*
|
||||
* Arms:
|
||||
* - named: explicit `import static ...ApiPaths.ROUTE_n` control
|
||||
* - wildcard: `import static ...ApiPaths.*` feature path
|
||||
*
|
||||
* Parsing is prepared outside the timer. The measured path mirrors ingestion:
|
||||
* build the constant-key index once, materialize pending wildcard imports, then
|
||||
* read the resulting binding. Route folding itself has separate integration
|
||||
* coverage and an older per-fold index cost shared by both arms.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/java-wildcard-route-constants/measure.mjs
|
||||
* node --import tsx bench/java-wildcard-route-constants/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
prepareJavaRouteConstants,
|
||||
} from '../../src/core/ingestion/route-extractors/java-const-resolver.ts';
|
||||
import {
|
||||
fingerprintIds,
|
||||
minSampleFresh,
|
||||
runBaselineCheck,
|
||||
runCountCheck,
|
||||
runFingerprintParityCheck,
|
||||
} from '../lib/route-constant-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Java);
|
||||
|
||||
function constantsSource(i) {
|
||||
return `package bench.constants;
|
||||
public final class ApiPaths${i} {
|
||||
public static final String ROUTE = "/api/routes/${i}";
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function controllerSource(i, mode) {
|
||||
const fqn = `bench.constants.ApiPaths${i}`;
|
||||
const imported = mode === 'wildcard' ? `import static ${fqn}.*;` : `import static ${fqn}.ROUTE;`;
|
||||
return `package bench.web;
|
||||
${imported}
|
||||
class Controller${i} {}
|
||||
`;
|
||||
}
|
||||
|
||||
function cloneConstants(mc) {
|
||||
return {
|
||||
literals: new Map(mc.literals),
|
||||
exprs: new Map(mc.exprs),
|
||||
imports: new Map(mc.imports),
|
||||
wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined,
|
||||
unfoldableDeclarations: new Set(mc.unfoldableDeclarations ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const constants = [];
|
||||
const controllers = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
constants.push({
|
||||
key: `bench/constants/ApiPaths${i}.java`,
|
||||
constants: extractJavaModuleConstants(parser.parse(constantsSource(i))),
|
||||
});
|
||||
controllers.push({
|
||||
key: `bench/web/Controller${i}.java`,
|
||||
route: 'ROUTE',
|
||||
constants: extractJavaModuleConstants(parser.parse(controllerSource(i, mode))),
|
||||
});
|
||||
}
|
||||
return { constants, controllers };
|
||||
}
|
||||
|
||||
function instantiate(prepared) {
|
||||
const repo = new Map();
|
||||
for (const constant of prepared.constants) {
|
||||
repo.set(constant.key, cloneConstants(constant.constants));
|
||||
}
|
||||
const controllers = [];
|
||||
for (const controller of prepared.controllers) {
|
||||
repo.set(controller.key, cloneConstants(controller.constants));
|
||||
controllers.push({ key: controller.key, route: controller.route });
|
||||
}
|
||||
return { repo, controllers };
|
||||
}
|
||||
|
||||
function runAll(instance) {
|
||||
const { repo, controllers } = instance;
|
||||
prepareJavaRouteConstants(repo);
|
||||
const bindings = [];
|
||||
for (const controller of controllers) {
|
||||
const mc = repo.get(controller.key);
|
||||
const binding = mc.imports.get(controller.route);
|
||||
if (binding) {
|
||||
bindings.push(
|
||||
`${controller.key}:${controller.route}:${binding.module}:${binding.originalName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const prepared = prepare(mode, fileCount);
|
||||
// Expansion mutates each importing file's `imports` map. Give every timed
|
||||
// sample a fresh repo, but build those clones outside the timer.
|
||||
const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
bindings: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
named_small: measure('named', SMALL),
|
||||
named_large: measure('named', LARGE),
|
||||
wildcard_small: measure('wildcard', SMALL),
|
||||
wildcard_large: measure('wildcard', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.overhead_us_per_binding = Number(
|
||||
(
|
||||
((report.wildcard_large.ms - report.named_large.ms) * 1000) /
|
||||
report.wildcard_large.bindings
|
||||
).toFixed(3),
|
||||
);
|
||||
report.absolute_ms = report.wildcard_large.ms;
|
||||
report.fingerprint = report.wildcard_large.fingerprint;
|
||||
|
||||
runCountCheck(report, 'bindings', {
|
||||
named_large: LARGE,
|
||||
wildcard_large: LARGE,
|
||||
});
|
||||
runFingerprintParityCheck(report, 'named_large', 'wildcard_large');
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
8
gitnexus/bench/kotlin-jvm-accessors/baselines.json
Normal file
8
gitnexus/bench/kotlin-jvm-accessors/baselines.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/kotlin-jvm-accessors/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the data_large corpus (800 data classes × 4 vars × 2 accessors = 6400 methods). no_props arm uses @JvmField so kotlinc and the synthesizer emit 0 accessor methods. Budgets are timing gates with CI headroom.",
|
||||
"fingerprint": "18e4f295a437a747c486699e8ec5d310d9bde54437d9a96356a1b1bf8442b0ef",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) on the data-class arm. Measured ~1.02.",
|
||||
"widening_overhead_budget": 2.5,
|
||||
"_widening_overhead_note": "data_large_ms / no_props_large_ms. The @JvmField control preserves four property declarations without accessors; budget guards against a pathological synthesis-arm regression."
|
||||
}
|
||||
121
gitnexus/bench/kotlin-jvm-accessors/measure.mjs
Normal file
121
gitnexus/bench/kotlin-jvm-accessors/measure.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Build-free throughput + identity bench for Kotlin JVM accessor synthesis.
|
||||
*
|
||||
* Arms:
|
||||
* - no_props: @JvmField properties with no JVM accessors (control)
|
||||
* - data_class: data class constructor properties (feature path)
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/kotlin-jvm-accessors/measure.mjs
|
||||
* node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
|
||||
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/kotlin/lombok-synthesizer.ts';
|
||||
import {
|
||||
fingerprintIds,
|
||||
minSample,
|
||||
runBaselineCheck,
|
||||
runMethodCountCheck,
|
||||
} from '../lib/identity-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
function entitySource(i, mode) {
|
||||
if (mode === 'data') {
|
||||
return `data class Entity${i}(var id: String, var name: String, var active: Boolean, var amount: Long)
|
||||
`;
|
||||
}
|
||||
// @JvmField suppresses accessors in kotlinc and in the synthesizer while
|
||||
// retaining the same four property declarations as the feature arm.
|
||||
return `class Entity${i} {
|
||||
@JvmField var id: String = ""
|
||||
@JvmField var name: String = ""
|
||||
@JvmField var active: Boolean = false
|
||||
@JvmField var amount: Long = 0
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function ownerMap(tree, filePath) {
|
||||
const map = new Map();
|
||||
const walk = (node) => {
|
||||
if (node.type === 'class_declaration' || node.type === 'object_declaration') {
|
||||
const name =
|
||||
node.childForFieldName('name')?.text ??
|
||||
node.namedChildren.find((c) => c.type === 'type_identifier')?.text;
|
||||
if (name) map.set(node.id, `Class:${filePath}:${name}`);
|
||||
}
|
||||
for (const c of node.children) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const files = [];
|
||||
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(lang);
|
||||
const filePath = `bench/${mode}/Entity${i}.kt`;
|
||||
const tree = parser.parse(entitySource(i, mode));
|
||||
files.push({ tree, filePath, owners: ownerMap(tree, filePath), parser });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function runAll(files) {
|
||||
const nodes = [];
|
||||
for (const f of files) {
|
||||
const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners);
|
||||
for (const n of result.nodes) nodes.push(n.id);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const files = prepare(mode, fileCount);
|
||||
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
methods: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
no_props_small: measure('hand', SMALL),
|
||||
no_props_large: measure('hand', LARGE),
|
||||
data_small: measure('data', SMALL),
|
||||
data_large: measure('data', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.data_large.ms / report.data_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.widening_overhead = Number(
|
||||
(report.data_large.ms / Math.max(report.no_props_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.fingerprint = report.data_large.fingerprint;
|
||||
|
||||
runMethodCountCheck(report, {
|
||||
no_props_large: 0,
|
||||
data_large: 6400,
|
||||
});
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
10
gitnexus/bench/kotlin-star-route-constants/baselines.json
Normal file
10
gitnexus/bench/kotlin-star-route-constants/baselines.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/kotlin-star-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 folded route facts from 800 constant files and must match the explicit-import control. The feature arm resolves package-star names through one prepared KotlinConstantIndex.",
|
||||
"fingerprint": "881101236c511d73d3894d3c9bd2e4a166e3437329149dd99fd16c256435482e",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) while both constant files and importing controllers scale. Measured about 1.07-1.14.",
|
||||
"widening_overhead_budget": 2.5,
|
||||
"_widening_overhead_note": "star_large_ms / named_large_ms. Measured below 1.0; budget guards against a pathological star-lookup regression.",
|
||||
"absolute_ms_budget": 5,
|
||||
"_absolute_ms_note": "Package-star folding for 800 controllers. Measured below 0.6 ms; budget includes substantial CI headroom."
|
||||
}
|
||||
156
gitnexus/bench/kotlin-star-route-constants/measure.mjs
Normal file
156
gitnexus/bench/kotlin-star-route-constants/measure.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Build-free throughput + identity benchmark for Kotlin package-star route constants.
|
||||
*
|
||||
* Arms:
|
||||
* - named: explicit `import bench.constants.ROUTE_n` control
|
||||
* - star: `import bench.constants.*` feature path
|
||||
*
|
||||
* Parsing is prepared outside the timer. The measured path mirrors the Kotlin
|
||||
* group plugin: overlay one importing controller on the prepared constant
|
||||
* index, then fold its route.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/kotlin-star-route-constants/measure.mjs
|
||||
* node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.ts';
|
||||
import {
|
||||
buildKotlinConstantIndex,
|
||||
extractKotlinModuleConstants,
|
||||
foldKotlinOperands,
|
||||
overlayKotlinConstantIndex,
|
||||
} from '../../src/core/ingestion/route-extractors/kotlin-const-resolver.ts';
|
||||
import {
|
||||
fingerprintIds,
|
||||
minSampleFresh,
|
||||
runBaselineCheck,
|
||||
runCountCheck,
|
||||
runFingerprintParityCheck,
|
||||
} from '../lib/route-constant-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(requireVendoredGrammar('tree-sitter-kotlin'));
|
||||
|
||||
function constantsSource(i) {
|
||||
return `package bench.constants
|
||||
const val ROUTE_${i} = "/api/routes/${i}"
|
||||
`;
|
||||
}
|
||||
|
||||
function controllerSource(i, mode) {
|
||||
const route = `ROUTE_${i}`;
|
||||
const imported = mode === 'star' ? 'import bench.constants.*' : `import bench.constants.${route}`;
|
||||
return `package bench.web
|
||||
${imported}
|
||||
class Controller${i}
|
||||
`;
|
||||
}
|
||||
|
||||
function cloneConstants(mc) {
|
||||
return {
|
||||
literals: new Map(mc.literals),
|
||||
exprs: new Map(mc.exprs),
|
||||
imports: new Map(mc.imports),
|
||||
wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined,
|
||||
packageName: mc.packageName,
|
||||
unfoldableDeclarations: new Set(mc.unfoldableDeclarations),
|
||||
topLevelDeclarations: new Set(mc.topLevelDeclarations),
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const constants = [];
|
||||
const controllers = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
constants.push({
|
||||
key: `bench/constants/ApiPaths${i}.kt`,
|
||||
constants: extractKotlinModuleConstants(parser.parse(constantsSource(i))),
|
||||
});
|
||||
controllers.push({
|
||||
key: `bench/web/Controller${i}.kt`,
|
||||
route: `ROUTE_${i}`,
|
||||
constants: extractKotlinModuleConstants(parser.parse(controllerSource(i, mode))),
|
||||
});
|
||||
}
|
||||
return { constants, controllers };
|
||||
}
|
||||
|
||||
function instantiate(prepared) {
|
||||
const baseRepo = new Map();
|
||||
for (const constant of prepared.constants) {
|
||||
baseRepo.set(constant.key, cloneConstants(constant.constants));
|
||||
}
|
||||
const controllers = prepared.controllers.map((controller) => ({
|
||||
key: controller.key,
|
||||
route: controller.route,
|
||||
constants: cloneConstants(controller.constants),
|
||||
}));
|
||||
return { baseRepo, controllers };
|
||||
}
|
||||
|
||||
function runAll(instance) {
|
||||
const { baseRepo, controllers } = instance;
|
||||
const baseIndex = buildKotlinConstantIndex(baseRepo);
|
||||
const routes = [];
|
||||
for (const controller of controllers) {
|
||||
const index = overlayKotlinConstantIndex(baseIndex, controller.key, controller.constants);
|
||||
const route = foldKotlinOperands(
|
||||
controller.key,
|
||||
[{ kind: 'ref', name: controller.route }],
|
||||
index.repo,
|
||||
[],
|
||||
index,
|
||||
);
|
||||
if (route !== null) routes.push(`${controller.key}:${route}`);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const prepared = prepare(mode, fileCount);
|
||||
const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
routes: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
named_small: measure('named', SMALL),
|
||||
named_large: measure('named', LARGE),
|
||||
star_small: measure('star', SMALL),
|
||||
star_large: measure('star', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.star_large.ms / report.star_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.widening_overhead = Number(
|
||||
(report.star_large.ms / Math.max(report.named_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.absolute_ms = report.star_large.ms;
|
||||
report.fingerprint = report.star_large.fingerprint;
|
||||
|
||||
runCountCheck(report, 'routes', {
|
||||
named_large: LARGE,
|
||||
star_large: LARGE,
|
||||
});
|
||||
runFingerprintParityCheck(report, 'named_large', 'star_large');
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
62
gitnexus/bench/lib/identity-guard.mjs
Normal file
62
gitnexus/bench/lib/identity-guard.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Shared fingerprint + --check for JVM accessor synthesis benches.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export function fingerprintIds(ids) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update([...ids].sort().join('\n'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function minSample(run, warmup, reps) {
|
||||
for (let w = 0; w < warmup; w++) run();
|
||||
const samples = [];
|
||||
let last;
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = performance.now();
|
||||
last = run();
|
||||
samples.push(performance.now() - t0);
|
||||
}
|
||||
return { last, ms: Math.min(...samples) };
|
||||
}
|
||||
|
||||
export function runMethodCountCheck(report, expectedCounts) {
|
||||
const errors = [];
|
||||
for (const [arm, expected] of Object.entries(expectedCounts)) {
|
||||
const actual = report[arm]?.methods;
|
||||
if (actual !== expected) {
|
||||
errors.push(`${arm}.methods ${String(actual)} != ${expected}`);
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error(JSON.stringify({ report, errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runBaselineCheck(report, baselinePath) {
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
||||
const errors = [];
|
||||
if (report.fingerprint !== baseline.fingerprint) {
|
||||
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
|
||||
}
|
||||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
|
||||
}
|
||||
if (
|
||||
baseline.widening_overhead_budget !== undefined &&
|
||||
report.widening_overhead > baseline.widening_overhead_budget
|
||||
) {
|
||||
errors.push(
|
||||
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
|
||||
);
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error(JSON.stringify({ report, errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, report }, null, 2));
|
||||
}
|
||||
77
gitnexus/bench/lib/route-constant-guard.mjs
Normal file
77
gitnexus/bench/lib/route-constant-guard.mjs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/** Shared fingerprint + --check helpers for route-constant benchmarks. */
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export function fingerprintIds(ids) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update([...ids].sort().join('\n'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/** Min sample for mutating benchmarks that need fresh state per repetition. */
|
||||
export function minSampleFresh(create, run, warmup, reps) {
|
||||
for (let w = 0; w < warmup; w++) run(create());
|
||||
const samples = [];
|
||||
let last;
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const state = create();
|
||||
const t0 = performance.now();
|
||||
last = run(state);
|
||||
samples.push(performance.now() - t0);
|
||||
}
|
||||
return { last, ms: Math.min(...samples) };
|
||||
}
|
||||
|
||||
export function runCountCheck(report, field, expectedCounts) {
|
||||
const errors = [];
|
||||
for (const [arm, expected] of Object.entries(expectedCounts)) {
|
||||
const actual = report[arm]?.[field];
|
||||
if (actual !== expected) {
|
||||
errors.push(`${arm}.${field} ${String(actual)} != ${expected}`);
|
||||
}
|
||||
}
|
||||
failIfNeeded(report, errors);
|
||||
}
|
||||
|
||||
export function runFingerprintParityCheck(report, leftArm, rightArm) {
|
||||
const left = report[leftArm]?.fingerprint;
|
||||
const right = report[rightArm]?.fingerprint;
|
||||
failIfNeeded(
|
||||
report,
|
||||
left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`],
|
||||
);
|
||||
}
|
||||
|
||||
export function runBaselineCheck(report, baselinePath) {
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
||||
const errors = [];
|
||||
if (report.fingerprint !== baseline.fingerprint) {
|
||||
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
|
||||
}
|
||||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
|
||||
}
|
||||
if (
|
||||
baseline.absolute_ms_budget !== undefined &&
|
||||
report.absolute_ms > baseline.absolute_ms_budget
|
||||
) {
|
||||
errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`);
|
||||
}
|
||||
if (
|
||||
baseline.widening_overhead_budget !== undefined &&
|
||||
report.widening_overhead > baseline.widening_overhead_budget
|
||||
) {
|
||||
errors.push(
|
||||
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
|
||||
);
|
||||
}
|
||||
failIfNeeded(report, errors);
|
||||
console.log(JSON.stringify({ ok: true, report }, null, 2));
|
||||
}
|
||||
|
||||
function failIfNeeded(report, errors) {
|
||||
if (errors.length === 0) return;
|
||||
console.error(JSON.stringify({ report, errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -209,8 +209,10 @@
|
|||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832",
|
||||
"fingerprint": "aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
|
||||
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 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.",
|
||||
|
|
@ -223,9 +225,9 @@
|
|||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
|
||||
"_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.",
|
||||
"capture_groups_small": 4753,
|
||||
"capture_groups_large": 15203,
|
||||
"capture_groups_fp": 2367,
|
||||
"capture_groups_small": 5753,
|
||||
"capture_groups_large": 18403,
|
||||
"capture_groups_fp": 2563,
|
||||
"fixture_count": 141
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
gitnexus/bench/spring-config-bindings/baselines.json
Normal file
8
gitnexus/bench/spring-config-bindings/baselines.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/spring-config-bindings/measure.mjs --check (#2412). fingerprint is sha256 over position-free Kotlin config-consumer fact ids on the wildcard_large corpus (800 files × 2 @Value properties + 1 @ConfigurationProperties class = 2400 facts). Both arms must fingerprint identically: the wildcard arm adds a sibling nested type named `Value`, which must not suppress the imported Spring annotation. Budgets are timing gates with CI headroom.",
|
||||
"fingerprint": "34776f883427479befbeb3c09eaae2260ba778e769bff195044d3cb8f5ad9889",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) on the wildcard arm. Measured ~0.99.",
|
||||
"widening_overhead_budget": 1.8,
|
||||
"_widening_overhead_note": "wildcard_large_ms / exact_large_ms. The exact-import control resolves each annotation from imports.exact before any shadow check, so this isolates the wildcard path's per-annotation lexical shadow walk. Measured ~1.09; budget guards against a per-annotation rescan of the file's declarations."
|
||||
}
|
||||
164
gitnexus/bench/spring-config-bindings/measure.mjs
Normal file
164
gitnexus/bench/spring-config-bindings/measure.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Build-free throughput + identity bench for Kotlin Spring config-consumer
|
||||
* capture (#2412).
|
||||
*
|
||||
* Arms (identical corpora except the import style):
|
||||
* - exact: explicit `import ...annotation.Value` control, which resolves the
|
||||
* annotation from `imports.exact` before any shadow check runs
|
||||
* - wildcard: `import ...annotation.*` feature path, where every simple-name
|
||||
* annotation pays the lexical local-type shadow walk. Each file also
|
||||
* declares a sibling nested type named `Value` that must NOT suppress the
|
||||
* Spring annotation — the file-wide-shadow regression fixed on this branch.
|
||||
*
|
||||
* Parsing is prepared outside the timer; the measured path is the capture
|
||||
* function the Kotlin worker calls on its own AST.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/spring-config-bindings/measure.mjs
|
||||
* node --import tsx bench/spring-config-bindings/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
|
||||
import { captureKotlinSpringConfigConsumerFacts } from '../../src/core/ingestion/languages/kotlin/spring-config-bindings.ts';
|
||||
import { fingerprintIds, minSample, runBaselineCheck } from '../lib/identity-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
/** Two @Value properties plus one @ConfigurationProperties class per file. */
|
||||
const FACTS_PER_FILE = 3;
|
||||
|
||||
function consumerSource(i, mode) {
|
||||
const imports =
|
||||
mode === 'wildcard'
|
||||
? `import org.springframework.beans.factory.annotation.*
|
||||
import org.springframework.boot.context.properties.*`
|
||||
: `import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties`;
|
||||
|
||||
return `package bench.config
|
||||
${imports}
|
||||
|
||||
class Shadowing${i} {
|
||||
class Value
|
||||
}
|
||||
|
||||
@ConfigurationProperties(prefix = "svc.${i}")
|
||||
class Props${i} {
|
||||
var endpoint: String? = null
|
||||
}
|
||||
|
||||
class Consumer${i} {
|
||||
@Value("\\\${app.key${i}}")
|
||||
var timeout: Int = 0
|
||||
|
||||
@Value("\\\${app.other${i}:5}")
|
||||
var other: String? = null
|
||||
|
||||
fun decoy() {}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Position-free fact identity, so both arms are directly comparable. */
|
||||
function factId(fact) {
|
||||
const consumer = fact.consumer;
|
||||
return consumer.kind === 'value'
|
||||
? `value|${consumer.fieldName}|${[...consumer.keys].sort().join(',')}`
|
||||
: `configuration-properties|${consumer.className}|${consumer.prefix}`;
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const files = [];
|
||||
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(lang);
|
||||
const filePath = `bench/${mode}/Consumer${i}.kt`;
|
||||
files.push({ tree: parser.parse(consumerSource(i, mode)), filePath, parser });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function runAll(files) {
|
||||
const ids = [];
|
||||
for (const f of files) {
|
||||
for (const fact of captureKotlinSpringConfigConsumerFacts(f.tree.rootNode, f.filePath)) {
|
||||
ids.push(factId(fact));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const files = prepare(mode, fileCount);
|
||||
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
facts: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
function failIfNeeded(current, errors) {
|
||||
if (errors.length === 0) return;
|
||||
console.error(JSON.stringify({ report: current, errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runFactCountCheck(current, expectedCounts) {
|
||||
const errors = [];
|
||||
for (const [arm, expected] of Object.entries(expectedCounts)) {
|
||||
const actual = current[arm]?.facts;
|
||||
if (actual !== expected) errors.push(`${arm}.facts ${String(actual)} != ${expected}`);
|
||||
}
|
||||
failIfNeeded(current, errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wildcard import plus a sibling `Value` declaration must capture exactly the
|
||||
* facts the explicit-import control captures.
|
||||
*/
|
||||
function runFingerprintParityCheck(current, leftArm, rightArm) {
|
||||
const left = current[leftArm]?.fingerprint;
|
||||
const right = current[rightArm]?.fingerprint;
|
||||
failIfNeeded(
|
||||
current,
|
||||
left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`],
|
||||
);
|
||||
}
|
||||
|
||||
const report = {
|
||||
exact_small: measure('exact', SMALL),
|
||||
exact_large: measure('exact', LARGE),
|
||||
wildcard_small: measure('wildcard', SMALL),
|
||||
wildcard_large: measure('wildcard', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.widening_overhead = Number(
|
||||
(report.wildcard_large.ms / Math.max(report.exact_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.fingerprint = report.wildcard_large.fingerprint;
|
||||
|
||||
runFactCountCheck(report, {
|
||||
exact_large: LARGE * FACTS_PER_FILE,
|
||||
wildcard_large: LARGE * FACTS_PER_FILE,
|
||||
});
|
||||
runFingerprintParityCheck(report, 'exact_large', 'wildcard_large');
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
91
gitnexus/bench/v8-sidecar/measure.mjs
Normal file
91
gitnexus/bench/v8-sidecar/measure.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Optional V8 sidecar warm-load bench (#3089).
|
||||
*
|
||||
* Not part of `npm test`. Measures repeated warm loads of the `.v8` ParsedFile
|
||||
* shards already on disk through the production loader. Replay of identical
|
||||
* shards is throughput-only — it is not unique-object scale.
|
||||
*
|
||||
* Copies the store into a temporary workspace first. The source cache is
|
||||
* never mutated.
|
||||
*
|
||||
* Usage (from gitnexus/):
|
||||
* node --expose-gc --import tsx bench/v8-sidecar/measure.mjs <storagePath>
|
||||
*/
|
||||
import { cp, mkdtemp, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { loadParsedFilesForPaths } from '../../src/storage/parsedfile-store.ts';
|
||||
import { inspectV8Cache } from '../../src/storage/v8-sidecar.ts';
|
||||
|
||||
const srcStorage = process.argv[2];
|
||||
if (!srcStorage) {
|
||||
console.error('usage: node --expose-gc --import tsx bench/v8-sidecar/measure.mjs <storagePath>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const srcStoreDir = path.join(srcStorage, 'parsedfile-store');
|
||||
const benchRoot = await mkdtemp(path.join(tmpdir(), 'gnx-v8-bench-'));
|
||||
const storeDir = path.join(benchRoot, 'parsedfile-store');
|
||||
const PATH_SOURCE_SHARDS = 8;
|
||||
const RUNS = 3;
|
||||
|
||||
try {
|
||||
await cp(srcStoreDir, storeDir, { recursive: true });
|
||||
|
||||
const names = (await readdir(storeDir))
|
||||
.filter((f) => f.endsWith('.v8') && !f.includes('.v8.'))
|
||||
.sort();
|
||||
if (names.length === 0) {
|
||||
throw new Error(
|
||||
`no .v8 ParsedFile shards in ${srcStoreDir} — run an analyze that populates the store first`,
|
||||
);
|
||||
}
|
||||
|
||||
const want = new Set();
|
||||
let sourceShards = 0;
|
||||
for (const name of names) {
|
||||
const inspected = await inspectV8Cache(path.join(storeDir, name));
|
||||
if (!inspected) continue;
|
||||
sourceShards++;
|
||||
for (const filePath of inspected.paths) want.add(filePath);
|
||||
if (sourceShards >= PATH_SOURCE_SHARDS) break;
|
||||
}
|
||||
if (want.size === 0) {
|
||||
throw new Error(
|
||||
`no file paths readable from ${names.length} shard(s) in ${srcStoreDir} — shards may be from another Node/V8 runtime, so re-analyze with this runtime`,
|
||||
);
|
||||
}
|
||||
|
||||
const rss = () => Math.round(process.memoryUsage().rss / 1024 / 1024);
|
||||
const heap = () => Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
|
||||
|
||||
const run = async (label) => {
|
||||
if (typeof globalThis.gc === 'function') globalThis.gc();
|
||||
const t0 = performance.now();
|
||||
const loaded = await loadParsedFilesForPaths(benchRoot, want);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (loaded.size !== want.size) {
|
||||
throw new Error(`incomplete V8 load: requested ${want.size} paths but loaded ${loaded.size}`);
|
||||
}
|
||||
if (typeof globalThis.gc === 'function') globalThis.gc();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
label,
|
||||
shards: names.length,
|
||||
wantPaths: want.size,
|
||||
files: loaded.size,
|
||||
ms,
|
||||
rssMiB: rss(),
|
||||
heapUsedMiB: heap(),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 1; i <= RUNS; i++) {
|
||||
await run(`v8-load-${i}`);
|
||||
}
|
||||
} finally {
|
||||
await rm(benchRoot, { recursive: true, force: true });
|
||||
}
|
||||
179
gitnexus/package-lock.json
generated
179
gitnexus/package-lock.json
generated
|
|
@ -14,17 +14,18 @@
|
|||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^15.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"glob": "^13.0.6",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"graphql": "^16.14.2",
|
||||
"graphql": "^17.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
|
|
@ -1397,6 +1398,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.144.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||
|
|
@ -1875,9 +1888,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
|
||||
"integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2153,6 +2166,18 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/apache-arrow": {
|
||||
"version": "21.1.0",
|
||||
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz",
|
||||
|
|
@ -2383,15 +2408,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
|
|
@ -3021,6 +3046,45 @@
|
|||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
|
||||
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^3.0.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^2.0.0",
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"strnum": "^2.4.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -3308,12 +3372,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
"version": "16.14.2",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz",
|
||||
"integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==",
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz",
|
||||
"integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
"node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/guid-typescript": {
|
||||
|
|
@ -3481,6 +3545,18 @@
|
|||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||
|
|
@ -3555,9 +3631,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
|
||||
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz",
|
||||
"integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -4262,15 +4338,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
|
||||
"integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==",
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.29.0.tgz",
|
||||
"integrity": "sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz",
|
||||
"integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==",
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.29.0.tgz",
|
||||
"integrity": "sha512-WjiVVB72riILz8HbYvxvmjKyE/WmkYoSfKY++axo5jAR609HQg8MwiG/HhShpTcJfmmAdzxxmB+MMST3A+SiPA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -4280,9 +4356,9 @@
|
|||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"adm-zip": "^0.6.0",
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.27.0"
|
||||
"onnxruntime-common": "1.29.0"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
|
|
@ -4334,6 +4410,21 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
|
@ -4628,12 +4719,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
|
|
@ -5080,6 +5171,21 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
||||
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
|
@ -5765,6 +5871,21 @@
|
|||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -60,17 +60,18 @@
|
|||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^15.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"glob": "^13.0.6",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"graphql": "^16.14.2",
|
||||
"graphql": "^17.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
|
||||
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import path from 'path';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { type GeneratedSkillInfo } from './generated-skill.js';
|
||||
import { STANDARD_SKILL_CATALOG } from './standard-skills.js';
|
||||
import { isEnoent } from './editor-targets.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
|
|
@ -42,6 +43,8 @@ export interface AIContextOptions {
|
|||
* "no PDG layer" note, so advertising it on a non-`--pdg` index is noise.
|
||||
*/
|
||||
hasPdg?: boolean;
|
||||
/** Whether this index includes opt-in Spring Actuator runtime evidence. */
|
||||
hasSpringActuator?: boolean;
|
||||
}
|
||||
|
||||
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
|
||||
|
|
@ -136,6 +139,8 @@ export interface GitNexusContentOptions {
|
|||
* line below — false (default) omits it, so a non-pdg index doesn't advertise
|
||||
* a tool that only returns a "no PDG layer" note. */
|
||||
hasPdg?: boolean;
|
||||
/** Whether Route nodes may carry Spring Actuator runtime evidence. */
|
||||
hasSpringActuator?: boolean;
|
||||
}
|
||||
|
||||
export function generateGitNexusContent(
|
||||
|
|
@ -151,6 +156,7 @@ export function generateGitNexusContent(
|
|||
runnerPath = '.gitnexus/run.cjs',
|
||||
defaultBranch = 'main',
|
||||
hasPdg = false,
|
||||
hasSpringActuator = false,
|
||||
} = opts;
|
||||
const generatedRows =
|
||||
generatedSkills && generatedSkills.length > 0
|
||||
|
|
@ -226,8 +232,11 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
|
|||
- **MUST analyze graph changes before committing.** Use \`detect_changes({scope: "all"})\` (MCP) or \`${runner} detect-changes --scope all --repo .\` (CLI fallback). \`partial: true\` or \`truncated: true\` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\` or \`${runner} detect-changes --scope compare --base-ref ${JSON.stringify(markdownSafeBranch(defaultBranch))} --repo .\`.
|
||||
- MUST warn on HIGH/CRITICAL \`risk\` pre-edit; never use \`riskSharedAxes\` to waive a HIGH/CRITICAL \`risk\` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat \`risk: UNKNOWN\` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). \`impact\` pairs \`UNKNOWN\` with a \`riskNote\` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- Explore with \`query({search_query: "concept"})\` for process-grouped flows.
|
||||
- Use \`context({name: "symbolName"})\` for callers, callees, and flows.
|
||||
- **MUST use \`query({search_query: "concept"})\` for concepts/flows, \`context({name: "symbolName"})\` for a named symbol, or \`impact\` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/\`UNKNOWN\`/literals.${
|
||||
hasSpringActuator
|
||||
? '\n- Spring Actuator runtime evidence is enabled. A Route is authoritative only when `runtimeConfirmed === true`; `runtimeSource` is provenance and may also describe conflicts. Snapshot values are never persisted.'
|
||||
: ''
|
||||
}
|
||||
- For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).${
|
||||
hasPdg
|
||||
? `\n- For control/data dependence, \`pdg_query({mode: "controls", target: "fileOrSymbol"})\` answers "under what condition does X run?" (CDG, incl. guard clauses) and \`pdg_query({mode: "flows", target, variable})\` traces "where does variable Y flow?" (REACHING_DEF). \`--pdg\` layer.`
|
||||
|
|
@ -432,17 +441,84 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<bool
|
|||
}
|
||||
}
|
||||
|
||||
const SKILL_PRESERVE_HINT =
|
||||
'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
|
||||
|
||||
async function readUtf8IfPresent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function skillBytesDiverge(existing: string | null, bundled: string): boolean {
|
||||
return existing !== null && existing !== bundled;
|
||||
}
|
||||
|
||||
/** Write bundled skill bytes unless an existing file already differs. */
|
||||
async function writeSkillUnlessDivergent(filePath: string, content: string): Promise<boolean> {
|
||||
const existing = await readUtf8IfPresent(filePath);
|
||||
if (skillBytesDiverge(existing, content)) {
|
||||
logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`);
|
||||
return true;
|
||||
}
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function inspectLegacySkillDir(
|
||||
legacyDir: string,
|
||||
): Promise<{ nestedExisting: string | null; hasSiblings: boolean } | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(legacyDir);
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
const nestedExisting = entries.includes('SKILL.md')
|
||||
? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8')
|
||||
: null;
|
||||
return {
|
||||
nestedExisting,
|
||||
hasSiblings: entries.some((entry) => entry !== 'SKILL.md'),
|
||||
};
|
||||
}
|
||||
|
||||
function formatSkillInstallLine(
|
||||
prefix: string,
|
||||
total: number,
|
||||
preserved: number,
|
||||
allWrittenSuffix: string,
|
||||
partialSuffix: string,
|
||||
): string {
|
||||
if (preserved > 0) {
|
||||
return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`;
|
||||
}
|
||||
return `${prefix} (${total} ${allWrittenSuffix})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills as direct children of .claude/skills/
|
||||
* Works natively with Claude Code, Cursor, and GitHub Copilot.
|
||||
* Mirrored to .agents/skills/ when .agents/ exists.
|
||||
*/
|
||||
async function installSkills(
|
||||
repoPath: string,
|
||||
): Promise<{ skills: string[]; agentsMirror: boolean }> {
|
||||
async function installSkills(repoPath: string): Promise<{
|
||||
skills: string[];
|
||||
agentsMirror: boolean;
|
||||
claudePreserved: number;
|
||||
agentsPreserved: number;
|
||||
legacyPreserved: number;
|
||||
}> {
|
||||
const skillsDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
|
||||
const installedSkills: string[] = [];
|
||||
let claudePreserved = 0;
|
||||
let agentsPreserved = 0;
|
||||
let legacyPreserved = 0;
|
||||
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
for (const skill of STANDARD_SKILL_CATALOG.filter(
|
||||
|
|
@ -452,9 +528,6 @@ async function installSkills(
|
|||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
try {
|
||||
// Create skill directory
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
|
||||
// Try to read from package skills directory
|
||||
const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`);
|
||||
let skillContent: string;
|
||||
|
|
@ -476,14 +549,13 @@ Use GitNexus tools to accomplish this task.
|
|||
`;
|
||||
}
|
||||
|
||||
await fs.writeFile(skillPath, skillContent, 'utf-8');
|
||||
if (await writeSkillUnlessDivergent(skillPath, skillContent)) claudePreserved += 1;
|
||||
|
||||
// Mirror to .agents/skills/ for agents that read repo-local skills
|
||||
if (agentsMirror) {
|
||||
try {
|
||||
const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
|
||||
await fs.mkdir(agentsSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
|
||||
const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md');
|
||||
if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent)) agentsPreserved += 1;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
|
||||
}
|
||||
|
|
@ -495,7 +567,20 @@ Use GitNexus tools to accomplish this task.
|
|||
// deep. Remove only the child owned by this installer; unknown siblings
|
||||
// under the legacy grouping directory may be user-authored and survive.
|
||||
try {
|
||||
await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true });
|
||||
const legacyDir = path.join(legacySkillsDir, skill.name);
|
||||
const nestedSkill = path.join(legacyDir, 'SKILL.md');
|
||||
const leftover = await inspectLegacySkillDir(legacyDir);
|
||||
if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) {
|
||||
logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`);
|
||||
legacyPreserved += 1;
|
||||
} else if (leftover?.hasSiblings) {
|
||||
logger.warn(
|
||||
`Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`,
|
||||
);
|
||||
legacyPreserved += 1;
|
||||
} else if (leftover !== null) {
|
||||
await fs.rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
|
||||
}
|
||||
|
|
@ -505,7 +590,13 @@ Use GitNexus tools to accomplish this task.
|
|||
}
|
||||
}
|
||||
|
||||
return { skills: installedSkills, agentsMirror };
|
||||
return {
|
||||
skills: installedSkills,
|
||||
agentsMirror,
|
||||
claudePreserved,
|
||||
agentsPreserved,
|
||||
legacyPreserved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -551,6 +642,7 @@ export async function generateAIContextFiles(
|
|||
runnerPath,
|
||||
defaultBranch: options?.defaultBranch ?? 'main',
|
||||
hasPdg: options?.hasPdg ?? false,
|
||||
hasSpringActuator: options?.hasSpringActuator ?? false,
|
||||
});
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
|
|
@ -583,12 +675,37 @@ export async function generateAIContextFiles(
|
|||
|
||||
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
|
||||
if (!options?.skipSkills) {
|
||||
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
|
||||
const {
|
||||
skills: installedSkills,
|
||||
agentsMirror,
|
||||
claudePreserved,
|
||||
agentsPreserved,
|
||||
legacyPreserved,
|
||||
} = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
|
||||
createdFiles.push(
|
||||
formatSkillInstallLine(
|
||||
'.claude/skills/gitnexus-*/',
|
||||
installedSkills.length,
|
||||
claudePreserved,
|
||||
'skills',
|
||||
'preserved',
|
||||
),
|
||||
);
|
||||
if (agentsMirror) {
|
||||
createdFiles.push(
|
||||
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
|
||||
formatSkillInstallLine(
|
||||
'.agents/skills/gitnexus-*/',
|
||||
installedSkills.length,
|
||||
agentsPreserved,
|
||||
'skills mirrored for .agents',
|
||||
'preserved for .agents',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (legacyPreserved > 0) {
|
||||
createdFiles.push(
|
||||
`.claude/skills/gitnexus/<name>/ (legacy directories preserved: ${legacyPreserved})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ type ValueKind =
|
|||
| 'string-array'
|
||||
| 'numeric-string'
|
||||
| 'embeddings'
|
||||
| 'branch';
|
||||
| 'branch'
|
||||
| 'path';
|
||||
|
||||
interface KeySpec {
|
||||
/** The `AnalyzeOptions` field this config key normalizes into. */
|
||||
|
|
@ -108,6 +109,9 @@ const KEY_SPECS: Record<string, KeySpec> = {
|
|||
// built-in convention set, is otherwise invisible to route_map consumers.
|
||||
// Listing it here adds it to the cross-file consumer scan.
|
||||
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
|
||||
// Explicit local Actuator snapshot input (#2418). The path itself is safe in
|
||||
// project config; payload contents are never copied into the graph wholesale.
|
||||
springActuator: { target: 'springActuator', kind: 'path' },
|
||||
// Auth token AND dims are intentionally CLI/env-only — no embeddingAuthToken
|
||||
// or embeddingDims key here:
|
||||
// - the token keeps secrets out of a committed .gitnexusrc;
|
||||
|
|
@ -226,6 +230,17 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
|
|||
throw new GitNexusRcError(`${source} must be a string branch name.`);
|
||||
}
|
||||
return validateBranchName(value, source);
|
||||
case 'path': {
|
||||
if (typeof value !== 'string') {
|
||||
throw new GitNexusRcError(`${source} must be a file or directory path.`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new GitNexusRcError(`${source} must not be empty.`);
|
||||
}
|
||||
assertNoHiddenChars(trimmed, source);
|
||||
return trimmed;
|
||||
}
|
||||
case 'string': {
|
||||
if (typeof value !== 'string') {
|
||||
throw new GitNexusRcError(`${source} must be a string.`);
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ export interface AnalyzeOptions {
|
|||
* outside the built-in convention still produces `route_map` consumers.
|
||||
*/
|
||||
fetchWrappers?: string[];
|
||||
/**
|
||||
* Explicit local Spring Boot Actuator snapshot input (#2418). Accepts a JSON
|
||||
* bundle or a directory containing endpoint JSON files. Disabled by default.
|
||||
*/
|
||||
springActuator?: string;
|
||||
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
|
||||
embeddingBaseUrl?: string;
|
||||
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */
|
||||
|
|
|
|||
|
|
@ -668,6 +668,8 @@ const ANALYZE_CLI_ENV_KEYS = [
|
|||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
'GITNEXUS_EMBEDDING_DEVICE',
|
||||
'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
|
||||
'GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS',
|
||||
'GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO',
|
||||
'GITNEXUS_EMBEDDING_URL',
|
||||
'GITNEXUS_EMBEDDING_MODEL',
|
||||
'GITNEXUS_EMBEDDING_API_KEY',
|
||||
|
|
@ -1372,6 +1374,7 @@ const analyzeCommandImpl = async (
|
|||
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
|
||||
// forwarded to the routes phase consumer scan.
|
||||
fetchWrappers: options.fetchWrappers,
|
||||
springActuatorPath: options.springActuator,
|
||||
// The CLI always process.exit()s after this returns (success path at the
|
||||
// end of analyzeCommandImpl, error/interrupt paths via process.exit too),
|
||||
// so the finalize close skips the native conn/db close — it can double-free
|
||||
|
|
@ -1530,6 +1533,7 @@ const analyzeCommandImpl = async (
|
|||
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
|
||||
noStats: options.stats === false,
|
||||
hasPdg: options.pdg === true,
|
||||
hasSpringActuator: options.springActuator !== undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export const en = {
|
|||
'help.option.analyze.skills':
|
||||
'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
|
||||
'help.option.analyze.skipAgentsMd':
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
|
||||
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
|
||||
'help.option.analyze.selfCommit':
|
||||
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
|
||||
|
|
@ -238,7 +238,7 @@ export const en = {
|
|||
'help.option.mcp.host':
|
||||
'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.',
|
||||
'help.option.mcp.authToken':
|
||||
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
|
||||
"Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.",
|
||||
'help.option.force.confirmation': 'Skip confirmation prompt',
|
||||
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
|
||||
'help.option.clean.all': 'Clean all indexed repos',
|
||||
|
|
|
|||
|
|
@ -188,7 +188,8 @@ export const zhCN = {
|
|||
'重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
|
||||
'help.option.analyze.skills':
|
||||
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
|
||||
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
|
||||
'help.option.analyze.skipAgentsMd':
|
||||
'跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill;如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。',
|
||||
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
|
||||
'help.option.analyze.selfCommit':
|
||||
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
|
||||
|
|
@ -222,7 +223,7 @@ export const zhCN = {
|
|||
'help.option.mcp.host':
|
||||
'HTTP 绑定地址(仅与 --http 搭配使用)。默认:127.0.0.1(回环)。使用 0.0.0.0 向所有接口开放。',
|
||||
'help.option.mcp.authToken':
|
||||
'要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
|
||||
'要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置,该变量同时为 gitnexus serve 的 /api/mcp 路由启用 MCP Bearer 认证。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
|
||||
'help.option.force.confirmation': '跳过确认提示',
|
||||
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
|
||||
'help.option.clean.all': '清理所有已索引仓库',
|
||||
|
|
|
|||
|
|
@ -76,7 +76,10 @@ program
|
|||
'Generate repo-specific skill files from detected communities ' +
|
||||
'(no-op when --index-only is also set).',
|
||||
)
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--skip-agents-md',
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
|
||||
)
|
||||
.option(
|
||||
'--pdg',
|
||||
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
|
||||
|
|
@ -139,6 +142,11 @@ program
|
|||
'--workers <n>',
|
||||
'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.',
|
||||
)
|
||||
.option(
|
||||
'--spring-actuator <path>',
|
||||
'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' +
|
||||
'configprops, env). Explicit opt-in; disabled by default.',
|
||||
)
|
||||
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
|
||||
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
|
||||
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
|
||||
|
|
@ -245,7 +253,7 @@ program
|
|||
)
|
||||
.option(
|
||||
'--auth-token <token>',
|
||||
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
|
||||
"Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.",
|
||||
)
|
||||
.action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
|
||||
|
|
|
|||
|
|
@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
|||
const skillDir = path.join(targetDir, skillName);
|
||||
|
||||
try {
|
||||
if (source.isDirectory) {
|
||||
const dirSource = path.join(skillsRoot, skillName);
|
||||
await copyDirRecursive(dirSource, skillDir);
|
||||
} else {
|
||||
const flatSource = path.join(skillsRoot, `${skillName}.md`);
|
||||
const content = await fs.readFile(flatSource, 'utf-8');
|
||||
const sourceSkillPath = source.isDirectory
|
||||
? path.join(skillsRoot, skillName, 'SKILL.md')
|
||||
: path.join(skillsRoot, `${skillName}.md`);
|
||||
const destinationSkillPath = path.join(skillDir, 'SKILL.md');
|
||||
const [sourceSkillContent, destinationSkillContent] = await Promise.all([
|
||||
fs.readFile(sourceSkillPath, 'utf-8'),
|
||||
fs.readFile(destinationSkillPath, 'utf-8').catch((err) => {
|
||||
if (!isEnoent(err)) throw err;
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
const preserved =
|
||||
destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent;
|
||||
if (preserved && !source.isDirectory) {
|
||||
console.log(
|
||||
`[gitnexus] preserved customized skill ${destinationSkillPath}; ` +
|
||||
'delete the file and rerun setup to refresh it.',
|
||||
);
|
||||
} else if (source.isDirectory) {
|
||||
await copyDirRecursive(path.join(skillsRoot, skillName), skillDir);
|
||||
} else if (!preserved) {
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
|
||||
await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8');
|
||||
}
|
||||
|
||||
// A directory superseded by a shipped rename is warned about, never
|
||||
|
|
@ -1113,7 +1129,7 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
|||
);
|
||||
}
|
||||
}
|
||||
installed.push(skillName);
|
||||
if (!preserved) installed.push(skillName);
|
||||
} catch {
|
||||
// Source skill not found — skip
|
||||
}
|
||||
|
|
@ -1133,9 +1149,23 @@ async function copyDirRecursive(src: string, dest: string): Promise<void> {
|
|||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
const [srcBuf, destBuf] = await Promise.all([
|
||||
fs.readFile(srcPath),
|
||||
fs.readFile(destPath).catch((err) => {
|
||||
if (!isEnoent(err)) throw err;
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
if (destBuf !== null && !destBuf.equals(srcBuf)) {
|
||||
console.log(
|
||||
`[gitnexus] preserved customized skill ${destPath}; ` +
|
||||
'delete the file and rerun setup to refresh it.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await fs.writeFile(destPath, srcBuf);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export async function resolveWatchOptions(
|
|||
['--self-commit', cli.selfCommit],
|
||||
['--index-only', cli.indexOnly],
|
||||
['--skip-git', cli.skipGit],
|
||||
['--spring-actuator', cli.springActuator],
|
||||
['walCheckpointThreshold', cli.walCheckpointThreshold],
|
||||
['embeddingThreads', cli.embeddingThreads],
|
||||
['embeddingBatchSize', cli.embeddingBatchSize],
|
||||
|
|
@ -137,6 +138,7 @@ export async function resolveWatchOptions(
|
|||
['skipAgentsMd', config.skipAgentsMd !== undefined],
|
||||
['skipSkills', config.skipSkills !== undefined],
|
||||
['stats', config.stats !== undefined],
|
||||
['springActuator', config.springActuator],
|
||||
['walCheckpointThreshold', config.walCheckpointThreshold],
|
||||
['embeddingThreads', config.embeddingThreads],
|
||||
['embeddingBatchSize', config.embeddingBatchSize],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants as fsConstants,
|
||||
existsSync,
|
||||
|
|
@ -38,6 +39,7 @@ import { spawnSync } from 'node:child_process';
|
|||
import { isDeepStrictEqual } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseTruthyEnv } from './ingestion/utils/env.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
|
||||
|
||||
|
|
@ -2335,8 +2337,30 @@ function snapshotCacheGuardDirect(request: CacheGuardRequest): CacheGuardResult
|
|||
}
|
||||
}
|
||||
|
||||
function snapshotCacheGuards(requests: CacheGuardRequest[]): CacheGuardResult[] {
|
||||
function installTreeUnwritable(packageRoot: string, buildRoot: string): boolean {
|
||||
for (const dir of [packageRoot, buildRoot]) {
|
||||
try {
|
||||
accessSync(dir, fsConstants.W_OK);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EACCES' || code === 'EROFS') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function snapshotCacheGuards(
|
||||
requests: CacheGuardRequest[],
|
||||
packageRoot: string,
|
||||
buildRoot: string,
|
||||
): CacheGuardResult[] {
|
||||
if (requests.length < 128) return requests.map(snapshotCacheGuardDirect);
|
||||
if (
|
||||
parseTruthyEnv(process.env.GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS) ||
|
||||
installTreeUnwritable(packageRoot, buildRoot)
|
||||
) {
|
||||
return requests.map(snapshotCacheGuardDirect);
|
||||
}
|
||||
try {
|
||||
const probe = spawnSync(
|
||||
process.execPath,
|
||||
|
|
@ -2468,7 +2492,7 @@ function validateIdentityCache(
|
|||
return { mode, absolutePath };
|
||||
});
|
||||
options.onCacheValidationPass?.({ guardCount: requests.length });
|
||||
const actual = snapshotCacheGuards(requests);
|
||||
const actual = snapshotCacheGuards(requests, cache.packageRoot, cache.buildRoot);
|
||||
const mismatch = actual.findIndex(
|
||||
(result, index) => !isDeepStrictEqual(result, entries[index][1]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@ export function validateGroupImpactParams(params: Record<string, unknown>):
|
|||
name: string;
|
||||
repoPath: string;
|
||||
target: string;
|
||||
// Target selectors, same names/semantics as the single-repo impact tool
|
||||
// (target_uid = zero-ambiguity lookup that wins over the name;
|
||||
// file_path/kind narrow a name shared by same-named symbols). Threading
|
||||
// them through HERE is what makes the MCP boundary's forwarding live —
|
||||
// dropping them at this boundary silently re-broke the group-mode
|
||||
// disambiguation loop once already.
|
||||
target_uid?: string;
|
||||
file_path?: string;
|
||||
kind?: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth: number;
|
||||
crossDepth: number;
|
||||
|
|
@ -173,11 +182,21 @@ export function validateGroupImpactParams(params: Record<string, unknown>):
|
|||
| { ok: false; error: string } {
|
||||
const name = String(params.name ?? '').trim();
|
||||
const repoPath = String(params.repo ?? '').trim();
|
||||
const target = String(params.target ?? '').trim();
|
||||
// Optional string, same helper shape as cross-trace's `str()`: empty/blank
|
||||
// counts as absent so `target_uid: ''` degrades to the name lookup rather
|
||||
// than a zero-ambiguity lookup of the empty uid. Parsed before the required
|
||||
// check so UID-only callers (MCP impact schema requires `direction`, not
|
||||
// `target`) are accepted.
|
||||
const str = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v.trim() !== '' ? v : undefined;
|
||||
const targetName = String(params.target ?? '').trim();
|
||||
const target_uidEarly = str(params.target_uid);
|
||||
if (!name) return { ok: false, error: 'name is required' };
|
||||
if (!repoPath)
|
||||
return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' };
|
||||
if (!target) return { ok: false, error: 'target is required' };
|
||||
if (!targetName && !target_uidEarly)
|
||||
return { ok: false, error: 'target or target_uid is required' };
|
||||
const target = targetName || target_uidEarly!;
|
||||
if (
|
||||
params.service !== undefined &&
|
||||
params.service !== null &&
|
||||
|
|
@ -205,6 +224,10 @@ export function validateGroupImpactParams(params: Record<string, unknown>):
|
|||
const service = normalizeServicePrefix(params.service);
|
||||
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
|
||||
|
||||
const target_uid = target_uidEarly;
|
||||
const file_path = str(params.file_path);
|
||||
const kind = str(params.kind);
|
||||
|
||||
// Clamp at the validate boundary so the downstream `deadline` (line
|
||||
// ~366) and `safeLocalImpact`'s `setTimeout` both see a single
|
||||
// bounded value. Without this, the outer deadline budgeted Phase-2
|
||||
|
|
@ -224,6 +247,9 @@ export function validateGroupImpactParams(params: Record<string, unknown>):
|
|||
name,
|
||||
repoPath,
|
||||
target,
|
||||
target_uid,
|
||||
file_path,
|
||||
kind,
|
||||
direction,
|
||||
maxDepth,
|
||||
crossDepth,
|
||||
|
|
@ -579,6 +605,9 @@ export async function runGroupImpact(
|
|||
name,
|
||||
repoPath,
|
||||
target,
|
||||
target_uid,
|
||||
file_path,
|
||||
kind,
|
||||
direction,
|
||||
maxDepth,
|
||||
crossDepth: _crossDepth,
|
||||
|
|
@ -606,6 +635,14 @@ export async function runGroupImpact(
|
|||
|
||||
const impactParams: Parameters<GroupToolPort['impact']>[1] = {
|
||||
target,
|
||||
// Selector params pass through to the member repo's impact (the port
|
||||
// contract in service.ts documents them), so the single-repo tool's
|
||||
// "re-call with target_uid to disambiguate" loop works unchanged in
|
||||
// group mode. `undefined` keeps the call shape flat — same convention
|
||||
// as the relationTypes line below.
|
||||
target_uid,
|
||||
file_path,
|
||||
kind,
|
||||
direction,
|
||||
maxDepth,
|
||||
relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ import {
|
|||
EXCHANGE_CONFIDENCE,
|
||||
} from './spring-consumer-shared.js';
|
||||
import {
|
||||
expandJavaWildcardStaticImports,
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
parseJavaConstOperands,
|
||||
prepareJavaRouteConstants,
|
||||
type JavaConstantIndex,
|
||||
type RepoConstants,
|
||||
} from '../../../ingestion/route-extractors/java-const-resolver.js';
|
||||
import {
|
||||
|
|
@ -917,7 +920,12 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
const tree = args.parseSource(args.parser, src);
|
||||
if (!tree) continue;
|
||||
const mc = extractJavaModuleConstants(tree);
|
||||
if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
|
||||
if (
|
||||
mc.literals.size > 0 ||
|
||||
mc.exprs.size > 0 ||
|
||||
mc.imports.size > 0 ||
|
||||
(mc.wildcardImports?.length ?? 0) > 0
|
||||
) {
|
||||
constants.set(rel, mc);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -927,11 +935,20 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
continue;
|
||||
}
|
||||
}
|
||||
return { constants };
|
||||
// On-demand static imports (`import static a.b.C.*`) were recorded as
|
||||
// pending class FQNs during extraction; materialize their bare-name
|
||||
// bindings now that the whole map exists. A wildcard's target is itself
|
||||
// a constants file, so it is necessarily a map entry — anything else
|
||||
// degrades to the fold's skip floor. In-place: each entry is owned by
|
||||
// this map, and every file is expanded exactly once.
|
||||
const constantIndex = prepareJavaRouteConstants(constants);
|
||||
return { constants, constantIndex };
|
||||
},
|
||||
scan(tree, repoContext, fileRel) {
|
||||
const out: HttpDetection[] = [];
|
||||
const javaCtx = repoContext as { constants: RepoConstants } | undefined;
|
||||
const javaCtx = repoContext as
|
||||
| { constants: RepoConstants; constantIndex: JavaConstantIndex }
|
||||
| undefined;
|
||||
|
||||
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
||||
// `scanRouteAnnotations` resolves every route-defining annotation —
|
||||
|
|
@ -966,8 +983,12 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
if (javaCtx.constants.has(fileRel)) return foldConstants;
|
||||
try {
|
||||
const mc = extractJavaModuleConstants(tree);
|
||||
if (mc.imports.size > 0) {
|
||||
// A file carrying ONLY wildcard static imports has an empty import
|
||||
// table pre-expansion — overlay it too, then materialize the promised
|
||||
// bindings against the repo map before it becomes a fold target.
|
||||
if (mc.imports.size > 0 || (mc.wildcardImports?.length ?? 0) > 0) {
|
||||
const merged = new Map(javaCtx.constants);
|
||||
expandJavaWildcardStaticImports(mc, fileRel, merged, javaCtx.constantIndex);
|
||||
merged.set(fileRel, mc);
|
||||
foldConstants = merged;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1328,6 +1328,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
mc.literals.size > 0 ||
|
||||
mc.exprs.size > 0 ||
|
||||
mc.imports.size > 0 ||
|
||||
(mc.wildcardImports?.length ?? 0) > 0 ||
|
||||
unfoldableDeclarationsOf(mc).size > 0
|
||||
) {
|
||||
// POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`.
|
||||
|
|
@ -1373,6 +1374,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
mc.literals.size > 0 ||
|
||||
mc.exprs.size > 0 ||
|
||||
mc.imports.size > 0 ||
|
||||
(mc.wildcardImports?.length ?? 0) > 0 ||
|
||||
unfoldableDeclarationsOf(mc).size > 0
|
||||
) {
|
||||
foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc);
|
||||
|
|
|
|||
|
|
@ -1137,13 +1137,17 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
name: 'python-http',
|
||||
language: Python,
|
||||
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
|
||||
// It would be a no-op even if set to 'complete': FastAPI decorator routes set
|
||||
// no handlerName (generic worker path) and Django sets methodName: null, so no
|
||||
// Python file ever resolves a handlerSymbolId and none would be parse-skipped.
|
||||
// Declaring 'complete' now is only a latent trap for the moment a follow-up
|
||||
// gives FastAPI routes a handlerName. `hasConsumerSignals` is kept (and is a
|
||||
// true superset of scan()'s consumer shapes) so the precondition already holds
|
||||
// when Python is later flipped to 'complete'.
|
||||
// 'complete' is now an active data-loss risk rather than a no-op: FastAPI and
|
||||
// Flask decorator routes do carry a handlerName (Python's
|
||||
// `decoratorRouteHandlerName` hook reads the `decorated_definition`), so their
|
||||
// files can resolve every handlerSymbolId and become parse-skip candidates.
|
||||
// The flag asserts more than that — it asserts ingestion emits a Route node
|
||||
// for EVERY provider route this scan() finds, and it does not: Flask's
|
||||
// imperative `add_url_rule('/p', view_func=handler)` registration below has no
|
||||
// ingestion counterpart, so skipping a file that mixes it with resolved
|
||||
// decorator routes would drop those providers. `hasConsumerSignals` is kept
|
||||
// (and is a true superset of scan()'s consumer shapes) so the consumer half of
|
||||
// the precondition already holds once provider parity is closed.
|
||||
// Consumer signals scan() can detect: `requests.<verb>`/`requests.request`,
|
||||
// `httpx` (sync/async client), the `uri=`/`url=` keyword/variable wrapper
|
||||
// calls, plus aiohttp/urllib. Conservative — over-matching only costs a parse.
|
||||
|
|
|
|||
|
|
@ -621,6 +621,7 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
dbExecutor,
|
||||
getDetections,
|
||||
resolveDetectionSymbol,
|
||||
loadFileSymbols,
|
||||
coveredFiles,
|
||||
)
|
||||
: [];
|
||||
|
|
@ -690,6 +691,7 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
db: CypherExecutor,
|
||||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
|
||||
loadFileSymbols: (filePath: string) => Promise<Record<string, unknown>[]>,
|
||||
coveredFiles?: Set<string>,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
|
@ -749,15 +751,11 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
if (!method) method = 'GET';
|
||||
symbolUid = handlerSymbolId;
|
||||
if (filePath) {
|
||||
try {
|
||||
const syms = await db(CONTAINING_QUERY, { filePath });
|
||||
const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
|
||||
if (hit) {
|
||||
symbolName = String(hit.name ?? hit[1]) || symbolName;
|
||||
symPath = String(hit.filePath ?? hit[2]) || filePath;
|
||||
}
|
||||
} catch {
|
||||
/* keep the authoritative uid + basename fallback */
|
||||
const syms = await loadFileSymbols(filePath);
|
||||
const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
|
||||
if (hit) {
|
||||
symbolName = String(hit.name ?? hit[1]) || symbolName;
|
||||
symPath = String(hit.filePath ?? hit[2]) || filePath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
|
@ -20,6 +21,21 @@ interface ImportedSymbol {
|
|||
filePath: string;
|
||||
}
|
||||
|
||||
type XmlNode = Record<string, unknown>;
|
||||
|
||||
// POMs are static metadata. Parse hierarchy with a real XML parser, but do not
|
||||
// invoke Maven or resolve the effective model. Properties, profiles, and remote
|
||||
// parent resolution remain outside this extractor's deterministic boundary.
|
||||
const pomParser = new XMLParser({
|
||||
ignoreAttributes: true,
|
||||
removeNSPrefix: true,
|
||||
trimValues: true,
|
||||
parseTagValue: false,
|
||||
processEntities: false,
|
||||
ignoreDeclaration: true,
|
||||
ignorePiTags: true,
|
||||
});
|
||||
|
||||
async function parseJavaManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> {
|
||||
|
|
@ -28,14 +44,15 @@ async function parseJavaManifest(
|
|||
const content = await fs.readFile(pomPath, 'utf-8');
|
||||
return parsePom(content);
|
||||
} catch {
|
||||
// fall through to Gradle
|
||||
// Missing pom.xml — fall through to Gradle.
|
||||
}
|
||||
|
||||
const gradleSidecars = await readGradleSidecars(repoPath);
|
||||
for (const name of ['build.gradle.kts', 'build.gradle']) {
|
||||
const gradlePath = path.join(repoPath, name);
|
||||
try {
|
||||
const content = await fs.readFile(gradlePath, 'utf-8');
|
||||
return parseGradle(content, repoPath);
|
||||
return parseGradle(content, repoPath, gradleSidecars);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -44,59 +61,286 @@ async function parseJavaManifest(
|
|||
return null;
|
||||
}
|
||||
|
||||
function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const projectGroupMatch = content.match(/<project[^>]*>[\s\S]*?<groupId>([^<]+)<\/groupId>/);
|
||||
const projectArtifactMatch = content.match(
|
||||
/<project[^>]*>[\s\S]*?<artifactId>([^<]+)<\/artifactId>/,
|
||||
);
|
||||
if (!projectGroupMatch || !projectArtifactMatch) return null;
|
||||
interface GradleSidecars {
|
||||
propertiesGroup?: string;
|
||||
rootProjectName?: string;
|
||||
catalogLibraries: Map<string, string>;
|
||||
catalogBundles: Map<string, string[]>;
|
||||
}
|
||||
|
||||
const groupId = projectGroupMatch[1].trim();
|
||||
const artifactId = projectArtifactMatch[1].trim();
|
||||
async function readIfPresent(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const deps: string[] = [];
|
||||
const depBlocks = content.matchAll(/<dependency>\s*([\s\S]*?)<\/dependency>/g);
|
||||
for (const block of depBlocks) {
|
||||
const gMatch = block[1].match(/<groupId>([^<]+)<\/groupId>/);
|
||||
const aMatch = block[1].match(/<artifactId>([^<]+)<\/artifactId>/);
|
||||
if (gMatch && aMatch) {
|
||||
deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`);
|
||||
async function readGradleSidecars(repoPath: string): Promise<GradleSidecars> {
|
||||
const [properties, settingsKts, settingsGroovy, catalog] = await Promise.all([
|
||||
readIfPresent(path.join(repoPath, 'gradle.properties')),
|
||||
readIfPresent(path.join(repoPath, 'settings.gradle.kts')),
|
||||
readIfPresent(path.join(repoPath, 'settings.gradle')),
|
||||
readIfPresent(path.join(repoPath, 'gradle', 'libs.versions.toml')),
|
||||
]);
|
||||
|
||||
const sidecars: GradleSidecars = {
|
||||
catalogLibraries: new Map(),
|
||||
catalogBundles: new Map(),
|
||||
};
|
||||
|
||||
const groupMatch = properties?.match(/(?:^|\n)\s*group\s*=\s*([^\s#]+)/);
|
||||
if (groupMatch) sidecars.propertiesGroup = groupMatch[1];
|
||||
|
||||
const settings = settingsKts ?? settingsGroovy;
|
||||
const nameMatch = settings?.match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (nameMatch) sidecars.rootProjectName = nameMatch[1];
|
||||
|
||||
if (catalog) {
|
||||
const parsed = parseGradleVersionCatalog(catalog);
|
||||
sidecars.catalogLibraries = parsed.libraries;
|
||||
sidecars.catalogBundles = parsed.bundles;
|
||||
}
|
||||
|
||||
return sidecars;
|
||||
}
|
||||
|
||||
function catalogAccessors(alias: string): string[] {
|
||||
const dotted = alias.replace(/[-_]/g, '.');
|
||||
const camel = alias.replace(/[-_]+([A-Za-z0-9])/g, (_, char: string) => char.toUpperCase());
|
||||
return [...new Set([alias, dotted, camel])];
|
||||
}
|
||||
|
||||
function projectAccessorToArtifactId(accessor: string): string {
|
||||
const last = accessor.split('.').pop()!;
|
||||
return last.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/^-/, '');
|
||||
}
|
||||
|
||||
function moduleToGa(module: string): string | undefined {
|
||||
const parts = module.split(':');
|
||||
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : undefined;
|
||||
}
|
||||
|
||||
function parseInlineTomlTable(rhs: string): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const match of rhs.matchAll(/([A-Za-z0-9_-]+)\s*=\s*['"]([^'"]+)['"]/g)) {
|
||||
fields[match[1]] = match[2];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Default Gradle catalog (`gradle/libs.versions.toml`) — aliases only, no version resolution. */
|
||||
function parseGradleVersionCatalog(toml: string): {
|
||||
libraries: Map<string, string>;
|
||||
bundles: Map<string, string[]>;
|
||||
} {
|
||||
const libraries = new Map<string, string>();
|
||||
const bundles = new Map<string, string[]>();
|
||||
let section: 'libraries' | 'bundles' | 'other' = 'other';
|
||||
|
||||
const addLibrary = (alias: string, ga: string) => {
|
||||
for (const accessor of catalogAccessors(alias)) libraries.set(accessor, ga);
|
||||
};
|
||||
|
||||
for (const raw of toml.split(/\r?\n/)) {
|
||||
const line = raw.replace(/#.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
const header = line.match(/^\[([^\]]+)\]$/);
|
||||
if (header) {
|
||||
const name = header[1];
|
||||
section =
|
||||
name === 'libraries' || name.endsWith('.libraries')
|
||||
? 'libraries'
|
||||
: name === 'bundles' || name.endsWith('.bundles')
|
||||
? 'bundles'
|
||||
: 'other';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section === 'libraries') {
|
||||
const dottedModule = line.match(/^([A-Za-z0-9._-]+)\.module\s*=\s*['"]([^'"]+)['"]$/);
|
||||
if (dottedModule) {
|
||||
const ga = moduleToGa(dottedModule[2]);
|
||||
if (ga) addLibrary(dottedModule[1], ga);
|
||||
continue;
|
||||
}
|
||||
const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*(.+)$/);
|
||||
if (!assignment) continue;
|
||||
const alias = assignment[1];
|
||||
const rhs = assignment[2].trim();
|
||||
const quoted = rhs.match(/^['"]([^'"]+)['"]$/);
|
||||
if (quoted) {
|
||||
const ga = moduleToGa(quoted[1]);
|
||||
if (ga) addLibrary(alias, ga);
|
||||
continue;
|
||||
}
|
||||
const table = parseInlineTomlTable(rhs);
|
||||
const ga = table.module
|
||||
? moduleToGa(table.module)
|
||||
: table.group && table.name
|
||||
? `${table.group}:${table.name}`
|
||||
: undefined;
|
||||
if (ga) addLibrary(alias, ga);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section === 'bundles') {
|
||||
const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*\[([^\]]*)\]$/);
|
||||
if (!assignment) continue;
|
||||
const members = [...assignment[2].matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1]);
|
||||
for (const accessor of catalogAccessors(assignment[1])) bundles.set(accessor, members);
|
||||
}
|
||||
}
|
||||
|
||||
return { libraries, bundles };
|
||||
}
|
||||
|
||||
const GRADLE_GROUP_PATTERNS = [
|
||||
/(?:^|[\n{;])\s*(?:rootProject\.)?group\s*=\s*['"]([^'"]+)['"]/,
|
||||
/(?:^|[\n{;])\s*group\s+['"]([^'"]+)['"]/,
|
||||
];
|
||||
|
||||
const GRADLE_COORD_CONFIGS =
|
||||
'implementation|api|compileOnly|runtimeOnly|testImplementation|testApi|testCompileOnly|compile|kapt|ksp|commonMainImplementation|commonMainApi';
|
||||
|
||||
const CATALOG_ALIAS = '([A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+)*)(?:\\.get\\(\\)|\\.asProvider\\(\\))?';
|
||||
|
||||
function gradleDepRe(suffix: string): RegExp {
|
||||
return new RegExp(`(?:${GRADLE_COORD_CONFIGS})\\s*${suffix}`, 'g');
|
||||
}
|
||||
|
||||
function parseGradleGroup(content: string): string | undefined {
|
||||
for (const pattern of GRADLE_GROUP_PATTERNS) {
|
||||
const match = content.match(pattern);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asXmlNode(value: unknown): XmlNode | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as XmlNode)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function xmlText(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const text = String(value).trim();
|
||||
return text || undefined;
|
||||
}
|
||||
const nested = asXmlNode(value)?.['#text'];
|
||||
if (nested === undefined) return undefined;
|
||||
return xmlText(nested);
|
||||
}
|
||||
|
||||
function xmlChildText(node: XmlNode | undefined, name: string): string | undefined {
|
||||
return node ? xmlText(node[name]) : undefined;
|
||||
}
|
||||
|
||||
function asList(value: unknown): unknown[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/** Direct project dependencies only — not BOM, profiles, or plugin classpath. */
|
||||
function collectProjectDependencies(project: XmlNode, deps: string[]): void {
|
||||
const dependencies = asXmlNode(project.dependencies);
|
||||
if (!dependencies) return;
|
||||
for (const dep of asList(dependencies.dependency)) {
|
||||
const depNode = asXmlNode(dep);
|
||||
const groupId = xmlChildText(depNode, 'groupId');
|
||||
const artifactId = xmlChildText(depNode, 'artifactId');
|
||||
if (groupId && artifactId) deps.push(`${groupId}:${artifactId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
// parseSourceSafe guards tree-sitter's Windows SIGSEGV by switching to a
|
||||
// chunked input callback above 16 KB; XMLParser only accepts XML text, so
|
||||
// routing POMs through it silently yields an empty document.
|
||||
// eslint-disable-next-line gitnexus/require-safe-parse
|
||||
parsed = pomParser.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const project = asXmlNode(asXmlNode(parsed)?.project);
|
||||
if (!project) return null;
|
||||
|
||||
// Maven inherits groupId from <parent>, but artifactId is always the
|
||||
// project's own direct child and must never fall back to parent.artifactId.
|
||||
const groupId =
|
||||
xmlChildText(project, 'groupId') ?? xmlChildText(asXmlNode(project.parent), 'groupId');
|
||||
const artifactId = xmlChildText(project, 'artifactId');
|
||||
if (!groupId || !artifactId) return null;
|
||||
|
||||
const deps: string[] = [];
|
||||
collectProjectDependencies(project, deps);
|
||||
return { groupId, artifactId, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function parseGradle(
|
||||
content: string,
|
||||
repoPath: string,
|
||||
sidecars: GradleSidecars = { catalogLibraries: new Map(), catalogBundles: new Map() },
|
||||
): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
|
||||
const dirName = path.basename(repoPath);
|
||||
const groupId = groupMatch ? groupMatch[1] : '';
|
||||
// Static text + default catalog file. Do not execute Gradle.
|
||||
const groupId = parseGradleGroup(content) ?? sidecars.propertiesGroup ?? '';
|
||||
if (!groupId) return null;
|
||||
|
||||
const artifactId = dirName;
|
||||
const artifactId = sidecars.rootProjectName ?? path.basename(repoPath);
|
||||
const { catalogLibraries, catalogBundles } = sidecars;
|
||||
|
||||
const deps: string[] = [];
|
||||
// implementation("group:artifact:version") or api("group:artifact:version")
|
||||
const depMatches = content.matchAll(
|
||||
/(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
const pushCatalogAlias = (alias: string) => {
|
||||
const ga = catalogLibraries.get(alias);
|
||||
if (ga) deps.push(ga);
|
||||
};
|
||||
|
||||
const namedPattern = gradleDepRe(
|
||||
`(?:\\(\\s*)?(?:group\\s*=\\s*['"](?<group1>[^'"]+)['"]\\s*,\\s*name\\s*=\\s*['"](?<name1>[^'"]+)['"]|name\\s*=\\s*['"](?<name2>[^'"]+)['"]\\s*,\\s*group\\s*=\\s*['"](?<group2>[^'"]+)['"]|group:\\s*['"](?<group3>[^'"]+)['"]\\s*,\\s*name:\\s*['"](?<name3>[^'"]+)['"]|name:\\s*['"](?<name4>[^'"]+)['"]\\s*,\\s*group:\\s*['"](?<group4>[^'"]+)['"])`,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
const parts = m[1].split(':');
|
||||
if (parts.length >= 2) {
|
||||
deps.push(`${parts[0]}:${parts[1]}`);
|
||||
for (const match of content.matchAll(namedPattern)) {
|
||||
const group =
|
||||
match.groups?.group1 ?? match.groups?.group2 ?? match.groups?.group3 ?? match.groups?.group4;
|
||||
const name =
|
||||
match.groups?.name1 ?? match.groups?.name2 ?? match.groups?.name3 ?? match.groups?.name4;
|
||||
if (group && name) deps.push(`${group}:${name}`);
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(
|
||||
gradleDepRe(`(?:\\(\\s*)?libs(?:\\.libraries)?\\.(?!bundles\\.|plugins\\.)${CATALOG_ALIAS}`),
|
||||
)) {
|
||||
pushCatalogAlias(match[1]);
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(
|
||||
gradleDepRe(`(?:\\(\\s*)?libs\\.bundles\\.${CATALOG_ALIAS}`),
|
||||
)) {
|
||||
for (const member of catalogBundles.get(match[1]) ?? []) {
|
||||
for (const accessor of catalogAccessors(member)) pushCatalogAlias(accessor);
|
||||
}
|
||||
}
|
||||
|
||||
// implementation(project(":subproject"))
|
||||
const projDeps = content.matchAll(
|
||||
/(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g,
|
||||
);
|
||||
for (const m of projDeps) {
|
||||
const subName = m[1].replace(/^:/, '');
|
||||
deps.push(`${groupId}:${subName}`);
|
||||
for (const match of content.matchAll(gradleDepRe(`\\(\\s*projects\\.([A-Za-z][A-Za-z0-9.]*)`))) {
|
||||
deps.push(`${groupId}:${projectAccessorToArtifactId(match[1])}`);
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(
|
||||
gradleDepRe(`(?:\\(\\s*['"]([^'"]+)['"]\\s*\\)|['"]([^'"]+)['"])`),
|
||||
)) {
|
||||
const coord = match[1] ?? match[2];
|
||||
if (!coord) continue;
|
||||
const parts = coord.split(':');
|
||||
if (parts.length >= 2) deps.push(`${parts[0]}:${parts[1]}`);
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(
|
||||
gradleDepRe(`(?:\\(\\s*)?project\\s*\\(\\s*['"]([^'"]+)['"]\\s*\\)`),
|
||||
)) {
|
||||
deps.push(`${groupId}:${match[1].replace(/^:/, '')}`);
|
||||
}
|
||||
|
||||
return { groupId, artifactId, deps: [...new Set(deps)] };
|
||||
|
|
|
|||
|
|
@ -91,6 +91,61 @@ function crossLinkKey(link: CrossLink): string {
|
|||
].join('\0');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a link endpoint carries no resolved graph symbol — empty
|
||||
* `symbolUid` or a missing/empty `symbolRef`.
|
||||
*
|
||||
* Sync marks a cross-link `degraded: true` when this holds for the PROVIDER
|
||||
* endpoint (`to`): the contract boundary is proven, but the empty uid can
|
||||
* never match a Phase-1 impact symbol id, so cross-repo fan-out across the
|
||||
* link silently yields nothing (the classic case is a provider whose handler
|
||||
* failed to resolve, leaving `symbolName` degraded to the file name with one
|
||||
* pseudo-symbol carrying every route in that file). Consumer-side (`from`)
|
||||
* emptiness is deliberately NOT degraded — several extractors (topics, grpc)
|
||||
* legitimately emit consumer contracts without a per-call symbol, and the
|
||||
* anchor that matters for far-side fan-out is the provider's.
|
||||
*
|
||||
* Kept next to the endpoint merge logic because `dedupeCrossLinks` must
|
||||
* re-derive the flag after a merge: `mergeEndpoints` backfills `symbolUid`
|
||||
* from the losing twin, which can invalidate a flag carried in from the winner.
|
||||
*
|
||||
* NOT unresolved: a deterministic `manifest::<repo>::<contractId>` synthetic
|
||||
* uid (see `manifestSymbolUid`). Manifest endpoints fall back to it precisely
|
||||
* when the graph has no symbol for them — its empty `symbolRef.filePath` would
|
||||
* otherwise trip the check below — yet cross-impact anchors those links by
|
||||
* design (#2722: the crossing is preserved with `fanout_status:
|
||||
* 'not_attempted'` instead of silently yielding cross=0). The prefix is the
|
||||
* canonical discriminator — real indexer uids never start with `manifest::`
|
||||
* — and `cross-impact.ts` branches on the same test. Encoding the exemption
|
||||
* HERE (not at the sync marking call site) keeps marking and the post-merge
|
||||
* re-derivation from drifting apart, and keeps the flag's meaning exactly what
|
||||
* `types.ts` documents: "distinct from manifest::… synthetic UIDs".
|
||||
*/
|
||||
export function isUnresolvedEndpoint(endpoint: CrossLinkEndpoint): boolean {
|
||||
if (endpoint.symbolUid.startsWith('manifest::')) return false;
|
||||
return (
|
||||
!endpoint.symbolUid ||
|
||||
!endpoint.symbolRef ||
|
||||
!endpoint.symbolRef.filePath ||
|
||||
!endpoint.symbolRef.name
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive `degraded` from the provider endpoint. Present (`true`) only when
|
||||
* unresolved; deleted otherwise so contracts.json stays "carried only when
|
||||
* meaningful" (`'degraded' in link === false` for anchored links).
|
||||
*/
|
||||
export function applyDegradedFlag(link: CrossLink): CrossLink {
|
||||
const next: CrossLink = { ...link };
|
||||
if (isUnresolvedEndpoint(next.to)) {
|
||||
next.degraded = true;
|
||||
} else {
|
||||
delete next.degraded;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function dedupeContracts(items: StoredContract[]): StoredContract[] {
|
||||
const deduped = new Map<string, StoredContract>();
|
||||
for (const contract of items) {
|
||||
|
|
@ -113,12 +168,15 @@ export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] {
|
|||
const keepIncoming = link.confidence > existing.confidence;
|
||||
const primary = keepIncoming ? link : existing;
|
||||
const secondary = keepIncoming ? existing : link;
|
||||
deduped.set(key, {
|
||||
const merged: CrossLink = {
|
||||
...primary,
|
||||
confidence: Math.max(existing.confidence, link.confidence),
|
||||
from: mergeEndpoints(primary.from, secondary.from),
|
||||
to: mergeEndpoints(primary.to, secondary.to),
|
||||
});
|
||||
};
|
||||
// Re-derive after mergeEndpoints: a richer twin can backfill `to.symbolUid`
|
||||
// and must not leave a stale `degraded` flag on an now-anchored link.
|
||||
deduped.set(key, applyDegradedFlag(merged));
|
||||
}
|
||||
return [...deduped.values()];
|
||||
return [...deduped.values()].map(applyDegradedFlag);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,17 @@ export interface GroupToolPort {
|
|||
repo: GroupRepoHandle,
|
||||
params: {
|
||||
target: string;
|
||||
/**
|
||||
* Target-selector params, same semantics as the single-repo `impact`
|
||||
* tool: `target_uid` is the zero-ambiguity lookup (it wins over the
|
||||
* name), `file_path`/`kind` narrow a name shared by several symbols
|
||||
* (e.g. same-named Api/Impl/Controller layers). The port implementation
|
||||
* consumes them directly; the Phase-1 caller in cross-impact.ts is
|
||||
* responsible for threading them from the MCP `impact` args.
|
||||
*/
|
||||
target_uid?: string;
|
||||
file_path?: string;
|
||||
kind?: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
|
|
@ -517,6 +528,14 @@ export class GroupService {
|
|||
// can otherwise see contract counts that disagree with this payload, with
|
||||
// nothing here explaining why the write was skipped.
|
||||
registryOutcome: result.registryOutcome,
|
||||
// Data-quality signals surfaced from the sync run: links whose provider
|
||||
// endpoint never resolved to a graph symbol, per-repo extraction
|
||||
// failures with reasons, and operator warnings (e.g. bridge.lbug write
|
||||
// failed after contracts.json was written). Always present so MCP
|
||||
// consumers can branch on them without existence checks.
|
||||
degradedLinks: result.degradedLinks,
|
||||
failedRepos: result.failedRepos,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.
|
|||
import type { WildcardMatchResult } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
import { applyDegradedFlag } from './normalization.js';
|
||||
import { getContractRegistryPath, readContractRegistry, writeContractRegistry } from './storage.js';
|
||||
import {
|
||||
markBridgeProvenanceUnknown,
|
||||
|
|
@ -103,6 +104,24 @@ export interface SyncResult {
|
|||
* none of that repo's contracts are in `contracts`.
|
||||
*/
|
||||
unreadableRepos: string[];
|
||||
/**
|
||||
* Cross-links whose provider endpoint has no resolved graph symbol
|
||||
* (`degraded: true` on the link — see `isUnresolvedEndpoint`). The boundary
|
||||
* is proven but cross-impact fan-out cannot anchor it; the usual remedy is
|
||||
* re-analyzing the provider repo so its handlers resolve.
|
||||
*/
|
||||
degradedLinks: number;
|
||||
/**
|
||||
* Repos whose per-repo extraction threw (init, an extractor, or the
|
||||
* snapshot read). Each still lands in `unreadableRepos` (group path) —
|
||||
* unchanged downstream semantics — but carries its failure reason here: the
|
||||
* catch used to swallow the exception, leaving contracts already pushed by
|
||||
* earlier extractors in this iteration as silent half-repo data. `repo` is
|
||||
* that same group path (e.g. `app/backend`), not the registry display name.
|
||||
*/
|
||||
failedRepos: Array<{ repo: string; reason: string }>;
|
||||
/** Operator-facing run warnings (e.g. bridge.lbug write failed after contracts.json was written). */
|
||||
warnings: string[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
/**
|
||||
* Matching stages this run was asked to skip. Populated on EVERY outcome,
|
||||
|
|
@ -275,6 +294,8 @@ export function partitionManifestWindows(
|
|||
|
||||
export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise<SyncResult> {
|
||||
const missingRepos: string[] = [];
|
||||
const failedRepos: Array<{ repo: string; reason: string }> = [];
|
||||
const warnings: string[] = [];
|
||||
// Repos that ARE registered but that we could not extract from — the index
|
||||
// would not open, or an extractor threw partway and the repo's staged
|
||||
// contracts were dropped. Kept separate from `missingRepos` because the two
|
||||
|
|
@ -472,6 +493,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
"⚠️ Could not read this repo's index; its contracts are omitted from this sync.",
|
||||
);
|
||||
unreadableRepos.push(groupPath);
|
||||
failedRepos.push({
|
||||
repo: groupPath,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
// Forget the handle recorded above (present only if the failure came
|
||||
// after initLbug). Deferred manifest resolution derives its known-repo
|
||||
// set from this map, so leaving the entry here re-opens a repo this
|
||||
|
|
@ -639,7 +664,9 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
|
||||
// same endpoints. Prefer the manifest version — it reflects operator intent
|
||||
// and carries matchType:'manifest' which downstream consumers may rely on.
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]).map(
|
||||
applyDegradedFlag,
|
||||
);
|
||||
const allContracts: StoredContract[] = autoContracts;
|
||||
|
||||
const registry: ContractRegistry = {
|
||||
|
|
@ -867,13 +894,16 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
'a lower bound rather than as complete.'
|
||||
: 'Its metadata could NOT be marked provenance-unknown, so those answers may still ' +
|
||||
'report as complete despite describing an older sync.';
|
||||
const writeBridgeWarn =
|
||||
'⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
|
||||
'but bridge.lbug was not replaced: cross-repo queries may still answer from ' +
|
||||
`the previous sync's contracts. ${provenanceNote} ` +
|
||||
'Re-run `gitnexus group sync` to retry.';
|
||||
logger.warn(
|
||||
{ err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn },
|
||||
'⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
|
||||
'but bridge.lbug was not replaced: cross-repo queries may still answer from ' +
|
||||
`the previous sync's contracts. ${provenanceNote} ` +
|
||||
'Re-run `gitnexus group sync` to retry.',
|
||||
writeBridgeWarn,
|
||||
);
|
||||
warnings.push(writeBridgeWarn);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -886,6 +916,9 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
unmatched: wildcard.remaining,
|
||||
missingRepos,
|
||||
unreadableRepos,
|
||||
failedRepos,
|
||||
warnings,
|
||||
degradedLinks: crossLinks.filter((l) => l.degraded === true).length,
|
||||
repoSnapshots,
|
||||
registryOutcome,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,6 +97,19 @@ export interface CrossLink {
|
|||
contractId: string;
|
||||
matchType: MatchType;
|
||||
confidence: number;
|
||||
/**
|
||||
* `true` when the PROVIDER endpoint (`to`) has no resolved graph symbol —
|
||||
* empty `symbolUid` / `symbolRef` at sync time (e.g. the handler failed to
|
||||
* resolve and `symbolName` degraded to the file name). The contract boundary
|
||||
* is still proven, but the link cannot anchor a cross-impact fan-out: an
|
||||
* empty provider uid never matches a Phase-1 symbol id, and a downstream
|
||||
* fan-out into it has no neighbor symbol to resolve. Derived once at the
|
||||
* sync persistence boundary (`isUnresolvedEndpoint` in normalization.ts) and
|
||||
* re-derived by `dedupeCrossLinks` when a merge backfills the uid. Absent on
|
||||
* fully-anchored links. Distinct from manifest `manifest::…` synthetic UIDs,
|
||||
* which have their own `fanout_status: 'not_attempted'` channel downstream.
|
||||
*/
|
||||
degraded?: boolean;
|
||||
}
|
||||
|
||||
export interface RepoSnapshot {
|
||||
|
|
|
|||
83
gitnexus/src/core/incremental/derived-writeback.ts
Normal file
83
gitnexus/src/core/incremental/derived-writeback.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Incremental derived-layer writeback helpers (#3016).
|
||||
*
|
||||
* The derived layers — Leiden communities, execution flows, and the FTS
|
||||
* indexes — are graph-wide, so every analyze run rebuilt all three in full no
|
||||
* matter how small the diff. A surgical incremental write can instead:
|
||||
* - drop and rebuild only the FTS indexes whose tables hold rows in the
|
||||
* write set (LadybugDB still cannot DML a table with a live FTS index —
|
||||
* #2589 — so a table being written must still lose its index first);
|
||||
* - leave the untouched tables' rows alone, so their indexes stay live;
|
||||
* - reuse persisted Community/Process rows only when the file-hash diff is
|
||||
* empty (no added, changed, or deleted files). Any content change can
|
||||
* add, rename, or retarget symbols that Leiden and flow extraction
|
||||
* consume — a no-deletion edit is not a validity proof.
|
||||
*/
|
||||
import { FTS_INDEXES } from '../search/fts-schema.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { FileHashDiff } from '../../storage/file-hash.js';
|
||||
|
||||
const FTS_TABLE_NAMES: ReadonlySet<string> = new Set(FTS_INDEXES.map((i) => i.table));
|
||||
|
||||
/** The FTS-backed members of `tables`. */
|
||||
export const ftsTablesAmong = (tables: Iterable<string>): Set<string> => {
|
||||
const out = new Set<string>();
|
||||
for (const table of tables) {
|
||||
if (FTS_TABLE_NAMES.has(table)) out.add(table);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a surgical incremental write may reuse the persisted derived layer.
|
||||
*
|
||||
* Deletions disqualify it: the persisted Community/Process rows and their
|
||||
* MEMBER_OF / STEP_IN_PROCESS edges can reference nodes that no longer exist
|
||||
* after this run, and nothing short of re-deriving can tell which.
|
||||
*
|
||||
* Added or content-changed files also disqualify it: they can introduce,
|
||||
* rename, or retarget symbols and CALLS edges that Leiden and flow extraction
|
||||
* consume. File-deletion-only was too weak a proof that the derived graph is
|
||||
* still valid.
|
||||
*/
|
||||
export const shouldPreservePersistedDerivedGraph = (
|
||||
diff: Pick<FileHashDiff, 'deleted' | 'added' | 'changed'>,
|
||||
): boolean => diff.deleted.length === 0 && diff.added.length === 0 && diff.changed.length === 0;
|
||||
|
||||
/**
|
||||
* FTS-backed node tables that the fresh graph will WRITE rows into for
|
||||
* `fileSet` — the inserting half of the DML.
|
||||
*
|
||||
* Callers must union this with a DB probe for the deleting half
|
||||
* (`nodeTablesWithRowsForFiles`): a table whose last row in these files was
|
||||
* just removed by the edit has nothing here, but still holds a stale row that
|
||||
* the writeback must delete, and deleting it means taking its index down too.
|
||||
*/
|
||||
export const incrementalFtsTablesFromGraph = (
|
||||
graph: KnowledgeGraph,
|
||||
fileSet: ReadonlySet<string>,
|
||||
): Set<string> => {
|
||||
const touched = new Set<string>();
|
||||
graph.forEachNode((n) => {
|
||||
const filePath = n.properties?.filePath as string | undefined;
|
||||
if (!filePath || !fileSet.has(filePath)) return;
|
||||
if (FTS_TABLE_NAMES.has(n.label)) touched.add(n.label);
|
||||
});
|
||||
return touched;
|
||||
};
|
||||
|
||||
/**
|
||||
* The node tables an incremental DETACH DELETE should target, given the FTS
|
||||
* tables this run is rebuilding.
|
||||
*
|
||||
* Every non-FTS table (Folder, CodeElement, …) deletes as before. An FTS-backed
|
||||
* table only deletes when its index is being rebuilt anyway, because deleting
|
||||
* from it otherwise would mean DML against a live FTS index (#2589).
|
||||
*/
|
||||
export const nodeTablesForIncrementalDelete = (
|
||||
allNodeTables: readonly string[],
|
||||
rebuildingFtsTables: ReadonlySet<string>,
|
||||
): string[] =>
|
||||
allNodeTables.filter(
|
||||
(tableName) => !FTS_TABLE_NAMES.has(tableName) || rebuildingFtsTables.has(tableName),
|
||||
);
|
||||
57
gitnexus/src/core/incremental/spring-config-drift.ts
Normal file
57
gitnexus/src/core/incremental/spring-config-drift.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { SPRING_CONFIG_UNRESOLVED_PREFIX } from '../ingestion/frameworks/spring/config-bindings.js';
|
||||
|
||||
export interface PersistedSpringConfigConsumerRow {
|
||||
readonly id?: unknown;
|
||||
readonly description?: unknown;
|
||||
}
|
||||
|
||||
const CONSUMER_LABELS = new Set(['Property', 'Class', 'Record']);
|
||||
|
||||
function unresolvedKeys(description: unknown): readonly string[] {
|
||||
if (typeof description !== 'string') return [];
|
||||
return description
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.startsWith(SPRING_CONFIG_UNRESOLVED_PREFIX))
|
||||
.map((part) => part.slice(SPRING_CONFIG_UNRESOLVED_PREFIX.length))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find unchanged Spring consumer files whose unresolved markers changed.
|
||||
*
|
||||
* A removed config key also removes the old USES edge from the fresh graph, so
|
||||
* ordinary new-graph boundary expansion cannot discover the consumer file.
|
||||
*/
|
||||
export function collectSpringConfigConsumerDriftFiles(
|
||||
graph: KnowledgeGraph,
|
||||
persistedRows: readonly PersistedSpringConfigConsumerRow[],
|
||||
): Set<string> {
|
||||
const persistedById = new Map<string, readonly string[]>();
|
||||
for (const row of persistedRows) {
|
||||
if (typeof row.id !== 'string') continue;
|
||||
persistedById.set(row.id, unresolvedKeys(row.description));
|
||||
}
|
||||
|
||||
const driftFiles = new Set<string>();
|
||||
graph.forEachNode((node) => {
|
||||
if (!CONSUMER_LABELS.has(node.label)) return;
|
||||
const filePath = node.properties.filePath;
|
||||
if (typeof filePath !== 'string') return;
|
||||
const description = node.properties.description;
|
||||
const persisted = persistedById.get(node.id);
|
||||
if (
|
||||
persisted === undefined &&
|
||||
(typeof description !== 'string' || !description.includes(SPRING_CONFIG_UNRESOLVED_PREFIX))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const current = unresolvedKeys(description);
|
||||
const prior = persisted ?? [];
|
||||
if (current.length !== prior.length || current.some((key, index) => key !== prior[index])) {
|
||||
driftFiles.add(filePath);
|
||||
}
|
||||
});
|
||||
return driftFiles;
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
* replaced, produce a smaller KnowledgeGraph that contains:
|
||||
*
|
||||
* - Every node whose `properties.filePath` is in `toWriteSet`.
|
||||
* - Every graph-wide node (Community, Process, and Spring metadata
|
||||
* placeholders) — these are regenerated each run and must be fully
|
||||
* rewritten.
|
||||
* - Graph-wide Community/Process nodes unless `includeDerivedGraphWide`
|
||||
* is false (#3016 incremental preserve). Spring metadata placeholders
|
||||
* and `Destination` nodes are always included — their owning phase
|
||||
* delete-alls them unconditionally before the writeback.
|
||||
* - Every relationship where AT LEAST ONE endpoint is in the writable
|
||||
* set above. Relationships entirely between unchanged-file nodes
|
||||
* are skipped — their rows are still in the DB and re-inserting
|
||||
|
|
@ -57,9 +58,31 @@ import {
|
|||
} from '../ingestion/frameworks/spring/auto-configuration.js';
|
||||
import { isSpringAopEvidenceNode } from '../ingestion/frameworks/spring/aop.js';
|
||||
|
||||
/**
|
||||
* `Destination` is graph-wide for the same reason as the Spring AOP evidence
|
||||
* nodes: the layer is recomputed in full on every run and deleted in full
|
||||
* before the writeback (`deleteAllDestinations`), so it must be re-included in
|
||||
* full or it is simply lost.
|
||||
*
|
||||
* The endpoint-writability rule cannot carry it. A RESOLVED destination stores
|
||||
* no `filePath` at all — deliberately, so an incremental delete keyed on
|
||||
* `filePath IN [...]` cannot cut a node shared across files — and the include
|
||||
* test below starts from exactly that property. The result was a defect in both
|
||||
* directions: a newly added file publishing to a new topic reported
|
||||
* `added=1, exit 0` and silently put neither the destination nor the
|
||||
* publisher's edge into the graph, so after the first index every new topic was
|
||||
* invisible until a full rebuild; and a destination whose last referrer stopped
|
||||
* referring to it survived forever as an edgeless orphan still carrying
|
||||
* `address`, the cross-repository join key.
|
||||
*
|
||||
* Unresolved destinations DO carry a file path and would ride the ordinary
|
||||
* rule, but they are included here too: the delete-all removes them as well, so
|
||||
* anything not re-included would be dropped rather than merely stale.
|
||||
*/
|
||||
const isGraphWideNode = (node: GraphNode): boolean =>
|
||||
node.label === 'Community' ||
|
||||
node.label === 'Process' ||
|
||||
node.label === 'Destination' ||
|
||||
isSpringAopEvidenceNode(node) ||
|
||||
isSpringAutoConfigurationSyntheticClass(node);
|
||||
|
||||
|
|
@ -122,13 +145,18 @@ const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map<string, string> => {
|
|||
export const extractChangedSubgraph = (
|
||||
fullGraph: KnowledgeGraph,
|
||||
toWriteSet: ReadonlySet<string>,
|
||||
options?: { includeDerivedGraphWide?: boolean },
|
||||
): KnowledgeGraph => {
|
||||
const sub = createKnowledgeGraph();
|
||||
const writableNodeIds = new Set<string>();
|
||||
|
||||
const includeDerivedGraphWide = options?.includeDerivedGraphWide !== false;
|
||||
|
||||
fullGraph.forEachNode((n: GraphNode) => {
|
||||
const filePath = n.properties?.filePath as string | undefined;
|
||||
const include = (filePath && toWriteSet.has(filePath)) || isGraphWideNode(n);
|
||||
const derivedWide =
|
||||
includeDerivedGraphWide || (n.label !== 'Community' && n.label !== 'Process');
|
||||
const include = (filePath && toWriteSet.has(filePath)) || (isGraphWideNode(n) && derivedWide);
|
||||
if (include) {
|
||||
sub.addNode(n);
|
||||
writableNodeIds.add(n.id);
|
||||
|
|
|
|||
|
|
@ -406,7 +406,9 @@ export function resolveRouteHandlerSymbols(
|
|||
httpMethod: string | null | undefined,
|
||||
symbolId: string | undefined,
|
||||
) => {
|
||||
if (!routePath) return;
|
||||
// An empty path is a valid, pathless mapping and normalizes to either `/`
|
||||
// or its class/router prefix. Only null means the extractor had no route.
|
||||
if (routePath === null) return;
|
||||
const url = normalizeExtractedRoutePath(routePath, prefix);
|
||||
const key = routeNodeKey(normalizeRouteMethod(httpMethod), url);
|
||||
if (claimed.has(key)) return; // first-writer-wins: later same-key routes can't override
|
||||
|
|
|
|||
62
gitnexus/src/core/ingestion/destination-key.ts
Normal file
62
gitnexus/src/core/ingestion/destination-key.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Shared destination-identity keying — the async counterpart of `routeNodeKey`
|
||||
* in `route-extractors/route-path.ts`.
|
||||
*
|
||||
* Deliberately OUTSIDE `frameworks/spring/`, and for the same reason
|
||||
* `routeNodeKey` sits outside the routes phase: the identity has to be mintable
|
||||
* by anything that names a broker address, so a Node Kafka client or a Celery
|
||||
* task queue can land on the very node a Spring publisher minted. A key that
|
||||
* lived in the Spring module would force every other producer to import Spring,
|
||||
* or — worse — let each one invent its own spelling, and two spellings of one
|
||||
* address is precisely the missed connection this overlay exists to make.
|
||||
*
|
||||
* `broker` is a plain `string`, NOT the Spring `SpringDestinationBroker` union.
|
||||
* Importing that union here is the dependency this module exists to avoid, and
|
||||
* widening it costs nothing that matters: the union is a subtype of `string`,
|
||||
* so a Spring caller passes its own values unchanged, while a future
|
||||
* non-Spring caller stays free to attest to a broker Spring has no name for.
|
||||
* The trade is real but small — this signature cannot reject a misspelled
|
||||
* broker — and it is the same trade `routeNodeKey` makes by taking `method` as
|
||||
* a `string` rather than an HTTP-verb union. Pure string logic, no
|
||||
* dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `Destination` node identity: `(broker, address)` when the broker is
|
||||
* known, falling back to the address alone when it is not.
|
||||
*
|
||||
* The broker belongs IN the key, exactly as the HTTP verb belongs in
|
||||
* `routeNodeKey`. `GET /x` and `POST /x` are two nodes, both fully joinable,
|
||||
* and neither is punished for the other's existence; `kafka orders` and
|
||||
* `rabbit orders` are two nodes on the same terms. A Kafka topic and a Rabbit
|
||||
* queue that happen to share a name are two places, and one node for both would
|
||||
* report a publisher and a subscriber as connected when nothing connects them.
|
||||
*
|
||||
* The known objection is that the broker is INFERRED — from a receiver's name,
|
||||
* from an annotation table — so a wrong guess splits a pair that is really one.
|
||||
* That is true and it is the cost. It is worth paying because the alternative
|
||||
* tried first was worse: withdrawing the address from every site that named it
|
||||
* split the pair even when the guess was RIGHT, since one unrelated third party
|
||||
* writing the same word anywhere in the repository was enough to disconnect
|
||||
* everybody on that spelling. Putting the broker in the key bounds the damage
|
||||
* of a wrong guess to the one pair it was wrong about, instead of spreading it
|
||||
* to every pair that shares an address with a stranger.
|
||||
*
|
||||
* ── THE ADDRESS-ONLY FALLBACK IS UNREACHABLE TODAY ──────────────────────
|
||||
*
|
||||
* `SpringDestinationCandidate.broker` is REQUIRED, and every annotation rule
|
||||
* and every producer template supplies one, so no Spring caller can reach the
|
||||
* `undefined` branch. It is written anyway, and on purpose: the parameter shape
|
||||
* is the contract this module offers the next language, and the next language
|
||||
* may well capture an address without being able to attest to a broker (a bare
|
||||
* `queue.publish(name)` in a dynamic language, a binding that names only a
|
||||
* channel). Degrading to address-only is the right answer there — silence about
|
||||
* the broker is not a claim about it, and refusing to key such a site at all
|
||||
* would lose a real destination over a value nobody disagreed about.
|
||||
*
|
||||
* Because the branch is dead, it is covered by testing THIS function directly
|
||||
* rather than by a pipeline test staged to look as though a phase reached it.
|
||||
*/
|
||||
export function destinationNodeKey(broker: string | undefined, address: string): string {
|
||||
return broker ? `${broker} ${address}` : address;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
import { detectFrameworkFromPath } from './framework-detection.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { providers } from './languages/index.js';
|
||||
import { isTestFilePath } from './utils/test-file-path.js';
|
||||
|
||||
// ============================================================================
|
||||
// NAME PATTERNS
|
||||
|
|
@ -164,54 +165,15 @@ export function calculateEntryPointScore(
|
|||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a file path is a test file (should be excluded from entry points)
|
||||
* Covers common test file patterns across all supported languages
|
||||
* Check if a file path is a test file (should be excluded from entry points).
|
||||
*
|
||||
* Delegates to the shared predicate in `utils/test-file-path.ts`. This used to be
|
||||
* a second, hand-maintained copy that had drifted from the one backing the MCP
|
||||
* `includeTests` flag — see that module's header. Re-exported under this name so
|
||||
* existing importers are unaffected.
|
||||
*/
|
||||
export function isTestFile(filePath: string): boolean {
|
||||
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
|
||||
return (
|
||||
// JavaScript/TypeScript test patterns
|
||||
p.includes('.test.') ||
|
||||
p.includes('.spec.') ||
|
||||
p.includes('__tests__/') ||
|
||||
p.includes('__mocks__/') ||
|
||||
// Generic test folders
|
||||
p.includes('/test/') ||
|
||||
p.includes('/tests/') ||
|
||||
p.includes('/testing/') ||
|
||||
// Python test patterns
|
||||
p.endsWith('_test.py') ||
|
||||
p.includes('/test_') ||
|
||||
// Go test patterns
|
||||
p.endsWith('_test.go') ||
|
||||
// Java test patterns
|
||||
p.includes('/src/test/') ||
|
||||
// Rust test patterns (inline tests are different, but test files)
|
||||
p.includes('/tests/') ||
|
||||
// Swift/iOS test patterns
|
||||
p.endsWith('tests.swift') ||
|
||||
p.endsWith('test.swift') ||
|
||||
p.includes('uitests/') ||
|
||||
// C# test patterns
|
||||
p.endsWith('tests.cs') ||
|
||||
p.endsWith('test.cs') ||
|
||||
p.includes('.tests/') ||
|
||||
p.includes('.test/') ||
|
||||
p.includes('.integrationtests/') ||
|
||||
p.includes('.unittests/') ||
|
||||
p.includes('/testproject/') ||
|
||||
// PHP/Laravel test patterns
|
||||
p.endsWith('test.php') ||
|
||||
p.endsWith('spec.php') ||
|
||||
p.includes('/tests/feature/') ||
|
||||
p.includes('/tests/unit/') ||
|
||||
// Ruby test patterns
|
||||
p.endsWith('_spec.rb') ||
|
||||
p.endsWith('_test.rb') ||
|
||||
p.includes('/spec/') ||
|
||||
p.includes('/test/fixtures/')
|
||||
);
|
||||
return isTestFilePath(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,968 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { SPRING_DI_PROVIDER_PROPERTY } from '../../di-extractors/spring.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
normalizeRouteMethod,
|
||||
routeNodeKey,
|
||||
} from '../../route-extractors/route-path.js';
|
||||
import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js';
|
||||
import { SPRING_CONFIG_DESCRIPTION } from './config-bindings.js';
|
||||
import { getProviderForFile } from '../../languages/index.js';
|
||||
import type { RuntimeCallableIdentity } from '../../language-provider.js';
|
||||
|
||||
export const ACTUATOR_ENDPOINTS = [
|
||||
'mappings',
|
||||
'beans',
|
||||
'conditions',
|
||||
'configprops',
|
||||
'env',
|
||||
] as const;
|
||||
type ActuatorEndpoint = (typeof ACTUATOR_ENDPOINTS)[number];
|
||||
|
||||
const MAX_ACTUATOR_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
||||
export const MAX_RUNTIME_RECORDS = 50_000;
|
||||
const MAX_RUNTIME_DEPTH = 64;
|
||||
const RUNTIME_FILE_PREFIX = 'spring-actuator:';
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface SpringActuatorImportStats {
|
||||
readonly payloads: number;
|
||||
readonly mappings: number;
|
||||
readonly beans: number;
|
||||
readonly conditions: number;
|
||||
readonly configProperties: number;
|
||||
readonly environmentProperties: number;
|
||||
/** Endpoint categories that exceeded the bounded import size. */
|
||||
readonly truncatedEndpoints: readonly ActuatorEndpoint[];
|
||||
}
|
||||
|
||||
interface MutableImportStats {
|
||||
payloads: number;
|
||||
mappings: number;
|
||||
beans: number;
|
||||
conditions: number;
|
||||
configProperties: number;
|
||||
environmentProperties: number;
|
||||
truncatedEndpoints: ActuatorEndpoint[];
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
readonly count: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export class SpringActuatorImportError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SpringActuatorImportError';
|
||||
}
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): JsonObject | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeText(value: unknown, maxLength = 1024): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const sanitized = stripBidiAndZeroWidth(value)
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return sanitized.length === 0 ? undefined : sanitized.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeStrings(value: unknown, limit = 100): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const strings: string[] = [];
|
||||
for (const item of value.slice(0, limit)) {
|
||||
const text = safeText(item);
|
||||
if (text !== undefined) strings.push(text);
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
async function readPayloadFile(filePath: string, label: string): Promise<JsonObject> {
|
||||
// Size gate and read share one handle so both observe the same inode.
|
||||
// Re-resolving the path for the read would let a swapped file bypass the
|
||||
// payload cap (CodeQL js/file-system-race).
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
|
||||
let raw: string;
|
||||
try {
|
||||
handle = await fs.open(filePath, 'r');
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new SpringActuatorImportError(`Spring Actuator ${label} input must be a JSON file.`);
|
||||
}
|
||||
if (stat.size > MAX_ACTUATOR_PAYLOAD_BYTES) {
|
||||
throw new SpringActuatorImportError(
|
||||
`Spring Actuator ${label} payload exceeds the ${MAX_ACTUATOR_PAYLOAD_BYTES / 1024 / 1024} MiB limit.`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.alloc(MAX_ACTUATOR_PAYLOAD_BYTES + 1);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < buffer.length) {
|
||||
const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
|
||||
if (result.bytesRead === 0) break;
|
||||
bytesRead += result.bytesRead;
|
||||
}
|
||||
if (bytesRead > MAX_ACTUATOR_PAYLOAD_BYTES) {
|
||||
throw new SpringActuatorImportError(
|
||||
`Spring Actuator ${label} payload exceeds the ${MAX_ACTUATOR_PAYLOAD_BYTES / 1024 / 1024} MiB limit.`,
|
||||
);
|
||||
}
|
||||
raw = buffer.subarray(0, bytesRead).toString('utf8');
|
||||
} catch (err) {
|
||||
if (err instanceof SpringActuatorImportError) throw err;
|
||||
throw new SpringActuatorImportError(`Spring Actuator ${label} input could not be read.`);
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// Do not include JSON.parse's message: newer runtimes may quote source text,
|
||||
// which could disclose an env/configprops value in CLI output.
|
||||
throw new SpringActuatorImportError(`Spring Actuator ${label} payload is not valid JSON.`);
|
||||
}
|
||||
const object = objectValue(parsed);
|
||||
if (object === undefined) {
|
||||
throw new SpringActuatorImportError(`Spring Actuator ${label} payload must be a JSON object.`);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
async function loadPayloads(
|
||||
repoPath: string,
|
||||
configuredPath: string,
|
||||
): Promise<Map<ActuatorEndpoint, JsonObject>> {
|
||||
const inputPath = path.resolve(repoPath, configuredPath);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(inputPath);
|
||||
} catch {
|
||||
throw new SpringActuatorImportError(
|
||||
'Spring Actuator input path does not exist or is unreadable.',
|
||||
);
|
||||
}
|
||||
|
||||
const payloads = new Map<ActuatorEndpoint, JsonObject>();
|
||||
if (stat.isDirectory()) {
|
||||
for (const endpoint of ACTUATOR_ENDPOINTS) {
|
||||
const filePath = path.join(inputPath, `${endpoint}.json`);
|
||||
try {
|
||||
const endpointStat = await fs.stat(filePath);
|
||||
if (!endpointStat.isFile()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
payloads.set(endpoint, await readPayloadFile(filePath, endpoint));
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const parsed = await readPayloadFile(inputPath, 'bundle');
|
||||
const endpointFromName = ACTUATOR_ENDPOINTS.find(
|
||||
(endpoint) => path.basename(inputPath).toLowerCase() === `${endpoint}.json`,
|
||||
);
|
||||
if (endpointFromName !== undefined) {
|
||||
payloads.set(endpointFromName, parsed);
|
||||
} else {
|
||||
for (const endpoint of ACTUATOR_ENDPOINTS) {
|
||||
const payload = objectValue(parsed[endpoint]);
|
||||
if (payload !== undefined) payloads.set(endpoint, payload);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new SpringActuatorImportError(
|
||||
'Spring Actuator input must be a JSON bundle or a directory of endpoint JSON files.',
|
||||
);
|
||||
}
|
||||
|
||||
if (payloads.size === 0) {
|
||||
throw new SpringActuatorImportError(
|
||||
'Spring Actuator input contains none of mappings, beans, conditions, configprops, or env.',
|
||||
);
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
function evidenceFile(graph: KnowledgeGraph, endpoint: ActuatorEndpoint): GraphNode {
|
||||
const filePath = `${RUNTIME_FILE_PREFIX}${endpoint}`;
|
||||
const id = generateId('File', filePath);
|
||||
const existing = graph.getNode(id);
|
||||
if (existing !== undefined) return existing;
|
||||
const node: GraphNode = {
|
||||
id,
|
||||
label: 'File',
|
||||
properties: { name: `${endpoint}.json`, filePath },
|
||||
};
|
||||
graph.addNode(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function appendRuntimeMarker(node: GraphNode, marker: string): void {
|
||||
const current =
|
||||
typeof node.properties.description === 'string' ? node.properties.description : '';
|
||||
if (current.includes(marker)) return;
|
||||
node.properties.description = current.length === 0 ? marker : `${current}; ${marker}`;
|
||||
}
|
||||
|
||||
function markRuntimeEvidence(
|
||||
graph: KnowledgeGraph,
|
||||
endpoint: ActuatorEndpoint,
|
||||
target: GraphNode,
|
||||
status: string = 'runtime-confirmed',
|
||||
confirmed: boolean = true,
|
||||
): void {
|
||||
// Only Route declares structured runtime columns in the persisted schema.
|
||||
// Other labels retain the same evidence durably through their description
|
||||
// plus the DECLARES edge below; setting undeclared properties would make the
|
||||
// in-memory graph promise data that CSV/LadybugDB silently drops.
|
||||
if (target.label === 'Route') {
|
||||
// Confirmation is conflict-dominant. Once any runtime observation
|
||||
// disagrees with static or runtime ownership, a later duplicate must not
|
||||
// restore authoritative status.
|
||||
target.properties.runtimeConfirmed =
|
||||
target.properties.runtimeConfirmed === false ? false : confirmed;
|
||||
// Source records provenance, not authority. Consumers MUST use
|
||||
// runtimeConfirmed === true before treating runtime evidence as confirmed.
|
||||
target.properties.runtimeSource = 'spring-actuator';
|
||||
const previousStatus = safeText(target.properties.runtimeStatus);
|
||||
target.properties.runtimeStatus = [...new Set([...(previousStatus?.split(',') ?? []), status])]
|
||||
.sort()
|
||||
.join(',');
|
||||
}
|
||||
const marker = `Spring Actuator ${endpoint} ${status}`;
|
||||
appendRuntimeMarker(target, marker);
|
||||
|
||||
const evidence = evidenceFile(graph, endpoint);
|
||||
graph.addRelationship({
|
||||
id: generateId('DECLARES', `${evidence.id}->${target.id}:${status}`),
|
||||
sourceId: evidence.id,
|
||||
targetId: target.id,
|
||||
type: 'DECLARES',
|
||||
confidence: 1,
|
||||
reason: `spring-actuator:${endpoint}:${status}`,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedQualifiedName(value: string): string {
|
||||
return value
|
||||
.replace(/\$\$(?:SpringCGLIB|EnhancerBySpringCGLIB|FastClassBySpringCGLIB).*$/, '')
|
||||
.replaceAll('$', '.');
|
||||
}
|
||||
|
||||
function uniqueIndexAdd(index: Map<string, GraphNode | null>, key: string, node: GraphNode): void {
|
||||
const existing = index.get(key);
|
||||
if (existing === undefined) index.set(key, node);
|
||||
else if (existing !== null && existing.id !== node.id) index.set(key, null);
|
||||
}
|
||||
|
||||
interface RuntimeNodeIndexes {
|
||||
readonly classesByQualifiedName: Map<string, GraphNode | null>;
|
||||
readonly classesByRuntimeAlias: Map<string, GraphNode | null>;
|
||||
readonly classesBySimpleName: Map<string, GraphNode | null>;
|
||||
readonly beanProvidersByName: Map<string, GraphNode | null>;
|
||||
readonly methodsByOwnerId: Map<string, GraphNode[]>;
|
||||
readonly callablesByRuntimeOwner: Map<string, GraphNode[]>;
|
||||
readonly routeOwnerFileIdsByRouteId: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
function addRuntimeCallable(
|
||||
index: Map<string, GraphNode[]>,
|
||||
ownerName: string,
|
||||
node: GraphNode,
|
||||
): void {
|
||||
const normalizedOwner = normalizedQualifiedName(ownerName);
|
||||
const nodes = index.get(normalizedOwner) ?? [];
|
||||
if (!nodes.some((candidate) => candidate.id === node.id)) nodes.push(node);
|
||||
index.set(normalizedOwner, nodes);
|
||||
}
|
||||
|
||||
function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes {
|
||||
const allNodes = [...graph.iterNodes()];
|
||||
const classesByQualifiedName = new Map<string, GraphNode | null>();
|
||||
const classesByRuntimeAlias = new Map<string, GraphNode | null>();
|
||||
const classesBySimpleName = new Map<string, GraphNode | null>();
|
||||
const beanProvidersByName = new Map<string, GraphNode | null>();
|
||||
const nodesById = new Map(allNodes.map((node) => [node.id, node]));
|
||||
const methodsByOwnerId = new Map<string, GraphNode[]>();
|
||||
const callablesByRuntimeOwner = new Map<string, GraphNode[]>();
|
||||
const routeOwnerFileIdsByRouteId = new Map<string, Set<string>>();
|
||||
for (const node of allNodes) {
|
||||
if (node.label === 'Class' || node.label === 'Record') {
|
||||
const qualified = safeText(node.properties.qualifiedName);
|
||||
if (qualified !== undefined) {
|
||||
uniqueIndexAdd(classesByQualifiedName, normalizedQualifiedName(qualified), node);
|
||||
}
|
||||
uniqueIndexAdd(classesBySimpleName, String(node.properties.name), node);
|
||||
}
|
||||
const provider = objectValue(node.properties[SPRING_DI_PROVIDER_PROPERTY]);
|
||||
for (const name of safeStrings(provider?.names))
|
||||
uniqueIndexAdd(beanProvidersByName, name, node);
|
||||
}
|
||||
const ownedNodeIds = new Set<string>();
|
||||
for (const relationshipType of ['HAS_METHOD', 'HAS_PROPERTY'] as const) {
|
||||
for (const relationship of graph.iterRelationshipsByType(relationshipType)) {
|
||||
const member = nodesById.get(relationship.targetId);
|
||||
const owner = nodesById.get(relationship.sourceId);
|
||||
if (
|
||||
member === undefined ||
|
||||
!['Method', 'Function', 'Property'].includes(member.label) ||
|
||||
owner === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ownedNodeIds.add(member.id);
|
||||
if (member.label === 'Method' || member.label === 'Function') {
|
||||
const methods = methodsByOwnerId.get(relationship.sourceId) ?? [];
|
||||
methods.push(member);
|
||||
methodsByOwnerId.set(relationship.sourceId, methods);
|
||||
}
|
||||
const ownerQualifiedName = safeText(owner.properties.qualifiedName);
|
||||
if (ownerQualifiedName !== undefined) {
|
||||
addRuntimeCallable(callablesByRuntimeOwner, ownerQualifiedName, member);
|
||||
}
|
||||
const strategy = getProviderForFile(
|
||||
String(member.properties.filePath),
|
||||
)?.runtimeSymbolStrategy;
|
||||
for (const alias of strategy?.callableOwnerAliases?.(member, owner) ?? []) {
|
||||
addRuntimeCallable(callablesByRuntimeOwner, alias, member);
|
||||
if (
|
||||
(owner.label === 'Class' || owner.label === 'Record') &&
|
||||
ownerQualifiedName !== undefined &&
|
||||
normalizedQualifiedName(alias) !== normalizedQualifiedName(ownerQualifiedName)
|
||||
) {
|
||||
uniqueIndexAdd(classesByRuntimeAlias, normalizedQualifiedName(alias), owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
ownedNodeIds.has(node.id) ||
|
||||
(node.label !== 'Function' && node.label !== 'Method' && node.label !== 'Property')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const strategy = getProviderForFile(String(node.properties.filePath))?.runtimeSymbolStrategy;
|
||||
for (const alias of strategy?.callableOwnerAliases?.(node, undefined) ?? []) {
|
||||
addRuntimeCallable(callablesByRuntimeOwner, alias, node);
|
||||
}
|
||||
}
|
||||
for (const relationship of graph.iterRelationshipsByType('HANDLES_ROUTE')) {
|
||||
const owners = routeOwnerFileIdsByRouteId.get(relationship.targetId) ?? new Set<string>();
|
||||
owners.add(relationship.sourceId);
|
||||
routeOwnerFileIdsByRouteId.set(relationship.targetId, owners);
|
||||
}
|
||||
return {
|
||||
classesByQualifiedName,
|
||||
classesByRuntimeAlias,
|
||||
classesBySimpleName,
|
||||
beanProvidersByName,
|
||||
methodsByOwnerId,
|
||||
callablesByRuntimeOwner,
|
||||
routeOwnerFileIdsByRouteId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClass(
|
||||
indexes: RuntimeNodeIndexes,
|
||||
rawType: string | undefined,
|
||||
): GraphNode | undefined {
|
||||
if (rawType === undefined) return undefined;
|
||||
const type = normalizedQualifiedName(rawType.replace(/\[\]$/, ''));
|
||||
const exact = indexes.classesByQualifiedName.get(type);
|
||||
if (exact !== null && exact !== undefined) return exact;
|
||||
const alias = indexes.classesByRuntimeAlias.get(type);
|
||||
if (alias !== null && alias !== undefined) return alias;
|
||||
// A qualified runtime name is authoritative. Falling back to a unique class
|
||||
// with the same simple name can bind a stale snapshot to a different package
|
||||
// and then mint confidence-1 handler evidence for the wrong source.
|
||||
if (type.includes('.')) return undefined;
|
||||
const simple = type.slice(type.lastIndexOf('.') + 1);
|
||||
const fallback = indexes.classesBySimpleName.get(simple);
|
||||
return fallback === null ? undefined : fallback;
|
||||
}
|
||||
|
||||
function providerMatchesRuntimeType(
|
||||
indexes: RuntimeNodeIndexes,
|
||||
providerNode: GraphNode,
|
||||
runtimeType: string | undefined,
|
||||
): boolean {
|
||||
if (runtimeType === undefined) return true;
|
||||
const provider = objectValue(providerNode.properties[SPRING_DI_PROVIDER_PROPERTY]);
|
||||
const providerType =
|
||||
safeText(provider?.providedTypeName) ??
|
||||
(providerNode.label === 'Class' || providerNode.label === 'Record'
|
||||
? safeText(providerNode.properties.qualifiedName)
|
||||
: undefined);
|
||||
if (providerType === undefined) return true;
|
||||
|
||||
const providerClass = resolveClass(indexes, providerType);
|
||||
const runtimeClass = resolveClass(indexes, runtimeType);
|
||||
if (providerClass !== undefined && runtimeClass !== undefined) {
|
||||
return providerClass.id === runtimeClass.id;
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizedQualifiedName(providerType);
|
||||
const normalizedRuntime = normalizedQualifiedName(runtimeType);
|
||||
if (normalizedProvider.includes('.')) return normalizedProvider === normalizedRuntime;
|
||||
return normalizedProvider === normalizedRuntime.slice(normalizedRuntime.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
function descriptorParameterTypes(descriptor: string | undefined): string[] | undefined {
|
||||
if (descriptor === undefined || descriptor.charAt(0) !== '(') return undefined;
|
||||
const types: string[] = [];
|
||||
for (let index = 1; index < descriptor.length && descriptor.charAt(index) !== ')'; ) {
|
||||
let arrayDimensions = 0;
|
||||
while (descriptor.charAt(index) === '[') {
|
||||
arrayDimensions++;
|
||||
index++;
|
||||
}
|
||||
const arraySuffix = '[]'.repeat(arrayDimensions);
|
||||
if (descriptor.charAt(index) === 'L') {
|
||||
const end = descriptor.indexOf(';', index);
|
||||
if (end === -1) return undefined;
|
||||
types.push(`${descriptor.slice(index + 1, end)}${arraySuffix}`);
|
||||
index = end + 1;
|
||||
} else {
|
||||
const primitive = descriptor.charAt(index);
|
||||
if (!'BCDFIJSZ'.includes(primitive)) return undefined;
|
||||
types.push(`${primitive}${arraySuffix}`);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return descriptor.includes(')') ? types : undefined;
|
||||
}
|
||||
|
||||
function matchesRuntimeCallable(node: GraphNode, runtime: RuntimeCallableIdentity): boolean {
|
||||
const strategy = getProviderForFile(String(node.properties.filePath))?.runtimeSymbolStrategy;
|
||||
if (strategy !== undefined) return strategy.matchesCallable(node, runtime);
|
||||
return (
|
||||
(node.label === 'Method' || node.label === 'Function') &&
|
||||
node.properties.name === runtime.name &&
|
||||
(runtime.descriptorParameterTypes === undefined ||
|
||||
node.properties.parameterCount === runtime.descriptorParameterTypes.length)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHandlerNode(
|
||||
indexes: RuntimeNodeIndexes,
|
||||
handlerMethod: JsonObject | undefined,
|
||||
): GraphNode | undefined {
|
||||
const className = safeText(handlerMethod?.className);
|
||||
const methodName = safeText(handlerMethod?.name);
|
||||
if (methodName === undefined) return resolveClass(indexes, className);
|
||||
if (className === undefined) return undefined;
|
||||
const owner = resolveClass(indexes, className);
|
||||
const runtime: RuntimeCallableIdentity = {
|
||||
name: methodName,
|
||||
descriptorParameterTypes: descriptorParameterTypes(safeText(handlerMethod?.descriptor)),
|
||||
};
|
||||
const ownerCandidates = owner === undefined ? [] : (indexes.methodsByOwnerId.get(owner.id) ?? []);
|
||||
const aliasCandidates =
|
||||
indexes.callablesByRuntimeOwner.get(normalizedQualifiedName(className)) ?? [];
|
||||
const candidates = [...ownerCandidates, ...aliasCandidates]
|
||||
.filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index)
|
||||
.filter((node) => matchesRuntimeCallable(node, runtime));
|
||||
return candidates.length === 1 ? candidates[0] : undefined;
|
||||
}
|
||||
|
||||
function predicateParts(predicate: string | undefined): {
|
||||
readonly methods: string[];
|
||||
readonly patterns: string[];
|
||||
} {
|
||||
if (predicate === undefined) return { methods: [], patterns: [] };
|
||||
const methodListEnd = predicate.indexOf('[');
|
||||
const methodRegion = methodListEnd === -1 ? predicate : predicate.slice(0, methodListEnd);
|
||||
const methods = [
|
||||
...methodRegion.matchAll(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)\b/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((method): method is string => method !== undefined);
|
||||
const patterns = [...predicate.matchAll(/(?:^|[\s[(])((?:\/)[^\s\]),}]+)/g)]
|
||||
.map((match) => safeText(match[1]))
|
||||
.filter((pattern): pattern is string => pattern !== undefined);
|
||||
return { methods: [...new Set(methods)], patterns: [...new Set(patterns)] };
|
||||
}
|
||||
|
||||
function mappingEntries(payload: JsonObject): {
|
||||
entries: JsonObject[];
|
||||
truncated: boolean;
|
||||
} {
|
||||
const entries: JsonObject[] = [];
|
||||
const contexts = objectValue(payload.contexts);
|
||||
if (contexts === undefined) return { entries, truncated: false };
|
||||
for (const context of Object.values(contexts)) {
|
||||
const mappings = objectValue(objectValue(context)?.mappings);
|
||||
if (mappings === undefined) continue;
|
||||
for (const groupName of ['dispatcherServlets', 'dispatcherHandlers']) {
|
||||
const groups = objectValue(mappings[groupName]);
|
||||
if (groups === undefined) continue;
|
||||
for (const group of Object.values(groups)) {
|
||||
if (!Array.isArray(group)) continue;
|
||||
for (const entry of group) {
|
||||
const object = objectValue(entry);
|
||||
if (object !== undefined) entries.push(object);
|
||||
if (entries.length > MAX_RUNTIME_RECORDS) {
|
||||
entries.pop();
|
||||
return { entries, truncated: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { entries, truncated: false };
|
||||
}
|
||||
|
||||
interface RuntimeMappingCandidate {
|
||||
readonly key: string;
|
||||
readonly method: string | undefined;
|
||||
readonly url: string;
|
||||
readonly handler: GraphNode | undefined;
|
||||
}
|
||||
|
||||
function importMappings(
|
||||
graph: KnowledgeGraph,
|
||||
payload: JsonObject,
|
||||
indexes: RuntimeNodeIndexes,
|
||||
): ImportResult {
|
||||
let imported = 0;
|
||||
const payloadEntries = mappingEntries(payload);
|
||||
let truncated = payloadEntries.truncated;
|
||||
const candidatesByKey = new Map<string, RuntimeMappingCandidate[]>();
|
||||
for (const entry of payloadEntries.entries) {
|
||||
const details = objectValue(entry.details);
|
||||
const conditions = objectValue(details?.requestMappingConditions);
|
||||
const predicate = predicateParts(safeText(entry.predicate));
|
||||
const patterns = safeStrings(conditions?.patterns);
|
||||
const methods = safeStrings(conditions?.methods)
|
||||
.map(normalizeRouteMethod)
|
||||
.filter((method): method is string => method !== undefined);
|
||||
const effectivePatterns = patterns.length > 0 ? patterns : predicate.patterns;
|
||||
const effectiveMethods = methods.length > 0 ? methods : predicate.methods;
|
||||
if (effectivePatterns.length === 0) continue;
|
||||
|
||||
const handler = resolveHandlerNode(indexes, objectValue(details?.handlerMethod));
|
||||
for (const rawPattern of effectivePatterns) {
|
||||
const url = normalizeExtractedRoutePath(rawPattern, null);
|
||||
for (const method of effectiveMethods.length > 0 ? effectiveMethods : [undefined]) {
|
||||
const normalizedMethod = normalizeRouteMethod(method);
|
||||
const key = routeNodeKey(normalizedMethod, url);
|
||||
const candidate = { key, method: normalizedMethod, url, handler };
|
||||
const existing = candidatesByKey.get(key);
|
||||
if (existing === undefined) {
|
||||
if (candidatesByKey.size >= MAX_RUNTIME_RECORDS) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
candidatesByKey.set(key, [candidate]);
|
||||
} else {
|
||||
existing.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidates of candidatesByKey.values()) {
|
||||
const first = candidates[0];
|
||||
if (first === undefined) continue;
|
||||
const { key, method: normalizedMethod, url } = first;
|
||||
const resolvedHandlers = new Map(
|
||||
candidates
|
||||
.map((candidate) => candidate.handler)
|
||||
.filter((handler): handler is GraphNode => handler !== undefined)
|
||||
.map((handler) => [handler.id, handler]),
|
||||
);
|
||||
const runtimeHandlerConflict = resolvedHandlers.size > 1;
|
||||
const handler = runtimeHandlerConflict ? undefined : resolvedHandlers.values().next().value;
|
||||
const exactId = generateId('Route', key);
|
||||
const fallbackId = generateId('Route', url);
|
||||
let route = graph.getNode(exactId) ?? graph.getNode(fallbackId);
|
||||
if (route?.label !== 'Route') route = undefined;
|
||||
const routeWasPresent = route !== undefined;
|
||||
if (route === undefined) {
|
||||
route = {
|
||||
id: exactId,
|
||||
label: 'Route',
|
||||
properties: {
|
||||
name: url,
|
||||
filePath: handler?.properties.filePath ?? `${RUNTIME_FILE_PREFIX}mappings`,
|
||||
...(normalizedMethod === undefined ? {} : { method: normalizedMethod }),
|
||||
...(handler === undefined ? {} : { handlerSymbolId: handler.id }),
|
||||
},
|
||||
};
|
||||
graph.addNode(route);
|
||||
}
|
||||
const existingHandlerId = safeText(route.properties.handlerSymbolId);
|
||||
const handlerFilePath =
|
||||
handler !== undefined && typeof handler.properties.filePath === 'string'
|
||||
? handler.properties.filePath
|
||||
: undefined;
|
||||
const handlerFileId =
|
||||
handlerFilePath === undefined ? undefined : generateId('File', handlerFilePath);
|
||||
const staticOwnerFileIds = indexes.routeOwnerFileIdsByRouteId.get(route.id);
|
||||
const conflictsWithStaticOwner =
|
||||
routeWasPresent &&
|
||||
handlerFileId !== undefined &&
|
||||
staticOwnerFileIds !== undefined &&
|
||||
[...staticOwnerFileIds].some((ownerFileId) => ownerFileId !== handlerFileId);
|
||||
if (
|
||||
runtimeHandlerConflict ||
|
||||
(handler !== undefined &&
|
||||
((existingHandlerId !== undefined && existingHandlerId !== handler.id) ||
|
||||
conflictsWithStaticOwner))
|
||||
) {
|
||||
// Static ownership and runtime ownership disagree. Preserve the
|
||||
// static handler, persist an explicit conflict, and do not mint an
|
||||
// authoritative HANDLES_ROUTE edge from the runtime candidate.
|
||||
markRuntimeEvidence(graph, 'mappings', route, 'handler-conflict', false);
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
if (handler !== undefined && existingHandlerId === undefined) {
|
||||
route.properties.handlerSymbolId = handler.id;
|
||||
}
|
||||
markRuntimeEvidence(graph, 'mappings', route);
|
||||
if (handler !== undefined && handlerFileId !== undefined) {
|
||||
if (graph.getNode(handlerFileId) !== undefined) {
|
||||
graph.addRelationship({
|
||||
id: generateId('HANDLES_ROUTE', `${handlerFileId}->${route.id}`),
|
||||
sourceId: handlerFileId,
|
||||
targetId: route.id,
|
||||
type: 'HANDLES_ROUTE',
|
||||
confidence: 1,
|
||||
reason: 'spring-actuator:runtime-confirmed',
|
||||
});
|
||||
}
|
||||
}
|
||||
imported++;
|
||||
}
|
||||
return { count: imported, truncated };
|
||||
}
|
||||
|
||||
function contextObjects(payload: JsonObject): JsonObject[] {
|
||||
const contexts = objectValue(payload.contexts);
|
||||
if (contexts === undefined) return [];
|
||||
return Object.values(contexts)
|
||||
.map(objectValue)
|
||||
.filter((context): context is JsonObject => context !== undefined);
|
||||
}
|
||||
|
||||
function importBeans(
|
||||
graph: KnowledgeGraph,
|
||||
payload: JsonObject,
|
||||
indexes: RuntimeNodeIndexes,
|
||||
): ImportResult {
|
||||
let imported = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const [contextIndex, context] of contextObjects(payload).entries()) {
|
||||
const beans = objectValue(context.beans);
|
||||
if (beans === undefined) continue;
|
||||
for (const [rawBeanName, rawBean] of Object.entries(beans)) {
|
||||
if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true };
|
||||
const beanName = safeText(rawBeanName, 512);
|
||||
const bean = objectValue(rawBean);
|
||||
if (beanName === undefined || bean === undefined) continue;
|
||||
const identity = `${contextIndex}:${beanName}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
const type = safeText(bean.type, 1024);
|
||||
const named = indexes.beanProvidersByName.get(beanName);
|
||||
let target = named === null ? undefined : named;
|
||||
if (target !== undefined && !providerMatchesRuntimeType(indexes, target, type)) {
|
||||
target = undefined;
|
||||
}
|
||||
target ??= resolveClass(indexes, type);
|
||||
if (target === undefined) {
|
||||
const id = generateId('CodeElement', `spring-runtime-bean:${identity}`);
|
||||
target = graph.getNode(id);
|
||||
if (target === undefined) {
|
||||
const scope = safeText(bean.scope, 128);
|
||||
target = {
|
||||
id,
|
||||
label: 'CodeElement',
|
||||
properties: {
|
||||
name: beanName,
|
||||
filePath: `${RUNTIME_FILE_PREFIX}beans`,
|
||||
description:
|
||||
`Spring runtime Bean ${beanName}` +
|
||||
(type === undefined ? '' : ` of type ${type}`) +
|
||||
(scope === undefined ? '' : ` (${scope})`),
|
||||
...(type === undefined ? {} : { qualifiedName: normalizedQualifiedName(type) }),
|
||||
},
|
||||
};
|
||||
graph.addNode(target);
|
||||
}
|
||||
}
|
||||
markRuntimeEvidence(graph, 'beans', target);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
return { count: imported, truncated: false };
|
||||
}
|
||||
|
||||
function resolveConditionOwner(
|
||||
indexes: RuntimeNodeIndexes,
|
||||
rawName: string,
|
||||
): GraphNode | undefined {
|
||||
const separator = rawName.lastIndexOf('#');
|
||||
return resolveHandlerNode(indexes, {
|
||||
className: separator === -1 ? rawName : rawName.slice(0, separator),
|
||||
...(separator === -1 ? {} : { name: rawName.slice(separator + 1) }),
|
||||
});
|
||||
}
|
||||
|
||||
function importConditions(
|
||||
graph: KnowledgeGraph,
|
||||
payload: JsonObject,
|
||||
indexes: RuntimeNodeIndexes,
|
||||
): ImportResult {
|
||||
let imported = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const context of contextObjects(payload)) {
|
||||
for (const [field, status] of [
|
||||
['positiveMatches', 'matched'],
|
||||
['negativeMatches', 'not-matched'],
|
||||
] as const) {
|
||||
const matches = objectValue(context[field]);
|
||||
if (matches === undefined) continue;
|
||||
for (const rawName of Object.keys(matches)) {
|
||||
if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true };
|
||||
const name = safeText(rawName);
|
||||
if (name === undefined || seen.has(`${status}:${name}`)) continue;
|
||||
seen.add(`${status}:${name}`);
|
||||
const owner = resolveConditionOwner(indexes, name);
|
||||
if (owner === undefined) continue;
|
||||
// Actuator reports this status for the aggregate owner entry. Its child
|
||||
// details may contain a mix of matched and not-matched conditions, but
|
||||
// do not carry a stable identifier that maps to our CONDITIONAL_ON
|
||||
// targets. Keep the aggregate on the owner instead of guessing.
|
||||
markRuntimeEvidence(graph, 'conditions', owner, status);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count: imported, truncated: false };
|
||||
}
|
||||
|
||||
function relaxedPropertyName(value: string): string {
|
||||
return value.toLowerCase().replace(/[-_.\[\]]/g, '');
|
||||
}
|
||||
|
||||
interface RuntimePropertyIndex {
|
||||
readonly exact: Map<string, GraphNode | null>;
|
||||
readonly relaxed: Map<string, GraphNode | null>;
|
||||
}
|
||||
|
||||
function buildRuntimePropertyIndex(graph: KnowledgeGraph): RuntimePropertyIndex {
|
||||
const exact = new Map<string, GraphNode | null>();
|
||||
const relaxed = new Map<string, GraphNode | null>();
|
||||
for (const node of graph.iterNodes()) {
|
||||
if (node.label !== 'Property') continue;
|
||||
const description = safeText(node.properties.description) ?? '';
|
||||
if (!description.startsWith(SPRING_CONFIG_DESCRIPTION) && !node.id.includes('spring-runtime')) {
|
||||
continue;
|
||||
}
|
||||
const name = String(node.properties.name);
|
||||
uniqueIndexAdd(exact, name, node);
|
||||
uniqueIndexAdd(relaxed, relaxedPropertyName(name), node);
|
||||
}
|
||||
return { exact, relaxed };
|
||||
}
|
||||
|
||||
function ensureRuntimeProperty(
|
||||
graph: KnowledgeGraph,
|
||||
index: RuntimePropertyIndex,
|
||||
endpoint: 'configprops' | 'env',
|
||||
rawName: string,
|
||||
): GraphNode | undefined {
|
||||
const name = safeText(rawName, 1024);
|
||||
if (name === undefined) return undefined;
|
||||
const exact = index.exact.get(name);
|
||||
let node = exact === null ? undefined : exact;
|
||||
if (node === undefined) {
|
||||
const relaxed = index.relaxed.get(relaxedPropertyName(name));
|
||||
node = relaxed === null ? undefined : relaxed;
|
||||
}
|
||||
if (node === undefined) {
|
||||
const id = generateId('Property', `spring-runtime-config:${name}`);
|
||||
node = graph.getNode(id);
|
||||
if (node === undefined) {
|
||||
node = {
|
||||
id,
|
||||
label: 'Property',
|
||||
properties: {
|
||||
name,
|
||||
filePath: `${RUNTIME_FILE_PREFIX}${endpoint}`,
|
||||
description: `${SPRING_CONFIG_DESCRIPTION}; imported from Spring Actuator ${endpoint}`,
|
||||
},
|
||||
};
|
||||
graph.addNode(node);
|
||||
}
|
||||
uniqueIndexAdd(index.exact, name, node);
|
||||
uniqueIndexAdd(index.relaxed, relaxedPropertyName(name), node);
|
||||
}
|
||||
markRuntimeEvidence(graph, endpoint, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function configInputPaths(inputs: unknown): {
|
||||
paths: string[];
|
||||
truncated: boolean;
|
||||
} {
|
||||
const out: string[] = [];
|
||||
const stack: Array<{ value: unknown; prefix: string; depth: number }> = [
|
||||
{ value: inputs, prefix: '', depth: 0 },
|
||||
];
|
||||
while (stack.length > 0 && out.length < MAX_RUNTIME_RECORDS) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined || current.depth > MAX_RUNTIME_DEPTH) continue;
|
||||
const object = objectValue(current.value);
|
||||
if (object === undefined) {
|
||||
if (current.prefix.length > 0) out.push(current.prefix);
|
||||
continue;
|
||||
}
|
||||
const keys = Object.keys(object);
|
||||
const metadataLeaf =
|
||||
keys.length === 0 || keys.every((key) => key === 'value' || key === 'origin');
|
||||
if (metadataLeaf) {
|
||||
if (current.prefix.length > 0) out.push(current.prefix);
|
||||
continue;
|
||||
}
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const rawKey = keys[index];
|
||||
if (rawKey === undefined) continue;
|
||||
const key = safeText(rawKey, 256);
|
||||
if (key === undefined) continue;
|
||||
stack.push({
|
||||
value: object[rawKey],
|
||||
prefix: current.prefix.length === 0 ? key : `${current.prefix}.${key}`,
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { paths: out, truncated: stack.length > 0 };
|
||||
}
|
||||
|
||||
function importConfigProperties(
|
||||
graph: KnowledgeGraph,
|
||||
payload: JsonObject,
|
||||
propertyIndex: RuntimePropertyIndex,
|
||||
): ImportResult {
|
||||
let imported = 0;
|
||||
let truncated = false;
|
||||
const seen = new Set<string>();
|
||||
for (const context of contextObjects(payload)) {
|
||||
const beans = objectValue(context.beans);
|
||||
if (beans === undefined) continue;
|
||||
for (const rawBean of Object.values(beans)) {
|
||||
const bean = objectValue(rawBean);
|
||||
const prefix = safeText(bean?.prefix, 512)?.replace(/\.+$/, '');
|
||||
if (bean === undefined || prefix === undefined) continue;
|
||||
const inputPaths = configInputPaths(bean.inputs);
|
||||
truncated ||= inputPaths.truncated;
|
||||
const names =
|
||||
inputPaths.paths.length === 0
|
||||
? [prefix]
|
||||
: inputPaths.paths.map((entry) => `${prefix}.${entry}`);
|
||||
for (const name of names) {
|
||||
if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true };
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
if (ensureRuntimeProperty(graph, propertyIndex, 'configprops', name) !== undefined)
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count: imported, truncated };
|
||||
}
|
||||
|
||||
function importEnvironmentProperties(
|
||||
graph: KnowledgeGraph,
|
||||
payload: JsonObject,
|
||||
propertyIndex: RuntimePropertyIndex,
|
||||
): ImportResult {
|
||||
let imported = 0;
|
||||
const seen = new Set<string>();
|
||||
if (!Array.isArray(payload.propertySources)) return { count: imported, truncated: false };
|
||||
for (const rawSource of payload.propertySources) {
|
||||
const properties = objectValue(objectValue(rawSource)?.properties);
|
||||
if (properties === undefined) continue;
|
||||
// Deliberately enumerate keys only. Never read, retain, interpolate, or log
|
||||
// the corresponding {value, origin} objects.
|
||||
for (const rawName of Object.keys(properties)) {
|
||||
if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true };
|
||||
const name = safeText(rawName, 1024);
|
||||
if (name === undefined || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
if (ensureRuntimeProperty(graph, propertyIndex, 'env', name) !== undefined) imported++;
|
||||
}
|
||||
}
|
||||
return { count: imported, truncated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import explicitly supplied Spring Boot Actuator snapshots. Runtime evidence
|
||||
* is additive: it confirms existing static nodes where possible and creates
|
||||
* conservative synthetic Route/Bean/Property nodes otherwise. Raw payloads,
|
||||
* condition messages, config values, env values, origins, and source names are
|
||||
* never copied into graph properties or logs.
|
||||
*/
|
||||
export async function importSpringActuatorRuntime(
|
||||
graph: KnowledgeGraph,
|
||||
repoPath: string,
|
||||
configuredPath: string,
|
||||
): Promise<SpringActuatorImportStats> {
|
||||
const payloads = await loadPayloads(repoPath, configuredPath);
|
||||
const stats: MutableImportStats = {
|
||||
payloads: payloads.size,
|
||||
mappings: 0,
|
||||
beans: 0,
|
||||
conditions: 0,
|
||||
configProperties: 0,
|
||||
environmentProperties: 0,
|
||||
truncatedEndpoints: [],
|
||||
};
|
||||
const indexes = buildRuntimeNodeIndexes(graph);
|
||||
const propertyIndex = buildRuntimePropertyIndex(graph);
|
||||
|
||||
const mappings = payloads.get('mappings');
|
||||
if (mappings !== undefined) {
|
||||
const result = importMappings(graph, mappings, indexes);
|
||||
stats.mappings = result.count;
|
||||
if (result.truncated) stats.truncatedEndpoints.push('mappings');
|
||||
}
|
||||
const beans = payloads.get('beans');
|
||||
if (beans !== undefined) {
|
||||
const result = importBeans(graph, beans, indexes);
|
||||
stats.beans = result.count;
|
||||
if (result.truncated) stats.truncatedEndpoints.push('beans');
|
||||
}
|
||||
const conditions = payloads.get('conditions');
|
||||
if (conditions !== undefined) {
|
||||
const result = importConditions(graph, conditions, indexes);
|
||||
stats.conditions = result.count;
|
||||
if (result.truncated) stats.truncatedEndpoints.push('conditions');
|
||||
}
|
||||
const configprops = payloads.get('configprops');
|
||||
if (configprops !== undefined) {
|
||||
const result = importConfigProperties(graph, configprops, propertyIndex);
|
||||
stats.configProperties = result.count;
|
||||
if (result.truncated) stats.truncatedEndpoints.push('configprops');
|
||||
}
|
||||
const env = payloads.get('env');
|
||||
if (env !== undefined) {
|
||||
const result = importEnvironmentProperties(graph, env, propertyIndex);
|
||||
stats.environmentProperties = result.count;
|
||||
if (result.truncated) stats.truncatedEndpoints.push('env');
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
|
@ -124,7 +124,13 @@ export function parseSpringAnnotationArguments(
|
|||
const body = annotationText.slice(open + 1, close).trim();
|
||||
if (body.length === 0) return [];
|
||||
const rawArguments = splitTopLevel(body, ',');
|
||||
if (rawArguments === null || rawArguments.some((argument) => argument.length === 0)) return null;
|
||||
if (rawArguments === null) return null;
|
||||
// Kotlin (and some formatters) allow a trailing comma. An empty *middle*
|
||||
// argument is still invalid and fail-closed.
|
||||
while (rawArguments.at(-1)?.length === 0) {
|
||||
rawArguments.pop();
|
||||
}
|
||||
if (rawArguments.some((argument) => argument.length === 0)) return null;
|
||||
|
||||
const parsed: SpringAnnotationArgument[] = [];
|
||||
for (const raw of rawArguments) {
|
||||
|
|
|
|||
140
gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts
Normal file
140
gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* One argument of a Spring annotation or of a messaging-template call, captured
|
||||
* exactly as it is written in source.
|
||||
*
|
||||
* Capture-time facts are deliberately UNRESOLVED. When these facts are produced
|
||||
* the file's imports are not finalized, constants declared in sibling files do
|
||||
* not exist yet, and no configuration source has been read — so a captured
|
||||
* `text` may be a string literal, a constant reference (`Destinations.ORDERS`),
|
||||
* a property placeholder (`"${app.orders.topic}"`), or an arbitrary expression.
|
||||
* Turning any of those into an address is a separate, later phase; nothing here
|
||||
* may call a resolver.
|
||||
*
|
||||
* NOT the same thing as `SpringAnnotationArgument` in `annotation-arguments.ts`,
|
||||
* and the two are deliberately not merged:
|
||||
*
|
||||
* - Source. This fact is built from AST nodes while the tree is in hand;
|
||||
* `parseSpringAnnotationArguments` re-parses an annotation's `text` much
|
||||
* later, from a string, with a hand-written delimiter scanner.
|
||||
* - Failure. The text parser returns `null` when its scanner cannot balance
|
||||
* the input, and a caller must decide what that means. There is no such
|
||||
* state here: the grammar has already decided where each argument begins
|
||||
* and ends.
|
||||
* - Absence. The text parser answers `[]` both for `@Scheduled` and for
|
||||
* `@Scheduled()`, because a string cannot tell "no list" from "empty list"
|
||||
* without re-deriving it. Capture keeps the two apart — absent versus `[]` —
|
||||
* so downstream code can rely on the distinction wherever arguments were
|
||||
* read at all. A capture that reads them for only some of its facts says so
|
||||
* on its own `args` field.
|
||||
* - Scope. This fact also describes CALL arguments (`template.send(topic, p)`),
|
||||
* which the annotation parser has no notion of.
|
||||
*
|
||||
* Collapsing them would mean giving the text parser a failure mode it cannot
|
||||
* produce, or taking the three-state distinction away from capture.
|
||||
*/
|
||||
export interface SpringArgumentFact {
|
||||
/**
|
||||
* Argument name for a named argument, absent for a positional one.
|
||||
*
|
||||
* Both forms occur, and where the destination sits differs by construct. An
|
||||
* annotation names it (`@KafkaListener(topics = ...)` versus
|
||||
* `@RabbitListener(queues = ...)`). A call normally gives it by position
|
||||
* (`kafkaTemplate.send(topic, payload)`) — always so in Java, which has no
|
||||
* named arguments — but a Kotlin call may name its arguments whenever the
|
||||
* callee is itself declared in Kotlin, and then the key is captured too.
|
||||
*/
|
||||
readonly name?: string;
|
||||
/**
|
||||
* Argument value in its source spelling — quotes, braces and casts intact,
|
||||
* nothing resolved — after `normalizeSpringFactText`. That pass trims the
|
||||
* text and collapses whitespace around the dots of a multi-line expression,
|
||||
* so one destination written two ways yields one fact. It is the only
|
||||
* rewrite; see the function for why formatting must not reach the data.
|
||||
*/
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an expression that the source wrapped across lines, so that one
|
||||
* expression has one spelling no matter where it was written.
|
||||
*
|
||||
* A receiver chain written as `outer\n .inner\n .kafkaTemplate`, and an
|
||||
* argument written as `Destinations\n .ORDERS`, are the same expressions as
|
||||
* their single-line spellings. Raw node text would carry the newline and the
|
||||
* ENCLOSING BLOCK's indentation across the worker boundary, so the same
|
||||
* expression at two nesting depths — or in a CRLF checkout — would not compare
|
||||
* equal downstream. Receivers and arguments get the identical treatment on
|
||||
* purpose: an inconsistent rule inside one fact is a trap for the phase that
|
||||
* has to match a publish against a subscription.
|
||||
*
|
||||
* Only a run of whitespace that CONTAINS A NEWLINE and sits next to a dot is
|
||||
* removed, and only OUTSIDE a string literal. Single-line spacing is left
|
||||
* alone, so `registry.get("a . b").template` keeps its argument exactly as
|
||||
* written; literal-awareness extends that to Java text blocks and Kotlin raw
|
||||
* strings, whose embedded newlines are part of the value and must survive
|
||||
* (`"""line-a\n.line-b"""` is not the same string as `"""line-a.line-b"""`).
|
||||
*
|
||||
* Wraps that are not adjacent to a dot (`"a" +\n "b"`) are left as written:
|
||||
* normalizing them would have to reason about operators, and the same
|
||||
* conservatism already applies to receivers.
|
||||
*/
|
||||
export function normalizeSpringFactText(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
// Fast path: the overwhelming majority of captured text is single-line.
|
||||
if (!trimmed.includes('\n') && !trimmed.includes('\r')) return trimmed;
|
||||
|
||||
let out = '';
|
||||
let index = 0;
|
||||
let quote: '"""' | '"' | "'" | null = null;
|
||||
while (index < trimmed.length) {
|
||||
const char = trimmed[index] as string;
|
||||
if (quote === '"""') {
|
||||
if (trimmed.startsWith('"""', index)) {
|
||||
out += '"""';
|
||||
index += 3;
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
out += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote !== null) {
|
||||
// A backslash escape is copied whole so that `"\\"` ends the literal and
|
||||
// `"\""` does not.
|
||||
if (char === '\\' && index + 1 < trimmed.length) {
|
||||
out += trimmed.slice(index, index + 2);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) quote = null;
|
||||
out += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('"""', index)) {
|
||||
quote = '"""';
|
||||
out += '"""';
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
out += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '.' || /\s/.test(char)) {
|
||||
const separator = /^\s*\.\s*/.exec(trimmed.slice(index));
|
||||
if (separator !== null) {
|
||||
const matched = separator[0];
|
||||
out += matched.includes('\n') ? '.' : matched;
|
||||
index += matched.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += char;
|
||||
index += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
|
|||
import { generateId } from '../../../../lib/utils.js';
|
||||
|
||||
export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property';
|
||||
export const SPRING_CONFIG_UNRESOLVED_PREFIX = 'Spring config unresolved: ';
|
||||
|
||||
export interface SpringValueConsumer {
|
||||
readonly kind: 'value';
|
||||
|
|
@ -41,7 +42,7 @@ function closestNode(
|
|||
}
|
||||
|
||||
function markUnresolved(node: GraphNode, key: string): void {
|
||||
const marker = `Spring config unresolved: ${key}`;
|
||||
const marker = `${SPRING_CONFIG_UNRESOLVED_PREFIX}${key}`;
|
||||
const existing =
|
||||
typeof node.properties.description === 'string' ? node.properties.description : '';
|
||||
if (existing.includes(marker)) return;
|
||||
|
|
|
|||
1216
gitnexus/src/core/ingestion/frameworks/spring/destinations.ts
Normal file
1216
gitnexus/src/core/ingestion/frameworks/spring/destinations.ts
Normal file
File diff suppressed because it is too large
Load diff
177
gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts
Normal file
177
gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type { ParsedFile, Range, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { DiInjectionMatch } from '../../di-extractors/index.js';
|
||||
import { SPRING_DI_INJECTION_SITES_PROPERTY } from '../../di-extractors/spring.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import {
|
||||
resolveCallerGraphId,
|
||||
resolveDefGraphId,
|
||||
} from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { isClassLike, lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
const COLLECTION_LOOKUP_METHODS = new Set(['getBeans', 'getBeansOfType']);
|
||||
const SINGLE_LOOKUP_METHODS = new Set(['getBean']);
|
||||
|
||||
/**
|
||||
* Distinctive utility names plus conventional Spring context variable names.
|
||||
* Generic locals remain recall-oriented because repositories often omit the
|
||||
* third-party context type from the index; AST call/class-literal gates and
|
||||
* import-aware target resolution prevent the raw-text false-positive class.
|
||||
*/
|
||||
const KNOWN_RECEIVERS = new Set([
|
||||
'SpringContextUtil',
|
||||
'SpringContextHolder',
|
||||
'SpringBeanUtil',
|
||||
'ApplicationContextProvider',
|
||||
'BeanFactoryProvider',
|
||||
'ApplicationContext',
|
||||
'BeanFactory',
|
||||
'ListableBeanFactory',
|
||||
'applicationContext',
|
||||
'context',
|
||||
'ctx',
|
||||
'appContext',
|
||||
'beanFactory',
|
||||
]);
|
||||
|
||||
export interface SpringDynamicLookupFact {
|
||||
readonly ownerScopeId: ScopeId;
|
||||
readonly ownerRange: Range;
|
||||
readonly receiverName: string;
|
||||
readonly methodName: string;
|
||||
readonly targetTypeName: string;
|
||||
}
|
||||
|
||||
export function springDynamicLookupCardinality(
|
||||
receiverName: string,
|
||||
methodName: string,
|
||||
): DiInjectionMatch['cardinality'] | null {
|
||||
const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1);
|
||||
if (!KNOWN_RECEIVERS.has(receiverSimpleName)) return null;
|
||||
if (COLLECTION_LOOKUP_METHODS.has(methodName)) return 'collection';
|
||||
if (SINGLE_LOOKUP_METHODS.has(methodName)) return 'single';
|
||||
return null;
|
||||
}
|
||||
|
||||
function visibleTypeDefinitions(
|
||||
fact: SpringDynamicLookupFact,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): readonly SymbolDefinition[] {
|
||||
const simpleName = fact.targetTypeName.slice(fact.targetTypeName.lastIndexOf('.') + 1);
|
||||
let scopeId: ScopeId | null = fact.ownerScopeId;
|
||||
|
||||
while (scopeId !== null) {
|
||||
const visible = lookupBindingsAt(scopeId, simpleName, indexes)
|
||||
.map(({ def }) => def)
|
||||
.filter((def) => isClassLike(def.type))
|
||||
.filter(
|
||||
(def) => !fact.targetTypeName.includes('.') || def.qualifiedName === fact.targetTypeName,
|
||||
);
|
||||
if (visible.length > 0) {
|
||||
const unique = new Map(visible.map((def) => [def.nodeId, def]));
|
||||
return [...unique.values()];
|
||||
}
|
||||
scopeId = indexes.scopeTree.getScope(scopeId)?.parent ?? null;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveTargetTypeName(
|
||||
graph: KnowledgeGraph,
|
||||
fact: SpringDynamicLookupFact,
|
||||
callerLanguage: string | undefined,
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): string | undefined {
|
||||
const graphIds = new Set<string>();
|
||||
for (const definition of visibleTypeDefinitions(fact, indexes)) {
|
||||
const graphId = resolveDefGraphId(definition.filePath, definition, nodeLookup);
|
||||
if (graphId === undefined) continue;
|
||||
const node = graph.getNode(graphId);
|
||||
if (
|
||||
(node?.label === 'Class' ||
|
||||
node?.label === 'Interface' ||
|
||||
node?.label === 'Record' ||
|
||||
node?.label === 'Enum') &&
|
||||
node.properties.language === callerLanguage
|
||||
) {
|
||||
graphIds.add(graphId);
|
||||
}
|
||||
}
|
||||
if (graphIds.size !== 1) return undefined;
|
||||
|
||||
const targetId = graphIds.values().next().value;
|
||||
if (targetId === undefined) return undefined;
|
||||
const target = graph.getNode(targetId);
|
||||
if (target === undefined) return undefined;
|
||||
const qualifiedName = target.properties.qualifiedName;
|
||||
return typeof qualifiedName === 'string' ? qualifiedName : target.properties.name;
|
||||
}
|
||||
|
||||
export interface SpringDynamicLookupMetadataAdapter {
|
||||
getFacts(filePath: string): readonly SpringDynamicLookupFact[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach AST-captured programmatic Spring lookups to the framework-neutral DI
|
||||
* resolver. Java/Kotlin own syntax capture; this shared JVM/Spring seam owns
|
||||
* import-aware type binding and metadata attachment.
|
||||
*/
|
||||
export function createSpringDynamicLookupMetadataAttacher(
|
||||
adapter: SpringDynamicLookupMetadataAdapter,
|
||||
) {
|
||||
return (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void => {
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const fact of adapter.getFacts(parsed.filePath)) {
|
||||
const cardinality = springDynamicLookupCardinality(fact.receiverName, fact.methodName);
|
||||
if (cardinality === null) continue;
|
||||
|
||||
const callerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup, {
|
||||
startLine: fact.ownerRange.startLine,
|
||||
startCol: fact.ownerRange.startCol,
|
||||
});
|
||||
if (callerId === undefined) continue;
|
||||
const caller = graph.getNode(callerId);
|
||||
if (
|
||||
caller === undefined ||
|
||||
(caller.label !== 'Function' &&
|
||||
caller.label !== 'Method' &&
|
||||
caller.label !== 'Constructor')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetTypeName = resolveTargetTypeName(
|
||||
graph,
|
||||
fact,
|
||||
caller.properties.language,
|
||||
nodeLookup,
|
||||
indexes,
|
||||
);
|
||||
if (targetTypeName === undefined) continue;
|
||||
|
||||
const match: DiInjectionMatch = {
|
||||
targetTypeName,
|
||||
cardinality,
|
||||
edgeSource: 'site',
|
||||
reason: `Spring dynamic lookup: ${fact.receiverName}.${fact.methodName}(${fact.targetTypeName})`,
|
||||
};
|
||||
// Singular lookups intentionally use the shared DI selection policy:
|
||||
// a unique/@Primary candidate wins; unresolved multiplicity is an
|
||||
// explicit 0.5-confidence fan-out rather than a guessed runtime winner.
|
||||
const existing = caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
|
||||
caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = [
|
||||
...(Array.isArray(existing) ? existing : []),
|
||||
match,
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import type { Range, ScopeId } from 'gitnexus-shared';
|
||||
import type { SpringArgumentFact } from './argument-facts.js';
|
||||
|
||||
/**
|
||||
* Outbound side of Spring messaging: the template calls that publish to a
|
||||
* broker destination, mirroring the inbound `@KafkaListener` / `@RabbitListener`
|
||||
* family already recognized in `non-http-handlers.ts`.
|
||||
*
|
||||
* Recognition is purely syntactic and happens while the language's own scope
|
||||
* query already has the call node in hand. The receiver's declared type is NOT
|
||||
* consulted: at capture time the field may be inherited, injected from another
|
||||
* file, or typed through an import that is not finalized yet. Matching on the
|
||||
* receiver's simple name instead keeps the capture cheap and resolver-free; a
|
||||
* later phase that owns type information can refine or discard a fact.
|
||||
*/
|
||||
export type SpringMessageProducerTemplate = 'kafka' | 'rabbit' | 'jms' | 'stream-bridge';
|
||||
|
||||
interface ProducerSignature {
|
||||
readonly template: SpringMessageProducerTemplate;
|
||||
/**
|
||||
* Simple type name of the template bean, matched case-insensitively as a
|
||||
* SUBSTRING of the receiver's folded simple name. The classifier below states
|
||||
* which decorations that accepts, and what it does when one receiver name
|
||||
* contains the type names of two different templates.
|
||||
*/
|
||||
readonly typeName: string;
|
||||
readonly methodName: string;
|
||||
}
|
||||
|
||||
const PRODUCER_SIGNATURES: readonly ProducerSignature[] = [
|
||||
{ template: 'kafka', typeName: 'KafkaTemplate', methodName: 'send' },
|
||||
{ template: 'rabbit', typeName: 'RabbitTemplate', methodName: 'convertAndSend' },
|
||||
{ template: 'jms', typeName: 'JmsTemplate', methodName: 'convertAndSend' },
|
||||
{ template: 'stream-bridge', typeName: 'StreamBridge', methodName: 'send' },
|
||||
];
|
||||
|
||||
const PRODUCER_METHOD_NAMES: ReadonlySet<string> = new Set(
|
||||
PRODUCER_SIGNATURES.map((signature) => signature.methodName),
|
||||
);
|
||||
|
||||
/** A receiver we can attribute; `templates["k"]` or `getTemplate()` cannot be. */
|
||||
const PLAIN_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
/**
|
||||
* Fold a receiver's simple name to the form the type-name match runs against.
|
||||
*
|
||||
* `_` and `$` are word separators in the spellings this has to accept, not part
|
||||
* of the words: `KAFKA_TEMPLATE` and `kafka_template` are the same bean name as
|
||||
* `kafkaTemplate`, written to the constant and snake conventions. Digits stay,
|
||||
* because they are part of a name (`kafkaTemplate2`), never a separator.
|
||||
*/
|
||||
function foldReceiverName(receiverSimpleName: string): string {
|
||||
return receiverSimpleName.replace(/[_$]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pre-filter usable before any receiver text is materialized. Both
|
||||
* languages visit every member call, so the common case must cost one set
|
||||
* lookup on the method name.
|
||||
*/
|
||||
export function isSpringMessageProducerMethod(methodName: string): boolean {
|
||||
return PRODUCER_METHOD_NAMES.has(methodName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a `receiver.method(...)` call as a messaging producer, or `null`.
|
||||
*
|
||||
* `receiverName` is the receiver expression as written; only its last
|
||||
* dot-separated segment participates, so `this.kafkaTemplate` and
|
||||
* `outer.inner.kafkaTemplate` match while `templates.get("k")` does not.
|
||||
*
|
||||
* The PLAIN_IDENTIFIER gate runs BEFORE the fold and is load-bearing, because
|
||||
* the last-dot split is textual: in `config.get("a.kafkaTemplate")` it yields
|
||||
* `kafkaTemplate")`, which folds to something a name match would accept. Only
|
||||
* an identifier survives the gate, which is also what rejects `templates["k"]`,
|
||||
* `getTemplate()`, and a receiver whose dot is separated by a comment.
|
||||
*
|
||||
* The folded segment then matches case-insensitively when it CONTAINS the
|
||||
* template type name, so every convention a template bean is really declared
|
||||
* with is recognized — decorated by prefix (`orderKafkaTemplate`), by suffix
|
||||
* (`kafkaTemplateDlq`, `kafkaTemplateV2`, `rabbitTemplate1`), or written as a
|
||||
* constant (`KAFKA_TEMPLATE`, `STREAM_BRIDGE`). A suffix-only rule accepted
|
||||
* one of those and silently dropped the rest, which are exactly the publishes
|
||||
* this capture exists to find. A receiver named only `template` still does not
|
||||
* match: without type information that would attribute any `send` in the
|
||||
* repository to Kafka.
|
||||
*
|
||||
* The bare type name (`KafkaTemplate.send(...)`) contains itself and so is
|
||||
* accepted. That is left as it is: the match is by NAME, a name equal to the
|
||||
* type is the strongest evidence the rule has, and a later phase that owns type
|
||||
* information can discard a static-looking receiver.
|
||||
*
|
||||
* A substring rule also lets ONE receiver satisfy TWO signatures, which a
|
||||
* suffix rule could not: `KafkaTemplate` and `StreamBridge` both publish
|
||||
* through `send`, and `RabbitTemplate` and `JmsTemplate` both through
|
||||
* `convertAndSend`, so `streamBridgeKafkaTemplate.send(...)` matches two
|
||||
* templates at once. Such a receiver yields NO fact. Nothing here can break the
|
||||
* tie honestly: the receiver's TYPE is deliberately not resolved, and the name
|
||||
* is not ranked evidence — neither the longest match, nor the last one, nor the
|
||||
* order of this list says whether that bean is a KafkaTemplate fronted by a
|
||||
* stream binding or a StreamBridge named after the broker behind it. Returning
|
||||
* the first match published an arbitrary choice as a definite broker
|
||||
* attribution, the one outcome a consumer cannot tell from a fact. Silence
|
||||
* costs a rare publish and stays recoverable by a phase that owns types.
|
||||
*/
|
||||
export function springMessageProducerTemplateOf(
|
||||
receiverName: string,
|
||||
methodName: string,
|
||||
): SpringMessageProducerTemplate | null {
|
||||
if (!isSpringMessageProducerMethod(methodName)) return null;
|
||||
const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1).trim();
|
||||
if (!PLAIN_IDENTIFIER.test(receiverSimpleName)) return null;
|
||||
const folded = foldReceiverName(receiverSimpleName);
|
||||
let matched: SpringMessageProducerTemplate | null = null;
|
||||
for (const signature of PRODUCER_SIGNATURES) {
|
||||
if (signature.methodName !== methodName) continue;
|
||||
if (!folded.includes(signature.typeName.toLowerCase())) continue;
|
||||
// A second match makes the receiver ambiguous; see above for why it is not
|
||||
// resolved by preferring one of them.
|
||||
if (matched !== null) return null;
|
||||
matched = signature.template;
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export interface SpringMessageProducerFact {
|
||||
/** Callable that performs the publish; the enclosing method or function. */
|
||||
readonly ownerScopeId: ScopeId;
|
||||
readonly ownerRange: Range;
|
||||
readonly template: SpringMessageProducerTemplate;
|
||||
/** Receiver expression as written, for example `this.orderKafkaTemplate`. */
|
||||
readonly receiverName: string;
|
||||
readonly methodName: string;
|
||||
/**
|
||||
* Call arguments in source order, or absent when the call site has no
|
||||
* argument list at all (a Kotlin trailing-lambda call). An empty array means
|
||||
* an empty argument list was written — a different fact from no list.
|
||||
*/
|
||||
readonly args?: readonly SpringArgumentFact[];
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
|
|||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveCallerGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { SpringArgumentFact } from './argument-facts.js';
|
||||
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
|
||||
import { SPRING_BEAN_ANNOTATION } from './bean-factories.js';
|
||||
|
||||
|
|
@ -14,6 +15,34 @@ export interface SpringNonHttpHandlerAnnotationFact {
|
|||
readonly name: string;
|
||||
/** Kotlin use-site targets describe generated/property elements, not the callable. */
|
||||
readonly useSiteTarget?: string;
|
||||
/**
|
||||
* Annotation arguments in source order. An empty array always means an empty
|
||||
* list was written (`@Scheduled()`), which is a different fact from absence —
|
||||
* but absence has TWO causes, and only one of them is a statement about the
|
||||
* source. Either the annotation was written without an argument list
|
||||
* (`@Scheduled`), or arguments were never read for this callable.
|
||||
*
|
||||
* They are read only for a callable that carries a handler annotation. Java
|
||||
* produces facts for no other callable, so there absence does mean "no list
|
||||
* was written". Kotlin also produces a fact for a merely annotated function —
|
||||
* it captures those without a name prefilter so an import alias cannot hide a
|
||||
* handler — and on those facts arguments are absent however the annotation
|
||||
* was written.
|
||||
*
|
||||
* The values keep their source spelling, with one deliberate exception:
|
||||
* `normalizeSpringFactText` trims them and collapses whitespace around the
|
||||
* dots of a multi-line expression, so `Destinations.ORDERS` and the same
|
||||
* reference wrapped across lines produce equal facts. Without that, source
|
||||
* formatting — including the enclosing block's indentation, which is not a
|
||||
* property of the expression at all — would leak into the data and make two
|
||||
* spellings of one destination compare unequal downstream.
|
||||
*
|
||||
* Nothing else is touched. `@KafkaListener(topics = ...)` and
|
||||
* `@RabbitListener(queues = ...)` name the destination differently, and a
|
||||
* destination may be a literal, a constant reference, or a `${...}`
|
||||
* placeholder; resolving any of those belongs to a later phase.
|
||||
*/
|
||||
readonly args?: readonly SpringArgumentFact[];
|
||||
}
|
||||
|
||||
export interface SpringNonHttpHandlerFact<
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import type { VariableExtractor } from './variable-types.js';
|
|||
import type { ImportResolverFn } from './import-resolvers/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import type { CfgVisitor } from './cfg/types.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
||||
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
||||
import type {
|
||||
|
|
@ -46,6 +46,16 @@ import type {
|
|||
} from './route-extractors/constant-resolver.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
import type { SpringNonHttpHandlerFact } from './frameworks/spring/non-http-handlers.js';
|
||||
import type { SpringMessageProducerFact } from './frameworks/spring/message-producers.js';
|
||||
|
||||
/** One file's captured Spring async messaging facts, in both directions. */
|
||||
export interface SpringMessagingFacts {
|
||||
/** Callables carrying a listener annotation — the inbound side. */
|
||||
readonly handlers: readonly SpringNonHttpHandlerFact[];
|
||||
/** Messaging-template publishes — the outbound side. */
|
||||
readonly producers: readonly SpringMessageProducerFact[];
|
||||
}
|
||||
|
||||
// ── Shared type aliases ────────────────────────────────────────────────────
|
||||
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
||||
|
|
@ -54,6 +64,7 @@ export type CaptureMap = Record<string, SyntaxNode | undefined>;
|
|||
export interface DefinitionPropertiesContext {
|
||||
readonly nodeLabel: NodeLabel;
|
||||
readonly nodeName: string;
|
||||
readonly filePath: string;
|
||||
readonly definitionNode: SyntaxNode;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
readonly isExported: boolean;
|
||||
|
|
@ -63,6 +74,26 @@ export type DefinitionPropertiesExtractor = (
|
|||
context: DefinitionPropertiesContext,
|
||||
) => Readonly<Record<string, unknown>> | undefined;
|
||||
|
||||
export interface RuntimeCallableIdentity {
|
||||
readonly name: string;
|
||||
readonly descriptorParameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional language-owned bridge from runtime/compiler symbol identities to
|
||||
* source graph symbols. Framework importers use this instead of naming
|
||||
* languages or reproducing compiler conventions in shared ingestion code.
|
||||
*/
|
||||
export interface RuntimeSymbolStrategy {
|
||||
/** Runtime owner names that may contain this callable/property. */
|
||||
readonly callableOwnerAliases?: (
|
||||
node: GraphNode,
|
||||
owner: GraphNode | undefined,
|
||||
) => readonly string[];
|
||||
/** Whether a runtime callable identity can conservatively identify a node. */
|
||||
readonly matchesCallable: (node: GraphNode, runtime: RuntimeCallableIdentity) => boolean;
|
||||
}
|
||||
|
||||
/** Run optional provider enrichment without allowing one hook failure to drop
|
||||
* the rest of the worker's language batch. */
|
||||
export function runDefinitionPropertiesExtractor(
|
||||
|
|
@ -192,6 +223,13 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly preprocessSource?: (sourceText: string, filePath: string) => string;
|
||||
|
||||
/**
|
||||
* Runtime/compiler identity reconciliation for framework metadata. The
|
||||
* central importer owns ambiguity handling; providers only supply aliases
|
||||
* and language-specific callable compatibility.
|
||||
*/
|
||||
readonly runtimeSymbolStrategy?: RuntimeSymbolStrategy;
|
||||
|
||||
// ── Core (required) ───────────────────────────────────────────────
|
||||
/** Type extraction: declarations, initializers, for-loop bindings */
|
||||
readonly typeConfig: LanguageTypeConfig;
|
||||
|
|
@ -385,6 +423,28 @@ interface LanguageProviderConfig {
|
|||
lineOffset: number,
|
||||
) => ExtractedDecoratorRoute[];
|
||||
|
||||
/**
|
||||
* Name of the function a route decorator captured by the worker's generic
|
||||
* `@decorator` query applies to, given the decorator's own AST node.
|
||||
*
|
||||
* The worker knows a decorator is a route decorator but not how this
|
||||
* language's grammar attaches it to a definition, so it hands the node over
|
||||
* unchanged and takes whatever the language returns. Only languages that
|
||||
* declare route handlers through the generic decorator captures need this;
|
||||
* languages with a dedicated {@link extractDecoratorRoutes} extractor
|
||||
* (JS/TS via `nest.ts`, Java via `spring.ts`) already set
|
||||
* `ExtractedDecoratorRoute.handlerName` there and should leave this undefined.
|
||||
*
|
||||
* Implementations must read their own decorated-definition shape directly and
|
||||
* return undefined for anything else — never climb ancestors to find a name,
|
||||
* since a decorator that is not attached to a function has no handler and a
|
||||
* borrowed enclosing name resolves `handlerSymbolId` to the wrong symbol. The
|
||||
* routes phase treats undefined as "fall back to the file-level edge".
|
||||
*
|
||||
* Default: undefined (no handler name from generic decorator captures).
|
||||
*/
|
||||
readonly decoratorRouteHandlerName?: (decoratorNode: SyntaxNode) => string | undefined;
|
||||
|
||||
/**
|
||||
* Collect a project-wide, language-agnostic view of route-defining
|
||||
* class/interface declarations (`SharedSpringType`) from a parsed file.
|
||||
|
|
@ -402,6 +462,54 @@ interface LanguageProviderConfig {
|
|||
filePath: string,
|
||||
) => SharedSpringType[];
|
||||
|
||||
/**
|
||||
* Optional post-capture emission of synthetic structure members (nodes,
|
||||
* symbols, ownership edges) that have no AST method node — e.g. Lombok
|
||||
* accessors. Called once per file after the capture loop, at the same
|
||||
* post-capture site as {@link extractDecoratorRoutes}.
|
||||
*
|
||||
* `classOwnersByNodeId` maps in-memory tree-sitter node ids of type
|
||||
* declarations materialized in THIS file's capture loop to their graph
|
||||
* node ids. Keys are never persisted; they exist only for the duration
|
||||
* of the worker pass.
|
||||
*
|
||||
* Default: undefined (no synthetic structure members).
|
||||
*/
|
||||
readonly synthesizeStructureMembers?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersByNodeId: ReadonlyMap<number, string>,
|
||||
) => {
|
||||
nodes: ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
properties: Record<string, unknown>;
|
||||
}>;
|
||||
symbols: ReadonlyArray<{
|
||||
filePath: string;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
type: string;
|
||||
ownerId?: string;
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
returnType?: string;
|
||||
visibility?: string;
|
||||
isStatic?: boolean;
|
||||
isAbstract?: boolean;
|
||||
isFinal?: boolean;
|
||||
}>;
|
||||
relationships: ReadonlyArray<{
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Harvest this file's module-level string constants (#2391 core, #2980 Java
|
||||
* parity) into the language-agnostic {@link ModuleConstants} shape, so the
|
||||
|
|
@ -436,6 +544,50 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly moduleConstantHeuristic?: (content: string) => boolean;
|
||||
|
||||
/**
|
||||
* Prepare this language's harvested constants once the complete repo map is
|
||||
* available and before route operands are folded. The parse phase passes only
|
||||
* entries owned by this provider, so implementations can build one reusable
|
||||
* language-specific index and may materialize deferred bindings in place.
|
||||
*
|
||||
* Default: undefined (the harvested constants are already fold-ready).
|
||||
*/
|
||||
readonly prepareRouteConstants?: (repo: RepoConstants) => void;
|
||||
|
||||
/**
|
||||
* Spring async messaging facts captured for one file — the listener
|
||||
* annotations that subscribe to a broker destination and the template calls
|
||||
* that publish to one.
|
||||
*
|
||||
* Both families are collected during capture and restored on the main thread
|
||||
* by {@link LanguageProviderConfig.applyCaptureSideChannel}, so they are only
|
||||
* readable AFTER scope resolution has run. The `springDestinations` phase is
|
||||
* the caller; routing through a provider hook is what keeps that phase from
|
||||
* naming a language to reach a per-language fact store.
|
||||
*
|
||||
* Default: undefined — this language captures no Spring messaging facts, and
|
||||
* the phase contributes nothing for its files.
|
||||
*/
|
||||
readonly getSpringMessagingFacts?: (filePath: string) => SpringMessagingFacts;
|
||||
|
||||
/**
|
||||
* Whether this language INTERPOLATES its string literals — Kotlin's
|
||||
* `"orders-$env"` and `"orders-${env}"` are string templates evaluated at
|
||||
* runtime, while Java's are ordinary characters.
|
||||
*
|
||||
* A capability rather than a language name, because shared ingestion code may
|
||||
* not branch on a language (see AGENTS.md) and because the capability is what
|
||||
* the consumer actually needs. Spring destination resolution is the caller:
|
||||
* in an interpolating language an unescaped `$` in a destination literal is a
|
||||
* runtime value and must be refused, and `"${app.topic}"` is a TEMPLATE, not
|
||||
* a Spring property placeholder — the placeholder has to be written
|
||||
* `"\${app.topic}"` there. Reading either as an address gives two unrelated
|
||||
* services one shared destination node.
|
||||
*
|
||||
* Default: false — literals are literal, `$` is a character.
|
||||
*/
|
||||
readonly interpolatesStringLiterals?: boolean;
|
||||
|
||||
/**
|
||||
* Fold one file's non-literal route-path operand list
|
||||
* (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`)
|
||||
|
|
@ -854,6 +1006,34 @@ export interface LanguageProvider extends Omit<LanguageProviderConfig, 'mroStrat
|
|||
readonly isBuiltInName: (name: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run each provider's repo-constant preparation hook once over only the files
|
||||
* that provider owns. Values are shared with `repo`, so in-place preparation
|
||||
* is visible to the subsequent fold without copying the complete map.
|
||||
*/
|
||||
export function prepareRouteConstantsByProvider(
|
||||
repo: RepoConstants,
|
||||
providerForFile: (filePath: string) => Pick<LanguageProvider, 'prepareRouteConstants'> | null,
|
||||
): void {
|
||||
const slices = new Map<
|
||||
Pick<LanguageProvider, 'prepareRouteConstants'>,
|
||||
Map<string, ModuleConstants>
|
||||
>();
|
||||
for (const [filePath, constants] of repo) {
|
||||
const provider = providerForFile(filePath);
|
||||
if (!provider?.prepareRouteConstants) continue;
|
||||
let slice = slices.get(provider);
|
||||
if (!slice) {
|
||||
slice = new Map();
|
||||
slices.set(provider, slice);
|
||||
}
|
||||
slice.set(filePath, constants);
|
||||
}
|
||||
for (const [provider, slice] of slices) {
|
||||
provider.prepareRouteConstants?.(slice);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULTS: Pick<LanguageProvider, 'mroStrategy'> = {
|
||||
mroStrategy: 'first-wins',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,955 @@
|
|||
/**
|
||||
* ASP.NET Core ViewComponent convention support.
|
||||
*
|
||||
* Same bound as Spring Boot DI in Java/Kotlin: do not resolve into the SDK
|
||||
* (`Microsoft.AspNetCore.Mvc.ViewComponent`, `IViewComponentHelper`,
|
||||
* `Component.InvokeAsync` itself). Those types live outside the workspace.
|
||||
* The only hop worth taking is the framework convention that lands on an
|
||||
* **in-repo** class — `InvokeAsync("Foo")` → workspace `FooViewComponent`,
|
||||
* just as a Spring `@Autowired IFoo` fans out to an in-repo `@Service`,
|
||||
* not to `ApplicationContext`.
|
||||
*
|
||||
* Razor templates are not parsed as C# (markup + code would poison
|
||||
* tree-sitter-c-sharp). A small Razor state machine extracts C# islands and
|
||||
* markup tag helpers; C# files use a string/comment-aware lexer so attributes
|
||||
* and literals are not mistaken for helper calls. Literal names are enough
|
||||
* because the target catalog is already built from parsed `.cs` classes.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { createIgnoreFilter } from '../../../../config/ignore-service.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { getMaxFileSizeBytes } from '../../utils/max-file-size.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js';
|
||||
|
||||
const VIEW_COMPONENT_SUFFIX = 'ViewComponent';
|
||||
const VIEW_COMPONENT_TAG_RE = /<\s*vc:([a-z][a-z0-9-]*)\b/gi;
|
||||
const COMPONENT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
||||
const TYPE_MODIFIERS = new Set([
|
||||
'public',
|
||||
'internal',
|
||||
'protected',
|
||||
'private',
|
||||
'abstract',
|
||||
'sealed',
|
||||
'partial',
|
||||
'static',
|
||||
'new',
|
||||
'file',
|
||||
'required',
|
||||
'unsafe',
|
||||
'readonly',
|
||||
]);
|
||||
const RAZOR_BLOCK_KEYWORDS = new Set([
|
||||
'if',
|
||||
'for',
|
||||
'foreach',
|
||||
'while',
|
||||
'using',
|
||||
'switch',
|
||||
'try',
|
||||
'lock',
|
||||
'functions',
|
||||
'helper',
|
||||
'code',
|
||||
'section',
|
||||
'do',
|
||||
]);
|
||||
|
||||
export interface RazorViewComponentConfig {
|
||||
/** Repo-relative `.cshtml` path → extracted invocation names. */
|
||||
readonly views: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
export interface ViewComponentAliasBind {
|
||||
readonly className: string;
|
||||
/** 1-based line of the type declaration (including leading attributes). */
|
||||
readonly startLine: number;
|
||||
/** 0-based column of the type declaration (including leading attributes). */
|
||||
readonly startCol: number;
|
||||
readonly aliases: readonly string[];
|
||||
}
|
||||
|
||||
class SourceCursor {
|
||||
i = 0;
|
||||
line = 1;
|
||||
col = 0;
|
||||
|
||||
constructor(readonly source: string) {}
|
||||
|
||||
get length(): number {
|
||||
return this.source.length;
|
||||
}
|
||||
|
||||
get done(): boolean {
|
||||
return this.i >= this.source.length;
|
||||
}
|
||||
|
||||
peek(n = 0): string {
|
||||
return this.source[this.i + n] ?? '';
|
||||
}
|
||||
|
||||
startsWith(value: string): boolean {
|
||||
return this.source.startsWith(value, this.i);
|
||||
}
|
||||
|
||||
snapshot(): { i: number; line: number; col: number } {
|
||||
return { i: this.i, line: this.line, col: this.col };
|
||||
}
|
||||
|
||||
restore(pos: { i: number; line: number; col: number }): void {
|
||||
this.i = pos.i;
|
||||
this.line = pos.line;
|
||||
this.col = pos.col;
|
||||
}
|
||||
|
||||
advance(count = 1): void {
|
||||
const end = Math.min(this.i + count, this.source.length);
|
||||
while (this.i < end) {
|
||||
const ch = this.source[this.i]!;
|
||||
this.i += 1;
|
||||
if (ch === '\n') {
|
||||
this.line += 1;
|
||||
this.col = 0;
|
||||
} else {
|
||||
this.col += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isIdentStart(ch: string): boolean {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_' || ch === '@';
|
||||
}
|
||||
|
||||
function isIdentPart(ch: string): boolean {
|
||||
return isIdentStart(ch) || (ch >= '0' && ch <= '9');
|
||||
}
|
||||
|
||||
function skipWhitespace(cur: SourceCursor): void {
|
||||
while (!cur.done) {
|
||||
const ch = cur.peek();
|
||||
if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r' && ch !== '\f' && ch !== '\v')
|
||||
break;
|
||||
cur.advance();
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip line comments and block comments. Returns true if a comment was consumed. */
|
||||
function skipCsharpComment(cur: SourceCursor): boolean {
|
||||
if (cur.startsWith('//')) {
|
||||
while (!cur.done && cur.peek() !== '\n') cur.advance();
|
||||
return true;
|
||||
}
|
||||
if (cur.startsWith('/*')) {
|
||||
cur.advance(2);
|
||||
while (!cur.done && !cur.startsWith('*/')) cur.advance();
|
||||
if (cur.startsWith('*/')) cur.advance(2);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function skipCsharpTrivia(cur: SourceCursor): void {
|
||||
for (;;) {
|
||||
skipWhitespace(cur);
|
||||
if (!skipCsharpComment(cur)) return;
|
||||
}
|
||||
}
|
||||
|
||||
function skipRegularString(cur: SourceCursor, interpolated: boolean): void {
|
||||
cur.advance(); // opening "
|
||||
while (!cur.done) {
|
||||
const ch = cur.peek();
|
||||
if (ch === '\\') {
|
||||
cur.advance(2);
|
||||
continue;
|
||||
}
|
||||
if (interpolated && ch === '{') {
|
||||
if (cur.peek(1) === '{') {
|
||||
cur.advance(2);
|
||||
continue;
|
||||
}
|
||||
skipInterpolation(cur);
|
||||
continue;
|
||||
}
|
||||
cur.advance();
|
||||
if (ch === '"') return;
|
||||
}
|
||||
}
|
||||
|
||||
function skipVerbatimString(cur: SourceCursor, interpolated: boolean): void {
|
||||
cur.advance(2); // @"
|
||||
while (!cur.done) {
|
||||
const ch = cur.peek();
|
||||
if (ch === '"') {
|
||||
if (cur.peek(1) === '"') {
|
||||
cur.advance(2);
|
||||
continue;
|
||||
}
|
||||
cur.advance();
|
||||
return;
|
||||
}
|
||||
if (interpolated && ch === '{') {
|
||||
if (cur.peek(1) === '{') {
|
||||
cur.advance(2);
|
||||
continue;
|
||||
}
|
||||
skipInterpolation(cur);
|
||||
continue;
|
||||
}
|
||||
cur.advance();
|
||||
}
|
||||
}
|
||||
|
||||
function skipRawString(cur: SourceCursor): void {
|
||||
let quoteCount = 0;
|
||||
while (cur.peek() === '"') {
|
||||
quoteCount += 1;
|
||||
cur.advance();
|
||||
}
|
||||
while (!cur.done) {
|
||||
if (cur.peek() !== '"') {
|
||||
cur.advance();
|
||||
continue;
|
||||
}
|
||||
let seen = 0;
|
||||
while (cur.peek() === '"') {
|
||||
seen += 1;
|
||||
cur.advance();
|
||||
}
|
||||
if (seen >= quoteCount) return;
|
||||
}
|
||||
}
|
||||
|
||||
function skipInterpolation(cur: SourceCursor): void {
|
||||
cur.advance(); // {
|
||||
let depth = 1;
|
||||
while (!cur.done && depth > 0) {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.done) return;
|
||||
if (skipCsharpString(cur)) continue;
|
||||
const ch = cur.peek();
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') depth -= 1;
|
||||
cur.advance();
|
||||
}
|
||||
}
|
||||
|
||||
function skipCsharpString(cur: SourceCursor): boolean {
|
||||
const ch = cur.peek();
|
||||
if (ch === "'") {
|
||||
cur.advance();
|
||||
if (cur.peek() === '\\') cur.advance(2);
|
||||
else cur.advance();
|
||||
if (cur.peek() === "'") cur.advance();
|
||||
return true;
|
||||
}
|
||||
if (ch === '"') {
|
||||
if (cur.peek(1) === '"' && cur.peek(2) === '"') skipRawString(cur);
|
||||
else skipRegularString(cur, false);
|
||||
return true;
|
||||
}
|
||||
if (ch === '$' && cur.peek(1) === '@' && cur.peek(2) === '"') {
|
||||
cur.advance();
|
||||
skipVerbatimString(cur, true);
|
||||
return true;
|
||||
}
|
||||
if (ch === '@' && cur.peek(1) === '$' && cur.peek(2) === '"') {
|
||||
cur.advance(2);
|
||||
skipVerbatimString(cur, true);
|
||||
return true;
|
||||
}
|
||||
if (ch === '@' && cur.peek(1) === '"') {
|
||||
skipVerbatimString(cur, false);
|
||||
return true;
|
||||
}
|
||||
if (ch === '$' && cur.peek(1) === '"') {
|
||||
if (cur.peek(2) === '"' && cur.peek(3) === '"') {
|
||||
cur.advance();
|
||||
skipRawString(cur);
|
||||
} else {
|
||||
cur.advance();
|
||||
skipRegularString(cur, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readIdent(cur: SourceCursor): string | undefined {
|
||||
if (!isIdentStart(cur.peek())) return undefined;
|
||||
const start = cur.i;
|
||||
if (cur.peek() === '@') cur.advance();
|
||||
if (!isIdentStart(cur.peek()) && !(cur.peek() >= 'A' && cur.peek() <= 'z')) {
|
||||
cur.i = start;
|
||||
return undefined;
|
||||
}
|
||||
while (isIdentPart(cur.peek()) && cur.peek() !== '@') cur.advance();
|
||||
const raw = cur.source.slice(start, cur.i);
|
||||
return raw.startsWith('@') ? raw.slice(1) : raw;
|
||||
}
|
||||
|
||||
function tryReadIdent(cur: SourceCursor): string | undefined {
|
||||
skipCsharpTrivia(cur);
|
||||
return readIdent(cur);
|
||||
}
|
||||
|
||||
function decodeCsharpString(cur: SourceCursor): string | undefined {
|
||||
skipCsharpTrivia(cur);
|
||||
const start = cur.snapshot();
|
||||
const ch = cur.peek();
|
||||
if (ch === '$') return undefined;
|
||||
if (ch === '@' && cur.peek(1) === '"') {
|
||||
cur.advance(2);
|
||||
let value = '';
|
||||
while (!cur.done) {
|
||||
if (cur.peek() === '"') {
|
||||
if (cur.peek(1) === '"') {
|
||||
value += '"';
|
||||
cur.advance(2);
|
||||
continue;
|
||||
}
|
||||
cur.advance();
|
||||
return value;
|
||||
}
|
||||
value += cur.peek();
|
||||
cur.advance();
|
||||
}
|
||||
cur.restore(start);
|
||||
return undefined;
|
||||
}
|
||||
if (ch === '"' && cur.peek(1) === '"' && cur.peek(2) === '"') {
|
||||
let quoteCount = 0;
|
||||
while (cur.peek() === '"') {
|
||||
quoteCount += 1;
|
||||
cur.advance();
|
||||
}
|
||||
const bodyStart = cur.i;
|
||||
while (!cur.done) {
|
||||
if (cur.peek() !== '"') {
|
||||
cur.advance();
|
||||
continue;
|
||||
}
|
||||
const closeStart = cur.i;
|
||||
let seen = 0;
|
||||
while (cur.peek() === '"') {
|
||||
seen += 1;
|
||||
cur.advance();
|
||||
}
|
||||
if (seen >= quoteCount) {
|
||||
return cur.source.slice(bodyStart, closeStart);
|
||||
}
|
||||
}
|
||||
cur.restore(start);
|
||||
return undefined;
|
||||
}
|
||||
if (ch === '"') {
|
||||
cur.advance();
|
||||
let value = '';
|
||||
while (!cur.done) {
|
||||
const next = cur.peek();
|
||||
if (next === '\\') {
|
||||
cur.advance();
|
||||
const esc = cur.peek();
|
||||
cur.advance();
|
||||
const map: Record<string, string> = {
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
'0': '\0',
|
||||
};
|
||||
value += map[esc] ?? esc;
|
||||
continue;
|
||||
}
|
||||
if (next === '"') {
|
||||
cur.advance();
|
||||
return value;
|
||||
}
|
||||
value += next;
|
||||
cur.advance();
|
||||
}
|
||||
cur.restore(start);
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function skipBalanced(cur: SourceCursor, open: string, close: string): boolean {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() !== open) return false;
|
||||
let depth = 0;
|
||||
while (!cur.done) {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.done) return false;
|
||||
if (skipCsharpString(cur)) continue;
|
||||
const ch = cur.peek();
|
||||
if (ch === open) depth += 1;
|
||||
else if (ch === close) {
|
||||
depth -= 1;
|
||||
cur.advance();
|
||||
if (depth === 0) return true;
|
||||
continue;
|
||||
}
|
||||
cur.advance();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function componentNameFromLiteral(value: string | undefined): string | undefined {
|
||||
if (value === undefined || !COMPONENT_NAME_RE.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isViewComponentAttributeName(name: string): boolean {
|
||||
return name === 'ViewComponent' || name === 'ViewComponentAttribute';
|
||||
}
|
||||
|
||||
function readQualifiedTail(cur: SourceCursor): string | undefined {
|
||||
skipCsharpTrivia(cur);
|
||||
let name = readIdent(cur);
|
||||
if (name === undefined) return undefined;
|
||||
for (;;) {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === '.' || (cur.peek() === ':' && cur.peek(1) === ':')) {
|
||||
cur.advance(cur.peek() === ':' ? 2 : 1);
|
||||
skipCsharpTrivia(cur);
|
||||
const next = readIdent(cur);
|
||||
if (next === undefined) return name;
|
||||
name = next;
|
||||
continue;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function readViewComponentNameArgument(cur: SourceCursor): string | undefined {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() !== '(') return undefined;
|
||||
cur.advance();
|
||||
let alias: string | undefined;
|
||||
while (!cur.done && cur.peek() !== ')') {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === ')') break;
|
||||
const beforeArg = cur.snapshot();
|
||||
const ident = readIdent(cur);
|
||||
skipCsharpTrivia(cur);
|
||||
if (ident === 'Name' && cur.peek() === '=') {
|
||||
cur.advance();
|
||||
alias = componentNameFromLiteral(decodeCsharpString(cur));
|
||||
} else {
|
||||
cur.restore(beforeArg);
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === '"' || cur.peek() === '@') {
|
||||
// Positional string arguments are not ViewComponentAttribute.Name.
|
||||
skipCsharpString(cur);
|
||||
} else if (cur.peek() === '(' || cur.peek() === '[' || cur.peek() === '{') {
|
||||
const open = cur.peek();
|
||||
const close = open === '(' ? ')' : open === '[' ? ']' : '}';
|
||||
skipBalanced(cur, open, close);
|
||||
} else {
|
||||
while (!cur.done && cur.peek() !== ',' && cur.peek() !== ')') {
|
||||
if (skipCsharpString(cur)) continue;
|
||||
if (skipCsharpComment(cur)) continue;
|
||||
cur.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === ',') cur.advance();
|
||||
}
|
||||
if (cur.peek() === ')') cur.advance();
|
||||
return alias;
|
||||
}
|
||||
|
||||
function collectInvokeAfterIdent(
|
||||
ident: string,
|
||||
cur: SourceCursor,
|
||||
previous: string | undefined,
|
||||
memberReceiver: string | undefined,
|
||||
names: Set<string>,
|
||||
): void {
|
||||
skipCsharpTrivia(cur);
|
||||
const hasMvcReceiver = previous !== '.' || memberReceiver === 'this' || memberReceiver === 'base';
|
||||
if (ident === 'ViewComponent' && cur.peek() === '(') {
|
||||
if (previous === '[' || previous === ',' || !hasMvcReceiver) return;
|
||||
cur.advance();
|
||||
const name = componentNameFromLiteral(decodeCsharpString(cur));
|
||||
if (name !== undefined) names.add(name);
|
||||
return;
|
||||
}
|
||||
if (ident !== 'Component' || cur.peek() !== '.' || !hasMvcReceiver) return;
|
||||
const afterDot = cur.snapshot();
|
||||
cur.advance();
|
||||
skipCsharpTrivia(cur);
|
||||
if (readIdent(cur) !== 'InvokeAsync') {
|
||||
cur.restore(afterDot);
|
||||
return;
|
||||
}
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() !== '(') return;
|
||||
cur.advance();
|
||||
const name = componentNameFromLiteral(decodeCsharpString(cur));
|
||||
if (name !== undefined) names.add(name);
|
||||
}
|
||||
|
||||
/** In-repo C# `Component.InvokeAsync("X")` / `ViewComponent("X")` literals. */
|
||||
export function extractCsharpViewComponentInvocations(source: string): string[] {
|
||||
if (!source.includes('ViewComponent') && !source.includes('InvokeAsync')) return [];
|
||||
const names = new Set<string>();
|
||||
const cur = new SourceCursor(source);
|
||||
let previous: string | undefined;
|
||||
let memberReceiver: string | undefined;
|
||||
let squareDepth = 0;
|
||||
while (!cur.done) {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.done) break;
|
||||
if (skipCsharpString(cur)) {
|
||||
previous = 'string';
|
||||
continue;
|
||||
}
|
||||
const ident = readIdent(cur);
|
||||
if (ident !== undefined) {
|
||||
const inAttribute = squareDepth > 0;
|
||||
collectInvokeAfterIdent(ident, cur, inAttribute ? '[' : previous, memberReceiver, names);
|
||||
previous = ident;
|
||||
memberReceiver = undefined;
|
||||
continue;
|
||||
}
|
||||
const ch = cur.peek();
|
||||
if (ch === '[') squareDepth += 1;
|
||||
else if (ch === ']' && squareDepth > 0) squareDepth -= 1;
|
||||
memberReceiver = ch === '.' ? previous : undefined;
|
||||
previous = ch;
|
||||
cur.advance();
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function parseAttributeListBody(cur: SourceCursor): string[] {
|
||||
const aliases: string[] = [];
|
||||
skipCsharpTrivia(cur);
|
||||
const specifier = cur.snapshot();
|
||||
const specifierName = readIdent(cur);
|
||||
skipCsharpTrivia(cur);
|
||||
if (specifierName !== undefined && cur.peek() === ':' && cur.peek(1) !== ':') {
|
||||
cur.advance();
|
||||
} else {
|
||||
cur.restore(specifier);
|
||||
}
|
||||
while (!cur.done && cur.peek() !== ']') {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === ']') break;
|
||||
const tail = readQualifiedTail(cur);
|
||||
skipCsharpTrivia(cur);
|
||||
if (tail !== undefined && isViewComponentAttributeName(tail) && cur.peek() === '(') {
|
||||
const alias = readViewComponentNameArgument(cur);
|
||||
if (alias !== undefined) aliases.push(alias);
|
||||
} else if (cur.peek() === '(') {
|
||||
skipBalanced(cur, '(', ')');
|
||||
}
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === ',') cur.advance();
|
||||
else break;
|
||||
}
|
||||
if (cur.peek() === ']') cur.advance();
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit `[ViewComponent(Name = "...")]` aliases keyed to the following
|
||||
* class declaration. Positional constructor arguments are ignored: the MVC
|
||||
* attribute only exposes `Name` as a property.
|
||||
*/
|
||||
export function extractViewComponentAliasBinds(source: string): ViewComponentAliasBind[] {
|
||||
if (!source.includes('ViewComponent')) return [];
|
||||
const binds: ViewComponentAliasBind[] = [];
|
||||
const cur = new SourceCursor(source);
|
||||
const pending: { startLine: number; startCol: number; aliases: string[] }[] = [];
|
||||
|
||||
const flushPending = (className: string, startLine: number, startCol: number): void => {
|
||||
const aliases = pending.flatMap((entry) => entry.aliases);
|
||||
const start = pending[0];
|
||||
binds.push({
|
||||
className,
|
||||
startLine: start?.startLine ?? startLine,
|
||||
startCol: start?.startCol ?? startCol,
|
||||
aliases: [...new Set(aliases)],
|
||||
});
|
||||
pending.length = 0;
|
||||
};
|
||||
|
||||
while (!cur.done) {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.done) break;
|
||||
if (skipCsharpString(cur)) continue;
|
||||
const startLine = cur.line;
|
||||
const startCol = cur.col;
|
||||
if (cur.peek() === '[') {
|
||||
cur.advance();
|
||||
const aliases = parseAttributeListBody(cur);
|
||||
pending.push({ startLine, startCol, aliases });
|
||||
continue;
|
||||
}
|
||||
const ident = readIdent(cur);
|
||||
if (ident === undefined) {
|
||||
pending.length = 0;
|
||||
cur.advance();
|
||||
continue;
|
||||
}
|
||||
if (TYPE_MODIFIERS.has(ident)) continue;
|
||||
if (ident === 'class' || ident === 'record') {
|
||||
let className = tryReadIdent(cur);
|
||||
if (ident === 'record' && (className === 'class' || className === 'struct')) {
|
||||
className = tryReadIdent(cur);
|
||||
}
|
||||
if (className !== undefined && pending.some((entry) => entry.aliases.length > 0)) {
|
||||
flushPending(className, startLine, startCol);
|
||||
} else {
|
||||
pending.length = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pending.length = 0;
|
||||
}
|
||||
return binds;
|
||||
}
|
||||
|
||||
/** Extract explicit `[ViewComponent(Name = "...")]` aliases by class name. */
|
||||
export function extractViewComponentAliases(
|
||||
source: string,
|
||||
): ReadonlyMap<string, readonly string[]> {
|
||||
const aliases = new Map<string, string[]>();
|
||||
for (const bind of extractViewComponentAliasBinds(source)) {
|
||||
if (bind.aliases.length === 0) continue;
|
||||
const existing = aliases.get(bind.className);
|
||||
if (existing) {
|
||||
for (const alias of bind.aliases) {
|
||||
if (!existing.includes(alias)) existing.push(alias);
|
||||
}
|
||||
} else {
|
||||
aliases.set(bind.className, [...bind.aliases]);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function tagNameToComponentName(tagName: string): string {
|
||||
return tagName
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]!.toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function collectVcTags(span: string, names: Set<string>): void {
|
||||
VIEW_COMPONENT_TAG_RE.lastIndex = 0;
|
||||
for (const match of span.matchAll(VIEW_COMPONENT_TAG_RE)) {
|
||||
names.add(tagNameToComponentName(match[1]!));
|
||||
}
|
||||
}
|
||||
|
||||
function skipRazorComment(cur: SourceCursor): boolean {
|
||||
if (!cur.startsWith('@*')) return false;
|
||||
cur.advance(2);
|
||||
while (!cur.done && !cur.startsWith('*@')) cur.advance();
|
||||
if (cur.startsWith('*@')) cur.advance(2);
|
||||
return true;
|
||||
}
|
||||
|
||||
function countAtRun(cur: SourceCursor): number {
|
||||
let count = 0;
|
||||
while (cur.peek() === '@') {
|
||||
count += 1;
|
||||
cur.advance();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function scanCsharpSpan(span: string, names: Set<string>): void {
|
||||
for (const name of extractCsharpViewComponentInvocations(span)) names.add(name);
|
||||
}
|
||||
|
||||
function skipOptionalParens(cur: SourceCursor): void {
|
||||
skipWhitespace(cur);
|
||||
if (cur.peek() === '(') skipBalanced(cur, '(', ')');
|
||||
}
|
||||
|
||||
function consumeRazorCodeBlock(cur: SourceCursor, names: Set<string>): void {
|
||||
skipCsharpTrivia(cur);
|
||||
skipOptionalParens(cur);
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() !== '{') {
|
||||
const start = cur.i;
|
||||
while (!cur.done && cur.peek() !== '\n' && cur.peek() !== '{') {
|
||||
if (skipCsharpString(cur) || skipCsharpComment(cur)) continue;
|
||||
cur.advance();
|
||||
}
|
||||
scanCsharpSpan(cur.source.slice(start, cur.i), names);
|
||||
if (cur.peek() === '{') consumeRazorCodeBlock(cur, names);
|
||||
return;
|
||||
}
|
||||
const bodyStart = cur.i + 1;
|
||||
if (!skipBalanced(cur, '{', '}')) return;
|
||||
scanCsharpSpan(cur.source.slice(bodyStart, cur.i - 1), names);
|
||||
}
|
||||
|
||||
function consumeImplicitExpression(cur: SourceCursor, names: Set<string>): void {
|
||||
const start = cur.i;
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.peek() === '(') {
|
||||
const innerStart = cur.i + 1;
|
||||
if (skipBalanced(cur, '(', ')')) {
|
||||
scanCsharpSpan(cur.source.slice(innerStart, cur.i - 1), names);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Implicit expressions: `@await Component.InvokeAsync("X")` / `@Component.InvokeAsync(...)`.
|
||||
while (!cur.done) {
|
||||
skipCsharpTrivia(cur);
|
||||
if (cur.done) break;
|
||||
if (skipCsharpString(cur)) continue;
|
||||
if (cur.peek() === '(') {
|
||||
skipBalanced(cur, '(', ')');
|
||||
continue;
|
||||
}
|
||||
if (cur.peek() === '{') {
|
||||
skipBalanced(cur, '{', '}');
|
||||
continue;
|
||||
}
|
||||
const ch = cur.peek();
|
||||
if (ch === '<' || ch === '\n') break;
|
||||
if (ch === '@') break;
|
||||
if (!isIdentPart(ch) && ch !== '.' && ch !== '?') {
|
||||
if (ch === ';') cur.advance();
|
||||
break;
|
||||
}
|
||||
cur.advance();
|
||||
}
|
||||
scanCsharpSpan(cur.source.slice(start, cur.i), names);
|
||||
}
|
||||
|
||||
function consumeRazorTransition(cur: SourceCursor, names: Set<string>): void {
|
||||
skipWhitespace(cur);
|
||||
if (cur.peek() === '{') {
|
||||
consumeRazorCodeBlock(cur, names);
|
||||
return;
|
||||
}
|
||||
if (cur.peek() === '(') {
|
||||
consumeImplicitExpression(cur, names);
|
||||
return;
|
||||
}
|
||||
const identStart = cur.snapshot();
|
||||
const ident = readIdent(cur);
|
||||
if (ident === undefined) {
|
||||
consumeImplicitExpression(cur, names);
|
||||
return;
|
||||
}
|
||||
if (ident === 'await' || ident === 'Component') {
|
||||
cur.restore(identStart);
|
||||
consumeImplicitExpression(cur, names);
|
||||
return;
|
||||
}
|
||||
if (RAZOR_BLOCK_KEYWORDS.has(ident)) {
|
||||
if (ident === 'section' || ident === 'helper') tryReadIdent(cur);
|
||||
consumeRazorCodeBlock(cur, names);
|
||||
return;
|
||||
}
|
||||
cur.restore(identStart);
|
||||
consumeImplicitExpression(cur, names);
|
||||
}
|
||||
|
||||
/** Extract statically resolvable ViewComponent names from one Razor template. */
|
||||
export function extractRazorViewComponentInvocations(source: string): string[] {
|
||||
// Most views do not invoke a ViewComponent. Avoid the character-by-character
|
||||
// Razor scan unless one of the two supported invocation spellings is present.
|
||||
// This is only a coarse gate; the state machine below still decides whether a
|
||||
// token is executable markup/C# or a comment/string/escaped transition.
|
||||
if (!source.includes('InvokeAsync') && !/<\s*vc:/i.test(source)) return [];
|
||||
|
||||
const names = new Set<string>();
|
||||
const cur = new SourceCursor(source);
|
||||
let markupStart = 0;
|
||||
const flushMarkup = (): void => {
|
||||
if (cur.i > markupStart) collectVcTags(source.slice(markupStart, cur.i), names);
|
||||
};
|
||||
|
||||
while (!cur.done) {
|
||||
if (cur.peek() !== '@') {
|
||||
cur.advance();
|
||||
continue;
|
||||
}
|
||||
flushMarkup();
|
||||
if (skipRazorComment(cur)) {
|
||||
markupStart = cur.i;
|
||||
continue;
|
||||
}
|
||||
const atCount = countAtRun(cur);
|
||||
const leftover = atCount % 2;
|
||||
if (leftover === 0) {
|
||||
markupStart = cur.i;
|
||||
continue;
|
||||
}
|
||||
consumeRazorTransition(cur, names);
|
||||
markupStart = cur.i;
|
||||
}
|
||||
flushMarkup();
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Razor views once per C# resolution pass. The same ignore rules and file
|
||||
* size ceiling as repository scanning are applied, and edge emission later
|
||||
* additionally requires a live File node. This prevents ignored, oversized,
|
||||
* or concurrently removed templates from entering the graph.
|
||||
*/
|
||||
export async function loadRazorViewComponentConfig(
|
||||
repoRoot: string,
|
||||
): Promise<RazorViewComponentConfig> {
|
||||
const ignore = await createIgnoreFilter(repoRoot);
|
||||
const paths = await glob('**/*.cshtml', {
|
||||
cwd: repoRoot,
|
||||
nodir: true,
|
||||
dot: false,
|
||||
ignore,
|
||||
});
|
||||
paths.sort();
|
||||
|
||||
const maxBytes = getMaxFileSizeBytes();
|
||||
const views = new Map<string, readonly string[]>();
|
||||
for (const rawPath of paths) {
|
||||
const filePath = rawPath.replace(/\\/g, '/');
|
||||
// The size gate and the read go through one handle so both observe the same
|
||||
// inode. Re-resolving the path for the read would let a template swapped in
|
||||
// between them be read unchecked (CodeQL js/file-system-race).
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(path.join(repoRoot, filePath), 'r');
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > maxBytes) continue;
|
||||
const source = await handle.readFile('utf8');
|
||||
views.set(filePath, extractRazorViewComponentInvocations(source));
|
||||
} catch {
|
||||
// A view may disappear between glob/open/read during watch mode.
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
return { views };
|
||||
}
|
||||
|
||||
function addCandidate(
|
||||
candidates: Map<string, Set<string>>,
|
||||
invocationName: string,
|
||||
targetId: string,
|
||||
): void {
|
||||
const key = invocationName.toLocaleLowerCase('en-US');
|
||||
const existing = candidates.get(key);
|
||||
if (existing) {
|
||||
existing.add(targetId);
|
||||
} else {
|
||||
candidates.set(key, new Set([targetId]));
|
||||
}
|
||||
}
|
||||
|
||||
function bindAliasesForClass(
|
||||
binds: readonly ViewComponentAliasBind[],
|
||||
className: string,
|
||||
nodeId: string,
|
||||
filePath: string,
|
||||
): readonly string[] | undefined {
|
||||
const matches = binds.filter((bind) => bind.className === className);
|
||||
if (matches.length === 0) return undefined;
|
||||
if (matches.length === 1) return matches[0]!.aliases;
|
||||
const pos = definitionIdPosition(nodeId, filePath);
|
||||
if (pos === undefined) return undefined;
|
||||
const atPosition = matches.filter(
|
||||
(bind) => bind.startLine === pos.line && bind.startCol === pos.column,
|
||||
);
|
||||
if (atPosition.length === 1) return atPosition[0]!.aliases;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit workspace File → in-repo ViewComponent Class CALLS edges.
|
||||
*
|
||||
* Targets are only Class nodes produced from this repo's `.cs` files. There is
|
||||
* no lookup of ASP.NET SDK types; `: ViewComponent` in source is a naming
|
||||
* hint, not a resolved EXTENDS edge to `Microsoft.AspNetCore.Mvc.ViewComponent`.
|
||||
*
|
||||
* Ambiguous component names fail closed: two in-repo classes claiming the
|
||||
* same name is not evidence for picking either one.
|
||||
*/
|
||||
export function emitRazorViewComponentEdges(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
config: RazorViewComponentConfig | undefined,
|
||||
csharpSources: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
if (!config) return;
|
||||
|
||||
const candidates = new Map<string, Set<string>>();
|
||||
for (const parsed of parsedFiles) {
|
||||
if (!parsed.filePath.endsWith('.cs')) continue;
|
||||
const source = csharpSources.get(parsed.filePath) ?? '';
|
||||
const binds = source.includes('ViewComponent') ? extractViewComponentAliasBinds(source) : [];
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Class') continue;
|
||||
const className = def.qualifiedName?.split('.').pop() ?? def.nodeId.split(':').pop() ?? '';
|
||||
const conventionalName = className.endsWith(VIEW_COMPONENT_SUFFIX)
|
||||
? className.slice(0, -VIEW_COMPONENT_SUFFIX.length)
|
||||
: undefined;
|
||||
const explicitAliases = bindAliasesForClass(binds, className, def.nodeId, parsed.filePath);
|
||||
if (!conventionalName && (explicitAliases === undefined || explicitAliases.length === 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (!targetId || !graph.getNode(targetId)) continue;
|
||||
// An explicit [ViewComponent(Name = "...")] replaces the suffix name,
|
||||
// matching ASP.NET. Never register the SDK base type as a candidate.
|
||||
if (explicitAliases !== undefined && explicitAliases.length > 0) {
|
||||
for (const alias of explicitAliases) addCandidate(candidates, alias, targetId);
|
||||
} else if (conventionalName) {
|
||||
addCandidate(candidates, conventionalName, targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emitFromFile = (filePath: string, invocationNames: readonly string[]): void => {
|
||||
const sourceId = generateId('File', filePath);
|
||||
if (!graph.getNode(sourceId)) return;
|
||||
for (const invocationName of invocationNames) {
|
||||
const matches = candidates.get(invocationName.toLocaleLowerCase('en-US'));
|
||||
if (!matches || matches.size !== 1) continue;
|
||||
const targetId = matches.values().next().value;
|
||||
if (typeof targetId !== 'string' || !graph.getNode(targetId)) continue;
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${sourceId}:razor-view-component:${targetId}`),
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'aspnet-razor-view-component',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const [viewPath, invocationNames] of config.views) {
|
||||
emitFromFile(viewPath, invocationNames);
|
||||
}
|
||||
for (const [filePath, source] of csharpSources) {
|
||||
if (!filePath.endsWith('.cs')) continue;
|
||||
if (!source.includes('ViewComponent') && !source.includes('InvokeAsync')) continue;
|
||||
emitFromFile(filePath, extractCsharpViewComponentInvocations(source));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,19 +12,29 @@ import {
|
|||
type CSharpProjectConfig,
|
||||
type CSharpNamespaceEvidence,
|
||||
} from '../../language-config.js';
|
||||
import {
|
||||
loadRazorViewComponentConfig,
|
||||
type RazorViewComponentConfig,
|
||||
} from './razor-view-components.js';
|
||||
|
||||
export interface CsharpResolutionConfig {
|
||||
readonly csharpConfigs: readonly CSharpProjectConfig[];
|
||||
/** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */
|
||||
readonly namespaces?: CSharpNamespaceEvidence;
|
||||
/** Razor views scanned for ASP.NET ViewComponent invocation conventions. */
|
||||
readonly razorViewComponents?: RazorViewComponentConfig;
|
||||
}
|
||||
|
||||
export async function loadCsharpResolutionConfig(
|
||||
repoRoot: string,
|
||||
): Promise<CsharpResolutionConfig> {
|
||||
const scan = await scanCSharpProject(repoRoot);
|
||||
const [scan, razorViewComponents] = await Promise.all([
|
||||
scanCSharpProject(repoRoot),
|
||||
loadRazorViewComponentConfig(repoRoot),
|
||||
]);
|
||||
return {
|
||||
csharpConfigs: scan.configs,
|
||||
namespaces: csharpScanToEvidence(scan),
|
||||
razorViewComponents,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
|
||||
import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js';
|
||||
import { unwrapCsharpElementType } from './accessor-unwrap.js';
|
||||
import { emitRazorViewComponentEdges } from './razor-view-components.js';
|
||||
|
||||
const csharpScopeResolver: ScopeResolver = {
|
||||
// Construction is keyword-prefixed: `new Service(db).doWork()` (#2708).
|
||||
|
|
@ -106,6 +107,20 @@ const csharpScopeResolver: ScopeResolver = {
|
|||
// `IValidator<string>` and `IValidator<String>` are one instantiation, so the
|
||||
// dispatch fan-out must not read them as two (#2912). See the alias table.
|
||||
normalizeTypeArgument: normalizeCsharpTypeArgument,
|
||||
|
||||
// Razor views stay out of the C# parser. Bind literal ViewComponent names
|
||||
// only onto in-repo classes (Spring-style: skip the SDK type, hop to the
|
||||
// workspace implementor).
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, _indexes, ctx) => {
|
||||
const config = ctx.resolutionConfig as CsharpResolutionConfig | undefined;
|
||||
emitRazorViewComponentEdges(
|
||||
graph,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
config?.razorViewComponents,
|
||||
ctx.fileContents,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
prepareJavaRouteConstants,
|
||||
} from '../route-extractors/java-const-resolver.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -32,12 +33,17 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
|
|||
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createJavaCfgVisitor } from '../cfg/visitors/java.js';
|
||||
import { assertCloneable } from '../workers/clone-safety.js';
|
||||
import { collectJavaCaptureSideChannel } from './java/capture-side-channel.js';
|
||||
import {
|
||||
collectJavaCaptureSideChannel,
|
||||
getJavaSpringMessageProducerFacts,
|
||||
getJavaSpringNonHttpHandlerFacts,
|
||||
} from './java/capture-side-channel.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
javaRecordMethodExtractor,
|
||||
shouldSkipJavaRecordComponentDefinition,
|
||||
} from './java/record-components.js';
|
||||
import { synthesizeLombokAccessors } from './java/lombok-synthesizer.js';
|
||||
import {
|
||||
emitJavaScopeCaptures,
|
||||
interpretJavaImport,
|
||||
|
|
@ -49,6 +55,7 @@ import {
|
|||
javaArityCompatibility,
|
||||
resolveJavaImportTarget,
|
||||
} from './java/index.js';
|
||||
import { javaRuntimeSymbolStrategy } from './java/spring-actuator.js';
|
||||
|
||||
/**
|
||||
* Java names the platform owns, matched against a BARE IDENTIFIER — a dropped
|
||||
|
|
@ -197,6 +204,7 @@ export const javaProvider = defineLanguage({
|
|||
shouldSkipDefinitionCapture: shouldSkipJavaRecordComponentDefinition,
|
||||
variableExtractor: createVariableExtractor(javaVariableConfig),
|
||||
classExtractor: createClassExtractor(javaClassConfig),
|
||||
runtimeSymbolStrategy: javaRuntimeSymbolStrategy,
|
||||
|
||||
// ── Javadoc → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
|
|
@ -222,6 +230,8 @@ export const javaProvider = defineLanguage({
|
|||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
extractRouteInheritanceTypes: extractSpringTypes,
|
||||
|
||||
synthesizeStructureMembers: synthesizeLombokAccessors,
|
||||
|
||||
// ── #2980: constant harvest + qualified-ref fold for non-literal mapping
|
||||
// paths (`@PostMapping(ApiPaths.SAVE_V1)`) — kept behind provider hooks so
|
||||
// the shared ingestion layers stay language-agnostic. The heuristic is
|
||||
|
|
@ -236,11 +246,17 @@ export const javaProvider = defineLanguage({
|
|||
// the group still published the contract).
|
||||
moduleConstantHeuristic: (content) =>
|
||||
isJavaConstantFile(content) ||
|
||||
// `import com.winning.opt.common.ApiPaths;` — ANY class import can bind a
|
||||
// constant ref (`ApiPaths.X` at an annotation site), so gate on the
|
||||
// general import shape, not on the imported name. Ingestion-only: this
|
||||
// side needs the importing controller's own import table, which the group
|
||||
// side instead derives lazily from the tree it already holds.
|
||||
/\bimport\s+(?:static\s+)?[\w.]+\s*;/.test(content),
|
||||
// Class imports and static (including on-demand) imports can bind a
|
||||
// constant ref. Ordinary `import a.b.*;` is not a Java type import and is
|
||||
// not expanded by extractJavaModuleConstants, so it must not harvest.
|
||||
/\bimport\s+(?:static\s+[\w.]+(?:\.\*)?|[\w.]+)\s*;/.test(content),
|
||||
prepareRouteConstants: prepareJavaRouteConstants,
|
||||
foldRoutePathOperands: foldJavaOperands,
|
||||
// Async messaging facts for the `springDestinations` phase. Both stores are
|
||||
// repopulated on the main thread by `applyJavaCaptureSideChannel`, so this
|
||||
// answers for cache hits and misses alike.
|
||||
getSpringMessagingFacts: (filePath) => ({
|
||||
handlers: getJavaSpringNonHttpHandlerFacts(filePath),
|
||||
producers: getJavaSpringMessageProducerFacts(filePath),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,17 +5,24 @@ function isSpringApplicationConfig(filePath: string): boolean {
|
|||
return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
|
||||
}
|
||||
|
||||
/** Durable completeness contract for Java Spring configuration bindings. */
|
||||
/** Durable completeness contract for Java and Kotlin Spring configuration bindings. */
|
||||
export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.config-bindings',
|
||||
version: 1,
|
||||
// Java sources need consumer extraction even without config files (missing
|
||||
// placeholders still get unresolved markers). Config-only repositories also
|
||||
// need a one-time rebuild to backfill language-agnostic Property nodes.
|
||||
version: 2,
|
||||
// Java and Kotlin sources need consumer extraction even without config files
|
||||
// (missing placeholders still get unresolved markers). Config-only
|
||||
// repositories also need a one-time rebuild to backfill language-agnostic
|
||||
// Property nodes. Gradle Kotlin DSL is not a consumer source.
|
||||
appliesTo: (filePaths) =>
|
||||
filePaths.some(
|
||||
(filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath),
|
||||
),
|
||||
filePaths.some((filePath) => {
|
||||
const normalized = filePath.replaceAll('\\', '/').toLowerCase();
|
||||
if (normalized.endsWith('.gradle.kts')) return false;
|
||||
return (
|
||||
normalized.endsWith('.java') ||
|
||||
normalized.endsWith('.kt') ||
|
||||
isSpringApplicationConfig(filePath)
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
/** Durable completeness contract for implicit Java record-component accessors. */
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
|
|||
import type { JavaSpringAopFact } from './spring-aop.js';
|
||||
import type { JavaSpringConditionalFact } from './spring-conditionals.js';
|
||||
import type { JavaSpringDiClassFact } from './spring-di.js';
|
||||
import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
|
||||
import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js';
|
||||
import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
|
||||
|
||||
export type JavaClassAnnotationFact = ClassAnnotationFact;
|
||||
|
|
@ -25,7 +27,9 @@ export interface JavaCaptureSideChannel {
|
|||
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
|
||||
readonly springConditionalFacts?: readonly JavaSpringConditionalFact[];
|
||||
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
|
||||
readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
|
||||
readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[];
|
||||
readonly springMessageProducerFacts?: readonly SpringMessageProducerFact[];
|
||||
}
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
|
|
@ -33,7 +37,9 @@ const springAopFacts = new Map<string, readonly JavaSpringAopFact[]>();
|
|||
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
|
||||
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
|
||||
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
|
||||
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
|
||||
const springNonHttpHandlerFacts = new Map<string, readonly JavaSpringNonHttpHandlerFact[]>();
|
||||
const springMessageProducerFacts = new Map<string, readonly SpringMessageProducerFact[]>();
|
||||
|
||||
/** Clear facts retained by a prior workspace pass in a long-lived process. */
|
||||
export function clearJavaClassAnnotationFacts(): void {
|
||||
|
|
@ -42,7 +48,9 @@ export function clearJavaClassAnnotationFacts(): void {
|
|||
springConfigConsumers.clear();
|
||||
springConditionalFacts.clear();
|
||||
springDiFacts.clear();
|
||||
springDynamicLookupFacts.clear();
|
||||
springNonHttpHandlerFacts.clear();
|
||||
springMessageProducerFacts.clear();
|
||||
}
|
||||
|
||||
export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void {
|
||||
|
|
@ -102,6 +110,20 @@ export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiCla
|
|||
return springDiFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringDynamicLookupFacts(
|
||||
filePath: string,
|
||||
facts: readonly SpringDynamicLookupFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springDynamicLookupFacts.delete(filePath);
|
||||
else springDynamicLookupFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getJavaSpringDynamicLookupFacts(
|
||||
filePath: string,
|
||||
): readonly SpringDynamicLookupFact[] {
|
||||
return springDynamicLookupFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringNonHttpHandlerFacts(
|
||||
filePath: string,
|
||||
facts: readonly JavaSpringNonHttpHandlerFact[],
|
||||
|
|
@ -116,6 +138,20 @@ export function getJavaSpringNonHttpHandlerFacts(
|
|||
return springNonHttpHandlerFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringMessageProducerFacts(
|
||||
filePath: string,
|
||||
facts: readonly SpringMessageProducerFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springMessageProducerFacts.delete(filePath);
|
||||
else springMessageProducerFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getJavaSpringMessageProducerFacts(
|
||||
filePath: string,
|
||||
): readonly SpringMessageProducerFact[] {
|
||||
return springMessageProducerFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
/** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
|
||||
export function collectJavaCaptureSideChannel(
|
||||
filePath: string,
|
||||
|
|
@ -125,7 +161,9 @@ export function collectJavaCaptureSideChannel(
|
|||
const configConsumers = springConfigConsumers.get(filePath) ?? [];
|
||||
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
|
||||
const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
|
||||
const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? [];
|
||||
const packageFact = getJavaPackageFact(filePath);
|
||||
if (
|
||||
facts.length === 0 &&
|
||||
|
|
@ -133,7 +171,9 @@ export function collectJavaCaptureSideChannel(
|
|||
configConsumers.length === 0 &&
|
||||
conditionFacts.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
dynamicLookupFacts.length === 0 &&
|
||||
nonHttpHandlerFacts.length === 0 &&
|
||||
messageProducerFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
return undefined;
|
||||
|
|
@ -146,7 +186,11 @@ export function collectJavaCaptureSideChannel(
|
|||
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
|
||||
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
|
||||
...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
|
||||
...(messageProducerFacts.length > 0
|
||||
? { springMessageProducerFacts: messageProducerFacts }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +213,9 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
|||
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
|
||||
setJavaSpringConditionalFacts(parsed.filePath, []);
|
||||
setJavaSpringDiFacts(parsed.filePath, []);
|
||||
setJavaSpringDynamicLookupFacts(parsed.filePath, []);
|
||||
setJavaSpringNonHttpHandlerFacts(parsed.filePath, []);
|
||||
setJavaSpringMessageProducerFacts(parsed.filePath, []);
|
||||
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
}
|
||||
|
|
@ -190,10 +236,18 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
|||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
);
|
||||
setJavaSpringDynamicLookupFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : [],
|
||||
);
|
||||
setJavaSpringNonHttpHandlerFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [],
|
||||
);
|
||||
setJavaSpringMessageProducerFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springMessageProducerFacts) ? data.springMessageProducerFacts : [],
|
||||
);
|
||||
setJavaPackageFact(
|
||||
parsed.filePath,
|
||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||
|
|
|
|||
|
|
@ -39,12 +39,18 @@ import {
|
|||
setJavaSpringConfigConsumerFacts,
|
||||
setJavaSpringConditionalFacts,
|
||||
setJavaSpringDiFacts,
|
||||
setJavaSpringDynamicLookupFacts,
|
||||
setJavaSpringMessageProducerFacts,
|
||||
setJavaSpringNonHttpHandlerFacts,
|
||||
} from './capture-side-channel.js';
|
||||
import { captureJavaPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
|
||||
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
|
||||
import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
|
||||
import { captureJavaSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
|
||||
import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js';
|
||||
import { captureJavaSpringMessageProducerFact } from './spring-message-producers.js';
|
||||
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
||||
import { captureJavaSpringAopFacts, type JavaSpringAopFact } from './spring-aop.js';
|
||||
import {
|
||||
|
|
@ -56,6 +62,7 @@ import {
|
|||
type JavaSpringNonHttpHandlerFact,
|
||||
} from './spring-non-http-handlers.js';
|
||||
import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js';
|
||||
import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
|
@ -146,6 +153,9 @@ export function emitJavaScopeCaptures(
|
|||
const springDiFacts: JavaSpringDiClassFact[] = [];
|
||||
const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
const springDynamicLookupFacts: SpringDynamicLookupFact[] = [];
|
||||
const springMessageProducerFacts: SpringMessageProducerFact[] = [];
|
||||
const springMemberCallNodeIds = new Set<number>();
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
|
|
@ -165,6 +175,17 @@ export function emitJavaScopeCaptures(
|
|||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// One visit per member call node: the same invocation can back several
|
||||
// query matches, and both Spring call-shape captures must see it once.
|
||||
const memberCallNode = nodeIfType(nodeMap['@reference.call.member'], 'method_invocation');
|
||||
if (memberCallNode !== null && !springMemberCallNodeIds.has(memberCallNode.id)) {
|
||||
springMemberCallNodeIds.add(memberCallNode.id);
|
||||
const lookupFact = captureJavaSpringDynamicLookupFact(memberCallNode, filePath);
|
||||
if (lookupFact !== null) springDynamicLookupFacts.push(lookupFact);
|
||||
const producerFact = captureJavaSpringMessageProducerFact(memberCallNode, filePath);
|
||||
if (producerFact !== null) springMessageProducerFacts.push(producerFact);
|
||||
}
|
||||
|
||||
const springAopTypeNode = [
|
||||
nodeIfType(nodeMap['@scope.class'], 'class_declaration'),
|
||||
nodeIfType(nodeMap['@scope.class'], 'interface_declaration'),
|
||||
|
|
@ -401,7 +422,9 @@ export function emitJavaScopeCaptures(
|
|||
setJavaSpringAopFacts(filePath, springAopFacts);
|
||||
setJavaSpringConditionalFacts(filePath, springConditionalFacts);
|
||||
setJavaSpringDiFacts(filePath, springDiFacts);
|
||||
setJavaSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
|
||||
setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
|
||||
setJavaSpringMessageProducerFacts(filePath, springMessageProducerFacts);
|
||||
|
||||
return [
|
||||
...resolveVarTypeBindings(out),
|
||||
|
|
@ -409,6 +432,7 @@ export function emitJavaScopeCaptures(
|
|||
...synthesizeJavaExplicitConstructorReferences(tree.rootNode),
|
||||
...synthesizeJavaAnonymousClassDeclarations(tree.rootNode),
|
||||
...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode),
|
||||
...synthesizeLombokAccessorCaptures(tree.rootNode),
|
||||
...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
539
gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts
Normal file
539
gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
/**
|
||||
* Lombok accessor synthesizer for Java.
|
||||
*
|
||||
* Lombok generates getters/setters at compile time. They are absent from the
|
||||
* AST, so calls like `obj.getOrderId()` on a `@Data` class would otherwise
|
||||
* leave unresolved CALLS edges. This module walks the tree-sitter Java AST
|
||||
* and synthesizes Method graph members for the accessors Lombok would emit
|
||||
* under the supported subset.
|
||||
*
|
||||
* ## Supported subset (v1)
|
||||
* - Proven `lombok.Data` / `lombok.Getter` / `lombok.Setter` (FQN or import).
|
||||
* - Class- or field-level enable; `AccessLevel.NONE` disables.
|
||||
* - Default JavaBeans naming; primitive `boolean isX` → `isX` / `setX`.
|
||||
* - Access levels PUBLIC/PROTECTED/PRIVATE/PACKAGE.
|
||||
* - `@Accessors(chain=true)` modeled as setter return = declaring type.
|
||||
* - `@Accessors(fluent=true)` / `prefix=…`: omit affected accessors (names
|
||||
* cannot be proven without full Lombok config).
|
||||
* - External `lombok.config`: unsupported (may change semantics invisibly).
|
||||
*
|
||||
* ## Identity
|
||||
* Owner lookup uses in-memory AST node ids only. Method ids are derived from
|
||||
* the stable declaring-owner graph key (the Class node id's name segment),
|
||||
* never from persisted tree-sitter node ids.
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { jvmGetterName, jvmSetterName } from '../jvm/beanspec.js';
|
||||
import {
|
||||
createExistingMethodIndex,
|
||||
createJvmAccessorSynthesis,
|
||||
hasExistingMethod,
|
||||
rememberExistingMethodRange,
|
||||
type ExistingMethodIndex,
|
||||
type PlannedJvmAccessor,
|
||||
type PlannedJvmAccessorOwner,
|
||||
type SyntheticAccessorResult,
|
||||
type SyntheticVisibility,
|
||||
} from '../jvm/accessor-synthesis.js';
|
||||
|
||||
const JAVA_TYPE_DECLS = new Set([
|
||||
'class_declaration',
|
||||
'enum_declaration',
|
||||
'interface_declaration',
|
||||
'record_declaration',
|
||||
]);
|
||||
|
||||
// ── Public result types (ParsedSymbol / ParsedNode compatible) ────────────
|
||||
|
||||
export type LombokVisibility = SyntheticVisibility;
|
||||
export type SyntheticSymbol = SyntheticAccessorResult['symbols'][number];
|
||||
export type SyntheticNode = SyntheticAccessorResult['nodes'][number];
|
||||
export type SyntheticRelationship = SyntheticAccessorResult['relationships'][number];
|
||||
export type LombokSynthesisResult = SyntheticAccessorResult;
|
||||
export type PlannedLombokAccessor = PlannedJvmAccessor;
|
||||
|
||||
export interface AccessorConfig {
|
||||
enabled: boolean;
|
||||
visibility: LombokVisibility;
|
||||
}
|
||||
|
||||
interface AccessorsOptions {
|
||||
/** When true, JavaBeans get/set/is prefixes are not used — omit (unsupported). */
|
||||
fluent: boolean;
|
||||
/** When true, field prefixes alter base names — omit (unsupported). */
|
||||
hasPrefix: boolean;
|
||||
/** When true, setters return the declaring type instead of void. */
|
||||
chain: boolean;
|
||||
}
|
||||
|
||||
interface LombokField {
|
||||
name: string;
|
||||
type: string;
|
||||
isStatic: boolean;
|
||||
isFinal: boolean;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
declaratorNode: Parser.SyntaxNode;
|
||||
fieldGetter: AccessorConfig | null;
|
||||
fieldSetter: AccessorConfig | null;
|
||||
accessors: AccessorsOptions;
|
||||
accessorsPresent: boolean;
|
||||
}
|
||||
|
||||
interface LombokClass {
|
||||
node: Parser.SyntaxNode;
|
||||
name: string;
|
||||
classGetter: AccessorConfig | null;
|
||||
classSetter: AccessorConfig | null;
|
||||
classAccessors: AccessorsOptions;
|
||||
fields: LombokField[];
|
||||
existingMethods: ExistingMethodIndex;
|
||||
}
|
||||
|
||||
const LOMBOK_ANNOTATION_PACKAGE = new Map<string, string>([
|
||||
['Data', 'lombok'],
|
||||
['Getter', 'lombok'],
|
||||
['Setter', 'lombok'],
|
||||
['Accessors', 'lombok.experimental'],
|
||||
['Tolerate', 'lombok.experimental'],
|
||||
]);
|
||||
|
||||
export function getterName(fieldName: string, fieldType: string): string {
|
||||
return jvmGetterName(fieldName, fieldType === 'boolean');
|
||||
}
|
||||
|
||||
export function setterName(fieldName: string, fieldType: string): string {
|
||||
return jvmSetterName(fieldName, fieldType === 'boolean');
|
||||
}
|
||||
|
||||
// ── Provenance / imports ──────────────────────────────────────────────────
|
||||
|
||||
function annotationSimpleName(nameText: string): string {
|
||||
return nameText.split('.').pop() ?? nameText;
|
||||
}
|
||||
|
||||
interface LombokImportIndex {
|
||||
bySimple: Map<string, string>;
|
||||
starPackages: Set<string>;
|
||||
shadowedSimpleNames: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compilation-unit imports only — Java `import` is never nested in a type body.
|
||||
*/
|
||||
function collectLombokImports(root: Parser.SyntaxNode): LombokImportIndex {
|
||||
const bySimple = new Map<string, string>();
|
||||
const starPackages = new Set<string>();
|
||||
const shadowedSimpleNames = new Set<string>();
|
||||
for (const child of root.children) {
|
||||
if (!JAVA_TYPE_DECLS.has(child.type) && child.type !== 'annotation_type_declaration') continue;
|
||||
const name = child.childForFieldName('name')?.text;
|
||||
if (name) shadowedSimpleNames.add(name);
|
||||
}
|
||||
for (const child of root.children) {
|
||||
if (child.type !== 'import_declaration') continue;
|
||||
if (/^import\s+static\b/.test(child.text)) continue;
|
||||
const text = child.text
|
||||
.replace(/^import\s+/, '')
|
||||
.replace(/;\s*$/, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.trim();
|
||||
if (text === 'lombok.*') {
|
||||
starPackages.add('lombok');
|
||||
} else if (text === 'lombok.experimental.*') {
|
||||
starPackages.add('lombok.experimental');
|
||||
} else if (!text.endsWith('.*')) {
|
||||
bySimple.set(annotationSimpleName(text), text);
|
||||
}
|
||||
}
|
||||
return { bySimple, starPackages, shadowedSimpleNames };
|
||||
}
|
||||
|
||||
function isProvenLombokAnnotation(nameText: string, imports: LombokImportIndex): boolean {
|
||||
const simple = annotationSimpleName(nameText);
|
||||
const packageName = LOMBOK_ANNOTATION_PACKAGE.get(simple);
|
||||
if (packageName === undefined) return false;
|
||||
if (nameText.includes('.')) return nameText === `${packageName}.${simple}`;
|
||||
const imported = imports.bySimple.get(simple);
|
||||
if (imported !== undefined) return imported === `${packageName}.${simple}`;
|
||||
if (imports.shadowedSimpleNames.has(simple)) return false;
|
||||
return imports.starPackages.has(packageName);
|
||||
}
|
||||
|
||||
// ── AccessLevel / Accessors structural parse ──────────────────────────────
|
||||
|
||||
function parseAccessLevelToken(text: string): LombokVisibility | 'none' | null {
|
||||
const simple = annotationSimpleName(text.trim());
|
||||
switch (simple) {
|
||||
case 'PUBLIC':
|
||||
return 'public';
|
||||
case 'PROTECTED':
|
||||
return 'protected';
|
||||
case 'PRIVATE':
|
||||
return 'private';
|
||||
case 'PACKAGE':
|
||||
case 'MODULE': // treated as package-private for graph metadata
|
||||
return 'package';
|
||||
case 'NONE':
|
||||
return 'none';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findAccessLevelInAnnotation(ann: Parser.SyntaxNode): LombokVisibility | 'none' | null {
|
||||
// Positional: @Getter(AccessLevel.PROTECTED) or @Getter(lombok.AccessLevel.NONE)
|
||||
// Named: @Getter(value = AccessLevel.PRIVATE)
|
||||
const stack: Parser.SyntaxNode[] = [...ann.children];
|
||||
while (stack.length > 0) {
|
||||
const n = stack.pop();
|
||||
if (!n) break;
|
||||
if (n.type === 'field_access' || n.type === 'identifier') {
|
||||
const level = parseAccessLevelToken(n.text);
|
||||
if (level !== null) return level;
|
||||
}
|
||||
for (const c of n.children) stack.push(c);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultAccessors(): AccessorsOptions {
|
||||
return { fluent: false, hasPrefix: false, chain: false };
|
||||
}
|
||||
|
||||
function parseAccessorsAnnotation(ann: Parser.SyntaxNode): AccessorsOptions {
|
||||
const opts = defaultAccessors();
|
||||
const stack: Parser.SyntaxNode[] = [...ann.children];
|
||||
while (stack.length > 0) {
|
||||
const n = stack.pop();
|
||||
if (!n) break;
|
||||
if (n.type === 'element_value_pair') {
|
||||
const key =
|
||||
n.childForFieldName('key')?.text ?? n.children.find((c) => c.type === 'identifier')?.text;
|
||||
const valueNode =
|
||||
n.childForFieldName('value') ??
|
||||
n.children.find(
|
||||
(c) =>
|
||||
c.type === 'true' || c.type === 'false' || c.type === 'element_value_array_initializer',
|
||||
);
|
||||
if (key === 'fluent' && (valueNode?.type === 'true' || valueNode?.type === 'false')) {
|
||||
opts.fluent = valueNode.type === 'true';
|
||||
}
|
||||
if (key === 'chain' && (valueNode?.type === 'true' || valueNode?.type === 'false')) {
|
||||
opts.chain = valueNode.type === 'true';
|
||||
}
|
||||
if (key === 'prefix') opts.hasPrefix = true;
|
||||
}
|
||||
for (const c of n.children) stack.push(c);
|
||||
}
|
||||
const text = ann.text;
|
||||
if (/\bprefix\s*=/.test(text)) opts.hasPrefix = true;
|
||||
if (/\bfluent\s*=\s*true\b/.test(text)) opts.fluent = true;
|
||||
if (/\bfluent\s*=\s*false\b/.test(text)) opts.fluent = false;
|
||||
if (/\bchain\s*=\s*true\b/.test(text)) opts.chain = true;
|
||||
if (/\bchain\s*=\s*false\b/.test(text)) opts.chain = false;
|
||||
return opts;
|
||||
}
|
||||
|
||||
interface ParsedAnnotations {
|
||||
getter: AccessorConfig | null;
|
||||
setter: AccessorConfig | null;
|
||||
accessors: AccessorsOptions;
|
||||
accessorsPresent: boolean;
|
||||
tolerate: boolean;
|
||||
}
|
||||
|
||||
function parseModifierAnnotations(
|
||||
modifiersNode: Parser.SyntaxNode | null,
|
||||
imports: LombokImportIndex,
|
||||
): ParsedAnnotations {
|
||||
const result: ParsedAnnotations = {
|
||||
getter: null,
|
||||
setter: null,
|
||||
accessors: defaultAccessors(),
|
||||
accessorsPresent: false,
|
||||
tolerate: false,
|
||||
};
|
||||
if (!modifiersNode) return result;
|
||||
|
||||
for (const child of modifiersNode.children) {
|
||||
if (child.type !== 'marker_annotation' && child.type !== 'annotation') continue;
|
||||
const nameNode = child.childForFieldName('name');
|
||||
const nameText = nameNode?.text ?? '';
|
||||
if (!isProvenLombokAnnotation(nameText, imports)) continue;
|
||||
const simple = annotationSimpleName(nameText);
|
||||
|
||||
if (simple === 'Tolerate') {
|
||||
result.tolerate = true;
|
||||
continue;
|
||||
}
|
||||
if (simple === 'Accessors') {
|
||||
result.accessors = parseAccessorsAnnotation(child);
|
||||
result.accessorsPresent = true;
|
||||
continue;
|
||||
}
|
||||
if (simple === 'Data') {
|
||||
result.getter ??= { enabled: true, visibility: 'public' };
|
||||
result.setter ??= { enabled: true, visibility: 'public' };
|
||||
continue;
|
||||
}
|
||||
if (simple === 'Getter' || simple === 'Setter') {
|
||||
const level = child.type === 'annotation' ? findAccessLevelInAnnotation(child) : null;
|
||||
const cfg: AccessorConfig =
|
||||
level === 'none'
|
||||
? { enabled: false, visibility: 'public' }
|
||||
: { enabled: true, visibility: level ?? 'public' };
|
||||
if (simple === 'Getter') result.getter = cfg;
|
||||
else result.setter = cfg;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeAccessors(
|
||||
classOpts: AccessorsOptions,
|
||||
fieldOpts: AccessorsOptions,
|
||||
fieldAccessorsPresent: boolean,
|
||||
): AccessorsOptions {
|
||||
return fieldAccessorsPresent ? fieldOpts : classOpts;
|
||||
}
|
||||
|
||||
function effectiveAccessor(
|
||||
classCfg: AccessorConfig | null,
|
||||
fieldCfg: AccessorConfig | null,
|
||||
): AccessorConfig | null {
|
||||
if (fieldCfg !== null) return fieldCfg;
|
||||
return classCfg;
|
||||
}
|
||||
|
||||
// ── Field / method collection ─────────────────────────────────────────────
|
||||
|
||||
function parseFieldDeclaration(
|
||||
fieldNode: Parser.SyntaxNode,
|
||||
imports: LombokImportIndex,
|
||||
): LombokField[] {
|
||||
const typeNode = fieldNode.childForFieldName('type');
|
||||
const fieldType = typeNode?.text ?? 'Object';
|
||||
const modifiers = fieldNode.children.find((c) => c.type === 'modifiers') ?? null;
|
||||
let isStatic = false;
|
||||
let isFinal = false;
|
||||
if (modifiers) {
|
||||
for (const mod of modifiers.children) {
|
||||
if (mod.text === 'static') isStatic = true;
|
||||
else if (mod.text === 'final') isFinal = true;
|
||||
}
|
||||
}
|
||||
const fieldAnn = parseModifierAnnotations(modifiers, imports);
|
||||
|
||||
const declarators: Parser.SyntaxNode[] = [];
|
||||
const declaratorField = fieldNode.childForFieldName('declarator');
|
||||
if (declaratorField) declarators.push(declaratorField);
|
||||
for (const child of fieldNode.children) {
|
||||
if (child.type === 'variable_declarator' && child !== declaratorField) {
|
||||
declarators.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const startLine = fieldNode.startPosition.row + 1;
|
||||
const endLine = fieldNode.endPosition.row + 1;
|
||||
const out: LombokField[] = [];
|
||||
for (const declaratorNode of declarators) {
|
||||
const nameNode = declaratorNode.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
out.push({
|
||||
name: nameNode.text,
|
||||
type: fieldType,
|
||||
isStatic,
|
||||
isFinal,
|
||||
startLine,
|
||||
endLine,
|
||||
declaratorNode,
|
||||
fieldGetter: fieldAnn.getter,
|
||||
fieldSetter: fieldAnn.setter,
|
||||
accessors: fieldAnn.accessors,
|
||||
accessorsPresent: fieldAnn.accessorsPresent,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function methodArityRange(methodNode: Parser.SyntaxNode): { min: number; max: number } {
|
||||
const params = methodNode.childForFieldName('parameters');
|
||||
if (!params) return { min: 0, max: 0 };
|
||||
let count = 0;
|
||||
for (const child of params.namedChildren) {
|
||||
if (child.type === 'spread_parameter') return { min: count, max: Number.POSITIVE_INFINITY };
|
||||
if (child.type === 'formal_parameter') count += 1;
|
||||
}
|
||||
return { min: count, max: count };
|
||||
}
|
||||
|
||||
function collectExistingMethods(
|
||||
classBody: Parser.SyntaxNode | null,
|
||||
imports: LombokImportIndex,
|
||||
): ExistingMethodIndex {
|
||||
const index = createExistingMethodIndex('case-folded');
|
||||
if (!classBody) return index;
|
||||
const scan = (container: Parser.SyntaxNode): void => {
|
||||
for (const child of container.children) {
|
||||
if (child.type === 'enum_body_declarations') {
|
||||
scan(child);
|
||||
continue;
|
||||
}
|
||||
if (child.type !== 'method_declaration') continue;
|
||||
const mods = child.children.find((c) => c.type === 'modifiers') ?? null;
|
||||
const ann = parseModifierAnnotations(mods, imports);
|
||||
if (ann.tolerate) continue;
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const arity = methodArityRange(child);
|
||||
rememberExistingMethodRange(index, nameNode.text, arity.min, arity.max);
|
||||
}
|
||||
};
|
||||
scan(classBody);
|
||||
return index;
|
||||
}
|
||||
|
||||
const TYPE_BODIES = new Set(['class_body', 'enum_body']);
|
||||
|
||||
function findTypeBody(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
return node.children.find((c) => TYPE_BODIES.has(c.type)) ?? null;
|
||||
}
|
||||
|
||||
function findLombokClasses(root: Parser.SyntaxNode, imports: LombokImportIndex): LombokClass[] {
|
||||
const classes: LombokClass[] = [];
|
||||
|
||||
function walk(node: Parser.SyntaxNode): void {
|
||||
if (node.type === 'class_declaration' || node.type === 'enum_declaration') {
|
||||
const modifiers = node.children.find((c) => c.type === 'modifiers') ?? null;
|
||||
const classAnn = parseModifierAnnotations(modifiers, imports);
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const className = nameNode?.text ?? '';
|
||||
if (className) {
|
||||
const body = findTypeBody(node);
|
||||
const fields: LombokField[] = [];
|
||||
if (body) {
|
||||
const collectFields = (container: Parser.SyntaxNode): void => {
|
||||
for (const child of container.children) {
|
||||
if (child.type === 'field_declaration') {
|
||||
for (const f of parseFieldDeclaration(child, imports)) {
|
||||
if (f.isStatic) continue;
|
||||
fields.push(f);
|
||||
}
|
||||
} else if (child.type === 'enum_body_declarations') {
|
||||
collectFields(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectFields(body);
|
||||
}
|
||||
|
||||
const anyFieldEnable = fields.some(
|
||||
(f) => f.fieldGetter?.enabled === true || f.fieldSetter?.enabled === true,
|
||||
);
|
||||
const classEnable = classAnn.getter?.enabled === true || classAnn.setter?.enabled === true;
|
||||
|
||||
// Class-level NONE alone is not enable — getter/setter configs may be disabled
|
||||
if (classEnable || anyFieldEnable) {
|
||||
classes.push({
|
||||
node,
|
||||
name: className,
|
||||
classGetter: classAnn.getter,
|
||||
classSetter: classAnn.setter,
|
||||
classAccessors: classAnn.accessors,
|
||||
fields,
|
||||
existingMethods: collectExistingMethods(body, imports),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return classes;
|
||||
}
|
||||
|
||||
function planAccessors(cls: LombokClass): PlannedLombokAccessor[] {
|
||||
const planned: PlannedLombokAccessor[] = [];
|
||||
for (const field of cls.fields) {
|
||||
const accessors = mergeAccessors(cls.classAccessors, field.accessors, field.accessorsPresent);
|
||||
// fluent/prefix change names — omit rather than invent wrong names
|
||||
if (accessors.fluent || accessors.hasPrefix) continue;
|
||||
|
||||
const getterCfg = effectiveAccessor(cls.classGetter, field.fieldGetter);
|
||||
const setterCfg = effectiveAccessor(cls.classSetter, field.fieldSetter);
|
||||
|
||||
if (getterCfg?.enabled) {
|
||||
const gName = getterName(field.name, field.type);
|
||||
if (!hasExistingMethod(cls.existingMethods, gName, 0)) {
|
||||
planned.push({
|
||||
kind: 'getter',
|
||||
name: gName,
|
||||
returnType: field.type,
|
||||
parameterTypes: [],
|
||||
visibility: getterCfg.visibility,
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
startLine: field.startLine,
|
||||
endLine: field.endLine,
|
||||
declaratorNode: field.declaratorNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (setterCfg?.enabled && !field.isFinal) {
|
||||
const sName = setterName(field.name, field.type);
|
||||
if (!hasExistingMethod(cls.existingMethods, sName, 1)) {
|
||||
// chain=true → setter returns declaring type; never emit void in that case
|
||||
const returnType = accessors.chain ? cls.name : 'void';
|
||||
planned.push({
|
||||
kind: 'setter',
|
||||
name: sName,
|
||||
returnType,
|
||||
parameterTypes: [field.type],
|
||||
visibility: setterCfg.visibility,
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
startLine: field.startLine,
|
||||
endLine: field.endLine,
|
||||
declaratorNode: field.declaratorNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
function planLombokAccessorOwners(root: Parser.SyntaxNode): PlannedJvmAccessorOwner[] {
|
||||
const imports = collectLombokImports(root);
|
||||
return findLombokClasses(root, imports).map((cls) => ({
|
||||
node: cls.node,
|
||||
name: cls.name,
|
||||
accessors: planAccessors(cls),
|
||||
}));
|
||||
}
|
||||
|
||||
const lombokAccessorSynthesis = createJvmAccessorSynthesis({
|
||||
language: 'java',
|
||||
synthetic: 'lombok',
|
||||
planOwners: planLombokAccessorOwners,
|
||||
});
|
||||
|
||||
// ── Main API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function synthesizeLombokAccessors(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: ReadonlyMap<number, string>,
|
||||
): LombokSynthesisResult {
|
||||
return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById);
|
||||
}
|
||||
|
||||
/** Scope captures for Lombok accessors (dual-path parity with record components). */
|
||||
export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] {
|
||||
return lombokAccessorSynthesis.captures(rootNode);
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
|
|||
import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js';
|
||||
import { attachJavaSpringDiMetadata } from './spring-di.js';
|
||||
import { attachJavaSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
|
||||
import { attachJavaSpringDynamicLookup } from './spring-dynamic-lookup.js';
|
||||
import {
|
||||
applyJavaCaptureSideChannel,
|
||||
clearJavaClassAnnotationFacts,
|
||||
|
|
@ -97,6 +98,7 @@ const javaScopeResolver: ScopeResolver = {
|
|||
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
|
||||
attachJavaSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { RuntimeCallableIdentity, RuntimeSymbolStrategy } from '../../language-provider.js';
|
||||
|
||||
const JVM_PRIMITIVES: Readonly<Record<string, string>> = {
|
||||
B: 'byte',
|
||||
C: 'char',
|
||||
D: 'double',
|
||||
F: 'float',
|
||||
I: 'int',
|
||||
J: 'long',
|
||||
S: 'short',
|
||||
Z: 'boolean',
|
||||
};
|
||||
|
||||
function normalizedType(value: string, runtime: boolean): string {
|
||||
let erased = value.trim();
|
||||
let arrayDimensions = 0;
|
||||
if (erased.endsWith('...')) {
|
||||
arrayDimensions++;
|
||||
erased = erased.slice(0, -3);
|
||||
}
|
||||
while (erased.endsWith('[]')) {
|
||||
arrayDimensions++;
|
||||
erased = erased.slice(0, -2);
|
||||
}
|
||||
erased = erased.replace(/<.*>$/, '').replaceAll('$', '.').replaceAll('/', '.');
|
||||
const simple = erased.slice(erased.lastIndexOf('.') + 1);
|
||||
const base = runtime ? (JVM_PRIMITIVES[simple] ?? simple) : simple;
|
||||
return `${base}${'[]'.repeat(arrayDimensions)}`;
|
||||
}
|
||||
|
||||
function sourceTypeIsUnknown(value: string): boolean {
|
||||
const type = normalizedType(value, false).replace(/(?:\[\])+$/, '');
|
||||
return type === '?' || /^[A-Z]$/.test(type);
|
||||
}
|
||||
|
||||
function matchesJavaCallable(node: GraphNode, runtime: RuntimeCallableIdentity): boolean {
|
||||
if (node.label !== 'Method' || node.properties.name !== runtime.name) return false;
|
||||
|
||||
const descriptorTypes = runtime.descriptorParameterTypes;
|
||||
if (descriptorTypes === undefined) return true;
|
||||
|
||||
const parameterCount = node.properties.parameterCount;
|
||||
if (typeof parameterCount === 'number' && parameterCount !== descriptorTypes.length) return false;
|
||||
|
||||
const sourceTypes = node.properties.parameterTypes;
|
||||
if (
|
||||
!Array.isArray(sourceTypes) ||
|
||||
sourceTypes.length !== descriptorTypes.length ||
|
||||
!sourceTypes.every((type): type is string => typeof type === 'string')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return sourceTypes.every((sourceType, index) => {
|
||||
if (sourceTypeIsUnknown(sourceType)) return true;
|
||||
const source = normalizedType(sourceType, false);
|
||||
const descriptor = normalizedType(descriptorTypes[index] ?? '', true);
|
||||
if (source === descriptor) return true;
|
||||
// Java parser metadata currently drops the ellipsis from varargs and also
|
||||
// leaves parameterCount open-ended. Only in that shape may T match JVM T[].
|
||||
return (
|
||||
typeof parameterCount !== 'number' &&
|
||||
descriptor.endsWith('[]') &&
|
||||
source === descriptor.slice(0, -2)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const javaRuntimeSymbolStrategy: RuntimeSymbolStrategy = {
|
||||
matchesCallable: matchesJavaCallable,
|
||||
};
|
||||
|
|
@ -12,13 +12,72 @@ import {
|
|||
hasSpringBeanFactorySyntax,
|
||||
type SpringBeanFactoryMethodFact,
|
||||
} from '../../frameworks/spring/bean-factories.js';
|
||||
import {
|
||||
normalizeSpringFactText,
|
||||
type SpringArgumentFact,
|
||||
} from '../../frameworks/spring/argument-facts.js';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { hasRecoveredSyntax, nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
import { getJavaSpringDiFacts } from './capture-side-channel.js';
|
||||
|
||||
export interface JavaAnnotationSyntaxFact extends SpringDiAnnotationFact {
|
||||
readonly line: number;
|
||||
/** Present only for callers that opt in via `javaSpringAnnotationFacts`. */
|
||||
readonly args?: readonly SpringArgumentFact[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `javaSpringAnnotationFacts`.
|
||||
*
|
||||
* The STRUCTURED arguments are opt-in because DI captures every annotated
|
||||
* field, constructor, and method in the repository, and none of its consumers
|
||||
* reads them. Note what this does and does not save: every fact already carries
|
||||
* `text`, the annotation's full source, so the argument TEXT crosses the worker
|
||||
* boundary either way. What the opt-in avoids is a second, parsed copy of that
|
||||
* same text on facts that would never look at it.
|
||||
*/
|
||||
export interface JavaSpringAnnotationFactOptions {
|
||||
readonly includeArguments?: boolean;
|
||||
}
|
||||
|
||||
const JAVA_COMMENT_NODE_TYPES = new Set(['line_comment', 'block_comment']);
|
||||
|
||||
/**
|
||||
* Annotation arguments as written, or `undefined` for a marker annotation.
|
||||
*
|
||||
* `@Scheduled` yields `undefined` (no argument list in the syntax) while
|
||||
* `@Scheduled()` yields `[]` (an empty list was written). Named arguments keep
|
||||
* their key, single-element ones stay positional, and array initializers are
|
||||
* kept as one raw `{...}` text — splitting or dereferencing them would be
|
||||
* resolution, which does not belong at capture time.
|
||||
*
|
||||
* An argument list that did not parse also yields `undefined`. Error recovery
|
||||
* fills gaps with invented nodes — `@KafkaListener(topics = "orders", groupId =`
|
||||
* hands back a `groupId` whose value is a `{}` that nobody wrote — and there is
|
||||
* no fourth state here for "unreadable". Collapsing it into the marker case is
|
||||
* deliberate: both tell a consumer there is nothing here to resolve, which is
|
||||
* true, whereas a fabricated value would send it somewhere real and wrong.
|
||||
*/
|
||||
function javaAnnotationArgumentFacts(annotation: SyntaxNode): SpringArgumentFact[] | undefined {
|
||||
const argumentList = annotation.childForFieldName('arguments');
|
||||
if (argumentList === null || hasRecoveredSyntax(argumentList)) return undefined;
|
||||
const args: SpringArgumentFact[] = [];
|
||||
for (const child of argumentList.namedChildren) {
|
||||
if (JAVA_COMMENT_NODE_TYPES.has(child.type)) continue;
|
||||
if (child.type === 'element_value_pair') {
|
||||
const key = child.childForFieldName('key');
|
||||
const value = child.childForFieldName('value');
|
||||
if (key === null || value === null) {
|
||||
args.push({ text: normalizeSpringFactText(child.text) });
|
||||
continue;
|
||||
}
|
||||
args.push({ name: key.text.trim(), text: normalizeSpringFactText(value.text) });
|
||||
continue;
|
||||
}
|
||||
args.push({ text: normalizeSpringFactText(child.text) });
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
|
||||
|
|
@ -36,7 +95,10 @@ export type JavaSpringDiClassFact = SpringDiClassFact<
|
|||
>;
|
||||
type JavaSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact<JavaAnnotationSyntaxFact>;
|
||||
|
||||
export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
||||
export function javaSpringAnnotationFacts(
|
||||
node: SyntaxNode,
|
||||
options: JavaSpringAnnotationFactOptions = {},
|
||||
): JavaAnnotationSyntaxFact[] {
|
||||
const facts: JavaAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers') continue;
|
||||
|
|
@ -44,10 +106,13 @@ export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSynta
|
|||
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
|
||||
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
|
||||
if (nameNode === null) continue;
|
||||
const args =
|
||||
options.includeArguments === true ? javaAnnotationArgumentFacts(modifier) : undefined;
|
||||
facts.push({
|
||||
name: nameNode.text.trim(),
|
||||
text: modifier.text.trim(),
|
||||
line: modifier.startPosition.row + 1,
|
||||
...(args === undefined ? {} : { args }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
createSpringDynamicLookupMetadataAttacher,
|
||||
springDynamicLookupCardinality,
|
||||
type SpringDynamicLookupFact,
|
||||
} from '../../frameworks/spring/dynamic-lookups.js';
|
||||
import {
|
||||
findAncestorBeforeBoundary,
|
||||
nodeToCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getJavaSpringDynamicLookupFacts } from './capture-side-channel.js';
|
||||
|
||||
const CALLABLE_NODE_TYPES = new Set([
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
]);
|
||||
const NO_CALLABLE_BOUNDARIES = new Set<string>();
|
||||
|
||||
function classLiteralTypeName(argument: SyntaxNode): string | null {
|
||||
if (argument.type !== 'class_literal' || argument.namedChildCount !== 1) return null;
|
||||
return argument.namedChild(0)?.text.trim() ?? null;
|
||||
}
|
||||
|
||||
/** Capture real Java method invocations; comments and literals are never visited as calls. */
|
||||
export function captureJavaSpringDynamicLookupFact(
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): SpringDynamicLookupFact | null {
|
||||
if (node.type !== 'method_invocation') return null;
|
||||
const receiverName = node.childForFieldName('object')?.text.trim();
|
||||
const methodName = node.childForFieldName('name')?.text.trim();
|
||||
const argumentsNode = node.childForFieldName('arguments');
|
||||
if (receiverName === undefined || methodName === undefined || argumentsNode === null) return null;
|
||||
if (springDynamicLookupCardinality(receiverName, methodName) === null) return null;
|
||||
|
||||
const argumentsWithoutComments = argumentsNode.namedChildren.filter(
|
||||
(child) => child.type !== 'line_comment' && child.type !== 'block_comment',
|
||||
);
|
||||
if (argumentsWithoutComments.length !== 1) return null;
|
||||
const argument = argumentsWithoutComments[0];
|
||||
if (argument === undefined) return null;
|
||||
const targetTypeName = classLiteralTypeName(argument);
|
||||
if (targetTypeName === null) return null;
|
||||
|
||||
const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES);
|
||||
if (owner === null) return null;
|
||||
const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner);
|
||||
return {
|
||||
ownerScopeId: makeScopeId({
|
||||
filePath,
|
||||
range: ownerCapture.range,
|
||||
kind: 'Function',
|
||||
}),
|
||||
ownerRange: ownerCapture.range,
|
||||
receiverName,
|
||||
methodName,
|
||||
targetTypeName,
|
||||
};
|
||||
}
|
||||
|
||||
/** Standalone extractor for focused tests; production reuses scope-query call nodes. */
|
||||
export function captureJavaSpringDynamicLookupFacts(
|
||||
rootNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): SpringDynamicLookupFact[] {
|
||||
return rootNode
|
||||
.descendantsOfType('method_invocation')
|
||||
.map((node) => captureJavaSpringDynamicLookupFact(node, filePath))
|
||||
.filter((fact): fact is SpringDynamicLookupFact => fact !== null);
|
||||
}
|
||||
|
||||
/** Attach Java lookup facts for later resolution by the shared DI phase. */
|
||||
export const attachJavaSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({
|
||||
getFacts: getJavaSpringDynamicLookupFacts,
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
normalizeSpringFactText,
|
||||
type SpringArgumentFact,
|
||||
} from '../../frameworks/spring/argument-facts.js';
|
||||
import {
|
||||
isSpringMessageProducerMethod,
|
||||
springMessageProducerTemplateOf,
|
||||
type SpringMessageProducerFact,
|
||||
} from '../../frameworks/spring/message-producers.js';
|
||||
import {
|
||||
findAncestorBeforeBoundary,
|
||||
hasRecoveredSyntax,
|
||||
nodeToCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
|
||||
const CALLABLE_NODE_TYPES = new Set([
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
]);
|
||||
/**
|
||||
* A type body ends the search for the publishing callable.
|
||||
*
|
||||
* Without it the ancestor walk passes THROUGH the body of a class declared
|
||||
* inside a method, so a publish in that class's field initializer is attributed
|
||||
* to the enclosing method, which may never run it. The identical construct at
|
||||
* the top level of a class already yields no fact — there is no enclosing
|
||||
* callable to find — and the rule has to read the same at every depth.
|
||||
*/
|
||||
const TYPE_BODY_BOUNDARIES = new Set([
|
||||
'class_body',
|
||||
'interface_body',
|
||||
'enum_body',
|
||||
'enum_body_declarations',
|
||||
'annotation_type_body',
|
||||
]);
|
||||
const COMMENT_NODE_TYPES = new Set(['line_comment', 'block_comment']);
|
||||
|
||||
/** Java has no named call arguments, so every argument is captured positionally. */
|
||||
function javaCallArgumentFacts(argumentList: SyntaxNode): SpringArgumentFact[] {
|
||||
return argumentList.namedChildren
|
||||
.filter((child) => !COMMENT_NODE_TYPES.has(child.type))
|
||||
.map((child) => ({ text: normalizeSpringFactText(child.text) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one messaging-template publish from a Java call already surfaced by
|
||||
* the scope query, without resolving the destination it names.
|
||||
*
|
||||
* The destination argument may be a literal, a reference to a constant that
|
||||
* lives in another file, or a `${...}` placeholder resolved from configuration;
|
||||
* all three are recorded as written and left to a later phase.
|
||||
*
|
||||
* A call whose argument list did not parse yields NO fact. The fact exists to
|
||||
* carry a destination, and error recovery invents argument boundaries — an
|
||||
* unterminated `send(TOPIC,` absorbs the next declaration's source and offers
|
||||
* it as an argument. There is no state on this fact that means "published
|
||||
* somewhere unreadable", so the choice is between silence and a plausible lie,
|
||||
* and silence is recoverable: the file is re-captured when it parses.
|
||||
*/
|
||||
export function captureJavaSpringMessageProducerFact(
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): SpringMessageProducerFact | null {
|
||||
if (node.type !== 'method_invocation') return null;
|
||||
const methodName = node.childForFieldName('name')?.text.trim();
|
||||
if (methodName === undefined || !isSpringMessageProducerMethod(methodName)) return null;
|
||||
const receiverText = node.childForFieldName('object')?.text;
|
||||
if (receiverText === undefined) return null;
|
||||
const receiverName = normalizeSpringFactText(receiverText);
|
||||
const template = springMessageProducerTemplateOf(receiverName, methodName);
|
||||
if (template === null) return null;
|
||||
|
||||
const argumentList = node.childForFieldName('arguments');
|
||||
if (argumentList !== null && hasRecoveredSyntax(argumentList)) return null;
|
||||
const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, TYPE_BODY_BOUNDARIES);
|
||||
if (owner === null) return null;
|
||||
const ownerCapture = nodeToCapture('@spring-message-producer.owner', owner);
|
||||
return {
|
||||
ownerScopeId: makeScopeId({ filePath, range: ownerCapture.range, kind: 'Function' }),
|
||||
ownerRange: ownerCapture.range,
|
||||
template,
|
||||
receiverName,
|
||||
methodName,
|
||||
...(argumentList === null ? {} : { args: javaCallArgumentFacts(argumentList) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Standalone extractor for focused tests; production reuses scope-query call nodes. */
|
||||
export function captureJavaSpringMessageProducerFacts(
|
||||
rootNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): SpringMessageProducerFact[] {
|
||||
return rootNode
|
||||
.descendantsOfType('method_invocation')
|
||||
.map((node) => captureJavaSpringMessageProducerFact(node, filePath))
|
||||
.filter((fact): fact is SpringMessageProducerFact => fact !== null);
|
||||
}
|
||||
|
|
@ -11,7 +11,17 @@ import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spri
|
|||
|
||||
export type JavaSpringNonHttpHandlerFact = SpringNonHttpHandlerFact<JavaAnnotationSyntaxFact>;
|
||||
|
||||
/** Capture callable syntax while the Java class AST is already in hand. */
|
||||
/**
|
||||
* Capture callable syntax while the Java class AST is already in hand.
|
||||
*
|
||||
* Annotation arguments are read in a second pass, only for callables that
|
||||
* already carry a handler annotation, so the destination-bearing arguments
|
||||
* (`topics`, `queues`, `destination`, `cron`) reach the fact without adding
|
||||
* structured argument text to every annotation in the repository. Java can
|
||||
* decide that on the simple name alone; Kotlin runs the same two passes but
|
||||
* widens the first one with the file's import aliases, because a Kotlin handler
|
||||
* annotation may be written under a name no list can contain.
|
||||
*/
|
||||
export function captureJavaSpringNonHttpHandlerFacts(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
|
|
@ -21,8 +31,8 @@ export function captureJavaSpringNonHttpHandlerFacts(
|
|||
if (body === null) return facts;
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type !== 'method_declaration') continue;
|
||||
const annotations = javaSpringAnnotationFacts(member);
|
||||
if (!hasSpringNonHttpHandlerRelevantAnnotation(annotations)) continue;
|
||||
if (!hasSpringNonHttpHandlerRelevantAnnotation(javaSpringAnnotationFacts(member))) continue;
|
||||
const annotations = javaSpringAnnotationFacts(member, { includeArguments: true });
|
||||
const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range;
|
||||
facts.push({
|
||||
ownerScopeId: makeScopeId({ filePath, range: ownerRange, kind: 'Function' }),
|
||||
|
|
|
|||
316
gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts
Normal file
316
gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Shared planning orchestration and emission for synthetic JVM accessors.
|
||||
*
|
||||
* Language adapters discover accessor plans. This module owns method-collision
|
||||
* policy, graph emission, and scope captures without naming any language.
|
||||
*/
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { toZeroBasedLine } from '../../utils/line-base.js';
|
||||
|
||||
export type SyntheticVisibility = 'public' | 'protected' | 'private' | 'package';
|
||||
export type MethodNameMatching = 'exact' | 'case-folded';
|
||||
|
||||
export interface ExistingMethodIndex {
|
||||
readonly matching: MethodNameMatching;
|
||||
readonly aritiesByName: Map<string, Set<number>>;
|
||||
readonly arityRangesByName: Map<string, Array<{ min: number; max: number }>>;
|
||||
}
|
||||
|
||||
export function createExistingMethodIndex(matching: MethodNameMatching): ExistingMethodIndex {
|
||||
return { matching, aritiesByName: new Map(), arityRangesByName: new Map() };
|
||||
}
|
||||
|
||||
function methodKey(index: ExistingMethodIndex, name: string): string {
|
||||
return index.matching === 'case-folded' ? name.toLowerCase() : name;
|
||||
}
|
||||
|
||||
export function rememberExistingMethod(
|
||||
index: ExistingMethodIndex,
|
||||
name: string,
|
||||
arity: number,
|
||||
): void {
|
||||
const key = methodKey(index, name);
|
||||
let arities = index.aritiesByName.get(key);
|
||||
if (!arities) {
|
||||
arities = new Set();
|
||||
index.aritiesByName.set(key, arities);
|
||||
}
|
||||
arities.add(arity);
|
||||
}
|
||||
|
||||
export function rememberExistingMethodRange(
|
||||
index: ExistingMethodIndex,
|
||||
name: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): void {
|
||||
if (min === max) {
|
||||
rememberExistingMethod(index, name, min);
|
||||
return;
|
||||
}
|
||||
const key = methodKey(index, name);
|
||||
const ranges = index.arityRangesByName.get(key) ?? [];
|
||||
ranges.push({ min, max });
|
||||
index.arityRangesByName.set(key, ranges);
|
||||
}
|
||||
|
||||
export function hasExistingMethod(
|
||||
index: ExistingMethodIndex,
|
||||
name: string,
|
||||
arity: number,
|
||||
): boolean {
|
||||
const key = methodKey(index, name);
|
||||
if (index.aritiesByName.get(key)?.has(arity) === true) return true;
|
||||
return (
|
||||
index.arityRangesByName.get(key)?.some((range) => range.min <= arity && arity <= range.max) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorSymbol {
|
||||
filePath: string;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
type: 'Method';
|
||||
ownerId: string;
|
||||
qualifiedName: string;
|
||||
parameterCount: number;
|
||||
requiredParameterCount: number;
|
||||
parameterTypes: string[];
|
||||
returnType: string;
|
||||
visibility: SyntheticVisibility;
|
||||
isStatic: boolean;
|
||||
isAbstract: boolean;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorNode {
|
||||
id: string;
|
||||
label: 'Method';
|
||||
properties: {
|
||||
name: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
language: string;
|
||||
isExported: boolean;
|
||||
synthetic: string;
|
||||
visibility: SyntheticVisibility;
|
||||
isStatic: boolean;
|
||||
returnType: string;
|
||||
parameterTypes: string[];
|
||||
parameterCount: number;
|
||||
qualifiedName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorRelationship {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: 'HAS_METHOD';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorResult {
|
||||
symbols: SyntheticAccessorSymbol[];
|
||||
nodes: SyntheticAccessorNode[];
|
||||
relationships: SyntheticAccessorRelationship[];
|
||||
}
|
||||
|
||||
export interface PlannedJvmAccessor {
|
||||
kind: 'getter' | 'setter';
|
||||
name: string;
|
||||
returnType: string;
|
||||
parameterTypes: string[];
|
||||
visibility: SyntheticVisibility;
|
||||
isStatic: boolean;
|
||||
isAbstract: boolean;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
declaratorNode: Parser.SyntaxNode;
|
||||
}
|
||||
|
||||
export interface PlannedJvmAccessorOwner {
|
||||
node: Parser.SyntaxNode;
|
||||
name: string;
|
||||
accessors: readonly PlannedJvmAccessor[];
|
||||
}
|
||||
|
||||
interface JvmAccessorSynthesisConfig {
|
||||
language: string;
|
||||
synthetic: string;
|
||||
planOwners(rootNode: Parser.SyntaxNode): readonly PlannedJvmAccessorOwner[];
|
||||
}
|
||||
|
||||
export interface JvmAccessorSynthesis {
|
||||
synthesize(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: ReadonlyMap<number, string>,
|
||||
): SyntheticAccessorResult;
|
||||
captures(rootNode: Parser.SyntaxNode): CaptureMatch[];
|
||||
}
|
||||
|
||||
export function createJvmAccessorSynthesis(
|
||||
config: JvmAccessorSynthesisConfig,
|
||||
): JvmAccessorSynthesis {
|
||||
return {
|
||||
synthesize(tree, filePath, classOwnersById) {
|
||||
const result = emptySyntheticAccessorResult();
|
||||
for (const owner of config.planOwners(tree.rootNode)) {
|
||||
const ownerId = classOwnersById.get(owner.node.id);
|
||||
if (!ownerId) continue;
|
||||
emitPlannedAccessors({
|
||||
planned: owner.accessors,
|
||||
filePath,
|
||||
ownerId,
|
||||
idPrefix: ownerIdNamePrefix(ownerId, filePath, owner.name),
|
||||
language: config.language,
|
||||
synthetic: config.synthetic,
|
||||
result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
captures(rootNode) {
|
||||
return capturesForPlannedAccessors(config.planOwners(rootNode));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptySyntheticAccessorResult(): SyntheticAccessorResult {
|
||||
return { symbols: [], nodes: [], relationships: [] };
|
||||
}
|
||||
|
||||
function ownerIdNamePrefix(ownerId: string, filePath: string, fallback: string): string {
|
||||
const needle = `Class:${filePath}:`;
|
||||
if (ownerId.startsWith(needle)) return ownerId.slice(needle.length);
|
||||
const enumNeedle = `Enum:${filePath}:`;
|
||||
if (ownerId.startsWith(enumNeedle)) return ownerId.slice(enumNeedle.length);
|
||||
const ifaceNeedle = `Interface:${filePath}:`;
|
||||
if (ownerId.startsWith(ifaceNeedle)) return ownerId.slice(ifaceNeedle.length);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function jvmTypeSimpleName(node: Parser.SyntaxNode): string | undefined {
|
||||
const named = node.childForFieldName('name')?.text;
|
||||
if (named) return named;
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'type_identifier' || child.type === 'simple_identifier') return child.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function emitPlannedAccessors(args: {
|
||||
planned: readonly PlannedJvmAccessor[];
|
||||
filePath: string;
|
||||
ownerId: string;
|
||||
idPrefix: string;
|
||||
language: string;
|
||||
synthetic: string;
|
||||
result: SyntheticAccessorResult;
|
||||
}): void {
|
||||
const emittedIds = new Set<string>();
|
||||
for (const acc of args.planned) {
|
||||
const arity = acc.parameterTypes.length;
|
||||
const qualifiedName = `${args.idPrefix}.${acc.name}`;
|
||||
const nodeId = `Method:${args.filePath}:${qualifiedName}#${arity}`;
|
||||
if (emittedIds.has(nodeId)) continue;
|
||||
emittedIds.add(nodeId);
|
||||
args.result.nodes.push({
|
||||
id: nodeId,
|
||||
label: 'Method',
|
||||
properties: {
|
||||
name: acc.name,
|
||||
filePath: args.filePath,
|
||||
startLine: toZeroBasedLine(acc.startLine),
|
||||
endLine: toZeroBasedLine(acc.endLine),
|
||||
language: args.language,
|
||||
isExported: false,
|
||||
synthetic: args.synthetic,
|
||||
visibility: acc.visibility,
|
||||
isStatic: acc.isStatic,
|
||||
returnType: acc.returnType,
|
||||
parameterTypes: acc.parameterTypes,
|
||||
parameterCount: arity,
|
||||
qualifiedName,
|
||||
},
|
||||
});
|
||||
args.result.symbols.push({
|
||||
filePath: args.filePath,
|
||||
name: acc.name,
|
||||
nodeId,
|
||||
type: 'Method',
|
||||
ownerId: args.ownerId,
|
||||
qualifiedName,
|
||||
parameterCount: arity,
|
||||
requiredParameterCount: arity,
|
||||
parameterTypes: acc.parameterTypes,
|
||||
returnType: acc.returnType,
|
||||
visibility: acc.visibility,
|
||||
isStatic: acc.isStatic,
|
||||
isAbstract: acc.isAbstract,
|
||||
isFinal: false,
|
||||
});
|
||||
args.result.relationships.push({
|
||||
id: `HAS_METHOD:${args.ownerId}->${nodeId}`,
|
||||
sourceId: args.ownerId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
confidence: 1.0,
|
||||
reason: acc.kind === 'getter' ? `${args.synthetic}-getter` : `${args.synthetic}-setter`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function accessorCapture(name: string, acc: PlannedJvmAccessor, text: string): Capture {
|
||||
const node = acc.declaratorNode;
|
||||
const startLine = node.startPosition.row + 1;
|
||||
const startCol = node.startPosition.column;
|
||||
const endLine = node.endPosition.row + 1;
|
||||
const endCol = acc.kind === 'getter' ? node.endPosition.column : startCol;
|
||||
return { name, range: { startLine, startCol, endLine, endCol }, text };
|
||||
}
|
||||
|
||||
function capturesForPlannedAccessors(owners: readonly PlannedJvmAccessorOwner[]): CaptureMatch[] {
|
||||
const captures: CaptureMatch[] = [];
|
||||
for (const owner of owners) {
|
||||
const enclosing = owner.name;
|
||||
const emitted = new Set<string>();
|
||||
for (const acc of owner.accessors) {
|
||||
const arity = String(acc.parameterTypes.length);
|
||||
const qualifiedName = `${enclosing}.${acc.name}`;
|
||||
const identity = `${qualifiedName}#${arity}`;
|
||||
if (emitted.has(identity)) continue;
|
||||
emitted.add(identity);
|
||||
captures.push({
|
||||
'@scope.function': accessorCapture('@scope.function', acc, acc.name),
|
||||
});
|
||||
captures.push({
|
||||
'@declaration.method': accessorCapture('@declaration.method', acc, acc.name),
|
||||
'@declaration.name': accessorCapture('@declaration.name', acc, acc.name),
|
||||
'@declaration.qualified_name': accessorCapture(
|
||||
'@declaration.qualified_name',
|
||||
acc,
|
||||
qualifiedName,
|
||||
),
|
||||
'@declaration.parameter-count': accessorCapture('@declaration.parameter-count', acc, arity),
|
||||
'@declaration.required-parameter-count': accessorCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
acc,
|
||||
arity,
|
||||
),
|
||||
'@declaration.return-type': accessorCapture(
|
||||
'@declaration.return-type',
|
||||
acc,
|
||||
acc.returnType,
|
||||
),
|
||||
'@declaration.is-synthetic': accessorCapture('@declaration.is-synthetic', acc, 'true'),
|
||||
});
|
||||
}
|
||||
}
|
||||
return captures;
|
||||
}
|
||||
49
gitnexus/src/core/ingestion/languages/jvm/beanspec.ts
Normal file
49
gitnexus/src/core/ingestion/languages/jvm/beanspec.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Language-neutral JVM JavaBeans naming primitives.
|
||||
*
|
||||
* Language adapters choose whether to invent/preserve an `is` prefix and
|
||||
* which single-character capitalization policy their compiler uses.
|
||||
*/
|
||||
|
||||
export function capitalizeBeanName(s: string): string {
|
||||
if (s.length === 0) return s;
|
||||
const first = s.charAt(0);
|
||||
const upper = first.toUpperCase();
|
||||
// Java Character case conversion is one UTF-16 code unit. JavaScript
|
||||
// full-case conversion may expand one unit (`ß` → `SS`), which would invent
|
||||
// a method name no JVM compiler emits.
|
||||
return (upper.length === 1 ? upper : first) + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitive-boolean / Kotlin `is`-prefix fields whose name already starts with
|
||||
* `is` plus a non-lowercase character keep that name for the getter and drop
|
||||
* the `is` prefix for the setter base (`isEnabled` → `isEnabled()` /
|
||||
* `setEnabled(...)`, `is1` → `is1()` / `set1(...)`). Digits and punctuation
|
||||
* count as non-lowercase, matching Lombok `!Character.isLowerCase` and kotlinc.
|
||||
*/
|
||||
export function booleanIsPrefixBase(fieldName: string, useIsPrefix: boolean): string | null {
|
||||
if (!useIsPrefix || !fieldName.startsWith('is') || fieldName.length < 3) return null;
|
||||
const third = fieldName.charAt(2);
|
||||
return third === third.toUpperCase() ? fieldName.slice(2) : null;
|
||||
}
|
||||
|
||||
export function jvmGetterName(
|
||||
fieldName: string,
|
||||
useIsPrefix: boolean,
|
||||
capitalize: (name: string) => string = capitalizeBeanName,
|
||||
): string {
|
||||
if (booleanIsPrefixBase(fieldName, useIsPrefix) !== null) return fieldName;
|
||||
if (useIsPrefix) return `is${capitalize(fieldName)}`;
|
||||
return `get${capitalize(fieldName)}`;
|
||||
}
|
||||
|
||||
export function jvmSetterName(
|
||||
fieldName: string,
|
||||
useIsPrefix: boolean,
|
||||
capitalize: (name: string) => string = capitalizeBeanName,
|
||||
): string {
|
||||
const stripped = booleanIsPrefixBase(fieldName, useIsPrefix);
|
||||
if (stripped !== null) return `set${stripped}`;
|
||||
return `set${capitalize(fieldName)}`;
|
||||
}
|
||||
|
|
@ -24,6 +24,10 @@ import type { SyntaxNode } from '../utils/ast-helpers.js';
|
|||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
|
||||
import { createKotlinCfgVisitor } from '../cfg/visitors/kotlin.js';
|
||||
import {
|
||||
getKotlinSpringMessageProducerFacts,
|
||||
getKotlinSpringNonHttpHandlerFacts,
|
||||
} from './kotlin/capture-side-channel.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { kotlinConfig } from '../field-extractors/configs/jvm.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
|
|
@ -41,6 +45,16 @@ import {
|
|||
kotlinMergeBindings,
|
||||
kotlinReceiverBinding,
|
||||
} from './kotlin/index.js';
|
||||
import { synthesizeLombokAccessors } from './kotlin/lombok-synthesizer.js';
|
||||
import {
|
||||
extractKotlinRuntimeSymbolProperties,
|
||||
kotlinRuntimeSymbolStrategy,
|
||||
} from './kotlin/spring-actuator.js';
|
||||
import { extractKotlinSpringRoutes } from '../route-extractors/kotlin-spring.js';
|
||||
import {
|
||||
extractKotlinModuleConstants,
|
||||
foldKotlinOperands,
|
||||
} from '../route-extractors/kotlin-const-resolver.js';
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
|
|
@ -174,6 +188,8 @@ export const kotlinProvider = defineLanguage({
|
|||
|
||||
// ── KDoc → description (issue #2270) ──
|
||||
descriptionExtractor: createLeadingDocDescriptionExtractor(),
|
||||
definitionPropertiesExtractor: extractKotlinRuntimeSymbolProperties,
|
||||
runtimeSymbolStrategy: kotlinRuntimeSymbolStrategy,
|
||||
|
||||
labelOverride: (functionNode, defaultLabel) => {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
|
|
@ -202,4 +218,23 @@ export const kotlinProvider = defineLanguage({
|
|||
mergeBindings: (_scope, bindings) => kotlinMergeBindings(bindings),
|
||||
receiverBinding: kotlinReceiverBinding,
|
||||
arityCompatibility: kotlinArityCompatibility,
|
||||
synthesizeStructureMembers: synthesizeLombokAccessors,
|
||||
|
||||
// ── Spring decorator routes + composed path constants (#3130) ──
|
||||
extractDecoratorRoutes: extractKotlinSpringRoutes,
|
||||
extractModuleConstants: extractKotlinModuleConstants,
|
||||
foldRoutePathOperands: foldKotlinOperands,
|
||||
|
||||
// Async messaging facts for the `springDestinations` phase. Both stores are
|
||||
// repopulated on the main thread by `applyKotlinCaptureSideChannel`, so this
|
||||
// answers for cache hits and misses alike.
|
||||
getSpringMessagingFacts: (filePath) => ({
|
||||
handlers: getKotlinSpringNonHttpHandlerFacts(filePath),
|
||||
producers: getKotlinSpringMessageProducerFacts(filePath),
|
||||
}),
|
||||
// Kotlin string literals interpolate: `"orders-$env"` and `"orders-${env}"`
|
||||
// are string templates, and a Spring property placeholder has to escape the
|
||||
// dollar (`"\${app.topic}"`). Destination resolution needs this to keep a
|
||||
// runtime template out of the address namespace.
|
||||
interpolatesStringLiterals: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,16 +49,22 @@ import {
|
|||
} from '../jvm/package-facts.js';
|
||||
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
|
||||
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
|
||||
import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
|
||||
import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js';
|
||||
import type { KotlinSpringAopFact } from './spring-aop.js';
|
||||
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
|
||||
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
||||
import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
|
||||
import type { KotlinSpringConfigConsumerFact } from './spring-config-bindings.js';
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springAopFacts = new Map<string, readonly KotlinSpringAopFact[]>();
|
||||
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
|
||||
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
|
||||
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
|
||||
const springNonHttpHandlerFacts = new Map<string, readonly KotlinSpringNonHttpHandlerFact[]>();
|
||||
const springConfigConsumerFacts = new Map<string, readonly KotlinSpringConfigConsumerFact[]>();
|
||||
const springMessageProducerFacts = new Map<string, readonly SpringMessageProducerFact[]>();
|
||||
|
||||
/**
|
||||
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
|
||||
|
|
@ -80,8 +86,14 @@ export interface KotlinCaptureSideChannel {
|
|||
readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[];
|
||||
/** Constructor, property, and method injection syntax captured per class. */
|
||||
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
|
||||
/** Programmatic Spring bean lookups captured per callable. */
|
||||
readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
|
||||
/** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */
|
||||
readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[];
|
||||
/** `@Value` / `@ConfigurationProperties` syntax captured per owner. */
|
||||
readonly springConfigConsumerFacts?: readonly KotlinSpringConfigConsumerFact[];
|
||||
/** Messaging-template publish syntax captured per callable. */
|
||||
readonly springMessageProducerFacts?: readonly SpringMessageProducerFact[];
|
||||
}
|
||||
|
||||
export function clearKotlinClassAnnotationFacts(): void {
|
||||
|
|
@ -89,7 +101,10 @@ export function clearKotlinClassAnnotationFacts(): void {
|
|||
springAopFacts.clear();
|
||||
springConditionalFacts.clear();
|
||||
springDiFacts.clear();
|
||||
springDynamicLookupFacts.clear();
|
||||
springNonHttpHandlerFacts.clear();
|
||||
springConfigConsumerFacts.clear();
|
||||
springMessageProducerFacts.clear();
|
||||
}
|
||||
|
||||
export function setKotlinSpringAopFacts(
|
||||
|
|
@ -141,6 +156,20 @@ export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringD
|
|||
return springDiFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setKotlinSpringDynamicLookupFacts(
|
||||
filePath: string,
|
||||
facts: readonly SpringDynamicLookupFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springDynamicLookupFacts.delete(filePath);
|
||||
else springDynamicLookupFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getKotlinSpringDynamicLookupFacts(
|
||||
filePath: string,
|
||||
): readonly SpringDynamicLookupFact[] {
|
||||
return springDynamicLookupFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setKotlinSpringNonHttpHandlerFacts(
|
||||
filePath: string,
|
||||
facts: readonly KotlinSpringNonHttpHandlerFact[],
|
||||
|
|
@ -155,6 +184,34 @@ export function getKotlinSpringNonHttpHandlerFacts(
|
|||
return springNonHttpHandlerFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setKotlinSpringConfigConsumerFacts(
|
||||
filePath: string,
|
||||
facts: readonly KotlinSpringConfigConsumerFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springConfigConsumerFacts.delete(filePath);
|
||||
else springConfigConsumerFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getKotlinSpringConfigConsumerFacts(
|
||||
filePath: string,
|
||||
): readonly KotlinSpringConfigConsumerFact[] {
|
||||
return springConfigConsumerFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setKotlinSpringMessageProducerFacts(
|
||||
filePath: string,
|
||||
facts: readonly SpringMessageProducerFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springMessageProducerFacts.delete(filePath);
|
||||
else springMessageProducerFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getKotlinSpringMessageProducerFacts(
|
||||
filePath: string,
|
||||
): readonly SpringMessageProducerFact[] {
|
||||
return springMessageProducerFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
|
||||
* Returns `undefined` when this file recorded no side-channel state at all, so
|
||||
|
|
@ -168,7 +225,10 @@ export function collectKotlinCaptureSideChannel(
|
|||
const aopFacts = springAopFacts.get(filePath) ?? [];
|
||||
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
|
||||
const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
|
||||
const configConsumerFacts = springConfigConsumerFacts.get(filePath) ?? [];
|
||||
const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? [];
|
||||
const packageFact = getKotlinPackageFact(filePath);
|
||||
if (
|
||||
companionScopes.length === 0 &&
|
||||
|
|
@ -176,7 +236,10 @@ export function collectKotlinCaptureSideChannel(
|
|||
aopFacts.length === 0 &&
|
||||
conditionFacts.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
dynamicLookupFacts.length === 0 &&
|
||||
nonHttpHandlerFacts.length === 0 &&
|
||||
configConsumerFacts.length === 0 &&
|
||||
messageProducerFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
return undefined;
|
||||
|
|
@ -189,7 +252,12 @@ export function collectKotlinCaptureSideChannel(
|
|||
...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}),
|
||||
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
|
||||
...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
|
||||
...(configConsumerFacts.length > 0 ? { springConfigConsumerFacts: configConsumerFacts } : {}),
|
||||
...(messageProducerFacts.length > 0
|
||||
? { springMessageProducerFacts: messageProducerFacts }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +283,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
|||
setKotlinSpringAopFacts(parsed.filePath, []);
|
||||
setKotlinSpringConditionalFacts(parsed.filePath, []);
|
||||
setKotlinSpringDiFacts(parsed.filePath, []);
|
||||
setKotlinSpringDynamicLookupFacts(parsed.filePath, []);
|
||||
setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []);
|
||||
setKotlinSpringConfigConsumerFacts(parsed.filePath, []);
|
||||
setKotlinSpringMessageProducerFacts(parsed.filePath, []);
|
||||
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
}
|
||||
|
|
@ -235,10 +306,22 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
|||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
);
|
||||
setKotlinSpringDynamicLookupFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : [],
|
||||
);
|
||||
setKotlinSpringNonHttpHandlerFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [],
|
||||
);
|
||||
setKotlinSpringConfigConsumerFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springConfigConsumerFacts) ? data.springConfigConsumerFacts : [],
|
||||
);
|
||||
setKotlinSpringMessageProducerFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springMessageProducerFacts) ? data.springMessageProducerFacts : [],
|
||||
);
|
||||
setKotlinPackageFact(
|
||||
parsed.filePath,
|
||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||
|
|
|
|||
|
|
@ -23,11 +23,20 @@ import {
|
|||
setKotlinSpringAopFacts,
|
||||
setKotlinSpringConditionalFacts,
|
||||
setKotlinSpringDiFacts,
|
||||
setKotlinSpringDynamicLookupFacts,
|
||||
setKotlinSpringMessageProducerFacts,
|
||||
setKotlinSpringNonHttpHandlerFacts,
|
||||
setKotlinSpringConfigConsumerFacts,
|
||||
} from './capture-side-channel.js';
|
||||
import { captureKotlinPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
|
||||
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
|
||||
import { captureKotlinSpringConfigConsumerFacts } from './spring-config-bindings.js';
|
||||
import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
|
||||
import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
|
||||
import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js';
|
||||
import { captureKotlinSpringMessageProducerFact } from './spring-message-producers.js';
|
||||
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
||||
import { captureKotlinSpringAopFacts, type KotlinSpringAopFact } from './spring-aop.js';
|
||||
import {
|
||||
|
|
@ -107,6 +116,9 @@ export function emitKotlinScopeCaptures(
|
|||
const springNonHttpHandlerFacts: KotlinSpringNonHttpHandlerFact[] = [];
|
||||
const springNonHttpHandlerTypeNodeIds = new Set<number>();
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
const springDynamicLookupFacts: SpringDynamicLookupFact[] = [];
|
||||
const springMessageProducerFacts: SpringMessageProducerFact[] = [];
|
||||
const springMemberCallNodeIds = new Set<number>();
|
||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
||||
|
|
@ -130,6 +142,17 @@ export function emitKotlinScopeCaptures(
|
|||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// One visit per member call node: the same invocation can back several
|
||||
// query matches, and both Spring call-shape captures must see it once.
|
||||
const memberCallNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression');
|
||||
if (memberCallNode !== null && !springMemberCallNodeIds.has(memberCallNode.id)) {
|
||||
springMemberCallNodeIds.add(memberCallNode.id);
|
||||
const lookupFact = captureKotlinSpringDynamicLookupFact(memberCallNode, filePath);
|
||||
if (lookupFact !== null) springDynamicLookupFacts.push(lookupFact);
|
||||
const producerFact = captureKotlinSpringMessageProducerFact(memberCallNode, filePath);
|
||||
if (producerFact !== null) springMessageProducerFacts.push(producerFact);
|
||||
}
|
||||
|
||||
// tree-sitter-kotlin represents both classes and interfaces with
|
||||
// `class_declaration`; `object_declaration` is the separate object form.
|
||||
const springAopTypeNode = [
|
||||
|
|
@ -357,7 +380,14 @@ export function emitKotlinScopeCaptures(
|
|||
setKotlinSpringAopFacts(filePath, springAopFacts);
|
||||
setKotlinSpringConditionalFacts(filePath, springConditionalFacts);
|
||||
setKotlinSpringDiFacts(filePath, springDiFacts);
|
||||
setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
|
||||
setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
|
||||
setKotlinSpringConfigConsumerFacts(
|
||||
filePath,
|
||||
captureKotlinSpringConfigConsumerFacts(tree.rootNode, filePath),
|
||||
);
|
||||
setKotlinSpringMessageProducerFacts(filePath, springMessageProducerFacts);
|
||||
out.push(...synthesizeLombokAccessorCaptures(tree.rootNode));
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,512 @@
|
|||
/**
|
||||
* Kotlin accessor synthesizer (same provider-hook role as Java Lombok).
|
||||
*
|
||||
* kotlinc emits JavaBeans getters/setters for `val`/`var` properties. Those
|
||||
* methods are absent from the tree-sitter AST, so Java (and Kotlin) calls
|
||||
* like `user.getName()` miss CALLS edges. Planning is Kotlin-specific;
|
||||
* naming and Method emission share `jvm/beanspec` + `jvm/accessor-synthesis`.
|
||||
*
|
||||
* ## Supported subset (v1)
|
||||
* - Class / data class / object / companion / interface `val`/`var` properties
|
||||
* (interface accessors without a custom body are abstract JVM methods).
|
||||
* - Primary-constructor `val`/`var` class parameters.
|
||||
* - Names beginning with `is` + a non-lowercase character keep that getter name; all other
|
||||
* properties, including `Boolean`, use `get`.
|
||||
* - Custom `get()`/`set()` bodies still emit their JVM accessor Methods.
|
||||
* - Explicit `fun getX` / `@JvmField` / `const` skip synthesis.
|
||||
* - `@JvmName`-renamed accessors are suppressed until custom-name emission lands.
|
||||
* Unsupported: `@JvmStatic` renaming, file-facade top-level properties.
|
||||
*/
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { booleanIsPrefixBase, jvmGetterName, jvmSetterName } from '../jvm/beanspec.js';
|
||||
import {
|
||||
createExistingMethodIndex,
|
||||
createJvmAccessorSynthesis,
|
||||
hasExistingMethod,
|
||||
jvmTypeSimpleName,
|
||||
rememberExistingMethod,
|
||||
type ExistingMethodIndex,
|
||||
type PlannedJvmAccessor,
|
||||
type PlannedJvmAccessorOwner,
|
||||
type SyntheticAccessorResult,
|
||||
type SyntheticVisibility,
|
||||
} from '../jvm/accessor-synthesis.js';
|
||||
|
||||
const KOTLIN_TYPE_DECLS = new Set(['class_declaration', 'object_declaration', 'companion_object']);
|
||||
|
||||
function capitalizeAscii(name: string): string {
|
||||
const first = name.charAt(0);
|
||||
return first >= 'a' && first <= 'z'
|
||||
? String.fromCharCode(first.charCodeAt(0) - 32) + name.slice(1)
|
||||
: name;
|
||||
}
|
||||
|
||||
export function kotlinGetterName(propertyName: string): string {
|
||||
return jvmGetterName(
|
||||
propertyName,
|
||||
booleanIsPrefixBase(propertyName, true) !== null,
|
||||
capitalizeAscii,
|
||||
);
|
||||
}
|
||||
|
||||
export function kotlinSetterName(propertyName: string): string {
|
||||
return jvmSetterName(propertyName, true, capitalizeAscii);
|
||||
}
|
||||
|
||||
interface KtProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
isVar: boolean;
|
||||
skipGetter: boolean;
|
||||
skipSetter: boolean;
|
||||
getterVisibility: SyntheticVisibility;
|
||||
setterVisibility: SyntheticVisibility;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
propertyNode: Parser.SyntaxNode;
|
||||
declaratorNode: Parser.SyntaxNode;
|
||||
}
|
||||
|
||||
interface KtClass {
|
||||
node: Parser.SyntaxNode;
|
||||
name: string;
|
||||
isStatic: boolean;
|
||||
isInterface: boolean;
|
||||
wasHoisted: boolean;
|
||||
properties: KtProperty[];
|
||||
existingMethods: ExistingMethodIndex;
|
||||
}
|
||||
|
||||
interface KotlinImportIndex {
|
||||
byLocalName: Map<string, string>;
|
||||
shadowedSimpleNames: Set<string>;
|
||||
}
|
||||
|
||||
function collectKotlinImports(root: Parser.SyntaxNode): KotlinImportIndex {
|
||||
const byLocalName = new Map<string, string>();
|
||||
const shadowedSimpleNames = new Set<string>();
|
||||
for (const child of root.children) {
|
||||
if (child.type !== 'class_declaration') continue;
|
||||
const name = jvmTypeSimpleName(child);
|
||||
if (name) shadowedSimpleNames.add(name);
|
||||
}
|
||||
const importList = root.children.find((child) => child.type === 'import_list');
|
||||
for (const child of importList?.children ?? []) {
|
||||
if (child.type !== 'import_header') continue;
|
||||
const text = child.text
|
||||
.replace(/^import\s+/, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
const [pathText, aliasText] = text.split(/\s+as\s+/, 2);
|
||||
const importPath = pathText?.replace(/\s+/g, '');
|
||||
if (!importPath || importPath.endsWith('.*')) continue;
|
||||
const localName = aliasText?.trim() || importPath.split('.').pop();
|
||||
if (localName) byLocalName.set(localName, importPath);
|
||||
}
|
||||
return { byLocalName, shadowedSimpleNames };
|
||||
}
|
||||
|
||||
function annotationUserTypeText(annotation: Parser.SyntaxNode): string {
|
||||
const constructor = annotation.namedChildren.find((c) => c.type === 'constructor_invocation');
|
||||
const userType =
|
||||
constructor?.namedChildren.find((c) => c.type === 'user_type') ??
|
||||
annotation.namedChildren.find((c) => c.type === 'user_type');
|
||||
return userType?.text ?? '';
|
||||
}
|
||||
|
||||
function isKotlinJvmAnnotation(
|
||||
annotation: Parser.SyntaxNode,
|
||||
name: string,
|
||||
imports: KotlinImportIndex,
|
||||
): boolean {
|
||||
const typeText = annotationUserTypeText(annotation);
|
||||
const canonical = `kotlin.jvm.${name}`;
|
||||
if (typeText.includes('.')) return typeText === canonical;
|
||||
const imported = imports.byLocalName.get(typeText);
|
||||
if (imported !== undefined) return imported === canonical;
|
||||
if (imports.shadowedSimpleNames.has(typeText)) return false;
|
||||
return typeText === name;
|
||||
}
|
||||
|
||||
function kotlinVisibility(modifiers: Parser.SyntaxNode | undefined): SyntheticVisibility {
|
||||
if (!modifiers) return 'public';
|
||||
for (const child of modifiers.namedChildren) {
|
||||
if (child.type !== 'visibility_modifier') continue;
|
||||
if (child.text === 'private') return 'private';
|
||||
if (child.text === 'protected') return 'protected';
|
||||
if (child.text === 'internal') return 'package';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
|
||||
function hasJvmField(node: Parser.SyntaxNode, imports: KotlinImportIndex): boolean {
|
||||
const mods = node.children.find((c) => c.type === 'modifiers');
|
||||
return (
|
||||
mods?.namedChildren.some(
|
||||
(child) => child.type === 'annotation' && isKotlinJvmAnnotation(child, 'JvmField', imports),
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
function hasConst(node: Parser.SyntaxNode): boolean {
|
||||
const mods = node.children.find((c) => c.type === 'modifiers');
|
||||
if (
|
||||
mods?.namedChildren.some(
|
||||
(child) => child.type === 'property_modifier' && child.text === 'const',
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return node.namedChildren.some((child) => child.type === 'const');
|
||||
}
|
||||
|
||||
function isVarBinding(node: Parser.SyntaxNode): boolean | null {
|
||||
const kind = node.children.find((c) => c.type === 'binding_pattern_kind');
|
||||
const text = kind?.text;
|
||||
if (text === 'var') return true;
|
||||
if (text === 'val') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferredInitializerType(node: Parser.SyntaxNode): string | undefined {
|
||||
switch (node.type) {
|
||||
case 'string_literal':
|
||||
case 'line_string_literal':
|
||||
case 'multi_line_string_literal':
|
||||
return 'String';
|
||||
case 'character_literal':
|
||||
return 'Char';
|
||||
case 'boolean_literal':
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'Boolean';
|
||||
case 'long_literal':
|
||||
return 'Long';
|
||||
case 'unsigned_literal':
|
||||
return /l$/i.test(node.text) ? 'ULong' : 'UInt';
|
||||
case 'integer_literal':
|
||||
case 'decimal_integer_literal':
|
||||
case 'hex_integer_literal':
|
||||
case 'octal_integer_literal':
|
||||
case 'binary_integer_literal':
|
||||
return 'Int';
|
||||
case 'real_literal':
|
||||
case 'decimal_floating_point_literal':
|
||||
return /f$/i.test(node.text) ? 'Float' : 'Double';
|
||||
case 'prefix_expression': {
|
||||
const operand = node.namedChildren.at(-1);
|
||||
return operand ? inferredInitializerType(operand) : undefined;
|
||||
}
|
||||
case 'call_expression': {
|
||||
const callee = node.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (!callee) return undefined;
|
||||
const first = callee.text.charAt(0);
|
||||
return first !== '' && first === first.toUpperCase() ? callee.text : undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function propertyTypeText(node: Parser.SyntaxNode): string {
|
||||
const declarator =
|
||||
node.type === 'class_parameter'
|
||||
? node
|
||||
: (node.children.find((c) => c.type === 'variable_declaration') ?? node);
|
||||
const colon = declarator.children.find((c) => c.type === ':');
|
||||
let typeNode = colon?.nextNamedSibling ?? null;
|
||||
while (typeNode?.type === 'type_modifiers') typeNode = typeNode.nextNamedSibling;
|
||||
if (typeNode) return typeNode.text;
|
||||
const initializer = node.namedChildren.find(
|
||||
(child) =>
|
||||
child.id !== declarator.id &&
|
||||
child.type !== 'binding_pattern_kind' &&
|
||||
child.type !== 'modifiers',
|
||||
);
|
||||
return initializer ? (inferredInitializerType(initializer) ?? 'unknown') : 'unknown';
|
||||
}
|
||||
|
||||
function propertyNameNode(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
if (node.type === 'class_parameter') {
|
||||
return node.children.find((c) => c.type === 'simple_identifier') ?? null;
|
||||
}
|
||||
const decl = node.children.find((c) => c.type === 'variable_declaration');
|
||||
if (decl) {
|
||||
return decl.children.find((c) => c.type === 'simple_identifier') ?? null;
|
||||
}
|
||||
return node.children.find((c) => c.type === 'simple_identifier') ?? null;
|
||||
}
|
||||
|
||||
function accessorMetadata(
|
||||
prop: Parser.SyntaxNode,
|
||||
propertyVisibility: SyntheticVisibility,
|
||||
imports: KotlinImportIndex,
|
||||
): {
|
||||
getterVisibility: SyntheticVisibility;
|
||||
setterVisibility: SyntheticVisibility;
|
||||
skipGetter: boolean;
|
||||
skipSetter: boolean;
|
||||
} {
|
||||
let getter = propertyVisibility;
|
||||
let setter = propertyVisibility;
|
||||
let skipGetter = false;
|
||||
let skipSetter = false;
|
||||
const propertyModifiers = prop.children.find((c) => c.type === 'modifiers');
|
||||
for (const annotation of propertyModifiers?.namedChildren ?? []) {
|
||||
if (
|
||||
annotation.type !== 'annotation' ||
|
||||
!isKotlinJvmAnnotation(annotation, 'JvmName', imports)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const target = annotation.children.find((c) => c.type === 'use_site_target')?.text;
|
||||
if (target === 'get:') skipGetter = true;
|
||||
if (target === 'set:') skipSetter = true;
|
||||
}
|
||||
const apply = (node: Parser.SyntaxNode): void => {
|
||||
const modifiers = node.children.find((c) => c.type === 'modifiers');
|
||||
if (!modifiers) return;
|
||||
if (node.type === 'getter') getter = kotlinVisibility(modifiers);
|
||||
if (node.type === 'setter') setter = kotlinVisibility(modifiers);
|
||||
if (
|
||||
modifiers.namedChildren.some((annotation) =>
|
||||
isKotlinJvmAnnotation(annotation, 'JvmName', imports),
|
||||
)
|
||||
) {
|
||||
if (node.type === 'getter') skipGetter = true;
|
||||
if (node.type === 'setter') skipSetter = true;
|
||||
}
|
||||
};
|
||||
for (const child of prop.children) {
|
||||
if (child.type === 'getter' || child.type === 'setter') apply(child);
|
||||
}
|
||||
let sib: Parser.SyntaxNode | null = prop.nextNamedSibling;
|
||||
while (sib && (sib.type === 'getter' || sib.type === 'setter')) {
|
||||
apply(sib);
|
||||
sib = sib.nextNamedSibling;
|
||||
}
|
||||
return {
|
||||
getterVisibility: getter,
|
||||
setterVisibility: setter,
|
||||
skipGetter,
|
||||
skipSetter,
|
||||
};
|
||||
}
|
||||
|
||||
function hasKotlinAccessorBody(prop: Parser.SyntaxNode, kind: 'getter' | 'setter'): boolean {
|
||||
const hasBody = (node: Parser.SyntaxNode): boolean =>
|
||||
node.type === kind && node.children.some((child) => child.type === 'function_body');
|
||||
if (prop.children.some(hasBody)) return true;
|
||||
let sib: Parser.SyntaxNode | null = prop.nextNamedSibling;
|
||||
while (sib && (sib.type === 'getter' || sib.type === 'setter')) {
|
||||
if (hasBody(sib)) return true;
|
||||
sib = sib.nextNamedSibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function functionName(node: Parser.SyntaxNode): string | undefined {
|
||||
return node.children.find((c) => c.type === 'simple_identifier')?.text;
|
||||
}
|
||||
|
||||
function functionArity(node: Parser.SyntaxNode): number {
|
||||
const params = node.children.find((c) => c.type === 'function_value_parameters');
|
||||
let arity =
|
||||
node.childForFieldName('receiver') !== null ||
|
||||
node.namedChildren.some((child) => child.type === 'receiver_type')
|
||||
? 1
|
||||
: 0;
|
||||
const modifiers = node.children.find((child) => child.type === 'modifiers');
|
||||
if (
|
||||
modifiers?.namedChildren.some(
|
||||
(child) => child.type === 'function_modifier' && child.text === 'suspend',
|
||||
)
|
||||
) {
|
||||
arity += 1;
|
||||
}
|
||||
for (const child of params?.namedChildren ?? []) {
|
||||
if (child.type === 'parameter' || child.type === 'parameter_with_optional_type') arity += 1;
|
||||
}
|
||||
return arity;
|
||||
}
|
||||
|
||||
function collectExistingMethods(...bodies: Array<Parser.SyntaxNode | null>): ExistingMethodIndex {
|
||||
const index = createExistingMethodIndex('exact');
|
||||
for (const body of bodies) {
|
||||
if (!body) continue;
|
||||
for (const child of body.children) {
|
||||
if (child.type !== 'function_declaration') continue;
|
||||
const name = functionName(child);
|
||||
if (!name) continue;
|
||||
rememberExistingMethod(index, name, functionArity(child));
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function toKtProperty(child: Parser.SyntaxNode, imports: KotlinImportIndex): KtProperty | null {
|
||||
const isVar = isVarBinding(child);
|
||||
if (isVar === null) return null;
|
||||
if (hasJvmField(child, imports) || hasConst(child)) return null;
|
||||
const nameNode = propertyNameNode(child);
|
||||
if (!nameNode) return null;
|
||||
const mods = child.children.find((c) => c.type === 'modifiers');
|
||||
const visibility = kotlinVisibility(mods);
|
||||
const accessor = accessorMetadata(child, visibility, imports);
|
||||
return {
|
||||
name: nameNode.text,
|
||||
type: propertyTypeText(child),
|
||||
isVar,
|
||||
skipGetter: accessor.skipGetter,
|
||||
skipSetter: accessor.skipSetter,
|
||||
getterVisibility: accessor.getterVisibility,
|
||||
setterVisibility: accessor.setterVisibility,
|
||||
startLine: child.startPosition.row + 1,
|
||||
endLine: child.endPosition.row + 1,
|
||||
propertyNode: child,
|
||||
declaratorNode: nameNode,
|
||||
};
|
||||
}
|
||||
|
||||
function collectTypedProperties(
|
||||
parent: Parser.SyntaxNode | null,
|
||||
type: 'class_parameter' | 'property_declaration',
|
||||
imports: KotlinImportIndex,
|
||||
): KtProperty[] {
|
||||
if (!parent) return [];
|
||||
const out: KtProperty[] = [];
|
||||
for (const child of parent.namedChildren) {
|
||||
if (child.type !== type) continue;
|
||||
const prop = toKtProperty(child, imports);
|
||||
if (prop) out.push(prop);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findKtClasses(root: Parser.SyntaxNode, imports: KotlinImportIndex): KtClass[] {
|
||||
const classes: KtClass[] = [];
|
||||
const graphOwnerNode = (node: Parser.SyntaxNode): Parser.SyntaxNode => {
|
||||
if (node.type !== 'companion_object') return node;
|
||||
if (jvmTypeSimpleName(node)) return node;
|
||||
let current = node.parent;
|
||||
while (current && !KOTLIN_TYPE_DECLS.has(current.type)) current = current.parent;
|
||||
return current ?? node;
|
||||
};
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
if (KOTLIN_TYPE_DECLS.has(node.type)) {
|
||||
const ownerNode = graphOwnerNode(node);
|
||||
const name = jvmTypeSimpleName(ownerNode) ?? '';
|
||||
const ctor = node.children.find((c) => c.type === 'primary_constructor') ?? null;
|
||||
const body = node.children.find((c) => c.type === 'class_body') ?? null;
|
||||
if (name) {
|
||||
const properties = [
|
||||
...collectTypedProperties(ctor, 'class_parameter', imports),
|
||||
...collectTypedProperties(body, 'property_declaration', imports),
|
||||
];
|
||||
if (properties.length > 0) {
|
||||
const ownerBody =
|
||||
ownerNode.id === node.id
|
||||
? null
|
||||
: (ownerNode.children.find((child) => child.type === 'class_body') ?? null);
|
||||
classes.push({
|
||||
node: ownerNode,
|
||||
name,
|
||||
isStatic: node.type === 'companion_object',
|
||||
isInterface: node.children.some((child) => child.type === 'interface'),
|
||||
wasHoisted: ownerNode.id !== node.id,
|
||||
properties,
|
||||
existingMethods: collectExistingMethods(body, ownerBody),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (body) {
|
||||
for (const child of body.namedChildren) {
|
||||
if (KOTLIN_TYPE_DECLS.has(child.type)) walk(child);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of node.namedChildren) walk(child);
|
||||
};
|
||||
walk(root);
|
||||
return classes;
|
||||
}
|
||||
|
||||
function planAccessors(cls: KtClass): PlannedJvmAccessor[] {
|
||||
const planned: PlannedJvmAccessor[] = [];
|
||||
for (const prop of cls.properties) {
|
||||
const gName = kotlinGetterName(prop.name);
|
||||
if (!prop.skipGetter && !hasExistingMethod(cls.existingMethods, gName, 0)) {
|
||||
planned.push({
|
||||
kind: 'getter',
|
||||
name: gName,
|
||||
returnType: prop.type,
|
||||
parameterTypes: [],
|
||||
visibility: prop.getterVisibility,
|
||||
isStatic: cls.isStatic,
|
||||
isAbstract: cls.isInterface && !hasKotlinAccessorBody(prop.propertyNode, 'getter'),
|
||||
startLine: prop.startLine,
|
||||
endLine: prop.endLine,
|
||||
declaratorNode: prop.declaratorNode,
|
||||
});
|
||||
}
|
||||
if (prop.isVar && !prop.skipSetter) {
|
||||
const sName = kotlinSetterName(prop.name);
|
||||
if (!hasExistingMethod(cls.existingMethods, sName, 1)) {
|
||||
planned.push({
|
||||
kind: 'setter',
|
||||
name: sName,
|
||||
returnType: 'void',
|
||||
parameterTypes: [prop.type],
|
||||
visibility: prop.setterVisibility,
|
||||
isStatic: cls.isStatic,
|
||||
isAbstract: cls.isInterface && !hasKotlinAccessorBody(prop.propertyNode, 'setter'),
|
||||
startLine: prop.startLine,
|
||||
endLine: prop.endLine,
|
||||
declaratorNode: prop.declaratorNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
function planKotlinAccessorOwners(rootNode: Parser.SyntaxNode): PlannedJvmAccessorOwner[] {
|
||||
const owners: PlannedJvmAccessorOwner[] = [];
|
||||
const imports = collectKotlinImports(rootNode);
|
||||
for (const cls of findKtClasses(rootNode, imports)) {
|
||||
const accessors = planAccessors(cls);
|
||||
const existingIndex = cls.wasHoisted
|
||||
? owners.findIndex((owner) => owner.node.id === cls.node.id)
|
||||
: -1;
|
||||
const existing = existingIndex >= 0 ? owners[existingIndex] : undefined;
|
||||
if (existing) {
|
||||
owners[existingIndex] = {
|
||||
...existing,
|
||||
accessors: [...existing.accessors, ...accessors],
|
||||
};
|
||||
} else {
|
||||
owners.push({ node: cls.node, name: cls.name, accessors });
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
const lombokAccessorSynthesis = createJvmAccessorSynthesis({
|
||||
language: 'kotlin',
|
||||
synthetic: 'kotlin-jvm',
|
||||
planOwners: planKotlinAccessorOwners,
|
||||
});
|
||||
|
||||
export function synthesizeLombokAccessors(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: ReadonlyMap<number, string>,
|
||||
): SyntheticAccessorResult {
|
||||
return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById);
|
||||
}
|
||||
|
||||
export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] {
|
||||
return lombokAccessorSynthesis.captures(rootNode);
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ import { clearKotlinPackageFacts } from './package-facts.js';
|
|||
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
||||
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
|
||||
import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
|
||||
import { attachKotlinSpringConfigBindings } from './spring-config-bindings.js';
|
||||
import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js';
|
||||
|
||||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
|
|
@ -148,6 +150,8 @@ export const kotlinScopeResolver: ScopeResolver = {
|
|||
attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
229
gitnexus/src/core/ingestion/languages/kotlin/spring-actuator.ts
Normal file
229
gitnexus/src/core/ingestion/languages/kotlin/spring-actuator.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import path from 'node:path';
|
||||
import type { GraphNode, ParsedImport } from 'gitnexus-shared';
|
||||
import type {
|
||||
DefinitionPropertiesContext,
|
||||
RuntimeCallableIdentity,
|
||||
RuntimeSymbolStrategy,
|
||||
} from '../../language-provider.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const RUNTIME_OWNER_ALIASES = 'runtimeOwnerAliases';
|
||||
const RUNTIME_CALLABLE_ALIASES = 'runtimeCallableAliases';
|
||||
const KOTLIN_SUSPEND = 'kotlinSuspend';
|
||||
const fileFacadeMetadataCache = new WeakMap<
|
||||
SyntaxNode,
|
||||
{ readonly packageName: string; readonly customFacade: string | undefined }
|
||||
>();
|
||||
|
||||
function rootNode(node: SyntaxNode): SyntaxNode {
|
||||
let current = node;
|
||||
while (current.parent) current = current.parent;
|
||||
return current;
|
||||
}
|
||||
|
||||
function packageName(root: SyntaxNode): string {
|
||||
const header = root.namedChildren.find((child) => child.type === 'package_header');
|
||||
return header?.text.replace(/^package\s+/, '').trim() ?? '';
|
||||
}
|
||||
|
||||
function qualify(packageNameValue: string, simpleName: string): string {
|
||||
return packageNameValue.length === 0 ? simpleName : `${packageNameValue}.${simpleName}`;
|
||||
}
|
||||
|
||||
function standardFacadeName(filePath: string): string {
|
||||
const stem = path.basename(filePath).replace(/\.(?:kt|kts)$/i, '');
|
||||
return `${stem.charAt(0).toUpperCase()}${stem.slice(1)}Kt`;
|
||||
}
|
||||
|
||||
function jvmNameIdentifiers(
|
||||
imports: readonly ParsedImport[],
|
||||
allowUnqualified: boolean,
|
||||
): readonly string[] {
|
||||
const names = new Set<string>(['kotlin.jvm.JvmName']);
|
||||
if (allowUnqualified) names.add('JvmName');
|
||||
for (const parsedImport of imports) {
|
||||
if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue;
|
||||
if (parsedImport.importedName !== 'JvmName') continue;
|
||||
const target = parsedImport.targetRaw.replace(/\\/g, '/');
|
||||
if (target === 'kotlin.jvm' || target === 'kotlin.jvm.JvmName') {
|
||||
names.add(parsedImport.localName);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function annotationJvmName(
|
||||
source: string,
|
||||
target = '',
|
||||
imports: readonly ParsedImport[] = [],
|
||||
allowUnqualified = true,
|
||||
): string | undefined {
|
||||
const names = jvmNameIdentifiers(imports, allowUnqualified);
|
||||
if (names.length === 0) return undefined;
|
||||
const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const prefix = target.length === 0 ? '' : `${escapedTarget}:`;
|
||||
const namePattern = [...names]
|
||||
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
return new RegExp(`@${prefix}(?:${namePattern})\\s*\\(\\s*["']([^"']+)["']\\s*\\)`).exec(
|
||||
source,
|
||||
)?.[1];
|
||||
}
|
||||
|
||||
function fileFacadeMetadata(
|
||||
root: SyntaxNode,
|
||||
imports: readonly ParsedImport[],
|
||||
allowUnqualified: boolean,
|
||||
): {
|
||||
readonly packageName: string;
|
||||
readonly customFacade: string | undefined;
|
||||
} {
|
||||
const cached = fileFacadeMetadataCache.get(root);
|
||||
if (cached !== undefined) return cached;
|
||||
const metadata = {
|
||||
packageName: packageName(root),
|
||||
customFacade: annotationJvmName(root.text, 'file', imports, allowUnqualified),
|
||||
};
|
||||
fileFacadeMetadataCache.set(root, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function hasEnclosingType(node: SyntaxNode): boolean {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (
|
||||
current.type === 'class_declaration' ||
|
||||
current.type === 'object_declaration' ||
|
||||
current.type === 'companion_object'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Transient graph metadata used only by the same analysis run's runtime import. */
|
||||
export function extractKotlinRuntimeSymbolProperties(
|
||||
context: DefinitionPropertiesContext,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
const properties: Record<string, unknown> = {};
|
||||
const source = context.definitionNode.text;
|
||||
const root = rootNode(context.definitionNode);
|
||||
const allowUnqualifiedJvmName = !/\bannotation\s+class\s+JvmName\b/.test(root.text);
|
||||
|
||||
if (
|
||||
(context.nodeLabel === 'Function' || context.nodeLabel === 'Method') &&
|
||||
context.definitionNode.type === 'function_declaration'
|
||||
) {
|
||||
if (/\bsuspend\b/.test(source.slice(0, source.indexOf('fun') + 3))) {
|
||||
properties[KOTLIN_SUSPEND] = true;
|
||||
}
|
||||
const callableJvmName = annotationJvmName(
|
||||
source,
|
||||
'',
|
||||
context.parsedImports,
|
||||
allowUnqualifiedJvmName,
|
||||
);
|
||||
if (callableJvmName !== undefined) {
|
||||
properties[RUNTIME_CALLABLE_ALIASES] = [callableJvmName];
|
||||
}
|
||||
if (!hasEnclosingType(context.definitionNode)) {
|
||||
const facade = fileFacadeMetadata(root, context.parsedImports, allowUnqualifiedJvmName);
|
||||
properties[RUNTIME_OWNER_ALIASES] = [
|
||||
qualify(facade.packageName, facade.customFacade ?? standardFacadeName(context.filePath)),
|
||||
];
|
||||
}
|
||||
} else if (context.nodeLabel === 'Property') {
|
||||
const getterJvmName = annotationJvmName(
|
||||
source,
|
||||
'get',
|
||||
context.parsedImports,
|
||||
allowUnqualifiedJvmName,
|
||||
);
|
||||
if (getterJvmName !== undefined) {
|
||||
properties[RUNTIME_CALLABLE_ALIASES] = [getterJvmName];
|
||||
}
|
||||
if (!hasEnclosingType(context.definitionNode)) {
|
||||
const facade = fileFacadeMetadata(root, context.parsedImports, allowUnqualifiedJvmName);
|
||||
properties[RUNTIME_OWNER_ALIASES] = [
|
||||
qualify(facade.packageName, facade.customFacade ?? standardFacadeName(context.filePath)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(properties).length === 0 ? undefined : properties;
|
||||
}
|
||||
|
||||
function stringArrayProperty(node: GraphNode, property: string): readonly string[] {
|
||||
const value = node.properties[property];
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
function callableNames(node: GraphNode): readonly string[] {
|
||||
return [String(node.properties.name), ...stringArrayProperty(node, RUNTIME_CALLABLE_ALIASES)];
|
||||
}
|
||||
|
||||
function propertyGetterNames(node: GraphNode): readonly string[] {
|
||||
const name = String(node.properties.name);
|
||||
const capitalized = `${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
||||
return [
|
||||
name.startsWith('is') && name.length > 2 && /[A-Z]/.test(name.charAt(2))
|
||||
? name
|
||||
: `get${capitalized}`,
|
||||
...stringArrayProperty(node, RUNTIME_CALLABLE_ALIASES),
|
||||
];
|
||||
}
|
||||
|
||||
function sourceCallableName(runtimeName: string): string {
|
||||
return runtimeName.endsWith('$default') ? runtimeName.slice(0, -'$default'.length) : runtimeName;
|
||||
}
|
||||
|
||||
function matchesKotlinCallable(node: GraphNode, runtime: RuntimeCallableIdentity): boolean {
|
||||
// Kotlin property declarations and their synthesized JVM accessor Methods
|
||||
// coexist in the graph. Bind runtime getters to the source Property so the
|
||||
// synthetic accessor cannot turn an otherwise exact match into ambiguity.
|
||||
if (node.properties.synthetic === 'kotlin-jvm') return false;
|
||||
|
||||
const runtimeName = sourceCallableName(runtime.name);
|
||||
if (node.label === 'Property') {
|
||||
const names = propertyGetterNames(node);
|
||||
if (!names.includes(runtime.name) && !names.includes(runtimeName)) return false;
|
||||
} else if (!callableNames(node).includes(runtimeName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameterCount = node.properties.parameterCount;
|
||||
const descriptorTypes = runtime.descriptorParameterTypes;
|
||||
if (
|
||||
typeof parameterCount !== 'number' ||
|
||||
descriptorTypes === undefined ||
|
||||
runtime.name.endsWith('$default')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (parameterCount === descriptorTypes.length) return true;
|
||||
return (
|
||||
node.properties[KOTLIN_SUSPEND] === true &&
|
||||
parameterCount + 1 === descriptorTypes.length &&
|
||||
descriptorTypes.at(-1) === 'kotlin/coroutines/Continuation'
|
||||
);
|
||||
}
|
||||
|
||||
export const kotlinRuntimeSymbolStrategy: RuntimeSymbolStrategy = {
|
||||
callableOwnerAliases(node, owner) {
|
||||
const aliases = [...stringArrayProperty(node, RUNTIME_OWNER_ALIASES)];
|
||||
const ownerName = owner?.properties.qualifiedName;
|
||||
if (typeof ownerName === 'string') {
|
||||
aliases.push(ownerName);
|
||||
if (node.properties.isStatic === true && !ownerName.endsWith('.Companion')) {
|
||||
aliases.push(`${ownerName}.Companion`);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
},
|
||||
|
||||
matchesCallable: matchesKotlinCallable,
|
||||
};
|
||||
|
|
@ -0,0 +1,467 @@
|
|||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { makeScopeId, type ParsedFile, type ScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
bindSpringConfigConsumers,
|
||||
type SpringConfigConsumer,
|
||||
} from '../../frameworks/spring/config-bindings.js';
|
||||
import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
|
||||
import {
|
||||
parseSpringAnnotationArguments,
|
||||
parseStaticStringLiteral,
|
||||
} from '../../frameworks/spring/annotation-arguments.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getKotlinParser } from './query.js';
|
||||
import { getKotlinSpringConfigConsumerFacts } from './capture-side-channel.js';
|
||||
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
||||
const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value';
|
||||
const CONFIGURATION_PROPERTIES_ANNOTATION =
|
||||
'org.springframework.boot.context.properties.ConfigurationProperties';
|
||||
|
||||
const VALUE_SIMPLE = 'Value';
|
||||
const CONFIGURATION_PROPERTIES_SIMPLE = 'ConfigurationProperties';
|
||||
const SKIP_USE_SITES = new Set(['get', 'property', 'file']);
|
||||
const BIND_USE_SITES = new Set(['field', 'set', 'param']);
|
||||
const OWNER_TYPES = new Set(['class_declaration', 'object_declaration', 'companion_object']);
|
||||
const INTERPOLATION_TYPES = new Set([
|
||||
'interpolated_identifier',
|
||||
'interpolated_expression',
|
||||
'interpolation_expression_start',
|
||||
]);
|
||||
const STRING_LITERAL_TYPES = new Set(['string_literal', 'character_literal']);
|
||||
|
||||
export interface KotlinSpringConfigConsumerFact {
|
||||
readonly consumer: SpringConfigConsumer;
|
||||
readonly annotationName: string;
|
||||
readonly classScopeId: ScopeId;
|
||||
}
|
||||
|
||||
interface KotlinAnnotation {
|
||||
readonly name: string;
|
||||
readonly node: SyntaxNode;
|
||||
readonly useSiteTarget?: string;
|
||||
}
|
||||
|
||||
interface KotlinImports {
|
||||
readonly exact: ReadonlyMap<string, string>;
|
||||
readonly wildcard: ReadonlySet<string>;
|
||||
readonly localTypes: ReadonlyMap<string, readonly SyntaxNode[]>;
|
||||
}
|
||||
|
||||
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined {
|
||||
const stack = [...node.namedChildren].reverse();
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) continue;
|
||||
if (current.type === type) return current;
|
||||
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
|
||||
const child = current.namedChildren[index];
|
||||
if (child !== undefined) stack.push(child);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ownerName(declaration: SyntaxNode): string | undefined {
|
||||
if (declaration.type === 'companion_object') {
|
||||
const named = declaration.namedChildren.find((child) => child.type === 'type_identifier');
|
||||
return named?.text.trim() || 'Companion';
|
||||
}
|
||||
return (
|
||||
declaration.namedChildren.find((child) => child.type === 'type_identifier')?.text.trim() ??
|
||||
declaration.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function enclosingOwner(node: SyntaxNode): SyntaxNode | undefined {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (OWNER_TYPES.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function classScopeId(filePath: string, declaration: SyntaxNode): ScopeId {
|
||||
return makeScopeId({
|
||||
filePath,
|
||||
range: nodeToCapture('@scope.class', declaration).range,
|
||||
kind: 'Class',
|
||||
});
|
||||
}
|
||||
|
||||
function collectKotlinImports(root: SyntaxNode): KotlinImports {
|
||||
const exact = new Map<string, string>();
|
||||
const wildcard = new Set<string>();
|
||||
const localTypes = new Map<string, SyntaxNode[]>();
|
||||
|
||||
for (const header of root.descendantsOfType('import_header')) {
|
||||
const text = header.text.replace(/^import\s+/, '').trim();
|
||||
const aliasMatch = text.match(/^([\w.]+)\s+as\s+(\w+)\s*$/);
|
||||
if (aliasMatch !== null) {
|
||||
exact.set(aliasMatch[2], aliasMatch[1]);
|
||||
continue;
|
||||
}
|
||||
if (text.endsWith('.*')) wildcard.add(text.slice(0, -2));
|
||||
else {
|
||||
const simple = text.slice(text.lastIndexOf('.') + 1);
|
||||
if (simple.length > 0) exact.set(simple, text);
|
||||
}
|
||||
}
|
||||
|
||||
for (const type of ['class_declaration', 'object_declaration']) {
|
||||
for (const declaration of root.descendantsOfType(type)) {
|
||||
const name = ownerName(declaration);
|
||||
if (name) {
|
||||
const declarations = localTypes.get(name) ?? [];
|
||||
declarations.push(declaration);
|
||||
localTypes.set(name, declarations);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { exact, wildcard, localTypes };
|
||||
}
|
||||
|
||||
function annotationFromNode(annotation: SyntaxNode): KotlinAnnotation | null {
|
||||
const nameNode =
|
||||
firstDescendantOfType(annotation, 'user_type') ??
|
||||
firstDescendantOfType(annotation, 'type_identifier') ??
|
||||
firstDescendantOfType(annotation, 'simple_identifier');
|
||||
if (nameNode === undefined) return null;
|
||||
const useSiteTarget = annotation.namedChildren
|
||||
.find((child) => child.type === 'use_site_target')
|
||||
?.text.replace(/:\s*$/, '')
|
||||
.trim();
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
node: annotation,
|
||||
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
|
||||
};
|
||||
}
|
||||
|
||||
function annotationsOn(node: SyntaxNode): KotlinAnnotation[] {
|
||||
const annotations: KotlinAnnotation[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'annotation') {
|
||||
const fact = annotationFromNode(child);
|
||||
if (fact !== null) annotations.push(fact);
|
||||
continue;
|
||||
}
|
||||
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
|
||||
for (const nested of child.namedChildren) {
|
||||
if (nested.type !== 'annotation') continue;
|
||||
const fact = annotationFromNode(nested);
|
||||
if (fact !== null) annotations.push(fact);
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
function simpleName(rawName: string): string {
|
||||
const parts = rawName.split('.');
|
||||
return parts[parts.length - 1] ?? rawName;
|
||||
}
|
||||
|
||||
function importedAs(
|
||||
imports: KotlinImports,
|
||||
simple: string,
|
||||
fqn: string,
|
||||
wildcardPackage: string,
|
||||
): boolean {
|
||||
if (imports.exact.get(simple) === fqn) return true;
|
||||
// An explicit import wins over a star import in Kotlin, so a conflicting
|
||||
// binding for the same simple name rules the Spring annotation out even when
|
||||
// its package is wildcard-imported.
|
||||
return imports.exact.get(simple) === undefined && imports.wildcard.has(wildcardPackage);
|
||||
}
|
||||
|
||||
function hasVisibleLocalType(
|
||||
imports: KotlinImports,
|
||||
simple: string,
|
||||
annotation: SyntaxNode,
|
||||
): boolean {
|
||||
for (const declaration of imports.localTypes.get(simple) ?? []) {
|
||||
const declarationOwner = enclosingOwner(declaration);
|
||||
if (declarationOwner === undefined) return true;
|
||||
let current: SyntaxNode | null = annotation;
|
||||
while (current !== null) {
|
||||
if (current.id === declarationOwner.id) return true;
|
||||
current = current.parent;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const SIMPLE_CONFIG_ANNOTATIONS = [
|
||||
{
|
||||
simple: VALUE_SIMPLE,
|
||||
kind: 'value',
|
||||
fqn: VALUE_ANNOTATION,
|
||||
wildcardPackage: 'org.springframework.beans.factory.annotation',
|
||||
},
|
||||
{
|
||||
simple: CONFIGURATION_PROPERTIES_SIMPLE,
|
||||
kind: 'configuration-properties',
|
||||
fqn: CONFIGURATION_PROPERTIES_ANNOTATION,
|
||||
wildcardPackage: 'org.springframework.boot.context.properties',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function configAnnotationKind(
|
||||
annotation: KotlinAnnotation,
|
||||
imports: KotlinImports,
|
||||
): 'value' | 'configuration-properties' | null {
|
||||
const rawName = annotation.name;
|
||||
if (rawName === VALUE_ANNOTATION) return 'value';
|
||||
if (rawName === CONFIGURATION_PROPERTIES_ANNOTATION) return 'configuration-properties';
|
||||
const simple = simpleName(rawName);
|
||||
const aliased = imports.exact.get(simple);
|
||||
if (aliased === VALUE_ANNOTATION) return 'value';
|
||||
if (aliased === CONFIGURATION_PROPERTIES_ANNOTATION) return 'configuration-properties';
|
||||
for (const candidate of SIMPLE_CONFIG_ANNOTATIONS) {
|
||||
if (simple !== candidate.simple) continue;
|
||||
if (hasVisibleLocalType(imports, simple, annotation.node) && !imports.exact.has(simple)) {
|
||||
return null;
|
||||
}
|
||||
return importedAs(imports, simple, candidate.fqn, candidate.wildcardPackage)
|
||||
? candidate.kind
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasInterpolation(annotation: SyntaxNode): boolean {
|
||||
const stack: SyntaxNode[] = [...annotation.namedChildren];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) continue;
|
||||
if (INTERPOLATION_TYPES.has(current.type)) return true;
|
||||
stack.push(...current.namedChildren);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeKotlinStringLiteral(literal: string): string | null {
|
||||
const raw = literal.startsWith('"""') && literal.endsWith('"""');
|
||||
const delimiterLength = raw ? 3 : 1;
|
||||
if (literal.length < delimiterLength * 2) return null;
|
||||
const body = literal.slice(delimiterLength, -delimiterLength);
|
||||
if (!raw && /(?<!\\)\$\{/.test(body)) return null;
|
||||
if (raw) return body;
|
||||
return body
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/\\(["'\\$btnfr])/g, (_match, escaped: string) => {
|
||||
const controls: Record<string, string> = {
|
||||
b: '\b',
|
||||
t: '\t',
|
||||
n: '\n',
|
||||
f: '\f',
|
||||
r: '\r',
|
||||
$: '$',
|
||||
};
|
||||
return controls[escaped] ?? escaped;
|
||||
});
|
||||
}
|
||||
|
||||
function kotlinStringLiterals(annotation: SyntaxNode): string[] {
|
||||
const literals: string[] = [];
|
||||
const stack: SyntaxNode[] = [...annotation.namedChildren];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) continue;
|
||||
if (STRING_LITERAL_TYPES.has(current.type)) {
|
||||
const decoded = decodeKotlinStringLiteral(current.text);
|
||||
if (decoded !== null) literals.push(decoded);
|
||||
continue;
|
||||
}
|
||||
stack.push(...current.namedChildren);
|
||||
}
|
||||
return literals;
|
||||
}
|
||||
|
||||
function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] {
|
||||
if (hasInterpolation(annotation)) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const literal of kotlinStringLiterals(annotation)) {
|
||||
for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
|
||||
const key = match[1].split(':', 1)[0].trim();
|
||||
if (/^[A-Za-z0-9_.-]+$/.test(key)) keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null {
|
||||
if (hasInterpolation(annotation)) return null;
|
||||
const argumentsList = parseSpringAnnotationArguments(annotation.text);
|
||||
if (argumentsList !== null) {
|
||||
const named = argumentsList.filter(
|
||||
(argument) => argument.name === 'prefix' || argument.name === 'value',
|
||||
);
|
||||
const positional = argumentsList.filter((argument) => argument.name === undefined);
|
||||
const chosen = named.length === 1 ? named[0] : named.length === 0 ? positional[0] : undefined;
|
||||
if (chosen !== undefined) {
|
||||
const decoded = parseStaticStringLiteral(chosen.value);
|
||||
if (decoded === null) return null;
|
||||
const prefix = decoded.replace(/^\.+|\.+$/g, '');
|
||||
if (/^[A-Za-z0-9_.-]+$/.test(prefix)) return prefix;
|
||||
return null;
|
||||
}
|
||||
if (argumentsList.length > 0) return null;
|
||||
}
|
||||
const literals = kotlinStringLiterals(annotation);
|
||||
if (literals.length !== 1) return null;
|
||||
const prefix = literals[0].trim().replace(/^\.+|\.+$/g, '');
|
||||
return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
|
||||
}
|
||||
|
||||
function allowedUseSite(useSiteTarget: string | undefined): boolean {
|
||||
if (useSiteTarget === undefined) return true;
|
||||
if (SKIP_USE_SITES.has(useSiteTarget)) return false;
|
||||
return BIND_USE_SITES.has(useSiteTarget);
|
||||
}
|
||||
|
||||
function hasBindingPattern(parameter: SyntaxNode): boolean {
|
||||
return parameter.namedChildren.some((child) => child.type === 'binding_pattern_kind');
|
||||
}
|
||||
|
||||
function propertyName(node: SyntaxNode): string | undefined {
|
||||
if (node.type === 'class_parameter') {
|
||||
return node.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
|
||||
}
|
||||
const variable = node.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
return variable?.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
|
||||
}
|
||||
|
||||
function underFileAnnotation(node: SyntaxNode): boolean {
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current !== null) {
|
||||
if (current.type === 'file_annotation') return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pushValueFacts(
|
||||
facts: KotlinSpringConfigConsumerFact[],
|
||||
member: SyntaxNode,
|
||||
filePath: string,
|
||||
imports: KotlinImports,
|
||||
): void {
|
||||
if (underFileAnnotation(member)) return;
|
||||
const owner = enclosingOwner(member);
|
||||
if (owner === undefined) return;
|
||||
const fieldName = propertyName(member);
|
||||
if (fieldName === undefined) return;
|
||||
for (const annotation of annotationsOn(member)) {
|
||||
if (!allowedUseSite(annotation.useSiteTarget)) continue;
|
||||
if (configAnnotationKind(annotation, imports) !== 'value') continue;
|
||||
const keys = parseValuePlaceholderKeys(annotation.node);
|
||||
if (keys.length === 0) continue;
|
||||
facts.push({
|
||||
consumer: {
|
||||
kind: 'value',
|
||||
fieldName,
|
||||
line: member.startPosition.row + 1,
|
||||
keys,
|
||||
},
|
||||
annotationName: annotation.name,
|
||||
classScopeId: classScopeId(filePath, owner),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect config facts from the Kotlin parser's existing AST (no reparse). */
|
||||
export function captureKotlinSpringConfigConsumerFacts(
|
||||
root: SyntaxNode,
|
||||
filePath: string,
|
||||
): KotlinSpringConfigConsumerFact[] {
|
||||
const imports = collectKotlinImports(root);
|
||||
const facts: KotlinSpringConfigConsumerFact[] = [];
|
||||
|
||||
for (const property of root.descendantsOfType('property_declaration')) {
|
||||
pushValueFacts(facts, property, filePath, imports);
|
||||
}
|
||||
|
||||
for (const parameter of root.descendantsOfType('class_parameter')) {
|
||||
if (!hasBindingPattern(parameter)) continue;
|
||||
pushValueFacts(facts, parameter, filePath, imports);
|
||||
}
|
||||
|
||||
for (const type of ['class_declaration', 'object_declaration']) {
|
||||
for (const declaration of root.descendantsOfType(type)) {
|
||||
const className = ownerName(declaration);
|
||||
if (className === undefined) continue;
|
||||
for (const annotation of annotationsOn(declaration)) {
|
||||
if (configAnnotationKind(annotation, imports) !== 'configuration-properties') {
|
||||
continue;
|
||||
}
|
||||
const prefix = parseConfigurationPropertiesPrefix(annotation.node);
|
||||
if (prefix === null) continue;
|
||||
facts.push({
|
||||
consumer: {
|
||||
kind: 'configuration-properties',
|
||||
className,
|
||||
line: declaration.startPosition.row + 1,
|
||||
prefix,
|
||||
},
|
||||
annotationName: annotation.name,
|
||||
classScopeId: classScopeId(filePath, declaration),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
/** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */
|
||||
export function extractKotlinSpringConfigConsumers(source: string): SpringConfigConsumer[] {
|
||||
const tree = parseSourceSafe(getKotlinParser(), source);
|
||||
return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>').map(
|
||||
(fact) => fact.consumer,
|
||||
);
|
||||
}
|
||||
|
||||
export function extractKotlinSpringConfigConsumerFacts(
|
||||
source: string,
|
||||
): KotlinSpringConfigConsumerFact[] {
|
||||
const tree = parseSourceSafe(getKotlinParser(), source);
|
||||
return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>');
|
||||
}
|
||||
|
||||
/** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */
|
||||
export function attachKotlinSpringConfigBindings(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void {
|
||||
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
|
||||
const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]);
|
||||
const batches: Array<{ filePath: string; consumers: SpringConfigConsumer[] }> = [];
|
||||
for (const parsed of parsedFiles) {
|
||||
const consumers: SpringConfigConsumer[] = [];
|
||||
for (const fact of getKotlinSpringConfigConsumerFacts(parsed.filePath)) {
|
||||
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
|
||||
if (classScope === undefined || classScope.kind !== 'Class') continue;
|
||||
const expectedAnnotation =
|
||||
fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION;
|
||||
const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent;
|
||||
const resolved = resolveAnnotation(
|
||||
fact.annotationName,
|
||||
parsed,
|
||||
enclosingScope,
|
||||
recognizedAnnotations,
|
||||
isKotlinPackageSiblingVisibilityIncomplete(parsed.filePath),
|
||||
);
|
||||
if (resolved === expectedAnnotation) consumers.push(fact.consumer);
|
||||
}
|
||||
if (consumers.length > 0) batches.push({ filePath: parsed.filePath, consumers });
|
||||
}
|
||||
bindSpringConfigConsumers(graph, batches);
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import {
|
||||
normalizeSpringFactText,
|
||||
type SpringArgumentFact,
|
||||
} from '../../frameworks/spring/argument-facts.js';
|
||||
import {
|
||||
createSpringDiMetadataAttacher,
|
||||
hasSpringDiRelevantAnnotation,
|
||||
|
|
@ -13,13 +17,29 @@ import {
|
|||
hasSpringBeanFactorySyntax,
|
||||
type SpringBeanFactoryMethodFact,
|
||||
} from '../../frameworks/spring/bean-factories.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { hasRecoveredSyntax, nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
||||
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
|
||||
readonly useSiteTarget?: string;
|
||||
readonly line: number;
|
||||
/** Present only for callers that opt in via `kotlinSpringAnnotationFacts`. */
|
||||
readonly args?: readonly SpringArgumentFact[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `kotlinSpringAnnotationFacts`.
|
||||
*
|
||||
* The STRUCTURED arguments are opt-in because DI captures every annotated
|
||||
* constructor parameter, property, and function in the repository, and none of
|
||||
* its consumers reads them. Note what this does and does not save: every fact
|
||||
* already carries `text`, the annotation's full source, so the argument TEXT
|
||||
* crosses the worker boundary either way. What the opt-in avoids is a second,
|
||||
* parsed copy of that same text on facts that would never look at it.
|
||||
*/
|
||||
export interface KotlinSpringAnnotationFactOptions {
|
||||
readonly includeArguments?: boolean;
|
||||
}
|
||||
|
||||
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
|
||||
|
|
@ -53,36 +73,128 @@ function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | und
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null {
|
||||
const KOTLIN_COMMENT_NODE_TYPES = new Set(['line_comment', 'multiline_comment']);
|
||||
|
||||
/**
|
||||
* Kotlin writes annotation arguments and call arguments with the same
|
||||
* `value_arguments` node, so one reader serves `@KafkaListener(topics = [...])`
|
||||
* and `kafkaTemplate.send(topic, payload)`.
|
||||
*
|
||||
* A named argument keeps its key; everything else — positional values, spreads,
|
||||
* collection literals, and interpolated strings — is kept as raw text, because
|
||||
* evaluating it would be resolution.
|
||||
*
|
||||
* Returns `null` for a list tree-sitter had to recover, and the callers decide
|
||||
* what that means: a producer call drops the whole fact, since it has no state
|
||||
* for "published somewhere unreadable", while an annotation reports no
|
||||
* arguments and collapses into the marker form. Both answers say "nothing here
|
||||
* to resolve", which is true; a fabricated value would send a consumer
|
||||
* somewhere real and wrong.
|
||||
*
|
||||
* The check lives HERE, not only in the callers. This function is exported and
|
||||
* already has a caller in another module, so a guard that every future caller
|
||||
* has to remember is the same fragility this change set exists to remove —
|
||||
* `null` makes the decision unavoidable at the type level. Per-argument
|
||||
* re-checks are still pointless: `hasError` propagates from any argument up to
|
||||
* the list, so a branch behind this one could never fire.
|
||||
*
|
||||
* A named argument is identified by the `=` TOKEN, and the two-child shape is
|
||||
* only a corroborating detail. Today nothing well formed reaches two children
|
||||
* without an `=`: an annotated positional argument such as
|
||||
* `@Suppress("UNCHECKED_CAST") "orders"` arrives as ONE `prefix_expression`, not
|
||||
* as two children, so the token test is currently redundant. It is kept as the
|
||||
* leading condition anyway, because the failure it prevents is asymmetric —
|
||||
* dropping it would let any future two-child positional shape be reported under
|
||||
* an argument key the source never wrote, which is the failure mode this whole
|
||||
* change set is about.
|
||||
*/
|
||||
export function kotlinValueArgumentFacts(valueArguments: SyntaxNode): SpringArgumentFact[] | null {
|
||||
if (hasRecoveredSyntax(valueArguments)) return null;
|
||||
const args: SpringArgumentFact[] = [];
|
||||
for (const argument of valueArguments.namedChildren) {
|
||||
if (argument.type !== 'value_argument') continue;
|
||||
const parts = argument.namedChildren.filter(
|
||||
(child) => !KOTLIN_COMMENT_NODE_TYPES.has(child.type),
|
||||
);
|
||||
const named = argument.children.some((child) => child.type === '=');
|
||||
const name = parts[0];
|
||||
const value = parts[1];
|
||||
if (named && parts.length === 2 && name !== undefined && value !== undefined) {
|
||||
args.push({ name: name.text.trim(), text: normalizeSpringFactText(value.text) });
|
||||
continue;
|
||||
}
|
||||
args.push({ text: normalizeSpringFactText(argument.text) });
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments of one annotation, or `undefined` when it was written without an
|
||||
* argument list (`@Scheduled`); `@Scheduled()` yields `[]` instead.
|
||||
*
|
||||
* Only the annotation's FIRST `user_type` / `constructor_invocation` child is
|
||||
* read, which is the same element `annotationFact` names. That matters for the
|
||||
* multi-annotation form `@field:[Alpha Beta("x")]`, where naively taking the
|
||||
* first constructor invocation would hand Beta's arguments to Alpha.
|
||||
*
|
||||
* An argument list that did not parse also yields `undefined`, collapsing into
|
||||
* the marker-annotation case on purpose: both say there is nothing readable to
|
||||
* resolve, while the recovered tree would offer values nobody wrote.
|
||||
*/
|
||||
function kotlinAnnotationArgumentFacts(annotation: SyntaxNode): SpringArgumentFact[] | undefined {
|
||||
const named = annotation.namedChildren.find(
|
||||
(child) => child.type === 'user_type' || child.type === 'constructor_invocation',
|
||||
);
|
||||
if (named === undefined || named.type !== 'constructor_invocation') return undefined;
|
||||
const valueArguments = named.namedChildren.find((child) => child.type === 'value_arguments');
|
||||
if (valueArguments === undefined) return undefined;
|
||||
// `null` here means recovered syntax; an annotation answers that by reporting
|
||||
// no arguments at all, which is the marker-annotation form.
|
||||
return kotlinValueArgumentFacts(valueArguments) ?? undefined;
|
||||
}
|
||||
|
||||
function annotationFact(
|
||||
annotation: SyntaxNode,
|
||||
options: KotlinSpringAnnotationFactOptions,
|
||||
): KotlinAnnotationSyntaxFact | null {
|
||||
const nameNode = firstDescendantOfType(annotation, 'user_type');
|
||||
if (nameNode === undefined) return null;
|
||||
const useSiteTarget = annotation.namedChildren
|
||||
.find((child) => child.type === 'use_site_target')
|
||||
?.text.replace(/:\s*$/, '')
|
||||
.trim();
|
||||
const args =
|
||||
options.includeArguments === true ? kotlinAnnotationArgumentFacts(annotation) : undefined;
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
text: annotation.text.trim(),
|
||||
line: annotation.startPosition.row + 1,
|
||||
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
|
||||
...(args === undefined ? {} : { args }),
|
||||
};
|
||||
}
|
||||
|
||||
function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
function annotationsFromModifierContainer(
|
||||
node: SyntaxNode,
|
||||
options: KotlinSpringAnnotationFactOptions = {},
|
||||
): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'annotation') continue;
|
||||
const fact = annotationFact(child);
|
||||
const fact = annotationFact(child, options);
|
||||
if (fact !== null) facts.push(fact);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function kotlinSpringAnnotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
export function kotlinSpringAnnotationFacts(
|
||||
node: SyntaxNode,
|
||||
options: KotlinSpringAnnotationFactOptions = {},
|
||||
): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
|
||||
facts.push(...annotationsFromModifierContainer(child));
|
||||
facts.push(...annotationsFromModifierContainer(child, options));
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue