gitnexus 1.6.5-rc.13 → 1.6.5-rc.15

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 (53) hide show
  1. package/dist/core/incremental/shadow-candidates.d.ts +44 -0
  2. package/dist/core/incremental/shadow-candidates.js +74 -0
  3. package/dist/core/incremental/subgraph-extract.d.ts +64 -0
  4. package/dist/core/incremental/subgraph-extract.js +111 -0
  5. package/dist/core/ingestion/call-processor.js +163 -45
  6. package/dist/core/ingestion/community-processor.js +18 -0
  7. package/dist/core/ingestion/languages/java/arity-metadata.d.ts +18 -0
  8. package/dist/core/ingestion/languages/java/arity-metadata.js +40 -0
  9. package/dist/core/ingestion/languages/java/arity.d.ts +10 -0
  10. package/dist/core/ingestion/languages/java/arity.js +24 -0
  11. package/dist/core/ingestion/languages/java/cache-stats.d.ts +15 -0
  12. package/dist/core/ingestion/languages/java/cache-stats.js +26 -0
  13. package/dist/core/ingestion/languages/java/captures.d.ts +17 -0
  14. package/dist/core/ingestion/languages/java/captures.js +187 -0
  15. package/dist/core/ingestion/languages/java/import-decomposer.d.ts +18 -0
  16. package/dist/core/ingestion/languages/java/import-decomposer.js +85 -0
  17. package/dist/core/ingestion/languages/java/import-target.d.ts +17 -0
  18. package/dist/core/ingestion/languages/java/import-target.js +100 -0
  19. package/dist/core/ingestion/languages/java/index.d.ts +29 -0
  20. package/dist/core/ingestion/languages/java/index.js +29 -0
  21. package/dist/core/ingestion/languages/java/interpret.d.ts +13 -0
  22. package/dist/core/ingestion/languages/java/interpret.js +131 -0
  23. package/dist/core/ingestion/languages/java/merge-bindings.d.ts +12 -0
  24. package/dist/core/ingestion/languages/java/merge-bindings.js +40 -0
  25. package/dist/core/ingestion/languages/java/query.d.ts +30 -0
  26. package/dist/core/ingestion/languages/java/query.js +192 -0
  27. package/dist/core/ingestion/languages/java/receiver-binding.d.ts +11 -0
  28. package/dist/core/ingestion/languages/java/receiver-binding.js +95 -0
  29. package/dist/core/ingestion/languages/java/scope-resolver.d.ts +50 -0
  30. package/dist/core/ingestion/languages/java/scope-resolver.js +74 -0
  31. package/dist/core/ingestion/languages/java/simple-hooks.d.ts +13 -0
  32. package/dist/core/ingestion/languages/java/simple-hooks.js +34 -0
  33. package/dist/core/ingestion/languages/java.js +11 -0
  34. package/dist/core/ingestion/parsing-processor.d.ts +22 -2
  35. package/dist/core/ingestion/parsing-processor.js +83 -49
  36. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +5 -0
  37. package/dist/core/ingestion/pipeline-phases/parse-impl.js +119 -11
  38. package/dist/core/ingestion/pipeline-phases/parse.d.ts +14 -0
  39. package/dist/core/ingestion/pipeline.d.ts +13 -0
  40. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +13 -1
  41. package/dist/core/ingestion/scope-resolution/pipeline/registry.js +2 -0
  42. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +17 -0
  43. package/dist/core/ingestion/scope-resolution/pipeline/run.js +20 -4
  44. package/dist/core/lbug/lbug-adapter.d.ts +26 -0
  45. package/dist/core/lbug/lbug-adapter.js +70 -0
  46. package/dist/core/run-analyze.js +318 -28
  47. package/dist/storage/file-hash.d.ts +47 -0
  48. package/dist/storage/file-hash.js +86 -0
  49. package/dist/storage/parse-cache.d.ts +67 -0
  50. package/dist/storage/parse-cache.js +182 -0
  51. package/dist/storage/repo-manager.d.ts +41 -1
  52. package/dist/storage/repo-manager.js +17 -2
  53. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shadow-candidate path derivation for incremental indexing.
