@psnext/lscg 0.1.5 → 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.
@@ -15,6 +15,8 @@ export interface ScanRepositoryOptions {
15
15
  root?: string | undefined;
16
16
  scope?: StorageScope | 'both' | undefined;
17
17
  plugins?: readonly ScannerPlugin[] | undefined;
18
+ /** Enables the bundled Git attribution enrichment plugin. */
19
+ attribution?: boolean | undefined;
18
20
  pluginLimits?: Partial<PluginResourceLimits> | undefined;
19
21
  full?: boolean | undefined;
20
22
  enrichmentRunner?: EnrichmentRunner | undefined;
@@ -32,12 +34,14 @@ export interface DrainRepositoryEnrichmentOptions {
32
34
  root?: string | undefined;
33
35
  scope?: StorageScope | 'both' | undefined;
34
36
  enrichmentRunner?: EnrichmentRunner | undefined;
37
+ pluginName?: string | undefined;
38
+ historyFingerprint?: string | null | undefined;
35
39
  }
36
40
  /**
37
41
  * Drains persisted, hash-compatible attribution work. This is deliberately
38
42
  * explicit: one-shot structural scans persist work but never start it.
39
43
  */
40
- export declare function drainRepositoryEnrichment({ root, scope, enrichmentRunner }?: DrainRepositoryEnrichmentOptions): Promise<EnrichmentSummary>;
44
+ export declare function drainRepositoryEnrichment({ root, scope, enrichmentRunner, pluginName, historyFingerprint }?: DrainRepositoryEnrichmentOptions): Promise<EnrichmentSummary>;
41
45
  export declare function graphStatus({ root, scope }?: {
42
46
  root?: string | undefined;
43
47
  scope?: StorageScope | 'both' | undefined;
@@ -65,10 +69,11 @@ export declare function listNodeText({ root, scope, kind, term, limit }?: {
65
69
  root: string;
66
70
  freshness: FreshnessReport;
67
71
  } & GraphNodeTextRow>;
68
- export declare function listEdges({ root, scope, kind, limit }?: {
72
+ export declare function listEdges({ root, scope, kind, type, limit }?: {
69
73
  root?: string | undefined;
70
74
  scope?: StorageScope | 'both' | undefined;
71
75
  kind?: string | undefined;
76
+ type?: string | undefined;
72
77
  limit?: number | undefined;
73
78
  }): Array<{
74
79
  scope: StorageScope;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { databasePathForScope, homeDatabasePath, normalizeScope, repoDatabasePath, resolveProjectRoot } from '../config/paths.js';
@@ -11,6 +12,7 @@ import { PackagePlugin } from '../scanner/packagePlugin.js';
11
12
  import { PythonScannerPlugin } from '../scanner/pythonPlugin.js';
12
13
  import { JavaScannerPlugin } from '../scanner/javaPlugin.js';
13
14
  import { JavaDependencyPlugin } from '../scanner/javaDependencyPlugin.js';
15
+ import { AttributionPlugin, ATTRIBUTION_PLUGIN_NAME } from '../scanner/attributionPlugin.js';
14
16
  import { inspectRepositoryManifests, mergeManifestInventories } from '../scanner/artifactInventory.js';
15
17
  import { runBatchedWorkers } from '../scanner/parallelScan.js';
16
18
  function normalizePluginFilePath(value) {
@@ -89,8 +91,8 @@ export async function scanRepository(options = {}) {
89
91
  try {
90
92
  const summary = await scanRepositoryOnce({ ...options, attempt: 1 });
91
93
  reportProgress(progress, {
92
- kind: 'scan', operation, scope, phase: 'terminal', status: summary.enrichment.state === 'complete' ? 'completed' : 'degraded',
93
- repository: summary.repository, attempt: 1, detail: summary.enrichment.state === 'complete' ? 'scan complete' : `scan complete with enrichment ${summary.enrichment.state}`,
94
+ kind: 'scan', operation, scope, phase: 'terminal', status: summary.enrichment.state === 'complete' || summary.enrichment.state === 'disabled' ? 'completed' : 'degraded',
95
+ repository: summary.repository, attempt: 1, detail: summary.enrichment.state === 'complete' || summary.enrichment.state === 'disabled' ? 'scan complete' : `scan complete with enrichment ${summary.enrichment.state}`,
94
96
  counts: { processed: summary.filesScanned, skipped: summary.skipped.length, pending: summary.enrichment.pending, complete: summary.enrichment.complete, failed: summary.enrichment.failed }
95
97
  });
96
98
  return summary;
@@ -105,8 +107,8 @@ export async function scanRepository(options = {}) {
105
107
  const summary = await scanRepositoryOnce({ ...options, attempt });
106
108
  if (summary.enrichment.pending === 0) {
107
109
  reportProgress(progress, {
108
- kind: 'scan', operation, scope, phase: 'terminal', status: summary.enrichment.state === 'complete' ? 'completed' : 'degraded',
109
- repository: summary.repository, attempt, detail: summary.enrichment.state === 'complete' ? 'full scan complete; enrichment stabilized' : `full scan complete with enrichment ${summary.enrichment.state}`,
110
+ kind: 'scan', operation, scope, phase: 'terminal', status: summary.enrichment.state === 'complete' || summary.enrichment.state === 'disabled' ? 'completed' : 'degraded',
111
+ repository: summary.repository, attempt, detail: summary.enrichment.state === 'complete' || summary.enrichment.state === 'disabled' ? 'full scan complete; enrichment stabilized' : `full scan complete with enrichment ${summary.enrichment.state}`,
110
112
  counts: { processed: summary.filesScanned, skipped: summary.skipped.length, pending: summary.enrichment.pending, complete: summary.enrichment.complete, failed: summary.enrichment.failed }
111
113
  });
112
114
  return summary;
@@ -124,8 +126,9 @@ export async function scanRepository(options = {}) {
124
126
  reportProgress(progress, { kind: 'scan', operation, scope, phase: 'terminal', status: 'failed', repository: repositoryForRoot(options.root), attempt: MAX_FULL_ENRICHMENT_ATTEMPTS, detail: error.message });
125
127
  throw error;
126
128
  }
127
- async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugins = [], pluginLimits, full = false, enrichmentRunner, progress, attempt = 1 } = {}) {
129
+ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugins = [], attribution = false, pluginLimits, full = false, enrichmentRunner, progress, attempt = 1 } = {}) {
128
130
  const repository = repositoryForRoot(root);
131
+ const attributionEnabled = attribution || plugins.some((plugin) => plugin.name === ATTRIBUTION_PLUGIN_NAME);
129
132
  const scopes = normalizeScope(scope);
130
133
  const files = discoverSourceFiles(repository.root);
131
134
  const operation = full ? 'full' : 'incremental';
@@ -161,9 +164,24 @@ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugin
161
164
  // Keep the analyzer active for current manifests and for one final
162
165
  // reconciliation after a restart/deletion of the last manifest.
163
166
  ...(hasJavaManifests || hasPriorJavaDependencyContribution ? [JavaDependencyPlugin] : []),
164
- ...plugins.filter((plugin) => ![PackagePlugin.name, PythonScannerPlugin.name, JavaScannerPlugin.name, JavaDependencyPlugin.name].includes(plugin.name))
167
+ ...(attribution || plugins.some((plugin) => plugin.name === ATTRIBUTION_PLUGIN_NAME) ? [AttributionPlugin] : []),
168
+ ...plugins.filter((plugin) => ![PackagePlugin.name, PythonScannerPlugin.name, JavaScannerPlugin.name, JavaDependencyPlugin.name, ATTRIBUTION_PLUGIN_NAME].includes(plugin.name))
165
169
  ];
166
- const filesToScan = full || inventoriesByScope.some((inventory) => inventory.size === 0)
170
+ const historyFingerprint = attributionEnabled ? gitHeadFingerprint(repository.root) : null;
171
+ const priorHistoryFingerprint = attributionEnabled && opened[0]
172
+ ? opened[0].db.prepare(`SELECT history_fingerprint AS historyFingerprint FROM file_enrichment_state WHERE repository_id = ? ORDER BY updated_at DESC LIMIT 1`).get(repository.id)?.historyFingerprint ?? null
173
+ : null;
174
+ const historyChanged = attributionEnabled && historyFingerprint !== priorHistoryFingerprint;
175
+ const missingAttributionState = attributionEnabled && opened[0]
176
+ ? Number(opened[0].db.prepare(`
177
+ SELECT count(*) AS count FROM files f
178
+ WHERE f.repository_id = ? AND NOT EXISTS (
179
+ SELECT 1 FROM file_enrichment_state e
180
+ WHERE e.file_id = f.id AND e.plugin_name = ?
181
+ )
182
+ `).get(repository.id, ATTRIBUTION_PLUGIN_NAME).count) > 0
183
+ : false;
184
+ const filesToScan = full || historyChanged || missingAttributionState || inventoriesByScope.some((inventory) => inventory.size === 0)
167
185
  ? files
168
186
  : files.filter((relativePath) => {
169
187
  try {
@@ -308,7 +326,13 @@ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugin
308
326
  edges: builtInGraph.edges
309
327
  };
310
328
  for (const handle of opened) {
311
- replaceFileGraph(handle.db, { repository, file, nodes: graph.nodes, edges: graph.edges });
329
+ replaceFileGraph(handle.db, {
330
+ repository,
331
+ file,
332
+ nodes: graph.nodes,
333
+ edges: graph.edges,
334
+ ...(attributionEnabled ? { enrichment: { pluginName: ATTRIBUTION_PLUGIN_NAME, historyFingerprint } } : {})
335
+ });
312
336
  clearFileScanFailure(handle.db, repository.id, relativePath);
313
337
  }
314
338
  filesScanned += 1;
@@ -343,12 +367,16 @@ async function scanRepositoryOnce({ root = process.cwd(), scope = 'repo', plugin
343
367
  closeDatabase(handle.db);
344
368
  }
345
369
  reportProgress(progress, { kind: 'scan', operation, scope, phase: 'enrichment', status: 'started', repository, attempt });
346
- const enrichment = full
347
- ? await drainRepositoryEnrichment({ root: repository.root, scope, enrichmentRunner })
348
- : summarizeRepositoryEnrichment(repository, scopes);
370
+ const selectedEnrichmentPlugin = pluginRun.enrichmentPlugins?.get(ATTRIBUTION_PLUGIN_NAME);
371
+ const selectedRunner = enrichmentRunner ?? (selectedEnrichmentPlugin?.enrich?.bind(selectedEnrichmentPlugin));
372
+ const enrichment = attributionEnabled && full
373
+ ? await drainRepositoryEnrichment({ root: repository.root, scope, enrichmentRunner: selectedRunner, pluginName: ATTRIBUTION_PLUGIN_NAME, historyFingerprint })
374
+ : attributionEnabled
375
+ ? summarizeRepositoryEnrichment(repository, scopes)
376
+ : disabledEnrichmentSummary();
349
377
  reportProgress(progress, {
350
- kind: 'scan', operation, scope, phase: 'enrichment', status: enrichment.state === 'complete' ? 'completed' : enrichment.state === 'pending' ? 'deferred' : 'degraded', repository, attempt,
351
- detail: enrichment.state === 'pending' ? 'structural scan complete; enrichment deferred' : enrichment.state === 'failed' ? 'enrichment failed; scan is degraded' : 'enrichment complete',
378
+ kind: 'scan', operation, scope, phase: 'enrichment', status: enrichment.state === 'complete' || enrichment.state === 'disabled' ? 'completed' : enrichment.state === 'pending' ? 'deferred' : 'degraded', repository, attempt,
379
+ detail: enrichment.state === 'pending' ? 'structural scan complete; enrichment deferred' : enrichment.state === 'failed' ? 'enrichment failed; scan is degraded' : enrichment.state === 'disabled' ? 'optional enrichment disabled' : 'enrichment complete',
352
380
  counts: { pending: enrichment.pending, complete: enrichment.complete, failed: enrichment.failed }
353
381
  });
354
382
  return {
@@ -375,13 +403,13 @@ export const defaultEnrichmentRunner = ({ repository, work, source, nodes }) =>
375
403
  * Drains persisted, hash-compatible attribution work. This is deliberately
376
404
  * explicit: one-shot structural scans persist work but never start it.
377
405
  */
378
- export async function drainRepositoryEnrichment({ root = process.cwd(), scope = 'repo', enrichmentRunner = defaultEnrichmentRunner } = {}) {
406
+ export async function drainRepositoryEnrichment({ root = process.cwd(), scope = 'repo', enrichmentRunner = defaultEnrichmentRunner, pluginName = ATTRIBUTION_PLUGIN_NAME, historyFingerprint = null } = {}) {
379
407
  const repository = repositoryForRoot(root);
380
408
  const scopes = normalizeScope(scope);
381
409
  for (const graphScope of scopes) {
382
410
  const db = openGraphDatabase(databasePathForScope(graphScope, repository.root));
383
411
  try {
384
- const workItems = selectEnrichmentWork(db, repository.id);
412
+ const workItems = selectEnrichmentWork(db, repository.id).filter((work) => work.pluginName === pluginName && (historyFingerprint === null || work.historyFingerprint === historyFingerprint));
385
413
  for (const work of workItems) {
386
414
  let outcome;
387
415
  try {
@@ -405,7 +433,7 @@ export async function drainRepositoryEnrichment({ root = process.cwd(), scope =
405
433
  catch (error) {
406
434
  outcome = { status: 'failed', diagnostic: `enrichment failed: ${diagnosticFor(error)}` };
407
435
  }
408
- applyFileAttribution(db, { repository, fileId: work.fileId, sourceHash: work.sourceHash, outcome });
436
+ applyFileAttribution(db, { repository, fileId: work.fileId, sourceHash: work.sourceHash, outcome, pluginName, historyFingerprint });
409
437
  }
410
438
  }
411
439
  finally {
@@ -414,6 +442,21 @@ export async function drainRepositoryEnrichment({ root = process.cwd(), scope =
414
442
  }
415
443
  return summarizeRepositoryEnrichment(repository, scopes);
416
444
  }
445
+ function disabledEnrichmentSummary() {
446
+ return { state: 'disabled', pending: 0, complete: 0, failed: 0, unavailable: 0, notApplicable: 0, diagnostics: [] };
447
+ }
448
+ function gitHeadFingerprint(root) {
449
+ try {
450
+ const result = spawnSync('git', ['-C', root, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' });
451
+ if (result.status !== 0)
452
+ return null;
453
+ const head = String(result.stdout ?? '').trim();
454
+ return head || null;
455
+ }
456
+ catch {
457
+ return null;
458
+ }
459
+ }
417
460
  function summarizeRepositoryEnrichment(repository, scopes) {
418
461
  const states = [];
419
462
  for (const graphScope of scopes) {
@@ -428,11 +471,14 @@ function summarizeRepositoryEnrichment(repository, scopes) {
428
471
  const pending = states.filter((entry) => entry.state === 'pending').length;
429
472
  const complete = states.filter((entry) => entry.state === 'complete').length;
430
473
  const failedEntries = states.filter((entry) => entry.state === 'failed');
474
+ const unavailableEntries = states.filter((entry) => entry.outcome === 'unavailable');
431
475
  return {
432
476
  state: failedEntries.length > 0 ? 'failed' : pending > 0 ? 'pending' : 'complete',
433
477
  pending,
434
- complete,
478
+ complete: complete - unavailableEntries.length,
435
479
  failed: failedEntries.length,
480
+ unavailable: unavailableEntries.length,
481
+ notApplicable: unavailableEntries.filter((entry) => entry.diagnostic === 'unavailable:not_applicable').length,
436
482
  diagnostics: failedEntries.flatMap((entry) => entry.diagnostic ? [entry.diagnostic] : [])
437
483
  };
438
484
  }
@@ -485,7 +531,7 @@ export function graphStatus({ root = process.cwd(), scope = 'repo' } = {}) {
485
531
  nodes: 0,
486
532
  edges: 0,
487
533
  freshness: { state: 'stale', reason: 'not_scanned' },
488
- enrichment: { state: 'complete', pending: 0, complete: 0, failed: 0, diagnostics: [] }
534
+ enrichment: disabledEnrichmentSummary()
489
535
  });
490
536
  }
491
537
  export function listNodes({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
@@ -512,13 +558,13 @@ export function listNodeText({ root = process.cwd(), scope = 'repo', kind, term,
512
558
  }));
513
559
  }).flat();
514
560
  }
515
- export function listEdges({ root = process.cwd(), scope = 'repo', kind, limit = 50 } = {}) {
561
+ export function listEdges({ root = process.cwd(), scope = 'repo', kind, type, limit = 50 } = {}) {
516
562
  const repository = repositoryForRoot(root);
517
563
  return withReadOnlyScopes(repository, scope, (db, graphScope) => {
518
564
  if (!db)
519
565
  return [];
520
566
  const freshness = selectFreshnessReport(db, repository.id);
521
- return selectEdges(db, repository.id, { kind, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
567
+ return selectEdges(db, repository.id, { kind, type, limit }).map((row) => ({ scope: graphScope, freshness, ...row }));
522
568
  }).flat();
523
569
  }
524
570
  export function neighbors({ root = process.cwd(), scope = 'repo', nodeId, depth = 1, limit = 100 } = {}) {
@@ -6,6 +6,7 @@ export { PackagePlugin } from './scanner/packagePlugin.js';
6
6
  export { PythonScannerPlugin, PYTHON_SCANNER_PLUGIN_NAME, scanPythonFiles } from './scanner/pythonPlugin.js';
7
7
  export { JavaScannerPlugin, JAVA_SCANNER_PLUGIN_NAME, scanJavaFiles } from './scanner/javaPlugin.js';
8
8
  export { JavaDependencyPlugin, JAVA_DEPENDENCY_PLUGIN_NAME, scanJavaDependencies } from './scanner/javaDependencyPlugin.js';
9
+ export { AttributionPlugin, ATTRIBUTION_PLUGIN_NAME } from './scanner/attributionPlugin.js';
9
10
  export { inventoryRepositoryManifests, inspectRepositoryManifests, mergeManifestInventories } from './scanner/artifactInventory.js';
10
11
  export type { RepositoryManifest, ManifestKind, ManifestStatus } from './scanner/artifactInventory.js';
11
12
  export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
package/dist/src/index.js CHANGED
@@ -5,6 +5,7 @@ export { PackagePlugin } from './scanner/packagePlugin.js';
5
5
  export { PythonScannerPlugin, PYTHON_SCANNER_PLUGIN_NAME, scanPythonFiles } from './scanner/pythonPlugin.js';
6
6
  export { JavaScannerPlugin, JAVA_SCANNER_PLUGIN_NAME, scanJavaFiles } from './scanner/javaPlugin.js';
7
7
  export { JavaDependencyPlugin, JAVA_DEPENDENCY_PLUGIN_NAME, scanJavaDependencies } from './scanner/javaDependencyPlugin.js';
8
+ export { AttributionPlugin, ATTRIBUTION_PLUGIN_NAME } from './scanner/attributionPlugin.js';
8
9
  export { inventoryRepositoryManifests, inspectRepositoryManifests, mergeManifestInventories } from './scanner/artifactInventory.js';
9
10
  export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
10
11
  export { repoDatabasePath, homeDatabasePath, resolveProjectRoot } from './config/paths.js';
@@ -11,8 +11,9 @@ export async function startMcpServer({ root = process.cwd() } = {}) {
11
11
  const tool = server.tool.bind(server);
12
12
  tool('context_graph_scan', 'Parse repository files with Tree-sitter and write discovered graph nodes/edges to SQLite.', {
13
13
  root: z.string().optional().describe('Repository root. Defaults to the MCP server cwd.'),
14
- scope: scopeSchema.describe('Graph storage scope to update.')
15
- }, async (input) => jsonResponse(await scanRepository({ root: input.root ?? root, scope: input.scope })));
14
+ scope: scopeSchema.describe('Graph storage scope to update.'),
15
+ attribution: z.boolean().default(false).describe('Enable optional Git user attribution enrichment.')
16
+ }, async (input) => jsonResponse(await scanRepository({ root: input.root ?? root, scope: input.scope, attribution: input.attribution })));
16
17
  tool('context_graph_context', 'Return stored, relationship-aware context and its persisted freshness report without scanning.', {
17
18
  root: z.string().optional(),
18
19
  scope: scopeSchema,
@@ -47,11 +48,13 @@ export async function startMcpServer({ root = process.cwd() } = {}) {
47
48
  root: z.string().optional(),
48
49
  scope: scopeSchema,
49
50
  kind: z.enum(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to', 'provides']).optional(),
51
+ type: z.string().min(1).optional(),
50
52
  limit: z.number().int().positive().max(500).default(50)
51
53
  }, async (input) => jsonResponse(listEdges({
52
54
  root: input.root ?? root,
53
55
  scope: input.scope,
54
56
  kind: input.kind,
57
+ type: input.type,
55
58
  limit: input.limit
56
59
  })));
57
60
  tool('context_graph_neighbors', 'Return nearby nodes around a graph node id.', {
@@ -0,0 +1,5 @@
1
+ import type { ScannerPlugin } from './plugins.js';
2
+ export declare const ATTRIBUTION_PLUGIN_NAME = "git-attribution-plugin";
3
+ /** Optional deferred Git attribution for structural graph nodes. */
4
+ export declare const AttributionPlugin: ScannerPlugin;
5
+ //# sourceMappingURL=attributionPlugin.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { buildFileAttribution } from '../graph/attribution.js';
2
+ export const ATTRIBUTION_PLUGIN_NAME = 'git-attribution-plugin';
3
+ /** Optional deferred Git attribution for structural graph nodes. */
4
+ export const AttributionPlugin = {
5
+ name: ATTRIBUTION_PLUGIN_NAME,
6
+ apiVersion: 1,
7
+ capabilities: ['enrichment'],
8
+ scan: () => ({ nodes: [], edges: [] }),
9
+ enrich: ({ repository, work, source, nodes }) => buildFileAttribution({
10
+ root: repository.root,
11
+ relativePath: work.path,
12
+ source,
13
+ nodes
14
+ })
15
+ };
16
+ //# sourceMappingURL=attributionPlugin.js.map
@@ -1,5 +1,4 @@
1
- import { readdirSync, statSync } from 'node:fs';
2
- import { spawnSync } from 'node:child_process';
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { isSupportedSourceFile } from '../parser/treeSitter.js';
5
4
  const DEFAULT_IGNORES = new Set([
@@ -13,13 +12,14 @@ const DEFAULT_IGNORES = new Set([
13
12
  ]);
14
13
  export function discoverSourceFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
15
14
  const files = [];
16
- walk(root, root, files, ignores);
15
+ const gitignore = readGitignore(root);
16
+ walk(root, root, files, ignores, gitignore);
17
17
  return files.sort();
18
18
  }
19
- function walk(root, current, files, ignores) {
19
+ function walk(root, current, files, ignores, gitignore) {
20
20
  const entries = readdirSync(current, { withFileTypes: true });
21
21
  const relativePaths = entries.map((entry) => path.relative(root, path.join(current, entry.name)));
22
- const gitIgnored = ignoredByGit(root, relativePaths);
22
+ const gitIgnored = ignoredByGit(gitignore, relativePaths);
23
23
  for (let index = 0; index < entries.length; index += 1) {
24
24
  const entry = entries[index];
25
25
  if (!entry)
@@ -31,7 +31,7 @@ function walk(root, current, files, ignores) {
31
31
  if (!relativePath || gitIgnored.has(relativePath))
32
32
  continue;
33
33
  if (entry.isDirectory()) {
34
- walk(root, fullPath, files, ignores);
34
+ walk(root, fullPath, files, ignores, gitignore);
35
35
  continue;
36
36
  }
37
37
  if (!entry.isFile())
@@ -44,17 +44,90 @@ function walk(root, current, files, ignores) {
44
44
  files.push(relativePath);
45
45
  }
46
46
  }
47
- function ignoredByGit(root, relativePaths) {
48
- if (relativePaths.length === 0)
49
- return new Set();
50
- const result = spawnSync('git', ['check-ignore', '--stdin', '-z'], {
51
- cwd: root,
52
- input: `${relativePaths.join('\0')}\0`,
53
- encoding: 'utf8'
47
+ function readGitignore(root) {
48
+ let contents;
49
+ try {
50
+ contents = readFileSync(path.join(root, '.gitignore'), 'utf8');
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ return contents
56
+ .split(/\r?\n/u)
57
+ .map((line) => line.trim())
58
+ .filter((line) => line.length > 0 && !line.startsWith('#'))
59
+ .map((line) => {
60
+ const negated = line.startsWith('!');
61
+ const pattern = negated ? line.slice(1) : line;
62
+ return { expression: gitignorePatternToRegExp(pattern), negated };
54
63
  });
55
- if (result.error || (result.status !== 0 && result.status !== 1)) {
56
- return new Set();
64
+ }
65
+ function ignoredByGit(patterns, relativePaths) {
66
+ const ignored = new Set();
67
+ for (const relativePath of relativePaths) {
68
+ const normalizedPath = relativePath.split(path.sep).join('/');
69
+ let isIgnored = false;
70
+ for (const pattern of patterns) {
71
+ if (pattern.expression.test(normalizedPath))
72
+ isIgnored = !pattern.negated;
73
+ }
74
+ if (isIgnored)
75
+ ignored.add(relativePath);
76
+ }
77
+ return ignored;
78
+ }
79
+ function gitignorePatternToRegExp(pattern) {
80
+ const directoryOnly = pattern.endsWith('/');
81
+ const withoutTrailingSlash = directoryOnly ? pattern.slice(0, -1) : pattern;
82
+ const anchored = withoutTrailingSlash.startsWith('/');
83
+ const value = anchored ? withoutTrailingSlash.slice(1) : withoutTrailingSlash;
84
+ const source = globToRegExp(value);
85
+ // A pattern without a slash applies to a name at any depth. Patterns with
86
+ // a slash are relative to the repository root, as they are in gitignore.
87
+ if (!value.includes('/')) {
88
+ return new RegExp(`(?:^|/)${source}(?:$|/)`);
89
+ }
90
+ return new RegExp(`^${source}(?:$|/)`);
91
+ }
92
+ function globToRegExp(pattern) {
93
+ let result = '';
94
+ for (let index = 0; index < pattern.length; index += 1) {
95
+ const character = pattern[index];
96
+ if (character === undefined)
97
+ continue;
98
+ if (character === '*') {
99
+ if (pattern[index + 1] === '*') {
100
+ while (pattern[index + 1] === '*')
101
+ index += 1;
102
+ if (pattern[index + 1] === '/') {
103
+ index += 1;
104
+ result += '(?:.*/)?';
105
+ }
106
+ else {
107
+ result += '.*';
108
+ }
109
+ }
110
+ else {
111
+ result += '[^/]*';
112
+ }
113
+ }
114
+ else if (character === '?') {
115
+ result += '[^/]';
116
+ }
117
+ else if (character === '[') {
118
+ const closing = pattern.indexOf(']', index + 1);
119
+ if (closing !== -1) {
120
+ result += pattern.slice(index, closing + 1);
121
+ index = closing;
122
+ }
123
+ else {
124
+ result += '\\[';
125
+ }
126
+ }
127
+ else {
128
+ result += /[\\^$+.()|{}]/u.test(character) ? `\\${character}` : character;
129
+ }
57
130
  }
58
- return new Set(result.stdout.split('\0').filter(Boolean));
131
+ return result;
59
132
  }
60
133
  //# sourceMappingURL=discover.js.map
@@ -1,8 +1,8 @@
1
- import type { FileRecord, GraphEdge, GraphNode, GraphExtractionResult, Point } from '../types.js';
1
+ import type { EnrichmentRunnerInput, AttributionOutcome, FileRecord, GraphEdge, GraphNode, GraphExtractionResult, Point } from '../types.js';
2
2
  import { type RepositoryManifest } from './artifactInventory.js';
3
3
  export declare const SCANNER_PLUGIN_API_VERSION: 1;
4
4
  export type PluginConflictPolicy = 'merge' | 'replace' | 'reject';
5
- export type ScannerCapability = 'file-scanner' | 'repository-analyzer';
5
+ export type ScannerCapability = 'file-scanner' | 'repository-analyzer' | 'enrichment';
6
6
  export interface RepositoryScanFile extends FileRecord {
7
7
  absolutePath: string;
8
8
  source: string;
@@ -45,6 +45,7 @@ export interface PluginEdgeFact {
45
45
  targetPlugin?: string;
46
46
  filePath?: string;
47
47
  kind: GraphEdge['kind'];
48
+ type?: string;
48
49
  confidence?: number;
49
50
  metadata?: Record<string, unknown>;
50
51
  conflict?: PluginConflictPolicy;
@@ -99,6 +100,8 @@ export interface ScannerPlugin {
99
100
  dispose?(context: RepositoryScanContext): void | Promise<void>;
100
101
  /** Opt-in delta hook. Plugins without it retain the complete-repository v1 scan contract. */
101
102
  scanIncremental?(context: RepositoryScanContext, delta: RepositoryScanDelta, previous?: PreviousPluginContribution): IncrementalPluginScanResult | Promise<IncrementalPluginScanResult>;
103
+ /** Optional deferred enrichment hook. The host supplies structural nodes after the file scan. */
104
+ enrich?(input: EnrichmentRunnerInput): AttributionOutcome | Promise<AttributionOutcome>;
102
105
  }
103
106
  export interface PluginRunResult extends GraphExtractionResult {
104
107
  diagnostics: string[];
@@ -110,6 +113,7 @@ export interface PluginRunResult extends GraphExtractionResult {
110
113
  nodes: GraphNode[];
111
114
  edges: GraphEdge[];
112
115
  }>;
116
+ enrichmentPlugins?: Map<string, ScannerPlugin>;
113
117
  }
114
118
  export declare function createRepositoryScanContext(repoPath: string, suppliedManifests?: readonly RepositoryManifest[]): RepositoryScanContext;
115
119
  export declare function runScannerPlugins(context: RepositoryScanContext, plugins: readonly ScannerPlugin[], resourceLimits?: Partial<PluginResourceLimits>, delta?: RepositoryScanDelta, previousContributions?: ReadonlyMap<string, PreviousPluginContribution>): Promise<PluginRunResult>;
@@ -50,6 +50,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
50
50
  const failedPlugins = [];
51
51
  const failedPaths = new Set();
52
52
  const successfulPlugins = [];
53
+ const enrichmentPlugins = new Map();
53
54
  const incrementalRetractions = new Map();
54
55
  const owners = new Map();
55
56
  for (const plugin of plugins) {
@@ -72,6 +73,11 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
72
73
  if (finalized)
73
74
  collect(plugin, finalized, nodes, edges, owners, diagnostics, limits);
74
75
  successfulPlugins.push(plugin.name);
76
+ if (plugin.capabilities.includes('enrichment')) {
77
+ if (typeof plugin.enrich !== 'function')
78
+ throw new Error(`invalid enrichment plugin: ${plugin.name} must define enrich()`);
79
+ enrichmentPlugins.set(plugin.name, plugin);
80
+ }
75
81
  }
76
82
  catch (error) {
77
83
  failedPlugins.push(plugin.name);
@@ -123,6 +129,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
123
129
  sourceId: source.id,
124
130
  targetId: target.id,
125
131
  kind: edge.kind,
132
+ type: edge.type ?? edge.kind,
126
133
  confidence: edge.confidence ?? 1,
127
134
  metadata: {
128
135
  ...(edge.metadata ?? {}),
@@ -180,7 +187,7 @@ export async function runScannerPlugins(context, plugins, resourceLimits = {}, d
180
187
  pluginContributions.set(plugin, created);
181
188
  return created;
182
189
  }
183
- return { nodes: mergedNodes, edges: mergedEdges, diagnostics, failedPaths: [...failedPaths].sort(), failedPlugins, successfulPlugins, pluginContributions };
190
+ return { nodes: mergedNodes, edges: mergedEdges, diagnostics, failedPaths: [...failedPaths].sort(), failedPlugins, successfulPlugins, pluginContributions, enrichmentPlugins };
184
191
  function collect(plugin, result, nodeOutput, edgeOutput, ownerMap, messages, contributionLimits) {
185
192
  for (const node of result.nodes ?? []) {
186
193
  const identity = canonicalNodeIdentity(plugin.name, node.factKey);
@@ -9,6 +9,7 @@ export function openGraphDatabase(databasePath) {
9
9
  db.exec('PRAGMA journal_mode = WAL;');
10
10
  db.exec(CREATE_SCHEMA_SQL);
11
11
  ensureSchemaVersion(db);
12
+ db.exec('CREATE INDEX IF NOT EXISTS idx_edges_repo_type ON edges(repository_id, type);');
12
13
  return db;
13
14
  }
14
15
  export function openReadOnlyGraphDatabase(databasePath) {
@@ -56,6 +57,14 @@ function ensureSchemaVersion(db) {
56
57
  }
57
58
  if (currentVersion <= 6) {
58
59
  migrateSchemaV6ToV7(db);
60
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(7);
61
+ }
62
+ if (currentVersion <= 7) {
63
+ migrateSchemaV7ToV8(db);
64
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(8);
65
+ }
66
+ if (currentVersion <= 8) {
67
+ migrateSchemaV8ToV9(db);
59
68
  db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
60
69
  return;
61
70
  }
@@ -126,6 +135,19 @@ function migrateSchemaV6ToV7(db) {
126
135
  ON repository_manifest_inventory(repository_id, status);
127
136
  `);
128
137
  }
138
+ function migrateSchemaV7ToV8(db) {
139
+ const columns = new Set(db.prepare('PRAGMA table_info(file_enrichment_state)').all().map((column) => column.name));
140
+ if (!columns.has('plugin_name'))
141
+ db.exec("ALTER TABLE file_enrichment_state ADD COLUMN plugin_name TEXT NOT NULL DEFAULT 'git-attribution-plugin'");
142
+ if (!columns.has('history_fingerprint'))
143
+ db.exec('ALTER TABLE file_enrichment_state ADD COLUMN history_fingerprint TEXT');
144
+ if (!columns.has('outcome'))
145
+ db.exec('ALTER TABLE file_enrichment_state ADD COLUMN outcome TEXT');
146
+ }
147
+ function migrateSchemaV8ToV9(db) {
148
+ db.exec('ALTER TABLE edges ADD COLUMN type TEXT NOT NULL DEFAULT kind;');
149
+ db.exec('CREATE INDEX IF NOT EXISTS idx_edges_repo_type ON edges(repository_id, type);');
150
+ }
129
151
  function getCurrentSchemaVersion(db) {
130
152
  const row = db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get();
131
153
  return Number(row?.version ?? 0);
@@ -0,0 +1,52 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import type { ExploreEdgeKind, ExploreDirection, GraphNodeKind, Point } from '../types.js';
3
+ export interface ExploreAggregateRow {
4
+ id: string;
5
+ path: string;
6
+ language: string;
7
+ childCount: number;
8
+ repositoryId: string;
9
+ }
10
+ export interface ExploreSourceRow {
11
+ id: string;
12
+ kind: GraphNodeKind;
13
+ type: string;
14
+ name: string | null;
15
+ fileId: string | null;
16
+ path: string | null;
17
+ startPoint: Point;
18
+ endPoint: Point;
19
+ metadata: Record<string, unknown>;
20
+ }
21
+ export interface ExploreCandidateRow extends ExploreSourceRow {
22
+ rank: number;
23
+ match: 'exact' | 'qualified' | 'path' | 'prefix' | 'substring';
24
+ qualifiedName: string | null;
25
+ aggregateId: string | null;
26
+ }
27
+ export interface ExploreEdgeRow {
28
+ id: string;
29
+ sourceId: string;
30
+ targetId: string;
31
+ kind: ExploreEdgeKind;
32
+ confidence: number;
33
+ metadata: Record<string, unknown>;
34
+ }
35
+ export declare function selectExploreAggregates(db: DatabaseSync, repositoryId: string, search?: string): ExploreAggregateRow[];
36
+ export declare function selectExploreAggregateEdges(db: DatabaseSync, repositoryId: string, kinds?: readonly ExploreEdgeKind[]): Array<ExploreEdgeRow & {
37
+ count: number;
38
+ sourceAggregateId: string;
39
+ targetAggregateId: string;
40
+ }>;
41
+ export declare function selectExploreSourcesForSearch(db: DatabaseSync, repositoryId: string, term: string, limit?: number): ExploreSourceRow[];
42
+ export declare function selectExploreCandidates(db: DatabaseSync, repositoryId: string, term: string, { kind, file, fileAnchor, limit }?: {
43
+ kind?: string;
44
+ file?: string;
45
+ fileAnchor?: boolean;
46
+ limit?: number;
47
+ }): ExploreCandidateRow[];
48
+ export declare function selectExploreNode(db: DatabaseSync, repositoryId: string, id: string): ExploreSourceRow | null;
49
+ export declare function selectExploreTraversalEdges(db: DatabaseSync, repositoryId: string, frontier: readonly string[], direction: ExploreDirection, kinds: readonly ExploreEdgeKind[], limit: number): ExploreEdgeRow[];
50
+ export declare function selectExploreNodeRows(db: DatabaseSync, repositoryId: string, ids: readonly string[]): ExploreSourceRow[];
51
+ export declare function aggregateId(repositoryId: string, fileId: string): string;
52
+ //# sourceMappingURL=explore-queries.d.ts.map