@psnext/lscg 0.1.4 → 0.1.6

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 (62) hide show
  1. package/README.md +117 -15
  2. package/dist/bin/lscg.js +0 -0
  3. package/dist/src/cli-progress.d.ts +7 -0
  4. package/dist/src/cli-progress.js +59 -0
  5. package/dist/src/cli.js +62 -9
  6. package/dist/src/explore/sigma-provider.d.ts +27 -0
  7. package/dist/src/explore/sigma-provider.js +87 -0
  8. package/dist/src/explore/sigma-render.d.ts +18 -0
  9. package/dist/src/explore/sigma-render.js +67 -0
  10. package/dist/src/graph/attribution.d.ts +2 -2
  11. package/dist/src/graph/attribution.js +36 -13
  12. package/dist/src/graph/explore.d.ts +20 -0
  13. package/dist/src/graph/explore.js +200 -0
  14. package/dist/src/graph/repository.d.ts +36 -4
  15. package/dist/src/graph/repository.js +443 -159
  16. package/dist/src/graph/repositoryScanWorker.d.ts +21 -0
  17. package/dist/src/graph/repositoryScanWorker.js +45 -0
  18. package/dist/src/index.d.ts +6 -0
  19. package/dist/src/index.js +5 -0
  20. package/dist/src/mcp/server.js +21 -3
  21. package/dist/src/parser/treeSitter.js +20 -1
  22. package/dist/src/scanner/artifactInventory.d.ts +35 -0
  23. package/dist/src/scanner/artifactInventory.js +139 -0
  24. package/dist/src/scanner/attributionPlugin.d.ts +5 -0
  25. package/dist/src/scanner/attributionPlugin.js +16 -0
  26. package/dist/src/scanner/discover.js +89 -16
  27. package/dist/src/scanner/fingerprint.js +5 -0
  28. package/dist/src/scanner/javaDependencyPlugin.d.ts +5 -0
  29. package/dist/src/scanner/javaDependencyPlugin.js +107 -0
  30. package/dist/src/scanner/javaPlugin.d.ts +5 -0
  31. package/dist/src/scanner/javaPlugin.js +199 -0
  32. package/dist/src/scanner/javaScanWorker.d.ts +2 -0
  33. package/dist/src/scanner/javaScanWorker.js +8 -0
  34. package/dist/src/scanner/packageParseWorker.d.ts +17 -0
  35. package/dist/src/scanner/packageParseWorker.js +30 -0
  36. package/dist/src/scanner/packagePlugin.js +83 -24
  37. package/dist/src/scanner/parallelScan.d.ts +2 -0
  38. package/dist/src/scanner/parallelScan.js +32 -0
  39. package/dist/src/scanner/plugins.d.ts +38 -4
  40. package/dist/src/scanner/plugins.js +58 -5
  41. package/dist/src/scanner/pythonPlugin.d.ts +5 -0
  42. package/dist/src/scanner/pythonPlugin.js +198 -0
  43. package/dist/src/scanner/pythonScanWorker.d.ts +2 -0
  44. package/dist/src/scanner/pythonScanWorker.js +8 -0
  45. package/dist/src/storage/connection.js +85 -0
  46. package/dist/src/storage/database.d.ts +1 -0
  47. package/dist/src/storage/database.js +1 -0
  48. package/dist/src/storage/explore-queries.d.ts +52 -0
  49. package/dist/src/storage/explore-queries.js +184 -0
  50. package/dist/src/storage/graph-writes.d.ts +22 -3
  51. package/dist/src/storage/graph-writes.js +167 -20
  52. package/dist/src/storage/manifest-inventory.d.ts +23 -0
  53. package/dist/src/storage/manifest-inventory.js +82 -0
  54. package/dist/src/storage/plugin-graph.js +3 -3
  55. package/dist/src/storage/queries.d.ts +10 -3
  56. package/dist/src/storage/queries.js +99 -24
  57. package/dist/src/storage/schema.d.ts +2 -2
  58. package/dist/src/storage/schema.js +48 -1
  59. package/dist/src/types.d.ts +110 -6
  60. package/dist/src/watch.d.ts +20 -2
  61. package/dist/src/watch.js +211 -46
  62. package/package.json +9 -3
package/dist/src/watch.js CHANGED
@@ -1,30 +1,118 @@
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, attribution: options.attribution, plugins: options.plugins, 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 shouldDrainEnrichment = options.attribution === true || dependencies.drainEnrichment !== undefined;
110
+ const enrichment = shouldDrainEnrichment
111
+ ? await drainEnrichment({ root: repository.root, scope, historyFingerprint: undefined })
112
+ : { state: 'disabled', pending: 0, complete: 0, failed: 0, unavailable: 0, notApplicable: 0, diagnostics: [] };
113
+ reportWatchProgress(progress, { event: 'enrichment', mode, repository, scope, detail: `state=${enrichment.state} pending=${enrichment.pending} complete=${enrichment.complete} failed=${enrichment.failed}` });
114
+ const summary = { ...structuralSummary, enrichment };
115
+ scanning = false;
28
116
  scans += 1;
