srcpack 0.2.0 → 1.0.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.
package/src/bundle.ts CHANGED
@@ -1,16 +1,36 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
- import { lstat, open, readFile } from "node:fs/promises";
4
- import { isAbsolute, join, resolve, sep } from "node:path";
5
3
  import { glob } from "fast-glob";
6
- import picomatch from "picomatch";
7
4
  import ignore, { type Ignore } from "ignore";
8
- import { ConfigError, expandPath, type BundleConfigInput } from "./config.ts";
5
+ import { lstat, open, readFile } from "node:fs/promises";
6
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
7
+ import picomatch from "picomatch";
8
+ import {
9
+ ConfigError,
10
+ expandPath,
11
+ type BundleConfigInput,
12
+ type LinearSourceInput,
13
+ } from "./config.ts";
14
+ import { pathKey } from "./fs.ts";
9
15
  import { isGitSource, resolveGitSource } from "./git.ts";
16
+ import { resolveLinearSource } from "./linear.ts";
10
17
 
11
18
  // Binary file detection: check first 8KB for null bytes (same heuristic as git)
12
19
  const BINARY_CHECK_SIZE = 8192;
13
20
 
21
+ /**
22
+ * Shared glob options. `followSymbolicLinks: false` is the load-bearing one:
23
+ * fast-glob defaults to true, so a link like `vendor -> ../../elsewhere` would
24
+ * be walked and every regular file under it bundled. Rejecting symlinks at the
25
+ * final component (see {@link isBundleable}) can't catch that — the leaf is an
26
+ * ordinary file; the escape happened in a directory along the way.
27
+ */
28
+ const GLOB_OPTIONS = {
29
+ onlyFiles: true,
30
+ dot: true,
31
+ followSymbolicLinks: false,
32
+ } as const;
33
+
14
34
  /**
15
35
  * Whether a path can be read into a bundle: an existing regular text file.
16
36
  * Globs only yield files, but git can name a submodule directory or a file
@@ -46,16 +66,29 @@ async function isBundleable(filePath: string): Promise<boolean> {
46
66
  }
47
67
  }
48
68
 
49
- export interface FileEntry {
50
- path: string; // Relative path from cwd
51
- lines: number; // Line count in source file
69
+ /**
70
+ * One bundle member, before its content is laid out.
71
+ *
72
+ * `content` present means the entry is virtual — produced by a non-filesystem
73
+ * source such as Linear — and its `path` is synthetic. Absent means an ordinary
74
+ * file, read from disk at bundle time.
75
+ */
76
+ export interface Entry {
77
+ path: string;
78
+ content?: string;
79
+ }
80
+
81
+ /** One line of the bundle index — a file or a virtual entry, once laid out. */
82
+ export interface IndexEntry {
83
+ path: string; // Relative path from cwd, or a synthetic path
84
+ lines: number; // Line count in the entry's content
52
85
  startLine: number; // Start line in bundle (1-indexed)
53
86
  endLine: number; // End line in bundle
54
87
  }
55
88
 
56
89
  export interface BundleResult {
57
90
  content: string;
58
- index: FileEntry[];
91
+ index: IndexEntry[];
59
92
  }
60
93
 
61
94
  /**
@@ -88,6 +121,9 @@ function normalizePatterns(config: BundleConfigInput): {
88
121
  patterns = [config];
89
122
  } else if (Array.isArray(config)) {
90
123
  patterns = config;
124
+ } else if (config.include === undefined) {
125
+ // A `linear`-only bundle has no patterns at all
126
+ patterns = [];
91
127
  } else {
92
128
  patterns = Array.isArray(config.include)
93
129
  ? config.include
@@ -156,25 +192,29 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
156
192
  // Skip empty lines and comments
157
193
  if (!trimmed || trimmed.startsWith("#")) continue;
158
194
 
195
+ // A trailing slash only says "directory", which is what this prunes anyway.
196
+ // Stripping it first matters: `node_modules/` is the common spelling, and
197
+ // testing for "/" before stripping rejected every one of them.
198
+ const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
199
+
159
200
  // Skip patterns with special gitignore features we can't safely convert:
160
201
  // - Root-anchored (starts with /)
161
202
  // - Contains globs (*, ?, [)
162
203
  // - Contains path separators (complex paths)
163
204
  // - Escaped characters
164
205
  if (
165
- trimmed.startsWith("/") ||
166
- trimmed.includes("*") ||
167
- trimmed.includes("?") ||
168
- trimmed.includes("[") ||
169
- trimmed.includes("/") ||
170
- trimmed.includes("\\")
206
+ name.startsWith("/") ||
207
+ name.includes("*") ||
208
+ name.includes("?") ||
209
+ name.includes("[") ||
210
+ name.includes("/") ||
211
+ name.includes("\\")
171
212
  ) {
172
213
  continue;
173
214
  }
174
215
 
175
216
  // Only convert simple directory names (e.g., "node_modules", "dist")
176
217
  // These are safe to prune at any depth
177
- const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
178
218
  if (name && /^[\w.-]+$/.test(name)) {
179
219
  patterns.push(`**/${name}/**`);
180
220
  }
@@ -184,28 +224,98 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
184
224
  }
185
225
 
186
226
  interface GitignoreResult {
187
- ignore: Ignore;
227
+ /** Whether git would ignore this cwd-relative posix path. */
228
+ ignores: (path: string) => boolean;
188
229
  globPatterns: string[];
189
230
  }
190
231
 
232
+ /** One directory's ignore rules. `dir` is a cwd-relative prefix, "" for root. */
233
+ interface IgnoreLayer {
234
+ dir: string;
235
+ ig: Ignore;
236
+ }
237
+
238
+ /**
239
+ * Read an ignore file. Only a missing file means "no rules" — a permission or
240
+ * I/O error would otherwise widen the bundle to exactly the files someone chose
241
+ * to hide, so it fails the run instead.
242
+ */
243
+ async function readIgnoreFile(path: string): Promise<string | null> {
244
+ try {
245
+ return await readFile(path, "utf-8");
246
+ } catch (error) {
247
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
248
+ throw new ConfigError(
249
+ `Cannot read "${path}": ${(error as Error).message}. ` +
250
+ "srcpack stops rather than bundle files it cannot confirm are ignored.",
251
+ );
252
+ }
253
+ }
254
+
191
255
  /**
192
- * Load and parse .gitignore file from a directory.
193
- * Returns both an Ignore instance for filtering and glob patterns for fast-glob.
256
+ * Load the .gitignore rules that apply anywhere under `cwd`.
257
+ *
258
+ * Git resolves ignores per directory: a path is governed by the ignore file in
259
+ * its own directory and in every parent, deepest rule winning. Reading only the
260
+ * root file bundles whatever a nested .gitignore hides — `packages/app/.env` in
261
+ * a monorepo being the case that matters, since ignored secrets staying out is
262
+ * a documented guarantee rather than a convenience.
194
263
  */
