srcpack 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.
package/dist/config.d.ts CHANGED
@@ -1,5 +1,30 @@
1
1
  import { z } from "zod";
2
2
  export declare function expandPath(p: string): string;
3
+ /**
4
+ * Linear issues as a bundle source. Each issue becomes a virtual file at
5
+ * `linear/issues/<identifier>.md`, so it gets its own index entry and line
6
+ * range and can be filtered with ordinary `!` exclusions.
7
+ *
8
+ * Authentication reads `LINEAR_API_KEY` from the environment. It is
9
+ * deliberately not a config field: config files are committed, and the
10
+ * `package.json` config form cannot express `process.env`.
11
+ *
12
+ * `team` is required. A workspace-wide fetch is a footgun — it looks innocuous
13
+ * and can pull thousands of issues into a context window.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * bundles: {
18
+ * backlog: { linear: "ENG" },
19
+ * roadmap: { linear: { team: "ENG", project: "Roadmap" } },
20
+ * }
21
+ * ```
22
+ */
23
+ declare const LinearSourceSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
24
+ team: z.ZodString;
25
+ project: z.ZodOptional<z.ZodString>;
26
+ includeClosed: z.ZodDefault<z.ZodBoolean>;
27
+ }, z.core.$strict>]>;
3
28
  /**
4
29
  * Bundle configuration. Accepts a string pattern, array of patterns, or object.
5
30
  * Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force
@@ -9,17 +34,27 @@ export declare function expandPath(p: string): string;
9
34
  * `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g.
10
35
  * `git:main`, `git:HEAD~3`).
11
36
  *
37
+ * The object form takes files (`include`), Linear issues (`linear`), or both.
38
+ *
12
39
  * @example
13
40
  * ```ts
14
- * bundles: { review: ["git:staged", "!bun.lock"] }
41
+ * bundles: {
42
+ * review: ["git:staged", "!bun.lock"],
43
+ * planning: { include: ["docs/**"], linear: { team: "ENG" } },
44
+ * }
15
45
  * ```
16
46
  */
17
47
  declare const BundleConfigSchema: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodObject<{
18
- include: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
48
+ include: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
49
+ linear: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
50
+ team: z.ZodString;
51
+ project: z.ZodOptional<z.ZodString>;
52
+ includeClosed: z.ZodDefault<z.ZodBoolean>;
53
+ }, z.core.$strict>]>>;
19
54
  outfile: z.ZodOptional<z.ZodString>;
20
55
  index: z.ZodDefault<z.ZodBoolean>;
21
56
  prompt: z.ZodOptional<z.ZodString>;
22
- }, z.core.$strip>]>;
57
+ }, z.core.$strict>]>;
23
58
  /**
24
59
  * Upload destination configuration.
25
60
  *
@@ -40,7 +75,7 @@ declare const UploadConfigSchema: z.ZodObject<{
40
75
  clientId: z.ZodString;
41
76
  clientSecret: z.ZodString;
42
77
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
43
- }, z.core.$strip>;
78
+ }, z.core.$strict>;
44
79
  /** Root configuration for srcpack. */
45
80
  declare const ConfigSchema: z.ZodObject<{
46
81
  root: z.ZodDefault<z.ZodString>;
@@ -52,21 +87,27 @@ declare const ConfigSchema: z.ZodObject<{
52
87
  clientId: z.ZodString;
53
88
  clientSecret: z.ZodString;
54
89
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
- }, z.core.$strip>, z.ZodArray<z.ZodObject<{
90
+ }, z.core.$strict>, z.ZodArray<z.ZodObject<{
56
91
  provider: z.ZodLiteral<"gdrive">;
57
92
  folderId: z.ZodOptional<z.ZodString>;
58
93
  clientId: z.ZodString;
59
94
  clientSecret: z.ZodString;
60
95
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
61
- }, z.core.$strip>>]>>;
96
+ }, z.core.$strict>>]>>;
62
97
  bundles: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodObject<{
63
- include: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
98
+ include: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
99
+ linear: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
100
+ team: z.ZodString;
101
+ project: z.ZodOptional<z.ZodString>;
102
+ includeClosed: z.ZodDefault<z.ZodBoolean>;
103
+ }, z.core.$strict>]>>;
64
104
  outfile: z.ZodOptional<z.ZodString>;
65
105
  index: z.ZodDefault<z.ZodBoolean>;
