@psnext/lscg 0.1.3 → 0.1.5

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 (52) hide show
  1. package/README.md +87 -9
  2. package/dist/src/cli-progress.d.ts +7 -0
  3. package/dist/src/cli-progress.js +59 -0
  4. package/dist/src/cli.js +14 -6
  5. package/dist/src/graph/attribution.d.ts +2 -2
  6. package/dist/src/graph/attribution.js +36 -13
  7. package/dist/src/graph/repository.d.ts +30 -3
  8. package/dist/src/graph/repository.js +396 -158
  9. package/dist/src/graph/repositoryScanWorker.d.ts +21 -0
  10. package/dist/src/graph/repositoryScanWorker.js +45 -0
  11. package/dist/src/index.d.ts +5 -0
  12. package/dist/src/index.js +4 -0
  13. package/dist/src/mcp/server.js +16 -1
  14. package/dist/src/parser/treeSitter.js +20 -1
  15. package/dist/src/scanner/artifactInventory.d.ts +35 -0
  16. package/dist/src/scanner/artifactInventory.js +139 -0
  17. package/dist/src/scanner/fingerprint.js +5 -0
  18. package/dist/src/scanner/javaDependencyPlugin.d.ts +5 -0
  19. package/dist/src/scanner/javaDependencyPlugin.js +107 -0
  20. package/dist/src/scanner/javaPlugin.d.ts +5 -0
  21. package/dist/src/scanner/javaPlugin.js +199 -0
  22. package/dist/src/scanner/javaScanWorker.d.ts +2 -0
  23. package/dist/src/scanner/javaScanWorker.js +8 -0
  24. package/dist/src/scanner/packageParseWorker.d.ts +17 -0
  25. package/dist/src/scanner/packageParseWorker.js +30 -0
  26. package/dist/src/scanner/packagePlugin.js +126 -24
  27. package/dist/src/scanner/parallelScan.d.ts +2 -0
  28. package/dist/src/scanner/parallelScan.js +32 -0
  29. package/dist/src/scanner/plugins.d.ts +32 -2
  30. package/dist/src/scanner/plugins.js +51 -5
  31. package/dist/src/scanner/pythonPlugin.d.ts +5 -0
  32. package/dist/src/scanner/pythonPlugin.js +198 -0
  33. package/dist/src/scanner/pythonScanWorker.d.ts +2 -0
  34. package/dist/src/scanner/pythonScanWorker.js +8 -0
  35. package/dist/src/storage/connection.js +63 -0
  36. package/dist/src/storage/database.d.ts +1 -0
  37. package/dist/src/storage/database.js +1 -0
  38. package/dist/src/storage/graph-writes.d.ts +16 -3
  39. package/dist/src/storage/graph-writes.js +142 -17
  40. package/dist/src/storage/manifest-inventory.d.ts +23 -0
  41. package/dist/src/storage/manifest-inventory.js +82 -0
  42. package/dist/src/storage/queries.d.ts +6 -1
  43. package/dist/src/storage/queries.js +67 -1
  44. package/dist/src/storage/schema.d.ts +2 -2
  45. package/dist/src/storage/schema.js +44 -1
  46. package/dist/src/types.d.ts +102 -6
  47. package/dist/src/view/templates/icons/call.svg +11 -9
  48. package/dist/src/view/templates/interactive.css +11 -8
  49. package/dist/src/view/templates/interactive.html +160 -46
  50. package/dist/src/watch.d.ts +17 -2
  51. package/dist/src/watch.js +208 -46
  52. package/package.json +9 -3
@@ -2,14 +2,17 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { databasePathForScope, homeDatabasePath, normalizeScope, repoDatabasePath, resolveProjectRoot } from '../config/paths.js';
5
- import { parseSource } from '../parser/treeSitter.js';
6
5
  import { discoverSourceFiles } from '../scanner/discover.js';
7
- import { collectRepositoryFingerprint } from '../scanner/fingerprint.js';
8
- import { attachDatabase, closeDatabase, getStatus, openGraphDatabase, replaceFileGraph, runSelect, selectCallGraph, selectEdges, selectNeighbors, selectNodes, selectNodeTextRows, upsertRepository, reconcileDeletedFiles, reconcilePluginEdges, reconcileRepositoryPluginNodes, insertRepositoryPluginNodes, loadPluginContributions, persistPluginContributions, nextPluginGeneration, selectFileInventory, selectContextCandidates, selectContextRelationships } from '../storage/database.js';
9
- import { extractGraph, hashParts } from './extract.js';
6
+ import { attachDatabase, closeDatabase, getStatus, openGraphDatabase, openReadOnlyGraphDatabase, replaceFileGraph, applyFileAttribution, selectEnrichmentWork, selectFileEnrichmentStates, clearFileScanFailure, recordFileScanFailure, persistManifestInventoryAndScanState, runSelect, selectCallGraph, selectEdges, selectNeighbors, selectNodes, selectNodeTextRows, upsertRepository, reconcileDeletedFiles, reconcilePluginEdges, reconcileRepositoryPluginNodes, insertRepositoryPluginNodes, loadPluginContributions, persistPluginContributions, nextPluginGeneration, selectFileInventory, selectManifestInventory, manifestRowsAsRecords, selectFreshnessReport, selectContextCandidates, selectContextRelationships } from '../storage/database.js';
7
+ import { hashParts } from './extract.js';
10
8
  import { buildFileAttribution } from './attribution.js';
11
9
  import { createRepositoryScanContext, runScannerPlugins, DEFAULT_PLUGIN_RESOURCE_LIMITS } from '../scanner/plugins.js';
12
10
  import { PackagePlugin } from '../scanner/packagePlugin.js';
