Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 23 additions & 13 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- uses: actions/setup-go@v5
with:
go-version-file: ui/go.mod
cache-dependency-path: ui/go.sum

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down Expand Up @@ -76,13 +72,7 @@ jobs:
- name: Workflow lint
run: |
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 "$RUNNER_TEMP"
"$RUNNER_TEMP/actionlint" .github/workflows/bundle-apps.yml .github/workflows/checks.yml .github/workflows/e2e.yml .github/workflows/purity.yml .github/workflows/renovate.yml

# The Go helper's own vet + tests, plus the shared protocol fixtures
# both languages golden-test (lib/ui/__tests__/protocol.test.ts runs
# in the unit shards).
- name: rt-ui vet and tests
run: bun run ui:test
"$RUNNER_TEMP/actionlint" .github/workflows/bundle-apps.yml .github/workflows/checks.yml .github/workflows/e2e.yml .github/workflows/purity.yml .github/workflows/renovate.yml .github/workflows/timings.yml

# Drift here is invisible until someone runs it by hand, which is how
# two commands reached main with no reference page.
Expand All @@ -95,6 +85,24 @@ jobs:
- name: Picker convention is enforced
run: bun run picker:check

# The Go helper's own vet + tests, plus the shared protocol fixtures both
# languages golden-test (lib/ui/__tests__/protocol.test.ts runs in the unit
# shards). Its own job: it is a minute of build time nothing else waits on.
go:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- uses: actions/setup-go@v5
with:
go-version-file: ui/go.mod
cache-dependency-path: ui/go.sum
- name: rt-ui vet and tests
run: bun run ui:test

# macos-latest, matching e2e.yml: much of the tree is macOS-specific (launchd
# labels, TCC, codesign, ~/Library paths) and the bunfig preload that isolates
# HOME for tests is exercised the same way developers run it locally.
Expand Down Expand Up @@ -152,7 +160,7 @@ jobs:
# would skip unit and this job too, and a skipped required check reads as
# passing on GitHub.
checks:
needs: [scope, static, unit]
needs: [scope, static, go, unit]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
Expand All @@ -161,12 +169,14 @@ jobs:
env:
SCOPE: ${{ needs.scope.result }}
STATIC: ${{ needs.static.result }}
GO: ${{ needs.go.result }}
UNIT: ${{ needs.unit.result }}
MODE: ${{ needs.scope.outputs.mode }}
run: |
echo "scope=$SCOPE static=$STATIC unit=$UNIT mode=$MODE"
echo "scope=$SCOPE static=$STATIC go=$GO unit=$UNIT mode=$MODE"
[ "$SCOPE" = success ] || { echo "scope did not succeed"; exit 1; }
[ "$STATIC" = success ] || { echo "static did not succeed"; exit 1; }
[ "$GO" = success ] || { echo "go did not succeed"; exit 1; }
[ "$UNIT" = success ] && exit 0
[ "$UNIT" = skipped ] && [ "$MODE" = skip ] && exit 0
echo "unit was $UNIT with mode $MODE"
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/timings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Timings

# Regenerates test-timings.json on the same runner the shards use; a file
# measured on a laptop balances the shards for the laptop, not for CI.
# Download the artifact, commit it at the repo root.
on:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/timings.yml

jobs:
timings:
runs-on: macos-latest
timeout-minutes: 20
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- name: Install backup dependencies
run: brew install age zstd git-lfs
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Time the unit suite
run: bun run test:timings
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-timings
path: test-timings.json
if-no-files-found: error
27 changes: 16 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,17 +377,22 @@ timeout). `test:all` runs all three. A green local `bun run test` says nothing
about either of the others, and the difference is invisible in the output.

