@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
|
@@ -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
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import Parser from 'tree-sitter';
|
|
2
|
+
import { parseSource } from '../parser/treeSitter.js';
|
|
3
|
+
import { runBatchedWorkers } from './parallelScan.js';
|
|
4
|
+
export const PYTHON_SCANNER_PLUGIN_NAME = 'python-scanner-plugin';
|
|
5
|
+
const PYTHON_EXTENSIONS = new Set(['.py']);
|
|
6
|
+
const SYMBOL_TYPES = new Set(['class_definition', 'function_definition', 'assignment']);
|
|
7
|
+
const IMPORT_TYPES = new Set(['import_statement', 'import_from_statement']);
|
|
8
|
+
const CALL_TYPES = new Set(['call']);
|
|
9
|
+
export function scanPythonFiles(files) {
|
|
10
|
+
const nodes = [];
|
|
11
|
+
const edges = [];
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
if (!isPython(file.path))
|
|
14
|
+
continue;
|
|
15
|
+
const parsed = parseSource(file.absolutePath, file.source);
|
|
16
|
+
if (!parsed || parsed.language !== 'python')
|
|
17
|
+
continue;
|
|
18
|
+
const sourceBuffer = Buffer.from(file.source, 'utf8');
|
|
19
|
+
const fileKey = factKey(file.path, 'file', 0, sourceBuffer.length);
|
|
20
|
+
nodes.push(node(file, fileKey, parsed, 'file', 'file', file.path, parsed.tree.rootNode, sourceBuffer, { language: 'python' }));
|
|
21
|
+
walk(parsed.tree.rootNode, [], [], fileKey, file, parsed, sourceBuffer, nodes, edges);
|
|
22
|
+
}
|
|
23
|
+
return { nodes, edges };
|
|
24
|
+
}
|
|
25
|
+
async function scanPythonFilesInBatches(files) {
|
|
26
|
+
const pythonFiles = files.filter((file) => isPython(file.path));
|
|
27
|
+
const results = await runBatchedWorkers(pythonFiles, new URL('./pythonScanWorker.js', import.meta.url), (batch) => batch);
|
|
28
|
+
return {
|
|
29
|
+
nodes: results.flatMap((result) => result.nodes),
|
|
30
|
+
edges: results.flatMap((result) => result.edges),
|
|
31
|
+
failedPaths: results.flatMap((result) => result.failedPaths ?? []),
|
|
32
|
+
diagnostics: results.flatMap((result) => result.diagnostics ?? [])
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const PythonScannerPlugin = {
|
|
36
|
+
name: PYTHON_SCANNER_PLUGIN_NAME,
|
|
37
|
+
apiVersion: 1,
|
|
38
|
+
capabilities: ['file-scanner'],
|
|
39
|
+
scan: (context) => scanPythonFilesInBatches(context.files),
|
|
40
|
+
scanIncremental: (context, delta, previous) => scanPythonIncremental(context, delta, previous)
|
|
41
|
+
};
|
|
42
|
+
async function scanPythonIncremental(context, delta, previous) {
|
|
43
|
+
if (delta.full || !previous)
|
|
44
|
+
return { ...(await scanPythonFilesInBatches(context.files)), retractions: { nodes: [], edges: [] } };
|
|
45
|
+
const changed = new Set([...delta.changedPaths, ...delta.deletedPaths].filter(isPython));
|
|
46
|
+
if (changed.size === 0)
|
|
47
|
+
return { nodes: [], edges: [], retractions: { nodes: [], edges: [] } };
|
|
48
|
+
const retractions = { nodes: [], edges: [] };
|
|
49
|
+
for (const fact of previous.nodes) {
|
|
50
|
+
if (fact.metadata.filePath && changed.has(String(fact.metadata.filePath))) {
|
|
51
|
+
const key = String(fact.metadata.factKey ?? '');
|
|
52
|
+
if (key)
|
|
53
|
+
retractions.nodes.push(key);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const fact of previous.edges) {
|
|
57
|
+
if (fact.metadata.filePath && changed.has(String(fact.metadata.filePath))) {
|
|
58
|
+
const key = String(fact.metadata.factKey ?? '');
|
|
59
|
+
if (key)
|
|
60
|
+
retractions.edges.push(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...(await scanPythonFilesInBatches(context.files.filter((file) => changed.has(file.path)))),
|
|
65
|
+
retractions
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function walk(treeNode, symbolAncestry, graphAncestry, fileKey, file, parsed, sourceBuffer, nodes, edges) {
|
|
69
|
+
let currentKey;
|
|
70
|
+
let nextSymbols = symbolAncestry;
|
|
71
|
+
let nextGraph = graphAncestry;
|
|
72
|
+
const kind = kindFor(treeNode);
|
|
73
|
+
if (kind) {
|
|
74
|
+
const name = nameFor(treeNode, sourceBuffer);
|
|
75
|
+
currentKey = factKey(file.path, kind, treeNode.startIndex, treeNode.endIndex);
|
|
76
|
+
const metadata = metadataFor(treeNode, sourceBuffer, name);
|
|
77
|
+
nodes.push(node(file, currentKey, parsed, kind, treeNode.type, name, treeNode, sourceBuffer, metadata));
|
|
78
|
+
const parentKey = graphAncestry.at(-1) ?? fileKey;
|
|
79
|
+
edges.push({
|
|
80
|
+
factKey: `contains:${file.path}:${treeNode.startIndex}:${treeNode.endIndex}`,
|
|
81
|
+
sourceFactKey: parentKey,
|
|
82
|
+
targetFactKey: currentKey,
|
|
83
|
+
filePath: file.path,
|
|
84
|
+
kind: kind === 'symbol' && symbolAncestry.length === 0 ? 'defines' : 'contains',
|
|
85
|
+
metadata: { discoveredFrom: treeNode.type }
|
|
86
|
+
});
|
|
87
|
+
if (kind === 'import') {
|
|
88
|
+
edges.push({
|
|
89
|
+
factKey: `imports:${file.path}:${treeNode.startIndex}:${treeNode.endIndex}`,
|
|
90
|
+
sourceFactKey: fileKey,
|
|
91
|
+
targetFactKey: currentKey,
|
|
92
|
+
filePath: file.path,
|
|
93
|
+
kind: 'imports',
|
|
94
|
+
confidence: 0.65,
|
|
95
|
+
metadata: { module: metadata.module ?? name }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (kind === 'call') {
|
|
99
|
+
edges.push({
|
|
100
|
+
factKey: `calls:${file.path}:${treeNode.startIndex}:${treeNode.endIndex}`,
|
|
101
|
+
sourceFactKey: symbolAncestry.at(-1) ?? fileKey,
|
|
102
|
+
targetFactKey: currentKey,
|
|
103
|
+
filePath: file.path,
|
|
104
|
+
kind: 'calls',
|
|
105
|
+
confidence: 0.65,
|
|
106
|
+
metadata: { callee: name }
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (kind === 'symbol') {
|
|
110
|
+
if (symbolAncestry.length === 0 && name && !name.startsWith('_')) {
|
|
111
|
+
const exportKey = factKey(file.path, 'export', treeNode.startIndex, treeNode.endIndex);
|
|
112
|
+
nodes.push(node(file, exportKey, parsed, 'export', 'implicit_export', name, treeNode, sourceBuffer, {
|
|
113
|
+
exportKind: 'implicit-python-export',
|
|
114
|
+
symbol: name
|
|
115
|
+
}));
|
|
116
|
+
edges.push({
|
|
117
|
+
factKey: `exports:${file.path}:${treeNode.startIndex}:${treeNode.endIndex}`,
|
|
118
|
+
sourceFactKey: fileKey,
|
|
119
|
+
targetFactKey: exportKey,
|
|
120
|
+
filePath: file.path,
|
|
121
|
+
kind: 'exports',
|
|
122
|
+
metadata: { symbol: name, implicit: true }
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
nextSymbols = [...symbolAncestry, currentKey];
|
|
126
|
+
}
|
|
127
|
+
nextGraph = [...graphAncestry, currentKey];
|
|
128
|
+
}
|
|
129
|
+
for (const child of treeNode.namedChildren) {
|
|
130
|
+
walk(child, nextSymbols, nextGraph, fileKey, file, parsed, sourceBuffer, nodes, edges);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function kindFor(node) {
|
|
134
|
+
if (SYMBOL_TYPES.has(node.type))
|
|
135
|
+
return 'symbol';
|
|
136
|
+
if (IMPORT_TYPES.has(node.type))
|
|
137
|
+
return 'import';
|
|
138
|
+
if (CALL_TYPES.has(node.type))
|
|
139
|
+
return 'call';
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
function nameFor(node, sourceBuffer) {
|
|
143
|
+
const name = node.childForFieldName('name');
|
|
144
|
+
if (name)
|
|
145
|
+
return textFor(name, sourceBuffer).trim();
|
|
146
|
+
if (node.type === 'assignment' || node.type === 'augmented_assignment') {
|
|
147
|
+
const left = node.childForFieldName('left') ?? node.namedChildren[0];
|
|
148
|
+
return left ? textFor(left, sourceBuffer).trim() : null;
|
|
149
|
+
}
|
|
150
|
+
if (node.type === 'call') {
|
|
151
|
+
const fn = node.childForFieldName('function') ?? node.namedChildren[0];
|
|
152
|
+
return fn ? textFor(fn, sourceBuffer).trim().slice(0, 120) : null;
|
|
153
|
+
}
|
|
154
|
+
if (IMPORT_TYPES.has(node.type))
|
|
155
|
+
return moduleForImport(node, sourceBuffer);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
function metadataFor(node, sourceBuffer, name) {
|
|
159
|
+
const snippet = textFor(node, sourceBuffer).trim().slice(0, 240);
|
|
160
|
+
if (IMPORT_TYPES.has(node.type))
|
|
161
|
+
return { module: moduleForImport(node, sourceBuffer), snippet };
|
|
162
|
+
if (node.type === 'call')
|
|
163
|
+
return { callee: name, snippet };
|
|
164
|
+
return { snippet };
|
|
165
|
+
}
|
|
166
|
+
function moduleForImport(node, sourceBuffer) {
|
|
167
|
+
const source = textFor(node, sourceBuffer).trim();
|
|
168
|
+
if (node.type === 'import_statement')
|
|
169
|
+
return source.replace(/^import\s+/, '').split(',')[0]?.trim() ?? source;
|
|
170
|
+
return /^from\s+([^\s]+)\s+import\s+/u.exec(source)?.[1] ?? source;
|
|
171
|
+
}
|
|
172
|
+
function node(file, factKeyValue, parsed, kind, type, name, treeNode, sourceBuffer, metadata) {
|
|
173
|
+
return {
|
|
174
|
+
factKey: factKeyValue,
|
|
175
|
+
filePath: file.path,
|
|
176
|
+
kind,
|
|
177
|
+
type,
|
|
178
|
+
name,
|
|
179
|
+
startByte: treeNode.startIndex,
|
|
180
|
+
endByte: treeNode.endIndex,
|
|
181
|
+
startPoint: treeNode.startPosition,
|
|
182
|
+
endPoint: treeNode.endPosition,
|
|
183
|
+
sourceHash: file.hash,
|
|
184
|
+
parser: parsed.parser,
|
|
185
|
+
parserVersion: parsed.parserVersion,
|
|
186
|
+
metadata: { ...metadata, filePath: file.path, factKey: factKeyValue, sourceBytes: sourceBuffer.length }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function factKey(filePath, kind, startByte, endByte) {
|
|
190
|
+
return `${kind}:${filePath}:${startByte}:${endByte}`;
|
|
191
|
+
}
|
|
192
|
+
function textFor(node, sourceBuffer) {
|
|
193
|
+
return sourceBuffer.subarray(node.startIndex, node.endIndex).toString('utf8');
|
|
194
|
+
}
|
|
195
|
+
function isPython(filePath) {
|
|
196
|
+
return PYTHON_EXTENSIONS.has(filePath.slice(filePath.lastIndexOf('.')).toLowerCase());
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=pythonPlugin.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { scanPythonFiles } from './pythonPlugin.js';
|
|
3
|
+
if (!parentPort)
|
|
4
|
+
throw new Error('python scan worker requires a parent port');
|
|
5
|
+
parentPort.on('message', (files) => {
|
|
6
|
+
parentPort.postMessage(scanPythonFiles(files));
|
|
7
|
+
});
|
|
8
|
+
//# sourceMappingURL=pythonScanWorker.js.map
|
|
@@ -44,6 +44,18 @@ function ensureSchemaVersion(db) {
|
|
|
44
44
|
}
|
|
45
45
|
if (currentVersion <= 3) {
|
|
46
46
|
migrateSchemaV3ToV4(db);
|
|
47
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(4);
|
|
48
|
+
}
|
|
49
|
+
if (currentVersion <= 4) {
|
|
50
|
+
migrateSchemaV4ToV5(db);
|
|
51
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(5);
|
|
52
|
+
}
|
|
53
|
+
if (currentVersion <= 5) {
|
|
54
|
+
migrateSchemaV5ToV6(db);
|
|
55
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(6);
|
|
56
|
+
}
|
|
57
|
+
if (currentVersion <= 6) {
|
|
58
|
+
migrateSchemaV6ToV7(db);
|
|
47
59
|
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
|
|
48
60
|
return;
|
|
49
61
|
}
|
|
@@ -63,6 +75,57 @@ function migrateSchemaV3ToV4(db) {
|
|
|
63
75
|
CREATE INDEX IF NOT EXISTS idx_plugin_contributions_repo ON plugin_contributions(repository_id);
|
|
64
76
|
`);
|
|
65
77
|
}
|
|
78
|
+
function migrateSchemaV4ToV5(db) {
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE TABLE IF NOT EXISTS file_scan_failures (
|
|
81
|
+
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
82
|
+
path TEXT NOT NULL,
|
|
83
|
+
diagnostic TEXT NOT NULL,
|
|
84
|
+
failed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
85
|
+
PRIMARY KEY(repository_id, path)
|
|
86
|
+
);
|
|
87
|
+
CREATE TABLE IF NOT EXISTS repository_scan_state (
|
|
88
|
+
repository_id TEXT PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
|
|
89
|
+
state TEXT NOT NULL CHECK(state IN ('fresh', 'degraded', 'stale')),
|
|
90
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
91
|
+
);
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
function migrateSchemaV5ToV6(db) {
|
|
95
|
+
db.exec(`
|
|
96
|
+
CREATE TABLE IF NOT EXISTS file_enrichment_state (
|
|
97
|
+
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
98
|
+
file_id TEXT PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
|
99
|
+
source_hash TEXT NOT NULL,
|
|
100
|
+
state TEXT NOT NULL CHECK(state IN ('pending', 'complete', 'failed')),
|
|
101
|
+
diagnostic TEXT,
|
|
102
|
+
queued_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
103
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
104
|
+
completed_at TEXT,
|
|
105
|
+
failed_at TEXT
|
|
106
|
+
);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_file_enrichment_state_repo_state ON file_enrichment_state(repository_id, state);
|
|
108
|
+
`);
|
|
109
|
+
}
|
|
110
|
+
function migrateSchemaV6ToV7(db) {
|
|
111
|
+
db.exec(`
|
|
112
|
+
CREATE TABLE IF NOT EXISTS repository_manifest_inventory (
|
|
113
|
+
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
114
|
+
path TEXT NOT NULL,
|
|
115
|
+
kind TEXT NOT NULL CHECK(kind IN ('maven-pom', 'gradle-groovy', 'gradle-kotlin')),
|
|
116
|
+
module_identity TEXT NOT NULL,
|
|
117
|
+
status TEXT NOT NULL CHECK(status IN ('observed', 'read_failed', 'traversal_incomplete', 'confirmed_deleted')),
|
|
118
|
+
size INTEGER NOT NULL,
|
|
119
|
+
mtime_ms INTEGER NOT NULL,
|
|
120
|
+
source_hash TEXT,
|
|
121
|
+
generation INTEGER NOT NULL,
|
|
122
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
123
|
+
PRIMARY KEY(repository_id, path)
|
|
124
|
+
);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_repository_manifest_inventory_repo_status
|
|
126
|
+
ON repository_manifest_inventory(repository_id, status);
|
|
127
|
+
`);
|
|
128
|
+
}
|
|
66
129
|
function getCurrentSchemaVersion(db) {
|
|
67
130
|
const row = db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get();
|
|
68
131
|
return Number(row?.version ?? 0);
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import type {
|
|
2
|
+
import type { AttributionApplyResult, AttributionOutcome, FileRecord, GraphEdge, GraphNode, RepositoryRecord } from '../types.js';
|
|
3
3
|
export declare function upsertRepository(db: DatabaseSync, repository: RepositoryRecord): void;
|
|
4
|
-
export declare function replaceFileGraph(db: DatabaseSync, { repository, file, nodes, edges
|
|
4
|
+
export declare function replaceFileGraph(db: DatabaseSync, { repository, file, nodes, edges }: {
|
|
5
5
|
repository: RepositoryRecord;
|
|
6
6
|
file: FileRecord;
|
|
7
7
|
nodes: GraphNode[];
|
|
8
8
|
edges: GraphEdge[];
|
|
9
|
-
attribution?: FileAttribution | null;
|
|
10
9
|
}): void;
|
|
10
|
+
/**
|
|
11
|
+
* Applies optional attribution only when it still matches the structural file
|
|
12
|
+
* hash. Failed outcomes never mutate existing attribution.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applyFileAttribution(db: DatabaseSync, { repository, fileId, sourceHash, outcome }: {
|
|
15
|
+
repository: RepositoryRecord;
|
|
16
|
+
fileId: string;
|
|
17
|
+
sourceHash: string;
|
|
18
|
+
outcome: AttributionOutcome;
|
|
19
|
+
}): AttributionApplyResult;
|
|
11
20
|
export declare function reconcileDeletedFiles(db: DatabaseSync, repositoryId: string, discoveredPaths: string[]): void;
|
|
21
|
+
export declare function recordFileScanFailure(db: DatabaseSync, repositoryId: string, filePath: string, diagnostic: string): void;
|
|
22
|
+
export declare function clearFileScanFailure(db: DatabaseSync, repositoryId: string, filePath: string): void;
|
|
23
|
+
export declare function setRepositoryScanState(db: DatabaseSync, repositoryId: string, state: 'fresh' | 'degraded' | 'stale'): void;
|
|
12
24
|
export declare function selectFileInventory(db: DatabaseSync, repositoryId: string): Array<{
|
|
13
25
|
path: string;
|
|
26
|
+
hash: string;
|
|
14
27
|
size: number;
|
|
15
28
|
mtimeMs: number;
|
|
16
29
|
}>;
|
|
@@ -10,13 +10,14 @@ export function upsertRepository(db, repository) {
|
|
|
10
10
|
updated_at = datetime('now')
|
|
11
11
|
`).run(repository.id, repository.root, repository.name);
|
|
12
12
|
}
|
|
13
|
-
export function replaceFileGraph(db, { repository, file, nodes, edges
|
|
13
|
+
export function replaceFileGraph(db, { repository, file, nodes, edges }) {
|
|
14
14
|
upsertRepository(db, repository);
|
|
15
15
|
db.exec('BEGIN IMMEDIATE');
|
|
16
16
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// The structural replacement must not depend on Git attribution. Preserve
|
|
18
|
+
// attribution for structurally stable nodes so a later failed enrichment
|
|
19
|
+
// does not make previously successful data disappear.
|
|
20
|
+
const priorAttribution = captureFileAttribution(db, file.id);
|
|
20
21
|
db.prepare('DELETE FROM edges WHERE file_id = ?').run(file.id);
|
|
21
22
|
db.prepare('DELETE FROM nodes WHERE file_id = ?').run(file.id);
|
|
22
23
|
db.prepare(`
|
|
@@ -36,10 +37,8 @@ export function replaceFileGraph(db, { repository, file, nodes, edges, attributi
|
|
|
36
37
|
start_point, end_point, source_hash, parser, parser_version, metadata_json, last_modified_user_id
|
|
37
38
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
38
39
|
`);
|
|
39
|
-
const attributionByNodeId = new Map(attribution?.nodeAttributions.map((entry) => [entry.nodeId, entry]) ?? []);
|
|
40
40
|
for (const node of nodes) {
|
|
41
|
-
|
|
42
|
-
insertNode.run(node.id, repository.id, file.id, node.kind, node.type, node.name ?? null, node.startByte, node.endByte, JSON.stringify(node.startPoint), JSON.stringify(node.endPoint), node.sourceHash, node.parser, node.parserVersion, JSON.stringify(node.metadata ?? {}), node.lastModifiedUserId ?? resolveUserId(repository, nodeAttribution?.lastModifiedEmail ?? null));
|
|
41
|
+
insertNode.run(node.id, repository.id, file.id, node.kind, node.type, node.name ?? null, node.startByte, node.endByte, JSON.stringify(node.startPoint), JSON.stringify(node.endPoint), node.sourceHash, node.parser, node.parserVersion, JSON.stringify(node.metadata ?? {}), null);
|
|
43
42
|
}
|
|
44
43
|
const insertEdge = db.prepare(`
|
|
45
44
|
INSERT INTO edges(
|
|
@@ -49,17 +48,43 @@ export function replaceFileGraph(db, { repository, file, nodes, edges, attributi
|
|
|
49
48
|
for (const edge of edges) {
|
|
50
49
|
insertEdge.run(edge.id, repository.id, file.id, edge.sourceId, edge.targetId, edge.kind, edge.confidence ?? 1, JSON.stringify(edge.metadata ?? {}));
|
|
51
50
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
restorePriorAttribution(db, file.id, new Set(nodes.map((node) => node.id)), priorAttribution);
|
|
52
|
+
queueFileEnrichment(db, repository.id, file.id, file.hash);
|
|
53
|
+
db.exec('COMMIT');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
db.exec('ROLLBACK');
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Applies optional attribution only when it still matches the structural file
|
|
62
|
+
* hash. Failed outcomes never mutate existing attribution.
|
|
63
|
+
*/
|
|
64
|
+
export function applyFileAttribution(db, { repository, fileId, sourceHash, outcome }) {
|
|
65
|
+
db.exec('BEGIN IMMEDIATE');
|
|
66
|
+
try {
|
|
67
|
+
const current = db.prepare(`
|
|
68
|
+
SELECT f.hash AS sourceHash, e.source_hash AS enrichmentHash
|
|
69
|
+
FROM files f LEFT JOIN file_enrichment_state e ON e.file_id = f.id
|
|
70
|
+
WHERE f.id = ? AND f.repository_id = ?
|
|
71
|
+
`).get(fileId, repository.id);
|
|
72
|
+
if (current?.sourceHash !== sourceHash || current.enrichmentHash !== sourceHash) {
|
|
73
|
+
db.exec('ROLLBACK');
|
|
74
|
+
return 'stale';
|
|
75
|
+
}
|
|
76
|
+
if (outcome.status === 'complete') {
|
|
77
|
+
replaceAttribution(db, repository, fileId, outcome.attribution);
|
|
78
|
+
setFileEnrichmentState(db, repository.id, fileId, 'complete', null);
|
|
79
|
+
}
|
|
80
|
+
else if (outcome.status === 'unavailable') {
|
|
81
|
+
setFileEnrichmentState(db, repository.id, fileId, 'complete', null);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
setFileEnrichmentState(db, repository.id, fileId, 'failed', outcome.diagnostic);
|
|
61
85
|
}
|
|
62
86
|
db.exec('COMMIT');
|
|
87
|
+
return 'applied';
|
|
63
88
|
}
|
|
64
89
|
catch (error) {
|
|
65
90
|
db.exec('ROLLBACK');
|
|
@@ -75,6 +100,8 @@ export function reconcileDeletedFiles(db, repositoryId, discoveredPaths) {
|
|
|
75
100
|
if (!keep.has(row.path))
|
|
76
101
|
db.prepare('DELETE FROM files WHERE repository_id = ? AND path = ?').run(repositoryId, row.path);
|
|
77
102
|
}
|
|
103
|
+
db.prepare(`DELETE FROM file_scan_failures WHERE repository_id = ? AND path NOT IN (SELECT value FROM json_each(?))`)
|
|
104
|
+
.run(repositoryId, JSON.stringify(discoveredPaths));
|
|
78
105
|
db.exec('COMMIT');
|
|
79
106
|
}
|
|
80
107
|
catch (error) {
|
|
@@ -82,8 +109,106 @@ export function reconcileDeletedFiles(db, repositoryId, discoveredPaths) {
|
|
|
82
109
|
throw error;
|
|
83
110
|
}
|
|
84
111
|
}
|
|
112
|
+
export function recordFileScanFailure(db, repositoryId, filePath, diagnostic) {
|
|
113
|
+
db.prepare(`
|
|
114
|
+
INSERT INTO file_scan_failures(repository_id, path, diagnostic, failed_at)
|
|
115
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
116
|
+
ON CONFLICT(repository_id, path) DO UPDATE SET
|
|
117
|
+
diagnostic = excluded.diagnostic,
|
|
118
|
+
failed_at = datetime('now')
|
|
119
|
+
`).run(repositoryId, filePath, diagnostic);
|
|
120
|
+
}
|
|
121
|
+
export function clearFileScanFailure(db, repositoryId, filePath) {
|
|
122
|
+
db.prepare('DELETE FROM file_scan_failures WHERE repository_id = ? AND path = ?').run(repositoryId, filePath);
|
|
123
|
+
}
|
|
124
|
+
export function setRepositoryScanState(db, repositoryId, state) {
|
|
125
|
+
db.prepare(`
|
|
126
|
+
INSERT INTO repository_scan_state(repository_id, state, updated_at)
|
|
127
|
+
VALUES (?, ?, datetime('now'))
|
|
128
|
+
ON CONFLICT(repository_id) DO UPDATE SET
|
|
129
|
+
state = excluded.state,
|
|
130
|
+
updated_at = datetime('now')
|
|
131
|
+
`).run(repositoryId, state);
|
|
132
|
+
}
|
|
85
133
|
export function selectFileInventory(db, repositoryId) {
|
|
86
|
-
return db.prepare('SELECT path, size, mtime_ms AS mtimeMs FROM files WHERE repository_id = ? ORDER BY path').all(repositoryId);
|
|
134
|
+
return db.prepare('SELECT path, hash, size, mtime_ms AS mtimeMs FROM files WHERE repository_id = ? ORDER BY path').all(repositoryId);
|
|
135
|
+
}
|
|
136
|
+
function captureFileAttribution(db, fileId) {
|
|
137
|
+
return {
|
|
138
|
+
lastModified: db.prepare(`
|
|
139
|
+
SELECT id AS nodeId, last_modified_user_id AS userId
|
|
140
|
+
FROM nodes
|
|
141
|
+
WHERE file_id = ? AND last_modified_user_id IS NOT NULL
|
|
142
|
+
`).all(fileId),
|
|
143
|
+
edges: db.prepare(`
|
|
144
|
+
SELECT id, source_id AS sourceId, target_id AS targetId, confidence, metadata_json AS metadataJson
|
|
145
|
+
FROM edges
|
|
146
|
+
WHERE file_id = ? AND kind = 'attributed_to'
|
|
147
|
+
`).all(fileId)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function restorePriorAttribution(db, fileId, currentNodeIds, prior) {
|
|
151
|
+
const updateLastModified = db.prepare('UPDATE nodes SET last_modified_user_id = ? WHERE id = ? AND file_id = ?');
|
|
152
|
+
for (const entry of prior.lastModified) {
|
|
153
|
+
if (currentNodeIds.has(entry.nodeId))
|
|
154
|
+
updateLastModified.run(entry.userId, entry.nodeId, fileId);
|
|
155
|
+
}
|
|
156
|
+
const insertEdge = db.prepare(`
|
|
157
|
+
INSERT OR IGNORE INTO edges(id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json)
|
|
158
|
+
SELECT ?, repository_id, ?, ?, ?, 'attributed_to', ?, ?
|
|
159
|
+
FROM files WHERE id = ?
|
|
160
|
+
`);
|
|
161
|
+
for (const edge of prior.edges) {
|
|
162
|
+
if (currentNodeIds.has(edge.sourceId)) {
|
|
163
|
+
insertEdge.run(edge.id, fileId, edge.sourceId, edge.targetId, edge.confidence, edge.metadataJson, fileId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function queueFileEnrichment(db, repositoryId, fileId, sourceHash) {
|
|
168
|
+
db.prepare(`
|
|
169
|
+
INSERT INTO file_enrichment_state(
|
|
170
|
+
repository_id, file_id, source_hash, state, diagnostic, queued_at, updated_at, completed_at, failed_at
|
|
171
|
+
) VALUES (?, ?, ?, 'pending', NULL, datetime('now'), datetime('now'), NULL, NULL)
|
|
172
|
+
ON CONFLICT(file_id) DO UPDATE SET
|
|
173
|
+
repository_id = excluded.repository_id,
|
|
174
|
+
source_hash = excluded.source_hash,
|
|
175
|
+
state = 'pending',
|
|
176
|
+
diagnostic = NULL,
|
|
177
|
+
queued_at = datetime('now'),
|
|
178
|
+
updated_at = datetime('now'),
|
|
179
|
+
completed_at = NULL,
|
|
180
|
+
failed_at = NULL
|
|
181
|
+
`).run(repositoryId, fileId, sourceHash);
|
|
182
|
+
}
|
|
183
|
+
function replaceAttribution(db, repository, fileId, attribution) {
|
|
184
|
+
upsertContributorNodes(db, repository, attribution.contributorEmails);
|
|
185
|
+
db.prepare("DELETE FROM edges WHERE file_id = ? AND kind = 'attributed_to'").run(fileId);
|
|
186
|
+
db.prepare('UPDATE nodes SET last_modified_user_id = NULL WHERE file_id = ?').run(fileId);
|
|
187
|
+
const updateLastModified = db.prepare('UPDATE nodes SET last_modified_user_id = ? WHERE id = ? AND file_id = ?');
|
|
188
|
+
const insertEdge = db.prepare(`
|
|
189
|
+
INSERT INTO edges(id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, 'attributed_to', 1, ?)
|
|
191
|
+
`);
|
|
192
|
+
for (const entry of attribution.nodeAttributions) {
|
|
193
|
+
const lastModifiedUserId = resolveUserId(repository, entry.lastModifiedEmail);
|
|
194
|
+
if (lastModifiedUserId)
|
|
195
|
+
updateLastModified.run(lastModifiedUserId, entry.nodeId, fileId);
|
|
196
|
+
for (const email of entry.contributorEmails) {
|
|
197
|
+
const contributorId = resolveUserId(repository, email);
|
|
198
|
+
if (!contributorId)
|
|
199
|
+
continue;
|
|
200
|
+
insertEdge.run(hashParts([repository.id, fileId, entry.nodeId, contributorId, 'attributed_to']), repository.id, fileId, entry.nodeId, contributorId, JSON.stringify({ email: normalizeEmail(email) }));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function setFileEnrichmentState(db, repositoryId, fileId, state, diagnostic) {
|
|
205
|
+
db.prepare(`
|
|
206
|
+
UPDATE file_enrichment_state
|
|
207
|
+
SET state = ?, diagnostic = ?, updated_at = datetime('now'),
|
|
208
|
+
completed_at = CASE WHEN ? = 'complete' THEN datetime('now') ELSE NULL END,
|
|
209
|
+
failed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END
|
|
210
|
+
WHERE repository_id = ? AND file_id = ?
|
|
211
|
+
`).run(state, diagnostic, state, state, repositoryId, fileId);
|
|
87
212
|
}
|
|
88
213
|
function upsertContributorNodes(db, repository, contributorEmails) {
|
|
89
214
|
const insertNode = db.prepare(`
|