29
117
  emit({
30
118
  event: 'scan',
@@ -34,44 +122,60 @@ export async function watchRepository(options = {}, dependencies = {}) {
34
122
  scope,
35
123
  summary: summarizeScan(summary)
36
124
  });
37
- const scanEndFingerprint = await fingerprintFn(repository.root);
38
- currentFingerprint = scanEndFingerprint;
125
+ if (polling) {
126
+ const scanEndFingerprint = await fingerprintFn(repository.root);
127
+ const changedDuringScan = scanStartFingerprint !== undefined && scanEndFingerprint !== scanStartFingerprint;
128
+ currentFingerprint = scanEndFingerprint;
129
+ if (changedDuringScan)
130
+ followUpQueued = true;
131
+ }
39
132
  if (signal?.aborted)
40
133
  break;
41
- if (scanEndFingerprint !== scanStartFingerprint) {
42
- emit({
43
- event: 'rescan-triggered',
44
- mode,
45
- reason: 'follow-up',
46
- repository,
47
- scope
48
- });
134
+ if (followUpQueued) {
135
+ followUpQueued = false;
136
+ pendingEventPaths = new Set();
137
+ emitRescanTriggered('follow-up', mode, repository, scope, emit, progress);
49
138
  continue;
50
139
  }
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) {
140
+ emit({ event: 'waiting', mode, repository, scope });
141
+ reportWatchProgress(progress, { event: 'waiting', mode, repository, scope, detail: polling ? 'waiting for polling interval' : 'waiting for file changes' });
142
+ if (polling) {
143
+ while (!signal?.aborted) {
144
+ await wait(intervalMs, signal);
145
+ const nextFingerprint = await fingerprintFn(repository.root);
146
+ if (nextFingerprint === currentFingerprint)
147
+ continue;
61
148
  currentFingerprint = nextFingerprint;
62
- emit({
63
- event: 'rescan-triggered',
64
- mode,
65
- reason: 'file-change',
66
- repository,
67
- scope
68
- });
149
+ emitRescanTriggered('file-change', mode, repository, scope, emit, progress);
69
150
  break;
70
151
  }
152
+ continue;
153
+ }
154
+ if (pendingEventPaths.size === 0) {
155
+ await waitForWatchEvent(signal, (resolve) => {
156
+ wakeWaiting = resolve;
157
+ });
71
158
  }
159
+ if (signal?.aborted)
160
+ break;
161
+ if (polling)
162
+ continue;
163
+ if (pendingEventPaths.size === 0)
164
+ continue;
165
+ await wait(debounceMs, signal);
166
+ if (signal?.aborted)
167
+ break;
168
+ pendingEventPaths = new Set();
169
+ emitRescanTriggered('file-change', mode, repository, scope, emit, progress);
72
170
  }
73
171
  }
172
+ catch (error) {
173
+ failed = true;
174
+ reportWatchProgress(progress, { event: 'failed', mode, repository, scope, detail: error instanceof Error ? error.message : String(error) });
175
+ throw error;
176
+ }
74
177
  finally {
178
+ closeWatchers(watchers);
75
179
  stopped = true;
76
180
  emit({
77
181
  event: 'stopped',
@@ -80,28 +184,90 @@ export async function watchRepository(options = {}, dependencies = {}) {
80
184
  scope,
81
185
  scans
82
186
  });
187
+ if (!failed)
188
+ reportWatchProgress(progress, { event: 'stopped', mode, repository, scope, detail: `watch session stopped after ${scans} scan(s)` });
83
189
  }
84
- return {
85
- repository,
86
- scope,
87
- scans,
88
- stopped
89
- };
190
+ return { repository, scope, scans, stopped };
90
191
  }
91
192
  function resolveWatchScope(scope) {
92
193
  if (scope === undefined || scope === 'repo')
93
194
  return 'repo';
94
195
  throw new Error('watch mode supports repo scope only');
95
196
  }