11
+ import { PythonScannerPlugin } from '../scanner/pythonPlugin.js';
12
+ import { JavaScannerPlugin } from '../scanner/javaPlugin.js';
13
+ import { JavaDependencyPlugin } from '../scanner/javaDependencyPlugin.js';
14
+ import { inspectRepositoryManifests, mergeManifestInventories } from '../scanner/artifactInventory.js';
15
+ import { runBatchedWorkers } from '../scanner/parallelScan.js';
13
16
  function normalizePluginFilePath(value) {
14
17
  if (typeof value !== 'string' || value.trim().length === 0)
15
18
  return undefined;
@@ -73,24 +76,134 @@ export function initGraph({ root = process.cwd(), scope = 'repo' } = {}) {
73
76
  }
74
77
  return { repository, initialized };
75
78
  }
76
- export async function scanRepository({ root = process.cwd(), scope = 'repo', plugins = [], pluginLimits } = {}) {
79
+ const MAX_FULL_ENRICHMENT_ATTEMPTS = 3;
80
+ /**
81
+ * A full scan must not report success with hash-incompatible enrichment work.
82
+ * Rebuild and drain again when a source changes while attribution is running.
83
+ */
84
+ export async function scanRepository(options = {}) {
85
+ const progress = options.progress;
86
+ const operation = options.full ? 'full' : 'incremental';
87
+ const scope = options.scope ?? 'repo';
88
+ if (!options.full) {
89
+ try {
90
+ const summary = await scanRepositoryOnce({ ...options, attempt: 1 });
91
+ reportProgress(progress, {
92
+ kind: 'scan', operation, scope, phase: 'terminal', status: summary.enrichment.state === 'complete' ? 'completed' : 'degraded',
93
+ repository: summary.repository, attempt: 1, detail: summary.enrichment.state === 'complete' ? 'scan complete' : `scan complete with enrichment ${summary.enrichment.state}`,
94
+ counts: { processed: summary.filesScanned, skipped: summary.skipped.length, pending: summary.enrichment.pending, complete: summary.enrichment.complete, failed: summary.enrichment.failed }
95
+ });
96
+ return summary;
97
+ }
98
+ catch (error) {
99
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'terminal', status: 'failed', repository: repositoryForRoot(options.root), detail: diagnosticFor(error) });
100
+ throw error;
101
+ }
102
+ }
103
+ for (let attempt = 1; attempt <= MAX_FULL_ENRICHMENT_ATTEMPTS; attempt += 1) {
104
+ try {
105
+ const summary = await scanRepositoryOnce({ ...options, attempt });
106
+ if (summary.enrichment.pending === 0) {
107
+ reportProgress(progress, {
108
+ kind: 'scan', operation, scope, phase: 'terminal', status: summary.enrichment.state === 'complete' ? 'completed' : 'degraded',
109
+ repository: summary.repository, attempt, detail: summary.enrichment.state === 'complete' ? 'full scan complete; enrichment stabilized' : `full scan complete with enrichment ${summary.enrichment.state}`,
110
+ counts: { processed: summary.filesScanned, skipped: summary.skipped.length, pending: summary.enrichment.pending, complete: summary.enrichment.complete, failed: summary.enrichment.failed }
111
+ });
112
+ return summary;
113
+ }
114
+ if (attempt < MAX_FULL_ENRICHMENT_ATTEMPTS) {
115
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'enrichment', status: 'retrying', repository: summary.repository, attempt, detail: `enrichment pending after attempt ${attempt}; retrying`, counts: { pending: summary.enrichment.pending } });
116
+ }
117
+ }
118
+ catch (error) {
119
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'terminal', status: 'failed', repository: repositoryForRoot(options.root), attempt, detail: diagnosticFor(error) });
120
+ throw error;
121
+ }
122
+ }
123
+ const error = new Error(`full scan enrichment did not stabilize after ${MAX_FULL_ENRICHMENT_ATTEMPTS} attempts`);
124
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'terminal', status: 'failed', repository: repositoryForRoot(options.root), attempt: MAX_FULL_ENRICHMENT_ATTEMPTS, detail: error.message });
125
+ throw error;
126
+ }
127
+ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugins = [], pluginLimits, full = false, enrichmentRunner, progress, attempt = 1 } = {}) {
77
128
  const repository = repositoryForRoot(root);
78
129
  const scopes = normalizeScope(scope);
79
130
  const files = discoverSourceFiles(repository.root);
80
- const configuredPlugins = [
81
- ...(existsSync(path.join(repository.root, 'package.json')) ? [PackagePlugin] : []),
82
- ...plugins.filter((plugin) => plugin.name !== PackagePlugin.name)
83
- ];
84
- const pluginRun = await runScannerPlugins(createRepositoryScanContext(repository.root), configuredPlugins, pluginLimits);
131
+ const operation = full ? 'full' : 'incremental';
132
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'discovery', status: 'started', repository, attempt, detail: 'discovering source files' });
85
133
  const opened = scopes.map((graphScope) => {
86
134
  const databasePath = databasePathForScope(graphScope, repository.root);
87
135
  return { scope: graphScope, databasePath, db: openGraphDatabase(databasePath) };
88
136
  });
