feat(spring): import actuator runtime data

This commit is contained in:
Shining 2026-08-31 19:10:08 +08:00
parent e04e1ecc65
commit f899da1ee3
26 changed files with 1467 additions and 62 deletions

View file

@ -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 and its path is not persisted; 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,37 +528,37 @@ 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_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`. |
| 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_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>

View file

@ -244,6 +244,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
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 and its path is not persisted; 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

View file

@ -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.`);

View file

@ -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. */

View file

@ -1372,6 +1372,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

View file

@ -139,6 +139,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')

View file

@ -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],

View file

@ -0,0 +1,722 @@
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';
const ACTUATOR_ENDPOINTS = ['mappings', 'beans', 'conditions', 'configprops', 'env'] as const;
type ActuatorEndpoint = (typeof ACTUATOR_ENDPOINTS)[number];
const MAX_ACTUATOR_PAYLOAD_BYTES = 16 * 1024 * 1024;
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;
}
interface MutableImportStats {
payloads: number;
mappings: number;
beans: number;
conditions: number;
configProperties: number;
environmentProperties: number;
}
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> {
let stat;
try {
stat = await fs.stat(filePath);
} catch {
throw new SpringActuatorImportError(`Spring Actuator ${label} input could not be read.`);
}
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.`,
);
}
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch {
throw new SpringActuatorImportError(`Spring Actuator ${label} input could not be read.`);
}
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',
): void {
target.properties.runtimeConfirmed = true;
target.properties.runtimeSource = 'spring-actuator';
const previousStatus = safeText(target.properties.runtimeStatus);
target.properties.runtimeStatus =
previousStatus === undefined || previousStatus === status
? status
: [previousStatus, 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.id !== node.id) index.set(key, null);
}
interface RuntimeNodeIndexes {
readonly classesByQualifiedName: Map<string, GraphNode | null>;
readonly classesBySimpleName: Map<string, GraphNode | null>;
readonly beanProvidersByName: Map<string, GraphNode | null>;
readonly methodsByOwnerId: Map<string, GraphNode[]>;
}
function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes {
const allNodes = [...graph.iterNodes()];
const classesByQualifiedName = 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[]>();
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);
}
for (const relationship of graph.iterRelationshipsByType('HAS_METHOD')) {
const method = nodesById.get(relationship.targetId);
if (method === undefined || (method.label !== 'Method' && method.label !== 'Function'))
continue;
const methods = methodsByOwnerId.get(relationship.sourceId) ?? [];
methods.push(method);
methodsByOwnerId.set(relationship.sourceId, methods);
}
return {
classesByQualifiedName,
classesBySimpleName,
beanProvidersByName,
methodsByOwnerId,
};
}
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 simple = type.slice(type.lastIndexOf('.') + 1);
const fallback = indexes.classesBySimpleName.get(simple);
return fallback === null ? undefined : fallback;
}
function descriptorParameterCount(descriptor: string | undefined): number | undefined {
if (descriptor === undefined || descriptor.charAt(0) !== '(') return undefined;
let count = 0;
for (let index = 1; index < descriptor.length && descriptor.charAt(index) !== ')'; count++) {
while (descriptor.charAt(index) === '[') index++;
if (descriptor.charAt(index) === 'L') {
const end = descriptor.indexOf(';', index);
if (end === -1) return undefined;
index = end + 1;
} else {
index++;
}
}
return count;
}
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);
const owner = resolveClass(indexes, className);
if (owner === undefined) return undefined;
const parameterCount = descriptorParameterCount(safeText(handlerMethod?.descriptor));
const candidates = (indexes.methodsByOwnerId.get(owner.id) ?? []).filter(
(node) =>
node.properties.name === methodName &&
(parameterCount === undefined || node.properties.parameterCount === parameterCount),
);
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 methods = [
...predicate.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): JsonObject[] {
const entries: JsonObject[] = [];
const contexts = objectValue(payload.contexts);
if (contexts === undefined) return entries;
for (const context of Object.values(contexts).slice(0, MAX_RUNTIME_RECORDS)) {
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) return entries;
}
}
}
}
return entries;
}
function importMappings(
graph: KnowledgeGraph,
payload: JsonObject,
indexes: RuntimeNodeIndexes,
): number {
let imported = 0;
const seen = new Set<string>();
for (const entry of mappingEntries(payload)) {
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]) {
if (imported >= MAX_RUNTIME_RECORDS) return imported;
const normalizedMethod = normalizeRouteMethod(method);
const key = routeNodeKey(normalizedMethod, url);
if (seen.has(key)) continue;
seen.add(key);
const exactId = generateId('Route', key);
const fallbackId = generateId('Route', url);
let route = graph.getNode(exactId) ?? graph.getNode(fallbackId);
if (route?.label !== 'Route') 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);
} else if (handler !== undefined && route.properties.handlerSymbolId === undefined) {
route.properties.handlerSymbolId = handler.id;
}
markRuntimeEvidence(graph, 'mappings', route);
if (handler !== undefined) {
const fileId = generateId('File', String(handler.properties.filePath));
if (graph.getNode(fileId) !== undefined) {
graph.addRelationship({
id: generateId('HANDLES_ROUTE', `${fileId}->${route.id}`),
sourceId: fileId,
targetId: route.id,
type: 'HANDLES_ROUTE',
confidence: 1,
reason: 'spring-actuator:runtime-confirmed',
});
}
}
imported++;
}
}
}
return imported;
}
function contextObjects(payload: JsonObject): JsonObject[] {
const contexts = objectValue(payload.contexts);
if (contexts === undefined) return [];
return Object.values(contexts)
.slice(0, MAX_RUNTIME_RECORDS)
.map(objectValue)
.filter((context): context is JsonObject => context !== undefined);
}
function importBeans(
graph: KnowledgeGraph,
payload: JsonObject,
indexes: RuntimeNodeIndexes,
): number {
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 imported;
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;
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 imported;
}
function resolveConditionOwner(
indexes: RuntimeNodeIndexes,
rawName: string,
): GraphNode | undefined {
const separator = rawName.lastIndexOf('#');
const className = separator === -1 ? rawName : rawName.slice(0, separator);
const methodName = separator === -1 ? undefined : rawName.slice(separator + 1);
const owner = resolveClass(indexes, className);
if (owner === undefined || methodName === undefined) return owner;
const candidates = (indexes.methodsByOwnerId.get(owner.id) ?? []).filter(
(node) => node.properties.name === methodName,
);
return candidates.length === 1 ? candidates[0] : undefined;
}
function importConditions(
graph: KnowledgeGraph,
payload: JsonObject,
indexes: RuntimeNodeIndexes,
): number {
const targetsByOwner = new Map<string, GraphNode[]>();
for (const relationship of graph.iterRelationshipsByType('CONDITIONAL_ON')) {
const target = graph.getNode(relationship.targetId);
if (target === undefined) continue;
const targets = targetsByOwner.get(relationship.sourceId) ?? [];
targets.push(target);
targetsByOwner.set(relationship.sourceId, targets);
}
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 imported;
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;
markRuntimeEvidence(graph, 'conditions', owner, status);
for (const target of targetsByOwner.get(owner.id) ?? []) {
markRuntimeEvidence(graph, 'conditions', target, status);
}
imported++;
}
}
}
return imported;
}
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): string[] {
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 out;
}
function importConfigProperties(
graph: KnowledgeGraph,
payload: JsonObject,
propertyIndex: RuntimePropertyIndex,
): number {
let imported = 0;
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 paths = configInputPaths(bean.inputs);
const names = paths.length === 0 ? [prefix] : paths.map((entry) => `${prefix}.${entry}`);
for (const name of names) {
if (imported >= MAX_RUNTIME_RECORDS) return imported;
if (seen.has(name)) continue;
seen.add(name);
if (ensureRuntimeProperty(graph, propertyIndex, 'configprops', name) !== undefined)
imported++;
}
}
}
return imported;
}
function importEnvironmentProperties(
graph: KnowledgeGraph,
payload: JsonObject,
propertyIndex: RuntimePropertyIndex,
): number {
let imported = 0;
const seen = new Set<string>();
if (!Array.isArray(payload.propertySources)) return imported;
for (const rawSource of payload.propertySources.slice(0, MAX_RUNTIME_RECORDS)) {
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 imported;
const name = safeText(rawName, 1024);
if (name === undefined || seen.has(name)) continue;
seen.add(name);
if (ensureRuntimeProperty(graph, propertyIndex, 'env', name) !== undefined) imported++;
}
}
return imported;
}
/**
* 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,
};
const indexes = buildRuntimeNodeIndexes(graph);
const propertyIndex = buildRuntimePropertyIndex(graph);
const mappings = payloads.get('mappings');
if (mappings !== undefined) stats.mappings = importMappings(graph, mappings, indexes);
const beans = payloads.get('beans');
if (beans !== undefined) stats.beans = importBeans(graph, beans, indexes);
const conditions = payloads.get('conditions');
if (conditions !== undefined) stats.conditions = importConditions(graph, conditions, indexes);
const configprops = payloads.get('configprops');
if (configprops !== undefined) {
stats.configProperties = importConfigProperties(graph, configprops, propertyIndex);
}
const env = payloads.get('env');
if (env !== undefined) {
stats.environmentProperties = importEnvironmentProperties(graph, env, propertyIndex);
}
return stats;
}

View file

@ -12,6 +12,7 @@
import type { PipelinePhase, PipelineContext } from './types.js';
import { walkRepositoryPaths } from '../filesystem-walker.js';
import path from 'node:path';
export interface ScanOutput {
scannedFiles: { path: string; size: number }[];
@ -19,6 +20,40 @@ export interface ScanOutput {
totalFiles: number;
}
const SPRING_ACTUATOR_ENDPOINT_FILES = new Set([
'mappings.json',
'beans.json',
'conditions.json',
'configprops.json',
'env.json',
]);
/**
* Runtime snapshots are external analysis inputs, not repository source. When
* a configured input lives below the repository root, exclude it before any
* downstream phase reads file contents. This is especially important for
* Actuator env/configprops payloads: their values must never become File-node
* content or enter FTS merely because the snapshot directory is in the repo.
*/
function excludesRuntimeInput(repoPath: string, filePath: string, inputPath: string): boolean {
const repo = path.resolve(repoPath);
const candidate = path.resolve(repoPath, filePath);
const input = path.resolve(repoPath, inputPath);
const inputRelativeToRepo = path.relative(repo, input);
if (inputRelativeToRepo === '') {
const candidateRelativeToRepo = path.relative(repo, candidate);
return (
path.dirname(candidateRelativeToRepo) === '.' &&
SPRING_ACTUATOR_ENDPOINT_FILES.has(path.basename(candidateRelativeToRepo).toLowerCase())
);
}
if (inputRelativeToRepo.startsWith('..') || path.isAbsolute(inputRelativeToRepo)) return false;
const relative = path.relative(input, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
export const scanPhase: PipelinePhase<ScanOutput> = {
name: 'scan',
deps: [],
@ -30,15 +65,19 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
message: 'Scanning repository...',
});
const springActuatorPath = ctx.options?.springActuatorPath;
let scannedFiles;
try {
scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
const scanProgress = Math.round((current / total) * 15);
const isRuntimeInput =
springActuatorPath !== undefined &&
excludesRuntimeInput(ctx.repoPath, filePath, springActuatorPath);
ctx.onProgress({
phase: 'extracting',
percent: scanProgress,
message: 'Scanning repository...',
detail: filePath,
...(isRuntimeInput ? {} : { detail: filePath }),
stats: {
filesProcessed: current,
totalFiles: total,
@ -56,6 +95,12 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
throw err;
}
if (springActuatorPath !== undefined) {
scannedFiles = scannedFiles.filter(
(file) => !excludesRuntimeInput(ctx.repoPath, file.path, springActuatorPath),
);
}
const totalFiles = scannedFiles.length;
const allPaths = scannedFiles.map((f) => f.path);

View file

@ -21,6 +21,10 @@ import {
SPRING_AUTO_CONFIGURATION_IMPORT_REASON,
SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION,
} from '../frameworks/spring/auto-configuration.js';
import {
importSpringActuatorRuntime,
type SpringActuatorImportStats,
} from '../frameworks/spring/actuator-runtime.js';
import { isDev } from '../utils/env.js';
import type { StructureOutput } from './structure.js';
import type { PipelineContext, PipelinePhase, PhaseResult } from './types.js';
@ -74,6 +78,7 @@ export interface SpringAutoConfigurationOutput {
readonly metadataFiles: number;
readonly autoConfigurations: number;
readonly ambiguousAutoConfigurations: number;
readonly actuatorRuntime?: SpringActuatorImportStats;
}
export function classifySpringAutoConfigurationMetadata(
@ -305,10 +310,20 @@ export const springAutoConfigurationPhase: PipelinePhase<SpringAutoConfiguration
);
}
const actuatorRuntime =
ctx.options?.springActuatorPath === undefined
? undefined
: await importSpringActuatorRuntime(
ctx.graph,
ctx.repoPath,
ctx.options.springActuatorPath,
);
return {
metadataFiles,
autoConfigurations,
ambiguousAutoConfigurations: ambiguousQualifiedNames.size,
...(actuatorRuntime === undefined ? {} : { actuatorRuntime }),
};
},
};

View file

@ -65,6 +65,12 @@ export interface PipelineOptions {
* Community/Process rows can be kept instead of wipe+rewrite.
*/
skipDerivedGraphPhases?: boolean;
/**
* Explicit local Spring Boot Actuator snapshot input. Accepts a directory
* containing endpoint-named JSON files or a JSON bundle keyed by endpoint.
* Undefined keeps runtime enrichment completely disabled.
*/
springActuatorPath?: string;
/** Per-advice Spring AOP candidate inspection cap. `0` disables this cap. */
springAopMaxCandidateInspectionsPerAdvice?: number;
/** Aggregate Spring AOP candidate inspection cap for one analysis. `0` disables this cap. */

View file

@ -518,7 +518,7 @@ export const streamAllCSVsToDisk = async (
// Route nodes for API endpoint mapping
const routeWriter = new BufferedCSVWriter(
path.join(csvDir, 'route.csv'),
'id,name,filePath,responseKeys,errorKeys,middleware,method,handlerSymbolId',
'id,name,filePath,responseKeys,errorKeys,middleware,method,handlerSymbolId,runtimeConfirmed,runtimeSource,runtimeStatus',
);
// Tool nodes for MCP tool definitions
@ -701,6 +701,9 @@ export const streamAllCSVsToDisk = async (
escapeCSVField(middlewareStr),
escapeCSVField(String(node.properties.method ?? '')),
escapeCSVField(String(node.properties.handlerSymbolId ?? '')),
node.properties.runtimeConfirmed === true ? 'true' : 'false',
escapeCSVField(String(node.properties.runtimeSource ?? '')),
escapeCSVField(String(node.properties.runtimeStatus ?? '')),
].join(','),
);
break;

View file

@ -125,6 +125,7 @@ import { PDG_EDGE_TYPES } from './pdg-emit-sink.js';
* METHOD_IMPLEMENTS - mro-processor
* DEFINES - local-symbol-pruner's isFileDefinesEdge test
* INJECTS - di phase fan-out
* CONDITIONAL_ON - Spring Actuator runtime condition enrichment
*
* Deliberately NOT retained: STEP_IN_PROCESS / ENTRY_POINT_OF / MEMBER_OF
* (written only by the `processes` / `communities` phases, which the streaming
@ -150,6 +151,7 @@ export const RETAINED_REL_TYPES: ReadonlySet<RelationshipType> = new Set<Relatio
'METHOD_IMPLEMENTS',
'DEFINES',
'INJECTS',
'CONDITIONAL_ON',
// springAopInheritance reads direct behavior evidence after MRO.
'ADVISED_BY',
]);

View file

@ -1530,7 +1530,7 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Route') {
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method, handlerSymbolId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method, handlerSymbolId, runtimeConfirmed, runtimeSource, runtimeStatus) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Tool') {
return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;

View file

@ -233,6 +233,9 @@ CREATE NODE TABLE Route (
middleware STRING[],
method STRING,
handlerSymbolId STRING,
runtimeConfirmed BOOLEAN,
runtimeSource STRING,
runtimeStatus STRING,
PRIMARY KEY (id)
)`;

View file

@ -477,6 +477,11 @@ export interface AnalyzeOptions {
* consumer scan unchanged.
*/
fetchWrappers?: string[];
/**
* Explicit local Spring Boot Actuator snapshot input (#2418), forwarded to
* the Spring enrichment phase. Undefined keeps static-only analysis.
*/
springActuatorPath?: string;
/**
* The caller will `process.exit()` immediately after this analyze returns (the
* CLI `analyze` command). When set, the finalize/error close CHECKPOINTs for
@ -1653,6 +1658,22 @@ async function runFullAnalysisInner(
options = { ...options, force: true };
}
// Actuator snapshots are external runtime inputs and are intentionally not
// hashed or persisted. Rebuild on every enabled run so updated snapshots
// cannot hit the git freshness fast path; rebuild once when the option is
// removed so stale runtime-only evidence is cleared from the index.
const springActuatorRequested = options.springActuatorPath !== undefined;
const springActuatorPreviouslyEnabled = existingMeta?.springActuator?.enabled === true;
if (springActuatorRequested) {
if (!options.force) {
log('Spring Actuator runtime enrichment requested; forcing a full rebuild.');
}
options = { ...options, force: true };
} else if (springActuatorPreviouslyEnabled) {
log('Spring Actuator runtime enrichment disabled; rebuilding to remove runtime evidence.');
options = { ...options, force: true };
}
// ── Early-return: already up to date ──────────────────────────────
if (
existingMeta &&
@ -1958,6 +1979,7 @@ async function runFullAnalysisInner(
: undefined,
fetchWrappers: options.fetchWrappers,
skipDerivedGraphPhases,
springActuatorPath: options.springActuatorPath,
},
);
@ -3685,6 +3707,9 @@ async function runFullAnalysisInner(
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
runnerIdentity,
// Records only the mode, never the input path or payload. The next run
// uses this to remove runtime evidence when the opt-in is omitted.
...(springActuatorRequested ? { springActuator: { enabled: true as const } } : {}),
// Branch identity this index represents (#2106). Recorded for the flat
// slot too (so resolveBranchPlacement knows which branch owns it). When
// the label is null (detached HEAD / non-git re-analyze) we PRESERVE an

View file

@ -401,7 +401,7 @@ export const getNodeQuery = (table: string, includeContent: boolean): string =>
return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
}
if (table === 'Route') {
return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware`;
return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware, n.runtimeConfirmed AS runtimeConfirmed, n.runtimeSource AS runtimeSource, n.runtimeStatus AS runtimeStatus`;
}
if (table === 'Tool') {
return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description`;
@ -430,6 +430,9 @@ const mapGraphNodeRow = (table: string, row: any, includeContent: boolean): Grap
responseKeys: row.responseKeys,
errorKeys: row.errorKeys,
middleware: row.middleware,
runtimeConfirmed: row.runtimeConfirmed,
runtimeSource: row.runtimeSource,
runtimeStatus: row.runtimeStatus,
heuristicLabel: row.heuristicLabel,
cohesion: row.cohesion,
symbolCount: row.symbolCount,

View file

@ -86,6 +86,11 @@ export interface RepoMeta {
repoPath: string;
lastCommit: string;
indexedAt: string;
/**
* Runtime enrichment mode for this index. Only the enabled bit is persisted:
* input paths and Actuator payload data are deliberately excluded from meta.
*/
springActuator?: { enabled: true };
/**
* Analyzer/runtime receipt for the successful run represented by this
* metadata. Optional so indexes written by older GitNexus releases remain

View file

@ -0,0 +1,63 @@
/**
* Real-LadybugDB round trip for Spring Actuator Route evidence (#2418).
* Pins the schema, CSV, COPY, and graph-read projections that must agree for
* runtime-confirmed markers to survive beyond the in-memory ingestion graph.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { expect, it } from 'vitest';
import { getNodeQuery } from '../../src/server/api.js';
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js';
import { generateId } from '../../src/lib/utils.js';
import { buildTestGraph } from '../helpers/test-graph.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
const ROUTE_ID = generateId('Route', 'GET /runtime-confirmed');
withTestLbugDB('route-runtime-evidence-roundtrip', (handle) => {
it('persists and projects Route runtime evidence through CSV→COPY→query', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const graph = buildTestGraph([
{
id: ROUTE_ID,
label: 'Route',
name: '/runtime-confirmed',
filePath: 'RuntimeController.java',
extra: {
method: 'GET',
responseKeys: [],
errorKeys: [],
middleware: [],
runtimeConfirmed: true,
runtimeSource: 'spring-actuator',
runtimeStatus: 'runtime-confirmed',
},
},
]);
const csvDir = path.join(handle.tmpHandle.dbPath, 'csv-runtime-evidence');
const repoDir = path.join(handle.tmpHandle.dbPath, 'repo-runtime-evidence');
await fs.mkdir(repoDir, { recursive: true });
await streamAllCSVsToDisk(graph, repoDir, csvDir);
const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf8');
expect(routeCsv.split('\n')[0]).toContain('runtimeConfirmed,runtimeSource,runtimeStatus');
expect(routeCsv).toContain('spring-actuator');
const routeCsvPath = path.join(csvDir, 'route.csv').replace(/\\/g, '/');
await adapter.executeQuery(adapter.getCopyQuery('Route', routeCsvPath));
const rows = (await adapter.executeQuery(getNodeQuery('Route', false))) as Record<
string,
unknown
>[];
expect(rows).toContainEqual(
expect.objectContaining({
id: ROUTE_ID,
runtimeConfirmed: true,
runtimeSource: 'spring-actuator',
runtimeStatus: 'runtime-confirmed',
}),
);
});
});

View file

@ -0,0 +1,370 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { GraphNode } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const SECRET_ENV_VALUE = 'ACTUATOR_ENV_SECRET_2418';
const SECRET_CONFIG_VALUE = 'ACTUATOR_CONFIG_SECRET_2418';
const SECRET_CONDITION_MESSAGE = 'runtime condition details must stay private';
function writeFixture(root: string, relativePath: string, content: string): void {
const target = path.join(root, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}
function writeJson(root: string, relativePath: string, value: unknown): void {
writeFixture(root, relativePath, JSON.stringify(value));
}
function runtimeFixture(): { repo: string; actuatorDir: string } {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-'));
writeFixture(
repo,
'src/main/java/com/example/RuntimeApplication.java',
`package com.example;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class OrderController {
@GetMapping("/orders")
String list() { return "ok"; }
}
class SiblingController {
String list() { return "not-the-handler"; }
}
@Service
class BillingService {}
@Configuration
class RuntimeConfig {
@Bean
@ConditionalOnProperty(prefix = "app.billing", name = "enabled")
BillingService billingService() { return new BillingService(); }
}
class SiblingConfig {
BillingService billingService() { return new BillingService(); }
}
@ConfigurationProperties(prefix = "app.billing")
class BillingProperties {
String url;
}
`,
);
writeFixture(
repo,
'src/main/resources/application.properties',
'app.billing.enabled=true\napp.billing.url=https://static.example\n',
);
const actuatorDir = path.join(repo, 'runtime-actuator');
writeJson(repo, 'runtime-actuator/mappings.json', {
contexts: {
application: {
mappings: {
dispatcherServlets: {
dispatcherServlet: [
{
predicate: '{GET [/orders]}',
details: {
handlerMethod: {
className: 'com.example.OrderController',
name: 'list',
descriptor: '()Ljava/lang/String;',
},
requestMappingConditions: { methods: ['GET'], patterns: ['/orders'] },
},
},
{
predicate: '{GET [/runtime-bound]}',
details: {
handlerMethod: {
className: 'com.example.OrderController',
name: 'list',
descriptor: '()Ljava/lang/String;',
},
requestMappingConditions: {
methods: ['GET'],
patterns: ['/runtime-bound'],
},
},
},
{
predicate: '{POST [/runtime-only]}',
details: {
handlerMethod: {
className: 'com.vendor.RuntimeController',
name: 'create',
descriptor: '()V',
},
requestMappingConditions: {
methods: ['POST'],
patterns: ['/runtime-only'],
},
},
},
],
},
},
},
},
});
writeJson(repo, 'runtime-actuator/beans.json', {
contexts: {
application: {
beans: {
billingService: {
type: 'com.example.BillingService',
scope: 'singleton',
dependencies: [],
},
runtimeOnlyBean: {
type: 'com.vendor.RuntimeOnlyBean',
scope: 'singleton',
dependencies: [],
},
},
},
},
});
writeJson(repo, 'runtime-actuator/conditions.json', {
contexts: {
application: {
positiveMatches: {
'com.example.RuntimeConfig#billingService': [
{ condition: 'OnPropertyCondition', message: SECRET_CONDITION_MESSAGE },
],
},
negativeMatches: {},
},
},
});
writeJson(repo, 'runtime-actuator/configprops.json', {
contexts: {
application: {
beans: {
billing: {
prefix: 'app.billing',
properties: { url: SECRET_CONFIG_VALUE },
inputs: {
url: { value: SECRET_CONFIG_VALUE, origin: 'systemEnvironment' },
enabled: { value: true },
},
},
},
},
},
});
writeJson(repo, 'runtime-actuator/env.json', {
activeProfiles: [],
propertySources: [
{
name: 'systemEnvironment',
properties: {
'app.billing.url': { value: SECRET_ENV_VALUE, origin: 'env' },
'db.password': { value: SECRET_ENV_VALUE, origin: 'env' },
},
},
],
});
return { repo, actuatorDir };
}
describe('Spring Boot Actuator runtime enrichment (#2418)', () => {
const tempRepos: string[] = [];
afterEach(() => {
for (const repo of tempRepos.splice(0)) {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('is disabled by default and does not add runtime evidence', async () => {
const { repo } = runtimeFixture();
tempRepos.push(repo);
const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true });
expect(
[...result.graph.iterNodes()].some(
(node) => node.properties.runtimeSource === 'spring-actuator',
),
).toBe(false);
expect(
[...result.graph.iterRelationships()].some((edge) =>
edge.reason.startsWith('spring-actuator:'),
),
).toBe(false);
}, 60_000);
it('confirms static routes/beans/properties, adds runtime-only evidence, and drops values', async () => {
const { repo, actuatorDir } = runtimeFixture();
tempRepos.push(repo);
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: actuatorDir,
});
const nodes = [...result.graph.iterNodes()];
const nodeNamed = (name: string, label?: GraphNode['label']): GraphNode | undefined =>
nodes.find(
(node) => node.properties.name === name && (label === undefined || node.label === label),
);
expect(nodeNamed('/orders', 'Route')?.properties).toMatchObject({
method: 'GET',
runtimeConfirmed: true,
runtimeSource: 'spring-actuator',
});
expect(nodeNamed('/runtime-only', 'Route')?.properties).toMatchObject({
method: 'POST',
runtimeConfirmed: true,
});
const owner = nodeNamed('OrderController', 'Class');
const ownerMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
(edge) =>
edge.sourceId === owner?.id &&
result.graph.getNode(edge.targetId)?.properties.name === 'list',
)?.targetId;
expect(nodeNamed('/runtime-bound', 'Route')?.properties.handlerSymbolId).toBe(ownerMethodId);
expect(nodeNamed('BillingService', 'Class')?.properties.runtimeConfirmed).toBe(true);
expect(nodeNamed('runtimeOnlyBean', 'CodeElement')?.properties.runtimeConfirmed).toBe(true);
const configOwner = nodeNamed('RuntimeConfig', 'Class');
const configMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
(edge) =>
edge.sourceId === configOwner?.id &&
result.graph.getNode(edge.targetId)?.properties.name === 'billingService',
)?.targetId;
expect(result.graph.getNode(configMethodId ?? '')?.properties.runtimeStatus).toContain(
'matched',
);
const siblingOwner = nodeNamed('SiblingConfig', 'Class');
const siblingMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
(edge) =>
edge.sourceId === siblingOwner?.id &&
result.graph.getNode(edge.targetId)?.properties.name === 'billingService',
)?.targetId;
expect(result.graph.getNode(siblingMethodId ?? '')?.properties.runtimeStatus).toBeUndefined();
expect(nodeNamed('app.billing.url', 'Property')?.properties.runtimeConfirmed).toBe(true);
expect(nodeNamed('db.password', 'Property')?.properties.runtimeConfirmed).toBe(true);
const graphText = JSON.stringify({
nodes,
relationships: [...result.graph.iterRelationships()],
});
expect(graphText).not.toContain(SECRET_ENV_VALUE);
expect(graphText).not.toContain(SECRET_CONFIG_VALUE);
expect(graphText).not.toContain(SECRET_CONDITION_MESSAGE);
expect(graphText).not.toContain('systemEnvironment');
// The configured snapshot directory is excluded before source ingestion,
// so env/configprops values cannot leak into File-node content or FTS.
expect(
nodes.some((node) => String(node.properties.filePath).includes('runtime-actuator/')),
).toBe(false);
expect(
[...result.graph.iterRelationshipsByType('DECLARES')].some((edge) =>
edge.reason.startsWith('spring-actuator:mappings:'),
),
).toBe(true);
}, 60_000);
it('reports invalid JSON without echoing payload source text', async () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-invalid-'));
tempRepos.push(repo);
writeFixture(repo, 'index.ts', 'export const value = 1;\n');
writeFixture(repo, 'actuator/env.json', `{"value":"${SECRET_ENV_VALUE}`);
await expect(
runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: 'actuator',
}),
).rejects.toSatisfy(
(error: unknown) =>
error instanceof Error &&
error.message.includes('not valid JSON') &&
!error.message.includes(SECRET_ENV_VALUE),
);
}, 60_000);
it('accepts a bundle file and excludes that file from source ingestion', async () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-bundle-'));
tempRepos.push(repo);
writeFixture(repo, 'index.ts', 'export const value = 1;\n');
writeJson(repo, 'runtime-snapshot.json', {
env: {
propertySources: [
{
name: 'bundle-source',
properties: { 'bundle.secret-key': { value: SECRET_ENV_VALUE } },
},
],
},
});
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: 'runtime-snapshot.json',
});
const nodes = [...result.graph.iterNodes()];
expect(
nodes.find(
(node) => node.label === 'Property' && node.properties.name === 'bundle.secret-key',
)?.properties.runtimeConfirmed,
).toBe(true);
expect(nodes.some((node) => node.properties.filePath === 'runtime-snapshot.json')).toBe(false);
expect(JSON.stringify(nodes)).not.toContain(SECRET_ENV_VALUE);
expect(JSON.stringify(nodes)).not.toContain('bundle-source');
}, 60_000);
it('keeps repository sources when the Actuator directory is the repository root', async () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-root-'));
tempRepos.push(repo);
writeFixture(repo, 'index.ts', 'export const retained = true;\n');
writeJson(repo, 'mappings.json', { contexts: {} });
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: repo,
});
const files = [...result.graph.iterNodes()].filter((node) => node.label === 'File');
expect(files.some((node) => node.properties.filePath === 'index.ts')).toBe(true);
expect(files.some((node) => node.properties.filePath === 'mappings.json')).toBe(false);
}, 60_000);
it('keeps repository sources when the Actuator directory is an ancestor', async () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-parent-'));
tempRepos.push(parent);
const repo = path.join(parent, 'repo');
fs.mkdirSync(repo);
writeFixture(repo, 'index.ts', 'export const retained = true;\n');
writeJson(parent, 'mappings.json', { contexts: {} });
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: parent,
});
expect(
[...result.graph.iterNodes()].some(
(node) => node.label === 'File' && node.properties.filePath === 'index.ts',
),
).toBe(true);
}, 60_000);
});

View file

@ -262,6 +262,26 @@ describe('analyze-config (.gitnexusrc support, #243)', () => {
expect(() => loadAnalyzeConfig(dir)).toThrow(/at least one string/);
});
// ── Spring Actuator runtime enrichment (#2418) ────────────────────
it('normalizes a Spring Actuator snapshot path', async () => {
await writeRc(JSON.stringify({ springActuator: ' fixtures/actuator snapshots ' }));
expect(loadAnalyzeConfig(dir)).toEqual({
springActuator: 'fixtures/actuator snapshots',
});
});
it('rejects empty, non-string, and hidden-character Actuator paths', async () => {
await writeRc(JSON.stringify({ springActuator: ' ' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must not be empty/);
await writeRc(JSON.stringify({ springActuator: 42 }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/file or directory path/);
await writeRc('{"springActuator":"actuator\\u0007"}');
expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/);
});
// ── validateBranchName ─────────────────────────────────────────────
it('validateBranchName trims and accepts normal branch names', () => {

View file

@ -89,6 +89,24 @@ describe('analyzeCommand --workers validation', () => {
);
});
it('threads --spring-actuator through to the core pipeline', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
runFullAnalysisMock.mockResolvedValue({
repoName: 'repo',
repoPath: '/repo',
stats: {},
alreadyUpToDate: true,
});
await analyzeCommand(undefined, { springActuator: 'snapshots/actuator' });
expect(runFullAnalysisMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ springActuatorPath: 'snapshots/actuator' }),
expect.any(Object),
);
});
it('rejects --workers 0 with a CLI error (sequential parsing was removed)', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
runFullAnalysisMock.mockResolvedValue({

View file

@ -146,6 +146,9 @@ describe('streamGraphNdjson', () => {
async (query: string, onRow: (row: any) => Promise<void>) => {
if (query.includes('MATCH (n:`Route`)')) {
expect(query).not.toContain('startLine');
expect(query).toContain('runtimeConfirmed');
expect(query).toContain('runtimeSource');
expect(query).toContain('runtimeStatus');
await onRow({
id: 'Route:/api/graph:GET',
name: 'GET /api/graph',
@ -153,6 +156,9 @@ describe('streamGraphNdjson', () => {
responseKeys: ['nodes', 'relationships'],
errorKeys: ['error'],
middleware: ['withAuth'],
runtimeConfirmed: true,
runtimeSource: 'spring-actuator',
runtimeStatus: 'runtime-confirmed',
});
return 1;
}
@ -193,6 +199,9 @@ describe('streamGraphNdjson', () => {
responseKeys: ['nodes', 'relationships'],
errorKeys: ['error'],
middleware: ['withAuth'],
runtimeConfirmed: true,
runtimeSource: 'spring-actuator',
runtimeStatus: 'runtime-confirmed',
heuristicLabel: undefined,
cohesion: undefined,
symbolCount: undefined,

View file

@ -486,6 +486,65 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
it('rebuilds for Actuator snapshots and once more when runtime enrichment is disabled', async () => {
const repo = await setupMiniRepo();
const runtimeInput = 'runtime-actuator';
const runtimeInputDir = path.join(repo.dbPath, runtimeInput);
const secretValue = 'ACTUATOR_META_SECRET_2418';
try {
await mkdir(runtimeInputDir, { recursive: true });
await writeFile(
path.join(runtimeInputDir, 'env.json'),
JSON.stringify({
propertySources: [
{
name: 'systemEnvironment',
properties: { 'runtime.secret': { value: secretValue, origin: 'env' } },
},
],
}),
'utf-8',
);
gitCommitAll(repo.dbPath, 'add actuator runtime snapshot');
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const enabled = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, springActuatorPath: runtimeInput },
{ onProgress: () => {} },
);
expect(enabled.alreadyUpToDate).toBeUndefined();
const { storagePath } = getStoragePaths(repo.dbPath);
const enabledMeta = await loadMeta(storagePath);
expect(enabledMeta?.springActuator).toEqual({ enabled: true });
expect(JSON.stringify(enabledMeta)).not.toContain(runtimeInput);
expect(JSON.stringify(enabledMeta)).not.toContain(secretValue);
expect(Object.keys(enabledMeta?.fileHashes ?? {})).not.toContain(`${runtimeInput}/env.json`);
const disableLogs: string[] = [];
const disabled = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{ onProgress: () => {}, onLog: (message) => disableLogs.push(message) },
);
expect(disabled.alreadyUpToDate).toBeUndefined();
expect(disableLogs.join('\n')).toContain(
'Spring Actuator runtime enrichment disabled; rebuilding to remove runtime evidence.',
);
expect((await loadMeta(storagePath))?.springActuator).toBeUndefined();
const steady = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{ onProgress: () => {} },
);
expect(steady.alreadyUpToDate).toBe(true);
} finally {
await repo.cleanup();
}
}, 300_000);
it('a same-commit v8 index missing the global Class capability rebuilds before the fast path', async () => {
const repo = await setupMiniRepo();
try {

View file

@ -137,8 +137,8 @@ describe('buildPhaseList under streamGraphEmit', () => {
});
describe('RETAINED_REL_TYPES tracks its readers', () => {
it('streams write-only conditional and declaration evidence', () => {
expect(RETAINED_REL_TYPES.has('CONDITIONAL_ON')).toBe(false);
it('retains runtime-read conditional evidence and streams write-only declarations', () => {
expect(RETAINED_REL_TYPES.has('CONDITIONAL_ON')).toBe(true);
expect(RETAINED_REL_TYPES.has('DECLARES')).toBe(false);
});

View file

@ -112,6 +112,7 @@ describe('watch path selection', () => {
skipAgentsMd: false,
skipSkills: false,
stats: true,
springActuator: './actuator',
}),
);
const ignored: string[][] = [];
@ -128,7 +129,7 @@ describe('watch path selection', () => {
),
).resolves.toMatchObject({ skipAgentsMd: true, skipSkills: true });
expect(ignored).toEqual([
['embeddings', 'defaultBranch', 'skipAgentsMd', 'skipSkills', 'stats'],
['embeddings', 'defaultBranch', 'skipAgentsMd', 'skipSkills', 'stats', 'springActuator'],
]);
const unsupportedCliOptions: Array<[Parameters<typeof resolveWatchOptions>[1], string]> = [
@ -137,6 +138,7 @@ describe('watch path selection', () => {
[{ skipAgentsMd: true }, '--skip-agents-md'],
[{ skipSkills: true }, '--skip-skills'],
[{ stats: false }, '--no-stats'],
[{ springActuator: './actuator' }, '--spring-actuator'],
];
for (const [options, flag] of unsupportedCliOptions) {
await expect(