3
+ *
4
+ * Background — Bugbot review on PR #1479:
5
+ * queryImporters() on a NEWLY ADDED file returns 0 importers in the
6
+ * pre-pipeline DB, because the new file's IMPORTS rows haven't been
7
+ * written yet. But pre-existing files may have IMPORTS edges that
8
+ * *resolved to a sibling path*, and the newcomer can now steal that
9
+ * resolution under standard JS/TS module-resolution rules. Without
10
+ * pulling those pre-existing files into the writable set, their
11
+ * stale CALLS edges remain pointing at the OLD resolution target.
12
+ *
13
+ * Given an added file path, this helper enumerates the pre-existing
14
+ * file paths whose import-resolution claim the newcomer can steal.
15
+ * Caller filters the candidates against the prior-run `fileHashes`
16
+ * map so we only query importers of paths that actually existed.
17
+ *
18
+ * Shadow patterns covered (resolution-priority-aware):
19
+ *
20
+ * (a) Same basename, different extension —
21
+ * added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`.
22
+ * (b) Bare-file beats directory-style index —
23
+ * added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`.
24
+ * (c) Directory-index beats bare-file —
25
+ * added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real,
26
+ * e.g. converting a single-file module into a directory module).
27
+ *
28
+ * Resolution-order priority is conservatively wide: we enumerate ALL
29
+ * common extensions because we don't know which the importer actually
30
+ * specified, and over-seeding is harmless (extra BFS work, but the
31
+ * subgraph extract still gates write-back by file membership).
32
+ *
33
+ * Cross-platform path separators: candidates are emitted with both `/`
34
+ * and `\` for shadow pattern (b), since the caller's prior fileHashes
35
+ * map may use either depending on the OS that wrote it.
36
+ */
37
+ /**
38
+ * Enumerate pre-existing paths whose import-resolution `added` can steal.
39
+ *
40
+ * @param added — repo-relative path of a newly-added file
41
+ * @returns deduplicated list of candidate paths (NOT filtered against
42
+ * any known-files set — caller does that)
43
+ */
44
+ export declare const shadowCandidatesFor: (added: string) => string[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shadow-candidate path derivation for incremental indexing.
3
+ *
4
+ * Background — Bugbot review on PR #1479:
5
+ * queryImporters() on a NEWLY ADDED file returns 0 importers in the
6
+ * pre-pipeline DB, because the new file's IMPORTS rows haven't been
7
+ * written yet. But pre-existing files may have IMPORTS edges that
8
+ * *resolved to a sibling path*, and the newcomer can now steal that
9
+ * resolution under standard JS/TS module-resolution rules. Without
10
+ * pulling those pre-existing files into the writable set, their
11
+ * stale CALLS edges remain pointing at the OLD resolution target.
12
+ *
13
+ * Given an added file path, this helper enumerates the pre-existing
14
+ * file paths whose import-resolution claim the newcomer can steal.
15
+ * Caller filters the candidates against the prior-run `fileHashes`
16
+ * map so we only query importers of paths that actually existed.
17
+ *
18
+ * Shadow patterns covered (resolution-priority-aware):
19
+ *
20
+ * (a) Same basename, different extension —
21
+ * added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`.
22
+ * (b) Bare-file beats directory-style index —
23
+ * added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`.
24
+ * (c) Directory-index beats bare-file —
25
+ * added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real,
26
+ * e.g. converting a single-file module into a directory module).
27
+ *
28
+ * Resolution-order priority is conservatively wide: we enumerate ALL
29
+ * common extensions because we don't know which the importer actually
30
+ * specified, and over-seeding is harmless (extra BFS work, but the
31
+ * subgraph extract still gates write-back by file membership).
32
+ *
33
+ * Cross-platform path separators: candidates are emitted with both `/`
34
+ * and `\` for shadow pattern (b), since the caller's prior fileHashes
35
+ * map may use either depending on the OS that wrote it.
36
+ */
37
+ const SHADOW_EXTS = ['.d.ts', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
38
+ /**
39
+ * Enumerate pre-existing paths whose import-resolution `added` can steal.
40
+ *
41
+ * @param added — repo-relative path of a newly-added file
42
+ * @returns deduplicated list of candidate paths (NOT filtered against
43
+ * any known-files set — caller does that)
44
+ */
45
+ export const shadowCandidatesFor = (added) => {
46
+ const ext = SHADOW_EXTS.find((e) => added.endsWith(e));
47
+ if (!ext)
48
+ return [];
49
+ const noExt = added.slice(0, -ext.length);
50
+ const out = new Set();
51
+ // (a) Same basename, different extension.
52
+ for (const alt of SHADOW_EXTS) {
53
+ if (alt !== ext)
54
+ out.add(noExt + alt);
55
+ }
56
+ // (b) Bare file beats sibling directory-style index.
57
+ for (const idx of SHADOW_EXTS) {
58
+ out.add(`${noExt}/index${idx}`);
59
+ out.add(`${noExt}\\index${idx}`);
60
+ }
61
+ // (c) New `foo/index.ext` shadows old `foo.ext`.
62
+ const idxSuffixSlash = '/index';
63
+ const idxSuffixBack = '\\index';
64
+ let dir = null;
65
+ if (noExt.endsWith(idxSuffixSlash))
66
+ dir = noExt.slice(0, -idxSuffixSlash.length);
67
+ else if (noExt.endsWith(idxSuffixBack))
68
+ dir = noExt.slice(0, -idxSuffixBack.length);
69
+ if (dir !== null) {
70
+ for (const alt of SHADOW_EXTS)
71
+ out.add(dir + alt);
72
+ }
73
+ return [...out];
74
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Subgraph extraction for incremental DB writeback.
3
+ *
4
+ * Given the FULL ctx.graph produced by the pipeline (all files parsed,
5
+ * all phases run) and the set of file paths whose DB rows must be
6
+ * replaced, produce a smaller KnowledgeGraph that contains:
7
+ *
8
+ * - Every node whose `properties.filePath` is in `toWriteSet`.
9
+ * - Every graph-wide node (Community, Process) — these are regenerated
10
+ * each run by the communities/processes phases and must be fully
11
+ * rewritten.
12
+ * - Every relationship where AT LEAST ONE endpoint is in the writable
13
+ * set above. Relationships entirely between unchanged-file nodes
14
+ * are skipped — their rows are still in the DB and re-inserting
15
+ * them would PK-conflict at COPY time.
16
+ *
17
+ * The resulting subgraph is what gets passed to `loadGraphToLbug` after
18
+ * the orchestrator has deleted the corresponding DB rows. Hydrated
19
+ * unchanged-file rows are never touched in the DB.
20
+ *
21
+ * # Cross-file edge consistency (Finding 1)
22
+ *
23
+ * `extractChangedSubgraph` intentionally does NOT expand the set it is
24
+ * given — expansion is the orchestrator's job, so the SAME expanded set
25
+ * can be fed to both `deleteNodesForFile` and this function (asymmetry
26
+ * between the delete set and the write set silently corrupts the DB).
27
+ * `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop
28
+ * walk; the orchestrator composes it with its importer-BFS expansion and
29
+ * passes the result here.
30
+ *
31
+ * Why the 1-hop walk is needed: consider a barrel re-export change —
32
+ * file C (a barrel) shifts `export { foo } from './b'` to
33
+ * `export { foo } from './d'`. After scope resolution, file A's CALLS
34
+ * edge to `foo` resolves to D instead of B, even though A's content is
35
+ * byte-for-byte identical:
36
+ *
37
+ * - Old A→B edge survives in DB (neither A nor B is changed → not deleted)
38
+ * - New A→D edge is missing (neither A nor D in writable set → skipped)
39
+ *
40
+ * Pulling the unchanged-side file of every writable-boundary-crossing
41
+ * edge into the write set fixes both halves: the orchestrator's
42
+ * `DETACH DELETE` cleans up the stale unchanged-side rows, and the new
43
+ * cross-file edges land because at least one endpoint is now writable.
44
+ *
45
+ * Limitation (documented): if a file X *stopped* importing from a
46
+ * changed file C, X has no edge to C in the new graph, so this 1-hop
47
+ * walk doesn't catch it. The orchestrator's importer-BFS (which reads
48
+ * IMPORTS from the pre-pipeline DB) covers that case instead.
49
+ */
50
+ import type { KnowledgeGraph } from '../graph/types.js';
51
+ export declare const extractChangedSubgraph: (fullGraph: KnowledgeGraph, toWriteSet: ReadonlySet<string>) => KnowledgeGraph;
52
+ /**
53
+ * Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one
54
+ * hop along every edge in the new graph that crosses the writable
55
+ * boundary (one endpoint in a writable file, the other in an unchanged
56
+ * file). The unchanged-side file is pulled in so its stale rows are
57
+ * deleted + rewritten in lockstep with the changed side.
58
+ *
59
+ * Single pass over the edge list. Does NOT mutate `toWriteSet`. The
60
+ * orchestrator MUST feed the returned set to both `deleteNodesForFile`
61
+ * and `extractChangedSubgraph` — feeding the unexpanded set to either
62
+ * one leaves stale rows or PK-conflicts at COPY time.
63
+ */
64
+ export declare const computeEffectiveWriteSet: (fullGraph: KnowledgeGraph, toWriteSet: ReadonlySet<string>) => Set<string>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Subgraph extraction for incremental DB writeback.
3
+ *
4
+ * Given the FULL ctx.graph produced by the pipeline (all files parsed,
5
+ * all phases run) and the set of file paths whose DB rows must be
6
+ * replaced, produce a smaller KnowledgeGraph that contains:
7
+ *
8
+ * - Every node whose `properties.filePath` is in `toWriteSet`.
9
+ * - Every graph-wide node (Community, Process) — these are regenerated
10
+ * each run by the communities/processes phases and must be fully
11
+ * rewritten.
12
+ * - Every relationship where AT LEAST ONE endpoint is in the writable
13
+ * set above. Relationships entirely between unchanged-file nodes
14
+ * are skipped — their rows are still in the DB and re-inserting
15
+ * them would PK-conflict at COPY time.
16
+ *
17
+ * The resulting subgraph is what gets passed to `loadGraphToLbug` after
18
+ * the orchestrator has deleted the corresponding DB rows. Hydrated
19
+ * unchanged-file rows are never touched in the DB.
20
+ *
21
+ * # Cross-file edge consistency (Finding 1)
22
+ *
23
+ * `extractChangedSubgraph` intentionally does NOT expand the set it is
24
+ * given — expansion is the orchestrator's job, so the SAME expanded set
25
+ * can be fed to both `deleteNodesForFile` and this function (asymmetry
26
+ * between the delete set and the write set silently corrupts the DB).
27
+ * `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop
28
+ * walk; the orchestrator composes it with its importer-BFS expansion and
29
+ * passes the result here.
30
+ *
31
+ * Why the 1-hop walk is needed: consider a barrel re-export change —
32
+ * file C (a barrel) shifts `export { foo } from './b'` to
33
+ * `export { foo } from './d'`. After scope resolution, file A's CALLS
34
+ * edge to `foo` resolves to D instead of B, even though A's content is
35
+ * byte-for-byte identical:
36
+ *
37
+ * - Old A→B edge survives in DB (neither A nor B is changed → not deleted)
38
+ * - New A→D edge is missing (neither A nor D in writable set → skipped)
39
+ *
40
+ * Pulling the unchanged-side file of every writable-boundary-crossing
41
+ * edge into the write set fixes both halves: the orchestrator's
42
+ * `DETACH DELETE` cleans up the stale unchanged-side rows, and the new
43
+ * cross-file edges land because at least one endpoint is now writable.
44
+ *
45
+ * Limitation (documented): if a file X *stopped* importing from a
46
+ * changed file C, X has no edge to C in the new graph, so this 1-hop
47
+ * walk doesn't catch it. The orchestrator's importer-BFS (which reads
48
+ * IMPORTS from the pre-pipeline DB) covers that case instead.
49
+ */
50
+ import { createKnowledgeGraph } from '../graph/graph.js';
51
+ const isGraphWide = (label) => label === 'Community' || label === 'Process';
52
+ /**
53
+ * Build a Map<nodeId, filePath> for every File-bound node in the graph.
54
+ * Graph-wide nodes (Community/Process) have no filePath and are filtered.
55
+ */
56
+ const indexNodeFilePaths = (fullGraph) => {
57
+ const idx = new Map();
58
+ fullGraph.forEachNode((n) => {
59
+ const fp = n.properties?.filePath;
60
+ if (fp)
61
+ idx.set(n.id, fp);
62
+ });
63
+ return idx;
64
+ };
65
+ export const extractChangedSubgraph = (fullGraph, toWriteSet) => {
66
+ const sub = createKnowledgeGraph();
67
+ const writableNodeIds = new Set();
68
+ fullGraph.forEachNode((n) => {
69
+ const filePath = n.properties?.filePath;
70
+ const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
71
+ if (include) {
72
+ sub.addNode(n);
73
+ writableNodeIds.add(n.id);
74
+ }
75
+ });
76
+ fullGraph.forEachRelationship((r) => {
77
+ if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
78
+ sub.addRelationship(r);
79
+ }
80
+ });
81
+ return sub;
82
+ };
83
+ /**
84
+ * Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one
85
+ * hop along every edge in the new graph that crosses the writable
86
+ * boundary (one endpoint in a writable file, the other in an unchanged
87
+ * file). The unchanged-side file is pulled in so its stale rows are
88
+ * deleted + rewritten in lockstep with the changed side.
89
+ *
90
+ * Single pass over the edge list. Does NOT mutate `toWriteSet`. The
91
+ * orchestrator MUST feed the returned set to both `deleteNodesForFile`
92
+ * and `extractChangedSubgraph` — feeding the unexpanded set to either
93
+ * one leaves stale rows or PK-conflicts at COPY time.
94
+ */
95
+ export const computeEffectiveWriteSet = (fullGraph, toWriteSet) => {
96
+ const nodeFilePaths = indexNodeFilePaths(fullGraph);
97
+ const expanded = new Set(toWriteSet);
98
+ fullGraph.forEachRelationship((r) => {
99
+ const sourcePath = nodeFilePaths.get(r.sourceId);
100
+ const targetPath = nodeFilePaths.get(r.targetId);
101
+ if (!sourcePath || !targetPath)
102
+ return; // skip edges to graph-wide nodes
103
+ const sourceWritable = toWriteSet.has(sourcePath);
104
+ const targetWritable = toWriteSet.has(targetPath);
105
+ if (sourceWritable && !targetWritable)
106
+ expanded.add(targetPath);
107
+ else if (targetWritable && !sourceWritable)
108
+ expanded.add(sourcePath);
109
+ });
110
+ return expanded;
111
+ };
@@ -30,7 +30,7 @@ import { isRegistryPrimary } from './registry-primary-flag.js';
30
30
  import { isVerboseIngestionEnabled } from './utils/verbose.js';
31
31
  import { yieldToEventLoop } from './utils/event-loop.js';
32
32
  import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
33
- import { FUNCTION_NODE_TYPES, findEnclosingClassId, findEnclosingClassInfo, genericFuncName, inferFunctionLabel, } from './utils/ast-helpers.js';
33
+ import { CLASS_CONTAINER_TYPES, FUNCTION_NODE_TYPES, findEnclosingClassInfo, genericFuncName, inferFunctionLabel, } from './utils/ast-helpers.js';
34
34
  import { typeTagForId, constTagForId, buildCollisionGroups } from './utils/method-props.js';
35
35
  import { countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, extractMixedChain, extractCallArgTypes, } from './utils/call-analysis.js';
36
36
  import { buildTypeEnv, isSubclassOf } from './type-env.js';
@@ -39,6 +39,57 @@ import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
39
39
  import { extractTemplateComponents } from './vue-sfc-extractor.js';
40
40
  import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
41
41
  import { logger } from '../logger.js';
42
+ // ── Property-prepass helpers (parity with parse-worker.ts) ──
43
+ // These mirror the sequential-path equivalents in parse-worker.ts so the main-
44
+ // thread `processCalls` pre-pass produces byte-identical Property nodes/symbols
45
+ // to the worker pool. Drift between the two paths breaks the
46
+ // `incremental ≡ --force` invariant the moment a repo crosses the worker
47
+ // threshold between runs.
48
+ /** Walk up to the nearest enclosing class/struct/interface AST node. */
49
+ const findEnclosingClassNode = (node) => {
50
+ let current = node.parent;
51
+ while (current) {
52
+ if (CLASS_CONTAINER_TYPES.has(current.type))
53
+ return current;
54
+ current = current.parent;
55
+ }
56
+ return null;
57
+ };
58
+ /** No-op SymbolTable stub for FieldExtractorContext — matches parse-worker. */
59
+ const NOOP_SYMBOL_TABLE = {
60
+ lookupExact: () => undefined,
61
+ lookupExactFull: () => undefined,
62
+ lookupExactAll: () => [],
63
+ lookupCallableByName: () => [],
64
+ getFiles: () => [][Symbol.iterator](),
65
+ getStats: () => ({ fileCount: 0 }),
66
+ };
67
+ /**
68
+ * Extract (and cache) field info for a class node. Cache is passed in so it
69
+ * stays scoped to a single `processCalls` invocation rather than leaking
70
+ * across analyze runs (worker uses module-level caching because each worker
71
+ * process is short-lived; the main thread is not).
72
+ *
73
+ * Cache key is `${filePath}:${classNode.startIndex}` — startIndex alone is a
74
+ * per-file byte offset, so almost every Ruby/Python file's leading class lands
75
+ * at byte 0 and would collide across files in the shared map.
76
+ */
77
+ const getFieldInfo = (classNode, provider, context, cache) => {
78
+ if (!provider.fieldExtractor)
79
+ return undefined;
80
+ const cacheKey = `${context.filePath}:${classNode.startIndex}`;
81
+ const cached = cache.get(cacheKey);
82
+ if (cached)
83
+ return cached;
84
+ const result = provider.fieldExtractor.extract(classNode, context);
85
+ if (!result?.fields?.length)
86
+ return undefined;
87
+ const map = new Map();
88
+ for (const field of result.fields)
89
+ map.set(field.name, field);
90
+ cache.set(cacheKey, map);
91
+ return map;
92
+ };
42
93
  /**
43
94
  * Type labels treated as class-like **method-dispatch receivers** by the call
44
95
  * resolver — the set walked by the MRO / heritage path for member and static
@@ -702,6 +753,111 @@ importedRawReturnTypesMap, heritageMap, bindingAccumulator) => {
702
753
  }
703
754
  prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv });
704
755
  }
756
+ // ── Property-registration pre-pass ──
757
+ // Register all routed properties (e.g. Ruby attr_accessor) BEFORE the
758
+ // resolution loop so cross-file field-type lookups (e.g.
759
+ // `user.address.save → Address#save`) succeed regardless of file
760
+ // processing order. This MUST stay in lockstep with the equivalent
761
+ // worker-path block in parse-worker.ts (kind === 'properties') — any
762
+ // divergence between the two paths breaks the `incremental ≡ --force`
763
+ // invariant once a repo crosses the worker threshold between runs.
764
+ const fieldInfoCache = new Map();
765
+ for (const { file, language, provider, matches, typeEnv } of prepared) {
766
+ const callRouter = provider.callRouter;
767
+ if (!callRouter)
768
+ continue;
769
+ matches.forEach((match) => {
770
+ const captureMap = {};
771
+ match.captures.forEach((c) => (captureMap[c.name] = c.node));
772
+ if (!captureMap['call'])
773
+ return;
774
+ const callNameNode = captureMap['call.name'];
775
+ if (!callNameNode)
776
+ return;
777
+ const routed = callRouter(callNameNode.text, captureMap['call']);
778
+ if (!routed || routed.kind !== 'properties')
779
+ return;
780
+ const propEnclosingInfo = findEnclosingClassInfo(captureMap['call'], file.path, provider.resolveEnclosingOwner);
781
+ const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
782
+ // Enrich routed properties with FieldExtractor metadata so types
783
+ // discovered from constructor assignments (e.g. `@address = Address.new`)
784
+ // are propagated even when the routing payload itself lacks declaredType.
785
+ let routedFieldMap;
786
+ if (provider.fieldExtractor && typeEnv) {
787
+ const classNode = findEnclosingClassNode(captureMap['call']);
788
+ if (classNode) {
789
+ routedFieldMap = getFieldInfo(classNode, provider, {
790
+ typeEnv,
791
+ symbolTable: NOOP_SYMBOL_TABLE,
792
+ filePath: file.path,
793
+ language,
794
+ }, fieldInfoCache);
795
+ }
796
+ }
797
+ const fileId = generateId('File', file.path);
798
+ for (const item of routed.items) {
799
+ const routedFieldInfo = routedFieldMap?.get(item.propName);
800
+ const propQualifiedName = propEnclosingInfo
801
+ ? `${propEnclosingInfo.className}.${item.propName}`
802
+ : item.propName;
803
+ const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
804
+ graph.addNode({
805
+ id: nodeId,
806
+ label: 'Property',
807
+ properties: {
808
+ name: item.propName,
809
+ filePath: file.path,
810
+ startLine: item.startLine,
811
+ endLine: item.endLine,
812
+ language,
813
+ isExported: true,
814
+ description: item.accessorType,
815
+ ...(item.declaredType
816
+ ? { declaredType: item.declaredType }
817
+ : routedFieldInfo?.type
818
+ ? { declaredType: routedFieldInfo.type }
819
+ : {}),
820
+ ...(routedFieldInfo?.visibility !== undefined
821
+ ? { visibility: routedFieldInfo.visibility }
822
+ : {}),
823
+ ...(routedFieldInfo?.isStatic !== undefined
824
+ ? { isStatic: routedFieldInfo.isStatic }
825
+ : {}),
826
+ ...(routedFieldInfo?.isReadonly !== undefined
827
+ ? { isReadonly: routedFieldInfo.isReadonly }
828
+ : {}),
829
+ },
830
+ });
831
+ ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
832
+ ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
833
+ ...(item.declaredType
834
+ ? { declaredType: item.declaredType }
835
+ : routedFieldInfo?.type
836
+ ? { declaredType: routedFieldInfo.type }
837
+ : {}),
838
+ });
839
+ const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
840
+ graph.addRelationship({
841
+ id: relId,
842
+ sourceId: fileId,
843
+ targetId: nodeId,
844
+ type: 'DEFINES',
845
+ confidence: 1.0,
846
+ reason: '',
847
+ });
848
+ if (propEnclosingClassId) {
849
+ graph.addRelationship({
850
+ id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
851
+ sourceId: propEnclosingClassId,
852
+ targetId: nodeId,
853
+ type: 'HAS_PROPERTY',
854
+ confidence: 1.0,
855
+ reason: '',
856
+ });
857
+ }
858
+ }
859
+ });
860
+ }
705
861
  // ── Resolution loop: verify constructor bindings and resolve calls ──
706
862
  // The accumulator (if present) is now fully populated from the preparation
707
863
  // loop above, so verifyConstructorBindings sees all provider bindings
@@ -750,9 +906,10 @@ importedRawReturnTypesMap, heritageMap, bindingAccumulator) => {
750
906
  if (receiverTypeName) {
751
907
  const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx, provider);
752
908
  const srcId = enclosing || generateId('File', file.path);
753
- // Defer resolution: Ruby attr_accessor properties are registered during
754
- // this same loop, so cross-file lookups fail if the declaring file hasn't
755
- // been processed yet. Collect now, resolve after all files are done.
909
+ // Defer resolution so write-access tracking sees the FINAL graph
910
+ // state properties from the pre-pass are present, but receiver-type
911
+ // resolution can still depend on inference that completes during the
912
+ // main loop. Resolve after all files have been processed.
756
913
  pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
757
914
  }
758
915
  // Assignment-only capture (no @call sibling): skip the rest of this
@@ -842,47 +999,8 @@ importedRawReturnTypesMap, heritageMap, bindingAccumulator) => {
842
999
  case 'import':
843
1000
  return;
844
1001
  case 'properties': {
845
- const fileId = generateId('File', file.path);
846
- const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
847
- for (const item of routed.items) {
848
- const nodeId = generateId('Property', `${file.path}:${item.propName}`);
849
- graph.addNode({
850
- id: nodeId,
851
- label: 'Property',
852
- properties: {
853
- name: item.propName,
854
- filePath: file.path,
855
- startLine: item.startLine,
856
- endLine: item.endLine,
857
- language,
858
- isExported: true,
859
- description: item.accessorType,
860
- },
861
- });
862
- ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
863
- ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
864
- ...(item.declaredType ? { declaredType: item.declaredType } : {}),
865
- });
866
- const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
867
- graph.addRelationship({
868
- id: relId,
869
- sourceId: fileId,
870
- targetId: nodeId,
871
- type: 'DEFINES',
872
- confidence: 1.0,
873
- reason: '',
874
- });
875
- if (propEnclosingClassId) {
876
- graph.addRelationship({
877
- id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
878
- sourceId: propEnclosingClassId,
879
- targetId: nodeId,
880
- type: 'HAS_PROPERTY',
881
- confidence: 1.0,
882
- reason: '',
883
- });
884
- }
885
- }
1002
+ // Properties already registered in the pre-pass above.
1003
+ // Skip to avoid duplicate nodes/edges.
886
1004
  return;
887
1005
  }
888
1006
  case 'call':
@@ -20,6 +20,23 @@ const __dirname = dirname(__filename);
20
20
  const leidenPath = resolve(__dirname, '..', '..', '..', 'vendor', 'leiden', 'index.cjs');
21
21
  const _require = createRequire(import.meta.url);
22
22
  const leiden = _require(leidenPath);
23
+ /**
24
+ * Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm.
25
+ * Vendored Leiden defaults `rng: Math.random`, which makes community
26
+ * assignment non-deterministic across runs. Passing a seeded RNG gives us
27
+ * reproducible community/modularity output, which is required for the
28
+ * incremental-indexing equivalence test (incremental ≡ full rebuild).
29
+ */
30
+ const LEIDEN_SEED = 0xc0de;
31
+ function createSeededRng(seed) {
32
+ let s = seed >>> 0;
33
+ return () => {
34
+ s = (s + 0x6d2b79f5) >>> 0;
35
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
36
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
38
+ };
39
+ }
23
40
  // ============================================================================
24
41
  // COMMUNITY COLORS (for visualization)
25
42
  // ============================================================================
@@ -83,6 +100,7 @@ export const processCommunities = async (knowledgeGraph, onProgress) => {
83
100
  Promise.resolve(leiden.detailed(graph, {
84
101
  resolution: isLarge ? 2.0 : 1.0,
85
102
  maxIterations: isLarge ? 3 : 0,
103
+ rng: createSeededRng(LEIDEN_SEED),
86
104
  })),
87
105
  new Promise((_, reject) => setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS)),
88
106
  ]);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Extract Java arity metadata from a method-like tree-sitter node —
3
+ * `method_declaration` or `constructor_declaration`.
4
+ *
5
+ * Reuses `javaMethodConfig.extractParameters` so scope-extracted defs
6
+ * carry the same arity semantics as the legacy parse-worker path:
7
+ * - varargs (`...`) collapses `parameterCount` to `undefined`
8
+ * - `parameterTypes` collects declared type names; a literal
9
+ * `'varargs'` marker is appended for variadic methods so
10
+ * `javaArityCompatibility` can detect them.
11
+ */
12
+ import type { SyntaxNode } from '../../utils/ast-helpers.js';
13
+ export interface JavaArityMetadata {
14
+ readonly parameterCount: number | undefined;
15
+ readonly requiredParameterCount: number | undefined;
16
+ readonly parameterTypes: readonly string[] | undefined;
17
+ }
18
+ export declare function computeJavaArityMetadata(fnNode: SyntaxNode): JavaArityMetadata;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Extract Java arity metadata from a method-like tree-sitter node —
3
+ * `method_declaration` or `constructor_declaration`.
4
+ *
5
+ * Reuses `javaMethodConfig.extractParameters` so scope-extracted defs
6
+ * carry the same arity semantics as the legacy parse-worker path:
7
+ * - varargs (`...`) collapses `parameterCount` to `undefined`
8
+ * - `parameterTypes` collects declared type names; a literal
9
+ * `'varargs'` marker is appended for variadic methods so
10
+ * `javaArityCompatibility` can detect them.
11
+ */
12
+ import { javaMethodConfig } from '../../method-extractors/configs/jvm.js';
13
+ export function computeJavaArityMetadata(fnNode) {
14
+ const params = javaMethodConfig.extractParameters?.(fnNode) ?? [];
15
+ let hasVariadic = false;
16
+ const types = [];
17
+ for (const p of params) {
18
+ if (p.isVariadic)
19
+ hasVariadic = true;
20
+ if (p.type !== null)
21
+ types.push(p.type);
22
+ }
23
+ if (hasVariadic)
24
+ types.push('varargs');
25
+ const total = params.length;
26
+ // For varargs methods, `parameterCount` (max) is unknown — any number of
27
+ // trailing arguments is valid. But the fixed-prefix parameters (everything
28
+ // before the variadic `...` param) are still required, so we preserve that
29
+ // count in `requiredParameterCount` so `javaArityCompatibility` can reject
30
+ // calls that undersupply the fixed prefix (e.g. `f(int x, String... args)`
31
+ // called with 0 args).
32
+ const fixedCount = params.filter((p) => !p.isVariadic).length;
33
+ const parameterCount = hasVariadic ? undefined : total;
34
+ const requiredParameterCount = hasVariadic ? fixedCount : total;
35
+ return {
36
+ parameterCount,
37
+ requiredParameterCount,
38
+ parameterTypes: types.length > 0 ? types : undefined,
39
+ };
40
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Java arity check, accommodating varargs (`...`).
3
+ *
4
+ * Verdicts:
5
+ * - `'compatible'` — argCount matches parameterCount, OR varargs present.
6
+ * - `'incompatible'` — argCount mismatches with no varargs.
7
+ * - `'unknown'` — metadata absent / incomplete.
8
+ */
9
+ import type { Callsite, SymbolDefinition } from '../../../../_shared/index.js';
10
+ export declare function javaArityCompatibility(def: SymbolDefinition, callsite: Callsite): 'compatible' | 'unknown' | 'incompatible';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Java arity check, accommodating varargs (`...`).
3
+ *
4
+ * Verdicts:
5
+ * - `'compatible'` — argCount matches parameterCount, OR varargs present.
6
+ * - `'incompatible'` — argCount mismatches with no varargs.
7
+ * - `'unknown'` — metadata absent / incomplete.
8
+ */
9
+ export function javaArityCompatibility(def, callsite) {
10
+ const max = def.parameterCount;
11
+ const min = def.requiredParameterCount;
12
+ if (max === undefined && min === undefined)
13
+ return 'unknown';
14
+ const argCount = callsite.arity;
15
+ if (!Number.isFinite(argCount) || argCount < 0)
16
+ return 'unknown';
17
+ const hasVarArgs = def.parameterTypes !== undefined &&
18
+ def.parameterTypes.some((t) => t === 'varargs' || t.includes('...'));
19
+ if (min !== undefined && argCount < min)
20
+ return 'incompatible';
21
+ if (max !== undefined && argCount > max && !hasVarArgs)
22
+ return 'incompatible';
23
+ return 'compatible';
24
+ }