@psnext/lscg 0.1.3 → 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 +126 -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/view/templates/icons/call.svg +11 -9
- package/dist/src/view/templates/interactive.css +11 -8
- package/dist/src/view/templates/interactive.html +160 -46
- 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,9 +1,33 @@
|
|
|
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';
|
|
6
|
+
// This plugin adds package-level relationships on top of the built-in source
|
|
7
|
+
// graph. Built-in extraction already creates file/import/symbol nodes; this
|
|
8
|
+
// plugin creates package nodes and connects them to the existing import nodes.
|
|
9
|
+
//
|
|
10
|
+
// The important identity rule for edges is:
|
|
11
|
+
// - package nodes use this plugin's fact-key namespace (`package:<name>`), and
|
|
12
|
+
// - built-in nodes are referenced by their final graph ID with
|
|
13
|
+
// `targetPlugin: BUILTIN_GRAPH_PLUGIN`.
|
|
14
|
+
//
|
|
15
|
+
// Keep that distinction in mind when repointing an edge to another existing
|
|
16
|
+
// node. Do not use the package node's eventual database ID as its fact key.
|
|
5
17
|
const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
6
18
|
const BUILTINS = new Set(builtinModules.flatMap((name) => [name, name.replace(/^node:/, ''), `node:${name.replace(/^node:/, '')}`]));
|
|
19
|
+
/**
|
|
20
|
+
* Converts an import specifier into its package root.
|
|
21
|
+
*
|
|
22
|
+
* Examples:
|
|
23
|
+
* react -> react
|
|
24
|
+
* lodash/map -> lodash
|
|
25
|
+
* @scope/library/tool -> @scope/library
|
|
26
|
+
* ./local-file -> undefined
|
|
27
|
+
* node:fs -> undefined
|
|
28
|
+
*
|
|
29
|
+
* Returning the root is what lets several imports share one package node.
|
|
30
|
+
*/
|
|
7
31
|
function packageRoot(specifier) {
|
|
8
32
|
if (!specifier || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(specifier))
|
|
9
33
|
return undefined;
|
|
@@ -18,11 +42,46 @@ function packageRoot(specifier) {
|
|
|
18
42
|
function isManifest(value) {
|
|
19
43
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
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
|
+
}
|
|
21
80
|
export const PackagePlugin = {
|
|
22
81
|
name: 'package-plugin',
|
|
23
82
|
apiVersion: 1,
|
|
24
83
|
capabilities: ['repository-analyzer'],
|
|
25
|
-
scan: (context) => {
|
|
84
|
+
scan: async (context) => {
|
|
26
85
|
let manifest;
|
|
27
86
|
try {
|
|
28
87
|
manifest = JSON.parse(context.readFile('package.json'));
|
|
@@ -55,28 +114,14 @@ export const PackagePlugin = {
|
|
|
55
114
|
declarations.set(name, list);
|
|
56
115
|
}
|
|
57
116
|
}
|
|
58
|
-
|
|
117
|
+
// Re-run the same extraction used by the built-in scanner. The resulting
|
|
118
|
+
// import IDs are stable graph node IDs, so they can be used directly as
|
|
119
|
+
// targets when targetPlugin is BUILTIN_GRAPH_PLUGIN.
|
|
59
120
|
const repositoryId = hashParts(['repository', context.repoPath]);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const extracted = extractGraph({
|
|
65
|
-
repositoryId,
|
|
66
|
-
fileId: hashParts([repositoryId, file.path]),
|
|
67
|
-
relativePath: file.path,
|
|
68
|
-
source: file.source,
|
|
69
|
-
sourceHash: file.hash,
|
|
70
|
-
parseResult: parsed
|
|
71
|
-
});
|
|
72
|
-
for (const node of extracted.nodes) {
|
|
73
|
-
if (node.kind !== 'import')
|
|
74
|
-
continue;
|
|
75
|
-
const module = typeof node.metadata.module === 'string' ? node.metadata.module : node.name;
|
|
76
|
-
if (module)
|
|
77
|
-
imports.push({ path: file.path, id: node.id, module });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
121
|
+
const imports = await parseImportsInBatches(repositoryId, context.files);
|
|
122
|
+
// A package is represented once even when it is imported by many files or
|
|
123
|
+
// through many subpaths. `implied` records imports that are not declared in
|
|
124
|
+
// any dependency section of package.json.
|
|
80
125
|
const usedPackages = new Map();
|
|
81
126
|
for (const entry of imports) {
|
|
82
127
|
const root = packageRoot(entry.module);
|
|
@@ -87,6 +132,8 @@ export const PackagePlugin = {
|
|
|
87
132
|
}
|
|
88
133
|
const packageNames = new Set(usedPackages.keys());
|
|
89
134
|
const nodes = [...packageNames].sort().map((name) => {
|
|
135
|
+
// This fact key is local to PackagePlugin. The scanner host later
|
|
136
|
+
// materializes it as hashParts(['plugin-node', 'package-plugin', factKey]).
|
|
90
137
|
const packageDeclarations = declarations.get(name) ?? [];
|
|
91
138
|
const implied = !declarations.has(name);
|
|
92
139
|
const versions = new Set(packageDeclarations.map((declaration) => declaration.version));
|
|
@@ -109,6 +156,18 @@ export const PackagePlugin = {
|
|
|
109
156
|
const name = packageRoot(entry.module);
|
|
110
157
|
if (!name || !packageNames.has(name))
|
|
111
158
|
return [];
|
|
159
|
+
// The source is a node emitted by this plugin, so sourceFactKey uses
|
|
160
|
+
// PackagePlugin's namespace and can be resolved as `package:<name>`.
|
|
161
|
+
//
|
|
162
|
+
// The target is an existing node emitted by the built-in graph scanner.
|
|
163
|
+
// `entry.id` is that node's final graph ID, not a plugin fact key. The
|
|
164
|
+
// explicit built-in namespace tells the scanner host to use the ID
|
|
165
|
+
// directly instead of looking for a PackagePlugin node with that key.
|
|
166
|
+
//
|
|
167
|
+
// To repoint this edge to another existing built-in node, replace
|
|
168
|
+
// `targetFactKey` with that node's ID and keep
|
|
169
|
+
// `targetPlugin: BUILTIN_GRAPH_PLUGIN`. For a node from another plugin,
|
|
170
|
+
// use that plugin's name in targetPlugin and its factKey in targetFactKey.
|
|
112
171
|
return [{
|
|
113
172
|
factKey: `provides:${name}:${entry.path}:${entry.id}`,
|
|
114
173
|
sourceFactKey: `package:${name}`,
|
|
@@ -120,6 +179,49 @@ export const PackagePlugin = {
|
|
|
120
179
|
}];
|
|
121
180
|
});
|
|
122
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 } : {}) };
|
|
123
225
|
}
|
|
124
226
|
};
|
|
125
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
|