137
+ const inventoriesByScope = opened.map((handle) => new Map(selectFileInventory(handle.db, repository.id).map((entry) => [entry.path, entry])));
138
+ const currentManifestScan = inspectRepositoryManifests(repository.root);
139
+ const manifestRowsByScope = opened.map((handle) => selectManifestInventory(handle.db, repository.id));
140
+ const previousManifestRecords = manifestRowsByScope[0] ? manifestRowsAsRecords(manifestRowsByScope[0]) : [];
141
+ const manifests = mergeManifestInventories(currentManifestScan.manifests, previousManifestRecords, currentManifestScan.complete);
142
+ const previousManifestByPath = new Map(previousManifestRecords.map((manifest) => [manifest.path, manifest]));
143
+ const currentManifestPaths = new Set(currentManifestScan.manifests.map((manifest) => manifest.path));
144
+ const changedManifests = manifests.filter((manifest) => {
145
+ if (manifest.status === 'confirmed_deleted')
146
+ return false;
147
+ const previous = previousManifestByPath.get(manifest.path);
148
+ return !previous || previous.status !== manifest.status || previous.size !== manifest.size || previous.mtimeMs !== manifest.mtimeMs || previous.sourceHash !== manifest.sourceHash;
149
+ }).map((manifest) => manifest.path);
150
+ const deletedManifests = manifests.filter((manifest) => manifest.status === 'confirmed_deleted' && !currentManifestPaths.has(manifest.path)).map((manifest) => manifest.path);
151
+ const hasPythonFiles = files.some((file) => file.toLowerCase().endsWith('.py')) || inventoriesByScope.some((inventory) => [...inventory.keys()].some((file) => file.toLowerCase().endsWith('.py')));
152
+ const hasJavaFiles = files.some((file) => file.toLowerCase().endsWith('.java')) || inventoriesByScope.some((inventory) => [...inventory.keys()].some((file) => file.toLowerCase().endsWith('.java')));
153
+ const hasJavaManifests = manifests.some((manifest) => manifest.status !== 'confirmed_deleted');
154
+ const hasPriorJavaDependencyContribution = opened[0]
155
+ ? loadPluginContributions(opened[0].db, repository, [JavaDependencyPlugin.name]).has(JavaDependencyPlugin.name)
156
+ : false;
157
+ const configuredPlugins = [
158
+ ...(hasPythonFiles || plugins.some((plugin) => plugin.name === PythonScannerPlugin.name) ? [PythonScannerPlugin] : []),
159
+ ...(hasJavaFiles || plugins.some((plugin) => plugin.name === JavaScannerPlugin.name) ? [JavaScannerPlugin] : []),
160
+ ...(existsSync(path.join(repository.root, 'package.json')) ? [PackagePlugin] : []),
161
+ // Keep the analyzer active for current manifests and for one final
162
+ // reconciliation after a restart/deletion of the last manifest.
163
+ ...(hasJavaManifests || hasPriorJavaDependencyContribution ? [JavaDependencyPlugin] : []),
164
+ ...plugins.filter((plugin) => ![PackagePlugin.name, PythonScannerPlugin.name, JavaScannerPlugin.name, JavaDependencyPlugin.name].includes(plugin.name))
165
+ ];
166
+ const filesToScan = full || inventoriesByScope.some((inventory) => inventory.size === 0)
167
+ ? files
168
+ : files.filter((relativePath) => {
169
+ try {
170
+ const stat = statSync(path.join(repository.root, relativePath));
171
+ return inventoriesByScope.some((inventory) => {
172
+ const previous = inventory.get(relativePath);
173
+ if (!previous || previous.size !== stat.size || previous.mtimeMs !== Math.round(stat.mtimeMs))
174
+ return true;
175
+ // Filesystem metadata can remain unchanged when an editor rewrites a
176
+ // file in place. Compare the persisted source hash before deciding
177
+ // that an incremental scan may safely skip the file.
178
+ try {
179
+ return previous.hash !== sha256(readFileSync(path.join(repository.root, relativePath), 'utf8'));
180
+ }
181
+ catch {
182
+ return true;
183
+ }
184
+ });
185
+ }
186
+ catch {
187
+ return true;
188
+ }
189
+ });
190
+ const deletedPaths = [...new Set(inventoriesByScope.flatMap((inventory) => [...inventory.keys()].filter((relativePath) => !files.includes(relativePath))))];
191
+ reportProgress(progress, {
192
+ kind: 'scan', operation, scope, phase: 'discovery', status: 'completed', repository, attempt,
193
+ detail: `planned ${filesToScan.length} selected file(s)`,
194
+ counts: { discovered: files.length, selected: filesToScan.length, unchanged: Math.max(0, files.length - filesToScan.length), deleted: deletedPaths.length }
195
+ });
196
+ const previousContributions = opened[0]
197
+ ? loadPluginContributions(opened[0].db, repository, configuredPlugins.map((plugin) => plugin.name))
198
+ : new Map();
199
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'plugins', status: 'started', repository, attempt, counts: { plugins: configuredPlugins.length } });
200
+ const pluginRun = await runScannerPlugins(createRepositoryScanContext(repository.root, manifests), configuredPlugins, pluginLimits, { changedPaths: filesToScan, deletedPaths, changedManifests, deletedManifests, manifestsComplete: currentManifestScan.complete, full: full || inventoriesByScope.some((inventory) => inventory.size === 0) }, previousContributions);
89
201
  let filesScanned = 0;
90
202
  let nodesWritten = 0;
91
203
  let edgesWritten = 0;
92
204
  const skipped = [];
93
205
  const stalePlugins = [];
206
+ let inventoryGeneration = 1;
94
207
  try {
95
208
  const primary = opened[0];
96
209
  const contributions = new Map(pluginRun.pluginContributions ?? new Map());
@@ -100,8 +213,8 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo', plu
100
213
  }
