feat(selection): source offer + target take selection model (path-identified leaves)#44
Merged
Conversation
…y base (SMR-001)
Manual re-extraction of three config-agnostic pieces from feat/file-and-path-selection,
the baseline the selection-model redesign builds on:
- safe_relpath (kernel/name.rs): interior-slash-admitting path guard that delegates each
component to safe_component, so Windows drive prefixes (C:/x), bare drive components
(a:b), and reserved DOS device names (NUL/COM1) are rejected — not just ../leading-/.
- RecordKind {File, #[default] Dir} + #[serde(default)] kind on RegistryRecord/ProjectedRecord,
threaded through RegistryRecord::projected. Legacy on-disk records without kind load as Dir
(byte-stable backward compat).
- record_manifest_base branches on kind (File => parent of the deployed file, Dir unchanged);
record_artifact_path stays kind-agnostic. MAP_LAYOUT retained (removed in SMR-031).
No selection-engine, config-DTO, archive.rs, basename-collision, or map-shorthand changes.
Tasks: SMR-001
Batch: 1
Bake in the structured selection diagnostic before P1-P3 so rich errors are emitted
from the start, never retrofitted. New src/diagnostic.rs:
- SelectionDiagnostic { entry, matched_against, why, did_you_mean, remedy, debug_hint }
carrying the full attribution chain the design names.
- Display renders named, exact-phrase sections (consts SELECTION / MATCHED_AGAINST /
DID_YOU_MEAN / REMEDY / TO_DEBUG, confine.rs convention) in canonical order; did-you-mean
and to-debug omitted when absent or empty (symmetric omission contract).
- config()/sync() wrap the full render into the existing Error::Config / Error::Sync.
Own module (error.rs forbids new message-bearing failures); pub consts let SMR-063 build
the named-phrase catalog. No strsim engine, no CLI commands, no call-site wiring yet.
Tasks: SMR-013
Batch: 2
…e & map (SMR-010/011)
Source-owned selection lands at the config layer:
SMR-010 (src/config/source.rs): ParsedSource::offer() -> Offer
{includes, excludes, is_implicit_full, vcs_excludes(.git/), root}. `!`-negation
rejected at parse via SelectionDiagnostic. Config-layer only; the gitignore→leaf
compilation is SMR-020.
SMR-011 (src/config/target.rs): TakeEntry{Leaf, Rename} untagged over
String | {src=dest}; Binding.take Option<Vec<TakeEntry>> (None=project everything,
Some([])=project nothing, D14); Target.take BTreeMap<anchor, Vec<TakeEntry>>
(target-owned mount take table, D13); ResolvedBinding carries take + source-owned
root/include/exclude (no binding override — closes the WHAT-exposure widening hole,
D2/D8). imports stays refinement-free Vec<String>.
Coherent removal of the old surface (ripple, user-approved):
- `map` construct fully deleted: DTO + dead deploy branches in
sync/{plan,preview,rebuild,target,transitive}; reject_map removed; ~48 obsolete
map tests + tests/scrut/mapped.md dropped.
- 13 sync binding-override tests triaged (7 deleted as removed-behavior, 6 migrated
to source-owned fixtures keeping aliasing/lock/collision assertions).
- phora.example.toml + README self-contained fence migrated to the take model.
CLI (source offers / target takes):
- `phora bind` mirrors config `take`: repeatable --take (literal/glob + `src=dest`
rename, split on first `=`), emit round-trips through Binding; --root routes to
the source; binding-level --include/--exclude removed.
- `phora add` CREATES the source, so --include/--exclude/--root route to the SOURCE
table (source-owned offer); selective deploy honors them today via
sync/target.rs Selection::new over the source-surfaced ResolvedBinding.
KNOWN GAP (SMR-021/031): `take` is DTO-only — not yet wired into deploy; binding
--take round-trips but does not filter projection until take resolution/apply land.
Verified: cargo test 25 suites / 0 failed (lib 1051+); all 6 scrut suites green;
cargo clippy --tests -- -D warnings clean.
…y dropping --root External review (codex-gpt5.5, gemini-3.5-flash) found that routing `bind --root` to the source via set_source_roots silently no-ops when the source's [sources.<name>] table is not in the file being edited. run_bind validates the source against the MERGED view (phora.toml + phora.local.toml) but mutates only target_config_file(local), so `bind <main-source> --to t --root R --local` edits phora.local.toml, finds no source table there, and drops the requested root with no error. Make set_source_roots return Error::Config when a named source has no table in the document, naming the source and `root` and pointing at the source-owned remedy. add_local is unaffected (it threads root through upsert_source at source creation). Regression test added. cargo test 25 suites / 0 failed; all 6 scrut green; clippy --tests -D warnings clean.
…iler (SMR-012/020) SMR-012 (config): reject_binding_scope_keys raw-TOML pre-scan emits a structured SelectionDiagnostic for binding-level root/include/exclude/map (redirect to source offer / target take); TakeEntry rename arity (one pair per entry, flat+mount); reject_malformed_take_globs over glob-classified take entries. Parse-time structural only (D12/D15); validate_imports stays refinement-free. SMR-020 (kernel): new OfferSelection offer→leaf-set compiler built alongside the old two-tier Selection (callers rewired in SMR-030/031). gitignore set composition (include−exclude, exclude-wins, no negation), dotfiles match, .git/ VCS prune unless the .git component is explicitly named, root re-anchor + root-relative publish with traversal-shaped names skipped (D4/D18/D20/D22/M5). Tasks: SMR-012, SMR-020 Batch: 4/8
…ont-door consolidation (SMR-021) resolve_take projects a binding take directive over the sealed offer leaf set with order-independent Scala-3 semantics: globs expand over the OFFER only (no-widen seal), explicit literal/rename consumes its leaf out of overlapping globs, rename is destructive, duplicate literal idempotent, non-offered = hard error, no-match glob = warn, take=[] = nothing, omitted = identity-everything. Single-binding duplicate-dest rejected under an NFC + simple-lowercase fold; rename dests gated by safe_relpath. Cross-binding dup-dest and full leaf-dest confinement remain SMR-030/031. Consolidates the three glob front-doors: is_take_glob is the sole D21 classifier and compile_take_glob (reusing OfferSelection's translate) the sole offer-glob compiler, shared by parse-time validation and the resolver so parse-time acceptance matches sync-time compilation (closes the batch4 deferral). safe_relpath tightened pub -> pub(crate) (first in-crate caller). Phase-C 4-reviewer gate cleared: same-src->two-dest fan-out now a hard error; dup-dest/warnings output made permutation-stable; tests hardened to exact-set oracles + all-consumed-glob no-warn edge. 1125 lib + scrut green, clippy -D clean.
…MR-022) Pure kernel collapse planner (src/kernel/collapse.rs): projects the resolved take leaf set onto maximal collapsed directories + per-leaf artifacts. - D5/C4: a wholly-taken dir collapses to one dir symlink (link) / subtree copy (copy); maximal — a clean nested tree collapses at its topmost dir. - D16: link within-dir exclude blocks collapse -> per-leaf fallback + one LostCollapseToExclude warning, never leaks the excluded leaf; copy prunes the excluded child in the subtree, no warning. - D23: `collapse` binding + mount-parity DTO key (Target.collapse anchor table). omitted = algorithmic; false = ForcePerLeaf; true = ForceCollapse hard-errors when a within-dir exclude (link) or per-leaf rename blocks whole-dir collapse. HOW-not-WHAT -> exempt from the SMR-012 binding-scope rejection like template/take. Phase-C 4-reviewer consensus fixes folded in: - a rename whose DEST lands under a collapsible dir now blocks that collapse (is_copy_collapsible checks source||dest), closing a silent overlapping-plan hole found by all four reviewers. - ForceCollapse diagnostics are deterministic under input permutation (collect all blocked dirs, name the lexical-first) rather than first-blocked-leaf. Kernel stays pure (no config/sync dep); config->kernel mapping is SMR-031. 1172 lib + 104 scrut green; clippy -D / fmt clean; zero net-new suppressions.
…aller (SMR-030) Strangler-fig leaf-granular resolver behind plan_target, alongside the still-live old directory-granular deploy_target (SMR-031 rewires that). First caller of the pure kernel: OfferSelection -> resolve_take -> plan_collapse per binding, with config->kernel mappings (TakeEntry->Take via is_take_glob, DeployMode->CollapseMode, Binding/Target collapse->CollapseChoice). - new SourceBackend::list_source_leaves seam (GitBackend body, HttpBackend delegates to its git mirror, RouterBackend routes by source mode); Link via discover_working_tree_leaves - TargetPlan reshaped to leaf/collapsed-dir Materializations + destinations; remove dead PlanEntry.mapped; prune derives expected keys from the plan with bidirectional dir<->leaf containment (no data loss while the live writer is still dir-granular) - cross-binding target-global dup-dest over the union of all bindings (fold_dest pub(crate), NFC + simple-lowercase) - sealed-offer D9 (validate_sealed_offer): a recorded artifact no longer in the resolved offer is a hard error; offer-vs-take containment; snapshot taken after recovery sweep so crash-recovered records are validated - mount take/collapse: consumer-owned (anchor.take/collapse) keyed by imported dep name, folded onto the composed dep's bindings (D13/C3) 4-reviewer Phase-C gate cleared (opus/sonnet/codex-gpt5.5/gemini-3.5-flash). GREEN: 1205 lib + 104 scrut, clippy -D / fmt clean.
…031 8a)
Rewire deploy_target off the old dir-granular Selection+discover_artifacts
engine onto plan_target's leaf-granular TargetPlan (strangler flip). Each
PlannedItem materializes per kind:
- CollapsedDir: dir symlink (link) / pruned subtree copy (copy)
- Leaf(ResolvedTake): file symlink (link) / single-file export (copy)
Registry reconciled to RecordKind::File (leaf) / Dir (collapsed); every
materialization destination run through safe_relpath + confine_destination
before any write (closes the deferred SMR-021 full-dest confinement: reserved
device names / colon dests now rejected at sync).
Phase-C 4-reviewer gate (claude opus+sonnet, codex-gpt5.5, gemini-3.5-flash)
consensus fixes applied + independently re-verified green:
- copy CollapsedDir export honors the resolved kept-leaf set via an anchored
Selection (no offer-excluded/take-dropped child leaks into a collapsed copy)
- reject_partial_take_collapse re-collapses a take-partial dir; its hardcoded
Link mode is load-bearing (blocks take-dropped children surviving a copy dir)
- per-leaf copy export now renders templates + strips .tmpl deployed name
(run_mapped render=true; identity deployed-name stripping in the planner)
- link-mode file leaf uses symlink_file vs symlink_dir per RecordKind (Windows)
- store.rs collect_records recurses per-source subtree for slash-bearing keys
1221 lib + 104 scrut green, clippy -D / fmt clean. Kernel untouched.
DEFERRED (tracked for 8b/8c/SMR-033): rebuild_registry flip; delete old
Selection engine + SourceBackend::discover_artifacts/list_artifact_files + map
construct (MAP_LAYOUT/path_map/run_mapped); flip preview/query (preview now
under-predicts top-level loose-file leaves vs deploy); dir->leaf prune migration
+ ejection overlap + validate_sealed_offer-vs-prune lockout (SMR-033);
Target.take/collapse merge-CLEAR + VCS-prune-at-write (M5) + manifest re-pin (8c).
…rt + leaf-granular rebuild/preview/query (SMR-031 8b)
Re-platform export/digest off the dir-granular Selection engine and delete it
entirely (user decision: full elimination). ExportRequest now carries an explicit
ExportLeaf{source,dest} plan; ExportWalk::run + run_mapped collapse into one
explicit-leaf export; compute_digest moves onto OfferSelection. Delete
SourceBackend::{discover_artifacts,list_artifact_files} (+ impls + spies),
discover.rs::{discover_working_tree,discover_artifacts_for_source}, the old
discover_binding, store::MAP_LAYOUT + its record_artifact_path/record_manifest_base
branches, and the Selection struct. Flip rebuild_registry, preview, query check-match,
and resolve onto the leaf-granular plan / OfferSelection. Fixes the preview divergence
(top-level loose-file leaves now predicted).
Digest byte-stability preserved (stage_leaf framing unchanged; digest_pin holds the
identical value across engines).
Phase-C 4-reviewer gate (claude-opus/sonnet + codex/gemini) consensus fixes:
- scan_foreign dropped binding identity -> false-foreign under by-source/prefixed
layouts (4/4 reviewers); now compares against identity-composed PlannedItem
destinations. + by-source foreign regression test.
- preview double-applied the source root (discover WITH root, then rooted offer
rejects the already-root-relative candidates) -> empty preview for rooted sources;
now discovers root=None like plan_target. + rooted-preview parity test.
- scan_foreign spares user-owned top-level dotfiles (ARCH-003 parity); orphan cleanup
is sync --prune's job. + user-dotfile-spare test.
- removed dead SourceError/Error::ArtifactNotFound; removed rebuild_linked dead report
param (let _ = report suppression).
- re-pinned prune-on-narrow guard as #[ignore] SMR-033 marker (sealed-offer locks out
prune of a narrowed offer today).
1207 lib + 104 scrut green; clippy -D / fmt clean; grep gates 0 (Selection, MAP_LAYOUT,
discover_artifacts, list_artifact_files, run_mapped, path_map).
…fest re-pin (FINAL)
Option-wrap Target.take/collapse (None=inherit, Some(_)=replace wholesale incl.
Some(empty)=clear), matching the sibling Option config fields — a local overlay's
explicit-empty mount table now clears the base back to take-all. Updated merged_with,
config/mod.rs validation, and transitive.rs consumers (compose_dep/mount_for/synthetic_target).
VCS-prune-at-write (M5): ExportWalk prunes any leaf whose DEST carries a .git path
component (component-wise, any depth), UNLESS ExportPolicy.vcs_opt_in — derived in
export_policy() from the source offer naming .git, mirroring OfferSelection.vcs_named.
Catches a take-rename into .git/ that confine (consumer-cwd/.git only) misses.
Re-pin dep_cannot_overwrite_the_consumer_phora_manifest via a take-rename dest landing
on the consumer's phora.toml (old vector was map={phora.toml=phora.toml}).
1217 lib / 104 scrut green, clippy -D / fmt clean. 2-reviewer Phase-C gate cleared.
allow_submodules was read nowhere: the flag was never branched in the export walk, both SubmoduleNotAllowed error variants were defined but never constructed, and submodules (EntryKind::Commit) are unconditionally skipped in the tree walk regardless of the flag. Drop the ExportPolicy field, both error variants, the source-config DTO surface (raw + parsed + merge), and its config_digest fold. Returns ExportPolicy to three bools, eliminating the struct_excessive_bools #[expect] added in 8c. No behavior change (submodules were already skipped). 1217 lib / 104 scrut + all integration crates green, clippy -D / fmt clean.
… prune (batch9) SMR-032: pin that offline preview reflects a narrowing take (taken leaf set, not the whole offer) — code already leaf-granular since 8b; adds the missing take-reflection test. SMR-033: replace batch7's bidirectional dir<->leaf prune regression-guard with EXACT-match record reconciliation + a path-overlap guard (overlaps_live_dest). prune drops a stale record whose granularity flipped (collapse<->per-leaf) but SKIPS the filesystem delete when the orphan path is an ancestor/descendant/equal of any live expected destination — closing a CRITICAL data-loss pair (remove_dir_all over a live deployed leaf; remove_file through a collapsed symlink into the SOURCE tree), pinned by two regression probes. Instance-granular via (target,identity) buckets; two anchors of one dep prune independently. Absorbs 3 deferred HIGHs: ejection dir<->leaf overlap (check_artifact_state bidirectional containment); sealed-offer vs --prune lock-out (validate_sealed_offer fires only when the offer config still admits the path = genuine source drop, else leaves it to prune — no blanket skip, via OfferSelection::admits_published); dir->leaf prune-migration core (record reconciled, live path preserved). 2-reviewer gate cleared (correctness+scope, both approved, data-loss guard confirmed sound). 1228 lib / 104 scrut / clippy -D / fmt clean.
Promote strsim 0.11.1 to a direct dep; add reusable diagnostic::did_you_mean(entry, candidates) — bounded Levenshtein (within max(2, len/3) edits), nearest-first, lexical tie-break, capped at 3, None (not empty Vec) when nothing is close so the diagnostic omits its line. Wire it into non_offered_diagnostic so a typo'd / non-offered take literal suggests the closest offered leaf (the headline 'why isn't X selected -> did you mean Y'). Engine is pub for validation + CLI reuse. Scope: covers non-offered take + typo'd literal (same site); duplicate-dest already names both entries (pre-existing). no-match-glob nearest-paths deferred to SMR-062 (which owns warning render); engine is ready for it. 1231 lib / clippy -D / fmt clean.
…atch10)
SMR-061: new offline, side-effect-free `phora explain <target> <source> [path]`
attributing a path's offer decision (which include offered / which exclude
vetoed, nearest-leaf did-you-mean when outside) and how `take` resolves it
(identity / rename / collapsed-dir / dropped); no-path mode summarizes offered
leaves. Reads lock + store cache only — never fetches, resolves, or writes;
cache-miss / unbound / unknown-target return structured SelectionDiagnostics.
Reuses resolve_binding_plan (same resolver deploy uses) + OfferSelection
per-pattern probing; no kernel change. discover_working_tree_leaves widened to
pub(crate) for link-mode offline listing.
SMR-041: README prose brought to the new model — source-owned offer vs
target-owned take, take literal/glob/{src=dest} rename + take=[]/omitted,
collapse override (D23) with the dotter-recurse analogy, anchor-keyed mount
take/collapse tables, binding-scope hard-error note, and an owner terminology
table; corrected stale CLI-flag claims and dropped the removed allow_submodules.
Phase-C review (2 read-only claude reviewers): scope/docs approved; correctness
changes_requested with F1 (Medium) + F2 (Low), both fixed pre-commit:
- F1: copy-mode `.tmpl` identity leaves were misattributed as Renamed because
apply_deployed_name rewrites the dest to the suffix-stripped name; attribute_take
now treats deployed_name(source)==dest under Copy as kept-at-identity. Pinned by
copy_mode_tmpl_suffix_strip_is_kept_at_identity_not_a_rename.
- F2: the cache-miss test only exercised the unlocked branch; added
locked_but_missing_cached_export_yields_a_structured_diagnostic_without_fetching
for the backend-error (missing cached export) path.
1247 lib / 0 failed (1245 + 2 fix pins); 104 scrut / 0 failed; clippy -D / fmt clean.
… no-match-glob suggestions (batch11)
Preview previously discarded ResolvedBindingPlan.warnings. It now threads them
per binding into a structured PreviewWarning model { TakeNoMatch{pattern,
suggestions} | CollapseBlocked{dir} } carried on PreviewTargetPlan and rendered
in BOTH the tree and JSON outputs. For TakeNoMatchGlob it absorbs the
SMR-060-deferred render: nearest-offered `did you mean` suggestions computed at
the preview boundary via the canonical crate::diagnostic::did_you_mean over the
recomputed offer set (kernel take resolver untouched). Renames render `src ->
dest` and collapsed dirs gain a trailing `/`; the copy-mode `.tmpl` suffix-strip
is excluded from rename detection (same trap fixed in SMR-061's explain).
2-reviewer Phase-C gate (read-only): both approved. Shared Medium test-gap fixed
pre-commit — rename_of's suffix-strip guard (the F1 safety net) was correct but
untested end-to-end; narrowed rename_of to (mode, template_opt_in, materialization)
and pinned both directions (copy_mode_tmpl_suffix_strip_is_not_a_rename +
a_take_rename_is_reported_as_a_rename) so removing the guard fails a test.
Sealed-offer drift DEFERRED to SMR-042/063 (needs Registry access offline preview
lacks; it is sync/validate_sealed_offer's job).
Golden snapshots + showcase.md scrut updated for the trailing-`/` collapsed-dir
marker and the new rename/collapsed/warnings JSON fields (verified honest:
destinations + child listings byte-unchanged; golden passes with INSTA_UPDATE=no).
1257 lib / 0 failed; 104 scrut / 0 failed; golden 1/0; clippy -D / fmt clean.
…-phrase catalog (batch12)
Every production include/exclude/take SelectionDiagnostic now ends with a `to
debug:` line naming the relevant command, alongside its concrete remedy:
- kernel take.rs (5 rejections) + collapse force-block use identity-free
placeholders (KERNEL_ATTRIBUTION_HINT = `phora explain <target> <source>
<path>`; `phora preview --files`) — kernel purity preserved (no config/cli
imports threaded in);
- sites that hold identifiers interpolate concrete commands: sealed-offer →
`phora explain {target} {source} {artifact}`, binding-scope → `phora explain
{target} {binding}`, cross-binding dup → `phora preview --target {target}`,
cli cache-miss/unbound → `phora preview --source/--target ...`.
unbound_diagnostic + reject_cross_binding_dups gained minimal plumbing params to
feed the hint; no diagnostic entry/matched_against/why/remedy text changed and
the sealed-offer detection logic is byte-identical.
Named-phrase catalog extended with exact-phrase tests (confine.rs convention):
each named diagnostic asserts its literal `to debug: phora ...` line, so removal
or miswording fails a test.
2-reviewer Phase-C gate: BOTH approved, zero findings (audit complete — no
production debug_hint:None remain; kernel purity intact; catalog tests exact;
no behavior regression).
1259 lib / 0 failed (1257 + 2 new); integration crates pass; 104 scrut / 0 failed
(no scrut wording needed updating); clippy -D / fmt clean.
… model (batch13) New tests/scrut/selection.md (24 testcases, 104→128 across the suite) exercises the redesigned model end to end: a take subset (literal + rename) deploys only the named leaves; take=[] deploys nothing (with explain proving the offer is intact but narrowed); leaf-granular prune removes only the now-unselected leaf and leaves its sibling (RQ-5/SMR-033); a rename-dest escape (../, absolute, reserved device) is rejected with the full structured diagnostic incl. the SMR-063 `to debug:` line; binding-level scope (include/map) is hard-rejected with the source-offer / target-take redirect; and phora explain / phora preview are exercised (offer+take attribution, did-you-mean on a non-offered leaf, rename + collapsed dir + no-match-glob warning, --files). Deploy/prune blocks back stdout with on-disk test -f/test ! -e probes. Two _setup.sh helpers added (seed_selection heredoc-free per a scrut quirk; reset_deploy for per-scenario hermeticity). Transitive take-subset + two-anchor Instance prune are DEFERRED from scrut to the lib layer (transitive.rs:1285 mount_take_table_subsets_a_mounted_deps_offer + :1346 two_anchors_mounting_same_dep_are_independent): real depth>1 composition is un-bootstrappable in the network-free hermetic harness (reject_escaping_remote blocks a depth>1 dep reaching a local/file:// remote); scrut covers the direct-binding equivalent driving the same resolver. Reviewer-confirmed sound. No production src/** changed. 1-reviewer acceptance gate cleared (zero findings: no tautological blocks, diagnostic phrases byte-match the binary, hermeticity + policy clean). 7 docs / 128 scrut testcases / 0 failed; 1259 lib / 0 failed; clippy -D / fmt clean.
…iew H1) reject_cross_binding_dups keyed a BTreeMap on the exact folded destination, so a collapsed dir at `d` and a leaf at `d/a.md` got distinct keys and both passed — yet `d` deploys as a directory symlink the leaf would escape into, a deploy-order-dependent escape into the linked source tree under flat layout. Add a second pass that rejects any destination whose path-component ancestor is itself a destination (split on `/`, not a raw byte prefix, so `d` vs `d.md` is not a false positive). Order-independent; reuses the existing diagnostic so phora preview surfaces it.
In ScanMode::Strict with allow_symlinks, the empty match arm dropped the symlink: scan.symlinks stayed empty and copy_tree iterates only scan.files, so the symlink was silently lost on the rename->copy fallback across a mount boundary. Record the allowed symlink in strict mode (as soft already does) and have copy_tree recreate each recorded symlink via read_link + symlink, preserving its target — matching the rename path's preserve-as-symlink semantics.
materialization_key/published_key was copy-pasted byte-identical across plan, preview, rebuild, and target. Make it one inherent method on the Materialization kernel type and drop the four free-function copies.
…eview M4) offered_leaves was duplicated in preview and cli/query, and both swallowed an OfferSelection::compile failure into an empty Vec. In the query explain path that silently rendered an empty offered-leaf summary instead of the error the real plan path raises. Consolidate into one shared Result-returning helper: query propagates the error; preview (suggestions-only, on an offer already validated by plan resolution) degrades gracefully.
as_dir(pattern, directory: bool) misused the as_ prefix (it allocates, not a cheap borrow conversion) and threaded a bool flag. The flag is constant within translate, so fold it into a single dir_suffix and inline the three call sites.
…ew M1) Name the O(kept^2)-per-binding collapsibility scan ceiling and its upgrade path (a dir->leaves prefix index) at the loop, per the tradeoff convention.
… (review M5) rebuild_binding called config.parsed_sources() for every binding just to look up its template_opt_in, re-parsing the whole config N times per target. rebuild_registry already parses once; thread that map through BindingRun.
…aled-offer + explain
The sealed-offer drift guard compared a recorded artifact key against the offered
source leaves, but a renamed artifact is keyed by its destination — never a source
leaf — so every re-sync of a `{ src = dest }` take with src != dest wrongly tripped
"a recorded artifact is no longer in the source's offer". Translate the key back to
its offered source leaf via the resolved take before the offer checks; the offer-vs-
take distinction stays in source-leaf space.
`phora explain <target> <source> <path>` had the mirror bug: a path under a source
`root` is given as a root-relative offered leaf, but the per-pattern selects strip
`root` from their candidates, so the leaf never matched and read as "outside the
offer". Reconstruct the full source path before those selects.
Regression tests: re-syncing an active rename binding; explain attributing an
offered leaf under a root.
srnnkls
added a commit
that referenced
this pull request
Jun 24, 2026
… model Rewrite GUIDE.md to PR #44's source-offer / target-take model: source owns the offer (`root` + gitignore `include`/`exclude`), binding owns the `take` (literal / glob / `{ src = dest }` rename), artifacts are path-identified leaves, and a `collapse` opt folds a wholly-taken directory into one artifact. Replaces the "mapped leaves" section with take renames, adds Collapse and mounted-dependency subsetting sections, documents `phora explain` and the binding-scope rejection, and fixes the lock/digest internals to talk in offer/take terms. Update the scrut suites to the new model and verified output: mapped.md uses `take` renames (preview `src -> dest -> path`, structured collision diagnostic), showcase and versions carry preview's trailing-slash collapse marker and check-match's gitignore-exclude semantics, and release-assets drops the obsolete "single files can't deploy" note (loose files now deploy as leaf artifacts).
srnnkls
added a commit
that referenced
this pull request
Jun 24, 2026
Stacked on the selection-model-redesign branch (PR #44). Adds GUIDE.md, the long-form companion to the README, written to the source-offer / target-take model: source owns the offer (`root` + gitignore `include`/`exclude`), binding owns the `take` (literal / glob / `{ src = dest }` rename), artifacts are path-identified leaves, and a `collapse` opt folds a wholly-taken directory into one artifact. Adds use-case scrut walkthroughs that drive the shipped binary against real upstreams and double as runnable, CI-verified usage docs: showcase, drift, versions, release-assets, mapped (take renames), hooks, templates, and transitive. These sit alongside the branch's hermetic suites (selection, query, manage, lifecycle); the README testing section indexes both.
srnnkls
added a commit
that referenced
this pull request
Jun 24, 2026
Stacked on the selection-model-redesign branch (PR #44). Adds GUIDE.md, the long-form companion to the README, written to the source-offer / target-take model: source owns the offer (`root` + gitignore `include`/`exclude`), binding owns the `take` (literal / glob / `{ src = dest }` rename), artifacts are path-identified leaves, and a `collapse` opt folds a wholly-taken directory into one artifact. Adds use-case scrut walkthroughs that drive the shipped binary against real upstreams and double as runnable, CI-verified usage docs: showcase, drift, versions, release-assets, mapped (take renames), hooks, templates, and transitive. These sit alongside the branch's hermetic suites (selection, query, manage, lifecycle); the README testing section indexes both.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the old binding-scoped
root/include/exclude/mapselection model with a two-sided model: a source publishes an offer (include−excludeover gitignore syntax) and a target binding takes a subset of it (take: literal leaves, globs, and{ src = dest }renames). Artifacts are now path-identified leaves, not top-level directories.Closes the
selection-model-redesignscope — 18 tasks, all with passing Phase-C review gates.What changed
Config model (P1)
include/exclude,rootre-anchoring, dotfiles match, implicit-full-minus-.git.take(String | { String = String }): omitted = take everything,take = []= take nothing; mounttake/collapsetables (target-owned, anchor-keyed) for composed deps.root/include/exclude/map) is now a hard parse-time error with a structured did-you-mean redirect (pre-alpha, no migration).SelectionDiagnosticfoundation baked in from the start.Selection engine (P2)
OfferSelection: gitignore offer → path-identified leaf set.takeresolution with Scala-3 precedence (order-independent; glob expands over the offer; explicit consumes-out; destructive rename; non-offered = error; offer sealed/no-widen).collapseoverride (false= force per-leaf,true= error when an exclude/rename blocks it — a dotter-recurseanalogue).Sync / deploy (P3)
Selectionelimination (explicitExportLeaf{source,dest}plan); dest confinement (safe_relpath/confine_destination) on every materialization; VCS-prune-at-write; Instance-granular prune ontakenarrowing.Docs + diagnostics (P4/P5)
did_you_meanengine; new offlinephora explain <target> <source> [path]attribution command; richphora preview(renames, collapsed dirs, no-match-glob suggestions, warnings); every include/exclude/take diagnostic ends with a concrete remedy + aphora explain/previewdebug command.Acceptance
tests/scrut/selection.md(24 testcases) exercises the model end to end.Verification
cargo test --lib: 1259 passed / 0 failedmise run test-integration: 7 docs / 128 scrut testcases / 0 failedINSTA_UPDATE=no): passcargo clippy --tests -- -D warnings+cargo fmt --check: cleanKnown follow-ups (non-blocking)
takesubset + two-anchor Instance prune and sealed-offer drift are covered by lib unit tests (transitive.rs), not scrut: real depth>1 composition is un-bootstrappable in the network-free hermetic scrut harness (reject_escaping_remoteblocks a depth>1 dep reaching a local/file://remote).*intakerename-maps) intentionally left in backlog.