Skip to content

feat(cli): Mill-style out directory with task records and install - #789

Merged
DamianReeves merged 63 commits into
mainfrom
t3code/understand-compile-output
Sep 3, 2026
Merged

feat(cli): Mill-style out directory with task records and install#789
DamianReeves merged 63 commits into
mainfrom
t3code/understand-compile-output

Conversation

@DamianReeves

@DamianReeves DamianReeves commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

Moves the CLI onto a Mill-style out directory. finos/morphir-rust#144 is merged; the ecosystem/morphir-rust submodule is pinned at its merged commit 473c849.

  • Every task runs to one workspace-level out root, <workspace>/.morphir/out by default. A root module's tasks sit directly under it; a member's under <member path>/. Each task owns <task>.dest (scratch) and <task>.json (result record). --out-dir and MORPHIR_OUT_DIR relocate the root.
  • compile writes IR in the storage [ir] asks for (single-file JSON, single-file YAML, or a document tree) and records where in the result. generate reads the IR through that record via resolve_ir_task, the single seam where transform stages (IR transform stages between compile and generate #786) will slot in. generate -i still accepts a file, a document tree, or a compile-output directory.
  • -o is now an install step, the Zig zig-out model: the task runs to its canonical location first, then its declared product is copied into the user directory. Install keeps a per-target ledger of the files it wrote, removes only those when they are no longer produced, never deletes a directory it did not create, refuses to overwrite files it does not own, and survives a failed run through a tombstone record. Single-file Elm compile joins the same scheme; its -o now takes a directory.
  • Config: [workspace].out_dir, [ir].layout, [ir].format; removed keys warn. Docs, config specs, examples, CLI reference, completions, man page, and CHANGELOG updated. New docs/design/out-directory.md describes the layout, record, and install rules.

Design

Spec discussion and rulings are summarised in docs/design/out-directory.md. Follow-ups: #785 (input hashing), #786 (transform stages), #788 (task graph; install becomes a graph node and the tombstone goes away).

Testing

cargo test -p morphir green (333 lib, 345 bin, 73 integration; 2 pre-existing ignored tests need external binaries). cargo clippy -p morphir --all-targets -- -D warnings, cargo fmt --check, and mise run ci:validate-docs clean.

Known gap: the single-file Elm -o path has no runnable test here (its end-to-end test is #[ignore]d behind MORPHIR_ELM_EXTENSION_BIN); CI with the real binary covers it.

…scapes

Only remove an existing directory at the eject target when this function
ejected that entry there before; a directory-valued entry that is new to
the target, including one the user already has, is merged into rather
than deleted. Removal now also confirms the destination's parent still
resolves under the canonicalized target before deleting anything, so a
symlinked intermediate directory inside the target cannot redirect a
removal outside it. The target itself may still legitimately be a
symlink, since both sides are canonicalized before comparing.

Adds coverage for: the previous-list entry validation loop, degenerate
value entries, merging into a foreign directory on first eject, removing
a file no longer produced on a re-eject of an owned directory, the
symlinked-intermediate escape attempt, and a symlinked target continuing
to work.
Round-1 merged into a user's pre-existing directory on the first eject
but then recorded the whole entry name as owned in ejected[target], so
a second eject saw the entry in its own history and wiped the
directory anyway, taking foreign content with it.

ejected[target] now stores the flattened list of individual files a
run actually writes under target (flatten_value_files): a file entry
contributes itself, a directory entry contributes every file beneath
it. A later eject diffs the old and new flattened lists and removes
only the files that dropped out, never a directory wholesale, so
foreign content survives any number of ejects. The remove_confined
branch that used to wipe a directory when its entry was in the
previous list is gone; directory entries are always merged into.

remove_entry/remove_confined now report whether they actually removed
something, so EjectReport.removed only lists files this call really
deleted rather than stale bookkeeping for something already gone.

Added a doc note on remove_confined's canonicalize check: it is
advisory against accidental/stale symlinks, not a security boundary,
and is TOCTOU-racy; closing that needs descriptor-relative removal,
which is out of scope here.
remove_entry recursed into remove_dir_all for any stale path that
happened to be a directory, so a record written by an earlier build of
this branch that still lists a directory ENTRY name (rather than a
flattened file path) would have that directory wholesale-deleted on
the first eject after upgrade, foreign content included. The same
happens if a user replaces a file eject wrote with their own directory
of the same name and the task later stops producing that entry.

remove_entry now refuses to remove anything that resolves to a
directory, returning false instead of recursing. This module only ever
deletes files it wrote; a directory it did not create as a file, under
any bookkeeping scheme past or present, is never its to remove.
prune_empty_parents remains the only place a directory is removed, and
only once file-by-file removal has left it empty.

Also switches the new-vs-old file diff to a HashSet lookup instead of
Vec::contains, and bumps the morphir-rust submodule to pick up the
ejected field doc/example update describing it as flattened file
paths.
Before this change, eject copied over whatever already sat at the
destination with no ownership check, then recorded those files as
ejected. A later run whose artifact set shrank would then delete
foreign content it never wrote, and a first run into a hand-written
directory would silently overwrite it.

eject now computes the full list of files it is about to write, stats
each one, and refuses the whole operation with a CliError::Validation
listing every conflicting path if any of them already exists and was
not written by eject on a previous run to this target. Nothing is
copied or removed when that error fires.

Also: `ejected` is now keyed on the canonicalized target instead of
the raw `-o` string, so `dist` and `./dist` share one bookkeeping
entry instead of shadowing each other. And eject refuses a target that
is the task's own `.dest` (or lies inside it), since that would make
copy's source and destination alias each other.
run_single_file_compile always writes classic v3 JSON and hardcoded
the literal "morphir-ir.json" rather than naming that path through
ir_storage::v3_json_descriptor(). It also silently ignored a
--config's [ir].layout/[ir].format, which apply on the project-mode
compile path but do nothing here.

Print one warning line when a config context is present and its [ir]
section asks for a layout other than single-file or a format other
than json, so the setting's absence of effect is visible instead of
silent. The boolean condition is split into
ir_storage_settings_apply_to_single_file_compile so it is
unit-testable without capturing stderr; the eprintln wiring itself was
verified manually with a scratch test exercising a real ConfigContext
and observing the warning line, then removed before this commit.
The record section claimed an incomplete .dest without a matching
.json is "always treated as incomplete and rerun" — nothing in the
CLI reruns a task automatically. Say precisely what happens: it is
treated as missing, and the user runs the task again.

The eject section's directory-deletion claim was checked against the
code and already states the rule precisely (eject never deletes a
directory wholesale, only prunes ones its own file removals leave
empty), so no change was needed there.
- Rename ir_storage.rs's SINGLE_FILE_STEM to IR_STEM: it also names the
  document-tree directory, not just the single-file stem.
- Make generate/artifacts.rs's MANIFEST_PATH pub(crate) and use it from
  generate.rs instead of re-literaling ".morphir-generated-artifacts.json".
- Delete help.rs's print_gleam_help: #[allow(dead_code)] with no callers,
  and it used no helper exclusive to it.
- Sync examples/morphir.toml's [ir] block with docs/configuration.md by
  adding the layout and format keys it was missing.

run_morphir in tests/cli_integration.rs already routes through
morphir_command() (the helper that scrubs MORPHIR_OUT_DIR), so no change
was needed there.
Rename the -o post-run copy step from "eject" to "install" throughout
the CLI: the eject command module, the record's ejected field (now
installed, via the morphir-devkit pointer bump), CLI help text, output
JSON keys, human-readable output lines, error messages, tests, and the
out-directory and configuration docs. This matches Zig's zig-out
install-step naming, which the design doc already used to describe the
model. No behaviour changes.
… record

prepare_dest used to delete the previous <task>.json outright at the
start of every run, carrying its installed map forward only through
the record a successful run writes. A run that failed wrote no
record, so the map was lost. The routine sequence compile -o dist
(succeeds), compile (fails on a source error), fix, compile -o dist
(succeeds again) would then have the second -o dist see its own
earlier output sitting in dist/ as foreign content it never wrote,
and refuse to run — the user had no way out short of deleting dist/
themselves.

prepare_dest now leaves a tombstone instead of deleting: when a
previous record exists and can be read, it writes back the same
record with value and inputs emptied and language and ir set to
None, keeping installed and completedAt as they were. generate now
treats a tombstone (no ir, empty value) the same as a missing record,
rather than reporting "produced no IR descriptor", which stays for a
record that has a value but no ir. An unreadable record is still
removed outright, as before.
Re-review of the tombstone fix found two latent hazards:

- prepare_dest's tombstone kept the record's extra (serde flatten)
  map, so an unknown field computed from the previous .dest, such as
  a future inputsHash, would ride onto a tombstone whose .dest is
  empty. Clear extra on the tombstone.
- prepare_dest wrote the tombstone after clearing .dest, not before.
  A crash or a failed record.write between the two would leave the
  full previous record, still claiming a real ir, beside an empty
  .dest, and generate would then fail deep in read_value with a raw
  I/O error instead of the friendly missing-record message. Writing
  the tombstone first closes that: the worst case is now the
  previous SUCCESSFUL record sitting beside its own intact .dest,
  which every reader already treats as a complete run.

install() only rejected a missing record. A tombstone (record
present, value empty) would pass, compute an empty file list, delete
every previously installed file, and write back an empty ledger.
Unreachable today, since every caller runs install right after a
successful record.write, but add the guard: an empty value is now
rejected with a message naming the task and telling the user to run
it first. Several existing unit tests exercised install() with a
value that shrinks to nothing as a way to test stale-file cleanup;
those now carry an unrelated placeholder entry instead, so cleanup
of the files that did drop out of value remains covered without
tripping the new guard.

Also: the design doc said the tombstone sets language and ir to
null; both are skip_serializing_if, so the keys are omitted, not
null. Fixed the wording and documented that extra is cleared too,
and added a JSON-output integration test asserting the compile
envelope carries installed_path when -o is given (the eject-to-
install key rename had no test covering the JSON path).
@netlify

netlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploy Preview for angry-raman-7c44f6 canceled.

Name Link
🔨 Latest commit 729189c
🔍 Latest deploy log https://app.netlify.com/projects/angry-raman-7c44f6/deploys/6a99f346d788560008b8b7c1

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T17:00:45.021167Z 75c50be Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d2fe2dac0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/morphir/src/commands/install.rs Outdated
// destination wiped first, whether this is the first time it
// has been installed here or the tenth, so any foreign content in
// it survives regardless.
copy_dir(&source, &destination)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse symlinked directories before installing

When an install target contains an intermediate symlink matching a directory-valued entry (for example, dist/morphir-ir -> /outside), copy_dir follows that symlink through create_dir_all and std::fs::copy. A first install can therefore create files outside the requested target; after a previous install has marked those paths as owned, replacing the directory with a symlink also bypasses the conflict check and can overwrite existing outside files. Verify each destination parent resolves beneath canonical_target, as the stale-removal path already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ab7a94: every install destination is confined in the pre-flight scan (nearest existing ancestor canonicalised and required to sit under the canonical target) before anything is written; an intermediate symlink pointing outside is refused with a Validation error naming both paths. Symlinks inside the target remain allowed. Unix symlink tests added.

Comment thread crates/morphir/src/commands/install.rs Outdated
// Every caller today runs `install` right after writing a successful
// record, so a tombstone can never actually reach this function, but the
// guard costs nothing and keeps that true if a future caller changes.
if record.value.is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish empty successful products from tombstones

When a backend succeeds with zero artifacts, run_generate writes a valid result record whose value is empty and immediately calls this function for -o; this guard then turns that successful generation into an error. The artifact publisher explicitly supports empty sets, including retiring a previous set, so this also prevents a later empty generation from removing files previously installed at the target. The record needs a distinct completion/tombstone state rather than using empty value for both cases.

AGENTS.md reference: AGENTS.md:L53-L57

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8a23d84 (with 2a7dd62 on the library side): the record now carries an explicit tombstone flag. install() refuses only tombstones, so a successful zero-artifact product installs and retires previously installed files; generate treats a tombstone as missing. Tests for both cases.

Comment on lines +145 to +148
if paths.dest.exists() {
std::fs::remove_dir_all(&paths.dest).map_err(|error| CliError::FileSystem { error })?;
}
std::fs::create_dir_all(&paths.dest).map_err(|error| CliError::FileSystem { error })?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Lock the task before clearing its destination

When two processes run the same compile or generate task concurrently, both enter prepare_dest without a task-level lock, so one can remove .dest while the other is writing it and both can overwrite the same result record. For generate, the publication lock in generate/artifacts.rs is acquired only later by write_all, so this deletion can even unlink the directory held by an active publisher; for compile, concurrent std::fs::write calls can directly leave corrupt or mismatched IR. Hold one lock across destination preparation, task execution, record publication, and installation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 77377a9: prepare_dest takes an exclusive advisory fs2 lock on <task>.lock and returns it as a guard held through the run, the record write, and the install in all three command flows; a second run blocks (with one stderr line) until release. Contention test added.

`copy_dir` and the single-file copy went straight to `create_dir_all` and
`fs::copy`, both of which follow a symlink sitting in the install target. A
`dist/morphir-ir` pointing at `/outside` therefore sent a first install's
whole entry outside `dist`; replacing an already-installed directory with
such a symlink slipped past the foreign-content check as well, because
every path underneath it is one install wrote itself, and the next run
overwrote files outside the target.

Every destination now has to resolve inside the target before anything is
written, the same rule `remove_confined` already applies to removals: the
nearest existing ancestor is canonicalized and checked against the
canonical target, and a dangling symlink is refused rather than written
through. The check runs in the pre-flight scan, so a refused install leaves
the target exactly as it was.
`install` refused any record with an empty `value` on the grounds that it
might be a tombstone. That also refused a generate that ran, succeeded, and
had nothing to emit — and stopped such a run from retiring the files an
earlier run had installed, leaving stale artifacts at the target with a
ledger that still claimed them.

A tombstone now says so: `prepare_dest` sets `tombstone` on the record it
leaves behind, and every successful record leaves it false. `install`
refuses a tombstone and nothing else, and `generate` reads the flag instead
of guessing from a missing `ir` and an empty `value`. A successful record
with an empty `value` installs normally, retiring what the previous run put
at the target and recording an empty ledger for it. The unit tests that
carried a placeholder entry only to get past the old guard go back to the
empty `value` they were written with.

Bumps the submodule for the `tombstone` field, which also brings in the
member-path confinement, the task id parse fix, and the member-adjacent
`out_dir` override fix; the workspace section of the design doc describes
the last two.
The install target lock lived at <out root>/install-locks/<hash>.lock,
so two different workspaces installing to the same absolute -o
directory held two different locks and could both believe they held
it uncontested.

install_lock_path now resolves the lock under the user-global Morphir
home directory instead: <Morphir home>/locks/install/<hash>.lock,
keyed only on the canonical target. If the home directory cannot be
resolved, it falls back to the old out-root location and says so on
stderr. The digest scheme is unchanged.

Unit tests now redirect MORPHIR_HOME to a sandbox directory once per
test process, so they do not touch the developer's real Morphir home.
A copy failing partway through a multi-file install left the files
copied before the failure on disk, but the function returned before
writing the ledger, so those files stayed unrecorded. The next install
read the old ledger, found files it did not know about, and refused
the whole run as foreign content — wedging -o until someone cleared
the target by hand.

install now tracks every file a copy phase actually writes as it goes.
On failure, it deletes whatever this run introduced that was not
already owned by an earlier install (an overwritten file that was
already ours is left alone), writes the ledger as the previous ledger
minus whatever stale removal already deleted, and only then returns
the error. Disk and the ledger agree again, so a later install is not
wedged by an earlier failure.
Picks up finos/morphir-rust#144's third review round: absolute config
context roots after a later chdir, a rejected unrecognized ir.mode
value, and validated ir.layout/ir.format values at decode time.

The last one means an IrSection can no longer be built through
Deserialize with an unrecognized layout, so ir_storage.rs's coverage
of IrStorage::from_config's own fallback check now builds that
IrSection by hand instead.
Two gaps in the install rollback added in 6b7e7a7:

1. A copy that fails partway through can still create a partial file
   at its own destination (fs::copy creates the file before it starts
   writing). That file was never added to copied_files, so rollback
   never removed it and the ledger never recorded it, leaving it as
   foreign content that would wedge the next install.

2. roll_back_partial_copy stopped and propagated on its first deletion
   error, which lost the original copy error and skipped the ledger
   rewrite, leaving the ledger stale against disk.

Rollback is now best-effort: every file this run introduced is
attempted regardless of an earlier failure, and the ledger is always
rewritten afterward to match what is really on disk, including any
file rollback could not remove. When rollback does hit an error, it is
folded into the message of the original copy failure rather than
replacing it.

Also documents the rollback behavior in docs/design/out-directory.md,
and adds a test for an owned file replaced by a symlink pointing
outside the install target (only the inside-target case was covered).
@DamianReeves

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d80ac78fec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

resolved_new_files,
target,
} = context;
let rollback_errors = roll_back_partial_copy(copied_files, previous_files_lookup, target);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare rollback ownership using resolved paths

