diff --git a/CHANGELOG.md b/CHANGELOG.md index bb241f22..c081d30c 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..7254a121 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 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 +``` + +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 new file mode 100644 index 00000000..5a353862 --- /dev/null +++ b/scripts/deps/echo-edict-relock.mjs @@ -0,0 +1,320 @@ +// 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. 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'; +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 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'], +]); + +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 }; +} + +/** 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')); + 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'); + 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; + + 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'); + 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'), + 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 }, + }); +} + +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..f6a2ff47 --- /dev/null +++ b/spec/echo-edict-relock.spec.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +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, +} 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/); +}); + +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'], () => {})); +});