@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
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
|
+
import { createDeadline, forEachCooperatively, yieldIfOverBudget, } from "./cooperative-budget.js";
|
|
19
|
+
import { KIND_EXTENSIONS } from "./file-kinds.js";
|
|
18
20
|
import { PathKeyedMap } from "./path-keyed-map.js";
|
|
19
21
|
import { isAtOrAboveHomeDir, normalizeEphemeralMapKey } from "./path-utils.js";
|
|
20
22
|
import { createDebounceScheduler, } from "./persist-debounce.js";
|
|
@@ -37,11 +39,50 @@ export const wordIndexKey = normalizeEphemeralMapKey;
|
|
|
37
39
|
// postings without improving relevance. Kept deliberately small and
|
|
38
40
|
// language-agnostic.
|
|
39
41
|
const STOPWORDS = new Set([
|
|
40
|
-
"the",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
42
|
+
"the",
|
|
43
|
+
"and",
|
|
44
|
+
"for",
|
|
45
|
+
"let",
|
|
46
|
+
"var",
|
|
47
|
+
"const",
|
|
48
|
+
"function",
|
|
49
|
+
"return",
|
|
50
|
+
"if",
|
|
51
|
+
"else",
|
|
52
|
+
"import",
|
|
53
|
+
"export",
|
|
54
|
+
"from",
|
|
55
|
+
"class",
|
|
56
|
+
"interface",
|
|
57
|
+
"type",
|
|
58
|
+
"enum",
|
|
59
|
+
"new",
|
|
60
|
+
"this",
|
|
61
|
+
"self",
|
|
62
|
+
"void",
|
|
63
|
+
"null",
|
|
64
|
+
"true",
|
|
65
|
+
"false",
|
|
66
|
+
"async",
|
|
67
|
+
"await",
|
|
68
|
+
"public",
|
|
69
|
+
"private",
|
|
70
|
+
"protected",
|
|
71
|
+
"static",
|
|
72
|
+
"def",
|
|
73
|
+
"fn",
|
|
74
|
+
"func",
|
|
75
|
+
"struct",
|
|
76
|
+
"impl",
|
|
77
|
+
"pub",
|
|
78
|
+
"use",
|
|
79
|
+
"mod",
|
|
80
|
+
"in",
|
|
81
|
+
"of",
|
|
82
|
+
"as",
|
|
83
|
+
"is",
|
|
84
|
+
"not",
|
|
85
|
+
"with",
|
|
45
86
|
]);
|
|
46
87
|
const TEST_VENDOR_RE = /(?:(^|[\\/])(?:tests?|__tests__|spec|specs|__mocks__|vendor|node_modules|examples?|fixtures?|\.git|dist|build|coverage)([\\/]|$))|(?:\.(?:test|spec)\.[a-z]+$)/i;
|
|
47
88
|
const DOC_FILE_RE = /\.(?:md|mdx|markdown|json|json5|jsonc|txt|rst|lock|ya?ml|toml|csv)$/i;
|
|
@@ -94,52 +135,112 @@ export function tokenizeLine(line) {
|
|
|
94
135
|
}
|
|
95
136
|
return tokens;
|
|
96
137
|
}
|
|
138
|
+
const WORD_INDEX_BUILD_YIELD_BUDGET_MS = 8;
|
|
97
139
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* that
|
|
140
|
+
* A line at or above this length is treated as a yield checkpoint on its own.
|
|
141
|
+
* The per-50-lines cadence is a proxy for work that only holds on hand-written
|
|
142
|
+
* source: a minified/bundled single-line file up to {@link WORD_INDEX_MAX_BYTES}
|
|
143
|
+
* that the source filter did not exclude would otherwise get ZERO in-document
|
|
144
|
+
* yields — one unbroken tokenize+push burst far above the budget (#1197 review
|
|
145
|
+
* finding 4). Checking the clock after any long line makes the cooperativeness
|
|
146
|
+
* claim true per BYTE, not just per line count.
|
|
102
147
|
*/
|
|
148
|
+
const WORD_INDEX_LONG_LINE_YIELD_CHARS = 4096;
|
|
149
|
+
/**
|
|
150
|
+
* Shared cooperative time-slicer for every bulk word-index path (#1197).
|
|
151
|
+
*
|
|
152
|
+
* Time-based, not count-based: "yield every N items" is only a bound when the
|
|
153
|
+
* per-item cost is bounded too, and the incremental refresh's per-document cost
|
|
154
|
+
* grows with the corpus (each replacement filters shared posting arrays). The
|
|
155
|
+
* #1197 outage was exactly that shape — 239 stale documents held pi's event loop
|
|
156
|
+
* for seconds between two "every 100 files" checkpoints. Callers combine this
|
|
157
|
+
* The shared deadline is checked at every bounded work unit so abort latency
|
|
158
|
+
* and occupancy are governed by the same monotonic budget.
|
|
159
|
+
*/
|
|
160
|
+
function createEmptyWordIndex(truncated) {
|
|
161
|
+
return {
|
|
162
|
+
postings: new Map(),
|
|
163
|
+
docLengths: new PathKeyedMap(wordIndexKey),
|
|
164
|
+
totalTokens: 0,
|
|
165
|
+
docCount: 0,
|
|
166
|
+
truncated,
|
|
167
|
+
forward: new PathKeyedMap(wordIndexKey),
|
|
168
|
+
fileMtimes: new PathKeyedMap(wordIndexKey),
|
|
169
|
+
fileSizes: new PathKeyedMap(wordIndexKey),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function indexWordLine(index, filePath, line, lineNumber, tokenLineCounts) {
|
|
173
|
+
const lineTokens = tokenizeLine(line);
|
|
174
|
+
const seenOnLine = new Set();
|
|
175
|
+
for (const token of lineTokens) {
|
|
176
|
+
if (seenOnLine.has(token))
|
|
177
|
+
continue;
|
|
178
|
+
seenOnLine.add(token);
|
|
179
|
+
const arr = index.postings.get(token);
|
|
180
|
+
if (arr)
|
|
181
|
+
arr.push({ file: filePath, line: lineNumber });
|
|
182
|
+
else
|
|
183
|
+
index.postings.set(token, [{ file: filePath, line: lineNumber }]);
|
|
184
|
+
tokenLineCounts.set(token, (tokenLineCounts.get(token) ?? 0) + 1);
|
|
185
|
+
}
|
|
186
|
+
return lineTokens.length;
|
|
187
|
+
}
|
|
188
|
+
function finishWordIndexDocument(index, doc, docLength, tokenLineCounts) {
|
|
189
|
+
index.docLengths.set(doc.path, docLength);
|
|
190
|
+
index.forward?.set(doc.path, tokenLineCounts);
|
|
191
|
+
index.fileMtimes.set(doc.path, doc.mtimeMs ?? 0);
|
|
192
|
+
index.fileSizes.set(doc.path, doc.size ?? Buffer.byteLength(doc.content, "utf-8"));
|
|
193
|
+
index.totalTokens += docLength;
|
|
194
|
+
index.docCount += 1;
|
|
195
|
+
}
|
|
196
|
+
function indexWordDocument(index, doc) {
|
|
197
|
+
const lines = doc.content.split(/\r?\n/);
|
|
198
|
+
const tokenLineCounts = new Map();
|
|
199
|
+
let docLength = 0;
|
|
200
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
201
|
+
docLength += indexWordLine(index, doc.path, lines[i], i + 1, tokenLineCounts);
|
|
202
|
+
}
|
|
203
|
+
finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
|
|
204
|
+
}
|
|
103
205
|
export function buildWordIndex(files) {
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
206
|
+
const index = createEmptyWordIndex(files.truncated ?? false);
|
|
207
|
+
for (const doc of files)
|
|
208
|
+
indexWordDocument(index, doc);
|
|
209
|
+
return index;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Cooperative production builder for bulk/cold paths. It produces the exact
|
|
213
|
+
* same index as {@link buildWordIndex}, but time-slices both between documents
|
|
214
|
+
* and within a large document so startup warmup cannot monopolize pi's TUI
|
|
215
|
+
* event loop (#1197). The index is private until this resolves, so a superseded
|
|
216
|
+
* build never publishes a partial replacement.
|
|
217
|
+
*/
|
|
218
|
+
export async function buildWordIndexAsync(files, shouldContinue = () => true) {
|
|
219
|
+
const index = createEmptyWordIndex(files.truncated ?? false);
|
|
220
|
+
const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
|
|
221
|
+
for (const doc of files) {
|
|
222
|
+
if (!shouldContinue())
|
|
223
|
+
throw new Error("word index build superseded");
|
|
224
|
+
const lines = doc.content.split(/\r?\n/);
|
|
112
225
|
const tokenLineCounts = new Map();
|
|
226
|
+
let docLength = 0;
|
|
113
227
|
for (let i = 0; i < lines.length; i += 1) {
|
|
114
|
-
const
|
|
115
|
-
docLength +=
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const arr = postings.get(token);
|
|
122
|
-
if (arr)
|
|
123
|
-
arr.push({ file: filePath, line: i + 1 });
|
|
124
|
-
else
|
|
125
|
-
postings.set(token, [{ file: filePath, line: i + 1 }]);
|
|
126
|
-
tokenLineCounts.set(token, (tokenLineCounts.get(token) ?? 0) + 1);
|
|
228
|
+
const line = lines[i];
|
|
229
|
+
docLength += indexWordLine(index, doc.path, line, i + 1, tokenLineCounts);
|
|
230
|
+
if (line.length >= WORD_INDEX_LONG_LINE_YIELD_CHARS ||
|
|
231
|
+
deadline.expired()) {
|
|
232
|
+
await yieldIfOverBudget(deadline);
|
|
233
|
+
if (!shouldContinue())
|
|
234
|
+
throw new Error("word index build superseded");
|
|
127
235
|
}
|
|
128
236
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
237
|
+
finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
|
|
238
|
+
if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
|
|
239
|
+
if (!shouldContinue())
|
|
240
|
+
throw new Error("word index build superseded");
|
|
241
|
+
}
|
|
133
242
|
}
|
|
134
|
-
return
|
|
135
|
-
postings,
|
|
136
|
-
docLengths,
|
|
137
|
-
totalTokens,
|
|
138
|
-
docCount: files.length,
|
|
139
|
-
truncated: files.truncated ?? false,
|
|
140
|
-
forward,
|
|
141
|
-
fileMtimes,
|
|
142
|
-
};
|
|
243
|
+
return index;
|
|
143
244
|
}
|
|
144
245
|
/**
|
|
145
246
|
* Remove `filePath`'s postings/docLength/forward entry from `index` in place,
|
|
@@ -175,6 +276,7 @@ export function removeWordIndexDocument(index, filePath) {
|
|
|
175
276
|
index.docLengths.delete(filePath);
|
|
176
277
|
index.forward.delete(filePath);
|
|
177
278
|
index.fileMtimes.delete(filePath);
|
|
279
|
+
index.fileSizes.delete(filePath);
|
|
178
280
|
index.totalTokens -= docLength;
|
|
179
281
|
index.docCount = Math.max(0, index.docCount - 1);
|
|
180
282
|
return true;
|
|
@@ -238,6 +340,117 @@ export function updateWordIndexDocument(index, doc) {
|
|
|
238
340
|
// legal on-disk mtime (SOURCE_DATE_EPOCH=0, archive extraction) and would
|
|
239
341
|
// collide, leaving such a file never re-tokenized (#958 review F2).
|
|
240
342
|
index.fileMtimes.set(doc.path, -1);
|
|
343
|
+
// Size is likewise recorded for the #1105 mtime+size refresh gate. The -1
|
|
344
|
+
// mtime already forces a re-read next session, so this value only keeps the
|
|
345
|
+
// map dense (parallel to fileMtimes); store the real byte length so a
|
|
346
|
+
// deserialize→reserialize round-trip before any refresh carries a truthful
|
|
347
|
+
// size rather than a placeholder.
|
|
348
|
+
index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
|
|
349
|
+
index.totalTokens += docLength;
|
|
350
|
+
index.docCount += 1;
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
|
|
354
|
+
if (!index.forward)
|
|
355
|
+
return undefined;
|
|
356
|
+
const tokenLineCounts = index.forward.get(filePath);
|
|
357
|
+
if (!tokenLineCounts)
|
|
358
|
+
return undefined;
|
|
359
|
+
const removedKey = wordIndexKey(filePath);
|
|
360
|
+
const postings = new Map();
|
|
361
|
+
const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
|
|
362
|
+
for (const token of tokenLineCounts.keys()) {
|
|
363
|
+
if (!shouldContinue())
|
|
364
|
+
throw new Error("word index refresh superseded");
|
|
365
|
+
const arr = index.postings.get(token);
|
|
366
|
+
if (!arr)
|
|
367
|
+
continue;
|
|
368
|
+
const next = [];
|
|
369
|
+
for (const hit of arr) {
|
|
370
|
+
if (wordIndexKey(hit.file) !== removedKey)
|
|
371
|
+
next.push(hit);
|
|
372
|
+
if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
|
|
373
|
+
if (!shouldContinue())
|
|
374
|
+
throw new Error("word index refresh superseded");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
postings.set(token, next.length > 0 ? next : undefined);
|
|
378
|
+
}
|
|
379
|
+
return { postings, docLength: index.docLengths.get(filePath) ?? 0 };
|
|
380
|
+
}
|
|
381
|
+
function commitWordIndexDocumentRemoval(index, filePath, staged) {
|
|
382
|
+
for (const [token, hits] of staged.postings) {
|
|
383
|
+
if (hits)
|
|
384
|
+
index.postings.set(token, hits);
|
|
385
|
+
else
|
|
386
|
+
index.postings.delete(token);
|
|
387
|
+
}
|
|
388
|
+
index.docLengths.delete(filePath);
|
|
389
|
+
index.forward?.delete(filePath);
|
|
390
|
+
index.fileMtimes.delete(filePath);
|
|
391
|
+
index.fileSizes.delete(filePath);
|
|
392
|
+
index.totalTokens -= staged.docLength;
|
|
393
|
+
index.docCount = Math.max(0, index.docCount - 1);
|
|
394
|
+
}
|
|
395
|
+
/** Cooperative, atomically-published removal used only by bulk refresh. */
|
|
396
|
+
export async function removeWordIndexDocumentAsync(index, filePath, shouldContinue = () => true) {
|
|
397
|
+
const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
|
|
398
|
+
if (!staged)
|
|
399
|
+
return false;
|
|
400
|
+
if (!shouldContinue())
|
|
401
|
+
throw new Error("word index refresh superseded");
|
|
402
|
+
commitWordIndexDocumentRemoval(index, filePath, staged);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
/** Cooperative replacement whose old/new state is committed without an await. */
|
|
406
|
+
export async function updateWordIndexDocumentAsync(index, doc, shouldContinue = () => true) {
|
|
407
|
+
if (!index.forward)
|
|
408
|
+
return false;
|
|
409
|
+
const removal = index.forward.has(doc.path)
|
|
410
|
+
? await stageWordIndexDocumentRemoval(index, doc.path, shouldContinue)
|
|
411
|
+
: undefined;
|
|
412
|
+
if (index.forward.has(doc.path) && !removal)
|
|
413
|
+
return false;
|
|
414
|
+
const perTokenHits = new Map();
|
|
415
|
+
let docLength = 0;
|
|
416
|
+
const lines = doc.content.split(/\r?\n/);
|
|
417
|
+
await forEachCooperatively(lines, (line, i) => {
|
|
418
|
+
const lineTokens = tokenizeLine(line);
|
|
419
|
+
docLength += lineTokens.length;
|
|
420
|
+
const seenOnLine = new Set();
|
|
421
|
+
for (const token of lineTokens) {
|
|
422
|
+
if (seenOnLine.has(token))
|
|
423
|
+
continue;
|
|
424
|
+
seenOnLine.add(token);
|
|
425
|
+
const arr = perTokenHits.get(token);
|
|
426
|
+
if (arr)
|
|
427
|
+
arr.push(i + 1);
|
|
428
|
+
else
|
|
429
|
+
perTokenHits.set(token, [i + 1]);
|
|
430
|
+
}
|
|
431
|
+
}, {
|
|
432
|
+
budgetMs: WORD_INDEX_BUILD_YIELD_BUDGET_MS,
|
|
433
|
+
shouldContinue,
|
|
434
|
+
abortMessage: "word index refresh superseded",
|
|
435
|
+
});
|
|
436
|
+
if (!shouldContinue())
|
|
437
|
+
throw new Error("word index refresh superseded");
|
|
438
|
+
if (removal)
|
|
439
|
+
commitWordIndexDocumentRemoval(index, doc.path, removal);
|
|
440
|
+
const tokenLineCounts = new Map();
|
|
441
|
+
for (const [token, lineNumbers] of perTokenHits) {
|
|
442
|
+
tokenLineCounts.set(token, lineNumbers.length);
|
|
443
|
+
const hits = lineNumbers.map((line) => ({ file: doc.path, line }));
|
|
444
|
+
const arr = index.postings.get(token);
|
|
445
|
+
if (arr)
|
|
446
|
+
arr.push(...hits);
|
|
447
|
+
else
|
|
448
|
+
index.postings.set(token, hits);
|
|
449
|
+
}
|
|
450
|
+
index.docLengths.set(doc.path, docLength);
|
|
451
|
+
index.forward.set(doc.path, tokenLineCounts);
|
|
452
|
+
index.fileMtimes.set(doc.path, -1);
|
|
453
|
+
index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
|
|
241
454
|
index.totalTokens += docLength;
|
|
242
455
|
index.docCount += 1;
|
|
243
456
|
return true;
|
|
@@ -262,7 +475,7 @@ export const WORD_INDEX_MAX_BYTES = 512 * 1024;
|
|
|
262
475
|
* `shouldContinue` lets a session-scoped caller abort early (session
|
|
263
476
|
* superseded) without this module knowing about RuntimeCoordinator.
|
|
264
477
|
*/
|
|
265
|
-
export async function collectWordIndexDocs(root, shouldContinue = () => true) {
|
|
478
|
+
export async function collectWordIndexDocs(root, shouldContinue = () => true, preflightFiles) {
|
|
266
479
|
const { collectSourceFilesAsync } = await import("./source-filter.js");
|
|
267
480
|
// #747 hardening: pass the cap INTO the walk — without it,
|
|
268
481
|
// `collectSourceFilesAsync` defaults to an unbounded traversal and the
|
|
@@ -272,29 +485,39 @@ export async function collectWordIndexDocs(root, shouldContinue = () => true) {
|
|
|
272
485
|
// visited-entry budget (DEFAULT_MAX_SCAN_ENTRIES), so a mixed tree with few
|
|
273
486
|
// source files among a huge pile of non-source files can't force a
|
|
274
487
|
// full-tree walk either; an index over the truncated list is acceptable.
|
|
275
|
-
const maxFiles = getWordIndexMaxFilesDerived(root);
|
|
488
|
+
const maxFiles = preflightFiles?.length ?? getWordIndexMaxFilesDerived(root);
|
|
276
489
|
// #894 review: prioritize code kinds within the cap — with broadened
|
|
277
490
|
// enumeration, thousands of data/doc files (locale JSON, fixtures, …)
|
|
278
491
|
// ahead of the code dirs in walk order could exhaust `maxFiles` and evict
|
|
279
492
|
// real source files from the index entirely (DOC_FILE_PENALTY can't
|
|
280
493
|
// rescue a file that never made the slice).
|
|
281
|
-
const files =
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
494
|
+
const files = preflightFiles
|
|
495
|
+
? preflightFiles.map((file) => file.path)
|
|
496
|
+
: await collectSourceFilesAsync(root, {
|
|
497
|
+
maxFiles,
|
|
498
|
+
prioritizeCodeKinds: true,
|
|
499
|
+
});
|
|
500
|
+
const truncated = preflightFiles?.truncated ?? files.length === maxFiles;
|
|
286
501
|
const docs = Object.assign([], { truncated, skipped: 0 });
|
|
287
502
|
if (!shouldContinue())
|
|
288
503
|
return docs;
|
|
289
|
-
|
|
504
|
+
const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
|
|
290
505
|
for (const file of files.slice(0, maxFiles)) {
|
|
506
|
+
if (!shouldContinue())
|
|
507
|
+
return docs;
|
|
291
508
|
try {
|
|
509
|
+
// A preflight list saves the second full WALK, but its metadata may be
|
|
510
|
+
// stale by the time this rebuild reads the file. Re-stat each document at
|
|
511
|
+
// content-read time so the stored freshness stamp describes the bytes we
|
|
512
|
+
// actually index and the next incremental refresh cannot miss a change in
|
|
513
|
+
// the walk-to-read window (#1302).
|
|
292
514
|
const stat = fs.statSync(file);
|
|
293
515
|
if (stat.size <= WORD_INDEX_MAX_BYTES) {
|
|
294
516
|
docs.push({
|
|
295
517
|
path: file,
|
|
296
518
|
content: fs.readFileSync(file, "utf-8"),
|
|
297
519
|
mtimeMs: stat.mtimeMs,
|
|
520
|
+
size: stat.size,
|
|
298
521
|
});
|
|
299
522
|
}
|
|
300
523
|
else {
|
|
@@ -308,30 +531,66 @@ export async function collectWordIndexDocs(root, shouldContinue = () => true) {
|
|
|
308
531
|
// unreadable / vanished file — skip, but count it (see above).
|
|
309
532
|
docs.skipped += 1;
|
|
310
533
|
}
|
|
311
|
-
if (
|
|
312
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
534
|
+
if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
|
|
313
535
|
if (!shouldContinue())
|
|
314
536
|
return docs;
|
|
315
537
|
}
|
|
316
538
|
}
|
|
317
539
|
return docs;
|
|
318
540
|
}
|
|
541
|
+
// Node's fs.promises.stat runs on libuv's threadpool (default 4 slots), so
|
|
542
|
+
// real parallelism tops out there; the surplus workers are queue depth that
|
|
543
|
+
// keeps the pool saturated without starving other threadpool consumers.
|
|
544
|
+
export const WORD_INDEX_STAT_CONCURRENCY = 8;
|
|
319
545
|
const WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD = 0.3;
|
|
546
|
+
// A ratio alone misclassifies one stale file in a three-file project as dense.
|
|
547
|
+
// Below this absolute floor, per-document replacement is bounded and cheaper
|
|
548
|
+
// than a second full walk/read even when the percentage is high (#1197).
|
|
549
|
+
const WORD_INDEX_DENSE_REFRESH_MIN_DOCUMENTS = 32;
|
|
550
|
+
/**
|
|
551
|
+
* Cost of one file's stat+read+decode expressed in tokenizer-token units, so
|
|
552
|
+
* the work model below can compare a path that re-reads EVERY file (full
|
|
553
|
+
* rebuild) against one that re-reads only the stale files (incremental) in a
|
|
554
|
+
* single currency. One read ≈ 300 tokens at the ~1 µs/token measured below.
|
|
555
|
+
* Order-of-magnitude by design — it only has to keep a tiny-vocabulary corpus
|
|
556
|
+
* (where posting scans are nearly free) from being sent to a full rebuild that
|
|
557
|
+
* re-reads everything to save a handful of array filters.
|
|
558
|
+
*/
|
|
559
|
+
const WORD_INDEX_FILE_READ_TOKEN_COST = 300;
|
|
560
|
+
/**
|
|
561
|
+
* Cost of scanning ONE posting-array element (the `wordIndexKey` compare in
|
|
562
|
+
* {@link removeWordIndexDocument}'s filter) relative to tokenizing one token,
|
|
563
|
+
* so both sides of the model below are denominated in tokens. Measured on this
|
|
564
|
+
* repository's own 2,062-document corpus: 213 ns per element scan against 979 ns
|
|
565
|
+
* per token — and the resulting predicted crossover (23 stale documents) lands
|
|
566
|
+
* on the directly measured one (~21, from 97.8 ms per document replacement
|
|
567
|
+
* against a 1,436 ms rebuild + re-read). The synthetic high-df corpus in the
|
|
568
|
+
* #1197 probe gives 0.09, so this is the conservative (incremental-favouring)
|
|
569
|
+
* end of the observed range.
|
|
570
|
+
*/
|
|
571
|
+
const WORD_INDEX_POSTING_SCAN_TOKEN_COST = 0.2;
|
|
320
572
|
/**
|
|
321
573
|
* Refresh a serializer-v2 index from the current bounded source-file set.
|
|
322
|
-
* The walk/stat pass is cheap; only stale/new documents are read and
|
|
323
|
-
*
|
|
574
|
+
* The walk/stat pass is cheap; only sparse stale/new documents are read and
|
|
575
|
+
* tokenized. Expected dense/legacy states return `full-required` BEFORE any
|
|
576
|
+
* mutation; unexpected corruption or supersession still throws.
|
|
324
577
|
*/
|
|
325
|
-
export async function refreshWordIndexIncrementally(index, root, shouldContinue = () => true) {
|
|
326
|
-
if (!index.forward || !index.fileMtimes) {
|
|
327
|
-
|
|
578
|
+
export async function refreshWordIndexIncrementally(index, root, shouldContinue = () => true, options = {}) {
|
|
579
|
+
if (!index.forward || !index.fileMtimes || !index.fileSizes) {
|
|
580
|
+
return {
|
|
581
|
+
mode: "full-required",
|
|
582
|
+
reason: "missing-incremental-metadata",
|
|
583
|
+
timings: { sourceWalkMs: 0, statWalkMs: 0, refreshReadsMs: 0 },
|
|
584
|
+
};
|
|
328
585
|
}
|
|
329
586
|
const { collectSourceFilesAsync } = await import("./source-filter.js");
|
|
330
587
|
const maxFiles = getWordIndexMaxFilesDerived(root);
|
|
588
|
+
const sourceWalkStartMs = Date.now();
|
|
331
589
|
const walked = await collectSourceFilesAsync(root, {
|
|
332
590
|
maxFiles,
|
|
333
591
|
prioritizeCodeKinds: true,
|
|
334
592
|
});
|
|
593
|
+
const sourceWalkMs = Date.now() - sourceWalkStartMs;
|
|
335
594
|
if (!shouldContinue())
|
|
336
595
|
throw new Error("word index refresh superseded");
|
|
337
596
|
// This set-difference must run in the SAME normalized key space the path
|
|
@@ -347,17 +606,53 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
347
606
|
// `wordIndexKey` keeps build/edit/refresh convergent; `current`'s value
|
|
348
607
|
// retains the raw walk path for the stat/read and for the display key.
|
|
349
608
|
const current = new Map();
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
609
|
+
const statWalkStartMs = Date.now();
|
|
610
|
+
const statFile = options.statFile ?? ((file) => fs.promises.stat(file));
|
|
611
|
+
const requestedConcurrency = options.statConcurrency ?? WORD_INDEX_STAT_CONCURRENCY;
|
|
612
|
+
const statConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0
|
|
613
|
+
? Math.max(1, Math.floor(requestedConcurrency))
|
|
614
|
+
: WORD_INDEX_STAT_CONCURRENCY;
|
|
615
|
+
const statResults = new Array(walked.length);
|
|
616
|
+
let cursor = 0;
|
|
617
|
+
let superseded = false;
|
|
618
|
+
const worker = async () => {
|
|
619
|
+
while (true) {
|
|
620
|
+
if (!shouldContinue()) {
|
|
621
|
+
superseded = true;
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const slot = cursor++;
|
|
625
|
+
if (slot >= walked.length)
|
|
626
|
+
return;
|
|
627
|
+
const file = walked[slot];
|
|
628
|
+
try {
|
|
629
|
+
const stat = await statFile(file);
|
|
630
|
+
if (stat.size <= WORD_INDEX_MAX_BYTES) {
|
|
631
|
+
statResults[slot] = {
|
|
632
|
+
path: file,
|
|
633
|
+
mtimeMs: stat.mtimeMs,
|
|
634
|
+
size: stat.size,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// A rejection has statSync parity: the file is simply absent.
|
|
355
640
|
}
|
|
356
641
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
642
|
+
};
|
|
643
|
+
await Promise.all(Array.from({ length: Math.min(statConcurrency, walked.length) }, () => worker()));
|
|
644
|
+
if (superseded || !shouldContinue()) {
|
|
645
|
+
throw new Error("word index refresh superseded");
|
|
646
|
+
}
|
|
647
|
+
const statWalkMs = Date.now() - statWalkStartMs;
|
|
648
|
+
for (const result of statResults) {
|
|
649
|
+
if (result)
|
|
650
|
+
current.set(wordIndexKey(result.path), result);
|
|
360
651
|
}
|
|
652
|
+
const timings = { sourceWalkMs, statWalkMs, refreshReadsMs: 0 };
|
|
653
|
+
const preflightFiles = Object.assign([...current.values()], {
|
|
654
|
+
truncated: walked.length === maxFiles,
|
|
655
|
+
});
|
|
361
656
|
const oldSet = new Set([...index.docLengths.keys()].map(wordIndexKey));
|
|
362
657
|
let changedSet = 0;
|
|
363
658
|
for (const key of oldSet)
|
|
@@ -368,14 +663,92 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
368
663
|
changedSet++;
|
|
369
664
|
const denominator = Math.max(oldSet.size, current.size, 1);
|
|
370
665
|
if (changedSet / denominator > WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD) {
|
|
371
|
-
|
|
666
|
+
return {
|
|
667
|
+
mode: "full-required",
|
|
668
|
+
reason: "file-set-churn",
|
|
669
|
+
preflightFiles,
|
|
670
|
+
timings,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
// Replacing one document filters every shared posting array for that
|
|
674
|
+
// document's tokens. That is excellent for a sparse edit, but repeating it
|
|
675
|
+
// for most of the corpus becomes effectively quadratic. Decide the dense
|
|
676
|
+
// transition BEFORE dropping or editing anything so the old index remains a
|
|
677
|
+
// valid fallback until a separately-built full replacement is ready (#1197).
|
|
678
|
+
let staleDocuments = 0;
|
|
679
|
+
for (const { path: file, mtimeMs, size } of current.values()) {
|
|
680
|
+
if (index.fileMtimes.get(file) !== mtimeMs ||
|
|
681
|
+
(index.fileSizes.get(file) ?? -1) !== size) {
|
|
682
|
+
staleDocuments += 1;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Density alone is NOT a bound (#1197 review finding 1): the per-document cost
|
|
686
|
+
// GROWS with the corpus, so a stale set comfortably under any fixed ratio (or
|
|
687
|
+
// any fixed absolute ceiling) still costs orders of magnitude more than a
|
|
688
|
+
// rebuild on a big enough repository. 800 documents with 239 stale — 29.875%,
|
|
689
|
+
// just under the ratio — measured 90.6 s with a 39.6 s synchronous block
|
|
690
|
+
// against 1.3 s / 10.6 ms for a full build, and at the 6,000-file cap up to
|
|
691
|
+
// 1,799 stale documents would have stayed on that path.
|
|
692
|
+
//
|
|
693
|
+
// So compare estimated WORK, both sides denominated in tokenizer tokens:
|
|
694
|
+
// incremental ≈ stale x (docTokens x postingLength x scanCost + readCost)
|
|
695
|
+
// full ≈ totalTokens + corpusSize x readCost
|
|
696
|
+
// This is self-bounding in a way no constant is: the incremental path is taken
|
|
697
|
+
// only while its total estimated cost stays under ONE full rebuild, so the
|
|
698
|
+
// worst case is "about as expensive as rebuilding", whatever the corpus size.
|
|
699
|
+
//
|
|
700
|
+
// Both statistics come from cheap metadata passes — one over `postings`
|
|
701
|
+
// (distinct tokens, reading only `.length`) and one over `forward` (documents,
|
|
702
|
+
// reading only `.size`). Neither touches a posting ELEMENT, which is the work
|
|
703
|
+
// this decision exists to avoid.
|
|
704
|
+
let postingEntries = 0;
|
|
705
|
+
let weightedPostingEntries = 0;
|
|
706
|
+
for (const hits of index.postings.values()) {
|
|
707
|
+
postingEntries += hits.length;
|
|
708
|
+
weightedPostingEntries += hits.length * hits.length;
|
|
372
709
|
}
|
|
710
|
+
// The mean posting-array length weighted by OCCURRENCES, not the plain mean:
|
|
711
|
+
// a document's tokens are drawn from the frequency distribution, so the arrays
|
|
712
|
+
// a removal actually filters skew hard toward the high-document-frequency
|
|
713
|
+
// tokens. The unweighted mean (dominated by the long tail of df=1 tokens)
|
|
714
|
+
// underestimates the real cost by an order of magnitude.
|
|
715
|
+
const expectedPostingLength = postingEntries > 0 ? weightedPostingEntries / postingEntries : 0;
|
|
716
|
+
let distinctTokenEntries = 0;
|
|
717
|
+
for (const tokenLineCounts of index.forward.values()) {
|
|
718
|
+
distinctTokenEntries += tokenLineCounts.size;
|
|
719
|
+
}
|
|
720
|
+
const expectedDocumentTokens = index.docCount > 0 ? distinctTokenEntries / index.docCount : 0;
|
|
721
|
+
const estimatedIncrementalWork = staleDocuments *
|
|
722
|
+
(expectedDocumentTokens *
|
|
723
|
+
expectedPostingLength *
|
|
724
|
+
WORD_INDEX_POSTING_SCAN_TOKEN_COST +
|
|
725
|
+
WORD_INDEX_FILE_READ_TOKEN_COST);
|
|
726
|
+
const estimatedFullRebuildWork = index.totalTokens + current.size * WORD_INDEX_FILE_READ_TOKEN_COST;
|
|
727
|
+
// The document-count floor guards the RATIO test only — its documented job is
|
|
728
|
+
// that "one stale file in a three-file project" is 33% but not dense. It must
|
|
729
|
+
// NOT gate the work test: a floor is another constant, and letting one
|
|
730
|
+
// suppress the work comparison would reintroduce exactly the unbounded-on-the
|
|
731
|
+
// -other-axis shape (31 documents at 98 ms each on this repository's own
|
|
732
|
+
// corpus, and worse as the corpus grows). The work test needs no floor — on a
|
|
733
|
+
// small project a full rebuild is cheap, so it never fires there.
|
|
734
|
+
if ((staleDocuments >= WORD_INDEX_DENSE_REFRESH_MIN_DOCUMENTS &&
|
|
735
|
+
staleDocuments / Math.max(current.size, 1) >
|
|
736
|
+
WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD) ||
|
|
737
|
+
estimatedIncrementalWork > estimatedFullRebuildWork) {
|
|
738
|
+
return {
|
|
739
|
+
mode: "full-required",
|
|
740
|
+
reason: "stale-document-churn",
|
|
741
|
+
preflightFiles,
|
|
742
|
+
timings,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
|
|
373
746
|
let dropped = 0;
|
|
374
747
|
for (const key of oldSet) {
|
|
375
748
|
if (!current.has(key)) {
|
|
376
749
|
// `key` is already folded; removeWordIndexDocument re-folds it
|
|
377
750
|
// idempotently via the PathKeyedMap, so the drop hits the right entry.
|
|
378
|
-
if (!
|
|
751
|
+
if (!(await removeWordIndexDocumentAsync(index, key, shouldContinue))) {
|
|
379
752
|
throw new Error(`failed to drop word-index document: ${key}`);
|
|
380
753
|
}
|
|
381
754
|
dropped++;
|
|
@@ -383,9 +756,17 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
383
756
|
}
|
|
384
757
|
let refreshed = 0;
|
|
385
758
|
let skipped = 0;
|
|
386
|
-
|
|
387
|
-
for (const { path: file, mtimeMs } of current.values()) {
|
|
388
|
-
|
|
759
|
+
const refreshReadsStartMs = Date.now();
|
|
760
|
+
for (const { path: file, mtimeMs, size } of current.values()) {
|
|
761
|
+
// #1105: mtime-first, size-second freshness — mtime alone is a stale
|
|
762
|
+
// signal (mtime-preserving content changes serve stale identifiers to
|
|
763
|
+
// symbol_search). Size is free (the stat above already read it) and
|
|
764
|
+
// catches every content change that alters byte length. Both come from the
|
|
765
|
+
// SAME stat, so a mismatch on EITHER axis re-reads. `?? -1` makes a file
|
|
766
|
+
// absent from the (possibly legacy, pre-#1105) size map always count as
|
|
767
|
+
// changed → one-time re-read that repopulates the size, the safe direction.
|
|
768
|
+
if (index.fileMtimes.get(file) !== mtimeMs ||
|
|
769
|
+
(index.fileSizes.get(file) ?? -1) !== size) {
|
|
389
770
|
// A file the walk/stat pass saw can still fail to read here — a
|
|
390
771
|
// transient exclusive lock (antivirus, an editor, a build step) or a
|
|
391
772
|
// file that vanished in the interim. Match collectWordIndexDocs'
|
|
@@ -400,18 +781,27 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
400
781
|
skipped++;
|
|
401
782
|
continue;
|
|
402
783
|
}
|
|
403
|
-
if (!
|
|
784
|
+
if (!(await updateWordIndexDocumentAsync(index, { path: file, content }, shouldContinue))) {
|
|
404
785
|
throw new Error(`failed to refresh word-index document: ${file}`);
|
|
405
786
|
}
|
|
787
|
+
// updateWordIndexDocument stamps mtime=-1 (per-edit convention); the
|
|
788
|
+
// refresh path KNOWS the real on-disk stat, so overwrite both axes with
|
|
789
|
+
// the true values so this document is not needlessly re-read next pass.
|
|
406
790
|
index.fileMtimes.set(file, mtimeMs);
|
|
791
|
+
index.fileSizes.set(file, size);
|
|
407
792
|
refreshed++;
|
|
408
793
|
}
|
|
409
|
-
|
|
410
|
-
|
|
794
|
+
// Same OR as the stat loop. This is the loop the #1197 outage actually
|
|
795
|
+
// blocked in: replacing one document costs more the larger the corpus is,
|
|
796
|
+
// so a count-only checkpoint let 100 replacements run back to back for
|
|
797
|
+
// seconds. The gate above keeps the stale set sparse; this keeps even a
|
|
798
|
+
// sparse-but-expensive set off the event loop.
|
|
799
|
+
if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
|
|
411
800
|
if (!shouldContinue())
|
|
412
801
|
throw new Error("word index refresh superseded");
|
|
413
802
|
}
|
|
414
803
|
}
|
|
804
|
+
timings.refreshReadsMs = Date.now() - refreshReadsStartMs;
|
|
415
805
|
index.truncated = walked.length === maxFiles;
|
|
416
806
|
return {
|
|
417
807
|
mode: "incremental",
|
|
@@ -419,16 +809,212 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
419
809
|
dropped,
|
|
420
810
|
skipped,
|
|
421
811
|
reused: current.size - refreshed - skipped,
|
|
812
|
+
timings,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
export const WORD_INDEX_QUERY_FILTER_KEYS = ["lang", "file", "ext"];
|
|
816
|
+
/** Thrown by {@link parseWordIndexQuery}/{@link buildWordIndexQueryFilter} for
|
|
817
|
+
* an unrecognized `key:` prefix or an unrecognized `lang:` value — a query
|
|
818
|
+
* typo fails loudly with the supported list instead of silently degrading
|
|
819
|
+
* into a literal search term (#1450 acceptance criterion). */
|
|
820
|
+
export class WordIndexQueryError extends Error {
|
|
821
|
+
constructor(message) {
|
|
822
|
+
super(message);
|
|
823
|
+
this.name = "WordIndexQueryError";
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const WORD_INDEX_FILTER_TOKEN_RE = /^(-)?([A-Za-z][A-Za-z0-9_-]*):(.+)$/;
|
|
827
|
+
/**
|
|
828
|
+
* Split a word-index query string into plain search terms and `key:value`
|
|
829
|
+
* prefix filters (#1450). Whitespace-delimited, one regex per token — no
|
|
830
|
+
* quoting scheme invented here, matching the pre-existing query path (which
|
|
831
|
+
* never supported quoting either). A `-` immediately before a recognized
|
|
832
|
+
* `key:` negates that filter; a bare `-term` (no colon) is left as an
|
|
833
|
+
* ordinary token, unchanged from prior behavior — `tokenizeLine` already
|
|
834
|
+
* strips the leading `-` when it extracts identifiers.
|
|
835
|
+
*
|
|
836
|
+
* An unrecognized `key:` (e.g. `type:foo`) throws {@link WordIndexQueryError}
|
|
837
|
+
* naming the supported prefixes, rather than falling through as a literal
|
|
838
|
+
* term — a typo must fail loudly, never silently rank as if the filter never
|
|
839
|
+
* existed. An empty value (`lang:`) is not treated as a filter token at all
|
|
840
|
+
* (kept as a term) since there is nothing to filter by.
|
|
841
|
+
*/
|
|
842
|
+
export function parseWordIndexQuery(query) {
|
|
843
|
+
const filters = [];
|
|
844
|
+
const termParts = [];
|
|
845
|
+
for (const raw of query.split(/\s+/)) {
|
|
846
|
+
if (!raw)
|
|
847
|
+
continue;
|
|
848
|
+
const match = raw.match(WORD_INDEX_FILTER_TOKEN_RE);
|
|
849
|
+
if (!match) {
|
|
850
|
+
termParts.push(raw);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
const [, negation, keyRaw, value] = match;
|
|
854
|
+
const key = keyRaw.toLowerCase();
|
|
855
|
+
if (!value) {
|
|
856
|
+
termParts.push(raw);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (!WORD_INDEX_QUERY_FILTER_KEYS.includes(key)) {
|
|
860
|
+
// Not a recognized filter key. Ordinary search terms legitimately
|
|
861
|
+
// contain colons (std::vector, error:foo, http://…, C:\ paths, a
|
|
862
|
+
// TODO:tag), and the old tokenizer searched them fine — a hard
|
|
863
|
+
// error here is a usability regression, not typo protection. The
|
|
864
|
+
// token passes through as a plain term; the loud-failure contract
|
|
865
|
+
// survives where it is unambiguous: a KNOWN key with a bad value
|
|
866
|
+
// (lang:notalang) still throws.
|
|
867
|
+
termParts.push(raw);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
filters.push({
|
|
871
|
+
key: key,
|
|
872
|
+
value,
|
|
873
|
+
negated: negation === "-",
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
return { terms: termParts.join(" "), filters };
|
|
877
|
+
}
|
|
878
|
+
/** `lang:X` → the extension set for FileKind X (case-insensitive), drawn
|
|
879
|
+
* exclusively from `KIND_EXTENSIONS` (clients/file-kinds.ts) — the single
|
|
880
|
+
* source of truth (#894 invariant: no second, hand-maintained language list).
|
|
881
|
+
* An unrecognized kind throws {@link WordIndexQueryError} listing the accepted
|
|
882
|
+
* kinds (KIND_EXTENSIONS' own keys), the same loud-failure contract as an
|
|
883
|
+
* unsupported `key:` prefix. */
|
|
884
|
+
function resolveLangExtensions(value) {
|
|
885
|
+
const kind = value.toLowerCase();
|
|
886
|
+
const extensions = KIND_EXTENSIONS[kind];
|
|
887
|
+
if (!extensions) {
|
|
888
|
+
const known = Object.keys(KIND_EXTENSIONS).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(", ");
|
|
889
|
+
throw new WordIndexQueryError(`Unknown lang: "${value}" in word-index query — supported languages: ${known}.`);
|
|
890
|
+
}
|
|
891
|
+
return extensions;
|
|
892
|
+
}
|
|
893
|
+
/** `ext:ts` / `ext:.ts` → normalized to a single leading-dot, lowercased form for extname comparison. */
|
|
894
|
+
function normalizeExtFilterValue(value) {
|
|
895
|
+
return `.${value.replace(/^\.+/, "").toLowerCase()}`;
|
|
896
|
+
}
|
|
897
|
+
function resolveWordIndexFilter(filter) {
|
|
898
|
+
if (filter.key === "lang") {
|
|
899
|
+
const extensions = resolveLangExtensions(filter.value);
|
|
900
|
+
return {
|
|
901
|
+
key: filter.key,
|
|
902
|
+
negated: filter.negated,
|
|
903
|
+
test: (file) => extensions.includes(path.extname(file).toLowerCase()),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
if (filter.key === "ext") {
|
|
907
|
+
const normalized = normalizeExtFilterValue(filter.value);
|
|
908
|
+
return {
|
|
909
|
+
key: filter.key,
|
|
910
|
+
negated: filter.negated,
|
|
911
|
+
test: (file) => path.extname(file).toLowerCase() === normalized,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// "file": substring match against the index's own normalized display path
|
|
915
|
+
// (#1450 — see the doc comment on {@link buildWordIndexQueryFilter} for the
|
|
916
|
+
// case-behavior choice). Both sides fold through `wordIndexKey` so the
|
|
917
|
+
// comparison matches the SAME normalization the index's own path keys use.
|
|
918
|
+
const needle = wordIndexKey(filter.value);
|
|
919
|
+
return {
|
|
920
|
+
key: filter.key,
|
|
921
|
+
negated: filter.negated,
|
|
922
|
+
test: (_file, displayPath) => displayPath.includes(needle),
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Build a pre-ranking predicate from parsed query filters (#1450). Composes
|
|
927
|
+
* (AND) with `searchWordIndex`'s pre-existing `fileFilter` option (#771) —
|
|
928
|
+
* `symbol_search`'s structured `paths`/`lang` params and this query's inline
|
|
929
|
+
* filters both apply when both are present.
|
|
930
|
+
*
|
|
931
|
+
* Semantics: multiple positive filters of the SAME key OR together
|
|
932
|
+
* (`lang:ts lang:go` matches either); filters of DIFFERENT keys AND
|
|
933
|
+
* (`lang:ts file:clients/` requires both); a negated filter always subtracts,
|
|
934
|
+
* regardless of any positive filter sharing its key.
|
|
935
|
+
*
|
|
936
|
+
* All filters are RESOLVED EAGERLY (extension lookups, `WordIndexQueryError`
|
|
937
|
+
* thrown) here at build time — before any candidate file is tested — so a bad
|
|
938
|
+
* `lang:` value fails once, loudly, rather than per-file during the scoring
|
|
939
|
+
* loop.
|
|
940
|
+
*
|
|
941
|
+
* `file:` case behavior: matched via `wordIndexKey` (this module's single
|
|
942
|
+
* path-key normalizer, `normalizeEphemeralMapKey`) on BOTH the candidate path
|
|
943
|
+
* and the filter value — slash-folded everywhere, and case-folded ONLY on
|
|
944
|
+
* win32 (Windows' case-insensitive filesystem), preserved on POSIX. This
|
|
945
|
+
* mirrors the SAME normalization the word index's own path-keyed maps already
|
|
946
|
+
* apply (#1025), rather than inventing a second, divergent case rule for this
|
|
947
|
+
* one filter. The candidate path matched is the RAW index-stored path (not
|
|
948
|
+
* cwd-relativized) — the index already stores paths in the form the caller
|
|
949
|
+
* built it with (relative in tests, absolute in the real project walk), and
|
|
950
|
+
* an absolute path's tail always contains its project-relative suffix, so
|
|
951
|
+
* `file:clients/word-index.ts` still matches naturally either way.
|
|
952
|
+
*/
|
|
953
|
+
export function buildWordIndexQueryFilter(filters) {
|
|
954
|
+
if (filters.length === 0)
|
|
955
|
+
return undefined;
|
|
956
|
+
const resolved = filters.map(resolveWordIndexFilter);
|
|
957
|
+
const positivesByKey = new Map();
|
|
958
|
+
const negatives = [];
|
|
959
|
+
for (const r of resolved) {
|
|
960
|
+
if (r.negated) {
|
|
961
|
+
negatives.push(r);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const group = positivesByKey.get(r.key) ?? [];
|
|
965
|
+
group.push(r);
|
|
966
|
+
positivesByKey.set(r.key, group);
|
|
967
|
+
}
|
|
968
|
+
return (file) => {
|
|
969
|
+
const displayPath = wordIndexKey(file);
|
|
970
|
+
for (const negative of negatives) {
|
|
971
|
+
if (negative.test(file, displayPath))
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
for (const group of positivesByKey.values()) {
|
|
975
|
+
if (!group.some((r) => r.test(file, displayPath)))
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
return true;
|
|
422
979
|
};
|
|
423
980
|
}
|
|
981
|
+
function combineFileFilters(a, b) {
|
|
982
|
+
if (!a)
|
|
983
|
+
return b;
|
|
984
|
+
if (!b)
|
|
985
|
+
return a;
|
|
986
|
+
return (file) => a(file) && b(file);
|
|
987
|
+
}
|
|
424
988
|
/**
|
|
425
989
|
* Rank files for a query by BM25 over the query's identifier tokens, then apply
|
|
426
990
|
* priors: demote test/vendor and doc/data files, and boost by graph centrality
|
|
427
991
|
* when supplied. Returns the top {@link RankOptions.limit} files, highest first.
|
|
992
|
+
*
|
|
993
|
+
* The query string may mix plain terms with `lang:`/`file:`/`ext:` prefix
|
|
994
|
+
* filters (+ `-` negation, #1450) — parsed by {@link parseWordIndexQuery} and
|
|
995
|
+
* applied as a `fileFilter` (composed, AND, with any caller-supplied one)
|
|
996
|
+
* BEFORE scoring, same as the pre-existing `paths`/`lang` options (#771): a
|
|
997
|
+
* surviving file's score is unaffected by filtering. A query with no filter
|
|
998
|
+
* tokens reproduces prior output byte-for-byte.
|
|
999
|
+
*
|
|
1000
|
+
* DF-normalization note: BM25's per-token `idf` is computed from `docFrequency`
|
|
1001
|
+
* over the FULL (unfiltered) postings for that token — `fileFilter` (whether
|
|
1002
|
+
* from `options.fileFilter` or from this query's inline filters) is applied
|
|
1003
|
+
* AFTER `idf` is computed, per file, inside the same loop. This means a
|
|
1004
|
+
* filtered query reuses the GLOBAL, corpus-wide document frequency rather than
|
|
1005
|
+
* recomputing it over just the filtered subset. That is the pre-existing #771
|
|
1006
|
+
* behavior this change does not alter; it is an acceptable approximation
|
|
1007
|
+
* (idf reflects true corpus rarity, not an artifact of the filter) and keeps
|
|
1008
|
+
* filtered/unfiltered scores for the SAME file directly comparable, which is
|
|
1009
|
+
* the property #1450 asks for ("BM25 and centrality stay comparable within
|
|
1010
|
+
* the filtered set").
|
|
428
1011
|
*/
|
|
429
1012
|
export function searchWordIndex(index, query, options = {}) {
|
|
430
1013
|
const { demoteTestVendor = true, demoteDocs = true, centrality, limit = 20, fileFilter, } = options;
|
|
431
|
-
const
|
|
1014
|
+
const parsedQuery = parseWordIndexQuery(query);
|
|
1015
|
+
const queryFilter = buildWordIndexQueryFilter(parsedQuery.filters);
|
|
1016
|
+
const combinedFilter = combineFileFilters(fileFilter, queryFilter);
|
|
1017
|
+
const queryTokens = [...new Set(tokenizeLine(parsedQuery.terms))];
|
|
432
1018
|
if (queryTokens.length === 0)
|
|
433
1019
|
return [];
|
|
434
1020
|
const docCount = index.docCount || 1;
|
|
@@ -449,7 +1035,7 @@ export function searchWordIndex(index, query, options = {}) {
|
|
|
449
1035
|
const docFrequency = linesByFile.size;
|
|
450
1036
|
const idf = Math.log(1 + (docCount - docFrequency + 0.5) / (docFrequency + 0.5));
|
|
451
1037
|
for (const [file, lines] of linesByFile) {
|
|
452
|
-
if (
|
|
1038
|
+
if (combinedFilter && !combinedFilter(file))
|
|
453
1039
|
continue;
|
|
454
1040
|
const termFrequency = lines.length;
|
|
455
1041
|
const docLength = index.docLengths.get(file) ?? avgDocLength;
|
|
@@ -510,6 +1096,8 @@ export function centralityFromReverseDeps(index, reverseDeps, normalizeKey = (fi
|
|
|
510
1096
|
}
|
|
511
1097
|
return centrality;
|
|
512
1098
|
}
|
|
1099
|
+
/** Persisted word-index serialization format version. Bump on breaking format changes. */
|
|
1100
|
+
export const WORD_INDEX_FORMAT_VERSION = 2;
|
|
513
1101
|
export function serializeWordIndex(index) {
|
|
514
1102
|
const files = [...index.docLengths.keys()];
|
|
515
1103
|
const fileIndex = new Map();
|
|
@@ -533,7 +1121,7 @@ export function serializeWordIndex(index) {
|
|
|
533
1121
|
])
|
|
534
1122
|
: undefined;
|
|
535
1123
|
return {
|
|
536
|
-
version:
|
|
1124
|
+
version: WORD_INDEX_FORMAT_VERSION,
|
|
537
1125
|
files,
|
|
538
1126
|
postings,
|
|
539
1127
|
docLengths: files.map((file) => index.docLengths.get(file) ?? 0),
|
|
@@ -541,12 +1129,13 @@ export function serializeWordIndex(index) {
|
|
|
541
1129
|
indexedFileCount: index.docCount,
|
|
542
1130
|
truncated: index.truncated,
|
|
543
1131
|
fileMtimes: files.map((file) => index.fileMtimes.get(file) ?? 0),
|
|
1132
|
+
fileSizes: files.map((file) => index.fileSizes.get(file) ?? 0),
|
|
544
1133
|
forward,
|
|
545
1134
|
};
|
|
546
1135
|
}
|
|
547
1136
|
export function deserializeWordIndex(data) {
|
|
548
1137
|
if (!data ||
|
|
549
|
-
data.version !==
|
|
1138
|
+
data.version !== WORD_INDEX_FORMAT_VERSION ||
|
|
550
1139
|
!Array.isArray(data.files) ||
|
|
551
1140
|
!Array.isArray(data.postings) ||
|
|
552
1141
|
!Array.isArray(data.docLengths) ||
|
|
@@ -556,8 +1145,17 @@ export function deserializeWordIndex(data) {
|
|
|
556
1145
|
}
|
|
557
1146
|
const docLengths = new PathKeyedMap(wordIndexKey);
|
|
558
1147
|
const fileMtimes = new PathKeyedMap(wordIndexKey);
|
|
1148
|
+
const fileSizes = new PathKeyedMap(wordIndexKey);
|
|
559
1149
|
data.files.forEach((file, i) => docLengths.set(file, data.docLengths[i] ?? 0));
|
|
560
1150
|
data.files.forEach((file, i) => fileMtimes.set(file, data.fileMtimes[i] ?? 0));
|
|
1151
|
+
// #1105: `fileSizes` is optional on the wire (pre-#1105 snapshots omit it).
|
|
1152
|
+
// Only populate when the array is present AND parallel to `files`; otherwise
|
|
1153
|
+
// leave it empty so the refresh gate re-reads every file once to repopulate,
|
|
1154
|
+
// rather than trusting a bogus/misaligned size. Never treated as "current".
|
|
1155
|
+
if (Array.isArray(data.fileSizes) &&
|
|
1156
|
+
data.fileSizes.length === data.files.length) {
|
|
1157
|
+
data.files.forEach((file, i) => fileSizes.set(file, data.fileSizes?.[i] ?? 0));
|
|
1158
|
+
}
|
|
561
1159
|
const postings = new Map();
|
|
562
1160
|
for (const [token, flat] of data.postings) {
|
|
563
1161
|
if (typeof token !== "string" || !Array.isArray(flat))
|
|
@@ -603,6 +1201,7 @@ export function deserializeWordIndex(data) {
|
|
|
603
1201
|
truncated: data.truncated === true,
|
|
604
1202
|
forward,
|
|
605
1203
|
fileMtimes,
|
|
1204
|
+
fileSizes,
|
|
606
1205
|
};
|
|
607
1206
|
}
|
|
608
1207
|
const buildStatuses = new Map();
|
|
@@ -650,9 +1249,9 @@ export function triggerBackgroundWordIndexBuild(cwd, dbg, options = {}) {
|
|
|
650
1249
|
void (async () => {
|
|
651
1250
|
const startMs = Date.now();
|
|
652
1251
|
try {
|
|
653
|
-
const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION } = await import("./project-snapshot.js");
|
|
1252
|
+
const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
|
|
654
1253
|
const docs = await collectWordIndexDocs(key);
|
|
655
|
-
const index =
|
|
1254
|
+
const index = await buildWordIndexAsync(docs);
|
|
656
1255
|
const existing = loadProjectSnapshot(key);
|
|
657
1256
|
const snapshot = existing ?? {
|
|
658
1257
|
version: PROJECT_SNAPSHOT_VERSION,
|
|
@@ -732,7 +1331,7 @@ function getWordIndexPersistScheduler() {
|
|
|
732
1331
|
}
|
|
733
1332
|
async function writeWordIndexSnapshot(cwd, index, dbg) {
|
|
734
1333
|
try {
|
|
735
|
-
const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION } = await import("./project-snapshot.js");
|
|
1334
|
+
const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
|
|
736
1335
|
const existing = loadProjectSnapshot(cwd);
|
|
737
1336
|
const snapshot = existing ?? {
|
|
738
1337
|
version: PROJECT_SNAPSHOT_VERSION,
|