From 0f0c780061c8a79e99c49c7feb66bf05d14ab99b Mon Sep 17 00:00:00 2001 From: 0xalexbel Date: Thu, 8 Oct 2026 21:00:43 +0200 Subject: [PATCH 1/5] feat(sdk): add fhevm-npm sync pinned to apply npm-manifest.json pins to every package.json Moving a pinned dependency meant hand-editing every package.json that declares it until `check pinned-dependencies` passed. `sync pinned` copies npm-manifest.json#dependencies.pinned into every declaration instead, walking exactly the packages and dependency fields the check walks, so the package.json half of the check passes by construction. `--check` compares without writing. Each spec is replaced as text inside its dependency block and the result proven by reparsing, so formatting and every other entry stay byte for byte. Lockfiles are left to npm: the command says to run `make install` and the consumer-lock regeneration next. Co-Authored-By: Claude Opus 5.5 --- fhevm-npm/README.md | 2 + fhevm-npm/base/sync-pinned.ts | 107 ++++++++++++++++++ fhevm-npm/cli-options.ts | 21 ++++ fhevm-npm/commands/sync-pinned.ts | 41 +++++++ fhevm-npm/fhevm-npm.ts | 7 ++ fhevm-npm/test/sync-pinned.test.ts | 172 +++++++++++++++++++++++++++++ 6 files changed, 350 insertions(+) create mode 100644 fhevm-npm/base/sync-pinned.ts create mode 100644 fhevm-npm/commands/sync-pinned.ts create mode 100644 fhevm-npm/test/sync-pinned.test.ts diff --git a/fhevm-npm/README.md b/fhevm-npm/README.md index f8033c3..d65d424 100644 --- a/fhevm-npm/README.md +++ b/fhevm-npm/README.md @@ -26,6 +26,8 @@ node ./fhevm-npm.ts check cleartext-config node ./fhevm-npm.ts check generations node ./fhevm-npm.ts sync vendored node ./fhevm-npm.ts sync vendored --check +node ./fhevm-npm.ts sync pinned +node ./fhevm-npm.ts sync pinned --check node ./fhevm-npm.ts bump vendored ./host-contracts-cleartext/v13 --tag v0.13.6 node ./fhevm-npm.ts bump vendored ./host-contracts-cleartext/v13 --tag v0.13.6 --check node ./fhevm-npm.ts sync fhevm-chains --latest diff --git a/fhevm-npm/base/sync-pinned.ts b/fhevm-npm/base/sync-pinned.ts new file mode 100644 index 0000000..4578487 --- /dev/null +++ b/fhevm-npm/base/sync-pinned.ts @@ -0,0 +1,107 @@ +// `sync pinned`: copy npm-manifest.json#dependencies.pinned into every package.json that declares a pinned +// package. The manifest is the authority (rule 3.3.4); this command makes the tree agree with it, so moving a +// pin is one edit of the manifest followed by one command, instead of a hand edit of every package.json. +// +// It walks exactly what `check pinned-dependencies` walks — the same packages, the same dependency fields — so +// after a sync the package.json half of that check passes by construction. Lockfiles are deliberately left +// alone: they are npm's output, and only npm may regenerate them (`make install`, then the consumer-lock +// regeneration). Until then the check keeps reporting them as stale, which is correct. +// +// Each file is edited as text, one spec string at a time, never re-serialized: formatting, key order and every +// other entry stay byte for byte. The result is then proven: parsed, it differs from the original in the +// planned specs and nowhere else. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { NpmManifest } from '../manifest.ts'; +import { packageJsonKey } from './checks/package-names.ts'; +import { type DependencyField, type LoadedPackage, declarationsByName } from './npm.ts'; + +export type PinnedEdit = { + readonly file: string; + readonly packageKey: string; + readonly field: DependencyField; + readonly name: string; + readonly from: string; + readonly to: string; +}; + +/** Every declaration of a pinned package whose spec differs from the pin, in package then field order. */ +export function planPinnedSync(manifest: NpmManifest, packages: readonly LoadedPackage[]): readonly PinnedEdit[] { + const pinned = Object.entries(manifest.dependencies?.pinned ?? {}); + const edits: PinnedEdit[] = []; + for (const pkg of packages) { + const declared = declarationsByName(pkg.packageJson); + for (const [name, pin] of pinned) { + for (const declaration of declared.get(name) ?? []) { + if (declaration.spec === pin) continue; + edits.push({ + file: join(pkg.directory, 'package.json'), + packageKey: packageJsonKey(pkg), + field: declaration.field, + name, + from: declaration.spec, + to: pin, + }); + } + } + } + return edits; +} + +/** + * Pure: package.json text in, the same text with each edit's spec replaced out. Each edit must find exactly + * one `"": ""` line inside its field's block; anything else is refused rather than guessed. + */ +export function renderPinnedEdits(file: string, text: string, edits: readonly PinnedEdit[]): string { + const lines = text.split('\n'); + for (const edit of edits) { + const opening = new RegExp(`^(\\s*)${escapeRegExp(JSON.stringify(edit.field))}:\\s*\\{\\s*$`); + const starts = lines.flatMap((line, index) => (opening.test(line) ? [index] : [])); + if (starts.length !== 1) { + throw new Error(`${file}: expected exactly one "${edit.field}" block, found ${String(starts.length)}`); + } + const start = starts[0] ?? 0; + const indent = opening.exec(lines[start] ?? '')?.[1] ?? ''; + const end = lines.findIndex((line, index) => index > start && line.startsWith(`${indent}}`)); + const entry = new RegExp( + `^(\\s*${escapeRegExp(JSON.stringify(edit.name))}:\\s*)${escapeRegExp(JSON.stringify(edit.from))}(,?\\s*)$`, + ); + const hits = lines.flatMap((line, index) => (index > start && index < end && entry.test(line) ? [index] : [])); + const hit = hits[0]; + if (end === -1 || hits.length !== 1 || hit === undefined) { + throw new Error( + `${file}: expected exactly one "${edit.name}": "${edit.from}" in "${edit.field}", found ${String(hits.length)}`, + ); + } + lines[hit] = (lines[hit] ?? '').replace(entry, `$1${JSON.stringify(edit.to)}$2`); + } + const updated = lines.join('\n'); + + // The proof: parsed, the result is the original with exactly the planned specs changed. + const expected = JSON.parse(text) as Record | undefined>; + for (const edit of edits) { + const block = expected[edit.field]; + if (block !== undefined) block[edit.name] = edit.to; + } + if (JSON.stringify(JSON.parse(updated)) !== JSON.stringify(expected)) { + throw new Error(`${file}: rewriting the pinned specs would change more than those specs; nothing written`); + } + return updated; +} + +/** Applies the plan file by file. Every file is rendered and proven before the first one is written. */ +export function applyPinnedEdits(edits: readonly PinnedEdit[]): void { + const byFile = new Map(); + for (const edit of edits) byFile.set(edit.file, [...(byFile.get(edit.file) ?? []), edit]); + const rendered = [...byFile].map(([file, fileEdits]) => { + const text = readFileSync(file, 'utf8'); + return { file, text: renderPinnedEdits(file, text, fileEdits) }; + }); + for (const { file, text } of rendered) writeFileSync(file, text); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/fhevm-npm/cli-options.ts b/fhevm-npm/cli-options.ts index c76773c..1eca8b8 100644 --- a/fhevm-npm/cli-options.ts +++ b/fhevm-npm/cli-options.ts @@ -55,6 +55,7 @@ export type CliOptions = { | 'sync-fhevm-chains' | 'sync-hh-v2-constants' // hh-v2-constants | 'sync-vendored' + | 'sync-pinned' | 'bump-vendored' | 'test-consumer' | 'test-consumer-regenerate-package-lock'; @@ -108,6 +109,7 @@ export type CliOptions = { | { readonly command: 'sync-fhevm-chains'; readonly commit?: string; readonly latest: boolean } | { readonly command: 'sync-hh-v2-constants'; readonly check: boolean } // hh-v2-constants | { readonly command: 'sync-vendored'; readonly check: boolean; readonly digest: boolean } + | { readonly command: 'sync-pinned'; readonly check: boolean } | { readonly command: 'bump-vendored'; readonly selector: string; @@ -194,6 +196,7 @@ export function parseCliOptions(argv: readonly string[]): CliOptions { } | undefined; let syncVendored: { readonly check: boolean; readonly digest: boolean } | undefined; + let syncPinned: { readonly check: boolean } | undefined; let bumpVendored: | { readonly selector: string; @@ -467,6 +470,13 @@ Why: .action((options: { readonly check: boolean; readonly digest: boolean }) => { syncVendored = { check: options.check, digest: options.digest }; }); + sync + .command('pinned') + .description("Copy npm-manifest.json's pinned dependency specs into every package.json that declares them.") + .option('--check', 'compare instead of writing, and fail on any difference', false) + .action((options: { readonly check: boolean }) => { + syncPinned = { check: options.check }; + }); // Distinct from `sync`: this MOVES a pin, then syncs what follows from it. const bump = program.command('bump').description('Move a recorded pin, and everything derived from it.'); bump @@ -717,6 +727,7 @@ Why: !regenerateConsumerPackageLocks && testConsumer === undefined && syncVendored === undefined && + syncPinned === undefined && bumpVendored === undefined && generateExports === undefined && generateCleartextConfig === undefined && @@ -731,6 +742,16 @@ Why: } const options = program.opts(); const workspaceRoot = resolve(options.root); + if (syncPinned !== undefined) { + return { + command: 'sync-pinned', + workspaceRoot, + manifestFile: resolve(workspaceRoot, 'npm-manifest.json'), + verbosity: options.verbose, + sortPackageJson: false, + ...syncPinned, + }; + } if (syncVendored !== undefined) { return { command: 'sync-vendored', diff --git a/fhevm-npm/commands/sync-pinned.ts b/fhevm-npm/commands/sync-pinned.ts new file mode 100644 index 0000000..3ca85eb --- /dev/null +++ b/fhevm-npm/commands/sync-pinned.ts @@ -0,0 +1,41 @@ +import { packageJsonKey } from '../base/checks/package-names.ts'; +import type { CommandReport } from '../base/diagnostics.ts'; +import { loadPackages } from '../base/npm.ts'; +import { applyPinnedEdits, planPinnedSync } from '../base/sync-pinned.ts'; +import type { NpmManifest } from '../manifest.ts'; + +export function syncPinnedCommand(options: { + readonly workspaceRoot: string; + readonly manifest: NpmManifest; + readonly check: boolean; +}): CommandReport { + const packages = loadPackages(options.workspaceRoot, options.manifest); + const edits = planPinnedSync(options.manifest, packages); + const command = options.check ? 'sync pinned --check' : 'sync pinned'; + const base = { command, checkedPackageKeys: packages.map(packageJsonKey), checkedItemLabel: 'package.json file(s)' }; + + if (options.check) { + return { + ...base, + violations: edits.map((edit) => ({ + rule: '3.3.4', + packageKey: edit.packageKey, + message: `'${edit.name}' in '${edit.field}' is "${edit.from}"; npm-manifest.json#dependencies.pinned requires "${edit.to}". Run 'fhevm-npm sync pinned'`, + })), + }; + } + + applyPinnedEdits(edits); + for (const edit of edits) { + console.log(`✏️ ${edit.packageKey}: '${edit.name}' in '${edit.field}' "${edit.from}" -> "${edit.to}"`); + } + return { + ...base, + // Writing package.json leaves every lockfile that copies these specs stale until npm regenerates it. + notes: + edits.length > 0 + ? ["lockfiles are not touched: run 'make install', then 'fhevm-npm test-consumer-regenerate-package-lock'"] + : [], + violations: [], + }; +} diff --git a/fhevm-npm/fhevm-npm.ts b/fhevm-npm/fhevm-npm.ts index 6064189..53c04d6 100755 --- a/fhevm-npm/fhevm-npm.ts +++ b/fhevm-npm/fhevm-npm.ts @@ -44,6 +44,7 @@ import { publishOrder } from './commands/publish-order.ts'; import { publishPack } from './commands/publish-pack.ts'; import { publishRender } from './commands/publish-render.ts'; import { syncVendoredCommand } from './commands/sync-vendored.ts'; +import { syncPinnedCommand } from './commands/sync-pinned.ts'; import { bumpVendoredCommand } from './commands/bump-vendored.ts'; import { testConsumerRegeneratePackageLock } from './commands/test-consumer-regenerate-package-lock.ts'; import { testConsumer } from './commands/test-consumer.ts'; @@ -135,6 +136,12 @@ async function main(): Promise { return; } const manifest = loadNpmManifest(options.manifestFile); + if (options.command === 'sync-pinned') { + const report = syncPinnedCommand({ workspaceRoot: options.workspaceRoot, manifest, check: options.check }); + printReport(report, options.verbosity); + if (report.violations.length > 0) process.exitCode = 1; + return; + } if (options.command === 'sync-vendored') { const report = syncVendoredCommand({ workspaceRoot: options.workspaceRoot, diff --git a/fhevm-npm/test/sync-pinned.test.ts b/fhevm-npm/test/sync-pinned.test.ts new file mode 100644 index 0000000..a6e489e --- /dev/null +++ b/fhevm-npm/test/sync-pinned.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { validatePinnedDependencies } from '../base/checks/pinned-dependencies.ts'; +import type { LoadedPackage, PackageJson } from '../base/npm.ts'; +import { applyPinnedEdits, planPinnedSync, renderPinnedEdits } from '../base/sync-pinned.ts'; +import { parseCliOptions } from '../cli-options.ts'; +import { loadedPackage, parseTestNpmManifest } from './helpers.ts'; + +const PINS = { '@fhevm/sdk': '^0.13.6', '@fhevm/solidity': '^0.13.4' }; + +function manifestWithPins(pinned: Record = PINS) { + return parseTestNpmManifest({ + dependencies: { forbidden: ['solhint'], pinned }, + packageJson: { published: { required: ['name', 'version'], excluded: ['private'] } }, + packages: { + '.': { kind: 'workspace-root', name: 'workspace', private: true, member: false }, + './plugin': { kind: 'standalone', name: 'plugin-dev', private: true, member: false }, + './plugin/pkg': { kind: 'standalone', name: 'plugin', private: true, member: false }, + }, + }); +} + +// Formatted the way the repository's package.json files are: two spaces, one entry per line. +const pluginText = `{ + "name": "plugin", + "private": true, + "dependencies": { + "@fhevm/sdk": "^0.13.4", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@fhevm/solidity": "^0.13.3", + "hardhat": "^3.0.0" + }, + "peerDependencies": { + "@fhevm/sdk": "^0.13.4" + } +} +`; + +const pluginSynced = pluginText + .replaceAll('"@fhevm/sdk": "^0.13.4"', '"@fhevm/sdk": "^0.13.6"') + .replace('"@fhevm/solidity": "^0.13.3"', '"@fhevm/solidity": "^0.13.4"'); + +test('the plan lists every declaration of a pinned package that differs from its pin, and nothing else', () => { + const manifest = manifestWithPins(); + const packages = [ + loadedPackage('.', manifest.packages['.']!, { name: 'workspace', private: true }), + loadedPackage('./plugin', manifest.packages['./plugin']!, { + name: 'plugin-dev', + private: true, + devDependencies: { '@fhevm/sdk': '^0.13.6', typescript: '^5.0.0' }, + }), + loadedPackage('./plugin/pkg', manifest.packages['./plugin/pkg']!, JSON.parse(pluginText) as PackageJson), + ]; + + assert.deepEqual( + planPinnedSync(manifest, packages).map(({ packageKey, field, name, from, to }) => ({ + packageKey, + field, + name, + from, + to, + })), + [ + { + packageKey: './plugin/pkg/package.json', + field: 'dependencies', + name: '@fhevm/sdk', + from: '^0.13.4', + to: '^0.13.6', + }, + { + packageKey: './plugin/pkg/package.json', + field: 'peerDependencies', + name: '@fhevm/sdk', + from: '^0.13.4', + to: '^0.13.6', + }, + { + packageKey: './plugin/pkg/package.json', + field: 'devDependencies', + name: '@fhevm/solidity', + from: '^0.13.3', + to: '^0.13.4', + }, + ], + ); +}); + +test('rendering replaces exactly the planned specs and leaves the file otherwise byte for byte', () => { + const manifest = manifestWithPins(); + const pkg = loadedPackage('./plugin/pkg', manifest.packages['./plugin/pkg']!, JSON.parse(pluginText) as PackageJson); + const edits = planPinnedSync(manifest, [pkg]); + assert.equal(renderPinnedEdits('package.json', pluginText, edits), pluginSynced); + // Idempotent: a synced file plans nothing. + const synced = loadedPackage( + './plugin/pkg', + manifest.packages['./plugin/pkg']!, + JSON.parse(pluginSynced) as PackageJson, + ); + assert.deepEqual(planPinnedSync(manifest, [synced]), []); +}); + +test('rendering refuses a file it cannot edit unambiguously, rather than guessing', () => { + const edit = { + file: 'package.json', + packageKey: './plugin/pkg/package.json', + field: 'devDependencies' as const, + name: '@fhevm/solidity', + from: '^0.13.3', + to: '^0.13.4', + }; + // The declaration is not where the plan says it is. + assert.throws( + () => renderPinnedEdits('package.json', pluginText, [{ ...edit, from: '^0.13.2' }]), + /expected exactly one "@fhevm\/solidity": "\^0\.13\.2" in "devDependencies", found 0/, + ); + // A dependency block written on one line has no line to edit. + const inline = '{ "name": "plugin", "devDependencies": { "@fhevm/solidity": "^0.13.3" } }\n'; + assert.throws( + () => renderPinnedEdits('package.json', inline, [edit]), + /expected exactly one "devDependencies" block/, + ); +}); + +test('after a sync the package.json half of check pinned-dependencies passes, and the lockfiles are left alone', () => { + const root = mkdtempSync(join(tmpdir(), 'sync-pinned-')); + try { + const manifest = manifestWithPins(); + const directory = join(root, 'plugin', 'pkg'); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'package.json'), pluginText); + const lockText = '{ "packages": { "": { "dependencies": { "@fhevm/sdk": "^0.13.4" } } } }\n'; + writeFileSync(join(directory, 'package-lock.json'), lockText); + const load = (): LoadedPackage => ({ + key: './plugin/pkg', + directory, + inventory: { ...manifest.packages['./plugin/pkg']!, type: 'esm', browser: false }, + packageJson: JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as PackageJson, + }); + + const before = validatePinnedDependencies(manifest, [load()]); + assert.ok(before.some((v) => v.packageKey === './plugin/pkg/package.json')); + + applyPinnedEdits(planPinnedSync(manifest, [load()])); + assert.equal(readFileSync(join(directory, 'package.json'), 'utf8'), pluginSynced); + + // Only the stale lockfile remains: npm's to regenerate, never this command's to edit. + const after = validatePinnedDependencies(manifest, [load()]); + assert.deepEqual( + after.map((v) => v.packageKey), + ['./plugin/pkg/package-lock.json'], + ); + assert.equal(readFileSync(join(directory, 'package-lock.json'), 'utf8'), lockText); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("'sync pinned' parses with and without --check", () => { + const plain = parseCliOptions(['sync', 'pinned']); + if (plain.command !== 'sync-pinned') throw new Error(`unexpected command ${plain.command}`); + assert.equal(plain.check, false); + const checked = parseCliOptions(['sync', 'pinned', '--check']); + if (checked.command !== 'sync-pinned') throw new Error(`unexpected command ${checked.command}`); + assert.equal(checked.check, true); +}); From 4f6a1720950d79e1da57bce5b72e89b0480b9a1e Mon Sep 17 00:00:00 2001 From: 0xalexbel Date: Thu, 8 Oct 2026 21:56:50 +0200 Subject: [PATCH 2/5] feat(sdk): add make sync-pinned to move a pinned dependency in one command Runs `fhevm-npm sync pinned`, rewrites each install root's lockfile with `npm install --package-lock-only` (no node_modules, no install scripts, already-locked versions kept), regenerates the consumer fixtures' lockfiles, proves the result with `check pinned-dependencies`, then installs from the new lockfiles with `install-ci`, as CI will. Co-Authored-By: Claude Opus 5.5 --- Makefile | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 61f59c8..b2789d5 100644 --- a/Makefile +++ b/Makefile @@ -121,7 +121,7 @@ run-fhevm-npm = $(FHEVM_NPM_CLI) $(FHEVM_NPM_ARGS) $(1) # Aggregates ######################################################################################################## -.PHONY: help graph build compile rebuild ci ci-fast ci-local build-ci ci-from-scratch distclean regenerate-package-lock lint test check check-pre generate fmt fmt-check clean clean-generated install install-fast install-ci install-npm-cli +.PHONY: help graph build compile rebuild ci ci-fast ci-local build-ci ci-from-scratch distclean regenerate-package-lock lint test check check-pre generate fmt fmt-check clean clean-generated install install-fast install-ci install-npm-cli sync-pinned help: ## List the targets @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) \ @@ -262,6 +262,25 @@ install-ci: ## Install every installation root from its committed lockfile (npm npm --prefix $(DIR_HH_V3) ci --no-audit --no-fund $(call run-fhevm-npm,install-forge-dependencies) +# Move a pinned dependency: edit its spec in npm-manifest.json#dependencies.pinned, then run this. +# 1. `sync pinned` copies every pin into each package.json that declares it. +# 2. `npm install --package-lock-only` rewrites each install root's lockfile, and nothing else: no +# node_modules, no install scripts. Already-locked versions that still satisfy their ranges stay, so +# only what the moved pins reach is re-resolved. fhevm-npm's root declares no pinned package. +# 3. The consumer fixtures' lockfiles, which no install root covers, are regenerated by fhevm-npm. +# 4. `check pinned-dependencies` proves every package.json and lockfile now agrees with the pins, then +# `install-ci` installs exactly what the new lockfiles say, as CI will. +# Review the lockfile diff before committing. A version younger than safe-chain's minimum package age +# cannot be resolved yet. +sync-pinned: ## Apply npm-manifest.json's pins to every package.json, regenerate every lockfile, then install from them + $(call run-fhevm-npm,sync pinned) + npm install --package-lock-only --ignore-scripts --no-audit --no-fund + npm --prefix $(DIR_HH_V2) install --package-lock-only --ignore-scripts --no-audit --no-fund + npm --prefix $(DIR_HH_V3) install --package-lock-only --ignore-scripts --no-audit --no-fund + $(call run-fhevm-npm,test-consumer-regenerate-package-lock) + $(call run-fhevm-npm,check pinned-dependencies) + $(MAKE) install-ci + # Deliberately NOT `git clean`: pattern-based deletion cannot distinguish a build output from a # developer's .vscode or .env, and gitignored files are invisible to the spotless guard — an editor # setup or a secret must never be the price of a from-scratch test. Everything here is explicit and From 58a96b1eed33ef16fb233632005a9560ffb6724c Mon Sep 17 00:00:00 2001 From: 0xalexbel Date: Thu, 8 Oct 2026 22:42:42 +0200 Subject: [PATCH 3/5] chore(sdk): ignore the scratch fhevm-npm leaves in the tree after an interrupted run Co-Authored-By: Claude Opus 5.5 --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitignore b/.gitignore index ca477f0..ab3c884 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,17 @@ out/ temp/ tmp/ +# fhevm-npm scratch written inside the tree. Every one is removed in a `finally` when its run ends, so they +# only survive an interrupted run (Ctrl-C ends node without running `finally`). Nothing tracked matches. +# .fhevm-npm-lock-*/ test-consumer-regenerate-package-lock: the staging copy of a consumer fixture. +# It sits beside the fixture, not in the system temp dir, so the fixture's +# relative file: links to the payloads still resolve. +# .package-lock.json.*.tmp the same command: the new lockfile, written beside the old one and renamed over it. +# fhevm-npm/.tmp-*/ fhevm-npm tests that need a scratch workspace under the CLI's own root. +.fhevm-npm-lock-*/ +.package-lock.json.*.tmp +fhevm-npm/.tmp-*/ + # Forge scratch output from the create2-deploy scripts. Each cleartext generation already ignores these, # but npm-manifest.json reserves v14 and v15, and a generation added without its own .gitignore would # otherwise leak them. From 0f84090675a46c739a703770827914aa3d076650 Mon Sep 17 00:00:00 2001 From: 0xalexbel Date: Thu, 8 Oct 2026 22:55:52 +0200 Subject: [PATCH 4/5] fix(sdk): update a consumer fixture's lockfile from the committed one instead of from scratch test-consumer-regenerate-package-lock deleted the fixture's lockfile before running npm, so every regeneration moved every dependency to the newest version its range allowed. A pin bump through `make sync-pinned` dragged unrelated updates into fixtures that do not even use the pinned package (@types/node, tsx and typescript in all four host-contracts fixtures). npm now starts from the committed lockfile, as `npm install` does in an installation root, so only what changed is re-resolved. `--fresh` keeps the old behaviour for a deliberate refresh. A local payload whose locked version went stale still fails the existing validation instead of being written. Co-Authored-By: Claude Opus 5.5 --- fhevm-npm/base/test-consumer.ts | 23 +++++++++- fhevm-npm/cli-options.ts | 16 ++++++- fhevm-npm/fhevm-npm.ts | 1 + .../test/test-consumer-package-lock.test.ts | 44 +++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/fhevm-npm/base/test-consumer.ts b/fhevm-npm/base/test-consumer.ts index c73af8b..d8317a7 100644 --- a/fhevm-npm/base/test-consumer.ts +++ b/fhevm-npm/base/test-consumer.ts @@ -103,6 +103,8 @@ export type RegenerateTestConsumerPackageLocksOptions = { readonly manifest: NpmManifest; readonly packageSelector?: string; readonly verbosity: Verbosity; + /** Resolve every dependency again instead of updating the committed lock; see regenerateFixturePackageLock. */ + readonly fresh?: boolean; }; export type NpmRunner = (directory: string, args: readonly string[], verbosity?: Verbosity) => void; @@ -349,7 +351,13 @@ export function regenerateTestConsumerPackageLocks( } for (const target of isolated) { - regenerateFixturePackageLock(target.source.directory, runner, target.linkedDependencies, options.verbosity); + regenerateFixturePackageLock( + target.source.directory, + runner, + target.linkedDependencies, + options.verbosity, + options.fresh ?? false, + ); console.log(`✅ Regenerated ${target.source.key}/package-lock.json (${target.moduleKind.toUpperCase()})`); } } @@ -361,16 +369,27 @@ function partition(values: readonly T[], predicate: (value: T) => boolean): [ return [matching, rest]; } +/** + * Regenerates one fixture's lockfile in a staging copy beside it, validates it, and swaps it in atomically. + * + * By default npm starts from the committed lock, as `npm install` does in an installation root: a locked version + * that still satisfies its range stays, so only what changed — a moved pin, a new declaration — is re-resolved. + * Starting from no lock instead (`fresh`) moves every dependency to the newest version its range allows, which is + * a deliberate refresh, not something a pin change should drag along. A local payload whose version moved without + * its lock entry following is not trusted to npm either way: the validation below refuses a lock that records it + * at another version, so a stale entry fails loudly instead of being committed. + */ export function regenerateFixturePackageLock( fixtureDirectory: string, runner: NpmRunner = runNpm, linkedDependencies: readonly LinkedDependency[] = [], verbosity: Verbosity = 0, + fresh = false, ): void { const stagingDirectory = mkdtempSync(join(dirname(fixtureDirectory), '.fhevm-npm-lock-')); try { copyFixture(fixtureDirectory, stagingDirectory); - rmSync(join(stagingDirectory, 'package-lock.json'), { force: true }); + if (fresh) rmSync(join(stagingDirectory, 'package-lock.json'), { force: true }); const injected = injectTransitiveDependencies(stagingDirectory, linkedDependencies); runner( stagingDirectory, diff --git a/fhevm-npm/cli-options.ts b/fhevm-npm/cli-options.ts index 1eca8b8..66d0267 100644 --- a/fhevm-npm/cli-options.ts +++ b/fhevm-npm/cli-options.ts @@ -118,7 +118,11 @@ export type CliOptions = { readonly commit?: string; readonly check: boolean; } - | { readonly command: 'test-consumer-regenerate-package-lock'; readonly packageSelector?: string } + | { + readonly command: 'test-consumer-regenerate-package-lock'; + readonly packageSelector?: string; + readonly fresh: boolean; + } | { readonly command: 'test-consumer'; readonly packageSelector?: string; @@ -211,6 +215,7 @@ export function parseCliOptions(argv: readonly string[]): CliOptions { let checkDeployments: { readonly base?: string } | undefined; let syncHhV2Constants: { readonly check: boolean } | undefined; // hh-v2-constants let regenerateConsumerPackageLocks = false; + let regenerateConsumerPackageLocksFresh = false; let regenerateConsumerPackageLockSelector: string | undefined; let testConsumer: | { @@ -701,9 +706,15 @@ Why: program .command('test-consumer-regenerate-package-lock [package]') .description('Rebuild the package-lock.json of the standalone test projects.') - .action((packageSelector: string | undefined) => { + .option( + '--fresh', + 'resolve every dependency again from no lock, instead of updating the committed one; moves every in-range version', + false, + ) + .action((packageSelector: string | undefined, options: { readonly fresh: boolean }) => { regenerateConsumerPackageLocks = true; regenerateConsumerPackageLockSelector = packageSelector; + regenerateConsumerPackageLocksFresh = options.fresh; }); program.parse([...argv], { from: 'user' }); @@ -850,6 +861,7 @@ Why: verbosity: options.verbose, sortPackageJson: false, packageSelector: regenerateConsumerPackageLockSelector, + fresh: regenerateConsumerPackageLocksFresh, }; } if (mirrorPackageSelector !== undefined) { diff --git a/fhevm-npm/fhevm-npm.ts b/fhevm-npm/fhevm-npm.ts index 53c04d6..b62f6b1 100755 --- a/fhevm-npm/fhevm-npm.ts +++ b/fhevm-npm/fhevm-npm.ts @@ -246,6 +246,7 @@ async function main(): Promise { manifest, packageSelector: options.packageSelector, verbosity: options.verbosity, + fresh: options.fresh, }); return; } diff --git a/fhevm-npm/test/test-consumer-package-lock.test.ts b/fhevm-npm/test/test-consumer-package-lock.test.ts index 5e87888..00e7eb2 100644 --- a/fhevm-npm/test/test-consumer-package-lock.test.ts +++ b/fhevm-npm/test/test-consumer-package-lock.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; +import { parseCliOptions } from '../cli-options.ts'; + import { consumerInstallArguments, consumerInstallMode, @@ -96,6 +98,48 @@ test('regenerates and validates a consumer lock atomically in a sibling staging } }); +test('npm starts from the committed lock by default, and from no lock only when fresh', () => { + // What npm finds in the staging copy when `install` runs decides how much moves: with the committed lock, + // only what no longer satisfies its range; without one, every dependency to the newest version in range. + const seenAtInstall = (fresh: boolean): string | undefined => { + const root = join(tmpdir(), `fhevm-npm-consumer-lock-mode-${String(process.pid)}-${String(Date.now())}`); + const fixture = join(root, 'test-consumer', 'esm'); + mkdirSync(fixture, { recursive: true }); + writeFileSync(join(fixture, 'package.json'), '{"name":"consumer","private":true,"type":"module"}\n'); + writeFileSync(join(fixture, 'package-lock.json'), '{"committed":true}\n'); + let seen: string | undefined; + try { + regenerateFixturePackageLock( + fixture, + (directory, args) => { + if (args[0] !== 'install') return; + const lock = join(directory, 'package-lock.json'); + seen = existsSync(lock) ? readFileSync(lock, 'utf8') : undefined; + writeFileSync(lock, '{"name":"consumer","lockfileVersion":3,"packages":{"":{}}}\n'); + }, + [], + 0, + fresh, + ); + return seen; + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; + + assert.equal(seenAtInstall(false), '{"committed":true}\n'); + assert.equal(seenAtInstall(true), undefined); +}); + +test("'test-consumer-regenerate-package-lock' keeps the committed lock unless --fresh is given", () => { + const plain = parseCliOptions(['test-consumer-regenerate-package-lock']); + if (plain.command !== 'test-consumer-regenerate-package-lock') throw new Error(`unexpected ${plain.command}`); + assert.equal(plain.fresh, false); + const fresh = parseCliOptions(['test-consumer-regenerate-package-lock', './hardhat/v3/plugin', '--fresh']); + if (fresh.command !== 'test-consumer-regenerate-package-lock') throw new Error(`unexpected ${fresh.command}`); + assert.deepEqual([fresh.packageSelector, fresh.fresh], ['./hardhat/v3/plugin', true]); +}); + test('keeps the committed lock when regeneration validation fails', () => { const root = join(tmpdir(), `fhevm-npm-consumer-lock-failure-${String(process.pid)}-${String(Date.now())}`); const fixture = join(root, 'test-consumer', 'esm'); From e7ff6c9fa7616dd6ae93cf3c864178aeb488f58c Mon Sep 17 00:00:00 2001 From: 0xalexbel Date: Thu, 8 Oct 2026 22:59:34 +0200 Subject: [PATCH 5/5] feat(sdk): sync the Hardhat 2 plugin constants in make sync-pinned A @fhevm/solidity pin bump left `make check-pre` failing until `sync hh-v2-constants` was run by hand. `make sync-pinned` now runs it right after `sync pinned`; it writes only when the constants are stale, so any other pin bump leaves them untouched. Tagged hh-v2-constants for removal with hardhat/v2. Co-Authored-By: Claude Opus 5.5 --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b2789d5..7a6de1f 100644 --- a/Makefile +++ b/Makefile @@ -263,7 +263,8 @@ install-ci: ## Install every installation root from its committed lockfile (npm $(call run-fhevm-npm,install-forge-dependencies) # Move a pinned dependency: edit its spec in npm-manifest.json#dependencies.pinned, then run this. -# 1. `sync pinned` copies every pin into each package.json that declares it. +# 1. `sync pinned` copies every pin into each package.json that declares it, and `sync hh-v2-constants` +# records the @fhevm/solidity pin in the Hardhat 2 plugin's constants (a no-op for any other pin). # 2. `npm install --package-lock-only` rewrites each install root's lockfile, and nothing else: no # node_modules, no install scripts. Already-locked versions that still satisfy their ranges stay, so # only what the moved pins reach is re-resolved. fhevm-npm's root declares no pinned package. @@ -274,6 +275,8 @@ install-ci: ## Install every installation root from its committed lockfile (npm # cannot be resolved yet. sync-pinned: ## Apply npm-manifest.json's pins to every package.json, regenerate every lockfile, then install from them $(call run-fhevm-npm,sync pinned) +# hh-v2-constants: Hardhat 2 only, delete the next line with hardhat/v2. + $(call run-fhevm-npm,sync hh-v2-constants) npm install --package-lock-only --ignore-scripts --no-audit --no-fund npm --prefix $(DIR_HH_V2) install --package-lock-only --ignore-scripts --no-audit --no-fund npm --prefix $(DIR_HH_V3) install --package-lock-only --ignore-scripts --no-audit --no-fund