diff --git a/.gitignore b/.gitignore index 032043e..f44709d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ coverage /srcpack.config.ts /.srcpack/ +tmp/ diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 2fc88a4..8261878 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -103,6 +103,18 @@ export default defineConfig({ text: "001 — git: source tokens", link: "/adr/001-git-source-tokens", }, + { + text: "002 — Minimum Node version", + link: "/adr/002-minimum-node-version", + }, + { + text: "003 — Linear issues as virtual files", + link: "/adr/003-linear-issues-as-virtual-files", + }, + { + text: "004 — Path boundaries", + link: "/adr/004-path-boundaries", + }, ], }, ], diff --git a/README.md b/README.md index 4f37ecd..1853ab2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Srcpack +[![npm version](https://img.shields.io/npm/v/srcpack)](https://www.npmjs.com/package/srcpack) [![npm downloads](https://img.shields.io/npm/dm/srcpack)](https://www.npmjs.com/package/srcpack) [![CI](https://img.shields.io/github/actions/workflow/status/kriasoft/srcpack/ci.yml?branch=main&label=CI)](https://github.com/kriasoft/srcpack/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/srcpack)](./LICENSE) [![Discord](https://img.shields.io/discord/643523529131950086?label=Discord&logo=discord&logoColor=white)](https://discord.com/invite/aG83xEb6RX) + Zero-config CLI for bundling code into LLM-optimized context files. **Requirements:** Node.js 22.18+ or Bun @@ -48,14 +50,14 @@ Or add to `package.json`: ### Options -| Option | Default | Description | -| ------------- | ---------- | -------------------------------------- | -| `outDir` | `.srcpack` | Output directory for bundles | -| `emptyOutDir` | `true`\* | Empty output directory before bundling | -| `bundles` | — | Named bundles with glob patterns | -| `upload` | — | Upload destination(s) | +| Option | Default | Description | +| ------------- | ---------- | ------------------------------------- | +| `outDir` | `.srcpack` | Output directory for bundles | +| `emptyOutDir` | `true`\* | Empty output directory before writing | +| `bundles` | — | Named bundle definitions | +| `upload` | — | Upload destination(s) | -\*`emptyOutDir` defaults to `true` when `outDir` is inside project root. When `outDir` is outside root, a warning is emitted unless explicitly set. Emptying happens only on a full run, so `npx srcpack web` leaves other bundles in place. +\*Only the default `.srcpack` is emptied automatically — it's srcpack's directory by convention. Any other `outDir` needs an explicit `emptyOutDir: true`, so `outDir: "src"` can't quietly delete your sources. Emptying also happens only on a full run, so `npx srcpack web` leaves other bundles in place. ### Bundle Config @@ -75,6 +77,7 @@ Or add to `package.json`: // Full options { include: "src/**/*", + linear: { team: "ENG" }, // Linear issues as virtual files outfile: "~/Downloads/bundle.txt", // custom output path index: true, // include index header (default) prompt: "./prompts/review.md" // prepend from file (or inline text) @@ -85,6 +88,22 @@ Patterns follow glob syntax. Prefix with `!` to exclude, `+` to force-include (b A pattern can also name a set of changed files: `git:staged`, `git:unstaged`, `git:untracked`, `git:dirty`, or `git:` (e.g. `git:main`, `git:HEAD~3`). Deleted files are skipped, and `git:` compares against the merge base so a stale branch still reports only your own changes. See [Git sources](https://kriasoft.com/srcpack/configuration#git-sources-git-prefix). +### Linear Issues + +A bundle can include [Linear](https://linear.app) issues next to your code. Each issue becomes a virtual file at `linear/issues/ENG-123.md`, so it gets its own index entry and line range — letting you ask whether `[4] src/board.ts` actually implements `[2] ENG-123`. + +```typescript +bundles: { + backlog: { linear: "ENG" }, // non-terminal issues, team ENG + planning: { + include: ["docs/**/*.md"], + linear: { team: "ENG", project: "Roadmap" }, // scoped to one project + }, +} +``` + +Authentication reads `LINEAR_API_KEY` from the environment (Linear → Settings → Security & access → Personal API keys), never from the config file. `team` is required, completed/canceled/duplicate issues are excluded by default, and issues obey `!` exclusions like any other entry. See [Linear issues](https://kriasoft.com/srcpack/configuration#linear-issues). + ### Google Drive Upload To upload bundles to Google Drive, add OAuth credentials to your config: @@ -115,16 +134,16 @@ export default defineConfig({ ```text # Index (3 files) -# [1] src/index.ts L1-L42 (42 lines) -# [2] src/utils.ts L43-L89 (47 lines) -# [3] src/api.ts L90-L150 (61 lines) +# [1] src/api.ts L7-L67 (61 lines) +# [2] src/index.ts L69-L110 (42 lines) +# [3] src/utils.ts L112-L158 (47 lines) -#==> [1] src/index.ts <== -import { utils } from "./utils"; +#==> [1] src/api.ts <== +export async function fetchBoard() { ... -#==> [2] src/utils.ts <== -export function utils() { +#==> [2] src/index.ts <== +import { utils } from "./utils"; ... ``` @@ -141,7 +160,7 @@ npx srcpack --staged # Bundle staged changes (no config needed) npx srcpack --dirty # Bundle staged + unstaged + untracked npx srcpack --since main # Bundle changes since main npx srcpack --dry-run # Preview without writing files -npx srcpack --emptyOutDir # Empty output directory before bundling +npx srcpack --emptyOutDir # Empty output directory before writing npx srcpack --no-emptyOutDir # Keep existing files in output directory npx srcpack --no-upload # Bundle only, skip upload npx srcpack init # Interactive config setup diff --git a/docs/adr/001-git-source-tokens.md b/docs/adr/001-git-source-tokens.md index b42c8af..4f969e5 100644 --- a/docs/adr/001-git-source-tokens.md +++ b/docs/adr/001-git-source-tokens.md @@ -4,9 +4,7 @@ ## Context -Bundling "what I'm currently changing" is the most common ad-hoc need: hand an -LLM your staged diff, or everything on this branch, for review. Globs can't -express it — the file set comes from git, not from the filesystem layout. +Bundling "what I'm currently changing" is the most common ad-hoc need: hand an LLM your staged diff, or everything on this branch, for review. Globs can't express it — the file set comes from git, not from the filesystem layout. The obvious-looking API spreads a resolved list into the pattern array: @@ -14,75 +12,38 @@ The obvious-looking API spreads a resolved list into the pattern array: review: [...$staged, "!bun.lock"]; ``` -It reads well and it's wrong. A spread forces `$staged` to be concrete at -config **import** time, which means git runs when the module loads (even for -`srcpack docs`), the config stops being inspectable data, `package.json` config -becomes impossible, and import-time `cwd` may differ from the resolved `root`. -Worst of all, concrete paths land in an array that is later matched as globs, -so a staged file named `src/[id].tsx` silently matches nothing. +It reads well and it's wrong. A spread forces `$staged` to be concrete at config **import** time, which means git runs when the module loads (even for `srcpack docs`), the config stops being inspectable data, `package.json` config becomes impossible, and import-time `cwd` may differ from the resolved `root`. Worst of all, concrete paths land in an array that is later matched as globs, so a staged file named `src/[id].tsx` silently matches nothing. ## Decision -A pattern may be a `git:` source instead of a glob, resolved lazily inside -`resolvePatterns()` alongside globs: +A pattern may be a `git:` source instead of a glob, resolved lazily inside `resolvePatterns()` alongside globs: ```ts review: ["git:staged", "!bun.lock"]; ``` -Sources: `git:staged`, `git:unstaged`, `git:untracked`, `git:dirty`, and -`git:` for any revision or range. +Sources: `git:staged`, `git:unstaged`, `git:untracked`, `git:dirty`, and `git:` for any revision or range. -CLI flags `--staged`, `--dirty`, and `--since ` build a one-off bundle from -the same tokens, and work with no config file at all. +CLI flags `--staged`, `--dirty`, and `--since ` build a one-off bundle from the same tokens, and work with no config file at all. Supporting decisions: -- **Deleted and unmerged entries are filtered** (`--diff-filter=ACMR`), and - every candidate is stat-checked before bundling. Git lists paths; only some - of them are readable regular files (submodules, or a file deleted after git - listed it). -- **`git:` uses `git diff --merge-base`** for a single revision. `git:main` - on a branch that has fallen behind main would otherwise report other people's - commits. For an ancestor like `HEAD~3` the merge base is the revision itself, - so this is a no-op — one rule that's right in both cases. Ranges pass through - verbatim. -- **A source selects paths; content always comes from the worktree.** Reading - staged blobs would put content in the bundle that doesn't match the files on - disk — confusing when the LLM's answer cites a line. -- **`.gitignore` does not apply** to git sources. Anything git reports is either - tracked (possibly force-added past `.gitignore`, and deliberately so) or was - filtered by `--exclude-standard` already. -- **`!git:...` and `+git:...` are errors.** Exclusion has no clear meaning, and - force-include is already implied. Failing loudly beats a silent no-op. -- **Empty bundles are not written**, and a previous run's file is removed. - "Nothing staged" is routine, and a stale bundle that then gets uploaded to - Drive is worse than no file. -- **Symlinks are never followed** (`lstat`, not `stat`). Git happily tracks a - link pointing anywhere; following one would bundle a file from outside the - project under an innocuous in-repo name. -- **Ad-hoc CLI bundles are never uploaded.** The user configured upload for the - bundles they declared, and `upload.exclude` cannot name a bundle that only - exists for one run. -- **`outDir` and every configured `outfile` are excluded from every bundle.** - Ad-hoc runs don't empty `outDir`, so `git:untracked` reports the last run's - bundle and each rerun nests it one level deeper. +- **Deleted and unmerged entries are filtered** (`--diff-filter=ACMR`), and every candidate is stat-checked before bundling. Git lists paths; only some of them are readable regular files (submodules, or a file deleted after git listed it). +- **`git:` uses `git diff --merge-base`** for a single revision. `git:main` on a branch that has fallen behind main would otherwise report other people's commits. For an ancestor like `HEAD~3` the merge base is the revision itself, so this is a no-op — one rule that's right in both cases. Ranges pass through verbatim. +- **A source selects paths; content always comes from the worktree.** Reading staged blobs would put content in the bundle that doesn't match the files on disk — confusing when the LLM's answer cites a line. +- **`.gitignore` does not apply** to git sources. Anything git reports is either tracked (possibly force-added past `.gitignore`, and deliberately so) or was filtered by `--exclude-standard` already. +- **`!git:...` and `+git:...` are errors.** Exclusion has no clear meaning, and force-include is already implied. Failing loudly beats a silent no-op. +- **Empty bundles are not written**, and a previous run's file is removed. "Nothing staged" is routine, and a stale bundle that then gets uploaded to Drive is worse than no file. +- **Symlinks are never followed** (`lstat`, not `stat`). Git happily tracks a link pointing anywhere; following one would bundle a file from outside the project under an innocuous in-repo name. +- **Ad-hoc CLI bundles are never uploaded.** The user configured upload for the bundles they declared, and `upload.exclude` cannot name a bundle that only exists for one run. +- **`outDir` and every configured `outfile` are excluded from every bundle.** Ad-hoc runs don't empty `outDir`, so `git:untracked` reports the last run's bundle and each rerun nests it one level deeper. ## Alternatives -- **Typed helpers** (`[staged(), "!bun.lock"]`) — real autocomplete, but the - array becomes `(string | Source)[]`, it can't work in `package.json`, and it - adds permanent public exports. -- **A `from` field** (`{ from: "staged", include: "src/**" }`) — conceptually - cleaner (a source isn't a glob), but adds a second axis plus an `exclude` - field, giving two ways to say the same thing. -- **An async resolver** (`include: async ({ git }) => …`) — maximum power, but - the config is no longer data and `git.*` becomes an API to maintain. +- **Typed helpers** (`[staged(), "!bun.lock"]`) — real autocomplete, but the array becomes `(string | Source)[]`, it can't work in `package.json`, and it adds permanent public exports. +- **A `from` field** (`{ from: "staged", include: "src/**" }`) — conceptually cleaner (a source isn't a glob), but adds a second axis plus an `exclude` field, giving two ways to say the same thing. +- **An async resolver** (`include: async ({ git }) => …`) — maximum power, but the config is no longer data and `git.*` becomes an API to maintain. ## Consequences -The pattern array gains a second kind of entry, so `git:` is now reserved as a -scheme (a branch named `staged` needs `git:refs/heads/staged`). In exchange -there is no new config shape, no new export, and the feature composes with `!` -exclusions and globs for free. Future non-glob sources can reuse the -`scheme:` convention. +The pattern array gains a second kind of entry, so `git:` is now reserved as a scheme (a branch named `staged` needs `git:refs/heads/staged`). In exchange there is no new config shape, no new export, and the feature composes with `!` exclusions and globs for free. Future non-glob sources can reuse the `scheme:` convention. diff --git a/docs/adr/002-minimum-node-version.md b/docs/adr/002-minimum-node-version.md index 56f3f96..051f231 100644 --- a/docs/adr/002-minimum-node-version.md +++ b/docs/adr/002-minimum-node-version.md @@ -4,55 +4,29 @@ ## Context -`srcpack.config.ts` is the primary config format — the first entry in -`searchPlaces`, and what `srcpack init` writes. Loading TypeScript at runtime -is therefore not optional, and until now cosmiconfig carried its own -`typescript` dependency to do it. +`srcpack.config.ts` is the primary config format — the first entry in `searchPlaces`, and what `srcpack init` writes. Loading TypeScript at runtime is therefore not optional, and until now cosmiconfig carried its own `typescript` dependency to do it. -cosmiconfig 10 removes that dependency in favour of Node's built-in type -stripping, and requires `^22.18 || >=24`. Type stripping is enabled by default -from Node 22.18, so the loader works on any runtime cosmiconfig itself accepts — -but on Node 20 a `.ts` config now fails to load at all. +cosmiconfig 10 removes that dependency in favour of Node's built-in type stripping, and requires `^22.18 || >=24`. Type stripping is enabled by default from Node 22.18, so the loader works on any runtime cosmiconfig itself accepts — but on Node 20 a `.ts` config now fails to load at all. -The declared floor was `>=18.0.0`, which had already drifted from reality: -Node 18 reached end-of-life 2025-04-30 and Node 20 followed on 2026-04-30. +The declared floor was `>=18.0.0`, which had already drifted from reality: Node 18 reached end-of-life 2025-04-30 and Node 20 followed on 2026-04-30. ## Decision -`engines.node` becomes `^22.18.0 || >=24`, matching cosmiconfig's own range -rather than inventing a looser one. +`engines.node` becomes `^22.18.0 || >=24`, matching cosmiconfig's own range rather than inventing a looser one. -Pinning to the dependency's range is deliberate. A floor of `>=20` would install -cleanly and then fail at the first `srcpack.config.ts` — the failure would -surface as a confusing parse error rather than an unmet engine warning at -install time. +Pinning to the dependency's range is deliberate. A floor of `>=20` would install cleanly and then fail at the first `srcpack.config.ts` — the failure would surface as a confusing parse error rather than an unmet engine warning at install time. ## Alternatives -- **Keep `>=18` and bundle a TypeScript parser** — restores Node 20 support at - the cost of a heavyweight dependency for a runtime everyone's package manager - already warns about. -- **Drop `.ts` config support below Node 22.18, keep the floor low** — two - behaviours for one documented feature, discovered only at run time. +- **Keep `>=18` and bundle a TypeScript parser** — restores Node 20 support at the cost of a heavyweight dependency for a runtime everyone's package manager already warns about. +- **Drop `.ts` config support below Node 22.18, keep the floor low** — two behaviours for one documented feature, discovered only at run time. ## Consequences -Config files must use erasable syntax only. Type annotations, `satisfies`, and -`import type` are fine; `enum` and `namespace` are not — Node strips types, it -does not compile them. `defineConfig` objects use none of the latter, so the -`init` template and every documented example are unaffected. - -Node also derives a `.ts` file's module format from the nearest package.json -`type`, so in a CommonJS project the template's `import { defineConfig }` line -is a syntax error — the bundled TypeScript compiler used to hide this. So -`srcpack.config.mts` joins `searchPlaces`, and `init` writes it whenever the -project is not `"type": "module"`. `.mts` is unconditionally ESM and loads -either way; `.ts` stays the default for ESM projects because it is the name -the docs use. - -The test suite runs on Bun, which loads either extension regardless of package -type and so cannot see this class of failure. CI installs the packed tarball -into a CommonJS project and runs the CLI under Node to cover it. - -Both EOL runtimes are dropped in one step, so the next floor bump can wait for -a real forcing function rather than following each dependency's minor releases. +Config files must use erasable syntax only. Type annotations, `satisfies`, and `import type` are fine; `enum` and `namespace` are not — Node strips types, it does not compile them. `defineConfig` objects use none of the latter, so the `init` template and every documented example are unaffected. + +Node also derives a `.ts` file's module format from the nearest package.json `type`, so in a CommonJS project the template's `import { defineConfig }` line is a syntax error — the bundled TypeScript compiler used to hide this. So `srcpack.config.mts` joins `searchPlaces`, and `init` writes it whenever the project is not `"type": "module"`. `.mts` is unconditionally ESM and loads either way; `.ts` stays the default for ESM projects because it is the name the docs use. + +The test suite runs on Bun, which loads either extension regardless of package type and so cannot see this class of failure. CI installs the packed tarball into a CommonJS project and runs the CLI under Node to cover it. + +Both EOL runtimes are dropped in one step, so the next floor bump can wait for a real forcing function rather than following each dependency's minor releases. diff --git a/docs/adr/003-linear-issues-as-virtual-files.md b/docs/adr/003-linear-issues-as-virtual-files.md new file mode 100644 index 0000000..570bc81 --- /dev/null +++ b/docs/adr/003-linear-issues-as-virtual-files.md @@ -0,0 +1,74 @@ +# ADR 003: Linear issues as virtual files, under a `linear` bundle key + +**Status:** Accepted — 2026-08-15 + +## Context + +A bundle answers questions about code. Many of those questions are really about intent — is this ticket implemented, does this code still match what we agreed — and the intent lives in an issue tracker, not the repo. Pasting both into a chat by hand is the workflow srcpack exists to remove. + +[ADR 001](./001-git-source-tokens.md) closed by suggesting that "future non-glob sources can reuse the `scheme:` convention", which points at `"linear:ENG"`. That turns out to be the wrong precedent to follow here, for two reasons. + +**`git:` names a closed set; Linear does not.** The git grammar is five words plus a rev, and it will never need a sixth. Linear has team, project, state, label, assignee, cycle, updated-since. The first person who wants "project X, excluding Done" forces `linear:ENG?state=open&project=roadmap` — a query DSL to parse, validate, document and keep compatible. That is the part that would not age. + +**`git:` yields paths; Linear yields documents.** Every stage after `resolvePatterns()` — `lstat`, binary sniffing, `.gitignore`, `isOwnOutput` — assumes a file on disk. `git:` slots in because it still hands back worktree paths. Linear has no path to hand back. + +## Decision + +**Issues become virtual files.** Each issue is rendered to markdown and given a synthetic path, `linear/issues/ENG-123.md`. Internally an entry is: + +```ts +interface Entry { + path: string; + content?: string; // present = virtual; absent = read from disk +} +``` + +Everything downstream then works unchanged: index numbering, `#==>` separators, line-range math, deterministic sort, and `!` exclusions all apply to issues exactly as they do to files. One file per issue, not one blob, so each issue earns its own index line and can be cited as `[2] ENG-123`. + +**A roster leads the set, at `linear/issues.md`.** One file per issue makes each citable, but it also means the index — the thing a model reads first — becomes forty lines of `linear/issues/ENG-*.md`, which carry no information. For a code file the path _is_ the summary; for an issue the identifier is opaque. The roster restores that: a scope heading, a count per state, and a row per issue with state, priority and title. + +It also carries the only sensible ordering. Entries sort by path as text, so `ENG-2` falls between `ENG-19` and `ENG-20`; natural-sorting the whole bundle to fix that would change ordering for every file in every bundle, which is a much larger claim than this needs. The roster is ordered by issue number instead, and the bodies keep the uniform path sort. + +The path sits outside `linear/issues/` so it sorts ahead of the issues it describes (`.` precedes `/`), and it is an ordinary entry — the same collision check and `!` exclusions apply, so `!linear/issues.md` drops it. + +**The surface is a `linear` key on the bundle, not a pattern token:** + +```ts +bundles: { + backlog: { linear: "ENG" }, + planning: { + include: ["docs/**/*.md"], + linear: { team: "ENG", project: "Roadmap" }, + }, +} +``` + +This mirrors `upload.provider: "gdrive"` — srcpack already ships a first-party network integration, so an input provider is a shape the config already has. A zod object gives typed options and autocomplete with no grammar to invent. + +Consequences of that choice, decided deliberately: + +- **`include` becomes optional**, and a bundle is required to declare at least one source. A Linear-only bundle is a legitimate thing to want. +- **Auth reads `LINEAR_API_KEY` from the environment**, and is not a config field. Config files are committed; `package.json` config cannot express `process.env` at all. This differs from the `gdrive` precedent, which requires its credentials in config — that precedent is not worth copying. It also keeps the whole `linear` key expressible as plain JSON. +- **Closed config objects are strict**, and not only this one. A stripped-through `projet` would widen the query from one project to the entire team — exactly the failure the required `team` and the ambiguity check exist to prevent — but the same hazard was already there in `emptyOutdir` and `upload.exlude`, the second of which now decides whether issue text reaches Google Drive. Unknown keys are rejected across the whole config rather than in `linear` alone. +- **`team` is required.** A workspace-wide fetch reads as innocuous in config and can pull thousands of issues into a context window. +- **A project name is resolved to an id within the team**, and must match exactly one project. Names are neither unique nor stable, and filtering issues by name would silently union two projects that happen to share one. +- **Unknown team or project is an error, not an empty result.** Linear answers an unknown team key with an empty issue list, so without a preflight check a typo produces a bundle that looks successful and contains nothing. +- **A real file colliding with a synthetic path is an error.** Two entries with one name is not a coin worth flipping. +- **`--dry-run` hits the network.** A dry run answers "what would this produce right now", which it cannot do offline. No cache, no `--no-remote`. + +## Alternatives + +- **`"linear:ENG"` pattern token** — smallest surface today, but the filter grammar problem above makes it the worst option in a year. It holds no advantage in `package.json` config: keeping auth in the environment means the chosen `linear` key is plain JSON too. +- **A typed function in `include`** (`linear({ team: "ENG" })`) — most powerful, and it would let users write their own sources. It freezes a public plugin contract for exactly one consumer, and breaks `package.json` config. `Entry` is deliberately kept internal: it is the foundation such an API would need, if a second and third integration ever justify one. +- **A separate `srcpack-linear` package** — keeps core lean, but core is not lean in that sense already (`@googleapis/drive` is a dependency), and it costs users a second install plus a version matrix. +- **A top-level `sources` section** referenced by name from bundles — two places to configure one bundle, for no gain at one provider. + +## Consequences + +The bundle object gains a second axis: it now takes files, issues, or both. Each future integration would add another key rather than composing, and that is the accepted cost — if a third one arrives, the `sources: [{ provider }]` shape that `upload` already demonstrates is the natural generalization, and 0.x can take that break. + +Bundling is no longer purely local for bundles that declare `linear`: those runs require network and a valid token, and their output changes when tickets change. Bundles without a `linear` key issue no requests at all. + +That also forced a fix to the run order. `outDir` was emptied before bundles were resolved, so any resolution failure left the directory empty and the previous run's output gone. With a purely local source that needed a rare filesystem error; with a remote one an expired token or a rate limit does it on an ordinary afternoon. Emptying now happens after every bundle has resolved and immediately before the writes — resolution never needed the files removed, since `ownOutputs` already keeps srcpack from bundling its own output. + +That makes a run resolution-safe, not atomic: a failure during the writes themselves can still leave `outDir` partially rewritten. Making that atomic wants a temp directory and a swap, which is a separate change and not one this feature forces. diff --git a/docs/adr/004-path-boundaries.md b/docs/adr/004-path-boundaries.md new file mode 100644 index 0000000..3e35cba --- /dev/null +++ b/docs/adr/004-path-boundaries.md @@ -0,0 +1,46 @@ +# ADR 004: What srcpack may read, delete and overwrite + +**Status:** Accepted — 2026-08-15 + +## Context + +A pre-release audit turned up five defects that look unrelated and are not: + +- `outDir: "src"` deleted the sources the same config asked to bundle. Emptying was automatic for any `outDir` inside the project, and a source directory is inside the project. +- `.srcpack` as a symlink emptied the directory it pointed at, and wrote bundles into it. The check that called it "inside the project" was lexical; `rm` and `writeFile` are not. +- A symlinked directory was walked, and every file under it bundled. Symlinks were rejected only at the final path component, so `vendor/private/key.txt` passed — the leaf is an ordinary file and the escape happened at `vendor`. +- A nested `.gitignore` was never read. Only the root file was loaded, so a monorepo's `packages/app/.env` went into the bundle. +- `--no-uplaod` uploaded. Unknown flags were filtered out silently, which turns a typo in the safe command into the dangerous one. + +Each was reproduced before being fixed. The common cause is treating a path as the string that names it, and treating a filter as best-effort. That is fine for "which files go in the bundle" and wrong for "which directory gets deleted" — and `.gitignore` had quietly become the second kind, because the docs promise it keeps secrets out. + +## Decision + +Three invariants, each enforced by a regression test that fails when reverted. + +**Output ownership — srcpack deletes and writes only what it owns.** Automatic emptying applies to the conventional `.srcpack` and nothing else; any other `outDir` requires `emptyOutDir: true`. Ownership is decided on the physical path (`realpath`), so a symlink cannot redirect a deletion into a directory that merely looks like srcpack's — and because the name is a claim about a place, a `.srcpack` that resolves elsewhere fails the run rather than quietly writing there. Bundles are written to a temp file and renamed into position, which replaces the directory entry instead of following a link that sits on it. + +A path therefore has two identities, and both are load-bearing. Its _entry_ identity — ancestors resolved, the entry itself left alone, because `rename` replaces it rather than following it — decides whether two bundles are the same file; resolution walks up to the deepest ancestor that exists, so an alias hidden behind a directory `mkdir -p` has yet to create is still caught. Its _lexical_ identity is what a glob rooted at a symlink produces. Excluding srcpack's own output from a bundle needs both, since either spelling can name the file the previous run wrote. + +Both comparisons reduce a path to a canonical spelling — Unicode NFC, then case folded — on every platform. A case-insensitive filesystem, the default on macOS and Windows though neither is a reliable proxy for it, treats `Context.txt` and `context.txt` as one directory entry; APFS folds normalisation too, so `Café` written precomposed and decomposed is also one entry. Normalising before folding is what makes the comparison sound, since equal inputs then stay equal whether or not case folding preserves normalisation. `realpath` canonicalises an existing component to its on-disk spelling, which is why an existing `.SRCPACK` is caught as a redirected `.srcpack` rather than emptied; but it cannot help where the difference actually bites, since an output not yet written has no on-disk spelling and the destination entry is deliberately left unresolved. The rule does not vary by filesystem: a config that works in Linux CI and silently loses a bundle on the author's laptop is worse than one rejected identically everywhere. + +Ownership stays an exact match, because folding there could only widen what gets deleted. A directory named `.SRCPACK` is consequently never the one srcpack clears unasked — refused as a redirected `.srcpack` where case folds, simply unrelated where it doesn't. + +**Input boundary — a bundle cannot leave the project by accident.** Globs no longer follow symlinks at any level, and `.gitignore` is resolved the way git resolves it: per directory, deepest rule first, with no re-inclusion under an ignored directory. An unreadable ignore file fails the run rather than widening the source set — only a missing one means "no rules". Explicit external patterns (`../`, absolute, `+`) remain the deliberate way out. + +**Intent boundary — a token that changes what a run destroys or publishes is never a silent no-op.** Unknown CLI flags, unknown config keys, bundle names that aren't filenames, and `upload.exclude` entries naming no bundle are all errors. + +## Alternatives + +- **An ownership manifest** — record what srcpack created and delete only that. Strictly safer, and it buys nothing over the convention: a directory named `.srcpack` that srcpack writes to is already an adequate claim, and a manifest is state to keep in sync. +- **A blacklist of dangerous directory names** (`src`, `docs`, …) — enumerating what must not be deleted never terminates. The question isn't which names are precious, it's which single directory is ours. +- **Keeping auto-empty for custom directories and warning instead** — a warning scrolls past in the same run that does the deleting. +- **Temp directory plus atomic swap for writes** — the right fix for the remaining gap (see below), but a separate change with its own failure modes. + +## Consequences + +`emptyOutDir` changes behaviour for anyone with a custom `outDir`: it no longer empties unless asked. Stale files are a nuisance; deleted sources are not, and 0.x is the time to take that break. + +Per-directory ignore resolution costs one extra glob for `**/.gitignore`, pruned by the root file's own directory patterns. Bundles that relied on a symlinked directory need an explicit external pattern, which is the honest spelling. + +Each file is now replaced atomically, but a run is not: `outDir` is emptied only after every bundle resolves, yet a failure partway through the writes can still leave some bundles new and others missing. A temp directory swapped in as a whole is the fix when that becomes worth doing. diff --git a/docs/adr/index.md b/docs/adr/index.md index 4a59ee7..211b4f4 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -1,13 +1,12 @@ # Architecture Decisions -Records of the decisions that shaped srcpack: what was chosen, what was -rejected, and the reasoning at the time. Written when the decision is made, -so they capture the trade-offs rather than a tidied-up version of them. +Records of the decisions that shaped srcpack: what was chosen, what was rejected, and the reasoning at the time. Written when the decision is made, so they capture the trade-offs rather than a tidied-up version of them. -A record is never rewritten once accepted. When a decision is reversed, a new -record supersedes it. +A record is never rewritten once accepted. When a decision is reversed, a new record supersedes it. -| ADR | Decision | Status | -| ------------------------------------ | -------------------------------------------- | ------------------- | -| [001](./001-git-source-tokens.md) | `git:` source tokens in the pattern language | Accepted 2026-08-15 | -| [002](./002-minimum-node-version.md) | Node 22.18 as the minimum runtime | Accepted 2026-08-15 | +| ADR | Decision | Status | +| --- | --- | --- | +| [001](./001-git-source-tokens.md) | `git:` source tokens in the pattern language | Accepted 2026-08-15 | +| [002](./002-minimum-node-version.md) | Node 22.18 as the minimum runtime | Accepted 2026-08-15 | +| [003](./003-linear-issues-as-virtual-files.md) | Linear issues as virtual files, under a `linear` bundle key | Accepted 2026-08-15 | +| [004](./004-path-boundaries.md) | What srcpack may read, delete and overwrite | Accepted 2026-08-15 | diff --git a/docs/cli.md b/docs/cli.md index c6b5c63..2abe897 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,16 +102,11 @@ npx srcpack --dirty # staged + unstaged + untracked npx srcpack --since main # everything you changed since main ``` -These build a one-off bundle from the current change set and need no config -file — handy for handing a work-in-progress to an LLM. The bundle is named -after the flag (`.srcpack/staged.txt`), other bundles in `outDir` are left -alone, and nothing is written when there are no changes. +These build a one-off bundle from the current change set and need no config file — handy for handing a work-in-progress to an LLM. The bundle is named after the flag (`.srcpack/staged.txt`), other bundles in `outDir` are left alone, and nothing is written when there are no changes. -Ad-hoc bundles stay local: they are never uploaded, even with Google Drive -configured. Declare a named bundle to publish changes. +Ad-hoc bundles stay local: they are never uploaded, even with Google Drive configured. Declare a named bundle to publish changes. -For a permanent version with review instructions attached, put a -[git source](./configuration.md#git-sources-git-prefix) in your config instead. +For a permanent version with review instructions attached, put a [git source](./configuration.md#git-sources-git-prefix) in your config instead. ### `srcpack init` @@ -167,15 +162,19 @@ Opens a browser to authorize access. Tokens are stored in `~/.config/srcpack/cre ## Options -| Option | Description | -| --------------- | ---------------------------------------------- | -| `--staged` | Bundle staged changes only | -| `--dirty` | Bundle staged, unstaged, and untracked changes | -| `--since ` | Bundle changes since `` | -| `--dry-run` | Preview bundles without writing files | -| `--no-upload` | Bundle only, skip upload | -| `--help` | Show help | -| `--version` | Show version | +| Option | Description | +| ------------------ | ---------------------------------------------- | +| `--staged` | Bundle staged changes only | +| `--dirty` | Bundle staged, unstaged, and untracked changes | +| `--since ` | Bundle changes since `` | +| `--dry-run` | Preview bundles without writing files | +| `--emptyOutDir` | Empty the output directory before writing | +| `--no-emptyOutDir` | Keep what is already in the output directory | +| `--no-upload` | Bundle only, skip upload | +| `-h`, `--help` | Show help | +| `-v`, `--version` | Show version | + +An unrecognized option is an error, not a no-op — `--no-uplaod` would otherwise upload, and `--dry-rnu` would write. ## Examples @@ -204,10 +203,18 @@ yarn dlx srcpack --dry-run Output: ``` -[dry-run] web → .srcpack/web.txt (24 files, 8.2 KB) -[dry-run] api → .srcpack/api.txt (18 files, 5.1 KB) + web 3 files 842 lines + src/api/routes.ts + src/index.ts + src/utils/helpers.ts + docs 1 file 96 lines + README.md + +Dry run: 2 bundles, 4 files, 938 lines ``` +Each bundle lists the files it would contain, so you can check the shape of a pattern before anything is written; `outDir` is left alone too. A bundle that declares [`linear`](./configuration.md#linear-issues) still calls the API — the counts are what it would produce right now, which it can't know offline. + ### Bundle without upload ::: code-group @@ -248,5 +255,4 @@ Srcpack searches for config in order: Searches from current directory up to filesystem root. -`srcpack init` writes `.ts` in an ESM project and `.mts` otherwise — see -[Configuration](./configuration.md#config-file-format). +`srcpack init` writes `.ts` in an ESM project and `.mts` otherwise — see [Configuration](./configuration.md#config-file-format). diff --git a/docs/configuration.md b/docs/configuration.md index f7ad896..db22c95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,21 +9,16 @@ Srcpack looks for configuration in the following order: ## Config File Format -Node decides a `.ts` file's module format from the nearest `package.json`, so -in a CommonJS project — the `npm init` default — the `import` line in a -`.ts` config fails to parse. Use `.mts` there: it is unconditionally ESM and -loads in both kinds of project. +Node decides a `.ts` file's module format from the nearest `package.json`, so in a CommonJS project — the `npm init` default — the `import` line in a `.ts` config fails to parse. Use `.mts` there: it is unconditionally ESM and loads in both kinds of project. -`srcpack init` picks the right extension for you. If you are writing the file -by hand: +`srcpack init` picks the right extension for you. If you are writing the file by hand: | Your `package.json` | Use | | ------------------------ | -------------------- | | `"type": "module"` | `srcpack.config.ts` | | no `type`, or `commonjs` | `srcpack.config.mts` | -Config files are type-stripped, not compiled, so they must use erasable syntax -— type annotations and `import type` are fine, `enum` and `namespace` are not. +Config files are type-stripped, not compiled, so they must use erasable syntax — type annotations and `import type` are fine, `enum` and `namespace` are not. ## Basic Structure @@ -43,15 +38,17 @@ export default defineConfig({ ## Options -| Option | Type | Default | Description | -| ------------- | --------- | --------------- | -------------------------------------- | -| `root` | `string` | `process.cwd()` | Project root directory | -| `outDir` | `string` | `.srcpack` | Output directory (relative to root) | -| `emptyOutDir` | `boolean` | `true`\* | Empty output directory before bundling | -| `bundles` | `object` | — | Named bundles (required) | -| `upload` | `object` | — | Upload destination | +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `root` | `string` | `process.cwd()` | Project root directory | +| `outDir` | `string` | `.srcpack` | Output directory (relative to root) | +| `emptyOutDir` | `boolean` | `true`\* | Empty output directory before writing | +| `bundles` | `object` | — | Named bundles (required) | +| `upload` | `object` | — | Upload destination | -\*`emptyOutDir` defaults to `true` when `outDir` is inside project root. +\*Only for the default `.srcpack`. Any other `outDir` defaults to `false` and must opt in with `emptyOutDir: true` — srcpack deletes nothing it doesn't own by convention. + +An unknown key anywhere in the config is an error rather than an ignored line. A silently dropped `emptyOutdir` or `exlude` would keep the default in force and read as if it had been set. ### root @@ -80,12 +77,13 @@ export default defineConfig({ }); ``` -`outDir` is emptied before bundling when it sits inside the project root, so -give srcpack a directory of its own. Pointing it at the root itself (`"."`) -is refused rather than emptied — that would delete the project. +Only the default `.srcpack` is emptied automatically. It is srcpack's directory by convention, so clearing it is safe; every other `outDir` is somewhere you chose, and `outDir: "src"` would otherwise turn a bundling run into a wipe of the sources it was asked to bundle. Set `emptyOutDir: true` to opt in, or clean up yourself. + +Ownership is decided by physical path. A `.srcpack` that turns out to be a symlink somewhere else fails the run: the name claims one specific place, and both the emptying and the writes would land somewhere it doesn't say. Name that directory as `outDir` instead. Pointing `outDir` at the root (`"."`) with `emptyOutDir: true` is refused for the same reason — it would delete the project. + +Emptying waits until every bundle has resolved, immediately before the new files are written. A run that fails while resolving — an unreadable `.gitignore`, an expired `LINEAR_API_KEY` — leaves the previous output intact. -Emptying only happens on a full run. `srcpack web` leaves the bundles it isn't -building in place, since it has no way to tell which of them are stale. +Emptying only happens on a full run. `srcpack web` leaves the bundles it isn't building in place, since it has no way to tell which of them are stale. ## Bundle Definitions @@ -123,12 +121,17 @@ bundles: { **Bundle options:** -| Option | Type | Default | Description | -| --------- | -------------------- | --------------------- | ----------------------------------------- | -| `include` | `string \| string[]` | — | Glob pattern(s) | -| `outfile` | `string` | `{outDir}/{name}.txt` | Custom output path | -| `index` | `boolean` | `true` | Include index header | -| `prompt` | `string` | — | Text or file path (`./`, `~/`) to prepend | +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `include` | `string \| string[]` | — | Glob pattern(s) | +| `linear` | `string \| object` | — | Linear issues (see below) | +| `outfile` | `string` | `{outDir}/{name}.txt` | Custom output path | +| `index` | `boolean` | `true` | Include index header | +| `prompt` | `string` | — | Text or file path (`./`, `~/`) to prepend | + +A bundle needs at least one source: `include`, `linear`, or both. + +Two bundles may not write to the same file. Names that differ only by case, or only in Unicode normalisation, count as the same file everywhere: on a case-insensitive filesystem — the default on macOS and Windows — `Web.txt` and `web.txt` are one directory entry, and APFS treats the two spellings of `Café` the same way, so one bundle would silently overwrite the other. ## Pattern Syntax @@ -197,19 +200,98 @@ Notes: - **An empty result writes no file**, and clears a stale one from a previous run, so a bundle never holds changes you've since committed. A custom `outfile` outside `outDir` is left alone. - `!git:...` and `+git:...` are errors: exclusion has no clear meaning, and force-include is already implied. +## Linear Issues + +A bundle can pull issues from [Linear](https://linear.app) alongside your code. Each issue becomes a virtual file at `linear/issues/.md`, so it gets its own index entry and line range: + +``` +# Index (5 files) +# [1] docs/roadmap.md L9-L94 (86 lines) +# [2] linear/issues.md L96-L103 (8 lines) +# [3] linear/issues/ENG-123.md L105-L132 (28 lines) +# [4] linear/issues/ENG-148.md L134-L169 (36 lines) +# [5] src/board.ts L171-L299 (129 lines) +``` + +That lets you ask an LLM things like _"does `[5] src/board.ts` actually implement `[3] ENG-123`?"_ + +### Roster + +`linear/issues.md` is generated alongside the issues: a scope heading, a count per workflow state, and one table row per issue ordered by number. + +```markdown +# ENG / Roadmap — 2 issues + +Backlog 1 · In Progress 1 + +| Issue | State | Priority | Title | +| ------- | ----------- | -------- | ------------------------- | +| ENG-123 | In Progress | High | Board history and restore | +| ENG-148 | Backlog | Medium | Weekly digest email | +``` + +It exists because the index lists paths, and `linear/issues/ENG-148.md` says nothing about ENG-148 — without a roster a model has to read every issue body to find the relevant ones, and cannot answer "what is in progress" at all. The rows are ordered by issue number, which the index itself cannot be: it sorts paths as text, so `ENG-2` lands between `ENG-19` and `ENG-20`. + +Drop it with `!linear/issues.md` if you only want the issue bodies. + +### Setup + +Create a personal API key in Linear (**Settings → Security & access → Personal API keys**) and export it: + +```bash +export LINEAR_API_KEY=lin_api_... +``` + +The key is read from the environment, never from the config file — config files get committed. + +### Usage + +```ts +bundles: { + // Shorthand: every non-terminal issue for team ENG + backlog: { linear: "ENG" }, + + // Code and the tickets that describe it, in one context file + planning: { + include: ["docs/**/*.md", "src/**/*.ts"], + linear: { team: "ENG", project: "Roadmap" }, + prompt: "Which roadmap items are already implemented?", + }, +} +``` + +**Linear options:** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `team` | `string` | — | Required. Team key — the `ENG` in `ENG-123` | +| `project` | `string` | — | Project name. Must match exactly one project in `team` | +| `includeClosed` | `boolean` | `false` | Include completed, canceled and duplicate issues | + +The string form `linear: "ENG"` is shorthand for `{ team: "ENG" }`. + +Notes: + +- **The default is every non-terminal issue**, not "active" in Linear's sense. Triage, Backlog, Todo and In Progress are all included; only the `completed`, `canceled` and `duplicate` state types are left out. +- **`includeClosed` does not reach archived issues.** Linear omits archived resources from ordinary responses, and srcpack does not ask for them, so `includeClosed: true` means "terminal issues too", not "all history". +- **`team` is required.** A workspace-wide fetch looks harmless in config and can pull thousands of issues into a context window. Declare one bundle per team if you need several. +- **Issues obey `!` exclusions like any other entry**, so you can drop individual tickets: `include: ["!linear/issues/ENG-7.md"]`. +- **A typo fails loudly.** An unknown team or project raises an error rather than producing an empty bundle; a project name matching two projects is rejected as ambiguous rather than silently merging both; and an unknown option key (`projet`) is rejected rather than ignored, which would quietly widen the bundle to the whole team. +- **Labels are capped at 50 per issue**, a deliberate context budget rather than a paginated read. +- **Issues travel with the bundle.** Issue text is ordinary bundle content, so a configured upload sends it to Google Drive alongside your code. Add the bundle to [`upload.exclude`](#upload-configuration) to keep it local. +- **`--dry-run` still calls the API**, because it has to in order to answer "what would this produce right now". There is no cache; a run without network access fails. Requests time out after 30 seconds. +- **`linear/issues/` is reserved** once a bundle pulls issues. A file on disk at that path is an error: two entries would share one name, and an `!` exclusion drops both rather than choosing. Rename the file, or narrow the include patterns so it isn't matched. + ## Automatic Exclusions Srcpack skips: -- Files matching `.gitignore` — including `node_modules/`, build output, and - secrets, since those are already ignored in any normal project +- Files matching `.gitignore` — including `node_modules/`, build output, and secrets, since those are already ignored in any normal project. Nested `.gitignore` files count too, resolved the way git resolves them: the rule in the deepest directory wins, and nothing under an ignored directory is re-included. A monorepo's `packages/app/.gitignore` hides its `.env` here exactly as it does for git. - Binary files (images, fonts, compiled assets), detected by content -- Symlinks, so a link can't pull in a file from outside the project -- Its own output — `outDir` and every configured `outfile`. Otherwise a rerun - would bundle the previous run's file, nesting it again each time. +- Symlinks, so a link can't pull in a file from outside the project — including symlinked directories, which are not walked into +- Its own output — `outDir` and every configured `outfile`. Otherwise a rerun would bundle the previous run's file, nesting it again each time. -Everything else matched by a pattern is included, so exclude what you don't -want explicitly: `["src/**/*", "!bun.lock"]`. +Everything else matched by a pattern is included, so exclude what you don't want explicitly: `["src/**/*", "!bun.lock"]`. ## Examples @@ -270,12 +352,15 @@ export default defineConfig({ { "srcpack": { "bundles": { - "app": "src/**/*" + "app": "src/**/*", + "backlog": { "linear": "ENG" } } } } ``` +Every bundle option is plain JSON, Linear included — its API key comes from the environment, so nothing here needs `process.env`. + ## Upload Configuration Configure cloud upload destinations. See [Google Drive Upload](/upload) for setup details. @@ -303,6 +388,8 @@ export default defineConfig({ | `clientSecret` | `string` | — | OAuth client secret (required) | | `exclude` | `string[]` | — | Bundle names to skip during upload | +Every name in `exclude` must match a configured bundle. A name that matches nothing is an error, since the alternative is uploading a bundle you meant to keep local. + ## TypeScript Support The `defineConfig` helper provides type checking and autocomplete: diff --git a/docs/getting-started.md b/docs/getting-started.md index e6caca4..1b6af72 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -72,7 +72,9 @@ export default defineConfig({ Run the bundle command and you'll see: ``` -✓ app → .srcpack/app.txt (12 files, 2.4 KB) + app 12 files 240 lines → .srcpack/app.txt + +Bundled: 1 bundle, 12 files, 240 lines ``` ## Understanding the Output @@ -81,16 +83,16 @@ Srcpack generates an indexed bundle optimized for AI consumption: ```text # Index (3 files) -# [1] src/index.ts L1-L42 -# [2] src/utils.ts L43-L89 -# [3] src/api.ts L90-L150 +# [1] src/api.ts L7-L67 (61 lines) +# [2] src/index.ts L69-L110 (42 lines) +# [3] src/utils.ts L112-L158 (47 lines) -#==> [1] src/index.ts <== -import { utils } from "./utils"; +#==> [1] src/api.ts <== +export async function fetchBoard() { ... -#==> [2] src/utils.ts <== -export function utils() { +#==> [2] src/index.ts <== +import { utils } from "./utils"; ... ``` @@ -174,8 +176,25 @@ export default defineConfig({ }); ``` -See [Git sources](./configuration.md#git-sources-git-prefix) for `git:dirty`, -`git:main`, and the rest. +See [Git sources](./configuration.md#git-sources-git-prefix) for `git:dirty`, `git:main`, and the rest. + +### Code and Tickets Together + +A bundle can include [Linear](https://linear.app) issues next to your code, so the LLM sees both the intent and the implementation: + +```ts +export default defineConfig({ + bundles: { + planning: { + include: ["src/**/*.ts", "docs/**/*.md"], + linear: { team: "ENG", project: "Roadmap" }, + prompt: "Which roadmap items are already implemented?", + }, + }, +}); +``` + +Each issue becomes its own indexed entry (`linear/issues/ENG-123.md`). Set `LINEAR_API_KEY` in your environment first — see [Linear issues](./configuration.md#linear-issues). ## CLI Reference diff --git a/docs/product-vision.md b/docs/product-vision.md deleted file mode 100644 index d0d1368..0000000 --- a/docs/product-vision.md +++ /dev/null @@ -1,41 +0,0 @@ -# Product Vision - -## One-liner - -CLI that bundles repo code into domain-focused context for LLMs. - -## Problem - -LLM context fails when it's too large, noisy, or flat. Existing tools split by size, not semantics. Teams need repeatable, indexed bundles per domain (db, API, web). - -## Users - -- Developers using LLMs for refactors, reviews, and design on real codebases -- Teams with multiple domains in one repo - -## Principles - -- Semantic splitting over size-based -- Index-first output, readable by humans and models -- Safe defaults (respects .gitignore, skips secrets) -- Zero-friction CLI - -## MVP Scope - -- Named bundles from one repo via simple config -- Per-bundle include/exclude globs -- Index at top (file list + tree), clear file boundaries -- Output: plain text, Markdown, or XML -- Upload to Google Drive folder - -## Non-Goals - -- MCP server or live streaming -- Auto-classification without config -- IDE plugins - -## Success - -- Bundles reused across tasks without edits -- Fewer "lost in the middle" hallucinations -- Teams converge on standard context presets diff --git a/docs/upload.md b/docs/upload.md index 5878051..163a94f 100644 --- a/docs/upload.md +++ b/docs/upload.md @@ -38,13 +38,13 @@ export default defineConfig({ **Upload options:** -| Option | Type | Description | -| -------------- | ---------- | ------------------------------------------------ | -| `provider` | `"gdrive"` | Upload provider (currently only gdrive) | -| `folderId` | `string` | Google Drive folder ID (optional, defaults root) | -| `clientId` | `string` | OAuth 2.0 client ID | -| `clientSecret` | `string` | OAuth 2.0 client secret | -| `exclude` | `string[]` | Bundle names to skip during upload | +| Option | Type | Description | +| --- | --- | --- | +| `provider` | `"gdrive"` | Upload provider (currently only gdrive) | +| `folderId` | `string` | Google Drive folder ID (optional, defaults root) | +| `clientId` | `string` | OAuth 2.0 client ID | +| `clientSecret` | `string` | OAuth 2.0 client secret | +| `exclude` | `string[]` | Bundle names to skip during upload | **Finding your folder ID:** @@ -87,9 +87,14 @@ This opens a browser window to authorize access. Tokens are stored locally and r Once configured, `npx srcpack` uploads bundles after bundling: ``` -✓ web → .srcpack/web.txt (24 files, 8.2 KB) -✓ api → .srcpack/api.txt (18 files, 5.1 KB) -↑ Uploaded to Google Drive + web 24 files 842 lines → .srcpack/web.txt + api 18 files 511 lines → .srcpack/api.txt + +Bundled: 2 bundles, 42 files, 1,353 lines + +Uploaded: 2 files to Google Drive + web.txt → https://drive.google.com/file/d/... + api.txt → https://drive.google.com/file/d/... ``` ### Exclude Bundles @@ -105,7 +110,7 @@ upload: { } ``` -This is useful for local-only bundles that shouldn't be shared. +This is useful for local-only bundles that shouldn't be shared — a bundle of [Linear issues](./configuration.md#linear-issues), say. Names are checked against your configured bundles, so a typo fails the run instead of quietly uploading what it was meant to hold back. ### Skip Upload @@ -155,9 +160,7 @@ yarn dlx srcpack web ## Environment Variables -::: warning -Never commit `clientId` and `clientSecret` directly in your config file. Use environment variables for shared or public repositories. -::: +::: warning Never commit `clientId` and `clientSecret` directly in your config file. Use environment variables for shared or public repositories. ::: For CI/CD or shared configs, use environment variables: @@ -183,14 +186,8 @@ Once uploaded, bundles appear in your Google Drive folder. You can: ## Troubleshooting -::: details "Access denied" error -Re-run the login command to refresh authentication. -::: +::: details "Access denied" error Re-run the login command to refresh authentication. ::: -::: details "Folder not found" error -Verify the folder ID is correct and you have write access to the folder. -::: +::: details "Folder not found" error Verify the folder ID is correct and you have write access to the folder. ::: -::: details Tokens expired -Srcpack automatically refreshes tokens. If issues persist, delete `~/.config/srcpack/credentials.json` and run the login command again. -::: +::: details Tokens expired Srcpack automatically refreshes tokens. If issues persist, delete `~/.config/srcpack/credentials.json` and run the login command again. ::: diff --git a/docs/why-srcpack.md b/docs/why-srcpack.md index 7353c5b..9dc9296 100644 --- a/docs/why-srcpack.md +++ b/docs/why-srcpack.md @@ -42,9 +42,7 @@ Onboard faster by querying the codebase directly: - "Where are database migrations defined?" - "What's the pattern for adding new API endpoints?" -::: tip -The indexed output means AI answers include exact file and line references, not vague descriptions. -::: +::: tip The indexed output means AI answers include exact file and line references, not vague descriptions. ::: ### Technical Writers @@ -67,9 +65,7 @@ Upload: .srcpack/app.txt What areas have the most technical debt? Estimate complexity to add OAuth." ``` -::: info -AI gives answers based on real code, not guesses. -::: +::: info AI gives answers based on real code, not guesses. ::: ### Cross-Team Knowledge Sharing @@ -82,9 +78,7 @@ Upload: .srcpack/api.txt What authentication does it expect? Show example request/response." ``` -::: tip -No meetings required. The code explains itself. -::: +::: tip No meetings required. The code explains itself. ::: ### Security Reviews diff --git a/package.json b/package.json index d4dd3c9..6e2d5a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "srcpack", - "version": "0.2.0", + "version": "0.3.0", "description": "Zero-config CLI for bundling code into LLM-optimized context files", "keywords": [ "llm", @@ -94,5 +94,8 @@ "typescript": "^6.0.3", "vitepress": "^2.0.0-alpha.19", "vitepress-plugin-llms": "^1.13.5" + }, + "prettier": { + "proseWrap": "never" } } diff --git a/src/bundle.ts b/src/bundle.ts index f19d771..860f8b6 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -1,16 +1,35 @@ // SPDX-License-Identifier: MIT -import { lstat, open, readFile } from "node:fs/promises"; -import { isAbsolute, join, resolve, sep } from "node:path"; import { glob } from "fast-glob"; -import picomatch from "picomatch"; import ignore, { type Ignore } from "ignore"; -import { ConfigError, expandPath, type BundleConfigInput } from "./config.ts"; +import { lstat, open, readFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import picomatch from "picomatch"; +import { + ConfigError, + expandPath, + type BundleConfigInput, + type LinearSourceInput, +} from "./config.ts"; import { isGitSource, resolveGitSource } from "./git.ts"; +import { resolveLinearSource } from "./linear.ts"; // Binary file detection: check first 8KB for null bytes (same heuristic as git) const BINARY_CHECK_SIZE = 8192; +/** + * Shared glob options. `followSymbolicLinks: false` is the load-bearing one: + * fast-glob defaults to true, so a link like `vendor -> ../../elsewhere` would + * be walked and every regular file under it bundled. Rejecting symlinks at the + * final component (see {@link isBundleable}) can't catch that — the leaf is an + * ordinary file; the escape happened in a directory along the way. + */ +const GLOB_OPTIONS = { + onlyFiles: true, + dot: true, + followSymbolicLinks: false, +} as const; + /** * Whether a path can be read into a bundle: an existing regular text file. * Globs only yield files, but git can name a submodule directory or a file @@ -46,16 +65,29 @@ async function isBundleable(filePath: string): Promise { } } -export interface FileEntry { - path: string; // Relative path from cwd - lines: number; // Line count in source file +/** + * One bundle member, before its content is laid out. + * + * `content` present means the entry is virtual — produced by a non-filesystem + * source such as Linear — and its `path` is synthetic. Absent means an ordinary + * file, read from disk at bundle time. + */ +export interface Entry { + path: string; + content?: string; +} + +/** One line of the bundle index — a file or a virtual entry, once laid out. */ +export interface IndexEntry { + path: string; // Relative path from cwd, or a synthetic path + lines: number; // Line count in the entry's content startLine: number; // Start line in bundle (1-indexed) endLine: number; // End line in bundle } export interface BundleResult { content: string; - index: FileEntry[]; + index: IndexEntry[]; } /** @@ -88,6 +120,9 @@ function normalizePatterns(config: BundleConfigInput): { patterns = [config]; } else if (Array.isArray(config)) { patterns = config; + } else if (config.include === undefined) { + // A `linear`-only bundle has no patterns at all + patterns = []; } else { patterns = Array.isArray(config.include) ? config.include @@ -156,25 +191,29 @@ function gitignoreToGlobPatterns(lines: string[]): string[] { // Skip empty lines and comments if (!trimmed || trimmed.startsWith("#")) continue; + // A trailing slash only says "directory", which is what this prunes anyway. + // Stripping it first matters: `node_modules/` is the common spelling, and + // testing for "/" before stripping rejected every one of them. + const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + // Skip patterns with special gitignore features we can't safely convert: // - Root-anchored (starts with /) // - Contains globs (*, ?, [) // - Contains path separators (complex paths) // - Escaped characters if ( - trimmed.startsWith("/") || - trimmed.includes("*") || - trimmed.includes("?") || - trimmed.includes("[") || - trimmed.includes("/") || - trimmed.includes("\\") + name.startsWith("/") || + name.includes("*") || + name.includes("?") || + name.includes("[") || + name.includes("/") || + name.includes("\\") ) { continue; } // Only convert simple directory names (e.g., "node_modules", "dist") // These are safe to prune at any depth - const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; if (name && /^[\w.-]+$/.test(name)) { patterns.push(`**/${name}/**`); } @@ -184,28 +223,98 @@ function gitignoreToGlobPatterns(lines: string[]): string[] { } interface GitignoreResult { - ignore: Ignore; + /** Whether git would ignore this cwd-relative posix path. */ + ignores: (path: string) => boolean; globPatterns: string[]; } +/** One directory's ignore rules. `dir` is a cwd-relative prefix, "" for root. */ +interface IgnoreLayer { + dir: string; + ig: Ignore; +} + /** - * Load and parse .gitignore file from a directory. - * Returns both an Ignore instance for filtering and glob patterns for fast-glob. + * Read an ignore file. Only a missing file means "no rules" — a permission or + * I/O error would otherwise widen the bundle to exactly the files someone chose + * to hide, so it fails the run instead. + */ +async function readIgnoreFile(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new ConfigError( + `Cannot read "${path}": ${(error as Error).message}. ` + + "srcpack stops rather than bundle files it cannot confirm are ignored.", + ); + } +} + +/** + * Load the .gitignore rules that apply anywhere under `cwd`. + * + * Git resolves ignores per directory: a path is governed by the ignore file in + * its own directory and in every parent, deepest rule winning. Reading only the + * root file bundles whatever a nested .gitignore hides — `packages/app/.env` in + * a monorepo being the case that matters, since ignored secrets staying out is + * a documented guarantee rather than a convenience. */ async function loadGitignore(cwd: string): Promise { - const ig = ignore(); - const gitignorePath = join(cwd, ".gitignore"); - let globPatterns: string[] = []; + const rootContent = await readIgnoreFile(join(cwd, ".gitignore")); + const globPatterns = rootContent + ? gitignoreToGlobPatterns(rootContent.split("\n")) + : []; + + const layers: IgnoreLayer[] = []; + if (rootContent) layers.push({ dir: "", ig: ignore().add(rootContent) }); + + // Pruned by the root file's directory patterns: no point walking node_modules + // to collect ignore files that only govern paths already ignored. + const nested = await glob(["**/.gitignore"], { + cwd, + dot: true, + onlyFiles: true, + followSymbolicLinks: false, + ignore: globPatterns, + }); - try { - const content = await readFile(gitignorePath, "utf-8"); - ig.add(content); - globPatterns = gitignoreToGlobPatterns(content.split("\n")); - } catch { - // No .gitignore file, return empty ignore instance + for (const file of nested) { + const content = await readIgnoreFile(join(cwd, file)); + if (content) layers.push({ dir: dirname(file), ig: ignore().add(content) }); } - return { ignore: ig, globPatterns }; + return { ignores: makeIgnores(layers), globPatterns }; +} + +/** Resolve the layered rules for one path, deepest directory first. */ +function makeIgnores(layers: IgnoreLayer[]): (path: string) => boolean { + if (layers.length === 0) return () => false; + const ordered = [...layers].sort((a, b) => b.dir.length - a.dir.length); + + // First layer with an opinion wins; an explicit negation (`!keep.env`) is an + // opinion too, which is what lets a nested file re-include what its parent hid. + const opinion = (path: string): boolean | undefined => { + for (const { dir, ig } of ordered) { + if (dir && !path.startsWith(`${dir}/`)) continue; + const relative = dir ? path.slice(dir.length + 1) : path; + if (!relative || relative === "/") continue; + const { ignored, unignored } = ig.test(relative); + if (ignored) return true; + if (unignored) return false; + } + return undefined; + }; + + return (path: string) => { + // Ancestors first: git never descends into an ignored directory, so nothing + // beneath one can be negated back in. + const segments = path.split("/"); + for (let i = 1; i < segments.length; i++) { + if (opinion(`${segments.slice(0, i).join("/")}/`)) return true; + } + return opinion(path) === true; + }; } /** @@ -221,14 +330,40 @@ function isExternalPattern(pattern: string): boolean { return normalized.startsWith("../"); } +/** + * The key two paths are compared by: canonical spelling, then case folded, on + * every platform. A case-insensitive filesystem — the default on macOS and + * Windows — treats `Context.txt` and `context.txt` as one directory entry, and + * APFS additionally folds Unicode normalisation, so `Café.txt` written as + * precomposed U+00E9 and as `e` plus U+0301 is also one entry. Normalising + * before folding is what makes the comparison sound: equal inputs stay equal + * afterwards whether or not case folding preserves normalisation. `realpath` + * resolves + * an existing component to its on-disk spelling, but that doesn't cover these: + * an output not yet written has no on-disk spelling, and the destination entry + * is deliberately left unresolved so `rename` replaces a symlink rather than + * following it. A config that works in Linux CI and loses a bundle on the + * author's laptop is worse than one rejected everywhere, so the rule is the + * same on every platform rather than keyed to the filesystem under it. + * + * Comparison only. Paths used for I/O keep their original spelling, and + * ownership stays an exact match: folding there could only widen what srcpack + * deletes, which is the one direction that must never be widened by a guess. + */ +export function pathKey(path: string): string { + return path.normalize("NFC").toLowerCase(); +} + /** * Whether a path is one srcpack writes. `outputs` holds absolute paths of * files or directories; a directory covers everything beneath it. */ function isOwnOutput(filePath: string, outputs: string[]): boolean { - return outputs.some( - (out) => filePath === out || filePath.startsWith(out + sep), - ); + const key = pathKey(filePath); + return outputs.some((out) => { + const outKey = pathKey(out); + return key === outKey || key.startsWith(outKey + sep); + }); } /** @@ -280,33 +415,86 @@ export async function resolvePatterns( // Internal patterns (within cwd): respect .gitignore const internalPatterns = globs.filter((p) => !isExternalPattern(p)); if (internalPatterns.length > 0) { - const { ignore: gitignore, globPatterns } = await loadGitignore(cwd); + const { ignores, globPatterns } = await loadGitignore(cwd); const matches = await glob(internalPatterns, { + ...GLOB_OPTIONS, cwd, - onlyFiles: true, - dot: true, ignore: globPatterns, }); - await add(matches.filter((m) => !gitignore.ignores(m))); + await add(matches.filter((m) => !ignores(m))); } // External patterns: skip .gitignore (it doesn't apply outside cwd) const externalPatterns = globs.filter(isExternalPattern); if (externalPatterns.length > 0) { - await add( - await glob(externalPatterns, { cwd, onlyFiles: true, dot: true }), - ); + await add(await glob(externalPatterns, { ...GLOB_OPTIONS, cwd })); } // Force includes: bypass .gitignore (no ignore patterns passed to glob) if (force.length > 0) { - await add(await glob(force, { cwd, onlyFiles: true, dot: true })); + await add(await glob(force, { ...GLOB_OPTIONS, cwd })); } // Sort for deterministic output return [...files].sort(); } +/** Read the `linear` source off a bundle config, if it declares one. */ +function getLinear(config: BundleConfigInput): LinearSourceInput | undefined { + if (typeof config === "object" && !Array.isArray(config)) { + return config.linear; + } + return undefined; +} + +/** + * Resolve every source a bundle declares into a sorted entry list. + * + * Filesystem and `git:` sources produce paths ({@link resolvePatterns}, which + * never touches the network); `linear` produces virtual entries carrying their + * own content. Both then meet the same rules — `!` exclusions apply uniformly, + * and the result is sorted by path so bundles stay deterministic. + */ +export async function resolveEntries( + config: BundleConfigInput, + cwd: string, + outputs: string[] = [], +): Promise { + const linearSource = getLinear(config); + + // Sequential, not parallel: a bad pattern should fail before spending a + // network round trip, and a failed fetch shouldn't race a filesystem walk. + const paths = await resolvePatterns(config, cwd, outputs); + const entries: Entry[] = paths.map((path) => ({ path })); + + if (linearSource) { + const { exclude } = normalizePatterns(config); + const excludeMatchers = exclude.map((p) => picomatch(p)); + // Compare resolved paths, not the strings: an absolute pattern and a + // relative one name the same file with different spellings, and only the + // resolved form tells whether a real file occupies a synthetic path. + const taken = new Set(paths.map((path) => resolve(cwd, path))); + + for (const entry of await resolveLinearSource(linearSource)) { + if (isExcluded(entry.path, excludeMatchers)) continue; + // `linear/issues/` is a reserved namespace once a bundle pulls issues: + // a real file there would put two entries in the index under one name, + // and an `!` exclusion can't drop one without dropping both. + if (taken.has(resolve(cwd, entry.path))) { + throw new ConfigError( + `Linear issue collides with the file "${entry.path}". ` + + "Rename the file, or narrow the include patterns so it isn't matched.", + ); + } + entries.push(entry); + } + } + + return entries.sort((a, b) => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0, + ); +} + /** * Count lines in a string (handles empty strings correctly) */ @@ -324,7 +512,7 @@ function countLines(content: string): number { * - ASCII-only characters for broad compatibility * - Line locations that point to actual file content */ -export function formatIndex(index: FileEntry[]): string { +export function formatIndex(index: IndexEntry[]): string { if (index.length === 0) return "# Index\n# (empty)"; const count = index.length; @@ -355,43 +543,46 @@ function formatSeparator(index: number, filePath: string): string { } /** - * Create a bundle from a list of files. + * Create a bundle from a list of entries. * Line numbers in the index point to the first line of actual file content, * not to the separator line. */ export async function createBundle( - files: string[], + entries: Entry[], cwd: string, options: BundleOptions = {}, ): Promise { const { includeIndex = true } = options; // Normalize prompt: trim and treat whitespace-only as no prompt const prompt = options.prompt?.trim() || undefined; - const index: FileEntry[] = []; + const index: IndexEntry[] = []; const contentParts: string[] = []; let currentLine = 1; - for (let i = 0; i < files.length; i++) { - const filePath = files[i]!; - const content = await readFile(resolve(cwd, filePath), "utf-8"); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const filePath = entry.path; + // Virtual entries carry their content; files are read from disk + const content = + entry.content ?? (await readFile(resolve(cwd, filePath), "utf-8")); const lines = countLines(content); // Separator takes 1 line, then content starts on next line const contentStartLine = currentLine + 1; - const entry: FileEntry = { + const indexEntry: IndexEntry = { path: filePath, lines, startLine: contentStartLine, endLine: contentStartLine + Math.max(0, lines - 1), }; - index.push(entry); + index.push(indexEntry); contentParts.push(formatSeparator(i + 1, filePath)); contentParts.push(content.endsWith("\n") ? content.slice(0, -1) : content); // Next separator line = after content - currentLine = entry.endLine + 1; + currentLine = indexEntry.endLine + 1; } // Calculate prompt offset (prompt text + blank + "---" + blank) @@ -494,8 +685,8 @@ export async function bundleOne( cwd: string, outputs: string[] = [], ): Promise { - const files = await resolvePatterns(config, cwd, outputs); + const entries = await resolveEntries(config, cwd, outputs); const includeIndex = getIncludeIndex(config); const prompt = await resolvePrompt(getPrompt(config), cwd); - return createBundle(files, cwd, { includeIndex, prompt }); + return createBundle(entries, cwd, { includeIndex, prompt }); } diff --git a/src/cli.ts b/src/cli.ts index a44f848..fe6005b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,26 @@ #!/usr/bin/env node // SPDX-License-Identifier: MIT -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + mkdir, + readdir, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import ora from "ora"; -import { bundleOne, type BundleResult } from "./bundle.ts"; +import { bundleOne, pathKey, type BundleResult } from "./bundle.ts"; import { ConfigError, loadConfig, @@ -12,7 +28,6 @@ import { type BundleConfig, type UploadConfig, } from "./config.ts"; -import { GitError } from "./git.ts"; import { ensureAuthenticated, login, @@ -20,7 +35,9 @@ import { uploadFile, type UploadResult, } from "./gdrive.ts"; +import { GitError } from "./git.ts"; import { runInit } from "./init.ts"; +import { LinearError } from "./linear.ts"; interface BundleOutput { name: string; @@ -40,12 +57,67 @@ function plural(n: number, singular: string, pluralForm?: string): string { return n === 1 ? singular : (pluralForm ?? singular + "s"); } +/** The directory srcpack owns by convention, and the only one it clears unasked. */ +const DEFAULT_OUT_DIR = ".srcpack"; + +/** + * Where a path physically is, with symlinks resolved. Destructive decisions are + * made on this rather than the lexical path: `.srcpack -> ../shared` is inside + * the project by name and somewhere else in fact, and it is the somewhere else + * whose contents `rm` would take. + * + * Resolves as much of the path as exists, however deep that is. Stopping at the + * immediate parent would call `.srcpack/nested/x.txt` and `alias/nested/x.txt` + * different files until `mkdir -p` runs, which is one step too late to still be + * a check: aliasing is a property of the ancestors, not of when they were made. + */ +async function physicalPath(path: string): Promise { + try { + return await realpath(path); + } catch { + const parent = dirname(path); + // dirname("/") === "/": nothing above the filesystem root left to resolve + if (parent === path) return path; + return join(await physicalPath(parent), basename(path)); + } +} + +/** + * Where `rename` puts a directory entry: ancestors resolved, the entry itself + * left alone. Writing replaces the entry instead of following it, so a bundle + * whose output is a symlink is identified as the link rather than its target — + * two bundles writing over one link's target are still two separate files. + */ +async function entryPath(path: string): Promise { + return join(await physicalPath(dirname(path)), basename(path)); +} + function isInside(path: string, dir: string): boolean { const rel = relative(dir, path); // Compare against ".." as a whole segment — "..cache/x" is a child, not an escape return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); } +/** + * Write a bundle by replacing the directory entry rather than the file behind + * it. Writing in place follows a symlink sitting at the output path, so + * `.srcpack/web.txt -> ~/.ssh/config` would be written through; rename replaces + * the link itself. It also makes each file appear whole or not at all. + * + * The temp name carries the pid so two runs can't rename each other's file. + */ +async function writeBundle(path: string, content: string): Promise { + const temp = `${path}.${process.pid}.tmp`; + try { + await writeFile(temp, content); + await rename(temp, path); + } finally { + // A failed write or rename would otherwise leave a partial file behind: + // stale inside outDir, and bundled by the next run beside a custom outfile. + await rm(temp, { force: true }); + } +} + /** * Empty a directory while preserving specified entries (e.g., `.git`). * Uses `force: true` to handle read-only or in-use files. @@ -54,8 +126,14 @@ async function emptyDirectory(dir: string, skip: string[] = []): Promise { let entries: string[]; try { entries = await readdir(dir); - } catch { - return; // Directory doesn't exist, nothing to empty + } catch (error) { + // Only a missing directory is "nothing to empty". Anything else — a + // permission error, a file where a directory belongs — would otherwise be + // reported as a clean run that then writes into a directory it never read. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw new ConfigError( + `Cannot empty outDir "${dir}": ${(error as Error).message}`, + ); } const skipSet = new Set(skip); await Promise.all( @@ -76,6 +154,39 @@ interface AdHocBundle { const AD_HOC_FLAGS = ["--staged", "--dirty", "--since"] as const; +/** + * Every option the CLI accepts. Anything else is a typo, and a typo that gets + * quietly dropped is the dangerous kind: `--no-uplaod` uploads, `--dry-rnu` + * writes, `--no-emptyOutdir` empties. Same rule as the config — a token that + * changes what a run destroys or publishes is never a silent no-op. + */ +const KNOWN_FLAGS = new Set([ + ...AD_HOC_FLAGS, + "--dry-run", + "--emptyOutDir", + "--no-emptyOutDir", + "--no-upload", + "--help", + "-h", + "--version", + "-v", +]); + +function assertKnownFlags(args: string[]): void { + const unknown = args.find( + (arg) => arg.startsWith("-") && !KNOWN_FLAGS.has(arg), + ); + if (unknown) { + console.error(`Unknown option: ${unknown}`); + console.error("Run `srcpack --help` to see the available options."); + process.exit(1); + } + if (args.includes("--emptyOutDir") && args.includes("--no-emptyOutDir")) { + console.error("Cannot combine --emptyOutDir with --no-emptyOutDir."); + process.exit(1); + } +} + function parseAdHocBundle(args: string[]): AdHocBundle | null { const flags = AD_HOC_FLAGS.filter((flag) => args.includes(flag)); @@ -147,7 +258,7 @@ Options: --dirty Bundle staged, unstaged, and untracked changes --since Bundle changes since (e.g. --since main) --dry-run Preview bundles without writing files - --emptyOutDir Empty output directory before bundling + --emptyOutDir Empty output directory before writing --no-emptyOutDir Keep existing files in output directory --no-upload Skip uploading to cloud storage -h, --help Show this help message @@ -158,15 +269,18 @@ Options: // Only in first position: elsewhere the word is a bundle name or a revision, // and `--since init` must diff against the `init` branch, not run the wizard. - if (args[0] === "init") { - await runInit(); + if (args[0] === "init" || args[0] === "login") { + // Neither takes arguments, so anything after is a misunderstanding worth + // saying out loud rather than a flag that silently does nothing. + if (args.length > 1) { + console.error(`srcpack ${args[0]} takes no arguments.`); + process.exit(1); + } + await (args[0] === "init" ? runInit() : runLogin()); return; } - if (args[0] === "login") { - await runLogin(); - return; - } + assertKnownFlags(args); const dryRun = args.includes("--dry-run"); const noUpload = args.includes("--no-upload"); @@ -210,7 +324,8 @@ Options: // Validate requested bundle names exist for (const name of bundleNames) { - if (!(name in bundles)) { + // hasOwn, not `in`: `srcpack toString` would otherwise find Object.prototype + if (!Object.hasOwn(bundles, name)) { console.error(`Unknown bundle: ${name}`); process.exit(1); } @@ -223,30 +338,43 @@ Options: const root = config.root; - // Resolve emptyOutDir: CLI flag > config > auto (true if inside root). - // Ad-hoc runs never empty by default — they shouldn't delete configured bundles. + // Resolve emptyOutDir: CLI flag > config > auto. + // + // Auto means only the conventional `.srcpack`: recursive deletion needs a + // directory srcpack demonstrably owns, and `outDir: "src"` reads as an + // ordinary setting while turning a bundling run into a source-tree wipe. + // Every other directory belongs to the user until they say otherwise. + // + // The comparison is physical, not lexical: `.srcpack -> ../shared` looks + // inside the project and deletes somewhere else. Ad-hoc runs never empty by + // default either — they shouldn't delete configured bundles. + // Lexical is what gets written to and excluded from bundles; physical is what + // decides ownership. Conflating them is what let a symlink redirect a delete. + const rootPath = await physicalPath(root); const outDirPath = resolve(root, config.outDir); - const outDirInsideRoot = isInside(outDirPath, root); - const emptyOutDir = - emptyOutDirFlag ?? - (adHoc ? false : (config.emptyOutDir ?? outDirInsideRoot)); + const outDirPhysical = await physicalPath(outDirPath); + const defaultOutDir = join(rootPath, DEFAULT_OUT_DIR); - // Warn if outDir is outside root and emptyOutDir is not explicitly set + // The conventional name is a claim about a place. A `.srcpack` that resolves + // somewhere else keeps the name while writing into a directory srcpack was + // never given — and would overwrite whatever shares a filename there. if ( - !adHoc && - !outDirInsideRoot && - emptyOutDirFlag === undefined && - config.emptyOutDir === undefined + resolve(root, DEFAULT_OUT_DIR) === outDirPath && + outDirPhysical !== defaultOutDir ) { - console.warn( - `Warning: outDir "${config.outDir}" is outside project root. ` + - "Use --emptyOutDir to suppress this warning and empty the directory.", + throw new ConfigError( + `Refusing to use "${DEFAULT_OUT_DIR}": it resolves to "${outDirPhysical}", not "${defaultOutDir}". ` + + "Set outDir to that path explicitly if that is where bundles belong.", ); } + const ownsOutDir = outDirPhysical === defaultOutDir; + const emptyOutDir = + emptyOutDirFlag ?? (adHoc ? false : (config.emptyOutDir ?? ownsOutDir)); + // `outDir: "."` resolves to the project root, where emptying deletes the // whole project — sources, config and all. Refuse rather than warn. - const outDirHoldsRoot = isInside(root, outDirPath); + const outDirHoldsRoot = isInside(rootPath, outDirPhysical); if (emptyOutDir && outDirHoldsRoot) { throw new ConfigError( `Refusing to empty outDir "${config.outDir}": it contains the project root. ` + @@ -254,20 +382,43 @@ Options: ); } - // Empty outDir before bundling (unless dry-run). Only for a full run: a named - // subset can't tell what is stale, so `srcpack web` must not delete api.txt. - if (emptyOutDir && !dryRun && requestedBundles.length === 0) { - await emptyDirectory(outDirPath, [".git"]); - } - // srcpack never bundles what srcpack writes. Every configured outfile is // named explicitly; outDir covers stale bundles from renamed config entries // too, but not when it holds the root — that would exclude the whole project. - const ownOutputs = Object.entries(config.bundles).map( - ([name, bundleConfig]) => - resolve(root, getOutfile(bundleConfig, name, config.outDir)), - ); - if (!outDirHoldsRoot) ownOutputs.push(outDirPath); + // + // Both spellings of every output are recorded. A glob rooted at a symlink + // yields lexical paths, one rooted at the real directory yields physical + // ones, and either can name a file the previous run wrote. + const ownOutputs = new Set(); + const writers = new Map(); + for (const [name, bundleConfig] of Object.entries(config.bundles)) { + const outfile = resolve( + root, + getOutfile(bundleConfig, name, config.outDir), + ); + // Two bundles sharing one file is silent loss: the second write replaces the + // first, and the upload step then sends the survivor twice under two names. + // Keyed by destination entry — `.srcpack/a.txt` and `alias/a.txt` are two + // spellings of one file as soon as `alias` links to `.srcpack`, and so are + // `Web.txt` and `web.txt` wherever the filesystem folds case. + const entry = await entryPath(outfile); + const key = pathKey(entry); + const first = writers.get(key); + if (first) { + throw new ConfigError( + `Bundles "${first}" and "${name}" both write to "${relative(root, outfile) || outfile}". ` + + "Give one of them its own outfile.", + ); + } + writers.set(key, name); + ownOutputs.add(outfile); + ownOutputs.add(entry); + } + if (!outDirHoldsRoot) { + ownOutputs.add(outDirPath); + ownOutputs.add(outDirPhysical); + } + const outputPaths = [...ownOutputs]; const outputs: BundleOutput[] = []; @@ -282,7 +433,21 @@ Options: const name = bundleNames[i]!; bundleSpinner.text = `Bundling ${name}... (${i + 1}/${bundleNames.length})`; const bundleConfig = bundles[name]!; - const result = await bundleOne(bundleConfig, root, ownOutputs); + let result: BundleResult; + try { + result = await bundleOne(bundleConfig, root, outputPaths); + } catch (error) { + // A config can declare many bundles; the underlying message says what + // broke but not which bundle asked for it. + if ( + error instanceof ConfigError || + error instanceof GitError || + error instanceof LinearError + ) { + error.message = `Bundle "${name}": ${error.message}`; + } + throw error; + } const outfile = getOutfile(bundleConfig, name, config.outDir); outputs.push({ name, outfile, result }); } @@ -290,6 +455,17 @@ Options: bundleSpinner.stop(); } + // Empty outDir only once every bundle has resolved, and only for a full run: + // a named subset can't tell what is stale, so `srcpack web` must not delete + // api.txt. Emptying earlier would destroy a good previous run whenever a + // later bundle fails — routine once a source is remote, since an expired + // token or a rate limit aborts the run after outDir is already gone. + // Resolution doesn't need the files removed first: `ownOutputs` already keeps + // srcpack's own output from being bundled. + if (emptyOutDir && !dryRun && requestedBundles.length === 0) { + await emptyDirectory(outDirPath, [".git"]); + } + // Calculate column widths for aligned output const maxNameLen = Math.max(...outputs.map((o) => o.name.length)); const maxFilesLen = Math.max( @@ -328,7 +504,7 @@ Options: ); } else { await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, result.content); + await writeBundle(outPath, result.content); const displayPath = relative(process.cwd(), outPath); console.log( ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${displayPath}`, @@ -378,16 +554,6 @@ function isGdriveConfigured(config: UploadConfig): boolean { ); } -function getGdriveConfig(config: { - upload?: UploadConfig | UploadConfig[]; -}): UploadConfig | null { - if (!config.upload) return null; - const uploads = Array.isArray(config.upload) - ? config.upload - : [config.upload]; - return uploads.find(isGdriveConfigured) ?? null; -} - async function runLogin(): Promise { let config; try { @@ -531,9 +697,13 @@ function getOutfile( } main().catch((err) => { - // Config and git failures are user-facing; a stack trace only adds noise + // Config, git and Linear failures are user-facing; a stack trace adds noise console.error( - err instanceof ConfigError || err instanceof GitError ? err.message : err, + err instanceof ConfigError || + err instanceof GitError || + err instanceof LinearError + ? err.message + : err, ); process.exit(1); }); diff --git a/src/config.ts b/src/config.ts index e8a68b0..36b856f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT +import { cosmiconfig } from "cosmiconfig"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { cosmiconfig } from "cosmiconfig"; import { z } from "zod"; export function expandPath(p: string): string { @@ -18,6 +18,65 @@ const PatternsSchema = z.union([ z.array(z.string().min(1)).min(1), ]); +/** + * A name typed by a human and handed to a remote API. Trimmed, because a key + * pasted with a stray space fails as "not found" or "not authorized", which + * reads like the wrong key rather than the wrong whitespace. + */ +const IdentifierSchema = z.string().trim().min(1); + +/** + * A bundle name, which is also a filename: the default output is + * `/.txt`. Unconstrained, `"../report"` writes outside `outDir` + * entirely, and a leading `-` names a bundle the CLI can never be asked for. + */ +const BundleNameSchema = z + .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", + ); + +// Every closed object below is strict. Zod strips unknown keys by default, and +// a stripped key is a typo that changes behaviour without saying so: `liner` +// drops a bundle's issues, `emptyOutdir` hands the decision back to the +// automatic default, `exlude` uploads the bundle it was meant to hold back. +// Config files are edited by hand and read by a machine — the failure has to be +// loud. Adding a key is a minor version either way, so nothing is lost. + +/** + * Linear issues as a bundle source. Each issue becomes a virtual file at + * `linear/issues/.md`, so it gets its own index entry and line + * range and can be filtered with ordinary `!` exclusions. + * + * Authentication reads `LINEAR_API_KEY` from the environment. It is + * deliberately not a config field: config files are committed, and the + * `package.json` config form cannot express `process.env`. + * + * `team` is required. A workspace-wide fetch is a footgun — it looks innocuous + * and can pull thousands of issues into a context window. + * + * @example + * ```ts + * bundles: { + * backlog: { linear: "ENG" }, + * roadmap: { linear: { team: "ENG", project: "Roadmap" } }, + * } + * ``` + */ +const LinearSourceSchema = z.union([ + /** Shorthand for `{ team: "" }`. */ + IdentifierSchema, + z.strictObject({ + /** Team key — the `ENG` in issue identifier `ENG-123`. */ + team: IdentifierSchema, + /** Project name. Must name exactly one project within the team. */ + project: IdentifierSchema.optional(), + /** Include completed, canceled and duplicate issues. Defaults to false. */ + includeClosed: z.boolean().default(false), + }), +]); + /** * Bundle configuration. Accepts a string pattern, array of patterns, or object. * Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force @@ -27,24 +86,35 @@ const PatternsSchema = z.union([ * `git:unstaged`, `git:untracked`, `git:dirty`, or `git:` (e.g. * `git:main`, `git:HEAD~3`). * + * The object form takes files (`include`), Linear issues (`linear`), or both. + * * @example * ```ts - * bundles: { review: ["git:staged", "!bun.lock"] } + * bundles: { + * review: ["git:staged", "!bun.lock"], + * planning: { include: ["docs/**"], linear: { team: "ENG" } }, + * } * ``` */ const BundleConfigSchema = z.union([ z.string().min(1), z.array(z.string().min(1)).min(1), - z.object({ - /** Glob patterns to include in the bundle. */ - include: PatternsSchema, - /** Custom output file path. Defaults to `/.txt`. */ - outfile: z.string().optional(), - /** Include file index header in output. Defaults to true. */ - index: z.boolean().default(true), - /** Text to prepend to bundle (e.g., review instructions for LLMs). */ - prompt: z.string().optional(), - }), + z + .strictObject({ + /** Glob patterns to include in the bundle. */ + include: PatternsSchema.optional(), + /** Linear issues to include in the bundle. */ + linear: LinearSourceSchema.optional(), + /** Custom output file path. Defaults to `/.txt`. */ + outfile: z.string().min(1).optional(), + /** Include file index header in output. Defaults to true. */ + index: z.boolean().default(true), + /** Text to prepend to bundle (e.g., review instructions for LLMs). */ + prompt: z.string().optional(), + }) + .refine((bundle) => bundle.include || bundle.linear, { + message: 'Bundle needs a source: "include" patterns, "linear", or both', + }), ]); /** @@ -61,39 +131,68 @@ const BundleConfigSchema = z.union([ * } * ``` */ -const UploadConfigSchema = z.object({ +const UploadConfigSchema = z.strictObject({ /** Upload provider. Currently only "gdrive" is supported. */ provider: z.literal("gdrive"), /** Google Drive folder ID to upload files to. If omitted, uploads to root. */ - folderId: z.string().optional(), + folderId: IdentifierSchema.optional(), /** OAuth 2.0 client ID from Google Cloud Console. */ - clientId: z.string().min(1), + clientId: IdentifierSchema, /** OAuth 2.0 client secret from Google Cloud Console. */ - clientSecret: z.string().min(1), + clientSecret: IdentifierSchema, /** Bundle names to skip during upload. Supports exact names only. */ exclude: z.array(z.string()).optional(), }); /** Root configuration for srcpack. */ -const ConfigSchema = z.object({ - /** - * Project root directory. Can be absolute or relative to CWD. - * @default process.cwd() - */ - root: z.string().default(""), - /** Output directory for bundle files (relative to root). Defaults to ".srcpack". */ - outDir: z.string().default(".srcpack"), - /** Empty outDir before bundling. Auto-enabled when outDir is inside project root. */ - emptyOutDir: z.boolean().optional(), - /** Upload configuration for cloud storage. Single destination or array. */ - upload: z - .union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)]) - .optional(), - /** Named bundles mapping bundle name to glob patterns or config object. */ - bundles: z.record(z.string(), BundleConfigSchema), -}); +const ConfigSchema = z + .strictObject({ + /** + * Project root directory. Can be absolute or relative to CWD. + * @default process.cwd() + */ + root: z.string().default(""), + /** Output directory for bundle files (relative to root). Defaults to ".srcpack". */ + outDir: z.string().default(".srcpack"), + /** Empty outDir before writing. Automatic only for the default `.srcpack`. */ + emptyOutDir: z.boolean().optional(), + /** Upload configuration for cloud storage. Single destination or array. */ + upload: z + .union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)]) + .optional(), + /** Named bundles mapping bundle name to glob patterns or config object. */ + bundles: z.record(BundleNameSchema, BundleConfigSchema), + }) + .superRefine((config, ctx) => { + // `upload.exclude` is the only thing keeping a bundle off Google Drive, so a + // name that matches nothing uploads the bundle it was meant to hold back — + // the one failure mode where a typo is worse than a missing line. A stale + // entry left over from a deleted bundle is cheap to fix by comparison. + const uploads = config.upload + ? Array.isArray(config.upload) + ? config.upload + : [config.upload] + : []; + const names = new Set(Object.keys(config.bundles)); + + uploads.forEach((upload, i) => { + const path = Array.isArray(config.upload) + ? ["upload", i, "exclude"] + : ["upload", "exclude"]; + for (const name of upload.exclude ?? []) { + if (!names.has(name)) { + ctx.addIssue({ + code: "custom", + path, + message: `Unknown bundle "${name}"`, + }); + } + } + }); + }); export type UploadConfig = z.infer; +export type LinearSourceInput = z.input; export type BundleConfig = z.infer; export type BundleConfigInput = z.input; export type Config = z.infer; @@ -110,13 +209,57 @@ export class ConfigError extends Error { } } +/** A zod issue, plus the nested issues a union or a bad record key carries. */ +interface Issue { + code: string; + message: string; + path: PropertyKey[]; + /** One entry per union branch. */ + errors?: Issue[][]; + /** Why a record key was rejected. */ + issues?: Issue[]; +} + +/** + * Reduce an issue to the leaves that actually explain it. Both wrappers say + * nothing on their own — "Invalid input", "Invalid key in record" — while the + * reason sits one level down. + */ +function flatten(issue: Issue, prefix: PropertyKey[] = []): Issue[] { + const path = [...prefix, ...issue.path]; + const nested = + issue.code === "invalid_union" + ? issue.errors?.flat() + : issue.code === "invalid_key" + ? issue.issues + : undefined; + return nested?.length + ? nested.flatMap((child) => flatten(child, path)) + : [{ ...issue, path }]; +} + +/** + * Describe the most specific reason a config failed. + * + * Bundle and upload configs are unions, and a union reports one failure per + * branch. Reporting the first would surface "expected string" from a branch + * that never applied, burying the branch that nearly matched — so prefer a + * leaf that says something other than "wrong type", deepest path first. + */ +function describe(issues: Issue[]): string { + const leaves = issues.flatMap((issue) => flatten(issue)); + const specific = leaves.filter((leaf) => leaf.code !== "invalid_type"); + const best = (specific.length ? specific : leaves).reduce((a, b) => + b.path.length > a.path.length ? b : a, + ); + const path = best.path.join("."); + return path ? `${path}: ${best.message}` : best.message; +} + export function parseConfig(value: unknown): Config { const result = ConfigSchema.safeParse(value); if (!result.success) { - const issue = result.error.issues[0]!; - const path = issue.path.join("."); - const message = path ? `${path}: ${issue.message}` : issue.message; - throw new ConfigError(message); + throw new ConfigError(describe(result.error.issues as unknown as Issue[])); } const config = result.data; diff --git a/src/linear.ts b/src/linear.ts new file mode 100644 index 0000000..4f75fd5 --- /dev/null +++ b/src/linear.ts @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: MIT + +import type { LinearSourceInput } from "./config.ts"; + +const API_URL = "https://api.linear.app/graphql"; + +/** A stalled connection shouldn't leave the CLI hanging with a spinner up. */ +const TIMEOUT_MS = 30_000; + +/** Personal API key: Linear → Settings → Security & access → Personal API keys. */ +const TOKEN_ENV = "LINEAR_API_KEY"; + +/** + * Linear charges query complexity as page size × selected fields, and the issue + * selection below is wide enough that asking for its 250 maximum is rejected + * outright as "Query too complex". + */ +const PAGE = 50; + +/** + * Terminal workflow state types, excluded unless asked for. `duplicate` is easy + * to miss — it is a state type of its own, not a state name under `canceled`, + * so omitting it leaks duplicates into a bundle meant to be non-terminal. + */ +const CLOSED_STATES = ["completed", "canceled", "duplicate"]; + +/** Linear encodes priority as 0-4; 0 sorts as "no priority", not "lowest". */ +const PRIORITY = ["None", "Urgent", "High", "Medium", "Low"]; + +export class LinearError extends Error { + constructor(message: string) { + super(message); + this.name = "LinearError"; + } +} + +interface Issue { + identifier: string; + title: string; + description: string | null; + priority: number; + estimate: number | null; + dueDate: string | null; + url: string; + createdAt: string; + updatedAt: string; + state: { name: string; type: string }; + labels: { nodes: { name: string; parent: { name: string } | null }[] }; + project: { name: string } | null; + projectMilestone: { name: string } | null; + parent: { identifier: string } | null; + assignee: { name: string } | null; +} + +interface Page { + nodes: T[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +} + +function requireToken(): string { + // Trimmed: a key read from a file or piped through shell tooling arrives with + // a trailing newline, which Linear answers with a bare "not authorized". + const token = process.env[TOKEN_ENV]?.trim(); + if (!token) { + throw new LinearError( + `${TOKEN_ENV} is not set. Create a personal API key in Linear ` + + "(Settings → Security & access → Personal API keys).", + ); + } + return token; +} + +async function graphql( + query: string, + variables: Record, + token: string, +): Promise { + let response: Response; + try { + response = await fetch(API_URL, { + method: "POST", + headers: { authorization: token, "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (error) { + // A timeout arrives as a DOMException, whose message ("The operation was + // aborted due to timeout") reads like an internal error rather than advice + if ((error as Error).name === "TimeoutError") { + throw new LinearError( + `Linear API request timed out after ${TIMEOUT_MS / 1000}s.`, + ); + } + throw new LinearError( + `Cannot reach the Linear API: ${(error as Error).message}`, + ); + } + + if (response.status === 401 || response.status === 403) { + throw new LinearError( + `${TOKEN_ENV} was rejected by Linear (not authorized).`, + ); + } + + let body: { data?: T; errors?: { message: string }[] }; + try { + body = (await response.json()) as typeof body; + } catch { + throw new LinearError(`Linear API returned ${response.status}.`); + } + + // Linear answers 200 with an `errors` array, so status alone proves nothing. + if (body.errors?.length) { + throw new LinearError(body.errors.map((e) => e.message).join("; ")); + } + if (!response.ok || !body.data) { + throw new LinearError(`Linear API returned ${response.status}.`); + } + return body.data; +} + +/** Fill in the shorthand form and the `includeClosed` default. */ +function normalize(source: LinearSourceInput): { + team: string; + project?: string; + includeClosed: boolean; +} { + if (typeof source === "string") { + return { team: source, includeClosed: false }; + } + return { + team: source.team, + project: source.project, + includeClosed: source.includeClosed ?? false, + }; +} + +/** + * Confirm the team exists and resolve the project name to a single id. + * + * Linear answers an unknown team key with an empty issue list rather than an + * error, so without this a typo yields a silently empty bundle — the one + * failure mode that looks like success. Project names are neither unique nor + * stable, so they are resolved to an id within the team and required to match + * exactly one project; filtering issues by name would silently union two. + */ +async function resolveScope( + team: string, + project: string | undefined, + token: string, +): Promise<{ projectId?: string }> { + const projects = project + ? "projects(filter: { name: { eq: $project } }, first: 2) { nodes { id } }" + : ""; + const query = ` + query ($team: String!${project ? ", $project: String!" : ""}) { + teams(filter: { key: { eq: $team } }, first: 1) { + nodes { key ${projects} } + } + } + `; + const data = await graphql<{ + teams: { nodes: { projects?: { nodes: { id: string }[] } }[] }; + }>(query, project ? { team, project } : { team }, token); + + const found = data.teams.nodes[0]; + if (!found) { + throw new LinearError( + `Team "${team}" not found in this Linear workspace. ` + + "Use the team key shown in issue identifiers (the ENG in ENG-123).", + ); + } + if (!project) return {}; + + const matches = found.projects?.nodes ?? []; + if (matches.length === 0) { + throw new LinearError(`Project "${project}" not found in team "${team}".`); + } + if (matches.length > 1) { + throw new LinearError( + `Project "${project}" is ambiguous: team "${team}" has more than one project with that name.`, + ); + } + return { projectId: matches[0]!.id }; +} + +async function fetchIssues( + filter: Record, + token: string, +): Promise { + // Ordered by `createdAt`, not the `updatedAt` default: a cursor walk is only + // stable while the sort key is. An issue edited mid-run reorders an + // `updatedAt` list under the cursor, dropping or repeating its neighbours. + const query = ` + query ($cursor: String, $filter: IssueFilter) { + issues(first: ${PAGE}, after: $cursor, filter: $filter, orderBy: createdAt) { + nodes { + identifier + title + description + priority + estimate + dueDate + url + createdAt + updatedAt + state { name type } + # A deliberate cap, not pagination: 50 labels is far past what an + # issue carries, and each nested page multiplies query complexity + labels(first: 50) { nodes { name parent { name } } } + project { name } + projectMilestone { name } + parent { identifier } + assignee { name } + } + pageInfo { hasNextPage endCursor } + } + } + `; + + const issues: Issue[] = []; + let cursor: string | null = null; + do { + const data: { issues: Page } = await graphql<{ + issues: Page; + }>(query, { cursor, filter }, token); + issues.push(...data.issues.nodes); + cursor = data.issues.pageInfo.hasNextPage + ? data.issues.pageInfo.endCursor + : null; + } while (cursor); + + return issues; +} + +/** + * Issue mentions arrive as ordinary markdown links and need no handling. + * Attachments do: an embedded image or video is one long `` tag + * carrying an upload URL and a JSON blob, which burns context for nothing. + */ +function stripEmbeds(markdown: string): string { + return markdown + .replace(/]*>.*?<\/linear-embed>/gs, "[embed]") + .replace(/]*\/?>/g, "[embed]") + .trimEnd(); +} + +function labelNames(issue: Issue): string[] { + return issue.labels.nodes.map((l) => + l.parent ? `${l.parent.name}/${l.name}` : l.name, + ); +} + +/** The number in `SM-13`, for ordering. Non-numeric suffixes sort last. */ +function issueNumber(issue: Issue): number { + const n = Number(issue.identifier.split("-").pop()); + return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER; +} + +/** + * A roster of every issue in the bundle, emitted as the first Linear entry. + * + * The bundle index lists paths, and `linear/issues/SM-13.md` says nothing about + * what SM-13 is — so without this a model has to read forty issue bodies to + * find the two that matter, and can't answer "what's in progress" at all. + * + * Rows are ordered by issue number, which the index itself cannot be: it sorts + * paths as text, so SM-2 lands between SM-19 and SM-20. + */ +function renderSummary(scope: string, issues: Issue[]): string { + const tally = new Map(); + for (const issue of issues) { + tally.set(issue.state.name, (tally.get(issue.state.name) ?? 0) + 1); + } + const counts = [...tally] + .sort((a, b) => b[1] - a[1]) + .map(([name, n]) => `${name} ${n}`) + .join(" · "); + + const rows = [...issues] + .sort((a, b) => issueNumber(a) - issueNumber(b)) + .map((issue) => { + // A title carrying `|` would otherwise split into a phantom column + const title = issue.title.replace(/\|/g, "\\|"); + const priority = PRIORITY[issue.priority] ?? String(issue.priority); + return `| ${issue.identifier} | ${issue.state.name} | ${priority} | ${title} |`; + }); + + return [ + `# ${scope} — ${issues.length} ${issues.length === 1 ? "issue" : "issues"}`, + "", + counts, + "", + "| Issue | State | Priority | Title |", + "| --- | --- | --- | --- |", + ...rows, + ].join("\n"); +} + +/** Render one issue as markdown: a title, an aligned field block, the body. */ +function render(issue: Issue): string { + const field = (name: string, value: string | null | undefined) => + `${name.padEnd(10)} ${value?.length ? value : "—"}`; + + const description = issue.description?.trim() + ? stripEmbeds(issue.description) + : "(no description)"; + + return [ + `# ${issue.identifier} ${issue.title}`, + "", + field("State", `${issue.state.name} (${issue.state.type})`), + field("Priority", PRIORITY[issue.priority] ?? String(issue.priority)), + field("Estimate", issue.estimate === null ? null : String(issue.estimate)), + field("Labels", labelNames(issue).join(", ")), + field("Project", issue.project?.name), + field("Milestone", issue.projectMilestone?.name), + field("Parent", issue.parent?.identifier), + field("Assignee", issue.assignee?.name), + field("Due", issue.dueDate), + field("Created", issue.createdAt.slice(0, 10)), + field("Updated", issue.updatedAt.slice(0, 10)), + field("URL", issue.url), + "", + description, + ].join("\n"); +} + +/** + * Resolve a `linear` bundle source to one virtual file per issue. + * + * Paths are namespaced under `linear/issues/` so they sort together, read as + * files in the index, and can be filtered with ordinary `!` exclusions. + * + * The return type is structural rather than `Entry` from `bundle.ts`: a source + * shouldn't depend on the module that orchestrates it. + */ +export async function resolveLinearSource( + source: LinearSourceInput, +): Promise<{ path: string; content: string }[]> { + const { team, project, includeClosed } = normalize(source); + const token = requireToken(); + + const { projectId } = await resolveScope(team, project, token); + + const filter: Record = { team: { key: { eq: team } } }; + if (projectId) filter.project = { id: { eq: projectId } }; + if (!includeClosed) filter.state = { type: { nin: CLOSED_STATES } }; + + const issues = await fetchIssues(filter, token); + if (issues.length === 0) return []; + + // Sorts ahead of `linear/issues/…` because "." precedes "/", so the roster is + // the first Linear entry a reader meets. Subject to the same collision check + // and `!` exclusions as any other entry. + const scope = project ? `${team} / ${project}` : team; + const entries = [ + { path: "linear/issues.md", content: renderSummary(scope, issues) }, + ]; + + for (const issue of issues) { + entries.push({ + path: `linear/issues/${issue.identifier}.md`, + content: render(issue), + }); + } + return entries; +} diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index f75dca8..267db0e 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -1,17 +1,21 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { describe, expect, test, afterEach } from "bun:test"; +import { join } from "node:path"; const CLI_PATH = join(import.meta.dir, "../../src/cli.ts"); const FIXTURE_PATH = join(import.meta.dir, "../fixtures/sample-project"); async function runCli( args: string[], - options?: { cwd?: string }, + options?: { cwd?: string; unsetEnv?: string[] }, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const env = { ...process.env }; + for (const key of options?.unsetEnv ?? []) delete env[key]; + const proc = Bun.spawn(["bun", CLI_PATH, ...args], { cwd: options?.cwd, + env, stdout: "pipe", stderr: "pipe", }); @@ -59,6 +63,46 @@ describe("cli", () => { ); }); + describe("argument validation", () => { + // Dropping an unknown flag turns the safe command the user typed into the + // dangerous one they didn't: --no-uplaod uploads, --dry-rnu writes + test.each([["--no-uplaod"], ["--dry-rnu"], ["--no-emptyOutdir"]])( + "should reject %p instead of ignoring it", + async (flag) => { + const result = await runCli([flag], { cwd: FIXTURE_PATH }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(`Unknown option: ${flag}`); + }, + ); + + test("should reject contradictory emptyOutDir flags", async () => { + const result = await runCli(["--emptyOutDir", "--no-emptyOutDir"], { + cwd: FIXTURE_PATH, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Cannot combine"); + }); + + test.each([["init"], ["login"]])( + "should reject arguments to %p, which takes none", + async (command) => { + const result = await runCli([command, "--wat"], { cwd: FIXTURE_PATH }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("takes no arguments"); + }, + ); + + test("should treat an inherited property as an unknown bundle", async () => { + const result = await runCli(["toString"], { cwd: FIXTURE_PATH }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unknown bundle: toString"); + }); + }); + describe("init subcommand", () => { test("should exit gracefully in non-TTY mode", async () => { const result = await runCli(["init"]); @@ -172,6 +216,54 @@ describe("cli", () => { }); }); + describe("failed run", () => { + const project = join(tmpdir(), `srcpack-fail-${Date.now()}`); + + afterEach(async () => { + await rm(project, { recursive: true, force: true }); + }); + + test("should keep the previous output when a bundle fails to resolve", async () => { + await mkdir(join(project, "docs"), { recursive: true }); + await writeFile(join(project, "docs/readme.md"), "# Readme\n"); + const config = join(project, "srcpack.config.ts"); + await writeFile( + config, + `export default { bundles: { docs: "docs/**/*.md" } };`, + ); + + const good = await runCli([], { cwd: project }); + expect(good.exitCode).toBe(0); + + const bundle = Bun.file(join(project, ".srcpack/docs.txt")); + const before = await bundle.text(); + + // Now add a bundle that cannot resolve. `linear` needs no network to + // fail: with no API key it throws before the first request. + await writeFile( + config, + `export default { + bundles: { + docs: "docs/**/*.md", + backlog: { linear: "ENG" }, + }, + };`, + ); + + const failed = await runCli([], { + cwd: project, + unsetEnv: ["LINEAR_API_KEY"], + }); + + expect(failed.exitCode).toBe(1); + expect(failed.stderr).toContain("LINEAR_API_KEY is not set"); + // outDir is emptied before writing, so emptying it before resolving + // would leave nothing behind when a later bundle throws + expect(await bundle.exists()).toBe(true); + expect(await bundle.text()).toBe(before); + }); + }); + describe("own output", () => { const project = join(tmpdir(), `srcpack-own-${Date.now()}`); @@ -185,7 +277,7 @@ describe("cli", () => { await writeFile(join(project, "keep.md"), "# keep\n"); await writeFile( join(project, "srcpack.config.ts"), - `export default { outDir: ".", bundles: { app: "src/**/*" } };`, + `export default { outDir: ".", emptyOutDir: true, bundles: { app: "src/**/*" } };`, ); const result = await runCli([], { cwd: project }); @@ -197,6 +289,355 @@ describe("cli", () => { expect(await Bun.file(join(project, "src/index.ts")).exists()).toBe(true); }); + test("should not empty a custom outDir unless asked", async () => { + // `outDir: "src"` reads as an ordinary setting; auto-emptying it would + // delete the sources the same config asks to bundle + await mkdir(join(project, "src"), { recursive: true }); + await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { outDir: "src", bundles: { app: "src/**/*.ts" } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + expect(await Bun.file(join(project, "src/index.ts")).exists()).toBe(true); + }); + + test("should refuse a .srcpack that resolves outside the project", async () => { + // Lexically `.srcpack` is inside the project; physically it is someone + // else's directory. Declining to empty it isn't enough — writing there + // still overwrites whatever shares a name with a bundle. + const elsewhere = `${project}-elsewhere`; + await mkdir(elsewhere, { recursive: true }); + await mkdir(project, { recursive: true }); + await writeFile(join(elsewhere, "sentinel.txt"), "do not delete\n"); + await symlink(elsewhere, join(project, ".srcpack")); + await writeFile(join(project, "a.md"), "# a\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { docs: "*.md" } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("it resolves to"); + expect(await Bun.file(join(elsewhere, "sentinel.txt")).exists()).toBe( + true, + ); + expect(await Bun.file(join(elsewhere, "docs.txt")).exists()).toBe(false); + await rm(elsewhere, { recursive: true, force: true }); + }); + + test("should still exclude stale output when root reaches it through a link", async () => { + // `root` is spelled with a symlink, so the paths srcpack compares are + // lexical while the directory it owns resolves elsewhere. Mixing the two + // makes the previous run's bundle look like an ordinary source file. + const real = join(project, "real"); + await mkdir(join(real, ".srcpack"), { recursive: true }); + await symlink(real, join(project, "app")); + await writeFile(join(real, "a.md"), "# a\n"); + await writeFile( + join(real, ".srcpack/old-name.txt"), + "STALE BUNDLE FROM A RENAMED CONFIG\n", + ); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { root: "./app", emptyOutDir: false, bundles: { current: "**/*" } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + const bundle = await Bun.file(join(real, ".srcpack/current.txt")).text(); + expect(bundle).not.toContain("STALE BUNDLE"); + }); + + test("should replace a symlinked output instead of writing through it", async () => { + // Nothing empties outDir here, so the link is still in place at write + // time — and writing in place would land in the linked file + const elsewhere = `${project}-elsewhere`; + await mkdir(elsewhere, { recursive: true }); + await mkdir(join(project, ".srcpack"), { recursive: true }); + await writeFile(join(elsewhere, "private.txt"), "untouched\n"); + await symlink( + join(elsewhere, "private.txt"), + join(project, ".srcpack/docs.txt"), + ); + await writeFile(join(project, "a.md"), "# a\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { emptyOutDir: false, bundles: { docs: "*.md" } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + expect(await Bun.file(join(elsewhere, "private.txt")).text()).toBe( + "untouched\n", + ); + expect( + await Bun.file(join(project, ".srcpack/docs.txt")).text(), + ).toContain("# a"); + await rm(elsewhere, { recursive: true, force: true }); + }); + + test("should reject two bundles whose paths alias one file", async () => { + await mkdir(join(project, "src"), { recursive: true }); + await mkdir(join(project, ".srcpack"), { recursive: true }); + await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); + await symlink(join(project, ".srcpack"), join(project, "alias")); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + frontend: { include: "src/**/*", outfile: ".srcpack/ctx.txt" }, + backend: { include: "src/**/*", outfile: "alias/ctx.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("both write to"); + }); + + // "Café.txt" precomposed (U+00E9) and decomposed (e + U+0301). APFS stores + // whichever spelling it is given but resolves both to one directory entry. + // Written as escapes: a literal would be at the mercy of whatever + // normalisation an editor or formatter applies to this file. + const NFC_NAME = "Caf\u00e9.txt"; + const NFD_NAME = "Cafe\u0301.txt"; + + test("should reject two bundles whose outfiles differ only by normalisation", async () => { + await mkdir(join(project, "src"), { recursive: true }); + await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + frontend: { include: "src/**/*", outfile: ".srcpack/${NFC_NAME}" }, + backend: { include: "src/**/*", outfile: ".srcpack/${NFD_NAME}" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("both write to"); + }); + + test("should exclude a stale output whose name differs only by normalisation", async () => { + await mkdir(join(project, "generated"), { recursive: true }); + await writeFile(join(project, "generated/notes.md"), "# notes\n"); + await writeFile( + join(project, `generated/${NFC_NAME}`), + "STALE BUNDLE FROM THE PREVIOUS RUN\n", + ); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + ctx: { include: "generated/**/*", outfile: "generated/${NFD_NAME}" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + const bundle = await Bun.file( + join(project, `generated/${NFD_NAME}`), + ).text(); + expect(bundle).toContain("# notes"); + expect(bundle).not.toContain("STALE BUNDLE"); + }); + + test("should never empty a directory that only case-matches .srcpack", async () => { + // Ownership is an exact match on purpose, so `.SRCPACK` is never the + // directory srcpack clears unasked. How it declines differs by + // filesystem — refused as a redirected `.srcpack` where case folds, an + // unrelated directory where it doesn't — but the contents survive either + // way, which is the property worth pinning. + await mkdir(join(project, ".SRCPACK"), { recursive: true }); + await writeFile( + join(project, ".SRCPACK/sentinel.txt"), + "irreplaceable\n", + ); + await writeFile(join(project, "a.md"), "# a\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { docs: "*.md" } };`, + ); + + await runCli([], { cwd: project }); + + expect( + await Bun.file(join(project, ".SRCPACK/sentinel.txt")).text(), + ).toBe("irreplaceable\n"); + }); + + test("should reject two bundles whose outfiles differ only by case", async () => { + // macOS and Windows fold case, so these are one directory entry there and + // the second bundle silently replaces the first. Rejected everywhere: a + // config that survives on Linux and loses a bundle on a laptop is worse + // than one that fails the same way on both. + await mkdir(join(project, "src"), { recursive: true }); + await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + Web: { include: "src/**/*", outfile: ".srcpack/Context.txt" }, + web: { include: "src/**/*", outfile: ".srcpack/context.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("both write to"); + expect( + await Bun.file(join(project, ".srcpack/Context.txt")).exists(), + ).toBe(false); + }); + + test("should exclude a stale output whose name differs only by case", async () => { + await mkdir(join(project, "generated"), { recursive: true }); + await writeFile(join(project, "generated/notes.md"), "# notes\n"); + await writeFile( + join(project, "generated/Context.txt"), + "STALE BUNDLE FROM THE PREVIOUS RUN\n", + ); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + ctx: { include: "generated/**/*", outfile: "generated/context.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + const written = join(project, "generated/context.txt"); + const bundle = await Bun.file(written).text(); + expect(bundle).toContain("# notes"); + expect(bundle).not.toContain("STALE BUNDLE"); + }); + + test("should exclude stale bundles under an outDir named by a link", async () => { + await mkdir(join(project, "generated"), { recursive: true }); + await symlink(join(project, "generated"), join(project, "alias")); + await writeFile(join(project, "notes.md"), "# notes\n"); + await writeFile( + join(project, "generated/old-name.txt"), + "STALE BUNDLE FROM A RENAMED CONFIG\n", + ); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { outDir: "alias", bundles: { ctx: ["**/*"] } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + const bundle = await Bun.file(join(project, "generated/ctx.txt")).text(); + expect(bundle).toContain("# notes"); + expect(bundle).not.toContain("STALE BUNDLE"); + }); + + test("should exclude a custom outfile when root reaches it through a link", async () => { + // The mirror of the case above: here the glob yields lexical paths and the + // outfile resolves physically. Neither identity alone covers both. + const real = join(project, "real"); + await mkdir(real, { recursive: true }); + await symlink(real, join(project, "app")); + await writeFile(join(real, "a.md"), "# a\n"); + await writeFile( + join(real, "ctx.txt"), + "STALE BUNDLE FROM THE PREVIOUS RUN\n", + ); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { root: "./app", bundles: { + ctx: { include: "**/*", outfile: "ctx.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + const bundle = await Bun.file(join(real, "ctx.txt")).text(); + expect(bundle).toContain("# a"); + expect(bundle).not.toContain("STALE BUNDLE"); + }); + + test("should reject aliased outfiles under a directory that does not exist yet", async () => { + // The alias only becomes visible once the intermediate directories are + // created, which the first write does. Resolving a fixed number of levels + // calls these two different files right up until they turn out to be one. + await mkdir(join(project, "src"), { recursive: true }); + await mkdir(join(project, ".srcpack"), { recursive: true }); + await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); + await symlink(join(project, ".srcpack"), join(project, "alias")); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + frontend: { include: "src/**/*", outfile: ".srcpack/nested/deep/ctx.txt" }, + backend: { include: "src/**/*", outfile: "alias/nested/deep/ctx.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("both write to"); + expect( + await Bun.file(join(project, ".srcpack/nested/deep/ctx.txt")).exists(), + ).toBe(false); + }); + + test("should exclude a custom outfile reached through a link from its own sources", async () => { + // The outfile and the source glob name one directory by two spellings, so + // lexical comparison alone lets the previous run's bundle back in. + await mkdir(join(project, "generated"), { recursive: true }); + await symlink(join(project, "generated"), join(project, "alias")); + await writeFile(join(project, "generated/notes.md"), "# notes\n"); + await writeFile( + join(project, "generated/context.txt"), + "STALE BUNDLE FROM THE PREVIOUS RUN\n", + ); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + context: { include: "generated/**/*", outfile: "alias/context.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(0); + const bundle = await Bun.file( + join(project, "generated/context.txt"), + ).text(); + expect(bundle).toContain("# notes"); + expect(bundle).not.toContain("STALE BUNDLE"); + }); + + test("should reject two bundles writing to one file", async () => { + await mkdir(join(project, "src"), { recursive: true }); + await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); + await writeFile( + join(project, "srcpack.config.ts"), + `export default { bundles: { + frontend: { include: "src/**/*", outfile: ".srcpack/ctx.txt" }, + backend: { include: "src/**/*", outfile: ".srcpack/ctx.txt" }, + } };`, + ); + + const result = await runCli([], { cwd: project }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("both write to"); + }); + test("should keep other bundles when building a named subset", async () => { await mkdir(join(project, "src"), { recursive: true }); await writeFile(join(project, "src/index.ts"), "export const x = 1;\n"); diff --git a/tests/unit/bundle.test.ts b/tests/unit/bundle.test.ts index 1c28c3c..f657d6e 100644 --- a/tests/unit/bundle.test.ts +++ b/tests/unit/bundle.test.ts @@ -1,13 +1,20 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "bun:test"; import { bundleOne, createBundle, formatIndex, resolvePatterns, - type FileEntry, + type Entry, + type IndexEntry, } from "../../src/bundle.ts"; +/** createBundle takes entries; these tests only exercise on-disk files. */ +const entries = (...paths: string[]): Entry[] => + paths.map((path) => ({ path })); + const fixturesDir = join(import.meta.dir, "../fixtures/sample-project"); const gitignoreFixturesDir = join( import.meta.dir, @@ -218,7 +225,7 @@ describe("resolvePatterns", () => { join(gitignoreFixturesDir, "src/index.ts"), fixturesDir, ); - const result = await createBundle(files, fixturesDir); + const result = await createBundle(entries(...files), fixturesDir); expect(result.index).toHaveLength(1); expect(result.index[0]!.lines).toBeGreaterThan(0); @@ -261,6 +268,77 @@ describe("resolvePatterns", () => { }); }); +/** + * The two boundaries a bundle must not cross: what .gitignore hides, and the + * project itself. Both are documented guarantees, so both are tested against a + * layout built here rather than a fixture — a symlink pointing out of the repo + * doesn't survive packaging. + */ +describe("resolvePatterns boundaries", () => { + let project: string; + let outside: string; + + beforeEach(async () => { + project = await mkdtemp(join(tmpdir(), "srcpack-project-")); + outside = await mkdtemp(join(tmpdir(), "srcpack-outside-")); + }); + + afterEach(async () => { + await rm(project, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + test("should apply a nested .gitignore the way git does", async () => { + await mkdir(join(project, "packages/app"), { recursive: true }); + await writeFile(join(project, ".gitignore"), "node_modules/\n"); + await writeFile( + join(project, "packages/app/.gitignore"), + ".env\n!keep.env\n", + ); + await writeFile( + join(project, "packages/app/.env"), + "DB_PASSWORD=hunter2\n", + ); + await writeFile(join(project, "packages/app/keep.env"), "PUBLIC=1\n"); + await writeFile(join(project, "packages/app/index.ts"), "export {};\n"); + + const files = await resolvePatterns("**/*", project); + + // Reading only the root .gitignore bundles every secret a monorepo hides + // one directory down — the case that motivates layered resolution + expect(files).not.toContain("packages/app/.env"); + // A negation in the same file re-includes, as it does for git + expect(files).toContain("packages/app/keep.env"); + expect(files).toContain("packages/app/index.ts"); + }); + + test("should not re-include what an ignored parent directory hides", async () => { + await mkdir(join(project, "hidden"), { recursive: true }); + await writeFile(join(project, ".gitignore"), "hidden/\n"); + await writeFile(join(project, "hidden/.gitignore"), "!secret.txt\n"); + await writeFile(join(project, "hidden/secret.txt"), "SECRET\n"); + + const files = await resolvePatterns("**/*", project); + + // Git never descends into an ignored directory, so the negation can't apply + expect(files).not.toContain("hidden/secret.txt"); + }); + + test("should not walk into a symlinked directory", async () => { + await mkdir(join(outside, "private"), { recursive: true }); + await writeFile(join(outside, "private/secret.txt"), "SECRET\n"); + await writeFile(join(project, "own.ts"), "export {};\n"); + await symlink(outside, join(project, "vendor")); + + const files = await resolvePatterns("**/*", project); + + // The leaf here is an ordinary file — the escape happened at `vendor`, + // which is why checking only the final component cannot catch it + expect(files).not.toContain("vendor/private/secret.txt"); + expect(files).toEqual(["own.ts"]); + }); +}); + describe("formatIndex", () => { test("should format empty index", () => { const result = formatIndex([]); @@ -269,7 +347,7 @@ describe("formatIndex", () => { }); test("should format single entry", () => { - const index: FileEntry[] = [ + const index: IndexEntry[] = [ { path: "src/index.ts", lines: 25, startLine: 1, endLine: 25 }, ]; const result = formatIndex(index); @@ -280,7 +358,7 @@ describe("formatIndex", () => { }); test("should format multiple entries", () => { - const index: FileEntry[] = [ + const index: IndexEntry[] = [ { path: "src/index.ts", lines: 25, startLine: 1, endLine: 25 }, { path: "src/utils.ts", lines: 100, startLine: 26, endLine: 125 }, ]; @@ -294,7 +372,7 @@ describe("formatIndex", () => { describe("createBundle", () => { test("should create bundle with correct content", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir); + const result = await createBundle(entries("src/index.ts"), fixturesDir); expect(result.content).toContain("# Index"); expect(result.content).toContain("#==> [1] src/index.ts <=="); @@ -304,7 +382,7 @@ describe("createBundle", () => { }); test("should compute correct line counts", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir); + const result = await createBundle(entries("src/index.ts"), fixturesDir); expect(result.index).toHaveLength(1); expect(result.index[0]!.path).toBe("src/index.ts"); @@ -313,7 +391,7 @@ describe("createBundle", () => { test("should handle multiple files with correct line ranges", async () => { const result = await createBundle( - ["src/index.ts", "src/utils/helpers.ts"], + entries("src/index.ts", "src/utils/helpers.ts"), fixturesDir, ); @@ -326,6 +404,28 @@ describe("createBundle", () => { expect(second!.startLine).toBeGreaterThan(first!.endLine); }); + test("should point the index at real content for virtual entries", async () => { + const result = await createBundle( + [ + { path: "src/index.ts" }, + { path: "linear/issues/ENG-1.md", content: "# ENG-1\n\nBody.\n" }, + ], + fixturesDir, + ); + + // The whole point of the index is that a cited line range is readable + const lines = result.content.split("\n"); + const issue = result.index[1]!; + expect(lines.slice(issue.startLine - 1, issue.endLine)).toEqual([ + "# ENG-1", + "", + "Body.", + ]); + expect(lines[issue.startLine - 2]).toBe( + "#==> [2] linear/issues/ENG-1.md <==", + ); + }); + test("should handle empty file list", async () => { const result = await createBundle([], fixturesDir); @@ -334,7 +434,7 @@ describe("createBundle", () => { }); test("should preserve file content exactly", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir); + const result = await createBundle(entries("src/index.ts"), fixturesDir); const content = await Bun.file(join(fixturesDir, "src/index.ts")).text(); // Bundle should contain the file content (without trailing newline) @@ -342,7 +442,10 @@ describe("createBundle", () => { }); test("should handle files with multiple lines", async () => { - const result = await createBundle(["src/utils/helpers.ts"], fixturesDir); + const result = await createBundle( + entries("src/utils/helpers.ts"), + fixturesDir, + ); expect(result.index[0]!.lines).toBeGreaterThan(1); expect(result.index[0]!.endLine).toBeGreaterThan( @@ -351,7 +454,7 @@ describe("createBundle", () => { }); test("should omit index header when includeIndex is false", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { includeIndex: false, }); @@ -363,7 +466,7 @@ describe("createBundle", () => { }); test("should not adjust line numbers when index is omitted", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { includeIndex: false, }); @@ -381,7 +484,7 @@ describe("createBundle", () => { test("should handle multiple files without index", async () => { const result = await createBundle( - ["src/index.ts", "src/utils/helpers.ts"], + entries("src/index.ts", "src/utils/helpers.ts"), fixturesDir, { includeIndex: false }, ); @@ -394,7 +497,7 @@ describe("createBundle", () => { }); test("should prepend prompt with separator", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { prompt: "Review this code for security issues.", }); @@ -404,7 +507,7 @@ describe("createBundle", () => { }); test("should adjust line numbers for prompt offset", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { prompt: "Review this code.", }); @@ -416,7 +519,7 @@ describe("createBundle", () => { }); test("should handle multi-line prompt", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { prompt: "Review this code.\nFocus on:\n- Security\n- Performance", }); @@ -428,7 +531,7 @@ describe("createBundle", () => { }); test("should prepend prompt without index", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { prompt: "Review this code.", includeIndex: false, }); @@ -443,7 +546,7 @@ describe("createBundle", () => { }); test("should ignore empty prompt", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { prompt: "", }); @@ -452,7 +555,7 @@ describe("createBundle", () => { }); test("should ignore whitespace-only prompt", async () => { - const result = await createBundle(["src/index.ts"], fixturesDir, { + const result = await createBundle(entries("src/index.ts"), fixturesDir, { prompt: " \n \n ", }); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 0541a7b..206c251 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -1,6 +1,6 @@ +import { describe, expect, expectTypeOf, test } from "bun:test"; import { homedir } from "node:os"; import { join } from "node:path"; -import { describe, expect, expectTypeOf, test } from "bun:test"; import { type BundleConfig, type BundleConfigInput, @@ -227,7 +227,11 @@ describe("parseConfig", () => { test("should accept upload.exclude as array of bundle names", () => { const config = parseConfig({ - bundles: { web: "src/**/*", local: "local/**/*" }, + bundles: { + web: "src/**/*", + local: "local/**/*", + debug: "debug/**/*", + }, upload: { provider: "gdrive", clientId: "id", @@ -242,6 +246,33 @@ describe("parseConfig", () => { ]); }); + test("should reject an upload.exclude name that is not a bundle", () => { + // A name that matches nothing uploads the bundle it was meant to hold + // back — for a bundle of Linear issues, off the machine entirely + const gdrive = { + provider: "gdrive", + clientId: "id", + clientSecret: "secret", + }; + + expect(() => + parseConfig({ + bundles: { planning: { linear: "ENG" } }, + upload: { ...gdrive, exclude: ["planing"] }, + }), + ).toThrow('upload.exclude: Unknown bundle "planing"'); + + expect(() => + parseConfig({ + bundles: { planning: { linear: "ENG" } }, + upload: [ + { ...gdrive, exclude: ["planning"] }, + { ...gdrive, exclude: ["planing"] }, + ], + }), + ).toThrow('upload.1.exclude: Unknown bundle "planing"'); + }); + test("should leave upload.exclude undefined when not provided", () => { const config = parseConfig({ bundles: { web: "src/**/*" }, @@ -334,12 +365,155 @@ describe("parseConfig", () => { } }); + test("should reject a bundle name that is not a filename", () => { + // The default output is `/.txt`, so "../report" writes + // outside outDir and "-x" names a bundle the CLI can never be given + for (const name of ["../report", "-x", "a/b", "."]) { + expect(() => parseConfig({ bundles: { [name]: "src/**/*" } })).toThrow( + "Bundle name must start with a letter or digit", + ); + } + + expect(() => + parseConfig({ bundles: { "web.v2_final-1": "src/**/*" } }), + ).not.toThrow(); + }); + + test("should reject unknown keys rather than strip them", () => { + // A stripped key changes behaviour without saying so: `liner` drops the + // bundle's issues, `emptyOutdir` leaves the automatic default in charge, + // and `exlude` uploads a bundle that was meant to stay local + const cases: [unknown, string][] = [ + [ + { bundles: { web: { include: "src/**", liner: "ENG" } } }, + 'bundles.web: Unrecognized key: "liner"', + ], + [ + { bundles: { web: { include: "src/**", oufile: "web.txt" } } }, + 'bundles.web: Unrecognized key: "oufile"', + ], + [ + { bundles: {}, emptyOutdir: false }, + 'Unrecognized key: "emptyOutdir"', + ], + [ + { + bundles: {}, + upload: { + provider: "gdrive", + clientId: "id", + clientSecret: "secret", + exlude: ["web"], + }, + }, + 'upload: Unrecognized key: "exlude"', + ], + ]; + + for (const [config, message] of cases) { + expect(() => parseConfig(config)).toThrow(message); + } + }); + test("should allow empty bundles object", () => { const config = parseConfig({ bundles: {} }); expect(config.bundles).toEqual({}); }); }); + + describe("linear source", () => { + test("should accept the team shorthand", () => { + const config = parseConfig({ bundles: { backlog: { linear: "ENG" } } }); + + expect(config.bundles.backlog).toEqual({ linear: "ENG", index: true }); + }); + + test("should default includeClosed to false", () => { + const config = parseConfig({ + bundles: { backlog: { linear: { team: "ENG" } } }, + }); + + expect(config.bundles.backlog).toMatchObject({ + linear: { team: "ENG", includeClosed: false }, + }); + }); + + test("should accept files and issues together", () => { + const config = parseConfig({ + bundles: { + planning: { + include: ["docs/**"], + linear: { team: "ENG", project: "Roadmap" }, + }, + }, + }); + + expect(config.bundles.planning).toMatchObject({ + include: ["docs/**"], + linear: { team: "ENG", project: "Roadmap", includeClosed: false }, + }); + }); + + test("should trim team and project", () => { + // A key pasted with a stray space would otherwise fail remotely as + // "not found", which reads like the wrong key rather than the wrong space + const config = parseConfig({ + bundles: { + backlog: { linear: " ENG " }, + roadmap: { linear: { team: " ENG ", project: " Roadmap " } }, + }, + }); + + expect(config.bundles.backlog).toMatchObject({ linear: "ENG" }); + expect(config.bundles.roadmap).toMatchObject({ + linear: { team: "ENG", project: "Roadmap" }, + }); + }); + + test("should require a team", () => { + expect(() => + parseConfig({ + bundles: { backlog: { linear: { project: "Roadmap" } } }, + }), + ).toThrow(ConfigError); + }); + + test("should reject an unknown key instead of silently widening", () => { + // Stripping `projet` would quietly fetch the whole team — the exact + // silent widening the required team and ambiguity checks guard against + try { + parseConfig({ + bundles: { backlog: { linear: { team: "ENG", projet: "Roadmap" } } }, + }); + expect.unreachable("should have thrown"); + } catch (e) { + expect((e as ConfigError).message).toBe( + 'bundles.backlog.linear: Unrecognized key: "projet"', + ); + } + }); + + test("should name the offending path for a missing team", () => { + try { + parseConfig({ bundles: { backlog: { linear: { project: "R" } } } }); + expect.unreachable("should have thrown"); + } catch (e) { + expect((e as ConfigError).message).toContain( + "bundles.backlog.linear.team", + ); + } + }); + + test("should reject a bundle with no source at all", () => { + try { + parseConfig({ bundles: { web: { outfile: "web.txt" } } }); + expect.unreachable("should have thrown"); + } catch (e) { + expect((e as ConfigError).message).toContain("Bundle needs a source"); + } + }); + }); }); describe("defineConfig", () => { diff --git a/tests/unit/git.test.ts b/tests/unit/git.test.ts index 34ba578..c517ec1 100644 --- a/tests/unit/git.test.ts +++ b/tests/unit/git.test.ts @@ -1,9 +1,9 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createBundle, resolvePatterns } from "../../src/bundle.ts"; import { ConfigError } from "../../src/config.ts"; import { GitError, isGitSource, resolveGitSource } from "../../src/git.ts"; @@ -263,7 +263,10 @@ describe("resolvePatterns with git sources", () => { test("should not follow a symlink out of the repository", async () => { const files = await resolvePatterns("git:staged", repo); - const bundle = await createBundle(files, repo); + const bundle = await createBundle( + files.map((path) => ({ path })), + repo, + ); expect(files).toContain("base.ts"); expect(files).not.toContain("leak.txt"); diff --git a/tests/unit/linear.test.ts b/tests/unit/linear.test.ts new file mode 100644 index 0000000..01044e1 --- /dev/null +++ b/tests/unit/linear.test.ts @@ -0,0 +1,446 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createBundle, resolveEntries } from "../../src/bundle.ts"; +import { ConfigError } from "../../src/config.ts"; +import { LinearError, resolveLinearSource } from "../../src/linear.ts"; + +interface Call { + query: string; + variables: Record; + token?: string; +} + +const realFetch = globalThis.fetch; +const realToken = process.env.LINEAR_API_KEY; + +/** A full issue node — the query selects every field, so the stub must too. */ +function issue(identifier: string, overrides: Record = {}) { + return { + identifier, + title: `Title of ${identifier}`, + description: "Body text.", + priority: 2, + estimate: null, + dueDate: null, + url: `https://linear.app/acme/issue/${identifier}`, + createdAt: "2026-01-02T03:04:05.000Z", + updatedAt: "2026-02-03T04:05:06.000Z", + state: { name: "In Progress", type: "started" }, + labels: { nodes: [] }, + project: null, + projectMilestone: null, + parent: null, + assignee: null, + ...overrides, + }; +} + +/** + * Serve the two queries the module issues (scope, then issue pages) and record + * what was asked, so tests can assert on the filter Linear actually receives. + */ +function stubLinear(options: { + teams?: unknown[]; + projects?: { id: string }[]; + pages?: ReturnType[][]; + status?: number; + errors?: { message: string }[]; +}): Call[] { + const calls: Call[] = []; + const pages = options.pages ?? [[]]; + let issueRequests = 0; + + globalThis.fetch = (async (_url: unknown, init: RequestInit) => { + const call = JSON.parse(String(init.body)) as Call; + call.token = (init.headers as Record).authorization; + calls.push(call); + + if (options.status && options.status !== 200) { + return new Response("{}", { status: options.status }); + } + if (options.errors) { + return Response.json({ errors: options.errors }); + } + + if (call.query.includes("teams(")) { + const teams = options.teams ?? [ + options.projects ? { projects: { nodes: options.projects } } : {}, + ]; + return Response.json({ data: { teams: { nodes: teams } } }); + } + + // Serve the page the cursor asks for, the way Linear would. A variable the + // query never references is inert in GraphQL, so a query that drops + // `after: $cursor` keeps getting page one however it fills its variables — + // which is what makes the pagination test catch that mutation. + const usesCursor = call.query.includes("after: $cursor"); + const cursor = usesCursor ? (call.variables.cursor as string | null) : null; + const index = cursor ? Number(cursor.slice(1)) : 0; + + if (++issueRequests > pages.length) { + return Response.json({ + errors: [{ message: "pagination did not advance past the first page" }], + }); + } + return Response.json({ + data: { + issues: { + nodes: pages[index] ?? [], + pageInfo: { + hasNextPage: index < pages.length - 1, + endCursor: `c${index + 1}`, + }, + }, + }, + }); + }) as typeof fetch; + + return calls; +} + +beforeEach(() => { + process.env.LINEAR_API_KEY = "lin_api_test"; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + if (realToken === undefined) delete process.env.LINEAR_API_KEY; + else process.env.LINEAR_API_KEY = realToken; +}); + +describe("resolveLinearSource", () => { + test("maps issues to virtual entries under linear/issues/", async () => { + stubLinear({ pages: [[issue("ENG-1"), issue("ENG-2")]] }); + + const entries = await resolveLinearSource("ENG"); + + expect(entries.map((e) => e.path)).toEqual([ + "linear/issues.md", + "linear/issues/ENG-1.md", + "linear/issues/ENG-2.md", + ]); + expect(entries[1]!.content).toContain("# ENG-1 Title of ENG-1"); + expect(entries[1]!.content).toContain("State In Progress (started)"); + expect(entries[1]!.content).toContain("Priority High"); + expect(entries[1]!.content).toContain("Body text."); + }); + + test("leads with a roster ordered by issue number", async () => { + // The bundle index lists paths, and `linear/issues/ENG-10.md` says nothing + // about ENG-10 — the roster is what makes the set readable at a glance. + stubLinear({ + pages: [ + [ + issue("ENG-10"), + issue("ENG-2", { state: { name: "Backlog", type: "backlog" } }), + ], + ], + }); + + const [summary] = await resolveLinearSource("ENG"); + + expect(summary!.path).toBe("linear/issues.md"); + expect(summary!.content).toContain("# ENG — 2 issues"); + expect(summary!.content).toContain("In Progress 1 · Backlog 1"); + // Numeric, not the lexical order the index is stuck with + expect(summary!.content.indexOf("| ENG-2 |")).toBeLessThan( + summary!.content.indexOf("| ENG-10 |"), + ); + }); + + test("names the project in the roster when the source is scoped", async () => { + stubLinear({ pages: [[issue("ENG-1")]], projects: [{ id: "proj_123" }] }); + + const [summary] = await resolveLinearSource({ + team: "ENG", + project: "Roadmap", + }); + + expect(summary!.content).toContain("# ENG / Roadmap — 1 issue"); + }); + + test("escapes a pipe in a title so the roster table survives", async () => { + stubLinear({ pages: [[issue("ENG-1", { title: "Parse a | b" })]] }); + + const [summary] = await resolveLinearSource("ENG"); + + expect(summary!.content).toContain("Parse a \\| b"); + }); + + test("emits no roster when the team has no issues", async () => { + stubLinear({ pages: [[]] }); + + expect(await resolveLinearSource("ENG")).toEqual([]); + }); + + test("renders missing fields as em dashes, not undefined", async () => { + stubLinear({ pages: [[issue("ENG-1", { description: null })]] }); + + const [, entry] = await resolveLinearSource("ENG"); + + expect(entry!.content).toContain("Assignee —"); + expect(entry!.content).toContain("(no description)"); + expect(entry!.content).not.toContain("undefined"); + }); + + test("strips linear-embed tags from descriptions", async () => { + stubLinear({ + pages: [ + [ + issue("ENG-1", { + description: + 'Before junk\nmore after', + }), + ], + ], + }); + + const [, entry] = await resolveLinearSource("ENG"); + + expect(entry!.content).toContain("Before [embed] after"); + expect(entry!.content).not.toContain("linear-embed"); + }); + + test("follows pagination until the cursor runs out", async () => { + const calls = stubLinear({ + pages: [[issue("ENG-1")], [issue("ENG-2")], [issue("ENG-3")]], + }); + + const entries = await resolveLinearSource("ENG"); + + expect(entries).toHaveLength(4); // roster + one issue per page + // Counting entries alone would still pass if `after: $cursor` were dropped, + // so assert each page was requested with the cursor the previous one returned + expect( + calls + .filter((c) => c.query.includes("issues(")) + .map((c) => c.variables.cursor), + ).toEqual([null, "c1", "c2"]); + // A cursor walk is only stable while its sort key is: on the `updatedAt` + // default, an issue edited mid-run reorders the list under the cursor + expect(calls.at(-1)!.query).toContain("orderBy: createdAt"); + }); + + test("excludes every closed state type by default", async () => { + const calls = stubLinear({}); + + await resolveLinearSource("ENG"); + + const filter = calls.at(-1)!.variables.filter as Record; + // `duplicate` is its own state type, not a state named "Duplicate" under + // `canceled` — leaving it out leaks duplicates into an open-issues bundle + expect(filter).toEqual({ + team: { key: { eq: "ENG" } }, + state: { type: { nin: ["completed", "canceled", "duplicate"] } }, + }); + }); + + test("includeClosed drops the state filter", async () => { + const calls = stubLinear({}); + + await resolveLinearSource({ team: "ENG", includeClosed: true }); + + const filter = calls.at(-1)!.variables.filter as Record; + expect(filter).toEqual({ team: { key: { eq: "ENG" } } }); + }); + + test("filters by resolved project id, not by name", async () => { + const calls = stubLinear({ projects: [{ id: "proj_123" }] }); + + await resolveLinearSource({ team: "ENG", project: "Roadmap" }); + + expect(calls[0]!.variables).toEqual({ team: "ENG", project: "Roadmap" }); + const filter = calls.at(-1)!.variables.filter as Record; + expect(filter).toMatchObject({ project: { id: { eq: "proj_123" } } }); + }); + + test("rejects an unknown team instead of returning an empty bundle", async () => { + stubLinear({ teams: [] }); + + await expect(resolveLinearSource("NOPE")).rejects.toThrow( + /Team "NOPE" not found/, + ); + }); + + test("rejects an unknown project", async () => { + stubLinear({ projects: [] }); + + await expect( + resolveLinearSource({ team: "ENG", project: "Ghost" }), + ).rejects.toThrow(/Project "Ghost" not found in team "ENG"/); + }); + + test("rejects an ambiguous project name", async () => { + stubLinear({ projects: [{ id: "a" }, { id: "b" }] }); + + await expect( + resolveLinearSource({ team: "ENG", project: "Roadmap" }), + ).rejects.toThrow(/ambiguous/); + }); + + test("reports a missing API key without calling the network", async () => { + const calls = stubLinear({}); + + for (const value of [undefined, " "]) { + if (value === undefined) delete process.env.LINEAR_API_KEY; + else process.env.LINEAR_API_KEY = value; + + await expect(resolveLinearSource("ENG")).rejects.toThrow( + /LINEAR_API_KEY is not set/, + ); + } + expect(calls).toHaveLength(0); + }); + + test("trims the API key", async () => { + // A key read from a file or piped through shell tooling carries a newline, + // which Linear answers with a bare "not authorized" + process.env.LINEAR_API_KEY = " lin_api_test\n"; + const calls = stubLinear({}); + + await resolveLinearSource("ENG"); + + expect(calls[0]!.token).toBe("lin_api_test"); + }); + + test("reports a rejected API key", async () => { + stubLinear({ status: 401 }); + + await expect(resolveLinearSource("ENG")).rejects.toThrow(/not authorized/); + }); + + test("surfaces GraphQL errors returned with status 200", async () => { + stubLinear({ errors: [{ message: "Query too complex" }] }); + + await expect(resolveLinearSource("ENG")).rejects.toThrow( + /Query too complex/, + ); + }); + + test("reports a timeout as advice, not as an AbortError", async () => { + globalThis.fetch = (() => { + // What AbortSignal.timeout() produces once the deadline passes + const error = new Error("The operation was aborted due to timeout"); + error.name = "TimeoutError"; + return Promise.reject(error); + }) as unknown as typeof fetch; + + const failure = resolveLinearSource("ENG"); + await expect(failure).rejects.toThrow(LinearError); + await expect(failure).rejects.toThrow(/timed out after 30s/); + await expect(failure).rejects.not.toThrow(/abort/i); + }); + + test("reports an unreachable API", async () => { + globalThis.fetch = (() => { + throw new TypeError("getaddrinfo ENOTFOUND api.linear.app"); + }) as unknown as typeof fetch; + + await expect(resolveLinearSource("ENG")).rejects.toThrow(LinearError); + }); +}); + +describe("resolveEntries with a linear source", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "srcpack-linear-")); + await mkdir(join(dir, "docs"), { recursive: true }); + await writeFile(join(dir, "docs/readme.md"), "# Readme\n"); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("merges files and issues into one sorted list", async () => { + stubLinear({ pages: [[issue("ENG-1")]] }); + + const entries = await resolveEntries( + { include: "docs/**", linear: "ENG" }, + dir, + ); + + expect(entries.map((e) => e.path)).toEqual([ + "docs/readme.md", + "linear/issues.md", + "linear/issues/ENG-1.md", + ]); + // Files stay lazy; only the virtual entries carry content + expect(entries[0]!.content).toBeUndefined(); + expect(entries[2]!.content).toContain("# ENG-1"); + }); + + test("applies ! exclusions to issues as well as files", async () => { + stubLinear({ pages: [[issue("ENG-1"), issue("ENG-2")]] }); + + const entries = await resolveEntries( + { include: ["docs/**", "!linear/issues/ENG-1.md"], linear: "ENG" }, + dir, + ); + + expect(entries.map((e) => e.path)).toEqual([ + "docs/readme.md", + "linear/issues.md", + "linear/issues/ENG-2.md", + ]); + }); + + test("bundles a linear-only bundle without touching the filesystem", async () => { + stubLinear({ pages: [[issue("ENG-1")]] }); + + const entries = await resolveEntries({ linear: "ENG" }, dir); + + expect(entries.map((e) => e.path)).toEqual([ + "linear/issues.md", + "linear/issues/ENG-1.md", + ]); + }); + + test("errors when a real file collides with an issue path", async () => { + stubLinear({ pages: [[issue("ENG-1")]] }); + await mkdir(join(dir, "linear/issues"), { recursive: true }); + await writeFile(join(dir, "linear/issues/ENG-1.md"), "not the issue\n"); + + await expect( + resolveEntries({ include: "**/*.md", linear: "ENG" }, dir), + ).rejects.toThrow(ConfigError); + }); + + test("catches a collision reached through an absolute pattern", async () => { + stubLinear({ pages: [[issue("ENG-1")]] }); + await mkdir(join(dir, "linear/issues"), { recursive: true }); + await writeFile(join(dir, "linear/issues/ENG-1.md"), "not the issue\n"); + + // The same file, spelled absolutely — comparing path strings would miss it + await expect( + resolveEntries( + { include: join(dir, "linear/issues/*.md"), linear: "ENG" }, + dir, + ), + ).rejects.toThrow(/collides with the file/); + }); + + test("keeps an empty issue description from falling back to disk", async () => { + // `??` not `||`: an empty virtual entry must not be read from the filesystem + stubLinear({ pages: [[issue("ENG-1")]] }); + const [, entry] = await resolveEntries({ linear: "ENG" }, dir); + const empty = { path: entry!.path, content: "" }; + + const bundle = await createBundle([empty], dir); + + expect(bundle.index[0]!.lines).toBe(0); + expect(bundle.content).toContain("#==> [1] linear/issues/ENG-1.md <=="); + }); + + test("makes no network call when no linear source is declared", async () => { + const calls = stubLinear({}); + + const entries = await resolveEntries({ include: "docs/**" }, dir); + + expect(entries.map((e) => e.path)).toEqual(["docs/readme.md"]); + expect(calls).toHaveLength(0); + }); +});