mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip coordinates agents that make and verify repository changes. > - Local verification currently defaults to the complete Vitest suite. > - Contributor instructions also require broad local checks at each PR handoff. > - CI already owns conservative affected-test selection and broad final-revision checks. > - This pull request adds local selection and pass reuse from those existing contracts. > - Agents can get focused feedback and use CI to close the remaining coverage gaps. ## Linked Issues or Issue Description **Subsystem affected** Developer verification scripts and contributor guidance. Related selection work: #18398 and #18464. **Problem or motivation** An agent that repeats an unchanged local verification step starts the complete suite again. The handoff guidance also duplicates broad CI coverage locally. **Proposed solution** Add `pnpm verify` for the actual working tree. Select affected Vitest specifications with the CI task policy and dependency graph. Reuse successful checks whose inputs are modeled. Run opaque checks fresh. Report exit 2 when broader verification is required. Update the contributor contract to accept applicable final-revision CI coverage. **Alternatives considered** Plain `vitest related` omits filesystem and opaque dependencies. Commit-only cache keys miss dirty files. Running full local suites at every handoff repeats CI work. **Roadmap alignment** This is repository development tooling. It does not change the product roadmap. ## What Changed - Add `pnpm verify`, `--explain`, `--base`, `--full`, and `--no-cache`. - Reuse CI selection and exact runner ownership for dirty, staged, committed, deleted, renamed, and untracked inputs. - Keep native server suites and serialized suites in their local owners. Keep six sequential chat shards. - Cache selected passes within one worktree. Hash source, fixtures, installed package contents, dependency bindings and installation metadata, dotenv files, child environment, toolchains, platform, and selection. - Run opaque consumers fresh. Preserve failed, interrupted, mutated-input, and partial-coverage outcomes. - Update AGENTS.md, CONTRIBUTING.md, and DEVELOPING.md guidance. Record the shared `verify` command as the sole exception to the package-script rule. Register the new tests in the existing CI contract-test command. - Add optional root/environment inputs to shared graph helpers. Existing CI defaults stay the same. ## Verification - Fourteen new integration tests pass locally across focused runs with real Vitest selection and execution; CI colors are covered. - 51 existing targeted selector/input, project-isolation, and stable-runner checks passed. Four relevant runner contracts also pass after the workspace change. - Repeated unchanged execution skips reusable tests. Source, environment, installed code, dependency bindings, and new untracked tests invalidate results. - Installed-code mutation with a restored modification time still invalidates a cached pass. Internal skill-directory links preserve reuse. - Workspace B/C tests prove discovery, reusable/fresh partition execution, pass reuse, and external-input failure. Chat selection produces exactly six single-worker partitions. - A deliberate unrelated failing test is omitted by narrow selection and caught by `--full`. - Red/green regressions prove fingerprints preserve file boundaries and reject edits made between diff discovery and snapshotting. - Opaque external-file changes still fail on a subsequent run. Source mutation and process interruption cannot publish reusable passes. - `pnpm verify --explain` works in the checkout and requests broad CI coverage for this tooling change. No duplicate full local suite was started. - [Final-revision CI](https://github.com/paperclipai/paperclip/actions/runs/42117291789) passed on `ed85d96ddff82c05c325948680b593b0f48c473d` (attempt 2). The scoped Canary retry preserved all passing test/build results. - [Greptile review](https://github.com/paperclipai/paperclip/pull/19345#issuecomment-6670050175) is 5/5; all four review threads are resolved. - The additional Paperclip review concluded neutral because its execution stopped; it produced no assessment. ## Risks - Pass reuse depends on conservative dependency classification. Opaque consumers, external database/live settings, and non-isolated projects retain fresh or broad coverage. - Local passes cover selected Vitest checks. Required CI, build/typecheck, release, browser, and platform acceptance remain separate obligations. - Cross-worktree caching and cross-agent scheduling are outside this change. - The initial installation scan reads dependency contents; stat-validated, worktree-local digest memoization avoids rereading unchanged contents. Subsequent scans still inspect file identity and nanosecond change/modification times. - No dependency lockfile or workflow changes. ## Model Used OpenAI Codex, `gpt-6.1-sol`, max reasoning, with local shell and file tools. Context window size is not exposed by this host. ## 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 `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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>
131 lines
7.1 KiB
JavaScript
131 lines
7.1 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { appendFileSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { refineTestPlan, selectionDurations } from "./test-selection-plan.mjs";
|
|
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
|
|
import { mergeDiff } from "../../.github/scripts/ci-diff.mjs";
|
|
import { selectProjects } from "./select-projects.mjs";
|
|
|
|
export function selectionEligibility(plan, root = process.cwd()) {
|
|
if (plan?.version !== 1 || plan.mode !== "enabled" || plan.forceReason) return "Full/shadow/forced task plan";
|
|
if (!Array.isArray(plan.changed) || !plan.changed.length) return "Incomplete comparison";
|
|
const changed = productInputs(plan.changed);
|
|
if (!changed.length) return "Documentation-only diff";
|
|
for (const file of changed) {
|
|
// Business modules can contain "config"/"setup" in their names. The graph
|
|
// separately tracks actual setup and configuration dependencies.
|
|
if (!/^(?:ui\/src|server\/src|cli\/src|packages\/.+\/src)\/.+\.[cm]?[jt]sx?$/.test(file)
|
|
|| /(?:^|\/)(?:vite|vitest)\.(?:config|setup)\.[cm]?[jt]sx?$|\.d\.[cm]?ts$/i.test(file)
|
|
|| file.split("/").includes("..")) return `Broad input: ${file}`;
|
|
try {
|
|
if (!lstatSync(path.join(root, file)).isFile()) return `Non-regular source: ${file}`;
|
|
} catch { return `Deleted or missing source: ${file}`; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function runnerInventory(group) {
|
|
const args = group === "serialized" ? ["--mode", "serialized", "--shard-index", "0", "--shard-count", "1"]
|
|
: ["--mode", "general", "--group", group,
|
|
...(group === "general-server-without-chat" ? ["--shard-index", "0", "--shard-count", "1"] : [])];
|
|
const env = { ...process.env, GITHUB_WORKFLOW: "PR" };
|
|
delete env.PAPERCLIP_CI_TEST_SELECTION;
|
|
return JSON.parse(execFileSync(process.execPath, ["scripts/run-vitest-stable.mjs", ...args, "--dry-run"],
|
|
{ env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }));
|
|
}
|
|
|
|
export async function createTestSelection(plan, head) {
|
|
const reason = selectionEligibility(plan);
|
|
if (reason) throw new Error(reason);
|
|
const groups = new Set();
|
|
for (const job of ["general_tests", "verify_serialized_server"]) {
|
|
if (plan.jobs[job].run) for (const entry of plan.jobs[job].matrix.include) groups.add(entry.group ?? "serialized");
|
|
}
|
|
groups.delete("general-chat"); // Keep its line sharding and DB ownership intact.
|
|
const owners = {}, projects = new Set(), inventories = new Map();
|
|
const inventoryFor = group => {
|
|
if (!inventories.has(group)) inventories.set(group, runnerInventory(group));
|
|
return inventories.get(group);
|
|
};
|
|
for (const group of groups) {
|
|
const inventory = inventoryFor(group);
|
|
if (group === "serialized" || group === "general-server-without-chat") {
|
|
owners[group] = { "@paperclipai/server": group === "serialized"
|
|
? inventory.selectedSerializedSuites : inventory.selectedGeneralServerSuites };
|
|
projects.add("@paperclipai/server");
|
|
} else {
|
|
owners[group] = Object.fromEntries(inventory.workspaceProjects.map((project) => [project, null]));
|
|
for (const project of inventory.workspaceProjects) projects.add(project);
|
|
}
|
|
}
|
|
const changed = productInputs(plan.changed);
|
|
const graphs = await selectProjects({ projects: [...projects], changed });
|
|
for (const graph of graphs) {
|
|
const { project } = graph;
|
|
console.log(`${project}: ${graph.selected.length}/${graph.inventory.length} files, ${graph.alwaysRun.length} retained conservatively, ${graph.seconds.toFixed(1)}s`);
|
|
for (const owner of Object.values(owners)) if (owner[project] === null) owner[project] = graph.inventory;
|
|
}
|
|
const covered = new Set(graphs.flatMap((graph) => graph.coveredChanges));
|
|
for (const file of changed) assert.ok(covered.has(file), `Change outside the resolved test graphs: ${file}`);
|
|
const server = graphs.find((graph) => graph.project === "@paperclipai/server");
|
|
if (server) {
|
|
const general = inventoryFor("general-server-without-chat");
|
|
const native = inventoryFor("general-server-native-runner");
|
|
assert.deepEqual([...general.selectedGeneralServerSuites, ...general.selectedSerializedSuites,
|
|
...native.selectedGeneralServerSuites, "server/src/__tests__/chat-channels.integration.test.ts"].sort(),
|
|
server.inventory, "Server inventory must be completely covered by existing lane ownership");
|
|
}
|
|
return { ...refineTestPlan({ plan, head, owners, graphs, durations: selectionDurations() }), graphs };
|
|
}
|
|
|
|
async function main() {
|
|
const plan = JSON.parse(process.env.CI_PLAN || "null");
|
|
const output = (key, value) => {
|
|
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
|
};
|
|
if (process.argv.includes("--eligible")) {
|
|
const reason = selectionEligibility(plan);
|
|
console.log(reason ?? "Source diff is eligible for graph selection");
|
|
output("enabled", !reason);
|
|
return;
|
|
}
|
|
const directory = ".ci-test-selection";
|
|
mkdirSync(directory, { recursive: true });
|
|
const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).trim();
|
|
let result;
|
|
try {
|
|
const head = git("rev-parse", "HEAD");
|
|
assert.equal(head, process.env.GITHUB_SHA, "Selection must describe the tested merge commit");
|
|
assert.ok(plan.diff, "Missing trusted merge diff");
|
|
const local = mergeDiff(head, plan.diff.prHead);
|
|
assert.deepEqual(local.diff, plan.diff, "Selection must use the trusted merge parents");
|
|
assert.deepEqual(local.changed, [...new Set(plan.changed)].sort(), "Local diff disagrees with the trusted routing diff");
|
|
assert.equal(git("status", "--porcelain", "--untracked-files=normal"), "", "Source inputs changed during dependency setup");
|
|
process.env.NODE_ENV = "test";
|
|
process.env.VITEST = "true";
|
|
result = await createTestSelection(plan, head);
|
|
writeFileSync(`${directory}/manifest.json`, JSON.stringify(result.manifest));
|
|
writeFileSync(`${directory}/audit.json`, JSON.stringify(result.graphs, null, 2));
|
|
output("selected", "true");
|
|
} catch (error) {
|
|
console.warn(`Using the complete task plan: ${error.message}`);
|
|
result = { plan: { ...plan, testSelectionFallback: error.message } };
|
|
output("selected", "false");
|
|
}
|
|
writeFileSync(`${directory}/plan.json`, JSON.stringify(result.plan));
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
const stats = result.plan.testSelection?.stats;
|
|
appendFileSync(process.env.GITHUB_STEP_SUMMARY, stats
|
|
? ["### Vitest test selection", "", "| Lane | Test files | Jobs |", "| --- | ---: | ---: |",
|
|
...stats.map((s) => `| ${s.group} | ${s.selected}/${s.total} | ${s.jobs}/${s.beforeJobs} |`), "",
|
|
"Filesystem/process/opaque consumers are retained. Chat and native Runner lanes keep their existing ownership.", ""].join("\n")
|
|
: `### Vitest test selection\n\nFull task plan: ${result.plan.testSelectionFallback}\n`);
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
}
|