pi-lens 3.8.73 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2614 -12
- package/README.md +50 -21
- package/dist/clients/actionable-warnings.js +73 -18
- package/dist/clients/agent-behavior-client.js +21 -4
- package/dist/clients/ast-grep-client.js +68 -16
- package/dist/clients/ast-grep-types.js +0 -1
- package/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/dist/clients/atomic-write-staging.js +63 -0
- package/dist/clients/atomic-write.js +116 -16
- package/dist/clients/bash-file-access.js +193 -23
- package/dist/clients/biome-client.js +29 -25
- package/dist/clients/bootstrap.js +11 -2
- package/dist/clients/bounded-cache.js +34 -0
- package/dist/clients/bounded-pid-file-lock.js +192 -0
- package/dist/clients/bus-events-logger.js +5 -1
- package/dist/clients/bus-publish.js +10 -3
- package/dist/clients/cache/rule-cache.js +89 -6
- package/dist/clients/cache-manager.js +97 -9
- package/dist/clients/cache-observability.js +498 -0
- package/dist/clients/call-graph.js +460 -108
- package/dist/clients/cascade-logger.js +5 -1
- package/dist/clients/child-unref.js +85 -0
- package/dist/clients/codebase-model.js +52 -23
- package/dist/clients/collateral-test-role.js +40 -0
- package/dist/clients/complexity-client.js +6 -8
- package/dist/clients/console-guard-install.js +14 -0
- package/dist/clients/cooperative-budget.js +42 -0
- package/dist/clients/dead-code-client.js +2 -1
- package/dist/clients/debug-handles.js +206 -0
- package/dist/clients/debug-heap.js +167 -0
- package/dist/clients/degradation-ledger.js +153 -0
- package/dist/clients/dependency-checker.js +466 -63
- package/dist/clients/diagnostic-dispositions.js +83 -13
- package/dist/clients/diagnostics-publish.js +10 -3
- package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
- package/dist/clients/dispatch/dispatcher.js +71 -13
- package/dist/clients/dispatch/fact-store.js +11 -0
- package/dist/clients/dispatch/facts/function-facts.js +37 -7
- package/dist/clients/dispatch/facts/import-facts.js +108 -119
- package/dist/clients/dispatch/facts/tree-sitter-facts.js +12 -12
- package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
- package/dist/clients/dispatch/indent-detect.js +35 -0
- package/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/dist/clients/dispatch/integration.js +1094 -611
- package/dist/clients/dispatch/lazy.js +14 -0
- package/dist/clients/dispatch/plan.js +11 -1
- package/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/dist/clients/dispatch/rule-policy.js +135 -0
- package/dist/clients/dispatch/rules/async-noise.js +1 -0
- package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
- package/dist/clients/dispatch/runners/ast-grep-napi.js +47 -6
- package/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/dist/clients/dispatch/runners/detekt.js +3 -2
- package/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/dist/clients/dispatch/runners/index.js +5 -1
- package/dist/clients/dispatch/runners/lsp.js +14 -6
- package/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
- package/dist/clients/dispatch/runners/pyright.js +9 -5
- package/dist/clients/dispatch/runners/ruff.js +4 -8
- package/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/dist/clients/dispatch/runners/taplo.js +2 -1
- package/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/dist/clients/dispatch/runners/tflint.js +37 -5
- package/dist/clients/dispatch/runners/tree-sitter.js +155 -166
- package/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +284 -34
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/dist/clients/disposition-publish.js +10 -3
- package/dist/clients/durable-store.js +97 -0
- package/dist/clients/event-loop-monitor.js +94 -3
- package/dist/clients/extension-log.js +139 -0
- package/dist/clients/extension-mode.js +73 -0
- package/dist/clients/feature-hints.js +53 -7
- package/dist/clients/file-kinds.js +143 -31
- package/dist/clients/file-role.js +18 -3
- package/dist/clients/file-utils.js +56 -34
- package/dist/clients/fix-worklog.js +14 -2
- package/dist/clients/format-events-publish.js +16 -3
- package/dist/clients/format-service.js +24 -8
- package/dist/clients/formatters-lazy.js +8 -0
- package/dist/clients/formatters.js +358 -61
- package/dist/clients/generated-artifacts.js +169 -15
- package/dist/clients/git-guard.js +681 -11
- package/dist/clients/git-tracked-ignore.js +4 -0
- package/dist/clients/go-client.js +2 -1
- package/dist/clients/govulncheck-client.js +8 -0
- package/dist/clients/grammar-source.js +12 -4
- package/dist/clients/gzip-stage-write.js +102 -0
- package/dist/clients/host-ports.js +31 -0
- package/dist/clients/installer/index.js +733 -213
- package/dist/clients/instance-reaper.js +142 -172
- package/dist/clients/jscpd-client.js +31 -54
- package/dist/clients/knip-client.js +78 -36
- package/dist/clients/language-policy.js +14 -2
- package/dist/clients/language-profile.js +72 -77
- package/dist/clients/latency-logger.js +28 -16
- package/dist/clients/lens-config.js +140 -166
- package/dist/clients/lens-engine.js +124 -21
- package/dist/clients/lens-events.js +27 -4
- package/dist/clients/lens-flag-registry.js +293 -0
- package/dist/clients/lens-map.js +96 -26
- package/dist/clients/live-bus-emitter.js +30 -0
- package/dist/clients/log-cleanup.js +8 -18
- package/dist/clients/lsp/cascade-tier.js +23 -3
- package/dist/clients/lsp/client.js +566 -57
- package/dist/clients/lsp/config.js +74 -10
- package/dist/clients/lsp/diagnostic-binding.js +86 -0
- package/dist/clients/lsp/edits.js +1012 -121
- package/dist/clients/lsp/index.js +1840 -803
- package/dist/clients/lsp/language.js +4 -1
- package/dist/clients/lsp/launch.js +40 -32
- package/dist/clients/lsp/path-utils.js +1 -1
- package/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/dist/clients/lsp/server.js +279 -60
- package/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/dist/clients/lsp-document-symbols.js +112 -0
- package/dist/clients/lsp-lazy.js +8 -0
- package/dist/clients/lsp-mutation.js +248 -0
- package/dist/clients/mcp/analyze.js +128 -36
- package/dist/clients/mcp/ipc.js +157 -24
- package/dist/clients/mcp/review.js +36 -3
- package/dist/clients/mcp/session.js +216 -8
- package/dist/clients/memory-sampler.js +115 -0
- package/dist/clients/metrics-client.js +2 -1
- package/dist/clients/metrics-history.js +2 -1
- package/dist/clients/module-report-lsp.js +5 -0
- package/dist/clients/module-report.js +267 -82
- package/dist/clients/ndjson-logger.js +334 -103
- package/dist/clients/opengrep-client.js +1 -1
- package/dist/clients/partial-edit-apply.js +120 -5
- package/dist/clients/path-keyed-map.js +76 -0
- package/dist/clients/path-utils.js +141 -5
- package/dist/clients/performance-report.js +327 -0
- package/dist/clients/pipeline.js +54 -13
- package/dist/clients/print-mode.js +21 -0
- package/dist/clients/project-changes.js +117 -4
- package/dist/clients/project-diagnostics/cache.js +3 -2
- package/dist/clients/project-diagnostics/extractors.js +26 -115
- package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
- package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +117 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
- package/dist/clients/project-diagnostics/scanner.js +268 -128
- package/dist/clients/project-lens-config.js +138 -34
- package/dist/clients/project-report.js +64 -25
- package/dist/clients/project-scale.js +8 -10
- package/dist/clients/project-snapshot-persist-worker.js +6 -0
- package/dist/clients/project-snapshot.js +767 -8
- package/dist/clients/project-trust.js +181 -0
- package/dist/clients/quiet-window.js +27 -5
- package/dist/clients/read-bridge.js +147 -0
- package/dist/clients/read-expansion.js +22 -12
- package/dist/clients/read-guard-logger.js +159 -1
- package/dist/clients/read-guard-tool-lines.js +150 -29
- package/dist/clients/read-guard.js +262 -3
- package/dist/clients/redact/secrets.js +449 -0
- package/dist/clients/resource-sampler.js +144 -39
- package/dist/clients/reverse-deps.js +48 -0
- package/dist/clients/review-graph/builder.js +2570 -326
- package/dist/clients/review-graph/git-identity.js +2 -1
- package/dist/clients/review-graph/import-resolvers.js +21 -10
- package/dist/clients/review-graph/persist-worker.js +10 -0
- package/dist/clients/review-graph/query.js +5 -0
- package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
- package/dist/clients/review-graph/symbol-id.js +46 -0
- package/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/dist/clients/review-graph/workspace-modules.js +51 -3
- package/dist/clients/review-graph-logger.js +53 -0
- package/dist/clients/ruff-client.js +27 -44
- package/dist/clients/runtime-agent-end.js +111 -75
- package/dist/clients/runtime-context.js +36 -12
- package/dist/clients/runtime-coordinator.js +76 -8
- package/dist/clients/runtime-session.js +799 -137
- package/dist/clients/runtime-tool-call.js +117 -62
- package/dist/clients/runtime-tool-result.js +218 -23
- package/dist/clients/runtime-turn.js +284 -40
- package/dist/clients/rust-client.js +2 -1
- package/dist/clients/safe-spawn.js +873 -71
- package/dist/clients/security-scan-client.js +2 -1
- package/dist/clients/session-start-observability.js +15 -0
- package/dist/clients/session-state-store.js +5 -3
- package/dist/clients/sessionstart-logger.js +18 -0
- package/dist/clients/sg-runner.js +284 -159
- package/dist/clients/slow-fs.js +2 -1
- package/dist/clients/smells-rollup.js +224 -0
- package/dist/clients/source-filter.js +231 -43
- package/dist/clients/source-walker.js +66 -10
- package/dist/clients/startup-scan.js +15 -16
- package/dist/clients/startup-timing.js +9 -0
- package/dist/clients/test-runner-client.js +38 -8
- package/dist/clients/todo-scanner.js +30 -3
- package/dist/clients/tool-event.js +35 -0
- package/dist/clients/tool-policy.js +388 -39
- package/dist/clients/tool-render.js +155 -0
- package/dist/clients/tree-sitter-cache.js +153 -140
- package/dist/clients/tree-sitter-client.js +1722 -80
- package/dist/clients/tree-sitter-logger.js +64 -1
- package/dist/clients/tree-sitter-query-loader.js +117 -12
- package/dist/clients/tree-sitter-shared.js +48 -19
- package/dist/clients/tree-sitter-symbol-extractor.js +115 -11
- package/dist/clients/user-notify.js +51 -0
- package/dist/clients/vanished-instance-marker.js +57 -0
- package/dist/clients/warm-attach.js +33 -13
- package/dist/clients/widget-state.js +342 -49
- package/dist/clients/word-index-logger.js +44 -0
- package/dist/clients/word-index.js +612 -68
- package/dist/clients/workspace-topology.js +100 -7
- package/dist/index.js +65923 -47161
- package/dist/mcp/analyze-cli.js +185 -29
- package/dist/mcp/cli.js +110 -0
- package/dist/mcp/server.js +272 -50
- package/dist/tools/ast-grep-search.js +93 -27
- package/dist/tools/lens-diagnostics.js +247 -41
- package/dist/tools/lsp-diagnostics.js +153 -40
- package/dist/tools/lsp-navigation.js +51 -31
- package/dist/tools/module-report.js +9 -0
- package/dist/tools/symbol-search.js +21 -2
- package/docs/agent-guide.md +247 -0
- package/docs/agent-tools.md +21 -10
- package/docs/analysisall.md +192 -0
- package/docs/api-ports-inventory.md +165 -0
- package/docs/ast-grep_rules_catalog.md +5 -2
- package/docs/astplayground.md +3 -4
- package/docs/audit1.md +5 -5
- package/docs/durable-store-audit-1202.md +32 -0
- package/docs/environment-variables.md +197 -11
- package/docs/fable.md +27 -0
- package/docs/features.md +35 -5
- package/docs/globalconfig.md +101 -5
- package/docs/language-coverage.md +6 -5
- package/docs/lsp-capability-matrix.md +2 -2
- package/docs/mcp.md +54 -20
- package/docs/servercapabilities.md +7 -4
- package/docs/settings.md +192 -0
- package/docs/tools.md +18 -1
- package/docs/tree-sitter_rules_catalog.md +11 -31
- package/docs/usage.md +31 -0
- package/package.json +139 -138
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
- package/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
- package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
- package/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
- package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
- package/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
- package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
- package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
- package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
- package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
- package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
- package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
- package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
- package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
- package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
- package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
- package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
- package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
- package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
- package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
- package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
- package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
- package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
- package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
- package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
- package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
- package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
- package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
- package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
- package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
- package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
- package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
- package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
- package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
- package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
- package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
- package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
- package/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
- package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
- package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
- package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
- package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
- package/rules/typos/_typos.toml +17 -0
- package/scripts/analyze-pi-lens-logs.mjs +55 -2
- package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
- package/dist/clients/ast-grep-parser.js +0 -86
- package/dist/clients/config-enabled-shape.js +0 -39
- package/dist/clients/lsp/interactive-install.js +0 -367
- package/dist/clients/lsp/lsp-index.js +0 -10
- package/dist/clients/lsp/server-strategies.js +0 -5
- package/dist/clients/project-metadata.js +0 -690
- package/dist/clients/source-groups.js +0 -96
- package/dist/clients/types.js +0 -11
- package/docs/env_variables.md +0 -10
- package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
- package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
- package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
- package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
- package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
- package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
- package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
- package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
- package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
- package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
- package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
- package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
- package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
* Builds a bidirectional function-level call graph by resolving symbol
|
|
5
5
|
* references across files. Provides BFS impact analysis with severity tiers.
|
|
6
6
|
* Uses the Symbol/SymbolRef data produced by TreeSitterSymbolExtractor and
|
|
7
|
-
* persists the result
|
|
7
|
+
* persists the result under the canonical review-graph identity; freshness is
|
|
8
|
+
* owned exclusively by the review-graph snapshot.
|
|
8
9
|
*/
|
|
9
10
|
import * as fs from "node:fs";
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
import { getProjectDataDir } from "./file-utils.js";
|
|
12
|
-
|
|
13
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
14
|
+
import { parseSymbolKey as parseCanonicalSymbolKey } from "./review-graph/symbol-id.js";
|
|
15
|
+
import { normalizeMapKey, toProjectRelativePath } from "./path-utils.js";
|
|
16
|
+
/** Persisted call-graph cache format version. Bump on breaking format changes. */
|
|
17
|
+
export const CALL_GRAPH_CACHE_VERSION = 5;
|
|
13
18
|
function severityForDepth(depth) {
|
|
14
19
|
if (depth === 1)
|
|
15
20
|
return "WillBreak";
|
|
@@ -65,6 +70,19 @@ export function impact(graph, startKey, maxDepth = 3, minWeight = 0.1) {
|
|
|
65
70
|
// Sort by depth then symbolKey for stable output
|
|
66
71
|
return results.sort((a, b) => a.depth - b.depth || a.symbolKey.localeCompare(b.symbolKey));
|
|
67
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Splits a `SymbolKey` into its file and symbol-name parts. Two shapes exist
|
|
75
|
+
* (see `buildCallGraph` above): the normal `"filePath:symbolName"` form, and
|
|
76
|
+
* the file-level fallback `"file:filePath"` used when no enclosing symbol was
|
|
77
|
+
* found for a call site (module-level code). Splitting at the LAST colon
|
|
78
|
+
* (rather than a naive `split(":")`) means a Windows drive letter in
|
|
79
|
+
* `filePath` (`C:\...`) is never mistaken for the name/file separator; the
|
|
80
|
+
* `"file:"` fallback is matched as a literal prefix first so it isn't
|
|
81
|
+
* misparsed by the same last-colon rule.
|
|
82
|
+
*/
|
|
83
|
+
export function parseSymbolKey(key, knownFilePath) {
|
|
84
|
+
return parseCanonicalSymbolKey(key, knownFilePath);
|
|
85
|
+
}
|
|
68
86
|
/**
|
|
69
87
|
* Format an impact result set as a compact human-readable summary.
|
|
70
88
|
* Example: "handleToolResult (WillBreak) → handleAgentEnd (MayBreak) → 3 Review callers"
|
|
@@ -77,12 +95,9 @@ export function formatImpact(results, projectRoot) {
|
|
|
77
95
|
const review = results.filter((r) => r.severity === "Review");
|
|
78
96
|
const parts = [];
|
|
79
97
|
const label = (r) => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const file = r.symbolKey.includes(":")
|
|
84
|
-
? r.symbolKey.split(":").slice(0, -1).join(":").replace(projectRoot, "").replace(/^[/\\]/, "")
|
|
85
|
-
: "";
|
|
98
|
+
const parsed = parseSymbolKey(r.symbolKey);
|
|
99
|
+
const name = parsed.symbolName ?? r.symbolKey;
|
|
100
|
+
const file = toProjectRelativePath(parsed.filePath, projectRoot);
|
|
86
101
|
return file ? `${name} (${file})` : name;
|
|
87
102
|
};
|
|
88
103
|
if (willBreak.length > 0) {
|
|
@@ -113,15 +128,15 @@ const STDLIB_NAMES = new Set([
|
|
|
113
128
|
"open", "isinstance", "issubclass", "type", "super", "hasattr", "getattr",
|
|
114
129
|
"setattr", "enumerate", "zip", "map", "filter", "sorted", "reversed",
|
|
115
130
|
// Go
|
|
116
|
-
"fmt", "log", "os", "io", "err", "make", "append", "
|
|
131
|
+
"fmt", "log", "os", "io", "err", "make", "append", "cap", "copy",
|
|
117
132
|
"close", "delete", "panic", "recover", "new",
|
|
118
133
|
// Rust
|
|
119
|
-
"
|
|
120
|
-
"Box", "Rc", "Arc", "
|
|
134
|
+
"eprintln", "eprint", "vec", "Some", "None", "Ok", "Err",
|
|
135
|
+
"Box", "Rc", "Arc", "Vec", "HashMap", "HashSet", "format",
|
|
121
136
|
// Java/Kotlin
|
|
122
|
-
"System", "
|
|
137
|
+
"System", "toString", "equals", "hashCode", "Objects",
|
|
123
138
|
// Generic
|
|
124
|
-
"
|
|
139
|
+
"this", "self", "nil", "null", "undefined", "true", "false",
|
|
125
140
|
]);
|
|
126
141
|
// ── Core resolution ────────────────────────────────────────────────────────────
|
|
127
142
|
/**
|
|
@@ -130,29 +145,34 @@ const STDLIB_NAMES = new Set([
|
|
|
130
145
|
* discounts edges when many files define the same name.
|
|
131
146
|
*/
|
|
132
147
|
function buildDefIndex(allSymbols) {
|
|
133
|
-
const
|
|
148
|
+
const byName = new Map();
|
|
149
|
+
const byId = new Map();
|
|
134
150
|
for (const [, symbols] of allSymbols) {
|
|
135
151
|
for (const sym of symbols) {
|
|
136
|
-
if (!sym.name)
|
|
152
|
+
if (!sym.name || !sym.id)
|
|
137
153
|
continue;
|
|
138
|
-
const key =
|
|
139
|
-
|
|
154
|
+
const key = sym.id;
|
|
155
|
+
byId.set(key, sym);
|
|
156
|
+
const existing = byName.get(sym.name) ?? [];
|
|
140
157
|
if (!existing.includes(key))
|
|
141
158
|
existing.push(key);
|
|
142
|
-
|
|
159
|
+
byName.set(sym.name, existing);
|
|
143
160
|
}
|
|
144
161
|
}
|
|
145
|
-
return
|
|
162
|
+
return { byName, byId };
|
|
146
163
|
}
|
|
147
164
|
/**
|
|
148
|
-
* Find the enclosing function/method for a ref at `refLine
|
|
149
|
-
*
|
|
150
|
-
*
|
|
165
|
+
* Find the enclosing function/method for a ref at `refLine`. A symbol with
|
|
166
|
+
* an end line is eligible only while the reference is inside its inclusive
|
|
167
|
+
* source range; symbols without an end line retain the conservative start-line
|
|
168
|
+
* fallback. Returns undefined when no symbol encloses the reference so callers
|
|
169
|
+
* can use the file-level module key.
|
|
151
170
|
*/
|
|
152
171
|
function findEnclosingSymbol(symbols, refLine) {
|
|
153
172
|
let best;
|
|
154
173
|
for (const sym of symbols) {
|
|
155
174
|
if (sym.line <= refLine &&
|
|
175
|
+
(sym.endLine === undefined || refLine <= sym.endLine) &&
|
|
156
176
|
(sym.kind === "function" || sym.kind === "method")) {
|
|
157
177
|
if (!best || sym.line > best.line)
|
|
158
178
|
best = sym;
|
|
@@ -161,74 +181,224 @@ function findEnclosingSymbol(symbols, refLine) {
|
|
|
161
181
|
return best;
|
|
162
182
|
}
|
|
163
183
|
/**
|
|
164
|
-
* Build the function-level call graph from
|
|
184
|
+
* Build the function-level call graph from normalized review-graph evidence.
|
|
165
185
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
186
|
+
* The adapter supplies canonical target ids for graph evidence. Such evidence
|
|
187
|
+
* is accepted only when it points at a concrete symbol and has a non-ambiguous
|
|
188
|
+
* resolution. The old name-only path remains for legacy SymbolRef callers, but
|
|
189
|
+
* it is deliberately not used by the review-graph adapter (#1070).
|
|
169
190
|
*/
|
|
170
|
-
export function buildCallGraph(
|
|
171
|
-
|
|
191
|
+
export function buildCallGraph(
|
|
192
|
+
/** Keys are normalizeMapKey-canonical file paths; Symbol.filePath values retain their display spelling. */
|
|
193
|
+
allSymbols,
|
|
194
|
+
/** Keys are normalizeMapKey-canonical caller file paths; SymbolRef.filePath retains its display spelling. */
|
|
195
|
+
allRefs, inputCoverage) {
|
|
196
|
+
// Keep the lookup index canonical even for legacy direct callers that still
|
|
197
|
+
// construct these maps from display paths; persisted/review-graph callers
|
|
198
|
+
// already satisfy the canonical-key contract documented above. A caller that
|
|
199
|
+
// supplies TWO raw spellings of the same file gets last-writer-wins here —
|
|
200
|
+
// raw compatibility is best-effort tolerance, not a merge contract.
|
|
201
|
+
const canonicalSymbols = new Map();
|
|
202
|
+
for (const [filePath, symbols] of allSymbols)
|
|
203
|
+
canonicalSymbols.set(normalizeMapKey(filePath), symbols);
|
|
204
|
+
const defIndex = buildDefIndex(canonicalSymbols);
|
|
172
205
|
const callees = new Map();
|
|
173
206
|
const callers = new Map();
|
|
174
207
|
const inDegree = new Map();
|
|
175
|
-
const
|
|
208
|
+
const edgeByPair = new Map();
|
|
176
209
|
let unresolvedRefs = 0;
|
|
177
210
|
let totalRefs = 0;
|
|
211
|
+
const hasInputCoverage = inputCoverage !== undefined;
|
|
212
|
+
const coverage = inputCoverage
|
|
213
|
+
? { ...inputCoverage }
|
|
214
|
+
: {
|
|
215
|
+
totalEvidence: 0,
|
|
216
|
+
callsEvidence: 0,
|
|
217
|
+
referencesEvidence: 0,
|
|
218
|
+
eligibleEvidence: 0,
|
|
219
|
+
resolvedEvidence: 0,
|
|
220
|
+
unresolvedEvidence: 0,
|
|
221
|
+
typeOnlyEvidence: 0,
|
|
222
|
+
unsupportedEvidence: 0,
|
|
223
|
+
sameFileEvidence: 0,
|
|
224
|
+
duplicateEvidence: 0,
|
|
225
|
+
complete: false,
|
|
226
|
+
};
|
|
178
227
|
for (const [callerFile, refs] of allRefs) {
|
|
179
|
-
const
|
|
228
|
+
const callerFileKey = normalizeMapKey(callerFile);
|
|
229
|
+
const callerSymbols = canonicalSymbols.get(callerFileKey) ?? [];
|
|
180
230
|
for (const ref of refs) {
|
|
181
231
|
totalRefs++;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
232
|
+
if (!hasInputCoverage) {
|
|
233
|
+
// Legacy callers do not carry an evidenceKind, but each ref is still
|
|
234
|
+
// raw call-graph evidence and must contribute to coverage totals.
|
|
235
|
+
coverage.totalEvidence++;
|
|
236
|
+
if (ref.evidenceKind === "references" || ref.referenceKind === "type")
|
|
237
|
+
coverage.referencesEvidence++;
|
|
238
|
+
else
|
|
239
|
+
coverage.callsEvidence++;
|
|
240
|
+
}
|
|
241
|
+
if (ref.referenceKind === "type") {
|
|
242
|
+
if (!hasInputCoverage)
|
|
243
|
+
coverage.typeOnlyEvidence++;
|
|
185
244
|
continue;
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
245
|
+
}
|
|
246
|
+
if (ref.evidenceKind && ref.referenceKind !== "call") {
|
|
247
|
+
if (!hasInputCoverage)
|
|
248
|
+
coverage.unsupportedEvidence++;
|
|
189
249
|
continue;
|
|
190
250
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
251
|
+
const candidates = [];
|
|
252
|
+
if (ref.targetId) {
|
|
253
|
+
// Canonical graph identity is authoritative. Never recover a name by
|
|
254
|
+
// splitting an id: symbol ids contain kind and line components.
|
|
255
|
+
const callee = defIndex.byId.get(ref.targetId);
|
|
256
|
+
if (!callee || ref.resolution === "name-only" || ref.resolution === "unresolved") {
|
|
257
|
+
if (!hasInputCoverage) {
|
|
258
|
+
unresolvedRefs++;
|
|
259
|
+
coverage.unresolvedEvidence++;
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
candidates.push({
|
|
264
|
+
callee,
|
|
265
|
+
resolution: ref.resolution ?? "exact",
|
|
266
|
+
candidateCount: 1,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Compatibility for pre-#1070 callers that supplied only name refs.
|
|
271
|
+
// There is no typed/import evidence to select one definition, so retain
|
|
272
|
+
// every cross-file candidate and discount each edge. Picking [0] made
|
|
273
|
+
// graph iteration order decide which caller was reported.
|
|
274
|
+
const refName = ref.symbolName ?? parseSymbolKey(ref.symbolId, callerFile).symbolName ?? "";
|
|
275
|
+
if (STDLIB_NAMES.has(refName) || !refName) {
|
|
276
|
+
if (!hasInputCoverage)
|
|
277
|
+
coverage.unsupportedEvidence++;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const defs = defIndex.byName.get(refName);
|
|
281
|
+
if (!defs || defs.length === 0) {
|
|
282
|
+
if (!hasInputCoverage) {
|
|
283
|
+
unresolvedRefs++;
|
|
284
|
+
coverage.unresolvedEvidence++;
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const crossFileDefs = defs
|
|
289
|
+
.map((id) => defIndex.byId.get(id))
|
|
290
|
+
.filter((candidate) => candidate !== undefined && normalizeMapKey(candidate.filePath) !== callerFileKey);
|
|
291
|
+
if (crossFileDefs.length === 0) {
|
|
292
|
+
if (!hasInputCoverage)
|
|
293
|
+
coverage.unsupportedEvidence++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const resolution = crossFileDefs.length === 1 ? "exact" : "name-only";
|
|
297
|
+
for (const callee of crossFileDefs) {
|
|
298
|
+
candidates.push({ callee, resolution, candidateCount: crossFileDefs.length });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (ref.evidenceKind && candidates.some(({ resolution }) => resolution === "name-only")) {
|
|
302
|
+
if (!hasInputCoverage) {
|
|
303
|
+
unresolvedRefs++;
|
|
304
|
+
coverage.unresolvedEvidence++;
|
|
305
|
+
}
|
|
194
306
|
continue;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
307
|
+
}
|
|
308
|
+
const caller = ref.callerSymbolId
|
|
309
|
+
? defIndex.byId.get(ref.callerSymbolId)
|
|
310
|
+
: undefined;
|
|
311
|
+
const enclosing = caller ?? findEnclosingSymbol(callerSymbols, ref.line);
|
|
312
|
+
const callerKey = enclosing?.id ?? `file:${callerFile}`;
|
|
313
|
+
let producedEdge = false;
|
|
314
|
+
let duplicateRef = false;
|
|
315
|
+
for (const { callee, resolution, candidateCount } of candidates) {
|
|
316
|
+
if (normalizeMapKey(callee.filePath) === callerFileKey) {
|
|
317
|
+
// This projection is intentionally cross-file-only. A canonical
|
|
318
|
+
// same-file target is resolved by the source graph, but is represented
|
|
319
|
+
// by an explicit coverage category rather than as unsupported evidence.
|
|
320
|
+
// That keeps the projection contract while allowing complete coverage
|
|
321
|
+
// to gate user-facing cross-file impact honestly.
|
|
322
|
+
coverage.sameFileEvidence++;
|
|
323
|
+
if (hasInputCoverage) {
|
|
324
|
+
coverage.eligibleEvidence--;
|
|
325
|
+
coverage.resolvedEvidence--;
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
producedEdge = true;
|
|
330
|
+
const pairKey = `${callerKey}\u0000${callee.id}`;
|
|
331
|
+
const existing = edgeByPair.get(pairKey);
|
|
332
|
+
if (existing) {
|
|
333
|
+
existing.evidenceCount = (existing.evidenceCount ?? 1) + 1;
|
|
334
|
+
existing.evidenceKind = existing.evidenceKind === ref.evidenceKind
|
|
335
|
+
? existing.evidenceKind
|
|
336
|
+
: "mixed";
|
|
337
|
+
// Duplicate evidence is a property of the logical pair, not of
|
|
338
|
+
// whether the producer happened to tag the record. Keep the edge,
|
|
339
|
+
// coverage, and centrality views consistent for legacy refs too.
|
|
340
|
+
duplicateRef = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const weight = ref.evidenceKind ? 1 : 1 / Math.max(1, candidateCount);
|
|
344
|
+
const edge = {
|
|
214
345
|
callerFile,
|
|
215
346
|
callerSymbol: enclosing?.name,
|
|
216
347
|
callerKey,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
348
|
+
callerKind: enclosing?.kind,
|
|
349
|
+
callerLine: enclosing?.line,
|
|
350
|
+
callerColumn: enclosing?.column,
|
|
351
|
+
calleeFile: callee.filePath,
|
|
352
|
+
calleeSymbol: callee.name,
|
|
353
|
+
calleeKey: callee.id,
|
|
354
|
+
calleeKind: callee.kind,
|
|
355
|
+
calleeLine: callee.line,
|
|
356
|
+
calleeColumn: callee.column,
|
|
357
|
+
evidenceKind: ref.evidenceKind,
|
|
358
|
+
resolution,
|
|
359
|
+
evidenceCount: 1,
|
|
220
360
|
weight,
|
|
221
|
-
}
|
|
361
|
+
};
|
|
362
|
+
edgeByPair.set(pairKey, edge);
|
|
363
|
+
const callerCallees = callees.get(callerKey) ?? new Set();
|
|
364
|
+
callerCallees.add(callee.id);
|
|
365
|
+
callees.set(callerKey, callerCallees);
|
|
366
|
+
const calleeCallers = callers.get(callee.id) ?? new Set();
|
|
367
|
+
calleeCallers.add(callerKey);
|
|
368
|
+
callers.set(callee.id, calleeCallers);
|
|
369
|
+
inDegree.set(callee.id, (inDegree.get(callee.id) ?? 0) + weight);
|
|
370
|
+
}
|
|
371
|
+
if (!hasInputCoverage && producedEdge) {
|
|
372
|
+
// Legacy name-only ambiguity can fan one raw record out to several
|
|
373
|
+
// weighted edges. Coverage remains a count of raw records, so account
|
|
374
|
+
// for eligibility and duplicates once per reference, not once per
|
|
375
|
+
// candidate edge.
|
|
376
|
+
coverage.eligibleEvidence++;
|
|
377
|
+
coverage.resolvedEvidence++;
|
|
378
|
+
if (duplicateRef)
|
|
379
|
+
coverage.duplicateEvidence++;
|
|
222
380
|
}
|
|
381
|
+
if (hasInputCoverage && producedEdge && duplicateRef)
|
|
382
|
+
coverage.duplicateEvidence++;
|
|
223
383
|
}
|
|
224
384
|
}
|
|
385
|
+
// With adapter-supplied coverage, totalRefs is the raw evidence count. Some
|
|
386
|
+
// raw records (for example an external node with no caller file) cannot be
|
|
387
|
+
// represented in allRefs, but they must not disappear from accounting.
|
|
388
|
+
if (hasInputCoverage)
|
|
389
|
+
totalRefs = coverage.totalEvidence;
|
|
390
|
+
const languagesKnownComplete = Object.values(coverage.languages ?? {}).every((status) => status === "complete");
|
|
391
|
+
coverage.complete = coverage.complete && hasInputCoverage &&
|
|
392
|
+
coverage.unsupportedEvidence === 0 && languagesKnownComplete &&
|
|
393
|
+
(allSymbols.size > 0 || coverage.totalEvidence > 0);
|
|
225
394
|
return {
|
|
226
395
|
callees,
|
|
227
396
|
callers,
|
|
228
397
|
inDegree,
|
|
229
|
-
edges,
|
|
230
|
-
unresolvedRefs,
|
|
398
|
+
edges: [...edgeByPair.values()],
|
|
399
|
+
unresolvedRefs: hasInputCoverage ? coverage.unresolvedEvidence : unresolvedRefs,
|
|
231
400
|
totalRefs,
|
|
401
|
+
coverage,
|
|
232
402
|
builtAt: new Date().toISOString(),
|
|
233
403
|
};
|
|
234
404
|
}
|
|
@@ -240,39 +410,252 @@ function metaFilePath(cwd) {
|
|
|
240
410
|
return path.join(getProjectDataDir(cwd), "cache", "call-graph.meta.json");
|
|
241
411
|
}
|
|
242
412
|
/**
|
|
243
|
-
* Persist
|
|
244
|
-
*
|
|
413
|
+
* Persist a call-graph projection. Current callers pass the canonical
|
|
414
|
+
* review-graph identity. There is deliberately no independent file/mtime
|
|
415
|
+
* freshness argument: callers must use the review graph's canonical identity.
|
|
245
416
|
*/
|
|
246
|
-
export function saveCallGraph(cwd, graph,
|
|
417
|
+
export function saveCallGraph(cwd, graph, identity) {
|
|
247
418
|
const cacheFile = cacheFilePath(cwd);
|
|
248
419
|
const metaFile = metaFilePath(cwd);
|
|
249
420
|
try {
|
|
250
421
|
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
251
422
|
const persisted = {
|
|
252
|
-
version:
|
|
423
|
+
version: CALL_GRAPH_CACHE_VERSION,
|
|
253
424
|
builtAt: graph.builtAt,
|
|
254
|
-
|
|
425
|
+
reviewGraphVersion: identity.reviewGraphVersion,
|
|
426
|
+
reviewGraphSignature: identity.reviewGraphSignature,
|
|
255
427
|
edges: graph.edges,
|
|
256
428
|
callees: [...graph.callees.entries()].map(([k, v]) => [k, [...v]]),
|
|
257
429
|
callers: [...graph.callers.entries()].map(([k, v]) => [k, [...v]]),
|
|
258
430
|
inDegree: [...graph.inDegree.entries()],
|
|
431
|
+
totalRefs: graph.totalRefs,
|
|
432
|
+
unresolvedRefs: graph.unresolvedRefs,
|
|
433
|
+
coverage: graph.coverage,
|
|
259
434
|
};
|
|
260
|
-
|
|
261
|
-
|
|
435
|
+
writeFileAtomic(cacheFile, JSON.stringify(persisted));
|
|
436
|
+
writeFileAtomic(metaFile, JSON.stringify({ savedAt: new Date().toISOString(), edgeCount: graph.edges.length }));
|
|
262
437
|
}
|
|
263
438
|
catch {
|
|
264
439
|
// Non-fatal — next session rebuilds from scratch.
|
|
265
440
|
}
|
|
266
441
|
}
|
|
442
|
+
function inferLegacyCoverage(edges) {
|
|
443
|
+
let weightedTotalEvidence = 0;
|
|
444
|
+
let weightedReferencesEvidence = 0;
|
|
445
|
+
let weightedDuplicateEvidence = 0;
|
|
446
|
+
for (const edge of edges) {
|
|
447
|
+
const count = Number.isFinite(edge.evidenceCount) && (edge.evidenceCount ?? 0) > 0
|
|
448
|
+
? Math.floor(edge.evidenceCount)
|
|
449
|
+
: 1;
|
|
450
|
+
const weight = Number.isFinite(edge.weight) && edge.weight >= 0 ? edge.weight : 1;
|
|
451
|
+
weightedTotalEvidence += count * weight;
|
|
452
|
+
weightedDuplicateEvidence += Math.max(0, count - 1) * weight;
|
|
453
|
+
if (edge.evidenceKind === "references")
|
|
454
|
+
weightedReferencesEvidence += count * weight;
|
|
455
|
+
}
|
|
456
|
+
const totalEvidence = Math.max(0, Math.round(weightedTotalEvidence));
|
|
457
|
+
const referencesEvidence = Math.max(0, Math.round(weightedReferencesEvidence));
|
|
458
|
+
const duplicateEvidence = Math.max(0, Math.round(weightedDuplicateEvidence));
|
|
459
|
+
return {
|
|
460
|
+
totalEvidence,
|
|
461
|
+
callsEvidence: totalEvidence - referencesEvidence,
|
|
462
|
+
referencesEvidence,
|
|
463
|
+
eligibleEvidence: totalEvidence,
|
|
464
|
+
resolvedEvidence: totalEvidence,
|
|
465
|
+
unresolvedEvidence: 0,
|
|
466
|
+
typeOnlyEvidence: 0,
|
|
467
|
+
unsupportedEvidence: 0,
|
|
468
|
+
sameFileEvidence: 0,
|
|
469
|
+
duplicateEvidence,
|
|
470
|
+
// A legacy graph can still be queried, but it cannot prove that the old
|
|
471
|
+
// extractor covered every source/reference kind.
|
|
472
|
+
complete: false,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function loadCoverage(rawCoverage, edges) {
|
|
476
|
+
const inferred = inferLegacyCoverage(edges);
|
|
477
|
+
return rawCoverage
|
|
478
|
+
? {
|
|
479
|
+
...inferred,
|
|
480
|
+
...rawCoverage,
|
|
481
|
+
}
|
|
482
|
+
: inferred;
|
|
483
|
+
}
|
|
484
|
+
function finiteCount(value) {
|
|
485
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
486
|
+
}
|
|
487
|
+
function countsMatch(left, right) {
|
|
488
|
+
return Math.abs(left - right) <= 1e-9 * Math.max(1, Math.abs(left), Math.abs(right));
|
|
489
|
+
}
|
|
490
|
+
function validatePersistedCallGraph(raw, coverage) {
|
|
491
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
|
|
492
|
+
const enumValue = (value, values) => typeof value === "string" && values.includes(value);
|
|
493
|
+
const edgeKinds = ["calls", "references", "mixed"];
|
|
494
|
+
const resolutions = ["exact", "import", "receiver-type", "name-only", "unresolved"];
|
|
495
|
+
if (!raw || raw.version !== CALL_GRAPH_CACHE_VERSION || !nonEmptyString(raw.builtAt))
|
|
496
|
+
return false;
|
|
497
|
+
if (!nonEmptyString(raw.reviewGraphVersion) || !nonEmptyString(raw.reviewGraphSignature))
|
|
498
|
+
return false;
|
|
499
|
+
if (raw.coverage !== undefined &&
|
|
500
|
+
(!raw.coverage || typeof raw.coverage !== "object" || Array.isArray(raw.coverage)))
|
|
501
|
+
return false;
|
|
502
|
+
if (!Array.isArray(raw.edges) || !Array.isArray(raw.callees) || !Array.isArray(raw.callers) || !Array.isArray(raw.inDegree))
|
|
503
|
+
return false;
|
|
504
|
+
const coverageValues = [
|
|
505
|
+
coverage.totalEvidence,
|
|
506
|
+
coverage.callsEvidence,
|
|
507
|
+
coverage.referencesEvidence,
|
|
508
|
+
coverage.eligibleEvidence,
|
|
509
|
+
coverage.resolvedEvidence,
|
|
510
|
+
coverage.unresolvedEvidence,
|
|
511
|
+
coverage.typeOnlyEvidence,
|
|
512
|
+
coverage.unsupportedEvidence,
|
|
513
|
+
coverage.sameFileEvidence,
|
|
514
|
+
coverage.duplicateEvidence,
|
|
515
|
+
];
|
|
516
|
+
if (typeof coverage.complete !== "boolean" || coverageValues.some((value) => !finiteCount(value)))
|
|
517
|
+
return false;
|
|
518
|
+
if (coverage.languages !== undefined &&
|
|
519
|
+
(!coverage.languages || typeof coverage.languages !== "object" || Array.isArray(coverage.languages) ||
|
|
520
|
+
Object.entries(coverage.languages).some(([language, status]) => !nonEmptyString(language) || !enumValue(status, ["complete", "partial", "unavailable"]))))
|
|
521
|
+
return false;
|
|
522
|
+
if (coverage.callsEvidence + coverage.referencesEvidence !== coverage.totalEvidence)
|
|
523
|
+
return false;
|
|
524
|
+
if (coverage.resolvedEvidence > coverage.eligibleEvidence)
|
|
525
|
+
return false;
|
|
526
|
+
if (coverage.eligibleEvidence !== coverage.resolvedEvidence)
|
|
527
|
+
return false;
|
|
528
|
+
if (coverage.resolvedEvidence + coverage.unresolvedEvidence + coverage.typeOnlyEvidence + coverage.unsupportedEvidence + coverage.sameFileEvidence !== coverage.totalEvidence)
|
|
529
|
+
return false;
|
|
530
|
+
if (coverage.complete && coverage.unsupportedEvidence > 0)
|
|
531
|
+
return false;
|
|
532
|
+
if (coverage.complete && Object.values(coverage.languages ?? {}).some((status) => status !== "complete"))
|
|
533
|
+
return false;
|
|
534
|
+
const expectedCallees = new Map();
|
|
535
|
+
const expectedCallers = new Map();
|
|
536
|
+
const expectedInDegree = new Map();
|
|
537
|
+
const pairKeys = new Set();
|
|
538
|
+
let weightedEdgeEvidence = 0;
|
|
539
|
+
let weightedDuplicateEvidence = 0;
|
|
540
|
+
for (const edge of raw.edges) {
|
|
541
|
+
if (!edge || typeof edge !== "object" || !nonEmptyString(edge.callerKey) || !nonEmptyString(edge.calleeKey) ||
|
|
542
|
+
!nonEmptyString(edge.callerFile) || !nonEmptyString(edge.calleeFile) ||
|
|
543
|
+
!nonEmptyString(edge.calleeSymbol) || typeof edge.weight !== "number" || !Number.isFinite(edge.weight) || edge.weight <= 0 || edge.weight > 1)
|
|
544
|
+
return false;
|
|
545
|
+
if (edge.callerSymbol !== undefined && !nonEmptyString(edge.callerSymbol))
|
|
546
|
+
return false;
|
|
547
|
+
if (edge.callerKind !== undefined && !nonEmptyString(edge.callerKind))
|
|
548
|
+
return false;
|
|
549
|
+
if (edge.calleeKind !== undefined && !nonEmptyString(edge.calleeKind))
|
|
550
|
+
return false;
|
|
551
|
+
if (edge.evidenceKind !== undefined && !enumValue(edge.evidenceKind, edgeKinds))
|
|
552
|
+
return false;
|
|
553
|
+
if (edge.resolution !== undefined && !enumValue(edge.resolution, resolutions))
|
|
554
|
+
return false;
|
|
555
|
+
if (edge.evidenceCount !== undefined && (!finiteCount(edge.evidenceCount) || edge.evidenceCount < 1))
|
|
556
|
+
return false;
|
|
557
|
+
const evidenceCount = edge.evidenceCount ?? 1;
|
|
558
|
+
const callerIdentity = parseCanonicalSymbolKey(edge.callerKey, edge.callerFile);
|
|
559
|
+
const calleeIdentity = parseCanonicalSymbolKey(edge.calleeKey, edge.calleeFile);
|
|
560
|
+
if (normalizeMapKey(callerIdentity.filePath) !== normalizeMapKey(edge.callerFile) ||
|
|
561
|
+
normalizeMapKey(calleeIdentity.filePath) !== normalizeMapKey(edge.calleeFile) ||
|
|
562
|
+
(calleeIdentity.symbolName !== undefined && calleeIdentity.symbolName !== edge.calleeSymbol) ||
|
|
563
|
+
(callerIdentity.symbolName !== undefined && edge.callerSymbol !== undefined && callerIdentity.symbolName !== edge.callerSymbol))
|
|
564
|
+
return false;
|
|
565
|
+
const pair = `${edge.callerKey}\u0000${edge.calleeKey}`;
|
|
566
|
+
if (pairKeys.has(pair))
|
|
567
|
+
return false;
|
|
568
|
+
pairKeys.add(pair);
|
|
569
|
+
// Ambiguous legacy name-only evidence may fan one raw record out to
|
|
570
|
+
// multiple weighted edges. Weight the persisted evidence back to raw
|
|
571
|
+
// records so semantic validation agrees with coverage accounting.
|
|
572
|
+
weightedEdgeEvidence += evidenceCount * edge.weight;
|
|
573
|
+
weightedDuplicateEvidence += (evidenceCount - 1) * edge.weight;
|
|
574
|
+
const callees = expectedCallees.get(edge.callerKey) ?? new Set();
|
|
575
|
+
callees.add(edge.calleeKey);
|
|
576
|
+
expectedCallees.set(edge.callerKey, callees);
|
|
577
|
+
const callers = expectedCallers.get(edge.calleeKey) ?? new Set();
|
|
578
|
+
callers.add(edge.callerKey);
|
|
579
|
+
expectedCallers.set(edge.calleeKey, callers);
|
|
580
|
+
expectedInDegree.set(edge.calleeKey, (expectedInDegree.get(edge.calleeKey) ?? 0) + edge.weight);
|
|
581
|
+
}
|
|
582
|
+
if (!countsMatch(weightedEdgeEvidence, coverage.resolvedEvidence))
|
|
583
|
+
return false;
|
|
584
|
+
if (!countsMatch(weightedDuplicateEvidence, coverage.duplicateEvidence))
|
|
585
|
+
return false;
|
|
586
|
+
if (!countsMatch(coverage.totalEvidence, weightedEdgeEvidence + coverage.unresolvedEvidence + coverage.typeOnlyEvidence + coverage.unsupportedEvidence + coverage.sameFileEvidence))
|
|
587
|
+
return false;
|
|
588
|
+
if (raw.totalRefs !== undefined && !finiteCount(raw.totalRefs))
|
|
589
|
+
return false;
|
|
590
|
+
if (raw.unresolvedRefs !== undefined && !finiteCount(raw.unresolvedRefs))
|
|
591
|
+
return false;
|
|
592
|
+
if (raw.totalRefs !== undefined && raw.totalRefs !== coverage.totalEvidence)
|
|
593
|
+
return false;
|
|
594
|
+
if (raw.unresolvedRefs !== undefined && raw.unresolvedRefs !== coverage.unresolvedEvidence)
|
|
595
|
+
return false;
|
|
596
|
+
const readAdjacency = (entries) => {
|
|
597
|
+
const result = new Map();
|
|
598
|
+
if (!Array.isArray(entries))
|
|
599
|
+
return undefined;
|
|
600
|
+
for (const entry of entries) {
|
|
601
|
+
if (!Array.isArray(entry) || !nonEmptyString(entry[0]) || !Array.isArray(entry[1]) || entry[1].some((key) => !nonEmptyString(key)))
|
|
602
|
+
return undefined;
|
|
603
|
+
const keys = entry[1];
|
|
604
|
+
if (new Set(keys).size !== keys.length || result.has(entry[0]))
|
|
605
|
+
return undefined;
|
|
606
|
+
result.set(entry[0], new Set(keys));
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
};
|
|
610
|
+
const actualCallees = readAdjacency(raw.callees);
|
|
611
|
+
const actualCallers = readAdjacency(raw.callers);
|
|
612
|
+
if (!actualCallees || !actualCallers || actualCallees.size !== expectedCallees.size || actualCallers.size !== expectedCallers.size)
|
|
613
|
+
return false;
|
|
614
|
+
const sameSets = (actual, expected) => {
|
|
615
|
+
for (const [key, values] of expected) {
|
|
616
|
+
const got = actual.get(key);
|
|
617
|
+
if (!got || got.size !== values.size || [...values].some((value) => !got.has(value)))
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
};
|
|
622
|
+
if (!sameSets(actualCallees, expectedCallees) || !sameSets(actualCallers, expectedCallers))
|
|
623
|
+
return false;
|
|
624
|
+
const actualInDegree = new Map();
|
|
625
|
+
for (const entry of raw.inDegree) {
|
|
626
|
+
if (!Array.isArray(entry) || !nonEmptyString(entry[0]) || typeof entry[1] !== "number" || !Number.isFinite(entry[1]) || entry[1] < 0 || actualInDegree.has(entry[0]))
|
|
627
|
+
return false;
|
|
628
|
+
actualInDegree.set(entry[0], entry[1]);
|
|
629
|
+
}
|
|
630
|
+
if (actualInDegree.size !== expectedInDegree.size)
|
|
631
|
+
return false;
|
|
632
|
+
for (const [key, value] of expectedInDegree) {
|
|
633
|
+
if (actualInDegree.get(key) !== value)
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
267
638
|
/**
|
|
268
639
|
* Load the persisted call graph from disk.
|
|
269
640
|
* Returns undefined if the cache is missing, version-mismatched, or corrupt.
|
|
270
641
|
*/
|
|
271
|
-
export function loadCallGraph(cwd) {
|
|
642
|
+
export function loadCallGraph(cwd, expectedIdentity) {
|
|
272
643
|
const cacheFile = cacheFilePath(cwd);
|
|
273
644
|
try {
|
|
274
645
|
const raw = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
|
|
275
|
-
if (raw.version !==
|
|
646
|
+
if (raw.version !== CALL_GRAPH_CACHE_VERSION)
|
|
647
|
+
return undefined;
|
|
648
|
+
if (typeof raw.reviewGraphVersion !== "string" || typeof raw.reviewGraphSignature !== "string")
|
|
649
|
+
return undefined;
|
|
650
|
+
const identity = {
|
|
651
|
+
reviewGraphVersion: raw.reviewGraphVersion,
|
|
652
|
+
reviewGraphSignature: raw.reviewGraphSignature,
|
|
653
|
+
};
|
|
654
|
+
if (expectedIdentity && (identity.reviewGraphVersion !== expectedIdentity.reviewGraphVersion ||
|
|
655
|
+
identity.reviewGraphSignature !== expectedIdentity.reviewGraphSignature))
|
|
656
|
+
return undefined;
|
|
657
|
+
const coverage = loadCoverage(raw.coverage, raw.edges);
|
|
658
|
+
if (!validatePersistedCallGraph(raw, coverage))
|
|
276
659
|
return undefined;
|
|
277
660
|
return {
|
|
278
661
|
graph: {
|
|
@@ -280,46 +663,15 @@ export function loadCallGraph(cwd) {
|
|
|
280
663
|
callers: new Map(raw.callers.map(([k, v]) => [k, new Set(v)])),
|
|
281
664
|
inDegree: new Map(raw.inDegree),
|
|
282
665
|
edges: raw.edges,
|
|
283
|
-
unresolvedRefs:
|
|
284
|
-
totalRefs:
|
|
666
|
+
unresolvedRefs: raw.unresolvedRefs ?? coverage.unresolvedEvidence,
|
|
667
|
+
totalRefs: raw.totalRefs ?? coverage.totalEvidence,
|
|
668
|
+
coverage,
|
|
285
669
|
builtAt: raw.builtAt,
|
|
286
670
|
},
|
|
287
|
-
|
|
671
|
+
identity,
|
|
288
672
|
};
|
|
289
673
|
}
|
|
290
674
|
catch {
|
|
291
675
|
return undefined;
|
|
292
676
|
}
|
|
293
677
|
}
|
|
294
|
-
/**
|
|
295
|
-
* Returns the set of file paths whose mtime has changed since the cache was saved.
|
|
296
|
-
* Files not in the mtime map are treated as new (stale).
|
|
297
|
-
*/
|
|
298
|
-
export function staleFiles(fileMtimes, currentFiles) {
|
|
299
|
-
return currentFiles.filter((f) => {
|
|
300
|
-
const cached = fileMtimes.get(f);
|
|
301
|
-
if (cached === undefined)
|
|
302
|
-
return true; // new file
|
|
303
|
-
try {
|
|
304
|
-
return fs.statSync(f).mtimeMs !== cached;
|
|
305
|
-
}
|
|
306
|
-
catch {
|
|
307
|
-
return true; // deleted or unreadable
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Read current mtimes for a set of files.
|
|
313
|
-
*/
|
|
314
|
-
export function readMtimes(files) {
|
|
315
|
-
const mtimes = new Map();
|
|
316
|
-
for (const f of files) {
|
|
317
|
-
try {
|
|
318
|
-
mtimes.set(f, fs.statSync(f).mtimeMs);
|
|
319
|
-
}
|
|
320
|
-
catch {
|
|
321
|
-
// skip
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return mtimes;
|
|
325
|
-
}
|