Skip to content

feat: bundle Linear issues, harden path boundaries - #10

Merged
koistya merged 4 commits into
mainfrom
feat/linear-and-path-hardening
Aug 15, 2026
Merged

feat: bundle Linear issues, harden path boundaries#10
koistya merged 4 commits into
mainfrom
feat/linear-and-path-hardening

Conversation

@koistya

@koistya koistya commented Aug 15, 2026

Copy link
Copy Markdown
Member

Adds Linear as a bundle source, and hardens the path handling around it after a pre-release audit turned up five ways a bundling run could read or delete the wrong thing.

Linear issues as virtual files

A bundle can now name linear alongside (or instead of) globs. Each issue becomes a virtual file, so it gets an index entry, a line range, and can be filtered with an ordinary ! exclusion — nothing downstream needs to know it wasn't read off disk.

bundles: {
  planning: { include: ["docs/**"], linear: { team: "ENG", project: "Roadmap" } },
  backlog: { linear: "ENG" },
}

Each source also emits linear/issues.md ahead of the issues: scope heading, a count per workflow state, and a row per issue ordered by number. Without it the index is forty lines of opaque identifiers, and issue numbers sort as text (SM-2 between SM-19 and SM-20). Drop it with !linear/issues.md.

team is required: a workspace-wide fetch looks innocuous and can pull thousands of issues into a context window. Auth reads LINEAR_API_KEY from the environment rather than config, because config files get committed. Pagination orders by createdAt — the updatedAt default is mutable, so an issue edited mid-run reorders the list under the cursor and drops or repeats its neighbours.

Path boundaries

Five defects, one cause: paths were compared as the strings that name them, and filters were treated as best-effort. That's fine for deciding what goes into a bundle and wrong for deciding what gets deleted — and .gitignore had quietly become the second kind, because the docs promise it keeps secrets out.

Defect Fix
outDir: "src" deleted the sources it was asked to bundle Automatic emptying only for the conventional .srcpack
A symlinked .srcpack emptied and wrote into its target Ownership decided on the physical path; a redirected .srcpack fails the run
A symlinked directory was walked and everything under it bundled Globs no longer follow symlinks at any level
Nested .gitignore files were never read Resolved per directory, deepest rule first, no re-inclusion under an ignored directory
--no-uplaod uploaded Unknown flags, unknown config keys, and upload.exclude entries naming no bundle are errors

Two related holes: two bundles could resolve to one physical file through a symlink or a case/normalisation alias, and a bundle could swallow its own previous output when reached by a different spelling.

Comparison identity is now NFC-normalised and case folded on every platform — a filesystem that folds either one turns two bundles into a silent overwrite, and a config that works in CI but loses a bundle on a laptop is worse than one rejected everywhere. Ownership deliberately stays an exact match, since folding there could only widen what gets deleted.

Writes go through a temp file and rename, so a symlink sitting at the output path is replaced rather than written through, and each bundle appears whole or not at all.

Full rationale in ADR 003 and ADR 004, both added here.

Breaking change

A custom outDir is no longer emptied automatically — set emptyOutDir: true to keep the old behaviour. Stale files are a nuisance; deleted sources are not, and 0.x is the time to take that break. Version bumped to 0.3.0.

Verification

Each defect was reproduced against the built CLI before being fixed, and every fix is mutation-tested: reverting it alone fails its own regression test. That covers symlink following, nested .gitignore, outDir ownership, lexical vs physical output identity, NFC and case folding, unknown-flag rejection, collision detection, and temp-file writes.

Ran bun test, bun run check, prettier --check, and the VitePress build. Layered .gitignore resolution was cross-checked against git check-ignore, and orderBy: createdAt against the live Linear API.

Bundles could only ever describe code, but the reason a change exists
usually lives in a tracker. A bundle may now name `linear`, and each issue
becomes a virtual file at `linear/issues/<identifier>.md` — indexed, line
counted and excludable like any other entry, so nothing downstream needs to
know it wasn't read off disk. Auth is `LINEAR_API_KEY` from the environment
rather than config, because config files get committed.

A pre-release audit of the surrounding path handling turned up five defects
that shared one cause: paths were compared as the strings that name them,
and filters were treated as best-effort. That is fine for deciding what goes
into a bundle and wrong for deciding what gets deleted.

- `outDir: "src"` deleted the sources it was asked to bundle. Emptying is
  now automatic only for the conventional `.srcpack`.
- A symlinked `.srcpack` emptied and wrote to its target. Ownership is
  decided on the physical path, and a redirected `.srcpack` fails the run.
- A symlinked directory was walked and everything under it bundled.
- Nested `.gitignore` files were never read, so `packages/app/.env` could
  land in a bundle the docs promise keeps secrets out.
- `--no-uplaod` uploaded. Unknown flags and config keys are now errors.

Comparison identity is NFC-normalised and case folded, since a filesystem
that folds either one turns two bundles into one silent overwrite. Ownership
stays an exact match — folding there could only widen what gets deleted.

Bundles are written via temp file and rename, so a symlink at the output
path is replaced rather than written through.

Each defect was reproduced first, and every fix has a regression test that
fails when the fix is reverted.

BREAKING CHANGE: a custom `outDir` is no longer emptied automatically; set
`emptyOutDir: true` to keep the old behaviour.
Every index and dry-run sample was hand-aligned into columns the formatter
never produces, and two showed line ranges starting at L1 — impossible, since
content begins below the index header. Replaced with output captured from
real runs, which also fixes the entry order: entries sort by path, so the
files were listed in an order srcpack would never emit.

Adds the -h/-v short forms to the CLI options table.
@koistya koistya changed the title feat: add Linear issues as a bundle source, harden path boundaries feat: bundle Linear issues, harden path boundaries Aug 15, 2026
npm version and downloads answer "is this maintained and used" before
someone reads any further; CI and license answer the next two questions.
Discord points at the community link the README already carries at the
bottom, where nobody arriving from npm will see it.
The bundle index lists paths, and for a code file the path is the summary. For an issue it isn't: forty lines of `linear/issues/SM-*.md` tell a model nothing, so it has to read every body to find the two that matter, and can't answer "what's in progress" at all.

Each Linear source now emits `linear/issues.md` ahead of the issues — scope heading, a count per workflow state, and one row per issue with state, priority and title. It sorts first because "." precedes "/", and it's an ordinary entry, so the collision check and `!` exclusions apply unchanged.

Rows are ordered by issue number, which the index cannot be: entries sort by path as text, so SM-2 lands between SM-19 and SM-20. Natural-sorting every bundle to fix that would be a far larger claim than this needs.
@koistya
koistya force-pushed the feat/linear-and-path-hardening branch from fb45172 to 4bec8a5 Compare August 15, 2026 18:06
@koistya
koistya merged commit 2dfc9f5 into main Aug 15, 2026
1 check passed
@koistya
koistya deleted the feat/linear-and-path-hardening branch August 15, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant