gitnexus 1.6.12-rc.25 → 1.6.12-rc.27

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.
@@ -6,8 +6,8 @@
6
6
  * does its own extraction, and writes directly to the graph.
7
7
  *
8
8
  * Pipeline:
9
- * 1. Separate programs from copybooks
10
- * 2. Build copybook map (name -> content)
9
+ * 1. Separate programs from copybooks and JCL
10
+ * 2. Index file content by path for COPY expansion
11
11
  * 3. For each program: expand COPY statements, then run regex extraction
12
12
  * 4. Map CobolRegexResults to graph nodes and relationships
13
13
  * 5. Optionally process JCL files for job-step cross-references
@@ -6,8 +6,8 @@
6
6
  * does its own extraction, and writes directly to the graph.
7
7
  *
8
8
  * Pipeline:
9
- * 1. Separate programs from copybooks
10
- * 2. Build copybook map (name -> content)
9
+ * 1. Separate programs from copybooks and JCL
10
+ * 2. Index file content by path for COPY expansion
11
11
  * 3. For each program: expand COPY statements, then run regex extraction
12
12
  * 4. Map CobolRegexResults to graph nodes and relationships
13
13
  * 5. Optionally process JCL files for job-step cross-references
@@ -19,6 +19,7 @@ import { SupportedLanguages } from '../../_shared/index.js';
19
19
  import { preprocessCobolSource, extractCobolSymbolsWithRegex, } from './cobol/cobol-preprocessor.js';
20
20
  import { expandCopies } from './cobol/cobol-copy-expander.js';
21
21
  import { processJclFiles } from './cobol/jcl-processor.js';
22
+ import { resolveCobolCopyTarget } from './languages/cobol/copy-target.js';
22
23
  import { logger } from '../logger.js';
23
24
  // ---------------------------------------------------------------------------
24
25
  // File detection
@@ -74,36 +75,22 @@ export const processCobol = (graph, files, allPathSet) => {
74
75
  };
75
76
  // ── 1. Separate programs, copybooks, and JCL ───────────────────────
76
77
  const programs = [];
77
- const copybooks = [];
78
78
  const jclFiles = [];