When a supported internal symlink maps morphir-ir/x to real/x (or a case-insensitive rename changes a path’s spelling), copied_files contains the spelled path while previous_files_lookup contains the resolved ledger path. This new rollback call therefore treats a previously owned file as newly introduced and deletes it after any later copy failure, while the reconstructed ledger can still claim that deleted file exists. The fresh evidence is the latest rollback path passing these differently normalized collections directly to roll_back_partial_copy; map copied paths to their resolved forms before deciding which files are safe to remove.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 55d7e96: copied paths are mapped to their resolved form before the rollback decides what is safe to remove and before the ledger is rebuilt, so ownership is compared resolved-to-resolved and a previously owned file reached through an internal symlink survives a later copy failure. Unix test added.

Comment thread crates/morphir/src/commands/install.rs Outdated
Comment on lines +144 to +147
let resolved_new_files = new_files
.iter()
.map(|file| resolved_relative(file, target, &canonical_target))
.collect::<Result<Vec<String>, CliError>>()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject output paths that resolve to one destination

When the install target contains two supported internal symlinks such as a -> real and b -> real, artifacts a/config and b/config both resolve to real/config. This collection preserves both entries without checking for duplicate resolved destinations, and because conflict detection finishes before copying starts, both copies proceed and the latter silently overwrites the former. Reject duplicate values in resolved_new_files before any target mutation so the installed product cannot lose an artifact.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d7b54f (and 75c50be for case-only collisions on case-insensitive filesystems): after resolving the new file set, two spelled outputs that resolve to one destination are refused before any mutation, with an error listing the colliding paths and their shared destination. Unix test with two symlinks to one directory.

