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