cgraphx 2.0.2 → 2.0.3
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/dist/core/code/engine/cli/analyze-config.js +320 -0
- package/dist/core/code/engine/cli/analyze.js +1086 -0
- package/dist/core/code/engine/cli/cli-message.js +83 -0
- package/dist/core/code/engine/cli/detect-changes-format.js +58 -0
- package/dist/core/code/engine/cli/embedding-dims.js +43 -0
- package/dist/core/code/engine/cli/format-elapsed.js +12 -0
- package/dist/core/code/engine/cli/help-i18n.js +144 -0
- package/dist/core/code/engine/cli/i18n/en.js +110 -0
- package/dist/core/code/engine/cli/i18n/index.js +47 -0
- package/dist/core/code/engine/cli/i18n/resources.js +9 -0
- package/dist/core/code/engine/cli/i18n/zh-CN.js +110 -0
- package/dist/core/code/engine/cli/lazy-action.js +67 -0
- package/dist/core/code/engine/cli/optional-grammars.js +136 -0
- package/dist/core/code/engine/cli/resolve-invocation.js +76 -0
- package/dist/core/code/engine/cli/status.js +156 -0
- package/dist/core/code/engine/cli/tool.js +384 -0
- package/dist/core/code/engine/config/ignore-service.js +511 -0
- package/dist/core/code/engine/config/supported-languages.js +17 -0
- package/dist/core/code/engine/core/analysis-features.js +64 -0
- package/dist/core/code/engine/core/analyzer-identity.js +2171 -0
- package/dist/core/code/engine/core/git-staleness.js +180 -0
- package/dist/core/code/engine/core/graph/graph.js +180 -0
- package/dist/core/code/engine/core/graph/import-cycles.js +106 -0
- package/dist/core/code/engine/core/graph/types.js +2 -0
- package/dist/core/code/engine/core/index-freshness.js +12 -0
- package/dist/core/code/engine/core/ingestion/binding-accumulator.js +341 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/c-cpp.js +168 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/csharp.js +9 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/dart.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/go.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/jvm.js +54 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/php.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/python.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/ruby.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/rust.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/swift.js +8 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/configs/typescript-javascript.js +11 -0
- package/dist/core/code/engine/core/ingestion/call-extractors/generic.js +62 -0
- package/dist/core/code/engine/core/ingestion/call-processor.js +503 -0
- package/dist/core/code/engine/core/ingestion/call-routing.js +98 -0
- package/dist/core/code/engine/core/ingestion/call-types.js +3 -0
- package/dist/core/code/engine/core/ingestion/cfg/callee-cell-format.js +45 -0
- package/dist/core/code/engine/core/ingestion/cfg/cfg-builder.js +202 -0
- package/dist/core/code/engine/core/ingestion/cfg/collect.js +81 -0
- package/dist/core/code/engine/core/ingestion/cfg/control-dependence.js +185 -0
- package/dist/core/code/engine/core/ingestion/cfg/control-flow-context.js +130 -0
- package/dist/core/code/engine/core/ingestion/cfg/emit.js +646 -0
- package/dist/core/code/engine/core/ingestion/cfg/post-dominators.js +182 -0
- package/dist/core/code/engine/core/ingestion/cfg/reaching-def-reason-codec.js +139 -0
- package/dist/core/code/engine/core/ingestion/cfg/reaching-defs-graph.js +322 -0
- package/dist/core/code/engine/core/ingestion/cfg/reaching-defs.js +792 -0
- package/dist/core/code/engine/core/ingestion/cfg/synthetic-escape.js +305 -0
- package/dist/core/code/engine/core/ingestion/cfg/traversal-result.js +6 -0
- package/dist/core/code/engine/core/ingestion/cfg/types.js +14 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/c-cpp-harvest.js +545 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/c-cpp.js +590 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/call-site-harvest.js +356 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/csharp-harvest.js +593 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/csharp.js +871 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/dart-harvest.js +874 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/dart.js +840 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/go-harvest.js +625 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/go.js +642 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/java-harvest.js +517 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/java.js +816 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/kotlin-harvest.js +723 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/kotlin.js +813 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/php-harvest.js +630 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/php.js +725 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/python-harvest.js +776 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/python.js +562 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/ruby-harvest.js +591 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/ruby.js +760 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/rust-harvest.js +877 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/rust.js +562 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/scope-tree-harvest.js +120 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/swift-harvest.js +683 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/swift.js +791 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/typescript-harvest.js +1060 -0
- package/dist/core/code/engine/core/ingestion/cfg/visitors/typescript.js +587 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/c-cpp.js +77 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/csharp.js +24 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/dart.js +10 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/go.js +28 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/jvm.js +67 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/php.js +10 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/python.js +10 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/ruby.js +13 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/rust.js +10 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/swift.js +21 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/configs/typescript-javascript.js +31 -0
- package/dist/core/code/engine/core/ingestion/class-extractors/generic.js +144 -0
- package/dist/core/code/engine/core/ingestion/class-types.js +2 -0
- package/dist/core/code/engine/core/ingestion/cluster-enricher.js +174 -0
- package/dist/core/code/engine/core/ingestion/community-processor.js +604 -0
- package/dist/core/code/engine/core/ingestion/constants.js +26 -0
- package/dist/core/code/engine/core/ingestion/cpp-ue-preprocessor.js +263 -0
- package/dist/core/code/engine/core/ingestion/csharp-namespace-gate.js +133 -0
- package/dist/core/code/engine/core/ingestion/di-extractors/index.js +38 -0
- package/dist/core/code/engine/core/ingestion/di-extractors/spring.js +310 -0
- package/dist/core/code/engine/core/ingestion/emit-references.js +244 -0
- package/dist/core/code/engine/core/ingestion/entry-point-scoring.js +201 -0
- package/dist/core/code/engine/core/ingestion/export-detection.js +244 -0
- package/dist/core/code/engine/core/ingestion/field-extractor.js +29 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/c-cpp.js +107 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/csharp.js +124 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/dart.js +99 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/go.js +102 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/helpers.js +198 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/jvm.js +172 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/php.js +67 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/python.js +94 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/ruby.js +79 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/rust.js +55 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/swift.js +93 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/configs/typescript-javascript.js +59 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/generic.js +147 -0
- package/dist/core/code/engine/core/ingestion/field-extractors/typescript.js +266 -0
- package/dist/core/code/engine/core/ingestion/field-types.js +3 -0
- package/dist/core/code/engine/core/ingestion/filesystem-walker.js +136 -0
- package/dist/core/code/engine/core/ingestion/finalize-orchestrator.js +159 -0
- package/dist/core/code/engine/core/ingestion/framework-detection.js +432 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/analysis-features.js +38 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/annotation-arguments.js +234 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/aop-candidates.js +88 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/aop.js +487 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/auto-configuration.js +21 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/bean-candidates.js +189 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/bean-catalog.js +32 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/bean-factories.js +73 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/conditionals.js +323 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/config-bindings.js +119 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/di-metadata.js +385 -0
- package/dist/core/code/engine/core/ingestion/frameworks/spring/resource-injection.js +96 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/c-cpp.js +17 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/csharp.js +46 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/dart.js +59 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/go.js +30 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/jvm.js +73 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/php.js +19 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/python.js +45 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/ruby.js +20 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/rust.js +58 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/swift.js +94 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/configs/typescript-javascript.js +26 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/csharp.js +128 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/go.js +50 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/jvm.js +112 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/php.js +80 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/python.js +75 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/resolver-factory.js +36 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/ruby.js +20 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/rust.js +79 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/standard.js +180 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/types.js +7 -0
- package/dist/core/code/engine/core/ingestion/import-resolvers/utils.js +153 -0
- package/dist/core/code/engine/core/ingestion/import-target-adapter.js +99 -0
- package/dist/core/code/engine/core/ingestion/language-config.js +391 -0
- package/dist/core/code/engine/core/ingestion/language-provider.js +25 -0
- package/dist/core/code/engine/core/ingestion/languages/c/arity-metadata.js +98 -0
- package/dist/core/code/engine/core/ingestion/languages/c/arity.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/c/capture-side-channel.js +69 -0
- package/dist/core/code/engine/core/ingestion/languages/c/captures.js +189 -0
- package/dist/core/code/engine/core/ingestion/languages/c/header-scan.js +58 -0
- package/dist/core/code/engine/core/ingestion/languages/c/import-decomposer.js +68 -0
- package/dist/core/code/engine/core/ingestion/languages/c/import-target.js +103 -0
- package/dist/core/code/engine/core/ingestion/languages/c/index.js +33 -0
- package/dist/core/code/engine/core/ingestion/languages/c/interpret.js +53 -0
- package/dist/core/code/engine/core/ingestion/languages/c/merge-bindings.js +26 -0
- package/dist/core/code/engine/core/ingestion/languages/c/query.js +210 -0
- package/dist/core/code/engine/core/ingestion/languages/c/scope-resolver.js +112 -0
- package/dist/core/code/engine/core/ingestion/languages/c/simple-hooks.js +24 -0
- package/dist/core/code/engine/core/ingestion/languages/c/static-linkage.js +109 -0
- package/dist/core/code/engine/core/ingestion/languages/c-cpp.js +506 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/adl.js +804 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/arity-metadata.js +258 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/arity.js +37 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/capture-side-channel.js +89 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/captures.js +1975 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/constraint-extractor.js +311 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/constraint-filter.js +210 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/conversion-rank.js +163 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/file-local-linkage.js +327 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/header-scan.js +53 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/import-decomposer.js +134 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/import-target.js +16 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/index.js +33 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/inline-namespaces.js +379 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/interpret.js +239 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/member-lookup.js +470 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/merge-bindings.js +32 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/query.js +763 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/range-bindings.js +230 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/scope-resolver.js +354 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/simple-hooks.js +67 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/two-phase-lookup.js +348 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/type-classifier.js +56 -0
- package/dist/core/code/engine/core/ingestion/languages/cpp/user-defined-conversions.js +128 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/accessor-unwrap.js +67 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/arity-metadata.js +49 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/arity.js +40 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/cache-stats.js +32 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/captures.js +557 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/import-decomposer.js +96 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/import-target.js +176 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/index.js +95 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/interpret.js +150 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/merge-bindings.js +58 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/namespace-siblings.js +708 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/qualified-type-names.js +62 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/query.js +578 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/receiver-binding.js +142 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/resolution-config.js +18 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/scope-resolver.js +84 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp/simple-hooks.js +81 -0
- package/dist/core/code/engine/core/ingestion/languages/csharp.js +204 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/arity-metadata.js +38 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/arity.js +34 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/built-ins.js +37 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/cache-stats.js +30 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/captures.js +1096 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/expand-wildcards.js +34 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/extension-type-preprocess.js +33 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/import-target.js +68 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/index.js +45 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/interpret.js +101 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/merge-bindings.js +42 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/query.js +246 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/receiver-binding.js +90 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/scope-resolver.js +197 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/signature-bindings.js +54 -0
- package/dist/core/code/engine/core/ingestion/languages/dart/simple-hooks.js +61 -0
- package/dist/core/code/engine/core/ingestion/languages/dart.js +138 -0
- package/dist/core/code/engine/core/ingestion/languages/go/arity-metadata.js +71 -0
- package/dist/core/code/engine/core/ingestion/languages/go/arity.js +17 -0
- package/dist/core/code/engine/core/ingestion/languages/go/cache-stats.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/go/captures.js +492 -0
- package/dist/core/code/engine/core/ingestion/languages/go/expand-wildcards.js +97 -0
- package/dist/core/code/engine/core/ingestion/languages/go/generic-type-parameters.js +146 -0
- package/dist/core/code/engine/core/ingestion/languages/go/import-decomposer.js +47 -0
- package/dist/core/code/engine/core/ingestion/languages/go/import-target.js +70 -0
- package/dist/core/code/engine/core/ingestion/languages/go/index.js +39 -0
- package/dist/core/code/engine/core/ingestion/languages/go/interface-impls.js +955 -0
- package/dist/core/code/engine/core/ingestion/languages/go/interpret.js +177 -0
- package/dist/core/code/engine/core/ingestion/languages/go/merge-bindings.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/go/method-owners.js +131 -0
- package/dist/core/code/engine/core/ingestion/languages/go/namespace-mirror.js +56 -0
- package/dist/core/code/engine/core/ingestion/languages/go/package-clause.js +79 -0
- package/dist/core/code/engine/core/ingestion/languages/go/package-siblings.js +83 -0
- package/dist/core/code/engine/core/ingestion/languages/go/query.js +298 -0
- package/dist/core/code/engine/core/ingestion/languages/go/range-binding.js +127 -0
- package/dist/core/code/engine/core/ingestion/languages/go/receiver-binding.js +24 -0
- package/dist/core/code/engine/core/ingestion/languages/go/scope-resolver.js +75 -0
- package/dist/core/code/engine/core/ingestion/languages/go/simple-hooks.js +31 -0
- package/dist/core/code/engine/core/ingestion/languages/go/type-binding.js +279 -0
- package/dist/core/code/engine/core/ingestion/languages/go.js +160 -0
- package/dist/core/code/engine/core/ingestion/languages/index.js +66 -0
- package/dist/core/code/engine/core/ingestion/languages/java/analysis-features.js +16 -0
- package/dist/core/code/engine/core/ingestion/languages/java/arity-metadata.js +43 -0
- package/dist/core/code/engine/core/ingestion/languages/java/arity.js +27 -0
- package/dist/core/code/engine/core/ingestion/languages/java/cache-stats.js +32 -0
- package/dist/core/code/engine/core/ingestion/languages/java/capture-side-channel.js +123 -0
- package/dist/core/code/engine/core/ingestion/languages/java/captures.js +791 -0
- package/dist/core/code/engine/core/ingestion/languages/java/import-decomposer.js +88 -0
- package/dist/core/code/engine/core/ingestion/languages/java/import-target.js +103 -0
- package/dist/core/code/engine/core/ingestion/languages/java/index.js +43 -0
- package/dist/core/code/engine/core/ingestion/languages/java/interpret.js +146 -0
- package/dist/core/code/engine/core/ingestion/languages/java/merge-bindings.js +43 -0
- package/dist/core/code/engine/core/ingestion/languages/java/package-facts.js +16 -0
- package/dist/core/code/engine/core/ingestion/languages/java/package-siblings.js +11 -0
- package/dist/core/code/engine/core/ingestion/languages/java/query.js +319 -0
- package/dist/core/code/engine/core/ingestion/languages/java/receiver-binding.js +98 -0
- package/dist/core/code/engine/core/ingestion/languages/java/scope-resolver.js +213 -0
- package/dist/core/code/engine/core/ingestion/languages/java/simple-hooks.js +39 -0
- package/dist/core/code/engine/core/ingestion/languages/java/spring-aop.js +53 -0
- package/dist/core/code/engine/core/ingestion/languages/java/spring-bean-metadata.js +11 -0
- package/dist/core/code/engine/core/ingestion/languages/java/spring-conditionals.js +52 -0
- package/dist/core/code/engine/core/ingestion/languages/java/spring-config-bindings.js +222 -0
- package/dist/core/code/engine/core/ingestion/languages/java/spring-di.js +165 -0
- package/dist/core/code/engine/core/ingestion/languages/java.js +192 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/arity.js +15 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/captures.js +1122 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/import-target.js +56 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/index.js +109 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/interpret.js +45 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/merge-bindings.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/query.js +659 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/scope-resolver.js +78 -0
- package/dist/core/code/engine/core/ingestion/languages/javascript/simple-hooks.js +44 -0
- package/dist/core/code/engine/core/ingestion/languages/jvm/package-facts.js +46 -0
- package/dist/core/code/engine/core/ingestion/languages/jvm/package-siblings.js +200 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/arity-metadata.js +23 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/arity.js +18 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/cache-stats.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/capture-side-channel.js +160 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/captures.js +1262 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/companion-scopes.js +72 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/import-decomposer.js +40 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/import-target.js +135 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/index.js +26 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/interpret.js +75 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/merge-bindings.js +28 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/owners.js +134 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/package-facts.js +16 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/package-siblings.js +11 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/query.js +243 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/receiver-binding.js +103 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/scope-resolver.js +207 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/simple-hooks.js +42 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/spring-aop.js +68 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/spring-bean-metadata.js +11 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/spring-conditionals.js +53 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin/spring-di.js +304 -0
- package/dist/core/code/engine/core/ingestion/languages/kotlin.js +189 -0
- package/dist/core/code/engine/core/ingestion/languages/php/arity-metadata.js +66 -0
- package/dist/core/code/engine/core/ingestion/languages/php/arity.js +43 -0
- package/dist/core/code/engine/core/ingestion/languages/php/cache-stats.js +32 -0
- package/dist/core/code/engine/core/ingestion/languages/php/captures.js +1166 -0
- package/dist/core/code/engine/core/ingestion/languages/php/import-decomposer.js +238 -0
- package/dist/core/code/engine/core/ingestion/languages/php/import-target.js +213 -0
- package/dist/core/code/engine/core/ingestion/languages/php/index.js +85 -0
- package/dist/core/code/engine/core/ingestion/languages/php/interpret.js +256 -0
- package/dist/core/code/engine/core/ingestion/languages/php/merge-bindings.js +50 -0
- package/dist/core/code/engine/core/ingestion/languages/php/namespace-siblings.js +353 -0
- package/dist/core/code/engine/core/ingestion/languages/php/query.js +391 -0
- package/dist/core/code/engine/core/ingestion/languages/php/receiver-binding.js +135 -0
- package/dist/core/code/engine/core/ingestion/languages/php/scope-resolver.js +361 -0
- package/dist/core/code/engine/core/ingestion/languages/php/simple-hooks.js +116 -0
- package/dist/core/code/engine/core/ingestion/languages/php.js +302 -0
- package/dist/core/code/engine/core/ingestion/languages/python/arity-metadata.js +49 -0
- package/dist/core/code/engine/core/ingestion/languages/python/arity.js +41 -0
- package/dist/core/code/engine/core/ingestion/languages/python/cache-stats.js +34 -0
- package/dist/core/code/engine/core/ingestion/languages/python/captures.js +299 -0
- package/dist/core/code/engine/core/ingestion/languages/python/depends-references.js +68 -0
- package/dist/core/code/engine/core/ingestion/languages/python/import-decomposer.js +115 -0
- package/dist/core/code/engine/core/ingestion/languages/python/import-target.js +440 -0
- package/dist/core/code/engine/core/ingestion/languages/python/index-stats.js +30 -0
- package/dist/core/code/engine/core/ingestion/languages/python/index.js +96 -0
- package/dist/core/code/engine/core/ingestion/languages/python/interpret.js +430 -0
- package/dist/core/code/engine/core/ingestion/languages/python/merge-bindings.js +47 -0
- package/dist/core/code/engine/core/ingestion/languages/python/query.js +323 -0
- package/dist/core/code/engine/core/ingestion/languages/python/receiver-binding.js +310 -0
- package/dist/core/code/engine/core/ingestion/languages/python/scope-resolver.js +80 -0
- package/dist/core/code/engine/core/ingestion/languages/python/simple-hooks.js +53 -0
- package/dist/core/code/engine/core/ingestion/languages/python.js +140 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/arity.js +41 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/cache-stats.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/captures.js +864 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/import-target.js +88 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/index.js +29 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/interpret.js +115 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/merge-bindings.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/query.js +349 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/receiver-binding.js +70 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/scope-resolver.js +263 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby/simple-hooks.js +68 -0
- package/dist/core/code/engine/core/ingestion/languages/ruby.js +215 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/arity.js +16 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/cache-stats.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/captures.js +304 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/import-decomposer.js +167 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/import-target.js +108 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/index.js +29 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/interpret.js +201 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/merge-bindings.js +21 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/method-owners.js +76 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/module-path.js +222 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/qualified-call.js +482 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/query.js +280 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/range-binding.js +687 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/receiver-binding.js +148 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/scope-resolver.js +151 -0
- package/dist/core/code/engine/core/ingestion/languages/rust/simple-hooks.js +32 -0
- package/dist/core/code/engine/core/ingestion/languages/rust.js +183 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/arity-metadata.js +44 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/arity.js +45 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/base-type.js +30 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/cache-stats.js +32 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/captures.js +594 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/conditional-directive-preprocess.js +256 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/implicit-imports.js +60 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/import-decomposer.js +87 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/import-target.js +84 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/index.js +56 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/interpret.js +93 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/merge-bindings.js +51 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/query.js +226 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/receiver-binding.js +169 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/scope-resolver.js +192 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/sibling-type-bindings.js +68 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/signature-bindings.js +69 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/simple-hooks.js +65 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/target-grouping.js +97 -0
- package/dist/core/code/engine/core/ingestion/languages/swift/target-siblings.js +74 -0
- package/dist/core/code/engine/core/ingestion/languages/swift.js +246 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/arity-metadata.js +106 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/arity.js +57 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/array-callback.js +58 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/cache-stats.js +34 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/captures.js +956 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/cjs-export-assignment.js +535 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/cjs-module-exports.js +196 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/import-decomposer.js +374 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/import-target.js +65 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/index.js +108 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/interpret.js +344 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/merge-bindings.js +161 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/nuxt-auto-imports.js +325 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/query.js +1328 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/receiver-binding.js +201 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/scope-resolver.js +293 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript/simple-hooks.js +139 -0
- package/dist/core/code/engine/core/ingestion/languages/typescript.js +455 -0
- package/dist/core/code/engine/core/ingestion/languages/vue/captures.js +70 -0
- package/dist/core/code/engine/core/ingestion/languages/vue/import-target.js +61 -0
- package/dist/core/code/engine/core/ingestion/languages/vue/index.js +55 -0
- package/dist/core/code/engine/core/ingestion/languages/vue/scope-resolver.js +295 -0
- package/dist/core/code/engine/core/ingestion/languages/vue.js +96 -0
- package/dist/core/code/engine/core/ingestion/local-symbol-pruner.js +68 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/c-cpp.js +387 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/csharp.js +290 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/dart.js +392 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/go.js +179 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/jvm.js +350 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/php.js +306 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/python.js +312 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/ruby.js +289 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/rust.js +198 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/swift.js +286 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/configs/typescript-javascript.js +341 -0
- package/dist/core/code/engine/core/ingestion/method-extractors/generic.js +209 -0
- package/dist/core/code/engine/core/ingestion/method-types.js +3 -0
- package/dist/core/code/engine/core/ingestion/model/field-registry.js +41 -0
- package/dist/core/code/engine/core/ingestion/model/index.js +52 -0
- package/dist/core/code/engine/core/ingestion/model/method-registry.js +138 -0
- package/dist/core/code/engine/core/ingestion/model/owned-members-lookup.js +46 -0
- package/dist/core/code/engine/core/ingestion/model/registration-table.js +234 -0
- package/dist/core/code/engine/core/ingestion/model/resolve.js +183 -0
- package/dist/core/code/engine/core/ingestion/model/scope-resolution-indexes.js +43 -0
- package/dist/core/code/engine/core/ingestion/model/semantic-model.js +179 -0
- package/dist/core/code/engine/core/ingestion/model/symbol-table.js +216 -0
- package/dist/core/code/engine/core/ingestion/model/type-registry.js +84 -0
- package/dist/core/code/engine/core/ingestion/mro-processor.js +709 -0
- package/dist/core/code/engine/core/ingestion/parsing-processor.js +312 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/communities.js +69 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/cross-file.js +71 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/di.js +338 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/http-api-calls.js +312 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/index.js +54 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/mro.js +40 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/orm.js +78 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/parse-impl.js +1286 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/parse.js +41 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/processes.js +193 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/prune-local-symbols.js +29 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/registry.js +52 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/routes.js +409 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/rpc-edges.js +301 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/runner.js +207 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/scan.js +49 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/spring-aop.js +442 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/spring-auto-configuration.js +264 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/spring-config.js +440 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/structure.js +38 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/tools.js +89 -0
- package/dist/core/code/engine/core/ingestion/pipeline-phases/types.js +40 -0
- package/dist/core/code/engine/core/ingestion/pipeline.js +152 -0
- package/dist/core/code/engine/core/ingestion/process-processor.js +325 -0
- package/dist/core/code/engine/core/ingestion/resolve-references.js +205 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/constant-resolver.js +135 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/django-root-discovery.js +221 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/django.js +428 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/expo.js +39 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/fastapi-router-bindings.js +264 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/laravel.js +501 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/middleware.js +175 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/nextjs.js +81 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/php.js +25 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/python-const-resolver.js +307 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/response-shapes.js +299 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/route-path.js +71 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/spring-shared.js +310 -0
- package/dist/core/code/engine/core/ingestion/route-extractors/spring.js +441 -0
- package/dist/core/code/engine/core/ingestion/scope-extractor-bridge.js +60 -0
- package/dist/core/code/engine/core/ingestion/scope-extractor.js +1373 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/contract/scope-resolver.js +282 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js +72 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/edges.js +194 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/ids.js +472 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/imports-to-edges.js +49 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/method-dispatch.js +43 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +274 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js +93 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/callable-value-flow.js +1240 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/compound-receiver.js +1174 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/free-call-fallback.js +873 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/imported-return-types.js +226 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/mro.js +107 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/overload-narrowing.js +441 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/property-dispatch.js +122 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +1720 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/pipeline/phase.js +395 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +208 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/pipeline/registry.js +49 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/pipeline/run.js +632 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/pipeline/validate-bindings-immutability.js +112 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/resolution-outcome.js +41 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/scope/namespace-targets.js +81 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/scope/walkers.js +1835 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/unresolved-receivers.js +242 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/utils/definition-id.js +22 -0
- package/dist/core/code/engine/core/ingestion/scope-resolution/workspace-index.js +151 -0
- package/dist/core/code/engine/core/ingestion/structure-processor.js +40 -0
- package/dist/core/code/engine/core/ingestion/tree-sitter-queries.js +2244 -0
- package/dist/core/code/engine/core/ingestion/ts-js-hoc-utils.js +115 -0
- package/dist/core/code/engine/core/ingestion/type-env.js +1136 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/c-cpp.js +555 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/csharp.js +570 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/dart.js +372 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/go.js +508 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/jvm.js +875 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/php.js +537 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/python.js +477 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/ruby.js +380 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/rust.js +502 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/shared.js +843 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/swift.js +490 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/types.js +2 -0
- package/dist/core/code/engine/core/ingestion/type-extractors/typescript.js +690 -0
- package/dist/core/code/engine/core/ingestion/utils/ast-helpers.js +1693 -0
- package/dist/core/code/engine/core/ingestion/utils/call-analysis.js +779 -0
- package/dist/core/code/engine/core/ingestion/utils/callable-flow-captures.js +932 -0
- package/dist/core/code/engine/core/ingestion/utils/callable-labels.js +49 -0
- package/dist/core/code/engine/core/ingestion/utils/deferred-resolution-profile.js +151 -0
- package/dist/core/code/engine/core/ingestion/utils/effective-ram.js +67 -0
- package/dist/core/code/engine/core/ingestion/utils/env.js +60 -0
- package/dist/core/code/engine/core/ingestion/utils/event-loop.js +9 -0
- package/dist/core/code/engine/core/ingestion/utils/graph-sort.js +103 -0
- package/dist/core/code/engine/core/ingestion/utils/heap-probe.js +45 -0
- package/dist/core/code/engine/core/ingestion/utils/heritage-marker.js +47 -0
- package/dist/core/code/engine/core/ingestion/utils/line-base.js +24 -0
- package/dist/core/code/engine/core/ingestion/utils/max-file-size.js +59 -0
- package/dist/core/code/engine/core/ingestion/utils/method-props.js +198 -0
- package/dist/core/code/engine/core/ingestion/utils/qualified-name.js +73 -0
- package/dist/core/code/engine/core/ingestion/utils/receiver-chain-captures.js +60 -0
- package/dist/core/code/engine/core/ingestion/utils/receiver-chain-codec.js +188 -0
- package/dist/core/code/engine/core/ingestion/utils/scope-tree-walk.js +36 -0
- package/dist/core/code/engine/core/ingestion/utils/symbol-labels.js +48 -0
- package/dist/core/code/engine/core/ingestion/utils/template-arguments.js +187 -0
- package/dist/core/code/engine/core/ingestion/utils/type-parameters.js +209 -0
- package/dist/core/code/engine/core/ingestion/utils/verbose.js +6 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/c-cpp.js +133 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/csharp.js +66 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/dart.js +111 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/go.js +153 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/jvm.js +145 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/php.js +61 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/python.js +104 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/ruby.js +55 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/rust.js +79 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/swift.js +91 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/configs/typescript-javascript.js +86 -0
- package/dist/core/code/engine/core/ingestion/variable-extractors/generic.js +111 -0
- package/dist/core/code/engine/core/ingestion/variable-types.js +3 -0
- package/dist/core/code/engine/core/ingestion/vue-sfc-extractor.js +544 -0
- package/dist/core/code/engine/core/ingestion/workers/callable-id.js +122 -0
- package/dist/core/code/engine/core/ingestion/workers/clone-safety.js +470 -0
- package/dist/core/code/engine/core/ingestion/workers/parse-worker.js +2456 -0
- package/dist/core/code/engine/core/ingestion/workers/post-result.js +90 -0
- package/dist/core/code/engine/core/ingestion/workers/quarantine.js +41 -0
- package/dist/core/code/engine/core/ingestion/workers/result-merge.js +59 -0
- package/dist/core/code/engine/core/ingestion/workers/worker-pool.js +1725 -0
- package/dist/core/code/engine/core/ingestion/workspace-config.js +139 -0
- package/dist/core/code/engine/core/lbug/conn-lock.js +72 -0
- package/dist/core/code/engine/core/lbug/csv-generator.js +710 -0
- package/dist/core/code/engine/core/lbug/cypher-escape.js +24 -0
- package/dist/core/code/engine/core/lbug/extension-load-error.js +339 -0
- package/dist/core/code/engine/core/lbug/extension-loader.js +261 -0
- package/dist/core/code/engine/core/lbug/graph-emit-sink.js +584 -0
- package/dist/core/code/engine/core/lbug/lbug-adapter.js +2703 -0
- package/dist/core/code/engine/core/lbug/lbug-config.js +1029 -0
- package/dist/core/code/engine/core/lbug/native-check.js +435 -0
- package/dist/core/code/engine/core/lbug/pool-adapter.js +973 -0
- package/dist/core/code/engine/core/lbug/query-params.js +25 -0
- package/dist/core/code/engine/core/lbug/query-result-utils.js +31 -0
- package/dist/core/code/engine/core/lbug/rel-pair-routing.js +373 -0
- package/dist/core/code/engine/core/lbug/schema.js +771 -0
- package/dist/core/code/engine/core/lbug/shutdown-helpers.js +40 -0
- package/dist/core/code/engine/core/lbug/sidecar-recovery.js +716 -0
- package/dist/core/code/engine/core/lbug/stdio-capture.js +49 -0
- package/dist/core/code/engine/core/lbug/sync-csv-writer.js +115 -0
- package/dist/core/code/engine/core/lbug/wal-checkpoint-driver.js +215 -0
- package/dist/core/code/engine/core/lbug/wal-driver-state.js +28 -0
- package/dist/core/code/engine/core/logger.js +339 -0
- package/dist/core/code/engine/core/platform/capabilities.js +91 -0
- package/dist/core/code/engine/core/run-analyze.js +1098 -0
- package/dist/core/code/engine/core/tree-sitter/parser-loader.js +281 -0
- package/dist/core/code/engine/core/tree-sitter/safe-parse.js +258 -0
- package/dist/core/code/engine/core/tree-sitter/vendored-grammars.js +64 -0
- package/dist/core/code/engine/lib/utils.js +121 -0
- package/dist/core/code/engine/mcp/core/lbug-adapter.js +27 -0
- package/dist/core/code/engine/mcp/local/aop-metadata.js +230 -0
- package/dist/core/code/engine/mcp/local/bean-metadata.js +49 -0
- package/dist/core/code/engine/mcp/local/limits.js +15 -0
- package/dist/core/code/engine/mcp/local/line-display.js +6 -0
- package/dist/core/code/engine/mcp/local/local-backend.js +4142 -0
- package/dist/core/code/engine/storage/branch-index.js +72 -0
- package/dist/core/code/engine/storage/file-hash.js +95 -0
- package/dist/core/code/engine/storage/fs-atomic.js +34 -0
- package/dist/core/code/engine/storage/git.js +555 -0
- package/dist/core/code/engine/storage/index-lock.js +664 -0
- package/dist/core/code/engine/storage/parse-cache.js +650 -0
- package/dist/core/code/engine/storage/parsedfile-store.js +620 -0
- package/dist/core/code/engine/storage/repo-manager.js +1061 -0
- package/dist/core/code/engine/storage/scope-index-store.js +247 -0
- package/dist/core/code/engine/types/pipeline.js +2 -0
- package/dist/core/code/scripts/install-duckdb-extension.mjs +125 -0
- package/dist/core/code/scripts/resolve-analyze-cmd.cjs +346 -0
- package/dist/core/code/shared/graph/types.js +8 -0
- package/dist/core/code/shared/index.js +105 -0
- package/dist/core/code/shared/integrations/circuit-breaker.js +242 -0
- package/dist/core/code/shared/integrations/resilient-fetch.js +224 -0
- package/dist/core/code/shared/integrations/retry.js +70 -0
- package/dist/core/code/shared/integrations/understand-quickly.js +145 -0
- package/dist/core/code/shared/language-detection.js +162 -0
- package/dist/core/code/shared/languages.js +27 -0
- package/dist/core/code/shared/lbug/schema-constants.js +98 -0
- package/dist/core/code/shared/mro-strategy.js +2 -0
- package/dist/core/code/shared/pipeline.js +5 -0
- package/dist/core/code/shared/scope-resolution/callable-flow-site.js +11 -0
- package/dist/core/code/shared/scope-resolution/def-index.js +53 -0
- package/dist/core/code/shared/scope-resolution/evidence-weights.js +87 -0
- package/dist/core/code/shared/scope-resolution/finalize-algorithm.js +807 -0
- package/dist/core/code/shared/scope-resolution/language-classification.js +46 -0
- package/dist/core/code/shared/scope-resolution/method-dispatch-index.js +100 -0
- package/dist/core/code/shared/scope-resolution/module-scope-index.js +59 -0
- package/dist/core/code/shared/scope-resolution/origin-priority.js +23 -0
- package/dist/core/code/shared/scope-resolution/parsed-file.js +54 -0
- package/dist/core/code/shared/scope-resolution/position-index.js +136 -0
- package/dist/core/code/shared/scope-resolution/qualified-name-index.js +77 -0
- package/dist/core/code/shared/scope-resolution/reference-site.js +24 -0
- package/dist/core/code/shared/scope-resolution/registries/class-registry.js +32 -0
- package/dist/core/code/shared/scope-resolution/registries/context.js +52 -0
- package/dist/core/code/shared/scope-resolution/registries/evidence.js +152 -0
- package/dist/core/code/shared/scope-resolution/registries/field-registry.js +33 -0
- package/dist/core/code/shared/scope-resolution/registries/lookup-core.js +392 -0
- package/dist/core/code/shared/scope-resolution/registries/lookup-qualified.js +58 -0
- package/dist/core/code/shared/scope-resolution/registries/macro-registry.js +34 -0
- package/dist/core/code/shared/scope-resolution/registries/method-registry.js +34 -0
- package/dist/core/code/shared/scope-resolution/registries/tie-breaks.js +63 -0
- package/dist/core/code/shared/scope-resolution/resolve-type-ref.js +128 -0
- package/dist/core/code/shared/scope-resolution/scope-id.js +49 -0
- package/dist/core/code/shared/scope-resolution/scope-tree.js +225 -0
- package/dist/core/code/shared/scope-resolution/symbol-definition.js +12 -0
- package/dist/core/code/shared/scope-resolution/types.js +25 -0
- package/dist/core/code/shared/test-helpers.js +17 -0
- package/dist/core/code/vendor/leiden/index.cjs +355 -0
- package/dist/core/code/vendor/leiden/utils.cjs +419 -0
- package/dist/core/timeline/cli.d.ts.map +1 -1
- package/dist/core/timeline/cli.js +13 -6
- package/dist/core/timeline/cli.js.map +1 -1
- package/dist/core/timeline/debris.d.ts +20 -0
- package/dist/core/timeline/debris.d.ts.map +1 -0
- package/dist/core/timeline/debris.js +124 -0
- package/dist/core/timeline/debris.js.map +1 -0
- package/dist/core/timeline/hook-runner.d.ts.map +1 -1
- package/dist/core/timeline/hook-runner.js +3 -1
- package/dist/core/timeline/hook-runner.js.map +1 -1
- package/dist/core/timeline/hooks.d.ts.map +1 -1
- package/dist/core/timeline/hooks.js +13 -5
- package/dist/core/timeline/hooks.js.map +1 -1
- package/dist/core/timeline/installer.d.ts +2 -0
- package/dist/core/timeline/installer.d.ts.map +1 -1
- package/dist/core/timeline/installer.js +12 -0
- package/dist/core/timeline/installer.js.map +1 -1
- package/dist/core/timeline/project-root.d.ts +21 -0
- package/dist/core/timeline/project-root.d.ts.map +1 -0
- package/dist/core/timeline/project-root.js +83 -0
- package/dist/core/timeline/project-root.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,4142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local Backend (Multi-Repo)
|
|
4
|
+
*
|
|
5
|
+
* Provides tool implementations using local .cgraphx/code/ indexes.
|
|
6
|
+
* Supports multiple indexed repositories via a global registry.
|
|
7
|
+
* LadybugDB connections are opened lazily per repo on first query.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.LocalBackend = exports.REPO_ID_HASH_LENGTH = exports.IMPACT_RELATION_CONFIDENCE = exports.EPISTEMIC_CONSUMER_RELATION_TYPES = exports.EPISTEMIC_HERITAGE_RELATION_TYPES = exports.VALID_RELATION_TYPES = exports.VALID_NODE_LABELS = void 0;
|
|
14
|
+
exports.isTestFilePath = isTestFilePath;
|
|
15
|
+
exports.resolveWorktreeCwd = resolveWorktreeCwd;
|
|
16
|
+
exports.buildDetectChangesDiffArgs = buildDetectChangesDiffArgs;
|
|
17
|
+
exports.discoverGitSubprojects = discoverGitSubprojects;
|
|
18
|
+
exports.attachToolStaleness = attachToolStaleness;
|
|
19
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const crypto_1 = require("crypto");
|
|
22
|
+
const pool_adapter_js_1 = require("../../core/lbug/pool-adapter.js");
|
|
23
|
+
const bean_metadata_js_1 = require("./bean-metadata.js");
|
|
24
|
+
const aop_metadata_js_1 = require("./aop-metadata.js");
|
|
25
|
+
const query_params_js_1 = require("../../core/lbug/query-params.js");
|
|
26
|
+
const line_display_js_1 = require("./line-display.js");
|
|
27
|
+
const lbug_config_js_1 = require("../../core/lbug/lbug-config.js");
|
|
28
|
+
const git_js_1 = require("../../storage/git.js");
|
|
29
|
+
const fs_1 = require("fs");
|
|
30
|
+
const repo_manager_js_1 = require("../../storage/repo-manager.js");
|
|
31
|
+
const git_staleness_js_1 = require("../../core/git-staleness.js");
|
|
32
|
+
const logger_js_1 = require("../../core/logger.js");
|
|
33
|
+
const utils_js_1 = require("../../lib/utils.js");
|
|
34
|
+
const unresolved_receivers_js_1 = require("../../core/ingestion/scope-resolution/unresolved-receivers.js");
|
|
35
|
+
/**
|
|
36
|
+
* Candidate `type`s that label enrichment newly populates (#2687). Before that,
|
|
37
|
+
* these surfaced as `''`, which several resolution gates read as "kind unknown".
|
|
38
|
+
* Anything keyed on the empty string must name these explicitly.
|
|
39
|
+
*/
|
|
40
|
+
const VALUE_CANDIDATE_TYPES = new Set(['Const', 'Variable', 'Static']);
|
|
41
|
+
/**
|
|
42
|
+
* Row cap on the name-resolution window in `resolveSymbolCandidates`. Named
|
|
43
|
+
* because the SQL `LIMIT` and the "is this window the complete match set?"
|
|
44
|
+
* guard must agree — a short page (`rows.length < CANDIDATE_WINDOW`) is the
|
|
45
|
+
* only truncation signal available when the COUNT leg fails.
|
|
46
|
+
*/
|
|
47
|
+
const CANDIDATE_WINDOW = 20;
|
|
48
|
+
/**
|
|
49
|
+
* The pieces every ambiguous-resolution payload shares, derived once.
|
|
50
|
+
*
|
|
51
|
+
* `outcome.total` is the resolver's real match count, NOT `candidates.length` —
|
|
52
|
+
* the window caps at {@link CANDIDATE_WINDOW}, so reporting the array length
|
|
53
|
+
* claimed 20 matches when 92 existed. `totalIsLowerBound` must travel with that
|
|
54
|
+
* count everywhere it is
|
|
55
|
+
* reported (#2787 review F3), INCLUDING the prose, because the message is what
|
|
56
|
+
* an agent actually reads: without it a failed COUNT reads as an exact 20. And
|
|
57
|
+
* truncation is measured against `total`, never against the window — the old
|
|
58
|
+
* form said "6 of 20" when 92 matched.
|
|
59
|
+
*
|
|
60
|
+
* Six call sites rebuilt all of that by hand with slightly different spellings.
|
|
61
|
+
* Deriving it in one place is what stops the invariants drifting apart again.
|
|
62
|
+
*
|
|
63
|
+
* @param shownCount how many candidates the payload actually carries — the full
|
|
64
|
+
* window for `context`/`trace`, the `AMBIGUOUS_MAX_CANDIDATES` slice for
|
|
65
|
+
* `impact`.
|
|
66
|
+
* @param withTotal picks the suffix form: `(showing 6 of 92)` for the `impact`
|
|
67
|
+
* paths that slice, `(showing 20)` for the paths that return the whole window.
|
|
68
|
+
*/
|
|
69
|
+
function ambiguityReport(outcome, shownCount, withTotal = false) {
|
|
70
|
+
const truncated = outcome.total > shownCount;
|
|
71
|
+
return {
|
|
72
|
+
atLeast: outcome.totalIsLowerBound ? 'at least ' : '',
|
|
73
|
+
showing: truncated
|
|
74
|
+
? withTotal
|
|
75
|
+
? ` (showing ${shownCount} of ${outcome.total})`
|
|
76
|
+
: ` (showing ${shownCount})`
|
|
77
|
+
: '',
|
|
78
|
+
fields: {
|
|
79
|
+
totalCandidates: outcome.total,
|
|
80
|
+
...(outcome.totalIsLowerBound ? { totalIsLowerBound: true } : {}),
|
|
81
|
+
...(truncated ? { candidatesTruncated: true } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a string tool param from its canonical name or legacy alias (#2175).
|
|
87
|
+
* Returns the first NON-BLANK string of [canonical, legacy] — the canonical (new)
|
|
88
|
+
* name is preferred when it carries a real value, otherwise the legacy value is used.
|
|
89
|
+
* A blank/whitespace new value therefore does NOT clobber a valid legacy value (e.g. a
|
|
90
|
+
* gradually-migrating client that always emits the new key, blank when unset). A
|
|
91
|
+
* non-string value (the MCP envelope is not schema-validated, so clients can send any
|
|
92
|
+
* JSON type) and an all-blank input resolve to `undefined`, so the caller returns a
|
|
93
|
+
* friendly required-param error instead of throwing `TypeError` on `.trim()`.
|
|
94
|
+
*/
|
|
95
|
+
function resolveAliasString(canonical, legacy) {
|
|
96
|
+
for (const value of [canonical, legacy]) {
|
|
97
|
+
if (typeof value === 'string' && value.trim())
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const TOOL_STRING_ALIASES = {
|
|
103
|
+
impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }],
|
|
104
|
+
context: [{ canonical: 'file_path', aliases: ['file'] }],
|
|
105
|
+
trace: [{ canonical: 'from_file', aliases: ['file'] }],
|
|
106
|
+
};
|
|
107
|
+
/** callTool 分发的全部工具名(v1 工具集,恰好 9 个)。 */
|
|
108
|
+
const KNOWN_TOOL_METHODS = new Set([
|
|
109
|
+
'context',
|
|
110
|
+
'impact',
|
|
111
|
+
'trace',
|
|
112
|
+
'cypher',
|
|
113
|
+
'detect_changes',
|
|
114
|
+
'route_map',
|
|
115
|
+
'api_impact',
|
|
116
|
+
'shape_check',
|
|
117
|
+
'tool_map',
|
|
118
|
+
]);
|
|
119
|
+
function normalizeToolParams(method, params) {
|
|
120
|
+
const input = params && typeof params === 'object' ? params : {};
|
|
121
|
+
const definitions = TOOL_STRING_ALIASES[method];
|
|
122
|
+
if (!definitions)
|
|
123
|
+
return { params: input };
|
|
124
|
+
const normalized = { ...input };
|
|
125
|
+
for (const { canonical, aliases } of definitions) {
|
|
126
|
+
const keys = [canonical, ...aliases];
|
|
127
|
+
const supplied = [];
|
|
128
|
+
for (const key of keys) {
|
|
129
|
+
if (!Object.prototype.hasOwnProperty.call(input, key))
|
|
130
|
+
continue;
|
|
131
|
+
const value = input[key];
|
|
132
|
+
// Internal CLI callers materialize omitted optional flags as undefined.
|
|
133
|
+
if (value === undefined)
|
|
134
|
+
continue;
|
|
135
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
136
|
+
return { error: `Parameter ${method}.${key} must be a non-empty string.` };
|
|
137
|
+
}
|
|
138
|
+
supplied.push({ key, value: value.trim() });
|
|
139
|
+
}
|
|
140
|
+
const distinctValues = new Set(supplied.map(({ value }) => value));
|
|
141
|
+
if (distinctValues.size > 1) {
|
|
142
|
+
return {
|
|
143
|
+
error: `Conflicting parameters for ${method}.${canonical}: ${supplied
|
|
144
|
+
.map(({ key }) => key)
|
|
145
|
+
.join(', ')} must agree.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
for (const alias of aliases)
|
|
149
|
+
delete normalized[alias];
|
|
150
|
+
if (supplied.length > 0)
|
|
151
|
+
normalized[canonical] = supplied[0].value;
|
|
152
|
+
}
|
|
153
|
+
if (method === 'impact' &&
|
|
154
|
+
typeof normalized.target !== 'string' &&
|
|
155
|
+
(typeof normalized.target_uid !== 'string' || !normalized.target_uid.trim())) {
|
|
156
|
+
return { error: 'impact requires target, name, symbol, or target_uid.' };
|
|
157
|
+
}
|
|
158
|
+
return { params: normalized };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Quick test-file detection for filtering impact results.
|
|
162
|
+
* Matches common test file patterns across all supported languages.
|
|
163
|
+
*/
|
|
164
|
+
function isTestFilePath(filePath) {
|
|
165
|
+
if (!filePath)
|
|
166
|
+
return false;
|
|
167
|
+
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
|
168
|
+
return (p.includes('.test.') ||
|
|
169
|
+
p.includes('.spec.') ||
|
|
170
|
+
p.includes('__tests__/') ||
|
|
171
|
+
p.includes('__mocks__/') ||
|
|
172
|
+
p.includes('/test/') ||
|
|
173
|
+
p.includes('/tests/') ||
|
|
174
|
+
p.includes('/testing/') ||
|
|
175
|
+
p.includes('/fixtures/') ||
|
|
176
|
+
p.endsWith('_test.go') ||
|
|
177
|
+
p.endsWith('_test.py') ||
|
|
178
|
+
p.endsWith('_spec.rb') ||
|
|
179
|
+
p.endsWith('_test.rb') ||
|
|
180
|
+
p.includes('/spec/') ||
|
|
181
|
+
p.includes('/test_') ||
|
|
182
|
+
p.includes('/conftest.'));
|
|
183
|
+
}
|
|
184
|
+
/** Valid LadybugDB node labels for safe Cypher query construction */
|
|
185
|
+
exports.VALID_NODE_LABELS = new Set([
|
|
186
|
+
'File',
|
|
187
|
+
'Folder',
|
|
188
|
+
'Function',
|
|
189
|
+
'Class',
|
|
190
|
+
'Interface',
|
|
191
|
+
'Method',
|
|
192
|
+
'CodeElement',
|
|
193
|
+
'Community',
|
|
194
|
+
'Process',
|
|
195
|
+
'Struct',
|
|
196
|
+
'Enum',
|
|
197
|
+
'Macro',
|
|
198
|
+
'Typedef',
|
|
199
|
+
'Union',
|
|
200
|
+
'Namespace',
|
|
201
|
+
'Trait',
|
|
202
|
+
'Impl',
|
|
203
|
+
'TypeAlias',
|
|
204
|
+
'Const',
|
|
205
|
+
'Static',
|
|
206
|
+
'Property',
|
|
207
|
+
'Record',
|
|
208
|
+
'Delegate',
|
|
209
|
+
'Annotation',
|
|
210
|
+
'Constructor',
|
|
211
|
+
'Template',
|
|
212
|
+
'Module',
|
|
213
|
+
'Route',
|
|
214
|
+
'Tool',
|
|
215
|
+
]);
|
|
216
|
+
/** Valid relation types for impact analysis filtering */
|
|
217
|
+
exports.VALID_RELATION_TYPES = new Set([
|
|
218
|
+
'CALLS',
|
|
219
|
+
'IMPORTS',
|
|
220
|
+
'EXTENDS',
|
|
221
|
+
'IMPLEMENTS',
|
|
222
|
+
'HAS_METHOD',
|
|
223
|
+
'HAS_PROPERTY',
|
|
224
|
+
'METHOD_OVERRIDES',
|
|
225
|
+
'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
|
|
226
|
+
'METHOD_IMPLEMENTS',
|
|
227
|
+
'ACCESSES',
|
|
228
|
+
// 跨服务调用边:rpc-edges / http-api-calls 阶段产出(method→method 或
|
|
229
|
+
// method→ExternalService)。spec §4 要求 impact 默认遍历包含这两类边,
|
|
230
|
+
// 使"改一个服务的方法"能波及跨服务调用方——与 CALLS 同级,非 opt-in
|
|
231
|
+
// (区别于 INJECTS/WRAPS)。边自带 confidence(1.0 匹配 / 0.5 单侧孤儿)。
|
|
232
|
+
'RCALLS',
|
|
233
|
+
'HTTP_CALLS',
|
|
234
|
+
// Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
|
|
235
|
+
// already part of the default impact relTypes + context() incoming queries.
|
|
236
|
+
// It was missing from this allowlist, so `impact({relationTypes:['USES']})`
|
|
237
|
+
// silently filtered to [] and fell back to the full default traversal
|
|
238
|
+
// (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
|
|
239
|
+
// matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
|
|
240
|
+
'USES',
|
|
241
|
+
'HANDLES_ROUTE',
|
|
242
|
+
'FETCHES',
|
|
243
|
+
'HANDLES_TOOL',
|
|
244
|
+
'ENTRY_POINT_OF',
|
|
245
|
+
'WRAPS',
|
|
246
|
+
// Emitted by the `di` pipeline phase (#2200 — DI collection injection,
|
|
247
|
+
// consumer Class → implementer Class). Valid here for explicit
|
|
248
|
+
// `relationTypes` filters, but deliberately NOT in the default impact()
|
|
249
|
+
// relTypes nor the context() incoming/outgoing lists — traversal is opt-in,
|
|
250
|
+
// like WRAPS/FETCHES. Also deliberately NO IMPACT_RELATION_CONFIDENCE entry
|
|
251
|
+
// (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there,
|
|
252
|
+
// and the edges carry their own confidence (0.8) in the graph.
|
|
253
|
+
'INJECTS',
|
|
254
|
+
// Conditional and metadata-declaration evidence is opt-in for impact
|
|
255
|
+
// traversal, like INJECTS: explicit filters can follow activation
|
|
256
|
+
// constraints and declarations without changing the default callgraph
|
|
257
|
+
// surface.
|
|
258
|
+
'CONDITIONAL_ON',
|
|
259
|
+
'DECLARES',
|
|
260
|
+
// Spring proxy/advice evidence (#2416). Opt-in for traversal so existing
|
|
261
|
+
// impact defaults do not silently widen; target enrichment still surfaces
|
|
262
|
+
// advised/proxied state on ordinary impact calls.
|
|
263
|
+
'ADVISED_BY',
|
|
264
|
+
]);
|
|
265
|
+
/**
|
|
266
|
+
* Relation types the #1858 epistemic-boundary probe keys on. Kept as
|
|
267
|
+
* module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
|
|
268
|
+
* binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
|
|
269
|
+
* The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
|
|
270
|
+
* "statically verifiable, but the concrete binding past it is not".
|
|
271
|
+
*/
|
|
272
|
+
exports.EPISTEMIC_HERITAGE_RELATION_TYPES = [
|
|
273
|
+
'IMPLEMENTS',
|
|
274
|
+
'METHOD_IMPLEMENTS',
|
|
275
|
+
'EXTENDS',
|
|
276
|
+
];
|
|
277
|
+
exports.EPISTEMIC_CONSUMER_RELATION_TYPES = ['CALLS', 'USES', 'ACCESSES'];
|
|
278
|
+
/**
|
|
279
|
+
* Per-relation-type confidence floor for impact analysis.
|
|
280
|
+
*
|
|
281
|
+
* When the graph stores a relation with a confidence value, that stored
|
|
282
|
+
* value is used as-is (it reflects resolution-tier accuracy from analysis
|
|
283
|
+
* time). This map provides the floor for each edge type when no stored
|
|
284
|
+
* confidence is available, and is also used for display / tooltip hints.
|
|
285
|
+
*
|
|
286
|
+
* Rationale:
|
|
287
|
+
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
|
|
288
|
+
* EXTENDS – class hierarchy, statically verifiable → 0.85
|
|
289
|
+
* IMPLEMENTS – interface contract, statically verifiable → 0.85
|
|
290
|
+
* METHOD_OVERRIDES – method override, statically verifiable → 0.85
|
|
291
|
+
* METHOD_IMPLEMENTS – interface method implementation, statically verifiable → 0.85
|
|
292
|
+
* HAS_METHOD – structural containment → 0.95
|
|
293
|
+
* HAS_PROPERTY – structural containment → 0.95
|
|
294
|
+
* ACCESSES – field read/write, may be indirect → 0.8
|
|
295
|
+
* CONTAINS – folder/file containment → 0.95
|
|
296
|
+
* RCALLS – 跨服务 RPC 调用,serviceId+methodName 精确匹配 → 0.9(CALLS 同级)
|
|
297
|
+
* HTTP_CALLS – 跨服务 HTTP API 调用,URL+method 匹配 → 0.9(CALLS 同级)
|
|
298
|
+
* (unknown type) – conservative fallback → 0.5
|
|
299
|
+
*/
|
|
300
|
+
exports.IMPACT_RELATION_CONFIDENCE = {
|
|
301
|
+
CALLS: 0.9,
|
|
302
|
+
IMPORTS: 0.9,
|
|
303
|
+
EXTENDS: 0.85,
|
|
304
|
+
IMPLEMENTS: 0.85,
|
|
305
|
+
METHOD_OVERRIDES: 0.85,
|
|
306
|
+
METHOD_IMPLEMENTS: 0.85,
|
|
307
|
+
HAS_METHOD: 0.95,
|
|
308
|
+
HAS_PROPERTY: 0.95,
|
|
309
|
+
ACCESSES: 0.8,
|
|
310
|
+
CONTAINS: 0.95,
|
|
311
|
+
// 跨服务调用边(spec §4)。fork 未定义置信度,取 CALLS 同级 0.9。
|
|
312
|
+
// 这两类边在图中自带 confidence(1.0 匹配 / 0.5 单侧孤儿),优先用存储值;
|
|
313
|
+
// 此处 floor 仅在存储缺失时生效(显示/tooltip 兜底)。
|
|
314
|
+
RCALLS: 0.9,
|
|
315
|
+
HTTP_CALLS: 0.9,
|
|
316
|
+
};
|
|
317
|
+
/**
|
|
318
|
+
* Return the confidence floor for a given relation type.
|
|
319
|
+
* Falls back to 0.5 for unknown types so they are not silently elevated.
|
|
320
|
+
*/
|
|
321
|
+
const confidenceForRelType = (relType) => exports.IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
|
|
322
|
+
/**
|
|
323
|
+
* Sort rank for the `confidence DESC` leg of {@link compareImpactEdgeStrength}.
|
|
324
|
+
* A null/non-numeric confidence ranks LAST (weakest), so a stored measurement
|
|
325
|
+
* always outranks a missing one. That is the same "keep the strongest evidence"
|
|
326
|
+
* intent the ordering exists for — and stating it here is the point: what an
|
|
327
|
+
* engine does with NULLs inside a DESC sort key is its choice, not ours.
|
|
328
|
+
*/
|
|
329
|
+
const impactEdgeConfidenceRank = (confidence) => typeof confidence === 'number' && Number.isFinite(confidence)
|
|
330
|
+
? confidence
|
|
331
|
+
: Number.NEGATIVE_INFINITY;
|
|
332
|
+
/**
|
|
333
|
+
* `relType ASC, confidence DESC, sourceId ASC` — decides which of several edges
|
|
334
|
+
* reaching the SAME node stamps its `relationType`/`confidence` onto that node's
|
|
335
|
+
* `impacted` entry. Total over those three columns, so the winner is a genuine
|
|
336
|
+
* argmax rather than "whichever row the engine happened to emit first".
|
|
337
|
+
*/
|
|
338
|
+
function compareImpactEdgeStrength(a, b) {
|
|
339
|
+
const byType = (0, utils_js_1.compareCodeUnits)(a.relType, b.relType);
|
|
340
|
+
if (byType !== 0)
|
|
341
|
+
return byType;
|
|
342
|
+
const aConfidence = impactEdgeConfidenceRank(a.confidence);
|
|
343
|
+
const bConfidence = impactEdgeConfidenceRank(b.confidence);
|
|
344
|
+
if (aConfidence !== bConfidence)
|
|
345
|
+
return aConfidence > bConfidence ? -1 : 1;
|
|
346
|
+
return (0, utils_js_1.compareCodeUnits)(a.sourceId, b.sourceId);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The whole former DB key: `id ASC` then {@link compareImpactEdgeStrength}.
|
|
350
|
+
* Orders the winning edges appended to `impacted` (distinct nodes, so `id` alone
|
|
351
|
+
* decides).
|
|
352
|
+
*/
|
|
353
|
+
function compareImpactFrontierEdges(a, b) {
|
|
354
|
+
return (0, utils_js_1.compareCodeUnits)(a.id, b.id) || compareImpactEdgeStrength(a, b);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Structured logging for *swallowed* query failures — replaces empty catch
|
|
358
|
+
* blocks. The level reflects telemetry severity, NOT a promise about the
|
|
359
|
+
* caller: most callers catch the failure and degrade to a genuinely safe
|
|
360
|
+
* fallback (a usable result, usually with a caller-visible `partial`/`ftsUsed`
|
|
361
|
+
* flag), so these are not operation-level errors and must not log at `error`:
|
|
362
|
+
*
|
|
363
|
+
* - A benign missing optional table/label/column — a repo analyzed without
|
|
364
|
+
* processes/communities, or a pre-v3 PDG index lacking the `calleeIds`
|
|
365
|
+
* column — is a normal configuration, not a failure. Logged at `debug`
|
|
366
|
+
* (suppressed at the default `info` level; surfaced only when troubleshooting).
|
|
367
|
+
* - Any other swallowed failure is an unexpected-but-handled degradation:
|
|
368
|
+
* logged at `warn` so it stays observable without raising a false `error`
|
|
369
|
+
* alarm that would drown genuine, operation-aborting failures.
|
|
370
|
+
*
|
|
371
|
+
* `error` is intentionally NOT used here — it is reserved for failures that
|
|
372
|
+
* actually abort an operation, which log directly rather than through this
|
|
373
|
+
* best-effort-degradation helper.
|
|
374
|
+
*
|
|
375
|
+
* Contract for callers (#2283 review): only route a failure here when the
|
|
376
|
+
* caller ALSO surfaces the degradation in its result (a `partial` flag,
|
|
377
|
+
* `failed_files`, `traversalComplete:false`, …). A safety-critical path that
|
|
378
|
+
* would otherwise report success/clean (e.g. the `detect_changes` safety gate)
|
|
379
|
+
* MUST set that result-level signal — `warn` alone is not a substitute for an
|
|
380
|
+
* honest result.
|
|
381
|
+
*/
|
|
382
|
+
function logQueryError(context, err) {
|
|
383
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
384
|
+
if (isBenignMissingTableError(err)) {
|
|
385
|
+
logger_js_1.logger.debug({ context, err: msg }, 'GitNexus query skipped (missing optional data)');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
logger_js_1.logger.warn({ context, err: msg }, 'GitNexus query failed (degraded)');
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* A "missing table/label/relation" prepare error is benign for the query tool's
|
|
392
|
+
* best-effort enrichment: a repo analyzed without processes or communities simply
|
|
393
|
+
* has no `Process`/`Community` tables, so the `STEP_IN_PROCESS` / `MEMBER_OF`
|
|
394
|
+
* enrichment queries fail to prepare. That is a normal configuration, NOT a
|
|
395
|
+
* degraded result — it must not raise the `partial` flag (which callers would
|
|
396
|
+
* then learn to ignore). Real failures (timeouts, locks, native faults) do.
|
|
397
|
+
*/
|
|
398
|
+
function isBenignMissingTableError(err) {
|
|
399
|
+
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
400
|
+
// The `not (defined|found)` arm is scoped to a schema object (table/label/
|
|
401
|
+
// rel/column/property), mirroring lbug-adapter's isMissingColumnError
|
|
402
|
+
// (`/(table|column|property).*not found/i`): an unscoped "not found" matched
|
|
403
|
+
// operation failures like `rg: not found` (ripgrep absent) or `Symbol not
|
|
404
|
+
// found`, which this helper would then silently demote to `debug` (#2283).
|
|
405
|
+
return /does not exist|no such (table|label|rel)|unknown (table|label)|(table|label|rel|column|property)[^\n]*\bnot (defined|found)\b/i.test(msg);
|
|
406
|
+
}
|
|
407
|
+
const isReadOnlyDbError = (err) => {
|
|
408
|
+
// Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
|
|
409
|
+
// pool adapter's `{ cause }` wrapper) is still detected here — this is the
|
|
410
|
+
// copy the MCP cypher handler uses to surface its curated read-only message
|
|
411
|
+
// (#2068 follow-up). Mirrors lbug-adapter's isReadOnlyDbError.
|
|
412
|
+
let cur = err;
|
|
413
|
+
for (let depth = 0; depth < 5 && cur != null; depth++) {
|
|
414
|
+
const msg = cur instanceof Error ? cur.message : String(cur);
|
|
415
|
+
if (/read-only database/i.test(msg))
|
|
416
|
+
return true;
|
|
417
|
+
cur = cur instanceof Error ? cur.cause : undefined;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
};
|
|
421
|
+
function epistemicFrom(dropped) {
|
|
422
|
+
// An index whose only drops were external still reports `exact` — nothing was
|
|
423
|
+
// lost — but carries the boundary count so "complete" is distinguishable from
|
|
424
|
+
// "we judged N calls to leave the program".
|
|
425
|
+
return dropped.notes.length === 0
|
|
426
|
+
? dropped.external > 0
|
|
427
|
+
? {
|
|
428
|
+
epistemic: 'exact',
|
|
429
|
+
causes: { receiverTyping: 0, dispatchBoundary: 0, externalBoundary: dropped.external },
|
|
430
|
+
}
|
|
431
|
+
: { epistemic: 'exact' }
|
|
432
|
+
: {
|
|
433
|
+
epistemic: 'lower-bound',
|
|
434
|
+
boundaries: [...dropped.notes],
|
|
435
|
+
// SITES, not notes. There is one note per symbol name but it reports N
|
|
436
|
+
// dropped sites, so counting notes would have published `1` next to
|
|
437
|
+
// prose saying `2 call sites` — a consumer branching on the number
|
|
438
|
+
// would read a different magnitude than the human reading the text.
|
|
439
|
+
causes: {
|
|
440
|
+
receiverTyping: dropped.sites,
|
|
441
|
+
dispatchBoundary: 0,
|
|
442
|
+
externalBoundary: dropped.external,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/** Resolve symlinks for path comparison; falls back to path.resolve on error.
|
|
447
|
+
* Uses `realpathSync.native` (not the pure-JS `realpathSync`) so that Windows
|
|
448
|
+
* 8.3 short names (e.g. RUNNER~1 → runneradmin) are expanded to long form,
|
|
449
|
+
* matching the output of `git rev-parse --show-toplevel`. */
|
|
450
|
+
function tryRealpath(p) {
|
|
451
|
+
try {
|
|
452
|
+
return fs_1.realpathSync.native(p);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return path_1.default.resolve(p);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Resolve the git diff cwd for detect_changes, auto-detecting linked worktrees.
|
|
460
|
+
*
|
|
461
|
+
* When `launchCwd` is a linked worktree of the same canonical repository as
|
|
462
|
+
* `repoPath` (i.e. `getGitRoot(launchCwd)` differs from `repoPath` but both
|
|
463
|
+
* share the same `getCanonicalRepoRoot`), returns the worktree's git root so
|
|
464
|
+
* that `git diff` sees the correct working directory and index.
|
|
465
|
+
*
|
|
466
|
+
* Returns `repoPath` unchanged in all other cases (non-worktree, git
|
|
467
|
+
* unavailable, unrelated repo).
|
|
468
|
+
*
|
|
469
|
+
* Extracted as a module-level export so tests can pass any `launchCwd` instead
|
|
470
|
+
* of relying on `process.cwd()`, which is fixed to the server launch directory
|
|
471
|
+
* and cannot be changed mid-process.
|
|
472
|
+
*/
|
|
473
|
+
function resolveWorktreeCwd(repoPath, launchCwd) {
|
|
474
|
+
try {
|
|
475
|
+
// Verify repoPath is a git root before comparing against its canonical
|
|
476
|
+
// root. If getGitRoot returns a different path, repoPath is an arbitrary
|
|
477
|
+
// subdirectory — skip both the linked-worktree guard and auto-detection
|
|
478
|
+
// and fall through to the repoPath fallback.
|
|
479
|
+
const repoGitRoot = (0, git_js_1.getGitRoot)(repoPath);
|
|
480
|
+
const repoCanonical = repoGitRoot && tryRealpath(repoGitRoot) === tryRealpath(repoPath)
|
|
481
|
+
? (0, git_js_1.getCanonicalRepoRoot)(repoPath)
|
|
482
|
+
: null;
|
|
483
|
+
// Early exit: if repoPath is a linked worktree (differs from its canonical
|
|
484
|
+
// main-checkout root), return it unchanged. Do NOT override it with the
|
|
485
|
+
// server's launch directory — that would silently replace the explicitly-
|
|
486
|
+
// resolved worktree index with the main checkout.
|
|
487
|
+
//
|
|
488
|
+
// getCanonicalRepoRoot returns the main-checkout path for both the checkout
|
|
489
|
+
// and all linked worktrees:
|
|
490
|
+
// repoPath === canonical → main checkout (auto-detect may fire below)
|
|
491
|
+
// repoPath !== canonical → linked worktree (return as-is)
|
|
492
|
+
if (repoCanonical && tryRealpath(repoPath) !== tryRealpath(repoCanonical)) {
|
|
493
|
+
return repoPath;
|
|
494
|
+
}
|
|
495
|
+
const launchGitRoot = (0, git_js_1.getGitRoot)(launchCwd);
|
|
496
|
+
if (launchGitRoot) {
|
|
497
|
+
// Normalise via realpathSync before comparing so macOS /var → /private/var
|
|
498
|
+
// symlinks (and Windows 8.3 short names) don't create false mismatches.
|
|
499
|
+
const realLaunch = tryRealpath(launchGitRoot);
|
|
500
|
+
const realRepo = tryRealpath(repoPath);
|
|
501
|
+
if (realLaunch !== realRepo) {
|
|
502
|
+
const launchCanonical = (0, git_js_1.getCanonicalRepoRoot)(launchCwd);
|
|
503
|
+
// Use tryRealpath on both canonical values for cross-platform safety.
|
|
504
|
+
if (launchCanonical &&
|
|
505
|
+
repoCanonical &&
|
|
506
|
+
tryRealpath(launchCanonical) === tryRealpath(repoCanonical)) {
|
|
507
|
+
return launchGitRoot;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Best-effort; fall through to repoPath.
|
|
514
|
+
}
|
|
515
|
+
return repoPath;
|
|
516
|
+
}
|
|
517
|
+
function buildDetectChangesDiffArgs(scope, baseRef) {
|
|
518
|
+
const args = ['diff', '--ignore-cr-at-eol'];
|
|
519
|
+
switch (scope) {
|
|
520
|
+
case 'staged':
|
|
521
|
+
return [...args, '--staged', '-U0'];
|
|
522
|
+
case 'all':
|
|
523
|
+
return [...args, 'HEAD', '-U0'];
|
|
524
|
+
case 'compare':
|
|
525
|
+
return baseRef ? [...args, baseRef, '-U0'] : null;
|
|
526
|
+
case 'unstaged':
|
|
527
|
+
default:
|
|
528
|
+
return [...args, '-U0'];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 在指定 cwd 运行一次 `git diff`(参数由 buildDetectChangesDiffArgs 构造)并解析为
|
|
533
|
+
* 逐文件 hunk。maxBuffer 256MB——Node 默认 1MB 在大未跟踪 diff 下会 ENOBUFS(见
|
|
534
|
+
* detectChanges 原始 bug "spawnSync git ENOBUFS")。失败时抛出含 git message 的
|
|
535
|
+
* Error,由调用方按单仓/子工程场景决定是整体失败还是记入 per-子工程说明。
|
|
536
|
+
*
|
|
537
|
+
* 动态 import('child_process') 与原 detectChanges 内联处一致——execFileSync 仅此
|
|
538
|
+
* 采集点使用,模块级缓存后无重复 spawn 开销。
|
|
539
|
+
*/
|
|
540
|
+
async function collectDetectChangesFileDiffs(diffCwd, diffArgs) {
|
|
541
|
+
const { execFileSync } = await import('child_process');
|
|
542
|
+
const output = execFileSync('git', diffArgs, {
|
|
543
|
+
cwd: diffCwd,
|
|
544
|
+
encoding: 'utf-8',
|
|
545
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
546
|
+
windowsHide: true,
|
|
547
|
+
});
|
|
548
|
+
return (0, git_js_1.parseDiffHunks)(output);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* 发现聚合根下的一级 git 子工程(spec §4 / T-13b)。
|
|
552
|
+
*
|
|
553
|
+
* 仅扫描 rootDir 的一级子目录,返回每个含 `.git` 条目(目录或 linked-worktree 的
|
|
554
|
+
* .git 文件)的子工程绝对路径,排序保证结果稳定(采集顺序确定)。用 hasGitDir 做
|
|
555
|
+
* 纯文件系统检查(不 spawn git),与 spec "一级子目录含 .git" 表述一致;隐藏目录
|
|
556
|
+
* (.cgraphx / .claude 等)跳过。
|
|
557
|
+
*
|
|
558
|
+
* 仅当 rootDir 自身非 git 仓库时由 detectChanges 调用——rootDir 是 git 仓库时走单仓
|
|
559
|
+
* 路径,不会进入此函数。导出以便测试直接覆盖发现逻辑。
|
|
560
|
+
*/
|
|
561
|
+
function discoverGitSubprojects(rootDir) {
|
|
562
|
+
let entries;
|
|
563
|
+
try {
|
|
564
|
+
entries = (0, fs_1.readdirSync)(rootDir, { withFileTypes: true });
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
const found = [];
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
// 仅一级子目录;隐藏目录(.cgraphx/.claude/.git 等)不是代码子工程。
|
|
572
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
573
|
+
continue;
|
|
574
|
+
const child = path_1.default.join(rootDir, entry.name);
|
|
575
|
+
if ((0, git_js_1.hasGitDir)(child))
|
|
576
|
+
found.push(child);
|
|
577
|
+
}
|
|
578
|
+
return found.sort(utils_js_1.compareCodeUnits);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* 逐子工程采集 git diff 并把 filePath 重写为相对聚合根(spec §4)。
|
|
582
|
+
*
|
|
583
|
+
* 索引里的 filePath 相对聚合根存储(filesystem-walker 以 repoPath 为基准,如
|
|
584
|
+
* `subA/src/foo.ts`),而 `git diff` 在子工程 subA 内输出相对 subA 的路径
|
|
585
|
+
* (`src/foo.ts`)。补 `<子工程相对根的目录>/<原路径>` 前缀后才能命中符号查询的
|
|
586
|
+
* `n.filePath ENDS WITH $filePath`,并使受影响符号自带子工程归属(可由路径推导)。
|
|
587
|
+
*
|
|
588
|
+
* 单个子工程失败(典型:compare 的 base-ref 在该子工程不存在)记入 errors 并继续,
|
|
589
|
+
* 不让一个子工程的缺 ref 拖垮整体;errors 随结果以 subproject_errors 带出。
|
|
590
|
+
* 顺序执行(v1 不并行优化,见任务注意事项)。
|
|
591
|
+
*/
|
|
592
|
+
async function collectSubprojectFileDiffs(aggregateRoot, subprojects, diffArgs) {
|
|
593
|
+
const fileDiffs = [];
|
|
594
|
+
const errors = [];
|
|
595
|
+
for (const sub of subprojects) {
|
|
596
|
+
try {
|
|
597
|
+
const diffs = await collectDetectChangesFileDiffs(sub, diffArgs);
|
|
598
|
+
const relPrefix = path_1.default.relative(aggregateRoot, sub).replace(/\\/g, '/');
|
|
599
|
+
for (const fd of diffs) {
|
|
600
|
+
fd.filePath = `${relPrefix}/${fd.filePath}`;
|
|
601
|
+
}
|
|
602
|
+
fileDiffs.push(...diffs);
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
errors.push({
|
|
606
|
+
path: path_1.default.relative(aggregateRoot, sub).replace(/\\/g, '/'),
|
|
607
|
+
error: e?.message ?? String(e),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return { fileDiffs, errors };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Length of the path-derived suffix appended to a colliding repo id.
|
|
615
|
+
* Exported so tests can pin the suffix shape without re-deriving the
|
|
616
|
+
* literal; see `assignRepoId()` and the hashed-id resolution tier (#1658).
|
|
617
|
+
*
|
|
618
|
+
* Note: base64url is an *encoding*, not a hash — it preserves byte order, so
|
|
619
|
+
* two paths that share a long common prefix (sibling clones under one parent)
|
|
620
|
+
* collapse to the same sliced suffix. `assignRepoId()` keeps the legacy
|
|
621
|
+
* base64url suffix only for the first colliding duplicate (id compatibility)
|
|
622
|
+
* and falls back to a content hash of the resolved path on a real collision
|
|
623
|
+
* (#2054).
|
|
624
|
+
*/
|
|
625
|
+
exports.REPO_ID_HASH_LENGTH = 6;
|
|
626
|
+
/**
|
|
627
|
+
* #2655: a tool result can carry a `staleness` field only if it is a plain
|
|
628
|
+
* object that isn't an error envelope and doesn't already carry one. Raw-array
|
|
629
|
+
* results (non-tabular `cypher` rows) are excluded because the CLI's `--limit`
|
|
630
|
+
* and other consumers branch on `Array.isArray`, so wrapping them would break
|
|
631
|
+
* that contract. Shared by `attachToolStaleness` and the dispatch site, which
|
|
632
|
+
* uses it to skip the freshness `git` spawn for results that can't carry it.
|
|
633
|
+
*/
|
|
634
|
+
function canCarryStaleness(result) {
|
|
635
|
+
return (result !== null &&
|
|
636
|
+
typeof result === 'object' &&
|
|
637
|
+
!Array.isArray(result) &&
|
|
638
|
+
!('error' in result) &&
|
|
639
|
+
!('staleness' in result));
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* #2655: attach a non-blocking `staleness` signal to a tool result when the
|
|
643
|
+
* index is behind HEAD, mirroring the `list_repos` `{commitsBehind, hint}`
|
|
644
|
+
* shape. Only ever ADDS a field to a carryable object result (see
|
|
645
|
+
* {@link canCarryStaleness}) — it never changes an existing result's shape.
|
|
646
|
+
*/
|
|
647
|
+
function attachToolStaleness(result, staleness) {
|
|
648
|
+
if (!staleness?.isStale || !canCarryStaleness(result)) {
|
|
649
|
+
return result;
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
...result,
|
|
653
|
+
staleness: { commitsBehind: staleness.commitsBehind, hint: staleness.hint },
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
class LocalBackend {
|
|
657
|
+
static TOOL_STALENESS_TTL_MS = 5000;
|
|
658
|
+
/**
|
|
659
|
+
* Explicit project root (`--path` / programmatic) that scopes cwd discovery
|
|
660
|
+
* (spec §8). When unset, discovery walks up from `process.cwd()`. Carried on
|
|
661
|
+
* the instance so every `refreshRepos()` re-read resolves the same root
|
|
662
|
+
* without callers threading it through each tool call.
|
|
663
|
+
*/
|
|
664
|
+
explicitProjectRoot;
|
|
665
|
+
repos = new Map();
|
|
666
|
+
contextCache = new Map();
|
|
667
|
+
initializedRepos = new Set();
|
|
668
|
+
reinitPromises = new Map();
|
|
669
|
+
lastStalenessCheck = new Map();
|
|
670
|
+
// #2655: commit-behind freshness for the hot read tools. Stores the IN-FLIGHT
|
|
671
|
+
// promise (not just a timestamp) so N concurrent tool calls arriving before
|
|
672
|
+
// the first `git rev-list` resolves share one subprocess instead of each
|
|
673
|
+
// spawning their own; the resolved value is reused for TOOL_STALENESS_TTL_MS.
|
|
674
|
+
// Keyed by lbugPath (like lastStalenessCheck) — NOT repoPath — because flat
|
|
675
|
+
// and branch handles for one repo share a repoPath but carry different
|
|
676
|
+
// lastCommit values, so a repoPath key would serve one handle's freshness for
|
|
677
|
+
// the other; lbugPath is unique per flat/branch index.
|
|
678
|
+
toolStalenessCache = new Map();
|
|
679
|
+
// tri-review Residual-2: consolidates what were parallel per-poolKey Maps
|
|
680
|
+
// (lastObservedIndexedAt / lastObservedDbIdentity) touched in lockstep at
|
|
681
|
+
// every call site below — one Map, one delete, one shape. Keyed by lbugPath
|
|
682
|
+
// (not stored on the repo handle) because branch handles are produced fresh
|
|
683
|
+
// by applyBranchScope on every resolveRepo call, so mutating the handle would
|
|
684
|
+
// not persist across calls and the staleness check would reinit forever (#2106).
|
|
685
|
+
// - `indexedAt`: last meta.indexedAt observed for an open pool.
|
|
686
|
+
// - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1)
|
|
687
|
+
// — an atomic swap or in-place incremental changes the inode; reiniting
|
|
688
|
+
// on that covers the window where meta.indexedAt hasn't caught up (and
|
|
689
|
+
// the incremental case), so a rebuilt index is never served stale even
|
|
690
|
+
// when the stamp looks current.
|
|
691
|
+
lastObservedPoolState = new Map();
|
|
692
|
+
/** Merge-patch one poolKey's observed state, preserving fields not passed. */
|
|
693
|
+
setObservedState(poolKey, patch) {
|
|
694
|
+
const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null };
|
|
695
|
+
this.lastObservedPoolState.set(poolKey, { ...current, ...patch });
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* One-shot stderr warnings for sibling-clone drift, keyed by
|
|
699
|
+
* `${repoId}|${cwdGitRoot}`. Without this guard every tool call
|
|
700
|
+
* from inside a sibling clone would print the same warning,
|
|
701
|
+
* making MCP stderr unreadable.
|
|
702
|
+
*/
|
|
703
|
+
warnedSiblingDrift = new Set();
|
|
704
|
+
/**
|
|
705
|
+
* @param opts.projectRoot explicit project root overriding cwd discovery
|
|
706
|
+
* (threaded from the CLI `--path` flag). Library consumers may omit it to
|
|
707
|
+
* use the default cwd-based discovery.
|
|
708
|
+
*/
|
|
709
|
+
constructor(opts) {
|
|
710
|
+
this.explicitProjectRoot = opts?.projectRoot;
|
|
711
|
+
}
|
|
712
|
+
/** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */
|
|
713
|
+
async dispose() {
|
|
714
|
+
await (0, pool_adapter_js_1.closeLbug)();
|
|
715
|
+
}
|
|
716
|
+
// ─── Initialization ──────────────────────────────────────────────
|
|
717
|
+
/**
|
|
718
|
+
* Initialize from the discovered project root (cwd-based, or the explicit
|
|
719
|
+
* `--path` / `CGRAPH_REPO` override). Returns true if an index is available.
|
|
720
|
+
*/
|
|
721
|
+
async init() {
|
|
722
|
+
await this.refreshRepos();
|
|
723
|
+
return this.repos.size > 0;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Re-discover the project-root index and update the in-memory repo map.
|
|
727
|
+
* With the global registry removed (spec §8), this resolves at most ONE repo
|
|
728
|
+
* via cwd discovery (or the explicit `--path` / `CGRAPH_REPO` override) and
|
|
729
|
+
* swaps it in atomically. LadybugDB connections for a previously-resolved
|
|
730
|
+
* repo that no longer resolves are pruned (they idle-timeout naturally).
|
|
731
|
+
*/
|
|
732
|
+
async refreshRepos() {
|
|
733
|
+
const entries = await (0, repo_manager_js_1.listRegisteredRepos)({
|
|
734
|
+
validate: true,
|
|
735
|
+
explicitPath: this.explicitProjectRoot,
|
|
736
|
+
});
|
|
737
|
+
// Build the next map from scratch and swap it in atomically. Mutating the
|
|
738
|
+
// live map in place let stale entries influence fresh id assignment: a
|
|
739
|
+
// bare-name id, once handed to the first registry entry, stuck to it across
|
|
740
|
+
// refreshes and reorders, and colliding path suffixes silently overwrote
|
|
741
|
+
// each other so sibling clones disappeared from `list_repos` (#2054).
|
|
742
|
+
const nextRepos = new Map();
|
|
743
|
+
const nextContext = new Map();
|
|
744
|
+
const assigned = new Map(); // id -> resolved repo path
|
|
745
|
+
// Assign ids over a path-sorted view so a registered clone always gets the
|
|
746
|
+
// same id regardless of the registry's on-disk order: the bare name and
|
|
747
|
+
// each path-derived suffix become a pure function of the resolved-path set,
|
|
748
|
+
// not of iteration order, so a memorized id can't drift to a different
|
|
749
|
+
// clone after a registry reorder (#2067 follow-up).
|
|
750
|
+
const ordered = [...entries].sort((a, b) => {
|
|
751
|
+
const ra = path_1.default.resolve(a.path);
|
|
752
|
+
const rb = path_1.default.resolve(b.path);
|
|
753
|
+
return ra < rb ? -1 : ra > rb ? 1 : 0;
|
|
754
|
+
});
|
|
755
|
+
for (const entry of ordered) {
|
|
756
|
+
// path.resolve (not canonicalizePath) matches the pre-#2054 collision
|
|
757
|
+
// check and keeps refreshRepos free of mockable deps on the hot init
|
|
758
|
+
// path. registerRepo writes path.resolve'd paths (not realpath), and
|
|
759
|
+
// resolveRepoFromCache canonicalizes both sides when matching by path, so
|
|
760
|
+
// keying id assignment on path.resolve here is consistent and correct.
|
|
761
|
+
const resolved = path_1.default.resolve(entry.path);
|
|
762
|
+
const id = this.assignRepoId(entry.name, entry.path, resolved, assigned);
|
|
763
|
+
const storagePath = entry.storagePath;
|
|
764
|
+
const lbugPath = path_1.default.join(storagePath, 'lbug');
|
|
765
|
+
// Clean up any leftover KuzuDB files from before the LadybugDB migration.
|
|
766
|
+
// If kuzu exists but lbug doesn't, warn so the user knows to re-analyze.
|
|
767
|
+
const kuzu = await (0, repo_manager_js_1.cleanupOldKuzuFiles)(storagePath);
|
|
768
|
+
if (kuzu.found && kuzu.needsReindex) {
|
|
769
|
+
logger_js_1.logger.error(`GitNexus: "${entry.name}" has a stale KuzuDB index. Run: cgraph analyze ${entry.path}`);
|
|
770
|
+
}
|
|
771
|
+
const handle = {
|
|
772
|
+
id,
|
|
773
|
+
name: entry.name,
|
|
774
|
+
repoPath: entry.path,
|
|
775
|
+
storagePath,
|
|
776
|
+
lbugPath,
|
|
777
|
+
indexedAt: entry.indexedAt,
|
|
778
|
+
lastCommit: entry.lastCommit,
|
|
779
|
+
remoteUrl: entry.remoteUrl,
|
|
780
|
+
stats: entry.stats,
|
|
781
|
+
branch: entry.branch,
|
|
782
|
+
branches: entry.branches,
|
|
783
|
+
};
|
|
784
|
+
nextRepos.set(id, handle);
|
|
785
|
+
// Build lightweight context (no LadybugDB needed)
|
|
786
|
+
const s = entry.stats || {};
|
|
787
|
+
nextContext.set(id, {
|
|
788
|
+
projectName: entry.name,
|
|
789
|
+
stats: {
|
|
790
|
+
fileCount: s.files || 0,
|
|
791
|
+
functionCount: s.nodes || 0,
|
|
792
|
+
communityCount: s.communities || 0,
|
|
793
|
+
processCount: s.processes || 0,
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
// Prune per-clone pool state for databases that are no longer registered.
|
|
798
|
+
// The LadybugDB pool and the init/staleness/reinit maps are keyed by the
|
|
799
|
+
// immutable lbugPath (see ensureInitialized), so a repo id that merely
|
|
800
|
+
// moves to a different clone needs NO eviction — distinct clones have
|
|
801
|
+
// distinct lbugPaths and can never share a pool entry, which is what closes
|
|
802
|
+
// the resolve→query wrong-clone window for good (#2067). Only a path that
|
|
803
|
+
// dropped out of the registry must release its pooled connection + state.
|
|
804
|
+
const liveLbugPaths = new Set([...nextRepos.values()].map((h) => h.lbugPath));
|
|
805
|
+
// Branch pools (opened on demand by applyBranchScope) are NOT in this.repos
|
|
806
|
+
// — branch handles are minted fresh and discarded — so add every registered
|
|
807
|
+
// branch's lbugPath to the live set. Pure string work over the already-in-
|
|
808
|
+
// memory registry snapshot; no disk I/O on this hot path (#2106 R3).
|
|
809
|
+
for (const entry of entries) {
|
|
810
|
+
for (const b of entry.branches ?? []) {
|
|
811
|
+
liveLbugPaths.add((0, repo_manager_js_1.getStoragePaths)(entry.path, b.branch).lbugPath);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// initializedRepos is the authoritative set of OPENED pool keys (flat AND
|
|
815
|
+
// branch); union it with the previously-known flat handles so an orphaned
|
|
816
|
+
// branch pool (e.g. after `clean --branch` removes its summary) is closed
|
|
817
|
+
// and forgotten too, not just flat handles.
|
|
818
|
+
const knownKeys = new Set([
|
|
819
|
+
...[...this.repos.values()].map((h) => h.lbugPath),
|
|
820
|
+
...this.initializedRepos,
|
|
821
|
+
]);
|
|
822
|
+
for (const key of knownKeys) {
|
|
823
|
+
if (liveLbugPaths.has(key))
|
|
824
|
+
continue;
|
|
825
|
+
this.initializedRepos.delete(key);
|
|
826
|
+
this.lastStalenessCheck.delete(key);
|
|
827
|
+
this.toolStalenessCache.delete(key);
|
|
828
|
+
this.lastObservedPoolState.delete(key);
|
|
829
|
+
this.reinitPromises.delete(key);
|
|
830
|
+
(0, pool_adapter_js_1.closeLbug)(key).catch(() => { });
|
|
831
|
+
}
|
|
832
|
+
this.repos = nextRepos;
|
|
833
|
+
this.contextCache = nextContext;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Assign a collision-free in-memory id for a registered repo.
|
|
837
|
+
*
|
|
838
|
+
* - Unique name → the bare lowercased name.
|
|
839
|
+
* - Duplicate name → a path-derived suffix. The *first* colliding clone keeps
|
|
840
|
+
* the legacy `base64url(path)` suffix so ids generated before #2054 still
|
|
841
|
+
* resolve (the #1658 hashed-id tier). base64url is an encoding, not a hash:
|
|
842
|
+
* it preserves byte order, so sibling clones under one parent (e.g.
|
|
843
|
+
* `.../REPO_2` and `.../REPO_3`) yield identical leading characters and thus
|
|
844
|
+
* the same sliced suffix. Any further collision therefore falls back to a
|
|
845
|
+
* content hash of the *resolved* path (order-insensitive), extended
|
|
846
|
+
* deterministically until unique.
|
|
847
|
+
*
|
|
848
|
+
* `assigned` maps every id handed out in this refresh to its resolved path,
|
|
849
|
+
* so a candidate is "free" when it is unused or already owned by this exact
|
|
850
|
+
* path. This method records its own assignment into `assigned` before
|
|
851
|
+
* returning, so the map-update is the function's invariant, not a caller
|
|
852
|
+
* obligation. A returned id never overwrites a different path's handle (#2054).
|
|
853
|
+
*/
|
|
854
|
+
assignRepoId(name, repoPath, resolved, assigned) {
|
|
855
|
+
const base = name.toLowerCase();
|
|
856
|
+
const free = (id) => {
|
|
857
|
+
const owner = assigned.get(id);
|
|
858
|
+
return owner === undefined || owner === resolved;
|
|
859
|
+
};
|
|
860
|
+
// Record the assignment so subsequent entries in the same refresh see this
|
|
861
|
+
// id as taken (the function owns its own invariant).
|
|
862
|
+
const claim = (id) => {
|
|
863
|
+
assigned.set(id, resolved);
|
|
864
|
+
return id;
|
|
865
|
+
};
|
|
866
|
+
if (free(base))
|
|
867
|
+
return claim(base);
|
|
868
|
+
// Legacy suffix from the *raw* path — kept byte-for-byte so the first
|
|
869
|
+
// colliding duplicate keeps the id it had before #2054 (#1658 tier).
|
|
870
|
+
const legacy = `${base}-${Buffer.from(repoPath)
|
|
871
|
+
.toString('base64url')
|
|
872
|
+
.slice(0, exports.REPO_ID_HASH_LENGTH)
|
|
873
|
+
.toLowerCase()}`;
|
|
874
|
+
if (free(legacy))
|
|
875
|
+
return claim(legacy);
|
|
876
|
+
// Real collision — hash the resolved path. Lowercase hex survives the
|
|
877
|
+
// `paramLower` lookup in resolveRepoFromCache.
|
|
878
|
+
const digest = (0, crypto_1.createHash)('sha256').update(resolved).digest('hex');
|
|
879
|
+
for (let len = exports.REPO_ID_HASH_LENGTH; len <= digest.length; len++) {
|
|
880
|
+
const candidate = `${base}-${digest.slice(0, len)}`;
|
|
881
|
+
if (free(candidate))
|
|
882
|
+
return claim(candidate);
|
|
883
|
+
}
|
|
884
|
+
// Two distinct resolved paths sharing a full SHA-256 digest is a hash
|
|
885
|
+
// break, not a runtime condition — fail loudly rather than silently
|
|
886
|
+
// overwrite a different repo's handle (#2054 invariant).
|
|
887
|
+
throw new Error(`GitNexus internal: unable to assign a unique repo id for "${name}" at ${repoPath}`);
|
|
888
|
+
}
|
|
889
|
+
// ─── Repo Resolution ─────────────────────────────────────────────
|
|
890
|
+
/**
|
|
891
|
+
* Resolve which repo to use.
|
|
892
|
+
* - If repoParam is given, match by name or path
|
|
893
|
+
* - If only 1 repo, use it
|
|
894
|
+
* - If 0 or multiple without param, throw with helpful message
|
|
895
|
+
*
|
|
896
|
+
* On a miss, re-reads the registry once in case a new repo was indexed
|
|
897
|
+
* while the MCP server was running.
|
|
898
|
+
*/
|
|
899
|
+
async resolveRepo(repoParam, branch) {
|
|
900
|
+
let refreshedAfterAmbiguity = false;
|
|
901
|
+
let result;
|
|
902
|
+
try {
|
|
903
|
+
result = this.resolveRepoFromCache(repoParam);
|
|
904
|
+
}
|
|
905
|
+
catch (err) {
|
|
906
|
+
if (!(err instanceof repo_manager_js_1.RegistryAmbiguousTargetError))
|
|
907
|
+
throw err;
|
|
908
|
+
// Stale in-memory duplicate siblings can linger after unregister; refresh
|
|
909
|
+
// once before re-throwing so a resolved registry can disambiguate (#1658).
|
|
910
|
+
await this.refreshRepos();
|
|
911
|
+
refreshedAfterAmbiguity = true;
|
|
912
|
+
result = this.resolveRepoFromCache(repoParam);
|
|
913
|
+
}
|
|
914
|
+
if (result) {
|
|
915
|
+
// Issue: silent graph drift across sibling clones.
|
|
916
|
+
// If the caller's cwd lives in a *different* on-disk clone of
|
|
917
|
+
// the same repo (matched by `remoteUrl`), warn once per
|
|
918
|
+
// (repo, cwd) pair on stderr. We do not fail or refuse to
|
|
919
|
+
// serve — the index is still the best answer we have — but
|
|
920
|
+
// the operator/agent has to know the answer may be stale.
|
|
921
|
+
this.maybeWarnSiblingDrift(result).catch(() => {
|
|
922
|
+
/* best-effort; never throw from resolveRepo */
|
|
923
|
+
});
|
|
924
|
+
return this.applyBranchScope(result, branch);
|
|
925
|
+
}
|
|
926
|
+
// Miss — refresh registry and try once more (skip if already refreshed above)
|
|
927
|
+
if (!refreshedAfterAmbiguity) {
|
|
928
|
+
await this.refreshRepos();
|
|
929
|
+
}
|
|
930
|
+
const retried = this.resolveRepoFromCache(repoParam);
|
|
931
|
+
if (retried) {
|
|
932
|
+
this.maybeWarnSiblingDrift(retried).catch(() => { });
|
|
933
|
+
return this.applyBranchScope(retried, branch);
|
|
934
|
+
}
|
|
935
|
+
// Still no match — throw with helpful message
|
|
936
|
+
if (this.repos.size === 0) {
|
|
937
|
+
throw new Error('No indexed repositories. Run: cgraph analyze');
|
|
938
|
+
}
|
|
939
|
+
// Build a disambiguated "Available: …" list (#829). When two handles
|
|
940
|
+
// share a name, annotate each colliding label with its path so the
|
|
941
|
+
// caller can actually pick the right one. Single-name entries render
|
|
942
|
+
// identically to pre-#829 output.
|
|
943
|
+
const nameCounts = new Map();
|
|
944
|
+
for (const h of this.repos.values()) {
|
|
945
|
+
const key = h.name.toLowerCase();
|
|
946
|
+
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
|
|
947
|
+
}
|
|
948
|
+
const labels = [...this.repos.values()].map((h) => (nameCounts.get(h.name.toLowerCase()) ?? 0) > 1 ? `${h.name} (${h.repoPath})` : h.name);
|
|
949
|
+
if (repoParam) {
|
|
950
|
+
throw new Error(`Repository "${repoParam}" not found. Available: ${labels.join(', ')}`);
|
|
951
|
+
}
|
|
952
|
+
throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Re-point a resolved repo handle at a specific branch index (#2106).
|
|
956
|
+
*
|
|
957
|
+
* - No `branch` (default) → the flat workspace handle, unchanged (backward
|
|
958
|
+
* compatible: every existing caller passes no branch).
|
|
959
|
+
* - `branch` equal to the flat slot's **on-disk** recorded branch → the
|
|
960
|
+
* flat handle. The disk meta is read before any cached state is trusted
|
|
961
|
+
* (#2364 review F1): the flat slot follows the checked-out working tree
|
|
962
|
+
* (#2354), so a plain analyze after a branch switch restamps the meta
|
|
963
|
+
* without any repo-resolution miss that would refresh a long-lived
|
|
964
|
+
* server's cached handle — the cached label can otherwise serve another
|
|
965
|
+
* branch's content under the old name (the pool staleness reinit
|
|
966
|
+
* hot-swaps content without updating `handle.branch`).
|
|
967
|
+
* - `branch` matching an indexed pinned branch → a handle whose
|
|
968
|
+
* `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
|
|
969
|
+
* `lbugPath`, so this is the only change needed to scope every tool. The
|
|
970
|
+
* sub-index lbug must actually exist on disk — `adoptFlatBranchLabel`
|
|
971
|
+
* deletes the whole dir when the flat slot takes ownership, and a stale
|
|
972
|
+
* cached summary must not route to the deleted path.
|
|
973
|
+
* - Cached `handle.branch` is trusted only when there is no readable flat
|
|
974
|
+
* meta to contradict it (legacy shapes, #2106 R4).
|
|
975
|
+
* - Any miss → a clear error (never a silently-empty result against the
|
|
976
|
+
* wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
|
|
977
|
+
* and restamped labels the cached handle predates resolve on the next
|
|
978
|
+
* call.
|
|
979
|
+
*/
|
|
980
|
+
async applyBranchScope(handle, branch) {
|
|
981
|
+
if (!branch)
|
|
982
|
+
return handle;
|
|
983
|
+
// At most one cache refresh per resolution: enough for the NEXT call to
|
|
984
|
+
// see fresh handles, without paying two registry re-scans when several
|
|
985
|
+
// stale arms fire in one degraded resolution.
|
|
986
|
+
let refreshed = false;
|
|
987
|
+
const refreshOnce = async () => {
|
|
988
|
+
if (refreshed)
|
|
989
|
+
return;
|
|
990
|
+
refreshed = true;
|
|
991
|
+
await this.refreshRepos().catch(() => { });
|
|
992
|
+
};
|
|
993
|
+
// One small JSON read per scoped call; mid-run meta writes preserve the
|
|
994
|
+
// old label until the end-of-run atomic stamp (run-analyze dirty stamps
|
|
995
|
+
// spread the existing meta), so this read never runs ahead of the DB.
|
|
996
|
+
const flatMeta = await (0, repo_manager_js_1.loadMeta)(path_1.default.dirname(handle.lbugPath));
|
|
997
|
+
if (flatMeta?.branch && flatMeta.branch === branch) {
|
|
998
|
+
// The disk meta decides routing, so it also supplies the metadata —
|
|
999
|
+
// the cached handle's label/commit/stats can predate the restamp.
|
|
1000
|
+
return {
|
|
1001
|
+
...handle,
|
|
1002
|
+
branch: flatMeta.branch,
|
|
1003
|
+
indexedAt: flatMeta.indexedAt ?? handle.indexedAt,
|
|
1004
|
+
lastCommit: flatMeta.lastCommit ?? handle.lastCommit,
|
|
1005
|
+
stats: flatMeta.stats ?? handle.stats,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
// A registry entry claiming `branch` both as the flat label AND as a
|
|
1009
|
+
// pinned summary is an adopt-degraded state (rm kept the summary while
|
|
1010
|
+
// the label restamped) — never serve the possibly stale-vintage pin for
|
|
1011
|
+
// a label the flat slot claims; fall through to the honest error.
|
|
1012
|
+
const summary = handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined;
|
|
1013
|
+
if (summary) {
|
|
1014
|
+
const { lbugPath } = (0, repo_manager_js_1.getStoragePaths)(handle.repoPath, branch);
|
|
1015
|
+
// The lbug is the artifact the pool opens, so its presence is the
|
|
1016
|
+
// serviceability truth — a half-deleted dir can outlive its meta.json
|
|
1017
|
+
// while the lbug is gone, and vice versa (#2364 review F1 arm ii).
|
|
1018
|
+
// Only provably-absent errno counts as missing: a transient EACCES/EIO
|
|
1019
|
+
// on a healthy pinned sub-index must serve the handle (the pool open
|
|
1020
|
+
// surfaces the real error) rather than a false "not indexed".
|
|
1021
|
+
const probeCode = await promises_1.default.access(lbugPath).then(() => null, (e) => e?.code ?? 'UNKNOWN');
|
|
1022
|
+
const subIndexMissing = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
|
|
1023
|
+
if (!subIndexMissing) {
|
|
1024
|
+
return {
|
|
1025
|
+
...handle,
|
|
1026
|
+
lbugPath,
|
|
1027
|
+
indexedAt: summary.indexedAt,
|
|
1028
|
+
lastCommit: summary.lastCommit,
|
|
1029
|
+
stats: summary.stats,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
// Stale summary (sub-index adopted/deleted): refresh so later calls see
|
|
1033
|
+
// fresh handles, then fall through — the flat meta above is the truth.
|
|
1034
|
+
await refreshOnce();
|
|
1035
|
+
}
|
|
1036
|
+
if (handle.branch && handle.branch === branch) {
|
|
1037
|
+
// No readable flat meta (missing/corrupt — loadMeta → null): keep the
|
|
1038
|
+
// pre-#2354 trust in the cached label (#2106 R4 legacy shapes). A
|
|
1039
|
+
// readable meta that names another branch means the label is stale.
|
|
1040
|
+
if (!flatMeta?.branch)
|
|
1041
|
+
return handle;
|
|
1042
|
+
}
|
|
1043
|
+
// Every miss refreshes once before erroring: newly-pinned branches and
|
|
1044
|
+
// restamped labels the cached handle predates become resolvable on the
|
|
1045
|
+
// caller's next attempt (the cache otherwise only refreshes on repo-
|
|
1046
|
+
// resolution misses and list_repos).
|
|
1047
|
+
await refreshOnce();
|
|
1048
|
+
// The flat slot's label comes from the authoritative meta when readable —
|
|
1049
|
+
// never echo a cached label the meta just contradicted (a "not indexed:
|
|
1050
|
+
// main / indexed: main" self-contradiction). Cached summaries may still
|
|
1051
|
+
// lag; they are a hint, not a promise.
|
|
1052
|
+
const flatLabel = flatMeta?.branch ?? handle.branch;
|
|
1053
|
+
const indexed = [flatLabel, ...(handle.branches?.map((b) => b.branch) ?? [])].filter((b) => Boolean(b) && b !== branch);
|
|
1054
|
+
const available = indexed.length > 0 ? indexed.join(', ') : '(workspace only)';
|
|
1055
|
+
// Post-#2354 a bare `analyze --branch <X>` refuses to run unless X is
|
|
1056
|
+
// checked out, so the guidance must lead with the checkout (#2364 F6).
|
|
1057
|
+
throw new Error(`Branch "${branch}" is not indexed for "${handle.name}". ` +
|
|
1058
|
+
`Indexed branches: ${available}. The workspace index follows the ` +
|
|
1059
|
+
`checked-out branch — check out "${branch}" and re-run: cgraph analyze ` +
|
|
1060
|
+
`(add --branch ${branch} while it is checked out to pin a separate sub-index).`);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Try to resolve a repo from the in-memory cache. Returns null on miss.
|
|
1064
|
+
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
|
|
1065
|
+
* multiple handles by name and cwd cannot disambiguate (#1658).
|
|
1066
|
+
*/
|
|
1067
|
+
resolveRepoFromCache(repoParam) {
|
|
1068
|
+
if (this.repos.size === 0)
|
|
1069
|
+
return null;
|
|
1070
|
+
if (repoParam) {
|
|
1071
|
+
const paramLower = repoParam.toLowerCase();
|
|
1072
|
+
const looksLikePath = path_1.default.isAbsolute(repoParam) || repoParam.includes(path_1.default.sep) || repoParam.includes('/');
|
|
1073
|
+
const resolvePathMatch = () => {
|
|
1074
|
+
const canonicalTarget = (0, repo_manager_js_1.canonicalizePath)(repoParam);
|
|
1075
|
+
return [...this.repos.values()].find((handle) => {
|
|
1076
|
+
const stored = (0, repo_manager_js_1.canonicalizePath)(handle.repoPath);
|
|
1077
|
+
return process.platform === 'win32'
|
|
1078
|
+
? stored.toLowerCase() === canonicalTarget.toLowerCase()
|
|
1079
|
+
: stored === canonicalTarget;
|
|
1080
|
+
});
|
|
1081
|
+
};
|
|
1082
|
+
// Path-like params first (absolute or contains separators) — aligns with
|
|
1083
|
+
// resolveRegistryEntry (#829). Bare aliases such as ".tmp-repro-mini" must
|
|
1084
|
+
// not be resolved via path.resolve(cwd) before duplicate-name handling.
|
|
1085
|
+
if (looksLikePath) {
|
|
1086
|
+
const pathMatch = resolvePathMatch();
|
|
1087
|
+
if (pathMatch)
|
|
1088
|
+
return pathMatch;
|
|
1089
|
+
}
|
|
1090
|
+
// Exact name before id — the first duplicate sibling keeps id === name
|
|
1091
|
+
// (e.g. id "shared"), so a name lookup must not be captured by the id tier.
|
|
1092
|
+
const nameMatches = [...this.repos.values()].filter((handle) => handle.name.toLowerCase() === paramLower);
|
|
1093
|
+
if (nameMatches.length === 1)
|
|
1094
|
+
return nameMatches[0];
|
|
1095
|
+
if (nameMatches.length > 1) {
|
|
1096
|
+
const cwdPick = this.pickRepoHandleForCwd(nameMatches);
|
|
1097
|
+
if (cwdPick)
|
|
1098
|
+
return cwdPick;
|
|
1099
|
+
throw new repo_manager_js_1.RegistryAmbiguousTargetError(repoParam, nameMatches.map((h) => this.handleToRegistryEntry(h)));
|
|
1100
|
+
}
|
|
1101
|
+
// Stable hashed id (e.g. "shared-abc123") from repoId() collision suffix
|
|
1102
|
+
if (this.repos.has(paramLower))
|
|
1103
|
+
return this.repos.get(paramLower);
|
|
1104
|
+
// Bare name resolved as a cwd-relative path (e.g. "myrepo" against process.cwd()),
|
|
1105
|
+
// after name/id tiers. Path-like strings with separators were handled at the top.
|
|
1106
|
+
if (!looksLikePath) {
|
|
1107
|
+
const pathMatch = resolvePathMatch();
|
|
1108
|
+
if (pathMatch)
|
|
1109
|
+
return pathMatch;
|
|
1110
|
+
}
|
|
1111
|
+
// Partial name — only when unambiguous
|
|
1112
|
+
const partialMatches = [...this.repos.values()].filter((handle) => handle.name.toLowerCase().includes(paramLower));
|
|
1113
|
+
if (partialMatches.length === 1)
|
|
1114
|
+
return partialMatches[0];
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
if (this.repos.size === 1) {
|
|
1118
|
+
return this.repos.values().next().value;
|
|
1119
|
+
}
|
|
1120
|
+
return null; // Multiple repos, no param — ambiguous
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Prefer the indexed repo whose path matches the git root of process.cwd().
|
|
1124
|
+
*
|
|
1125
|
+
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
|
|
1126
|
+
* not the agent client's cwd. If the server was started from an unrelated
|
|
1127
|
+
* directory, `getGitRoot` returns null and duplicate-name resolution throws
|
|
1128
|
+
* {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
|
|
1129
|
+
*/
|
|
1130
|
+
pickRepoHandleForCwd(candidates) {
|
|
1131
|
+
const cwdRoot = (0, git_js_1.getGitRoot)(process.cwd());
|
|
1132
|
+
if (!cwdRoot)
|
|
1133
|
+
return null;
|
|
1134
|
+
const canonicalCwd = (0, repo_manager_js_1.canonicalizePath)(cwdRoot);
|
|
1135
|
+
const cwdMatches = candidates.filter((handle) => {
|
|
1136
|
+
const stored = (0, repo_manager_js_1.canonicalizePath)(handle.repoPath);
|
|
1137
|
+
return process.platform === 'win32'
|
|
1138
|
+
? stored.toLowerCase() === canonicalCwd.toLowerCase()
|
|
1139
|
+
: stored === canonicalCwd;
|
|
1140
|
+
});
|
|
1141
|
+
return cwdMatches.length === 1 ? cwdMatches[0] : null;
|
|
1142
|
+
}
|
|
1143
|
+
handleToRegistryEntry(handle) {
|
|
1144
|
+
return {
|
|
1145
|
+
name: handle.name,
|
|
1146
|
+
path: handle.repoPath,
|
|
1147
|
+
storagePath: handle.storagePath,
|
|
1148
|
+
indexedAt: handle.indexedAt,
|
|
1149
|
+
lastCommit: handle.lastCommit,
|
|
1150
|
+
stats: handle.stats,
|
|
1151
|
+
remoteUrl: handle.remoteUrl,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
// ─── Lazy LadybugDB Init ────────────────────────────────────────────
|
|
1155
|
+
/**
|
|
1156
|
+
* Ensure the LadybugDB pool is open for the *resolved* repo.
|
|
1157
|
+
*
|
|
1158
|
+
* Takes the `RepoHandle` the caller resolved — NOT a bare id — and keys the
|
|
1159
|
+
* pool (and the init/staleness/reinit maps) by the immutable `lbugPath`. Two
|
|
1160
|
+
* things matter for multi-clone correctness: (1) the handle is the one the
|
|
1161
|
+
* caller resolved, so a concurrent `refreshRepos` can't substitute a different
|
|
1162
|
+
* clone; (2) the pool key is the database path, so distinct clones never share
|
|
1163
|
+
* a pool entry even when their name-derived id transiently collides (#2067).
|
|
1164
|
+
*/
|
|
1165
|
+
async ensureInitialized(repo) {
|
|
1166
|
+
const poolKey = repo.lbugPath;
|
|
1167
|
+
// If a reinit is already in progress for this repo, wait for it
|
|
1168
|
+
const pending = this.reinitPromises.get(poolKey);
|
|
1169
|
+
if (pending)
|
|
1170
|
+
return pending;
|
|
1171
|
+
// Check if the index was rebuilt since we opened the connection (#297).
|
|
1172
|
+
// Throttle staleness checks to at most once per 5 seconds per repo to
|
|
1173
|
+
// avoid an fs.readFile round-trip on every tool invocation.
|
|
1174
|
+
if (this.initializedRepos.has(poolKey) && (0, pool_adapter_js_1.isLbugReady)(poolKey)) {
|
|
1175
|
+
const now = Date.now();
|
|
1176
|
+
const lastCheck = this.lastStalenessCheck.get(poolKey) ?? 0;
|
|
1177
|
+
if (now - lastCheck < 5000)
|
|
1178
|
+
return; // Checked recently — skip
|
|
1179
|
+
this.lastStalenessCheck.set(poolKey, now);
|
|
1180
|
+
try {
|
|
1181
|
+
// Read the metadata that sits next to THIS handle's lbug. For the
|
|
1182
|
+
// flat/primary handle this is `<storagePath>/cgraph.json`; for a
|
|
1183
|
+
// branch handle it is `<storagePath>/branches/<slug>/cgraph.json`.
|
|
1184
|
+
// loadMeta falls back to legacy meta.json during migration.
|
|
1185
|
+
// Reading the flat meta for a branch handle would compare the branch
|
|
1186
|
+
// index's indexedAt against the primary's and thrash the pool (#2106).
|
|
1187
|
+
const meta = await (0, repo_manager_js_1.loadMeta)(path_1.default.dirname(repo.lbugPath));
|
|
1188
|
+
const observedState = this.lastObservedPoolState.get(poolKey);
|
|
1189
|
+
// Compare against the last indexedAt OBSERVED for this pool (keyed by
|
|
1190
|
+
// lbugPath), not the handle's — branch handles are fresh spreads so a
|
|
1191
|
+
// handle mutation would not persist and would reinit on every check.
|
|
1192
|
+
const observed = observedState?.indexedAt ?? repo.indexedAt;
|
|
1193
|
+
const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed;
|
|
1194
|
+
// #2614 F1: also reinit on a file-identity change. An atomic swap (or an
|
|
1195
|
+
// in-place incremental) changes the lbug inode; keying only on
|
|
1196
|
+
// meta.indexedAt let a reader that reinited inside the pre-swap window
|
|
1197
|
+
// latch on the old inode forever (its stamp already == meta.indexedAt).
|
|
1198
|
+
const currentIdentity = await (0, pool_adapter_js_1.statDbIdentity)(repo.lbugPath);
|
|
1199
|
+
const identityChanged = (0, pool_adapter_js_1.dbIdentityChanged)(observedState?.dbIdentity ?? null, currentIdentity);
|
|
1200
|
+
if (stampChanged || identityChanged) {
|
|
1201
|
+
// Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's
|
|
1202
|
+
// initLbug, which refuses to evict (and close the shared Database)
|
|
1203
|
+
// while a query is in flight (its checkedOut>0 guard). Calling
|
|
1204
|
+
// closeLbug directly here bypassed that guard and could close a
|
|
1205
|
+
// Database mid-query — a native use-after-free (#2614). Wrap in
|
|
1206
|
+
// reinitPromises to serialize concurrent detectors.
|
|
1207
|
+
const reinit = (async () => {
|
|
1208
|
+
try {
|
|
1209
|
+
const reopened = await (0, pool_adapter_js_1.initLbug)(poolKey, repo.lbugPath);
|
|
1210
|
+
// tri-review NEW-7: advance the observed stamp watermark only
|
|
1211
|
+
// AFTER initLbug completes, not before calling it — still
|
|
1212
|
+
// regardless of `reopened` true/false (a stamp change with an
|
|
1213
|
+
// unchanged file must not re-trigger on every check), but if
|
|
1214
|
+
// initLbug THROWS the watermark must stay at its old value so
|
|
1215
|
+
// the next staleness check retries, instead of a failed reinit
|
|
1216
|
+
// silently latching as "already applied" and never trying again.
|
|
1217
|
+
const patch = {};
|
|
1218
|
+
if (meta?.indexedAt)
|
|
1219
|
+
patch.indexedAt = meta.indexedAt;
|
|
1220
|
+
// Advance the observed IDENTITY only when the pool actually rolled
|
|
1221
|
+
// over. If a query was in flight, initLbug served the current
|
|
1222
|
+
// handle and returned false; leaving the identity divergent
|
|
1223
|
+
// re-triggers the reopen on a later idle check instead of latching.
|
|
1224
|
+
if (reopened)
|
|
1225
|
+
patch.dbIdentity = await (0, pool_adapter_js_1.statDbIdentity)(repo.lbugPath);
|
|
1226
|
+
this.setObservedState(poolKey, patch);
|
|
1227
|
+
}
|
|
1228
|
+
finally {
|
|
1229
|
+
this.reinitPromises.delete(poolKey);
|
|
1230
|
+
}
|
|
1231
|
+
})();
|
|
1232
|
+
this.reinitPromises.set(poolKey, reinit);
|
|
1233
|
+
return reinit;
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
return; // Pool is current
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
return; // Can't read meta — assume pool is fine
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
await (0, pool_adapter_js_1.initLbug)(poolKey, repo.lbugPath);
|
|
1245
|
+
this.initializedRepos.add(poolKey);
|
|
1246
|
+
this.setObservedState(poolKey, {
|
|
1247
|
+
indexedAt: repo.indexedAt,
|
|
1248
|
+
dbIdentity: await (0, pool_adapter_js_1.statDbIdentity)(repo.lbugPath),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
catch (err) {
|
|
1252
|
+
// If lock error, mark as not initialized so next call retries
|
|
1253
|
+
this.initializedRepos.delete(poolKey);
|
|
1254
|
+
throw err;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
// ─── Public Getters ──────────────────────────────────────────────
|
|
1258
|
+
/**
|
|
1259
|
+
* Get context for a specific repo (or the single repo if only one).
|
|
1260
|
+
*/
|
|
1261
|
+
getContext(repoId) {
|
|
1262
|
+
if (repoId && this.contextCache.has(repoId)) {
|
|
1263
|
+
return this.contextCache.get(repoId);
|
|
1264
|
+
}
|
|
1265
|
+
if (this.repos.size === 1) {
|
|
1266
|
+
return this.contextCache.values().next().value ?? null;
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Best-effort sibling-clone drift warning.
|
|
1272
|
+
*
|
|
1273
|
+
* When the resolved index has a `remoteUrl` recorded and the caller's
|
|
1274
|
+
* `process.cwd()` is inside a *different* clone of the same repo, emit
|
|
1275
|
+
* one stderr line per (repo, cwd) pair so the operator knows the
|
|
1276
|
+
* graph may be stale relative to what's actually on disk under their
|
|
1277
|
+
* cwd. Silent on path matches and on repos without a remote URL.
|
|
1278
|
+
*
|
|
1279
|
+
* Limitation: in MCP stdio server mode `process.cwd()` is the
|
|
1280
|
+
* server's CWD at start time, *not* the agent client's CWD. The
|
|
1281
|
+
* warning therefore only fires when the MCP server itself was
|
|
1282
|
+
* launched from inside a sibling clone (typical for `npx cgraph
|
|
1283
|
+
* serve` from a polecat workspace). Surfacing the client's CWD
|
|
1284
|
+
* would require a per-tool-call `cwd` parameter — out of scope for
|
|
1285
|
+
* the current MCP contract.
|
|
1286
|
+
*
|
|
1287
|
+
* Pure side-effect (stderr); never affects the returned handle.
|
|
1288
|
+
* After the first computation for a given (repo, cwd) pair the
|
|
1289
|
+
* result is cached so subsequent `resolveRepo()` calls don't
|
|
1290
|
+
* re-shell-out to git.
|
|
1291
|
+
*/
|
|
1292
|
+
async maybeWarnSiblingDrift(handle) {
|
|
1293
|
+
if (!handle.remoteUrl)
|
|
1294
|
+
return;
|
|
1295
|
+
let cwd;
|
|
1296
|
+
try {
|
|
1297
|
+
cwd = process.cwd();
|
|
1298
|
+
}
|
|
1299
|
+
catch {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
// Early-exit cache: keyed on (repo, cwd) BEFORE any git shellout.
|
|
1303
|
+
// After the first call for a given cwd, this short-circuits the
|
|
1304
|
+
// up-to-four `execSync`/`execFileSync` calls inside `checkCwdMatch`
|
|
1305
|
+
// — important for MCP-server mode where `process.cwd()` is constant
|
|
1306
|
+
// and `resolveRepo` runs on every tool call.
|
|
1307
|
+
const cacheKey = `${handle.id}|${cwd}`;
|
|
1308
|
+
if (this.warnedSiblingDrift.has(cacheKey))
|
|
1309
|
+
return;
|
|
1310
|
+
const match = await (0, git_staleness_js_1.checkCwdMatch)(cwd);
|
|
1311
|
+
if (match.match !== 'sibling-by-remote' ||
|
|
1312
|
+
!match.entry ||
|
|
1313
|
+
!match.cwdGitRoot ||
|
|
1314
|
+
match.entry.path !== handle.repoPath ||
|
|
1315
|
+
!match.hint) {
|
|
1316
|
+
// Cache "nothing to warn about" outcomes too — `checkCwdMatch`
|
|
1317
|
+
// is deterministic for a fixed (registry, cwd) pair, so re-running
|
|
1318
|
+
// it yields nothing new.
|
|
1319
|
+
this.warnedSiblingDrift.add(cacheKey);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
this.warnedSiblingDrift.add(cacheKey);
|
|
1323
|
+
logger_js_1.logger.error(`GitNexus: ${match.hint}`);
|
|
1324
|
+
}
|
|
1325
|
+
// ─── Tool Dispatch ───────────────────────────────────────────────
|
|
1326
|
+
/**
|
|
1327
|
+
* #2655: attach a commits-behind freshness signal to a hot-read-tool result,
|
|
1328
|
+
* skipping the `git` spawn entirely for results that can't carry it (error
|
|
1329
|
+
* envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an
|
|
1330
|
+
* error-returning call pays nothing.
|
|
1331
|
+
*/
|
|
1332
|
+
async withToolStaleness(repo, result) {
|
|
1333
|
+
if (!canCarryStaleness(result))
|
|
1334
|
+
return result;
|
|
1335
|
+
// Defensive: `checkStalenessAsync` self-catches today, but a rejection here
|
|
1336
|
+
// must never fail the tool — degrade to no-staleness. Paired with the
|
|
1337
|
+
// evict-on-reject in `stalenessForTool`, a transient failure also can't
|
|
1338
|
+
// poison the TTL cache entry (#2655 review F1).
|
|
1339
|
+
const staleness = await this.stalenessForTool(repo).catch(() => undefined);
|
|
1340
|
+
return attachToolStaleness(result, staleness);
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* #2655: commits-behind freshness for the hot read tools, deduped per index.
|
|
1344
|
+
* Returns a shared in-flight promise so concurrent tool calls spawn at most
|
|
1345
|
+
* one `git rev-list` per index per TTL window; the resolved value is cached
|
|
1346
|
+
* for TOOL_STALENESS_TTL_MS. Keyed by lbugPath so flat and branch handles
|
|
1347
|
+
* (same repoPath, different lastCommit) don't share an entry. Non-blocking by
|
|
1348
|
+
* construction: `checkStalenessAsync` swallows git failures to
|
|
1349
|
+
* `{ isStale: false }`, so a git error never fails the tool — it just omits
|
|
1350
|
+
* the `staleness` field.
|
|
1351
|
+
*/
|
|
1352
|
+
stalenessForTool(repo) {
|
|
1353
|
+
const now = Date.now();
|
|
1354
|
+
const cached = this.toolStalenessCache.get(repo.lbugPath);
|
|
1355
|
+
if (cached && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) {
|
|
1356
|
+
return cached.value;
|
|
1357
|
+
}
|
|
1358
|
+
// Evict the entry if the check rejects so a transient failure isn't served
|
|
1359
|
+
// (as a permanently-rejecting promise) for the rest of the TTL window; the
|
|
1360
|
+
// next call then re-runs. A resolving promise is never evicted, so happy-path
|
|
1361
|
+
// dedup is untouched (#2655 review F1). `Promise.resolve` wraps the call so a
|
|
1362
|
+
// non-thenable return can't throw at this boundary — a no-op for the real
|
|
1363
|
+
// async `checkStalenessAsync`, robust defense-in-depth otherwise.
|
|
1364
|
+
const entry = {
|
|
1365
|
+
at: now,
|
|
1366
|
+
// Only evict if THIS entry is still current — a later call may have
|
|
1367
|
+
// installed a fresh (resolving) entry for the same key before a slow
|
|
1368
|
+
// rejection lands, and that newer entry must not be dropped.
|
|
1369
|
+
value: Promise.resolve((0, git_staleness_js_1.checkStalenessAsync)(repo.repoPath, repo.lastCommit)).catch((err) => {
|
|
1370
|
+
if (this.toolStalenessCache.get(repo.lbugPath) === entry) {
|
|
1371
|
+
this.toolStalenessCache.delete(repo.lbugPath);
|
|
1372
|
+
}
|
|
1373
|
+
throw err;
|
|
1374
|
+
}),
|
|
1375
|
+
};
|
|
1376
|
+
this.toolStalenessCache.set(repo.lbugPath, entry);
|
|
1377
|
+
return entry.value;
|
|
1378
|
+
}
|
|
1379
|
+
async callTool(method, params) {
|
|
1380
|
+
// 已删工具名(query/rename/explain/pdg_query/group_*/list_repos 等)与未知
|
|
1381
|
+
// 工具名一律显式报错,不依赖索引状态、不静默。
|
|
1382
|
+
if (!KNOWN_TOOL_METHODS.has(method)) {
|
|
1383
|
+
return { error: `Unknown tool: ${method}` };
|
|
1384
|
+
}
|
|
1385
|
+
const normalized = normalizeToolParams(method, params);
|
|
1386
|
+
if ('error' in normalized)
|
|
1387
|
+
return { error: normalized.error };
|
|
1388
|
+
const p = normalized.params;
|
|
1389
|
+
// Resolve repo from optional param (re-reads registry on miss). An optional
|
|
1390
|
+
// `branch` param scopes the resolved handle to that branch's index (#2106).
|
|
1391
|
+
const repo = await this.resolveRepo(p.repo, p.branch);
|
|
1392
|
+
switch (method) {
|
|
1393
|
+
case 'cypher': {
|
|
1394
|
+
const raw = await this.cypher(repo, p);
|
|
1395
|
+
return this.withToolStaleness(repo, this.formatCypherAsMarkdown(raw));
|
|
1396
|
+
}
|
|
1397
|
+
case 'context':
|
|
1398
|
+
return this.withToolStaleness(repo, await this.context(repo, p));
|
|
1399
|
+
case 'impact':
|
|
1400
|
+
return this.withToolStaleness(repo, await this.impact(repo, p));
|
|
1401
|
+
case 'detect_changes':
|
|
1402
|
+
return this.detectChanges(repo, p);
|
|
1403
|
+
case 'route_map':
|
|
1404
|
+
return this.routeMap(repo, p);
|
|
1405
|
+
case 'shape_check':
|
|
1406
|
+
return this.shapeCheck(repo, p);
|
|
1407
|
+
case 'tool_map':
|
|
1408
|
+
return this.toolMap(repo, p);
|
|
1409
|
+
case 'api_impact':
|
|
1410
|
+
return this.apiImpact(repo, p);
|
|
1411
|
+
case 'trace':
|
|
1412
|
+
return this.trace(repo, p);
|
|
1413
|
+
default:
|
|
1414
|
+
throw new Error(`Internal: unreachable dispatch for ${method}`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
// ─── Tool Implementations ────────────────────────────────────────
|
|
1418
|
+
async executeCypher(repoName, query, params = {}) {
|
|
1419
|
+
const repo = await this.resolveRepo(repoName);
|
|
1420
|
+
return this.cypher(repo, { query, params });
|
|
1421
|
+
}
|
|
1422
|
+
async cypher(repo,
|
|
1423
|
+
// #2175: "statement" is the advertised param; "query" is the legacy alias,
|
|
1424
|
+
// still accepted (and the field the internal executeCypher() passes). New wins.
|
|
1425
|
+
request) {
|
|
1426
|
+
await this.ensureInitialized(repo);
|
|
1427
|
+
if (!(0, pool_adapter_js_1.isLbugReady)(repo.lbugPath)) {
|
|
1428
|
+
return { error: 'LadybugDB not ready. Index may be corrupted.' };
|
|
1429
|
+
}
|
|
1430
|
+
if (request.params !== undefined && !(0, query_params_js_1.isValidQueryParams)(request.params)) {
|
|
1431
|
+
return {
|
|
1432
|
+
error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
const cypherText = resolveAliasString(request.statement, request.query) ?? '';
|
|
1436
|
+
if (!cypherText.trim()) {
|
|
1437
|
+
// Mirror query()'s friendly required-param error instead of letting an empty
|
|
1438
|
+
// string fall through to a raw LadybugDB prepare error (#2175 review).
|
|
1439
|
+
return { error: 'statement (or legacy query) parameter is required and cannot be empty.' };
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
const result = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, cypherText, request.params ?? {});
|
|
1443
|
+
return result;
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
const msg = err.message || 'Query failed';
|
|
1447
|
+
if (isReadOnlyDbError(err)) {
|
|
1448
|
+
return {
|
|
1449
|
+
error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
if ((0, lbug_config_js_1.isWalCorruptionError)(err)) {
|
|
1453
|
+
return {
|
|
1454
|
+
error: msg,
|
|
1455
|
+
recoverySuggestion: lbug_config_js_1.WAL_RECOVERY_SUGGESTION,
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
return { error: msg };
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Format raw Cypher result rows as a markdown table for LLM readability.
|
|
1463
|
+
* Falls back to raw result if rows aren't tabular objects.
|
|
1464
|
+
*/
|
|
1465
|
+
formatCypherAsMarkdown(result) {
|
|
1466
|
+
if (!Array.isArray(result) || result.length === 0)
|
|
1467
|
+
return result;
|
|
1468
|
+
const firstRow = result[0];
|
|
1469
|
+
if (typeof firstRow !== 'object' || firstRow === null)
|
|
1470
|
+
return result;
|
|
1471
|
+
const keys = Object.keys(firstRow);
|
|
1472
|
+
if (keys.length === 0)
|
|
1473
|
+
return result;
|
|
1474
|
+
const header = '| ' + keys.join(' | ') + ' |';
|
|
1475
|
+
const separator = '| ' + keys.map(() => '---').join(' | ') + ' |';
|
|
1476
|
+
const dataRows = result.map((row) => '| ' +
|
|
1477
|
+
keys
|
|
1478
|
+
.map((k) => {
|
|
1479
|
+
const v = row[k];
|
|
1480
|
+
if (v === null || v === undefined)
|
|
1481
|
+
return '';
|
|
1482
|
+
if (typeof v === 'object')
|
|
1483
|
+
return JSON.stringify(v);
|
|
1484
|
+
// Collapse newlines so a multi-line cell value (e.g. a symbol's
|
|
1485
|
+
// `content`) stays on one physical line. Otherwise the rendered row
|
|
1486
|
+
// spans multiple lines, which corrupts the table and breaks the
|
|
1487
|
+
// CLI's `--limit` line-based slicing (#2310 review).
|
|
1488
|
+
return String(v).replace(/\r?\n/g, ' ');
|
|
1489
|
+
})
|
|
1490
|
+
.join(' | ') +
|
|
1491
|
+
' |');
|
|
1492
|
+
return {
|
|
1493
|
+
markdown: [header, separator, ...dataRows].join('\n'),
|
|
1494
|
+
row_count: result.length,
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Aggregate same-named clusters: group by heuristicLabel, sum symbols,
|
|
1499
|
+
* weighted-average cohesion, filter out tiny clusters (<5 symbols).
|
|
1500
|
+
* Raw communities stay intact in LadybugDB for Cypher queries.
|
|
1501
|
+
*/
|
|
1502
|
+
aggregateClusters(clusters) {
|
|
1503
|
+
const groups = new Map();
|
|
1504
|
+
for (const c of clusters) {
|
|
1505
|
+
const label = c.heuristicLabel || c.label || 'Unknown';
|
|
1506
|
+
const symbols = c.symbolCount || 0;
|
|
1507
|
+
const cohesion = c.cohesion || 0;
|
|
1508
|
+
const existing = groups.get(label);
|
|
1509
|
+
if (!existing) {
|
|
1510
|
+
groups.set(label, {
|
|
1511
|
+
ids: [c.id],
|
|
1512
|
+
totalSymbols: symbols,
|
|
1513
|
+
weightedCohesion: cohesion * symbols,
|
|
1514
|
+
largest: c,
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
existing.ids.push(c.id);
|
|
1519
|
+
existing.totalSymbols += symbols;
|
|
1520
|
+
existing.weightedCohesion += cohesion * symbols;
|
|
1521
|
+
if (symbols > (existing.largest.symbolCount || 0)) {
|
|
1522
|
+
existing.largest = c;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return Array.from(groups.entries())
|
|
1527
|
+
.map(([label, g]) => ({
|
|
1528
|
+
id: g.largest.id,
|
|
1529
|
+
label,
|
|
1530
|
+
heuristicLabel: label,
|
|
1531
|
+
symbolCount: g.totalSymbols,
|
|
1532
|
+
cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0,
|
|
1533
|
+
subCommunities: g.ids.length,
|
|
1534
|
+
}))
|
|
1535
|
+
.filter((c) => c.symbolCount >= 5)
|
|
1536
|
+
.sort((a, b) => b.symbolCount - a.symbolCount || (0, utils_js_1.compareCodeUnits)(String(a.id), String(b.id)));
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Patch the `type` field on candidates whose `labels(n)[0]` projection
|
|
1540
|
+
* came back empty — a known LadybugDB behaviour for several node types.
|
|
1541
|
+
*
|
|
1542
|
+
* Uses one scoped UNION query across the priority labels rather than
|
|
1543
|
+
* per-candidate round-trips, so cost is a single DB call regardless of how
|
|
1544
|
+
* many candidates need enrichment. No-op when every candidate already has a
|
|
1545
|
+
* non-empty type.
|
|
1546
|
+
*
|
|
1547
|
+
* The value labels (`Const` / `Variable` / `Static`) are included because a
|
|
1548
|
+
* value candidate otherwise surfaces with `kind: ""` — which reads as
|
|
1549
|
+
* "unknown kind" and, worse, makes the `kind` disambiguation hint unable to
|
|
1550
|
+
* filter it out (#2687).
|
|
1551
|
+
*
|
|
1552
|
+
* Failures are swallowed: label enrichment is an optimisation for
|
|
1553
|
+
* downstream scoring and #480 Class/Interface BFS seeding; if it fails
|
|
1554
|
+
* the symbol still resolves, just without the kind-priority bonus.
|
|
1555
|
+
*/
|
|
1556
|
+
async enrichCandidateLabels(repo, candidates) {
|
|
1557
|
+
const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id);
|
|
1558
|
+
if (ids.length === 0)
|
|
1559
|
+
return;
|
|
1560
|
+
try {
|
|
1561
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
1562
|
+
MATCH (n:\`Class\`) WHERE n.id IN $ids RETURN n.id AS id, 'Class' AS label
|
|
1563
|
+
UNION ALL
|
|
1564
|
+
MATCH (n:\`Interface\`) WHERE n.id IN $ids RETURN n.id AS id, 'Interface' AS label
|
|
1565
|
+
UNION ALL
|
|
1566
|
+
MATCH (n:\`Function\`) WHERE n.id IN $ids RETURN n.id AS id, 'Function' AS label
|
|
1567
|
+
UNION ALL
|
|
1568
|
+
MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label
|
|
1569
|
+
UNION ALL
|
|
1570
|
+
MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
|
|
1571
|
+
UNION ALL
|
|
1572
|
+
MATCH (n:\`CodeElement\`) WHERE n.id IN $ids RETURN n.id AS id, 'CodeElement' AS label
|
|
1573
|
+
UNION ALL
|
|
1574
|
+
MATCH (n:\`Const\`) WHERE n.id IN $ids RETURN n.id AS id, 'Const' AS label
|
|
1575
|
+
UNION ALL
|
|
1576
|
+
MATCH (n:\`Variable\`) WHERE n.id IN $ids RETURN n.id AS id, 'Variable' AS label
|
|
1577
|
+
UNION ALL
|
|
1578
|
+
MATCH (n:\`Static\`) WHERE n.id IN $ids RETURN n.id AS id, 'Static' AS label
|
|
1579
|
+
`, { ids });
|
|
1580
|
+
const labelById = new Map();
|
|
1581
|
+
for (const r of rows) {
|
|
1582
|
+
const id = (r.id ?? r[0]);
|
|
1583
|
+
const label = (r.label ?? r[1]);
|
|
1584
|
+
if (id && label && !labelById.has(id))
|
|
1585
|
+
labelById.set(id, label);
|
|
1586
|
+
}
|
|
1587
|
+
for (const c of candidates) {
|
|
1588
|
+
if (c.type === '' && labelById.has(c.id))
|
|
1589
|
+
c.type = labelById.get(c.id);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
/* best-effort — downstream resolvers still work without the label */
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Score a symbol candidate for disambiguation ranking.
|
|
1598
|
+
*
|
|
1599
|
+
* Deterministic, no DB round-trip:
|
|
1600
|
+
* - base 0.50
|
|
1601
|
+
* - +0.40 when file_path hint matches (substring, case-insensitive)
|
|
1602
|
+
* - +0.20 when kind hint exactly matches the candidate's kind
|
|
1603
|
+
* - when no kind hint, a small priority bonus (Class > Interface >
|
|
1604
|
+
* Function > Method > Constructor) to preserve the intuition that
|
|
1605
|
+
* class-level names are usually what the user wanted.
|
|
1606
|
+
*
|
|
1607
|
+
* Capped at 1.0. Intentionally simple and inspectable — a future v2 can
|
|
1608
|
+
* plug in BM25/embedding signals here without changing the surrounding
|
|
1609
|
+
* resolver shape.
|
|
1610
|
+
*/
|
|
1611
|
+
scoreCandidate(c, hints) {
|
|
1612
|
+
let s = 0.5;
|
|
1613
|
+
if (hints.file_path && c.filePath && typeof c.filePath === 'string') {
|
|
1614
|
+
if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) {
|
|
1615
|
+
s += 0.4;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
if (hints.kind && c.kind === hints.kind) {
|
|
1619
|
+
s += 0.2;
|
|
1620
|
+
}
|
|
1621
|
+
if (!hints.kind) {
|
|
1622
|
+
const priority = {
|
|
1623
|
+
Class: 5,
|
|
1624
|
+
Interface: 4,
|
|
1625
|
+
Function: 3,
|
|
1626
|
+
Method: 2,
|
|
1627
|
+
Constructor: 1,
|
|
1628
|
+
};
|
|
1629
|
+
s += (priority[c.kind] ?? 0) * 0.02;
|
|
1630
|
+
}
|
|
1631
|
+
return Math.min(1.0, s);
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Shared symbol resolver used by `context` and `impact`.
|
|
1635
|
+
*
|
|
1636
|
+
* Returns one of:
|
|
1637
|
+
* - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
|
|
1638
|
+
* (either direct UID, only one candidate after filtering, Class/
|
|
1639
|
+
* Constructor collapse, or a top-scoring candidate with a clear gap
|
|
1640
|
+
* to the runner-up).
|
|
1641
|
+
* - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
|
|
1642
|
+
* sorted by score desc. Each candidate carries a relevance score.
|
|
1643
|
+
* - `{ kind: 'not_found' }` — no matches at all.
|
|
1644
|
+
*
|
|
1645
|
+
* Preserves the #480 Class/Constructor preference: when the only
|
|
1646
|
+
* ambiguity is between a Class and its own Constructor (same name,
|
|
1647
|
+
* same filePath), the Class wins silently.
|
|
1648
|
+
*/
|
|
1649
|
+
async resolveSymbolCandidates(repo, query, hints) {
|
|
1650
|
+
const { uid, name, include_content } = query;
|
|
1651
|
+
const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
|
|
1652
|
+
// Direct UID — zero-ambiguity path.
|
|
1653
|
+
if (uid) {
|
|
1654
|
+
// determinism: probe — PK-anchored singleton. $uid is a node primary key, so at most one row can match and
|
|
1655
|
+
// the LIMIT never chooses between rows.
|
|
1656
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`, { uid });
|
|
1657
|
+
if (rows.length === 0)
|
|
1658
|
+
return { kind: 'not_found' };
|
|
1659
|
+
const r = rows[0];
|
|
1660
|
+
const symbol = {
|
|
1661
|
+
id: (r.id ?? r[0]),
|
|
1662
|
+
name: (r.name ?? r[1]),
|
|
1663
|
+
type: (r.type ?? r[2] ?? ''),
|
|
1664
|
+
filePath: (r.filePath ?? r[3]),
|
|
1665
|
+
startLine: (r.startLine ?? r[4]),
|
|
1666
|
+
endLine: (r.endLine ?? r[5]),
|
|
1667
|
+
...(include_content ? { content: (r.content ?? r[6]) } : {}),
|
|
1668
|
+
};
|
|
1669
|
+
// Same LadybugDB label-enrichment as the name-based path: a UID
|
|
1670
|
+
// pointing at a Class must still surface `type: 'Class'` so impact's
|
|
1671
|
+
// Class/Interface BFS seed fires. No-op when type is already set.
|
|
1672
|
+
await this.enrichCandidateLabels(repo, [symbol]);
|
|
1673
|
+
return { kind: 'ok', symbol, resolvedLabel: symbol.type };
|
|
1674
|
+
}
|
|
1675
|
+
if (!name)
|
|
1676
|
+
return { kind: 'not_found' };
|
|
1677
|
+
const isQualified = name.includes('/') || name.includes(':');
|
|
1678
|
+
let whereClause;
|
|
1679
|
+
const queryParams = { symName: name };
|
|
1680
|
+
if (hints.file_path) {
|
|
1681
|
+
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
|
|
1682
|
+
queryParams.filePath = hints.file_path;
|
|
1683
|
+
}
|
|
1684
|
+
else if (isQualified) {
|
|
1685
|
+
// Parenthesised because the kind filter below is appended with AND, which
|
|
1686
|
+
// binds tighter than OR.
|
|
1687
|
+
whereClause = `WHERE (n.id = $symName OR n.name = $symName)`;
|
|
1688
|
+
}
|
|
1689
|
+
else {
|
|
1690
|
+
whereClause = `WHERE n.name = $symName`;
|
|
1691
|
+
}
|
|
1692
|
+
// A `kind` hint FILTERS, it does not merely score (#2787 review F5). Node
|
|
1693
|
+
// ids are `Label:filePath:qualifiedName`, so the ORDER BY below is a
|
|
1694
|
+
// label-major sort: `Class` < `Const` < `Constructor` < `Function` <
|
|
1695
|
+
// `Interface` < `Method`. A caller asking for kind:'Method' on a name with
|
|
1696
|
+
// many Function/Const homonyms could therefore have every Method sorted out
|
|
1697
|
+
// of the window (`run` has 7 Methods in this repo's index; 2 survive the
|
|
1698
|
+
// ordered page), and scoreCandidate's +0.20 kind bonus can only rank rows
|
|
1699
|
+
// that came back — it can never recover one the LIMIT dropped. The tool
|
|
1700
|
+
// schema already calls this a "Kind filter". Same label-prefix invariant
|
|
1701
|
+
// the ORDER BY depends on, so it costs nothing extra.
|
|
1702
|
+
const kindClause = hints.kind ? `${whereClause} AND n.id STARTS WITH $kindPrefix` : whereClause;
|
|
1703
|
+
const kindParams = hints.kind
|
|
1704
|
+
? { ...queryParams, kindPrefix: `${hints.kind}:` }
|
|
1705
|
+
: queryParams;
|
|
1706
|
+
// LIMIT CANDIDATE_WINDOW (20; was 10) — scoring is the point now, so give
|
|
1707
|
+
// the ranker headroom instead of arbitrary truncation.
|
|
1708
|
+
//
|
|
1709
|
+
// ORDER BY n.id is load-bearing, not cosmetic (#2787). A bare `LIMIT`
|
|
1710
|
+
// hands back an ARBITRARY subset when more nodes share the name than the
|
|
1711
|
+
// cap (`constructor` = 92 in this repo's own index, `get` = 34), and
|
|
1712
|
+
// LadybugDB picks a different subset from one process to the next — so
|
|
1713
|
+
// `impact`/`context` resolved a different symbol on every invocation and
|
|
1714
|
+
// the HIGH/CRITICAL warning the agent workflow depends on fired at random.
|
|
1715
|
+
// `n.id` is the PRIMARY KEY on every node table: non-null, unique, and a
|
|
1716
|
+
// total order, which is what pins WHICH rows come back.
|
|
1717
|
+
//
|
|
1718
|
+
// `labels(n)[0]` is not an alternative ordering key: it comes back empty for
|
|
1719
|
+
// Class nodes (see enrichCandidateLabels below), so ordering on the
|
|
1720
|
+
// projected type would sort the HIGHEST-priority kind into the empty bucket.
|
|
1721
|
+
//
|
|
1722
|
+
// The COUNT reports the TRUE total, which a LIMIT-capped page's row count
|
|
1723
|
+
// cannot stand in for (#2084 review P2-4). Without it every "Found N symbols
|
|
1724
|
+
// matching 'x'" message and the `totalCandidates` field report the cap, so
|
|
1725
|
+
// 92 collisions read as 20 — and now that the window is ordered, that
|
|
1726
|
+
// undercount is stable, which makes it look authoritative rather than flaky.
|
|
1727
|
+
//
|
|
1728
|
+
// It runs AFTER the window rather than alongside it because a SHORT page
|
|
1729
|
+
// proves the LIMIT never bound, which makes `COUNT(*)` identically
|
|
1730
|
+
// `rows.length`. The COUNT is unlabeled and `n.name` is unindexed, so it is
|
|
1731
|
+
// a full scan of every node table — skipping it halves the cost of the
|
|
1732
|
+
// hottest step in `context`/`impact`/`trace` (`trace` resolves twice).
|
|
1733
|
+
// Trade-off: the full-window (>= CANDIDATE_WINDOW
|
|
1734
|
+
// homonyms) minority path is now sequential instead of concurrent.
|
|
1735
|
+
const fetchWindow = async (where, params) => {
|
|
1736
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (n) ${where} RETURN ${selectClause} ORDER BY n.id LIMIT ${CANDIDATE_WINDOW}`, params);
|
|
1737
|
+
if (rows.length < CANDIDATE_WINDOW)
|
|
1738
|
+
return { rows, countedTotal: rows.length };
|
|
1739
|
+
const countRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (n) ${where} RETURN COUNT(*) AS total`, params).catch((e) => {
|
|
1740
|
+
// Never swallowed (#2787 review F3): a failed COUNT falls through to
|
|
1741
|
+
// the window-length floor below, and without a signal the response
|
|
1742
|
+
// ships `totalCandidates: 20` with no `candidatesTruncated` — byte
|
|
1743
|
+
// identical to a genuine 20-match result. `totalIsLowerBound` is the
|
|
1744
|
+
// caller-visible degradation this log is contracted to accompany.
|
|
1745
|
+
logQueryError('resolve:candidate-count', e);
|
|
1746
|
+
return [];
|
|
1747
|
+
});
|
|
1748
|
+
return {
|
|
1749
|
+
rows,
|
|
1750
|
+
countedTotal: Number(countRows[0]?.total ?? countRows[0]?.[0] ?? Number.NaN),
|
|
1751
|
+
};
|
|
1752
|
+
};
|
|
1753
|
+
let { rows, countedTotal } = await fetchWindow(kindClause, kindParams);
|
|
1754
|
+
if (rows.length === 0 && hints.kind) {
|
|
1755
|
+
// `kind` is a free-form string on the tool schema, so a hint that matches
|
|
1756
|
+
// no label prefix (wrong case, or a kind this repo has no nodes for) must
|
|
1757
|
+
// not turn a real name into `not_found`. Fall back to the unfiltered
|
|
1758
|
+
// window and let scoreCandidate treat the hint as a ranking term, exactly
|
|
1759
|
+
// as it did before the filter existed.
|
|
1760
|
+
({ rows, countedTotal } = await fetchWindow(whereClause, queryParams));
|
|
1761
|
+
}
|
|
1762
|
+
if (rows.length === 0)
|
|
1763
|
+
return { kind: 'not_found' };
|
|
1764
|
+
// Normalise row shape across object / tuple returns from LadybugDB.
|
|
1765
|
+
const normalized = rows.map((r) => ({
|
|
1766
|
+
id: (r.id ?? r[0]),
|
|
1767
|
+
name: (r.name ?? r[1]),
|
|
1768
|
+
type: (r.type ?? r[2] ?? ''),
|
|
1769
|
+
filePath: (r.filePath ?? r[3]),
|
|
1770
|
+
startLine: (r.startLine ?? r[4]),
|
|
1771
|
+
endLine: (r.endLine ?? r[5]),
|
|
1772
|
+
...(include_content ? { content: (r.content ?? r[6]) } : {}),
|
|
1773
|
+
}));
|
|
1774
|
+
// The COUNT can never legitimately be below the page it accompanies, so a
|
|
1775
|
+
// value under `normalized.length` means the count leg failed or returned an
|
|
1776
|
+
// unreadable shape. Keep the window size as the floor — reporting zero would
|
|
1777
|
+
// be worse — but mark the number a LOWER BOUND so no consumer treats it as
|
|
1778
|
+
// the exact match count this PR otherwise promises (#2787 review F3).
|
|
1779
|
+
const totalIsExact = Number.isFinite(countedTotal) && countedTotal >= normalized.length;
|
|
1780
|
+
const totalMatches = totalIsExact ? countedTotal : normalized.length;
|
|
1781
|
+
// Enrich labels for any candidates where `labels(n)[0]` came back empty.
|
|
1782
|
+
// LadybugDB returns an empty string for that projection on certain node
|
|
1783
|
+
// types (notably Class), which left downstream consumers (impact's
|
|
1784
|
+
// Class/Interface BFS seed, the kind-priority scoring bonus) unable to
|
|
1785
|
+
// distinguish a Class target from "unknown kind". One scoped UNION
|
|
1786
|
+
// across the priority labels patches the type in-place without
|
|
1787
|
+
// per-candidate round-trips.
|
|
1788
|
+
await this.enrichCandidateLabels(repo, normalized);
|
|
1789
|
+
// Preserve #480 Class/Constructor collapse: if we have exactly one
|
|
1790
|
+
// Class (or Interface) candidate and one Constructor sharing name +
|
|
1791
|
+
// filePath, fold into the Class. This used to require a follow-up
|
|
1792
|
+
// label query because LadybugDB sometimes returns an empty labels()[0]
|
|
1793
|
+
// for Class nodes — enrichment above handles the empty-type case, but
|
|
1794
|
+
// the `type === 'Constructor'` gate still correctly triggers when a
|
|
1795
|
+
// Class and its Constructor share the name.
|
|
1796
|
+
if (!hints.kind && normalized.length > 1) {
|
|
1797
|
+
// A value candidate (`Const`/`Variable`/`Static`) used to reach here with
|
|
1798
|
+
// `type === ''`, which is what kept this gate true for a `class Foo` +
|
|
1799
|
+
// `const Foo` pair and let the collapse resolve it to the Class. Label
|
|
1800
|
+
// enrichment now fills those in (#2687), so they must be named explicitly
|
|
1801
|
+
// or the collapse silently stops firing and confident resolutions become
|
|
1802
|
+
// `ambiguous` across every resolver-backed tool.
|
|
1803
|
+
const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor' || VALUE_CANDIDATE_TYPES.has(s.type));
|
|
1804
|
+
// Collapsing is a CONFIDENT resolution — it returns `kind: 'ok'` and the
|
|
1805
|
+
// caller never sees the scorer or the ambiguity report — so it may only
|
|
1806
|
+
// fire when the label match is genuinely unique (#2787 review F4).
|
|
1807
|
+
// Ordering the probe made the wrong pick repeatable; it did not make it
|
|
1808
|
+
// right. Two same-named classes in two files is routine, and this is the
|
|
1809
|
+
// only confident path a bare name can take (scoreCandidate tops out at
|
|
1810
|
+
// 0.60 without a file_path hint, the confident gate below needs >= 0.95),
|
|
1811
|
+
// so an arbitrary winner here is `context`/`impact` silently analysing the
|
|
1812
|
+
// wrong file. A window that was itself truncated is disqualifying for the
|
|
1813
|
+
// same reason: a candidate outside the page could carry the label too.
|
|
1814
|
+
const windowIsComplete = normalized.length < CANDIDATE_WINDOW || (totalIsExact && totalMatches <= normalized.length);
|
|
1815
|
+
if (ambiguousType && windowIsComplete) {
|
|
1816
|
+
const candidateIds = normalized.map((s) => s.id).filter(Boolean);
|
|
1817
|
+
for (const label of ['Class', 'Interface']) {
|
|
1818
|
+
// LIMIT 2, not 1: the second row is the uniqueness check. One row back
|
|
1819
|
+
// means exactly one candidate carries the label and the collapse is
|
|
1820
|
+
// safe; two means fall through to normal ambiguity scoring.
|
|
1821
|
+
const labelRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id ORDER BY n.id LIMIT 2`, { candidateIds }).catch(() => []);
|
|
1822
|
+
if (labelRows.length === 1) {
|
|
1823
|
+
const preferredId = labelRows[0].id ?? labelRows[0][0];
|
|
1824
|
+
const preferred = normalized.find((s) => s.id === preferredId);
|
|
1825
|
+
if (preferred) {
|
|
1826
|
+
return {
|
|
1827
|
+
kind: 'ok',
|
|
1828
|
+
symbol: preferred,
|
|
1829
|
+
resolvedLabel: label,
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
if (normalized.length === 1) {
|
|
1837
|
+
return {
|
|
1838
|
+
kind: 'ok',
|
|
1839
|
+
symbol: normalized[0],
|
|
1840
|
+
resolvedLabel: '',
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
// Score, sort desc, stable tiebreak on shorter filePath then lex uid.
|
|
1844
|
+
const scored = normalized.map((s) => ({
|
|
1845
|
+
...s,
|
|
1846
|
+
score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints),
|
|
1847
|
+
}));
|
|
1848
|
+
scored.sort((a, b) => {
|
|
1849
|
+
if (b.score !== a.score)
|
|
1850
|
+
return b.score - a.score;
|
|
1851
|
+
const fpA = (a.filePath || '').length;
|
|
1852
|
+
const fpB = (b.filePath || '').length;
|
|
1853
|
+
if (fpA !== fpB)
|
|
1854
|
+
return fpA - fpB;
|
|
1855
|
+
return (0, utils_js_1.compareCodeUnits)(String(a.id), String(b.id));
|
|
1856
|
+
});
|
|
1857
|
+
// Confident single-result: top score ≥ 0.95 AND beats runner-up by a
|
|
1858
|
+
// clear margin. This lets a very strong file_path/kind hint resolve
|
|
1859
|
+
// cleanly instead of forcing the caller through a disambiguation
|
|
1860
|
+
// round-trip.
|
|
1861
|
+
//
|
|
1862
|
+
// The gap threshold uses `> 0.09` rather than `>= 0.10` on purpose:
|
|
1863
|
+
// IEEE754 addition of the scoring terms (0.50 + 0.40 + 0.20 - 0.90
|
|
1864
|
+
// yields 0.09999999999999998, not exactly 0.10) would otherwise break
|
|
1865
|
+
// the comparison for legitimate "top is 1.00, runner is 0.90" cases.
|
|
1866
|
+
// The intent is a clearly-dominant winner; 0.09 is a large enough
|
|
1867
|
+
// margin to mean that unambiguously.
|
|
1868
|
+
//
|
|
1869
|
+
// The `scored.length >= 2` guard is defensive. The `normalized.length === 1`
|
|
1870
|
+
// early return above already handles the single-candidate path, so in
|
|
1871
|
+
// practice `scored` always has at least two elements by the time we get
|
|
1872
|
+
// here — keeping the guard means changes to the upstream early-return
|
|
1873
|
+
// logic cannot accidentally index out of bounds at `scored[1]`.
|
|
1874
|
+
if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score > 0.09) {
|
|
1875
|
+
return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type };
|
|
1876
|
+
}
|
|
1877
|
+
return {
|
|
1878
|
+
kind: 'ambiguous',
|
|
1879
|
+
candidates: scored,
|
|
1880
|
+
total: totalMatches,
|
|
1881
|
+
...(totalIsExact ? {} : { totalIsLowerBound: true }),
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Context tool — 360-degree symbol view with categorized refs.
|
|
1886
|
+
* Disambiguation (ranked) when multiple symbols share a name.
|
|
1887
|
+
* UID-based direct lookup. No cluster in output.
|
|
1888
|
+
*/
|
|
1889
|
+
async context(repo, params) {
|
|
1890
|
+
try {
|
|
1891
|
+
return await this._contextImpl(repo, params);
|
|
1892
|
+
}
|
|
1893
|
+
catch (err) {
|
|
1894
|
+
const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed';
|
|
1895
|
+
if ((0, lbug_config_js_1.isWalCorruptionError)(err)) {
|
|
1896
|
+
return {
|
|
1897
|
+
error: msg,
|
|
1898
|
+
recoverySuggestion: lbug_config_js_1.WAL_RECOVERY_SUGGESTION,
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
throw err;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
async _contextImpl(repo, params) {
|
|
1905
|
+
await this.ensureInitialized(repo);
|
|
1906
|
+
const { name, uid, file_path, kind, include_content } = params;
|
|
1907
|
+
if (!name && !uid) {
|
|
1908
|
+
return { error: 'Either "name" or "uid" parameter is required.' };
|
|
1909
|
+
}
|
|
1910
|
+
const outcome = await this.resolveSymbolCandidates(repo, { uid, name, include_content }, { file_path, kind });
|
|
1911
|
+
if (outcome.kind === 'not_found') {
|
|
1912
|
+
return { error: `Symbol '${name || uid}' not found` };
|
|
1913
|
+
}
|
|
1914
|
+
if (outcome.kind === 'ambiguous') {
|
|
1915
|
+
const { atLeast, showing, fields } = ambiguityReport(outcome, outcome.candidates.length);
|
|
1916
|
+
return {
|
|
1917
|
+
status: 'ambiguous',
|
|
1918
|
+
message: `Found ${atLeast}${outcome.total} symbols matching '${name}'${showing}. Use uid, file_path, or kind to disambiguate.`,
|
|
1919
|
+
...fields,
|
|
1920
|
+
candidates: outcome.candidates.map((c) => ({
|
|
1921
|
+
uid: c.id,
|
|
1922
|
+
name: c.name,
|
|
1923
|
+
kind: c.type,
|
|
1924
|
+
filePath: c.filePath,
|
|
1925
|
+
line: (0, line_display_js_1.toDisplayLine)(c.startLine),
|
|
1926
|
+
score: Number(c.score.toFixed(2)),
|
|
1927
|
+
})),
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
// Step 3: Build full context
|
|
1931
|
+
const sym = outcome.symbol;
|
|
1932
|
+
const resolvedLabel = outcome.resolvedLabel;
|
|
1933
|
+
const symId = sym.id;
|
|
1934
|
+
// Categorized incoming refs.
|
|
1935
|
+
//
|
|
1936
|
+
// ORDER BY uid, relType — the CATEGORY column must NOT lead (#2787 review
|
|
1937
|
+
// F1). With `relType` first the 30-row window fills in alphabetical
|
|
1938
|
+
// category order, so any category whose alphabetical predecessors already
|
|
1939
|
+
// total 30 is dropped in 100% of runs: a symbol with {ACCESSES:3, CALLS:35,
|
|
1940
|
+
// USES:1, HAS_METHOD:1} came back as {ACCESSES:3, CALLS:27} — HAS_METHOD
|
|
1941
|
+
// (which names the owning class) and USES simply gone, and categorize()
|
|
1942
|
+
// below emits whatever buckets it is handed with no truncation flag, so the
|
|
1943
|
+
// loss is silent. Leading with `uid` (a node primary key) is still a total
|
|
1944
|
+
// order and still deterministic, but it spreads the window across
|
|
1945
|
+
// categories the way the unordered scan incidentally did.
|
|
1946
|
+
const [incomingRows, incomingAdvisedRows] = await Promise.all([
|
|
1947
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
1948
|
+
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
|
1949
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES', 'RCALLS', 'HTTP_CALLS']
|
|
1950
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
1951
|
+
ORDER BY uid, relType
|
|
1952
|
+
LIMIT 30
|
|
1953
|
+
`, { symId }),
|
|
1954
|
+
// Keep high-fan-in advice edges out of the legacy 30-row context window.
|
|
1955
|
+
// A broad pointcut can advise hundreds of methods; sharing that LIMIT
|
|
1956
|
+
// would make CALLS/HAS_METHOD/etc. disappear nondeterministically.
|
|
1957
|
+
// Splitting the window bounded that; ORDER BY finishes the job (#2787) —
|
|
1958
|
+
// an unordered LIMIT still let the surviving 30 change per process, and
|
|
1959
|
+
// these rows reach the response in query order via categorize().
|
|
1960
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
1961
|
+
MATCH (caller)-[r:CodeRelation {type: 'ADVISED_BY'}]->(n {id: $symId})
|
|
1962
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
1963
|
+
ORDER BY uid
|
|
1964
|
+
LIMIT 30
|
|
1965
|
+
`, { symId }),
|
|
1966
|
+
]);
|
|
1967
|
+
incomingRows.push(...incomingAdvisedRows);
|
|
1968
|
+
let typedPropertyRows = [];
|
|
1969
|
+
// Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
|
|
1970
|
+
// those point to Constructor and File nodes respectively. Fetch those
|
|
1971
|
+
// extra incoming refs and merge them in so context() shows real callers.
|
|
1972
|
+
//
|
|
1973
|
+
// Determine if this is a Class/Interface node. If resolvedLabel was set
|
|
1974
|
+
// during disambiguation (Step 2), use it directly — no extra round-trip.
|
|
1975
|
+
// Otherwise fall back to a single label check only when the type field is
|
|
1976
|
+
// empty (LadybugDB labels(n)[0] limitation).
|
|
1977
|
+
const symRawType = sym.type || sym[2] || '';
|
|
1978
|
+
let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface';
|
|
1979
|
+
if (!isClassLike && symRawType === '') {
|
|
1980
|
+
try {
|
|
1981
|
+
// Single UNION query instead of two serial round-trips.
|
|
1982
|
+
// determinism: probe — existence only, and each UNION branch is PK-anchored on $symId. Only typeCheck.length
|
|
1983
|
+
// > 0 is read; the projected label is discarded.
|
|
1984
|
+
const typeCheck = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
1985
|
+
MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1
|
|
1986
|
+
UNION ALL
|
|
1987
|
+
MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1
|
|
1988
|
+
`, { symId });
|
|
1989
|
+
isClassLike = typeCheck.length > 0;
|
|
1990
|
+
}
|
|
1991
|
+
catch {
|
|
1992
|
+
/* not a Class/Interface node */
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
else if (!isClassLike) {
|
|
1996
|
+
isClassLike = symRawType === 'Class' || symRawType === 'Interface';
|
|
1997
|
+
}
|
|
1998
|
+
if (isClassLike) {
|
|
1999
|
+
try {
|
|
2000
|
+
// Run incoming-ref queries in parallel — they are independent.
|
|
2001
|
+
const [ctorIncoming, fileIncoming, typedPropertyIncoming, typedProperties] = await Promise.all([
|
|
2002
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2003
|
+
MATCH (n)-[hm:CodeRelation]->(ctor:Constructor)
|
|
2004
|
+
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
|
2005
|
+
MATCH (caller)-[r:CodeRelation]->(ctor)
|
|
2006
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES', 'RCALLS', 'HTTP_CALLS']
|
|
2007
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
2008
|
+
ORDER BY uid, relType
|
|
2009
|
+
LIMIT 30
|
|
2010
|
+
`, { symId }),
|
|
2011
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2012
|
+
MATCH (f:File)-[rel:CodeRelation]->(n)
|
|
2013
|
+
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
|
2014
|
+
MATCH (caller)-[r:CodeRelation]->(f)
|
|
2015
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'RCALLS', 'HTTP_CALLS']
|
|
2016
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
2017
|
+
ORDER BY uid, relType
|
|
2018
|
+
LIMIT 30
|
|
2019
|
+
`, { symId }),
|
|
2020
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2021
|
+
MATCH (p:\`Property\`)
|
|
2022
|
+
WHERE p.declaredType = $name
|
|
2023
|
+
OR p.declaredType STARTS WITH $genericPrefix
|
|
2024
|
+
OR p.declaredType CONTAINS $genericArg
|
|
2025
|
+
MATCH (caller)-[r:CodeRelation]->(p)
|
|
2026
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES', 'RCALLS', 'HTTP_CALLS']
|
|
2027
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
2028
|
+
ORDER BY uid, relType
|
|
2029
|
+
LIMIT 30
|
|
2030
|
+
`, {
|
|
2031
|
+
name: sym.name,
|
|
2032
|
+
genericPrefix: `${sym.name}<`,
|
|
2033
|
+
genericArg: `<${sym.name}>`,
|
|
2034
|
+
}),
|
|
2035
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2036
|
+
MATCH (p:\`Property\`)
|
|
2037
|
+
WHERE p.declaredType = $name
|
|
2038
|
+
OR p.declaredType STARTS WITH $genericPrefix
|
|
2039
|
+
OR p.declaredType CONTAINS $genericArg
|
|
2040
|
+
RETURN p.id AS uid, p.name AS name, p.filePath AS filePath, labels(p)[0] AS kind,
|
|
2041
|
+
p.declaredType AS declaredType
|
|
2042
|
+
ORDER BY uid
|
|
2043
|
+
LIMIT 30
|
|
2044
|
+
`, {
|
|
2045
|
+
name: sym.name,
|
|
2046
|
+
genericPrefix: `${sym.name}<`,
|
|
2047
|
+
genericArg: `<${sym.name}>`,
|
|
2048
|
+
}),
|
|
2049
|
+
]);
|
|
2050
|
+
typedPropertyRows = typedProperties;
|
|
2051
|
+
// Deduplicate by (relType, uid) — a caller can have multiple relation
|
|
2052
|
+
// types to the same target (e.g. both IMPORTS and CALLS), and each
|
|
2053
|
+
// must be preserved so every category appears in the output.
|
|
2054
|
+
const seenKeys = new Set(incomingRows.map((r) => `${r.relType || r[0]}:${r.uid || r[1]}`));
|
|
2055
|
+
for (const r of [...ctorIncoming, ...fileIncoming, ...typedPropertyIncoming]) {
|
|
2056
|
+
const key = `${r.relType || r[0]}:${r.uid || r[1]}`;
|
|
2057
|
+
if (!seenKeys.has(key)) {
|
|
2058
|
+
seenKeys.add(key);
|
|
2059
|
+
incomingRows.push(r);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
catch (e) {
|
|
2064
|
+
logQueryError('context:class-incoming-expansion', e);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
// Categorized outgoing refs. uid-major for the same reason as the incoming
|
|
2068
|
+
// window above — a category-major key starves whole buckets (#2787 F1).
|
|
2069
|
+
const [outgoingRows, outgoingAdvisedRows] = await Promise.all([
|
|
2070
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2071
|
+
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
|
2072
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES', 'RCALLS', 'HTTP_CALLS']
|
|
2073
|
+
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
|
2074
|
+
ORDER BY uid, relType
|
|
2075
|
+
LIMIT 30
|
|
2076
|
+
`, { symId }),
|
|
2077
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2078
|
+
MATCH (n {id: $symId})-[r:CodeRelation {type: 'ADVISED_BY'}]->(target)
|
|
2079
|
+
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
|
2080
|
+
ORDER BY uid
|
|
2081
|
+
LIMIT 30
|
|
2082
|
+
`, { symId }),
|
|
2083
|
+
]);
|
|
2084
|
+
outgoingRows.push(...outgoingAdvisedRows);
|
|
2085
|
+
// Process participation.
|
|
2086
|
+
//
|
|
2087
|
+
// MIN(r.step) enforces one row per process (#2787). It is behaviour
|
|
2088
|
+
// preserving on today's data — a full scan of this repo's index puts the
|
|
2089
|
+
// maximum STEP_IN_PROCESS edge count for any (symbol, process) pair at 1 —
|
|
2090
|
+
// but nothing in the schema caps it there, and `processes` is the only
|
|
2091
|
+
// uncapped number in this response, so a symbol that ever picks up a second
|
|
2092
|
+
// step edge would silently report an edge count instead of a process count.
|
|
2093
|
+
// Aggregating makes the one-row-per-process invariant explicit rather than
|
|
2094
|
+
// inherited from the data, and matches _runImpactBFS's twin query.
|
|
2095
|
+
let processRows = [];
|
|
2096
|
+
try {
|
|
2097
|
+
processRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2098
|
+
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
2099
|
+
RETURN p.id AS pid, p.heuristicLabel AS label, MIN(r.step) AS step, p.stepCount AS stepCount
|
|
2100
|
+
ORDER BY pid
|
|
2101
|
+
`, { symId });
|
|
2102
|
+
}
|
|
2103
|
+
catch (e) {
|
|
2104
|
+
logQueryError('context:process-participation', e);
|
|
2105
|
+
}
|
|
2106
|
+
// Helper to categorize refs
|
|
2107
|
+
const categorize = (rows) => {
|
|
2108
|
+
const cats = {};
|
|
2109
|
+
for (const row of rows) {
|
|
2110
|
+
const relType = (row.relType || row[0] || '').toLowerCase();
|
|
2111
|
+
const entry = {
|
|
2112
|
+
uid: row.uid || row[1],
|
|
2113
|
+
name: row.name || row[2],
|
|
2114
|
+
filePath: row.filePath || row[3],
|
|
2115
|
+
kind: row.kind || row[4],
|
|
2116
|
+
};
|
|
2117
|
+
if (!cats[relType])
|
|
2118
|
+
cats[relType] = [];
|
|
2119
|
+
cats[relType].push(entry);
|
|
2120
|
+
}
|
|
2121
|
+
return cats;
|
|
2122
|
+
};
|
|
2123
|
+
// Method/Function/Constructor enrichment: fetch method-specific properties
|
|
2124
|
+
const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
|
|
2125
|
+
const isMethodLike = symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
|
|
2126
|
+
// #1858 review F2 — start the epistemic boundary probe here (right after
|
|
2127
|
+
// `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
|
|
2128
|
+
// below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
|
|
2129
|
+
// at result assembly. (It cannot start earlier — `symKind` is only computed
|
|
2130
|
+
// on this line, after the incoming/outgoing round-trips.)
|
|
2131
|
+
//
|
|
2132
|
+
// #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
|
|
2133
|
+
// `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
|
|
2134
|
+
// is '' on the single-candidate path), which would skip computeEpistemicBoundary's
|
|
2135
|
+
// `symType === 'Interface'` self-boundary branch and under-report a leaf
|
|
2136
|
+
// interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
|
|
2137
|
+
// early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
|
|
2138
|
+
// '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
|
|
2139
|
+
// here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
|
|
2140
|
+
// "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
|
|
2141
|
+
const epistemicSymType = (resolvedLabel || sym.type || symKind || '');
|
|
2142
|
+
const epistemicPromise = this.computeEpistemicBoundary(repo, symId, epistemicSymType, (sym.name || sym[1]));
|
|
2143
|
+
const beanMetadataPromise = (0, bean_metadata_js_1.queryClassBeanMetadata)(repo.lbugPath, symId, epistemicSymType);
|
|
2144
|
+
const aopMetadataPromise = (0, aop_metadata_js_1.querySpringAopMetadata)(repo.lbugPath, symId, epistemicSymType);
|
|
2145
|
+
let methodMetadata;
|
|
2146
|
+
if (isMethodLike) {
|
|
2147
|
+
try {
|
|
2148
|
+
// determinism: probe — PK-anchored singleton. $symId is a node primary key, so at most one row of method
|
|
2149
|
+
// metadata can match.
|
|
2150
|
+
const metaRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2151
|
+
MATCH (n {id: $symId})
|
|
2152
|
+
RETURN n.visibility AS visibility, n.isStatic AS isStatic, n.isAbstract AS isAbstract,
|
|
2153
|
+
n.isFinal AS isFinal, n.isVirtual AS isVirtual, n.isOverride AS isOverride,
|
|
2154
|
+
n.isAsync AS isAsync, n.isPartial AS isPartial, n.returnType AS returnType,
|
|
2155
|
+
n.parameterCount AS parameterCount, n.isVariadic AS isVariadic,
|
|
2156
|
+
n.requiredParameterCount AS requiredParameterCount,
|
|
2157
|
+
n.parameterTypes AS parameterTypes, n.annotations AS annotations
|
|
2158
|
+
LIMIT 1
|
|
2159
|
+
`, { symId });
|
|
2160
|
+
if (metaRows.length > 0) {
|
|
2161
|
+
const row = metaRows[0];
|
|
2162
|
+
const meta = {};
|
|
2163
|
+
// Only include defined properties to distinguish "not applicable" from "not enriched"
|
|
2164
|
+
for (const key of Object.keys(row)) {
|
|
2165
|
+
const val = row[key];
|
|
2166
|
+
if (val !== null && val !== undefined)
|
|
2167
|
+
meta[key] = val;
|
|
2168
|
+
}
|
|
2169
|
+
if (Object.keys(meta).length > 0)
|
|
2170
|
+
methodMetadata = meta;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
catch {
|
|
2174
|
+
/* method metadata unavailable — omit silently */
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
// #1858 — same epistemic boundary signal as impact(): when this symbol sits
|
|
2178
|
+
// behind an interface / indirection boundary, callers binding via DI or
|
|
2179
|
+
// dynamic dispatch are not reflected in `incoming`, so the view is a lower
|
|
2180
|
+
// bound. Additive; never suppresses a field. Resolved from the probe started
|
|
2181
|
+
// above (concurrent with methodMetadata).
|
|
2182
|
+
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
|
|
2183
|
+
epistemicPromise,
|
|
2184
|
+
beanMetadataPromise,
|
|
2185
|
+
aopMetadataPromise,
|
|
2186
|
+
]);
|
|
2187
|
+
return {
|
|
2188
|
+
status: 'found',
|
|
2189
|
+
symbol: {
|
|
2190
|
+
uid: sym.id || sym[0],
|
|
2191
|
+
name: sym.name || sym[1],
|
|
2192
|
+
kind: symKind,
|
|
2193
|
+
filePath: sym.filePath || sym[3],
|
|
2194
|
+
startLine: (0, line_display_js_1.toDisplayLine)(sym.startLine ?? sym[4]),
|
|
2195
|
+
endLine: (0, line_display_js_1.toDisplayLine)(sym.endLine ?? sym[5]),
|
|
2196
|
+
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
|
2197
|
+
...(methodMetadata ? { methodMetadata } : {}),
|
|
2198
|
+
...(beanMetadata ? { bean: beanMetadata } : {}),
|
|
2199
|
+
...(aopMetadata ? { aop: aopMetadata } : {}),
|
|
2200
|
+
},
|
|
2201
|
+
...epistemic,
|
|
2202
|
+
incoming: categorize(incomingRows),
|
|
2203
|
+
outgoing: categorize(outgoingRows),
|
|
2204
|
+
...(typedPropertyRows.length > 0
|
|
2205
|
+
? {
|
|
2206
|
+
typed_properties: typedPropertyRows.map((r) => ({
|
|
2207
|
+
uid: r.uid || r[0],
|
|
2208
|
+
name: r.name || r[1],
|
|
2209
|
+
filePath: r.filePath || r[2],
|
|
2210
|
+
kind: r.kind || r[3],
|
|
2211
|
+
declaredType: r.declaredType || r[4],
|
|
2212
|
+
})),
|
|
2213
|
+
}
|
|
2214
|
+
: {}),
|
|
2215
|
+
processes: processRows.map((r) => ({
|
|
2216
|
+
id: r.pid || r[0],
|
|
2217
|
+
name: r.label || r[1],
|
|
2218
|
+
step_index: r.step || r[2],
|
|
2219
|
+
step_count: r.stepCount || r[3],
|
|
2220
|
+
})),
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Detect changes — git-diff based impact analysis.
|
|
2225
|
+
* Maps changed lines to indexed symbols, then finds affected processes.
|
|
2226
|
+
*/
|
|
2227
|
+
async detectChanges(repo, params) {
|
|
2228
|
+
await this.ensureInitialized(repo);
|
|
2229
|
+
const scope = params.scope || 'unstaged';
|
|
2230
|
+
// Ignore CR-only EOL differences, while preserving meaningful whitespace changes.
|
|
2231
|
+
// execFileSync receives an argv array, so refs never pass through a shell.
|
|
2232
|
+
const diffArgs = buildDetectChangesDiffArgs(scope, params.base_ref);
|
|
2233
|
+
if (!diffArgs)
|
|
2234
|
+
return { error: 'base_ref is required for "compare" scope' };
|
|
2235
|
+
// Resolve the cwd for git diff (worktree handling — see resolveWorktreeCwd).
|
|
2236
|
+
//
|
|
2237
|
+
// diffCwd 即"聚合根":单仓场景下等于仓库根,多 git 子工程场景下是那层非 git
|
|
2238
|
+
// 的项目根。两种情况下索引里的 filePath 都相对它存储(filesystem-walker 以
|
|
2239
|
+
// repoPath 为基准),因此子工程 diff 路径需补该前缀才能命中符号(T-13b)。
|
|
2240
|
+
//
|
|
2241
|
+
// Resolution order (see resolveWorktreeCwd for details):
|
|
2242
|
+
// 1. params.worktree — explicit override, validated against the
|
|
2243
|
+
// registered repo's canonical root.
|
|
2244
|
+
// 2. Auto-detect — if the server's launch cwd (process.cwd()) is a
|
|
2245
|
+
// linked worktree of the same canonical repo, use its git root.
|
|
2246
|
+
// 3. repo.repoPath — fallback (original behaviour, handled inside
|
|
2247
|
+
// resolveWorktreeCwd when no worktree is detected).
|
|
2248
|
+
let diffCwd = resolveWorktreeCwd(repo.repoPath, process.cwd());
|
|
2249
|
+
if (params.worktree) {
|
|
2250
|
+
if (!path_1.default.isAbsolute(params.worktree)) {
|
|
2251
|
+
return {
|
|
2252
|
+
error: `worktree must be an absolute path, got: "${params.worktree}"`,
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
const providedResolved = path_1.default.resolve(params.worktree);
|
|
2256
|
+
const repoCanonical = (0, git_js_1.getCanonicalRepoRoot)(repo.repoPath);
|
|
2257
|
+
if (!repoCanonical) {
|
|
2258
|
+
return {
|
|
2259
|
+
error: `Could not determine canonical root for repo "${repo.repoPath}". Is git available?`,
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
const worktreeCanonical = (0, git_js_1.getCanonicalRepoRoot)(providedResolved);
|
|
2263
|
+
if (!worktreeCanonical || tryRealpath(worktreeCanonical) !== tryRealpath(repoCanonical)) {
|
|
2264
|
+
return {
|
|
2265
|
+
error: `worktree "${params.worktree}" is not a worktree of repo "${repo.repoPath}". Ensure the path is inside the same git repository.`,
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
diffCwd = providedResolved;
|
|
2269
|
+
}
|
|
2270
|
+
let fileDiffs;
|
|
2271
|
+
// 子工程采集失败(如 compare 缺 base-ref):逐项记录说明,不整体失败(spec §4)。
|
|
2272
|
+
const subprojectErrors = [];
|
|
2273
|
+
try {
|
|
2274
|
+
if ((0, git_js_1.isGitRepo)(diffCwd)) {
|
|
2275
|
+
// 单仓路径(基线):diffCwd 即仓库根,git diff 输出的 filePath 已相对该根。
|
|
2276
|
+
fileDiffs = await collectDetectChangesFileDiffs(diffCwd, diffArgs);
|
|
2277
|
+
}
|
|
2278
|
+
else {
|
|
2279
|
+
// 聚合根非 git(spec §4 / T-13b):发现一级 git 子工程,逐个按其自身 git
|
|
2280
|
+
// 状态采集变更,合并映射为受影响符号与执行流。scope/base-ref 语义对每个
|
|
2281
|
+
// 子工程一致适用;某子工程缺 base-ref 等失败记入 subprojectErrors。
|
|
2282
|
+
const subprojects = discoverGitSubprojects(diffCwd);
|
|
2283
|
+
if (subprojects.length === 0) {
|
|
2284
|
+
// 根与各子工程均无 git → 明确报错(spec §9,T-13 守卫保留此路径)。
|
|
2285
|
+
return {
|
|
2286
|
+
error: '[cgraph] detect-changes 需要 git 工作树。该索引为纯目录(无 .git)且未发现 git 子工程——变更检测不可用。',
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
const collected = await collectSubprojectFileDiffs(diffCwd, subprojects, diffArgs);
|
|
2290
|
+
fileDiffs = collected.fileDiffs;
|
|
2291
|
+
subprojectErrors.push(...collected.errors);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
catch (err) {
|
|
2295
|
+
return { error: `Git diff failed: ${err.message}` };
|
|
2296
|
+
}
|
|
2297
|
+
if (fileDiffs.length === 0) {
|
|
2298
|
+
return {
|
|
2299
|
+
summary: {
|
|
2300
|
+
changed_count: 0,
|
|
2301
|
+
affected_count: 0,
|
|
2302
|
+
risk_level: 'none',
|
|
2303
|
+
message: 'No changes detected.',
|
|
2304
|
+
},
|
|
2305
|
+
changed_symbols: [],
|
|
2306
|
+
affected_processes: [],
|
|
2307
|
+
// 子工程采集失败时即便无变更也要把失败说明带出去,避免假"干净"。
|
|
2308
|
+
...(subprojectErrors.length > 0 && { subproject_errors: subprojectErrors }),
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
// Map diff hunks to indexed symbols via range overlap
|
|
2312
|
+
const changedSymbols = [];
|
|
2313
|
+
// Set if a swallowed graph query fails below — surfaces `partial:true` so a
|
|
2314
|
+
// degraded run cannot report a false-clean `risk_level:'low'` (#2283).
|
|
2315
|
+
let queryDegraded = false;
|
|
2316
|
+
for (const fileDiff of fileDiffs) {
|
|
2317
|
+
if (fileDiff.hunks.length === 0)
|
|
2318
|
+
continue;
|
|
2319
|
+
// Build range overlap conditions for all hunks in this file
|
|
2320
|
+
const overlapConditions = fileDiff.hunks
|
|
2321
|
+
.map((_, i) => `(n.startLine <= $hunkEnd${i} AND n.endLine >= $hunkStart${i})`)
|
|
2322
|
+
.join(' OR ');
|
|
2323
|
+
const queryParams = { filePath: fileDiff.filePath };
|
|
2324
|
+
fileDiff.hunks.forEach((hunk, i) => {
|
|
2325
|
+
queryParams[`hunkStart${i}`] = hunk.startLine;
|
|
2326
|
+
queryParams[`hunkEnd${i}`] = hunk.endLine;
|
|
2327
|
+
});
|
|
2328
|
+
// Exclude BasicBlock rows by id prefix: on a --pdg index every edited
|
|
2329
|
+
// function otherwise contributes N nameless BasicBlock pseudo-"symbols"
|
|
2330
|
+
// (they carry filePath/start/end but no name), inflating changed_count
|
|
2331
|
+
// and risk level with rows no consumer can act on (#2082 U7). Blocks
|
|
2332
|
+
// are implementation substrate, not symbols — the owning Function row
|
|
2333
|
+
// already represents the change. The id prefix (`BasicBlock:<file>:…`,
|
|
2334
|
+
// cfg/emit.ts basicBlockId) beats a label predicate (`labels(n)[0]` is
|
|
2335
|
+
// known to come back empty for several node types — see
|
|
2336
|
+
// enrichCandidateLabels) AND beats `n.name IS NOT NULL` (which would
|
|
2337
|
+
// also drop legitimate symbols whose name loaded as NULL, e.g.
|
|
2338
|
+
// quoted-empty CSV fields for anonymous constructs).
|
|
2339
|
+
const symbolQuery = `
|
|
2340
|
+
MATCH (n) WHERE n.filePath ENDS WITH $filePath
|
|
2341
|
+
AND NOT n.id STARTS WITH 'BasicBlock:'
|
|
2342
|
+
AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL
|
|
2343
|
+
AND (${overlapConditions})
|
|
2344
|
+
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type,
|
|
2345
|
+
n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
|
2346
|
+
`;
|
|
2347
|
+
try {
|
|
2348
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, symbolQuery, queryParams);
|
|
2349
|
+
for (const sym of rows) {
|
|
2350
|
+
changedSymbols.push({
|
|
2351
|
+
id: sym.id || sym[0],
|
|
2352
|
+
name: sym.name || sym[1],
|
|
2353
|
+
type: sym.type || sym[2],
|
|
2354
|
+
filePath: sym.filePath || sym[3],
|
|
2355
|
+
change_type: 'touched',
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
catch (e) {
|
|
2360
|
+
logQueryError('detect-changes:file-symbols', e);
|
|
2361
|
+
// The symbol query failed: changedSymbols stays empty and the result
|
|
2362
|
+
// would otherwise look like a clean no-op (`changed_count:0`,
|
|
2363
|
+
// `risk_level:'low'`). detect_changes is the pre-commit safety gate, so
|
|
2364
|
+
// flag the result `partial` rather than let a swallowed failure
|
|
2365
|
+
// masquerade as "nothing changed" (#2283).
|
|
2366
|
+
queryDegraded = true;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
// Find affected processes -- single batched query instead of N+1
|
|
2370
|
+
const affectedProcesses = new Map();
|
|
2371
|
+
if (changedSymbols.length > 0) {
|
|
2372
|
+
const symIds = changedSymbols.map((s) => s.id);
|
|
2373
|
+
const symNameById = new Map(changedSymbols.map((s) => [s.id, s.name]));
|
|
2374
|
+
try {
|
|
2375
|
+
const procs = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
2376
|
+
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
2377
|
+
WHERE n.id IN $ids
|
|
2378
|
+
RETURN n.id AS nodeId, p.id AS pid, p.heuristicLabel AS label,
|
|
2379
|
+
p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
|
2380
|
+
`, { ids: symIds });
|
|
2381
|
+
for (const proc of procs) {
|
|
2382
|
+
const nodeId = proc.nodeId || proc[0];
|
|
2383
|
+
const pid = proc.pid || proc[1];
|
|
2384
|
+
if (!affectedProcesses.has(pid)) {
|
|
2385
|
+
affectedProcesses.set(pid, {
|
|
2386
|
+
id: pid,
|
|
2387
|
+
name: proc.label || proc[2],
|
|
2388
|
+
process_type: proc.processType || proc[3],
|
|
2389
|
+
step_count: proc.stepCount || proc[4],
|
|
2390
|
+
changed_steps: [],
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
affectedProcesses.get(pid).changed_steps.push({
|
|
2394
|
+
symbol: symNameById.get(nodeId) ?? nodeId,
|
|
2395
|
+
step: proc.step || proc[5],
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
catch (e) {
|
|
2400
|
+
logQueryError('detect-changes:process-lookup', e);
|
|
2401
|
+
queryDegraded = true;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
const processCount = affectedProcesses.size;
|
|
2405
|
+
const risk = processCount === 0
|
|
2406
|
+
? 'low'
|
|
2407
|
+
: processCount <= 5
|
|
2408
|
+
? 'medium'
|
|
2409
|
+
: processCount <= 15
|
|
2410
|
+
? 'high'
|
|
2411
|
+
: 'critical';
|
|
2412
|
+
return {
|
|
2413
|
+
summary: {
|
|
2414
|
+
changed_count: changedSymbols.length,
|
|
2415
|
+
affected_count: processCount,
|
|
2416
|
+
changed_files: fileDiffs.length,
|
|
2417
|
+
risk_level: risk,
|
|
2418
|
+
},
|
|
2419
|
+
changed_symbols: changedSymbols,
|
|
2420
|
+
affected_processes: Array.from(affectedProcesses.values()),
|
|
2421
|
+
// A swallowed query failure makes the counts/risk above incomplete — tell
|
|
2422
|
+
// the caller so the safety gate isn't trusted as a clean result (#2283).
|
|
2423
|
+
...(queryDegraded && { partial: true }),
|
|
2424
|
+
// 聚合根多 git 子工程场景(T-13b):逐子工程采集时,某子工程失败(典型为
|
|
2425
|
+
// compare 的 base-ref 在该子工程不存在)在此带出而非整体失败(spec §4)。
|
|
2426
|
+
...(subprojectErrors.length > 0 && { subproject_errors: subprojectErrors }),
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
async trace(repo, params) {
|
|
2430
|
+
try {
|
|
2431
|
+
return await this._traceImpl(repo, params);
|
|
2432
|
+
}
|
|
2433
|
+
catch (err) {
|
|
2434
|
+
return {
|
|
2435
|
+
status: 'error',
|
|
2436
|
+
error: (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed',
|
|
2437
|
+
from: { name: params.from },
|
|
2438
|
+
to: { name: params.to },
|
|
2439
|
+
suggestion: 'The graph query failed — try cgraph context <symbol> to see connections, ' +
|
|
2440
|
+
'or check if an interface bridges them.',
|
|
2441
|
+
...((0, lbug_config_js_1.isWalCorruptionError)(err) ? { recoverySuggestion: lbug_config_js_1.WAL_RECOVERY_SUGGESTION } : {}),
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
async _traceImpl(repo, params) {
|
|
2446
|
+
await this.ensureInitialized(repo);
|
|
2447
|
+
// resolveSymbolCandidates feeds `from`/`to` into string operations
|
|
2448
|
+
// (e.g. name.includes), so a non-string param would surface a low-level
|
|
2449
|
+
// "x.includes is not a function". Reject it with a clear message instead.
|
|
2450
|
+
const isStringOrAbsent = (v) => v === undefined || typeof v === 'string';
|
|
2451
|
+
if (!isStringOrAbsent(params.from) ||
|
|
2452
|
+
!isStringOrAbsent(params.to) ||
|
|
2453
|
+
!isStringOrAbsent(params.from_uid) ||
|
|
2454
|
+
!isStringOrAbsent(params.to_uid)) {
|
|
2455
|
+
return {
|
|
2456
|
+
status: 'error',
|
|
2457
|
+
error: "'from', 'to', and their *_uid variants must be strings.",
|
|
2458
|
+
suggestion: 'Pass symbol names or UIDs as strings, e.g. trace from="A" to="B".',
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
// A trace needs a target — reject a to-less call with an actionable error
|
|
2462
|
+
// rather than the opaque "Target symbol 'undefined' not found".
|
|
2463
|
+
const hasTo = (typeof params.to === 'string' && params.to.trim() !== '') ||
|
|
2464
|
+
(typeof params.to_uid === 'string' && params.to_uid.trim() !== '');
|
|
2465
|
+
if (!hasTo) {
|
|
2466
|
+
return {
|
|
2467
|
+
status: 'error',
|
|
2468
|
+
error: 'trace requires `to` (or `to_uid`).',
|
|
2469
|
+
suggestion: 'Pass a target symbol name or UID.',
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
const fromOutcome = await this.resolveSymbolCandidates(repo, { uid: params.from_uid, name: params.from }, { file_path: params.from_file });
|
|
2473
|
+
if (fromOutcome.kind === 'not_found') {
|
|
2474
|
+
return {
|
|
2475
|
+
status: 'not_found',
|
|
2476
|
+
error: `Source symbol '${params.from_uid ?? params.from}' not found.`,
|
|
2477
|
+
suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.',
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
if (fromOutcome.kind === 'ambiguous') {
|
|
2481
|
+
const { atLeast, showing, fields } = ambiguityReport(fromOutcome, fromOutcome.candidates.length);
|
|
2482
|
+
return {
|
|
2483
|
+
status: 'ambiguous',
|
|
2484
|
+
role: 'from',
|
|
2485
|
+
message: `Found ${atLeast}${fromOutcome.total} symbols matching '${params.from}'${showing}. Disambiguate with --from-uid.`,
|
|
2486
|
+
...fields,
|
|
2487
|
+
candidates: fromOutcome.candidates,
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
const toOutcome = await this.resolveSymbolCandidates(repo, { uid: params.to_uid, name: params.to }, { file_path: params.to_file });
|
|
2491
|
+
if (toOutcome.kind === 'not_found') {
|
|
2492
|
+
return {
|
|
2493
|
+
status: 'not_found',
|
|
2494
|
+
error: `Target symbol '${params.to_uid ?? params.to}' not found.`,
|
|
2495
|
+
suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.',
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
if (toOutcome.kind === 'ambiguous') {
|
|
2499
|
+
const { atLeast, showing, fields } = ambiguityReport(toOutcome, toOutcome.candidates.length);
|
|
2500
|
+
return {
|
|
2501
|
+
status: 'ambiguous',
|
|
2502
|
+
role: 'to',
|
|
2503
|
+
message: `Found ${atLeast}${toOutcome.total} symbols matching '${params.to}'${showing}. Disambiguate with --to-uid.`,
|
|
2504
|
+
...fields,
|
|
2505
|
+
candidates: toOutcome.candidates,
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
const fromSym = fromOutcome.symbol;
|
|
2509
|
+
const toSym = toOutcome.symbol;
|
|
2510
|
+
if (fromSym.id === toSym.id) {
|
|
2511
|
+
return {
|
|
2512
|
+
status: 'ok',
|
|
2513
|
+
from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
|
|
2514
|
+
to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
|
|
2515
|
+
hopCount: 0,
|
|
2516
|
+
hops: [{ name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }],
|
|
2517
|
+
edges: [],
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
// Sanitize maxDepth at the real boundary: the MCP inputSchema's
|
|
2521
|
+
// minimum/maximum is advisory only (callTool is reachable directly), so a
|
|
2522
|
+
// caller can pass 0, a negative, NaN, or a non-integer. `??` does NOT
|
|
2523
|
+
// recover 0/NaN, and Math.min has no lower bound — left unguarded, any of
|
|
2524
|
+
// those makes the BFS loop run zero iterations and return a false no_path.
|
|
2525
|
+
const DEFAULT_TRACE_DEPTH = 10;
|
|
2526
|
+
const MAX_TRACE_DEPTH = 30;
|
|
2527
|
+
const requestedDepth = Number.isInteger(params.maxDepth) && params.maxDepth > 0
|
|
2528
|
+
? params.maxDepth
|
|
2529
|
+
: DEFAULT_TRACE_DEPTH;
|
|
2530
|
+
const maxDepth = Math.min(requestedDepth, MAX_TRACE_DEPTH);
|
|
2531
|
+
const includeTests = params.includeTests ?? false;
|
|
2532
|
+
// Traversal vocabulary: CALLS for actual calls, HAS_METHOD so a class-rooted
|
|
2533
|
+
// trace can descend into its methods. Not "calls only" — per-hop edge type is
|
|
2534
|
+
// surfaced in edges[] so containment hops stay distinguishable.
|
|
2535
|
+
const TRAVERSAL_EDGE_TYPES = ['CALLS', 'HAS_METHOD'];
|
|
2536
|
+
// Bound the traversal so a high-fanout hub (a logger/util reached by many
|
|
2537
|
+
// symbols) can't materialize an unbounded frontier. Per-level rows are
|
|
2538
|
+
// capped and the total visited set is capped; either cap sets `truncated`
|
|
2539
|
+
// so a resulting no_path is never reported as if the graph was exhausted.
|
|
2540
|
+
const PER_NODE_FANOUT_CAP = 200;
|
|
2541
|
+
const ABS_ROW_CAP = 5000;
|
|
2542
|
+
const MAX_VISITED = 50000;
|
|
2543
|
+
let truncated = false;
|
|
2544
|
+
const visited = new Set([fromSym.id]);
|
|
2545
|
+
let frontier = [fromSym.id];
|
|
2546
|
+
const parent = new Map();
|
|
2547
|
+
let found = false;
|
|
2548
|
+
// The last node discovered at the deepest reached level — surfaced as
|
|
2549
|
+
// `furthest` in the no_path response to hint where the chain breaks.
|
|
2550
|
+
let lastReached = null;
|
|
2551
|
+
let reachedDepth = 0;
|
|
2552
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !found; depth++) {
|
|
2553
|
+
const nextFrontier = [];
|
|
2554
|
+
// LadybugDB/Kuzu does not support a parameterized LIMIT, so the cap is
|
|
2555
|
+
// interpolated (it is a derived integer, not user input).
|
|
2556
|
+
//
|
|
2557
|
+
// The ORDER BY below is required for two separate reasons (#2787). When a
|
|
2558
|
+
// level overflows `rowCap`, an unordered LIMIT decided WHICH neighbours
|
|
2559
|
+
// survived per process — so the same trace(from, to) could return a path
|
|
2560
|
+
// on one run and `no_path` on the next. And even with no truncation, the
|
|
2561
|
+
// `parent` map below is first-writer-wins, so among several equal-length
|
|
2562
|
+
// shortest paths the reported hops/edges (and `lastReached`) followed raw
|
|
2563
|
+
// row order.
|
|
2564
|
+
const rowCap = Math.min(frontier.length * PER_NODE_FANOUT_CAP, ABS_ROW_CAP);
|
|
2565
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (n)-[r:CodeRelation]->(m)
|
|
2566
|
+
WHERE n.id IN $frontierIds AND r.type IN $edgeTypes
|
|
2567
|
+
RETURN n.id AS sourceId, m.id AS id, m.name AS name, labels(m)[0] AS type,
|
|
2568
|
+
m.filePath AS filePath, m.startLine AS startLine,
|
|
2569
|
+
r.type AS edgeType, r.confidence AS confidence
|
|
2570
|
+
ORDER BY sourceId, id, edgeType
|
|
2571
|
+
LIMIT ${rowCap}`, { frontierIds: frontier, edgeTypes: TRAVERSAL_EDGE_TYPES });
|
|
2572
|
+
// A clipped level may have dropped a node that lies on the only shortest
|
|
2573
|
+
// path, so any subsequent no_path is not authoritative.
|
|
2574
|
+
if (rows.length >= rowCap)
|
|
2575
|
+
truncated = true;
|
|
2576
|
+
for (const row of rows) {
|
|
2577
|
+
// Decode once. The `?? row[N]` fallback handles LadybugDB tuple-mode
|
|
2578
|
+
// returns; the positional indices mirror the RETURN column order above.
|
|
2579
|
+
const nodeId = (row.id ?? row[1]);
|
|
2580
|
+
const sourceId = (row.sourceId ?? row[0]);
|
|
2581
|
+
const name = (row.name ?? row[2]);
|
|
2582
|
+
const filePath = (row.filePath ?? row[4]);
|
|
2583
|
+
const startLine = (row.startLine ?? row[5]);
|
|
2584
|
+
const edgeType = (row.edgeType ?? row[6]);
|
|
2585
|
+
const storedConfidence = row.confidence ?? row[7];
|
|
2586
|
+
const confidence = typeof storedConfidence === 'number' && storedConfidence > 0
|
|
2587
|
+
? storedConfidence
|
|
2588
|
+
: confidenceForRelType(edgeType);
|
|
2589
|
+
// Match the explicitly-requested target before the test-file filter.
|
|
2590
|
+
// resolveSymbolCandidates does not exclude test-file symbols, so a
|
|
2591
|
+
// target (or a required hop) that lives in a test file would otherwise
|
|
2592
|
+
// be dropped by the includeTests guard below and produce a false
|
|
2593
|
+
// no_path even when a direct edge exists.
|
|
2594
|
+
if (nodeId === toSym.id) {
|
|
2595
|
+
parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
|
|
2596
|
+
found = true;
|
|
2597
|
+
break;
|
|
2598
|
+
}
|
|
2599
|
+
// Skip non-target nodes that live in test files unless includeTests.
|
|
2600
|
+
if (!includeTests && isTestFilePath(filePath))
|
|
2601
|
+
continue;
|
|
2602
|
+
if (!visited.has(nodeId)) {
|
|
2603
|
+
visited.add(nodeId);
|
|
2604
|
+
parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
|
|
2605
|
+
nextFrontier.push(nodeId);
|
|
2606
|
+
lastReached = { name, filePath, startLine };
|
|
2607
|
+
reachedDepth = depth;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
frontier = nextFrontier;
|
|
2611
|
+
if (visited.size >= MAX_VISITED) {
|
|
2612
|
+
truncated = true;
|
|
2613
|
+
break;
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
if (found) {
|
|
2617
|
+
const path = [];
|
|
2618
|
+
const edges = [];
|
|
2619
|
+
let current = toSym.id;
|
|
2620
|
+
while (current !== fromSym.id) {
|
|
2621
|
+
const info = parent.get(current);
|
|
2622
|
+
path.unshift({ name: info.name, filePath: info.filePath, startLine: info.startLine });
|
|
2623
|
+
edges.unshift({ relType: info.edgeType, confidence: info.confidence });
|
|
2624
|
+
current = info.from;
|
|
2625
|
+
}
|
|
2626
|
+
path.unshift({
|
|
2627
|
+
name: fromSym.name,
|
|
2628
|
+
filePath: fromSym.filePath,
|
|
2629
|
+
startLine: fromSym.startLine,
|
|
2630
|
+
});
|
|
2631
|
+
return {
|
|
2632
|
+
status: 'ok',
|
|
2633
|
+
from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
|
|
2634
|
+
to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
|
|
2635
|
+
hopCount: edges.length,
|
|
2636
|
+
hops: path,
|
|
2637
|
+
edges,
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
return {
|
|
2641
|
+
status: 'no_path',
|
|
2642
|
+
from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
|
|
2643
|
+
to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
|
|
2644
|
+
furthest: lastReached ? { ...lastReached, depth: reachedDepth } : null,
|
|
2645
|
+
...(truncated ? { truncated: true } : {}),
|
|
2646
|
+
suggestion: truncated
|
|
2647
|
+
? 'Search was truncated at a traversal cap before exhausting the graph — a path ' +
|
|
2648
|
+
'may still exist. Narrow the search (a lower --depth, or trace from a more ' +
|
|
2649
|
+
'specific symbol), or use cgraph context <symbol> to inspect connections.'
|
|
2650
|
+
: 'No directed path found. The call chain likely breaks at dynamic dispatch, ' +
|
|
2651
|
+
'reflection, or an external API boundary. Try cgraph context <symbol> to see ' +
|
|
2652
|
+
"both symbols' connections, or check if an interface/abstraction bridges them.",
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
async impact(repo, params) {
|
|
2656
|
+
try {
|
|
2657
|
+
return await this._impactImpl(repo, params);
|
|
2658
|
+
}
|
|
2659
|
+
catch (err) {
|
|
2660
|
+
// Return structured error instead of crashing (#321)
|
|
2661
|
+
const message = (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed';
|
|
2662
|
+
const suggestion = 'The graph query failed — try cgraph context <symbol> as a fallback';
|
|
2663
|
+
const recoverySuggestion = (0, lbug_config_js_1.isWalCorruptionError)(err) ? lbug_config_js_1.WAL_RECOVERY_SUGGESTION : undefined;
|
|
2664
|
+
return {
|
|
2665
|
+
error: message,
|
|
2666
|
+
target: { name: params.target },
|
|
2667
|
+
direction: params.direction,
|
|
2668
|
+
impactedCount: 0,
|
|
2669
|
+
risk: 'UNKNOWN',
|
|
2670
|
+
suggestion,
|
|
2671
|
+
...(recoverySuggestion ? { recoverySuggestion } : {}),
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
async _impactImpl(repo, params) {
|
|
2676
|
+
await this.ensureInitialized(repo);
|
|
2677
|
+
const { target, direction } = params;
|
|
2678
|
+
const maxDepth = params.maxDepth || 3;
|
|
2679
|
+
// Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES)
|
|
2680
|
+
const mappedRelTypes = params.relationTypes?.flatMap((t) => t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t]);
|
|
2681
|
+
const hasExplicitRelationTypes = mappedRelTypes !== undefined && mappedRelTypes.length > 0;
|
|
2682
|
+
const rawRelTypes = mappedRelTypes && mappedRelTypes.length > 0
|
|
2683
|
+
? mappedRelTypes.filter((t) => exports.VALID_RELATION_TYPES.has(t))
|
|
2684
|
+
: [
|
|
2685
|
+
'CALLS',
|
|
2686
|
+
'IMPORTS',
|
|
2687
|
+
'EXTENDS',
|
|
2688
|
+
'IMPLEMENTS',
|
|
2689
|
+
'USES',
|
|
2690
|
+
'METHOD_OVERRIDES',
|
|
2691
|
+
'OVERRIDES',
|
|
2692
|
+
'METHOD_IMPLEMENTS',
|
|
2693
|
+
// 跨服务调用边(spec §4):使 impact 默认遍历可跨 RCALLS/HTTP_CALLS
|
|
2694
|
+
'RCALLS',
|
|
2695
|
+
'HTTP_CALLS',
|
|
2696
|
+
];
|
|
2697
|
+
const relationTypes = rawRelTypes.length > 0
|
|
2698
|
+
? rawRelTypes
|
|
2699
|
+
: [
|
|
2700
|
+
'CALLS',
|
|
2701
|
+
'IMPORTS',
|
|
2702
|
+
'EXTENDS',
|
|
2703
|
+
'IMPLEMENTS',
|
|
2704
|
+
'USES',
|
|
2705
|
+
'METHOD_OVERRIDES',
|
|
2706
|
+
'OVERRIDES',
|
|
2707
|
+
'METHOD_IMPLEMENTS',
|
|
2708
|
+
// 跨服务调用边(spec §4):使 impact 默认遍历可跨 RCALLS/HTTP_CALLS
|
|
2709
|
+
'RCALLS',
|
|
2710
|
+
'HTTP_CALLS',
|
|
2711
|
+
];
|
|
2712
|
+
const includeTests = params.includeTests ?? false;
|
|
2713
|
+
const minConfidence = params.minConfidence ?? 0;
|
|
2714
|
+
// Resolve target via the shared symbol resolver. When the caller passes
|
|
2715
|
+
// target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise
|
|
2716
|
+
// we rank candidates (#470) and either proceed with a confident single
|
|
2717
|
+
// match, or return a structured ambiguous response instead of silently
|
|
2718
|
+
// picking the wrong symbol.
|
|
2719
|
+
//
|
|
2720
|
+
// The resolver preserves the #480 Class/Constructor preference heuristic:
|
|
2721
|
+
// when a Class and its Constructor share name + filePath, the Class is
|
|
2722
|
+
// selected silently.
|
|
2723
|
+
const outcome = await this.resolveSymbolCandidates(repo, { uid: params.target_uid, name: target }, { file_path: params.file_path, kind: params.kind });
|
|
2724
|
+
if (outcome.kind === 'not_found') {
|
|
2725
|
+
const missing = params.target_uid ?? target;
|
|
2726
|
+
return {
|
|
2727
|
+
error: `Target '${missing}' not found`,
|
|
2728
|
+
target: { name: target },
|
|
2729
|
+
direction,
|
|
2730
|
+
impactedCount: 0,
|
|
2731
|
+
risk: 'UNKNOWN',
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
if (outcome.kind === 'ambiguous') {
|
|
2735
|
+
// Truncation cap for the ambiguous candidate probe list.
|
|
2736
|
+
const AMBIGUOUS_MAX_CANDIDATES = 6;
|
|
2737
|
+
// #2129 — a bare name that collides with several symbols must NOT report a
|
|
2738
|
+
// bare `impactedCount: 0`. The real blast radius lives under whichever
|
|
2739
|
+
// candidate the caller meant; a flat zero here is precisely the silent
|
|
2740
|
+
// under-report the "run impact before editing" workflow exists to prevent
|
|
2741
|
+
// (the dropped caller calls a *different* same-name node, so it never shows
|
|
2742
|
+
// up against the one the resolver happened to pick). Run a bounded,
|
|
2743
|
+
// summary-only BFS per candidate so each one's true count + risk is
|
|
2744
|
+
// visible, and surface the maximum at the top level so the headline can
|
|
2745
|
+
// never read as "safe to refactor". Candidates arrive sorted by score.
|
|
2746
|
+
const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
|
|
2747
|
+
// `partialProbe` is intentionally a SECOND incompleteness flag, distinct
|
|
2748
|
+
// from the traversal-interrupted `partial` flag used elsewhere: it means
|
|
2749
|
+
// one or more per-candidate probes threw, so maxRisk / maxImpactedCount
|
|
2750
|
+
// are lower bounds over the probes that succeeded (a failed candidate must
|
|
2751
|
+
// not be masked by a benign sibling success).
|
|
2752
|
+
let probeFailed = false;
|
|
2753
|
+
const candidateSummaries = await Promise.all(probed.map(async (c) => {
|
|
2754
|
+
const cType = c.type || '';
|
|
2755
|
+
const cRelTypes = (cType === 'Class' || cType === 'Interface') &&
|
|
2756
|
+
!hasExplicitRelationTypes &&
|
|
2757
|
+
!relationTypes.includes('ACCESSES')
|
|
2758
|
+
? [...relationTypes, 'ACCESSES']
|
|
2759
|
+
: relationTypes;
|
|
2760
|
+
// #1858/#2129 review F8 — name the shape the probe summary is read
|
|
2761
|
+
// through (`_runImpactBFS` returns `Promise<any>`, so this is the
|
|
2762
|
+
// narrowing cast) so a future rename of those fields fails tsc instead
|
|
2763
|
+
// of silently zeroing candidate counts.
|
|
2764
|
+
let summary = null;
|
|
2765
|
+
try {
|
|
2766
|
+
summary = await this._runImpactBFS(repo, { id: c.id, name: c.name, filePath: c.filePath }, cType, direction, {
|
|
2767
|
+
maxDepth,
|
|
2768
|
+
relationTypes: cRelTypes,
|
|
2769
|
+
includeTests,
|
|
2770
|
+
minConfidence,
|
|
2771
|
+
summaryOnly: true,
|
|
2772
|
+
skipEpistemic: true,
|
|
2773
|
+
skipEnrichment: true,
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
catch (e) {
|
|
2777
|
+
probeFailed = true;
|
|
2778
|
+
logQueryError('impact:ambiguous-candidate', e);
|
|
2779
|
+
}
|
|
2780
|
+
return {
|
|
2781
|
+
uid: c.id,
|
|
2782
|
+
name: c.name,
|
|
2783
|
+
kind: c.type,
|
|
2784
|
+
filePath: c.filePath,
|
|
2785
|
+
line: (0, line_display_js_1.toDisplayLine)(c.startLine),
|
|
2786
|
+
score: Number(c.score.toFixed(2)),
|
|
2787
|
+
impactedCount: summary?.impactedCount ?? 0,
|
|
2788
|
+
risk: summary?.risk ?? 'UNKNOWN',
|
|
2789
|
+
direct: summary?.summary?.direct ?? 0,
|
|
2790
|
+
};
|
|
2791
|
+
}));
|
|
2792
|
+
// Rank by blast radius so the most-impactful interpretation is first, and
|
|
2793
|
+
// hoist the maximum count/risk to the top level so the response cannot be
|
|
2794
|
+
// misread as "no impact".
|
|
2795
|
+
candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
|
|
2796
|
+
const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
|
|
2797
|
+
const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
|
2798
|
+
// If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
|
|
2799
|
+
// under the fan-out), the worst real risk is genuinely unknown, not LOW.
|
|
2800
|
+
// Reporting LOW here would re-introduce the false-safe signal. Only fall to
|
|
2801
|
+
// the LOW seed when at least one candidate produced a real risk.
|
|
2802
|
+
const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
|
|
2803
|
+
const maxRisk = anyKnownRisk
|
|
2804
|
+
? candidateSummaries.reduce((worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst), 'LOW')
|
|
2805
|
+
: 'UNKNOWN';
|
|
2806
|
+
// `candidateSummaries` is `Promise.all` over `probed`, so the two lengths
|
|
2807
|
+
// are the same; `probed` is the one the message and the flag agree on.
|
|
2808
|
+
const { atLeast, showing, fields } = ambiguityReport(outcome, probed.length, true);
|
|
2809
|
+
return {
|
|
2810
|
+
status: 'ambiguous',
|
|
2811
|
+
message: `Found ${atLeast}${outcome.total} symbols matching '${target}'` +
|
|
2812
|
+
showing +
|
|
2813
|
+
`. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
|
|
2814
|
+
`Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
|
|
2815
|
+
target: { name: target },
|
|
2816
|
+
direction,
|
|
2817
|
+
// `totalCandidates` is the resolver's COUNT, not `candidates.length`:
|
|
2818
|
+
// that array is truncated to AMBIGUOUS_MAX_CANDIDATES and the resolver
|
|
2819
|
+
// window itself caps at CANDIDATE_WINDOW, so consumers (CLI formatter)
|
|
2820
|
+
// need the COUNT to report "N of M" honestly (#2129 review F11; the CLI
|
|
2821
|
+
// previously read the truncated array length, then the capped window
|
|
2822
|
+
// length — both undercounts).
|
|
2823
|
+
...fields,
|
|
2824
|
+
// `impactedCount` is `null` — UNDETERMINED, not zero — and `risk` stays
|
|
2825
|
+
// UNKNOWN, because there is no single resolved symbol. #2129 hoisted
|
|
2826
|
+
// `maxImpactedCount` / `maxRisk` here so a real caller could not hide
|
|
2827
|
+
// behind the ambiguous zero, but the zero itself remained
|
|
2828
|
+
// byte-identical to a genuine "nothing depends on this": a consumer
|
|
2829
|
+
// testing `impactedCount === 0` still read a confident all-clear
|
|
2830
|
+
// without ever looking at `candidates[]`. `null` cannot be mistaken for
|
|
2831
|
+
// a measured zero, while `|| 0` consumers are unchanged (#2687).
|
|
2832
|
+
impactedCount: null,
|
|
2833
|
+
risk: 'UNKNOWN',
|
|
2834
|
+
maxImpactedCount,
|
|
2835
|
+
maxRisk,
|
|
2836
|
+
...(probeFailed ? { partialProbe: true } : {}),
|
|
2837
|
+
candidates: candidateSummaries,
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
const sym = {
|
|
2841
|
+
id: outcome.symbol.id,
|
|
2842
|
+
name: outcome.symbol.name,
|
|
2843
|
+
filePath: outcome.symbol.filePath,
|
|
2844
|
+
};
|
|
2845
|
+
const symType = outcome.resolvedLabel || outcome.symbol.type || '';
|
|
2846
|
+
const effectiveRelationTypes = (symType === 'Class' || symType === 'Interface') &&
|
|
2847
|
+
!hasExplicitRelationTypes &&
|
|
2848
|
+
!relationTypes.includes('ACCESSES')
|
|
2849
|
+
? [...relationTypes, 'ACCESSES']
|
|
2850
|
+
: relationTypes;
|
|
2851
|
+
return this._runImpactBFS(repo, sym, symType, direction, {
|
|
2852
|
+
maxDepth,
|
|
2853
|
+
relationTypes: effectiveRelationTypes,
|
|
2854
|
+
includeTests,
|
|
2855
|
+
minConfidence,
|
|
2856
|
+
limit: Number.isFinite(params.limit) ? params.limit : 100,
|
|
2857
|
+
offset: Number.isFinite(params.offset) ? params.offset : 0,
|
|
2858
|
+
summaryOnly: params.summaryOnly,
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* #1858 — epistemic lower-bound detection.
|
|
2863
|
+
*
|
|
2864
|
+
* impact()/context() traverse only edges materialized in the graph. When the
|
|
2865
|
+
* queried symbol sits on an interface / abstract boundary, callers that bind
|
|
2866
|
+
* to the interface via DI, a container, or dynamic dispatch — rather than
|
|
2867
|
+
* naming the concrete symbol — are not traced. The reported count is then a
|
|
2868
|
+
* lower bound, not an exact figure. Instead of returning a confident count
|
|
2869
|
+
* that silently omits those callers, annotate the result with
|
|
2870
|
+
* `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
|
|
2871
|
+
* resolved leaf with no indirection stays `epistemic: 'exact'`.
|
|
2872
|
+
*
|
|
2873
|
+
* Aligns with the numeric confidence model rather than the long-deleted
|
|
2874
|
+
* TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
|
|
2875
|
+
* (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
|
|
2876
|
+
* `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
|
|
2877
|
+
* concrete binding past it is not".
|
|
2878
|
+
*
|
|
2879
|
+
* Never throws: on query error it returns 'exact', so it can only add signal,
|
|
2880
|
+
* never suppress a result.
|
|
2881
|
+
*/
|
|
2882
|
+
async computeEpistemicBoundary(repo, symId, symType, symName) {
|
|
2883
|
+
const HERITAGE_TYPES = exports.EPISTEMIC_HERITAGE_RELATION_TYPES;
|
|
2884
|
+
const CONSUMER_TYPES = exports.EPISTEMIC_CONSUMER_RELATION_TYPES;
|
|
2885
|
+
// #2744 — call sites dropped for want of a receiver type. Checked BEFORE
|
|
2886
|
+
// the heritage probe below and reported even when that probe finds nothing:
|
|
2887
|
+
// the two are independent reasons a count can be short, and this one is the
|
|
2888
|
+
// reason #2708 was filed. A dropped site's callee is unknown, so the index
|
|
2889
|
+
// records the member NAME invoked at the drop; a match on the queried
|
|
2890
|
+
// symbol's name means at least one call to something of that name was lost.
|
|
2891
|
+
const droppedBoundaries = await this.unresolvedReceiverBoundaries(repo, symName);
|
|
2892
|
+
try {
|
|
2893
|
+
// Discover the interface / abstract supertypes on the target's boundary.
|
|
2894
|
+
// If the target is itself an interface, it is its own boundary node.
|
|
2895
|
+
const boundary = new Map();
|
|
2896
|
+
if (symType === 'Interface') {
|
|
2897
|
+
boundary.set(symId, { name: symName || '', label: 'Interface' });
|
|
2898
|
+
}
|
|
2899
|
+
const ifaceRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (x)-[r:CodeRelation]->(iface)
|
|
2900
|
+
WHERE x.id = $symId AND r.type IN $heritage
|
|
2901
|
+
RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
|
|
2902
|
+
ORDER BY id
|
|
2903
|
+
LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch(() => []);
|
|
2904
|
+
for (const r of ifaceRows) {
|
|
2905
|
+
const id = (r.id ?? r[0]);
|
|
2906
|
+
if (id && !boundary.has(id)) {
|
|
2907
|
+
boundary.set(id, {
|
|
2908
|
+
name: (r.name ?? r[1] ?? ''),
|
|
2909
|
+
label: (r.label ?? r[2] ?? 'Interface'),
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
if (boundary.size === 0)
|
|
2914
|
+
return epistemicFrom(droppedBoundaries);
|
|
2915
|
+
const ifaceIds = Array.from(boundary.keys());
|
|
2916
|
+
// Count per interface id with scalar equality. A parameterized
|
|
2917
|
+
// `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
|
|
2918
|
+
// group-by returns no rows under the LadybugDB cypher subset, so query
|
|
2919
|
+
// each boundary node individually (boundary is small — capped at 25).
|
|
2920
|
+
const countByType = async (types) => {
|
|
2921
|
+
const m = new Map();
|
|
2922
|
+
await Promise.all(ifaceIds.map(async (ifaceId) => {
|
|
2923
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `MATCH (other)-[r:CodeRelation]->(iface)
|
|
2924
|
+
WHERE iface.id = $ifaceId AND r.type IN $types
|
|
2925
|
+
RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch(() => []);
|
|
2926
|
+
const cnt = rows.length > 0 ? Number(rows[0].cnt ?? rows[0][0] ?? 0) : 0;
|
|
2927
|
+
m.set(ifaceId, cnt);
|
|
2928
|
+
}));
|
|
2929
|
+
return m;
|
|
2930
|
+
};
|
|
2931
|
+
const [implCounts, consumerCounts] = await Promise.all([
|
|
2932
|
+
countByType(HERITAGE_TYPES),
|
|
2933
|
+
countByType(CONSUMER_TYPES),
|
|
2934
|
+
]);
|
|
2935
|
+
const boundaries = [];
|
|
2936
|
+
// Magnitude, not note count: see `EpistemicCauses.dispatchBoundary`. One
|
|
2937
|
+
// note can describe an interface with 40 implementations and hundreds of
|
|
2938
|
+
// interface-level consumers, so publishing `boundaries.length` would put
|
|
2939
|
+
// `1` next to a `receiverTyping` of `12` and tell a consumer branching on
|
|
2940
|
+
// the numbers that receiver typing dominates — the opposite of the truth.
|
|
2941
|
+
let dispatchBoundarySymbols = 0;
|
|
2942
|
+
for (const [id, info] of boundary) {
|
|
2943
|
+
const impls = implCounts.get(id) ?? 0;
|
|
2944
|
+
const consumers = consumerCounts.get(id) ?? 0;
|
|
2945
|
+
// Flag only a genuine indirection risk: an interface that is actually
|
|
2946
|
+
// consumed (callers bind to it) or that has multiple implementations
|
|
2947
|
+
// (runtime dispatch is ambiguous). A concrete type implementing an
|
|
2948
|
+
// interface nothing references is fully traced → stays exact.
|
|
2949
|
+
if (consumers >= 1 || impls >= 2) {
|
|
2950
|
+
dispatchBoundarySymbols += impls + consumers;
|
|
2951
|
+
const label = (info.label || 'Interface').toLowerCase();
|
|
2952
|
+
const name = info.name || '(unnamed)';
|
|
2953
|
+
const article = /^[aeiou]/.test(label) ? 'an' : 'a';
|
|
2954
|
+
const parts = [];
|
|
2955
|
+
if (impls >= 1)
|
|
2956
|
+
parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
|
|
2957
|
+
if (consumers >= 1)
|
|
2958
|
+
parts.push(`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`);
|
|
2959
|
+
boundaries.push(`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
|
|
2960
|
+
`(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
|
|
2961
|
+
`actual impact may be higher.`);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
if (boundaries.length === 0)
|
|
2965
|
+
return epistemicFrom(droppedBoundaries);
|
|
2966
|
+
return {
|
|
2967
|
+
epistemic: 'lower-bound',
|
|
2968
|
+
boundaries: [...droppedBoundaries.notes, ...boundaries],
|
|
2969
|
+
causes: {
|
|
2970
|
+
receiverTyping: droppedBoundaries.sites,
|
|
2971
|
+
dispatchBoundary: dispatchBoundarySymbols,
|
|
2972
|
+
externalBoundary: droppedBoundaries.external,
|
|
2973
|
+
},
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
catch {
|
|
2977
|
+
// Never let the heritage probe's failure suppress a drop we already know
|
|
2978
|
+
// about — the whole point is that silence must not read as certainty.
|
|
2979
|
+
return epistemicFrom(droppedBoundaries);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Boundary notes for call sites the analyzer dropped because it could not
|
|
2984
|
+
* type their receiver, when the queried symbol's name is among them (#2744).
|
|
2985
|
+
* Empty when the index records no drops for this name — including every
|
|
2986
|
+
* index written before the summary existed, which is why the schema version
|
|
2987
|
+
* was bumped rather than treating "absent" as "none".
|
|
2988
|
+
*/
|
|
2989
|
+
async unresolvedReceiverBoundaries(repo, symName) {
|
|
2990
|
+
if (symName.length === 0)
|
|
2991
|
+
return { notes: [], sites: 0, external: 0 };
|
|
2992
|
+
try {
|
|
2993
|
+
const meta = await (0, repo_manager_js_1.loadMeta)(path_1.default.dirname(repo.lbugPath));
|
|
2994
|
+
const summary = meta?.unresolvedReceiverMembers;
|
|
2995
|
+
// Prototype-safe: see `lookupUnresolvedCallCount`. A bare `counts[symName]`
|
|
2996
|
+
// returns a Function for `constructor`/`toString`/… and `NaN <= 0` is false,
|
|
2997
|
+
// so the old guard let it through into user-facing text.
|
|
2998
|
+
const sites = (0, unresolved_receivers_js_1.lookupUnresolvedCallCount)(summary, symName);
|
|
2999
|
+
const external = (0, unresolved_receivers_js_1.lookupExternalCallCount)(summary, symName) ?? 0;
|
|
3000
|
+
if (sites === undefined)
|
|
3001
|
+
return { notes: [], sites: 0, external };
|
|
3002
|
+
const notes = [
|
|
3003
|
+
`${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${sites === 1 ? 'was' : 'were'} dropped at index time because the receiver's type could not be ` +
|
|
3004
|
+
`established (e.g. an unresolved constructor, factory or chained ` +
|
|
3005
|
+
`expression). Those callers are absent from this result — actual ` +
|
|
3006
|
+
`impact may be higher.`,
|
|
3007
|
+
];
|
|
3008
|
+
return { notes, sites, external };
|
|
3009
|
+
}
|
|
3010
|
+
catch {
|
|
3011
|
+
return { notes: [], sites: 0, external: 0 };
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
|
|
3016
|
+
*/
|
|
3017
|
+
async _runImpactBFS(repo, sym, symType, direction, opts) {
|
|
3018
|
+
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
|
|
3019
|
+
const skipEnrichment = opts.skipEnrichment ?? false;
|
|
3020
|
+
const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
|
|
3021
|
+
const paginationLimit = hasExplicitLimit
|
|
3022
|
+
? Math.max(1, Math.min(Math.trunc(opts.limit), 10000))
|
|
3023
|
+
: Infinity;
|
|
3024
|
+
const rawOffset = typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0;
|
|
3025
|
+
const paginationOffset = Math.max(0, Math.trunc(rawOffset));
|
|
3026
|
+
const summaryOnly = opts.summaryOnly ?? false;
|
|
3027
|
+
// Bind the BFS frontier query's filters as parameters (#1907 review F5):
|
|
3028
|
+
// node ids and relation types as bound lists, the confidence floor as a
|
|
3029
|
+
// bound number — no string interpolation reaches the query text. Preserve
|
|
3030
|
+
// the original "no confidence clause when minConfidence <= 0" behavior: an
|
|
3031
|
+
// unconditional `>= 0` would wrongly exclude NULL-confidence edges that the
|
|
3032
|
+
// unfiltered query includes.
|
|
3033
|
+
const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
|
|
3034
|
+
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
|
3035
|
+
const symId = sym.id || sym[0];
|
|
3036
|
+
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
|
|
3037
|
+
// It depends only on symId/symType/symName (all known now) and touches no
|
|
3038
|
+
// shared state, so its extra round-trip overlaps the traversal instead of
|
|
3039
|
+
// adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
|
|
3040
|
+
// probes, group fan-out) resolves to no field, preserving prior behavior.
|
|
3041
|
+
// #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
|
|
3042
|
+
// optional here (the union's `{}` subtype). computeEpistemicBoundary's own
|
|
3043
|
+
// return keeps `epistemic` REQUIRED — only this promise widens to the skip
|
|
3044
|
+
// subtype.
|
|
3045
|
+
// `causes` is part of the annotation, not just of the runtime value: the
|
|
3046
|
+
// spread below is what publishes these fields, and a narrower annotation
|
|
3047
|
+
// erases `causes` at the type level while still shipping it at runtime —
|
|
3048
|
+
// so every consumer would be reading a field the compiler says is absent.
|
|
3049
|
+
const epistemicPromise = opts.skipEpistemic
|
|
3050
|
+
? Promise.resolve({})
|
|
3051
|
+
: this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]));
|
|
3052
|
+
const beanMetadataPromise = opts.skipEpistemic || summaryOnly
|
|
3053
|
+
? Promise.resolve(undefined)
|
|
3054
|
+
: (0, bean_metadata_js_1.queryClassBeanMetadata)(repo.lbugPath, symId, symType);
|
|
3055
|
+
const aopMetadataPromise = opts.skipEpistemic || summaryOnly
|
|
3056
|
+
? Promise.resolve(undefined)
|
|
3057
|
+
: (0, aop_metadata_js_1.querySpringAopMetadata)(repo.lbugPath, symId, symType);
|
|
3058
|
+
const impacted = [];
|
|
3059
|
+
const visited = new Set([symId]);
|
|
3060
|
+
let frontier = [symId];
|
|
3061
|
+
let traversalComplete = true;
|
|
3062
|
+
// Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
|
|
3063
|
+
// point to Constructor nodes and IMPORTS edges point to File nodes — not
|
|
3064
|
+
// the Class/Interface itself. Seed the frontier with the Constructor(s)
|
|
3065
|
+
// and owning File so the BFS traversal finds those edges naturally.
|
|
3066
|
+
// The owning File is kept only as an internal seed (frontier/visited) and
|
|
3067
|
+
// is NOT added to impacted — it is the definition container, not an
|
|
3068
|
+
// upstream dependent. The BFS will discover IMPORTS edges on it naturally.
|
|
3069
|
+
if (symType === 'Class' || symType === 'Interface') {
|
|
3070
|
+
try {
|
|
3071
|
+
// Run both seed queries in parallel — they are independent.
|
|
3072
|
+
const [ctorRows, fileRows] = await Promise.all([
|
|
3073
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3074
|
+
MATCH (n)-[hm:CodeRelation]->(c:Constructor)
|
|
3075
|
+
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
|
3076
|
+
RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath
|
|
3077
|
+
`, { symId }),
|
|
3078
|
+
// Restrict to DEFINES edges only — other File->Class edge types (if
|
|
3079
|
+
// any) should not be treated as the owning file relationship.
|
|
3080
|
+
(0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3081
|
+
MATCH (f:File)-[rel:CodeRelation]->(n)
|
|
3082
|
+
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
|
3083
|
+
RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath
|
|
3084
|
+
`, { symId }),
|
|
3085
|
+
]);
|
|
3086
|
+
for (const r of ctorRows) {
|
|
3087
|
+
const rid = r.id || r[0];
|
|
3088
|
+
if (rid && !visited.has(rid)) {
|
|
3089
|
+
visited.add(rid);
|
|
3090
|
+
frontier.push(rid);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
for (const r of fileRows) {
|
|
3094
|
+
const rid = r.id || r[0];
|
|
3095
|
+
if (rid && !visited.has(rid)) {
|
|
3096
|
+
visited.add(rid);
|
|
3097
|
+
frontier.push(rid);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
const typedPropertyRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3101
|
+
MATCH (p:\`Property\`)
|
|
3102
|
+
WHERE p.declaredType = $name
|
|
3103
|
+
OR p.declaredType STARTS WITH $genericPrefix
|
|
3104
|
+
OR p.declaredType CONTAINS $genericArg
|
|
3105
|
+
RETURN p.id AS id, p.name AS name, labels(p)[0] AS type, p.filePath AS filePath
|
|
3106
|
+
`, {
|
|
3107
|
+
name: sym.name,
|
|
3108
|
+
genericPrefix: `${sym.name}<`,
|
|
3109
|
+
genericArg: `<${sym.name}>`,
|
|
3110
|
+
});
|
|
3111
|
+
for (const r of typedPropertyRows) {
|
|
3112
|
+
const rid = r.id || r[0];
|
|
3113
|
+
if (rid && !visited.has(rid)) {
|
|
3114
|
+
visited.add(rid);
|
|
3115
|
+
frontier.push(rid);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
catch (e) {
|
|
3120
|
+
logQueryError('impact:class-node-expansion', e);
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
3124
|
+
const nextFrontier = [];
|
|
3125
|
+
// Batch frontier nodes into a single Cypher query per depth level.
|
|
3126
|
+
// ids/types/confidence are bound parameters (see above) — no interpolation.
|
|
3127
|
+
//
|
|
3128
|
+
// Deliberately NO `ORDER BY` (#2787). Every other ordered query in this
|
|
3129
|
+
// file pairs its key with a small `LIMIT`, so the engine answers it from a
|
|
3130
|
+
// bounded top-k heap and the ordering is nearly free. This one had no such
|
|
3131
|
+
// escape: the engine had to materialize and fully sort EVERY neighbour edge
|
|
3132
|
+
// of the whole frontier — tens of thousands of rows at depth 2 for a hub
|
|
3133
|
+
// symbol — on a four-key comparator led by a long
|
|
3134
|
+
// `Label:filePath:qualifiedName` string, dragging the wide `name`/
|
|
3135
|
+
// `filePath` columns through the sort, once per depth level, on the tool
|
|
3136
|
+
// that runs before every symbol edit.
|
|
3137
|
+
//
|
|
3138
|
+
// The ordering only ever served three POSITIONAL consumers of `impacted`:
|
|
3139
|
+
// the relationType/confidence stamped on a node reached by more than one
|
|
3140
|
+
// edge (the first row won); the process/module enrichment, which covers
|
|
3141
|
+
// only `impacted.slice(0, MAX_CHUNKS * CHUNK_SIZE)` and feeds the risk
|
|
3142
|
+
// thresholds; and byDepth pagination, which slices without re-sorting. All
|
|
3143
|
+
// three need strictly less than a total sort of edges, so the ordering now
|
|
3144
|
+
// lives in JS below, where the guarantee is STRONGER as well as cheaper:
|
|
3145
|
+
// an engine's collation and sort stability are not ours to specify or
|
|
3146
|
+
// version-pin, whereas `compareCodeUnits` is exactly UTF-16 code-unit
|
|
3147
|
+
// order and `impactEdgeConfidenceRank` pins where a NULL confidence lands.
|
|
3148
|
+
// Reproduced there, at O(E) plus a sort of NODES rather than of edges:
|
|
3149
|
+
// * per reached id, an argmax under `relType ASC, confidence DESC,
|
|
3150
|
+
// sourceId ASC` — what "first row wins" meant once the key led with
|
|
3151
|
+
// `id` — via `compareImpactEdgeStrength`, and
|
|
3152
|
+
// * `impacted` appended id-ascending over the distinct newly-visited
|
|
3153
|
+
// nodes, via `compareImpactFrontierEdges`.
|
|
3154
|
+
//
|
|
3155
|
+
// `confidence DESC` is part of the key, not decoration (#2787 review F2):
|
|
3156
|
+
// (id, relType) is NOT unique — 2181 of ~10020 groups on this repo's index
|
|
3157
|
+
// carry more than one distinct confidence, and 0.7 vs 0.85 straddles the
|
|
3158
|
+
// `< 0.8 = fuzzy` boundary the tool description publishes. Taking the
|
|
3159
|
+
// strongest edge makes the stamped pair fully determined by the key AND
|
|
3160
|
+
// retains the strongest evidence, the safer default for a blast-radius
|
|
3161
|
+
// tool. `sourceId` closes the order for edges that tie on both.
|
|
3162
|
+
const query = direction === 'upstream'
|
|
3163
|
+
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
|
3164
|
+
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
|
3165
|
+
try {
|
|
3166
|
+
const related = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, query, {
|
|
3167
|
+
frontierIds: frontier,
|
|
3168
|
+
relTypes: relationTypes,
|
|
3169
|
+
...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
|
|
3170
|
+
});
|
|
3171
|
+
const edges = related.map((rel) => ({
|
|
3172
|
+
id: rel.id || rel[1],
|
|
3173
|
+
name: rel.name || rel[2],
|
|
3174
|
+
type: rel.type || rel[3],
|
|
3175
|
+
filePath: rel.filePath || rel[4] || '',
|
|
3176
|
+
relType: rel.relType || rel[5],
|
|
3177
|
+
confidence: rel.confidence ?? rel[6],
|
|
3178
|
+
sourceId: String(rel.sourceId ?? rel[0] ?? ''),
|
|
3179
|
+
}));
|
|
3180
|
+
// Argmax edge per newly-reached node, under the key the DB used to
|
|
3181
|
+
// carry. Rebuilt per depth level, exactly like `nextFrontier`.
|
|
3182
|
+
const bestEdgeByNode = new Map();
|
|
3183
|
+
for (const edge of edges) {
|
|
3184
|
+
if (!includeTests && isTestFilePath(edge.filePath))
|
|
3185
|
+
continue;
|
|
3186
|
+
// Nodes seeded or reached at an EARLIER depth contribute no new
|
|
3187
|
+
// `impacted` entry. `visited` no longer grows inside this pass — it
|
|
3188
|
+
// used to double as the "first row wins" argmax, a job
|
|
3189
|
+
// `bestEdgeByNode` now does explicitly — so the guard reads the same
|
|
3190
|
+
// set for every edge of the level, which is what it always meant.
|
|
3191
|
+
if (visited.has(edge.id))
|
|
3192
|
+
continue;
|
|
3193
|
+
const incumbent = bestEdgeByNode.get(edge.id);
|
|
3194
|
+
if (incumbent === undefined || compareImpactEdgeStrength(edge, incumbent) < 0) {
|
|
3195
|
+
bestEdgeByNode.set(edge.id, edge);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
for (const edge of [...bestEdgeByNode.values()].sort(compareImpactFrontierEdges)) {
|
|
3199
|
+
visited.add(edge.id);
|
|
3200
|
+
nextFrontier.push(edge.id);
|
|
3201
|
+
const storedConfidence = edge.confidence;
|
|
3202
|
+
const relationType = edge.relType;
|
|
3203
|
+
// Prefer the stored confidence from the graph (set at analysis time);
|
|
3204
|
+
// fall back to the per-type floor for edges without a stored value.
|
|
3205
|
+
const effectiveConfidence = typeof storedConfidence === 'number' && storedConfidence > 0
|
|
3206
|
+
? storedConfidence
|
|
3207
|
+
: confidenceForRelType(relationType);
|
|
3208
|
+
impacted.push({
|
|
3209
|
+
depth,
|
|
3210
|
+
id: edge.id,
|
|
3211
|
+
name: edge.name,
|
|
3212
|
+
type: edge.type,
|
|
3213
|
+
filePath: edge.filePath,
|
|
3214
|
+
relationType,
|
|
3215
|
+
confidence: effectiveConfidence,
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
catch (e) {
|
|
3220
|
+
logQueryError('impact:depth-traversal', e);
|
|
3221
|
+
// Break out of depth loop on query failure but return partial results
|
|
3222
|
+
// collected so far, rather than silently swallowing the error (#321)
|
|
3223
|
+
traversalComplete = false;
|
|
3224
|
+
break;
|
|
3225
|
+
}
|
|
3226
|
+
frontier = nextFrontier;
|
|
3227
|
+
}
|
|
3228
|
+
const grouped = {};
|
|
3229
|
+
for (const item of impacted) {
|
|
3230
|
+
if (!grouped[item.depth])
|
|
3231
|
+
grouped[item.depth] = [];
|
|
3232
|
+
grouped[item.depth].push(item);
|
|
3233
|
+
}
|
|
3234
|
+
// ── Enrichment: affected processes, modules, risk ──────────────
|
|
3235
|
+
const directCount = (grouped[1] || []).length;
|
|
3236
|
+
let affectedProcesses = [];
|
|
3237
|
+
let affectedModules = [];
|
|
3238
|
+
// Per-symbol process membership: maps impacted symbol id -> list of processes
|
|
3239
|
+
// it participates in. Populated by a second chunked Cypher pass below when
|
|
3240
|
+
// any process is affected at all. Surfaced as `processes: [...]` on each
|
|
3241
|
+
// byDepth item so consumers can tell which caller belongs to which cron/
|
|
3242
|
+
// webhook/route without a follow-up query.
|
|
3243
|
+
const perSymbolProcesses = new Map();
|
|
3244
|
+
// Chunking bounds for batched DB round-trips. Declared at function scope so
|
|
3245
|
+
// both the in-block enrichment passes and the post-pagination per-symbol
|
|
3246
|
+
// process enrichment can reference them.
|
|
3247
|
+
const CHUNK_SIZE = 100;
|
|
3248
|
+
// Max number of chunks to process to avoid unbounded DB round-trips.
|
|
3249
|
+
// Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
|
|
3250
|
+
const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
|
|
3251
|
+
// `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
|
|
3252
|
+
// process/module aggregation passes entirely — those probes need only the
|
|
3253
|
+
// count + a count-based risk, so paying the bounded-but-real enrichment cost
|
|
3254
|
+
// ~6× per ambiguous call is wasted. risk then derives from directCount /
|
|
3255
|
+
// total only (processCount/moduleCount stay 0), an acceptable approximation
|
|
3256
|
+
// for a disambiguation aid.
|
|
3257
|
+
if (impacted.length > 0 && !skipEnrichment) {
|
|
3258
|
+
// ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
|
|
3259
|
+
// Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
|
|
3260
|
+
// process + entry point info in 1 round-trip per chunk. Converted to
|
|
3261
|
+
// parameterized queries to avoid manual string escaping and long query strings.
|
|
3262
|
+
const entryPointMap = new Map();
|
|
3263
|
+
// Map process id -> entryPointId to allow fixing missing minStep values later
|
|
3264
|
+
const processToEntryPoint = new Map();
|
|
3265
|
+
// Collect process ids where MIN(r.step) returned null so we can retry in batch
|
|
3266
|
+
const processesMissingMinStep = new Set();
|
|
3267
|
+
let chunksProcessed = 0;
|
|
3268
|
+
for (let i = 0; i < impacted.length && chunksProcessed < MAX_CHUNKS; i += CHUNK_SIZE, chunksProcessed++) {
|
|
3269
|
+
const chunk = impacted.slice(i, i + CHUNK_SIZE);
|
|
3270
|
+
const ids = chunk.map((item) => String(item.id ?? ''));
|
|
3271
|
+
try {
|
|
3272
|
+
// Use parameterized list to avoid building long query strings
|
|
3273
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3274
|
+
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
3275
|
+
WHERE s.id IN $ids
|
|
3276
|
+
WITH p, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep
|
|
3277
|
+
OPTIONAL MATCH (ep {id: p.entryPointId})
|
|
3278
|
+
RETURN p.id AS pId, p.heuristicLabel AS name, p.processType AS processType,
|
|
3279
|
+
p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount,
|
|
3280
|
+
ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath
|
|
3281
|
+
ORDER BY pId
|
|
3282
|
+
`, { ids }).catch(() => []);
|
|
3283
|
+
for (const row of rows) {
|
|
3284
|
+
const pId = row.pId ?? row[0];
|
|
3285
|
+
const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
|
|
3286
|
+
// Track mapping from process -> entryPoint so we can backfill missing minStep
|
|
3287
|
+
if (pId)
|
|
3288
|
+
processToEntryPoint.set(String(pId), String(epId));
|
|
3289
|
+
// Normalize epName: prefer epName, fall back to other columns, and
|
|
3290
|
+
// ensure we don't keep an empty string (labels(...) can return "").
|
|
3291
|
+
const epNameRaw = row.epName ?? row[7] ?? row.name ?? row[1] ?? 'unknown';
|
|
3292
|
+
const epName = typeof epNameRaw === 'string' && epNameRaw.trim().length > 0
|
|
3293
|
+
? epNameRaw.trim()
|
|
3294
|
+
: 'unknown';
|
|
3295
|
+
// Normalize epType: labels(ep)[0] can return an empty string in
|
|
3296
|
+
// some DBs (LadybugDB). Using nullish coalescing (??) preserves
|
|
3297
|
+
// empty strings, which results in empty `type` values being
|
|
3298
|
+
// propagated. Treat empty-string labels as missing and fall back
|
|
3299
|
+
// to the next candidate or a sensible default.
|
|
3300
|
+
const epTypeRaw = row.epType ?? row[8] ?? '';
|
|
3301
|
+
const epType = typeof epTypeRaw === 'string' && epTypeRaw.trim().length > 0
|
|
3302
|
+
? epTypeRaw.trim()
|
|
3303
|
+
: 'Function';
|
|
3304
|
+
const epFilePath = row.epFilePath ?? row[9] ?? '';
|
|
3305
|
+
const hits = row.hits ?? row[4] ?? 0;
|
|
3306
|
+
const minStep = row.minStep ?? row[5];
|
|
3307
|
+
// If the DB returned null for minStep, note the process id so we
|
|
3308
|
+
// can run a follow-up query using a different aggregation strategy.
|
|
3309
|
+
if (minStep === null || minStep === undefined) {
|
|
3310
|
+
if (pId)
|
|
3311
|
+
processesMissingMinStep.add(String(pId));
|
|
3312
|
+
}
|
|
3313
|
+
if (!entryPointMap.has(epId)) {
|
|
3314
|
+
entryPointMap.set(epId, {
|
|
3315
|
+
name: epName,
|
|
3316
|
+
type: epType,
|
|
3317
|
+
filePath: epFilePath,
|
|
3318
|
+
affected_process_count: 0,
|
|
3319
|
+
total_hits: 0,
|
|
3320
|
+
earliest_broken_step: Infinity,
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
const ep = entryPointMap.get(epId);
|
|
3324
|
+
ep.affected_process_count += 1;
|
|
3325
|
+
ep.total_hits += hits;
|
|
3326
|
+
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
catch (e) {
|
|
3330
|
+
logQueryError('impact:process-chunk', e);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
// If some processes returned null minStep, try a batched follow-up query
|
|
3334
|
+
// using the full impacted id set. This handles older indexes or DBs
|
|
3335
|
+
// where MIN(r.step) can come back null even when step properties exist.
|
|
3336
|
+
if (processesMissingMinStep.size > 0) {
|
|
3337
|
+
try {
|
|
3338
|
+
const pIds = Array.from(processesMissingMinStep);
|
|
3339
|
+
const allImpactedIds = impacted.map((it) => String(it.id ?? ''));
|
|
3340
|
+
const missingRows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3341
|
+
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
3342
|
+
WHERE p.id IN $pIds AND s.id IN $ids
|
|
3343
|
+
RETURN p.id AS pid, MIN(r.step) AS minStep
|
|
3344
|
+
`, { pIds, ids: allImpactedIds }).catch(() => []);
|
|
3345
|
+
for (const mr of missingRows) {
|
|
3346
|
+
const pid = mr.pid ?? mr[0];
|
|
3347
|
+
const minStep = mr.minStep ?? mr[1];
|
|
3348
|
+
const epId = processToEntryPoint.get(String(pid));
|
|
3349
|
+
if (!epId)
|
|
3350
|
+
continue;
|
|
3351
|
+
const ep = entryPointMap.get(epId);
|
|
3352
|
+
if (!ep)
|
|
3353
|
+
continue;
|
|
3354
|
+
if (typeof minStep === 'number') {
|
|
3355
|
+
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep);
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
catch (e) {
|
|
3360
|
+
logQueryError('impact:process-chunk-backfill', e);
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
// If we capped chunks, mark traversal incomplete so caller knows results are partial
|
|
3364
|
+
if (chunksProcessed * CHUNK_SIZE < impacted.length) {
|
|
3365
|
+
traversalComplete = false;
|
|
3366
|
+
}
|
|
3367
|
+
// (total_hits, filePath, name) is NOT unique across distinct entry points —
|
|
3368
|
+
// two of them collide on all three in this repo alone (`step`, same file,
|
|
3369
|
+
// cgraph-web/src/hooks/useSigma.ts) and equal `total_hits` is the norm —
|
|
3370
|
+
// so ties fell through to `Map` insertion order, i.e. raw row order (#2787
|
|
3371
|
+
// review F6). Sort the ENTRIES so the entry-point id (the map key) can close
|
|
3372
|
+
// the order, then project: the id stays out of the response payload.
|
|
3373
|
+
affectedProcesses = Array.from(entryPointMap.entries())
|
|
3374
|
+
.sort(([aId, a], [bId, b]) => b.total_hits - a.total_hits ||
|
|
3375
|
+
(0, utils_js_1.compareCodeUnits)(a.filePath, b.filePath) ||
|
|
3376
|
+
(0, utils_js_1.compareCodeUnits)(a.name, b.name) ||
|
|
3377
|
+
(0, utils_js_1.compareCodeUnits)(aId, bId))
|
|
3378
|
+
.map(([, ep]) => ({
|
|
3379
|
+
...ep,
|
|
3380
|
+
earliest_broken_step: ep.earliest_broken_step === Infinity ? null : ep.earliest_broken_step,
|
|
3381
|
+
}));
|
|
3382
|
+
// Per-symbol process membership is populated post-pagination (see below)
|
|
3383
|
+
// so it covers exactly the symbols returned in byDepth, not a pre-capped
|
|
3384
|
+
// flat slice that could miss depth-2+ symbols when depth-1 is large.
|
|
3385
|
+
// ── Module enrichment: use same cap as process enrichment and parameterized queries
|
|
3386
|
+
const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE);
|
|
3387
|
+
const cappedImpacted = impacted.slice(0, maxItems);
|
|
3388
|
+
const allIdsArr = cappedImpacted.map((i) => String(i.id ?? ''));
|
|
3389
|
+
const d1Items = (grouped[1] || []).slice(0, maxItems);
|
|
3390
|
+
const d1IdsArr = d1Items.map((i) => String(i.id ?? ''));
|
|
3391
|
+
// Chunked module enrichment: run the MEMBER_OF queries in chunks
|
|
3392
|
+
// to avoid large single queries or concurrent Kuzu calls that can
|
|
3393
|
+
// crash (SIGSEGV) on arm64 macOS; behavior preserves existing maxItems cap and returns equivalent aggregated results.
|
|
3394
|
+
const moduleHitsMap = new Map();
|
|
3395
|
+
const directModuleSet = new Set();
|
|
3396
|
+
// Helper to run a single module chunk and accumulate hits by name
|
|
3397
|
+
const runModuleChunk = async (idsChunk) => {
|
|
3398
|
+
if (!idsChunk || idsChunk.length === 0)
|
|
3399
|
+
return;
|
|
3400
|
+
try {
|
|
3401
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3402
|
+
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
3403
|
+
WHERE s.id IN $ids
|
|
3404
|
+
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
|
|
3405
|
+
ORDER BY hits DESC, name
|
|
3406
|
+
LIMIT 20
|
|
3407
|
+
`, { ids: idsChunk }).catch(() => []);
|
|
3408
|
+
for (const r of rows) {
|
|
3409
|
+
const name = r.name ?? r[0] ?? null;
|
|
3410
|
+
const hits = (r.hits ?? r[1]) || 0;
|
|
3411
|
+
if (!name)
|
|
3412
|
+
continue;
|
|
3413
|
+
moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits);
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
catch (e) {
|
|
3417
|
+
logQueryError('impact:module-chunk', e);
|
|
3418
|
+
}
|
|
3419
|
+
};
|
|
3420
|
+
// Run module query chunks sequentially (safe on arm64 macOS)
|
|
3421
|
+
for (let i = 0; i < allIdsArr.length; i += CHUNK_SIZE) {
|
|
3422
|
+
const chunkIds = allIdsArr.slice(i, i + CHUNK_SIZE);
|
|
3423
|
+
await runModuleChunk(chunkIds);
|
|
3424
|
+
}
|
|
3425
|
+
// Run direct module query similarly (distinct heuristic labels for depth-1 items)
|
|
3426
|
+
const runDirectModuleChunk = async (idsChunk) => {
|
|
3427
|
+
if (!idsChunk || idsChunk.length === 0)
|
|
3428
|
+
return;
|
|
3429
|
+
try {
|
|
3430
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3431
|
+
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
3432
|
+
WHERE s.id IN $ids
|
|
3433
|
+
RETURN DISTINCT c.heuristicLabel AS name
|
|
3434
|
+
`, { ids: idsChunk }).catch(() => []);
|
|
3435
|
+
for (const r of rows) {
|
|
3436
|
+
const name = r.name ?? r[0] ?? null;
|
|
3437
|
+
if (name)
|
|
3438
|
+
directModuleSet.add(name);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
catch (e) {
|
|
3442
|
+
logQueryError('impact:direct-module-chunk', e);
|
|
3443
|
+
}
|
|
3444
|
+
};
|
|
3445
|
+
for (let i = 0; i < d1IdsArr.length; i += CHUNK_SIZE) {
|
|
3446
|
+
const chunkIds = d1IdsArr.slice(i, i + CHUNK_SIZE);
|
|
3447
|
+
await runDirectModuleChunk(chunkIds);
|
|
3448
|
+
}
|
|
3449
|
+
// Build final moduleRows array from aggregated hits map, sorted & limited
|
|
3450
|
+
const moduleRows = Array.from(moduleHitsMap.entries())
|
|
3451
|
+
.map(([name, hits]) => ({ name, hits }))
|
|
3452
|
+
.sort((a, b) => b.hits - a.hits || (0, utils_js_1.compareCodeUnits)(String(a.name), String(b.name)))
|
|
3453
|
+
.slice(0, 20);
|
|
3454
|
+
const directModuleRows = Array.from(directModuleSet).map((name) => ({ name }));
|
|
3455
|
+
// Build affectedModules in the same shape as original implementation
|
|
3456
|
+
const directModuleNameSet = new Set(directModuleRows.map((r) => r.name || r[0]));
|
|
3457
|
+
affectedModules = moduleRows.map((r) => {
|
|
3458
|
+
const name = r.name ?? r[0];
|
|
3459
|
+
const hits = r.hits ?? r[1] ?? 0;
|
|
3460
|
+
return {
|
|
3461
|
+
name,
|
|
3462
|
+
hits,
|
|
3463
|
+
impact: directModuleNameSet.has(name) ? 'direct' : 'indirect',
|
|
3464
|
+
};
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
// Risk scoring
|
|
3468
|
+
const processCount = affectedProcesses.length;
|
|
3469
|
+
const moduleCount = affectedModules.length;
|
|
3470
|
+
let risk = 'LOW';
|
|
3471
|
+
if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) {
|
|
3472
|
+
risk = 'CRITICAL';
|
|
3473
|
+
}
|
|
3474
|
+
else if (directCount >= 15 ||
|
|
3475
|
+
processCount >= 3 ||
|
|
3476
|
+
moduleCount >= 3 ||
|
|
3477
|
+
impacted.length >= 100) {
|
|
3478
|
+
risk = 'HIGH';
|
|
3479
|
+
}
|
|
3480
|
+
else if (directCount >= 5 || impacted.length >= 30) {
|
|
3481
|
+
risk = 'MEDIUM';
|
|
3482
|
+
}
|
|
3483
|
+
// Build per-depth counts (always included, even in summaryOnly mode)
|
|
3484
|
+
const byDepthCounts = {};
|
|
3485
|
+
for (const [depth, items] of Object.entries(grouped)) {
|
|
3486
|
+
byDepthCounts[Number(depth)] = items.length;
|
|
3487
|
+
}
|
|
3488
|
+
// #1858 — await the epistemic boundary probe kicked off alongside the BFS
|
|
3489
|
+
// above. Additive: leaves impactedCount and every existing field untouched.
|
|
3490
|
+
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
|
|
3491
|
+
epistemicPromise,
|
|
3492
|
+
beanMetadataPromise,
|
|
3493
|
+
aopMetadataPromise,
|
|
3494
|
+
]);
|
|
3495
|
+
const base = {
|
|
3496
|
+
target: {
|
|
3497
|
+
id: symId,
|
|
3498
|
+
name: sym.name || sym[1],
|
|
3499
|
+
type: symType,
|
|
3500
|
+
filePath: sym.filePath || sym[2],
|
|
3501
|
+
...(beanMetadata ? { bean: beanMetadata } : {}),
|
|
3502
|
+
...(aopMetadata ? { aop: aopMetadata } : {}),
|
|
3503
|
+
},
|
|
3504
|
+
direction,
|
|
3505
|
+
impactedCount: impacted.length,
|
|
3506
|
+
risk,
|
|
3507
|
+
...epistemic,
|
|
3508
|
+
...(!traversalComplete && { partial: true }),
|
|
3509
|
+
summary: {
|
|
3510
|
+
direct: directCount,
|
|
3511
|
+
processes_affected: processCount,
|
|
3512
|
+
modules_affected: moduleCount,
|
|
3513
|
+
},
|
|
3514
|
+
byDepthCounts,
|
|
3515
|
+
affected_processes: affectedProcesses,
|
|
3516
|
+
affected_modules: affectedModules,
|
|
3517
|
+
};
|
|
3518
|
+
if (summaryOnly) {
|
|
3519
|
+
return base;
|
|
3520
|
+
}
|
|
3521
|
+
// Apply limit/offset pagination per depth level.
|
|
3522
|
+
const paginatedGrouped = {};
|
|
3523
|
+
let anyTruncated = false;
|
|
3524
|
+
for (const [depth, items] of Object.entries(grouped)) {
|
|
3525
|
+
const total = items.length;
|
|
3526
|
+
const sliced = items.slice(paginationOffset, paginationOffset + paginationLimit);
|
|
3527
|
+
paginatedGrouped[Number(depth)] = sliced;
|
|
3528
|
+
if (paginationOffset > 0 || paginationOffset + paginationLimit < total) {
|
|
3529
|
+
anyTruncated = true;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
// ── Per-symbol process membership enrichment (post-pagination) ───────
|
|
3533
|
+
// Runs after paginatedGrouped is built so we enrich only the IDs that
|
|
3534
|
+
// actually appear in the response. This eliminates the false-empty
|
|
3535
|
+
// processes:[] case where a depth-2+ symbol's flat position in `impacted`
|
|
3536
|
+
// exceeded MAX_CHUNKS*CHUNK_SIZE even though it is returned by byDepth.
|
|
3537
|
+
// Also uses DISTINCT + MIN(r.step) per (symbol, process) pair to avoid
|
|
3538
|
+
// duplicate entries when a symbol has multiple STEP_IN_PROCESS edges.
|
|
3539
|
+
let perSymbolEnrichmentCapped = false;
|
|
3540
|
+
if (affectedProcesses.length > 0) {
|
|
3541
|
+
// Collect unique IDs from the paginated result in one pass.
|
|
3542
|
+
const pageIds = new Set();
|
|
3543
|
+
for (const items of Object.values(paginatedGrouped)) {
|
|
3544
|
+
for (const it of items) {
|
|
3545
|
+
const id = String(it.id ?? '');
|
|
3546
|
+
if (id)
|
|
3547
|
+
pageIds.add(id);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
// Bound the enrichment to the same ceiling as the aggregation pass
|
|
3551
|
+
// (MAX_CHUNKS * CHUNK_SIZE) so a large paginated page cannot trigger
|
|
3552
|
+
// unbounded DB round-trips (DoD 2.6). When capped, mark the result
|
|
3553
|
+
// partial so callers know some returned symbols may carry an empty
|
|
3554
|
+
// processes:[] that is a cap artifact, not a true absence.
|
|
3555
|
+
const maxPageIds = MAX_CHUNKS * CHUNK_SIZE;
|
|
3556
|
+
let pageIdArr = Array.from(pageIds);
|
|
3557
|
+
if (pageIdArr.length > maxPageIds) {
|
|
3558
|
+
pageIdArr = pageIdArr.slice(0, maxPageIds);
|
|
3559
|
+
perSymbolEnrichmentCapped = true;
|
|
3560
|
+
}
|
|
3561
|
+
for (let i = 0; i < pageIdArr.length; i += CHUNK_SIZE) {
|
|
3562
|
+
const chunkIds = pageIdArr.slice(i, i + CHUNK_SIZE);
|
|
3563
|
+
try {
|
|
3564
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3565
|
+
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
3566
|
+
WHERE s.id IN $ids
|
|
3567
|
+
RETURN s.id AS sid, p.id AS pid, p.heuristicLabel AS pName,
|
|
3568
|
+
p.processType AS pType, MIN(r.step) AS step
|
|
3569
|
+
`, { ids: chunkIds }).catch(() => []);
|
|
3570
|
+
for (const row of rows) {
|
|
3571
|
+
const sid = row.sid ?? row[0];
|
|
3572
|
+
if (!sid)
|
|
3573
|
+
continue;
|
|
3574
|
+
const procEntry = {
|
|
3575
|
+
id: String(row.pid ?? row[1] ?? ''),
|
|
3576
|
+
label: String(row.pName ?? row[2] ?? ''),
|
|
3577
|
+
processType: String(row.pType ?? row[3] ?? ''),
|
|
3578
|
+
step: Number(row.step ?? row[4] ?? -1),
|
|
3579
|
+
};
|
|
3580
|
+
const list = perSymbolProcesses.get(String(sid));
|
|
3581
|
+
if (list)
|
|
3582
|
+
list.push(procEntry);
|
|
3583
|
+
else
|
|
3584
|
+
perSymbolProcesses.set(String(sid), [procEntry]);
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
catch (e) {
|
|
3588
|
+
logQueryError('impact:per-symbol-process-chunk', e);
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
// Attach processes field to each paginated item.
|
|
3593
|
+
for (const items of Object.values(paginatedGrouped)) {
|
|
3594
|
+
for (const it of items) {
|
|
3595
|
+
it.processes = perSymbolProcesses.get(String(it.id)) ?? [];
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
return {
|
|
3599
|
+
...base,
|
|
3600
|
+
// Surface partial if the per-symbol enrichment was capped, even when the
|
|
3601
|
+
// BFS traversal itself completed — some returned symbols may carry an
|
|
3602
|
+
// empty processes:[] that is a cap artifact rather than a true absence.
|
|
3603
|
+
...(perSymbolEnrichmentCapped && { partial: true }),
|
|
3604
|
+
...(anyTruncated && {
|
|
3605
|
+
pagination: {
|
|
3606
|
+
...(Number.isFinite(paginationLimit) && { limit: paginationLimit }),
|
|
3607
|
+
offset: paginationOffset,
|
|
3608
|
+
truncated: true,
|
|
3609
|
+
},
|
|
3610
|
+
}),
|
|
3611
|
+
byDepth: paginatedGrouped,
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
/**
|
|
3615
|
+
* Fetch Route nodes with their consumers in a single query.
|
|
3616
|
+
* Shared by routeMap and shapeCheck to avoid N+1 query patterns.
|
|
3617
|
+
*/
|
|
3618
|
+
async fetchRoutesWithConsumers(repoId, routeFilter, params) {
|
|
3619
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repoId, `
|
|
3620
|
+
MATCH (n:Route)
|
|
3621
|
+
WHERE n.id STARTS WITH 'Route:' ${routeFilter}
|
|
3622
|
+
OPTIONAL MATCH (consumer)-[r:CodeRelation]->(n)
|
|
3623
|
+
WHERE r.type = 'FETCHES'
|
|
3624
|
+
RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile,
|
|
3625
|
+
n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware,
|
|
3626
|
+
consumer.name AS consumerName, consumer.filePath AS consumerFile,
|
|
3627
|
+
r.reason AS fetchReason, n.method AS method
|
|
3628
|
+
`, params);
|
|
3629
|
+
// Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which
|
|
3630
|
+
// LadybugDB may return as "'key'" rather than "key"
|
|
3631
|
+
const stripQuotes = (keys) => keys ? keys.map((k) => k.replace(/^['"]|['"]$/g, '')) : null;
|
|
3632
|
+
const routeMap = new Map();
|
|
3633
|
+
for (const row of rows) {
|
|
3634
|
+
const id = row.routeId ?? row[0];
|
|
3635
|
+
const name = row.routeName ?? row[1];
|
|
3636
|
+
const filePath = row.handlerFile ?? row[2];
|
|
3637
|
+
const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null);
|
|
3638
|
+
const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null);
|
|
3639
|
+
const middleware = stripQuotes(row.middleware ?? row[5] ?? null);
|
|
3640
|
+
const consumerName = row.consumerName ?? row[6];
|
|
3641
|
+
const consumerFile = row.consumerFile ?? row[7];
|
|
3642
|
+
const fetchReason = row.fetchReason ?? row[8] ?? null;
|
|
3643
|
+
// Verb is the literal '*' for method-agnostic routes (Django function
|
|
3644
|
+
// views) and absent (null) for method-less routes (filesystem, Laravel
|
|
3645
|
+
// resource). Appended last in RETURN so positional fallbacks for the
|
|
3646
|
+
// consumer/reason columns above stay stable.
|
|
3647
|
+
const method = row.method ?? row[9] ?? null;
|
|
3648
|
+
if (!routeMap.has(id)) {
|
|
3649
|
+
routeMap.set(id, {
|
|
3650
|
+
id,
|
|
3651
|
+
name,
|
|
3652
|
+
method,
|
|
3653
|
+
filePath,
|
|
3654
|
+
responseKeys,
|
|
3655
|
+
errorKeys,
|
|
3656
|
+
middleware,
|
|
3657
|
+
consumers: [],
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
if (consumerName && consumerFile) {
|
|
3661
|
+
// Parse accessed keys from reason field: "fetch-url-match|keys:data,pagination|fetches:3"
|
|
3662
|
+
let accessedKeys;
|
|
3663
|
+
let fetchCount;
|
|
3664
|
+
if (fetchReason) {
|
|
3665
|
+
const keysMatch = fetchReason.match(/\|keys:([^|]+)/);
|
|
3666
|
+
if (keysMatch) {
|
|
3667
|
+
accessedKeys = keysMatch[1].split(',').filter((k) => k.length > 0);
|
|
3668
|
+
}
|
|
3669
|
+
const fetchesMatch = fetchReason.match(/\|fetches:(\d+)/);
|
|
3670
|
+
if (fetchesMatch) {
|
|
3671
|
+
fetchCount = parseInt(fetchesMatch[1], 10);
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
routeMap.get(id).consumers.push({
|
|
3675
|
+
name: consumerName,
|
|
3676
|
+
filePath: consumerFile,
|
|
3677
|
+
...(accessedKeys ? { accessedKeys } : {}),
|
|
3678
|
+
...(fetchCount && fetchCount > 1 ? { fetchCount } : {}),
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
return [...routeMap.values()];
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* Batch-fetch execution flows linked to a set of Route or Tool nodes.
|
|
3686
|
+
* Single query instead of N+1.
|
|
3687
|
+
*/
|
|
3688
|
+
async fetchLinkedFlowsBatch(repoId, nodeIds) {
|
|
3689
|
+
const result = new Map();
|
|
3690
|
+
if (nodeIds.length === 0)
|
|
3691
|
+
return result;
|
|
3692
|
+
try {
|
|
3693
|
+
// Use list_contains to filter at DB level instead of fetching all and filtering in memory
|
|
3694
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repoId, `
|
|
3695
|
+
MATCH (source)-[r:CodeRelation]->(proc:Process)
|
|
3696
|
+
WHERE r.type = 'ENTRY_POINT_OF'
|
|
3697
|
+
AND list_contains($nodeIds, source.id)
|
|
3698
|
+
RETURN source.id AS sourceId, proc.label AS name
|
|
3699
|
+
`, { nodeIds });
|
|
3700
|
+
for (const row of rows) {
|
|
3701
|
+
const sourceId = row.sourceId ?? row[0];
|
|
3702
|
+
const name = row.name ?? row[1];
|
|
3703
|
+
if (!name)
|
|
3704
|
+
continue;
|
|
3705
|
+
let list = result.get(sourceId);
|
|
3706
|
+
if (!list) {
|
|
3707
|
+
list = [];
|
|
3708
|
+
result.set(sourceId, list);
|
|
3709
|
+
}
|
|
3710
|
+
list.push(name);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
catch {
|
|
3714
|
+
/* no ENTRY_POINT_OF edges yet */
|
|
3715
|
+
}
|
|
3716
|
+
return result;
|
|
3717
|
+
}
|
|
3718
|
+
async routeMap(repo, params) {
|
|
3719
|
+
await this.ensureInitialized(repo);
|
|
3720
|
+
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
|
3721
|
+
const queryParams = params.route ? { route: params.route } : {};
|
|
3722
|
+
const routes = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
|
|
3723
|
+
if (routes.length === 0) {
|
|
3724
|
+
return {
|
|
3725
|
+
routes: [],
|
|
3726
|
+
total: 0,
|
|
3727
|
+
message: params.route
|
|
3728
|
+
? `No routes matching "${params.route}"`
|
|
3729
|
+
: 'No routes found in this project.',
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
const flowMap = await this.fetchLinkedFlowsBatch(repo.lbugPath, routes.map((r) => r.id));
|
|
3733
|
+
return {
|
|
3734
|
+
routes: routes.map((r) => ({
|
|
3735
|
+
route: r.name,
|
|
3736
|
+
method: r.method,
|
|
3737
|
+
handler: r.filePath,
|
|
3738
|
+
middleware: r.middleware || [],
|
|
3739
|
+
consumers: r.consumers,
|
|
3740
|
+
flows: flowMap.get(r.id) || [],
|
|
3741
|
+
})),
|
|
3742
|
+
total: routes.length,
|
|
3743
|
+
};
|
|
3744
|
+
}
|
|
3745
|
+
async shapeCheck(repo, params) {
|
|
3746
|
+
await this.ensureInitialized(repo);
|
|
3747
|
+
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
|
3748
|
+
const queryParams = params.route ? { route: params.route } : {};
|
|
3749
|
+
const allRoutes = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
|
|
3750
|
+
const results = allRoutes
|
|
3751
|
+
.filter((r) => ((r.responseKeys && r.responseKeys.length > 0) ||
|
|
3752
|
+
(r.errorKeys && r.errorKeys.length > 0)) &&
|
|
3753
|
+
r.consumers.length > 0)
|
|
3754
|
+
.map((r) => {
|
|
3755
|
+
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
|
|
3756
|
+
const responseKeys = r.responseKeys ?? [];
|
|
3757
|
+
const errorKeys = r.errorKeys ?? [];
|
|
3758
|
+
// Combined set: consumer accessing either success or error keys is valid
|
|
3759
|
+
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
|
|
3760
|
+
// Check each consumer's accessed keys against the route's response shape
|
|
3761
|
+
const responseKeySet = new Set(responseKeys);
|
|
3762
|
+
const consumers = r.consumers.map((c) => {
|
|
3763
|
+
if (!c.accessedKeys || c.accessedKeys.length === 0) {
|
|
3764
|
+
return { name: c.name, filePath: c.filePath };
|
|
3765
|
+
}
|
|
3766
|
+
const mismatched = c.accessedKeys.filter((k) => !allKnownKeys.has(k));
|
|
3767
|
+
// Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys)
|
|
3768
|
+
const errorPathKeys = c.accessedKeys.filter((k) => allKnownKeys.has(k) && !responseKeySet.has(k));
|
|
3769
|
+
const isMultiFetch = (c.fetchCount ?? 1) > 1;
|
|
3770
|
+
return {
|
|
3771
|
+
name: c.name,
|
|
3772
|
+
filePath: c.filePath,
|
|
3773
|
+
accessedKeys: c.accessedKeys,
|
|
3774
|
+
...(mismatched.length > 0
|
|
3775
|
+
? {
|
|
3776
|
+
mismatched,
|
|
3777
|
+
mismatchConfidence: isMultiFetch ? 'low' : 'high',
|
|
3778
|
+
}
|
|
3779
|
+
: {}),
|
|
3780
|
+
...(errorPathKeys.length > 0 ? { errorPathKeys } : {}),
|
|
3781
|
+
...(isMultiFetch
|
|
3782
|
+
? {
|
|
3783
|
+
attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`,
|
|
3784
|
+
}
|
|
3785
|
+
: {}),
|
|
3786
|
+
};
|
|
3787
|
+
});
|
|
3788
|
+
const hasMismatches = consumers.some((c) => 'mismatched' in c && c.mismatched.length > 0);
|
|
3789
|
+
return {
|
|
3790
|
+
route: r.name,
|
|
3791
|
+
method: r.method,
|
|
3792
|
+
handler: r.filePath,
|
|
3793
|
+
...(responseKeys.length > 0 ? { responseKeys } : {}),
|
|
3794
|
+
...(errorKeys.length > 0 ? { errorKeys } : {}),
|
|
3795
|
+
consumers,
|
|
3796
|
+
...(hasMismatches ? { status: 'MISMATCH' } : {}),
|
|
3797
|
+
};
|
|
3798
|
+
});
|
|
3799
|
+
const mismatchCount = results.filter((r) => r.status === 'MISMATCH').length;
|
|
3800
|
+
return {
|
|
3801
|
+
routes: results,
|
|
3802
|
+
total: results.length,
|
|
3803
|
+
routesWithShapes: results.length,
|
|
3804
|
+
...(mismatchCount > 0 ? { mismatches: mismatchCount } : {}),
|
|
3805
|
+
message: results.length === 0
|
|
3806
|
+
? 'No routes with both response shapes and consumers found.'
|
|
3807
|
+
: mismatchCount > 0
|
|
3808
|
+
? `Found ${results.length} route(s) with response shape data. ${mismatchCount} route(s) have consumer/shape mismatches.`
|
|
3809
|
+
: `Found ${results.length} route(s) with response shape data and consumers.`,
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
async toolMap(repo, params) {
|
|
3813
|
+
await this.ensureInitialized(repo);
|
|
3814
|
+
const toolFilter = params.tool ? `AND n.name CONTAINS $tool` : '';
|
|
3815
|
+
const queryParams = params.tool ? { tool: params.tool } : {};
|
|
3816
|
+
const rows = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
3817
|
+
MATCH (n:Tool)
|
|
3818
|
+
WHERE n.id STARTS WITH 'Tool:' ${toolFilter}
|
|
3819
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description
|
|
3820
|
+
`, queryParams);
|
|
3821
|
+
if (rows.length === 0) {
|
|
3822
|
+
return {
|
|
3823
|
+
tools: [],
|
|
3824
|
+
total: 0,
|
|
3825
|
+
message: params.tool ? `No tools matching "${params.tool}"` : 'No tool definitions found.',
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
const toolIds = rows.map((r) => r.id ?? r[0]);
|
|
3829
|
+
const flowMap = await this.fetchLinkedFlowsBatch(repo.lbugPath, toolIds);
|
|
3830
|
+
return {
|
|
3831
|
+
tools: rows.map((r) => {
|
|
3832
|
+
const id = r.id ?? r[0];
|
|
3833
|
+
return {
|
|
3834
|
+
name: r.name ?? r[1],
|
|
3835
|
+
filePath: r.filePath ?? r[2],
|
|
3836
|
+
description: (r.description ?? r[3] ?? '').slice(0, 200),
|
|
3837
|
+
flows: flowMap.get(id) || [],
|
|
3838
|
+
};
|
|
3839
|
+
}),
|
|
3840
|
+
total: rows.length,
|
|
3841
|
+
};
|
|
3842
|
+
}
|
|
3843
|
+
async apiImpact(repo, params) {
|
|
3844
|
+
await this.ensureInitialized(repo);
|
|
3845
|
+
if (!params.route && !params.file) {
|
|
3846
|
+
return { error: 'Either "route" or "file" parameter is required.' };
|
|
3847
|
+
}
|
|
3848
|
+
// If file is provided but route is not, look up the route by file path
|
|
3849
|
+
let routeFilter = '';
|
|
3850
|
+
const queryParams = {};
|
|
3851
|
+
if (params.route) {
|
|
3852
|
+
routeFilter = `AND n.name CONTAINS $route`;
|
|
3853
|
+
queryParams.route = params.route;
|
|
3854
|
+
}
|
|
3855
|
+
else if (params.file) {
|
|
3856
|
+
routeFilter = `AND n.filePath CONTAINS $file`;
|
|
3857
|
+
queryParams.file = params.file;
|
|
3858
|
+
}
|
|
3859
|
+
// After #2302 the same URL/handler can expose one Route node per HTTP verb.
|
|
3860
|
+
// An optional `method` narrows to that one verb so the response collapses to
|
|
3861
|
+
// the singular shape. A method-agnostic route (method `'*'`, e.g. a Django
|
|
3862
|
+
// function view) matches any selector; verbless routes (null method) never do.
|
|
3863
|
+
// `method` arrives unvalidated from the MCP envelope (the JSON schema is
|
|
3864
|
+
// advisory), so reject a non-string verb with a structured error instead of
|
|
3865
|
+
// throwing on `.toUpperCase()`; empty/whitespace collapses to no selector.
|
|
3866
|
+
const rawMethod = params.method;
|
|
3867
|
+
if (rawMethod !== undefined && typeof rawMethod !== 'string') {
|
|
3868
|
+
return { error: '"method" must be a string (e.g. "GET", "POST").' };
|
|
3869
|
+
}
|
|
3870
|
+
const wantedMethod = typeof rawMethod === 'string' ? rawMethod.trim().toUpperCase() || undefined : undefined;
|
|
3871
|
+
const matched = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
|
|
3872
|
+
const routes = matched.filter((r) => !wantedMethod || r.method === '*' || r.method?.toUpperCase() === wantedMethod);
|
|
3873
|
+
if (routes.length === 0) {
|
|
3874
|
+
const target = params.route || params.file;
|
|
3875
|
+
// Only append the verb when the URL/file matched routes but none used it;
|
|
3876
|
+
// a non-existent URL/file gets the plain "no routes found" message.
|
|
3877
|
+
const verb = wantedMethod && matched.length > 0 ? ` with method "${wantedMethod}"` : '';
|
|
3878
|
+
return { error: `No routes found matching "${target}"${verb}.` };
|
|
3879
|
+
}
|
|
3880
|
+
const flowMap = await this.fetchLinkedFlowsBatch(repo.lbugPath, routes.map((r) => r.id));
|
|
3881
|
+
// Count verbs per handler from the FULL match (before the method filter) so a
|
|
3882
|
+
// method-scoped query still flags a multi-verb handler's partial middleware.
|
|
3883
|
+
const routeCountByHandler = new Map();
|
|
3884
|
+
for (const r of matched) {
|
|
3885
|
+
if (r.filePath) {
|
|
3886
|
+
routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
const results = routes.map((r) => {
|
|
3890
|
+
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
|
|
3891
|
+
const responseKeys = r.responseKeys ?? [];
|
|
3892
|
+
const errorKeys = r.errorKeys ?? [];
|
|
3893
|
+
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
|
|
3894
|
+
// Build consumer list with mismatch detection
|
|
3895
|
+
const consumers = r.consumers.map((c) => ({
|
|
3896
|
+
name: c.name,
|
|
3897
|
+
file: c.filePath,
|
|
3898
|
+
accesses: c.accessedKeys ?? [],
|
|
3899
|
+
...(c.fetchCount && c.fetchCount > 1
|
|
3900
|
+
? {
|
|
3901
|
+
attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`,
|
|
3902
|
+
}
|
|
3903
|
+
: {}),
|
|
3904
|
+
}));
|
|
3905
|
+
// Detect mismatches: consumer accesses keys not in response shape
|
|
3906
|
+
const mismatches = [];
|
|
3907
|
+
if (allKnownKeys.size > 0) {
|
|
3908
|
+
for (const c of r.consumers) {
|
|
3909
|
+
if (!c.accessedKeys)
|
|
3910
|
+
continue;
|
|
3911
|
+
const isMultiFetch = (c.fetchCount ?? 1) > 1;
|
|
3912
|
+
for (const key of c.accessedKeys) {
|
|
3913
|
+
if (!allKnownKeys.has(key)) {
|
|
3914
|
+
mismatches.push({
|
|
3915
|
+
consumer: c.filePath,
|
|
3916
|
+
field: key,
|
|
3917
|
+
reason: 'accessed but not in response shape',
|
|
3918
|
+
confidence: isMultiFetch ? 'low' : 'high',
|
|
3919
|
+
});
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
const flows = flowMap.get(r.id) || [];
|
|
3925
|
+
const consumerCount = r.consumers.length;
|
|
3926
|
+
// Risk level heuristic
|
|
3927
|
+
let riskLevel;
|
|
3928
|
+
if (consumerCount >= 10) {
|
|
3929
|
+
riskLevel = 'HIGH';
|
|
3930
|
+
}
|
|
3931
|
+
else if (consumerCount >= 4) {
|
|
3932
|
+
riskLevel = 'MEDIUM';
|
|
3933
|
+
}
|
|
3934
|
+
else {
|
|
3935
|
+
riskLevel = 'LOW';
|
|
3936
|
+
}
|
|
3937
|
+
// Bump up one level if mismatches exist
|
|
3938
|
+
if (mismatches.length > 0) {
|
|
3939
|
+
if (riskLevel === 'LOW')
|
|
3940
|
+
riskLevel = 'MEDIUM';
|
|
3941
|
+
else if (riskLevel === 'MEDIUM')
|
|
3942
|
+
riskLevel = 'HIGH';
|
|
3943
|
+
}
|
|
3944
|
+
const warning = consumerCount > 0
|
|
3945
|
+
? `Changing response shape will affect ${consumerCount} component${consumerCount === 1 ? '' : 's'}`
|
|
3946
|
+
: undefined;
|
|
3947
|
+
// Flag when middleware was detected but handler exports multiple HTTP methods
|
|
3948
|
+
// (middleware chain may only reflect one export)
|
|
3949
|
+
const middlewareArr = r.middleware || [];
|
|
3950
|
+
const handlerRouteCount = r.filePath ? (routeCountByHandler.get(r.filePath) ?? 1) : 1;
|
|
3951
|
+
const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1;
|
|
3952
|
+
return {
|
|
3953
|
+
route: r.name,
|
|
3954
|
+
method: r.method,
|
|
3955
|
+
handler: r.filePath,
|
|
3956
|
+
responseShape: {
|
|
3957
|
+
success: responseKeys,
|
|
3958
|
+
error: errorKeys,
|
|
3959
|
+
},
|
|
3960
|
+
middleware: middlewareArr,
|
|
3961
|
+
...(middlewarePartial
|
|
3962
|
+
? {
|
|
3963
|
+
middlewareDetection: 'partial',
|
|
3964
|
+
middlewareNote: 'Middleware captured from the first route export only — other route exports in this handler may use different middleware chains.',
|
|
3965
|
+
}
|
|
3966
|
+
: {}),
|
|
3967
|
+
consumers,
|
|
3968
|
+
...(mismatches.length > 0 ? { mismatches } : {}),
|
|
3969
|
+
executionFlows: flows,
|
|
3970
|
+
impactSummary: {
|
|
3971
|
+
directConsumers: consumerCount,
|
|
3972
|
+
affectedFlows: flows.length,
|
|
3973
|
+
riskLevel,
|
|
3974
|
+
...(warning ? { warning } : {}),
|
|
3975
|
+
},
|
|
3976
|
+
};
|
|
3977
|
+
});
|
|
3978
|
+
// If a single route was targeted, return it directly (not wrapped in array)
|
|
3979
|
+
if (results.length === 1) {
|
|
3980
|
+
return results[0];
|
|
3981
|
+
}
|
|
3982
|
+
return { routes: results, total: results.length };
|
|
3983
|
+
}
|
|
3984
|
+
// ─── Direct Graph Queries (for resources.ts) ────────────────────
|
|
3985
|
+
/**
|
|
3986
|
+
* Query clusters (communities) directly from graph.
|
|
3987
|
+
* Used by getClustersResource — avoids legacy overview() dispatch.
|
|
3988
|
+
*/
|
|
3989
|
+
async queryClusters(repoName, limit = 100) {
|
|
3990
|
+
const repo = await this.resolveRepo(repoName);
|
|
3991
|
+
await this.ensureInitialized(repo);
|
|
3992
|
+
try {
|
|
3993
|
+
const rawLimit = Math.max(limit * 5, 200);
|
|
3994
|
+
const clusters = await (0, pool_adapter_js_1.executeQuery)(repo.lbugPath, `
|
|
3995
|
+
MATCH (c:Community)
|
|
3996
|
+
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
3997
|
+
ORDER BY c.symbolCount DESC, c.id
|
|
3998
|
+
LIMIT ${rawLimit}
|
|
3999
|
+
`);
|
|
4000
|
+
const rawClusters = clusters.map((c) => ({
|
|
4001
|
+
id: c.id || c[0],
|
|
4002
|
+
label: c.label || c[1],
|
|
4003
|
+
heuristicLabel: c.heuristicLabel || c[2],
|
|
4004
|
+
cohesion: c.cohesion || c[3],
|
|
4005
|
+
symbolCount: c.symbolCount || c[4],
|
|
4006
|
+
}));
|
|
4007
|
+
return { clusters: this.aggregateClusters(rawClusters).slice(0, limit) };
|
|
4008
|
+
}
|
|
4009
|
+
catch {
|
|
4010
|
+
return { clusters: [] };
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
/**
|
|
4014
|
+
* Query processes directly from graph.
|
|
4015
|
+
* Used by getProcessesResource — avoids legacy overview() dispatch.
|
|
4016
|
+
*/
|
|
4017
|
+
async queryProcesses(repoName, limit = 50) {
|
|
4018
|
+
const repo = await this.resolveRepo(repoName);
|
|
4019
|
+
await this.ensureInitialized(repo);
|
|
4020
|
+
try {
|
|
4021
|
+
const processes = await (0, pool_adapter_js_1.executeQuery)(repo.lbugPath, `
|
|
4022
|
+
MATCH (p:Process)
|
|
4023
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
4024
|
+
ORDER BY p.stepCount DESC, p.id
|
|
4025
|
+
LIMIT ${limit}
|
|
4026
|
+
`);
|
|
4027
|
+
return {
|
|
4028
|
+
processes: processes.map((p) => ({
|
|
4029
|
+
id: p.id || p[0],
|
|
4030
|
+
label: p.label || p[1],
|
|
4031
|
+
heuristicLabel: p.heuristicLabel || p[2],
|
|
4032
|
+
processType: p.processType || p[3],
|
|
4033
|
+
stepCount: p.stepCount || p[4],
|
|
4034
|
+
})),
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
catch {
|
|
4038
|
+
return { processes: [] };
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Query cluster detail (members) directly from graph.
|
|
4043
|
+
* Used by getClusterDetailResource.
|
|
4044
|
+
*/
|
|
4045
|
+
async queryClusterDetail(name, repoName) {
|
|
4046
|
+
const repo = await this.resolveRepo(repoName);
|
|
4047
|
+
await this.ensureInitialized(repo);
|
|
4048
|
+
const clusters = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
4049
|
+
MATCH (c:Community)
|
|
4050
|
+
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
|
4051
|
+
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
4052
|
+
`, { clusterName: name });
|
|
4053
|
+
if (clusters.length === 0)
|
|
4054
|
+
return { error: `Cluster '${name}' not found` };
|
|
4055
|
+
const rawClusters = clusters.map((c) => ({
|
|
4056
|
+
id: c.id || c[0],
|
|
4057
|
+
label: c.label || c[1],
|
|
4058
|
+
heuristicLabel: c.heuristicLabel || c[2],
|
|
4059
|
+
cohesion: c.cohesion || c[3],
|
|
4060
|
+
symbolCount: c.symbolCount || c[4],
|
|
4061
|
+
}));
|
|
4062
|
+
let totalSymbols = 0, weightedCohesion = 0;
|
|
4063
|
+
for (const c of rawClusters) {
|
|
4064
|
+
const s = c.symbolCount || 0;
|
|
4065
|
+
totalSymbols += s;
|
|
4066
|
+
weightedCohesion += (c.cohesion || 0) * s;
|
|
4067
|
+
}
|
|
4068
|
+
const members = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
4069
|
+
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
4070
|
+
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
|
4071
|
+
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
4072
|
+
ORDER BY filePath, name, type
|
|
4073
|
+
LIMIT 30
|
|
4074
|
+
`, { clusterName: name });
|
|
4075
|
+
return {
|
|
4076
|
+
cluster: {
|
|
4077
|
+
id: rawClusters[0].id,
|
|
4078
|
+
label: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
|
4079
|
+
heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
|
4080
|
+
cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0,
|
|
4081
|
+
symbolCount: totalSymbols,
|
|
4082
|
+
subCommunities: rawClusters.length,
|
|
4083
|
+
},
|
|
4084
|
+
members: members.map((m) => ({
|
|
4085
|
+
name: m.name || m[0],
|
|
4086
|
+
type: m.type || m[1],
|
|
4087
|
+
filePath: m.filePath || m[2],
|
|
4088
|
+
})),
|
|
4089
|
+
};
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* Query process detail (steps) directly from graph.
|
|
4093
|
+
* Used by getProcessDetailResource.
|
|
4094
|
+
*/
|
|
4095
|
+
async queryProcessDetail(name, repoName) {
|
|
4096
|
+
const repo = await this.resolveRepo(repoName);
|
|
4097
|
+
await this.ensureInitialized(repo);
|
|
4098
|
+
const processes = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
4099
|
+
MATCH (p:Process)
|
|
4100
|
+
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
|
4101
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
4102
|
+
ORDER BY id
|
|
4103
|
+
LIMIT 1
|
|
4104
|
+
`, { processName: name });
|
|
4105
|
+
if (processes.length === 0)
|
|
4106
|
+
return { error: `Process '${name}' not found` };
|
|
4107
|
+
const proc = processes[0];
|
|
4108
|
+
const procId = proc.id || proc[0];
|
|
4109
|
+
const steps = await (0, pool_adapter_js_1.executeParameterized)(repo.lbugPath, `
|
|
4110
|
+
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
|
4111
|
+
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
|
4112
|
+
ORDER BY r.step
|
|
4113
|
+
`, { procId });
|
|
4114
|
+
return {
|
|
4115
|
+
process: {
|
|
4116
|
+
id: procId,
|
|
4117
|
+
label: proc.label || proc[1],
|
|
4118
|
+
heuristicLabel: proc.heuristicLabel || proc[2],
|
|
4119
|
+
processType: proc.processType || proc[3],
|
|
4120
|
+
stepCount: proc.stepCount || proc[4],
|
|
4121
|
+
},
|
|
4122
|
+
steps: steps.map((s) => ({
|
|
4123
|
+
step: s.step || s[3],
|
|
4124
|
+
name: s.name || s[0],
|
|
4125
|
+
type: s.type || s[1],
|
|
4126
|
+
filePath: s.filePath || s[2],
|
|
4127
|
+
})),
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
async disconnect() {
|
|
4131
|
+
await (0, pool_adapter_js_1.closeLbug)(); // close all connections
|
|
4132
|
+
// Note: we intentionally do NOT call disposeEmbedder() here.
|
|
4133
|
+
// ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
|
|
4134
|
+
// and importing the embedder module on Node v24+ crashes if onnxruntime
|
|
4135
|
+
// was never loaded during the session. Since process.exit(0) follows
|
|
4136
|
+
// immediately after disconnect(), the OS reclaims everything. See #38, #89.
|
|
4137
|
+
this.repos.clear();
|
|
4138
|
+
this.contextCache.clear();
|
|
4139
|
+
this.initializedRepos.clear();
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
exports.LocalBackend = LocalBackend;
|