gitnexus 1.6.13-rc.83 → 1.6.13-rc.85

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.
@@ -9,6 +9,8 @@ export interface FilePath {
9
9
  }
10
10
  export declare const READ_CONCURRENCY = 32;
11
11
  export interface WalkRepositoryOptions {
12
+ /** Capture required metadata before stat/size filtering can omit its inputs. */
13
+ onPathsDiscovered?: (paths: readonly string[]) => Promise<void>;
12
14
  /**
13
15
  * Suppress the operator-facing large-file notice. Set by read-only callers
14
16
  * such as `status`, which reuse this scan purely to learn which files the
@@ -1,6 +1,7 @@
1
1
  import { isVerboseIngestionEnabled } from './utils/verbose.js';
2
2
  import { DEFAULT_MAX_FILE_SIZE_BYTES, getMaxFileSizeBytes } from './utils/max-file-size.js';
3
3
  import fs from 'fs/promises';
4
+ import { readdir } from 'node:fs';
4
5
  import path from 'path';
5
6
  import { glob } from 'glob';
6
7
  import { createIgnoreFilter } from '../../config/ignore-service.js';
@@ -36,7 +37,7 @@ const warnLargeFileSkip = (message) => {
36
37
  logger.warn(message);
37
38
  };
38
39
  /**
39
- * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
40
+ * Phase 1: Scan repository — capture required metadata, then stat paths + sizes.
40
41
  * Memory: ~10MB for 100K files vs ~1GB+ with content.
41
42
  */
42
43
  const assertWalkRootIsDirectory = async (repoPath) => {
@@ -59,12 +60,29 @@ export const walkRepositoryPaths = async (repoPath, onProgress, options = {}) =>
59
60
  await assertWalkRootIsDirectory(repoPath);
60
61
  const ignoreFilter = await createIgnoreFilter(repoPath);
61
62
  const maxFileSizeBytes = options.maxFileSizeBytes ?? getMaxFileSizeBytes();
62
- const filtered = await glob('**/*', {
63
+ let enumerationError;
64
+ const filtered = (await glob('**/*', {
63
65
  cwd: repoPath,
64
66
  nodir: true,
65
67
  dot: false,
66
68
  ignore: ignoreFilter,
67
- });
69
+ // glob treats unreadable directories as empty. Required metadata must
70
+ // not silently lose declarations, so retain the first enumeration error.
71
+ fs: options.onPathsDiscovered === undefined
72
+ ? undefined
73
+ : {
74
+ readdir(directory, readOptions, callback) {
75
+ readdir(directory, readOptions, (error, entries) => {
76
+ if (error)
77
+ enumerationError ??= error;
78
+ callback(error, entries);
79
+ });
80
+ },
81
+ },
82
+ })).map((filePath) => filePath.replace(/\\/g, '/'));
83
+ if (enumerationError !== undefined)
84
+ throw enumerationError;
85
+ await options.onPathsDiscovered?.(filtered);
68
86
  const entries = [];
69
87
  let processed = 0;
70
88
  let skippedLarge = 0;
@@ -76,10 +94,10 @@ export const walkRepositoryPaths = async (repoPath, onProgress, options = {}) =>
76
94
  const stat = await fs.stat(fullPath);
77
95
  if (stat.size > maxFileSizeBytes) {
78
96
  skippedLarge++;
79
- skippedLargePaths.push(relativePath.replace(/\\/g, '/'));
97
+ skippedLargePaths.push(relativePath);
80
98
  return null;
81
99
  }
82
- return { path: relativePath.replace(/\\/g, '/'), size: stat.size };
100
+ return { path: relativePath, size: stat.size };
83
101
  }));
