@herbertgao/pi-extensions 2026.8.3 → 2026.8.5
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/README.md +45 -4
- package/examples/pi-footer.json +275 -0
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
- package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
- package/node_modules/@narumitw/pi-btw/README.md +8 -7
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
- package/node_modules/pi-footer/CHANGELOG.md +145 -0
- package/node_modules/pi-footer/LICENSE +21 -0
- package/node_modules/pi-footer/README.md +354 -0
- package/node_modules/pi-footer/package.json +68 -0
- package/node_modules/pi-footer/src/cache.ts +106 -0
- package/node_modules/pi-footer/src/colors.ts +237 -0
- package/node_modules/pi-footer/src/config.ts +202 -0
- package/node_modules/pi-footer/src/event-widgets.ts +41 -0
- package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
- package/node_modules/pi-footer/src/git.ts +135 -0
- package/node_modules/pi-footer/src/index.ts +268 -0
- package/node_modules/pi-footer/src/metrics.ts +141 -0
- package/node_modules/pi-footer/src/presets.ts +383 -0
- package/node_modules/pi-footer/src/render.ts +114 -0
- package/node_modules/pi-footer/src/separators.ts +52 -0
- package/node_modules/pi-footer/src/types.ts +122 -0
- package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
- package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
- package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
- package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
- package/node_modules/pi-footer/src/ui/events.ts +35 -0
- package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
- package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
- package/node_modules/pi-footer/src/ui/fields.ts +252 -0
- package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
- package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
- package/node_modules/pi-footer/src/ui/layout.ts +17 -0
- package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
- package/node_modules/pi-footer/src/ui/model.ts +64 -0
- package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
- package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
- package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
- package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
- package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
- package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
- package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
- package/node_modules/pi-footer/src/ui/screen.ts +243 -0
- package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
- package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
- package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
- package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
- package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
- package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
- package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
- package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
- package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
- package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
- package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
- package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
- package/node_modules/pi-footer/src/ui/theme.ts +48 -0
- package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
- package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
- package/node_modules/pi-footer/src/ui.ts +66 -0
- package/node_modules/pi-footer/src/widgets/context.ts +28 -0
- package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
- package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
- package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
- package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
- package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
- package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
- package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
- package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
- package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
- package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
- package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
- package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
- package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
- package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
- package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
- package/node_modules/pi-footer/src/widgets/options.ts +122 -0
- package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
- package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
- package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/store.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/types.ts +181 -0
- package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
- package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
- package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
- package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
- package/node_modules/pi-lens/CHANGELOG.md +2440 -954
- package/node_modules/pi-lens/README.md +13 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
- package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
- package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
- package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
- package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
- package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
- package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
- package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
- package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
- package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
- package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
- package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
- package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
- package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
- package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
- package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
- package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
- package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
- package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
- package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
- package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
- package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
- package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
- package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
- package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
- package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
- package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
- package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
- package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
- package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
- package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
- package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
- package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
- package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
- package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
- package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
- package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
- package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
- package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
- package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
- package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
- package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
- package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
- package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
- package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
- package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
- package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
- package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
- package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
- package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
- package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
- package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
- package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
- package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
- package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
- package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
- package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
- package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
- package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
- package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
- package/node_modules/pi-lens/dist/index.js +49361 -36888
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
- package/node_modules/pi-lens/dist/mcp/server.js +211 -44
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
- package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
- package/node_modules/pi-lens/docs/agent-guide.md +2 -2
- package/node_modules/pi-lens/docs/agent-tools.md +21 -10
- package/node_modules/pi-lens/docs/analysisall.md +192 -0
- package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
- package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
- package/node_modules/pi-lens/docs/environment-variables.md +44 -0
- package/node_modules/pi-lens/docs/fable.md +27 -0
- package/node_modules/pi-lens/docs/features.md +31 -2
- package/node_modules/pi-lens/docs/globalconfig.md +45 -1
- package/node_modules/pi-lens/docs/language-coverage.md +4 -3
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
- package/node_modules/pi-lens/docs/mcp.md +37 -4
- package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
- package/node_modules/pi-lens/docs/settings.md +12 -1
- package/node_modules/pi-lens/docs/tools.md +4 -1
- package/node_modules/pi-lens/docs/usage.md +18 -0
- package/node_modules/pi-lens/package.json +10 -10
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
- package/node_modules/pi-mcp-adapter/README.md +34 -3
- package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
- package/node_modules/pi-mcp-adapter/commands.ts +15 -7
- package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
- package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
- package/node_modules/pi-mcp-adapter/index.ts +8 -3
- package/node_modules/pi-mcp-adapter/init.ts +29 -5
- package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
- package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
- package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
- package/node_modules/pi-mcp-adapter/package.json +3 -3
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
- package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
- package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
- package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
- package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
- package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
- package/node_modules/pi-mcp-adapter/types.ts +93 -27
- package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
- package/package.json +17 -13
- package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
- package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
- package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
- package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
- package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
- package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
- package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
- package/node_modules/pi-lens/dist/clients/types.js +0 -11
|
@@ -12,6 +12,7 @@ import { detectFileKind, KIND_EXTENSIONS } from "../file-kinds.js";
|
|
|
12
12
|
import { detectFileRole } from "../file-role.js";
|
|
13
13
|
import { getProjectDataDir } from "../file-utils.js";
|
|
14
14
|
import { collectUntrackedIgnoredIds } from "../git-tracked-ignore.js";
|
|
15
|
+
import { realIsPidAlive } from "../instance-reaper.js";
|
|
15
16
|
import { logLatency } from "../latency-logger.js";
|
|
16
17
|
import { containerNameChain, getOpenDocumentSymbols, lspSymbolKindName, } from "../lsp-document-symbols.js";
|
|
17
18
|
import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, toProjectRelativePath, } from "../path-utils.js";
|
|
@@ -22,7 +23,7 @@ import { RUNTIME_CONFIG } from "../runtime-config.js";
|
|
|
22
23
|
import { buildReverseDependencyIndexFromGraph, rankFilesByReverseDependencyCentrality, } from "../reverse-deps.js";
|
|
23
24
|
import { buildQualifiedName, findOwnerName } from "../symbol-containment.js";
|
|
24
25
|
import { logTreeSitterCacheStats } from "../tree-sitter-logger.js";
|
|
25
|
-
import { flushReviewGraphLogSync, logReviewGraph, } from "../review-graph-logger.js";
|
|
26
|
+
import { flushReviewGraphLogSync, logReviewGraph, makeReviewGraphBuildMetadata, } from "../review-graph-logger.js";
|
|
26
27
|
import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
|
|
27
28
|
import { TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
|
|
28
29
|
import { withTreeSitterRoot } from "../tree-sitter-shared.js";
|
|
@@ -58,7 +59,10 @@ import { clearReviewGraphFileIr, getFreshReviewGraphFileIr, reviewGraphIrContent
|
|
|
58
59
|
// safe-rebuild mechanism as the v2→v3/v3→v4/v4→v5 bumps above.
|
|
59
60
|
// v7 (#939): the canonical snapshot is streamed gzip. A v7 payload in the
|
|
60
61
|
// legacy uncompressed filename remains readable for one compatibility release.
|
|
61
|
-
|
|
62
|
+
// v8 (#1070): call/reference evidence now records call-like vs type-only
|
|
63
|
+
// references and tree-sitter query coverage, so call-graph consumers cannot
|
|
64
|
+
// mistake incomplete extraction for a clean zero.
|
|
65
|
+
export const REVIEW_GRAPH_VERSION = "v8";
|
|
62
66
|
const MAIN_KINDS = new Set([
|
|
63
67
|
"jsts",
|
|
64
68
|
"python",
|
|
@@ -84,6 +88,8 @@ const MAIN_KINDS = new Set([
|
|
|
84
88
|
// so a repo heavy in JSON/YAML/Markdown doesn't trip the cap on files the graph
|
|
85
89
|
// would have filtered out anyway (the cap is on the walk, not on noise). #250.
|
|
86
90
|
const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTENSIONS[kind] ?? []);
|
|
91
|
+
/** The bounded, source-filtered extension set shared by graph cache readers. */
|
|
92
|
+
export const REVIEW_GRAPH_SOURCE_EXTENSIONS = MAIN_KIND_EXTENSIONS;
|
|
87
93
|
const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
|
|
88
94
|
const extractorCache = new Map();
|
|
89
95
|
// Per-invocation Promise cache: deduplicates concurrent buildOrUpdateGraph calls
|
|
@@ -92,17 +98,87 @@ const extractorCache = new Map();
|
|
|
92
98
|
// graph across invocations when source file mtimes/sizes have not changed.
|
|
93
99
|
const _buildCache = new Map();
|
|
94
100
|
const _workspaceGraphCache = new Map();
|
|
101
|
+
const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
|
|
102
|
+
const REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
|
|
103
|
+
// A workspace-wide clear must invalidate builds for workspaces that are not
|
|
104
|
+
// resident yet, too. This process-wide component therefore survives cache
|
|
105
|
+
// deletion; per-workspace eviction/reset increments the map component below.
|
|
106
|
+
let _workspaceCacheEpoch = 0;
|
|
107
|
+
const _workspaceCacheEpochs = new Map();
|
|
108
|
+
function workspaceCacheEpoch(key) {
|
|
109
|
+
return _workspaceCacheEpoch + (_workspaceCacheEpochs.get(key) ?? 0);
|
|
110
|
+
}
|
|
111
|
+
function reviewGraphIdleEvictMs() {
|
|
112
|
+
const value = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_IDLE_EVICT_MS ?? "", 10);
|
|
113
|
+
return Number.isSafeInteger(value) && value > 0 ? value : REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT;
|
|
114
|
+
}
|
|
115
|
+
function clearWorkspaceGraphTimer(entry) {
|
|
116
|
+
if (entry.idleTimer)
|
|
117
|
+
clearTimeout(entry.idleTimer);
|
|
118
|
+
entry.idleTimer = undefined;
|
|
119
|
+
}
|
|
120
|
+
function evictWorkspaceGraph(key, entry) {
|
|
121
|
+
if (_workspaceGraphCache.get(key) !== entry)
|
|
122
|
+
return;
|
|
123
|
+
clearWorkspaceGraphTimer(entry);
|
|
124
|
+
for (const buildKey of _buildCache.keys()) {
|
|
125
|
+
const separator = buildKey.indexOf("|");
|
|
126
|
+
if (separator >= 0 && normalizeMapKey(buildKey.slice(0, separator)) === key) {
|
|
127
|
+
_buildCache.delete(buildKey);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
_workspaceCacheEpochs.set(key, (_workspaceCacheEpochs.get(key) ?? 0) + 1);
|
|
131
|
+
_workspaceGraphCache.delete(key);
|
|
132
|
+
}
|
|
133
|
+
function scheduleWorkspaceGraphEviction(key, entry) {
|
|
134
|
+
clearWorkspaceGraphTimer(entry);
|
|
135
|
+
const epoch = workspaceCacheEpoch(key);
|
|
136
|
+
entry.idleTimer = setTimeout(() => {
|
|
137
|
+
entry.idleTimer = undefined;
|
|
138
|
+
if (_workspaceGraphCache.get(key) !== entry || workspaceCacheEpoch(key) !== epoch)
|
|
139
|
+
return;
|
|
140
|
+
evictWorkspaceGraph(key, entry);
|
|
141
|
+
}, reviewGraphIdleEvictMs());
|
|
142
|
+
entry.idleTimer.unref?.();
|
|
143
|
+
}
|
|
144
|
+
function touchWorkspaceGraph(key) {
|
|
145
|
+
const entry = _workspaceGraphCache.get(key);
|
|
146
|
+
if (!entry)
|
|
147
|
+
return;
|
|
148
|
+
entry.lastUsedAt = Date.now();
|
|
149
|
+
scheduleWorkspaceGraphEviction(key, entry);
|
|
150
|
+
}
|
|
151
|
+
function setWorkspaceGraph(key, entry, epoch) {
|
|
152
|
+
if (epoch !== undefined && workspaceCacheEpoch(key) !== epoch)
|
|
153
|
+
return false;
|
|
154
|
+
const resident = { ...entry, lastUsedAt: Date.now() };
|
|
155
|
+
_workspaceGraphCache.set(key, resident);
|
|
156
|
+
scheduleWorkspaceGraphEviction(key, resident);
|
|
157
|
+
while (_workspaceGraphCache.size > REVIEW_GRAPH_MAX_WARM_WORKSPACES) {
|
|
158
|
+
const victim = [..._workspaceGraphCache.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)[0];
|
|
159
|
+
if (!victim)
|
|
160
|
+
break;
|
|
161
|
+
evictWorkspaceGraph(victim[0], victim[1]);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
95
165
|
// #459: process-wide monotonic source for ReviewGraph.buildGeneration stamps.
|
|
96
166
|
// Never reset (uniqueness is the invariant — a workspace-cache clear must not
|
|
97
167
|
// let a new build collide with a generation a derived-data cache recorded
|
|
98
168
|
// earlier in the same process).
|
|
99
169
|
let _graphGenerationCounter = 0;
|
|
170
|
+
// Build invocation identity is separate from graph content generation: cached
|
|
171
|
+
// builds can reuse content while still needing a stable lifecycle join key.
|
|
172
|
+
let _buildIdCounter = 0;
|
|
100
173
|
/** Beyond this many seq-changed files, incremental re-extract nears sweep cost — just sweep (#451). */
|
|
101
174
|
const SEQ_FASTPATH_MAX_CHANGES = 32;
|
|
102
175
|
/** Force a full walk+stat re-verify at least this often in wall time (external-edit safety valve, #451). */
|
|
103
176
|
const SEQ_FASTPATH_REVERIFY_MS = 5 * 60_000;
|
|
104
177
|
/** ...and at least every Nth fast-path build per workspace. */
|
|
105
178
|
const SEQ_FASTPATH_REVERIFY_EVERY = 20;
|
|
179
|
+
function graphLogMetadata(graph, options = {}) {
|
|
180
|
+
return makeReviewGraphBuildMetadata(graph, options);
|
|
181
|
+
}
|
|
106
182
|
function seqFastpathEnabled() {
|
|
107
183
|
const raw = process.env.PI_LENS_GRAPH_SEQ_FASTPATH;
|
|
108
184
|
return raw !== "0" && raw !== "false";
|
|
@@ -112,13 +188,68 @@ let _lastGraphBuildInfo = {
|
|
|
112
188
|
mode: "full",
|
|
113
189
|
graphChanged: true,
|
|
114
190
|
};
|
|
191
|
+
// The global slot above is retained for legacy status surfaces, but overlapping
|
|
192
|
+
// deferred builds need a per-result identity for lifecycle telemetry.
|
|
193
|
+
const _graphBuildInfoByGraph = new WeakMap();
|
|
194
|
+
// #1179: true once ANY graph has been stamped since the last cache clear — i.e.
|
|
195
|
+
// `_lastGraphBuildInfo` may now hold a REAL build's info (a potential sibling)
|
|
196
|
+
// rather than the pristine `mode: "full"` default. Used only by the fail-closed
|
|
197
|
+
// safety read below to decide whether the global slot is safe to serve on a
|
|
198
|
+
// WeakMap identity miss.
|
|
199
|
+
let _anyGraphStamped = false;
|
|
200
|
+
function setGraphBuildInfo(graph, info) {
|
|
201
|
+
_lastGraphBuildInfo = info;
|
|
202
|
+
_graphBuildInfoByGraph.set(graph, info);
|
|
203
|
+
_anyGraphStamped = true;
|
|
204
|
+
}
|
|
205
|
+
export function getGraphBuildInfoForGraph(graph) {
|
|
206
|
+
return _graphBuildInfoByGraph.get(graph) ?? _lastGraphBuildInfo;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* #1179 (fail-closed guard, latent P3 from the #1108/#1180 side-channel audit).
|
|
210
|
+
* Whether `getGraphBuildInfoForGraph(graph)` can be TRUSTED to describe `graph`
|
|
211
|
+
* itself — for a SAFETY gate that must never read a sibling graph's state.
|
|
212
|
+
*
|
|
213
|
+
* `getGraphBuildInfoForGraph` deliberately falls back to the global
|
|
214
|
+
* `_lastGraphBuildInfo` slot on a WeakMap identity miss — fine for the telemetry
|
|
215
|
+
* and `updateGraphBuildInfo` base-read callers, where a sibling build's `mode` is
|
|
216
|
+
* only informational (and the first stamp of a graph legitimately bases off the
|
|
217
|
+
* prior slot). But a gate that decides degraded-vs-clean must not read a DIFFERENT
|
|
218
|
+
* graph's `mode: "cached"` (healthy) off that fallback, or it would mistake an
|
|
219
|
+
* unstamped/rehydrated graph for a clean leaf and serve a silent all-clear (the
|
|
220
|
+
* #533 false-clean trap). Such a caller reads THIS first and, when it returns
|
|
221
|
+
* false, fails CLOSED (treats coverage as unknown/degraded) instead of trusting
|
|
222
|
+
* the slot.
|
|
223
|
+
*
|
|
224
|
+
* Trustworthy iff EITHER the graph's own identity is present in the WeakMap (the
|
|
225
|
+
* fallback is not even taken), OR nothing has been stamped since the last cache
|
|
226
|
+
* clear (`_lastGraphBuildInfo` is still the pristine `mode: "full"` default, which
|
|
227
|
+
* cannot be a sibling's real state). Only a miss AFTER some real build has stamped
|
|
228
|
+
* — where the global slot could be a sibling's info — is untrustworthy.
|
|
229
|
+
*
|
|
230
|
+
* Live path: always trustworthy. Every `_doBuildGraph` return stamps the graph via
|
|
231
|
+
* `setGraphBuildInfo` before returning it, so the cascade reads the freshly-stamped
|
|
232
|
+
* same-identity instance (`has(graph)` true) and the miss branch is unreachable
|
|
233
|
+
* today. This only hardens a future path that ever surfaces an unstamped graph.
|
|
234
|
+
*/
|
|
235
|
+
export function graphBuildInfoIsTrustworthy(graph) {
|
|
236
|
+
return _graphBuildInfoByGraph.has(graph) || !_anyGraphStamped;
|
|
237
|
+
}
|
|
238
|
+
function updateGraphBuildInfo(graph, patch) {
|
|
239
|
+
const info = { ...getGraphBuildInfoForGraph(graph), ...patch };
|
|
240
|
+
setGraphBuildInfo(graph, info);
|
|
241
|
+
return info;
|
|
242
|
+
}
|
|
115
243
|
export function clearGraphCache() {
|
|
116
244
|
_buildCache.clear();
|
|
117
245
|
}
|
|
118
246
|
export function clearReviewGraphWorkspaceCache(cwd) {
|
|
119
247
|
if (cwd === undefined) {
|
|
120
248
|
_buildCache.clear();
|
|
249
|
+
for (const entry of _workspaceGraphCache.values())
|
|
250
|
+
clearWorkspaceGraphTimer(entry);
|
|
121
251
|
_workspaceGraphCache.clear();
|
|
252
|
+
_workspaceCacheEpoch++;
|
|
122
253
|
_sizeSkipVerdicts.clear();
|
|
123
254
|
}
|
|
124
255
|
else {
|
|
@@ -128,21 +259,93 @@ export function clearReviewGraphWorkspaceCache(cwd) {
|
|
|
128
259
|
_buildCache.delete(key);
|
|
129
260
|
}
|
|
130
261
|
}
|
|
262
|
+
const entry = _workspaceGraphCache.get(normalized);
|
|
263
|
+
if (entry)
|
|
264
|
+
clearWorkspaceGraphTimer(entry);
|
|
265
|
+
_workspaceCacheEpochs.set(normalized, (_workspaceCacheEpochs.get(normalized) ?? 0) + 1);
|
|
131
266
|
_workspaceGraphCache.delete(normalized);
|
|
132
267
|
_sizeSkipVerdicts.delete(normalized);
|
|
133
268
|
}
|
|
134
269
|
_lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
|
|
270
|
+
// #1179: the global slot is back to the pristine default, so a subsequent
|
|
271
|
+
// identity miss can safely serve it again (it cannot be a sibling's state).
|
|
272
|
+
_anyGraphStamped = false;
|
|
273
|
+
}
|
|
274
|
+
let _reviewGraphBuildGateForTests;
|
|
275
|
+
/** Test seam for deterministically interleaving a build with cache eviction. */
|
|
276
|
+
export function _setReviewGraphBuildGateForTests(gate) {
|
|
277
|
+
_reviewGraphBuildGateForTests = gate;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* O(cache-entries) snapshot of the resident workspace graph cache — NOT
|
|
281
|
+
* O(nodes)/O(edges): only `.size`/`.length` are read per entry, never the
|
|
282
|
+
* graph contents themselves (#1123 item 2 memory-attribution sample). Cache
|
|
283
|
+
* entries are per-cwd and normally number 1 (a session_start clears the
|
|
284
|
+
* cache), so this is cheap enough for a per-N-turn sample.
|
|
285
|
+
*/
|
|
286
|
+
export function getReviewGraphWorkspaceCacheSnapshot() {
|
|
287
|
+
let totalNodes = 0;
|
|
288
|
+
let totalEdges = 0;
|
|
289
|
+
for (const entry of _workspaceGraphCache.values()) {
|
|
290
|
+
totalNodes += entry.graph.nodes.size;
|
|
291
|
+
totalEdges += entry.graph.edges.length;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
cacheEntries: _workspaceGraphCache.size,
|
|
295
|
+
totalNodes,
|
|
296
|
+
totalEdges,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** Test-only cache keys, including the LRU order from oldest to newest. */
|
|
300
|
+
export function _getReviewGraphWorkspaceCacheKeysForTests() {
|
|
301
|
+
return [..._workspaceGraphCache.entries()]
|
|
302
|
+
.sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
|
|
303
|
+
.map(([key]) => key);
|
|
135
304
|
}
|
|
136
305
|
export function _getReviewGraphCacheStateForTests(cwd) {
|
|
137
306
|
const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
|
|
138
307
|
if (!cached)
|
|
139
308
|
return undefined;
|
|
309
|
+
touchWorkspaceGraph(normalizeMapKey(cwd));
|
|
140
310
|
return {
|
|
141
311
|
signature: cached.signature,
|
|
142
312
|
fileSignatures: new Map(cached.fileSignatures),
|
|
143
313
|
fileHashes: cached.fileHashes ? new Map(cached.fileHashes) : undefined,
|
|
144
314
|
};
|
|
145
315
|
}
|
|
316
|
+
export function getReviewGraphCacheIdentity(cwd, graph) {
|
|
317
|
+
const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
|
|
318
|
+
if (!cached)
|
|
319
|
+
return undefined;
|
|
320
|
+
touchWorkspaceGraph(normalizeMapKey(cwd));
|
|
321
|
+
// #1088: `version` is the constant schema tag ("v8" today) — identical for
|
|
322
|
+
// every live graph, so comparing it can never detect that `graph` is a
|
|
323
|
+
// stale instance the workspace cache has since replaced (e.g. a concurrent
|
|
324
|
+
// build racing between this caller's projection snapshot and its identity
|
|
325
|
+
// lookup). Compare the ENTRY's generation stamp (#459), not the stored
|
|
326
|
+
// graph object's: reuse paths store an unstamped `cloneGraph` copy and
|
|
327
|
+
// stamp only the returned instance, so `cached.graph.buildGeneration` is
|
|
328
|
+
// undefined on every drift-reuse / disk-hit entry while the caller's graph
|
|
329
|
+
// carries the entry's generation. The both-undefined case is legitimate
|
|
330
|
+
// only for the tier-3 hydration entry, where the caller holds the exact
|
|
331
|
+
// stored instance — anything else unstamped must not resolve an identity
|
|
332
|
+
// (e.g. the size-skip empty graph racing a hydrated entry).
|
|
333
|
+
if (graph) {
|
|
334
|
+
if (cached.buildGeneration === undefined && graph.buildGeneration === undefined) {
|
|
335
|
+
if (cached.graph !== graph)
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
else if (cached.buildGeneration !== graph.buildGeneration) {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const version = graph?.version ?? cached.graph.version;
|
|
343
|
+
if (typeof version !== "string" || version.length === 0 ||
|
|
344
|
+
typeof cached.signature !== "string") {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return { version, signature: cached.signature };
|
|
348
|
+
}
|
|
146
349
|
// #300 Edge 2: the review-graph's cross-worktree isolation is INCIDENTAL to
|
|
147
350
|
// the cwd-derived data-dir slug (getProjectDataDir) — it holds only because
|
|
148
351
|
// every process is launched with its own worktree as cwd. If a host ever
|
|
@@ -225,6 +428,7 @@ export function getCachedReviewGraph(cwd) {
|
|
|
225
428
|
return undefined;
|
|
226
429
|
const cached = _workspaceGraphCache.get(key);
|
|
227
430
|
if (cached) {
|
|
431
|
+
touchWorkspaceGraph(key);
|
|
228
432
|
ensureIndexed(cached.graph);
|
|
229
433
|
return cached.graph;
|
|
230
434
|
}
|
|
@@ -242,7 +446,7 @@ export function getCachedReviewGraph(cwd) {
|
|
|
242
446
|
});
|
|
243
447
|
if (!disk)
|
|
244
448
|
return undefined;
|
|
245
|
-
|
|
449
|
+
setWorkspaceGraph(key, {
|
|
246
450
|
signature: disk.signature,
|
|
247
451
|
fileSignatures: disk.fileSignatures,
|
|
248
452
|
fileHashes: disk.fileHashes,
|
|
@@ -301,6 +505,10 @@ function cloneGraph(graph) {
|
|
|
301
505
|
persistCoverage: graph.persistCoverage,
|
|
302
506
|
};
|
|
303
507
|
}
|
|
508
|
+
/** Refresh the content timestamp only when an incremental path changed graph data. */
|
|
509
|
+
function refreshGraphBuiltAt(graph) {
|
|
510
|
+
graph.builtAt = new Date().toISOString();
|
|
511
|
+
}
|
|
304
512
|
function sourceSignatureEntry(file) {
|
|
305
513
|
try {
|
|
306
514
|
const stat = fs.statSync(file);
|
|
@@ -410,7 +618,7 @@ function diffSignatureMaps(previous, next) {
|
|
|
410
618
|
// `RUNTIME_CONFIG.reviewGraph.maxFiles` constant as the fallback — the ratio
|
|
411
619
|
// table reproduces that same 1,000-file default at the default base, so this
|
|
412
620
|
// is behavior-neutral when nothing is configured.
|
|
413
|
-
function getReviewGraphMaxFiles(cwd) {
|
|
621
|
+
export function getReviewGraphMaxFiles(cwd) {
|
|
414
622
|
const override = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_MAX_FILES ?? "", 10);
|
|
415
623
|
return Number.isFinite(override) && override > 0
|
|
416
624
|
? override
|
|
@@ -463,26 +671,50 @@ export function getLastReviewGraphBuildAttempt(cwd) {
|
|
|
463
671
|
export function _resetReviewGraphBuildAttemptsForTests() {
|
|
464
672
|
_buildAttempts.clear();
|
|
465
673
|
}
|
|
466
|
-
function recordBuildAttempt(cwd, outcome, reason) {
|
|
467
|
-
|
|
674
|
+
function recordBuildAttempt(cwd, outcome, reason, buildId) {
|
|
675
|
+
const key = normalizeMapKey(cwd);
|
|
676
|
+
const prior = _buildAttempts.get(key);
|
|
677
|
+
// Build IDs are assigned at start, so a terminal event from an older
|
|
678
|
+
// overlapping build must never overwrite the newest build's status/reason.
|
|
679
|
+
// Keep the newest started attempt as the project-report truth source.
|
|
680
|
+
if (prior?.buildId !== undefined &&
|
|
681
|
+
(buildId === undefined || buildId < prior.buildId)) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
_buildAttempts.set(key, {
|
|
468
685
|
when: new Date().toISOString(),
|
|
469
686
|
outcome,
|
|
687
|
+
...(buildId === undefined ? {} : { buildId }),
|
|
470
688
|
...(reason ? { reason } : {}),
|
|
471
689
|
});
|
|
472
690
|
}
|
|
473
|
-
function recordPersistFailure(cwd, reason, error) {
|
|
691
|
+
function recordPersistFailure(cwd, reason, error, pending, workerState) {
|
|
474
692
|
// A debounced persist can fail AFTER a newer build already recorded
|
|
475
693
|
// "failed" or "running" for this cwd — don't relabel a dead/in-flight
|
|
476
694
|
// build as succeeded; only annotate a record that says succeeded.
|
|
477
695
|
const prior = _buildAttempts.get(normalizeMapKey(cwd));
|
|
478
|
-
|
|
479
|
-
|
|
696
|
+
const pendingBuildId = pending?.graphMetadata.buildId;
|
|
697
|
+
const belongsToCurrentBuild = pendingBuildId === undefined || prior?.buildId === pendingBuildId;
|
|
698
|
+
if (belongsToCurrentBuild &&
|
|
699
|
+
(prior === undefined || prior.outcome === "succeeded")) {
|
|
700
|
+
recordBuildAttempt(cwd, "succeeded", `graph built but persistence failed: ${error}`, pendingBuildId);
|
|
480
701
|
}
|
|
481
702
|
logReviewGraph({
|
|
482
703
|
cwd,
|
|
483
704
|
phase: "persist_failed",
|
|
484
705
|
reason,
|
|
485
706
|
error,
|
|
707
|
+
...(pending
|
|
708
|
+
? {
|
|
709
|
+
observability: persistObservability(pending, {
|
|
710
|
+
status: "failed",
|
|
711
|
+
reason: workerState?.fallbackReason ?? reason,
|
|
712
|
+
workerStarted: workerState?.started,
|
|
713
|
+
workerCompleted: workerState?.completed,
|
|
714
|
+
...(workerState?.fallbackReason ? { workerFallback: true } : {}),
|
|
715
|
+
}),
|
|
716
|
+
}
|
|
717
|
+
: {}),
|
|
486
718
|
});
|
|
487
719
|
}
|
|
488
720
|
const _sizeSkipVerdicts = new Map();
|
|
@@ -494,12 +726,18 @@ function recordReviewGraphSizeSkip(cwd, sourceFileCount, maxFileCount) {
|
|
|
494
726
|
_sizeSkipVerdicts.set(normalizeMapKey(cwd), {
|
|
495
727
|
sourceFileCount,
|
|
496
728
|
maxFileCount,
|
|
729
|
+
sourceFileCountTruncated: true,
|
|
497
730
|
skippedAt: Date.now(),
|
|
498
731
|
});
|
|
499
732
|
}
|
|
500
733
|
function clearReviewGraphSizeSkip(cwd) {
|
|
501
734
|
_sizeSkipVerdicts.delete(normalizeMapKey(cwd));
|
|
502
735
|
}
|
|
736
|
+
const REVIEW_GRAPH_SIZE_NEAR_MISS_RATIO = 0.05;
|
|
737
|
+
function isReviewGraphSizeNearMiss(sourceFileCount, maxFileCount) {
|
|
738
|
+
return (sourceFileCount > maxFileCount &&
|
|
739
|
+
sourceFileCount <= maxFileCount * (1 + REVIEW_GRAPH_SIZE_NEAR_MISS_RATIO));
|
|
740
|
+
}
|
|
503
741
|
/**
|
|
504
742
|
* The most recent size-skip verdict for `cwd`, if one was recorded and it's
|
|
505
743
|
* still within its TTL — undefined once expired (a shrink or a raised cap
|
|
@@ -519,7 +757,17 @@ export function getReviewGraphSizeSkipVerdict(cwd, now = Date.now()) {
|
|
|
519
757
|
}
|
|
520
758
|
return verdict;
|
|
521
759
|
}
|
|
522
|
-
|
|
760
|
+
// Test-only override so the entry-budget propagation contract can be exercised
|
|
761
|
+
// without constructing a 200k-entry fixture tree.
|
|
762
|
+
let _reviewGraphEntryBudgetForTests;
|
|
763
|
+
export function _setReviewGraphEntryBudgetForTests(maxScanEntries) {
|
|
764
|
+
_reviewGraphEntryBudgetForTests = maxScanEntries;
|
|
765
|
+
}
|
|
766
|
+
let _reviewGraphEntryCounterForTests;
|
|
767
|
+
export function _setReviewGraphEntryCounterForTests(counter) {
|
|
768
|
+
_reviewGraphEntryCounterForTests = counter;
|
|
769
|
+
}
|
|
770
|
+
export async function getGraphSourceFiles(cwd) {
|
|
523
771
|
// Async, chunked-yield walk (identical output to the sync collector) so the
|
|
524
772
|
// per-edit cascade graph rebuild doesn't block the event loop on a large repo.
|
|
525
773
|
//
|
|
@@ -529,17 +777,21 @@ async function getGraphSourceFiles(cwd) {
|
|
|
529
777
|
// the cap is hit the caller skips the build on count alone, so the unfiltered
|
|
530
778
|
// over-limit list is all it needs — see _doBuildGraph's too_many_files branch.
|
|
531
779
|
const maxGraphFiles = getReviewGraphMaxFiles(cwd);
|
|
532
|
-
// #760: the maxFiles cap
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
// graph (it degrades to a partial graph, same as maxFiles trimming), so just
|
|
537
|
-
// log the truncation for observability rather than failing the build.
|
|
780
|
+
// #760: the maxFiles cap bounds results FOUND, not entries VISITED. A mixed
|
|
781
|
+
// tree with few source files among a huge pile of non-source files can trip the
|
|
782
|
+
// entry budget first. That result is a useful partial graph, but its lower-bound
|
|
783
|
+
// count MUST travel with the graph and persistence metadata; it is never clean.
|
|
538
784
|
const { files: collected, entryBudgetExceeded } = await collectProjectSourceFilesWithBudgetAsync(cwd, {
|
|
539
785
|
// Only walk graph-relevant extensions so the cap counts what the graph
|
|
540
786
|
// keeps (post-filter), not JSON/YAML/MD noise it would discard anyway.
|
|
541
787
|
extensions: MAIN_KIND_EXTENSIONS,
|
|
542
788
|
maxFiles: maxGraphFiles + 1,
|
|
789
|
+
...(_reviewGraphEntryBudgetForTests === undefined
|
|
790
|
+
? {}
|
|
791
|
+
: { maxScanEntries: _reviewGraphEntryBudgetForTests }),
|
|
792
|
+
...(_reviewGraphEntryCounterForTests === undefined
|
|
793
|
+
? {}
|
|
794
|
+
: { onEntryVisited: _reviewGraphEntryCounterForTests }),
|
|
543
795
|
});
|
|
544
796
|
if (entryBudgetExceeded) {
|
|
545
797
|
logLatency({
|
|
@@ -553,7 +805,12 @@ async function getGraphSourceFiles(cwd) {
|
|
|
553
805
|
if (collected.length > maxGraphFiles) {
|
|
554
806
|
// Contents are unused by the too_many_files branch; return the capped list
|
|
555
807
|
// so the caller's `length > maxGraphFiles` check still trips.
|
|
556
|
-
return
|
|
808
|
+
return {
|
|
809
|
+
files: collected,
|
|
810
|
+
sourceFileCount: collected.length,
|
|
811
|
+
maxFileCount: maxGraphFiles,
|
|
812
|
+
entryBudgetExceeded,
|
|
813
|
+
};
|
|
557
814
|
}
|
|
558
815
|
const result = [];
|
|
559
816
|
let sinceYield = 0;
|
|
@@ -576,7 +833,12 @@ async function getGraphSourceFiles(cwd) {
|
|
|
576
833
|
await yieldToLoop();
|
|
577
834
|
}
|
|
578
835
|
}
|
|
579
|
-
return
|
|
836
|
+
return {
|
|
837
|
+
files: result,
|
|
838
|
+
sourceFileCount: result.length,
|
|
839
|
+
maxFileCount: maxGraphFiles,
|
|
840
|
+
entryBudgetExceeded,
|
|
841
|
+
};
|
|
580
842
|
}
|
|
581
843
|
function addNode(graph, node) {
|
|
582
844
|
graph.nodes.set(node.id, node);
|
|
@@ -635,7 +897,13 @@ function loadPersistedGraph(cwd, opts) {
|
|
|
635
897
|
? gunzipSync(fs.readFileSync(cachePath)).toString("utf-8")
|
|
636
898
|
: fs.readFileSync(legacyPath, "utf-8");
|
|
637
899
|
const data = JSON.parse(raw);
|
|
638
|
-
|
|
900
|
+
// Derived projections require a canonical source identity. Legacy or
|
|
901
|
+
// malformed snapshots are unavailable, never a clean empty graph.
|
|
902
|
+
if (data.version !== REVIEW_GRAPH_VERSION ||
|
|
903
|
+
typeof data.signature !== "string" ||
|
|
904
|
+
typeof data.builtAt !== "string" ||
|
|
905
|
+
!Array.isArray(data.nodes) ||
|
|
906
|
+
!Array.isArray(data.edges))
|
|
639
907
|
return null;
|
|
640
908
|
if (data.coverage?.partial && !opts?.allowPartial)
|
|
641
909
|
return null;
|
|
@@ -727,7 +995,9 @@ function getPersistedReviewGraphVersion(cwd) {
|
|
|
727
995
|
fd = fs.openSync(legacyPath, "r");
|
|
728
996
|
const buf = Buffer.alloc(200);
|
|
729
997
|
const n = fs.readSync(fd, buf, 0, 200, 0);
|
|
730
|
-
const match = buf
|
|
998
|
+
const match = buf
|
|
999
|
+
.toString("utf-8", 0, n)
|
|
1000
|
+
.match(/"version"\s*:\s*"([^"]+)"/);
|
|
731
1001
|
return match ? match[1] : null;
|
|
732
1002
|
}
|
|
733
1003
|
catch {
|
|
@@ -785,7 +1055,9 @@ const _persistGenerations = new Map();
|
|
|
785
1055
|
const _workerRequests = new Map();
|
|
786
1056
|
let _persistWorker;
|
|
787
1057
|
let _persistWorkerRequestId = 0;
|
|
1058
|
+
let _persistAttemptId = 0;
|
|
788
1059
|
let _workerDisabled = false;
|
|
1060
|
+
let _persistWorkerUnavailableReason;
|
|
789
1061
|
let _lastWorkerFallbackReasonForTests;
|
|
790
1062
|
const _checkpointGenerations = new Map();
|
|
791
1063
|
const _checkpointWorkerRequests = new Map();
|
|
@@ -808,14 +1080,33 @@ function persistedData(pending) {
|
|
|
808
1080
|
gitStamp: pending.gitStamp,
|
|
809
1081
|
};
|
|
810
1082
|
}
|
|
811
|
-
function
|
|
1083
|
+
function countRetainedSourceFiles(graph, sourceFilePaths) {
|
|
1084
|
+
if (sourceFilePaths === undefined)
|
|
1085
|
+
return graph.fileNodes.size;
|
|
1086
|
+
const sourceKeys = new Set();
|
|
1087
|
+
for (const filePath of sourceFilePaths) {
|
|
1088
|
+
sourceKeys.add(normalizeMapKey(filePath));
|
|
1089
|
+
}
|
|
1090
|
+
let retained = 0;
|
|
1091
|
+
for (const filePath of sourceKeys) {
|
|
1092
|
+
if (graph.fileNodes.has(filePath))
|
|
1093
|
+
retained++;
|
|
1094
|
+
}
|
|
1095
|
+
return retained;
|
|
1096
|
+
}
|
|
1097
|
+
function graphCoverage(graph, cap, sourceFileCount = graph.persistCoverage?.totalFiles ?? graph.fileNodes.size, sourceFilesTruncated = graph.persistCoverage?.sourceFilesTruncated === true, sourceFilePaths) {
|
|
1098
|
+
const inherited = graph.persistCoverage;
|
|
812
1099
|
return {
|
|
813
|
-
partial:
|
|
1100
|
+
partial: inherited?.partial === true || sourceFilesTruncated,
|
|
814
1101
|
cap,
|
|
815
1102
|
totalNodes: graph.nodes.size,
|
|
816
1103
|
totalEdges: graph.edges.length,
|
|
817
1104
|
persistedNodes: graph.nodes.size,
|
|
818
1105
|
persistedEdges: graph.edges.length,
|
|
1106
|
+
totalFiles: sourceFileCount,
|
|
1107
|
+
persistedFiles: countRetainedSourceFiles(graph, sourceFilePaths),
|
|
1108
|
+
...(sourceFilesTruncated ? { sourceFilesTruncated: true } : {}),
|
|
1109
|
+
...(inherited?.inProgress ? { inProgress: true } : {}),
|
|
819
1110
|
};
|
|
820
1111
|
}
|
|
821
1112
|
/**
|
|
@@ -824,7 +1115,7 @@ function graphCoverage(graph, cap) {
|
|
|
824
1115
|
* graph's node/edge ratio so symbol-dense and edge-dense repositories both
|
|
825
1116
|
* retain a useful mix instead of allowing either side to consume the cap.
|
|
826
1117
|
*/
|
|
827
|
-
function capGraphForPersist(cwd, graph, cap) {
|
|
1118
|
+
function capGraphForPersist(cwd, graph, cap, options = {}) {
|
|
828
1119
|
const totalElements = graph.nodes.size + graph.edges.length;
|
|
829
1120
|
const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / totalElements));
|
|
830
1121
|
const reverseDeps = buildReverseDependencyIndexFromGraph({ cwd, graph });
|
|
@@ -842,7 +1133,9 @@ function capGraphForPersist(cwd, graph, cap) {
|
|
|
842
1133
|
const ids = nodeIdsByFile.get(filePath) ?? [];
|
|
843
1134
|
if (ids.length === 0)
|
|
844
1135
|
continue;
|
|
845
|
-
const effectiveNodeBudget = keptIds.size === 0
|
|
1136
|
+
const effectiveNodeBudget = keptIds.size === 0
|
|
1137
|
+
? Math.max(nodeBudget, Math.min(cap, ids.length))
|
|
1138
|
+
: nodeBudget;
|
|
846
1139
|
if (keptIds.size + ids.length > effectiveNodeBudget)
|
|
847
1140
|
continue;
|
|
848
1141
|
for (const id of ids)
|
|
@@ -860,6 +1153,15 @@ function capGraphForPersist(cwd, graph, cap) {
|
|
|
860
1153
|
totalEdges: graph.edges.length,
|
|
861
1154
|
persistedNodes: nodes.size,
|
|
862
1155
|
persistedEdges: edges.length,
|
|
1156
|
+
totalFiles: options.sourceFileCount ??
|
|
1157
|
+
graph.persistCoverage?.totalFiles ??
|
|
1158
|
+
graph.fileNodes.size,
|
|
1159
|
+
persistedFiles: 0,
|
|
1160
|
+
...(options.sourceFilesTruncated ||
|
|
1161
|
+
graph.persistCoverage?.sourceFilesTruncated
|
|
1162
|
+
? { sourceFilesTruncated: true }
|
|
1163
|
+
: {}),
|
|
1164
|
+
...(graph.persistCoverage?.inProgress ? { inProgress: true } : {}),
|
|
863
1165
|
};
|
|
864
1166
|
const capped = {
|
|
865
1167
|
...graph,
|
|
@@ -870,12 +1172,20 @@ function capGraphForPersist(cwd, graph, cap) {
|
|
|
870
1172
|
fileNodes: new Map(),
|
|
871
1173
|
symbolNodesByFile: new Map(),
|
|
872
1174
|
changedSymbolsByFile: new Map(),
|
|
873
|
-
persistCoverage:
|
|
1175
|
+
persistCoverage: undefined,
|
|
874
1176
|
};
|
|
875
1177
|
rebuildIndexes(capped);
|
|
1178
|
+
coverage.persistedFiles = countRetainedSourceFiles(capped, options.sourceFilePaths);
|
|
1179
|
+
capped.persistCoverage = coverage;
|
|
876
1180
|
return capped;
|
|
877
1181
|
}
|
|
878
|
-
function
|
|
1182
|
+
function persistObservability(pending, patch) {
|
|
1183
|
+
return {
|
|
1184
|
+
graph: pending.graphMetadata,
|
|
1185
|
+
persistence: { ...pending.persistenceMetadata, ...patch },
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
function logPersistSuccess(key, pending, stats, workerState = { started: false, completed: false }) {
|
|
879
1189
|
logLatency({
|
|
880
1190
|
type: "phase",
|
|
881
1191
|
phase: "review_graph_persist",
|
|
@@ -888,9 +1198,15 @@ function logPersistSuccess(key, pending, stats) {
|
|
|
888
1198
|
phase: "persist_succeeded",
|
|
889
1199
|
elements: pending.elementCount,
|
|
890
1200
|
...stats,
|
|
1201
|
+
observability: persistObservability(pending, {
|
|
1202
|
+
status: "succeeded",
|
|
1203
|
+
workerStarted: workerState.started,
|
|
1204
|
+
workerCompleted: workerState.completed,
|
|
1205
|
+
...(workerState.fallback ? { workerFallback: true } : {}),
|
|
1206
|
+
}),
|
|
891
1207
|
});
|
|
892
1208
|
}
|
|
893
|
-
function writePendingOnMainThread(key, pending, reason) {
|
|
1209
|
+
function writePendingOnMainThread(key, pending, reason, workerState = { started: false, completed: false }) {
|
|
894
1210
|
const serializeStarted = performance.now();
|
|
895
1211
|
try {
|
|
896
1212
|
const json = JSON.stringify(persistedData(pending));
|
|
@@ -909,6 +1225,10 @@ function writePendingOnMainThread(key, pending, reason) {
|
|
|
909
1225
|
serializeMs,
|
|
910
1226
|
writeMs: performance.now() - writeStarted,
|
|
911
1227
|
offloaded: false,
|
|
1228
|
+
}, {
|
|
1229
|
+
started: workerState.started,
|
|
1230
|
+
completed: workerState.completed,
|
|
1231
|
+
fallback: Boolean(reason),
|
|
912
1232
|
});
|
|
913
1233
|
if (reason) {
|
|
914
1234
|
// The persist SUCCEEDED via fallback — log the degradation under its
|
|
@@ -921,13 +1241,22 @@ function writePendingOnMainThread(key, pending, reason) {
|
|
|
921
1241
|
reason: "worker_fallback",
|
|
922
1242
|
error: reason,
|
|
923
1243
|
offloaded: false,
|
|
1244
|
+
observability: persistObservability(pending, {
|
|
1245
|
+
status: "fallback",
|
|
1246
|
+
workerStarted: workerState.started,
|
|
1247
|
+
workerCompleted: workerState.completed,
|
|
1248
|
+
workerFallback: true,
|
|
1249
|
+
reason: workerState.fallbackReason ?? "worker_fallback",
|
|
1250
|
+
}),
|
|
924
1251
|
});
|
|
925
1252
|
}
|
|
926
1253
|
}
|
|
927
1254
|
catch (err) {
|
|
928
1255
|
const message = err instanceof Error ? err.message : String(err);
|
|
929
|
-
recordPersistFailure(key, "cache_write_failed", message);
|
|
930
|
-
|
|
1256
|
+
recordPersistFailure(key, "cache_write_failed", message, pending, workerState);
|
|
1257
|
+
// #1333: recordPersistFailure already emits `phase: "persist_failed"` to
|
|
1258
|
+
// review-graph.log with this same message — the console.error was a
|
|
1259
|
+
// duplicate RAW write into pi's frame, not a second destination.
|
|
931
1260
|
}
|
|
932
1261
|
}
|
|
933
1262
|
function handleWorkerResult(result) {
|
|
@@ -946,17 +1275,50 @@ function handleWorkerResult(result) {
|
|
|
946
1275
|
}
|
|
947
1276
|
_workerRequests.delete(result.id);
|
|
948
1277
|
const { key, pending } = request;
|
|
1278
|
+
const currentGeneration = _persistGenerations.get(key);
|
|
1279
|
+
if (currentGeneration !== result.generation) {
|
|
1280
|
+
// Removing the request makes completion observable to test/CLI waiters.
|
|
1281
|
+
// Reap our completed loser synchronously first so "no requests in flight"
|
|
1282
|
+
// also means its stage namespace is clean (#1318). Caught (#1361 review):
|
|
1283
|
+
// force suppresses ENOENT but not EBUSY/EPERM (Windows AV/backup can
|
|
1284
|
+
// briefly hold the handle) -- a failed reap must never abort this
|
|
1285
|
+
// callback, or the WINNING generation's completion is lost with it. The
|
|
1286
|
+
// startup sweep reclaims anything a failed unlink leaves behind.
|
|
1287
|
+
try {
|
|
1288
|
+
fs.rmSync(result.stagePath, { force: true });
|
|
1289
|
+
}
|
|
1290
|
+
catch (reapErr) {
|
|
1291
|
+
logReviewGraph({
|
|
1292
|
+
cwd: key,
|
|
1293
|
+
phase: "persist_failed",
|
|
1294
|
+
reason: `superseded-stage reap failed: ${String(reapErr)}`,
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
logReviewGraph({
|
|
1298
|
+
cwd: key,
|
|
1299
|
+
phase: "persist_skipped",
|
|
1300
|
+
reason: "superseded",
|
|
1301
|
+
observability: persistObservability(pending, {
|
|
1302
|
+
status: "superseded",
|
|
1303
|
+
supersededByGeneration: currentGeneration,
|
|
1304
|
+
reason: "newer_generation_scheduled",
|
|
1305
|
+
workerStarted: true,
|
|
1306
|
+
workerCompleted: true,
|
|
1307
|
+
}),
|
|
1308
|
+
});
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
949
1311
|
if (result.error ||
|
|
950
1312
|
result.rawBytes === undefined ||
|
|
951
1313
|
result.gzBytes === undefined ||
|
|
952
1314
|
result.serializeMs === undefined ||
|
|
953
1315
|
result.writeMs === undefined) {
|
|
954
1316
|
fs.rm(result.stagePath, { force: true }, () => { });
|
|
955
|
-
writePendingOnMainThread(key, pending, result.error ?? "invalid worker result"
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1317
|
+
writePendingOnMainThread(key, pending, result.error ?? "invalid worker result", {
|
|
1318
|
+
started: true,
|
|
1319
|
+
completed: true,
|
|
1320
|
+
fallbackReason: result.error ? "worker_error" : "invalid_worker_result",
|
|
1321
|
+
});
|
|
960
1322
|
return;
|
|
961
1323
|
}
|
|
962
1324
|
try {
|
|
@@ -970,11 +1332,15 @@ function handleWorkerResult(result) {
|
|
|
970
1332
|
serializeMs: result.serializeMs,
|
|
971
1333
|
writeMs: result.writeMs,
|
|
972
1334
|
offloaded: true,
|
|
973
|
-
});
|
|
1335
|
+
}, { started: true, completed: true });
|
|
974
1336
|
}
|
|
975
1337
|
catch (err) {
|
|
976
1338
|
fs.rm(result.stagePath, { force: true }, () => { });
|
|
977
|
-
writePendingOnMainThread(key, pending, err instanceof Error ? err.message : String(err)
|
|
1339
|
+
writePendingOnMainThread(key, pending, err instanceof Error ? err.message : String(err), {
|
|
1340
|
+
started: true,
|
|
1341
|
+
completed: true,
|
|
1342
|
+
fallbackReason: "promotion_failed",
|
|
1343
|
+
});
|
|
978
1344
|
}
|
|
979
1345
|
}
|
|
980
1346
|
function handleCheckpointWorkerResult(cp, result) {
|
|
@@ -1024,12 +1390,32 @@ function handleCheckpointWorkerResult(cp, result) {
|
|
|
1024
1390
|
}
|
|
1025
1391
|
}
|
|
1026
1392
|
function handleWorkerDeath(reason) {
|
|
1393
|
+
_persistWorkerUnavailableReason = reason;
|
|
1027
1394
|
_persistWorker = undefined;
|
|
1028
1395
|
_workerDisabled = true;
|
|
1029
1396
|
const requests = [..._workerRequests.values()];
|
|
1030
1397
|
_workerRequests.clear();
|
|
1031
1398
|
for (const { key, pending } of requests) {
|
|
1032
|
-
|
|
1399
|
+
if (_persistGenerations.get(key) !== pending.generation) {
|
|
1400
|
+
logReviewGraph({
|
|
1401
|
+
cwd: key,
|
|
1402
|
+
phase: "persist_skipped",
|
|
1403
|
+
reason: "superseded",
|
|
1404
|
+
observability: persistObservability(pending, {
|
|
1405
|
+
status: "superseded",
|
|
1406
|
+
supersededByGeneration: _persistGenerations.get(key),
|
|
1407
|
+
reason: "worker_death_after_newer_generation",
|
|
1408
|
+
workerStarted: true,
|
|
1409
|
+
workerCompleted: false,
|
|
1410
|
+
}),
|
|
1411
|
+
});
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
writePendingOnMainThread(key, pending, reason, {
|
|
1415
|
+
started: true,
|
|
1416
|
+
completed: false,
|
|
1417
|
+
fallbackReason: "worker_death",
|
|
1418
|
+
});
|
|
1033
1419
|
}
|
|
1034
1420
|
// Best-effort checkpoints don't fall back (no retained DTO to pin heap); drop
|
|
1035
1421
|
// their in-flight requests and clean up any stage files they may have left.
|
|
@@ -1080,22 +1466,27 @@ function getPersistWorker() {
|
|
|
1080
1466
|
return undefined;
|
|
1081
1467
|
}
|
|
1082
1468
|
const worker = new Worker(workerPath);
|
|
1083
|
-
worker.unref();
|
|
1084
1469
|
worker.on("message", handleWorkerResult);
|
|
1085
1470
|
worker.on("error", (err) => handleWorkerDeath(err.message));
|
|
1086
1471
|
worker.on("exit", (code) => {
|
|
1087
1472
|
if (_persistWorker !== worker)
|
|
1088
1473
|
return;
|
|
1089
|
-
|
|
1090
|
-
|
|
1474
|
+
const hasPendingRequests = _workerRequests.size > 0 || _checkpointWorkerRequests.size > 0;
|
|
1475
|
+
if (code !== 0 || hasPendingRequests) {
|
|
1476
|
+
handleWorkerDeath(code !== 0
|
|
1477
|
+
? `persist worker exited with code ${code}`
|
|
1478
|
+
: "persist worker exited with pending requests");
|
|
1091
1479
|
}
|
|
1092
1480
|
else {
|
|
1093
|
-
// Clean exit (unref'd worker at teardown, or host
|
|
1094
|
-
// drop the stale reference so a later persist respawns
|
|
1095
|
-
// of posting into a dead worker (#950 review F7).
|
|
1481
|
+
// Clean exit with no pending work (unref'd worker at teardown, or host
|
|
1482
|
+
// recycling): drop the stale reference so a later persist respawns
|
|
1483
|
+
// instead of posting into a dead worker (#950 review F7).
|
|
1096
1484
|
_persistWorker = undefined;
|
|
1097
1485
|
}
|
|
1098
1486
|
});
|
|
1487
|
+
// #1148: adding a message listener refs the Worker's public MessagePort.
|
|
1488
|
+
// Unref only after every listener is installed so it stays background-only.
|
|
1489
|
+
worker.unref();
|
|
1099
1490
|
_persistWorker = worker;
|
|
1100
1491
|
return worker;
|
|
1101
1492
|
}
|
|
@@ -1116,7 +1507,12 @@ function writePending(key) {
|
|
|
1116
1507
|
}
|
|
1117
1508
|
const worker = getPersistWorker();
|
|
1118
1509
|
if (!worker) {
|
|
1119
|
-
|
|
1510
|
+
const unavailableReason = _persistWorkerUnavailableReason ?? "persist worker unavailable";
|
|
1511
|
+
writePendingOnMainThread(key, pending, unavailableReason, {
|
|
1512
|
+
started: false,
|
|
1513
|
+
completed: false,
|
|
1514
|
+
fallbackReason: unavailableReason,
|
|
1515
|
+
});
|
|
1120
1516
|
return;
|
|
1121
1517
|
}
|
|
1122
1518
|
const id = ++_persistWorkerRequestId;
|
|
@@ -1139,31 +1535,81 @@ function writePending(key) {
|
|
|
1139
1535
|
// snapshot isn't lost. Sync writes only (no child spawn — see the teardown
|
|
1140
1536
|
// libuv hazard); best-effort.
|
|
1141
1537
|
let _persistExitHookInstalled = false;
|
|
1538
|
+
function flushPendingReviewGraphPersistsAtExit() {
|
|
1539
|
+
const keys = new Set([
|
|
1540
|
+
..._pendingPersist.keys(),
|
|
1541
|
+
...[..._workerRequests.values()].map((request) => request.key),
|
|
1542
|
+
]);
|
|
1543
|
+
for (const key of keys) {
|
|
1544
|
+
// Shared with the CLI's forced flush — same persistedData DTO, same
|
|
1545
|
+
// atomic writer (#762), distinct failure label per source.
|
|
1546
|
+
flushReviewGraphPersist(key, "exit_hook");
|
|
1547
|
+
}
|
|
1548
|
+
// The review-graph logger's shared process-exit flusher was registered
|
|
1549
|
+
// before this hook. Flush again after emitting exit-hook outcomes so a
|
|
1550
|
+
// successful forced write cannot leave its lifecycle event buffered.
|
|
1551
|
+
flushReviewGraphLogSync();
|
|
1552
|
+
void _persistWorker?.terminate();
|
|
1553
|
+
}
|
|
1554
|
+
/** Test-only seam for the exit-hook ordering/durability contract. */
|
|
1555
|
+
export function flushReviewGraphPersistsForExitForTests() {
|
|
1556
|
+
flushPendingReviewGraphPersistsAtExit();
|
|
1557
|
+
}
|
|
1142
1558
|
function ensurePersistExitHook() {
|
|
1143
1559
|
if (_persistExitHookInstalled)
|
|
1144
1560
|
return;
|
|
1145
1561
|
_persistExitHookInstalled = true;
|
|
1146
|
-
process.once("exit",
|
|
1147
|
-
const keys = new Set([
|
|
1148
|
-
..._pendingPersist.keys(),
|
|
1149
|
-
...[..._workerRequests.values()].map((request) => request.key),
|
|
1150
|
-
]);
|
|
1151
|
-
for (const key of keys) {
|
|
1152
|
-
// Shared with the CLI's forced flush — same persistedData DTO, same
|
|
1153
|
-
// atomic writer (#762), distinct failure label per source.
|
|
1154
|
-
const result = flushReviewGraphPersist(key, "exit_hook");
|
|
1155
|
-
if (!result.ok)
|
|
1156
|
-
flushReviewGraphLogSync();
|
|
1157
|
-
}
|
|
1158
|
-
void _persistWorker?.terminate();
|
|
1159
|
-
});
|
|
1562
|
+
process.once("exit", flushPendingReviewGraphPersistsAtExit);
|
|
1160
1563
|
}
|
|
1161
1564
|
// #950 review F3: a process that dies between a worker's staged write and its
|
|
1162
1565
|
// promotion leaves review-graph.json.gz.stage-<pid>-<gen> (and the worker's
|
|
1163
|
-
//
|
|
1164
|
-
// Sweep leftovers from PRIOR processes once per cache
|
|
1165
|
-
//
|
|
1566
|
+
// <stage>.tmp-<pid>) behind forever — the exit hook can't run
|
|
1567
|
+
// handleWorkerResult's rm. Sweep leftovers from PRIOR processes once per cache
|
|
1568
|
+
// dir.
|
|
1569
|
+
//
|
|
1570
|
+
// #1206: `cacheDir` is the SHARED project cache dir (getProjectDataDir/cache)
|
|
1571
|
+
// that every durable store stages into via writeFileAtomic's generic
|
|
1572
|
+
// `<target>.tmp-<pid>`. The old predicate also matched that shape, so this
|
|
1573
|
+
// sweep deleted OTHER modules' in-flight staging files in the window between
|
|
1574
|
+
// their write and their rename — turning their rename into ENOENT (propagated
|
|
1575
|
+
// out of markDisposition for bestEffort:false stores) or silently dropping the
|
|
1576
|
+
// update. The sweep is therefore scoped to artifacts the review graph itself
|
|
1577
|
+
// produces, all of which are `<review-graph.*>.stage-<pid>-<gen>`:
|
|
1578
|
+
// review-graph.json.gz.stage-<pid>-<gen> (persistGraph, L1861)
|
|
1579
|
+
// review-graph.checkpoint.json.gz.stage-<pid>-<gen> (checkpoint, L2259)
|
|
1580
|
+
// ...plus either's worker tmp `<stage>.tmp-<pid>`, which still carries both
|
|
1581
|
+
// the `review-graph.` prefix and the `.stage-` marker.
|
|
1582
|
+
// (LEGACY_GRAPH_CACHE_FILENAME, `review-graph.json`, is never staged — it is
|
|
1583
|
+
// only ever `rmSync`'d as a one-time migration cleanup — so it is not a
|
|
1584
|
+
// producer here despite matching the prefix.)
|
|
1585
|
+
// The bare `.tmp-<pid>` shape is never matched, which also makes this sweep
|
|
1586
|
+
// independent of any change to atomic-write's staging name (#1205). Dropping
|
|
1587
|
+
// that shape means the review-graph sweep is no longer the incidental GC for
|
|
1588
|
+
// other stores' orphaned atomic-write temps; the generic `.tmp-*` namespace is
|
|
1589
|
+
// now swept at session_start by instance-reaper (#1228), not by this graph
|
|
1590
|
+
// artifact-specific pass.
|
|
1591
|
+
//
|
|
1592
|
+
// Liveness: an entry whose embedded stage pid is still alive belongs to a
|
|
1593
|
+
// concurrent healthy owner (or to us) and is skipped, reusing the reaper's
|
|
1594
|
+
// conservative `realIsPidAlive` (ESRCH-only-means-dead) rather than inventing
|
|
1595
|
+
// a second liveness probe. A recycled pid can therefore leave one stale stage
|
|
1596
|
+
// file behind instead of destroying a live one — deliberately the safe
|
|
1597
|
+
// direction; a later process whose pid table has moved on sweeps it.
|
|
1166
1598
|
const _sweptStageDirs = new Set();
|
|
1599
|
+
const REVIEW_GRAPH_ARTIFACT_PREFIX = "review-graph.";
|
|
1600
|
+
const STAGE_PID_PATTERN = /\.stage-(\d+)-/;
|
|
1601
|
+
/** True only for a review-graph stage artifact left behind by a dead process. */
|
|
1602
|
+
function isStaleReviewGraphStageFile(entry) {
|
|
1603
|
+
if (!entry.startsWith(REVIEW_GRAPH_ARTIFACT_PREFIX))
|
|
1604
|
+
return false;
|
|
1605
|
+
const match = STAGE_PID_PATTERN.exec(entry);
|
|
1606
|
+
if (!match)
|
|
1607
|
+
return false;
|
|
1608
|
+
const pid = Number(match[1]);
|
|
1609
|
+
if (pid === process.pid)
|
|
1610
|
+
return false; // our own live stage file
|
|
1611
|
+
return !realIsPidAlive(pid);
|
|
1612
|
+
}
|
|
1167
1613
|
function sweepStaleStageFiles(cacheDir) {
|
|
1168
1614
|
if (_sweptStageDirs.has(cacheDir))
|
|
1169
1615
|
return;
|
|
@@ -1171,22 +1617,35 @@ function sweepStaleStageFiles(cacheDir) {
|
|
|
1171
1617
|
fs.readdir(cacheDir, (err, entries) => {
|
|
1172
1618
|
if (err)
|
|
1173
1619
|
return;
|
|
1174
|
-
const ownMarker = `.stage-${process.pid}-`;
|
|
1175
1620
|
for (const entry of entries) {
|
|
1176
|
-
|
|
1177
|
-
if (!isStage || entry.includes(ownMarker))
|
|
1621
|
+
if (!isStaleReviewGraphStageFile(entry))
|
|
1178
1622
|
continue;
|
|
1179
1623
|
fs.rm(path.join(cacheDir, entry), { force: true }, () => { });
|
|
1180
1624
|
}
|
|
1181
1625
|
});
|
|
1182
1626
|
}
|
|
1183
|
-
|
|
1627
|
+
/** Test seam: the sweep runs at most once per cache dir per process. */
|
|
1628
|
+
export function _resetReviewGraphStageSweepForTests() {
|
|
1629
|
+
_sweptStageDirs.clear();
|
|
1630
|
+
}
|
|
1631
|
+
function persistGraph(cwd, signature, fileSignatures, fileHashes, graph, options = {}) {
|
|
1184
1632
|
const totalElementCount = graph.nodes.size + graph.edges.length;
|
|
1185
1633
|
const cap = graphPersistMaxElements();
|
|
1634
|
+
const sourceFileCount = options.sourceFileCount ?? fileSignatures.size;
|
|
1635
|
+
const sourceFilePaths = options.sourceFilePaths ?? fileSignatures.keys();
|
|
1186
1636
|
const persistedGraph = totalElementCount > cap
|
|
1187
|
-
? capGraphForPersist(cwd, graph, cap
|
|
1188
|
-
|
|
1637
|
+
? capGraphForPersist(cwd, graph, cap, {
|
|
1638
|
+
sourceFileCount,
|
|
1639
|
+
sourceFilesTruncated: options.sourceFilesTruncated,
|
|
1640
|
+
sourceFilePaths,
|
|
1641
|
+
})
|
|
1642
|
+
: {
|
|
1643
|
+
...graph,
|
|
1644
|
+
persistCoverage: graphCoverage(graph, cap, sourceFileCount, options.sourceFilesTruncated, sourceFilePaths),
|
|
1645
|
+
};
|
|
1189
1646
|
const elementCount = persistedGraph.nodes.size + persistedGraph.edges.length;
|
|
1647
|
+
const sourceWalkPartial = persistedGraph.persistCoverage?.sourceFilesTruncated === true;
|
|
1648
|
+
let persistReason;
|
|
1190
1649
|
if (totalElementCount > cap) {
|
|
1191
1650
|
const coverage = persistedGraph.persistCoverage;
|
|
1192
1651
|
if (!coverage)
|
|
@@ -1203,18 +1662,13 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
|
|
|
1203
1662
|
cap,
|
|
1204
1663
|
},
|
|
1205
1664
|
});
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
reason: "element_cap_exceeded",
|
|
1214
|
-
elements: totalElementCount,
|
|
1215
|
-
persistedElements: elementCount,
|
|
1216
|
-
cap,
|
|
1217
|
-
});
|
|
1665
|
+
persistReason =
|
|
1666
|
+
`persisted partial review graph (${elementCount}/${totalElementCount} elements; ` +
|
|
1667
|
+
`${coverage.persistedNodes}/${coverage.totalNodes} nodes, ` +
|
|
1668
|
+
`${coverage.persistedEdges}/${coverage.totalEdges} edges; ${cap} cap)`;
|
|
1669
|
+
}
|
|
1670
|
+
else if (sourceWalkPartial) {
|
|
1671
|
+
persistReason = "persisted partial review graph (source walk entry budget)";
|
|
1218
1672
|
}
|
|
1219
1673
|
const cacheDir = path.join(getProjectDataDir(cwd), "cache");
|
|
1220
1674
|
const cachePath = path.join(cacheDir, GRAPH_CACHE_FILENAME);
|
|
@@ -1228,9 +1682,31 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
|
|
|
1228
1682
|
// arrays only after the quiet window. Replacing a pending entry during an edit
|
|
1229
1683
|
// burst now avoids both serialization and the pre-serialization full copies.
|
|
1230
1684
|
const key = normalizeMapKey(cwd);
|
|
1231
|
-
const
|
|
1685
|
+
const priorGeneration = _persistGenerations.get(key);
|
|
1686
|
+
const existingPending = _pendingPersist.get(key);
|
|
1687
|
+
const existingTimer = _persistTimers.get(key);
|
|
1688
|
+
const generation = (priorGeneration ?? 0) + 1;
|
|
1689
|
+
const attemptId = ++_persistAttemptId;
|
|
1690
|
+
const coalesced = Boolean(existingPending || existingTimer);
|
|
1232
1691
|
_persistGenerations.set(key, generation);
|
|
1233
|
-
|
|
1692
|
+
const graphMetadata = graphLogMetadata(persistedGraph, {
|
|
1693
|
+
...options,
|
|
1694
|
+
sourceFileCount,
|
|
1695
|
+
sourceFileCountTruncated: persistedGraph.persistCoverage?.sourceFilesTruncated === true,
|
|
1696
|
+
});
|
|
1697
|
+
const persistenceMetadata = {
|
|
1698
|
+
generation,
|
|
1699
|
+
attemptId,
|
|
1700
|
+
status: "scheduled",
|
|
1701
|
+
...(priorGeneration === undefined
|
|
1702
|
+
? {}
|
|
1703
|
+
: {
|
|
1704
|
+
supersededGeneration: priorGeneration,
|
|
1705
|
+
coalesced,
|
|
1706
|
+
reason: coalesced ? "debounced_coalescing" : "in_flight_supersession",
|
|
1707
|
+
}),
|
|
1708
|
+
};
|
|
1709
|
+
const pending = {
|
|
1234
1710
|
cacheDir,
|
|
1235
1711
|
cachePath,
|
|
1236
1712
|
signature,
|
|
@@ -1240,12 +1716,50 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
|
|
|
1240
1716
|
gitStamp,
|
|
1241
1717
|
elementCount,
|
|
1242
1718
|
generation,
|
|
1243
|
-
|
|
1719
|
+
attemptId,
|
|
1720
|
+
graphMetadata,
|
|
1721
|
+
persistenceMetadata,
|
|
1722
|
+
};
|
|
1723
|
+
if (existingPending) {
|
|
1724
|
+
logReviewGraph({
|
|
1725
|
+
cwd: key,
|
|
1726
|
+
phase: "persist_skipped",
|
|
1727
|
+
reason: "superseded",
|
|
1728
|
+
observability: persistObservability(existingPending, {
|
|
1729
|
+
status: "superseded",
|
|
1730
|
+
supersededByGeneration: generation,
|
|
1731
|
+
reason: "newer_generation_scheduled",
|
|
1732
|
+
workerStarted: false,
|
|
1733
|
+
workerCompleted: false,
|
|
1734
|
+
}),
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
_pendingPersist.set(key, pending);
|
|
1738
|
+
if (totalElementCount > cap || sourceWalkPartial) {
|
|
1739
|
+
logReviewGraph({
|
|
1740
|
+
cwd,
|
|
1741
|
+
phase: "persist_partial",
|
|
1742
|
+
reason: totalElementCount > cap
|
|
1743
|
+
? "element_cap_exceeded"
|
|
1744
|
+
: "source_walk_entry_budget",
|
|
1745
|
+
elements: totalElementCount,
|
|
1746
|
+
persistedElements: elementCount,
|
|
1747
|
+
cap,
|
|
1748
|
+
observability: {
|
|
1749
|
+
graph: graphMetadata,
|
|
1750
|
+
persistence: persistenceMetadata,
|
|
1751
|
+
},
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1244
1754
|
logReviewGraph({
|
|
1245
1755
|
cwd,
|
|
1246
1756
|
phase: "persist_scheduled",
|
|
1247
1757
|
elements: elementCount,
|
|
1248
1758
|
cap,
|
|
1759
|
+
observability: {
|
|
1760
|
+
graph: graphMetadata,
|
|
1761
|
+
persistence: persistenceMetadata,
|
|
1762
|
+
},
|
|
1249
1763
|
});
|
|
1250
1764
|
ensurePersistExitHook();
|
|
1251
1765
|
const debounce = graphPersistDebounceMs();
|
|
@@ -1254,13 +1768,14 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
|
|
|
1254
1768
|
clearTimeout(existing);
|
|
1255
1769
|
if (debounce === 0) {
|
|
1256
1770
|
writePending(key);
|
|
1257
|
-
return;
|
|
1771
|
+
return persistReason;
|
|
1258
1772
|
}
|
|
1259
1773
|
const timer = setTimeout(() => writePending(key), debounce);
|
|
1260
1774
|
// Don't keep the event loop alive solely for a cache write.
|
|
1261
1775
|
if (typeof timer.unref === "function")
|
|
1262
1776
|
timer.unref();
|
|
1263
1777
|
_persistTimers.set(key, timer);
|
|
1778
|
+
return persistReason;
|
|
1264
1779
|
}
|
|
1265
1780
|
// --- Cross-session resumable full build (checkpointing, #936 limit 2) ---
|
|
1266
1781
|
// A full build walks + tree-sitter-parses every source file, then resolves
|
|
@@ -1306,7 +1821,7 @@ function reviewGraphCheckpointPath(cwd) {
|
|
|
1306
1821
|
function hashIgnoredIds(ignoredIds) {
|
|
1307
1822
|
if (ignoredIds === undefined)
|
|
1308
1823
|
return "unavailable";
|
|
1309
|
-
const joined = [...ignoredIds].sort((a, b) => a.localeCompare(b)).join("
|
|
1824
|
+
const joined = [...ignoredIds].sort((a, b) => a.localeCompare(b)).join("\u0000");
|
|
1310
1825
|
return createHash("sha256").update(joined).digest("hex");
|
|
1311
1826
|
}
|
|
1312
1827
|
/** Assemble the checkpoint DTO from the current PRE-resolution graph. Isolated
|
|
@@ -1501,9 +2016,14 @@ function loadReviewGraphCheckpoint(cwd) {
|
|
|
1501
2016
|
totalEdges,
|
|
1502
2017
|
persistedNodes: totalNodes,
|
|
1503
2018
|
persistedEdges: totalEdges,
|
|
2019
|
+
totalFiles: data.targetFileCount,
|
|
2020
|
+
persistedFiles: 0,
|
|
1504
2021
|
},
|
|
1505
2022
|
};
|
|
1506
2023
|
rebuildIndexes(graph);
|
|
2024
|
+
if (graph.persistCoverage) {
|
|
2025
|
+
graph.persistCoverage.persistedFiles = countRetainedSourceFiles(graph, data.processedFiles.map(([filePath]) => filePath));
|
|
2026
|
+
}
|
|
1507
2027
|
return {
|
|
1508
2028
|
graph,
|
|
1509
2029
|
processedHashes: new Map(data.processedFiles),
|
|
@@ -1670,6 +2190,8 @@ export async function terminateReviewGraphPersistWorkerForTests() {
|
|
|
1670
2190
|
/** Test-only: restore worker creation after a deliberate death. */
|
|
1671
2191
|
export function resetReviewGraphPersistWorkerForTests() {
|
|
1672
2192
|
_workerDisabled = false;
|
|
2193
|
+
_persistWorker = undefined;
|
|
2194
|
+
_persistWorkerUnavailableReason = undefined;
|
|
1673
2195
|
_lastWorkerFallbackReasonForTests = undefined;
|
|
1674
2196
|
_checkpointWorkerRequests.clear();
|
|
1675
2197
|
_checkpointGenerations.clear();
|
|
@@ -1701,16 +2223,39 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
|
|
|
1701
2223
|
// the flush. Removed requests' late results hit the no-request branch in
|
|
1702
2224
|
// handleWorkerResult, which deletes their stage files.
|
|
1703
2225
|
let pending = _pendingPersist.get(key);
|
|
2226
|
+
const removedWorkerRequests = [];
|
|
2227
|
+
let selectedWorkerRequest;
|
|
1704
2228
|
for (const [id, request] of [..._workerRequests]) {
|
|
1705
2229
|
if (request.key !== key)
|
|
1706
2230
|
continue;
|
|
1707
2231
|
_workerRequests.delete(id);
|
|
2232
|
+
removedWorkerRequests.push(request.pending);
|
|
1708
2233
|
if (!pending || request.pending.generation > pending.generation) {
|
|
1709
2234
|
pending = request.pending;
|
|
2235
|
+
selectedWorkerRequest = request.pending;
|
|
1710
2236
|
}
|
|
1711
2237
|
}
|
|
2238
|
+
for (const removed of removedWorkerRequests) {
|
|
2239
|
+
if (!pending || removed.generation === pending.generation)
|
|
2240
|
+
continue;
|
|
2241
|
+
logReviewGraph({
|
|
2242
|
+
cwd: key,
|
|
2243
|
+
phase: "persist_skipped",
|
|
2244
|
+
reason: "superseded",
|
|
2245
|
+
observability: persistObservability(removed, {
|
|
2246
|
+
status: "superseded",
|
|
2247
|
+
supersededByGeneration: pending.generation,
|
|
2248
|
+
reason: "forced_flush_newest_generation",
|
|
2249
|
+
workerStarted: true,
|
|
2250
|
+
workerCompleted: false,
|
|
2251
|
+
}),
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
1712
2254
|
if (!pending) {
|
|
1713
|
-
return {
|
|
2255
|
+
return {
|
|
2256
|
+
ok: false,
|
|
2257
|
+
reason: "no graph snapshot was queued for persistence",
|
|
2258
|
+
};
|
|
1714
2259
|
}
|
|
1715
2260
|
// Invalidate every staged worker completion before doing the forced write.
|
|
1716
2261
|
// Workers never promote their own stage file, so a late result can only be
|
|
@@ -1759,6 +2304,12 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
|
|
|
1759
2304
|
serializeMs,
|
|
1760
2305
|
writeMs,
|
|
1761
2306
|
offloaded: false,
|
|
2307
|
+
observability: persistObservability(pending, {
|
|
2308
|
+
status: "succeeded",
|
|
2309
|
+
reason: source === "exit_hook" ? "exit_flush" : "forced_flush",
|
|
2310
|
+
workerStarted: selectedWorkerRequest !== undefined,
|
|
2311
|
+
workerCompleted: false,
|
|
2312
|
+
}),
|
|
1762
2313
|
});
|
|
1763
2314
|
return {
|
|
1764
2315
|
ok: true,
|
|
@@ -1770,7 +2321,13 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
|
|
|
1770
2321
|
}
|
|
1771
2322
|
catch (err) {
|
|
1772
2323
|
const reason = err instanceof Error ? err.message : String(err);
|
|
1773
|
-
recordPersistFailure(cwd, source === "exit_hook" ? "exit_flush_failed" : "forced_flush_failed", reason
|
|
2324
|
+
recordPersistFailure(cwd, source === "exit_hook" ? "exit_flush_failed" : "forced_flush_failed", reason, pending, selectedWorkerRequest
|
|
2325
|
+
? {
|
|
2326
|
+
started: true,
|
|
2327
|
+
completed: false,
|
|
2328
|
+
fallbackReason: "forced_flush_write_failed",
|
|
2329
|
+
}
|
|
2330
|
+
: undefined);
|
|
1774
2331
|
return { ok: false, reason };
|
|
1775
2332
|
}
|
|
1776
2333
|
}
|
|
@@ -1814,7 +2371,11 @@ function localImportToFile(cwd, filePath, source, ignoredIds) {
|
|
|
1814
2371
|
}
|
|
1815
2372
|
const root = path.resolve(cwd);
|
|
1816
2373
|
for (const candidate of jsTsCandidatePaths(filePath, source)) {
|
|
1817
|
-
|
|
2374
|
+
const relative = path.relative(root, candidate);
|
|
2375
|
+
if ((relative.startsWith("..") &&
|
|
2376
|
+
(relative.length === 2 || relative.startsWith(`..${path.sep}`))) ||
|
|
2377
|
+
path.isAbsolute(relative) ||
|
|
2378
|
+
!fs.existsSync(candidate))
|
|
1818
2379
|
continue;
|
|
1819
2380
|
const normalized = normalizeMapKey(candidate);
|
|
1820
2381
|
if (ignoredIds?.has(normalized))
|
|
@@ -1862,7 +2423,12 @@ async function ensureReviewGraphFacts(filePath, cwd, facts, contentOverride) {
|
|
|
1862
2423
|
// pi-lens-ignore: missing-error-propagation
|
|
1863
2424
|
}
|
|
1864
2425
|
catch (err) {
|
|
1865
|
-
|
|
2426
|
+
logReviewGraph({
|
|
2427
|
+
cwd,
|
|
2428
|
+
phase: "build_skipped",
|
|
2429
|
+
reason: "structural_facts_disabled",
|
|
2430
|
+
error: err?.message ?? String(err),
|
|
2431
|
+
});
|
|
1866
2432
|
}
|
|
1867
2433
|
}
|
|
1868
2434
|
function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
@@ -1870,6 +2436,14 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
|
1870
2436
|
const hintPath = toProjectRelativePath(normalized, cwd);
|
|
1871
2437
|
const content = facts.getFileFact(normalized, "file.content") ?? "";
|
|
1872
2438
|
const fileNodeId = `file:${normalized}`;
|
|
2439
|
+
// The function-facts provider uses the shared tree-sitter integration for
|
|
2440
|
+
// both TypeScript and JavaScript-family grammars. Do not suppress JS call
|
|
2441
|
+
// evidence merely because import resolution may canonicalize a compiled twin
|
|
2442
|
+
// onto its source; the graph must describe every supported source file, and
|
|
2443
|
+
// lens-map performs the separate presentation-level twin merge.
|
|
2444
|
+
const warmCallCoverage = facts.getFileFact(normalized, "file.functionFactsCoverage") ??
|
|
2445
|
+
"unavailable";
|
|
2446
|
+
const importCoverage = facts.getFileFact(normalized, "file.importFactsCoverage") ?? "unavailable";
|
|
1873
2447
|
addNode(graph, {
|
|
1874
2448
|
id: fileNodeId,
|
|
1875
2449
|
kind: "file",
|
|
@@ -1877,6 +2451,12 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
|
1877
2451
|
filePath: normalized,
|
|
1878
2452
|
metadata: {
|
|
1879
2453
|
lineCount: content.split("\n").length,
|
|
2454
|
+
extractionCoverage: {
|
|
2455
|
+
definitions: warmCallCoverage,
|
|
2456
|
+
references: warmCallCoverage,
|
|
2457
|
+
imports: importCoverage,
|
|
2458
|
+
calls: warmCallCoverage,
|
|
2459
|
+
},
|
|
1880
2460
|
...featureHintMetadata(hintPath),
|
|
1881
2461
|
},
|
|
1882
2462
|
});
|
|
@@ -1953,6 +2533,7 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
|
1953
2533
|
exported: new RegExp(String.raw `export\s+(?:async\s+)?(?:function|const|let|var)\s+${escapeRegExp(fn.name)}\b`).test(content),
|
|
1954
2534
|
metadata: {
|
|
1955
2535
|
line: fn.line,
|
|
2536
|
+
endLine: fn.endLine,
|
|
1956
2537
|
column: fn.column,
|
|
1957
2538
|
cyclomaticComplexity: fn.cyclomaticComplexity,
|
|
1958
2539
|
maxNestingDepth: fn.maxNestingDepth,
|
|
@@ -2007,7 +2588,10 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
|
2007
2588
|
language: "jsts",
|
|
2008
2589
|
symbolName: site.method,
|
|
2009
2590
|
qualifiedName: `${receiverClass}.${site.method}`,
|
|
2010
|
-
metadata: {
|
|
2591
|
+
metadata: {
|
|
2592
|
+
unresolvedName: callText,
|
|
2593
|
+
ambiguousCandidates: candidates.length,
|
|
2594
|
+
},
|
|
2011
2595
|
});
|
|
2012
2596
|
}
|
|
2013
2597
|
addEdge(graph, {
|
|
@@ -2081,26 +2665,42 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
|
|
|
2081
2665
|
}
|
|
2082
2666
|
function mapKindToTreeSitterLanguage(kind, filePath) {
|
|
2083
2667
|
switch (kind) {
|
|
2084
|
-
case "python":
|
|
2085
|
-
|
|
2086
|
-
case "
|
|
2087
|
-
|
|
2668
|
+
case "python":
|
|
2669
|
+
return "python";
|
|
2670
|
+
case "go":
|
|
2671
|
+
return "go";
|
|
2672
|
+
case "rust":
|
|
2673
|
+
return "rust";
|
|
2674
|
+
case "ruby":
|
|
2675
|
+
return "ruby";
|
|
2088
2676
|
case "cxx": {
|
|
2089
2677
|
const ext = filePath ? path.extname(filePath).toLowerCase() : "";
|
|
2090
2678
|
return ext === ".c" || ext === ".h" ? "c" : "cpp";
|
|
2091
2679
|
}
|
|
2092
|
-
case "java":
|
|
2093
|
-
|
|
2094
|
-
case "
|
|
2095
|
-
|
|
2096
|
-
case "
|
|
2097
|
-
|
|
2098
|
-
case "
|
|
2099
|
-
|
|
2100
|
-
case "
|
|
2101
|
-
|
|
2102
|
-
case "
|
|
2103
|
-
|
|
2680
|
+
case "java":
|
|
2681
|
+
return "java";
|
|
2682
|
+
case "kotlin":
|
|
2683
|
+
return "kotlin";
|
|
2684
|
+
case "dart":
|
|
2685
|
+
return "dart";
|
|
2686
|
+
case "elixir":
|
|
2687
|
+
return "elixir";
|
|
2688
|
+
case "csharp":
|
|
2689
|
+
return "csharp";
|
|
2690
|
+
case "php":
|
|
2691
|
+
return "php";
|
|
2692
|
+
case "swift":
|
|
2693
|
+
return "swift";
|
|
2694
|
+
case "lua":
|
|
2695
|
+
return "lua";
|
|
2696
|
+
case "ocaml":
|
|
2697
|
+
return "ocaml";
|
|
2698
|
+
case "zig":
|
|
2699
|
+
return "zig";
|
|
2700
|
+
case "shell":
|
|
2701
|
+
return "bash";
|
|
2702
|
+
default:
|
|
2703
|
+
return undefined;
|
|
2104
2704
|
}
|
|
2105
2705
|
}
|
|
2106
2706
|
async function getExtractor(languageId) {
|
|
@@ -2123,7 +2723,16 @@ async function getExtractor(languageId) {
|
|
|
2123
2723
|
return extractor;
|
|
2124
2724
|
}
|
|
2125
2725
|
async function extractTreeSitterSymbols(filePath, languageId, contentOverride) {
|
|
2126
|
-
const empty = {
|
|
2726
|
+
const empty = {
|
|
2727
|
+
symbols: [],
|
|
2728
|
+
refs: [],
|
|
2729
|
+
imports: [],
|
|
2730
|
+
coverage: {
|
|
2731
|
+
definitions: "unavailable",
|
|
2732
|
+
references: "unavailable",
|
|
2733
|
+
imports: "unavailable",
|
|
2734
|
+
},
|
|
2735
|
+
};
|
|
2127
2736
|
if (contentOverride === null)
|
|
2128
2737
|
return empty;
|
|
2129
2738
|
const treeSitterClient = getSharedTreeSitterClient();
|
|
@@ -2158,6 +2767,19 @@ export async function captureReviewGraphStructuralIr(filePath, cwd, content, fac
|
|
|
2158
2767
|
const parsed = await withTreeSitterRoot(filePath, content, () => true);
|
|
2159
2768
|
if (!parsed.parsed)
|
|
2160
2769
|
return { complete: false };
|
|
2770
|
+
const functionCoverage = facts.getFileFact(filePath, "file.functionFactsCoverage") ??
|
|
2771
|
+
"unavailable";
|
|
2772
|
+
const importCoverage = facts.getFileFact(filePath, "file.importFactsCoverage") ??
|
|
2773
|
+
"unavailable";
|
|
2774
|
+
const coverage = {
|
|
2775
|
+
definitions: functionCoverage,
|
|
2776
|
+
references: functionCoverage,
|
|
2777
|
+
imports: importCoverage,
|
|
2778
|
+
calls: functionCoverage,
|
|
2779
|
+
};
|
|
2780
|
+
if (Object.values(coverage).some((status) => status !== "complete")) {
|
|
2781
|
+
return { complete: false };
|
|
2782
|
+
}
|
|
2161
2783
|
return {
|
|
2162
2784
|
complete: true,
|
|
2163
2785
|
structural: {
|
|
@@ -2165,12 +2787,15 @@ export async function captureReviewGraphStructuralIr(filePath, cwd, content, fac
|
|
|
2165
2787
|
imports: facts.getFileFact(filePath, "file.imports") ?? [],
|
|
2166
2788
|
reexports: facts.getFileFact(filePath, "file.reexports") ?? [],
|
|
2167
2789
|
functionSummaries: facts.getFileFact(filePath, "file.functionSummaries") ?? [],
|
|
2790
|
+
coverage,
|
|
2168
2791
|
},
|
|
2169
2792
|
};
|
|
2170
2793
|
}
|
|
2171
2794
|
const languageId = mapKindToTreeSitterLanguage(kind, filePath);
|
|
2795
|
+
// A graph-relevant kind without a tree-sitter mapping is unsupported, not
|
|
2796
|
+
// an empty successful extraction. Do not publish a complete IR for it.
|
|
2172
2797
|
if (!languageId)
|
|
2173
|
-
return { complete:
|
|
2798
|
+
return { complete: false };
|
|
2174
2799
|
const client = getSharedTreeSitterClient();
|
|
2175
2800
|
if (!client || !(await client.init()))
|
|
2176
2801
|
return { complete: false };
|
|
@@ -2232,7 +2857,10 @@ function addTreeSitterFile(graph, cwd, filePath, languageId, extracted, ignoredI
|
|
|
2232
2857
|
kind: "file",
|
|
2233
2858
|
language: languageId,
|
|
2234
2859
|
filePath: normalized,
|
|
2235
|
-
metadata:
|
|
2860
|
+
metadata: {
|
|
2861
|
+
...featureHintMetadata(hintPath),
|
|
2862
|
+
extractionCoverage: extracted.coverage,
|
|
2863
|
+
},
|
|
2236
2864
|
});
|
|
2237
2865
|
const dedupedSymbols = dedupeSamePositionSymbols(extracted.symbols);
|
|
2238
2866
|
// refs #655 phase 2: qualified (owner-chain) display name, computed via the
|
|
@@ -2261,6 +2889,7 @@ function addTreeSitterFile(graph, cwd, filePath, languageId, extracted, ignoredI
|
|
|
2261
2889
|
exported: symbol.isExported,
|
|
2262
2890
|
metadata: {
|
|
2263
2891
|
line: symbol.line,
|
|
2892
|
+
endLine: symbol.endLine,
|
|
2264
2893
|
column: symbol.column,
|
|
2265
2894
|
signature: symbol.signature,
|
|
2266
2895
|
...featureHintMetadata(`${symbol.name} ${hintPath}`),
|
|
@@ -2270,21 +2899,26 @@ function addTreeSitterFile(graph, cwd, filePath, languageId, extracted, ignoredI
|
|
|
2270
2899
|
addEdge(graph, { from: fileNodeId, to: symbolId, kind: "defines" });
|
|
2271
2900
|
}
|
|
2272
2901
|
for (const ref of extracted.refs) {
|
|
2273
|
-
const
|
|
2902
|
+
const refName = ref.symbolName ?? ref.symbolId;
|
|
2903
|
+
const targetId = `symbol-name:${refName}`;
|
|
2274
2904
|
if (!graph.nodes.has(targetId)) {
|
|
2275
2905
|
addNode(graph, {
|
|
2276
2906
|
id: targetId,
|
|
2277
2907
|
kind: "symbol",
|
|
2278
2908
|
language: languageId,
|
|
2279
|
-
symbolName:
|
|
2280
|
-
metadata: { unresolvedName:
|
|
2909
|
+
symbolName: refName,
|
|
2910
|
+
metadata: { unresolvedName: refName },
|
|
2281
2911
|
});
|
|
2282
2912
|
}
|
|
2283
2913
|
addEdge(graph, {
|
|
2284
2914
|
from: fileNodeId,
|
|
2285
2915
|
to: targetId,
|
|
2286
2916
|
kind: "references",
|
|
2287
|
-
metadata: {
|
|
2917
|
+
metadata: {
|
|
2918
|
+
line: ref.line,
|
|
2919
|
+
column: ref.column,
|
|
2920
|
+
referenceKind: ref.referenceKind ?? "unknown",
|
|
2921
|
+
},
|
|
2288
2922
|
// Always starts bare-name-only (the extractor has no scope/type info);
|
|
2289
2923
|
// resolveDeferredSymbolEdges may upgrade this to "exact" below.
|
|
2290
2924
|
resolution: "name-only",
|
|
@@ -2367,12 +3001,8 @@ export function addLspFallbackSymbols(graph, filePath, languageId, symbols) {
|
|
|
2367
3001
|
// Shared with the read-path enrichment (#951 Sonar dedup): flat
|
|
2368
3002
|
// results qualify through the full containerName chain, not just
|
|
2369
3003
|
// the immediate owner.
|
|
2370
|
-
const owners = ancestry.length > 0
|
|
2371
|
-
|
|
2372
|
-
: containerNameChain(symbol, symbols);
|
|
2373
|
-
const qualifiedName = owners.length > 0
|
|
2374
|
-
? [...owners, symbol.name].join(".")
|
|
2375
|
-
: undefined;
|
|
3004
|
+
const owners = ancestry.length > 0 ? ancestry : containerNameChain(symbol, symbols);
|
|
3005
|
+
const qualifiedName = owners.length > 0 ? [...owners, symbol.name].join(".") : undefined;
|
|
2376
3006
|
addNode(graph, {
|
|
2377
3007
|
id: symbolId,
|
|
2378
3008
|
kind: "symbol",
|
|
@@ -2561,6 +3191,8 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
|
|
|
2561
3191
|
facts.setFileFact(file, "file.imports", sharedIr.imports);
|
|
2562
3192
|
facts.setFileFact(file, "file.reexports", sharedIr.reexports);
|
|
2563
3193
|
facts.setFileFact(file, "file.functionSummaries", sharedIr.functionSummaries);
|
|
3194
|
+
facts.setFileFact(file, "file.functionFactsCoverage", sharedIr.coverage.calls);
|
|
3195
|
+
facts.setFileFact(file, "file.importFactsCoverage", sharedIr.coverage.imports);
|
|
2564
3196
|
}
|
|
2565
3197
|
else {
|
|
2566
3198
|
await ensureReviewGraphFacts(file, cwd, facts, contentOverride);
|
|
@@ -2578,8 +3210,7 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
|
|
|
2578
3210
|
const languageId = mapKindToTreeSitterLanguage(kind, file);
|
|
2579
3211
|
if (!languageId)
|
|
2580
3212
|
return;
|
|
2581
|
-
const irExtracted = sharedIr?.kind === "tree-sitter" &&
|
|
2582
|
-
sharedIr.languageId === languageId
|
|
3213
|
+
const irExtracted = sharedIr?.kind === "tree-sitter" && sharedIr.languageId === languageId
|
|
2583
3214
|
? sharedIr.extracted
|
|
2584
3215
|
: undefined;
|
|
2585
3216
|
const extracted = irExtracted ??
|
|
@@ -2772,16 +3403,20 @@ async function tryIncrementalFromCache(cached, ctx) {
|
|
|
2772
3403
|
for (const file of ctx.normalizedChanged) {
|
|
2773
3404
|
upsertChangedSymbols(graph, ctx.facts, file);
|
|
2774
3405
|
}
|
|
2775
|
-
|
|
3406
|
+
setWorkspaceGraph(ctx.normalizedCwd, {
|
|
2776
3407
|
signature: ctx.signature,
|
|
2777
3408
|
fileSignatures: new Map(ctx.fileSignatures),
|
|
2778
3409
|
fileHashes: hashes,
|
|
2779
3410
|
graph: cloneGraph(cached.graph),
|
|
2780
3411
|
buildGeneration: generation,
|
|
2781
3412
|
...verifiedCacheFields(ctx.seqAtBuildStart),
|
|
2782
|
-
});
|
|
3413
|
+
}, ctx.cacheEpoch);
|
|
2783
3414
|
// #260: pure drift leaves the graph unchanged — don't rewrite the disk blob.
|
|
2784
|
-
|
|
3415
|
+
setGraphBuildInfo(graph, {
|
|
3416
|
+
reused: true,
|
|
3417
|
+
mode: "cached",
|
|
3418
|
+
graphChanged: false,
|
|
3419
|
+
});
|
|
2785
3420
|
graph.buildGeneration = generation;
|
|
2786
3421
|
ctx.facts.setSessionFact("session.reviewGraph", graph);
|
|
2787
3422
|
return graph;
|
|
@@ -2795,19 +3430,30 @@ async function tryIncrementalFromCache(cached, ctx) {
|
|
|
2795
3430
|
const priorGeneration = cached.graph.buildGeneration;
|
|
2796
3431
|
const graph = cloneGraph(cached.graph);
|
|
2797
3432
|
const importChanges = await updateGraphFiles(graph, ctx.cwd, filesToUpdate, ctx.facts, ctx.ignoredIds);
|
|
3433
|
+
refreshGraphBuiltAt(graph);
|
|
2798
3434
|
// #459: real re-extract ⇒ new generation.
|
|
2799
3435
|
const generation = ++_graphGenerationCounter;
|
|
2800
3436
|
graph.buildGeneration = generation;
|
|
2801
|
-
|
|
3437
|
+
setWorkspaceGraph(ctx.normalizedCwd, {
|
|
2802
3438
|
signature: ctx.signature,
|
|
2803
3439
|
fileSignatures: new Map(ctx.fileSignatures),
|
|
2804
3440
|
fileHashes: hashes,
|
|
2805
3441
|
graph,
|
|
2806
3442
|
buildGeneration: generation,
|
|
2807
3443
|
...verifiedCacheFields(ctx.seqAtBuildStart),
|
|
3444
|
+
}, ctx.cacheEpoch);
|
|
3445
|
+
const persistReason = persistGraph(ctx.cwd, ctx.signature, ctx.fileSignatures, hashes, graph, {
|
|
3446
|
+
buildId: ctx.buildId,
|
|
3447
|
+
projectSeq: ctx.seqAtBuildStart,
|
|
3448
|
+
seqHint: ctx.seqHint,
|
|
3449
|
+
mode: ctx.mode,
|
|
3450
|
+
});
|
|
3451
|
+
setGraphBuildInfo(graph, {
|
|
3452
|
+
reused: true,
|
|
3453
|
+
mode: "incremental",
|
|
3454
|
+
...(persistReason ? { persistReason } : {}),
|
|
3455
|
+
graphChanged: true,
|
|
2808
3456
|
});
|
|
2809
|
-
persistGraph(ctx.cwd, ctx.signature, ctx.fileSignatures, hashes, graph);
|
|
2810
|
-
_lastGraphBuildInfo = { reused: true, mode: "incremental", graphChanged: true };
|
|
2811
3457
|
_graphImportChanges.set(graph, {
|
|
2812
3458
|
fromGeneration: priorGeneration,
|
|
2813
3459
|
changes: importChanges,
|
|
@@ -2827,9 +3473,14 @@ function hasGraphKindExtension(file) {
|
|
|
2827
3473
|
* `_lastGraphBuildInfo` (the caller's full sweep sets the mode and stamps the
|
|
2828
3474
|
* fallback reason). Correctness bar is HIGH: any doubt ⇒ fall back.
|
|
2829
3475
|
*/
|
|
2830
|
-
async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds) {
|
|
3476
|
+
async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds, cacheEpoch) {
|
|
2831
3477
|
const cached = _workspaceGraphCache.get(normalizedCwd);
|
|
2832
|
-
// Condition 2: need an in-process entry that recorded a build seq.
|
|
3478
|
+
// Condition 2: need an in-process complete entry that recorded a build seq.
|
|
3479
|
+
// A capped or entry-budget-truncated graph is read-only orientation data, not
|
|
3480
|
+
// a safe incremental base; force the next build through a complete walk.
|
|
3481
|
+
if (cached?.graph.persistCoverage?.partial) {
|
|
3482
|
+
return { fallback: "partial-base" };
|
|
3483
|
+
}
|
|
2833
3484
|
if (!cached || cached.builtAtProjectSeq === undefined) {
|
|
2834
3485
|
return { fallback: "no-seq" };
|
|
2835
3486
|
}
|
|
@@ -2840,7 +3491,8 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2840
3491
|
? Number.POSITIVE_INFINITY
|
|
2841
3492
|
: now - cached.lastFullVerifyMs;
|
|
2842
3493
|
const sinceVerify = cached.fastPathSinceVerify ?? 0;
|
|
2843
|
-
if (ageMs > SEQ_FASTPATH_REVERIFY_MS ||
|
|
3494
|
+
if (ageMs > SEQ_FASTPATH_REVERIFY_MS ||
|
|
3495
|
+
sinceVerify >= SEQ_FASTPATH_REVERIFY_EVERY) {
|
|
2844
3496
|
return { fallback: "verify-due" };
|
|
2845
3497
|
}
|
|
2846
3498
|
// Condition 3: bounded change set. changed ∪ changedFiles(param), normalized.
|
|
@@ -2858,7 +3510,7 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2858
3510
|
// (a genuine new file — updateGraphFiles' remove-then-add handles the add). A
|
|
2859
3511
|
// changed file that no longer exists on disk is a DELETION: incremental has no
|
|
2860
3512
|
// node-removal here, so fall back to the sweep (simple + correct).
|
|
2861
|
-
const
|
|
3513
|
+
const candidateFiles = [];
|
|
2862
3514
|
for (const file of changed) {
|
|
2863
3515
|
const known = cached.fileSignatures.has(file);
|
|
2864
3516
|
let existsOnDisk = false;
|
|
@@ -2883,9 +3535,13 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2883
3535
|
if (!hasGraphKindExtension(file))
|
|
2884
3536
|
continue;
|
|
2885
3537
|
}
|
|
2886
|
-
|
|
3538
|
+
candidateFiles.push(file);
|
|
2887
3539
|
}
|
|
2888
|
-
|
|
3540
|
+
// A pi-observed write can advance projectSeq without changing bytes (format
|
|
3541
|
+
// no-op, save, or an idempotent edit). Confirm content before re-extracting so
|
|
3542
|
+
// the seq fast path does not refresh builtAt or claim graphChanged for drift.
|
|
3543
|
+
const { trulyChanged, hashes } = await confirmContentChanged(candidateFiles, cached.fileHashes);
|
|
3544
|
+
if (trulyChanged.length === 0) {
|
|
2889
3545
|
// Nothing graph-relevant actually changed. Reuse the cached graph as-is,
|
|
2890
3546
|
// refresh changed-symbol annotations, bump the fast-path counter.
|
|
2891
3547
|
const graph = cloneGraph(cached.graph);
|
|
@@ -2896,18 +3552,30 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2896
3552
|
}
|
|
2897
3553
|
// Stamp the seq captured at BUILD START — a bump that raced in during this
|
|
2898
3554
|
// build has seq > stamp and is re-diffed next build, never missed.
|
|
3555
|
+
const nextSignatures = new Map(cached.fileSignatures);
|
|
3556
|
+
for (const file of candidateFiles) {
|
|
3557
|
+
nextSignatures.set(file, sourceSignatureEntry(file));
|
|
3558
|
+
}
|
|
3559
|
+
cached.signature = sourceSignatureFromMap(nextSignatures);
|
|
3560
|
+
cached.fileSignatures = nextSignatures;
|
|
3561
|
+
cached.fileHashes = hashes;
|
|
2899
3562
|
cached.builtAtProjectSeq = seqAtBuildStart;
|
|
2900
3563
|
cached.fastPathSinceVerify = sinceVerify + 1;
|
|
2901
3564
|
// #459: nothing graph-relevant changed — this is a genuine no-op reuse.
|
|
2902
3565
|
const generation = (cached.buildGeneration ??= ++_graphGenerationCounter);
|
|
2903
|
-
|
|
3566
|
+
setGraphBuildInfo(graph, {
|
|
3567
|
+
reused: true,
|
|
3568
|
+
mode: "seq-fastpath",
|
|
3569
|
+
graphChanged: false,
|
|
3570
|
+
});
|
|
2904
3571
|
graph.buildGeneration = generation;
|
|
2905
3572
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
2906
3573
|
return { graph };
|
|
2907
3574
|
}
|
|
2908
|
-
// Incremental re-extract over exactly the changed files. Reuses the
|
|
2909
|
-
// machinery as the signature-diff incremental path (updateGraphFiles), so
|
|
3575
|
+
// Incremental re-extract over exactly the content-changed files. Reuses the
|
|
3576
|
+
// SAME machinery as the signature-diff incremental path (updateGraphFiles), so
|
|
2910
3577
|
// there's no second incremental implementation.
|
|
3578
|
+
const filesToUpdate = trulyChanged;
|
|
2911
3579
|
const priorGeneration = cached.graph.buildGeneration;
|
|
2912
3580
|
const graph = cloneGraph(cached.graph);
|
|
2913
3581
|
let importChanges;
|
|
@@ -2917,23 +3585,22 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2917
3585
|
catch {
|
|
2918
3586
|
return { fallback: "stat-error" };
|
|
2919
3587
|
}
|
|
3588
|
+
refreshGraphBuiltAt(graph);
|
|
2920
3589
|
// Update fileSignatures/fileHashes for ONLY the touched files (stat/hash just
|
|
2921
3590
|
// those — the whole point of the fast path). Recompute the aggregate signature
|
|
2922
3591
|
// the same way the incremental branch does (sourceSignatureFromMap).
|
|
2923
3592
|
const nextSignatures = new Map(cached.fileSignatures);
|
|
2924
|
-
|
|
2925
|
-
for (const file of filesToUpdate) {
|
|
3593
|
+
for (const file of candidateFiles) {
|
|
2926
3594
|
nextSignatures.set(file, sourceSignatureEntry(file));
|
|
2927
|
-
nextHashes.set(file, contentHashEntry(file));
|
|
2928
3595
|
}
|
|
2929
3596
|
const nextSignature = sourceSignatureFromMap(nextSignatures);
|
|
2930
3597
|
// #459: real re-extract ⇒ new generation.
|
|
2931
3598
|
const generation = ++_graphGenerationCounter;
|
|
2932
3599
|
graph.buildGeneration = generation;
|
|
2933
|
-
|
|
3600
|
+
setWorkspaceGraph(normalizedCwd, {
|
|
2934
3601
|
signature: nextSignature,
|
|
2935
3602
|
fileSignatures: nextSignatures,
|
|
2936
|
-
fileHashes:
|
|
3603
|
+
fileHashes: hashes,
|
|
2937
3604
|
graph,
|
|
2938
3605
|
buildGeneration: generation,
|
|
2939
3606
|
// Build-start seq, not stamp-time: see verifiedCacheFields — a bump that
|
|
@@ -2941,11 +3608,21 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2941
3608
|
builtAtProjectSeq: seqAtBuildStart,
|
|
2942
3609
|
lastFullVerifyMs: cached.lastFullVerifyMs,
|
|
2943
3610
|
fastPathSinceVerify: sinceVerify + 1,
|
|
3611
|
+
}, cacheEpoch);
|
|
3612
|
+
const persistReason = persistGraph(cwd, nextSignature, nextSignatures, hashes, graph, {
|
|
3613
|
+
buildId,
|
|
3614
|
+
projectSeq: seqAtBuildStart,
|
|
3615
|
+
seqHint: true,
|
|
3616
|
+
mode: "seq-fastpath",
|
|
2944
3617
|
});
|
|
2945
|
-
persistGraph(cwd, nextSignature, nextSignatures, nextHashes, graph);
|
|
2946
3618
|
// #459: filesToUpdate was non-empty — this fastpath re-extracted real files,
|
|
2947
3619
|
// so (unlike the no-op branch above) the graph object did change.
|
|
2948
|
-
|
|
3620
|
+
setGraphBuildInfo(graph, {
|
|
3621
|
+
reused: true,
|
|
3622
|
+
mode: "seq-fastpath",
|
|
3623
|
+
...(persistReason ? { persistReason } : {}),
|
|
3624
|
+
graphChanged: true,
|
|
3625
|
+
});
|
|
2949
3626
|
_graphImportChanges.set(graph, {
|
|
2950
3627
|
fromGeneration: priorGeneration,
|
|
2951
3628
|
changes: importChanges,
|
|
@@ -2953,8 +3630,14 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
|
|
|
2953
3630
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
2954
3631
|
return { graph };
|
|
2955
3632
|
}
|
|
2956
|
-
async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
3633
|
+
async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
|
|
2957
3634
|
const normalizedCwd = normalizeMapKey(cwd);
|
|
3635
|
+
const cacheEpoch = workspaceCacheEpoch(normalizedCwd);
|
|
3636
|
+
// `await undefined` still yields a microtask, which reorders overlapping
|
|
3637
|
+
// builds in production where no test gate is installed — only await a gate
|
|
3638
|
+
// that exists.
|
|
3639
|
+
if (_reviewGraphBuildGateForTests)
|
|
3640
|
+
await _reviewGraphBuildGateForTests();
|
|
2958
3641
|
const normalizedChanged = changedFiles.map((file) => normalizeMapKey(file));
|
|
2959
3642
|
const normalizedChangedSet = new Set(normalizedChanged);
|
|
2960
3643
|
logCwdWorktreeMismatchOnce(cwd);
|
|
@@ -2983,14 +3666,14 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
2983
3666
|
for (const file of normalizedChanged) {
|
|
2984
3667
|
upsertChangedSymbols(graph, facts, file);
|
|
2985
3668
|
}
|
|
2986
|
-
|
|
3669
|
+
setGraphBuildInfo(graph, {
|
|
2987
3670
|
reused: false,
|
|
2988
3671
|
mode: "skipped",
|
|
2989
3672
|
skipReason: "unsafe_root",
|
|
2990
3673
|
// #459: never persisted/reused, same as the too_many_files skip below —
|
|
2991
3674
|
// treat as changed so dependents never trust stale derived state.
|
|
2992
3675
|
graphChanged: true,
|
|
2993
|
-
};
|
|
3676
|
+
});
|
|
2994
3677
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
2995
3678
|
return graph;
|
|
2996
3679
|
}
|
|
@@ -3010,14 +3693,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3010
3693
|
// cascade.log can watch the fast-path hit/miss rate.
|
|
3011
3694
|
let seqFastpathFallback;
|
|
3012
3695
|
if (seqHint && seqAtBuildStart !== undefined && seqFastpathEnabled()) {
|
|
3013
|
-
const fast = await trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, await ignoredIdsPromise);
|
|
3696
|
+
const fast = await trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, await ignoredIdsPromise, cacheEpoch);
|
|
3014
3697
|
if ("graph" in fast)
|
|
3015
3698
|
return fast.graph;
|
|
3016
3699
|
seqFastpathFallback = fast.fallback;
|
|
3017
3700
|
}
|
|
3018
|
-
const
|
|
3701
|
+
const sourceCollection = await getGraphSourceFiles(cwd);
|
|
3702
|
+
const filesToBuild = sourceCollection.files;
|
|
3703
|
+
const sourceFileCount = sourceCollection.sourceFileCount;
|
|
3704
|
+
const sourceFilesTruncated = sourceCollection.entryBudgetExceeded;
|
|
3019
3705
|
const ignoredIds = await ignoredIdsPromise;
|
|
3020
|
-
const maxGraphFiles =
|
|
3706
|
+
const maxGraphFiles = sourceCollection.maxFileCount;
|
|
3021
3707
|
if (filesToBuild.length > maxGraphFiles) {
|
|
3022
3708
|
const graph = createEmptyGraph();
|
|
3023
3709
|
graph.version = REVIEW_GRAPH_VERSION;
|
|
@@ -3029,7 +3715,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3029
3715
|
// any graph cached/persisted from before the repo crossed the cap, and so
|
|
3030
3716
|
// project_report can render an honest "disabled at N files" hint instead
|
|
3031
3717
|
// of "retry shortly" — see getReviewGraphSizeSkipVerdict.
|
|
3032
|
-
recordReviewGraphSizeSkip(cwd,
|
|
3718
|
+
recordReviewGraphSizeSkip(cwd, sourceFileCount, maxGraphFiles);
|
|
3033
3719
|
// #775 R3: `_lastGraphBuildInfo`/the size-skip verdict above are only
|
|
3034
3720
|
// SURFACED by callers that happen to read them (dispatch/integration.ts's
|
|
3035
3721
|
// cascade path logs a `graph_build` phase; lens-map.ts, project-report.ts,
|
|
@@ -3044,15 +3730,33 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3044
3730
|
durationMs: 0,
|
|
3045
3731
|
metadata: {
|
|
3046
3732
|
cwd,
|
|
3047
|
-
sourceFileCount
|
|
3733
|
+
sourceFileCount,
|
|
3048
3734
|
maxFileCount: maxGraphFiles,
|
|
3735
|
+
sourceFileCountLabel: `more than ${maxGraphFiles} files`,
|
|
3736
|
+
sourceFileCountTruncated: true,
|
|
3049
3737
|
},
|
|
3050
3738
|
});
|
|
3051
|
-
|
|
3739
|
+
if (isReviewGraphSizeNearMiss(sourceFileCount, maxGraphFiles)) {
|
|
3740
|
+
logLatency({
|
|
3741
|
+
type: "phase",
|
|
3742
|
+
phase: "review_graph_size_near_miss",
|
|
3743
|
+
filePath: cwd,
|
|
3744
|
+
durationMs: 0,
|
|
3745
|
+
metadata: {
|
|
3746
|
+
cwd,
|
|
3747
|
+
maxFileCount: maxGraphFiles,
|
|
3748
|
+
sourceFileCount,
|
|
3749
|
+
sourceFileCountLabel: `more than ${maxGraphFiles} files`,
|
|
3750
|
+
sourceFileCountTruncated: true,
|
|
3751
|
+
},
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
setGraphBuildInfo(graph, {
|
|
3052
3755
|
reused: false,
|
|
3053
3756
|
mode: "skipped",
|
|
3054
3757
|
skipReason: "too_many_files",
|
|
3055
|
-
sourceFileCount
|
|
3758
|
+
sourceFileCount,
|
|
3759
|
+
sourceFileCountTruncated: sourceFilesTruncated || filesToBuild.length > maxGraphFiles,
|
|
3056
3760
|
maxFileCount: maxGraphFiles,
|
|
3057
3761
|
seqFastpathFallback,
|
|
3058
3762
|
// #459: a fresh empty graph is returned every call on this path (never
|
|
@@ -3060,7 +3764,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3060
3764
|
// derived state across skip/unskip transitions. Deliberately NOT stamped
|
|
3061
3765
|
// with a buildGeneration: absent ⇒ derived caches rebuild every time.
|
|
3062
3766
|
graphChanged: true,
|
|
3063
|
-
};
|
|
3767
|
+
});
|
|
3064
3768
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
3065
3769
|
return graph;
|
|
3066
3770
|
}
|
|
@@ -3068,11 +3772,14 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3068
3772
|
// previously recorded size-skip verdict immediately (rather than waiting
|
|
3069
3773
|
// out the TTL) so a shrink or a raised cap re-enables reads the moment a
|
|
3070
3774
|
// build actually succeeds.
|
|
3071
|
-
|
|
3775
|
+
if (!sourceFilesTruncated)
|
|
3776
|
+
clearReviewGraphSizeSkip(cwd);
|
|
3072
3777
|
const fileSignatures = await sourceSignatureMapAsync(filesToBuild);
|
|
3073
3778
|
const signature = sourceSignatureFromMap(fileSignatures);
|
|
3074
3779
|
// Tier 1: in-memory cache (hot path — same process, already built this session)
|
|
3075
|
-
let memCached =
|
|
3780
|
+
let memCached = sourceFilesTruncated
|
|
3781
|
+
? undefined
|
|
3782
|
+
: _workspaceGraphCache.get(normalizedCwd);
|
|
3076
3783
|
// A partial graph (hydrated from a capped snapshot for read-only orientation
|
|
3077
3784
|
// via getCachedReviewGraph) can share this cache. It MUST NOT seed a build:
|
|
3078
3785
|
// serving it silently drops the capped-away nodes/edges, and extending then
|
|
@@ -3082,6 +3789,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3082
3789
|
if (memCached?.graph.persistCoverage?.partial)
|
|
3083
3790
|
memCached = undefined;
|
|
3084
3791
|
if (memCached?.signature === signature) {
|
|
3792
|
+
touchWorkspaceGraph(normalizedCwd);
|
|
3085
3793
|
const graph = cloneGraph(memCached.graph);
|
|
3086
3794
|
rebuildIndexes(graph);
|
|
3087
3795
|
graph.changedSymbolsByFile.clear();
|
|
@@ -3093,13 +3801,14 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3093
3801
|
// later fast path diffs from here and the periodic re-verify resets.
|
|
3094
3802
|
Object.assign(memCached, verifiedCacheFields(seqAtBuildStart));
|
|
3095
3803
|
// #459: content unchanged ⇒ carry the entry's generation forward.
|
|
3096
|
-
const generation = (memCached.buildGeneration ??=
|
|
3097
|
-
|
|
3804
|
+
const generation = (memCached.buildGeneration ??=
|
|
3805
|
+
++_graphGenerationCounter);
|
|
3806
|
+
setGraphBuildInfo(graph, {
|
|
3098
3807
|
reused: true,
|
|
3099
3808
|
mode: "cached",
|
|
3100
3809
|
seqFastpathFallback,
|
|
3101
3810
|
graphChanged: false,
|
|
3102
|
-
};
|
|
3811
|
+
});
|
|
3103
3812
|
graph.buildGeneration = generation;
|
|
3104
3813
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
3105
3814
|
return graph;
|
|
@@ -3107,6 +3816,9 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3107
3816
|
if (memCached) {
|
|
3108
3817
|
const incremental = await tryIncrementalFromCache(memCached, {
|
|
3109
3818
|
cwd,
|
|
3819
|
+
buildId,
|
|
3820
|
+
seqHint: seqHint !== undefined,
|
|
3821
|
+
mode: "incremental",
|
|
3110
3822
|
normalizedCwd,
|
|
3111
3823
|
normalizedChanged,
|
|
3112
3824
|
fileSignatures,
|
|
@@ -3114,9 +3826,10 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3114
3826
|
facts,
|
|
3115
3827
|
seqAtBuildStart,
|
|
3116
3828
|
ignoredIds,
|
|
3829
|
+
cacheEpoch,
|
|
3117
3830
|
});
|
|
3118
3831
|
if (incremental) {
|
|
3119
|
-
|
|
3832
|
+
updateGraphBuildInfo(incremental, { seqFastpathFallback });
|
|
3120
3833
|
return incremental;
|
|
3121
3834
|
}
|
|
3122
3835
|
}
|
|
@@ -3125,7 +3838,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3125
3838
|
// #202 content-hash confirm below already content-verify the load, and
|
|
3126
3839
|
// dropping on every HEAD move would force a full whole-repo rebuild after
|
|
3127
3840
|
// each plain `git commit` (HEAD moves, files unchanged).
|
|
3128
|
-
const diskCached = loadPersistedGraph(cwd);
|
|
3841
|
+
const diskCached = sourceFilesTruncated ? null : loadPersistedGraph(cwd);
|
|
3129
3842
|
if (diskCached?.signature === signature) {
|
|
3130
3843
|
const graph = cloneGraph(diskCached.graph);
|
|
3131
3844
|
rebuildIndexes(graph);
|
|
@@ -3137,20 +3850,20 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3137
3850
|
// process's derived caches don't exist here; in-process derived caches from
|
|
3138
3851
|
// before a workspace-cache clear must not match it).
|
|
3139
3852
|
const generation = ++_graphGenerationCounter;
|
|
3140
|
-
|
|
3853
|
+
setWorkspaceGraph(normalizedCwd, {
|
|
3141
3854
|
signature,
|
|
3142
3855
|
fileSignatures: new Map(fileSignatures),
|
|
3143
3856
|
fileHashes: diskCached.fileHashes,
|
|
3144
3857
|
graph: cloneGraph(diskCached.graph),
|
|
3145
3858
|
buildGeneration: generation,
|
|
3146
3859
|
...verifiedCacheFields(seqAtBuildStart),
|
|
3147
|
-
});
|
|
3148
|
-
|
|
3860
|
+
}, cacheEpoch);
|
|
3861
|
+
setGraphBuildInfo(graph, {
|
|
3149
3862
|
reused: true,
|
|
3150
3863
|
mode: "cached",
|
|
3151
3864
|
seqFastpathFallback,
|
|
3152
3865
|
graphChanged: false,
|
|
3153
|
-
};
|
|
3866
|
+
});
|
|
3154
3867
|
graph.buildGeneration = generation;
|
|
3155
3868
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
3156
3869
|
return graph;
|
|
@@ -3167,6 +3880,9 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3167
3880
|
graph: diskCached.graph,
|
|
3168
3881
|
}, {
|
|
3169
3882
|
cwd,
|
|
3883
|
+
buildId,
|
|
3884
|
+
seqHint: seqHint !== undefined,
|
|
3885
|
+
mode: "incremental",
|
|
3170
3886
|
normalizedCwd,
|
|
3171
3887
|
normalizedChanged,
|
|
3172
3888
|
fileSignatures,
|
|
@@ -3176,13 +3892,15 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3176
3892
|
ignoredIds,
|
|
3177
3893
|
});
|
|
3178
3894
|
if (incremental) {
|
|
3179
|
-
|
|
3895
|
+
updateGraphBuildInfo(incremental, { seqFastpathFallback });
|
|
3180
3896
|
return incremental;
|
|
3181
3897
|
}
|
|
3182
3898
|
}
|
|
3183
3899
|
// Tier 3: full build — resumed from a prior session's checkpoint when one is
|
|
3184
3900
|
// present and still current (#936 limit 2), else cold from an empty graph.
|
|
3185
|
-
const resumed =
|
|
3901
|
+
const resumed = sourceFilesTruncated
|
|
3902
|
+
? null
|
|
3903
|
+
: await tryResumeFromCheckpoint(cwd, filesToBuild, ignoredIds);
|
|
3186
3904
|
const graph = resumed?.graph ?? createEmptyGraph();
|
|
3187
3905
|
const filesToExtract = resumed?.remaining ?? filesToBuild;
|
|
3188
3906
|
const treeSitterClient = getSharedTreeSitterClient();
|
|
@@ -3285,28 +4003,44 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3285
4003
|
// in-progress/partial marker a resumed seed carried so the finished graph is
|
|
3286
4004
|
// never mistaken for (or persisted as) a partial one, and retire the
|
|
3287
4005
|
// checkpoint now that an authoritative snapshot supersedes it.
|
|
3288
|
-
graph.persistCoverage =
|
|
4006
|
+
graph.persistCoverage = sourceFilesTruncated
|
|
4007
|
+
? graphCoverage(graph, graphPersistMaxElements(), sourceFileCount, true, filesToBuild)
|
|
4008
|
+
: undefined;
|
|
3289
4009
|
deleteReviewGraphCheckpoint(cwd);
|
|
3290
4010
|
// #202: the full-build pass hashes the same bytes it supplies to extraction,
|
|
3291
4011
|
// so change detection does not reread every file after the graph is built.
|
|
3292
4012
|
// #459: full rebuild ⇒ new generation.
|
|
3293
4013
|
const generation = ++_graphGenerationCounter;
|
|
3294
4014
|
const graphSnapshot = cloneGraph(graph);
|
|
3295
|
-
|
|
4015
|
+
rebuildIndexes(graphSnapshot);
|
|
4016
|
+
// Keep the content generation on the persisted snapshot instance too, so
|
|
4017
|
+
// scheduled persistence logs join the same graph identity as build success.
|
|
4018
|
+
graphSnapshot.buildGeneration = generation;
|
|
4019
|
+
setWorkspaceGraph(normalizedCwd, {
|
|
3296
4020
|
signature,
|
|
3297
4021
|
fileSignatures: new Map(fileSignatures),
|
|
3298
4022
|
fileHashes,
|
|
3299
4023
|
graph: graphSnapshot,
|
|
3300
4024
|
buildGeneration: generation,
|
|
3301
4025
|
...verifiedCacheFields(seqAtBuildStart),
|
|
3302
|
-
});
|
|
3303
|
-
persistGraph(cwd, signature, fileSignatures, fileHashes, graphSnapshot
|
|
3304
|
-
|
|
4026
|
+
}, cacheEpoch);
|
|
4027
|
+
const persistReason = persistGraph(cwd, signature, fileSignatures, fileHashes, graphSnapshot, {
|
|
4028
|
+
buildId,
|
|
4029
|
+
projectSeq: seqAtBuildStart,
|
|
4030
|
+
seqHint: seqHint !== undefined,
|
|
4031
|
+
mode: "full",
|
|
4032
|
+
sourceFileCount,
|
|
4033
|
+
sourceFilesTruncated,
|
|
4034
|
+
}); // fire-and-forget
|
|
4035
|
+
setGraphBuildInfo(graph, {
|
|
3305
4036
|
reused: false,
|
|
3306
4037
|
mode: "full",
|
|
4038
|
+
sourceFileCount,
|
|
4039
|
+
sourceFileCountTruncated: sourceFilesTruncated,
|
|
3307
4040
|
seqFastpathFallback,
|
|
4041
|
+
...(persistReason ? { persistReason } : {}),
|
|
3308
4042
|
graphChanged: true,
|
|
3309
|
-
};
|
|
4043
|
+
});
|
|
3310
4044
|
graph.buildGeneration = generation;
|
|
3311
4045
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
3312
4046
|
return graph;
|
|
@@ -3317,34 +4051,67 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3317
4051
|
if (cached)
|
|
3318
4052
|
return cached;
|
|
3319
4053
|
const startedAt = Date.now();
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
const
|
|
4054
|
+
const buildId = ++_buildIdCounter;
|
|
4055
|
+
recordBuildAttempt(cwd, "running", undefined, buildId);
|
|
4056
|
+
const startedProjectSeq = seqHint?.projectSeq();
|
|
4057
|
+
logReviewGraph({
|
|
4058
|
+
cwd,
|
|
4059
|
+
phase: "build_started",
|
|
4060
|
+
observability: {
|
|
4061
|
+
graph: {
|
|
4062
|
+
buildId,
|
|
4063
|
+
...(startedProjectSeq === undefined
|
|
4064
|
+
? {}
|
|
4065
|
+
: { projectSeq: startedProjectSeq }),
|
|
4066
|
+
...(seqHint === undefined ? {} : { seqHint: true }),
|
|
4067
|
+
nodes: 0,
|
|
4068
|
+
edges: 0,
|
|
4069
|
+
},
|
|
4070
|
+
},
|
|
4071
|
+
});
|
|
4072
|
+
const promise = _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId)
|
|
3323
4073
|
.then((graph) => {
|
|
3324
|
-
const
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
? `source file cap exceeded (${sizeSkip.sourceFileCount} > ${sizeSkip.maxFileCount})`
|
|
3329
|
-
: "unsafe_root";
|
|
3330
|
-
recordBuildAttempt(cwd, "skipped", reason);
|
|
4074
|
+
const buildInfo = getGraphBuildInfoForGraph(graph);
|
|
4075
|
+
if (buildInfo.mode === "skipped") {
|
|
4076
|
+
const reason = buildInfo.skipReason ?? "skipped";
|
|
4077
|
+
recordBuildAttempt(cwd, "skipped", reason, buildId);
|
|
3331
4078
|
logReviewGraph({
|
|
3332
4079
|
cwd,
|
|
3333
4080
|
phase: "build_skipped",
|
|
3334
4081
|
reason,
|
|
3335
4082
|
durationMs: Date.now() - startedAt,
|
|
4083
|
+
observability: {
|
|
4084
|
+
graph: graphLogMetadata(graph, {
|
|
4085
|
+
buildId,
|
|
4086
|
+
projectSeq: startedProjectSeq,
|
|
4087
|
+
seqHint: seqHint !== undefined,
|
|
4088
|
+
mode: buildInfo.mode,
|
|
4089
|
+
sourceFileCount: buildInfo.sourceFileCount,
|
|
4090
|
+
sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
|
|
4091
|
+
}),
|
|
4092
|
+
},
|
|
3336
4093
|
});
|
|
3337
4094
|
}
|
|
3338
4095
|
else {
|
|
3339
|
-
const
|
|
3340
|
-
recordBuildAttempt(cwd, "succeeded",
|
|
4096
|
+
const reason = buildInfo.persistReason;
|
|
4097
|
+
recordBuildAttempt(cwd, "succeeded", reason, buildId);
|
|
3341
4098
|
logReviewGraph({
|
|
3342
4099
|
cwd,
|
|
3343
4100
|
phase: "build_succeeded",
|
|
3344
4101
|
durationMs: Date.now() - startedAt,
|
|
3345
4102
|
nodes: graph.nodes.size,
|
|
3346
4103
|
edges: graph.edges.length,
|
|
3347
|
-
...(
|
|
4104
|
+
...(reason ? { reason } : {}),
|
|
4105
|
+
observability: {
|
|
4106
|
+
graph: graphLogMetadata(graph, {
|
|
4107
|
+
buildId,
|
|
4108
|
+
projectSeq: startedProjectSeq,
|
|
4109
|
+
seqHint: seqHint !== undefined,
|
|
4110
|
+
mode: buildInfo.mode,
|
|
4111
|
+
sourceFileCount: buildInfo.sourceFileCount,
|
|
4112
|
+
sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
|
|
4113
|
+
}),
|
|
4114
|
+
},
|
|
3348
4115
|
});
|
|
3349
4116
|
}
|
|
3350
4117
|
return graph;
|
|
@@ -3352,13 +4119,21 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3352
4119
|
.catch((err) => {
|
|
3353
4120
|
_buildCache.delete(cacheKey);
|
|
3354
4121
|
const reason = err instanceof Error ? err.message : String(err);
|
|
3355
|
-
recordBuildAttempt(cwd, "failed", reason);
|
|
4122
|
+
recordBuildAttempt(cwd, "failed", reason, buildId);
|
|
3356
4123
|
logReviewGraph({
|
|
3357
4124
|
cwd,
|
|
3358
4125
|
phase: "build_failed",
|
|
3359
4126
|
reason,
|
|
3360
4127
|
durationMs: Date.now() - startedAt,
|
|
3361
4128
|
error: reason,
|
|
4129
|
+
observability: {
|
|
4130
|
+
graph: {
|
|
4131
|
+
buildId,
|
|
4132
|
+
...(seqHint === undefined ? {} : { seqHint: true }),
|
|
4133
|
+
nodes: 0,
|
|
4134
|
+
edges: 0,
|
|
4135
|
+
},
|
|
4136
|
+
},
|
|
3362
4137
|
});
|
|
3363
4138
|
throw err;
|
|
3364
4139
|
});
|
|
@@ -3366,46 +4141,172 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
|
|
|
3366
4141
|
return promise;
|
|
3367
4142
|
}
|
|
3368
4143
|
/**
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
3371
|
-
*
|
|
3372
|
-
*
|
|
4144
|
+
* Normalize graph symbol/call/reference evidence for call-graph construction.
|
|
4145
|
+
*
|
|
4146
|
+
* Graph node ids are opaque canonical identities (kind and line are part of the
|
|
4147
|
+
* id), so this adapter always reads target metadata from the target node. It
|
|
4148
|
+
* retains unresolved/type-only evidence for bounded coverage accounting, but
|
|
4149
|
+
* buildCallGraph will not turn those records into concrete edges.
|
|
3373
4150
|
*/
|
|
3374
4151
|
export function extractSymbolsAndRefsFromGraph(graph) {
|
|
3375
4152
|
const allSymbols = new Map();
|
|
3376
4153
|
const allRefs = new Map();
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
4154
|
+
const nodeSymbols = new Map();
|
|
4155
|
+
const coverage = {
|
|
4156
|
+
totalEvidence: 0,
|
|
4157
|
+
callsEvidence: 0,
|
|
4158
|
+
referencesEvidence: 0,
|
|
4159
|
+
eligibleEvidence: 0,
|
|
4160
|
+
resolvedEvidence: 0,
|
|
4161
|
+
unresolvedEvidence: 0,
|
|
4162
|
+
typeOnlyEvidence: 0,
|
|
4163
|
+
unsupportedEvidence: 0,
|
|
4164
|
+
sameFileEvidence: 0,
|
|
4165
|
+
duplicateEvidence: 0,
|
|
4166
|
+
complete: graph.persistCoverage?.partial !== true && graph.persistCoverage?.inProgress !== true,
|
|
4167
|
+
languages: {},
|
|
4168
|
+
};
|
|
4169
|
+
const numberMetadata = (node, key) => {
|
|
4170
|
+
const value = node?.metadata?.[key];
|
|
4171
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
4172
|
+
};
|
|
4173
|
+
const symbolKind = (node) => {
|
|
4174
|
+
switch (node.symbolKind) {
|
|
4175
|
+
case "class":
|
|
4176
|
+
case "interface":
|
|
4177
|
+
case "type":
|
|
4178
|
+
case "variable":
|
|
4179
|
+
case "method":
|
|
4180
|
+
case "property":
|
|
4181
|
+
case "function":
|
|
4182
|
+
return node.symbolKind;
|
|
4183
|
+
default:
|
|
4184
|
+
return "function";
|
|
3391
4185
|
}
|
|
3392
|
-
}
|
|
4186
|
+
};
|
|
4187
|
+
for (const node of graph.nodes.values()) {
|
|
4188
|
+
if (node.kind !== "symbol" || !node.filePath || !node.symbolName)
|
|
4189
|
+
continue;
|
|
4190
|
+
const sym = {
|
|
4191
|
+
id: node.id,
|
|
4192
|
+
name: node.symbolName,
|
|
4193
|
+
kind: symbolKind(node),
|
|
4194
|
+
filePath: node.filePath,
|
|
4195
|
+
line: numberMetadata(node, "line") ?? 1,
|
|
4196
|
+
endLine: numberMetadata(node, "endLine"),
|
|
4197
|
+
column: numberMetadata(node, "column") ?? 1,
|
|
4198
|
+
isExported: node.exported === true,
|
|
4199
|
+
};
|
|
4200
|
+
nodeSymbols.set(node.id, sym);
|
|
4201
|
+
const list = allSymbols.get(node.filePath) ?? [];
|
|
4202
|
+
list.push(sym);
|
|
4203
|
+
allSymbols.set(node.filePath, list);
|
|
4204
|
+
}
|
|
4205
|
+
const addRef = (ref) => {
|
|
4206
|
+
const list = allRefs.get(ref.filePath) ?? [];
|
|
4207
|
+
list.push(ref);
|
|
4208
|
+
allRefs.set(ref.filePath, list);
|
|
4209
|
+
};
|
|
3393
4210
|
for (const edge of graph.edges) {
|
|
3394
|
-
if (edge.kind
|
|
3395
|
-
|
|
3396
|
-
|
|
4211
|
+
if (edge.kind !== "calls" && edge.kind !== "references")
|
|
4212
|
+
continue;
|
|
4213
|
+
coverage.totalEvidence++;
|
|
4214
|
+
if (edge.kind === "calls")
|
|
4215
|
+
coverage.callsEvidence++;
|
|
4216
|
+
else
|
|
4217
|
+
coverage.referencesEvidence++;
|
|
4218
|
+
const fromNode = graph.nodes.get(edge.from);
|
|
4219
|
+
const targetNode = graph.nodes.get(edge.to);
|
|
4220
|
+
const callerFile = fromNode?.kind === "symbol"
|
|
4221
|
+
? fromNode.filePath
|
|
4222
|
+
: fromNode?.kind === "file"
|
|
4223
|
+
? fromNode.filePath
|
|
4224
|
+
: edge.from.startsWith("file:")
|
|
4225
|
+
? edge.from.slice("file:".length)
|
|
4226
|
+
: undefined;
|
|
4227
|
+
if (!callerFile) {
|
|
4228
|
+
coverage.unsupportedEvidence++;
|
|
4229
|
+
continue;
|
|
4230
|
+
}
|
|
4231
|
+
const metadata = edge.metadata ?? {};
|
|
4232
|
+
const referenceKind = edge.kind === "calls"
|
|
4233
|
+
? "call"
|
|
4234
|
+
: metadata.referenceKind === "call"
|
|
4235
|
+
? "call"
|
|
4236
|
+
: metadata.referenceKind === "type"
|
|
4237
|
+
? "type"
|
|
4238
|
+
: "unknown";
|
|
4239
|
+
const resolution = edge.resolution ?? (targetNode && !targetNode.metadata?.unresolvedName ? "exact" : "unresolved");
|
|
4240
|
+
const targetSymbol = nodeSymbols.get(edge.to);
|
|
4241
|
+
const targetName = targetNode?.symbolName ??
|
|
4242
|
+
(edge.to.startsWith("symbol-name:")
|
|
3397
4243
|
? edge.to.slice("symbol-name:".length)
|
|
3398
|
-
:
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
4244
|
+
: undefined);
|
|
4245
|
+
const line = typeof metadata.line === "number" ? metadata.line : numberMetadata(fromNode, "line") ?? 1;
|
|
4246
|
+
const column = typeof metadata.column === "number" ? metadata.column : numberMetadata(fromNode, "column") ?? 1;
|
|
4247
|
+
addRef({
|
|
4248
|
+
symbolId: targetName ? `${callerFile}:${targetName}` : edge.to,
|
|
4249
|
+
filePath: callerFile,
|
|
4250
|
+
line,
|
|
4251
|
+
column,
|
|
4252
|
+
evidenceKind: edge.kind,
|
|
4253
|
+
referenceKind,
|
|
4254
|
+
targetId: edge.to,
|
|
4255
|
+
callerSymbolId: fromNode?.kind === "symbol" ? fromNode.id : undefined,
|
|
4256
|
+
resolution,
|
|
4257
|
+
targetName,
|
|
4258
|
+
targetKind: targetNode?.symbolKind,
|
|
4259
|
+
targetFilePath: targetNode?.filePath,
|
|
4260
|
+
targetLine: numberMetadata(targetNode, "line"),
|
|
4261
|
+
targetColumn: numberMetadata(targetNode, "column"),
|
|
4262
|
+
});
|
|
4263
|
+
if (referenceKind === "type")
|
|
4264
|
+
coverage.typeOnlyEvidence++;
|
|
4265
|
+
else if (referenceKind !== "call")
|
|
4266
|
+
coverage.unsupportedEvidence++;
|
|
4267
|
+
else if (!targetSymbol || resolution === "name-only" || resolution === "unresolved") {
|
|
4268
|
+
coverage.unresolvedEvidence++;
|
|
4269
|
+
}
|
|
4270
|
+
else {
|
|
4271
|
+
coverage.eligibleEvidence++;
|
|
4272
|
+
coverage.resolvedEvidence++;
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
// Capped/partial graph persistence and file-level extractor metadata are
|
|
4276
|
+
// explicit degradation signals for read-only consumers. Missing metadata is
|
|
4277
|
+
// also unavailable: old/synthetic file nodes cannot prove that definitions,
|
|
4278
|
+
// references, or the TS/TSX warm function facts were attempted successfully.
|
|
4279
|
+
let sawRelevantFileNode = false;
|
|
4280
|
+
for (const node of graph.nodes.values()) {
|
|
4281
|
+
if (node.kind !== "file")
|
|
4282
|
+
continue;
|
|
4283
|
+
sawRelevantFileNode = true;
|
|
4284
|
+
const extraction = node.metadata?.extractionCoverage;
|
|
4285
|
+
const statuses = extraction ? Object.values(extraction) : [];
|
|
4286
|
+
const languageStatus = !extraction || statuses.length === 0 || statuses.includes("unavailable")
|
|
4287
|
+
? "unavailable"
|
|
4288
|
+
: statuses.includes("partial")
|
|
4289
|
+
? "partial"
|
|
4290
|
+
: "complete";
|
|
4291
|
+
if (languageStatus !== "complete")
|
|
4292
|
+
coverage.complete = false;
|
|
4293
|
+
if (node.language) {
|
|
4294
|
+
const prior = coverage.languages[node.language];
|
|
4295
|
+
coverage.languages[node.language] =
|
|
4296
|
+
prior === "unavailable" || languageStatus === "unavailable"
|
|
4297
|
+
? "unavailable"
|
|
4298
|
+
: prior === "partial" || languageStatus === "partial"
|
|
4299
|
+
? "partial"
|
|
4300
|
+
: languageStatus;
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
// A graph with symbols/evidence but no file coverage metadata is a
|
|
4304
|
+
// synthetic/legacy shape that cannot prove which source files were
|
|
4305
|
+
// actually extracted. Do not let it masquerade as a complete scan.
|
|
4306
|
+
// No file node means there is no bounded source/extractor coverage to
|
|
4307
|
+
// justify a clean empty call graph (including an entirely empty or
|
|
4308
|
+
// external-only synthetic graph).
|
|
4309
|
+
if (!sawRelevantFileNode)
|
|
4310
|
+
coverage.complete = false;
|
|
4311
|
+
return { allSymbols, allRefs, coverage };
|
|
3411
4312
|
}
|