@danypops/lector 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -64,5 +64,7 @@ Three independent `SymbolIndexPort` implementations use one result DTO with expl
64
64
  - **TypeScript compiler** extracts bounded structural declarations from cold, malformed, or partially configured projects. It does not claim cross-file identity.
65
65
  - **tree-sitter** extracts bounded structural declarations and caches them by content hash. It does not claim type or language-server identity.
66
66
 
67
- Symbol results report `fidelity`, `backend`, `authority`, `freshness`, `limitations`, and `truncated`. Language-server messages, pending requests, opened files, file bytes, settling, parser files, parser nodes, parser bytes, and returned symbols are bounded.
67
+ File-position operations dispatch by extension and seed a cold server with the requested file. Workspace symbol search and graph population detect every enabled language, merge results deterministically, and retain per-symbol and per-backend provenance. Backend and file-level semantic failures produce bounded partial results without discarding successful languages or files. Bash remains explicit-file-only until its vulnerable upstream dependency chain is removed; generic YAML is explicit-file-only because lockfiles and data files do not establish a workspace language.
68
+
69
+ Symbol results report `fidelity`, `backend`, `authority`, `freshness`, `limitations`, `truncated`, and composite `sources`/`completeness` when applicable. Language-server messages, pending requests, opened files, file bytes, settling, parser files, parser nodes, parser bytes, language indexes, and returned symbols are bounded.
68
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/lector",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Platform-neutral filesystem & code-intelligence capability: core, service, and driven adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,6 +6,7 @@ import { refineTypescriptSeedFile } from "./typescript-project-files.ts";
6
6
  const SKIP_DIRECTORY_NAMES = new Set(["node_modules", "dist", "build", "out", "coverage"]);
7
7
  const MAX_SCAN_DEPTH = 4;
8
8
  const MAX_ENTRIES_SCANNED = 2_000;
9
+ const MAX_WORKSPACE_DESCRIPTORS = 32;
9
10
 
10
11
  /** Raised when no matching source file could be found to warm a language server with. */
