Skip to content

fix: fluid split left a moved $ref pointing off, wrote over files in --out, and half-undid a refused split - #765

Merged
fas89 merged 32 commits into
mainfrom
fix/split-edge-cases
Oct 29, 2026
Merged

fas89 merged 32 commits into
mainfrom
fix/split-edge-cases

Conversation

@fas89

@fas89 fas89 commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Follow-ups of the drafts 21-29 work (#760), Trello WffUBM9S (SP-01..06), plus what two review rounds and a final wave found in the same command. The card's strict xfails are gone and their tests pass.

What changed

A relative $ref inside a part split moves is rebased (SP-02). sql: {$ref: ./sql/transform.sql} was copied unchanged into fragments/builds/transform.yaml, where it named a file one or two directories off, so split refused the contract. A split in two passes (--parts sql, then --parts builds) could not work. The ref now names the same file from the fragment (../../sql/transform.sql, a #/pointer kept). A URL, an absolute path and a same-document pointer are left as written. A rebased ref keeps its quoting and its letters; a character that hides (a bidi control, a zero-width one) is written escaped.

A refused split leaves the tree as it was, as its message says.

  • It took back the files it wrote but left the directories it made (an empty fragments/ changed what other commands did), and after a failed write the empty file.
  • A read-only root ended in "Unexpected error" (exit 2); it is refused like any failed write (exit 1), as is a JSON root with a character that has no UTF-8 form.
  • An interrupt (Ctrl-C) halfway through the writes is taken back too, the root first.
  • The dry run refuses a symlinked directory on the way, as the real run already did.

A part in a container a YAML alias or << merge shares is split from its own place. Two builds merging the same defaults made fluid split --parts sql fail with "write() argument must be str, not dict" and leave an empty sql/b.sql. Each build's sql is now a file of its own. The data split writes every object in full, with no alias, so its output stays under the loader's 50-alias cap.

fluid split --out DIR never writes over another file.

  • An existing DIR/contract.fluid.yaml that is not the contract being split was replaced without a word. The one exception is a byte-for-byte copy of it that is no hard link to another file, and --help now says so.
  • A second split into the same --out wrote -2 copies and left the first run's fragments unreferenced.
  • Both are refused now, the dry run included, with the remedy. For a hard link the remedy is "Move it away, or choose another --out".

A long id gets a file name a file system takes (SP-05). An id of 300 characters gave a 305-byte name: the dry run listed it and the real run failed. A slug over 100 characters now ends in 8 hex digits of a hash of the whole id.

Split keeps more of the root as written (SP-01, SP-04).

  • A comment on the last line of a moved block keeps the line's CR in a CRLF file.
  • A keep-chomped block scalar (|+, >+) keeps its trailing blank lines.
  • A tag or an unused anchor on a moved section's key line no longer makes the splice write unparseable YAML.
  • A comment on a block scalar's header line stays beside its $ref.

Hardening of the new code, found by review:

  • The header-comment search and the ./ steps of a ref are linear in time and memory: 100k blanks or 1M steps in well under a second.
  • A copy in --out that is a hard link to a third file is never written through.

Borrow before build

Bug fixes inside existing code (the design space was chosen in #760). Reused in-repo:

  • util/ref_confinement (a new is_relative_file_ref beside confine_ref, checked against it on 13,253 edge strings);
  • the loader's own compose for the post-split check;
  • the slug and hash scheme fluid docs uses.

Validation

  • Full suite (-n auto --dist loadscope --ignore=tests/perf): 30592 passed. The 7 failures and 2 collection errors fail identically on clean main: dlt credential-field drift, the DuckDB sandbox broad-root tests, the live claude CLI test, and two CI-only modules. After the last refactor and merging main: the split test files, 395 passed.
  • tests/perf (serial), ruff, black 24.10.0, dead-code gate, product guardrail, import-linter 6/6, mypy --strict hotspots, detect-secrets, license headers: green (tests/perf 38 passed; the CHANGELOG gate 45 passed).
  • Every new test was checked red on the code before it. Each review round's mutants were run, and those left alive were pinned by a test.
  • Property test of rebased_ref: 190,909 random refs and containers, every rebased ref resolving to the same target. Fault injection at 46 points (mkdir, open, write, compose): always exit 1 and an exactly restored tree.
  • ripwire --quality-delta (branch diff applied on main): _write_checked stays under the complexity bar, with the encode step and the take-back report in _encoded and _taken_back. Left, with reasons: _rebased_refs (new) has complexity 19 and nesting 5, as one walk over every ref form a moved part can hold; run() length 67→73 (minor); dead-code rows on _put_back (called through functools.partial) and _InFull.ignore_aliases (a PyYAML dumper hook); _root_in_the_way catches every compose failure on purpose, since a file there that does not compose is another contract; test-only duplication (house style).
  • Live, through the CLI: on four contracts, branch against main. A two-pass split (--parts builds over a sql ref): rc 0 and the result bundles; main refuses with $ref target not found. Two builds merging one << default with --parts sql: rc 0 with a.sql and b.sql; main fails with write() argument must be str, not dict and leaves sql/b.sql. --out holding another contract: refused, rc 1, file untouched; main overwrote it with rc 0. A 300-character id: rc 0 with a 105-character file name; main fails with [Errno 63] File name too long.

Reviews

  • /security-review:
    • Round 1: PASS (one LOW).
    • Round 2: PASS (LOWs).
    • The final wave fixed the last LOWs: the escaped rebased ref, the take-back order through an interrupt, and the bounded memory of the ./ scan.
  • /world-class-inspection: round 1 PASS (no P0, P1 or P2). Round 2 re-verification: GATE: PASS.
  • /defect-scan: round 1, 4 NEW findings (aliased containers in the data split, a JSON root with no UTF-8 form, two quadratic paths) and 5 pre-existing. Every NEW one is fixed and pinned. The pre-existing ones are below.

Follow-ups (pre-existing, same on main, re-checked on this branch)

  • P1. A moved part that ends in a block scalar whose last line has trailing blanks is refused ("does not compose to the original one"), with the default --parts. The span is cut at .rstrip() (_split_text.py).
  • P2. The post-split check composes the no-env document only. An overlay whose $ref points into a moved part splits with exit 0, then fluid bundle --env dev fails.
  • P3. A newline in an item id, exposeId or extension name puts a non-comment line in the fragment header. It is refused, cleanly.
  • P4. "Unexpected error" (exit 2) for JSON with tabs in a file whose suffix is not .json/.yaml/.yml, and for an untraversable fragments/.
  • P5. --out <a regular file> --dry-run exits 0; the real run refuses.

Documentation

  • Docs PR linked
  • No docs needed
  • Docs TODO — forge_docs docs/cli/split.md: refs in moved parts are rebased, a refused split changes nothing, --out never writes over another file (the copy exception), and the slug cap. Comes with the forge_docs pages PR of this wave.

fas89 added 30 commits October 29, 2026 08:34
In a CRLF root, the span of a moved block that ended in a trailing comment
ran to the line's LF, so it took the CR with it: the $ref line that replaced
the block ended in a bare LF among CRLF lines, in the root and in a fragment
a nested part was cut from (finding SP-01).

The span now stops before the CR, which stays to end the ref's line. The
CRLF fixtures and the generated roots keep the comments they left out to
avoid the bug.
A block scalar's span ended at its last character of text, which is right
for |, |-, > and >-, whose blank lines after the text are not their value.
A keep-chomped one (|+, >+) keeps them: when it ended a moved part, the
fragment lost them, the split contract no longer composed to the original
one, and split refused a valid contract (finding SP-04).

The span of a keep-chomped scalar now runs over its blank lines, up to the
line break that ends the last of them, which stays in the root to end the
ref's line. The value is the same in the fragment, with LF or CRLF.
A node's start mark is at its tag or anchor when it has one. Written on the
key's line ('sovereignty: !!map' or '&s', the mapping below), that start put
the splice on the key's line, which got 'key: $ref: ...', a line that does
not parse; split refused the contract (finding SP-06).

The ref now takes the key's line as a flow mapping, and the tag or anchor
starts the fragment, so the text is still spliced and every comment kept.
The fragment's lines are dedented by the indentation of the part's first
entry, not by the anchor's column, so a block scalar under it keeps its
indentation.
'sql: |  # the main query' put the comment inside the span a moved text
part replaces with a ref, and a text file cannot hold a YAML comment: the
comment was in no file after the split, and nothing said so (finding SP-10).

The comment on a block scalar's header line now stays in the file that
holds the ref, on the ref's line: the root, or the fragment the part was
cut from. The generated roots of the comment check put trailing comments on
header lines too.
…there

A relative $ref resolves against the directory of the file that holds it.
A part that moved to fragments/builds/t.yaml took its refs along unchanged,
so './sql/t.sql' now named fragments/builds/sql/t.sql: the split did not
compose, and split refused any contract with a ref in a build or an expose,
including one split once with --parts sql, while --dry-run listed the files
and exited 0 (finding SP-02).

Split now rewrites each relative ref in a part that moves to name the same
file from the part's new one ('../../sql/t.sql', '../../cols.yaml#/a'), in
the text splice (in the ref's own quoting, every comment kept) and in the
data split. The ref's own path is kept as written: only the climb out of
the fragment's directory goes in front, so a '..' in it is never folded
past a symlinked directory. A URL, an absolute path and a same-document
'#/...' pointer are left alone, by the predicate the ref resolver uses
(ref_confinement.is_relative_file_ref). A second pass ('--parts sql', then
'--parts builds') ends where one split of both parts does.

What rebasing cannot fix is still refused and taken back: a file ref whose
pointer runs through a part that moves out of the root
('./contract.fluid.yaml#/builds/0/id'). The compose-and-compare check stays.
A refused split said 'nothing was changed', and the module promises as
much, but it left behind every directory it had made: an empty fragments/
and fragments/builds/, or the whole --out directory. A leftover fragments/
changes what 'fluid bundle <contract>' does without --out (it writes
contract.bundled.fluid.yaml instead of printing). A write that failed on a
lone surrogate also left the empty file it had just created, which the next
run numbered around (finding SP-05).

Split now makes each directory itself and records the ones it creates,
records a fragment the moment its file exists, and takes all of it back on
a refusal, directories last and deepest first. Every text is encoded before
anything is made, so one with no UTF-8 form is refused with nothing to take
back, and the message names its file. 'nothing was changed' now ends the
message only after the take-back.
Every failed write ended in exit 1, 'Split not written', except the last
one: a read-only root failed to open, and the undo then wrote the old text
back to that same root, which raised the same PermissionError inside the
handler: exit 2, 'Unexpected error', and the directories made stayed
(finding SP-09).

The undo now puts the root back only when it was opened for writing, so a
root that could not be opened is left as it was, and every step of it runs
whatever an earlier one did and none raises. A read-only root is now a
clean exit 1 with the tree as it was; if a step of the undo does fail, the
message names what is left instead of saying nothing was changed.
The slug of an id was never shortened: an id of 300 characters named a
305-byte file, over the 255 bytes a file name may have. The dry run listed
it and exited 0; the real run failed with ENAMETOOLONG (finding SP-07).

A slug over 100 characters is now cut to 91 and ends in '-' and 8 hex
digits of the SHA-256 of the whole id, so every name stays far under 255
bytes with a '-N' and the longest suffix, and two long ids that start alike
still get a name each. Builds, exposes, their sql, schema and quality, and
extension blocks are all named this way, and the dry run lists exactly the
names the real run writes. The cap is pants' safe_filename default; the
hash is kubespawner's strip_and_hash.
…does

Finding SP-08: with <out>/fragments a symlink to a directory outside <out>,
split wrote the fragments through the link before a later check refused
the split, and left the directories it made out there. The real run has
refused such a link before writing anything since the fragments PR; the
dry run never checked, so it listed fragments under the link and exited 0
for a split the real run refuses.

The checks (a symlink at the root's name, a symlinked directory on the way
to any fragment, however deep) now run before the dry run lists anything,
with the same message and exit 1, and again before the first write. The
test pins the whole tree, the outside directory included, after both runs.
Fragments never overwrote a file, but the root did: 'fluid split c --out
DIR' replaced a different DIR/contract.fluid.yaml without a word and exited
0, and the dry run did not mention it. A second split into the same --out
met its own first-run files, wrote '-2' copies of every fragment and left
the first run's fragments unreferenced (finding SP-11).

The root may now be written only over the contract itself (an in-place
split) or a byte-for-byte copy of it, where nothing is lost. Any other file
there is refused before anything is written, the dry run included, with
the file named and what to do: move it away or choose another --out. When
the file there composes to the same document (the second-run case) the
message says so and gives the command that splits that contract further.
A split already there is not reused: the source may have changed since. A
hard link to the contract in another directory is refused too, as writing
it would change the contract in its own directory.
… to data

The SP-02 rebase writes the new ref over the old one's scalar in the text
splice, in the quoting it was written in; those branches (plain, single,
double, tagged, a block mapping) and the fallback for a ref written as a
block scalar, which the text splice refuses and the data split rebases,
had no test of their own. Each one now checks the fragment's line, that
every comment is kept, and the invariant.
…place

The data split (a root with YAML aliases or merge keys, or a JSON root)
replaced each part by its ref in the container that held it. An anchor or
a merge key ('<<: *defaults') puts one container at several places, so the
part was replaced at all of them, and the next part there moved that ref
instead of itself (finding N1). With the sql of two builds merged from one
defaults block, the second sql file got a dict: 'Unexpected error', exit 2
(main refused cleanly). With one aliased contract mapping at two exposes,
the second schema fragment got the splitter's own ref, which the SP-02
rebase then sent out of the ref root: exit 1, where main split it.

Each container on the way to a part is now replaced, in the one above it,
by a shallow copy of itself, so the part is replaced where it moves from
and nowhere else; what the part shares with the places that keep it stays
as it is. Six contracts that share containers through anchors, aliases and
merge keys pin it, both repros among them, each with the invariant. And a
part that is not text when it is written, whatever put it there, is now a
clean refusal with nothing made, never an unexpected error.
… is made

JSON can escape a lone surrogate ('"\ud800"'), and a key with one that
stays in a JSON root comes back in the root's text, which then has no
UTF-8 form. The fragments were encoded before anything was made, but the
root was encoded outside that guard: 'Unexpected error', exit 2 (finding
N2).

The root is now encoded in the same step, so it is refused as a part with
no UTF-8 form is: 'Split not written: <root> cannot be written as UTF-8',
exit 1, with nothing made.
…near time

The comment on a block scalar's header line was found with '[ \t]+#.*$',
searched from every position: on a run of blanks with no '#' after it,
each blank started a match that took the rest of the run and then gave it
back one blank at a time. 80,000 blanks after 'sql: |' took 17 s, against
2.3 s for the whole split on main.

A match now starts only where a run of blanks starts (a lookbehind), which
finds the same comment, the blanks before it included, in time linear in
the line. A test times the search over 100,000 blanks, with and without a
comment after them.
rebased_ref stripped the './' steps a relative ref starts with one at a
time, each step copying the rest of the ref: a ref of 200,000 './' steps
took seconds to rebase.

The steps ('./', './/', '././') are now matched once, by one anchored
pattern with one way to match, and the rest is split from there. A dot
that starts a name ('./.x/y.yaml') and a '../' after them stay as they
were. A test rebases four 200,000-step refs under a second each.
…fused

The SP-11 guard lets the root be written over a byte-for-byte copy of the
contract in --out, as nothing is lost. But the root is written in place,
and a copy that is also another name of a third file (a hard link) took
that file with it: the third file was rewritten as the split root.

The copy branch now also needs the file to have one name (st_nlink == 1);
a hard link with the same bytes is refused like any other file there,
before anything is written. A test hard-links a third file into --out and
checks that file, and the whole tree, are as they were.
The take-back ran for an Exception only. A KeyboardInterrupt (Ctrl-C) or a
SystemExit while the fragments and the root were written, or while the
result was checked, went past it and left the split unchecked: fragments
written, the root rewritten (C1).

Any BaseException now takes the split back first; one that is not an
Exception is then raised again, so the CLI reports the interrupt as it
reports any ('Operation cancelled by user', exit 1), and what could not be
taken back, if anything, is printed before it goes on. A test interrupts
the check of a written split with each of the two and finds the tree as it
was.
_write_checked named the text of each fragment 'content' when it encoded
it, then the same name for the bytes it wrote, so mypy took the second
for a str: 'Incompatible types in assignment' and an 'incompatible type
str' for the write (C2). The bytes are now 'encoded'.
Four properties had no test that fails when they break:

- SP-05, the file being written: a fragment's file is made, then its write
  fails (a full disk, the second 'xb' open). It is taken back with the
  rest; recording it only after its write would leave it behind.
- SP-02 in the data split: one ref object an alias puts in the root's
  metadata and in a build is rebased in the build's fragment only; a
  rebase in place would change the root's too.
- SP-02 in the text splice: a ref in the columns, cut out of an expose that
  moves as well, is rewritten once, in the columns' own file; walking the
  columns again from the expose would write over the cut.
- The generated roots now hold refs too (parameters with and without a
  sibling, a column, a whole schema), with their files beside the root,
  in the root's line ends. 300 seeds past the 24 in the suite pass.
…s letters

What the docstrings and the help said had drifted from what split does:

- The module docstring said no file is written over but the contract split
  in place; a byte-for-byte copy of it in --out, the only name of its file,
  is written over too (SP-11). It says so now.
- A rebased ref was said to keep 'its own quoting', and a test named so,
  but a plain './cols.yaml#/a' comes back double-quoted: its new path needs
  quotes. It keeps its quoting where the new path allows it, which is what
  the docstring and the test say now, with a case for each such path.
- The double quotes were written without allow_unicode, so a ref with a
  letter that is not ASCII ('./sql/my file é.sql') came back escaped
  ('\xE9'). The letter is written as it is now; NEL, LS, PS and what is
  not printable are escaped as before, so the value reads back exactly.
- The help did not say that with --out the files a relative $ref names
  must be under the output directory too (C5). One sentence says it.
- The test module said findings are kept as strict xfails; none is left.
The './' steps a relative ref starts with were matched with '(?:\./+)+',
linear in time but holding state for every step it took: a million steps
peaked at 124 MB, 2.5 million at 330 MB of RSS. Python 3.10 is supported,
so a possessive quantifier cannot drop that state.

The steps are now scanned: the run of '.' and '/' the path starts with,
cut before its first '..' (a climb, kept) or before a last lone '.' (a
name that starts with one). The scan keeps nothing per step. A test pins
it against the pattern it replaced on every path of up to eight '.', '/'
and 'x', and bounds the memory: the scan under 1 MB for a million steps,
the whole rebase under 32 MB (the rest is reading the path for the ref
root's own checks, a few bytes per character).
…rupt

Two gaps were left in taking back an interrupted split.

The flag that sent the take-back to the root was set after open(root,
'wb') returned, but open() empties the root before it returns: an
interrupt just then left the contract empty, and the take-back passed it
by. The flag is now set before the open, and the root is written back
whenever its bytes differ from what they were, so a read-only root that
could not be opened is still left alone.

The take-back removed the fragments before it put the root back, and
caught only an Exception, so a second Ctrl-C during it stopped it there:
a split root over fragments it had already removed. The root now goes
back first, every step runs whatever an earlier one raised, an interrupt
included, and the first interrupt is raised again at the end (also when
the split was being taken back after a plain refusal), with what could not
be taken back printed before it.

Tests interrupt the root's open just after it empties the file, and the
take-back's first fragment removal after an interrupt or a refusal at the
check; they find the root put back before any fragment was touched.
…n escaped

The double quotes of a rebased ref were written with allow_unicode, which
PyYAML takes to mean writing a bidi control (U+202E RLO, the isolates,
LRM, ALM), a zero-width space or joiner, a soft hyphen, a private-use or
an unassigned code point as it is: a fragment line that shows other text
than it holds, the Trojan Source pattern.

A value is now written with its letters that are not ASCII ('é') only when
it is all printable, the rule the single-quoted branch already had; one
that holds anything else is escaped whole. NEL, LS and PS are escaped
either way. Tests check each such character comes out escaped and reads
back exactly, and that a ref the contract escapes ('\u202E') stays escaped
in the fragment, no file the split writes holding the character itself.
The test source spells every such character as an escape.
The copies a data split makes on each part's way (N1) share what else they
hold with the original, so one object a YAML alias put at two places can
end up at many. The dumper named shared objects, and wrote an alias for
each: a builds list aliased in the metadata, 60 builds with a 'tags' list
each and '--parts sql', gave 60 aliases, past the 50 the loader reads when
the split is checked. The split was refused ('more than 50 alias
references'), where the split before N1 split it.

The data split's root and fragments are now dumped with aliases off, every
object in full wherever it is. Nothing grows past what the contract's own
alias cap let in, and a document that contains itself is refused before (it
does not compose). A test splits that contract and finds no anchor or alias
in any file it writes.
- A fragment counted as written before its exclusive create: when another
  process makes a file at that name just before split does, the create
  fails and the take-back would remove that other file. A test makes one
  there at that moment and finds it kept, the refused split taken back.
- The line an interrupt prints when its take-back leaves something: with
  the interrupt going on there is no message to carry it. A test fails a
  take-back step during an interrupt and finds the line, with the path and
  the reason, and the root put back all the same.
…a way out

Two things the words got wrong after SP-11:

- The help said 'a file that exists is never written over' without the one
  exception: a byte-for-byte copy of the contract in --out that is the only
  name of its file. It says so now.
- When the file in --out composes to this contract but is a hard link (to
  the contract in its own directory, or to a third file), the refusal gave
  'To split that one, run: fluid split <out>/contract.fluid.yaml', which
  splits it in place, through the link: the other name changes with it. The
  refusal now says it is a hard link and gives a way out that writes through
  nothing: move it away, or choose another --out. The command stays the way
  out for a file that is the only name of its own.

The two hard-link tests check the way out given, and a test reads the help.
@github-actions github-actions Bot added cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes labels Oct 29, 2026
Comment thread fluid_build/cli/split.py Fixed
Comment thread fluid_build/cli/split.py Fixed
fas89 added 2 commits October 29, 2026 16:01
…d of catching BaseException

Ctrl-C and an exit are still taken back and raised again; nothing else
that is not an Exception is caught (code-quality: except BaseException).
@fas89
fas89 merged commit 7c69502 into main Oct 29, 2026
37 checks passed
@fas89
fas89 deleted the fix/split-edge-cases branch October 29, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant