pi-lens 4.1.2 → 4.1.4
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 +1308 -0
- package/README.md +3 -0
- package/config/biome/core.jsonc +11 -2
- package/config/dependency-cruiser-eager-allowlist.json +91 -0
- package/dist/clients/actionable-warnings-logger.js +18 -3
- package/dist/clients/actionable-warnings.js +927 -80
- package/dist/clients/agent-behavior-client.js +13 -4
- package/dist/clients/ast-grep-client.js +136 -9
- package/dist/clients/ast-grep-rule-manager.js +60 -5
- package/dist/clients/ast-grep-tool-logger.js +2 -2
- package/dist/clients/availability-probe-flight.js +12 -0
- package/dist/clients/bash-file-access.js +1 -3
- package/dist/clients/biome-client.js +16 -2
- package/dist/clients/blocker-freshness.js +34 -0
- package/dist/clients/bootstrap.js +509 -73
- package/dist/clients/bounded-cache.js +152 -12
- package/dist/clients/bounded-telemetry.js +115 -23
- package/dist/clients/build-identity.js +95 -0
- package/dist/clients/bundled-resource-health.js +113 -0
- package/dist/clients/bus-events-logger.js +2 -2
- package/dist/clients/bus-publish.js +3 -0
- package/dist/clients/cache/rule-cache.js +33 -5
- package/dist/clients/cache-manager.js +105 -6
- package/dist/clients/cache-observability.js +46 -19
- package/dist/clients/cargo-manifest.js +422 -0
- package/dist/clients/cascade-logger.js +17 -3
- package/dist/clients/code-quality-warnings.js +13 -3
- package/dist/clients/complexity-client.js +16 -5
- package/dist/clients/config-core/deny.js +221 -0
- package/dist/clients/config-core/index.js +47 -0
- package/dist/clients/config-core/merge.js +357 -0
- package/dist/clients/config-core/normalize.js +340 -0
- package/dist/clients/config-core/process-spec.js +248 -0
- package/dist/clients/config-core/provenance.js +171 -0
- package/dist/clients/config-core/records.js +218 -0
- package/dist/clients/config-core/resolve.js +125 -0
- package/dist/clients/config-core/safe-object.js +78 -0
- package/dist/clients/config-core/schema.js +167 -0
- package/dist/clients/config-diagnostic-codes.js +281 -0
- package/dist/clients/config-locations.js +160 -0
- package/dist/clients/config-resolve.js +789 -0
- package/dist/clients/config-schema.js +197 -0
- package/dist/clients/config-warn.js +407 -0
- package/dist/clients/dead-code-client.js +33 -25
- package/dist/clients/dead-code-logger.js +2 -2
- package/dist/clients/deadline-utils.js +178 -0
- package/dist/clients/debug-handles.js +2 -2
- package/dist/clients/debug-heap.js +3 -3
- package/dist/clients/deferred-lsp-work.js +106 -0
- package/dist/clients/degradation-ledger.js +166 -8
- package/dist/clients/demoted-finding-render.js +14 -0
- package/dist/clients/dependency-checker.js +28 -22
- package/dist/clients/deps/minimatch.js +1 -1
- package/dist/clients/diagnostic-line-freshness.js +12 -9
- package/dist/clients/diagnostic-logger.js +14 -4
- package/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/dist/clients/dispatch/dispatcher.js +304 -19
- package/dist/clients/dispatch/fact-store.js +328 -9
- package/dist/clients/dispatch/integration.js +214 -107
- package/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/dist/clients/dispatch/plan.js +21 -5
- package/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
- package/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/dist/clients/dispatch/runners/cpp-check.js +0 -1
- package/dist/clients/dispatch/runners/credo.js +0 -1
- package/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/dist/clients/dispatch/runners/detekt.js +0 -1
- package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/dist/clients/dispatch/runners/eslint.js +5 -2
- package/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/dist/clients/dispatch/runners/helm-lint.js +16 -4
- package/dist/clients/dispatch/runners/helm-render.js +54 -10
- package/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/dist/clients/dispatch/runners/index.js +1 -3
- package/dist/clients/dispatch/runners/javac.js +0 -1
- package/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/dist/clients/dispatch/runners/lsp.js +37 -2
- package/dist/clients/dispatch/runners/markdownlint.js +4 -2
- package/dist/clients/dispatch/runners/mypy.js +0 -1
- package/dist/clients/dispatch/runners/oxlint.js +24 -22
- package/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
- package/dist/clients/dispatch/runners/pyright.js +7 -4
- package/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/dist/clients/dispatch/runners/ruff.js +0 -1
- package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
- package/dist/clients/dispatch/runners/shellcheck.js +0 -1
- package/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/dist/clients/dispatch/runners/spellcheck.js +0 -1
- package/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/dist/clients/dispatch/runners/taplo.js +0 -1
- package/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/dist/clients/dispatch/runners/tflint.js +0 -1
- package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
- package/dist/clients/dispatch/runners/vale.js +0 -1
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/dist/clients/dispatch/runners/yamllint.js +0 -1
- package/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/dist/clients/disposition-logger.js +3 -3
- package/dist/clients/effective-config.js +403 -0
- package/dist/clients/error-class.js +23 -0
- package/dist/clients/event-loop-hold.js +274 -0
- package/dist/clients/event-loop-monitor.js +66 -1
- package/dist/clients/extension-log.js +2 -2
- package/dist/clients/feature-hints.js +2 -1
- package/dist/clients/file-time.js +6 -1
- package/dist/clients/file-utils.js +266 -13
- package/dist/clients/finding-delivery-gate.js +55 -13
- package/dist/clients/format-service.js +6 -1
- package/dist/clients/formatters.js +303 -104
- package/dist/clients/freshness-cadence.js +17 -0
- package/dist/clients/generated-artifacts.js +114 -19
- package/dist/clients/generation-guard.js +4 -9
- package/dist/clients/git-guard.js +198 -48
- package/dist/clients/git-tracked-ignore.js +32 -1
- package/dist/clients/go-client.js +39 -0
- package/dist/clients/govulncheck-client.js +5 -1
- package/dist/clients/gradle-ktfmt-style.js +252 -0
- package/dist/clients/gzip-stage-write.js +7 -0
- package/dist/clients/hashline-anchor.js +424 -0
- package/dist/clients/hook-budgets.js +76 -0
- package/dist/clients/host-edit-normalize.js +5 -2
- package/dist/clients/host-ports.js +1 -1
- package/dist/clients/installer/index.js +200 -84
- package/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/dist/clients/instance-reaper.js +108 -243
- package/dist/clients/instance-registry-lock.js +145 -0
- package/dist/clients/instance-registry.js +435 -122
- package/dist/clients/jscpd-client.js +6 -1
- package/dist/clients/language-profile.js +4 -2
- package/dist/clients/language-registry.js +595 -0
- package/dist/clients/latency-logger.js +179 -3
- package/dist/clients/ledger-bounds.js +13 -0
- package/dist/clients/lens-config.js +141 -30
- package/dist/clients/lens-engine.js +30 -37
- package/dist/clients/lens-flag-registry.js +67 -6
- package/dist/clients/lens-map.js +3 -1
- package/dist/clients/log-cleanup.js +2 -2
- package/dist/clients/lsp/aggregation.js +3 -1
- package/dist/clients/lsp/cascade-tier.js +4 -0
- package/dist/clients/lsp/client.js +672 -49
- package/dist/clients/lsp/config.js +504 -156
- package/dist/clients/lsp/diagnostic-binding.js +10 -5
- package/dist/clients/lsp/edits.js +15 -2
- package/dist/clients/lsp/index.js +1179 -247
- package/dist/clients/lsp/jvm-runtime.js +5 -0
- package/dist/clients/lsp/language.js +18 -169
- package/dist/clients/lsp/launch.js +38 -9
- package/dist/clients/lsp/pending-aux-coverage.js +113 -12
- package/dist/clients/lsp/server.js +426 -160
- package/dist/clients/lsp/session-roots.js +192 -0
- package/dist/clients/lsp/spawn-history.js +2 -7
- package/dist/clients/lsp/tsserver-sync.js +9 -2
- package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
- package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/dist/clients/lsp-mutation.js +179 -23
- package/dist/clients/map-with-concurrency.js +36 -0
- package/dist/clients/mcp/analyze.js +45 -10
- package/dist/clients/mcp/session.js +19 -17
- package/dist/clients/memory-sampler.js +28 -1
- package/dist/clients/message-end-attribution.js +30 -0
- package/dist/clients/metrics-history.js +21 -4
- package/dist/clients/middle-man-analysis.js +2 -4
- package/dist/clients/module-report.js +20 -45
- package/dist/clients/mutating-tool.js +651 -0
- package/dist/clients/mutation-attribution.js +368 -0
- package/dist/clients/mutation-bridge.js +240 -0
- package/dist/clients/ndjson-logger.js +247 -20
- package/dist/clients/observed-mutation-sources.js +101 -0
- package/dist/clients/observed-mutation.js +1215 -0
- package/dist/clients/opaque-mutation-scan.js +300 -40
- package/dist/clients/opengrep-config.js +4 -0
- package/dist/clients/package-manager.js +201 -44
- package/dist/clients/partial-edit-apply.js +359 -80
- package/dist/clients/path-attribution-telemetry.js +23 -7
- package/dist/clients/path-keyed-map.js +35 -3
- package/dist/clients/path-utils.js +460 -13
- package/dist/clients/persist-debounce.js +8 -1
- package/dist/clients/php-cs-fixer-config.js +114 -0
- package/dist/clients/pipeline.js +77 -24
- package/dist/clients/probe-home-state.js +228 -0
- package/dist/clients/process-bridge.js +66 -0
- package/dist/clients/process-singletons.js +210 -0
- package/dist/clients/process-snapshot.js +68 -0
- package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
- package/dist/clients/project-diagnostics/scanner.js +61 -24
- package/dist/clients/project-lens-config.js +393 -122
- package/dist/clients/project-snapshot.js +85 -35
- package/dist/clients/python-environment.js +217 -0
- package/dist/clients/python-provenance.js +639 -0
- package/dist/clients/quiet-window.js +6 -1
- package/dist/clients/read-bridge.js +13 -21
- package/dist/clients/read-expansion.js +9 -46
- package/dist/clients/read-guard-logger.js +39 -6
- package/dist/clients/read-guard-tool-lines.js +364 -212
- package/dist/clients/read-guard.js +95 -7
- package/dist/clients/recent-touches.js +17 -5
- package/dist/clients/resource-sampler.js +195 -96
- package/dist/clients/review-graph/builder.js +752 -132
- package/dist/clients/review-graph/service.js +55 -3
- package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/dist/clients/review-graph/workspace-modules.js +10 -74
- package/dist/clients/review-graph-logger.js +30 -4
- package/dist/clients/runtime-agent-end.js +18 -2
- package/dist/clients/runtime-context.js +49 -16
- package/dist/clients/runtime-coordinator.js +93 -6
- package/dist/clients/runtime-session.js +169 -34
- package/dist/clients/runtime-tool-call.js +183 -35
- package/dist/clients/runtime-tool-result.js +894 -241
- package/dist/clients/runtime-turn.js +1264 -74
- package/dist/clients/rust-client.js +31 -0
- package/dist/clients/safe-spawn.js +191 -28
- package/dist/clients/sanitize.js +7 -1
- package/dist/clients/scratch-tree-policy.js +1 -3
- package/dist/clients/security-scan-client.js +51 -17
- package/dist/clients/session-lifecycle.js +238 -42
- package/dist/clients/session-start-observability.js +79 -0
- package/dist/clients/sessionstart-logger.js +12 -3
- package/dist/clients/sg-runner.js +27 -15
- package/dist/clients/sgconfig.js +6 -1
- package/dist/clients/shared-checkout-guard.js +338 -0
- package/dist/clients/skills-resolver.js +105 -0
- package/dist/clients/smells-rollup.js +2 -2
- package/dist/clients/spawn-output-cap.js +37 -0
- package/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/dist/clients/startup-scan.js +6 -7
- package/dist/clients/startup-timing.js +69 -4
- package/dist/clients/string-utils.js +33 -0
- package/dist/clients/subagent-mode.js +17 -4
- package/dist/clients/test-runner-client.js +586 -118
- package/dist/clients/test-runner-delivery.js +239 -0
- package/dist/clients/tool-definition.js +41 -1
- package/dist/clients/tool-policy.js +92 -22
- package/dist/clients/tree-sitter-cache.js +40 -40
- package/dist/clients/tree-sitter-client.js +97 -53
- package/dist/clients/tree-sitter-logger.js +19 -3
- package/dist/clients/tree-sitter-query-loader.js +81 -1
- package/dist/clients/tree-sitter-shared.js +51 -46
- package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/dist/clients/typos-config.js +5 -0
- package/dist/clients/user-notify.js +6 -2
- package/dist/clients/warm-attach.js +8 -10
- package/dist/clients/widget-state.js +191 -26
- package/dist/clients/word-index-logger.js +15 -3
- package/dist/clients/word-index-store.js +463 -0
- package/dist/clients/word-index.js +815 -125
- package/dist/clients/workspace-topology.js +25 -1
- package/dist/clients/zizmor-config.js +29 -6
- package/dist/index.js +68577 -53950
- package/dist/mcp/analyze-cli.js +3 -1
- package/dist/mcp/build-staleness.js +8 -7
- package/dist/mcp/cli.js +3 -1
- package/dist/mcp/server.js +133 -6
- package/dist/scripts/lib/process-scan.mjs +583 -0
- package/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/dist/tools/effective-config.js +89 -0
- package/dist/tools/lens-diagnostics.js +99 -24
- package/dist/tools/lsp-diagnostics.js +81 -85
- package/dist/tools/lsp-navigation.js +40 -17
- package/dist/tools/shared.js +0 -1
- package/docs/agent-guide.md +68 -3
- package/docs/ast-grep_rules_catalog.md +41 -10
- package/docs/astplayground.md +26 -17
- package/docs/configuration.md +222 -0
- package/docs/dependencies.md +3 -3
- package/docs/features.md +44 -5
- package/docs/language-coverage.md +2 -2
- package/docs/pi-lens-fixer.md +25 -0
- package/docs/pi-lens-investigator.md +25 -0
- package/docs/pi-lens-reviewer.md +27 -0
- package/docs/pi-lens-subagent.md +38 -0
- package/docs/pi-lens-warden.md +55 -0
- package/docs/public-api-stability.md +359 -0
- package/docs/release-qa-baseline.md +182 -0
- package/docs/subagent-compat.md +110 -30
- package/docs/tree-sitter_rules_catalog.md +2 -2
- package/package.json +17 -21
- package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/scripts/install-selftest.mjs +58 -2
- package/scripts/lib/skills-predicate.mjs +129 -0
- package/scripts/rpc-load-check.mjs +3 -1
|
@@ -18,6 +18,8 @@ import { containerNameChain, getOpenDocumentSymbols, lspSymbolKindName, } from "
|
|
|
18
18
|
import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, toProjectRelativePath, } from "../path-utils.js";
|
|
19
19
|
import { collectProjectSourceFilesWithBudgetAsync } from "../project-scan-policy.js";
|
|
20
20
|
import { getReviewGraphMaxFilesDerived } from "../project-scale.js";
|
|
21
|
+
import { compareOrdinal, escapeRegExp } from "../string-utils.js";
|
|
22
|
+
import { BoundedLruCache } from "../bounded-cache.js";
|
|
21
23
|
import { jsTsCandidatePaths, resolveAliasedImport, resolveImportToFiles, resolveProjectReferenceImport, resolveWorkspacePackageImport, } from "./import-resolvers.js";
|
|
22
24
|
import { RUNTIME_CONFIG } from "../runtime-config.js";
|
|
23
25
|
import { buildReverseDependencyIndexFromGraph, rankFilesByReverseDependencyCentrality, } from "../reverse-deps.js";
|
|
@@ -25,7 +27,7 @@ import { buildQualifiedName, findOwnerName } from "../symbol-containment.js";
|
|
|
25
27
|
import { logTreeSitterCacheStats } from "../tree-sitter-logger.js";
|
|
26
28
|
import { flushReviewGraphLogSync, logReviewGraph, makeReviewGraphBuildMetadata, } from "../review-graph-logger.js";
|
|
27
29
|
import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
|
|
28
|
-
import { TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
|
|
30
|
+
import { symbolExtractionGrammar, TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
|
|
29
31
|
import { withTreeSitterRoot } from "../tree-sitter-shared.js";
|
|
30
32
|
import { incrementDegradationCount } from "../degradation-ledger.js";
|
|
31
33
|
import { resolveGitIdentity } from "./git-identity.js";
|
|
@@ -94,6 +96,45 @@ const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTEN
|
|
|
94
96
|
export const REVIEW_GRAPH_SOURCE_EXTENSIONS = MAIN_KIND_EXTENSIONS;
|
|
95
97
|
const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
|
|
96
98
|
const extractorCache = new Map();
|
|
99
|
+
const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
|
|
100
|
+
// Walker output is raw, so its spelling still needs the canonical path seam.
|
|
101
|
+
// Keep that expensive raw-to-canonical step per project across builds, bounded
|
|
102
|
+
// like TestRunnerClient's instance-lifetime canonicalRootMemo (#2058). A new
|
|
103
|
+
// spelling is resolved once; repeated builds reuse the result. The project
|
|
104
|
+
// memo is bounded and workspace-cache clears provide a freshness boundary.
|
|
105
|
+
const REVIEW_GRAPH_SOURCE_PATH_MEMO_ENTRIES = 16_384;
|
|
106
|
+
// BoundedLruCache, not BoundedFifoMap: a hit here must refresh the workspace's
|
|
107
|
+
// recency (the hand-rolled version did its own delete+set for exactly that),
|
|
108
|
+
// so a workspace still being built is never the one evicted (#2442).
|
|
109
|
+
const _sourcePathMemos = new BoundedLruCache(REVIEW_GRAPH_MAX_WARM_WORKSPACES);
|
|
110
|
+
function sourcePathMemo(cwd) {
|
|
111
|
+
const key = normalizeMapKey(path.resolve(cwd));
|
|
112
|
+
const existing = _sourcePathMemos.get(key);
|
|
113
|
+
if (existing)
|
|
114
|
+
return existing;
|
|
115
|
+
const memo = {
|
|
116
|
+
cache: new BoundedLruCache(REVIEW_GRAPH_SOURCE_PATH_MEMO_ENTRIES),
|
|
117
|
+
normalizeCalls: { value: 0 },
|
|
118
|
+
};
|
|
119
|
+
_sourcePathMemos.set(key, memo);
|
|
120
|
+
return memo;
|
|
121
|
+
}
|
|
122
|
+
function normalizeGraphSourcePath(memo, raw) {
|
|
123
|
+
const cached = memo.cache.get(raw);
|
|
124
|
+
if (cached !== undefined)
|
|
125
|
+
return cached;
|
|
126
|
+
const normalized = normalizeMapKey(raw);
|
|
127
|
+
// A missing file is normalized through resolveNonExisting, which lowercases
|
|
128
|
+
// its tail. It may be recreated with different casing before the next build,
|
|
129
|
+
// so never make that spelling a process-lifetime memo entry (#2072 F2).
|
|
130
|
+
if (fs.existsSync(raw))
|
|
131
|
+
memo.cache.set(raw, normalized);
|
|
132
|
+
memo.normalizeCalls.value++;
|
|
133
|
+
return normalized;
|
|
134
|
+
}
|
|
135
|
+
export function _resetReviewGraphSourcePathMemoForTests() {
|
|
136
|
+
_sourcePathMemos.clear();
|
|
137
|
+
}
|
|
97
138
|
// IN-FLIGHT Promise cache: deduplicates CONCURRENT buildOrUpdateGraph calls for
|
|
98
139
|
// the same (cwd, changedFiles). A separate workspace cache below preserves the
|
|
99
140
|
// expensive parsed graph across invocations when source file mtimes/sizes have
|
|
@@ -111,7 +152,6 @@ const extractorCache = new Map();
|
|
|
111
152
|
// operation's, not the process's.
|
|
112
153
|
const _buildCache = new Map();
|
|
113
154
|
const _workspaceGraphCache = new Map();
|
|
114
|
-
const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
|
|
115
155
|
const REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
|
|
116
156
|
// A workspace-wide clear must invalidate builds for workspaces that are not
|
|
117
157
|
// resident yet, too. This process-wide component therefore survives cache
|
|
@@ -166,6 +206,7 @@ function evictWorkspaceGraph(key, entry) {
|
|
|
166
206
|
_buildCache.delete(buildKey);
|
|
167
207
|
}
|
|
168
208
|
}
|
|
209
|
+
_sourcePathMemos.delete(key);
|
|
169
210
|
_workspaceCacheEpochs.set(key, (_workspaceCacheEpochs.get(key) ?? 0) + 1);
|
|
170
211
|
_workspaceGraphCache.delete(key);
|
|
171
212
|
}
|
|
@@ -191,8 +232,16 @@ function touchWorkspaceGraph(key) {
|
|
|
191
232
|
function setWorkspaceGraph(key, entry, epoch) {
|
|
192
233
|
if (epoch !== undefined && workspaceCacheEpoch(key) !== epoch)
|
|
193
234
|
return false;
|
|
235
|
+
const previous = _workspaceGraphCache.get(key);
|
|
236
|
+
if (previous)
|
|
237
|
+
clearWorkspaceGraphTimer(previous);
|
|
238
|
+
// #2255: bound what the cache RETAINS. The caller keeps its full-graph
|
|
239
|
+
// reference for the current turn; only the retained copy is trimmed, so an
|
|
240
|
+
// over-budget repo never accumulates an unbounded graph across the process.
|
|
241
|
+
const boundedGraph = retainedGraph(key, entry.graph);
|
|
194
242
|
const resident = {
|
|
195
243
|
...entry,
|
|
244
|
+
graph: boundedGraph,
|
|
196
245
|
lastUsedAt: Date.now(),
|
|
197
246
|
};
|
|
198
247
|
_workspaceGraphCache.set(key, resident);
|
|
@@ -292,8 +341,14 @@ export function clearReviewGraphWorkspaceCache(cwd) {
|
|
|
292
341
|
for (const entry of _workspaceGraphCache.values())
|
|
293
342
|
clearWorkspaceGraphTimer(entry);
|
|
294
343
|
_workspaceGraphCache.clear();
|
|
344
|
+
_sourcePathMemos.clear();
|
|
295
345
|
_workspaceCacheEpoch++;
|
|
296
346
|
_sizeSkipVerdicts.clear();
|
|
347
|
+
// #2255 review V2: the non-cache retention registry is memory-attribution
|
|
348
|
+
// state for THIS session's graphs. Without this, a new session's
|
|
349
|
+
// `memory_sample` kept attributing the previous session's graph, which the
|
|
350
|
+
// registry still held a live `WeakRef` to.
|
|
351
|
+
_retainedGraphSites.clear();
|
|
297
352
|
}
|
|
298
353
|
else {
|
|
299
354
|
const normalized = normalizeMapKey(cwd);
|
|
@@ -311,6 +366,7 @@ export function clearReviewGraphWorkspaceCache(cwd) {
|
|
|
311
366
|
clearWorkspaceGraphTimer(entry);
|
|
312
367
|
_workspaceCacheEpochs.set(normalized, (_workspaceCacheEpochs.get(normalized) ?? 0) + 1);
|
|
313
368
|
_workspaceGraphCache.delete(normalized);
|
|
369
|
+
_sourcePathMemos.delete(normalized);
|
|
314
370
|
_sizeSkipVerdicts.delete(normalized);
|
|
315
371
|
}
|
|
316
372
|
_lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
|
|
@@ -324,23 +380,217 @@ export function _setReviewGraphBuildGateForTests(gate) {
|
|
|
324
380
|
_reviewGraphBuildGateForTests = gate;
|
|
325
381
|
}
|
|
326
382
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
383
|
+
* Measured heap coefficients for the graph's current object shape. An
|
|
384
|
+
* isolated store of the production node and edge objects was forced through
|
|
385
|
+
* GC and divided by its census: 450.5 bytes per node and 243.0 bytes per
|
|
386
|
+
* edge, including the two edge-index references. The measurement used 18,695
|
|
387
|
+
* nodes and 48,815 edges across three identical runs with --expose-gc.
|
|
388
|
+
*/
|
|
389
|
+
const REVIEW_GRAPH_NODE_RESIDENT_BYTES = 450.5;
|
|
390
|
+
const REVIEW_GRAPH_EDGE_RESIDENT_BYTES = 243.0;
|
|
391
|
+
export function estimateReviewGraphStoreBytes(totalNodes, totalEdges) {
|
|
392
|
+
return (totalNodes * REVIEW_GRAPH_NODE_RESIDENT_BYTES +
|
|
393
|
+
totalEdges * REVIEW_GRAPH_EDGE_RESIDENT_BYTES);
|
|
394
|
+
}
|
|
395
|
+
// --- In-memory live-graph byte bound (#2255) ---
|
|
396
|
+
// The persist element cap (`GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT`) guards the
|
|
397
|
+
// synchronous serialize+gzip spike; it trims only the on-disk snapshot. The live
|
|
398
|
+
// `ReviewGraph` had no bound and grew with project size — the second unbounded
|
|
399
|
+
// dispatch store behind the #2240 OOM (FactStore's fileFacts was the first,
|
|
400
|
+
// bounded in #2243). This bound caps the RETAINED live graph by estimated
|
|
401
|
+
// resident bytes, using the same centrality-ranked induced-subgraph selection the
|
|
402
|
+
// snapshot uses (`capGraphForPersist`).
|
|
403
|
+
//
|
|
404
|
+
// A graph has TWO process-lifetime retention sites, not one: the workspace cache,
|
|
405
|
+
// and `session.reviewGraph` on a caller's FactStore — and two of those stores are
|
|
406
|
+
// module-scope (`dispatch/integration.ts`, `mcp/analyze.ts`, the latter never
|
|
407
|
+
// cleared). Bounding only the cache left the full graph resident on the fact, so
|
|
408
|
+
// both sites go through `retainedGraph` below, memoized per graph instance so the
|
|
409
|
+
// two sites share ONE bounded object instead of trimming twice (#2255 review F2).
|
|
410
|
+
export const GRAPH_MAX_IN_MEMORY_BYTES_DEFAULT = 512 * 1024 * 1024;
|
|
411
|
+
function graphMaxInMemoryBytes() {
|
|
412
|
+
const raw = Number(process.env.PI_LENS_GRAPH_MAX_IN_MEMORY_BYTES);
|
|
413
|
+
return Number.isFinite(raw) && raw > 0
|
|
414
|
+
? raw
|
|
415
|
+
: GRAPH_MAX_IN_MEMORY_BYTES_DEFAULT;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The graph any process-lifetime holder may RETAIN, bounded to the in-memory byte
|
|
419
|
+
* budget (#2255). Callers keep the full graph for the current turn; only what
|
|
420
|
+
* outlives the turn goes through here.
|
|
421
|
+
*
|
|
422
|
+
* In budget, this is an O(1) size read returning the same object, so a normal
|
|
423
|
+
* repository sees no behavior change. Over budget, the byte budget converts to an
|
|
424
|
+
* element cap using the graph's own node/edge split, then `capGraphForPersist`
|
|
425
|
+
* runs one centrality selection.
|
|
426
|
+
*
|
|
427
|
+
* Deliberately NOT memoized. A memo keyed on the source graph short-circuits the
|
|
428
|
+
* budget check, so a graph mutated in place after its first trim keeps answering
|
|
429
|
+
* with the stale earlier result (#2255 review R4). Callers that retain the same
|
|
430
|
+
* graph at two sites dedupe at the CALL SITE by capping once and sharing the
|
|
431
|
+
* result, which is both simpler and staleness-free.
|
|
432
|
+
*
|
|
433
|
+
* The result is marked `partial` AND `capTrimmed`. `partial` keeps every
|
|
434
|
+
* coverage-reporting consumer honest; `capTrimmed` records the narrower fact that
|
|
435
|
+
* the source WALK was complete and only size was cut, which is what lets the two
|
|
436
|
+
* incremental-base gates rebuild from it instead of forcing a full walk every
|
|
437
|
+
* turn. `capTrimmed` is process-local and stripped before persist.
|
|
438
|
+
*/
|
|
439
|
+
function retainedGraph(cwd, graph) {
|
|
440
|
+
const nodes = graph.nodes.size;
|
|
441
|
+
const edges = graph.edges.length;
|
|
442
|
+
const bytes = estimateReviewGraphStoreBytes(nodes, edges);
|
|
443
|
+
const budget = graphMaxInMemoryBytes();
|
|
444
|
+
// Deliberately NOT skipped for an already-partial graph. A full build over the
|
|
445
|
+
// source-walk entry budget sets `partial: true` on a graph that is still the
|
|
446
|
+
// full walked set, so skipping partials exempted the exact population this
|
|
447
|
+
// bound targets (#2255 review F3). The byte check below is the only
|
|
448
|
+
// idempotence this needs: a graph already under budget is returned as-is.
|
|
449
|
+
if (bytes <= budget)
|
|
450
|
+
return graph;
|
|
451
|
+
// Scale both axes by the same budget/bytes ratio so the element cap preserves
|
|
452
|
+
// the graph's node/edge split; `capGraphForPersist` re-splits the cap by that
|
|
453
|
+
// same ratio, landing the capped graph at or under the budget.
|
|
454
|
+
const elementCap = Math.max(1, Math.floor(((nodes + edges) * budget) / bytes));
|
|
455
|
+
let capped = capGraphForPersist(cwd, graph, elementCap);
|
|
456
|
+
// Floor: a selection that retains NOTHING is never an acceptable answer for a
|
|
457
|
+
// non-empty input — it silently converts "too big" into "no graph at all", and
|
|
458
|
+
// every query against it reads as a clean empty result (#2255 review F4).
|
|
459
|
+
// Fall back to a deterministic head slice, which is still bounded by the same
|
|
460
|
+
// element cap but cannot be empty, and say so under its own ledger kind so the
|
|
461
|
+
// two outcomes are never blended into one record.
|
|
462
|
+
if (capped.nodes.size === 0 && nodes > 0) {
|
|
463
|
+
capped = headSliceGraph(graph, elementCap);
|
|
464
|
+
incrementDegradationCount({
|
|
465
|
+
kind: "review-graph-memory-cap-floor",
|
|
466
|
+
subject: cwd,
|
|
467
|
+
reason: `centrality selection retained 0 of ${nodes} nodes at cap ${elementCap}; fell back to a ${capped.nodes.size}-node head slice`,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const cappedNodes = capped.nodes.size;
|
|
471
|
+
const cappedEdges = capped.edges.length;
|
|
472
|
+
capped.persistCoverage = {
|
|
473
|
+
...(capped.persistCoverage ?? graphCoverage(capped, elementCap)),
|
|
474
|
+
partial: true,
|
|
475
|
+
capTrimmed: true,
|
|
476
|
+
};
|
|
477
|
+
incrementDegradationCount({
|
|
478
|
+
kind: "review-graph-memory-cap",
|
|
479
|
+
subject: cwd,
|
|
480
|
+
reason: `live graph ${nodes}n/${edges}e ~${Math.round(bytes / (1024 * 1024))}MiB over ${Math.round(budget / (1024 * 1024))}MiB budget; trimmed to ${cappedNodes}n/${cappedEdges}e ~${Math.round(estimateReviewGraphStoreBytes(cappedNodes, cappedEdges) / (1024 * 1024))}MiB`,
|
|
481
|
+
});
|
|
482
|
+
return capped;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Deterministic non-empty fallback selection: take whole per-file node groups in
|
|
486
|
+
* stable path order until the element cap is reached, then the induced edges.
|
|
487
|
+
* Used only when centrality selection returns nothing (#2255 review F4).
|
|
488
|
+
*/
|
|
489
|
+
function headSliceGraph(graph, cap) {
|
|
490
|
+
const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / (graph.nodes.size + graph.edges.length)));
|
|
491
|
+
const keptIds = new Set();
|
|
492
|
+
for (const [id] of graph.nodes) {
|
|
493
|
+
if (keptIds.size >= nodeBudget)
|
|
494
|
+
break;
|
|
495
|
+
keptIds.add(id);
|
|
496
|
+
}
|
|
497
|
+
const nodes = new Map([...graph.nodes].filter(([id]) => keptIds.has(id)));
|
|
498
|
+
const edgeBudget = Math.max(0, cap - nodes.size);
|
|
499
|
+
const edges = graph.edges
|
|
500
|
+
.filter((edge) => keptIds.has(edge.from) && keptIds.has(edge.to))
|
|
501
|
+
.slice(0, edgeBudget);
|
|
502
|
+
const sliced = {
|
|
503
|
+
...graph,
|
|
504
|
+
nodes,
|
|
505
|
+
edges,
|
|
506
|
+
edgesByFrom: new Map(),
|
|
507
|
+
edgesByTo: new Map(),
|
|
508
|
+
fileNodes: new Map(),
|
|
509
|
+
symbolNodesByFile: new Map(),
|
|
510
|
+
changedSymbolsByFile: new Map(),
|
|
511
|
+
persistCoverage: undefined,
|
|
512
|
+
};
|
|
513
|
+
rebuildIndexes(sliced);
|
|
514
|
+
return sliced;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Store the graph on `session.reviewGraph` bounded (#2255 review F2). Two of the
|
|
518
|
+
* FactStores this reaches are module-scope, so an unbounded value here outlives
|
|
519
|
+
* every session and defeats the cache bound entirely.
|
|
520
|
+
*
|
|
521
|
+
* This bounds the VALUE, not the store, and deliberately adds no second bounding
|
|
522
|
+
* mechanism to `FactStore` (#2243 owns the store-level discipline).
|
|
523
|
+
*
|
|
524
|
+
* `sessionFacts` entry COUNT (a separate #2240 sibling, #2282) is now bounded
|
|
525
|
+
* too: `session.baseline.${path}` (`dispatch/dispatcher.ts`),
|
|
526
|
+
* `session.baseline.cascade.${path}` (`dispatch/integration.ts`), and this
|
|
527
|
+
* module's own `changedSymbols`/`entitySnapshot` per-file keys all go through
|
|
528
|
+
* `FactStore.setBoundedSessionFact`/`getBoundedSessionFact`, an LRU-count-cap
|
|
529
|
+
* sibling of the per-file `fileFacts` bound reusing the SAME discipline
|
|
530
|
+
* (#2243), not a second mechanism. `session.reviewGraph` itself stays on the
|
|
531
|
+
* plain, unbounded `sessionFacts` map — its key is a fixed singleton, so the
|
|
532
|
+
* VALUE bound below is what keeps IT small, not an entry-count cap.
|
|
533
|
+
*/
|
|
534
|
+
function setSessionReviewGraphFact(cwd, facts, graph) {
|
|
535
|
+
const retained = retainedGraph(cwd, graph);
|
|
536
|
+
// The raw `cwd` is the registry key on purpose. Folding it here would add a
|
|
537
|
+
// `realpath` probe to EVERY build, and the key only has to be stable: the
|
|
538
|
+
// snapshot deduplicates by graph object IDENTITY, so two spellings of one
|
|
539
|
+
// workspace cost one extra bounded registry slot and never a double count.
|
|
540
|
+
registerRetainedGraph(`fact:${cwd}`, retained);
|
|
541
|
+
facts.setSessionFact("session.reviewGraph", retained);
|
|
542
|
+
}
|
|
543
|
+
// Retention sites OUTSIDE the workspace cache — today `session.reviewGraph` on a
|
|
544
|
+
// caller's FactStore. Held through `WeakRef` so registering adds no retention of
|
|
545
|
+
// its own: a graph whose only remaining referent is this registry is collectable,
|
|
546
|
+
// and its slot is pruned on the next write or read. Keyed by site, so the map is
|
|
547
|
+
// bounded by the number of distinct workspaces, not by builds (#2255 review F2).
|
|
548
|
+
const _retainedGraphSites = new Map();
|
|
549
|
+
function registerRetainedGraph(site, graph) {
|
|
550
|
+
for (const [key, ref] of _retainedGraphSites) {
|
|
551
|
+
if (ref.deref() === undefined)
|
|
552
|
+
_retainedGraphSites.delete(key);
|
|
553
|
+
}
|
|
554
|
+
_retainedGraphSites.set(site, new WeakRef(graph));
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* O(retention-sites) snapshot of every resident review graph — NOT
|
|
558
|
+
* O(nodes)/O(edges): only `.size`/`.length` are read per graph, never the graph
|
|
559
|
+
* contents themselves (#1123 item 2 memory-attribution sample).
|
|
560
|
+
*
|
|
561
|
+
* Counts the workspace cache AND the non-cache retention sites, deduplicated by
|
|
562
|
+
* object IDENTITY. Reading the cache alone reported zero bytes while a full graph
|
|
563
|
+
* was still resident on `session.reviewGraph` — the sample read clean during the
|
|
564
|
+
* exact heap exhaustion it is cited to prove against (#2255 review F2). The
|
|
565
|
+
* dedupe matters because the bound hands both sites the same bounded object;
|
|
566
|
+
* summing them would double-count the common case.
|
|
332
567
|
*/
|
|
333
568
|
export function getReviewGraphWorkspaceCacheSnapshot() {
|
|
334
569
|
let totalNodes = 0;
|
|
335
570
|
let totalEdges = 0;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
571
|
+
const counted = new Set();
|
|
572
|
+
const count = (graph) => {
|
|
573
|
+
if (counted.has(graph))
|
|
574
|
+
return;
|
|
575
|
+
counted.add(graph);
|
|
576
|
+
totalNodes += graph.nodes.size;
|
|
577
|
+
totalEdges += graph.edges.length;
|
|
578
|
+
};
|
|
579
|
+
for (const entry of _workspaceGraphCache.values())
|
|
580
|
+
count(entry.graph);
|
|
581
|
+
for (const [key, ref] of _retainedGraphSites) {
|
|
582
|
+
const graph = ref.deref();
|
|
583
|
+
if (graph === undefined) {
|
|
584
|
+
_retainedGraphSites.delete(key);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
count(graph);
|
|
339
588
|
}
|
|
340
589
|
return {
|
|
341
590
|
cacheEntries: _workspaceGraphCache.size,
|
|
342
591
|
totalNodes,
|
|
343
592
|
totalEdges,
|
|
593
|
+
residentBytes: estimateReviewGraphStoreBytes(totalNodes, totalEdges),
|
|
344
594
|
};
|
|
345
595
|
}
|
|
346
596
|
/** Test-only cache keys, including the LRU order from oldest to newest. */
|
|
@@ -349,6 +599,26 @@ export function _getReviewGraphWorkspaceCacheKeysForTests() {
|
|
|
349
599
|
.sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
|
|
350
600
|
.map(([key]) => key);
|
|
351
601
|
}
|
|
602
|
+
/** Test-only replacement seam for the workspace idle-eviction family. */
|
|
603
|
+
export function _setReviewGraphWorkspaceEntryForTests(key, graph) {
|
|
604
|
+
setWorkspaceGraph(key, {
|
|
605
|
+
signature: "test",
|
|
606
|
+
fileSignatures: new Map(),
|
|
607
|
+
graph,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/** Test-only drive of the session-fact retention seam the build paths use. */
|
|
611
|
+
export function _setSessionReviewGraphFactForTests(cwd, facts, graph) {
|
|
612
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
613
|
+
}
|
|
614
|
+
/** Test-only view of what a graph's coverage looks like once persisted. */
|
|
615
|
+
export function _persistedCoverageForTests(coverage) {
|
|
616
|
+
return stripProcessLocalCoverage(coverage);
|
|
617
|
+
}
|
|
618
|
+
/** Test-only read of the exact retained graph for a raw cache key (#2255). */
|
|
619
|
+
export function _getReviewGraphWorkspaceGraphForTests(key) {
|
|
620
|
+
return _workspaceGraphCache.get(key)?.graph;
|
|
621
|
+
}
|
|
352
622
|
export function _getReviewGraphCacheStateForTests(cwd) {
|
|
353
623
|
const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
|
|
354
624
|
if (!cached)
|
|
@@ -552,9 +822,6 @@ function makeCtx(filePath, cwd, facts) {
|
|
|
552
822
|
log: () => { },
|
|
553
823
|
};
|
|
554
824
|
}
|
|
555
|
-
function escapeRegExp(string) {
|
|
556
|
-
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
557
|
-
}
|
|
558
825
|
function createEmptyGraph() {
|
|
559
826
|
return {
|
|
560
827
|
version: REVIEW_GRAPH_VERSION,
|
|
@@ -628,7 +895,7 @@ async function sourceSignatureMapAsync(files) {
|
|
|
628
895
|
}
|
|
629
896
|
function sourceSignatureFromMap(signatures) {
|
|
630
897
|
return [...signatures.entries()]
|
|
631
|
-
.sort(([a], [b]) => a
|
|
898
|
+
.sort(([a], [b]) => compareOrdinal(a, b))
|
|
632
899
|
.map(([file, signature]) => `${file}:${signature}`)
|
|
633
900
|
.join("|");
|
|
634
901
|
}
|
|
@@ -850,6 +1117,8 @@ export function _setReviewGraphEntryCounterForTests(counter) {
|
|
|
850
1117
|
_reviewGraphEntryCounterForTests = counter;
|
|
851
1118
|
}
|
|
852
1119
|
export async function getGraphSourceFiles(cwd) {
|
|
1120
|
+
const sourceMemo = sourcePathMemo(cwd);
|
|
1121
|
+
const normalizeCallsBefore = sourceMemo.normalizeCalls.value;
|
|
853
1122
|
// Async, chunked-yield walk (identical output to the sync collector) so the
|
|
854
1123
|
// per-edit cascade graph rebuild doesn't block the event loop on a large repo.
|
|
855
1124
|
//
|
|
@@ -889,6 +1158,7 @@ export async function getGraphSourceFiles(cwd) {
|
|
|
889
1158
|
// so the caller's `length > maxGraphFiles` check still trips.
|
|
890
1159
|
return {
|
|
891
1160
|
files: collected,
|
|
1161
|
+
pathNormalizeCalls: sourceMemo.normalizeCalls.value - normalizeCallsBefore,
|
|
892
1162
|
sourceFileCount: collected.length,
|
|
893
1163
|
maxFileCount: maxGraphFiles,
|
|
894
1164
|
entryBudgetExceeded,
|
|
@@ -897,7 +1167,7 @@ export async function getGraphSourceFiles(cwd) {
|
|
|
897
1167
|
const result = [];
|
|
898
1168
|
let sinceYield = 0;
|
|
899
1169
|
for (const raw of collected) {
|
|
900
|
-
const file =
|
|
1170
|
+
const file = normalizeGraphSourcePath(sourceMemo, raw);
|
|
901
1171
|
const kind = detectFileKind(file);
|
|
902
1172
|
// isWithinReviewGraphSizeLimit does a statSync per file — yield periodically
|
|
903
1173
|
// so the size-limit filter (one stat each) can't hold the loop in one burst.
|
|
@@ -917,19 +1187,30 @@ export async function getGraphSourceFiles(cwd) {
|
|
|
917
1187
|
}
|
|
918
1188
|
return {
|
|
919
1189
|
files: result,
|
|
1190
|
+
pathNormalizeCalls: sourceMemo.normalizeCalls.value - normalizeCallsBefore,
|
|
920
1191
|
sourceFileCount: result.length,
|
|
921
1192
|
maxFileCount: maxGraphFiles,
|
|
922
1193
|
entryBudgetExceeded,
|
|
923
1194
|
};
|
|
924
1195
|
}
|
|
925
1196
|
function addNode(graph, node) {
|
|
1197
|
+
// #2074: keep symbolNodesByFile live here, not only in rebuildIndexes, so the
|
|
1198
|
+
// incremental path can drop its terminal O(graph) reindex. Re-adding an id
|
|
1199
|
+
// already in the map must not push a duplicate — rebuildIndexes starts from
|
|
1200
|
+
// empty maps, so the two producers stay consistent.
|
|
1201
|
+
const isNew = !graph.nodes.has(node.id);
|
|
926
1202
|
graph.nodes.set(node.id, node);
|
|
927
1203
|
if (node.kind === "file" && node.filePath) {
|
|
928
1204
|
graph.fileNodes.set(node.filePath, node.id);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (isNew && node.kind === "symbol" && node.filePath) {
|
|
1208
|
+
const ids = graph.symbolNodesByFile.get(node.filePath) ?? [];
|
|
1209
|
+
ids.push(node.id);
|
|
1210
|
+
graph.symbolNodesByFile.set(node.filePath, ids);
|
|
929
1211
|
}
|
|
930
1212
|
}
|
|
931
|
-
function
|
|
932
|
-
graph.edges.push(edge);
|
|
1213
|
+
function indexEdge(graph, edge) {
|
|
933
1214
|
const from = graph.edgesByFrom.get(edge.from) ?? [];
|
|
934
1215
|
from.push(edge);
|
|
935
1216
|
graph.edgesByFrom.set(edge.from, from);
|
|
@@ -937,6 +1218,79 @@ function addEdge(graph, edge) {
|
|
|
937
1218
|
to.push(edge);
|
|
938
1219
|
graph.edgesByTo.set(edge.to, to);
|
|
939
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Drop `edge` from both adjacency buckets. This is kept for the single-edge
|
|
1223
|
+
* dedupe path; multi-file removal uses `unindexEdges` below so a shared hub
|
|
1224
|
+
* bucket is scanned once rather than once per removed edge (#2074).
|
|
1225
|
+
*/
|
|
1226
|
+
function unindexEdge(graph, edge) {
|
|
1227
|
+
const from = graph.edgesByFrom.get(edge.from);
|
|
1228
|
+
if (from) {
|
|
1229
|
+
// Count the full bucket as the linear `indexOf` scan's bounded work.
|
|
1230
|
+
_rebuildCounters.removeOwnedEdgePositions += from.length;
|
|
1231
|
+
const at = from.indexOf(edge);
|
|
1232
|
+
if (at >= 0)
|
|
1233
|
+
from.splice(at, 1);
|
|
1234
|
+
if (from.length === 0)
|
|
1235
|
+
graph.edgesByFrom.delete(edge.from);
|
|
1236
|
+
}
|
|
1237
|
+
const to = graph.edgesByTo.get(edge.to);
|
|
1238
|
+
if (to) {
|
|
1239
|
+
_rebuildCounters.removeOwnedEdgePositions += to.length;
|
|
1240
|
+
const at = to.indexOf(edge);
|
|
1241
|
+
if (at >= 0)
|
|
1242
|
+
to.splice(at, 1);
|
|
1243
|
+
if (to.length === 0)
|
|
1244
|
+
graph.edgesByTo.delete(edge.to);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Remove a batch of edges from both live adjacency indexes. Each touched
|
|
1249
|
+
* bucket is filtered once, so the work is proportional to the bucket lengths,
|
|
1250
|
+
* not to the product of a hub's fan-in and its removed-edge count (#2074).
|
|
1251
|
+
*/
|
|
1252
|
+
function unindexEdges(graph, removedEdges) {
|
|
1253
|
+
const fromIds = new Set();
|
|
1254
|
+
const toIds = new Set();
|
|
1255
|
+
for (const edge of removedEdges) {
|
|
1256
|
+
fromIds.add(edge.from);
|
|
1257
|
+
toIds.add(edge.to);
|
|
1258
|
+
}
|
|
1259
|
+
for (const fromId of fromIds) {
|
|
1260
|
+
const bucket = graph.edgesByFrom.get(fromId);
|
|
1261
|
+
if (!bucket)
|
|
1262
|
+
continue;
|
|
1263
|
+
const kept = [];
|
|
1264
|
+
for (const edge of bucket) {
|
|
1265
|
+
_rebuildCounters.removeOwnedEdgePositions++;
|
|
1266
|
+
if (!removedEdges.has(edge))
|
|
1267
|
+
kept.push(edge);
|
|
1268
|
+
}
|
|
1269
|
+
if (kept.length === 0)
|
|
1270
|
+
graph.edgesByFrom.delete(fromId);
|
|
1271
|
+
else
|
|
1272
|
+
graph.edgesByFrom.set(fromId, kept);
|
|
1273
|
+
}
|
|
1274
|
+
for (const toId of toIds) {
|
|
1275
|
+
const bucket = graph.edgesByTo.get(toId);
|
|
1276
|
+
if (!bucket)
|
|
1277
|
+
continue;
|
|
1278
|
+
const kept = [];
|
|
1279
|
+
for (const edge of bucket) {
|
|
1280
|
+
_rebuildCounters.removeOwnedEdgePositions++;
|
|
1281
|
+
if (!removedEdges.has(edge))
|
|
1282
|
+
kept.push(edge);
|
|
1283
|
+
}
|
|
1284
|
+
if (kept.length === 0)
|
|
1285
|
+
graph.edgesByTo.delete(toId);
|
|
1286
|
+
else
|
|
1287
|
+
graph.edgesByTo.set(toId, kept);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function addEdge(graph, edge) {
|
|
1291
|
+
graph.edges.push(edge);
|
|
1292
|
+
indexEdge(graph, edge);
|
|
1293
|
+
}
|
|
940
1294
|
function rebuildIndexes(graph) {
|
|
941
1295
|
graph.edgesByFrom = new Map();
|
|
942
1296
|
graph.edgesByTo = new Map();
|
|
@@ -1191,17 +1545,31 @@ function persistedData(pending) {
|
|
|
1191
1545
|
: undefined,
|
|
1192
1546
|
nodes: Array.from(pending.graph.nodes.entries()),
|
|
1193
1547
|
edges: pending.graph.edges,
|
|
1194
|
-
coverage: pending.graph.persistCoverage,
|
|
1548
|
+
coverage: stripProcessLocalCoverage(pending.graph.persistCoverage),
|
|
1195
1549
|
gitStamp: pending.gitStamp,
|
|
1196
1550
|
};
|
|
1197
1551
|
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Drop coverage fields that are true only of THIS process's graph before the
|
|
1554
|
+
* snapshot is written (#2255 review F5).
|
|
1555
|
+
*
|
|
1556
|
+
* `capTrimmed` means "this process walked every file, then cut for size", which
|
|
1557
|
+
* is what makes a graph a safe incremental base. A snapshot read back in a later
|
|
1558
|
+
* process carries no such guarantee — the tree may have changed underneath it —
|
|
1559
|
+
* so persisting the marker would let a hydrated graph claim base-eligibility
|
|
1560
|
+
* nobody established, the #936 laundering shape. `partial` itself is preserved,
|
|
1561
|
+
* so the snapshot still reports honestly that it is incomplete.
|
|
1562
|
+
*/
|
|
1563
|
+
function stripProcessLocalCoverage(coverage) {
|
|
1564
|
+
if (!coverage?.capTrimmed)
|
|
1565
|
+
return coverage;
|
|
1566
|
+
const { capTrimmed: _capTrimmed, ...persisted } = coverage;
|
|
1567
|
+
return persisted;
|
|
1568
|
+
}
|
|
1198
1569
|
function countRetainedSourceFiles(graph, sourceFilePaths) {
|
|
1199
1570
|
if (sourceFilePaths === undefined)
|
|
1200
1571
|
return graph.fileNodes.size;
|
|
1201
|
-
const sourceKeys = new Set();
|
|
1202
|
-
for (const filePath of sourceFilePaths) {
|
|
1203
|
-
sourceKeys.add(normalizeMapKey(filePath));
|
|
1204
|
-
}
|
|
1572
|
+
const sourceKeys = new Set(sourceFilePaths);
|
|
1205
1573
|
let retained = 0;
|
|
1206
1574
|
for (const filePath of sourceKeys) {
|
|
1207
1575
|
if (graph.fileNodes.has(filePath))
|
|
@@ -1235,13 +1603,52 @@ function capGraphForPersist(cwd, graph, cap, options = {}) {
|
|
|
1235
1603
|
const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / totalElements));
|
|
1236
1604
|
const reverseDeps = buildReverseDependencyIndexFromGraph({ cwd, graph });
|
|
1237
1605
|
const rankedFiles = rankFilesByReverseDependencyCentrality(reverseDeps);
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1606
|
+
// Both sides of this lookup must agree on path spelling. `rankedFiles` comes
|
|
1607
|
+
// from the reverse-dependency index, which keys by `normalizeMapKey`
|
|
1608
|
+
// (reverse-deps.ts), while this map keys by the RAW `node.filePath`. They agree
|
|
1609
|
+
// because production paths already arrive folded (normalizeGraphSourcePath), so
|
|
1610
|
+
// the raw pass is both correct and free. An unfolded path missed its lookup and
|
|
1611
|
+
// was dropped from the ranking with no signal (#2255 review F1).
|
|
1612
|
+
//
|
|
1613
|
+
// Folding every node up front would fix that but costs a `realpath` probe PER
|
|
1614
|
+
// NODE — measured at 93.5us per node, about 14 seconds at 150k nodes — on the
|
|
1615
|
+
// pre-existing persist cap that every over-cap build already pays. So fold
|
|
1616
|
+
// LAZILY: index raw first, and pay one folding pass only when the raw index
|
|
1617
|
+
// leaves nodes the ranking cannot reach. Reach, not emptiness, is the trigger:
|
|
1618
|
+
// a PARTIAL raw match would otherwise silently under-select the rest.
|
|
1619
|
+
const indexNodesByFile = (fold) => {
|
|
1620
|
+
const byFile = new Map();
|
|
1621
|
+
for (const [id, node] of graph.nodes) {
|
|
1622
|
+
if (!node.filePath)
|
|
1623
|
+
continue;
|
|
1624
|
+
const fileKey = fold ? normalizeMapKey(node.filePath) : node.filePath;
|
|
1625
|
+
const ids = byFile.get(fileKey) ?? [];
|
|
1626
|
+
ids.push(id);
|
|
1627
|
+
byFile.set(fileKey, ids);
|
|
1628
|
+
}
|
|
1629
|
+
return byFile;
|
|
1630
|
+
};
|
|
1631
|
+
// Measure reach in NODES, not files. Counting files is too weak: when only some
|
|
1632
|
+
// of a file's nodes are unfolded, the file still appears covered while its group
|
|
1633
|
+
// is short, and the missing nodes drop out of the ranking with no signal.
|
|
1634
|
+
const reachableNodes = (byFile) => {
|
|
1635
|
+
let reached = 0;
|
|
1636
|
+
for (const filePath of rankedFiles)
|
|
1637
|
+
reached += byFile.get(filePath)?.length ?? 0;
|
|
1638
|
+
return reached;
|
|
1639
|
+
};
|
|
1640
|
+
let placeableNodes = 0;
|
|
1641
|
+
for (const node of graph.nodes.values())
|
|
1642
|
+
if (node.filePath)
|
|
1643
|
+
placeableNodes++;
|
|
1644
|
+
let nodeIdsByFile = indexNodesByFile(false);
|
|
1645
|
+
if (reachableNodes(nodeIdsByFile) < placeableNodes) {
|
|
1646
|
+
const folded = indexNodesByFile(true);
|
|
1647
|
+
// Keep whichever index the ranking can actually reach. Folding repairs
|
|
1648
|
+
// unfolded input; it is never a downgrade for input that was already correct.
|
|
1649
|
+
if (reachableNodes(folded) > reachableNodes(nodeIdsByFile)) {
|
|
1650
|
+
nodeIdsByFile = folded;
|
|
1651
|
+
}
|
|
1245
1652
|
}
|
|
1246
1653
|
const keptIds = new Set();
|
|
1247
1654
|
for (const filePath of rankedFiles) {
|
|
@@ -1305,7 +1712,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
|
|
|
1305
1712
|
type: "phase",
|
|
1306
1713
|
phase: "review_graph_persist",
|
|
1307
1714
|
filePath: pending.cachePath,
|
|
1308
|
-
durationMs: stats.
|
|
1715
|
+
durationMs: stats.durationMs,
|
|
1309
1716
|
metadata: { elements: pending.elementCount, ...stats },
|
|
1310
1717
|
});
|
|
1311
1718
|
logReviewGraph({
|
|
@@ -1313,6 +1720,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
|
|
|
1313
1720
|
phase: "persist_succeeded",
|
|
1314
1721
|
elements: pending.elementCount,
|
|
1315
1722
|
...stats,
|
|
1723
|
+
durationMs: stats.durationMs,
|
|
1316
1724
|
observability: persistObservability(pending, {
|
|
1317
1725
|
status: "succeeded",
|
|
1318
1726
|
workerStarted: workerState.started,
|
|
@@ -1322,6 +1730,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
|
|
|
1322
1730
|
});
|
|
1323
1731
|
}
|
|
1324
1732
|
function writePendingOnMainThread(key, pending, reason, workerState = { started: false, completed: false }) {
|
|
1733
|
+
const persistStarted = performance.now();
|
|
1325
1734
|
const serializeStarted = performance.now();
|
|
1326
1735
|
try {
|
|
1327
1736
|
const json = JSON.stringify(persistedData(pending));
|
|
@@ -1339,6 +1748,7 @@ function writePendingOnMainThread(key, pending, reason, workerState = { started:
|
|
|
1339
1748
|
gzBytes: gzip.byteLength,
|
|
1340
1749
|
serializeMs,
|
|
1341
1750
|
writeMs: performance.now() - writeStarted,
|
|
1751
|
+
durationMs: performance.now() - persistStarted,
|
|
1342
1752
|
offloaded: false,
|
|
1343
1753
|
}, {
|
|
1344
1754
|
started: workerState.started,
|
|
@@ -1427,7 +1837,8 @@ function handleWorkerResult(result) {
|
|
|
1427
1837
|
result.rawBytes === undefined ||
|
|
1428
1838
|
result.gzBytes === undefined ||
|
|
1429
1839
|
result.serializeMs === undefined ||
|
|
1430
|
-
result.writeMs === undefined
|
|
1840
|
+
result.writeMs === undefined ||
|
|
1841
|
+
result.durationMs === undefined) {
|
|
1431
1842
|
fs.rm(result.stagePath, { force: true }, () => { });
|
|
1432
1843
|
writePendingOnMainThread(key, pending, result.error ?? "invalid worker result", {
|
|
1433
1844
|
started: true,
|
|
@@ -1446,6 +1857,7 @@ function handleWorkerResult(result) {
|
|
|
1446
1857
|
gzBytes: result.gzBytes,
|
|
1447
1858
|
serializeMs: result.serializeMs,
|
|
1448
1859
|
writeMs: result.writeMs,
|
|
1860
|
+
durationMs: result.durationMs,
|
|
1449
1861
|
offloaded: true,
|
|
1450
1862
|
}, { started: true, completed: true });
|
|
1451
1863
|
}
|
|
@@ -1937,7 +2349,7 @@ function hashIgnoredIds(ignoredIds) {
|
|
|
1937
2349
|
if (ignoredIds === undefined)
|
|
1938
2350
|
return "unavailable";
|
|
1939
2351
|
const joined = [...ignoredIds]
|
|
1940
|
-
.sort((a, b) => a
|
|
2352
|
+
.sort((a, b) => compareOrdinal(a, b))
|
|
1941
2353
|
.join("\u0000");
|
|
1942
2354
|
return createHash("sha256").update(joined).digest("hex");
|
|
1943
2355
|
}
|
|
@@ -2248,9 +2660,16 @@ async function tryResumeFromCheckpoint(cwd, filesToBuild, ignoredIds) {
|
|
|
2248
2660
|
}
|
|
2249
2661
|
const graph = loaded.graph;
|
|
2250
2662
|
// Evict every stale (content-changed) processed file so its outdated
|
|
2251
|
-
// contribution is replaced by a fresh walk below.
|
|
2663
|
+
// contribution is replaced by a fresh walk below. One shared drop set,
|
|
2664
|
+
// compacted once (#2074) — pruneOrphanNonFileNodes reads graph.edges next,
|
|
2665
|
+
// so the compaction must land before it runs.
|
|
2666
|
+
const removedEdges = new Set();
|
|
2252
2667
|
for (const file of stale)
|
|
2253
|
-
removeFileOwnedGraphData(graph, file);
|
|
2668
|
+
removeFileOwnedGraphData(graph, file, removedEdges);
|
|
2669
|
+
if (removedEdges.size > 0) {
|
|
2670
|
+
unindexEdges(graph, removedEdges);
|
|
2671
|
+
graph.edges = graph.edges.filter((edge) => !removedEdges.has(edge));
|
|
2672
|
+
}
|
|
2254
2673
|
pruneOrphanNonFileNodes(graph);
|
|
2255
2674
|
graph.changedSymbolsByFile = new Map();
|
|
2256
2675
|
const remaining = filesToBuild.filter((file) => !reusableHashes.has(file));
|
|
@@ -2281,7 +2700,7 @@ export function _readReviewGraphCheckpointForTests(cwd) {
|
|
|
2281
2700
|
}
|
|
2282
2701
|
/** Test hook: force any pending debounced persist to write immediately. */
|
|
2283
2702
|
export function flushReviewGraphPersistsForTests() {
|
|
2284
|
-
for (const key of
|
|
2703
|
+
for (const key of _pendingPersist.keys()) {
|
|
2285
2704
|
const pending = _pendingPersist.get(key);
|
|
2286
2705
|
if (!pending)
|
|
2287
2706
|
continue;
|
|
@@ -2345,7 +2764,7 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
|
|
|
2345
2764
|
let pending = _pendingPersist.get(key);
|
|
2346
2765
|
const removedWorkerRequests = [];
|
|
2347
2766
|
let selectedWorkerRequest;
|
|
2348
|
-
for (const [id, request] of
|
|
2767
|
+
for (const [id, request] of _workerRequests) {
|
|
2349
2768
|
if (request.key !== key)
|
|
2350
2769
|
continue;
|
|
2351
2770
|
_workerRequests.delete(id);
|
|
@@ -2418,6 +2837,7 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
|
|
|
2418
2837
|
logReviewGraph({
|
|
2419
2838
|
cwd,
|
|
2420
2839
|
phase: "persist_succeeded",
|
|
2840
|
+
durationMs: performance.now() - startedAt,
|
|
2421
2841
|
elements: pending.elementCount,
|
|
2422
2842
|
rawBytes,
|
|
2423
2843
|
gzBytes: gzip.byteLength,
|
|
@@ -2509,7 +2929,7 @@ function upsertChangedSymbols(graph, facts, filePath) {
|
|
|
2509
2929
|
if (detectFileRole(filePath) === "test")
|
|
2510
2930
|
return;
|
|
2511
2931
|
const normalized = normalizeMapKey(filePath);
|
|
2512
|
-
const changed = facts.
|
|
2932
|
+
const changed = facts.getBoundedSessionFact(`${CHANGED_SYMBOLS_PREFIX}${normalized}`);
|
|
2513
2933
|
if (changed && changed.length > 0) {
|
|
2514
2934
|
graph.changedSymbolsByFile.set(normalized, [...changed]);
|
|
2515
2935
|
}
|
|
@@ -2784,45 +3204,13 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
|
2784
3204
|
}
|
|
2785
3205
|
}
|
|
2786
3206
|
}
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
return "rust";
|
|
2795
|
-
case "ruby":
|
|
2796
|
-
return "ruby";
|
|
2797
|
-
case "cxx": {
|
|
2798
|
-
const ext = filePath ? path.extname(filePath).toLowerCase() : "";
|
|
2799
|
-
return ext === ".c" || ext === ".h" ? "c" : "cpp";
|
|
2800
|
-
}
|
|
2801
|
-
case "java":
|
|
2802
|
-
return "java";
|
|
2803
|
-
case "kotlin":
|
|
2804
|
-
return "kotlin";
|
|
2805
|
-
case "dart":
|
|
2806
|
-
return "dart";
|
|
2807
|
-
case "elixir":
|
|
2808
|
-
return "elixir";
|
|
2809
|
-
case "csharp":
|
|
2810
|
-
return "csharp";
|
|
2811
|
-
case "php":
|
|
2812
|
-
return "php";
|
|
2813
|
-
case "swift":
|
|
2814
|
-
return "swift";
|
|
2815
|
-
case "lua":
|
|
2816
|
-
return "lua";
|
|
2817
|
-
case "ocaml":
|
|
2818
|
-
return "ocaml";
|
|
2819
|
-
case "zig":
|
|
2820
|
-
return "zig";
|
|
2821
|
-
case "shell":
|
|
2822
|
-
return "bash";
|
|
2823
|
-
default:
|
|
2824
|
-
return undefined;
|
|
2825
|
-
}
|
|
3207
|
+
// The review graph's kind -> tree-sitter grammar answer. Delegates to the
|
|
3208
|
+
// registry-derived resolver (#2424): this used to be a hand-written switch that
|
|
3209
|
+
// re-derived the `.c`/`.h` split inline and drifted from the ext -> grammar
|
|
3210
|
+
// authority. Exported for the language-identity golden snapshot
|
|
3211
|
+
// (scripts/gen-language-snapshot.mjs).
|
|
3212
|
+
export function mapKindToTreeSitterLanguage(kind, filePath) {
|
|
3213
|
+
return symbolExtractionGrammar(kind, filePath);
|
|
2826
3214
|
}
|
|
2827
3215
|
async function getExtractor(languageId) {
|
|
2828
3216
|
if (extractorCache.has(languageId))
|
|
@@ -3248,37 +3636,76 @@ function addCxxIncludeEdges(graph, cwd, filePath, ignoredIds, contentOverride) {
|
|
|
3248
3636
|
});
|
|
3249
3637
|
}
|
|
3250
3638
|
}
|
|
3251
|
-
|
|
3639
|
+
/**
|
|
3640
|
+
* Drop the nodes/edges a changed file owns, and index-maintain as it goes.
|
|
3641
|
+
* `removedEdges` accumulates edges to drop from `graph.edges` — the CALLER
|
|
3642
|
+
* compacts the array once for the whole batch (#2074): before this, each call
|
|
3643
|
+
* ran its own `graph.edges.filter()`, so a batch of N changed files scanned
|
|
3644
|
+
* the whole edge array N times (changedFiles x graph, not changedFiles x
|
|
3645
|
+
* fan-in/out).
|
|
3646
|
+
*
|
|
3647
|
+
* `removedIds` is exactly `fileNodes`/`symbolNodesByFile`'s entries for this
|
|
3648
|
+
* file — `file` and `symbol` are the only node kinds that ever set `filePath`
|
|
3649
|
+
* (`module`/`external` placeholders never do) — so no `graph.nodes` scan is
|
|
3650
|
+
* needed either, and the owned edges are read straight off `edgesByFrom` /
|
|
3651
|
+
* `edgesByTo` instead of scanning `graph.edges`.
|
|
3652
|
+
*/
|
|
3653
|
+
function removeFileOwnedGraphData(graph, filePath, removedEdges) {
|
|
3252
3654
|
const normalized = normalizeMapKey(filePath);
|
|
3253
|
-
const fileNodeId = `file:${normalized}`;
|
|
3254
|
-
const
|
|
3255
|
-
const
|
|
3256
|
-
for (const [id, node] of graph.nodes) {
|
|
3257
|
-
if (node.filePath !== normalized)
|
|
3258
|
-
continue;
|
|
3259
|
-
removedIds.add(id);
|
|
3260
|
-
if (node.kind === "symbol")
|
|
3261
|
-
removedSymbolIds.add(id);
|
|
3262
|
-
}
|
|
3655
|
+
const fileNodeId = graph.fileNodes.get(normalized) ?? `file:${normalized}`;
|
|
3656
|
+
const removedSymbolIds = new Set(graph.symbolNodesByFile.get(normalized));
|
|
3657
|
+
const removedIds = new Set(removedSymbolIds);
|
|
3263
3658
|
if (graph.nodes.has(fileNodeId))
|
|
3264
3659
|
removedIds.add(fileNodeId);
|
|
3660
|
+
const candidates = new Set();
|
|
3661
|
+
for (const id of removedIds) {
|
|
3662
|
+
for (const edge of graph.edgesByFrom.get(id) ?? []) {
|
|
3663
|
+
_rebuildCounters.removeOwnedEdgeVisits++;
|
|
3664
|
+
candidates.add(edge);
|
|
3665
|
+
}
|
|
3666
|
+
for (const edge of graph.edgesByTo.get(id) ?? []) {
|
|
3667
|
+
_rebuildCounters.removeOwnedEdgeVisits++;
|
|
3668
|
+
candidates.add(edge);
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3265
3671
|
const preservedIncomingSymbolEdges = [];
|
|
3266
|
-
|
|
3672
|
+
for (const edge of candidates) {
|
|
3673
|
+
// A cross-edge can be discovered from both changed files' adjacency
|
|
3674
|
+
// buckets. Preserve and remove it only once, matching eager unindexing
|
|
3675
|
+
// while the batch indexes remain live until the end.
|
|
3676
|
+
if (removedEdges.has(edge))
|
|
3677
|
+
continue;
|
|
3267
3678
|
const fromRemoved = removedIds.has(edge.from);
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3679
|
+
// Preserve importer edges to the stable file node id; the node is
|
|
3680
|
+
// re-added below.
|
|
3681
|
+
if (!fromRemoved && edge.to === fileNodeId)
|
|
3682
|
+
continue;
|
|
3683
|
+
if (!fromRemoved && removedSymbolIds.has(edge.to)) {
|
|
3272
3684
|
preservedIncomingSymbolEdges.push({ ...edge });
|
|
3273
|
-
return false;
|
|
3274
3685
|
}
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
});
|
|
3280
|
-
for (const id of removedIds)
|
|
3686
|
+
removedEdges.add(edge);
|
|
3687
|
+
}
|
|
3688
|
+
for (const id of removedIds) {
|
|
3689
|
+
const node = graph.nodes.get(id);
|
|
3281
3690
|
graph.nodes.delete(id);
|
|
3691
|
+
if (!node?.filePath)
|
|
3692
|
+
continue;
|
|
3693
|
+
if (node.kind === "file") {
|
|
3694
|
+
if (graph.fileNodes.get(node.filePath) === id) {
|
|
3695
|
+
graph.fileNodes.delete(node.filePath);
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
else if (node.kind === "symbol") {
|
|
3699
|
+
const ids = graph.symbolNodesByFile.get(node.filePath);
|
|
3700
|
+
if (!ids)
|
|
3701
|
+
continue;
|
|
3702
|
+
const at = ids.indexOf(id);
|
|
3703
|
+
if (at >= 0)
|
|
3704
|
+
ids.splice(at, 1);
|
|
3705
|
+
if (ids.length === 0)
|
|
3706
|
+
graph.symbolNodesByFile.delete(node.filePath);
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3282
3709
|
return preservedIncomingSymbolEdges;
|
|
3283
3710
|
}
|
|
3284
3711
|
async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverride) {
|
|
@@ -3362,16 +3789,102 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
|
|
|
3362
3789
|
addCxxIncludeEdges(graph, cwd, file, ignoredIds, contentOverride);
|
|
3363
3790
|
}
|
|
3364
3791
|
}
|
|
3792
|
+
/**
|
|
3793
|
+
* #2074 acceptance instrumentation. `restoreComparisons` counts edge-metadata
|
|
3794
|
+
* stringifications inside `restoreValidIncomingEdges`; `importTargetEdgeScans`
|
|
3795
|
+
* counts edges visited by `importTargetsForFile`; `removeOwnedEdgeVisits`
|
|
3796
|
+
* counts edges visited while collecting a changed file's owned edges in
|
|
3797
|
+
* `removeFileOwnedGraphData`. Before this change all three grew with the whole
|
|
3798
|
+
* graph on every one-file rebuild, and `removeOwnedEdgeVisits` grew with
|
|
3799
|
+
* changedFiles x graph on a multi-file batch (one full `graph.edges` scan per
|
|
3800
|
+
* file). `removeOwnedEdgePositions` counts adjacency positions examined while
|
|
3801
|
+
* removing edges; batching keeps a high-fan-in bucket linear instead of
|
|
3802
|
+
* rescanning its prefix for every edge. They are the count-based signal the
|
|
3803
|
+
* issue asks for, because wall time on this hardware is IO-noisy (#1920).
|
|
3804
|
+
*/
|
|
3805
|
+
const _rebuildCounters = {
|
|
3806
|
+
restoreComparisons: 0,
|
|
3807
|
+
importTargetEdgeScans: 0,
|
|
3808
|
+
removeOwnedEdgeVisits: 0,
|
|
3809
|
+
removeOwnedEdgePositions: 0,
|
|
3810
|
+
};
|
|
3811
|
+
export function _getReviewGraphRebuildCountersForTests() {
|
|
3812
|
+
return { ..._rebuildCounters };
|
|
3813
|
+
}
|
|
3814
|
+
export function _resetReviewGraphRebuildCountersForTests() {
|
|
3815
|
+
_rebuildCounters.restoreComparisons = 0;
|
|
3816
|
+
_rebuildCounters.importTargetEdgeScans = 0;
|
|
3817
|
+
_rebuildCounters.removeOwnedEdgeVisits = 0;
|
|
3818
|
+
_rebuildCounters.removeOwnedEdgePositions = 0;
|
|
3819
|
+
}
|
|
3820
|
+
/**
|
|
3821
|
+
* Identity of an edge for dedupe purposes, WITHIN one target bucket: the array
|
|
3822
|
+
* form needs no separator sentinel and cannot collide across fields.
|
|
3823
|
+
*/
|
|
3824
|
+
function edgeIdentityKey(edge) {
|
|
3825
|
+
_rebuildCounters.restoreComparisons++;
|
|
3826
|
+
return JSON.stringify([edge.from, edge.kind, edge.metadata ?? {}]);
|
|
3827
|
+
}
|
|
3365
3828
|
function restoreValidIncomingEdges(graph, edges) {
|
|
3366
|
-
|
|
3829
|
+
// #2074: dedupe per TARGET, using `edgesByTo`, instead of building a key set
|
|
3830
|
+
// over every edge in the graph. Preserved incoming edges point at symbols of
|
|
3831
|
+
// the files just re-extracted, and `removeFileOwnedGraphData` has already
|
|
3832
|
+
// dropped those symbols' incoming edges, so each bucket read here is tiny.
|
|
3833
|
+
// The per-target `Set` keeps the restore linear even when one changed file is
|
|
3834
|
+
// a hub with thousands of preserved incoming edges — scanning the bucket per
|
|
3835
|
+
// edge instead would be quadratic in that fan-in.
|
|
3836
|
+
const seenByTarget = new Map();
|
|
3367
3837
|
for (const edge of edges) {
|
|
3368
3838
|
if (!graph.nodes.has(edge.from) || !graph.nodes.has(edge.to))
|
|
3369
3839
|
continue;
|
|
3370
|
-
|
|
3371
|
-
if (
|
|
3840
|
+
let seen = seenByTarget.get(edge.to);
|
|
3841
|
+
if (!seen) {
|
|
3842
|
+
seen = new Set();
|
|
3843
|
+
for (const candidate of graph.edgesByTo.get(edge.to) ?? []) {
|
|
3844
|
+
seen.add(edgeIdentityKey(candidate));
|
|
3845
|
+
}
|
|
3846
|
+
seenByTarget.set(edge.to, seen);
|
|
3847
|
+
}
|
|
3848
|
+
const key = edgeIdentityKey(edge);
|
|
3849
|
+
if (seen.has(key))
|
|
3372
3850
|
continue;
|
|
3373
|
-
|
|
3374
|
-
|
|
3851
|
+
seen.add(key);
|
|
3852
|
+
addEdge(graph, edge);
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
/**
|
|
3856
|
+
* Remove duplicates created when deferred targets converge after restoration.
|
|
3857
|
+
* The scan is limited to resolved edges and their target buckets, not the
|
|
3858
|
+
* complete graph, so same-batch repairs remain proportional to the resolved
|
|
3859
|
+
* edges and the fan-in of their target buckets.
|
|
3860
|
+
*/
|
|
3861
|
+
function dedupeResolvedEdges(graph, replacements) {
|
|
3862
|
+
const resolvedEdges = new Set(replacements.map(([, after]) => after));
|
|
3863
|
+
const seenByTarget = new Map();
|
|
3864
|
+
const removed = new Set();
|
|
3865
|
+
for (const [, edge] of replacements) {
|
|
3866
|
+
if (removed.has(edge))
|
|
3867
|
+
continue;
|
|
3868
|
+
let seen = seenByTarget.get(edge.to);
|
|
3869
|
+
if (!seen) {
|
|
3870
|
+
seen = new Set();
|
|
3871
|
+
for (const candidate of graph.edgesByTo.get(edge.to) ?? []) {
|
|
3872
|
+
if (!resolvedEdges.has(candidate)) {
|
|
3873
|
+
seen.add(edgeIdentityKey(candidate));
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
seenByTarget.set(edge.to, seen);
|
|
3877
|
+
}
|
|
3878
|
+
const key = edgeIdentityKey(edge);
|
|
3879
|
+
if (seen.has(key)) {
|
|
3880
|
+
removed.add(edge);
|
|
3881
|
+
unindexEdge(graph, edge);
|
|
3882
|
+
continue;
|
|
3883
|
+
}
|
|
3884
|
+
seen.add(key);
|
|
3885
|
+
}
|
|
3886
|
+
if (removed.size > 0) {
|
|
3887
|
+
graph.edges = graph.edges.filter((edge) => !removed.has(edge));
|
|
3375
3888
|
}
|
|
3376
3889
|
}
|
|
3377
3890
|
const _graphImportChanges = new WeakMap();
|
|
@@ -3383,33 +3896,52 @@ function importTargetsForFile(graph, filePath) {
|
|
|
3383
3896
|
const normalized = normalizeMapKey(filePath);
|
|
3384
3897
|
const fileNodeId = graph.fileNodes.get(normalized) ?? `file:${normalized}`;
|
|
3385
3898
|
const targets = new Set();
|
|
3386
|
-
//
|
|
3387
|
-
//
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3899
|
+
// #2074: read this file's own out-edges from `edgesByFrom` rather than
|
|
3900
|
+
// scanning every edge in the graph. The only caller, `updateGraphFiles`,
|
|
3901
|
+
// indexes the graph before its first call and keeps the indexes live for the
|
|
3902
|
+
// rest of the update, so the bucket is always authoritative here.
|
|
3903
|
+
for (const edge of graph.edgesByFrom.get(fileNodeId) ?? []) {
|
|
3904
|
+
_rebuildCounters.importTargetEdgeScans++;
|
|
3391
3905
|
if (edge.kind !== "imports")
|
|
3392
3906
|
continue;
|
|
3393
3907
|
const target = graph.nodes.get(edge.to)?.filePath;
|
|
3394
3908
|
if (target)
|
|
3395
3909
|
targets.add(normalizeMapKey(target));
|
|
3396
3910
|
}
|
|
3397
|
-
return [...targets].sort((a, b) => a
|
|
3911
|
+
return [...targets].sort((a, b) => compareOrdinal(a, b));
|
|
3398
3912
|
}
|
|
3399
3913
|
async function updateGraphFiles(graph, cwd, files, facts, ignoredIds) {
|
|
3914
|
+
// #2074: index ONCE, up front, then keep the indexes live through the update.
|
|
3915
|
+
// This is the same single O(graph) pass the terminal `rebuildIndexes` used to
|
|
3916
|
+
// cost, moved to the front, and it buys three things: the two
|
|
3917
|
+
// `importTargetsForFile` calls per changed file become bucket lookups,
|
|
3918
|
+
// `restoreValidIncomingEdges` dedupes against a fan-in bucket instead of the
|
|
3919
|
+
// whole edge list, and `existedBefore` finally reads a POPULATED `fileNodes`.
|
|
3920
|
+
// `cloneGraph` returns empty indexes, so before this the first file's
|
|
3921
|
+
// `existedBefore` was always false on every update path — which forced
|
|
3922
|
+
// `importsChanged` true in `clients/dispatch/integration.ts:1077` and blocked
|
|
3923
|
+
// reverse-dependency index reuse on every incremental build.
|
|
3924
|
+
rebuildIndexes(graph);
|
|
3400
3925
|
const prior = files.map((file) => ({
|
|
3401
3926
|
filePath: normalizeMapKey(file),
|
|
3402
3927
|
existedBefore: graph.fileNodes.has(normalizeMapKey(file)),
|
|
3403
3928
|
priorTargets: importTargetsForFile(graph, file),
|
|
3404
3929
|
}));
|
|
3405
3930
|
const preservedIncoming = [];
|
|
3931
|
+
// #2074: one shared drop set for the whole batch, compacted into graph.edges
|
|
3932
|
+
// exactly once below — not once per file, which made a multi-file rebuild
|
|
3933
|
+
// scan the edge array changedFiles times over.
|
|
3934
|
+
const removedEdges = new Set();
|
|
3406
3935
|
for (const file of files) {
|
|
3407
|
-
preservedIncoming.push(...removeFileOwnedGraphData(graph, file));
|
|
3936
|
+
preservedIncoming.push(...removeFileOwnedGraphData(graph, file, removedEdges));
|
|
3408
3937
|
await addFileToGraph(graph, cwd, file, facts, ignoredIds);
|
|
3409
3938
|
}
|
|
3939
|
+
if (removedEdges.size > 0) {
|
|
3940
|
+
unindexEdges(graph, removedEdges);
|
|
3941
|
+
graph.edges = graph.edges.filter((edge) => !removedEdges.has(edge));
|
|
3942
|
+
}
|
|
3410
3943
|
restoreValidIncomingEdges(graph, preservedIncoming);
|
|
3411
3944
|
resolveDeferredSymbolEdges(graph, false);
|
|
3412
|
-
rebuildIndexes(graph);
|
|
3413
3945
|
graph.changedSymbolsByFile.clear();
|
|
3414
3946
|
for (const file of files) {
|
|
3415
3947
|
upsertChangedSymbols(graph, facts, file);
|
|
@@ -3433,6 +3965,12 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
|
|
|
3433
3965
|
ids.push(node.id);
|
|
3434
3966
|
symbolNameToIds.set(node.symbolName, ids);
|
|
3435
3967
|
}
|
|
3968
|
+
// #2074: this pass REPLACES edge objects, so the adjacency buckets that hold
|
|
3969
|
+
// the old objects go stale. Callers that keep the indexes live (the
|
|
3970
|
+
// incremental path, which no longer reindexes afterwards) need each
|
|
3971
|
+
// replacement patched into the buckets; collect them here rather than paying
|
|
3972
|
+
// a whole-graph reindex for a handful of newly resolved edges.
|
|
3973
|
+
const replacements = [];
|
|
3436
3974
|
graph.edges = graph.edges.map((edge) => {
|
|
3437
3975
|
const targetNode = graph.nodes.get(edge.to);
|
|
3438
3976
|
if (!targetNode?.metadata?.unresolvedName)
|
|
@@ -3447,13 +3985,25 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
|
|
|
3447
3985
|
if (importHintFile) {
|
|
3448
3986
|
const scoped = candidates.filter((id) => graph.nodes.get(id)?.filePath === importHintFile);
|
|
3449
3987
|
if (scoped.length === 1) {
|
|
3450
|
-
|
|
3988
|
+
const resolved = {
|
|
3989
|
+
...edge,
|
|
3990
|
+
to: scoped[0],
|
|
3991
|
+
resolution: "import",
|
|
3992
|
+
};
|
|
3993
|
+
replacements.push([edge, resolved]);
|
|
3994
|
+
return resolved;
|
|
3451
3995
|
}
|
|
3452
3996
|
}
|
|
3453
3997
|
if (candidates.length === 1) {
|
|
3454
3998
|
// Exactly one same-named real symbol exists graph-wide: the bare-name
|
|
3455
3999
|
// match is provably unambiguous (refs #655 — resolution confidence).
|
|
3456
|
-
|
|
4000
|
+
const resolved = {
|
|
4001
|
+
...edge,
|
|
4002
|
+
to: candidates[0],
|
|
4003
|
+
resolution: "exact",
|
|
4004
|
+
};
|
|
4005
|
+
replacements.push([edge, resolved]);
|
|
4006
|
+
return resolved;
|
|
3457
4007
|
}
|
|
3458
4008
|
// 0 or 2+ candidates (and no import hint narrowed it): stays on the
|
|
3459
4009
|
// unresolved placeholder, resolution stays "name-only" (set at edge
|
|
@@ -3461,8 +4011,47 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
|
|
|
3461
4011
|
// confirmed graph node.
|
|
3462
4012
|
return edge;
|
|
3463
4013
|
});
|
|
3464
|
-
if (rebuild)
|
|
4014
|
+
if (rebuild) {
|
|
3465
4015
|
rebuildIndexes(graph);
|
|
4016
|
+
return;
|
|
4017
|
+
}
|
|
4018
|
+
if (replacements.length === 0)
|
|
4019
|
+
return;
|
|
4020
|
+
// Bucket ORDER is part of the contract, not just bucket membership:
|
|
4021
|
+
// `resolveUsedBy` in module-report walks `edgesByTo` and truncates at a cap,
|
|
4022
|
+
// so the order decides which callers a reader sees. `rebuildIndexes` orders
|
|
4023
|
+
// every bucket by position in `graph.edges`, and the incremental path must
|
|
4024
|
+
// match it exactly.
|
|
4025
|
+
//
|
|
4026
|
+
// `from` is unchanged by a resolution, so the from-bucket only needs the new
|
|
4027
|
+
// object swapped in at the OLD object's position — order is preserved for
|
|
4028
|
+
// free. `to` moves from the placeholder's bucket to the resolved symbol's,
|
|
4029
|
+
// and appending there would put an early edge behind later ones. Rebuild
|
|
4030
|
+
// exactly the affected to-buckets from `graph.edges`, which restores
|
|
4031
|
+
// canonical order without touching the rest of the index.
|
|
4032
|
+
const affectedTargets = new Set();
|
|
4033
|
+
for (const [before, after] of replacements) {
|
|
4034
|
+
const fromBucket = graph.edgesByFrom.get(before.from);
|
|
4035
|
+
if (fromBucket) {
|
|
4036
|
+
const at = fromBucket.indexOf(before);
|
|
4037
|
+
if (at >= 0)
|
|
4038
|
+
fromBucket[at] = after;
|
|
4039
|
+
}
|
|
4040
|
+
affectedTargets.add(before.to);
|
|
4041
|
+
affectedTargets.add(after.to);
|
|
4042
|
+
}
|
|
4043
|
+
for (const target of affectedTargets)
|
|
4044
|
+
graph.edgesByTo.set(target, []);
|
|
4045
|
+
for (const edge of graph.edges) {
|
|
4046
|
+
if (affectedTargets.has(edge.to))
|
|
4047
|
+
graph.edgesByTo.get(edge.to)?.push(edge);
|
|
4048
|
+
}
|
|
4049
|
+
for (const target of affectedTargets) {
|
|
4050
|
+
if (graph.edgesByTo.get(target)?.length === 0) {
|
|
4051
|
+
graph.edgesByTo.delete(target);
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
dedupeResolvedEdges(graph, replacements);
|
|
3466
4055
|
}
|
|
3467
4056
|
/**
|
|
3468
4057
|
* #451: the freshness-provenance fields written onto a workspace cache entry by
|
|
@@ -3537,7 +4126,7 @@ async function tryIncrementalFromCache(cached, ctx) {
|
|
|
3537
4126
|
graphChanged: false,
|
|
3538
4127
|
});
|
|
3539
4128
|
graph.buildGeneration = generation;
|
|
3540
|
-
ctx.facts
|
|
4129
|
+
setSessionReviewGraphFact(ctx.cwd, ctx.facts, graph);
|
|
3541
4130
|
return graph;
|
|
3542
4131
|
}
|
|
3543
4132
|
// Record content hashes for the newly-added files too, so the next run can
|
|
@@ -3577,7 +4166,7 @@ async function tryIncrementalFromCache(cached, ctx) {
|
|
|
3577
4166
|
fromGeneration: priorGeneration,
|
|
3578
4167
|
changes: importChanges,
|
|
3579
4168
|
});
|
|
3580
|
-
ctx.facts
|
|
4169
|
+
setSessionReviewGraphFact(ctx.cwd, ctx.facts, graph);
|
|
3581
4170
|
return graph;
|
|
3582
4171
|
}
|
|
3583
4172
|
function hasGraphKindExtension(file) {
|
|
@@ -3594,10 +4183,16 @@ function hasGraphKindExtension(file) {
|
|
|
3594
4183
|
*/
|
|
3595
4184
|
async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds, cacheEpoch) {
|
|
3596
4185
|
const cached = _workspaceGraphCache.get(normalizedCwd);
|
|
3597
|
-
// Condition 2: need an in-process
|
|
3598
|
-
//
|
|
3599
|
-
//
|
|
3600
|
-
|
|
4186
|
+
// Condition 2: need an in-process entry that recorded a build seq and whose
|
|
4187
|
+
// SOURCE WALK was complete. An entry-budget-truncated or checkpoint graph has
|
|
4188
|
+
// UNSEEN files, so it is read-only orientation data and must force a complete
|
|
4189
|
+
// walk. A `capTrimmed` graph is a different case: this process walked every
|
|
4190
|
+
// file and then cut the graph for size, so rebuilding from it is safe, and
|
|
4191
|
+
// refusing it puts an over-budget repository on a full walk every single turn
|
|
4192
|
+
// (#2255 review F5). The marker is process-local and never hydrated from disk,
|
|
4193
|
+
// so a snapshot-derived partial still takes the refusal above.
|
|
4194
|
+
const cachedCoverage = cached?.graph.persistCoverage;
|
|
4195
|
+
if (cachedCoverage?.partial && cachedCoverage.capTrimmed !== true) {
|
|
3601
4196
|
return { fallback: "partial-base" };
|
|
3602
4197
|
}
|
|
3603
4198
|
if (!cached || cached.builtAtProjectSeq === undefined) {
|
|
@@ -3688,7 +4283,7 @@ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, fa
|
|
|
3688
4283
|
graphChanged: false,
|
|
3689
4284
|
});
|
|
3690
4285
|
graph.buildGeneration = generation;
|
|
3691
|
-
|
|
4286
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
3692
4287
|
return { graph };
|
|
3693
4288
|
}
|
|
3694
4289
|
// Incremental re-extract over exactly the content-changed files. Reuses the
|
|
@@ -3746,7 +4341,7 @@ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, fa
|
|
|
3746
4341
|
fromGeneration: priorGeneration,
|
|
3747
4342
|
changes: importChanges,
|
|
3748
4343
|
});
|
|
3749
|
-
|
|
4344
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
3750
4345
|
return { graph };
|
|
3751
4346
|
}
|
|
3752
4347
|
async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
@@ -3793,7 +4388,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3793
4388
|
// treat as changed so dependents never trust stale derived state.
|
|
3794
4389
|
graphChanged: true,
|
|
3795
4390
|
});
|
|
3796
|
-
|
|
4391
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
3797
4392
|
return graph;
|
|
3798
4393
|
}
|
|
3799
4394
|
// #451: capture the seq BEFORE any await — every builtAtProjectSeq stamp in
|
|
@@ -3820,6 +4415,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3820
4415
|
const sourceCollection = await getGraphSourceFiles(cwd);
|
|
3821
4416
|
const filesToBuild = sourceCollection.files;
|
|
3822
4417
|
const sourceFileCount = sourceCollection.sourceFileCount;
|
|
4418
|
+
const pathNormalizeCalls = sourceCollection.pathNormalizeCalls;
|
|
3823
4419
|
const sourceFilesTruncated = sourceCollection.entryBudgetExceeded;
|
|
3824
4420
|
const ignoredIds = await ignoredIdsPromise;
|
|
3825
4421
|
const maxGraphFiles = sourceCollection.maxFileCount;
|
|
@@ -3877,6 +4473,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3877
4473
|
sourceFileCount,
|
|
3878
4474
|
sourceFileCountTruncated: sourceFilesTruncated || filesToBuild.length > maxGraphFiles,
|
|
3879
4475
|
maxFileCount: maxGraphFiles,
|
|
4476
|
+
pathNormalizeCalls,
|
|
3880
4477
|
seqFastpathFallback,
|
|
3881
4478
|
// #459: a fresh empty graph is returned every call on this path (never
|
|
3882
4479
|
// persisted/reused) — treat it as changed so dependents never trust stale
|
|
@@ -3884,7 +4481,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3884
4481
|
// with a buildGeneration: absent ⇒ derived caches rebuild every time.
|
|
3885
4482
|
graphChanged: true,
|
|
3886
4483
|
});
|
|
3887
|
-
|
|
4484
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
3888
4485
|
return graph;
|
|
3889
4486
|
}
|
|
3890
4487
|
// #782: the repo is within the cap on this build attempt — drop any
|
|
@@ -3905,8 +4502,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3905
4502
|
// re-persisting it would launder partial coverage onto disk as a complete
|
|
3906
4503
|
// snapshot (#936 review). Ignore it here — the disk tier rejects a partial
|
|
3907
4504
|
// base too, so the build falls through to a full rebuild.
|
|
3908
|
-
|
|
4505
|
+
//
|
|
4506
|
+
// `capTrimmed` is the one partial cause this does NOT refuse: this process
|
|
4507
|
+
// walked every file and then cut the graph to the memory budget, so no file is
|
|
4508
|
+
// unaccounted for. Refusing it forced a full walk on every turn for exactly
|
|
4509
|
+
// the repositories the memory bound targets (#2255 review F5). The marker is
|
|
4510
|
+
// process-local and stripped at persist, so a snapshot-hydrated partial —
|
|
4511
|
+
// whose completeness this process cannot vouch for — is still refused.
|
|
4512
|
+
const memCoverage = memCached?.graph.persistCoverage;
|
|
4513
|
+
if (memCoverage?.partial && memCoverage.capTrimmed !== true) {
|
|
3909
4514
|
memCached = undefined;
|
|
4515
|
+
}
|
|
3910
4516
|
if (memCached?.signature === signature) {
|
|
3911
4517
|
touchWorkspaceGraph(normalizedCwd);
|
|
3912
4518
|
const graph = cloneGraph(memCached.graph);
|
|
@@ -3929,7 +4535,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3929
4535
|
graphChanged: false,
|
|
3930
4536
|
});
|
|
3931
4537
|
graph.buildGeneration = generation;
|
|
3932
|
-
|
|
4538
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
3933
4539
|
return graph;
|
|
3934
4540
|
}
|
|
3935
4541
|
if (memCached) {
|
|
@@ -3984,7 +4590,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
3984
4590
|
graphChanged: false,
|
|
3985
4591
|
});
|
|
3986
4592
|
graph.buildGeneration = generation;
|
|
3987
|
-
|
|
4593
|
+
setSessionReviewGraphFact(cwd, facts, graph);
|
|
3988
4594
|
return graph;
|
|
3989
4595
|
}
|
|
3990
4596
|
if (diskCached) {
|
|
@@ -4143,8 +4749,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
4143
4749
|
// so change detection does not reread every file after the graph is built.
|
|
4144
4750
|
// #459: full rebuild ⇒ new generation.
|
|
4145
4751
|
const generation = ++_graphGenerationCounter;
|
|
4146
|
-
|
|
4147
|
-
|
|
4752
|
+
// #2255 review R1: cap ONCE and let both retention sites share the result. The
|
|
4753
|
+
// cache took a clone while the session fact took the original, two objects a
|
|
4754
|
+
// `cloneGraph` apart, so each ran its own centrality pass and retained its own
|
|
4755
|
+
// trimmed copy — two budgets resident, not one. Over budget the cap already
|
|
4756
|
+
// returns a fresh, fully-indexed graph, so it IS the snapshot and no clone is
|
|
4757
|
+
// needed. In budget nothing is trimmed and the clone happens exactly as before.
|
|
4758
|
+
const retained = retainedGraph(cwd, graph);
|
|
4759
|
+
const wasCapped = retained !== graph;
|
|
4760
|
+
const graphSnapshot = wasCapped ? retained : cloneGraph(graph);
|
|
4761
|
+
if (!wasCapped)
|
|
4762
|
+
rebuildIndexes(graphSnapshot);
|
|
4148
4763
|
// Keep the content generation on the persisted snapshot instance too, so
|
|
4149
4764
|
// scheduled persistence logs join the same graph identity as build success.
|
|
4150
4765
|
graphSnapshot.buildGeneration = generation;
|
|
@@ -4169,12 +4784,15 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
4169
4784
|
mode: "full",
|
|
4170
4785
|
sourceFileCount,
|
|
4171
4786
|
sourceFileCountTruncated: sourceFilesTruncated,
|
|
4787
|
+
pathNormalizeCalls,
|
|
4172
4788
|
seqFastpathFallback,
|
|
4173
4789
|
...(persistReason ? { persistReason } : {}),
|
|
4174
4790
|
graphChanged: true,
|
|
4175
4791
|
});
|
|
4176
4792
|
graph.buildGeneration = generation;
|
|
4177
|
-
|
|
4793
|
+
// Share the cache's object, so an over-budget build retains ONE trimmed graph
|
|
4794
|
+
// across both sites. In budget this stores the equivalent clone the cache holds.
|
|
4795
|
+
setSessionReviewGraphFact(cwd, facts, graphSnapshot);
|
|
4178
4796
|
return graph;
|
|
4179
4797
|
}
|
|
4180
4798
|
/**
|
|
@@ -4196,7 +4814,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
|
4196
4814
|
* rather than by coincidence.
|
|
4197
4815
|
*/
|
|
4198
4816
|
function buildCacheKey(cwd, changedFiles) {
|
|
4199
|
-
return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => a
|
|
4817
|
+
return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => compareOrdinal(a, b)).join(",")}`;
|
|
4200
4818
|
}
|
|
4201
4819
|
/**
|
|
4202
4820
|
* Whether a build for this exact (cwd, changedFiles) key is PENDING right now
|
|
@@ -4254,6 +4872,7 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
4254
4872
|
mode: buildInfo.mode,
|
|
4255
4873
|
sourceFileCount: buildInfo.sourceFileCount,
|
|
4256
4874
|
sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
|
|
4875
|
+
pathNormalizeCalls: buildInfo.pathNormalizeCalls,
|
|
4257
4876
|
}),
|
|
4258
4877
|
},
|
|
4259
4878
|
});
|
|
@@ -4276,6 +4895,7 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
4276
4895
|
mode: buildInfo.mode,
|
|
4277
4896
|
sourceFileCount: buildInfo.sourceFileCount,
|
|
4278
4897
|
sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
|
|
4898
|
+
pathNormalizeCalls: buildInfo.pathNormalizeCalls,
|
|
4279
4899
|
}),
|
|
4280
4900
|
},
|
|
4281
4901
|
});
|