@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { parseSource } from '../parser/treeSitter.js';
|
|
2
|
+
import { runBatchedWorkers } from './parallelScan.js';
|
|
3
|
+
export const JAVA_SCANNER_PLUGIN_NAME = 'java-scanner-plugin';
|
|
4
|
+
const JAVA_EXTENSIONS = new Set(['.java']);
|
|
5
|
+
const TYPE_DECLARATIONS = new Set(['class_declaration', 'interface_declaration', 'enum_declaration', 'record_declaration', 'annotation_type_declaration', 'annotation_type_declaration']);
|
|
6
|
+
const MEMBER_DECLARATIONS = new Set(['method_declaration', 'constructor_declaration', 'field_declaration']);
|
|
7
|
+
const IMPORT_DECLARATIONS = new Set(['import_declaration']);
|
|
8
|
+
const CALL_DECLARATIONS = new Set(['method_invocation']);
|
|
9
|
+
const MAX_SNIPPET_BYTES = 240;
|
|
10
|
+
export function scanJavaFiles(files) {
|
|
11
|
+
const nodes = [];
|
|
12
|
+
const edges = [];
|
|
13
|
+
const failedPaths = [];
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
if (!isJava(file.path))
|
|
17
|
+
continue;
|
|
18
|
+
const parsed = parseSource(file.absolutePath, file.source);
|
|
19
|
+
if (!parsed || parsed.language !== 'java') {
|
|
20
|
+
failedPaths.push(file.path);
|
|
21
|
+
diagnostics.push(`${file.path}: Java parser failed; retaining prior facts`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (containsError(parsed.tree.rootNode)) {
|
|
25
|
+
failedPaths.push(file.path);
|
|
26
|
+
diagnostics.push(`${file.path}: Java source contains a parse error; retaining prior facts`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const sourceBuffer = Buffer.from(file.source, 'utf8');
|
|
30
|
+
const fileKey = factKey(file.path, 'file', 0, sourceBuffer.length);
|
|
31
|
+
nodes.push(makeNode(file, fileKey, parsed, 'file', 'file', file.path, parsed.tree.rootNode, sourceBuffer, { language: 'java' }));
|
|
32
|
+
walk(parsed.tree.rootNode, [], [], fileKey, file, parsed, sourceBuffer, nodes, edges);
|
|
33
|
+
}
|
|
34
|
+
return { nodes, edges, failedPaths, diagnostics };
|
|
35
|
+
}
|
|
36
|
+
async function scanJavaFilesInBatches(files) {
|
|
37
|
+
const javaFiles = files.filter((file) => isJava(file.path));
|
|
38
|
+
const results = await runBatchedWorkers(javaFiles, new URL('./javaScanWorker.js', import.meta.url), (batch) => batch);
|
|
39
|
+
return {
|
|
40
|
+
nodes: results.flatMap((result) => result.nodes),
|
|
41
|
+
edges: results.flatMap((result) => result.edges),
|
|
42
|
+
failedPaths: results.flatMap((result) => result.failedPaths ?? []),
|
|
43
|
+
diagnostics: results.flatMap((result) => result.diagnostics ?? [])
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export const JavaScannerPlugin = {
|
|
47
|
+
name: JAVA_SCANNER_PLUGIN_NAME,
|
|
48
|
+
apiVersion: 1,
|
|
49
|
+
capabilities: ['file-scanner'],
|
|
50
|
+
scan: (context) => scanJavaFilesInBatches(context.files),
|
|
51
|
+
scanIncremental: (context, delta, previous) => scanJavaIncremental(context, delta, previous)
|
|
52
|
+
};
|
|
53
|
+
async function scanJavaIncremental(context, delta, previous) {
|
|
54
|
+
if (!previous)
|
|
55
|
+
return { ...(await scanJavaFilesInBatches(context.files)), retractions: { nodes: [], edges: [] } };
|
|
56
|
+
const changed = delta.full
|
|
57
|
+
? new Set(previous.nodes.flatMap((node) => typeof node.metadata.filePath === 'string' ? [node.metadata.filePath] : []).concat(context.files.filter((file) => isJava(file.path)).map((file) => file.path)))
|
|
58
|
+
: new Set([...delta.changedPaths, ...delta.deletedPaths].filter(isJava));
|
|
59
|
+
if (changed.size === 0)
|
|
60
|
+
return { nodes: [], edges: [], retractions: { nodes: [], edges: [] } };
|
|
61
|
+
const result = await scanJavaFilesInBatches(context.files.filter((file) => changed.has(file.path)));
|
|
62
|
+
const failed = new Set(result.failedPaths ?? []);
|
|
63
|
+
const retractions = { nodes: [], edges: [] };
|
|
64
|
+
for (const node of previous.nodes) {
|
|
65
|
+
const filePath = typeof node.metadata.filePath === 'string' ? node.metadata.filePath : undefined;
|
|
66
|
+
if (filePath && changed.has(filePath) && !failed.has(filePath))
|
|
67
|
+
retractions.nodes.push(String(node.metadata.factKey ?? ''));
|
|
68
|
+
}
|
|
69
|
+
for (const edge of previous.edges) {
|
|
70
|
+
const filePath = typeof edge.metadata.filePath === 'string' ? edge.metadata.filePath : undefined;
|
|
71
|
+
if (filePath && changed.has(filePath) && !failed.has(filePath))
|
|
72
|
+
retractions.edges.push(String(edge.metadata.factKey ?? ''));
|
|
73
|
+
}
|
|
74
|
+
return { ...result, retractions };
|
|
75
|
+
}
|
|
76
|
+
function walk(node, symbols, graph, fileKey, file, parsed, sourceBuffer, nodes, edges) {
|
|
77
|
+
// Java permits several variable declarators in one field declaration (for
|
|
78
|
+
// example, `int first, second`). Each declarator is a distinct graph field;
|
|
79
|
+
// the parent field_declaration is only a syntax container and must not cause
|
|
80
|
+
// the later declarators to be dropped.
|
|
81
|
+
if (node.type === 'field_declaration') {
|
|
82
|
+
const declarators = node.namedChildren.filter((child) => child.type === 'variable_declarator');
|
|
83
|
+
if (declarators.length > 0) {
|
|
84
|
+
for (const declarator of declarators) {
|
|
85
|
+
walkFieldDeclarator(declarator, node, symbols, graph, fileKey, file, parsed, sourceBuffer, nodes, edges);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const kind = kindFor(node);
|
|
91
|
+
let currentKey;
|
|
92
|
+
let nextSymbols = symbols;
|
|
93
|
+
let nextGraph = graph;
|
|
94
|
+
if (kind) {
|
|
95
|
+
const name = nameFor(node, sourceBuffer);
|
|
96
|
+
currentKey = factKey(file.path, kind, node.startIndex, node.endIndex);
|
|
97
|
+
const metadata = metadataFor(node, sourceBuffer, name);
|
|
98
|
+
nodes.push(makeNode(file, currentKey, parsed, kind, node.type, name, node, sourceBuffer, metadata));
|
|
99
|
+
const parent = graph.at(-1) ?? fileKey;
|
|
100
|
+
edges.push({ factKey: `contains:${file.path}:${node.startIndex}:${node.endIndex}`, sourceFactKey: parent, targetFactKey: currentKey, filePath: file.path, kind: kind === 'symbol' && symbols.length === 0 ? 'defines' : 'contains', metadata: { discoveredFrom: node.type } });
|
|
101
|
+
if (kind === 'import')
|
|
102
|
+
edges.push({ factKey: `imports:${file.path}:${node.startIndex}:${node.endIndex}`, sourceFactKey: fileKey, targetFactKey: currentKey, filePath: file.path, kind: 'imports', confidence: 0.9, metadata: { module: metadata.module, static: metadata.static, wildcard: metadata.wildcard } });
|
|
103
|
+
if (kind === 'call')
|
|
104
|
+
edges.push({ factKey: `calls:${file.path}:${node.startIndex}:${node.endIndex}`, sourceFactKey: symbols.at(-1) ?? fileKey, targetFactKey: currentKey, filePath: file.path, kind: 'calls', confidence: 0.65, metadata: { callee: name, syntaxOnly: true } });
|
|
105
|
+
if (kind === 'symbol') {
|
|
106
|
+
if (isPublic(node, sourceBuffer) && (symbols.length === 0 || containingTypesAccessible(node, sourceBuffer))) {
|
|
107
|
+
const exportKey = factKey(file.path, 'export', node.startIndex, node.endIndex);
|
|
108
|
+
nodes.push(makeNode(file, exportKey, parsed, 'export', 'java-public-export', name, node, sourceBuffer, { exportKind: 'java-public', symbol: name }));
|
|
109
|
+
edges.push({ factKey: `exports:${file.path}:${node.startIndex}:${node.endIndex}`, sourceFactKey: fileKey, targetFactKey: exportKey, filePath: file.path, kind: 'exports', metadata: { symbol: name, syntaxOnly: true } });
|
|
110
|
+
}
|
|
111
|
+
nextSymbols = [...symbols, currentKey];
|
|
112
|
+
}
|
|
113
|
+
nextGraph = [...graph, currentKey];
|
|
114
|
+
}
|
|
115
|
+
for (const child of node.namedChildren)
|
|
116
|
+
walk(child, nextSymbols, nextGraph, fileKey, file, parsed, sourceBuffer, nodes, edges);
|
|
117
|
+
}
|
|
118
|
+
function walkFieldDeclarator(declarator, declaration, symbols, graph, fileKey, file, parsed, sourceBuffer, nodes, edges) {
|
|
119
|
+
const currentKey = factKey(file.path, 'symbol', declarator.startIndex, declarator.endIndex);
|
|
120
|
+
const name = nameFor(declarator, sourceBuffer);
|
|
121
|
+
const metadata = metadataFor(declarator, sourceBuffer, name, declaration);
|
|
122
|
+
nodes.push(makeNode(file, currentKey, parsed, 'symbol', 'field_declaration', name, declarator, sourceBuffer, metadata));
|
|
123
|
+
const parent = graph.at(-1) ?? fileKey;
|
|
124
|
+
edges.push({ factKey: `contains:${file.path}:${declarator.startIndex}:${declarator.endIndex}`, sourceFactKey: parent, targetFactKey: currentKey, filePath: file.path, kind: symbols.length === 0 ? 'defines' : 'contains', metadata: { discoveredFrom: 'field_declaration' } });
|
|
125
|
+
if (isPublic(declaration, sourceBuffer) && (symbols.length === 0 || containingTypesAccessible(declaration, sourceBuffer))) {
|
|
126
|
+
const exportKey = factKey(file.path, 'export', declarator.startIndex, declarator.endIndex);
|
|
127
|
+
nodes.push(makeNode(file, exportKey, parsed, 'export', 'java-public-export', name, declarator, sourceBuffer, { exportKind: 'java-public', symbol: name }));
|
|
128
|
+
edges.push({ factKey: `exports:${file.path}:${declarator.startIndex}:${declarator.endIndex}`, sourceFactKey: fileKey, targetFactKey: exportKey, filePath: file.path, kind: 'exports', metadata: { symbol: name, syntaxOnly: true } });
|
|
129
|
+
}
|
|
130
|
+
for (const child of declarator.namedChildren)
|
|
131
|
+
walk(child, [...symbols, currentKey], [...graph, currentKey], fileKey, file, parsed, sourceBuffer, nodes, edges);
|
|
132
|
+
}
|
|
133
|
+
function kindFor(node) {
|
|
134
|
+
if (TYPE_DECLARATIONS.has(node.type) || MEMBER_DECLARATIONS.has(node.type))
|
|
135
|
+
return 'symbol';
|
|
136
|
+
if (IMPORT_DECLARATIONS.has(node.type))
|
|
137
|
+
return 'import';
|
|
138
|
+
if (CALL_DECLARATIONS.has(node.type))
|
|
139
|
+
return 'call';
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
function nameFor(node, source) {
|
|
143
|
+
const name = node.childForFieldName('name');
|
|
144
|
+
if (name)
|
|
145
|
+
return textFor(name, source).trim();
|
|
146
|
+
if (node.type === 'field_declaration') {
|
|
147
|
+
const declarator = node.childForFieldName('declarator') ?? node.namedChildren.find((child) => child.type === 'variable_declarator');
|
|
148
|
+
return declarator?.childForFieldName('name') ? textFor(declarator.childForFieldName('name'), source).trim() : null;
|
|
149
|
+
}
|
|
150
|
+
if (node.type === 'import_declaration')
|
|
151
|
+
return textFor(node, source).replace(/^import\s+(?:static\s+)?/u, '').replace(/;\s*$/u, '').trim();
|
|
152
|
+
if (node.type === 'method_invocation')
|
|
153
|
+
return textFor(name ?? node.childForFieldName('object') ?? node, source).trim().slice(0, 120);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
function metadataFor(node, source, name, visibilityNode = node) {
|
|
157
|
+
const snippet = textFor(node, source).trim().slice(0, MAX_SNIPPET_BYTES);
|
|
158
|
+
if (node.type === 'import_declaration') {
|
|
159
|
+
const raw = textFor(node, source).trim().replace(/^import\s+/u, '').replace(/;\s*$/u, '');
|
|
160
|
+
const isStatic = raw.startsWith('static ');
|
|
161
|
+
const module = (isStatic ? raw.slice(7) : raw).replace(/\.\*$/u, '');
|
|
162
|
+
return { module, static: isStatic, wildcard: raw.endsWith('.*'), snippet };
|
|
163
|
+
}
|
|
164
|
+
if (node.type === 'method_invocation')
|
|
165
|
+
return { callee: name, snippet, syntaxOnly: true };
|
|
166
|
+
return { snippet, visibility: visibilityFor(visibilityNode, source) };
|
|
167
|
+
}
|
|
168
|
+
function isPublic(node, source) {
|
|
169
|
+
const modifiers = node.childForFieldName('modifiers') ?? node.namedChildren.find((child) => child.type === 'modifiers');
|
|
170
|
+
return Boolean(modifiers && /\bpublic\b/u.test(textFor(modifiers, source)));
|
|
171
|
+
}
|
|
172
|
+
function containingTypesAccessible(node, source) {
|
|
173
|
+
let parent = node.parent;
|
|
174
|
+
while (parent) {
|
|
175
|
+
if (TYPE_DECLARATIONS.has(parent.type) && !isPublic(parent, source))
|
|
176
|
+
return false;
|
|
177
|
+
parent = parent.parent;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
function visibilityFor(node, source) {
|
|
182
|
+
const modifiers = node.childForFieldName('modifiers') ?? node.namedChildren.find((child) => child.type === 'modifiers');
|
|
183
|
+
const value = modifiers ? textFor(modifiers, source) : '';
|
|
184
|
+
if (/\bpublic\b/u.test(value))
|
|
185
|
+
return 'public';
|
|
186
|
+
if (/\bprotected\b/u.test(value))
|
|
187
|
+
return 'protected';
|
|
188
|
+
if (/\bprivate\b/u.test(value))
|
|
189
|
+
return 'private';
|
|
190
|
+
return 'package';
|
|
191
|
+
}
|
|
192
|
+
function makeNode(file, key, parsed, kind, type, name, treeNode, source, metadata) {
|
|
193
|
+
return { factKey: key, filePath: file.path, kind, type, name, startByte: treeNode.startIndex, endByte: treeNode.endIndex, startPoint: treeNode.startPosition, endPoint: treeNode.endPosition, sourceHash: file.hash, parser: parsed.parser, parserVersion: parsed.parserVersion, metadata: { ...metadata, filePath: file.path, factKey: key, sourceBytes: source.length } };
|
|
194
|
+
}
|
|
195
|
+
function factKey(filePath, kind, startByte, endByte) { return `${kind}:${filePath}:${startByte}:${endByte}`; }
|
|
196
|
+
function textFor(node, source) { return source.subarray(node.startIndex, node.endIndex).toString('utf8'); }
|
|
197
|
+
function containsError(node) { return Boolean(node.hasError) || node.type === 'ERROR' || node.namedChildren.some(containsError); }
|
|
198
|
+
function isJava(filePath) { return JAVA_EXTENSIONS.has(filePath.slice(filePath.lastIndexOf('.')).toLowerCase()); }
|
|
199
|
+
//# sourceMappingURL=javaPlugin.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { scanJavaFiles } from './javaPlugin.js';
|
|
3
|
+
if (!parentPort)
|
|
4
|
+
throw new Error('Java scan worker requires a parent port');
|
|
5
|
+
parentPort.on('message', (files) => {
|
|
6
|
+
parentPort.postMessage(scanJavaFiles(files));
|
|
7
|
+
});
|
|
8
|
+
//# sourceMappingURL=javaScanWorker.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface ParseFileTask {
|
|
2
|
+
path: string;
|
|
3
|
+
absolutePath: string;
|
|
4
|
+
source: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PackageParseTask {
|
|
8
|
+
repositoryId: string;
|
|
9
|
+
files: ParseFileTask[];
|
|
10
|
+
}
|
|
11
|
+
export interface PackageImport {
|
|
12
|
+
path: string;
|
|
13
|
+
id: string;
|
|
14
|
+
module: string;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=packageParseWorker.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { extractGraph, hashParts } from '../graph/extract.js';
|
|
3
|
+
import { parseSource } from '../parser/treeSitter.js';
|
|
4
|
+
if (!parentPort)
|
|
5
|
+
throw new Error('package parse worker requires a parent port');
|
|
6
|
+
parentPort.on('message', ({ repositoryId, files }) => {
|
|
7
|
+
const imports = [];
|
|
8
|
+
for (const file of files) {
|
|
9
|
+
const parsed = parseSource(file.absolutePath, file.source);
|
|
10
|
+
if (!parsed)
|
|
11
|
+
continue;
|
|
12
|
+
const extracted = extractGraph({
|
|
13
|
+
repositoryId,
|
|
14
|
+
fileId: hashParts([repositoryId, file.path]),
|
|
15
|
+
relativePath: file.path,
|
|
16
|
+
source: file.source,
|
|
17
|
+
sourceHash: file.hash,
|
|
18
|
+
parseResult: parsed
|
|
19
|
+
});
|
|
20
|
+
for (const node of extracted.nodes) {
|
|
21
|
+
if (node.kind !== 'import')
|
|
22
|
+
continue;
|
|
23
|
+
const module = typeof node.metadata.module === 'string' ? node.metadata.module : node.name;
|
|
24
|
+
if (module)
|
|
25
|
+
imports.push({ path: file.path, id: node.id, module });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
parentPort.postMessage(imports);
|
|
29
|
+
});
|
|
30
|
+
//# sourceMappingURL=packageParseWorker.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { builtinModules } from 'node:module';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { availableParallelism } from 'node:os';
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
4
|
+
import { hashParts } from '../graph/extract.js';
|
|
4
5
|
import { BUILTIN_GRAPH_PLUGIN } from './plugins.js';
|
|
5
6
|
// This plugin adds package-level relationships on top of the built-in source
|
|
6
7
|
// graph. Built-in extraction already creates file/import/symbol nodes; this
|
|
@@ -41,11 +42,46 @@ function packageRoot(specifier) {
|
|
|
41
42
|
function isManifest(value) {
|
|
42
43
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
43
44
|
}
|
|
45
|
+
const MAX_PARSE_WORKERS = 8;
|
|
46
|
+
async function parseImportsInBatches(repositoryId, files) {
|
|
47
|
+
const sourceFiles = files.filter((file) => !file.path.toLowerCase().endsWith('.java'));
|
|
48
|
+
if (sourceFiles.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
const workerCount = Math.min(Math.max(1, availableParallelism() - 1), MAX_PARSE_WORKERS, sourceFiles.length);
|
|
51
|
+
const batchSize = Math.ceil(sourceFiles.length / workerCount);
|
|
52
|
+
const batches = Array.from({ length: workerCount }, (_, index) => sourceFiles.slice(index * batchSize, (index + 1) * batchSize));
|
|
53
|
+
const results = await Promise.all(batches.map((batch) => runParseWorker({ repositoryId, files: batch })));
|
|
54
|
+
return results.flat();
|
|
55
|
+
}
|
|
56
|
+
function runParseWorker(task) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const worker = new Worker(new URL('./packageParseWorker.js', import.meta.url), {
|
|
59
|
+
// Test-runner and eval flags are not valid worker entry-point flags.
|
|
60
|
+
execArgv: [],
|
|
61
|
+
env: { ...process.env, NODE_OPTIONS: '' }
|
|
62
|
+
});
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (callback) => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
callback();
|
|
69
|
+
void worker.terminate();
|
|
70
|
+
};
|
|
71
|
+
worker.once('message', (imports) => finish(() => resolve(imports)));
|
|
72
|
+
worker.once('error', (error) => finish(() => reject(error)));
|
|
73
|
+
worker.once('exit', (code) => {
|
|
74
|
+
if (code !== 0)
|
|
75
|
+
finish(() => reject(new Error(`package parse worker exited with code ${code}`)));
|
|
76
|
+
});
|
|
77
|
+
worker.postMessage(task);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
44
80
|
export const PackagePlugin = {
|
|
45
81
|
name: 'package-plugin',
|
|
46
82
|
apiVersion: 1,
|
|
47
83
|
capabilities: ['repository-analyzer'],
|
|
48
|
-
scan: (context) => {
|
|
84
|
+
scan: async (context) => {
|
|
49
85
|
let manifest;
|
|
50
86
|
try {
|
|
51
87
|
manifest = JSON.parse(context.readFile('package.json'));
|
|
@@ -81,28 +117,8 @@ export const PackagePlugin = {
|
|
|
81
117
|
// Re-run the same extraction used by the built-in scanner. The resulting
|
|
82
118
|
// import IDs are stable graph node IDs, so they can be used directly as
|
|
83
119
|
// targets when targetPlugin is BUILTIN_GRAPH_PLUGIN.
|
|
84
|
-
const imports = [];
|
|
85
120
|
const repositoryId = hashParts(['repository', context.repoPath]);
|
|
86
|
-
|
|
87
|
-
const parsed = parseSource(file.absolutePath, file.source);
|
|
88
|
-
if (!parsed)
|
|
89
|
-
continue;
|
|
90
|
-
const extracted = extractGraph({
|
|
91
|
-
repositoryId,
|
|
92
|
-
fileId: hashParts([repositoryId, file.path]),
|
|
93
|
-
relativePath: file.path,
|
|
94
|
-
source: file.source,
|
|
95
|
-
sourceHash: file.hash,
|
|
96
|
-
parseResult: parsed
|
|
97
|
-
});
|
|
98
|
-
for (const node of extracted.nodes) {
|
|
99
|
-
if (node.kind !== 'import')
|
|
100
|
-
continue;
|
|
101
|
-
const module = typeof node.metadata.module === 'string' ? node.metadata.module : node.name;
|
|
102
|
-
if (module)
|
|
103
|
-
imports.push({ path: file.path, id: node.id, module });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
121
|
+
const imports = await parseImportsInBatches(repositoryId, context.files);
|
|
106
122
|
// A package is represented once even when it is imported by many files or
|
|
107
123
|
// through many subpaths. `implied` records imports that are not declared in
|
|
108
124
|
// any dependency section of package.json.
|
|
@@ -163,6 +179,49 @@ export const PackagePlugin = {
|
|
|
163
179
|
}];
|
|
164
180
|
});
|
|
165
181
|
return { nodes, edges, diagnostics };
|
|
182
|
+
},
|
|
183
|
+
scanIncremental: async (context, delta, previous) => {
|
|
184
|
+
if (delta.full || !previous)
|
|
185
|
+
return await PackagePlugin.scan(context);
|
|
186
|
+
const changed = new Set(delta.changedPaths);
|
|
187
|
+
const removed = new Set(delta.deletedPaths);
|
|
188
|
+
const changedResult = await PackagePlugin.scan({ ...context, files: context.files.filter((file) => changed.has(file.path)) });
|
|
189
|
+
const retainedEdges = previous.edges.flatMap((edge) => {
|
|
190
|
+
const filePath = typeof edge.metadata.filePath === 'string' ? edge.metadata.filePath : undefined;
|
|
191
|
+
const factKey = typeof edge.metadata.factKey === 'string' ? edge.metadata.factKey : undefined;
|
|
192
|
+
if (!filePath || !factKey || changed.has(filePath) || removed.has(filePath))
|
|
193
|
+
return [];
|
|
194
|
+
return [{
|
|
195
|
+
factKey,
|
|
196
|
+
sourceFactKey: typeof edge.metadata.package === 'string' ? `package:${edge.metadata.package}` : edge.sourceId,
|
|
197
|
+
targetFactKey: edge.targetId,
|
|
198
|
+
targetPlugin: BUILTIN_GRAPH_PLUGIN,
|
|
199
|
+
filePath,
|
|
200
|
+
kind: edge.kind,
|
|
201
|
+
confidence: edge.confidence,
|
|
202
|
+
metadata: Object.fromEntries(Object.entries(edge.metadata).filter(([key]) => key !== 'factKey' && key !== 'plugin'))
|
|
203
|
+
}];
|
|
204
|
+
});
|
|
205
|
+
const edges = [...retainedEdges, ...changedResult.edges];
|
|
206
|
+
const packageNames = new Set(edges.flatMap((edge) => typeof edge.metadata?.package === 'string' ? [edge.metadata.package] : []));
|
|
207
|
+
const changedNodes = new Map(changedResult.nodes.map((node) => [node.factKey, node]));
|
|
208
|
+
const nodes = [...packageNames].sort().flatMap((name) => {
|
|
209
|
+
const factKey = `package:${name}`;
|
|
210
|
+
const current = changedNodes.get(factKey);
|
|
211
|
+
if (current)
|
|
212
|
+
return [current];
|
|
213
|
+
const previousNode = previous.nodes.find((node) => node.metadata.factKey === factKey);
|
|
214
|
+
if (!previousNode)
|
|
215
|
+
return [];
|
|
216
|
+
return [{
|
|
217
|
+
factKey,
|
|
218
|
+
kind: previousNode.kind,
|
|
219
|
+
type: previousNode.type,
|
|
220
|
+
name: previousNode.name,
|
|
221
|
+
metadata: Object.fromEntries(Object.entries(previousNode.metadata).filter(([key]) => key !== 'factKey' && key !== 'plugin'))
|
|
222
|
+
}];
|
|
223
|
+
});
|
|
224
|
+
return { nodes, edges, ...(changedResult.diagnostics ? { diagnostics: changedResult.diagnostics } : {}) };
|
|
166
225
|
}
|
|
167
226
|
};
|
|
168
227
|
//# sourceMappingURL=packagePlugin.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
const MAX_SCAN_WORKERS = 8;
|
|
4
|
+
export async function runBatchedWorkers(items, workerUrl, createTask) {
|
|
5
|
+
if (items.length === 0)
|
|
6
|
+
return [];
|
|
7
|
+
const workerCount = Math.min(Math.max(1, availableParallelism() - 1), MAX_SCAN_WORKERS, items.length);
|
|
8
|
+
const batchSize = Math.ceil(items.length / workerCount);
|
|
9
|
+
const batches = Array.from({ length: workerCount }, (_, index) => items.slice(index * batchSize, (index + 1) * batchSize));
|
|
10
|
+
return Promise.all(batches.map((batch) => runWorker(workerUrl, createTask(batch))));
|
|
11
|
+
}
|
|
12
|
+
function runWorker(workerUrl, task) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const worker = new Worker(workerUrl, { execArgv: [], env: { ...process.env, NODE_OPTIONS: '' } });
|
|
15
|
+
let settled = false;
|
|
16
|
+
const finish = (callback) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
callback();
|
|
21
|
+
void worker.terminate();
|
|
22
|
+
};
|
|
23
|
+
worker.once('message', (result) => finish(() => resolve(result)));
|
|
24
|
+
worker.once('error', (error) => finish(() => reject(error)));
|
|
25
|
+
worker.once('exit', (code) => {
|
|
26
|
+
if (code !== 0)
|
|
27
|
+
finish(() => reject(new Error(`scanner worker exited with code ${code}`)));
|
|
28
|
+
});
|
|
29
|
+
worker.postMessage(task);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=parallelScan.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FileRecord, GraphEdge, GraphNode, GraphExtractionResult, Point } from '../types.js';
|
|
2
|
+
import { type RepositoryManifest } from './artifactInventory.js';
|
|
2
3
|
export declare const SCANNER_PLUGIN_API_VERSION: 1;
|
|
3
4
|
export type PluginConflictPolicy = 'merge' | 'replace' | 'reject';
|
|
4
5
|
export type ScannerCapability = 'file-scanner' | 'repository-analyzer';
|
|
@@ -10,6 +11,8 @@ export interface RepositoryScanContext {
|
|
|
10
11
|
repoPath: string;
|
|
11
12
|
files: readonly RepositoryScanFile[];
|
|
12
13
|
discoveredPaths: readonly string[];
|
|
14
|
+
/** Bounded root/immediate-module Maven and Gradle inventory. */
|
|
15
|
+
manifests: readonly RepositoryManifest[];
|
|
13
16
|
readFile(relativePath: string): string;
|
|
14
17
|
}
|
|
15
18
|
export interface PluginNodeFact {
|
|
@@ -50,6 +53,29 @@ export interface PluginScanResult {
|
|
|
50
53
|
nodes: PluginNodeFact[];
|
|
51
54
|
edges: PluginEdgeFact[];
|
|
52
55
|
diagnostics?: string[];
|
|
56
|
+
/** Paths whose previous contribution must be retained after a failed replacement. */
|
|
57
|
+
failedPaths?: string[];
|
|
58
|
+
}
|
|
59
|
+
/** Optional v1-compatible extension for plugins that can reconcile a repository delta. */
|
|
60
|
+
export interface RepositoryScanDelta {
|
|
61
|
+
changedPaths: readonly string[];
|
|
62
|
+
deletedPaths: readonly string[];
|
|
63
|
+
/** Additive repository-artifact delta fields; source-only plugins may ignore them. */
|
|
64
|
+
changedManifests?: readonly string[];
|
|
65
|
+
deletedManifests?: readonly string[];
|
|
66
|
+
manifestsComplete?: boolean;
|
|
67
|
+
full: boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface PreviousPluginContribution {
|
|
70
|
+
nodes: readonly GraphNode[];
|
|
71
|
+
edges: readonly GraphEdge[];
|
|
72
|
+
}
|
|
73
|
+
export interface IncrementalPluginScanResult extends PluginScanResult {
|
|
74
|
+
/** Fact keys replaced by this delta when a plugin elects not to return a complete contribution. */
|
|
75
|
+
retractions?: {
|
|
76
|
+
nodes?: string[];
|
|
77
|
+
edges?: string[];
|
|
78
|
+
};
|
|
53
79
|
}
|
|
54
80
|
export interface PluginResourceLimits {
|
|
55
81
|
maxNodes: number;
|
|
@@ -71,9 +97,13 @@ export interface ScannerPlugin {
|
|
|
71
97
|
scan(context: RepositoryScanContext): PluginScanResult | Promise<PluginScanResult>;
|
|
72
98
|
finalize?(context: RepositoryScanContext): PluginScanResult | void | Promise<PluginScanResult | void>;
|
|
73
99
|
dispose?(context: RepositoryScanContext): void | Promise<void>;
|
|
100
|
+
/** Opt-in delta hook. Plugins without it retain the complete-repository v1 scan contract. */
|
|
101
|
+
scanIncremental?(context: RepositoryScanContext, delta: RepositoryScanDelta, previous?: PreviousPluginContribution): IncrementalPluginScanResult | Promise<IncrementalPluginScanResult>;
|
|
74
102
|
}
|
|
75
103
|
export interface PluginRunResult extends GraphExtractionResult {
|
|
76
104
|
diagnostics: string[];
|
|
105
|
+
/** Per-file failures are degraded but do not make the complete plugin stale. */
|
|
106
|
+
failedPaths?: string[];
|
|
77
107
|
failedPlugins: string[];
|
|
78
108
|
successfulPlugins?: string[];
|
|
79
109
|
pluginContributions?: Map<string, {
|
|
@@ -81,6 +111,6 @@ export interface PluginRunResult extends GraphExtractionResult {
|
|
|
81
111
|
edges: GraphEdge[];
|
|
82
112
|
}>;
|
|
83
113
|
}
|
|
84
|
-
export declare function createRepositoryScanContext(repoPath: string): RepositoryScanContext;
|
|
85
|
-
export declare function runScannerPlugins(context: RepositoryScanContext, plugins: readonly ScannerPlugin[], resourceLimits?: Partial<PluginResourceLimits>): Promise<PluginRunResult>;
|
|
114
|
+
export declare function createRepositoryScanContext(repoPath: string, suppliedManifests?: readonly RepositoryManifest[]): RepositoryScanContext;
|
|
115
|
+
export declare function runScannerPlugins(context: RepositoryScanContext, plugins: readonly ScannerPlugin[], resourceLimits?: Partial<PluginResourceLimits>, delta?: RepositoryScanDelta, previousContributions?: ReadonlyMap<string, PreviousPluginContribution>): Promise<PluginRunResult>;
|
|
86
116
|
//# sourceMappingURL=plugins.d.ts.map
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { discoverSourceFiles } from './discover.js';
|
|
5
|
+
import { inventoryRepositoryManifests } from './artifactInventory.js';
|
|
5
6
|
export const SCANNER_PLUGIN_API_VERSION = 1;
|
|
6
7
|
export const DEFAULT_PLUGIN_RESOURCE_LIMITS = {
|
|
7
8
|
maxNodes: 100_000,
|
|
@@ -16,8 +17,9 @@ const GRAPH_NODE_KINDS = new Set(['file', 'symbol', 'import', 'export', 'call',
|
|
|
16
17
|
const GRAPH_EDGE_KINDS = new Set(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to', 'provides']);
|
|
17
18
|
/** Namespace used by plugins to target nodes produced by the built-in scanner. */
|
|
18
19
|
export const BUILTIN_GRAPH_PLUGIN = '__lscg_builtin__';
|
|
19
|
-
export function createRepositoryScanContext(repoPath) {
|
|
20
|
+
export function createRepositoryScanContext(repoPath, suppliedManifests) {
|
|
20
21
|
const discoveredPaths = discoverSourceFiles(repoPath);
|
|
22
|
+
const manifests = suppliedManifests ? [...suppliedManifests] : inventoryRepositoryManifests(repoPath);
|
|
21
23
|
const files = discoveredPaths.flatMap((relativePath) => {
|
|
22
24
|
try {
|
|
23
25
|
const absolutePath = path.join(repoPath, relativePath);
|
|
@@ -38,23 +40,31 @@ export function createRepositoryScanContext(repoPath) {
|
|
|
38
40
|
return [];
|
|
39
41
|
}
|
|
40
42
|
});
|
|
41
|
-
return { repoPath, files, discoveredPaths, readFile: (relativePath) => readFileSync(path.join(repoPath, relativePath), 'utf8') };
|
|
43
|
+
return { repoPath, files, discoveredPaths, manifests, readFile: (relativePath) => readFileSync(path.join(repoPath, relativePath), 'utf8') };
|
|
42
44
|
}
|
|
43
|
-
export async function runScannerPlugins(context, plugins, resourceLimits = {}) {
|
|
45
|
+
export async function runScannerPlugins(context, plugins, resourceLimits = {}, delta, previousContributions = new Map()) {
|
|
44
46
|
const limits = normalizePluginResourceLimits(resourceLimits);
|
|
45
47
|
const nodes = [];
|
|
46
48
|
const edges = [];
|
|
47
49
|
const diagnostics = [];
|
|
48
50
|
const failedPlugins = [];
|
|
51
|
+
const failedPaths = new Set();
|
|
49
52
|
const successfulPlugins = [];
|
|
53
|
+
const incrementalRetractions = new Map();
|
|
50
54
|
const owners = new Map();
|
|
51
55
|
for (const plugin of plugins) {
|
|
52
56
|
try {
|
|
53
57
|
validatePlugin(plugin);
|
|
54
58
|
await plugin.initialize?.(context);
|
|
55
|
-
const result =
|
|
59
|
+
const result = plugin.scanIncremental && delta
|
|
60
|
+
? await plugin.scanIncremental(context, delta, previousContributions.get(plugin.name))
|
|
61
|
+
: await plugin.scan(context);
|
|
56
62
|
const finalized = await plugin.finalize?.(context);
|
|
63
|
+
if (plugin.scanIncremental && delta && 'retractions' in result && result.retractions)
|
|
64
|
+
incrementalRetractions.set(plugin.name, result.retractions);
|
|
57
65
|
validatePluginResult(plugin.name, result, 'scan');
|
|
66
|
+
for (const failedPath of result.failedPaths ?? [])
|
|
67
|
+
failedPaths.add(failedPath);
|
|
58
68
|
if (finalized !== undefined)
|
|
59
69
|
validatePluginResult(plugin.name, finalized, 'finalize');
|
|
60
70
|
enforceResourceLimits(plugin, result, finalized, limits);
|
|
@@ -86,6 +96,15 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}) {
|
|
|
86
96
|
return [canonicalNodeIdentity(node.pluginName ?? '', node.factKey), materialized];
|
|
87
97
|
}));
|
|
88
98
|
const materializedNodes = [...nodeByIdentity.values()];
|
|
99
|
+
for (const [plugin, retractions] of incrementalRetractions) {
|
|
100
|
+
const previous = previousContributions.get(plugin);
|
|
101
|
+
const retractedNodeKeys = new Set(retractions.nodes ?? []);
|
|
102
|
+
for (const node of previous?.nodes ?? []) {
|
|
103
|
+
const factKey = String(node.metadata.factKey ?? '');
|
|
104
|
+
if (factKey && !retractedNodeKeys.has(factKey))
|
|
105
|
+
nodeByIdentity.set(canonicalNodeIdentity(plugin, factKey), node);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
89
108
|
const materializedEdges = [];
|
|
90
109
|
for (const edge of edges) {
|
|
91
110
|
const sourceIdentity = canonicalNodeIdentity(edge.sourcePlugin ?? edge.pluginName ?? '', edge.sourceFactKey);
|
|
@@ -126,6 +145,33 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}) {
|
|
|
126
145
|
if (plugin)
|
|
127
146
|
contributionFor(plugin).edges.push(edge);
|
|
128
147
|
}
|
|
148
|
+
for (const [plugin, retractions] of incrementalRetractions) {
|
|
149
|
+
const previous = previousContributions.get(plugin);
|
|
150
|
+
if (!previous)
|
|
151
|
+
continue;
|
|
152
|
+
const current = contributionFor(plugin);
|
|
153
|
+
const nodeKeys = new Set(retractions.nodes ?? []);
|
|
154
|
+
const edgeKeys = new Set(retractions.edges ?? []);
|
|
155
|
+
const currentNodeKeys = new Set(current.nodes.map((node) => String(node.metadata.factKey ?? '')));
|
|
156
|
+
const currentEdgeKeys = new Set(current.edges.map((edge) => String(edge.metadata.factKey ?? '')));
|
|
157
|
+
current.nodes = [
|
|
158
|
+
...previous.nodes.filter((node) => !nodeKeys.has(String(node.metadata.factKey ?? '')) && !currentNodeKeys.has(String(node.metadata.factKey ?? ''))),
|
|
159
|
+
...current.nodes
|
|
160
|
+
];
|
|
161
|
+
current.edges = [
|
|
162
|
+
...previous.edges.filter((edge) => !edgeKeys.has(String(edge.metadata.factKey ?? '')) && !currentEdgeKeys.has(String(edge.metadata.factKey ?? ''))),
|
|
163
|
+
...current.edges
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
const partiallyReconciledPlugins = new Set(incrementalRetractions.keys());
|
|
167
|
+
const mergedNodes = [
|
|
168
|
+
...materializedNodes.filter((node) => !partiallyReconciledPlugins.has(String(node.metadata.plugin ?? ''))),
|
|
169
|
+
...[...partiallyReconciledPlugins].flatMap((plugin) => pluginContributions.get(plugin)?.nodes ?? [])
|
|
170
|
+
];
|
|
171
|
+
const mergedEdges = [
|
|
172
|
+
...materializedEdges.filter((edge) => !partiallyReconciledPlugins.has(String(edge.metadata.plugin ?? ''))),
|
|
173
|
+
...[...partiallyReconciledPlugins].flatMap((plugin) => pluginContributions.get(plugin)?.edges ?? [])
|
|
174
|
+
];
|
|
129
175
|
function contributionFor(plugin) {
|
|
130
176
|
const existing = pluginContributions.get(plugin);
|
|
131
177
|
if (existing)
|
|
@@ -134,7 +180,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}) {
|
|
|
134
180
|
pluginContributions.set(plugin, created);
|
|
135
181
|
return created;
|
|
136
182
|
}
|
|
137
|
-
return { nodes:
|
|
183
|
+
return { nodes: mergedNodes, edges: mergedEdges, diagnostics, failedPaths: [...failedPaths].sort(), failedPlugins, successfulPlugins, pluginContributions };
|
|
138
184
|
function collect(plugin, result, nodeOutput, edgeOutput, ownerMap, messages, contributionLimits) {
|
|
139
185
|
for (const node of result.nodes ?? []) {
|
|
140
186
|
const identity = canonicalNodeIdentity(plugin.name, node.factKey);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PluginScanResult, RepositoryScanFile, ScannerPlugin } from './plugins.js';
|
|
2
|
+
export declare const PYTHON_SCANNER_PLUGIN_NAME = "python-scanner-plugin";
|
|
3
|
+
export declare function scanPythonFiles(files: readonly RepositoryScanFile[]): PluginScanResult;
|
|
4
|
+
export declare const PythonScannerPlugin: ScannerPlugin;
|
|
5
|
+
//# sourceMappingURL=pythonPlugin.d.ts.map
|