@herbertgao/pi-extensions 2026.9.0 → 2026.9.1
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/node_modules/@czottmann/pi-automode/CHANGELOG.md +13 -1
- package/node_modules/@czottmann/pi-automode/README.md +20 -0
- package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +1 -1
- package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +2 -1
- package/node_modules/@czottmann/pi-automode/docs/defaults.md +3 -2
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +38 -2
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +5 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +11 -3
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@pi-plugins/fast-mode/README.md +5 -3
- package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
- package/node_modules/pi-lens/CHANGELOG.md +988 -0
- package/node_modules/pi-lens/README.md +3 -0
- package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
- package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
- package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +1 -3
- package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -0
- package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -4
- package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
- package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
- package/node_modules/pi-lens/dist/clients/cache-observability.js +44 -18
- package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
- package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
- package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
- package/node_modules/pi-lens/dist/clients/config-core/index.js +47 -0
- package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
- package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
- package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
- package/node_modules/pi-lens/dist/clients/config-core/provenance.js +171 -0
- package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
- package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
- package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
- package/node_modules/pi-lens/dist/clients/config-core/schema.js +167 -0
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
- package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
- package/node_modules/pi-lens/dist/clients/config-schema.js +197 -0
- package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
- package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
- package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
- package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
- package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
- package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +14 -4
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -18
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +9 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +380 -26
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +13 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -3
- package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
- package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
- package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
- package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
- package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
- package/node_modules/pi-lens/dist/clients/file-time.js +6 -1
- package/node_modules/pi-lens/dist/clients/file-utils.js +112 -9
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
- package/node_modules/pi-lens/dist/clients/format-service.js +6 -1
- package/node_modules/pi-lens/dist/clients/formatters.js +276 -102
- package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +114 -19
- package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
- package/node_modules/pi-lens/dist/clients/git-guard.js +1 -1
- package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
- package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
- package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
- package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
- package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
- package/node_modules/pi-lens/dist/clients/installer/index.js +71 -52
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +104 -230
- package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
- package/node_modules/pi-lens/dist/clients/language-registry.js +595 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +160 -2
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +13 -0
- package/node_modules/pi-lens/dist/clients/lens-config.js +141 -30
- package/node_modules/pi-lens/dist/clients/lens-engine.js +30 -37
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +59 -6
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -2
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
- package/node_modules/pi-lens/dist/clients/lsp/client.js +168 -30
- package/node_modules/pi-lens/dist/clients/lsp/config.js +483 -158
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +15 -2
- package/node_modules/pi-lens/dist/clients/lsp/index.js +676 -71
- package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -169
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -16
- package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +9 -2
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
- package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
- package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
- package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +2 -4
- package/node_modules/pi-lens/dist/clients/module-report.js +20 -45
- package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
- package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
- package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -20
- package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
- package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +68 -22
- package/node_modules/pi-lens/dist/clients/opengrep-config.js +4 -0
- package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
- package/node_modules/pi-lens/dist/clients/path-utils.js +428 -13
- package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
- package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +77 -16
- package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
- package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
- package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +393 -122
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
- package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
- package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
- package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
- package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +15 -4
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
- package/node_modules/pi-lens/dist/clients/read-guard.js +21 -0
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +152 -109
- package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
- package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +18 -2
- package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
- package/node_modules/pi-lens/dist/clients/runtime-session.js +139 -22
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +168 -35
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
- package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
- package/node_modules/pi-lens/dist/clients/sanitize.js +7 -1
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
- package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
- package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
- package/node_modules/pi-lens/dist/clients/sgconfig.js +6 -1
- package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +2 -2
- package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
- package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
- package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +239 -0
- package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
- package/node_modules/pi-lens/dist/clients/tool-policy.js +91 -21
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +86 -48
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
- package/node_modules/pi-lens/dist/clients/user-notify.js +6 -2
- package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/word-index-store.js +20 -5
- package/node_modules/pi-lens/dist/clients/word-index.js +243 -31
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +3 -0
- package/node_modules/pi-lens/dist/index.js +64335 -54300
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
- package/node_modules/pi-lens/dist/mcp/server.js +127 -5
- package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
- package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +95 -24
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +67 -80
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
- package/node_modules/pi-lens/dist/tools/shared.js +0 -1
- package/node_modules/pi-lens/docs/agent-guide.md +64 -1
- package/node_modules/pi-lens/docs/configuration.md +222 -0
- package/node_modules/pi-lens/docs/dependencies.md +3 -3
- package/node_modules/pi-lens/docs/features.md +44 -5
- package/node_modules/pi-lens/docs/language-coverage.md +2 -2
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +27 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +38 -0
- package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +182 -0
- package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
- package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
- package/node_modules/pi-lens/package.json +15 -23
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
- package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
- package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
- package/node_modules/pi-web-access/CHANGELOG.md +28 -0
- package/node_modules/pi-web-access/README.md +62 -15
- package/node_modules/pi-web-access/curator-page.ts +3 -1
- package/node_modules/pi-web-access/curator-server.ts +5 -1
- package/node_modules/pi-web-access/gemini-search.ts +8 -4
- package/node_modules/pi-web-access/github-extract.ts +242 -1
- package/node_modules/pi-web-access/index.ts +118 -64
- package/node_modules/pi-web-access/mistral-search.ts +281 -0
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/perplexity.ts +14 -1
- package/node_modules/pi-web-access/utils.ts +23 -6
- package/node_modules/pi-web-access/xai-search.ts +96 -33
- package/package.json +5 -5
|
@@ -4,11 +4,14 @@ import * as path from "node:path";
|
|
|
4
4
|
import { hashText, normalizeMessage, stableFindingId, } from "./finding-identity.js";
|
|
5
5
|
import { applyWorkspaceEdit } from "./lsp/edits.js";
|
|
6
6
|
import { getLSPService } from "./lsp/index.js";
|
|
7
|
-
import { normalizeMapKey } from "./path-utils.js";
|
|
7
|
+
import { isUnderDir, normalizeMapKey } from "./path-utils.js";
|
|
8
|
+
import { bounded, combineAbortSignals, withDeadline, } from "./deadline-utils.js";
|
|
9
|
+
import { armDeferredLspWork, awaitDeferredLspWork, registerDeferredLspWork, } from "./deferred-lsp-work.js";
|
|
10
|
+
import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
|
|
8
11
|
import { recordLspMutationBatch, } from "./lsp-mutation.js";
|
|
9
12
|
import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
|
|
10
13
|
import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
|
|
11
|
-
import { getProjectDataDir } from "./file-utils.js";
|
|
14
|
+
import { displayProjectDataPath, getProjectDataDir } from "./file-utils.js";
|
|
12
15
|
import { commitDurableStore } from "./durable-store.js";
|
|
13
16
|
let beforeWarningStateLockForTests = null;
|
|
14
17
|
/** Test seam for a sibling process commit immediately before lock acquisition. */
|
|
@@ -305,10 +308,161 @@ function mergeWarnings(records) {
|
|
|
305
308
|
return [...byId.values()].sort((a, b) => a.displayPath.localeCompare(b.displayPath) ||
|
|
306
309
|
(a.line ?? 0) - (b.line ?? 0));
|
|
307
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* #2504 — bounds on the LSP enrichment loop.
|
|
313
|
+
*
|
|
314
|
+
* This function runs on the AWAITED turn_end hook. With `includeLspCodeActions`
|
|
315
|
+
* on and a cold LSP cache it opened every file it was handed and pulled fresh
|
|
316
|
+
* per-file diagnostics serially at ~880 ms each: 147 files, 187 891 ms, for
|
|
317
|
+
* `warnings: 0`. Three bounds, plus a project-root filter (it had opened
|
|
318
|
+
* `~/.claude/plans/*.md` in an LSP client), plus a deferral: when the turn
|
|
319
|
+
* primed NO cache, every file would be a fresh pull, so the whole loop moves
|
|
320
|
+
* off the hook and delivers through the cached channel instead.
|
|
321
|
+
*/
|
|
322
|
+
export const ACTIONABLE_WARNINGS_LSP_FILE_CAP = 25;
|
|
323
|
+
export const ACTIONABLE_WARNINGS_LSP_BUDGET_MS = 2_500;
|
|
324
|
+
export const ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS = 60_000;
|
|
325
|
+
/**
|
|
326
|
+
* #2504 review round 2 (F3): the per-round-trip bound. The batch deadlines
|
|
327
|
+
* above are checked only BETWEEN files, so a single wedged `getDiagnostics`
|
|
328
|
+
* (or `openFile`, or `codeAction`) was unbounded no matter how small the
|
|
329
|
+
* batch budget was. Generous relative to the ~880 ms a real cold pull costs —
|
|
330
|
+
* this is a wedge detector, not a latency target.
|
|
331
|
+
*/
|
|
332
|
+
export const ACTIONABLE_WARNINGS_LSP_PULL_TIMEOUT_MS = 10_000;
|
|
333
|
+
/**
|
|
334
|
+
* #2504 review round 3 (S-2): the third bound, per FILE.
|
|
335
|
+
*
|
|
336
|
+
* The wall budgets above are re-checked BETWEEN files only — deliberately, so
|
|
337
|
+
* that a file already opened is finished rather than half-enriched — and the
|
|
338
|
+
* per-round-trip timeout bounds one call. Neither bounds the COUNT of calls
|
|
339
|
+
* one file can demand: a generated or vendored file with hundreds of warnings
|
|
340
|
+
* on modified lines costs one `codeAction` round trip each, all inside a
|
|
341
|
+
* single between-files interval. This caps that fan-out. The abort signal
|
|
342
|
+
* still escapes promptly — `boundedLspCall` checks it on every trip.
|
|
343
|
+
*/
|
|
344
|
+
export const ACTIONABLE_WARNINGS_MAX_CODE_ACTIONS_PER_FILE = 25;
|
|
345
|
+
/**
|
|
346
|
+
* The in-flight deferred fresh-pull, if any. Exposed for tests only: the
|
|
347
|
+
* deferral is fire-and-forget by design, and a test asserting that the work
|
|
348
|
+
* still happens off-hook needs a handle to await.
|
|
349
|
+
*
|
|
350
|
+
* #2504 review round 2 (F3): the handle no longer lives here as a bare
|
|
351
|
+
* module-level `let` that a second deferral silently overwrote (leaving the
|
|
352
|
+
* first loop running, untracked and unstoppable) and that nothing ever reset.
|
|
353
|
+
* `clients/deferred-lsp-work.ts` owns the single slot and its abort signal.
|
|
354
|
+
*/
|
|
355
|
+
export function _awaitDeferredLspPullForTest() {
|
|
356
|
+
return awaitDeferredLspWork();
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Both bounds on ONE LSP round trip (#2504 review round 2, F3), as AGENTS.md
|
|
360
|
+
* requires of any async step in a sweep loop: a per-call timeout AND the abort
|
|
361
|
+
* signal. Resolves `undefined` when either bound wins; the caller reads that
|
|
362
|
+
* as "this file was not checked", never as "this file is clean".
|
|
363
|
+
*
|
|
364
|
+
* #2523 slice 2 folded this onto `bounded()`. It used to be `withDeadline`
|
|
365
|
+
* (deadline only) raced against a hand-built `abortRace` leg carried on the
|
|
366
|
+
* deps — the fifth private spelling of "deadline AND signal".
|
|
367
|
+
*
|
|
368
|
+
* What the fold did NOT fix, stated because the first draft of this PR claimed
|
|
369
|
+
* it did (#2557 review F5): the old shape did not silently degrade any wait.
|
|
370
|
+
* Both construction sites derived the leg from the signal in the same
|
|
371
|
+
* expression (`args.signal ? makeAbortRace(args.signal) : undefined`), so
|
|
372
|
+
* `abortRace` was absent exactly when `signal` was, and the behaviour before
|
|
373
|
+
* and after this fold is identical on that axis. What the fold actually buys
|
|
374
|
+
* is that the two fields which had to AGREE are now one — a pairing invariant
|
|
375
|
+
* that lived in prose is gone rather than enforced — plus the abandonment now
|
|
376
|
+
* reaching the ledger, and the leg's listener now being released per call
|
|
377
|
+
* instead of living as long as the loop's signal does.
|
|
378
|
+
*
|
|
379
|
+
* ## The loop's residual budget is a THIRD bound, and it must not reach the
|
|
380
|
+
* ledger (#2557 review F-A)
|
|
381
|
+
*
|
|
382
|
+
* Round 1 clamped `bounded()`'s own timer to
|
|
383
|
+
* `Math.min(pullTimeoutMs, remainingMs)`, so whenever the LOOP's own wall
|
|
384
|
+
* budget was already low, `bounded()` armed at the shrunken value and — on
|
|
385
|
+
* firing — recorded `hook-await-exceeded` naming THAT value as "the budget".
|
|
386
|
+
* A healthy pull that simply started late in a big batch was reported as
|
|
387
|
+
* exceeding a budget that exists in no configuration (measured: 400 ms
|
|
388
|
+
* pulls, 12 files, "exceeded 65ms budget after 77ms"), and because
|
|
389
|
+
* `recordDegradationOnce` is rising-edge, that benign row could silence a
|
|
390
|
+
* genuinely wedged server later in the same session.
|
|
391
|
+
*
|
|
392
|
+
* `bounded()`'s OWN timer is now always armed at the full `pullTimeoutMs`, so
|
|
393
|
+
* it only ever fires — and only ever records — when a call outlives its own
|
|
394
|
+
* configured per-trip budget (`metadata.budgetMs === pullTimeoutMs`, always).
|
|
395
|
+
* The loop's shrinking residual is enforced SEPARATELY, by racing `bounded()`
|
|
396
|
+
* against `withDeadline(…, { onTimeout: "undefined" })` keyed to the loop's
|
|
397
|
+
* own `deadlineAt` — a deadline-only wait with no ledger connection of its
|
|
398
|
+
* own, the same primitive `bounded()` itself is built to replace when a
|
|
399
|
+
* caller needs BOTH bounds. The still-running `bounded()` call is not
|
|
400
|
+
* cancelled (nothing here can cancel already-started work); if it later
|
|
401
|
+
* genuinely outlives `pullTimeoutMs`, it still records, correctly, on its own
|
|
402
|
+
* clock.
|
|
403
|
+
*/
|
|
404
|
+
async function boundedLspCall(call, deps) {
|
|
405
|
+
if (deps.signal?.aborted)
|
|
406
|
+
return undefined;
|
|
407
|
+
// #2504 review round 4 (F2): the loop's wall budget bounds THIS trip too. A
|
|
408
|
+
// round trip that starts with 5 ms of budget left may not run for 10 s just
|
|
409
|
+
// because the per-call timeout says so -- that is how one file held the
|
|
410
|
+
// awaited hook for minutes past a spent batch budget.
|
|
411
|
+
const remainingMs = deps.deadlineAt !== undefined ? deps.deadlineAt - Date.now() : undefined;
|
|
412
|
+
// Returned BEFORE `bounded()` rather than handed to it as a zero budget: a
|
|
413
|
+
// trip that never started did not exceed anything, and recording it as an
|
|
414
|
+
// exceedance would attribute the loop's spent budget to whichever call
|
|
415
|
+
// happened to be next.
|
|
416
|
+
if (remainingMs !== undefined && remainingMs <= 0)
|
|
417
|
+
return undefined;
|
|
418
|
+
const inner = bounded(call().then((value) => ({ value })), {
|
|
419
|
+
// Always the FULL per-trip budget -- never clamped to the loop's own
|
|
420
|
+
// residual. The residual is enforced below, off the ledger.
|
|
421
|
+
ms: deps.pullTimeoutMs,
|
|
422
|
+
// Optional for the in-band caller; `bounded()` reads a missing signal
|
|
423
|
+
// as one that never aborts, so the deadline half stays live.
|
|
424
|
+
signal: deps.signal,
|
|
425
|
+
// From the deps, never a literal: the in-band and deferred loops must
|
|
426
|
+
// key separately (#2557 review F3).
|
|
427
|
+
hook: deps.site.hook,
|
|
428
|
+
label: deps.site.label,
|
|
429
|
+
});
|
|
430
|
+
const boxed = deps.deadlineAt !== undefined
|
|
431
|
+
? await withDeadline(inner, {
|
|
432
|
+
deadlineAt: deps.deadlineAt,
|
|
433
|
+
onTimeout: "undefined",
|
|
434
|
+
})
|
|
435
|
+
: await inner;
|
|
436
|
+
// Boxed because an LSP pull legitimately resolves `undefined` (no
|
|
437
|
+
// diagnostics for this file), which must stay distinguishable from a bound
|
|
438
|
+
// firing even though both currently mean "not checked" to the caller.
|
|
439
|
+
return boxed?.value;
|
|
440
|
+
}
|
|
441
|
+
/** Positive finite bound, else the default. Guards NaN from env/config. */
|
|
442
|
+
function boundedNumber(value, fallback) {
|
|
443
|
+
return value !== undefined && Number.isFinite(value) && value > 0
|
|
444
|
+
? value
|
|
445
|
+
: fallback;
|
|
446
|
+
}
|
|
308
447
|
export async function buildActionableWarningsReport(args) {
|
|
309
448
|
const cwd = path.resolve(args.cwd);
|
|
310
449
|
const records = [...args.dispatchWarnings];
|
|
311
450
|
const lspService = getLSPService();
|
|
451
|
+
// #2504 review round 5 (F2): when each file's LSP observation FINISHED,
|
|
452
|
+
// keyed by normalized path. assembleReport used to stamp every entry with
|
|
453
|
+
// one `new Date()` taken at ASSEMBLY -- which for the deferred loop is the
|
|
454
|
+
// loop's END, so a file read 60 s and 24 files ago claimed to be
|
|
455
|
+
// milliseconds old. applyDeltaFreshnessGate compares that stamp against the
|
|
456
|
+
// file's mtime to catch an out-of-band edit made after the observation, and
|
|
457
|
+
// a stamp that late hands it a window which has already closed. Shared with
|
|
458
|
+
// the deferred closure below, so the in-band entries it carries over keep
|
|
459
|
+
// the stamps their own pass gave them.
|
|
460
|
+
const observedAtByPath = new Map();
|
|
461
|
+
// The conservative stand-in for an entry no LSP pull ever touched (a
|
|
462
|
+
// dispatch-origin warning): the moment this build began. Never LATER than
|
|
463
|
+
// the observation it stands in for, which is the direction that matters --
|
|
464
|
+
// an over-old stamp costs a line number, an over-new one leaks a stale one.
|
|
465
|
+
const buildStartedAt = new Date().toISOString();
|
|
312
466
|
logActionableWarningsEvent({
|
|
313
467
|
event: "report_started",
|
|
314
468
|
sessionId: args.sessionId,
|
|
@@ -321,8 +475,27 @@ export async function buildActionableWarningsReport(args) {
|
|
|
321
475
|
},
|
|
322
476
|
});
|
|
323
477
|
if (args.includeLspCodeActions) {
|
|
478
|
+
const fileCap = Math.floor(boundedNumber(args.lspFileCap, ACTIONABLE_WARNINGS_LSP_FILE_CAP));
|
|
479
|
+
const budgetMs = boundedNumber(args.lspBudgetMs, ACTIONABLE_WARNINGS_LSP_BUDGET_MS);
|
|
480
|
+
// #2504 (1) project-root filter. The worklist this loop is handed had
|
|
481
|
+
// accumulated paths from two other agents' scratchpads, `~/.claude/plans`
|
|
482
|
+
// and `~/.plegma/work` — none of which belong to an LSP client rooted at
|
|
483
|
+
// this project. Rejected before any file read.
|
|
484
|
+
const eligible = [];
|
|
485
|
+
let outsideRoot = 0;
|
|
324
486
|
for (const file of args.files) {
|
|
325
487
|
const filePath = path.resolve(cwd, file);
|
|
488
|
+
if (normalizeMapKey(filePath) === normalizeMapKey(cwd) ||
|
|
489
|
+
!isUnderDir(filePath, cwd)) {
|
|
490
|
+
outsideRoot++;
|
|
491
|
+
logActionableWarningsEvent({
|
|
492
|
+
event: "lsp_file_skipped",
|
|
493
|
+
sessionId: args.sessionId,
|
|
494
|
+
filePath,
|
|
495
|
+
metadata: { reason: "outside_project_root" },
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
326
499
|
if (!lspService.supportsLSP(filePath)) {
|
|
327
500
|
logActionableWarningsEvent({
|
|
328
501
|
event: "lsp_file_skipped",
|
|
@@ -332,88 +505,400 @@ export async function buildActionableWarningsReport(args) {
|
|
|
332
505
|
});
|
|
333
506
|
continue;
|
|
334
507
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
508
|
+
eligible.push(filePath);
|
|
509
|
+
}
|
|
510
|
+
if (outsideRoot > 0) {
|
|
511
|
+
args.dbg?.(`actionable_warnings: skipped ${outsideRoot} file(s) outside the project root`);
|
|
512
|
+
}
|
|
513
|
+
// #2504 (2) file cap.
|
|
514
|
+
const capped = eligible.slice(0, fileCap);
|
|
515
|
+
if (capped.length < eligible.length) {
|
|
516
|
+
recordDegradationOnce({
|
|
517
|
+
kind: "actionable-warnings-cap",
|
|
518
|
+
subject: `${cwd}:file-cap`,
|
|
519
|
+
reason: `LSP enrichment capped at ${fileCap} file(s); ${eligible.length - capped.length} modified file(s) were not checked for code actions this turn`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// Resolve every file's cache state BEFORE doing any LSP work. This is a
|
|
523
|
+
// read + a sha256 per file (sub-millisecond) and it is what tells us
|
|
524
|
+
// whether the turn primed the cache at all — the difference between a
|
|
525
|
+
// loop that costs nothing and one that costs ~880 ms per file.
|
|
526
|
+
const primed = [];
|
|
527
|
+
const cold = [];
|
|
528
|
+
for (const filePath of capped) {
|
|
529
|
+
// Reuse the cache primed by the dispatch pipeline's touchFile earlier
|
|
530
|
+
// in this turn — but only when it is verified current. A second
|
|
531
|
+
// open+wait here costs ~1 s/file with the LSP cold, so we pass the
|
|
532
|
+
// hash of the current file bytes: getLastKnownDiagnostics returns the
|
|
533
|
+
// entry only if it was primed for the SAME content, so a previous
|
|
534
|
+
// turn's diagnostics are never served as current. On any miss (no
|
|
535
|
+
// entry, content drift, or an entry written without content) the file
|
|
536
|
+
// needs a fresh read.
|
|
537
|
+
const content = fs.existsSync(filePath)
|
|
345
538
|
? fs.readFileSync(filePath, "utf-8")
|
|
346
539
|
: undefined;
|
|
347
|
-
const contentHash =
|
|
348
|
-
? createHash("sha256").update(
|
|
540
|
+
const contentHash = content !== undefined
|
|
541
|
+
? createHash("sha256").update(content).digest("hex")
|
|
349
542
|
: undefined;
|
|
350
543
|
const cached = contentHash !== undefined
|
|
351
544
|
? lspService.getLastKnownDiagnostics(filePath, contentHash)
|
|
352
545
|
: undefined;
|
|
353
|
-
|
|
354
|
-
|
|
546
|
+
(cached !== undefined ? primed : cold).push({
|
|
547
|
+
filePath,
|
|
548
|
+
content,
|
|
549
|
+
cached,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// #2504 (3) wall budget, raced against the turn's abort signal. Both
|
|
553
|
+
// bounds, per AGENTS.md: neither a cap nor a deadline alone stops a
|
|
554
|
+
// cancelled turn from paying for work nobody is waiting for.
|
|
555
|
+
const deadline = Date.now() + budgetMs;
|
|
556
|
+
const exhausted = () => args.signal?.aborted === true || Date.now() >= deadline;
|
|
557
|
+
const pullTimeoutMs = boundedNumber(args.lspPullTimeoutMs, ACTIONABLE_WARNINGS_LSP_PULL_TIMEOUT_MS);
|
|
558
|
+
// #2504 review round 2 (F3): the in-band loop gets the same per-call
|
|
559
|
+
// bound and the same once-resolved service as the deferred one — the
|
|
560
|
+
// deferral must not be the only path that is bounded.
|
|
561
|
+
const inBandDeps = {
|
|
562
|
+
lspService,
|
|
563
|
+
pullTimeoutMs,
|
|
564
|
+
// #2504 review round 4 (F2). This is the AWAITED hook: the batch
|
|
565
|
+
// budget has to bound what happens INSIDE a file, not only how many
|
|
566
|
+
// files get started.
|
|
567
|
+
deadlineAt: deadline,
|
|
568
|
+
signal: args.signal,
|
|
569
|
+
// This loop IS on the awaited hook, so its exceedances are turn_end's.
|
|
570
|
+
site: { hook: "turn_end", label: "lspEnrichmentRoundTrip" },
|
|
571
|
+
};
|
|
572
|
+
let unchecked = 0;
|
|
573
|
+
const runInBand = async (targets) => {
|
|
574
|
+
for (const target of targets) {
|
|
575
|
+
if (exhausted()) {
|
|
576
|
+
unchecked += targets.length - targets.indexOf(target);
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
records.push(...(await enrichFileFromLsp(cwd, args, target, inBandDeps)));
|
|
580
|
+
// #2504 review round 5 (F2): stamped when THIS file's pull
|
|
581
|
+
// returned, not when the report is assembled.
|
|
582
|
+
observedAtByPath.set(normalizeMapKey(target.filePath), new Date().toISOString());
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
// Cached files first: they are free, and whether ANY of them exist is
|
|
586
|
+
// what decides the cold set's fate below.
|
|
587
|
+
await runInBand(primed);
|
|
588
|
+
// #2504 (4) cold-cache deferral. When the turn primed nothing, every
|
|
589
|
+
// remaining file is a full open + diagnostic wait; that whole loop is
|
|
590
|
+
// what held the terminal for 187 s. It still runs — off the awaited
|
|
591
|
+
// hook — and lands in the same `actionable-warnings` cache the in-band
|
|
592
|
+
// report goes to, so the findings reach the agent by the same channel,
|
|
593
|
+
// one turn later at worst.
|
|
594
|
+
if (cold.length > 0 && primed.length === 0) {
|
|
595
|
+
// #2504 review round 2 (F3). The pre-fix loop captured
|
|
596
|
+
// `args.signal` — the COMPLETED turn's `ctx.signal`, which
|
|
597
|
+
// `index.ts` clears from the ambient slot in its `finally`, so it
|
|
598
|
+
// could never fire. `armDeferredLspWork` returns the module slot's
|
|
599
|
+
// live signal; `resetLSPService` fires it, which is how
|
|
600
|
+
// session_shutdown, session_start and the idle reset all reach this
|
|
601
|
+
// loop. The turn's own signal is still folded in so a genuine
|
|
602
|
+
// mid-turn abort counts.
|
|
603
|
+
//
|
|
604
|
+
// #2504 review round 3 (F-A(d)): it returns `undefined` when an
|
|
605
|
+
// EARLIER deferral still holds the slot. The incumbent wins — see
|
|
606
|
+
// `armDeferredLspWork` — and this turn states its loss instead of
|
|
607
|
+
// cancelling a loop that is about to publish.
|
|
608
|
+
const deferredSignal = armDeferredLspWork();
|
|
609
|
+
if (deferredSignal === undefined) {
|
|
610
|
+
incrementDegradationCount({
|
|
611
|
+
kind: "actionable-warnings-cap",
|
|
612
|
+
subject: `${cwd}:deferral-declined`,
|
|
613
|
+
// #2504 review round 4 (F1): the old wording ended "rather than
|
|
614
|
+
// cancel it", which asserted a preservation that did not
|
|
615
|
+
// happen -- the incumbent's report was then discarded whole by
|
|
616
|
+
// the persisted-newer guard. With the per-file merge below it
|
|
617
|
+
// is true, so it now says exactly WHAT survives and what does
|
|
618
|
+
// not.
|
|
619
|
+
reason: `an earlier deferred LSP pull is still running; ${cold.length} file(s) went unchecked for code actions this turn. What IS preserved is the incumbent loop's work: when it lands, its per-file entries are merged into whatever report is persisted then, for every file whose fileSeq has not advanced. This turn's own cold files are the loss, and nothing re-derives them -- the next report is a delta over a different file set`,
|
|
620
|
+
});
|
|
621
|
+
logActionableWarningsEvent({
|
|
622
|
+
event: "lsp_pull_deferral_declined",
|
|
623
|
+
sessionId: args.sessionId,
|
|
624
|
+
metadata: { turnIndex: args.turnIndex, files: cold.length },
|
|
625
|
+
});
|
|
626
|
+
args.dbg?.(`actionable_warnings: an earlier deferred LSP pull still holds the slot — skipping ${cold.length} fresh pull(s) this turn`);
|
|
355
627
|
}
|
|
356
628
|
else {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
629
|
+
const carried = [...records];
|
|
630
|
+
const deferredArgs = args;
|
|
631
|
+
const loopSignal = combineAbortSignals(args.signal, deferredSignal) ?? deferredSignal;
|
|
632
|
+
const deferredDeps = {
|
|
633
|
+
lspService,
|
|
634
|
+
pullTimeoutMs,
|
|
635
|
+
signal: loopSignal,
|
|
636
|
+
// OFF the hook by construction: this loop is the answer to
|
|
637
|
+
// turn_end's budget, not a spender of it, and it runs on its
|
|
638
|
+
// own ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS deadline. Charging
|
|
639
|
+
// its exceedances to `turn_end` would make the hook look over
|
|
640
|
+
// budget for having correctly deferred the work (#2557 F3).
|
|
641
|
+
site: {
|
|
642
|
+
hook: "off_hook",
|
|
643
|
+
label: "deferredLspEnrichmentRoundTrip",
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
const deferredWork = (async () => {
|
|
647
|
+
// Yield a full macrotask first. Without this the loop would run
|
|
648
|
+
// its first open+pull inside the awaited call's own microtask
|
|
649
|
+
// drain — "deferred" only on paper, and still on the hook.
|
|
650
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
651
|
+
const deferredRecords = [...carried];
|
|
652
|
+
const deferredDeadline = Date.now() + ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS;
|
|
653
|
+
// #2504 review round 4 (F2): set here, not at construction --
|
|
654
|
+
// the deferred loop's budget starts when the loop does, one
|
|
655
|
+
// macrotask after these deps were built.
|
|
656
|
+
deferredDeps.deadlineAt = deferredDeadline;
|
|
657
|
+
let deferredUnchecked = 0;
|
|
658
|
+
let abortedMidLoop = false;
|
|
659
|
+
for (const target of cold) {
|
|
660
|
+
if (loopSignal.aborted || Date.now() >= deferredDeadline) {
|
|
661
|
+
abortedMidLoop = loopSignal.aborted;
|
|
662
|
+
deferredUnchecked += cold.length - cold.indexOf(target);
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
deferredRecords.push(...(await enrichFileFromLsp(cwd, deferredArgs, target, deferredDeps)));
|
|
666
|
+
// #2504 review round 5 (F2). This is the loop the defect
|
|
667
|
+
// was about: it can run for a minute, and one stamp taken
|
|
668
|
+
// at its end described all of it.
|
|
669
|
+
observedAtByPath.set(normalizeMapKey(target.filePath), new Date().toISOString());
|
|
670
|
+
}
|
|
671
|
+
if (deferredUnchecked > 0) {
|
|
672
|
+
recordDegradationOnce({
|
|
673
|
+
kind: "actionable-warnings-cap",
|
|
674
|
+
subject: `${cwd}:deferred-budget`,
|
|
675
|
+
reason: `deferred LSP enrichment stopped early; ${deferredUnchecked} file(s) were not checked for code actions`,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
// An ABORTED loop delivers nothing (#2504 review round 2, F3).
|
|
679
|
+
// The service it was reading is gone — session_shutdown,
|
|
680
|
+
// session_start, or the idle reset retired it — so its partial
|
|
681
|
+
// record set describes nothing current, and publishing it would
|
|
682
|
+
// be exactly the stale-clobber F2 guards against on the other
|
|
683
|
+
// side.
|
|
684
|
+
if (abortedMidLoop || loopSignal.aborted) {
|
|
685
|
+
logActionableWarningsEvent({
|
|
686
|
+
event: "lsp_pull_aborted",
|
|
687
|
+
sessionId: deferredArgs.sessionId,
|
|
688
|
+
metadata: {
|
|
689
|
+
turnIndex: deferredArgs.turnIndex,
|
|
690
|
+
unchecked: deferredUnchecked,
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
deferredArgs.onDeferredReport?.(assembleReport(cwd, deferredArgs, deferredRecords, {
|
|
696
|
+
observedAtByPath,
|
|
697
|
+
fallbackObservedAt: buildStartedAt,
|
|
698
|
+
}));
|
|
699
|
+
})().catch((err) => {
|
|
700
|
+
args.dbg?.(`actionable_warnings: deferred LSP pull failed: ${err}`);
|
|
701
|
+
});
|
|
702
|
+
registerDeferredLspWork(deferredSignal, deferredWork);
|
|
703
|
+
logActionableWarningsEvent({
|
|
704
|
+
event: "lsp_pull_deferred",
|
|
705
|
+
sessionId: args.sessionId,
|
|
706
|
+
metadata: { turnIndex: args.turnIndex, files: cold.length },
|
|
707
|
+
});
|
|
708
|
+
args.dbg?.(`actionable_warnings: no LSP cache primed this turn — deferring ${cold.length} fresh pull(s) off the turn_end hook`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
await runInBand(cold);
|
|
713
|
+
}
|
|
714
|
+
if (unchecked > 0) {
|
|
715
|
+
recordDegradationOnce({
|
|
716
|
+
kind: "actionable-warnings-cap",
|
|
717
|
+
subject: `${cwd}:wall-budget`,
|
|
718
|
+
reason: `LSP enrichment hit its ${Math.round(budgetMs)}ms turn budget; ${unchecked} file(s) were not checked for code actions this turn`,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return assembleReport(cwd, args, records, {
|
|
723
|
+
observedAtByPath,
|
|
724
|
+
fallbackObservedAt: buildStartedAt,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Enrich one file's LSP warnings into records. Split out of
|
|
729
|
+
* `buildActionableWarningsReport` (#2504) so the in-band loop and the deferred
|
|
730
|
+
* off-hook loop run byte-identical logic — the deferral must not become a
|
|
731
|
+
* second, drifting copy of the enrichment.
|
|
732
|
+
*/
|
|
733
|
+
async function enrichFileFromLsp(cwd, args, target, deps) {
|
|
734
|
+
const { lspService } = deps;
|
|
735
|
+
const { filePath } = target;
|
|
736
|
+
const out = [];
|
|
737
|
+
let diags;
|
|
738
|
+
let lspSource = "cache";
|
|
739
|
+
if (target.cached !== undefined) {
|
|
740
|
+
diags = target.cached;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
// #2504 review round 2 (F3): never OPEN a file once the loop has been
|
|
744
|
+
// signalled. The checks inside `boundedLspCall` cover the round trips;
|
|
745
|
+
// this one covers the decision to touch the file at all, which is what
|
|
746
|
+
// makes a session_shutdown landing mid-loop a no-op rather than one
|
|
747
|
+
// more document handed to a service that is being torn down.
|
|
748
|
+
if (deps.signal?.aborted) {
|
|
749
|
+
logActionableWarningsEvent({
|
|
750
|
+
event: "lsp_file_skipped",
|
|
751
|
+
sessionId: args.sessionId,
|
|
752
|
+
filePath,
|
|
753
|
+
metadata: { reason: "aborted" },
|
|
754
|
+
});
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
if (target.content) {
|
|
759
|
+
// #2504 review round 3 (F-B), the #240 shape. `openFile` resolves
|
|
760
|
+
// `void`, so the bounded call cannot distinguish success from
|
|
761
|
+
// either bound winning unless the success path returns a value of
|
|
762
|
+
// its own. Round 2 discarded the result entirely: a 10 s timeout
|
|
763
|
+
// or an abort "succeeded" silently, and the pull below then asked
|
|
764
|
+
// the server about a document it had never received. The `[]` that
|
|
765
|
+
// answers means UNKNOWN, but it was recorded
|
|
766
|
+
// `lsp_file_checked lspSource:"fresh"` — a failed pull read as
|
|
767
|
+
// clean, which is precisely what the comment on the pull forbids.
|
|
768
|
+
const opened = await boundedLspCall(async () => {
|
|
769
|
+
await lspService.openFile(filePath, target.content);
|
|
770
|
+
return true;
|
|
771
|
+
}, deps);
|
|
772
|
+
if (opened === undefined) {
|
|
365
773
|
logActionableWarningsEvent({
|
|
366
774
|
event: "lsp_file_skipped",
|
|
367
775
|
sessionId: args.sessionId,
|
|
368
776
|
filePath,
|
|
369
|
-
metadata: {
|
|
777
|
+
metadata: {
|
|
778
|
+
reason: deps.signal?.aborted ? "aborted" : "open_timeout",
|
|
779
|
+
pullTimeoutMs: deps.pullTimeoutMs,
|
|
780
|
+
},
|
|
370
781
|
});
|
|
371
|
-
|
|
782
|
+
return out;
|
|
372
783
|
}
|
|
373
784
|
}
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
catch (err) {
|
|
390
|
-
args.dbg?.(`actionable_warnings: LSP codeAction failed for ${filePath}: ${err}`);
|
|
391
|
-
}
|
|
392
|
-
if (record.actions.length > 0) {
|
|
393
|
-
records.push(record);
|
|
394
|
-
enriched++;
|
|
395
|
-
}
|
|
785
|
+
const pulled = await boundedLspCall(() => lspService.getDiagnostics(filePath), deps);
|
|
786
|
+
if (pulled === undefined) {
|
|
787
|
+
// Either bound won. A failed pull is NEVER read as clean (#240):
|
|
788
|
+
// the file is reported unchecked and contributes no records.
|
|
789
|
+
logActionableWarningsEvent({
|
|
790
|
+
event: "lsp_file_skipped",
|
|
791
|
+
sessionId: args.sessionId,
|
|
792
|
+
filePath,
|
|
793
|
+
metadata: {
|
|
794
|
+
reason: deps.signal?.aborted ? "aborted" : "pull_timeout",
|
|
795
|
+
pullTimeoutMs: deps.pullTimeoutMs,
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
return out;
|
|
396
799
|
}
|
|
800
|
+
diags = pulled;
|
|
801
|
+
lspSource = "fresh";
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
args.dbg?.(`actionable_warnings: LSP diagnostics failed for ${filePath}: ${err}`);
|
|
397
805
|
logActionableWarningsEvent({
|
|
398
|
-
event: "
|
|
806
|
+
event: "lsp_file_skipped",
|
|
399
807
|
sessionId: args.sessionId,
|
|
400
808
|
filePath,
|
|
401
|
-
metadata: {
|
|
402
|
-
diagsTotal: diags.length,
|
|
403
|
-
diagsWarning: diagsWarning.length,
|
|
404
|
-
deltaFiltered,
|
|
405
|
-
enriched,
|
|
406
|
-
modifiedRangesCount: ranges.length,
|
|
407
|
-
lspSource,
|
|
408
|
-
},
|
|
809
|
+
metadata: { reason: "lsp_error", error: String(err) },
|
|
409
810
|
});
|
|
811
|
+
return out;
|
|
410
812
|
}
|
|
411
813
|
}
|
|
814
|
+
const ranges = args.modifiedRangesByFile.get(normalizeMapKey(filePath)) ?? [];
|
|
815
|
+
const diagsWarning = diags.filter((d) => d.severity === 2);
|
|
816
|
+
let deltaFiltered = 0;
|
|
817
|
+
let enriched = 0;
|
|
818
|
+
let actionPulls = 0;
|
|
819
|
+
let actionCapped = 0;
|
|
820
|
+
let budgetStopped = 0;
|
|
821
|
+
for (const diag of diagsWarning) {
|
|
822
|
+
// #2504 review round 4 (F2). The wall budget used to be re-checked
|
|
823
|
+
// BETWEEN files only, so a single file could keep the AWAITED hook for
|
|
824
|
+
// openFile + getDiagnostics + up to 25 codeAction round trips long after
|
|
825
|
+
// the batch budget expired. The loop deadline is threaded through deps
|
|
826
|
+
// and re-read HERE, between round trips: a file already opened still
|
|
827
|
+
// finishes its cheap work, but it cannot buy more LSP time.
|
|
828
|
+
if (deps.deadlineAt !== undefined && Date.now() >= deps.deadlineAt) {
|
|
829
|
+
budgetStopped = diagsWarning.length - diagsWarning.indexOf(diag);
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
const line = diag.range.start.line + 1;
|
|
833
|
+
if (args.deltaOnly !== false && !lineInModifiedRanges(line, ranges)) {
|
|
834
|
+
deltaFiltered++;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
// #2504 review round 3 (S-2): bound the per-file codeAction fan-out.
|
|
838
|
+
if (actionPulls >= ACTIONABLE_WARNINGS_MAX_CODE_ACTIONS_PER_FILE) {
|
|
839
|
+
actionCapped++;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
actionPulls++;
|
|
843
|
+
const record = recordFromLspDiagnostic(diag, filePath, cwd);
|
|
844
|
+
try {
|
|
845
|
+
const actions = await boundedLspCall(() => lspService.codeAction(filePath, diag.range.start.line, diag.range.start.character, diag.range.end.line, diag.range.end.character), deps);
|
|
846
|
+
record.actions = (actions ?? []).map(serializeAction).slice(0, 5);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
args.dbg?.(`actionable_warnings: LSP codeAction failed for ${filePath}: ${err}`);
|
|
850
|
+
}
|
|
851
|
+
if (record.actions.length > 0) {
|
|
852
|
+
out.push(record);
|
|
853
|
+
enriched++;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (actionCapped > 0) {
|
|
857
|
+
incrementDegradationCount({
|
|
858
|
+
kind: "actionable-warnings-cap",
|
|
859
|
+
subject: `${cwd}:code-action-fanout`,
|
|
860
|
+
// #2504 review round 4 (S-2): "reported without fix actions" was
|
|
861
|
+
// wrong in both halves. A capped warning is skipped BEFORE its record
|
|
862
|
+
// is built, and a record with no action is dropped below anyway, so
|
|
863
|
+
// it is not reported at all -- it never reaches the agent on this
|
|
864
|
+
// channel.
|
|
865
|
+
reason: `a file exceeded the ${ACTIONABLE_WARNINGS_MAX_CODE_ACTIONS_PER_FILE}-warning code-action cap; ${actionCapped} warning(s) in ${toRunnerDisplayPath(cwd, filePath)} were NOT reported this turn`,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
if (budgetStopped > 0) {
|
|
869
|
+
incrementDegradationCount({
|
|
870
|
+
kind: "actionable-warnings-cap",
|
|
871
|
+
subject: `${cwd}:in-file-budget`,
|
|
872
|
+
reason: `the LSP enrichment wall budget expired part-way through a file; ${budgetStopped} warning(s) in ${toRunnerDisplayPath(cwd, filePath)} were NOT checked for fix actions`,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
logActionableWarningsEvent({
|
|
876
|
+
event: "lsp_file_checked",
|
|
877
|
+
sessionId: args.sessionId,
|
|
878
|
+
filePath,
|
|
879
|
+
metadata: {
|
|
880
|
+
diagsTotal: diags.length,
|
|
881
|
+
diagsWarning: diagsWarning.length,
|
|
882
|
+
deltaFiltered,
|
|
883
|
+
enriched,
|
|
884
|
+
actionCapped,
|
|
885
|
+
budgetStopped,
|
|
886
|
+
modifiedRangesCount: ranges.length,
|
|
887
|
+
lspSource,
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Assemble the report from a record set. Called once for the in-band report
|
|
894
|
+
* and again, off-hook, when the deferred fresh pull completes (#2504).
|
|
895
|
+
*/
|
|
896
|
+
function assembleReport(cwd, args, records, stamps) {
|
|
412
897
|
const merged = mergeWarnings(records);
|
|
413
898
|
updateWarningState(cwd, merged);
|
|
414
899
|
// legacyId is #1816 migration bookkeeping for updateWarningState above —
|
|
415
900
|
// strip it before the report leaves this function, so it never lands in
|
|
416
|
-
// the
|
|
901
|
+
// the `<project-data-dir>/cache/actionable-warnings.json` cache file or any
|
|
417
902
|
// agent-facing rendering of a warning record.
|
|
418
903
|
const reportWarnings = merged.map(({ legacyId: _legacyId, ...rest }) => rest);
|
|
419
904
|
const byFile = new Map();
|
|
@@ -422,36 +907,32 @@ export async function buildActionableWarningsReport(args) {
|
|
|
422
907
|
arr.push(warning);
|
|
423
908
|
byFile.set(warning.filePath, arr);
|
|
424
909
|
}
|
|
910
|
+
// #2504 review round 4 (F1): every entry carries the moment it was
|
|
911
|
+
// assembled, so a report that later absorbs a deferred pull's entries can
|
|
912
|
+
// still age each half honestly. For a report built in one pass they are all
|
|
913
|
+
// the report stamp.
|
|
914
|
+
const generatedAt = new Date().toISOString();
|
|
425
915
|
const files = [...byFile.entries()].map(([filePath, warnings]) => ({
|
|
426
916
|
filePath,
|
|
427
917
|
displayPath: toRunnerDisplayPath(cwd, filePath),
|
|
428
918
|
fileSeq: args.fileSeqByPath?.get(normalizeMapKey(filePath)),
|
|
919
|
+
// #2504 review round 5 (F2): the moment THIS file was observed. The
|
|
920
|
+
// report-level stamp remains the assembly moment and is only the
|
|
921
|
+
// last-resort fallback, for a caller that supplied no observations
|
|
922
|
+
// at all.
|
|
923
|
+
generatedAt: stamps?.observedAtByPath.get(normalizeMapKey(filePath)) ??
|
|
924
|
+
stamps?.fallbackObservedAt ??
|
|
925
|
+
generatedAt,
|
|
429
926
|
warnings,
|
|
430
927
|
}));
|
|
431
|
-
const
|
|
432
|
-
const unsuppressed = merged.filter((warning) => !warning.suppressed);
|
|
433
|
-
const countTier = (tier) => unsuppressed.filter((warning) => warning.severity === tier).length;
|
|
434
|
-
const summary = {
|
|
435
|
-
warnings: merged.length,
|
|
436
|
-
unsuppressed: unsuppressed.length,
|
|
437
|
-
byTier: {
|
|
438
|
-
warning: countTier("warning"),
|
|
439
|
-
info: countTier("info"),
|
|
440
|
-
hint: countTier("hint"),
|
|
441
|
-
},
|
|
442
|
-
suppressed: merged.filter((warning) => warning.suppressed).length,
|
|
443
|
-
files: files.length,
|
|
444
|
-
actions: allActions.length,
|
|
445
|
-
autoFixEligible: allActions.filter((action) => action.autoFixEligible)
|
|
446
|
-
.length,
|
|
447
|
-
};
|
|
928
|
+
const summary = summarizeReportFiles(files);
|
|
448
929
|
logActionableWarningsEvent({
|
|
449
930
|
event: "report_complete",
|
|
450
931
|
sessionId: args.sessionId,
|
|
451
932
|
metadata: { turnIndex: args.turnIndex, summary },
|
|
452
933
|
});
|
|
453
934
|
return {
|
|
454
|
-
generatedAt
|
|
935
|
+
generatedAt,
|
|
455
936
|
scope: "turn_delta",
|
|
456
937
|
sessionId: args.sessionId,
|
|
457
938
|
turnIndex: args.turnIndex,
|
|
@@ -463,9 +944,356 @@ export async function buildActionableWarningsReport(args) {
|
|
|
463
944
|
summary,
|
|
464
945
|
};
|
|
465
946
|
}
|
|
466
|
-
|
|
947
|
+
/**
|
|
948
|
+
* The report summary, derived from the per-file entries.
|
|
949
|
+
*
|
|
950
|
+
* ONE derivation (#2504 review round 4, F1): a merged report's summary has to
|
|
951
|
+
* describe the merged file set, and a second hand-rolled tally beside
|
|
952
|
+
* assembleReport's would be the mirrored-registry defect AGENTS.md names.
|
|
953
|
+
* Every warning belongs to exactly one file entry, so summing over entries and
|
|
954
|
+
* summing over the flat record list give the same numbers.
|
|
955
|
+
*/
|
|
956
|
+
function summarizeReportFiles(files) {
|
|
957
|
+
const warnings = files.flatMap((file) => file.warnings);
|
|
958
|
+
const unsuppressed = warnings.filter((warning) => !warning.suppressed);
|
|
959
|
+
const allActions = warnings.flatMap((warning) => warning.actions);
|
|
960
|
+
const countTier = (tier) => unsuppressed.filter((warning) => warning.severity === tier).length;
|
|
961
|
+
return {
|
|
962
|
+
warnings: warnings.length,
|
|
963
|
+
unsuppressed: unsuppressed.length,
|
|
964
|
+
byTier: {
|
|
965
|
+
warning: countTier("warning"),
|
|
966
|
+
info: countTier("info"),
|
|
967
|
+
hint: countTier("hint"),
|
|
968
|
+
},
|
|
969
|
+
suppressed: warnings.filter((warning) => warning.suppressed).length,
|
|
970
|
+
files: files.length,
|
|
971
|
+
actions: allActions.length,
|
|
972
|
+
autoFixEligible: allActions.filter((action) => action.autoFixEligible)
|
|
973
|
+
.length,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* The ONE place an actionable-warnings report reaches disk.
|
|
978
|
+
*
|
|
979
|
+
* Deliberately NOT exported (#2504 review round 5, F1). Two writers used to
|
|
980
|
+
* publish to this key: the in-band turn_end report, which OVERWROTE blindly,
|
|
981
|
+
* and the deferred off-hook report, which read-modify-wrote. A deferred merge
|
|
982
|
+
* that landed anywhere inside turn N+1's handleTurnEnd -- the cascade settle,
|
|
983
|
+
* knip, madge, the test batch, the in-band LSP enrichment; a window seconds
|
|
984
|
+
* wide, measured at 607 ms in the reviewer's trace -- was erased by that
|
|
985
|
+
* turn's blind write, and the findings the whole deferral exists to deliver
|
|
986
|
+
* were gone with it. A blind writer and a merging writer on one key can only
|
|
987
|
+
* ever be a race. There is now one publisher and it always merges; the private
|
|
988
|
+
* scope is what keeps it that way.
|
|
989
|
+
*/
|
|
990
|
+
function writeActionableWarningsReport(cacheManager, cwd, report) {
|
|
467
991
|
cacheManager.writeCache("actionable-warnings", report, cwd);
|
|
468
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Merge a report about to be published into whatever is already persisted,
|
|
995
|
+
* PER FILE.
|
|
996
|
+
*
|
|
997
|
+
* #2504 review round 4 (F1) established the shape: a report is a MAP of
|
|
998
|
+
* per-file entries, each stamped with that file's fileSeq and its own
|
|
999
|
+
* observation time, so ordering belongs PER FILE and not to the report as a
|
|
1000
|
+
* whole. Rounds 2 and 3 ordered whole reports -- publish, or discard on a
|
|
1001
|
+
* newer turnIndex/projectSeqEnd -- and that composed with incumbent-wins into
|
|
1002
|
+
* "publish nothing".
|
|
1003
|
+
*
|
|
1004
|
+
* #2504 review round 5 (F1) generalized it to BOTH publishers, because the
|
|
1005
|
+
* in-band write was still blind and erased a deferred merge that landed
|
|
1006
|
+
* mid-turn. The two directions are asymmetric and the asymmetry is the whole
|
|
1007
|
+
* design:
|
|
1008
|
+
*
|
|
1009
|
+
* - A DEFERRED publish carries the OLDER observation. Everything persisted is
|
|
1010
|
+
* newer and is kept unconditionally; the deferred entries upsert into it
|
|
1011
|
+
* wherever their file has not moved.
|
|
1012
|
+
* - An IN-BAND publish carries the NEWER observation. It is this turn's own
|
|
1013
|
+
* report, and its identity (turnIndex, projectSeq window, deltaOnly) is
|
|
1014
|
+
* published untouched. From the persisted report it carries forward ONLY
|
|
1015
|
+
* entries a deferral produced -- the scope guard, see
|
|
1016
|
+
* {@link ActionableWarningsReportFile.origin}. Carrying everything would
|
|
1017
|
+
* accumulate every prior turn's findings into a report whose scope field
|
|
1018
|
+
* says "turn_delta"; carrying nothing is the F1 defect.
|
|
1019
|
+
*
|
|
1020
|
+
* Where both halves hold a file the warnings are UNIONED through mergeWarnings
|
|
1021
|
+
* (the same de-duplicating merge the dispatch/LSP union already uses), so a
|
|
1022
|
+
* newer entry is never REPLACED by an older one -- it only gains what the
|
|
1023
|
+
* older one found -- and the merged entry is aged by the OLDER of the two
|
|
1024
|
+
* stamps, because it now holds both observations.
|
|
1025
|
+
*
|
|
1026
|
+
* Exported because the merge, not the write, is what has to be pinned: a test
|
|
1027
|
+
* can hand it two reports and read the ordering decision directly.
|
|
1028
|
+
*/
|
|
1029
|
+
export function mergeActionableWarningsReports(args) {
|
|
1030
|
+
const { persisted, incoming, origin } = args;
|
|
1031
|
+
const deferredPublish = origin === "deferred";
|
|
1032
|
+
// The newer half wins identity and fileSeq; the older half is folded in.
|
|
1033
|
+
// #2504 review round 6 (F1) removed a same-sessionId gate here, reasoning
|
|
1034
|
+
// that the fileSeq equality check below subsumed it: pi's telemetry
|
|
1035
|
+
// sessionId is STABLE across a quit->resume (setSessionLifecycle pins it,
|
|
1036
|
+
// runtime-coordinator.ts:677-682), but resetForSession clears the live
|
|
1037
|
+
// _fileSeq map for the new process, so a resumed process's getFileSeq
|
|
1038
|
+
// answers 0 for a file a PRE-restart deferral had recorded at a nonzero
|
|
1039
|
+
// seq -- the sessionId gate could not see that (same id, stale data), the
|
|
1040
|
+
// equality check could.
|
|
1041
|
+
// #2504 review round 7 (F4): that reasoning breaks at fileSeq 0. A file
|
|
1042
|
+
// THIS process has never touched also answers getFileSeq 0 (a fresh
|
|
1043
|
+
// RuntimeCoordinator's map starts empty), and an entry a DIFFERENT
|
|
1044
|
+
// process stamped at fileSeq 0 can reach the persisted report entirely
|
|
1045
|
+
// unrelated to a resume: `mcp/analyze.ts` and `mcp/session.ts` register
|
|
1046
|
+
// modified ranges through their OWN CacheManager, which never bumps the
|
|
1047
|
+
// extension runtime's `_fileSeq` map, so an MCP-touched file can persist
|
|
1048
|
+
// at seq 0 under a foreign sessionId. `0 !== 0` then reads FALSE and the
|
|
1049
|
+
// entry is carried as if it were this session's own unmoved file. The
|
|
1050
|
+
// sessionId gate is restored ALONGSIDE the equality check, not instead of
|
|
1051
|
+
// it: equality alone catches the resume-reset case (same session, seq
|
|
1052
|
+
// forcibly reset to 0), the sessionId gate alone catches the
|
|
1053
|
+
// foreign-session case (different session, seq coincidentally equal,
|
|
1054
|
+
// often both 0) -- neither subsumes the other.
|
|
1055
|
+
const sessionMismatch = !deferredPublish &&
|
|
1056
|
+
persisted?.sessionId !== undefined &&
|
|
1057
|
+
persisted.sessionId !== incoming.sessionId;
|
|
1058
|
+
const newerHalf = deferredPublish ? (persisted?.files ?? []) : incoming.files;
|
|
1059
|
+
const olderHalf = deferredPublish ? incoming.files : (persisted?.files ?? []);
|
|
1060
|
+
const newerReport = deferredPublish ? persisted : incoming;
|
|
1061
|
+
const olderReport = deferredPublish ? incoming : persisted;
|
|
1062
|
+
const byPath = new Map();
|
|
1063
|
+
for (const entry of newerHalf) {
|
|
1064
|
+
byPath.set(normalizeMapKey(entry.filePath), entry);
|
|
1065
|
+
}
|
|
1066
|
+
const droppedFiles = [];
|
|
1067
|
+
let mergedFiles = 0;
|
|
1068
|
+
for (const entry of olderHalf) {
|
|
1069
|
+
// The scope guard. An in-band publish exists to add THIS turn's
|
|
1070
|
+
// findings; the only thing it owes the persisted report is the deferred
|
|
1071
|
+
// work that would otherwise be erased between the read and the write.
|
|
1072
|
+
// #2504 review round 6 (b): the marker spend (below) means an entry
|
|
1073
|
+
// carried once and then dropped here on the NEXT in-band publish is
|
|
1074
|
+
// expected, not a fault -- traced to dbg (informational) rather than
|
|
1075
|
+
// the degradation ledger (which is for loss the agent didn't cause).
|
|
1076
|
+
if (!deferredPublish && entry.origin !== "deferred") {
|
|
1077
|
+
args.dbg?.(`actionable_warnings: in-band publish dropped ${entry.displayPath || entry.filePath} -- its carry-forward window (from an earlier deferred publish) already closed`);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
const key = normalizeMapKey(entry.filePath);
|
|
1081
|
+
const incumbent = byPath.get(key);
|
|
1082
|
+
const liveBaseline = args.getFileSeq !== undefined;
|
|
1083
|
+
const baselineSeq = args.getFileSeq?.(entry.filePath) ?? incumbent?.fileSeq;
|
|
1084
|
+
// #2504 review round 6 (F1): the in-band carry-forward path, when a
|
|
1085
|
+
// LIVE baseline is available, requires EXACT equality rather than
|
|
1086
|
+
// "baseline advanced past it". An unmoved file's live fileSeq always
|
|
1087
|
+
// equals its persisted entry's recorded fileSeq; any mismatch --
|
|
1088
|
+
// higher (edited since) OR lower (a resumed process whose fileSeq map
|
|
1089
|
+
// was cleared, #2504 r6 F1) -- means the entry no longer describes
|
|
1090
|
+
// what is on disk now. The deferred-publish path keeps the coarser
|
|
1091
|
+
// ">" check: a live process's own fileSeq only ever advances, so
|
|
1092
|
+
// "advanced past it" and "mismatched" agree there, and the deferred
|
|
1093
|
+
// loop's entries can legitimately equal a JUST-bumped incumbent seq
|
|
1094
|
+
// it upserts into.
|
|
1095
|
+
const stale = !deferredPublish && liveBaseline
|
|
1096
|
+
? sessionMismatch ||
|
|
1097
|
+
(typeof entry.fileSeq === "number" && baselineSeq !== entry.fileSeq)
|
|
1098
|
+
: typeof baselineSeq === "number" &&
|
|
1099
|
+
typeof entry.fileSeq === "number" &&
|
|
1100
|
+
baselineSeq > entry.fileSeq;
|
|
1101
|
+
if (stale) {
|
|
1102
|
+
droppedFiles.push(entry.displayPath || entry.filePath);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
mergedFiles++;
|
|
1106
|
+
const merged = incumbent
|
|
1107
|
+
? {
|
|
1108
|
+
...incumbent,
|
|
1109
|
+
fileSeq: incumbent.fileSeq ?? entry.fileSeq,
|
|
1110
|
+
generatedAt: olderStamp(incumbent.generatedAt ?? newerReport?.generatedAt, entry.generatedAt ?? olderReport?.generatedAt),
|
|
1111
|
+
warnings: mergeWarnings([...incumbent.warnings, ...entry.warnings]),
|
|
1112
|
+
}
|
|
1113
|
+
: { ...entry };
|
|
1114
|
+
if (deferredPublish) {
|
|
1115
|
+
// Mark it, so the next in-band publish carries it forward across the
|
|
1116
|
+
// window in which it would otherwise be overwritten.
|
|
1117
|
+
merged.origin = "deferred";
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
// Carried forward exactly once: the marker is spent here.
|
|
1121
|
+
merged.origin = undefined;
|
|
1122
|
+
}
|
|
1123
|
+
byPath.set(key, merged);
|
|
1124
|
+
}
|
|
1125
|
+
const files = [...byPath.values()];
|
|
1126
|
+
if (!deferredPublish) {
|
|
1127
|
+
// This turn's report, with the rescued entries folded in. Its identity
|
|
1128
|
+
// is published untouched: a carried entry's provenance lives ON the
|
|
1129
|
+
// entry (its own fileSeq and generatedAt), which is exactly why round 4
|
|
1130
|
+
// moved those fields there, and widening turnIndex/projectSeqStart to
|
|
1131
|
+
// span the older half would make the report claim a window it never
|
|
1132
|
+
// observed.
|
|
1133
|
+
return {
|
|
1134
|
+
report: { ...incoming, files, summary: summarizeReportFiles(files) },
|
|
1135
|
+
mergedFiles,
|
|
1136
|
+
droppedFiles,
|
|
1137
|
+
// sessionMismatch is a single flag for the whole call, not per-entry:
|
|
1138
|
+
// once true, every stale check above is forced true by the `||`, so
|
|
1139
|
+
// EITHER every drop in this batch is a session-boundary drop, OR none
|
|
1140
|
+
// are (droppedFiles is then purely fileSeq-mismatch drops).
|
|
1141
|
+
droppedForSessionMismatch: sessionMismatch && droppedFiles.length > 0,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
const base = persisted ?? incoming;
|
|
1145
|
+
return {
|
|
1146
|
+
report: {
|
|
1147
|
+
...base,
|
|
1148
|
+
// The report-level stamp is only the fallback for entries with none
|
|
1149
|
+
// of their own (a cache file from an older build), so it takes the
|
|
1150
|
+
// NEWER of the two; per-entry stamps carry the real ages.
|
|
1151
|
+
generatedAt: newerStamp(persisted?.generatedAt, incoming.generatedAt) ??
|
|
1152
|
+
base.generatedAt,
|
|
1153
|
+
turnIndex: Math.max(base.turnIndex, incoming.turnIndex),
|
|
1154
|
+
projectSeqStart: minDefined(persisted?.projectSeqStart, incoming.projectSeqStart),
|
|
1155
|
+
// max, so a merged report never claims to be fresher OR staler than
|
|
1156
|
+
// the newest part it holds. checkActionableWarningsReportFresh still
|
|
1157
|
+
// demands exact equality with the live projectSeq and re-checks every
|
|
1158
|
+
// entry's fileSeq, so this widens nothing.
|
|
1159
|
+
projectSeqEnd: maxDefined(persisted?.projectSeqEnd, incoming.projectSeqEnd),
|
|
1160
|
+
includeLspCodeActions: base.includeLspCodeActions || incoming.includeLspCodeActions,
|
|
1161
|
+
files,
|
|
1162
|
+
summary: summarizeReportFiles(files),
|
|
1163
|
+
},
|
|
1164
|
+
mergedFiles,
|
|
1165
|
+
droppedFiles,
|
|
1166
|
+
// The deferred-publish path never sets sessionMismatch (gated by
|
|
1167
|
+
// `!deferredPublish` above), so its drops are always fileSeq-mismatch.
|
|
1168
|
+
droppedForSessionMismatch: false,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Publish an actionable-warnings report: read what is persisted, merge per
|
|
1173
|
+
* file, write. THE choke point (#2504 review round 5, F1) -- every publisher
|
|
1174
|
+
* goes through it, so no writer can clobber another's entries.
|
|
1175
|
+
*
|
|
1176
|
+
* The read and the merge and the write are one synchronous block, which is
|
|
1177
|
+
* what makes this safe against the deferred callback: node runs no other
|
|
1178
|
+
* continuation between them, so the only interleaving left is publish-vs-
|
|
1179
|
+
* publish, and that is exactly what the merge resolves.
|
|
1180
|
+
*/
|
|
1181
|
+
export function publishActionableWarningsReport(cacheManager, cwd, report, opts = {}) {
|
|
1182
|
+
const origin = opts.origin ?? "in-band";
|
|
1183
|
+
// No TTL: "what is already on disk" is a question about ORDERING, not
|
|
1184
|
+
// freshness, so an entry old enough to have expired for a CONSUMER still
|
|
1185
|
+
// has to be merged into rather than clobbered.
|
|
1186
|
+
const persisted = cacheManager.readCache("actionable-warnings", cwd, Number.MAX_SAFE_INTEGER)?.data;
|
|
1187
|
+
const merged = mergeActionableWarningsReports({
|
|
1188
|
+
persisted,
|
|
1189
|
+
incoming: report,
|
|
1190
|
+
origin,
|
|
1191
|
+
getFileSeq: opts.getFileSeq,
|
|
1192
|
+
dbg: opts.dbg,
|
|
1193
|
+
});
|
|
1194
|
+
writeActionableWarningsReport(cacheManager, cwd, merged.report);
|
|
1195
|
+
// The history records what THIS publish OBSERVED, not what it carried: a
|
|
1196
|
+
// merged-in entry is already on the NDJSON from the publish that produced
|
|
1197
|
+
// it, and appending the merged report would duplicate every carried row on
|
|
1198
|
+
// every subsequent turn.
|
|
1199
|
+
appendActionableWarningsHistory(cwd, report);
|
|
1200
|
+
if (merged.mergedFiles > 0) {
|
|
1201
|
+
opts.dbg?.(`actionable_warnings: ${origin} publish merged ${merged.mergedFiles} persisted file entry/entries`);
|
|
1202
|
+
}
|
|
1203
|
+
// #2504 review round 7 (F5): the deferred origin's own loss is already
|
|
1204
|
+
// recorded by {@link writeDeferredActionableWarningsReport}, which calls
|
|
1205
|
+
// through here first. The IN-BAND origin has the same shape of loss --
|
|
1206
|
+
// this turn's own publish carries a carried-forward deferred entry
|
|
1207
|
+
// forward only while its file has not moved, and a file that moved
|
|
1208
|
+
// between the deferral and this publish is dropped -- but nothing counted
|
|
1209
|
+
// or traced it: only the benign, expected scope-guard spend (an entry
|
|
1210
|
+
// whose carry-forward window already closed) got a dbg line. Same
|
|
1211
|
+
// accounting, mirrored for the origin that was silent.
|
|
1212
|
+
if (origin === "in-band" && merged.droppedFiles.length > 0) {
|
|
1213
|
+
// #2504 review round 8 (S2): "changed" is only true on the fileSeq-
|
|
1214
|
+
// mismatch path. A foreign sessionId (a resumed process, or a
|
|
1215
|
+
// different session's write) drops the same carried-forward entries
|
|
1216
|
+
// without any file having moved -- name the actual cause instead of
|
|
1217
|
+
// asserting a change that may not have happened.
|
|
1218
|
+
const cause = merged.droppedForSessionMismatch
|
|
1219
|
+
? "the publish crossed a session boundary (a different session's write, or a resumed process) before this turn's in-band publish could keep them"
|
|
1220
|
+
: "changed before this turn's in-band publish could keep them";
|
|
1221
|
+
incrementDegradationCount({
|
|
1222
|
+
kind: "actionable-warnings-inband-superseded",
|
|
1223
|
+
subject: `${path.resolve(cwd)}:inband-carry-superseded`,
|
|
1224
|
+
reason: `${merged.droppedFiles.length} carried-forward deferred file entry/entries ${cause} (${merged.droppedFiles.slice(0, 3).join(", ")}${merged.droppedFiles.length > 3 ? ", ..." : ""}); their earlier findings are LOST on this channel rather than published against content that has since moved`,
|
|
1225
|
+
});
|
|
1226
|
+
opts.dbg?.(`actionable_warnings: in-band publish dropped ${merged.droppedFiles.length} superseded carried-forward file entry/entries (${merged.droppedFiles.slice(0, 3).join(", ")}${merged.droppedFiles.length > 3 ? ", ..." : ""})`);
|
|
1227
|
+
}
|
|
1228
|
+
return merged;
|
|
1229
|
+
}
|
|
1230
|
+
/** The earlier of two ISO stamps; either may be missing or unparseable. */
|
|
1231
|
+
function olderStamp(a, b) {
|
|
1232
|
+
if (a === undefined)
|
|
1233
|
+
return b;
|
|
1234
|
+
if (b === undefined)
|
|
1235
|
+
return a;
|
|
1236
|
+
const at = Date.parse(a);
|
|
1237
|
+
const bt = Date.parse(b);
|
|
1238
|
+
if (!Number.isFinite(at))
|
|
1239
|
+
return b;
|
|
1240
|
+
if (!Number.isFinite(bt))
|
|
1241
|
+
return a;
|
|
1242
|
+
return at <= bt ? a : b;
|
|
1243
|
+
}
|
|
1244
|
+
/** The later of two ISO stamps; either may be missing or unparseable. */
|
|
1245
|
+
function newerStamp(a, b) {
|
|
1246
|
+
const older = olderStamp(a, b);
|
|
1247
|
+
if (a === undefined)
|
|
1248
|
+
return b;
|
|
1249
|
+
if (b === undefined)
|
|
1250
|
+
return a;
|
|
1251
|
+
return older === a ? b : a;
|
|
1252
|
+
}
|
|
1253
|
+
function minDefined(a, b) {
|
|
1254
|
+
if (typeof a !== "number")
|
|
1255
|
+
return b;
|
|
1256
|
+
if (typeof b !== "number")
|
|
1257
|
+
return a;
|
|
1258
|
+
return Math.min(a, b);
|
|
1259
|
+
}
|
|
1260
|
+
function maxDefined(a, b) {
|
|
1261
|
+
if (typeof a !== "number")
|
|
1262
|
+
return b;
|
|
1263
|
+
if (typeof b !== "number")
|
|
1264
|
+
return a;
|
|
1265
|
+
return Math.max(a, b);
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Publish a DEFERRED off-hook report.
|
|
1269
|
+
*
|
|
1270
|
+
* The deferred fresh-pull loop is stamped with the ORIGINATING turn's
|
|
1271
|
+
* turnIndex/projectSeq and may run for up to
|
|
1272
|
+
* ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS -- many turns in a busy session. It
|
|
1273
|
+
* must therefore neither overwrite a newer report nor be thrown away because
|
|
1274
|
+
* one exists; see mergeActionableWarningsReports for why replace-or-discard
|
|
1275
|
+
* could only ever discard. Per-file loss is the only loss left, and it is
|
|
1276
|
+
* recorded rather than silent.
|
|
1277
|
+
*
|
|
1278
|
+
* A thin wrapper over the choke point since #2504 review round 5 (F1). It
|
|
1279
|
+
* exists only to own the degradation record for the per-file loss -- the
|
|
1280
|
+
* "declined the write" branches it used to carry are gone, because a publish
|
|
1281
|
+
* that merges has nothing to decline.
|
|
1282
|
+
*/
|
|
1283
|
+
export function writeDeferredActionableWarningsReport(args) {
|
|
1284
|
+
const { mergedFiles, droppedFiles } = publishActionableWarningsReport(args.cacheManager, args.cwd, args.report, { origin: "deferred", getFileSeq: args.getFileSeq, dbg: args.dbg });
|
|
1285
|
+
if (droppedFiles.length > 0) {
|
|
1286
|
+
// Bounded and counted: one subject per project, a tally per occurrence.
|
|
1287
|
+
incrementDegradationCount({
|
|
1288
|
+
kind: "actionable-warnings-deferred-superseded",
|
|
1289
|
+
subject: `${path.resolve(args.cwd)}:deferred-file-superseded`,
|
|
1290
|
+
reason: `${droppedFiles.length} file(s) changed while the deferred LSP pull was reading them (${droppedFiles.slice(0, 3).join(", ")}${droppedFiles.length > 3 ? ", ..." : ""}); their warnings are LOST on this channel rather than published against content that has since moved. Every file that did NOT change was merged into the persisted report`,
|
|
1291
|
+
});
|
|
1292
|
+
args.dbg?.(`turn_end: deferred actionable-warnings dropped ${droppedFiles.length} superseded file entry/entries`);
|
|
1293
|
+
}
|
|
1294
|
+
args.dbg?.(`turn_end: deferred actionable-warnings merged ${mergedFiles} file entry/entries into the persisted report`);
|
|
1295
|
+
return { mergedFiles, droppedFiles: droppedFiles.length };
|
|
1296
|
+
}
|
|
469
1297
|
export function getActionableWarningsHistoryPath(cwd) {
|
|
470
1298
|
return path.join(getProjectDataDir(cwd), "actionable-warnings.jsonl");
|
|
471
1299
|
}
|
|
@@ -677,7 +1505,18 @@ export async function applyConservativeActionableWarningFixes(args) {
|
|
|
677
1505
|
}
|
|
678
1506
|
return summary;
|
|
679
1507
|
}
|
|
680
|
-
|
|
1508
|
+
/**
|
|
1509
|
+
* The turn-end advisory for this report.
|
|
1510
|
+
*
|
|
1511
|
+
* `cwd` is REQUIRED (#2521): the advisory names where the report lives, and
|
|
1512
|
+
* that location is `getProjectDataDir(cwd)`-resolved, not a constant. It was a
|
|
1513
|
+
* hardcoded `.pi-lens/cache/actionable-warnings.json` — correct only for a
|
|
1514
|
+
* project that already had a legacy `.pi-lens/` directory — so in every other
|
|
1515
|
+
* project the agent's `cat` of the advised path failed while the report sat
|
|
1516
|
+
* unread under `~/.pi-lens/projects/<slug>/cache/`. An OPTIONAL `cwd` would
|
|
1517
|
+
* have kept that bug alive behind a default, so callers must pass it.
|
|
1518
|
+
*/
|
|
1519
|
+
export function formatActionableWarningsAdvisory(report, cwd) {
|
|
681
1520
|
if (report.summary.unsuppressed === 0)
|
|
682
1521
|
return undefined;
|
|
683
1522
|
const files = report.files.filter((file) => file.warnings.some((warning) => !warning.suppressed));
|
|
@@ -697,12 +1536,20 @@ export function formatActionableWarningsAdvisory(report) {
|
|
|
697
1536
|
const tierLine = quiet > 0
|
|
698
1537
|
? `${quiet} of those are hint/info tier — style opinions, worth fixing only while you are already in that code.`
|
|
699
1538
|
: undefined;
|
|
1539
|
+
// #2521: lead with the TOOL route. `lens_diagnostics mode=delta` reads the
|
|
1540
|
+
// same cache this advisory describes, so an agent never needs to know the
|
|
1541
|
+
// store's layout — which is exactly the knowledge the old hardcoded path
|
|
1542
|
+
// got wrong. The resolved file stays as a named fallback for a human
|
|
1543
|
+
// reading the transcript, rendered through the one helper that cannot
|
|
1544
|
+
// disagree with the writer.
|
|
1545
|
+
const reportPath = displayProjectDataPath(cwd, "cache", "actionable-warnings.json");
|
|
700
1546
|
return [
|
|
701
1547
|
`🟡 Fixable warnings introduced this turn: ${report.summary.unsuppressed}.${safe}`,
|
|
702
1548
|
tierLine,
|
|
703
|
-
|
|
1549
|
+
"Use lens_diagnostics with mode=delta to inspect these warnings.",
|
|
704
1550
|
fileList ? `Files:\n${fileList}${more}` : undefined,
|
|
705
|
-
"If continuing in these files,
|
|
1551
|
+
"If continuing in these files, resolve warnings that are safe and relevant. Do not apply broad refactors unless requested.",
|
|
1552
|
+
`Raw report (only if you need the JSON): ${reportPath}`,
|
|
706
1553
|
]
|
|
707
1554
|
.filter(Boolean)
|
|
708
1555
|
.join("\n");
|