@psnext/lscg 0.1.4 → 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 (49) 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 +83 -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/watch.d.ts +17 -2
  48. package/dist/src/watch.js +208 -46
  49. package/package.json +9 -3
@@ -1,11 +1,77 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { parsePoint } from './row-decoders.js';
3
+ export function selectFreshnessReport(db, repositoryId) {
4
+ const scanState = db.prepare('SELECT state FROM repository_scan_state WHERE repository_id = ?').get(repositoryId);
5
+ if (!scanState?.state)
6
+ return { state: 'stale', reason: 'not_scanned' };
7
+ const failures = db.prepare('SELECT count(*) AS count FROM file_scan_failures WHERE repository_id = ?').get(repositoryId);
8
+ if (scanState.state === 'degraded' || failures.count > 0)
9
+ return { state: 'degraded', reason: 'retained_failure' };
10
+ if (scanState.state === 'stale')
11
+ return { state: 'stale', reason: 'known_invalidation' };
12
+ return { state: 'fresh' };
13
+ }
14
+ export function selectFileEnrichmentState(db, repositoryId, fileId) {
15
+ const row = db.prepare(`
16
+ SELECT repository_id AS repositoryId, file_id AS fileId, source_hash AS sourceHash,
17
+ state, diagnostic, queued_at AS queuedAt, updated_at AS updatedAt,
18
+ completed_at AS completedAt, failed_at AS failedAt
19
+ FROM file_enrichment_state
20
+ WHERE repository_id = ? AND file_id = ?
21
+ `).get(repositoryId, fileId);
22
+ return row ?? null;
23
+ }
24
+ export function selectFileEnrichmentStates(db, repositoryId, states = ['pending', 'failed']) {
25
+ if (states.length === 0)
26
+ return [];
27
+ const placeholders = states.map(() => '?').join(', ');
28
+ return db.prepare(`
29
+ SELECT repository_id AS repositoryId, file_id AS fileId, source_hash AS sourceHash,
30
+ state, diagnostic, queued_at AS queuedAt, updated_at AS updatedAt,
31
+ completed_at AS completedAt, failed_at AS failedAt
32
+ FROM file_enrichment_state
33
+ WHERE repository_id = ? AND state IN (${placeholders})
34
+ ORDER BY queued_at, file_id
35
+ `).all(repositoryId, ...states);
36
+ }
37
+ export function selectEnrichmentWork(db, repositoryId, states = ['pending', 'failed']) {
38
+ if (states.length === 0)
39
+ return [];
40
+ const placeholders = states.map(() => '?').join(', ');
41
+ return db.prepare(`
42
+ SELECT e.repository_id AS repositoryId, e.file_id AS fileId, e.source_hash AS sourceHash,
43
+ e.state, e.diagnostic, e.queued_at AS queuedAt, e.updated_at AS updatedAt,
44
+ e.completed_at AS completedAt, e.failed_at AS failedAt, f.path, f.language
45
+ FROM file_enrichment_state e
46
+ JOIN files f ON f.id = e.file_id
47
+ WHERE e.repository_id = ? AND e.state IN (${placeholders})
48
+ ORDER BY e.queued_at, e.file_id
49
+ `).all(repositoryId, ...states);
50
+ }
51
+ export function selectEnrichmentSummary(db, repositoryId) {
52
+ const states = selectFileEnrichmentStates(db, repositoryId, ['pending', 'complete', 'failed']);
53
+ const failedEntries = states.filter((entry) => entry.state === 'failed');
54
+ return {
55
+ state: failedEntries.length > 0 ? 'failed' : states.some((entry) => entry.state === 'pending') ? 'pending' : 'complete',
56
+ pending: states.filter((entry) => entry.state === 'pending').length,
57
+ complete: states.filter((entry) => entry.state === 'complete').length,
58
+ failed: failedEntries.length,
59
+ diagnostics: failedEntries.flatMap((entry) => entry.diagnostic ? [entry.diagnostic] : [])
60
+ };
61
+ }
3
62
  export function getStatus(db, repositoryId) {
4
63
  const repository = db.prepare('SELECT * FROM repositories WHERE id = ?').get(repositoryId);
5
64
  const files = db.prepare('SELECT count(*) AS count FROM files WHERE repository_id = ?').get(repositoryId).count;
6
65
  const nodes = db.prepare('SELECT count(*) AS count FROM nodes WHERE repository_id = ?').get(repositoryId).count;
7
66
  const edges = db.prepare('SELECT count(*) AS count FROM edges WHERE repository_id = ?').get(repositoryId).count;
8
- return { repository: repository ?? null, files, nodes, edges };
67
+ return {
68
+ repository: repository ?? null,
69
+ files,
70
+ nodes,
71
+ edges,
72
+ freshness: selectFreshnessReport(db, repositoryId),
73
+ enrichment: selectEnrichmentSummary(db, repositoryId)
74
+ };
9
75
  }