With dist/a and dist/b both linking to dist/real inside the install
target, the entries a/config and b/config name the same file on disk.
Every check before this one passes each of them individually, so the
second copy silently overwrote the first.

Refuse the whole install instead, before anything is written, once
two spelled output paths resolve to the same destination. The error
names every colliding spelling and the destination they share.
copied_files holds the SPELLED paths a copy loop wrote to, while the
ledger (previous_files) holds RESOLVED paths. After a failed copy,
roll_back_partial_copy and the ledger rebuild both compared these
directly, so a file already owned through an internal symlink -
dist/morphir-ir/x, say, when morphir-ir links to real and the ledger
names real/x - looked unowned. Rollback deleted it as though this run
had introduced it, while the rebuilt ledger, hitting the same
mismatch, went on claiming it was still there.

Map every copied path to its resolved form before either comparison
runs, so ownership is judged resolved-to-resolved on both sides.
…er fixes

Picks up two fixes from finos/morphir-rust#144: workspace.out_dir is
now validated as a confined relative path at configuration load time,
and wildcard member expansion (packages/*) now follows directory
symlinks instead of silently skipping them.
…ensitive filesystems

resolved_relative only resolves the directories above a destination,
never the file name itself, so two output entries differing only in
the case of their final component - a/Config and a/config - resolved
to two different strings even with no symlink involved. On a
case-insensitive filesystem (the default on macOS and Windows) those
name the same file, so reject_colliding_destinations missed exactly
the clobber it exists to prevent.

Probe the install target's filesystem once per install - by filesystem
identity on an existing entry when there is one, or on a disposable
marker under the install lock's own directory when the target is
empty - and fold case in the collision key when the probe says the
filesystem does not distinguish it. An inconclusive probe is treated
as case-insensitive, the safe direction.
@DamianReeves

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75c50be2ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
let out = OutContext::resolve(config_context.as_ref(), &options.out, &start_dir);
let task = TaskId::compile();
let prepared = out.prepare_dest(&task)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate the task before loading single-file input

When a previous single-file Elm compile succeeded and the next invocation cannot read its source because the file was deleted, became unreadable, or contains invalid UTF-8, read_single_source returns before this preparation step. The old successful record and IR therefore remain consumable, so a later generate can silently use stale IR even though the latest compile failed. Acquire the task lock and write the tombstone before loading the source, as the provider compile path does.

AGENTS.md reference: AGENTS.md:L53-L53

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0ea258f: single-file Elm compile now prepares the task (lock and tombstone) before reading the source, matching the provider path, so an unreadable source can no longer leave the previous record consumable.

Comment thread crates/morphir/src/commands/install.rs Outdated
Comment on lines +182 to +184
let candidates: Vec<&String> = unmatched.iter().map(|(_, resolved)| *resolved).collect();
let renamed = renamed_previous_files(target, &previous_files, &candidates)?;
let renamed_to: HashSet<&str> = renamed.values().map(String::as_str).collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject hard-link aliases instead of treating them as renames

When the next artifact name already exists as a hard link to a previously installed file, this identity lookup classifies the new name as a case-only rename and removes it from the conflict list. Copying the new artifact then truncates the shared inode, also changing the old output; the old name is skipped during stale removal and becomes an untracked leftover. A differently named hard link is foreign content, so rename handling should require actual filesystem case equivalence rather than identity alone.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8bd5723: identity matching is treated as a case-only rename only when the two spellings are equal under case folding in the same directory; a differently named hard link is a conflict.

Comment thread crates/morphir/src/commands/install.rs Outdated
Comment on lines +308 to +311
record.installed.insert(key, resolved_new_files);
record
.write(&paths.result)
.map_err(|error| CliError::Config { error })?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back copies when the ledger write fails

If all target copies succeed but this result-record write fails—for example, the install fills the filesystem after the caller's initial record write—the target now contains newly added files while the persisted ledger still contains the previous set or is unreadable. A later install consequently rejects those files as foreign content and remains wedged until they are removed manually. The successful-copy path needs the same recovery treatment as copy failures when ledger publication fails.

AGENTS.md reference: AGENTS.md:L53-L53

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d4ffa2f: a failed ledger write after successful copies is handled like a copy failure — files this run added that were not previously owned are rolled back and the original error is returned — so the next install is not wedged.

The case-only output collision test asked the filesystem whether it
distinguishes letter case and then asserted the matching outcome, but on a
case-sensitive filesystem the assertion never held, because install's own
probe could not reach the same answer.

`probe_case_sensitivity_at` treated a case-swapped spelling that is simply
not there as "could not tell". On a case-sensitive filesystem the swapped
spelling is never there, so every probe was inconclusive, install fell
back to assuming case-insensitive, and two outputs that really are two
different files were refused as a collision. A missing swapped spelling is
now read as the answer it is: the filesystem tells the two names apart.

The collision test's case-sensitive branch now also checks the contents of
both files and both ledger entries, and a new test pins the probe itself
to whatever the host filesystem does, so neither kind of filesystem is
assumed.
…lved-path ownership

Install used to support a symbolic link inside the `-o` target as an
ordinary part of a user's layout. Paying for that meant owning every file
by where it RESOLVED rather than by how it was spelled, and then guarding
every way that resolution could go wrong: a check that each destination
still resolved inside the target, a check that no ledger path had moved
since install wrote it, a check that no two spellings landed on one file
through links, and a spelled-to-resolved map so rollback could tell a file
this run introduced from one it merely overwrote. Four rounds of review
found four more edge cases in that machinery.

One rule replaces all of it: nothing below the target may be a symbolic
link. The target itself may still be one — `-o` pointing at a link is
ordinary, and it is canonicalised first — but every component of every
path install would write, and of every path its ledger owns, has to be a
real file or a real directory. The pre-flight scan walks those components
with `symlink_metadata` and refuses the whole install before any mutation,
naming the link and suggesting the real directory instead. A link leading
out of the target, a link with nothing at the far end, a directory swapped
for a link after install created it, and two output paths made one file by
a link are all the same refusal now.

With one spelling per file, the ledger goes back to plain relative paths
and ownership is a string comparison. `resolved_relative`,
`resolve_directory`, `reject_moved_ledger_entries`, `symlinked_ancestor`,
`confined_destination` and the rollback's spelled-to-resolved map are all
gone.

Kept as they were: the identity match that recognises a case-only rename
of a file install already owns, the case-fold collision check for
filesystems that do not distinguish letter case, the canonical-parent
check on the removal side, the target lock and the shared input lock, the
rollback, the tombstone rules, and the foreign-content refusal.
Rewrite the install section's symlink paragraphs to the one rule that
replaced them — nothing below the `-o` target may be a symbolic link — and
drop the description of resolved-path ownership that went with the old
behaviour.

Add a paragraph on member identity: a member is identified by its declared
path, and the directory that path names may be a symbolic link leading
outside the workspace, followed for reading with one warning.

Add a Threat model subsection listing what the install and out-root code
actually defends against, and against what it does not claim to be a
boundary.

Also correct the `ir.mode` note: the alias is now applied inside each
configuration layer before merging, so precedence between layers works the
ordinary way.
… inode

`renamed_previous_files` recognises a file install already owns that has
come back under a slightly different name — `Foo.gleam` as `foo.gleam` on a
case-insensitive filesystem — by asking the filesystem whether the two
paths are the same file. Any same-inode match counted, and a hard link is
also the same file: a foreign `mine.txt` the user hard-linked to a file
install owns was adopted as a rename of it, so the foreign-content check
never ran, and the copy wrote through the shared inode and truncated both
names at once.

A match now has to be a case-only rename on both counts: the same file by
identity, AND the same path but for letter case. Anything else is foreign
content and is refused by name.

`hard_linked_ledger_entries_are_all_kept_when_one_of_them_matches` proved
the old adopt-any-inode behaviour, so it is replaced: one test for the hard
link now being refused, and one that keeps the property the old test
actually protected — every ledger entry matching one file is remembered,
not just the last — set up with case-only names, which can only be two
names on a case-sensitive filesystem.
Every copy could succeed and the record that names them still fail to
reach disk — the filesystem holding the out root fills up, or the record's
directory is not writable. The target was then left holding files no ledger
on disk named, and the next install refused the whole run as foreign
content it never wrote: the same wedge the copy-failure rollback exists to
prevent, reached one step later.

A ledger write that fails is now treated like a copy that fails. Everything
this run introduced is removed again, best effort, leaving the target as
the ledger still on disk describes it, and the original write error is
returned. A file this run only overwrote was already owned, so it stays. A
file the stale-removal pass deleted cannot be brought back, but a ledger
entry whose file is already gone is something every later install handles
without complaint.
…he source

Single-file Elm compile read its input before calling `prepare_dest`, so a
deleted or unreadable source returned early — before the task lock was
taken and before the previous record was replaced with a tombstone. The
previous run's SUCCESSFUL record was then left on disk beside a `.dest`
that no longer matched it, and `generate`, which treats a tombstone as a
missing compile but consumes a successful record without question, went on
reading IR from a compile that had just failed.

The source is now read after `prepare_dest`, which is the order the
provider path has always used.

A real single-file compile needs the Elm extension, so the test covers the
ordering rather than the whole run: the failure comes from reading the
source, so the tombstone is only written if `prepare_dest` ran first.
On macOS, realpath preserves the caller's spelling instead of correcting
it to the spelling already on disk. So `-o dist` followed by `-o DIST`
canonicalize to two different strings for what is really one directory:
the second run found an empty previous-files list and refused Morphir's
own output as foreign content, and the two spellings held two different
lock files.

Fix: when the target filesystem does not tell letter case apart, derive
both the ledger key and the lock name from the canonical target with its
components folded to lower case (a simple Unicode case fold is adequate,
since it only decides which entries are the same key, never anything
written to disk). When reading the ledger, also look up the un-folded key
once, so records written before this fix still resolve; this run writes
back only under the folded key, retiring the old one.
A failed std::fs::copy during install, whether for a single file entry
or a file inside copy_dir, surfaced as a bare CliError::FileSystem: "File
system error / Permission denied (os error 13)", with no indication of
which file or where it was headed. Add CliError::Copy, which names the
source and destination and keeps the original io::Error as its source,
and raise it from both copy sites instead. The rollback behaviour after a
failed copy is unchanged.
…the checks

Two related hardening fixes:

- ir_storage::read_value joined a task record's IR descriptor path onto
  its base directory unchecked. A hand-edited compile.json with a
  descriptor path like "../../x" could make it read outside the task's
  own output. Apply the same confinement rule install::validate_entry
  already uses for a record's value/installed entries (no absolute path,
  no "..", no empty or "." segment), factored out as
  install::confine_relative_path so both call sites share it, and fail
  with a Validation error naming the record's IR descriptor path.

- install() created the -o target directory before checking whether the
  install would even be allowed to proceed, so a refused install (an
  escaping entry, a symlink below the target, colliding destinations,
  foreign content already there) still left a new, empty target
  directory behind. Move the directory creation to after the lock is
  held and every pre-flight check has passed. Since
  std::fs::canonicalize needs the target to exist, add
  canonicalize_for_install, which canonicalizes the nearest existing
  ancestor and joins the remaining, not-yet-existing components onto it
  literally -- correct because install goes on to create exactly those
  components, with exactly those names, and nothing can already be a
  symlink where nothing yet exists.
compile's JSON result reported output_path as the IR artifact file inside
.dest (morphir-ir.json, or whatever the configured storage names), on
both the classic single-file path and the v4 native path. generate
already reports the task's canonical .dest directory itself, and that is
what the out-directory design doc's output envelope describes. Make
compile report .dest the same way, on every success and failure branch.

Update crates/integration-tests/tests/elm_extension.rs, which asserted a
path ending in .morphir/out/compile.dest/morphir-ir.json, to assert the
.dest directory instead and read the IR back through
Path::new(&output.output_path).join("morphir-ir.json").

Note in the CHANGELOG's out-directory entry that output_path is the
task's .dest directory for every command.
…er note

Three corrections to docs/design/out-directory.md, none of them matching
what the code has done since earlier fixes in this series:

- The rename-adoption paragraph said a destination is adopted as a rename
  of a ledger entry on filesystem identity alone. It also has to differ
  from the old spelling only by letter case, or a foreign file the user
  hard-linked to one install owns would be adopted as a rename and the
  copy would corrupt both files through the shared inode.
- The threat-model bullet on the task lock said it is held "until the
  record is written." It is held through the end of the task's own -o
  install, matching what the earlier paragraph on `<task>.lock` already
  said.
- The members paragraph did not say what happens when a symlinked member
  is reached by another path: running from inside the symlink's physical
  target discovers no enclosing workspace, and the project is treated as
  standalone rather than as that member.
Bring in a3615a7: fix(devkit): apply the ir.mode alias to the environment
layer too.
Formatting fallout from the preceding fixes, mostly line-wrapping
multi-argument function signatures and one long assertion.
@DamianReeves
DamianReeves merged commit 64dc34e into main Sep 3, 2026
15 checks passed
@DamianReeves
DamianReeves deleted the t3code/understand-compile-output branch September 3, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant