Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
32cefa1
refactor(archive): extract tarballs with Bun.Archive instead of spawn…
rhuanbarreto Aug 20, 2026
885ced4
perf(test): run the suite in parallel, and pin coverage to one process
rhuanbarreto Aug 20, 2026
b4f7b6d
feat(output): measure table columns and render ADRs with Bun 1.4 text…
rhuanbarreto Aug 20, 2026
04fad64
perf(session-context): stop inflating whole codex rollouts to read th…
rhuanbarreto Aug 20, 2026
760a355
style(markdown-terminal): write the sentinels as escapes, not literal…
rhuanbarreto Aug 20, 2026
245ad7d
chore(types): upgrade @types/bun to 1.4.0 and correct the codex comment
rhuanbarreto Aug 20, 2026
3923ad7
fix(typecheck): invalidate the build cache when the dependency tree m…
rhuanbarreto Aug 20, 2026
1b2dcfe
docs(memory): record that proto resolves bun's version from the cwd
rhuanbarreto Aug 20, 2026
bf21e81
test(archive): point the symlink fixture at a target Bun.Archive will…
rhuanbarreto Aug 20, 2026
89a11cb
fix(ci): stop the Windows job appending --coverage to the parallel sc…
rhuanbarreto Aug 20, 2026
1285540
fix: address review findings on the Bun 1.4 sweep
rhuanbarreto Aug 21, 2026
883bcec
fix(plugin-install): stop a downloaded bundle supplying cursor hooks.…
rhuanbarreto Aug 21, 2026
ebbb5d4
fix(plugin-install): replace only the hooks this installer generated
rhuanbarreto Aug 21, 2026
2527072
test: prove the head-read bound instead of asserting around it
rhuanbarreto Aug 21, 2026
ebcd58f
test(codex): drop the hand-written meta type and use Bun.write for th…
rhuanbarreto Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .archgate/adrs/ARCH-005-testing-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ id: ARCH-005
title: Testing Standards
domain: architecture
rules: true
files: ["tests/**/*.ts"]
files: ["tests/**/*.ts", "package.json", ".github/workflows/*.yml"]
---

