@psnext/lscg 0.1.1
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 +143 -0
- package/dist/bin/lscg.d.ts +3 -0
- package/dist/bin/lscg.js +11 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +524 -0
- package/dist/src/config/paths.d.ts +12 -0
- package/dist/src/config/paths.js +54 -0
- package/dist/src/graph/attribution.d.ts +8 -0
- package/dist/src/graph/attribution.js +112 -0
- package/dist/src/graph/extract.d.ts +4 -0
- package/dist/src/graph/extract.js +199 -0
- package/dist/src/graph/repository.d.ts +93 -0
- package/dist/src/graph/repository.js +361 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +5 -0
- package/dist/src/mcp/server.d.ts +6 -0
- package/dist/src/mcp/server.js +81 -0
- package/dist/src/parser/treeSitter.d.ts +13 -0
- package/dist/src/parser/treeSitter.js +61 -0
- package/dist/src/scanner/discover.d.ts +4 -0
- package/dist/src/scanner/discover.js +62 -0
- package/dist/src/scanner/fingerprint.d.ts +3 -0
- package/dist/src/scanner/fingerprint.js +27 -0
- package/dist/src/storage/database.d.ts +72 -0
- package/dist/src/storage/database.js +563 -0
- package/dist/src/storage/schema.d.ts +4 -0
- package/dist/src/storage/schema.js +93 -0
- package/dist/src/types.d.ts +233 -0
- package/dist/src/types.js +2 -0
- package/dist/src/view/index.d.ts +27 -0
- package/dist/src/view/index.js +42 -0
- package/dist/src/view/layout.d.ts +28 -0
- package/dist/src/view/layout.js +235 -0
- package/dist/src/view/model.d.ts +64 -0
- package/dist/src/view/model.js +396 -0
- package/dist/src/view/open.d.ts +15 -0
- package/dist/src/view/open.js +37 -0
- package/dist/src/view/render.d.ts +9 -0
- package/dist/src/view/render.js +321 -0
- package/dist/src/watch.d.ts +40 -0
- package/dist/src/watch.js +118 -0
- package/package.json +65 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { databasePathForScope, homeDatabasePath, normalizeScope, repoDatabasePath, resolveProjectRoot } from '../config/paths.js';
|
|
5
|
+
import { parseSource } from '../parser/treeSitter.js';
|
|
6
|
+
import { discoverSourceFiles } from '../scanner/discover.js';
|
|
7
|
+
import { collectRepositoryFingerprint } from '../scanner/fingerprint.js';
|
|
8
|
+
import { attachDatabase, closeDatabase, getStatus, openGraphDatabase, replaceFileGraph, runSelect, selectCallGraph, selectEdges, selectNeighbors, selectNodes, selectNodeTextRows, upsertRepository, reconcileDeletedFiles, selectFileInventory, selectContextCandidates, selectContextRelationships } from '../storage/database.js';
|
|
9
|
+
import { extractGraph, hashParts } from './extract.js';
|
|
10
|
+
import { buildFileAttribution } from './attribution.js';
|
|
11
|
+
export function repositoryForRoot(root = process.cwd()) {
|
|
12
|
+
const resolvedRoot = resolveProjectRoot(root);
|
|
13
|
+
return {
|
|
14
|
+
id: hashParts(['repository', resolvedRoot]),
|
|
15
|
+
root: resolvedRoot,
|
|
16
|
+
name: path.basename(resolvedRoot)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function initGraph({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
20
|
+
const repository = repositoryForRoot(root);
|
|
21
|
+
const scopes = normalizeScope(scope);
|
|
22
|
+
const initialized = [];
|
|
23
|
+
for (const graphScope of scopes) {
|
|
24
|
+
const databasePath = databasePathForScope(graphScope, repository.root);
|
|
25
|
+
const db = openGraphDatabase(databasePath);
|
|
26
|
+
try {
|
|
27
|
+
upsertRepository(db, repository);
|
|
28
|
+
initialized.push({ scope: graphScope, databasePath });
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
closeDatabase(db);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { repository, initialized };
|
|
35
|
+
}
|
|
36
|
+
export async function scanRepository({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
37
|
+
const repository = repositoryForRoot(root);
|
|
38
|
+
const scopes = normalizeScope(scope);
|
|
39
|
+
const files = discoverSourceFiles(repository.root);
|
|
40
|
+
const opened = scopes.map((graphScope) => {
|
|
41
|
+
const databasePath = databasePathForScope(graphScope, repository.root);
|
|
42
|
+
return { scope: graphScope, databasePath, db: openGraphDatabase(databasePath) };
|
|
43
|
+
});
|
|
44
|
+
let filesScanned = 0;
|
|
45
|
+
let nodesWritten = 0;
|
|
46
|
+
let edgesWritten = 0;
|
|
47
|
+
const skipped = [];
|
|
48
|
+
try {
|
|
49
|
+
for (const handle of opened)
|
|
50
|
+
upsertRepository(handle.db, repository);
|
|
51
|
+
for (const relativePath of files) {
|
|
52
|
+
const absolutePath = path.join(repository.root, relativePath);
|
|
53
|
+
const source = readFileSync(absolutePath, 'utf8');
|
|
54
|
+
const parseResult = parseSource(absolutePath, source);
|
|
55
|
+
if (!parseResult) {
|
|
56
|
+
skipped.push(relativePath);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const stat = statSync(absolutePath);
|
|
60
|
+
const sourceHash = sha256(source);
|
|
61
|
+
const fileId = hashParts([repository.id, relativePath]);
|
|
62
|
+
const file = {
|
|
63
|
+
id: fileId,
|
|
64
|
+
path: relativePath,
|
|
65
|
+
language: parseResult.language,
|
|
66
|
+
hash: sourceHash,
|
|
67
|
+
size: stat.size,
|
|
68
|
+
mtimeMs: Math.round(stat.mtimeMs)
|
|
69
|
+
};
|
|
70
|
+
const graph = extractGraph({
|
|
71
|
+
repositoryId: repository.id,
|
|
72
|
+
fileId,
|
|
73
|
+
relativePath,
|
|
74
|
+
source,
|
|
75
|
+
sourceHash,
|
|
76
|
+
parseResult
|
|
77
|
+
});
|
|
78
|
+
const attribution = buildFileAttribution({
|
|
79
|
+
root: repository.root,
|
|
80
|
+
relativePath,
|
|
81
|
+
source,
|
|
82
|
+
nodes: graph.nodes
|
|
83
|
+
});
|
|
84
|
+
for (const handle of opened) {
|
|
85
|
+
replaceFileGraph(handle.db, { repository, file, nodes: graph.nodes, edges: graph.edges, attribution });
|
|
86
|
+
}
|
|
87
|
+
filesScanned += 1;
|
|
88
|
+
nodesWritten += graph.nodes.length + (attribution?.contributorEmails.length ?? 0);
|
|
89
|
+
edgesWritten += graph.edges.length + (attribution?.nodeAttributions.reduce((sum, entry) => sum + entry.contributorEmails.length, 0) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
for (const handle of opened)
|
|
94
|
+
closeDatabase(handle.db);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
repository,
|
|
98
|
+
scopes: opened.map(({ scope: graphScope, databasePath }) => ({ scope: graphScope, databasePath })),
|
|
99
|
+
filesDiscovered: files.length,
|
|
100
|
+
filesScanned,
|
|
101
|
+
nodesWritten,
|
|
102
|
+
edgesWritten,
|
|
103
|
+
skipped
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function graphStatus({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
107
|
+
const repository = repositoryForRoot(root);
|
|
108
|
+
return withScopes(repository, scope, (db, graphScope, databasePath) => ({
|
|
109
|
+
scope: graphScope,
|
|
110
|
+
databasePath,
|
|
111
|
+
...getStatus(db, repository.id)
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
export function listNodes({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
|
|
115
|
+
const repository = repositoryForRoot(root);
|
|
116
|
+
return withScopes(repository, scope, (db, graphScope) => selectNodes(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
|
|
117
|
+
}
|
|
118
|
+
export function listNodeText({ root = process.cwd(), scope = 'repo', kind, term, limit = 50 } = {}) {
|
|
119
|
+
const repository = repositoryForRoot(root);
|
|
120
|
+
return withScopes(repository, scope, (db, graphScope) => selectNodeTextRows(db, repository.id, { kind, term, limit }).map((row) => ({
|
|
121
|
+
scope: graphScope,
|
|
122
|
+
root: repository.root,
|
|
123
|
+
...row,
|
|
124
|
+
path: row.path ? path.resolve(repository.root, row.path) : null
|
|
125
|
+
}))).flat();
|
|
126
|
+
}
|
|
127
|
+
export function listEdges({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
|
|
128
|
+
const repository = repositoryForRoot(root);
|
|
129
|
+
return withScopes(repository, scope, (db, graphScope) => selectEdges(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
|
|
130
|
+
}
|
|
131
|
+
export function neighbors({ root = process.cwd(), scope = 'repo', nodeId, depth = 1, limit = 100 } = {}) {
|
|
132
|
+
if (!nodeId)
|
|
133
|
+
throw new Error('neighbors requires a nodeId');
|
|
134
|
+
const repository = repositoryForRoot(root);
|
|
135
|
+
return withScopes(repository, scope, (db, graphScope) => selectNeighbors(db, repository.id, nodeId, { depth, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
|
|
136
|
+
}
|
|
137
|
+
export function callGraph({ root = process.cwd(), scope = 'repo', term, kind, depth = 5, limit = 100 } = {}) {
|
|
138
|
+
if (!term?.trim())
|
|
139
|
+
throw new Error('callgraph requires a term');
|
|
140
|
+
const repository = repositoryForRoot(root);
|
|
141
|
+
return withScopes(repository, scope, (db, graphScope) => selectCallGraph(db, repository.id, term.trim(), { kind, depth, limit }).map((row) => ({ scope: graphScope, ...row }))).flat();
|
|
142
|
+
}
|
|
143
|
+
const CONTEXT_DEFAULTS = {
|
|
144
|
+
depth: 2,
|
|
145
|
+
relationshipLimit: 50,
|
|
146
|
+
candidateLimit: 20,
|
|
147
|
+
excerptLines: 80,
|
|
148
|
+
excerptBytes: 12_000
|
|
149
|
+
};
|
|
150
|
+
export async function contextGraph(options) {
|
|
151
|
+
if (!options.symbol?.trim())
|
|
152
|
+
throw new Error('context requires a symbol');
|
|
153
|
+
const repository = repositoryForRoot(options.root);
|
|
154
|
+
const scopeValue = options.scope ?? 'repo';
|
|
155
|
+
const budget = {
|
|
156
|
+
depth: clampContext(options.depth, 0, 5, CONTEXT_DEFAULTS.depth),
|
|
157
|
+
relationshipLimit: clampContext(options.limit, 1, 500, CONTEXT_DEFAULTS.relationshipLimit),
|
|
158
|
+
candidateLimit: clampContext(options.candidateLimit, 1, 100, CONTEXT_DEFAULTS.candidateLimit),
|
|
159
|
+
excerptLines: clampContext(options.excerptLines, 1, 500, CONTEXT_DEFAULTS.excerptLines),
|
|
160
|
+
excerptBytes: clampContext(options.excerptBytes, 1, 100_000, CONTEXT_DEFAULTS.excerptBytes)
|
|
161
|
+
};
|
|
162
|
+
const request = {
|
|
163
|
+
symbol: options.symbol.trim(),
|
|
164
|
+
root: repository.root,
|
|
165
|
+
scope: scopeValue,
|
|
166
|
+
...(options.kind ? { kind: options.kind } : {}),
|
|
167
|
+
...(options.file ? { file: options.file } : {}),
|
|
168
|
+
budget,
|
|
169
|
+
excerpts: Boolean(options.excerpts)
|
|
170
|
+
};
|
|
171
|
+
const scopes = normalizeScope(scopeValue);
|
|
172
|
+
const scopeResults = [];
|
|
173
|
+
for (const graphScope of scopes) {
|
|
174
|
+
scopeResults.push(await contextForScope(repository, graphScope, request, options.excerpts === true));
|
|
175
|
+
}
|
|
176
|
+
return { contract_version: 1, repository, request, scopes: scopeResults };
|
|
177
|
+
}
|
|
178
|
+
async function contextForScope(repository, graphScope, request, excerpts) {
|
|
179
|
+
const databasePath = databasePathForScope(graphScope, repository.root);
|
|
180
|
+
const discovered = discoverSourceFiles(repository.root);
|
|
181
|
+
const beforeFingerprint = collectRepositoryFingerprint(repository.root);
|
|
182
|
+
let db = openGraphDatabase(databasePath);
|
|
183
|
+
let refresh = {
|
|
184
|
+
outcome: 'reused', filesDiscovered: discovered.length, filesScanned: 0, skipped: []
|
|
185
|
+
};
|
|
186
|
+
let freshness = 'unchanged';
|
|
187
|
+
let priorGraph = false;
|
|
188
|
+
try {
|
|
189
|
+
upsertRepository(db, repository);
|
|
190
|
+
const inventory = selectFileInventory(db, repository.id);
|
|
191
|
+
priorGraph = inventory.length > 0;
|
|
192
|
+
const inventoryChanged = inventory.length !== discovered.length || discovered.some((file, index) => {
|
|
193
|
+
const row = inventory[index];
|
|
194
|
+
if (!row || row.path !== file)
|
|
195
|
+
return true;
|
|
196
|
+
try {
|
|
197
|
+
const stat = statSync(path.join(repository.root, file));
|
|
198
|
+
return row.size !== stat.size || row.mtimeMs !== Math.round(stat.mtimeMs);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
if (inventoryChanged) {
|
|
205
|
+
closeDatabase(db);
|
|
206
|
+
db = undefined;
|
|
207
|
+
try {
|
|
208
|
+
const summary = await scanRepository({ root: repository.root, scope: graphScope });
|
|
209
|
+
refresh = {
|
|
210
|
+
outcome: summary.skipped.length > 0 ? 'refresh_failed' : 'refreshed',
|
|
211
|
+
filesDiscovered: summary.filesDiscovered,
|
|
212
|
+
filesScanned: summary.filesScanned,
|
|
213
|
+
skipped: summary.skipped
|
|
214
|
+
};
|
|
215
|
+
if (summary.skipped.length > 0)
|
|
216
|
+
return failedContextScope(graphScope, databasePath, refresh, priorGraph);
|
|
217
|
+
const refreshedDb = openGraphDatabase(databasePath);
|
|
218
|
+
try {
|
|
219
|
+
reconcileDeletedFiles(refreshedDb, repository.id, discovered);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
closeDatabase(refreshedDb);
|
|
223
|
+
}
|
|
224
|
+
freshness = 'refreshed';
|
|
225
|
+
db = openGraphDatabase(databasePath);
|
|
226
|
+
upsertRepository(db, repository);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
refresh = { ...refresh, outcome: 'refresh_failed', skipped: [error instanceof Error ? error.message : String(error)] };
|
|
230
|
+
return failedContextScope(graphScope, databasePath, refresh, priorGraph);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const afterFingerprint = collectRepositoryFingerprint(repository.root);
|
|
234
|
+
if (afterFingerprint !== beforeFingerprint) {
|
|
235
|
+
return failedContextScope(graphScope, databasePath, refresh, priorGraph, 'stale');
|
|
236
|
+
}
|
|
237
|
+
const candidateRows = selectContextCandidates(db, repository.id, request.symbol, {
|
|
238
|
+
kind: request.kind,
|
|
239
|
+
file: request.file,
|
|
240
|
+
limit: request.budget.candidateLimit + 1
|
|
241
|
+
});
|
|
242
|
+
const candidateTruncated = candidateRows.length > request.budget.candidateLimit;
|
|
243
|
+
const candidates = candidateRows.slice(0, request.budget.candidateLimit);
|
|
244
|
+
const truncation = candidateTruncated
|
|
245
|
+
? [{ section: 'candidates', reason: 'candidate_limit', omitted: 1 }]
|
|
246
|
+
: [];
|
|
247
|
+
if (candidates.length === 0) {
|
|
248
|
+
return { scope: graphScope, databasePath, result: { state: 'not_found', impact: [], dependencies: [], warnings: [], truncation }, freshness: { state: freshness, verification: 'metadata_only', refresh } };
|
|
249
|
+
}
|
|
250
|
+
if (candidateTruncated || candidates.length !== 1) {
|
|
251
|
+
return { scope: graphScope, databasePath, result: { state: 'ambiguous', candidates: candidates.map(stripCandidate), impact: [], dependencies: [], warnings: [], truncation }, freshness: { state: freshness, verification: 'metadata_only', refresh } };
|
|
252
|
+
}
|
|
253
|
+
const anchor = stripInternalReference(candidates[0]);
|
|
254
|
+
const rawRelations = request.budget.depth === 0
|
|
255
|
+
? []
|
|
256
|
+
: selectContextRelationships(db, repository.id, anchor.id, request.budget.relationshipLimit, request.budget.depth);
|
|
257
|
+
const impact = [];
|
|
258
|
+
const dependencies = [];
|
|
259
|
+
for (const relation of rawRelations) {
|
|
260
|
+
const source = stripInternalReference(relation.source);
|
|
261
|
+
const target = stripInternalReference(relation.target);
|
|
262
|
+
if (relation.edgeKind === 'calls') {
|
|
263
|
+
impact.push({ category: 'impact', edgeKind: relation.edgeKind, direction: relation.source.id === anchor.id ? 'callee' : 'caller', source, target, confidence: relation.confidence, provenance: relation.metadata, depth: relation.depth });
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
dependencies.push({ category: 'dependency', edgeKind: relation.edgeKind, direction: relation.edgeKind === 'imports' ? 'import' : 'export', source, target, confidence: relation.confidence, provenance: relation.metadata, depth: relation.depth });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const warnings = candidates[0]?.match === 'substring' ? ['substring_search=bounded_like'] : [];
|
|
270
|
+
let verification = 'metadata_only';
|
|
271
|
+
if (excerpts) {
|
|
272
|
+
const excerptStatus = await addExcerpt(repository.root, anchor, request.budget);
|
|
273
|
+
if (excerptStatus === 'checked')
|
|
274
|
+
verification = 'content_hash';
|
|
275
|
+
if (excerptStatus === 'unavailable')
|
|
276
|
+
warnings.push('excerpt_unavailable');
|
|
277
|
+
if (excerptStatus === 'mismatch')
|
|
278
|
+
warnings.push('excerpt_unavailable:source_hash_mismatch');
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
scope: graphScope,
|
|
282
|
+
databasePath,
|
|
283
|
+
result: { state: 'ok', anchor, definition: anchor, impact, dependencies, warnings, truncation },
|
|
284
|
+
freshness: { state: freshness, verification, refresh }
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
if (db)
|
|
289
|
+
closeDatabase(db);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function failedContextScope(scope, databasePath, refresh, priorGeneration, state = 'refresh_failed') {
|
|
293
|
+
return {
|
|
294
|
+
scope,
|
|
295
|
+
databasePath,
|
|
296
|
+
result: { state, impact: [], dependencies: [], warnings: refresh.skipped, truncation: [] },
|
|
297
|
+
freshness: { state: state === 'stale' || priorGeneration ? 'stale' : 'refresh_failed', verification: 'metadata_only', refresh: { ...refresh, outcome: state === 'stale' ? refresh.outcome : 'refresh_failed' } }
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function stripCandidate(candidate) {
|
|
301
|
+
const { fileId: _fileId, ...publicCandidate } = candidate;
|
|
302
|
+
return publicCandidate;
|
|
303
|
+
}
|
|
304
|
+
function stripInternalReference(reference) {
|
|
305
|
+
const { fileId: _fileId, rank: _rank, match: _match, ...publicReference } = reference;
|
|
306
|
+
return publicReference;
|
|
307
|
+
}
|
|
308
|
+
async function addExcerpt(root, anchor, budget) {
|
|
309
|
+
if (!anchor.path || !anchor.sourceHash)
|
|
310
|
+
return 'unavailable';
|
|
311
|
+
try {
|
|
312
|
+
const source = readFileSync(path.join(root, anchor.path), 'utf8');
|
|
313
|
+
if (sha256(source) !== anchor.sourceHash)
|
|
314
|
+
return 'mismatch';
|
|
315
|
+
const lines = source.split(/\r?\n/);
|
|
316
|
+
const start = Math.max(0, anchor.span.start.row);
|
|
317
|
+
const end = Math.min(lines.length, start + budget.excerptLines);
|
|
318
|
+
const bounded = Buffer.from(lines.slice(start, end).join('\\n'), 'utf8').subarray(0, budget.excerptBytes).toString('utf8');
|
|
319
|
+
anchor.excerpt = bounded;
|
|
320
|
+
return 'checked';
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return 'unavailable';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function clampContext(value, min, max, fallback) {
|
|
327
|
+
if (value === undefined || !Number.isFinite(value))
|
|
328
|
+
return fallback;
|
|
329
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
330
|
+
}
|
|
331
|
+
export function runReadOnlySql({ root = process.cwd(), scope = 'repo', sql, attachHome = false, limit = 200 } = {}) {
|
|
332
|
+
if (!sql)
|
|
333
|
+
throw new Error('query requires SQL text');
|
|
334
|
+
const repository = repositoryForRoot(root);
|
|
335
|
+
return withScopes(repository, scope, (db, graphScope) => {
|
|
336
|
+
if (attachHome && graphScope === 'repo')
|
|
337
|
+
attachDatabase(db, 'home_graph', homeDatabasePath());
|
|
338
|
+
const rows = runSelect(db, sql, { limit });
|
|
339
|
+
return { scope: graphScope, rows };
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function withScopes(repository, scope, fn) {
|
|
343
|
+
const scopes = normalizeScope(scope);
|
|
344
|
+
const results = [];
|
|
345
|
+
for (const graphScope of scopes) {
|
|
346
|
+
const databasePath = graphScope === 'repo' ? repoDatabasePath(repository.root) : homeDatabasePath();
|
|
347
|
+
const db = openGraphDatabase(databasePath);
|
|
348
|
+
try {
|
|
349
|
+
upsertRepository(db, repository);
|
|
350
|
+
results.push(fn(db, graphScope, databasePath));
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
closeDatabase(db);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return results;
|
|
357
|
+
}
|
|
358
|
+
function sha256(value) {
|
|
359
|
+
return createHash('sha256').update(value).digest('hex');
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=repository.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { initGraph, listEdges, listNodes, graphStatus, callGraph, neighbors, contextGraph, runReadOnlySql, scanRepository } from './graph/repository.js';
|
|
2
|
+
export { watchRepository } from './watch.js';
|
|
3
|
+
export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
|
|
4
|
+
export { repoDatabasePath, homeDatabasePath, resolveProjectRoot } from './config/paths.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { initGraph, listEdges, listNodes, graphStatus, callGraph, neighbors, contextGraph, runReadOnlySql, scanRepository } from './graph/repository.js';
|
|
2
|
+
export { watchRepository } from './watch.js';
|
|
3
|
+
export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
|
|
4
|
+
export { repoDatabasePath, homeDatabasePath, resolveProjectRoot } from './config/paths.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { graphStatus, listEdges, listNodes, neighbors, runReadOnlySql, scanRepository } from '../graph/repository.js';
|
|
5
|
+
const scopeSchema = z.enum(['repo', 'home', 'both']).default('repo');
|
|
6
|
+
export async function startMcpServer({ root = process.cwd() } = {}) {
|
|
7
|
+
const server = new McpServer({
|
|
8
|
+
name: 'lscg',
|
|
9
|
+
version: '0.1.0'
|
|
10
|
+
});
|
|
11
|
+
const tool = server.tool.bind(server);
|
|
12
|
+
tool('context_graph_scan', 'Parse repository files with Tree-sitter and write discovered graph nodes/edges to SQLite.', {
|
|
13
|
+
root: z.string().optional().describe('Repository root. Defaults to the MCP server cwd.'),
|
|
14
|
+
scope: scopeSchema.describe('Graph storage scope to update.')
|
|
15
|
+
}, async (input) => jsonResponse(await scanRepository({ root: input.root ?? root, scope: input.scope })));
|
|
16
|
+
tool('context_graph_status', 'Return repository graph counts and database paths.', {
|
|
17
|
+
root: z.string().optional(),
|
|
18
|
+
scope: scopeSchema
|
|
19
|
+
}, async (input) => jsonResponse(graphStatus({ root: input.root ?? root, scope: input.scope })));
|
|
20
|
+
tool('context_graph_nodes', 'List graph nodes discovered in the repository.', {
|
|
21
|
+
root: z.string().optional(),
|
|
22
|
+
scope: scopeSchema,
|
|
23
|
+
kind: z.enum(['file', 'symbol', 'import', 'export', 'call', 'user']).optional(),
|
|
24
|
+
limit: z.number().int().positive().max(500).default(50)
|
|
25
|
+
}, async (input) => jsonResponse(listNodes({
|
|
26
|
+
root: input.root ?? root,
|
|
27
|
+
scope: input.scope,
|
|
28
|
+
kind: input.kind,
|
|
29
|
+
limit: input.limit
|
|
30
|
+
})));
|
|
31
|
+
tool('context_graph_edges', 'List graph edges discovered in the repository.', {
|
|
32
|
+
root: z.string().optional(),
|
|
33
|
+
scope: scopeSchema,
|
|
34
|
+
kind: z.enum(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to']).optional(),
|
|
35
|
+
limit: z.number().int().positive().max(500).default(50)
|
|
36
|
+
}, async (input) => jsonResponse(listEdges({
|
|
37
|
+
root: input.root ?? root,
|
|
38
|
+
scope: input.scope,
|
|
39
|
+
kind: input.kind,
|
|
40
|
+
limit: input.limit
|
|
41
|
+
})));
|
|
42
|
+
tool('context_graph_neighbors', 'Return nearby nodes around a graph node id.', {
|
|
43
|
+
root: z.string().optional(),
|
|
44
|
+
scope: scopeSchema,
|
|
45
|
+
nodeId: z.string().min(1),
|
|
46
|
+
depth: z.number().int().positive().max(5).default(1),
|
|
47
|
+
limit: z.number().int().positive().max(500).default(100)
|
|
48
|
+
}, async (input) => jsonResponse(neighbors({
|
|
49
|
+
root: input.root ?? root,
|
|
50
|
+
scope: input.scope,
|
|
51
|
+
nodeId: input.nodeId,
|
|
52
|
+
depth: input.depth,
|
|
53
|
+
limit: input.limit
|
|
54
|
+
})));
|
|
55
|
+
tool('context_graph_query', 'Run a read-only SQLite query against the graph database. Only SELECT, WITH, and PRAGMA are allowed.', {
|
|
56
|
+
root: z.string().optional(),
|
|
57
|
+
scope: scopeSchema,
|
|
58
|
+
sql: z.string().min(1),
|
|
59
|
+
attachHome: z.boolean().default(false).describe('When querying repo scope, ATTACH the home graph as home_graph.'),
|
|
60
|
+
limit: z.number().int().positive().max(1000).default(200)
|
|
61
|
+
}, async (input) => jsonResponse(runReadOnlySql({
|
|
62
|
+
root: input.root ?? root,
|
|
63
|
+
scope: input.scope,
|
|
64
|
+
sql: input.sql,
|
|
65
|
+
attachHome: input.attachHome,
|
|
66
|
+
limit: input.limit
|
|
67
|
+
})));
|
|
68
|
+
const transport = new StdioServerTransport();
|
|
69
|
+
await server.connect(transport);
|
|
70
|
+
}
|
|
71
|
+
function jsonResponse(value) {
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: 'text',
|
|
76
|
+
text: JSON.stringify(value, null, 2)
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ParseResult } from '../types.js';
|
|
2
|
+
type LanguageConfig = {
|
|
3
|
+
name: string;
|
|
4
|
+
parserVersion: string;
|
|
5
|
+
extensions: string[];
|
|
6
|
+
language: any;
|
|
7
|
+
};
|
|
8
|
+
export declare function supportedExtensions(): string[];
|
|
9
|
+
export declare function languageForPath(filePath: string): LanguageConfig | undefined;
|
|
10
|
+
export declare function isSupportedSourceFile(filePath: string): boolean;
|
|
11
|
+
export declare function parseSource(filePath: string, source: string): ParseResult | null;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=treeSitter.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import Parser from 'tree-sitter';
|
|
3
|
+
import JavaScriptLanguage from 'tree-sitter-javascript';
|
|
4
|
+
import TypeScriptLanguages from 'tree-sitter-typescript';
|
|
5
|
+
const typeScriptModule = TypeScriptLanguages.default ?? TypeScriptLanguages;
|
|
6
|
+
const LANGUAGE_CONFIGS = [
|
|
7
|
+
{
|
|
8
|
+
name: 'javascript',
|
|
9
|
+
parserVersion: 'tree-sitter-javascript',
|
|
10
|
+
extensions: ['.js', '.mjs', '.cjs', '.jsx'],
|
|
11
|
+
language: JavaScriptLanguage.default ?? JavaScriptLanguage
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'typescript',
|
|
15
|
+
parserVersion: 'tree-sitter-typescript',
|
|
16
|
+
extensions: ['.ts', '.mts', '.cts'],
|
|
17
|
+
language: typeScriptModule.typescript ?? typeScriptModule
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'tsx',
|
|
21
|
+
parserVersion: 'tree-sitter-typescript-tsx',
|
|
22
|
+
extensions: ['.tsx'],
|
|
23
|
+
language: typeScriptModule.tsx ?? typeScriptModule.typescript ?? typeScriptModule
|
|
24
|
+
}
|
|
25
|
+
];
|
|
26
|
+
const byExtension = new Map();
|
|
27
|
+
for (const config of LANGUAGE_CONFIGS) {
|
|
28
|
+
for (const extension of config.extensions) {
|
|
29
|
+
byExtension.set(extension, config);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const parserCache = new Map();
|
|
33
|
+
export function supportedExtensions() {
|
|
34
|
+
return [...byExtension.keys()];
|
|
35
|
+
}
|
|
36
|
+
export function languageForPath(filePath) {
|
|
37
|
+
return byExtension.get(path.extname(filePath));
|
|
38
|
+
}
|
|
39
|
+
export function isSupportedSourceFile(filePath) {
|
|
40
|
+
return Boolean(languageForPath(filePath));
|
|
41
|
+
}
|
|
42
|
+
export function parseSource(filePath, source) {
|
|
43
|
+
const config = languageForPath(filePath);
|
|
44
|
+
if (!config)
|
|
45
|
+
return null;
|
|
46
|
+
let parser = parserCache.get(config.name);
|
|
47
|
+
if (!parser) {
|
|
48
|
+
parser = new Parser();
|
|
49
|
+
parser.setLanguage(config.language);
|
|
50
|
+
parserCache.set(config.name, parser);
|
|
51
|
+
}
|
|
52
|
+
let tree;
|
|
53
|
+
try {
|
|
54
|
+
tree = parser.parse(source);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return { tree, language: config.name, parser: 'tree-sitter', parserVersion: config.parserVersion };
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=treeSitter.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isSupportedSourceFile } from '../parser/treeSitter.js';
|
|
5
|
+
const DEFAULT_IGNORES = new Set([
|
|
6
|
+
'.git',
|
|
7
|
+
'.sling',
|
|
8
|
+
'.pi-subagents',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'dist',
|
|
11
|
+
'coverage',
|
|
12
|
+
'.next',
|
|
13
|
+
'.turbo',
|
|
14
|
+
'.cache'
|
|
15
|
+
]);
|
|
16
|
+
export function discoverSourceFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
|
|
17
|
+
const files = [];
|
|
18
|
+
walk(root, root, files, ignores);
|
|
19
|
+
return files.sort();
|
|
20
|
+
}
|
|
21
|
+
function walk(root, current, files, ignores) {
|
|
22
|
+
const entries = readdirSync(current, { withFileTypes: true });
|
|
23
|
+
const relativePaths = entries.map((entry) => path.relative(root, path.join(current, entry.name)));
|
|
24
|
+
const gitIgnored = ignoredByGit(root, relativePaths);
|
|
25
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
26
|
+
const entry = entries[index];
|
|
27
|
+
if (!entry)
|
|
28
|
+
continue;
|
|
29
|
+
if (ignores.has(entry.name))
|
|
30
|
+
continue;
|
|
31
|
+
const fullPath = path.join(current, entry.name);
|
|
32
|
+
const relativePath = relativePaths[index];
|
|
33
|
+
if (!relativePath || gitIgnored.has(relativePath))
|
|
34
|
+
continue;
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
walk(root, fullPath, files, ignores);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!entry.isFile())
|
|
40
|
+
continue;
|
|
41
|
+
if (!isSupportedSourceFile(fullPath))
|
|
42
|
+
continue;
|
|
43
|
+
const stat = statSync(fullPath);
|
|
44
|
+
if (stat.size > 2_000_000)
|
|
45
|
+
continue;
|
|
46
|
+
files.push(relativePath);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function ignoredByGit(root, relativePaths) {
|
|
50
|
+
if (relativePaths.length === 0)
|
|
51
|
+
return new Set();
|
|
52
|
+
const result = spawnSync('git', ['check-ignore', '--stdin', '-z'], {
|
|
53
|
+
cwd: root,
|
|
54
|
+
input: `${relativePaths.join('\0')}\0`,
|
|
55
|
+
encoding: 'utf8'
|
|
56
|
+
});
|
|
57
|
+
if (result.error || (result.status !== 0 && result.status !== 1)) {
|
|
58
|
+
return new Set();
|
|
59
|
+
}
|
|
60
|
+
return new Set(result.stdout.split('\0').filter(Boolean));
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=discover.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { discoverSourceFiles } from './discover.js';
|
|
5
|
+
/** Fast metadata fingerprint shared by watch and context freshness checks. */
|
|
6
|
+
export function collectRepositoryFingerprint(root) {
|
|
7
|
+
const files = discoverSourceFiles(root);
|
|
8
|
+
const hash = createHash('sha256');
|
|
9
|
+
for (const relativePath of files) {
|
|
10
|
+
const absolutePath = path.join(root, relativePath);
|
|
11
|
+
try {
|
|
12
|
+
const stat = statSync(absolutePath);
|
|
13
|
+
hash.update(relativePath);
|
|
14
|
+
hash.update('\0');
|
|
15
|
+
hash.update(String(stat.size));
|
|
16
|
+
hash.update('\0');
|
|
17
|
+
hash.update(String(Math.round(stat.mtimeMs)));
|
|
18
|
+
hash.update('\0');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
hash.update(relativePath);
|
|
22
|
+
hash.update('\0missing\0');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return hash.digest('hex');
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=fingerprint.js.map
|