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. diff --git a/Makefile b/Makefile index 61f59c8..7a6de1f 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,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 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/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 c76773c..66d0267 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; @@ -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; @@ -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; @@ -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: | { @@ -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 @@ -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' }); @@ -717,6 +738,7 @@ Why: !regenerateConsumerPackageLocks && testConsumer === undefined && syncVendored === undefined && + syncPinned === undefined && bumpVendored === undefined && generateExports === undefined && generateCleartextConfig === undefined && @@ -731,6 +753,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', @@ -829,6 +861,7 @@ Why: verbosity: options.verbose, sortPackageJson: false, packageSelector: regenerateConsumerPackageLockSelector, + fresh: regenerateConsumerPackageLocksFresh, }; } if (mirrorPackageSelector !== undefined) { 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..b62f6b1 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, @@ -239,6 +246,7 @@ async function main(): Promise { manifest, packageSelector: options.packageSelector, verbosity: options.verbosity, + fresh: options.fresh, }); return; } 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); +}); 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');