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.
- package/dist/core/ingestion/filesystem-walker.d.ts +2 -0
- package/dist/core/ingestion/filesystem-walker.js +23 -5
- package/dist/core/ingestion/languages/dart/package-config.d.ts +12 -40
- package/dist/core/ingestion/languages/dart/package-config.js +137 -377
- package/dist/core/ingestion/languages/dart/scope-resolver.js +2 -1
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +1 -1
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +6 -4
- package/dist/core/ingestion/pipeline-phases/parse.js +2 -2
- package/dist/core/ingestion/pipeline-phases/scan.d.ts +3 -1
- package/dist/core/ingestion/pipeline-phases/scan.js +15 -2
- package/dist/core/ingestion/pipeline-phases/structure.d.ts +2 -0
- package/dist/core/ingestion/pipeline-phases/structure.js +2 -2
- package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +9 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -4
- package/package.json +3 -3
- package/scripts/cross-platform-tests.ts +3 -0
- package/scripts/execution-reporter.ts +18 -13
|
@@ -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 —
|
|
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
|
-
|
|
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
|
|
97
|
+
skippedLargePaths.push(relativePath);
|
|
80
98
|
return null;
|
|
81
99
|
}
|
|
82
|
-
return { path: relativePath
|
|
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
|
|
8
|
-
readonly
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
40
|
+
}
|
|
41
|
+
if (before.size > BigInt(maxManifestSize))
|
|
32
42
|
return { ok: false, reason: 'manifest-size' };
|
|
33
|
-
const toRead = Math.min(maxManifestSize + 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 (
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
109
|
+
await loadIgnoreRules(repoPath, { strictRepoControlFiles: true, noGlobalIgnore: true });
|
|
290
110
|
}
|
|
291
111
|
catch {
|
|
292
|
-
return incomplete('
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
424
|
-
await
|
|
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,
|
|
1491
|
-
?
|
|
1492
|
-
:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
261
|
-
?
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 &&
|
|
28
|
-
executionErrors:
|
|
29
|
-
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
|
}
|