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.
Files changed (55) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/clients/bootstrap.ts +106 -0
  3. package/clients/dispatch/dispatcher.ts +75 -91
  4. package/clients/dispatch/fact-provider-types.ts +22 -0
  5. package/clients/dispatch/fact-rule-runner.ts +22 -0
  6. package/clients/dispatch/fact-runner.ts +28 -0
  7. package/clients/dispatch/fact-scheduler.ts +78 -0
  8. package/clients/dispatch/fact-store.ts +67 -0
  9. package/clients/dispatch/facts/comment-facts.ts +59 -0
  10. package/clients/dispatch/facts/file-content.ts +20 -0
  11. package/clients/dispatch/facts/function-facts.ts +177 -0
  12. package/clients/dispatch/facts/try-catch-facts.ts +80 -0
  13. package/clients/dispatch/integration.ts +130 -24
  14. package/clients/dispatch/priorities.ts +22 -0
  15. package/clients/dispatch/rules/async-noise.ts +43 -0
  16. package/clients/dispatch/rules/error-obscuring.ts +40 -0
  17. package/clients/dispatch/rules/error-swallowing.ts +35 -0
  18. package/clients/dispatch/rules/pass-through-wrappers.ts +52 -0
  19. package/clients/dispatch/rules/placeholder-comments.ts +47 -0
  20. package/clients/dispatch/runners/architect.ts +2 -1
  21. package/clients/dispatch/runners/ast-grep-napi.ts +2 -1
  22. package/clients/dispatch/runners/biome-check.ts +40 -8
  23. package/clients/dispatch/runners/biome.ts +2 -1
  24. package/clients/dispatch/runners/eslint.ts +34 -6
  25. package/clients/dispatch/runners/go-vet.ts +2 -1
  26. package/clients/dispatch/runners/golangci-lint.ts +2 -1
  27. package/clients/dispatch/runners/index.ts +29 -27
  28. package/clients/dispatch/runners/lsp.ts +2 -1
  29. package/clients/dispatch/runners/oxlint.ts +2 -1
  30. package/clients/dispatch/runners/pyright.ts +2 -1
  31. package/clients/dispatch/runners/python-slop.ts +2 -1
  32. package/clients/dispatch/runners/rubocop.ts +2 -1
  33. package/clients/dispatch/runners/ruff.ts +2 -1
  34. package/clients/dispatch/runners/rust-clippy.ts +2 -1
  35. package/clients/dispatch/runners/shellcheck.ts +2 -1
  36. package/clients/dispatch/runners/similarity.ts +2 -1
  37. package/clients/dispatch/runners/spellcheck.ts +2 -1
  38. package/clients/dispatch/runners/sqlfluff.ts +2 -1
  39. package/clients/dispatch/runners/tree-sitter.ts +2 -1
  40. package/clients/dispatch/runners/ts-lsp.ts +2 -1
  41. package/clients/dispatch/runners/type-safety.ts +2 -1
  42. package/clients/dispatch/runners/yamllint.ts +2 -1
  43. package/clients/dispatch/tool-profile.ts +40 -0
  44. package/clients/dispatch/types.ts +3 -13
  45. package/clients/lsp/client.ts +137 -9
  46. package/clients/lsp/config.ts +17 -9
  47. package/clients/lsp/index.ts +263 -75
  48. package/clients/lsp/launch.ts +42 -2
  49. package/clients/lsp/server.ts +195 -13
  50. package/clients/runtime-context.ts +2 -2
  51. package/clients/session-summary.ts +21 -0
  52. package/index.ts +123 -51
  53. package/package.json +2 -1
  54. package/tools/lsp-navigation.js +263 -107
  55. 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
- // --- In-Memory Baseline Store ---
44
+ // --- Runner Registry ---
43
45
 
44
- export function createBaselineStore(): BaselineStore {
45
- const baselines = new Map<string, unknown[]>();
46
+ export class RunnerRegistry implements RunnerRegistryContract {
47
+ private readonly runners = new Map<string, RunnerDefinition>();
46
48
 
47
- return {
48
- get(filePath) {
49
- return baselines.get(normalizeMapKey(filePath));
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
- const globalRegistry = new Map<string, RunnerDefinition>();
54
+ get(id: string): RunnerDefinition | undefined {
55
+ return this.runners.get(id);
56
+ }
63
57
 
64
- export function registerRunner(runner: RunnerDefinition): void {
65
- if (globalRegistry.has(runner.id)) return; // Already registered, skip silently
66
- globalRegistry.set(runner.id, runner);
67
- }
58
+ getForKind(kind: FileKind, filePath?: string): RunnerDefinition[] {
59
+ const matching: RunnerDefinition[] = [];
60
+ const isTest = filePath ? isTestFile(filePath) : false;
68
61
 
69
- export function getRunner(id: string): RunnerDefinition | undefined {
70
- return globalRegistry.get(id);
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
- export function getRunnersForKind(
74
- kind: FileKind | undefined,
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
- for (const runner of globalRegistry.values()) {
82
- // Skip runners that shouldn't run on test files
83
- if (isTest && runner.skipTestFiles) continue;
72
+ list(): RunnerDefinition[] {
73
+ return Array.from(this.runners.values());
74
+ }
84
75
 
85
- if (runner.appliesTo.includes(kind) || runner.appliesTo.length === 0) {
86
- runners.push(runner);
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
- export function listRunners(): RunnerDefinition[] {
93
- return Array.from(globalRegistry.values());
94
- }
81
+ // --- Tool Availability Cache ---
95
82
 
96
83
  /**
97
- * Clear all registered runners. Used primarily for testing.
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 clearRunnerRegistry(): void {
100
- globalRegistry.clear();
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
- // --- Tool Availability Cache ---
104
-
105
- const toolCache = new Map<string, boolean>();
106
-
107
- async function checkToolAvailability(command: string): Promise<boolean> {
108
- if (toolCache.has(command)) {
109
- return toolCache.get(command)!;
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
- toolCache.set(command, available);
107
+ facts.setSessionFact(key, available);
117
108
  return available;
118
109
  } catch {
119
- toolCache.set(command, false);
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
- baselines?: BaselineStore,
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
- baselines: baselines ?? createBaselineStore(),
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
- const t = tool.toLowerCase();
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
- const t = tool.toLowerCase();
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 = getRunner(id);
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 = getRunner(runnerId);
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
- ? ((ctx.baselines.get(ctx.filePath) as Diagnostic[] | undefined) ??
612
- (ctx.baselines.get(relativeKey) as Diagnostic[] | undefined))
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.baselines.set(ctx.filePath, [...dedupedDiagnostics]);
645
- ctx.baselines.set(relativeKey, [...dedupedDiagnostics]);
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
- baselines?: BaselineStore,
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, baselines, true);
798
+ const ctx = createDispatchContext(filePath, cwd, pi, facts, true);
816
799
 
817
800
  // Get runners for this file kind
818
- const runners = getRunnersForKind(ctx.kind);
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
+ }