Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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) \
Expand Down Expand Up @@ -262,6 +262,28 @@ 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, 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.
# 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)
# 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
$(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
Expand Down
2 changes: 2 additions & 0 deletions fhevm-npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions fhevm-npm/base/sync-pinned.ts
Original file line number Diff line number Diff line change
@@ -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 `"<name>": "<from>"` 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<string, Record<string, string> | 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<string, PinnedEdit[]>();
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, '\\$&');
}
23 changes: 21 additions & 2 deletions fhevm-npm/base/test-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()})`);
}
}
Expand All @@ -361,16 +369,27 @@ function partition<T>(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,
Expand Down
37 changes: 35 additions & 2 deletions fhevm-npm/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -116,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;
Expand Down Expand Up @@ -194,6 +200,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;
Expand All @@ -208,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:
| {
Expand Down Expand Up @@ -467,6 +475,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
Expand Down Expand Up @@ -691,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' });
Expand All @@ -717,6 +738,7 @@ Why:
!regenerateConsumerPackageLocks &&
testConsumer === undefined &&
syncVendored === undefined &&
syncPinned === undefined &&
bumpVendored === undefined &&
generateExports === undefined &&
generateCleartextConfig === undefined &&
Expand All @@ -731,6 +753,16 @@ Why:
}
const options = program.opts<RawOptions>();
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',
Expand Down Expand Up @@ -829,6 +861,7 @@ Why:
verbosity: options.verbose,
sortPackageJson: false,
packageSelector: regenerateConsumerPackageLockSelector,
fresh: regenerateConsumerPackageLocksFresh,
};
}
if (mirrorPackageSelector !== undefined) {
Expand Down
41 changes: 41 additions & 0 deletions fhevm-npm/commands/sync-pinned.ts
Original file line number Diff line number Diff line change
@@ -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: [],
};
}
Loading
Loading