10
76
  export function selectNodes(db, repositoryId, { kind, limit = 50 } = {}) {
11
77
  const safeLimit = clampLimit(limit);
@@ -1,4 +1,4 @@
1
- export declare const SCHEMA_VERSION = 4;
2
- export declare const CREATE_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS schema_migrations (\n version INTEGER PRIMARY KEY,\n applied_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS repositories (\n id TEXT PRIMARY KEY,\n root TEXT NOT NULL,\n name TEXT NOT NULL,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS files (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n path TEXT NOT NULL,\n language TEXT NOT NULL,\n hash TEXT NOT NULL,\n size INTEGER NOT NULL,\n mtime_ms INTEGER NOT NULL,\n scanned_at TEXT NOT NULL DEFAULT (datetime('now')),\n UNIQUE(repository_id, path)\n);\n\nCREATE TABLE IF NOT EXISTS nodes (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n file_id TEXT REFERENCES files(id) ON DELETE CASCADE,\n kind TEXT NOT NULL,\n type TEXT NOT NULL,\n name TEXT,\n start_byte INTEGER NOT NULL,\n end_byte INTEGER NOT NULL,\n start_point TEXT NOT NULL,\n end_point TEXT NOT NULL,\n source_hash TEXT NOT NULL,\n parser TEXT NOT NULL,\n parser_version TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n last_modified_user_id TEXT\n);\n\nCREATE TABLE IF NOT EXISTS edges (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n file_id TEXT REFERENCES files(id) ON DELETE CASCADE,\n source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,\n target_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,\n kind TEXT NOT NULL,\n confidence REAL NOT NULL DEFAULT 1.0,\n metadata_json TEXT NOT NULL DEFAULT '{}'\n);\n\nCREATE TABLE IF NOT EXISTS overlays (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n subject_kind TEXT NOT NULL,\n operation TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_files_repo_path ON files(repository_id, path);\nCREATE INDEX IF NOT EXISTS idx_nodes_repo_kind ON nodes(repository_id, kind);\nCREATE INDEX IF NOT EXISTS idx_nodes_repo_name ON nodes(repository_id, name);\nCREATE INDEX IF NOT EXISTS idx_edges_repo_kind ON edges(repository_id, kind);\nCREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);\nCREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);\n\nCREATE TABLE IF NOT EXISTS plugin_contributions (\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n plugin_name TEXT NOT NULL,\n generation INTEGER NOT NULL,\n nodes_json TEXT NOT NULL,\n edges_json TEXT NOT NULL,\n updated_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY(repository_id, plugin_name)\n);\nCREATE INDEX IF NOT EXISTS idx_plugin_contributions_repo ON plugin_contributions(repository_id);\n";
1
+ export declare const SCHEMA_VERSION = 7;
2
+ export declare const CREATE_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS schema_migrations (\n version INTEGER PRIMARY KEY,\n applied_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS repositories (\n id TEXT PRIMARY KEY,\n root TEXT NOT NULL,\n name TEXT NOT NULL,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS files (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n path TEXT NOT NULL,\n language TEXT NOT NULL,\n hash TEXT NOT NULL,\n size INTEGER NOT NULL,\n mtime_ms INTEGER NOT NULL,\n scanned_at TEXT NOT NULL DEFAULT (datetime('now')),\n UNIQUE(repository_id, path)\n);\n\nCREATE TABLE IF NOT EXISTS nodes (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n file_id TEXT REFERENCES files(id) ON DELETE CASCADE,\n kind TEXT NOT NULL,\n type TEXT NOT NULL,\n name TEXT,\n start_byte INTEGER NOT NULL,\n end_byte INTEGER NOT NULL,\n start_point TEXT NOT NULL,\n end_point TEXT NOT NULL,\n source_hash TEXT NOT NULL,\n parser TEXT NOT NULL,\n parser_version TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n last_modified_user_id TEXT\n);\n\nCREATE TABLE IF NOT EXISTS edges (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n file_id TEXT REFERENCES files(id) ON DELETE CASCADE,\n source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,\n target_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,\n kind TEXT NOT NULL,\n confidence REAL NOT NULL DEFAULT 1.0,\n metadata_json TEXT NOT NULL DEFAULT '{}'\n);\n\nCREATE TABLE IF NOT EXISTS overlays (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n subject_kind TEXT NOT NULL,\n operation TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_files_repo_path ON files(repository_id, path);\nCREATE INDEX IF NOT EXISTS idx_nodes_repo_kind ON nodes(repository_id, kind);\nCREATE INDEX IF NOT EXISTS idx_nodes_repo_name ON nodes(repository_id, name);\nCREATE INDEX IF NOT EXISTS idx_edges_repo_kind ON edges(repository_id, kind);\nCREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);\nCREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);\n\nCREATE TABLE IF NOT EXISTS plugin_contributions (\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n plugin_name TEXT NOT NULL,\n generation INTEGER NOT NULL,\n nodes_json TEXT NOT NULL,\n edges_json TEXT NOT NULL,\n updated_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY(repository_id, plugin_name)\n);\nCREATE INDEX IF NOT EXISTS idx_plugin_contributions_repo ON plugin_contributions(repository_id);\n\nCREATE TABLE IF NOT EXISTS file_scan_failures (\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n path TEXT NOT NULL,\n diagnostic TEXT NOT NULL,\n failed_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY(repository_id, path)\n);\n\nCREATE TABLE IF NOT EXISTS repository_scan_state (\n repository_id TEXT PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,\n state TEXT NOT NULL CHECK(state IN ('fresh', 'degraded', 'stale')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS file_enrichment_state (\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n file_id TEXT PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,\n source_hash TEXT NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('pending', 'complete', 'failed')),\n diagnostic TEXT,\n queued_at TEXT NOT NULL DEFAULT (datetime('now')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now')),\n completed_at TEXT,\n failed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_file_enrichment_state_repo_state ON file_enrichment_state(repository_id, state);\n\nCREATE TABLE IF NOT EXISTS repository_manifest_inventory (\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n path TEXT NOT NULL,\n kind TEXT NOT NULL CHECK(kind IN ('maven-pom', 'gradle-groovy', 'gradle-kotlin')),\n module_identity TEXT NOT NULL,\n status TEXT NOT NULL CHECK(status IN ('observed', 'read_failed', 'traversal_incomplete', 'confirmed_deleted')),\n size INTEGER NOT NULL,\n mtime_ms INTEGER NOT NULL,\n source_hash TEXT,\n generation INTEGER NOT NULL,\n updated_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY(repository_id, path)\n);\nCREATE INDEX IF NOT EXISTS idx_repository_manifest_inventory_repo_status\n ON repository_manifest_inventory(repository_id, status);\n";
3
3
  export declare const CREATE_NODES_TABLE_SQL = "\nCREATE TABLE nodes_new (\n id TEXT PRIMARY KEY,\n repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,\n file_id TEXT REFERENCES files(id) ON DELETE CASCADE,\n kind TEXT NOT NULL,\n type TEXT NOT NULL,\n name TEXT,\n start_byte INTEGER NOT NULL,\n end_byte INTEGER NOT NULL,\n start_point TEXT NOT NULL,\n end_point TEXT NOT NULL,\n source_hash TEXT NOT NULL,\n parser TEXT NOT NULL,\n parser_version TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n last_modified_user_id TEXT\n);\n";
4
4
  //# sourceMappingURL=schema.d.ts.map
@@ -1,4 +1,4 @@
1
- export const SCHEMA_VERSION = 4;
1
+ export const SCHEMA_VERSION = 7;
2
2
  export const CREATE_SCHEMA_SQL = `
3
3
  CREATE TABLE IF NOT EXISTS schema_migrations (
4
4
  version INTEGER PRIMARY KEY,
@@ -81,6 +81,49 @@ CREATE TABLE IF NOT EXISTS plugin_contributions (
81
81
  PRIMARY KEY(repository_id, plugin_name)
82
82
  );
83
83
  CREATE INDEX IF NOT EXISTS idx_plugin_contributions_repo ON plugin_contributions(repository_id);
84
+
85
+ CREATE TABLE IF NOT EXISTS file_scan_failures (
86
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
87
+ path TEXT NOT NULL,
88
+ diagnostic TEXT NOT NULL,
89
+ failed_at TEXT NOT NULL DEFAULT (datetime('now')),
90
+ PRIMARY KEY(repository_id, path)
91
+ );
92
+
93
+ CREATE TABLE IF NOT EXISTS repository_scan_state (
94
+ repository_id TEXT PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
95
+ state TEXT NOT NULL CHECK(state IN ('fresh', 'degraded', 'stale')),
96
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
97
+ );
98
+
99
+ CREATE TABLE IF NOT EXISTS file_enrichment_state (
100
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
101
+ file_id TEXT PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
102
+ source_hash TEXT NOT NULL,
103
+ state TEXT NOT NULL CHECK(state IN ('pending', 'complete', 'failed')),
104
+ diagnostic TEXT,
105
+ queued_at TEXT NOT NULL DEFAULT (datetime('now')),
106
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
107
+ completed_at TEXT,
108
+ failed_at TEXT
109
+ );
110
+ CREATE INDEX IF NOT EXISTS idx_file_enrichment_state_repo_state ON file_enrichment_state(repository_id, state);
111
+
112
+ CREATE TABLE IF NOT EXISTS repository_manifest_inventory (
113
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
114
+ path TEXT NOT NULL,
115
+ kind TEXT NOT NULL CHECK(kind IN ('maven-pom', 'gradle-groovy', 'gradle-kotlin')),
116
+ module_identity TEXT NOT NULL,
117
+ status TEXT NOT NULL CHECK(status IN ('observed', 'read_failed', 'traversal_incomplete', 'confirmed_deleted')),
118
+ size INTEGER NOT NULL,
119
+ mtime_ms INTEGER NOT NULL,
120
+ source_hash TEXT,
121
+ generation INTEGER NOT NULL,
122
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
123
+ PRIMARY KEY(repository_id, path)
124
+ );
125
+ CREATE INDEX IF NOT EXISTS idx_repository_manifest_inventory_repo_status
126
+ ON repository_manifest_inventory(repository_id, status);
84
127
  `;
85
128
  export const CREATE_NODES_TABLE_SQL = `
86
129
  CREATE TABLE nodes_new (
@@ -73,6 +73,51 @@ export interface FileAttribution {
73
73
  contributorEmails: ContributorIdentity[];
74
74
  nodeAttributions: NodeAttribution[];
75
75
  }
76
+ /** The result of attempting optional Git attribution for one file. */
77
+ export type AttributionOutcome = {
78
+ status: 'complete';
79
+ attribution: FileAttribution;
80
+ } | {
81
+ status: 'unavailable';
82
+ reason: 'not_applicable' | 'unavailable';
83
+ } | {
84
+ status: 'failed';
85
+ diagnostic: string;
86
+ };
87
+ export type EnrichmentState = 'pending' | 'complete' | 'failed';
88
+ /** Durable, hash-bound optional-enrichment work for a scanned file. */
89
+ export interface FileEnrichmentState {
90
+ repositoryId: string;
91
+ fileId: string;
92
+ sourceHash: string;
93
+ state: EnrichmentState;
94
+ diagnostic: string | null;
95
+ queuedAt: string;
96
+ updatedAt: string;
97
+ completedAt: string | null;
98
+ failedAt: string | null;
99
+ }
100
+ export interface EnrichmentWork extends FileEnrichmentState {
101
+ path: string;
102
+ language: string;
103
+ }
104
+ export type AttributionApplyResult = 'applied' | 'stale';
105
+ /** Input supplied to an optional attribution runner for hash-compatible work. */
106
+ export interface EnrichmentRunnerInput {
107
+ repository: RepositoryRecord;
108
+ work: EnrichmentWork;
109
+ source: string;
110
+ nodes: GraphNode[];
111
+ }
112
+ /** Injectable optional-attribution implementation used when enrichment is drained. */
113
+ export type EnrichmentRunner = (input: EnrichmentRunnerInput) => AttributionOutcome | Promise<AttributionOutcome>;
114
+ export interface EnrichmentSummary {
115
+ state: EnrichmentState;
116
+ pending: number;
117
+ complete: number;
118
+ failed: number;
119
+ diagnostics: string[];
120
+ }
76
121
  export interface ScanSummary {
77
122
  repository: RepositoryRecord;
78
123
  scopes: Array<{
@@ -84,10 +129,56 @@ export interface ScanSummary {
84
129
  nodesWritten: number;
85
130
  edgesWritten: number;
86
131
  skipped: string[];
132
+ enrichment: EnrichmentSummary;
87
133
  pluginDiagnostics?: string[];
88
134
  failedPlugins?: string[];
89
135
  stalePlugins?: string[];
90
136
  }
137
+ /**
138
+ * Internal, transport-neutral facts emitted by an explicit scan. These are
139
+ * deliberately aggregate/lifecycle events rather than a public wire format.
140
+ * In particular, counters describe one scan operation (including scope=both),
141
+ * not one database handle.
142
+ */
143
+ export type ScanProgressOperation = 'incremental' | 'full';
144
+ export type ScanProgressPhase = 'discovery' | 'plugins' | 'files' | 'reconciliation' | 'enrichment' | 'terminal';
145
+ export type ScanProgressStatus = 'started' | 'completed' | 'processed' | 'skipped' | 'deferred' | 'failed' | 'retrying' | 'degraded';
146
+ export interface ScanProgressEvent {
147
+ kind: 'scan';
148
+ operation: ScanProgressOperation;
149
+ scope: GraphScope;
150
+ phase: ScanProgressPhase;
151
+ status: ScanProgressStatus;
152
+ repository: RepositoryRecord;
153
+ attempt?: number;
154
+ detail?: string;
155
+ path?: string;
156
+ counts?: {
157
+ discovered?: number;
158
+ selected?: number;
159
+ unchanged?: number;
160
+ deleted?: number;
161
+ processed?: number;
162
+ skipped?: number;
163
+ plugins?: number;
164
+ failedPlugins?: number;
165
+ stalePlugins?: number;
166
+ pending?: number;
167
+ complete?: number;
168
+ failed?: number;
169
+ };
170
+ }
171
+ export type WatchProgressEventName = 'watching' | 'initial-scan' | 'rescan' | 'coalesced-change' | 'follow-up' | 'waiting' | 'polling-fallback' | 'enrichment' | 'stopped' | 'failed';
172
+ export interface WatchProgressEvent {
173
+ kind: 'watch';
174
+ event: WatchProgressEventName;
175
+ mode: 'watch' | 'scan --watch';
176
+ repository: RepositoryRecord;
177
+ scope: StorageScope;
178
+ detail?: string;
179
+ }
180
+ export type ProgressEvent = ScanProgressEvent | WatchProgressEvent;
181
+ export type ProgressReporter = (event: ProgressEvent) => void;
91
182
  export interface RepositoryInfo {
92
183
  id: string;
93
184
  root: string;
@@ -142,15 +233,24 @@ export interface CallGraphMatch extends GraphNodeTextRow {
142
233
  upstream: CallGraphRelation[];
143
234
  downstream: CallGraphRelation[];
144
235
  }
236
+ export type FreshnessState = 'fresh' | 'stale' | 'degraded';
237
+ export type FreshnessReason = 'not_scanned' | 'known_invalidation' | 'retained_failure';
238
+ /** Persisted graph freshness, evaluated only from stored scan state. */
239
+ export interface FreshnessReport {
240
+ state: FreshnessState;
241
+ reason?: FreshnessReason | undefined;
242
+ }
145
243
  export interface StatusResult {
146
244
  repository: RepositoryRecord | null;
147
245
  files: number;
148
246
  nodes: number;
149
247
  edges: number;
248
+ freshness: FreshnessReport;
249
+ enrichment: EnrichmentSummary;
150
250
  }
151
251
  /** Stable, transport-neutral contract returned by the context retrieval command. */
152
252
  export type ContextResultState = 'ok' | 'ambiguous' | 'not_found' | 'refresh_failed' | 'stale';
153
- export type ContextFreshnessState = 'unchanged' | 'refreshed' | 'stale' | 'refresh_failed';
253
+ export type ContextFreshnessState = FreshnessState;
154
254
  export type ContextFreshnessVerification = 'metadata_only' | 'content_hash';
155
255
  export type ContextRelationCategory = 'definition' | 'impact' | 'dependency';
156
256
  export interface ContextBudget {
@@ -224,11 +324,7 @@ export interface ContextScopeResult {
224
324
  warnings: string[];
225
325
  truncation: ContextTruncation[];
226
326
  };
227
- freshness: {
228
- state: ContextFreshnessState;
229
- verification: ContextFreshnessVerification;
230
- refresh: ContextRefreshSummary;
231
- };
327
+ freshness: FreshnessReport;
232
328
  }
233
329
  export interface ContextGraphResult {
234
330
  contract_version: 1;
@@ -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