Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.
Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
Phase 0 of epic #4636. ADR-4650 fixes the decisions Phases 1-4 inherit, so that
four phases do not each invent them independently.
The load-bearing decision is that the engine and the exported shape are
separable. A resolver-based, symlink-safe containment predicate already exists
as validatePath, and building the epic's literal assertWithinRoot() from scratch
would create a sixth implementation of the very thing this epic consolidates --
while risking silent loss of behavior validatePath acquired as bug fixes (a
closed dangling-symlink existence oracle, ancestor canonicalization for
non-canonical roots, a separator-aware boundary test).
But the epic's other clause is correct and lands on the current export:
validatePath returns a boolean a caller can forget to check, and populates
`resolved` with the escaping path precisely on the traversal branch. While that
form stays exported, the Phase-4 ratchet could only assert that a helper was
called -- validatePath(x, root).resolved would pass the rule.
So: preserve the engine, narrow the export. assertWithinRoot becomes the only
export and yields a branded ContainedPath.
Also recorded, each found by measurement rather than from the epic text:
- The rejection message text is a real contract. tests/quick-batch.test.cjs
asserts a user-facing `reason` field matches /escapes allowed directory/, so
the string reaches CLI consumers and Phase 3 must preserve it verbatim.
- Two further unconfined boundaries the epic does not enumerate: resolvePath
and gap-analysis.plan-post, both in check-command-router.cts.
- --phase-dir also interpolates into ${PHASE_DIR} for command-exit-zero, so
confining at the boundary covers both predicate kinds; the evaluator stays
fs-free.
- opts.allowAbsolute is a per-call-site liberality knob, which is an acceptance
policy living exactly where this ADR says it must not.
- planning-inspect's isWithinRoot is deliberately pure-string with no I/O; its
contract differs, so Phase 3 decides rather than assumes.
The acceptance policy is stated once: conservative about the resource, exact
about the classification. #4580's guard was not too strict, it was wrong -- a
category error comparing a whole suffix against a set of final extensions.
ADR opens as Proposed; ratified at Phase 4 closeout per docs/adr/README.md.
No changeset: the diff touches docs/adr/ only, which is outside
USER_FACING_PREFIXES in scripts/changeset/lint.cjs.
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>