pi-lens 3.8.22 → 3.8.24
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 +30 -0
- package/clients/bootstrap.ts +106 -0
- package/clients/dispatch/dispatcher.ts +75 -91
- package/clients/dispatch/fact-provider-types.ts +22 -0
- package/clients/dispatch/fact-rule-runner.ts +22 -0
- package/clients/dispatch/fact-runner.ts +28 -0
- package/clients/dispatch/fact-scheduler.ts +78 -0
- package/clients/dispatch/fact-store.ts +67 -0
- package/clients/dispatch/facts/comment-facts.ts +59 -0
- package/clients/dispatch/facts/file-content.ts +20 -0
- package/clients/dispatch/facts/function-facts.ts +177 -0
- package/clients/dispatch/facts/try-catch-facts.ts +80 -0
- package/clients/dispatch/integration.ts +130 -24
- package/clients/dispatch/priorities.ts +22 -0
- package/clients/dispatch/rules/async-noise.ts +43 -0
- package/clients/dispatch/rules/error-obscuring.ts +40 -0
- package/clients/dispatch/rules/error-swallowing.ts +35 -0
- package/clients/dispatch/rules/pass-through-wrappers.ts +52 -0
- package/clients/dispatch/rules/placeholder-comments.ts +47 -0
- package/clients/dispatch/runners/architect.ts +2 -1
- package/clients/dispatch/runners/ast-grep-napi.ts +2 -1
- package/clients/dispatch/runners/biome-check.ts +40 -8
- package/clients/dispatch/runners/biome.ts +2 -1
- package/clients/dispatch/runners/eslint.ts +34 -6
- package/clients/dispatch/runners/go-vet.ts +2 -1
- package/clients/dispatch/runners/golangci-lint.ts +2 -1
- package/clients/dispatch/runners/index.ts +29 -27
- package/clients/dispatch/runners/lsp.ts +2 -1
- package/clients/dispatch/runners/oxlint.ts +2 -1
- package/clients/dispatch/runners/pyright.ts +2 -1
- package/clients/dispatch/runners/python-slop.ts +2 -1
- package/clients/dispatch/runners/rubocop.ts +2 -1
- package/clients/dispatch/runners/ruff.ts +2 -1
- package/clients/dispatch/runners/rust-clippy.ts +2 -1
- package/clients/dispatch/runners/shellcheck.ts +2 -1
- package/clients/dispatch/runners/similarity.ts +2 -1
- package/clients/dispatch/runners/spellcheck.ts +2 -1
- package/clients/dispatch/runners/sqlfluff.ts +2 -1
- package/clients/dispatch/runners/tree-sitter.ts +2 -1
- package/clients/dispatch/runners/ts-lsp.ts +2 -1
- package/clients/dispatch/runners/type-safety.ts +2 -1
- package/clients/dispatch/runners/yamllint.ts +2 -1
- package/clients/dispatch/tool-profile.ts +40 -0
- package/clients/dispatch/types.ts +3 -13
- package/clients/lsp/client.ts +137 -9
- package/clients/lsp/config.ts +17 -9
- package/clients/lsp/index.ts +263 -75
- package/clients/lsp/launch.ts +42 -2
- package/clients/lsp/server.ts +195 -13
- package/clients/runtime-context.ts +2 -2
- package/clients/session-summary.ts +21 -0
- package/index.ts +123 -51
- package/package.json +2 -1
- package/tools/lsp-navigation.js +263 -107
- package/tools/lsp-navigation.ts +358 -121
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-lens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [3.8.24] - 2026-04-12
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **Lazy bootstrap client loading** — startup now defers heavy client initialization behind a shared bootstrap promise, reducing first-turn startup overhead while preserving tool behavior.
|
|
11
|
+
- **LSP config discovery scope** — `.pi-lens/lsp.json` (and related config paths) are now resolved from the current directory up through parent directories, improving nested-workspace support.
|
|
12
|
+
- **Ruby server fallback chain** — Ruby LSP startup now tries `ruby-lsp`, then `solargraph`, then `rubocop --lsp` for broader environment compatibility.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **LSP config activation timing** — LSP server config initialization now runs reliably at `session_start` and before LSP-backed `tool_call` operations, so server enable/disable overrides apply in one-shot and interactive sessions.
|
|
16
|
+
|
|
17
|
+
## [3.8.23] - 2026-04-12
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **LSP auto-touch warm-up** — tool-call flow now proactively opens/syncs supported files (`read`/`write`/`edit`/`lsp_navigation`) so LSP clients warm up earlier and first semantic requests are less likely to return cold-start empties.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- **Ruby LSP spawn resilience on Windows** — Ruby command discovery now tries `ruby-lsp`/`solargraph` from PATH plus common Ruby install locations before marking servers unavailable.
|
|
24
|
+
- **LSP diagnostics dedupe strategy** — multi-server diagnostics aggregation now dedupes using a simpler key (`line`, `character`, `message`) to better collapse equivalent findings across servers.
|
|
25
|
+
- **Windows LSP PATH fallback** — language-server spawns now augment PATH with common user-level tool locations (`.cargo\bin`, `go\bin`, common Ruby bin dirs) to improve server discovery on Windows shells.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **LSP diagnostics key normalization** — publish diagnostics now store/update using normalized file-path keys, fixing Windows path mismatches that could hide diagnostics in some languages.
|
|
29
|
+
- **Pull diagnostics fallback path** — when a server advertises pull diagnostics, `textDocument/diagnostic` is now attempted before push-wait fallback.
|
|
30
|
+
- **Navigation diagnostics/health observability** — `lsp_navigation` and diagnostics aggregation now emit explicit `failureKind`/health metadata to latency logs and tool details for faster root-cause triage (`no_server`, `unsupported`, `empty_result`, `lsp_error`, etc.).
|
|
31
|
+
- **Scoped workspaceDiagnostics collection** — `workspaceDiagnostics` with `filePath` now forces file-level diagnostics collection (instead of only returning tracked snapshots), including pull-mode aggregation metadata.
|
|
32
|
+
- **Rust pull diagnostics cold-start handling** — pull diagnostics now retry briefly and then fall back to push-wait if pull responses remain empty, improving first-hit Rust diagnostic reliability.
|
|
33
|
+
- **Context injection message role validity** — session-start guidance is now injected as `user` context (valid `AgentMessage` role), preventing dropped context on providers that reject/ignore `system` in this path.
|
|
34
|
+
|
|
5
35
|
## [3.8.22] - 2026-04-09
|
|
6
36
|
|
|
7
37
|
### Changed
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { AgentBehaviorClient } from "./agent-behavior-client.js";
|
|
2
|
+
import type { ArchitectClient } from "./architect-client.js";
|
|
3
|
+
import type { BiomeClient } from "./biome-client.js";
|
|
4
|
+
import type { ComplexityClient } from "./complexity-client.js";
|
|
5
|
+
import type { DependencyChecker } from "./dependency-checker.js";
|
|
6
|
+
import type { GoClient } from "./go-client.js";
|
|
7
|
+
import type { JscpdClient } from "./jscpd-client.js";
|
|
8
|
+
import type { KnipClient } from "./knip-client.js";
|
|
9
|
+
import type { MetricsClient } from "./metrics-client.js";
|
|
10
|
+
import type { RuffClient } from "./ruff-client.js";
|
|
11
|
+
import type { RustClient } from "./rust-client.js";
|
|
12
|
+
import type { TestRunnerClient } from "./test-runner-client.js";
|
|
13
|
+
import type { TodoScanner } from "./todo-scanner.js";
|
|
14
|
+
import type { TypeCoverageClient } from "./type-coverage-client.js";
|
|
15
|
+
|
|
16
|
+
export interface BootstrapClients {
|
|
17
|
+
ruffClient: RuffClient;
|
|
18
|
+
biomeClient: BiomeClient;
|
|
19
|
+
knipClient: KnipClient;
|
|
20
|
+
todoScanner: TodoScanner;
|
|
21
|
+
jscpdClient: JscpdClient;
|
|
22
|
+
typeCoverageClient: TypeCoverageClient;
|
|
23
|
+
depChecker: DependencyChecker;
|
|
24
|
+
testRunnerClient: TestRunnerClient;
|
|
25
|
+
metricsClient: MetricsClient;
|
|
26
|
+
complexityClient: ComplexityClient;
|
|
27
|
+
architectClient: ArchitectClient;
|
|
28
|
+
goClient: GoClient;
|
|
29
|
+
rustClient: RustClient;
|
|
30
|
+
agentBehaviorClient: AgentBehaviorClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let bootstrapPromise: Promise<BootstrapClients> | null = null;
|
|
34
|
+
|
|
35
|
+
function createArchitectFallback(): ArchitectClient {
|
|
36
|
+
return {
|
|
37
|
+
loadConfig: () => false,
|
|
38
|
+
isUserDefined: () => false,
|
|
39
|
+
hasConfig: () => false,
|
|
40
|
+
getRulesForFile: () => [],
|
|
41
|
+
checkFile: () => [],
|
|
42
|
+
checkFileSize: () => null,
|
|
43
|
+
getHints: () => [],
|
|
44
|
+
} as unknown as ArchitectClient;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadBootstrapClients(): Promise<BootstrapClients> {
|
|
48
|
+
bootstrapPromise ??= (async () => {
|
|
49
|
+
const [
|
|
50
|
+
ruffMod,
|
|
51
|
+
biomeMod,
|
|
52
|
+
knipMod,
|
|
53
|
+
todoMod,
|
|
54
|
+
jscpdMod,
|
|
55
|
+
typeCoverageMod,
|
|
56
|
+
depCheckerMod,
|
|
57
|
+
testRunnerMod,
|
|
58
|
+
metricsMod,
|
|
59
|
+
complexityMod,
|
|
60
|
+
goMod,
|
|
61
|
+
rustMod,
|
|
62
|
+
agentBehaviorMod,
|
|
63
|
+
] = await Promise.all([
|
|
64
|
+
import("./ruff-client.js"),
|
|
65
|
+
import("./biome-client.js"),
|
|
66
|
+
import("./knip-client.js"),
|
|
67
|
+
import("./todo-scanner.js"),
|
|
68
|
+
import("./jscpd-client.js"),
|
|
69
|
+
import("./type-coverage-client.js"),
|
|
70
|
+
import("./dependency-checker.js"),
|
|
71
|
+
import("./test-runner-client.js"),
|
|
72
|
+
import("./metrics-client.js"),
|
|
73
|
+
import("./complexity-client.js"),
|
|
74
|
+
import("./go-client.js"),
|
|
75
|
+
import("./rust-client.js"),
|
|
76
|
+
import("./agent-behavior-client.js"),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
let architectClient: ArchitectClient;
|
|
80
|
+
try {
|
|
81
|
+
const architectMod = await import("./architect-client.js");
|
|
82
|
+
architectClient = new architectMod.ArchitectClient();
|
|
83
|
+
} catch {
|
|
84
|
+
architectClient = createArchitectFallback();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
ruffClient: new ruffMod.RuffClient(),
|
|
89
|
+
biomeClient: new biomeMod.BiomeClient(),
|
|
90
|
+
knipClient: new knipMod.KnipClient(),
|
|
91
|
+
todoScanner: new todoMod.TodoScanner(),
|
|
92
|
+
jscpdClient: new jscpdMod.JscpdClient(),
|
|
93
|
+
typeCoverageClient: new typeCoverageMod.TypeCoverageClient(),
|
|
94
|
+
depChecker: new depCheckerMod.DependencyChecker(),
|
|
95
|
+
testRunnerClient: new testRunnerMod.TestRunnerClient(),
|
|
96
|
+
metricsClient: new metricsMod.MetricsClient(),
|
|
97
|
+
complexityClient: new complexityMod.ComplexityClient(),
|
|
98
|
+
architectClient,
|
|
99
|
+
goClient: new goMod.GoClient(),
|
|
100
|
+
rustClient: new rustMod.RustClient(),
|
|
101
|
+
agentBehaviorClient: new agentBehaviorMod.AgentBehaviorClient(),
|
|
102
|
+
};
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
return bootstrapPromise;
|
|
106
|
+
}
|
|
@@ -25,9 +25,10 @@ import { normalizeMapKey } from "../path-utils.js";
|
|
|
25
25
|
import { RUNTIME_CONFIG } from "../runtime-config.js";
|
|
26
26
|
import { safeSpawnAsync } from "../safe-spawn.js";
|
|
27
27
|
import { classifyDiagnostic } from "./diagnostic-taxonomy.js";
|
|
28
|
+
import { FactStore } from "./fact-store.js";
|
|
28
29
|
import { resolveRunnerPath } from "./runner-context.js";
|
|
30
|
+
import { getToolProfile } from "./tool-profile.js";
|
|
29
31
|
import type {
|
|
30
|
-
BaselineStore,
|
|
31
32
|
Diagnostic,
|
|
32
33
|
DispatchContext,
|
|
33
34
|
DispatchResult,
|
|
@@ -35,88 +36,78 @@ import type {
|
|
|
35
36
|
PiAgentAPI,
|
|
36
37
|
RunnerDefinition,
|
|
37
38
|
RunnerGroup,
|
|
39
|
+
RunnerRegistry as RunnerRegistryContract,
|
|
38
40
|
RunnerResult,
|
|
39
41
|
} from "./types.js";
|
|
40
42
|
import { formatDiagnostics } from "./utils/format-utils.js";
|
|
41
43
|
|
|
42
|
-
// ---
|
|
44
|
+
// --- Runner Registry ---
|
|
43
45
|
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
+
export class RunnerRegistry implements RunnerRegistryContract {
|
|
47
|
+
private readonly runners = new Map<string, RunnerDefinition>();
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
set(filePath, diagnostics) {
|
|
52
|
-
baselines.set(normalizeMapKey(filePath), diagnostics);
|
|
53
|
-
},
|
|
54
|
-
clear() {
|
|
55
|
-
baselines.clear();
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// --- Runner Registry ---
|
|
49
|
+
register(runner: RunnerDefinition): void {
|
|
50
|
+
if (this.runners.has(runner.id)) return;
|
|
51
|
+
this.runners.set(runner.id, runner);
|
|
52
|
+
}
|
|
61
53
|
|
|
62
|
-
|
|
54
|
+
get(id: string): RunnerDefinition | undefined {
|
|
55
|
+
return this.runners.get(id);
|
|
56
|
+
}
|
|
63
57
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
58
|
+
getForKind(kind: FileKind, filePath?: string): RunnerDefinition[] {
|
|
59
|
+
const matching: RunnerDefinition[] = [];
|
|
60
|
+
const isTest = filePath ? isTestFile(filePath) : false;
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
for (const runner of this.runners.values()) {
|
|
63
|
+
if (isTest && runner.skipTestFiles) continue;
|
|
64
|
+
if (runner.appliesTo.includes(kind) || runner.appliesTo.length === 0) {
|
|
65
|
+
matching.push(runner);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
72
68
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
filePath?: string,
|
|
76
|
-
): RunnerDefinition[] {
|
|
77
|
-
if (!kind) return [];
|
|
78
|
-
const runners: RunnerDefinition[] = [];
|
|
79
|
-
const isTest = filePath ? isTestFile(filePath) : false;
|
|
69
|
+
return matching.sort((a, b) => a.priority - b.priority);
|
|
70
|
+
}
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
list(): RunnerDefinition[] {
|
|
73
|
+
return Array.from(this.runners.values());
|
|
74
|
+
}
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
76
|
+
clear(): void {
|
|
77
|
+
this.runners.clear();
|
|
88
78
|
}
|
|
89
|
-
return runners.sort((a, b) => a.priority - b.priority);
|
|
90
79
|
}
|
|
91
80
|
|
|
92
|
-
|
|
93
|
-
return Array.from(globalRegistry.values());
|
|
94
|
-
}
|
|
81
|
+
// --- Tool Availability Cache ---
|
|
95
82
|
|
|
96
83
|
/**
|
|
97
|
-
*
|
|
84
|
+
* Normalize a command name to a FactStore session key.
|
|
85
|
+
* Strips .cmd/.exe suffixes (case-insensitive) and lowercases,
|
|
86
|
+
* then prefixes with "session.toolCache.".
|
|
98
87
|
*/
|
|
99
|
-
export function
|
|
100
|
-
|
|
88
|
+
export function normalizeCacheKey(cmd: string): string {
|
|
89
|
+
const normalized = cmd.replace(/\.(cmd|exe)$/i, "").toLowerCase();
|
|
90
|
+
return `session.toolCache.${normalized}`;
|
|
101
91
|
}
|
|
102
92
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
93
|
+
async function checkToolAvailability(
|
|
94
|
+
command: string,
|
|
95
|
+
facts: FactStore,
|
|
96
|
+
): Promise<boolean> {
|
|
97
|
+
const key = normalizeCacheKey(command);
|
|
98
|
+
const cached = facts.getSessionFact<boolean>(key);
|
|
99
|
+
if (cached !== undefined) {
|
|
100
|
+
return cached;
|
|
110
101
|
}
|
|
111
102
|
try {
|
|
112
103
|
const result = await safeSpawnAsync(command, ["--version"], {
|
|
113
104
|
timeout: 5000,
|
|
114
105
|
});
|
|
115
106
|
const available = result.status === 0;
|
|
116
|
-
|
|
107
|
+
facts.setSessionFact(key, available);
|
|
117
108
|
return available;
|
|
118
109
|
} catch {
|
|
119
|
-
|
|
110
|
+
facts.setSessionFact(key, false);
|
|
120
111
|
return false;
|
|
121
112
|
}
|
|
122
113
|
}
|
|
@@ -127,7 +118,7 @@ export function createDispatchContext(
|
|
|
127
118
|
filePath: string,
|
|
128
119
|
cwd: string,
|
|
129
120
|
pi: PiAgentAPI,
|
|
130
|
-
|
|
121
|
+
facts: FactStore,
|
|
131
122
|
blockingOnly?: boolean,
|
|
132
123
|
modifiedRanges?: import("./types.js").ModifiedRange[],
|
|
133
124
|
): DispatchContext {
|
|
@@ -144,12 +135,12 @@ export function createDispatchContext(
|
|
|
144
135
|
pi,
|
|
145
136
|
autofix: !!(pi.getFlag("autofix-biome") || pi.getFlag("autofix-ruff")),
|
|
146
137
|
deltaMode: !pi.getFlag("no-delta"),
|
|
147
|
-
|
|
138
|
+
facts,
|
|
148
139
|
blockingOnly,
|
|
149
140
|
modifiedRanges,
|
|
150
141
|
|
|
151
142
|
async hasTool(command: string): Promise<boolean> {
|
|
152
|
-
return checkToolAvailability(command);
|
|
143
|
+
return checkToolAvailability(command, facts);
|
|
153
144
|
},
|
|
154
145
|
|
|
155
146
|
log(message: string): void {
|
|
@@ -186,14 +177,7 @@ function semanticRank(semantic: OutputSemantic): number {
|
|
|
186
177
|
}
|
|
187
178
|
|
|
188
179
|
function toolPriority(tool: string, defectClass: string): number {
|
|
189
|
-
|
|
190
|
-
if (defectClass === "silent-error" && t === "tree-sitter") return 200;
|
|
191
|
-
if (t === "lsp" || t === "ts-lsp") return 120;
|
|
192
|
-
if (t === "eslint") return 110;
|
|
193
|
-
if (t.includes("biome")) return 100;
|
|
194
|
-
if (t === "tree-sitter") return 90;
|
|
195
|
-
if (t.includes("ast-grep")) return 80;
|
|
196
|
-
return 50;
|
|
180
|
+
return getToolProfile(tool, defectClass).dedupPriority;
|
|
197
181
|
}
|
|
198
182
|
|
|
199
183
|
function dedupeOverlappingDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] {
|
|
@@ -228,19 +212,7 @@ function suppressLintOverlapsWithLsp(diagnostics: Diagnostic[]): Diagnostic[] {
|
|
|
228
212
|
const lspBySpanClass = new Set<string>();
|
|
229
213
|
const lspByLine = new Set<string>();
|
|
230
214
|
const isLintTool = (tool: string): boolean => {
|
|
231
|
-
|
|
232
|
-
return (
|
|
233
|
-
t === "eslint" ||
|
|
234
|
-
t.includes("biome") ||
|
|
235
|
-
t === "ruff-lint" ||
|
|
236
|
-
t === "oxlint" ||
|
|
237
|
-
t === "rubocop" ||
|
|
238
|
-
t === "go-vet" ||
|
|
239
|
-
t === "golangci-lint" ||
|
|
240
|
-
t === "rust-clippy" ||
|
|
241
|
-
t === "shellcheck" ||
|
|
242
|
-
t === "type-safety"
|
|
243
|
-
);
|
|
215
|
+
return getToolProfile(tool).lintLike;
|
|
244
216
|
};
|
|
245
217
|
|
|
246
218
|
for (const d of diagnostics) {
|
|
@@ -455,6 +427,7 @@ interface GroupResult {
|
|
|
455
427
|
async function runGroup(
|
|
456
428
|
ctx: DispatchContext,
|
|
457
429
|
group: RunnerGroup,
|
|
430
|
+
registry: RunnerRegistryContract,
|
|
458
431
|
): Promise<GroupResult> {
|
|
459
432
|
const diagnostics: Diagnostic[] = [];
|
|
460
433
|
const latencies: RunnerLatency[] = [];
|
|
@@ -463,16 +436,16 @@ async function runGroup(
|
|
|
463
436
|
// Filter runners by kind if specified
|
|
464
437
|
const runnerIds = group.filterKinds
|
|
465
438
|
? group.runnerIds.filter((id) => {
|
|
466
|
-
const runner =
|
|
439
|
+
const runner = registry.get(id);
|
|
467
440
|
return runner && ctx.kind && group.filterKinds?.includes(ctx.kind);
|
|
468
|
-
|
|
441
|
+
})
|
|
469
442
|
: group.runnerIds;
|
|
470
443
|
|
|
471
444
|
const semantic = group.semantic ?? "warning";
|
|
472
445
|
|
|
473
446
|
for (const runnerId of runnerIds) {
|
|
474
447
|
const runnerStart = Date.now();
|
|
475
|
-
const runner =
|
|
448
|
+
const runner = registry.get(runnerId);
|
|
476
449
|
|
|
477
450
|
if (!runner) {
|
|
478
451
|
latencies.push({
|
|
@@ -576,6 +549,7 @@ async function runGroup(
|
|
|
576
549
|
export async function dispatchForFile(
|
|
577
550
|
ctx: DispatchContext,
|
|
578
551
|
groups: RunnerGroup[],
|
|
552
|
+
registry: RunnerRegistryContract,
|
|
579
553
|
): Promise<DispatchResult> {
|
|
580
554
|
const _overallStart = Date.now();
|
|
581
555
|
const allDiagnostics: Diagnostic[] = [];
|
|
@@ -602,14 +576,16 @@ export async function dispatchForFile(
|
|
|
602
576
|
// preserved (sequential first-success). Results are merged in original
|
|
603
577
|
// group order so output is deterministic.
|
|
604
578
|
const groupResults = await Promise.all(
|
|
605
|
-
groups.map((group) => runGroup(ctx, group)),
|
|
579
|
+
groups.map((group) => runGroup(ctx, group, registry)),
|
|
606
580
|
);
|
|
607
581
|
|
|
608
582
|
// Count baseline warnings before filtering (for delta count display)
|
|
609
583
|
const relativeKey = path.relative(ctx.cwd, ctx.filePath).replace(/\\/g, "/");
|
|
584
|
+
const baselineAbsKey = `session.baseline.${normalizeMapKey(ctx.filePath)}`;
|
|
585
|
+
const baselineRelKey = `session.baseline.${normalizeMapKey(relativeKey)}`;
|
|
610
586
|
const previousBaseline = ctx.deltaMode
|
|
611
|
-
? (
|
|
612
|
-
|
|
587
|
+
? (ctx.facts.getSessionFact<Diagnostic[]>(baselineAbsKey) ??
|
|
588
|
+
ctx.facts.getSessionFact<Diagnostic[]>(baselineRelKey))
|
|
613
589
|
: undefined;
|
|
614
590
|
const baselineWarnings = previousBaseline?.filter(
|
|
615
591
|
(d) => d.semantic === "warning" || d.semantic === "none",
|
|
@@ -641,8 +617,8 @@ export async function dispatchForFile(
|
|
|
641
617
|
|
|
642
618
|
// Persist full current snapshot for next run (not delta-filtered subset).
|
|
643
619
|
if (ctx.deltaMode) {
|
|
644
|
-
ctx.
|
|
645
|
-
ctx.
|
|
620
|
+
ctx.facts.setSessionFact(baselineAbsKey, [...dedupedDiagnostics]);
|
|
621
|
+
ctx.facts.setSessionFact(baselineRelKey, [...dedupedDiagnostics]);
|
|
646
622
|
}
|
|
647
623
|
|
|
648
624
|
// Categorize results
|
|
@@ -805,17 +781,25 @@ async function runRunner(
|
|
|
805
781
|
|
|
806
782
|
// --- Simple Integration Helper ---
|
|
807
783
|
|
|
784
|
+
/**
|
|
785
|
+
* @internal
|
|
786
|
+
* Low-level dispatch entry point. Use `dispatchLint` from `./integration.js` instead —
|
|
787
|
+
* that version provides session-persistent baselines and FactStore.
|
|
788
|
+
* This function creates an ephemeral FactStore per call; facts do not persist across calls.
|
|
789
|
+
*/
|
|
808
790
|
export async function dispatchLint(
|
|
809
791
|
filePath: string,
|
|
810
792
|
cwd: string,
|
|
811
793
|
pi: PiAgentAPI,
|
|
812
|
-
|
|
794
|
+
facts: FactStore,
|
|
795
|
+
registry: RunnerRegistryContract,
|
|
813
796
|
): Promise<string> {
|
|
814
797
|
// By default, only run BLOCKING rules for fast feedback on file write
|
|
815
|
-
const ctx = createDispatchContext(filePath, cwd, pi,
|
|
798
|
+
const ctx = createDispatchContext(filePath, cwd, pi, facts, true);
|
|
816
799
|
|
|
817
800
|
// Get runners for this file kind
|
|
818
|
-
|
|
801
|
+
if (!ctx.kind) return "";
|
|
802
|
+
const runners = registry.getForKind(ctx.kind, ctx.filePath);
|
|
819
803
|
if (runners.length === 0) {
|
|
820
804
|
return "";
|
|
821
805
|
}
|
|
@@ -828,6 +812,6 @@ export async function dispatchLint(
|
|
|
828
812
|
},
|
|
829
813
|
];
|
|
830
814
|
|
|
831
|
-
const result = await dispatchForFile(ctx, groups);
|
|
815
|
+
const result = await dispatchForFile(ctx, groups, registry);
|
|
832
816
|
return result.output;
|
|
833
817
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FactStore, ReadonlyFactStore } from "./fact-store.js";
|
|
2
|
+
import type { DispatchContext } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface FactProvider {
|
|
5
|
+
/** e.g. "fact.file.content" */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Keys this provider writes, e.g. ["file.content", "file.lineCount"] */
|
|
8
|
+
provides: string[];
|
|
9
|
+
/** Keys that must exist in the store before this provider runs */
|
|
10
|
+
requires: string[];
|
|
11
|
+
appliesTo(ctx: DispatchContext): boolean;
|
|
12
|
+
run(ctx: DispatchContext, store: FactStore): Promise<void> | void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FactRule {
|
|
16
|
+
/** e.g. "rule.defensive.error-obscuring" */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Keys required from the store — rule is skipped if any are absent */
|
|
19
|
+
requires: string[];
|
|
20
|
+
appliesTo(ctx: DispatchContext): boolean;
|
|
21
|
+
evaluate(ctx: DispatchContext, store: ReadonlyFactStore): import("./types.js").Diagnostic[];
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FactRule } from "./fact-provider-types.js";
|
|
2
|
+
import type { Diagnostic, DispatchContext } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const rules: FactRule[] = [];
|
|
5
|
+
|
|
6
|
+
export function registerRule(r: FactRule): void {
|
|
7
|
+
rules.push(r);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function clearRules(): void {
|
|
11
|
+
rules.length = 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function evaluateRules(ctx: DispatchContext): Diagnostic[] {
|
|
15
|
+
const diagnostics: Diagnostic[] = [];
|
|
16
|
+
for (const rule of rules) {
|
|
17
|
+
if (!rule.appliesTo(ctx)) continue;
|
|
18
|
+
const results = rule.evaluate(ctx, ctx.facts);
|
|
19
|
+
diagnostics.push(...results);
|
|
20
|
+
}
|
|
21
|
+
return diagnostics;
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FactProvider } from "./fact-provider-types.js";
|
|
2
|
+
import type { DispatchContext } from "./types.js";
|
|
3
|
+
import { scheduleProviders } from "./fact-scheduler.js";
|
|
4
|
+
|
|
5
|
+
const providers: FactProvider[] = [];
|
|
6
|
+
|
|
7
|
+
export function registerProvider(p: FactProvider): void {
|
|
8
|
+
providers.push(p);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clearProviders(): void {
|
|
12
|
+
providers.length = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runProviders(ctx: DispatchContext): Promise<void> {
|
|
16
|
+
const applicable = providers.filter((p) => p.appliesTo(ctx));
|
|
17
|
+
const ordered = scheduleProviders(applicable);
|
|
18
|
+
|
|
19
|
+
for (const provider of ordered) {
|
|
20
|
+
// Skip if all provided facts are already present
|
|
21
|
+
const allPresent = provider.provides.every((key) =>
|
|
22
|
+
ctx.facts.hasFileFact(ctx.filePath, key),
|
|
23
|
+
);
|
|
24
|
+
if (allPresent) continue;
|
|
25
|
+
|
|
26
|
+
await provider.run(ctx, ctx.facts);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { FactProvider } from "./fact-provider-types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Orders providers topologically so each provider's `requires` are satisfied
|
|
5
|
+
* before it runs. Tie-breaks alphabetically by `id`. Detects cycles.
|
|
6
|
+
*
|
|
7
|
+
* Only deps that have a provider in the input list count toward in-degree;
|
|
8
|
+
* external facts (provided outside this list) are treated as always available.
|
|
9
|
+
*/
|
|
10
|
+
export function scheduleProviders(providers: FactProvider[]): FactProvider[] {
|
|
11
|
+
if (providers.length <= 1) return providers.slice();
|
|
12
|
+
|
|
13
|
+
// Map: factKey → provider that provides it
|
|
14
|
+
const factToProvider = new Map<string, FactProvider>();
|
|
15
|
+
for (const p of providers) {
|
|
16
|
+
for (const key of p.provides) {
|
|
17
|
+
factToProvider.set(key, p);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// For each provider, track in-degree and which providers depend on it
|
|
22
|
+
const inDegree = new Map<string, number>();
|
|
23
|
+
// dependents[id] = set of provider ids that require a fact provided by this provider
|
|
24
|
+
const dependents = new Map<string, Set<string>>();
|
|
25
|
+
|
|
26
|
+
for (const p of providers) {
|
|
27
|
+
if (!inDegree.has(p.id)) inDegree.set(p.id, 0);
|
|
28
|
+
if (!dependents.has(p.id)) dependents.set(p.id, new Set());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const p of providers) {
|
|
32
|
+
const seenDeps = new Set<string>();
|
|
33
|
+
for (const req of p.requires) {
|
|
34
|
+
const dep = factToProvider.get(req);
|
|
35
|
+
if (dep && dep.id !== p.id && !seenDeps.has(dep.id)) {
|
|
36
|
+
seenDeps.add(dep.id);
|
|
37
|
+
inDegree.set(p.id, (inDegree.get(p.id) ?? 0) + 1);
|
|
38
|
+
dependents.get(dep.id)!.add(p.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const idToProvider = new Map<string, FactProvider>(providers.map((p) => [p.id, p]));
|
|
44
|
+
|
|
45
|
+
// Start with providers that have no unsatisfied deps, sorted by id
|
|
46
|
+
let wave = providers
|
|
47
|
+
.filter((p) => (inDegree.get(p.id) ?? 0) === 0)
|
|
48
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
49
|
+
|
|
50
|
+
const result: FactProvider[] = [];
|
|
51
|
+
|
|
52
|
+
while (wave.length > 0) {
|
|
53
|
+
const nextWave: FactProvider[] = [];
|
|
54
|
+
for (const p of wave) {
|
|
55
|
+
result.push(p);
|
|
56
|
+
for (const depId of dependents.get(p.id) ?? []) {
|
|
57
|
+
const newDegree = (inDegree.get(depId) ?? 0) - 1;
|
|
58
|
+
inDegree.set(depId, newDegree);
|
|
59
|
+
if (newDegree === 0) {
|
|
60
|
+
nextWave.push(idToProvider.get(depId)!);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
wave = nextWave.sort((a, b) => a.id.localeCompare(b.id));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (result.length < providers.length) {
|
|
68
|
+
const cycleParticipants = providers
|
|
69
|
+
.filter((p) => !result.includes(p))
|
|
70
|
+
.map((p) => p.id)
|
|
71
|
+
.sort();
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Cycle detected among FactProviders: ${cycleParticipants.join(", ")}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { normalizeMapKey } from "../path-utils.js";
|
|
2
|
+
|
|
3
|
+
type FactValue = unknown;
|
|
4
|
+
|
|
5
|
+
export interface ReadonlyFactStore {
|
|
6
|
+
getFileFact<T>(filePath: string, factId: string): T | undefined;
|
|
7
|
+
hasFileFact(filePath: string, factId: string): boolean;
|
|
8
|
+
getSessionFact<T>(factId: string): T | undefined;
|
|
9
|
+
hasSessionFact(factId: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class FactStore implements ReadonlyFactStore {
|
|
13
|
+
private readonly fileFacts = new Map<string, Map<string, FactValue>>();
|
|
14
|
+
private readonly sessionFacts = new Map<string, FactValue>();
|
|
15
|
+
|
|
16
|
+
// All file-keyed methods normalize the path internally via normalizeMapKey().
|
|
17
|
+
// Callers always pass raw/resolved paths — normalization is not their concern.
|
|
18
|
+
|
|
19
|
+
getFileFact<T>(filePath: string, factId: string): T | undefined {
|
|
20
|
+
return this.fileFacts.get(normalizeMapKey(filePath))?.get(factId) as T | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setFileFact(filePath: string, factId: string, value: FactValue): void {
|
|
24
|
+
const key = normalizeMapKey(filePath);
|
|
25
|
+
let facts = this.fileFacts.get(key);
|
|
26
|
+
if (!facts) {
|
|
27
|
+
facts = new Map();
|
|
28
|
+
this.fileFacts.set(key, facts);
|
|
29
|
+
}
|
|
30
|
+
facts.set(factId, value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
hasFileFact(filePath: string, factId: string): boolean {
|
|
34
|
+
return this.fileFacts.get(normalizeMapKey(filePath))?.has(factId) ?? false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Clear facts for one specific file only. Use at the start of each per-file dispatch call.
|
|
38
|
+
* Preserves facts for other files computed in the same turn.
|
|
39
|
+
* Normalizes filePath internally — callers pass raw paths. */
|
|
40
|
+
clearFileFactsFor(filePath: string): void {
|
|
41
|
+
this.fileFacts.delete(normalizeMapKey(filePath));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Clear all file facts across all paths. Reserve for explicit full resets only —
|
|
45
|
+
* do NOT use in the normal per-file dispatch path. */
|
|
46
|
+
clearFileFacts(): void {
|
|
47
|
+
this.fileFacts.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getSessionFact<T>(factId: string): T | undefined {
|
|
51
|
+
return this.sessionFacts.get(factId) as T | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setSessionFact(factId: string, value: FactValue): void {
|
|
55
|
+
this.sessionFacts.set(factId, value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
hasSessionFact(factId: string): boolean {
|
|
59
|
+
return this.sessionFacts.has(factId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Call on session reset only. Clears everything including tool cache and baselines. */
|
|
63
|
+
clearAll(): void {
|
|
64
|
+
this.fileFacts.clear();
|
|
65
|
+
this.sessionFacts.clear();
|
|
66
|
+
}
|
|
67
|
+
}
|