pi-lens 3.8.41 → 3.8.42

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +36 -15
  3. package/clients/cache-manager.ts +3 -0
  4. package/clients/dispatch/integration.ts +176 -56
  5. package/clients/dispatch/plan.ts +13 -2
  6. package/clients/dispatch/rules/error-swallowing.ts +2 -2
  7. package/clients/dispatch/rules/quality-rules.ts +14 -4
  8. package/clients/dispatch/rules/sonar-rules.ts +36 -8
  9. package/clients/dispatch/runners/oxlint.ts +51 -10
  10. package/clients/dispatch/runners/tree-sitter.ts +21 -10
  11. package/clients/file-utils.ts +32 -0
  12. package/clients/formatters.ts +9 -0
  13. package/clients/installer/index.ts +123 -1
  14. package/clients/knip-client.ts +1 -4
  15. package/clients/lsp/index.ts +45 -15
  16. package/clients/path-utils.ts +15 -3
  17. package/clients/pipeline.ts +5 -1
  18. package/clients/read-guard-tool-lines.ts +1 -1
  19. package/clients/runtime-config.ts +5 -0
  20. package/clients/runtime-context.ts +3 -3
  21. package/clients/runtime-session.ts +30 -10
  22. package/clients/runtime-tool-result.ts +2 -1
  23. package/clients/runtime-turn.ts +100 -109
  24. package/clients/source-filter.ts +3 -2
  25. package/clients/test-runner-client.ts +99 -6
  26. package/clients/tool-policy.ts +39 -1
  27. package/clients/tree-sitter-client.ts +25 -4
  28. package/clients/widget-state.ts +1 -1
  29. package/commands/booboo.ts +14 -2
  30. package/index.ts +73 -16
  31. package/package.json +3 -3
  32. package/rules/tree-sitter-queries/go/go-command-injection.yml +2 -2
  33. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +2 -2
  34. package/rules/tree-sitter-queries/go/go-sql-injection.yml +2 -2
  35. package/rules/tree-sitter-queries/go/go-weak-hash.yml +2 -2
  36. package/rules/tree-sitter-queries/python/python-command-injection.yml +2 -2
  37. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +1 -1
  38. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +2 -2
  39. package/rules/tree-sitter-queries/python/python-sql-injection.yml +2 -2
  40. package/rules/tree-sitter-queries/python/python-weak-hash.yml +2 -2
  41. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +2 -2
  42. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +2 -2
  43. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +2 -2
  44. package/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml +55 -0
  45. package/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml +1 -1
  46. package/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml +54 -0
  47. package/rules/tree-sitter-queries/typescript/ts-open-redirect.yml +111 -0
  48. package/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml +6 -3
  49. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +2 -2
  50. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +2 -2
  51. package/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml +100 -0
  52. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +3 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.42] - 2026-05-08
