@psnext/lscg 0.1.4 → 0.1.6

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