gitnexus 1.6.12-rc.4 → 1.6.12-rc.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.
@@ -11,17 +11,14 @@
11
11
  * Conservative by design: we only tag an edge when we can prove the
12
12
  * gating expression evaluates to `false`. Anything ambiguous → live.
13
13
  *
14
- * Scope of v1:
14
+ * Supported scope:
15
15
  *
16
16
  * (a) **File-local** consts (`pub const FOO = false;`, plus const-to-const
17
17
  * aliases up to 5 hops), built once per file by `buildZigBoolConstMap`.
18
- * (b) **Cross-file** (`const cfg = @import("./cfg.zig"); if (cfg.FOO)`) is
19
- * NOT resolved yet. The evaluator keeps the seam for it (`importAliases`
20
- * + `lookupBoolsForPath`, consumed by the `field_expression` case), but
21
- * the only caller passes an empty alias map and a lookup that always
22
- * returns `undefined`, because the capture emitter runs in the parse
23
- * worker and sees only the current file. Tracked in #3162. Until then
24
- * every `cfg.FOO` condition folds to unknown, i.e. live.
18
+ * (b) **Cross-file** direct imports (`const cfg = @import("./cfg.zig");
19
+ * if (cfg.FOO)`) are enriched after per-file extraction. The workspace
20
+ * caller supplies `importAliases` and `lookupBoolsForPath`; the parse
21
+ * worker still uses empty/undefined inputs and remains file-local.
25
22
  *
26
23
  * Also out of scope: multi-hop member access (`cfg.sub.FOO`), re-exported
27
24
  * consts, runtime-evaluated bools (`const FOO = computeIt();`), and
@@ -16,6 +16,7 @@ import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
16
16
  import { zigProvider } from '../zig.js';
17
17
  import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
18
18
  import { populateZigRangeBindings } from './range-binding.js';
19
+ import { populateZigWorkspaceStaticGating } from './workspace-static-gating.js';
19
20
  export const zigScopeResolver = {
20
21
  language: SupportedLanguages.Zig,
21
22
  languageProvider: zigProvider,
@@ -48,6 +49,7 @@ export const zigScopeResolver = {
48
49
  arityCompatibility: zigArityCompatibility,
49
50
  buildMro: (graph, parsedFiles, nodeLookup) => buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
50
51
  populateOwners: (parsed) => populateClassOwnedMembers(parsed),
52
+ populateWorkspaceReferences: populateZigWorkspaceStaticGating,
51
53
  // Payload captures — `for (items) |it|`, `if (opt) |v|`, `while (it.next())
52
54
  // |x|` — typed from the subject's binding after finalize (F6).
53
55
  populateRangeBindings: populateZigRangeBindings,
@@ -0,0 +1,8 @@
1
+ import type { ParsedFile } from '../../../../_shared/index.js';
2
+ export declare function populateZigWorkspaceStaticGating(parsedFiles: ParsedFile[], ctx: {
3
+ readonly fileContents: ReadonlyMap<string, string>;
4
+ readonly treeCache?: {
5
+ get(filePath: string): unknown;
6
+ };
7
+ readonly resolutionConfig?: unknown;
8
+ }): void;
@@ -0,0 +1,76 @@
1
+ import { getTreeSitterBufferSize } from '../../constants.js';
2
+ import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
3
+ import { buildZigBoolConstMap, collectZigStaticGatedRanges, isPositionStaticGated, } from '../../call-extractors/zig-static-gating.js';
4
+ import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
5
+ import { getZigParser } from './query.js';
6
+ export function populateZigWorkspaceStaticGating(parsedFiles, ctx) {
7
+ const parser = getZigParser();
8
+ const trees = new Map();
9
+ const bools = new Map();
10
+ for (const parsed of parsedFiles) {
11
+ const source = ctx.fileContents.get(parsed.filePath);
12
+ if (source === undefined)
13
+ continue;
14
+ let tree = ctx.treeCache?.get(parsed.filePath);
15
+ if (tree === undefined) {
16
+ try {
17
+ tree = parseSourceSafe(parser, source, undefined, {
18
+ bufferSize: getTreeSitterBufferSize(source),
19
+ });
20
+ }
21
+ catch (err) {
22
+ if (err instanceof ParseTimeoutError)
23
+ continue;
24
+ throw err;
25
+ }
26
+ }
27
+ trees.set(parsed.filePath, tree);
28
+ bools.set(parsed.filePath, buildZigBoolConstMap(tree.rootNode));
29
+ }
30
+ const knownPaths = new Set(trees.keys());
31
+ for (const [index, parsed] of parsedFiles.entries()) {
32
+ const tree = trees.get(parsed.filePath);
33
+ if (tree === undefined)
34
+ continue;
35
+ const aliases = collectImportAliases(tree, parsed.filePath, knownPaths, ctx.resolutionConfig);
36
+ if (aliases.size === 0)
37
+ continue;
38
+ const ranges = collectZigStaticGatedRanges(tree.rootNode, bools.get(parsed.filePath) ?? new Map(), aliases, (filePath) => bools.get(filePath));
39
+ if (ranges.length === 0)
40
+ continue;
41
+ const next = parsed.referenceSites.map((site) => site.kind === 'call' &&
42
+ site.staticGated !== true &&
43
+ isPositionStaticGated(site.atRange.startLine, site.atRange.startCol, ranges)
44
+ ? { ...site, staticGated: true }
45
+ : site);
46
+ parsedFiles[index] = Object.freeze({ ...parsed, referenceSites: Object.freeze(next) });
47
+ }
48
+ }
49
+ function collectImportAliases(tree, fromFile, knownPaths, resolutionConfig) {
50
+ const candidates = new Map();
51
+ const declarationCounts = new Map();
52
+ for (const decl of tree.rootNode.descendantsOfType('variable_declaration')) {
53
+ const names = decl.namedChildren.filter((node) => node.type === 'identifier');
54
+ const binding = names[0]?.text;
55
+ if (binding === undefined)
56
+ continue;
57
+ declarationCounts.set(binding, (declarationCounts.get(binding) ?? 0) + 1);
58
+ const builtin = decl.namedChildren.find((node) => node.type === 'builtin_function' && node.text.startsWith('@import('));
59
+ const raw = builtin?.descendantsOfType('string').at(0)?.text;
60
+ if (raw === undefined)
61
+ continue;
62
+ const specifier = raw.replace(/^['"]|['"]$/g, '');
63
+ const target = resolveZigImportInternal(fromFile, specifier, knownPaths, resolutionConfig);
64
+ if (target !== null)
65
+ candidates.set(binding, target);
66
+ }
67
+ const aliases = new Map();
68
+ for (const [binding, target] of candidates) {
69
+ // Alias lookup below is name-based rather than position-aware. If a name
70
+ // is redeclared in another lexical scope, fail open instead of applying
71
+ // either module's constants to every use of that spelling.
72
+ if (declarationCounts.get(binding) === 1)
73
+ aliases.set(binding, target);
74
+ }
75
+ return aliases;
76
+ }
@@ -3,6 +3,7 @@ import type { SymbolTableWriter } from './model/index.js';
3
3
  import { type ExportedTypeMap } from './call-processor.js';
4
4
  import type { ParsedFile } from '../../_shared/index.js';
5
5
  import { WorkerPool } from './workers/worker-pool.js';
6
+ import type { DispatchGroup } from './workers/worker-pool.js';
6
7
  import type { ParseWorkerResult, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedModuleConstants, ExtractedToolDef, FileScopeBindings, ExtractedORMQuery, FetchWrapperDef } from './workers/parse-worker.js';
7
8
  import type { ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
8
9
  import type { SharedSpringType } from './route-extractors/spring-shared.js';
@@ -78,13 +79,10 @@ chunkHash?: string) => Promise<ParseWorkerResult[]>;
78
79
  * boundaries, so every result stays attributable to the chunk whose cache key
79
80
  * owns it.
80
81
  */
81
- export declare const dispatchChunkParseRound: (groups: ReadonlyArray<{
82
- items: {
83
- path: string;
84
- content: string;
85
- }[];
86
- chunkHash?: string;
87
- }>, workerPool: WorkerPool, onFileProgress?: FileProgressCallback) => Promise<ParseWorkerResult[][]>;
82
+ export declare const dispatchChunkParseRound: (groups: ReadonlyArray<DispatchGroup<{
83
+ path: string;
84
+ content: string;
85
+ }>>, workerPool: WorkerPool, onFileProgress?: FileProgressCallback) => Promise<ParseWorkerResult[][]>;
88
86
  export declare const processParsing: (graph: KnowledgeGraph, files: {
89
87
  path: string;
90
88
  content: string;
@@ -28,7 +28,7 @@ import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
28
28
  import { getProvider, getProviderForFile, providers } from '../languages/index.js';
29
29
  import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
30
30
  import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
31
- import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
31
+ import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, envWorkerPoolSize, resolveHostParallelism, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
32
32
  import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
33
33
  import { resolveOperands } from '../route-extractors/python-const-resolver.js';
34
34
  import { prepareRouteConstantsByProvider } from '../language-provider.js';
@@ -43,6 +43,8 @@ import { isVerboseIngestionEnabled } from '../utils/verbose.js';
43
43
  import { endTimer, isDeferredResolutionProfileEnabled, logDeferredProfile, startTimer, } from '../utils/deferred-resolution-profile.js';
44
44
  import { isDebugHeapEnabled, logHeapProbe } from '../utils/heap-probe.js';
45
45
  import { logger } from '../../logger.js';
46
+ import { mapConcurrent } from '../../../lib/utils.js';
47
+ import { createRoundBudget } from './parse-round-budget.js';
46
48
  // ── Constants ──────────────────────────────────────────────────────────────
47
49
  /**
48
50
  * Heap-scale guardrail constants (#2649). Measured on a Linux-kernel analyze:
@@ -130,10 +132,16 @@ const CHUNK_BYTES_PER_WORKER = DEFAULT_CHUNK_BYTE_BUDGET;
130
132
  * while the rest finish early). Drives the derived `subBatchMaxBytes`.
131
133
  */
132
134
  const TARGET_JOBS_PER_WORKER = 3;
135
+ /**
136
+ * Concurrent durable ParsedFile directory resets per round. Matches the file
137
+ * reader's `READ_CONCURRENCY`, because both compete for the same descriptors.
138
+ */
139
+ const DURABLE_RESET_CONCURRENCY = 32;
133
140
  /** Floor for a derived sub-batch so jobs don't shrink to per-file IPC churn. */
134
141
  const MIN_SUB_BATCH_BYTES = 256 * 1024;
135
142
  /**
136
- * Source bytes of cache-missing chunks allowed in flight in one pool round.
143
+ * Source bytes an open round may HOLD cache hits and misses alike — before
144
+ * it is dispatched and drained.
137
145
  *
138
146
  * A `dispatch` is a barrier, so one round-trip per cache pack leaves most slots
139
147
  * idle: packs are keyed by `(language, hash(path) % 128)` and routinely land far
@@ -413,11 +421,38 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
413
421
  // cores-based auto size is capped by source bytes / CHUNK_BYTES_PER_WORKER
414
422
  // so a tiny repo does not spawn a full idle pool. Cache pack membership
415
423
  // is independent of this number (#3088).
416
- const explicitPoolSize = options?.workerPoolSize;
424
+ // `--workers <N>` and `GITNEXUS_WORKER_POOL_SIZE` are both deliberate
425
+ // operator input, so both bypass the work-proportional cap below. Only the
426
+ // env path used to be clamped by it, which made the documented escape hatch
427
+ // silently do nothing: on a 30MB repo the cap resolves to 16, so an operator
428
+ // asking for 24 still got 16 with no warning, while `--workers 24` got 24.
429
+ const explicitPoolSize = options?.workerPoolSize ?? envWorkerPoolSize();
430
+ // Cores-based auto size, bounded by source bytes so a tiny repo does not
431
+ // spawn a full idle pool.
417
432
  const workProportionalCap = Math.max(1, Math.ceil(totalBytes / CHUNK_BYTES_PER_WORKER));
433
+ // An operator's number is honored, but never exceeds the number of files
434
+ // there are to parse — `GITNEXUS_WORKER_POOL_SIZE=100000` on a five-file repo
435
+ // should not become the literal thread count. This bounds `--workers` and the
436
+ // env var identically, keeping the parity above intact. Note it does NOT
437
+ // shrink an incremental re-analyze: `totalParseable` counts every parseable
438
+ // file in the scan, not the changed ones, so a warm run of a large repo still
439
+ // spawns the full requested pool.
418
440
  const effectivePoolSize = explicitPoolSize && explicitPoolSize > 0
419
- ? explicitPoolSize
441
+ ? Math.min(explicitPoolSize, Math.max(1, totalParseable))
420
442
  : Math.min(resolveAutoPoolSize(), workProportionalCap);
443
+ // Deliberate over-subscription is the operator's call, so this warns rather
444
+ // than caps — silently capping is what the override exists to stop. But an
445
+ // exported `GITNEXUS_WORKER_POOL_SIZE` applies to EVERY analyze in a
446
+ // long-lived caller (watch auto-sync, the MCP server), including small
447
+ // incremental ones, and that is easy to set once and forget.
448
+ if (explicitPoolSize && explicitPoolSize > 0) {
449
+ const hostParallelism = resolveHostParallelism();
450
+ if (effectivePoolSize > hostParallelism) {
451
+ logger.warn({ requested: explicitPoolSize, spawning: effectivePoolSize, hostParallelism }, `Worker pool size ${effectivePoolSize} exceeds this host's ${hostParallelism} usable core(s); ` +
452
+ `parsing is CPU-bound, so the extra workers add memory pressure without throughput. ` +
453
+ `This applies to every analyze while the override is set.`);
454
+ }
455
+ }
421
456
  // Cache packs: stable (language, hash(path) mod 128) buckets, then the
422
457
  // per-call byte budget inside each bucket (#3088). Pool size is used only
423
458
  // for worker count and sub-batch fan-out, not membership.
@@ -618,20 +653,30 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
618
653
  // the env can't change mid-run.
619
654
  const verboseThroughputLog = isDev || isVerboseIngestionEnabled();
620
655
  const heapProbeEveryN = isDebugHeapEnabled() ? 25 : 0;
656
+ /**
657
+ * Chunk hashes whose durable ParsedFile directory could not be reset. The
658
+ * old generation's shards are still on disk, so a warm hit would union
659
+ * stale shards with the new ones. Treated exactly like a quarantined chunk:
660
+ * skip the parse-cache write so the next run re-dispatches into a clean
661
+ * directory rather than trusting a generation we could not clear.
662
+ */
663
+ const durablePrepareFailures = new Set();
621
664
  const roundByteBudget = resolveParseRoundByteBudget(options);
622
665
  let roundEntries = [];
623
- let roundMissBytes = 0;
624
666
  /**
625
667
  * Bytes an open round is HOLDING, counting hits as well as misses.
626
668
  *
627
- * `roundMissBytes` alone bounds only what the workers are asked to do, so a
628
- * warm run — where nothing misses — would never reach the close condition
629
- * and would buffer every chunk's cached output until the tail drain. That
630
- * is the #2649 heap failure on a large repo. Closing on either cap keeps a
631
- * hits-only run draining at the same cadence as a cold one; `startRound`
632
- * already supports a round with no misses.
669
+ * Counting only the cache-MISSING bytes would bound just what the workers
670
+ * are asked to do, so a warm run — where nothing misses — would never reach
671
+ * the close condition and would buffer every chunk's cached output until
672
+ * the tail drain. That is the #2649 heap failure on a large repo. Counting
673
+ * both keeps a hits-only run draining at the same cadence as a cold one;
674
+ * `startRound` already supports a round with no misses.
675
+ *
676
+ * Measured in UTF-8 bytes, matching `estimateItemBytes` in the worker pool,
677
+ * so the cap means the same thing here as it does for a job's payload.
633
678
  */
634
- let roundBufferedBytes = 0;
679
+ const roundBudget = createRoundBudget(roundByteBudget);
635
680
  /**
636
681
  * Files QUEUED into rounds so far. `filesParsedSoFar` only advances when a
637
682
  * round drains, so it is the right number for the throughput log but would
@@ -755,7 +800,12 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
755
800
  if (parseCache && p.chunkHash && rawResults.length > 0) {
756
801
  const quarantineSet = new Set(workerPool?.getQuarantinedPaths?.() ?? []);
757
802
  const chunkHadQuarantine = p.chunkFiles.some((f) => quarantineSet.has(f.path));
758
- if (chunkHadQuarantine) {
803
+ const durableGenerationStale = durablePrepareFailures.has(p.chunkHash);
804
+ if (durableGenerationStale) {
805
+ logger.warn({ chunkHash: p.chunkHash.slice(0, 8) }, 'parse-cache SKIP: durable generation for this chunk could not be reset, ' +
806
+ 'so its shards may be stale; next run will re-dispatch it');
807
+ }
808
+ else if (chunkHadQuarantine) {
759
809
  if (isDev) {
760
810
  const quarantinedInChunk = p.chunkFiles.filter((f) => quarantineSet.has(f.path)).length;
761
811
  logger.info(`📦 parse-cache SKIP: chunk ${p.chunkIdx + 1}/${numChunks} ` +
@@ -785,19 +835,34 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
785
835
  if (misses.length === 0) {
786
836
  return { entries, results: Promise.resolve([]) };
787
837
  }
788
- for (const miss of misses) {
789
- if (durableParsedFileDir !== undefined && miss.chunkHash !== null) {
790
- try {
791
- await prepareDurableParsedFileChunk(durableParsedFileDir, miss.chunkHash);
792
- }
793
- catch (err) {
794
- // The durable store is an optimization degrade like the restore
795
- // path does instead of failing the analyze. Workers recreate the
796
- // directory on write, so at worst the old generation lingers.
797
- logger.warn({ err, chunkHash: miss.chunkHash.slice(0, 8) }, 'parsedfile-cache: could not reset durable chunk generation; continuing');
798
- }
838
+ // Each chunk resets its own directory, so these are independent and run
839
+ // concurrently: serially they would sit on the critical path this round
840
+ // exists to shorten, with the pool idle and the previous round's merge
841
+ // waiting, once per miss.
842
+ //
843
+ // BOUNDED, though. A round can hold hundreds of small packs, and each
844
+ // reset is a recursive rm + mkdir. Firing all of them at once competes
845
+ // for descriptors with the chunk prefetch this loop already has in
846
+ // flight, and `readFileContents` degrades a losing read SILENTLY by
847
+ // contract a dropped file would vanish from the chunk, from the graph,
848
+ // and from the chunk hash, shipping a narrowed index with exit 0. Same
849
+ // helper and width the file reads use.
850
+ await mapConcurrent(misses, async (miss) => {
851
+ if (durableParsedFileDir === undefined || miss.chunkHash === null)
852
+ return;
853
+ try {
854
+ await prepareDurableParsedFileChunk(durableParsedFileDir, miss.chunkHash);
799
855
  }
800
- }
856
+ catch (err) {
857
+ // The durable store is an optimization — degrade like the restore
858
+ // path does instead of failing the analyze. Workers recreate the
859
+ // directory on write, so at worst the old generation lingers.
860
+ // Caught per chunk so one failure cannot abort the others.
861
+ durablePrepareFailures.add(miss.chunkHash);
862
+ logger.warn({ err, chunkHash: miss.chunkHash.slice(0, 8) }, 'parsedfile-cache: could not reset durable chunk generation; ' +
863
+ 'continuing without caching this chunk');
864
+ }
865
+ }, { concurrency: DURABLE_RESET_CONCURRENCY });
801
866
  const roundFiles = misses.reduce((sum, miss) => sum + miss.chunkFiles.length, 0);
802
867
  const firstIdx = misses[0].chunkIdx;
803
868
  const lastIdx = misses[misses.length - 1].chunkIdx;
@@ -844,7 +909,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
844
909
  */
845
910
  const drainRound = async (round) => {
846
911
  const missResults = round.missResults;
847
- const missCount = round.entries.reduce((sum, entry) => sum + (entry.kind === 'miss' ? 1 : 0), 0);
912
+ const missCount = round.entries.filter((entry) => entry.kind === 'miss').length;
848
913
  // `dispatchGroups` returns one array per input group. If that contract
849
914
  // ever breaks, every later entry in this round would silently merge the
850
915
  // wrong chunk's results and skip its cache write, with a clean exit.
@@ -874,8 +939,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
874
939
  const closeRound = async () => {
875
940
  const started = await startRound(roundEntries);
876
941
  roundEntries = [];
877
- roundMissBytes = 0;
878
- roundBufferedBytes = 0;
942
+ roundBudget.reset();
879
943
  const previous = pendingRound;
880
944
  pendingRound = null;
881
945
  if (previous) {
@@ -987,6 +1051,8 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
987
1051
  parsedFileStorePath !== undefined &&
988
1052
  durableExpectedPaths !== undefined &&
989
1053
  (await durableChunkHasShards(parsedFileStorePath, chunkHash, durableExpectedPaths));
1054
+ // Set by whichever branch queues this chunk; drives the close below.
1055
+ let roundIsFull = false;
990
1056
  if (cachedRaw && cachedRaw.length > 0 && (durableHit || parsedFileStorePath === undefined)) {
991
1057
  // Cache hit: replay cached worker output. Finalize any parked worker
992
1058
  // chunk FIRST so deferred aggregation stays in chunk order, then merge
@@ -1022,8 +1088,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1022
1088
  chunkStartMs,
1023
1089
  cachedRaw,
1024
1090
  });
1025
- for (const file of chunkFiles)
1026
- roundBufferedBytes += file.content.length;
1091
+ roundIsFull = roundBudget.addChunk(chunkFiles.map((file) => file.content));
1027
1092
  queuedFilesSoFar += chunkFiles.length;
1028
1093
  }
1029
1094
  else {
@@ -1032,18 +1097,14 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1032
1097
  chunkCacheMisses++;
1033
1098
  reparsedFileCount += chunkFiles.length;
1034
1099
  roundEntries.push({ kind: 'miss', chunkIdx, chunkHash, chunkFiles, chunkStartMs });
1035
- for (const file of chunkFiles) {
1036
- roundMissBytes += file.content.length;
1037
- roundBufferedBytes += file.content.length;
1038
- }
1100
+ roundIsFull = roundBudget.addChunk(chunkFiles.map((file) => file.content));
1039
1101
  queuedFilesSoFar += chunkFiles.length;
1040
1102
  }
1041
- // Close on EITHER cap. `roundMissBytes` sizes the worker round;
1042
- // `roundBufferedBytes` bounds what the main thread is holding, which is
1043
- // the only cap a warm run can ever reach.
1044
- if (roundMissBytes >= roundByteBudget || roundBufferedBytes >= roundByteBudget) {
1103
+ // One cap, on what the main thread is holding. That bounds the worker
1104
+ // round too, since a round's dispatched bytes are a subset of its
1105
+ // buffered bytes.
1106
+ if (roundIsFull)
1045
1107
  await closeRound();
1046
- }
1047
1108
  // (Per-chunk aggregation + parse-cache write + throughput log now run in
1048
1109
  // `applyChunkResults` / `finalizeWorkerChunk` — see the merge-pipelining
1049
1110
  // block above. Route/import/inheritance edges are emitted later: route
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The fold that decides when an open dispatch round closes.
3
+ *
4
+ * Extracted so the decision is a shared, inspectable unit rather than four
5
+ * loose statements inside `runChunkedParseAndResolve`. The parse loop is
6
+ * STREAMING — it reads chunk contents lazily, so it cannot know every chunk's
7
+ * size up front and cannot "plan" rounds ahead. That makes an accumulator, not
8
+ * a planner, the honest shape: feed it each chunk as it is queued and it tells
9
+ * you whether the round is now full.
10
+ *
11
+ * Being a real unit is what makes round cadence observable. Round boundaries
12
+ * are otherwise invisible from outside the parse phase: they change no graph
13
+ * output (that is the point of batching) and surface only in a log line, which
14
+ * is why `bench/parse-dispatch-rounds` measures this directly rather than
15
+ * inferring cadence from a full analyze.
16
+ */
17
+ /** Bytes a file contributes to the open round's retained total. */
18
+ export declare const roundFileBytes: (content: string) => number;
19
+ export interface RoundBudget {
20
+ /**
21
+ * Add one queued chunk's files. Returns true when the round is now full and
22
+ * the caller should close it. Closing resets the accumulator.
23
+ */
24
+ addChunk(contents: readonly string[]): boolean;
25
+ /** Bytes currently held by the open round. */
26
+ readonly bufferedBytes: number;
27
+ /** Reset without closing — used when the caller closes for another reason. */
28
+ reset(): void;
29
+ }
30
+ /**
31
+ * `budgetBytes` bounds what the main thread HOLDS, counting cache hits as well
32
+ * as misses. Counting only cache-missing bytes would bound just the work sent
33
+ * to workers, so a warm run — where nothing misses — would never reach the
34
+ * close condition and would buffer every chunk's cached output until the tail
35
+ * drain. That is the #2649 heap failure on a large repo.
36
+ *
37
+ * Measured in UTF-8 bytes, matching `estimateItemBytes` in the worker pool.
38
+ * `String.length` would return UTF-16 code units, undercounting non-ASCII
39
+ * source by up to 3x and letting a CJK-heavy repo hold well past its nominal
40
+ * budget before draining.
41
+ */
42
+ export declare const createRoundBudget: (budgetBytes: number) => RoundBudget;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The fold that decides when an open dispatch round closes.
3
+ *
4
+ * Extracted so the decision is a shared, inspectable unit rather than four
5
+ * loose statements inside `runChunkedParseAndResolve`. The parse loop is
6
+ * STREAMING — it reads chunk contents lazily, so it cannot know every chunk's
7
+ * size up front and cannot "plan" rounds ahead. That makes an accumulator, not
8
+ * a planner, the honest shape: feed it each chunk as it is queued and it tells
9
+ * you whether the round is now full.
10
+ *
11
+ * Being a real unit is what makes round cadence observable. Round boundaries
12
+ * are otherwise invisible from outside the parse phase: they change no graph
13
+ * output (that is the point of batching) and surface only in a log line, which
14
+ * is why `bench/parse-dispatch-rounds` measures this directly rather than
15
+ * inferring cadence from a full analyze.
16
+ */
17
+ /** Bytes a file contributes to the open round's retained total. */
18
+ export const roundFileBytes = (content) => Buffer.byteLength(content, 'utf8');
19
+ /**
20
+ * `budgetBytes` bounds what the main thread HOLDS, counting cache hits as well
21
+ * as misses. Counting only cache-missing bytes would bound just the work sent
22
+ * to workers, so a warm run — where nothing misses — would never reach the
23
+ * close condition and would buffer every chunk's cached output until the tail
24
+ * drain. That is the #2649 heap failure on a large repo.
25
+ *
26
+ * Measured in UTF-8 bytes, matching `estimateItemBytes` in the worker pool.
27
+ * `String.length` would return UTF-16 code units, undercounting non-ASCII
28
+ * source by up to 3x and letting a CJK-heavy repo hold well past its nominal
29
+ * budget before draining.
30
+ */
31
+ export const createRoundBudget = (budgetBytes) => {
32
+ let bufferedBytes = 0;
33
+ return {
34
+ addChunk(contents) {
35
+ for (const content of contents)
36
+ bufferedBytes += roundFileBytes(content);
37
+ if (bufferedBytes >= budgetBytes) {
38
+ bufferedBytes = 0;
39
+ return true;
40
+ }
41
+ return false;
42
+ },
43
+ get bufferedBytes() {
44
+ return bufferedBytes;
45
+ },
46
+ reset() {
47
+ bufferedBytes = 0;
48
+ },
49
+ };
50
+ };
@@ -615,6 +615,19 @@ export interface ScopeResolver {
615
615
  readonly populateWorkspaceOwners?: (parsedFiles: readonly ParsedFile[], ctx: {
616
616
  readonly fileContents: ReadonlyMap<string, string>;
617
617
  }) => void;
618
+ /**
619
+ * Optional workspace-wide enrichment of extracted reference sites. Runs
620
+ * after all files have been extracted and before reference finalization.
621
+ * Use this when a per-file capture needs conservative facts from an
622
+ * imported sibling (for example a compile-time branch constant).
623
+ */
624
+ readonly populateWorkspaceReferences?: (parsedFiles: ParsedFile[], ctx: {
625
+ readonly fileContents: ReadonlyMap<string, string>;
626
+ readonly treeCache?: {
627
+ get(filePath: string): unknown;
628
+ };
629
+ readonly resolutionConfig?: unknown;
630
+ }) => void;
618
631
  /**
619
632
  * Recognize a `super(...)`-style receiver text. Python returns
620
633
  * `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
@@ -55,6 +55,7 @@ const NOOP_OUTPUT = Object.freeze({
55
55
  /** Select source files that must be materialized for one resolver pass. */
56
56
  export function selectScopeSourcePathsToRead(provider, primaryFilePaths, preExtractedByPath) {
57
57
  const hasPostExtractHooks = provider.populateWorkspaceOwners !== undefined ||
58
+ provider.populateWorkspaceReferences !== undefined ||
58
59
  provider.populateNamespaceSiblings !== undefined ||
59
60
  provider.populateRangeBindings !== undefined ||
60
61
  provider.emitPostResolutionEdges !== undefined;
@@ -312,6 +312,11 @@ export function runScopeResolution(input, provider) {
312
312
  }
313
313
  logHeapProbe('sr-extract-end', `lang=${provider.language} parsedFiles=${parsedFiles.length} preExtractedHits=${preExtractedHits} skipped=${filesSkipped}`);
314
314
  provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
315
+ provider.populateWorkspaceReferences?.(parsedFiles, {
316
+ fileContents: getFileContents(),
317
+ treeCache,
318
+ resolutionConfig: input.resolutionConfig,
319
+ });
315
320
  // A callable-flow-only provider has no reason to build the whole-graph
316
321
  // lookup or finalize ordinary references when none of its files emitted a
317
322
  // callable fact. This keeps the opt-in path proportional to source scanning
@@ -27,6 +27,12 @@ export interface WorkerPool {
27
27
  *
28
28
  * Returns one result array per input group, in input order. A group whose
29
29
  * items were all quarantined yields an empty array.
30
+ *
31
+ * Required, not optional. `getQuarantinedPaths?` and `getStats?` below are
32
+ * marked optional as a compatibility accommodation for `WorkerPool` shapes
33
+ * that predate them — not as a convention for new members. Making this one
34
+ * optional would force a `?.` plus a fallback branch at its only production
35
+ * call site, and that branch could never run.
30
36
  */
31
37
  dispatchGroups<TInput, TResult>(groups: readonly DispatchGroup<TInput>[], onProgress?: (filesProcessed: number) => void): Promise<TResult[][]>;
32
38
  /**
@@ -260,6 +266,17 @@ interface ResolvedWorkerPoolOptions {
260
266
  workerReadyTimeoutMs: number;
261
267
  }
262
268
  export declare function resolveWorkerPoolOptions(options?: WorkerPoolOptions, poolSize?: number): ResolvedWorkerPoolOptions;
269
+ /**
270
+ * The pool size requested via the `GITNEXUS_WORKER_POOL_SIZE` env var, or
271
+ * `undefined` when unset, empty/whitespace, or invalid. Module-internal sizing
272
+ * reader consumed by {@link resolveAutoPoolSize} (the env override) and
273
+ * {@link workerPoolDisabledByEnv} (the disabled-channel check). Reads only —
274
+ * never mutates `process.env`. Empty/whitespace is treated as *unset* (falls
275
+ * through to the auto formula), not as 0 — an empty assignment (`export
276
+ * GITNEXUS_WORKER_POOL_SIZE=`) is an accident, not a request for zero workers;
277
+ * only a literal `0` disables the pool.
278
+ */
279
+ export declare function envWorkerPoolSize(): number | undefined;
263
280
  /**
264
281
  * True when the operator set `GITNEXUS_WORKER_POOL_SIZE=0` — the env-channel
265
282
  * equivalent of `--workers 0`. The parse phase consults this (only when no
@@ -285,6 +302,13 @@ export declare function workerPoolDisabledByEnv(): boolean;
285
302
  * on the env / default.
286
303
  */
287
304
  export declare function resolveAutoPoolSize(): number;
305
+ /**
306
+ * Usable parallelism for this process. Prefers `os.availableParallelism` so
307
+ * cgroup CPU limits are honored, falling back to `os.cpus().length` on older
308
+ * Node. Exported so callers that size work against the host (rather than
309
+ * against the pool default) do not re-derive the fallback.
310
+ */
311
+ export declare function resolveHostParallelism(): number;
288
312
  export declare function startHeartbeatStallTracker(): {
289
313
  read: () => number;
290
314
  stop: () => void;
@@ -124,7 +124,9 @@ const DEFAULT_WORKER_READY_TIMEOUT_MS = 5_000;
124
124
  * extraction / structured-clone overhead, and the marginal worker adds
125
125
  * memory pressure (tree-sitter state + sub-batch buffer) without much
126
126
  * throughput gain. Operators on bigger machines override via
127
- * `GITNEXUS_WORKER_POOL_SIZE` or `--workers <N>`.
127
+ * `GITNEXUS_WORKER_POOL_SIZE` or `--workers <N>`; both are deliberate
128
+ * operator input and bypass the work-proportional sizing in `parse-impl`,
129
+ * which only bounds the AUTO default.
128
130
  */
129
131
  const DEFAULT_POOL_SIZE_CAP = 16;
130
132
  // ── Self-healing startup restart policy (#1741) ──────────────────────────────
@@ -263,7 +265,7 @@ export function resolveWorkerPoolOptions(options = {}, poolSize) {
263
265
  * GITNEXUS_WORKER_POOL_SIZE=`) is an accident, not a request for zero workers;
264
266
  * only a literal `0` disables the pool.
265
267
  */
266
- function envWorkerPoolSize() {
268
+ export function envWorkerPoolSize() {
267
269
  const raw = process.env.GITNEXUS_WORKER_POOL_SIZE;
268
270
  if (raw === undefined || raw.trim() === '')
269
271
  return undefined;
@@ -307,8 +309,18 @@ export function resolveAutoPoolSize() {
307
309
  // pool cap exists to prevent. Falls back to os.cpus().length on
308
310
  // older Node versions. Mirrors `capabilities.ts:85`
309
311
  // (`defaultEmbeddingThreads`).
310
- const cores = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
311
- return Math.min(DEFAULT_POOL_SIZE_CAP, Math.max(1, cores - 1));
312
+ return Math.min(DEFAULT_POOL_SIZE_CAP, Math.max(1, resolveHostParallelism() - 1));
313
+ }
314
+ /**
315
+ * Usable parallelism for this process. Prefers `os.availableParallelism` so
316
+ * cgroup CPU limits are honored, falling back to `os.cpus().length` on older
317
+ * Node. Exported so callers that size work against the host (rather than
318
+ * against the pool default) do not re-derive the fallback.
319
+ */
320
+ export function resolveHostParallelism() {
321
+ return typeof os.availableParallelism === 'function'
322
+ ? os.availableParallelism()
323
+ : os.cpus().length;
312
324
  }
313
325
  /**
314
326
  * Max characters of a worker's stderr retained for crash diagnostics. A
@@ -889,16 +901,20 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
889
901
  // Layer 3: filter out quarantined paths so a known-bad file never reaches
890
902
  // a worker again this pool lifetime. The caller queries
891
903
  // `getQuarantinedPaths` after dispatch to route filtered items.
892
- const dispatchableGroups = groups.map((group) => {
893
- const items = [];
894
- for (const item of group.items) {
895
- const path = itemPath(item);
896
- if (path !== undefined && quarantine.has(path))
897
- continue;
898
- items.push(item);
899
- }
900
- return { items, chunkHash: group.chunkHash };
901
- });
904
+ // Quarantine is empty on every run that has not had a worker die, so the
905
+ // filter below would be an identity copy of every group's items. Skip it.
906
+ const dispatchableGroups = quarantine.size === 0
907
+ ? groups
908
+ : groups.map((group) => {
909
+ const items = [];
910
+ for (const item of group.items) {
911
+ const path = itemPath(item);
912
+ if (path !== undefined && quarantine.has(path))
913
+ continue;
914
+ items.push(item);
915
+ }
916
+ return { items, chunkHash: group.chunkHash };
917
+ });
902
918
  const dispatchableCount = dispatchableGroups.reduce((sum, group) => sum + group.items.length, 0);
903
919
  if (dispatchableCount === 0)
904
920
  return emptyPerGroup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.4",
3
+ "version": "1.6.12-rc.6",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",