@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.
Files changed (52) hide show
  1. package/README.md +87 -9
  2. package/dist/src/cli-progress.d.ts +7 -0
  3. package/dist/src/cli-progress.js +59 -0
  4. package/dist/src/cli.js +14 -6
  5. package/dist/src/graph/attribution.d.ts +2 -2
  6. package/dist/src/graph/attribution.js +36 -13
  7. package/dist/src/graph/repository.d.ts +30 -3
  8. package/dist/src/graph/repository.js +396 -158
  9. package/dist/src/graph/repositoryScanWorker.d.ts +21 -0
  10. package/dist/src/graph/repositoryScanWorker.js +45 -0
  11. package/dist/src/index.d.ts +5 -0
  12. package/dist/src/index.js +4 -0
  13. package/dist/src/mcp/server.js +16 -1
  14. package/dist/src/parser/treeSitter.js +20 -1
  15. package/dist/src/scanner/artifactInventory.d.ts +35 -0
  16. package/dist/src/scanner/artifactInventory.js +139 -0
  17. package/dist/src/scanner/fingerprint.js +5 -0
  18. package/dist/src/scanner/javaDependencyPlugin.d.ts +5 -0
  19. package/dist/src/scanner/javaDependencyPlugin.js +107 -0
  20. package/dist/src/scanner/javaPlugin.d.ts +5 -0
  21. package/dist/src/scanner/javaPlugin.js +199 -0
  22. package/dist/src/scanner/javaScanWorker.d.ts +2 -0
  23. package/dist/src/scanner/javaScanWorker.js +8 -0
  24. package/dist/src/scanner/packageParseWorker.d.ts +17 -0
  25. package/dist/src/scanner/packageParseWorker.js +30 -0
  26. package/dist/src/scanner/packagePlugin.js +126 -24
  27. package/dist/src/scanner/parallelScan.d.ts +2 -0
  28. package/dist/src/scanner/parallelScan.js +32 -0
  29. package/dist/src/scanner/plugins.d.ts +32 -2
  30. package/dist/src/scanner/plugins.js +51 -5
  31. package/dist/src/scanner/pythonPlugin.d.ts +5 -0
  32. package/dist/src/scanner/pythonPlugin.js +198 -0
  33. package/dist/src/scanner/pythonScanWorker.d.ts +2 -0
  34. package/dist/src/scanner/pythonScanWorker.js +8 -0
  35. package/dist/src/storage/connection.js +63 -0
  36. package/dist/src/storage/database.d.ts +1 -0
  37. package/dist/src/storage/database.js +1 -0
  38. package/dist/src/storage/graph-writes.d.ts +16 -3
  39. package/dist/src/storage/graph-writes.js +142 -17
  40. package/dist/src/storage/manifest-inventory.d.ts +23 -0
  41. package/dist/src/storage/manifest-inventory.js +82 -0
  42. package/dist/src/storage/queries.d.ts +6 -1
  43. package/dist/src/storage/queries.js +67 -1
  44. package/dist/src/storage/schema.d.ts +2 -2
  45. package/dist/src/storage/schema.js +44 -1
  46. package/dist/src/types.d.ts +102 -6
  47. package/dist/src/view/templates/icons/call.svg +11 -9
  48. package/dist/src/view/templates/interactive.css +11 -8
  49. package/dist/src/view/templates/interactive.html +160 -46
  50. package/dist/src/watch.d.ts +17 -2
  51. package/dist/src/watch.js +208 -46
  52. 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
- const links = linkLayer.selectAll('line')
244
- .data(linksData)
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
- nodeLayer.raise();
256
-
257
- nodes.each(function(d) {
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' && d.kind === 'file' ? 'middle' : 'hanging')
264
- .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
265
- .text(d.label);
266
- group.append('title').text(d.label);
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-hidden', (node) => !visibleNodeIds.has(node.id))
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-hidden', (edge) => !visibleEdgeIds.has(edge.id))
427
- .classed('is-dimmed', (edge) => !focusVisibleOnly && anchorId ? !visibleEdgeIds.has(edge.id) : false)
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
- nodes
558
- .on('click', (event, node) => {
559
- event.stopPropagation();
560
- setAnchor(node.id);
561
- })
562
- .on('mouseenter', function(event, node) {
563
- if (!tooltip) return;
564
- tooltip.style.display = 'block';
565
- tooltip.textContent = node.label + ' · ' + node.kind;
566
- })
567
- .on('mousemove', function(event) {
568
- if (!tooltip) return;
569
- tooltip.style.left = event.clientX + 14 + 'px';
570
- tooltip.style.top = event.clientY + 14 + 'px';
571
- })
572
- .on('mouseleave', function() {
573
- if (!tooltip) return;
574
- tooltip.style.display = 'none';
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;
@@ -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 currentFingerprint = await fingerprintFn(repository.root);
18
- emit({
19
- event: 'watching',
20
- mode,
21
- repository,
22
- scope
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
- const scanStartFingerprint = currentFingerprint;
27
- const summary = await scanFn({ root: repository.root, scope });
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
- const scanEndFingerprint = await fingerprintFn(repository.root);
38
- currentFingerprint = scanEndFingerprint;
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 (scanEndFingerprint !== scanStartFingerprint) {
42
- emit({
43
- event: 'rescan-triggered',
44
- mode,
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
- 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) {
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