mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
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 <[email protected]>
This commit is contained in:
1 parent
70d23612dd
commit
2f35eb1080
15 files changed
+907
-19
No files matched your search
@@ -12,3 +12,5 @@ referenced file as the source of truth rather than maintaining a separate copy
|
||||
in dashboard instructions.
|
||||
|
||||
Do not add package.json scripts for new utilities, checks, benchmarks, or specialized tests.
|
||||
The shared `verify` entry point is the repository-wide exception. Keep new
|
||||
specialized checks as direct script invocations.
|
||||
@@ -198,13 +198,27 @@ ignored; never commit aggregate catalogs, refresh review fingerprints, or append
|
||||
a shared review journal. Use Git diffs and keep review evidence in the PR or issue.
|
||||
Structural checks are not production acceptance.
|
||||
|
||||
Default local/agent test path:
|
||||
Default local/agent verification path:
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in:
|
||||
This selects affected Vitest files with the same conservative dependency policy
|
||||
as CI. It includes committed, staged, unstaged, and untracked changes against the
|
||||
merge base with local `origin/master` (or `master`). Use `--base <ref>` for another
|
||||
base and `--explain` to inspect the selection. Passing checks with complete input
|
||||
graphs can be reused within this worktree. Opaque integration checks run fresh.
|
||||
Source, test, fixture, configuration, dependency, environment, and toolchain changes
|
||||
invalidate matching results. Failed or interrupted attempts are never passes.
|
||||
|
||||
Exit 2 means the change needs broader verification: run focused checks and let
|
||||
required CI supply that coverage, or explicitly use `pnpm verify --full` for the
|
||||
full local Vitest suite. Do not automatically follow exit 2 with a full local run.
|
||||
Use `--no-cache` to request fresh execution without reading or saving passes.
|
||||
`pnpm test` and `pnpm test:run` retain their full-suite behavior.
|
||||
|
||||
Browser suites stay opt-in:
|
||||
|
||||
```sh
|
||||
pnpm test:e2e
|
||||
@@ -215,7 +229,17 @@ Run the browser suites only when your change touches them or when you are explic
|
||||
|
||||
For normal issue work, run the smallest relevant verification first. Do not default to repo-wide typecheck/build/test on every heartbeat when a narrower check is enough to prove the change.
|
||||
|
||||
Run this full check before claiming repo work done in a PR-ready hand-off, or when the change scope is broad enough that targeted checks are not sufficient:
|
||||
For a PR-ready handoff, run the relevant local checks and require the applicable
|
||||
CI typecheck, build, and test checks to pass for the final tested revision. Record
|
||||
their scope, revision, environment, and result links. Fresh CI can supply broad
|
||||
coverage; do not duplicate a full local run merely to prepare a PR, resume a chat,
|
||||
or repeat unchanged passing verification. A selected local pass does not replace
|
||||
required CI, release qualification, browser acceptance, or a local/platform-specific
|
||||
regression. After edits or a rebase, rerun affected checks and invalidate evidence
|
||||
whose inputs or required coverage changed.
|
||||
|
||||
Use broad local checks when they resolve an actual remaining gap, such as a local
|
||||
platform fault or explicit full-suite qualification:
|
||||
|
||||
```sh
|
||||
pnpm -r typecheck
|
||||
@@ -264,7 +288,7 @@ When creating a pull request (via `gh pr create` or any other method), you **mus
|
||||
A change is done when all are true:
|
||||
|
||||
1. Behavior matches `doc/SPEC-implementation.md`
|
||||
2. Typecheck, tests, and build pass
|
||||
2. Relevant local verification and required CI typecheck, test, and build checks pass for the final revision, with any acceptance or platform-specific gaps resolved
|
||||
3. Contracts are synced across db/shared/server/ui
|
||||
4. Docs updated when behavior or commands change
|
||||
5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used)
|
||||
|
||||
+6
-1
@@ -111,7 +111,12 @@ Every PR must include a **Model Used** section specifying which AI model produce
|
||||
|
||||
### Tests Must Pass
|
||||
|
||||
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
|
||||
All required checks must pass before a PR can be merged. Run relevant local checks
|
||||
with `pnpm verify`, then require applicable typecheck, build, and test CI checks on
|
||||
the final tested revision. Fresh CI can supply broad coverage. Run a full local
|
||||
suite when it resolves a remaining local/platform-specific gap or is explicitly
|
||||
required for qualification. See `AGENTS.md` and `doc/DEVELOPING.md` for result reuse
|
||||
and coverage requirements.
|
||||
|
||||
### Telemetry Changes
|
||||
|
||||
|
||||
+60
-2
@@ -1054,7 +1054,63 @@ defines synchronization, permissions and evidence. After map edits, run
|
||||
`node scripts/check-maps.mjs` for structural/reference checks; it does not start an
|
||||
instance or qualify access as an agent.
|
||||
|
||||
Use the cheap local default unless you are specifically working on browser flows:
|
||||
### Incremental local verification
|
||||
|
||||
Use the local verification entry point for ordinary issue work:
|
||||
|
||||
```sh
|
||||
pnpm verify
|
||||
pnpm verify --explain
|
||||
pnpm verify --base origin/master
|
||||
```
|
||||
|
||||
The command compares the actual working tree with the merge base of the supplied
|
||||
local ref, including committed, staged, unstaged, deleted, renamed, and untracked
|
||||
paths. The default ref is local `origin/master`, falling back to `master`; it does
|
||||
not fetch. Tests read the current working-tree bytes. CI must still verify the
|
||||
final committed revision. The task/input policy, conservative import and file-read
|
||||
graph, exact Vitest specifications, and stable test runner are shared with CI.
|
||||
Serialized server tests retain their owner. Native server tests remain in local
|
||||
coverage. Selected chat tests retain six sequential isolated line shards.
|
||||
|
||||
Successful selected checks with complete input graphs are reusable in this
|
||||
worktree. Opaque filesystem, subprocess, network, generated/untracked-module, and
|
||||
unreviewed dependency consumers continue to run fresh. A selected pass never
|
||||
satisfies a full run. Non-isolated projects and uncertain selection require broader
|
||||
verification. The command prints the local test scope and remaining CI lanes.
|
||||
|
||||
An unchanged repeat can reuse both graph discovery and passing checks. Keys include
|
||||
actual tracked and non-ignored file contents (including dirty and untracked files),
|
||||
the shared audited-prose exclusions, dotenv files, lockfile, installation metadata,
|
||||
dependency bindings and installed package contents, the complete effective child environment, Node/pnpm/Rust toolchains,
|
||||
platform, checkout path, comparison, and selection. Values from the environment
|
||||
are hashed, not written to results. External database/live-test settings and
|
||||
out-of-checkout source symlinks disable reuse. Cache state lives under the ignored
|
||||
`.turbo/local-verify/` directory and is not shared across worktrees or machines.
|
||||
Installed package content digests are memoized against file identity, size, mode,
|
||||
and nanosecond modification/change times; every check rescans the installation.
|
||||
Generated Vite/cache directories are excluded. The first run after installation
|
||||
reads package contents; unchanged repeats avoid reading those bytes again.
|
||||
`last-run.json` retains the latest completed execution's status and scope. Failed/interrupted
|
||||
attempts do not publish passes. Inputs changed during a run invalidate its result.
|
||||
|
||||
Exit 2 reports a broad input, incomplete graph, or unsupported selection. It does
|
||||
not silently launch the full suite or claim affected coverage passed. Use focused
|
||||
checks and required CI to close that gap, or explicitly request a broad local run:
|
||||
|
||||
```sh
|
||||
pnpm verify --full
|
||||
pnpm verify --no-cache
|
||||
```
|
||||
|
||||
`--full` executes the complete existing Vitest path with fresh integration coverage.
|
||||
`--no-cache` bypasses pass/plan reuse and does not save passes. A fresh failed or
|
||||
interrupted attempt removes its old reusable pass. Required CI on the final tested
|
||||
revision may supply broad typecheck/build/test coverage for a PR handoff; do not
|
||||
repeat a full local suite solely because a chat resumed or a PR is ready. Keep
|
||||
release checks and any real browser, provider, local-platform or acceptance proof.
|
||||
|
||||
The full local Vitest commands remain available:
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
@@ -1092,7 +1148,9 @@ the development server's unbundled module graph on each navigation. Browser
|
||||
assertion deadlines and retries remain unchanged. Use `pnpm dev` separately when
|
||||
verifying Vite/HMR behavior.
|
||||
|
||||
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
|
||||
For normal issue work, start with the smallest targeted check that proves the change.
|
||||
Use the incremental command above and required final-revision CI for PR handoff.
|
||||
Run broad local checks when an unresolved risk or explicit qualification needs them.
|
||||
|
||||
The Runner transport suite keeps attachment recovery, warm attachment, live
|
||||
adoption, and suspension in separate test files. Vitest can schedule these
|
||||
|
||||
+3
-2
@@ -61,7 +61,7 @@
|
||||
"smoke:notion-generic-live": "node scripts/smoke/notion-generic-live.mjs",
|
||||
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node scripts/ci-profile/node-test.mjs scripts/ci-profile/reporters.test.mjs scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/public-install-registry.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/__tests__/runner-debug-crate-cache.test.mjs scripts/__tests__/vitest-worker-isolation.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
|
||||
"test:release-registry": "node scripts/ci-profile/node-test.mjs scripts/ci-profile/reporters.test.mjs scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/public-install-registry.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/__tests__/runner-debug-crate-cache.test.mjs scripts/__tests__/vitest-worker-isolation.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs scripts/__tests__/local-verify.test.mjs",
|
||||
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
|
||||
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
|
||||
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
||||
@@ -94,7 +94,8 @@
|
||||
"connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs",
|
||||
"test:lifecycle-baseline": "node tests/lifecycle-baseline/run.mjs",
|
||||
"test:lifecycle-baseline:support": "node --test tests/lifecycle-baseline/report.test.mjs",
|
||||
"test:lifecycle-baseline:typecheck": "node scripts/typecheck-test-projects.mjs tests/lifecycle-baseline"
|
||||
"test:lifecycle-baseline:typecheck": "node scripts/typecheck-test-projects.mjs tests/lifecycle-baseline",
|
||||
"verify": "node --import ./cli/node_modules/tsx/dist/loader.mjs scripts/ensure-workspace-package-links.ts && node scripts/local-verify.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import { comparison, localEnvironment, snapshot } from "../local-verify/inputs.mjs";
|
||||
import { planVerification } from "../local-verify/plan.mjs";
|
||||
|
||||
const repository = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
function fixture(t) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "local-verify-")));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const write = (file, text) => {
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), text);
|
||||
};
|
||||
for (const directory of ["scripts/ci", "scripts/local-verify", ".github/scripts"]) {
|
||||
cpSync(path.join(repository, directory), path.join(root, directory), { recursive: true });
|
||||
}
|
||||
for (const file of ["scripts/local-verify.mjs", "scripts/run-vitest-stable.mjs", "scripts/general-server-shard.mjs",
|
||||
"scripts/test-line-shard.mjs", "scripts/ci-profile/record.mjs", "turbo.json", ".github/ci-task-matrix.json"]) {
|
||||
write(file, readFileSync(path.join(repository, file), "utf8"));
|
||||
}
|
||||
for (const directory of ["server/src/__tests__", "server/scripts"]) mkdirSync(path.join(root, directory), { recursive: true });
|
||||
mkdirSync(path.join(root, "node_modules/.pnpm"), { recursive: true });
|
||||
for (const name of ["vitest", "oxc-parser", ".bin"]) {
|
||||
symlinkSync(path.join(repository, "node_modules", name), path.join(root, "node_modules", name), "dir");
|
||||
}
|
||||
for (const file of ["node_modules/.modules.yaml", "node_modules/.pnpm/lock.yaml"]) {
|
||||
write(file, readFileSync(path.join(repository, file), "utf8"));
|
||||
}
|
||||
write(".gitignore", "node_modules\nnode_modules/\n.turbo/\n.env\n");
|
||||
write("package.json", JSON.stringify({ name: "local-verification-fixture", private: true, type: "module", packageManager: "[email protected]",
|
||||
scripts: { verify: "node scripts/local-verify.mjs", "test:run": "vitest run" } }));
|
||||
write("vitest.config.mjs", 'export default {test:{projects:["ui","cli"]}};');
|
||||
for (const [directory, name] of [["ui", "@paperclipai/ui"], ["cli", "paperclipai"]]) {
|
||||
write(`${directory}/vitest.config.mjs`, `export default {test:{name:${JSON.stringify(name)},include:["src/**/*.test.ts"]}};`);
|
||||
}
|
||||
write("ui/src/leaf.ts", "export const value = 1;\n");
|
||||
write("ui/src/other.ts", "export const other = 1;\n");
|
||||
write("ui/src/consumer.test.ts", 'import {test,expect} from "vitest"; import {value} from "./leaf"; test("affected consumer",()=>{expect(value).toBeGreaterThan(0);expect(process.env.LOCAL_VERIFY_FLAVOR).not.toBe("fail");});');
|
||||
write("ui/src/unrelated.test.ts", 'import {test,expect} from "vitest"; import {other} from "./other"; test("unrelated full-suite control",()=>expect(other).toBe(123456));');
|
||||
write("cli/src/independent.test.ts", 'import {test,expect} from "vitest"; test("unchanged consumer project",()=>expect(1).toBe(1));');
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
git("init", "-q"); git("add", ".");
|
||||
git("-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "baseline");
|
||||
const base = git("rev-parse", "HEAD");
|
||||
write("ui/src/leaf.ts", "export const value = 2;\n");
|
||||
const env = localEnvironment({ ...process.env, FORCE_COLOR: "0" });
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
delete env.DATABASE_URL;
|
||||
delete env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL;
|
||||
for (const name of Object.keys(env)) if (/^PAPERCLIP_.*_TEST_DATABASE_URL$/.test(name)) delete env[name];
|
||||
const run = (args = [], extra = {}) => {
|
||||
const result = spawnSync(process.execPath, [path.join(root, "scripts/local-verify.mjs"), "--base", base, ...args], {
|
||||
cwd: root, env: { ...env, ...extra }, encoding: "utf8", timeout: 120_000, maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(result.error, undefined);
|
||||
return { ...result, text: stripVTControlCharacters(result.stdout + result.stderr) };
|
||||
};
|
||||
return { root, write, git, base, env, run };
|
||||
}
|
||||
|
||||
test("real selected execution reuses passes and invalidates source, environment, untracked tests and broad inputs", { timeout: 180_000 }, async t => {
|
||||
const f = fixture(t);
|
||||
const first = f.run();
|
||||
assert.equal(first.status, 0, first.text);
|
||||
assert.match(first.text, /Test Files\s+1 passed/);
|
||||
assert.match(first.text, /1\/3 Vitest test files selected/);
|
||||
assert.doesNotMatch(first.text, /unrelated full-suite control/);
|
||||
const reused = f.run();
|
||||
assert.equal(reused.status, 0, reused.text);
|
||||
assert.match(reused.text, /reusing a passing local result/);
|
||||
assert.doesNotMatch(reused.text, /Test Files/);
|
||||
const last = JSON.parse(readFileSync(path.join(f.root, ".turbo/local-verify/last-run.json"), "utf8"));
|
||||
const passFile = path.join(f.root, ".turbo/local-verify", `${last.key}.json`);
|
||||
const corrupt = JSON.parse(readFileSync(passFile, "utf8"));
|
||||
corrupt.plan.selectedFiles = [];
|
||||
writeFileSync(passFile, JSON.stringify(corrupt));
|
||||
const recovered = f.run();
|
||||
assert.equal(recovered.status, 0, recovered.text);
|
||||
assert.doesNotMatch(recovered.text, /reusing a passing local result/);
|
||||
assert.match(recovered.text, /Reusing passed general-workspaces-a-reusable/);
|
||||
f.write("ui/src/leaf.ts", "export const value = 3;\n");
|
||||
const edited = f.run();
|
||||
assert.equal(edited.status, 0, edited.text);
|
||||
assert.match(edited.text, /Test Files\s+1 passed/);
|
||||
const environment = f.run([], { LOCAL_VERIFY_FLAVOR: "different" });
|
||||
assert.equal(environment.status, 0, environment.text);
|
||||
assert.match(environment.text, /Test Files\s+1 passed/);
|
||||
const environmentFailure = f.run([], { LOCAL_VERIFY_FLAVOR: "fail" });
|
||||
assert.equal(environmentFailure.status, 1, environmentFailure.text);
|
||||
assert.match(environmentFailure.text, /affected consumer/);
|
||||
const installed = readFileSync(path.join(f.root, "node_modules/.modules.yaml"), "utf8");
|
||||
f.write("node_modules/.modules.yaml", `${installed}\n# changed installation\n`);
|
||||
const dependencies = f.run();
|
||||
assert.equal(dependencies.status, 0, dependencies.text);
|
||||
assert.match(dependencies.text, /Test Files\s+1 passed/);
|
||||
rmSync(path.join(f.root, "node_modules/vitest"));
|
||||
const brokenInstall = f.run();
|
||||
assert.equal(brokenInstall.status, 2, brokenInstall.text);
|
||||
assert.doesNotMatch(brokenInstall.text, /reusing a passing local result/);
|
||||
symlinkSync(path.join(repository, "node_modules/vitest"), path.join(f.root, "node_modules/vitest"), "dir");
|
||||
f.write("ui/src/new.test.ts", 'import {test,expect} from "vitest"; test("new untracked regression",()=>expect(false).toBe(true));');
|
||||
const untracked = f.run();
|
||||
assert.equal(untracked.status, 1, untracked.text);
|
||||
assert.match(untracked.text, /new untracked regression/);
|
||||
const failedAgain = f.run();
|
||||
assert.equal(failedAgain.status, 1, failedAgain.text);
|
||||
assert.doesNotMatch(failedAgain.text, /reusing a passing local result/);
|
||||
rmSync(path.join(f.root, "ui/src/new.test.ts"));
|
||||
f.write("ui/vitest.config.mjs", 'export default {test:{name:"@paperclipai/ui",include:["src/**/*.test.ts"],testTimeout:10000}};');
|
||||
const config = f.run();
|
||||
assert.equal(config.status, 2, config.text);
|
||||
assert.match(config.text, /Broader verification required/);
|
||||
assert.doesNotMatch(config.text, /Test Files/);
|
||||
assert.match(config.text, /vitest.config.mjs/);
|
||||
});
|
||||
|
||||
test("explicit full execution cannot reuse a passing subset; opaque checks stay fresh", { timeout: 180_000 }, async t => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run().status, 0);
|
||||
const full = f.run(["--full"]);
|
||||
assert.equal(full.status, 1, full.text);
|
||||
assert.match(full.text, /unrelated full-suite control/);
|
||||
f.write("ui/src/consumer.test.ts", 'import {test,expect} from "vitest"; import {readFileSync} from "node:fs"; import {value} from "./leaf"; test("opaque fresh check",()=>expect(readFileSync(process.env.LOCAL_VERIFY_INPUT,"utf8")).toBe(String(value)));');
|
||||
f.git("add", "ui/src/consumer.test.ts");
|
||||
f.write(".turbo/external-input", "2");
|
||||
const input = { LOCAL_VERIFY_INPUT: path.join(f.root, ".turbo/external-input") };
|
||||
const opaque = f.run([], input);
|
||||
assert.equal(opaque.status, 0, opaque.text);
|
||||
assert.match(opaque.text, /Test Files\s+1 passed/);
|
||||
const fresh = f.run([], input);
|
||||
assert.equal(fresh.status, 0, fresh.text);
|
||||
assert.match(fresh.text, /Test Files\s+1 passed/);
|
||||
f.write(".turbo/external-input", "changed externally");
|
||||
const external = f.run([], input);
|
||||
assert.equal(external.status, 1, external.text);
|
||||
assert.match(external.text, /opaque fresh check/);
|
||||
});
|
||||
|
||||
test("working tree comparison includes commits, staging, deletion, rename and untracked files", t => {
|
||||
const f = fixture(t);
|
||||
f.git("add", "ui/src/leaf.ts");
|
||||
f.git("-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "committed change");
|
||||
f.git("mv", "ui/src/other.ts", "ui/src/renamed.ts");
|
||||
rmSync(path.join(f.root, "cli/src/independent.test.ts"));
|
||||
f.write("ui/src/added.ts", "export const added = true;");
|
||||
assert.deepEqual(comparison(f.root, f.base).changed, ["cli/src/independent.test.ts", "ui/src/added.ts", "ui/src/leaf.ts", "ui/src/other.ts", "ui/src/renamed.ts"]);
|
||||
assert.throws(() => comparison(f.root, "missing-ref"), /Cannot resolve a base/);
|
||||
});
|
||||
|
||||
test("dependency installation, ignored dotenv and child environment enter the cache fingerprint", t => {
|
||||
const f = fixture(t), before = snapshot(f.root, f.env).hash;
|
||||
f.write(".env", "FEATURE=true\n");
|
||||
assert.notEqual(snapshot(f.root, f.env).hash, before);
|
||||
rmSync(path.join(f.root, ".env"));
|
||||
assert.equal(snapshot(f.root, f.env).hash, before);
|
||||
f.write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
|
||||
assert.notEqual(snapshot(f.root, f.env).hash, before);
|
||||
assert.notEqual(snapshot(f.root, { ...f.env, NEW_TEST_FLAG: "1" }).hash, before);
|
||||
assert.equal(snapshot(f.root, { ...f.env, PAPERCLIP_TEST_POSTGRES_ADMIN_URL: "postgres://external.invalid/test" }).cacheable, false);
|
||||
});
|
||||
|
||||
test("installed dependency code changes invalidate a real passing result", { timeout: 120_000 }, t => {
|
||||
const f = fixture(t);
|
||||
const installed = "node_modules/.pnpm/[email protected]/node_modules/clsx";
|
||||
f.write(`${installed}/package.json`, '{"name":"clsx","version":"1.0.0","type":"module","exports":"./index.mjs"}');
|
||||
f.write(`${installed}/index.mjs`, "export default () => 2;\n");
|
||||
symlinkSync(path.join(f.root, installed), path.join(f.root, "node_modules/clsx"), "dir");
|
||||
f.write("ui/src/consumer.test.ts", 'import {test,expect} from "vitest"; import clsx from "clsx"; import {value} from "./leaf"; test("installed dependency",()=>expect(clsx()).toBe(value));');
|
||||
const first = f.run();
|
||||
assert.equal(first.status, 0, first.text);
|
||||
const reused = f.run();
|
||||
assert.equal(reused.status, 0, reused.text);
|
||||
assert.match(reused.text, /reusing a passing local result/);
|
||||
const moduleFile = path.join(f.root, installed, "index.mjs"), original = lstatSync(moduleFile);
|
||||
f.write(`${installed}/index.mjs`, "export default () => 0;\n");
|
||||
utimesSync(moduleFile, original.atime, original.mtime);
|
||||
const changed = f.run();
|
||||
assert.equal(changed.status, 1, changed.text);
|
||||
assert.match(changed.text, /installed dependency/);
|
||||
assert.doesNotMatch(changed.text, /reusing a passing local result/);
|
||||
});
|
||||
|
||||
test("repository skill directory links preserve reuse while target edits invalidate it", t => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(path.join(f.root, ".claude/skills"), { recursive: true });
|
||||
symlinkSync("../../ui", path.join(f.root, ".claude/skills/local"), "dir");
|
||||
f.git("add", ".claude/skills/local");
|
||||
const before = snapshot(f.root, f.env);
|
||||
assert.equal(before.cacheable, true);
|
||||
f.write("ui/src/leaf.ts", "export const value = 3;\n");
|
||||
assert.notEqual(snapshot(f.root, f.env).hash, before.hash);
|
||||
});
|
||||
|
||||
test("local chat selection schedules exactly six single-worker partitions", { timeout: 120_000 }, async t => {
|
||||
const f = fixture(t);
|
||||
f.write("ui/src/leaf.ts", "export const value = 1;\n");
|
||||
f.write("vitest.config.mjs", 'export default {test:{projects:["server"]}};');
|
||||
f.write("server/vitest.config.mjs", 'export default {test:{name:"@paperclipai/server",include:["src/**/*.test.ts"]}};');
|
||||
f.write("server/src/leaf.ts", "export const value = 1;\n");
|
||||
f.write("server/src/__tests__/chat-channels.integration.test.ts", 'import {test,expect} from "vitest"; import {value} from "../leaf"; test("chat selection",()=>expect(value).toBeGreaterThan(0));');
|
||||
f.write(".github/ci-task-matrix.json", JSON.stringify({ general_tests: [
|
||||
{ task: "ci:server", group: "general-server-without-chat" }, { task: "ci:chat", group: "general-chat" },
|
||||
] }));
|
||||
f.git("add", "."); f.git("-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "chat baseline");
|
||||
const base = f.git("rev-parse", "HEAD");
|
||||
f.write("server/src/leaf.ts", "export const value = 2;\n");
|
||||
const plan = await planVerification({ root: f.root, env: f.env, diff: comparison(f.root, base) });
|
||||
assert.equal(plan.kind, "selected", plan.reason);
|
||||
const shards = plan.jobs.flatMap(job => JSON.parse(execFileSync(process.execPath,
|
||||
[path.join(f.root, "scripts/run-vitest-stable.mjs"), ...job.args, "--dry-run"],
|
||||
{ cwd: f.root, env: f.env, encoding: "utf8" })).chatVitestShards);
|
||||
assert.deepEqual(shards, Array.from({ length: 6 }, (_, index) => ({ index, count: 6 })));
|
||||
});
|
||||
|
||||
for (const [group, task, primary, directory] of [
|
||||
["general-workspaces-b", "ci:workspaces-b", "@paperclipai/shared", "packages/shared"],
|
||||
["general-workspaces-c", "ci:db", "@paperclipai/db", "packages/db"],
|
||||
]) test(`${group} discovers and executes reusable and fresh selected partitions`, { timeout: 180_000 }, t => {
|
||||
const f = fixture(t);
|
||||
const inventory = JSON.parse(execFileSync(process.execPath, [path.join(f.root, "scripts/run-vitest-stable.mjs"),
|
||||
"--mode", "general", "--group", group, "--dry-run"], { cwd: f.root, env: f.env, encoding: "utf8" }));
|
||||
const projects = inventory.workspaceProjects.map((project, index) => {
|
||||
const folder = project === primary ? directory : `packages/fixture-${index}`;
|
||||
f.write(`${folder}/vitest.config.mjs`, `export default {test:{name:${JSON.stringify(project)},include:["src/**/*.test.ts"]}};`);
|
||||
f.write(`${folder}/src/independent.test.ts`, 'import {test,expect} from "vitest"; test("independent workspace",()=>expect(1).toBe(1));');
|
||||
return folder;
|
||||
});
|
||||
f.write("ui/src/leaf.ts", "export const value = 1;\n");
|
||||
f.write("vitest.config.mjs", `export default {test:{projects:${JSON.stringify(projects)}}};`);
|
||||
f.write(".github/ci-task-matrix.json", JSON.stringify({ general_tests: [{ task, group }] }));
|
||||
f.write(`${directory}/src/leaf.ts`, "export const value = 1;\n");
|
||||
f.write(`${directory}/src/reusable.test.ts`, 'import {test,expect} from "vitest"; import {value} from "./leaf"; test("workspace consumer",()=>expect(value).toBeGreaterThan(0));');
|
||||
f.write(`${directory}/src/fresh.test.ts`, 'import {test,expect} from "vitest"; import {readFileSync} from "node:fs"; import {value} from "./leaf"; test("fresh workspace consumer",()=>expect(readFileSync(process.env.LOCAL_VERIFY_INPUT,"utf8")).toBe(String(value)));');
|
||||
f.git("add", "."); f.git("-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "workspace baseline");
|
||||
const base = f.git("rev-parse", "HEAD");
|
||||
f.write(`${directory}/src/leaf.ts`, "export const value = 2;\n");
|
||||
f.write(".turbo/external-input", "2");
|
||||
const env = { LOCAL_VERIFY_INPUT: path.join(f.root, ".turbo/external-input") };
|
||||
const first = f.run(["--base", base], env);
|
||||
assert.equal(first.status, 0, first.text);
|
||||
assert.equal((first.text.match(/Test Files\s+1 passed/g) ?? []).length, 2, first.text);
|
||||
const reused = f.run(["--base", base], env);
|
||||
assert.equal(reused.status, 0, reused.text);
|
||||
assert.ok(reused.text.includes(`Reusing passed ${group}-reusable`), reused.text);
|
||||
assert.equal((reused.text.match(/Test Files\s+1 passed/g) ?? []).length, 1, reused.text);
|
||||
f.write(".turbo/external-input", "changed externally");
|
||||
const changed = f.run(["--base", base], env);
|
||||
assert.equal(changed.status, 1, changed.text);
|
||||
assert.match(changed.text, /fresh workspace consumer/);
|
||||
});
|
||||
|
||||
test("fingerprints preserve file boundaries when content resembles another input record", t => {
|
||||
const f = fixture(t), a = "ui/src/hash-a.ts", b = "ui/src/hash-b.ts";
|
||||
f.write(a, ""); f.write(b, "tail");
|
||||
const boundary = JSON.stringify(b) + String(lstatSync(path.join(f.root, b)).mode);
|
||||
f.write(a, `${boundary}payload`);
|
||||
const first = snapshot(f.root, f.env).hash;
|
||||
f.write(a, ""); f.write(b, `payload${boundary}tail`);
|
||||
assert.notEqual(snapshot(f.root, f.env).hash, first);
|
||||
});
|
||||
|
||||
test("an edit between diff discovery and fingerprinting cannot certify a partial selection", { skip: process.platform === "win32" }, t => {
|
||||
const f = fixture(t);
|
||||
const executable = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
||||
f.write(".turbo/bin/package.json", '{"type":"commonjs"}');
|
||||
f.write(".turbo/bin/git", `#!${process.execPath}
|
||||
const {execFileSync}=require('node:child_process');
|
||||
const fs=require('node:fs');
|
||||
const args=process.argv.slice(2);
|
||||
const result=execFileSync(${JSON.stringify(executable)},args,{stdio:['ignore','pipe','pipe']});
|
||||
if(args[0]==='diff'&&!args.includes('--cached')&&!fs.existsSync('.turbo/diff-edited')){
|
||||
fs.writeFileSync('ui/src/other.ts','export const other = 2;\\n');
|
||||
fs.writeFileSync('.turbo/diff-edited','done');
|
||||
}
|
||||
process.stdout.write(result);
|
||||
`);
|
||||
chmodSync(path.join(f.root, ".turbo/bin/git"), 0o755);
|
||||
const result = f.run([], { PATH: `${path.join(f.root, ".turbo/bin")}${path.delimiter}${f.env.PATH}` });
|
||||
assert.equal(result.status, 1, result.text);
|
||||
assert.match(result.text, /Inputs changed/);
|
||||
assert.doesNotMatch(result.text, /Local Vitest verification passed/);
|
||||
});
|
||||
|
||||
test("documentation-only changes and empty diffs do not start a full suite", async t => {
|
||||
const f = fixture(t);
|
||||
f.write("ui/src/leaf.ts", "export const value = 1;\n");
|
||||
f.write("doc/DEVELOPING.md", "Guidance only\n");
|
||||
const result = f.run(["--explain"]);
|
||||
assert.equal(result.status, 0, result.text);
|
||||
assert.match(result.text, /0 Vitest test files selected/);
|
||||
assert.doesNotMatch(result.text, /Test Files/);
|
||||
const plan = await planVerification({ root: f.root, env: f.env,
|
||||
diff: { ...comparison(f.root, f.base), changed: [] } });
|
||||
assert.deepEqual(plan.jobs, []);
|
||||
});
|
||||
|
||||
test("local server selection retains native suites and serialized ownership under a CI caller", { timeout: 120_000 }, t => {
|
||||
const f = fixture(t);
|
||||
f.write("ui/src/leaf.ts", "export const value = 1;\n");
|
||||
f.write("vitest.config.mjs", 'export default {test:{projects:["server"]}};');
|
||||
f.write("server/vitest.config.mjs", 'export default {test:{name:"@paperclipai/server",include:["src/**/*.test.ts"]}};');
|
||||
f.write("server/src/leaf.ts", "export const value = 1;\n");
|
||||
const native = "server/src/services/native-runtime/plugin-runner-probe.test.ts";
|
||||
const serialized = "server/src/__tests__/authz-owner.test.ts";
|
||||
for (const [file, imported] of [[native, "../../leaf"], [serialized, "../leaf"]]) {
|
||||
f.write(file, `import {test,expect} from "vitest"; import {value} from ${JSON.stringify(imported)}; test("local owner",()=>expect(value).toBeGreaterThan(0));`);
|
||||
}
|
||||
f.write(".github/ci-task-matrix.json", JSON.stringify({
|
||||
general_tests: [{ task: "ci:server", group: "general-server-without-chat" }],
|
||||
verify_serialized_server: [{ task: "ci:serialized" }],
|
||||
}));
|
||||
f.git("add", "."); f.git("-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "server baseline");
|
||||
const base = f.git("rev-parse", "HEAD");
|
||||
f.write("server/src/leaf.ts", "export const value = 2;\n");
|
||||
const result = f.run(["--base", base], { GITHUB_WORKFLOW: "PR" });
|
||||
assert.equal(result.status, 0, result.text);
|
||||
assert.equal((result.text.match(/Test Files\s+1 passed/g) ?? []).length, 2);
|
||||
const record = JSON.parse(readFileSync(path.join(f.root, ".turbo/local-verify/last-run.json"), "utf8"));
|
||||
assert.deepEqual(record.plan.manifest.lanes["general-server-without-chat"].shards[0]["@paperclipai/server"], [native]);
|
||||
assert.deepEqual(record.plan.manifest.lanes.serialized.shards[0]["@paperclipai/server"], [serialized]);
|
||||
});
|
||||
|
||||
test("source mutation and interruption do not publish reusable passes", { timeout: 120_000 }, async t => {
|
||||
const f = fixture(t);
|
||||
const runControlled = action => new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(f.root, "scripts/local-verify.mjs"), "--base", f.base, "--no-cache"], {
|
||||
cwd: f.root, env: f.env, stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let text = "", acted = false;
|
||||
const timer = setTimeout(() => { child.kill("SIGTERM"); reject(new Error("Controlled verification did not finish")); }, 60_000);
|
||||
const collect = bytes => {
|
||||
text += bytes;
|
||||
if (!acted && text.includes("[verify] Running")) { acted = true; action(child); }
|
||||
};
|
||||
child.stdout.on("data", collect); child.stderr.on("data", collect);
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) => { clearTimeout(timer); resolve({ code, signal, text, acted }); });
|
||||
});
|
||||
const changed = await runControlled(() => f.write("ui/src/leaf.ts", "export const value = 4;\n"));
|
||||
assert.equal(changed.acted, true);
|
||||
assert.equal(changed.code, 1, changed.text);
|
||||
assert.match(changed.text, /Inputs changed during verification/);
|
||||
const beforeInterrupt = f.run();
|
||||
assert.equal(beforeInterrupt.status, 0, beforeInterrupt.text);
|
||||
const interrupted = await runControlled(child => child.kill("SIGTERM"));
|
||||
assert.equal(interrupted.acted, true);
|
||||
assert.equal(interrupted.code, 143, interrupted.text);
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.text);
|
||||
assert.match(result.text, /Test Files\s+1 passed/);
|
||||
assert.doesNotMatch(result.text, /reusing a passing local result/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
// Each Vite context stays in its own process: aliases, plugins, globals and
|
||||
// heaps cannot leak between projects. Two contexts bound memory on hosted CI.
|
||||
export async function selectProjects({ projects, changed, root = process.cwd(), concurrency = Math.min(2, availableParallelism()) }) {
|
||||
export async function selectProjects({ projects, changed, root = process.cwd(), concurrency = Math.min(2, availableParallelism()), env = process.env }) {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 2) throw new Error("Expected one or two graph workers");
|
||||
const results = new Array(projects.length), children = new Set();
|
||||
let next = 0, failure;
|
||||
@@ -13,10 +13,10 @@ export async function selectProjects({ projects, changed, root = process.cwd(),
|
||||
const index = next++;
|
||||
try {
|
||||
results[index] = await new Promise((resolve, reject) => {
|
||||
const env = { ...process.env };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const childEnv = { ...env };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
const child = fork(fileURLToPath(import.meta.url), [], {
|
||||
cwd: root, env, execArgv: ["--max-old-space-size=2048"], stdio: ["ignore", "inherit", "inherit", "ipc"],
|
||||
cwd: root, env: childEnv, execArgv: ["--max-old-space-size=2048"], stdio: ["ignore", "inherit", "inherit", "ipc"],
|
||||
});
|
||||
children.add(child);
|
||||
let result;
|
||||
|
||||
@@ -8,7 +8,7 @@ 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) {
|
||||
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);
|
||||
@@ -20,7 +20,7 @@ export function selectionEligibility(plan) {
|
||||
|| /(?:^|\/)(?:vite|vitest)\.(?:config|setup)\.[cm]?[jt]sx?$|\.d\.[cm]?ts$/i.test(file)
|
||||
|| file.split("/").includes("..")) return `Broad input: ${file}`;
|
||||
try {
|
||||
if (!lstatSync(file).isFile()) return `Non-regular source: ${file}`;
|
||||
if (!lstatSync(path.join(root, file)).isFile()) return `Non-regular source: ${file}`;
|
||||
} catch { return `Deleted or missing source: ${file}`; }
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -313,6 +313,7 @@ export async function selectProjectTests({ root = process.cwd(), project, change
|
||||
const selected = [...new Set([...raw, ...inputRelated, ...retained].map(relative))].sort();
|
||||
return {
|
||||
project,
|
||||
isolated: owner.config.isolate !== false,
|
||||
inventory: inventory.map(relative).sort(),
|
||||
selected,
|
||||
related: raw.map(relative).sort(),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import { realpathSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { createHash } from "node:crypto";
|
||||
import { comparison, localEnvironment, protocolVersion, requestKey, snapshot } from "./local-verify/inputs.mjs";
|
||||
import { planVerification } from "./local-verify/plan.mjs";
|
||||
import { invalidatePass, mayReuseAttempt, readPass, readPlan, runPlan, writeRecord } from "./local-verify/run.mjs";
|
||||
|
||||
function describe(plan, cached) {
|
||||
console.log(`[verify] ${plan.kind === "full" ? "Full Vitest suite" : plan.kind === "broad" ? "Broader verification required"
|
||||
: `${plan.selectedFiles.length}${plan.totalFiles === undefined ? "" : `/${plan.totalFiles}`} Vitest test files selected`}${cached ? "; reusing a passing local result" : ""}`);
|
||||
if (plan.reason) console.log(`[verify] ${plan.reason}`);
|
||||
if (plan.retainedFiles) console.log(`[verify] ${plan.retainedFiles} test files retained conservatively`);
|
||||
if (plan.requiredCI.length) console.log(`[verify] Required CI coverage: ${plan.requiredCI.join(", ")}`);
|
||||
console.log("[verify] Local Vitest coverage does not replace required CI, typecheck/build, browser, release, or platform-specific checks.");
|
||||
}
|
||||
|
||||
export async function verify({ root = process.cwd(), base, full = false, explain = false, noCache = false, env = localEnvironment() } = {}) {
|
||||
root = realpathSync(root);
|
||||
const before = snapshot(root, env), diff = comparison(root, base), key = requestKey(before, diff, full);
|
||||
const canReuse = !noCache && before.cacheable && mayReuseAttempt(root, key);
|
||||
const cached = canReuse ? readPass(root, key) : null;
|
||||
console.log(`[verify] Base ${diff.base.slice(0, 12)}; ${diff.changed.length} changed paths, including local edits`);
|
||||
if (cached) {
|
||||
if (snapshot(root, env).hash !== before.hash) throw new Error("Inputs changed during cache lookup. Run verification again.");
|
||||
describe(cached.plan, true);
|
||||
console.log(`[verify] Passed result from ${cached.completedAt}; input fingerprint ${before.hash.slice(0, 12)}`);
|
||||
if (explain) console.log(JSON.stringify({ ...diff, inputFingerprint: before.hash, cacheable: before.cacheable, plan: cached.plan }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
if (!explain) {
|
||||
invalidatePass(root, key);
|
||||
writeRecord(root, `attempt-${key}`, { version: protocolVersion, key, status: "running", plan: null });
|
||||
}
|
||||
const plan = (canReuse ? readPlan(root, key) : null)
|
||||
?? await planVerification({ root, env, diff, full });
|
||||
if (plan.manifest) plan.manifest.head = diff.head;
|
||||
if (snapshot(root, env).hash !== before.hash) throw new Error("Inputs changed during selection. Run verification again.");
|
||||
describe(plan, false);
|
||||
if (explain) {
|
||||
console.log(JSON.stringify({ ...diff, inputFingerprint: before.hash, cacheable: before.cacheable, plan }, null, 2));
|
||||
return plan.kind === "broad" ? 2 : 0;
|
||||
}
|
||||
if (plan.kind === "broad") {
|
||||
console.log("[verify] Run focused checks and use required CI for broad coverage, or explicitly run pnpm verify --full.");
|
||||
writeRecord(root, `attempt-${key}`, { version: protocolVersion, key, status: "broad", plan });
|
||||
return 2;
|
||||
}
|
||||
if (before.cacheable && !noCache && plan.kind === "selected") {
|
||||
writeRecord(root, `plan-${key}`, { version: protocolVersion, key, status: "planned", plan });
|
||||
}
|
||||
invalidatePass(root, key);
|
||||
const startedAt = new Date().toISOString();
|
||||
let code = 0;
|
||||
for (const job of plan.jobs) {
|
||||
const jobKey = createHash("sha256").update(`${key}\0${job.id}`).digest("hex");
|
||||
if (canReuse && job.cacheable && readPass(root, jobKey)) {
|
||||
console.log(`[verify] Reusing passed ${job.id}`);
|
||||
continue;
|
||||
}
|
||||
invalidatePass(root, jobKey);
|
||||
code = await runPlan(root, env, { ...plan, jobs: [job] });
|
||||
if (snapshot(root, env).hash !== before.hash) {
|
||||
console.error("[verify] Inputs changed during verification. The result cannot be reused.");
|
||||
code ||= 1;
|
||||
}
|
||||
if (code !== 0) break;
|
||||
if (!noCache && before.cacheable && job.cacheable) writeRecord(root, jobKey, {
|
||||
version: protocolVersion, key: jobKey, status: "passed", plan, jobId: job.id, completedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const after = snapshot(root, env);
|
||||
if (after.hash !== before.hash) {
|
||||
console.error("[verify] Inputs changed during verification. The result cannot be reused.");
|
||||
code ||= 1;
|
||||
}
|
||||
const record = { version: protocolVersion, key, status: code === 0 ? "passed" : "failed", startedAt,
|
||||
completedAt: new Date().toISOString(), head: diff.head, inputFingerprint: before.hash, plan, exitCode: code };
|
||||
writeRecord(root, "last-run", record);
|
||||
writeRecord(root, `attempt-${key}`, record);
|
||||
if (code === 0 && !noCache && before.cacheable && after.cacheable && plan.jobs.every(job => job.cacheable)) writeRecord(root, key, record);
|
||||
console.log(`[verify] ${code === 0 ? "Local Vitest verification passed" : "Verification failed or interrupted"}${code === 0 && !before.cacheable ? "; external inputs disable pass reuse" : ""}`);
|
||||
return code;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({ options: { base: { type: "string" }, full: { type: "boolean" },
|
||||
explain: { type: "boolean" }, "no-cache": { type: "boolean" }, help: { type: "boolean" } } });
|
||||
if (values.help) {
|
||||
console.log("Usage: pnpm verify [--base REF] [--explain] [--full] [--no-cache]\nRuns affected local Vitest checks. Exit 2 means broader coverage is required; use CI or --full.");
|
||||
return;
|
||||
}
|
||||
process.exitCode = await verify({ base: values.base, full: values.full, explain: values.explain, noCache: values["no-cache"] });
|
||||
}
|
||||
|
||||
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch(error => { console.error(`[verify] ${error.message}`); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const caches = new Map();
|
||||
|
||||
export function installedInputs(root, directories, append) {
|
||||
const cacheFile = path.join(root, ".turbo/local-verify/installed-digests.json");
|
||||
if (!caches.has(root)) {
|
||||
let entries = [];
|
||||
try {
|
||||
const saved = JSON.parse(readFileSync(cacheFile, "utf8"));
|
||||
if (saved.version === 1) entries = Object.entries(saved.digests).filter(([, hash]) => /^[a-f0-9]{64}$/.test(hash));
|
||||
} catch {}
|
||||
caches.set(root, new Map(entries));
|
||||
}
|
||||
const digests = caches.get(root), current = new Map();
|
||||
let dirty = false;
|
||||
const visited = new Set();
|
||||
const workspaces = new Set(directories.map(directory => path.join(root, directory)));
|
||||
const visit = absolute => {
|
||||
if (visited.has(absolute)) return;
|
||||
visited.add(absolute);
|
||||
let stat;
|
||||
try { stat = lstatSync(absolute, { bigint: true }); }
|
||||
catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
append(["installed-missing", absolute]);
|
||||
return;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = readlinkSync(absolute);
|
||||
append(["installed-link", absolute, String(stat.mode), target]);
|
||||
visit(path.resolve(path.dirname(absolute), target));
|
||||
} else if (stat.isDirectory()) {
|
||||
if (workspaces.has(absolute)) { visit(path.join(absolute, "node_modules")); return; }
|
||||
const names = readdirSync(absolute).sort().filter(name => path.basename(absolute) !== "node_modules"
|
||||
|| ![".vite", ".vite-temp", ".cache"].includes(name));
|
||||
append(["installed-directory", absolute, String(stat.mode), names]);
|
||||
for (const name of names) visit(path.join(absolute, name));
|
||||
} else if (stat.isFile()) {
|
||||
const identity = [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join(":");
|
||||
if (!digests.has(identity)) {
|
||||
digests.set(identity, createHash("sha256").update(readFileSync(absolute)).digest("hex"));
|
||||
dirty = true;
|
||||
}
|
||||
current.set(identity, digests.get(identity));
|
||||
append(["installed-file", absolute, String(stat.mode), digests.get(identity)]);
|
||||
} else throw new Error(`Unsupported installed input: ${absolute}`);
|
||||
};
|
||||
for (const directory of directories) visit(path.join(root, directory, "node_modules"));
|
||||
if (dirty || current.size !== digests.size) {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
const temporary = `${cacheFile}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify({ version: 1, digests: Object.fromEntries(current) }), { mode: 0o600 });
|
||||
renameSync(temporary, cacheFile);
|
||||
}
|
||||
caches.set(root, current);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { planTasks } from "../../.github/scripts/ci-plan.mjs";
|
||||
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
|
||||
import { selectionEligibility } from "../ci/select-tests.mjs";
|
||||
|
||||
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
|
||||
function inventory(root, env, group) {
|
||||
const args = group === "serialized" ? ["--mode", "serialized"] : ["--mode", "general", "--group", group];
|
||||
const shards = ["general-workspaces-b", "general-workspaces-c"].includes(group) ? []
|
||||
: ["--shard-index", "0", "--shard-count", "1"];
|
||||
return JSON.parse(execFileSync(process.execPath, [path.join(root, "scripts/run-vitest-stable.mjs"), ...args,
|
||||
...shards, "--dry-run"], {
|
||||
cwd: root, env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function planVerification({ root, env, diff, full = false }) {
|
||||
const config = JSON.parse(readFileSync(path.join(root, "turbo.json"), "utf8"));
|
||||
const lanes = JSON.parse(readFileSync(path.join(root, ".github/ci-task-matrix.json"), "utf8"));
|
||||
const coarse = planTasks({ config, lanes, paths: diff.changed });
|
||||
const requiredCI = diff.changed.length ? Object.entries(coarse.jobs).filter(([, job]) => job.run).map(([name]) => name) : [];
|
||||
if (full) return { kind: "full", jobs: [{ id: "full", args: null, cacheable: false }], requiredCI, selectedFiles: null, reason: "Full Vitest suite explicitly requested; opaque integration coverage runs fresh" };
|
||||
if (!productInputs(diff.changed).length) return { kind: "selected", jobs: [], requiredCI, selectedFiles: [], reason: "No changed product inputs" };
|
||||
const reason = coarse.forceReason ?? selectionEligibility(coarse, root);
|
||||
if (reason) return { kind: "broad", reason, jobs: [], requiredCI };
|
||||
try {
|
||||
const groups = [...new Set(["general_tests", "verify_serialized_server"].flatMap(job =>
|
||||
coarse.jobs[job]?.run ? coarse.jobs[job].matrix.include.map(entry => entry.group ?? "serialized") : []))];
|
||||
const inventories = new Map(groups.filter(group => group !== "general-chat").map(group => [group, inventory(root, env, group)]));
|
||||
const projects = [...new Set([...inventories.values()].flatMap(value => value.workspaceProjects ?? ["@paperclipai/server"]))];
|
||||
const { selectProjects } = await import("../ci/select-projects.mjs");
|
||||
const graphs = await selectProjects({ root, projects, changed: productInputs(diff.changed), concurrency: 1, env });
|
||||
if (graphs.some(graph => !graph.isolated)) throw new Error("Non-isolated projects require broad verification");
|
||||
const covered = new Set(graphs.flatMap(graph => graph.coveredChanges));
|
||||
const uncovered = productInputs(diff.changed).filter(file => !covered.has(file));
|
||||
if (uncovered.length) throw new Error(`Changes outside resolved test graphs: ${uncovered.join(", ")}`);
|
||||
const byProject = new Map(graphs.map(graph => [graph.project, graph]));
|
||||
const manifest = { version: 1, head: diff.head, lanes: {} }, jobs = [], selectedFiles = new Set();
|
||||
for (const group of groups) {
|
||||
if (group === "general-chat") {
|
||||
if (byProject.get("@paperclipai/server")?.selected.includes(chatSuite)) {
|
||||
selectedFiles.add(chatSuite);
|
||||
for (let index = 0; index < 6; index++) jobs.push({ id: `chat-${index + 1}`, cacheable: false, args: ["--mode", "general", "--group", group,
|
||||
"--shard-index", String(index), "--shard-count", "6", "--serial-chat"] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const value = inventories.get(group);
|
||||
const owners = value.workspaceProjects
|
||||
? Object.fromEntries(value.workspaceProjects.map(project => [project, byProject.get(project).inventory]))
|
||||
: { "@paperclipai/server": group === "serialized" ? value.selectedSerializedSuites : value.selectedGeneralServerSuites };
|
||||
const reusable = {}, fresh = {};
|
||||
for (const [project, files] of Object.entries(owners)) {
|
||||
const graph = byProject.get(project), all = new Set(graph.inventory), affected = new Set(graph.selected);
|
||||
if (!Array.isArray(files) || files.some(file => !all.has(file))) throw new Error(`Runner/graph inventory mismatch: ${group}`);
|
||||
const matches = files.filter(file => affected.has(file));
|
||||
const opaque = new Set(graph.alwaysRun);
|
||||
const pure = matches.filter(file => !opaque.has(file)), retained = matches.filter(file => opaque.has(file));
|
||||
if (pure.length) reusable[project] = pure;
|
||||
if (retained.length) fresh[project] = retained;
|
||||
matches.forEach(file => selectedFiles.add(file));
|
||||
}
|
||||
const partitions = [reusable, fresh].filter(files => Object.keys(files).length);
|
||||
if (!partitions.length) continue;
|
||||
manifest.lanes[group] = { shards: partitions };
|
||||
for (const [index, files] of partitions.entries()) jobs.push({ id: `${group}-${files === reusable ? "reusable" : "fresh"}`,
|
||||
cacheable: files === reusable, selected: true, args: ["--mode", group === "serialized" ? "serialized" : "general",
|
||||
...(group === "serialized" ? [] : ["--group", group]), "--shard-index", String(index), "--shard-count", String(partitions.length)] });
|
||||
}
|
||||
const expected = new Set(graphs.flatMap(graph => graph.selected));
|
||||
if (expected.size !== selectedFiles.size || [...expected].some(file => !selectedFiles.has(file))) {
|
||||
throw new Error("Selected tests are not completely covered by local runner ownership");
|
||||
}
|
||||
const { validateSelection } = await import("../ci/test-selection-manifest.mjs");
|
||||
validateSelection(manifest);
|
||||
return { kind: "selected", jobs, manifest, requiredCI, selectedFiles: [...selectedFiles].sort(),
|
||||
totalFiles: graphs.reduce((count, graph) => count + graph.inventory.length, 0),
|
||||
retainedFiles: graphs.reduce((count, graph) => count + graph.alwaysRun.length, 0) };
|
||||
} catch (error) {
|
||||
return { kind: "broad", reason: `Conservative selection unavailable: ${error.message}`, jobs: [], requiredCI };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { selectionHash } from "../ci/test-selection-manifest.mjs";
|
||||
import { protocolVersion } from "./inputs.mjs";
|
||||
|
||||
export const cacheDirectory = root => path.join(root, ".turbo/local-verify");
|
||||
const planHash = plan => createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
||||
|
||||
export function readPass(root, key) {
|
||||
try {
|
||||
const result = JSON.parse(readFileSync(path.join(cacheDirectory(root), `${key}.json`), "utf8"));
|
||||
return result.version === protocolVersion && result.key === key && result.status === "passed" && result.planHash === planHash(result.plan)
|
||||
&& ["selected", "full"].includes(result.plan?.kind) && Array.isArray(result.plan.jobs) ? result : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function readPlan(root, key) {
|
||||
try {
|
||||
const result = JSON.parse(readFileSync(path.join(cacheDirectory(root), `plan-${key}.json`), "utf8"));
|
||||
return result.version === protocolVersion && result.key === key && result.status === "planned" && result.planHash === planHash(result.plan)
|
||||
&& result.plan?.kind === "selected" && Array.isArray(result.plan.jobs) ? result.plan : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function mayReuseAttempt(root, key) {
|
||||
try {
|
||||
const result = JSON.parse(readFileSync(path.join(cacheDirectory(root), `attempt-${key}.json`), "utf8"));
|
||||
return result.version === protocolVersion && result.key === key && result.status === "passed";
|
||||
} catch (error) { return error.code === "ENOENT"; }
|
||||
}
|
||||
|
||||
export function writeRecord(root, name, record) {
|
||||
const directory = cacheDirectory(root);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const temporary = path.join(directory, `${name}.${randomUUID()}.tmp`);
|
||||
writeFileSync(temporary, JSON.stringify({ ...record, planHash: planHash(record.plan) }, null, 2), { mode: 0o600 });
|
||||
renameSync(temporary, path.join(directory, `${name}.json`));
|
||||
}
|
||||
|
||||
export const invalidatePass = (root, key) => rmSync(path.join(cacheDirectory(root), `${key}.json`), { force: true });
|
||||
|
||||
async function execute(command, args, root, env, label) {
|
||||
return new Promise(resolve => {
|
||||
const grouped = process.platform !== "win32";
|
||||
const child = spawn(command, args, { cwd: root, env, stdio: "inherit", detached: grouped });
|
||||
let stopped;
|
||||
let finished = false;
|
||||
const interrupt = signal => {
|
||||
stopped = signal;
|
||||
try {
|
||||
if (grouped && child.pid) process.kill(-child.pid, signal);
|
||||
else child.kill(signal);
|
||||
} catch (error) { if (error.code !== "ESRCH") throw error; }
|
||||
};
|
||||
const onInterrupt = () => interrupt("SIGINT"), onTerminate = () => interrupt("SIGTERM");
|
||||
process.on("SIGINT", onInterrupt); process.on("SIGTERM", onTerminate);
|
||||
const finish = code => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate);
|
||||
resolve(stopped ? stopped === "SIGINT" ? 130 : 143 : code);
|
||||
};
|
||||
child.once("error", error => { console.error(error.message); finish(1); });
|
||||
child.once("close", code => finish(code ?? 1));
|
||||
console.log(`[verify] Running ${label}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPlan(root, env, plan) {
|
||||
const directory = cacheDirectory(root);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const manifestFile = path.join(directory, `selection-${randomUUID()}.json`);
|
||||
if (plan.manifest) writeFileSync(manifestFile, JSON.stringify(plan.manifest));
|
||||
try {
|
||||
for (const job of plan.jobs) {
|
||||
const code = job.args === null ? await execute("pnpm", ["test:run"], root, env, job.id)
|
||||
: await execute(process.execPath, [path.join(root, "scripts/run-vitest-stable.mjs"), ...job.args], root,
|
||||
job.selected ? { ...env, PAPERCLIP_CI_TEST_SELECTION: manifestFile,
|
||||
PAPERCLIP_CI_SELECTION_HASH: selectionHash(plan.manifest) } : env, job.id);
|
||||
if (code !== 0) return code;
|
||||
}
|
||||
return 0;
|
||||
} finally { rmSync(manifestFile, { force: true }); }
|
||||
}
|
||||
@@ -267,6 +267,7 @@ function parseCliOptions(argv) {
|
||||
let shardCount = null;
|
||||
let group = null;
|
||||
let dryRun = false;
|
||||
let serialChat = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
@@ -312,6 +313,11 @@ function parseCliOptions(argv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--serial-chat") {
|
||||
serialChat = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--group") {
|
||||
group = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
@@ -337,10 +343,12 @@ function parseCliOptions(argv) {
|
||||
const shardAllowed =
|
||||
mode === serializedModeName ||
|
||||
(mode === generalModeName &&
|
||||
([...generalServerGroups, generalChatGroupName, generalWorkspacesAGroupName].includes(group)));
|
||||
([...generalServerGroups, generalChatGroupName, generalWorkspacesAGroupName].includes(group)
|
||||
|| Boolean(process.env.PAPERCLIP_CI_TEST_SELECTION)
|
||||
&& [generalWorkspacesBGroupName, generalWorkspacesCGroupName].includes(group)));
|
||||
if (!shardAllowed && shardIndex !== null) {
|
||||
fail(
|
||||
"--shard-index/--shard-count are only valid with serialized mode or a shardable general server/chat/workspaces-a group.",
|
||||
"--shard-index/--shard-count require serialized mode, a shardable general server/chat/workspaces-a group, or a selected workspace group.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,6 +360,10 @@ function parseCliOptions(argv) {
|
||||
fail(`Unknown group "${group}". Expected one of: ${allowedGeneralGroupNames.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (serialChat && (mode !== generalModeName || group !== generalChatGroupName)) {
|
||||
fail("--serial-chat is only valid with --mode general --group general-chat.");
|
||||
}
|
||||
|
||||
if (shardIndex !== null) {
|
||||
if (shardIndex >= shardCount) {
|
||||
fail(`--shard-index must be less than --shard-count. Received ${shardIndex} of ${shardCount}.`);
|
||||
@@ -374,6 +386,7 @@ function parseCliOptions(argv) {
|
||||
shardCount,
|
||||
group,
|
||||
dryRun,
|
||||
serialChat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -493,7 +506,7 @@ async function runProjectGroup(projects, groupName, shardIndex = null, shardCoun
|
||||
}
|
||||
|
||||
function chatShardWorkers(index, count) {
|
||||
const workers = count > 1 && !hasSharedTestDatabase ? 2 : 1;
|
||||
const workers = count > 1 && !hasSharedTestDatabase && !options.serialChat ? 2 : 1;
|
||||
return Array.from({ length: workers }, (_, offset) => ({
|
||||
index: (index ?? 0) * workers + offset,
|
||||
count: (count ?? 1) * workers,
|
||||
|
||||
Reference in new issue
Block a user