gitnexus 1.6.10-rc.215 → 1.6.10-rc.216

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.
@@ -36,12 +36,23 @@ export function resolvePhpImportInternal(importPath, composerConfig, allFiles, n
36
36
  return null;
37
37
  if (composerConfig) {
38
38
  const sorted = getSortedPsr4(composerConfig);
39
+ const authoritativePsr4 = composerConfig.authoritativePsr4 ?? new Set(sorted.map(([namespace]) => namespace));
40
+ let matchedAuthoritativeNamespace = false;
41
+ let hasAuthoritativeCatchAllNamespace = false;
42
+ const ownershipPath = normalized.replace(/^\/+/, '');
39
43
  for (const [nsPrefix, dirPrefix] of sorted) {
40
- const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
41
- if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
42
- const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
44
+ const nsPrefixSlash = nsPrefix.replace(/\\/g, '/').replace(/\/+$/, '');
45
+ const isCatchAll = nsPrefixSlash === '';
46
+ if (isCatchAll ||
47
+ ownershipPath.startsWith(nsPrefixSlash + '/') ||
48
+ ownershipPath === nsPrefixSlash) {
49
+ const isAuthoritative = authoritativePsr4.has(nsPrefix);
50
+ matchedAuthoritativeNamespace ||= isAuthoritative;
51
+ hasAuthoritativeCatchAllNamespace ||= isAuthoritative && isCatchAll;
52
+ const remainder = ownershipPath.slice(nsPrefixSlash.length).replace(/^\//, '');
43
53
  // 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
44
- const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
54
+ const mappedPath = dirPrefix === '' ? remainder : dirPrefix + (remainder ? '/' + remainder : '');
55
+ const filePath = mappedPath + '.php';
45
56
  if (allFiles.has(filePath))
46
57
  return filePath;
47
58
  if (index) {
@@ -51,44 +62,60 @@ export function resolvePhpImportInternal(importPath, composerConfig, allFiles, n
51
62
  }
52
63
  // 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
53
64
  // e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
54
- const lastSlash = remainder.lastIndexOf('/');
55
- const nsDir = lastSlash >= 0 ? dirPrefix + '/' + remainder.slice(0, lastSlash) : dirPrefix;
56
- // Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
57
- //
58
- // An EMPTY bucket is a final answer, not a miss to retry with the scan
59
- // below — which is what the `else` restores, and what this comment
60
- // always claimed. Re-scanning on empty was the last per-import
61
- // workspace traversal left in PHP resolution after #2901: any `use`
62
- // matching a PSR-4 prefix whose directory holds no direct `.php` child
63
- // (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
64
- // 200 imports.
65
- //
66
- // The bucket is a superset of what the scan can find, for BOTH index
67
- // shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
68
- // has its directory exactly equal to `nsDir`, and `nsDir` is always one
69
- // of that directory's own suffixes — so the shared `dirMap` (keyed on
70
- // every directory suffix) necessarily contains it, as does the
71
- // root-anchored parity index `languages/php/import-target.ts` builds.
72
- // Empty superset therefore implies empty scan, and control falls
73
- // through to the next PSR-4 prefix exactly as before.
74
- if (index) {
75
- const candidates = index.getFilesInDir(nsDir, '.php');
76
- if (candidates.length > 0)
77
- return candidates[0];
78
- }
79
- else {
80
- // Linear scan, only when a SuffixIndex is genuinely unavailable.
81
- const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
82
- for (const f of allFiles) {
83
- if (f.startsWith(nsDirPrefix) &&
84
- f.endsWith('.php') &&
85
- !f.slice(nsDirPrefix.length).includes('/')) {
86
- return f;
65
+ // A root/catch-all mapping cannot safely infer a symbol's declaring
66
+ // file from an arbitrary sibling. The higher-level PHP resolver has
67
+ // parsed symbol-kind and declaration evidence for function/const
68
+ // imports; class imports must not inherit this directory heuristic.
69
+ if (!isCatchAll && dirPrefix !== '') {
70
+ const lastSlash = remainder.lastIndexOf('/');
71
+ const relativeNamespace = lastSlash >= 0 ? remainder.slice(0, lastSlash) : '';
72
+ const nsDir = relativeNamespace === '' ? dirPrefix : `${dirPrefix}/${relativeNamespace}`;
73
+ // Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
74
+ //
75
+ // An EMPTY bucket is a final answer, not a miss to retry with the scan
76
+ // below — which is what the `else` restores, and what this comment
77
+ // always claimed. Re-scanning on empty was the last per-import
78
+ // workspace traversal left in PHP resolution after #2901: any `use`
79
+ // matching a PSR-4 prefix whose directory holds no direct `.php` child
80
+ // (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
81
+ // 200 imports.
82
+ //
83
+ // The bucket is a superset of what the scan can find, for BOTH index
84
+ // shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
85
+ // has its directory exactly equal to `nsDir`, and `nsDir` is always one
86
+ // of that directory's own suffixes — so the shared `dirMap` (keyed on
87
+ // every directory suffix) necessarily contains it, as does the
88
+ // root-anchored parity index `languages/php/import-target.ts` builds.
89
+ // Empty superset therefore implies empty scan, and control falls
90
+ // through to the next PSR-4 prefix exactly as before.
91
+ if (index) {
92
+ const candidates = index.getFilesInDir(nsDir, '.php');
93
+ if (candidates.length > 0)
94
+ return candidates[0];
95
+ }
96
+ else {
97
+ // Linear scan, only when a SuffixIndex is genuinely unavailable.
98
+ const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
99
+ for (const f of allFiles) {
100
+ if (f.startsWith(nsDirPrefix) &&
101
+ f.endsWith('.php') &&
102
+ !f.slice(nsDirPrefix.length).includes('/')) {
103
+ return f;
104
+ }
87
105
  }
88
106
  }
89
107
  }
90
108
  }
91
109
  }
110
+ // A non-empty PSR-4 map is authoritative for namespaces it does not own.
111
+ // Preserve the existing mapped-namespace fallback behavior; #2962 is the
112
+ // conservative external-namespace gate, not a rewrite of mapped lookup.
113
+ // A catch-all owns every namespace, so its misses remain authoritative.
114
+ if (authoritativePsr4.size > 0 &&
115
+ !composerConfig.hasUnmodeledAutoload &&
116
+ (!matchedAuthoritativeNamespace || hasAuthoritativeCatchAllNamespace)) {
117
+ return null;
118
+ }
92
119
  }
93
120
  // Fallback: suffix matching (works without composer.json)
94
121
  const pathParts = normalized.split('/').filter(Boolean);
@@ -14,10 +14,19 @@ export interface GoModuleConfig {
14
14
  export interface ComposerConfig {
15
15
  /** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
16
16
  psr4: Map<string, string>;
17
+ /** Production `autoload.psr-4` prefixes that may gate external namespaces.
18
+ * Absent on legacy/manual configs, where every mapping remains authoritative. */
19
+ authoritativePsr4?: ReadonlySet<string>;
20
+ /** True when Composer also declares an autoload mechanism this resolver does not model. */
21
+ hasUnmodeledAutoload?: boolean;
17
22
  /** PSR-4 entries sorted by namespace length descending (longest match wins).
18
23
  * Cached once at config load time to avoid re-sorting on every import. */
19
24
  psr4Sorted?: readonly [string, string][];
20
25
  }
26
+ /** Parse one Composer manifest without performing I/O. */
27
+ export declare function parseComposerConfig(value: unknown, baseDir?: string): ComposerConfig | null;
28
+ /** Merge package-local Composer manifests into one repository-relative config. */
29
+ export declare function mergeComposerConfigs(configs: readonly ComposerConfig[]): ComposerConfig | null;
21
30
  /** C# project config parsed from .csproj files */
22
31
  export interface CSharpProjectConfig {
23
32
  /** Root namespace from <RootNamespace> or assembly name (default: project directory name) */
@@ -5,6 +5,93 @@ import path from 'path';
5
5
  import { isDev } from './utils/env.js';
6
6
  import { mapConcurrent } from '../../lib/utils.js';
7
7
  import { logger } from '../logger.js';
8
+ function normalizeComposerDirectory(baseDir, directory) {
9
+ const normalizedBase = baseDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
10
+ const normalizedDirectory = directory
11
+ .replace(/\\/g, '/')
12
+ .replace(/^(?:\.\/)+/, '')
13
+ .replace(/\/+$/, '');
14
+ if (normalizedBase === '')
15
+ return normalizedDirectory;
16
+ if (normalizedDirectory === '')
17
+ return normalizedBase;
18
+ return path.posix.normalize(`${normalizedBase}/${normalizedDirectory}`);
19
+ }
20
+ /** Parse one Composer manifest without performing I/O. */
21
+ export function parseComposerConfig(value, baseDir = '') {
22
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
23
+ return null;
24
+ const composer = value;
25
+ const autoload = composer.autoload;
26
+ const autoloadDev = composer['autoload-dev'];
27
+ if (autoload === undefined && autoloadDev === undefined)
28
+ return null;
29
+ const psr4 = new Map();
30
+ const authoritativePsr4 = new Set();
31
+ let hasUnmodeledAutoload = false;
32
+ const addSection = (sectionValue, authoritative) => {
33
+ if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) {
34
+ return;
35
+ }
36
+ const section = sectionValue;
37
+ if ('psr-0' in section || 'classmap' in section)
38
+ hasUnmodeledAutoload = true;
39
+ const rawPsr4 = section['psr-4'];
40
+ if (typeof rawPsr4 !== 'object' || rawPsr4 === null || Array.isArray(rawPsr4))
41
+ return;
42
+ for (const [namespace, directories] of Object.entries(rawPsr4)) {
43
+ const stringDirectories = Array.isArray(directories)
44
+ ? directories.filter((entry) => typeof entry === 'string')
45
+ : typeof directories === 'string'
46
+ ? [directories]
47
+ : [];
48
+ if (stringDirectories.length === 0)
49
+ continue;
50
+ if (stringDirectories.length > 1)
51
+ hasUnmodeledAutoload = true;
52
+ const normalizedNamespace = namespace.replace(/\\+$/, '');
53
+ const normalizedDirectory = normalizeComposerDirectory(baseDir, stringDirectories[0]);
54
+ const existing = psr4.get(normalizedNamespace);
55
+ if (existing !== undefined && existing !== normalizedDirectory) {
56
+ hasUnmodeledAutoload = true;
57
+ continue;
58
+ }
59
+ if (existing === undefined)
60
+ psr4.set(normalizedNamespace, normalizedDirectory);
61
+ if (authoritative)
62
+ authoritativePsr4.add(normalizedNamespace);
63
+ }
64
+ };
65
+ // Production mappings win duplicate prefixes. Development mappings remain
66
+ // usable for test code but do not establish authority for the external gate.
67
+ addSection(autoload, true);
68
+ addSection(autoloadDev, false);
69
+ return { psr4, authoritativePsr4, hasUnmodeledAutoload };
70
+ }
71
+ /** Merge package-local Composer manifests into one repository-relative config. */
72
+ export function mergeComposerConfigs(configs) {
73
+ if (configs.length === 0)
74
+ return null;
75
+ const psr4 = new Map();
76
+ const authoritativePsr4 = new Set();
77
+ let hasUnmodeledAutoload = false;
78
+ for (const config of configs) {
79
+ hasUnmodeledAutoload ||= config.hasUnmodeledAutoload === true;
80
+ for (const [namespace, directory] of config.psr4) {
81
+ const existing = psr4.get(namespace);
82
+ if (existing !== undefined && existing !== directory) {
83
+ hasUnmodeledAutoload = true;
84
+ continue;
85
+ }
86
+ if (existing === undefined)
87
+ psr4.set(namespace, directory);
88
+ }
89
+ for (const namespace of config.authoritativePsr4 ?? config.psr4.keys()) {
90
+ authoritativePsr4.add(namespace);
91
+ }
92
+ }
93
+ return { psr4, authoritativePsr4, hasUnmodeledAutoload };
94
+ }
8
95
  /** Project the one-pass {@link CSharpProjectScan} into the
9
96
  * {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
10
97
  * #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
@@ -85,20 +172,13 @@ export async function loadComposerConfig(repoRoot) {
85
172
  try {
86
173
  const composerPath = path.join(repoRoot, 'composer.json');
87
174
  const raw = await fs.readFile(composerPath, 'utf-8');
88
- const composer = JSON.parse(raw);
89
- const psr4Raw = composer.autoload?.['psr-4'] ?? {};
90
- const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {};
91
- const merged = { ...psr4Raw, ...psr4Dev };
92
- const psr4 = new Map();
93
- for (const [ns, dir] of Object.entries(merged)) {
94
- const nsNorm = ns.replace(/\\+$/, '');
95
- const dirNorm = dir.replace(/\\/g, '/').replace(/\/+$/, '');
96
- psr4.set(nsNorm, dirNorm);
97
- }
175
+ const config = parseComposerConfig(JSON.parse(raw));
176
+ if (config === null)
177
+ return null;
98
178
  if (isDev) {
99
- logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
179
+ logger.info(`📦 Loaded ${config.psr4.size} PSR-4 mappings from composer.json`);
100
180
  }
101
- return { psr4 };
181
+ return config;
102
182
  }
103
183
  catch {
104
184
  return null;
@@ -16,15 +16,14 @@
16
16
  */
17
17
  import type { ParsedImport, WorkspaceIndex } from '../../../../_shared/index.js';
18
18
  import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js';
19
- import type { ComposerConfig } from '../../language-config.js';
19
+ import { type ComposerConfig } from '../../language-config.js';
20
20
  export interface PhpResolveContext {
21
21
  readonly fromFile: string;
22
22
  readonly allFilePaths: ReadonlySet<string>;
23
23
  }
24
24
  /**
25
- * Load and parse `composer.json` from the repo root. Returns a
26
- * `ComposerConfig` object (PSR-4 namespace → directory mappings) or
27
- * `null` when no `composer.json` is present or it cannot be parsed.
25
+ * Load and parse repository and package-local `composer.json` manifests.
26
+ * Package mappings are rebased to repository-relative paths before merging.
28
27
  *
29
28
  * The result is threaded into each `resolvePhpImportInternal` call as
30
29
  * the `composerConfig` argument.
@@ -17,8 +17,9 @@
17
17
  import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
18
18
  import { perFileSet } from '../../import-resolvers/per-file-set.js';
19
19
  import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
20
- import { readFileSync } from 'node:fs';
21
- import { join } from 'node:path';
20
+ import { mergeComposerConfigs, parseComposerConfig, } from '../../language-config.js';
21
+ import { readdirSync, readFileSync } from 'node:fs';
22
+ import { dirname, join, relative } from 'node:path';
22
23
  function normalizePhpPath(value) {
23
24
  return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
24
25
  }
@@ -32,17 +33,18 @@ function namespaceDirectories(targetRaw, composerConfig, resolved) {
32
33
  }
33
34
  if (composerConfig === null)
34
35
  return [...directories];
35
- const normalizedTarget = normalizePhpPath(targetRaw);
36
+ const normalizedTarget = normalizePhpPath(targetRaw).replace(/^\/+/, '');
36
37
  const mappings = [...composerConfig.psr4.entries()].sort((left, right) => {
37
38
  const lengthDifference = right[0].length - left[0].length;
38
39
  return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]);
39
40
  });
40
41
  for (const [namespacePrefix, directoryPrefix] of mappings) {
41
42
  const normalizedPrefix = normalizePhpPath(namespacePrefix);
42
- if (normalizedTarget !== normalizedPrefix &&
43
- !normalizedTarget.startsWith(`${normalizedPrefix}/`)) {
43
+ const matchesNamespace = normalizedPrefix === '' ||
44
+ normalizedTarget === normalizedPrefix ||
45
+ normalizedTarget.startsWith(`${normalizedPrefix}/`);
46
+ if (!matchesNamespace)
44
47
  continue;
45
- }
46
48
  const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, '');
47
49
  const separator = remainder.lastIndexOf('/');
48
50
  const relativeNamespace = separator >= 0 ? remainder.slice(0, separator) : '';
@@ -57,20 +59,10 @@ function parentDirectory(filePath) {
57
59
  return separator < 0 ? '' : normalizedPath.slice(0, separator);
58
60
  }
59
61
  function directoryAliases(filePath) {
60
- const normalizedPath = normalizePhpPath(filePath);
61
- const separator = normalizedPath.lastIndexOf('/');
62
- if (separator < 0)
63
- return [''];
64
- const parent = normalizedPath.slice(0, separator);
65
- const aliases = new Set([parent]);
66
- const segments = parent.split('/').filter(Boolean);
67
- for (let index = 0; index < segments.length; index++) {
68
- aliases.add(segments.slice(index).join('/'));
69
- }
70
- return [...aliases];
62
+ return [parentDirectory(filePath)];
71
63
  }
72
64
  /**
73
- * Directory alias → the files under it, built once per pass.
65
+ * Exact repository-relative directory → the files under it, built once per pass.
74
66
  *
75
67
  * A scope-resolution pass shares one stable `parsedFiles` array across imports,
76
68
  * so the array identity is the memo key — see `perFileSet`.
@@ -200,41 +192,69 @@ const getPhpWorkspaceIndex = perFileSet((allFilePaths) => {
200
192
  });
201
193
  // ─── loadResolutionConfig ──────────────────────────────────────────────────
202
194
  /**
203
- * Load and parse `composer.json` from the repo root. Returns a
204
- * `ComposerConfig` object (PSR-4 namespace → directory mappings) or
205
- * `null` when no `composer.json` is present or it cannot be parsed.
195
+ * Load and parse repository and package-local `composer.json` manifests.
196
+ * Package mappings are rebased to repository-relative paths before merging.
206
197
  *
207
198
  * The result is threaded into each `resolvePhpImportInternal` call as
208
199
  * the `composerConfig` argument.
209
200
  */
210
201
  export function loadPhpComposerConfig(repoPath) {
211
- try {
212
- const composerPath = join(repoPath, 'composer.json');
213
- const raw = readFileSync(composerPath, 'utf8');
214
- const parsed = JSON.parse(raw);
215
- if (typeof parsed !== 'object' || parsed === null)
216
- return null;
217
- const composer = parsed;
218
- const autoload = composer['autoload'];
219
- if (autoload === undefined)
220
- return null;
221
- const psr4Raw = (autoload['psr-4'] ?? {});
222
- const psr4 = new Map();
223
- for (const [ns, dirs] of Object.entries(psr4Raw)) {
224
- // namespace prefix ends with `\` — keep as-is; resolver strips it
225
- const normalizedNs = ns.replace(/\\$/, '');
226
- const dir = Array.isArray(dirs) ? dirs[0] : dirs;
227
- if (typeof dir === 'string') {
228
- // Normalize directory path (strip trailing slash)
229
- const normalizedDir = dir.replace(/\/+$/, '');
230
- psr4.set(normalizedNs, normalizedDir);
202
+ const skipDirectories = new Set([
203
+ '.git',
204
+ '.gitnexus',
205
+ 'node_modules',
206
+ 'vendor',
207
+ 'dist',
208
+ 'build',
209
+ 'coverage',
210
+ ]);
211
+ const pending = [repoPath];
212
+ const manifests = [];
213
+ let incomplete = false;
214
+ let visitedDirectories = 0;
215
+ while (pending.length > 0) {
216
+ const directory = pending.pop();
217
+ if (directory === undefined)
218
+ break;
219
+ if (++visitedDirectories > 20_000) {
220
+ incomplete = true;
221
+ break;
222
+ }
223
+ let entries;
224
+ try {
225
+ entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
226
+ }
227
+ catch {
228
+ incomplete = true;
229
+ continue;
230
+ }
231
+ for (const entry of entries) {
232
+ if (entry.isFile() && entry.name === 'composer.json') {
233
+ manifests.push(join(directory, entry.name));
234
+ }
235
+ else if (entry.isDirectory() && !skipDirectories.has(entry.name)) {
236
+ pending.push(join(directory, entry.name));
231
237
  }
232
238
  }
233
- return { psr4 };
234
239
  }
235
- catch {
236
- return null;
240
+ const configs = [];
241
+ for (const manifest of manifests.sort()) {
242
+ try {
243
+ const baseDir = normalizePhpPath(relative(repoPath, dirname(manifest)));
244
+ const config = parseComposerConfig(JSON.parse(readFileSync(manifest, 'utf8')), baseDir);
245
+ if (config !== null)
246
+ configs.push(config);
247
+ }
248
+ catch {
249
+ incomplete = true;
250
+ }
237
251
  }
252
+ const merged = mergeComposerConfigs(configs);
253
+ if (merged === null)
254
+ return null;
255
+ if (incomplete)
256
+ merged.hasUnmodeledAutoload = true;
257
+ return merged;
238
258
  }
239
259
  // ─── resolvePhpImportTarget ────────────────────────────────────────────────
240
260
  /**
@@ -295,11 +315,7 @@ export function resolvePhpImportTargetInternal(targetRaw, _fromFile, allFilePath
295
315
  const candidateFiles = [
296
316
  ...new Set(directories.flatMap((directory) => {
297
317
  const files = directoryIndex.get(normalizePhpPath(directory)) ?? [];
298
- // A suffix alias can match directories under different roots (for
299
- // example app/Models and vendor/pkg/app/Models). Picking either root
300
- // would be a guess, so fail closed to the composer resolution instead.
301
- const distinctParents = new Set(files.map((file) => parentDirectory(file.filePath)));
302
- return distinctParents.size > 1 ? [] : files;
318
+ return files;
303
319
  })),
304
320
  ];
305
321
  const expectedType = symbolKind === 'function' ? 'Function' : 'Variable';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.215",
3
+ "version": "1.6.10-rc.216",
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",