101
214
  if (primary) {
102
215
  upsertRepository(primary.db, repository);
103
- const generation = nextPluginGeneration(primary.db, repository);
104
- persistPluginContributions(primary.db, repository, contributions, generation);
216
+ inventoryGeneration = nextPluginGeneration(primary.db, repository);
217
+ persistPluginContributions(primary.db, repository, contributions, inventoryGeneration);
105
218
  const previous = loadPluginContributions(primary.db, repository, pluginRun.failedPlugins ?? []);
106
219
  for (const pluginName of pluginRun.failedPlugins ?? []) {
107
220
  const contribution = previous.get(pluginName);
@@ -121,60 +234,98 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo', plu
121
234
  persistPluginContributions(handle.db, repository, contributions, nextPluginGeneration(handle.db, repository));
122
235
  }
123
236
  const activePlugins = [...(pluginRun.successfulPlugins ?? []), ...stalePlugins];
237
+ reportProgress(progress, {
238
+ kind: 'scan', operation, scope, phase: 'plugins', status: pluginRun.failedPlugins.length > 0 || stalePlugins.length > 0 ? 'degraded' : 'completed', repository, attempt,
239
+ detail: pluginRun.failedPlugins.length > 0
240
+ ? `failed plugins: ${pluginRun.failedPlugins.join(', ')}${stalePlugins.length > 0 ? `; reused stale: ${stalePlugins.join(', ')}` : ''}`
241
+ : 'plugin work complete',
242
+ counts: { plugins: configuredPlugins.length, failedPlugins: pluginRun.failedPlugins.length, stalePlugins: stalePlugins.length }
243
+ });
124
244
  for (const handle of opened) {
125
245
  upsertRepository(handle.db, repository);
126
246
  reconcileRepositoryPluginNodes(handle.db, repository, activePlugins);
127
247
  insertRepositoryPluginNodes(handle.db, repository, pluginRun.nodes);
128
248
  }
129
- for (const relativePath of files) {
130
- const absolutePath = path.join(repository.root, relativePath);
131
- const source = readFileSync(absolutePath, 'utf8');
132
- const parseResult = parseSource(absolutePath, source);
133
- if (!parseResult) {
249
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'files', status: 'started', repository, attempt, counts: { selected: filesToScan.length } });
250
+ const scanTasks = [];
251
+ const preScanFailures = new Map();
252
+ for (const relativePath of filesToScan) {
253
+ if ((pluginRun.failedPaths ?? []).includes(relativePath))
254
+ continue;
255
+ try {
256
+ const absolutePath = path.join(repository.root, relativePath);
257
+ const source = readFileSync(absolutePath, 'utf8');
258
+ const stat = statSync(absolutePath);
259
+ scanTasks.push({
260
+ repositoryId: repository.id,
261
+ relativePath,
262
+ absolutePath,
263
+ source,
264
+ sourceHash: sha256(source),
265
+ mtimeMs: Math.round(stat.mtimeMs)
266
+ });
267
+ }
268
+ catch (error) {
269
+ preScanFailures.set(relativePath, error instanceof Error ? error.message : String(error));
270
+ }
271
+ }
272
+ const scanResults = await runBatchedWorkers(scanTasks, new URL('./repositoryScanWorker.js', import.meta.url), (batch) => batch);
273
+ const scanResultsByPath = new Map(scanResults.flatMap((batch) => batch.map((result) => [result.relativePath, result])));
274
+ for (const relativePath of filesToScan) {
275
+ // A file-level plugin parse failure retains its prior graph contribution
276
+ // and is surfaced as degraded freshness; never replace it with partial
277
+ // Tree-sitter facts from an ERROR tree.
278
+ if ((pluginRun.failedPaths ?? []).includes(relativePath)) {
279
+ skipped.push(relativePath);
280
+ const diagnostic = pluginRun.diagnostics.find((message) => message.includes(relativePath)) ?? `${relativePath}: plugin parse failed`;
281
+ for (const handle of opened)
282
+ recordFileScanFailure(handle.db, repository.id, relativePath, diagnostic);
283
+ continue;
284
+ }
285
+ const result = scanResultsByPath.get(relativePath);
286
+ const diagnostic = preScanFailures.get(relativePath) ?? result?.error;
287
+ if (!result || diagnostic) {
134
288
  skipped.push(relativePath);
289
+ const message = diagnostic ?? 'scanner worker returned no result';
290
+ for (const handle of opened)
291
+ recordFileScanFailure(handle.db, repository.id, relativePath, message);
292
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'files', status: 'skipped', repository, attempt, path: relativePath, detail: message, counts: { processed: filesScanned, skipped: skipped.length } });
135
293
  continue;
136
294
  }
137
- const stat = statSync(absolutePath);
138
- const sourceHash = sha256(source);
139
- const fileId = hashParts([repository.id, relativePath]);
140
295
  const file = {
141
- id: fileId,
296
+ id: hashParts([repository.id, relativePath]),
142
297
  path: relativePath,
143
- language: parseResult.language,
144
- hash: sourceHash,
145
- size: stat.size,
146
- mtimeMs: Math.round(stat.mtimeMs)
298
+ language: result.language,
299
+ hash: result.sourceHash,
300
+ size: result.size,
301
+ mtimeMs: result.mtimeMs
147
302
  };
148
- const builtInGraph = extractGraph({
149
- repositoryId: repository.id,
150
- fileId,
151
- relativePath,
152
- source,
153
- sourceHash,
154
- parseResult
155
- });
303
+ const builtInGraph = result.graph ?? { nodes: [], edges: [] };
156
304
  const normalizedRelativePath = normalizePluginFilePath(relativePath);
157
305
  const pluginNodes = pluginRun.nodes.filter((node) => normalizePluginFilePath(node.metadata.filePath) === normalizedRelativePath);
158
306
  const graph = {
159
307
  nodes: [...builtInGraph.nodes, ...pluginNodes],
160
308
  edges: builtInGraph.edges
161
309
  };
162
- const attribution = buildFileAttribution({
163
- root: repository.root,
164
- relativePath,
165
- source,
166
- nodes: graph.nodes
167
- });
168
310
  for (const handle of opened) {
169
- replaceFileGraph(handle.db, { repository, file, nodes: graph.nodes, edges: graph.edges, attribution });
311
+ replaceFileGraph(handle.db, { repository, file, nodes: graph.nodes, edges: graph.edges });
312
+ clearFileScanFailure(handle.db, repository.id, relativePath);
170
313
  }
171
314
  filesScanned += 1;
172
- nodesWritten += graph.nodes.length + (attribution?.contributorEmails.length ?? 0);
173
- edgesWritten += graph.edges.length + (attribution?.nodeAttributions.reduce((sum, entry) => sum + entry.contributorEmails.length, 0) ?? 0);
315
+ nodesWritten += graph.nodes.length;
316
+ edgesWritten += graph.edges.length;
317
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'files', status: 'processed', repository, attempt, path: relativePath, counts: { processed: filesScanned, skipped: skipped.length } });
174
318
  }
175
- const persistedFileIds = new Map(files
176
- .filter((relativePath) => !skipped.includes(relativePath))
177
- .map((relativePath) => [normalizePluginFilePath(relativePath) ?? relativePath, hashParts([repository.id, relativePath])]));
319
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'reconciliation', status: 'started', repository, attempt, counts: { deleted: deletedPaths.length } });
320
+ const manifestTraversalDegraded = !currentManifestScan.complete || manifests.some((manifest) => manifest.status === 'read_failed' || manifest.status === 'traversal_incomplete');
321
+ for (const handle of opened) {
322
+ reconcileDeletedFiles(handle.db, repository.id, files);
323
+ persistManifestInventoryAndScanState(handle.db, repository.id, manifests, inventoryGeneration, skipped.length > 0 || manifestTraversalDegraded || pluginRun.failedPlugins.length > 0 || stalePlugins.length > 0 ? 'degraded' : 'fresh');
324
+ }
325
+ const persistedFileIds = new Map(
326
+ // Failed paths retain their previous persisted file graph, so they are
327
+ // still valid owners for retained plugin facts during reconciliation.
328
+ files.map((relativePath) => [normalizePluginFilePath(relativePath) ?? relativePath, hashParts([repository.id, relativePath])]));
178
329
  const unresolvedOwnership = diagnoseUnresolvedPluginOwnership(pluginRun.nodes, pluginRun.edges, persistedFileIds);
179
330
  const diagnosticLimit = pluginLimits?.maxDiagnostics ?? DEFAULT_PLUGIN_RESOURCE_LIMITS.maxDiagnostics;
180
331
  const messageLimit = pluginLimits?.maxDiagnosticMessageBytes ?? DEFAULT_PLUGIN_RESOURCE_LIMITS.maxDiagnosticMessageBytes;
@@ -185,11 +336,21 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo', plu
185
336
  edgesWritten += reconcilePluginEdges(opened[0].db, repository, pluginRun.edges, persistedFileIds, activePlugins);
186
337
  for (const handle of opened.slice(1))
187
338
  reconcilePluginEdges(handle.db, repository, pluginRun.edges, persistedFileIds, activePlugins);
339
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'reconciliation', status: 'completed', repository, attempt, counts: { deleted: deletedPaths.length, processed: filesScanned, skipped: skipped.length } });
188
340
  }
189
341
  finally {
190
342
  for (const handle of opened)
191
343
  closeDatabase(handle.db);
192
344
  }
345
+ reportProgress(progress, { kind: 'scan', operation, scope, phase: 'enrichment', status: 'started', repository, attempt });
346
+ const enrichment = full
347
+ ? await drainRepositoryEnrichment({ root: repository.root, scope, enrichmentRunner })
348
+ : summarizeRepositoryEnrichment(repository, scopes);
349
+ reportProgress(progress, {
350
+ kind: 'scan', operation, scope, phase: 'enrichment', status: enrichment.state === 'complete' ? 'completed' : enrichment.state === 'pending' ? 'deferred' : 'degraded', repository, attempt,
351
+ detail: enrichment.state === 'pending' ? 'structural scan complete; enrichment deferred' : enrichment.state === 'failed' ? 'enrichment failed; scan is degraded' : 'enrichment complete',
352
+ counts: { pending: enrichment.pending, complete: enrichment.complete, failed: enrichment.failed }
353
+ });
193
354
  return {
194
355
  repository,
195
356
  scopes: opened.map(({ scope: graphScope, databasePath }) => ({ scope: graphScope, databasePath })),
@@ -198,47 +359,189 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo', plu
198
359
  nodesWritten,
199
360
  edgesWritten,
200
361
  skipped,
362
+ enrichment,
201
363
  pluginDiagnostics: pluginRun.diagnostics,
202
364
  failedPlugins: pluginRun.failedPlugins,
203
365
  stalePlugins
204
366
  };
205
367
  }
