@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.
- package/README.md +117 -15
- package/dist/bin/lscg.js +0 -0
- package/dist/src/cli-progress.d.ts +7 -0
- package/dist/src/cli-progress.js +59 -0
- package/dist/src/cli.js +62 -9
- package/dist/src/explore/sigma-provider.d.ts +27 -0
- package/dist/src/explore/sigma-provider.js +87 -0
- package/dist/src/explore/sigma-render.d.ts +18 -0
- package/dist/src/explore/sigma-render.js +67 -0
- package/dist/src/graph/attribution.d.ts +2 -2
- package/dist/src/graph/attribution.js +36 -13
- package/dist/src/graph/explore.d.ts +20 -0
- package/dist/src/graph/explore.js +200 -0
- package/dist/src/graph/repository.d.ts +36 -4
- package/dist/src/graph/repository.js +443 -159
- package/dist/src/graph/repositoryScanWorker.d.ts +21 -0
- package/dist/src/graph/repositoryScanWorker.js +45 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +5 -0
- package/dist/src/mcp/server.js +21 -3
- package/dist/src/parser/treeSitter.js +20 -1
- package/dist/src/scanner/artifactInventory.d.ts +35 -0
- package/dist/src/scanner/artifactInventory.js +139 -0
- package/dist/src/scanner/attributionPlugin.d.ts +5 -0
- package/dist/src/scanner/attributionPlugin.js +16 -0
- package/dist/src/scanner/discover.js +89 -16
- package/dist/src/scanner/fingerprint.js +5 -0
- package/dist/src/scanner/javaDependencyPlugin.d.ts +5 -0
- package/dist/src/scanner/javaDependencyPlugin.js +107 -0
- package/dist/src/scanner/javaPlugin.d.ts +5 -0
- package/dist/src/scanner/javaPlugin.js +199 -0
- package/dist/src/scanner/javaScanWorker.d.ts +2 -0
- package/dist/src/scanner/javaScanWorker.js +8 -0
- package/dist/src/scanner/packageParseWorker.d.ts +17 -0
- package/dist/src/scanner/packageParseWorker.js +30 -0
- package/dist/src/scanner/packagePlugin.js +83 -24
- package/dist/src/scanner/parallelScan.d.ts +2 -0
- package/dist/src/scanner/parallelScan.js +32 -0
- package/dist/src/scanner/plugins.d.ts +38 -4
- package/dist/src/scanner/plugins.js +58 -5
- package/dist/src/scanner/pythonPlugin.d.ts +5 -0
- package/dist/src/scanner/pythonPlugin.js +198 -0
- package/dist/src/scanner/pythonScanWorker.d.ts +2 -0
- package/dist/src/scanner/pythonScanWorker.js +8 -0
- package/dist/src/storage/connection.js +85 -0
- package/dist/src/storage/database.d.ts +1 -0
- package/dist/src/storage/database.js +1 -0
- package/dist/src/storage/explore-queries.d.ts +52 -0
- package/dist/src/storage/explore-queries.js +184 -0
- package/dist/src/storage/graph-writes.d.ts +22 -3
- package/dist/src/storage/graph-writes.js +167 -20
- package/dist/src/storage/manifest-inventory.d.ts +23 -0
- package/dist/src/storage/manifest-inventory.js +82 -0
- package/dist/src/storage/plugin-graph.js +3 -3
- package/dist/src/storage/queries.d.ts +10 -3
- package/dist/src/storage/queries.js +99 -24
- package/dist/src/storage/schema.d.ts +2 -2
- package/dist/src/storage/schema.js +48 -1
- package/dist/src/types.d.ts +110 -6
- package/dist/src/watch.d.ts +20 -2
- package/dist/src/watch.js +211 -46
- 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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
104
|
-
persistPluginContributions(primary.db, repository, contributions,
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
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:
|
|
314
|
+
id: hashParts([repository.id, relativePath]),
|
|
142
315
|
path: relativePath,
|
|
143
|
-
language:
|
|
144
|
-
hash: sourceHash,
|
|
145
|
-
size:
|
|
146
|
-
mtimeMs:
|
|
316
|
+
language: result.language,
|
|
317
|
+
hash: result.sourceHash,
|
|
318
|
+
size: result.size,
|
|
319
|
+
mtimeMs: result.mtimeMs
|
|
147
320
|
};
|
|
148
|
-
const builtInGraph =
|
|
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, {
|
|
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
|
|
173
|
-
edgesWritten += graph.edges.length
|
|
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(
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
679
|
+
freshness
|
|
385
680
|
};
|
|
386
681
|
}
|
|
387
682
|
finally {
|
|
388
|
-
|
|
389
|
-
closeDatabase(db);
|
|
683
|
+
closeDatabase(db);
|
|
390
684
|
}
|
|
391
685
|
}
|
|
392
|
-
function
|
|
686
|
+
function emptyContextScope(scope, databasePath, freshness) {
|
|
393
687
|
return {
|
|
394
688
|
scope,
|
|
395
689
|
databasePath,
|
|
396
|
-
result: { state, impact: [], dependencies: [], warnings:
|
|
397
|
-
freshness
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|