gitnexus 1.6.4-rc.37 → 1.6.4-rc.38

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.
@@ -41,35 +41,76 @@ export function resolvePythonImportTarget(parsedImport, workspaceIndex) {
41
41
  const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
42
42
  if (pathLike.includes('/')) {
43
43
  const [leadingSegment] = pathLike.split('/').filter(Boolean);
44
- if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
44
+ if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths, ctx.fromFile)) {
45
45
  return null;
46
46
  }
47
47
  }
48
- // Multi-segment absolute resolve: try exact paths first, then suffix
49
- // match in nested repos. Using direct `Set.has` + `endsWith` instead of
50
- // `suffixResolve`'s shared helper because that helper requires a
51
- // pre-built `SuffixIndex` to disambiguate ties without one it falls
52
- // back to an O(files) scan that silently picks the wrong file when
53
- // the last segment collides across directories (e.g. `accounts.models`
54
- // matching `billing/models.py` when both files exist).
55
- return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
48
+ // Multi-segment absolute resolve: try exact paths first, then ancestor
49
+ // walk (mirrors the single-segment ancestor walk in
50
+ // `resolvePythonImportInternal`), then a suffix match in nested repos.
51
+ // Using direct `Set.has` + `endsWith` instead of `suffixResolve`'s shared
52
+ // helper because that helper requires a pre-built `SuffixIndex` to
53
+ // disambiguate ties without one it falls back to an O(files) scan that
54
+ // silently picks the wrong file when the last segment collides across
55
+ // directories (e.g. `accounts.models` matching `billing/models.py` when
56
+ // both files exist).
57
+ return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
56
58
  }
57
59
  /**
58
60
  * Resolve `package/sub/module` style paths (already dot-flattened) to a
59
- * concrete file in `allFilePaths`. Tries the exact path first, then the
60
- * `__init__.py` variant, then a suffix match for nested layouts.
61
+ * concrete file in `allFilePaths`. Tries the exact path first, then walks
62
+ * ancestors of `fromFile` looking for `<ancestor>/<pathLike>.py` (or
63
+ * `__init__.py`), then falls back to a suffix match for nested layouts.
61
64
  * Returns the original (un-normalized) path from the set.
65
+ *
66
+ * Precedence order:
67
+ * 1. Workspace-root direct hit (`<pathLike>.py`, `<pathLike>/__init__.py`).
68
+ * 2. Closest-ancestor match walking up from the importer's directory.
69
+ * 3. Suffix fallback (first match).
70
+ *
71
+ * Root wins over ancestor by construction — if both `services/sync.py` and
72
+ * `backend/services/sync.py` exist, `backend/routers/cron.py`'s
73
+ * `from services.sync import X` resolves to the root file. This mirrors
74
+ * Python's `sys.path` semantics where the project root is searched first.
75
+ *
76
+ * The ancestor walk mirrors the single-segment behavior in
77
+ * `resolvePythonImportInternal`. For `from services.sync import X` in
78
+ * `backend/routers/cron.py`, walk up: `backend/routers/services/sync.py` →
79
+ * `backend/services/sync.py` ✓.
62
80
  */
