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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions edict/replace-range/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
320 changes: 320 additions & 0 deletions scripts/deps/echo-edict-relock.mjs
Original file line number Diff line number Diff line change
@@ -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']);
Comment thread
flyingrobots marked this conversation as resolved.
Resolved
}

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));
}
Loading
Loading