@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
|
@@ -38,6 +38,18 @@
|
|
|
38
38
|
</select>
|
|
39
39
|
</label>
|
|
40
40
|
</div>
|
|
41
|
+
<div class="section">
|
|
42
|
+
<label>
|
|
43
|
+
<span class="status">Layout</span>
|
|
44
|
+
<select id="layout-select">
|
|
45
|
+
<option value="force">Force-directed</option>
|
|
46
|
+
<option value="radial">Radial</option>
|
|
47
|
+
<option value="grid">Grid</option>
|
|
48
|
+
<option value="kind">By kind</option>
|
|
49
|
+
<option value="treemap">Treemap</option>
|
|
50
|
+
</select>
|
|
51
|
+
</label>
|
|
52
|
+
</div>
|
|
41
53
|
<div class="section">
|
|
42
54
|
<label>
|
|
43
55
|
<span class="status">View box zoom</span>
|
|
@@ -68,12 +80,14 @@
|
|
|
68
80
|
|
|
69
81
|
const data = {{DATA}};
|
|
70
82
|
let display = data.display ?? 'shape';
|
|
83
|
+
let layoutMode = 'force';
|
|
71
84
|
const app = document.getElementById('app');
|
|
72
85
|
const svg = d3.select('#graph');
|
|
73
86
|
const graphPanel = document.querySelector('.graph-panel');
|
|
74
87
|
const tooltip = document.getElementById('tooltip');
|
|
75
88
|
const searchInput = document.getElementById('search');
|
|
76
89
|
const displaySelect = document.getElementById('display-select');
|
|
90
|
+
const layoutSelect = document.getElementById('layout-select');
|
|
77
91
|
const zoomInput = document.getElementById('zoom');
|
|
78
92
|
const zoomValue = document.getElementById('zoom-value');
|
|
79
93
|
const zoomAllButton = document.getElementById('zoom-all');
|
|
@@ -240,31 +254,52 @@
|
|
|
240
254
|
.force('x', d3.forceX(width / 2).strength(0.05))
|
|
241
255
|
.force('y', d3.forceY(height / 2).strength(0.05));
|
|
242
256
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
.join('line')
|
|
246
|
-
.attr('class', (d) => 'edge edge--' + d.kind)
|
|
247
|
-
.attr('marker-end', 'url(#arrow)');
|
|
248
|
-
|
|
249
|
-
const nodes = nodeLayer.selectAll('g')
|
|
250
|
-
.data(nodesData)
|
|
251
|
-
.join('g')
|
|
252
|
-
.attr('class', (d) => 'node node--' + d.kind)
|
|
253
|
-
.call(drag(simulation));
|
|
257
|
+
let links = linkLayer.selectAll('line');
|
|
258
|
+
let nodes = nodeLayer.selectAll('g');
|
|
254
259
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const group = d3.select(this);
|
|
259
|
-
appendNodeShape(group, d);
|
|
260
|
+
function renderNode(group, node) {
|
|
261
|
+
appendNodeShape(group, node);
|
|
262
|
+
group.append('title').text(node.label);
|
|
260
263
|
group.append('text')
|
|
261
264
|
.attr('class', 'node-label')
|
|
262
265
|
.attr('text-anchor', 'middle')
|
|
263
|
-
.attr('dominant-baseline', display === 'shape' &&
|
|
264
|
-
.attr('y', display === 'shape' &&
|
|
265
|
-
.text(
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
.attr('dominant-baseline', display === 'shape' && node.kind === 'file' ? 'middle' : 'hanging')
|
|
267
|
+
.attr('y', display === 'shape' && node.kind === 'file' ? 0 : (node.height / 2) + 14)
|
|
268
|
+
.text(node.label);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderGraph(visibleNodeIds, visibleEdgeIds) {
|
|
272
|
+
links = linkLayer.selectAll('line')
|
|
273
|
+
.data(linksData.filter((edge) => visibleEdgeIds.has(edge.id)), (edge) => edge.id)
|
|
274
|
+
.join(
|
|
275
|
+
(enter) => enter.append('line'),
|
|
276
|
+
(update) => update,
|
|
277
|
+
(exit) => exit.remove()
|
|
278
|
+
)
|
|
279
|
+
.attr('class', (d) => 'edge edge--' + d.kind)
|
|
280
|
+
.attr('marker-end', 'url(#arrow)');
|
|
281
|
+
|
|
282
|
+
nodes = nodeLayer.selectAll('g')
|
|
283
|
+
.data(nodesData.filter((node) => visibleNodeIds.has(node.id)), (node) => node.id)
|
|
284
|
+
.join(
|
|
285
|
+
(enter) => {
|
|
286
|
+
const selection = enter.append('g')
|
|
287
|
+
.attr('class', (d) => 'node node--' + d.kind)
|
|
288
|
+
if (layoutMode === 'force')
|
|
289
|
+
selection.call(drag(simulation));
|
|
290
|
+
|
|
291
|
+
selection.each(function(node) {
|
|
292
|
+
renderNode(d3.select(this), node);
|
|
293
|
+
});
|
|
294
|
+
return selection;
|
|
295
|
+
},
|
|
296
|
+
(update) => update,
|
|
297
|
+
(exit) => exit.remove()
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
nodeLayer.raise();
|
|
301
|
+
bindNodeInteractions();
|
|
302
|
+
}
|
|
268
303
|
|
|
269
304
|
function updateDisplay() {
|
|
270
305
|
nodes.each(function(d) {
|
|
@@ -273,7 +308,8 @@
|
|
|
273
308
|
appendNodeShape(group, d);
|
|
274
309
|
group.select('.node-label')
|
|
275
310
|
.attr('dominant-baseline', display === 'shape' && d.kind === 'file' ? 'middle' : 'hanging')
|
|
276
|
-
.attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
|
|
311
|
+
.attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
|
|
312
|
+
.raise();
|
|
277
313
|
});
|
|
278
314
|
}
|
|
279
315
|
|
|
@@ -361,6 +397,74 @@
|
|
|
361
397
|
return typeof endpoint === 'string' ? endpoint : endpoint?.id;
|
|
362
398
|
}
|
|
363
399
|
|
|
400
|
+
function applyLayout(visibleNodeIds) {
|
|
401
|
+
if (layoutMode === 'force') {
|
|
402
|
+
for (const node of nodesData) {
|
|
403
|
+
node.fx = null;
|
|
404
|
+
node.fy = null;
|
|
405
|
+
}
|
|
406
|
+
simulation.alpha(0.8).restart();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
simulation.stop();
|
|
411
|
+
const visibleNodes = nodesData.filter((node) => visibleNodeIds.has(node.id));
|
|
412
|
+
const { width: canvasWidth, height: canvasHeight } = canvasSize();
|
|
413
|
+
const centerX = canvasWidth / 2;
|
|
414
|
+
const centerY = canvasHeight / 2;
|
|
415
|
+
const padding = 80;
|
|
416
|
+
const radius = Math.max(40, Math.min(canvasWidth, canvasHeight) / 2 - padding);
|
|
417
|
+
const columns = Math.max(1, Math.ceil(Math.sqrt(visibleNodes.length)));
|
|
418
|
+
const rows = Math.max(1, Math.ceil(visibleNodes.length / columns));
|
|
419
|
+
|
|
420
|
+
const orderedNodes = layoutMode === 'kind'
|
|
421
|
+
? [...visibleNodes].sort((left, right) => left.kind.localeCompare(right.kind) || left.label.localeCompare(right.label))
|
|
422
|
+
: visibleNodes;
|
|
423
|
+
const treemapRoot = layoutMode === 'treemap'
|
|
424
|
+
? d3.treemap().size([canvasWidth - padding * 2, canvasHeight - padding * 2]).paddingInner(12)(
|
|
425
|
+
d3.hierarchy({ children: visibleNodes }).sum((node) => node.degree ? Math.max(1, node.degree + 1) : 1)
|
|
426
|
+
)
|
|
427
|
+
: null;
|
|
428
|
+
|
|
429
|
+
orderedNodes.forEach((node, index) => {
|
|
430
|
+
let x = centerX;
|
|
431
|
+
let y = centerY;
|
|
432
|
+
if (layoutMode === 'treemap') {
|
|
433
|
+
const tile = treemapRoot?.leaves().find((leaf) => leaf.data === node);
|
|
434
|
+
x = padding + ((tile?.x0 ?? 0) + (tile?.x1 ?? 0)) / 2;
|
|
435
|
+
y = padding + ((tile?.y0 ?? 0) + (tile?.y1 ?? 0)) / 2;
|
|
436
|
+
} else if (layoutMode === 'radial') {
|
|
437
|
+
const angle = (index * Math.PI * 2) / Math.max(visibleNodes.length, 1) - Math.PI / 2;
|
|
438
|
+
x = centerX + Math.cos(angle) * radius;
|
|
439
|
+
y = centerY + Math.sin(angle) * radius;
|
|
440
|
+
} else {
|
|
441
|
+
const kindIndex = layoutMode === 'kind'
|
|
442
|
+
? orderedNodes.slice(0, index).filter((candidate) => candidate.kind === node.kind).length
|
|
443
|
+
: index;
|
|
444
|
+
const kindCount = layoutMode === 'kind'
|
|
445
|
+
? orderedNodes.filter((candidate) => candidate.kind === node.kind).length
|
|
446
|
+
: visibleNodes.length;
|
|
447
|
+
const kindColumn = layoutMode === 'kind'
|
|
448
|
+
? [...new Set(orderedNodes.map((candidate) => candidate.kind))].indexOf(node.kind)
|
|
449
|
+
: 0;
|
|
450
|
+
const kindColumns = layoutMode === 'kind'
|
|
451
|
+
? Math.max(1, new Set(orderedNodes.map((candidate) => candidate.kind)).size)
|
|
452
|
+
: columns;
|
|
453
|
+
const localColumns = layoutMode === 'kind' ? Math.max(1, Math.ceil(Math.sqrt(kindCount))) : columns;
|
|
454
|
+
const localRows = layoutMode === 'kind' ? Math.max(1, Math.ceil(kindCount / localColumns)) : rows;
|
|
455
|
+
const column = layoutMode === 'kind' ? kindIndex % localColumns : index % columns;
|
|
456
|
+
const row = layoutMode === 'kind' ? Math.floor(kindIndex / localColumns) : Math.floor(index / columns);
|
|
457
|
+
x = layoutMode === 'kind'
|
|
458
|
+
? ((kindColumn + 0.5) * canvasWidth) / kindColumns
|
|
459
|
+
: ((column + 1) * canvasWidth) / (columns + 1);
|
|
460
|
+
y = ((row + 1) * canvasHeight) / (localRows + 1);
|
|
461
|
+
}
|
|
462
|
+
node.x = node.fx = x;
|
|
463
|
+
node.y = node.fy = y;
|
|
464
|
+
});
|
|
465
|
+
ticked();
|
|
466
|
+
}
|
|
467
|
+
|
|
364
468
|
function updateStatus() {
|
|
365
469
|
if (anchorId && focusVisibleOnly) {
|
|
366
470
|
subtitle.textContent = 'Focus on ' + (nodeById.get(anchorId)?.label ?? anchorId);
|
|
@@ -416,16 +520,17 @@
|
|
|
416
520
|
const visibleEdgeIds = visibleSets.visibleEdgeIds;
|
|
417
521
|
const activeAnchorVisible = Boolean(anchorId && visibleNodeIds.has(anchorId));
|
|
418
522
|
|
|
523
|
+
renderGraph(visibleNodeIds, visibleEdgeIds);
|
|
524
|
+
applyLayout(visibleNodeIds);
|
|
525
|
+
|
|
419
526
|
nodes
|
|
420
|
-
.classed('is-
|
|
421
|
-
.classed('is-dimmed', (node) => !focusVisibleOnly && anchorId ? !visibleNodeIds.has(node.id) : false)
|
|
527
|
+
.classed('is-dimmed', () => false)
|
|
422
528
|
.classed('is-selected', (node) => anchorId === node.id)
|
|
423
529
|
.classed('is-highlighted', (node) => activeAnchorVisible && visibleNodeIds.has(node.id));
|
|
424
530
|
|
|
425
531
|
links
|
|
426
|
-
.classed('is-
|
|
427
|
-
.classed('is-
|
|
428
|
-
.classed('is-highlighted', (edge) => visibleEdgeIds.has(edge.id));
|
|
532
|
+
.classed('is-dimmed', () => false)
|
|
533
|
+
.classed('is-highlighted', () => true);
|
|
429
534
|
|
|
430
535
|
updateStatus();
|
|
431
536
|
updateInfo();
|
|
@@ -554,25 +659,27 @@
|
|
|
554
659
|
nodes.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')');
|
|
555
660
|
}
|
|
556
661
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
662
|
+
function bindNodeInteractions() {
|
|
663
|
+
nodes
|
|
664
|
+
.on('click', (event, node) => {
|
|
665
|
+
event.stopPropagation();
|
|
666
|
+
setAnchor(node.id);
|
|
667
|
+
})
|
|
668
|
+
.on('mouseenter', function(event, node) {
|
|
669
|
+
if (!tooltip) return;
|
|
670
|
+
tooltip.style.display = 'block';
|
|
671
|
+
tooltip.textContent = node.label + ' · ' + node.kind;
|
|
672
|
+
})
|
|
673
|
+
.on('mousemove', function(event) {
|
|
674
|
+
if (!tooltip) return;
|
|
675
|
+
tooltip.style.left = event.clientX + 14 + 'px';
|
|
676
|
+
tooltip.style.top = event.clientY + 14 + 'px';
|
|
677
|
+
})
|
|
678
|
+
.on('mouseleave', function() {
|
|
679
|
+
if (!tooltip) return;
|
|
680
|
+
tooltip.style.display = 'none';
|
|
681
|
+
});
|
|
682
|
+
}
|
|
576
683
|
|
|
577
684
|
svg.on('click', (event) => {
|
|
578
685
|
if (event.target === svg.node()) {
|
|
@@ -589,6 +696,13 @@
|
|
|
589
696
|
updateDisplay();
|
|
590
697
|
});
|
|
591
698
|
|
|
699
|
+
layoutSelect?.addEventListener('change', (event) => {
|
|
700
|
+
const value = event.target instanceof HTMLSelectElement ? event.target.value : 'force';
|
|
701
|
+
if (value !== 'force' && value !== 'radial' && value !== 'grid' && value !== 'kind' && value !== 'treemap') return;
|
|
702
|
+
layoutMode = value;
|
|
703
|
+
applyState();
|
|
704
|
+
});
|
|
705
|
+
|
|
592
706
|
searchInput.value = filterText;
|
|
593
707
|
searchInput.addEventListener('input', (event) => {
|
|
594
708
|
filterText = event.target.value;
|
package/dist/src/watch.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { scanRepository } from './graph/repository.js';
|
|
2
|
-
import type { RepositoryRecord, StorageScope } from './types.js';
|
|
1
|
+
import { drainRepositoryEnrichment, scanRepository } from './graph/repository.js';
|
|
2
|
+
import type { EnrichmentSummary, ProgressReporter, RepositoryRecord, StorageScope } from './types.js';
|
|
3
3
|
export interface WatchCommandOptions {
|
|
4
4
|
root?: string | undefined;
|
|
5
5
|
scope?: StorageScope | 'both' | undefined;
|
|
6
6
|
intervalMs?: number | undefined;
|
|
7
|
+
debounceMs?: number | undefined;
|
|
7
8
|
}
|
|
8
9
|
export interface WatchSummary {
|
|
9
10
|
filesDiscovered: number;
|
|
@@ -11,6 +12,7 @@ export interface WatchSummary {
|
|
|
11
12
|
nodesWritten: number;
|
|
12
13
|
edgesWritten: number;
|
|
13
14
|
skippedCount: number;
|
|
15
|
+
enrichment: EnrichmentSummary;
|
|
14
16
|
}
|
|
15
17
|
export interface WatchEvent {
|
|
16
18
|
event: 'watching' | 'scan' | 'waiting' | 'rescan-triggered' | 'stopped';
|
|
@@ -22,13 +24,26 @@ export interface WatchEvent {
|
|
|
22
24
|
summary?: WatchSummary | undefined;
|
|
23
25
|
scans?: number | undefined;
|
|
24
26
|
}
|
|
27
|
+
export type FileSystemWatchEvent = 'change' | 'rename';
|
|
28
|
+
export type FileSystemWatchListener = (event: FileSystemWatchEvent, filename: string | Buffer | null) => void;
|
|
29
|
+
/** Minimal fs.watch surface so watch behavior can be tested without the filesystem. */
|
|
30
|
+
export interface FileSystemWatcher {
|
|
31
|
+
close(): void;
|
|
32
|
+
on(event: 'error', listener: (error: Error) => void): this;
|
|
33
|
+
}
|
|
34
|
+
export type FileSystemWatcherFactory = (directory: string, options: {
|
|
35
|
+
recursive: boolean;
|
|
36
|
+
}, listener: FileSystemWatchListener) => FileSystemWatcher;
|
|
25
37
|
export interface WatchDependencies {
|
|
26
38
|
scanRepository?: typeof scanRepository | undefined;
|
|
39
|
+
drainEnrichment?: typeof drainRepositoryEnrichment | undefined;
|
|
27
40
|
fingerprint?: ((root: string) => Promise<string> | string) | undefined;
|
|
41
|
+
watcherFactory?: FileSystemWatcherFactory | undefined;
|
|
28
42
|
emit?: ((event: WatchEvent) => void) | undefined;
|
|
29
43
|
wait?: ((ms: number, signal?: AbortSignal) => Promise<void>) | undefined;
|
|
30
44
|
signal?: AbortSignal | undefined;
|
|
31
45
|
mode?: 'watch' | 'scan --watch' | undefined;
|
|
46
|
+
progress?: ProgressReporter | undefined;
|
|
32
47
|
}
|
|
33
48
|
export interface WatchRunResult {
|
|
34
49
|
repository: RepositoryRecord;
|
package/dist/src/watch.js
CHANGED
|
@@ -1,30 +1,115 @@
|
|
|
1
|
+
import { readdirSync, watch as watchFileSystem } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
-
import { repositoryForRoot, scanRepository } from './graph/repository.js';
|
|
4
|
+
import { drainRepositoryEnrichment, repositoryForRoot, scanRepository } from './graph/repository.js';
|
|
3
5
|
import { collectRepositoryFingerprint } from './scanner/fingerprint.js';
|
|
6
|
+
import { discoverSourceFiles } from './scanner/discover.js';
|
|
7
|
+
import { isSupportedSourceFile } from './parser/treeSitter.js';
|
|
4
8
|
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
9
|
+
const DEFAULT_DEBOUNCE_MS = 100;
|
|
10
|
+
const IGNORED_WATCH_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'coverage', '.next', '.turbo', '.cache', '.sling']);
|
|
5
11
|
export async function watchRepository(options = {}, dependencies = {}) {
|
|
6
12
|
const scope = resolveWatchScope(options.scope);
|
|
7
13
|
const repository = repositoryForRoot(options.root);
|
|
8
14
|
const scanFn = dependencies.scanRepository ?? scanRepository;
|
|
15
|
+
const drainEnrichment = dependencies.drainEnrichment ?? drainRepositoryEnrichment;
|
|
9
16
|
const fingerprintFn = dependencies.fingerprint ?? ((root) => collectRepositoryFingerprint(root));
|
|
17
|
+
const watcherFactory = dependencies.watcherFactory ?? defaultWatcherFactory;
|
|
10
18
|
const emit = dependencies.emit ?? defaultEmit;
|
|
11
19
|
const wait = dependencies.wait ?? defaultWait;
|
|
12
20
|
const signal = dependencies.signal;
|
|
13
21
|
const mode = dependencies.mode ?? 'watch';
|
|
22
|
+
const progress = dependencies.progress;
|
|
14
23
|
const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
24
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
15
25
|
let scans = 0;
|
|
16
26
|
let stopped = false;
|
|
17
|
-
let
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
let scanning = false;
|
|
28
|
+
let polling = false;
|
|
29
|
+
let currentFingerprint;
|
|
30
|
+
let followUpQueued = false;
|
|
31
|
+
let pendingEventPaths = new Set();
|
|
32
|
+
let failed = false;
|
|
33
|
+
let wakeWaiting;
|
|
34
|
+
const watchers = [];
|
|
35
|
+
const wake = () => {
|
|
36
|
+
const resolve = wakeWaiting;
|
|
37
|
+
wakeWaiting = undefined;
|
|
38
|
+
resolve?.();
|
|
39
|
+
};
|
|
40
|
+
const queueReconciliation = () => {
|
|
41
|
+
if (scanning) {
|
|
42
|
+
followUpQueued = true;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
pendingEventPaths.add('');
|
|
46
|
+
}
|
|
47
|
+
wake();
|
|
48
|
+
};
|
|
49
|
+
const usePollingFallback = () => {
|
|
50
|
+
if (polling)
|
|
51
|
+
return;
|
|
52
|
+
polling = true;
|
|
53
|
+
currentFingerprint = undefined;
|
|
54
|
+
closeWatchers(watchers);
|
|
55
|
+
reportWatchProgress(progress, { event: 'polling-fallback', mode, repository, scope, detail: 'filesystem watcher unavailable; polling fallback enabled' });
|
|
56
|
+
queueReconciliation();
|
|
57
|
+
};
|
|
58
|
+
const handleWatchEvent = (watchedDirectory, event, filename) => {
|
|
59
|
+
if (event === 'rename' || filename === null) {
|
|
60
|
+
usePollingFallback();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const relativePath = relevantWatchPath(repository.root, watchedDirectory, filename);
|
|
64
|
+
if (!relativePath)
|
|
65
|
+
return;
|
|
66
|
+
pendingEventPaths.add(relativePath);
|
|
67
|
+
if (scanning)
|
|
68
|
+
followUpQueued = true;
|
|
69
|
+
wake();
|
|
70
|
+
};
|
|
71
|
+
const addWatcher = (directory, recursive) => {
|
|
72
|
+
const watcher = watcherFactory(directory, { recursive }, (event, filename) => handleWatchEvent(directory, event, filename));
|
|
73
|
+
watcher.on('error', () => usePollingFallback());
|
|
74
|
+
watchers.push(watcher);
|
|
75
|
+
};
|
|
24
76
|
try {
|
|
77
|
+
try {
|
|
78
|
+
addWatcher(repository.root, true);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
closeWatchers(watchers);
|
|
82
|
+
try {
|
|
83
|
+
for (const directory of watchDirectories(repository.root)) {
|
|
84
|
+
addWatcher(directory, false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
closeWatchers(watchers);
|
|
89
|
+
polling = true;
|
|
90
|
+
reportWatchProgress(progress, { event: 'polling-fallback', mode, repository, scope, detail: 'filesystem watcher unavailable; polling fallback enabled' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
emit({
|
|
94
|
+
event: 'watching',
|
|
95
|
+
mode,
|
|
96
|
+
repository,
|
|
97
|
+
scope
|
|
98
|
+
});
|
|
99
|
+
reportWatchProgress(progress, { event: 'watching', mode, repository, scope, detail: 'watch session started' });
|
|
25
100
|
while (!signal?.aborted) {
|
|
26
|
-
|
|
27
|
-
const
|
|
101
|
+
scanning = true;
|
|
102
|
+
const phase = scans === 0 ? 'initial-scan' : 'rescan';
|
|
103
|
+
reportWatchProgress(progress, { event: phase, mode, repository, scope, detail: scans === 0 ? 'starting initial scan' : 'starting rescan' });
|
|
104
|
+
const scanStartFingerprint = polling ? await fingerprintFn(repository.root) : undefined;
|
|
105
|
+
const structuralSummary = await scanFn({ root: repository.root, scope, progress });
|
|
106
|
+
// A watch owns in-process enrichment. Awaiting it while `scanning` is
|
|
107
|
+
// true serializes durable work with structural replacement; a filesystem
|
|
108
|
+
// event during either phase queues exactly one follow-up structural scan.
|
|
109
|
+
const enrichment = await drainEnrichment({ root: repository.root, scope });
|
|
110
|
+
reportWatchProgress(progress, { event: 'enrichment', mode, repository, scope, detail: `state=${enrichment.state} pending=${enrichment.pending} complete=${enrichment.complete} failed=${enrichment.failed}` });
|
|
111
|
+
const summary = { ...structuralSummary, enrichment };
|
|
112
|
+
scanning = false;
|
|
28
113
|
scans += 1;
|
|
29
114
|
emit({
|
|
30
115
|
event: 'scan',
|
|
@@ -34,44 +119,60 @@ export async function watchRepository(options = {}, dependencies = {}) {
|
|
|
34
119
|
scope,
|
|
35
120
|
summary: summarizeScan(summary)
|
|
36
121
|
});
|
|
37
|
-
|
|
38
|
-
|
|
122
|
+
if (polling) {
|
|
123
|
+
const scanEndFingerprint = await fingerprintFn(repository.root);
|
|
124
|
+
const changedDuringScan = scanStartFingerprint !== undefined && scanEndFingerprint !== scanStartFingerprint;
|
|
125
|
+
currentFingerprint = scanEndFingerprint;
|
|
126
|
+
if (changedDuringScan)
|
|
127
|
+
followUpQueued = true;
|
|
128
|
+
}
|
|
39
129
|
if (signal?.aborted)
|
|
40
130
|
break;
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
reason: 'follow-up',
|
|
46
|
-
repository,
|
|
47
|
-
scope
|
|
48
|
-
});
|
|
131
|
+
if (followUpQueued) {
|
|
132
|
+
followUpQueued = false;
|
|
133
|
+
pendingEventPaths = new Set();
|
|
134
|
+
emitRescanTriggered('follow-up', mode, repository, scope, emit, progress);
|
|
49
135
|
continue;
|
|
50
136
|
}
|
|
51
|
-
emit({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const nextFingerprint = await fingerprintFn(repository.root);
|
|
60
|
-
if (nextFingerprint !== currentFingerprint) {
|
|
137
|
+
emit({ event: 'waiting', mode, repository, scope });
|
|
138
|
+
reportWatchProgress(progress, { event: 'waiting', mode, repository, scope, detail: polling ? 'waiting for polling interval' : 'waiting for file changes' });
|
|
139
|
+
if (polling) {
|
|
140
|
+
while (!signal?.aborted) {
|
|
141
|
+
await wait(intervalMs, signal);
|
|
142
|
+
const nextFingerprint = await fingerprintFn(repository.root);
|
|
143
|
+
if (nextFingerprint === currentFingerprint)
|
|
144
|
+
continue;
|
|
61
145
|
currentFingerprint = nextFingerprint;
|
|
62
|
-
emit
|
|
63
|
-
event: 'rescan-triggered',
|
|
64
|
-
mode,
|
|
65
|
-
reason: 'file-change',
|
|
66
|
-
repository,
|
|
67
|
-
scope
|
|
68
|
-
});
|
|
146
|
+
emitRescanTriggered('file-change', mode, repository, scope, emit, progress);
|
|
69
147
|
break;
|
|
70
148
|
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (pendingEventPaths.size === 0) {
|
|
152
|
+
await waitForWatchEvent(signal, (resolve) => {
|
|
153
|
+
wakeWaiting = resolve;
|
|
154
|
+
});
|
|
71
155
|
}
|
|
156
|
+
if (signal?.aborted)
|
|
157
|
+
break;
|
|
158
|
+
if (polling)
|
|
159
|
+
continue;
|
|
160
|
+
if (pendingEventPaths.size === 0)
|
|
161
|
+
continue;
|
|
162
|
+
await wait(debounceMs, signal);
|
|
163
|
+
if (signal?.aborted)
|
|
164
|
+
break;
|
|
165
|
+
pendingEventPaths = new Set();
|
|
166
|
+
emitRescanTriggered('file-change', mode, repository, scope, emit, progress);
|
|
72
167
|
}
|
|
73
168
|
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
failed = true;
|
|
171
|
+
reportWatchProgress(progress, { event: 'failed', mode, repository, scope, detail: error instanceof Error ? error.message : String(error) });
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
74
174
|
finally {
|
|
175
|
+
closeWatchers(watchers);
|
|
75
176
|
stopped = true;
|
|
76
177
|
emit({
|
|
77
178
|
event: 'stopped',
|
|
@@ -80,28 +181,90 @@ export async function watchRepository(options = {}, dependencies = {}) {
|
|
|
80
181
|
scope,
|
|
81
182
|
scans
|
|
82
183
|
});
|
|
184
|
+
if (!failed)
|
|
185
|
+
reportWatchProgress(progress, { event: 'stopped', mode, repository, scope, detail: `watch session stopped after ${scans} scan(s)` });
|
|
83
186
|
}
|
|
84
|
-
return {
|
|
85
|
-
repository,
|
|
86
|
-
scope,
|
|
87
|
-
scans,
|
|
88
|
-
stopped
|
|
89
|
-
};
|
|
187
|
+
return { repository, scope, scans, stopped };
|
|
90
188
|
}
|
|
91
189
|
function resolveWatchScope(scope) {
|
|
92
190
|
if (scope === undefined || scope === 'repo')
|
|
93
191
|
return 'repo';
|
|
94
192
|
throw new Error('watch mode supports repo scope only');
|
|
95
193
|
}
|
|
194
|
+
function watchDirectories(root) {
|
|
195
|
+
const directories = new Set([root]);
|
|
196
|
+
for (const relativePath of discoverSourceFiles(root)) {
|
|
197
|
+
directories.add(path.dirname(path.join(root, relativePath)));
|
|
198
|
+
}
|
|
199
|
+
// Immediate module directories contain the supported Maven/Gradle manifests
|
|
200
|
+
// and must also be watched when a module has no source files yet.
|
|
201
|
+
try {
|
|
202
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
203
|
+
if (entry.isDirectory() && !entry.isSymbolicLink() && !IGNORED_WATCH_DIRECTORIES.has(entry.name))
|
|
204
|
+
directories.add(path.join(root, entry.name));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch { /* scan will report traversal degradation */ }
|
|
208
|
+
return [...directories].sort();
|
|
209
|
+
}
|
|
210
|
+
function relevantWatchPath(root, watchedDirectory, filename) {
|
|
211
|
+
const filenameText = Buffer.isBuffer(filename) ? filename.toString() : filename;
|
|
212
|
+
const absolutePath = path.resolve(watchedDirectory, filenameText);
|
|
213
|
+
const relativePath = path.relative(root, absolutePath);
|
|
214
|
+
if (!relativePath || path.isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${path.sep}`))
|
|
215
|
+
return undefined;
|
|
216
|
+
const pathSegments = relativePath.split(path.sep);
|
|
217
|
+
if (pathSegments.some((segment) => IGNORED_WATCH_DIRECTORIES.has(segment)))
|
|
218
|
+
return undefined;
|
|
219
|
+
const basename = path.basename(relativePath);
|
|
220
|
+
const depth = pathSegments.length;
|
|
221
|
+
const supportedManifest = depth <= 2 && (basename === 'pom.xml' || basename === 'build.gradle' || basename === 'build.gradle.kts');
|
|
222
|
+
if (basename !== 'package.json' && !isSupportedSourceFile(absolutePath) && !supportedManifest)
|
|
223
|
+
return undefined;
|
|
224
|
+
return relativePath;
|
|
225
|
+
}
|
|
226
|
+
function closeWatchers(watchers) {
|
|
227
|
+
while (watchers.length > 0) {
|
|
228
|
+
watchers.pop()?.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function waitForWatchEvent(signal, setWake) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const done = () => {
|
|
234
|
+
signal?.removeEventListener('abort', done);
|
|
235
|
+
resolve();
|
|
236
|
+
};
|
|
237
|
+
if (signal?.aborted) {
|
|
238
|
+
done();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
setWake(done);
|
|
242
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function emitRescanTriggered(reason, mode, repository, scope, emit, progress) {
|
|
246
|
+
emit({ event: 'rescan-triggered', mode, reason, repository, scope });
|
|
247
|
+
reportWatchProgress(progress, { event: reason === 'follow-up' ? 'follow-up' : 'coalesced-change', mode, repository, scope, detail: reason === 'follow-up' ? 'change queued during scan; running follow-up' : 'coalesced file changes; running one rescan' });
|
|
248
|
+
}
|
|
96
249
|
function summarizeScan(summary) {
|
|
97
250
|
return {
|
|
98
251
|
filesDiscovered: summary.filesDiscovered,
|
|
99
252
|
filesScanned: summary.filesScanned,
|
|
100
253
|
nodesWritten: summary.nodesWritten,
|
|
101
254
|
edgesWritten: summary.edgesWritten,
|
|
102
|
-
skippedCount: summary.skipped.length
|
|
255
|
+
skippedCount: summary.skipped.length,
|
|
256
|
+
enrichment: summary.enrichment
|
|
103
257
|
};
|
|
104
258
|
}
|
|
259
|
+
function reportWatchProgress(progress, event) {
|
|
260
|
+
try {
|
|
261
|
+
progress?.({ kind: 'watch', ...event });
|
|
262
|
+
}
|
|
263
|
+
catch { /* progress is observational */ }
|
|
264
|
+
}
|
|
265
|
+
function defaultWatcherFactory(directory, options, listener) {
|
|
266
|
+
return watchFileSystem(directory, options, (event, filename) => listener(event, filename));
|
|
267
|
+
}
|
|
105
268
|
function defaultEmit(event) {
|
|
106
269
|
console.log(JSON.stringify(event));
|
|
107
270
|
}
|
|
@@ -110,9 +273,8 @@ async function defaultWait(ms, signal) {
|
|
|
110
273
|
await sleep(ms, undefined, signal ? { signal } : undefined);
|
|
111
274
|
}
|
|
112
275
|
catch (error) {
|
|
113
|
-
if (!signal?.aborted)
|
|
276
|
+
if (!signal?.aborted)
|
|
114
277
|
throw error;
|
|
115
|
-
}
|
|
116
278
|
}
|
|
117
279
|
}
|
|
118
280
|
//# sourceMappingURL=watch.js.map
|