@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
@@ -0,0 +1,184 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { parseMetadata, parsePoint } from './row-decoders.js';
3
+ const EXPLORE_KINDS = ['contains', 'defines', 'calls', 'imports', 'exports'];
4
+ export function selectExploreAggregates(db, repositoryId, search) {
5
+ const predicates = ['f.repository_id = ?'];
6
+ const params = [repositoryId];
7
+ if (search?.trim()) {
8
+ predicates.push('(lower(f.path) LIKE ? OR lower(f.path) = ?)');
9
+ const normalized = search.trim().toLocaleLowerCase();
10
+ params.push(`%${normalized}%`, normalized);
11
+ }
12
+ const rows = db.prepare(`
13
+ SELECT f.id, f.path, f.language, f.repository_id AS repositoryId,
14
+ (SELECT count(*) FROM nodes n WHERE n.repository_id = f.repository_id AND n.file_id = f.id) AS childCount
15
+ FROM files f
16
+ WHERE ${predicates.join(' AND ')}
17
+ ORDER BY lower(f.path), f.id
18
+ `).all(...params);
19
+ return rows.map((row) => ({ ...row, id: aggregateId(repositoryId, row.id), childCount: Number(row.childCount) }));
20
+ }
21
+ export function selectExploreAggregateEdges(db, repositoryId, kinds = EXPLORE_KINDS) {
22
+ const safeKinds = kinds.filter((kind) => EXPLORE_KINDS.includes(kind));
23
+ if (safeKinds.length === 0)
24
+ return [];
25
+ const placeholders = safeKinds.map(() => '?').join(', ');
26
+ const rows = db.prepare(`
27
+ SELECT MIN(e.id) AS id, e.kind,
28
+ 'aggregate:' || e.repository_id || ':' || sf.id AS sourceAggregateId,
29
+ 'aggregate:' || e.repository_id || ':' || tf.id AS targetAggregateId,
30
+ AVG(e.confidence) AS confidence, count(*) AS count
31
+ FROM edges e
32
+ JOIN nodes s ON s.id = e.source_id AND s.repository_id = e.repository_id
33
+ JOIN nodes t ON t.id = e.target_id AND t.repository_id = e.repository_id
34
+ LEFT JOIN files sf ON sf.id = s.file_id
35
+ LEFT JOIN files tf ON tf.id = t.file_id
36
+ WHERE e.repository_id = ? AND e.kind IN (${placeholders}) AND sf.id IS NOT NULL AND tf.id IS NOT NULL AND sf.id <> tf.id
37
+ GROUP BY sf.id, tf.id, e.kind
38
+ ORDER BY sourceAggregateId, targetAggregateId, e.kind
39
+ `).all(repositoryId, ...safeKinds);
40
+ return rows.map((row) => ({
41
+ id: String(row.id), sourceId: String(row.sourceAggregateId), targetId: String(row.targetAggregateId),
42
+ sourceAggregateId: String(row.sourceAggregateId), targetAggregateId: String(row.targetAggregateId),
43
+ kind: row.kind, confidence: Number(row.confidence ?? 0), count: Number(row.count ?? 0), metadata: {}
44
+ }));
45
+ }
46
+ export function selectExploreSourcesForSearch(db, repositoryId, term, limit = 200) {
47
+ const normalized = term.trim().toLocaleLowerCase();
48
+ if (!normalized)
49
+ return [];
50
+ const rows = db.prepare(`
51
+ SELECT n.id, n.kind, n.type, n.name, n.file_id AS fileId,
52
+ n.start_point AS startPoint, n.end_point AS endPoint, n.metadata_json AS metadataJson, f.path AS path
53
+ FROM nodes n LEFT JOIN files f ON f.id = n.file_id
54
+ WHERE n.repository_id = ? AND (
55
+ lower(COALESCE(n.name, '')) = ? OR
56
+ lower(COALESCE(f.path, '') || ':' || COALESCE(n.name, '')) = ? OR
57
+ lower(COALESCE(n.name, '')) LIKE ? OR
58
+ lower(COALESCE(n.name, '')) LIKE ?
59
+ )
60
+ ORDER BY CASE
61
+ WHEN lower(COALESCE(f.path, '') || ':' || COALESCE(n.name, '')) = ? THEN 0
62
+ WHEN lower(COALESCE(n.name, '')) = ? THEN 1
63
+ WHEN lower(COALESCE(n.name, '')) LIKE ? THEN 2 ELSE 3 END,
64
+ lower(COALESCE(f.path, '')), lower(COALESCE(n.name, '')), n.id
65
+ LIMIT ?
66
+ `).all(repositoryId, normalized, normalized, `${normalized}%`, `%${normalized}%`, normalized, normalized, `${normalized}%`, Math.max(1, Math.min(1000, limit)));
67
+ return rows.map(sourceFromRow);
68
+ }
69
+ export function selectExploreCandidates(db, repositoryId, term, { kind, file, fileAnchor = false, limit = 20 } = {}) {
70
+ const normalized = term.trim().toLocaleLowerCase();
71
+ if (!normalized)
72
+ return [];
73
+ const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
74
+ const explicitFiles = db.prepare('SELECT id, path FROM files WHERE repository_id = ? AND path = ? ORDER BY id LIMIT ?').all(repositoryId, term.trim(), safeLimit);
75
+ if (fileAnchor && explicitFiles.length > 0)
76
+ return explicitFiles.map((row, index) => {
77
+ const fileId = String(row.id);
78
+ const filePath = String(row.path);
79
+ return { id: aggregateId(repositoryId, fileId), kind: 'file', type: 'file', name: filePath, fileId, path: filePath, startPoint: { row: 0, column: 0 }, endPoint: { row: 0, column: 0 }, metadata: {}, rank: index, match: 'path', qualifiedName: filePath, aggregateId: aggregateId(repositoryId, fileId) };
80
+ });
81
+ const stages = [
82
+ { match: 'exact', predicate: 'lower(COALESCE(n.name, \'\')) = ?', params: [normalized] },
83
+ { match: 'qualified', predicate: "lower(COALESCE(f.path, '') || ':' || COALESCE(n.name, '')) = ?", params: [normalized] },
84
+ { match: 'prefix', predicate: 'lower(COALESCE(n.name, \'\')) LIKE ?', params: [`${normalized}%`] },
85
+ { match: 'substring', predicate: 'lower(COALESCE(n.name, \'\')) LIKE ?', params: [`%${normalized}%`] }
86
+ ];
87
+ const result = [];
88
+ const seen = new Set();
89
+ for (const stage of stages) {
90
+ const predicates = ['n.repository_id = ?', stage.predicate];
91
+ const params = [repositoryId, ...stage.params];
92
+ if (kind) {
93
+ predicates.push('n.kind = ?');
94
+ params.push(kind);
95
+ }
96
+ if (file) {
97
+ predicates.push('f.path = ?');
98
+ params.push(file);
99
+ }
100
+ const rows = db.prepare(`
101
+ SELECT n.id, n.kind, n.type, n.name, n.file_id AS fileId,
102
+ n.start_point AS startPoint, n.end_point AS endPoint, n.metadata_json AS metadataJson, f.path AS path
103
+ FROM nodes n LEFT JOIN files f ON f.id = n.file_id
104
+ WHERE ${predicates.join(' AND ')}
105
+ ORDER BY lower(COALESCE(f.path, '')), lower(COALESCE(n.name, '')), n.id LIMIT ?
106
+ `).all(...params, safeLimit);
107
+ for (const row of rows) {
108
+ const source = sourceFromRow(row);
109
+ if (seen.has(source.id))
110
+ continue;
111
+ seen.add(source.id);
112
+ result.push({ ...source, rank: result.length, match: stage.match, qualifiedName: qualifiedName(source), aggregateId: source.fileId ? aggregateId(repositoryId, source.fileId) : null });
113
+ }
114
+ if (result.length > 0)
115
+ break;
116
+ }
117
+ // A path is an explicit file identity and resolves to its aggregate.
118
+ for (const row of explicitFiles) {
119
+ const fileId = String(row.id);
120
+ const id = aggregateId(repositoryId, fileId);
121
+ if (seen.has(id))
122
+ continue;
123
+ result.push({ id, kind: 'file', type: 'file', name: String(row.path), fileId, path: String(row.path), startPoint: { row: 0, column: 0 }, endPoint: { row: 0, column: 0 }, metadata: {}, rank: result.length, match: 'path', qualifiedName: String(row.path), aggregateId: id });
124
+ }
125
+ return result.slice(0, safeLimit);
126
+ }
127
+ export function selectExploreNode(db, repositoryId, id) {
128
+ const row = db.prepare(`
129
+ SELECT n.id, n.kind, n.type, n.name, n.file_id AS fileId,
130
+ n.start_point AS startPoint, n.end_point AS endPoint, n.metadata_json AS metadataJson, f.path AS path
131
+ FROM nodes n LEFT JOIN files f ON f.id = n.file_id WHERE n.repository_id = ? AND n.id = ?
132
+ `).get(repositoryId, id);
133
+ return row ? sourceFromRow(row) : null;
134
+ }
135
+ export function selectExploreTraversalEdges(db, repositoryId, frontier, direction, kinds, limit) {
136
+ if (frontier.length === 0 || limit <= 0)
137
+ return [];
138
+ const safeKinds = kinds.filter((kind) => EXPLORE_KINDS.includes(kind));
139
+ if (safeKinds.length === 0)
140
+ return [];
141
+ const nodePlaceholders = frontier.map(() => '?').join(', ');
142
+ const kindPlaceholders = safeKinds.map(() => '?').join(', ');
143
+ const adjacency = direction === 'incoming'
144
+ ? `(e.target_id IN (${nodePlaceholders}))`
145
+ : direction === 'outgoing'
146
+ ? `(e.source_id IN (${nodePlaceholders}))`
147
+ : `(e.source_id IN (${nodePlaceholders}) OR e.target_id IN (${nodePlaceholders}))`;
148
+ const nodeParams = direction === 'both' ? [...frontier, ...frontier] : [...frontier];
149
+ const rows = db.prepare(`
150
+ SELECT e.id, e.source_id AS sourceId, e.target_id AS targetId, e.kind, e.confidence, e.metadata_json AS metadataJson
151
+ FROM edges e
152
+ WHERE e.repository_id = ? AND e.kind IN (${kindPlaceholders}) AND ${adjacency}
153
+ ORDER BY e.kind, e.id
154
+ LIMIT ?
155
+ `).all(repositoryId, ...safeKinds, ...nodeParams, Math.max(1, limit));
156
+ return rows.map((row) => ({ id: String(row.id), sourceId: String(row.sourceId), targetId: String(row.targetId), kind: row.kind, confidence: Number(row.confidence ?? 0), metadata: parseMetadata(row.metadataJson) }));
157
+ }
158
+ export function selectExploreNodeRows(db, repositoryId, ids) {
159
+ if (ids.length === 0)
160
+ return [];
161
+ const placeholders = ids.map(() => '?').join(', ');
162
+ const rows = db.prepare(`
163
+ SELECT n.id, n.kind, n.type, n.name, n.file_id AS fileId,
164
+ n.start_point AS startPoint, n.end_point AS endPoint, n.metadata_json AS metadataJson, f.path AS path
165
+ FROM nodes n LEFT JOIN files f ON f.id = n.file_id
166
+ WHERE n.repository_id = ? AND n.id IN (${placeholders})
167
+ `).all(repositoryId, ...ids);
168
+ const byId = new Map(rows.map((row) => [String(row.id), sourceFromRow(row)]));
169
+ return ids.flatMap((id) => byId.get(id) ? [byId.get(id)] : []);
170
+ }
171
+ export function aggregateId(repositoryId, fileId) {
172
+ return `aggregate:${repositoryId}:${fileId}`;
173
+ }
174
+ function sourceFromRow(row) {
175
+ return {
176
+ id: String(row.id), kind: row.kind, type: String(row.type), name: row.name == null ? null : String(row.name),
177
+ fileId: row.fileId == null ? null : String(row.fileId), path: row.path == null ? null : String(row.path),
178
+ startPoint: parsePoint(row.startPoint), endPoint: parsePoint(row.endPoint), metadata: parseMetadata(row.metadataJson)
179
+ };
180
+ }
181
+ function qualifiedName(source) {
182
+ return source.name && source.path ? `${source.path}:${source.name}` : source.name;
183
+ }
184
+ //# sourceMappingURL=explore-queries.js.map
@@ -1,16 +1,35 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import type { FileAttribution, FileRecord, GraphEdge, GraphNode, RepositoryRecord } from '../types.js';
2
+ import type { AttributionApplyResult, AttributionOutcome, FileRecord, GraphEdge, GraphNode, RepositoryRecord } from '../types.js';
3
3
  export declare function upsertRepository(db: DatabaseSync, repository: RepositoryRecord): void;
4
- export declare function replaceFileGraph(db: DatabaseSync, { repository, file, nodes, edges, attribution }: {
4
+ export declare function replaceFileGraph(db: DatabaseSync, { repository, file, nodes, edges, enrichment }: {
5
5
  repository: RepositoryRecord;
6
6
  file: FileRecord;
7
7
  nodes: GraphNode[];
8
8
  edges: GraphEdge[];
9
- attribution?: FileAttribution | null;
9
+ enrichment?: {
10
+ pluginName: string;
11
+ historyFingerprint: string | null;
12
+ };
10
13
  }): void;
14
+ /**
15
+ * Applies optional attribution only when it still matches the structural file
16
+ * hash. Failed outcomes never mutate existing attribution.
17
+ */
18
+ export declare function applyFileAttribution(db: DatabaseSync, { repository, fileId, sourceHash, outcome, pluginName, historyFingerprint }: {
19
+ repository: RepositoryRecord;
20
+ fileId: string;
21
+ sourceHash: string;
22
+ outcome: AttributionOutcome;
23
+ pluginName?: string;
24
+ historyFingerprint?: string | null;
25
+ }): AttributionApplyResult;
11
26
  export declare function reconcileDeletedFiles(db: DatabaseSync, repositoryId: string, discoveredPaths: string[]): void;
27
+ export declare function recordFileScanFailure(db: DatabaseSync, repositoryId: string, filePath: string, diagnostic: string): void;
28
+ export declare function clearFileScanFailure(db: DatabaseSync, repositoryId: string, filePath: string): void;
29
+ export declare function setRepositoryScanState(db: DatabaseSync, repositoryId: string, state: 'fresh' | 'degraded' | 'stale'): void;
12
30
  export declare function selectFileInventory(db: DatabaseSync, repositoryId: string): Array<{
13
31
  path: string;
32
+ hash: string;
14
33
  size: number;
15
34
  mtimeMs: number;
16
35
  }>;
@@ -10,13 +10,14 @@ export function upsertRepository(db, repository) {
10
10
  updated_at = datetime('now')
11
11
  `).run(repository.id, repository.root, repository.name);
12
12
  }
13
- export function replaceFileGraph(db, { repository, file, nodes, edges, attribution }) {
13
+ export function replaceFileGraph(db, { repository, file, nodes, edges, enrichment }) {
14
14
  upsertRepository(db, repository);
15
15
  db.exec('BEGIN IMMEDIATE');
16
16
  try {
17
- if (attribution) {
18
- upsertContributorNodes(db, repository, attribution.contributorEmails);
19
- }
17
+ // The structural replacement must not depend on Git attribution. Preserve
18
+ // attribution for structurally stable nodes so a later failed enrichment
19
+ // does not make previously successful data disappear.
20
+ const priorAttribution = captureFileAttribution(db, file.id);
20
21
  db.prepare('DELETE FROM edges WHERE file_id = ?').run(file.id);
21
22
  db.prepare('DELETE FROM nodes WHERE file_id = ?').run(file.id);
22
23
  db.prepare(`
@@ -36,30 +37,62 @@ export function replaceFileGraph(db, { repository, file, nodes, edges, attributi
36
37
  start_point, end_point, source_hash, parser, parser_version, metadata_json, last_modified_user_id
37
38
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38
39
  `);
39
- const attributionByNodeId = new Map(attribution?.nodeAttributions.map((entry) => [entry.nodeId, entry]) ?? []);
40
40
  for (const node of nodes) {
41
- const nodeAttribution = attributionByNodeId.get(node.id);
42
- insertNode.run(node.id, repository.id, file.id, node.kind, node.type, node.name ?? null, node.startByte, node.endByte, JSON.stringify(node.startPoint), JSON.stringify(node.endPoint), node.sourceHash, node.parser, node.parserVersion, JSON.stringify(node.metadata ?? {}), node.lastModifiedUserId ?? resolveUserId(repository, nodeAttribution?.lastModifiedEmail ?? null));
41
+ insertNode.run(node.id, repository.id, file.id, node.kind, node.type, node.name ?? null, node.startByte, node.endByte, JSON.stringify(node.startPoint), JSON.stringify(node.endPoint), node.sourceHash, node.parser, node.parserVersion, JSON.stringify(node.metadata ?? {}), null);
43
42
  }
44
43
  const insertEdge = db.prepare(`
45
44
  INSERT INTO edges(
46
- id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json
47
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
45
+ id, repository_id, file_id, source_id, target_id, kind, type, confidence, metadata_json
46
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
48
47
  `);
49
48
  for (const edge of edges) {
50
- insertEdge.run(edge.id, repository.id, file.id, edge.sourceId, edge.targetId, edge.kind, edge.confidence ?? 1, JSON.stringify(edge.metadata ?? {}));
49
+ insertEdge.run(edge.id, repository.id, file.id, edge.sourceId, edge.targetId, edge.kind, edge.type ?? edge.kind, edge.confidence ?? 1, JSON.stringify(edge.metadata ?? {}));
51
50
  }
52
- if (attribution) {
53
- for (const entry of attribution.nodeAttributions) {
54
- for (const email of entry.contributorEmails) {
55
- const contributorId = resolveUserId(repository, email);
56
- if (!contributorId)
57
- continue;
58
- insertEdge.run(hashParts([repository.id, file.id, entry.nodeId, contributorId, 'attributed_to']), repository.id, file.id, entry.nodeId, contributorId, 'attributed_to', 1, JSON.stringify({ email }));
59
- }
60
- }
51
+ restorePriorAttribution(db, file.id, new Set(nodes.map((node) => node.id)), priorAttribution);
52
+ if (enrichment)
53
+ queueFileEnrichment(db, repository.id, file.id, file.hash, enrichment.pluginName, enrichment.historyFingerprint);
54
+ db.exec('COMMIT');
55
+ }
56
+ catch (error) {
57
+ db.exec('ROLLBACK');
58
+ throw error;
59
+ }
60
+ }
61
+ /**
62
+ * Applies optional attribution only when it still matches the structural file
63
+ * hash. Failed outcomes never mutate existing attribution.
64
+ */
65
+ export function applyFileAttribution(db, { repository, fileId, sourceHash, outcome, pluginName = 'git-attribution-plugin', historyFingerprint = null }) {
66
+ db.exec('BEGIN IMMEDIATE');
67
+ try {
68
+ const current = db.prepare(`
69
+ SELECT f.hash AS sourceHash, e.source_hash AS enrichmentHash,
70
+ e.plugin_name AS pluginName, e.history_fingerprint AS historyFingerprint
71
+ FROM files f LEFT JOIN file_enrichment_state e ON e.file_id = f.id
72
+ WHERE f.id = ? AND f.repository_id = ?
73
+ `).get(fileId, repository.id);
74
+ if (current?.sourceHash === sourceHash && current.enrichmentHash === undefined) {
75
+ queueFileEnrichment(db, repository.id, fileId, sourceHash, pluginName, historyFingerprint);
76
+ }
77
+ const refreshed = current?.enrichmentHash === undefined
78
+ ? { ...current, enrichmentHash: sourceHash, pluginName, historyFingerprint }
79
+ : current;
80
+ if (refreshed?.sourceHash !== sourceHash || refreshed.enrichmentHash !== sourceHash || refreshed.pluginName !== pluginName || refreshed.historyFingerprint !== historyFingerprint) {
81
+ db.exec('ROLLBACK');
82
+ return 'stale';
83
+ }
84
+ if (outcome.status === 'complete') {
85
+ replaceAttribution(db, repository, fileId, outcome.attribution);
86
+ setFileEnrichmentState(db, repository.id, fileId, 'complete', 'complete', null);
87
+ }
88
+ else if (outcome.status === 'unavailable') {
89
+ setFileEnrichmentState(db, repository.id, fileId, 'complete', 'unavailable', `unavailable:${outcome.reason}`);
90
+ }
91
+ else {
92
+ setFileEnrichmentState(db, repository.id, fileId, 'failed', 'failed', outcome.diagnostic);
61
93
  }
62
94
  db.exec('COMMIT');
95
+ return 'applied';
63
96
  }
64
97
  catch (error) {
65
98
  db.exec('ROLLBACK');
@@ -75,6 +108,8 @@ export function reconcileDeletedFiles(db, repositoryId, discoveredPaths) {
75
108
  if (!keep.has(row.path))
76
109
  db.prepare('DELETE FROM files WHERE repository_id = ? AND path = ?').run(repositoryId, row.path);
77
110
  }
111
+ db.prepare(`DELETE FROM file_scan_failures WHERE repository_id = ? AND path NOT IN (SELECT value FROM json_each(?))`)
112
+ .run(repositoryId, JSON.stringify(discoveredPaths));
78
113
  db.exec('COMMIT');
79
114
  }
80
115
  catch (error) {
@@ -82,8 +117,120 @@ export function reconcileDeletedFiles(db, repositoryId, discoveredPaths) {
82
117
  throw error;
83
118
  }
84
119
  }
120
+ export function recordFileScanFailure(db, repositoryId, filePath, diagnostic) {
121
+ db.prepare(`
122
+ INSERT INTO file_scan_failures(repository_id, path, diagnostic, failed_at)
123
+ VALUES (?, ?, ?, datetime('now'))
124
+ ON CONFLICT(repository_id, path) DO UPDATE SET
125
+ diagnostic = excluded.diagnostic,
126
+ failed_at = datetime('now')
127
+ `).run(repositoryId, filePath, diagnostic);
128
+ }
129
+ export function clearFileScanFailure(db, repositoryId, filePath) {
130
+ db.prepare('DELETE FROM file_scan_failures WHERE repository_id = ? AND path = ?').run(repositoryId, filePath);
131
+ }
132
+ export function setRepositoryScanState(db, repositoryId, state) {
133
+ db.prepare(`
134
+ INSERT INTO repository_scan_state(repository_id, state, updated_at)
135
+ VALUES (?, ?, datetime('now'))
136
+ ON CONFLICT(repository_id) DO UPDATE SET
137
+ state = excluded.state,
138
+ updated_at = datetime('now')
139
+ `).run(repositoryId, state);
140
+ }
85
141
  export function selectFileInventory(db, repositoryId) {
86
- return db.prepare('SELECT path, size, mtime_ms AS mtimeMs FROM files WHERE repository_id = ? ORDER BY path').all(repositoryId);
142
+ return db.prepare('SELECT path, hash, size, mtime_ms AS mtimeMs FROM files WHERE repository_id = ? ORDER BY path').all(repositoryId);
143
+ }
144
+ function captureFileAttribution(db, fileId) {
145
+ return {
146
+ lastModified: db.prepare(`
147
+ SELECT id AS nodeId, last_modified_user_id AS userId,
148
+ kind || ':' || type || ':' || COALESCE(name, '') AS key
149
+ FROM nodes
150
+ WHERE file_id = ? AND last_modified_user_id IS NOT NULL
151
+ `).all(fileId),
152
+ edges: db.prepare(`
153
+ SELECT e.id, e.source_id AS sourceId,
154
+ n.kind || ':' || n.type || ':' || COALESCE(n.name, '') AS sourceKey,
155
+ e.target_id AS targetId, e.confidence, e.metadata_json AS metadataJson
156
+ FROM edges e JOIN nodes n ON n.id = e.source_id
157
+ WHERE e.file_id = ? AND e.kind = 'attributed_to'
158
+ `).all(fileId)
159
+ };
160
+ }
161
+ function restorePriorAttribution(db, fileId, currentNodeIds, prior) {
162
+ const currentByKey = new Map(db.prepare(`
163
+ SELECT id, kind || ':' || type || ':' || COALESCE(name, '') AS key
164
+ FROM nodes WHERE file_id = ?
165
+ `).all(fileId).map((row) => [row.key, row.id]));
166
+ const mappedNodeId = (nodeId, key) => currentNodeIds.has(nodeId) ? nodeId : currentByKey.get(key);
167
+ const updateLastModified = db.prepare('UPDATE nodes SET last_modified_user_id = ? WHERE id = ? AND file_id = ?');
168
+ for (const entry of prior.lastModified) {
169
+ const currentId = mappedNodeId(entry.nodeId, entry.key);
170
+ if (currentId)
171
+ updateLastModified.run(entry.userId, currentId, fileId);
172
+ }
173
+ const insertEdge = db.prepare(`
174
+ INSERT OR IGNORE INTO edges(id, repository_id, file_id, source_id, target_id, kind, type, confidence, metadata_json)
175
+ SELECT ?, repository_id, ?, ?, ?, 'attributed_to', 'attributed_to', ?, ?
176
+ FROM files WHERE id = ?
177
+ `);
178
+ for (const edge of prior.edges) {
179
+ const currentSourceId = mappedNodeId(edge.sourceId, edge.sourceKey);
180
+ if (currentSourceId) {
181
+ const edgeId = currentSourceId === edge.sourceId ? edge.id : hashParts([fileId, currentSourceId, edge.targetId, 'attributed_to']);
182
+ insertEdge.run(edgeId, fileId, currentSourceId, edge.targetId, edge.confidence, edge.metadataJson, fileId);
183
+ }
184
+ }
185
+ }
186
+ function queueFileEnrichment(db, repositoryId, fileId, sourceHash, pluginName, historyFingerprint) {
187
+ db.prepare(`
188
+ INSERT INTO file_enrichment_state(
189
+ repository_id, file_id, plugin_name, source_hash, history_fingerprint, state, outcome, diagnostic, queued_at, updated_at, completed_at, failed_at
190
+ ) VALUES (?, ?, ?, ?, ?, 'pending', NULL, NULL, datetime('now'), datetime('now'), NULL, NULL)
191
+ ON CONFLICT(file_id) DO UPDATE SET
192
+ repository_id = excluded.repository_id,
193
+ plugin_name = excluded.plugin_name,
194
+ source_hash = excluded.source_hash,
195
+ history_fingerprint = excluded.history_fingerprint,
196
+ state = 'pending',
197
+ outcome = NULL,
198
+ diagnostic = NULL,
199
+ queued_at = datetime('now'),
200
+ updated_at = datetime('now'),
201
+ completed_at = NULL,
202
+ failed_at = NULL
203
+ `).run(repositoryId, fileId, pluginName, sourceHash, historyFingerprint);
204
+ }
205
+ function replaceAttribution(db, repository, fileId, attribution) {
206
+ upsertContributorNodes(db, repository, attribution.contributorEmails);
207
+ db.prepare("DELETE FROM edges WHERE file_id = ? AND kind = 'attributed_to'").run(fileId);
208
+ db.prepare('UPDATE nodes SET last_modified_user_id = NULL WHERE file_id = ?').run(fileId);
209
+ const updateLastModified = db.prepare('UPDATE nodes SET last_modified_user_id = ? WHERE id = ? AND file_id = ?');
210
+ const insertEdge = db.prepare(`
211
+ INSERT INTO edges(id, repository_id, file_id, source_id, target_id, kind, type, confidence, metadata_json)
212
+ VALUES (?, ?, ?, ?, ?, 'attributed_to', 'attributed_to', 1, ?)
213
+ `);
214
+ for (const entry of attribution.nodeAttributions) {
215
+ const lastModifiedUserId = resolveUserId(repository, entry.lastModifiedEmail);
216
+ if (lastModifiedUserId)
217
+ updateLastModified.run(lastModifiedUserId, entry.nodeId, fileId);
218
+ for (const email of entry.contributorEmails) {
219
+ const contributorId = resolveUserId(repository, email);
220
+ if (!contributorId)
221
+ continue;
222
+ insertEdge.run(hashParts([repository.id, fileId, entry.nodeId, contributorId, 'attributed_to']), repository.id, fileId, entry.nodeId, contributorId, JSON.stringify({ email: normalizeEmail(email) }));
223
+ }
224
+ }
225
+ }
226
+ function setFileEnrichmentState(db, repositoryId, fileId, state, outcome, diagnostic) {
227
+ db.prepare(`
228
+ UPDATE file_enrichment_state
229
+ SET state = ?, outcome = ?, diagnostic = ?, updated_at = datetime('now'),
230
+ completed_at = CASE WHEN ? = 'complete' THEN datetime('now') ELSE NULL END,
231
+ failed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END
232
+ WHERE repository_id = ? AND file_id = ?
233
+ `).run(state, outcome, diagnostic, state, state, repositoryId, fileId);
87
234
  }
88
235
  function upsertContributorNodes(db, repository, contributorEmails) {
89
236
  const insertNode = db.prepare(`
@@ -0,0 +1,23 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { ManifestKind, ManifestStatus, RepositoryManifest } from '../scanner/artifactInventory.js';
3
+ export interface RepositoryManifestInventoryRow {
4
+ repositoryId: string;
5
+ path: string;
6
+ kind: ManifestKind;
7
+ moduleIdentity: string;
8
+ status: ManifestStatus;
9
+ size: number;
10
+ mtimeMs: number;
11
+ sourceHash: string | null;
12
+ generation: number;
13
+ }
14
+ export declare function selectManifestInventory(db: DatabaseSync, repositoryId: string): RepositoryManifestInventoryRow[];
15
+ /** Persist one complete inventory snapshot. Uncertain records remain queryable for restart-safe retention. */
16
+ export declare function persistManifestInventory(db: DatabaseSync, repositoryId: string, manifests: readonly RepositoryManifest[], generation: number): void;
17
+ /** Persist inventory and freshness together at the end of a structural scan. */
18
+ export declare function persistManifestInventoryAndScanState(db: DatabaseSync, repositoryId: string, manifests: readonly RepositoryManifest[], generation: number, state: 'fresh' | 'degraded' | 'stale'): void;
19
+ /** Descriptive aliases for callers that prefer the table's full name. */
20
+ export declare const selectRepositoryManifestInventory: typeof selectManifestInventory;
21
+ export declare const persistRepositoryManifestInventory: typeof persistManifestInventory;
22
+ export declare function manifestRowsAsRecords(rows: readonly RepositoryManifestInventoryRow[]): RepositoryManifest[];
23
+ //# sourceMappingURL=manifest-inventory.d.ts.map
@@ -0,0 +1,82 @@
1
+ export function selectManifestInventory(db, repositoryId) {
2
+ const rows = db.prepare(`
3
+ SELECT repository_id AS repositoryId, path, kind, module_identity AS moduleIdentity,
4
+ status, size, mtime_ms AS mtimeMs, source_hash AS sourceHash, generation
5
+ FROM repository_manifest_inventory
6
+ WHERE repository_id = ?
7
+ ORDER BY path
8
+ `).all(repositoryId);
9
+ return rows.map((row) => ({
10
+ repositoryId: String(row.repositoryId),
11
+ path: String(row.path),
12
+ kind: row.kind,
13
+ moduleIdentity: String(row.moduleIdentity),
14
+ status: row.status,
15
+ size: Number(row.size),
16
+ mtimeMs: Number(row.mtimeMs),
17
+ sourceHash: typeof row.sourceHash === 'string' ? row.sourceHash : null,
18
+ generation: Number(row.generation)
19
+ }));
20
+ }
21
+ /** Persist one complete inventory snapshot. Uncertain records remain queryable for restart-safe retention. */
22
+ export function persistManifestInventory(db, repositoryId, manifests, generation) {
23
+ inTransaction(db, () => writeManifestInventory(db, repositoryId, manifests, generation));
24
+ }
25
+ /** Persist inventory and freshness together at the end of a structural scan. */
26
+ export function persistManifestInventoryAndScanState(db, repositoryId, manifests, generation, state) {
27
+ inTransaction(db, () => {
28
+ writeManifestInventory(db, repositoryId, manifests, generation);
29
+ db.prepare(`
30
+ INSERT INTO repository_scan_state(repository_id, state, updated_at)
31
+ VALUES (?, ?, datetime('now'))
32
+ ON CONFLICT(repository_id) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at
33
+ `).run(repositoryId, state);
34
+ });
35
+ }
36
+ function writeManifestInventory(db, repositoryId, manifests, generation) {
37
+ const statement = db.prepare(`
38
+ INSERT INTO repository_manifest_inventory(
39
+ repository_id, path, kind, module_identity, status, size, mtime_ms, source_hash, generation
40
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
41
+ ON CONFLICT(repository_id, path) DO UPDATE SET
42
+ kind = excluded.kind,
43
+ module_identity = excluded.module_identity,
44
+ status = excluded.status,
45
+ size = excluded.size,
46
+ mtime_ms = excluded.mtime_ms,
47
+ source_hash = excluded.source_hash,
48
+ generation = excluded.generation,
49
+ updated_at = datetime('now')
50
+ `);
51
+ for (const manifest of manifests) {
52
+ statement.run(repositoryId, manifest.path, manifest.kind, manifest.moduleIdentity, manifest.status, manifest.size, manifest.mtimeMs, manifest.sourceHash, generation);
53
+ }
54
+ }
55
+ function inTransaction(db, work) {
56
+ db.exec('BEGIN IMMEDIATE');
57
+ try {
58
+ work();
59
+ db.exec('COMMIT');
60
+ }
61
+ catch (error) {
62
+ db.exec('ROLLBACK');
63
+ throw error;
64
+ }
65
+ }
66
+ /** Descriptive aliases for callers that prefer the table's full name. */
67
+ export const selectRepositoryManifestInventory = selectManifestInventory;
68
+ export const persistRepositoryManifestInventory = persistManifestInventory;
69
+ export function manifestRowsAsRecords(rows) {
70
+ return rows.map((row) => ({
71
+ path: row.path,
72
+ kind: row.kind,
73
+ moduleIdentity: row.moduleIdentity,
74
+ size: row.size,
75
+ mtimeMs: row.mtimeMs,
76
+ sourceHash: row.sourceHash,
77
+ content: null,
78
+ status: row.status,
79
+ generation: row.generation
80
+ }));
81
+ }
82
+ //# sourceMappingURL=manifest-inventory.js.map
@@ -13,8 +13,8 @@ export function reconcilePluginEdges(db, repository, edges, fileIdByPath, active
13
13
  `).run(repository.id, plugin);
14
14
  }
15
15
  const insert = db.prepare(`
16
- INSERT OR REPLACE INTO edges(id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json)
17
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
16
+ INSERT OR REPLACE INTO edges(id, repository_id, file_id, source_id, target_id, kind, type, confidence, metadata_json)
17
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
18
18
  `);
19
19
  for (const edge of edges) {
20
20
  const plugin = typeof edge.metadata.plugin === 'string' ? edge.metadata.plugin : undefined;
@@ -29,7 +29,7 @@ export function reconcilePluginEdges(db, repository, edges, fileIdByPath, active
29
29
  const requiredEndpoints = edge.sourceId === edge.targetId ? 1 : 2;
30
30
  if (endpoints.count !== requiredEndpoints)
31
31
  continue;
32
- insert.run(edge.id, repository.id, fileId, edge.sourceId, edge.targetId, edge.kind, edge.confidence ?? 1, JSON.stringify(edge.metadata ?? {}));
32
+ insert.run(edge.id, repository.id, fileId, edge.sourceId, edge.targetId, edge.kind, edge.type ?? edge.kind, edge.confidence ?? 1, JSON.stringify(edge.metadata ?? {}));
33
33
  inserted += 1;
34
34
  }
35
35
  db.exec('COMMIT');
@@ -1,5 +1,10 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import type { GraphEdgeRow, GraphNodeRow, GraphNodeTextRow, StatusResult } from '../types.js';
2
+ import type { EnrichmentState, EnrichmentSummary, EnrichmentWork, FileEnrichmentState, FreshnessReport, GraphEdgeRow, GraphNodeRow, GraphNodeTextRow, StatusResult } from '../types.js';
3
+ export declare function selectFreshnessReport(db: DatabaseSync, repositoryId: string): FreshnessReport;
4
+ export declare function selectFileEnrichmentState(db: DatabaseSync, repositoryId: string, fileId: string): FileEnrichmentState | null;
5
+ export declare function selectFileEnrichmentStates(db: DatabaseSync, repositoryId: string, states?: readonly EnrichmentState[]): FileEnrichmentState[];
6
+ export declare function selectEnrichmentWork(db: DatabaseSync, repositoryId: string, states?: readonly EnrichmentState[]): EnrichmentWork[];
7
+ export declare function selectEnrichmentSummary(db: DatabaseSync, repositoryId: string): EnrichmentSummary;
3
8
  export declare function getStatus(db: DatabaseSync, repositoryId: string): StatusResult;
4
9
  export declare function selectNodes(db: DatabaseSync, repositoryId: string, { kind, limit }?: {
5
10
  kind?: string | undefined;
@@ -14,12 +19,14 @@ export declare function selectAllNodeTextRows(db: DatabaseSync, repositoryId: st
14
19
  export declare function selectAllNodes(db: DatabaseSync, repositoryId: string, { kind }?: {
15
20
  kind?: string | undefined;
16
21
  }): GraphNodeRow[];
17
- export declare function selectEdges(db: DatabaseSync, repositoryId: string, { kind, limit }?: {
22
+ export declare function selectEdges(db: DatabaseSync, repositoryId: string, { kind, type, limit }?: {
18
23
  kind?: string | undefined;
24
+ type?: string | undefined;
19
25
  limit?: number | undefined;
20
26
  }): GraphEdgeRow[];
21
- export declare function selectAllEdges(db: DatabaseSync, repositoryId: string, { kind }?: {
27
+ export declare function selectAllEdges(db: DatabaseSync, repositoryId: string, { kind, type }?: {
22
28
  kind?: string | undefined;
29
+ type?: string | undefined;
23
30
  }): GraphEdgeRow[];
24
31
  export declare function clampLimit(limit: number | undefined, max?: number): number;
25
32
  //# sourceMappingURL=queries.d.ts.map