mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native ACPX runs admit a provider only when its saved profile and `commandDigest` match the current profile exactly. > - For Copilot, Grok and Pi, `commandDigest` hashed dozens of Paperclip source files. Claude, Codex, Cursor and Antigravity hash only their upstream release. > - Any change to a bound source therefore advanced these profiles. #18212 alone moved Copilot from v88 to v91 and Grok from v15 to v19. Each bump reset in-flight sessions and added immutable identity fixtures (about 160 on master). > - #18237 added the provider pack `contractDigest`, which now guards Runner source compatibility at the pack boundary. > - This pull request changes the Copilot, Grok and Pi digests to upstream release attestations, as the other providers already use. > - It also binds the shared ACPX layer the same way for every ACPX harness: the profile's `acpxVersion` and the pack `contractDigest`, which now covers the Runner's ACPX patch. > - The benefit is that Runner source changes no longer reset these sessions or need new profile versions and fixtures. ## Linked Issues or Issue Description Refs #18237, #18212, #17972. **What happened?** A Runner change that touched a bound source file changed the Copilot, Grok or Pi `commandDigest`. The author had to advance the profile version, regenerate an identity fixture and keep every old fixture. On deploy, saved sessions for that provider failed the exact admission check and started fresh. **Expected behavior** A provider's `commandDigest` changes only when its upstream release changes: an upstream version, a platform closure, the ACPX patch or the Grok launcher. Runner source compatibility is the job of the pack `contractDigest`. **Steps to reproduce** 1. On master, change a comment in `src/drivers/acpx/runtime-host.ts`. 2. Run `node packages/paperclip-runner/scripts/grok-profile-identity.mjs --check`. 3. The check fails. The Grok profile must advance, and the next deploy resets Grok sessions. ## What Changed - Add `packages/paperclip-runner/scripts/acpx-release-attestations.mjs`. It computes the release attestation for each provider: - Copilot and Pi: a canonical-JSON hash of the profile version, upstream package versions and the platform closure pins (Pi also binds its Node version). - Grok: the SHA-256 of `launcher.cjs`. Admission already hashes these bytes. - The attestation binds no Paperclip source, shared ACPX layer, absolute path, pack manifest digest or install layout. - Bind the shared ACPX layer once for every ACPX harness (Claude, Codex, Copilot, Cursor, Grok, Pi, Antigravity): - Add the Runner's ACPX patch (`patches/acpx@<runner acpx version>.patch`) to the pack `contractDigest`. A patch change now retires older image packs for every harness. Other packages' ACPX patches are not read. A build context without the Runner's patch fails. - Remove the ACPX patch hash from Cursor's release declaration (`cursor-contract.json`) and advance Cursor once to profile 17. Copilot, Pi and Cursor no longer bind ACPX in their own digests, like Claude, Codex, Grok and Antigravity. - Saved inputs for every harness already must match the current `acpxVersion`. - Claude and Codex digests do not change. - `generate-acpx-profiles.mjs` writes these digests to `acpx-profiles.json`. `check:acpx-profiles` refuses a stale digest. - Move the Copilot closure pins to `copilot-closure-pins.ts`, so that the generator can import them. `copilot-installation.ts` re-exports them. - Advance each profile once and then freeze it: Copilot 92, Grok 20, Pi 37. A version change at the switch keeps the rollout on the same path as every earlier profile bump. - Replace the historical version lists in `profile-compatibility.ts` with a range. Every revision from 1 through the current one decodes. Claude and Codex still decode revisions 1 through 5. Launch and recovery admission still require the exact current profile and digest. - Delete: - `scripts/copilot-profile-identity.mjs`, `scripts/grok-profile-identity.mjs` and its test - the Grok launcher source stamp - 159 Copilot, Grok and Pi identity fixtures, including three unreferenced `copilot-mainline-v*` fixtures. The Cursor fixtures stay. - Rewrite the affected tests as behaviour checks: - Each provider admits only the exact current profile. Every earlier version decodes and is refused at admission. - The last source-bound digests, and a changed digest at the current version, are refused by recovery and by the backend factory. - The Rust Pi descriptor test uses the generated profile instead of fixtures. - New `acpx-release-attestations.test.mjs`: a relocated copy of the inputs gives the same digests, each upstream input changes only its own providers, and the generator refreshes and checks the manifest. - Remove the fixture exceptions from `.dockerignore`. Add the attestation script and the generator to the Daytona image content inputs. - Update the Pi install probe and the Rust integration constant for the new Pi digest. - Update the docs to describe the current attestation model: `doc/grok-native-runner.md`, `doc/RUNNER-INTEGRATION-PLAYBOOK.md`, `doc/observability.md`, `packages/paperclip-runner/docs/durable-recovery.md`, and the Copilot, Pi and rich ACP architecture reports. Each report now opens with the current profile identity; earlier source-bound profiles remain as dated chronology. No doc references a deleted file. ## Verification - In `packages/paperclip-runner`: - `node --test scripts/acpx-release-attestations.test.mjs scripts/contracts/contract-fingerprints.test.mjs`: 19 tests pass, including a Runner ACPX patch edit that changes the contract digest, other patch edits (including `acpx@0.12.0` for `adapter-utils`) that do not, and a build context without the patch that fails. - `node scripts/generate-acpx-profiles.mjs --check` passes. - `vitest run src/drivers/acpx src/backends src/contracts`: 1647 tests pass, 9 skipped. - `tsc -p tsconfig.json --noEmit` passes. - From the repository root, `vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/daytona-image.test.ts`: 11 tests pass. - In `server`, the 8 test files that use ACPX profiles, pack digests or `contractDigest`: 1617 tests pass. - Not run locally: the Rust tests and `cargo fmt`, because this machine has no Rust toolchain. CI runs them. - Before this change, a synthetic upgrade check built two Runner revisions that differ only in shared source. A Claude checkpoint saved under the first revision rebinds under the second, and a changed Claude `commandDigest` starts fresh. This confirms that the provider JSON has no other build-dependent field. ## Risks - **One reset at rollout.** The Copilot, Grok, Pi and Cursor digests and versions change once. Saved sessions for these providers start fresh on their next run, as after any earlier profile bump. After this change, Runner source changes no longer cause this reset. - **Lost "source changed, so requalify" signal.** The old digest forced a new profile version when bound source changed. Nothing enforced requalification: Copilot was last qualified at v47. Pack-boundary contract changes are now caught by the pack `contractDigest`. - **Image packs are retired once for every provider.** Adding the ACPX patch changes the pack `contractDigest`, so the controller uploads its bundled pack (about 16 s per remote start) until images are refreshed. Claude and Codex sessions are not reset: their digests and versions do not change. - **Grok launcher and closure pins.** The Grok digest covers only the launcher bytes. Grok's runtime version is still checked separately at admission, as before. - **Grok digest equals early Grok profiles.** The launcher bytes have not changed since profile 1, so the new Grok digest equals the published profile 1 digest. The profile version still separates them: launch, recovery and the backend factory require profile 20. An image pack from that period has no `contractDigest`, so the pack gate refuses it. `remote-provider-pack-verification.test.ts` now covers this case. - **Docker context checks** run `generate-acpx-profiles.mjs --check`, which now imports `.ts` pin files through Node type stripping. `materialize-pi-distribution.mjs` already relies on this on Node 24. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, local test runs). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
24 KiB
JavaScript
483 lines
24 KiB
JavaScript
// Fingerprints the contracts that cross the Runner/provider-pack boundary.
|
|
// Contracts are declared as JSON (PRP and sidecar schemas, generated catalogs)
|
|
// and read structurally, with description text stripped; Rust shapes are read
|
|
// lexically (serde structs, payload keys, match literals, env keys). Comments,
|
|
// formatting and refactors do not change a fingerprint; a new field, accepted
|
|
// value or env key does. build-provider-pack.mjs records the combined digest so
|
|
// an image pack is reused only by a controller built from the same contracts.
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
|
|
const R = "packages/paperclip-runner";
|
|
const RUST = `${R}/runner/crates/runner-core/src`;
|
|
const DOC_KEYS = new Set(["description", "title", "$comment", "examples", "markdownDescription"]);
|
|
|
|
/** Contract registry. Each contract binds probes; a probe reads one artifact.
|
|
* Only trees present in every pack build context are read (Docker contexts
|
|
* exclude tests, fixtures and docs), so the digest is the same wherever a pack
|
|
* is built. A TypeScript contract joins the digest by moving its definition
|
|
* into a schema under protocol/ and generating the TypeScript from it. */
|
|
export const CONTRACTS = [
|
|
{ id: "prp.schemas", expand: { kind: "json-dir", dirs: [`${R}/protocol/schemas`, `${R}/protocol/provider-schemas`] } },
|
|
{ id: "generated", expand: { dirs: [`${R}/generated`], suffix: ".json", idPrefix: "generated.", probes: [{ kind: "json", stripDocs: true }] } },
|
|
// Rust file and module names are private, so the crate is one contract whose
|
|
// values are merged across files; moving code between files changes nothing.
|
|
{ id: "rust", expand: { dirs: [RUST], suffix: ".rs", aggregate: true, probes: [
|
|
{ kind: "rust-serde" }, { kind: "rust-json-keys" }, { kind: "rust-match-literals" }, { kind: "rust-env-keys" },
|
|
] } },
|
|
// Reserved and forwarded runtime env keys are const lists in Rust.
|
|
{ id: "configured-environment", probes: [{ kind: "rust-consts", path: `${RUST}/configured_environment.rs` }] },
|
|
// The Runner's ACPX patch changes the wire behaviour every ACPX harness
|
|
// shares, so it binds the pack once for all providers. Other packages may pin
|
|
// other ACPX versions; only the Runner's own version is read, and it must exist.
|
|
{ id: "acpx-patch", expand: { dirs: ["patches"], suffix: ".patch", required: true, aggregate: true, probes: [{ kind: "bytes" }],
|
|
include: readFile => {
|
|
const version = JSON.parse(readFile(`${R}/package.json`) ?? "{}").dependencies?.acpx;
|
|
return path => path === `patches/acpx@${version}.patch`;
|
|
} } },
|
|
];
|
|
|
|
/** Every hand-registered file a probe reads. */
|
|
export function probePaths() {
|
|
return CONTRACTS.flatMap(c => c.probes ?? []).map(p => p.path);
|
|
}
|
|
export function expandDirs() {
|
|
return CONTRACTS.flatMap(c => c.expand?.dirs ?? []);
|
|
}
|
|
|
|
/**
|
|
* @param {(path: string) => string | null} readFile repository-relative path
|
|
* @param {(dir: string) => string[]} listDir repository-relative file paths under dir
|
|
* @returns {Record<string, { fingerprint: string, value: unknown }>}
|
|
*/
|
|
export function fingerprintContracts(readFile, listDir) {
|
|
const out = {};
|
|
for (const contract of CONTRACTS) {
|
|
if (contract.expand?.kind === "json-dir") {
|
|
for (const path of contract.expand.dirs.flatMap(listDir).filter(p => p.endsWith(".json")).sort()) {
|
|
const value = probe({ kind: "json", path, stripDocs: true }, readFile);
|
|
out[`prp.${path.split("/").pop().replace(/\.schema\.json$|\.json$/, "")}`] = { fingerprint: digest(value), value };
|
|
}
|
|
continue;
|
|
}
|
|
if (contract.expand) {
|
|
const { dirs, suffix, include, required, exclude, idPrefix, aggregate, probes } = contract.expand;
|
|
const included = include ? include(readFile) : () => true;
|
|
const candidates = dirs.flatMap(listDir).filter(p => p.endsWith(suffix) && included(p) && !exclude?.test(p));
|
|
if (required && candidates.length === 0) throw new Error(`Registered contract source is missing: ${contract.id}`);
|
|
const testOnly = suffix === ".rs" ? rustTestOnlyFiles(candidates, readFile) : new Set();
|
|
const sources = candidates.filter(p => !testOnly.has(p)).sort();
|
|
// Env keys are often passed through `const NAME: &str = "KEY"` defined in
|
|
// another file, so resolve constant arguments crate-wide.
|
|
const constants = suffix === ".rs" ? rustStringConstants(sources, readFile) : {};
|
|
// Serde type names never reach JSON; a reference to one is replaced by the
|
|
// referenced type's shape, which may be defined in another file.
|
|
const typeShapes = suffix === ".rs" ? rustSerdeTypeShapes(sources, readFile) : {};
|
|
const crateWide = { "rust-env-keys": { constants }, "rust-serde": { typeShapes } };
|
|
const values = [];
|
|
for (const path of sources) {
|
|
const value = probes.map(p => probe({ ...p, path, ...crateWide[p.kind] }, readFile));
|
|
if (isEmpty(value)) continue;
|
|
if (aggregate) { values.push(value); continue; }
|
|
const id = idPrefix + path.slice(dirs.find(d => path.startsWith(`${d}/`)).length + 1).replace(/\.[a-z]+$/, "");
|
|
out[id] = { fingerprint: digest(value), value };
|
|
}
|
|
if (aggregate) {
|
|
const value = probes.map((_, i) => union(values.map(v => v[i])));
|
|
out[contract.id] = { fingerprint: digest(value), value };
|
|
}
|
|
continue;
|
|
}
|
|
const value = contract.probes.map(p => probe(p, readFile));
|
|
// A contract whose artifacts do not exist yet has not been introduced.
|
|
if (value.every(v => v?.absent)) continue;
|
|
out[contract.id] = { fingerprint: digest(value), value };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Combined digest of every contract in a checkout rooted at `root`. A missing
|
|
* registered file or directory is a build-context error, never an absent
|
|
* contract, so two build contexts cannot silently disagree. */
|
|
export function packContractDigest(root, { read, list } = checkoutSources(root)) {
|
|
for (const path of probePaths()) {
|
|
if (read(path) === null) throw new Error(`Registered contract source is missing: ${path}`);
|
|
}
|
|
for (const dir of expandDirs()) {
|
|
if (list(dir).length === 0) throw new Error(`Registered contract directory is missing: ${dir}`);
|
|
}
|
|
const contracts = fingerprintContracts(read, list);
|
|
return digest(Object.fromEntries(Object.entries(contracts).map(([id, c]) => [id, c.fingerprint])));
|
|
}
|
|
|
|
/** Repository-relative readers over a checkout. */
|
|
export function checkoutSources(root) {
|
|
return {
|
|
read: path => {
|
|
const absolute = join(root, path);
|
|
return existsSync(absolute) && statSync(absolute).isFile() ? readFileSync(absolute, "utf8") : null;
|
|
},
|
|
list: dir => existsSync(join(root, dir)) ? readdirSync(join(root, dir), { recursive: true, withFileTypes: true })
|
|
.filter(entry => entry.isFile())
|
|
.map(entry => relative(root, join(entry.parentPath, entry.name)).split("\\").join("/")) : [],
|
|
};
|
|
}
|
|
|
|
/** Merges probe values from several files: lists become sorted sets and
|
|
* objects merge by key. */
|
|
function union(values) {
|
|
if (values.every(Array.isArray)) return [...new Set(values.flat())].sort();
|
|
const keys = [...new Set(values.flatMap(Object.keys))].sort();
|
|
return Object.fromEntries(keys.map(k => [k, union(values.map(v => v[k] ?? []))]));
|
|
}
|
|
/** Files that expose no contract artifacts produce no contract. */
|
|
function isEmpty(value) {
|
|
const empty = v => v == null || (Array.isArray(v) ? v.every(empty) : typeof v === "object" ? Object.values(v).every(empty) : false);
|
|
return empty(value);
|
|
}
|
|
const probeCache = new Map();
|
|
function probe(spec, readFile) {
|
|
const source = readFile(spec.path);
|
|
if (source === null) return { absent: true };
|
|
const key = `${JSON.stringify(spec, (k, v) => v instanceof RegExp ? String(v) : v)}\0${createHash("sha1").update(source).digest("hex")}`;
|
|
if (!probeCache.has(key)) probeCache.set(key, probeUncached(spec, source));
|
|
return probeCache.get(key);
|
|
}
|
|
function probeUncached(spec, source) {
|
|
switch (spec.kind) {
|
|
case "json": return canonicalJson(JSON.parse(source), spec);
|
|
case "bytes": return [`${spec.path.split("/").pop()}:${digest(source)}`];
|
|
case "rust-consts": return rustConsts(source);
|
|
case "rust-serde": return rustSerde(source, spec.typeShapes ?? {});
|
|
case "rust-json-keys": return rustJsonKeys(source);
|
|
case "rust-match-literals": return rustMatchLiterals(source);
|
|
case "rust-env-keys": return rustEnvKeys(source, spec.constants ?? {});
|
|
default: throw new Error(`unknown probe kind ${spec.kind}`);
|
|
}
|
|
}
|
|
|
|
// ---------- canonical values ----------
|
|
|
|
export function digest(value) {
|
|
return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`;
|
|
}
|
|
function stableStringify(value) {
|
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
/** With `stripDocs`, drops schema annotations (description, title, $comment,
|
|
* examples) by their value shape: annotations are text or example lists, while
|
|
* a property declared as `properties.description` holds a schema object and is
|
|
* kept with its constraints. */
|
|
function canonicalJson(value, { stripDocs = false } = {}) {
|
|
if (Array.isArray(value)) return value.map(v => canonicalJson(v, { stripDocs }));
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value)
|
|
.filter(([k, v]) => !(stripDocs && isAnnotation(k, v)))
|
|
.map(([k, v]) => [k, canonicalJson(v, { stripDocs })]));
|
|
}
|
|
return value;
|
|
}
|
|
function isAnnotation(key, value) {
|
|
return DOC_KEYS.has(key) && (typeof value === "string" || (key === "examples" && Array.isArray(value)));
|
|
}
|
|
|
|
// ---------- Rust (lexical, no syn available) ----------
|
|
|
|
function stripRustComments(source) {
|
|
let out = "", i = 0;
|
|
while (i < source.length) {
|
|
const c = source[i], n = source[i + 1];
|
|
if (c === "/" && n === "/") { while (i < source.length && source[i] !== "\n") i++; continue; }
|
|
if (c === "/" && n === "*") { let depth = 1; i += 2; while (i < source.length && depth) { if (source.startsWith("/*", i)) { depth++; i += 2; } else if (source.startsWith("*/", i)) { depth--; i += 2; } else i++; } continue; }
|
|
const raw = /^r(#*)"/.exec(source.slice(i, i + 10));
|
|
if (raw && !/[\w]/.test(source[i - 1] ?? "")) {
|
|
const end = source.indexOf(`"${raw[1]}`, i + raw[0].length);
|
|
out += source.slice(i, end + 1 + raw[1].length); i = end + 1 + raw[1].length; continue;
|
|
}
|
|
if (c === '"') { let j = i + 1; while (j < source.length && source[j] !== '"') j += source[j] === "\\" ? 2 : 1; out += source.slice(i, j + 1); i = j + 1; continue; }
|
|
if (c === "'" && /^'(\\.|[^\\'])'/.test(source.slice(i, i + 4))) { const m = /^'(\\.|[^\\'])'/.exec(source.slice(i)); out += m[0]; i += m[0].length; continue; }
|
|
out += c; i++;
|
|
}
|
|
return out;
|
|
}
|
|
/** Files compiled only under `#[cfg(test)]`: modules declared as
|
|
* `#[cfg(test)] [#[path = "..."]] mod name;`, their submodules, and files under
|
|
* a test module's directory. Inline `#[cfg(test)] mod name { .. }` blocks are
|
|
* removed separately by withoutTestModules. */
|
|
function rustTestOnlyFiles(paths, readFile) {
|
|
const known = new Set(paths);
|
|
const testOnly = new Set();
|
|
const pending = [...paths];
|
|
const declaration = /#\[cfg\(test\)\]\s*((?:#\[[^\]]*\]\s*)*)(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/g;
|
|
const visit = (path) => {
|
|
const source = readFile(path);
|
|
if (source === null) return [];
|
|
const directory = path.slice(0, path.lastIndexOf("/"));
|
|
const file = path.slice(path.lastIndexOf("/") + 1);
|
|
const ownDirectory = /^(lib|main|mod)\.rs$/.test(file) ? directory : `${directory}/${file.replace(/\.rs$/, "")}`;
|
|
const found = [];
|
|
for (const m of stripRustComments(source).matchAll(declaration)) {
|
|
const explicit = /#\[path\s*=\s*"([^"]+)"\s*\]/.exec(m[1])?.[1];
|
|
// A #[path] on a module declared in a non-mod.rs file resolves from the file's directory.
|
|
const targets = explicit ? [normalizePath(`${directory}/${explicit}`)]
|
|
: [`${ownDirectory}/${m[2]}.rs`, `${ownDirectory}/${m[2]}/mod.rs`];
|
|
for (const target of targets) {
|
|
found.push(target);
|
|
const moduleDirectory = target.endsWith("/mod.rs") ? target.slice(0, -"/mod.rs".length) : target.replace(/\.rs$/, "");
|
|
for (const other of known) if (other.startsWith(`${moduleDirectory}/`)) found.push(other);
|
|
}
|
|
}
|
|
return found.filter(target => known.has(target));
|
|
};
|
|
while (pending.length) {
|
|
for (const target of visit(pending.pop())) {
|
|
if (testOnly.has(target)) continue;
|
|
testOnly.add(target);
|
|
pending.push(target);
|
|
}
|
|
}
|
|
return testOnly;
|
|
}
|
|
function normalizePath(path) {
|
|
const parts = [];
|
|
for (const part of path.split("/")) {
|
|
if (part === "..") parts.pop();
|
|
else if (part !== "." && part !== "") parts.push(part);
|
|
}
|
|
return parts.join("/");
|
|
}
|
|
function withoutTestModules(text) {
|
|
let out = text;
|
|
for (let m; (m = /#\[cfg\(test\)\]\s*mod\s+\w+\s*\{/.exec(out));) {
|
|
let depth = 0, j = m.index + m[0].length - 1;
|
|
for (; j < out.length; j++) { if (out[j] === "{") depth++; else if (out[j] === "}" && --depth === 0) break; }
|
|
out = out.slice(0, m.index) + out.slice(j + 1);
|
|
}
|
|
return out;
|
|
}
|
|
function rustStrings(text) {
|
|
return [...text.matchAll(/"((?:\\.|[^"\\])*)"/g)].map(m => m[1]);
|
|
}
|
|
function rustConsts(source) {
|
|
const text = stripRustComments(source);
|
|
const out = {};
|
|
for (const m of text.matchAll(/\b(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:[^=]+=/g)) {
|
|
const end = text.indexOf(";", m.index + m[0].length);
|
|
out[m[1]] = [...rustStrings(text.slice(m.index + m[0].length, end))].sort();
|
|
}
|
|
return out;
|
|
}
|
|
const SERDE_ITEM = /((?:#\[[^\]]*\]\s*)+)(?:pub(?:\([^)]*\))?\s+)?(struct|enum)\s+(\w+)(<[^>{]*>)?/g;
|
|
const isSerdeItem = attrs => /derive\([^)]*\b(Serialize|Deserialize)\b/.test(attrs);
|
|
|
|
/** Serialized shapes of a file's derive(Serialize|Deserialize) items: serde
|
|
* attributes and field/variant bodies. Rust type names never reach JSON, so
|
|
* item names are dropped and a reference to a crate serde type is replaced by
|
|
* that type's shape digest; renaming a type is not a contract change, while
|
|
* field and variant names, field types (including which shape a reference
|
|
* names) and serde attributes are. */
|
|
function rustSerde(source, typeShapes) {
|
|
return rustSerdeItems(source)
|
|
.map(item => serdeShape(item, name => Object.hasOwn(typeShapes, name) ? typeShapes[name] : null))
|
|
.sort();
|
|
}
|
|
function serdeShape({ kind, serde, body }, shapeOf) {
|
|
return stableStringify({ kind, serde, body: withNormalizedTypeReferences(body, shapeOf) });
|
|
}
|
|
/** derive(Serialize|Deserialize) items with whitespace-normalized bodies. */
|
|
function rustSerdeItems(source) {
|
|
const text = withoutTestModules(stripRustComments(source));
|
|
const items = [];
|
|
for (const m of text.matchAll(SERDE_ITEM)) {
|
|
const attrs = m[1];
|
|
if (!isSerdeItem(attrs)) continue;
|
|
let i = m.index + m[0].length;
|
|
while (i < text.length && text[i] !== "{" && text[i] !== ";" && text[i] !== "(") i++;
|
|
let body = "";
|
|
if (text[i] === "{" || text[i] === "(") {
|
|
const open = text[i], close = open === "{" ? "}" : ")";
|
|
let depth = 0, j = i;
|
|
for (; j < text.length; j++) { if (text[j] === open) depth++; else if (text[j] === close && --depth === 0) break; }
|
|
body = text.slice(i, j + 1);
|
|
}
|
|
// `#[serde(skip)]` fields never reach the wire or the persisted file.
|
|
const normalized = body.replace(/#\[serde\(skip\)\]\s*(?:pub(?:\([^)]*\))?\s+)?\w+\s*:[^,{}]*(?:<[^>]*>)?[^,{}]*,?/g, "")
|
|
.replace(/\bpub(\([^)]*\))?\s+/g, "").replace(/\s+/g, " ").replace(/\s*([{}(),:<>;\[\]=])\s*/g, "$1").replace(/,([})\]])/g, "$1");
|
|
items.push({
|
|
name: m[3],
|
|
kind: m[2],
|
|
serde: (attrs.match(/#\[serde\([^\]]*\)\]/g) ?? []).map(a => a.replace(/\s+/g, "")).sort(),
|
|
body: normalized,
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
/** Replaces crate serde type names in type positions (after `:`, or inside
|
|
* `(..)` and `<..>`) with `$type:<shape>`. Names directly inside `{..}` after
|
|
* `{`, `,` or an attribute are field or variant names, which serialize, and
|
|
* stay. */
|
|
function withNormalizedTypeReferences(body, shapeOf) {
|
|
const stack = [];
|
|
let previous = "";
|
|
// Quoted strings (serde renames) are wire names and pass through unchanged.
|
|
return body.replace(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_]*|[^A-Za-z0-9_]/g, token => {
|
|
if (token.startsWith("\"")) { previous = "string"; return token; }
|
|
if (/^[A-Za-z_]/.test(token)) {
|
|
const namePosition = stack.at(-1) === "{" && ["{", ",", "]"].includes(previous);
|
|
previous = "ident";
|
|
const shape = namePosition ? null : shapeOf(token);
|
|
return shape === null ? token : `$type:${shape}`;
|
|
}
|
|
if ("{(<[".includes(token)) stack.push(token);
|
|
else if ("})>]".includes(token)) stack.pop();
|
|
previous = token;
|
|
return token;
|
|
});
|
|
}
|
|
/** Shape digest of every derive(Serialize|Deserialize) item across the crate,
|
|
* keyed by its Rust name. A reference inside a shape resolves to the referenced
|
|
* shape, so the digest is independent of every Rust type name. A reference back
|
|
* into the current resolution path is recorded by its depth. Items that share a
|
|
* name in different modules resolve to all of their shapes. */
|
|
function rustSerdeTypeShapes(paths, readFile) {
|
|
const byName = new Map();
|
|
for (const path of paths) {
|
|
const source = readFile(path);
|
|
if (source === null) continue;
|
|
for (const item of rustSerdeItems(source)) byName.set(item.name, [...(byName.get(item.name) ?? []), item]);
|
|
}
|
|
const resolved = new Map();
|
|
const resolve = (name, path) => {
|
|
if (resolved.has(name)) return { shape: resolved.get(name), cyclic: false };
|
|
const back = path.indexOf(name);
|
|
if (back >= 0) return { shape: `cycle:${path.length - back}`, cyclic: true };
|
|
let cyclic = false;
|
|
const shapes = byName.get(name).map(item => serdeShape(item, reference => {
|
|
if (!byName.has(reference)) return null;
|
|
const result = resolve(reference, [...path, name]);
|
|
cyclic ||= result.cyclic;
|
|
return result.shape;
|
|
})).sort();
|
|
const shape = digest(shapes).slice("sha256:".length, "sha256:".length + 16);
|
|
// A shape inside a cycle depends on where resolution entered it.
|
|
if (!cyclic) resolved.set(name, shape);
|
|
return { shape, cyclic };
|
|
};
|
|
return Object.fromEntries([...byName.keys()].sort().map(name => [name, resolve(name, []).shape]));
|
|
}
|
|
|
|
/** Payload keys built or read in code, `json!({"key": ...})`, `["key"]` and
|
|
* `.get("key")`, grouped by enclosing function. Groups carry no function name,
|
|
* so renaming or moving a function is not a contract change, while a parser
|
|
* that starts reading a different key is, even when another function already
|
|
* reads that key. */
|
|
function rustJsonKeys(source) {
|
|
return rustScopeGroups(source, text => {
|
|
const writes = new Set(), reads = new Set();
|
|
for (const m of text.matchAll(/json!\s*\(/g)) {
|
|
const body = balanced(text, m.index + m[0].length - 1, "(", ")");
|
|
for (const k of body.matchAll(/"((?:\\.|[^"\\])*)"\s*:/g)) writes.add(k[1]);
|
|
}
|
|
for (const m of text.matchAll(/\[\s*"((?:\\.|[^"\\])*)"\s*\]|\.get\(\s*"((?:\\.|[^"\\])*)"\s*\)/g)) reads.add(m[1] ?? m[2]);
|
|
return writes.size || reads.size ? { writes: [...writes].sort(), reads: [...reads].sort() } : null;
|
|
});
|
|
}
|
|
/** Inline accept-sets, string literals in `matches!(...)` and in match-arm
|
|
* patterns, grouped by enclosing function like rustJsonKeys. */
|
|
function rustMatchLiterals(source) {
|
|
return rustScopeGroups(source, text => {
|
|
const out = new Set();
|
|
for (const m of text.matchAll(/matches!\s*\(/g)) {
|
|
for (const s of rustStrings(balanced(text, m.index + m[0].length - 1, "(", ")"))) out.add(s);
|
|
}
|
|
const lit = String.raw`"(?:\\.|[^"\\])*"`;
|
|
for (const m of text.matchAll(new RegExp(`(${lit}(?:\\s*\\|\\s*${lit})*)\\s*(?:if\\b[^=]*)?=>`, "g"))) {
|
|
for (const s of rustStrings(m[1])) out.add(s);
|
|
}
|
|
return out.size ? [...out].sort() : null;
|
|
});
|
|
}
|
|
/** Applies `collect` to each outermost function body, and to the code outside
|
|
* any function, and returns the sorted set of non-empty results. */
|
|
function rustScopeGroups(source, collect) {
|
|
const groups = rustScopes(withoutTestModules(stripRustComments(source))).map(collect).filter(Boolean);
|
|
return [...new Set(groups.map(stableStringify))].sort();
|
|
}
|
|
/** Outermost `fn` bodies, then the remaining text. String contents are masked
|
|
* while locating braces, so `"{}"` format strings do not end a body early. */
|
|
function rustScopes(text) {
|
|
const masked = text.replace(/"(?:\\.|[^"\\])*"/g, m => `"${" ".repeat(m.length - 2)}"`);
|
|
const scopes = [];
|
|
let rest = "", last = 0;
|
|
const fn = /\bfn\s+\w+/g;
|
|
for (let m; (m = fn.exec(masked));) {
|
|
// A declaration without a body ends at `;` outside the signature's (..) and [..].
|
|
let i = m.index + m[0].length, depth = 0;
|
|
for (; i < masked.length; i++) {
|
|
const c = masked[i];
|
|
if (c === "(" || c === "[") depth++;
|
|
else if (c === ")" || c === "]") depth--;
|
|
else if (depth === 0 && (c === "{" || c === ";")) break;
|
|
}
|
|
if (masked[i] !== "{") continue;
|
|
const end = i + balanced(masked, i, "{", "}").length;
|
|
rest += text.slice(last, m.index);
|
|
scopes.push(text.slice(i, end));
|
|
last = end;
|
|
fn.lastIndex = end;
|
|
}
|
|
scopes.push(rest + text.slice(last));
|
|
return scopes;
|
|
}
|
|
function balanced(text, start, open, close) {
|
|
let depth = 0, j = start;
|
|
for (; j < text.length; j++) { if (text[j] === open) depth++; else if (text[j] === close && --depth === 0) break; }
|
|
return text.slice(start, j + 1);
|
|
}
|
|
/** Environment keys passed or read in code: `keys.push(K)`, `.env(K, ..)`,
|
|
* `.env_remove(K)` and `env::var(K)` / `env::var_os(K)`, where K is a string
|
|
* literal or a string constant, and key lists: `vec![..]` or `&[..]` literals
|
|
* whose elements are all key-shaped strings or constants, at least one of them
|
|
* an upper-case `NAME_WITH_UNDERSCORE`. A constant resolves to its value, so
|
|
* renaming the Rust constant is not a contract change; an unresolved constant
|
|
* keeps its name so a rename remains visible. */
|
|
function rustEnvKeys(source, constants) {
|
|
const text = withoutTestModules(stripRustComments(source));
|
|
const keys = new Set();
|
|
const constant = String.raw`(?:[a-z_][a-z0-9_]*::)*([A-Z][A-Z0-9_]*)\b`;
|
|
const resolve = (literal, name) => literal ?? constants[name] ?? `const:${name}`;
|
|
const pattern = new RegExp(String.raw`(?:keys\.push|\.env|\.env_remove|env::var|env::var_os)\(\s*(?:"([A-Z][A-Z0-9_]*)"|${constant})`, "g");
|
|
for (const m of text.matchAll(pattern)) keys.add(resolve(m[1], m[2]));
|
|
const element = new RegExp(String.raw`^\s*(?:"([A-Za-z_][A-Za-z0-9_]*)"|${constant})\s*$`);
|
|
for (const m of text.matchAll(/(?:vec!\s*|&\s*)\[/g)) {
|
|
const body = balanced(text, m.index + m[0].length - 1, "[", "]").slice(1, -1);
|
|
const parts = body.split(",").filter((part, i, all) => i < all.length - 1 || part.trim());
|
|
const matches = parts.map(part => element.exec(part));
|
|
if (!matches.length || matches.some(e => !e)) continue;
|
|
const list = matches.map(e => resolve(e[1], e[2]));
|
|
if (list.some(key => /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(key))) for (const key of list) keys.add(key);
|
|
}
|
|
return [...keys].sort();
|
|
}
|
|
/** String constants (`const NAME: &str = "VALUE";`) across the crate's sources.
|
|
* Names defined twice with different values are ambiguous and left unresolved. */
|
|
function rustStringConstants(paths, readFile) {
|
|
const found = new Map();
|
|
for (const path of paths) {
|
|
const source = readFile(path);
|
|
if (source === null) continue;
|
|
const text = withoutTestModules(stripRustComments(source));
|
|
for (const m of text.matchAll(/\b(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:\s*&(?:'static\s+)?str\s*=\s*"([^"\\]*)"\s*;/g)) {
|
|
const values = found.get(m[1]) ?? new Set();
|
|
values.add(m[2]);
|
|
found.set(m[1], values);
|
|
}
|
|
}
|
|
return Object.fromEntries([...found].filter(([, values]) => values.size === 1).map(([name, values]) => [name, [...values][0]]).sort());
|
|
}
|