## Context
Expand All @@ -29,7 +29,7 @@ Use Bun's built-in test runner (`bun test`) for all tests. Test files live in `t
3. **Temp directories for filesystem tests** — tests that write files use `mkdtemp` for isolation and clean up in `afterEach` or `afterAll`.
4. **Test file naming** — `<module-name>.test.ts`.
5. **Coverage target: 99.9%** — enforced in CI. PRs that drop total line coverage below 99.9% are blocked by the `Validate Code` gate check. A residue is unreachable: Bun emits never-incrementing lcov records for some structural tokens (`} catch {`; on Linux also blank lines, comments, braces).
6. **Isolation is the test author's job** — Bun runs every test file in one process, so environment writes, `mock.module()` calls, and un-restored spies escape into later files and produce order-dependent flakes. Restore env vars with `restoreEnv()`, mock first-party modules with `spyOn` over an `import * as mod` namespace, and keep every write inside a `mkdtemp` directory.
6. **Isolation is the test author's job** — `test:coverage`, the run CI gates on, executes every file in one process, so environment writes, `mock.module()` calls, and un-restored spies escape into later files. Restore env vars with `restoreEnv()`, mock first-party modules with `spyOn` over an `import * as mod` namespace, and keep every write inside a `mkdtemp` directory.
7. **Mock `os.homedir()`, never `HOME`** — Bun caches `os.homedir()` on Linux, so a runtime `HOME` override is silently ignored and the code under test resolves the REAL home directory. Env-var overrides remain valid ONLY for code that reads `Bun.env.*` at call time (`vscode-settings.ts`'s `APPDATA` branch, the `paths.ts` helpers documented as "resolved at call time"). Production code MUST NOT be rewritten to read `Bun.env.HOME` just to make an env override work.
8. **Per-test timeouts only ever raise the global** — `bun run test` applies `--timeout 60000`, so a shorter override such as `}, 30_000` makes that test _more_ likely to time out, not less.

Expand Down Expand Up @@ -203,14 +203,16 @@ describe.each([

- **Fewer features than Jest/Vitest** — coverage reporting is limited to the `--coverage` flag and mock utilities are sparse next to Jest's, though snapshot testing (`toMatchSnapshot`, `toMatchInlineSnapshot`) is supported.
- **Limited community resources** — fewer Stack Overflow answers and tutorials; contributors consult Bun documentation directly.
- **One shared process** — `mock.module()` is process-global and retroactive (it patches a module for every importer, including ones that ran before the call) and not undone by `mock.restore()`, so other test files intermittently receive the mock instead of the real implementation; env writes escape into later test files the same way. Isolation is the test author's responsibility rather than the runner's — never dodge this by splitting production code into an `-impl` file, since that only changes what gets mocked, not whether the leak happens.
- **One shared process, on the run that gates** — `mock.module()` is process-global and retroactive (it patches a module for every importer, including ones that ran before the call) and not undone by `mock.restore()`, so other test files intermittently receive the mock instead of the real implementation; env writes escape into later test files the same way. Bun 1.4's `--isolate` ends this for `bun run test`, but not for `test:coverage`, which must stay single-process to keep the coverage denominator comparable (Decision #9). The leak class therefore still reaches CI, and isolation stays the test author's responsibility — never dodge it by splitting production code into an `-impl` file, since that only changes what gets mocked, not whether the leak happens.

### Risks

- **Bun test runner API changes** — newer APIs may still evolve between minor versions.
- **Mitigation:** the project pins a Bun version via `.prototools`; API changes surface during controlled upgrades with full suite validation.
- **Coverage reporting gaps** — `bun test --coverage` may misreport code paths, especially dynamically imported modules.
- **Mitigation:** the 99.9% threshold is enforced on total line coverage, not per-file, and critical modules (engine, formats) are tested thoroughly regardless of the aggregate.
- **Coverage denominator scales with process count** — Bun merges per-process lcov correctly for lines _hit_, but each process lazily compiles a different subset of function bodies, and the merge unions the lines each reports as _found_. More processes therefore means a larger denominator over the same executed code. Measured on this suite: 1 process 97.2%, 3 (`--shard=i/3`) 91.3%, 16 (`--parallel`) 85.8% — with hits constant at ~11255 throughout. Multi-process coverage would fail the gate while covering exactly as much.
- **Mitigation:** Decision #9 keeps `test:coverage` single-process. `bun run test` takes `--parallel` freely, since it reports no coverage.
- **Cross-file pollution from shared process state** — leaked env vars, leaked spies, and writes to real user-scope paths (`~/.config/Code/User/settings.json`, `%APPDATA%`, `~/.cursor/`, `~/.config/opencode/`) produce order-dependent flakes that pass on a PR run and fail after merge with identical code. `Bun.env.NODE_ENV` left unset instead of set to `"test"` before Sentry initializes is the same class of leak — the SDK sets `enabled: Bun.env.NODE_ENV !== "test"`.
- **Mitigation:** `restoreEnv()` for every env capture, `try/finally` around inline spies, and an `os.homedir()` spy that keeps writes inside a `mkdtemp` directory; `test-isolation/no-bare-env-restore` blocks the env variant at lint time.
- **Platform-specific hangs and timeouts** — an external SDK instance left open keeps Bun's event loop alive on Linux and hangs `bun test` after every test passes, while slow Windows CI filesystems let large fixtures blow the per-test timeout and kill the staging subprocess (`git add . failed (exit 143)`, where 143 = 128 + SIGTERM). Neither reproduces on macOS or locally.
Expand Down
90 changes: 90 additions & 0 deletions .archgate/adrs/ARCH-005-testing-standards.rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,98 @@ function dirnameOf(path: string): string {
return idx === -1 ? "." : path.slice(0, idx);
}

/** Flags that split a `bun test` run across more than one process. */
const MULTIPROCESS_FLAGS = ["--parallel", "--shard"];

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Whether `command` passes `flag` as its own token. A substring test would
* read `--parallelism` as `--parallel`; the flag ends at whitespace, `=`, or
* end of string, and `--shard=1/3` must still count.
*/
function hasFlag(command: string, flag: string): boolean {
return new RegExp(String.raw`(?:^|\s)${flag}(?=$|[\s=])`, "u").test(command);
}

export default {
rules: {
/**
* Splitting a coverage run across processes inflates the lines-found
* denominator without changing lines hit, so it fails the 99.9% gate while
* covering identical code. See ARCH-005 Consequences → Risks.
*/
"coverage-runs-single-process": {
description:
"test:coverage must not split across processes — it shifts the coverage denominator",
severity: "error",
async check(ctx) {
// ctx.readFile rejects on a missing file rather than returning null,
// so absence and malformed JSON share one guard.
let scripts: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(
await ctx.readFile("package.json")
);
if (!isRecord(parsed) || !isRecord(parsed.scripts)) return;
scripts = parsed.scripts;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch {
return;
}

/** Script names whose command splits the run across processes. */
const multiprocess = new Set<string>();

for (const [name, command] of Object.entries(scripts)) {
if (typeof command !== "string") continue;
const flag = MULTIPROCESS_FLAGS.find((f) => hasFlag(command, f));
if (flag === undefined) continue;
multiprocess.add(name);

if (!hasFlag(command, "--coverage")) continue;
ctx.report.violation({
message: `Script "${name}" combines ${flag} with --coverage; multi-process runs inflate the lines-found denominator and fail the 99.9% gate`,
file: "package.json",
fix: `Drop ${flag} from "${name}". Use it on a script that reports no coverage, such as "test".`,
});
}

if (multiprocess.size === 0) return;

// `bun run test --coverage` reassembles the combination at the call
// site, where the scan above cannot see it. Longest name first so
// `test:changed` wins over `test`, each name escaped so `test.ci`
// cannot match `testXci`, and the name ends at whitespace because `\b`
// stops at the `:` inside one.
const names = [...multiprocess]
.sort((a, b) => b.length - a.length)
.map((name) =>
name.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)
);
const callSite = new RegExp(
String.raw`bun run (${names.join("|")})(?=\s)[^\n]*--coverage`,
"u"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);

const workflows = [
...(await ctx.glob(".github/workflows/*.yml")),
...(await ctx.glob(".github/workflows/*.yaml")),
];
for (const file of workflows) {
// oxlint-disable-next-line no-await-in-loop -- reads are cached per path
const text = await ctx.readFile(file);
const match = callSite.exec(text);
if (match === null) continue;
ctx.report.violation({
message: `"${match[0].trim()}" adds --coverage to "${match[1]}", which runs multi-process; the inflated denominator fails the 99.9% gate`,
file,
fix: `Call the single-process script instead, e.g. \`bun run test:coverage\` (GEN-003 — invoke by script name rather than appending flags).`,
});
}
},
},
"test-mirrors-src": {
description: "Test directory structure should mirror src/ structure",
severity: "error",
Expand Down
1 change: 1 addition & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Exceptions: minor follow-up tweaks after validation already passed, and non-code
- **`docs/public/llms-full.txt` is auto-regenerated by the `update-llms.yaml` PR workflow whenever `docs/src/content/docs/**` changes** — a bot commit lands on the branch shortly after pushing docs edits. Never hand-edit it; `git pull` before continuing, and when CodeRabbit flags stale wording inside it, fix the source `.mdx`.
- **CodeRabbit and oxlint-tsgolint give contradictory orders on Bun's `expect(...).rejects.toThrow(...)`** — CodeRabbit flags the unawaited form as a race; the repo's type-aware lint hard-rejects the `await` (`await-thenable` + `no-confusing-void-expression`, via bun-types declaring it `void`). The lint gate wins: keep the unawaited form (the convention #534 set repo-wide), decline the finding on-thread citing the conflict, and don't re-litigate per PR.
- **Content filtering blocks policy/legal boilerplate** — generating a Contributor Covenant or license text can trip API filtering. Ask the user to copy it from the official source.
- **`bun` outside the repo is a DIFFERENT bun — proto resolves the version from the working directory.** A probe run from a scratchpad or `/tmp` finds no `.prototools`, falls back to proto's global pin, and silently answers for that version instead of the repo's. This reported half of Bun 1.4's APIs as missing from a 1.4.0 install: the probes ran on 1.3.9, and because `Bun.Archive` shipped in 1.3.6 the results looked like a partial 1.4 rather than the wrong runtime. `bun --version` from the repo said 1.4.0 the whole time. Run capability probes with `cd` into the repo, or invoke `~/.proto/tools/bun/<version>/bun.exe` by absolute path, and print `bun --revision` inside the probe itself rather than checking it separately. The same applies to any proto-managed tool (node, npm, gh).
- **Files written under `/tmp` by this agent's own Bash/Write calls can vanish between tool calls.** Write anything that must survive several calls to a real Windows path (e.g. `C:/Users/<user>/AppData/Local/Temp/<task-name>/`); Bun/Node on Windows don't resolve Git-Bash-style `/c/Users/...` paths.

## Topic files
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/code-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jobs:
echo "::error::docs/public/llms-full.txt is out of date. Run 'bun run docs/scripts/generate-llms-full.ts' and commit the result."
exit 1
}
- name: Audit dependencies
# Not part of `bun run validate`: that runs in the pre-push hook, which
# must work offline. CI always has network, so the advisory check lives
# here (ARCH-006).
run: bun run audit
- name: Validate
id: validate
run: bun run validate:coverage
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/smoke-test-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ jobs:

- name: Tests
id: tests
run: bun run test --coverage
# `test:coverage`, not `test --coverage`: `test` carries `--parallel`,
# and a multi-process run inflates the lines-found denominator that the
# merged 99.9% gate is calibrated against (ARCH-005).
run: bun run test:coverage
- name: Upload coverage
if: always() && steps.tests.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,6 @@ shims/pypi/**/__pycache__/
shims/pypi/.pytest_cache/

# Invalid HOME/USERPROFILE (e.g. literal "undefined") can create this under cwd — never commit
/undefined/
/undefined/
# CPU/heap profiles from `bun run profile:*`
/.profiles/
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
],
"type": "module",
"scripts": {
"audit": "bun audit",
"build:check": "bun build src/cli.ts --compile --bytecode --outfile dist/.build-check && rm -f dist/.build-check dist/.build-check.exe",
"build:winget": "bun run shims/winget/build.ts",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
Expand All @@ -52,8 +53,12 @@
"hook:session-start": "bun run scripts/session-start.ts",
"hook:worktree-create": "bun run scripts/worktree-create.ts",
"knip": "knip",
"licenses": "bun pm licenses",
"lint": "bun run scripts/ensure-rules-dts.ts && oxlint --deny-warnings .",
"test": "bun test --timeout 60000",
"profile:cpu": "bun --cpu-prof-md --cpu-prof-dir=.profiles src/cli.ts",
"profile:heap": "bun --heap-prof-md --heap-prof-dir=.profiles src/cli.ts",
"test": "bun test --timeout 60000 --parallel",
"test:changed": "bun test --timeout 60000 --parallel --changed",
"test:coverage": "bun test --timeout 60000 --coverage",
"test:watch": "bun test --watch --timeout 60000",
"typecheck": "bun run scripts/ensure-rules-dts.ts && tsc --build",
Expand All @@ -66,7 +71,7 @@
"@commitlint/config-conventional": "21.2.0",
"@sentry/node-core": "10.67.0",
"@simple-release/npm": "3.2.5",
"@types/bun": "1.3.14",
"@types/bun": "1.4.0",
"conventional-changelog": "8.1.0",
"conventional-changelog-angular": "9.2.1",
"czg": "1.13.1",
Expand Down
51 changes: 46 additions & 5 deletions scripts/ensure-rules-dts.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Archgate
/**
* Regenerates the gitignored `.archgate/rules.d.ts` ambient types shim
* before linting, so a fresh checkout's `ctx.*` accesses in
* `.archgate/adrs/*.rules.ts` type-check instead of resolving to `any`
* (the shim otherwise only gets written by `archgate check`, which
* `bun run validate` runs after `lint`).
* Prepares the inputs `tsc --build` reads, so a typecheck reflects the tree it
* runs against. Two stale-input hazards, both silent: `.archgate/rules.d.ts`
* is gitignored and written only by `archgate check` (which `validate` runs
* after `lint`), and `.tsbuildinfo` is keyed on sources and tsconfig, never on
* installed `@types/*`.
*/

import { join } from "node:path";

import { z } from "zod";

import { ensureRulesShim } from "../src/helpers/rules-shim";

const projectRoot = join(import.meta.dir, "..");

/** Only the field this script reads; the rest of tsconfig passes through. */
const TsconfigSchema = z.object({
compilerOptions: z.object({ outDir: z.string().optional() }).optional(),
});

/**
* Drop the incremental build cache when the dependency tree moved under it.
* `bun.lock` is the signal: it changes on every install, the only way
* `@types/*` can change. The cost is one full rebuild per install; the cache
* can otherwise withhold a real error as readily as report a fixed one.
*/
async function invalidateStaleBuildInfo(): Promise<void> {
const lock = Bun.file(join(projectRoot, "bun.lock"));
if (!(await lock.exists())) return;

// Read outDir rather than hardcoding it, so moving it in tsconfig cannot
// silently reinstate the stale cache. tsconfig.json permits comments.
const tsconfig = Bun.file(join(projectRoot, "tsconfig.json"));
if (!(await tsconfig.exists())) return;

let outDir: string;
try {
const parsed = TsconfigSchema.safeParse(
Bun.JSONC.parse(await tsconfig.text())
);
outDir = parsed.success
? (parsed.data.compilerOptions?.outDir ?? ".")
: ".";
} catch {
return;
}

const buildInfo = Bun.file(join(projectRoot, outDir, "tsconfig.tsbuildinfo"));
if (!(await buildInfo.exists())) return;

if (lock.lastModified > buildInfo.lastModified) await buildInfo.delete();
}

await invalidateStaleBuildInfo();
await ensureRulesShim(projectRoot);
Loading
Loading