79
79
  for (const file of files) {
80
80
  const ext = path.extname(file.path).toLowerCase();
81
81
  if (JCL_EXTENSIONS.has(ext)) {
82
82
  jclFiles.push(file);
83
83
  }
84
- else if (isCopybook(file.path)) {
85
- copybooks.push(file);
86
- }
87
- else if (COBOL_EXTENSIONS.has(ext)) {
84
+ else if (COBOL_EXTENSIONS.has(ext) && !isCopybook(file.path)) {
88
85
  programs.push(file);
89
86
  }
90
87
  }
91
- // ── 2. Build copybook map (uppercase name -> content) ──────────────
92
- const copybookMap = new Map();
93
- for (const cb of copybooks) {
94
- const name = path.basename(cb.path, path.extname(cb.path)).toUpperCase();
95
- copybookMap.set(name, { content: cb.content, path: cb.path });
96
- }
97
- // Build reverse lookup: path -> content for O(1) readCopy
88
+ // Path → content for every COBOL/JCL file we ingested. Resolution picks
89
+ // the path (#2967 / census lockstep); this map only supplies the body.
98
90
  const copybookByPath = new Map();
99
- for (const [, entry] of copybookMap) {
100
- copybookByPath.set(entry.path, entry.content);
91
+ for (const f of files) {
92
+ copybookByPath.set(f.path, f.content);
101
93
  }
102
- // Resolve and read callbacks for expandCopies
103
- const resolveCopy = (name) => {
104
- const entry = copybookMap.get(name.toUpperCase());
105
- return entry ? entry.path : null;
106
- };
107
94
  // Memoize preprocessed copybook content for the duration of this
108
95
  // processCobol call. A single copybook is COPYed by many programs (and at
109
96
  // many COPY sites within a program); without this cache
@@ -142,6 +129,9 @@ export const processCobol = (graph, files, allPathSet) => {
142
129
  continue;
143
130
  // Preprocess: clean patch markers
144
131
  const cleaned = preprocessCobolSource(file.content);
132
+ // Per-program so COPY EXTERNAL in src/PROG.cbl cannot land on
133
+ // vendor/EXTERNAL.cpy when a copybooks/ dir is present (#2967).
134
+ const resolveCopy = (name) => resolveCobolCopyTarget(name, file.path, allPathSet);
145
135
  // Expand COPY statements
146
136
  const { expandedContent, copyResolutions } = expandCopies(cleaned, file.path, resolveCopy, readCopy);
147
137
  // Extract symbols from expanded source
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Resolve a COBOL `COPY` member name against the workspace file set.
3
+ *
4
+ * `fromFile` is the importing program (census) or the program currently
5
+ * being expanded (processor). It is used only to include the importer's
6
+ * directory in the preferred class when a well-known copybook dir exists.
7
+ */
8
+ export declare function resolveCobolCopyTarget(targetRaw: string, fromFile: string, allFilePaths: ReadonlySet<string>): string | null;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared COBOL `COPY` target resolution (#2967 / #2908).
3
+ *
4
+ * Both the census (`cobolScopeResolver.resolveImportTarget`) and the live
5
+ * regex processor (`resolveCopy` → `cobol-copy` IMPORTS) must answer the
6
+ * same path for the same `(name, fromFile, allFilePaths)`. Greening only
7
+ * the census would leave analyze fabricating an IMPORTS edge onto a vendor
8
+ * decoy the compiler would never have searched.
9
+ *
10
+ * Well-known copybook directory segments (`copybooks`, `COPYBOOKS`, `cpy`,
11
+ * `copy`) plus the importer's own directory are the preferred class. When
12
+ * the file set contains at least one of those well-known segments, a `COPY`
13
+ * name resolves only inside that class (first in Set-iteration order).
14
+ * `vendor/EXTERNAL.cpy` therefore misses while `copybooks/CUSTREC.cpy` hits.
15
+ *
16
+ * When the file set has no such directory, fail-open to today's two-tier
17
+ * first-wins basename index — shops whose copybooks *are* the tree have
18
+ * nothing to prefer. The importer directory is *not* enough on its own to
19
+ * leave fail-open (`{vendor/EXTERNAL.cpy, src/PROG.cbl}` must still answer
20
+ * `vendor/EXTERNAL.cpy`).
21
+ *
22
+ * The two-tier index is still memoized on the `allFilePaths` Set identity
23
+ * (#2908). Preferred-class filtering happens at pick time from the stored
24
+ * per-name arrays, so a pass still scans the set once.
25
+ */
26
+ import path from 'node:path';
27
+ import { perFileSet } from '../../import-resolvers/per-file-set.js';
28
+ const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
29
+ const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']);
30
+ /** Path-component names that mark a conventional copybook directory. */
31
+ const PREFERRED_DIR_NAMES = new Set(['copybooks', 'COPYBOOKS', 'cpy', 'copy']);
32
+ function pathHasPreferredDir(fp) {
33
+ const parts = fp.split('/');
34
+ for (let i = 0; i < parts.length - 1; i++) {
35
+ if (PREFERRED_DIR_NAMES.has(parts[i]))
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+ function pushUnique(map, key, fp) {
41
+ const list = map.get(key);
42
+ if (list === undefined) {
43
+ map.set(key, [fp]);
44
+ return;
45
+ }
46
+ list.push(fp);
47
+ }
48
+ const getCobolCopyIndex = perFileSet((allFilePaths) => {
49
+ const copybooks = new Map();
50
+ const sources = new Map();
51
+ let hasPreferredDir = false;
52
+ for (const fp of allFilePaths) {
53
+ const extRaw = path.extname(fp);
54
+ const extLower = extRaw.toLowerCase();
55
+ const tier = COPYBOOK_EXTENSIONS.has(extLower)
56
+ ? copybooks
57
+ : COBOL_SOURCE_EXTENSIONS.has(extLower)
58
+ ? sources
59
+ : undefined;
60
+ if (tier === undefined)
61
+ continue;
62
+ if (!hasPreferredDir && pathHasPreferredDir(fp))
63
+ hasPreferredDir = true;
64
+ const basename = path.basename(fp, extRaw).toUpperCase();
65
+ pushUnique(tier, basename, fp);
66
+ }
67
+ return { copybooks, sources, hasPreferredDir };
68
+ });
69
+ function pickCopyPath(paths, fromFile, hasPreferredDir) {
70
+ if (paths === undefined || paths.length === 0)
71
+ return null;
72
+ if (!hasPreferredDir)
73
+ return paths[0] ?? null;
74
+ const fromDir = path.dirname(fromFile);
75
+ for (const fp of paths) {
76
+ if (pathHasPreferredDir(fp) || path.dirname(fp) === fromDir)
77
+ return fp;
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Resolve a COBOL `COPY` member name against the workspace file set.
83
+ *
84
+ * `fromFile` is the importing program (census) or the program currently
85
+ * being expanded (processor). It is used only to include the importer's
86
+ * directory in the preferred class when a well-known copybook dir exists.
87
+ */
88
+ export function resolveCobolCopyTarget(targetRaw, fromFile, allFilePaths) {
89
+ const upper = targetRaw.toUpperCase();
90
+ const index = getCobolCopyIndex(allFilePaths);
91
+ return (pickCopyPath(index.copybooks.get(upper), fromFile, index.hasPreferredDir) ??
92
+ pickCopyPath(index.sources.get(upper), fromFile, index.hasPreferredDir) ??
93
+ null);
94
+ }
@@ -8,46 +8,18 @@
8
8
  *
9
9
  * Reference: `languages/python/scope-resolver.ts`.
10
10
  */
11
- import path from 'node:path';
12
11
  import { SupportedLanguages } from '../../../../_shared/index.js';
13
- import { perFileSet } from '../../import-resolvers/per-file-set.js';
14
12
  import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
15
13
  import { cobolProvider } from '../cobol.js';
16
- // Copybook file extensions for COPY name resolution
17
- const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
18
- // COBOL source files, searched only after every copybook has missed.
19
- const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']);
20
- const getCobolCopyIndex = perFileSet((allFilePaths) => {
21
- const copybooks = new Map();
22
- const sources = new Map();
23
- // One pass builds both tiers: the two scans walked the same files and
24
- // classified each by the same extension test.
25
- for (const fp of allFilePaths) {
26
- const ext = path.extname(fp).toLowerCase();
27
- const tier = COPYBOOK_EXTENSIONS.has(ext)
28
- ? copybooks
29
- : COBOL_SOURCE_EXTENSIONS.has(ext)
30
- ? sources
31
- : undefined;
32
- if (tier === undefined)
33
- continue;
34
- const basename = path.basename(fp, ext).toUpperCase();
35
- // First in Set-iteration order wins, as the scans' first-match `return` did.
36
- if (!tier.has(basename))
37
- tier.set(basename, fp);
38
- }
39
- return { copybooks, sources };
40
- });
14
+ import { resolveCobolCopyTarget } from './copy-target.js';
41
15
  const cobolScopeResolver = {
42
16
  language: SupportedLanguages.Cobol,
43
17
  languageProvider: cobolProvider,
44
18
  importEdgeReason: 'cobol-scope: copy',
45
19
  // ── Resolve COPY bookname to file path ─────────────────────────────
46
- resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => {
47
- const upper = targetRaw.toUpperCase();
48
- const index = getCobolCopyIndex(allFilePaths);
49
- // Copybooks first, then COBOL sources — the tier order IS the tie-break.
50
- return index.copybooks.get(upper) ?? index.sources.get(upper) ?? null;
20
+ // Shared with the regex processor's resolveCopy (#2967 lockstep).
21
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
22
+ return resolveCobolCopyTarget(targetRaw, fromFile, allFilePaths);
51
23
  },
52
24
  // COBOL has no binding-merge rules beyond the default (local-first-then-imports).
53
25
  mergeBindings: (existing) => [...existing],
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Ruby import resolution rules:
5
5
  * - `require_relative './foo'` → resolve relative to the importing file's dir
6
- * - `require 'foo'` → suffix-match via the existing Ruby import resolver
7
- * - External gems → null (unresolvable within the repo)
6
+ * - `require 'foo'` → use scoped gem metadata before legacy suffix matching
7
+ * - Known local gems → resolve only within their declared load roots
8
+ * - Known external gems → null, even if an unrelated repo file suffix matches
8
9
  */
9
10
  export interface RubyResolveContext {
10
11
  readonly fromFile: string;
@@ -18,9 +19,10 @@ export interface RubyResolveContext {
18
19
  * against the importing file's directory, trying `.rb` and `/index.rb`
19
20
  * suffixes.
20
21
  *
21
- * For bare requires (gem-style like `'json'`, `'serializable'`), delegates
22
- * to the existing `resolveRubyImportInternal` which uses suffix matching.
23
- *
24
- * Returns `null` for external gems that have no matching file in the repo.
22
+ * For bare requires, scoped manifest metadata takes precedence: known local
23
+ * gems resolve only within their declared load roots (a miss returns `null`),
24
+ * and known external gem prefixes return `null` even if a repo suffix matches.
25
+ * Only requires without matching gem evidence delegate to the existing
26
+ * `resolveRubyImportInternal` suffix matcher.
25
27
  */
26
- export declare function resolveRubyImportTarget(targetRaw: string, fromFile: string, allFilePaths: ReadonlySet<string>, _resolutionConfig?: unknown): string | readonly string[] | null;
28
+ export declare function resolveRubyImportTarget(targetRaw: string, fromFile: string, allFilePaths: ReadonlySet<string>, resolutionConfig?: unknown): string | readonly string[] | null;
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Ruby import resolution rules:
5
5
  * - `require_relative './foo'` → resolve relative to the importing file's dir
6
- * - `require 'foo'` → suffix-match via the existing Ruby import resolver
7
- * - External gems → null (unresolvable within the repo)
6
+ * - `require 'foo'` → use scoped gem metadata before legacy suffix matching
7
+ * - Known local gems → resolve only within their declared load roots
8
+ * - Known external gems → null, even if an unrelated repo file suffix matches
8
9
  */
9
10
  import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
10
11
  import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
11
12
  import { isHeritageMarker } from '../../utils/heritage-marker.js';
13
+ import { findRubyResolutionScope, } from './resolution-config.js';
12
14
  // ─── resolveRubyImportTarget ──────────────────────────────────────────────
13
15
  /**
14
16
  * ScopeResolver-shaped adapter:
@@ -18,12 +20,13 @@ import { isHeritageMarker } from '../../utils/heritage-marker.js';
18
20
  * against the importing file's directory, trying `.rb` and `/index.rb`
19
21
  * suffixes.
20
22
  *
21
- * For bare requires (gem-style like `'json'`, `'serializable'`), delegates
22
- * to the existing `resolveRubyImportInternal` which uses suffix matching.
23
- *
24
- * Returns `null` for external gems that have no matching file in the repo.
23
+ * For bare requires, scoped manifest metadata takes precedence: known local
24
+ * gems resolve only within their declared load roots (a miss returns `null`),
25
+ * and known external gem prefixes return `null` even if a repo suffix matches.
26
+ * Only requires without matching gem evidence delegate to the existing
27
+ * `resolveRubyImportInternal` suffix matcher.
25
28
  */
26
- export function resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, _resolutionConfig) {
29
+ export function resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig) {
27
30
  if (!targetRaw)
28
31
  return null;
29
32
  if (isHeritageMarker(targetRaw))
@@ -37,7 +40,16 @@ export function resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, _reso
37
40
  const resolved = resolveRelative(targetRaw, fromDir, allFilePaths);
38
41
  return resolved;
39
42
  }
40
- // ── require: bare/gem-style suffix matching ─────────────────────────
43
+ // ── require: scoped gem evidence before repository-wide fallback ────
44
+ const config = resolutionConfig;
45
+ const scope = config === null || config === undefined ? undefined : findRubyResolutionScope(config, fromFile);
46
+ if (scope !== undefined) {
47
+ const localGemTarget = resolveLocalGemTarget(targetRaw, scope, allFilePaths);
48
+ if (localGemTarget !== undefined)
49
+ return localGemTarget;
50
+ if (matchesRequirePrefix(targetRaw, scope.externalRequirePrefixes))
51
+ return null;
52
+ }
41
53
  return resolveBare(targetRaw, allFilePaths);
42
54
  }
43
55
  // ─── internal helpers ─────────────────────────────────────────────────────
@@ -73,6 +85,55 @@ function resolveRelative(targetRaw, fromDir, allFilePaths) {
73
85
  return resolvedPath;
74
86
  return null;
75
87
  }
88
+ /**
89
+ * Yield the require stem, then each slash-delimited ancestor. This makes both
90
+ * local-root and external-gem lookup O(require path depth), not O(gem count).
91
+ * A trailing `.rb` is stripped first so `require 'my_engine.rb'` matches the
92
+ * configured prefix `my_engine`, matching Ruby's optional-suffix require.
93
+ */
94
+ function requirePrefixCandidates(targetRaw) {
95
+ const stem = targetRaw.endsWith('.rb') ? targetRaw.slice(0, -3) : targetRaw;
96
+ if (stem.length === 0)
97
+ return [];
98
+ const candidates = [stem];
99
+ let slash = stem.lastIndexOf('/');
100
+ while (slash !== -1) {
101
+ candidates.push(stem.slice(0, slash));
102
+ slash = stem.lastIndexOf('/', slash - 1);
103
+ }
104
+ return candidates;
105
+ }
106
+ function matchesRequirePrefix(targetRaw, prefixes) {
107
+ return requirePrefixCandidates(targetRaw).some((candidate) => prefixes.has(candidate));
108
+ }
109
+ /**
110
+ * Resolve a path/gemspec-backed gem against its declared Ruby load roots.
111
+ * `undefined` means no local-gem prefix matched; `null` means one matched but
112
+ * none of its declared load roots contained the target.
113
+ */
114
+ function resolveLocalGemTarget(targetRaw, scope, allFilePaths) {
115
+ for (const prefix of requirePrefixCandidates(targetRaw)) {
116
+ const loadRoots = scope.localLoadRootsByPrefix.get(prefix);
117
+ if (loadRoots === undefined)
118
+ continue;
119
+ for (const loadRoot of loadRoots) {
120
+ const base = loadRoot ? `${loadRoot}/${targetRaw}` : targetRaw;
121
+ if (base.endsWith('.rb')) {
122
+ if (allFilePaths.has(base))
123
+ return base;
124
+ continue;
125
+ }
126
+ const rbFile = `${base}.rb`;
127
+ if (allFilePaths.has(rbFile))
128
+ return rbFile;
129
+ }
130
+ // The prefix is owned by a known local gem. If its declared roots do not
131
+ // contain the target, repository-wide suffix matching would fabricate an
132
+ // edge to an unrelated file.
133
+ return null;
134
+ }
135
+ return undefined;
136
+ }
76
137
  /**
77
138
  * Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`)
78
139
  * via suffix matching using the existing Ruby import resolver.
@@ -0,0 +1,28 @@
1
+ export interface RubyResolutionScope {
2
+ /** Require prefixes provided by gems whose source is outside this repository. */
3
+ readonly externalRequirePrefixes: ReadonlySet<string>;
4
+ /** Require prefix -> repository-relative Ruby load roots for local gems. */
5
+ readonly localLoadRootsByPrefix: ReadonlyMap<string, readonly string[]>;
6
+ }
7
+ export interface RubyResolutionConfig {
8
+ /** Manifest directory (repository-relative POSIX path) -> dependency scope. */
9
+ readonly scopesByDirectory: ReadonlyMap<string, RubyResolutionScope>;
10
+ }
11
+ /**
12
+ * Select the nearest manifest directory that owns `fromFile`.
13
+ *
14
+ * Walking ancestors makes lookup O(path depth), independent of how many
15
+ * sibling Gemfiles a monorepo contains.
16
+ */
17
+ export declare function findRubyResolutionScope(config: RubyResolutionConfig, fromFile: string): RubyResolutionScope | undefined;
18
+ /**
19
+ * Statically collect Ruby dependency sources without evaluating Gemfile or
20
+ * gemspec code. Scopes stay separate by manifest directory so sibling projects
21
+ * in a monorepo cannot suppress one another's local imports. Lockfiles
22
+ * contribute remote GEM/GIT specs and local PATH/GEMSPEC load roots only when
23
+ * an adjacent Gemfile or gemspec establishes a Bundler/RubyGems project.
24
+ *
25
+ * No declarative manifest means no safe gate, so return null and preserve the
26
+ * resolver's existing fail-open behavior for loose script directories.
27
+ */
28
+ export declare function loadRubyResolutionConfig(repoPath: string): RubyResolutionConfig | null;
@@ -0,0 +1,311 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, relative, resolve } from 'node:path';
3
+ const MAX_VISITED_DIRECTORIES = 20_000;
4
+ const SKIP_DIRECTORIES = new Set([
5
+ '.git',
6
+ '.gitnexus',
7
+ 'node_modules',
8
+ 'vendor',
9
+ 'dist',
10
+ 'build',
11
+ 'coverage',
12
+ ]);
13
+ const GEMFILE_DEPENDENCY = /^\s*gem\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1(?<options>.*)$/;
14
+ const GEMSPEC_DEPENDENCY = /^\s*(?:[A-Za-z_]\w*\.)?(?:add_dependency|add_runtime_dependency|add_development_dependency)\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1/;
15
+ const GEMSPEC_NAME = /^\s*(?:[A-Za-z_]\w*\.)?name\s*=\s*(['"])([A-Za-z0-9_.-]+)\1/;
16
+ const GEMSPEC_REQUIRE_PATHS = /^\s*(?:[A-Za-z_]\w*\.)?require_paths\s*=\s*\[([^\]]*)\]/;
17
+ const QUOTED_LITERAL = /(['"])([^'"]+)\1/g;
18
+ const LOCKFILE_SECTION = /^([A-Z][A-Z ]*)\s*$/;
19
+ const LOCKFILE_REMOTE = /^ {2}remote:\s*(.+?)\s*$/;
20
+ const LOCKFILE_SPECS_HEADER = /^ {2}specs:\s*$/;
21
+ const LOCKFILE_SPEC = /^ {4}([A-Za-z0-9_.-]+) \(/;
22
+ function createMutableScope() {
23
+ return {
24
+ externalRequirePrefixes: new Set(),
25
+ localLoadRootsByPrefix: new Map(),
26
+ };
27
+ }
28
+ function normalizeRepoPath(filePath) {
29
+ return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
30
+ }
31
+ function repoRelativePath(repoPath, candidate) {
32
+ const rel = relative(resolve(repoPath), resolve(candidate));
33
+ if (rel === '')
34
+ return '';
35
+ if (rel === '..' || rel.startsWith(`..\\`) || rel.startsWith('../') || isAbsolute(rel)) {
36
+ return null;
37
+ }
38
+ return normalizeRepoPath(rel);
39
+ }
40
+ function requirePrefixes(gemName, requireAs) {
41
+ const prefixes = new Set([gemName]);
42
+ if (gemName.includes('-'))
43
+ prefixes.add(gemName.replaceAll('-', '/'));
44
+ if (requireAs !== undefined && requireAs.length > 0)
45
+ prefixes.add(requireAs);
46
+ return [...prefixes];
47
+ }
48
+ function literalOption(options, name) {
49
+ const modern = new RegExp(`(?:^|[,\\s])${name}\\s*:\\s*(['"])([^'"]+)\\1`).exec(options);
50
+ if (modern !== null)
51
+ return modern[2];
52
+ const hashRocket = new RegExp(`(?:^|[,\\s]):${name}\\s*=>\\s*(['"])([^'"]+)\\1`).exec(options);
53
+ return hashRocket?.[2];
54
+ }
55
+ function hasOption(options, name) {
56
+ return (new RegExp(`(?:^|[,\\s])${name}\\s*:`).test(options) ||
57
+ new RegExp(`(?:^|[,\\s]):${name}\\s*=>`).test(options));
58
+ }
59
+ function addExternalGem(scope, gemName, requireAs) {
60
+ for (const prefix of requirePrefixes(gemName, requireAs)) {
61
+ if (!scope.localLoadRootsByPrefix.has(prefix))
62
+ scope.externalRequirePrefixes.add(prefix);
63
+ }
64
+ }
65
+ function addLocalGem(scope, gemName, loadRoots, requireAs) {
66
+ for (const prefix of requirePrefixes(gemName, requireAs)) {
67
+ scope.externalRequirePrefixes.delete(prefix);
68
+ let roots = scope.localLoadRootsByPrefix.get(prefix);
69
+ if (roots === undefined) {
70
+ roots = new Set();
71
+ scope.localLoadRootsByPrefix.set(prefix, roots);
72
+ }
73
+ for (const loadRoot of loadRoots)
74
+ roots.add(loadRoot);
75
+ }
76
+ }
77
+ function parseGemspecMetadata(contents) {
78
+ let name = null;
79
+ let requirePaths = null;
80
+ for (const line of contents.split(/\r?\n/)) {
81
+ const nameMatch = GEMSPEC_NAME.exec(line);
82
+ if (nameMatch !== null)
83
+ name = nameMatch[2];
84
+ const requirePathsMatch = GEMSPEC_REQUIRE_PATHS.exec(line);
85
+ if (requirePathsMatch === null)
86
+ continue;
87
+ const paths = [];
88
+ for (const match of requirePathsMatch[1].matchAll(QUOTED_LITERAL))
89
+ paths.push(match[2]);
90
+ if (paths.length > 0)
91
+ requirePaths = paths;
92
+ }
93
+ return { name, requirePaths: requirePaths ?? ['lib'] };
94
+ }
95
+ function loadLocalGemRoots(repoPath, gemRoot, expectedGemName) {
96
+ const gemRootRelative = repoRelativePath(repoPath, gemRoot);
97
+ if (gemRootRelative === null)
98
+ return null;
99
+ let requirePaths = ['lib'];
100
+ try {
101
+ const gemspecs = readdirSync(gemRoot, { withFileTypes: true })
102
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.gemspec'))
103
+ .map((entry) => join(gemRoot, entry.name))
104
+ .sort();
105
+ for (const gemspec of gemspecs) {
106
+ const metadata = parseGemspecMetadata(readFileSync(gemspec, 'utf8'));
107
+ if (metadata.name === expectedGemName) {
108
+ requirePaths = metadata.requirePaths;
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ catch {
114
+ // An unreadable local gem stays local; the conventional lib root is a
115
+ // bounded best effort, and resolution falls through if no file matches.
116
+ }
117
+ return requirePaths
118
+ .map((requirePath) => repoRelativePath(repoPath, resolve(gemRoot, requirePath)))
119
+ .filter((loadRoot) => loadRoot !== null);
120
+ }
121
+ function addGemfileDependencies(contents, manifestDirectory, repoPath, scope) {
122
+ for (const line of contents.split(/\r?\n/)) {
123
+ const match = GEMFILE_DEPENDENCY.exec(line);
124
+ if (match === null)
125
+ continue;
126
+ const gemName = match[2];
127
+ const options = match.groups?.options ?? '';
128
+ const requireAs = literalOption(options, 'require');
129
+ const localPath = literalOption(options, 'path');
130
+ if (localPath !== undefined) {
131
+ const loadRoots = loadLocalGemRoots(repoPath, resolve(manifestDirectory, localPath), gemName);
132
+ if (loadRoots === null)
133
+ addExternalGem(scope, gemName, requireAs);
134
+ else
135
+ addLocalGem(scope, gemName, loadRoots, requireAs);
136
+ continue;
137
+ }
138
+ // A dynamic path expression cannot be classified without evaluating Ruby.
139
+ // Fail open instead of deleting a potentially real in-repo import edge.
140
+ if (hasOption(options, 'path'))
141
+ continue;
142
+ addExternalGem(scope, gemName, requireAs);
143
+ }
144
+ }
145
+ function addGemspecDependencies(contents, gemspecDirectory, repoPath, scope) {
146
+ const metadata = parseGemspecMetadata(contents);
147
+ if (metadata.name !== null) {
148
+ if (repoRelativePath(repoPath, gemspecDirectory) !== null) {
149
+ const loadRoots = metadata.requirePaths
150
+ .map((requirePath) => repoRelativePath(repoPath, resolve(gemspecDirectory, requirePath)))
151
+ .filter((loadRoot) => loadRoot !== null);
152
+ addLocalGem(scope, metadata.name, loadRoots);
153
+ }
154
+ }
155
+ for (const line of contents.split(/\r?\n/)) {
156
+ const match = GEMSPEC_DEPENDENCY.exec(line);
157
+ if (match !== null)
158
+ addExternalGem(scope, match[2]);
159
+ }
160
+ }
161
+ function addLockedDependencies(contents, lockfileDirectory, repoPath, scope) {
162
+ let section = '';
163
+ let remote = null;
164
+ let inSpecs = false;
165
+ for (const line of contents.split(/\r?\n/)) {
166
+ const sectionMatch = LOCKFILE_SECTION.exec(line);
167
+ if (sectionMatch !== null) {
168
+ section = sectionMatch[1];
169
+ remote = null;
170
+ inSpecs = false;
171
+ continue;
172
+ }
173
+ const remoteMatch = LOCKFILE_REMOTE.exec(line);
174
+ if (remoteMatch !== null) {
175
+ remote = remoteMatch[1];
176
+ continue;
177
+ }
178
+ if (LOCKFILE_SPECS_HEADER.test(line)) {
179
+ inSpecs = true;
180
+ continue;
181
+ }
182
+ if (!inSpecs)
183
+ continue;
184
+ const specMatch = LOCKFILE_SPEC.exec(line);
185
+ if (specMatch === null)
186
+ continue;
187
+ const gemName = specMatch[1];
188
+ if (section === 'GEM' || section === 'GIT') {
189
+ addExternalGem(scope, gemName);
190
+ continue;
191
+ }
192
+ if (section !== 'PATH' && section !== 'GEMSPEC')
193
+ continue;
194
+ if (remote === null)
195
+ continue;
196
+ const loadRoots = loadLocalGemRoots(repoPath, resolve(lockfileDirectory, remote), gemName);
197
+ if (loadRoots === null)
198
+ addExternalGem(scope, gemName);
199
+ else
200
+ addLocalGem(scope, gemName, loadRoots);
201
+ }
202
+ }
203
+ function freezeScope(scope) {
204
+ const localLoadRootsByPrefix = new Map();
205
+ for (const [prefix, roots] of scope.localLoadRootsByPrefix) {
206
+ localLoadRootsByPrefix.set(prefix, [...roots].sort());
207
+ }
208
+ return {
209
+ externalRequirePrefixes: new Set(scope.externalRequirePrefixes),
210
+ localLoadRootsByPrefix,
211
+ };
212
+ }
213
+ /**
214
+ * Select the nearest manifest directory that owns `fromFile`.
215
+ *
216
+ * Walking ancestors makes lookup O(path depth), independent of how many
217
+ * sibling Gemfiles a monorepo contains.
218
+ */
219
+ export function findRubyResolutionScope(config, fromFile) {
220
+ const normalized = normalizeRepoPath(fromFile);
221
+ let directory = normalized.includes('/') ? normalized.slice(0, normalized.lastIndexOf('/')) : '';
222
+ for (;;) {
223
+ const scope = config.scopesByDirectory.get(directory);
224
+ if (scope !== undefined)
225
+ return scope;
226
+ if (directory === '')
227
+ return undefined;
228
+ const slash = directory.lastIndexOf('/');
229
+ directory = slash === -1 ? '' : directory.slice(0, slash);
230
+ }
231
+ }
232
+ /**
233
+ * Statically collect Ruby dependency sources without evaluating Gemfile or
234
+ * gemspec code. Scopes stay separate by manifest directory so sibling projects
235
+ * in a monorepo cannot suppress one another's local imports. Lockfiles
236
+ * contribute remote GEM/GIT specs and local PATH/GEMSPEC load roots only when
237
+ * an adjacent Gemfile or gemspec establishes a Bundler/RubyGems project.
238
+ *
239
+ * No declarative manifest means no safe gate, so return null and preserve the
240
+ * resolver's existing fail-open behavior for loose script directories.
241
+ */
242
+ export function loadRubyResolutionConfig(repoPath) {
243
+ const pending = [repoPath];
244
+ const manifestsByDirectory = new Map();
245
+ const lockfilesByDirectory = new Map();
246
+ let visitedDirectories = 0;
247
+ while (pending.length > 0 && visitedDirectories < MAX_VISITED_DIRECTORIES) {
248
+ const directory = pending.pop();
249
+ if (directory === undefined)
250
+ break;
251
+ visitedDirectories++;
252
+ let entries;
253
+ try {
254
+ entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
255
+ }
256
+ catch {
257
+ continue;
258
+ }
259
+ for (const entry of entries) {
260
+ if (entry.isDirectory() && !SKIP_DIRECTORIES.has(entry.name)) {
261
+ pending.push(join(directory, entry.name));
262
+ }
263
+ else if (entry.isFile() && (entry.name === 'Gemfile' || entry.name.endsWith('.gemspec'))) {
264
+ const manifests = manifestsByDirectory.get(directory) ?? [];
265
+ manifests.push(join(directory, entry.name));
266
+ manifestsByDirectory.set(directory, manifests);
267
+ }
268
+ else if (entry.isFile() && entry.name === 'Gemfile.lock') {
269
+ lockfilesByDirectory.set(directory, join(directory, entry.name));
270
+ }
271
+ }
272
+ }
273
+ if (manifestsByDirectory.size === 0)
274
+ return null;
275
+ const mutableScopes = new Map();
276
+ for (const [directory, manifests] of [...manifestsByDirectory].sort(([left], [right]) => left.localeCompare(right))) {
277
+ const directoryRelative = repoRelativePath(repoPath, directory);
278
+ if (directoryRelative === null)
279
+ continue;
280
+ const scope = createMutableScope();
281
+ mutableScopes.set(directoryRelative, scope);
282
+ for (const manifest of manifests.sort()) {
283
+ try {
284
+ const contents = readFileSync(manifest, 'utf8');
285
+ if (manifest.endsWith('.gemspec')) {
286
+ addGemspecDependencies(contents, directory, repoPath, scope);
287
+ }
288
+ else {
289
+ addGemfileDependencies(contents, directory, repoPath, scope);
290
+ }
291
+ }
292
+ catch {
293
+ // A partially readable scope remains fail-open for unknown gems.
294
+ }
295
+ }
296
+ const lockfile = lockfilesByDirectory.get(directory);
297
+ if (lockfile === undefined)
298
+ continue;
299
+ try {
300
+ addLockedDependencies(readFileSync(lockfile, 'utf8'), directory, repoPath, scope);
301
+ }
302
+ catch {
303
+ // Direct declarations still provide bounded evidence when the lock is unreadable.
304
+ }
305
+ }
306
+ const scopesByDirectory = new Map();
307
+ for (const [directory, scope] of mutableScopes) {
308
+ scopesByDirectory.set(directory, freezeScope(scope));
309
+ }
310
+ return { scopesByDirectory };
311
+ }
@@ -7,6 +7,7 @@ import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
7
7
  import { generateId } from '../../../../lib/utils.js';
8
8
  import { decodeMarker } from '../../utils/heritage-marker.js';
9
9
  import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
10
+ import { loadRubyResolutionConfig } from './resolution-config.js';
10
11
  /**
11
12
  * #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by
12
13
  * the INCLUDING class's lexical scope — Ruby looks up a constant in the innermost
@@ -248,6 +249,7 @@ export const rubyScopeResolver = {
248
249
  languageProvider: rubyProvider,
249
250
  importEdgeReason: 'ruby-scope: import',
250
251
  resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
252
+ loadResolutionConfig: (repoPath) => loadRubyResolutionConfig(repoPath),
251
253
  expandsWildcardTo: (targetModuleScope, parsedFiles) => expandRubyWildcardNames(targetModuleScope, parsedFiles),
252
254
  mergeBindings: (existing, incoming, scopeId) => rubyMergeBindings(existing, incoming, scopeId),
253
255
  arityCompatibility: (callsite, def) => rubyArityCompatibility(def, callsite),
@@ -47,7 +47,7 @@ export declare const DESTINATION_SCHEMA = "\nCREATE NODE TABLE Destination (\n
47
47
  export declare const SECTION_SCHEMA = "\nCREATE NODE TABLE Section (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n level INT64,\n content STRING,\n description STRING,\n PRIMARY KEY (id)\n)";
48
48
  export declare const BASICBLOCK_SCHEMA = "\nCREATE NODE TABLE BasicBlock (\n id STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n text STRING,\n callees STRING,\n calleeIds STRING,\n PRIMARY KEY (id)\n)";
49
49
  /**
50
- * The 66 pairs NEITHER rule above generates — everything left after the two
50
+ * The pairs NEITHER rule above generates — everything left after the two
51
51
  * cross products are subtracted. Carried by CONTAINMENT, inheritance, imports
52
52
  * and DI: a container label crossed with a contained label. No predicate
53
53
  * describes that surface (any container can hold any definition).
@@ -60,6 +60,8 @@ export declare const BASICBLOCK_SCHEMA = "\nCREATE NODE TABLE BasicBlock (\n id
60
60
  * templates), the two `Route|Process` / `Tool|Process` entry points whose
61
61
  * emitter names both labels as literals, and `BasicBlock|BasicBlock`, the PDG
62
62
  * substrate.
63
+ * Spring dynamic lookup in a constructor also emits INJECTS to a synthetic
64
+ * @Bean CodeElement (#3238), so Constructor|CodeElement belongs here.
63
65
  *
64
66
  * NOTHING A RULE ALREADY COVERS BELONGS HERE. `generatedRelationPairs` skips
65
67
  * any pair present in this block, so a redundant line does not merely duplicate
@@ -96,7 +98,7 @@ export declare const BASICBLOCK_SCHEMA = "\nCREATE NODE TABLE BasicBlock (\n id
96
98
  * `test/integration/structural-pair-coverage.test.ts` guards this half from a
97
99
  * corpus — that is the guard, not this comment.
98
100
  */
99
- export declare const STRUCTURAL_PAIR_DDL = " FROM File TO Folder,\n FROM File TO CodeElement,\n FROM File TO `Typedef`,\n FROM File TO `Namespace`,\n FROM File TO `Impl`,\n FROM File TO `Static`,\n FROM File TO `Template`,\n FROM File TO Section,\n FROM Folder TO Folder,\n FROM Folder TO File,\n FROM Function TO `Template`,\n FROM Function TO `Namespace`,\n FROM Function TO `Impl`,\n FROM Function TO `Typedef`,\n FROM Function TO CodeElement,\n FROM Class TO `Template`,\n FROM Class TO `Impl`,\n FROM Class TO `Namespace`,\n FROM Class TO `Typedef`,\n FROM Class TO CodeElement,\n FROM Method TO `Template`,\n FROM Method TO `Namespace`,\n FROM Method TO `Impl`,\n FROM Method TO CodeElement,\n FROM `Template` TO `Template`,\n FROM `Template` TO Function,\n FROM `Template` TO Method,\n FROM `Template` TO Class,\n FROM `Template` TO `Struct`,\n FROM `Template` TO `TypeAlias`,\n FROM `Template` TO `Enum`,\n FROM `Template` TO `Macro`,\n FROM `Template` TO Interface,\n FROM `Template` TO `Constructor`,\n FROM `Module` TO CodeElement,\n FROM `Module` TO `Namespace`,\n FROM `Namespace` TO Function,\n FROM CodeElement TO CodeElement,\n FROM CodeElement TO Class,\n FROM CodeElement TO Category,\n FROM CodeElement TO Method,\n FROM CodeElement TO `Module`,\n FROM CodeElement TO `Property`,\n FROM Section TO Section,\n FROM Interface TO CodeElement,\n FROM `Namespace` TO `Struct`,\n FROM `Impl` TO Method,\n FROM `Impl` TO Function,\n FROM `Impl` TO `Constructor`,\n FROM `Impl` TO `Property`,\n FROM `Impl` TO `Trait`,\n FROM `Impl` TO `Struct`,\n FROM `Impl` TO `Impl`,\n FROM `Constructor` TO `Template`,\n FROM `Constructor` TO `Impl`,\n FROM `Constructor` TO `Namespace`,\n FROM `Constructor` TO `Typedef`,\n FROM Route TO Process,\n FROM Tool TO Process,\n FROM Destination TO `Property`,\n FROM BasicBlock TO BasicBlock";
101
+ export declare const STRUCTURAL_PAIR_DDL = " FROM File TO Folder,\n FROM File TO CodeElement,\n FROM File TO `Typedef`,\n FROM File TO `Namespace`,\n FROM File TO `Impl`,\n FROM File TO `Static`,\n FROM File TO `Template`,\n FROM File TO Section,\n FROM Folder TO Folder,\n FROM Folder TO File,\n FROM Function TO `Template`,\n FROM Function TO `Namespace`,\n FROM Function TO `Impl`,\n FROM Function TO `Typedef`,\n FROM Function TO CodeElement,\n FROM Class TO `Template`,\n FROM Class TO `Impl`,\n FROM Class TO `Namespace`,\n FROM Class TO `Typedef`,\n FROM Class TO CodeElement,\n FROM Method TO `Template`,\n FROM Method TO `Namespace`,\n FROM Method TO `Impl`,\n FROM Method TO CodeElement,\n FROM `Template` TO `Template`,\n FROM `Template` TO Function,\n FROM `Template` TO Method,\n FROM `Template` TO Class,\n FROM `Template` TO `Struct`,\n FROM `Template` TO `TypeAlias`,\n FROM `Template` TO `Enum`,\n FROM `Template` TO `Macro`,\n FROM `Template` TO Interface,\n FROM `Template` TO `Constructor`,\n FROM `Module` TO CodeElement,\n FROM `Module` TO `Namespace`,\n FROM `Namespace` TO Function,\n FROM CodeElement TO CodeElement,\n FROM CodeElement TO Class,\n FROM CodeElement TO Category,\n FROM CodeElement TO Method,\n FROM CodeElement TO `Module`,\n FROM CodeElement TO `Property`,\n FROM Section TO Section,\n FROM Interface TO CodeElement,\n FROM `Namespace` TO `Struct`,\n FROM `Impl` TO Method,\n FROM `Impl` TO Function,\n FROM `Impl` TO `Constructor`,\n FROM `Impl` TO `Property`,\n FROM `Impl` TO `Trait`,\n FROM `Impl` TO `Struct`,\n FROM `Impl` TO `Impl`,\n FROM `Constructor` TO `Template`,\n FROM `Constructor` TO `Impl`,\n FROM `Constructor` TO `Namespace`,\n FROM `Constructor` TO `Typedef`,\n FROM `Constructor` TO CodeElement,\n FROM Route TO Process,\n FROM Tool TO Process,\n FROM Destination TO `Property`,\n FROM BasicBlock TO BasicBlock";
100
102
  export declare const RELATION_SCHEMA: string;
101
103
  export declare const EMBEDDING_DIMS: number;
102
104
  /** HNSW vector index name for the CodeEmbedding table. */
@@ -485,7 +485,7 @@ const ATTACHMENT_TARGET_LABELS = [
485
485
  'Record',
486
486
  ];
487
487
  /**
488
- * The 66 pairs NEITHER rule above generates — everything left after the two
488
+ * The pairs NEITHER rule above generates — everything left after the two
489
489
  * cross products are subtracted. Carried by CONTAINMENT, inheritance, imports
490
490
  * and DI: a container label crossed with a contained label. No predicate
491
491
  * describes that surface (any container can hold any definition).
@@ -498,6 +498,8 @@ const ATTACHMENT_TARGET_LABELS = [
498
498
  * templates), the two `Route|Process` / `Tool|Process` entry points whose
499
499
  * emitter names both labels as literals, and `BasicBlock|BasicBlock`, the PDG
500
500
  * substrate.
501
+ * Spring dynamic lookup in a constructor also emits INJECTS to a synthetic
502
+ * @Bean CodeElement (#3238), so Constructor|CodeElement belongs here.
501
503
  *
502
504
  * NOTHING A RULE ALREADY COVERS BELONGS HERE. `generatedRelationPairs` skips
503
505
  * any pair present in this block, so a redundant line does not merely duplicate
@@ -591,6 +593,7 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
591
593
  FROM \`Constructor\` TO \`Impl\`,
592
594
  FROM \`Constructor\` TO \`Namespace\`,
593
595
  FROM \`Constructor\` TO \`Typedef\`,
596
+ FROM \`Constructor\` TO CodeElement,
594
597
  FROM Route TO Process,
595
598
  FROM Tool TO Process,
596
599
  FROM Destination TO \`Property\`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.25",
3
+ "version": "1.6.12-rc.27",
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",