mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +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>
97 lines
4.5 KiB
JavaScript
97 lines
4.5 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { lstatSync, readFileSync, realpathSync, readlinkSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { sourceFiles } from "../ci/execution-plan.mjs";
|
|
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
|
|
import { installedInputs } from "./installed-inputs.mjs";
|
|
|
|
export const protocolVersion = 1;
|
|
|
|
export function git(root, ...args) {
|
|
return execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"] });
|
|
}
|
|
|
|
export function localEnvironment(env = process.env) {
|
|
const result = { ...env };
|
|
delete result.GITHUB_WORKFLOW;
|
|
delete result.PAPERCLIP_CI_TEST_SELECTION;
|
|
delete result.PAPERCLIP_CI_SELECTION_HASH;
|
|
return result;
|
|
}
|
|
|
|
export function comparison(root, requestedBase) {
|
|
let base;
|
|
for (const ref of requestedBase ? [requestedBase] : ["origin/master", "master"]) {
|
|
try {
|
|
const commit = git(root, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`).trim();
|
|
base = git(root, "merge-base", "HEAD", commit).trim();
|
|
break;
|
|
} catch {}
|
|
}
|
|
if (!base) throw new Error("Cannot resolve a base. Pass --base <ref>.");
|
|
const changed = [...new Set([
|
|
...git(root, "diff", "--name-only", "--no-renames", "-z", base, "--").split("\0"),
|
|
...git(root, "diff", "--cached", "--name-only", "--no-renames", "-z", base, "--").split("\0"),
|
|
...git(root, "ls-files", "--others", "--exclude-standard", "-z").split("\0"),
|
|
].filter(Boolean))].sort();
|
|
return { base, head: git(root, "rev-parse", "HEAD").trim(), changed };
|
|
}
|
|
|
|
function version(root, env, command, args) {
|
|
try { return execFileSync(command, args, { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
catch { return "unavailable"; }
|
|
}
|
|
|
|
export function snapshot(root, env = localEnvironment()) {
|
|
root = realpathSync(root);
|
|
const files = sourceFiles(root);
|
|
const hash = createHash("sha256");
|
|
const append = record => hash.update(`${JSON.stringify(record)}\n`);
|
|
const digest = file => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
let cacheable = true;
|
|
const addFile = file => {
|
|
const absolute = path.join(root, file);
|
|
try {
|
|
const stat = lstatSync(absolute);
|
|
if (stat.isSymbolicLink()) {
|
|
const target = realpathSync(absolute);
|
|
if (!target.startsWith(`${root}${path.sep}`)) cacheable = false;
|
|
const targetStat = lstatSync(target), regular = targetStat.isFile();
|
|
if (!regular && !targetStat.isDirectory()) cacheable = false;
|
|
append(["link", file, stat.mode, readlinkSync(absolute), target, regular ? digest(target) : "directory"]);
|
|
} else if (stat.isFile()) append(["file", file, stat.mode, digest(absolute)]);
|
|
else { cacheable = false; append(["special", file, stat.mode]); }
|
|
} catch (error) {
|
|
if (error.code !== "ENOENT") throw error;
|
|
append(["missing", file]);
|
|
}
|
|
};
|
|
productInputs(files).forEach(addFile);
|
|
const directories = new Set(["", ...files.filter(file => path.basename(file) === "package.json").map(path.dirname)]);
|
|
for (const directory of [...directories].sort()) {
|
|
for (const name of [".env", ".env.local", ".env.test", ".env.test.local"]) addFile(path.join(directory, name));
|
|
}
|
|
installedInputs(root, [...directories].sort(), append);
|
|
for (const file of ["node_modules/.modules.yaml", "node_modules/.pnpm/lock.yaml"]) addFile(file);
|
|
const environment = Object.fromEntries(Object.keys(env).sort().map(name => [name, env[name]]));
|
|
if (Object.entries(env).some(([name, value]) => value?.trim() && (
|
|
name === "DATABASE_URL" || name === "PAPERCLIP_TEST_POSTGRES_ADMIN_URL"
|
|
|| /^PAPERCLIP_.*_TEST_DATABASE_URL$/.test(name)
|
|
|| /^(?:PAPERCLIP|TEST)_.*(?:LIVE|E2E_LIVE)$/.test(name) && /^(?:1|true)$/i.test(value)
|
|
))) cacheable = false;
|
|
const context = { version: protocolVersion, root, node: process.version, executable: realpathSync(process.execPath),
|
|
platform: process.platform, arch: process.arch, environment,
|
|
pnpm: version(root, env, "pnpm", ["--version"]),
|
|
rust: version(root, env, "rustc", ["--version", "--verbose"]),
|
|
cargo: version(root, env, "cargo", ["--version"]) };
|
|
append(["context", context]);
|
|
return { hash: hash.digest("hex"), cacheable };
|
|
}
|
|
|
|
export function requestKey(inputs, diff, full) {
|
|
return createHash("sha256").update(JSON.stringify({ version: protocolVersion, inputs: inputs.hash,
|
|
base: diff.base, changed: diff.changed, full })).digest("hex");
|
|
}
|