63
- function resolveAbsoluteFromFiles(pathLike, allFilePaths) {
81
+ function resolveAbsoluteFromFiles(pathLike, allFilePaths, fromFile) {
64
82
  const directFile = `${pathLike}.py`;
65
83
  const directPkg = `${pathLike}/__init__.py`;
84
+ // Direct hit at workspace root.
85
+ if (allFilePaths.has(directFile))
86
+ return directFile;
87
+ if (allFilePaths.has(directPkg))
88
+ return directPkg;
89
+ // Ancestor walk — match the single-segment resolver's behavior at
90
+ // multi-segment granularity. Closest match wins. Stop at `i > 0` because
91
+ // `i === 0` would re-check the workspace-root candidates already covered
92
+ // by the direct check above.
93
+ const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
94
+ if (importerDir) {
95
+ const dirParts = importerDir.split('/').filter(Boolean);
96
+ for (let i = dirParts.length; i > 0; i--) {
97
+ const ancestor = dirParts.slice(0, i).join('/');
98
+ const prefix = `${ancestor}/`;
99
+ const candidateFile = `${prefix}${directFile}`;
100
+ const candidatePkg = `${prefix}${directPkg}`;
101
+ if (allFilePaths.has(candidateFile))
102
+ return candidateFile;
103
+ if (allFilePaths.has(candidatePkg))
104
+ return candidatePkg;
105
+ }
106
+ }
107
+ // Existing suffix-match fallback (preserved for monorepo/nested-repo
108
+ // layouts that don't share a directory ancestor with the importer).
66
109
  const suffixFile = `/${directFile}`;
67
110
  const suffixPkg = `/${directPkg}`;
68
111
  let suffixMatch = null;
69
112
  for (const raw of allFilePaths) {
70
113
  const f = raw.replace(/\\/g, '/');
71
- if (f === directFile || f === directPkg)
72
- return raw;
73
114
  if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
74
115
  suffixMatch = raw;
75
116
  }
@@ -77,23 +118,51 @@ function resolveAbsoluteFromFiles(pathLike, allFilePaths) {
77
118
  return suffixMatch;
78
119
  }
79
120
  /**
80
- * Does the repo contain a module/package named `leadingSegment` at the top
81
- * level? Used to guard against false-positive suffix matches on external
82
- * dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
121
+ * Does the repo contain a module/package named `leadingSegment` somewhere
122
+ * the importer can plausibly reach?
83
123
  *
84
- * Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
85
- * regular package, or any `<segment>/**.py` file (namespace package).
124
+ * Used to guard against false-positive suffix matches on external dotted
125
+ * imports (e.g. `django.apps` matching a local `accounts/apps.py`).
126
+ *
127
+ * Checks, in order:
128
+ * 1. `SEGMENT.py` root file or `SEGMENT/__init__.py` regular package.
129
+ * 2. Any `SEGMENT/...py` file at the workspace root (namespace package).
130
+ * 3. Any `<importer-ancestor>/SEGMENT/...py` file (nested namespace
131
+ * package the importer could reach via an ancestor walk, e.g.
132
+ * `backend/services/sync.py` from `backend/routers/cron.py`).
133
+ *
134
+ * The nested case is bounded to the importer's own ancestors so a
135
+ * vendored copy of an external package (e.g. `vendor/django/urls.py`)
136
+ * does not gate-pass external imports like `from django.urls import path`
137
+ * issued from `app/main.py`. Files inside the vendored tree itself
138
+ * (importer under `vendor/django/...`) still resolve correctly because
139
+ * the ancestor walk includes their own parents.
86
140
  */
87
- function hasRepoCandidate(leadingSegment, allFilePaths) {
141
+ function hasRepoCandidate(leadingSegment, allFilePaths, fromFile) {
88
142
  const prefix = `${leadingSegment}/`;
89
143
  const rootFile = `${leadingSegment}.py`;
90
144
  const initFile = `${leadingSegment}/__init__.py`;
145
+ // Build importer-ancestor prefixes: for `backend/routers/cron.py`,
146
+ // produces `["backend/routers/services/", "backend/services/"]` for
147
+ // segment `services` (closest first, root excluded — covered above).
148
+ const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
149
+ const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : [];
150
+ const ancestorPrefixes = [];
151
+ for (let i = dirParts.length; i > 0; i--) {
152
+ ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
153
+ }
91
154
  for (const raw of allFilePaths) {
92
155
  const f = raw.replace(/\\/g, '/');
93
156
  if (f === rootFile || f === initFile)
94
157
  return true;
95
158
  if (f.startsWith(prefix) && f.endsWith('.py'))
96
159
  return true;
160
+ if (f.endsWith('.py')) {
161
+ for (const ap of ancestorPrefixes) {
162
+ if (f.startsWith(ap))
163
+ return true;
164
+ }
165
+ }
97
166
  }
98
167
  return false;
99
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.4-rc.37",
3
+ "version": "1.6.4-rc.38",
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",