@herbertgao/pi-extensions 2026.8.4 → 2026.8.6
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 +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
- 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/feature/context.ts +74 -5
- 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 +53 -14
- 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 +18 -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 +20 -4
- 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/fleet-list.ts +15 -6
- 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/@herbertgao/pi-subagents/src/worktree.ts +9 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -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 +32 -24
- package/node_modules/@narumitw/pi-btw/package.json +6 -6
- package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
- package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
- package/node_modules/pi-lens/CHANGELOG.md +2535 -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/advisory-provenance.js +314 -0
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
- 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 +136 -24
- 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-events-logger.js +62 -6
- package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
- 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/cascade-format.js +57 -2
- 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 +26 -0
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
- 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 +361 -77
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
- 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 +1172 -683
- 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 +47 -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/availability-policy.js +226 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
- 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 +473 -42
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -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 +18 -4
- package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
- package/node_modules/pi-lens/dist/clients/extension-log.js +432 -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/fix-worklog.js +5 -1
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
- 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 +678 -11
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
- package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
- 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 +395 -100
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
- package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
- 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 +72 -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 +63 -9
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
- package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
- package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
- 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 +437 -86
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
- 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 +127 -32
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
- package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
- 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/model-provider.js +53 -0
- 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 +76 -15
- 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/fresh-fetch.js +1 -1
- 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/runner-adapters/runner-findings.js +23 -2
- 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/query.js +24 -0
- 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/run-duration.js +55 -0
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
- package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
- package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -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 +342 -33
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
- package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
- package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
- package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
- package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -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 +7 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
- package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
- package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
- package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -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 +50 -13
- package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
- package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
- package/node_modules/pi-lens/dist/index.js +51785 -36118
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
- package/node_modules/pi-lens/dist/mcp/server.js +219 -46
- package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
- 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/dist/tools/symbol-search.js +1 -1
- package/node_modules/pi-lens/docs/agent-guide.md +40 -15
- 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 +14 -3
- 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 +45 -3
- package/node_modules/pi-lens/docs/globalconfig.md +56 -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 +18 -1
- package/node_modules/pi-lens/docs/tools.md +4 -1
- package/node_modules/pi-lens/docs/usage.md +41 -5
- package/node_modules/pi-lens/docs/word-index.md +35 -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-chained-type-assertions-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -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-reflect-apply-js-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -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-chained-type-assertions.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -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-reflect-apply-js.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -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 +134 -2
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
- package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
- package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
- package/node_modules/pi-mcp-adapter/README.md +38 -4
- package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
- package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
- package/node_modules/pi-mcp-adapter/cli.js +25 -4
- package/node_modules/pi-mcp-adapter/commands.ts +15 -7
- package/node_modules/pi-mcp-adapter/config.ts +4 -4
- package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
- 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/mcp-setup-panel.ts +2 -1
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
- package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
- package/node_modules/pi-mcp-adapter/package.json +4 -3
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
- package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
- package/node_modules/pi-mcp-adapter/server-manager.ts +41 -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 +62 -4
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
- package/node_modules/pi-mcp-adapter/types.ts +176 -27
- package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +18 -12
- package/node_modules/pi-web-access/auth-fetch.ts +148 -0
- package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
- package/node_modules/pi-web-access/curator-page.ts +5 -3
- package/node_modules/pi-web-access/curator-server.ts +2 -1
- package/node_modules/pi-web-access/extract.ts +106 -34
- package/node_modules/pi-web-access/fetch-params.ts +17 -3
- package/node_modules/pi-web-access/firecrawl.ts +172 -12
- package/node_modules/pi-web-access/gemini-search.ts +18 -4
- package/node_modules/pi-web-access/index.ts +120 -48
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/summary-review.ts +11 -5
- package/node_modules/pi-web-access/youtube-extract.ts +2 -2
- package/package.json +12 -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
|
@@ -19,19 +19,24 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
|
|
20
20
|
import { logCascade } from "../cascade-logger.js";
|
|
21
21
|
import { getDiagnosticTracker } from "../diagnostic-tracker.js";
|
|
22
|
-
import { isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
|
|
23
|
-
import {
|
|
22
|
+
import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
|
|
23
|
+
import { bindingStateLabel, touchCoverageGap, } from "../lsp/diagnostic-binding.js";
|
|
24
24
|
import { getServersForFileWithConfig } from "../lsp/config.js";
|
|
25
25
|
import { getLSPService } from "../lsp/index.js";
|
|
26
26
|
import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
|
|
27
27
|
import { getProjectIgnoreMatcher } from "../file-utils.js";
|
|
28
|
-
import {
|
|
28
|
+
import { resetAstGrepUnsupportedLanguageLog } from "./runners/ast-grep-napi.js";
|
|
29
|
+
import { isTestRoleCollateral } from "../collateral-test-role.js";
|
|
30
|
+
import { clearReviewGraphWorkspaceCache, getGraphBuildInfoForGraph, getGraphImportChanges, graphBuildInfoIsTrustworthy, } from "../review-graph/builder.js";
|
|
29
31
|
import { buildReverseDependencyIndexFromGraph, getAffectedFilesFromIndex, patchReverseDependencyIndex, writeReverseDependencyIndexToSnapshot, } from "../reverse-deps.js";
|
|
30
32
|
import { buildOrUpdateGraph, computeImpactCascade, computeTransitiveImpact, formatImpactCascade, } from "../review-graph/service.js";
|
|
31
33
|
import { clearModuleGraphCache } from "../review-graph/workspace-modules.js";
|
|
34
|
+
import { releaseWorkspaceTopologyIdleTimers } from "../workspace-topology.js";
|
|
32
35
|
import { RUNTIME_CONFIG } from "../runtime-config.js";
|
|
33
36
|
import { findCompiledClassesDir, hasJavaBuildDescriptor, } from "../tool-policy.js";
|
|
34
37
|
import { removeWordIndexDocument, updateWordIndexDocument, WORD_INDEX_MAX_BYTES, } from "../word-index.js";
|
|
38
|
+
import { reconcileCascadeNeighborLspErrors } from "../widget-state.js";
|
|
39
|
+
import { findAuxiliaryProfileForSource } from "./auxiliary-lsp.js";
|
|
35
40
|
// Register fact providers. All register eagerly here (the dispatch entry) — the
|
|
36
41
|
// tree-sitter-backed providers included, since the parsing stack loads
|
|
37
42
|
// `web-tree-sitter` lazily inside client.init(), not at module import, so it
|
|
@@ -292,6 +297,7 @@ export function getDispatchGroupsForKind(kind, pi) {
|
|
|
292
297
|
export function resetDispatchBaselines(cwd) {
|
|
293
298
|
if (cwd)
|
|
294
299
|
applyProjectLensConfig(cwd);
|
|
300
|
+
resetAstGrepUnsupportedLanguageLog();
|
|
295
301
|
sessionFacts.clearAll();
|
|
296
302
|
resetSessionSlopScore();
|
|
297
303
|
clearCoverageNoticeState();
|
|
@@ -321,6 +327,14 @@ export function getCascadeSessionStats() {
|
|
|
321
327
|
}
|
|
322
328
|
const neighborTouchCache = new Map();
|
|
323
329
|
const recentlyCleanNeighborCache = new Map();
|
|
330
|
+
/** O(1) entry counts of this module's turn-bounded caches (#1123 item 2
|
|
331
|
+
* memory attribution) — both are `Map.size` reads, never iterated. */
|
|
332
|
+
export function getDispatchCascadeCacheStats() {
|
|
333
|
+
return {
|
|
334
|
+
neighborTouchCacheSize: neighborTouchCache.size,
|
|
335
|
+
recentlyCleanNeighborCacheSize: recentlyCleanNeighborCache.size,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
324
338
|
const RECENTLY_CLEAN_TTL_TURNS = 5;
|
|
325
339
|
// B10: tracks files that were the *primary* edited file this turn.
|
|
326
340
|
// These are excluded from cascade neighbor results — their own pipeline run
|
|
@@ -342,14 +356,143 @@ function ensureCascadeTurnScope(turnSeq) {
|
|
|
342
356
|
const CASCADE_TTL_MS = 240_000;
|
|
343
357
|
const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
|
|
344
358
|
const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
|
|
359
|
+
/**
|
|
360
|
+
* The genuine language-server ERROR diagnostics from a cascade neighbor
|
|
361
|
+
* re-check, ready to reconcile into the footer widget (#1093).
|
|
362
|
+
*
|
|
363
|
+
* Drops any diagnostic whose `source` matches an auxiliary-LSP profile
|
|
364
|
+
* (opengrep/ast-grep/zizmor/typos — `findAuxiliaryProfileForSource`). The
|
|
365
|
+
* cascade's `convertLspDiagnostics` tags everything `tool: "lsp"`,
|
|
366
|
+
* `semantic: "blocking"` and — unlike every OTHER widget writer
|
|
367
|
+
* (`runners/lsp.ts`, `tools/lsp-diagnostics.ts`, `tools/lens-diagnostics.ts`) —
|
|
368
|
+
* never runs `retagAuxiliaryDiagnostics`. Since `getAllDiagnostics` /
|
|
369
|
+
* `touchFile({clientScope:"all"})` include the auxiliary servers' findings,
|
|
370
|
+
* writing them here would (a) DOUBLE-COUNT a neighbor's own correctly-tagged aux
|
|
371
|
+
* entry and (b) ESCALATE an advisory/suppressed aux finding into a blocking
|
|
372
|
+
* `tool:"lsp"` error, bypassing aux policy (semantic downgrade, native
|
|
373
|
+
* `# nosemgrep`/`zizmor:ignore` suppression, `skipTestFiles`). Aux findings are
|
|
374
|
+
* per-file lint/security signals the neighbor's OWN per-edit runners own; they
|
|
375
|
+
* are not cross-file impact, so the cascade simply excludes them and the merge
|
|
376
|
+
* (`reconcileCascadeNeighborLspErrors`) preserves the neighbor's existing aux
|
|
377
|
+
* entries untouched. This keeps `isLspErrorEntry`'s "tool === 'lsp' uniquely
|
|
378
|
+
* identifies a genuine language-server entry" contract true for what we write.
|
|
379
|
+
*
|
|
380
|
+
* The DISPLAY list (`diags` at each call site) is deliberately left as-is —
|
|
381
|
+
* that is pre-existing cascade output behavior, out of scope for #1093.
|
|
382
|
+
*/
|
|
383
|
+
function cascadeReconcilableLspErrors(rawDiags, neighborPath) {
|
|
384
|
+
return convertLspDiagnostics(rawDiags
|
|
385
|
+
.filter((d) => d.severity === 1 && !findAuxiliaryProfileForSource(d.source))
|
|
386
|
+
.slice(0, MAX_PER_FILE), neighborPath);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Read the content `binding` verdict off a diagnostics result (#1095).
|
|
390
|
+
*
|
|
391
|
+
* `touchFile` carries `binding` as an EXPLICIT enumerable field on its
|
|
392
|
+
* `TouchFileResult` wrapper (#1179), so it survives any copy of `.diags`.
|
|
393
|
+
* `getAllDiagnostics` still attaches `binding` as a lazy, disk-verifying
|
|
394
|
+
* NON-enumerable getter on each Map entry — reading it directly off the
|
|
395
|
+
* producer's own entry (never a spread/clone, which drops non-enumerables)
|
|
396
|
+
* triggers the disk verify exactly once (memoized per entry). Accepts either
|
|
397
|
+
* shape; returns `undefined` when no binding was attached at all (a partially-
|
|
398
|
+
* mocked client, a non-collecting touch) — indistinguishable from "unknown" at
|
|
399
|
+
* every call site, which is the intended pre-#1095 fall-through.
|
|
400
|
+
*/
|
|
401
|
+
function readBoundToCurrentDisk(rawDiags) {
|
|
402
|
+
return rawDiags
|
|
403
|
+
?.binding?.boundToCurrentDisk;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* #1095: is this active-touch result a CONFIRMED observation of the neighbor's
|
|
407
|
+
* current on-disk LSP-error state — safe to reconcile into the footer widget and
|
|
408
|
+
* (when clean) to seed the recently-clean neighbor cache?
|
|
409
|
+
*
|
|
410
|
+
* Composes the two independent disqualifiers into ONE clearly-named predicate so
|
|
411
|
+
* a future flag cannot be silently missed at just one of the several gate sites:
|
|
412
|
+
* - `inconclusive` (#1093/#571): the notify/diagnostics wait lapsed its deadline,
|
|
413
|
+
* so a resolved `[]` is NOT a confirmed clean (the #533 false-clean trap).
|
|
414
|
+
* - a COVERAGE GAP (#1470): an auxiliary's push wait was cut off by the aux grace
|
|
415
|
+
* timer, so the merged result is missing whatever that scanner would have said.
|
|
416
|
+
* Such a touch is deliberately NOT `inconclusive` (the primary answered), which
|
|
417
|
+
* is exactly why it needs naming here: reading `!inconclusive` alone would let a
|
|
418
|
+
* hung opengrep wipe a live footer finding and seed the recently-clean cache.
|
|
419
|
+
* - `binding.boundToCurrentDisk === false` (#1095): the diagnostics were computed
|
|
420
|
+
* against a DIFFERENT disk state than what is on disk now (the server's view
|
|
421
|
+
* diverged / a pre-fix buffer) — not an observation of current disk. `true` and
|
|
422
|
+
* `"unknown"` both pass; `"unknown"` preserves pre-#1095 behavior for a
|
|
423
|
+
* version-less server exactly.
|
|
424
|
+
*
|
|
425
|
+
* #1179: both flags are EXPLICIT enumerable fields on the `touchFile`
|
|
426
|
+
* `TouchFileResult` wrapper — read them off the wrapper (`rawDiags`), whose
|
|
427
|
+
* `.diags` a downstream `.filter()`/copy operates on without touching the flags.
|
|
428
|
+
*/
|
|
429
|
+
function readInconclusive(rawDiags) {
|
|
430
|
+
return rawDiags?.inconclusive === true;
|
|
431
|
+
}
|
|
432
|
+
function isConfirmedTouch(rawDiags) {
|
|
433
|
+
return (!readInconclusive(rawDiags) &&
|
|
434
|
+
touchCoverageGap(rawDiags).length === 0 &&
|
|
435
|
+
readBoundToCurrentDisk(rawDiags) !== false);
|
|
436
|
+
}
|
|
345
437
|
const reverseDepsIndexCache = new Map();
|
|
438
|
+
const REVERSE_DEPS_MAX_WARM_ROOTS = 8;
|
|
439
|
+
const REVERSE_DEPS_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
|
|
440
|
+
function reverseDepsIdleEvictMs() {
|
|
441
|
+
const value = Number.parseInt(process.env.PI_LENS_REVERSE_DEPS_IDLE_EVICT_MS ?? "", 10);
|
|
442
|
+
return Number.isSafeInteger(value) && value > 0 ? value : REVERSE_DEPS_IDLE_EVICT_MS_DEFAULT;
|
|
443
|
+
}
|
|
444
|
+
function deleteReverseDepsEntry(key) {
|
|
445
|
+
const entry = reverseDepsIndexCache.get(key);
|
|
446
|
+
if (entry?.idleTimer !== undefined)
|
|
447
|
+
clearTimeout(entry.idleTimer);
|
|
448
|
+
if (entry)
|
|
449
|
+
entry.idleTimer = undefined;
|
|
450
|
+
reverseDepsIndexCache.delete(key);
|
|
451
|
+
}
|
|
452
|
+
function touchReverseDepsEntry(key, entry, armIdleTimer = true) {
|
|
453
|
+
entry.lastUsedAt = Date.now();
|
|
454
|
+
if (entry.idleTimer !== undefined)
|
|
455
|
+
clearTimeout(entry.idleTimer);
|
|
456
|
+
entry.idleTimer = undefined;
|
|
457
|
+
if (!armIdleTimer)
|
|
458
|
+
return;
|
|
459
|
+
const stamp = entry.lastUsedAt;
|
|
460
|
+
entry.idleTimer = setTimeout(() => {
|
|
461
|
+
if (reverseDepsIndexCache.get(key) !== entry || entry.lastUsedAt !== stamp)
|
|
462
|
+
return;
|
|
463
|
+
deleteReverseDepsEntry(key);
|
|
464
|
+
}, reverseDepsIdleEvictMs());
|
|
465
|
+
entry.idleTimer.unref?.();
|
|
466
|
+
}
|
|
467
|
+
function setReverseDepsEntry(key, entry, armIdleTimer = true) {
|
|
468
|
+
const resident = { ...entry, lastUsedAt: Date.now() };
|
|
469
|
+
reverseDepsIndexCache.set(key, resident);
|
|
470
|
+
touchReverseDepsEntry(key, resident, armIdleTimer);
|
|
471
|
+
while (reverseDepsIndexCache.size > REVERSE_DEPS_MAX_WARM_ROOTS) {
|
|
472
|
+
const victim = [...reverseDepsIndexCache.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)[0];
|
|
473
|
+
if (!victim)
|
|
474
|
+
break;
|
|
475
|
+
deleteReverseDepsEntry(victim[0]);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
346
478
|
function reverseDepsReuseEnabled() {
|
|
347
479
|
const raw = process.env.PI_LENS_REVERSE_DEPS_REUSE;
|
|
348
480
|
return raw !== "0" && raw !== "false";
|
|
349
481
|
}
|
|
350
482
|
/** Test-reset hook — mirrors clearReviewGraphWorkspaceCache's scope. */
|
|
351
483
|
export function clearReverseDepsIndexCache() {
|
|
352
|
-
reverseDepsIndexCache.
|
|
484
|
+
for (const key of reverseDepsIndexCache.keys())
|
|
485
|
+
deleteReverseDepsEntry(key);
|
|
486
|
+
}
|
|
487
|
+
/** Test-only visibility for Tier-2 eviction/recovery tests. */
|
|
488
|
+
export function _getReverseDepsIndexCacheKeysForTests() {
|
|
489
|
+
return [...reverseDepsIndexCache.entries()]
|
|
490
|
+
.sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
|
|
491
|
+
.map(([key]) => key);
|
|
492
|
+
}
|
|
493
|
+
/** Test-only seed that still exercises the production timer/cap seam. */
|
|
494
|
+
export function _seedReverseDepsIndexCacheForTests(key, index, generation) {
|
|
495
|
+
setReverseDepsEntry(key, { index, savedToSnapshot: false, generation });
|
|
353
496
|
}
|
|
354
497
|
// Bounded transitive cascade (#162): expand neighbour derivation beyond the
|
|
355
498
|
// one-hop importers/callers to depth-2 dependents, so an edit's blast radius
|
|
@@ -460,758 +603,1073 @@ function updateWordIndexForCascade(args) {
|
|
|
460
603
|
onUpdated?.(wordIndex);
|
|
461
604
|
}
|
|
462
605
|
export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
reason: "primary_has_blockers",
|
|
470
|
-
});
|
|
471
|
-
return {
|
|
472
|
-
filePath,
|
|
473
|
-
result: undefined,
|
|
474
|
-
neighborCount: 0,
|
|
475
|
-
diagnosticCount: 0,
|
|
476
|
-
skipReason: "blockers",
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
const fileKind = detectFileKind(filePath);
|
|
480
|
-
if (!fileKind) {
|
|
481
|
-
logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
|
|
482
|
-
return {
|
|
483
|
-
filePath,
|
|
484
|
-
result: undefined,
|
|
485
|
-
neighborCount: 0,
|
|
486
|
-
diagnosticCount: 0,
|
|
487
|
-
skipReason: "non_code",
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
const normalizedFile = resolveRunnerPath(cwd, filePath);
|
|
491
|
-
const normalizedFileKey = normalizeMapKey(normalizedFile);
|
|
492
|
-
// B10: record this file as a primary edit so later cascade calls in the same
|
|
493
|
-
// turn won't show it as a neighbor.
|
|
494
|
-
primaryFilesThisTurn.add(normalizedFileKey);
|
|
495
|
-
// #348 phase 2: warm per-edit word-index maintenance, review-graph style —
|
|
496
|
-
// update at the SAME seam as the graph rebuild below, using content the
|
|
497
|
-
// pipeline already read (no extra I/O). See computeCascadeForFile's
|
|
498
|
-
// `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
|
|
499
|
-
// no-op rules.
|
|
500
|
-
//
|
|
501
|
-
// Keyed by `path.resolve(filePath)` (native separators, tool-input casing).
|
|
502
|
-
// Since #1025 the word index's path maps are `PathKeyedMap`s that fold every
|
|
503
|
-
// key through `wordIndexKey` (`normalizeEphemeralMapKey` — slash-fold +
|
|
504
|
-
// win32-lowercase) INTERNALLY, so this per-edit key and the build path's own
|
|
505
|
-
// walk-derived key (`collectWordIndexDocs` → `collectSourceFilesAsync`)
|
|
506
|
-
// collapse to the same entry regardless of on-disk-vs-input casing/separator.
|
|
507
|
-
// The old hazard — this update silently orphaning a SECOND entry next to the
|
|
508
|
-
// walker's original-cased one — is now structurally impossible at the map
|
|
509
|
-
// layer, so this seam no longer has to hand-match the build path's key shape.
|
|
510
|
-
updateWordIndexForCascade({
|
|
511
|
-
wordIndex,
|
|
512
|
-
filePath: nodePath.resolve(filePath),
|
|
513
|
-
content: fileContent,
|
|
514
|
-
onUpdated: onWordIndexUpdated,
|
|
515
|
-
dbg,
|
|
516
|
-
});
|
|
517
|
-
let impact = {
|
|
518
|
-
filePath: normalizedFile,
|
|
519
|
-
changedSymbols: [],
|
|
520
|
-
directImporters: [],
|
|
521
|
-
directCallers: [],
|
|
522
|
-
neighborFiles: [],
|
|
523
|
-
riskFlags: [],
|
|
524
|
-
};
|
|
525
|
-
let sortedNeighbors = [];
|
|
526
|
-
let importerSet = new Set();
|
|
527
|
-
let callerSet = new Set();
|
|
528
|
-
let referenceCount = 0;
|
|
529
|
-
if (CASCADE_GRAPH_KINDS.has(fileKind)) {
|
|
530
|
-
const graphStart = Date.now();
|
|
531
|
-
const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
|
|
532
|
-
const graphMs = Date.now() - graphStart;
|
|
533
|
-
// #459: the reuse decision keys on graph.buildGeneration — a stamp that
|
|
534
|
-
// travels WITH the graph instance this cascade holds. Deliberately NOT the
|
|
535
|
-
// global last-build-info slot: post-#450 cascades overlap, and another
|
|
536
|
-
// cascade's cache-hit build can overwrite that slot (graphChanged:false)
|
|
537
|
-
// between this build mutating the graph and this read — which would turn a
|
|
538
|
-
// changed graph into a spurious reuse of a stale index that steady-state
|
|
539
|
-
// cache hits then never heal. Generation equality can't be clobbered into
|
|
540
|
-
// a false positive: a graph-mutating build always mints a new generation.
|
|
541
|
-
// An unstamped graph (mode "skipped") always rebuilds.
|
|
542
|
-
const graphBuildInfo = getLastGraphBuildInfo();
|
|
543
|
-
const workspaceKey = normalizeMapKey(cwd);
|
|
544
|
-
const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
|
|
545
|
-
const importDelta = getGraphImportChanges(graph);
|
|
546
|
-
// A one-step delta is only usable against an index cached at exactly the
|
|
547
|
-
// delta's predecessor generation. Builds minted elsewhere (mcp analyze,
|
|
548
|
-
// lens-map, session warm) advance generations whose import changes this
|
|
549
|
-
// delta does not cover — reusing/patching across that gap stamps a stale
|
|
550
|
-
// index as current, and generation equality then hides the loss forever
|
|
551
|
-
// (#939 review finding 1).
|
|
552
|
-
const deltaContiguous = importDelta !== undefined &&
|
|
553
|
-
importDelta.fromGeneration !== undefined &&
|
|
554
|
-
cachedReverseDeps !== undefined &&
|
|
555
|
-
cachedReverseDeps.generation === importDelta.fromGeneration;
|
|
556
|
-
const importChanges = deltaContiguous ? importDelta.changes : undefined;
|
|
557
|
-
const importsChanged = importChanges?.some((change) => change.existedBefore !== change.existsAfter ||
|
|
558
|
-
change.priorTargets.length !== change.newTargets.length ||
|
|
559
|
-
change.priorTargets.some((target, index) => target !== change.newTargets[index]));
|
|
560
|
-
const canReuse = reverseDepsReuseEnabled() &&
|
|
561
|
-
cachedReverseDeps !== undefined &&
|
|
562
|
-
((graph.buildGeneration !== undefined &&
|
|
563
|
-
cachedReverseDeps.generation === graph.buildGeneration) ||
|
|
564
|
-
(importChanges !== undefined && !importsChanged));
|
|
565
|
-
let reverseDepsIndex;
|
|
566
|
-
let reverseDepsSaved;
|
|
567
|
-
if (canReuse && cachedReverseDeps) {
|
|
568
|
-
reverseDepsIndex = cachedReverseDeps.index;
|
|
569
|
-
reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
|
|
570
|
-
cachedReverseDeps.generation = graph.buildGeneration;
|
|
606
|
+
const reverseDepsTimersToRelease = new Set();
|
|
607
|
+
const reverseDepsEntriesAtStart = new Set(reverseDepsIndexCache.values());
|
|
608
|
+
try {
|
|
609
|
+
const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, fileContent, wordIndex, onWordIndexUpdated, } = options;
|
|
610
|
+
ensureCascadeTurnScope(turnSeq);
|
|
611
|
+
if (hasBlockers) {
|
|
571
612
|
logCascade({
|
|
572
|
-
phase: "
|
|
613
|
+
phase: "cascade_skip",
|
|
573
614
|
filePath,
|
|
574
|
-
|
|
575
|
-
metadata: {
|
|
576
|
-
action: "reused_unchanged",
|
|
577
|
-
savedToSnapshot: reverseDepsSaved,
|
|
578
|
-
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
|
|
579
|
-
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
|
|
580
|
-
},
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
else if (cachedReverseDeps && importChanges && importsChanged) {
|
|
584
|
-
reverseDepsIndex = patchReverseDependencyIndex(cachedReverseDeps.index, importChanges);
|
|
585
|
-
reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
|
|
586
|
-
cwd,
|
|
587
|
-
index: reverseDepsIndex,
|
|
588
|
-
dbg,
|
|
615
|
+
reason: "primary_has_blockers",
|
|
589
616
|
});
|
|
590
|
-
|
|
591
|
-
index: reverseDepsIndex,
|
|
592
|
-
savedToSnapshot: reverseDepsSaved,
|
|
593
|
-
generation: graph.buildGeneration,
|
|
594
|
-
});
|
|
595
|
-
logCascade({
|
|
596
|
-
phase: "reverse_deps_cache",
|
|
617
|
+
return {
|
|
597
618
|
filePath,
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
},
|
|
604
|
-
});
|
|
619
|
+
result: undefined,
|
|
620
|
+
neighborCount: 0,
|
|
621
|
+
diagnosticCount: 0,
|
|
622
|
+
skipReason: "blockers",
|
|
623
|
+
};
|
|
605
624
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
});
|
|
611
|
-
reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
|
|
612
|
-
cwd,
|
|
613
|
-
index: reverseDepsIndex,
|
|
614
|
-
dbg,
|
|
615
|
-
});
|
|
616
|
-
reverseDepsIndexCache.set(workspaceKey, {
|
|
617
|
-
index: reverseDepsIndex,
|
|
618
|
-
savedToSnapshot: reverseDepsSaved,
|
|
619
|
-
generation: graph.buildGeneration,
|
|
620
|
-
});
|
|
621
|
-
logCascade({
|
|
622
|
-
phase: "reverse_deps_cache",
|
|
625
|
+
const fileKind = detectFileKind(filePath);
|
|
626
|
+
if (!fileKind) {
|
|
627
|
+
logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
|
|
628
|
+
return {
|
|
623
629
|
filePath,
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
|
|
629
|
-
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
|
|
630
|
-
importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
|
|
631
|
-
},
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
// Count files represented in the graph (nodes with a filePath).
|
|
635
|
-
const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
|
|
636
|
-
logCascade({
|
|
637
|
-
phase: "graph_build",
|
|
638
|
-
filePath,
|
|
639
|
-
graphBuiltMs: graphMs,
|
|
640
|
-
graphReused: graphBuildInfo.reused,
|
|
641
|
-
graphNodeCount: graph.nodes.size,
|
|
642
|
-
graphFileCount,
|
|
643
|
-
graphChangedSymbolCount: (graph.changedSymbolsByFile.get(normalizedFileKey) ?? []).length,
|
|
644
|
-
metadata: {
|
|
645
|
-
graphBuildMode: graphBuildInfo.mode,
|
|
646
|
-
skipReason: graphBuildInfo.skipReason,
|
|
647
|
-
sourceFileCount: graphBuildInfo.sourceFileCount,
|
|
648
|
-
maxFileCount: graphBuildInfo.maxFileCount,
|
|
649
|
-
// #451: when the seq fast path fell back (or was skipped), why — so
|
|
650
|
-
// cascade.log surfaces the fast-path hit/miss rate.
|
|
651
|
-
seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
|
|
652
|
-
},
|
|
653
|
-
});
|
|
654
|
-
impact = computeImpactCascade(graph, normalizedFile, cwd);
|
|
655
|
-
// #1023: buildOrUpdateGraph returns an EMPTY graph (seeded only with the
|
|
656
|
-
// changed file's own symbols) when the repo is over
|
|
657
|
-
// PI_LENS_REVIEW_GRAPH_MAX_FILES (`too_many_files`) or the root is unsafe
|
|
658
|
-
// (`unsafe_root`) — both stamp mode "skipped" on the already-read
|
|
659
|
-
// graphBuildInfo. Computing impact against that empty graph yields zero
|
|
660
|
-
// neighbors for EVERY edit, indistinguishable from a genuine clean leaf.
|
|
661
|
-
// Thread the ALREADY-KNOWN degraded state (never re-derived) onto the
|
|
662
|
-
// result so the turn-end seam surfaces an honest advisory instead of a
|
|
663
|
-
// silent all-clear (#533). Keyed strictly off the degraded build mode —
|
|
664
|
-
// NOT off `neighborFiles.length === 0` — so a healthy build with a real
|
|
665
|
-
// empty dependent set stays silent (no crying wolf).
|
|
666
|
-
if (graphBuildInfo.mode === "skipped" && !impact.indeterminate) {
|
|
667
|
-
impact.indeterminate = {
|
|
668
|
-
reason: "graph_degraded",
|
|
669
|
-
detail: graphBuildInfo.skipReason === "too_many_files"
|
|
670
|
-
? `review graph disabled — ${graphBuildInfo.sourceFileCount ?? "?"} files over the ${graphBuildInfo.maxFileCount ?? "?"} cap`
|
|
671
|
-
: graphBuildInfo.skipReason === "unsafe_root"
|
|
672
|
-
? "review graph skipped — workspace root is at/above home dir"
|
|
673
|
-
: `review graph unavailable (${graphBuildInfo.skipReason ?? "skipped"})`,
|
|
674
|
-
sourceFileCount: graphBuildInfo.sourceFileCount,
|
|
675
|
-
maxFileCount: graphBuildInfo.maxFileCount,
|
|
630
|
+
result: undefined,
|
|
631
|
+
neighborCount: 0,
|
|
632
|
+
diagnosticCount: 0,
|
|
633
|
+
skipReason: "non_code",
|
|
676
634
|
};
|
|
677
635
|
}
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
636
|
+
const normalizedFile = resolveRunnerPath(cwd, filePath);
|
|
637
|
+
const normalizedFileKey = normalizeMapKey(normalizedFile);
|
|
638
|
+
// B10: record this file as a primary edit so later cascade calls in the same
|
|
639
|
+
// turn won't show it as a neighbor.
|
|
640
|
+
primaryFilesThisTurn.add(normalizedFileKey);
|
|
641
|
+
// #348 phase 2: warm per-edit word-index maintenance, review-graph style —
|
|
642
|
+
// update at the SAME seam as the graph rebuild below, using content the
|
|
643
|
+
// pipeline already read (no extra I/O). See computeCascadeForFile's
|
|
644
|
+
// `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
|
|
645
|
+
// no-op rules.
|
|
646
|
+
//
|
|
647
|
+
// Keyed by `path.resolve(filePath)` (native separators, tool-input casing).
|
|
648
|
+
// Since #1025 the word index's path maps are `PathKeyedMap`s that fold every
|
|
649
|
+
// key through `wordIndexKey` (`normalizeEphemeralMapKey` — slash-fold +
|
|
650
|
+
// win32-lowercase) INTERNALLY, so this per-edit key and the build path's own
|
|
651
|
+
// walk-derived key (`collectWordIndexDocs` → `collectSourceFilesAsync`)
|
|
652
|
+
// collapse to the same entry regardless of on-disk-vs-input casing/separator.
|
|
653
|
+
// The old hazard — this update silently orphaning a SECOND entry next to the
|
|
654
|
+
// walker's original-cased one — is now structurally impossible at the map
|
|
655
|
+
// layer, so this seam no longer has to hand-match the build path's key shape.
|
|
656
|
+
updateWordIndexForCascade({
|
|
657
|
+
wordIndex,
|
|
658
|
+
filePath: nodePath.resolve(filePath),
|
|
659
|
+
content: fileContent,
|
|
660
|
+
onUpdated: onWordIndexUpdated,
|
|
661
|
+
dbg,
|
|
688
662
|
});
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
]
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
]
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
663
|
+
let impact = {
|
|
664
|
+
filePath: normalizedFile,
|
|
665
|
+
changedSymbols: [],
|
|
666
|
+
directImporters: [],
|
|
667
|
+
directCallers: [],
|
|
668
|
+
neighborFiles: [],
|
|
669
|
+
riskFlags: [],
|
|
670
|
+
};
|
|
671
|
+
let sortedNeighbors = [];
|
|
672
|
+
let importerSet = new Set();
|
|
673
|
+
let callerSet = new Set();
|
|
674
|
+
let referenceCount = 0;
|
|
675
|
+
// #1446 item 4: how many eligible neighbors the flat CASCADE_NEIGHBOUR_BUDGET
|
|
676
|
+
// cut off, distinct from candidates dropped by the filters above it
|
|
677
|
+
// (missing on disk, vendor, ignored, already-primary-this-turn) — those are
|
|
678
|
+
// never actionable regardless of budget, so counting them as "truncated"
|
|
679
|
+
// would overstate what a larger budget could actually recover.
|
|
680
|
+
let cascadeBudgetTruncated = 0;
|
|
681
|
+
if (CASCADE_GRAPH_KINDS.has(fileKind)) {
|
|
682
|
+
const graphStart = Date.now();
|
|
683
|
+
const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
|
|
684
|
+
const graphMs = Date.now() - graphStart;
|
|
685
|
+
// #459: the reuse decision keys on graph.buildGeneration — a stamp that
|
|
686
|
+
// travels WITH the graph instance this cascade holds. Deliberately NOT the
|
|
687
|
+
// global last-build-info slot: post-#450 cascades overlap, and another
|
|
688
|
+
// cascade's cache-hit build can overwrite that slot (graphChanged:false)
|
|
689
|
+
// between this build mutating the graph and this read — which would turn a
|
|
690
|
+
// changed graph into a spurious reuse of a stale index that steady-state
|
|
691
|
+
// cache hits then never heal. Generation equality can't be clobbered into
|
|
692
|
+
// a false positive: a graph-mutating build always mints a new generation.
|
|
693
|
+
// An unstamped graph (mode "skipped") always rebuilds.
|
|
694
|
+
const graphBuildInfo = getGraphBuildInfoForGraph(graph);
|
|
695
|
+
// #1179 fail-closed: `getGraphBuildInfoForGraph` falls back to the global
|
|
696
|
+
// last-build slot on a WeakMap identity miss, which — once some real build
|
|
697
|
+
// has stamped — could carry a SIBLING graph's healthy `mode: "cached"`. The
|
|
698
|
+
// degraded-marker gate below must not read a sibling's state, so it checks
|
|
699
|
+
// trustworthiness and, when the slot cannot be trusted for THIS graph, treats
|
|
700
|
+
// coverage as unknown (degraded) rather than clean. Always trustworthy on the
|
|
701
|
+
// live path — every `_doBuildGraph` return stamps the graph before the cascade
|
|
702
|
+
// reads it — so this is inert today and only hardens a future unstamped graph.
|
|
703
|
+
const graphBuildInfoTrustworthy = graphBuildInfoIsTrustworthy(graph);
|
|
704
|
+
const workspaceKey = normalizeMapKey(cwd);
|
|
705
|
+
const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
|
|
706
|
+
if (cachedReverseDeps) {
|
|
707
|
+
touchReverseDepsEntry(workspaceKey, cachedReverseDeps, false);
|
|
708
|
+
reverseDepsTimersToRelease.add(workspaceKey);
|
|
709
|
+
}
|
|
710
|
+
const importDelta = getGraphImportChanges(graph);
|
|
711
|
+
// A one-step delta is only usable against an index cached at exactly the
|
|
712
|
+
// delta's predecessor generation. Builds minted elsewhere (mcp analyze,
|
|
713
|
+
// lens-map, session warm) advance generations whose import changes this
|
|
714
|
+
// delta does not cover — reusing/patching across that gap stamps a stale
|
|
715
|
+
// index as current, and generation equality then hides the loss forever
|
|
716
|
+
// (#939 review finding 1).
|
|
717
|
+
const deltaContiguous = importDelta !== undefined &&
|
|
718
|
+
importDelta.fromGeneration !== undefined &&
|
|
719
|
+
cachedReverseDeps !== undefined &&
|
|
720
|
+
cachedReverseDeps.generation === importDelta.fromGeneration;
|
|
721
|
+
const importChanges = deltaContiguous ? importDelta.changes : undefined;
|
|
722
|
+
const importsChanged = importChanges?.some((change) => change.existedBefore !== change.existsAfter ||
|
|
723
|
+
change.priorTargets.length !== change.newTargets.length ||
|
|
724
|
+
change.priorTargets.some((target, index) => target !== change.newTargets[index]));
|
|
725
|
+
const canReuse = reverseDepsReuseEnabled() &&
|
|
726
|
+
cachedReverseDeps !== undefined &&
|
|
727
|
+
((graph.buildGeneration !== undefined &&
|
|
728
|
+
cachedReverseDeps.generation === graph.buildGeneration) ||
|
|
729
|
+
(importChanges !== undefined && !importsChanged));
|
|
730
|
+
let reverseDepsIndex;
|
|
731
|
+
let reverseDepsSaved;
|
|
732
|
+
if (canReuse && cachedReverseDeps) {
|
|
733
|
+
reverseDepsIndex = cachedReverseDeps.index;
|
|
734
|
+
reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
|
|
735
|
+
cachedReverseDeps.generation = graph.buildGeneration;
|
|
736
|
+
logCascade({
|
|
737
|
+
phase: "reverse_deps_cache",
|
|
738
|
+
filePath,
|
|
739
|
+
durationMs: Date.now() - graphStart,
|
|
740
|
+
metadata: {
|
|
741
|
+
action: "reused_unchanged",
|
|
742
|
+
savedToSnapshot: reverseDepsSaved,
|
|
743
|
+
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
|
|
744
|
+
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
|
|
745
|
+
},
|
|
717
746
|
});
|
|
718
|
-
if (!symbolNodeId)
|
|
719
|
-
continue;
|
|
720
|
-
const node = graph.nodes.get(symbolNodeId);
|
|
721
|
-
const line = Number(node?.metadata?.line ?? 0);
|
|
722
|
-
const column = Number(node?.metadata?.column ?? 0);
|
|
723
|
-
if (line <= 0)
|
|
724
|
-
continue;
|
|
725
|
-
try {
|
|
726
|
-
const refs = await Promise.race([
|
|
727
|
-
lspService.references(normalizedFile, line - 1, column - 1, false),
|
|
728
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 750)),
|
|
729
|
-
]);
|
|
730
|
-
for (const ref of refs) {
|
|
731
|
-
let resolved;
|
|
732
|
-
try {
|
|
733
|
-
resolved = ref.uri.startsWith("file://")
|
|
734
|
-
? fileURLToPath(ref.uri)
|
|
735
|
-
: ref.uri;
|
|
736
|
-
}
|
|
737
|
-
catch {
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
if (normalizeMapKey(resolved) !== normalizedFileKey &&
|
|
741
|
-
nodeFs.existsSync(resolved)) {
|
|
742
|
-
refFiles.add(normalizeMapKey(resolved));
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
catch {
|
|
747
|
-
// Timeout or LSP error — fall back to import-graph neighbors
|
|
748
|
-
}
|
|
749
|
-
if (Date.now() - refsStart > 1200)
|
|
750
|
-
break; // Hard ceiling
|
|
751
747
|
}
|
|
752
|
-
if (
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
748
|
+
else if (cachedReverseDeps && importChanges && importsChanged) {
|
|
749
|
+
reverseDepsIndex = patchReverseDependencyIndex(cachedReverseDeps.index, importChanges);
|
|
750
|
+
reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
|
|
751
|
+
cwd,
|
|
752
|
+
index: reverseDepsIndex,
|
|
753
|
+
dbg,
|
|
754
|
+
});
|
|
755
|
+
setReverseDepsEntry(workspaceKey, {
|
|
756
|
+
index: reverseDepsIndex,
|
|
757
|
+
savedToSnapshot: reverseDepsSaved,
|
|
758
|
+
generation: graph.buildGeneration,
|
|
759
|
+
}, false);
|
|
760
|
+
reverseDepsTimersToRelease.add(workspaceKey);
|
|
756
761
|
logCascade({
|
|
757
|
-
phase: "
|
|
762
|
+
phase: "reverse_deps_cache",
|
|
758
763
|
filePath,
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
+
durationMs: Date.now() - graphStart,
|
|
765
|
+
metadata: {
|
|
766
|
+
action: "patched_import_changes",
|
|
767
|
+
changedFileCount: importChanges.length,
|
|
768
|
+
savedToSnapshot: reverseDepsSaved,
|
|
769
|
+
},
|
|
764
770
|
});
|
|
765
771
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
.
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
impact.neighborFiles = [...impact.neighborFiles, ...added];
|
|
772
|
+
else {
|
|
773
|
+
reverseDepsIndex = buildReverseDependencyIndexFromGraph({
|
|
774
|
+
cwd,
|
|
775
|
+
graph,
|
|
776
|
+
});
|
|
777
|
+
reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
|
|
778
|
+
cwd,
|
|
779
|
+
index: reverseDepsIndex,
|
|
780
|
+
dbg,
|
|
781
|
+
});
|
|
782
|
+
setReverseDepsEntry(workspaceKey, {
|
|
783
|
+
index: reverseDepsIndex,
|
|
784
|
+
savedToSnapshot: reverseDepsSaved,
|
|
785
|
+
generation: graph.buildGeneration,
|
|
786
|
+
}, false);
|
|
787
|
+
reverseDepsTimersToRelease.add(workspaceKey);
|
|
783
788
|
logCascade({
|
|
784
|
-
phase: "
|
|
789
|
+
phase: "reverse_deps_cache",
|
|
785
790
|
filePath,
|
|
786
|
-
|
|
787
|
-
diagnosticCount: added.length,
|
|
788
|
-
autoPropagate: false,
|
|
791
|
+
durationMs: Date.now() - graphStart,
|
|
789
792
|
metadata: {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
793
|
+
action: "refresh_from_review_graph",
|
|
794
|
+
savedToSnapshot: reverseDepsSaved,
|
|
795
|
+
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
|
|
796
|
+
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
|
|
797
|
+
importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
|
|
794
798
|
},
|
|
795
799
|
});
|
|
796
800
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
801
|
+
// Count files represented in the graph (nodes with a filePath).
|
|
802
|
+
const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
|
|
803
|
+
logCascade({
|
|
804
|
+
phase: "graph_build",
|
|
805
|
+
filePath,
|
|
806
|
+
graphBuiltMs: graphMs,
|
|
807
|
+
graphReused: graphBuildInfo.reused,
|
|
808
|
+
graphNodeCount: graph.nodes.size,
|
|
809
|
+
graphFileCount,
|
|
810
|
+
graphChangedSymbolCount: (graph.changedSymbolsByFile.get(normalizedFileKey) ?? []).length,
|
|
811
|
+
metadata: {
|
|
812
|
+
graphBuildMode: graphBuildInfo.mode,
|
|
813
|
+
skipReason: graphBuildInfo.skipReason,
|
|
814
|
+
sourceFileCount: graphBuildInfo.sourceFileCount,
|
|
815
|
+
maxFileCount: graphBuildInfo.maxFileCount,
|
|
816
|
+
// #451: when the seq fast path fell back (or was skipped), why — so
|
|
817
|
+
// cascade.log surfaces the fast-path hit/miss rate.
|
|
818
|
+
seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
impact = computeImpactCascade(graph, normalizedFile, cwd);
|
|
822
|
+
// #1023: buildOrUpdateGraph returns an EMPTY graph (seeded only with the
|
|
823
|
+
// changed file's own symbols) when the repo is over
|
|
824
|
+
// PI_LENS_REVIEW_GRAPH_MAX_FILES (`too_many_files`) or the root is unsafe
|
|
825
|
+
// (`unsafe_root`) — both stamp mode "skipped" on the already-read
|
|
826
|
+
// graphBuildInfo. A capped or entry-budget-truncated graph is also not a
|
|
827
|
+
// complete dependent set even though it has nodes, so it must not look like
|
|
828
|
+
// a clean leaf. Thread the ALREADY-KNOWN degraded state (never re-derived)
|
|
829
|
+
// onto the result so the turn-end seam surfaces an honest advisory instead
|
|
830
|
+
// of a silent all-clear (#533). Keyed strictly off the graph's explicit
|
|
831
|
+
// degraded marker, NOT off `neighborFiles.length === 0`.
|
|
832
|
+
if ((graphBuildInfo.mode === "skipped" ||
|
|
833
|
+
graph.persistCoverage?.partial === true ||
|
|
834
|
+
!graphBuildInfoTrustworthy) &&
|
|
835
|
+
!impact.indeterminate) {
|
|
836
|
+
const coverage = graph.persistCoverage;
|
|
837
|
+
impact.indeterminate = {
|
|
838
|
+
reason: "graph_degraded",
|
|
839
|
+
detail: !graphBuildInfoTrustworthy
|
|
840
|
+
? // #1179 fail-closed: the graph's own build-info was not found under
|
|
841
|
+
// its identity and the global slot may be a sibling build's — don't
|
|
842
|
+
// trust its mode. Surface an honest "unknown" advisory rather than a
|
|
843
|
+
// (possibly sibling-derived) all-clear.
|
|
844
|
+
"review graph coverage unknown — build state unavailable for this graph"
|
|
845
|
+
: graphBuildInfo.mode === "skipped"
|
|
846
|
+
? graphBuildInfo.skipReason === "too_many_files"
|
|
847
|
+
? graphBuildInfo.sourceFileCountTruncated
|
|
848
|
+
? `review graph disabled — more than ${graphBuildInfo.maxFileCount ?? "?"} files (cap ${graphBuildInfo.maxFileCount ?? "?"})`
|
|
849
|
+
: `review graph disabled — ${graphBuildInfo.sourceFileCount ?? "?"} files over the ${graphBuildInfo.maxFileCount ?? "?"} cap`
|
|
850
|
+
: graphBuildInfo.skipReason === "unsafe_root"
|
|
851
|
+
? "review graph skipped — workspace root is at/above home dir"
|
|
852
|
+
: `review graph unavailable (${graphBuildInfo.skipReason ?? "skipped"})`
|
|
853
|
+
: coverage?.sourceFilesTruncated
|
|
854
|
+
? "review graph partial — source walk stopped at its visited-entry budget"
|
|
855
|
+
: "review graph partial — persisted graph coverage is incomplete",
|
|
856
|
+
sourceFileCount: graphBuildInfo.sourceFileCount,
|
|
857
|
+
maxFileCount: graphBuildInfo.maxFileCount,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
const reverseDepNeighbors = getAffectedFilesFromIndex(reverseDepsIndex, normalizedFile, 1, MAX_FILES * 2);
|
|
861
|
+
logCascade({
|
|
862
|
+
phase: "reverse_deps_cache",
|
|
863
|
+
filePath,
|
|
864
|
+
metadata: {
|
|
865
|
+
action: "merge_neighbors",
|
|
866
|
+
depth: 1,
|
|
867
|
+
neighborCount: reverseDepNeighbors.length,
|
|
868
|
+
neighbors: reverseDepNeighbors.slice(0, 10),
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
if (reverseDepNeighbors.length > 0) {
|
|
872
|
+
impact.directImporters = [
|
|
873
|
+
...new Set([...impact.directImporters, ...reverseDepNeighbors]),
|
|
874
|
+
];
|
|
875
|
+
impact.neighborFiles = [
|
|
876
|
+
...new Set([...impact.neighborFiles, ...reverseDepNeighbors]),
|
|
877
|
+
];
|
|
872
878
|
logCascade({
|
|
873
879
|
phase: "neighbor_snapshot",
|
|
874
880
|
filePath,
|
|
875
|
-
neighborFile:
|
|
876
|
-
diagnosticCount:
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
881
|
+
neighborFile: "[reverse-deps-cache]",
|
|
882
|
+
diagnosticCount: reverseDepNeighbors.length,
|
|
883
|
+
autoPropagate: false,
|
|
884
|
+
metadata: { reverseDepsCache: true },
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
// Symbol-level blast radius via LSP references (precision upgrade over
|
|
888
|
+
// file-level import edges). Only when changed symbols are detected.
|
|
889
|
+
// Keep the budget tight: 750ms per symbol, 1200ms total, max 3 symbols.
|
|
890
|
+
if (impact.changedSymbols.length > 0) {
|
|
891
|
+
const lspService = getLSPService();
|
|
892
|
+
const symbolNodeIds = graph.symbolNodesByFile.get(normalizedFileKey) ?? [];
|
|
893
|
+
const refFiles = new Set();
|
|
894
|
+
const refsStart = Date.now();
|
|
895
|
+
for (const symbolName of impact.changedSymbols.slice(0, 3)) {
|
|
896
|
+
const symbolNodeId = symbolNodeIds.find((id) => {
|
|
897
|
+
const node = graph.nodes.get(id);
|
|
898
|
+
return node?.symbolName === symbolName;
|
|
899
|
+
});
|
|
900
|
+
if (!symbolNodeId)
|
|
901
|
+
continue;
|
|
902
|
+
const node = graph.nodes.get(symbolNodeId);
|
|
903
|
+
const line = Number(node?.metadata?.line ?? 0);
|
|
904
|
+
const column = Number(node?.metadata?.column ?? 0);
|
|
905
|
+
if (line <= 0)
|
|
906
|
+
continue;
|
|
907
|
+
// #1109: store the timer and clear it once the race settles. Without
|
|
908
|
+
// this, when `references()` wins (the common case), the losing
|
|
909
|
+
// `setTimeout` stays a REF'D pending timer for the remaining 750ms —
|
|
910
|
+
// harmless in a long-lived session, but a keep-alive tail in a
|
|
911
|
+
// one-shot `pi --print` process (same uncleared-race-timeout class
|
|
912
|
+
// fixed for the LSP client-wait leak in clients/lsp/index.ts, #1097).
|
|
913
|
+
let refsTimer;
|
|
914
|
+
try {
|
|
915
|
+
const refs = await Promise.race([
|
|
916
|
+
lspService.references(normalizedFile, line - 1, column - 1, false),
|
|
917
|
+
new Promise((_, reject) => {
|
|
918
|
+
refsTimer = setTimeout(() => reject(new Error("timeout")), 750);
|
|
919
|
+
}),
|
|
920
|
+
]);
|
|
921
|
+
for (const ref of refs) {
|
|
922
|
+
let resolved;
|
|
923
|
+
try {
|
|
924
|
+
resolved = ref.uri.startsWith("file://")
|
|
925
|
+
? fileURLToPath(ref.uri)
|
|
926
|
+
: ref.uri;
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (normalizeMapKey(resolved) !== normalizedFileKey &&
|
|
932
|
+
nodeFs.existsSync(resolved)) {
|
|
933
|
+
refFiles.add(normalizeMapKey(resolved));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
// Timeout or LSP error — fall back to import-graph neighbors
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
if (refsTimer !== undefined)
|
|
942
|
+
clearTimeout(refsTimer);
|
|
943
|
+
}
|
|
944
|
+
if (Date.now() - refsStart > 1200)
|
|
945
|
+
break; // Hard ceiling
|
|
946
|
+
}
|
|
947
|
+
if (refFiles.size > 0) {
|
|
948
|
+
impact.neighborFiles = [
|
|
949
|
+
...new Set([...impact.neighborFiles, ...refFiles]),
|
|
950
|
+
];
|
|
951
|
+
logCascade({
|
|
952
|
+
phase: "neighbor_snapshot",
|
|
953
|
+
filePath,
|
|
954
|
+
neighborFile: "[lsp-references]",
|
|
955
|
+
diagnosticCount: refFiles.size,
|
|
956
|
+
durationMs: Date.now() - refsStart,
|
|
957
|
+
autoPropagate: false,
|
|
958
|
+
metadata: { lspReferences: true },
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Bounded transitive expansion: add depth>1 dependents (indirect
|
|
963
|
+
// importers/callers/referencers) so the blast radius isn't limited to one
|
|
964
|
+
// hop. The one-hop sets above remain the floor (they sort first); these
|
|
965
|
+
// fill the remaining budget. Graph BFS is in-memory + capped.
|
|
966
|
+
if (CASCADE_TRANSITIVE_DEPTH > 1) {
|
|
967
|
+
const transitive = computeTransitiveImpact(graph, normalizedFile, {
|
|
968
|
+
maxDepth: CASCADE_TRANSITIVE_DEPTH,
|
|
969
|
+
maxHits: CASCADE_NEIGHBOUR_BUDGET,
|
|
882
970
|
});
|
|
883
|
-
|
|
884
|
-
|
|
971
|
+
const added = [
|
|
972
|
+
...new Set(transitive.hits
|
|
973
|
+
.map((hit) => hit.file)
|
|
974
|
+
.filter((file) => file && normalizeMapKey(file) !== normalizedFileKey)),
|
|
975
|
+
].filter((file) => !impact.neighborFiles.includes(file));
|
|
976
|
+
if (added.length > 0) {
|
|
977
|
+
impact.neighborFiles = [...impact.neighborFiles, ...added];
|
|
978
|
+
logCascade({
|
|
979
|
+
phase: "neighbor_snapshot",
|
|
980
|
+
filePath,
|
|
981
|
+
neighborFile: "[transitive-impact]",
|
|
982
|
+
diagnosticCount: added.length,
|
|
983
|
+
autoPropagate: false,
|
|
984
|
+
metadata: {
|
|
985
|
+
transitive: true,
|
|
986
|
+
maxDepth: CASCADE_TRANSITIVE_DEPTH,
|
|
987
|
+
maxDepthReached: transitive.maxDepthReached,
|
|
988
|
+
truncated: transitive.truncated,
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
}
|
|
885
992
|
}
|
|
886
|
-
// #
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
993
|
+
// #1080: exclude KNOWN test-role files from every collateral impact
|
|
994
|
+
// surface — the formatted header (formatImpactCascade reads `impact`
|
|
995
|
+
// directly for `Direct importers`/`Direct callers`/`Check next` counts and
|
|
996
|
+
// names), the active-touch/passive-snapshot neighbor set (sortedNeighbors is
|
|
997
|
+
// derived from `impact.neighborFiles` below), and the returned `impact`
|
|
998
|
+
// object. Applied HERE — after graph neighbors, reverse-deps, LSP reference
|
|
999
|
+
// expansion, and transitive expansion have all been merged in — so it covers
|
|
1000
|
+
// every neighbor source (incl. module-level downstream files that entered via
|
|
1001
|
+
// computeImpactCascade and reference URIs pointing at `*.test.*`). Filtering
|
|
1002
|
+
// upstream of `sortedNeighbors` also means a test URI is never actively
|
|
1003
|
+
// touched solely for cascade diagnostics. Composes the shared `detectFileRole`
|
|
1004
|
+
// seam; a classifier failure RETAINS the candidate (honest — never a false
|
|
1005
|
+
// clean). The project ignore filter below is separate and unchanged (#297).
|
|
1006
|
+
impact.directImporters = impact.directImporters.filter((f) => !isTestRoleCollateral(f));
|
|
1007
|
+
impact.directCallers = impact.directCallers.filter((f) => !isTestRoleCollateral(f));
|
|
1008
|
+
impact.neighborFiles = impact.neighborFiles.filter((f) => !isTestRoleCollateral(f));
|
|
1009
|
+
// Sort by relationship strength (B6) then cap to the neighbour budget.
|
|
1010
|
+
// directImporters are most impactful, then callers, then reference edges.
|
|
1011
|
+
importerSet = new Set(impact.directImporters);
|
|
1012
|
+
callerSet = new Set(impact.directCallers);
|
|
1013
|
+
// neighbors that are neither direct importers nor callers are reference-edge neighbors
|
|
1014
|
+
const importerOrCallerSet = new Set([
|
|
1015
|
+
...impact.directImporters,
|
|
1016
|
+
...impact.directCallers,
|
|
1017
|
+
]);
|
|
1018
|
+
referenceCount = impact.neighborFiles.filter((n) => !importerOrCallerSet.has(n)).length;
|
|
1019
|
+
const eligibleNeighbors = [...impact.neighborFiles]
|
|
1020
|
+
.filter((n) => nodeFs.existsSync(n))
|
|
1021
|
+
.filter((n) => !isExternalOrVendorFile(n, cwd))
|
|
1022
|
+
// Honour the project's ignore config: a user-ignored neighbour (e.g.
|
|
1023
|
+
// `**/*.test.ts`) must not surface as collateral cascade noise (#297).
|
|
1024
|
+
.filter((n) => !isIgnoredCascadeNeighbor(n, cwd))
|
|
1025
|
+
// B10: exclude files already edited as primary this turn — their own pipeline
|
|
1026
|
+
// run is the authoritative diagnostic source; showing them as neighbors is noise.
|
|
1027
|
+
.filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
|
|
1028
|
+
.sort((a, b) => {
|
|
1029
|
+
const rank = (p) => importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
|
|
1030
|
+
return rank(a) - rank(b);
|
|
1031
|
+
});
|
|
1032
|
+
cascadeBudgetTruncated = Math.max(0, eligibleNeighbors.length - CASCADE_NEIGHBOUR_BUDGET);
|
|
1033
|
+
sortedNeighbors = eligibleNeighbors.slice(0, CASCADE_NEIGHBOUR_BUDGET);
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
895
1036
|
logCascade({
|
|
896
|
-
phase: "
|
|
1037
|
+
phase: "cascade_skip",
|
|
897
1038
|
filePath,
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
durationMs,
|
|
901
|
-
autoPropagate: true,
|
|
902
|
-
snapshotMissing: false,
|
|
903
|
-
snapshotAgeSec,
|
|
904
|
-
});
|
|
905
|
-
neighbors.push({
|
|
906
|
-
filePath: neighborPath,
|
|
907
|
-
reason: neighborReason(importerSet, callerSet, neighborPath),
|
|
908
|
-
diagnostics: diags,
|
|
909
|
-
lspTouched: false,
|
|
910
|
-
durationMs,
|
|
1039
|
+
reason: "unsupported_graph_kind",
|
|
1040
|
+
metadata: { fileKind },
|
|
911
1041
|
});
|
|
1042
|
+
return {
|
|
1043
|
+
filePath,
|
|
1044
|
+
result: undefined,
|
|
1045
|
+
neighborCount: 0,
|
|
1046
|
+
diagnosticCount: 0,
|
|
1047
|
+
skipReason: "non_code",
|
|
1048
|
+
};
|
|
912
1049
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1050
|
+
logCascade({
|
|
1051
|
+
phase: "neighbors_computed",
|
|
1052
|
+
filePath,
|
|
1053
|
+
neighborCount: sortedNeighbors.length,
|
|
1054
|
+
totalNeighborCount: impact.neighborFiles.length,
|
|
1055
|
+
importerCount: impact.directImporters.length,
|
|
1056
|
+
callerCount: impact.directCallers.length,
|
|
1057
|
+
referenceCount: Math.max(0, referenceCount),
|
|
1058
|
+
riskFlags: impact.riskFlags,
|
|
1059
|
+
metadata: { neighbors: sortedNeighbors.slice(0, 10) },
|
|
1060
|
+
});
|
|
1061
|
+
const lspService = getLSPService();
|
|
1062
|
+
// Hoist passive snapshot once — used for auto-propagating LSPs and fallback path.
|
|
1063
|
+
// #1095 memo-freeze caveat: each getAllDiagnostics() result attaches `.binding`
|
|
1064
|
+
// as a LAZY getter that memoizes its disk verdict PER RESULT OBJECT. This cascade
|
|
1065
|
+
// re-calls getAllDiagnostics() every run and never retains the Map across turns,
|
|
1066
|
+
// so every run reads a FRESH verdict against current disk. If cross-turn retention
|
|
1067
|
+
// of this Map is ever introduced, the memoized binding would freeze stale — re-read
|
|
1068
|
+
// a fresh getAllDiagnostics() result at binding-read time instead of caching it.
|
|
1069
|
+
const allDiags = await lspService.getAllDiagnostics();
|
|
1070
|
+
const neighbors = [];
|
|
1071
|
+
let producedLspData = false;
|
|
1072
|
+
let coldSnapshotPaths = [];
|
|
1073
|
+
// #1104: did any DEGRADED-fallback display path (touch-error fallback,
|
|
1074
|
+
// appendFallbackNeighbors) withhold a TTL-fresh entry solely because its
|
|
1075
|
+
// content binding was rejected (`boundToCurrentDisk === false` — computed
|
|
1076
|
+
// against a diverged/pre-fix-edit disk state)? Tracked separately from
|
|
1077
|
+
// `producedLspData` so the HONESTY check below can tell "genuinely nothing to
|
|
1078
|
+
// show" apart from "something existed but was untrustworthy and was hidden" —
|
|
1079
|
+
// the latter must not collapse into a clean-looking result (#1104 honesty
|
|
1080
|
+
// rule, same doctrine as #1023's graph-degraded indeterminate marker).
|
|
1081
|
+
let fallbackBindingRejected = false;
|
|
1082
|
+
// #1444: neighbours whose in-lane wait was skipped for the quiet-window
|
|
1083
|
+
// reconcile to answer later. Logged on `cascade_result` so a cascade that
|
|
1084
|
+
// deferred EVERY neighbour is distinguishable from a genuine leaf (both are
|
|
1085
|
+
// `neighborCount: 0` with no output otherwise).
|
|
1086
|
+
let collectLaterSkipped = 0;
|
|
1087
|
+
// #1446 item 5: `recentlyCleanNeighborCache` and `neighborTouchCache` hits
|
|
1088
|
+
// are the whole point of both caches, but neither was ever counted — the
|
|
1089
|
+
// only visible signal was 267s/day of touch wall time with no way to tell
|
|
1090
|
+
// whether the caches were absorbing repeat work or every touch was cold.
|
|
1091
|
+
let recentlyCleanHits = 0;
|
|
1092
|
+
let cacheHits = 0;
|
|
1093
|
+
// F1 (#1446 follow-up): `coldTouches` must be counted at the point each
|
|
1094
|
+
// neighbour's OUTCOME is actually known, not derived from `coldSnapshotPaths`
|
|
1095
|
+
// (finalized earlier, before the cache-hit checks below run against it). Using
|
|
1096
|
+
// the pre-outcome list let a neighbour double-count (cold-snapshot AND cache/
|
|
1097
|
+
// recently-clean hit) or vanish from every bucket (an `activePaths` neighbour —
|
|
1098
|
+
// e.g. Python/Go — that misses both caches). These four counters partition the
|
|
1099
|
+
// touched-neighbour set `[...activePaths, ...coldSnapshotPaths]` exactly once
|
|
1100
|
+
// each: a neighbour with no LSP server configured is the only outcome
|
|
1101
|
+
// deliberately excluded (never attempted, no bucket).
|
|
1102
|
+
let deferredTouches = 0;
|
|
1103
|
+
let coldTouches = 0;
|
|
1104
|
+
if (sortedNeighbors.length > 0) {
|
|
1105
|
+
const snapshotPaths = sortedNeighbors.filter(shouldReadCascadeFromSnapshot);
|
|
1106
|
+
const activePaths = sortedNeighbors.filter((n) => !shouldReadCascadeFromSnapshot(n));
|
|
1107
|
+
// Auto-propagating LSPs (TypeScript/Deno) — read passive snapshot with normalized key.
|
|
1108
|
+
// When the snapshot is valid, use it immediately (no touch needed — server already has
|
|
1109
|
+
// fresh data from auto-propagation). When missing or stale, fall through to the active
|
|
1110
|
+
// touch pool below so we get real diagnostics instead of silently returning zero.
|
|
1111
|
+
coldSnapshotPaths = [];
|
|
1112
|
+
for (const neighborPath of snapshotPaths) {
|
|
1113
|
+
const neighborStart = Date.now();
|
|
1114
|
+
const entry = allDiags.get(normalizeMapKey(neighborPath));
|
|
1115
|
+
const snapshotAgeSec = entry
|
|
1116
|
+
? Math.round((Date.now() - entry.ts) / 1000)
|
|
1117
|
+
: undefined;
|
|
1118
|
+
const ttlFresh = entry != null && Date.now() - entry.ts < CASCADE_TTL_MS;
|
|
1119
|
+
// #1095: content binding is the INNER gate; TTL stays the outer bound.
|
|
1120
|
+
// false → the server's diagnostics were computed against a DIFFERENT
|
|
1121
|
+
// disk state (e.g. the PRE-fix content) — don't trust or
|
|
1122
|
+
// reconcile this snapshot; fall through to an active touch on
|
|
1123
|
+
// the same (cold-snapshot) budget as a TTL-stale entry. This
|
|
1124
|
+
// kills the window where the first cascade after a fix-edit
|
|
1125
|
+
// replays the neighbor's pre-fix snapshot.
|
|
1126
|
+
// "unknown" → version-less/unreadable: keep EXACTLY the pre-#1095 TTL-only
|
|
1127
|
+
// behavior (reconcile if TTL-fresh).
|
|
1128
|
+
// true → bound to current disk: reconcile (TTL still the outer bound).
|
|
1129
|
+
// Reading `.binding` triggers the lazy disk verify on the getAllDiagnostics
|
|
1130
|
+
// result — done ONLY when TTL-fresh so a doomed (stale) entry never pays the
|
|
1131
|
+
// stat+hash.
|
|
1132
|
+
const boundToDisk = ttlFresh
|
|
1133
|
+
? readBoundToCurrentDisk(entry)
|
|
1134
|
+
: undefined;
|
|
1135
|
+
const bindingRejected = boundToDisk === false;
|
|
1136
|
+
const snapshotValid = ttlFresh && !bindingRejected;
|
|
1137
|
+
if (!snapshotValid) {
|
|
1138
|
+
// No usable snapshot — queue for active touch alongside non-jsts neighbors.
|
|
1139
|
+
logCascade({
|
|
1140
|
+
phase: "neighbor_snapshot",
|
|
1141
|
+
filePath,
|
|
1142
|
+
neighborFile: neighborPath,
|
|
1143
|
+
diagnosticCount: 0,
|
|
1144
|
+
durationMs: Date.now() - neighborStart,
|
|
1145
|
+
autoPropagate: true,
|
|
1146
|
+
snapshotMissing: entry == null,
|
|
1147
|
+
snapshotAgeSec,
|
|
1148
|
+
coldSnapshot: true,
|
|
1149
|
+
// #1095: distinguish a binding-rejected fall-through (TTL-fresh but the
|
|
1150
|
+
// server's view diverged from disk) from a plain TTL-stale/missing one.
|
|
1151
|
+
...(bindingRejected && {
|
|
1152
|
+
metadata: { bindingState: bindingStateLabel(boundToDisk) },
|
|
1153
|
+
}),
|
|
1154
|
+
});
|
|
1155
|
+
coldSnapshotPaths.push(neighborPath);
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
// #692: `source: "cascade"` used to be passed here to label `rule`
|
|
1159
|
+
// (`cascade:<code>`) — that override is gone (identity must come from
|
|
1160
|
+
// the diagnostic's own source; see `scanOrigin`'s doc comment), and
|
|
1161
|
+
// cascade neighbor diagnostics are ephemeral display-only output
|
|
1162
|
+
// (never reconciled into persisted widget/dedup state), so the label
|
|
1163
|
+
// had no remaining purpose and is simply dropped rather than migrated.
|
|
1164
|
+
const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
|
|
929
1165
|
producedLspData = true;
|
|
930
1166
|
const durationMs = Date.now() - neighborStart;
|
|
931
1167
|
logCascade({
|
|
932
1168
|
phase: "neighbor_snapshot",
|
|
933
1169
|
filePath,
|
|
934
1170
|
neighborFile: neighborPath,
|
|
935
|
-
diagnosticCount:
|
|
1171
|
+
diagnosticCount: diags.length,
|
|
936
1172
|
durationMs,
|
|
937
|
-
autoPropagate:
|
|
1173
|
+
autoPropagate: true,
|
|
938
1174
|
snapshotMissing: false,
|
|
939
|
-
|
|
940
|
-
recentlyClean: true,
|
|
941
|
-
cleanTurnSeq: recentlyClean.turnSeq,
|
|
942
|
-
},
|
|
1175
|
+
snapshotAgeSec,
|
|
943
1176
|
});
|
|
944
|
-
|
|
1177
|
+
// #1093: a valid passive snapshot IS a confirmed observation of this
|
|
1178
|
+
// neighbor's current LSP-error state (#571 semantics) — reconcile it into
|
|
1179
|
+
// the footer widget, INCLUDING the confirmed-clean `[]` case, so a
|
|
1180
|
+
// fix-edit to the primary that resolves a cross-file error in this
|
|
1181
|
+
// neighbor clears the neighbor's now-stale footer entry (the #1092
|
|
1182
|
+
// defect). MERGE (genuine LSP errors only — auxiliary findings excluded,
|
|
1183
|
+
// see `cascadeReconcilableLspErrors`) so a live biome/ruff/aux finding or
|
|
1184
|
+
// LSP warning on the neighbor is preserved. Keyed by the primary edit's
|
|
1185
|
+
// `writeSeq` so a genuinely newer per-edit write still wins the
|
|
1186
|
+
// WriteOrderingGuard. `observedAt = entry.ts` (the snapshot's own publish
|
|
1187
|
+
// time, up to CASCADE_TTL_MS old) — NOT now() — so replaying an aging
|
|
1188
|
+
// snapshot never re-arms the mtime-staleness gate (the same #1092
|
|
1189
|
+
// re-arming defect this PR fixes for cache hits).
|
|
1190
|
+
//
|
|
1191
|
+
// #1186: `observedAt` here stamps only the INCOMING LSP-error entries.
|
|
1192
|
+
// PRESERVED entries keep their own (possibly fresher) per-entry
|
|
1193
|
+
// `observedAt`, and `reconcileStaleWidgetFiles` now gates per ENTRY — so
|
|
1194
|
+
// if the neighbor's mtime later falls between this `entry.ts` and a
|
|
1195
|
+
// preserved entry's real observation time, only the stale incoming entry
|
|
1196
|
+
// drops and the fresher preserved finding survives (previously the whole
|
|
1197
|
+
// record was over-cleared; that residual is now fixed).
|
|
1198
|
+
reconcileCascadeNeighborLspErrors(neighborPath, cascadeReconcilableLspErrors(entry.diags, neighborPath), writeSeq, entry.ts);
|
|
1199
|
+
neighbors.push({
|
|
945
1200
|
filePath: neighborPath,
|
|
946
1201
|
reason: neighborReason(importerSet, callerSet, neighborPath),
|
|
947
|
-
diagnostics:
|
|
1202
|
+
diagnostics: diags,
|
|
948
1203
|
lspTouched: false,
|
|
949
1204
|
durationMs,
|
|
950
|
-
};
|
|
1205
|
+
});
|
|
951
1206
|
}
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1207
|
+
// fan-out active touches in parallel (A3):
|
|
1208
|
+
// - non-jsts neighbors (always touched)
|
|
1209
|
+
// - autoPropagate neighbors whose snapshot was missing/stale (coldSnapshotPaths)
|
|
1210
|
+
// use a tighter 1000ms budget since the server is expected to be warm already.
|
|
1211
|
+
const touchResults = await Promise.allSettled([...activePaths, ...coldSnapshotPaths].map(async (neighborPath) => {
|
|
1212
|
+
const isColdSnapshot = coldSnapshotPaths.includes(neighborPath);
|
|
1213
|
+
const neighborStart = Date.now();
|
|
1214
|
+
const cacheKey = normalizeMapKey(neighborPath);
|
|
1215
|
+
const passiveEntry = allDiags.get(cacheKey);
|
|
1216
|
+
const hasFreshPassiveErrors = passiveEntry != null &&
|
|
1217
|
+
Date.now() - passiveEntry.ts < CASCADE_TTL_MS &&
|
|
1218
|
+
passiveEntry.diags.some((d) => d.severity === 1);
|
|
1219
|
+
const recentlyClean = recentlyCleanNeighborCache.get(cacheKey);
|
|
1220
|
+
if (recentlyClean &&
|
|
1221
|
+
turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
|
|
1222
|
+
!hasFreshPassiveErrors) {
|
|
1223
|
+
producedLspData = true;
|
|
1224
|
+
recentlyCleanHits++;
|
|
1225
|
+
const durationMs = Date.now() - neighborStart;
|
|
1226
|
+
logCascade({
|
|
1227
|
+
phase: "neighbor_snapshot",
|
|
1228
|
+
filePath,
|
|
1229
|
+
neighborFile: neighborPath,
|
|
1230
|
+
diagnosticCount: 0,
|
|
1231
|
+
durationMs,
|
|
1232
|
+
autoPropagate: false,
|
|
1233
|
+
snapshotMissing: false,
|
|
1234
|
+
metadata: {
|
|
1235
|
+
recentlyClean: true,
|
|
1236
|
+
cleanTurnSeq: recentlyClean.turnSeq,
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
return {
|
|
1240
|
+
filePath: neighborPath,
|
|
1241
|
+
reason: neighborReason(importerSet, callerSet, neighborPath),
|
|
1242
|
+
diagnostics: [],
|
|
1243
|
+
lspTouched: false,
|
|
1244
|
+
durationMs,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
// A5: skip re-touch if this neighbor was already diagnosed at the current
|
|
1248
|
+
// write sequence. A new write (higher writeSeq) invalidates the cache entry.
|
|
1249
|
+
const cached = writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
|
|
1250
|
+
if (cached?.turnSeq === turnSeq && cached?.writeSeq === writeSeq) {
|
|
1251
|
+
producedLspData = true;
|
|
1252
|
+
cacheHits++;
|
|
1253
|
+
const durationMs = Date.now() - neighborStart;
|
|
1254
|
+
logCascade({
|
|
1255
|
+
phase: "neighbor_snapshot",
|
|
1256
|
+
filePath,
|
|
1257
|
+
neighborFile: neighborPath,
|
|
1258
|
+
diagnosticCount: cached.diagnostics.length,
|
|
1259
|
+
durationMs,
|
|
1260
|
+
autoPropagate: false,
|
|
1261
|
+
snapshotMissing: false,
|
|
1262
|
+
metadata: { cachedWriteSeq: writeSeq },
|
|
1263
|
+
});
|
|
1264
|
+
return {
|
|
1265
|
+
filePath: neighborPath,
|
|
1266
|
+
reason: neighborReason(importerSet, callerSet, neighborPath),
|
|
1267
|
+
diagnostics: cached.diagnostics,
|
|
1268
|
+
lspTouched: false,
|
|
1269
|
+
durationMs,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
const configuredServerCount = getServersForFileWithConfig(neighborPath).length;
|
|
1273
|
+
if (configuredServerCount === 0) {
|
|
1274
|
+
logCascade({
|
|
1275
|
+
phase: "neighbor_fallback",
|
|
1276
|
+
filePath,
|
|
1277
|
+
neighborFile: neighborPath,
|
|
1278
|
+
fallbackUsed: false,
|
|
1279
|
+
error: "no_lsp_server_configured",
|
|
1280
|
+
});
|
|
1281
|
+
return undefined;
|
|
1282
|
+
}
|
|
1283
|
+
// A6: async read to avoid blocking event loop on network-mounted drives
|
|
1284
|
+
const content = await nodeFs.promises.readFile(neighborPath, "utf8");
|
|
1285
|
+
// #458/#1444: tier-aware cascade-lane wait. A Tier-3 silent server
|
|
1286
|
+
// cannot give this wait an affirmative clean signal. Native TS7 does
|
|
1287
|
+
// publish, but not inside the cold-snapshot budget. In both cases the
|
|
1288
|
+
// in-lane budget is pure cost. Fire the touch (didOpen/
|
|
1289
|
+
// didChange still happens — the server starts real work) and record
|
|
1290
|
+
// it as outstanding for the agent_settled quiet window to reconcile
|
|
1291
|
+
// instead of waiting here. Ambiguous/missing capability data always
|
|
1292
|
+
// classifies as "waits" (today's behavior) — see cascade-tier.ts.
|
|
1293
|
+
// The whole attempt is try/caught: any surprise (a service shape
|
|
1294
|
+
// that doesn't expose getCapabilitySnapshots/getClientForFile, a
|
|
1295
|
+
// thrown rejection) falls through to the existing full-wait path
|
|
1296
|
+
// below rather than skip the wait on a failure.
|
|
1297
|
+
if (isTierAwareCascadeEnabled()) {
|
|
1298
|
+
try {
|
|
1299
|
+
const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
|
|
1300
|
+
const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
|
|
1301
|
+
if (tier === "tier3-silent" || tier === "collect-later") {
|
|
1302
|
+
const spawnedForTouch = await lspService.getClientForFile(neighborPath);
|
|
1303
|
+
if (spawnedForTouch) {
|
|
1304
|
+
// Sampled BEFORE the touchFile notify: a publish landing
|
|
1305
|
+
// in the notify→record gap must read as post-touch at
|
|
1306
|
+
// reconcile time, never be misclassified as pre-touch
|
|
1307
|
+
// (the reconcile compares this against the client's
|
|
1308
|
+
// PER-FILE publish timestamp — see cascade-tier.ts).
|
|
1309
|
+
const touchedAt = Date.now();
|
|
1310
|
+
await lspService.touchFile(neighborPath, content, {
|
|
1311
|
+
diagnostics: "none",
|
|
1312
|
+
collectDiagnostics: false,
|
|
1313
|
+
silent: true,
|
|
1314
|
+
source: "cascade",
|
|
1315
|
+
clientScope: "primary",
|
|
1316
|
+
});
|
|
1317
|
+
recordOutstandingCascadeTouch({
|
|
1318
|
+
filePath: neighborPath,
|
|
1319
|
+
serverId: spawnedForTouch.client.serverId,
|
|
1320
|
+
touchedAt,
|
|
1321
|
+
});
|
|
1322
|
+
const durationMs = Date.now() - neighborStart;
|
|
1323
|
+
if (tier === "collect-later")
|
|
1324
|
+
collectLaterSkipped++;
|
|
1325
|
+
// F1: both tier3-silent and collect-later skip the in-lane
|
|
1326
|
+
// wait and record an outstanding touch for the quiet-window
|
|
1327
|
+
// reconcile — neither a cache hit nor a genuine completed
|
|
1328
|
+
// cold touch, so both share this explicit "deferred" bucket
|
|
1329
|
+
// instead of falling out of the partition uncounted.
|
|
1330
|
+
deferredTouches++;
|
|
1331
|
+
logCascade({
|
|
1332
|
+
phase: "cascade_tier3_skip",
|
|
1333
|
+
filePath,
|
|
1334
|
+
neighborFile: neighborPath,
|
|
1335
|
+
durationMs,
|
|
1336
|
+
lspServerCount: configuredServerCount,
|
|
1337
|
+
coldSnapshot: isColdSnapshot,
|
|
1338
|
+
metadata: {
|
|
1339
|
+
serverId: spawnedForTouch.client.serverId,
|
|
1340
|
+
waitTier: tier,
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
// Deliberately NOT cached as clean/diagnosed — the wait was
|
|
1344
|
+
// skipped, not resolved, so neither neighborTouchCache nor
|
|
1345
|
+
// recentlyCleanNeighborCache may treat this as a real answer
|
|
1346
|
+
// (#240 doctrine). Return undefined: the degraded-fallback
|
|
1347
|
+
// path below still has a chance to surface a passive/stale
|
|
1348
|
+
// snapshot, same as any other "no fresh data this touch" case.
|
|
1349
|
+
return undefined;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch (tierErr) {
|
|
1354
|
+
dbg?.(`cascade tier-aware skip attempt failed for ${neighborPath}, falling back to full wait: ${tierErr}`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// Open with silent=true (suppresses didChangeWatchedFiles rechecks, C2)
|
|
1358
|
+
// and collect diagnostics from the same touched clients.
|
|
1359
|
+
// Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
|
|
1360
|
+
// 1000ms budget — they should respond quickly; we'd rather return zero
|
|
1361
|
+
// than block cascade for 2s on a slow open.
|
|
1362
|
+
// F1: this is the ONE remaining outcome after cache hit, recently-clean
|
|
1363
|
+
// hit, and tier-aware deferral have all been ruled out — a genuine
|
|
1364
|
+
// active LSP touch is being issued right now. Count it here (an
|
|
1365
|
+
// attempt, whether it resolves, times out, or the promise rejects
|
|
1366
|
+
// below in the allSettled catch) rather than from `coldSnapshotPaths`,
|
|
1367
|
+
// which is finalized before any of the above checks run and includes
|
|
1368
|
+
// neighbours that resolve via cache/recently-clean instead.
|
|
1369
|
+
coldTouches++;
|
|
1370
|
+
const rawDiags = await lspService.touchFile(neighborPath, content, {
|
|
1371
|
+
diagnostics: "document",
|
|
1372
|
+
collectDiagnostics: true,
|
|
1373
|
+
maxClientWaitMs: isColdSnapshot ? 1000 : 2000,
|
|
1374
|
+
silent: true,
|
|
1375
|
+
source: "cascade",
|
|
1376
|
+
clientScope: "all",
|
|
1377
|
+
});
|
|
1378
|
+
if (!rawDiags)
|
|
1379
|
+
return undefined;
|
|
1380
|
+
// #1093/#571/#1095: a touch result is only a CONFIRMED observation of the
|
|
1381
|
+
// neighbor's current on-disk state when it is neither `inconclusive` (the
|
|
1382
|
+
// notify/diagnostics wait lapsed — e.g. the tight 1000ms cold-snapshot
|
|
1383
|
+
// budget on a slow server) NOR bound-false (`binding.boundToCurrentDisk
|
|
1384
|
+
// === false` — the diagnostics were computed against a different disk state
|
|
1385
|
+
// than what's on disk now). Either disqualifier means a resolved `[]` is
|
|
1386
|
+
// NOT a confirmed clean: treating it as one would WIPE a live footer finding
|
|
1387
|
+
// (the #533 false-clean trap, worse than the stale-display bug). Both flags
|
|
1388
|
+
// are folded into `isConfirmedTouch` so no gate below can miss one. A
|
|
1389
|
+
// confirmed result reconciles and may seed the recently-clean cache; an
|
|
1390
|
+
// unconfirmed one does neither (else the short-circuit on the next cascade
|
|
1391
|
+
// would make the wipe self-sustain).
|
|
1392
|
+
const confirmed = isConfirmedTouch(rawDiags);
|
|
1393
|
+
const bindingRejected = readBoundToCurrentDisk(rawDiags) === false;
|
|
1394
|
+
const inconclusive = readInconclusive(rawDiags);
|
|
1395
|
+
// #1470: the third, independent reason a touch is unconfirmed — an
|
|
1396
|
+
// auxiliary our grace timer cut off. Logged alongside the other two so
|
|
1397
|
+
// cascade.log alone still tells the three apart.
|
|
1398
|
+
const unconfirmedServerIds = touchCoverageGap(rawDiags);
|
|
1399
|
+
// #692: `source: "cascade"` no longer overrides `rule` (see the
|
|
1400
|
+
// doc comment on the sibling call above) — dropped rather than
|
|
1401
|
+
// migrated to `scanOrigin` since cascade output never touches
|
|
1402
|
+
// persisted widget/dedup state.
|
|
1403
|
+
// #1179: `.filter()` here operates on `rawDiags.diags`; the
|
|
1404
|
+
// `inconclusive`/`binding` flags read above stay on the `rawDiags`
|
|
1405
|
+
// wrapper and are unaffected by this copy (the shape-5 fix).
|
|
1406
|
+
const diags = convertLspDiagnostics(rawDiags.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
|
|
957
1407
|
const durationMs = Date.now() - neighborStart;
|
|
1408
|
+
// Cache only a confirmed answer. An inconclusive or binding-rejected
|
|
1409
|
+
// result must not become a confirmed cache hit on the next cascade.
|
|
1410
|
+
if (writeSeq != null && confirmed) {
|
|
1411
|
+
neighborTouchCache.set(cacheKey, {
|
|
1412
|
+
turnSeq,
|
|
1413
|
+
writeSeq,
|
|
1414
|
+
diagnostics: diags,
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
if (diags.length === 0) {
|
|
1418
|
+
// Only a CONFIRMED clean touch may seed the recently-clean cache
|
|
1419
|
+
// (#1095: a bound-false touch is unconfirmed, exactly like inconclusive).
|
|
1420
|
+
if (confirmed) {
|
|
1421
|
+
recentlyCleanNeighborCache.set(cacheKey, {
|
|
1422
|
+
turnSeq,
|
|
1423
|
+
checkedAt: Date.now(),
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
recentlyCleanNeighborCache.delete(cacheKey);
|
|
1429
|
+
}
|
|
1430
|
+
producedLspData = true;
|
|
958
1431
|
logCascade({
|
|
959
|
-
phase: "
|
|
1432
|
+
phase: "neighbor_touch",
|
|
960
1433
|
filePath,
|
|
961
1434
|
neighborFile: neighborPath,
|
|
962
|
-
diagnosticCount:
|
|
1435
|
+
diagnosticCount: diags.length,
|
|
963
1436
|
durationMs,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1437
|
+
lspTouched: true,
|
|
1438
|
+
lspServerCount: configuredServerCount,
|
|
1439
|
+
coldSnapshot: isColdSnapshot,
|
|
1440
|
+
// #1104: an unconfirmed touch has two independent, otherwise
|
|
1441
|
+
// indistinguishable causes — `inconclusive` (the notify/diagnostics
|
|
1442
|
+
// wait lapsed its deadline) and bound-false (`bindingState`, #1095 —
|
|
1443
|
+
// diagnostics computed against a diverged disk state). Surface
|
|
1444
|
+
// `inconclusive` unconditionally so cascade.log alone (no
|
|
1445
|
+
// cross-referencing latency.log) tells them apart; `bindingState`
|
|
1446
|
+
// stays conditional since "bound" carries no extra signal.
|
|
1447
|
+
metadata: {
|
|
1448
|
+
inconclusive,
|
|
1449
|
+
...(bindingRejected && { bindingState: bindingStateLabel(false) }),
|
|
1450
|
+
...(unconfirmedServerIds.length > 0 && {
|
|
1451
|
+
unconfirmedServerIds: [...unconfirmedServerIds],
|
|
1452
|
+
}),
|
|
1453
|
+
},
|
|
967
1454
|
});
|
|
1455
|
+
// #1093/#1095: a completed, CONFIRMED active touch is a confirmed
|
|
1456
|
+
// observation of this neighbor's current LSP-error state (#571) —
|
|
1457
|
+
// reconcile it into the footer widget, INCLUDING the confirmed-clean `[]`
|
|
1458
|
+
// case, so a fix-edit to the primary that resolves a cross-file error in
|
|
1459
|
+
// this neighbor clears the neighbor's now-stale footer entry (the #1092
|
|
1460
|
+
// defect). MERGE (genuine LSP errors only — auxiliary findings excluded,
|
|
1461
|
+
// see `cascadeReconcilableLspErrors`) so a live biome/ruff/aux finding or
|
|
1462
|
+
// LSP warning survives this errors-only re-check. Keyed by the primary
|
|
1463
|
+
// edit's `writeSeq` so a genuinely newer per-edit write still wins the
|
|
1464
|
+
// WriteOrderingGuard. `observedAt` stays now (a fresh touch). The
|
|
1465
|
+
// inconclusive touch, the BOUND-FALSE touch (#1095 — computed against a
|
|
1466
|
+
// diverged disk state), the tier-3-silent skip, the recently-clean
|
|
1467
|
+
// short-circuit, the within-turn cache hit, and the rejected-touch
|
|
1468
|
+
// fallback are all deliberately NOT reconciled — none is a confirmed
|
|
1469
|
+
// observation.
|
|
1470
|
+
if (confirmed) {
|
|
1471
|
+
reconcileCascadeNeighborLspErrors(neighborPath, cascadeReconcilableLspErrors(rawDiags.diags, neighborPath), writeSeq);
|
|
1472
|
+
}
|
|
968
1473
|
return {
|
|
969
1474
|
filePath: neighborPath,
|
|
970
1475
|
reason: neighborReason(importerSet, callerSet, neighborPath),
|
|
971
|
-
diagnostics:
|
|
972
|
-
lspTouched:
|
|
1476
|
+
diagnostics: diags,
|
|
1477
|
+
lspTouched: true,
|
|
1478
|
+
...(inconclusive && { inconclusive: true }),
|
|
973
1479
|
durationMs,
|
|
974
1480
|
};
|
|
975
|
-
}
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
error: "no_lsp_server_configured",
|
|
984
|
-
});
|
|
985
|
-
return undefined;
|
|
986
|
-
}
|
|
987
|
-
// A6: async read to avoid blocking event loop on network-mounted drives
|
|
988
|
-
const content = await nodeFs.promises.readFile(neighborPath, "utf8");
|
|
989
|
-
// #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
|
|
990
|
-
// silent-on-clean — typescript is the lone core-set instance today)
|
|
991
|
-
// primary can never give this in-lane wait an affirmative clean
|
|
992
|
-
// signal, so the budget is pure cost. Fire the touch (didOpen/
|
|
993
|
-
// didChange still happens — the server starts real work) and record
|
|
994
|
-
// it as outstanding for the agent_settled quiet window to reconcile
|
|
995
|
-
// instead of waiting here. Ambiguous/missing capability data always
|
|
996
|
-
// classifies as "waits" (today's behavior) — see cascade-tier.ts.
|
|
997
|
-
// The whole attempt is try/caught: any surprise (a service shape
|
|
998
|
-
// that doesn't expose getCapabilitySnapshots/getClientForFile, a
|
|
999
|
-
// thrown rejection) falls through to the existing full-wait path
|
|
1000
|
-
// below rather than skip the wait on a failure.
|
|
1001
|
-
if (isTierAwareCascadeEnabled()) {
|
|
1002
|
-
try {
|
|
1003
|
-
const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
|
|
1004
|
-
const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
|
|
1005
|
-
if (tier === "tier3-silent") {
|
|
1006
|
-
const spawnedForTouch = await lspService.getClientForFile(neighborPath);
|
|
1007
|
-
if (spawnedForTouch) {
|
|
1008
|
-
// Sampled BEFORE the touchFile notify: a publish landing
|
|
1009
|
-
// in the notify→record gap must read as post-touch at
|
|
1010
|
-
// reconcile time, never be misclassified as pre-touch
|
|
1011
|
-
// (the reconcile compares this against the client's
|
|
1012
|
-
// PER-FILE publish timestamp — see cascade-tier.ts).
|
|
1013
|
-
const touchedAt = Date.now();
|
|
1014
|
-
await lspService.touchFile(neighborPath, content, {
|
|
1015
|
-
diagnostics: "none",
|
|
1016
|
-
collectDiagnostics: false,
|
|
1017
|
-
silent: true,
|
|
1018
|
-
source: "cascade",
|
|
1019
|
-
clientScope: "primary",
|
|
1020
|
-
});
|
|
1021
|
-
recordOutstandingCascadeTouch({
|
|
1022
|
-
filePath: neighborPath,
|
|
1023
|
-
serverId: spawnedForTouch.client.serverId,
|
|
1024
|
-
touchedAt,
|
|
1025
|
-
});
|
|
1026
|
-
const durationMs = Date.now() - neighborStart;
|
|
1027
|
-
logCascade({
|
|
1028
|
-
phase: "cascade_tier3_skip",
|
|
1029
|
-
filePath,
|
|
1030
|
-
neighborFile: neighborPath,
|
|
1031
|
-
durationMs,
|
|
1032
|
-
lspServerCount: configuredServerCount,
|
|
1033
|
-
coldSnapshot: isColdSnapshot,
|
|
1034
|
-
metadata: { serverId: spawnedForTouch.client.serverId },
|
|
1035
|
-
});
|
|
1036
|
-
// Deliberately NOT cached as clean/diagnosed — the wait was
|
|
1037
|
-
// skipped, not resolved, so neither neighborTouchCache nor
|
|
1038
|
-
// recentlyCleanNeighborCache may treat this as a real answer
|
|
1039
|
-
// (#240 doctrine). Return undefined: the degraded-fallback
|
|
1040
|
-
// path below still has a chance to surface a passive/stale
|
|
1041
|
-
// snapshot, same as any other "no fresh data this touch" case.
|
|
1042
|
-
return undefined;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1481
|
+
}));
|
|
1482
|
+
const allTouchPaths = [...activePaths, ...coldSnapshotPaths];
|
|
1483
|
+
for (let i = 0; i < touchResults.length; i++) {
|
|
1484
|
+
const result = touchResults[i];
|
|
1485
|
+
const neighborPath = allTouchPaths[i];
|
|
1486
|
+
if (result.status === "fulfilled") {
|
|
1487
|
+
if (result.value)
|
|
1488
|
+
neighbors.push(result.value);
|
|
1045
1489
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1490
|
+
else {
|
|
1491
|
+
// A3: one failed LSP doesn't kill the rest — fall back to passive snapshot
|
|
1492
|
+
dbg?.(`cascade neighbor touch error for ${neighborPath}: ${result.reason}`);
|
|
1493
|
+
const entry = allDiags.get(normalizeMapKey(neighborPath));
|
|
1494
|
+
const ttlFresh = entry != null && Date.now() - entry.ts < CASCADE_TTL_MS;
|
|
1495
|
+
// #1104: consult binding before trusting a TTL-fresh fallback snapshot —
|
|
1496
|
+
// MATCH #1100/#1095 semantics (false → skip, "unknown" → keep the
|
|
1497
|
+
// pre-#1104 TTL-only behavior, true → use). Without this, a failed
|
|
1498
|
+
// active touch could still re-display a bound-false (pre-fix-edit)
|
|
1499
|
+
// snapshot even though the reconcile path (#1100) already refuses to
|
|
1500
|
+
// trust it for the widget — the widget is protected but the display
|
|
1501
|
+
// wasn't. Reading `.binding` triggers the lazy disk verify — done ONLY
|
|
1502
|
+
// when TTL-fresh, same discipline as the snapshot-tier gate above.
|
|
1503
|
+
const boundToDisk = ttlFresh
|
|
1504
|
+
? readBoundToCurrentDisk(entry)
|
|
1505
|
+
: undefined;
|
|
1506
|
+
const bindingRejected = boundToDisk === false;
|
|
1507
|
+
if (bindingRejected)
|
|
1508
|
+
fallbackBindingRejected = true;
|
|
1509
|
+
logCascade({
|
|
1510
|
+
phase: "neighbor_fallback",
|
|
1511
|
+
filePath,
|
|
1512
|
+
neighborFile: neighborPath,
|
|
1513
|
+
fallbackUsed: true,
|
|
1514
|
+
error: String(result.reason),
|
|
1515
|
+
// #1104: distinguish a binding-rejected fallback (TTL-fresh but the
|
|
1516
|
+
// server's view diverged from disk) from a plain TTL-stale/missing
|
|
1517
|
+
// one — same conditional pattern as the neighbor_touch/neighbor_snapshot
|
|
1518
|
+
// phases above.
|
|
1519
|
+
...(bindingRejected && {
|
|
1520
|
+
metadata: { bindingState: bindingStateLabel(boundToDisk) },
|
|
1521
|
+
}),
|
|
1522
|
+
});
|
|
1523
|
+
// #692: `source: "cascade"` dropped (see the doc comment above the
|
|
1524
|
+
// first cascade call site in this file) — no longer affects `rule`
|
|
1525
|
+
// and cascade output never touches persisted widget/dedup state.
|
|
1526
|
+
const diags = ttlFresh && !bindingRejected
|
|
1527
|
+
? convertLspDiagnostics(entry.diags
|
|
1528
|
+
.filter((d) => d.severity === 1)
|
|
1529
|
+
.slice(0, MAX_PER_FILE), neighborPath)
|
|
1530
|
+
: [];
|
|
1531
|
+
neighbors.push({
|
|
1532
|
+
filePath: neighborPath,
|
|
1533
|
+
reason: "fallback",
|
|
1534
|
+
diagnostics: diags,
|
|
1535
|
+
lspTouched: false,
|
|
1536
|
+
});
|
|
1048
1537
|
}
|
|
1049
1538
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
maxClientWaitMs: isColdSnapshot ? 1000 : 2000,
|
|
1059
|
-
silent: true,
|
|
1060
|
-
source: "cascade",
|
|
1061
|
-
clientScope: "all",
|
|
1062
|
-
});
|
|
1063
|
-
if (!rawDiags)
|
|
1064
|
-
return undefined;
|
|
1065
|
-
// #692: `source: "cascade"` no longer overrides `rule` (see the
|
|
1066
|
-
// doc comment on the sibling call above) — dropped rather than
|
|
1067
|
-
// migrated to `scanOrigin` since cascade output never touches
|
|
1068
|
-
// persisted widget/dedup state.
|
|
1069
|
-
const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
|
|
1070
|
-
const durationMs = Date.now() - neighborStart;
|
|
1071
|
-
// Update cache for this neighbor at the current write sequence
|
|
1072
|
-
if (writeSeq != null) {
|
|
1073
|
-
neighborTouchCache.set(cacheKey, {
|
|
1074
|
-
turnSeq,
|
|
1075
|
-
writeSeq,
|
|
1076
|
-
diagnostics: diags,
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
if (diags.length === 0) {
|
|
1080
|
-
recentlyCleanNeighborCache.set(cacheKey, {
|
|
1081
|
-
turnSeq,
|
|
1082
|
-
checkedAt: Date.now(),
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
else {
|
|
1086
|
-
recentlyCleanNeighborCache.delete(cacheKey);
|
|
1087
|
-
}
|
|
1088
|
-
producedLspData = true;
|
|
1089
|
-
logCascade({
|
|
1090
|
-
phase: "neighbor_touch",
|
|
1091
|
-
filePath,
|
|
1092
|
-
neighborFile: neighborPath,
|
|
1093
|
-
diagnosticCount: diags.length,
|
|
1094
|
-
durationMs,
|
|
1095
|
-
lspTouched: true,
|
|
1096
|
-
lspServerCount: configuredServerCount,
|
|
1097
|
-
coldSnapshot: isColdSnapshot,
|
|
1098
|
-
});
|
|
1099
|
-
return {
|
|
1100
|
-
filePath: neighborPath,
|
|
1101
|
-
reason: neighborReason(importerSet, callerSet, neighborPath),
|
|
1102
|
-
diagnostics: diags,
|
|
1103
|
-
lspTouched: true,
|
|
1104
|
-
durationMs,
|
|
1105
|
-
};
|
|
1106
|
-
}));
|
|
1107
|
-
const allTouchPaths = [...activePaths, ...coldSnapshotPaths];
|
|
1108
|
-
for (let i = 0; i < touchResults.length; i++) {
|
|
1109
|
-
const result = touchResults[i];
|
|
1110
|
-
const neighborPath = allTouchPaths[i];
|
|
1111
|
-
if (result.status === "fulfilled") {
|
|
1112
|
-
if (result.value)
|
|
1113
|
-
neighbors.push(result.value);
|
|
1114
|
-
}
|
|
1115
|
-
else {
|
|
1116
|
-
// A3: one failed LSP doesn't kill the rest — fall back to passive snapshot
|
|
1117
|
-
dbg?.(`cascade neighbor touch error for ${neighborPath}: ${result.reason}`);
|
|
1539
|
+
}
|
|
1540
|
+
// CR-3/A2: degraded fallback when no neighbor produced trustworthy LSP data —
|
|
1541
|
+
// not merely when the graph returned zero neighbors.
|
|
1542
|
+
if (!producedLspData) {
|
|
1543
|
+
const bindingRejected = appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath);
|
|
1544
|
+
if (bindingRejected)
|
|
1545
|
+
fallbackBindingRejected = true;
|
|
1546
|
+
if (neighbors.some((n) => n.reason === "fallback")) {
|
|
1118
1547
|
logCascade({
|
|
1119
1548
|
phase: "neighbor_fallback",
|
|
1120
1549
|
filePath,
|
|
1121
|
-
neighborFile: neighborPath,
|
|
1122
1550
|
fallbackUsed: true,
|
|
1123
|
-
|
|
1124
|
-
});
|
|
1125
|
-
const entry = allDiags.get(normalizeMapKey(neighborPath));
|
|
1126
|
-
// #692: `source: "cascade"` dropped (see the doc comment above the
|
|
1127
|
-
// first cascade call site in this file) — no longer affects `rule`
|
|
1128
|
-
// and cascade output never touches persisted widget/dedup state.
|
|
1129
|
-
const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
|
|
1130
|
-
? convertLspDiagnostics(entry.diags
|
|
1131
|
-
.filter((d) => d.severity === 1)
|
|
1132
|
-
.slice(0, MAX_PER_FILE), neighborPath)
|
|
1133
|
-
: [];
|
|
1134
|
-
neighbors.push({
|
|
1135
|
-
filePath: neighborPath,
|
|
1136
|
-
reason: "fallback",
|
|
1137
|
-
diagnostics: diags,
|
|
1138
|
-
lspTouched: false,
|
|
1551
|
+
neighborCount: neighbors.length,
|
|
1139
1552
|
});
|
|
1140
1553
|
}
|
|
1141
1554
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1555
|
+
const visibleNeighbors = applyCascadeDeltaBaselines(neighbors);
|
|
1556
|
+
const formatted = formatCascadeResult(cwd, impact, visibleNeighbors, impact.neighborFiles.length);
|
|
1557
|
+
// #1104 HONESTY: filtering a bound-false display candidate must not turn a
|
|
1558
|
+
// degraded/indeterminate cascade into a clean-looking one (same doctrine as
|
|
1559
|
+
// #1023's graph-degraded marker). If every candidate the degraded-fallback
|
|
1560
|
+
// paths considered this run was binding-rejected and nothing else produced
|
|
1561
|
+
// output, thread the SAME indeterminate marker #1023 built so the turn-end
|
|
1562
|
+
// advisory (clients/runtime-turn.ts) surfaces an honest note instead of
|
|
1563
|
+
// silence — never let a withheld-stale-snapshot run look like a genuine
|
|
1564
|
+
// clean leaf. `!impact.indeterminate` preserves a graph-degraded marker that
|
|
1565
|
+
// already exists (never overwritten).
|
|
1566
|
+
if (!formatted && fallbackBindingRejected && !impact.indeterminate) {
|
|
1567
|
+
impact.indeterminate = {
|
|
1568
|
+
reason: "lsp_binding_rejected",
|
|
1569
|
+
detail: "cascade fallback diagnostics were withheld — stale snapshot content did not match current disk (binding rejected)",
|
|
1570
|
+
};
|
|
1154
1571
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1572
|
+
const filesWithErrors = visibleNeighbors.filter((n) => n.diagnostics.length > 0).length;
|
|
1573
|
+
logCascade({
|
|
1574
|
+
phase: "cascade_result",
|
|
1575
|
+
filePath,
|
|
1576
|
+
neighborCount: visibleNeighbors.length,
|
|
1577
|
+
diagnosticCount: visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0),
|
|
1578
|
+
metadata: {
|
|
1579
|
+
filesWithErrors,
|
|
1580
|
+
hasOutput: formatted.length > 0,
|
|
1581
|
+
// #1444: >0 means "answers are still outstanding", not "nothing found".
|
|
1582
|
+
collectLaterSkipped,
|
|
1583
|
+
// Log when cascade ran but found nothing — distinguishes "clean" from "no signal"
|
|
1584
|
+
noNeighbors: visibleNeighbors.length === 0,
|
|
1585
|
+
noErrors: visibleNeighbors.length > 0 && filesWithErrors === 0,
|
|
1586
|
+
// #1446 item 5: cache effectiveness as a number instead of an inference
|
|
1587
|
+
// from `coldSnapshot`/`snapshotMissing` flags scattered across
|
|
1588
|
+
// per-neighbor `neighbor_touch`/`neighbor_snapshot` rows.
|
|
1589
|
+
// F1: cacheHits + recentlyCleanHits + deferredTouches + coldTouches
|
|
1590
|
+
// partition `[...activePaths, ...coldSnapshotPaths]` exactly — each
|
|
1591
|
+
// counter increments at the point its neighbour's outcome is actually
|
|
1592
|
+
// decided, not from `coldSnapshotPaths` (a pre-outcome list finalized
|
|
1593
|
+
// before the cache-hit checks run). A neighbour with no LSP server
|
|
1594
|
+
// configured is the one deliberately uncounted outcome (never touched).
|
|
1595
|
+
cacheHits,
|
|
1596
|
+
recentlyCleanHits,
|
|
1597
|
+
deferredTouches,
|
|
1598
|
+
coldTouches,
|
|
1599
|
+
// #1446 item 4: the budget in force and how many eligible candidates
|
|
1600
|
+
// it cut off this run — the correctness half (a truncated run being
|
|
1601
|
+
// silently discarded) is #1443; this is observability only.
|
|
1602
|
+
neighborBudget: CASCADE_NEIGHBOUR_BUDGET,
|
|
1603
|
+
budgetTruncated: cascadeBudgetTruncated,
|
|
1604
|
+
neighbors: visibleNeighbors.slice(0, 10).map((n) => ({
|
|
1605
|
+
file: n.filePath.replace(/\\/g, "/").split("/").slice(-2).join("/"),
|
|
1606
|
+
diagnostics: n.diagnostics.length,
|
|
1607
|
+
})),
|
|
1608
|
+
},
|
|
1609
|
+
});
|
|
1610
|
+
const diagCount = visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0);
|
|
1611
|
+
cascadeSessionStats.runs += 1;
|
|
1612
|
+
cascadeSessionStats.diagnosticsSurfaced += diagCount;
|
|
1613
|
+
cascadeSessionStats.coldSnapshotTouches += coldSnapshotPaths.length;
|
|
1614
|
+
if (!formatted) {
|
|
1615
|
+
// #1023: an indeterminate compute (degraded/cold/missing-node graph) must
|
|
1616
|
+
// NOT collapse into "no_neighbors" — that is the exact silent all-clear the
|
|
1617
|
+
// bug is about. Distinguish it by the marker threaded onto `impact`, never
|
|
1618
|
+
// by `visibleNeighbors.length === 0` alone (a healthy leaf is also empty).
|
|
1619
|
+
if (impact.indeterminate) {
|
|
1620
|
+
return {
|
|
1621
|
+
filePath,
|
|
1622
|
+
result: undefined,
|
|
1623
|
+
neighborCount: visibleNeighbors.length,
|
|
1624
|
+
diagnosticCount: diagCount,
|
|
1625
|
+
skipReason: "indeterminate",
|
|
1626
|
+
indeterminate: impact.indeterminate,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
const skipReason = visibleNeighbors.length === 0 ? "no_neighbors" : "clean";
|
|
1186
1630
|
return {
|
|
1187
1631
|
filePath,
|
|
1188
1632
|
result: undefined,
|
|
1189
1633
|
neighborCount: visibleNeighbors.length,
|
|
1190
1634
|
diagnosticCount: diagCount,
|
|
1191
|
-
skipReason
|
|
1192
|
-
indeterminate: impact.indeterminate,
|
|
1635
|
+
skipReason,
|
|
1193
1636
|
};
|
|
1194
1637
|
}
|
|
1195
|
-
|
|
1638
|
+
getDiagnosticTracker().trackShown(visibleNeighbors.flatMap((n) => n.diagnostics));
|
|
1196
1639
|
return {
|
|
1197
1640
|
filePath,
|
|
1198
|
-
result:
|
|
1641
|
+
result: { filePath, impact, neighbors: visibleNeighbors, formatted },
|
|
1199
1642
|
neighborCount: visibleNeighbors.length,
|
|
1200
1643
|
diagnosticCount: diagCount,
|
|
1201
|
-
|
|
1644
|
+
// #1023: even when some fallback neighbors surfaced, a degraded graph means
|
|
1645
|
+
// the dependent set is INCOMPLETE — carry the marker so the turn-end seam
|
|
1646
|
+
// still notes downstream impact was under-computed this turn.
|
|
1647
|
+
...(impact.indeterminate && { indeterminate: impact.indeterminate }),
|
|
1202
1648
|
};
|
|
1203
1649
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1650
|
+
finally {
|
|
1651
|
+
// Keep the cache entry warm, but do not let a one-shot cascade leave an
|
|
1652
|
+
// idle handle behind. The next consumer re-arms it through touch.
|
|
1653
|
+
for (const key of reverseDepsTimersToRelease) {
|
|
1654
|
+
const entry = reverseDepsIndexCache.get(key);
|
|
1655
|
+
if (entry?.idleTimer !== undefined)
|
|
1656
|
+
clearTimeout(entry.idleTimer);
|
|
1657
|
+
if (entry)
|
|
1658
|
+
entry.idleTimer = undefined;
|
|
1659
|
+
}
|
|
1660
|
+
for (const [key, entry] of reverseDepsIndexCache) {
|
|
1661
|
+
if (entry.idleTimer !== undefined)
|
|
1662
|
+
clearTimeout(entry.idleTimer);
|
|
1663
|
+
entry.idleTimer = undefined;
|
|
1664
|
+
if (!reverseDepsTimersToRelease.has(key) && reverseDepsEntriesAtStart.has(entry)) {
|
|
1665
|
+
reverseDepsTimersToRelease.add(key);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
for (const timer of astGrepWarnDebounceTimers.values())
|
|
1669
|
+
clearTimeout(timer);
|
|
1670
|
+
astGrepWarnDebounceTimers.clear();
|
|
1671
|
+
releaseWorkspaceTopologyIdleTimers();
|
|
1672
|
+
}
|
|
1215
1673
|
}
|
|
1216
1674
|
function diagnosticDeltaKey(diagnostic) {
|
|
1217
1675
|
return [
|
|
@@ -1238,10 +1696,17 @@ function applyCascadeDeltaBaselines(neighbors) {
|
|
|
1238
1696
|
};
|
|
1239
1697
|
});
|
|
1240
1698
|
}
|
|
1241
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* Returns `true` when at least one otherwise-eligible candidate was withheld
|
|
1701
|
+
* because its content binding was rejected (#1104) — the caller folds this
|
|
1702
|
+
* into the run-level `fallbackBindingRejected` flag for the honesty check.
|
|
1703
|
+
*/
|
|
1704
|
+
function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath) {
|
|
1242
1705
|
const now = Date.now();
|
|
1243
1706
|
const seen = new Set(neighbors.map((n) => normalizeMapKey(n.filePath)));
|
|
1244
|
-
|
|
1707
|
+
let bindingRejected = false;
|
|
1708
|
+
for (const [diagPath, entry] of allDiags) {
|
|
1709
|
+
const { diags, ts } = entry;
|
|
1245
1710
|
const diagKey = normalizeMapKey(diagPath);
|
|
1246
1711
|
if (diagKey === normalizedFileKey || seen.has(diagKey))
|
|
1247
1712
|
continue;
|
|
@@ -1251,10 +1716,33 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
|
|
|
1251
1716
|
continue;
|
|
1252
1717
|
if (isIgnoredCascadeNeighbor(diagPath, cwd))
|
|
1253
1718
|
continue;
|
|
1719
|
+
// #1080: a KNOWN test-role file must not surface as a collateral fallback
|
|
1720
|
+
// neighbor either (the passive-snapshot path the graph/reference filters
|
|
1721
|
+
// above never reach). Ignore filtering (#297) stays separate and above.
|
|
1722
|
+
if (isTestRoleCollateral(diagPath))
|
|
1723
|
+
continue;
|
|
1254
1724
|
if (!nodeFs.existsSync(diagPath))
|
|
1255
1725
|
continue;
|
|
1256
1726
|
if (now - ts > CASCADE_TTL_MS)
|
|
1257
1727
|
continue;
|
|
1728
|
+
// #1104: a TTL-fresh entry is not automatically trustworthy — consult
|
|
1729
|
+
// binding the same way the reconcile path (#1100) and the touch-error
|
|
1730
|
+
// fallback above do. `false` → skip (a stale/pre-fix-edit snapshot whose
|
|
1731
|
+
// server view diverged from current disk); "unknown"/`true` → unchanged
|
|
1732
|
+
// (the pre-#1104 fallback contract). Reading `.binding` triggers the lazy
|
|
1733
|
+
// disk verify — done ONLY when TTL-fresh, per the established discipline.
|
|
1734
|
+
const boundToDisk = readBoundToCurrentDisk(entry);
|
|
1735
|
+
if (boundToDisk === false) {
|
|
1736
|
+
bindingRejected = true;
|
|
1737
|
+
logCascade({
|
|
1738
|
+
phase: "neighbor_fallback",
|
|
1739
|
+
filePath,
|
|
1740
|
+
neighborFile: diagPath,
|
|
1741
|
+
fallbackUsed: false,
|
|
1742
|
+
metadata: { bindingState: bindingStateLabel(false) },
|
|
1743
|
+
});
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1258
1746
|
// #692: `source: "cascade"` dropped — see the doc comment above the
|
|
1259
1747
|
// first cascade `convertLspDiagnostics` call site in this file.
|
|
1260
1748
|
const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
|
|
@@ -1270,6 +1758,7 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
|
|
|
1270
1758
|
if (neighbors.length >= MAX_FILES)
|
|
1271
1759
|
break;
|
|
1272
1760
|
}
|
|
1761
|
+
return bindingRejected;
|
|
1273
1762
|
}
|
|
1274
1763
|
function shouldReadCascadeFromSnapshot(filePath) {
|
|
1275
1764
|
return getServersForFileWithConfig(filePath).some((server) => server.autoPropagateDiagnostics === true);
|
|
@@ -1314,11 +1803,11 @@ function formatCascadeResult(cwd, impact, neighbors, totalNeighbors) {
|
|
|
1314
1803
|
* @param pi - Pi agent API (for flags)
|
|
1315
1804
|
* @returns Output string to display to user
|
|
1316
1805
|
*/
|
|
1317
|
-
export async function dispatchLint(filePath, cwd, pi, modifiedRanges) {
|
|
1806
|
+
export async function dispatchLint(filePath, cwd, pi, modifiedRanges, projectRoot) {
|
|
1318
1807
|
// By default, only run BLOCKING rules for fast feedback on file write
|
|
1319
1808
|
// Uses persistent sessionBaselines so delta mode actually filters
|
|
1320
1809
|
// pre-existing issues after the first write.
|
|
1321
|
-
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, true, modifiedRanges);
|
|
1810
|
+
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, true, modifiedRanges, projectRoot);
|
|
1322
1811
|
sessionFacts.clearFileFactsFor(ctx.filePath);
|
|
1323
1812
|
const kind = ctx.kind;
|
|
1324
1813
|
if (!kind)
|
|
@@ -1338,7 +1827,7 @@ export async function dispatchLintWithResult(filePath, cwd, pi, modifiedRanges,
|
|
|
1338
1827
|
// Default true preserves the per-edit fast path (errors only). Callers that
|
|
1339
1828
|
// want the full picture (warnings + structural smells), e.g. the MCP review
|
|
1340
1829
|
// facade, pass blockingOnly=false to run every runner.
|
|
1341
|
-
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges);
|
|
1830
|
+
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges, options?.projectRoot, options?.writeIndex, options?.telemetryModel, options?.telemetryProvider);
|
|
1342
1831
|
sessionFacts.clearFileFactsFor(ctx.filePath);
|
|
1343
1832
|
const kind = ctx.kind;
|
|
1344
1833
|
if (!kind) {
|
|
@@ -1400,7 +1889,7 @@ export async function dispatchLintDetailed(filePath, cwd, pi, options) {
|
|
|
1400
1889
|
blockerOutput: "",
|
|
1401
1890
|
hasBlockers: false,
|
|
1402
1891
|
};
|
|
1403
|
-
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges);
|
|
1892
|
+
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges, options?.projectRoot);
|
|
1404
1893
|
sessionFacts.clearFileFactsFor(ctx.filePath);
|
|
1405
1894
|
const kind = ctx.kind;
|
|
1406
1895
|
if (!kind)
|