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.
- package/CHANGELOG.md +70 -0
- package/README.md +36 -15
- package/clients/cache-manager.ts +3 -0
- package/clients/dispatch/integration.ts +176 -56
- package/clients/dispatch/plan.ts +13 -2
- package/clients/dispatch/rules/error-swallowing.ts +2 -2
- package/clients/dispatch/rules/quality-rules.ts +14 -4
- package/clients/dispatch/rules/sonar-rules.ts +36 -8
- package/clients/dispatch/runners/oxlint.ts +51 -10
- package/clients/dispatch/runners/tree-sitter.ts +21 -10
- package/clients/file-utils.ts +32 -0
- package/clients/formatters.ts +9 -0
- package/clients/installer/index.ts +123 -1
- package/clients/knip-client.ts +1 -4
- package/clients/lsp/index.ts +45 -15
- package/clients/path-utils.ts +15 -3
- package/clients/pipeline.ts +5 -1
- package/clients/read-guard-tool-lines.ts +1 -1
- package/clients/runtime-config.ts +5 -0
- package/clients/runtime-context.ts +3 -3
- package/clients/runtime-session.ts +30 -10
- package/clients/runtime-tool-result.ts +2 -1
- package/clients/runtime-turn.ts +100 -109
- package/clients/source-filter.ts +3 -2
- package/clients/test-runner-client.ts +99 -6
- package/clients/tool-policy.ts +39 -1
- package/clients/tree-sitter-client.ts +25 -4
- package/clients/widget-state.ts +1 -1
- package/commands/booboo.ts +14 -2
- package/index.ts +73 -16
- package/package.json +3 -3
- package/rules/tree-sitter-queries/go/go-command-injection.yml +2 -2
- package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +2 -2
- package/rules/tree-sitter-queries/go/go-sql-injection.yml +2 -2
- package/rules/tree-sitter-queries/go/go-weak-hash.yml +2 -2
- package/rules/tree-sitter-queries/python/python-command-injection.yml +2 -2
- package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +1 -1
- package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +2 -2
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +2 -2
- package/rules/tree-sitter-queries/python/python-weak-hash.yml +2 -2
- package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +2 -2
- package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +2 -2
- package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +2 -2
- package/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml +55 -0
- package/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml +1 -1
- package/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml +54 -0
- package/rules/tree-sitter-queries/typescript/ts-open-redirect.yml +111 -0
- package/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml +6 -3
- package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +2 -2
- package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +2 -2
- package/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml +100 -0
- 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.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
- **
|
|
131
|
-
- **
|
|
132
|
-
- **
|
|
133
|
-
- **
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
- **
|
|
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
|
package/clients/cache-manager.ts
CHANGED
|
@@ -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")))
|
|
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 = {
|
|
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 = {
|
|
418
|
+
let cascadeSessionStats = {
|
|
419
|
+
runs: 0,
|
|
420
|
+
diagnosticsSurfaced: 0,
|
|
421
|
+
coldSnapshotTouches: 0,
|
|
422
|
+
};
|
|
413
423
|
|
|
414
|
-
export function getCascadeSessionStats(): {
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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: [],
|
package/clients/dispatch/plan.ts
CHANGED
|
@@ -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: [
|
|
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: [
|
|
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: "
|
|
28
|
-
semantic: "
|
|
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
|
|
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 (
|
|
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 =
|
|
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));
|