CI runs the unit suite as three macOS shards (`bun test --shard=i/3
--timings=test-timings.json`, balanced by the committed timings file;
`bun run test:timings` refreshes it) and runs the non-Mac gates on ubuntu.
`scripts/ci/test-scope.ts` decides a PR's scope from its diff: only docs or
Swift files that no unit test reads skips the shards; a TypeScript-only diff
runs `--changed=HEAD^1` plus the `lib/__tests__/no-*.test.ts` guards; any
other change runs the full suite (a non-TypeScript file outside that skip
set, a fixture, the preload or its imports, anything under `scripts/ci/`).
Any other test that spawns `cli.ts` or reads source as text is not selected
by `--changed`; it runs on main, so a TypeScript-only PR can go green and
break main there. Refresh the timings file with `bun run test:timings` when
the shards' printed wall times drift more than a minute apart.
--timings=test-timings.json`, balanced by the committed timings file,
which the Timings workflow regenerates) and runs the non-Mac gates on
ubuntu. `scripts/ci/test-scope.ts` decides a PR's scope from its diff: only
docs or Swift files that no unit test reads skips the shards; a
TypeScript-only diff runs `--changed=HEAD^1` plus every `no-*.test.ts`
guard in the unit directories; any other change runs the full suite (a
non-TypeScript file outside that skip set, a fixture, the preload or its
imports, anything under `scripts/ci/`). Any other test that spawns
`cli.ts` or reads source as text is not selected by `--changed`, so it
must be named `no-*` to run on a PR at all; an un-prefixed one only runs
on main, so a TypeScript-only PR can go green and break main there. When
the shards' printed wall times drift more than a minute apart, refresh
the timings file by running the Timings workflow
(`gh workflow run timings.yml`) and committing its artifact;
`bun run test:timings` on a laptop produces a laptop-balanced file,
which is not the same thing.

It matters most for anything asserted verbatim end to end (the chat delivery
frame, a CLI's `--json` envelope, a usage string) and for anything glitter or
Expand Down
2 changes: 1 addition & 1 deletion lib/__tests__/flavor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("captureProcessFlavor", () => {
expect(seen.flavor).toBe("dev");
expect(seen.passed).toBe("");
// Bun hands an env-less child the environment the parent started with,
// which no delete can reach: why lib/__tests__/spawn-env.test.ts
// which no delete can reach: why lib/__tests__/no-spawn-without-env.test.ts
// requires env on every call.
expect(seen.plain).toBe("dev");
} finally {
Expand Down
92 changes: 92 additions & 0 deletions lib/__tests__/no-hand-built-repo-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Source-guard tests: everything here reads source as text, so --changed
* cannot select it, and the no-* prefix is what puts it in the always-run
* set.
*/

import { test, expect } from "bun:test";
import { readFileSync, readdirSync, statSync } from "fs";
import { join } from "path";

test("log writers resolve the log dir at call time, not at module load", () => {
// Source-guard: a writer that binds its log dir to a module-level const is
// invisible until it silently pollutes the real tree, so pin it here.
for (const file of ["../daemon-logger.ts", "../cli-logger.ts"]) {
const src = readFileSync(join(import.meta.dir, file), "utf8");
expect(src).toContain("logsDir()");
expect(src).not.toMatch(/^const LOG_DIR\s*=/m);
}
});

/** Walk .ts sources (skipping tests, node_modules, dist) under a root. */
const walkSources = (root: string, visit: (file: string, src: string) => void) => {
const st = statSync(root);
if (!st.isDirectory()) {
visit(root, readFileSync(root, "utf8"));
return;
}
for (const entry of readdirSync(root)) {
const full = join(root, entry);
if (statSync(full).isDirectory()) {
if (entry === "node_modules" || entry === "__tests__" || entry === "dist") continue;
walkSources(full, visit);
continue;
}
if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue;
if (entry.endsWith(".test.ts")) continue;
if (full.endsWith("/rt-paths.ts")) continue; // the one allowed home
visit(full, readFileSync(full, "utf8"));
}
};

test("no code in lib/ reconstructs <rtDir>/<repoName> by hand", () => {
const libDir = join(import.meta.dir, "..");
// The old per-repo-at-root shapes. Per-repo paths MUST go through
// repoDataDir(); app-level joins use string literals and are not matched.
const antipatterns = [
/join\(\s*RT_DIR\s*,\s*[a-zA-Z_$]/, // join(RT_DIR, repoName...
/join\(\s*homedir\(\)\s*,\s*"\.rt"\s*,\s*[a-zA-Z_$]/, // legacy join(homedir(), <dir>, repoName)
/["']\.rt["']\s*,\s*repoName/, // legacy literal, repoName
];
const offenders: string[] = [];
walkSources(libDir, (file, src) => {
src.split("\n").forEach((line, i) => {
if (antipatterns.some((re) => re.test(line))) {
offenders.push(`${file}:${i + 1} ${line.trim()}`);
}
});
});
expect(offenders, `Per-repo paths must use repoDataDir() from rt-paths.ts:\n${offenders.join("\n")}`).toEqual([]);
});

test("no source builds a legacy .rt path outside rt-paths.ts", () => {
// The ~/.rt to ~/.mattstack/rt move is complete: nothing may
// reference the legacy tree except rt-paths.ts itself (which owns the
// migration + canary). Catches both quoted literals (".rt") and path-like
// occurrences in strings/comments (~/.rt/..., $HOME/.rt/...).
const repoRoot = join(import.meta.dir, "..", "..");
const roots = [
join(repoRoot, "lib"),
join(repoRoot, "commands"),
join(repoRoot, "cli.ts"),
join(repoRoot, "packages", "rt-client", "src"),
];
const antipatterns = [
/["'`]\.rt["'`]/, // the quoted literal ".rt"
/\/\.rt(\/|\b)/, // a /.rt path segment (~/.rt/..., $HOME/.rt)
];
const offenders: string[] = [];
for (const root of roots) {
walkSources(root, (file, src) => {
src.split("\n").forEach((line, i) => {
if (antipatterns.some((re) => re.test(line))) {
offenders.push(`${file}:${i + 1} ${line.trim()}`);
}
});
});
}
expect(
offenders,
`Legacy .rt paths must go through rt-paths.ts (rtDir() et al.):\n${offenders.join("\n")}`,
).toEqual([]);
});
99 changes: 5 additions & 94 deletions lib/__tests__/rt-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
/**
* rt-paths — the single source of truth for the ~/.mattstack/rt layout.
*
* Includes SOURCE-GUARD tests that fail the build if:
* - the old per-repo-at-root pattern (`join(RT_DIR, repoName, ...)`) reappears
* anywhere in lib/ outside this module, or
* - the legacy `.rt` path literal reappears anywhere under lib/, commands/,
* cli.ts, or packages/rt-client/src outside rt-paths.ts (RT-46: the
* ~/.rt compat symlink is being removed; every path must build the real
* ~/.mattstack/rt tree).
* Those tripwires are the point: they keep a future relayout a one-line change
* here instead of a scavenger hunt.
* The source-scanning tripwires that guard this layout (no hand-built
* <rtDir>/<repoName> joins, no legacy `.rt` path literal outside rt-paths.ts)
* live in no-hand-built-repo-paths.test.ts, where the always-run glob finds
* them.
*/

