@aaroncql/pim-agent 0.2.0 → 0.3.0

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.
@@ -0,0 +1,492 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import ignore, { type Ignore } from "ignore";
4
+
5
+ /**
6
+ * Max in-flight `readdir` syscalls.
7
+ */
8
+ const CONCURRENCY = 32;
9
+
10
+ /**
11
+ * Options that decide what the walk enumerates.
12
+ */
13
+ export type EnumerateOptions = {
14
+ /**
15
+ * Include dot-prefixed files/dirs such as `.env`, `.github`. Default false.
16
+ */
17
+ readonly includeDotfiles?: boolean;
18
+ /**
19
+ * Include gitignored / normally-ignored paths such as `node_modules`. Default false.
20
+ */
21
+ readonly includeIgnored?: boolean;
22
+ /**
23
+ * Emit directories as well as files, each marked with a trailing `/`. Default false.
24
+ */
25
+ readonly includeDirectories?: boolean;
26
+ };
27
+
28
+ type StackEntry = {
29
+ /**
30
+ * Absolute path of the directory.
31
+ */
32
+ abs: string;
33
+ /**
34
+ * Root-relative POSIX path of the directory ("" for root), no trailing slash.
35
+ */
36
+ rel: string;
37
+ /**
38
+ * Whether this directory lies within a git repository. When false, no
39
+ * `.gitignore` files are honored — matching git/fd, which treat ignore files
40
+ * as inert outside a repository.
41
+ */
42
+ inRepo: boolean;
43
+ /**
44
+ * Absolute path of the git repository root the ignore rules are anchored to.
45
+ * Paths are tested relative to this. Only meaningful when `inRepo`.
46
+ */
47
+ repoRootAbs: string;
48
+ /**
49
+ * Every gitignore pattern that applies to this subtree, ordered shallowest
50
+ * (repo root) to deepest, each already re-anchored to be relative to
51
+ * `repoRootAbs`. Held so a nested `.gitignore` can extend it without losing
52
+ * the ancestor rules. Empty when `inRepo` is false.
53
+ *
54
+ * Keeping all of a repo's rules in a single matcher (rather than one matcher
55
+ * per `.gitignore`) is what lets negations work across files: a `build/`
56
+ * exclusion at the repo root and a `!build/` re-inclusion in a nested
57
+ * `.gitignore` are only resolved correctly when evaluated together.
58
+ */
59
+ ignoreRules: string[];
60
+ /**
61
+ * Matcher built from `ignoreRules`; tests paths relative to `repoRootAbs`.
62
+ */
63
+ matcher: Ignore;
64
+ };
65
+
66
+ /** Reused for directories outside any repo, where no rules apply. */
67
+ const EMPTY_MATCHER = ignore();
68
+
69
+ /**
70
+ * Shared mutable state threaded through one `enumerate` walk.
71
+ */
72
+ type WalkContext = {
73
+ includeDotfiles: boolean;
74
+ includeDirectories: boolean;
75
+ useIgnore: boolean;
76
+ globalGitIgnore: string | undefined;
77
+ stack: StackEntry[];
78
+ result: string[];
79
+ };
80
+
81
+ async function readIgnoreFile(path: string): Promise<string | undefined> {
82
+ try {
83
+ return await Bun.file(path).text();
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ function globalGitIgnorePath(): string | undefined {
90
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
91
+ if (xdgConfigHome !== undefined && xdgConfigHome !== "") {
92
+ return join(xdgConfigHome, "git", "ignore");
93
+ }
94
+
95
+ const home = process.env.HOME;
96
+ if (home !== undefined && home !== "") {
97
+ return join(home, ".config", "git", "ignore");
98
+ }
99
+
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * Path of `absPath` relative to `baseAbs` (POSIX), or undefined if `absPath`
105
+ * is not within `baseAbs`. Returns "" when they are the same directory.
106
+ */
107
+ function relFromBase(absPath: string, baseAbs: string): string | undefined {
108
+ if (absPath === baseAbs) {
109
+ return "";
110
+ }
111
+
112
+ const prefix = baseAbs.endsWith("/") ? baseAbs : `${baseAbs}/`;
113
+ if (!absPath.startsWith(prefix)) {
114
+ return undefined;
115
+ }
116
+
117
+ return absPath.slice(prefix.length);
118
+ }
119
+
120
+ /**
121
+ * Verdict for one path against a repo's combined matcher. `ignored` is the net
122
+ * decision; `unignored` is true when a `!` negation rule was the last to match,
123
+ * i.e. the path is explicitly re-included. The two are distinct because a
124
+ * negation can re-include a path that would otherwise be hidden as a dotfile.
125
+ */
126
+ function ignoreVerdict(
127
+ matcher: Ignore,
128
+ repoRootAbs: string,
129
+ absPath: string,
130
+ isDirectory: boolean
131
+ ): { ignored: boolean; unignored: boolean } {
132
+ const path = relFromBase(absPath, repoRootAbs);
133
+ if (path === undefined || path === "") {
134
+ return { ignored: false, unignored: false };
135
+ }
136
+ const result = matcher.test(isDirectory ? `${path}/` : path);
137
+ return { ignored: result.ignored, unignored: result.unignored };
138
+ }
139
+
140
+ /**
141
+ * Append the non-empty, non-comment lines of `content` to `out` verbatim. Used
142
+ * for rules already anchored at the repo root (the repo's own `.gitignore`,
143
+ * `.git/info/exclude`, and global excludes).
144
+ */
145
+ function pushRules(out: string[], content: string): void {
146
+ for (const line of content.split(/\r?\n/)) {
147
+ if (line.trim() === "" || line.startsWith("#")) {
148
+ continue;
149
+ }
150
+ out.push(line);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Re-anchor each pattern of a nested `.gitignore` so it is relative to the repo
156
+ * root rather than to the `.gitignore`'s own directory, appending to `out`.
157
+ * `basePrefix` is the `.gitignore`'s repo-root-relative directory path with a
158
+ * trailing slash (e.g. `"engine/src/"`).
159
+ *
160
+ * Mirrors gitignore anchoring rules: a pattern with a leading slash, or one
161
+ * containing a non-trailing slash, is anchored to the `.gitignore`'s directory,
162
+ * so it just gains the prefix; a pattern with no slash (or only a trailing one)
163
+ * matches at any depth below that directory, so it gains a "prefix + doubled
164
+ * star + slash" lead-in to match through intervening directories.
165
+ */
166
+ function reanchorRules(
167
+ content: string,
168
+ basePrefix: string,
169
+ out: string[]
170
+ ): void {
171
+ for (const line of content.split(/\r?\n/)) {
172
+ if (line.trim() === "" || line.startsWith("#")) {
173
+ continue;
174
+ }
175
+
176
+ let negated = false;
177
+ let pattern = line;
178
+ if (pattern.startsWith("!")) {
179
+ negated = true;
180
+ pattern = pattern.slice(1);
181
+ }
182
+ if (pattern === "") {
183
+ continue;
184
+ }
185
+
186
+ let body: string;
187
+ if (pattern.startsWith("/")) {
188
+ body = basePrefix + pattern.slice(1);
189
+ } else {
190
+ const core = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
191
+ body = core.includes("/")
192
+ ? basePrefix + pattern
193
+ : basePrefix + "**/" + pattern;
194
+ }
195
+
196
+ out.push(negated ? `!${body}` : body);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Nearest ancestor of `start` (inclusive) that contains a `.git` entry, i.e.
202
+ * the git repository owning `start`, or undefined if `start` is not in a repo.
203
+ * A `.git` may be a directory (normal clone) or a file (worktree/submodule).
204
+ */
205
+ async function findRepoRoot(start: string): Promise<string | undefined> {
206
+ let dir = start;
207
+ for (;;) {
208
+ if (await Bun.file(join(dir, ".git")).exists()) {
209
+ return dir;
210
+ }
211
+ const parent = dirname(dir);
212
+ if (parent === dir) {
213
+ return undefined;
214
+ }
215
+ dir = parent;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Build the base rule set for a repository rooted at `repoAbs`: global excludes,
221
+ * then .git/info/exclude, then the repo root's own .gitignore — appended in that
222
+ * precedence order. All are anchored at the repo root already, so no
223
+ * re-anchoring is needed.
224
+ */
225
+ async function repoBaseRules(
226
+ repoAbs: string,
227
+ globalGitIgnore: string | undefined
228
+ ): Promise<string[]> {
229
+ const rules: string[] = [];
230
+ if (globalGitIgnore !== undefined) {
231
+ pushRules(rules, globalGitIgnore);
232
+ }
233
+ const [infoExclude, repoGitIgnore] = await Promise.all([
234
+ readIgnoreFile(join(repoAbs, ".git", "info", "exclude")),
235
+ readIgnoreFile(join(repoAbs, ".gitignore")),
236
+ ]);
237
+ if (infoExclude !== undefined) {
238
+ pushRules(rules, infoExclude);
239
+ }
240
+ if (repoGitIgnore !== undefined) {
241
+ pushRules(rules, repoGitIgnore);
242
+ }
243
+ return rules;
244
+ }
245
+
246
+ /**
247
+ * Read one directory: collect its files and queue its subdirectories, honoring
248
+ * ignore rules.
249
+ */
250
+ async function processDir(
251
+ ctx: WalkContext,
252
+ currentDir: StackEntry
253
+ ): Promise<void> {
254
+ let entries;
255
+ try {
256
+ entries = await readdir(currentDir.abs, { withFileTypes: true });
257
+ } catch {
258
+ // Unreadable directory (permissions, race): skip it.
259
+ return;
260
+ }
261
+
262
+ let inRepo = currentDir.inRepo;
263
+ let repoRootAbs = currentDir.repoRootAbs;
264
+ let rules = currentDir.ignoreRules;
265
+ let matcher = currentDir.matcher;
266
+
267
+ if (ctx.useIgnore) {
268
+ const hasDotGit = entries.some((e) => e.name === ".git");
269
+ if (hasDotGit) {
270
+ // A .git here marks a repository boundary. Start a fresh rule set for
271
+ // this repo, discarding any inherited (parent-repo) rules.
272
+ inRepo = true;
273
+ repoRootAbs = currentDir.abs;
274
+ rules = await repoBaseRules(currentDir.abs, ctx.globalGitIgnore);
275
+ matcher = ignore().add(rules);
276
+ } else if (inRepo) {
277
+ // Within a repo, a nested .gitignore extends the rules for this subtree.
278
+ const hasGitIgnore = entries.some(
279
+ (e) => e.isFile() && e.name === ".gitignore"
280
+ );
281
+ if (hasGitIgnore) {
282
+ const content = await readIgnoreFile(
283
+ join(currentDir.abs, ".gitignore")
284
+ );
285
+ if (content !== undefined) {
286
+ const dirRel = relFromBase(currentDir.abs, repoRootAbs);
287
+ const next = rules.slice();
288
+ if (dirRel === undefined || dirRel === "") {
289
+ pushRules(next, content);
290
+ } else {
291
+ reanchorRules(content, `${dirRel}/`, next);
292
+ }
293
+ rules = next;
294
+ matcher = ignore().add(rules);
295
+ }
296
+ }
297
+ }
298
+ // When !inRepo, .gitignore files are not honored: rules stays empty.
299
+ }
300
+
301
+ for (const entry of entries) {
302
+ const name = entry.name;
303
+ const relPath = currentDir.rel === "" ? name : `${currentDir.rel}/${name}`;
304
+ const isDir = entry.isDirectory();
305
+ const isSymlink = entry.isSymbolicLink();
306
+
307
+ // Pruned regardless of includeDotfiles.
308
+ if (name === ".git") {
309
+ continue;
310
+ }
311
+
312
+ // Skip the join unless the abs path is actually needed below.
313
+ const needsAbs = isDir || (ctx.useIgnore && inRepo);
314
+ const childAbs = needsAbs ? join(currentDir.abs, name) : "";
315
+
316
+ // One ignore evaluation per entry, reused for both the dotfile rule and the
317
+ // ignore prune below.
318
+ const verdict =
319
+ ctx.useIgnore && inRepo
320
+ ? ignoreVerdict(matcher, repoRootAbs, childAbs, isDir)
321
+ : { ignored: false, unignored: false };
322
+
323
+ // Dot-prefixed entries are hidden by default, but — like git/fd — an
324
+ // explicit `!` negation in a .gitignore re-includes them.
325
+ if (
326
+ !ctx.includeDotfiles &&
327
+ name.charCodeAt(0) === 0x2e /* "." */ &&
328
+ !verdict.unignored
329
+ ) {
330
+ continue;
331
+ }
332
+
333
+ if (verdict.ignored) {
334
+ continue;
335
+ }
336
+
337
+ if (isDir) {
338
+ // Do not follow symlinked dirs, to avoid cycles.
339
+ if (isSymlink) {
340
+ continue;
341
+ }
342
+
343
+ if (ctx.includeDirectories) {
344
+ ctx.result.push(`${relPath}/`);
345
+ }
346
+
347
+ ctx.stack.push({
348
+ abs: childAbs,
349
+ rel: relPath,
350
+ inRepo,
351
+ repoRootAbs,
352
+ ignoreRules: rules,
353
+ matcher,
354
+ });
355
+ continue;
356
+ }
357
+
358
+ if (entry.isFile() || isSymlink) {
359
+ ctx.result.push(relPath);
360
+ continue;
361
+ }
362
+
363
+ // Other dirent types (fifo, socket, block/char device) are ignored.
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Bounded-concurrency pump: keep up to CONCURRENCY `processDir` calls in
369
+ * flight. Each completion refills freed slots from the shared stack, which
370
+ * grows as directories are discovered. A simple worker loop can't be used
371
+ * here: at startup the stack holds only the root, so all but one worker
372
+ * would drain it and exit before any children were pushed.
373
+ */
374
+ function drain(ctx: WalkContext): Promise<void> {
375
+ let inFlight = 0;
376
+ return new Promise<void>((resolve, reject) => {
377
+ // Self-referential to refill slots on each completion; must close over
378
+ // the executor's resolve/reject, so it stays nested here.
379
+ const pump = (): void => {
380
+ while (inFlight < CONCURRENCY && ctx.stack.length > 0) {
381
+ const currentDir = ctx.stack.pop()!;
382
+ inFlight++;
383
+ processDir(ctx, currentDir).then(() => {
384
+ inFlight--;
385
+ pump();
386
+ }, reject);
387
+ }
388
+ if (inFlight === 0 && ctx.stack.length === 0) {
389
+ resolve();
390
+ }
391
+ };
392
+ pump();
393
+ });
394
+ }
395
+
396
+ export class FileEnumerator {
397
+ /**
398
+ * Enumerate all files under `root` as an array of root-relative POSIX paths.
399
+ *
400
+ * Descent is async with a bounded concurrency cap: up to `CONCURRENCY`
401
+ * `readdir` syscalls are in flight at once, all pulling from a shared stack,
402
+ * so directory-read latency overlaps instead of running one-at-a-time.
403
+ *
404
+ * Gitignore handling is repo-aware, matching git/fd: a `.gitignore` is only
405
+ * honored within a git repository, each nested `.git` is a boundary that
406
+ * resets the ignore scope (a child repo does not inherit its parent's rules),
407
+ * and if `root` itself sits inside a repository the enclosing rules are
408
+ * seeded. A `.gitignore` with no enclosing repo is inert, matching git/fd.
409
+ */
410
+ public static async enumerate(
411
+ root: string,
412
+ opts?: EnumerateOptions
413
+ ): Promise<string[]> {
414
+ const includeDotfiles = opts?.includeDotfiles ?? false;
415
+ const includeIgnored = opts?.includeIgnored ?? false;
416
+ const includeDirectories = opts?.includeDirectories ?? false;
417
+ const useIgnore = !includeIgnored;
418
+
419
+ // Global excludes (core.excludesFile / XDG). Read once; applies only within
420
+ // a repository, anchored as if it were a .gitignore at the repo root.
421
+ let globalGitIgnore: string | undefined;
422
+ if (useIgnore) {
423
+ const pathname = globalGitIgnorePath();
424
+ globalGitIgnore =
425
+ pathname === undefined ? undefined : await readIgnoreFile(pathname);
426
+ }
427
+
428
+ // Seed rules from any repository that ENCLOSES `root` (its .git lives at an
429
+ // ancestor of root). The repo root's base rules plus every intermediate
430
+ // .gitignore between it and root are applied. Root's own .gitignore, if any,
431
+ // is added by processDir when root is walked. When root is itself a repo
432
+ // root (or not in a repo at all), processDir handles it from a clean slate.
433
+ let initialInRepo = false;
434
+ let initialRepoRootAbs = root;
435
+ let initialRules: string[] = [];
436
+ if (useIgnore) {
437
+ const repoRoot = await findRepoRoot(root);
438
+ if (repoRoot !== undefined && repoRoot !== root) {
439
+ initialInRepo = true;
440
+ initialRepoRootAbs = repoRoot;
441
+ initialRules = await repoBaseRules(repoRoot, globalGitIgnore);
442
+
443
+ // Intermediate dirs strictly between repoRoot and root, shallowest first.
444
+ const intermediates: string[] = [];
445
+ let dir = dirname(root);
446
+ while (dir !== repoRoot && dir.length > repoRoot.length) {
447
+ intermediates.push(dir);
448
+ const parent = dirname(dir);
449
+ if (parent === dir) {
450
+ break;
451
+ }
452
+ dir = parent;
453
+ }
454
+ intermediates.reverse();
455
+
456
+ for (const dirAbs of intermediates) {
457
+ const content = await readIgnoreFile(join(dirAbs, ".gitignore"));
458
+ if (content !== undefined) {
459
+ const dirRel = relFromBase(dirAbs, repoRoot);
460
+ if (dirRel === undefined || dirRel === "") {
461
+ pushRules(initialRules, content);
462
+ } else {
463
+ reanchorRules(content, `${dirRel}/`, initialRules);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ const ctx: WalkContext = {
471
+ includeDotfiles,
472
+ includeDirectories,
473
+ useIgnore,
474
+ globalGitIgnore,
475
+ stack: [
476
+ {
477
+ abs: root,
478
+ rel: "",
479
+ inRepo: initialInRepo,
480
+ repoRootAbs: initialRepoRootAbs,
481
+ ignoreRules: initialRules,
482
+ matcher: initialInRepo ? ignore().add(initialRules) : EMPTY_MATCHER,
483
+ },
484
+ ],
485
+ result: [],
486
+ };
487
+
488
+ await drain(ctx);
489
+
490
+ return ctx.result;
491
+ }
492
+ }
@@ -1,5 +1,5 @@
1
- import { resolve } from "node:path";
2
- import { GitignoreFilter } from "./GitignoreFilter";
1
+ import { join, resolve } from "node:path";
2
+ import { FileEnumerator } from "./FileEnumerator";
3
3
  import { GlobExclusions } from "./GlobExclusions";
4
4
 
5
5
  export type FileScanOptions = {
@@ -15,25 +15,23 @@ export class FileScanner {
15
15
  options: FileScanOptions
16
16
  ): Promise<readonly string[]> {
17
17
  const absoluteRoot = resolve(root);
18
- const filter = options.includeIgnored
19
- ? undefined
20
- : await GitignoreFilter.for(absoluteRoot);
18
+ const relativePaths = await FileEnumerator.enumerate(absoluteRoot, {
19
+ includeDotfiles: options.includeDotfiles,
20
+ includeIgnored: options.includeIgnored,
21
+ });
22
+ const matcher = new Bun.Glob(pattern);
21
23
  const excludes = GlobExclusions.compile(options.exclude);
22
- const glob = new Bun.Glob(pattern);
23
24
  const files: string[] = [];
24
25
 
25
- for await (const filePath of glob.scan({
26
- cwd: absoluteRoot,
27
- absolute: true,
28
- onlyFiles: true,
29
- dot: options.includeDotfiles,
30
- })) {
31
- if (
32
- (filter === undefined || !filter.ignores(filePath)) &&
33
- !GlobExclusions.ignores(excludes, absoluteRoot, filePath)
34
- ) {
35
- files.push(filePath);
26
+ for (const relativePath of relativePaths) {
27
+ if (!matcher.match(relativePath)) {
28
+ continue;
36
29
  }
30
+ const absolutePath = join(absoluteRoot, relativePath);
31
+ if (GlobExclusions.ignores(excludes, absoluteRoot, absolutePath)) {
32
+ continue;
33
+ }
34
+ files.push(absolutePath);
37
35
  }
38
36
 
39
37
  return files;
@@ -1,142 +0,0 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, parse, relative, resolve } from "node:path";
3
- import ignore, { type Ignore } from "ignore";
4
- import { FsErrors } from "./FsErrors";
5
- import { Paths } from "./Paths";
6
-
7
- type IgnoreMatcher = {
8
- readonly baseDirectory: string;
9
- readonly matcher: Ignore;
10
- };
11
-
12
- export class GitignoreFilter {
13
- private static readonly alwaysIgnoredPatterns = [
14
- ".git/",
15
- "node_modules/",
16
- "dist/",
17
- "build/",
18
- "out/",
19
- "target/",
20
- "coverage/",
21
- ".next/",
22
- ".cache/",
23
- ".turbo/",
24
- ".vercel/",
25
- ".svelte-kit/",
26
- ] as const;
27
-
28
- private readonly matchers: readonly IgnoreMatcher[];
29
-
30
- private constructor(matchers: readonly IgnoreMatcher[]) {
31
- this.matchers = matchers;
32
- }
33
-
34
- public static async for(root: string): Promise<GitignoreFilter> {
35
- const absoluteRoot = resolve(root);
36
- const rootDirectory =
37
- await GitignoreFilter.containingDirectory(absoluteRoot);
38
- const directories =
39
- await GitignoreFilter.gitignoreDirectories(rootDirectory);
40
- const contents = await Promise.all(
41
- directories.map((directory) => GitignoreFilter.readGitignore(directory))
42
- );
43
- const matchers: IgnoreMatcher[] = [
44
- {
45
- baseDirectory: rootDirectory,
46
- matcher: ignore().add([...GitignoreFilter.alwaysIgnoredPatterns]),
47
- },
48
- ];
49
-
50
- for (const [index, directory] of directories.entries()) {
51
- const body = contents[index];
52
-
53
- if (body !== undefined) {
54
- matchers.push({
55
- baseDirectory: directory,
56
- matcher: ignore().add(body),
57
- });
58
- }
59
- }
60
-
61
- return new GitignoreFilter(matchers);
62
- }
63
-
64
- public ignores(absolutePath: string): boolean {
65
- if (!isAbsolute(absolutePath)) {
66
- throw new Error(`Expected absolute path: ${absolutePath}`);
67
- }
68
-
69
- for (const { baseDirectory, matcher } of this.matchers) {
70
- const candidate = this.relativePath(baseDirectory, absolutePath);
71
-
72
- if (candidate !== undefined && matcher.ignores(candidate)) {
73
- return true;
74
- }
75
- }
76
-
77
- return false;
78
- }
79
-
80
- private static async containingDirectory(path: string): Promise<string> {
81
- const metadata = await stat(path);
82
-
83
- return metadata.isDirectory() ? path : dirname(path);
84
- }
85
-
86
- private static async gitignoreDirectories(
87
- root: string
88
- ): Promise<readonly string[]> {
89
- const directories: string[] = [];
90
- const filesystemRoot = parse(root).root;
91
- let current = root;
92
-
93
- while (true) {
94
- directories.push(current);
95
-
96
- if (await Bun.file(resolve(current, ".git")).exists()) {
97
- break;
98
- }
99
-
100
- if (current === filesystemRoot) {
101
- break;
102
- }
103
-
104
- current = dirname(current);
105
- }
106
-
107
- return directories;
108
- }
109
-
110
- private static async readGitignore(
111
- directory: string
112
- ): Promise<string | undefined> {
113
- const path = resolve(directory, ".gitignore");
114
-
115
- try {
116
- return await readFile(path, "utf8");
117
- } catch (error) {
118
- if (FsErrors.code(error) === "ENOENT") {
119
- return undefined;
120
- }
121
-
122
- throw error;
123
- }
124
- }
125
-
126
- private relativePath(
127
- baseDirectory: string,
128
- absolutePath: string
129
- ): string | undefined {
130
- const candidate = relative(baseDirectory, absolutePath);
131
-
132
- if (
133
- candidate.length === 0 ||
134
- candidate.startsWith("..") ||
135
- isAbsolute(candidate)
136
- ) {
137
- return undefined;
138
- }
139
-
140
- return Paths.toForwardSlashes(candidate);
141
- }
142
- }