pi-lens 3.8.42 → 3.8.43
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 +19 -0
- package/clients/dispatch/dispatcher.ts +3 -1
- package/clients/dispatch/integration.ts +2 -0
- package/clients/dispatch/runners/oxlint.ts +8 -7
- package/clients/dispatch/runners/tree-sitter.ts +7 -0
- package/clients/dispatch/types.ts +6 -0
- package/clients/lsp/client.ts +16 -1
- package/clients/lsp/index.ts +49 -3
- package/clients/lsp/server-strategies.ts +7 -0
- package/clients/lsp/server.ts +6 -7
- package/clients/pipeline.ts +56 -2
- package/clients/review-graph/builder.ts +80 -7
- package/clients/runtime-coordinator.ts +20 -0
- package/clients/runtime-tool-result.ts +7 -0
- package/clients/runtime-turn.ts +8 -0
- package/clients/tree-sitter-client.ts +59 -0
- package/index.ts +1 -1
- package/package.json +1 -1
- package/rules/tree-sitter-queries/typescript/incomplete-assertion.yml +50 -0
- package/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml +53 -0
- package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to pi-lens will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.8.43] - 2026-05-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Unresolved inline blocker re-surfacing at turn_end** — when the agent ignores a blocking diagnostic shown during a write/edit and moves to the next turn without fixing it, the blocker now reappears in the turn_end injection framed as `"Unresolved from this turn — <file>: 🔴 STOP…"`. Previously, unresolved inline blockers were silently lost until cascade happened to re-touch the same file via an importer. `RuntimeCoordinator` tracks the last-seen blocking output per file (`_pendingInlineBlockers`); a subsequent write that produces no blockers clears the entry, so only genuinely unresolved issues resurface. The map is cleared at `beginTurn` to prevent cross-turn contamination.
|
|
12
|
+
- **S1219 (switch non-case labels) and S2970 (incomplete assertions) blocking tree-sitter rules** — S1219 detects labeled statements inside switch cases in TypeScript (SonarCloud S1219); S2970 detects Jest/Vitest `expect()` chains that are never called (e.g. `expect(x).toBe(y)` without `await`), with Chai property assertion exclusion. S2083 (path traversal) moved to disabled — regex heuristics on tree-sitter syntax are the wrong layer; needs taint/data-flow analysis. Adds `parent?` field to `TreeSitterNode` interface.
|
|
13
|
+
- **Inline code snippets in blocker output** — each 🔴 STOP diagnostic now includes the exact source line the agent wrote that caused the violation, so the agent can identify and fix the issue without re-reading the file. `fixSuggestion` is also surfaced inline when present. Snippet capped at 120 chars.
|
|
14
|
+
- **AST node type and matched text in blocker output** — tree-sitter diagnostics now carry `matchedText` (the exact matched node, more precise than the full source line) and `astNodeType` (e.g. `call_expression`, `template_string`). The agent sees: `L12: SQL query built with string interpolation (template_string) → db.query(...)`.
|
|
15
|
+
- **Persist review graph to disk** — `_workspaceGraphCache` is now backed by `.pi-lens/cache/review-graph.json`. On cold start, if source file signatures match the stored cache, the full 2–4 s tree-sitter + import-fact build is skipped (~20 ms JSON parse + `rebuildIndexes` instead). Write is fire-and-forget, never blocks dispatch.
|
|
16
|
+
- **Preserve last known LSP diagnostics when LSP goes inactive** — when no live clients are available (dead client respawning, circuit-breaker cooldown), `getDiagnostics` now returns the last non-empty result for that file instead of `[]`. The widget keeps showing the last known issues rather than going blank mid-session. Live clients returning `[]` clears the stale entry. Stale hits are logged as `failureKind: "no_clients_stale"`.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Read-guard false-positive block on files outside the project root** — edits to files outside `projectRoot` (e.g. `C:/llama/*.bat`, scripts in arbitrary directories) were always blocked with `zero_read` because reads for external files are intentionally not recorded (`isExternalOrVendor` gate in the read handler), but the `checkEdit` call had no matching guard. Added `!isExternalOrVendor` to the `checkEdit` condition so external files bypass the read-guard entirely, consistent with how reads are handled.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Replace pyright-langserver and pylsp with jedi-language-server for Python LSP** — `PythonServer` (pyright-langserver) and `PythonPylspServer` (pylsp) removed from `LSP_SERVERS`; replaced by `PythonJediServer` which spawns `jedi-language-server`. pyright-langserver was causing 5–14 s cold-start delays on large Python projects (e.g. tinygrad) because it performs full workspace analysis on startup; jedi starts in ~200–500 ms via lazy per-file analysis. pylsp was removed because it consistently returned 0 diagnostics (no venv → jedi can't resolve imports; 1500 ms aggregate timeout hit on warm runs). Deep type checking is unaffected — the standalone `pyright` CLI runner and `mypy` runner continue to run in parallel. Added `"python-jedi"` strategy entry (`seedFirstPush: true`, `aggregateWaitMs: 1000`). Wall-clock gate for Python dispatch shifts from LSP (~5–14 s) to mypy (~3.5 s).
|
|
25
|
+
|
|
7
26
|
## [3.8.42] - 2026-05-08
|
|
8
27
|
|
|
9
28
|
### Added
|
|
@@ -742,7 +742,8 @@ export async function dispatchForFile(
|
|
|
742
742
|
|
|
743
743
|
// Format output — only blocking issues shown inline
|
|
744
744
|
// Warnings tracked but not shown (noise) — surfaced via /lens-booboo
|
|
745
|
-
|
|
745
|
+
const blockerOutput = formatDiagnostics(inlineBlockers, "blocking");
|
|
746
|
+
let output = blockerOutput;
|
|
746
747
|
output += formatDiagnostics(inlineFixed, "fixed");
|
|
747
748
|
if (coverageNotice) {
|
|
748
749
|
output += formatDiagnostics([coverageNotice], "warning", 1);
|
|
@@ -807,6 +808,7 @@ export async function dispatchForFile(
|
|
|
807
808
|
fixed: fixedItems,
|
|
808
809
|
resolvedCount,
|
|
809
810
|
output,
|
|
811
|
+
blockerOutput,
|
|
810
812
|
hasBlockers: blockers.length > 0,
|
|
811
813
|
};
|
|
812
814
|
}
|
|
@@ -1127,6 +1127,7 @@ export async function dispatchLintWithResult(
|
|
|
1127
1127
|
fixed: [],
|
|
1128
1128
|
resolvedCount: 0,
|
|
1129
1129
|
output: "",
|
|
1130
|
+
blockerOutput: "",
|
|
1130
1131
|
hasBlockers: false,
|
|
1131
1132
|
};
|
|
1132
1133
|
}
|
|
@@ -1145,6 +1146,7 @@ export async function dispatchLintWithResult(
|
|
|
1145
1146
|
fixed: [],
|
|
1146
1147
|
resolvedCount: 0,
|
|
1147
1148
|
output: "",
|
|
1149
|
+
blockerOutput: "",
|
|
1148
1150
|
hasBlockers: false,
|
|
1149
1151
|
};
|
|
1150
1152
|
}
|
|
@@ -22,12 +22,10 @@ import type {
|
|
|
22
22
|
RunnerResult,
|
|
23
23
|
} from "../types.js";
|
|
24
24
|
import {
|
|
25
|
-
|
|
25
|
+
resolveToolCommand,
|
|
26
26
|
resolveToolCommandWithInstallFallback,
|
|
27
27
|
} from "./utils/runner-helpers.js";
|
|
28
28
|
|
|
29
|
-
const oxlint = createAvailabilityChecker("oxlint", ".exe");
|
|
30
|
-
|
|
31
29
|
function resolveLocalVp(cwd: string): string | null {
|
|
32
30
|
const isWin = process.platform === "win32";
|
|
33
31
|
let dir = cwd;
|
|
@@ -81,11 +79,14 @@ const oxlintRunner: RunnerDefinition = {
|
|
|
81
79
|
}
|
|
82
80
|
if (cmd) {
|
|
83
81
|
args = ["lint", "--format", "unix", ctx.filePath];
|
|
84
|
-
} else if (oxlint.isAvailable(cwd)) {
|
|
85
|
-
cmd = oxlint.getCommand(cwd);
|
|
86
|
-
args = ["--format", "unix", ctx.filePath];
|
|
87
82
|
} else {
|
|
88
|
-
|
|
83
|
+
// Use ctx.hasTool for async availability check — avoids the synchronous
|
|
84
|
+
// spawnSync probe that blocks the event loop on first call per cwd.
|
|
85
|
+
// FactStore caches the result for the session so subsequent writes are free.
|
|
86
|
+
const oxlintCmd = resolveToolCommand(cwd, "oxlint") ?? "oxlint";
|
|
87
|
+
cmd = (await ctx.hasTool(oxlintCmd))
|
|
88
|
+
? oxlintCmd
|
|
89
|
+
: await resolveToolCommandWithInstallFallback(cwd, "oxlint");
|
|
89
90
|
args = ["--format", "unix", ctx.filePath];
|
|
90
91
|
}
|
|
91
92
|
if (!cmd) {
|
|
@@ -449,6 +449,11 @@ const treeSitterRunner: RunnerDefinition = {
|
|
|
449
449
|
const queryResults: Diagnostic[][] = [];
|
|
450
450
|
|
|
451
451
|
for (let i = 0; i < effectiveQueries.length; i += CONCURRENCY_LIMIT) {
|
|
452
|
+
// Yield the event loop between batches so already-resolved promises from
|
|
453
|
+
// other parallel groups (LSP, eslint skip, etc.) can drain. Each wasm query
|
|
454
|
+
// batch is synchronous CPU work that would otherwise pin the event loop for
|
|
455
|
+
// the full runner duration, making other runners' latency measurements wrong.
|
|
456
|
+
if (i > 0) await new Promise<void>((r) => setImmediate(r));
|
|
452
457
|
const batch = effectiveQueries.slice(i, i + CONCURRENCY_LIMIT);
|
|
453
458
|
const batchResults = await Promise.all(
|
|
454
459
|
batch.map(async (query) => {
|
|
@@ -519,6 +524,8 @@ const treeSitterRunner: RunnerDefinition = {
|
|
|
519
524
|
autoFixAvailable: false,
|
|
520
525
|
fixKind: hasSuggestedFix ? "suggestion" : undefined,
|
|
521
526
|
fixSuggestion: suggestion,
|
|
527
|
+
matchedText: match.matchedText || undefined,
|
|
528
|
+
astNodeType: match.nodeType || undefined,
|
|
522
529
|
});
|
|
523
530
|
}
|
|
524
531
|
} catch (err) {
|
|
@@ -84,6 +84,10 @@ export interface Diagnostic {
|
|
|
84
84
|
fixKind?: "pipeline" | "manual" | "suggestion";
|
|
85
85
|
/** Auto-fix command/suggestion */
|
|
86
86
|
fixSuggestion?: string;
|
|
87
|
+
/** Exact matched text from tree-sitter (more precise than the full source line) */
|
|
88
|
+
matchedText?: string;
|
|
89
|
+
/** Tree-sitter AST node type of the match (e.g. "call_expression", "template_string") */
|
|
90
|
+
astNodeType?: string;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
export interface DispatchResult {
|
|
@@ -101,6 +105,8 @@ export interface DispatchResult {
|
|
|
101
105
|
resolvedCount: number;
|
|
102
106
|
/** Formatted output for display */
|
|
103
107
|
output: string;
|
|
108
|
+
/** Blocking-only portion of output (without auto-fix section) — for turn_end re-surfacing */
|
|
109
|
+
blockerOutput: string;
|
|
104
110
|
/** Whether any blockers were found */
|
|
105
111
|
hasBlockers: boolean;
|
|
106
112
|
}
|
package/clients/lsp/client.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { EventEmitter } from "node:events";
|
|
|
13
13
|
import { existsSync } from "node:fs";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import type { MessageConnection } from "vscode-jsonrpc";
|
|
16
|
+
import { logLatency } from "../latency-logger.js";
|
|
16
17
|
import {
|
|
17
18
|
createMessageConnection,
|
|
18
19
|
StreamMessageReader,
|
|
@@ -549,10 +550,24 @@ function setupConnectionLifecycle(state: LSPClientState): void {
|
|
|
549
550
|
disposeClientConnection(state);
|
|
550
551
|
});
|
|
551
552
|
|
|
552
|
-
state.lspProcess.process.on("exit", (
|
|
553
|
+
state.lspProcess.process.on("exit", (code) => {
|
|
554
|
+
const wasConnected = state.isConnected;
|
|
553
555
|
state.isConnected = false;
|
|
554
556
|
state.isDestroyed = true;
|
|
555
557
|
disposeClientConnection(state);
|
|
558
|
+
if (wasConnected) {
|
|
559
|
+
logLatency({
|
|
560
|
+
type: "phase",
|
|
561
|
+
phase: "lsp_server_unexpected_exit",
|
|
562
|
+
filePath: state.root,
|
|
563
|
+
durationMs: 0,
|
|
564
|
+
metadata: {
|
|
565
|
+
serverId: state.serverId,
|
|
566
|
+
pid: state.lspProcess.pid,
|
|
567
|
+
exitCode: code ?? null,
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|
|
556
571
|
});
|
|
557
572
|
}
|
|
558
573
|
|
package/clients/lsp/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface LSPState {
|
|
|
30
30
|
servers: Map<string, LSPServerInfo>;
|
|
31
31
|
broken: Map<string, number>; // servers that failed to initialize with retry-at timestamp
|
|
32
32
|
inFlight: Map<string, Promise<SpawnedServer | undefined>>; // prevent duplicate spawns
|
|
33
|
+
clientSpawnedAt: Map<string, number>; // key: "serverId:root" → epoch ms of last successful spawn
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const BROKEN_BASE_COOLDOWN_MS = 15_000;
|
|
@@ -137,6 +138,15 @@ export class LSPService {
|
|
|
137
138
|
private readonly optionalDisabled = new Set<string>();
|
|
138
139
|
/** Consecutive failure counts for exponential backoff circuit breaker */
|
|
139
140
|
private readonly failureCounts = new Map<string, number>();
|
|
141
|
+
/**
|
|
142
|
+
* Last non-empty diagnostic result per normalized file path.
|
|
143
|
+
* Returned as a fallback when no live LSP clients are available so the
|
|
144
|
+
* widget keeps showing the last known issues rather than going blank.
|
|
145
|
+
*/
|
|
146
|
+
private readonly lastKnownDiagnostics = new Map<
|
|
147
|
+
string,
|
|
148
|
+
import("./client.js").LSPDiagnostic[]
|
|
149
|
+
>();
|
|
140
150
|
private readonly recentTouches = new Map<
|
|
141
151
|
string,
|
|
142
152
|
{ fingerprint: string; touchedAt: number; clientScope: "primary" | "all" }
|
|
@@ -150,6 +160,7 @@ export class LSPService {
|
|
|
150
160
|
servers: new Map(),
|
|
151
161
|
broken: new Map(),
|
|
152
162
|
inFlight: new Map(),
|
|
163
|
+
clientSpawnedAt: new Map(),
|
|
153
164
|
};
|
|
154
165
|
}
|
|
155
166
|
|
|
@@ -287,6 +298,14 @@ export class LSPService {
|
|
|
287
298
|
]);
|
|
288
299
|
|
|
289
300
|
if (!timeoutResult) {
|
|
301
|
+
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
302
|
+
const knownHealth = [...this.state.clients.entries()]
|
|
303
|
+
.filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
|
|
304
|
+
.map(([k, c]) => ({
|
|
305
|
+
serverId: k.split(":")[0],
|
|
306
|
+
alive: c.isAlive(),
|
|
307
|
+
spawnedAt: this.state.clientSpawnedAt.get(k) ?? null,
|
|
308
|
+
}));
|
|
290
309
|
logLatency({
|
|
291
310
|
type: "phase",
|
|
292
311
|
phase: "lsp_client_wait_timeout",
|
|
@@ -295,6 +314,8 @@ export class LSPService {
|
|
|
295
314
|
metadata: {
|
|
296
315
|
maxWaitMs: effectiveMaxWaitMs,
|
|
297
316
|
serverIds: servers.map((s) => s.id),
|
|
317
|
+
// servers absent from knownHealth were never spawned or are still spawning
|
|
318
|
+
knownClientHealth: knownHealth,
|
|
298
319
|
},
|
|
299
320
|
});
|
|
300
321
|
}
|
|
@@ -374,12 +395,26 @@ export class LSPService {
|
|
|
374
395
|
}
|
|
375
396
|
return { client: existing, info: server };
|
|
376
397
|
}
|
|
398
|
+
// Dead client — was previously alive, now needs respawn
|
|
399
|
+
const spawnedAt = this.state.clientSpawnedAt.get(key);
|
|
400
|
+
logLatency({
|
|
401
|
+
type: "phase",
|
|
402
|
+
phase: "lsp_server_respawn",
|
|
403
|
+
filePath,
|
|
404
|
+
durationMs: 0,
|
|
405
|
+
metadata: {
|
|
406
|
+
serverId: server.id,
|
|
407
|
+
root,
|
|
408
|
+
uptimeMs: spawnedAt != null ? Date.now() - spawnedAt : null,
|
|
409
|
+
},
|
|
410
|
+
});
|
|
377
411
|
try {
|
|
378
412
|
await existing.shutdown();
|
|
379
413
|
} catch {
|
|
380
414
|
/* ignore dead client shutdown errors */
|
|
381
415
|
}
|
|
382
416
|
this.state.clients.delete(key);
|
|
417
|
+
this.state.clientSpawnedAt.delete(key);
|
|
383
418
|
this.state.broken.delete(key);
|
|
384
419
|
}
|
|
385
420
|
|
|
@@ -482,6 +517,7 @@ export class LSPService {
|
|
|
482
517
|
};
|
|
483
518
|
|
|
484
519
|
this.state.clients.set(key, client);
|
|
520
|
+
this.state.clientSpawnedAt.set(key, Date.now());
|
|
485
521
|
this.failureCounts.delete(key);
|
|
486
522
|
if (isOptionalServer) {
|
|
487
523
|
this.optionalDisabled.delete(key);
|
|
@@ -699,6 +735,7 @@ export class LSPService {
|
|
|
699
735
|
const normalizedPath = normalizeMapKey(filePath);
|
|
700
736
|
const { clients: spawned, serverCountAttempted } = await this.getClientsForFile(filePath);
|
|
701
737
|
if (spawned.length === 0) {
|
|
738
|
+
const stale = this.lastKnownDiagnostics.get(normalizedPath);
|
|
702
739
|
logLatency({
|
|
703
740
|
type: "phase",
|
|
704
741
|
phase: "lsp_diagnostics_aggregate",
|
|
@@ -707,14 +744,14 @@ export class LSPService {
|
|
|
707
744
|
metadata: {
|
|
708
745
|
serverCountAttempted,
|
|
709
746
|
serverCountReady: 0,
|
|
710
|
-
mergedCount: 0,
|
|
747
|
+
mergedCount: stale?.length ?? 0,
|
|
711
748
|
dedupDroppedCount: 0,
|
|
712
|
-
failureKind: "no_clients",
|
|
749
|
+
failureKind: stale?.length ? "no_clients_stale" : "no_clients",
|
|
713
750
|
health: "no_clients",
|
|
714
751
|
servers: [],
|
|
715
752
|
},
|
|
716
753
|
});
|
|
717
|
-
return [];
|
|
754
|
+
return stale ?? [];
|
|
718
755
|
}
|
|
719
756
|
|
|
720
757
|
// Per-server entries produced by client waits. Each promise resolves
|
|
@@ -840,6 +877,15 @@ export class LSPService {
|
|
|
840
877
|
},
|
|
841
878
|
});
|
|
842
879
|
|
|
880
|
+
// Keep last known so the widget can show stale diagnostics if LSP dies.
|
|
881
|
+
// Live clients returning [] means genuinely no errors — clear the stale
|
|
882
|
+
// entry so the widget doesn't show resolved issues.
|
|
883
|
+
if (merged.length > 0) {
|
|
884
|
+
this.lastKnownDiagnostics.set(normalizedPath, merged);
|
|
885
|
+
} else {
|
|
886
|
+
this.lastKnownDiagnostics.delete(normalizedPath);
|
|
887
|
+
}
|
|
888
|
+
|
|
843
889
|
return merged;
|
|
844
890
|
}
|
|
845
891
|
|
|
@@ -48,6 +48,13 @@ export const SERVER_DIAGNOSTIC_STRATEGIES: Record<string, DiagnosticStrategy> =
|
|
|
48
48
|
aggregateWaitMs: 1500,
|
|
49
49
|
expectSemanticSecondPush: false,
|
|
50
50
|
},
|
|
51
|
+
"python-jedi": {
|
|
52
|
+
seedFirstPush: true,
|
|
53
|
+
pullRetryBudgetMs: 0,
|
|
54
|
+
debounceMs: 100,
|
|
55
|
+
aggregateWaitMs: 1000,
|
|
56
|
+
expectSemanticSecondPush: false,
|
|
57
|
+
},
|
|
51
58
|
eslint: {
|
|
52
59
|
seedFirstPush: true,
|
|
53
60
|
pullRetryBudgetMs: 0,
|
package/clients/lsp/server.ts
CHANGED
|
@@ -969,9 +969,9 @@ export const PythonServer: LSPServerInfo = {
|
|
|
969
969
|
},
|
|
970
970
|
};
|
|
971
971
|
|
|
972
|
-
export const
|
|
973
|
-
id: "python-
|
|
974
|
-
name: "
|
|
972
|
+
export const PythonJediServer: LSPServerInfo = {
|
|
973
|
+
id: "python-jedi",
|
|
974
|
+
name: "Jedi Language Server",
|
|
975
975
|
extensions: KIND_EXTENSIONS["python"],
|
|
976
976
|
root: RootWithFallback(
|
|
977
977
|
createRootDetector([
|
|
@@ -986,10 +986,10 @@ export const PythonPylspServer: LSPServerInfo = {
|
|
|
986
986
|
),
|
|
987
987
|
async spawn(root) {
|
|
988
988
|
try {
|
|
989
|
-
const proc = await launchLSP("
|
|
989
|
+
const proc = await launchLSP("jedi-language-server", [], { cwd: root });
|
|
990
990
|
const pythonPath = await detectPythonVenv(root);
|
|
991
991
|
const initialization: Record<string, unknown> = pythonPath
|
|
992
|
-
? {
|
|
992
|
+
? { workspace: { environmentPath: pythonPath } }
|
|
993
993
|
: {};
|
|
994
994
|
return { process: proc, source: "direct", initialization };
|
|
995
995
|
} catch {
|
|
@@ -1703,8 +1703,7 @@ export const CssServer: LSPServerInfo = {
|
|
|
1703
1703
|
export const LSP_SERVERS: LSPServerInfo[] = [
|
|
1704
1704
|
TypeScriptServer,
|
|
1705
1705
|
DenoServer,
|
|
1706
|
-
|
|
1707
|
-
PythonPylspServer,
|
|
1706
|
+
PythonJediServer,
|
|
1708
1707
|
GoServer,
|
|
1709
1708
|
RustServer,
|
|
1710
1709
|
RubyServer,
|
package/clients/pipeline.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
resolveToolCommand,
|
|
29
29
|
resolveToolCommandWithInstallFallback,
|
|
30
30
|
} from "./dispatch/runners/utils/runner-helpers.js";
|
|
31
|
-
import type { PiAgentAPI } from "./dispatch/types.js";
|
|
31
|
+
import type { Diagnostic, PiAgentAPI } from "./dispatch/types.js";
|
|
32
32
|
import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
|
|
33
33
|
import {
|
|
34
34
|
detectFileChangedAfterCommand,
|
|
@@ -178,6 +178,8 @@ export interface PipelineResult {
|
|
|
178
178
|
fileModified: boolean;
|
|
179
179
|
/** Files modified by pi-lens format/autofix, including side-effect files. */
|
|
180
180
|
changedFiles?: string[];
|
|
181
|
+
/** Blocking-only formatted output for turn_end re-surfacing if agent didn't fix */
|
|
182
|
+
inlineBlockerSummary?: string;
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
// --- Phase timing helpers ---
|
|
@@ -765,6 +767,45 @@ export async function runFormatPhase(
|
|
|
765
767
|
return { formatChanged, formattersUsed, formatFailures, fileContent };
|
|
766
768
|
}
|
|
767
769
|
|
|
770
|
+
/**
|
|
771
|
+
* Build the 🔴 STOP blocker output with an inline code snippet for each
|
|
772
|
+
* diagnostic so the agent can see the exact line it wrote without re-reading
|
|
773
|
+
* the file.
|
|
774
|
+
*
|
|
775
|
+
* Example:
|
|
776
|
+
* L4: 'randomInt' is declared but its value is never read.
|
|
777
|
+
* → const randomInt = Math.floor(result);
|
|
778
|
+
*/
|
|
779
|
+
function buildEnrichedBlockerOutput(
|
|
780
|
+
blockers: Diagnostic[],
|
|
781
|
+
fileContent: string,
|
|
782
|
+
): string {
|
|
783
|
+
const fileLines = fileContent.split("\n");
|
|
784
|
+
const MAX_SNIPPET = 120; // chars — keep it tight in context
|
|
785
|
+
|
|
786
|
+
let out = `\n\n🔴 STOP — ${blockers.length} issue(s) must be fixed:\n`;
|
|
787
|
+
const shown = blockers.slice(0, 10);
|
|
788
|
+
|
|
789
|
+
for (const d of shown) {
|
|
790
|
+
const lineNo = d.line ?? 1;
|
|
791
|
+
const nodeCtx = d.astNodeType ? ` (${d.astNodeType})` : "";
|
|
792
|
+
out += ` L${lineNo}: ${d.message}${nodeCtx}\n`;
|
|
793
|
+
// Prefer the exact matched node text (tree-sitter); fall back to the
|
|
794
|
+
// full source line (LSP / other runners).
|
|
795
|
+
const snippet = d.matchedText
|
|
796
|
+
? d.matchedText.trim().split("\n")[0]?.slice(0, MAX_SNIPPET)
|
|
797
|
+
: fileLines[lineNo - 1]?.trim().slice(0, MAX_SNIPPET);
|
|
798
|
+
if (snippet) out += ` → ${snippet}\n`;
|
|
799
|
+
if (d.fixSuggestion) out += ` 💡 ${d.fixSuggestion}\n`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (blockers.length > 10) {
|
|
803
|
+
out += ` ... and ${blockers.length - 10} more\n`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
|
|
768
809
|
// --- Main Pipeline ---
|
|
769
810
|
|
|
770
811
|
export async function runPipeline(
|
|
@@ -942,7 +983,17 @@ export async function runPipeline(
|
|
|
942
983
|
getDiagnosticTracker().trackAgentFixed(dispatchResult.resolvedCount);
|
|
943
984
|
|
|
944
985
|
let output = "";
|
|
945
|
-
if (dispatchResult.
|
|
986
|
+
if (dispatchResult.hasBlockers && fileContent) {
|
|
987
|
+
// Enrich blocker output with a code snippet so the agent can see the
|
|
988
|
+
// exact line it wrote that caused each violation — no re-read needed.
|
|
989
|
+
output += buildEnrichedBlockerOutput(dispatchResult.blockers, fileContent);
|
|
990
|
+
// Append fixed/coverage parts from the original output (slice off the
|
|
991
|
+
// blocker section we're replacing).
|
|
992
|
+
const rest = dispatchResult.output.slice(dispatchResult.blockerOutput.length);
|
|
993
|
+
if (rest) output += rest;
|
|
994
|
+
} else if (dispatchResult.output) {
|
|
995
|
+
output += `\n\n${dispatchResult.output}`;
|
|
996
|
+
}
|
|
946
997
|
if (fixedCount > 0) {
|
|
947
998
|
const detail =
|
|
948
999
|
autofixTools.length > 0 ? ` (${autofixTools.join(", ")})` : "";
|
|
@@ -1005,5 +1056,8 @@ export async function runPipeline(
|
|
|
1005
1056
|
isError: false,
|
|
1006
1057
|
fileModified: formatChanged || fixedCount > 0,
|
|
1007
1058
|
changedFiles: [...piChangedFiles],
|
|
1059
|
+
inlineBlockerSummary: dispatchResult.hasBlockers
|
|
1060
|
+
? dispatchResult.blockerOutput.trim() || undefined
|
|
1061
|
+
: undefined,
|
|
1008
1062
|
};
|
|
1009
1063
|
}
|
|
@@ -174,6 +174,59 @@ function rebuildIndexes(graph: ReviewGraph): void {
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
const GRAPH_CACHE_REL = path.join(".pi-lens", "cache", "review-graph.json");
|
|
178
|
+
|
|
179
|
+
interface PersistedGraphData {
|
|
180
|
+
version: string;
|
|
181
|
+
builtAt: string;
|
|
182
|
+
signature: string;
|
|
183
|
+
nodes: Array<[string, ReviewGraphNode]>;
|
|
184
|
+
edges: ReviewGraphEdge[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function loadPersistedGraph(
|
|
188
|
+
cwd: string,
|
|
189
|
+
): { signature: string; graph: ReviewGraph } | null {
|
|
190
|
+
const cachePath = path.join(cwd, GRAPH_CACHE_REL);
|
|
191
|
+
try {
|
|
192
|
+
const raw = fs.readFileSync(cachePath, "utf-8");
|
|
193
|
+
const data = JSON.parse(raw) as PersistedGraphData;
|
|
194
|
+
if (data.version !== REVIEW_GRAPH_VERSION) return null;
|
|
195
|
+
const graph: ReviewGraph = {
|
|
196
|
+
version: data.version,
|
|
197
|
+
builtAt: data.builtAt,
|
|
198
|
+
nodes: new Map(data.nodes),
|
|
199
|
+
edges: data.edges,
|
|
200
|
+
edgesByFrom: new Map(),
|
|
201
|
+
edgesByTo: new Map(),
|
|
202
|
+
fileNodes: new Map(),
|
|
203
|
+
symbolNodesByFile: new Map(),
|
|
204
|
+
changedSymbolsByFile: new Map(),
|
|
205
|
+
};
|
|
206
|
+
rebuildIndexes(graph);
|
|
207
|
+
return { signature: data.signature, graph };
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function persistGraph(cwd: string, signature: string, graph: ReviewGraph): void {
|
|
214
|
+
const cacheDir = path.join(cwd, ".pi-lens", "cache");
|
|
215
|
+
const cachePath = path.join(cwd, GRAPH_CACHE_REL);
|
|
216
|
+
const data: PersistedGraphData = {
|
|
217
|
+
version: graph.version,
|
|
218
|
+
builtAt: graph.builtAt,
|
|
219
|
+
signature,
|
|
220
|
+
nodes: Array.from(graph.nodes.entries()),
|
|
221
|
+
edges: graph.edges,
|
|
222
|
+
};
|
|
223
|
+
const json = JSON.stringify(data);
|
|
224
|
+
fs.mkdir(cacheDir, { recursive: true }, (mkdirErr) => {
|
|
225
|
+
if (mkdirErr) return;
|
|
226
|
+
fs.writeFile(cachePath, json, "utf-8", () => {});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
177
230
|
function localImportToFile(
|
|
178
231
|
cwd: string,
|
|
179
232
|
filePath: string,
|
|
@@ -456,19 +509,40 @@ async function _doBuildGraph(
|
|
|
456
509
|
const normalizedChanged = changedFiles.map((file) => normalizeMapKey(file));
|
|
457
510
|
const filesToBuild = getGraphSourceFiles(cwd);
|
|
458
511
|
const signature = sourceSignature(filesToBuild);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
512
|
+
|
|
513
|
+
// Tier 1: in-memory cache (hot path — same process, already built this session)
|
|
514
|
+
const memCached = _workspaceGraphCache.get(normalizedCwd);
|
|
515
|
+
if (memCached?.signature === signature) {
|
|
516
|
+
const graph = cloneGraph(memCached.graph);
|
|
517
|
+
rebuildIndexes(graph);
|
|
518
|
+
graph.changedSymbolsByFile.clear();
|
|
519
|
+
for (const file of normalizedChanged) {
|
|
520
|
+
upsertChangedSymbols(graph, facts, file);
|
|
521
|
+
}
|
|
522
|
+
_lastGraphBuildInfo = { reused: true, mode: "cached" };
|
|
523
|
+
facts.setSessionFact("session.reviewGraph", graph);
|
|
524
|
+
return graph;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Tier 2: disk cache (cold start — files unchanged since last persist)
|
|
528
|
+
const diskCached = loadPersistedGraph(cwd);
|
|
529
|
+
if (diskCached?.signature === signature) {
|
|
530
|
+
const graph = cloneGraph(diskCached.graph);
|
|
462
531
|
rebuildIndexes(graph);
|
|
463
532
|
graph.changedSymbolsByFile.clear();
|
|
464
533
|
for (const file of normalizedChanged) {
|
|
465
534
|
upsertChangedSymbols(graph, facts, file);
|
|
466
535
|
}
|
|
536
|
+
_workspaceGraphCache.set(normalizedCwd, {
|
|
537
|
+
signature,
|
|
538
|
+
graph: cloneGraph(diskCached.graph),
|
|
539
|
+
});
|
|
467
540
|
_lastGraphBuildInfo = { reused: true, mode: "cached" };
|
|
468
541
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
469
542
|
return graph;
|
|
470
543
|
}
|
|
471
544
|
|
|
545
|
+
// Tier 3: full build
|
|
472
546
|
const graph = createEmptyGraph();
|
|
473
547
|
for (const file of filesToBuild) {
|
|
474
548
|
const kind = detectFileKind(file);
|
|
@@ -490,10 +564,9 @@ async function _doBuildGraph(
|
|
|
490
564
|
resolveDeferredSymbolEdges(graph);
|
|
491
565
|
graph.version = REVIEW_GRAPH_VERSION;
|
|
492
566
|
graph.builtAt = new Date().toISOString();
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
});
|
|
567
|
+
const graphSnapshot = cloneGraph(graph);
|
|
568
|
+
_workspaceGraphCache.set(normalizedCwd, { signature, graph: graphSnapshot });
|
|
569
|
+
persistGraph(cwd, signature, graphSnapshot); // fire-and-forget
|
|
497
570
|
_lastGraphBuildInfo = { reused: false, mode: "full" };
|
|
498
571
|
facts.setSessionFact("session.reviewGraph", graph);
|
|
499
572
|
return graph;
|
|
@@ -61,6 +61,10 @@ export class RuntimeCoordinator {
|
|
|
61
61
|
string,
|
|
62
62
|
{ status: "warming" | "ready"; ts: number }
|
|
63
63
|
>();
|
|
64
|
+
private readonly _pendingInlineBlockers = new Map<
|
|
65
|
+
string,
|
|
66
|
+
{ filePath: string; summary: string }
|
|
67
|
+
>();
|
|
64
68
|
|
|
65
69
|
resetForSession(): void {
|
|
66
70
|
this._sessionGeneration += 1;
|
|
@@ -87,6 +91,7 @@ export class RuntimeCoordinator {
|
|
|
87
91
|
this._readGuard = null;
|
|
88
92
|
this._pendingDeferredFormatFiles.clear();
|
|
89
93
|
this._lspReadWarmState.clear();
|
|
94
|
+
this._pendingInlineBlockers.clear();
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
get sessionStartedAt(): number {
|
|
@@ -132,6 +137,7 @@ export class RuntimeCoordinator {
|
|
|
132
137
|
|
|
133
138
|
beginTurn(): void {
|
|
134
139
|
this._cascadeResults = [];
|
|
140
|
+
this._pendingInlineBlockers.clear();
|
|
135
141
|
this._turnIndex += 1;
|
|
136
142
|
this._writeIndex = 0;
|
|
137
143
|
this._reportedThisTurn.clear();
|
|
@@ -266,6 +272,20 @@ export class RuntimeCoordinator {
|
|
|
266
272
|
return results;
|
|
267
273
|
}
|
|
268
274
|
|
|
275
|
+
recordInlineBlockers(filePath: string, summary: string): void {
|
|
276
|
+
this._pendingInlineBlockers.set(path.resolve(filePath), { filePath, summary });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
clearInlineBlockers(filePath: string): void {
|
|
280
|
+
this._pendingInlineBlockers.delete(path.resolve(filePath));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
consumeInlineBlockers(): Array<{ filePath: string; summary: string }> {
|
|
284
|
+
const entries = [...this._pendingInlineBlockers.values()];
|
|
285
|
+
this._pendingInlineBlockers.clear();
|
|
286
|
+
return entries;
|
|
287
|
+
}
|
|
288
|
+
|
|
269
289
|
get complexityBaselines(): Map<string, FileComplexity> {
|
|
270
290
|
return this._complexityBaselines;
|
|
271
291
|
}
|
|
@@ -281,6 +281,7 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
281
281
|
isError?: boolean;
|
|
282
282
|
cascadeResult?: import("./cascade-types.js").CascadeResult;
|
|
283
283
|
changedFiles?: string[];
|
|
284
|
+
inlineBlockerSummary?: string;
|
|
284
285
|
};
|
|
285
286
|
const pipelinePromise = runPipeline(
|
|
286
287
|
{
|
|
@@ -401,6 +402,12 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
401
402
|
runtime.appendCascadeResult(result.cascadeResult);
|
|
402
403
|
}
|
|
403
404
|
|
|
405
|
+
if (result.inlineBlockerSummary) {
|
|
406
|
+
runtime.recordInlineBlockers(filePath, result.inlineBlockerSummary);
|
|
407
|
+
} else {
|
|
408
|
+
runtime.clearInlineBlockers(filePath);
|
|
409
|
+
}
|
|
410
|
+
|
|
404
411
|
if (result.isError) {
|
|
405
412
|
return {
|
|
406
413
|
content: [...event.content, { type: "text", text: result.output }],
|
package/clients/runtime-turn.ts
CHANGED
|
@@ -129,6 +129,14 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
129
129
|
const blockerParts: string[] = [];
|
|
130
130
|
const advisoryParts: string[] = [];
|
|
131
131
|
|
|
132
|
+
// Re-surface inline blockers from this turn that the agent didn't fix.
|
|
133
|
+
// These were shown inline during write/edit but the agent moved on without resolving them.
|
|
134
|
+
const unresolvedBlockers = runtime.consumeInlineBlockers();
|
|
135
|
+
for (const { filePath: bPath, summary } of unresolvedBlockers) {
|
|
136
|
+
const displayPath = toRunnerDisplayPath(cwd, bPath);
|
|
137
|
+
blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
// Merge accumulated cascade results from all pipeline runs this turn.
|
|
133
141
|
// Two-pass dedup:
|
|
134
142
|
// 1. Primary-level: dedup by primary file (last writer wins).
|
|
@@ -43,6 +43,7 @@ interface TreeSitterNode {
|
|
|
43
43
|
type: string;
|
|
44
44
|
text: string;
|
|
45
45
|
children: TreeSitterNode[];
|
|
46
|
+
parent?: TreeSitterNode | null;
|
|
46
47
|
isNamed: boolean;
|
|
47
48
|
childCount: number;
|
|
48
49
|
startPosition: { row: number; column: number };
|
|
@@ -80,6 +81,8 @@ export interface StructuralMatch {
|
|
|
80
81
|
line: number;
|
|
81
82
|
column: number;
|
|
82
83
|
matchedText: string;
|
|
84
|
+
/** Tree-sitter node type of the first capture (e.g. "call_expression") */
|
|
85
|
+
nodeType?: string;
|
|
83
86
|
captures: Record<string, string>;
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -973,6 +976,61 @@ export class TreeSitterClient {
|
|
|
973
976
|
}
|
|
974
977
|
case "ts_detached_async_call":
|
|
975
978
|
return /(Async$|fetch$|request$)/.test(captures.FN?.text ?? "");
|
|
979
|
+
case "incomplete_assertion": {
|
|
980
|
+
const expectNode = captures.EXPECT;
|
|
981
|
+
if (!expectNode) return false;
|
|
982
|
+
const CHAI_PROPERTY_ASSERTIONS = new Set([
|
|
983
|
+
"true",
|
|
984
|
+
"false",
|
|
985
|
+
"null",
|
|
986
|
+
"undefined",
|
|
987
|
+
"empty",
|
|
988
|
+
"NaN",
|
|
989
|
+
"finite",
|
|
990
|
+
"exist",
|
|
991
|
+
"arguments",
|
|
992
|
+
"extensible",
|
|
993
|
+
"sealed",
|
|
994
|
+
"frozen",
|
|
995
|
+
"locked",
|
|
996
|
+
]);
|
|
997
|
+
// The expect identifier is inside a call_expression. Walk up past that
|
|
998
|
+
// call_expression to the container that determines if it's a complete
|
|
999
|
+
// assertion or an incomplete one.
|
|
1000
|
+
let current: TreeSitterNode | null | undefined = expectNode.parent;
|
|
1001
|
+
if (!current) return false;
|
|
1002
|
+
current = current.parent; // skip the expect(...) call_expression
|
|
1003
|
+
if (!current) return false;
|
|
1004
|
+
// Bare expect(foo); or return expect(foo);
|
|
1005
|
+
if (
|
|
1006
|
+
current.type === "expression_statement" ||
|
|
1007
|
+
current.type === "return_statement"
|
|
1008
|
+
)
|
|
1009
|
+
return true;
|
|
1010
|
+
let lastPropertyName: string | null = null;
|
|
1011
|
+
while (current && current.type === "member_expression") {
|
|
1012
|
+
const propNode = current.children?.find(
|
|
1013
|
+
(c: any) => c.type === "property_identifier",
|
|
1014
|
+
);
|
|
1015
|
+
if (propNode) lastPropertyName = propNode.text;
|
|
1016
|
+
const parent: TreeSitterNode | null | undefined = current.parent;
|
|
1017
|
+
if (!parent) return false;
|
|
1018
|
+
if (
|
|
1019
|
+
parent.type === "expression_statement" ||
|
|
1020
|
+
parent.type === "return_statement"
|
|
1021
|
+
) {
|
|
1022
|
+
if (
|
|
1023
|
+
lastPropertyName &&
|
|
1024
|
+
CHAI_PROPERTY_ASSERTIONS.has(lastPropertyName)
|
|
1025
|
+
)
|
|
1026
|
+
return false;
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
if (parent.type === "call_expression") return false;
|
|
1030
|
+
current = parent;
|
|
1031
|
+
}
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
976
1034
|
case "py_command_injection_sink": {
|
|
977
1035
|
const mod = captures.MOD?.text ?? "";
|
|
978
1036
|
const fn = captures.FN?.text ?? "";
|
|
@@ -1119,6 +1177,7 @@ export class TreeSitterClient {
|
|
|
1119
1177
|
line: firstNode.startPosition.row + 1,
|
|
1120
1178
|
column: firstNode.startPosition.column + 1,
|
|
1121
1179
|
matchedText: firstNode.text,
|
|
1180
|
+
nodeType: firstNode.type as string | undefined,
|
|
1122
1181
|
captures: textCaptures,
|
|
1123
1182
|
});
|
|
1124
1183
|
}
|
package/index.ts
CHANGED
|
@@ -1339,7 +1339,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1339
1339
|
const isExistingFile =
|
|
1340
1340
|
typeof readGuard?.isNewFile !== "function" ||
|
|
1341
1341
|
!readGuard.isNewFile(filePath);
|
|
1342
|
-
if (readGuard && isExistingFile) {
|
|
1342
|
+
if (readGuard && isExistingFile && !isExternalOrVendor) {
|
|
1343
1343
|
const { touchedLines, editRanges, preflightError } =
|
|
1344
1344
|
getTouchedLinesForGuard(event, filePath, runtime.telemetrySessionId);
|
|
1345
1345
|
if (preflightError) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# TypeScript Incomplete Assertion
|
|
2
|
+
# Detects expect() chains that are never called (Jest/Vitest style).
|
|
3
|
+
# Chai property assertions (e.g. expect(foo).to.be.true) are excluded.
|
|
4
|
+
id: ts-incomplete-assertion
|
|
5
|
+
name: Incomplete Test Assertion
|
|
6
|
+
severity: error
|
|
7
|
+
category: testing
|
|
8
|
+
defect_class: correctness
|
|
9
|
+
inline_tier: blocking
|
|
10
|
+
language: typescript
|
|
11
|
+
|
|
12
|
+
message: "Incomplete assertion — expect() chain is not called"
|
|
13
|
+
|
|
14
|
+
description: |
|
|
15
|
+
Jest and Vitest matchers are methods that must be called.
|
|
16
|
+
`expect(foo).toBe` (without parentheses) or `expect(foo)` (without a
|
|
17
|
+
matcher) does not actually assert anything. The test will pass silently.
|
|
18
|
+
|
|
19
|
+
✅ FIX: call the matcher: `expect(foo).toBe(true)`
|
|
20
|
+
|
|
21
|
+
query: |
|
|
22
|
+
(call_expression
|
|
23
|
+
function: (identifier) @EXPECT
|
|
24
|
+
(#eq? @EXPECT "expect")
|
|
25
|
+
arguments: (arguments)) @EXPR
|
|
26
|
+
|
|
27
|
+
metavars:
|
|
28
|
+
- EXPECT
|
|
29
|
+
- EXPR
|
|
30
|
+
|
|
31
|
+
post_filter: incomplete_assertion
|
|
32
|
+
|
|
33
|
+
has_fix: false
|
|
34
|
+
|
|
35
|
+
tags:
|
|
36
|
+
- typescript
|
|
37
|
+
- testing
|
|
38
|
+
- jest
|
|
39
|
+
- vitest
|
|
40
|
+
- assertions
|
|
41
|
+
|
|
42
|
+
examples:
|
|
43
|
+
bad: |
|
|
44
|
+
expect(foo).toBe; // BAD — matcher not called
|
|
45
|
+
expect(foo).not.toBe; // BAD — matcher not called
|
|
46
|
+
expect(foo); // BAD — no matcher at all
|
|
47
|
+
|
|
48
|
+
good: |
|
|
49
|
+
expect(foo).toBe(true); // OK
|
|
50
|
+
expect(foo).not.toBe(1); // OK
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# TypeScript Switch Non-Case Labels
|
|
2
|
+
# Detects non-case labels inside switch statements in TypeScript
|
|
3
|
+
id: switch-non-case-labels-ts
|
|
4
|
+
name: Switch Should Not Contain Non-Case Labels
|
|
5
|
+
severity: error
|
|
6
|
+
category: reliability
|
|
7
|
+
defect_class: correctness
|
|
8
|
+
inline_tier: blocking
|
|
9
|
+
language: typescript
|
|
10
|
+
|
|
11
|
+
message: "switch statements should not contain non-case labels"
|
|
12
|
+
|
|
13
|
+
description: |
|
|
14
|
+
Non-case labels in switch statements create confusing control flow.
|
|
15
|
+
This is a MISRA rule violation.
|
|
16
|
+
|
|
17
|
+
✅ FIX: Remove labels or restructure code
|
|
18
|
+
|
|
19
|
+
query: |
|
|
20
|
+
(switch_statement
|
|
21
|
+
body: (switch_body
|
|
22
|
+
(switch_case
|
|
23
|
+
(labeled_statement
|
|
24
|
+
(statement_identifier) @LABEL) @LABELED)))
|
|
25
|
+
|
|
26
|
+
metavars:
|
|
27
|
+
- LABEL
|
|
28
|
+
- LABELED
|
|
29
|
+
|
|
30
|
+
tags:
|
|
31
|
+
- reliability
|
|
32
|
+
- typescript
|
|
33
|
+
- misra
|
|
34
|
+
- control-flow
|
|
35
|
+
|
|
36
|
+
examples:
|
|
37
|
+
bad: |
|
|
38
|
+
switch (x) {
|
|
39
|
+
case 1:
|
|
40
|
+
break;
|
|
41
|
+
myLabel: // BAD
|
|
42
|
+
doSomething();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
good: |
|
|
46
|
+
switch (x) {
|
|
47
|
+
case 1:
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
has_fix: false
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# TypeScript Path Traversal
|
|
2
|
+
# Detects fs and path API calls with potentially user-controlled paths.
|
|
3
|
+
id: ts-path-traversal
|
|
4
|
+
name: Path Traversal Risk
|
|
5
|
+
severity: error
|
|
6
|
+
category: security
|
|
7
|
+
defect_class: injection
|
|
8
|
+
inline_tier: blocking
|
|
9
|
+
language: typescript
|
|
10
|
+
|
|
11
|
+
message: "Potential path traversal sink — avoid filesystem I/O with untrusted input"
|
|
12
|
+
|
|
13
|
+
description: |
|
|
14
|
+
File-system APIs (`fs.readFile`, `fs.writeFile`, `path.join`, etc.) are
|
|
15
|
+
vulnerable when path arguments include untrusted input. An attacker can
|
|
16
|
+
inject `../` sequences to access files outside the intended directory.
|
|
17
|
+
|
|
18
|
+
✅ FIX: validate and sanitize paths; use allowlists or chroot jails.
|
|
19
|
+
|
|
20
|
+
query: |
|
|
21
|
+
(call_expression
|
|
22
|
+
function: (member_expression
|
|
23
|
+
object: (identifier) @MOD
|
|
24
|
+
property: (property_identifier) @FN)
|
|
25
|
+
arguments: (arguments
|
|
26
|
+
[(identifier) (member_expression) (template_string) (call_expression) (binary_expression)] @PATH)
|
|
27
|
+
(#eq? @MOD "fs"))
|
|
28
|
+
(call_expression
|
|
29
|
+
function: (member_expression
|
|
30
|
+
object: (identifier) @MOD
|
|
31
|
+
property: (property_identifier) @FN)
|
|
32
|
+
arguments: (arguments
|
|
33
|
+
[(identifier) (member_expression) (template_string) (call_expression) (binary_expression)] @PATH)
|
|
34
|
+
(#eq? @MOD "path")
|
|
35
|
+
(#match? @FN "^(join|resolve)$"))
|
|
36
|
+
(call_expression
|
|
37
|
+
function: (member_expression
|
|
38
|
+
object: (member_expression
|
|
39
|
+
object: (identifier) @MOD
|
|
40
|
+
property: (property_identifier) @PROMISES)
|
|
41
|
+
property: (property_identifier) @FN)
|
|
42
|
+
arguments: (arguments
|
|
43
|
+
[(identifier) (member_expression) (template_string) (call_expression) (binary_expression)] @PATH)
|
|
44
|
+
(#eq? @MOD "fs")
|
|
45
|
+
(#eq? @PROMISES "promises"))
|
|
46
|
+
|
|
47
|
+
metavars:
|
|
48
|
+
- MOD
|
|
49
|
+
- PROMISES
|
|
50
|
+
- FN
|
|
51
|
+
- PATH
|
|
52
|
+
|
|
53
|
+
post_filter: ts_path_traversal_sink
|
|
54
|
+
|
|
55
|
+
has_fix: false
|
|
56
|
+
|
|
57
|
+
tags:
|
|
58
|
+
- typescript
|
|
59
|
+
- security
|
|
60
|
+
- path-traversal
|
|
61
|
+
- cwe-22
|
|
62
|
+
- owasp-a01
|
|
63
|
+
|
|
64
|
+
examples:
|
|
65
|
+
bad: |
|
|
66
|
+
fs.readFile(req.query.path); // BAD — user controls path
|
|
67
|
+
path.join(baseDir, userFile); // BAD — user controls segment
|
|
68
|
+
|
|
69
|
+
good: |
|
|
70
|
+
fs.readFile("./safe.txt"); // OK — literal path
|
|
71
|
+
path.join(baseDir, "static"); // OK — literal segment
|