import { describe, test, expect, afterEach, mock } from "bun:test";
import {
existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
rmSync, statSync, symlinkSync, writeFileSync,
} from "fs";
import * as osReal from "os";
Expand Down Expand Up @@ -63,16 +58,6 @@ describe("rt-paths", () => {
expect(logsDir()).toBe("/tmp/home-logs-2/.mattstack/rt/logs");
});

test("log writers resolve the log dir at call time, not at module load", () => {
// Source-guard: a writer that binds its log dir to a module-level const is
// invisible until it silently pollutes the real tree, so pin it here.
for (const file of ["../daemon-logger.ts", "../cli-logger.ts"]) {
const src = readFileSync(join(import.meta.dir, file), "utf8");
expect(src).toContain("logsDir()");
expect(src).not.toMatch(/^const LOG_DIR\s*=/m);
}
});

test("a repo dir is NEVER directly under the rt dir (regression guard for the move)", () => {
process.env.HOME = "/tmp/h";
expect(repoDataDir("x")).toBe(join(reposDir(), "x"));
Expand Down Expand Up @@ -472,80 +457,6 @@ describe("rt-paths", () => {
});
});

// ── Source-guards ────────────────────────────────────────────────────────────

/** Walk .ts sources (skipping tests, node_modules, dist) under a root. */
const walkSources = (root: string, visit: (file: string, src: string) => void) => {
const st = statSync(root);
if (!st.isDirectory()) {
visit(root, readFileSync(root, "utf8"));
return;
}
for (const entry of readdirSync(root)) {
const full = join(root, entry);
if (statSync(full).isDirectory()) {
if (entry === "node_modules" || entry === "__tests__" || entry === "dist") continue;
walkSources(full, visit);
continue;
}
if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue;
if (entry.endsWith(".test.ts")) continue;
if (full.endsWith("/rt-paths.ts")) continue; // the one allowed home
visit(full, readFileSync(full, "utf8"));
}
};

