From c218953aaffbe2a236f97ce4a9db8ce8860cb782 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 20 Oct 2026 13:09:17 -0700 Subject: [PATCH 1/2] build(deps): relock Jedit to new Echo and Edict commits in one command scripts/deps/echo-edict-relock.mjs takes clean Echo and Edict checkouts and regenerates every derived artifact: host and conformance-runtime Echo pins, Wesley host bindings, the ReplaceRange oracle, the lawpack's oracle digests, the republished jedit.text vendor and import digest, the toolchain lock (provider pins and validation-file hashes), and the build and executable-subject locks, then runs the package-chain proof. A host that no longer compiles stops the run before generated artifacts change. Pure helpers are pinned by spec/echo-edict-relock.spec.mjs. Run against Echo 0faaf58 and Edict 2ddc069 on the #374 tree, the tool reproduces the hand relock byte for byte and the chain verifies. --- CHANGELOG.md | 6 + edict/replace-range/README.md | 10 ++ scripts/deps/echo-edict-relock.mjs | 257 +++++++++++++++++++++++++++++ spec/echo-edict-relock.spec.mjs | 154 +++++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 scripts/deps/echo-edict-relock.mjs create mode 100644 spec/echo-edict-relock.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7b3e05..e9151d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- `scripts/deps/echo-edict-relock.mjs` moves Jedit to new Echo and Edict commits in + one command: Echo pins, regenerated host bindings, the ReplaceRange oracle, the + republished `jedit.text` lawpack and import digest, and every proof lock, + followed by the package-chain proof. Run against the current pins it + reproduces them byte for byte. + - Jim now runs on current Echo and Edict. The native host had been pinned to Echo `90f63ae` (2026-07-17), 1054 commits behind main, so Jim never exercised the Echo being built. It now builds against Echo main `0faaf58`, with contract diff --git a/edict/replace-range/README.md b/edict/replace-range/README.md index f2a6b56c..60a81f2c 100644 --- a/edict/replace-range/README.md +++ b/edict/replace-range/README.md @@ -112,3 +112,13 @@ the other. The next gate must exercise an authored rope consequence under generic Echo stateful execution and compare it with the independent oracle. This pure boundary witness is not a substitute for that evidence. + +## Moving to new Echo and Edict commits + +`scripts/deps/echo-edict-relock.mjs` moves Jedit to the Echo and Edict commits checked out in two clean checkouts and regenerates everything derived from them: the host's and conformance runtime's Echo pins, the host's Wesley contract bindings, the ReplaceRange oracle, this lawpack's oracle digests, the vendored `jedit-text` publication and the source's import digest, the toolchain lock, and the build and executable-subject locks. It then runs `tests/build.sh`. Run it with the Node, npm, and Rust releases this toolchain lock pins: + +```bash +node scripts/deps/echo-edict-relock.mjs --echo-repo /path/to/echo --edict-repo /path/to/edict +``` + +If the host stops compiling against the new Echo, the run stops before any generated artifact changes; that is source work, not relocking. diff --git a/scripts/deps/echo-edict-relock.mjs b/scripts/deps/echo-edict-relock.mjs new file mode 100644 index 00000000..b69537b8 --- /dev/null +++ b/scripts/deps/echo-edict-relock.mjs @@ -0,0 +1,257 @@ +// Moves Jedit to the Echo and Edict commits checked out in two clean +// checkouts, regenerates everything that derives from them, and proves the +// result with the ReplaceRange package chain. +// +// node scripts/deps/echo-edict-relock.mjs --echo-repo DIR --edict-repo DIR +// +// Run it with the Node, npm, and Rust releases pinned in +// edict/replace-range/edict.toolchain-lock.json; the package chain refuses +// anything else. A host that no longer compiles against the new Echo stops the +// run before any generated artifact is rewritten. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { decode } from 'cbor-x'; + +const ECHO_GIT = 'https://github.com/flyingrobots/echo'; +const TEXT_LAWPACK = 'jedit.text@1'; +const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const HOST_ROOT = path.join(PROJECT_ROOT, 'native', 'jedit-echo-host'); +const APPLICATION_ROOT = path.join(PROJECT_ROOT, 'edict', 'replace-range'); +const ORACLE_PATH = path.join( + PROJECT_ROOT, 'contracts', 'jedit', 'lawpacks', 'replace-range-v1', 'replace-range-v1.oracle.json', +); +const PROVIDER_PACKAGE = path.join('schemas', 'edict-provider', 'package', 'v1'); +const PROVIDER_COMPONENTS = Object.freeze([ + ['lowerer', 'lowerer.echo-dpo', 'lowerer.echo-dpo.component.wasm'], + ['verifier', 'verifier.echo-dpo', 'verifier.echo-dpo.component.wasm'], +]); + +function sha256Hex(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Points every Cargo dependency on `gitUrl` at `rev`. */ +export function withGitRev(manifest, gitUrl, rev) { + const pattern = new RegExp(`(git = "${escapeRegExp(gitUrl)}", rev = ")[0-9a-f]{40}(")`, 'g'); + if (!pattern.test(manifest)) { + throw new Error(`manifest has no dependency on ${gitUrl}`); + } + return manifest.replace(pattern, `$1${rev}$2`); +} + +/** Names the Cargo dependencies that come from `gitUrl`. */ +export function gitDependencyNames(manifest, gitUrl) { + const pattern = new RegExp(`^([A-Za-z0-9_-]+) = \\{ git = "${escapeRegExp(gitUrl)}"`, 'gm'); + return [...manifest.matchAll(pattern)].map((match) => match[1]); +} + +/** Binds the lawpack's compatibility and conformance digests to the oracle. */ +export function withOracleDigests(lawpack, { sourceSetHex, oracleSha256Hex }) { + const bound = structuredClone(lawpack); + bound.lawpack.compatibility.digest = `sha256:${sourceSetHex}`; + bound.lawpack.conformanceFixtureCorpus.digest = `sha256:${oracleSha256Hex}`; + return bound; +} + +/** Rewrites the pinned digest of one `use lawpack` import. */ +export function withImportDigest(source, lawpack, digest) { + const pattern = new RegExp(`(use lawpack ${escapeRegExp(lawpack)} digest ")sha256:[0-9a-f]+(")`); + if (!pattern.test(source)) { + throw new Error(`source has no import of ${lawpack}`); + } + return source.replace(pattern, `$1${digest}$2`); +} + +/** Returns the manifest digest Edict published for a lawpack. */ +export function lawpackManifestDigest(output) { + const manifests = output.artifacts.filter((artifact) => artifact.kind === 'manifest'); + if (manifests.length !== 1) { + throw new Error(`lawpack output must name one manifest artifact, found ${manifests.length}`); + } + return manifests[0].digest; +} + +function providerResource(manifest, role) { + const matches = manifest.artifacts.filter((artifact) => artifact.role === role); + if (matches.length !== 1) { + throw new Error(`provider must contain one ${role} artifact`); + } + return matches[0].resource; +} + +function componentPin(root, manifest, [role, fileName]) { + const resource = providerResource(manifest, role); + const sha256 = sha256Hex(readFileSync(path.join(root, 'components', fileName))); + if (resource.digest !== `sha256:${sha256}`) { + throw new Error(`${role} component bytes disagree with the provider manifest`); + } + return { coordinate: resource.coordinate, sha256 }; +} + +/** Derives the toolchain-lock provider entry from Echo's checked provider package. */ +export function providerPins(root) { + const manifestBytes = readFileSync(path.join(root, 'provider-manifest.echo.json')); + const manifest = JSON.parse(manifestBytes); + const [lowerer, verifier] = PROVIDER_COMPONENTS.map(([, role, fileName]) => + componentPin(root, manifest, [role, fileName])); + const targetProfile = providerResource(manifest, 'target-profile.echo-dpo'); + const profile = decode(readFileSync(path.join(root, 'generated', 'primary', 'target-profile.echo-dpo.cbor'))); + return { + coordinate: manifest.provider.coordinate, + digest: manifest.provider.digest, + manifestSha256: sha256Hex(manifestBytes), + targetProfile: { coordinate: targetProfile.coordinate, digest: targetProfile.digest }, + lowerer, + verifier: { + ...verifier, + contract: { + coordinate: profile.verifier.id, + digest: `sha256:${Buffer.from(profile.verifier.digest[1]).toString('hex')}`, + }, + }, + }; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...options }); + if (result.status !== 0) { + process.stderr.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + throw new Error(`${command} ${args.join(' ')} exited with status ${result.status}`); + } + return result.stdout; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function cleanCommit(repository, label) { + if (run('git', ['-C', repository, 'status', '--porcelain=v1', '--untracked-files=all']) !== '') { + throw new Error(`${label} checkout must be clean`); + } + return run('git', ['-C', repository, 'rev-parse', 'HEAD']).trim(); +} + +function moveCargoPins(manifestPath, echoCommit) { + const manifest = readFileSync(manifestPath, 'utf8'); + writeFileSync(manifestPath, withGitRev(manifest, ECHO_GIT, echoCommit)); + const packages = gitDependencyNames(manifest, ECHO_GIT).flatMap((name) => ['-p', name]); + run('cargo', ['update', '--manifest-path', manifestPath, ...packages]); +} + +function regenerateHostBindings(echoRepository) { + const generated = path.join(HOST_ROOT, 'src', 'generated', 'contract.rs'); + run('cargo', [ + 'run', '-q', '-p', 'echo-wesley-gen', '--', + '--schema', path.join(PROJECT_ROOT, 'contracts', 'jedit', 'echo-text.graphql'), + '--contract-host', '--out', generated, + ], { cwd: echoRepository }); + run('rustfmt', ['--edition', '2021', generated]); + run('cargo', ['build', '--manifest-path', path.join(HOST_ROOT, 'Cargo.toml'), '--all-targets']); +} + +function edictCompiler(edictRepository, toolchain) { + const output = run('cargo', [ + `+${toolchain}`, 'build', '--locked', '-p', 'edict-cli', '--message-format=json-render-diagnostics', + ], { cwd: edictRepository }); + const executables = output.split('\n').filter(Boolean).map((line) => JSON.parse(line)) + .filter((artifact) => artifact.reason === 'compiler-artifact' && artifact.target.name === 'edict' + && artifact.target.kind.includes('bin') && typeof artifact.executable === 'string'); + if (executables.length !== 1) { + throw new Error('Cargo must emit one Edict executable'); + } + return executables[0].executable; +} + +function edictBuild(compiler, settings) { + run(compiler, [], { + cwd: APPLICATION_ROOT, + input: `${JSON.stringify({ schema: 'edict.compiler.settings/v1', type: 'compilerSettings', operation: 'build', ...settings })}\n`, + }); +} + +function relockToolchain(lockPath, { echoRepository, echoCommit, edictCommit, cliVersion }) { + const lock = readJson(lockPath); + lock.edict = { ...lock.edict, commit: edictCommit, cliVersion }; + lock.echo = { commit: echoCommit, provider: providerPins(path.join(echoRepository, PROVIDER_PACKAGE)) }; + lock.validation.files = lock.validation.files.map((file) => ({ + path: file.path, + sha256: sha256Hex(readFileSync(path.join(PROJECT_ROOT, file.path))), + })); + writeJson(lockPath, lock); + return lock; +} + +function republishApplication({ echoRepository, edictRepository, toolchain }) { + const compiler = edictCompiler(edictRepository, toolchain); + const cliVersion = JSON.parse(run(compiler, ['--version'])).version; + edictBuild(compiler, { lawpack: 'edict.lawpack.json' }); + const sourcePath = path.join(APPLICATION_ROOT, 'src', 'ReplaceRange.edict'); + const published = readJson(path.join(APPLICATION_ROOT, 'vendor', 'jedit-text', 'edict.lawpack-output.json')); + writeFileSync(sourcePath, withImportDigest(readFileSync(sourcePath, 'utf8'), TEXT_LAWPACK, lawpackManifestDigest(published))); + const buildRoot = path.join(APPLICATION_ROOT, '.build'); + rmSync(path.join(buildRoot, 'echo-provider'), { recursive: true, force: true }); + rmSync(path.join(buildRoot, 'application'), { recursive: true, force: true }); + mkdirSync(path.join(buildRoot, 'echo-provider'), { recursive: true }); + cpSync(path.join(echoRepository, PROVIDER_PACKAGE), path.join(buildRoot, 'echo-provider'), { recursive: true }); + edictBuild(compiler, { application: 'edict.application.json' }); + return cliVersion; +} + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 2) { + options[argv[index]] = argv[index + 1]; + } + const echoRepository = options['--echo-repo']; + const edictRepository = options['--edict-repo']; + if (!echoRepository || !edictRepository || Object.keys(options).length !== 2) { + throw new Error('usage: echo-edict-relock.mjs --echo-repo DIR --edict-repo DIR'); + } + return { echoRepository: path.resolve(echoRepository), edictRepository: path.resolve(edictRepository) }; +} + +function main(argv) { + const { echoRepository, edictRepository } = parseArguments(argv); + const echoCommit = cleanCommit(echoRepository, 'Echo'); + const edictCommit = cleanCommit(edictRepository, 'Edict'); + const lockPath = path.join(APPLICATION_ROOT, 'edict.toolchain-lock.json'); + const toolchain = readJson(lockPath).rust.toolchain; + + moveCargoPins(path.join(HOST_ROOT, 'Cargo.toml'), echoCommit); + moveCargoPins(path.join(APPLICATION_ROOT, 'tests', 'runtime', 'Cargo.toml'), echoCommit); + regenerateHostBindings(echoRepository); + run('npm', ['run', '-s', 'lawpack:replace-range:update'], { cwd: PROJECT_ROOT }); + + const lawpackPath = path.join(APPLICATION_ROOT, 'edict.lawpack.json'); + writeJson(lawpackPath, withOracleDigests(readJson(lawpackPath), { + sourceSetHex: readJson(ORACLE_PATH).sourceSet.digestHex, + oracleSha256Hex: sha256Hex(readFileSync(ORACLE_PATH)), + })); + const cliVersion = republishApplication({ echoRepository, edictRepository, toolchain }); + relockToolchain(lockPath, { echoRepository, echoCommit, edictCommit, cliVersion }); + run(process.execPath, [path.join(APPLICATION_ROOT, 'tests', 'assert-build-output.mjs'), + path.join(APPLICATION_ROOT, '.build', 'application'), '--write-locks'], { cwd: APPLICATION_ROOT }); + run(path.join(APPLICATION_ROOT, 'tests', 'build.sh'), [], { + cwd: PROJECT_ROOT, + stdio: 'inherit', + env: { ...process.env, ECHO_REPO: echoRepository, EDICT_REPO: edictRepository }, + }); + process.stdout.write(`Jedit now builds against Echo ${echoCommit} and Edict ${edictCommit}.\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/spec/echo-edict-relock.spec.mjs b/spec/echo-edict-relock.spec.mjs new file mode 100644 index 00000000..67548fcd --- /dev/null +++ b/spec/echo-edict-relock.spec.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { encode } from 'cbor-x'; + +import { + lawpackManifestDigest, + providerPins, + withGitRev, + withImportDigest, + withOracleDigests, +} from '../scripts/deps/echo-edict-relock.mjs'; + +const ECHO_GIT = 'https://github.com/flyingrobots/echo'; +const OLD_REV = 'a'.repeat(40); +const NEW_REV = 'b'.repeat(40); + +function sha256Hex(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function temporaryDirectory(t) { + const directory = realpathSync(mkdtempSync(path.join(tmpdir(), 'jedit-relock-'))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +test('withGitRev moves every dependency on the named repository and nothing else', () => { + const manifest = [ + '[dependencies]', + `warp-core = { git = "${ECHO_GIT}", rev = "${OLD_REV}", features = ["trusted_runtime"] }`, + `echo-wasm-abi = { git = "${ECHO_GIT}", rev = "${OLD_REV}" }`, + `other = { git = "https://github.com/flyingrobots/edict", rev = "${OLD_REV}" }`, + 'serde = "1"', + '', + ].join('\n'); + + const moved = withGitRev(manifest, ECHO_GIT, NEW_REV); + + assert.equal(moved.split(NEW_REV).length - 1, 2); + assert.match(moved, new RegExp(`edict", rev = "${OLD_REV}"`)); + assert.match(moved, /features = \["trusted_runtime"\]/); +}); + +test('withGitRev refuses a manifest with no dependency on the repository', () => { + assert.throws(() => withGitRev('serde = "1"\n', ECHO_GIT, NEW_REV), /no dependency on/); +}); + +test('withOracleDigests binds the lawpack to the oracle source set and file digest', () => { + const lawpack = { + lawpack: { + compatibility: { id: 'jedit.text.compatibility/v1', digest: 'sha256:old' }, + conformanceFixtureCorpus: { id: 'jedit.text.ReplaceRange.oracle@1', digest: 'sha256:old' }, + }, + }; + + const bound = withOracleDigests(lawpack, { sourceSetHex: '11', oracleSha256Hex: '22' }); + + assert.equal(bound.lawpack.compatibility.digest, 'sha256:11'); + assert.equal(bound.lawpack.conformanceFixtureCorpus.digest, 'sha256:22'); + assert.equal(bound.lawpack.compatibility.id, 'jedit.text.compatibility/v1'); + assert.equal(lawpack.lawpack.compatibility.digest, 'sha256:old'); +}); + +test('withImportDigest rewrites only the named lawpack import', () => { + const source = [ + 'package jedit.text.replace_range@1;', + '', + 'use lawpack jedit.text@1 digest "sha256:0000" as text;', + 'use lawpack causal.cell@1 digest "sha256:1111" as cell;', + '', + ].join('\n'); + + const rewritten = withImportDigest(source, 'jedit.text@1', 'sha256:ffff'); + + assert.match(rewritten, /use lawpack jedit\.text@1 digest "sha256:ffff" as text;/); + assert.match(rewritten, /use lawpack causal\.cell@1 digest "sha256:1111" as cell;/); + assert.throws(() => withImportDigest(source, 'missing.pack@1', 'sha256:ffff'), /no import of missing\.pack@1/); +}); + +test('lawpackManifestDigest reads the manifest digest from Edict lawpack output', () => { + const output = { + schema: 'edict.lawpack-output/v1', + artifacts: [ + { path: 'manifest.cbor', kind: 'manifest', digest: 'sha256:abc' }, + { path: 'exports.cbor', kind: 'exports', digest: 'sha256:def' }, + ], + }; + + assert.equal(lawpackManifestDigest(output), 'sha256:abc'); + assert.throws(() => lawpackManifestDigest({ ...output, artifacts: [] }), /one manifest artifact/); +}); + +test('providerPins derives the toolchain-lock provider entry from a checked provider package', (t) => { + const root = temporaryDirectory(t); + mkdirSync(path.join(root, 'components')); + mkdirSync(path.join(root, 'generated', 'primary'), { recursive: true }); + const lowerer = Buffer.from('lowerer component'); + const verifier = Buffer.from('verifier component'); + writeFileSync(path.join(root, 'components', 'lowerer.echo-dpo.component.wasm'), lowerer); + writeFileSync(path.join(root, 'components', 'verifier.echo-dpo.component.wasm'), verifier); + const contractDigest = Buffer.alloc(32, 7); + writeFileSync( + path.join(root, 'generated', 'primary', 'target-profile.echo-dpo.cbor'), + encode({ verifier: { id: 'echo.dpo.verifier/v1', digest: ['sha256', contractDigest] } }), + ); + const manifestBytes = Buffer.from(JSON.stringify({ + provider: { coordinate: 'echo.edict-provider@1', digest: 'sha256:provider' }, + artifacts: [ + { role: 'lowerer.echo-dpo', resource: { coordinate: 'echo.dpo.lowerer/component@1', digest: `sha256:${sha256Hex(lowerer)}` } }, + { role: 'verifier.echo-dpo', resource: { coordinate: 'echo.dpo.verifier/component@1', digest: `sha256:${sha256Hex(verifier)}` } }, + { role: 'target-profile.echo-dpo', resource: { coordinate: 'echo.dpo@1', digest: 'sha256:profile' } }, + ], + })); + writeFileSync(path.join(root, 'provider-manifest.echo.json'), manifestBytes); + + assert.deepEqual(providerPins(root), { + coordinate: 'echo.edict-provider@1', + digest: 'sha256:provider', + manifestSha256: sha256Hex(manifestBytes), + targetProfile: { coordinate: 'echo.dpo@1', digest: 'sha256:profile' }, + lowerer: { coordinate: 'echo.dpo.lowerer/component@1', sha256: sha256Hex(lowerer) }, + verifier: { + coordinate: 'echo.dpo.verifier/component@1', + sha256: sha256Hex(verifier), + contract: { coordinate: 'echo.dpo.verifier/v1', digest: `sha256:${contractDigest.toString('hex')}` }, + }, + }); +}); + +test('providerPins refuses a component whose bytes disagree with the provider manifest', (t) => { + const root = temporaryDirectory(t); + mkdirSync(path.join(root, 'components')); + mkdirSync(path.join(root, 'generated', 'primary'), { recursive: true }); + writeFileSync(path.join(root, 'components', 'lowerer.echo-dpo.component.wasm'), 'lowerer'); + writeFileSync(path.join(root, 'components', 'verifier.echo-dpo.component.wasm'), 'verifier'); + writeFileSync( + path.join(root, 'generated', 'primary', 'target-profile.echo-dpo.cbor'), + encode({ verifier: { id: 'echo.dpo.verifier/v1', digest: ['sha256', Buffer.alloc(32)] } }), + ); + writeFileSync(path.join(root, 'provider-manifest.echo.json'), JSON.stringify({ + provider: { coordinate: 'echo.edict-provider@1', digest: 'sha256:provider' }, + artifacts: [ + { role: 'lowerer.echo-dpo', resource: { coordinate: 'echo.dpo.lowerer/component@1', digest: 'sha256:wrong' } }, + { role: 'verifier.echo-dpo', resource: { coordinate: 'echo.dpo.verifier/component@1', digest: `sha256:${sha256Hex(Buffer.from('verifier'))}` } }, + { role: 'target-profile.echo-dpo', resource: { coordinate: 'echo.dpo@1', digest: 'sha256:profile' } }, + ], + })); + + assert.throws(() => providerPins(root), /lowerer\.echo-dpo component bytes disagree/); +}); From 780384bde69bc562fc4e174fd197eb3996d3e1b6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 20 Oct 2026 16:36:32 -0700 Subject: [PATCH 2/2] fix(deps): restore managed files on failure and rebind adapter targets Review findings on the relock tool: - A failure after regeneration began (for example the host no longer compiling against the new Echo) left the Cargo pins and contract.rs rewritten. The run now refuses to start unless its managed paths are clean, and on any failure restores them and removes files it created. Verified against Echo 90f63ae: the host fails to compile and the tree is left unchanged. - The lawpack's targetAdapters kept acceptedTargetProfile and acceptedTargetIr from the previous provider. They are now derived from the selected provider's target profile before the lawpack is republished. Specs cover both. Against Echo 0faaf58 and Edict 2ddc069 the tool still reproduces main byte for byte. --- edict/replace-range/README.md | 4 +- scripts/deps/echo-edict-relock.mjs | 73 ++++++++++++++++++++++-- spec/echo-edict-relock.spec.mjs | 90 +++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/edict/replace-range/README.md b/edict/replace-range/README.md index 60a81f2c..7254a121 100644 --- a/edict/replace-range/README.md +++ b/edict/replace-range/README.md @@ -115,10 +115,10 @@ boundary witness is not a substitute for that evidence. ## Moving to new Echo and Edict commits -`scripts/deps/echo-edict-relock.mjs` moves Jedit to the Echo and Edict commits checked out in two clean checkouts and regenerates everything derived from them: the host's and conformance runtime's Echo pins, the host's Wesley contract bindings, the ReplaceRange oracle, this lawpack's oracle digests, the vendored `jedit-text` publication and the source's import digest, the toolchain lock, and the build and executable-subject locks. It then runs `tests/build.sh`. Run it with the Node, npm, and Rust releases this toolchain lock pins: +`scripts/deps/echo-edict-relock.mjs` moves Jedit to the Echo and Edict commits checked out in two clean checkouts and regenerates everything derived from them: the host's and conformance runtime's Echo pins, the host's Wesley contract bindings, the ReplaceRange oracle, this lawpack's oracle digests and target-adapter profile and IR references, the vendored `jedit-text` publication and the source's import digest, the toolchain lock, and the build and executable-subject locks. It then runs `tests/build.sh`. Run it with the Node, npm, and Rust releases this toolchain lock pins: ```bash node scripts/deps/echo-edict-relock.mjs --echo-repo /path/to/echo --edict-repo /path/to/edict ``` -If the host stops compiling against the new Echo, the run stops before any generated artifact changes; that is source work, not relocking. +It refuses to start unless the files it manages are clean, and any failure (a host that no longer compiles against the new Echo, a refused build, a broken package chain) restores them and removes anything the run created, so a failed relock leaves the tree as it found it. A host that stops compiling is source work, not relocking. diff --git a/scripts/deps/echo-edict-relock.mjs b/scripts/deps/echo-edict-relock.mjs index b69537b8..5a353862 100644 --- a/scripts/deps/echo-edict-relock.mjs +++ b/scripts/deps/echo-edict-relock.mjs @@ -6,8 +6,9 @@ // // Run it with the Node, npm, and Rust releases pinned in // edict/replace-range/edict.toolchain-lock.json; the package chain refuses -// anything else. A host that no longer compiles against the new Echo stops the -// run before any generated artifact is rewritten. +// anything else. Any failure (a host that no longer compiles against the new +// Echo, a refused build, a broken package chain) restores every file the run +// manages, so a failed relock leaves the tree as it found it. import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -24,6 +25,20 @@ const ORACLE_PATH = path.join( PROJECT_ROOT, 'contracts', 'jedit', 'lawpacks', 'replace-range-v1', 'replace-range-v1.oracle.json', ); const PROVIDER_PACKAGE = path.join('schemas', 'edict-provider', 'package', 'v1'); +const MANAGED_PATHS = Object.freeze([ + 'native/jedit-echo-host/Cargo.toml', + 'native/jedit-echo-host/Cargo.lock', + 'native/jedit-echo-host/src/generated', + 'contracts/jedit/lawpacks/replace-range-v1', + 'edict/replace-range/edict.lawpack.json', + 'edict/replace-range/edict.toolchain-lock.json', + 'edict/replace-range/edict.build-lock.json', + 'edict/replace-range/edict.executable-subject-lock.json', + 'edict/replace-range/src', + 'edict/replace-range/vendor', + 'edict/replace-range/tests/runtime/Cargo.toml', + 'edict/replace-range/tests/runtime/Cargo.lock', +]); const PROVIDER_COMPONENTS = Object.freeze([ ['lowerer', 'lowerer.echo-dpo', 'lowerer.echo-dpo.component.wasm'], ['verifier', 'verifier.echo-dpo', 'verifier.echo-dpo.component.wasm'], @@ -95,6 +110,48 @@ function componentPin(root, manifest, [role, fileName]) { return { coordinate: resource.coordinate, sha256 }; } +/** Reads the target profile and target IR identities a provider publishes. */ +export function adapterTargets(root) { + const manifest = JSON.parse(readFileSync(path.join(root, 'provider-manifest.echo.json'))); + const profileResource = providerResource(manifest, 'target-profile.echo-dpo'); + const profile = decode(readFileSync(path.join(root, 'generated', 'primary', 'target-profile.echo-dpo.cbor'))); + return { + acceptedTargetProfile: { id: profileResource.coordinate, digest: profileResource.digest }, + acceptedTargetIr: { + id: profile.targetIr.id, + digest: `sha256:${Buffer.from(profile.targetIr.digest[1]).toString('hex')}`, + }, + }; +} + +/** Rebinds every target adapter to the given target profile and target IR. */ +export function withAdapterTargets(lawpack, { acceptedTargetProfile, acceptedTargetIr }) { + const bound = structuredClone(lawpack); + for (const adapter of bound.lawpack.targetAdapters) { + adapter.acceptedTargetProfile = structuredClone(acceptedTargetProfile); + adapter.acceptedTargetIr = structuredClone(acceptedTargetIr); + } + return bound; +} + +/** + * Runs `action` over managed paths that must start clean, and restores them + * (removing anything the run created there) if it throws. + */ +export function restoringOnFailure(repository, paths, action) { + const dirty = run('git', ['-C', repository, 'status', '--porcelain=v1', '--untracked-files=all', '--', ...paths]); + if (dirty !== '') { + throw new Error(`managed paths must be clean before relocking:\n${dirty}`); + } + try { + return action(); + } catch (error) { + run('git', ['-C', repository, 'checkout', '--', ...paths]); + run('git', ['-C', repository, 'clean', '-fdq', '--', ...paths]); + throw error; + } +} + /** Derives the toolchain-lock provider entry from Echo's checked provider package. */ export function providerPins(root) { const manifestBytes = readFileSync(path.join(root, 'provider-manifest.echo.json')); @@ -227,6 +284,12 @@ function main(argv) { const { echoRepository, edictRepository } = parseArguments(argv); const echoCommit = cleanCommit(echoRepository, 'Echo'); const edictCommit = cleanCommit(edictRepository, 'Edict'); + restoringOnFailure(PROJECT_ROOT, MANAGED_PATHS, () => + relock({ echoRepository, edictRepository, echoCommit, edictCommit })); + process.stdout.write(`Jedit now builds against Echo ${echoCommit} and Edict ${edictCommit}.\n`); +} + +function relock({ echoRepository, edictRepository, echoCommit, edictCommit }) { const lockPath = path.join(APPLICATION_ROOT, 'edict.toolchain-lock.json'); const toolchain = readJson(lockPath).rust.toolchain; @@ -236,10 +299,11 @@ function main(argv) { run('npm', ['run', '-s', 'lawpack:replace-range:update'], { cwd: PROJECT_ROOT }); const lawpackPath = path.join(APPLICATION_ROOT, 'edict.lawpack.json'); - writeJson(lawpackPath, withOracleDigests(readJson(lawpackPath), { + const withOracle = withOracleDigests(readJson(lawpackPath), { sourceSetHex: readJson(ORACLE_PATH).sourceSet.digestHex, oracleSha256Hex: sha256Hex(readFileSync(ORACLE_PATH)), - })); + }); + writeJson(lawpackPath, withAdapterTargets(withOracle, adapterTargets(path.join(echoRepository, PROVIDER_PACKAGE)))); const cliVersion = republishApplication({ echoRepository, edictRepository, toolchain }); relockToolchain(lockPath, { echoRepository, echoCommit, edictCommit, cliVersion }); run(process.execPath, [path.join(APPLICATION_ROOT, 'tests', 'assert-build-output.mjs'), @@ -249,7 +313,6 @@ function main(argv) { stdio: 'inherit', env: { ...process.env, ECHO_REPO: echoRepository, EDICT_REPO: edictRepository }, }); - process.stdout.write(`Jedit now builds against Echo ${echoCommit} and Edict ${edictCommit}.\n`); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/spec/echo-edict-relock.spec.mjs b/spec/echo-edict-relock.spec.mjs index 67548fcd..f6a2ff47 100644 --- a/spec/echo-edict-relock.spec.mjs +++ b/spec/echo-edict-relock.spec.mjs @@ -1,14 +1,18 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { encode } from 'cbor-x'; +import { git } from './git-fixture-helpers.mjs'; import { + adapterTargets, lawpackManifestDigest, providerPins, + restoringOnFailure, + withAdapterTargets, withGitRev, withImportDigest, withOracleDigests, @@ -152,3 +156,87 @@ test('providerPins refuses a component whose bytes disagree with the provider ma assert.throws(() => providerPins(root), /lowerer\.echo-dpo component bytes disagree/); }); + +function providerWithProfile(t, profile, artifacts) { + const root = temporaryDirectory(t); + mkdirSync(path.join(root, 'generated', 'primary'), { recursive: true }); + writeFileSync(path.join(root, 'generated', 'primary', 'target-profile.echo-dpo.cbor'), encode(profile)); + writeFileSync(path.join(root, 'provider-manifest.echo.json'), JSON.stringify({ provider: {}, artifacts })); + return root; +} + +test('adapterTargets reads the target profile and target IR the provider publishes', (t) => { + const irDigest = Buffer.alloc(32, 9); + const root = providerWithProfile( + t, + { targetIr: { id: 'echo.span-ir/v2', digest: ['sha256', irDigest] } }, + [{ role: 'target-profile.echo-dpo', resource: { coordinate: 'echo.dpo@2', digest: 'sha256:profile2' } }], + ); + + assert.deepEqual(adapterTargets(root), { + acceptedTargetProfile: { id: 'echo.dpo@2', digest: 'sha256:profile2' }, + acceptedTargetIr: { id: 'echo.span-ir/v2', digest: `sha256:${irDigest.toString('hex')}` }, + }); +}); + +test('withAdapterTargets rebinds every target adapter and leaves the input unchanged', () => { + const stale = { id: 'echo.dpo@1', digest: 'sha256:old' }; + const lawpack = { + lawpack: { + targetAdapters: [ + { coordinate: 'a', acceptedTargetProfile: stale, acceptedTargetIr: stale }, + { coordinate: 'b', acceptedTargetProfile: stale, acceptedTargetIr: stale }, + ], + }, + }; + const targets = { + acceptedTargetProfile: { id: 'echo.dpo@2', digest: 'sha256:new' }, + acceptedTargetIr: { id: 'echo.span-ir/v2', digest: 'sha256:ir' }, + }; + + const bound = withAdapterTargets(lawpack, targets); + + for (const adapter of bound.lawpack.targetAdapters) { + assert.deepEqual(adapter.acceptedTargetProfile, targets.acceptedTargetProfile); + assert.deepEqual(adapter.acceptedTargetIr, targets.acceptedTargetIr); + } + assert.equal(lawpack.lawpack.targetAdapters[0].acceptedTargetProfile.digest, 'sha256:old'); +}); + +function fixtureRepository(t) { + const repo = temporaryDirectory(t); + mkdirSync(path.join(repo, 'managed')); + writeFileSync(path.join(repo, 'managed', 'pinned.txt'), 'original\n'); + writeFileSync(path.join(repo, 'outside.txt'), 'outside\n'); + git(repo, 'init', '-q'); + git(repo, 'add', '-A'); + git(repo, '-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-qm', 'fixture'); + return repo; +} + +test('restoringOnFailure restores managed paths and removes files the failed run created', (t) => { + const repo = fixtureRepository(t); + + assert.throws(() => restoringOnFailure(repo, ['managed'], () => { + writeFileSync(path.join(repo, 'managed', 'pinned.txt'), 'regenerated\n'); + writeFileSync(path.join(repo, 'managed', 'fresh.txt'), 'new\n'); + throw new Error('host failed to compile'); + }), /host failed to compile/); + + assert.equal(readFileSync(path.join(repo, 'managed', 'pinned.txt'), 'utf8'), 'original\n'); + assert.equal(git(repo, 'status', '--porcelain=v1', '--untracked-files=all'), ''); +}); + +test('restoringOnFailure keeps a successful run and refuses dirty managed paths up front', (t) => { + const repo = fixtureRepository(t); + + restoringOnFailure(repo, ['managed'], () => { + writeFileSync(path.join(repo, 'managed', 'pinned.txt'), 'relocked\n'); + }); + assert.equal(readFileSync(path.join(repo, 'managed', 'pinned.txt'), 'utf8'), 'relocked\n'); + + assert.throws(() => restoringOnFailure(repo, ['managed'], () => {}), /managed paths must be clean/); + writeFileSync(path.join(repo, 'outside.txt'), 'edited\n'); + git(repo, 'checkout', '--', 'managed'); + assert.doesNotThrow(() => restoringOnFailure(repo, ['managed'], () => {})); +});