84
102
  for (const result of results) {
85
103
  processed++;
@@ -1,49 +1,21 @@
1
- import type { Dirent } from 'node:fs';
2
1
  export interface DartPackageConfig {
3
2
  readonly packages: ReadonlyMap<string, string>;
4
3
  readonly manifestsByName: ReadonlyMap<string, readonly string[]>;
5
4
  }
6
5
  export interface DartPackageConfigOptions {
7
- /** Test seam. Production calls omit it and use the module directory limit. */
8
- readonly directoryLimit?: number;
9
- /** Test seam. Production calls omit it and use the open-directory cap. */
10
- readonly directoryDepthLimit?: number;
11
- /** Test seam. Production calls omit it and use the per-directory entry cap. */
12
- readonly directoryEntryLimit?: number;
13
- /**
14
- * Test seam. Production calls omit it. Invoked after the directory inode
15
- * is listed and before its entries are opened.
16
- */
17
- readonly beforeEntryOpen?: (relativePath: string) => void | Promise<void>;
18
- /**
19
- * Test seam. Production calls omit it. Invoked after the directory is
20
- * opened and before it is listed.
21
- */
22
- readonly beforeDirectoryList?: (relativePath: string) => void | Promise<void>;
6
+ /** Test seam. Production calls use the manifest-count limit. */
7
+ readonly manifestLimit?: number;
23
8
  }
24
- /** Linux anchors child opens. macOS verifies them. Every other platform does neither. */
25
- export declare function pubspecWalkAnchored(): boolean;
26
- export declare function directoryOpenFlags(): number;
27
9
  /**
28
- * Path that lists the directory inode already open on `fd`.
29
- * Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on
30
- * `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the
31
- * walker lists the lexical path and verifies the pinned chain around it.
32
- * Every other platform returns null and is not walked.
10
+ * Capture declarations from the shared scan, before source stat/size filtering.
11
+ * No directory enumeration occurs here. Every enumerated regular manifest must
12
+ * be captured successfully before this config can be published.
13
+ *
14
+ * Acquisition assumes a stable, trusted workspace, like the source-file reader.
15
+ * Static symlinks/junctions are excluded and observed file changes are rejected;
16
+ * these checks do not provide a sandbox or an atomic view of a mutating tree.
17
+ * Once returned, resolution uses only this compact map, on every operating system.
33
18
  */
34
- export declare function descriptorDirectoryPath(fd: number): string | null;
35
- /**
36
- * One entry of the directory inode open on `fd`.
37
- * Linux looks up `/proc/self/fd/N/<name>` in that inode. macOS `/dev/fd/N/<name>`
38
- * does not resolve a child, so this returns null and the walker verifies the
39
- * pinned parent chain instead. Every other platform returns null and is not walked.
40
- */
41
- export declare function descriptorEntryPath(fd: number, name: string): string | null;
42
- /**
43
- * Open `directory` without following a final symlink, then list that inode.
44
- * A path swapped for a symlink after the parent listing fails this open
45
- * (`ENOTDIR` / `ELOOP`) instead of being traversed.
46
- */
47
- export declare function readDirectoryNoFollow(directory: string): Promise<Dirent[]>;
48
- /** Discover only in-repository packages; never follow dependency paths or symlinks. */
19
+ export declare function captureDartPackageConfig(repoPath: string, filePaths: readonly string[], options?: DartPackageConfigOptions): Promise<DartPackageConfig>;
20
+ /** Standalone callers use the same scanner/capture boundary as the pipeline. */
49
21
  export declare function loadDartPackageConfig(repoPath: string, options?: DartPackageConfigOptions): Promise<DartPackageConfig>;
@@ -1,36 +1,46 @@
1
- import { constants, lstat, open, opendir } from 'node:fs/promises';
1
+ import { constants, lstat, open } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { JSON_SCHEMA, load } from 'js-yaml';
4
- import { createWatchIgnorePredicate } from '../../../../config/ignore-service.js';
4
+ import { loadIgnoreRules } from '../../../../config/ignore-service.js';
5
5
  import { logger } from '../../../logger.js';
6
+ import { walkRepositoryPaths } from '../../filesystem-walker.js';
6
7
  import { getMaxFileSizeBytes } from '../../utils/max-file-size.js';
7
- /** An incomplete walk cannot prove package names are unique. */
8
- const DART_PUBSPEC_DIRECTORY_LIMIT = 20_000;
9
- /**
10
- * Directory descriptors one walk may hold at once. The visit budget is far
11
- * above a process file-descriptor limit, so a deep chain is refused before
12
- * the next open instead of failing later with EMFILE.
13
- */
14
- const DART_PUBSPEC_OPEN_DIRECTORY_LIMIT = 64;
15
- /**
16
- * Names read from one directory. `readdir` would retain every entry before the
17
- * visit budget can run, so the walk counts names as it reads and stops there.
18
- */
19
- const DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT = 100_000;
20
- /**
21
- * Read at most `maxManifestSize` bytes from a descriptor already opened
22
- * with `O_NOFOLLOW`. A size check after the read rejects a file that grew
23
- * past the captured bytes, including past the cap. The caller closes nothing;
24
- * this function owns `handle`.
25
- */
26
- async function readManifestBounded(handle, maxManifestSize) {
8
+ const DART_PUBSPEC_MANIFEST_LIMIT = 20_000;
9
+ function warn(reason, relativePath) {
10
+ logger.warn({ reason, relativePath }, 'Dart pubspec discovery could not read a valid package declaration.');
11
+ }
12
+ function incomplete(reason, relativePath = '.') {
13
+ warn(reason, relativePath);
14
+ throw new Error(`Dart pubspec discovery failed (${reason}): ${relativePath}`);
15
+ }
16
+ function manifestIdentity(info) {
17
+ return `${info.dev}:${info.ino}:${info.mode}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
18
+ }
19
+ /** Open once, validate and read that descriptor. Null excludes a symbolic link. */
20
+ async function readManifestBounded(absolute, maxManifestSize) {
21
+ let handle;
22
+ try {
23
+ handle = await open(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
24
+ }
25
+ catch {
26
+ // O_NOFOLLOW rejects leaf symlinks on POSIX. Keep static links excluded,
27
+ // including dangling links, without retrying the pathname open.
28
+ if ((await lstat(absolute).catch(() => null))?.isSymbolicLink())
29
+ return null;
30
+ return { ok: false, reason: 'read-pubspec' };
31
+ }
27
32
  try {
28
- const info = await handle.stat();
29
- if (!info.isFile())
33
+ const before = await handle.stat({ bigint: true });
34
+ const current = await lstat(absolute, { bigint: true });
35
+ // Also exclude links before reading on platforms without O_NOFOLLOW.
36
+ if (current.isSymbolicLink())
37
+ return null;
38
+ if (!before.isFile() || manifestIdentity(before) !== manifestIdentity(current)) {
30
39
  return { ok: false, reason: 'read-pubspec' };
31
- if (info.size > maxManifestSize)
40
+ }
41
+ if (before.size > BigInt(maxManifestSize))
32
42
  return { ok: false, reason: 'manifest-size' };
33
- const toRead = Math.min(maxManifestSize + 1, info.size + 1);
43
+ const toRead = Math.min(maxManifestSize + 1, Number(before.size) + 1);
34
44
  const buffer = Buffer.allocUnsafe(toRead);
35
45
  let bytesRead = 0;
36
46
  while (bytesRead < toRead) {
@@ -41,13 +51,12 @@ async function readManifestBounded(handle, maxManifestSize) {
41
51
  }
42
52
  if (bytesRead > maxManifestSize)
43
53
  return { ok: false, reason: 'manifest-size' };
44
- const after = await handle.stat();
45
- if (!after.isFile())
46
- return { ok: false, reason: 'read-pubspec' };
47
- if (after.size > maxManifestSize)
54
+ const after = await handle.stat({ bigint: true });
55
+ if (after.size > BigInt(maxManifestSize))
48
56
  return { ok: false, reason: 'manifest-size' };
49
- if (after.size !== bytesRead)
57
+ if (manifestIdentity(after) !== manifestIdentity(before) || after.size !== BigInt(bytesRead)) {
50
58
  return { ok: false, reason: 'read-pubspec' };
59
+ }
51
60
  return { ok: true, content: buffer.subarray(0, bytesRead).toString('utf8') };
52
61
  }
53
62
  catch {
@@ -57,371 +66,122 @@ async function readManifestBounded(handle, maxManifestSize) {
57
66
  await handle.close();
58
67
  }
59
68
  }
60
- function requireNoFollowFlag() {
61
- const noFollow = constants.O_NOFOLLOW;
62
- if (typeof noFollow !== 'number' || noFollow === 0) {
63
- throw Object.assign(new Error('O_NOFOLLOW is unavailable'), { code: 'ENOTSUP' });
64
- }
65
- return noFollow;
66
- }
67
- /** Linux anchors child opens. macOS verifies them. Every other platform does neither. */
68
- export function pubspecWalkAnchored() {
69
- const noFollow = constants.O_NOFOLLOW;
70
- return ((process.platform === 'linux' || process.platform === 'darwin') &&
71
- typeof noFollow === 'number' &&
72
- noFollow !== 0);
73
- }
74
- export function directoryOpenFlags() {
75
- let flags = constants.O_RDONLY | requireNoFollowFlag();
76
- if (typeof constants.O_DIRECTORY === 'number')
77
- flags |= constants.O_DIRECTORY;
78
- return flags;
79
- }
80
- /**
81
- * Read-only, no-follow, and non-blocking. `O_NONBLOCK` does not change a
82
- * regular-file read. Without it, a FIFO blocks inside `open` until a writer
83
- * connects, so the later file-type check never runs.
84
- */
85
- function fileOpenFlags() {
86
- const nonBlock = constants.O_NONBLOCK;
87
- if (typeof nonBlock !== 'number' || nonBlock === 0) {
88
- throw Object.assign(new Error('O_NONBLOCK is unavailable'), { code: 'ENOTSUP' });
89
- }
90
- return constants.O_RDONLY | requireNoFollowFlag() | nonBlock;
91
- }
92
- function directoryIdentity(stat) {
93
- return `${stat.dev}:${stat.ino}:${stat.mode}`;
94
- }
95
- /**
96
- * Path that lists the directory inode already open on `fd`.
97
- * Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on
98
- * `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the
99
- * walker lists the lexical path and verifies the pinned chain around it.
100
- * Every other platform returns null and is not walked.
101
- */
102
- export function descriptorDirectoryPath(fd) {
103
- if (process.platform === 'linux')
104
- return `/proc/self/fd/${fd}`;
105
- return null;
106
- }
107
- /**
108
- * One entry of the directory inode open on `fd`.
109
- * Linux looks up `/proc/self/fd/N/<name>` in that inode. macOS `/dev/fd/N/<name>`
110
- * does not resolve a child, so this returns null and the walker verifies the
111
- * pinned parent chain instead. Every other platform returns null and is not walked.
112
- */
113
- export function descriptorEntryPath(fd, name) {
114
- if (process.platform !== 'linux')
115
- return null;
116
- if (!isSingleDirectoryEntry(name))
117
- return null;
118
- return `/proc/self/fd/${fd}/${name}`;
119
- }
120
- function isSingleDirectoryEntry(name) {
121
- return (name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\0'));
122
- }
123
- async function openVerifiedDirectory(directory) {
124
- const handle = await open(directory, directoryOpenFlags());
125
- try {
126
- const info = await handle.stat({ bigint: true });
127
- if (!info.isDirectory()) {
128
- throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
129
- }
130
- return { handle, identity: directoryIdentity(info) };
131
- }
132
- catch (error) {
133
- await handle.close();
134
- throw error;
135
- }
136
- }
137
- /** Re-stat each pinned directory and its path. A replaced inode or a symlink fails the walk. */
138
- async function assertPinnedChain(frames) {
139
- for (const frame of frames) {
140
- const pinned = await frame.handle.stat({ bigint: true });
141
- if (!pinned.isDirectory() || directoryIdentity(pinned) !== frame.identity) {
142
- throw Object.assign(new Error('parent descriptor changed'), { code: 'ELOOP' });
143
- }
144
- let lexical;
145
- try {
146
- lexical = await lstat(frame.absolute, { bigint: true });
147
- }
148
- catch {
149
- throw Object.assign(new Error('parent path changed'), { code: 'ELOOP' });
150
- }
151
- if (lexical.isSymbolicLink() ||
152
- !lexical.isDirectory() ||
153
- directoryIdentity(lexical) !== frame.identity) {
154
- throw Object.assign(new Error('parent path changed'), { code: 'ELOOP' });
155
- }
156
- }
157
- }
158
- /**
159
- * macOS has no descriptor-relative child lookup. Re-check the pinned parents,
160
- * open the child with O_NOFOLLOW, then re-check the parents. The opened
161
- * descriptor is the only child metadata consulted.
162
- */
163
- async function openLexicalDirectory(frames, lexicalPath) {
164
- await assertPinnedChain(frames);
165
- const opened = await openVerifiedDirectory(lexicalPath);
69
+ /** Package identity is a pure function of captured manifest text. */
70
+ function packageName(content, manifestPath) {
166
71
  try {
167
- await assertPinnedChain(frames);
168
- return opened;
72
+ const manifest = load(content, { schema: JSON_SCHEMA });
73
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest))
74
+ return null;
75
+ const name = manifest.name;
76
+ return typeof name === 'string' && /^[a-z_][a-z0-9_]*$/.test(name) ? name : null;
169
77
  }
170
- catch (error) {
171
- await opened.handle.close();
172
- throw error;
173
- }
174
- }
175
- async function openLexicalFile(frames, lexicalPath) {
176
- await assertPinnedChain(frames);
177
- const handle = await open(lexicalPath, fileOpenFlags());
178
- try {
179
- const opened = await handle.stat({ bigint: true });
180
- if (!opened.isFile()) {
181
- throw Object.assign(new Error('not a file'), { code: 'ELOOP' });
182
- }
183
- await assertPinnedChain(frames);
184
- return handle;
185
- }
186
- catch (error) {
187
- await handle.close();
188
- throw error;
78
+ catch {
79
+ warn('invalid-yaml', manifestPath);
80
+ return null;
189
81
  }
190
82
  }
191
- async function openChildDirectory(frames, parentFd, name, lexicalPath) {
192
- const anchored = descriptorEntryPath(parentFd, name);
193
- if (anchored !== null)
194
- return openVerifiedDirectory(anchored);
195
- if (process.platform === 'darwin')
196
- return openLexicalDirectory(frames, lexicalPath);
197
- throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' });
198
- }
199
- async function openChildFile(frames, parentFd, name, lexicalPath) {
200
- const anchored = descriptorEntryPath(parentFd, name);
201
- if (anchored !== null)
202
- return open(anchored, fileOpenFlags());
203
- if (process.platform === 'darwin')
204
- return openLexicalFile(frames, lexicalPath);
205
- throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' });
206
- }
207
83
  /**
208
- * List the directory open on the last frame of `frames`. Linux lists the
209
- * pinned inode through its descriptor. macOS re-checks the pinned chain, lists
210
- * the lexical path, then re-checks the chain, so a path replaced around the
211
- * listing fails the walk instead of being listed.
84
+ * Capture declarations from the shared scan, before source stat/size filtering.
85
+ * No directory enumeration occurs here. Every enumerated regular manifest must
86
+ * be captured successfully before this config can be published.
87
+ *
88
+ * Acquisition assumes a stable, trusted workspace, like the source-file reader.
89
+ * Static symlinks/junctions are excluded and observed file changes are rejected;
90
+ * these checks do not provide a sandbox or an atomic view of a mutating tree.
91
+ * Once returned, resolution uses only this compact map, on every operating system.
212
92
  */
213
- async function listOpenedDirectory(frames, entryLimit, beforeList) {
214
- const frame = frames[frames.length - 1];
215
- if (frame === undefined) {
216
- throw Object.assign(new Error('no directory to list'), { code: 'EINVAL' });
217
- }
218
- const anchored = descriptorDirectoryPath(frame.handle.fd);
219
- if (anchored === null && process.platform !== 'darwin') {
220
- throw Object.assign(new Error('no descriptor listing'), { code: 'ENOTSUP' });
221
- }
222
- if (anchored === null)
223
- await assertPinnedChain(frames);
224
- if (beforeList)
225
- await beforeList();
226
- const entries = await readDirectoryBounded(anchored ?? frame.absolute, entryLimit);
227
- if (anchored === null)
228
- await assertPinnedChain(frames);
229
- return entries;
230
- }
231
- async function readDirectoryBounded(listing, entryLimit) {
232
- const dir = await opendir(listing);
233
- const entries = [];
234
- try {
235
- let count = 0;
236
- while (true) {
237
- const entry = await dir.read();
238
- if (entry === null)
239
- break;
240
- count += 1;
241
- if (count > entryLimit) {
242
- throw Object.assign(new Error('directory entry limit'), { code: 'E2BIG' });
243
- }
244
- entries.push(entry);
93
+ export async function captureDartPackageConfig(repoPath, filePaths, options) {
94
+ const manifests = new Set();
95
+ const limit = options?.manifestLimit ?? DART_PUBSPEC_MANIFEST_LIMIT;
96
+ for (const filePath of filePaths) {
97
+ if (filePath !== 'pubspec.yaml' && !filePath.endsWith('/pubspec.yaml'))
98
+ continue;
99
+ if (/[\\\0]/.test(filePath) ||
100
+ /^[a-zA-Z]:/.test(filePath) ||
101
+ filePath.split('/').some((part) => part === '' || part === '.' || part === '..')) {
102
+ return incomplete('manifest-path');
245
103
  }
104
+ manifests.add(filePath);
105
+ if (manifests.size > limit)
106
+ return incomplete('manifest-limit');
246
107
  }
247
- finally {
248
- await dir.close().catch(() => undefined);
249
- }
250
- return entries;
251
- }
252
- /**
253
- * Open `directory` without following a final symlink, then list that inode.
254
- * A path swapped for a symlink after the parent listing fails this open
255
- * (`ENOTDIR` / `ELOOP`) instead of being traversed.
256
- */
257
- export async function readDirectoryNoFollow(directory) {
258
- const opened = await openVerifiedDirectory(directory);
259
- const frame = {
260
- relative: '',
261
- absolute: directory,
262
- handle: opened.handle,
263
- identity: opened.identity,
264
- entries: [],
265
- next: 0,
266
- };
267
- try {
268
- return await listOpenedDirectory([frame], DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT);
269
- }
270
- finally {
271
- await opened.handle.close();
272
- }
273
- }
274
- /** Discover only in-repository packages; never follow dependency paths or symlinks. */
275
- export async function loadDartPackageConfig(repoPath, options) {
276
- const warn = (reason, relativePath = '.') => {
277
- logger.warn({ reason, relativePath }, 'Dart pubspec discovery could not read a valid package declaration.');
278
- };
279
- const incomplete = (reason, relativePath = '.') => {
280
- warn(reason, relativePath);
281
- throw new Error(`Dart pubspec discovery failed (${reason}): ${relativePath}`);
282
- };
283
- if (!pubspecWalkAnchored()) {
284
- warn('nofollow-anchor');
285
- return { packages: new Map(), manifestsByName: new Map() };
286
- }
287
- let isIgnored;
288
108
  try {
289
- isIgnored = await createWatchIgnorePredicate(repoPath);
109
+ await loadIgnoreRules(repoPath, { strictRepoControlFiles: true, noGlobalIgnore: true });
290
110
  }
291
111
  catch {
292
- return incomplete('ignore-rules');
112
+ return incomplete('scan-inputs');
293
113
  }
294
114
  const packages = new Map();
295
115
  const manifestsByName = new Map();
116
+ if (manifests.size === 0)
117
+ return { packages, manifestsByName };
118
+ // Cache static parent checks once per directory, not once per import/file.
119
+ // This cache belongs only to this capture and never survives another analysis.
120
+ const directories = new Map();
121
+ const root = await lstat(repoPath).catch(() => null);
122
+ if (root === null || !root.isDirectory() || root.isSymbolicLink()) {
123
+ return incomplete('read-directory');
124
+ }
296
125
  const maxManifestSize = Math.min(1024 * 1024, getMaxFileSizeBytes());
297
- const directoryLimit = options?.directoryLimit ?? DART_PUBSPEC_DIRECTORY_LIMIT;
298
- const directoryDepthLimit = options?.directoryDepthLimit ?? DART_PUBSPEC_OPEN_DIRECTORY_LIMIT;
299
- const directoryEntryLimit = options?.directoryEntryLimit ?? DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT;
300
- const ambiguous = new Set();
301
- const stack = [];
302
- let visited = 0;
303
- const closeStack = async () => {
304
- const frames = stack.splice(0);
305
- await Promise.all(frames.map((frame) => frame.handle.close().catch(() => undefined)));
306
- };
307
- const fillFrame = async (frame) => {
308
- if (++visited > directoryLimit)
309
- return incomplete('directory-limit', frame.relative || '.');
310
- try {
311
- const beforeList = options?.beforeDirectoryList;
312
- frame.entries = await listOpenedDirectory(stack, directoryEntryLimit, beforeList ? () => beforeList(frame.relative) : undefined);
313
- }
314
- catch (error) {
315
- const reason = error.code === 'E2BIG' ? 'directory-entries' : 'read-directory';
316
- return incomplete(reason, frame.relative || '.');
317
- }
318
- if (options?.beforeEntryOpen)
319
- await options.beforeEntryOpen(frame.relative);
320
- };
321
- try {
322
- let rootOpened;
323
- try {
324
- rootOpened = await openVerifiedDirectory(repoPath);
325
- }
326
- catch {
327
- return incomplete('read-directory', '.');
328
- }
329
- const root = {
330
- relative: '',
331
- absolute: repoPath,
332
- handle: rootOpened.handle,
333
- identity: rootOpened.identity,
334
- entries: [],
335
- next: 0,
336
- };
337
- stack.push(root);
338
- await fillFrame(root);
339
- while (stack.length > 0) {
340
- const frame = stack[stack.length - 1];
341
- if (frame === undefined)
342
- break;
343
- if (frame.next >= frame.entries.length) {
344
- stack.pop();
345
- await frame.handle.close().catch(() => undefined);
346
- continue;
347
- }
348
- const entry = frame.entries[frame.next];
349
- frame.next += 1;
350
- if (entry === undefined || !isSingleDirectoryEntry(entry.name) || entry.isSymbolicLink()) {
351
- continue;
352
- }
353
- const childRelative = frame.relative ? `${frame.relative}/${entry.name}` : entry.name;
354
- const entryPath = path.join(repoPath, childRelative);
355
- if (entry.isDirectory()) {
356
- if (entry.name.startsWith('.') || isIgnored(entryPath, true))
357
- continue;
358
- if (stack.length >= directoryDepthLimit) {
359
- return incomplete('directory-depth', childRelative);
126
+ for (const manifestPath of manifests) {
127
+ const parts = manifestPath.split('/');
128
+ let parent = '';
129
+ let linked = false;
130
+ for (const part of parts.slice(0, -1)) {
131
+ parent = parent ? `${parent}/${part}` : part;
132
+ let accepted = directories.get(parent);
133
+ if (accepted === undefined) {
134
+ const info = await lstat(path.join(repoPath, parent)).catch(() => null);
135
+ if (info === null || (!info.isDirectory() && !info.isSymbolicLink())) {
136
+ return incomplete('read-directory', parent);
360
137
  }
361
- let childOpened;
362
- try {
363
- childOpened = await openChildDirectory(stack, frame.handle.fd, entry.name, entryPath);
364
- }
365
- catch {
366
- return incomplete('read-directory', childRelative);
367
- }
368
- const child = {
369
- relative: childRelative,
370
- absolute: entryPath,
371
- handle: childOpened.handle,
372
- identity: childOpened.identity,
373
- entries: [],
374
- next: 0,
375
- };
376
- stack.push(child);
377
- await fillFrame(child);
138
+ accepted = !info.isSymbolicLink();
139
+ directories.set(parent, accepted);
378
140
  }
379
- else if (entry.isFile() && entry.name === 'pubspec.yaml' && !isIgnored(entryPath, false)) {
380
- const manifestPath = frame.relative ? `${frame.relative}/pubspec.yaml` : 'pubspec.yaml';
381
- let manifestHandle;
382
- try {
383
- manifestHandle = await openChildFile(stack, frame.handle.fd, entry.name, entryPath);
384
- }
385
- catch {
386
- return incomplete('read-pubspec', manifestPath);
387
- }
388
- const read = await readManifestBounded(manifestHandle, maxManifestSize);
389
- if (read.ok === false)
390
- return incomplete(read.reason, manifestPath);
391
- try {
392
- const manifest = load(read.content, {
393
- schema: JSON_SCHEMA,
394
- });
395
- if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest))
396
- continue;
397
- const name = manifest.name;
398
- if (typeof name !== 'string' || !/^[a-z_][a-z0-9_]*$/.test(name))
399
- continue;
400
- const manifests = manifestsByName.get(name) ?? [];
401
- manifests.push(manifestPath);
402
- // Two deterministic witnesses suffice to prove ambiguity. Retaining
403
- // more would create unbounded duplicate-package dependency fanout.
404
- manifests.sort();
405
- if (manifests.length > 2)
406
- manifests.length = 2;
407
- manifestsByName.set(name, manifests);
408
- if (packages.has(name) || ambiguous.has(name)) {
409
- packages.delete(name);
410
- ambiguous.add(name);
411
- }
412
- else {
413
- packages.set(name, frame.relative ? `${frame.relative}/lib` : 'lib');
414
- }
415
- }
416
- catch {
417
- // Invalid YAML cannot declare a package. Other valid packages remain usable.
418
- warn('invalid-yaml', manifestPath);
419
- }
141
+ if (!accepted) {
142
+ linked = true;
143
+ break;
420
144
  }
421
145
  }
422
- }
423
- finally {
424
- await closeStack();
146
+ if (linked)
147
+ continue;
148
+ const read = await readManifestBounded(path.join(repoPath, manifestPath), maxManifestSize);
149
+ if (read === null)
150
+ continue;
151
+ if (read.ok === false)
152
+ return incomplete(read.reason, manifestPath);
153
+ const name = packageName(read.content, manifestPath);
154
+ if (name === null)
155
+ continue;
156
+ const witnesses = manifestsByName.get(name) ?? [];
157
+ witnesses.push(manifestPath);
158
+ witnesses.sort();
159
+ // Two deterministic witnesses prove ambiguity without duplicate fanout.
160
+ if (witnesses.length > 2)
161
+ witnesses.length = 2;
162
+ manifestsByName.set(name, witnesses);
163
+ if (witnesses.length > 1)
164
+ packages.delete(name);
165
+ else
166
+ packages.set(name, parent ? `${parent}/lib` : 'lib');
425
167
  }
426
168
  return { packages, manifestsByName };
427
169
  }
170
+ /** Standalone callers use the same scanner/capture boundary as the pipeline. */
171
+ export async function loadDartPackageConfig(repoPath, options) {
172
+ let captured;
173
+ try {
174
+ await walkRepositoryPaths(repoPath, undefined, {
175
+ onPathsDiscovered: async (paths) => {
176
+ captured = await captureDartPackageConfig(repoPath, paths, options);
177
+ },
178
+ });
179
+ }
180
+ catch (error) {
181
+ if (error instanceof Error && error.message.startsWith('Dart pubspec discovery failed')) {
182
+ throw error;
183
+ }
184
+ return incomplete('scan-inputs');
185
+ }
186
+ return captured ?? incomplete('scan-inputs');
187
+ }
@@ -36,7 +36,7 @@ import { decodeMarker } from '../../utils/heritage-marker.js';
36
36
  import { typeApplicationArguments } from '../../utils/template-arguments.js';
37
37
  import { expandDartWildcardNames } from './expand-wildcards.js';
38
38
  import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
39
- import { loadDartPackageConfig } from './package-config.js';
39
+ import { captureDartPackageConfig, loadDartPackageConfig } from './package-config.js';
40
40
  import { emitDartPackageDependencies } from './package-dependencies.js';
41
41
  /**
42
42
  * Resolve a class simple name to a graph id with FILE AFFINITY: prefer a
@@ -182,6 +182,7 @@ export const dartScopeResolver = {
182
182
  languageProvider: dartProvider,
183
183
  importEdgeReason: 'dart-scope: import',
184
184
  loadResolutionConfig: loadDartPackageConfig,
185
+ captureResolutionConfig: captureDartPackageConfig,
185
186
  // Package dependencies use cached ParsedFile facts, never raw source text.
186
187
  postExtractSourceTextPolicy: 'uncached-files',
187
188
  emitPostResolutionEdges: (graph, parsedFiles, _nodeLookup, _indexes, ctx) => emitDartPackageDependencies(graph, parsedFiles, ctx.resolutionConfig),
@@ -62,7 +62,7 @@ export declare function extractCrossFileRoutes(allPaths: string[], repoPath: str
62
62
  * Import, call, and inheritance edges are emitted by the scope-resolution
63
63
  * phase, not here (RING4-1 #942 / RING4-2 #943 removed the legacy passes).
64
64
  */
65
- export declare function runChunkedParseAndResolve(graph: KnowledgeGraph, scannedFiles: ScannedFile[], allPaths: string[], totalFiles: number, repoPath: string, pipelineStart: number, onProgress: ProgressFn, options?: PipelineOptions): Promise<{
65
+ export declare function runChunkedParseAndResolve(graph: KnowledgeGraph, scannedFiles: ScannedFile[], allPaths: string[], totalFiles: number, repoPath: string, pipelineStart: number, onProgress: ProgressFn, options?: PipelineOptions, capturedResolutionConfigs?: ReadonlyMap<SupportedLanguages, unknown>): Promise<{
66
66
  exportedTypeMap: ExportedTypeMap;
67
67
  allFetchCalls: ExtractedFetchCall[];
68
68
  allFetchWrapperDefs: FetchWrapperDef[];
@@ -328,7 +328,7 @@ export function handleWorkerStartupFailure(err) {
328
328
  * Import, call, and inheritance edges are emitted by the scope-resolution
329
329
  * phase, not here (RING4-1 #942 / RING4-2 #943 removed the legacy passes).
330
330
  */
331
- export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, totalFiles, repoPath, pipelineStart, onProgress, options) {
331
+ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, totalFiles, repoPath, pipelineStart, onProgress, options, capturedResolutionConfigs) {
332
332
  const model = createSemanticModel();
333
333
  const symbolTable = model.symbols;
334
334
  const contentClassifiedPaths = scannedFiles
@@ -1487,9 +1487,11 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1487
1487
  if (language === null || routeResolutionConfigs.has(language))
1488
1488
  continue;
1489
1489
  const resolver = SCOPE_RESOLVERS.get(language);
1490
- routeResolutionConfigs.set(language, resolver?.loadResolutionConfig === undefined
1491
- ? undefined
1492
- : await resolver.loadResolutionConfig(repoPath));
1490
+ routeResolutionConfigs.set(language, capturedResolutionConfigs?.has(language)
1491
+ ? capturedResolutionConfigs.get(language)
1492
+ : resolver?.loadResolutionConfig === undefined
1493
+ ? undefined
1494
+ : await resolver.loadResolutionConfig(repoPath));
1493
1495
  }
1494
1496
  let routeResolutionFiles = allParsedFiles;
1495
1497
  const resolveRouteImportTargets = (parsedImport, fromFile) => {
@@ -26,8 +26,8 @@ export const parsePhase = {
26
26
  // `mapCobolToGraph` scans CALLS edges and removes the unresolved ones — and
27
27
  // nothing before parse produces bulk edge volume anyway.
28
28
  ctx.graphEmit?.beginStreaming();
29
- const { scannedFiles, allPaths, allPathSet, totalFiles } = getPhaseOutput(deps, 'structure');
30
- const result = await runChunkedParseAndResolve(ctx.graph, scannedFiles, allPaths, totalFiles, ctx.repoPath, ctx.pipelineStart, ctx.onProgress, ctx.options);
29
+ const { scannedFiles, allPaths, allPathSet, totalFiles, resolutionConfigs } = getPhaseOutput(deps, 'structure');
30
+ const result = await runChunkedParseAndResolve(ctx.graph, scannedFiles, allPaths, totalFiles, ctx.repoPath, ctx.pipelineStart, ctx.onProgress, ctx.options, resolutionConfigs);
31
31
  return {
32
32
  ...result,
33
33
  allPaths,
@@ -2,7 +2,7 @@
2
2
  * Phase: scan
3
3
  *
4
4
  * Walks the repository filesystem and collects file paths + sizes.
5
- * Does NOT read file contents — that happens in downstream phases.
5
+ * Captures required provider metadata; source contents are read downstream.
6
6
  *
7
7
  * @deps (none — this is the pipeline root)
8
8
  * @reads repoPath (filesystem)
@@ -10,6 +10,7 @@
10
10
  * @output ScannedFile[], allPaths[], totalFiles
11
11
  */
12
12
  import type { PipelinePhase } from './types.js';
13
+ import { type SupportedLanguages } from '../../../_shared/index.js';
13
14
  export interface ScanOutput {
14
15
  scannedFiles: {
15
16
  path: string;
@@ -17,5 +18,6 @@ export interface ScanOutput {
17
18
  }[];
18
19
  allPaths: string[];
19
20
  totalFiles: number;
21
+ resolutionConfigs?: ReadonlyMap<SupportedLanguages, unknown>;
20
22
  }
21
23
  export declare const scanPhase: PipelinePhase<ScanOutput>;
@@ -2,7 +2,7 @@
2
2
  * Phase: scan
3
3
  *
4
4
  * Walks the repository filesystem and collects file paths + sizes.
5
- * Does NOT read file contents — that happens in downstream phases.
5
+ * Captures required provider metadata; source contents are read downstream.
6
6
  *
7
7
  * @deps (none — this is the pipeline root)
8
8
  * @reads repoPath (filesystem)
@@ -12,6 +12,8 @@
12
12
  import { walkRepositoryPaths } from '../filesystem-walker.js';
13
13
  import fs from 'node:fs/promises';
14
14
  import path from 'node:path';
15
+ import { getLanguageFromFilename } from '../../../_shared/index.js';
16
+ import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
15
17
  const SPRING_ACTUATOR_ENDPOINT_FILES = new Set([
16
18
  'mappings.json',
17
19
  'beans.json',
@@ -97,6 +99,7 @@ export const scanPhase = {
97
99
  ...(ctx.options?.springActuatorScanExclusions ?? []),
98
100
  ]);
99
101
  let scannedFiles;
102
+ const resolutionConfigs = new Map();
100
103
  try {
101
104
  scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
102
105
  const scanProgress = Math.round((current / total) * 15);
@@ -112,6 +115,16 @@ export const scanPhase = {
112
115
  nodesCreated: ctx.graph.nodeCount,
113
116
  },
114
117
  });
118
+ }, {
119
+ onPathsDiscovered: async (paths) => {
120
+ const inputs = paths.filter((filePath) => !matchesActuatorExclusion(canonicalRepoPath, filePath, actuatorExclusions));
121
+ const languages = new Set(inputs.map(getLanguageFromFilename));
122
+ for (const [language, provider] of SCOPE_RESOLVERS) {
123
+ if (languages.has(language) && provider.captureResolutionConfig !== undefined) {
124
+ resolutionConfigs.set(language, await provider.captureResolutionConfig(ctx.repoPath, inputs));
125
+ }
126
+ }
127
+ },
115
128
  });
116
129
  }
117
130
  catch (err) {
@@ -134,6 +147,6 @@ export const scanPhase = {
134
147
  message: 'Repository scanned successfully',
135
148
  stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
136
149
  });
137
- return { scannedFiles, allPaths, totalFiles };
150
+ return { scannedFiles, allPaths, totalFiles, resolutionConfigs };
138
151
  },
139
152
  };
@@ -8,6 +8,7 @@
8
8
  * @writes graph (File, Folder nodes + CONTAINS edges)
9
9
  */
10
10
  import type { PipelinePhase } from './types.js';
11
+ import type { SupportedLanguages } from '../../../_shared/index.js';
11
12
  /** Structure phase produces no additional data — it writes directly to the graph. */
12
13
  export interface StructureOutput {
13
14
  /** Pass-through from scan for downstream phases. */
@@ -23,5 +24,6 @@ export interface StructureOutput {
23
24
  */
24
25
  allPathSet: ReadonlySet<string>;
25
26
  totalFiles: number;
27
+ resolutionConfigs?: ReadonlyMap<SupportedLanguages, unknown>;
26
28
  }
27
29
  export declare const structurePhase: PipelinePhase<StructureOutput>;
@@ -13,7 +13,7 @@ export const structurePhase = {
13
13
  name: 'structure',
14
14
  deps: ['scan'],
15
15
  async execute(ctx, deps) {
16
- const { scannedFiles, allPaths, totalFiles } = getPhaseOutput(deps, 'scan');
16
+ const { scannedFiles, allPaths, totalFiles, resolutionConfigs } = getPhaseOutput(deps, 'scan');
17
17
  ctx.onProgress({
18
18
  phase: 'structure',
19
19
  percent: 15,
@@ -30,6 +30,6 @@ export const structurePhase = {
30
30
  // Build the set once here so cobol, markdown, and cross-file propagation
31
31
  // can all reuse it instead of re-materializing `new Set(allPaths)` each.
32
32
  const allPathSet = new Set(allPaths);
33
- return { scannedFiles, allPaths, allPathSet, totalFiles };
33
+ return { scannedFiles, allPaths, allPathSet, totalFiles, resolutionConfigs };
34
34
  },
35
35
  };
@@ -425,6 +425,15 @@ export interface ScopeResolver {
425
425
  * `resolveImportTarget` casts it to the language's expected shape.
426
426
  */
427
427
  loadResolutionConfig?(repoPath: string): Promise<unknown> | unknown;
428
+ /**
429
+ * Capture metadata from the scan's complete, nonignored candidate paths,
430
+ * before unreadable/large source files are filtered out. Called once for a
431
+ * language present in the scan. The compact result is shared by every
432
+ * resolution pass instead of calling loadResolutionConfig again.
433
+ * Capture failures must throw; consumers must not mutate the result.
434
+ * This is a stable-workspace input boundary, not an atomic filesystem snapshot.
435
+ */
436
+ captureResolutionConfig?(repoPath: string, filePaths: readonly string[]): Promise<unknown> | unknown;
428
437
  /**
429
438
  * Per-scope binding-merge precedence. The shared finalize pass
430
439
  * collects bindings from multiple sources (local declarations,
@@ -79,7 +79,7 @@ export const scopeResolutionPhase = {
79
79
  deps: ['parse', 'crossFile', 'structure'],
80
80
  async execute(ctx, deps) {
81
81
  logHeapProbe('scopeResolution-enter');
82
- const { scannedFiles } = getPhaseOutput(deps, 'structure');
82
+ const { scannedFiles, resolutionConfigs } = getPhaseOutput(deps, 'structure');
83
83
  const parseOutput = getPhaseOutput(deps, 'parse');
84
84
  const { model, parsedFiles: workerParsedFiles, contentLanguageByPath } = parseOutput;
85
85
  const scopeExtractionFailures = new Set(parseOutput.scopeExtractionFailures);
@@ -257,9 +257,11 @@ export const scopeResolutionPhase = {
257
257
  // composer.json autoload, go.mod, ...). One I/O round trip per
258
258
  // workspace pass — cached implicitly by the result handed to
259
259
  // every `resolveImportTarget` call below.
260
- const resolutionConfig = provider.loadResolutionConfig !== undefined
261
- ? await provider.loadResolutionConfig(ctx.repoPath)
262
- : undefined;
260
+ const resolutionConfig = resolutionConfigs?.has(lang)
261
+ ? resolutionConfigs.get(lang)
262
+ : provider.loadResolutionConfig !== undefined
263
+ ? await provider.loadResolutionConfig(ctx.repoPath)
264
+ : undefined;
263
265
  // Some languages (e.g. Vue) expand their file universe beyond the
264
266
  // primary-language files via the `collectScopeContextPaths` hook.
265
267
  // The hook receives raw source contents of the primary files so it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.83",
3
+ "version": "1.6.13-rc.85",
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",
@@ -128,11 +128,11 @@
128
128
  "@types/express": "^5.0.6",
129
129
  "@types/node": "^26.0.0",
130
130
  "@types/proxy-addr": "^2.0.3",
131
- "@vitest/coverage-v8": "^4.0.18",
131
+ "@vitest/coverage-v8": "^5.0.0",
132
132
  "gitnexus-shared": "file:../gitnexus-shared",
133
133
  "tsx": "^4.0.0",
134
134
  "typescript": "^7.0.2",
135
- "vitest": "^4.0.18"
135
+ "vitest": "^5.0.0"
136
136
  },
137
137
  "overrides": {
138
138
  "adm-zip": ">=0.6.0",
@@ -311,7 +311,10 @@ const FILESYSTEM = [
311
311
  // The deletion-guard cases in this file require real Windows path semantics.
312
312
  'test/unit/canonicalize-path-long-path-prefix.test.ts',
313
313
  'test/unit/storage-resolver.test.ts',
314
+ // Dart package metadata is captured during the shared scan on every platform.
314
315
  'test/unit/dart-package-imports.test.ts',
316
+ // Verify the same capture is reused by native parsing and scope resolution.
317
+ 'test/integration/resolvers/dart.test.ts',
315
318
  // Cargo membership uses path normalization, descriptor validation, symlinks,
316
319
  // and Rust native parsing (including long Windows source strings).
317
320
  'test/unit/scope-resolution/rust-cargo-targets.test.ts',
@@ -1,10 +1,9 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
1
3
  import { JsonReporter, type TestRunEndReason } from 'vitest/node';
2
4
 
3
5
  /** Vitest's stock JSON omits unhandled errors, even when success is true. */
4
6
  export default class ExecutionReporter extends JsonReporter {
5
- private executionErrors = 0;
6
- private executionReason: TestRunEndReason = 'interrupted';
7
-
8
7
  constructor() {
9
8
  super({});
10
9
  }
@@ -14,20 +13,26 @@ export default class ExecutionReporter extends JsonReporter {
14
13
  errors: readonly unknown[] = [],
15
14
  reason: TestRunEndReason = 'interrupted',
16
15
  ): Promise<void> {
17
- this.executionErrors = errors.length;
18
- this.executionReason = reason;
16
+ const configuredOutput = this.ctx.config.outputFile;
17
+ const outputFile = resolve(
18
+ this.ctx.config.root,
19
+ (typeof configuredOutput === 'string' ? configuredOutput : configuredOutput?.json) ??
20
+ '.vitest/json/output.json',
21
+ );
22
+ // Vitest 5 writes JSON directly instead of calling writeReport. Use an
23
+ // explicit path so this reporter also works for the web suite on Vitest 4.
24
+ this.options.outputFile = outputFile;
19
25
  await super.onTestRunEnd(modules);
20
- }
21
-
22
- override async writeReport(json: string): Promise<void> {
23
- const report = JSON.parse(json);
24
- await super.writeReport(
26
+ const report = JSON.parse(await readFile(outputFile, 'utf8'));
27
+ await writeFile(
28
+ outputFile,
25
29
  JSON.stringify({
26
30
  ...report,
27
- success: report.success && this.executionErrors === 0 && this.executionReason === 'passed',
28
- executionErrors: this.executionErrors,
29
- executionReason: this.executionReason,
31
+ success: report.success && errors.length === 0 && reason === 'passed',
32
+ executionErrors: errors.length,
33
+ executionReason: reason,
30
34
  }),
35
+ 'utf8',
31
36
  );
32
37
  }
33
38
  }