@herbertgao/pi-extensions 2026.9.14 → 2026.9.16
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/README.md +8 -3
- package/THIRD_PARTY_NOTICES.md +28 -2
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
- package/node_modules/pi-jev-auto-mode/CHANGELOG.md +8 -0
- package/node_modules/pi-jev-auto-mode/README.md +7 -0
- package/node_modules/pi-jev-auto-mode/package.json +1 -1
- package/node_modules/pi-jev-auto-mode/src/extension.ts +21 -2
- package/node_modules/pi-jev-auto-mode/src/records.ts +68 -2
- package/node_modules/pi-jev-auto-mode/src/settings.ts +22 -0
- package/node_modules/pi-jev-auto-mode/src/ui.ts +10 -0
- package/node_modules/pi-lens/CHANGELOG.md +498 -0
- package/node_modules/pi-lens/README.md +45 -2
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
- package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
- package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
- package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
- package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
- package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
- package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
- package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
- package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
- package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
- package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
- package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
- package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
- package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
- package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
- package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
- package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
- package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
- package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
- package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
- package/node_modules/pi-lens/dist/index.js +5695 -3733
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
- package/node_modules/pi-lens/dist/mcp/server.js +33 -21
- package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
- package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
- package/node_modules/pi-lens/docs/adr/README.md +26 -0
- package/node_modules/pi-lens/docs/agent-tools.md +18 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
- package/node_modules/pi-lens/docs/configuration.md +55 -1
- package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
- package/node_modules/pi-lens/docs/environment-variables.md +61 -4
- package/node_modules/pi-lens/docs/features.md +2 -2
- package/node_modules/pi-lens/docs/globalconfig.md +6 -1
- package/node_modules/pi-lens/docs/language-coverage.md +17 -0
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
- package/node_modules/pi-lens/docs/settings.md +12 -3
- package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
- package/node_modules/pi-lens/package.json +5 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
- package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
- package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
- package/node_modules/pi-mcp-adapter/README.md +12 -5
- package/node_modules/pi-mcp-adapter/config.ts +31 -8
- package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
- package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
- package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
- package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
- package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +37 -20
- package/node_modules/pi-mcp-adapter/init.ts +14 -15
- package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
- package/node_modules/pi-mcp-adapter/package.json +3 -2
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
- package/node_modules/pi-mcp-adapter/types.ts +2 -2
- package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
- package/node_modules/pi-mcp-adapter/utils.ts +11 -3
- package/node_modules/pi-multi-account/CHANGELOG.md +6 -0
- package/node_modules/pi-multi-account/README.md +15 -1
- package/node_modules/pi-multi-account/index.ts +295 -41
- package/node_modules/pi-multi-account/package.json +1 -1
- package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
- package/node_modules/pi-next-cue/LICENSE +21 -0
- package/node_modules/pi-next-cue/README.md +81 -0
- package/node_modules/pi-next-cue/index.ts +358 -0
- package/node_modules/pi-next-cue/package.json +28 -0
- package/package.json +10 -7
|
@@ -1547,22 +1547,35 @@ export async function applyConservativeActionableWarningFixes(args) {
|
|
|
1547
1547
|
* unread under `~/.pi-lens/projects/<slug>/cache/`. An OPTIONAL `cwd` would
|
|
1548
1548
|
* have kept that bug alive behind a default, so callers must pass it.
|
|
1549
1549
|
*/
|
|
1550
|
-
export function formatActionableWarningsAdvisory(report, cwd, host = "pi") {
|
|
1551
|
-
|
|
1550
|
+
export function formatActionableWarningsAdvisory(report, cwd, host = "pi", filterBuiltReport) {
|
|
1551
|
+
// The cache record is deliberately kept raw: lens_diagnostics mode=delta
|
|
1552
|
+
// applies dispositions on read. The turn-end advisory is a separate
|
|
1553
|
+
// delivery surface, so filter the report AFTER its builder and after the
|
|
1554
|
+
// deferred merge (which is where LSP-enriched rows enter), then derive the
|
|
1555
|
+
// summary from the same per-file rows before rendering.
|
|
1556
|
+
const filtered = filterBuiltReport?.(report);
|
|
1557
|
+
const advisoryReport = filtered
|
|
1558
|
+
? {
|
|
1559
|
+
...report,
|
|
1560
|
+
files: filtered.files,
|
|
1561
|
+
summary: summarizeReportFiles(filtered.files),
|
|
1562
|
+
}
|
|
1563
|
+
: report;
|
|
1564
|
+
if (advisoryReport.summary.unsuppressed === 0)
|
|
1552
1565
|
return undefined;
|
|
1553
|
-
const files =
|
|
1566
|
+
const files = advisoryReport.files.filter((file) => file.warnings.some((warning) => !warning.suppressed));
|
|
1554
1567
|
const fileList = files
|
|
1555
1568
|
.slice(0, 5)
|
|
1556
1569
|
.map((file) => ` ${file.displayPath}: ${file.warnings.filter((warning) => !warning.suppressed).length}${file.reVerifyIncomplete ? " (re-verify incomplete)" : ""}`)
|
|
1557
1570
|
.join("\n");
|
|
1558
1571
|
const more = files.length > 5 ? `\n ... and ${files.length - 5} more file(s)` : "";
|
|
1559
|
-
const safe =
|
|
1560
|
-
? ` ${
|
|
1572
|
+
const safe = advisoryReport.summary.autoFixEligible > 0
|
|
1573
|
+
? ` ${advisoryReport.summary.autoFixEligible} appear to have conservative preferred quickfixes.`
|
|
1561
1574
|
: "";
|
|
1562
1575
|
// #1777: hint and info are style opinions, so say how much of the count is
|
|
1563
1576
|
// opinion. The line appears only when a quiet tier is actually present —
|
|
1564
1577
|
// an all-warning turn already says everything in the count above.
|
|
1565
|
-
const byTier =
|
|
1578
|
+
const byTier = advisoryReport.summary.byTier;
|
|
1566
1579
|
const quiet = byTier ? byTier.hint + byTier.info : 0;
|
|
1567
1580
|
const tierLine = quiet > 0
|
|
1568
1581
|
? `${quiet} of those are hint/info tier — style opinions, worth fixing only while you are already in that code.`
|
|
@@ -1580,7 +1593,10 @@ export function formatActionableWarningsAdvisory(report, cwd, host = "pi") {
|
|
|
1580
1593
|
// defensive only.
|
|
1581
1594
|
const diagnosticsTool = resolveLensToolName("lens_diagnostics", host);
|
|
1582
1595
|
return [
|
|
1583
|
-
`🟡 Fixable warnings introduced this turn: ${
|
|
1596
|
+
`🟡 Fixable warnings introduced this turn: ${advisoryReport.summary.unsuppressed}.${safe}`,
|
|
1597
|
+
filtered && filtered.suppressed > 0
|
|
1598
|
+
? `suppressed by disposition: ${filtered.suppressed} finding(s).`
|
|
1599
|
+
: undefined,
|
|
1584
1600
|
tierLine,
|
|
1585
1601
|
diagnosticsTool
|
|
1586
1602
|
? `Use ${diagnosticsTool} with mode=delta to inspect these warnings.`
|
|
@@ -248,25 +248,39 @@ const MAX_LOGGED_DEAD_PATHS = 3;
|
|
|
248
248
|
* truth for the measured skew.
|
|
249
249
|
*/
|
|
250
250
|
export function findingPathFreshness(resolvedPath, scannedAtMs) {
|
|
251
|
+
return freshnessFromFacts(statFindingPath(resolvedPath), scannedAtMs);
|
|
252
|
+
}
|
|
253
|
+
/** The one filesystem touch. Injectable via `probePath` so rules stay testable. */
|
|
254
|
+
function statFindingPath(resolvedPath) {
|
|
251
255
|
try {
|
|
252
|
-
|
|
253
|
-
// No scan timestamp recorded: the pre-kernel behavior treated the
|
|
254
|
-
// path as live (no reference to be stale against) - preserved.
|
|
255
|
-
if (scannedAtMs === undefined)
|
|
256
|
-
return "live";
|
|
257
|
-
const verdict = freshnessFromMtime({
|
|
258
|
-
mtimeMs: stat.mtimeMs,
|
|
259
|
-
referenceMs: scannedAtMs,
|
|
260
|
-
});
|
|
261
|
-
return verdict.verdict === "stale" ? "stale" : "live";
|
|
256
|
+
return { state: "present", mtimeMs: fs.statSync(resolvedPath).mtimeMs };
|
|
262
257
|
}
|
|
263
258
|
catch (error) {
|
|
264
259
|
const code = error.code ?? "unknown";
|
|
265
260
|
// ENOTDIR: an ancestor component is no longer a directory — the cited
|
|
266
261
|
// path cannot exist either, same as ENOENT.
|
|
267
|
-
return code === "ENOENT" || code === "ENOTDIR"
|
|
262
|
+
return code === "ENOENT" || code === "ENOTDIR"
|
|
263
|
+
? { state: "missing" }
|
|
264
|
+
: { state: "unknown" };
|
|
268
265
|
}
|
|
269
266
|
}
|
|
267
|
+
/** Pure: one store's verdict for one path's facts. No filesystem, no policy. */
|
|
268
|
+
function freshnessFromFacts(facts, scannedAtMs) {
|
|
269
|
+
if (facts.state === "missing")
|
|
270
|
+
return "missing";
|
|
271
|
+
if (facts.state === "unknown")
|
|
272
|
+
return "unknown";
|
|
273
|
+
// No scan timestamp recorded: the pre-kernel behavior treated the path as
|
|
274
|
+
// live (no reference to be stale against) - preserved.
|
|
275
|
+
if (scannedAtMs === undefined)
|
|
276
|
+
return "live";
|
|
277
|
+
return freshnessFromMtime({
|
|
278
|
+
mtimeMs: facts.mtimeMs,
|
|
279
|
+
referenceMs: scannedAtMs,
|
|
280
|
+
}).verdict === "stale"
|
|
281
|
+
? "stale"
|
|
282
|
+
: "live";
|
|
283
|
+
}
|
|
270
284
|
/** Existence-only probe. Retained for callers that have no scan timestamp. */
|
|
271
285
|
export function findingPathExistence(resolvedPath) {
|
|
272
286
|
return findingPathFreshness(resolvedPath);
|
|
@@ -294,8 +308,17 @@ export function parseScannedAtMs(scannedAt) {
|
|
|
294
308
|
* every later turn, which is the very defect this gate exists to close.
|
|
295
309
|
*/
|
|
296
310
|
export function partitionFindingsByCitedPath(args) {
|
|
297
|
-
|
|
298
|
-
|
|
311
|
+
return partitionOneSource({ ...args, store: args.store ?? "" }, createPathFactsMemo(args));
|
|
312
|
+
}
|
|
313
|
+
function createPathFactsMemo(args) {
|
|
314
|
+
return {
|
|
315
|
+
facts: new Map(),
|
|
316
|
+
probe: args.probePath ?? statFindingPath,
|
|
317
|
+
limit: args.maxUniquePaths ?? MAX_FINDING_PATH_STATS,
|
|
318
|
+
starved: new Map(),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function partitionOneSource(args, memo) {
|
|
299
322
|
const scannedAtMs = parseScannedAtMs(args.scannedAt);
|
|
300
323
|
const onMissing = args.onMissing ?? "drop";
|
|
301
324
|
// Two dedup layers, cheapest first. `rawVerdicts` collapses findings that
|
|
@@ -303,19 +326,24 @@ export function partitionFindingsByCitedPath(args) {
|
|
|
303
326
|
// (#1460's live record: 126 findings over a handful of distinct `file`
|
|
304
327
|
// strings). Only a raw string not seen before pays for
|
|
305
328
|
// `resolveRunnerPath`/`advisoryPathKey`, which folds FS-confirmed spelling
|
|
306
|
-
// variants (case, separators, ancestor walk-up) into `
|
|
329
|
+
// variants (case, separators, ancestor walk-up) into `memo.facts`, the
|
|
307
330
|
// canonical-key map that `statCount` reports. Keying the expensive work by
|
|
308
331
|
// the RESOLVED path instead of the raw one (the pre-#1461-HIGH-2 shape)
|
|
309
332
|
// still called it once per finding, since the dedup lookup came after the
|
|
310
333
|
// cost it was meant to dedupe.
|
|
334
|
+
//
|
|
335
|
+
// `rawVerdicts` is per SOURCE (the verdict is this store's), `memo.facts` is
|
|
336
|
+
// per DELIVERY (the stat is nobody's). #1892.
|
|
311
337
|
const rawVerdicts = new Map();
|
|
312
|
-
const verdicts = new Map();
|
|
313
338
|
const live = [];
|
|
314
339
|
const stale = [];
|
|
315
340
|
const dropped = [];
|
|
316
341
|
const deadPaths = [];
|
|
317
342
|
const stalePaths = [];
|
|
318
|
-
|
|
343
|
+
const seenPaths = new Set();
|
|
344
|
+
// This SOURCE's own probes. A memo hit costs nothing, so an earlier source
|
|
345
|
+
// having paid for a path only ever helps this one (#3264 review F2).
|
|
346
|
+
let probed = 0;
|
|
319
347
|
for (const finding of args.findings) {
|
|
320
348
|
const cited = args.citedPath(finding);
|
|
321
349
|
if (!cited) {
|
|
@@ -332,30 +360,37 @@ export function partitionFindingsByCitedPath(args) {
|
|
|
332
360
|
// `normalizeMapKey`, so the resolved path IS the canonical key — a
|
|
333
361
|
// second `advisoryPathKey` pass would re-pay the same realpath cost.
|
|
334
362
|
const resolved = resolveRunnerPath(args.cwd, cited);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
363
|
+
let facts = memo.facts.get(resolved);
|
|
364
|
+
if (facts === undefined) {
|
|
365
|
+
if (probed >= memo.limit) {
|
|
366
|
+
// Allowance spent on paths nobody in this delivery has seen —
|
|
367
|
+
// deliver rather than guess, exactly as the pre-fold per-lane cap
|
|
368
|
+
// did. Guessing a verdict for a path nobody looked at is the worse
|
|
369
|
+
// error; what the fold adds is that the guess is now RECORDED.
|
|
370
|
+
memo.starved.set(args.store, (memo.starved.get(args.store) ?? 0) + 1);
|
|
371
|
+
facts = { state: "unknown" };
|
|
343
372
|
}
|
|
344
373
|
else {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
// every downstream list and record sees one consistent verdict.
|
|
349
|
-
if (verdict === "missing" && onMissing === "demote") {
|
|
350
|
-
verdict = "stale";
|
|
351
|
-
}
|
|
352
|
-
verdicts.set(key, verdict);
|
|
353
|
-
if (verdict === "missing")
|
|
354
|
-
deadPaths.push(resolved);
|
|
355
|
-
else if (verdict === "stale")
|
|
356
|
-
stalePaths.push(resolved);
|
|
374
|
+
probed += 1;
|
|
375
|
+
facts = memo.probe(resolved);
|
|
376
|
+
memo.facts.set(resolved, facts);
|
|
357
377
|
}
|
|
358
378
|
}
|
|
379
|
+
verdict = freshnessFromFacts(facts, scannedAtMs);
|
|
380
|
+
// A store that treats deletion as evidence-loss rather than
|
|
381
|
+
// remediation-loss folds `missing` into the demote arm here, so
|
|
382
|
+
// every downstream list and record sees one consistent verdict. It is
|
|
383
|
+
// applied per SOURCE, after the shared lookup: `onMissing` is this
|
|
384
|
+
// store's policy and must never be cached into another store's answer.
|
|
385
|
+
if (verdict === "missing" && onMissing === "demote")
|
|
386
|
+
verdict = "stale";
|
|
387
|
+
if (!seenPaths.has(resolved)) {
|
|
388
|
+
seenPaths.add(resolved);
|
|
389
|
+
if (verdict === "missing")
|
|
390
|
+
deadPaths.push(resolved);
|
|
391
|
+
else if (verdict === "stale")
|
|
392
|
+
stalePaths.push(resolved);
|
|
393
|
+
}
|
|
359
394
|
rawVerdicts.set(cited, verdict);
|
|
360
395
|
}
|
|
361
396
|
if (verdict === "missing")
|
|
@@ -371,8 +406,8 @@ export function partitionFindingsByCitedPath(args) {
|
|
|
371
406
|
dropped,
|
|
372
407
|
deadPaths,
|
|
373
408
|
stalePaths,
|
|
374
|
-
statCount:
|
|
375
|
-
truncated,
|
|
409
|
+
statCount: memo.facts.size,
|
|
410
|
+
truncated: (memo.starved.get(args.store) ?? 0) > 0,
|
|
376
411
|
};
|
|
377
412
|
}
|
|
378
413
|
/**
|
|
@@ -386,32 +421,93 @@ export function partitionFindingsByCitedPath(args) {
|
|
|
386
421
|
export function dropFindingsForMissingPaths(args) {
|
|
387
422
|
// No `scannedAt` — existence-only, so `partition.stale` is always empty.
|
|
388
423
|
const partition = partitionFindingsByCitedPath(args);
|
|
389
|
-
emitDeadPathDropRecord(args.
|
|
424
|
+
emitDeadPathDropRecord(args.cwd, partition, {
|
|
425
|
+
[args.store]: partition.dropped.length,
|
|
426
|
+
});
|
|
390
427
|
return partition.live;
|
|
391
428
|
}
|
|
392
429
|
/**
|
|
393
|
-
* Delivery-seam wrapper with the #1622 freshness verdict
|
|
394
|
-
* `
|
|
395
|
-
*
|
|
430
|
+
* Delivery-seam wrapper with the #1622 freshness verdict, over one or more
|
|
431
|
+
* stores at once. The store name is the KEY of `sources`, and it is also the
|
|
432
|
+
* finding's source identity inside the gate: every store's `scannedAt` and
|
|
433
|
+
* `onMissing` decide that store's findings and only that store's.
|
|
434
|
+
*
|
|
435
|
+
* #1892: the three turn-end scanner lanes used to call this once each, so one
|
|
436
|
+
* file cited by gitleaks AND trivy-secrets was stat'd twice, spent two separate
|
|
437
|
+
* stat budgets, and wrote two `finding_stale_line_demote` rows for ONE decision.
|
|
438
|
+
* Passing them together makes the filesystem pass shared and the record one per
|
|
439
|
+
* delivery, without letting one store's clean/stale/missing answer speak for
|
|
440
|
+
* another's — the two directions this seam has to keep apart.
|
|
396
441
|
*
|
|
397
442
|
* Callers that have no scan timestamp keep using `dropFindingsForMissingPaths`.
|
|
398
443
|
*/
|
|
399
444
|
export function gateFindingsByPathFreshness(args) {
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
445
|
+
const memo = createPathFactsMemo(args);
|
|
446
|
+
// Sorted, not literal order: which source pays for a shared path, and the
|
|
447
|
+
// order stores appear in the records, must not depend on how the CALLER
|
|
448
|
+
// happened to write the object (#3264 review F2).
|
|
449
|
+
const names = Object.keys(args.sources).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
450
|
+
const gates = {};
|
|
451
|
+
const dropped = {};
|
|
452
|
+
const demoted = {};
|
|
453
|
+
const merged = {
|
|
454
|
+
live: [],
|
|
455
|
+
stale: [],
|
|
456
|
+
dropped: [],
|
|
457
|
+
deadPaths: [],
|
|
458
|
+
stalePaths: [],
|
|
459
|
+
statCount: 0,
|
|
460
|
+
truncated: false,
|
|
461
|
+
};
|
|
462
|
+
const scannedAtByStore = {};
|
|
463
|
+
for (const name of names) {
|
|
464
|
+
const source = args.sources[name];
|
|
465
|
+
const partition = partitionOneSource({ ...source, store: name, cwd: args.cwd }, memo);
|
|
466
|
+
gates[name] = { live: partition.live, stale: partition.stale };
|
|
467
|
+
if (partition.dropped.length > 0)
|
|
468
|
+
dropped[name] = partition.dropped.length;
|
|
469
|
+
if (partition.stale.length > 0)
|
|
470
|
+
demoted[name] = partition.stale.length;
|
|
471
|
+
if (source.scannedAt !== undefined) {
|
|
472
|
+
scannedAtByStore[name] = String(source.scannedAt);
|
|
473
|
+
}
|
|
474
|
+
merged.live.push(...partition.live);
|
|
475
|
+
merged.stale.push(...partition.stale);
|
|
476
|
+
merged.dropped.push(...partition.dropped);
|
|
477
|
+
for (const deadPath of partition.deadPaths) {
|
|
478
|
+
if (!merged.deadPaths.includes(deadPath))
|
|
479
|
+
merged.deadPaths.push(deadPath);
|
|
480
|
+
}
|
|
481
|
+
for (const stalePath of partition.stalePaths) {
|
|
482
|
+
if (!merged.stalePaths.includes(stalePath)) {
|
|
483
|
+
merged.stalePaths.push(stalePath);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
merged.statCount = memo.facts.size;
|
|
488
|
+
merged.truncated = memo.starved.size > 0;
|
|
489
|
+
emitDeadPathDropRecord(args.cwd, merged, dropped);
|
|
490
|
+
emitStaleLineDemoteRecord(args.cwd, scannedAtByStore, merged, demoted);
|
|
491
|
+
emitStatBudgetRecord(args.cwd, memo);
|
|
492
|
+
return gates;
|
|
404
493
|
}
|
|
405
|
-
|
|
494
|
+
/**
|
|
495
|
+
* `store` is the joined source names, so a single-source delivery still reads
|
|
496
|
+
* `store: "gitleaks"` exactly as it did pre-#1892; `byStore` is what makes the
|
|
497
|
+
* merged record discriminating — which store's findings this decision retired.
|
|
498
|
+
*/
|
|
499
|
+
function emitDeadPathDropRecord(cwd, partition, byStore) {
|
|
406
500
|
if (partition.dropped.length === 0)
|
|
407
501
|
return;
|
|
502
|
+
const stores = Object.keys(byStore);
|
|
408
503
|
logLatency({
|
|
409
504
|
type: "phase",
|
|
410
505
|
phase: "finding_dead_path_drop",
|
|
411
506
|
filePath: cwd,
|
|
412
507
|
durationMs: 0,
|
|
413
508
|
metadata: {
|
|
414
|
-
store,
|
|
509
|
+
store: stores.join("+"),
|
|
510
|
+
byStore,
|
|
415
511
|
droppedDeadPaths: partition.dropped.length,
|
|
416
512
|
deadPathCount: partition.deadPaths.length,
|
|
417
513
|
deliveredCount: partition.live.length,
|
|
@@ -428,16 +524,22 @@ function emitDeadPathDropRecord(store, cwd, partition) {
|
|
|
428
524
|
* path. Before this, a stale-line replay window was reconstructible only by
|
|
429
525
|
* proxy — the blocker payload is written nowhere.
|
|
430
526
|
*/
|
|
431
|
-
function emitStaleLineDemoteRecord(
|
|
527
|
+
function emitStaleLineDemoteRecord(cwd, scannedAtByStore, partition, byStore) {
|
|
432
528
|
if (partition.stale.length === 0)
|
|
433
529
|
return;
|
|
530
|
+
// Only the stores that actually contributed to THIS decision are named, so
|
|
531
|
+
// `store` still reads `gitleaks` for a gitleaks-only demotion whether the
|
|
532
|
+
// delivery gated one store or three.
|
|
533
|
+
const stores = Object.keys(byStore);
|
|
534
|
+
const single = stores.length === 1 ? scannedAtByStore[stores[0]] : undefined;
|
|
434
535
|
logLatency({
|
|
435
536
|
type: "phase",
|
|
436
537
|
phase: "finding_stale_line_demote",
|
|
437
538
|
filePath: cwd,
|
|
438
539
|
durationMs: 0,
|
|
439
540
|
metadata: {
|
|
440
|
-
store,
|
|
541
|
+
store: stores.join("+"),
|
|
542
|
+
byStore,
|
|
441
543
|
demotedStalePaths: partition.stale.length,
|
|
442
544
|
stalePathCount: partition.stalePaths.length,
|
|
443
545
|
deliveredCount: partition.live.length,
|
|
@@ -445,11 +547,45 @@ function emitStaleLineDemoteRecord(store, cwd, scannedAt, partition) {
|
|
|
445
547
|
samplePaths: partition.stalePaths
|
|
446
548
|
.slice(0, MAX_LOGGED_DEAD_PATHS)
|
|
447
549
|
.map((stalePath) => toProjectRelativePath(stalePath, cwd)),
|
|
448
|
-
|
|
550
|
+
// One store: the flat `scannedAt` field, unchanged. Several: the
|
|
551
|
+
// per-store map, because the whole point of the fold is that they
|
|
552
|
+
// differ — one flattened timestamp here would be the same authority
|
|
553
|
+
// collapse the gate refuses to make on the findings themselves.
|
|
554
|
+
...(single === undefined ? {} : { scannedAt: single }),
|
|
555
|
+
...(stores.length > 1
|
|
556
|
+
? {
|
|
557
|
+
scannedAtByStore: Object.fromEntries(stores.map((name) => [name, scannedAtByStore[name] ?? ""])),
|
|
558
|
+
}
|
|
559
|
+
: {}),
|
|
449
560
|
...(partition.truncated ? { truncated: true } : {}),
|
|
450
561
|
},
|
|
451
562
|
});
|
|
452
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* #3264 review F2: the drop and demote records both return early when nothing
|
|
566
|
+
* was dropped or demoted, so a delivery whose only casualty was the stat
|
|
567
|
+
* allowance — every starved finding failing OPEN, delivered at full severity
|
|
568
|
+
* against a path nobody looked at — wrote no row at all. This is that row: one
|
|
569
|
+
* per delivery, naming the cap and every source that hit it, never one per
|
|
570
|
+
* finding.
|
|
571
|
+
*/
|
|
572
|
+
function emitStatBudgetRecord(cwd, memo) {
|
|
573
|
+
if (memo.starved.size === 0)
|
|
574
|
+
return;
|
|
575
|
+
const stores = [...memo.starved.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
576
|
+
logLatency({
|
|
577
|
+
type: "phase",
|
|
578
|
+
phase: "finding_path_stat_budget_exhausted",
|
|
579
|
+
filePath: cwd,
|
|
580
|
+
durationMs: 0,
|
|
581
|
+
metadata: {
|
|
582
|
+
store: stores.join("+"),
|
|
583
|
+
byStore: Object.fromEntries(stores.map((name) => [name, memo.starved.get(name) ?? 0])),
|
|
584
|
+
maxUniquePaths: memo.limit,
|
|
585
|
+
statCount: memo.facts.size,
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
}
|
|
453
589
|
export function provenanceStamp(provenance) {
|
|
454
590
|
if (!isWellFormed(provenance))
|
|
455
591
|
return "session unknown / turn unknown / generation unknown";
|
|
@@ -17,7 +17,7 @@ import * as fs from "node:fs";
|
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import { getProjectDataDir } from "./file-utils.js";
|
|
19
19
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
20
|
-
import { readJsonCache } from "./json-cache-read.js";
|
|
20
|
+
import { readJsonCache, readJsonCacheAsync } from "./json-cache-read.js";
|
|
21
21
|
import { isUnderDir, normalizeMapKey } from "./path-utils.js";
|
|
22
22
|
// --- Defaults ---
|
|
23
23
|
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
|
|
@@ -93,8 +93,69 @@ export class CacheManager {
|
|
|
93
93
|
return state.files[key];
|
|
94
94
|
}
|
|
95
95
|
// ---- Scanner Cache ----
|
|
96
|
+
/**
|
|
97
|
+
* The ONE staleness rule both scanner reads apply (#3274): the envelope's
|
|
98
|
+
* age in ms when it is inside `maxAgeMs`, or `null` — having logged the
|
|
99
|
+
* stale verdict — when it is not.
|
|
100
|
+
*
|
|
101
|
+
* Extracted rather than copied into {@link readCacheAsync} because the TTL
|
|
102
|
+
* boundary is exactly what the two seams must not disagree about while
|
|
103
|
+
* #3300 migrates callers across: a delivery that read one store through
|
|
104
|
+
* each method would otherwise be able to see it fresh and stale at once.
|
|
105
|
+
*/
|
|
106
|
+
freshAgeMs(scanner, meta, maxAgeMs) {
|
|
107
|
+
const age = Date.now() - new Date(meta.timestamp).getTime();
|
|
108
|
+
if (age > maxAgeMs) {
|
|
109
|
+
this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return age;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Read a scanner cache entry WITHOUT blocking the event loop (#3274).
|
|
116
|
+
*
|
|
117
|
+
* `readCache` below is synchronous: two `existsSync` plus two
|
|
118
|
+
* `readFileSync`+`JSON.parse`, all of which complete during ARGUMENT
|
|
119
|
+
* EVALUATION. `bounded()` (`clients/deadline-utils.ts`) takes a promise, so
|
|
120
|
+
* a hook that wrapped the sync read registered a budget that could never be
|
|
121
|
+
* spent — probed on #3274 with an already-aborted signal and `ms: 0`:
|
|
122
|
+
* `bounded()` returned `undefined` and the read had already parsed its JSON.
|
|
123
|
+
* This sibling suspends on `fs.promises`, so the turn_end budget and the
|
|
124
|
+
* hook's `ctx.signal` can actually abandon it.
|
|
125
|
+
*
|
|
126
|
+
* Same outcomes as the sync seam — an envelope, or `null` for missing,
|
|
127
|
+
* stale, corrupt or unreadable — and the same parser
|
|
128
|
+
* (`readJsonCacheAsync`, the async sibling in `clients/json-cache-read.ts`;
|
|
129
|
+
* there is no second parser). ONE deliberate difference, in the verbose log
|
|
130
|
+
* only: a missing store is reported as `Cache miss: <scanner> — <err>` from
|
|
131
|
+
* the read's own ENOENT instead of a preceding `existsSync` pair, because
|
|
132
|
+
* the pair is half the blocking cost this method exists to remove.
|
|
133
|
+
*/
|
|
134
|
+
async readCacheAsync(scanner, cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
135
|
+
const cachePath = path.join(getCacheDir(cwd), `${scanner}.json`);
|
|
136
|
+
const metaPath = path.join(getCacheDir(cwd), `${scanner}.meta.json`);
|
|
137
|
+
const onReadError = (err) => {
|
|
138
|
+
this.log(`Cache miss: ${scanner} — ${err}`);
|
|
139
|
+
};
|
|
140
|
+
const meta = await readJsonCacheAsync(metaPath, (parsed) => parsed, onReadError);
|
|
141
|
+
if (meta === undefined)
|
|
142
|
+
return null;
|
|
143
|
+
const age = this.freshAgeMs(scanner, meta, maxAgeMs);
|
|
144
|
+
if (age === null)
|
|
145
|
+
return null;
|
|
146
|
+
const data = await readJsonCacheAsync(cachePath, (parsed) => parsed, onReadError);
|
|
147
|
+
if (data === undefined)
|
|
148
|
+
return null;
|
|
149
|
+
this.log(`Cache hit: ${scanner} (age: ${Math.round(age / 1000)}s)`);
|
|
150
|
+
return { data, meta };
|
|
151
|
+
}
|
|
96
152
|
/**
|
|
97
153
|
* Read a scanner cache entry. Returns null if not found or stale.
|
|
154
|
+
*
|
|
155
|
+
* Synchronous, and therefore unboundable on a hook path — see
|
|
156
|
+
* {@link readCacheAsync}, and the `sync-hook-read` population in
|
|
157
|
+
* `tests/config/hook-await-bounds.test.ts` that counts the callers still
|
|
158
|
+
* on it. #3300 migrates them and deletes this method in its last slice.
|
|
98
159
|
*/
|
|
99
160
|
readCache(scanner, cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
100
161
|
const cachePath = path.join(getCacheDir(cwd), `${scanner}.json`);
|
|
@@ -110,11 +171,9 @@ export class CacheManager {
|
|
|
110
171
|
const meta = readJsonCache(metaPath, (parsed) => parsed, onReadError);
|
|
111
172
|
if (meta === undefined)
|
|
112
173
|
return null;
|
|
113
|
-
const age =
|
|
114
|
-
if (age
|
|
115
|
-
this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
|
|
174
|
+
const age = this.freshAgeMs(scanner, meta, maxAgeMs);
|
|
175
|
+
if (age === null)
|
|
116
176
|
return null;
|
|
117
|
-
}
|
|
118
177
|
const data = readJsonCache(cachePath, (parsed) => parsed, onReadError);
|
|
119
178
|
if (data === undefined)
|
|
120
179
|
return null;
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* new-code-duplication finding: two near-identical spawn→collect-stdout
|
|
12
12
|
* blocks in `clients/resource-sampler.ts` collapsed to one shared helper).
|
|
13
13
|
*
|
|
14
|
-
* Deliberately has NO imports beyond `node:child_process` types
|
|
14
|
+
* Deliberately has NO imports beyond `node:child_process` types and
|
|
15
|
+
* `spawn-output-cap.ts` (itself import-free, #3383), so both
|
|
15
16
|
* `clients/instance-reaper.ts` and `clients/resource-sampler.ts` (which
|
|
16
17
|
* `clients/safe-spawn.ts` itself depends on) can import this without risking
|
|
17
|
-
* a circular-import chain.
|
|
18
|
+
* a circular-import chain. That is also why the output bound below reports
|
|
19
|
+
* nothing to the degradation ledger: importing the ledger here would put
|
|
20
|
+
* `safe-spawn.ts`'s own dependency above it in the graph.
|
|
18
21
|
*/
|
|
19
22
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
23
|
+
import { createBoundedOutputSink } from "./spawn-output-cap.js";
|
|
20
24
|
/**
|
|
21
25
|
* Detach a best-effort, fire-and-forget child process from the event loop.
|
|
22
26
|
*
|
|
@@ -64,13 +68,18 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
|
|
|
64
68
|
try {
|
|
65
69
|
const child = nodeSpawn(command, args, options);
|
|
66
70
|
unrefChildAndPipes(child);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
// #3383: bounded, not `out += chunk`. The producer is a process-table
|
|
72
|
+
// query (`ps`, `wmic`, `powershell`) whose output nothing else limits,
|
|
73
|
+
// and the concatenation runs inside a `data` handler where V8's
|
|
74
|
+
// `RangeError: Invalid string length` would be an uncaught exception
|
|
75
|
+
// rather than this promise's result. A truncated table is the ending
|
|
76
|
+
// this contract already documents for a timed-out child: whatever was
|
|
77
|
+
// kept is parsed, and the caller reads `status`.
|
|
78
|
+
const out = createBoundedOutputSink();
|
|
79
|
+
child.stdout?.on("data", (chunk) => out.append(chunk));
|
|
71
80
|
child.once("error", (error) => settle({ stdout: "", status: "spawn-error", error }));
|
|
72
81
|
child.once("close", (code, signal) => settle(code === 0 && signal === null
|
|
73
|
-
? { stdout: out, status: "ok" }
|
|
82
|
+
? { stdout: out.text, status: "ok" }
|
|
74
83
|
: {
|
|
75
84
|
stdout: "",
|
|
76
85
|
status: "exit-error",
|
|
@@ -83,7 +92,7 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
|
|
|
83
92
|
// Settle only AFTER the child's fate is known. Killing and
|
|
84
93
|
// resolving in the same tick bounds the caller and abandons the
|
|
85
94
|
// child, which is the leak this timeout exists to prevent.
|
|
86
|
-
void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out, status: "timeout", timeoutKill }));
|
|
95
|
+
void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out.text, status: "timeout", timeoutKill }));
|
|
87
96
|
}, timeoutMs);
|
|
88
97
|
timer.unref();
|
|
89
98
|
}
|
|
@@ -173,6 +173,8 @@ export const CONFIG_DIAGNOSTIC_CODES = {
|
|
|
173
173
|
PILENS_CFG_0008: "config resolution failed; whole configuration ignored",
|
|
174
174
|
/** A tool key is unknown or names a required, non-disableable tool. */
|
|
175
175
|
PILENS_CFG_0009: "unknown or non-disableable tool config key ignored",
|
|
176
|
+
/** A lower-precedence supported global config file was shadowed by the winner. */
|
|
177
|
+
PILENS_CFG_0010: "lower-precedence global config file shadowed",
|
|
176
178
|
};
|
|
177
179
|
/** The shape every code in the namespace must match. */
|
|
178
180
|
export const CONFIG_DIAGNOSTIC_CODE_PATTERN = /^PILENS_CFG_\d{4}$/;
|