Files
paperclip-docs/scripts/sync/test.mjs
T
Aron PrinsandClaude Opus 5 c593c23233 seo: date sitemap entries by body changes, not frontmatter sweeps (#118)
Follow-up to #117. That PR got `<lastmod>` publishing at all; this one
makes the dates worth resubmitting.

## The problem

**190 of 193 pages read `2026-08-25`** — the date of #115, which added
`seo_title`/`seo_description` to all 192 pages. Here is everything it
changed in a representative file:

```diff
+---
+seo_title: "Key Concepts: Companies, Agents, Tasks"
+seo_description: The seven ideas behind Paperclip — company, agent, task...
+---
+
 # Key Concepts
```

Not a word of prose. `lastmod` is meant to carry the date a page's
**content** last changed, so resubmitting this hands Search Console the
"whole site changed at once" signal that `dropUniformLastmod` and the
shallow-checkout guard both exist to prevent. It cleared
`dropUniformLastmod` only because 3 pages happened to differ.

## The fix

Walk each file's history and report the newest commit that changed the
**rendered body** — comparing `parseFrontmatter().body` across
consecutive commits, so frontmatter-only commits are skipped. Reusing
the build's own parser means the body compared is exactly the body the
site renders.

| | Before | After |
|---|---|---|
| Distinct dates | **2** | **31** |
| Largest single-date cluster | 190 pages | 54 pages |
| Range | 2 days | 2026-04-17 → 2026-08-27 |

Top of the new distribution:

| Date | Pages |
|---|---|
| 2026-08-18 | 54 |
| 2026-06-04 | 21 |
| 2026-08-25 | 17 |
| 2026-07-08 | 16 |
| 2026-07-20 | 15 |
| 2026-04-22 | 12 |

Spot-checked both directions:

- `key-concepts.md` — last touched by the frontmatter sweep, now dates
to **2026-07-08**, the commit that changed "Claude Local" to "Claude
Code" in the body.
- `day-to-day/issues.md` — **keeps 2026-08-25**, because release commit
`6c4519a` that day added a real "Who may resolve a card" section. Its
frontmatter-only `paperclip_version` bump in the same release was
correctly skipped.

## Two supporting fixes

**Concurrency.** ~600 git round-trips in series cost 40s. Resolving them
in parallel: **11s**.

**A race the concurrency exposed** — and the reason this is worth
reading closely. `hasTrustworthyGitHistory()` memoised its *resolved
value*, so once all 192 pages asked at once, every one of them passed
the guard before the first resolved:

```
Could not deepen the shallow checkout: fatal: Unable to create '.git/shallow.lock': File exists.
Still shallow: omitting sitemap <lastmod> rather than dating every page alike.
   × 192
```

192 concurrent `git fetch --unshallow` calls fighting over the lock, and
**a sitemap with no dates at all**. This only reproduces on a shallow
clone — which is to say only on Cloudflare Pages, never locally or in
`docs-test`. Fixed by memoising the promise (single-flight).

## Verification

| Scenario | Result |
|---|---|
| Full clone | 193 dates, 31 distinct, 11s |
| `git clone --depth 1` (reproduces CF) | one unshallow, **193 dates, 31
distinct** |
| Shallow + unreachable remote | no dates, exit 0 |
| `npm run docs:test` (6 suites) | pass |
| `npm run sync:test` | 39 pass, 0 fail (+1 new) |

New unit test covers the `--follow --name-status` rename parsing — 55
docs files have been renamed, and reading the wrong field silently drops
their pre-rename history.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 10:55:32 +02:00

1392 lines
63 KiB
JavaScript

#!/usr/bin/env node
// Self-contained test runner for the three sync helper scripts.
// No test framework: spawns each script with cwd set to a tmpdir fixture
// where ROOT (computed from import.meta.url as <tmp>/scripts/sync/../..) is the tmpdir.
//
// Usage: node scripts/sync/test.mjs
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { isPathInside, parseFollowLog, parseFrontmatter } from "../../site/build-release.mjs";
import { instanceEnv } from "../screenshots/config.mjs";
const SELF_DIR = dirname(fileURLToPath(import.meta.url));
const SCRIPTS = {
lint: join(SELF_DIR, "lint-links.mjs"),
nav: join(SELF_DIR, "verify-nav.mjs"),
shots: join(SELF_DIR, "backfill-screenshots.mjs"),
};
let pass = 0;
let fail = 0;
const failures = [];
function makeFixture() {
const dir = mkdtempSync(join(tmpdir(), "sync-test-"));
mkdirSync(join(dir, "scripts/sync"), { recursive: true });
return dir;
}
function placeScript(fixture, srcPath) {
const dest = join(fixture, "scripts/sync", srcPath.split("/").pop());
copyFileSync(srcPath, dest);
return dest;
}
function writeFile(fixture, relPath, contents) {
const p = join(fixture, relPath);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, contents);
}
function run(scriptPath, args = [], cwd) {
const r = spawnSync(process.execPath, [scriptPath, ...args], {
cwd,
encoding: "utf8",
});
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
function test(name, fn) {
try {
fn();
pass++;
console.log(` PASS ${name}`);
} catch (e) {
fail++;
failures.push({ name, error: e });
console.log(` FAIL ${name}`);
console.log(` ${e.message}`);
}
}
// ----------------------------------------------------------------------------
// lint-links
// ----------------------------------------------------------------------------
test("lint-links: detects broken md + missing image, skips all skip-cases", () => {
const fix = makeFixture();
const script = placeScript(fix, SCRIPTS.lint);
// Create a real target file the valid link can point at.
writeFile(fix, "docs/real.md", "# real\n");
// Author the test file with all the variants.
const md = [
"# test",
"",
"[valid](real.md)",
"[broken](does-not-exist.md)",
"[external](https://example.com/foo)",
"[agent](agent://some/thing)",
"[mailto](mailto:[email protected])",
"[anchor](#section)",
"[abs](/spa-route)",
"[tmpl-dollar](path/${VAR}/x.md)",
"[tmpl-mustache](path/{{name}}/x.md)",
"[tmpl-brace](path/{lowercase}/x.md)",
"![missing-img](./missing.png)",
"",
].join("\n");
writeFile(fix, "docs/test.md", md);
const r = run(script, ["--json"], fix);
assert(r.code === 1, `expected exit 1, got ${r.code}. stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.broken.length === 2, `expected 2 broken, got ${out.broken.length}: ${JSON.stringify(out.broken)}`);
const targets = out.broken.map((b) => b.target).sort();
assert(targets.includes("does-not-exist.md"), `missing broken md link in: ${targets}`);
assert(targets.includes("./missing.png"), `missing broken image in: ${targets}`);
rmSync(fix, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
// verify-nav
// ----------------------------------------------------------------------------
test("verify-nav: 1 dangling + 1 orphan, screenshots md ignored", () => {
const fix = makeFixture();
const script = placeScript(fix, SCRIPTS.nav);
writeFile(fix, "docs/intro.md", "# intro\n");
writeFile(fix, "docs/extra.md", "# extra (orphan)\n");
writeFile(fix, "docs/user-guides/screenshots/notes.md", "# should be ignored\n");
const content = {
sections: [
{ items: [{ title: "Intro", file: "../docs/intro.md" }] },
{ items: [{ title: "Gone", file: "../docs/missing.md" }] },
],
};
writeFile(fix, "site/content.json", JSON.stringify(content));
const r = run(script, ["--json"], fix);
assert(r.code === 1, `expected exit 1, got ${r.code}. stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.dangling.length === 1, `dangling=${JSON.stringify(out.dangling)}`);
assert(out.dangling[0].endsWith("docs/missing.md"), `dangling path: ${out.dangling[0]}`);
assert(out.orphans.length === 1, `orphans=${JSON.stringify(out.orphans)}`);
assert(out.orphans[0].endsWith("docs/extra.md"), `orphan path: ${out.orphans[0]}`);
// screenshots md must not appear anywhere
assert(
!out.orphans.some((o) => o.includes("screenshots/")),
`screenshot md leaked into orphans: ${JSON.stringify(out.orphans)}`
);
rmSync(fix, { recursive: true, force: true });
});
test("verify-nav: --strict promotes orphan-only to exit 1", () => {
const fix = makeFixture();
const script = placeScript(fix, SCRIPTS.nav);
writeFile(fix, "docs/intro.md", "# intro\n");
writeFile(fix, "docs/orphan.md", "# orphan\n");
const content = { sections: [{ items: [{ title: "Intro", file: "../docs/intro.md" }] }] };
writeFile(fix, "site/content.json", JSON.stringify(content));
const nonStrict = run(script, ["--json"], fix);
assert(nonStrict.code === 0, `non-strict orphan-only should exit 0, got ${nonStrict.code}`);
const strict = run(script, ["--json", "--strict"], fix);
assert(strict.code === 1, `strict orphan-only should exit 1, got ${strict.code}`);
const out = JSON.parse(strict.stdout);
assert(out.dangling.length === 0, `expected no dangling, got ${JSON.stringify(out.dangling)}`);
assert(out.orphans.length === 1, `expected 1 orphan, got ${JSON.stringify(out.orphans)}`);
rmSync(fix, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
// backfill-screenshots
// ----------------------------------------------------------------------------
test("backfill-screenshots: preserves entries with depends_on, scaffolds the rest", () => {
const fix = makeFixture();
const script = placeScript(fix, SCRIPTS.shots);
// Minimal valid PNG bytes (8-byte signature is enough; script only stats by extension).
const pngStub = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const shotsDir = join(fix, "docs/user-guides/screenshots");
mkdirSync(join(shotsDir, "light"), { recursive: true });
mkdirSync(join(shotsDir, "dark"), { recursive: true });
writeFileSync(join(shotsDir, "light/dashboard.png"), pngStub);
writeFileSync(join(shotsDir, "dark/dashboard.png"), pngStub);
const preExisting = {
entries: [
{
file: "light/dashboard.png",
theme: "light",
viewport: "1920x1080",
route: "/dashboard",
captured_against: "abc",
captured_sha: "deadbeef",
depends_on: ["some/path.ts"],
},
],
};
writeFile(fix, "docs/user-guides/screenshots/registry.json", JSON.stringify(preExisting));
const r = run(script, [], fix);
assert(r.code === 0 || r.code === null, `expected exit 0, got ${r.code}. stderr=${r.stderr}`);
const reg = JSON.parse(readFileSync(join(shotsDir, "registry.json"), "utf8"));
assert(reg.entries.length === 2, `expected 2 entries, got ${reg.entries.length}`);
const light = reg.entries.find((e) => e.file === "light/dashboard.png");
const dark = reg.entries.find((e) => e.file === "dark/dashboard.png");
assert(light, "light entry missing");
assert(dark, "dark entry missing");
// Pre-existing preserved verbatim.
assert(light.viewport === "1920x1080", `light viewport changed: ${light.viewport}`);
assert(light.route === "/dashboard", `light route changed: ${light.route}`);
assert(light.captured_sha === "deadbeef", "light captured_sha changed");
assert(Array.isArray(light.depends_on) && light.depends_on.length === 1 && light.depends_on[0] === "some/path.ts",
`light depends_on changed: ${JSON.stringify(light.depends_on)}`);
assert(light._todo === undefined, "light entry should not have _todo (was preserved)");
// Dark scaffolded fresh.
assert(dark.theme === "dark", `dark theme: ${dark.theme}`);
assert(Array.isArray(dark.depends_on) && dark.depends_on.length === 0, `dark depends_on: ${JSON.stringify(dark.depends_on)}`);
assert(typeof dark._todo === "string" && dark._todo.length > 0, "dark _todo missing");
rmSync(fix, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
// compare-window (unit, fixture-driven)
// ----------------------------------------------------------------------------
const COMPARE_SCRIPT = join(SELF_DIR, "compare-window.mjs");
function writeFixture(dir, name, payload) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), typeof payload === "string" ? payload : JSON.stringify(payload));
}
function runCompare(args, fixtureDir) {
const r = spawnSync(process.execPath, [COMPARE_SCRIPT, ...args], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtureDir },
});
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
test("compare-window: no truncation → single leaf, all files preserved", () => {
const fix = mkdtempSync(join(tmpdir(), "cw-test-"));
// SHA lookups for the two refs.
writeFixture(fix, "sha-A.json", { sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" });
writeFixture(fix, "sha-B.json", { sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" });
// Compare body keyed by the resolved SHAs.
const files = [];
for (let i = 0; i < 42; i++) {
files.push({ filename: `f${i}.ts`, status: "modified", additions: 1, deletions: 0 });
}
const commits = Array.from({ length: 10 }, (_, i) => ({ sha: `c${i}`.padEnd(40, "0") }));
writeFixture(fix, "compare-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json", {
total_commits: 10,
commits,
files,
});
const r = runCompare(["A", "B", "--json"], fix);
assert(r.code === 0, `expected 0, got ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.leaves === 1, `leaves=${out.leaves}`);
assert(out.truncated_leaves === 0, `truncated_leaves=${out.truncated_leaves}`);
assert(out.files.length === 42, `files.length=${out.files.length}`);
assert(out.total_commits_seen === 10, `commits=${out.total_commits_seen}`);
rmSync(fix, { recursive: true, force: true });
});
test("compare-window: single split bisects and unions correctly", () => {
const fix = mkdtempSync(join(tmpdir(), "cw-test-"));
const aSha = "a".repeat(40);
const bSha = "b".repeat(40);
const midSha = "m".repeat(40);
writeFixture(fix, "sha-A.json", { sha: aSha });
writeFixture(fix, "sha-B.json", { sha: bSha });
// Top-level A...B is truncated: 300 files, 250 commits in array, total_commits=500.
const topFiles = [];
for (let i = 0; i < 300; i++) topFiles.push({ filename: `top${i}.ts`, status: "modified", additions: 1, deletions: 0 });
const topCommits = [];
for (let i = 0; i < 250; i++) topCommits.push({ sha: i === 125 ? midSha : `t${i}`.padEnd(40, "0") });
writeFixture(fix, `compare-${aSha}...${bSha}.json`, {
total_commits: 250,
commits: topCommits,
files: topFiles,
});
// Left leaf A...mid (non-truncated)
writeFixture(fix, `compare-${aSha}...${midSha}.json`, {
total_commits: 50,
commits: Array.from({ length: 50 }, (_, i) => ({ sha: `l${i}`.padEnd(40, "0") })),
files: [
{ filename: "left-only.ts", status: "added", additions: 5, deletions: 0 },
{ filename: "shared.ts", status: "added", additions: 3, deletions: 0 },
],
});
// Right leaf mid...B (non-truncated). shared.ts becomes "modified" — should merge
// with leaf-1's "added" to become "modified".
writeFixture(fix, `compare-${midSha}...${bSha}.json`, {
total_commits: 60,
commits: Array.from({ length: 60 }, (_, i) => ({ sha: `r${i}`.padEnd(40, "0") })),
files: [
{ filename: "right-only.ts", status: "added", additions: 2, deletions: 0 },
{ filename: "shared.ts", status: "modified", additions: 7, deletions: 1 },
],
});
const r = runCompare(["A", "B", "--json"], fix);
assert(r.code === 0, `expected 0, got ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.leaves === 2, `leaves=${out.leaves}`);
assert(out.truncated_leaves === 0, `truncated_leaves=${out.truncated_leaves}`);
const byName = Object.fromEntries(out.files.map((f) => [f.filename, f]));
assert(byName["left-only.ts"], "missing left-only.ts");
assert(byName["right-only.ts"], "missing right-only.ts");
assert(byName["shared.ts"], "missing shared.ts");
assert(byName["shared.ts"].status === "modified", `shared.ts status=${byName["shared.ts"].status}`);
// additions summed: 3 + 7
assert(byName["shared.ts"].additions === 10, `shared.ts additions=${byName["shared.ts"].additions}`);
rmSync(fix, { recursive: true, force: true });
});
test("compare-window: status merge — added+removed drops, added+modified→modified", () => {
const fix = mkdtempSync(join(tmpdir(), "cw-test-"));
const aSha = "a".repeat(40);
const bSha = "b".repeat(40);
const midSha = "c".repeat(40);
writeFixture(fix, "sha-A.json", { sha: aSha });
writeFixture(fix, "sha-B.json", { sha: bSha });
// Truncated parent forcing a split.
const topFiles = [];
for (let i = 0; i < 300; i++) topFiles.push({ filename: `pad${i}.ts`, status: "modified", additions: 0, deletions: 0 });
const topCommits = [];
for (let i = 0; i < 50; i++) topCommits.push({ sha: i === 25 ? midSha : `t${i}`.padEnd(40, "0") });
writeFixture(fix, `compare-${aSha}...${bSha}.json`, {
total_commits: 50,
commits: topCommits,
files: topFiles,
});
writeFixture(fix, `compare-${aSha}...${midSha}.json`, {
total_commits: 10,
commits: Array.from({ length: 10 }, (_, i) => ({ sha: `l${i}`.padEnd(40, "0") })),
files: [
{ filename: "foo.ts", status: "added", additions: 50, deletions: 0 },
{ filename: "bar.ts", status: "added", additions: 30, deletions: 0 },
],
});
writeFixture(fix, `compare-${midSha}...${bSha}.json`, {
total_commits: 10,
commits: Array.from({ length: 10 }, (_, i) => ({ sha: `r${i}`.padEnd(40, "0") })),
files: [
{ filename: "foo.ts", status: "removed", additions: 0, deletions: 50 },
{ filename: "bar.ts", status: "modified", additions: 4, deletions: 1 },
],
});
const r = runCompare(["A", "B", "--json"], fix);
assert(r.code === 0, `expected 0, got ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const byName = Object.fromEntries(out.files.map((f) => [f.filename, f]));
assert(!byName["foo.ts"], `foo.ts should be dropped (added+removed); got ${JSON.stringify(byName["foo.ts"])}`);
assert(byName["bar.ts"], "bar.ts missing");
assert(byName["bar.ts"].status === "modified", `bar.ts status=${byName["bar.ts"].status} (expected modified)`);
rmSync(fix, { recursive: true, force: true });
});
test("compare-window: rename after prior modification moves old path state", () => {
const fix = mkdtempSync(join(tmpdir(), "cw-test-"));
const aSha = "a".repeat(40);
const bSha = "b".repeat(40);
const midSha = "d".repeat(40);
writeFixture(fix, "sha-A.json", { sha: aSha });
writeFixture(fix, "sha-B.json", { sha: bSha });
const topFiles = [];
for (let i = 0; i < 300; i++) topFiles.push({ filename: `pad${i}.ts`, status: "modified", additions: 0, deletions: 0 });
const topCommits = [];
for (let i = 0; i < 50; i++) topCommits.push({ sha: i === 25 ? midSha : `u${i}`.padEnd(40, "0") });
writeFixture(fix, `compare-${aSha}...${bSha}.json`, {
total_commits: 50,
commits: topCommits,
files: topFiles,
});
writeFixture(fix, `compare-${aSha}...${midSha}.json`, {
total_commits: 10,
commits: Array.from({ length: 10 }, (_, i) => ({ sha: `l${i}`.padEnd(40, "0") })),
files: [
{ filename: "old.ts", status: "modified", additions: 3, deletions: 1 },
],
});
writeFixture(fix, `compare-${midSha}...${bSha}.json`, {
total_commits: 10,
commits: Array.from({ length: 10 }, (_, i) => ({ sha: `r${i}`.padEnd(40, "0") })),
files: [
{ filename: "new.ts", previous_filename: "old.ts", status: "renamed", additions: 1, deletions: 0 },
],
});
const r = runCompare(["A", "B", "--json"], fix);
assert(r.code === 0, `expected 0, got ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const names = out.files.map((f) => f.filename);
assert(!names.includes("old.ts"), `old.ts should have moved to new.ts; files=${JSON.stringify(out.files)}`);
const moved = out.files.find((f) => f.filename === "new.ts");
assert(moved, `new.ts missing: ${JSON.stringify(out.files)}`);
assert(moved.status === "renamed", `new.ts status=${moved.status}`);
assert(moved.previous_filename === "old.ts", `previous_filename=${moved.previous_filename}`);
assert(moved.additions === 4, `additions=${moved.additions}`);
rmSync(fix, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
// compare-window:integration — live network test against paperclipai/paperclip
// ----------------------------------------------------------------------------
test("compare-window:integration v2026.318.0...v2026.512.0 (live)", () => {
// Skip (don't fail) if gh isn't authed.
const auth = spawnSync("gh", ["auth", "status"], { encoding: "utf8" });
if (auth.status !== 0) {
console.log(" SKIP — gh auth status failed; this test needs an authed gh + network.");
pass--; // counter the auto-pass from the wrapper; we want a neutral marker
// We re-increment via a "skip" path: don't throw, but mark visually.
pass++; // restore (treat skip as pass for runner)
return;
}
const r = spawnSync(process.execPath, [
COMPARE_SCRIPT,
"v2026.318.0",
"v2026.512.0",
"--json",
"--repo",
"paperclipai/paperclip",
], { encoding: "utf8", env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: "" } });
if (r.status !== 0) {
throw new Error(`live run failed (exit ${r.status}). stderr=${r.stderr}`);
}
const out = JSON.parse(r.stdout);
assert(out.truncated_leaves === 0, `truncated_leaves=${out.truncated_leaves} (must be 0)`);
assert(out.files.length >= 1000, `files.length=${out.files.length} (expected >= 1000)`);
const names = new Set(out.files.map((f) => f.filename));
for (const expected of [
"cli/src/commands/client/secrets.ts",
".env.example",
"server/src/routes/companies.ts",
]) {
assert(names.has(expected), `expected file missing from output: ${expected}`);
}
console.log(` integration stats: files=${out.files.length}, leaves=${out.leaves}, truncated=${out.truncated_leaves}, commits=${out.total_commits_seen}`);
});
// ----------------------------------------------------------------------------
// check-drift (unit, fixture-driven)
// ----------------------------------------------------------------------------
const DRIFT_SCRIPT = join(SELF_DIR, "check-drift.mjs");
function runDrift(args, fixtureDir, cwd) {
const r = spawnSync(process.execPath, [DRIFT_SCRIPT, ...args], {
cwd,
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtureDir },
});
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
function makeDriftFixture() {
// The check-drift script computes ROOT from its own location: ../.. relative
// to the script. So we replicate that layout: put the real script under
// scripts/sync/ in a tmpdir, plus a sibling anchor-map.json (needed for
// env-var sources), plus the docs/ tree we want to scan.
const dir = mkdtempSync(join(tmpdir(), "drift-test-"));
mkdirSync(join(dir, "scripts/sync"), { recursive: true });
// Copy the script.
copyFileSync(DRIFT_SCRIPT, join(dir, "scripts/sync/check-drift.mjs"));
// Copy real anchor-map.json so envVarSourcesToCheck() resolves consistently.
copyFileSync(join(SELF_DIR, "anchor-map.json"), join(dir, "scripts/sync/anchor-map.json"));
// Fixture dir for gh stubs.
const fixDir = join(dir, "_fixtures");
mkdirSync(fixDir, { recursive: true });
// Tell the script the repo's default branch is "master".
writeFile(dir, "_fixtures/repo.json", JSON.stringify({ default_branch: "master" }));
return { root: dir, fixtures: fixDir, scriptInRoot: join(dir, "scripts/sync/check-drift.mjs") };
}
function writeFixtureContents(fixDir, parentPath, ref, body) {
const slug = parentPath.replace(/\//g, "__");
writeFileSync(join(fixDir, `contents-${slug}-${ref}.json`), JSON.stringify(body));
}
test("check-drift: parent path drift caught (high confidence)", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
// Doc that references a now-deleted parent file.
writeFile(root, "docs/reference/sample.md", "See [foo](../../cli/src/commands/foo.ts) for details.\n");
// Fixture says the path 404s.
writeFixtureContents(fixtures, "cli/src/commands/foo.ts", "master", { status: 404 });
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const pp = out.drift.filter((d) => d.kind === "parent-path-missing");
assert(pp.length === 1, `expected 1 parent-path-missing, got ${pp.length}: ${JSON.stringify(out.drift)}`);
assert(pp[0].confidence === "high", `expected high confidence, got ${pp[0].confidence}`);
assert(pp[0].documented === "cli/src/commands/foo.ts", `documented=${pp[0].documented}`);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: env var drift caught (high confidence)", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
// env-vars doc with one row referencing OLD_VAR.
const envDoc = [
"# Environment variables",
"",
"| Variable | Default | Meaning |",
"|---|---|---|",
"| `OLD_VAR` | none | removed thing |",
"",
].join("\n");
writeFile(root, "docs/reference/deploy/environment-variables.md", envDoc);
// .env.example and config.ts don't mention OLD_VAR.
writeFixtureContents(fixtures, ".env.example", "master", {
status: 200,
content_base64: Buffer.from("PORT=3100\nHOST=127.0.0.1\n").toString("base64"),
});
writeFixtureContents(fixtures, "server/src/config.ts", "master", {
status: 200,
content_base64: Buffer.from('export const config = { port: process.env.PORT };\n').toString("base64"),
});
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const ev = out.drift.filter((d) => d.kind === "env-var-missing");
assert(ev.length === 1, `expected 1 env-var-missing, got ${ev.length}: ${JSON.stringify(out.drift)}`);
assert(ev[0].documented === "OLD_VAR", `documented=${ev[0].documented}`);
assert(ev[0].confidence === "high", `expected high, got ${ev[0].confidence}`);
rmSync(root, { recursive: true, force: true });
});
const PERM_DOC = "docs/administration/roles-and-permissions.md";
function permB64(s) {
return { status: 200, content_base64: Buffer.from(s).toString("base64") };
}
test("check-drift: permission catalog + role-grant drift caught (high confidence)", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
// Doc: catalog table omits `joins:approve`; owner grants omit `environments:manage`.
const doc = [
"# Roles & Permissions",
"",
"| Role | Who | Implicit grants |",
"|---|---|---|",
"| **Owner** | run | `agents:create`, `users:invite` |",
"| **Viewer** | read | *(none)* |",
"",
"| Permission key | What it allows | In which role |",
"|---|---|---|",
"| `agents:create` | make agents | Owner |",
"| `users:invite` | invite | Owner |",
"",
].join("\n");
writeFile(root, PERM_DOC, doc);
writeFixtureContents(fixtures, "packages/shared/src/constants.ts", "master",
permB64('export const PERMISSION_KEYS = [\n "agents:create",\n "users:invite",\n "joins:approve",\n] as const;\n'));
writeFixtureContents(fixtures, "server/src/services/company-member-roles.ts", "master",
permB64('export function grantsForHumanRole(role) {\n switch (role) {\n case "owner":\n return [\n { permissionKey: "agents:create", scope: null },\n { permissionKey: "users:invite", scope: null },\n { permissionKey: "environments:manage", scope: null },\n ];\n case "viewer":\n return [];\n }\n}\nexport function other() {}\n'));
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const pc = out.drift.filter((d) => d.kind === "permission-catalog-drift");
assert(pc.length === 2, `expected 2 permission-catalog-drift, got ${pc.length}: ${JSON.stringify(pc)}`);
assert(pc.some((d) => /joins:approve/.test(d.documented)), `expected joins:approve catalog drift: ${JSON.stringify(pc)}`);
assert(pc.some((d) => /owner/.test(d.documented) && /environments:manage/.test(d.documented)), `expected owner role-grant drift: ${JSON.stringify(pc)}`);
assert(pc.every((d) => d.confidence === "high"), "expected all high confidence");
rmSync(root, { recursive: true, force: true });
});
test("check-drift: no permission drift when docs match parent", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const doc = [
"# Roles & Permissions",
"",
"| Role | Who | Implicit grants |",
"|---|---|---|",
"| **Owner** | run | `agents:create`, `joins:approve` |",
"| **Viewer** | read | *(none)* |",
"",
"| Permission key | What it allows | In which role |",
"|---|---|---|",
"| `agents:create` | make agents | Owner |",
"| `joins:approve` | approve | Owner |",
"",
].join("\n");
writeFile(root, PERM_DOC, doc);
writeFixtureContents(fixtures, "packages/shared/src/constants.ts", "master",
permB64('export const PERMISSION_KEYS = [\n "agents:create",\n "joins:approve",\n] as const;\n'));
writeFixtureContents(fixtures, "server/src/services/company-member-roles.ts", "master",
permB64('export function grantsForHumanRole(role) {\n switch (role) {\n case "owner":\n return [\n { permissionKey: "agents:create", scope: null },\n { permissionKey: "joins:approve", scope: null },\n ];\n case "viewer":\n return [];\n }\n}\n'));
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const pc = out.drift.filter((d) => d.kind === "permission-catalog-drift");
assert(pc.length === 0, `expected no permission drift, got ${pc.length}: ${JSON.stringify(pc)}`);
assert(out.stats.permission_keys_checked === 2, `expected 2 keys checked, got ${out.stats.permission_keys_checked}`);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: REST route drift caught (medium confidence)", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const apiDoc = [
"# Foo API",
"",
"## POST /api/foo/{id}/bar",
"",
"Does the thing.",
"",
].join("\n");
writeFile(root, "docs/reference/api/foo.md", apiDoc);
// Parent routes/foo.ts exists but does NOT contain /foo/:id/bar or /foo/{id}/bar.
writeFixtureContents(fixtures, "server/src/routes/foo.ts", "master", {
status: 200,
content_base64: Buffer.from('app.get("/foo", listFoos);\napp.get("/foo/:id", getFoo);\n').toString("base64"),
});
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const rr = out.drift.filter((d) => d.kind === "rest-route-missing");
assert(rr.length === 1, `expected 1 rest-route-missing, got ${rr.length}: ${JSON.stringify(out.drift)}`);
assert(rr[0].confidence === "medium", `expected medium, got ${rr[0].confidence}`);
assert(rr[0].documented.includes("POST"), `documented=${rr[0].documented}`);
assert(rr[0].documented.includes("/api/foo/{id}/bar"), `documented=${rr[0].documented}`);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: no false positive when doc uses {id} but parent uses :id", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const apiDoc = "# Foo API\n\n## GET /api/foo/{id}\n\nReads one.\n";
writeFile(root, "docs/reference/api/foo.md", apiDoc);
writeFixtureContents(fixtures, "server/src/routes/foo.ts", "master", {
status: 200,
content_base64: Buffer.from('app.get("/foo/:id", (req, res) => res.json({}));\n').toString("base64"),
});
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const rr = out.drift.filter((d) => d.kind === "rest-route-missing");
assert(rr.length === 0, `expected 0 rest-route-missing (normalization failed), got ${rr.length}: ${JSON.stringify(rr)}`);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: table routes can resolve in any parent route file", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const apiDoc = [
"# Instance Admin API",
"",
"| Endpoint | Purpose |",
"|---|---|",
"| `POST /api/instance/database-backups` | Trigger a backup. |",
"",
].join("\n");
writeFile(root, "docs/reference/api/instance-admin.md", apiDoc);
writeFileSync(join(fixtures, "tree-master.json"), JSON.stringify([
"server/src/routes/instance-database-backups.ts",
]));
writeFixtureContents(fixtures, "server/src/routes/instance-database-backups.ts", "master", {
status: 200,
content_base64: Buffer.from('router.post("/instance/database-backups", handler);\n').toString("base64"),
});
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.stats.rest_routes_checked === 1, `expected 1 route checked, got ${out.stats.rest_routes_checked}`);
const rr = out.drift.filter((d) => d.kind === "rest-route-missing");
assert(rr.length === 0, `expected 0 rest-route-missing, got ${rr.length}: ${JSON.stringify(rr)}`);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: env vars can verify against tree-discovered package sources", () => {
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const envDoc = [
"# Environment variables",
"",
"| Variable | Default | Meaning |",
"|---|---|---|",
"| `DAYTONA_API_KEY` | none | plugin credential |",
"",
].join("\n");
writeFile(root, "docs/reference/deploy/environment-variables.md", envDoc);
writeFileSync(join(fixtures, "tree-master.json"), JSON.stringify([
".env.example",
"server/src/config.ts",
"packages/plugins/sandbox-providers/daytona/src/manifest.ts",
]));
writeFixtureContents(fixtures, ".env.example", "master", {
status: 200,
content_base64: Buffer.from("PORT=3100\n").toString("base64"),
});
writeFixtureContents(fixtures, "server/src/config.ts", "master", {
status: 200,
content_base64: Buffer.from("export const PORT = process.env.PORT;\n").toString("base64"),
});
writeFixtureContents(fixtures, "packages/plugins/sandbox-providers/daytona/src/manifest.ts", "master", {
status: 200,
content_base64: Buffer.from('export const requiredEnv = ["DAYTONA_API_KEY"];\n').toString("base64"),
});
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const ev = out.drift.filter((d) => d.kind === "env-var-missing");
assert(ev.length === 0, `expected 0 env-var-missing, got ${ev.length}: ${JSON.stringify(ev)}`);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: cache is keyed by resolved SHA, never a moving ref name", () => {
// Regression guard for the stale-`master` cache bug: `--against master`
// resolves to an immutable SHA, and the cache directory is keyed by that SHA
// (drift-<sha>), never by the moving branch name (drift-master). Fetches also
// use the resolved SHA — proven below by foo.ts resolving with no drift even
// though its content fixture is keyed by the SHA, not "master".
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const SHA = "0123456789abcdef0123456789abcdef01234567"; // 40 hex
writeFile(root, "docs/reference/sample.md", "See cli/src/commands/foo.ts for details.\n");
// Resolve `master` → SHA via the commit stub.
writeFile(root, "_fixtures/commit-master.json", JSON.stringify({ sha: SHA }));
// Content fixture keyed by the RESOLVED SHA (not "master").
writeFixtureContents(fixtures, "cli/src/commands/foo.ts", SHA, { status: 200 });
const cacheDir = join(root, "_cache");
const r = spawnSync(process.execPath, [scriptInRoot, "--json", "--against", "master"], {
encoding: "utf8",
env: {
...process.env,
PAPERCLIP_SYNC_FIXTURE_DIR: fixtures,
PAPERCLIP_SYNC_CACHE_DIR: cacheDir,
},
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(
out.checked_against === `master (${SHA.slice(0, 7)})`,
`expected checked_against "master (${SHA.slice(0, 7)})", got ${out.checked_against}`
);
const pp = out.drift.filter((d) => d.kind === "parent-path-missing");
assert(pp.length === 0, `expected no parent-path drift (fetch used SHA), got ${JSON.stringify(pp)}`);
assert(existsSync(join(cacheDir, `drift-${SHA}`)), `expected cache dir drift-${SHA}`);
assert(
!existsSync(join(cacheDir, "drift-master")),
"cache must NOT be keyed by the moving ref name 'master'"
);
rmSync(root, { recursive: true, force: true });
});
test("check-drift: ref comes from .sync-state.json when --against is absent", () => {
// Mode-awareness: with no --against, the ref is taken from .sync-state.json
// (nightly → last_seen_parent_sha), not the moving default branch. repo.json
// still says the default branch is "master", so if mode-awareness were
// ignored the fetch would use "master" and foo.ts (keyed by the state SHA)
// would 404 into drift.
const { root, fixtures, scriptInRoot } = makeDriftFixture();
const SHA = "89abcdef0123456789abcdef0123456789abcdef"; // 40 hex
writeFile(
root,
".sync-state.json",
JSON.stringify({ branch_mode: "nightly", last_seen_parent_sha: SHA })
);
writeFile(root, "docs/reference/sample.md", "See cli/src/commands/foo.ts for details.\n");
writeFixtureContents(fixtures, "cli/src/commands/foo.ts", SHA, { status: 200 });
const r = spawnSync(process.execPath, [scriptInRoot, "--json"], {
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
assert(r.status === 0, `expected exit 0, got ${r.status}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(
out.checked_against === SHA.slice(0, 7),
`expected checked_against ${SHA.slice(0, 7)}, got ${out.checked_against}`
);
const pp = out.drift.filter((d) => d.kind === "parent-path-missing");
assert(pp.length === 0, `expected no parent-path drift (fetch used state SHA), got ${JSON.stringify(pp)}`);
rmSync(root, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
// verify-edit (unit, fixture-driven)
// ----------------------------------------------------------------------------
const VERIFY_SCRIPT = join(SELF_DIR, "verify-edit.mjs");
function makeVerifyFixture() {
const dir = mkdtempSync(join(tmpdir(), "verify-test-"));
mkdirSync(join(dir, "scripts/sync"), { recursive: true });
copyFileSync(VERIFY_SCRIPT, join(dir, "scripts/sync/verify-edit.mjs"));
copyFileSync(join(SELF_DIR, "anchor-map.json"), join(dir, "scripts/sync/anchor-map.json"));
const fixDir = join(dir, "_fixtures");
mkdirSync(fixDir, { recursive: true });
writeFile(dir, "_fixtures/repo.json", JSON.stringify({ default_branch: "master" }));
return { root: dir, fixtures: fixDir, scriptInRoot: join(dir, "scripts/sync/verify-edit.mjs") };
}
function writeVerifyContents(fixDir, parentPath, ref, body) {
const slug = parentPath.replace(/\//g, "__");
writeFileSync(join(fixDir, `contents-${slug}-${ref}.json`), JSON.stringify(body));
}
function runVerify(scriptInRoot, docPath, fixtures, extraArgs = []) {
const r = spawnSync(process.execPath, [scriptInRoot, docPath, "--json", "--against", "master", ...extraArgs], {
cwd: dirname(dirname(dirname(scriptInRoot))),
encoding: "utf8",
env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixtures },
});
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
test("verify-edit: CLI command verification — pass", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
writeFile(root, "docs/reference/cli/foo.md", "# Foo\n\nRun:\n\n```sh\npaperclipai foo\n```\n");
// Directory listing for cli/src/commands containing foo.ts.
writeVerifyContents(fixtures, "cli/src/commands", "master", {
status: 200,
list: [{ type: "file", name: "foo.ts", path: "cli/src/commands/foo.ts" }],
});
writeVerifyContents(fixtures, "cli/src/commands/foo.ts", "master", {
status: 200,
content_base64: Buffer.from('program.command("foo").action(() => {});\n').toString("base64"),
});
// Empty subdir & index.
writeVerifyContents(fixtures, "cli/src/commands/client", "master", { status: 404 });
writeVerifyContents(fixtures, "cli/src/index.ts", "master", { status: 404 });
const r = runVerify(scriptInRoot, "docs/reference/cli/foo.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const cliClaims = [
...((out.unverified || []).filter((c) => c.kind === "cli-command")),
];
assert(out.claims_extracted >= 1, `claims_extracted=${out.claims_extracted}`);
assert(out.verified >= 1, `verified=${out.verified}, body=${r.stdout}`);
assert(cliClaims.length === 0, `unexpected unverified cli-command: ${JSON.stringify(cliClaims)}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: CLI command verification — fail", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
writeFile(root, "docs/reference/cli/bar.md", "# Bar\n\n```sh\npaperclipai bar\n```\n");
// No bar in commands.
writeVerifyContents(fixtures, "cli/src/commands", "master", {
status: 200,
list: [{ type: "file", name: "baz.ts", path: "cli/src/commands/baz.ts" }],
});
writeVerifyContents(fixtures, "cli/src/commands/baz.ts", "master", {
status: 200,
content_base64: Buffer.from('program.command("baz");').toString("base64"),
});
writeVerifyContents(fixtures, "cli/src/commands/client", "master", { status: 404 });
writeVerifyContents(fixtures, "cli/src/index.ts", "master", { status: 404 });
const r = runVerify(scriptInRoot, "docs/reference/cli/bar.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const cli = (out.unverified || []).filter((c) => c.kind === "cli-command");
assert(cli.length === 1, `expected 1 unverified cli-command, got ${cli.length}: ${JSON.stringify(out.unverified)}`);
assert(cli[0].value.includes("bar"), `value=${cli[0].value}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: adapter config field — typo caught with suggestion", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
// Doc inside a Config heading mentions `approveReads`.
const doc = [
"# Cursor Cloud",
"",
"## Config",
"",
"- `approveReads`: whether to auto-approve reads",
"",
].join("\n");
writeFile(root, "docs/reference/adapters/cursor-cloud.md", doc);
// Parent adapter source uses kebab-case 'approve-reads'.
writeVerifyContents(fixtures, "packages/adapters/cursor-cloud", "master", {
status: 200,
list: [{ type: "file", name: "index.ts", path: "packages/adapters/cursor-cloud/index.ts" }],
});
writeVerifyContents(fixtures, "packages/adapters/cursor-cloud/index.ts", "master", {
status: 200,
content_base64: Buffer.from('export const MODE = "approve-reads";\n').toString("base64"),
});
writeVerifyContents(fixtures, "packages/adapters/cursor-cloud/src", "master", { status: 404 });
writeVerifyContents(fixtures, "packages/plugins/sandbox-providers/cursor-cloud", "master", { status: 404 });
const r = runVerify(scriptInRoot, "docs/reference/adapters/cursor-cloud.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const fields = (out.unverified || []).filter((c) => c.kind === "adapter-config-field" && c.value === "approveReads");
assert(fields.length === 1, `expected 1 unverified approveReads, got: ${JSON.stringify(out.unverified)}`);
assert(fields[0].suggest && fields[0].suggest.includes("approve-reads"),
`suggest should mention approve-reads, got: ${fields[0].suggest}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: REST route — normalized path matches :id", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
writeFile(root, "docs/reference/api/foo.md", "# Foo API\n\n## GET /api/foo/{id}\n\nReads one.\n");
writeVerifyContents(fixtures, "server/src/routes/foo.ts", "master", {
status: 200,
content_base64: Buffer.from('app.get("/foo/:id", handler);\n').toString("base64"),
});
const r = runVerify(scriptInRoot, "docs/reference/api/foo.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const routes = (out.unverified || []).filter((c) => c.kind === "rest-route");
assert(routes.length === 0, `expected 0 unverified rest-route, got: ${JSON.stringify(routes)}`);
assert(out.verified >= 1, `expected ≥1 verified, got ${out.verified}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: REST route tables are extracted and searched across route files", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
const doc = [
"# Plugins API",
"",
"| Endpoint | Purpose |",
"|---|---|",
"| `POST /api/plugins/:pluginId/jobs/:jobId/trigger` | Trigger a job. |",
"",
].join("\n");
writeFile(root, "docs/reference/api/plugins.md", doc);
writeFileSync(join(fixtures, "tree-master.json"), JSON.stringify([
"server/src/routes/plugins.ts",
]));
writeVerifyContents(fixtures, "server/src/routes/plugins.ts", "master", {
status: 200,
content_base64: Buffer.from('router.post("/plugins/:pluginId/jobs/:jobId/trigger", handler);\n').toString("base64"),
});
const r = runVerify(scriptInRoot, "docs/reference/api/plugins.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.claims_extracted === 1, `expected 1 route claim, got ${out.claims_extracted}: ${r.stdout}`);
const routes = (out.unverified || []).filter((c) => c.kind === "rest-route");
assert(routes.length === 0, `expected 0 unverified rest-route, got: ${JSON.stringify(routes)}`);
assert(out.verified >= 1, `expected ≥1 verified, got ${out.verified}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: env var passes when present in .env.example", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
const doc = [
"# Environment variables",
"",
"The deployment needs `BETTER_AUTH_SECRET` to sign sessions.",
"",
].join("\n");
writeFile(root, "docs/reference/deploy/environment-variables.md", doc);
writeVerifyContents(fixtures, ".env.example", "master", {
status: 200,
content_base64: Buffer.from("BETTER_AUTH_SECRET=changeme\nPORT=3100\n").toString("base64"),
});
writeVerifyContents(fixtures, "server/src/config.ts", "master", { status: 404 });
const r = runVerify(scriptInRoot, "docs/reference/deploy/environment-variables.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const ev = (out.unverified || []).filter((c) => c.kind === "env-var");
assert(ev.length === 0, `expected 0 unverified env-var, got: ${JSON.stringify(ev)}`);
assert(out.verified >= 1, `expected ≥1 verified, got ${out.verified}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: env var passes when present in tree-discovered package source", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
writeFile(root, "docs/reference/adapters/sandbox-providers.md", "# Sandbox Providers\n\nUse the env variable `DAYTONA_API_KEY` for Daytona.\n");
writeFileSync(join(fixtures, "tree-master.json"), JSON.stringify([
".env.example",
"server/src/config.ts",
"packages/plugins/sandbox-providers/daytona/src/manifest.ts",
]));
writeVerifyContents(fixtures, ".env.example", "master", {
status: 200,
content_base64: Buffer.from("PORT=3100\n").toString("base64"),
});
writeVerifyContents(fixtures, "server/src/config.ts", "master", {
status: 200,
content_base64: Buffer.from("export const PORT = process.env.PORT;\n").toString("base64"),
});
writeVerifyContents(fixtures, "packages/plugins/sandbox-providers/daytona/src/manifest.ts", "master", {
status: 200,
content_base64: Buffer.from('export const env = "DAYTONA_API_KEY";\n').toString("base64"),
});
const r = runVerify(scriptInRoot, "docs/reference/adapters/sandbox-providers.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const ev = (out.unverified || []).filter((c) => c.kind === "env-var");
assert(ev.length === 0, `expected 0 unverified env-var, got: ${JSON.stringify(ev)}`);
assert(out.verified >= 1, `expected ≥1 verified, got ${out.verified}`);
rmSync(root, { recursive: true, force: true });
});
test("verify-edit: backticked identifier in narrative prose is NOT extracted", () => {
const { root, fixtures, scriptInRoot } = makeVerifyFixture();
// Adapter doc — but the backticked `model` appears in narrative prose, NOT
// under a Config heading nor inside code/table. Should yield 0 claims.
const doc = [
"# Cursor Cloud",
"",
"## Overview",
"",
"When configuring the adapter, use the `model` field to specify which LLM to call.",
"",
].join("\n");
writeFile(root, "docs/reference/adapters/cursor-cloud.md", doc);
// No adapter source fetches needed; the test asserts 0 extraction.
writeVerifyContents(fixtures, "packages/adapters/cursor-cloud", "master", { status: 404 });
writeVerifyContents(fixtures, "packages/adapters/cursor-cloud/src", "master", { status: 404 });
writeVerifyContents(fixtures, "packages/plugins/sandbox-providers/cursor-cloud", "master", { status: 404 });
const r = runVerify(scriptInRoot, "docs/reference/adapters/cursor-cloud.md", fixtures);
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.claims_extracted === 0,
`narrative prose should not yield claims, got ${out.claims_extracted}: ${r.stdout}`);
rmSync(root, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
// frontmatter (unit, in-process — imports parseFrontmatter directly)
// ----------------------------------------------------------------------------
test("frontmatter: parses a well-formed YAML head and strips it from the body", () => {
const md = [
"---",
"paperclip_version: v2026.512.0",
"foo: bar",
"---",
"",
"# Real content",
"",
"Body text.",
"",
].join("\n");
const { body, frontmatter } = parseFrontmatter(md);
assert(body.startsWith("# Real content"), `body should start with heading, got: ${JSON.stringify(body.slice(0, 40))}`);
assert(!body.includes("---"), `body should not leak frontmatter fence: ${JSON.stringify(body.slice(0, 40))}`);
assert(frontmatter.paperclip_version === "v2026.512.0", `paperclip_version=${frontmatter.paperclip_version}`);
assert(frontmatter.foo === "bar", `foo=${frontmatter.foo}`);
});
test("frontmatter: file with no frontmatter is returned unchanged", () => {
const md = "# Heading\n\nJust a body.\n";
const { body, frontmatter } = parseFrontmatter(md);
assert(body === md, `body should equal input, got: ${JSON.stringify(body)}`);
assert(Object.keys(frontmatter).length === 0, `frontmatter should be empty, got: ${JSON.stringify(frontmatter)}`);
});
test("frontmatter: malformed (missing closing fence) falls back to full body", () => {
const md = "---\npaperclip_version: v2026.512.0\nfoo: bar\n\n# No closing fence\n\nBody.\n";
const { body, frontmatter } = parseFrontmatter(md);
assert(body === md, `malformed input should be returned unchanged, got: ${JSON.stringify(body.slice(0, 40))}`);
assert(Object.keys(frontmatter).length === 0, `frontmatter should be empty on malformed input, got: ${JSON.stringify(frontmatter)}`);
});
test("build-release: follow-log parser reads the path as of each commit across renames", () => {
// `git log --follow --name-status --format='C %H %cs'`: a rename lists both
// paths, and the one to hand `git show` is the path at that commit (the new
// one). Getting this wrong silently drops pre-rename history for the 55 docs
// files that have been moved.
const stdout = [
"C 325c9eb819b32c292a1453ffcc1ee7cb98848710 2026-08-25",
"",
"M\tdocs/guides/welcome/key-concepts.md",
"C 56b8d7d52de2c0410b16dd0a50e6a5f2990df1aa 2026-04-22",
"",
"R095\tdocs/user-guides/guides/key-concepts.md\tdocs/guides/welcome/key-concepts.md",
"C 3a852ec7e939280e614260a1954f24146001e0dc 2026-04-17",
"",
"A\tdocs/user-guides/guides/key-concepts.md",
"",
].join("\n");
const commits = parseFollowLog(stdout);
assert(commits.length === 3, `expected 3 commits, got ${commits.length}`);
assert(commits[0].date === "2026-08-25", `first date wrong: ${commits[0].date}`);
assert(
commits[1].path === "docs/guides/welcome/key-concepts.md",
`rename commit must use the post-rename path, got: ${commits[1].path}`,
);
assert(
commits[2].path === "docs/user-guides/guides/key-concepts.md",
`pre-rename commit must keep the old path, got: ${commits[2].path}`,
);
});
test("build-release: path containment rejects sibling docs-prefixed directories", () => {
const parent = resolve("/tmp/repo/docs");
assert(isPathInside(parent, resolve("/tmp/repo/docs/guide/page.md")), "normal child path should be inside");
assert(!isPathInside(parent, resolve("/tmp/repo/docs-evil/page.md")), "docs-evil sibling must not be inside docs");
assert(!isPathInside(parent, resolve("/tmp/repo/docs/../outside.md")), "parent traversal must not be inside docs");
});
test("screenshots: instance env does not forward host credentials", () => {
const original = {
DATABASE_URL: process.env.DATABASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
HOME: process.env.HOME,
};
try {
process.env.DATABASE_URL = "postgres://real-db";
process.env.OPENAI_API_KEY = "sk-real-provider-key";
process.env.GITHUB_TOKEN = "ghp_real-token";
process.env.HOME = "/Users/real-home";
const home = resolve(tmpdir(), "paperclip-docs-test-home");
const env = instanceEnv(home);
assert(env.HOME === home, `HOME should point at scratch home, got ${env.HOME}`);
assert(env.PAPERCLIP_HOME === home, `PAPERCLIP_HOME should point at scratch home, got ${env.PAPERCLIP_HOME}`);
// DATABASE_URL is pinned to "" (not omitted) so it wins over the parent
// repo's `.env`, which dotenv loads with `override: false`. The host's real
// value must never leak through — an empty pin is what forces embedded Postgres.
assert(env.DATABASE_URL === "", `DATABASE_URL must be pinned empty, not forwarded from host, got ${JSON.stringify(env.DATABASE_URL)}`);
assert(!("OPENAI_API_KEY" in env), "OPENAI_API_KEY must not be forwarded");
assert(!("GITHUB_TOKEN" in env), "GITHUB_TOKEN must not be forwarded");
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
// ----------------------------------------------------------------------------
// detect-renames (unit, fixture-driven)
// ----------------------------------------------------------------------------
const RENAMES_SCRIPT = join(SELF_DIR, "detect-renames.mjs");
function runRenames(manifest) {
const fix = mkdtempSync(join(tmpdir(), "renames-test-"));
const inputPath = join(fix, "window.json");
writeFileSync(inputPath, JSON.stringify(manifest));
const r = spawnSync(process.execPath, [RENAMES_SCRIPT, inputPath, "--json"], {
encoding: "utf8",
});
rmSync(fix, { recursive: true, force: true });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
function dirFiles(dir, suffixes, status) {
return suffixes.map((s) => ({ filename: `${dir}/${s}`, status, additions: 1, deletions: 0 }));
}
test("detect-renames: pure rename via file-content-overlap", () => {
const suffixes = ["index.ts", "client.ts", "types.ts", "config.ts", "auth.ts",
"session.ts", "stream.ts", "errors.ts", "README.md", "package.json"];
const files = [
...dirFiles("packages/adapters/old-name", suffixes, "removed"),
...dirFiles("packages/adapters/new-name", suffixes, "added"),
];
const r = runRenames({ files });
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.renames.length === 1, `expected 1 rename, got ${out.renames.length}: ${JSON.stringify(out.renames)}`);
const ren = out.renames[0];
assert(ren.from === "packages/adapters/old-name", `from=${ren.from}`);
assert(ren.to === "packages/adapters/new-name", `to=${ren.to}`);
assert(ren.signal === "file-content-overlap", `signal=${ren.signal}`);
assert(ren.confidence === "high", `confidence=${ren.confidence}`);
});
test("detect-renames: shared-prefix rename without file overlap", () => {
const removedSuffixes = ["old-a.ts", "old-b.ts", "old-c.ts", "old-d.ts", "old-e.ts"];
const addedSuffixes = ["new-a.ts", "new-b.ts", "new-c.ts", "new-d.ts", "new-e.ts"];
const files = [
...dirFiles("packages/adapters/cursor-local", removedSuffixes, "removed"),
...dirFiles("packages/adapters/cursor-cloud", addedSuffixes, "added"),
];
const r = runRenames({ files });
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.renames.length === 1, `expected 1 rename, got ${out.renames.length}`);
const ren = out.renames[0];
assert(ren.from === "packages/adapters/cursor-local", `from=${ren.from}`);
assert(ren.to === "packages/adapters/cursor-cloud", `to=${ren.to}`);
assert(ren.signal === "shared-prefix", `signal=${ren.signal}`);
assert(ren.confidence === "high" || ren.confidence === "medium", `confidence=${ren.confidence}`);
});
test("detect-renames: genuinely new dir (no match)", () => {
const files = dirFiles("packages/adapters/acpx-local",
["index.ts", "client.ts", "types.ts"], "added");
const r = runRenames({ files });
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.renames.length === 0, `expected 0 renames, got ${out.renames.length}`);
assert(out.added_dirs_genuinely_new.length === 1, `expected 1 new dir, got ${JSON.stringify(out.added_dirs_genuinely_new)}`);
assert(out.added_dirs_genuinely_new[0] === "packages/adapters/acpx-local",
`entry=${out.added_dirs_genuinely_new[0]}`);
});
test("detect-renames: genuine removal (no match)", () => {
const files = dirFiles("packages/adapters/legacy-thing",
["index.ts", "client.ts", "types.ts"], "removed");
const r = runRenames({ files });
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
assert(out.renames.length === 0, `expected 0 renames, got ${out.renames.length}`);
assert(out.removed_dirs_no_match.length === 1, `expected 1 orphan removal, got ${JSON.stringify(out.removed_dirs_no_match)}`);
assert(out.removed_dirs_no_match[0] === "packages/adapters/legacy-thing",
`entry=${out.removed_dirs_no_match[0]}`);
});
test("detect-renames: two candidates → file-overlap wins over shared-prefix", () => {
// The added dir 'packages/adapters/cursor-renamed' shares a prefix with
// 'cursor-local' (shared-prefix candidate) but its internal files are
// identical to 'completely-different' (file-overlap candidate).
const sharedSuffixes = ["index.ts", "client.ts", "types.ts", "auth.ts",
"stream.ts", "errors.ts", "config.ts", "README.md"];
const files = [
// shared-prefix candidate: same parent, similar leaf, different filenames
...dirFiles("packages/adapters/cursor-local",
["a.ts", "b.ts", "c.ts", "d.ts"], "removed"),
// file-overlap candidate: different leaf name, but matching internal files
...dirFiles("packages/adapters/completely-different", sharedSuffixes, "removed"),
// the added dir
...dirFiles("packages/adapters/cursor-renamed", sharedSuffixes, "added"),
];
const r = runRenames({ files });
assert(r.code === 0, `exit ${r.code}; stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
// The added dir should match the file-overlap candidate.
const renForAdded = out.renames.find((x) => x.to === "packages/adapters/cursor-renamed");
assert(renForAdded, `expected a rename for cursor-renamed, got: ${JSON.stringify(out.renames)}`);
assert(renForAdded.signal === "file-content-overlap",
`expected file-content-overlap, got ${renForAdded.signal}`);
assert(renForAdded.from === "packages/adapters/completely-different",
`from=${renForAdded.from}`);
});
// ----------------------------------------------------------------------------
// check-tag-accuracy (hermetic end-to-end: real git fixture repo + stubbed
// parent via PAPERCLIP_SYNC_FIXTURE_DIR — no network)
// ----------------------------------------------------------------------------
const TAGACC_SCRIPT = join(SELF_DIR, "check-tag-accuracy.mjs");
// A file-path claim in a doc page: `<prefix>/...ts` (verified iff the parent
// returns 200 at the tag). Using only file-path claims keeps verify-edit from
// reaching for the tree/env/route/adapter fetchers, so the whole run is stubbed
// by a handful of contents-*.json files.
function makeTagAccFixture() {
const dir = makeFixture();
copyFileSync(TAGACC_SCRIPT, join(dir, "scripts/sync/check-tag-accuracy.mjs"));
copyFileSync(VERIFY_SCRIPT, join(dir, "scripts/sync/verify-edit.mjs"));
copyFileSync(join(SELF_DIR, "anchor-map.json"), join(dir, "scripts/sync/anchor-map.json"));
const g = (...args) => {
const r = spawnSync("git", args, { cwd: dir, encoding: "utf8" });
if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr || r.stdout}`);
return r.stdout;
};
g("init", "-q");
g("config", "user.email", "[email protected]");
g("config", "user.name", "t");
g("config", "commit.gpgsign", "false");
// BASE: `existing.md` already links a pre-existing file path.
writeFile(dir, "docs/how-to/existing.md", "# Existing\n\nSee `packages/stale/pre.ts` for details.\n");
g("add", "-A");
g("commit", "-q", "-m", "base");
// HEAD: append a NEW file-path link to existing.md, add a leak page and a
// clean page.
writeFile(
dir,
"docs/how-to/existing.md",
"# Existing\n\nSee `packages/stale/pre.ts` for details.\n\nAlso see `packages/ghost/added.ts` now.\n",
);
writeFile(dir, "docs/how-to/ghost-leak.md", "# Ghost\n\nRuns from `packages/ghost/src/index.ts`.\n");
// real page: a .ts that DOES exist at the tag (200) plus a .md link that does
// not (404). The .md must be ignored (it's a doc link, not a product surface).
writeFile(
dir,
"docs/how-to/real-clean.md",
"# Real\n\nRuns from `packages/adapters/real/src/index.ts`. Config is `packages/adapters/real/config.md`.\n",
);
g("add", "-A");
g("commit", "-q", "-m", "head");
// Stubbed parent: only the real .ts resolves at the tag. Everything else 404s
// by omission.
const fixDir = join(dir, "_fixtures");
mkdirSync(fixDir, { recursive: true });
writeFileSync(join(fixDir, "repo.json"), JSON.stringify({ default_branch: "master" }));
writeFileSync(
join(fixDir, "contents-packages__adapters__real__src__index.ts-faketag.json"),
JSON.stringify({ content: "export {}\n" }),
);
return { dir, fixDir };
}
test("check-tag-accuracy: flags a post-tag leak, keeps in-tag page clean, scopes to added lines", () => {
const { dir, fixDir } = makeTagAccFixture();
const r = spawnSync(
process.execPath,
[join(dir, "scripts/sync/check-tag-accuracy.mjs"), "--tag", "faketag", "--base", "HEAD~1", "--head", "HEAD", "--json"],
{ cwd: dir, encoding: "utf8", env: { ...process.env, PAPERCLIP_SYNC_FIXTURE_DIR: fixDir } },
);
assert(r.status === 0, `gate should always exit 0, got ${r.status}. stderr=${r.stderr}`);
const out = JSON.parse(r.stdout);
const leakFiles = out.leaks.map((l) => l.file);
// The new page whose only surface is a tag-absent .ts is a leak.
assert(leakFiles.includes("docs/how-to/ghost-leak.md"), `ghost-leak.md should be a leak; leaks=${JSON.stringify(leakFiles)}`);
// The modified page is a leak because of its ADDED tag-absent .ts.
assert(leakFiles.includes("docs/how-to/existing.md"), `existing.md should be a leak; leaks=${JSON.stringify(leakFiles)}`);
// The page whose .ts exists at the tag (and whose only miss is a .md doc link) is NOT a leak.
assert(!leakFiles.includes("docs/how-to/real-clean.md"), `real-clean.md must not be a leak; leaks=${JSON.stringify(leakFiles)}`);
assert(!out.review.some((x) => x.file === "docs/how-to/real-clean.md"), "real-clean.md must not be in review (its .ts verifies; the .md link is ignored)");
// Added-line scoping: the pre-existing `pre.ts` (also 404) must NOT be flagged
// — only the added `added.ts`.
const ex = out.leaks.find((l) => l.file === "docs/how-to/existing.md");
const vals = ex.claims.map((c) => c.value);
assert(vals.includes("packages/ghost/added.ts"), `existing.md leak should cite the added path; got ${JSON.stringify(vals)}`);
assert(!vals.includes("packages/stale/pre.ts"), `pre-existing path must be scoped out of the leak; got ${JSON.stringify(vals)}`);
rmSync(dir, { recursive: true, force: true });
});
// ----------------------------------------------------------------------------
console.log("");
console.log(`Results: ${pass} passed, ${fail} failed.`);
if (fail > 0) {
for (const { name, error } of failures) {
console.log(`\n- ${name}\n ${error.stack || error.message}`);
}
process.exit(1);
}
process.exit(0);