pi-lens 3.8.73 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2614 -12
- package/README.md +50 -21
- package/dist/clients/actionable-warnings.js +73 -18
- package/dist/clients/agent-behavior-client.js +21 -4
- package/dist/clients/ast-grep-client.js +68 -16
- package/dist/clients/ast-grep-types.js +0 -1
- package/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/dist/clients/atomic-write-staging.js +63 -0
- package/dist/clients/atomic-write.js +116 -16
- package/dist/clients/bash-file-access.js +193 -23
- package/dist/clients/biome-client.js +29 -25
- package/dist/clients/bootstrap.js +11 -2
- package/dist/clients/bounded-cache.js +34 -0
- package/dist/clients/bounded-pid-file-lock.js +192 -0
- package/dist/clients/bus-events-logger.js +5 -1
- package/dist/clients/bus-publish.js +10 -3
- package/dist/clients/cache/rule-cache.js +89 -6
- package/dist/clients/cache-manager.js +97 -9
- package/dist/clients/cache-observability.js +498 -0
- package/dist/clients/call-graph.js +460 -108
- package/dist/clients/cascade-logger.js +5 -1
- package/dist/clients/child-unref.js +85 -0
- package/dist/clients/codebase-model.js +52 -23
- package/dist/clients/collateral-test-role.js +40 -0
- package/dist/clients/complexity-client.js +6 -8
- package/dist/clients/console-guard-install.js +14 -0
- package/dist/clients/cooperative-budget.js +42 -0
- package/dist/clients/dead-code-client.js +2 -1
- package/dist/clients/debug-handles.js +206 -0
- package/dist/clients/debug-heap.js +167 -0
- package/dist/clients/degradation-ledger.js +153 -0
- package/dist/clients/dependency-checker.js +466 -63
- package/dist/clients/diagnostic-dispositions.js +83 -13
- package/dist/clients/diagnostics-publish.js +10 -3
- package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
- package/dist/clients/dispatch/dispatcher.js +71 -13
- package/dist/clients/dispatch/fact-store.js +11 -0
- package/dist/clients/dispatch/facts/function-facts.js +37 -7
- package/dist/clients/dispatch/facts/import-facts.js +108 -119
- package/dist/clients/dispatch/facts/tree-sitter-facts.js +12 -12
- package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
- package/dist/clients/dispatch/indent-detect.js +35 -0
- package/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/dist/clients/dispatch/integration.js +1094 -611
- package/dist/clients/dispatch/lazy.js +14 -0
- package/dist/clients/dispatch/plan.js +11 -1
- package/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/dist/clients/dispatch/rule-policy.js +135 -0
- package/dist/clients/dispatch/rules/async-noise.js +1 -0
- package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
- package/dist/clients/dispatch/runners/ast-grep-napi.js +47 -6
- package/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/dist/clients/dispatch/runners/detekt.js +3 -2
- package/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/dist/clients/dispatch/runners/index.js +5 -1
- package/dist/clients/dispatch/runners/lsp.js +14 -6
- package/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
- package/dist/clients/dispatch/runners/pyright.js +9 -5
- package/dist/clients/dispatch/runners/ruff.js +4 -8
- package/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/dist/clients/dispatch/runners/taplo.js +2 -1
- package/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/dist/clients/dispatch/runners/tflint.js +37 -5
- package/dist/clients/dispatch/runners/tree-sitter.js +155 -166
- package/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +284 -34
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/dist/clients/disposition-publish.js +10 -3
- package/dist/clients/durable-store.js +97 -0
- package/dist/clients/event-loop-monitor.js +94 -3
- package/dist/clients/extension-log.js +139 -0
- package/dist/clients/extension-mode.js +73 -0
- package/dist/clients/feature-hints.js +53 -7
- package/dist/clients/file-kinds.js +143 -31
- package/dist/clients/file-role.js +18 -3
- package/dist/clients/file-utils.js +56 -34
- package/dist/clients/fix-worklog.js +14 -2
- package/dist/clients/format-events-publish.js +16 -3
- package/dist/clients/format-service.js +24 -8
- package/dist/clients/formatters-lazy.js +8 -0
- package/dist/clients/formatters.js +358 -61
- package/dist/clients/generated-artifacts.js +169 -15
- package/dist/clients/git-guard.js +681 -11
- package/dist/clients/git-tracked-ignore.js +4 -0
- package/dist/clients/go-client.js +2 -1
- package/dist/clients/govulncheck-client.js +8 -0
- package/dist/clients/grammar-source.js +12 -4
- package/dist/clients/gzip-stage-write.js +102 -0
- package/dist/clients/host-ports.js +31 -0
- package/dist/clients/installer/index.js +733 -213
- package/dist/clients/instance-reaper.js +142 -172
- package/dist/clients/jscpd-client.js +31 -54
- package/dist/clients/knip-client.js +78 -36
- package/dist/clients/language-policy.js +14 -2
- package/dist/clients/language-profile.js +72 -77
- package/dist/clients/latency-logger.js +28 -16
- package/dist/clients/lens-config.js +140 -166
- package/dist/clients/lens-engine.js +124 -21
- package/dist/clients/lens-events.js +27 -4
- package/dist/clients/lens-flag-registry.js +293 -0
- package/dist/clients/lens-map.js +96 -26
- package/dist/clients/live-bus-emitter.js +30 -0
- package/dist/clients/log-cleanup.js +8 -18
- package/dist/clients/lsp/cascade-tier.js +23 -3
- package/dist/clients/lsp/client.js +566 -57
- package/dist/clients/lsp/config.js +74 -10
- package/dist/clients/lsp/diagnostic-binding.js +86 -0
- package/dist/clients/lsp/edits.js +1012 -121
- package/dist/clients/lsp/index.js +1840 -803
- package/dist/clients/lsp/language.js +4 -1
- package/dist/clients/lsp/launch.js +40 -32
- package/dist/clients/lsp/path-utils.js +1 -1
- package/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/dist/clients/lsp/server.js +279 -60
- package/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/dist/clients/lsp-document-symbols.js +112 -0
- package/dist/clients/lsp-lazy.js +8 -0
- package/dist/clients/lsp-mutation.js +248 -0
- package/dist/clients/mcp/analyze.js +128 -36
- package/dist/clients/mcp/ipc.js +157 -24
- package/dist/clients/mcp/review.js +36 -3
- package/dist/clients/mcp/session.js +216 -8
- package/dist/clients/memory-sampler.js +115 -0
- package/dist/clients/metrics-client.js +2 -1
- package/dist/clients/metrics-history.js +2 -1
- package/dist/clients/module-report-lsp.js +5 -0
- package/dist/clients/module-report.js +267 -82
- package/dist/clients/ndjson-logger.js +334 -103
- package/dist/clients/opengrep-client.js +1 -1
- package/dist/clients/partial-edit-apply.js +120 -5
- package/dist/clients/path-keyed-map.js +76 -0
- package/dist/clients/path-utils.js +141 -5
- package/dist/clients/performance-report.js +327 -0
- package/dist/clients/pipeline.js +54 -13
- package/dist/clients/print-mode.js +21 -0
- package/dist/clients/project-changes.js +117 -4
- package/dist/clients/project-diagnostics/cache.js +3 -2
- package/dist/clients/project-diagnostics/extractors.js +26 -115
- package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
- package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +117 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
- package/dist/clients/project-diagnostics/scanner.js +268 -128
- package/dist/clients/project-lens-config.js +138 -34
- package/dist/clients/project-report.js +64 -25
- package/dist/clients/project-scale.js +8 -10
- package/dist/clients/project-snapshot-persist-worker.js +6 -0
- package/dist/clients/project-snapshot.js +767 -8
- package/dist/clients/project-trust.js +181 -0
- package/dist/clients/quiet-window.js +27 -5
- package/dist/clients/read-bridge.js +147 -0
- package/dist/clients/read-expansion.js +22 -12
- package/dist/clients/read-guard-logger.js +159 -1
- package/dist/clients/read-guard-tool-lines.js +150 -29
- package/dist/clients/read-guard.js +262 -3
- package/dist/clients/redact/secrets.js +449 -0
- package/dist/clients/resource-sampler.js +144 -39
- package/dist/clients/reverse-deps.js +48 -0
- package/dist/clients/review-graph/builder.js +2570 -326
- package/dist/clients/review-graph/git-identity.js +2 -1
- package/dist/clients/review-graph/import-resolvers.js +21 -10
- package/dist/clients/review-graph/persist-worker.js +10 -0
- package/dist/clients/review-graph/query.js +5 -0
- package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
- package/dist/clients/review-graph/symbol-id.js +46 -0
- package/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/dist/clients/review-graph/workspace-modules.js +51 -3
- package/dist/clients/review-graph-logger.js +53 -0
- package/dist/clients/ruff-client.js +27 -44
- package/dist/clients/runtime-agent-end.js +111 -75
- package/dist/clients/runtime-context.js +36 -12
- package/dist/clients/runtime-coordinator.js +76 -8
- package/dist/clients/runtime-session.js +799 -137
- package/dist/clients/runtime-tool-call.js +117 -62
- package/dist/clients/runtime-tool-result.js +218 -23
- package/dist/clients/runtime-turn.js +284 -40
- package/dist/clients/rust-client.js +2 -1
- package/dist/clients/safe-spawn.js +873 -71
- package/dist/clients/security-scan-client.js +2 -1
- package/dist/clients/session-start-observability.js +15 -0
- package/dist/clients/session-state-store.js +5 -3
- package/dist/clients/sessionstart-logger.js +18 -0
- package/dist/clients/sg-runner.js +284 -159
- package/dist/clients/slow-fs.js +2 -1
- package/dist/clients/smells-rollup.js +224 -0
- package/dist/clients/source-filter.js +231 -43
- package/dist/clients/source-walker.js +66 -10
- package/dist/clients/startup-scan.js +15 -16
- package/dist/clients/startup-timing.js +9 -0
- package/dist/clients/test-runner-client.js +38 -8
- package/dist/clients/todo-scanner.js +30 -3
- package/dist/clients/tool-event.js +35 -0
- package/dist/clients/tool-policy.js +388 -39
- package/dist/clients/tool-render.js +155 -0
- package/dist/clients/tree-sitter-cache.js +153 -140
- package/dist/clients/tree-sitter-client.js +1722 -80
- package/dist/clients/tree-sitter-logger.js +64 -1
- package/dist/clients/tree-sitter-query-loader.js +117 -12
- package/dist/clients/tree-sitter-shared.js +48 -19
- package/dist/clients/tree-sitter-symbol-extractor.js +115 -11
- package/dist/clients/user-notify.js +51 -0
- package/dist/clients/vanished-instance-marker.js +57 -0
- package/dist/clients/warm-attach.js +33 -13
- package/dist/clients/widget-state.js +342 -49
- package/dist/clients/word-index-logger.js +44 -0
- package/dist/clients/word-index.js +612 -68
- package/dist/clients/workspace-topology.js +100 -7
- package/dist/index.js +65923 -47161
- package/dist/mcp/analyze-cli.js +185 -29
- package/dist/mcp/cli.js +110 -0
- package/dist/mcp/server.js +272 -50
- package/dist/tools/ast-grep-search.js +93 -27
- package/dist/tools/lens-diagnostics.js +247 -41
- package/dist/tools/lsp-diagnostics.js +153 -40
- package/dist/tools/lsp-navigation.js +51 -31
- package/dist/tools/module-report.js +9 -0
- package/dist/tools/symbol-search.js +21 -2
- package/docs/agent-guide.md +247 -0
- package/docs/agent-tools.md +21 -10
- package/docs/analysisall.md +192 -0
- package/docs/api-ports-inventory.md +165 -0
- package/docs/ast-grep_rules_catalog.md +5 -2
- package/docs/astplayground.md +3 -4
- package/docs/audit1.md +5 -5
- package/docs/durable-store-audit-1202.md +32 -0
- package/docs/environment-variables.md +197 -11
- package/docs/fable.md +27 -0
- package/docs/features.md +35 -5
- package/docs/globalconfig.md +101 -5
- package/docs/language-coverage.md +6 -5
- package/docs/lsp-capability-matrix.md +2 -2
- package/docs/mcp.md +54 -20
- package/docs/servercapabilities.md +7 -4
- package/docs/settings.md +192 -0
- package/docs/tools.md +18 -1
- package/docs/tree-sitter_rules_catalog.md +11 -31
- package/docs/usage.md +31 -0
- package/package.json +139 -138
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
- package/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
- package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
- package/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
- package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
- package/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
- package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
- package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
- package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
- package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
- package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
- package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
- package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
- package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
- package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
- package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
- package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
- package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
- package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
- package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
- package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
- package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
- package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
- package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
- package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
- package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
- package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
- package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
- package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
- package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
- package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
- package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
- package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
- package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
- package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
- package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
- package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
- package/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
- package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
- package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
- package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
- package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
- package/rules/typos/_typos.toml +17 -0
- package/scripts/analyze-pi-lens-logs.mjs +55 -2
- package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
- package/dist/clients/ast-grep-parser.js +0 -86
- package/dist/clients/config-enabled-shape.js +0 -39
- package/dist/clients/lsp/interactive-install.js +0 -367
- package/dist/clients/lsp/lsp-index.js +0 -10
- package/dist/clients/lsp/server-strategies.js +0 -5
- package/dist/clients/project-metadata.js +0 -690
- package/dist/clients/source-groups.js +0 -96
- package/dist/clients/types.js +0 -11
- package/docs/env_variables.md +0 -10
- package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
- package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
- package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
- package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
- package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
- package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
- package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
- package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
- package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
- package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
- package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
- package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
- package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `Map` for file-path keys that folds every key through a caller-supplied
|
|
3
|
+
* normalizer INTERNALLY, so keying on a raw (non-normalized) path is
|
|
4
|
+
* structurally impossible — the whole path-key divergence class (#1025,
|
|
5
|
+
* follow-up to #1020/#210) collapses to "pick the right normalizer once, at
|
|
6
|
+
* construction," instead of every call site having to remember to normalize on
|
|
7
|
+
* write AND read AND delete (the exact discipline #210 declared and #1020
|
|
8
|
+
* proved is easy to miss).
|
|
9
|
+
*
|
|
10
|
+
* The normalizer runs on EVERY `get`/`set`/`has`/`delete`, so two
|
|
11
|
+
* differently-formed keys for the same path (`SUB\a.ts` vs `sub/a.ts`, on a
|
|
12
|
+
* case-insensitive FS) collapse to a single entry — no silent duplicate, no
|
|
13
|
+
* `has()` miss, no orphaned stale entry.
|
|
14
|
+
*
|
|
15
|
+
* Values retain their ORIGINAL display path. Consumers that render paths
|
|
16
|
+
* (symbol_search results, footer widgets) must not see a lowercased /
|
|
17
|
+
* slash-folded key, so the entry stores the display path alongside the value
|
|
18
|
+
* and every iteration surface (`keys`/`values`/`entries`/`forEach`/the default
|
|
19
|
+
* iterator) yields that display path — the exact form last written, never the
|
|
20
|
+
* folded internal key. `set` refreshes the display path to the most recent
|
|
21
|
+
* write (last-writer-wins), matching a plain `Map`'s "the key you set is the
|
|
22
|
+
* key you see" intuition while still de-duplicating by normalized identity.
|
|
23
|
+
*
|
|
24
|
+
* Choose the normalizer to match how the keyed state is used:
|
|
25
|
+
* - `normalizeEphemeralMapKey` (path-utils.ts) — cheap slash-fold +
|
|
26
|
+
* win32-lowercase, NO filesystem I/O — for hot, single-process, in-memory
|
|
27
|
+
* indexes whose keys this process produced itself (e.g. the word index).
|
|
28
|
+
* - `normalizeMapKey` — realpath-canonicalizing — for long-lived state shared
|
|
29
|
+
* across call sites where symlink/real-casing resolution matters.
|
|
30
|
+
*/
|
|
31
|
+
export class PathKeyedMap {
|
|
32
|
+
normalize;
|
|
33
|
+
store = new Map();
|
|
34
|
+
constructor(normalize) {
|
|
35
|
+
this.normalize = normalize;
|
|
36
|
+
}
|
|
37
|
+
get size() {
|
|
38
|
+
return this.store.size;
|
|
39
|
+
}
|
|
40
|
+
get(path) {
|
|
41
|
+
return this.store.get(this.normalize(path))?.value;
|
|
42
|
+
}
|
|
43
|
+
has(path) {
|
|
44
|
+
return this.store.has(this.normalize(path));
|
|
45
|
+
}
|
|
46
|
+
set(path, value) {
|
|
47
|
+
this.store.set(this.normalize(path), { displayPath: path, value });
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
delete(path) {
|
|
51
|
+
return this.store.delete(this.normalize(path));
|
|
52
|
+
}
|
|
53
|
+
clear() {
|
|
54
|
+
this.store.clear();
|
|
55
|
+
}
|
|
56
|
+
*keys() {
|
|
57
|
+
for (const entry of this.store.values())
|
|
58
|
+
yield entry.displayPath;
|
|
59
|
+
}
|
|
60
|
+
*values() {
|
|
61
|
+
for (const entry of this.store.values())
|
|
62
|
+
yield entry.value;
|
|
63
|
+
}
|
|
64
|
+
*entries() {
|
|
65
|
+
for (const entry of this.store.values())
|
|
66
|
+
yield [entry.displayPath, entry.value];
|
|
67
|
+
}
|
|
68
|
+
forEach(callback) {
|
|
69
|
+
for (const entry of this.store.values()) {
|
|
70
|
+
callback(entry.value, entry.displayPath, this);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
[Symbol.iterator]() {
|
|
74
|
+
return this.entries();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -13,13 +13,63 @@
|
|
|
13
13
|
import { existsSync, realpathSync } from "node:fs";
|
|
14
14
|
import * as os from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import {
|
|
16
|
+
import { win32 } from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import { minimatch } from "./deps/minimatch.js";
|
|
18
19
|
/**
|
|
19
|
-
* Detect
|
|
20
|
+
* Detect a positively Windows-shaped path, regardless of the host OS.
|
|
21
|
+
*
|
|
22
|
+
* A backslash anywhere in a path is not enough: it is a legal character in a
|
|
23
|
+
* POSIX filename. Only a drive-letter prefix (`X:`), a UNC root (`\\`), or a
|
|
24
|
+
* rooted backslash at position zero (`\`) selects Windows parsing.
|
|
25
|
+
*/
|
|
26
|
+
export function isWindowsPath(filePath) {
|
|
27
|
+
return /^[A-Za-z]:/.test(filePath) || filePath.startsWith("\\");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Canonical backslash→forward-slash fold — the single sanctioned form of the
|
|
31
|
+
* `p.replace(/\\/g, "/")` idiom otherwise hand-rolled across the codebase
|
|
32
|
+
* (~138 sites, #1193). PURE separator normalization: it does NOT resolve,
|
|
33
|
+
* canonicalize, lowercase, or collapse repeated slashes — reach for
|
|
34
|
+
* `normalizeFilePath`/`normalizeMapKey`/`normalizeEphemeralMapKey` when a
|
|
35
|
+
* canonical map *key* (case-fold / realpath) is what you need. Consolidating on
|
|
36
|
+
* this funnels the scattered transform and makes a shape-2 lint/ast-grep rule
|
|
37
|
+
* possible for the first time: today a bare inline `.replace(/\\/g, "/")` is
|
|
38
|
+
* byte-identical to the sanctioned use so it can't be ruled (#1158); once
|
|
39
|
+
* everything routes through `toPosix`, an *un-migrated* inline `.replace`
|
|
40
|
+
* becomes detectable.
|
|
41
|
+
*/
|
|
42
|
+
export function toPosix(filePath) {
|
|
43
|
+
return filePath.replace(/\\/g, "/");
|
|
44
|
+
}
|
|
45
|
+
/** Return whether `filePath` is fully qualified under Windows semantics. */
|
|
46
|
+
export function isFullyQualifiedWin32(filePath) {
|
|
47
|
+
return win32.isAbsolute(filePath) && win32.parse(filePath).root.length > 1;
|
|
48
|
+
}
|
|
49
|
+
/** Return whether `filePath` is fully qualified under POSIX semantics. */
|
|
50
|
+
export function isFullyQualifiedPosix(filePath) {
|
|
51
|
+
return path.posix.isAbsolute(filePath) && !isWindowsPath(filePath);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Return whether `filePath` is fully qualified under the host's semantics.
|
|
55
|
+
*
|
|
56
|
+
* In particular, `/foo` is rooted-relative under Win32 (ambient-drive
|
|
57
|
+
* dependent) but fully qualified under POSIX.
|
|
58
|
+
*/
|
|
59
|
+
export function isFullyQualified(filePath) {
|
|
60
|
+
return process.platform === "win32"
|
|
61
|
+
? isFullyQualifiedWin32(filePath)
|
|
62
|
+
: isFullyQualifiedPosix(filePath);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Split a path into its non-empty segments on EITHER separator (`\` or `/`),
|
|
66
|
+
* regardless of the running OS — the shape-safe form of `p.split(path.sep)` /
|
|
67
|
+
* an inline `p.split(/[\\/]+/)`, which #1161/#1163 showed must not assume the
|
|
68
|
+
* host separator for a possibly-cross-shaped path. Drops empty segments
|
|
69
|
+
* (leading slash, drive-root, doubled separators).
|
|
20
70
|
*/
|
|
21
|
-
function
|
|
22
|
-
return
|
|
71
|
+
export function splitPathSegments(filePath) {
|
|
72
|
+
return filePath.split(/[\\/]+/).filter(Boolean);
|
|
23
73
|
}
|
|
24
74
|
/**
|
|
25
75
|
* Normalize a file path for consistent Map key usage.
|
|
@@ -86,7 +136,15 @@ function resolveNonExisting(filePath) {
|
|
|
86
136
|
const base = canonical.replace(/\\/g, "/");
|
|
87
137
|
return base.endsWith("/") ? base + tail : `${base}/${tail}`;
|
|
88
138
|
}
|
|
89
|
-
|
|
139
|
+
// Use win32.dirname (not the platform-default dirname) so a
|
|
140
|
+
// Windows-shaped path is parsed with win32 semantics regardless of the
|
|
141
|
+
// running OS — consistent with the win32.resolve/win32.normalize this
|
|
142
|
+
// branch already commits to. The platform-default POSIX dirname would
|
|
143
|
+
// find no separator in a win32-resolved "C:\repo\..." path (its only
|
|
144
|
+
// separators are backslashes), collapse to ".", stop the upward walk at
|
|
145
|
+
// cwd, and mangle the key on Linux CI (refs #1150, the #1024
|
|
146
|
+
// OS-divergence class).
|
|
147
|
+
const parent = win32.dirname(current);
|
|
90
148
|
if (parent === current) {
|
|
91
149
|
// Reached filesystem root without finding existing dir
|
|
92
150
|
// Fall back to full lowercase
|
|
@@ -110,6 +168,29 @@ export function uriToPath(uri) {
|
|
|
110
168
|
return normalizeFilePath(uri);
|
|
111
169
|
}
|
|
112
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Decode a file:// URI to an on-disk path WITHOUT map-key normalization.
|
|
173
|
+
*
|
|
174
|
+
* `uriToPath` runs its result through `normalizeFilePath`, which on win32
|
|
175
|
+
* lowercases the nonexistent tail of a path (see `resolveNonExisting`) and
|
|
176
|
+
* canonicalizes an existing path to its real casing. That is correct for Map
|
|
177
|
+
* keys, but DESTRUCTIVE for a real create/rename target: creating `NewFile.txt`
|
|
178
|
+
* would write `newfile.txt`, and a legitimate case-only rename would collapse
|
|
179
|
+
* to a no-op ("source and destination must differ"). Disk mutations must honor
|
|
180
|
+
* the caller's intended casing, so they resolve their target through this
|
|
181
|
+
* decode-only path while confinement/validation keep using the normalized
|
|
182
|
+
* `uriToPath`. Non-win32 is unaffected either way (normalizeFilePath is a
|
|
183
|
+
* near-identity there).
|
|
184
|
+
*/
|
|
185
|
+
export function uriToDiskPath(uri) {
|
|
186
|
+
try {
|
|
187
|
+
return fileURLToPath(uri);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Not a valid file:// URI — treat as a plain path (matches uriToPath).
|
|
191
|
+
return uri;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
113
194
|
/**
|
|
114
195
|
* Convert a path to a file:// URI.
|
|
115
196
|
* Does NOT normalize the path - URIs preserve original casing.
|
|
@@ -124,6 +205,31 @@ export function pathToUri(filePath) {
|
|
|
124
205
|
export function normalizeMapKey(filePath) {
|
|
125
206
|
return normalizeFilePath(filePath);
|
|
126
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Human-facing path relative to a project root when the file is inside it.
|
|
210
|
+
*
|
|
211
|
+
* Parses by path SHAPE, not host OS (refs #1150/#1152, shape-2 class #1163):
|
|
212
|
+
* a Windows-shaped `filePath` (drive-letter/UNC — e.g. a persisted call-graph
|
|
213
|
+
* symbol-key path `C:\repo\src\x.ts` rehydrated on a Linux CI run) is split
|
|
214
|
+
* with `win32.*` regardless of `process.platform`. The host-default
|
|
215
|
+
* `isAbsolute`/`relative` find no drive-letter anchor in a win32 path on POSIX:
|
|
216
|
+
* `path.isAbsolute("C:\\repo\\x.ts")` returns FALSE on Linux, short-circuiting
|
|
217
|
+
* to the raw absolute path instead of ever relativizing it — so a file that IS
|
|
218
|
+
* under the project root renders as a full absolute path on Linux but the
|
|
219
|
+
* expected `src/x.ts` on Windows (green-locally / wrong-on-CI, the #1024
|
|
220
|
+
* divergence class). `win32.*` on a native POSIX path (Windows never sees one;
|
|
221
|
+
* Linux native paths aren't Windows-shaped) is never selected, so same-OS
|
|
222
|
+
* native paths are unchanged either way.
|
|
223
|
+
*/
|
|
224
|
+
export function toProjectRelativePath(filePath, projectRoot) {
|
|
225
|
+
const p = isWindowsPath(filePath) ? win32 : path;
|
|
226
|
+
if (!p.isAbsolute(filePath))
|
|
227
|
+
return filePath.replace(/\\/g, "/");
|
|
228
|
+
const relative = p.relative(p.resolve(projectRoot), filePath);
|
|
229
|
+
return relative && !relative.startsWith("..") && !p.isAbsolute(relative)
|
|
230
|
+
? relative.replace(/\\/g, "/")
|
|
231
|
+
: filePath.replace(/\\/g, "/");
|
|
232
|
+
}
|
|
127
233
|
/**
|
|
128
234
|
* Cheap, syntactic-only Map key normalization: slash-fold + (on Windows)
|
|
129
235
|
* lowercase. No `realpathSync` / filesystem I/O.
|
|
@@ -146,6 +252,11 @@ export function normalizeMapKey(filePath) {
|
|
|
146
252
|
* real-casing resolution actually matters.
|
|
147
253
|
*/
|
|
148
254
|
export function normalizeEphemeralMapKey(filePath) {
|
|
255
|
+
// Most hot-path keys on POSIX are already canonical slash-separated strings.
|
|
256
|
+
// Preserve that identity instead of allocating a replacement string for each
|
|
257
|
+
// file in a large diagnostics reconciliation.
|
|
258
|
+
if (process.platform !== "win32" && !filePath.includes("\\"))
|
|
259
|
+
return filePath;
|
|
149
260
|
const slashed = filePath.replace(/\\/g, "/");
|
|
150
261
|
return process.platform === "win32" ? slashed.toLowerCase() : slashed;
|
|
151
262
|
}
|
|
@@ -316,3 +427,28 @@ export function isExternalOrVendorFile(filePath, projectRoot) {
|
|
|
316
427
|
: normalized;
|
|
317
428
|
return rel.split("/").some((seg) => VENDOR_DIR_NAMES.has(seg));
|
|
318
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Shared marker-glob semantics for every "does this directory contain a file
|
|
432
|
+
* matching this glob" probe (#895 review): match against the entry NAME only,
|
|
433
|
+
* `dot: true` so dotfile markers match, `nocase` on win32 to match the
|
|
434
|
+
* filesystem (and the project ignore matcher). The three marker probes —
|
|
435
|
+
* language-profile.ts `hasProjectMarker`, workspace-topology.ts
|
|
436
|
+
* `hasBasenameMarker`, lsp/server.ts `markerExists` — must all route their
|
|
437
|
+
* glob matching through here rather than call minimatch with hand-copied
|
|
438
|
+
* options.
|
|
439
|
+
*/
|
|
440
|
+
export function nameMatchesMarkerGlob(name, pattern) {
|
|
441
|
+
return minimatch(name, pattern, {
|
|
442
|
+
dot: true,
|
|
443
|
+
nocase: process.platform === "win32",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Files/symlinks-only marker-glob probe over a directory listing — a
|
|
448
|
+
* *directory* named like a marker (e.g. a `Foo.csproj/` dir) is not a project
|
|
449
|
+
* file (#201).
|
|
450
|
+
*/
|
|
451
|
+
export function direntsHaveMarkerGlobMatch(entries, pattern) {
|
|
452
|
+
return entries.some((entry) => (entry.isFile() || entry.isSymbolicLink()) &&
|
|
453
|
+
nameMatchesMarkerGlob(entry.name, pattern));
|
|
454
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as readline from "node:readline";
|
|
3
|
+
import { flushLatencyLog, getLatencyLogPath, } from "./latency-logger.js";
|
|
4
|
+
import { getMaxLogSizeMB } from "./log-cleanup.js";
|
|
5
|
+
export const DEFAULT_PERF_TOP_N = 5;
|
|
6
|
+
export const MAX_PERF_TOP_N = 50;
|
|
7
|
+
export const MAX_PERF_PHASE_SAMPLES = 20_000;
|
|
8
|
+
const PARSE_YIELD_EVERY = 500;
|
|
9
|
+
// The window follows the same threshold that rotates the log out from under it.
|
|
10
|
+
// Exported so a test can size a fixture to the window it means to saturate
|
|
11
|
+
// rather than to a second copy of the default that can drift out of agreement.
|
|
12
|
+
export function resolveLogByteBudget() {
|
|
13
|
+
return getMaxLogSizeMB() * 1024 * 1024;
|
|
14
|
+
}
|
|
15
|
+
function boundedPositiveInteger(value, maximum, fallback = maximum) {
|
|
16
|
+
return Number.isFinite(value) && value > 0
|
|
17
|
+
? Math.min(maximum, Math.max(1, Math.floor(value)))
|
|
18
|
+
: fallback;
|
|
19
|
+
}
|
|
20
|
+
function percentile(sorted, quantile) {
|
|
21
|
+
const position = (sorted.length - 1) * quantile;
|
|
22
|
+
const lower = Math.floor(position);
|
|
23
|
+
const upper = Math.ceil(position);
|
|
24
|
+
return lower === upper
|
|
25
|
+
? sorted[lower]
|
|
26
|
+
: sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
|
|
27
|
+
}
|
|
28
|
+
function roundedMs(value) {
|
|
29
|
+
return Math.round(value * 10) / 10;
|
|
30
|
+
}
|
|
31
|
+
function isEntryObject(value) {
|
|
32
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function isPhaseRecord(value) {
|
|
35
|
+
if (!isEntryObject(value))
|
|
36
|
+
return false;
|
|
37
|
+
return (value.type === "phase" &&
|
|
38
|
+
typeof value.phase === "string" &&
|
|
39
|
+
value.phase.trim().length > 0 &&
|
|
40
|
+
typeof value.durationMs === "number" &&
|
|
41
|
+
Number.isFinite(value.durationMs) &&
|
|
42
|
+
value.durationMs >= 0 &&
|
|
43
|
+
(value.toolName === undefined || typeof value.toolName === "string") &&
|
|
44
|
+
(value.ts === undefined || typeof value.ts === "string") &&
|
|
45
|
+
(value.startedAt === undefined || typeof value.startedAt === "string") &&
|
|
46
|
+
(value.pid === undefined ||
|
|
47
|
+
(typeof value.pid === "number" && Number.isFinite(value.pid))));
|
|
48
|
+
}
|
|
49
|
+
function isPhaseSample(value) {
|
|
50
|
+
return isPhaseRecord(value) && value.durationMs > 0;
|
|
51
|
+
}
|
|
52
|
+
function phaseKey(entry) {
|
|
53
|
+
const phase = entry.phase?.trim() ?? "";
|
|
54
|
+
const toolName = entry.toolName?.trim();
|
|
55
|
+
return toolName ? `${toolName}/${phase}` : phase;
|
|
56
|
+
}
|
|
57
|
+
function isCurrentSessionSample(entry, processId, sessionStartedAt) {
|
|
58
|
+
if (entry.pid !== processId || typeof entry.ts !== "string")
|
|
59
|
+
return false;
|
|
60
|
+
const finishedAt = Date.parse(entry.ts);
|
|
61
|
+
if (!Number.isFinite(finishedAt) || finishedAt < sessionStartedAt)
|
|
62
|
+
return false;
|
|
63
|
+
if (entry.startedAt === undefined)
|
|
64
|
+
return true;
|
|
65
|
+
const startedAt = Date.parse(entry.startedAt);
|
|
66
|
+
return Number.isFinite(startedAt) && startedAt >= sessionStartedAt;
|
|
67
|
+
}
|
|
68
|
+
function createPhaseBuffer(limit) {
|
|
69
|
+
return { values: [], nextIndex: 0, total: 0, limit };
|
|
70
|
+
}
|
|
71
|
+
function retainEntry(buffer, entry) {
|
|
72
|
+
buffer.total += 1;
|
|
73
|
+
if (buffer.values.length < buffer.limit) {
|
|
74
|
+
buffer.values.push(entry);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
buffer.values[buffer.nextIndex] = entry;
|
|
78
|
+
buffer.nextIndex = (buffer.nextIndex + 1) % buffer.limit;
|
|
79
|
+
}
|
|
80
|
+
function orderedEntries(buffer) {
|
|
81
|
+
return buffer.total > buffer.values.length
|
|
82
|
+
? [
|
|
83
|
+
...buffer.values.slice(buffer.nextIndex),
|
|
84
|
+
...buffer.values.slice(0, buffer.nextIndex),
|
|
85
|
+
]
|
|
86
|
+
: buffer.values;
|
|
87
|
+
}
|
|
88
|
+
export function summarizePhaseLatency(entries, topN = DEFAULT_PERF_TOP_N) {
|
|
89
|
+
const durationsByPhase = new Map();
|
|
90
|
+
let oldestTimestamp;
|
|
91
|
+
let newestTimestamp;
|
|
92
|
+
let sampleCount = 0;
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!isPhaseSample(entry))
|
|
95
|
+
continue;
|
|
96
|
+
const phase = phaseKey(entry);
|
|
97
|
+
const durations = durationsByPhase.get(phase) ?? [];
|
|
98
|
+
durations.push(entry.durationMs);
|
|
99
|
+
durationsByPhase.set(phase, durations);
|
|
100
|
+
sampleCount += 1;
|
|
101
|
+
if (typeof entry.ts === "string") {
|
|
102
|
+
const ms = Date.parse(entry.ts);
|
|
103
|
+
if (Number.isFinite(ms)) {
|
|
104
|
+
if (!oldestTimestamp || ms < oldestTimestamp.ms) {
|
|
105
|
+
oldestTimestamp = { ms, iso: entry.ts };
|
|
106
|
+
}
|
|
107
|
+
if (!newestTimestamp || ms > newestTimestamp.ms) {
|
|
108
|
+
newestTimestamp = { ms, iso: entry.ts };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const summaries = Array.from(durationsByPhase, ([phase, durations]) => {
|
|
114
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
115
|
+
const p50RawMs = percentile(sorted, 0.5);
|
|
116
|
+
const p99RawMs = percentile(sorted, 0.99);
|
|
117
|
+
return {
|
|
118
|
+
phase,
|
|
119
|
+
samples: sorted.length,
|
|
120
|
+
p50Ms: roundedMs(p50RawMs),
|
|
121
|
+
p99Ms: roundedMs(p99RawMs),
|
|
122
|
+
p50RawMs,
|
|
123
|
+
p99RawMs,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
const limit = boundedPositiveInteger(topN, MAX_PERF_TOP_N, DEFAULT_PERF_TOP_N);
|
|
127
|
+
const tieBreak = (a, b) => b.samples - a.samples || a.phase.localeCompare(b.phase);
|
|
128
|
+
const toPublic = ({ phase, samples, p50Ms, p99Ms, }) => ({
|
|
129
|
+
phase,
|
|
130
|
+
samples,
|
|
131
|
+
p50Ms,
|
|
132
|
+
p99Ms,
|
|
133
|
+
});
|
|
134
|
+
const rankBy = (primary, secondary) => [...summaries]
|
|
135
|
+
.sort((a, b) => b[primary] - a[primary] ||
|
|
136
|
+
b[secondary] - a[secondary] ||
|
|
137
|
+
tieBreak(a, b))
|
|
138
|
+
.slice(0, limit)
|
|
139
|
+
.map(toPublic);
|
|
140
|
+
return {
|
|
141
|
+
sampleCount,
|
|
142
|
+
phaseCount: durationsByPhase.size,
|
|
143
|
+
oldestTs: oldestTimestamp?.iso,
|
|
144
|
+
newestTs: newestTimestamp?.iso,
|
|
145
|
+
slowestByP50: rankBy("p50RawMs", "p99RawMs"),
|
|
146
|
+
slowestByP99: rankBy("p99RawMs", "p50RawMs"),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async function readPhaseLogTail(filePath, windowBytes, maxSamples, processId, sessionStartedAt) {
|
|
150
|
+
const empty = () => ({
|
|
151
|
+
logEntries: [],
|
|
152
|
+
sessionEntries: [],
|
|
153
|
+
windowTruncated: false,
|
|
154
|
+
logSamplesTruncated: false,
|
|
155
|
+
sessionSamplesTruncated: false,
|
|
156
|
+
totalPhaseSamples: 0,
|
|
157
|
+
totalSessionPhaseSamples: 0,
|
|
158
|
+
malformedLines: 0,
|
|
159
|
+
invalidRecords: 0,
|
|
160
|
+
});
|
|
161
|
+
let handle;
|
|
162
|
+
try {
|
|
163
|
+
handle = await fs.promises.open(filePath, "r");
|
|
164
|
+
const stat = await handle.stat();
|
|
165
|
+
if (!stat.isFile()) {
|
|
166
|
+
// POSIX rejects opening a directory with EISDIR, but Windows opens it
|
|
167
|
+
// successfully with size 0 — which would fall into the empty() branch
|
|
168
|
+
// and conflate "unreadable path" with "no data yet". Throw the same
|
|
169
|
+
// error POSIX would so both platforms surface the bad path.
|
|
170
|
+
const notAFile = new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`);
|
|
171
|
+
notAFile.code = "EISDIR";
|
|
172
|
+
throw notAFile;
|
|
173
|
+
}
|
|
174
|
+
if (stat.size === 0)
|
|
175
|
+
return empty();
|
|
176
|
+
const sampleLimit = boundedPositiveInteger(maxSamples, MAX_PERF_PHASE_SAMPLES);
|
|
177
|
+
const start = Math.max(0, stat.size - windowBytes);
|
|
178
|
+
let discardPartialFirstLine = false;
|
|
179
|
+
if (start > 0) {
|
|
180
|
+
const previousByte = Buffer.alloc(1);
|
|
181
|
+
const { bytesRead } = await handle.read(previousByte, 0, 1, start - 1);
|
|
182
|
+
discardPartialFirstLine = bytesRead === 1 && previousByte[0] !== 0x0a;
|
|
183
|
+
}
|
|
184
|
+
const stream = handle.createReadStream({
|
|
185
|
+
start,
|
|
186
|
+
end: stat.size - 1,
|
|
187
|
+
autoClose: false,
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
});
|
|
190
|
+
const lines = readline.createInterface({
|
|
191
|
+
input: stream,
|
|
192
|
+
crlfDelay: Infinity,
|
|
193
|
+
});
|
|
194
|
+
const logBuffer = createPhaseBuffer(sampleLimit);
|
|
195
|
+
const sessionBuffer = createPhaseBuffer(sampleLimit);
|
|
196
|
+
let malformedLines = 0;
|
|
197
|
+
let invalidRecords = 0;
|
|
198
|
+
let lineCount = 0;
|
|
199
|
+
for await (const line of lines) {
|
|
200
|
+
lineCount += 1;
|
|
201
|
+
if (lineCount % PARSE_YIELD_EVERY === 0) {
|
|
202
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
203
|
+
}
|
|
204
|
+
if (discardPartialFirstLine) {
|
|
205
|
+
discardPartialFirstLine = false;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (!trimmed)
|
|
210
|
+
continue;
|
|
211
|
+
try {
|
|
212
|
+
const value = JSON.parse(trimmed);
|
|
213
|
+
if (isPhaseSample(value)) {
|
|
214
|
+
retainEntry(logBuffer, value);
|
|
215
|
+
if (isCurrentSessionSample(value, processId, sessionStartedAt)) {
|
|
216
|
+
retainEntry(sessionBuffer, value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else if (!isPhaseRecord(value) &&
|
|
220
|
+
(!isEntryObject(value) || value.type === "phase")) {
|
|
221
|
+
invalidRecords += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
malformedLines += 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
logEntries: orderedEntries(logBuffer),
|
|
230
|
+
sessionEntries: orderedEntries(sessionBuffer),
|
|
231
|
+
windowTruncated: start > 0,
|
|
232
|
+
logSamplesTruncated: logBuffer.total > sampleLimit,
|
|
233
|
+
sessionSamplesTruncated: sessionBuffer.total > sampleLimit,
|
|
234
|
+
totalPhaseSamples: logBuffer.total,
|
|
235
|
+
totalSessionPhaseSamples: sessionBuffer.total,
|
|
236
|
+
malformedLines,
|
|
237
|
+
invalidRecords,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error.code === "ENOENT")
|
|
242
|
+
return empty();
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await handle?.close().catch(() => { });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export async function collectLatencyPerformance(options) {
|
|
250
|
+
await flushLatencyLog();
|
|
251
|
+
const logPath = options.logPath ?? getLatencyLogPath();
|
|
252
|
+
const processId = options.processId ?? process.pid;
|
|
253
|
+
const topN = boundedPositiveInteger(options.topN ?? DEFAULT_PERF_TOP_N, MAX_PERF_TOP_N, DEFAULT_PERF_TOP_N);
|
|
254
|
+
const byteBudget = resolveLogByteBudget();
|
|
255
|
+
const windowBytes = boundedPositiveInteger(options.maxBytes ?? byteBudget, byteBudget);
|
|
256
|
+
const tail = await readPhaseLogTail(logPath, windowBytes, options.maxSamples ?? MAX_PERF_PHASE_SAMPLES, processId, options.sessionStartedAt);
|
|
257
|
+
const { logEntries, sessionEntries, ...counters } = tail;
|
|
258
|
+
return {
|
|
259
|
+
logPath,
|
|
260
|
+
topN,
|
|
261
|
+
windowBytes,
|
|
262
|
+
...counters,
|
|
263
|
+
session: summarizePhaseLatency(sessionEntries, topN),
|
|
264
|
+
logWindow: summarizePhaseLatency(logEntries, topN),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function formatDuration(ms) {
|
|
268
|
+
if (ms >= 1000)
|
|
269
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
270
|
+
if (ms > 0 && ms < 1)
|
|
271
|
+
return `${ms.toFixed(2)}ms`;
|
|
272
|
+
return `${Number.isInteger(ms) ? ms : ms.toFixed(1)}ms`;
|
|
273
|
+
}
|
|
274
|
+
function formatByteBudget(bytes) {
|
|
275
|
+
if (bytes >= 1024 * 1024) {
|
|
276
|
+
return `${Number((bytes / (1024 * 1024)).toFixed(1))}MB`;
|
|
277
|
+
}
|
|
278
|
+
if (bytes >= 1024)
|
|
279
|
+
return `${Number((bytes / 1024).toFixed(1))}KB`;
|
|
280
|
+
return `${bytes}B`;
|
|
281
|
+
}
|
|
282
|
+
function formatTimestamp(ts) {
|
|
283
|
+
const parsed = new Date(ts);
|
|
284
|
+
return Number.isFinite(parsed.getTime())
|
|
285
|
+
? `${parsed.toISOString().slice(0, 19).replace("T", " ")}Z`
|
|
286
|
+
: ts;
|
|
287
|
+
}
|
|
288
|
+
function renderRanking(lines, label, phases) {
|
|
289
|
+
lines.push(` ${label}:`);
|
|
290
|
+
for (const phase of phases) {
|
|
291
|
+
lines.push(` ${phase.phase}: p50 ${formatDuration(phase.p50Ms)}, p99 ${formatDuration(phase.p99Ms)}, n=${phase.samples}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function renderScope(lines, label, scope) {
|
|
295
|
+
const range = scope.oldestTs && scope.newestTs
|
|
296
|
+
? `, ${formatTimestamp(scope.oldestTs)} to ${formatTimestamp(scope.newestTs)}`
|
|
297
|
+
: "";
|
|
298
|
+
lines.push("", `${label} (${scope.sampleCount} samples across ${scope.phaseCount} phases${range})`);
|
|
299
|
+
if (scope.slowestByP99.length === 0) {
|
|
300
|
+
lines.push(" No phase timings yet.");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
renderRanking(lines, "Highest p50", scope.slowestByP50);
|
|
304
|
+
renderRanking(lines, "Highest p99", scope.slowestByP99);
|
|
305
|
+
}
|
|
306
|
+
export function renderLatencyPerformanceReport(report) {
|
|
307
|
+
const lines = [
|
|
308
|
+
"⏱️ PI-LENS PERFORMANCE",
|
|
309
|
+
`Top ${report.topN} sustained and tail latency phases.`,
|
|
310
|
+
];
|
|
311
|
+
renderScope(lines, "Current process session", report.session);
|
|
312
|
+
renderScope(lines, "Machine-wide active log window", report.logWindow);
|
|
313
|
+
lines.push("", `Source: ${report.logPath}`);
|
|
314
|
+
if (report.windowTruncated) {
|
|
315
|
+
lines.push(`Both scopes use only the newest ${formatByteBudget(report.windowBytes)} of the active log.`);
|
|
316
|
+
}
|
|
317
|
+
if (report.sessionSamplesTruncated) {
|
|
318
|
+
lines.push(`Session percentiles use the newest ${report.session.sampleCount.toLocaleString("en-US")} of ${report.totalSessionPhaseSamples.toLocaleString("en-US")} phase samples.`);
|
|
319
|
+
}
|
|
320
|
+
if (report.logSamplesTruncated) {
|
|
321
|
+
lines.push(`Machine-wide percentiles use the newest ${report.logWindow.sampleCount.toLocaleString("en-US")} of ${report.totalPhaseSamples.toLocaleString("en-US")} phase samples.`);
|
|
322
|
+
}
|
|
323
|
+
if (report.malformedLines > 0 || report.invalidRecords > 0) {
|
|
324
|
+
lines.push(`Skipped ${report.malformedLines} malformed line(s) and ${report.invalidRecords} invalid record(s).`);
|
|
325
|
+
}
|
|
326
|
+
return lines.join("\n");
|
|
327
|
+
}
|