mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:12:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors need fast, reliable CI feedback. > - Diff-aware CI selects test files and partitions them into runner jobs. > - Stale cost estimates put several slow suites on the same runner. Broad imports also couple small tests to large modules. > - This PR refreshes test costs, runs independent graph discovery in parallel, and narrows three dependency boundaries. > - Selection safeguards and CODEOWNER-only AWS routing stay in force. ## Linked Issues or Issue Description Refs #18398. This follows the audit of [PR #18427's verification run](https://github.com/paperclipai/paperclip/actions/runs/41169978784). The audit found 403 selected general-server files without timing weights. Three suites measured at 232s, 122s and 116s shared one shard because each received a roughly 2s fallback weight. Local graph discovery across four projects took 43.7s serially and 21.7s with two workers. A reverse-order repeat took 37.7s serially and 22.6s with two workers. Both comparisons produced identical selection results. This is a local measurement, not a claim about production CI speed. ## What Changed - Refresh general-server costs for 1,285 of 1,309 current files and serialized costs for all 163 files from two successful protected-master Cloud readiness runs. Retain prior costs for unmeasured files, including dedicated chat and native-runner suites. - Record passing server file costs, including collection, setup and environment preparation. A weekly hosted workflow validates trusted source runs and opens a timing-only PR. It tests shard coverage, pauses while a refresh PR is open, and never merges automatically. - Discover at most two project graphs concurrently in isolated, heap-limited Node processes. Cache repeated inventory reads within one selection. Worker errors and timeouts retain the full task plan. - Fetch only the merge commit and parents for the selector. A real shallow-clone regression proves the complete diff still includes more than 300 files, renames and deletions. - Share the identical log viewer and run details between both AgentDetail pages. The viewer test imports the shared implementation and runs each of its 13 distinct cases once. - Extract the pure API error class while preserving the API client's public exports. Import delivery work signals through their existing narrow DB export. ## Verification - PASS: `pnpm -r typecheck` and `pnpm build`. - PASS: 284 CI selection, timing, shallow-diff, sharding and routing tests. - PASS: 74 further cache, routing and graph-proof tests. - PASS: 94 targeted UI assertions across the viewer, run invocation, API errors/client and progress helpers. All 76 assertions in the 12 server suites made independently selectable also pass. - PASS: `pnpm check:token-gates`, module-boundary checks, YAML parsing and `git diff --check`. - PASS: [Full PR CI and required `ci / verify` aggregate](https://github.com/paperclipai/paperclip/actions/runs/41206683345), including all 12 general-server shards, all nine serialized shards, workspace groups, chat suites, Runner lanes and build checks. - PASS: [GitHub hosted real-workspace selector and cache/routing qualification](https://github.com/paperclipai/paperclip/actions/runs/41206683205). - PASS: Both Greptile and Paperclip reviews are 5/5 with no actionable findings or inline comments on `7dc65c51b8`. The PR has no merge conflicts. - PASS: The production refresh parser accepted all 107 structured file-cost records from a real successful server CI job, including setup/loading cost. - Local `pnpm test:run` completed all six chat shards successfully. The duplicate monolithic local run was stopped after the complete remote suite passed; it was not completed locally. The focused log-viewer command improved from 18.84s (26 duplicate variant cases) to 8.49s (all 13 distinct behavior cases). The dependency audit confirms 12 server files and the API-error test no longer need conservative always-run retention. Repartitioning the audited PR with the new costs changes the slowest shard from 810s to 591s of summed historical test execution (1,124 of 1,142 files measured). This is a workload simulation, not measured new CI wall time. ## Risks - Two graph processes use more peak memory. Each has a 2 GiB heap limit and a three-minute timeout. Any failure preserves the original task plan. Real workspace qualification runs in GitHub CI. - Historical logs contain test/hook costs only. New reporter records add loading/setup costs after merge. The manifests identify measurement coverage and source runs. These weights change placement, never permission to skip tests. - Runner load adds timing noise. The refresh uses medians and requires at least 90% measurement coverage in each lane. - GitHub may require approval to run checks on a PR created with `GITHUB_TOKEN`. Refreshes remain reviewable and do not merge themselves. - The shared viewer is a mechanical extraction. Both pages retain their existing exports, and the behavior assertions remain covered. AWS runner authorization is unchanged. ## Model Used OpenAI Codex, GPT-6, with repository inspection, reasoning, code execution and GitHub tools. The exact deployment ID and context-window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub references) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused checks and six chat shards; complete suite passed in CI as detailed above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
305 lines
18 KiB
JavaScript
305 lines
18 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { comparisonPaths, planTasks } from "../ci-plan.mjs";
|
|
import { mergeDiff } from "../ci-diff.mjs";
|
|
|
|
const root = new URL("../../../", import.meta.url);
|
|
const config = JSON.parse(readFileSync(new URL("turbo.json", root), "utf8"));
|
|
const lanes = JSON.parse(readFileSync(new URL(".github/ci-task-matrix.json", root), "utf8"));
|
|
const plan = (paths, options = {}) => planTasks({ config, lanes, paths, ...options });
|
|
const selected = (result, job) => result.jobs[job].run;
|
|
const selectedTasks = (result) => new Set(Object.values(result.jobs).filter((job) => job.run)
|
|
.flatMap((job) => job.matrix.include.map((entry) => entry.task)));
|
|
|
|
test("trusted workflow fallbacks preserve every declared partition and command", () => {
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
for (const [job, entries] of Object.entries(lanes)) {
|
|
const block = workflow.split(`\n ${job}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
|
|
const normalized = block.replaceAll("(needs.test_selection.outputs.plan || needs.gate.outputs.plan)", "needs.gate.outputs.plan");
|
|
assert.ok(normalized.includes(`fromJSON(needs.gate.outputs.plan).jobs.${job}.run`), job);
|
|
if (!block.includes(" matrix:")) { assert.equal(entries.length, 1); continue; }
|
|
const fallback = block.match(/matrix:.*\|\| '(.*)'\) \}\}/)?.[1];
|
|
assert.ok(fallback, `${job}: missing full fallback`);
|
|
assert.deepEqual(JSON.parse(fallback), { include: entries }, job);
|
|
}
|
|
});
|
|
|
|
test("a UI component retains browser and build coverage without unrelated server/Rust jobs", () => {
|
|
const result = plan(["ui/src/components/Sidebar.tsx"]);
|
|
assert.equal(selected(result, "build"), true);
|
|
assert.equal(selected(result, "e2e_shards"), true);
|
|
assert.equal(selected(result, "verify_serialized_server"), false);
|
|
assert.deepEqual([...selectedTasks(result)].filter((task) => /server|chat|runner/.test(task)), []);
|
|
assert.equal(result.jobs.general_tests.selected, 2);
|
|
});
|
|
|
|
test("source inspected across package boundaries selects its consumers", () => {
|
|
for (const file of ["ui/src/lib/issue-execution-policy.ts", "ui/src/index.css", "ui/src/pages/CompanyExport.tsx",
|
|
"ui/src/components/MarkdownBody.tsx"]) {
|
|
const tasks = selectedTasks(plan([file]));
|
|
assert.ok(tasks.has("ci:server"), file);
|
|
assert.ok(tasks.has("ci:runner-vitest"), file);
|
|
}
|
|
assert.ok(selectedTasks(plan(["ui/public/app-logos/example.svg"])).has("ci:workspaces-b"));
|
|
});
|
|
|
|
test("repository-wide guidance guards run uncached for every scanned UI file type", () => {
|
|
const source = readFileSync(new URL("server/src/__tests__/cli-invocation-safety.test.ts", root), "utf8");
|
|
const extensions = [...source.split("const SCAN_EXTENSIONS = new Set([")[1].split("]);")[0].matchAll(/"(\.[a-z]+)"/g)]
|
|
.map((match) => match[1]);
|
|
assert.ok(extensions.length > 0);
|
|
for (const extension of extensions) {
|
|
for (const folder of ["ui/src/components", "ui/src/pages", "ui/src/hooks", "ui/public"]) {
|
|
assert.ok(plan([`${folder}/guidance${extension}`]).jobs.typecheck_release_registry.run);
|
|
}
|
|
}
|
|
assert.equal(config.tasks["//#ci:typecheck"].cache, false);
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
const job = workflow.split("\n typecheck_release_registry:\n")[1].split("\n general_tests:\n")[0];
|
|
assert.match(job, /- name: Verify repository-wide CLI guidance safety\n run: pnpm exec vitest run --project @paperclipai\/server server\/src\/__tests__\/cli-invocation-safety.test.ts/);
|
|
assert.ok(!job.includes("uses: ./.github/actions/run-ci-task"));
|
|
});
|
|
|
|
test("shared contracts, runtime prompts, protocol inputs and fixtures reach consumers", () => {
|
|
const shared = selectedTasks(plan(["packages/shared/src/types/issue.ts"]));
|
|
for (const task of ["ci:server", "ci:db", "ci:workspaces-a", "ci:workspaces-b", "ci:serialized", "ci:e2e"]) {
|
|
assert.ok(shared.has(task), task);
|
|
}
|
|
assert.ok(selectedTasks(plan(["server/src/onboarding-assets/default/AGENTS.md"])).has("ci:runner-vitest"));
|
|
assert.ok(selectedTasks(plan(["packages/paperclip-runner/protocol/schema.json"])).has("ci:runner-rust"));
|
|
for (const file of ["tests/runner-e2e/fixtures/prompt.md", "scripts/run-vitest-stable.mjs", "pnpm-lock.yaml"]) {
|
|
assert.ok(Object.values(plan([file]).jobs).every((job) => job.selected === job.total), file);
|
|
}
|
|
});
|
|
|
|
test("renames out of source and deletions cannot masquerade as release notes", () => {
|
|
const paths = comparisonPaths({ files: [
|
|
{ filename: "releases/v2026.1029.0.md", previous_filename: "packages/shared/src/constants.ts", status: "renamed" },
|
|
{ filename: "server/src/deleted.ts", status: "removed" },
|
|
] });
|
|
assert.ok(selectedTasks(plan(paths)).has("ci:server"));
|
|
assert.ok(selectedTasks(plan(paths)).has("ci:db"));
|
|
});
|
|
|
|
test("unclassified paths and uncertain comparisons select full verification", () => {
|
|
for (const paths of [[], ["new-root/file.ts"], ["../escaped"]]) {
|
|
const result = plan(paths);
|
|
assert.ok(result.forceReason);
|
|
assert.ok(Object.values(result.jobs).every((job) => job.selected === job.total));
|
|
}
|
|
for (const files of [null, [], [{ status: "modified" }], [{ filename: "a", status: "renamed" }],
|
|
Array.from({ length: 300 }, () => ({ filename: "ui/src/test.ts" }))]) {
|
|
assert.throws(() => comparisonPaths({ files }));
|
|
}
|
|
});
|
|
|
|
test("shadow mode explains savings but runs every task; force overrides selection", () => {
|
|
const shadow = plan(["ui/src/components/Sidebar.tsx"], { mode: "shadow" });
|
|
assert.equal(shadow.jobs.verify_serialized_server.proposed, 0);
|
|
assert.equal(shadow.jobs.verify_serialized_server.selected, 9);
|
|
for (const options of [{ mode: "full" }, { forceReason: "Stack" }]) {
|
|
assert.ok(Object.values(plan(["ui/src/components/Sidebar.tsx"], options).jobs)
|
|
.every((job) => job.selected === job.total));
|
|
}
|
|
});
|
|
|
|
test("cache-affecting definition changes force execution across successive PR pushes", () => {
|
|
for (const file of ["turbo.json", "package.json", "packages/db/package.json", "scripts/ci/run-task.mjs",
|
|
".github/actions/run-ci-task/action.yml"]) {
|
|
for (const paths of [[file], [file, "packages/db/src/schema/issues.ts"]]) {
|
|
const result = plan(paths);
|
|
assert.match(result.forceReason, /Global CI input changed/);
|
|
assert.ok(Object.values(result.jobs).every((job) => job.selected === job.total));
|
|
}
|
|
}
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
assert.equal((workflow.match(/force:.*\.forceReason != null/g) ?? []).length, 3);
|
|
const action = readFileSync(new URL(".github/actions/run-ci-task/action.yml", root), "utf8");
|
|
assert.match(action, /CI_FORCE_TASKS: \$\{\{ inputs.force \}\}/);
|
|
assert.equal((action.match(/if:.*inputs.force != 'true'/g) ?? []).length, 2);
|
|
});
|
|
|
|
test("task dependencies propagate input changes and unsupported graph syntax fails closed", () => {
|
|
const fixture = { globalDependencies: [], tasks: {
|
|
"//#build": { inputs: ["lib/**"] }, "//#test": { inputs: ["app/**"], dependsOn: ["//#build"] },
|
|
} };
|
|
assert.equal(planTasks({ config: fixture, lanes: { tests: [{ task: "test" }] }, paths: ["lib/index.ts"] }).jobs.tests.run, true);
|
|
fixture.tasks["//#build"].dependsOn = ["//#test"];
|
|
assert.throws(() => planTasks({ config: fixture, lanes: { tests: [{ task: "test" }] }, paths: ["app/index.ts"] }), /Cyclic/);
|
|
assert.throws(() => plan(["ui/src/a.ts"], { mode: "typo" }), /Invalid CI mode/);
|
|
});
|
|
|
|
test("local planner reads a real Git rename and handles a missing revision conservatively", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-plan-"));
|
|
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
try {
|
|
git("init", "-q"); git("config", "user.name", "CI Test"); git("config", "user.email", "ci@example.invalid");
|
|
mkdirSync(path.join(directory, "packages/shared/src"), { recursive: true });
|
|
writeFileSync(path.join(directory, "packages/shared/src/input.ts"), "original\n");
|
|
git("add", "."); git("commit", "-qm", "base");
|
|
const base = git("rev-parse", "HEAD").trim();
|
|
mkdirSync(path.join(directory, "releases"));
|
|
git("mv", "packages/shared/src/input.ts", "releases/v1.md"); git("commit", "-qm", "rename");
|
|
for (const revision of [base, "missing-revision"]) {
|
|
const result = spawnSync(process.execPath, [new URL("../ci-plan.mjs", import.meta.url).pathname, revision], {
|
|
cwd: directory, encoding: "utf8", env: { ...process.env, GITHUB_OUTPUT: "", GITHUB_STEP_SUMMARY: "",
|
|
CI_TASK_CONFIG: new URL("turbo.json", root).pathname, CI_TASK_MATRIX: new URL(".github/ci-task-matrix.json", root).pathname },
|
|
});
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const resultPlan = JSON.parse(result.stdout);
|
|
assert.ok(selectedTasks(resultPlan).has("ci:server"));
|
|
if (revision !== base) assert.match(resultPlan.forceReason, /Diff unavailable/);
|
|
}
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("required aggregates reject omitted, failed and cancelled selected work", () => {
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
for (const id of ["verify", "e2e"]) {
|
|
const block = workflow.split(`\n ${id}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
|
|
const code = block.split(" node --input-type=module <<'NODE'\n")[1].split("\n NODE")[0]
|
|
.split("\n").map((line) => line.slice(10)).join("\n");
|
|
const names = /needs: \[([^\]]+)\]/.exec(block)[1].split(", ");
|
|
const resultPlan = plan(["ui/src/components/Sidebar.tsx"]);
|
|
const results = Object.fromEntries(names.map((name) => [name, { result:
|
|
name === "gate" || name === "policy" || resultPlan.jobs[name]?.run ? "success" : "skipped" }]));
|
|
const run = () => spawnSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8",
|
|
env: { ...process.env, FULL_CI: "true", CI_PLAN: JSON.stringify(resultPlan), CI_RESULTS: JSON.stringify(results) } });
|
|
assert.equal(run().status, 0);
|
|
for (const name of names) {
|
|
// Optional analysis can fail or be skipped; the unchanged task plan
|
|
// still has to pass. Selected-plan cases are exercised separately.
|
|
if (name === "test_selection") continue;
|
|
const previous = results[name].result;
|
|
for (const bad of previous === "success" ? ["failure", "cancelled", "skipped"] : ["success"]) {
|
|
results[name].result = bad;
|
|
assert.notEqual(run().status, 0, `${id}: ${name} must reject ${bad}`);
|
|
}
|
|
results[name].result = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("the real routing step uses pinned trusted definitions and defaults to full on fetch failure", () => {
|
|
const workflow = readFileSync(new URL(".github/workflows/ci-routing.yml", root), "utf8");
|
|
const block = workflow.split(" - name: Plan affected CI tasks from trusted definitions\n")[1];
|
|
const shell = block.split(" run: |\n")[1].split("\n").map((line) => line.slice(10)).join("\n");
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-routing-plan-"));
|
|
try {
|
|
const repository = path.join(directory, "source");
|
|
mkdirSync(repository);
|
|
const gitBin = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
|
const git = (...args) => execFileSync(gitBin, args, { cwd: repository, encoding: "utf8" }).trim();
|
|
git("init", "-q", "-b", "main"); git("config", "user.name", "CI"); git("config", "user.email", "ci@example.invalid");
|
|
writeFileSync(path.join(repository, "base"), "base"); git("add", "."); git("commit", "-qm", "base");
|
|
git("checkout", "-qb", "pr");
|
|
mkdirSync(path.join(repository, "ui/src/components"), { recursive: true });
|
|
writeFileSync(path.join(repository, "ui/src/components/Sidebar.tsx"), "change");
|
|
git("add", "."); git("commit", "-qm", "PR");
|
|
const prHead = git("rev-parse", "HEAD");
|
|
git("checkout", "-q", "main");
|
|
writeFileSync(path.join(repository, "unrelated-master"), "already merged");
|
|
git("add", "."); git("commit", "-qm", "advance base");
|
|
const base = git("rev-parse", "HEAD");
|
|
git("merge", "--no-ff", "pr", "-m", "tested merge");
|
|
const merge = git("rev-parse", "HEAD");
|
|
writeFileSync(path.join(directory, "git"), `#!/usr/bin/env node
|
|
const {spawnSync}=require('node:child_process');
|
|
if(process.env.TEST_GIT_FAILURE==='true')process.exit(1);
|
|
const args=process.argv.slice(2).map(a=>a==='https://github.com/paperclipai/paperclip.git'?process.env.TEST_GIT_REPOSITORY:a);
|
|
const r=spawnSync(${JSON.stringify(gitBin)},args,{stdio:'inherit'});process.exit(r.status??1);
|
|
`);
|
|
chmodSync(path.join(directory, "git"), 0o755);
|
|
const gh = path.join(directory, "gh");
|
|
writeFileSync(gh, `#!/usr/bin/env node
|
|
const fs = require('node:fs'), path = require('node:path');
|
|
const endpoint = process.argv.find(arg => arg.startsWith('/repos/'));
|
|
if (endpoint?.endsWith('/git/ref/heads/master')) console.log('a'.repeat(40));
|
|
else if (endpoint?.includes('/contents/')) {
|
|
if (process.env.TEST_FETCH_FAILURE === 'true') process.exit(1);
|
|
const [file, query] = endpoint.split('/contents/')[1].split('?');
|
|
if (query !== 'ref=' + 'a'.repeat(40)) process.exit(2);
|
|
process.stdout.write(fs.readFileSync(path.join(process.env.TEST_TRUSTED_ROOT, file)));
|
|
}
|
|
else process.exit(3);
|
|
`);
|
|
chmodSync(gh, 0o755);
|
|
for (const overrides of [{}, { TEST_FETCH_FAILURE: "true" }, { TEST_GIT_FAILURE: "true" }, { PROFILE_CI: "true" }, { STACK_JSON: "{}" }, { FULL_CI: "false" }]) {
|
|
const output = path.join(directory, "output");
|
|
writeFileSync(output, "");
|
|
const result = spawnSync("bash", ["-c", shell], { encoding: "utf8", env: {
|
|
...process.env, PATH: `${directory}:${process.env.PATH}`, TEST_TRUSTED_ROOT: root.pathname,
|
|
RUNNER_TEMP: directory, FULL_CI: "true", PROFILE_CI: "false", FORCE_FULL_CI: "false",
|
|
CI_INCREMENTAL_MODE: "enabled", STACK_JSON: "null", EVENT_BASE_SHA: "b".repeat(40), EVENT_HEAD_SHA: prHead, EVENT_MERGE_SHA: merge,
|
|
TEST_GIT_REPOSITORY: repository,
|
|
GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: "", ...overrides,
|
|
} });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const value = readFileSync(output, "utf8").trim();
|
|
if (overrides.TEST_FETCH_FAILURE || overrides.TEST_GIT_FAILURE || overrides.FULL_CI === "false") assert.equal(value, "plan=");
|
|
else {
|
|
assert.notEqual(value, "plan=", `${JSON.stringify(overrides)}: ${result.stderr}`);
|
|
const resultPlan = JSON.parse(value.slice("plan=".length));
|
|
assert.equal(resultPlan.jobs.general_tests.selected, overrides.PROFILE_CI || overrides.STACK_JSON ? 19 : 2);
|
|
assert.equal(resultPlan.diff.base, base);
|
|
assert.deepEqual(resultPlan.changed, ["ui/src/components/Sidebar.tsx"]);
|
|
}
|
|
}
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
|
|
test("audited prose does not widen source selection, but guidance still runs its uncached guard", () => {
|
|
const source = "ui/src/components/Sidebar.tsx";
|
|
const sourcePlan = plan([source]);
|
|
for (const doc of ["doc/DEVELOPING.md", "doc/DATABASE.md", "doc/plans/2026-10-30-example.md"]) {
|
|
const mixed = plan([source, doc]);
|
|
assert.equal(mixed.forceReason, null);
|
|
assert.deepEqual(mixed.jobs, sourcePlan.jobs);
|
|
assert.deepEqual(mixed.documentation, [doc]);
|
|
}
|
|
const guidance = plan(["doc/DEVELOPING.md"]);
|
|
assert.deepEqual([...selectedTasks(guidance)], ["ci:typecheck"]);
|
|
assert.equal(config.tasks["//#ci:typecheck"].cache, false);
|
|
assert.deepEqual([...selectedTasks(plan(["doc/plans/2026-10-30-example.md"]))], []);
|
|
for (const file of ["docs/api/issues.md", "doc/unknown.md", "doc/plans/tool.sh", "skills/paperclip/SKILL.md",
|
|
"server/src/onboarding-assets/default/AGENTS.md", "tests/fixtures/example.md"]) {
|
|
assert.ok(selectedTasks(plan([source, file])).has("ci:server"), file);
|
|
}
|
|
});
|
|
|
|
test("complete merge trees handle more than 300 files, base advancement, renames and deletions", (t) => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-merge-diff-"));
|
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim();
|
|
const write = (file, value) => { mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }); writeFileSync(path.join(directory, file), value); };
|
|
const commit = () => { git("add", "."); git("commit", "-qm", "fixture"); return git("rev-parse", "HEAD"); };
|
|
git("init", "-q", "-b", "main"); git("config", "user.name", "CI"); git("config", "user.email", "ci@example.invalid");
|
|
write("ui/src/deleted.ts", "old"); write("ui/src/renamed.ts", "rename"); const staleBase = commit();
|
|
git("checkout", "-qb", "pr");
|
|
for (let index = 0; index < 350; index++) write(`ui/src/component-${index}.ts`, "new");
|
|
git("rm", "ui/src/deleted.ts");
|
|
mkdirSync(path.join(directory, "doc/plans"), { recursive: true });
|
|
git("mv", "ui/src/renamed.ts", "doc/plans/2026-10-30-moved.md");
|
|
const prHead = commit();
|
|
git("checkout", "-q", "main"); write(".github/workflows/unrelated.yml", "already merged CI change"); const base = commit();
|
|
git("merge", "--no-ff", "pr", "-m", "tested merge"); const head = git("rev-parse", "HEAD");
|
|
const result = mergeDiff(head, prHead, directory);
|
|
assert.equal(result.diff.base, base); assert.notEqual(result.diff.base, staleBase);
|
|
assert.equal(result.changed.length, 353);
|
|
assert.ok(result.changed.includes("ui/src/deleted.ts")); assert.ok(result.changed.includes("ui/src/renamed.ts"));
|
|
assert.ok(!result.changed.includes(".github/workflows/unrelated.yml"));
|
|
assert.equal(plan(result.changed).forceReason, null);
|
|
const shallow = path.join(directory, "shallow");
|
|
git("clone", "--quiet", "--depth", "2", `file://${directory}`, shallow);
|
|
assert.equal(execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: shallow, encoding: "utf8" }).trim(), "true");
|
|
assert.deepEqual(mergeDiff(head, prHead, shallow), result);
|
|
assert.throws(() => mergeDiff(head, base, directory), /expected PR head/);
|
|
assert.throws(() => mergeDiff(prHead, prHead, directory), /merge commit/);
|
|
assert.throws(() => mergeDiff("f".repeat(40), prHead, directory));
|
|
});
|