195
264
  async function loadGitignore(cwd: string): Promise<GitignoreResult> {
196
- const ig = ignore();
197
- const gitignorePath = join(cwd, ".gitignore");
198
- let globPatterns: string[] = [];
265
+ const rootContent = await readIgnoreFile(join(cwd, ".gitignore"));
266
+ const globPatterns = rootContent
267
+ ? gitignoreToGlobPatterns(rootContent.split("\n"))
268
+ : [];
269
+
270
+ const layers: IgnoreLayer[] = [];
271
+ if (rootContent) layers.push({ dir: "", ig: ignore().add(rootContent) });
272
+
273
+ // Pruned by the root file's directory patterns: no point walking node_modules
274
+ // to collect ignore files that only govern paths already ignored.
275
+ const nested = await glob(["**/.gitignore"], {
276
+ cwd,
277
+ dot: true,
278
+ onlyFiles: true,
279
+ followSymbolicLinks: false,
280
+ ignore: globPatterns,
281
+ });
199
282
 
200
- try {
201
- const content = await readFile(gitignorePath, "utf-8");
202
- ig.add(content);
203
- globPatterns = gitignoreToGlobPatterns(content.split("\n"));
204
- } catch {
205
- // No .gitignore file, return empty ignore instance
283
+ for (const file of nested) {
284
+ const content = await readIgnoreFile(join(cwd, file));
285
+ if (content) layers.push({ dir: dirname(file), ig: ignore().add(content) });
206
286
  }
207
287
 
208
- return { ignore: ig, globPatterns };
288
+ return { ignores: makeIgnores(layers), globPatterns };
289
+ }
290
+
291
+ /** Resolve the layered rules for one path, deepest directory first. */
292
+ function makeIgnores(layers: IgnoreLayer[]): (path: string) => boolean {
293
+ if (layers.length === 0) return () => false;
294
+ const ordered = [...layers].sort((a, b) => b.dir.length - a.dir.length);
295
+
296
+ // First layer with an opinion wins; an explicit negation (`!keep.env`) is an
297
+ // opinion too, which is what lets a nested file re-include what its parent hid.
298
+ const opinion = (path: string): boolean | undefined => {
299
+ for (const { dir, ig } of ordered) {
300
+ if (dir && !path.startsWith(`${dir}/`)) continue;
301
+ const relative = dir ? path.slice(dir.length + 1) : path;
302
+ if (!relative || relative === "/") continue;
303
+ const { ignored, unignored } = ig.test(relative);
304
+ if (ignored) return true;
305
+ if (unignored) return false;
306
+ }
307
+ return undefined;
308
+ };
309
+
310
+ return (path: string) => {
311
+ // Ancestors first: git never descends into an ignored directory, so nothing
312
+ // beneath one can be negated back in.
313
+ const segments = path.split("/");
314
+ for (let i = 1; i < segments.length; i++) {
315
+ if (opinion(`${segments.slice(0, i).join("/")}/`)) return true;
316
+ }
317
+ return opinion(path) === true;
318
+ };
209
319
  }
210
320
 
211
321
  /**
@@ -226,9 +336,11 @@ function isExternalPattern(pattern: string): boolean {
226
336
  * files or directories; a directory covers everything beneath it.
227
337
  */
228
338
  function isOwnOutput(filePath: string, outputs: string[]): boolean {
229
- return outputs.some(
230
- (out) => filePath === out || filePath.startsWith(out + sep),
231
- );
339
+ const key = pathKey(filePath);
340
+ return outputs.some((out) => {
341
+ const outKey = pathKey(out);
342
+ return key === outKey || key.startsWith(outKey + sep);
343
+ });
232
344
  }
233
345
 
234
346
  /**
@@ -280,33 +392,86 @@ export async function resolvePatterns(
280
392
  // Internal patterns (within cwd): respect .gitignore
281
393
  const internalPatterns = globs.filter((p) => !isExternalPattern(p));
282
394
  if (internalPatterns.length > 0) {
283
- const { ignore: gitignore, globPatterns } = await loadGitignore(cwd);
395
+ const { ignores, globPatterns } = await loadGitignore(cwd);
284
396
  const matches = await glob(internalPatterns, {
397
+ ...GLOB_OPTIONS,
285
398
  cwd,
286
- onlyFiles: true,
287
- dot: true,
288
399
  ignore: globPatterns,
289
400
  });
290
- await add(matches.filter((m) => !gitignore.ignores(m)));
401
+ await add(matches.filter((m) => !ignores(m)));
291
402
  }
292
403
 
293
404
  // External patterns: skip .gitignore (it doesn't apply outside cwd)
294
405
  const externalPatterns = globs.filter(isExternalPattern);
295
406
  if (externalPatterns.length > 0) {
296
- await add(
297
- await glob(externalPatterns, { cwd, onlyFiles: true, dot: true }),
298
- );
407
+ await add(await glob(externalPatterns, { ...GLOB_OPTIONS, cwd }));
299
408
  }
300
409
 
301
410
  // Force includes: bypass .gitignore (no ignore patterns passed to glob)
302
411
  if (force.length > 0) {
303
- await add(await glob(force, { cwd, onlyFiles: true, dot: true }));
412
+ await add(await glob(force, { ...GLOB_OPTIONS, cwd }));
304
413
  }
305
414
 
306
415
  // Sort for deterministic output
307
416
  return [...files].sort();
308
417
  }
309
418
 
419
+ /** Read the `linear` source off a bundle config, if it declares one. */
420
+ function getLinear(config: BundleConfigInput): LinearSourceInput | undefined {
421
+ if (typeof config === "object" && !Array.isArray(config)) {
422
+ return config.linear;
423
+ }
424
+ return undefined;
425
+ }
426
+
427
+ /**
428
+ * Resolve every source a bundle declares into a sorted entry list.
429
+ *
430
+ * Filesystem and `git:` sources produce paths ({@link resolvePatterns}, which
431
+ * never touches the network); `linear` produces virtual entries carrying their
432
+ * own content. Both then meet the same rules — `!` exclusions apply uniformly,
433
+ * and the result is sorted by path so bundles stay deterministic.
434
+ */
435
+ export async function resolveEntries(
436
+ config: BundleConfigInput,
437
+ cwd: string,
438
+ outputs: string[] = [],
439
+ ): Promise<Entry[]> {
440
+ const linearSource = getLinear(config);
441
+
442
+ // Sequential, not parallel: a bad pattern should fail before spending a
443
+ // network round trip, and a failed fetch shouldn't race a filesystem walk.
444
+ const paths = await resolvePatterns(config, cwd, outputs);
445
+ const entries: Entry[] = paths.map((path) => ({ path }));
446
+
447
+ if (linearSource) {
448
+ const { exclude } = normalizePatterns(config);
449
+ const excludeMatchers = exclude.map((p) => picomatch(p));
450
+ // Compare resolved paths, not the strings: an absolute pattern and a
451
+ // relative one name the same file with different spellings, and only the
452
+ // resolved form tells whether a real file occupies a synthetic path.
453
+ const taken = new Set(paths.map((path) => resolve(cwd, path)));
454
+
455
+ for (const entry of await resolveLinearSource(linearSource)) {
456
+ if (isExcluded(entry.path, excludeMatchers)) continue;
457
+ // `linear/issues/` is a reserved namespace once a bundle pulls issues:
458
+ // a real file there would put two entries in the index under one name,
459
+ // and an `!` exclusion can't drop one without dropping both.
460
+ if (taken.has(resolve(cwd, entry.path))) {
461
+ throw new ConfigError(
462
+ `Linear issue collides with the file "${entry.path}". ` +
463
+ "Rename the file, or narrow the include patterns so it isn't matched.",
464
+ );
465
+ }
466
+ entries.push(entry);
467
+ }
468
+ }
469
+
470
+ return entries.sort((a, b) =>
471
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
472
+ );
473
+ }
474
+
310
475
  /**
311
476
  * Count lines in a string (handles empty strings correctly)
312
477
  */
@@ -324,7 +489,7 @@ function countLines(content: string): number {
324
489
  * - ASCII-only characters for broad compatibility
325
490
  * - Line locations that point to actual file content
326
491
  */
327
- export function formatIndex(index: FileEntry[]): string {
492
+ export function formatIndex(index: IndexEntry[]): string {
328
493
  if (index.length === 0) return "# Index\n# (empty)";
329
494
 
330
495
  const count = index.length;
@@ -355,43 +520,46 @@ function formatSeparator(index: number, filePath: string): string {
355
520
  }
356
521
 
357
522
  /**
358
- * Create a bundle from a list of files.
523
+ * Create a bundle from a list of entries.
359
524
  * Line numbers in the index point to the first line of actual file content,
360
525
  * not to the separator line.
361
526
  */
362
527
  export async function createBundle(
363
- files: string[],
528
+ entries: Entry[],
364
529
  cwd: string,
365
530
  options: BundleOptions = {},
366
531
  ): Promise<BundleResult> {
367
532
  const { includeIndex = true } = options;
368
533
  // Normalize prompt: trim and treat whitespace-only as no prompt
369
534
  const prompt = options.prompt?.trim() || undefined;
370
- const index: FileEntry[] = [];
535
+ const index: IndexEntry[] = [];
371
536
  const contentParts: string[] = [];
372
537
  let currentLine = 1;
373
538
 
374
- for (let i = 0; i < files.length; i++) {
375
- const filePath = files[i]!;
376
- const content = await readFile(resolve(cwd, filePath), "utf-8");
539
+ for (let i = 0; i < entries.length; i++) {
540
+ const entry = entries[i]!;
541
+ const filePath = entry.path;
542
+ // Virtual entries carry their content; files are read from disk
543
+ const content =
544
+ entry.content ?? (await readFile(resolve(cwd, filePath), "utf-8"));
377
545
  const lines = countLines(content);
378
546
 
379
547
  // Separator takes 1 line, then content starts on next line
380
548
  const contentStartLine = currentLine + 1;
381
549
 
382
- const entry: FileEntry = {
550
+ const indexEntry: IndexEntry = {
383
551
  path: filePath,
384
552
  lines,
385
553
  startLine: contentStartLine,
386
554
  endLine: contentStartLine + Math.max(0, lines - 1),
387
555
  };
388
- index.push(entry);
556
+ index.push(indexEntry);
389
557
 
390
558
  contentParts.push(formatSeparator(i + 1, filePath));
391
559
  contentParts.push(content.endsWith("\n") ? content.slice(0, -1) : content);
392
560
 
393
561
  // Next separator line = after content
394
- currentLine = entry.endLine + 1;
562
+ currentLine = indexEntry.endLine + 1;
395
563
  }
396
564
 
397
565
  // Calculate prompt offset (prompt text + blank + "---" + blank)
@@ -494,8 +662,8 @@ export async function bundleOne(
494
662
  cwd: string,
495
663
  outputs: string[] = [],
496
664
  ): Promise<BundleResult> {
497
- const files = await resolvePatterns(config, cwd, outputs);
665
+ const entries = await resolveEntries(config, cwd, outputs);
498
666
  const includeIndex = getIncludeIndex(config);
499
667
  const prompt = await resolvePrompt(getPrompt(config), cwd);
500
- return createBundle(files, cwd, { includeIndex, prompt });
668
+ return createBundle(entries, cwd, { includeIndex, prompt });
501
669
  }