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
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* "console.log($MSG)" matches any console.log call, captures argument as MSG
|
|
15
15
|
* "function $NAME($$$PARAMS) { $BODY }" matches function declarations
|
|
16
16
|
*/
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
import * as crypto from "node:crypto";
|
|
17
19
|
import * as fs from "node:fs";
|
|
18
20
|
import { createRequire } from "node:module";
|
|
19
21
|
import * as path from "node:path";
|
|
@@ -21,14 +23,40 @@ import { loadWebTreeSitter } from "./deps/web-tree-sitter.js";
|
|
|
21
23
|
import { getProjectIgnoreMatcher, isExcludedDirName } from "./file-utils.js";
|
|
22
24
|
import { downloadGrammar, grammarBlockReason, LANGUAGE_TO_GRAMMAR, } from "./grammar-source.js";
|
|
23
25
|
import { resolvePackagePath } from "./package-root.js";
|
|
26
|
+
import { recordDegradation } from "./degradation-ledger.js";
|
|
27
|
+
import { assertInstallAllowed, getProjectTrustGeneration, } from "./project-trust.js";
|
|
28
|
+
import { logTreeSitterDiagnostic } from "./tree-sitter-logger.js";
|
|
29
|
+
import { notifyUserDegradation } from "./user-notify.js";
|
|
24
30
|
const _require = createRequire(import.meta.url);
|
|
25
|
-
import { TreeCache } from "./tree-sitter-cache.js";
|
|
31
|
+
import { createTreeCacheCounters, TreeCache, } from "./tree-sitter-cache.js";
|
|
26
32
|
import { TreeSitterNavigator } from "./tree-sitter-navigator.js";
|
|
27
33
|
import { TreeSitterQueryLoader, } from "./tree-sitter-query-loader.js";
|
|
28
34
|
// Hard cap on a single structural-search file walk. Bounds a misrooted scan so
|
|
29
35
|
// it can't enumerate an unbounded tree synchronously before result collection
|
|
30
36
|
// short-circuits (#262).
|
|
31
37
|
const TREE_SITTER_MAX_SCAN_FILES = 20_000;
|
|
38
|
+
// Consecutive grammar-load failures after which a batch cache key gives up and
|
|
39
|
+
// caches null like any other deterministic failure. Bounds the retry loop when
|
|
40
|
+
// a grammar simply never loads, while still letting a transient loadLanguage()
|
|
41
|
+
// failure (offline lazy fetch, mid-scan load error) recover on a later scan
|
|
42
|
+
// instead of paying the 3.3x per-rule fallback for the process lifetime (#889).
|
|
43
|
+
const QUERY_BATCH_MAX_LOAD_FAILURES = 3;
|
|
44
|
+
// Keep post-filter tree walks bounded. A malformed or unexpectedly huge tree
|
|
45
|
+
// must not stall the batched query walk; the filter fails open when this cap
|
|
46
|
+
// is reached so unrelated diagnostics and the current match are preserved.
|
|
47
|
+
const NO_NESTED_ANCHOR_VISIT_CAP = 10_000;
|
|
48
|
+
const NOT_PARSED = { parsed: false };
|
|
49
|
+
function createParserCounters() {
|
|
50
|
+
return {
|
|
51
|
+
parserInvocations: 0,
|
|
52
|
+
parserDurationMs: 0,
|
|
53
|
+
parserFailures: 0,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function isTreeSitterWasmAbortError(error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
return message.includes("Aborted") || message.includes("abort()");
|
|
59
|
+
}
|
|
32
60
|
// --- Parser Manager ---
|
|
33
61
|
export class TreeSitterClient {
|
|
34
62
|
initialized = false;
|
|
@@ -40,23 +68,162 @@ export class TreeSitterClient {
|
|
|
40
68
|
grammarsDir;
|
|
41
69
|
/** In-flight/settled lazy grammar fetches, keyed by wasm filename. */
|
|
42
70
|
grammarEnsurePromises = new Map();
|
|
71
|
+
trustBlockedGrammarNotifications = new Set();
|
|
72
|
+
trustNotificationsGeneration = getProjectTrustGeneration();
|
|
43
73
|
// biome-ignore lint/suspicious/noExplicitAny: Optional dependency loaded dynamically
|
|
44
74
|
ParserClass = null;
|
|
45
75
|
// biome-ignore lint/suspicious/noExplicitAny: Language loader from module
|
|
46
76
|
LanguageLoader = null;
|
|
47
77
|
// biome-ignore lint/suspicious/noExplicitAny: Compiled query cache by language+pattern hash
|
|
48
78
|
queryCache = new Map();
|
|
79
|
+
/** Combined multi-rule queries by language + rule-set identity (null = don't retry). */
|
|
80
|
+
queryBatchCache = new Map();
|
|
81
|
+
static QUERY_CACHE_MAX_ENTRIES = 256;
|
|
82
|
+
static QUERY_BATCH_CACHE_MAX_ENTRIES = 256;
|
|
83
|
+
queryCacheCap() {
|
|
84
|
+
const value = Number.parseInt(process.env.PI_LENS_TREE_SITTER_QUERY_CACHE_CAP ?? "", 10);
|
|
85
|
+
return Number.isSafeInteger(value) && value > 0 ? value : TreeSitterClient.QUERY_CACHE_MAX_ENTRIES;
|
|
86
|
+
}
|
|
87
|
+
queryBatchCacheCap() {
|
|
88
|
+
const value = Number.parseInt(process.env.PI_LENS_TREE_SITTER_QUERY_BATCH_CACHE_CAP ?? "", 10);
|
|
89
|
+
return Number.isSafeInteger(value) && value > 0 ? value : TreeSitterClient.QUERY_BATCH_CACHE_MAX_ENTRIES;
|
|
90
|
+
}
|
|
91
|
+
cacheQuery(key, value) {
|
|
92
|
+
this.queryCache.delete(key);
|
|
93
|
+
this.queryCache.set(key, value);
|
|
94
|
+
while (this.queryCache.size > this.queryCacheCap()) {
|
|
95
|
+
const oldest = this.queryCache.entries().next().value;
|
|
96
|
+
if (!oldest)
|
|
97
|
+
break;
|
|
98
|
+
this.queryCache.delete(oldest[0]);
|
|
99
|
+
oldest[1]?.query?.delete?.();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
cacheQueryBatch(key, value) {
|
|
103
|
+
this.queryBatchCache.delete(key);
|
|
104
|
+
this.queryBatchCache.set(key, value);
|
|
105
|
+
while (this.queryBatchCache.size > this.queryBatchCacheCap()) {
|
|
106
|
+
const oldest = this.queryBatchCache.entries().next().value;
|
|
107
|
+
if (!oldest)
|
|
108
|
+
break;
|
|
109
|
+
this.queryBatchCache.delete(oldest[0]);
|
|
110
|
+
oldest[1]?.query?.delete?.();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Consecutive grammar-load failures per batch key — bounds load retries (#889). */
|
|
114
|
+
queryBatchLoadFailures = new Map();
|
|
49
115
|
queryLoader = new TreeSitterQueryLoader();
|
|
50
116
|
verbose;
|
|
51
|
-
|
|
117
|
+
parserCounters = createParserCounters();
|
|
118
|
+
parseCacheMeasurement = new AsyncLocalStorage();
|
|
119
|
+
activeMeasurements = 0;
|
|
120
|
+
onWasmAbort;
|
|
121
|
+
wasmAborted = false;
|
|
122
|
+
constructor(verbose = false, onWasmAbort) {
|
|
52
123
|
this.grammarsDir = this.findGrammarsDir();
|
|
53
124
|
this.verbose = verbose;
|
|
54
|
-
this.
|
|
125
|
+
this.onWasmAbort = onWasmAbort;
|
|
126
|
+
this.treeCache = new TreeCache(50, verbose, 4096, (key, amount) => {
|
|
127
|
+
const measurement = this.parseCacheMeasurement.getStore();
|
|
128
|
+
if (measurement)
|
|
129
|
+
measurement[key] += amount;
|
|
130
|
+
}, (error) => this.reportWasmAbort(error));
|
|
131
|
+
}
|
|
132
|
+
recordParserCounter(key, amount = 1) {
|
|
133
|
+
this.parserCounters[key] += amount;
|
|
134
|
+
const measurement = this.parseCacheMeasurement.getStore();
|
|
135
|
+
if (measurement)
|
|
136
|
+
measurement[key] += amount;
|
|
137
|
+
}
|
|
138
|
+
reportWasmAbort(error) {
|
|
139
|
+
if (!isTreeSitterWasmAbortError(error))
|
|
140
|
+
return false;
|
|
141
|
+
if (!this.wasmAborted) {
|
|
142
|
+
this.wasmAborted = true;
|
|
143
|
+
recordDegradation({
|
|
144
|
+
kind: "wasm-abort",
|
|
145
|
+
subject: "web-tree-sitter",
|
|
146
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
147
|
+
});
|
|
148
|
+
this.onWasmAbort?.();
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* O(1) runtime footprint counters (#1123 item 2 memory attribution): every
|
|
154
|
+
* field is a `Map.size` read, never an iteration over the maps' contents.
|
|
155
|
+
* `wasmMemoryBytes` is deliberately NOT included here — web-tree-sitter
|
|
156
|
+
* 0.25.10's Emscripten `Module` (which owns the WASM linear memory /
|
|
157
|
+
* `HEAPU8.buffer`) is a private closure variable in the package's
|
|
158
|
+
* `bindings.ts` and is not exposed through any public export (`Parser`,
|
|
159
|
+
* `Language`, `Query`, ...); reaching it would require either reflecting
|
|
160
|
+
* into the package's internal module state (brittle across web-tree-sitter
|
|
161
|
+
* versions/bundling) or overriding Emscripten's `wasmMemory` module option
|
|
162
|
+
* at `Parser.init()` time with a hand-constructed `WebAssembly.Memory`
|
|
163
|
+
* matching the library's own default page-count math (risks a memory-import
|
|
164
|
+
* mismatch that would break ALL structural analysis, for an
|
|
165
|
+
* observability-only feature). `process.memoryUsage().arrayBuffers` is the
|
|
166
|
+
* process-wide proxy used instead (WASM linear memory backs an ArrayBuffer,
|
|
167
|
+
* so it is included there) — see clients/memory-sampler.ts.
|
|
168
|
+
*/
|
|
169
|
+
getRuntimeStats() {
|
|
170
|
+
return {
|
|
171
|
+
languagesLoaded: this.languages.size,
|
|
172
|
+
parsersLoaded: this.parsers.size,
|
|
173
|
+
queryCacheSize: this.queryCache.size,
|
|
174
|
+
queryBatchCacheSize: this.queryBatchCache.size,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
getParseCacheStats() {
|
|
178
|
+
return {
|
|
179
|
+
...this.treeCache.getStats(),
|
|
180
|
+
...this.parserCounters,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async withParseCacheMeasurement(work, onComplete) {
|
|
184
|
+
const measurement = {
|
|
185
|
+
...createTreeCacheCounters(),
|
|
186
|
+
...createParserCounters(),
|
|
187
|
+
};
|
|
188
|
+
this.activeMeasurements++;
|
|
189
|
+
try {
|
|
190
|
+
return await this.parseCacheMeasurement.run(measurement, async () => {
|
|
191
|
+
try {
|
|
192
|
+
return await work();
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
const cacheStats = this.treeCache.getStats();
|
|
196
|
+
try {
|
|
197
|
+
onComplete({
|
|
198
|
+
...cacheStats,
|
|
199
|
+
...measurement,
|
|
200
|
+
misses: measurement.lookups - measurement.hits,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Telemetry must not fail the measured work.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
// On Node 22 an active AsyncLocalStorage keeps an async_hooks init hook
|
|
211
|
+
// enabled for every promise in the process, so release it once the last
|
|
212
|
+
// overlapping measurement is done rather than taxing the whole session.
|
|
213
|
+
if (--this.activeMeasurements === 0)
|
|
214
|
+
this.parseCacheMeasurement.disable();
|
|
215
|
+
}
|
|
55
216
|
}
|
|
56
217
|
/** Debug logging helper */
|
|
57
218
|
dbg(msg) {
|
|
58
219
|
if (this.verbose) {
|
|
59
|
-
|
|
220
|
+
// #1333: verbose gating is preserved, but the SINK is tree-sitter.log —
|
|
221
|
+
// a raw write would corrupt pi's frame the moment verbose is enabled.
|
|
222
|
+
logTreeSitterDiagnostic({
|
|
223
|
+
subsystem: "tree-sitter-client",
|
|
224
|
+
level: "debug",
|
|
225
|
+
message: msg,
|
|
226
|
+
});
|
|
60
227
|
}
|
|
61
228
|
}
|
|
62
229
|
/**
|
|
@@ -196,6 +363,32 @@ export class TreeSitterClient {
|
|
|
196
363
|
if (this.resolveGrammarFile(grammarFile)) {
|
|
197
364
|
return true;
|
|
198
365
|
}
|
|
366
|
+
if (!assertInstallAllowed(`tree-sitter grammar fetch: ${grammarFile}`)) {
|
|
367
|
+
const unavailable = `tree-sitter grammar '${grammarFile}' is unavailable because the project is not trusted; ` +
|
|
368
|
+
`runtime grammar downloads are disabled until trust is granted.`;
|
|
369
|
+
logTreeSitterDiagnostic({
|
|
370
|
+
subsystem: "tree-sitter-client",
|
|
371
|
+
message: unavailable,
|
|
372
|
+
metadata: { grammarFile, outcome: "trust-gated" },
|
|
373
|
+
});
|
|
374
|
+
recordDegradation({
|
|
375
|
+
kind: "grammar-blocked",
|
|
376
|
+
subject: grammarFile,
|
|
377
|
+
reason: "runtime grammar download blocked because project is untrusted",
|
|
378
|
+
});
|
|
379
|
+
// Lazy clear-on-transition (#1363 review): compare the trust
|
|
380
|
+
// generation at use time -- no listener registration, no retention.
|
|
381
|
+
const generation = getProjectTrustGeneration();
|
|
382
|
+
if (generation !== this.trustNotificationsGeneration) {
|
|
383
|
+
this.trustNotificationsGeneration = generation;
|
|
384
|
+
this.trustBlockedGrammarNotifications.clear();
|
|
385
|
+
}
|
|
386
|
+
if (!this.trustBlockedGrammarNotifications.has(grammarFile)) {
|
|
387
|
+
this.trustBlockedGrammarNotifications.add(grammarFile);
|
|
388
|
+
notifyUserDegradation(`pi-lens: ${unavailable}`);
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
199
392
|
const inflight = this.grammarEnsurePromises.get(grammarFile);
|
|
200
393
|
if (inflight)
|
|
201
394
|
return inflight;
|
|
@@ -211,17 +404,36 @@ export class TreeSitterClient {
|
|
|
211
404
|
if (ok) {
|
|
212
405
|
if (!this.grammarsDir)
|
|
213
406
|
this.grammarsDir = dir;
|
|
214
|
-
|
|
407
|
+
logTreeSitterDiagnostic({
|
|
408
|
+
subsystem: "tree-sitter-client",
|
|
409
|
+
level: "warn",
|
|
410
|
+
message: `fetched missing tree-sitter grammar ${grammarFile} at runtime (install scripts were skipped by the package manager)`,
|
|
411
|
+
metadata: { grammarFile, outcome: "fetched" },
|
|
412
|
+
});
|
|
215
413
|
}
|
|
216
414
|
else {
|
|
217
415
|
// Surface the degradation once per grammar (the promise cache dedupes)
|
|
218
416
|
// instead of failing silently — otherwise pnpm/bun users offline get
|
|
219
417
|
// no signal that a language's tree-sitter features are unavailable.
|
|
220
|
-
|
|
418
|
+
const unavailable = `tree-sitter grammar '${grammarFile}' is unavailable — ` +
|
|
221
419
|
`symbol search, module reports and structural rules for this language will be degraded. ` +
|
|
222
420
|
`The package manager skipped install scripts and the runtime download failed (offline or CDN unreachable). ` +
|
|
223
421
|
`Fix: reinstall with a manager that runs postinstall, allow its build scripts ` +
|
|
224
|
-
`(pnpm approve-builds / bun trustedDependencies), or restore network access
|
|
422
|
+
`(pnpm approve-builds / bun trustedDependencies), or restore network access.`;
|
|
423
|
+
logTreeSitterDiagnostic({
|
|
424
|
+
subsystem: "tree-sitter-client",
|
|
425
|
+
message: unavailable,
|
|
426
|
+
metadata: { grammarFile, outcome: "unavailable" },
|
|
427
|
+
});
|
|
428
|
+
recordDegradation({
|
|
429
|
+
kind: "grammar-blocked",
|
|
430
|
+
subject: grammarFile,
|
|
431
|
+
reason: "runtime grammar download failed",
|
|
432
|
+
});
|
|
433
|
+
// HUMAN-audience: an offline grammar fetch silently degrades this
|
|
434
|
+
// language's features, so it reaches the user through the HOST's
|
|
435
|
+
// render path (#1333) rather than a raw terminal write.
|
|
436
|
+
notifyUserDegradation(`pi-lens: ${unavailable}`);
|
|
225
437
|
}
|
|
226
438
|
return ok;
|
|
227
439
|
})();
|
|
@@ -230,6 +442,8 @@ export class TreeSitterClient {
|
|
|
230
442
|
}
|
|
231
443
|
/** Initialize tree-sitter WASM runtime */
|
|
232
444
|
async init() {
|
|
445
|
+
if (this.wasmAborted)
|
|
446
|
+
return false;
|
|
233
447
|
if (this.initialized)
|
|
234
448
|
return true;
|
|
235
449
|
if (this.initPromise)
|
|
@@ -268,6 +482,7 @@ export class TreeSitterClient {
|
|
|
268
482
|
return true;
|
|
269
483
|
}
|
|
270
484
|
catch (err) {
|
|
485
|
+
this.reportWasmAbort(err);
|
|
271
486
|
this.dbg(`Init error: ${err}`);
|
|
272
487
|
return false;
|
|
273
488
|
}
|
|
@@ -279,6 +494,8 @@ export class TreeSitterClient {
|
|
|
279
494
|
}
|
|
280
495
|
/** Load language grammar */
|
|
281
496
|
async loadLanguage(languageId) {
|
|
497
|
+
if (this.wasmAborted)
|
|
498
|
+
return null;
|
|
282
499
|
this.dbg(`Loading language: ${languageId}`);
|
|
283
500
|
if (this.languages.has(languageId)) {
|
|
284
501
|
this.dbg(`Language ${languageId} already loaded`);
|
|
@@ -299,6 +516,11 @@ export class TreeSitterClient {
|
|
|
299
516
|
const blockReason = grammarBlockReason(grammarFile);
|
|
300
517
|
if (blockReason) {
|
|
301
518
|
this.dbg(`Grammar ${grammarFile} blocked on this runtime — ${blockReason}`);
|
|
519
|
+
recordDegradation({
|
|
520
|
+
kind: "grammar-blocked",
|
|
521
|
+
subject: grammarFile,
|
|
522
|
+
reason: blockReason,
|
|
523
|
+
});
|
|
302
524
|
return null;
|
|
303
525
|
}
|
|
304
526
|
// Look across the bundled core `grammars/` dir and the postinstall/lazy
|
|
@@ -330,12 +552,15 @@ export class TreeSitterClient {
|
|
|
330
552
|
return language;
|
|
331
553
|
}
|
|
332
554
|
catch (err) {
|
|
555
|
+
this.reportWasmAbort(err);
|
|
333
556
|
this.dbg(`Language load error: ${err}`);
|
|
334
557
|
return null;
|
|
335
558
|
}
|
|
336
559
|
}
|
|
337
560
|
/** Get or create parser for a language */
|
|
338
561
|
async getParser(languageId) {
|
|
562
|
+
if (this.wasmAborted)
|
|
563
|
+
return null;
|
|
339
564
|
if (this.parsers.has(languageId)) {
|
|
340
565
|
return this.parsers.get(languageId);
|
|
341
566
|
}
|
|
@@ -347,33 +572,62 @@ export class TreeSitterClient {
|
|
|
347
572
|
this.parsers.set(languageId, parser);
|
|
348
573
|
return parser;
|
|
349
574
|
}
|
|
350
|
-
/**
|
|
575
|
+
/**
|
|
576
|
+
* Parse a file and return the AST tree. The tree stays valid only until the
|
|
577
|
+
* caller's next `await` — prefer `withParsedTree`, which extracts inside the
|
|
578
|
+
* cache-safe window (#417/#675).
|
|
579
|
+
*/
|
|
351
580
|
async parseFile(filePath, languageId, contentOverride) {
|
|
581
|
+
const outcome = await this.parseFileAndUse(filePath, languageId, contentOverride, (tree) => tree);
|
|
582
|
+
return outcome.parsed ? outcome.value : null;
|
|
583
|
+
}
|
|
584
|
+
async withParsedTree(filePath, languageId, contentOverride, consume) {
|
|
585
|
+
return this.parseFileAndUse(filePath, languageId, contentOverride, consume);
|
|
586
|
+
}
|
|
587
|
+
async parseFileAndUse(filePath, languageId, contentOverride, consume) {
|
|
352
588
|
this.dbg(`Parsing ${filePath} with language ${languageId}`);
|
|
353
589
|
const parser = await this.getParser(languageId);
|
|
354
590
|
if (!parser) {
|
|
355
591
|
this.dbg(`Failed to get parser for ${languageId}`);
|
|
356
|
-
return
|
|
592
|
+
return NOT_PARSED;
|
|
357
593
|
}
|
|
594
|
+
let tree;
|
|
358
595
|
try {
|
|
359
596
|
const content = contentOverride ?? fs.readFileSync(filePath, "utf-8");
|
|
360
597
|
this.dbg(`File content length: ${content.length}`);
|
|
361
|
-
// Check cache first
|
|
362
598
|
const cachedTree = this.treeCache.get(filePath, content, languageId);
|
|
363
599
|
if (cachedTree) {
|
|
364
600
|
this.dbg(`Using cached tree for ${filePath}`);
|
|
365
|
-
|
|
601
|
+
tree = cachedTree;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const parseStartedAt = performance.now();
|
|
605
|
+
this.recordParserCounter("parserInvocations");
|
|
606
|
+
try {
|
|
607
|
+
tree = parser.parse(content);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
this.recordParserCounter("parserFailures");
|
|
611
|
+
throw err;
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
this.recordParserCounter("parserDurationMs", performance.now() - parseStartedAt);
|
|
615
|
+
}
|
|
616
|
+
this.dbg(`Parsed, root node type: ${tree.rootNode.type}`);
|
|
617
|
+
this.treeCache.set(filePath, content, languageId, tree);
|
|
366
618
|
}
|
|
367
|
-
// Parse and cache
|
|
368
|
-
const tree = parser.parse(content);
|
|
369
|
-
this.dbg(`Parsed, root node type: ${tree.rootNode.type}`);
|
|
370
|
-
// Cache the tree
|
|
371
|
-
this.treeCache.set(filePath, content, languageId, tree);
|
|
372
|
-
return tree;
|
|
373
619
|
}
|
|
374
620
|
catch (err) {
|
|
621
|
+
this.reportWasmAbort(err);
|
|
375
622
|
this.dbg(`Parse error: ${err}`);
|
|
376
|
-
return
|
|
623
|
+
return NOT_PARSED;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
return { parsed: true, value: consume(tree) };
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
this.reportWasmAbort(error);
|
|
630
|
+
throw error;
|
|
377
631
|
}
|
|
378
632
|
}
|
|
379
633
|
/**
|
|
@@ -421,6 +675,8 @@ export class TreeSitterClient {
|
|
|
421
675
|
}
|
|
422
676
|
/** Check if tree-sitter is available (a core grammar resolves somewhere). */
|
|
423
677
|
isAvailable() {
|
|
678
|
+
if (this.wasmAborted)
|
|
679
|
+
return false;
|
|
424
680
|
// Available if the core TS grammar resolves in ANY source dir — the bundled
|
|
425
681
|
// `grammars/` counts even when web-tree-sitter/grammars is empty (no
|
|
426
682
|
// postinstall on pnpm/bun, or a fresh CI checkout).
|
|
@@ -433,6 +689,8 @@ export class TreeSitterClient {
|
|
|
433
689
|
}
|
|
434
690
|
/** Check if specific language is supported */
|
|
435
691
|
async isLanguageSupported(languageId) {
|
|
692
|
+
if (this.wasmAborted)
|
|
693
|
+
return false;
|
|
436
694
|
if (!this.initialized)
|
|
437
695
|
await this.init();
|
|
438
696
|
const language = await this.loadLanguage(languageId);
|
|
@@ -440,6 +698,8 @@ export class TreeSitterClient {
|
|
|
440
698
|
}
|
|
441
699
|
/** Get loaded language for symbol extraction */
|
|
442
700
|
getLanguage(languageId) {
|
|
701
|
+
if (this.wasmAborted)
|
|
702
|
+
return null;
|
|
443
703
|
return this.languages.get(languageId) || null;
|
|
444
704
|
}
|
|
445
705
|
// --- Structural Search ---
|
|
@@ -496,13 +756,201 @@ export class TreeSitterClient {
|
|
|
496
756
|
if (!ok)
|
|
497
757
|
return [];
|
|
498
758
|
}
|
|
499
|
-
|
|
759
|
+
// Compile against the language the FILE is parsed as, never the query's
|
|
760
|
+
// own `language:` key. A Query is bound to the grammar it compiled
|
|
761
|
+
// against; run it on a tree from another grammar and tree-sitter returns
|
|
762
|
+
// zero matches forever — which is what the javascript→typescript rule
|
|
763
|
+
// merge had been doing (31 queries per JS file, structurally unable to
|
|
764
|
+
// fire). `getQueryCacheKey` already namespaces by language.
|
|
765
|
+
const compiled = await this.compileRawQuery(queryDef.id, queryDef.query, queryDef.metavars, languageId, queryDef.post_filter, queryDef.post_filter_params);
|
|
500
766
|
if (!compiled)
|
|
501
767
|
return [];
|
|
502
768
|
const matches = await this.searchFileWithQuery(filePath, compiled.query, compiled.metavars, languageId, queryDef.id, compiled.postFilter, compiled.postFilterParams, contentOverride);
|
|
503
769
|
const maxResults = options.maxResults ?? 50;
|
|
504
770
|
return matches.slice(0, maxResults);
|
|
505
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Run a whole rule set against one file in a SINGLE tree walk (#675).
|
|
774
|
+
*
|
|
775
|
+
* Calling `runQueryOnFile` per rule re-walks the tree once per rule — ~34
|
|
776
|
+
* walks per file on a project scan, measured at 3.3× the cost of one
|
|
777
|
+
* combined query for byte-identical matches. Patterns are concatenated in
|
|
778
|
+
* rule order and `match.patternIndex` maps back to the owning rule, so
|
|
779
|
+
* per-rule metavars, predicates, post-filters and caps still apply and
|
|
780
|
+
* results stay grouped in rule order. Rules that don't compile against this
|
|
781
|
+
* language are dropped individually (never poisoning the batch), and a
|
|
782
|
+
* combined query that fails to compile falls back to per-rule execution.
|
|
783
|
+
*/
|
|
784
|
+
async runQueriesOnFile(queryDefs, filePath, languageId, options = {}, contentOverride) {
|
|
785
|
+
if (queryDefs.length === 0)
|
|
786
|
+
return [];
|
|
787
|
+
if (!this.initialized) {
|
|
788
|
+
const ok = await this.init();
|
|
789
|
+
if (!ok)
|
|
790
|
+
return [];
|
|
791
|
+
}
|
|
792
|
+
const maxResults = options.maxResults ?? 50;
|
|
793
|
+
const batch = await this.compileQueryBatch(queryDefs, languageId);
|
|
794
|
+
if (!batch) {
|
|
795
|
+
// Fallback: one walk per rule, preserving rule order.
|
|
796
|
+
const results = [];
|
|
797
|
+
for (const queryDef of queryDefs) {
|
|
798
|
+
const matches = await this.runQueryOnFile(queryDef, filePath, languageId, options, contentOverride);
|
|
799
|
+
for (const match of matches)
|
|
800
|
+
results.push({ queryDef, match });
|
|
801
|
+
}
|
|
802
|
+
return results;
|
|
803
|
+
}
|
|
804
|
+
const perQuery = new Map();
|
|
805
|
+
await this.parseFileAndUse(filePath, languageId, contentOverride, (tree) => {
|
|
806
|
+
try {
|
|
807
|
+
for (const match of batch.query.matches(tree.rootNode)) {
|
|
808
|
+
const owner = batch.ownerOfPattern[match.patternIndex];
|
|
809
|
+
if (owner === undefined)
|
|
810
|
+
continue;
|
|
811
|
+
const bucket = perQuery.get(owner) ?? [];
|
|
812
|
+
if (bucket.length >= maxResults)
|
|
813
|
+
continue;
|
|
814
|
+
const entry = batch.entries[owner];
|
|
815
|
+
const captures = {};
|
|
816
|
+
for (const capture of match.captures) {
|
|
817
|
+
if (entry.metavars.includes(capture.name)) {
|
|
818
|
+
captures[capture.name] = capture.node;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (!this.evaluatePredicates(batch.query, match))
|
|
822
|
+
continue;
|
|
823
|
+
if (entry.postFilter &&
|
|
824
|
+
!this.applyPostFilter(entry.postFilter, entry.postFilterParams, captures, tree.rootNode)) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (match.captures.length === 0)
|
|
828
|
+
continue;
|
|
829
|
+
const firstNode = match.captures[0].node;
|
|
830
|
+
const textCaptures = {};
|
|
831
|
+
for (const [name, node] of Object.entries(captures)) {
|
|
832
|
+
textCaptures[name] = node.text;
|
|
833
|
+
}
|
|
834
|
+
bucket.push({
|
|
835
|
+
file: filePath,
|
|
836
|
+
line: firstNode.startPosition.row + 1,
|
|
837
|
+
column: firstNode.startPosition.column + 1,
|
|
838
|
+
matchedText: firstNode.text,
|
|
839
|
+
nodeType: firstNode.type,
|
|
840
|
+
captures: textCaptures,
|
|
841
|
+
});
|
|
842
|
+
perQuery.set(owner, bucket);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
this.reportWasmAbort(err);
|
|
847
|
+
this.dbg(`Batched query matching error: ${err}`);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
const results = [];
|
|
851
|
+
for (let i = 0; i < batch.entries.length; i++) {
|
|
852
|
+
for (const match of perQuery.get(i) ?? []) {
|
|
853
|
+
results.push({ queryDef: batch.entries[i].queryDef, match });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return results;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Compile `queryDefs` into one multi-pattern Query for `languageId`, with a
|
|
860
|
+
* pattern-index → rule map. Cached per language + rule-set identity.
|
|
861
|
+
*/
|
|
862
|
+
async compileQueryBatch(queryDefs, languageId) {
|
|
863
|
+
// Key on rule CONTENT, not just ids: the batch stores each queryDef (its
|
|
864
|
+
// message reaches diagnostics) and the compiled patterns. Rule ids are
|
|
865
|
+
// stable across edits, so an id-only key kept serving the pre-edit batch
|
|
866
|
+
// for the process lifetime even after the runner reloaded the rules from
|
|
867
|
+
// disk on a RuleCache miss (#878). sha256 keeps the key bounded without
|
|
868
|
+
// #889's 32-bit collision risk.
|
|
869
|
+
const identity = crypto
|
|
870
|
+
.createHash("sha256")
|
|
871
|
+
.update(JSON.stringify(queryDefs))
|
|
872
|
+
.digest("hex");
|
|
873
|
+
const cacheKey = this.getQueryCacheKey(`batch:${identity}`, languageId);
|
|
874
|
+
const cached = this.queryBatchCache.get(cacheKey);
|
|
875
|
+
if (cached !== undefined) {
|
|
876
|
+
this.queryBatchCache.delete(cacheKey);
|
|
877
|
+
this.queryBatchCache.set(cacheKey, cached);
|
|
878
|
+
return cached;
|
|
879
|
+
}
|
|
880
|
+
// A loadLanguage() failure is transient (offline lazy grammar fetch,
|
|
881
|
+
// transient mid-scan load error) — do NOT cache null for it, or every
|
|
882
|
+
// later scan pays the ~3.3x per-rule fallback for the process lifetime
|
|
883
|
+
// (#889). Retry on the next call, bounded so a grammar that never loads
|
|
884
|
+
// doesn't re-attempt every scan.
|
|
885
|
+
const language = await this.loadLanguage(languageId);
|
|
886
|
+
if (!language) {
|
|
887
|
+
const failures = (this.queryBatchLoadFailures.get(cacheKey) ?? 0) + 1;
|
|
888
|
+
if (failures >= QUERY_BATCH_MAX_LOAD_FAILURES) {
|
|
889
|
+
this.dbg(`Batch: grammar for ${languageId} failed to load ${failures} times — caching miss`);
|
|
890
|
+
this.queryBatchLoadFailures.delete(cacheKey);
|
|
891
|
+
this.cacheQueryBatch(cacheKey, null);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
this.queryBatchLoadFailures.set(cacheKey, failures);
|
|
895
|
+
}
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
this.queryBatchLoadFailures.delete(cacheKey);
|
|
899
|
+
const build = async () => {
|
|
900
|
+
const Query = (await loadWebTreeSitter()).Query;
|
|
901
|
+
const entries = [];
|
|
902
|
+
const sources = [];
|
|
903
|
+
const ownerOfPattern = [];
|
|
904
|
+
for (const queryDef of queryDefs) {
|
|
905
|
+
let patternCount;
|
|
906
|
+
try {
|
|
907
|
+
// biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
|
|
908
|
+
const probe = new Query(language, queryDef.query);
|
|
909
|
+
patternCount = probe.patternCount();
|
|
910
|
+
probe.delete?.();
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
if (this.reportWasmAbort(err))
|
|
914
|
+
return null;
|
|
915
|
+
// A rule that can't compile against THIS grammar is simply not
|
|
916
|
+
// applicable here (e.g. a `type_annotation` pattern on javascript).
|
|
917
|
+
this.dbg(`Batch: skipping ${queryDef.id} for ${languageId}: ${err}`);
|
|
918
|
+
this.reportQueryCompileFailure(queryDef.id, languageId, err);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const owner = entries.length;
|
|
922
|
+
entries.push({
|
|
923
|
+
queryDef,
|
|
924
|
+
metavars: queryDef.metavars ?? [],
|
|
925
|
+
postFilter: queryDef.post_filter,
|
|
926
|
+
postFilterParams: queryDef.post_filter_params,
|
|
927
|
+
});
|
|
928
|
+
sources.push(queryDef.query);
|
|
929
|
+
for (let i = 0; i < patternCount; i++)
|
|
930
|
+
ownerOfPattern.push(owner);
|
|
931
|
+
}
|
|
932
|
+
if (entries.length === 0)
|
|
933
|
+
return null;
|
|
934
|
+
try {
|
|
935
|
+
// biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
|
|
936
|
+
const query = new Query(language, sources.join("\n"));
|
|
937
|
+
if (query.patternCount() !== ownerOfPattern.length) {
|
|
938
|
+
this.dbg(`Batch pattern count mismatch for ${languageId} (${query.patternCount()} vs ${ownerOfPattern.length}) — falling back`);
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
return { query, entries, ownerOfPattern };
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
if (this.reportWasmAbort(err))
|
|
945
|
+
return null;
|
|
946
|
+
this.dbg(`Batch compile failed for ${languageId}: ${err}`);
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
const batch = await build();
|
|
951
|
+
this.cacheQueryBatch(cacheKey, batch);
|
|
952
|
+
return batch;
|
|
953
|
+
}
|
|
506
954
|
/**
|
|
507
955
|
* Convert pattern to tree-sitter query
|
|
508
956
|
* First tries to load from query files, then falls back to inline patterns
|
|
@@ -600,13 +1048,10 @@ export class TreeSitterClient {
|
|
|
600
1048
|
*/
|
|
601
1049
|
/** Generate cache key for compiled query */
|
|
602
1050
|
getQueryCacheKey(pattern, languageId) {
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
hash = ((hash << 5) - hash + char) | 0; // NOSONAR: intentional 32-bit truncation for hash stability, not float→int conversion
|
|
608
|
-
}
|
|
609
|
-
return `${languageId}:${hash.toString(36)}`;
|
|
1051
|
+
// Full pattern text as the key. The previous 32-bit hash could collide
|
|
1052
|
+
// across rules and permanently poison a cache slot with the wrong
|
|
1053
|
+
// compiled query (or a null meant for a different rule set) (#889).
|
|
1054
|
+
return `${languageId}:${pattern}`;
|
|
610
1055
|
}
|
|
611
1056
|
/** Compile a pattern into a tree-sitter Query with caching */
|
|
612
1057
|
async compileQuery(pattern, languageId) {
|
|
@@ -614,7 +1059,10 @@ export class TreeSitterClient {
|
|
|
614
1059
|
// Check cache first
|
|
615
1060
|
if (this.queryCache.has(cacheKey)) {
|
|
616
1061
|
this.dbg(`Query cache hit: ${cacheKey}`);
|
|
617
|
-
|
|
1062
|
+
const cached = this.queryCache.get(cacheKey);
|
|
1063
|
+
this.queryCache.delete(cacheKey);
|
|
1064
|
+
this.queryCache.set(cacheKey, cached);
|
|
1065
|
+
return cached;
|
|
618
1066
|
}
|
|
619
1067
|
const language = await this.loadLanguage(languageId);
|
|
620
1068
|
if (!language) {
|
|
@@ -628,13 +1076,14 @@ export class TreeSitterClient {
|
|
|
628
1076
|
const Query = (await loadWebTreeSitter()).Query;
|
|
629
1077
|
// biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
|
|
630
1078
|
const query = new Query(language, queryStr);
|
|
631
|
-
this.dbg(`Query compiled with ${query.patternCount} patterns`);
|
|
1079
|
+
this.dbg(`Query compiled with ${query.patternCount()} patterns`);
|
|
632
1080
|
const result = { query, metavars, postFilter, postFilterParams };
|
|
633
1081
|
// Cache the compiled query
|
|
634
|
-
this.
|
|
1082
|
+
this.cacheQuery(cacheKey, result);
|
|
635
1083
|
return result;
|
|
636
1084
|
}
|
|
637
1085
|
catch (err) {
|
|
1086
|
+
this.reportWasmAbort(err);
|
|
638
1087
|
this.dbg(`Query compilation failed: ${err}`);
|
|
639
1088
|
return null;
|
|
640
1089
|
}
|
|
@@ -643,7 +1092,10 @@ export class TreeSitterClient {
|
|
|
643
1092
|
async compileRawQuery(queryId, queryStr, metavars, languageId, postFilter, postFilterParams) {
|
|
644
1093
|
const cacheKey = this.getQueryCacheKey(`raw:${queryId}:${queryStr}`, languageId);
|
|
645
1094
|
if (this.queryCache.has(cacheKey)) {
|
|
646
|
-
|
|
1095
|
+
const cached = this.queryCache.get(cacheKey);
|
|
1096
|
+
this.queryCache.delete(cacheKey);
|
|
1097
|
+
this.queryCache.set(cacheKey, cached);
|
|
1098
|
+
return cached;
|
|
647
1099
|
}
|
|
648
1100
|
const language = await this.loadLanguage(languageId);
|
|
649
1101
|
if (!language)
|
|
@@ -654,17 +1106,79 @@ export class TreeSitterClient {
|
|
|
654
1106
|
// biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
|
|
655
1107
|
const query = new Query(language, queryStr);
|
|
656
1108
|
const result = { query, metavars, postFilter, postFilterParams };
|
|
657
|
-
this.
|
|
1109
|
+
this.cacheQuery(cacheKey, result);
|
|
658
1110
|
return result;
|
|
659
1111
|
}
|
|
660
1112
|
catch (err) {
|
|
1113
|
+
this.reportWasmAbort(err);
|
|
661
1114
|
this.dbg(`Raw query compilation failed (${queryId}): ${err}`);
|
|
1115
|
+
this.reportQueryCompileFailure(queryId, languageId, err);
|
|
662
1116
|
return null;
|
|
663
1117
|
}
|
|
664
1118
|
}
|
|
1119
|
+
reportedCompileFailures = new Set();
|
|
1120
|
+
/** Warn once per rule+grammar pair whose query fails to compile — a silently-dead rule needs a trail. */
|
|
1121
|
+
reportQueryCompileFailure(ruleId, languageId, err) {
|
|
1122
|
+
// Keyed by rule AND language: a rule can be dispatched against more than one
|
|
1123
|
+
// grammar (`queriesForLanguage` hands the typescript set to tsx too), and
|
|
1124
|
+
// failing on one must not mute the report for the others.
|
|
1125
|
+
const key = `${ruleId}:${languageId}`;
|
|
1126
|
+
if (this.reportedCompileFailures.has(key))
|
|
1127
|
+
return;
|
|
1128
|
+
this.reportedCompileFailures.add(key);
|
|
1129
|
+
logTreeSitterDiagnostic({
|
|
1130
|
+
subsystem: "tree-sitter-client",
|
|
1131
|
+
languageId,
|
|
1132
|
+
message: `tree-sitter rule '${ruleId}' failed to compile against '${languageId}' — ` +
|
|
1133
|
+
`matches for this rule are silently dropped rather than reported. ` +
|
|
1134
|
+
`Fix the query in the rule definition to re-enable it. (${err})`,
|
|
1135
|
+
metadata: { ruleId },
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
665
1138
|
hasChildToken(node, token) {
|
|
666
1139
|
return node.children?.some((child) => child.type === token || child.text === token);
|
|
667
1140
|
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Collect names introduced by a parameter binding pattern.
|
|
1143
|
+
*
|
|
1144
|
+
* Binding-aware: only identifiers in a *binding* position are counted.
|
|
1145
|
+
* Two node shapes hold reference expressions rather than bindings and
|
|
1146
|
+
* must not be descended into:
|
|
1147
|
+
* - `assignment_pattern` / `object_assignment_pattern` (`pattern = default`,
|
|
1148
|
+
* e.g. `[a = b]` or `{a = b}`) — the `value`/`right` side is a default
|
|
1149
|
+
* *expression*, not a new name (`b` in `{a = b}` is a reference).
|
|
1150
|
+
* - `computed_property_name` (`[expr]:` in a destructuring pattern,
|
|
1151
|
+
* e.g. `{[key]: a}`) — `expr` is a reference, not a binding.
|
|
1152
|
+
* Nested binding positions (destructured sub-patterns) are still walked
|
|
1153
|
+
* so renamed/duplicate collisions inside them are found.
|
|
1154
|
+
*/
|
|
1155
|
+
bindingNames(node) {
|
|
1156
|
+
const names = new Set();
|
|
1157
|
+
if (!node)
|
|
1158
|
+
return names;
|
|
1159
|
+
const stack = [node];
|
|
1160
|
+
while (stack.length > 0) {
|
|
1161
|
+
const current = stack.pop();
|
|
1162
|
+
if (current.type === "identifier")
|
|
1163
|
+
names.add(current.text);
|
|
1164
|
+
else if (current.type === "shorthand_property_identifier_pattern") {
|
|
1165
|
+
names.add(current.text);
|
|
1166
|
+
}
|
|
1167
|
+
if (current.type === "property_identifier" ||
|
|
1168
|
+
current.type === "type_identifier" ||
|
|
1169
|
+
current.type === "computed_property_name")
|
|
1170
|
+
continue;
|
|
1171
|
+
if (current.type === "assignment_pattern" ||
|
|
1172
|
+
current.type === "object_assignment_pattern") {
|
|
1173
|
+
const left = current.childForFieldName?.("left");
|
|
1174
|
+
if (left)
|
|
1175
|
+
stack.push(left);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
stack.push(...(current.children ?? []));
|
|
1179
|
+
}
|
|
1180
|
+
return names;
|
|
1181
|
+
}
|
|
668
1182
|
containsYieldInFunctionBody(node, root = node) {
|
|
669
1183
|
for (const child of node.children ?? []) {
|
|
670
1184
|
if (child.type === "yield")
|
|
@@ -687,6 +1201,556 @@ export class TreeSitterClient {
|
|
|
687
1201
|
"sync_session",
|
|
688
1202
|
]).has(tail.toLowerCase());
|
|
689
1203
|
}
|
|
1204
|
+
/**
|
|
1205
|
+
* The body statements of a `switch_case`, in order. A switch_case's named
|
|
1206
|
+
* children are `[value, ...statements]` (its statements are direct children,
|
|
1207
|
+
* not a wrapping statement_block), so drop the leading `case <value>` and any
|
|
1208
|
+
* comments.
|
|
1209
|
+
*/
|
|
1210
|
+
switchCaseBodyStatements(caseNode) {
|
|
1211
|
+
// biome-ignore lint/suspicious/noExplicitAny: AST child iteration
|
|
1212
|
+
const named = (caseNode.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
|
|
1213
|
+
// First named child is the case's value expression (`case <value>`).
|
|
1214
|
+
return named.slice(1);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Whether a case body carries an intentional fallthrough marker comment
|
|
1218
|
+
* (`// fallthrough`, `// falls through`, …). Only a *trailing* marker is
|
|
1219
|
+
* honored: a comment attached to the case node after its body statements
|
|
1220
|
+
* (TypeScript), the case's next sibling in the switch body (JavaScript), or
|
|
1221
|
+
* one inside the trailing body statement's block. A comment in a nested
|
|
1222
|
+
* function or an earlier statement is not a marker for this case and must
|
|
1223
|
+
* not suppress a genuine fall-through. Only comment nodes are checked, never
|
|
1224
|
+
* the case value or statement text.
|
|
1225
|
+
*/
|
|
1226
|
+
hasFallthroughMarker(caseNode) {
|
|
1227
|
+
const comments = [];
|
|
1228
|
+
const body = this.switchCaseBodyStatements(caseNode);
|
|
1229
|
+
const last = body[body.length - 1];
|
|
1230
|
+
const lastEnd = last?.endIndex ?? caseNode.startIndex;
|
|
1231
|
+
// TypeScript attaches a trailing `// fallthrough` as a direct child of the
|
|
1232
|
+
// case node, after the body statements.
|
|
1233
|
+
for (const c of caseNode.children ?? []) {
|
|
1234
|
+
if (c.type.includes("comment") && c.startIndex >= lastEnd) {
|
|
1235
|
+
comments.push(c);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// JavaScript attaches it to the switch body as the case's next sibling.
|
|
1239
|
+
const siblings = (caseNode.parent?.children ?? []).filter((c) => c.isNamed);
|
|
1240
|
+
const index = siblings.findIndex((c) => c.startIndex === caseNode.startIndex);
|
|
1241
|
+
const next = index >= 0 ? siblings[index + 1] : undefined;
|
|
1242
|
+
if (next?.type.includes("comment"))
|
|
1243
|
+
comments.push(next);
|
|
1244
|
+
// A marker inside the trailing body statement's block (e.g. `{ work();
|
|
1245
|
+
// /* fallthrough */ }`). Only the trailing statement is walked, and nested
|
|
1246
|
+
// functions are not descended into, so a comment in a nested function or
|
|
1247
|
+
// an earlier statement can't suppress a genuine fall-through.
|
|
1248
|
+
if (last) {
|
|
1249
|
+
const NESTED_FN = new Set([
|
|
1250
|
+
"function_declaration",
|
|
1251
|
+
"function_expression",
|
|
1252
|
+
"arrow_function",
|
|
1253
|
+
"method_definition",
|
|
1254
|
+
"generator_function",
|
|
1255
|
+
"generator_function_declaration",
|
|
1256
|
+
"class_declaration",
|
|
1257
|
+
]);
|
|
1258
|
+
const stack = [last];
|
|
1259
|
+
for (let visited = 0; stack.length > 0 && visited < 500; visited++) {
|
|
1260
|
+
const node = stack.pop();
|
|
1261
|
+
if (!node)
|
|
1262
|
+
break;
|
|
1263
|
+
if (node.type.includes("comment"))
|
|
1264
|
+
comments.push(node);
|
|
1265
|
+
if (!NESTED_FN.has(node.type)) {
|
|
1266
|
+
stack.push(...(node.children ?? []));
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return comments.some((c) => /falls?\s?through/i.test(c.text));
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Whether a statement terminates control flow (does not fall through to the
|
|
1274
|
+
* next statement). Handles terminators, trailing blocks, try/catch/finally,
|
|
1275
|
+
* and exhaustive if/else. Fail-safe: any unrecognized shape returns false
|
|
1276
|
+
* (falls through), so a blocking rule never under-reports. Depth-bounded so a
|
|
1277
|
+
* pathological nesting cannot blow the stack.
|
|
1278
|
+
*/
|
|
1279
|
+
statementTerminates(node, depth = 0) {
|
|
1280
|
+
if (depth > 20)
|
|
1281
|
+
return false;
|
|
1282
|
+
const t = node.type;
|
|
1283
|
+
const TERMINATORS = new Set([
|
|
1284
|
+
"break_statement",
|
|
1285
|
+
"return_statement",
|
|
1286
|
+
"throw_statement",
|
|
1287
|
+
"continue_statement",
|
|
1288
|
+
]);
|
|
1289
|
+
if (TERMINATORS.has(t))
|
|
1290
|
+
return true;
|
|
1291
|
+
// Wrapper nodes whose trailing statement decides termination: a block, an
|
|
1292
|
+
// `else` clause, a `catch` clause, or a `finally` clause.
|
|
1293
|
+
if (t === "statement_block" ||
|
|
1294
|
+
t === "else_clause" ||
|
|
1295
|
+
t === "catch_clause" ||
|
|
1296
|
+
t === "finally_clause") {
|
|
1297
|
+
const inner = (node.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
|
|
1298
|
+
const last = inner[inner.length - 1];
|
|
1299
|
+
if (!last)
|
|
1300
|
+
return false; // empty wrapper falls through
|
|
1301
|
+
return this.statementTerminates(last, depth + 1);
|
|
1302
|
+
}
|
|
1303
|
+
if (t === "try_statement") {
|
|
1304
|
+
// The try body is the first statement_block child. A returning/throwing
|
|
1305
|
+
// try completes after finally, so only a catch that handles a thrown try
|
|
1306
|
+
// and falls through can still reach the end. A try body that completes
|
|
1307
|
+
// normally reaches the end unless a finally terminates it.
|
|
1308
|
+
const tryBody = (node.children ?? []).find((c) => c.type === "statement_block");
|
|
1309
|
+
const tryTerminates = tryBody
|
|
1310
|
+
? this.statementTerminates(tryBody, depth + 1)
|
|
1311
|
+
: false;
|
|
1312
|
+
const catches = (node.children ?? []).filter((c) => c.type === "catch_clause");
|
|
1313
|
+
const fin = (node.children ?? []).find((c) => c.type === "finally_clause");
|
|
1314
|
+
if (tryTerminates) {
|
|
1315
|
+
return catches.every((c) => this.statementTerminates(c, depth + 1));
|
|
1316
|
+
}
|
|
1317
|
+
return !!fin && this.statementTerminates(fin, depth + 1);
|
|
1318
|
+
}
|
|
1319
|
+
if (t === "if_statement") {
|
|
1320
|
+
// An if only terminates when it has an else and both branches do.
|
|
1321
|
+
const named = (node.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
|
|
1322
|
+
// named = [condition, consequence, (else_clause)]
|
|
1323
|
+
const consequence = named[1];
|
|
1324
|
+
const alternative = named[2];
|
|
1325
|
+
if (!consequence || !alternative)
|
|
1326
|
+
return false;
|
|
1327
|
+
return (this.statementTerminates(consequence, depth + 1) &&
|
|
1328
|
+
this.statementTerminates(alternative, depth + 1));
|
|
1329
|
+
}
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Whether a `switch_case`'s next sibling is another label — `case "a": case
|
|
1334
|
+
* "b": handle()` groups two labels onto one body, which is idiomatic, not a
|
|
1335
|
+
* dead case.
|
|
1336
|
+
*/
|
|
1337
|
+
nextSiblingIsSwitchLabel(caseNode) {
|
|
1338
|
+
const siblings = (caseNode.parent?.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
|
|
1339
|
+
// Match by source offset: web-tree-sitter hands out fresh node wrappers per
|
|
1340
|
+
// access, so identity comparison against `caseNode` is not reliable.
|
|
1341
|
+
const index = siblings.findIndex((c) => c.startIndex === caseNode.startIndex);
|
|
1342
|
+
const next = index >= 0 ? siblings[index + 1] : undefined;
|
|
1343
|
+
return next?.type === "switch_case" || next?.type === "switch_default";
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Whether a loop body contains a statement that can terminate the loop:
|
|
1347
|
+
* `return`/`throw` anywhere (they unwind past the loop), or a `break` that is
|
|
1348
|
+
* not swallowed by a nested loop/switch. Does not descend into nested
|
|
1349
|
+
* functions, whose `return` exits the function rather than the loop.
|
|
1350
|
+
*/
|
|
1351
|
+
bodyHasLoopExit(node, insideNestedLoop) {
|
|
1352
|
+
const NESTS_BREAK = new Set([
|
|
1353
|
+
"for_statement",
|
|
1354
|
+
"for_in_statement",
|
|
1355
|
+
"while_statement",
|
|
1356
|
+
"do_statement",
|
|
1357
|
+
"switch_statement",
|
|
1358
|
+
]);
|
|
1359
|
+
const NESTED_FN = new Set([
|
|
1360
|
+
"function_declaration",
|
|
1361
|
+
"function_expression",
|
|
1362
|
+
"arrow_function",
|
|
1363
|
+
"method_definition",
|
|
1364
|
+
"generator_function",
|
|
1365
|
+
"generator_function_declaration",
|
|
1366
|
+
"class_declaration",
|
|
1367
|
+
]);
|
|
1368
|
+
for (const child of node.children ?? []) {
|
|
1369
|
+
const t = child.type;
|
|
1370
|
+
if (NESTED_FN.has(t))
|
|
1371
|
+
continue;
|
|
1372
|
+
if (t === "return_statement" || t === "throw_statement")
|
|
1373
|
+
return true;
|
|
1374
|
+
// A labeled `break outer;` is not swallowed by the nested loop it sits
|
|
1375
|
+
// in. Which label it targets isn't resolved here — counting any labeled
|
|
1376
|
+
// break as an exit errs toward not flagging, the safe direction for a
|
|
1377
|
+
// blocking rule.
|
|
1378
|
+
if (t === "break_statement") {
|
|
1379
|
+
const labeled = (child.children ?? []).some((c) => c.isNamed);
|
|
1380
|
+
if (!insideNestedLoop || labeled)
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
if (this.bodyHasLoopExit(child, insideNestedLoop || NESTS_BREAK.has(t))) {
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Name of the binding a node's value flows into: the declared name of an
|
|
1391
|
+
* enclosing `variable_declarator`, or the left-hand side of an enclosing
|
|
1392
|
+
* `assignment_expression`. Returns "" if the node is not part of a binding
|
|
1393
|
+
* (the walk stops at function/block/program boundaries so it never reaches
|
|
1394
|
+
* out to an unrelated outer binding).
|
|
1395
|
+
*/
|
|
1396
|
+
enclosingBindingName(node) {
|
|
1397
|
+
let cur = node?.parent;
|
|
1398
|
+
for (let depth = 0; cur && depth < 12; depth++) {
|
|
1399
|
+
const t = cur.type;
|
|
1400
|
+
if (t === "variable_declarator") {
|
|
1401
|
+
const name = cur.children?.find((c) => c.isNamed);
|
|
1402
|
+
return name?.text ?? "";
|
|
1403
|
+
}
|
|
1404
|
+
if (t === "assignment_expression") {
|
|
1405
|
+
return cur.children?.[0]?.text ?? "";
|
|
1406
|
+
}
|
|
1407
|
+
if (t === "statement_block" ||
|
|
1408
|
+
t === "program" ||
|
|
1409
|
+
t === "function_declaration" ||
|
|
1410
|
+
t === "function_expression" ||
|
|
1411
|
+
t === "arrow_function" ||
|
|
1412
|
+
t === "method_definition") {
|
|
1413
|
+
return "";
|
|
1414
|
+
}
|
|
1415
|
+
cur = cur.parent;
|
|
1416
|
+
}
|
|
1417
|
+
return "";
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Resolves `name` (as used in the *same file*) to a provably fixed URL:
|
|
1421
|
+
* a `const` declarator whose initializer is a string literal, or a
|
|
1422
|
+
* template literal with no `${...}` substitutions.
|
|
1423
|
+
*
|
|
1424
|
+
* This is deliberately conservative — naming convention (e.g.
|
|
1425
|
+
* SCREAMING_SNAKE_CASE) proves nothing about provenance, so it is never
|
|
1426
|
+
* consulted here. Any of the following makes resolution fail (and the
|
|
1427
|
+
* caller must then treat the identifier as potentially tainted):
|
|
1428
|
+
* - no declarator found for `name` in this file;
|
|
1429
|
+
* - more than one declarator for `name` (ambiguous/shadowed — refuse
|
|
1430
|
+
* rather than guess which one applies at the use site);
|
|
1431
|
+
* - declared with `let`/`var` (not `const`);
|
|
1432
|
+
* - the identifier is reassigned anywhere in the file
|
|
1433
|
+
* (`name = ...`), even if the declaration itself is `const`-like in
|
|
1434
|
+
* spirit — this also catches destructuring/compound-assignment
|
|
1435
|
+
* edge cases conservatively since we only special-case a clean
|
|
1436
|
+
* assignment_expression;
|
|
1437
|
+
* - the initializer is not a plain string/no-substitution template
|
|
1438
|
+
* literal (e.g. `process.env.X`, a function call, a member
|
|
1439
|
+
* expression, another identifier).
|
|
1440
|
+
*/
|
|
1441
|
+
resolvesToFileLiteralConst(name, rootNode) {
|
|
1442
|
+
const valueNode = this.resolveFileConstValueNode(name, rootNode);
|
|
1443
|
+
if (!valueNode)
|
|
1444
|
+
return false;
|
|
1445
|
+
return this.isFixedUrlLiteralExpr(valueNode);
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Resolves `name` to the initializer value node of its *single, clean*
|
|
1449
|
+
* file-local `const` declarator, or `null` when resolution must be refused.
|
|
1450
|
+
*
|
|
1451
|
+
* Refusal (returns `null`) on any of: no declarator; more than one
|
|
1452
|
+
* declarator (shadowed/ambiguous — don't guess); a `let`/`var` binding for
|
|
1453
|
+
* the same name anywhere; or a reassignment (`name = ...`) anywhere in the
|
|
1454
|
+
* file. This is the shared, provenance-safe gate used by every "provably
|
|
1455
|
+
* fixed value" check; callers inspect the returned value node themselves.
|
|
1456
|
+
*/
|
|
1457
|
+
resolveFileConstValueNode(name, rootNode) {
|
|
1458
|
+
const constDeclarators = [];
|
|
1459
|
+
let hasNonConstBinding = false;
|
|
1460
|
+
let hasReassignment = false;
|
|
1461
|
+
const stack = [rootNode];
|
|
1462
|
+
while (stack.length > 0) {
|
|
1463
|
+
const node = stack.pop();
|
|
1464
|
+
if (!node)
|
|
1465
|
+
continue;
|
|
1466
|
+
if (node.type === "variable_declarator") {
|
|
1467
|
+
const nameNode = node.childForFieldName?.("name");
|
|
1468
|
+
if (nameNode?.type === "identifier" && nameNode.text === name) {
|
|
1469
|
+
const decl = node.parent;
|
|
1470
|
+
const isConst = decl?.type === "lexical_declaration" &&
|
|
1471
|
+
(decl.children ?? []).some((c) => c.type === "const");
|
|
1472
|
+
if (isConst) {
|
|
1473
|
+
constDeclarators.push(node);
|
|
1474
|
+
}
|
|
1475
|
+
else {
|
|
1476
|
+
hasNonConstBinding = true;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
else if (node.type === "assignment_expression" ||
|
|
1481
|
+
node.type === "augmented_assignment_expression") {
|
|
1482
|
+
const left = node.childForFieldName?.("left");
|
|
1483
|
+
if (left?.type === "identifier" && left.text === name) {
|
|
1484
|
+
hasReassignment = true;
|
|
1485
|
+
}
|
|
1486
|
+
else if ((left?.type === "member_expression" ||
|
|
1487
|
+
left?.type === "subscript_expression") &&
|
|
1488
|
+
left.childForFieldName?.("object")?.type === "identifier" &&
|
|
1489
|
+
left.childForFieldName?.("object")?.text === name) {
|
|
1490
|
+
// Property/subscript write to the bound receiver
|
|
1491
|
+
// (`name.<prop> = …` / `name[…] = …`). Any origin/host/path/
|
|
1492
|
+
// protocol/port/href mutation re-taints the destination after
|
|
1493
|
+
// construction, so fail closed and treat it as a reassignment
|
|
1494
|
+
// (#1008). The ONE safe exception is a query-string-only write
|
|
1495
|
+
// (`name.search = …`), which never alters the origin;
|
|
1496
|
+
// `name.searchParams.<method>(…)` is a call_expression (not an
|
|
1497
|
+
// assignment) and so never reaches this branch, staying exempt.
|
|
1498
|
+
const searchOnly = left.type === "member_expression" &&
|
|
1499
|
+
left.childForFieldName?.("property")?.text === "search";
|
|
1500
|
+
if (!searchOnly) {
|
|
1501
|
+
hasReassignment = true;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
for (const child of node.children ?? [])
|
|
1506
|
+
stack.push(child);
|
|
1507
|
+
}
|
|
1508
|
+
// Ambiguous (shadowed/duplicated), reassigned anywhere, or backed by a
|
|
1509
|
+
// non-const binding somewhere in the file: refuse to resolve.
|
|
1510
|
+
if (hasNonConstBinding || hasReassignment || constDeclarators.length !== 1) {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
return constDeclarators[0].childForFieldName?.("value") ?? null;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* True when `node` is a self-contained fixed URL string: a plain string
|
|
1517
|
+
* literal, or a template literal with no `${...}` substitutions.
|
|
1518
|
+
*/
|
|
1519
|
+
isFixedUrlLiteralExpr(node) {
|
|
1520
|
+
if (node.type === "string")
|
|
1521
|
+
return true;
|
|
1522
|
+
if (node.type === "template_string") {
|
|
1523
|
+
return !(node.children ?? []).some((c) => c.type === "template_substitution");
|
|
1524
|
+
}
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* True when `name` is a binding introduced by an `import` in this file
|
|
1529
|
+
* (named/aliased/default/namespace). An import binding is immutable and its
|
|
1530
|
+
* value is fixed at module-load time from source — it is never request- or
|
|
1531
|
+
* attacker-scoped, so an imported base URL is treated as fixed. (Limitation:
|
|
1532
|
+
* we cannot see the exporting module, so an imported value that is itself
|
|
1533
|
+
* `process.env.X` in another file is not distinguished — an accepted, bounded
|
|
1534
|
+
* gap; direct env/param/request taint at the sink still fires.)
|
|
1535
|
+
*/
|
|
1536
|
+
isImportedBinding(name, rootNode) {
|
|
1537
|
+
return this.importedAs(name, rootNode) !== null;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* If `name` is an import binding, returns the *imported* name (e.g. `URL`
|
|
1541
|
+
* for `import { URL as NodeURL }` → `importedAs("NodeURL") === "URL"`, and
|
|
1542
|
+
* `importedAs("URL") === "URL"`). Returns `null` when `name` is not imported.
|
|
1543
|
+
*/
|
|
1544
|
+
importedAs(name, rootNode) {
|
|
1545
|
+
const stack = [rootNode];
|
|
1546
|
+
while (stack.length > 0) {
|
|
1547
|
+
const node = stack.pop();
|
|
1548
|
+
if (!node)
|
|
1549
|
+
continue;
|
|
1550
|
+
if (node.type === "import_specifier") {
|
|
1551
|
+
const nameNode = node.childForFieldName?.("name");
|
|
1552
|
+
const aliasNode = node.childForFieldName?.("alias");
|
|
1553
|
+
const local = (aliasNode ?? nameNode)?.text;
|
|
1554
|
+
if (local === name)
|
|
1555
|
+
return nameNode?.text ?? null;
|
|
1556
|
+
}
|
|
1557
|
+
else if (node.type === "namespace_import" ||
|
|
1558
|
+
node.type === "import_clause") {
|
|
1559
|
+
// `import X from …` / `import * as X from …` — default/namespace
|
|
1560
|
+
// local binding is a direct identifier child.
|
|
1561
|
+
for (const c of node.children ?? []) {
|
|
1562
|
+
if (c.type === "identifier" && c.text === name)
|
|
1563
|
+
return name;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
for (const child of node.children ?? [])
|
|
1567
|
+
stack.push(child);
|
|
1568
|
+
}
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* True when `base` (the second argument of `new URL(path, base)`) is a
|
|
1573
|
+
* provably fixed origin: a literal/substitution-free template, an identifier
|
|
1574
|
+
* resolving to a file-local literal `const` or an import binding, or a
|
|
1575
|
+
* template whose every `${…}` substitution is such an identifier. Anything
|
|
1576
|
+
* else (function params, `process.env.X`, member expressions, calls) fails.
|
|
1577
|
+
*/
|
|
1578
|
+
isFixedUrlBaseExpr(base, rootNode) {
|
|
1579
|
+
if (base.type === "string")
|
|
1580
|
+
return true;
|
|
1581
|
+
if (base.type === "identifier") {
|
|
1582
|
+
return this.isFixedBaseIdentifier(base, rootNode);
|
|
1583
|
+
}
|
|
1584
|
+
if (base.type === "template_string") {
|
|
1585
|
+
for (const child of base.children ?? []) {
|
|
1586
|
+
if (child.type !== "template_substitution")
|
|
1587
|
+
continue;
|
|
1588
|
+
const inner = (child.children ?? []).find((c) => c.isNamed);
|
|
1589
|
+
if (!inner ||
|
|
1590
|
+
inner.type !== "identifier" ||
|
|
1591
|
+
!this.isFixedBaseIdentifier(inner, rootNode)) {
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* True when the identifier `ident` (used as, or inside, a `new URL` base) is
|
|
1601
|
+
* a provably fixed origin AT ITS USE SITE. A file-local literal `const` is
|
|
1602
|
+
* only trusted when no nearer binding shadows it: `resolveFileConstValueNode`
|
|
1603
|
+
* already fails closed on any `let`/`var` of the same name and on multiple
|
|
1604
|
+
* `const` declarators, but function/method PARAMETERS are not variable
|
|
1605
|
+
* declarators and so slip past that gate — a request-tainted parameter base
|
|
1606
|
+
* would otherwise be exempted merely because an unrelated same-named
|
|
1607
|
+
* module-level `const` literal exists (#1008). So we additionally refuse the
|
|
1608
|
+
* file-const path when an enclosing function on the path from the use site to
|
|
1609
|
+
* the module root binds a parameter of the same name. Imported bindings stay
|
|
1610
|
+
* trusted unconditionally (imported-base-as-fixed is sound; see #1000).
|
|
1611
|
+
*/
|
|
1612
|
+
isFixedBaseIdentifier(ident, rootNode) {
|
|
1613
|
+
const name = ident.text;
|
|
1614
|
+
if (!this.isShadowedByEnclosingParam(ident, name) &&
|
|
1615
|
+
this.resolvesToFileLiteralConst(name, rootNode)) {
|
|
1616
|
+
return true;
|
|
1617
|
+
}
|
|
1618
|
+
return this.isImportedBinding(name, rootNode);
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* True when some function/method/arrow on the ancestor chain of `node` (up to
|
|
1622
|
+
* the module root) binds a PARAMETER named `name` — i.e. `name` at `node`'s
|
|
1623
|
+
* location resolves to a parameter, not to an outer `const`. Only binding
|
|
1624
|
+
* positions are inspected (a parameter's `pattern`, including destructured
|
|
1625
|
+
* bindings); default-value expressions (`= expr`) are uses, not bindings, and
|
|
1626
|
+
* are skipped so an outer const referenced in a default is not mistaken for a
|
|
1627
|
+
* shadow. Fail-closed bias: unknown parameter shapes that surface a matching
|
|
1628
|
+
* identifier in a binding position are treated as a shadow.
|
|
1629
|
+
*/
|
|
1630
|
+
isShadowedByEnclosingParam(node, name) {
|
|
1631
|
+
const FUNCTION_TYPES = new Set([
|
|
1632
|
+
"function_declaration",
|
|
1633
|
+
"function_expression",
|
|
1634
|
+
"generator_function",
|
|
1635
|
+
"generator_function_declaration",
|
|
1636
|
+
"arrow_function",
|
|
1637
|
+
"method_definition",
|
|
1638
|
+
]);
|
|
1639
|
+
let current = node.parent;
|
|
1640
|
+
while (current) {
|
|
1641
|
+
if (FUNCTION_TYPES.has(current.type)) {
|
|
1642
|
+
// Bare-identifier arrow parameter: `name => …`.
|
|
1643
|
+
const bare = current.childForFieldName?.("parameter");
|
|
1644
|
+
if (bare?.type === "identifier" && bare.text === name)
|
|
1645
|
+
return true;
|
|
1646
|
+
const params = current.childForFieldName?.("parameters");
|
|
1647
|
+
if (params && this.paramsBindName(params, name))
|
|
1648
|
+
return true;
|
|
1649
|
+
}
|
|
1650
|
+
current = current.parent;
|
|
1651
|
+
}
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
/** True when a `formal_parameters` node binds `name` in any binding position. */
|
|
1655
|
+
paramsBindName(params, name) {
|
|
1656
|
+
for (const param of params.children ?? []) {
|
|
1657
|
+
if (!param.isNamed)
|
|
1658
|
+
continue;
|
|
1659
|
+
// required_parameter / optional_parameter carry the binding in `pattern`
|
|
1660
|
+
// and the default (a use, not a binding) in `value`.
|
|
1661
|
+
const pattern = param.type === "required_parameter" ||
|
|
1662
|
+
param.type === "optional_parameter"
|
|
1663
|
+
? param.childForFieldName?.("pattern")
|
|
1664
|
+
: param;
|
|
1665
|
+
if (pattern && this.patternBindsName(pattern, name))
|
|
1666
|
+
return true;
|
|
1667
|
+
}
|
|
1668
|
+
return false;
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* True when a binding pattern (`identifier`, or a destructuring
|
|
1672
|
+
* object/array/rest pattern) introduces `name`. Walks the pattern but skips
|
|
1673
|
+
* `assignment_pattern` default values (`= expr`), which are uses.
|
|
1674
|
+
*/
|
|
1675
|
+
patternBindsName(pattern, name) {
|
|
1676
|
+
if (pattern.type === "identifier")
|
|
1677
|
+
return pattern.text === name;
|
|
1678
|
+
const stack = [pattern];
|
|
1679
|
+
while (stack.length > 0) {
|
|
1680
|
+
const n = stack.pop();
|
|
1681
|
+
if (!n)
|
|
1682
|
+
continue;
|
|
1683
|
+
if ((n.type === "identifier" ||
|
|
1684
|
+
n.type === "shorthand_property_identifier_pattern") &&
|
|
1685
|
+
n.text === name) {
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
if (n.type === "assignment_pattern") {
|
|
1689
|
+
// Only the left (binding) side introduces names; skip the default.
|
|
1690
|
+
const left = n.childForFieldName?.("left") ?? n.children?.[0];
|
|
1691
|
+
if (left)
|
|
1692
|
+
stack.push(left);
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
for (const c of n.children ?? [])
|
|
1696
|
+
stack.push(c);
|
|
1697
|
+
}
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* True when `name` resolves (same file) to a `const` initialized with
|
|
1702
|
+
* `new URL(<literalPath>, <fixedBase>)` — a fully fixed destination origin
|
|
1703
|
+
* and path. Query parameters added later via `url.searchParams.set(...)` do
|
|
1704
|
+
* not alter origin/path, so they never taint the destination. The `URL`
|
|
1705
|
+
* constructor may be imported under an alias (e.g. `NodeURL`).
|
|
1706
|
+
*/
|
|
1707
|
+
resolvesToFixedNewUrlConst(name, rootNode) {
|
|
1708
|
+
const value = this.resolveFileConstValueNode(name, rootNode);
|
|
1709
|
+
if (!value || value.type !== "new_expression")
|
|
1710
|
+
return false;
|
|
1711
|
+
const ctor = value.childForFieldName?.("constructor");
|
|
1712
|
+
const ctorName = ctor?.text ?? "";
|
|
1713
|
+
const isUrlCtor = ctorName === "URL" ||
|
|
1714
|
+
(ctor?.type === "identifier" &&
|
|
1715
|
+
this.importedAs(ctorName, rootNode) === "URL");
|
|
1716
|
+
if (!isUrlCtor)
|
|
1717
|
+
return false;
|
|
1718
|
+
const args = (value.childForFieldName?.("arguments")?.children ?? []).filter((c) => c.isNamed && c.type !== "comment");
|
|
1719
|
+
if (args.length === 0)
|
|
1720
|
+
return false;
|
|
1721
|
+
// First arg (relative path / full URL) must be a literal.
|
|
1722
|
+
if (!this.isFixedUrlLiteralExpr(args[0]))
|
|
1723
|
+
return false;
|
|
1724
|
+
// Single-arg form: `new URL("https://host/path")` — already fully fixed.
|
|
1725
|
+
if (args.length === 1)
|
|
1726
|
+
return true;
|
|
1727
|
+
// Two-arg form: base must resolve to a fixed origin.
|
|
1728
|
+
return this.isFixedUrlBaseExpr(args[1], rootNode);
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* For a fetch URL argument of the form `u.toString()` (call_expression) or
|
|
1732
|
+
* `u.href` (member_expression), returns the receiver identifier name `u`
|
|
1733
|
+
* (only for the `toString`/`href` accessors a `URL` yields). Returns `null`
|
|
1734
|
+
* for any other shape so the caller falls through to taint heuristics.
|
|
1735
|
+
*/
|
|
1736
|
+
newUrlBaseVarName(urlNode) {
|
|
1737
|
+
let member;
|
|
1738
|
+
if (urlNode.type === "call_expression") {
|
|
1739
|
+
member = urlNode.childForFieldName?.("function");
|
|
1740
|
+
}
|
|
1741
|
+
else if (urlNode.type === "member_expression") {
|
|
1742
|
+
member = urlNode;
|
|
1743
|
+
}
|
|
1744
|
+
if (!member || member.type !== "member_expression")
|
|
1745
|
+
return null;
|
|
1746
|
+
const prop = member.childForFieldName?.("property")?.text;
|
|
1747
|
+
if (prop !== "toString" && prop !== "href")
|
|
1748
|
+
return null;
|
|
1749
|
+
const object = member.childForFieldName?.("object");
|
|
1750
|
+
if (object?.type !== "identifier")
|
|
1751
|
+
return null;
|
|
1752
|
+
return object.text;
|
|
1753
|
+
}
|
|
690
1754
|
isSafeSqlAlchemyExpressionCall(node) {
|
|
691
1755
|
if (node.type !== "call")
|
|
692
1756
|
return false;
|
|
@@ -699,7 +1763,7 @@ export class TreeSitterClient {
|
|
|
699
1763
|
* Each branch is an independent filter identified by name — flat dispatch, no nesting.
|
|
700
1764
|
*/
|
|
701
1765
|
// biome-ignore lint/suspicious/noExplicitAny: postFilterParams is untyped per-filter config
|
|
702
|
-
applyPostFilter(postFilter, postFilterParams, captures) {
|
|
1766
|
+
applyPostFilter(postFilter, postFilterParams, captures, rootNode) {
|
|
703
1767
|
/**
|
|
704
1768
|
* Extract the list of declared slot names from a class_definition's
|
|
705
1769
|
* `__slots__` assignment. Returns:
|
|
@@ -759,7 +1823,323 @@ export class TreeSitterClient {
|
|
|
759
1823
|
}
|
|
760
1824
|
return slots;
|
|
761
1825
|
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Escape a string for safe interpolation into a `RegExp` source —
|
|
1828
|
+
* needed anywhere an identifier's raw text is combined with regex
|
|
1829
|
+
* metacharacters like `\b` word boundaries (see
|
|
1830
|
+
* "not_closed_or_try_with_resources" below; #1089 P2).
|
|
1831
|
+
*/
|
|
1832
|
+
function escapeRegExp(s) {
|
|
1833
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1834
|
+
}
|
|
762
1835
|
switch (postFilter) {
|
|
1836
|
+
case "no_nested_anchor_chain": {
|
|
1837
|
+
// Tree-sitter queries can express the opening-tag shape, but they
|
|
1838
|
+
// cannot express both an arbitrary-depth descendant relationship and
|
|
1839
|
+
// an ancestor exclusion. Walk the captured JSX element here so the
|
|
1840
|
+
// TSX path agrees with the ast-grep rule: keep exactly the outermost
|
|
1841
|
+
// anchor that contains another anchor, including through wrappers.
|
|
1842
|
+
// This post-filter runs inside a batched query walk. It must never
|
|
1843
|
+
// abort that walk or turn a malformed tree into a false negative.
|
|
1844
|
+
try {
|
|
1845
|
+
const outer = captures.OUTER_ELEMENT;
|
|
1846
|
+
if (!outer) {
|
|
1847
|
+
this.reportPostFilterFailure(postFilter, "missing OUTER_ELEMENT capture");
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1850
|
+
const isAnchorElement = (node) => {
|
|
1851
|
+
if (node.type !== "jsx_element")
|
|
1852
|
+
return false;
|
|
1853
|
+
const opening = node.childForFieldName?.("open_tag") ??
|
|
1854
|
+
node.children?.find((child) => child.type === "jsx_opening_element");
|
|
1855
|
+
const name = opening?.childForFieldName?.("name") ??
|
|
1856
|
+
opening?.children?.find((child) => child.type === "identifier");
|
|
1857
|
+
return name?.text === "a";
|
|
1858
|
+
};
|
|
1859
|
+
let visited = 0;
|
|
1860
|
+
let ancestor = outer.parent;
|
|
1861
|
+
while (ancestor) {
|
|
1862
|
+
if (++visited > NO_NESTED_ANCHOR_VISIT_CAP) {
|
|
1863
|
+
this.reportPostFilterFailure(postFilter, `ancestor walk exceeded ${NO_NESTED_ANCHOR_VISIT_CAP} nodes`);
|
|
1864
|
+
return true;
|
|
1865
|
+
}
|
|
1866
|
+
if (isAnchorElement(ancestor))
|
|
1867
|
+
return false;
|
|
1868
|
+
ancestor = ancestor.parent;
|
|
1869
|
+
}
|
|
1870
|
+
const pending = [...(outer.children ?? [])];
|
|
1871
|
+
while (pending.length > 0) {
|
|
1872
|
+
const node = pending.pop();
|
|
1873
|
+
if (!node)
|
|
1874
|
+
continue;
|
|
1875
|
+
if (++visited > NO_NESTED_ANCHOR_VISIT_CAP) {
|
|
1876
|
+
this.reportPostFilterFailure(postFilter, `descendant walk exceeded ${NO_NESTED_ANCHOR_VISIT_CAP} nodes`);
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
if (isAnchorElement(node))
|
|
1880
|
+
return true;
|
|
1881
|
+
pending.push(...(node.children ?? []));
|
|
1882
|
+
}
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
catch (error) {
|
|
1886
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1887
|
+
this.reportPostFilterFailure(postFilter, `tree walk failed${reason ? `: ${reason}` : ""}`);
|
|
1888
|
+
return true;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
case "differs_only_by_case": {
|
|
1892
|
+
try {
|
|
1893
|
+
const field = captures.FIELD?.text ?? "";
|
|
1894
|
+
const method = captures.METHOD?.text ?? "";
|
|
1895
|
+
return (field !== method &&
|
|
1896
|
+
field.toLocaleLowerCase() === method.toLocaleLowerCase());
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
return true;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
case "in_default_package": {
|
|
1903
|
+
try {
|
|
1904
|
+
if (!rootNode)
|
|
1905
|
+
return true;
|
|
1906
|
+
return !(rootNode.children ?? []).some((node) => node.type === "package_declaration");
|
|
1907
|
+
}
|
|
1908
|
+
catch {
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
case "missing_mimetype_and_download_name": {
|
|
1913
|
+
try {
|
|
1914
|
+
const firstArg = captures.FIRST_ARG;
|
|
1915
|
+
if (!firstArg)
|
|
1916
|
+
return true;
|
|
1917
|
+
// Flask can infer a MIME type from a filesystem path. The warning
|
|
1918
|
+
// is for file-like objects, where it needs an explicit MIME type or
|
|
1919
|
+
// download name.
|
|
1920
|
+
if (firstArg.type === "string")
|
|
1921
|
+
return false;
|
|
1922
|
+
const args = firstArg.parent;
|
|
1923
|
+
if (!args)
|
|
1924
|
+
return true;
|
|
1925
|
+
return !(args.children ?? []).some((node) => {
|
|
1926
|
+
if (node.type !== "keyword_argument")
|
|
1927
|
+
return false;
|
|
1928
|
+
const name = node.childForFieldName?.("name")?.text;
|
|
1929
|
+
return name === "mimetype" || name === "download_name";
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
catch {
|
|
1933
|
+
return true;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
case "missing_super_call": {
|
|
1937
|
+
try {
|
|
1938
|
+
const body = captures.BODY;
|
|
1939
|
+
const method = captures.METHOD?.text ?? "";
|
|
1940
|
+
if (!body || !method)
|
|
1941
|
+
return true;
|
|
1942
|
+
const stack = [body];
|
|
1943
|
+
for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
|
|
1944
|
+
const node = stack.pop();
|
|
1945
|
+
if (!node)
|
|
1946
|
+
break;
|
|
1947
|
+
if (node.type === "method_invocation" &&
|
|
1948
|
+
node.childForFieldName?.("object")?.text === "super" &&
|
|
1949
|
+
node.childForFieldName?.("name")?.text === method) {
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1952
|
+
stack.push(...(node.children ?? []));
|
|
1953
|
+
}
|
|
1954
|
+
return true;
|
|
1955
|
+
}
|
|
1956
|
+
catch {
|
|
1957
|
+
return true;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
case "no_assertion_call": {
|
|
1961
|
+
try {
|
|
1962
|
+
const body = captures.BODY;
|
|
1963
|
+
if (!body)
|
|
1964
|
+
return true;
|
|
1965
|
+
const assertionNames = /^(?:assert\w*|fail|verify|expect|check|assume\w*)$/i;
|
|
1966
|
+
const stack = [body];
|
|
1967
|
+
for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
|
|
1968
|
+
const node = stack.pop();
|
|
1969
|
+
if (!node)
|
|
1970
|
+
break;
|
|
1971
|
+
if (node.type === "method_invocation") {
|
|
1972
|
+
const name = node.childForFieldName?.("name")?.text ?? "";
|
|
1973
|
+
if (assertionNames.test(name))
|
|
1974
|
+
return false;
|
|
1975
|
+
}
|
|
1976
|
+
stack.push(...(node.children ?? []));
|
|
1977
|
+
}
|
|
1978
|
+
return true;
|
|
1979
|
+
}
|
|
1980
|
+
catch {
|
|
1981
|
+
return true;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
case "not_closed_or_try_with_resources": {
|
|
1985
|
+
try {
|
|
1986
|
+
const declaration = captures.DECL;
|
|
1987
|
+
const resource = captures.RESOURCE?.text ?? "";
|
|
1988
|
+
if (!declaration || !resource)
|
|
1989
|
+
return true;
|
|
1990
|
+
// (#956 review: the old resource_specification ANCESTOR walk was
|
|
1991
|
+
// dead code — the query only matches local_variable_declaration,
|
|
1992
|
+
// which the Java grammar never places inside a try header. The
|
|
1993
|
+
// real try-with-resources shapes are handled in the scope scan
|
|
1994
|
+
// below: a `resource` node naming this variable covers both
|
|
1995
|
+
// `try (Type x = …)` and the Java 9 `try (x)` form.)
|
|
1996
|
+
const scope = this.navigator.findParent(declaration, [
|
|
1997
|
+
"method_declaration",
|
|
1998
|
+
"constructor_declaration",
|
|
1999
|
+
"block",
|
|
2000
|
+
]) ?? rootNode;
|
|
2001
|
+
if (!scope)
|
|
2002
|
+
return true;
|
|
2003
|
+
// `\b` in a template literal is the BACKSPACE control char
|
|
2004
|
+
// (U+0008), not a regex word-boundary escape — that requires
|
|
2005
|
+
// the literal two-character sequence `\\b`. The identifier
|
|
2006
|
+
// itself must also be regex-escaped (#1089 P2).
|
|
2007
|
+
const resourceWord = new RegExp(`\\b${escapeRegExp(resource)}\\b`);
|
|
2008
|
+
const stack = [scope];
|
|
2009
|
+
for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
|
|
2010
|
+
const node = stack.pop();
|
|
2011
|
+
if (!node)
|
|
2012
|
+
break;
|
|
2013
|
+
if (node.type === "method_invocation" &&
|
|
2014
|
+
node.childForFieldName?.("object")?.text === resource &&
|
|
2015
|
+
node.childForFieldName?.("name")?.text === "close") {
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
if (node.type === "resource" &&
|
|
2019
|
+
resourceWord.test(node.text ?? "")) {
|
|
2020
|
+
return false;
|
|
2021
|
+
}
|
|
2022
|
+
stack.push(...(node.children ?? []));
|
|
2023
|
+
}
|
|
2024
|
+
return true;
|
|
2025
|
+
}
|
|
2026
|
+
catch {
|
|
2027
|
+
return true;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
case "same_method_no_base_case": {
|
|
2031
|
+
try {
|
|
2032
|
+
const method = captures.NAME?.text ?? "";
|
|
2033
|
+
if (!method || captures.RECURSE?.text !== method)
|
|
2034
|
+
return false;
|
|
2035
|
+
const call = captures.CALL;
|
|
2036
|
+
const declaration = call
|
|
2037
|
+
? this.navigator.findParent(call, ["method_declaration"])
|
|
2038
|
+
: undefined;
|
|
2039
|
+
if (!declaration)
|
|
2040
|
+
return true;
|
|
2041
|
+
const stack = [declaration];
|
|
2042
|
+
for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
|
|
2043
|
+
const node = stack.pop();
|
|
2044
|
+
if (!node)
|
|
2045
|
+
break;
|
|
2046
|
+
// Any conditional or loop construct is a plausible base-case
|
|
2047
|
+
// guard (#956 review: while/for-guarded recursion and ternary
|
|
2048
|
+
// base cases are common). This errs toward suppressing a
|
|
2049
|
+
// blocking diagnostic, never inventing one.
|
|
2050
|
+
if (node.type === "if_statement" ||
|
|
2051
|
+
node.type === "switch_expression" ||
|
|
2052
|
+
node.type === "switch_statement" ||
|
|
2053
|
+
node.type === "while_statement" ||
|
|
2054
|
+
node.type === "do_statement" ||
|
|
2055
|
+
node.type === "for_statement" ||
|
|
2056
|
+
node.type === "enhanced_for_statement" ||
|
|
2057
|
+
node.type === "ternary_expression") {
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
stack.push(...(node.children ?? []));
|
|
2061
|
+
}
|
|
2062
|
+
// Cap exhausted without a verdict: this rule is BLOCKING, so a
|
|
2063
|
+
// >10k-node method whose guard sits beyond the budget must not
|
|
2064
|
+
// become a silent blocking FP — suppress instead (#956 review;
|
|
2065
|
+
// the advisory filters keep their keep-the-diagnostic default).
|
|
2066
|
+
return stack.length > 0 ? false : true;
|
|
2067
|
+
}
|
|
2068
|
+
catch {
|
|
2069
|
+
return true;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
case "memset_for_sensitive_data": {
|
|
2073
|
+
try {
|
|
2074
|
+
const destination = captures.DEST?.text ?? "";
|
|
2075
|
+
const value = captures.VALUE?.text?.trim() ?? "";
|
|
2076
|
+
if (!/^(?:0|0x0+|NULL|nullptr)$/.test(value))
|
|
2077
|
+
return false;
|
|
2078
|
+
return /(?:pass(?:word)?|passwd|secret|token|api_?key|private_?key|credential|auth|pin)/i.test(destination);
|
|
2079
|
+
}
|
|
2080
|
+
catch {
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
case "unsafe_regex_dynamic_identifier": {
|
|
2085
|
+
// unsafe-regex is intentionally a coarse advisory heuristic. When the
|
|
2086
|
+
// interpolation is a plain identifier, recover the common safe-before-
|
|
2087
|
+
// assignment pattern without pretending to perform whole-program
|
|
2088
|
+
// dataflow. #949 review hardening: the LAST write (declarator or
|
|
2089
|
+
// assignment) textually BEFORE the use decides — a later reassignment
|
|
2090
|
+
// to user input un-suppresses, and an unrelated declaration after the
|
|
2091
|
+
// use never suppresses. Initializers come from the "value" field (a
|
|
2092
|
+
// type annotation is not an initializer), and bare .replace("a","b")
|
|
2093
|
+
// is not escaping — only escape-named callees or .replace(/regex/
|
|
2094
|
+
// count. Any internal error keeps the diagnostic: never silently
|
|
2095
|
+
// suppress via a filter crash, and a throw here would abort the whole
|
|
2096
|
+
// file's batch.
|
|
2097
|
+
try {
|
|
2098
|
+
const interpolationNode = captures.INTERPOLATION;
|
|
2099
|
+
const interpolation = interpolationNode?.text ?? "";
|
|
2100
|
+
const identifier = interpolation.match(/^\$\{\s*([A-Za-z_$][\w$]*)\s*\}$/)?.[1];
|
|
2101
|
+
if (!identifier || !rootNode || !interpolationNode)
|
|
2102
|
+
return true;
|
|
2103
|
+
const useRow = interpolationNode.startPosition.row;
|
|
2104
|
+
const hasEscapeSignal = (text) => /\b(?:escape|Escape)\w*\s*\(/.test(text) ||
|
|
2105
|
+
/\.\s*replace\s*\(\s*\//.test(text);
|
|
2106
|
+
// Track the last write to the identifier before the use site.
|
|
2107
|
+
let lastWriteRow = -1;
|
|
2108
|
+
let lastWriteSafe = false;
|
|
2109
|
+
const consider = (row, valueText) => {
|
|
2110
|
+
if (row >= useRow || row < lastWriteRow)
|
|
2111
|
+
return;
|
|
2112
|
+
lastWriteRow = row;
|
|
2113
|
+
lastWriteSafe =
|
|
2114
|
+
valueText !== undefined && hasEscapeSignal(valueText);
|
|
2115
|
+
};
|
|
2116
|
+
const stack = [rootNode];
|
|
2117
|
+
while (stack.length > 0) {
|
|
2118
|
+
const node = stack.pop();
|
|
2119
|
+
if (!node)
|
|
2120
|
+
continue;
|
|
2121
|
+
if (node.type === "variable_declarator") {
|
|
2122
|
+
const name = node.childForFieldName?.("name");
|
|
2123
|
+
if (name?.text === identifier) {
|
|
2124
|
+
const initializer = node.childForFieldName?.("value");
|
|
2125
|
+
consider(node.startPosition.row, initializer?.text);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
else if (node.type === "assignment_expression") {
|
|
2129
|
+
const left = node.childForFieldName?.("left");
|
|
2130
|
+
if (left?.type === "identifier" && left.text === identifier) {
|
|
2131
|
+
const right = node.childForFieldName?.("right");
|
|
2132
|
+
consider(node.startPosition.row, right?.text);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
stack.push(...node.children);
|
|
2136
|
+
}
|
|
2137
|
+
return lastWriteRow >= 0 && lastWriteSafe ? false : true;
|
|
2138
|
+
}
|
|
2139
|
+
catch {
|
|
2140
|
+
return true;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
763
2143
|
case "is_generator_with_valued_return": {
|
|
764
2144
|
const returnNode = captures.RETURN;
|
|
765
2145
|
const functionNode = captures.FUNCTION ??
|
|
@@ -809,23 +2189,162 @@ export class TreeSitterClient {
|
|
|
809
2189
|
c.type !== "block_comment");
|
|
810
2190
|
return meaningful.length === 0;
|
|
811
2191
|
}
|
|
2192
|
+
case "is_empty_block": {
|
|
2193
|
+
// empty-switch-case: keep a `switch_case` that has no body statements.
|
|
2194
|
+
// A switch_case's named children are `[value, ...body statements]`
|
|
2195
|
+
// (the case's statements are direct children, not a statement_block),
|
|
2196
|
+
// so an empty case is one whose only named child is its `case <value>`.
|
|
2197
|
+
// An empty case followed by another label is a fall-through group, not
|
|
2198
|
+
// a dead case — only the last label of a group carries the body.
|
|
2199
|
+
const caseNode = captures.CASE;
|
|
2200
|
+
if (!caseNode)
|
|
2201
|
+
return false;
|
|
2202
|
+
if (this.switchCaseBodyStatements(caseNode).length > 0)
|
|
2203
|
+
return false;
|
|
2204
|
+
return !this.nextSiblingIsSwitchLabel(caseNode);
|
|
2205
|
+
}
|
|
2206
|
+
case "no_break_or_return_in_body": {
|
|
2207
|
+
// infinite-loop: keep a `while(true)`/`for(;;)` whose body contains no
|
|
2208
|
+
// statement that can terminate the loop (break/return/throw). `continue`
|
|
2209
|
+
// does not count — it keeps the loop running.
|
|
2210
|
+
const bodyNode = captures.BODY;
|
|
2211
|
+
if (!bodyNode)
|
|
2212
|
+
return false;
|
|
2213
|
+
return !this.bodyHasLoopExit(bodyNode, false);
|
|
2214
|
+
}
|
|
2215
|
+
case "same_param_name": {
|
|
2216
|
+
// duplicate-function-arg: keep the pair when the captured binding
|
|
2217
|
+
// patterns share any name.
|
|
2218
|
+
const first = this.bindingNames(captures.PARAM1);
|
|
2219
|
+
const second = this.bindingNames(captures.NAME);
|
|
2220
|
+
return [...first].some((name) => second.has(name));
|
|
2221
|
+
}
|
|
2222
|
+
case "no_terminating_statement": {
|
|
2223
|
+
// switch-case-termination: keep a non-empty case (already known to be
|
|
2224
|
+
// followed by another case via the query) whose last body statement
|
|
2225
|
+
// does not terminate, i.e. it falls through. Empty cases are handled
|
|
2226
|
+
// by empty-switch-case, so require at least one body statement here.
|
|
2227
|
+
// An intentional `// fallthrough` marker suppresses the finding.
|
|
2228
|
+
const caseNode = captures.CASE;
|
|
2229
|
+
if (!caseNode)
|
|
2230
|
+
return false;
|
|
2231
|
+
if (this.hasFallthroughMarker(caseNode))
|
|
2232
|
+
return false;
|
|
2233
|
+
const body = this.switchCaseBodyStatements(caseNode);
|
|
2234
|
+
if (body.length === 0)
|
|
2235
|
+
return false;
|
|
2236
|
+
return !this.statementTerminates(body[body.length - 1]);
|
|
2237
|
+
}
|
|
2238
|
+
case "no_break_or_return": {
|
|
2239
|
+
// infinite-loop-java: keep a `while(true)`/`for(;;)` whose body has no
|
|
2240
|
+
// break/return/throw reachable in this loop's scope.
|
|
2241
|
+
const bodyNode = captures.BODY;
|
|
2242
|
+
if (!bodyNode)
|
|
2243
|
+
return false;
|
|
2244
|
+
return !this.bodyHasLoopExit(bodyNode, false);
|
|
2245
|
+
}
|
|
2246
|
+
case "is_double_checked_locking": {
|
|
2247
|
+
// no-double-checked-locking: the query pins the if→synchronized→if
|
|
2248
|
+
// shape; keep it only when both null-checks test the same field.
|
|
2249
|
+
const outer = captures.FIELD?.text;
|
|
2250
|
+
const inner = captures.FIELD2?.text;
|
|
2251
|
+
return !!outer && outer === inner;
|
|
2252
|
+
}
|
|
2253
|
+
case "shadows_parent_field": {
|
|
2254
|
+
// no-field-shadowing: keep only when the named parent class is
|
|
2255
|
+
// declared in the same file and it declares a field with the same
|
|
2256
|
+
// name. Cross-file inheritance can't be resolved here — fail closed.
|
|
2257
|
+
const parentName = captures.PARENT?.text;
|
|
2258
|
+
const fieldName = captures.NAME?.text;
|
|
2259
|
+
if (!parentName || !fieldName)
|
|
2260
|
+
return false;
|
|
2261
|
+
// biome-ignore lint/suspicious/noExplicitAny: AST traversal
|
|
2262
|
+
let root = captures.NAME;
|
|
2263
|
+
while (root.parent)
|
|
2264
|
+
root = root.parent;
|
|
2265
|
+
// biome-ignore lint/suspicious/noExplicitAny: AST traversal
|
|
2266
|
+
const stack = [root];
|
|
2267
|
+
while (stack.length) {
|
|
2268
|
+
const node = stack.pop();
|
|
2269
|
+
if (node.type === "class_declaration" &&
|
|
2270
|
+
node.childForFieldName?.("name")?.text === parentName) {
|
|
2271
|
+
const body = node.childForFieldName?.("body");
|
|
2272
|
+
for (const member of body?.children ?? []) {
|
|
2273
|
+
if (member.type !== "field_declaration")
|
|
2274
|
+
continue;
|
|
2275
|
+
for (const declarator of member.children ?? []) {
|
|
2276
|
+
if (declarator.type === "variable_declarator" &&
|
|
2277
|
+
declarator.childForFieldName?.("name")?.text === fieldName) {
|
|
2278
|
+
return true;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
for (const child of node.children ?? [])
|
|
2284
|
+
stack.push(child);
|
|
2285
|
+
}
|
|
2286
|
+
return false;
|
|
2287
|
+
}
|
|
2288
|
+
case "missing_break_between_cases": {
|
|
2289
|
+
// switch-fall-through: keep when the labeled statement group contains
|
|
2290
|
+
// no terminating statement at all — its control flow reaches the next
|
|
2291
|
+
// case. The query already requires a following group.
|
|
2292
|
+
const label = captures.LABEL;
|
|
2293
|
+
const group = label?.parent;
|
|
2294
|
+
if (!group)
|
|
2295
|
+
return false;
|
|
2296
|
+
const TERMINATORS = new Set([
|
|
2297
|
+
"break_statement",
|
|
2298
|
+
"return_statement",
|
|
2299
|
+
"throw_statement",
|
|
2300
|
+
"continue_statement",
|
|
2301
|
+
"yield_statement",
|
|
2302
|
+
]);
|
|
2303
|
+
for (const child of group.children ?? []) {
|
|
2304
|
+
if (TERMINATORS.has(child.type))
|
|
2305
|
+
return false;
|
|
2306
|
+
}
|
|
2307
|
+
return true;
|
|
2308
|
+
}
|
|
2309
|
+
case "scoped_lock_empty_args": {
|
|
2310
|
+
// no-scoped-lock-without-args: the query only matches a bare
|
|
2311
|
+
// `declarator: (identifier)` — an arg-taking declaration parses as an
|
|
2312
|
+
// init/function declarator instead — so a match IS the defect. Keep it
|
|
2313
|
+
// as long as the captured declarator has no sibling argument list.
|
|
2314
|
+
const decl = captures.DECL;
|
|
2315
|
+
if (!decl)
|
|
2316
|
+
return false;
|
|
2317
|
+
for (const sibling of decl.parent?.children ?? []) {
|
|
2318
|
+
if (sibling.type === "argument_list")
|
|
2319
|
+
return false;
|
|
2320
|
+
}
|
|
2321
|
+
return true;
|
|
2322
|
+
}
|
|
2323
|
+
case "calc_missing_spaces": {
|
|
2324
|
+
// calc-spacing: keep a calc() whose +/- has an operand directly on
|
|
2325
|
+
// both sides (e.g. `100%-20px`). A leading sign after `(` or a comma
|
|
2326
|
+
// is legitimate and stays allowed.
|
|
2327
|
+
const text = captures.EXPR?.text ?? "";
|
|
2328
|
+
return /[\w%)][+-][\w.(]/.test(text);
|
|
2329
|
+
}
|
|
812
2330
|
case "bare_except_only": {
|
|
813
2331
|
const clauseNode = captures.CLAUSE;
|
|
814
2332
|
if (!clauseNode)
|
|
815
2333
|
return true;
|
|
816
2334
|
// A typed `except` clause has a named child for the exception
|
|
817
2335
|
// spec — one of: identifier (e.g. `except ValueError`),
|
|
818
|
-
//
|
|
2336
|
+
// attribute (e.g. `except asyncio.TimeoutError` — dotted name),
|
|
2337
|
+
// tuple (e.g. `except (E, F)`), as_pattern (e.g. `except E as e`),
|
|
2338
|
+
// parenthesized_expression (e.g. `except (E)`), or subscript
|
|
2339
|
+
// (e.g. `except dict[str, int]` — #1244, mirrors the ast-grep
|
|
2340
|
+
// rule's shape space).
|
|
819
2341
|
// Bare `except:` has NO named children (just the `except` keyword,
|
|
820
2342
|
// the `:` colon, and the body block).
|
|
821
2343
|
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
|
|
822
2344
|
const hasExceptionSpec = clauseNode.children.some((c) => {
|
|
823
2345
|
if (!c.isNamed)
|
|
824
2346
|
return false;
|
|
825
|
-
return (c.type
|
|
826
|
-
c.type === "tuple" ||
|
|
827
|
-
c.type === "as_pattern" ||
|
|
828
|
-
c.type === "parenthesized_expression");
|
|
2347
|
+
return (c.type !== "block");
|
|
829
2348
|
});
|
|
830
2349
|
// Fire ONLY when bare (no exception spec)
|
|
831
2350
|
return !hasExceptionSpec;
|
|
@@ -945,7 +2464,7 @@ export class TreeSitterClient {
|
|
|
945
2464
|
// The %(name)s style: counts as 1 with name
|
|
946
2465
|
// The %% escape: doesn't count
|
|
947
2466
|
let placeholderCount = 0;
|
|
948
|
-
|
|
2467
|
+
const namedKeys = [];
|
|
949
2468
|
// biome-ignore lint/suspicious/noExplicitAny: regex match
|
|
950
2469
|
const positionalRegex = /%(?:\([^)]+\))?[#0\- +]*\d*(?:\.\d+)?[hlL]?[diouxXeEfFgGcrs%]/g;
|
|
951
2470
|
// biome-ignore lint/suspicious/noExplicitAny: regex match
|
|
@@ -1107,7 +2626,7 @@ export class TreeSitterClient {
|
|
|
1107
2626
|
]));
|
|
1108
2627
|
}
|
|
1109
2628
|
case "check_secret_pattern": {
|
|
1110
|
-
const varName =
|
|
2629
|
+
const varName = captures.VARNAME?.text ?? "";
|
|
1111
2630
|
const varNameLower = varName.toLowerCase();
|
|
1112
2631
|
// Skip UPPER_CASE constants — they're module-level constants
|
|
1113
2632
|
// (e.g. `GITHUB_TYPE_FOR_PERSONAL_API_KEY = "..."`), not secrets.
|
|
@@ -1156,6 +2675,67 @@ export class TreeSitterClient {
|
|
|
1156
2675
|
// biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
|
|
1157
2676
|
return !bodyNode.children.some((c) => c.isNamed && c.type !== "pass_statement" && c.type !== "comment");
|
|
1158
2677
|
}
|
|
2678
|
+
case "check_in_operator_types": {
|
|
2679
|
+
// `in`/`not in` require __contains__, __iter__ or __getitem__. We can't
|
|
2680
|
+
// do real type inference from a bare identifier, so only flag when the
|
|
2681
|
+
// right-hand side is a literal of a type known NOT to support
|
|
2682
|
+
// containment (None, bool, int, float) — identifiers, strings, lists,
|
|
2683
|
+
// dicts, sets, tuples etc. are left alone to avoid false positives.
|
|
2684
|
+
const target = captures.TARGET;
|
|
2685
|
+
if (!target)
|
|
2686
|
+
return false;
|
|
2687
|
+
return ["none", "true", "false", "integer", "float"].includes(target.type);
|
|
2688
|
+
}
|
|
2689
|
+
case "torchscript_super_call": {
|
|
2690
|
+
// The query only anchors on the `super()` call itself (so it can find
|
|
2691
|
+
// it at any nesting depth inside the method body); this filter walks
|
|
2692
|
+
// back up to confirm the enclosing method OR its class actually carries
|
|
2693
|
+
// a @torch.jit.script / @jit.script decorator.
|
|
2694
|
+
const call = captures.CALL;
|
|
2695
|
+
if (!call)
|
|
2696
|
+
return false;
|
|
2697
|
+
const isTorchScriptDecorated = (node) => {
|
|
2698
|
+
const decorated = node?.parent;
|
|
2699
|
+
if (!decorated || decorated.type !== "decorated_definition")
|
|
2700
|
+
return false;
|
|
2701
|
+
// biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
|
|
2702
|
+
return (decorated.children ?? []).some((c) => c.type === "decorator" &&
|
|
2703
|
+
/^@(torch\.jit\.script|jit\.script)$/.test(c.text ?? ""));
|
|
2704
|
+
};
|
|
2705
|
+
const methodNode = this.navigator.findParent(call, [
|
|
2706
|
+
"function_definition",
|
|
2707
|
+
]);
|
|
2708
|
+
if (!methodNode)
|
|
2709
|
+
return false;
|
|
2710
|
+
const classNode = this.navigator.findParent(methodNode, [
|
|
2711
|
+
"class_definition",
|
|
2712
|
+
]);
|
|
2713
|
+
return (isTorchScriptDecorated(methodNode) ||
|
|
2714
|
+
isTorchScriptDecorated(classNode));
|
|
2715
|
+
}
|
|
2716
|
+
case "exit_params_insufficient": {
|
|
2717
|
+
// __exit__ must accept (self, exc_type, exc_value, traceback) — 4 named
|
|
2718
|
+
// parameters total. The query captures the whole `parameters` node
|
|
2719
|
+
// rather than binding each slot individually: tree-sitter's optional
|
|
2720
|
+
// (`?`) quantifiers on consecutive anchored siblings match every valid
|
|
2721
|
+
// sub-alignment (e.g. self+exc_type alone, or self+exc_type+exc_value),
|
|
2722
|
+
// not just the maximal one, so per-slot captures produce spurious
|
|
2723
|
+
// duplicate/partial matches for a single, fully-correct signature.
|
|
2724
|
+
// Counting named children of the whole node sidesteps that entirely.
|
|
2725
|
+
const params = captures.PARAMS;
|
|
2726
|
+
if (!params)
|
|
2727
|
+
return true;
|
|
2728
|
+
// biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
|
|
2729
|
+
const named = (params.children ?? []).filter((c) => c.isNamed);
|
|
2730
|
+
// `def __exit__(self, *args)` / `(self, *exc_info)` are valid: the splat
|
|
2731
|
+
// absorbs the whole exception triple, so it satisfies every remaining
|
|
2732
|
+
// slot no matter how few named children the node has.
|
|
2733
|
+
if (named.some((c) => c.type === "list_splat_pattern" ||
|
|
2734
|
+
c.type === "dictionary_splat_pattern")) {
|
|
2735
|
+
return false;
|
|
2736
|
+
}
|
|
2737
|
+
return named.length < 4;
|
|
2738
|
+
}
|
|
1159
2739
|
case "ruby_empty_rescue": {
|
|
1160
2740
|
const bodyNode = captures.BODY;
|
|
1161
2741
|
if (!bodyNode)
|
|
@@ -1181,6 +2761,33 @@ export class TreeSitterClient {
|
|
|
1181
2761
|
]);
|
|
1182
2762
|
if (!allowedFns.has(fn))
|
|
1183
2763
|
return false;
|
|
2764
|
+
// A bare identifier that provably resolves (in this file) to a
|
|
2765
|
+
// `const` initialized with a fixed string/template literal is a
|
|
2766
|
+
// genuinely fixed URL — exempt it regardless of naming
|
|
2767
|
+
// convention (SCREAMING_SNAKE_CASE proves nothing about
|
|
2768
|
+
// provenance; see #963). Anything we cannot definitively prove
|
|
2769
|
+
// falls through to the existing broad heuristic below, so
|
|
2770
|
+
// member expressions, non-literal consts, reassigned
|
|
2771
|
+
// bindings, and unresolved identifiers all keep being
|
|
2772
|
+
// evaluated for taint as before — a false positive here is
|
|
2773
|
+
// acceptable, a missed SSRF is not.
|
|
2774
|
+
if (rootNode &&
|
|
2775
|
+
captures.URL?.type === "identifier" &&
|
|
2776
|
+
this.resolvesToFileLiteralConst(urlText, rootNode)) {
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
// `fetch(u.toString())` / `fetch(u.href)` where `u` is a file-local
|
|
2780
|
+
// `const u = new URL(<literalPath>, <fixedBase>)`. The origin+path are
|
|
2781
|
+
// fully fixed; dynamic query params set later via
|
|
2782
|
+
// `u.searchParams.set(...)` don't control the destination. Anything we
|
|
2783
|
+
// cannot prove fixed (param/env/member/tainted base) falls through to
|
|
2784
|
+
// the broad heuristic and still fires. See #1000.
|
|
2785
|
+
if (rootNode && captures.URL) {
|
|
2786
|
+
const urlVar = this.newUrlBaseVarName(captures.URL);
|
|
2787
|
+
if (urlVar && this.resolvesToFixedNewUrlConst(urlVar, rootNode)) {
|
|
2788
|
+
return false;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
1184
2791
|
// Only flag when the URL argument looks like it could carry external
|
|
1185
2792
|
// input: member expressions (req.url, ctx.query.x) or identifiers
|
|
1186
2793
|
// whose names suggest user/external provenance. Plain generic names
|
|
@@ -1208,8 +2815,10 @@ export class TreeSitterClient {
|
|
|
1208
2815
|
case "ts_insecure_random_source": {
|
|
1209
2816
|
if (captures.OBJ?.text !== "Math" || captures.FN?.text !== "random")
|
|
1210
2817
|
return false;
|
|
1211
|
-
// Only flag when
|
|
1212
|
-
|
|
2818
|
+
// Only flag when the result flows into a security-sensitive binding.
|
|
2819
|
+
// Walk up from the `Math.random()` call so chained forms such as
|
|
2820
|
+
// `Math.random().toString(36)` are still attributed to their binding.
|
|
2821
|
+
const varName = this.enclosingBindingName(captures.CALL ?? captures.VAR);
|
|
1213
2822
|
return /token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin/i.test(varName);
|
|
1214
2823
|
}
|
|
1215
2824
|
case "ts_detached_async_call":
|
|
@@ -1489,9 +3098,42 @@ export class TreeSitterClient {
|
|
|
1489
3098
|
return false;
|
|
1490
3099
|
}
|
|
1491
3100
|
default:
|
|
1492
|
-
|
|
3101
|
+
// A rule whose post_filter has no implementation cannot honour its
|
|
3102
|
+
// own definition, so DROP the match instead of reporting every raw
|
|
3103
|
+
// structural hit. Failing open here made `duplicate-function-arg`
|
|
3104
|
+
// (`same_param_name`, unimplemented) fire on 59 of 60 files that
|
|
3105
|
+
// contain no duplicate parameter at all.
|
|
3106
|
+
this.reportMissingPostFilter(postFilter);
|
|
3107
|
+
return false;
|
|
1493
3108
|
}
|
|
1494
3109
|
}
|
|
3110
|
+
reportedMissingPostFilters = new Set();
|
|
3111
|
+
reportedPostFilterFailures = new Set();
|
|
3112
|
+
/** Keep a failed post-filter match and leave a bounded diagnostic trail. */
|
|
3113
|
+
reportPostFilterFailure(postFilter, reason) {
|
|
3114
|
+
if (this.reportedPostFilterFailures.has(postFilter))
|
|
3115
|
+
return;
|
|
3116
|
+
this.reportedPostFilterFailures.add(postFilter);
|
|
3117
|
+
logTreeSitterDiagnostic({
|
|
3118
|
+
subsystem: "tree-sitter-client",
|
|
3119
|
+
message: `tree-sitter rule post_filter '${postFilter}' failed — ` +
|
|
3120
|
+
`keeping the diagnostic (fail-open): ${reason}.`,
|
|
3121
|
+
metadata: { postFilter },
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
/** Warn once per unimplemented post_filter — a silent rule needs a trail. */
|
|
3125
|
+
reportMissingPostFilter(postFilter) {
|
|
3126
|
+
if (this.reportedMissingPostFilters.has(postFilter))
|
|
3127
|
+
return;
|
|
3128
|
+
this.reportedMissingPostFilters.add(postFilter);
|
|
3129
|
+
logTreeSitterDiagnostic({
|
|
3130
|
+
subsystem: "tree-sitter-client",
|
|
3131
|
+
message: `tree-sitter rule post_filter '${postFilter}' is not implemented — ` +
|
|
3132
|
+
`matches for the rules using it are suppressed rather than reported unfiltered. ` +
|
|
3133
|
+
`Implement it in applyPostFilter (clients/tree-sitter-client.ts) to re-enable them.`,
|
|
3134
|
+
metadata: { postFilter },
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
1495
3137
|
/**
|
|
1496
3138
|
* Evaluate text predicates (#match?, #eq?) for a query match.
|
|
1497
3139
|
* web-tree-sitter stores these as compiled functions in query.textPredicates[patternIndex]
|
|
@@ -1508,50 +3150,50 @@ export class TreeSitterClient {
|
|
|
1508
3150
|
query, metavars, languageId, _originalPattern, postFilter,
|
|
1509
3151
|
// biome-ignore lint/suspicious/noExplicitAny: Post filter params
|
|
1510
3152
|
postFilterParams, contentOverride) {
|
|
1511
|
-
const tree = await this.parseFile(filePath, languageId, contentOverride);
|
|
1512
|
-
if (!tree)
|
|
1513
|
-
return [];
|
|
1514
3153
|
const matches = [];
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
3154
|
+
await this.parseFileAndUse(filePath, languageId, contentOverride, (tree) => {
|
|
3155
|
+
try {
|
|
3156
|
+
const queryMatches = query.matches(tree.rootNode);
|
|
3157
|
+
for (const match of queryMatches) {
|
|
3158
|
+
const captures = {};
|
|
3159
|
+
for (const capture of match.captures) {
|
|
3160
|
+
if (metavars.includes(capture.name)) {
|
|
3161
|
+
captures[capture.name] = capture.node;
|
|
3162
|
+
}
|
|
1522
3163
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
3164
|
+
// Evaluate #match? and #eq? predicates that web-tree-sitter doesn't enforce automatically
|
|
3165
|
+
if (!this.evaluatePredicates(query, match)) {
|
|
3166
|
+
continue;
|
|
3167
|
+
}
|
|
3168
|
+
if (postFilter &&
|
|
3169
|
+
!this.applyPostFilter(postFilter, postFilterParams, captures, tree.rootNode)) {
|
|
3170
|
+
continue;
|
|
3171
|
+
}
|
|
3172
|
+
if (match.captures.length > 0) {
|
|
3173
|
+
const firstNode = match.captures[0].node;
|
|
3174
|
+
const textCaptures = {};
|
|
3175
|
+
for (const [name, node] of Object.entries(captures)) {
|
|
3176
|
+
textCaptures[name] = node.text;
|
|
3177
|
+
}
|
|
3178
|
+
matches.push({
|
|
3179
|
+
file: filePath,
|
|
3180
|
+
line: firstNode.startPosition.row + 1,
|
|
3181
|
+
column: firstNode.startPosition.column + 1,
|
|
3182
|
+
matchedText: firstNode.text,
|
|
3183
|
+
nodeType: firstNode.type,
|
|
3184
|
+
captures: textCaptures,
|
|
3185
|
+
});
|
|
1537
3186
|
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
column: firstNode.startPosition.column + 1,
|
|
1542
|
-
matchedText: firstNode.text,
|
|
1543
|
-
nodeType: firstNode.type,
|
|
1544
|
-
captures: textCaptures,
|
|
1545
|
-
});
|
|
3187
|
+
}
|
|
3188
|
+
if (matches.length > 0) {
|
|
3189
|
+
this.dbg(`Found ${matches.length} matches in ${path.basename(filePath)}`);
|
|
1546
3190
|
}
|
|
1547
3191
|
}
|
|
1548
|
-
|
|
1549
|
-
this.
|
|
3192
|
+
catch (err) {
|
|
3193
|
+
this.reportWasmAbort(err);
|
|
3194
|
+
this.dbg(`Query matching error: ${err}`);
|
|
1550
3195
|
}
|
|
1551
|
-
}
|
|
1552
|
-
catch (err) {
|
|
1553
|
-
this.dbg(`Query matching error: ${err}`);
|
|
1554
|
-
}
|
|
3196
|
+
});
|
|
1555
3197
|
return matches;
|
|
1556
3198
|
}
|
|
1557
3199
|
/** Collect source files for a language */
|