test("no code in lib/ reconstructs <rtDir>/<repoName> by hand", () => {
const libDir = join(import.meta.dir, "..");
// The old per-repo-at-root shapes. Per-repo paths MUST go through
// repoDataDir(); app-level joins use string literals and are not matched.
const antipatterns = [
/join\(\s*RT_DIR\s*,\s*[a-zA-Z_$]/, // join(RT_DIR, repoName...
/join\(\s*homedir\(\)\s*,\s*"\.rt"\s*,\s*[a-zA-Z_$]/, // legacy join(homedir(), <dir>, repoName)
/["']\.rt["']\s*,\s*repoName/, // legacy literal, repoName
];
const offenders: string[] = [];
walkSources(libDir, (file, src) => {
src.split("\n").forEach((line, i) => {
if (antipatterns.some((re) => re.test(line))) {
offenders.push(`${file}:${i + 1} ${line.trim()}`);
}
});
});
expect(offenders, `Per-repo paths must use repoDataDir() from rt-paths.ts:\n${offenders.join("\n")}`).toEqual([]);
});

test("no source builds a legacy .rt path outside rt-paths.ts (RT-46)", () => {
// The ~/.rt → ~/.mattstack/rt move (RT-33/RT-46) is complete: nothing may
// reference the legacy tree except rt-paths.ts itself (which owns the
// migration + canary). Catches both quoted literals (".rt") and path-like
// occurrences in strings/comments (~/.rt/..., $HOME/.rt/...).
const repoRoot = join(import.meta.dir, "..", "..");
const roots = [
join(repoRoot, "lib"),
join(repoRoot, "commands"),
join(repoRoot, "cli.ts"),
join(repoRoot, "packages", "rt-client", "src"),
];
const antipatterns = [
/["'`]\.rt["'`]/, // the quoted literal ".rt"
/\/\.rt(\/|\b)/, // a /.rt path segment (~/.rt/..., $HOME/.rt)
];
const offenders: string[] = [];
for (const root of roots) {
walkSources(root, (file, src) => {
src.split("\n").forEach((line, i) => {
if (antipatterns.some((re) => re.test(line))) {
offenders.push(`${file}:${i + 1} ${line.trim()}`);
}
});
});
}
expect(
offenders,
`Legacy .rt paths must go through rt-paths.ts (rtDir() et al.):\n${offenders.join("\n")}`,
).toEqual([]);
});
});

describe("worktreePoolRoot (friendly PATH-safe identity segment)", () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/rt-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* Keeping the construction here means there is exactly one place that knows
* the layout, so a future move is a one-line change and stray `join(RT_DIR,
* repoName, ...)` callsites can't drift. The source-guard tests
* (lib/__tests__/rt-paths.test.ts) fail the build if that pattern — or any
* legacy `.rt` literal — reappears outside this module.
* (lib/__tests__/no-hand-built-repo-paths.test.ts) fail the build if that
* pattern, or any legacy `.rt` literal, reappears outside this module.
*
* HOME is resolved at CALL time via `process.env.HOME ?? homedir()` so tests can
* point the whole tree at a temp dir by setting process.env.HOME before calling.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
},
"scripts": {
"test": "bun test lib commands packages scripts rt-tray/Tests/stub-rt rt-tray/vm/run/helpers",
"test:watch": "bun test --watch lib commands packages scripts rt-tray/Tests/stub-rt rt-tray/vm/run/helpers",
"test:watch": "bun run test --watch",
"test:timings": "rm -f test-timings.json && bun run test --timings=test-timings.json --update-timings",
"test:e2e": "bun test --preload ./e2e/setup.ts --timeout 60000 e2e/tests/",
"test:pty": "bun test --preload ./e2e/setup.ts --timeout 120000 e2e/pty/",
"test:all": "bun test lib commands packages scripts rt-tray/Tests/stub-rt rt-tray/vm/run/helpers && bun test --preload ./e2e/setup.ts --timeout 60000 e2e/tests/ && bun test --preload ./e2e/setup.ts --timeout 120000 e2e/pty/",
"test:all": "bun run test && bun run test:e2e && bun run test:pty",
"docs:gen": "bun scripts/gen-docs.ts",
"docs:check": "bun scripts/check-docs.ts",
"picker:check": "bun scripts/lib/picker-conformance.ts",
Expand Down
Loading
Loading