@cleocode/nexus 2026.9.8 → 2026.9.10

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 (68) hide show
  1. package/dist/src/code/parser.d.ts +19 -1
  2. package/dist/src/code/parser.d.ts.map +1 -1
  3. package/dist/src/code/parser.js +61 -1
  4. package/dist/src/code/parser.js.map +1 -1
  5. package/dist/src/intelligence/impact.d.ts +9 -0
  6. package/dist/src/intelligence/impact.d.ts.map +1 -1
  7. package/dist/src/intelligence/impact.js +25 -13
  8. package/dist/src/intelligence/impact.js.map +1 -1
  9. package/dist/src/pipeline/call-processor.d.ts +4 -1
  10. package/dist/src/pipeline/call-processor.d.ts.map +1 -1
  11. package/dist/src/pipeline/call-processor.js +43 -3
  12. package/dist/src/pipeline/call-processor.js.map +1 -1
  13. package/dist/src/pipeline/community-processor.d.ts.map +1 -1
  14. package/dist/src/pipeline/community-processor.js +6 -27
  15. package/dist/src/pipeline/community-processor.js.map +1 -1
  16. package/dist/src/pipeline/extractors/typescript-extractor.d.ts +27 -35
  17. package/dist/src/pipeline/extractors/typescript-extractor.d.ts.map +1 -1
  18. package/dist/src/pipeline/extractors/typescript-extractor.js +100 -213
  19. package/dist/src/pipeline/extractors/typescript-extractor.js.map +1 -1
  20. package/dist/src/pipeline/filesystem-walker.d.ts +9 -5
  21. package/dist/src/pipeline/filesystem-walker.d.ts.map +1 -1
  22. package/dist/src/pipeline/filesystem-walker.js +154 -79
  23. package/dist/src/pipeline/filesystem-walker.js.map +1 -1
  24. package/dist/src/pipeline/heritage-processor.d.ts.map +1 -1
  25. package/dist/src/pipeline/heritage-processor.js +29 -4
  26. package/dist/src/pipeline/heritage-processor.js.map +1 -1
  27. package/dist/src/pipeline/import-processor.d.ts +28 -6
  28. package/dist/src/pipeline/import-processor.d.ts.map +1 -1
  29. package/dist/src/pipeline/import-processor.js +68 -45
  30. package/dist/src/pipeline/import-processor.js.map +1 -1
  31. package/dist/src/pipeline/index.d.ts +32 -20
  32. package/dist/src/pipeline/index.d.ts.map +1 -1
  33. package/dist/src/pipeline/index.js +173 -148
  34. package/dist/src/pipeline/index.js.map +1 -1
  35. package/dist/src/pipeline/knowledge-graph.d.ts +3 -1
  36. package/dist/src/pipeline/knowledge-graph.d.ts.map +1 -1
  37. package/dist/src/pipeline/knowledge-graph.js +36 -2
  38. package/dist/src/pipeline/knowledge-graph.js.map +1 -1
  39. package/dist/src/pipeline/lexical-scope.d.ts +60 -0
  40. package/dist/src/pipeline/lexical-scope.d.ts.map +1 -0
  41. package/dist/src/pipeline/lexical-scope.js +362 -0
  42. package/dist/src/pipeline/lexical-scope.js.map +1 -0
  43. package/dist/src/pipeline/parse-loop.d.ts +49 -2
  44. package/dist/src/pipeline/parse-loop.d.ts.map +1 -1
  45. package/dist/src/pipeline/parse-loop.js +346 -144
  46. package/dist/src/pipeline/parse-loop.js.map +1 -1
  47. package/dist/src/pipeline/processors/access-processor.d.ts +27 -14
  48. package/dist/src/pipeline/processors/access-processor.d.ts.map +1 -1
  49. package/dist/src/pipeline/processors/access-processor.js +106 -27
  50. package/dist/src/pipeline/processors/access-processor.js.map +1 -1
  51. package/dist/src/pipeline/structure-processor.d.ts.map +1 -1
  52. package/dist/src/pipeline/structure-processor.js +1 -0
  53. package/dist/src/pipeline/structure-processor.js.map +1 -1
  54. package/dist/src/pipeline/symbol-table.d.ts +41 -3
  55. package/dist/src/pipeline/symbol-table.d.ts.map +1 -1
  56. package/dist/src/pipeline/symbol-table.js +104 -2
  57. package/dist/src/pipeline/symbol-table.js.map +1 -1
  58. package/dist/src/pipeline/workers/parse-worker.d.ts +29 -92
  59. package/dist/src/pipeline/workers/parse-worker.d.ts.map +1 -1
  60. package/dist/src/pipeline/workers/parse-worker.js +49 -1110
  61. package/dist/src/pipeline/workers/parse-worker.js.map +1 -1
  62. package/dist/src/pipeline/workers/worker-pool.d.ts +6 -4
  63. package/dist/src/pipeline/workers/worker-pool.d.ts.map +1 -1
  64. package/dist/src/pipeline/workers/worker-pool.js +112 -22
  65. package/dist/src/pipeline/workers/worker-pool.js.map +1 -1
  66. package/dist/tsconfig.build.tsbuildinfo +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +3 -2
