Files
PaperClipAI/scripts/local-verify/inputs.mjs
T
DottaandPaperclip 2f35eb1080 feat(dev): add incremental local verification and pass reuse (#19345)
## 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>
2026-11-06 07:29:10 -06:00

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");
}