66
106
  prompt: z.ZodOptional<z.ZodString>;
67
- }, z.core.$strip>]>>;
68
- }, z.core.$strip>;
107
+ }, z.core.$strict>]>>;
108
+ }, z.core.$strict>;
69
109
  export type UploadConfig = z.infer<typeof UploadConfigSchema>;
110
+ export type LinearSourceInput = z.input<typeof LinearSourceSchema>;
70
111
  export type BundleConfig = z.infer<typeof BundleConfigSchema>;
71
112
  export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
72
113
  export type Config = z.infer<typeof ConfigSchema>;
package/dist/index.js CHANGED
@@ -18292,29 +18292,57 @@ var PatternsSchema = exports_external.union([
18292
18292
  exports_external.string().min(1),
18293
18293
  exports_external.array(exports_external.string().min(1)).min(1)
18294
18294
  ]);
18295
+ var IdentifierSchema = exports_external.string().trim().min(1);
18296
+ var BundleNameSchema = exports_external.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "Bundle name must start with a letter or digit and contain only letters, digits, dot, underscore or hyphen");
18297
+ var LinearSourceSchema = exports_external.union([
18298
+ IdentifierSchema,
18299
+ exports_external.strictObject({
18300
+ team: IdentifierSchema,
18301
+ project: IdentifierSchema.optional(),
18302
+ includeClosed: exports_external.boolean().default(false)
18303
+ })
18304
+ ]);
18295
18305
  var BundleConfigSchema = exports_external.union([
18296
18306
  exports_external.string().min(1),
18297
18307
  exports_external.array(exports_external.string().min(1)).min(1),
18298
- exports_external.object({
18299
- include: PatternsSchema,
18300
- outfile: exports_external.string().optional(),
18308
+ exports_external.strictObject({
18309
+ include: PatternsSchema.optional(),
18310
+ linear: LinearSourceSchema.optional(),
18311
+ outfile: exports_external.string().min(1).optional(),
18301
18312
  index: exports_external.boolean().default(true),
18302
18313
  prompt: exports_external.string().optional()
18314
+ }).refine((bundle) => bundle.include || bundle.linear, {
18315
+ message: 'Bundle needs a source: "include" patterns, "linear", or both'
18303
18316
  })
18304
18317
  ]);
18305
- var UploadConfigSchema = exports_external.object({
18318
+ var UploadConfigSchema = exports_external.strictObject({
18306
18319
  provider: exports_external.literal("gdrive"),
18307
- folderId: exports_external.string().optional(),
18308
- clientId: exports_external.string().min(1),
18309
- clientSecret: exports_external.string().min(1),
18320
+ folderId: IdentifierSchema.optional(),
18321
+ clientId: IdentifierSchema,
18322
+ clientSecret: IdentifierSchema,
18310
18323
  exclude: exports_external.array(exports_external.string()).optional()
18311
18324
  });
18312
- var ConfigSchema = exports_external.object({
18325
+ var ConfigSchema = exports_external.strictObject({
18313
18326
  root: exports_external.string().default(""),
18314
18327
  outDir: exports_external.string().default(".srcpack"),
18315
18328
  emptyOutDir: exports_external.boolean().optional(),
18316
18329
  upload: exports_external.union([UploadConfigSchema, exports_external.array(UploadConfigSchema).min(1)]).optional(),
18317
- bundles: exports_external.record(exports_external.string(), BundleConfigSchema)
18330
+ bundles: exports_external.record(BundleNameSchema, BundleConfigSchema)
18331
+ }).superRefine((config2, ctx) => {
18332
+ const uploads = config2.upload ? Array.isArray(config2.upload) ? config2.upload : [config2.upload] : [];
18333
+ const names = new Set(Object.keys(config2.bundles));
18334
+ uploads.forEach((upload, i) => {
18335
+ const path = Array.isArray(config2.upload) ? ["upload", i, "exclude"] : ["upload", "exclude"];
18336
+ for (const name of upload.exclude ?? []) {
18337
+ if (!names.has(name)) {
18338
+ ctx.addIssue({
18339
+ code: "custom",
18340
+ path,
18341
+ message: `Unknown bundle "${name}"`
18342
+ });
18343
+ }
18344
+ }
18345
+ });
18318
18346
  });
18319
18347
  function defineConfig(config2) {
18320
18348
  return config2;
@@ -18326,13 +18354,22 @@ class ConfigError extends Error {
18326
18354
  this.name = "ConfigError";
18327
18355
  }
18328
18356
  }
18357
+ function flatten(issue2, prefix = []) {
18358
+ const path = [...prefix, ...issue2.path];
18359
+ const nested = issue2.code === "invalid_union" ? issue2.errors?.flat() : issue2.code === "invalid_key" ? issue2.issues : undefined;
18360
+ return nested?.length ? nested.flatMap((child) => flatten(child, path)) : [{ ...issue2, path }];
18361
+ }
18362
+ function describe3(issues) {
18363
+ const leaves = issues.flatMap((issue2) => flatten(issue2));
18364
+ const specific = leaves.filter((leaf) => leaf.code !== "invalid_type");
18365
+ const best = (specific.length ? specific : leaves).reduce((a, b) => b.path.length > a.path.length ? b : a);
18366
+ const path = best.path.join(".");
18367
+ return path ? `${path}: ${best.message}` : best.message;
18368
+ }
18329
18369
  function parseConfig(value) {
18330
18370
  const result = ConfigSchema.safeParse(value);
18331
18371
  if (!result.success) {
18332
- const issue2 = result.error.issues[0];
18333
- const path = issue2.path.join(".");
18334
- const message = path ? `${path}: ${issue2.message}` : issue2.message;
18335
- throw new ConfigError(message);
18372
+ throw new ConfigError(describe3(result.error.issues));
18336
18373
  }
18337
18374
  const config2 = result.data;
18338
18375
  config2.root = config2.root ? resolve(expandPath(config2.root)) : process.cwd();
@@ -0,0 +1,17 @@
1
+ import type { LinearSourceInput } from "./config.ts";
2
+ export declare class LinearError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /**
6
+ * Resolve a `linear` bundle source to one virtual file per issue.
7
+ *
8
+ * Paths are namespaced under `linear/issues/` so they sort together, read as
9
+ * files in the index, and can be filtered with ordinary `!` exclusions.
10
+ *
11
+ * The return type is structural rather than `Entry` from `bundle.ts`: a source
12
+ * shouldn't depend on the module that orchestrates it.
13
+ */
14
+ export declare function resolveLinearSource(source: LinearSourceInput): Promise<{
15
+ path: string;
16
+ content: string;
17
+ }[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srcpack",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-config CLI for bundling code into LLM-optimized context files",
5
5
  "keywords": [
6
6
  "llm",
@@ -94,5 +94,8 @@
94
94
  "typescript": "^6.0.3",
95
95
  "vitepress": "^2.0.0-alpha.19",
96
96
  "vitepress-plugin-llms": "^1.13.5"
97
+ },
98
+ "prettier": {
99
+ "proseWrap": "never"
97
100
  }
98
101
  }
package/src/bundle.ts CHANGED
@@ -1,16 +1,35 @@
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";
9
14
  import { isGitSource, resolveGitSource } from "./git.ts";
15
+ import { resolveLinearSource } from "./linear.ts";
10
16
 
11
17
  // Binary file detection: check first 8KB for null bytes (same heuristic as git)
12
18
  const BINARY_CHECK_SIZE = 8192;
13
19
 
20
+ /**
21
+ * Shared glob options. `followSymbolicLinks: false` is the load-bearing one:
22
+ * fast-glob defaults to true, so a link like `vendor -> ../../elsewhere` would
23
+ * be walked and every regular file under it bundled. Rejecting symlinks at the
24
+ * final component (see {@link isBundleable}) can't catch that — the leaf is an
25
+ * ordinary file; the escape happened in a directory along the way.
26
+ */
27
+ const GLOB_OPTIONS = {
28
+ onlyFiles: true,
29
+ dot: true,
30
+ followSymbolicLinks: false,
31
+ } as const;
32
+
14
33
  /**
15
34
  * Whether a path can be read into a bundle: an existing regular text file.
16
35
  * Globs only yield files, but git can name a submodule directory or a file
@@ -46,16 +65,29 @@ async function isBundleable(filePath: string): Promise<boolean> {
46
65
  }
47
66
  }
48
67
 
49
- export interface FileEntry {
50
- path: string; // Relative path from cwd
51
- lines: number; // Line count in source file
68
+ /**
69
+ * One bundle member, before its content is laid out.
70
+ *
71
+ * `content` present means the entry is virtual — produced by a non-filesystem
72
+ * source such as Linear — and its `path` is synthetic. Absent means an ordinary
73
+ * file, read from disk at bundle time.
74
+ */
75
+ export interface Entry {
76
+ path: string;
77
+ content?: string;
78
+ }
79
+
80
+ /** One line of the bundle index — a file or a virtual entry, once laid out. */
81
+ export interface IndexEntry {
82
+ path: string; // Relative path from cwd, or a synthetic path
83
+ lines: number; // Line count in the entry's content
52
84
  startLine: number; // Start line in bundle (1-indexed)
53
85
  endLine: number; // End line in bundle
54
86
  }
55
87
 
56
88
  export interface BundleResult {
57
89
  content: string;
58
- index: FileEntry[];
90
+ index: IndexEntry[];
59
91
  }
60
92
 
61
93
  /**
@@ -88,6 +120,9 @@ function normalizePatterns(config: BundleConfigInput): {
88
120
  patterns = [config];
89
121
  } else if (Array.isArray(config)) {
90
122
  patterns = config;
123
+ } else if (config.include === undefined) {
124
+ // A `linear`-only bundle has no patterns at all
125
+ patterns = [];
91
126
  } else {
92
127
  patterns = Array.isArray(config.include)
93
128
  ? config.include
@@ -156,25 +191,29 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
156
191
  // Skip empty lines and comments
157
192
  if (!trimmed || trimmed.startsWith("#")) continue;
158
193
 
194
+ // A trailing slash only says "directory", which is what this prunes anyway.
195
+ // Stripping it first matters: `node_modules/` is the common spelling, and
196
+ // testing for "/" before stripping rejected every one of them.
197
+ const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
198
+
159
199
  // Skip patterns with special gitignore features we can't safely convert:
160
200
  // - Root-anchored (starts with /)
161
201
  // - Contains globs (*, ?, [)
162
202
  // - Contains path separators (complex paths)
163
203
  // - Escaped characters
164
204
  if (
165
- trimmed.startsWith("/") ||
166
- trimmed.includes("*") ||
167
- trimmed.includes("?") ||
168
- trimmed.includes("[") ||
169
- trimmed.includes("/") ||
170
- trimmed.includes("\\")
205
+ name.startsWith("/") ||
206
+ name.includes("*") ||
207
+ name.includes("?") ||
208
+ name.includes("[") ||
209
+ name.includes("/") ||
210
+ name.includes("\\")
171
211
  ) {
172
212
  continue;
173
213
  }
174
214
 
175
215
  // Only convert simple directory names (e.g., "node_modules", "dist")
176
216
  // These are safe to prune at any depth
177
- const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
178
217
  if (name && /^[\w.-]+$/.test(name)) {
179
218
  patterns.push(`**/${name}/**`);
180
219
  }
@@ -184,28 +223,98 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
184
223
  }
185
224
 
186
225
  interface GitignoreResult {
187
- ignore: Ignore;
226
+ /** Whether git would ignore this cwd-relative posix path. */
227
+ ignores: (path: string) => boolean;
188
228
  globPatterns: string[];
189
229
  }
190
230
 
231
+ /** One directory's ignore rules. `dir` is a cwd-relative prefix, "" for root. */
232
+ interface IgnoreLayer {
233
+ dir: string;
234
+ ig: Ignore;
235
+ }
236
+
191
237
  /**
192
- * Load and parse .gitignore file from a directory.
193
- * Returns both an Ignore instance for filtering and glob patterns for fast-glob.
238
+ * Read an ignore file. Only a missing file means "no rules" — a permission or
239
+ * I/O error would otherwise widen the bundle to exactly the files someone chose
240
+ * to hide, so it fails the run instead.
241
+ */
242
+ async function readIgnoreFile(path: string): Promise<string | null> {
243
+ try {
244
+ return await readFile(path, "utf-8");
245
+ } catch (error) {
246
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
247
+ throw new ConfigError(
248
+ `Cannot read "${path}": ${(error as Error).message}. ` +
249
+ "srcpack stops rather than bundle files it cannot confirm are ignored.",
250
+ );
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Load the .gitignore rules that apply anywhere under `cwd`.
256
+ *
257
+ * Git resolves ignores per directory: a path is governed by the ignore file in
258
+ * its own directory and in every parent, deepest rule winning. Reading only the
259
+ * root file bundles whatever a nested .gitignore hides — `packages/app/.env` in
260
+ * a monorepo being the case that matters, since ignored secrets staying out is
261
+ * a documented guarantee rather than a convenience.
194
262
  */
195
263
  async function loadGitignore(cwd: string): Promise<GitignoreResult> {
196
- const ig = ignore();
197
- const gitignorePath = join(cwd, ".gitignore");
198
- let globPatterns: string[] = [];
264
+ const rootContent = await readIgnoreFile(join(cwd, ".gitignore"));
265
+ const globPatterns = rootContent
266
+ ? gitignoreToGlobPatterns(rootContent.split("\n"))
267
+ : [];
268
+
269
+ const layers: IgnoreLayer[] = [];
270
+ if (rootContent) layers.push({ dir: "", ig: ignore().add(rootContent) });
271
+
272
+ // Pruned by the root file's directory patterns: no point walking node_modules
273
+ // to collect ignore files that only govern paths already ignored.
274
+ const nested = await glob(["**/.gitignore"], {
275
+ cwd,
276
+ dot: true,
277
+ onlyFiles: true,
278
+ followSymbolicLinks: false,
279
+ ignore: globPatterns,
280
+ });
199
281
 
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
282
+ for (const file of nested) {
283
+ const content = await readIgnoreFile(join(cwd, file));
284
+ if (content) layers.push({ dir: dirname(file), ig: ignore().add(content) });
206
285
  }
207
286
 
208
- return { ignore: ig, globPatterns };
287
+ return { ignores: makeIgnores(layers), globPatterns };
288
+ }
289
+
290
+ /** Resolve the layered rules for one path, deepest directory first. */
291
+ function makeIgnores(layers: IgnoreLayer[]): (path: string) => boolean {
292
+ if (layers.length === 0) return () => false;
293
+ const ordered = [...layers].sort((a, b) => b.dir.length - a.dir.length);
294
+
295
+ // First layer with an opinion wins; an explicit negation (`!keep.env`) is an
296
+ // opinion too, which is what lets a nested file re-include what its parent hid.
297
+ const opinion = (path: string): boolean | undefined => {
298
+ for (const { dir, ig } of ordered) {
299
+ if (dir && !path.startsWith(`${dir}/`)) continue;
300
+ const relative = dir ? path.slice(dir.length + 1) : path;
301
+ if (!relative || relative === "/") continue;
302
+ const { ignored, unignored } = ig.test(relative);
303
+ if (ignored) return true;
304
+ if (unignored) return false;
305
+ }
306
+ return undefined;
307
+ };
308
+
309
+ return (path: string) => {
310
+ // Ancestors first: git never descends into an ignored directory, so nothing
311
+ // beneath one can be negated back in.
312
+ const segments = path.split("/");
313
+ for (let i = 1; i < segments.length; i++) {
314
+ if (opinion(`${segments.slice(0, i).join("/")}/`)) return true;
315
+ }
316
+ return opinion(path) === true;
317
+ };
209
318
  }
210
319
 
211
320
  /**
@@ -221,14 +330,40 @@ function isExternalPattern(pattern: string): boolean {
221
330
  return normalized.startsWith("../");
222
331
  }
223
332
 
333
+ /**
334
+ * The key two paths are compared by: canonical spelling, then case folded, on
335
+ * every platform. A case-insensitive filesystem — the default on macOS and
336
+ * Windows — treats `Context.txt` and `context.txt` as one directory entry, and
337
+ * APFS additionally folds Unicode normalisation, so `Café.txt` written as
338
+ * precomposed U+00E9 and as `e` plus U+0301 is also one entry. Normalising
339
+ * before folding is what makes the comparison sound: equal inputs stay equal
340
+ * afterwards whether or not case folding preserves normalisation. `realpath`
341
+ * resolves
342
+ * an existing component to its on-disk spelling, but that doesn't cover these:
343
+ * an output not yet written has no on-disk spelling, and the destination entry
344
+ * is deliberately left unresolved so `rename` replaces a symlink rather than
345
+ * following it. A config that works in Linux CI and loses a bundle on the
346
+ * author's laptop is worse than one rejected everywhere, so the rule is the
347
+ * same on every platform rather than keyed to the filesystem under it.
348
+ *
349
+ * Comparison only. Paths used for I/O keep their original spelling, and
350
+ * ownership stays an exact match: folding there could only widen what srcpack
351
+ * deletes, which is the one direction that must never be widened by a guess.
352
+ */
353
+ export function pathKey(path: string): string {
354
+ return path.normalize("NFC").toLowerCase();
355
+ }
356
+
224
357
  /**
225
358
  * Whether a path is one srcpack writes. `outputs` holds absolute paths of
226
359
  * files or directories; a directory covers everything beneath it.
227
360
  */
228
361
  function isOwnOutput(filePath: string, outputs: string[]): boolean {
229
- return outputs.some(
230
- (out) => filePath === out || filePath.startsWith(out + sep),
231
- );
362
+ const key = pathKey(filePath);
363
+ return outputs.some((out) => {
364
+ const outKey = pathKey(out);
365
+ return key === outKey || key.startsWith(outKey + sep);
366
+ });
232
367
  }
233
368
 
234
369
  /**
@@ -280,33 +415,86 @@ export async function resolvePatterns(
280
415
  // Internal patterns (within cwd): respect .gitignore
281
416
  const internalPatterns = globs.filter((p) => !isExternalPattern(p));
282
417
  if (internalPatterns.length > 0) {
283
- const { ignore: gitignore, globPatterns } = await loadGitignore(cwd);
418
+ const { ignores, globPatterns } = await loadGitignore(cwd);
284
419
  const matches = await glob(internalPatterns, {
420
+ ...GLOB_OPTIONS,
285
421
  cwd,
286
- onlyFiles: true,
287
- dot: true,
288
422
  ignore: globPatterns,
289
423
  });
290
- await add(matches.filter((m) => !gitignore.ignores(m)));
424
+ await add(matches.filter((m) => !ignores(m)));
291
425
  }
292
426
 
293
427
  // External patterns: skip .gitignore (it doesn't apply outside cwd)
294
428
  const externalPatterns = globs.filter(isExternalPattern);
295
429
  if (externalPatterns.length > 0) {
296
- await add(
297
- await glob(externalPatterns, { cwd, onlyFiles: true, dot: true }),
298
- );
430
+ await add(await glob(externalPatterns, { ...GLOB_OPTIONS, cwd }));
299
431
  }
300
432
 
301
433
  // Force includes: bypass .gitignore (no ignore patterns passed to glob)
302
434
  if (force.length > 0) {
303
- await add(await glob(force, { cwd, onlyFiles: true, dot: true }));
435
+ await add(await glob(force, { ...GLOB_OPTIONS, cwd }));
304
436
  }
305
437
 
306
438
  // Sort for deterministic output
307
439
  return [...files].sort();
308
440
  }
309
441
 
442
+ /** Read the `linear` source off a bundle config, if it declares one. */
443
+ function getLinear(config: BundleConfigInput): LinearSourceInput | undefined {
444
+ if (typeof config === "object" && !Array.isArray(config)) {
445
+ return config.linear;
446
+ }
447
+ return undefined;
448
+ }
449
+
450
+ /**
451
+ * Resolve every source a bundle declares into a sorted entry list.
452
+ *
453
+ * Filesystem and `git:` sources produce paths ({@link resolvePatterns}, which
454
+ * never touches the network); `linear` produces virtual entries carrying their
455
+ * own content. Both then meet the same rules — `!` exclusions apply uniformly,
456
+ * and the result is sorted by path so bundles stay deterministic.
457
+ */
458
+ export async function resolveEntries(
459
+ config: BundleConfigInput,
460
+ cwd: string,
461
+ outputs: string[] = [],
462
+ ): Promise<Entry[]> {
463
+ const linearSource = getLinear(config);
464
+
465
+ // Sequential, not parallel: a bad pattern should fail before spending a
466
+ // network round trip, and a failed fetch shouldn't race a filesystem walk.
467
+ const paths = await resolvePatterns(config, cwd, outputs);
468
+ const entries: Entry[] = paths.map((path) => ({ path }));
469
+
470
+ if (linearSource) {
471
+ const { exclude } = normalizePatterns(config);
472
+ const excludeMatchers = exclude.map((p) => picomatch(p));
473
+ // Compare resolved paths, not the strings: an absolute pattern and a
474
+ // relative one name the same file with different spellings, and only the
475
+ // resolved form tells whether a real file occupies a synthetic path.
476
+ const taken = new Set(paths.map((path) => resolve(cwd, path)));
477
+
478
+ for (const entry of await resolveLinearSource(linearSource)) {
479
+ if (isExcluded(entry.path, excludeMatchers)) continue;
480
+ // `linear/issues/` is a reserved namespace once a bundle pulls issues:
481
+ // a real file there would put two entries in the index under one name,
482
+ // and an `!` exclusion can't drop one without dropping both.
483
+ if (taken.has(resolve(cwd, entry.path))) {
484
+ throw new ConfigError(
485
+ `Linear issue collides with the file "${entry.path}". ` +
486
+ "Rename the file, or narrow the include patterns so it isn't matched.",
487
+ );
488
+ }
489
+ entries.push(entry);
490
+ }
491
+ }
492
+
493
+ return entries.sort((a, b) =>
494
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
495
+ );
496
+ }
497
+
310
498
  /**
311
499
  * Count lines in a string (handles empty strings correctly)
312
500
  */
@@ -324,7 +512,7 @@ function countLines(content: string): number {
324
512
  * - ASCII-only characters for broad compatibility
325
513
  * - Line locations that point to actual file content
326
514
  */
327
- export function formatIndex(index: FileEntry[]): string {
515
+ export function formatIndex(index: IndexEntry[]): string {
328
516
  if (index.length === 0) return "# Index\n# (empty)";
329
517
 
330
518
  const count = index.length;
@@ -355,43 +543,46 @@ function formatSeparator(index: number, filePath: string): string {
355
543
  }
356
544
 
357
545
  /**
358
- * Create a bundle from a list of files.
546
+ * Create a bundle from a list of entries.
359
547
  * Line numbers in the index point to the first line of actual file content,
360
548
  * not to the separator line.
361
549
  */
362
550
  export async function createBundle(
363
- files: string[],
551
+ entries: Entry[],
364
552
  cwd: string,
365
553
  options: BundleOptions = {},
366
554
  ): Promise<BundleResult> {
367
555
  const { includeIndex = true } = options;
368
556
  // Normalize prompt: trim and treat whitespace-only as no prompt
369
557
  const prompt = options.prompt?.trim() || undefined;
370
- const index: FileEntry[] = [];
558
+ const index: IndexEntry[] = [];
371
559
  const contentParts: string[] = [];
372
560
  let currentLine = 1;
373
561
 
374
- for (let i = 0; i < files.length; i++) {
375
- const filePath = files[i]!;
376
- const content = await readFile(resolve(cwd, filePath), "utf-8");
562
+ for (let i = 0; i < entries.length; i++) {
563
+ const entry = entries[i]!;
564
+ const filePath = entry.path;
565
+ // Virtual entries carry their content; files are read from disk
566
+ const content =
567
+ entry.content ?? (await readFile(resolve(cwd, filePath), "utf-8"));
377
568
  const lines = countLines(content);
378
569
 
379
570
  // Separator takes 1 line, then content starts on next line
380
571
  const contentStartLine = currentLine + 1;
381
572
 
382
- const entry: FileEntry = {
573
+ const indexEntry: IndexEntry = {
383
574
  path: filePath,
384
575
  lines,
385
576
  startLine: contentStartLine,
386
577
  endLine: contentStartLine + Math.max(0, lines - 1),
387
578
  };
388
- index.push(entry);
579
+ index.push(indexEntry);
389
580
 
390
581
  contentParts.push(formatSeparator(i + 1, filePath));
391
582
  contentParts.push(content.endsWith("\n") ? content.slice(0, -1) : content);
392
583
 
393
584
  // Next separator line = after content
394
- currentLine = entry.endLine + 1;
585
+ currentLine = indexEntry.endLine + 1;
395
586
  }
396
587
 
397
588
  // Calculate prompt offset (prompt text + blank + "---" + blank)
@@ -494,8 +685,8 @@ export async function bundleOne(
494
685
  cwd: string,
495
686
  outputs: string[] = [],
496
687
  ): Promise<BundleResult> {
497
- const files = await resolvePatterns(config, cwd, outputs);
688
+ const entries = await resolveEntries(config, cwd, outputs);
498
689
  const includeIndex = getIncludeIndex(config);
499
690
  const prompt = await resolvePrompt(getPrompt(config), cwd);
500
- return createBundle(files, cwd, { includeIndex, prompt });
691
+ return createBundle(entries, cwd, { includeIndex, prompt });
501
692
  }