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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Requires: npm install -D madge
|
|
9
9
|
* Docs: https://github.com/pahen/madge
|
|
10
10
|
*/
|
|
11
|
+
import { recordDegradationOnce } from "./degradation-ledger.js";
|
|
11
12
|
import { createSubsystemLogger } from "./extension-log.js";
|
|
12
13
|
import * as fs from "node:fs";
|
|
13
14
|
import * as path from "node:path";
|
|
@@ -36,34 +37,79 @@ export function buildMadgeArgs(target, projectRoot) {
|
|
|
36
37
|
if (fs.existsSync(tsConfig)) {
|
|
37
38
|
args.push("--ts-config", tsConfig);
|
|
38
39
|
}
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// skipped
|
|
42
|
-
//
|
|
43
|
-
|
|
40
|
+
// No `--warning`: madge gates it on `!program.json` (bin/cli.js:195) and this
|
|
41
|
+
// argv always passes `--json`, so the flag is inert; ungated it prints the
|
|
42
|
+
// skipped-file list to STDOUT (lib/output.js), which would corrupt the JSON
|
|
43
|
+
// this reader parses. The skip list is therefore unreachable through any
|
|
44
|
+
// argv this builder emits — `parseMadgeSkips` could only ever return zeros
|
|
45
|
+
// (#3436). The lost visibility is disclosed once per session/root by
|
|
46
|
+
// `recordDegradationOnce` from `parseMadgeCycles`, the one reader both
|
|
47
|
+
// madge lanes pass through, instead of parsed from bytes madge never
|
|
48
|
+
// writes.
|
|
49
|
+
args.push("--json", target);
|
|
44
50
|
return args;
|
|
45
51
|
}
|
|
46
52
|
/**
|
|
47
|
-
*
|
|
48
|
-
* package specifiers (bare names / subpaths like `web-tree-sitter`,
|
|
49
|
-
* `vitest/config`) are expected — madge doesn't traverse node_modules. We flag
|
|
50
|
-
* only **local** skips (relative/absolute paths), which are the ones that could
|
|
51
|
-
* silently drop an internal edge and hide a cycle.
|
|
53
|
+
* The ONE reader of madge's `--circular --json` contract (#3428).
|
|
52
54
|
*
|
|
53
|
-
*
|
|
55
|
+
* madge prints an array of cycles, each cycle an array of member paths —
|
|
56
|
+
* `[["src/a.ts", "src/b.ts"]]` — not a dependency graph. Upstream 8.0.0:
|
|
57
|
+
* `bin/cli.js:258-268` hands `Madge.circular()` (an Array, `lib/api.js:103`) to
|
|
58
|
+
* `output.circular`, which for `--json` is `printJSON(circular)`
|
|
59
|
+
* (`lib/output.js:71-73`). The graph OBJECT is what `--json` prints WITHOUT
|
|
60
|
+
* `--circular`, and `buildMadgeArgs` always passes `--circular`.
|
|
61
|
+
*
|
|
62
|
+
* This function exists because there were two readers and they disagreed: the
|
|
63
|
+
* whole-project scan read the array with `Object.entries`, so every cycle it
|
|
64
|
+
* reported was anchored at the array INDEX (`<root>/0`, a path that does not
|
|
65
|
+
* exist) and every member was resolved against the extension host's cwd. Both
|
|
66
|
+
* callers now share this one reader.
|
|
67
|
+
*
|
|
68
|
+
* `baseDir` is the directory madge's relative members resolve against, which is
|
|
69
|
+
* the TARGET it was pointed at (measured, madge 8.0.0: `--json <root>` prints
|
|
70
|
+
* `src/a.ts`; `--json <root>/src/a.ts` prints `a.ts`), so each caller passes
|
|
71
|
+
* the base its own target implies.
|
|
72
|
+
*
|
|
73
|
+
* Malformed JSON throws, which both callers already treat as "did not parse". A
|
|
74
|
+
* parse that is not an array does NOT throw: it yields zero cycles, so an
|
|
75
|
+
* unexpected shape reports no findings instead of turning a completed scan into
|
|
76
|
+
* a failed one (#2154 — a failed madge scan is a COLD lane in `mode=full`).
|
|
77
|
+
*
|
|
78
|
+
* This reader is also the ONE place both madge lanes (the turn-end
|
|
79
|
+
* `runMadgeSpawn` and the session-start `runScanProject`) turn bytes into
|
|
80
|
+
* findings, so the argv's blind spot is recorded here rather than once per
|
|
81
|
+
* caller (#3436). `projectRoot` is the ledger subject, not the resolve
|
|
82
|
+
* `baseDir`: a single-file target resolves members against its own directory
|
|
83
|
+
* but the gap is a property of the project's scan, disclosed once per
|
|
84
|
+
* session/root.
|
|
54
85
|
*/
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
function parseMadgeCycles(stdout, baseDir, projectRoot) {
|
|
87
|
+
const parsed = JSON.parse(stdout || "[]");
|
|
88
|
+
const cycles = Array.isArray(parsed) ? parsed : [];
|
|
89
|
+
const circular = [];
|
|
90
|
+
const circularFiles = new Set();
|
|
91
|
+
for (const cycle of cycles) {
|
|
92
|
+
const resolvedPaths = cycle.map((f) => path.resolve(baseDir, f));
|
|
93
|
+
for (const f of resolvedPaths) {
|
|
94
|
+
circularFiles.add(f);
|
|
95
|
+
}
|
|
96
|
+
circular.push({
|
|
97
|
+
file: resolvedPaths[0],
|
|
98
|
+
path: resolvedPaths,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// madge's `--warning` is inert under `--json` (bin/cli.js:195 gates it on
|
|
102
|
+
// `!program.json`) and, ungated, prints the skip list to stdout where it
|
|
103
|
+
// would corrupt the JSON this reader parses, so no argv `buildMadgeArgs`
|
|
104
|
+
// emits can report which LOCAL files madge failed to resolve. A skipped
|
|
105
|
+
// local file can hide a real cycle, so the gap is disclosed once per
|
|
106
|
+
// session/root instead of being read as a clean graph (#3436).
|
|
107
|
+
recordDegradationOnce({
|
|
108
|
+
kind: "madge-skip-visibility-unavailable",
|
|
109
|
+
subject: projectRoot,
|
|
110
|
+
reason: "madge --warning is inert under --json; skipped-file visibility is unavailable for this scan",
|
|
111
|
+
});
|
|
112
|
+
return { circular, circularFiles };
|
|
67
113
|
}
|
|
68
114
|
/**
|
|
69
115
|
* Defensive cap on concurrent madge spawns for a single `checkFilesBatch`
|
|
@@ -520,7 +566,6 @@ export class DependencyChecker {
|
|
|
520
566
|
circular: spawnResult.circular.filter((d) => d.file === normalized || d.path.includes(normalized)),
|
|
521
567
|
checked: true,
|
|
522
568
|
cacheHit: false,
|
|
523
|
-
localSkips: spawnResult.localSkips,
|
|
524
569
|
};
|
|
525
570
|
}
|
|
526
571
|
/**
|
|
@@ -542,31 +587,20 @@ export class DependencyChecker {
|
|
|
542
587
|
this.log(`Check error: ${result.error.message}`);
|
|
543
588
|
return { ok: false };
|
|
544
589
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
circular.push({
|
|
557
|
-
file: resolvedPaths[0],
|
|
558
|
-
path: resolvedPaths,
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
const skips = parseMadgeSkips(result.stderr || "");
|
|
562
|
-
if (skips.local.length > 0) {
|
|
563
|
-
this.log(`madge skipped ${skips.local.length} local file(s) (possible silent cycle-miss): ${skips.local.slice(0, 5).join(", ")}`);
|
|
564
|
-
}
|
|
590
|
+
// madge resolves its printed members against the TARGET, which this
|
|
591
|
+
// lane points at a single FILE, so they come back relative to that
|
|
592
|
+
// file's directory — `a.ts`/`b.ts` for a `src/a.ts` target, and
|
|
593
|
+
// `../lib/b.ts` for a cycle that leaves it (measured with madge 8.0.0,
|
|
594
|
+
// see parseMadgeCycles). Resolving them against `projectRoot` instead
|
|
595
|
+
// anchors `<root>/a.ts`, a path that does not exist, and keys the
|
|
596
|
+
// shared `circularFiles` set with it (#3435). `path.dirname` of the
|
|
597
|
+
// target is the file-independent form of "the directory madge printed
|
|
598
|
+
// relative to"; a top-level target degenerates to `projectRoot`.
|
|
599
|
+
const { circular, circularFiles } = parseMadgeCycles(result.stdout || "", path.dirname(normalized), projectRoot);
|
|
565
600
|
return {
|
|
566
601
|
ok: true,
|
|
567
602
|
circular,
|
|
568
603
|
circularFiles,
|
|
569
|
-
localSkips: skips.local.length,
|
|
570
604
|
};
|
|
571
605
|
}
|
|
572
606
|
catch (err) {
|
|
@@ -729,7 +763,6 @@ export class DependencyChecker {
|
|
|
729
763
|
circular: spawnResult.circular.filter((d) => d.file === entry.normalized || d.path.includes(entry.normalized)),
|
|
730
764
|
checked: true,
|
|
731
765
|
cacheHit: false,
|
|
732
|
-
localSkips: spawnResult.localSkips,
|
|
733
766
|
});
|
|
734
767
|
}
|
|
735
768
|
// A batch that learned nothing publishes nothing — advancing the
|
|
@@ -800,25 +833,15 @@ export class DependencyChecker {
|
|
|
800
833
|
this.log(`Scan error: ${result.error.message}`);
|
|
801
834
|
return { circular: [], count: 0 };
|
|
802
835
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const circularFiles =
|
|
807
|
-
for (const [file, deps] of Object.entries(data)) {
|
|
808
|
-
if (Array.isArray(deps) && deps.length > 0) {
|
|
809
|
-
const resolvedFile = path.resolve(file);
|
|
810
|
-
circularFiles.add(resolvedFile);
|
|
811
|
-
circular.push({
|
|
812
|
-
file: resolvedFile,
|
|
813
|
-
path: [resolvedFile, ...deps.map((d) => path.resolve(d))],
|
|
814
|
-
});
|
|
815
|
-
}
|
|
816
|
-
}
|
|
836
|
+
// The scan's target IS `projectRoot`, so that is the base madge's
|
|
837
|
+
// relative members resolve against (#3428: this site used to read the
|
|
838
|
+
// cycle array as a graph and anchor every finding at `<hostCwd>/0`).
|
|
839
|
+
const { circular, circularFiles } = parseMadgeCycles(result.stdout || "", projectRoot, projectRoot);
|
|
817
840
|
this.publishState(gen, circular, circularFiles);
|
|
818
|
-
// #2154: the one madge site that parsed
|
|
819
|
-
// other return here (missing root, no top-level source file,
|
|
820
|
-
// unavailable, spawn error, parse throw) is the SAME empty shape
|
|
821
|
-
// must not be read as "no cycles in this project".
|
|
841
|
+
// #2154: the one madge site that parsed madge's output for this root.
|
|
842
|
+
// Every other return here (missing root, no top-level source file,
|
|
843
|
+
// madge unavailable, spawn error, parse throw) is the SAME empty shape
|
|
844
|
+
// and must not be read as "no cycles in this project".
|
|
822
845
|
return { circular, count: circular.length, analyzed: true };
|
|
823
846
|
}
|
|
824
847
|
catch (err) {
|
|
@@ -118,10 +118,42 @@ export const normalizeMessage = sharedNormalizeMessage;
|
|
|
118
118
|
// file, and the module doc/comments above referencing `relativeFile`, keep
|
|
119
119
|
// working unchanged.
|
|
120
120
|
const relativeFile = sharedRelativeFile;
|
|
121
|
+
/**
|
|
122
|
+
* The SPAN component of a strict anchor: the `lineContentHash` of the
|
|
123
|
+
* diagnostic's OWN line in `content`. `undefined` when there is no line to
|
|
124
|
+
* hash — no content, no line, or a line outside the content's range.
|
|
125
|
+
*
|
|
126
|
+
* Exported for #3183. The widget store's retention lifetime asks the same
|
|
127
|
+
* question a strict anchor does — "is the marked line still in this file?" —
|
|
128
|
+
* so it reuses this derivation rather than re-spelling it; a second spelling is
|
|
129
|
+
* how the store's answer drifts from the filter's (the retention identity's own
|
|
130
|
+
* drift from `computeWeakAnchor`'s parts cost #3158 a round). Note what is NOT
|
|
131
|
+
* here: the cwd/path component `stableFindingId` adds. Both sides of a
|
|
132
|
+
* retention comparison are the same file, so the span alone discriminates
|
|
133
|
+
* exactly as well while needing no `cwd` — which is why the store can ask it at
|
|
134
|
+
* all (see `retentionIdentity` in `clients/widget-state.ts`).
|
|
135
|
+
*/
|
|
136
|
+
export function strictAnchorSpan(content, line) {
|
|
137
|
+
if (content === undefined || line === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
const lines = content.split(/\r?\n/);
|
|
140
|
+
if (line < 1 || line > lines.length)
|
|
141
|
+
return undefined;
|
|
142
|
+
return lineContentHash(lines[line - 1] ?? "");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every span {@link strictAnchorSpan} could produce for `content` — one hash
|
|
146
|
+
* per line (#3183). Membership answers "does the line a mark was made against
|
|
147
|
+
* still exist in this file", wherever it has since moved to.
|
|
148
|
+
*/
|
|
149
|
+
export function strictAnchorSpansIn(content) {
|
|
150
|
+
const spans = new Set();
|
|
151
|
+
for (const line of content.split(/\r?\n/))
|
|
152
|
+
spans.add(lineContentHash(line));
|
|
153
|
+
return spans;
|
|
154
|
+
}
|
|
121
155
|
/** Site-specific anchor — see module doc. Used only for false-positive. */
|
|
122
156
|
export function computeStrictAnchor(args) {
|
|
123
|
-
const lines = args.content?.split(/\r?\n/);
|
|
124
|
-
const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
|
|
125
157
|
return stableFindingId("dd:", {
|
|
126
158
|
cwd: args.cwd,
|
|
127
159
|
filePath: args.filePath,
|
|
@@ -129,7 +161,11 @@ export function computeStrictAnchor(args) {
|
|
|
129
161
|
args.tool ?? "",
|
|
130
162
|
args.rule ?? "",
|
|
131
163
|
normalizeMessage(args.message),
|
|
132
|
-
|
|
164
|
+
// An absent/out-of-range line hashes the empty string, exactly as the
|
|
165
|
+
// pre-#3183 `lines[args.line - 1] ?? ""` fallback did: stable, but less
|
|
166
|
+
// resistant to a same-file/rule/message collision (see
|
|
167
|
+
// `DispositionAnchorArgs.content`).
|
|
168
|
+
strictAnchorSpan(args.content, args.line) ?? lineContentHash(""),
|
|
133
169
|
],
|
|
134
170
|
});
|
|
135
171
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* hold dispatch `Diagnostic`s, `applyLspFindingPolicy` for the probe lane,
|
|
19
19
|
* which holds raw `LSPDiagnostic`s.
|
|
20
20
|
*/
|
|
21
|
-
import { applyDispositions, hasAnyDispositionMarks, } from "../diagnostic-dispositions.js";
|
|
21
|
+
import { applyDispositions, applyDispositionsMultiFile, hasAnyDispositionMarks, } from "../diagnostic-dispositions.js";
|
|
22
22
|
import { loadPiLensProjectConfig } from "../project-lens-config.js";
|
|
23
23
|
import { retagAuxiliaryDiagnostics } from "./auxiliary-lsp.js";
|
|
24
24
|
import { applyInlineSuppressions } from "./inline-suppressions.js";
|
|
@@ -129,6 +129,57 @@ function probeIdentities(canonical, raw) {
|
|
|
129
129
|
export function renderedRuleIdentities(diagnostic) {
|
|
130
130
|
return expandRenderedIdentities([[diagnostic.tool, diagnostic.rule]]);
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* #3246: the identity spellings a mark against a surface that renders NEITHER
|
|
134
|
+
* the tool NOR the rule can carry — the turn-end unresolved-inline-blocker
|
|
135
|
+
* body, whose every line is `formatDiagnostics`' ` L<n>: <message>` (plus an
|
|
136
|
+
* optional fix hint). An agent marking from that text has only the message and
|
|
137
|
+
* the line to give `lens_diagnostic_mark`, whose `tool` AND `rule` parameters
|
|
138
|
+
* are both optional, so the mark's anchor carries neither; a mark made from the
|
|
139
|
+
* widget or `mode=full` carries the full canonical pair for the same finding.
|
|
140
|
+
* Honoring one spelling and not the other is exactly the non-convergence #3088
|
|
141
|
+
* reported, so both go through the SAME expansion `probeIdentities` uses. The
|
|
142
|
+
* bare spelling stays safe for this STOP-tier surface because a `"blocking"`
|
|
143
|
+
* diagnostic can only ever be dropped by the STRICT, content-bound
|
|
144
|
+
* false-positive anchor (`applyDispositions`' F1 rule), which still binds the
|
|
145
|
+
* normalized message and the flagged line's content hash.
|
|
146
|
+
*/
|
|
147
|
+
export function inlineBlockerIdentities(diagnostic) {
|
|
148
|
+
return expandRenderedIdentities([
|
|
149
|
+
[diagnostic.tool, diagnostic.rule],
|
|
150
|
+
[undefined, undefined],
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The whole stack for a turn-end PUSH surface that renders dispatch
|
|
155
|
+
* diagnostics for ONE file — the late-auxiliary drain (#3102) and the
|
|
156
|
+
* late-runner drain (#3248), which are the same shape: a post-freshness-gate
|
|
157
|
+
* `Diagnostic[]`, a `<file>:<line>:<col> [<rule>] <message>` rendering that
|
|
158
|
+
* prints the rule but never the tool, and a suppressed count the caller
|
|
159
|
+
* surfaces once per delivery (#1616).
|
|
160
|
+
*
|
|
161
|
+
* It exists so those two lanes cannot drift apart: this used to be open-coded
|
|
162
|
+
* at the late-auxiliary site, and #3248 would have made a second copy of the
|
|
163
|
+
* identical four arguments — the single-source rule's exact failure shape. The
|
|
164
|
+
* caller still owns its own content read, because the two lanes read on
|
|
165
|
+
* different schedules (the auxiliary drain reads once per FILE across several
|
|
166
|
+
* server pairs; the runner drain reads once per pending runner).
|
|
167
|
+
*
|
|
168
|
+
* `content` is `undefined` when the file could not be read: it becomes `""`,
|
|
169
|
+
* `FindingPolicyOptions.content`'s documented fail-open input, so the STRICT
|
|
170
|
+
* anchor cannot match and nothing is hidden over an I/O error (shape 48).
|
|
171
|
+
*/
|
|
172
|
+
export function applyPushedFindingPolicy(findings, options) {
|
|
173
|
+
const { kept } = applyFindingPolicy(findings, {
|
|
174
|
+
cwd: options.cwd,
|
|
175
|
+
filePath: options.filePath,
|
|
176
|
+
content: options.content ?? "",
|
|
177
|
+
// mtime-cached, so several files in one drain cost one stat.
|
|
178
|
+
policyMap: loadProjectRulePolicyMap(options.cwd),
|
|
179
|
+
identities: renderedRuleIdentities,
|
|
180
|
+
});
|
|
181
|
+
return { kept, suppressed: findings.length - kept.length };
|
|
182
|
+
}
|
|
132
183
|
/** Every rendered `(tool, rule)` spelling plus the `tool`-omitted form of
|
|
133
184
|
* each, de-duplicated, in order. One derivation for both callers. */
|
|
134
185
|
function expandRenderedIdentities(rendered) {
|
|
@@ -220,3 +271,69 @@ export function applyLspFindingPolicy(diagnostics, options) {
|
|
|
220
271
|
};
|
|
221
272
|
return { kept: survivorsOf(kept), inlineKept: survivorsOf(inlineKept) };
|
|
222
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
|
|
276
|
+
* session-scan caches and formats them into advisory/blocker text — a
|
|
277
|
+
* reporting lane parallel to (and, before this fix, entirely bypassing)
|
|
278
|
+
* `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
|
|
279
|
+
* false-positive/won't-fix on one of these findings never suppressed it
|
|
280
|
+
* here, so it re-reported on every turn.
|
|
281
|
+
*
|
|
282
|
+
* Filters `findings` through the SAME anchor derivation the dispatch path
|
|
283
|
+
* and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
|
|
284
|
+
* `diagnostic-dispositions.ts`), keyed off each lane's own canonical
|
|
285
|
+
* `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
|
|
286
|
+
* identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
|
|
287
|
+
* already anchors a mark against, not a second, cloned identity that would
|
|
288
|
+
* silently diverge from what the agent actually marked.
|
|
289
|
+
*
|
|
290
|
+
* Returns the surviving findings plus how many were dropped, so a caller can
|
|
291
|
+
* still surface a "suppressed by disposition: N" trace (the #1616
|
|
292
|
+
* suppressed-bucket rule — a security finding must never vanish with no
|
|
293
|
+
* trace, even when the disposition that dropped it is working as intended).
|
|
294
|
+
*/
|
|
295
|
+
export function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
|
|
296
|
+
if (findings.length === 0)
|
|
297
|
+
return { kept: findings, suppressed: 0 };
|
|
298
|
+
// #3248: every `(tool, rule)` spelling a mark against these findings can
|
|
299
|
+
// carry, not just the canonical one. NONE of these turn-end surfaces prints
|
|
300
|
+
// a tool — knip renders `<file>:<line> — <type>: <name>`, dead-code
|
|
301
|
+
// `unused <kind> <name>`, the security lanes `Potential secret: <rule>` —
|
|
302
|
+
// so an agent marking from what it was SHOWN has no tool to pass, and
|
|
303
|
+
// `lens_diagnostic_mark`'s `tool` parameter is optional. Honouring only the
|
|
304
|
+
// spelling `lens_diagnostics` happens to show is the #3088 non-convergence,
|
|
305
|
+
// and it is fixed HERE, once, for every caller of this helper rather than
|
|
306
|
+
// per lane. Widening is safe for the blocking members because a
|
|
307
|
+
// `semantic: "blocking"` finding can still only be dropped by the STRICT,
|
|
308
|
+
// content-bound false-positive anchor, which keeps binding the normalized
|
|
309
|
+
// message and the flagged line's content.
|
|
310
|
+
// The expanded candidate is a `DispositionCandidate` with a path, NOT a
|
|
311
|
+
// `ProjectDiagnostic`: the tool-omitted spelling has no `tool`, which that
|
|
312
|
+
// type requires. Only the anchor derivation reads it.
|
|
313
|
+
const candidates = findings.flatMap((finding) => {
|
|
314
|
+
const diagnostic = toDiagnostic(finding);
|
|
315
|
+
return renderedRuleIdentities(diagnostic).map((identity) => ({
|
|
316
|
+
finding,
|
|
317
|
+
candidate: {
|
|
318
|
+
filePath: diagnostic.filePath,
|
|
319
|
+
message: diagnostic.message,
|
|
320
|
+
...(diagnostic.line !== undefined && { line: diagnostic.line }),
|
|
321
|
+
...(diagnostic.semantic !== undefined && {
|
|
322
|
+
semantic: diagnostic.semantic,
|
|
323
|
+
}),
|
|
324
|
+
...(identity.tool !== undefined && { tool: identity.tool }),
|
|
325
|
+
...(identity.rule !== undefined && { rule: identity.rule }),
|
|
326
|
+
},
|
|
327
|
+
}));
|
|
328
|
+
});
|
|
329
|
+
const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.candidate), cwd, (d) => d.filePath));
|
|
330
|
+
const disposed = new Set();
|
|
331
|
+
for (const candidate of candidates) {
|
|
332
|
+
if (!survivors.has(candidate.candidate))
|
|
333
|
+
disposed.add(candidate.finding);
|
|
334
|
+
}
|
|
335
|
+
const kept = disposed.size === 0
|
|
336
|
+
? findings
|
|
337
|
+
: findings.filter((finding) => !disposed.has(finding));
|
|
338
|
+
return { kept, suppressed: findings.length - kept.length };
|
|
339
|
+
}
|
|
@@ -12,8 +12,14 @@ export function detectIndentation(content) {
|
|
|
12
12
|
const spaceCounts = lines
|
|
13
13
|
.map((line) => line.match(/^ +(?=\S)/)?.[0].length ?? 0)
|
|
14
14
|
.filter((count) => count > 0);
|
|
15
|
-
if (tabs === 0 && spaceCounts.length === 0)
|
|
16
|
-
|
|
15
|
+
if (tabs === 0 && spaceCounts.length === 0) {
|
|
16
|
+
// The formatter gate uses the shared hasDetectableIndentation predicate. If
|
|
17
|
+
// that predicate fired but structural masking removed every candidate line,
|
|
18
|
+
// there is no style to pin and the caller must take the #3038 skip valve
|
|
19
|
+
// instead of imposing the default width. Keep the historical default for
|
|
20
|
+
// content with no raw indentation, whose callers never enter this path.
|
|
21
|
+
return hasDetectableIndentation(content) ? undefined : DEFAULT_INDENTATION;
|
|
22
|
+
}
|
|
17
23
|
if (tabs > spaceCounts.length)
|
|
18
24
|
return { style: "tab", width: 1 };
|
|
19
25
|
if (spaceCounts.length > tabs) {
|
|
@@ -11,6 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { normalizeRuleId } from "./rule-id-normalize.js";
|
|
13
13
|
const SUPPRESS_RE = /(?:\/\/|#)\s*pi-lens-ignore:\s*(.+)/;
|
|
14
|
+
const TRAILING_REASON_RE = /(?:\s+--\s+|\s+—\s+|:\s+).+$/u;
|
|
15
|
+
export function splitInlineSuppressionPayload(payload) {
|
|
16
|
+
const reasonMatch = TRAILING_REASON_RE.exec(payload);
|
|
17
|
+
const trailingReason = reasonMatch?.[0];
|
|
18
|
+
if (reasonMatch === null || trailingReason === undefined) {
|
|
19
|
+
return { ruleList: payload, trailingReason: "" };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ruleList: payload.slice(0, reasonMatch.index),
|
|
23
|
+
trailingReason,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function parseInlineSuppressionRuleIds(payload) {
|
|
27
|
+
const { ruleList } = splitInlineSuppressionPayload(payload);
|
|
28
|
+
return ruleList
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((rule) => rule.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
14
33
|
/**
|
|
15
34
|
* Normalize a rule id to the form a user writes in a `pi-lens-ignore` comment.
|
|
16
35
|
* The napi scan and the ast-grep LSP tag the same rule as `ast-grep:<id>` /
|
|
@@ -37,10 +56,10 @@ export function applyInlineSuppressions(diagnostics, content) {
|
|
|
37
56
|
const m = SUPPRESS_RE.exec(lines[i]);
|
|
38
57
|
if (!m)
|
|
39
58
|
continue;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
const payload = m[1];
|
|
60
|
+
if (payload === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
const rules = parseInlineSuppressionRuleIds(payload);
|
|
44
63
|
const suppressedLine = i + 1; // same line (1-based)
|
|
45
64
|
const nextLine = i + 2; // next line (1-based)
|
|
46
65
|
for (const ruleId of rules) {
|
|
@@ -15,22 +15,29 @@
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { minimatch } from "../deps/minimatch.js";
|
|
17
17
|
import { buildEffectiveAstGrepCatalog } from "./ast-grep-catalog.js";
|
|
18
|
+
import { isWindowsPath, toPosix } from "../path-utils.js";
|
|
18
19
|
/**
|
|
19
20
|
* True when `filePath` is carved out of a rule by one of its glob `patterns`.
|
|
20
21
|
*
|
|
21
22
|
* The glob is matched against `filePath` relative to `root`, forward-slashed.
|
|
22
23
|
* Falls back to the absolute (slash-normalized) path when `filePath` isn't
|
|
23
|
-
* under `root` (e.g. an out-of-tree temp file)
|
|
24
|
-
*
|
|
24
|
+
* under `root` (e.g. an out-of-tree temp file). Leaf-file patterns retain that
|
|
25
|
+
* fallback for sinks such as a double-star logger leaf; directory carve-outs
|
|
26
|
+
* ending in a slash plus double-star remain root-contained so a project's CLI
|
|
27
|
+
* exemption cannot suppress a finding in an unrelated out-of-tree file.
|
|
25
28
|
*/
|
|
26
29
|
export function isRuleIgnoredForPath(filePath, root, patterns) {
|
|
27
30
|
if (!patterns || patterns.length === 0)
|
|
28
31
|
return false;
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const pathApi = isWindowsPath(root) || isWindowsPath(filePath) ? path.win32 : path.posix;
|
|
33
|
+
const relative = pathApi.relative(root, filePath);
|
|
34
|
+
const outsideRoot = relative !== "" &&
|
|
35
|
+
(relative === ".." ||
|
|
36
|
+
relative.startsWith(`..${pathApi.sep}`) ||
|
|
37
|
+
pathApi.isAbsolute(relative));
|
|
38
|
+
const displayPath = toPosix(outsideRoot ? filePath : relative);
|
|
39
|
+
return patterns.some((pattern) => (!outsideRoot || !pattern.replaceAll("\\", "/").endsWith("/**")) &&
|
|
40
|
+
minimatch(displayPath, pattern, { dot: true }));
|
|
34
41
|
}
|
|
35
42
|
/**
|
|
36
43
|
* Rule id → its `ignores` globs, for the effective ast-grep catalog at `root`.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { pathsEqual } from "../../path-utils.js";
|
|
2
3
|
import { safeSpawnAsync } from "../../safe-spawn.js";
|
|
3
4
|
import { resolveRunnerCwd } from "../../tool-cwd.js";
|
|
4
5
|
import { PRIORITY } from "../priorities.js";
|
|
5
6
|
import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
|
|
7
|
+
import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
|
|
6
8
|
const actionlint = createAvailabilityChecker("actionlint", ".exe");
|
|
7
9
|
export function isGitHubWorkflowFile(filePath) {
|
|
8
10
|
const normalized = filePath.replace(/\\/g, "/");
|
|
@@ -31,14 +33,18 @@ function toDiagnostic(issue, filePath) {
|
|
|
31
33
|
matchedText: issue.snippet,
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
|
-
export function parseActionlintJson(raw, filePath) {
|
|
36
|
+
export function parseActionlintJson(raw, filePath, cwd) {
|
|
35
37
|
const trimmed = raw.trim();
|
|
36
38
|
if (!trimmed)
|
|
37
39
|
return [];
|
|
40
|
+
// #3295: actionlint resolves reusable-workflow and composite-action refs, so
|
|
41
|
+
// its `filepath` is not always the workflow we asked about.
|
|
42
|
+
const absTarget = path.resolve(cwd, filePath);
|
|
43
|
+
const isTarget = (issue) => !issue.filepath || pathsEqual(path.resolve(cwd, issue.filepath), absTarget);
|
|
38
44
|
try {
|
|
39
45
|
const parsed = JSON.parse(trimmed);
|
|
40
46
|
const issues = Array.isArray(parsed) ? parsed : [parsed];
|
|
41
|
-
return issues.
|
|
47
|
+
return issues.flatMap((issue) => isTarget(issue) ? [toDiagnostic(issue, filePath)] : []);
|
|
42
48
|
}
|
|
43
49
|
catch {
|
|
44
50
|
// Some actionlint versions or wrappers may emit one JSON object per line.
|
|
@@ -48,6 +54,8 @@ export function parseActionlintJson(raw, filePath) {
|
|
|
48
54
|
continue;
|
|
49
55
|
try {
|
|
50
56
|
const parsed = JSON.parse(line);
|
|
57
|
+
if (!isTarget(parsed))
|
|
58
|
+
continue;
|
|
51
59
|
diagnostics.push(toDiagnostic(parsed, filePath));
|
|
52
60
|
}
|
|
53
61
|
catch {
|
|
@@ -82,32 +90,22 @@ const actionlintRunner = {
|
|
|
82
90
|
cwd,
|
|
83
91
|
timeout: 15000,
|
|
84
92
|
});
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
];
|
|
102
|
-
}
|
|
103
|
-
if (diagnostics.length === 0) {
|
|
104
|
-
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
status: "failed",
|
|
108
|
-
diagnostics,
|
|
109
|
-
semantic: "blocking",
|
|
110
|
-
};
|
|
93
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
94
|
+
const parsed = parseToolRun("actionlint", {
|
|
95
|
+
result,
|
|
96
|
+
output,
|
|
97
|
+
// EXIT TABLE (actionlint 1.7.7 measured fixture): 0 clean; 1 findings; 2 error; other nonzero rejected.
|
|
98
|
+
exitCodes: { ran: [1, 2] },
|
|
99
|
+
}, (raw) => parseActionlintJson(raw, ctx.filePath, cwd));
|
|
100
|
+
if (parsed.skipped)
|
|
101
|
+
return parsed.skipped;
|
|
102
|
+
return finishParsedRun({
|
|
103
|
+
tool: "actionlint",
|
|
104
|
+
ctx,
|
|
105
|
+
result,
|
|
106
|
+
diagnostics: parsed.diagnostics,
|
|
107
|
+
classify: () => ({ status: "failed", semantic: "blocking" }),
|
|
108
|
+
});
|
|
111
109
|
},
|
|
112
110
|
};
|
|
113
111
|
export default actionlintRunner;
|