@@ -31,15 +31,19 @@
31
31
  * @task T540
32
32
  * @module pipeline/parse-loop
33
33
  */
34
+ import { createHash } from 'node:crypto';
34
35
  import fs from 'node:fs/promises';
36
+ import { basename, extname } from 'node:path';
35
37
  import { fileURLToPath } from 'node:url';
36
38
  import { confidenceLabelFromNumeric } from '@cleocode/contracts';
39
+ import { parseOriginalSource } from '../code/parser.js';
37
40
  import { extractGo } from './extractors/go-extractor.js';
38
41
  import { extractPython } from './extractors/python-extractor.js';
39
42
  import { extractRust } from './extractors/rust-extractor.js';
40
43
  import { extractTypeScript, } from './extractors/typescript-extractor.js';
41
- import { buildBarrelExportMap, extractImportsViaRegex, extractReExportsViaRegex, processExtractedImports, } from './import-processor.js';
44
+ import { buildBarrelExportMap, processExtractedImports } from './import-processor.js';
42
45
  import { detectLanguageFromPath } from './language-detection.js';
46
+ import { buildLexicalScopeModel } from './lexical-scope.js';
43
47
  import { extractAccesses } from './processors/access-processor.js';
44
48
  import { createWorkerPool } from './workers/worker-pool.js';
45
49
  // ---------------------------------------------------------------------------
@@ -124,32 +128,6 @@ function loadGrammar(langKey) {
124
128
  }
125
129
  }
126
130
  // ---------------------------------------------------------------------------
127
- // Content sanitization
128
- // ---------------------------------------------------------------------------
129
- /**
130
- * Sanitize source content for tree-sitter parsing.
131
- *
132
- * tree-sitter 0.21.x Node.js bindings throw `Error: Invalid argument`
133
- * when source strings contain non-ASCII Unicode characters (e.g. em-dash
134
- * `—`, curly quotes, etc.) in comments. Since all structural elements that
135
- * nexus cares about (function names, export specifiers, import paths) are
136
- * ASCII, replacing non-ASCII characters with spaces is safe and preserves
137
- * line/column positions for line number reporting.
138
- *
139
- * @param content - Raw file content read from disk
140
- * @returns Content with non-ASCII characters replaced by spaces
141
- */
142
- function sanitizeForParsing(content) {
143
- // eslint-disable-next-line no-control-regex
144
- return content.replace(/[^\x00-\x7F]/g, ' ');
145
- }
146
- /**
147
- * Hard limit for tree-sitter 0.21.x: native code uses a 15-bit byte-offset
148
- * counter that overflows at 32768 characters. Files larger than this use
149
- * regex-based fallback extraction.
150
- */
151
- const TREE_SITTER_MAX_CHARS = 32_767;
152
- // ---------------------------------------------------------------------------
153
131
  // Language key mapping
154
132
  // ---------------------------------------------------------------------------
155
133
  /**
@@ -175,16 +153,20 @@ function grammarKeyForLanguage(language) {
175
153
  * @param language - Canonical language name from `detectLanguageFromPath`
176
154
  * @param rootNode - Parsed tree-sitter AST root node
177
155
  * @param filePath - File path relative to repo root
156
+ * @param sourceGeneration - SHA-256 of original source bytes, distinct from publication identity.
157
+ * @param publicationGeneration - Preallocated graph publication identity, when publishing.
178
158
  * @returns Uniform extraction result
179
159
  */
180
- function runExtractor(language, rootNode, filePath) {
181
- // biome-ignore lint/suspicious/noExplicitAny: tree-sitter rootNode has no shared TS type
160
+ function runExtractor(language, rootNode, filePath, sourceGeneration, publicationGeneration) {
182
161
  const node = rootNode;
162
+ const model = language === 'typescript' || language === 'javascript'
163
+ ? buildLexicalScopeModel(rootNode, filePath, sourceGeneration, language, publicationGeneration)
164
+ : undefined;
183
165
  let result;
184
166
  switch (language) {
185
167
  case 'typescript':
186
168
  case 'javascript':
187
- result = extractTypeScript(node, filePath, language);
169
+ result = extractTypeScript(node, filePath, language, model);
188
170
  break;
189
171
  case 'python':
190
172
  result = extractPython(node, filePath);
@@ -201,9 +183,39 @@ function runExtractor(language, rootNode, filePath) {
201
183
  // Run access extraction on the parsed AST (Phase 3f — T1837).
202
184
  // Supports all languages with member_expression / attribute / field_expression /
203
185
  // selector_expression AST node types (TS, JS, Python, Go, Rust).
204
- result.accesses = extractAccesses(node, filePath);
186
+ result.accesses = extractAccesses(node, filePath, model);
205
187
  return result;
206
188
  }
189
+ /**
190
+ * Extract a file through the same native parser and language capabilities in either realm.
191
+ * @param source - Original Unicode source text.
192
+ * @param filePath - Repository-relative source path used for symbol identities.
193
+ * @param limits - Native source-byte and synchronous parsing deadline limits.
194
+ * @param publicationGeneration - Optional immutable publication identity passed by the pipeline.
195
+ * @returns Declarations, references, imports, heritage and access evidence.
196
+ * @remarks Static extraction does not establish complete runtime-call discovery.
197
+ * @example
198
+ * ```ts
199
+ * const extracted = extractOriginalSource('export const 文 = 1;', 'source.ts');
200
+ * ```
201
+ */
202
+ export function extractOriginalSource(source, filePath, limits, publicationGeneration) {
203
+ const language = detectLanguageFromPath(filePath);
204
+ const grammarKey = language
205
+ ? filePath.endsWith('.tsx')
206
+ ? 'tsx'
207
+ : grammarKeyForLanguage(language)
208
+ : null;
209
+ const parser = getParser();
210
+ const grammar = grammarKey ? loadGrammar(grammarKey) : null;
211
+ if (!language || !grammarKey)
212
+ throw new Error('Unsupported source language');
213
+ if (!parser || !grammar)
214
+ throw new Error(`Parser or grammar unavailable: ${grammarKey}`);
215
+ parser.setLanguage(grammar);
216
+ const tree = parseOriginalSource(parser, source, limits);
217
+ return runExtractor(language, tree.rootNode, filePath, createHash('sha256').update(source).digest('hex'), publicationGeneration);
218
+ }
207
219
  // ---------------------------------------------------------------------------
208
220
  // SymbolTable registration helpers
209
221
  // ---------------------------------------------------------------------------
@@ -289,33 +301,199 @@ function emitDefinesEdges(fileNodeId, symbols, graph) {
289
301
  });
290
302
  }
291
303
  }
304
+ const EXECUTABLE_CAPABILITIES = [
305
+ 'file-evidence',
306
+ 'declarations',
307
+ 'imports',
308
+ 'call-references',
309
+ 'access-references',
310
+ 'type-heritage',
311
+ ];
312
+ const SQL_CAPABILITIES = [
313
+ 'file-evidence',
314
+ 'sql-schema-objects',
315
+ 'sql-migrations',
316
+ 'sql-triggers',
317
+ 'sql-constraints',
318
+ 'sql-literal-references',
319
+ 'sql-dynamic-references',
320
+ ];
321
+ const PARSEABLE_LANGUAGES = new Set(['typescript', 'javascript', 'python', 'go', 'rust']);
322
+ const EXECUTABLE_LANGUAGES = new Set([
323
+ ...PARSEABLE_LANGUAGES,
324
+ 'java',
325
+ 'kotlin',
326
+ 'c',
327
+ 'cpp',
328
+ 'csharp',
329
+ 'php',
330
+ 'ruby',
331
+ 'swift',
332
+ 'dart',
333
+ 'vue',
334
+ 'shell',
335
+ 'cobol',
336
+ ]);
337
+ /** Build a role observation from independently stated requested capabilities. */
338
+ function roleCoverage(role, classification, evidence) {
339
+ return {
340
+ role,
341
+ classification,
342
+ requested: role === 'sql'
343
+ ? [...SQL_CAPABILITIES]
344
+ : role === 'executable' || role === 'unknown'
345
+ ? [...EXECUTABLE_CAPABILITIES]
346
+ : ['file-evidence', ...(evidence ? [evidence] : [])],
347
+ completed: ['file-evidence', ...(evidence ? [evidence] : [])],
348
+ limitations: role === 'sql'
349
+ ? [
350
+ 'SQL extraction is unsupported; schema and literal references remain unassessed.',
351
+ 'Dynamic SQL references remain unresolved.',
352
+ ]
353
+ : role === 'executable' || role === 'unknown'
354
+ ? ['Static extraction cannot establish complete runtime-call discovery.']
355
+ : [],
356
+ };
357
+ }
358
+ /** Identify known binary resources from both their extension and observed bytes. */
359
+ function hasResourceSignature(extension, bytes) {
360
+ const prefix = bytes.subarray(0, 12);
361
+ if (extension === '.png')
362
+ return prefix.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
363
+ if (extension === '.jpg' || extension === '.jpeg')
364
+ return prefix.subarray(0, 3).equals(Buffer.from('ffd8ff', 'hex'));
365
+ if (extension === '.gif')
366
+ return /^GIF8[79]a/.test(prefix.toString('ascii'));
367
+ if (extension === '.webp')
368
+ return (prefix.subarray(0, 4).toString('ascii') === 'RIFF' &&
369
+ prefix.subarray(8).toString('ascii') === 'WEBP');
370
+ if (extension === '.woff')
371
+ return prefix.subarray(0, 4).toString('ascii') === 'wOFF';
372
+ if (extension === '.woff2')
373
+ return prefix.subarray(0, 4).toString('ascii') === 'wOF2';
374
+ return false;
375
+ }
376
+ /** Classify only observed role evidence; unsupported or ambiguous code remains a gap. */
377
+ async function classifyFileCapabilities(file, repoPath, signal) {
378
+ const path = file.path;
379
+ const extension = extname(path).toLowerCase();
380
+ const language = detectLanguageFromPath(path);
381
+ const bytes = await fs.readFile(path.startsWith('/') ? path : `${repoPath}/${path}`, { signal });
382
+ if (file.contentHash && createHash('sha256').update(bytes).digest('hex') !== file.contentHash)
383
+ throw new Error('Source changed between scanning and capability classification');
384
+ const report = (capabilities) => ({
385
+ path,
386
+ status: capabilities.requested.every((capability) => capabilities.completed.includes(capability))
387
+ ? 'analyzed'
388
+ : 'unsupported',
389
+ ...(capabilities.role === 'sql'
390
+ ? { reason: 'No SQL schema or reference extractor is available' }
391
+ : capabilities.role === 'executable' || capabilities.role === 'unknown'
392
+ ? {
393
+ reason: 'Unsupported or unclassified executable capabilities require a symbol extractor',
394
+ }
395
+ : {}),
396
+ capabilities,
397
+ });
398
+ if (hasResourceSignature(extension, bytes))
399
+ return report(roleCoverage('asset', {
400
+ basis: 'path-and-content',
401
+ reason: `Resource extension ${extension} agrees with its observed binary signature`,
402
+ }, 'resource-evidence'));
403
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
404
+ if (/^\uFEFF?#!/.test(content))
405
+ return report(roleCoverage('executable', {
406
+ basis: 'content',
407
+ reason: 'Observed executable shebang; an asset extension cannot override it',
408
+ }));
409
+ if (EXECUTABLE_LANGUAGES.has(language ?? '') ||
410
+ ['.svelte', '.mdx', '.lua', '.pl', '.r', '.ps1', '.bat', '.cmd'].includes(extension) ||
411
+ /^(?:Makefile|Dockerfile)$/.test(basename(path)))
412
+ return report(roleCoverage('executable', {
413
+ basis: 'path',
414
+ reason: `Recognized executable language or filename: ${path}`,
415
+ }));
416
+ if (extension === '.sql')
417
+ return report(roleCoverage('sql', {
418
+ basis: 'path',
419
+ reason: 'SQL source extension; no schema or reference extractor is implemented',
420
+ }));
421
+ if (extension === '.md' || extension === '.markdown')
422
+ return report(roleCoverage('documentation', {
423
+ basis: 'path-and-content',
424
+ reason: 'Readable documentary Markdown; embedded examples are not production callers',
425
+ }, 'documentary-evidence'));
426
+ if (extension === '.json') {
427
+ let value;
428
+ try {
429
+ value = JSON.parse(content);
430
+ }
431
+ catch (error) {
432
+ return {
433
+ path,
434
+ status: 'failed',
435
+ reason: `JSON evidence is malformed: ${error instanceof Error ? error.message : String(error)}`,
436
+ capabilities: {
437
+ ...roleCoverage('data', {
438
+ basis: 'path',
439
+ reason: 'JSON extension with invalid content; role-specific analysis did not complete',
440
+ }, 'data-evidence'),
441
+ completed: ['file-evidence'],
442
+ },
443
+ };
444
+ }
445
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
446
+ if ('$schema' in value &&
447
+ typeof value.$schema === 'string' &&
448
+ /^https?:\/\/json-schema\.org\/(?:draft-0[467]|draft\/20\d\d-\d\d)\/schema#?$/.test(value.$schema))
449
+ return report(roleCoverage('schema', {
450
+ basis: 'content',
451
+ reason: `Valid JSON declares the JSON Schema dialect ${value.$schema}`,
452
+ }, 'schema-evidence'));
453
+ if (basename(path) === 'package-lock.json' &&
454
+ 'lockfileVersion' in value &&
455
+ typeof value.lockfileVersion === 'number' &&
456
+ 'packages' in value &&
457
+ value.packages !== null &&
458
+ typeof value.packages === 'object' &&
459
+ !Array.isArray(value.packages))
460
+ return report(roleCoverage('generated-data', {
461
+ basis: 'path-and-content',
462
+ reason: 'npm lockfile path with numeric lockfileVersion and packages map; generated data evidence only',
463
+ }, 'data-evidence'));
464
+ if (['package.json', 'tsconfig.json', 'jsconfig.json', 'composer.json', 'deno.json'].includes(basename(path)) ||
465
+ /(?:^|\/)\.vscode\/(?:settings|extensions|launch|tasks)\.json$/.test(path))
466
+ return report(roleCoverage('configuration', {
467
+ basis: 'path-and-content',
468
+ reason: `Recognized configuration path ${path} contains a valid JSON object; it is not executed`,
469
+ }, 'configuration-evidence'));
470
+ }
471
+ return report(roleCoverage('data', {
472
+ basis: 'content',
473
+ reason: 'Valid ordinary JSON without positive configuration, schema or generated-data provenance',
474
+ }, 'data-evidence'));
475
+ }
476
+ if (extension === '.snap') {
477
+ const header = /^\/\/ (?:Vitest|Jest) Snapshot v1,[^\r\n]*\r?\n/.exec(content);
478
+ const literal = String.raw `\x60(?:\\[\s\S]|[^\x60\\$]|\$(?!\{))*\x60`;
479
+ if (header &&
480
+ new RegExp(String.raw `^(?:\s*exports\[${literal}\]\s*=\s*${literal};\s*)+$`).test(content.slice(header[0].length)))
481
+ return report(roleCoverage('generated-data', {
482
+ basis: 'path-and-content',
483
+ reason: 'Recognized generated snapshot header and literal-only snapshot assignments',
484
+ }, 'data-evidence'));
485
+ }
486
+ return report(roleCoverage('unknown', {
487
+ basis: 'unknown',
488
+ reason: 'No positive role evidence; potentially executable content remains unassessed',
489
+ }));
490
+ }
292
491
  // ---------------------------------------------------------------------------
293
492
  // Main parse loop
294
493
  // ---------------------------------------------------------------------------
295
494
  // ---------------------------------------------------------------------------
296
495
  // Parallel parse path helpers (Wave H — T540)
297
496
  // ---------------------------------------------------------------------------
298
- /**
299
- * Map a `WorkerParsedSymbol` to a `GraphNode` for consumption by the
300
- * knowledge graph. The worker's extraction mirrors the sequential extractor
301
- * in structure so this conversion is 1:1.
302
- */
303
- function workerSymbolToGraphNode(sym) {
304
- return {
305
- id: sym.id,
306
- kind: sym.kind,
307
- name: sym.name,
308
- filePath: sym.filePath,
309
- startLine: sym.startLine,
310
- endLine: sym.endLine,
311
- language: sym.language,
312
- exported: sym.exported,
313
- parameters: sym.parameters,
314
- returnType: sym.returnType,
315
- docSummary: sym.docSummary,
316
- parent: sym.parent,
317
- };
318
- }
319
497
  /**
320
498
  * Run the parse loop using a worker pool for parallel parsing.
321
499
  *
@@ -350,20 +528,26 @@ async function runParallelParseLoop(parseableFiles, graph, symbolTable, importCt
350
528
  const file = parseableFiles[i];
351
529
  try {
352
530
  const absPath = file.path.startsWith('/') ? file.path : `${repoPath}/${file.path}`;
353
- const content = await fs.readFile(absPath, 'utf-8');
354
- workerInputs.push({ path: file.path, content });
531
+ options.parserLimits?.signal?.throwIfAborted();
532
+ const bytes = await fs.readFile(absPath);
533
+ if (file.contentHash &&
534
+ createHash('sha256').update(bytes).digest('hex') !== file.contentHash) {
535
+ throw new Error('Source changed between scanning and parsing');
536
+ }
537
+ const { signal: _signal, ...limits } = options.parserLimits ?? {};
538
+ workerInputs.push({
539
+ path: file.path,
540
+ content: bytes.toString('utf8'),
541
+ limits,
542
+ publicationGeneration: options.publicationGeneration,
543
+ });
355
544
  }
356
- catch {
357
- // Skip unreadable files
545
+ catch (error) {
546
+ options.parserLimits?.signal?.throwIfAborted();
547
+ throw new Error(`Parser input unavailable: ${file.path}: ${error instanceof Error ? error.message : String(error)}`);
358
548
  }
359
549
  }
360
- let pool;
361
- try {
362
- pool = createWorkerPool(workerUrl);
363
- }
364
- catch {
365
- return null; // Worker script unavailable — fall back to sequential
366
- }
550
+ const pool = createWorkerPool(workerUrl, undefined, options.parserLimits, options.parserExecution);
367
551
  let filesProcessedSoFar = 0;
368
552
  let workerResults;
369
553
  try {
@@ -375,12 +559,6 @@ async function runParallelParseLoop(parseableFiles, graph, symbolTable, importCt
375
559
  }
376
560
  });
377
561
  }
378
- catch (err) {
379
- await pool.terminate().catch(() => undefined);
380
- const msg = err instanceof Error ? err.message : String(err);
381
- process.stderr.write(`[nexus] Worker pool failed (${msg}), falling back to sequential.\n`);
382
- return null;
383
- }
384
562
  finally {
385
563
  await pool.terminate().catch(() => undefined);
386
564
  }
@@ -390,16 +568,19 @@ async function runParallelParseLoop(parseableFiles, graph, symbolTable, importCt
390
568
  const allParallelReExports = [];
391
569
  const allHeritage = [];
392
570
  const allCalls = [];
393
- // Note: access extraction (Phase 3f) runs on the parsed AST in the main thread
394
- // only. Workers do not transport ASTs, so parallel mode does not accumulate
395
- // accesses via this path — the sequential fallback handles small repos and
396
- // fixture tests. A future wave can add worker support for access extraction.
571
+ // Workers invoke the same extractor, including access records.
397
572
  const allParallelAccesses = [];
398
573
  for (const workerResult of workerResults) {
574
+ for (const report of workerResult.reports)
575
+ options.onFileReport?.(report);
576
+ const failed = workerResult.reports.filter((report) => report.status !== 'analyzed');
577
+ if (failed.length > 0)
578
+ throw new Error(`Parser worker incomplete: ${failed.map((report) => `${report.path}: ${report.reason}`).join('; ')}`);
579
+ allParallelAccesses.push(...workerResult.accesses);
399
580
  // Register symbols into SymbolTable and add graph nodes
400
581
  const fileGraphNodes = new Map();
401
582
  for (const sym of workerResult.symbols) {
402
- const graphNode = workerSymbolToGraphNode(sym);
583
+ const graphNode = sym;
403
584
  registerInSymbolTable([graphNode], symbolTable);
404
585
  graph.addNode(graphNode);
405
586
  // Group by filePath for defines edge emission below
@@ -413,40 +594,10 @@ async function runParallelParseLoop(parseableFiles, graph, symbolTable, importCt
413
594
  for (const [filePath, nodes] of fileGraphNodes) {
414
595
  emitDefinesEdges(filePath, nodes, graph);
415
596
  }
416
- // Collect imports (including named bindings for Tier 2a resolution — T617)
417
- for (const imp of workerResult.imports) {
418
- allExtractedImports.push({
419
- filePath: imp.filePath,
420
- rawImportPath: imp.rawImportPath,
421
- namedBindings: imp.namedBindings,
422
- });
423
- }
424
- // Collect re-exports for barrel map construction (T617)
425
- if (workerResult.reExports) {
426
- for (const re of workerResult.reExports) {
427
- allParallelReExports.push(re);
428
- }
429
- }
430
- // Map heritage — worker uses same field names as sequential path
431
- for (const h of workerResult.heritage) {
432
- allHeritage.push({
433
- filePath: h.filePath,
434
- typeName: h.typeName,
435
- typeNodeId: h.typeNodeId,
436
- kind: h.kind,
437
- parentName: h.parentName,
438
- });
439
- }
440
- // Map calls from worker format to pipeline format
441
- for (const c of workerResult.calls) {
442
- allCalls.push({
443
- filePath: c.filePath,
444
- sourceId: c.sourceId,
445
- calledName: c.calledName,
446
- callForm: c.callForm,
447
- receiverName: c.receiverName,
448
- });
449
- }
597
+ allExtractedImports.push(...workerResult.imports);
598
+ allParallelReExports.push(...workerResult.reExports);
599
+ allHeritage.push(...workerResult.heritage);
600
+ allCalls.push(...workerResult.calls);
450
601
  }
451
602
  if (onProgress && total > 0) {
452
603
  const lastFile = parseableFiles[total - 1];
@@ -475,7 +626,7 @@ async function runParallelParseLoop(parseableFiles, graph, symbolTable, importCt
475
626
  process.stderr.write(`[parse-loop-debug] internal.ts in workerInputs = ${internalInput ? 'YES: ' + internalInput.path : 'NO'}\n`);
476
627
  // Check if internal.ts had symbols or imports
477
628
  for (const wr of workerResults) {
478
- const hasInternalSymbol = wr.symbols.some((s) => s.filePath.includes('core/src/internal'));
629
+ const hasInternalSymbol = wr.symbols.some((s) => s.filePath?.includes('core/src/internal'));
479
630
  const hasInternalImport = wr.imports.some((i) => i.filePath.includes('core/src/internal'));
480
631
  const hasInternalReExport = wr.reExports.some((r) => r.filePath.includes('core/src/internal'));
481
632
  if (hasInternalSymbol || hasInternalImport || hasInternalReExport) {
@@ -522,12 +673,58 @@ async function runParallelParseLoop(parseableFiles, graph, symbolTable, importCt
522
673
  */
523
674
  export async function runParseLoop(files, graph, symbolTable, importCtx, repoPath, options = {}) {
524
675
  const { tsconfigPaths = null, namedImportMap = new Map(), onProgress } = options;
525
- // Filter to languages supported by the parse loop (Wave I adds Python, Go, Rust)
526
- const PARSEABLE_LANGUAGES = new Set(['typescript', 'javascript', 'python', 'go', 'rust']);
527
- const parseableFiles = files.filter((f) => {
528
- const lang = detectLanguageFromPath(f.path);
529
- return lang !== null && PARSEABLE_LANGUAGES.has(lang);
530
- });
676
+ // Attach one capability contract to sequential and worker-returned reports.
677
+ const callerReports = options.onFileReport;
678
+ const classifications = new Map();
679
+ const emitReport = (report) => {
680
+ const coverage = classifications.get(report.path);
681
+ if (!coverage)
682
+ throw new Error(`Missing file capability classification: ${report.path}`);
683
+ callerReports?.({
684
+ ...report,
685
+ capabilities: {
686
+ ...coverage,
687
+ requested: [...coverage.requested],
688
+ completed: report.status === 'analyzed' && coverage.role === 'executable'
689
+ ? [...coverage.requested]
690
+ : [...coverage.completed],
691
+ limitations: [...coverage.limitations],
692
+ },
693
+ });
694
+ };
695
+ options = { ...options, onFileReport: emitReport };
696
+ const parseableFiles = [];
697
+ for (const file of files) {
698
+ options.parserLimits?.signal?.throwIfAborted();
699
+ let report;
700
+ try {
701
+ report = await classifyFileCapabilities(file, repoPath, options.parserLimits?.signal);
702
+ }
703
+ catch (error) {
704
+ options.parserLimits?.signal?.throwIfAborted();
705
+ report = {
706
+ path: file.path,
707
+ status: 'failed',
708
+ reason: `read: ${error instanceof Error ? error.message : String(error)}`,
709
+ capabilities: {
710
+ ...roleCoverage('unknown', {
711
+ basis: 'unknown',
712
+ reason: 'Role evidence unavailable because the source read failed',
713
+ }),
714
+ completed: [],
715
+ },
716
+ };
717
+ }
718
+ if (!report.capabilities)
719
+ throw new Error(`Missing file capability assessment: ${file.path}`);
720
+ classifications.set(file.path, report.capabilities);
721
+ if (report.status !== 'failed' &&
722
+ report.capabilities.role === 'executable' &&
723
+ PARSEABLE_LANGUAGES.has(detectLanguageFromPath(file.path) ?? ''))
724
+ parseableFiles.push(file);
725
+ else
726
+ emitReport(report);
727
+ }
531
728
  const total = parseableFiles.length;
532
729
  if (total === 0)
533
730
  return {
@@ -538,21 +735,25 @@ export async function runParseLoop(files, graph, symbolTable, importCtx, repoPat
538
735
  };
539
736
  // Wave H: Check thresholds for parallel dispatch
540
737
  const totalBytes = files.reduce((acc, f) => acc + f.size, 0);
541
- const useWorkers = total >= WORKER_FILE_THRESHOLD || totalBytes >= WORKER_BYTE_THRESHOLD;
542
- if (useWorkers) {
738
+ const useWorkers = Boolean(options.parserExecution) ||
739
+ total >= WORKER_FILE_THRESHOLD ||
740
+ totalBytes >= WORKER_BYTE_THRESHOLD;
741
+ if (useWorkers && (options.parserExecution || !callerReports)) {
543
742
  process.stderr.write(`[nexus] Parallel parse: ${total} files, ${Math.round(totalBytes / 1024)}KB total — spawning worker pool\n`);
544
743
  try {
545
- const parallelResult = await runParallelParseLoop(parseableFiles, graph, symbolTable, importCtx, repoPath, { tsconfigPaths, namedImportMap, onProgress });
744
+ const parallelResult = await runParallelParseLoop(parseableFiles, graph, symbolTable, importCtx, repoPath, { ...options, tsconfigPaths, namedImportMap, onProgress });
546
745
  if (parallelResult !== null) {
547
746
  process.stderr.write('[nexus] Parallel parse complete.\n');
548
747
  return parallelResult;
549
748
  }
749
+ if (options.parserExecution)
750
+ throw new Error('Isolated parser executable unavailable');
550
751
  // Worker unavailable — fall through to sequential
551
752
  process.stderr.write('[nexus] Worker script not found — using sequential parse.\n');
552
753
  }
553
754
  catch (err) {
554
755
  const msg = err instanceof Error ? err.message : String(err);
555
- process.stderr.write(`[nexus] Parallel parse error (${msg}) — using sequential parse.\n`);
756
+ throw new Error(`Parallel parser failed; generation not published: ${msg}`);
556
757
  }
557
758
  }
558
759
  const allExtractedImports = [];
@@ -562,6 +763,13 @@ export async function runParseLoop(files, graph, symbolTable, importCtx, repoPat
562
763
  const allAccesses = [];
563
764
  const parser = getParser();
564
765
  if (!parser) {
766
+ for (const file of parseableFiles) {
767
+ options.onFileReport?.({
768
+ path: file.path,
769
+ status: 'failed',
770
+ reason: 'tree-sitter native module unavailable',
771
+ });
772
+ }
565
773
  process.stderr.write('[nexus] WARNING: tree-sitter native module not available — parse loop skipped.\n');
566
774
  return {
567
775
  allHeritage: [],
@@ -572,6 +780,7 @@ export async function runParseLoop(files, graph, symbolTable, importCtx, repoPat
572
780
  }
573
781
  let filesProcessed = 0;
574
782
  for (const file of parseableFiles) {
783
+ options.parserLimits?.signal?.throwIfAborted();
575
784
  filesProcessed++;
576
785
  // Progress reporting (every file for small repos, every 10 for larger ones)
577
786
  if (onProgress && (total <= 100 || filesProcessed % 10 === 0 || filesProcessed === total)) {
@@ -592,6 +801,11 @@ export async function runParseLoop(files, graph, symbolTable, importCtx, repoPat
592
801
  // Load grammar
593
802
  const grammar = loadGrammar(grammarKey);
594
803
  if (!grammar) {
804
+ options.onFileReport?.({
805
+ path: file.path,
806
+ status: 'failed',
807
+ reason: `No grammar for ${lang}`,
808
+ });
595
809
  process.stderr.write(`[nexus] SKIP: no grammar for ${lang} (file: ${file.path})\n`);
596
810
  continue;
597
811
  }
@@ -599,57 +813,45 @@ export async function runParseLoop(files, graph, symbolTable, importCtx, repoPat
599
813
  let source;
600
814
  try {
601
815
  const absPath = file.path.startsWith('/') ? file.path : `${repoPath}/${file.path}`;
602
- source = await fs.readFile(absPath, 'utf-8');
816
+ const bytes = await fs.readFile(absPath);
817
+ if (file.contentHash &&
818
+ createHash('sha256').update(bytes).digest('hex') !== file.contentHash) {
819
+ throw new Error('Source changed between scanning and parsing');
820
+ }
821
+ source = bytes.toString('utf-8');
603
822
  }
604
823
  catch (err) {
605
824
  const msg = err instanceof Error ? err.message : String(err);
825
+ options.onFileReport?.({ path: file.path, status: 'failed', reason: `read: ${msg}` });
606
826
  process.stderr.write(`[nexus] SKIP read error: ${file.path}: ${msg}\n`);
607
827
  continue;
608
828
  }
609
- // tree-sitter 0.21.x has a hard limit of 32767 chars per file (15-bit offset counter).
610
- // Files larger than this use a regex fallback for imports and re-exports only.
611
- const sanitized = sanitizeForParsing(source);
612
- if (sanitized.length > TREE_SITTER_MAX_CHARS) {
613
- // Regex-based fallback: extract re-exports and imports for barrel tracing.
614
- // Symbol definitions, heritage, and calls are skipped for oversized files.
615
- try {
616
- const regexReExports = extractReExportsViaRegex(source, file.path);
617
- for (const re of regexReExports) {
618
- allReExports.push(re);
619
- }
620
- const regexImports = extractImportsViaRegex(source, file.path);
621
- for (const imp of regexImports) {
622
- allExtractedImports.push(imp);
623
- }
624
- }
625
- catch (err) {
626
- const msg = err instanceof Error ? err.message : String(err);
627
- process.stderr.write(`[nexus] SKIP regex fallback error: ${file.path}: ${msg}\n`);
628
- }
629
- continue;
630
- }
631
829
  // Parse with tree-sitter
632
830
  let rootNode;
633
831
  try {
634
832
  parser.setLanguage(grammar);
635
- const tree = parser.parse(sanitized);
833
+ const tree = parseOriginalSource(parser, source, options.parserLimits);
636
834
  rootNode = tree.rootNode;
637
835
  }
638
836
  catch (err) {
639
837
  const msg = err instanceof Error ? err.message : String(err);
838
+ options.onFileReport?.({ path: file.path, status: 'failed', reason: `parse: ${msg}` });
839
+ options.parserLimits?.signal?.throwIfAborted();
640
840
  process.stderr.write(`[nexus] SKIP parse error: ${file.path}: ${msg}\n`);
641
841
  continue;
642
842
  }
643
843
  // Extract definitions, imports, heritage, calls, and re-exports — dispatch by language
644
844
  let extracted;
645
845
  try {
646
- extracted = runExtractor(lang, rootNode, file.path);
846
+ extracted = runExtractor(lang, rootNode, file.path, createHash('sha256').update(source).digest('hex'), options.publicationGeneration);
647
847
  }
648
848
  catch (err) {
649
849
  const msg = err instanceof Error ? err.message : String(err);
850
+ options.onFileReport?.({ path: file.path, status: 'failed', reason: `extract: ${msg}` });
650
851
  process.stderr.write(`[nexus] SKIP extract error: ${file.path}: ${msg}\n`);
651
852
  continue;
652
853
  }
854
+ options.onFileReport?.({ path: file.path, status: 'analyzed' });
653
855
  // Register in SymbolTable
654
856
  registerInSymbolTable(extracted.definitions, symbolTable);
655
857
  // Add nodes to graph