pi-lens 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +543 -0
- package/README.md +45 -2
- package/dist/clients/actionable-warnings.js +44 -16
- package/dist/clients/advisory-provenance.js +187 -51
- package/dist/clients/cache-manager.js +64 -5
- package/dist/clients/cascade-format.js +24 -0
- package/dist/clients/child-unref.js +17 -8
- package/dist/clients/config-diagnostic-codes.js +2 -0
- package/dist/clients/config-locations.js +237 -9
- package/dist/clients/config-resolve.js +7 -1
- package/dist/clients/degradation-ledger.js +33 -3
- package/dist/clients/dependency-checker.js +87 -64
- package/dist/clients/diagnostic-dispositions.js +39 -3
- package/dist/clients/dispatch/finding-policy.js +118 -1
- package/dist/clients/dispatch/indent-detect.js +8 -2
- package/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/dist/clients/dispatch/rule-ignores.js +14 -7
- package/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/dist/clients/dispatch/runners/credo.js +16 -14
- package/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/dist/clients/dispatch/runners/detekt.js +11 -4
- package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/dist/clients/dispatch/runners/eslint.js +29 -46
- package/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/dist/clients/dispatch/runners/javac.js +31 -21
- package/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/dist/clients/dispatch/runners/lsp.js +6 -5
- package/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/dist/clients/dispatch/runners/mypy.js +2 -1
- package/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/dist/clients/dispatch/runners/ruff.js +18 -19
- package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/dist/clients/dispatch/runners/taplo.js +12 -6
- package/dist/clients/dispatch/runners/tflint.js +30 -15
- package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/dist/clients/dispatch/runners/vale.js +15 -3
- package/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/dist/clients/dispatch/suppress-writer.js +13 -3
- package/dist/clients/effective-config.js +3 -1
- package/dist/clients/extension-log.js +48 -1
- package/dist/clients/finding-delivery-gate.js +53 -23
- package/dist/clients/formatters.js +49 -11
- package/dist/clients/git-guard.js +247 -34
- package/dist/clients/gradle-ktfmt-style.js +121 -25
- package/dist/clients/inline-blocker-dispositions.js +186 -0
- package/dist/clients/install-diagnostics.js +19 -6
- package/dist/clients/installer/index.js +281 -32
- package/dist/clients/jscpd-client.js +8 -0
- package/dist/clients/latency-logger.js +8 -0
- package/dist/clients/lens-config.js +66 -8
- package/dist/clients/lsp/cascade-tier.js +3 -0
- package/dist/clients/lsp/client.js +139 -11
- package/dist/clients/lsp/config.js +9 -7
- package/dist/clients/lsp/content-limits.js +41 -0
- package/dist/clients/lsp/index.js +42 -8
- package/dist/clients/lsp/launch.js +23 -2
- package/dist/clients/lsp/server.js +106 -25
- package/dist/clients/lsp/sync-kind.js +14 -0
- package/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/dist/clients/mcp/analyze.js +9 -2
- package/dist/clients/mcp/ipc.js +232 -44
- package/dist/clients/mcp/review.js +53 -7
- package/dist/clients/opengrep-client.js +11 -6
- package/dist/clients/path-utils.js +51 -9
- package/dist/clients/persistent-reverify.js +271 -0
- package/dist/clients/pipeline.js +136 -67
- package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/dist/clients/project-lens-config.js +11 -3
- package/dist/clients/review-graph/builder.js +34 -26
- package/dist/clients/review-graph/git-identity.js +16 -10
- package/dist/clients/ruff-client.js +31 -4
- package/dist/clients/runtime-coordinator.js +38 -4
- package/dist/clients/runtime-tool-call.js +16 -5
- package/dist/clients/runtime-tool-result.js +27 -12
- package/dist/clients/runtime-turn.js +571 -287
- package/dist/clients/safe-spawn.js +256 -65
- package/dist/clients/skills-resolver.js +38 -2
- package/dist/clients/spawn-output-cap.js +84 -1
- package/dist/clients/tree-sitter-client.js +119 -58
- package/dist/clients/turn-end/lane.js +51 -0
- package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/dist/clients/warm-attach.js +59 -0
- package/dist/clients/widget-state.js +294 -17
- package/dist/clients/workspace-topology.js +57 -3
- package/dist/clients/zizmor-config.js +26 -13
- package/dist/index.js +6342 -3995
- package/dist/mcp/analyze-cli.js +4 -0
- package/dist/mcp/server.js +33 -21
- package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/dist/tools/lens-diagnostic-mark.js +14 -2
- package/dist/tools/lens-diagnostics.js +127 -33
- package/dist/tools/lsp-diagnostics.js +150 -12
- package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/docs/adr/0009-reported-path-attribution.md +114 -0
- package/docs/adr/README.md +26 -0
- package/docs/agent-tools.md +18 -1
- package/docs/ast-grep_rules_catalog.md +4 -3
- package/docs/configuration.md +55 -1
- package/docs/engineering-principles.md +202 -0
- package/docs/environment-variables.md +61 -4
- package/docs/features.md +2 -2
- package/docs/globalconfig.md +6 -1
- package/docs/language-coverage.md +17 -0
- package/docs/lsp-capability-matrix.md +98 -31
- package/docs/pi-lens-fixer.md +57 -3
- package/docs/pi-lens-investigator.md +9 -0
- package/docs/pi-lens-reviewer.md +16 -0
- package/docs/pi-lens-subagent.md +9 -0
- package/docs/public-api-stability.md +1 -0
- package/docs/release-qa-baseline.md +2 -0
- package/docs/servercapabilities.md +21 -11
- package/docs/settings.md +12 -3
- package/docs/subagent-compat.md +1 -1
- package/package.json +5 -3
- package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/scripts/install-selftest.mjs +21 -17
- package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { buildActionableWarningsReport, formatActionableWarningsAdvisory, publishActionableWarningsReport, writeDeferredActionableWarningsReport, } from "./actionable-warnings.js";
|
|
4
4
|
import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
|
|
5
5
|
import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
|
|
6
|
-
import { clearGitGuardTestFailure, mergeGitGuardTestFailure, writeGitGuardRecord, } from "./git-guard.js";
|
|
6
|
+
import { clearGitGuardTestFailure, mergeGitGuardTestFailure, resyncGitGuardAfterInlinePolicy, writeGitGuardRecord, } from "./git-guard.js";
|
|
7
7
|
import { cascadeSettleWaitMs } from "./cascade-budget.js";
|
|
8
8
|
import { logCascade } from "./cascade-logger.js";
|
|
9
9
|
import { normalizeMapKey } from "./path-utils.js";
|
|
@@ -13,17 +13,15 @@ import { getKnipIgnorePatterns } from "./file-utils.js";
|
|
|
13
13
|
import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
|
|
14
14
|
import { getFullScanWallClockMs, isWorkspaceSweepActive, runWhenWorkspaceSweepIdle, SWEEP_IDLE_SAFETY_MARGIN_MS, } from "./lsp/workspace-sweep-hold.js";
|
|
15
15
|
import { isTestRoleCollateral } from "./collateral-test-role.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { isSecretWarning, secretLocationKey } from "./secret-findings.js";
|
|
17
|
+
import { govulncheckLane } from "./turn-end/lanes/govulncheck.js";
|
|
18
|
+
import { secretsLane } from "./turn-end/lanes/secrets.js";
|
|
18
19
|
import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, stableFindingKey, } from "./dead-code-client.js";
|
|
19
20
|
import { logDeadCodeScan } from "./dead-code-logger.js";
|
|
20
21
|
import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
|
|
21
22
|
import { deadCodeIssueToProjectDiagnostic } from "./project-diagnostics/runner-adapters/dead-code.js";
|
|
22
|
-
import {
|
|
23
|
-
import { govulncheckFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/govulncheck.js";
|
|
24
|
-
import { trivyFindingToProjectDiagnostic, trivySecretFindingToProjectDiagnostic, } from "./project-diagnostics/runner-adapters/trivy.js";
|
|
23
|
+
import { trivyFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/trivy.js";
|
|
25
24
|
import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
|
|
26
|
-
import { applyDispositionsMultiFile } from "./diagnostic-dispositions.js";
|
|
27
25
|
import { logLatency } from "./latency-logger.js";
|
|
28
26
|
import { getLspBudgetIdleTimeoutMs, shouldShortenLspIdleTimeout, } from "./lsp-budget.js";
|
|
29
27
|
import { updateHeartbeat } from "./instance-registry.js";
|
|
@@ -40,8 +38,11 @@ import { getLSPService } from "./lsp/index.js";
|
|
|
40
38
|
import { drainPendingAuxCapEvictedCount, drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, rearmPendingAuxiliaryCoverage, MAX_LATE_AUX_REARMS, pendingAuxiliaryCoverageSize, } from "./lsp/pending-aux-coverage.js";
|
|
41
39
|
import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
|
|
42
40
|
import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
|
|
43
|
-
import {
|
|
41
|
+
import { runPersistentReverify, } from "./persistent-reverify.js";
|
|
42
|
+
import { cascadeCarrySuffix } from "./cascade-format.js";
|
|
43
|
+
import { applyPushedFindingPolicy, filterFindingsByDisposition, } from "./dispatch/finding-policy.js";
|
|
44
44
|
import { detectFileRole } from "./file-role.js";
|
|
45
|
+
import { applyInlineBlockerPolicy, summarizeInlineBlockerPolicy, } from "./inline-blocker-dispositions.js";
|
|
45
46
|
import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
|
|
46
47
|
// #1631 review V2: moved to its own leaf module so a low-level store
|
|
47
48
|
// (widget-state.ts) can use the marker without importing this orchestrator —
|
|
@@ -281,40 +282,6 @@ export async function runTestTargetsBounded(args) {
|
|
|
281
282
|
stopReason,
|
|
282
283
|
};
|
|
283
284
|
}
|
|
284
|
-
/**
|
|
285
|
-
* #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
|
|
286
|
-
* session-scan caches and formats them into advisory/blocker text — a
|
|
287
|
-
* reporting lane parallel to (and, before this fix, entirely bypassing)
|
|
288
|
-
* `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
|
|
289
|
-
* false-positive/won't-fix on one of these findings never suppressed it
|
|
290
|
-
* here, so it re-reported on every turn.
|
|
291
|
-
*
|
|
292
|
-
* Filters `findings` through the SAME anchor derivation the dispatch path
|
|
293
|
-
* and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
|
|
294
|
-
* `diagnostic-dispositions.ts`), keyed off each lane's own canonical
|
|
295
|
-
* `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
|
|
296
|
-
* identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
|
|
297
|
-
* already anchors a mark against, not a second, cloned identity that would
|
|
298
|
-
* silently diverge from what the agent actually marked.
|
|
299
|
-
*
|
|
300
|
-
* Returns the surviving findings plus how many were dropped, so a caller can
|
|
301
|
-
* still surface a "suppressed by disposition: N" trace (the #1616
|
|
302
|
-
* suppressed-bucket rule — a security finding must never vanish with no
|
|
303
|
-
* trace, even when the disposition that dropped it is working as intended).
|
|
304
|
-
*/
|
|
305
|
-
function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
|
|
306
|
-
if (findings.length === 0)
|
|
307
|
-
return { kept: findings, suppressed: 0 };
|
|
308
|
-
const candidates = findings.map((finding) => ({
|
|
309
|
-
finding,
|
|
310
|
-
diagnostic: toDiagnostic(finding),
|
|
311
|
-
}));
|
|
312
|
-
const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.diagnostic), cwd, (d) => d.filePath));
|
|
313
|
-
const kept = candidates
|
|
314
|
-
.filter((c) => survivors.has(c.diagnostic))
|
|
315
|
-
.map((c) => c.finding);
|
|
316
|
-
return { kept, suppressed: findings.length - kept.length };
|
|
317
|
-
}
|
|
318
285
|
/**
|
|
319
286
|
* Would writing `next` over `prev` throw away a good scan for a failed one?
|
|
320
287
|
*
|
|
@@ -744,9 +711,21 @@ export async function handleTurnEnd(deps) {
|
|
|
744
711
|
* branch below.
|
|
745
712
|
*/
|
|
746
713
|
const pendingDependencyDriftDeliveries = [];
|
|
747
|
-
|
|
714
|
+
/** #3246: one bounded record per TURN for the policy pass, never per finding. */
|
|
715
|
+
const inlinePolicyEntries = [];
|
|
716
|
+
/**
|
|
717
|
+
* #3248: files whose EVERY blocker this pass suppressed — the post-policy
|
|
718
|
+
* survivor set, emptied per file. The commit gate is recomputed from it
|
|
719
|
+
* below, once, after the loop.
|
|
720
|
+
*/
|
|
721
|
+
const policySuppressedPaths = [];
|
|
722
|
+
const inlinePolicyStart = Date.now();
|
|
723
|
+
for (const record of unresolvedBlockers) {
|
|
724
|
+
const { filePath: bPath, summary, stale, staleReason } = record;
|
|
748
725
|
const displayPath = toRunnerDisplayPath(cwd, bPath);
|
|
726
|
+
const tally = { displayPath, sources: record.sources };
|
|
749
727
|
if (stale) {
|
|
728
|
+
inlinePolicyEntries.push({ ...tally, stale: true });
|
|
750
729
|
// #1631: demoted — out of the authoritative blocker channel and into the
|
|
751
730
|
// advisory channel with a stale marker, so the agent is told to re-run
|
|
752
731
|
// rather than pressured by a verdict that may already be resolved.
|
|
@@ -810,10 +789,61 @@ export async function handleTurnEnd(deps) {
|
|
|
810
789
|
(retirementNote ? `\n${retirementNote}` : ""));
|
|
811
790
|
}
|
|
812
791
|
else {
|
|
792
|
+
// #3246: the agent may have marked one of these blockers
|
|
793
|
+
// `false-positive` AFTER the record was written, via
|
|
794
|
+
// `lens_diagnostic_mark` — which every other findings surface honors.
|
|
795
|
+
// Re-derive the body from the record's structured diagnostics through
|
|
796
|
+
// the shared `dispatch/finding-policy.ts` stack against the file's
|
|
797
|
+
// CURRENT bytes, exactly as the late-auxiliary lane below does.
|
|
798
|
+
const policy = applyInlineBlockerPolicy(record, cwd);
|
|
799
|
+
inlinePolicyEntries.push({ ...tally, stale: false, outcome: policy });
|
|
800
|
+
if (policy.body === undefined) {
|
|
801
|
+
// Every blocker on this file was suppressed. This is a PUSH
|
|
802
|
+
// surface: silence after a mark is the mark working, not a clean
|
|
803
|
+
// verdict, so the count rides the bounded per-turn record below
|
|
804
|
+
// instead of announcing the suppression on every later turn.
|
|
805
|
+
policySuppressedPaths.push(bPath);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
// #1616 suppressed-bucket rule: a delivery that still has something to
|
|
809
|
+
// say states what it dropped, once per delivery.
|
|
810
|
+
const suppressedNote = policy.suppressed > 0
|
|
811
|
+
? ` (suppressed by disposition: ${policy.suppressed} finding(s))`
|
|
812
|
+
: "";
|
|
813
813
|
// @delivery-surface: runtime-turn:unresolved-inline-blocker
|
|
814
|
-
blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${
|
|
814
|
+
blockerParts.push(`Unresolved from this turn — ${displayPath}${suppressedNote}:\n${policy.body}`);
|
|
815
815
|
}
|
|
816
816
|
}
|
|
817
|
+
// #3248: the post-policy survivor set per file is FINAL here. The commit
|
|
818
|
+
// gate reads a latch (`gitGuardHasBlockers`) before it ever reads the
|
|
819
|
+
// persisted record, and the policy wrote neither — so a file whose every
|
|
820
|
+
// blocker was just suppressed kept blocking `git commit` while the banner
|
|
821
|
+
// above said nothing about it. Recompute the latch from this set; the
|
|
822
|
+
// persisted record is rewritten or cleared further down by the writer that
|
|
823
|
+
// already owns it (`:4287` / `:4379`), from the same survivor set, and that
|
|
824
|
+
// clear is gated on the latch recomputed here.
|
|
825
|
+
const guardLatchBefore = runtime.gitGuardHasBlockers;
|
|
826
|
+
resyncGitGuardAfterInlinePolicy({
|
|
827
|
+
runtime,
|
|
828
|
+
suppressedFilePaths: policySuppressedPaths,
|
|
829
|
+
});
|
|
830
|
+
if (inlinePolicyEntries.length > 0) {
|
|
831
|
+
logLatency({
|
|
832
|
+
type: "phase",
|
|
833
|
+
toolName: "turn_end",
|
|
834
|
+
filePath: cwd,
|
|
835
|
+
phase: "inline_blocker_policy",
|
|
836
|
+
durationMs: Date.now() - inlinePolicyStart,
|
|
837
|
+
metadata: {
|
|
838
|
+
...summarizeInlineBlockerPolicy(inlinePolicyEntries),
|
|
839
|
+
// #3248: one row per TURN for the gate outcome, never per finding
|
|
840
|
+
// — the flip is the event a reader needs to explain why a commit
|
|
841
|
+
// that was blocked is now allowed.
|
|
842
|
+
guardFilesSuppressed: policySuppressedPaths.length,
|
|
843
|
+
guardLatchCleared: guardLatchBefore && !runtime.gitGuardHasBlockers,
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
}
|
|
817
847
|
// Drain the deferred cascade computes kicked off this turn (#450). They ran
|
|
818
848
|
// concurrently off the write hot path; wait a bounded time for them here so
|
|
819
849
|
// their runs are available to the merge below. A compute still in flight at
|
|
@@ -894,6 +924,24 @@ export async function handleTurnEnd(deps) {
|
|
|
894
924
|
return true;
|
|
895
925
|
});
|
|
896
926
|
const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
|
|
927
|
+
// Fix B (#3167): which results belong to a CARRIED run — the label is
|
|
928
|
+
// per-result at render time, so the run→result pairing must survive the
|
|
929
|
+
// flatMap above (which discards the wrapper).
|
|
930
|
+
// Fix B (#3168 F6): the success-path record — latency.log could not show
|
|
931
|
+
// the feature ever rendered without it (the only phases were the drop/
|
|
932
|
+
// settle records). One bounded record per turn, emitted after both render
|
|
933
|
+
// sections below.
|
|
934
|
+
let carriedRunsRendered = 0;
|
|
935
|
+
let labeledAdvisories = 0;
|
|
936
|
+
const carriedMetaByResult = new Map();
|
|
937
|
+
for (const r of cascadeRuns) {
|
|
938
|
+
if (r.result && (r.carriedTurns ?? 0) > 0 && r.carriedTurns !== undefined) {
|
|
939
|
+
carriedMetaByResult.set(r.result, {
|
|
940
|
+
carriedTurns: r.carriedTurns,
|
|
941
|
+
observedAt: r.observedAt,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
897
945
|
// #1550 class sweep: every cascade record below summarises `cascadeResults`
|
|
898
946
|
// — runs, which carry their own paths and can be carried across turns
|
|
899
947
|
// (#1443) — so labelling them with the turn's first EDITED file is the same
|
|
@@ -931,7 +979,16 @@ export async function handleTurnEnd(deps) {
|
|
|
931
979
|
const pk = normalizeMapKey(result.filePath);
|
|
932
980
|
const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
|
|
933
981
|
if (ownsAny && result.formatted) {
|
|
934
|
-
|
|
982
|
+
// Fix B (#3167/#3168): a carried run's re-rendered blocker is labeled
|
|
983
|
+
// so the agent can tell it from a fresh observation — with the
|
|
984
|
+
// run's own observation age (#3168 F3).
|
|
985
|
+
const carryMeta = carriedMetaByResult.get(result);
|
|
986
|
+
const carrySuffix = cascadeCarrySuffix(carryMeta?.carriedTurns, carryMeta?.observedAt);
|
|
987
|
+
parts.push(carrySuffix
|
|
988
|
+
? `${result.formatted}\n${carrySuffix}`
|
|
989
|
+
: result.formatted);
|
|
990
|
+
if (carrySuffix)
|
|
991
|
+
carriedRunsRendered += 1;
|
|
935
992
|
injectedNeighborCount += result.neighbors.length;
|
|
936
993
|
injectedDiagnosticCount += result.neighbors.reduce((s, n) => s + n.diagnostics.length, 0);
|
|
937
994
|
}
|
|
@@ -1081,27 +1138,59 @@ export async function handleTurnEnd(deps) {
|
|
|
1081
1138
|
// "ℹ️ Advisory — no action required this turn:" label, so an imperative
|
|
1082
1139
|
// ("review dependents manually") would contradict it. The #533 substance
|
|
1083
1140
|
// stays: a clean cascade result does NOT cover these files' dependents.
|
|
1084
|
-
|
|
1141
|
+
// Fix B (#3167/#3168 F4): a coverage advisory computed from a CARRIED
|
|
1142
|
+
// indeterminate run describes the previous turn's evidence — label it so
|
|
1143
|
+
// the absence-of-coverage statement is not read as current. A MIXED
|
|
1144
|
+
// bucket (some carried, some fresh) is left UNLABELED: a Math.max
|
|
1145
|
+
// suffix on the finished multi-line advisory would attach the carry to
|
|
1146
|
+
// a file that was not carried.
|
|
1147
|
+
const withCarryLabel = (advisory, runs) => {
|
|
1148
|
+
if (advisory === undefined)
|
|
1149
|
+
return undefined;
|
|
1150
|
+
const carried = runs.filter((r) => (r.carriedTurns ?? 0) > 0);
|
|
1151
|
+
if (carried.length === 0 || carried.length !== runs.length) {
|
|
1152
|
+
return advisory;
|
|
1153
|
+
}
|
|
1154
|
+
// #3168 F13: the bucket's age is the OLDEST carried observation, and
|
|
1155
|
+
// only when EVERY carried run carries one. A `Math.min` sentinel
|
|
1156
|
+
// (`?? Number.MAX_SAFE_INTEGER`) silently ignored the unstamped runs
|
|
1157
|
+
// and stated a confident age for a bucket that contains an unaged
|
|
1158
|
+
// one; one missing stamp collapses the age half to the helper's
|
|
1159
|
+
// neutral "scan age unknown" wording instead.
|
|
1160
|
+
const stamps = carried.map((r) => r.observedAt);
|
|
1161
|
+
const observedAt = stamps.every((s) => s !== undefined)
|
|
1162
|
+
? Math.min(...stamps)
|
|
1163
|
+
: undefined;
|
|
1164
|
+
const suffix = cascadeCarrySuffix(carried[0]?.carriedTurns, observedAt);
|
|
1165
|
+
if (!suffix)
|
|
1166
|
+
return advisory;
|
|
1167
|
+
labeledAdvisories += 1;
|
|
1168
|
+
// #3168 F11: newline, as the blocker path does above. A space join
|
|
1169
|
+
// welded the suffix onto the LAST bullet of a multi-bullet advisory,
|
|
1170
|
+
// so the carry label read as a property of that one file.
|
|
1171
|
+
return `${advisory}\n${suffix}`;
|
|
1172
|
+
};
|
|
1173
|
+
const graphAdvisory = withCarryLabel(buildAdvisory(graphRuns, {
|
|
1085
1174
|
lead: (fileCount, reasons) => `Cascade could not compute downstream impact for ${fileCount} edited file(s) this turn — ` +
|
|
1086
1175
|
`the review graph was unavailable (${reasons}), so their dependents were not ` +
|
|
1087
1176
|
`cascade-checked and a clean cascade result does not cover them.`,
|
|
1088
1177
|
fallbackDetail: (r) => r.indeterminate?.reason === "missing_node"
|
|
1089
1178
|
? "changed file not in the review graph"
|
|
1090
1179
|
: "review graph unavailable",
|
|
1091
|
-
});
|
|
1180
|
+
}), graphRuns);
|
|
1092
1181
|
// @delivery-surface: runtime-turn:cascade-coverage-advisory
|
|
1093
1182
|
if (graphAdvisory)
|
|
1094
1183
|
advisoryParts.push(graphAdvisory);
|
|
1095
|
-
const bindingAdvisory = buildAdvisory(bindingRuns, {
|
|
1184
|
+
const bindingAdvisory = withCarryLabel(buildAdvisory(bindingRuns, {
|
|
1096
1185
|
lead: (fileCount, reasons) => `Cascade identified dependents for ${fileCount} edited file(s) this turn, but their ` +
|
|
1097
1186
|
`diagnostics could not be freshly confirmed (${reasons}) and were withheld — a clean ` +
|
|
1098
1187
|
`cascade result does not cover them.`,
|
|
1099
1188
|
fallbackDetail: () => "cascade diagnostics withheld (binding rejected)",
|
|
1100
|
-
});
|
|
1189
|
+
}), bindingRuns);
|
|
1101
1190
|
// @delivery-surface: runtime-turn:cascade-coverage-advisory
|
|
1102
1191
|
if (bindingAdvisory)
|
|
1103
1192
|
advisoryParts.push(bindingAdvisory);
|
|
1104
|
-
const budgetAdvisory = buildAdvisory(budgetRuns, {
|
|
1193
|
+
const budgetAdvisory = withCarryLabel(buildAdvisory(budgetRuns, {
|
|
1105
1194
|
lead: (fileCount, reasons) => `Cascade checked the selected neighbors for ${fileCount} edited file(s) this turn, ` +
|
|
1106
1195
|
`but some eligible dependents were not checked because the cascade budget ` +
|
|
1107
1196
|
`was exhausted (${reasons}); a clean cascade result does not cover them.`,
|
|
@@ -1114,7 +1203,7 @@ export async function handleTurnEnd(deps) {
|
|
|
1114
1203
|
? `${detail}; transitive expansion was capped before all eligible dependents were enumerated`
|
|
1115
1204
|
: detail;
|
|
1116
1205
|
},
|
|
1117
|
-
});
|
|
1206
|
+
}), budgetRuns);
|
|
1118
1207
|
// @delivery-surface: runtime-turn:cascade-coverage-advisory
|
|
1119
1208
|
if (budgetAdvisory)
|
|
1120
1209
|
advisoryParts.push(budgetAdvisory);
|
|
@@ -1152,6 +1241,14 @@ export async function handleTurnEnd(deps) {
|
|
|
1152
1241
|
},
|
|
1153
1242
|
});
|
|
1154
1243
|
}
|
|
1244
|
+
// Fix B (#3168 F6): the success-path record — see the counters above.
|
|
1245
|
+
if (carriedRunsRendered > 0 || labeledAdvisories > 0) {
|
|
1246
|
+
logCascade({
|
|
1247
|
+
phase: "cascade_carry_rendered",
|
|
1248
|
+
filePath: cascadeLogFilePath,
|
|
1249
|
+
metadata: { carriedRunsRendered, labeledAdvisories },
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1155
1252
|
const cascadeSkipped = {
|
|
1156
1253
|
blockers: 0,
|
|
1157
1254
|
non_code: 0,
|
|
@@ -1220,6 +1317,9 @@ export async function handleTurnEnd(deps) {
|
|
|
1220
1317
|
totalIssues: knipResult.issues.length,
|
|
1221
1318
|
newIssues: 0,
|
|
1222
1319
|
blockerIssues: 0,
|
|
1320
|
+
// #3248: bounded per-turn, on the row this lane already writes —
|
|
1321
|
+
// never one record per finding.
|
|
1322
|
+
dispositionSuppressed: 0,
|
|
1223
1323
|
...(!knipResult.success && { reason: knipResult.summary }),
|
|
1224
1324
|
...(knipResult.failureKind && { failureKind: knipResult.failureKind }),
|
|
1225
1325
|
...(knipWouldPoison && { cacheKept: true }),
|
|
@@ -1243,7 +1343,30 @@ export async function handleTurnEnd(deps) {
|
|
|
1243
1343
|
projectDiagnosticsDelta.push(...knipIssuesToProjectDiagnostics(cwd, newIssues));
|
|
1244
1344
|
projectDiagnosticsSources.add("knip");
|
|
1245
1345
|
}
|
|
1246
|
-
|
|
1346
|
+
// #3248: what the agent READS goes through the same stored-
|
|
1347
|
+
// disposition filter every other findings surface applies, keyed off
|
|
1348
|
+
// knip's OWN `ProjectDiagnostic` adapter — the identity
|
|
1349
|
+
// `lens_diagnostics` surfaces and `lens_diagnostic_mark` anchors
|
|
1350
|
+
// against — so a marked finding stops re-reporting here. The
|
|
1351
|
+
// `projectDiagnosticsDelta` push above deliberately keeps the
|
|
1352
|
+
// UNFILTERED set: that record is what the scan found, and its reader
|
|
1353
|
+
// (`lens_diagnostics`) applies dispositions on read, so filtering it
|
|
1354
|
+
// here would apply the same policy twice on one lane.
|
|
1355
|
+
// Paired through `flatMap` rather than indexing the adapter's array:
|
|
1356
|
+
// `knipIssuesToProjectDiagnostics` is a straight `issues.map(...)`
|
|
1357
|
+
// (one diagnostic per issue, never empty), so this keeps the pairing
|
|
1358
|
+
// total while staying honest under `noUncheckedIndexedAccess`.
|
|
1359
|
+
const knipPaired = newIssues.flatMap((issue) => knipIssuesToProjectDiagnostics(cwd, [issue]).map((diagnostic) => ({
|
|
1360
|
+
issue,
|
|
1361
|
+
diagnostic,
|
|
1362
|
+
})));
|
|
1363
|
+
const knipFiltered = filterFindingsByDisposition(knipPaired, cwd, (pair) => pair.diagnostic);
|
|
1364
|
+
const knipDeliverable = {
|
|
1365
|
+
kept: knipFiltered.kept.map((pair) => pair.issue),
|
|
1366
|
+
suppressed: knipFiltered.suppressed,
|
|
1367
|
+
};
|
|
1368
|
+
knipMeta.dispositionSuppressed = knipDeliverable.suppressed;
|
|
1369
|
+
const blockerIssues = knipDeliverable.kept.filter((i) => i.type === "unlisted" || i.type === "bin");
|
|
1247
1370
|
knipMeta.blockerIssues = blockerIssues.length;
|
|
1248
1371
|
if (blockerIssues.length > 0) {
|
|
1249
1372
|
let report = "🔴 New unresolved imports/deps in modified code (Knip):\n";
|
|
@@ -1270,7 +1393,7 @@ export async function handleTurnEnd(deps) {
|
|
|
1270
1393
|
// drown the blockers and burn context every turn); it's available
|
|
1271
1394
|
// on demand via lens_diagnostics. The delta also feeds the session-slop
|
|
1272
1395
|
// record (`projectDiagnosticsDelta`) above.
|
|
1273
|
-
const unusedExportDelta =
|
|
1396
|
+
const unusedExportDelta = knipDeliverable.kept.filter((i) => i.type === "export" || i.type === "enumMember");
|
|
1274
1397
|
if (unusedExportDelta.length > 0) {
|
|
1275
1398
|
let report = "⚠️ Newly unused exports in files you edited — check if callers need updating (Knip):\n";
|
|
1276
1399
|
for (const issue of unusedExportDelta.slice(0, 5)) {
|
|
@@ -1318,6 +1441,8 @@ export async function handleTurnEnd(deps) {
|
|
|
1318
1441
|
let modifiedSet = null;
|
|
1319
1442
|
const modifiedFiles = () => (modifiedSet ??= new Set(files.map((f) => resolveRunnerPath(cwd, f))));
|
|
1320
1443
|
let newIssueTotal = 0;
|
|
1444
|
+
/** #3248: bounded per-turn on this lane's own row, never per finding. */
|
|
1445
|
+
let deadCodeDispositionSuppressed = 0;
|
|
1321
1446
|
const reasons = [];
|
|
1322
1447
|
// A malformed client or deps object must never abort turn_end. Before the
|
|
1323
1448
|
// per-turn delta this block only read a cache; now it iterates and awaits,
|
|
@@ -1403,8 +1528,21 @@ export async function handleTurnEnd(deps) {
|
|
|
1403
1528
|
newIssueTotal += newIssues.length;
|
|
1404
1529
|
projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
|
|
1405
1530
|
projectDiagnosticsSources.add("dead-code");
|
|
1531
|
+
// #3248: the rendered advisory takes the stored-disposition
|
|
1532
|
+
// filter, keyed off this lane's OWN adapter. The delta record
|
|
1533
|
+
// above keeps the unfiltered set — its reader applies the policy
|
|
1534
|
+
// on read, so filtering both would double-apply on one lane.
|
|
1535
|
+
const deadCodeDeliverable = filterFindingsByDisposition(newIssues, cwd, (issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language));
|
|
1536
|
+
deadCodeDispositionSuppressed += deadCodeDeliverable.suppressed;
|
|
1537
|
+
// Every finding on this scan was marked: a PUSH surface stays
|
|
1538
|
+
// silent rather than re-announcing that the mark is working; the
|
|
1539
|
+
// count rides this lane's bounded per-turn row.
|
|
1540
|
+
if (deadCodeDeliverable.kept.length === 0) {
|
|
1541
|
+
reasons.push(`${client.id}:all_disposed`);
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1406
1544
|
// @delivery-surface: runtime-turn:dead-code-advisory
|
|
1407
|
-
advisoryParts.push(formatDeadCodeDelta(
|
|
1545
|
+
advisoryParts.push(formatDeadCodeDelta(deadCodeDeliverable.kept, result.language));
|
|
1408
1546
|
}
|
|
1409
1547
|
catch (err) {
|
|
1410
1548
|
dbg(`turn_end: dead-code(${client.id}) failed: ${err}`);
|
|
@@ -1417,6 +1555,7 @@ export async function handleTurnEnd(deps) {
|
|
|
1417
1555
|
reasons.push("block_threw");
|
|
1418
1556
|
}
|
|
1419
1557
|
deadCodeMeta.newIssues = newIssueTotal;
|
|
1558
|
+
deadCodeMeta.dispositionSuppressed = deadCodeDispositionSuppressed;
|
|
1420
1559
|
if (reasons.length > 0)
|
|
1421
1560
|
deadCodeMeta.reason = reasons.join(",");
|
|
1422
1561
|
}
|
|
@@ -1444,212 +1583,167 @@ export async function handleTurnEnd(deps) {
|
|
|
1444
1583
|
dispositionSuppressedByLane[lane] =
|
|
1445
1584
|
(dispositionSuppressedByLane[lane] ?? 0) + count;
|
|
1446
1585
|
}
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1586
|
+
// #1892: ONE read per scanner store per turn_end. The inline lanes got this
|
|
1587
|
+
// for free by reading each cache into a local; now that a lane reads its own
|
|
1588
|
+
// stores (`TurnEndLaneContext.readScannerCache`), the composer and the lanes
|
|
1589
|
+
// must still share one envelope per store — two reads of one store inside
|
|
1590
|
+
// one delivery is the parallel-store shape this umbrella exists to kill, and
|
|
1591
|
+
// the cache's TTL boundary can fall between them, so the secrets tier and
|
|
1592
|
+
// the CVE tier would disagree about the trivy store they both read.
|
|
1593
|
+
//
|
|
1594
|
+
// #3274: the memo holds the PROMISE of each store's envelope, not the
|
|
1595
|
+
// envelope. The read itself moved to `CacheManager.readCacheAsync`, which
|
|
1596
|
+
// suspends on `fs.promises`, so `bounded()` finally has something to bound:
|
|
1597
|
+
// the sync `readCache` completed during ARGUMENT EVALUATION, before
|
|
1598
|
+
// `bounded()` was ever handed a promise, and a wrapper around it registered
|
|
1599
|
+
// a turn_end budget that could never be spent (probed on #3274 with an
|
|
1600
|
+
// already-aborted signal and `ms: 0` — bounded returned undefined and the
|
|
1601
|
+
// read had already parsed). Memoizing the promise is also what keeps the
|
|
1602
|
+
// one-envelope-per-store rule under concurrency: two lanes that await the
|
|
1603
|
+
// same store share ONE read and ONE TTL boundary even when neither has
|
|
1604
|
+
// settled yet, which a value memo could not do.
|
|
1605
|
+
//
|
|
1606
|
+
// An abandoned read yields null, never a throw and never a late envelope:
|
|
1607
|
+
// `bounded()` resolves `undefined` on the budget or the hook's signal and
|
|
1608
|
+
// records ONE `hook-await-exceeded` row per (hook, label) for the deadline
|
|
1609
|
+
// arm, and the memoized promise is already settled by the time the
|
|
1610
|
+
// abandoned read resolves, so nothing mutates after the delivery composed.
|
|
1611
|
+
// A lane that receives null behaves exactly as it does for a cold cache.
|
|
1612
|
+
const scannerCacheReads = new Map();
|
|
1613
|
+
// Stores this delivery composed WITHOUT because a bound fired, in read
|
|
1614
|
+
// order. `bounded()` records the AWAIT's own row (`hook-await-exceeded`),
|
|
1615
|
+
// but only for the deadline arm and only about the await; the agent-facing
|
|
1616
|
+
// consequence — the secrets tier went out with no gitleaks store behind it,
|
|
1617
|
+
// which looks exactly like a clean scan (AGENTS.md defect shape 10) — has no
|
|
1618
|
+
// record otherwise, on either arm. One row per DELIVERY, below, never one
|
|
1619
|
+
// per store and never on a healthy turn.
|
|
1620
|
+
const scannerStoresUnread = [];
|
|
1621
|
+
function readScannerCache(scanner) {
|
|
1622
|
+
let pending = scannerCacheReads.get(scanner);
|
|
1623
|
+
if (pending === undefined) {
|
|
1624
|
+
// An already-aborted turn does not start the read at all. `bounded()`
|
|
1625
|
+
// abandons the await but cannot cancel work already dispatched, so
|
|
1626
|
+
// without this the cancelled turn still pays six file reads whose
|
|
1627
|
+
// result nothing can use. Checked AFTER the memo lookup: a signal that
|
|
1628
|
+
// fires mid-delivery must not give the second lane a different answer
|
|
1629
|
+
// from the first (that is the TTL-boundary split this memo exists for).
|
|
1630
|
+
// It records nothing, on purpose: an already-cancelled turn is Escape,
|
|
1631
|
+
// which `bounded()` also keeps off the ledger — the row below is for a
|
|
1632
|
+
// delivery that went out degraded, not one the user stopped.
|
|
1633
|
+
pending = deps.signal?.aborted
|
|
1634
|
+
? Promise.resolve(null)
|
|
1635
|
+
: bounded(cacheManager.readCacheAsync(scanner, cwd), {
|
|
1636
|
+
ms: HOOK_WALL_BUDGET_MS.turn_end,
|
|
1637
|
+
signal: deps.signal,
|
|
1638
|
+
hook: "turn_end",
|
|
1639
|
+
label: `readScannerCache:${scanner}`,
|
|
1640
|
+
}).then((entry) => {
|
|
1641
|
+
if (entry === undefined)
|
|
1642
|
+
scannerStoresUnread.push(scanner);
|
|
1643
|
+
return entry ?? null;
|
|
1644
|
+
});
|
|
1645
|
+
scannerCacheReads.set(scanner, pending);
|
|
1646
|
+
}
|
|
1647
|
+
return pending;
|
|
1648
|
+
}
|
|
1649
|
+
const laneCtx = {
|
|
1650
|
+
cwd,
|
|
1651
|
+
signal: deps.signal,
|
|
1652
|
+
readScannerCache,
|
|
1653
|
+
peekActionableWarnings: () => runtime.peekActionableWarnings(),
|
|
1654
|
+
};
|
|
1655
|
+
// govulncheck — the session_start-cached Go CVE store, delivered as ONE
|
|
1656
|
+
// advisory tier by the govulncheck LANE
|
|
1657
|
+
// (`clients/turn-end/lanes/govulncheck.ts`), which owns every rule this block
|
|
1658
|
+
// used to state inline: the `onMissing: "demote"` freshness declaration and
|
|
1659
|
+
// its first-filename-frame `citedPath` (#1622 H1), the disposition anchor
|
|
1660
|
+
// over BOTH freshness arms (#1694 F1), the stale-line withholding and marker,
|
|
1661
|
+
// the module/package fallback, the fix hint and the display cap. No per-turn
|
|
1662
|
+
// re-run in this slice; the cache refreshes at next session_start. Like every
|
|
1663
|
+
// lane it does NOT gate itself — the freshness pass below is shared.
|
|
1664
|
+
const govSources = await govulncheckLane.collect(laneCtx);
|
|
1665
|
+
const trivyCacheEntry = await readScannerCache("trivy");
|
|
1666
|
+
// The secrets lane (`clients/turn-end/lanes/secrets.ts`) reads the gitleaks
|
|
1667
|
+
// and trivy stores, classifies, and states the freshness policy its rows
|
|
1668
|
+
// need; every rendering and disposition rule for the two secrets tiers lives
|
|
1669
|
+
// there. It does NOT gate itself either.
|
|
1670
|
+
const secretsSources = await secretsLane.collect(laneCtx);
|
|
1671
|
+
// #1892: ONE freshness pass for the three cached scanner stores that cite a
|
|
1672
|
+
// file. Each store keeps its own `scannedAt` and its own `onMissing` — the
|
|
1673
|
+
// gate carries source identity, so gitleaks' older scan cannot demote a
|
|
1674
|
+
// trivy secret its newer scan covers, and govulncheck's `demote` verdict for
|
|
1675
|
+
// a deleted path cannot reach gitleaks, which must drop it. What IS shared
|
|
1676
|
+
// is the filesystem: one `statSync` per unique cited path per delivery, one
|
|
1677
|
+
// stat budget, and one bounded drop/demote record instead of up to six.
|
|
1678
|
+
//
|
|
1679
|
+
// #1461 slice 1 (#1460) on gitleaks: the cache is TTL-only, so a finding for
|
|
1680
|
+
// a file deleted after the scan was served as a 🔴 blocker for the rest of
|
|
1681
|
+
// the 30-minute window — 119 of 126 findings in pi-lens's own cache. This
|
|
1682
|
+
// read is the single agent-facing consumer of that store (session_start's
|
|
1683
|
+
// read only decides whether to re-scan; the project-diagnostics path
|
|
1684
|
+
// re-scans fresh and reconciles at load), so the drop belongs here, before
|
|
1685
|
+
// the findings enter the shared secret pipeline. #1622 extends the gate from
|
|
1686
|
+
// existence to freshness, and adds trivy secrets — the sibling store with
|
|
1687
|
+
// the identical shape. A cited file edited after the scan keeps its finding
|
|
1688
|
+
// but loses its line number: the credential may still be there, just not
|
|
1689
|
+
// where the snapshot says. Dropping instead would let any edit — malicious
|
|
1690
|
+
// or accidental — mute a real secret.
|
|
1691
|
+
if (scannerStoresUnread.length > 0) {
|
|
1692
|
+
logLatency({
|
|
1693
|
+
type: "phase",
|
|
1694
|
+
toolName: "turn_end",
|
|
1695
|
+
filePath: cwd,
|
|
1696
|
+
phase: "scanner_cache_read_abandoned",
|
|
1697
|
+
durationMs: 0,
|
|
1698
|
+
metadata: {
|
|
1699
|
+
stores: scannerStoresUnread.join("+"),
|
|
1700
|
+
aborted: deps.signal?.aborted === true,
|
|
1701
|
+
},
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
const scannerGates = gateFindingsByPathFreshness({
|
|
1462
1705
|
cwd,
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1706
|
+
sources: {
|
|
1707
|
+
...govSources,
|
|
1708
|
+
...secretsSources,
|
|
1709
|
+
},
|
|
1466
1710
|
});
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
// never fall back to a raw-cache-length check (that would print the header
|
|
1474
|
-
// with zero rows beneath it whenever either filter empties the list).
|
|
1475
|
-
const govFiltered = filterFindingsByDisposition([...govGate.live, ...govGate.stale], cwd, (f) => govulncheckFindingToProjectDiagnostic(cwd, f));
|
|
1476
|
-
recordDispositionSuppressed("govulncheck", govFiltered.suppressed);
|
|
1477
|
-
const govFindings = govFiltered.kept;
|
|
1478
|
-
if (govFindings.length) {
|
|
1479
|
-
const findings = govFindings.slice(0, 5);
|
|
1480
|
-
let report = "🛡️ Go CVEs reachable from this code (govulncheck) — upgrade where possible:\n";
|
|
1481
|
-
for (const f of findings) {
|
|
1482
|
-
const callSite = f.trace.find((t) => t.filename);
|
|
1483
|
-
const stale = govStale.has(f);
|
|
1484
|
-
const where = callSite?.filename
|
|
1485
|
-
? `${toRunnerDisplayPath(cwd, callSite.filename)}${!stale && callSite.line ? `:${callSite.line}` : ""}${stale ? ` ${STALE_LINE_MARKER}` : ""}`
|
|
1486
|
-
: (f.module ?? f.packageName ?? "(module)");
|
|
1487
|
-
const fix = f.fixedVersion
|
|
1488
|
-
? ` — upgrade to ${f.fixedVersion} or later`
|
|
1489
|
-
: " — no fix yet, track upstream";
|
|
1490
|
-
report += ` ${f.osv} (${where})${fix}\n`;
|
|
1491
|
-
}
|
|
1492
|
-
if (govFindings.length > findings.length) {
|
|
1493
|
-
report += ` … and ${govFindings.length - findings.length} more\n`;
|
|
1494
|
-
}
|
|
1495
|
-
// @delivery-surface: runtime-turn:govulncheck-advisory
|
|
1496
|
-
advisoryParts.push(report);
|
|
1711
|
+
const govGate = scannerGates.govulncheck;
|
|
1712
|
+
const gitleaksGate = scannerGates.gitleaks;
|
|
1713
|
+
const trivySecretsGate = scannerGates["trivy-secrets"];
|
|
1714
|
+
const govDelivery = govulncheckLane.render(govulncheckLane.gate({ govulncheck: govGate }, laneCtx), laneCtx);
|
|
1715
|
+
for (const [store, count] of Object.entries(govDelivery.dispositionSuppressed ?? {})) {
|
|
1716
|
+
recordDispositionSuppressed(store, count);
|
|
1497
1717
|
}
|
|
1498
|
-
|
|
1718
|
+
// @delivery-surface: runtime-turn:govulncheck-advisory
|
|
1719
|
+
advisoryParts.push(...(govDelivery.advisoryParts ?? []));
|
|
1499
1720
|
// Secrets — UNIFIED surfacing (#131 Mode 3). gitleaks, trivy secret, and the
|
|
1500
1721
|
// ast-grep hardcoded-secret rules can each flag the SAME line with different
|
|
1501
1722
|
// rule ids, which the rule-keyed diagnostic dedup can't collapse. Collapse by
|
|
1502
1723
|
// location so a committed/hardcoded secret is reported ONCE (with combined
|
|
1503
1724
|
// provenance) — a blocker, since credentials need rotation before merge.
|
|
1504
|
-
const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
|
|
1505
|
-
const trivySecretsData = trivyCacheEntry?.data;
|
|
1506
|
-
// Gitleaks deliberately scans gitignored local files and nested repositories
|
|
1507
|
-
// so an explicit security audit can still inspect them. The adapter is the
|
|
1508
|
-
// source of truth for whether a finding belongs in a blocking delivery lane;
|
|
1509
|
-
// filter here before freshness handling so demoted findings cannot leak into
|
|
1510
|
-
// either the blocker or stale-secret turn context.
|
|
1511
|
-
const boundedClassification = await bounded(classifyAndFilterFindings(gitleaksData?.findings ?? [], cwd), {
|
|
1512
|
-
ms: HOOK_WALL_BUDGET_MS.turn_end,
|
|
1513
|
-
signal: deps.signal,
|
|
1514
|
-
hook: "turn_end",
|
|
1515
|
-
label: "classifyAndFilterFindings",
|
|
1516
|
-
});
|
|
1517
|
-
const classifiedGitleaksFindings = boundedClassification ?? gitleaksData?.findings ?? [];
|
|
1518
|
-
if (boundedClassification === undefined) {
|
|
1519
|
-
recordDegradationOnce({
|
|
1520
|
-
kind: "gitleaks_classification_timeout",
|
|
1521
|
-
subject: cwd,
|
|
1522
|
-
reason: "gitleaks classification exceeded the turn_end budget; retained raw findings to fail open",
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1525
|
-
const blockingGitleaksFindings = classifiedGitleaksFindings.filter((finding) => gitleaksFindingToProjectDiagnostic(cwd, finding).semantic === "blocking");
|
|
1526
|
-
// #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
|
|
1527
|
-
// file deleted after the scan is still served as a 🔴 blocker for the rest
|
|
1528
|
-
// of the 30-minute window — the live case, and 119 of 126 findings in
|
|
1529
|
-
// pi-lens's own cache. This read is the single agent-facing consumer of that
|
|
1530
|
-
// store (session_start's read only decides whether to re-scan; the
|
|
1531
|
-
// project-diagnostics path re-scans fresh and reconciles at load), so the
|
|
1532
|
-
// drop belongs here, before the findings enter the shared secret pipeline.
|
|
1533
|
-
// #1622 extends that gate from existence to freshness, and adds trivy
|
|
1534
|
-
// secrets — the sibling store with the identical shape. A cited file edited
|
|
1535
|
-
// after the scan keeps its finding but loses its line number: the credential
|
|
1536
|
-
// may still be there, just not where the snapshot says. Dropping instead
|
|
1537
|
-
// would let any edit — malicious or accidental — mute a real secret.
|
|
1538
|
-
const gitleaksGate = gateFindingsByPathFreshness({
|
|
1539
|
-
store: "gitleaks",
|
|
1540
|
-
findings: blockingGitleaksFindings,
|
|
1541
|
-
cwd,
|
|
1542
|
-
scannedAt: gitleaksData?.scannedAt,
|
|
1543
|
-
citedPath: (finding) => finding.file,
|
|
1544
|
-
});
|
|
1545
|
-
const trivySecretsGate = gateFindingsByPathFreshness({
|
|
1546
|
-
store: "trivy-secrets",
|
|
1547
|
-
findings: trivySecretsData?.secrets ?? [],
|
|
1548
|
-
cwd,
|
|
1549
|
-
scannedAt: trivySecretsData?.scannedAt,
|
|
1550
|
-
citedPath: (finding) => finding.file,
|
|
1551
|
-
});
|
|
1552
|
-
// #1617: THE bug this issue exists for — gitleaks findings never passed
|
|
1553
|
-
// through `applyDispositions`, so an agent-marked false-positive/won't-fix
|
|
1554
|
-
// re-reported as a 🔴 STOP blocker on every turn. Filter through the SAME
|
|
1555
|
-
// `gitleaksFindingToProjectDiagnostic` identity `lens_diagnostics
|
|
1556
|
-
// mode=full` surfaces (tool="gitleaks", rule="gitleaks:<ruleId>", the
|
|
1557
|
-
// exact "Potential secret: …" message) so a mark made against what the
|
|
1558
|
-
// agent was shown is honored here too.
|
|
1559
|
-
//
|
|
1560
|
-
// #1625 review round: filtered AFTER the #1622 freshness gate above — the
|
|
1561
|
-
// anchor is derived from each finding's post-demotion identity, never the
|
|
1562
|
-
// raw pre-gate cache. Applied to BOTH `gitleaksGate.live` AND
|
|
1563
|
-
// `gitleaksGate.stale`: `staleSecretEntries` below derives from the stale
|
|
1564
|
-
// arm, and an fp-marked finding that later goes stale must not reappear
|
|
1565
|
-
// there — a suppression escape (and a double count against
|
|
1566
|
-
// `dispositionSuppressedTotal`) that a live-only filter would have missed.
|
|
1567
|
-
//
|
|
1568
|
-
// #1628: trivy-secret findings get the SAME treatment, now that
|
|
1569
|
-
// `trivySecretFindingToProjectDiagnostic` (project-diagnostics/runner-
|
|
1570
|
-
// adapters/trivy.ts) gives them a `lens_diagnostics`-surfaced identity
|
|
1571
|
-
// (tool="trivy", rule="trivy-secret:<ruleId>") to anchor a mark against —
|
|
1572
|
-
// same pattern as gitleaks above, applied to both the live and stale arms
|
|
1573
|
-
// for the same reason.
|
|
1574
1725
|
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
const
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
]);
|
|
1591
|
-
// Demoted secrets are addressed by FILE, never by line — the line is the one
|
|
1592
|
-
// field the edit invalidated. Rule id and source survive it and must be
|
|
1593
|
-
// carried through (review round M1): an agent triages an `aws-access-token`
|
|
1594
|
-
// differently from a low-confidence `generic-api-key`, and cannot do that
|
|
1595
|
-
// from a bare path. Deduped on file+rule+source so a file with twenty stale
|
|
1596
|
-
// hits of one rule is named once.
|
|
1597
|
-
const staleSecretEntries = [
|
|
1598
|
-
...gitleaksStaleFiltered.kept.map((f) => ({
|
|
1599
|
-
file: toRunnerDisplayPath(cwd, f.file),
|
|
1600
|
-
rule: f.ruleId,
|
|
1601
|
-
source: "gitleaks",
|
|
1602
|
-
})),
|
|
1603
|
-
...trivySecretsStaleFiltered.kept.map((f) => ({
|
|
1604
|
-
file: toRunnerDisplayPath(cwd, f.file),
|
|
1605
|
-
rule: f.ruleId,
|
|
1606
|
-
source: "trivy",
|
|
1607
|
-
})),
|
|
1608
|
-
];
|
|
1609
|
-
const staleSecrets = [
|
|
1610
|
-
...new Map(staleSecretEntries.map((e) => [`${e.file}|${e.rule}|${e.source}`, e])).values(),
|
|
1611
|
-
];
|
|
1612
|
-
// Locations already surfaced as session-scan secret blockers — used to enrich
|
|
1613
|
-
// provenance where ast-grep agrees and to suppress the duplicate ast-grep copy
|
|
1614
|
-
// from the actionable-warnings advisory below.
|
|
1615
|
-
const secretBlockedLocations = new Set(sessionSecrets.map((f) => secretLocationKey(f.file, f.line)));
|
|
1616
|
-
if (sessionSecrets.length) {
|
|
1617
|
-
// Fold in ast-grep provenance ONLY where it coincides with a session
|
|
1618
|
-
// secret — don't promote ast-grep-only findings out of their advisory tier.
|
|
1619
|
-
const enriched = dedupeSecretFindings([
|
|
1620
|
-
...sessionSecrets,
|
|
1621
|
-
...fromAstGrepWarnings(astSecretWarnings).filter((a) => secretBlockedLocations.has(secretLocationKey(a.file, a.line))),
|
|
1622
|
-
]);
|
|
1623
|
-
const shown = enriched.slice(0, 5);
|
|
1624
|
-
let report = "🔴 STOP — hardcoded secrets detected. Rotate the credentials and remove them from source:\n";
|
|
1625
|
-
for (const f of shown) {
|
|
1626
|
-
const where = `${toRunnerDisplayPath(cwd, f.file)}:${f.line}`;
|
|
1627
|
-
report += ` ${where} — ${f.rule} [${f.sources.join(" + ")}]${f.description ? `: ${f.description}` : ""}\n`;
|
|
1628
|
-
}
|
|
1629
|
-
if (enriched.length > shown.length) {
|
|
1630
|
-
report += ` … and ${enriched.length - shown.length} more\n`;
|
|
1631
|
-
}
|
|
1632
|
-
// @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
|
|
1633
|
-
blockerParts.push(report);
|
|
1634
|
-
}
|
|
1635
|
-
if (staleSecrets.length) {
|
|
1636
|
-
// Its OWN tier, never `advisoryParts` (review round M2). The advisory tier
|
|
1637
|
-
// is labelled "no action required this turn", which would sit directly
|
|
1638
|
-
// above copy telling the agent to re-scan — a section that contradicts its
|
|
1639
|
-
// own heading. This preamble is imperative because the action is real: the
|
|
1640
|
-
// finding is unverified, not dismissed.
|
|
1641
|
-
const shown = staleSecrets.slice(0, 5);
|
|
1642
|
-
let report = `🔑 ACTION NEEDED — secrets were flagged in files that changed after the scan. ${STALE_LINE_MARKER}\n` +
|
|
1643
|
-
"The cached line numbers are no longer trustworthy, so they are withheld. Re-run a secrets scan to confirm or clear these:\n";
|
|
1644
|
-
for (const entry of shown) {
|
|
1645
|
-
report += ` ${entry.file} — ${entry.rule} [${entry.source}]\n`;
|
|
1646
|
-
}
|
|
1647
|
-
if (staleSecrets.length > shown.length) {
|
|
1648
|
-
report += ` … and ${staleSecrets.length - shown.length} more\n`;
|
|
1649
|
-
}
|
|
1650
|
-
// @delivery-surface: runtime-turn:stale-secrets-tier
|
|
1651
|
-
staleSecretParts.push(report);
|
|
1726
|
+
// #1892: both tiers are rendered by the secrets LANE
|
|
1727
|
+
// (`clients/turn-end/lanes/secrets.ts`), which owns every rule this block
|
|
1728
|
+
// used to state inline — the two stores' disposition anchors over BOTH
|
|
1729
|
+
// freshness arms (#1617/#1625/#1628), the location dedupe and ast-grep
|
|
1730
|
+
// provenance enrichment, the demoted tier's file+rule+source identity
|
|
1731
|
+
// (#1622 M1) and its own-tier placement (#1622 M2). The composer keeps only
|
|
1732
|
+
// what is not one lane's rule: the gated arms it hands over, the order the
|
|
1733
|
+
// tiers are pushed in, and the per-lane suppression counts that fold into
|
|
1734
|
+
// the one notice below.
|
|
1735
|
+
const secretsDelivery = secretsLane.render(secretsLane.gate({
|
|
1736
|
+
gitleaks: gitleaksGate,
|
|
1737
|
+
"trivy-secrets": trivySecretsGate,
|
|
1738
|
+
}, laneCtx), laneCtx);
|
|
1739
|
+
for (const [store, count] of Object.entries(secretsDelivery.dispositionSuppressed ?? {})) {
|
|
1740
|
+
recordDispositionSuppressed(store, count);
|
|
1652
1741
|
}
|
|
1742
|
+
const secretBlockedLocations = secretsDelivery.deliveredLocationKeys ?? new Set();
|
|
1743
|
+
// @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
|
|
1744
|
+
blockerParts.push(...(secretsDelivery.blockerParts ?? []));
|
|
1745
|
+
// @delivery-surface: runtime-turn:stale-secrets-tier
|
|
1746
|
+
staleSecretParts.push(...(secretsDelivery.staleSecretParts ?? []));
|
|
1653
1747
|
// trivy — surface session_start-cached dependency CVEs (#131, Phase 1).
|
|
1654
1748
|
// CRITICAL is a blocker (a known-exploitable CVE in a shipped dep is real
|
|
1655
1749
|
// production risk); HIGH/MEDIUM/LOW are advisory. The agent gets the upgrade
|
|
@@ -1759,11 +1853,6 @@ export async function handleTurnEnd(deps) {
|
|
|
1759
1853
|
const depResult = depResults.get(absPath);
|
|
1760
1854
|
if (!depResult)
|
|
1761
1855
|
continue;
|
|
1762
|
-
if (depResult.localSkips && depResult.localSkips > 0) {
|
|
1763
|
-
// Not silent: a skipped LOCAL import means madge couldn't resolve
|
|
1764
|
-
// it into the graph, so a cycle through it would be missed.
|
|
1765
|
-
dbg(`turn_end: madge skipped ${depResult.localSkips} local file(s) resolving ${file} — possible silent cycle-miss`);
|
|
1766
|
-
}
|
|
1767
1856
|
if (depResult.hasCircular && depResult.circular.length > 0) {
|
|
1768
1857
|
// Whole-project circular deps are surfaced in lens_diagnostics via the
|
|
1769
1858
|
// session-start `madge` cache + extractor; this per-file turn-end pass
|
|
@@ -2591,10 +2680,13 @@ export async function handleTurnEnd(deps) {
|
|
|
2591
2680
|
"the affected files may have unreported callers.");
|
|
2592
2681
|
}
|
|
2593
2682
|
else {
|
|
2683
|
+
const callGraphStart = Date.now();
|
|
2594
2684
|
try {
|
|
2595
2685
|
const { impact, formatImpact, parseSymbolKey } = await import("./call-graph.js");
|
|
2596
2686
|
const { callGraphImpactToProjectDiagnostics } = await import("./project-diagnostics/runner-adapters/call-graph-impact.js");
|
|
2597
2687
|
const impactLines = [];
|
|
2688
|
+
/** #3248: bounded per-turn, never per finding. */
|
|
2689
|
+
let callGraphDispositionSuppressed = 0;
|
|
2598
2690
|
const impactFindings = [];
|
|
2599
2691
|
for (const filePath of files.slice(0, 5)) {
|
|
2600
2692
|
// Turn-state files may be cwd-relative while graph keys are absolute,
|
|
@@ -2622,7 +2714,33 @@ export async function handleTurnEnd(deps) {
|
|
|
2622
2714
|
});
|
|
2623
2715
|
if (results.length > 0) {
|
|
2624
2716
|
impactFindings.push({ calleeKey, results });
|
|
2625
|
-
|
|
2717
|
+
// #3248: the rendered line takes the stored-disposition
|
|
2718
|
+
// filter. A result is mapped to its diagnostic by the
|
|
2719
|
+
// lane's OWN adapter, ONE result at a time, so the
|
|
2720
|
+
// identity can never diverge from the one
|
|
2721
|
+
// `lens_diagnostics` surfaces and a mark anchors against
|
|
2722
|
+
// — and a result the adapter does not map (the Review
|
|
2723
|
+
// tier, an unattributable key, a test-role caller) is
|
|
2724
|
+
// kept unconditionally: it is not markable, so nothing
|
|
2725
|
+
// may suppress it. Re-rendered with `formatImpact`, the
|
|
2726
|
+
// same renderer, never a second one.
|
|
2727
|
+
// `flatMap` rather than a push-under-an-`if`: an entry the
|
|
2728
|
+
// adapter does not map simply yields nothing, so the array
|
|
2729
|
+
// is well-typed with no branch whose removal changes no
|
|
2730
|
+
// behaviour (there is nothing here to mutate).
|
|
2731
|
+
const markable = results.flatMap((result) => callGraphImpactToProjectDiagnostics(cwd, [
|
|
2732
|
+
{ calleeKey, results: [result] },
|
|
2733
|
+
]).map((diagnostic) => ({ result, diagnostic })));
|
|
2734
|
+
const filtered = filterFindingsByDisposition(markable, cwd, (entry) => entry.diagnostic);
|
|
2735
|
+
callGraphDispositionSuppressed += filtered.suppressed;
|
|
2736
|
+
const keptEntries = new Set(filtered.kept);
|
|
2737
|
+
const dropped = new Set(markable
|
|
2738
|
+
.filter((entry) => !keptEntries.has(entry))
|
|
2739
|
+
.map((entry) => entry.result));
|
|
2740
|
+
const survivors = dropped.size === 0
|
|
2741
|
+
? results
|
|
2742
|
+
: results.filter((r) => !dropped.has(r));
|
|
2743
|
+
const summary = survivors.length > 0 ? formatImpact(survivors, cwd) : "";
|
|
2626
2744
|
if (summary)
|
|
2627
2745
|
impactLines.push(` ${parseSymbolKey(calleeKey).symbolName ?? calleeKey}: ${summary}`);
|
|
2628
2746
|
}
|
|
@@ -2639,6 +2757,23 @@ export async function handleTurnEnd(deps) {
|
|
|
2639
2757
|
projectDiagnosticsSources.add("call-graph");
|
|
2640
2758
|
}
|
|
2641
2759
|
}
|
|
2760
|
+
// #3248: this lane wrote nothing per turn, so its delivery decision
|
|
2761
|
+
// — including how many callers a stored disposition dropped — was
|
|
2762
|
+
// unobservable. One bounded row per turn, only when the lane ran.
|
|
2763
|
+
if (impactFindings.length > 0 || callGraphDispositionSuppressed > 0) {
|
|
2764
|
+
logLatency({
|
|
2765
|
+
type: "phase",
|
|
2766
|
+
toolName: "turn_end",
|
|
2767
|
+
filePath: cwd,
|
|
2768
|
+
phase: "call_graph_impact",
|
|
2769
|
+
durationMs: Date.now() - callGraphStart,
|
|
2770
|
+
metadata: {
|
|
2771
|
+
callees: impactFindings.length,
|
|
2772
|
+
lines: impactLines.length,
|
|
2773
|
+
dispositionSuppressed: callGraphDispositionSuppressed,
|
|
2774
|
+
},
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2642
2777
|
// Non-fatal — call graph is best-effort
|
|
2643
2778
|
}
|
|
2644
2779
|
catch {
|
|
@@ -2672,7 +2807,37 @@ export async function handleTurnEnd(deps) {
|
|
|
2672
2807
|
fileSeqByPath.set(filePath, getFileSeq.call(runtime, filePath));
|
|
2673
2808
|
}
|
|
2674
2809
|
}
|
|
2810
|
+
let reverify;
|
|
2675
2811
|
if (getFlag("lens-actionable-warnings")) {
|
|
2812
|
+
// #3170: re-verify carried deferred findings before the advisory
|
|
2813
|
+
// assembles — a finding whose file is unchanged re-serves from the
|
|
2814
|
+
// persisted report without ever being re-observed; root-cause-fixed-
|
|
2815
|
+
// elsewhere findings converge here instead of repeating. Bounded in the
|
|
2816
|
+
// module (≤4 files, wall budget, abort signal). #3176 F1: the
|
|
2817
|
+
// replacement entries fold into THIS turn's single in-band publish
|
|
2818
|
+
// below — a separate replacement publish spends the carry marker and
|
|
2819
|
+
// this publish then drops the entries at the scope guard (the blocker
|
|
2820
|
+
// the review caught).
|
|
2821
|
+
const persistedReport = cacheManager.readCache("actionable-warnings", cwd, Number.MAX_SAFE_INTEGER)?.data;
|
|
2822
|
+
if (persistedReport?.files?.some((entry) => entry.origin === "deferred")) {
|
|
2823
|
+
const reverifyLspService = getLSPService();
|
|
2824
|
+
if (reverifyLspService) {
|
|
2825
|
+
// #2523: the hook-path await is bound-wrapped with the hook's own
|
|
2826
|
+
// budget and signal; the pass's internal deadline (3s) is the
|
|
2827
|
+
// tighter of the two.
|
|
2828
|
+
reverify = await bounded(runPersistentReverify({
|
|
2829
|
+
report: persistedReport,
|
|
2830
|
+
cwd,
|
|
2831
|
+
lspService: reverifyLspService,
|
|
2832
|
+
signal: getAmbientAbortSignal(),
|
|
2833
|
+
}), {
|
|
2834
|
+
ms: HOOK_WALL_BUDGET_MS.turn_end,
|
|
2835
|
+
signal: getAmbientAbortSignal(),
|
|
2836
|
+
hook: "turn_end",
|
|
2837
|
+
label: "persistent_reverify",
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2676
2841
|
try {
|
|
2677
2842
|
const report = await buildActionableWarningsReport({
|
|
2678
2843
|
cwd,
|
|
@@ -2680,14 +2845,7 @@ export async function handleTurnEnd(deps) {
|
|
|
2680
2845
|
turnIndex: runtime.turnIndex,
|
|
2681
2846
|
files,
|
|
2682
2847
|
modifiedRangesByFile,
|
|
2683
|
-
|
|
2684
|
-
// surfaced in the unified secrets blocker above (#131 Mode 3) — the
|
|
2685
|
-
// secret is reported once, not twice.
|
|
2686
|
-
dispatchWarnings: runtime
|
|
2687
|
-
.peekActionableWarnings()
|
|
2688
|
-
.filter((w) => !(isSecretWarning(w) &&
|
|
2689
|
-
typeof w.line === "number" &&
|
|
2690
|
-
secretBlockedLocations.has(secretLocationKey(w.filePath, w.line)))),
|
|
2848
|
+
dispatchWarnings: runtime.peekActionableWarnings(),
|
|
2691
2849
|
includeLspCodeActions: !!getFlag("lens-actionable-warning-actions"),
|
|
2692
2850
|
projectSeqStart: runtime.turnStartProjectSeq,
|
|
2693
2851
|
projectSeqEnd: runtime.projectSeq,
|
|
@@ -2751,6 +2909,24 @@ export async function handleTurnEnd(deps) {
|
|
|
2751
2909
|
// forward only entries a DEFERRAL produced, and only while their
|
|
2752
2910
|
// file has not moved, so a `turn_delta` report does not accumulate
|
|
2753
2911
|
// every prior turn's findings.
|
|
2912
|
+
// #3176 F1: the re-verify replacements fold into THIS report — one
|
|
2913
|
+
// in-band publish total, so the carry marker is spent exactly once and
|
|
2914
|
+
// the merged report cannot drop the entries behind the publish (the
|
|
2915
|
+
// blocker the review caught).
|
|
2916
|
+
if (reverify?.replacementFiles.length) {
|
|
2917
|
+
const replacementByPath = new Map(reverify.replacementFiles.map((file) => [
|
|
2918
|
+
normalizeMapKey(file.filePath),
|
|
2919
|
+
file,
|
|
2920
|
+
]));
|
|
2921
|
+
const files = (report.files ?? []).map((file) => replacementByPath.get(normalizeMapKey(file.filePath)) ?? file);
|
|
2922
|
+
for (const replacement of reverify.replacementFiles) {
|
|
2923
|
+
if (!files.some((file) => normalizeMapKey(file.filePath) ===
|
|
2924
|
+
normalizeMapKey(replacement.filePath))) {
|
|
2925
|
+
files.push(replacement);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
report.files = files;
|
|
2929
|
+
}
|
|
2754
2930
|
const publishResult = publishActionableWarningsReport(cacheManager, cwd, report, {
|
|
2755
2931
|
origin: "in-band",
|
|
2756
2932
|
getFileSeq: getFileSeq
|
|
@@ -2766,7 +2942,36 @@ export async function handleTurnEnd(deps) {
|
|
|
2766
2942
|
// #2521: `cwd` is what resolves the store location the advisory
|
|
2767
2943
|
// names -- it is the SAME cwd `publishActionableWarningsReport`
|
|
2768
2944
|
// just wrote through, so the two cannot disagree.
|
|
2769
|
-
const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd, host)
|
|
2945
|
+
const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd, host, (report) => {
|
|
2946
|
+
let dispositionSuppressed = 0;
|
|
2947
|
+
const files = report.files
|
|
2948
|
+
.map((file) => {
|
|
2949
|
+
const policy = filterFindingsByDisposition(file.warnings, cwd, (warning) => {
|
|
2950
|
+
const { line, column, rule, code } = warning;
|
|
2951
|
+
return {
|
|
2952
|
+
filePath: warning.filePath,
|
|
2953
|
+
severity: warning.severity,
|
|
2954
|
+
semantic: "warning",
|
|
2955
|
+
tool: warning.tool,
|
|
2956
|
+
runner: warning.tool,
|
|
2957
|
+
message: warning.message,
|
|
2958
|
+
source: warning.origin === "lsp" ? "lsp" : "dispatch",
|
|
2959
|
+
...(line === undefined ? {} : { line: warning.line }),
|
|
2960
|
+
...(column === undefined ? {} : { column: warning.column }),
|
|
2961
|
+
...(rule === undefined ? {} : { rule: warning.rule }),
|
|
2962
|
+
...(code === undefined ? {} : { code }),
|
|
2963
|
+
};
|
|
2964
|
+
});
|
|
2965
|
+
dispositionSuppressed += policy.suppressed;
|
|
2966
|
+
const kept = policy.kept.filter((warning) => !(isSecretWarning(warning) &&
|
|
2967
|
+
typeof warning.line === "number" &&
|
|
2968
|
+
secretBlockedLocations.has(secretLocationKey(warning.filePath, warning.line))));
|
|
2969
|
+
return kept.length > 0 ? { ...file, warnings: kept } : undefined;
|
|
2970
|
+
})
|
|
2971
|
+
.filter((file) => file !== undefined);
|
|
2972
|
+
recordDispositionSuppressed("actionable-warnings", dispositionSuppressed);
|
|
2973
|
+
return { files, suppressed: dispositionSuppressed };
|
|
2974
|
+
});
|
|
2770
2975
|
// @delivery-surface: runtime-turn:actionable-warnings-advisory
|
|
2771
2976
|
if (advisory)
|
|
2772
2977
|
advisoryParts.push(advisory);
|
|
@@ -2810,11 +3015,46 @@ export async function handleTurnEnd(deps) {
|
|
|
2810
3015
|
}
|
|
2811
3016
|
const t5 = Date.now();
|
|
2812
3017
|
try {
|
|
3018
|
+
// #3248: the code-quality records were filtered at DISPATCH time, before
|
|
3019
|
+
// any `lens_diagnostic_mark`. `lens_diagnostics mode=delta` re-applies
|
|
3020
|
+
// dispositions when it re-serves this same cache
|
|
3021
|
+
// (`tools/lens-diagnostics.ts`'s `visibleWarningFiles`), so without this
|
|
3022
|
+
// the turn-end advisory counted warnings the delta view had already
|
|
3023
|
+
// dropped — the two surfaces disagreed about the same records. Filtered
|
|
3024
|
+
// at the report INPUT so the advisory, the persisted report and the delta
|
|
3025
|
+
// view all agree; the delta view's own filter then re-applies to the same
|
|
3026
|
+
// set and is idempotent. Per file, because the anchor is content-bound.
|
|
3027
|
+
const qualityWarningsByFile = new Map();
|
|
3028
|
+
for (const warning of runtime.peekCodeQualityWarnings()) {
|
|
3029
|
+
const group = qualityWarningsByFile.get(warning.filePath);
|
|
3030
|
+
if (group)
|
|
3031
|
+
group.push(warning);
|
|
3032
|
+
else
|
|
3033
|
+
qualityWarningsByFile.set(warning.filePath, [warning]);
|
|
3034
|
+
}
|
|
3035
|
+
const qualityWarnings = [];
|
|
3036
|
+
let qualityDispositionSuppressed = 0;
|
|
3037
|
+
for (const [filePath, group] of qualityWarningsByFile) {
|
|
3038
|
+
let content;
|
|
3039
|
+
try {
|
|
3040
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
3041
|
+
}
|
|
3042
|
+
catch {
|
|
3043
|
+
content = undefined;
|
|
3044
|
+
}
|
|
3045
|
+
const { kept, suppressed } = applyPushedFindingPolicy(group, {
|
|
3046
|
+
cwd,
|
|
3047
|
+
filePath,
|
|
3048
|
+
content,
|
|
3049
|
+
});
|
|
3050
|
+
qualityWarnings.push(...kept);
|
|
3051
|
+
qualityDispositionSuppressed += suppressed;
|
|
3052
|
+
}
|
|
2813
3053
|
const qualityReport = buildCodeQualityWarningsReport({
|
|
2814
3054
|
cwd,
|
|
2815
3055
|
sessionId: runtime.telemetrySessionId,
|
|
2816
3056
|
turnIndex: runtime.turnIndex,
|
|
2817
|
-
warnings:
|
|
3057
|
+
warnings: qualityWarnings,
|
|
2818
3058
|
modifiedRangesByFile,
|
|
2819
3059
|
projectSeqStart: runtime.turnStartProjectSeq,
|
|
2820
3060
|
projectSeqEnd: runtime.projectSeq,
|
|
@@ -2832,7 +3072,11 @@ export async function handleTurnEnd(deps) {
|
|
|
2832
3072
|
filePath: cwd,
|
|
2833
3073
|
phase: "code_quality_warnings_report",
|
|
2834
3074
|
durationMs: Date.now() - t5,
|
|
2835
|
-
metadata:
|
|
3075
|
+
metadata: {
|
|
3076
|
+
...qualityReport.summary,
|
|
3077
|
+
// #3248: bounded per-turn on this lane's own row.
|
|
3078
|
+
dispositionSuppressed: qualityDispositionSuppressed,
|
|
3079
|
+
},
|
|
2836
3080
|
});
|
|
2837
3081
|
}
|
|
2838
3082
|
catch (err) {
|
|
@@ -2863,6 +3107,8 @@ export async function handleTurnEnd(deps) {
|
|
|
2863
3107
|
let runnerFindingsStale = 0;
|
|
2864
3108
|
let runnerFindingsFailed = 0;
|
|
2865
3109
|
let runnerFindingsDropped = 0;
|
|
3110
|
+
/** #3248: bounded per-turn on this lane's own row, never per finding. */
|
|
3111
|
+
let runnerFindingsDispositionSuppressed = 0;
|
|
2866
3112
|
const runnerFindingsDeliveredIds = [];
|
|
2867
3113
|
for (const pending of pendingRunnerFindings) {
|
|
2868
3114
|
const result = pending.result;
|
|
@@ -2879,12 +3125,15 @@ export async function handleTurnEnd(deps) {
|
|
|
2879
3125
|
const findings = result.diagnostics;
|
|
2880
3126
|
if (findings.length === 0)
|
|
2881
3127
|
continue;
|
|
2882
|
-
const gate = gateFindingsByPathFreshness({
|
|
2883
|
-
store: "late-runner-findings",
|
|
2884
|
-
findings,
|
|
3128
|
+
const { "late-runner-findings": gate } = gateFindingsByPathFreshness({
|
|
2885
3129
|
cwd,
|
|
2886
|
-
|
|
2887
|
-
|
|
3130
|
+
sources: {
|
|
3131
|
+
"late-runner-findings": {
|
|
3132
|
+
findings,
|
|
3133
|
+
scannedAt: pending.markedAtMs,
|
|
3134
|
+
citedPath: (finding) => finding.filePath,
|
|
3135
|
+
},
|
|
3136
|
+
},
|
|
2888
3137
|
});
|
|
2889
3138
|
runnerFindingsStale += gate.stale.length;
|
|
2890
3139
|
if (gate.stale.length > 0) {
|
|
@@ -2897,15 +3146,46 @@ export async function handleTurnEnd(deps) {
|
|
|
2897
3146
|
if (gate.live.length === 0)
|
|
2898
3147
|
continue;
|
|
2899
3148
|
const displayPath = toRunnerDisplayPath(cwd, pending.filePath);
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3149
|
+
// #3248: the survivors are what the agent READS, so they take the same
|
|
3150
|
+
// policy stack the late-AUXILIARY drain below applies — this lane is its
|
|
3151
|
+
// twin (same post-gate `Diagnostic[]`, same rendering) and was the one
|
|
3152
|
+
// push surface still re-reporting a finding the agent had marked. AFTER
|
|
3153
|
+
// the freshness gate, like every other lane: the anchor is derived from
|
|
3154
|
+
// the post-gate identity, never the raw pre-gate set. The file's CURRENT
|
|
3155
|
+
// bytes; an unreadable file fails open inside the helper.
|
|
3156
|
+
let runnerContent;
|
|
3157
|
+
try {
|
|
3158
|
+
runnerContent = fs.readFileSync(pending.filePath, "utf-8");
|
|
3159
|
+
}
|
|
3160
|
+
catch {
|
|
3161
|
+
runnerContent = undefined;
|
|
3162
|
+
}
|
|
3163
|
+
const { kept: runnerKept, suppressed: runnerSuppressedHere } = applyPushedFindingPolicy(gate.live, {
|
|
3164
|
+
cwd,
|
|
3165
|
+
filePath: pending.filePath,
|
|
3166
|
+
content: runnerContent,
|
|
3167
|
+
});
|
|
3168
|
+
runnerFindingsDispositionSuppressed += runnerSuppressedHere;
|
|
3169
|
+
if (runnerKept.length === 0) {
|
|
3170
|
+
// Every late finding was marked. A PUSH surface stays silent rather
|
|
3171
|
+
// than re-announcing that the mark is working; the count rides this
|
|
3172
|
+
// lane's bounded per-turn row below.
|
|
3173
|
+
continue;
|
|
3174
|
+
}
|
|
3175
|
+
const lines = runnerKept.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
|
|
3176
|
+
runnerFindingsDelivered += runnerKept.length;
|
|
3177
|
+
for (const finding of runnerKept) {
|
|
2903
3178
|
if (runnerFindingsDeliveredIds.length < 50) {
|
|
2904
3179
|
runnerFindingsDeliveredIds.push(finding.id);
|
|
2905
3180
|
}
|
|
2906
3181
|
}
|
|
3182
|
+
// #1616 suppressed-bucket rule: a delivery that still has something to
|
|
3183
|
+
// say states what it dropped, once per delivery.
|
|
3184
|
+
const runnerSuppressedNote = runnerSuppressedHere > 0
|
|
3185
|
+
? `; suppressed by disposition: ${runnerSuppressedHere} finding(s)`
|
|
3186
|
+
: "";
|
|
2907
3187
|
// @delivery-surface: runtime-turn:late-runner-findings
|
|
2908
|
-
advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit):\n${lines.join("\n")}`);
|
|
3188
|
+
advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit${runnerSuppressedNote}):\n${lines.join("\n")}`);
|
|
2909
3189
|
}
|
|
2910
3190
|
logLatency({
|
|
2911
3191
|
type: "phase",
|
|
@@ -2919,6 +3199,7 @@ export async function handleTurnEnd(deps) {
|
|
|
2919
3199
|
stale: runnerFindingsStale,
|
|
2920
3200
|
failed: runnerFindingsFailed,
|
|
2921
3201
|
dropped: runnerFindingsDropped,
|
|
3202
|
+
dispositionSuppressed: runnerFindingsDispositionSuppressed,
|
|
2922
3203
|
deliveredIds: runnerFindingsDeliveredIds,
|
|
2923
3204
|
},
|
|
2924
3205
|
});
|
|
@@ -3147,12 +3428,15 @@ export async function handleTurnEnd(deps) {
|
|
|
3147
3428
|
// one), so a stale-arm replay would double-report old content.
|
|
3148
3429
|
// Both drops are COUNTED here and in the latency record below —
|
|
3149
3430
|
// never silent (shape 10).
|
|
3150
|
-
const gate = gateFindingsByPathFreshness({
|
|
3151
|
-
store: "late-auxiliary-findings",
|
|
3152
|
-
findings: retained,
|
|
3431
|
+
const { "late-auxiliary-findings": gate } = gateFindingsByPathFreshness({
|
|
3153
3432
|
cwd,
|
|
3154
|
-
|
|
3155
|
-
|
|
3433
|
+
sources: {
|
|
3434
|
+
"late-auxiliary-findings": {
|
|
3435
|
+
findings: retained,
|
|
3436
|
+
scannedAt: pair.markedAtMs,
|
|
3437
|
+
citedPath: () => lateAuxPath,
|
|
3438
|
+
},
|
|
3439
|
+
},
|
|
3156
3440
|
});
|
|
3157
3441
|
lateAuxStale += gate.stale.length;
|
|
3158
3442
|
lateAuxMissing +=
|
|
@@ -3197,15 +3481,15 @@ export async function handleTurnEnd(deps) {
|
|
|
3197
3481
|
// a late pair. Runs AFTER the freshness gate, like the #1625
|
|
3198
3482
|
// govulncheck/secrets filters: the anchor is derived from the
|
|
3199
3483
|
// post-gate identity, never the raw pre-gate set.
|
|
3200
|
-
|
|
3484
|
+
// #3248: the four arguments moved into
|
|
3485
|
+
// `applyPushedFindingPolicy` so the late-RUNNER drain below
|
|
3486
|
+
// cannot make a second copy of them. Same stack, same
|
|
3487
|
+
// identities, same fail-open content rule.
|
|
3488
|
+
const { kept: lateAuxKept, suppressed: lateAuxSuppressedHere } = applyPushedFindingPolicy(gate.live, {
|
|
3201
3489
|
cwd,
|
|
3202
3490
|
filePath: lateAuxPath,
|
|
3203
|
-
content: lateAuxContent
|
|
3204
|
-
// mtime-cached, so a drain of several files costs one stat.
|
|
3205
|
-
policyMap: loadProjectRulePolicyMap(cwd),
|
|
3206
|
-
identities: renderedRuleIdentities,
|
|
3491
|
+
content: lateAuxContent,
|
|
3207
3492
|
});
|
|
3208
|
-
const lateAuxSuppressedHere = gate.live.length - lateAuxKept.length;
|
|
3209
3493
|
lateAuxDispositionSuppressed += lateAuxSuppressedHere;
|
|
3210
3494
|
if (lateAuxKept.length === 0) {
|
|
3211
3495
|
// Every late finding was suppressed. This is a PUSH surface:
|