206
- export function graphStatus({ root = process.cwd(), scope = 'repo' } = {}) {
368
+ export const defaultEnrichmentRunner = ({ repository, work, source, nodes }) => buildFileAttribution({
369
+ root: repository.root,
370
+ relativePath: work.path,
371
+ source,
372
+ nodes
373
+ });
374
+ /**
375
+ * Drains persisted, hash-compatible attribution work. This is deliberately
376
+ * explicit: one-shot structural scans persist work but never start it.
377
+ */
378
+ export async function drainRepositoryEnrichment({ root = process.cwd(), scope = 'repo', enrichmentRunner = defaultEnrichmentRunner } = {}) {
207
379
  const repository = repositoryForRoot(root);
208
- return withScopes(repository, scope, (db, graphScope, databasePath) => ({
209
- scope: graphScope,
210
- databasePath,
211
- ...getStatus(db, repository.id)
380
+ const scopes = normalizeScope(scope);
381
+ for (const graphScope of scopes) {
382
+ const db = openGraphDatabase(databasePathForScope(graphScope, repository.root));
383
+ try {
384
+ const workItems = selectEnrichmentWork(db, repository.id);
385
+ for (const work of workItems) {
386
+ let outcome;
387
+ try {
388
+ const source = readFileSync(path.join(repository.root, work.path), 'utf8');
389
+ // Work is tied to the structural source hash. Leave incompatible work
390
+ // pending for the next structural scan instead of applying it late.
391
+ if (sha256(source) !== work.sourceHash)
392
+ continue;
393
+ outcome = await enrichmentRunner({
394
+ repository,
395
+ work,
396
+ source,
397
+ nodes: selectStructuralNodesForEnrichment(db, work)
398
+ });
399
+ // The runner can be asynchronous, so verify the source again before
400
+ // committing its result. A changed file remains pending and a full
401
+ // scan will rebuild its structural graph before retrying enrichment.
402
+ if (sha256(readFileSync(path.join(repository.root, work.path), 'utf8')) !== work.sourceHash)
403
+ continue;
404
+ }
405
+ catch (error) {
406
+ outcome = { status: 'failed', diagnostic: `enrichment failed: ${diagnosticFor(error)}` };
407
+ }
408
+ applyFileAttribution(db, { repository, fileId: work.fileId, sourceHash: work.sourceHash, outcome });
409
+ }
410
+ }
411
+ finally {
412
+ closeDatabase(db);
413
+ }
414
+ }
415
+ return summarizeRepositoryEnrichment(repository, scopes);
416
+ }
417
+ function summarizeRepositoryEnrichment(repository, scopes) {
418
+ const states = [];
419
+ for (const graphScope of scopes) {
420
+ const db = openGraphDatabase(databasePathForScope(graphScope, repository.root));
421
+ try {
422
+ states.push(...selectFileEnrichmentStates(db, repository.id, ['pending', 'complete', 'failed']));
423
+ }
424
+ finally {
425
+ closeDatabase(db);
426
+ }
427
+ }
428
+ const pending = states.filter((entry) => entry.state === 'pending').length;
429
+ const complete = states.filter((entry) => entry.state === 'complete').length;
430
+ const failedEntries = states.filter((entry) => entry.state === 'failed');
431
+ return {
432
+ state: failedEntries.length > 0 ? 'failed' : pending > 0 ? 'pending' : 'complete',
433
+ pending,
434
+ complete,
435
+ failed: failedEntries.length,
436
+ diagnostics: failedEntries.flatMap((entry) => entry.diagnostic ? [entry.diagnostic] : [])
437
+ };
438
+ }
439
+ function selectStructuralNodesForEnrichment(db, work) {
440
+ const rows = db.prepare(`
441
+ SELECT id, kind, type, name, start_byte AS startByte, end_byte AS endByte,
442
+ start_point AS startPoint, end_point AS endPoint, source_hash AS sourceHash,
443
+ parser, parser_version AS parserVersion, metadata_json AS metadataJson,
444
+ last_modified_user_id AS lastModifiedUserId
445
+ FROM nodes
446
+ WHERE repository_id = ? AND file_id = ? AND source_hash = ?
447
+ ORDER BY id
448
+ `).all(work.repositoryId, work.fileId, work.sourceHash);
449
+ return rows.map((row) => ({
450
+ ...row,
451
+ startPoint: JSON.parse(row.startPoint),
452
+ endPoint: JSON.parse(row.endPoint),
453
+ metadata: parseMetadata(row.metadataJson)
212
454
  }));
213
455
  }
456
+ function parseMetadata(value) {
457
+ try {
458
+ const parsed = JSON.parse(value);
459
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
460
+ }
461
+ catch {
462
+ return {};
463
+ }
464
+ }
465
+ function diagnosticFor(error) {
466
+ return error instanceof Error ? error.message : String(error);
467
+ }
468
+ function reportProgress(reporter, event) {
469
+ // Progress is observational: a faulty consumer must never change scan
470
+ // semantics or turn a successful scan into a failed one.
471
+ try {
472
+ reporter?.(event);
473
+ }
474
+ catch { /* diagnostics must not affect scanning */ }
475
+ }
476
+ export function graphStatus({ root = process.cwd(), scope = 'repo' } = {}) {
477
+ const repository = repositoryForRoot(root);
478
+ return withReadOnlyScopes(repository, scope, (db, graphScope, databasePath) => db
479
+ ? { scope: graphScope, databasePath, ...getStatus(db, repository.id) }
480
+ : {
481
+ scope: graphScope,
482
+ databasePath,
483
+ repository: null,
484
+ files: 0,
485
+ nodes: 0,
486
+ edges: 0,
487
+ freshness: { state: 'stale', reason: 'not_scanned' },
488
+ enrichment: { state: 'complete', pending: 0, complete: 0, failed: 0, diagnostics: [] }
489
+ });
490
+ }
214
491
  export function listNodes({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
215
492
  const repository = repositoryForRoot(root);
216
- return withScopes(repository, scope, (db, graphScope) => selectNodes(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
493
+ return withReadOnlyScopes(repository, scope, (db, graphScope) => {
494
+ if (!db)
495
+ return [];
496
+ const freshness = selectFreshnessReport(db, repository.id);
497
+ return selectNodes(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
498
+ }).flat();
217
499
  }
218
500
  export function listNodeText({ root = process.cwd(), scope = 'repo', kind, term, limit = 50 } = {}) {
219
501
  const repository = repositoryForRoot(root);
220
- return withScopes(repository, scope, (db, graphScope) => selectNodeTextRows(db, repository.id, { kind, term, limit }).map((row) => ({
221
- scope: graphScope,
222
- root: repository.root,
223
- ...row,
224
- path: row.path ? path.resolve(repository.root, row.path) : null
225
- }))).flat();
502
+ return withReadOnlyScopes(repository, scope, (db, graphScope) => {
503
+ if (!db)
504
+ return [];
505
+ const freshness = selectFreshnessReport(db, repository.id);
506
+ return selectNodeTextRows(db, repository.id, { kind, term, limit }).map((row) => ({
507
+ scope: graphScope,
508
+ root: repository.root,
509
+ freshness,
510
+ ...row,
511
+ path: row.path ? path.resolve(repository.root, row.path) : null
512
+ }));
513
+ }).flat();
226
514
  }
227
515
  export function listEdges({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
228
516
  const repository = repositoryForRoot(root);
229
- return withScopes(repository, scope, (db, graphScope) => selectEdges(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
517
+ return withReadOnlyScopes(repository, scope, (db, graphScope) => {
518
+ if (!db)
519
+ return [];
520
+ const freshness = selectFreshnessReport(db, repository.id);
521
+ return selectEdges(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
522
+ }).flat();
230
523
  }
231
524
  export function neighbors({ root = process.cwd(), scope = 'repo', nodeId, depth = 1, limit = 100 } = {}) {
232
525
  if (!nodeId)
233
526
  throw new Error('neighbors requires a nodeId');
234
527
  const repository = repositoryForRoot(root);
235
- return withScopes(repository, scope, (db, graphScope) => selectNeighbors(db, repository.id, nodeId, { depth, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
528
+ return withReadOnlyScopes(repository, scope, (db, graphScope) => {
529
+ if (!db)
530
+ return [];
531
+ const freshness = selectFreshnessReport(db, repository.id);
532
+ return selectNeighbors(db, repository.id, nodeId, { depth, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
533
+ }).flat();
236
534
  }
237
535
  export function callGraph({ root = process.cwd(), scope = 'repo', term, kind, depth = 5, limit = 100 } = {}) {
238
536
  if (!term?.trim())
239
537
  throw new Error('callgraph requires a term');
240
538
  const repository = repositoryForRoot(root);
241
- return withScopes(repository, scope, (db, graphScope) => selectCallGraph(db, repository.id, term.trim(), { kind, depth, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
539
+ return withReadOnlyScopes(repository, scope, (db, graphScope) => {
540
+ if (!db)
541
+ return [];
542
+ const freshness = selectFreshnessReport(db, repository.id);
543
+ return selectCallGraph(db, repository.id, term.trim(), { kind, depth, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
544
+ }).flat();
242
545
  }
243
546
  const CONTEXT_DEFAULTS = {
244
547
  depth: 2,
@@ -277,63 +580,15 @@ export async function contextGraph(options) {
277
580
  }
278
581
  async function contextForScope(repository, graphScope, request, excerpts) {
279
582
  const databasePath = databasePathForScope(graphScope, repository.root);
280
- const discovered = discoverSourceFiles(repository.root);
281
- const beforeFingerprint = collectRepositoryFingerprint(repository.root);
282
- let db = openGraphDatabase(databasePath);
283
- let refresh = {
284
- outcome: 'reused', filesDiscovered: discovered.length, filesScanned: 0, skipped: []
285
- };
286
- let freshness = 'unchanged';
287
- let priorGraph = false;
583
+ let db;
288
584
  try {
289
- upsertRepository(db, repository);
290
- const inventory = selectFileInventory(db, repository.id);
291
- priorGraph = inventory.length > 0;
292
- const inventoryChanged = inventory.length !== discovered.length || discovered.some((file, index) => {
293
- const row = inventory[index];
294
- if (!row || row.path !== file)
295
- return true;
296
- try {
297
- const stat = statSync(path.join(repository.root, file));
298
- return row.size !== stat.size || row.mtimeMs !== Math.round(stat.mtimeMs);
299
- }
300
- catch {
301
- return true;
302
- }
303
- });
304
- if (inventoryChanged) {
305
- closeDatabase(db);
306
- db = undefined;
307
- try {
308
- const summary = await scanRepository({ root: repository.root, scope: graphScope });
309
- refresh = {
310
- outcome: summary.skipped.length > 0 ? 'refresh_failed' : 'refreshed',
311
- filesDiscovered: summary.filesDiscovered,
312
- filesScanned: summary.filesScanned,
313
- skipped: summary.skipped
314
- };
315
- if (summary.skipped.length > 0)
316
- return failedContextScope(graphScope, databasePath, refresh, priorGraph);
317
- const refreshedDb = openGraphDatabase(databasePath);
318
- try {
319
- reconcileDeletedFiles(refreshedDb, repository.id, discovered);
320
- }
321
- finally {
322
- closeDatabase(refreshedDb);
323
- }
324
- freshness = 'refreshed';
325
- db = openGraphDatabase(databasePath);
326
- upsertRepository(db, repository);
327
- }
328
- catch (error) {
329
- refresh = { ...refresh, outcome: 'refresh_failed', skipped: [error instanceof Error ? error.message : String(error)] };
330
- return failedContextScope(graphScope, databasePath, refresh, priorGraph);
331
- }
332
- }
333
- const afterFingerprint = collectRepositoryFingerprint(repository.root);
334
- if (afterFingerprint !== beforeFingerprint) {
335
- return failedContextScope(graphScope, databasePath, refresh, priorGraph, 'stale');
336
- }
585
+ db = openReadOnlyGraphDatabase(databasePath);
586
+ }
587
+ catch {
588
+ return emptyContextScope(graphScope, databasePath, { state: 'stale', reason: 'not_scanned' });
589
+ }
590
+ try {
591
+ const freshness = selectFreshnessReport(db, repository.id);
337
592
  const candidateRows = selectContextCandidates(db, repository.id, request.symbol, {
338
593
  kind: request.kind,
339
594
  file: request.file,
@@ -344,11 +599,12 @@ async function contextForScope(repository, graphScope, request, excerpts) {
344
599
  const truncation = candidateTruncated
345
600
  ? [{ section: 'candidates', reason: 'candidate_limit', omitted: 1 }]
346
601
  : [];
602
+ const excerptWarning = excerpts ? ['excerpt_unavailable:stored_graph_only'] : [];
347
603
  if (candidates.length === 0) {
348
- return { scope: graphScope, databasePath, result: { state: 'not_found', impact: [], dependencies: [], warnings: [], truncation }, freshness: { state: freshness, verification: 'metadata_only', refresh } };
604
+ return { scope: graphScope, databasePath, result: { state: 'not_found', impact: [], dependencies: [], warnings: excerptWarning, truncation }, freshness };
349
605
  }
350
606
  if (candidateTruncated || candidates.length !== 1) {
351
- return { scope: graphScope, databasePath, result: { state: 'ambiguous', candidates: candidates.map(stripCandidate), impact: [], dependencies: [], warnings: [], truncation }, freshness: { state: freshness, verification: 'metadata_only', refresh } };
607
+ return { scope: graphScope, databasePath, result: { state: 'ambiguous', candidates: candidates.map(stripCandidate), impact: [], dependencies: [], warnings: excerptWarning, truncation }, freshness };
352
608
  }
353
609
  const anchor = stripInternalReference(candidates[0]);
354
610
  const rawRelations = request.budget.depth === 0
@@ -366,35 +622,27 @@ async function contextForScope(repository, graphScope, request, excerpts) {
366
622
  dependencies.push({ category: 'dependency', edgeKind: relation.edgeKind, direction: relation.edgeKind === 'imports' ? 'import' : 'export', source, target, confidence: relation.confidence, provenance: relation.metadata, depth: relation.depth });
367
623
  }
368
624
  }
369
- const warnings = candidates[0]?.match === 'substring' ? ['substring_search=bounded_like'] : [];
370
- let verification = 'metadata_only';
371
- if (excerpts) {
372
- const excerptStatus = await addExcerpt(repository.root, anchor, request.budget);
373
- if (excerptStatus === 'checked')
374
- verification = 'content_hash';
375
- if (excerptStatus === 'unavailable')
376
- warnings.push('excerpt_unavailable');
377
- if (excerptStatus === 'mismatch')
378
- warnings.push('excerpt_unavailable:source_hash_mismatch');
379
- }
625
+ const warnings = [
626
+ ...(candidates[0]?.match === 'substring' ? ['substring_search=bounded_like'] : []),
627
+ ...excerptWarning
628
+ ];
380
629
  return {
381
630
  scope: graphScope,
382
631
  databasePath,
383
632
  result: { state: 'ok', anchor, definition: anchor, impact, dependencies, warnings, truncation },
384
- freshness: { state: freshness, verification, refresh }
633
+ freshness
385
634
  };
386
635
  }
387
636
  finally {
388
- if (db)
389
- closeDatabase(db);
637
+ closeDatabase(db);
390
638
  }
391
639
  }
392
- function failedContextScope(scope, databasePath, refresh, priorGeneration, state = 'refresh_failed') {
640
+ function emptyContextScope(scope, databasePath, freshness) {
393
641
  return {
394
642
  scope,
395
643
  databasePath,
396
- result: { state, impact: [], dependencies: [], warnings: refresh.skipped, truncation: [] },
397
- freshness: { state: state === 'stale' || priorGeneration ? 'stale' : 'refresh_failed', verification: 'metadata_only', refresh: { ...refresh, outcome: state === 'stale' ? refresh.outcome : 'refresh_failed' } }
644
+ result: { state: 'not_found', impact: [], dependencies: [], warnings: [], truncation: [] },
645
+ freshness
398
646
  };
399
647
  }
400
648
  function stripCandidate(candidate) {
@@ -405,24 +653,6 @@ function stripInternalReference(reference) {
405
653
  const { fileId: _fileId, rank: _rank, match: _match, ...publicReference } = reference;
406
654
  return publicReference;
407
655
  }
408
- async function addExcerpt(root, anchor, budget) {
409
- if (!anchor.path || !anchor.sourceHash)
410
- return 'unavailable';
411
- try {
412
- const source = readFileSync(path.join(root, anchor.path), 'utf8');
413
- if (sha256(source) !== anchor.sourceHash)
414
- return 'mismatch';
415
- const lines = source.split(/\r?\n/);
416
- const start = Math.max(0, anchor.span.start.row);
417
- const end = Math.min(lines.length, start + budget.excerptLines);
418
- const bounded = Buffer.from(lines.slice(start, end).join('\\n'), 'utf8').subarray(0, budget.excerptBytes).toString('utf8');
419
- anchor.excerpt = bounded;
420
- return 'checked';
421
- }
422
- catch {
423
- return 'unavailable';
424
- }
425
- }
426
656
  function clampContext(value, min, max, fallback) {
427
657
  if (value === undefined || !Number.isFinite(value))
428
658
  return fallback;
@@ -432,21 +662,29 @@ export function runReadOnlySql({ root = process.cwd(), scope = 'repo', sql, atta
432
662
  if (!sql)
433
663
  throw new Error('query requires SQL text');
434
664
  const repository = repositoryForRoot(root);
435
- return withScopes(repository, scope, (db, graphScope) => {
665
+ return withReadOnlyScopes(repository, scope, (db, graphScope) => {
666
+ if (!db)
667
+ return { scope: graphScope, rows: [], freshness: { state: 'stale', reason: 'not_scanned' } };
436
668
  if (attachHome && graphScope === 'repo')
437
669
  attachDatabase(db, 'home_graph', homeDatabasePath());
438
670
  const rows = runSelect(db, sql, { limit });
439
- return { scope: graphScope, rows };
671
+ return { scope: graphScope, rows, freshness: selectFreshnessReport(db, repository.id) };
440
672
  });
441
673
  }
442
- function withScopes(repository, scope, fn) {
674
+ function withReadOnlyScopes(repository, scope, fn) {
443
675
  const scopes = normalizeScope(scope);
444
676
  const results = [];
445
677
  for (const graphScope of scopes) {
446
678
  const databasePath = graphScope === 'repo' ? repoDatabasePath(repository.root) : homeDatabasePath();
447
- const db = openGraphDatabase(databasePath);
679
+ let db;
680
+ try {
681
+ db = openReadOnlyGraphDatabase(databasePath);
682
+ }
683
+ catch {
684
+ results.push(fn(undefined, graphScope, databasePath));
685
+ continue;
686
+ }
448
687
  try {
449
- upsertRepository(db, repository);
450
688
  results.push(fn(db, graphScope, databasePath));
451
689
  }
452
690
  finally {