197
+ function watchDirectories(root) {
198
+ const directories = new Set([root]);
199
+ for (const relativePath of discoverSourceFiles(root)) {
200
+ directories.add(path.dirname(path.join(root, relativePath)));
201
+ }
202
+ // Immediate module directories contain the supported Maven/Gradle manifests
203
+ // and must also be watched when a module has no source files yet.
204
+ try {
205
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
206
+ if (entry.isDirectory() && !entry.isSymbolicLink() && !IGNORED_WATCH_DIRECTORIES.has(entry.name))
207
+ directories.add(path.join(root, entry.name));
208
+ }
209
+ }
210
+ catch { /* scan will report traversal degradation */ }
211
+ return [...directories].sort();
212
+ }
213
+ function relevantWatchPath(root, watchedDirectory, filename) {
214
+ const filenameText = Buffer.isBuffer(filename) ? filename.toString() : filename;
215
+ const absolutePath = path.resolve(watchedDirectory, filenameText);
216
+ const relativePath = path.relative(root, absolutePath);
217
+ if (!relativePath || path.isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${path.sep}`))
218
+ return undefined;
219
+ const pathSegments = relativePath.split(path.sep);
220
+ if (pathSegments.some((segment) => IGNORED_WATCH_DIRECTORIES.has(segment)))
221
+ return undefined;
222
+ const basename = path.basename(relativePath);
223
+ const depth = pathSegments.length;
224
+ const supportedManifest = depth <= 2 && (basename === 'pom.xml' || basename === 'build.gradle' || basename === 'build.gradle.kts');
225
+ if (basename !== 'package.json' && !isSupportedSourceFile(absolutePath) && !supportedManifest)
226
+ return undefined;
227
+ return relativePath;
228
+ }
229
+ function closeWatchers(watchers) {
230
+ while (watchers.length > 0) {
231
+ watchers.pop()?.close();
232
+ }
233
+ }
234
+ function waitForWatchEvent(signal, setWake) {
235
+ return new Promise((resolve) => {
236
+ const done = () => {
237
+ signal?.removeEventListener('abort', done);
238
+ resolve();
239
+ };
240
+ if (signal?.aborted) {
241
+ done();
242
+ return;
243
+ }
244
+ setWake(done);
245
+ signal?.addEventListener('abort', done, { once: true });
246
+ });
247
+ }
248
+ function emitRescanTriggered(reason, mode, repository, scope, emit, progress) {
249
+ emit({ event: 'rescan-triggered', mode, reason, repository, scope });
250
+ 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' });
251
+ }
96
252
  function summarizeScan(summary) {
97
253
  return {
98
254
  filesDiscovered: summary.filesDiscovered,
99
255
  filesScanned: summary.filesScanned,
100
256
  nodesWritten: summary.nodesWritten,
101
257
  edgesWritten: summary.edgesWritten,
102
- skippedCount: summary.skipped.length
258
+ skippedCount: summary.skipped.length,
259
+ enrichment: summary.enrichment
103
260
  };
104
261
  }
262
+ function reportWatchProgress(progress, event) {
263
+ try {
264
+ progress?.({ kind: 'watch', ...event });
265
+ }
266
+ catch { /* progress is observational */ }
267
+ }
268
+ function defaultWatcherFactory(directory, options, listener) {
269
+ return watchFileSystem(directory, options, (event, filename) => listener(event, filename));
270
+ }
105
271
  function defaultEmit(event) {
106
272
  console.log(JSON.stringify(event));
107
273
  }
@@ -110,9 +276,8 @@ async function defaultWait(ms, signal) {
110
276
  await sleep(ms, undefined, signal ? { signal } : undefined);
111
277
  }
112
278
  catch (error) {
113
- if (!signal?.aborted) {
279
+ if (!signal?.aborted)
114
280
  throw error;
115
- }
116
281
  }
117
282
  }
118
283
  //# sourceMappingURL=watch.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psnext/lscg",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local source context graphs for repositories, exposed as a CLI and MCP server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,9 @@
33
33
  "pretest": "npm run build",
34
34
  "test": "node --test ./dist/test/*.test.js",
35
35
  "prebenchmark:context": "npm run build",
36
- "benchmark:context": "node ./dist/test/context-benchmark.js"
36
+ "benchmark:context": "node ./dist/test/context-benchmark.js",
37
+ "prebenchmark:scan-watch": "npm run build",
38
+ "benchmark:scan-watch": "node ./dist/test/scan-watch-benchmark.js"
37
39
  },
38
40
  "keywords": [
39
41
  "tree-sitter",
@@ -51,13 +53,17 @@
51
53
  "install-scripts": "^1.2.0",
52
54
  "tree-sitter": "^0.21.1",
53
55
  "tree-sitter-javascript": "^0.23.1",
56
+ "tree-sitter-python": "^0.21.0",
57
+ "tree-sitter-java": "0.21.0",
54
58
  "tree-sitter-typescript": "^0.23.2",
55
59
  "zod": "^3.25.76"
56
60
  },
57
61
  "allowScripts": {
58
62
  "tree-sitter@0.21.1": true,
59
63
  "tree-sitter-javascript@0.23.1": true,
60
- "tree-sitter-typescript@0.23.2": true
64
+ "tree-sitter-typescript@0.23.2": true,
65
+ "tree-sitter-python@0.21.0": true,
66
+ "tree-sitter-java@0.21.0": true
61
67
  },
62
68
  "devDependencies": {
63
69
  "@types/node": "^26.1.2",