@herbertgao/pi-extensions 2026.8.13 → 2026.8.15
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 +19 -14
- package/THIRD_PARTY_NOTICES.md +0 -1
- package/node_modules/@czottmann/pi-automode/CHANGELOG.md +19 -0
- package/node_modules/@czottmann/pi-automode/README.md +5 -1
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +126 -13
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@herbertgao/{pi-stash → pi-bark}/LICENSE +4 -5
- package/node_modules/@herbertgao/pi-bark/README.md +44 -0
- package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
- package/node_modules/@herbertgao/pi-bark/package.json +53 -0
- package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/README.md +125 -88
- package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-btw/package.json +5 -8
- package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
- package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
- package/node_modules/@tifan/pi-handoff/README.md +36 -13
- package/node_modules/@tifan/pi-handoff/package.json +2 -2
- package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
- package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
- package/node_modules/@tifan/pi-recap/README.md +1 -1
- package/node_modules/@tifan/pi-recap/package.json +2 -2
- package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
- package/node_modules/@tifan/pi-rename/README.md +42 -21
- package/node_modules/@tifan/pi-rename/package.json +1 -1
- package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
- package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
- package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
- package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
- package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
- package/node_modules/pi-lens/CHANGELOG.md +320 -0
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
- package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
- package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
- package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
- package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
- package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
- package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
- package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
- package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
- package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
- package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
- package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
- package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
- package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
- package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
- package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
- package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
- package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
- package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
- package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
- package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
- package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
- package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
- package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
- package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
- package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
- package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
- package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
- package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
- package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
- package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
- package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
- package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
- package/node_modules/pi-lens/dist/index.js +11243 -6651
- package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
- package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
- package/node_modules/pi-lens/dist/mcp/server.js +6 -1
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
- package/node_modules/pi-lens/docs/agent-guide.md +4 -2
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
- package/node_modules/pi-lens/docs/astplayground.md +26 -17
- package/node_modules/pi-lens/package.json +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +35 -0
- package/node_modules/pi-mcp-adapter/README.md +5 -2
- package/node_modules/pi-mcp-adapter/cli.js +4 -4
- package/node_modules/pi-mcp-adapter/commands.ts +1 -1
- package/node_modules/pi-mcp-adapter/config.ts +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +3 -1
- package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +90 -6
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +124 -32
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
- package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
- package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
- package/node_modules/pi-mcp-adapter/package.json +1 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +62 -22
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
- package/node_modules/pi-mcp-adapter/types.ts +3 -1
- package/node_modules/pi-web-access/CHANGELOG.md +42 -0
- package/node_modules/pi-web-access/README.md +26 -10
- package/node_modules/pi-web-access/curator-page.ts +12 -3
- package/node_modules/pi-web-access/curator-server.ts +3 -1
- package/node_modules/pi-web-access/extract.ts +88 -13
- package/node_modules/pi-web-access/gemini-search.ts +10 -5
- package/node_modules/pi-web-access/github-extract.ts +47 -3
- package/node_modules/pi-web-access/index.ts +40 -40
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/page-query.ts +61 -8
- package/node_modules/pi-web-access/xcrawl.ts +264 -0
- package/package.json +19 -22
- package/node_modules/@herbertgao/pi-stash/README.md +0 -34
- package/node_modules/@herbertgao/pi-stash/package.json +0 -51
- package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
- package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
- package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
- package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
|
@@ -16,11 +16,14 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import { createDeadline, forEachCooperatively, yieldIfOverBudget, } from "./cooperative-budget.js";
|
|
19
|
+
import { incrementDegradationCount } from "./degradation-ledger.js";
|
|
19
20
|
import { KIND_EXTENSIONS } from "./file-kinds.js";
|
|
20
21
|
import { PathKeyedMap } from "./path-keyed-map.js";
|
|
21
22
|
import { isAtOrAboveHomeDir, normalizeEphemeralMapKey } from "./path-utils.js";
|
|
23
|
+
import { createSingleFlight } from "./single-flight.js";
|
|
22
24
|
import { createDebounceScheduler, } from "./persist-debounce.js";
|
|
23
25
|
import { getWordIndexMaxFilesDerived } from "./project-scale.js";
|
|
26
|
+
import { compactPostingsIntoArena, compactPostingsIntoArenaCooperatively, countPostingBackingStores, countPostingEntries, estimateWordIndexStoreBytes, WordForwardEntry, WordIndexFileTable, WordPostingList, } from "./word-index-store.js";
|
|
24
27
|
import { logWordIndex } from "./word-index-logger.js";
|
|
25
28
|
/**
|
|
26
29
|
* The single normalizer every word-index path key folds through (#1025). The
|
|
@@ -160,6 +163,7 @@ const WORD_INDEX_LONG_LINE_YIELD_CHARS = 4096;
|
|
|
160
163
|
function createEmptyWordIndex(truncated) {
|
|
161
164
|
return {
|
|
162
165
|
postings: new Map(),
|
|
166
|
+
fileTable: new WordIndexFileTable(),
|
|
163
167
|
docLengths: new PathKeyedMap(wordIndexKey),
|
|
164
168
|
totalTokens: 0,
|
|
165
169
|
docCount: 0,
|
|
@@ -167,38 +171,274 @@ function createEmptyWordIndex(truncated) {
|
|
|
167
171
|
forward: new PathKeyedMap(wordIndexKey),
|
|
168
172
|
fileMtimes: new PathKeyedMap(wordIndexKey),
|
|
169
173
|
fileSizes: new PathKeyedMap(wordIndexKey),
|
|
174
|
+
replacementStats: { count: 0, totalMs: 0, maxMs: 0 },
|
|
175
|
+
dirtyFiles: new Set(),
|
|
176
|
+
postingStoreCount: 0,
|
|
177
|
+
recompactFlight: createSingleFlight(),
|
|
170
178
|
};
|
|
171
179
|
}
|
|
172
|
-
function
|
|
180
|
+
export function countWordIndexPostingEntries(index) {
|
|
181
|
+
return countPostingEntries(index);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Estimated resident bytes of the index's two packed stores (#2069).
|
|
185
|
+
* Reported on the `full_rebuild`, `incremental_refresh`, and `cold_build`
|
|
186
|
+
* word-index log records and on the `memory_sample` word-index subsystem, so
|
|
187
|
+
* a heap census can be reconciled against the log without taking a snapshot —
|
|
188
|
+
* the gap that let #1999 under-count this subsystem by a factor of sixty.
|
|
189
|
+
*/
|
|
190
|
+
export function estimateWordIndexResidentBytes(index) {
|
|
191
|
+
return estimateWordIndexStoreBytes(index);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Record a posting whose file id the file table cannot resolve (#2069).
|
|
195
|
+
*
|
|
196
|
+
* Unreachable by construction: {@link WordIndexFileTable.release} only runs
|
|
197
|
+
* after the forward index has enumerated and removed every posting naming that
|
|
198
|
+
* id. If it fires, that invariant broke, and the visible symptom is a query
|
|
199
|
+
* returning FEWER results with nothing to distinguish it from a smaller match
|
|
200
|
+
* set — the clean-versus-errored ambiguity AGENTS.md catalogs as shape 10.
|
|
201
|
+
*
|
|
202
|
+
* `incrementDegradationCount` rather than `recordDegradationOnce` because
|
|
203
|
+
* ranking is a hot loop: the tally stays exact for every occurrence while only
|
|
204
|
+
* the first per (kind, orphaned id) writes a durable row. Subject is the id
|
|
205
|
+
* itself, so aggregation keeps the discriminating identity.
|
|
206
|
+
*/
|
|
207
|
+
function recordOrphanWordIndexFileId(fileId, token, seam) {
|
|
208
|
+
incrementDegradationCount({
|
|
209
|
+
kind: "word-index-orphan-file-id",
|
|
210
|
+
subject: `fileId:${fileId}`,
|
|
211
|
+
reason: `${seam} dropped a posting for token "${token}": the file table has no path for this id`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Decode one token’s postings into `{ file, line }` objects. Allocates, so it
|
|
216
|
+
* is for callers that genuinely need the display form (tests, diagnostics) —
|
|
217
|
+
* never for the hot ranking loop, which reads the packed lanes directly.
|
|
218
|
+
*/
|
|
219
|
+
export function wordIndexPostingHits(index, token) {
|
|
220
|
+
const list = index.postings.get(token);
|
|
221
|
+
if (!list)
|
|
222
|
+
return [];
|
|
223
|
+
const hits = [];
|
|
224
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
225
|
+
const fileId = list.fileIdAt(i);
|
|
226
|
+
const file = index.fileTable.pathFor(fileId);
|
|
227
|
+
if (file === undefined) {
|
|
228
|
+
recordOrphanWordIndexFileId(fileId, token, "decode");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
hits.push({ file, line: list.lineAt(i) });
|
|
232
|
+
}
|
|
233
|
+
return hits;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Settle a freshly built index's posting store into its compact resident form
|
|
237
|
+
* (#2069): re-home every list into one shared arena, sized from the live entry
|
|
238
|
+
* counts. That both releases the doubling growth slack a build leaves behind
|
|
239
|
+
* and stops the long tail of rare tokens paying for an `ArrayBuffer` header
|
|
240
|
+
* each.
|
|
241
|
+
*/
|
|
242
|
+
function compactWordIndexPostings(index) {
|
|
243
|
+
compactPostingsIntoArena(index.postings);
|
|
244
|
+
index.postingStoreCount = countPostingBackingStores(index.postings);
|
|
245
|
+
}
|
|
246
|
+
/** Bump the running distinct-backing-store tally by `count` (#2117). */
|
|
247
|
+
function notePostingStoresAllocated(index, count) {
|
|
248
|
+
index.postingStoreCount = (index.postingStoreCount ?? 0) + count;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Floor for the recompaction gate: below this many backing stores the arena is
|
|
252
|
+
* never repacked, so a small index never pays an O(vocab) walk it cannot amortize
|
|
253
|
+
* (#2117). This is also the whole gate for indexes whose vocabulary is under
|
|
254
|
+
* `WORD_INDEX_RECOMPACT_STORE_FLOOR / WORD_INDEX_RECOMPACT_STORE_FRACTION`
|
|
255
|
+
* tokens, keeping the pre-#2246 behaviour for small corpora bit-identical.
|
|
256
|
+
*/
|
|
257
|
+
const WORD_INDEX_RECOMPACT_STORE_FLOOR = 64;
|
|
258
|
+
/**
|
|
259
|
+
* Recompaction gate as a share of the live vocabulary (#2246). The flat 64-store
|
|
260
|
+
* threshold #2117 shipped was crossed by every single edit: one document
|
|
261
|
+
* replacement raises `postingStoreCount` by roughly the edited document's
|
|
262
|
+
* distinct-token count (hundreds), so the whole O(vocab) arena was rebuilt after
|
|
263
|
+
* every edit. Gating on a fraction of `postings.size` instead lets fragmentation
|
|
264
|
+
* accumulate proportionally to the index, so recompaction fires once per many
|
|
265
|
+
* edits rather than once per edit.
|
|
266
|
+
*
|
|
267
|
+
* Memory ceiling: the store count can reach a tenth of the vocabulary in private
|
|
268
|
+
* stores before a repack, each carrying a fixed header plus growth slack. #2117
|
|
269
|
+
* measured the fully-churned ceiling (every token private, store count =
|
|
270
|
+
* vocabulary) at +22% resident. The cost does not scale with the store count
|
|
271
|
+
* alone, because the tokens that churn most are also the ones carrying the
|
|
272
|
+
* longest posting lists. PR #2246's adversarial hot-token probe measured this
|
|
273
|
+
* gate at +13.7% resident at rest and +19.9% peak over base — inside the +22%
|
|
274
|
+
* #2117 accepted, but not by the wide margin a naive store-count ratio would
|
|
275
|
+
* predict. Raising the fraction further would cross that bound.
|
|
276
|
+
*/
|
|
277
|
+
const WORD_INDEX_RECOMPACT_STORE_FRACTION = 0.1;
|
|
278
|
+
/**
|
|
279
|
+
* Backing-store count that triggers a repack for `index`: a fixed floor, or a
|
|
280
|
+
* share of the live vocabulary once the index is large enough for the share to
|
|
281
|
+
* exceed the floor. O(1) — reads `postings.size`, never walks the vocabulary.
|
|
282
|
+
*/
|
|
283
|
+
function wordIndexRecompactThreshold(index) {
|
|
284
|
+
return Math.max(WORD_INDEX_RECOMPACT_STORE_FLOOR, Math.floor(index.postings.size * WORD_INDEX_RECOMPACT_STORE_FRACTION));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Pack the arena if churn has spread the postings across more than the
|
|
288
|
+
* threshold of backing stores (#2117, #2246). Runs the exact O(vocab) store
|
|
289
|
+
* count as the authoritative gate — the per-edit hot path uses the O(1)
|
|
290
|
+
* `postingStoreCount` estimate, so this expensive walk only happens inside the
|
|
291
|
+
* bounded, off-hot-path recompaction it guards. Resets the estimate to the
|
|
292
|
+
* true post-compaction count.
|
|
293
|
+
*/
|
|
294
|
+
async function recompactWordIndexPostingsIfNeeded(index, root) {
|
|
295
|
+
const threshold = wordIndexRecompactThreshold(index);
|
|
296
|
+
const beforeStores = countPostingBackingStores(index.postings);
|
|
297
|
+
if (beforeStores <= threshold) {
|
|
298
|
+
index.postingStoreCount = beforeStores;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const beforeBytes = estimateWordIndexResidentBytes(index);
|
|
302
|
+
await compactPostingsIntoArenaCooperatively(index.postings);
|
|
303
|
+
const afterStores = countPostingBackingStores(index.postings);
|
|
304
|
+
index.postingStoreCount = afterStores;
|
|
305
|
+
const afterBytes = estimateWordIndexResidentBytes(index);
|
|
306
|
+
const firstForRoot = incrementDegradationCount({
|
|
307
|
+
kind: "word-index-arena-recompact",
|
|
308
|
+
subject: path.resolve(root),
|
|
309
|
+
reason: `arena store threshold ${threshold} exceeded`,
|
|
310
|
+
metadata: { beforeBytes, afterBytes, beforeStores, afterStores },
|
|
311
|
+
});
|
|
312
|
+
// Keep the detailed record bounded per root. The ledger still counts every
|
|
313
|
+
// repeated recompaction and emits its own power-of-two summaries.
|
|
314
|
+
if (firstForRoot) {
|
|
315
|
+
logWordIndex({
|
|
316
|
+
phase: "incremental_refresh",
|
|
317
|
+
cwd: path.resolve(root),
|
|
318
|
+
trigger: "incremental_refresh",
|
|
319
|
+
reason: `arena_recompact beforeBytes=${beforeBytes} afterBytes=${afterBytes} beforeStores=${beforeStores} afterStores=${afterStores}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Serialize a recompaction through the per-index async operation queue so no
|
|
325
|
+
* async edit or refresh interleaves it (#2117). The queue plus the corruption-
|
|
326
|
+
* proof cooperative compactor together close the review-F2 data race: the queue
|
|
327
|
+
* excludes async operations, and the compactor's snapshot-and-skip publish
|
|
328
|
+
* excludes the synchronous per-edit path the queue cannot see. The per-index
|
|
329
|
+
* `recompactFlight` single-flight (#1753) keeps a burst of edits from stacking
|
|
330
|
+
* duplicates: a caller that arrives while one recompaction runs joins it rather
|
|
331
|
+
* than starting a second. The single slot uses a constant key.
|
|
332
|
+
*/
|
|
333
|
+
function enqueueWordIndexRecompact(index, root) {
|
|
334
|
+
if ((index.postingStoreCount ?? 0) <= wordIndexRecompactThreshold(index)) {
|
|
335
|
+
return Promise.resolve();
|
|
336
|
+
}
|
|
337
|
+
const flight = (index.recompactFlight ??= createSingleFlight());
|
|
338
|
+
return flight.run("recompact", () => enqueueAsyncWordIndexOperation(index, () => recompactWordIndexPostingsIfNeeded(index, root)));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* O(1) hot-path gate for the synchronous per-edit seam. Reads the running
|
|
342
|
+
* store-count estimate, never a fresh `Set` over the vocabulary, then defers
|
|
343
|
+
* the actual work onto the async queue so it cannot stall the edit (#2117).
|
|
344
|
+
*/
|
|
345
|
+
function scheduleWordIndexRecompact(index, filePath) {
|
|
346
|
+
void enqueueWordIndexRecompact(index, path.dirname(filePath));
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Test hook: settle any queued arena recompaction for `index`. Draining the
|
|
350
|
+
* index's own async operation queue is enough because the recompaction runs
|
|
351
|
+
* through it; there is no module-level scheduler to flush.
|
|
352
|
+
*/
|
|
353
|
+
export async function flushWordIndexRecompactionsForTests(index) {
|
|
354
|
+
await (asyncWordIndexOperations.get(index) ?? Promise.resolve());
|
|
355
|
+
}
|
|
356
|
+
function recordWordIndexReplacement(index, startedAt) {
|
|
357
|
+
const stats = index.replacementStats ?? { count: 0, totalMs: 0, maxMs: 0 };
|
|
358
|
+
const durationMs = Math.max(0, Date.now() - startedAt);
|
|
359
|
+
stats.count += 1;
|
|
360
|
+
stats.totalMs += durationMs;
|
|
361
|
+
stats.maxMs = Math.max(stats.maxMs, durationMs);
|
|
362
|
+
index.replacementStats = stats;
|
|
363
|
+
}
|
|
364
|
+
/** Intern a document path once per index; posting removal compares this id. */
|
|
365
|
+
function internWordIndexFile(index, filePath) {
|
|
366
|
+
return index.fileTable.intern(wordIndexKey(filePath), filePath);
|
|
367
|
+
}
|
|
368
|
+
function appendWordIndexPostings(index, filePath, perTokenHits) {
|
|
369
|
+
const fileId = internWordIndexFile(index, filePath);
|
|
370
|
+
const tokenLineCounts = new Map();
|
|
371
|
+
for (const [token, lineNumbers] of perTokenHits) {
|
|
372
|
+
let list = index.postings.get(token);
|
|
373
|
+
if (list) {
|
|
374
|
+
// A grow allocates a fresh private store; count it so the O(1) gate
|
|
375
|
+
// tracks fragmentation without re-walking the vocabulary (#2117).
|
|
376
|
+
if (list.reserve(list.length + lineNumbers.length)) {
|
|
377
|
+
notePostingStoresAllocated(index, 1);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
list = new WordPostingList(token, lineNumbers.length);
|
|
382
|
+
index.postings.set(token, list);
|
|
383
|
+
notePostingStoresAllocated(index, 1);
|
|
384
|
+
}
|
|
385
|
+
// `list.token` is the canonical instance, so the forward entry points at
|
|
386
|
+
// the same string the postings map is keyed by instead of retaining this
|
|
387
|
+
// document’s own tokenizer allocation (#2069).
|
|
388
|
+
tokenLineCounts.set(list.token, lineNumbers.length);
|
|
389
|
+
for (const line of lineNumbers)
|
|
390
|
+
list.push(fileId, line);
|
|
391
|
+
}
|
|
392
|
+
return tokenLineCounts;
|
|
393
|
+
}
|
|
394
|
+
function commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt) {
|
|
395
|
+
const tokenLineCounts = appendWordIndexPostings(index, doc.path, perTokenHits);
|
|
396
|
+
index.docLengths.set(doc.path, docLength);
|
|
397
|
+
index.forward.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
|
|
398
|
+
index.fileMtimes.set(doc.path, -1);
|
|
399
|
+
index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
|
|
400
|
+
index.dirtyFiles?.add(wordIndexKey(doc.path));
|
|
401
|
+
index.totalTokens += docLength;
|
|
402
|
+
index.docCount += 1;
|
|
403
|
+
recordWordIndexReplacement(index, startedAt);
|
|
404
|
+
}
|
|
405
|
+
function indexWordLine(index, fileId, line, lineNumber, tokenLineCounts) {
|
|
173
406
|
const lineTokens = tokenizeLine(line);
|
|
174
407
|
const seenOnLine = new Set();
|
|
175
408
|
for (const token of lineTokens) {
|
|
176
409
|
if (seenOnLine.has(token))
|
|
177
410
|
continue;
|
|
178
411
|
seenOnLine.add(token);
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
412
|
+
let list = index.postings.get(token);
|
|
413
|
+
if (!list) {
|
|
414
|
+
list = new WordPostingList(token, 1);
|
|
415
|
+
index.postings.set(token, list);
|
|
416
|
+
}
|
|
417
|
+
list.push(fileId, lineNumber);
|
|
418
|
+
// Canonical instance, not this line’s fresh tokenizer allocation (#2069).
|
|
419
|
+
const canonical = list.token;
|
|
420
|
+
tokenLineCounts.set(canonical, (tokenLineCounts.get(canonical) ?? 0) + 1);
|
|
185
421
|
}
|
|
186
422
|
return lineTokens.length;
|
|
187
423
|
}
|
|
188
424
|
function finishWordIndexDocument(index, doc, docLength, tokenLineCounts) {
|
|
189
425
|
index.docLengths.set(doc.path, docLength);
|
|
190
|
-
index.forward?.set(doc.path, tokenLineCounts);
|
|
426
|
+
index.forward?.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
|
|
191
427
|
index.fileMtimes.set(doc.path, doc.mtimeMs ?? 0);
|
|
192
428
|
index.fileSizes.set(doc.path, doc.size ?? Buffer.byteLength(doc.content, "utf-8"));
|
|
429
|
+
// A build has no prior wire cache. Keeping the marker makes a future
|
|
430
|
+
// serializer cache explicit and gives incremental refresh one uniform seam.
|
|
431
|
+
index.dirtyFiles?.add(wordIndexKey(doc.path));
|
|
193
432
|
index.totalTokens += docLength;
|
|
194
433
|
index.docCount += 1;
|
|
195
434
|
}
|
|
196
435
|
function indexWordDocument(index, doc) {
|
|
436
|
+
const fileId = internWordIndexFile(index, doc.path);
|
|
197
437
|
const lines = doc.content.split(/\r?\n/);
|
|
198
438
|
const tokenLineCounts = new Map();
|
|
199
439
|
let docLength = 0;
|
|
200
440
|
for (let i = 0; i < lines.length; i += 1) {
|
|
201
|
-
docLength += indexWordLine(index,
|
|
441
|
+
docLength += indexWordLine(index, fileId, lines[i], i + 1, tokenLineCounts);
|
|
202
442
|
}
|
|
203
443
|
finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
|
|
204
444
|
}
|
|
@@ -206,6 +446,7 @@ export function buildWordIndex(files) {
|
|
|
206
446
|
const index = createEmptyWordIndex(files.truncated ?? false);
|
|
207
447
|
for (const doc of files)
|
|
208
448
|
indexWordDocument(index, doc);
|
|
449
|
+
compactWordIndexPostings(index);
|
|
209
450
|
return index;
|
|
210
451
|
}
|
|
211
452
|
/**
|
|
@@ -221,12 +462,13 @@ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
|
|
|
221
462
|
for (const doc of files) {
|
|
222
463
|
if (!shouldContinue())
|
|
223
464
|
throw new Error("word index build superseded");
|
|
465
|
+
const fileId = internWordIndexFile(index, doc.path);
|
|
224
466
|
const lines = doc.content.split(/\r?\n/);
|
|
225
467
|
const tokenLineCounts = new Map();
|
|
226
468
|
let docLength = 0;
|
|
227
469
|
for (let i = 0; i < lines.length; i += 1) {
|
|
228
470
|
const line = lines[i];
|
|
229
|
-
docLength += indexWordLine(index,
|
|
471
|
+
docLength += indexWordLine(index, fileId, line, i + 1, tokenLineCounts);
|
|
230
472
|
if (line.length >= WORD_INDEX_LONG_LINE_YIELD_CHARS ||
|
|
231
473
|
deadline.expired()) {
|
|
232
474
|
await yieldIfOverBudget(deadline);
|
|
@@ -240,6 +482,7 @@ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
|
|
|
240
482
|
throw new Error("word index build superseded");
|
|
241
483
|
}
|
|
242
484
|
}
|
|
485
|
+
compactWordIndexPostings(index);
|
|
243
486
|
return index;
|
|
244
487
|
}
|
|
245
488
|
/**
|
|
@@ -256,19 +499,27 @@ export function removeWordIndexDocument(index, filePath) {
|
|
|
256
499
|
const tokenLineCounts = index.forward.get(filePath);
|
|
257
500
|
if (!tokenLineCounts)
|
|
258
501
|
return false;
|
|
259
|
-
// `postings` is token-keyed (not a PathKeyedMap), so
|
|
260
|
-
//
|
|
261
|
-
//
|
|
502
|
+
// `postings` is token-keyed (not a PathKeyedMap), so the file identity it
|
|
503
|
+
// carries must be resolved through the SAME normalizer the path maps use —
|
|
504
|
+
// otherwise a build-form hit (`SUB/a.ts`) survives an edit-form removal
|
|
262
505
|
// (`sub/a.ts`) on a case-insensitive FS and lingers as a stale posting
|
|
263
|
-
// (the #1025 item #2 bug this fix closes).
|
|
506
|
+
// (the #1025 item #2 bug this fix closes). Since #2069 that identity is the
|
|
507
|
+
// file table’s integer id, so the comparison below is an int compare rather
|
|
508
|
+
// than a per-element string compare.
|
|
264
509
|
const removedKey = wordIndexKey(filePath);
|
|
510
|
+
const removedId = index.fileTable.idFor(removedKey);
|
|
511
|
+
if (removedId === undefined)
|
|
512
|
+
return false;
|
|
265
513
|
for (const token of tokenLineCounts.keys()) {
|
|
266
|
-
const
|
|
267
|
-
if (!
|
|
514
|
+
const list = index.postings.get(token);
|
|
515
|
+
if (!list)
|
|
268
516
|
continue;
|
|
269
|
-
const next =
|
|
270
|
-
|
|
517
|
+
const next = list.withoutFile(removedId);
|
|
518
|
+
// `withoutFile` always returns a freshly allocated private store (#2117).
|
|
519
|
+
if (next.length > 0) {
|
|
271
520
|
index.postings.set(token, next);
|
|
521
|
+
notePostingStoresAllocated(index, 1);
|
|
522
|
+
}
|
|
272
523
|
else
|
|
273
524
|
index.postings.delete(token);
|
|
274
525
|
}
|
|
@@ -277,6 +528,8 @@ export function removeWordIndexDocument(index, filePath) {
|
|
|
277
528
|
index.forward.delete(filePath);
|
|
278
529
|
index.fileMtimes.delete(filePath);
|
|
279
530
|
index.fileSizes.delete(filePath);
|
|
531
|
+
index.dirtyFiles?.add(wordIndexKey(filePath));
|
|
532
|
+
index.fileTable.release(removedKey);
|
|
280
533
|
index.totalTokens -= docLength;
|
|
281
534
|
index.docCount = Math.max(0, index.docCount - 1);
|
|
282
535
|
return true;
|
|
@@ -297,6 +550,7 @@ export function removeWordIndexDocument(index, filePath) {
|
|
|
297
550
|
export function updateWordIndexDocument(index, doc) {
|
|
298
551
|
if (!index.forward)
|
|
299
552
|
return false;
|
|
553
|
+
const startedAt = Date.now();
|
|
300
554
|
// Remove the old contribution first (no-op if this is a brand new doc).
|
|
301
555
|
if (index.forward.has(doc.path)) {
|
|
302
556
|
removeWordIndexDocument(index, doc.path);
|
|
@@ -322,34 +576,27 @@ export function updateWordIndexDocument(index, doc) {
|
|
|
322
576
|
perTokenHits.set(token, [i + 1]);
|
|
323
577
|
}
|
|
324
578
|
}
|
|
325
|
-
const tokenLineCounts = new Map();
|
|
326
|
-
for (const [token, lineNumbers] of perTokenHits) {
|
|
327
|
-
tokenLineCounts.set(token, lineNumbers.length);
|
|
328
|
-
const hits = lineNumbers.map((line) => ({ file: doc.path, line }));
|
|
329
|
-
const arr = index.postings.get(token);
|
|
330
|
-
if (arr)
|
|
331
|
-
arr.push(...hits);
|
|
332
|
-
else
|
|
333
|
-
index.postings.set(token, hits);
|
|
334
|
-
}
|
|
335
|
-
index.docLengths.set(doc.path, docLength);
|
|
336
|
-
index.forward.set(doc.path, tokenLineCounts);
|
|
337
579
|
// Per-edit callers generally already have content but not a stat. -1 is an
|
|
338
580
|
// impossible real mtime, so it deliberately makes the document stale at the
|
|
339
581
|
// next startup refresh (`-1 !== realMtime` always) — unlike 0, which is a
|
|
340
582
|
// legal on-disk mtime (SOURCE_DATE_EPOCH=0, archive extraction) and would
|
|
341
583
|
// collide, leaving such a file never re-tokenized (#958 review F2).
|
|
342
|
-
index
|
|
343
|
-
|
|
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;
|
|
584
|
+
commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
|
|
585
|
+
scheduleWordIndexRecompact(index, doc.path);
|
|
351
586
|
return true;
|
|
352
587
|
}
|
|
588
|
+
const asyncWordIndexOperations = new WeakMap();
|
|
589
|
+
function enqueueAsyncWordIndexOperation(index, operation) {
|
|
590
|
+
const previous = asyncWordIndexOperations.get(index) ?? Promise.resolve();
|
|
591
|
+
const run = previous.catch(() => undefined).then(operation);
|
|
592
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
593
|
+
asyncWordIndexOperations.set(index, settled);
|
|
594
|
+
return run.finally(() => {
|
|
595
|
+
if (asyncWordIndexOperations.get(index) === settled) {
|
|
596
|
+
asyncWordIndexOperations.delete(index);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
353
600
|
async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
|
|
354
601
|
if (!index.forward)
|
|
355
602
|
return undefined;
|
|
@@ -357,31 +604,43 @@ async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
|
|
|
357
604
|
if (!tokenLineCounts)
|
|
358
605
|
return undefined;
|
|
359
606
|
const removedKey = wordIndexKey(filePath);
|
|
607
|
+
const removedId = index.fileTable.idFor(removedKey);
|
|
608
|
+
if (removedId === undefined)
|
|
609
|
+
return undefined;
|
|
360
610
|
const postings = new Map();
|
|
361
611
|
const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
|
|
362
612
|
for (const token of tokenLineCounts.keys()) {
|
|
363
613
|
if (!shouldContinue())
|
|
364
614
|
throw new Error("word index refresh superseded");
|
|
365
|
-
const
|
|
366
|
-
if (!
|
|
615
|
+
const list = index.postings.get(token);
|
|
616
|
+
if (!list)
|
|
367
617
|
continue;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
618
|
+
// One token's postings, filtered by the SAME packed primitive the
|
|
619
|
+
// synchronous path uses, so the staged result is identical rather than
|
|
620
|
+
// merely equivalent. The work unit is the token, not the posting element:
|
|
621
|
+
// a per-element deadline check cost one `performance.now()` per entry —
|
|
622
|
+
// on the order of a million per edit on a 2.2M-posting corpus — which is
|
|
623
|
+
// the tax that made this path unusable from the per-edit seam (#2067).
|
|
624
|
+
// One list's scan is a bounded, non-yieldable unit; the deadline is
|
|
625
|
+
// checked between lists.
|
|
626
|
+
const survivors = list.withoutFile(removedId);
|
|
627
|
+
postings.set(token, survivors.length > 0 ? survivors : undefined);
|
|
628
|
+
if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
|
|
629
|
+
if (!shouldContinue())
|
|
630
|
+
throw new Error("word index refresh superseded");
|
|
376
631
|
}
|
|
377
|
-
postings.set(token, next.length > 0 ? next : undefined);
|
|
378
632
|
}
|
|
379
633
|
return { postings, docLength: index.docLengths.get(filePath) ?? 0 };
|
|
380
634
|
}
|
|
381
635
|
function commitWordIndexDocumentRemoval(index, filePath, staged) {
|
|
382
|
-
for (const [token,
|
|
383
|
-
|
|
384
|
-
|
|
636
|
+
for (const [token, list] of staged.postings) {
|
|
637
|
+
// Staged survivors come from `WordPostingList.withoutFile` — a fresh
|
|
638
|
+
// private store each, so the O(1) fragmentation gate must count them
|
|
639
|
+
// (#2117).
|
|
640
|
+
if (list) {
|
|
641
|
+
index.postings.set(token, list);
|
|
642
|
+
notePostingStoresAllocated(index, 1);
|
|
643
|
+
}
|
|
385
644
|
else
|
|
386
645
|
index.postings.delete(token);
|
|
387
646
|
}
|
|
@@ -389,23 +648,49 @@ function commitWordIndexDocumentRemoval(index, filePath, staged) {
|
|
|
389
648
|
index.forward?.delete(filePath);
|
|
390
649
|
index.fileMtimes.delete(filePath);
|
|
391
650
|
index.fileSizes.delete(filePath);
|
|
651
|
+
index.dirtyFiles?.add(wordIndexKey(filePath));
|
|
652
|
+
index.fileTable.release(wordIndexKey(filePath));
|
|
392
653
|
index.totalTokens -= staged.docLength;
|
|
393
654
|
index.docCount = Math.max(0, index.docCount - 1);
|
|
394
655
|
}
|
|
395
656
|
/** Cooperative, atomically-published removal used only by bulk refresh. */
|
|
396
657
|
export async function removeWordIndexDocumentAsync(index, filePath, shouldContinue = () => true) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
658
|
+
return enqueueAsyncWordIndexOperation(index, async () => {
|
|
659
|
+
const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
|
|
660
|
+
if (!staged)
|
|
661
|
+
return false;
|
|
662
|
+
if (!shouldContinue())
|
|
663
|
+
throw new Error("word index refresh superseded");
|
|
664
|
+
commitWordIndexDocumentRemoval(index, filePath, staged);
|
|
665
|
+
return true;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The per-edit replacement primitive the cascade seam calls (#2067).
|
|
670
|
+
*
|
|
671
|
+
* `updateWordIndexDocumentAsync` plus the arena-recompaction gate the
|
|
672
|
+
* synchronous variant carries, which is what makes this the per-edit entry
|
|
673
|
+
* point rather than a rename. Bulk refresh must NOT use it: one refreshed
|
|
674
|
+
* document raises `postingStoreCount` by roughly its distinct-token count, so a
|
|
675
|
+
* per-document schedule would drive an O(vocabulary) recompaction between
|
|
676
|
+
* nearly every pair of documents. `refreshWordIndexIncrementally` therefore
|
|
677
|
+
* drives `updateWordIndexDocumentAsync` directly and recompacts ONCE after its
|
|
678
|
+
* loop; the per-edit seam has no such "after the loop" and schedules here.
|
|
679
|
+
*/
|
|
680
|
+
export async function updateWordIndexDocumentForEdit(index, doc) {
|
|
681
|
+
const updated = await updateWordIndexDocumentAsync(index, doc);
|
|
682
|
+
if (updated)
|
|
683
|
+
scheduleWordIndexRecompact(index, doc.path);
|
|
684
|
+
return updated;
|
|
404
685
|
}
|
|
405
686
|
/** Cooperative replacement whose old/new state is committed without an await. */
|
|
406
687
|
export async function updateWordIndexDocumentAsync(index, doc, shouldContinue = () => true) {
|
|
688
|
+
return enqueueAsyncWordIndexOperation(index, () => updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue));
|
|
689
|
+
}
|
|
690
|
+
async function updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue) {
|
|
407
691
|
if (!index.forward)
|
|
408
692
|
return false;
|
|
693
|
+
const startedAt = Date.now();
|
|
409
694
|
const removal = index.forward.has(doc.path)
|
|
410
695
|
? await stageWordIndexDocumentRemoval(index, doc.path, shouldContinue)
|
|
411
696
|
: undefined;
|
|
@@ -437,22 +722,7 @@ export async function updateWordIndexDocumentAsync(index, doc, shouldContinue =
|
|
|
437
722
|
throw new Error("word index refresh superseded");
|
|
438
723
|
if (removal)
|
|
439
724
|
commitWordIndexDocumentRemoval(index, doc.path, removal);
|
|
440
|
-
|
|
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"));
|
|
454
|
-
index.totalTokens += docLength;
|
|
455
|
-
index.docCount += 1;
|
|
725
|
+
commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
|
|
456
726
|
return true;
|
|
457
727
|
}
|
|
458
728
|
/** Bounds shared by every word-index build path — keep the walk off the
|
|
@@ -567,6 +837,12 @@ const WORD_INDEX_FILE_READ_TOKEN_COST = 300;
|
|
|
567
837
|
* against a 1,436 ms rebuild + re-read). The synthetic high-df corpus in the
|
|
568
838
|
* #1197 probe gives 0.09, so this is the conservative (incremental-favouring)
|
|
569
839
|
* end of the observed range.
|
|
840
|
+
*
|
|
841
|
+
* #2069 made the scan an Int32 compare over packed lanes instead of a string
|
|
842
|
+
* compare over boxed objects, so the real per-element cost is now BELOW this
|
|
843
|
+
* constant. The constant is left as measured: over-stating incremental cost
|
|
844
|
+
* only ever routes work to a full rebuild, which is the bounded side of the
|
|
845
|
+
* decision. Re-measuring it is #2067/#2068 territory, not this change.
|
|
570
846
|
*/
|
|
571
847
|
const WORD_INDEX_POSTING_SCAN_TOKEN_COST = 0.2;
|
|
572
848
|
/**
|
|
@@ -703,9 +979,9 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
703
979
|
// this decision exists to avoid.
|
|
704
980
|
let postingEntries = 0;
|
|
705
981
|
let weightedPostingEntries = 0;
|
|
706
|
-
for (const
|
|
707
|
-
postingEntries +=
|
|
708
|
-
weightedPostingEntries +=
|
|
982
|
+
for (const list of index.postings.values()) {
|
|
983
|
+
postingEntries += list.length;
|
|
984
|
+
weightedPostingEntries += list.length * list.length;
|
|
709
985
|
}
|
|
710
986
|
// The mean posting-array length weighted by OCCURRENCES, not the plain mean:
|
|
711
987
|
// a document's tokens are drawn from the frequency distribution, so the arrays
|
|
@@ -802,6 +1078,9 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
|
|
|
802
1078
|
}
|
|
803
1079
|
}
|
|
804
1080
|
timings.refreshReadsMs = Date.now() - refreshReadsStartMs;
|
|
1081
|
+
// Route through the same guarded queue the per-edit seam uses, so a refresh
|
|
1082
|
+
// and a concurrent cascade edit cannot both drive a recompaction (#2117).
|
|
1083
|
+
await enqueueWordIndexRecompact(index, root);
|
|
805
1084
|
index.truncated = walked.length === maxFiles;
|
|
806
1085
|
return {
|
|
807
1086
|
mode: "incremental",
|
|
@@ -1026,17 +1305,29 @@ export function searchWordIndex(index, query, options = {}) {
|
|
|
1026
1305
|
const posting = index.postings.get(token);
|
|
1027
1306
|
if (!posting)
|
|
1028
1307
|
continue;
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1308
|
+
// Grouped by the packed file id, then resolved to the display path once
|
|
1309
|
+
// per (token, file) instead of once per posting (#2069). Insertion order,
|
|
1310
|
+
// document frequency, and the per-file line lists are identical to the
|
|
1311
|
+
// boxed representation, so scores and ordering are unchanged.
|
|
1312
|
+
const linesByFileId = new Map();
|
|
1313
|
+
for (let i = 0; i < posting.length; i += 1) {
|
|
1314
|
+
const fileId = posting.fileIdAt(i);
|
|
1315
|
+
const arr = linesByFileId.get(fileId);
|
|
1032
1316
|
if (arr)
|
|
1033
|
-
arr.push(
|
|
1317
|
+
arr.push(posting.lineAt(i));
|
|
1034
1318
|
else
|
|
1035
|
-
|
|
1319
|
+
linesByFileId.set(fileId, [posting.lineAt(i)]);
|
|
1036
1320
|
}
|
|
1037
|
-
const docFrequency =
|
|
1321
|
+
const docFrequency = linesByFileId.size;
|
|
1038
1322
|
const idf = Math.log(1 + (docCount - docFrequency + 0.5) / (docFrequency + 0.5));
|
|
1039
|
-
for (const [
|
|
1323
|
+
for (const [fileId, lines] of linesByFileId) {
|
|
1324
|
+
const file = index.fileTable.pathFor(fileId);
|
|
1325
|
+
if (file === undefined) {
|
|
1326
|
+
// Dropping this silently would shorten the result list with nothing to
|
|
1327
|
+
// tell it apart from a smaller match set. Record, then drop.
|
|
1328
|
+
recordOrphanWordIndexFileId(fileId, token, "search");
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1040
1331
|
if (combinedFilter && !combinedFilter(file))
|
|
1041
1332
|
continue;
|
|
1042
1333
|
const termFrequency = lines.length;
|
|
@@ -1100,18 +1391,32 @@ export function centralityFromReverseDeps(index, reverseDeps, normalizeKey = (fi
|
|
|
1100
1391
|
}
|
|
1101
1392
|
/** Persisted word-index serialization format version. Bump on breaking format changes. */
|
|
1102
1393
|
export const WORD_INDEX_FORMAT_VERSION = 2;
|
|
1103
|
-
|
|
1394
|
+
const serializedWordIndexCaches = new WeakMap();
|
|
1395
|
+
let _lastSerializeWork;
|
|
1396
|
+
/** Test-only: work stats for the most recent {@link serializeWordIndex} call. */
|
|
1397
|
+
export function getLastWordIndexSerializeWork() {
|
|
1398
|
+
return _lastSerializeWork;
|
|
1399
|
+
}
|
|
1400
|
+
function serializeWordIndexFull(index) {
|
|
1104
1401
|
const files = [...index.docLengths.keys()];
|
|
1105
|
-
|
|
1106
|
-
|
|
1402
|
+
// `files` carries whatever spelling `docLengths` last stored, which can
|
|
1403
|
+
// differ from the spelling the file table interned. Both fold through
|
|
1404
|
+
// `wordIndexKey`, so the slot lookup runs in the folded key space and a
|
|
1405
|
+
// separator- or case-divergent spelling still finds its postings (#1025).
|
|
1406
|
+
const slotByFileId = new Map();
|
|
1407
|
+
files.forEach((file, i) => {
|
|
1408
|
+
const fileId = index.fileTable.idFor(wordIndexKey(file));
|
|
1409
|
+
if (fileId !== undefined)
|
|
1410
|
+
slotByFileId.set(fileId, i);
|
|
1411
|
+
});
|
|
1107
1412
|
const postings = [];
|
|
1108
|
-
for (const [token,
|
|
1413
|
+
for (const [token, list] of index.postings) {
|
|
1109
1414
|
const flat = [];
|
|
1110
|
-
for (
|
|
1111
|
-
const
|
|
1112
|
-
if (
|
|
1415
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
1416
|
+
const slot = slotByFileId.get(list.fileIdAt(i));
|
|
1417
|
+
if (slot === undefined)
|
|
1113
1418
|
continue;
|
|
1114
|
-
flat.push(
|
|
1419
|
+
flat.push(slot, list.lineAt(i));
|
|
1115
1420
|
}
|
|
1116
1421
|
if (flat.length > 0)
|
|
1117
1422
|
postings.push([token, flat]);
|
|
@@ -1119,10 +1424,10 @@ export function serializeWordIndex(index) {
|
|
|
1119
1424
|
const forward = index.forward
|
|
1120
1425
|
? files.map((file, i) => [
|
|
1121
1426
|
i,
|
|
1122
|
-
[...(index.forward.get(file) ??
|
|
1427
|
+
[...(index.forward.get(file)?.entries() ?? [])],
|
|
1123
1428
|
])
|
|
1124
1429
|
: undefined;
|
|
1125
|
-
|
|
1430
|
+
const serialized = {
|
|
1126
1431
|
version: WORD_INDEX_FORMAT_VERSION,
|
|
1127
1432
|
files,
|
|
1128
1433
|
postings,
|
|
@@ -1134,6 +1439,145 @@ export function serializeWordIndex(index) {
|
|
|
1134
1439
|
fileSizes: files.map((file) => index.fileSizes.get(file) ?? 0),
|
|
1135
1440
|
forward,
|
|
1136
1441
|
};
|
|
1442
|
+
const tokensByFile = new Map();
|
|
1443
|
+
if (index.forward) {
|
|
1444
|
+
for (const file of files) {
|
|
1445
|
+
tokensByFile.set(wordIndexKey(file), new Set(index.forward.get(file)?.keys() ?? []));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
_lastSerializeWork = {
|
|
1449
|
+
affectedTokenCount: postings.length,
|
|
1450
|
+
tookFullPath: true,
|
|
1451
|
+
};
|
|
1452
|
+
return { serialized, slotByFileId, tokensByFile };
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Refresh the cached wire view without walking untouched posting lanes.
|
|
1456
|
+
* Existing files keep their slots, so a document replacement only rebuilds
|
|
1457
|
+
* the token lists named by that document's old or new forward entry. A file
|
|
1458
|
+
* removal or reordering changes slot identity and deliberately takes the
|
|
1459
|
+
* bounded full path.
|
|
1460
|
+
*/
|
|
1461
|
+
function serializeWordIndexIncrementally(index, cache) {
|
|
1462
|
+
const files = [...index.docLengths.keys()];
|
|
1463
|
+
const priorFiles = cache.serialized.files;
|
|
1464
|
+
const currentKeys = new Set(files.map(wordIndexKey));
|
|
1465
|
+
const priorKeys = new Set(priorFiles.map(wordIndexKey));
|
|
1466
|
+
if ([...priorKeys].some((key) => !currentKeys.has(key))) {
|
|
1467
|
+
return serializeWordIndexFull(index);
|
|
1468
|
+
}
|
|
1469
|
+
// Re-flattening most lanes costs more than rebuilding the compact wire view.
|
|
1470
|
+
// The review fixture crosses over between 10% and 100% dirty, so use the
|
|
1471
|
+
// midpoint as a conservative bound until a workload-specific model exists.
|
|
1472
|
+
const dirty = index.dirtyFiles;
|
|
1473
|
+
if (dirty && dirty.size * 2 > files.length) {
|
|
1474
|
+
return serializeWordIndexFull(index);
|
|
1475
|
+
}
|
|
1476
|
+
// A replacement deletes and re-adds a PathKeyedMap entry, which changes Map
|
|
1477
|
+
// insertion order. Keep the cached wire slots stable instead of rewriting
|
|
1478
|
+
// every posting just because one document changed order.
|
|
1479
|
+
const filesInWireOrder = [
|
|
1480
|
+
...priorFiles,
|
|
1481
|
+
...files.filter((file) => !priorKeys.has(wordIndexKey(file))),
|
|
1482
|
+
];
|
|
1483
|
+
if (!dirty || dirty.size === 0) {
|
|
1484
|
+
_lastSerializeWork = { affectedTokenCount: 0, tookFullPath: false };
|
|
1485
|
+
return cache;
|
|
1486
|
+
}
|
|
1487
|
+
const slotByFileId = new Map(cache.slotByFileId);
|
|
1488
|
+
for (let i = priorFiles.length; i < filesInWireOrder.length; i += 1) {
|
|
1489
|
+
const fileId = index.fileTable.idFor(wordIndexKey(filesInWireOrder[i]));
|
|
1490
|
+
if (fileId !== undefined)
|
|
1491
|
+
slotByFileId.set(fileId, i);
|
|
1492
|
+
}
|
|
1493
|
+
const postings = cache.serialized.postings.slice();
|
|
1494
|
+
const postingAt = new Map();
|
|
1495
|
+
postings.forEach(([token], i) => postingAt.set(token, i));
|
|
1496
|
+
const removedTokens = new Set();
|
|
1497
|
+
const tokensByFile = new Map(cache.tokensByFile);
|
|
1498
|
+
const fileByKey = new Map(filesInWireOrder.map((file) => [wordIndexKey(file), file]));
|
|
1499
|
+
const affectedTokens = new Set();
|
|
1500
|
+
for (const fileKey of dirty) {
|
|
1501
|
+
const file = fileByKey.get(fileKey);
|
|
1502
|
+
const oldTokens = tokensByFile.get(fileKey) ?? new Set();
|
|
1503
|
+
const currentEntry = file === undefined ? undefined : index.forward?.get(file);
|
|
1504
|
+
const currentTokens = new Set(currentEntry?.keys() ?? []);
|
|
1505
|
+
for (const token of oldTokens)
|
|
1506
|
+
affectedTokens.add(token);
|
|
1507
|
+
for (const token of currentTokens)
|
|
1508
|
+
affectedTokens.add(token);
|
|
1509
|
+
if (file === undefined)
|
|
1510
|
+
tokensByFile.delete(fileKey);
|
|
1511
|
+
else
|
|
1512
|
+
tokensByFile.set(fileKey, currentTokens);
|
|
1513
|
+
}
|
|
1514
|
+
for (const token of affectedTokens) {
|
|
1515
|
+
const list = index.postings.get(token);
|
|
1516
|
+
const flat = [];
|
|
1517
|
+
if (list) {
|
|
1518
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
1519
|
+
const slot = slotByFileId.get(list.fileIdAt(i));
|
|
1520
|
+
if (slot !== undefined)
|
|
1521
|
+
flat.push(slot, list.lineAt(i));
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
const at = postingAt.get(token);
|
|
1525
|
+
if (flat.length === 0) {
|
|
1526
|
+
if (at !== undefined)
|
|
1527
|
+
removedTokens.add(token);
|
|
1528
|
+
}
|
|
1529
|
+
else if (at === undefined) {
|
|
1530
|
+
postings.push([token, flat]);
|
|
1531
|
+
postingAt.set(token, postings.length - 1);
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
postings[at] = [postings[at][0], flat];
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const compactedPostings = removedTokens.size
|
|
1538
|
+
? postings.filter(([token]) => !removedTokens.has(token))
|
|
1539
|
+
: postings;
|
|
1540
|
+
const forward = index.forward
|
|
1541
|
+
? (cache.serialized.forward
|
|
1542
|
+
?.slice()
|
|
1543
|
+
.map((entry) => [entry[0], entry[1]]) ?? [])
|
|
1544
|
+
: undefined;
|
|
1545
|
+
for (const fileKey of dirty) {
|
|
1546
|
+
const file = fileByKey.get(fileKey);
|
|
1547
|
+
const fileId = file === undefined
|
|
1548
|
+
? undefined
|
|
1549
|
+
: index.fileTable.idFor(wordIndexKey(file));
|
|
1550
|
+
const slot = fileId === undefined ? undefined : slotByFileId.get(fileId);
|
|
1551
|
+
if (slot === undefined || !forward || file === undefined)
|
|
1552
|
+
continue;
|
|
1553
|
+
forward[slot] = [slot, [...(index.forward?.get(file)?.entries() ?? [])]];
|
|
1554
|
+
}
|
|
1555
|
+
const serialized = {
|
|
1556
|
+
...cache.serialized,
|
|
1557
|
+
files: filesInWireOrder,
|
|
1558
|
+
postings: compactedPostings,
|
|
1559
|
+
docLengths: filesInWireOrder.map((file) => index.docLengths.get(file) ?? 0),
|
|
1560
|
+
fileMtimes: filesInWireOrder.map((file) => index.fileMtimes.get(file) ?? 0),
|
|
1561
|
+
fileSizes: filesInWireOrder.map((file) => index.fileSizes.get(file) ?? 0),
|
|
1562
|
+
forward,
|
|
1563
|
+
totalTokens: index.totalTokens,
|
|
1564
|
+
indexedFileCount: index.docCount,
|
|
1565
|
+
truncated: index.truncated,
|
|
1566
|
+
};
|
|
1567
|
+
_lastSerializeWork = {
|
|
1568
|
+
affectedTokenCount: affectedTokens.size,
|
|
1569
|
+
tookFullPath: false,
|
|
1570
|
+
};
|
|
1571
|
+
return { serialized, slotByFileId, tokensByFile };
|
|
1572
|
+
}
|
|
1573
|
+
export function serializeWordIndex(index) {
|
|
1574
|
+
const prior = serializedWordIndexCaches.get(index);
|
|
1575
|
+
const cache = prior
|
|
1576
|
+
? serializeWordIndexIncrementally(index, prior)
|
|
1577
|
+
: serializeWordIndexFull(index);
|
|
1578
|
+
serializedWordIndexCaches.set(index, cache);
|
|
1579
|
+
index.dirtyFiles?.clear();
|
|
1580
|
+
return cache.serialized;
|
|
1137
1581
|
}
|
|
1138
1582
|
export function deserializeWordIndex(data) {
|
|
1139
1583
|
if (!data ||
|
|
@@ -1146,6 +1590,12 @@ export function deserializeWordIndex(data) {
|
|
|
1146
1590
|
return null;
|
|
1147
1591
|
}
|
|
1148
1592
|
const docLengths = new PathKeyedMap(wordIndexKey);
|
|
1593
|
+
// Slot i of the wire `files` array becomes file id `fileIdBySlot[i]`. Two
|
|
1594
|
+
// slots whose spellings fold to one key share one id, exactly as a build
|
|
1595
|
+
// would intern them, so a divergent snapshot cannot deserialize into two
|
|
1596
|
+
// posting identities for one document.
|
|
1597
|
+
const fileTable = new WordIndexFileTable();
|
|
1598
|
+
const fileIdBySlot = data.files.map((file) => fileTable.intern(wordIndexKey(file), file));
|
|
1149
1599
|
const fileMtimes = new PathKeyedMap(wordIndexKey);
|
|
1150
1600
|
const fileSizes = new PathKeyedMap(wordIndexKey);
|
|
1151
1601
|
data.files.forEach((file, i) => docLengths.set(file, data.docLengths[i] ?? 0));
|
|
@@ -1162,17 +1612,23 @@ export function deserializeWordIndex(data) {
|
|
|
1162
1612
|
for (const [token, flat] of data.postings) {
|
|
1163
1613
|
if (typeof token !== "string" || !Array.isArray(flat))
|
|
1164
1614
|
continue;
|
|
1165
|
-
const
|
|
1615
|
+
const lanes = [];
|
|
1166
1616
|
for (let i = 0; i + 1 < flat.length; i += 2) {
|
|
1167
|
-
const
|
|
1617
|
+
const fileId = fileIdBySlot[flat[i]];
|
|
1168
1618
|
const line = flat[i + 1];
|
|
1169
|
-
if (typeof
|
|
1170
|
-
|
|
1619
|
+
if (typeof fileId === "number" && typeof line === "number") {
|
|
1620
|
+
lanes.push(fileId, line);
|
|
1171
1621
|
}
|
|
1172
1622
|
}
|
|
1173
|
-
if (
|
|
1174
|
-
postings.set(token,
|
|
1623
|
+
if (lanes.length > 0) {
|
|
1624
|
+
postings.set(token, WordPostingList.fromLanes(token, lanes));
|
|
1625
|
+
}
|
|
1175
1626
|
}
|
|
1627
|
+
// Every list above was sized exactly from the wire payload, so there is no
|
|
1628
|
+
// growth slack to release — but the per-list `ArrayBuffer` headers are the
|
|
1629
|
+
// same third-of-the-store cost a fresh build pays, and a deserialized index
|
|
1630
|
+
// is the LONG-lived one (a warm session reuses it across turns).
|
|
1631
|
+
compactPostingsIntoArena(postings);
|
|
1176
1632
|
let forward;
|
|
1177
1633
|
if (Array.isArray(data.forward)) {
|
|
1178
1634
|
forward = new PathKeyedMap(wordIndexKey);
|
|
@@ -1189,14 +1645,17 @@ export function deserializeWordIndex(data) {
|
|
|
1189
1645
|
continue;
|
|
1190
1646
|
const [token, count] = pair;
|
|
1191
1647
|
if (typeof token === "string" && typeof count === "number") {
|
|
1192
|
-
|
|
1648
|
+
// Point at the canonical instance the postings map holds, not
|
|
1649
|
+
// this document’s own JSON.parse allocation (#2069).
|
|
1650
|
+
perToken.set(postings.get(token)?.token ?? token, count);
|
|
1193
1651
|
}
|
|
1194
1652
|
}
|
|
1195
|
-
forward.set(file, perToken);
|
|
1653
|
+
forward.set(file, WordForwardEntry.fromTally(perToken));
|
|
1196
1654
|
}
|
|
1197
1655
|
}
|
|
1198
1656
|
return {
|
|
1199
1657
|
postings,
|
|
1658
|
+
fileTable,
|
|
1200
1659
|
docLengths,
|
|
1201
1660
|
totalTokens: typeof data.totalTokens === "number" ? data.totalTokens : 0,
|
|
1202
1661
|
docCount: data.files.length,
|
|
@@ -1204,6 +1663,10 @@ export function deserializeWordIndex(data) {
|
|
|
1204
1663
|
forward,
|
|
1205
1664
|
fileMtimes,
|
|
1206
1665
|
fileSizes,
|
|
1666
|
+
// Postings were just packed into one arena above, so the running gate
|
|
1667
|
+
// starts from the exact post-compaction store count (#2117).
|
|
1668
|
+
postingStoreCount: countPostingBackingStores(postings),
|
|
1669
|
+
recompactFlight: createSingleFlight(),
|
|
1207
1670
|
};
|
|
1208
1671
|
}
|
|
1209
1672
|
const buildStatuses = new Map();
|
|
@@ -1278,6 +1741,8 @@ export function triggerBackgroundWordIndexBuild(cwd, dbg, options = {}) {
|
|
|
1278
1741
|
durationMs: Date.now() - startMs,
|
|
1279
1742
|
indexedFileCount: index.docCount,
|
|
1280
1743
|
tokens: index.postings.size,
|
|
1744
|
+
postingEntries: countWordIndexPostingEntries(index),
|
|
1745
|
+
residentBytes: estimateWordIndexResidentBytes(index),
|
|
1281
1746
|
truncated: index.truncated,
|
|
1282
1747
|
skipped: docs.skipped,
|
|
1283
1748
|
});
|
|
@@ -1332,6 +1797,7 @@ function getWordIndexPersistScheduler() {
|
|
|
1332
1797
|
return wordIndexPersistScheduler;
|
|
1333
1798
|
}
|
|
1334
1799
|
async function writeWordIndexSnapshot(cwd, index, dbg) {
|
|
1800
|
+
const persistStartedAt = Date.now();
|
|
1335
1801
|
try {
|
|
1336
1802
|
const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
|
|
1337
1803
|
const existing = loadProjectSnapshot(cwd);
|
|
@@ -1346,8 +1812,11 @@ async function writeWordIndexSnapshot(cwd, index, dbg) {
|
|
|
1346
1812
|
cachedExports: [],
|
|
1347
1813
|
};
|
|
1348
1814
|
snapshot.generatedAt = new Date().toISOString();
|
|
1815
|
+
const serializeStartedAt = performance.now();
|
|
1349
1816
|
snapshot.wordIndex = serializeWordIndex(index);
|
|
1817
|
+
const serializeMs = performance.now() - serializeStartedAt;
|
|
1350
1818
|
saveProjectSnapshot(cwd, snapshot);
|
|
1819
|
+
const writeMs = performance.now() - serializeStartedAt - serializeMs;
|
|
1351
1820
|
dbg?.(`word-index persist: ${index.docCount} files, ${index.postings.size} tokens`);
|
|
1352
1821
|
// #958 review F1: durably record persist SUCCESS too, not just failures —
|
|
1353
1822
|
// in the MCP host (`dbg` is a no-op) this is the only signal that the index
|
|
@@ -1357,9 +1826,18 @@ async function writeWordIndexSnapshot(cwd, index, dbg) {
|
|
|
1357
1826
|
phase: "persist_succeeded",
|
|
1358
1827
|
cwd: path.resolve(cwd),
|
|
1359
1828
|
trigger: "per_edit",
|
|
1829
|
+
durationMs: Date.now() - persistStartedAt,
|
|
1830
|
+
serializeMs,
|
|
1831
|
+
writeMs,
|
|
1360
1832
|
indexedFileCount: index.docCount,
|
|
1361
1833
|
tokens: index.postings.size,
|
|
1834
|
+
postingEntries: countWordIndexPostingEntries(index),
|
|
1835
|
+
residentBytes: estimateWordIndexResidentBytes(index),
|
|
1836
|
+
replacementCount: index.replacementStats?.count ?? 0,
|
|
1837
|
+
totalReplacementMs: index.replacementStats?.totalMs ?? 0,
|
|
1838
|
+
maxReplacementMs: index.replacementStats?.maxMs ?? 0,
|
|
1362
1839
|
});
|
|
1840
|
+
index.replacementStats = { count: 0, totalMs: 0, maxMs: 0 };
|
|
1363
1841
|
}
|
|
1364
1842
|
catch (err) {
|
|
1365
1843
|
dbg?.(`word-index persist: failed: ${err}`);
|