8
+
9
+ ### Added
10
+
11
+ - **Fact-rules wired into all language dispatch plans** — the `fact-rules` runner was registered but never listed in any `RunnerGroup`; 20 TypeScript FactRule instances (`corsWildcardRule`, `jwtWithoutVerifyRule`, `dynamicRegexpRule`, `errorObscuringRule`, `highComplexityRule`, etc.) were never executing. Added `mode:all fact-rules` group to jsts, python, go, rust, ruby, cmake, and shell write plans.
12
+ - **3 fact-rules promoted to blocking (inline at write time):** `cors-wildcard` (CORS `*` origin — no ast-grep/tree-sitter equivalent), `error-swallowing` (empty catch — smarter than the disabled tree-sitter `empty-catch`, skips fs-boundary and documented fallbacks), `no-commented-credentials` (credentials in commented code — complementary to ast-grep which covers live code). `high-entropy-string` was already blocking.
13
+ - **Fact-rule false-positive reductions:** `no-boolean-params` now exempts names with `*Only`/`*Enabled`/`*Disabled` suffixes, `allow*`/`skip*`/`needs*`/`auto*` prefixes, and `_`-prefixed params. `duplicate-string-literal` SKIP_STRINGS expanded with DSL discriminators (`types`, `fallback`, `direct`, `all`, `mode`, `source`) and infrastructure strings (`github`, `rubocop`, `arm64`). `high-import-coupling` threshold raised 10→15 and exempts `index.ts`/`integration.ts` registry/hub files. `no-commented-credentials` exempts scanner/fixture files.
14
+ - **Severity alignment for 3 existing TS tree-sitter blocking rules** — `ts-command-injection`, `ts-ssrf`, `unsafe-regex` had `inline_tier: blocking` but `severity: warning`, producing `semantic: "warning"` which is never shown inline. Fixed to `severity: error` → `semantic: "blocking"` → actually surfaces to the agent.
15
+ - **Fixed `inline_tier: error` typo** on `ts-hallucinated-react-import` and `python-hallucinated-import` (→ `blocking`).
16
+ - **13 new high-confidence blocking promotions across 5 languages** (all `severity: error`, `inline_tier: blocking`):
17
+ - *TypeScript:* `ts-weak-hash` (`createHash("md5"/"sha1")` — confidence: high)
18
+ - *Python:* `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`
19
+ - *Go:* `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash`
20
+ - *Ruby:* `ruby-weak-hash`
21
+ - *Rust:* `rust-lock-held-across-await`
22
+ - **4 new blocking tree-sitter rules (SonarCloud BLOCKER equivalents)**:
23
+ - `ts-xss-dom-sink` (S5696) — flags dynamic values assigned to `innerHTML`/`outerHTML` or passed to `document.write()` / `document.writeln()`
24
+ - `ts-dynamic-require` (S5335) — flags `require()` called with a non-string-literal argument (arbitrary module loading)
25
+ - `ts-open-redirect` (S6105) — flags `res.redirect(variable)` / `response.redirect` / `ctx.redirect` with dynamic URL, and `window.location.href = variable`
26
+ - `ts-nosql-injection` (S5147) — flags any MongoDB `$where` key (JS-execution sink, dangerous regardless of value)
27
+ - **2 existing security rules promoted to `inline_tier: blocking`** — `ts-command-injection` (maps to SonarCloud S2076) and `ts-ssrf` (maps to S5146) were previously `warning`; now block the agent turn on detection.
28
+
29
+ ### Fixed
30
+
31
+ - **`fact-rules` `RuleCache` blind to built-in rule changes** — the cache hash only covered project-local rule files; for any project with no local `rules/` directory the hash was a constant, so new pi-lens built-in rules were silently ignored after the first run. Fixed by including both project-local files and `resolvePackagePath()`-resolved built-in files in the hash, with a `Set` to deduplicate when pi-lens analyzes itself.
32
+
33
+ ### Changed
34
+
35
+ - **`max-switch-cases` threshold raised 30→40** — `applyPostFilter` dispatch table now has 31 cases and is expected to grow; the old threshold triggered a false positive on pi-lens itself.
36
+ - **Package scope migration** — all `@mariozechner/*` import references updated to `@earendil-works/*` following the repo move to `earendil-works/pi-mono`. `@earendil-works/pi-tui` dependency bumped to `^0.74.0`.
37
+ - **Startup: `lsp-config` phase is now fully fire-and-forget** — `loadLSPConfig` and `igniteWarmFiles` no longer block the interactive path, removing ~1s from session start on Windows (previously dominated by sequential ENOENT `readFile` calls walking the directory tree to find a config file).
38
+ - **Startup: persistent tool probe cache** — `ensureTool` now checks `~/.pi-lens/probe-cache.json` before falling back to the full `verifyToolBinary` process spawn. Cache entries are validated with `fs.access` + mtime check and expire after 24 h; stale or missing entries fall through to the full probe and update the cache on success.
39
+
40
+ ### Added
41
+
42
+ - **Startup observability** — `checkProbeCache` now logs the reason for each cache miss (`ttl expired`, `gone`, `mtime changed`); the lsp-config fire-and-forget callback logs how many warm files were configured once the config resolves asynchronously.
43
+
44
+
45
+
46
+ ### Added
47
+
48
+ - **Test runner: import-based fallback discovery** — when basename pattern lookup finds no test file for a modified source file (e.g. `cline.test.ts` for `cline-auth.ts`), the runner now scans `tests/`, `__tests__/`, and the source file's own directory for any `*.test.*` file whose content references the source basename in an import path. Fixes the silent `no test file found` for files whose test is named after a module rather than the source file.
49
+ - **Test runner: prefer local `node_modules/.bin` binary over `npx`** — `vitest` and `jest` now resolve the project-local binary (`node_modules/.bin/vitest.cmd` on Windows, `node_modules/.bin/vitest` on Unix) before falling back to `npx`, saving ~150ms of startup overhead per test run.
50
+ - **Turn-end test runner logging** — `turn_end` now logs the outcome of every test run: `turn_end: test vitest util.test.ts → PASS 8p/0f (412ms)` or `FAIL 2p/8f (930ms)`. Stale results (turn advanced while tests ran) are logged with a `[stale]` prefix instead of being silently discarded. All-pass turns are no longer silent.
51
+ - **Per-file test target logging** — `turn_end` now logs which test file was resolved for each modified source file, or `no test file found` when none matched. Previously silent; impossible to distinguish "runner disabled" from "no test found".
52
+ - **Session-scoped turn-end dedup** — `turn-end-findings-last` now stores the current session ID alongside the content signature. Identical findings from a previous session are no longer suppressed — each new session sees its blockers fresh. Same-session dedup continues to work as before.
53
+ - **Cross-session turn state eviction** — turn state (modified file ranges) now carries the session ID set at first edit. If `turn_end` reads a turn state written by a different session, it evicts it immediately and logs `turn_end: evicting stale turn state (session X ≠ current Y)`, preventing stale cross-session file lists from triggering jscpd, madge, or test runs.
54
+
55
+ ### Changed
56
+
57
+ - **Context injections framed as automated checks** — all three `consume*` injections (`turn-end findings`, `test findings`, `session guidance`) now prefix their content with `[pi-lens automated check — not a user request]` so the agent cannot mistake a hook-injected message for a direct user command. Advisory sections additionally carry `ℹ️ Advisory — no action required this turn:` before their content; blockers (🔴) continue to require action.
58
+
59
+ - **`/lens-widget-toggle` command** — toggles the pi-lens diagnostics widget below the editor on/off for the current session, so users can reclaim footer/editor space without disabling pi-lens analysis.
60
+
61
+ ### Changed
62
+
63
+ - **Removed per-turn jscpd scans** — jscpd remains in the session-start project scan, but no longer runs unconditionally at `turn_end`; inline structural-similarity checks cover the high-value duplicate-code signal during active edits without the repeated multi-second clone scan.
64
+ - **Cascade avoids low-value work** — unsupported graph kinds now skip review-graph construction and go straight to passive LSP fallback diagnostics, and neighbor files that recently returned clean can skip repeated active LSP touches for a few turns unless the passive snapshot already contains fresh errors.
65
+ - **Knip now surfaces unused-export regressions** — newly unused exports in modified files are shown as advisory end-of-turn findings when they were absent from the previous Knip cache.
66
+
67
+ ### Fixed
68
+
69
+ - **Knip latency log now includes result metadata** — the `turn_end` Knip phase previously logged only duration with empty `metadata: {}`, making it impossible to distinguish a clean run from a silent failure. It now logs `success`, `totalIssues`, `newIssues`, `blockerIssues`, and `skipped` when the startup scan is still in flight.
70
+
71
+ - **LSP timeout log now includes `serverIds`** — `lsp_client_wait_timeout` previously only recorded `maxWaitMs`, making it impossible to identify which server consistently failed to respond within the budget. The event now includes the array of server IDs that were being waited on.
72
+
73
+ - **Vendor/third-party files excluded from cascade neighbor analysis** — `isExternalOrVendorFile()` previously only checked `node_modules`; it now checks every path segment against `vendor`, `vendors`, `third_party`, and `third-party` as well. Cascade neighbor discovery and fallback neighbor injection both skip files inside these directories, preventing vendored dependency diagnostics from surfacing in cascade output.
74
+
75
+ - **`lens-booboo` hangs on repos with large vendored trees (fixes #57)** — `collectSourceFiles` and the `sg scan` runner in `lens-booboo` now exclude `vendor/`, `third_party/`, `third-party/`, and `vendors/` by default (added to `EXCLUDED_DIRS`). Additionally, `readGitignoreDirs()` reads the root `.gitignore` and extracts simple directory-name entries (bare names and `name/` patterns — no wildcards, negations, or internal slashes), merging them into the exclusion list for `collectSourceFiles` and the `sg scan` glob arguments. This covers project-specific large dirs (e.g. `my-upstream/`) without requiring full gitignore-spec compliance.
76
+
7
77
  ## [3.8.41] - 2026-05-05
8
78
 
9
79
  ### Fixed
package/README.md CHANGED
@@ -124,25 +124,45 @@ Supported: TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, Ruby.
124
124
 
125
125
  ### Fact Rules Pipeline
126
126
 
127
- Covers JavaScript/TypeScript, Python, Go, Rust, Ruby, Shell, and CMake. Dispatch includes a fact-rule engine that extracts function-level metrics (cyclomatic complexity, nesting depth, outgoing calls) and evaluates quality rules inline:
128
-
129
- - **high-complexity** flags functions exceeding configurable CC thresholds
130
- - **unsafe-boundary** — detects dangerous boundary crossings (unvalidated user input → trusted context)
131
- - **high-fan-out** — flags excessive outgoing call count (default threshold: 20)
132
- - **comment-facts** — classifies comment quality (TODO density, doc coverage)
133
- - **try-catch-facts** — flags empty/obscuring catch blocks
134
- - **import-facts** — detects circular/star/unused imports
135
- - **file-role** classifies files as source/test/config/vendor and adjusts severity
127
+ Covers JavaScript/TypeScript, Python, Go, Rust, Ruby, Shell, and CMake. A TypeScript AST-based fact-rule engine extracts function-level metrics and evaluates quality and security rules inline. Blocking rules surface immediately at write time; advisory rules are available via `/lens-booboo`.
128
+
129
+ **Blocking (surface inline at write time):**
130
+ - **cors-wildcard** — `Access-Control-Allow-Origin: *` in server-side code
131
+ - **error-swallowing** — empty catch block (skips documented local fallbacks and fs-boundary catches)
132
+ - **no-commented-credentials** — password/token/secret in commented-out code
133
+ - **high-entropy-string** — string literals with suspiciously high Shannon entropy (possible hardcoded secret)
134
+
135
+ **Advisory (accessible via `/lens-booboo`):**
136
+ - **high-complexity** / **no-complex-conditionals** — cyclomatic complexity and deeply nested conditions
137
+ - **high-fan-out** — function calls too many distinct functions (coordination smell)
138
+ - **unsafe-boundary** — dangerous `any` casts at API boundaries
139
+ - **async-noise** / **async-unnecessary-wrapper** — async functions with no await; wrappers that add no value
140
+ - **pass-through-wrappers** — trivial wrapper functions
141
+ - **dynamic-regexp** — `new RegExp(variable)` (potential ReDoS; complements tree-sitter `unsafe-regex`)
142
+ - **jwt-without-verify** — `jwt.sign()` without `jwt.verify()` in the same file
143
+ - **missing-error-propagation** — catch blocks that log but don't rethrow
144
+ - **error-obscuring** — catch blocks that wrap errors in a different type
145
+ - **duplicate-string-literal** / **no-boolean-params** / **high-import-coupling** — code-quality signals
136
146
 
137
147
  ### Tree-sitter Rules
138
148
 
139
- Structural rules are organized by language in `rules/tree-sitter-queries/`:
149
+ Structural rules organized by language in `rules/tree-sitter-queries/`. Rules marked **🔴** block the agent inline at write time (only for lines in the current edit); others are advisory.
150
+
151
+ **TypeScript (23 rules):**
152
+ 🔴 `eval`, `sql-injection`, `ts-command-injection`, `ts-ssrf`, `ts-xss-dom-sink`, `ts-dynamic-require`, `ts-open-redirect`, `ts-nosql-injection`, `ts-weak-hash`, `ts-hallucinated-react-import`, `unsafe-regex`, `debugger`, `default-not-last`, `duplicate-function-arg`, `empty-switch-case`, `infinite-loop`, `self-assignment`, `switch-case-termination`
153
+ ⚠️ `console-statement`, `deep-promise-chain`, `mixed-async-styles`, `ts-insecure-random`, `ts-detached-async-call`, `ts-react-antipatterns`, `ts-weak-hash`, `variable-shadowing`
154
+
155
+ **Python:** 🔴 `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`, `python-hallucinated-import` + 20 advisory rules
156
+
157
+ **Go:** 🔴 `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash` + 13 advisory rules
158
+
159
+ **Rust:** 🔴 `rust-lock-held-across-await` + 3 advisory rules (`rust-unsafe-block`, `rust-expect`, `rust-clone-in-loop`)
160
+
161
+ **Ruby:** 🔴 `ruby-weak-hash` + 14 advisory rules
162
+
163
+ **Suppressing a finding:** add `// pi-lens-ignore: rule-id` on the flagged line or the line above (JS/TS), or `# pi-lens-ignore: rule-id` for Python/Ruby/Shell. This suppresses that specific rule at that location only.
140
164
 
141
- - **TypeScript** (18 rules): console-statement, debugger, deep-nesting, eval, sql-injection, ssrf, weak-hash, unsafe-regex, variable-shadowing, and more
142
- - **Python** (26 rules): debug statements, hardcoded secrets, mutable class attrs, unsafe regex, empty except, and more
143
- - **Go** (17 rules): defer-in-loop, hardcoded secrets, unchecked errors, and more
144
- - **Rust** (6 rules): unsafe blocks, unwrap outside tests, and more
145
- - **Ruby** (15 rules): empty rescue, rescue Exception, debugger, hardcoded secrets, and more
165
+ **Project-wide disabling** is not currently supported through config — there is no `.pi-lens/disabled-rules` file. Use inline suppression for per-occurrence overrides. When editing pi-lens itself, move a rule file to the `<language>-disabled/` directory to prevent it from running.
146
166
 
147
167
  ### Ast-Grep Rules
148
168
 
@@ -268,6 +288,7 @@ pi --lens-semgrep-config p/ci # Explicit Semgrep config for dispatch (requires
268
288
  ## Key Commands
269
289
 
270
290
  - `/lens-toggle` — toggle pi-lens on/off for the current session without restarting
291
+ - `/lens-widget-toggle` — show/hide the pi-lens diagnostics widget below the editor
271
292
  - `/lens-booboo` — full quality report for current project state
272
293
  - `/lens-health` — runtime health, latency, and diagnostic telemetry
273
294
  - `/lens-tools` — tool installation status: globally installed, auto-installed, or npx fallback
@@ -43,6 +43,7 @@ export interface TurnState {
43
43
  turnCycles: number;
44
44
  maxCycles: number;
45
45
  lastUpdated: string;
46
+ sessionId?: string;
46
47
  }
47
48
 
48
49
  // --- Defaults ---
@@ -250,8 +251,10 @@ export class CacheManager {
250
251
  range: ModifiedRange,
251
252
  importsChanged: boolean,
252
253
  cwd: string,
254
+ sessionId?: string,
253
255
  ): TurnState {
254
256
  const state = this.readTurnState(cwd);
257
+ if (sessionId) state.sessionId = sessionId;
255
258
  const normalizedPath = this.toTurnStateKey(filePath, cwd);
256
259
 
257
260
  const existing = state.files[normalizedPath];
@@ -48,7 +48,7 @@ import type { CascadeResult } from "../cascade-types.js";
48
48
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
49
49
  import { getServersForFileWithConfig } from "../lsp/config.js";
50
50
  import { getLSPService } from "../lsp/index.js";
51
- import { normalizeMapKey } from "../path-utils.js";
51
+ import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
52
52
  import {
53
53
  clearReviewGraphWorkspaceCache,
54
54
  getLastGraphBuildInfo,
@@ -325,7 +325,8 @@ function withSemgrepGroup(
325
325
  config: ctx.pi.getFlag("lens-semgrep-config"),
326
326
  });
327
327
  if (!config.enabled) return groups;
328
- if (groups.some((group) => group.runnerIds.includes("semgrep"))) return groups;
328
+ if (groups.some((group) => group.runnerIds.includes("semgrep")))
329
+ return groups;
329
330
  return [
330
331
  ...groups,
331
332
  {
@@ -402,16 +403,29 @@ export function resetDispatchBaselines(): void {
402
403
  clearCoverageNoticeState();
403
404
  clearReviewGraphWorkspaceCache();
404
405
  neighborTouchCache.clear();
406
+ recentlyCleanNeighborCache.clear();
405
407
  primaryFilesThisTurn.clear();
406
408
  cascadeDiagnosticBaselines.clear();
407
- cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
409
+ cascadeSessionStats = {
410
+ runs: 0,
411
+ diagnosticsSurfaced: 0,
412
+ coldSnapshotTouches: 0,
413
+ };
408
414
  for (const timer of astGrepWarnDebounceTimers.values()) clearTimeout(timer);
409
415
  astGrepWarnDebounceTimers.clear();
410
416
  }
411
417
 
412
- let cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
418
+ let cascadeSessionStats = {
419
+ runs: 0,
420
+ diagnosticsSurfaced: 0,
421
+ coldSnapshotTouches: 0,
422
+ };
413
423
 
414
- export function getCascadeSessionStats(): { runs: number; diagnosticsSurfaced: number; coldSnapshotTouches: number } {
424
+ export function getCascadeSessionStats(): {
425
+ runs: number;
426
+ diagnosticsSurfaced: number;
427
+ coldSnapshotTouches: number;
428
+ } {
415
429
  return { ...cascadeSessionStats };
416
430
  }
417
431
 
@@ -425,6 +439,16 @@ type NeighborCacheEntry = {
425
439
  };
426
440
  const neighborTouchCache = new Map<string, NeighborCacheEntry>();
427
441
 
442
+ // Cross-turn clean cache: neighbor touches that recently returned no errors can
443
+ // be skipped for a few turns. LSP servers push diagnostics proactively when a
444
+ // file becomes unhealthy, so repeatedly re-opening known-clean neighbors is low value.
445
+ type RecentlyCleanNeighborEntry = { turnSeq: number; checkedAt: number };
446
+ const recentlyCleanNeighborCache = new Map<
447
+ string,
448
+ RecentlyCleanNeighborEntry
449
+ >();
450
+ const RECENTLY_CLEAN_TTL_TURNS = 5;
451
+
428
452
  // B10: tracks files that were the *primary* edited file this turn.
429
453
  // These are excluded from cascade neighbor results — their own pipeline run
430
454
  // already reported their diagnostics authoritatively.
@@ -436,11 +460,17 @@ function ensureCascadeTurnScope(turnSeq: number): void {
436
460
  cascadeTurnScope = turnSeq;
437
461
  primaryFilesThisTurn.clear();
438
462
  neighborTouchCache.clear();
463
+ for (const [key, entry] of recentlyCleanNeighborCache) {
464
+ if (turnSeq - entry.turnSeq > RECENTLY_CLEAN_TTL_TURNS) {
465
+ recentlyCleanNeighborCache.delete(key);
466
+ }
467
+ }
439
468
  }
440
469
 
441
470
  const CASCADE_TTL_MS = 240_000;
442
471
  const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
443
472
  const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
473
+ const CASCADE_GRAPH_KINDS = new Set(["jsts", "python", "go", "rust", "ruby"]);
444
474
 
445
475
  /**
446
476
  * Unified cascade orchestration — builds graph, discovers neighbors, and
@@ -477,7 +507,8 @@ export async function computeCascadeForFile(
477
507
  return undefined;
478
508
  }
479
509
 
480
- if (!detectFileKind(filePath)) {
510
+ const fileKind = detectFileKind(filePath);
511
+ if (!fileKind) {
481
512
  logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
482
513
  return undefined;
483
514
  }
@@ -489,54 +520,87 @@ export async function computeCascadeForFile(
489
520
  // turn won't show it as a neighbor.
490
521
  primaryFilesThisTurn.add(normalizedFileKey);
491
522
 
492
- const graphStart = Date.now();
493
- const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
494
- const graphMs = Date.now() - graphStart;
495
-
496
- // Count files represented in the graph (nodes with a filePath).
497
- const graphFileCount = new Set(
498
- [...graph.nodes.values()].flatMap((n) => (n.filePath ? [n.filePath] : [])),
499
- ).size;
523
+ let impact: ReturnType<typeof computeImpactCascade> = {
524
+ filePath: normalizedFile,
525
+ changedSymbols: [],
526
+ directImporters: [],
527
+ directCallers: [],
528
+ neighborFiles: [],
529
+ riskFlags: [],
530
+ };
531
+ let sortedNeighbors: string[] = [];
532
+ let importerSet = new Set<string>();
533
+ let callerSet = new Set<string>();
534
+ let referenceCount = 0;
535
+
536
+ if (CASCADE_GRAPH_KINDS.has(fileKind)) {
537
+ const graphStart = Date.now();
538
+ const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
539
+ const graphMs = Date.now() - graphStart;
540
+
541
+ // Count files represented in the graph (nodes with a filePath).
542
+ const graphFileCount = new Set(
543
+ [...graph.nodes.values()].flatMap((n) =>
544
+ n.filePath ? [n.filePath] : [],
545
+ ),
546
+ ).size;
500
547
 
501
- const graphBuildInfo = getLastGraphBuildInfo();
502
- logCascade({
503
- phase: "graph_build",
504
- filePath,
505
- graphBuiltMs: graphMs,
506
- graphReused: graphBuildInfo.reused,
507
- graphNodeCount: graph.nodes.size,
508
- graphFileCount,
509
- graphChangedSymbolCount: (
510
- graph.changedSymbolsByFile.get(normalizedFileKey) ?? []
511
- ).length,
512
- metadata: { graphBuildMode: graphBuildInfo.mode },
513
- });
548
+ const graphBuildInfo = getLastGraphBuildInfo();
549
+ logCascade({
550
+ phase: "graph_build",
551
+ filePath,
552
+ graphBuiltMs: graphMs,
553
+ graphReused: graphBuildInfo.reused,
554
+ graphNodeCount: graph.nodes.size,
555
+ graphFileCount,
556
+ graphChangedSymbolCount: (
557
+ graph.changedSymbolsByFile.get(normalizedFileKey) ?? []
558
+ ).length,
559
+ metadata: { graphBuildMode: graphBuildInfo.mode },
560
+ });
514
561
 
515
- const impact = computeImpactCascade(graph, normalizedFile);
516
-
517
- // Sort by relationship strength (B6) then cap to MAX_FILES.
518
- // directImporters are most impactful, then callers, then reference edges.
519
- const importerSet = new Set(impact.directImporters);
520
- const callerSet = new Set(impact.directCallers);
521
- // neighbors that are neither direct importers nor callers are reference-edge neighbors
522
- const importerOrCallerSet = new Set([
523
- ...impact.directImporters,
524
- ...impact.directCallers,
525
- ]);
526
- const referenceCount = impact.neighborFiles.filter(
527
- (n) => !importerOrCallerSet.has(n),
528
- ).length;
529
- const sortedNeighbors = [...impact.neighborFiles]
530
- .filter((n) => nodeFs.existsSync(n))
531
- // B10: exclude files already edited as primary this turn — their own pipeline
532
- // run is the authoritative diagnostic source; showing them as neighbors is noise.
533
- .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
534
- .sort((a, b) => {
535
- const rank = (p: string) =>
536
- importerSet.has(p) ? 0 : (callerSet.has(p) ? 1 : 2);
537
- return rank(a) - rank(b);
538
- })
539
- .slice(0, MAX_FILES);
562
+ impact = computeImpactCascade(graph, normalizedFile);
563
+
564
+ // Sort by relationship strength (B6) then cap to MAX_FILES.
565
+ // directImporters are most impactful, then callers, then reference edges.
566
+ importerSet = new Set(impact.directImporters);
567
+ callerSet = new Set(impact.directCallers);
568
+ // neighbors that are neither direct importers nor callers are reference-edge neighbors
569
+ const importerOrCallerSet = new Set([
570
+ ...impact.directImporters,
571
+ ...impact.directCallers,
572
+ ]);
573
+ referenceCount = impact.neighborFiles.filter(
574
+ (n) => !importerOrCallerSet.has(n),
575
+ ).length;
576
+ sortedNeighbors = [...impact.neighborFiles]
577
+ .filter((n) => nodeFs.existsSync(n))
578
+ .filter((n) => !isExternalOrVendorFile(n, cwd))
579
+ // B10: exclude files already edited as primary this turn their own pipeline
580
+ // run is the authoritative diagnostic source; showing them as neighbors is noise.
581
+ .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
582
+ .sort((a, b) => {
583
+ const rank = (p: string) =>
584
+ importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
585
+ return rank(a) - rank(b);
586
+ })
587
+ .slice(0, MAX_FILES);
588
+ } else {
589
+ logCascade({
590
+ phase: "graph_build",
591
+ filePath,
592
+ graphBuiltMs: 0,
593
+ graphReused: false,
594
+ graphNodeCount: 0,
595
+ graphFileCount: 0,
596
+ graphChangedSymbolCount: 0,
597
+ metadata: {
598
+ graphBuildMode: "skipped",
599
+ reason: "unsupported_kind",
600
+ fileKind,
601
+ },
602
+ });
603
+ }
540
604
 
541
605
  logCascade({
542
606
  phase: "neighbors_computed",
@@ -634,6 +698,41 @@ export async function computeCascadeForFile(
634
698
  const neighborStart = Date.now();
635
699
  const cacheKey = normalizeMapKey(neighborPath);
636
700
 
701
+ const passiveEntry = allDiags.get(cacheKey);
702
+ const hasFreshPassiveErrors =
703
+ passiveEntry != null &&
704
+ Date.now() - passiveEntry.ts < CASCADE_TTL_MS &&
705
+ passiveEntry.diags.some((d) => d.severity === 1);
706
+ const recentlyClean = recentlyCleanNeighborCache.get(cacheKey);
707
+ if (
708
+ recentlyClean &&
709
+ turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
710
+ !hasFreshPassiveErrors
711
+ ) {
712
+ producedLspData = true;
713
+ const durationMs = Date.now() - neighborStart;
714
+ logCascade({
715
+ phase: "neighbor_snapshot",
716
+ filePath,
717
+ neighborFile: neighborPath,
718
+ diagnosticCount: 0,
719
+ durationMs,
720
+ autoPropagate: false,
721
+ snapshotMissing: false,
722
+ metadata: {
723
+ recentlyClean: true,
724
+ cleanTurnSeq: recentlyClean.turnSeq,
725
+ },
726
+ });
727
+ return {
728
+ filePath: neighborPath,
729
+ reason: neighborReason(importerSet, callerSet, neighborPath),
730
+ diagnostics: [],
731
+ lspTouched: false,
732
+ durationMs,
733
+ } satisfies CascadeResult["neighbors"][number];
734
+ }
735
+
637
736
  // A5: skip re-touch if this neighbor was already diagnosed at the current
638
737
  // write sequence. A new write (higher writeSeq) invalidates the cache entry.
639
738
  const cached =
@@ -704,6 +803,14 @@ export async function computeCascadeForFile(
704
803
  diagnostics: diags,
705
804
  });
706
805
  }
806
+ if (diags.length === 0) {
807
+ recentlyCleanNeighborCache.set(cacheKey, {
808
+ turnSeq,
809
+ checkedAt: Date.now(),
810
+ });
811
+ } else {
812
+ recentlyCleanNeighborCache.delete(cacheKey);
813
+ }
707
814
  producedLspData = true;
708
815
 
709
816
  logCascade({
@@ -769,7 +876,7 @@ export async function computeCascadeForFile(
769
876
  // CR-3/A2: degraded fallback when no neighbor produced trustworthy LSP data —
770
877
  // not merely when the graph returned zero neighbors.
771
878
  if (!producedLspData) {
772
- appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey);
879
+ appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd);
773
880
  if (neighbors.some((n) => n.reason === "fallback")) {
774
881
  logCascade({
775
882
  phase: "neighbor_fallback",
@@ -867,6 +974,7 @@ function appendFallbackNeighbors(
867
974
  { diags: import("../lsp/client.js").LSPDiagnostic[]; ts: number }
868
975
  >,
869
976
  normalizedFileKey: string,
977
+ cwd: string,
870
978
  ): void {
871
979
  const now = Date.now();
872
980
  const seen = new Set(neighbors.map((n) => normalizeMapKey(n.filePath)));
@@ -874,6 +982,7 @@ function appendFallbackNeighbors(
874
982
  const diagKey = normalizeMapKey(diagPath);
875
983
  if (diagKey === normalizedFileKey || seen.has(diagKey)) continue;
876
984
  if (primaryFilesThisTurn.has(diagKey)) continue;
985
+ if (isExternalOrVendorFile(diagPath, cwd)) continue;
877
986
  if (!nodeFs.existsSync(diagPath)) continue;
878
987
  if (now - ts > CASCADE_TTL_MS) continue;
879
988
  const errors = convertLspDiagnostics(
@@ -921,7 +1030,10 @@ function formatCascadeResult(
921
1030
  });
922
1031
  if (!diagnosticsBlock) return "";
923
1032
 
924
- const impactHeader = formatImpactCascade(impact, RUNTIME_CONFIG.pipeline.cascadeMaxFiles);
1033
+ const impactHeader = formatImpactCascade(
1034
+ impact,
1035
+ RUNTIME_CONFIG.pipeline.cascadeMaxFiles,
1036
+ );
925
1037
  let out = impactHeader
926
1038
  ? `${impactHeader}\n${diagnosticsBlock}`
927
1039
  : diagnosticsBlock;
@@ -972,7 +1084,11 @@ export async function dispatchLint(
972
1084
  const kind = ctx.kind;
973
1085
  if (!kind) return "";
974
1086
 
975
- const groups = withSemgrepGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
1087
+ const groups = withSemgrepGroup(
1088
+ kind,
1089
+ getDispatchGroupsForKind(kind, pi),
1090
+ ctx,
1091
+ );
976
1092
  if (groups.length === 0) return "";
977
1093
 
978
1094
  await runProviders(ctx);
@@ -1015,7 +1131,11 @@ export async function dispatchLintWithResult(
1015
1131
  };
1016
1132
  }
1017
1133
 
1018
- const groups = withSemgrepGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
1134
+ const groups = withSemgrepGroup(
1135
+ kind,
1136
+ getDispatchGroupsForKind(kind, pi),
1137
+ ctx,
1138
+ );
1019
1139
  if (groups.length === 0) {
1020
1140
  return {
1021
1141
  diagnostics: [],
@@ -35,6 +35,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
35
35
  writeGroups: [
36
36
  primary("jsts"),
37
37
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["jsts"] },
38
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["jsts"] },
38
39
  { mode: "all", runnerIds: ["ast-grep-napi"], filterKinds: ["jsts"] },
39
40
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
40
41
  {
@@ -59,6 +60,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
59
60
  { mode: "fallback", runnerIds: ["ruff-lint"], filterKinds: ["python"] },
60
61
  { mode: "fallback", runnerIds: ["mypy"], filterKinds: ["python"] },
61
62
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["python"] },
63
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["python"] },
62
64
  ],
63
65
  fullOnlyGroups: [
64
66
  { mode: "fallback", runnerIds: ["python-slop"], filterKinds: ["python"] },
@@ -72,6 +74,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
72
74
  { mode: "fallback", runnerIds: ["go-vet"], filterKinds: ["go"] },
73
75
  { mode: "fallback", runnerIds: ["golangci-lint"], filterKinds: ["go"] },
74
76
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["go"] },
77
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["go"] },
75
78
  ],
76
79
  },
77
80
  rust: {
@@ -81,6 +84,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
81
84
  primary("rust"),
82
85
  { mode: "fallback", runnerIds: ["rust-clippy"], filterKinds: ["rust"] },
83
86
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["rust"] },
87
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["rust"] },
84
88
  ],
85
89
  },
86
90
  ruby: {
@@ -90,6 +94,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
90
94
  primary("ruby"),
91
95
  { mode: "fallback", runnerIds: ["rubocop"], filterKinds: ["ruby"] },
92
96
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["ruby"] },
97
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["ruby"] },
93
98
  ],
94
99
  },
95
100
  cxx: {
@@ -100,12 +105,18 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
100
105
  cmake: {
101
106
  name: "CMake Processing",
102
107
  capabilities: ["lint"],
103
- writeGroups: [primary("cmake")],
108
+ writeGroups: [
109
+ primary("cmake"),
110
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["cmake"] },
111
+ ],
104
112
  },
105
113
  shell: {
106
114
  name: "Shell Script Linting",
107
115
  capabilities: ["lint", "security"],
108
- writeGroups: [primary("shell")],
116
+ writeGroups: [
117
+ primary("shell"),
118
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["shell"] },
119
+ ],
109
120
  },
110
121
  json: {
111
122
  name: "JSON Processing",
@@ -24,8 +24,8 @@ export const errorSwallowingRule: FactRule = {
24
24
  filePath: ctx.filePath,
25
25
  line: s.line,
26
26
  column: s.column,
27
- severity: "warning",
28
- semantic: "warning",
27
+ severity: "error",
28
+ semantic: "blocking",
29
29
  message: `Empty catch block silently swallows errors`,
30
30
  });
31
31
  }
@@ -129,8 +129,10 @@ export const noMagicNumbersRule: FactRule = {
129
129
 
130
130
  // ---------- QR-002: no-boolean-params ----------
131
131
 
132
- // Names prefixed with is/has/should/can/was/did are clearly boolean — skip them
133
- const BOOLEAN_PREFIX_OK = /^(is|has|should|can|was|did|will|are|use)[A-Z_]/;
132
+ // Names that unambiguously communicate boolean intent — skip them
133
+ const BOOLEAN_PREFIX_OK = /^(is|has|should|can|was|did|will|are|use|allow|skip|needs|wait|want|auto|show|hide|keep|ignore|include|exclude)[A-Z_]/;
134
+ const BOOLEAN_SUFFIX_OK = /(Only|Enabled|Disabled|Allowed|Changed|Available|Active|Silent|Strict|Required|Blocking|Verbose|Force|Lazy|Auto)$/;
135
+ const BOOLEAN_WHOLE_OK = /^(enabled|disabled|silent|verbose|blocking|strict|force|lazy|allow|allowed|required|active)$/i;
134
136
 
135
137
  export const noBooleanParamsRule: FactRule = {
136
138
  id: "no-boolean-params",
@@ -147,7 +149,12 @@ export const noBooleanParamsRule: FactRule = {
147
149
  if (!param.type) continue;
148
150
  const name =
149
151
  ts.isIdentifier(param.name) ? param.name.text : "";
150
- if (BOOLEAN_PREFIX_OK.test(name)) continue;
152
+ if (
153
+ BOOLEAN_PREFIX_OK.test(name) ||
154
+ BOOLEAN_SUFFIX_OK.test(name) ||
155
+ BOOLEAN_WHOLE_OK.test(name) ||
156
+ name.startsWith("_")
157
+ ) continue;
151
158
 
152
159
  const isBoolean =
153
160
  param.type.kind === ts.SyntaxKind.BooleanKeyword ||
@@ -192,13 +199,16 @@ export const noBooleanParamsRule: FactRule = {
192
199
 
193
200
  // ---------- QR-003: high-import-coupling ----------
194
201
 
195
- const IMPORT_COUPLING_THRESHOLD = 10;
202
+ const IMPORT_COUPLING_THRESHOLD = 15;
203
+ // Registry/hub files are intentionally wide — they import everything by design
204
+ const IMPORT_COUPLING_EXEMPT = /[/\\](index|integration)\.[cm]?tsx?$/;
196
205
 
197
206
  export const highImportCouplingRule: FactRule = {
198
207
  id: "high-import-coupling",
199
208
  requires: ["file.imports"],
200
209
  appliesTo: tsFile,
201
210
  evaluate(ctx, store) {
211
+ if (IMPORT_COUPLING_EXEMPT.test(ctx.filePath)) return [];
202
212
  const imports = store.getFileFact<ImportEntry[]>(ctx.filePath, "file.imports") ?? [];
203
213
  // Count distinct module sources
204
214
  const sources = new Set(imports.map((i) => i.source));