Skip to content

Have the Tree Check Call escapes_repo_root Rather Than Restate a Weaker Subset of It #1452

Description

@ptr727

Found while fixing #1445 (2026-09-08). That issue reported one spelling the tree check accepts and
scripts/carry.py refuses. Two more of the same shape are still open, and both have the same root
cause: the tree check hand-rolls a weaker subset of this file's own escapes_repo_root.

spec/validate.py's tree loop tests each of source and target with three conditions of its own.
escapes_repo_root, defined in the same file, already covers those and two more, and it is called
only for baseline paths, never for trees. Its docstring even names the two the tree check misses.

A backslash spelling escapes the root, and only the carrier notices

"..\\" has PurePosixPath("..\\").parts == ("..\\",), one part, so ".." in parts is false. It
has no leading / and does not reduce to the root, so the validator accepts it.

scripts/carry.py's relative_root accepts it at the same first check, then resolves and calls
relative_to, which on Windows takes C:\hub\.. to C:\ and raises. So the manifest author gets a
green validator and a failing carrier, which is exactly what #1445 reported for "./".

The same spelling also makes the validator itself host-dependent. A source of "spec\\schemas"
fails is_dir() on Linux, since no file carries that literal name, and passes on Windows. A
target has no existence check at all, so "docs\\x" is accepted on both and means one directory
on one host and two on the other.

A symlinked source is green here and refused by the carrier everywhere

The tree loop's existence check is (ROOT / source).is_dir(), which follows a symlink, so a source
naming a link to a directory validates. scripts/carry.py refuses a symlinked component of a tree
source outright, on every platform, so this one does not even need Windows to diverge.

Suggested shape

Call escapes_repo_root from the tree loop rather than restating a subset of it, keeping the
root-reduction helper #1445 added beside it. That closes the backslash and drive-letter cases in
one move and leaves one implementation of the rule instead of two.

The symlink case is a separate check, since escapes_repo_root does not read the filesystem.
Resolving the source and refusing a symlinked component, the way the carrier already does, is the
matching fix.

Not live today

spec/files.json declares one tree, source: ".github/skills", so nothing in the fleet trips
either of these now. Both are gates that would fail to fire rather than defects with a current
victim, which is the same standing #1445 had.

Metadata

Metadata

Assignees

No one assigned

    Labels

    gateA rule with no mechanical check, or a check that misses a shape

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions