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.
- package/dist/core/ingestion/call-extractors/zig-static-gating.d.ts +5 -8
- package/dist/core/ingestion/languages/zig/scope-resolver.js +2 -0
- package/dist/core/ingestion/languages/zig/workspace-static-gating.d.ts +8 -0
- package/dist/core/ingestion/languages/zig/workspace-static-gating.js +76 -0
- package/dist/core/ingestion/parsing-processor.d.ts +5 -7
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +100 -39
- package/dist/core/ingestion/pipeline-phases/parse-round-budget.d.ts +42 -0
- package/dist/core/ingestion/pipeline-phases/parse-round-budget.js +50 -0
- package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +13 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +1 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +5 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +24 -0
- package/dist/core/ingestion/workers/worker-pool.js +30 -14
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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");
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
628
|
-
* warm run — where nothing misses — would never reach
|
|
629
|
-
* and would buffer every chunk's cached output until
|
|
630
|
-
* is the #2649 heap failure on a large repo.
|
|
631
|
-
* hits-only run draining at the same cadence as a cold one;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
if (
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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