@psnext/lscg 0.1.5 → 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 +30 -6
- package/dist/bin/lscg.js +0 -0
- package/dist/src/cli.js +54 -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/explore.d.ts +20 -0
- package/dist/src/graph/explore.js +200 -0
- package/dist/src/graph/repository.d.ts +7 -2
- package/dist/src/graph/repository.js +66 -20
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/mcp/server.js +5 -2
- 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/plugins.d.ts +6 -2
- package/dist/src/scanner/plugins.js +8 -1
- package/dist/src/storage/connection.js +22 -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 +8 -2
- package/dist/src/storage/graph-writes.js +55 -33
- package/dist/src/storage/plugin-graph.js +3 -3
- package/dist/src/storage/queries.d.ts +4 -2
- package/dist/src/storage/queries.js +40 -31
- package/dist/src/storage/schema.d.ts +2 -2
- package/dist/src/storage/schema.js +5 -1
- package/dist/src/types.d.ts +10 -2
- package/dist/src/watch.d.ts +3 -0
- package/dist/src/watch.js +5 -2
- package/package.json +1 -1
|
@@ -15,6 +15,8 @@ export interface ScanRepositoryOptions {
|
|
|
15
15
|
root?: string | undefined;
|
|
16
16
|
scope?: StorageScope | 'both' | undefined;
|
|
17
17
|
plugins?: readonly ScannerPlugin[] | undefined;
|
|
18
|
+
/** Enables the bundled Git attribution enrichment plugin. */
|
|
19
|
+
attribution?: boolean | undefined;
|
|
18
20
|
pluginLimits?: Partial<PluginResourceLimits> | undefined;
|
|
19
21
|
full?: boolean | undefined;
|
|
20
22
|
enrichmentRunner?: EnrichmentRunner | undefined;
|
|
@@ -32,12 +34,14 @@ export interface DrainRepositoryEnrichmentOptions {
|
|
|
32
34
|
root?: string | undefined;
|
|
33
35
|
scope?: StorageScope | 'both' | undefined;
|
|
34
36
|
enrichmentRunner?: EnrichmentRunner | undefined;
|
|
37
|
+
pluginName?: string | undefined;
|
|
38
|
+
historyFingerprint?: string | null | undefined;
|
|
35
39
|
}
|
|
36
40
|
/**
|
|
37
41
|
* Drains persisted, hash-compatible attribution work. This is deliberately
|
|
38
42
|
* explicit: one-shot structural scans persist work but never start it.
|
|
39
43
|
*/
|
|
40
|
-
export declare function drainRepositoryEnrichment({ root, scope, enrichmentRunner }?: DrainRepositoryEnrichmentOptions): Promise<EnrichmentSummary>;
|
|
44
|
+
export declare function drainRepositoryEnrichment({ root, scope, enrichmentRunner, pluginName, historyFingerprint }?: DrainRepositoryEnrichmentOptions): Promise<EnrichmentSummary>;
|
|
41
45
|
export declare function graphStatus({ root, scope }?: {
|
|
42
46
|
root?: string | undefined;
|
|
43
47
|
scope?: StorageScope | 'both' | undefined;
|
|
@@ -65,10 +69,11 @@ export declare function listNodeText({ root, scope, kind, term, limit }?: {
|
|
|
65
69
|
root: string;
|
|
66
70
|
freshness: FreshnessReport;
|
|
67
71
|
} & GraphNodeTextRow>;
|
|
68
|
-
export declare function listEdges({ root, scope, kind, limit }?: {
|
|
72
|
+
export declare function listEdges({ root, scope, kind, type, limit }?: {
|
|
69
73
|
root?: string | undefined;
|
|
70
74
|
scope?: StorageScope | 'both' | undefined;
|
|
71
75
|
kind?: string | undefined;
|
|
76
|
+
type?: string | undefined;
|
|
72
77
|
limit?: number | undefined;
|
|
73
78
|
}): Array<{
|
|
74
79
|
scope: StorageScope;
|
|
@@ -1,4 +1,5 @@
|
|
|
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';
|
|
@@ -11,6 +12,7 @@ import { PackagePlugin } from '../scanner/packagePlugin.js';
|
|
|
11
12
|
import { PythonScannerPlugin } from '../scanner/pythonPlugin.js';
|
|
12
13
|
import { JavaScannerPlugin } from '../scanner/javaPlugin.js';
|
|
13
14
|
import { JavaDependencyPlugin } from '../scanner/javaDependencyPlugin.js';
|
|
15
|
+
import { AttributionPlugin, ATTRIBUTION_PLUGIN_NAME } from '../scanner/attributionPlugin.js';
|
|
14
16
|
import { inspectRepositoryManifests, mergeManifestInventories } from '../scanner/artifactInventory.js';
|
|
15
17
|
import { runBatchedWorkers } from '../scanner/parallelScan.js';
|
|
16
18
|
function normalizePluginFilePath(value) {
|
|
@@ -89,8 +91,8 @@ export async function scanRepository(options = {}) {
|
|
|
89
91
|
try {
|
|
90
92
|
const summary = await scanRepositoryOnce({ ...options, attempt: 1 });
|
|
91
93
|
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
|
+
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}`,
|
|
94
96
|
counts: { processed: summary.filesScanned, skipped: summary.skipped.length, pending: summary.enrichment.pending, complete: summary.enrichment.complete, failed: summary.enrichment.failed }
|
|
95
97
|
});
|
|
96
98
|
return summary;
|
|
@@ -105,8 +107,8 @@ export async function scanRepository(options = {}) {
|
|
|
105
107
|
const summary = await scanRepositoryOnce({ ...options, attempt });
|
|
106
108
|
if (summary.enrichment.pending === 0) {
|
|
107
109
|
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
|
+
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}`,
|
|
110
112
|
counts: { processed: summary.filesScanned, skipped: summary.skipped.length, pending: summary.enrichment.pending, complete: summary.enrichment.complete, failed: summary.enrichment.failed }
|
|
111
113
|
});
|
|
112
114
|
return summary;
|
|
@@ -124,8 +126,9 @@ export async function scanRepository(options = {}) {
|
|
|
124
126
|
reportProgress(progress, { kind: 'scan', operation, scope, phase: 'terminal', status: 'failed', repository: repositoryForRoot(options.root), attempt: MAX_FULL_ENRICHMENT_ATTEMPTS, detail: error.message });
|
|
125
127
|
throw error;
|
|
126
128
|
}
|
|
127
|
-
async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugins = [], pluginLimits, full = false, enrichmentRunner, progress, attempt = 1 } = {}) {
|
|
129
|
+
async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugins = [], attribution = false, pluginLimits, full = false, enrichmentRunner, progress, attempt = 1 } = {}) {
|
|
128
130
|
const repository = repositoryForRoot(root);
|
|
131
|
+
const attributionEnabled = attribution || plugins.some((plugin) => plugin.name === ATTRIBUTION_PLUGIN_NAME);
|
|
129
132
|
const scopes = normalizeScope(scope);
|
|
130
133
|
const files = discoverSourceFiles(repository.root);
|
|
131
134
|
const operation = full ? 'full' : 'incremental';
|
|
@@ -161,9 +164,24 @@ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugin
|
|
|
161
164
|
// Keep the analyzer active for current manifests and for one final
|
|
162
165
|
// reconciliation after a restart/deletion of the last manifest.
|
|
163
166
|
...(hasJavaManifests || hasPriorJavaDependencyContribution ? [JavaDependencyPlugin] : []),
|
|
164
|
-
...plugins.
|
|
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))
|
|
165
169
|
];
|
|
166
|
-
const
|
|
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)
|
|
167
185
|
? files
|
|
168
186
|
: files.filter((relativePath) => {
|
|
169
187
|
try {
|
|
@@ -308,7 +326,13 @@ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugin
|
|
|
308
326
|
edges: builtInGraph.edges
|
|
309
327
|
};
|
|
310
328
|
for (const handle of opened) {
|
|
311
|
-
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
|
+
});
|
|
312
336
|
clearFileScanFailure(handle.db, repository.id, relativePath);
|
|
313
337
|
}
|
|
314
338
|
filesScanned += 1;
|
|
@@ -343,12 +367,16 @@ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugin
|
|
|
343
367
|
closeDatabase(handle.db);
|
|
344
368
|
}
|
|
345
369
|
reportProgress(progress, { kind: 'scan', operation, scope, phase: 'enrichment', status: 'started', repository, attempt });
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
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();
|
|
349
377
|
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',
|
|
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',
|
|
352
380
|
counts: { pending: enrichment.pending, complete: enrichment.complete, failed: enrichment.failed }
|
|
353
381
|
});
|
|
354
382
|
return {
|
|
@@ -375,13 +403,13 @@ export const defaultEnrichmentRunner = ({ repository, work, source, nodes }) =>
|
|
|
375
403
|
* Drains persisted, hash-compatible attribution work. This is deliberately
|
|
376
404
|
* explicit: one-shot structural scans persist work but never start it.
|
|
377
405
|
*/
|
|
378
|
-
export async function drainRepositoryEnrichment({ root = process.cwd(), scope = 'repo', enrichmentRunner = defaultEnrichmentRunner } = {}) {
|
|
406
|
+
export async function drainRepositoryEnrichment({ root = process.cwd(), scope = 'repo', enrichmentRunner = defaultEnrichmentRunner, pluginName = ATTRIBUTION_PLUGIN_NAME, historyFingerprint = null } = {}) {
|
|
379
407
|
const repository = repositoryForRoot(root);
|
|
380
408
|
const scopes = normalizeScope(scope);
|
|
381
409
|
for (const graphScope of scopes) {
|
|
382
410
|
const db = openGraphDatabase(databasePathForScope(graphScope, repository.root));
|
|
383
411
|
try {
|
|
384
|
-
const workItems = selectEnrichmentWork(db, repository.id);
|
|
412
|
+
const workItems = selectEnrichmentWork(db, repository.id).filter((work) => work.pluginName === pluginName && (historyFingerprint === null || work.historyFingerprint === historyFingerprint));
|
|
385
413
|
for (const work of workItems) {
|
|
386
414
|
let outcome;
|
|
387
415
|
try {
|
|
@@ -405,7 +433,7 @@ export async function drainRepositoryEnrichment({ root = process.cwd(), scope =
|
|
|
405
433
|
catch (error) {
|
|
406
434
|
outcome = { status: 'failed', diagnostic: `enrichment failed: ${diagnosticFor(error)}` };
|
|
407
435
|
}
|
|
408
|
-
applyFileAttribution(db, { repository, fileId: work.fileId, sourceHash: work.sourceHash, outcome });
|
|
436
|
+
applyFileAttribution(db, { repository, fileId: work.fileId, sourceHash: work.sourceHash, outcome, pluginName, historyFingerprint });
|
|
409
437
|
}
|
|
410
438
|
}
|
|
411
439
|
finally {
|
|
@@ -414,6 +442,21 @@ export async function drainRepositoryEnrichment({ root = process.cwd(), scope =
|
|
|
414
442
|
}
|
|
415
443
|
return summarizeRepositoryEnrichment(repository, scopes);
|
|
416
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
|
+
}
|
|
417
460
|
function summarizeRepositoryEnrichment(repository, scopes) {
|
|
418
461
|
const states = [];
|
|
419
462
|
for (const graphScope of scopes) {
|
|
@@ -428,11 +471,14 @@ function summarizeRepositoryEnrichment(repository, scopes) {
|
|
|
428
471
|
const pending = states.filter((entry) => entry.state === 'pending').length;
|
|
429
472
|
const complete = states.filter((entry) => entry.state === 'complete').length;
|
|
430
473
|
const failedEntries = states.filter((entry) => entry.state === 'failed');
|
|
474
|
+
const unavailableEntries = states.filter((entry) => entry.outcome === 'unavailable');
|
|
431
475
|
return {
|
|
432
476
|
state: failedEntries.length > 0 ? 'failed' : pending > 0 ? 'pending' : 'complete',
|
|
433
477
|
pending,
|
|
434
|
-
complete,
|
|
478
|
+
complete: complete - unavailableEntries.length,
|
|
435
479
|
failed: failedEntries.length,
|
|
480
|
+
unavailable: unavailableEntries.length,
|
|
481
|
+
notApplicable: unavailableEntries.filter((entry) => entry.diagnostic === 'unavailable:not_applicable').length,
|
|
436
482
|
diagnostics: failedEntries.flatMap((entry) => entry.diagnostic ? [entry.diagnostic] : [])
|
|
437
483
|
};
|
|
438
484
|
}
|
|
@@ -485,7 +531,7 @@ export function graphStatus({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
|
485
531
|
nodes: 0,
|
|
486
532
|
edges: 0,
|
|
487
533
|
freshness: { state: 'stale', reason: 'not_scanned' },
|
|
488
|
-
enrichment:
|
|
534
|
+
enrichment: disabledEnrichmentSummary()
|
|
489
535
|
});
|
|
490
536
|
}
|
|
491
537
|
export function listNodes({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
|
|
@@ -512,13 +558,13 @@ export function listNodeText({ root = process.cwd(), scope = 'repo', kind, term,
|
|
|
512
558
|
}));
|
|
513
559
|
}).flat();
|
|
514
560
|
}
|
|
515
|
-
export function listEdges({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
|
|
561
|
+
export function listEdges({ root = process.cwd(), scope = 'repo', kind, type, limit = 50 } = {}) {
|
|
516
562
|
const repository = repositoryForRoot(root);
|
|
517
563
|
return withReadOnlyScopes(repository, scope, (db, graphScope) => {
|
|
518
564
|
if (!db)
|
|
519
565
|
return [];
|
|
520
566
|
const freshness = selectFreshnessReport(db, repository.id);
|
|
521
|
-
return selectEdges(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
|
|
567
|
+
return selectEdges(db, repository.id, { kind, type, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
|
|
522
568
|
}).flat();
|
|
523
569
|
}
|
|
524
570
|
export function neighbors({ root = process.cwd(), scope = 'repo', nodeId, depth = 1, limit = 100 } = {}) {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { PackagePlugin } from './scanner/packagePlugin.js';
|
|
|
6
6
|
export { PythonScannerPlugin, PYTHON_SCANNER_PLUGIN_NAME, scanPythonFiles } from './scanner/pythonPlugin.js';
|
|
7
7
|
export { JavaScannerPlugin, JAVA_SCANNER_PLUGIN_NAME, scanJavaFiles } from './scanner/javaPlugin.js';
|
|
8
8
|
export { JavaDependencyPlugin, JAVA_DEPENDENCY_PLUGIN_NAME, scanJavaDependencies } from './scanner/javaDependencyPlugin.js';
|
|
9
|
+
export { AttributionPlugin, ATTRIBUTION_PLUGIN_NAME } from './scanner/attributionPlugin.js';
|
|
9
10
|
export { inventoryRepositoryManifests, inspectRepositoryManifests, mergeManifestInventories } from './scanner/artifactInventory.js';
|
|
10
11
|
export type { RepositoryManifest, ManifestKind, ManifestStatus } from './scanner/artifactInventory.js';
|
|
11
12
|
export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
|
package/dist/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { PackagePlugin } from './scanner/packagePlugin.js';
|
|
|
5
5
|
export { PythonScannerPlugin, PYTHON_SCANNER_PLUGIN_NAME, scanPythonFiles } from './scanner/pythonPlugin.js';
|
|
6
6
|
export { JavaScannerPlugin, JAVA_SCANNER_PLUGIN_NAME, scanJavaFiles } from './scanner/javaPlugin.js';
|
|
7
7
|
export { JavaDependencyPlugin, JAVA_DEPENDENCY_PLUGIN_NAME, scanJavaDependencies } from './scanner/javaDependencyPlugin.js';
|
|
8
|
+
export { AttributionPlugin, ATTRIBUTION_PLUGIN_NAME } from './scanner/attributionPlugin.js';
|
|
8
9
|
export { inventoryRepositoryManifests, inspectRepositoryManifests, mergeManifestInventories } from './scanner/artifactInventory.js';
|
|
9
10
|
export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
|
|
10
11
|
export { repoDatabasePath, homeDatabasePath, resolveProjectRoot } from './config/paths.js';
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -11,8 +11,9 @@ export async function startMcpServer({ root = process.cwd() } = {}) {
|
|
|
11
11
|
const tool = server.tool.bind(server);
|
|
12
12
|
tool('context_graph_scan', 'Parse repository files with Tree-sitter and write discovered graph nodes/edges to SQLite.', {
|
|
13
13
|
root: z.string().optional().describe('Repository root. Defaults to the MCP server cwd.'),
|
|
14
|
-
scope: scopeSchema.describe('Graph storage scope to update.')
|
|
15
|
-
|
|
14
|
+
scope: scopeSchema.describe('Graph storage scope to update.'),
|
|
15
|
+
attribution: z.boolean().default(false).describe('Enable optional Git user attribution enrichment.')
|
|
16
|
+
}, async (input) => jsonResponse(await scanRepository({ root: input.root ?? root, scope: input.scope, attribution: input.attribution })));
|
|
16
17
|
tool('context_graph_context', 'Return stored, relationship-aware context and its persisted freshness report without scanning.', {
|
|
17
18
|
root: z.string().optional(),
|
|
18
19
|
scope: scopeSchema,
|
|
@@ -47,11 +48,13 @@ export async function startMcpServer({ root = process.cwd() } = {}) {
|
|
|
47
48
|
root: z.string().optional(),
|
|
48
49
|
scope: scopeSchema,
|
|
49
50
|
kind: z.enum(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to', 'provides']).optional(),
|
|
51
|
+
type: z.string().min(1).optional(),
|
|
50
52
|
limit: z.number().int().positive().max(500).default(50)
|
|
51
53
|
}, async (input) => jsonResponse(listEdges({
|
|
52
54
|
root: input.root ?? root,
|
|
53
55
|
scope: input.scope,
|
|
54
56
|
kind: input.kind,
|
|
57
|
+
type: input.type,
|
|
55
58
|
limit: input.limit
|
|
56
59
|
})));
|
|
57
60
|
tool('context_graph_neighbors', 'Return nearby nodes around a graph node id.', {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ScannerPlugin } from './plugins.js';
|
|
2
|
+
export declare const ATTRIBUTION_PLUGIN_NAME = "git-attribution-plugin";
|
|
3
|
+
/** Optional deferred Git attribution for structural graph nodes. */
|
|
4
|
+
export declare const AttributionPlugin: ScannerPlugin;
|
|
5
|
+
//# sourceMappingURL=attributionPlugin.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { buildFileAttribution } from '../graph/attribution.js';
|
|
2
|
+
export const ATTRIBUTION_PLUGIN_NAME = 'git-attribution-plugin';
|
|
3
|
+
/** Optional deferred Git attribution for structural graph nodes. */
|
|
4
|
+
export const AttributionPlugin = {
|
|
5
|
+
name: ATTRIBUTION_PLUGIN_NAME,
|
|
6
|
+
apiVersion: 1,
|
|
7
|
+
capabilities: ['enrichment'],
|
|
8
|
+
scan: () => ({ nodes: [], edges: [] }),
|
|
9
|
+
enrich: ({ repository, work, source, nodes }) => buildFileAttribution({
|
|
10
|
+
root: repository.root,
|
|
11
|
+
relativePath: work.path,
|
|
12
|
+
source,
|
|
13
|
+
nodes
|
|
14
|
+
})
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=attributionPlugin.js.map
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { isSupportedSourceFile } from '../parser/treeSitter.js';
|
|
5
4
|
const DEFAULT_IGNORES = new Set([
|
|
@@ -13,13 +12,14 @@ const DEFAULT_IGNORES = new Set([
|
|
|
13
12
|
]);
|
|
14
13
|
export function discoverSourceFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
|
|
15
14
|
const files = [];
|
|
16
|
-
|
|
15
|
+
const gitignore = readGitignore(root);
|
|
16
|
+
walk(root, root, files, ignores, gitignore);
|
|
17
17
|
return files.sort();
|
|
18
18
|
}
|
|
19
|
-
function walk(root, current, files, ignores) {
|
|
19
|
+
function walk(root, current, files, ignores, gitignore) {
|
|
20
20
|
const entries = readdirSync(current, { withFileTypes: true });
|
|
21
21
|
const relativePaths = entries.map((entry) => path.relative(root, path.join(current, entry.name)));
|
|
22
|
-
const gitIgnored = ignoredByGit(
|
|
22
|
+
const gitIgnored = ignoredByGit(gitignore, relativePaths);
|
|
23
23
|
for (let index = 0; index < entries.length; index += 1) {
|
|
24
24
|
const entry = entries[index];
|
|
25
25
|
if (!entry)
|
|
@@ -31,7 +31,7 @@ function walk(root, current, files, ignores) {
|
|
|
31
31
|
if (!relativePath || gitIgnored.has(relativePath))
|
|
32
32
|
continue;
|
|
33
33
|
if (entry.isDirectory()) {
|
|
34
|
-
walk(root, fullPath, files, ignores);
|
|
34
|
+
walk(root, fullPath, files, ignores, gitignore);
|
|
35
35
|
continue;
|
|
36
36
|
}
|
|
37
37
|
if (!entry.isFile())
|
|
@@ -44,17 +44,90 @@ function walk(root, current, files, ignores) {
|
|
|
44
44
|
files.push(relativePath);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
function readGitignore(root) {
|
|
48
|
+
let contents;
|
|
49
|
+
try {
|
|
50
|
+
contents = readFileSync(path.join(root, '.gitignore'), 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return contents
|
|
56
|
+
.split(/\r?\n/u)
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
|
59
|
+
.map((line) => {
|
|
60
|
+
const negated = line.startsWith('!');
|
|
61
|
+
const pattern = negated ? line.slice(1) : line;
|
|
62
|
+
return { expression: gitignorePatternToRegExp(pattern), negated };
|
|
54
63
|
});
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
}
|
|
65
|
+
function ignoredByGit(patterns, relativePaths) {
|
|
66
|
+
const ignored = new Set();
|
|
67
|
+
for (const relativePath of relativePaths) {
|
|
68
|
+
const normalizedPath = relativePath.split(path.sep).join('/');
|
|
69
|
+
let isIgnored = false;
|
|
70
|
+
for (const pattern of patterns) {
|
|
71
|
+
if (pattern.expression.test(normalizedPath))
|
|
72
|
+
isIgnored = !pattern.negated;
|
|
73
|
+
}
|
|
74
|
+
if (isIgnored)
|
|
75
|
+
ignored.add(relativePath);
|
|
76
|
+
}
|
|
77
|
+
return ignored;
|
|
78
|
+
}
|
|
79
|
+
function gitignorePatternToRegExp(pattern) {
|
|
80
|
+
const directoryOnly = pattern.endsWith('/');
|
|
81
|
+
const withoutTrailingSlash = directoryOnly ? pattern.slice(0, -1) : pattern;
|
|
82
|
+
const anchored = withoutTrailingSlash.startsWith('/');
|
|
83
|
+
const value = anchored ? withoutTrailingSlash.slice(1) : withoutTrailingSlash;
|
|
84
|
+
const source = globToRegExp(value);
|
|
85
|
+
// A pattern without a slash applies to a name at any depth. Patterns with
|
|
86
|
+
// a slash are relative to the repository root, as they are in gitignore.
|
|
87
|
+
if (!value.includes('/')) {
|
|
88
|
+
return new RegExp(`(?:^|/)${source}(?:$|/)`);
|
|
89
|
+
}
|
|
90
|
+
return new RegExp(`^${source}(?:$|/)`);
|
|
91
|
+
}
|
|
92
|
+
function globToRegExp(pattern) {
|
|
93
|
+
let result = '';
|
|
94
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
95
|
+
const character = pattern[index];
|
|
96
|
+
if (character === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
if (character === '*') {
|
|
99
|
+
if (pattern[index + 1] === '*') {
|
|
100
|
+
while (pattern[index + 1] === '*')
|
|
101
|
+
index += 1;
|
|
102
|
+
if (pattern[index + 1] === '/') {
|
|
103
|
+
index += 1;
|
|
104
|
+
result += '(?:.*/)?';
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
result += '.*';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
result += '[^/]*';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (character === '?') {
|
|
115
|
+
result += '[^/]';
|
|
116
|
+
}
|
|
117
|
+
else if (character === '[') {
|
|
118
|
+
const closing = pattern.indexOf(']', index + 1);
|
|
119
|
+
if (closing !== -1) {
|
|
120
|
+
result += pattern.slice(index, closing + 1);
|
|
121
|
+
index = closing;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
result += '\\[';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
result += /[\\^$+.()|{}]/u.test(character) ? `\\${character}` : character;
|
|
129
|
+
}
|
|
57
130
|
}
|
|
58
|
-
return
|
|
131
|
+
return result;
|
|
59
132
|
}
|
|
60
133
|
//# sourceMappingURL=discover.js.map
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { FileRecord, GraphEdge, GraphNode, GraphExtractionResult, Point } from '../types.js';
|
|
1
|
+
import type { EnrichmentRunnerInput, AttributionOutcome, FileRecord, GraphEdge, GraphNode, GraphExtractionResult, Point } from '../types.js';
|
|
2
2
|
import { type RepositoryManifest } from './artifactInventory.js';
|
|
3
3
|
export declare const SCANNER_PLUGIN_API_VERSION: 1;
|
|
4
4
|
export type PluginConflictPolicy = 'merge' | 'replace' | 'reject';
|
|
5
|
-
export type ScannerCapability = 'file-scanner' | 'repository-analyzer';
|
|
5
|
+
export type ScannerCapability = 'file-scanner' | 'repository-analyzer' | 'enrichment';
|
|
6
6
|
export interface RepositoryScanFile extends FileRecord {
|
|
7
7
|
absolutePath: string;
|
|
8
8
|
source: string;
|
|
@@ -45,6 +45,7 @@ export interface PluginEdgeFact {
|
|
|
45
45
|
targetPlugin?: string;
|
|
46
46
|
filePath?: string;
|
|
47
47
|
kind: GraphEdge['kind'];
|
|
48
|
+
type?: string;
|
|
48
49
|
confidence?: number;
|
|
49
50
|
metadata?: Record<string, unknown>;
|
|
50
51
|
conflict?: PluginConflictPolicy;
|
|
@@ -99,6 +100,8 @@ export interface ScannerPlugin {
|
|
|
99
100
|
dispose?(context: RepositoryScanContext): void | Promise<void>;
|
|
100
101
|
/** Opt-in delta hook. Plugins without it retain the complete-repository v1 scan contract. */
|
|
101
102
|
scanIncremental?(context: RepositoryScanContext, delta: RepositoryScanDelta, previous?: PreviousPluginContribution): IncrementalPluginScanResult | Promise<IncrementalPluginScanResult>;
|
|
103
|
+
/** Optional deferred enrichment hook. The host supplies structural nodes after the file scan. */
|
|
104
|
+
enrich?(input: EnrichmentRunnerInput): AttributionOutcome | Promise<AttributionOutcome>;
|
|
102
105
|
}
|
|
103
106
|
export interface PluginRunResult extends GraphExtractionResult {
|
|
104
107
|
diagnostics: string[];
|
|
@@ -110,6 +113,7 @@ export interface PluginRunResult extends GraphExtractionResult {
|
|
|
110
113
|
nodes: GraphNode[];
|
|
111
114
|
edges: GraphEdge[];
|
|
112
115
|
}>;
|
|
116
|
+
enrichmentPlugins?: Map<string, ScannerPlugin>;
|
|
113
117
|
}
|
|
114
118
|
export declare function createRepositoryScanContext(repoPath: string, suppliedManifests?: readonly RepositoryManifest[]): RepositoryScanContext;
|
|
115
119
|
export declare function runScannerPlugins(context: RepositoryScanContext, plugins: readonly ScannerPlugin[], resourceLimits?: Partial<PluginResourceLimits>, delta?: RepositoryScanDelta, previousContributions?: ReadonlyMap<string, PreviousPluginContribution>): Promise<PluginRunResult>;
|
|
@@ -50,6 +50,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
|
|
|
50
50
|
const failedPlugins = [];
|
|
51
51
|
const failedPaths = new Set();
|
|
52
52
|
const successfulPlugins = [];
|
|
53
|
+
const enrichmentPlugins = new Map();
|
|
53
54
|
const incrementalRetractions = new Map();
|
|
54
55
|
const owners = new Map();
|
|
55
56
|
for (const plugin of plugins) {
|
|
@@ -72,6 +73,11 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
|
|
|
72
73
|
if (finalized)
|
|
73
74
|
collect(plugin, finalized, nodes, edges, owners, diagnostics, limits);
|
|
74
75
|
successfulPlugins.push(plugin.name);
|
|
76
|
+
if (plugin.capabilities.includes('enrichment')) {
|
|
77
|
+
if (typeof plugin.enrich !== 'function')
|
|
78
|
+
throw new Error(`invalid enrichment plugin: ${plugin.name} must define enrich()`);
|
|
79
|
+
enrichmentPlugins.set(plugin.name, plugin);
|
|
80
|
+
}
|
|
75
81
|
}
|
|
76
82
|
catch (error) {
|
|
77
83
|
failedPlugins.push(plugin.name);
|
|
@@ -123,6 +129,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
|
|
|
123
129
|
sourceId: source.id,
|
|
124
130
|
targetId: target.id,
|
|
125
131
|
kind: edge.kind,
|
|
132
|
+
type: edge.type ?? edge.kind,
|
|
126
133
|
confidence: edge.confidence ?? 1,
|
|
127
134
|
metadata: {
|
|
128
135
|
...(edge.metadata ?? {}),
|
|
@@ -180,7 +187,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
|
|
|
180
187
|
pluginContributions.set(plugin, created);
|
|
181
188
|
return created;
|
|
182
189
|
}
|
|
183
|
-
return { nodes: mergedNodes, edges: mergedEdges, diagnostics, failedPaths: [...failedPaths].sort(), failedPlugins, successfulPlugins, pluginContributions };
|
|
190
|
+
return { nodes: mergedNodes, edges: mergedEdges, diagnostics, failedPaths: [...failedPaths].sort(), failedPlugins, successfulPlugins, pluginContributions, enrichmentPlugins };
|
|
184
191
|
function collect(plugin, result, nodeOutput, edgeOutput, ownerMap, messages, contributionLimits) {
|
|
185
192
|
for (const node of result.nodes ?? []) {
|
|
186
193
|
const identity = canonicalNodeIdentity(plugin.name, node.factKey);
|
|
@@ -9,6 +9,7 @@ export function openGraphDatabase(databasePath) {
|
|
|
9
9
|
db.exec('PRAGMA journal_mode = WAL;');
|
|
10
10
|
db.exec(CREATE_SCHEMA_SQL);
|
|
11
11
|
ensureSchemaVersion(db);
|
|
12
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_edges_repo_type ON edges(repository_id, type);');
|
|
12
13
|
return db;
|
|
13
14
|
}
|
|
14
15
|
export function openReadOnlyGraphDatabase(databasePath) {
|
|
@@ -56,6 +57,14 @@ function ensureSchemaVersion(db) {
|
|
|
56
57
|
}
|
|
57
58
|
if (currentVersion <= 6) {
|
|
58
59
|
migrateSchemaV6ToV7(db);
|
|
60
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(7);
|
|
61
|
+
}
|
|
62
|
+
if (currentVersion <= 7) {
|
|
63
|
+
migrateSchemaV7ToV8(db);
|
|
64
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(8);
|
|
65
|
+
}
|
|
66
|
+
if (currentVersion <= 8) {
|
|
67
|
+
migrateSchemaV8ToV9(db);
|
|
59
68
|
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
|
|
60
69
|
return;
|
|
61
70
|
}
|
|
@@ -126,6 +135,19 @@ function migrateSchemaV6ToV7(db) {
|
|
|
126
135
|
ON repository_manifest_inventory(repository_id, status);
|
|
127
136
|
`);
|
|
128
137
|
}
|
|
138
|
+
function migrateSchemaV7ToV8(db) {
|
|
139
|
+
const columns = new Set(db.prepare('PRAGMA table_info(file_enrichment_state)').all().map((column) => column.name));
|
|
140
|
+
if (!columns.has('plugin_name'))
|
|
141
|
+
db.exec("ALTER TABLE file_enrichment_state ADD COLUMN plugin_name TEXT NOT NULL DEFAULT 'git-attribution-plugin'");
|
|
142
|
+
if (!columns.has('history_fingerprint'))
|
|
143
|
+
db.exec('ALTER TABLE file_enrichment_state ADD COLUMN history_fingerprint TEXT');
|
|
144
|
+
if (!columns.has('outcome'))
|
|
145
|
+
db.exec('ALTER TABLE file_enrichment_state ADD COLUMN outcome TEXT');
|
|
146
|
+
}
|
|
147
|
+
function migrateSchemaV8ToV9(db) {
|
|
148
|
+
db.exec('ALTER TABLE edges ADD COLUMN type TEXT NOT NULL DEFAULT kind;');
|
|
149
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_edges_repo_type ON edges(repository_id, type);');
|
|
150
|
+
}
|
|
129
151
|
function getCurrentSchemaVersion(db) {
|
|
130
152
|
const row = db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get();
|
|
131
153
|
return Number(row?.version ?? 0);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { ExploreEdgeKind, ExploreDirection, GraphNodeKind, Point } from '../types.js';
|
|
3
|
+
export interface ExploreAggregateRow {
|
|
4
|
+
id: string;
|
|
5
|
+
path: string;
|
|
6
|
+
language: string;
|
|
7
|
+
childCount: number;
|
|
8
|
+
repositoryId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ExploreSourceRow {
|
|
11
|
+
id: string;
|
|
12
|
+
kind: GraphNodeKind;
|
|
13
|
+
type: string;
|
|
14
|
+
name: string | null;
|
|
15
|
+
fileId: string | null;
|
|
16
|
+
path: string | null;
|
|
17
|
+
startPoint: Point;
|
|
18
|
+
endPoint: Point;
|
|
19
|
+
metadata: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
export interface ExploreCandidateRow extends ExploreSourceRow {
|
|
22
|
+
rank: number;
|
|
23
|
+
match: 'exact' | 'qualified' | 'path' | 'prefix' | 'substring';
|
|
24
|
+
qualifiedName: string | null;
|
|
25
|
+
aggregateId: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface ExploreEdgeRow {
|
|
28
|
+
id: string;
|
|
29
|
+
sourceId: string;
|
|
30
|
+
targetId: string;
|
|
31
|
+
kind: ExploreEdgeKind;
|
|
32
|
+
confidence: number;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
export declare function selectExploreAggregates(db: DatabaseSync, repositoryId: string, search?: string): ExploreAggregateRow[];
|
|
36
|
+
export declare function selectExploreAggregateEdges(db: DatabaseSync, repositoryId: string, kinds?: readonly ExploreEdgeKind[]): Array<ExploreEdgeRow & {
|
|
37
|
+
count: number;
|
|
38
|
+
sourceAggregateId: string;
|
|
39
|
+
targetAggregateId: string;
|
|
40
|
+
}>;
|
|
41
|
+
export declare function selectExploreSourcesForSearch(db: DatabaseSync, repositoryId: string, term: string, limit?: number): ExploreSourceRow[];
|
|
42
|
+
export declare function selectExploreCandidates(db: DatabaseSync, repositoryId: string, term: string, { kind, file, fileAnchor, limit }?: {
|
|
43
|
+
kind?: string;
|
|
44
|
+
file?: string;
|
|
45
|
+
fileAnchor?: boolean;
|
|
46
|
+
limit?: number;
|
|
47
|
+
}): ExploreCandidateRow[];
|
|
48
|
+
export declare function selectExploreNode(db: DatabaseSync, repositoryId: string, id: string): ExploreSourceRow | null;
|
|
49
|
+
export declare function selectExploreTraversalEdges(db: DatabaseSync, repositoryId: string, frontier: readonly string[], direction: ExploreDirection, kinds: readonly ExploreEdgeKind[], limit: number): ExploreEdgeRow[];
|
|
50
|
+
export declare function selectExploreNodeRows(db: DatabaseSync, repositoryId: string, ids: readonly string[]): ExploreSourceRow[];
|
|
51
|
+
export declare function aggregateId(repositoryId: string, fileId: string): string;
|
|
52
|
+
//# sourceMappingURL=explore-queries.d.ts.map
|