@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,321 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export function renderSvgMarkup(model, options = {}) {
|
|
5
|
+
const nodes = options.includeHiddenNodes ? model.fullNodes : model.nodes;
|
|
6
|
+
const edges = options.includeHiddenNodes ? model.fullEdges : model.edges;
|
|
7
|
+
const baseViewBox = options.includeHiddenNodes ? model.fullInitialViewBox : model.initialViewBox;
|
|
8
|
+
const nodePresentations = nodes.map((node) => ({
|
|
9
|
+
node,
|
|
10
|
+
presentation: nodePresentation(node)
|
|
11
|
+
}));
|
|
12
|
+
const viewBox = expandViewBox(baseViewBox, Math.max(64, ...nodePresentations.map(({ presentation }) => presentation.padding)));
|
|
13
|
+
const width = Math.max(Math.round(viewBox.width), 1);
|
|
14
|
+
const height = Math.max(Math.round(viewBox.height), 1);
|
|
15
|
+
const graphLabel = `${model.repository.name} store graph`;
|
|
16
|
+
const title = options.includeHiddenNodes ? `${graphLabel} · full graph` : graphLabel;
|
|
17
|
+
const mode = options.interactive ? 'interactive' : 'snapshot';
|
|
18
|
+
const renderedEdges = edges
|
|
19
|
+
.map((edge) => {
|
|
20
|
+
const source = nodes.find((node) => node.id === edge.sourceId);
|
|
21
|
+
const target = nodes.find((node) => node.id === edge.targetId);
|
|
22
|
+
if (!source || !target)
|
|
23
|
+
return '';
|
|
24
|
+
return `
|
|
25
|
+
<line class="edge edge--${escapeHtml(edge.kind)}" x1="${formatNumber(source.x)}" y1="${formatNumber(source.y)}" x2="${formatNumber(target.x)}" y2="${formatNumber(target.y)}" marker-end="url(#arrow-${mode})" />`;
|
|
26
|
+
})
|
|
27
|
+
.join('');
|
|
28
|
+
const renderedNodes = nodes
|
|
29
|
+
.map((node) => {
|
|
30
|
+
const presentation = nodePresentation(node);
|
|
31
|
+
const labelY = node.kind === 'file' ? 0 : presentation.height / 2 + 14;
|
|
32
|
+
return `
|
|
33
|
+
<g class="node node--${escapeHtml(node.kind)}" data-node-id="${escapeHtml(node.id)}" data-node-kind="${escapeHtml(node.kind)}" data-node-shape="${escapeHtml(presentation.shape)}" transform="translate(${formatNumber(node.x)},${formatNumber(node.y)})">
|
|
34
|
+
${renderNodeShapeMarkup(node, presentation)}
|
|
35
|
+
<text class="node-label" text-anchor="middle" dominant-baseline="${node.kind === 'file' ? 'middle' : 'hanging'}" y="${formatNumber(labelY)}">${escapeHtml(node.label)}</text>
|
|
36
|
+
<title>${escapeHtml(node.label)} (${escapeHtml(node.id)})</title>
|
|
37
|
+
</g>`;
|
|
38
|
+
})
|
|
39
|
+
.join('');
|
|
40
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${formatNumber(viewBox.x)} ${formatNumber(viewBox.y)} ${formatNumber(viewBox.width)} ${formatNumber(viewBox.height)}" width="${width}" height="${height}" role="img" aria-labelledby="title desc">
|
|
42
|
+
<title id="title">${escapeHtml(title)}</title>
|
|
43
|
+
<desc id="desc">${escapeHtml(`Force-directed graph for ${model.repository.name} in ${model.scope} scope.`)}</desc>
|
|
44
|
+
<defs>
|
|
45
|
+
<marker id="arrow-${mode}" viewBox="0 0 10 10" refX="7.5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
46
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8" />
|
|
47
|
+
</marker>
|
|
48
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
49
|
+
<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#0f172a" flood-opacity="0.18" />
|
|
50
|
+
</filter>
|
|
51
|
+
</defs>
|
|
52
|
+
<style>
|
|
53
|
+
:root { color-scheme: dark; }
|
|
54
|
+
svg { background: #020617; }
|
|
55
|
+
.edge { stroke: #64748b; stroke-opacity: 0.5; stroke-width: 1.25; }
|
|
56
|
+
.edge--imports { stroke: #f59e0b; }
|
|
57
|
+
.edge--exports { stroke: #a855f7; }
|
|
58
|
+
.edge--calls { stroke: #fb7185; }
|
|
59
|
+
.edge--defines { stroke: #22c55e; }
|
|
60
|
+
.edge--contains { stroke: #38bdf8; }
|
|
61
|
+
.node { filter: url(#shadow); }
|
|
62
|
+
.node-shape { stroke: #e2e8f0; stroke-opacity: 0.12; stroke-width: 1.5; shape-rendering: geometricPrecision; }
|
|
63
|
+
.node-label { fill: #e2e8f0; font: 11px/1.4 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 600; paint-order: stroke; stroke: #020617; stroke-width: 3px; stroke-linejoin: round; }
|
|
64
|
+
</style>
|
|
65
|
+
<g class="edges">${renderedEdges}
|
|
66
|
+
</g>
|
|
67
|
+
<g class="nodes">${renderedNodes}
|
|
68
|
+
</g>
|
|
69
|
+
</svg>`;
|
|
70
|
+
}
|
|
71
|
+
const interactiveHtmlTemplate = loadTemplateAsset('interactive.html');
|
|
72
|
+
const interactiveCss = loadTemplateAsset('interactive.css');
|
|
73
|
+
export function renderInteractiveHtml(model) {
|
|
74
|
+
const data = serializeInteractiveGraph(model);
|
|
75
|
+
const graphTitle = `${model.repository.name} store graph`;
|
|
76
|
+
const subtitle = model.anchorNodeId
|
|
77
|
+
? `Anchored on ${model.nodes.find((node) => node.id === model.anchorNodeId)?.label ?? model.anchorNodeId}`
|
|
78
|
+
: 'Showing entire graph';
|
|
79
|
+
const searchTerm = escapeHtml(model.search ?? '');
|
|
80
|
+
return interactiveHtmlTemplate
|
|
81
|
+
.replace('{{CSS}}', interactiveCss)
|
|
82
|
+
.replaceAll('{{GRAPH_TITLE}}', escapeHtml(graphTitle))
|
|
83
|
+
.replace('{{SUBTITLE}}', escapeHtml(subtitle))
|
|
84
|
+
.replace('{{LEGEND_ITEMS}}', legendItems())
|
|
85
|
+
.replace('{{DATA}}', data)
|
|
86
|
+
.replace('{{SEARCH_TERM}}', searchTerm);
|
|
87
|
+
}
|
|
88
|
+
function loadTemplateAsset(name) {
|
|
89
|
+
const currentFileDir = path.dirname(fileURLToPath(import.meta.url));
|
|
90
|
+
const candidates = [
|
|
91
|
+
path.resolve(currentFileDir, 'templates', name),
|
|
92
|
+
path.resolve(currentFileDir, '../../../src/view/templates', name),
|
|
93
|
+
path.resolve(process.cwd(), 'src/view/templates', name)
|
|
94
|
+
];
|
|
95
|
+
for (const candidate of candidates) {
|
|
96
|
+
if (existsSync(candidate)) {
|
|
97
|
+
return readFileSync(candidate, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Missing view template asset ${name}. Looked in: ${candidates.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
export function writeSvgSnapshot(filePath, svgMarkup) {
|
|
103
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
104
|
+
writeFileSync(filePath, svgMarkup, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
function serializeInteractiveGraph(model) {
|
|
107
|
+
const nodes = model.fullNodes.map((node) => {
|
|
108
|
+
const presentation = nodePresentation(node);
|
|
109
|
+
return {
|
|
110
|
+
id: node.id,
|
|
111
|
+
kind: node.kind,
|
|
112
|
+
type: node.type,
|
|
113
|
+
name: node.name,
|
|
114
|
+
label: node.label,
|
|
115
|
+
degree: node.degree,
|
|
116
|
+
x: node.x,
|
|
117
|
+
y: node.y,
|
|
118
|
+
color: colorForKind(node.kind),
|
|
119
|
+
shape: presentation.shape,
|
|
120
|
+
width: presentation.width,
|
|
121
|
+
height: presentation.height,
|
|
122
|
+
presentationRadius: presentation.presentationRadius
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
const edges = model.fullEdges.map((edge) => ({
|
|
126
|
+
id: edge.id,
|
|
127
|
+
kind: edge.kind,
|
|
128
|
+
source: edge.sourceId,
|
|
129
|
+
target: edge.targetId,
|
|
130
|
+
confidence: edge.confidence,
|
|
131
|
+
metadataJson: edge.metadataJson
|
|
132
|
+
}));
|
|
133
|
+
return escapeJson(JSON.stringify({
|
|
134
|
+
repository: model.repository,
|
|
135
|
+
scope: model.scope,
|
|
136
|
+
search: model.search,
|
|
137
|
+
anchorNodeId: model.anchorNodeId,
|
|
138
|
+
fullNeighborhoods: model.fullNeighborhoods,
|
|
139
|
+
nodes,
|
|
140
|
+
edges
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
function renderNodeShapeMarkup(node, presentation) {
|
|
144
|
+
const fill = colorForKind(node.kind);
|
|
145
|
+
switch (presentation.shape) {
|
|
146
|
+
case 'rounded-rect':
|
|
147
|
+
return `<rect class="node-shape node-shape--rounded-rect" x="${formatNumber(-presentation.width / 2)}" y="${formatNumber(-presentation.height / 2)}" width="${formatNumber(presentation.width)}" height="${formatNumber(presentation.height)}" rx="10" ry="10" fill="${fill}" />`;
|
|
148
|
+
case 'box':
|
|
149
|
+
return `<rect class="node-shape node-shape--box" x="${formatNumber(-presentation.width / 2)}" y="${formatNumber(-presentation.height / 2)}" width="${formatNumber(presentation.width)}" height="${formatNumber(presentation.height)}" rx="4" ry="4" fill="${fill}" />`;
|
|
150
|
+
case 'ellipse':
|
|
151
|
+
return `<ellipse class="node-shape node-shape--ellipse" cx="0" cy="0" rx="${formatNumber(presentation.width / 2)}" ry="${formatNumber(presentation.height / 2)}" fill="${fill}" />`;
|
|
152
|
+
case 'diamond':
|
|
153
|
+
return `<polygon class="node-shape node-shape--diamond" points="${shapePoints(presentation, 'diamond')}" fill="${fill}" />`;
|
|
154
|
+
case 'hexagon':
|
|
155
|
+
return `<polygon class="node-shape node-shape--hexagon" points="${shapePoints(presentation, 'hexagon')}" fill="${fill}" />`;
|
|
156
|
+
case 'user':
|
|
157
|
+
return renderUserNodeShapeMarkup(presentation, fill);
|
|
158
|
+
default:
|
|
159
|
+
return `<rect class="node-shape" x="${formatNumber(-presentation.width / 2)}" y="${formatNumber(-presentation.height / 2)}" width="${formatNumber(presentation.width)}" height="${formatNumber(presentation.height)}" rx="6" ry="6" fill="${fill}" />`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function shapePoints(presentation, shape) {
|
|
163
|
+
const halfWidth = presentation.width / 2;
|
|
164
|
+
const halfHeight = presentation.height / 2;
|
|
165
|
+
if (shape === 'diamond') {
|
|
166
|
+
return `${formatNumber(0)},${formatNumber(-halfHeight)} ${formatNumber(halfWidth)},${formatNumber(0)} ${formatNumber(0)},${formatNumber(halfHeight)} ${formatNumber(-halfWidth)},${formatNumber(0)}`;
|
|
167
|
+
}
|
|
168
|
+
const inset = Math.min(halfWidth * 0.35, halfHeight * 0.9);
|
|
169
|
+
return `${formatNumber(-halfWidth + inset)},${formatNumber(-halfHeight)} ${formatNumber(halfWidth - inset)},${formatNumber(-halfHeight)} ${formatNumber(halfWidth)},${formatNumber(0)} ${formatNumber(halfWidth - inset)},${formatNumber(halfHeight)} ${formatNumber(-halfWidth + inset)},${formatNumber(halfHeight)} ${formatNumber(-halfWidth)},${formatNumber(0)}`;
|
|
170
|
+
}
|
|
171
|
+
function nodePresentation(node) {
|
|
172
|
+
const labelLength = Math.max(node.label.length, 1);
|
|
173
|
+
const shape = shapeForKind(node.kind);
|
|
174
|
+
const width = node.kind === 'file'
|
|
175
|
+
? shapeWidthForKind(node.kind, labelLength)
|
|
176
|
+
: node.kind === 'user'
|
|
177
|
+
? 72
|
|
178
|
+
: 16;
|
|
179
|
+
const height = node.kind === 'file'
|
|
180
|
+
? shapeHeightForKind(node.kind, labelLength)
|
|
181
|
+
: node.kind === 'user'
|
|
182
|
+
? 72
|
|
183
|
+
: 16;
|
|
184
|
+
const presentationRadius = Math.max(width, height) / 2 + 10;
|
|
185
|
+
return {
|
|
186
|
+
shape,
|
|
187
|
+
width,
|
|
188
|
+
height,
|
|
189
|
+
presentationRadius,
|
|
190
|
+
padding: Math.ceil(presentationRadius + 24)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function shapeForKind(kind) {
|
|
194
|
+
switch (kind) {
|
|
195
|
+
case 'file':
|
|
196
|
+
return 'rounded-rect';
|
|
197
|
+
case 'symbol':
|
|
198
|
+
return 'box';
|
|
199
|
+
case 'import':
|
|
200
|
+
return 'ellipse';
|
|
201
|
+
case 'export':
|
|
202
|
+
return 'diamond';
|
|
203
|
+
case 'call':
|
|
204
|
+
return 'hexagon';
|
|
205
|
+
case 'user':
|
|
206
|
+
return 'user';
|
|
207
|
+
default:
|
|
208
|
+
return 'box';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function shapeWidthForKind(kind, labelLength) {
|
|
212
|
+
if (kind === 'file') {
|
|
213
|
+
return Math.max(32, Math.min(320, Math.round(32 + labelLength * 7.8)));
|
|
214
|
+
}
|
|
215
|
+
return shapeSquareSizeForKind(kind, labelLength);
|
|
216
|
+
}
|
|
217
|
+
function shapeHeightForKind(kind, labelLength) {
|
|
218
|
+
if (kind === 'file') {
|
|
219
|
+
return 36;
|
|
220
|
+
}
|
|
221
|
+
return shapeSquareSizeForKind(kind, labelLength ?? 1);
|
|
222
|
+
}
|
|
223
|
+
function shapeSquareSizeForKind(kind, labelLength) {
|
|
224
|
+
const sizeByKind = {
|
|
225
|
+
symbol: { base: 64, perChar: 7, min: 96, max: 220 },
|
|
226
|
+
import: { base: 68, perChar: 7, min: 96, max: 220 },
|
|
227
|
+
export: { base: 68, perChar: 7, min: 96, max: 220 },
|
|
228
|
+
call: { base: 68, perChar: 7, min: 96, max: 220 },
|
|
229
|
+
user: { base: 68, perChar: 7, min: 72, max: 180 }
|
|
230
|
+
};
|
|
231
|
+
const spec = sizeByKind[kind];
|
|
232
|
+
return Math.max(spec.min, Math.min(spec.max, Math.round(spec.base + labelLength * spec.perChar)));
|
|
233
|
+
}
|
|
234
|
+
function expandViewBox(viewBox, padding) {
|
|
235
|
+
return {
|
|
236
|
+
x: viewBox.x - padding,
|
|
237
|
+
y: viewBox.y - padding,
|
|
238
|
+
width: viewBox.width + padding * 2,
|
|
239
|
+
height: viewBox.height + padding * 2
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function renderUserNodeShapeMarkup(presentation, fill) {
|
|
243
|
+
const scale = Math.max(presentation.width, presentation.height) / 24;
|
|
244
|
+
return `<g class="node-shape node-shape--user" transform="scale(${formatNumber(scale)}) translate(-12,-12)" fill="none" stroke="${fill}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
245
|
+
<circle cx="12" cy="9" r="3" />
|
|
246
|
+
<circle cx="12" cy="12" r="10" />
|
|
247
|
+
<path d="M17.9691 20C17.81 17.1085 16.9247 15 11.9999 15C7.07521 15 6.18991 17.1085 6.03076 20" />
|
|
248
|
+
</g>`;
|
|
249
|
+
}
|
|
250
|
+
function colorForKind(kind) {
|
|
251
|
+
switch (kind) {
|
|
252
|
+
case 'file':
|
|
253
|
+
return '#38bdf8';
|
|
254
|
+
case 'symbol':
|
|
255
|
+
return '#22c55e';
|
|
256
|
+
case 'import':
|
|
257
|
+
return '#f59e0b';
|
|
258
|
+
case 'export':
|
|
259
|
+
return '#a855f7';
|
|
260
|
+
case 'call':
|
|
261
|
+
return '#fb7185';
|
|
262
|
+
case 'user':
|
|
263
|
+
return '#14b8a6';
|
|
264
|
+
default:
|
|
265
|
+
return '#94a3b8';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function escapeHtml(str) {
|
|
269
|
+
return str.replace(/[&<>"']/g, (match) => {
|
|
270
|
+
switch (match) {
|
|
271
|
+
case '&':
|
|
272
|
+
return '&';
|
|
273
|
+
case '<':
|
|
274
|
+
return '<';
|
|
275
|
+
case '>':
|
|
276
|
+
return '>';
|
|
277
|
+
case '"':
|
|
278
|
+
return '"';
|
|
279
|
+
case "'":
|
|
280
|
+
return ''';
|
|
281
|
+
default:
|
|
282
|
+
return match;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function escapeJson(value) {
|
|
287
|
+
return value.replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
288
|
+
}
|
|
289
|
+
function formatNumber(value) {
|
|
290
|
+
return Number.isFinite(value) ? Number(value.toFixed(2)).toString() : '0';
|
|
291
|
+
}
|
|
292
|
+
function legendItems() {
|
|
293
|
+
const items = [
|
|
294
|
+
['file', 'File'],
|
|
295
|
+
['symbol', 'Symbol'],
|
|
296
|
+
['import', 'Import'],
|
|
297
|
+
['export', 'Export'],
|
|
298
|
+
['call', 'Call'],
|
|
299
|
+
['user', 'Contributor']
|
|
300
|
+
];
|
|
301
|
+
return items
|
|
302
|
+
.map(([kind, label]) => `
|
|
303
|
+
<button class="legend-toggle" type="button" data-kind-toggle="${escapeHtml(kind)}" aria-pressed="true">
|
|
304
|
+
${legendIconMarkup(kind)}
|
|
305
|
+
<span>${escapeHtml(label)}</span>
|
|
306
|
+
</button>`)
|
|
307
|
+
.join('\n');
|
|
308
|
+
}
|
|
309
|
+
function legendIconMarkup(kind) {
|
|
310
|
+
if (kind === 'user') {
|
|
311
|
+
return `<span class="legend-icon legend-icon--user" aria-hidden="true">
|
|
312
|
+
<svg viewBox="0 0 24 24" fill="none" role="presentation" focusable="false" aria-hidden="true">
|
|
313
|
+
<circle cx="12" cy="9" r="3" stroke="currentColor" stroke-width="1.5" />
|
|
314
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5" />
|
|
315
|
+
<path d="M17.9691 20C17.81 17.1085 16.9247 15 11.9999 15C7.07521 15 6.18991 17.1085 6.03076 20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
316
|
+
</svg>
|
|
317
|
+
</span>`;
|
|
318
|
+
}
|
|
319
|
+
return `<span class="legend-swatch" aria-hidden="true" style="background:${colorForKind(kind)}"></span>`;
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { scanRepository } from './graph/repository.js';
|
|
2
|
+
import type { RepositoryRecord, StorageScope } from './types.js';
|
|
3
|
+
export interface WatchCommandOptions {
|
|
4
|
+
root?: string | undefined;
|
|
5
|
+
scope?: StorageScope | 'both' | undefined;
|
|
6
|
+
intervalMs?: number | undefined;
|
|
7
|
+
}
|
|
8
|
+
export interface WatchSummary {
|
|
9
|
+
filesDiscovered: number;
|
|
10
|
+
filesScanned: number;
|
|
11
|
+
nodesWritten: number;
|
|
12
|
+
edgesWritten: number;
|
|
13
|
+
skippedCount: number;
|
|
14
|
+
}
|
|
15
|
+
export interface WatchEvent {
|
|
16
|
+
event: 'watching' | 'scan' | 'waiting' | 'rescan-triggered' | 'stopped';
|
|
17
|
+
mode?: 'watch' | 'scan --watch' | undefined;
|
|
18
|
+
phase?: 'initial' | 'rescan' | undefined;
|
|
19
|
+
reason?: 'file-change' | 'follow-up' | undefined;
|
|
20
|
+
repository: RepositoryRecord;
|
|
21
|
+
scope: StorageScope;
|
|
22
|
+
summary?: WatchSummary | undefined;
|
|
23
|
+
scans?: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
export interface WatchDependencies {
|
|
26
|
+
scanRepository?: typeof scanRepository | undefined;
|
|
27
|
+
fingerprint?: ((root: string) => Promise<string> | string) | undefined;
|
|
28
|
+
emit?: ((event: WatchEvent) => void) | undefined;
|
|
29
|
+
wait?: ((ms: number, signal?: AbortSignal) => Promise<void>) | undefined;
|
|
30
|
+
signal?: AbortSignal | undefined;
|
|
31
|
+
mode?: 'watch' | 'scan --watch' | undefined;
|
|
32
|
+
}
|
|
33
|
+
export interface WatchRunResult {
|
|
34
|
+
repository: RepositoryRecord;
|
|
35
|
+
scope: StorageScope;
|
|
36
|
+
scans: number;
|
|
37
|
+
stopped: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function watchRepository(options?: WatchCommandOptions, dependencies?: WatchDependencies): Promise<WatchRunResult>;
|
|
40
|
+
//# sourceMappingURL=watch.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { repositoryForRoot, scanRepository } from './graph/repository.js';
|
|
3
|
+
import { collectRepositoryFingerprint } from './scanner/fingerprint.js';
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
5
|
+
export async function watchRepository(options = {}, dependencies = {}) {
|
|
6
|
+
const scope = resolveWatchScope(options.scope);
|
|
7
|
+
const repository = repositoryForRoot(options.root);
|
|
8
|
+
const scanFn = dependencies.scanRepository ?? scanRepository;
|
|
9
|
+
const fingerprintFn = dependencies.fingerprint ?? ((root) => collectRepositoryFingerprint(root));
|
|
10
|
+
const emit = dependencies.emit ?? defaultEmit;
|
|
11
|
+
const wait = dependencies.wait ?? defaultWait;
|
|
12
|
+
const signal = dependencies.signal;
|
|
13
|
+
const mode = dependencies.mode ?? 'watch';
|
|
14
|
+
const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
15
|
+
let scans = 0;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let currentFingerprint = await fingerprintFn(repository.root);
|
|
18
|
+
emit({
|
|
19
|
+
event: 'watching',
|
|
20
|
+
mode,
|
|
21
|
+
repository,
|
|
22
|
+
scope
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
while (!signal?.aborted) {
|
|
26
|
+
const scanStartFingerprint = currentFingerprint;
|
|
27
|
+
const summary = await scanFn({ root: repository.root, scope });
|
|
28
|
+
scans += 1;
|
|
29
|
+
emit({
|
|
30
|
+
event: 'scan',
|
|
31
|
+
mode,
|
|
32
|
+
phase: scans === 1 ? 'initial' : 'rescan',
|
|
33
|
+
repository,
|
|
34
|
+
scope,
|
|
35
|
+
summary: summarizeScan(summary)
|
|
36
|
+
});
|
|
37
|
+
const scanEndFingerprint = await fingerprintFn(repository.root);
|
|
38
|
+
currentFingerprint = scanEndFingerprint;
|
|
39
|
+
if (signal?.aborted)
|
|
40
|
+
break;
|
|
41
|
+
if (scanEndFingerprint !== scanStartFingerprint) {
|
|
42
|
+
emit({
|
|
43
|
+
event: 'rescan-triggered',
|
|
44
|
+
mode,
|
|
45
|
+
reason: 'follow-up',
|
|
46
|
+
repository,
|
|
47
|
+
scope
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
emit({
|
|
52
|
+
event: 'waiting',
|
|
53
|
+
mode,
|
|
54
|
+
repository,
|
|
55
|
+
scope
|
|
56
|
+
});
|
|
57
|
+
while (!signal?.aborted) {
|
|
58
|
+
await wait(intervalMs, signal);
|
|
59
|
+
const nextFingerprint = await fingerprintFn(repository.root);
|
|
60
|
+
if (nextFingerprint !== currentFingerprint) {
|
|
61
|
+
currentFingerprint = nextFingerprint;
|
|
62
|
+
emit({
|
|
63
|
+
event: 'rescan-triggered',
|
|
64
|
+
mode,
|
|
65
|
+
reason: 'file-change',
|
|
66
|
+
repository,
|
|
67
|
+
scope
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
stopped = true;
|
|
76
|
+
emit({
|
|
77
|
+
event: 'stopped',
|
|
78
|
+
mode,
|
|
79
|
+
repository,
|
|
80
|
+
scope,
|
|
81
|
+
scans
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
repository,
|
|
86
|
+
scope,
|
|
87
|
+
scans,
|
|
88
|
+
stopped
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function resolveWatchScope(scope) {
|
|
92
|
+
if (scope === undefined || scope === 'repo')
|
|
93
|
+
return 'repo';
|
|
94
|
+
throw new Error('watch mode supports repo scope only');
|
|
95
|
+
}
|
|
96
|
+
function summarizeScan(summary) {
|
|
97
|
+
return {
|
|
98
|
+
filesDiscovered: summary.filesDiscovered,
|
|
99
|
+
filesScanned: summary.filesScanned,
|
|
100
|
+
nodesWritten: summary.nodesWritten,
|
|
101
|
+
edgesWritten: summary.edgesWritten,
|
|
102
|
+
skippedCount: summary.skipped.length
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function defaultEmit(event) {
|
|
106
|
+
console.log(JSON.stringify(event));
|
|
107
|
+
}
|
|
108
|
+
async function defaultWait(ms, signal) {
|
|
109
|
+
try {
|
|
110
|
+
await sleep(ms, undefined, signal ? { signal } : undefined);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!signal?.aborted) {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=watch.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@psnext/lscg",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local source context graphs for repositories, exposed as a CLI and MCP server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lscg": "./dist/bin/lscg.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/src/index.js",
|
|
11
|
+
"./mcp": "./dist/src/mcp/server.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"!dist/test",
|
|
16
|
+
"!dist/**/*.map"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"prepare": "npm run build",
|
|
22
|
+
"prestart": "npm run build",
|
|
23
|
+
"start": "node ./dist/bin/lscg.js",
|
|
24
|
+
"prescan": "npm run build",
|
|
25
|
+
"scan": "node ./dist/bin/lscg.js scan",
|
|
26
|
+
"prescan:watch": "npm run build",
|
|
27
|
+
"scan:watch": "node ./dist/bin/lscg.js scan --watch",
|
|
28
|
+
"prewatch": "npm run build",
|
|
29
|
+
"watch": "node ./dist/bin/lscg.js watch",
|
|
30
|
+
"premcp": "npm run build",
|
|
31
|
+
"mcp": "node ./dist/bin/lscg.js mcp",
|
|
32
|
+
"pretest": "npm run build",
|
|
33
|
+
"test": "node --test ./dist/test/*.test.js",
|
|
34
|
+
"prebenchmark:context": "npm run build",
|
|
35
|
+
"benchmark:context": "node ./dist/test/context-benchmark.js"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"tree-sitter",
|
|
39
|
+
"sqlite",
|
|
40
|
+
"mcp",
|
|
41
|
+
"code-graph",
|
|
42
|
+
"context-graph"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=24.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.24.0",
|
|
49
|
+
"graphology": "^0.26.0",
|
|
50
|
+
"install-scripts": "^1.2.0",
|
|
51
|
+
"tree-sitter": "^0.21.1",
|
|
52
|
+
"tree-sitter-javascript": "^0.23.1",
|
|
53
|
+
"tree-sitter-typescript": "^0.23.2",
|
|
54
|
+
"zod": "^3.25.76"
|
|
55
|
+
},
|
|
56
|
+
"allowScripts": {
|
|
57
|
+
"tree-sitter@0.21.1": true,
|
|
58
|
+
"tree-sitter-javascript@0.23.1": true,
|
|
59
|
+
"tree-sitter-typescript@0.23.2": true
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^26.1.2",
|
|
63
|
+
"typescript": "^7.0.2"
|
|
64
|
+
}
|
|
65
|
+
}
|