11
12
  export class NoSeedFileFound extends Error {
@@ -110,21 +111,28 @@ export function resolveSeedFile(rootPath: string, descriptor: LanguageServerDesc
110
111
  return descriptor.languageId === "typescript" ? refineTypescriptSeedFile(rootPath, candidate) : candidate;
111
112
  }
112
113
 
113
- /**
114
- * For workspace.findSymbols called with no seedFile -- no anchor file to pick a language from.
115
- * Tries each descriptor's own discoverSeedFile in declared order; first real match wins.
116
- */
117
- export function discoverWorkspaceDescriptor(
114
+ /** Detects every auto-enabled language in descriptor order; each language keeps the same bounded seed scan. */
115
+ export function discoverWorkspaceDescriptors(
118
116
  rootPath: string,
119
117
  descriptors: readonly LanguageServerDescriptor[],
120
- ): { descriptor: LanguageServerDescriptor; seedFile: string } | undefined {
118
+ ): readonly { descriptor: LanguageServerDescriptor; seedFile: string }[] {
119
+ if (descriptors.length > MAX_WORKSPACE_DESCRIPTORS) throw new TypeError(`workspace descriptor count exceeds ${MAX_WORKSPACE_DESCRIPTORS}`);
120
+ const discovered: { descriptor: LanguageServerDescriptor; seedFile: string }[] = [];
121
121
  for (const descriptor of descriptors) {
122
+ if (descriptor.workspaceDiscovery === "explicit-only") continue;
122
123
  try {
123
- const seedFile = resolveSeedFile(rootPath, descriptor);
124
- return { descriptor, seedFile };
124
+ discovered.push({ descriptor, seedFile: resolveSeedFile(rootPath, descriptor) });
125
125
  } catch (error) {
126
126
  if (!(error instanceof NoSeedFileFound)) throw error;
127
127
  }
128
128
  }
129
- return undefined;
129
+ return discovered;
130
+ }
131
+
132
+ /** Compatibility helper for callers that still need one deterministic primary language. */
133
+ export function discoverWorkspaceDescriptor(
134
+ rootPath: string,
135
+ descriptors: readonly LanguageServerDescriptor[],
136
+ ): { descriptor: LanguageServerDescriptor; seedFile: string } | undefined {
137
+ return discoverWorkspaceDescriptors(rootPath, descriptors)[0];
130
138
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { extname, join } from "node:path";
2
+ import { extname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "../../domain/call-hierarchy.ts";
5
5
  import type { CodeRange } from "../../domain/code-range.ts";
@@ -27,6 +27,16 @@ export interface LspSymbolIndexOptions {
27
27
  readonly maxFallbackSeedFiles?: number;
28
28
  }
29
29
 
30
+ export class LanguageFileOutsideWorkspace extends Error {
31
+ constructor(
32
+ readonly path: string,
33
+ readonly root: string,
34
+ ) {
35
+ super(`language file "${path}" resolves outside workspace root "${root}"`);
36
+ this.name = "LanguageFileOutsideWorkspace";
37
+ }
38
+ }
39
+
30
40
  export class LanguageFileLimitExceeded extends Error {
31
41
  constructor(
32
42
  readonly limit: "open-files" | "file-bytes" | "refresh-bytes",
@@ -270,6 +280,7 @@ function normalizeDocumentSymbol(path: string, item: LspDocumentSymbol | LspSymb
270
280
  export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
271
281
  readonly provenance: IntelligenceProvenance;
272
282
  private readonly cwd: string;
283
+ private readonly canonicalCwd: string;
273
284
  private readonly descriptor: LanguageServerDescriptor;
274
285
  private readonly explicitSeedFile: string | undefined;
275
286
  private fallbackSeedFile: string | undefined;
@@ -284,7 +295,8 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
284
295
  private initializing: Promise<LanguageServerProcess> | undefined;
285
296
 
286
297
  constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string, options: LspSymbolIndexOptions = {}) {
287
- this.cwd = cwd;
298
+ this.cwd = resolve(cwd);
299
+ this.canonicalCwd = realpathSync(this.cwd);
288
300
  this.descriptor = descriptor;
289
301
  this.explicitSeedFile = seedFile;
290
302
  this.maxOpenFiles = positiveLimit(options.maxOpenFiles, DEFAULT_MAX_OPEN_FILES, "maxOpenFiles");
@@ -309,7 +321,18 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
309
321
  return this.process?.pid;
310
322
  }
311
323
 
312
- private async ensureInitialized(): Promise<LanguageServerProcess> {
324
+ private resolveTargetPath(path: string): string {
325
+ const absolute = resolve(this.cwd, path);
326
+ const canonical = realpathSync(absolute);
327
+ const relativeToRoot = relative(this.canonicalCwd, canonical);
328
+ if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) {
329
+ throw new LanguageFileOutsideWorkspace(path, this.cwd);
330
+ }
331
+ return absolute;
332
+ }
333
+
334
+ private async ensureInitialized(initialPath?: string): Promise<LanguageServerProcess> {
335
+ const initialTargetPath = initialPath ? this.resolveTargetPath(initialPath) : undefined;
313
336
  if (this.process) return this.process;
314
337
  if (!this.initializing) {
315
338
  let spawned: LanguageServerProcess | undefined;
@@ -350,8 +373,11 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
350
373
  });
351
374
  proc.notify("initialized", {});
352
375
 
353
- const seedFile = this.fallbackSeedFile ?? this.explicitSeedFile ?? resolveSeedFile(this.cwd, this.descriptor);
354
- await this.ensureFileOpen(proc, join(this.cwd, seedFile));
376
+ const configuredSeedFile = this.fallbackSeedFile ?? this.explicitSeedFile;
377
+ const seedPath = configuredSeedFile
378
+ ? resolve(this.cwd, configuredSeedFile)
379
+ : (initialTargetPath ?? resolve(this.cwd, resolveSeedFile(this.cwd, this.descriptor)));
380
+ await this.ensureFileOpen(proc, seedPath);
355
381
 
356
382
  this.process = proc;
357
383
  return proc;
@@ -374,6 +400,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
374
400
 
375
401
  /** Opens a file or sends a monotonic full-document change when its disk content changed since the prior query. */
376
402
  private async ensureFileOpen(proc: LanguageServerProcess, path: string): Promise<void> {
403
+ path = this.resolveTargetPath(path);
377
404
  const content = this.readBoundedFile(path);
378
405
  const opened = this.openedFiles.get(path);
379
406
  if (opened?.content === content) return;
@@ -459,7 +486,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
459
486
  }
460
487
 
461
488
  async goToDefinition(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
462
- const proc = await this.ensureInitialized();
489
+ const proc = await this.ensureInitialized(at.path);
463
490
  await this.ensureFileOpen(proc, at.path);
464
491
  const result = await proc.request<LspLocation | LspLocation[] | LspLocationLink[] | null>("textDocument/definition", {
465
492
  textDocument: { uri: pathToFileURL(at.path).href },
@@ -469,7 +496,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
469
496
  }
470
497
 
471
498
  async goToImplementation(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
472
- const proc = await this.ensureInitialized();
499
+ const proc = await this.ensureInitialized(at.path);
473
500
  await this.ensureFileOpen(proc, at.path);
474
501
  const result = await proc.request<LspLocation | LspLocation[] | LspLocationLink[] | null>("textDocument/implementation", {
475
502
  textDocument: { uri: pathToFileURL(at.path).href },
@@ -479,7 +506,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
479
506
  }
480
507
 
481
508
  async findReferences(at: WorkspaceLocation, includeDeclaration: boolean): Promise<WorkspaceLocation[]> {
482
- const proc = await this.ensureInitialized();
509
+ const proc = await this.ensureInitialized(at.path);
483
510
  await this.ensureFileOpen(proc, at.path);
484
511
  const results =
485
512
  (await proc.request<LspLocation[] | null>("textDocument/references", {
@@ -491,7 +518,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
491
518
  }
492
519
 
493
520
  async hover(at: WorkspaceLocation): Promise<Hover | undefined> {
494
- const proc = await this.ensureInitialized();
521
+ const proc = await this.ensureInitialized(at.path);
495
522
  await this.ensureFileOpen(proc, at.path);
496
523
  const result = await proc.request<LspHover | null>("textDocument/hover", {
497
524
  textDocument: { uri: pathToFileURL(at.path).href },
@@ -502,7 +529,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
502
529
  }
503
530
 
504
531
  async documentSymbols(path: string): Promise<DocumentSymbolEntry[]> {
505
- const proc = await this.ensureInitialized();
532
+ const proc = await this.ensureInitialized(path);
506
533
  await this.ensureFileOpen(proc, path);
507
534
  const results =
508
535
  (await proc.request<(LspDocumentSymbol | LspSymbolInformation)[] | null>("textDocument/documentSymbol", {
@@ -512,7 +539,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
512
539
  }
513
540
 
514
541
  async diagnostics(path: string): Promise<Diagnostic[]> {
515
- const proc = await this.ensureInitialized();
542
+ const proc = await this.ensureInitialized(path);
516
543
  await this.ensureFileOpen(proc, path);
517
544
  if (!this.latestDiagnostics.has(path)) await this.waitForDiagnosticsNotification(path, 5000);
518
545
  return this.latestDiagnostics.get(path) ?? [];
@@ -529,13 +556,13 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
529
556
  }
530
557
 
531
558
  async prepareCallHierarchy(at: WorkspaceLocation): Promise<CallHierarchyEntry[]> {
532
- const proc = await this.ensureInitialized();
559
+ const proc = await this.ensureInitialized(at.path);
533
560
  const items = await this.prepareCallHierarchyRaw(proc, at);
534
561
  return items.map((item) => normalizeCallHierarchyItem(item));
535
562
  }
536
563
 
537
564
  async incomingCalls(at: WorkspaceLocation): Promise<IncomingCall[]> {
538
- const proc = await this.ensureInitialized();
565
+ const proc = await this.ensureInitialized(at.path);
539
566
  const root = (await this.prepareCallHierarchyRaw(proc, at))[0];
540
567
  if (!root) return [];
541
568
  const results = (await proc.request<LspCallHierarchyIncomingCall[] | null>("callHierarchy/incomingCalls", { item: root })) ?? [];
@@ -546,7 +573,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
546
573
  }
547
574
 
548
575
  async outgoingCalls(at: WorkspaceLocation): Promise<OutgoingCall[]> {
549
- const proc = await this.ensureInitialized();
576
+ const proc = await this.ensureInitialized(at.path);
550
577
  const root = (await this.prepareCallHierarchyRaw(proc, at))[0];
551
578
  if (!root) return [];
552
579
  const results = (await proc.request<LspCallHierarchyOutgoingCall[] | null>("callHierarchy/outgoingCalls", { item: root })) ?? [];
@@ -0,0 +1,139 @@
1
+ import { extname } from "node:path";
2
+ import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "../domain/call-hierarchy.ts";
3
+ import type { Diagnostic } from "../domain/diagnostic.ts";
4
+ import type { DocumentSymbolEntry } from "../domain/document-symbol.ts";
5
+ import type { Hover } from "../domain/hover.ts";
6
+ import type { IntelligenceProvenance, IntelligenceSourceOutcome, SymbolSearchBounds } from "../domain/intelligence-provenance.ts";
7
+ import type { LanguageServerDescriptor } from "../domain/language-server-descriptor.ts";
8
+ import type { SymbolSearchResult, WorkspaceLocation, WorkspaceSymbol } from "../domain/workspace-symbol.ts";
9
+ import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
10
+ import type { SymbolIndexPort } from "../ports/symbol-index-port.ts";
11
+
12
+ const MAX_SOURCE_ERROR_LENGTH = 500;
13
+ const MAX_LANGUAGE_INDEXES = 16;
14
+ const DEFAULT_MAX_RESULTS = 1_000;
15
+
16
+ export interface PolyglotIndexEntry {
17
+ readonly descriptor: LanguageServerDescriptor;
18
+ readonly index: SymbolIndexPort;
19
+ }
20
+
21
+ function boundedError(error: unknown): { code: string; message: string } {
22
+ const code = error instanceof Error && error.name ? error.name : "CodeIntelligenceError";
23
+ const message = (error instanceof Error ? error.message : String(error)).slice(0, MAX_SOURCE_ERROR_LENGTH);
24
+ return { code, message };
25
+ }
26
+
27
+ function supportsCodeIntelligence(index: SymbolIndexPort): index is SymbolIndexPort & CodeIntelligencePort {
28
+ return "goToDefinition" in index && typeof index.goToDefinition === "function";
29
+ }
30
+
31
+ function compareSymbols(left: WorkspaceSymbol, right: WorkspaceSymbol): number {
32
+ return (
33
+ left.location.path.localeCompare(right.location.path) ||
34
+ left.location.line - right.location.line ||
35
+ left.location.character - right.location.character ||
36
+ left.name.localeCompare(right.name) ||
37
+ left.kind.localeCompare(right.kind)
38
+ );
39
+ }
40
+
41
+ export class PolyglotCodeIntelligenceIndex implements SymbolIndexPort, CodeIntelligencePort {
42
+ readonly provenance: IntelligenceProvenance = {
43
+ fidelity: "semantic",
44
+ backend: "polyglot-language-servers",
45
+ languageId: "polyglot",
46
+ authority: "language-server",
47
+ freshness: "live-process",
48
+ limitations: [],
49
+ };
50
+
51
+ constructor(private readonly entries: readonly PolyglotIndexEntry[]) {
52
+ if (entries.length === 0 || entries.length > MAX_LANGUAGE_INDEXES) {
53
+ throw new TypeError(`polyglot index requires between 1 and ${MAX_LANGUAGE_INDEXES} language indexes`);
54
+ }
55
+ }
56
+
57
+ private indexForPath(path: string): SymbolIndexPort & CodeIntelligencePort {
58
+ const extension = extname(path);
59
+ const entry = this.entries.find((candidate) => candidate.descriptor.extensions.includes(extension));
60
+ if (!entry) throw new Error(`no language index available for extension "${extension}"`);
61
+ if (!supportsCodeIntelligence(entry.index)) throw new Error(`code intelligence unavailable for ${entry.descriptor.languageId}`);
62
+ return entry.index;
63
+ }
64
+
65
+ async findSymbols(query: string, bounds: SymbolSearchBounds = { maxResults: DEFAULT_MAX_RESULTS }): Promise<SymbolSearchResult> {
66
+ if (!Number.isSafeInteger(bounds.maxResults) || bounds.maxResults < 1) throw new TypeError("maxResults must be a positive safe integer");
67
+ const settled = await Promise.allSettled(this.entries.map(({ index }) => index.findSymbols(query, bounds)));
68
+ const symbols: WorkspaceSymbol[] = [];
69
+ const sources: IntelligenceSourceOutcome[] = [];
70
+ let sourceTruncated = false;
71
+
72
+ for (const [position, outcome] of settled.entries()) {
73
+ const entry = this.entries[position];
74
+ if (!entry) continue;
75
+ if (outcome.status === "rejected") {
76
+ sources.push({ provenance: entry.index.provenance, status: "failed", symbolCount: 0, error: boundedError(outcome.reason) });
77
+ continue;
78
+ }
79
+ sources.push({
80
+ provenance: outcome.value.provenance,
81
+ status: "ready",
82
+ symbolCount: outcome.value.symbols.length,
83
+ truncated: outcome.value.truncated,
84
+ });
85
+ sourceTruncated ||= outcome.value.truncated;
86
+ for (const symbol of outcome.value.symbols) symbols.push({ ...symbol, provenance: outcome.value.provenance });
87
+ }
88
+
89
+ symbols.sort(compareSymbols);
90
+ const truncated = sourceTruncated || symbols.length > bounds.maxResults;
91
+ return {
92
+ symbols: symbols.slice(0, bounds.maxResults),
93
+ truncated,
94
+ provenance: this.provenance,
95
+ completeness: sources.every((source) => source.status === "ready") ? "complete" : "partial",
96
+ sources,
97
+ };
98
+ }
99
+
100
+ provenanceForPath(path: string): IntelligenceProvenance {
101
+ return this.indexForPath(path).provenance;
102
+ }
103
+
104
+ goToDefinition(at: WorkspaceLocation) {
105
+ return this.indexForPath(at.path).goToDefinition(at);
106
+ }
107
+
108
+ goToImplementation(at: WorkspaceLocation) {
109
+ return this.indexForPath(at.path).goToImplementation(at);
110
+ }
111
+
112
+ findReferences(at: WorkspaceLocation, includeDeclaration: boolean) {
113
+ return this.indexForPath(at.path).findReferences(at, includeDeclaration);
114
+ }
115
+
116
+ hover(at: WorkspaceLocation): Promise<Hover | undefined> {
117
+ return this.indexForPath(at.path).hover(at);
118
+ }
119
+
120
+ documentSymbols(path: string): Promise<DocumentSymbolEntry[]> {
121
+ return this.indexForPath(path).documentSymbols(path);
122
+ }
123
+
124
+ diagnostics(path: string): Promise<Diagnostic[]> {
125
+ return this.indexForPath(path).diagnostics(path);
126
+ }
127
+
128
+ prepareCallHierarchy(at: WorkspaceLocation): Promise<CallHierarchyEntry[]> {
129
+ return this.indexForPath(at.path).prepareCallHierarchy(at);
130
+ }
131
+
132
+ incomingCalls(at: WorkspaceLocation): Promise<IncomingCall[]> {
133
+ return this.indexForPath(at.path).incomingCalls(at);
134
+ }
135
+
136
+ outgoingCalls(at: WorkspaceLocation): Promise<OutgoingCall[]> {
137
+ return this.indexForPath(at.path).outgoingCalls(at);
138
+ }
139
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import { type Migration, openSqliteWithPragmas } from "@danypops/daemon-kit/storage";
3
3
  import type { IntelligenceProvenance } from "../domain/intelligence-provenance.ts";
4
+ import type { PopulateSymbolGraphResult, SymbolGraphPopulationFailure } from "../domain/populate-symbol-graph.ts";
4
5
  import type { SymbolGraphGeneration } from "../domain/symbol-graph-generation.ts";
5
6
  import type { SymbolNodeId } from "../domain/symbol-node-id.ts";
6
7
  import type { SymbolEdgeKind, SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
@@ -28,6 +29,14 @@ const MIGRATIONS: Migration[] = [
28
29
  version: 3,
29
30
  up: (db) => db.exec("ALTER TABLE symbol_graph_generation ADD COLUMN provenance_json TEXT"),
30
31
  },
32
+ {
33
+ version: 4,
34
+ up: (db) => db.exec("ALTER TABLE symbol_graph_generation ADD COLUMN sources_json TEXT"),
35
+ },
36
+ {
37
+ version: 5,
38
+ up: (db) => db.exec("ALTER TABLE symbol_graph_generation ADD COLUMN result_json TEXT"),
39
+ },
31
40
  ];
32
41
 
33
42
  interface NodeRow {
@@ -48,20 +57,15 @@ interface GenerationRow {
48
57
  nodes_added: number;
49
58
  edges_added: number;
50
59
  provenance_json: string | null;
60
+ sources_json: string | null;
61
+ result_json: string | null;
51
62
  }
52
63
 
53
64
  function isRecord(value: unknown): value is Record<string, unknown> {
54
65
  return typeof value === "object" && value !== null;
55
66
  }
56
67
 
57
- function parseProvenance(json: string | null): IntelligenceProvenance | undefined {
58
- if (!json) return undefined;
59
- let value: unknown;
60
- try {
61
- value = JSON.parse(json);
62
- } catch {
63
- return undefined;
64
- }
68
+ function asProvenance(value: unknown): IntelligenceProvenance | undefined {
65
69
  if (!isRecord(value)) return undefined;
66
70
  const candidate = value;
67
71
  if (
@@ -85,6 +89,89 @@ function parseProvenance(json: string | null): IntelligenceProvenance | undefine
85
89
  };
86
90
  }
87
91
 
92
+ function parseJson(json: string | null): unknown {
93
+ if (!json) return undefined;
94
+ try {
95
+ return JSON.parse(json);
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ function parseProvenance(json: string | null): IntelligenceProvenance | undefined {
102
+ return asProvenance(parseJson(json));
103
+ }
104
+
105
+ function parseSources(json: string | null): readonly IntelligenceProvenance[] | undefined {
106
+ const value = parseJson(json);
107
+ if (!Array.isArray(value)) return undefined;
108
+ const sources = value.map(asProvenance);
109
+ return sources.every((source) => source !== undefined) ? sources : undefined;
110
+ }
111
+
112
+ function isNonNegativeInteger(value: unknown): value is number {
113
+ return Number.isSafeInteger(value) && typeof value === "number" && value >= 0;
114
+ }
115
+
116
+ function asPopulationFailure(value: unknown): SymbolGraphPopulationFailure | undefined {
117
+ if (!isRecord(value)) return undefined;
118
+ const provenance = asProvenance(value.provenance);
119
+ if (
120
+ typeof value.path !== "string" ||
121
+ value.path.length > 4096 ||
122
+ (value.operation !== "document-symbols" && value.operation !== "outgoing-calls") ||
123
+ typeof value.code !== "string" ||
124
+ value.code.length > 100 ||
125
+ typeof value.message !== "string" ||
126
+ value.message.length > 500 ||
127
+ !provenance
128
+ ) {
129
+ return undefined;
130
+ }
131
+ return { path: value.path, operation: value.operation, code: value.code, message: value.message, provenance };
132
+ }
133
+
134
+ function parsePopulationResult(json: string | null): PopulateSymbolGraphResult | undefined {
135
+ const value = parseJson(json);
136
+ if (!isRecord(value) || (value.completeness !== "complete" && value.completeness !== "partial") || !Array.isArray(value.failures)) return undefined;
137
+ if (
138
+ !isNonNegativeInteger(value.filesAttempted) ||
139
+ !isNonNegativeInteger(value.filesProcessed) ||
140
+ !isNonNegativeInteger(value.filesFailed) ||
141
+ !isNonNegativeInteger(value.symbolsProcessed) ||
142
+ !isNonNegativeInteger(value.nodesAdded) ||
143
+ !isNonNegativeInteger(value.edgesAdded) ||
144
+ !isNonNegativeInteger(value.failureCount) ||
145
+ typeof value.failuresTruncated !== "boolean" ||
146
+ value.failures.length > 100
147
+ ) {
148
+ return undefined;
149
+ }
150
+ const failures = value.failures.map(asPopulationFailure);
151
+ if (
152
+ !failures.every((failure) => failure !== undefined) ||
153
+ value.filesProcessed > value.filesAttempted ||
154
+ value.filesFailed > value.filesAttempted ||
155
+ failures.length > value.failureCount ||
156
+ (value.completeness === "complete") !== (value.failureCount === 0) ||
157
+ value.failuresTruncated !== value.failureCount > failures.length
158
+ ) {
159
+ return undefined;
160
+ }
161
+ return {
162
+ completeness: value.completeness,
163
+ filesAttempted: value.filesAttempted,
164
+ filesProcessed: value.filesProcessed,
165
+ filesFailed: value.filesFailed,
166
+ symbolsProcessed: value.symbolsProcessed,
167
+ nodesAdded: value.nodesAdded,
168
+ edgesAdded: value.edgesAdded,
169
+ failureCount: value.failureCount,
170
+ failures,
171
+ failuresTruncated: value.failuresTruncated,
172
+ };
173
+ }
174
+
88
175
  /**
89
176
  * SQLite-backed SymbolGraphPort; survives a daemon restart pointed at the
90
177
  * same database file. reachableFrom uses `WITH RECURSIVE` rather than an
@@ -154,30 +241,41 @@ export class SqliteSymbolGraph implements SymbolGraphPort {
154
241
  async getGeneration(): Promise<SymbolGraphGeneration | undefined> {
155
242
  const row = this.db
156
243
  .query(
157
- "SELECT source_fingerprint, max_files, max_symbols_per_file, completed_at, files_processed, symbols_processed, nodes_added, edges_added, provenance_json FROM symbol_graph_generation WHERE singleton = 1",
244
+ "SELECT source_fingerprint, max_files, max_symbols_per_file, completed_at, files_processed, symbols_processed, nodes_added, edges_added, provenance_json, sources_json, result_json FROM symbol_graph_generation WHERE singleton = 1",
158
245
  )
159
246
  .get() as GenerationRow | null;
160
247
  if (!row) return undefined;
161
248
  const provenance = parseProvenance(row.provenance_json);
249
+ const sources = parseSources(row.sources_json);
250
+ const result =
251
+ parsePopulationResult(row.result_json) ??
252
+ ({
253
+ completeness: "complete",
254
+ filesAttempted: row.files_processed,
255
+ filesProcessed: row.files_processed,
256
+ filesFailed: 0,
257
+ symbolsProcessed: row.symbols_processed,
258
+ nodesAdded: row.nodes_added,
259
+ edgesAdded: row.edges_added,
260
+ failureCount: 0,
261
+ failures: [],
262
+ failuresTruncated: false,
263
+ } satisfies PopulateSymbolGraphResult);
162
264
  return {
163
265
  sourceFingerprint: row.source_fingerprint,
164
266
  maxFiles: row.max_files,
165
267
  maxSymbolsPerFile: row.max_symbols_per_file,
166
268
  completedAt: row.completed_at,
167
269
  ...(provenance ? { provenance } : {}),
168
- result: {
169
- filesProcessed: row.files_processed,
170
- symbolsProcessed: row.symbols_processed,
171
- nodesAdded: row.nodes_added,
172
- edgesAdded: row.edges_added,
173
- },
270
+ ...(sources ? { sources } : {}),
271
+ result,
174
272
  };
175
273
  }
176
274
 
177
275
  async setGeneration(generation: SymbolGraphGeneration): Promise<void> {
178
276
  this.db
179
277
  .query(
180
- "INSERT INTO symbol_graph_generation (singleton, source_fingerprint, max_files, max_symbols_per_file, completed_at, files_processed, symbols_processed, nodes_added, edges_added, provenance_json) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton) DO UPDATE SET source_fingerprint = excluded.source_fingerprint, max_files = excluded.max_files, max_symbols_per_file = excluded.max_symbols_per_file, completed_at = excluded.completed_at, files_processed = excluded.files_processed, symbols_processed = excluded.symbols_processed, nodes_added = excluded.nodes_added, edges_added = excluded.edges_added, provenance_json = excluded.provenance_json",
278
+ "INSERT INTO symbol_graph_generation (singleton, source_fingerprint, max_files, max_symbols_per_file, completed_at, files_processed, symbols_processed, nodes_added, edges_added, provenance_json, sources_json, result_json) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton) DO UPDATE SET source_fingerprint = excluded.source_fingerprint, max_files = excluded.max_files, max_symbols_per_file = excluded.max_symbols_per_file, completed_at = excluded.completed_at, files_processed = excluded.files_processed, symbols_processed = excluded.symbols_processed, nodes_added = excluded.nodes_added, edges_added = excluded.edges_added, provenance_json = excluded.provenance_json, sources_json = excluded.sources_json, result_json = excluded.result_json",
181
279
  )
182
280
  .run(
183
281
  generation.sourceFingerprint,
@@ -189,6 +287,8 @@ export class SqliteSymbolGraph implements SymbolGraphPort {
189
287
  generation.result.nodesAdded,
190
288
  generation.result.edgesAdded,
191
289
  generation.provenance ? JSON.stringify(generation.provenance) : null,
290
+ generation.sources ? JSON.stringify(generation.sources) : null,
291
+ JSON.stringify(generation.result),
192
292
  );
193
293
  }
194
294
 
package/src/cli.ts CHANGED
@@ -12,6 +12,8 @@ import { serveMain } from "./daemon.ts";
12
12
  import type { JobSnapshot } from "./domain/bounded-job-executor.ts";
13
13
  import type { ContentHash } from "./domain/content-hash.ts";
14
14
  import { DEFAULT_PACKAGE_SOURCE_BOUNDS, type PackageSourceOperationResult } from "./domain/package-source.ts";
15
+ import type { PopulateSymbolGraphResult } from "./domain/populate-symbol-graph.ts";
16
+ import type { SymbolSearchResult } from "./domain/workspace-symbol.ts";
15
17
  import type { WorkspacePort } from "./ports/workspace-port.ts";
16
18
  import type { WorkspaceId } from "./service.ts";
17
19
 
@@ -130,6 +132,14 @@ async function runWorkspaceRegister(dir: string | undefined, flags: string[]): P
130
132
  console.log(hasFlag(flags, "--json") ? JSON.stringify(result) : `${result.workspaceId} (${result.created ? "created" : "already registered"})`);
131
133
  }
132
134
 
135
+ function formatSymbolSources(result: SymbolSearchResult): readonly string[] {
136
+ return (result.sources ?? []).map((source) => {
137
+ const identity = `${source.provenance.languageId}: ${source.status} via ${source.provenance.backend}`;
138
+ if (source.status === "failed") return source.error ? `${identity} [${source.error.code}] ${source.error.message}` : identity;
139
+ return `${identity} (${source.symbolCount} symbol${source.symbolCount === 1 ? "" : "s"}${source.truncated ? ", truncated" : ""})`;
140
+ });
141
+ }
142
+
133
143
  async function runWorkspaceSymbols(workspaceId: string | undefined, query: string | undefined, flags: string[]): Promise<void> {
134
144
  if (!workspaceId || !query) fail(USAGE);
135
145
  const seedFile = flagValue(flags, "--seed-file"); // omit to auto-discover one
@@ -141,6 +151,7 @@ async function runWorkspaceSymbols(workspaceId: string | undefined, query: strin
141
151
  }
142
152
  const { symbols, provenance, truncated } = result;
143
153
  console.log(`${provenance.fidelity} via ${provenance.backend}${truncated ? " (truncated)" : ""}`);
154
+ for (const source of formatSymbolSources(result)) console.log(source);
144
155
  if (symbols.length === 0) {
145
156
  console.log(`no symbols matched "${query}"`);
146
157
  return;
@@ -351,11 +362,19 @@ function requiredIntFlag(flags: string[], flag: string): number {
351
362
  return parsed;
352
363
  }
353
364
 
354
- function formatJobSnapshot(job: JobSnapshot<{ filesProcessed: number; symbolsProcessed: number; nodesAdded: number; edgesAdded: number }>): string {
365
+ function formatPopulationResult(result: PopulateSymbolGraphResult): string {
366
+ const counts = `${result.filesProcessed}/${result.filesAttempted} files, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`;
367
+ if (result.completeness === "complete") return counts;
368
+ const first = result.failures[0];
369
+ const failure = first ? `; first failure: ${first.path} [${first.code} via ${first.provenance.backend}] ${first.message}` : "";
370
+ return `partial -- ${counts}, ${result.filesFailed} failed files (${result.failureCount} failed operations)${failure}`;
371
+ }
372
+
373
+ function formatJobSnapshot(job: JobSnapshot<PopulateSymbolGraphResult>): string {
355
374
  if (job.status === "queued") return `${job.id}: queued (${job.operation}); poll with: lector job status ${job.id}`;
356
375
  if (job.status === "running") return `${job.id}: still running (${job.operation}); poll with: lector job status ${job.id}`;
357
376
  if (job.status === "failed") return `${job.id}: failed [${job.error.code}] -- ${job.error.message}`;
358
- return `${job.id}: succeeded -- ${job.result.filesProcessed} files, ${job.result.symbolsProcessed} symbols, ${job.result.nodesAdded} nodes, ${job.result.edgesAdded} edges`;
377
+ return `${job.id}: succeeded -- ${formatPopulationResult(job.result)}`;
359
378
  }
360
379
 
361
380
  async function runWorkspacePopulateSymbolGraph(workspaceId: string | undefined, flags: string[]): Promise<void> {
@@ -375,11 +394,7 @@ async function runWorkspacePopulateSymbolGraph(workspaceId: string | undefined,
375
394
  return;
376
395
  }
377
396
  const result = await client.call("workspace.populateSymbolGraph", { workspaceId, maxFiles, maxSymbolsPerFile });
378
- console.log(
379
- hasFlag(flags, "--json")
380
- ? JSON.stringify(result)
381
- : `${result.filesProcessed} files, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`,
382
- );
397
+ console.log(hasFlag(flags, "--json") ? JSON.stringify(result) : formatPopulationResult(result));
383
398
  }
384
399
 
385
400
  async function runJobStatus(jobId: string | undefined, flags: string[]): Promise<void> {
@@ -401,6 +416,7 @@ async function runWorkspaceCacheStatus(workspaceId: string | undefined, flags: s
401
416
  }
402
417
  if (status.status === "not-cached") console.log(`not cached -- ${status.reason}`);
403
418
  else if (status.status === "caching") console.log(`caching -- job ${status.jobId}`);
419
+ else if (status.status === "partial") console.log(`partially cached -- ${formatPopulationResult(status.generation.result)}`);
404
420
  else console.log(`cached -- completed ${new Date(status.generation.completedAt).toISOString()}`);
405
421
  }
406
422
 
@@ -9,6 +9,14 @@ export interface IntelligenceProvenance {
9
9
  readonly limitations: readonly string[];
10
10
  }
11
11
 
12
+ export interface IntelligenceSourceOutcome {
13
+ readonly provenance: IntelligenceProvenance;
14
+ readonly status: "ready" | "failed";
15
+ readonly symbolCount: number;
16
+ readonly truncated?: boolean;
17
+ readonly error?: { readonly code: string; readonly message: string };
18
+ }
19
+
12
20
  export interface SymbolSearchBounds {
13
21
  readonly maxResults: number;
14
22
  }
@@ -23,6 +23,8 @@ export interface LanguageServerDescriptor {
23
23
  readonly rootMarkers: readonly string[];
24
24
  /** Tried before the bounded directory scan when picking a file to warm the server with (e.g. a language's usual entry-point names). */
25
25
  readonly commonSeedCandidates: readonly string[];
26
+ /** Excludes unsafe or unusually expensive servers from automatic workspace-wide fan-out while preserving explicit file operations. */
27
+ readonly workspaceDiscovery?: "enabled" | "explicit-only";
26
28
  /** Extra textDocument/workspace capabilities this specific server gates real features behind (e.g. typescript-language-server withholds diagnostics/callHierarchy unless declared). */
27
29
  readonly extraCapabilities?: Record<string, unknown>;
28
30
  /** Milliseconds to wait after opening a file before trusting the server's answers -- no server signals "project loaded". Default 1000ms; rust-analyzer needs more (see RUST_DESCRIPTOR). */
@@ -101,6 +103,7 @@ export const BASH_DESCRIPTOR: LanguageServerDescriptor = {
101
103
  languageId: "shellscript",
102
104
  backendId: "bash-language-server",
103
105
  extensions: [".sh", ".bash"],
106
+ workspaceDiscovery: "explicit-only",
104
107
  launch: { kind: "npm-module", entryModule: "bash-language-server/out/cli.js" },
105
108
  args: ["start"],
106
109
  rootMarkers: [],
@@ -111,6 +114,7 @@ export const YAML_DESCRIPTOR: LanguageServerDescriptor = {
111
114
  languageId: "yaml",
112
115
  backendId: "yaml-language-server",
113
116
  extensions: [".yaml", ".yml"],
117
+ workspaceDiscovery: "explicit-only",
114
118
  launch: { kind: "npm-module", entryModule: "yaml-language-server/bin/yaml-language-server" },
115
119
  args: ["--stdio"],
116
120
  rootMarkers: [],
@@ -1,17 +1,60 @@
1
1
  import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
2
2
  import type { SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
3
+ import type { OutgoingCall } from "./call-hierarchy.ts";
3
4
  import type { DocumentSymbolEntry } from "./document-symbol.ts";
5
+ import type { IntelligenceProvenance } from "./intelligence-provenance.ts";
4
6
  import { deriveSymbolNodeId } from "./symbol-node-id.ts";
5
7
  import type { WorkspaceLocation } from "./workspace-symbol.ts";
6
8
 
7
9
  const CALLABLE_KINDS = new Set(["function", "method", "constructor"]);
10
+ const MAX_RECORDED_FAILURES = 100;
11
+ const MAX_FAILURE_MESSAGE_LENGTH = 500;
12
+
13
+ export interface SymbolGraphPopulationFailure {
14
+ readonly path: string;
15
+ readonly operation: "document-symbols" | "outgoing-calls";
16
+ readonly code: string;
17
+ readonly message: string;
18
+ readonly provenance: IntelligenceProvenance;
19
+ }
8
20
 
9
21
  export interface PopulateSymbolGraphResult {
22
+ readonly completeness: "complete" | "partial";
23
+ readonly filesAttempted: number;
10
24
  readonly filesProcessed: number;
25
+ readonly filesFailed: number;
11
26
  readonly symbolsProcessed: number;
12
27
  /** addNode calls made, not necessarily new nodes -- a symbol reached from multiple edges is upserted once per encounter within a run, deduped in-memory. */
13
28
  readonly nodesAdded: number;
14
29
  readonly edgesAdded: number;
30
+ readonly failureCount: number;
31
+ readonly failures: readonly SymbolGraphPopulationFailure[];
32
+ readonly failuresTruncated: boolean;
33
+ }
34
+
35
+ const UNKNOWN_PROVENANCE: IntelligenceProvenance = {
36
+ fidelity: "semantic",
37
+ backend: "unavailable",
38
+ languageId: "unknown",
39
+ authority: "language-server",
40
+ freshness: "live-process",
41
+ limitations: ["source provenance was unavailable"],
42
+ };
43
+
44
+ function boundedFailure(
45
+ index: CodeIntelligencePort,
46
+ path: string,
47
+ operation: SymbolGraphPopulationFailure["operation"],
48
+ error: unknown,
49
+ ): SymbolGraphPopulationFailure {
50
+ const errorName = error instanceof Error ? error.name : undefined;
51
+ return {
52
+ path,
53
+ operation,
54
+ code: errorName && errorName !== "Error" ? errorName : "CodeIntelligenceFileError",
55
+ message: (error instanceof Error ? error.message : String(error)).slice(0, MAX_FAILURE_MESSAGE_LENGTH),
56
+ provenance: index.provenanceForPath?.(path) ?? index.provenance ?? UNKNOWN_PROVENANCE,
57
+ };
15
58
  }
16
59
 
17
60
  function toLocation(entry: DocumentSymbolEntry): WorkspaceLocation {
@@ -50,7 +93,16 @@ export async function populateSymbolGraph(
50
93
  let symbolsProcessed = 0;
51
94
  let nodesAdded = 0;
52
95
  let edgesAdded = 0;
96
+ let failureCount = 0;
53
97
  const addedNodeIds = new Set<string>();
98
+ const failedFiles = new Set<string>();
99
+ const failures: SymbolGraphPopulationFailure[] = [];
100
+
101
+ function recordFailure(file: string, operation: SymbolGraphPopulationFailure["operation"], error: unknown): void {
102
+ failureCount++;
103
+ failedFiles.add(file);
104
+ if (failures.length < MAX_RECORDED_FAILURES) failures.push(boundedFailure(index, file, operation, error));
105
+ }
54
106
 
55
107
  async function ensureNode(node: SymbolNode): Promise<void> {
56
108
  if (addedNodeIds.has(node.id)) return;
@@ -60,7 +112,13 @@ export async function populateSymbolGraph(
60
112
  }
61
113
 
62
114
  for (const file of files) {
63
- const topLevel = await index.documentSymbols(file);
115
+ let topLevel: DocumentSymbolEntry[];
116
+ try {
117
+ topLevel = await index.documentSymbols(file);
118
+ } catch (error) {
119
+ recordFailure(file, "document-symbols", error);
120
+ continue;
121
+ }
64
122
  const flattened = flattenDocumentSymbols(topLevel).slice(0, maxSymbolsPerFile);
65
123
  filesProcessed++;
66
124
 
@@ -76,7 +134,13 @@ export async function populateSymbolGraph(
76
134
  }
77
135
 
78
136
  if (CALLABLE_KINDS.has(entry.kind)) {
79
- const callees = await index.outgoingCalls(location);
137
+ let callees: OutgoingCall[];
138
+ try {
139
+ callees = await index.outgoingCalls(location);
140
+ } catch (error) {
141
+ recordFailure(file, "outgoing-calls", error);
142
+ continue;
143
+ }
80
144
  for (const call of callees) {
81
145
  const calleeNode: SymbolNode = { id: deriveSymbolNodeId(call.to.location), name: call.to.name, kind: call.to.kind, location: call.to.location };
82
146
  await ensureNode(calleeNode);
@@ -87,5 +151,16 @@ export async function populateSymbolGraph(
87
151
  }
88
152
  }
89
153
 
90
- return { filesProcessed, symbolsProcessed, nodesAdded, edgesAdded };
154
+ return {
155
+ completeness: failureCount === 0 ? "complete" : "partial",
156
+ filesAttempted: files.length,
157
+ filesProcessed,
158
+ filesFailed: failedFiles.size,
159
+ symbolsProcessed,
160
+ nodesAdded,
161
+ edgesAdded,
162
+ failureCount,
163
+ failures,
164
+ failuresTruncated: failureCount > failures.length,
165
+ };
91
166
  }
@@ -8,10 +8,13 @@ export interface SymbolGraphGeneration {
8
8
  readonly completedAt: number;
9
9
  /** Absent only for generations persisted before provenance was recorded. */
10
10
  readonly provenance?: IntelligenceProvenance;
11
+ /** Per-language authorities included when provenance is polyglot. */
12
+ readonly sources?: readonly IntelligenceProvenance[];
11
13
  readonly result: PopulateSymbolGraphResult;
12
14
  }
13
15
 
14
16
  export type WorkspaceCacheStatus =
15
17
  | { readonly status: "not-cached"; readonly reason: "no-completed-generation" | "bounds-changed" | "source-changed" }
16
18
  | { readonly status: "caching"; readonly jobId: string }
19
+ | { readonly status: "partial"; readonly generation: SymbolGraphGeneration }
17
20
  | { readonly status: "cached"; readonly generation: SymbolGraphGeneration };
@@ -1,4 +1,4 @@
1
- import type { IntelligenceProvenance } from "./intelligence-provenance.ts";
1
+ import type { IntelligenceProvenance, IntelligenceSourceOutcome } from "./intelligence-provenance.ts";
2
2
 
3
3
  /** A location within a workspace file: 1-indexed line and character, matching how humans and CLIs present positions. */
4
4
  export interface WorkspaceLocation {
@@ -13,10 +13,15 @@ export interface WorkspaceSymbol {
13
13
  readonly kind: string;
14
14
  readonly location: WorkspaceLocation;
15
15
  readonly containerName?: string;
16
+ /** Present when a workspace-wide composite query needs to preserve which backend produced this symbol. */
17
+ readonly provenance?: IntelligenceProvenance;
16
18
  }
17
19
 
18
20
  export interface SymbolSearchResult {
19
21
  readonly symbols: readonly WorkspaceSymbol[];
20
22
  readonly truncated: boolean;
21
23
  readonly provenance: IntelligenceProvenance;
24
+ /** Present for composite workspace queries; omitted for one-language adapters. */
25
+ readonly completeness?: "complete" | "partial";
26
+ readonly sources?: readonly IntelligenceSourceOutcome[];
22
27
  }
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export {
12
12
  LanguageServerProcessExited,
13
13
  LanguageServerRequestTimedOut,
14
14
  } from "./adapters/lsp/language-server-process.ts";
15
- export { LanguageFileLimitExceeded, LspSymbolIndex, type LspSymbolIndexOptions } from "./adapters/lsp/lsp-symbol-index.ts";
15
+ export { LanguageFileLimitExceeded, LanguageFileOutsideWorkspace, LspSymbolIndex, type LspSymbolIndexOptions } from "./adapters/lsp/lsp-symbol-index.ts";
16
16
  export { InvalidInstalledPackageVersionRequest, NpmLockfileVersionResolver } from "./adapters/npm-lockfile-version-resolver.ts";
17
17
  export { NpmPackageSourceResolver, type NpmPackageSourceResolverOptions } from "./adapters/npm-package-source-resolver.ts";
18
18
  export {
@@ -26,6 +26,7 @@ export {
26
26
  NpmRegistryResponseLimitExceeded,
27
27
  NpmVersionNotFound,
28
28
  } from "./adapters/npm-registry-client.ts";
29
+ export { PolyglotCodeIntelligenceIndex, type PolyglotIndexEntry } from "./adapters/polyglot-code-intelligence-index.ts";
29
30
  export { ReadOnlyWorkspace, WorkspaceIsReadOnly } from "./adapters/read-only-workspace.ts";
30
31
  export { RipgrepTextSearch } from "./adapters/ripgrep-text-search.ts";
31
32
  export { deriveSourceManifest, type SourceManifest, SourceManifestLimitExceeded } from "./adapters/source-manifest.ts";
@@ -92,7 +93,13 @@ export type {
92
93
  ResolvedInstalledPackageVersion,
93
94
  UnavailableInstalledPackageVersion,
94
95
  } from "./domain/installed-package-version.ts";
95
- export type { IntelligenceFidelity, IntelligenceProvenance, ProvenancedResult, SymbolSearchBounds } from "./domain/intelligence-provenance.ts";
96
+ export type {
97
+ IntelligenceFidelity,
98
+ IntelligenceProvenance,
99
+ IntelligenceSourceOutcome,
100
+ ProvenancedResult,
101
+ SymbolSearchBounds,
102
+ } from "./domain/intelligence-provenance.ts";
96
103
  export {
97
104
  descriptorForExtension,
98
105
  LANGUAGE_SERVER_DESCRIPTORS,
@@ -123,7 +130,7 @@ export type {
123
130
  VerifiedPackageSource,
124
131
  } from "./domain/package-source.ts";
125
132
  export { DEFAULT_PACKAGE_SOURCE_BOUNDS } from "./domain/package-source.ts";
126
- export { type PopulateSymbolGraphResult, populateSymbolGraph } from "./domain/populate-symbol-graph.ts";
133
+ export { type PopulateSymbolGraphResult, populateSymbolGraph, type SymbolGraphPopulationFailure } from "./domain/populate-symbol-graph.ts";
127
134
  export { prepareCallHierarchy } from "./domain/prepare-call-hierarchy.ts";
128
135
  export { raceWorkspaceQuery } from "./domain/race-workspace-query.ts";
129
136
  export { type RawRead, rawRead, WorkspaceEntryNotFound } from "./domain/raw-read.ts";
@@ -2,6 +2,7 @@ import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "../domain/c
2
2
  import type { Diagnostic } from "../domain/diagnostic.ts";
3
3
  import type { DocumentSymbolEntry } from "../domain/document-symbol.ts";
4
4
  import type { Hover } from "../domain/hover.ts";
5
+ import type { IntelligenceProvenance } from "../domain/intelligence-provenance.ts";
5
6
  import type { WorkspaceLocation } from "../domain/workspace-symbol.ts";
6
7
 
7
8
  /**
@@ -17,6 +18,8 @@ import type { WorkspaceLocation } from "../domain/workspace-symbol.ts";
17
18
  * process findSymbols already keeps alive per workspace -- not a second one.
18
19
  */
19
20
  export interface CodeIntelligencePort {
21
+ readonly provenance?: IntelligenceProvenance;
22
+ provenanceForPath?(path: string): IntelligenceProvenance | undefined;
20
23
  /** Where the symbol at `at` is actually declared -- may cross files, may return more than one candidate. */
21
24
  goToDefinition(at: WorkspaceLocation): Promise<WorkspaceLocation[]>;
22
25
  /** Every concrete implementation of the interface/abstract member at `at` -- unlike goToDefinition, crosses a port/interface boundary into its real adapters. */
package/src/service.ts CHANGED
@@ -6,11 +6,12 @@ import { InMemorySearchCache } from "./adapters/in-memory-search-cache.ts";
6
6
  import { InMemorySymbolGraph } from "./adapters/in-memory-symbol-graph.ts";
7
7
  import { LocalFilesystemWorkspace } from "./adapters/local-filesystem-workspace.ts";
8
8
  import { LocalGit } from "./adapters/local-git.ts";
9
- import { discoverWorkspaceDescriptor } from "./adapters/lsp/discover-seed-file.ts";
9
+ import { discoverWorkspaceDescriptor, discoverWorkspaceDescriptors } from "./adapters/lsp/discover-seed-file.ts";
10
10
  import { LspSymbolIndex } from "./adapters/lsp/lsp-symbol-index.ts";
11
11
  import { NpmLockfileVersionResolver } from "./adapters/npm-lockfile-version-resolver.ts";
12
12
  import { NpmPackageSourceResolver } from "./adapters/npm-package-source-resolver.ts";
13
13
  import { NpmRegistryClient } from "./adapters/npm-registry-client.ts";
14
+ import { PolyglotCodeIntelligenceIndex } from "./adapters/polyglot-code-intelligence-index.ts";
14
15
  import { ReadOnlyWorkspace } from "./adapters/read-only-workspace.ts";
15
16
  import { RipgrepTextSearch } from "./adapters/ripgrep-text-search.ts";
16
17
  import { deriveSourceManifest } from "./adapters/source-manifest.ts";
@@ -312,7 +313,7 @@ export interface OperationOutputs {
312
313
  "workspace.prepareCallHierarchy": Provenanced<{ items: readonly CallHierarchyEntry[] }>;
313
314
  "workspace.incomingCalls": Provenanced<{ calls: readonly IncomingCall[] }>;
314
315
  "workspace.outgoingCalls": Provenanced<{ calls: readonly OutgoingCall[] }>;
315
- "workspace.populateSymbolGraph": { filesProcessed: number; symbolsProcessed: number; nodesAdded: number; edgesAdded: number };
316
+ "workspace.populateSymbolGraph": PopulateSymbolGraphResult;
316
317
  "workspace.reachableFrom": { symbols: readonly SymbolNode[] };
317
318
  "workspace.symbolEdgesFrom": { symbols: readonly SymbolNode[] };
318
319
  "workspace.symbolEdgesTo": { symbols: readonly SymbolNode[] };
@@ -408,7 +409,7 @@ function resolveWorkspace(registry: MutableRegistry, workspaceId: WorkspaceId):
408
409
 
409
410
  /** True when a warm SymbolIndexPort is also a real CodeIntelligencePort (currently: any LspSymbolIndex, never TreeSitterSymbolIndex). */
410
411
  function supportsCodeIntelligence(index: SymbolIndexPort): index is SymbolIndexPort & CodeIntelligencePort {
411
- return typeof (index as Partial<CodeIntelligencePort>).goToDefinition === "function";
412
+ return "goToDefinition" in index && typeof index.goToDefinition === "function";
412
413
  }
413
414
 
414
415
  type OperationHandlers = {
@@ -677,6 +678,18 @@ export function createLectorService(workspaces: ReadonlyMap<WorkspaceId, Workspa
677
678
  return { descriptor: discovered.descriptor, seedFile: discovered.seedFile };
678
679
  }
679
680
 
681
+ function ensureLanguageIndex(workspaceId: WorkspaceId, rootPath: string, descriptor: LanguageServerDescriptor, seedFile?: string): ClosableSymbolIndex {
682
+ const key = symbolIndexKey(workspaceId, descriptor.languageId);
683
+ let entryIndex = symbolIndexes.get(key);
684
+ if (!entryIndex) {
685
+ entryIndex = { index: createSymbolIndex(rootPath, descriptor, seedFile), workspaceId, lastUsedAt: Date.now() };
686
+ symbolIndexes.set(key, entryIndex);
687
+ } else {
688
+ entryIndex.lastUsedAt = Date.now();
689
+ }
690
+ return entryIndex.index;
691
+ }
692
+
680
693
  async function ensureWarmIndex(input: {
681
694
  workspaceId: WorkspaceId;
682
695
  path?: string;
@@ -685,18 +698,45 @@ export function createLectorService(workspaces: ReadonlyMap<WorkspaceId, Workspa
685
698
  const entry = registry.get(input.workspaceId);
686
699
  if (!entry) throw new UnknownWorkspace(input.workspaceId);
687
700
  if (!entry.rootPath) throw new SymbolQueryUnavailable(input.workspaceId);
688
- const rootPath = entry.rootPath;
701
+ const { descriptor, seedFile } = resolveDescriptor(entry.rootPath, input);
702
+ return { index: ensureLanguageIndex(input.workspaceId, entry.rootPath, descriptor, seedFile), descriptor };
703
+ }
689
704
 
690
- const { descriptor, seedFile } = resolveDescriptor(rootPath, input);
691
- const key = symbolIndexKey(input.workspaceId, descriptor.languageId);
692
- let entryIndex = symbolIndexes.get(key);
693
- if (!entryIndex) {
694
- entryIndex = { index: createSymbolIndex(rootPath, descriptor, seedFile), workspaceId: input.workspaceId, lastUsedAt: Date.now() };
695
- symbolIndexes.set(key, entryIndex);
705
+ function ensureWorkspaceIndex(
706
+ workspaceId: WorkspaceId,
707
+ preferredSeedFile?: string,
708
+ ): {
709
+ index: SymbolIndexPort;
710
+ descriptors: readonly LanguageServerDescriptor[];
711
+ sources: readonly IntelligenceProvenance[];
712
+ } {
713
+ const entry = registry.get(workspaceId);
714
+ if (!entry) throw new UnknownWorkspace(workspaceId);
715
+ if (!entry.rootPath) throw new SymbolQueryUnavailable(workspaceId);
716
+ const rootPath = entry.rootPath;
717
+ const preferredDescriptor = preferredSeedFile ? descriptorForPath(preferredSeedFile) : undefined;
718
+ if (preferredSeedFile && !preferredDescriptor) throw new UnsupportedLanguage(preferredSeedFile);
719
+ const discovered = [...discoverWorkspaceDescriptors(rootPath, LANGUAGE_SERVER_DESCRIPTORS)];
720
+ if (preferredDescriptor && preferredSeedFile && !discovered.some(({ descriptor }) => descriptor.languageId === preferredDescriptor.languageId)) {
721
+ discovered.push({ descriptor: preferredDescriptor, seedFile: preferredSeedFile });
722
+ }
723
+ if (discovered.length === 0) throw new UnsupportedLanguage(rootPath);
724
+ const indexes = discovered.map(({ descriptor, seedFile }) => ({
725
+ descriptor,
726
+ index: ensureLanguageIndex(workspaceId, rootPath, descriptor, preferredDescriptor?.languageId === descriptor.languageId ? preferredSeedFile : seedFile),
727
+ }));
728
+ const first = indexes[0];
729
+ let index: SymbolIndexPort;
730
+ if (indexes.length === 1 && first) {
731
+ index = first.index;
696
732
  } else {
697
- entryIndex.lastUsedAt = Date.now();
733
+ index = new PolyglotCodeIntelligenceIndex(indexes);
698
734
  }
699
- return { index: entryIndex.index, descriptor };
735
+ return { index, descriptors: discovered.map(({ descriptor }) => descriptor), sources: indexes.map(({ index: source }) => source.provenance) };
736
+ }
737
+
738
+ function workspaceSourceExtensions(descriptors: readonly LanguageServerDescriptor[]): readonly string[] {
739
+ return Array.from(new Set(descriptors.flatMap((descriptor) => descriptor.extensions)));
700
740
  }
701
741
 
702
742
  /** Never spawns -- a caller deciding whether to enrich a result with LSP-backed info must not pay a cold-start cost just to check. With a path, checks that file's own language; without one, whether anything is warm for the workspace at all. */
@@ -723,7 +763,7 @@ export function createLectorService(workspaces: ReadonlyMap<WorkspaceId, Workspa
723
763
  if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > MAX_SYMBOL_RESULTS) {
724
764
  throw new TypeError(`maxResults must be a positive safe integer no greater than ${MAX_SYMBOL_RESULTS}`);
725
765
  }
726
- const { index } = await ensureWarmIndex(input);
766
+ const { index } = ensureWorkspaceIndex(input.workspaceId, input.seedFile);
727
767
  return findWorkspaceSymbols(index, input.query, { maxResults });
728
768
  }
729
769
 
@@ -819,21 +859,24 @@ export function createLectorService(workspaces: ReadonlyMap<WorkspaceId, Workspa
819
859
  _registry: MutableRegistry,
820
860
  input: OperationInputs["workspace.populateSymbolGraph"],
821
861
  ): Promise<OperationOutputs["workspace.populateSymbolGraph"]> {
822
- const { index, descriptor } = await requireCodeIntelligence(input);
862
+ const workspaceIndex = ensureWorkspaceIndex(input.workspaceId);
863
+ if (!supportsCodeIntelligence(workspaceIndex.index)) throw new CodeIntelligenceUnavailable(input.workspaceId);
823
864
  const entry = registry.get(input.workspaceId);
824
865
  if (!entry?.rootPath) throw new SymbolQueryUnavailable(input.workspaceId);
825
866
  const rootPath = entry.rootPath;
826
- const before = await deriveSourceManifest(rootPath, descriptor.extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES);
867
+ const extensions = workspaceSourceExtensions(workspaceIndex.descriptors);
868
+ const before = await deriveSourceManifest(rootPath, extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES);
827
869
  const graph = ensureSymbolGraph(input.workspaceId);
828
- const result = await populateSymbolGraphQuery(index, graph, before.absoluteFiles, input.maxSymbolsPerFile);
829
- const after = await deriveSourceManifest(rootPath, descriptor.extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES);
870
+ const result = await populateSymbolGraphQuery(workspaceIndex.index, graph, before.absoluteFiles, input.maxSymbolsPerFile);
871
+ const after = await deriveSourceManifest(rootPath, extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES);
830
872
  if (after.fingerprint !== before.fingerprint) throw new WorkspaceChangedDuringPopulation(input.workspaceId);
831
873
  await graph.setGeneration({
832
874
  sourceFingerprint: after.fingerprint,
833
875
  maxFiles: input.maxFiles,
834
876
  maxSymbolsPerFile: input.maxSymbolsPerFile,
835
877
  completedAt: Date.now(),
836
- provenance: index.provenance,
878
+ provenance: workspaceIndex.index.provenance,
879
+ sources: workspaceIndex.sources,
837
880
  result,
838
881
  });
839
882
  return result;
@@ -858,15 +901,17 @@ export function createLectorService(workspaces: ReadonlyMap<WorkspaceId, Workspa
858
901
  if (generation.maxFiles !== input.maxFiles || generation.maxSymbolsPerFile !== input.maxSymbolsPerFile) {
859
902
  return { status: "not-cached", reason: "bounds-changed" };
860
903
  }
861
- const { descriptor } = resolveDescriptor(entry.rootPath, {});
904
+ const discovered = discoverWorkspaceDescriptors(entry.rootPath, LANGUAGE_SERVER_DESCRIPTORS);
905
+ if (discovered.length === 0) return { status: "not-cached", reason: "source-changed" };
906
+ const extensions = workspaceSourceExtensions(discovered.map(({ descriptor }) => descriptor));
862
907
  let currentFingerprint: string;
863
908
  try {
864
- currentFingerprint = (await deriveSourceManifest(entry.rootPath, descriptor.extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES)).fingerprint;
909
+ currentFingerprint = (await deriveSourceManifest(entry.rootPath, extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES)).fingerprint;
865
910
  } catch {
866
911
  return { status: "not-cached", reason: "source-changed" };
867
912
  }
868
913
  if (currentFingerprint !== generation.sourceFingerprint) return { status: "not-cached", reason: "source-changed" };
869
- return { status: "cached", generation };
914
+ return generation.result.completeness === "partial" ? { status: "partial", generation } : { status: "cached", generation };
870
915
  }
871
916
 
872
917
  function isRecord(value: unknown): value is Record<string, unknown> {