From 247e58cb3b0644f613eb2333aea08898a0fd1498 Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 2 Sep 2026 00:57:59 -0600 Subject: [PATCH] feat(implement): stage piece-wise partials off the bootable file, publish atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured live (greenhouse fixture series, run 12): mode=start/append wrote each partial DIRECTLY to the live plugin source the app registers and loads at boot. An append left a class with an unclosed brace; that half-written file was loaded at boot, so the whole app — the agent that had to repair it included — died inside a process that would no longer boot. A partial must never be a bootable file. The staging discipline: - STAGING_SUFFIX (`.milpa-part`): the partial lives at `.php.milpa-part`, a sibling whose path does NOT end in `.php`, so every autoloader/glob keyed on the `*.php` extension is blind to it and can never load it as a source. - mode=start writes the first section to STAGING (truncating stale staging), NOT the live file; mode=append appends verbatim to STAGING. The live scaffold stays byte-identical to what `make` left through the whole authoring — a mid-authoring boot loads valid PHP. - mode=append/finish now require the STAGING file to exist (the scaffold existing is no longer sufficient); its absence teaches mode=start first. - mode=finish reads the assembly from STAGING and judges it through the SAME landing gate a single-shot passes. On GREEN it publishes atomically and deletes staging; on RED the live file stays the untouched scaffold and staging is KEPT, so the caller can append a fix and finish again. The bootable file never carries a red assembly. The landing gate is one code path for both doors and now lands through publishAtomically (temp file + rename): rename(2) is atomic on POSIX, so a crash never leaves the live file half-written — single-shot included. The temp lives in the target's own directory (a cross-device rename would fail) and inherits the scaffold's file mode, so the durability is invisible past the bytes. The 0.22 equivalence still holds: start+append+finish over a split of C lands the live file byte-identical to single-shot with C, same verify verdict. Falsifiers (tests/Operations/ImplementHandlerTest.php): D-11 core (live scaffold byte-identical through start+append; the deliberately-unclosed partial only at staging), finish publishes atomically on green and deletes staging, finish discards on red keeping staging, append/finish refused without staging even when the scaffold exists, single-shot leaves no staging sibling, publish preserves the file mode. testAppendIsVerbatimByteConcatenation strengthened to assert the bytes on staging AND the live scaffold untouched (on 0.22 the same append wrote straight into the live file). --- src/Operations/ImplementHandler.php | 131 ++++++++++++++--- tests/Operations/ImplementHandlerTest.php | 163 +++++++++++++++++++++- 2 files changed, 271 insertions(+), 23 deletions(-) diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index e72fb84..1920ffc 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -85,6 +85,23 @@ final class ImplementHandler */ public const MAX_INLINE_CHARS = 4000; + /** + * The suffix of the staging sibling a parts author writes to, never the live scaffold. + * + * ── WHY A PARTIAL MUST NOT BE A BOOTABLE FILE ──────────────────────────────────────────────── + * + * Measured live (greenhouse fixture series, run 12): mode=append wrote each section DIRECTLY to + * the live plugin source the app registers and loads at boot. One append left an unclosed brace, + * that half-written file was loaded at boot, and the WHOLE app died — the agent that had to + * repair it included — inside a process that would no longer boot. So a partial lives BESIDE the + * scaffold, at `.php`, until finish validates and publishes it. The + * suffix is appended AFTER `.php` (the path ends `.milpa-part`, not `.php`), so every autoloader + * or glob that keys on the `*.php` extension is blind to it — the staging file can never be + * mistaken for a source the app loads. The live scaffold stays valid and untouched through the + * whole start→append authoring; only finish, having judged the assembly green, publishes it. + */ + public const STAGING_SUFFIX = '.milpa-part'; + /** The modes of the parts door; absent means single-shot, today's behavior byte for byte. */ private const MODES = ['start', 'append', 'finish']; @@ -196,43 +213,82 @@ public function handle(array $input): array ]; } - // ── The partial writes: a partial file is not valid PHP, so NOTHING verifies here ──────── + // The staging sibling: a partial writes HERE, never to the live scaffold the app boots. + $staging = $file . self::STAGING_SUFFIX; + + // append and finish assemble what an EARLIER start opened — the scaffold existing is no + // longer enough, the staging file must exist. Its absence teaches mode=start first. + if (($mode === 'append' || $mode === 'finish') && !is_file($staging)) { + return [ + 'ok' => false, + 'error' => "nothing to {$mode}: no staged work for class «{$class}» in plugin «{$plugin}» — " + . 'open the file with mode=start, which writes the first section beside the scaffold', + ]; + } + + // ── The partial writes: they land at STAGING, unjudged, and the live scaffold stays byte ── + // ── for byte what `make` left — a mid-authoring boot loads valid PHP, never a partial. ──── // // The results carry no `verified` key and no postcondition a caller could quote as green — // the work-protocol doctrine: a claimable green from a partial would be a lie wearing keys. if ($mode === 'start') { - if (file_put_contents($file, $content) === false) { - return ['ok' => false, 'error' => "could not write {$file}"]; + // Truncate any stale staging: mode=start always opens a fresh assembly. + if (file_put_contents($staging, $content) === false) { + return ['ok' => false, 'error' => "could not open staging beside {$file}"]; } return [ 'ok' => true, 'file' => substr($file, \strlen($root) + 1), - 'partial' => 'started — nothing verified, nothing judged: a partial file is not valid PHP. ' - . 'Send each next section with mode=append (each under ' . self::MAX_INLINE_CHARS - . ' chars), then mode=finish to verify and judge.', + 'partial' => 'started (staged, nothing live) — nothing verified, nothing judged: a partial file ' + . 'is not valid PHP, and the live scaffold stays untouched. Send each next section with ' + . 'mode=append (each under ' . self::MAX_INLINE_CHARS + . ' chars), then mode=finish to verify, judge, and publish.', ]; } if ($mode === 'append') { - if (file_put_contents($file, $content, \FILE_APPEND) === false) { - return ['ok' => false, 'error' => "could not append to {$file}"]; + if (file_put_contents($staging, $content, \FILE_APPEND) === false) { + return ['ok' => false, 'error' => "could not append to staging beside {$file}"]; } return [ 'ok' => true, 'file' => substr($file, \strlen($root) + 1), - 'partial' => 'appended verbatim — nothing verified, nothing judged. More sections go through ' - . 'mode=append; mode=finish verifies and judges the assembled file.', + 'partial' => 'appended verbatim to staging — nothing verified, nothing judged, live scaffold ' + . 'untouched. More sections go through mode=append; mode=finish verifies, judges, and ' + . 'publishes the assembled file.', ]; } - // finish: the assembled file IS the content, and from here the pipeline is single-shot's — - // same checks, same judge, same result shape. Two doors, one landing gate. + // finish: the assembly is READ FROM STAGING (never the still-scaffold live file), judged + // through the SAME landing gate a single-shot passes. Only a GREEN assembly is published — + // atomically over the live file — and its staging deleted. On RED the live file is still the + // untouched scaffold and the staging is KEPT, so the caller can append a fix and finish again; + // the red assembly never reaches a bootable file. Two doors, one landing gate. if ($mode === 'finish') { - $content = (string) file_get_contents($file); + $result = $this->land($root, $file, $plugin, $class, (string) file_get_contents($staging)); + if (($result['ok'] ?? false) === true) { + @unlink($staging); + } + + return $result; } - // ── The landing gate: everything verifies on a copy, or nothing lands ──────────────────── + // Single-shot: the content lands directly (no staging) through the same gate, published atomically. + return $this->land($root, $file, $plugin, $class, $content); + } + + /** + * The one landing gate both doors pass — single-shot's assembled content and finish's staged + * assembly alike. Everything verifies (syntax, strict_types, class, namespace, static + * conformance, the class's own test), or nothing lands; a green assembly is PUBLISHED ATOMICALLY + * (temp file + rename — a crash never leaves the live file half-written) and a red one leaves the + * live file byte for byte the scaffold it was. + * + * @return array + */ + private function land(string $root, string $file, string $plugin, string $class, string $content): array + { if (!str_contains($content, 'declare(strict_types=1)')) { return ['ok' => false, 'error' => 'refused: every PHP file in this house declares strict_types=1']; } @@ -282,12 +338,15 @@ public function handle(array $input): array // ── Static conformance, when the app ships an analyzer ─────────────────────────────────── // - // The candidate is analyzed IN PLACE: linkage — does the interface exist, do the - // signatures match — is only visible with the app's autoloader, which a staged temp file - // does not have. The original is held in memory and restored byte for byte on any finding, - // so the transactional guarantee moves from «never touched» to «atomically restored». + // The candidate is placed IN PLACE — analysis and the behavioral test see linkage (does the + // interface exist, do the signatures match, does the class DO what its test demands) only + // through the app's autoloader, which a staged temp file does not have. The placement and + // every restore go through publishAtomically (temp file + rename): the assembly is always + // COMPLETE and either the good scaffold or the finished candidate is on disk — never a + // half-written file, on any crash. The original is held in memory and, on any finding, + // restored byte for byte, so the guarantee is «atomically restored to the scaffold». $previous = (string) file_get_contents($file); - if (file_put_contents($file, $content) === false) { + if (!$this->publishAtomically($file, $content)) { return ['ok' => false, 'error' => "verified clean but could not write {$file}"]; } @@ -295,7 +354,7 @@ public function handle(array $input): array if ($analyzer !== null) { exec($analyzer . ' ' . escapeshellarg($file) . ' 2>&1', $findings, $verdict); if ($verdict !== 0) { - file_put_contents($file, $previous); + $this->publishAtomically($file, $previous); // Only the findings travel — `path:line:message`, the raw format's shape. The // analyzer's banners and tips would bury the one line the model corrects from. $lines = array_values(array_filter( @@ -339,7 +398,7 @@ public function handle(array $input): array } else { exec($runner . ' ' . escapeshellarg($testFile) . ' 2>&1', $verdictLines, $verdictCode); if ($verdictCode !== 0) { - file_put_contents($file, $previous); + $this->publishAtomically($file, $previous); $tail = implode("\n", \array_slice(array_values(array_filter( $verdictLines, static fn (string $l): bool => trim($l) !== '', @@ -365,6 +424,36 @@ public function handle(array $input): array ]; } + /** + * Land `$content` on `$file` ATOMICALLY: a sibling temp file carries the bytes, then a single + * rename swaps it in. rename(2) is atomic on POSIX within one filesystem — which is why the temp + * lives in the target's OWN directory, never sys_get_temp_dir (a cross-device rename would fail). + * A crash mid-write corrupts only the temp; the live file is always either its whole prior + * content or the whole new content, never a truncate-then-write half. This is the ONLY writer of + * the live source in the landing gate — both the publish and every restore go through it. + * + * The temp inherits the target's file mode before the swap, so a rename (which carries the temp's + * own inode and perms) leaves the published file with the scaffold's mode — the durability is + * invisible past the bytes, never a source silently narrowed from 0644 to tempnam's 0600. + */ + private function publishAtomically(string $file, string $content): bool + { + $temp = @tempnam(\dirname($file), '.milpa-land-'); + if ($temp === false) { + return false; + } + $mode = @fileperms($file); + if (@file_put_contents($temp, $content) === false + || ($mode !== false && !@chmod($temp, $mode & 0o777)) + || !@rename($temp, $file)) { + @unlink($temp); + + return false; + } + + return true; + } + /** The class's behavioral test under `tests/Plugins//`, or `null` when none declares it. */ private function testFor(string $root, string $plugin, string $class): ?string { diff --git a/tests/Operations/ImplementHandlerTest.php b/tests/Operations/ImplementHandlerTest.php index 0df824f..fa28fc8 100644 --- a/tests/Operations/ImplementHandlerTest.php +++ b/tests/Operations/ImplementHandlerTest.php @@ -404,6 +404,12 @@ private function archivo(): string return $this->raiz . '/src/Plugins/Demo/Services/GreeterService.php'; } + /** The staging sibling a parts author writes to — never the live scaffold the app boots. */ + private function staging(): string + { + return $this->archivo() . ImplementHandler::STAGING_SUFFIX; + } + /** * THE measured killer, refused at the gate: content over the cap never reaches a write — the * scaffold survives byte for byte — and the refusal names the constant and all three modes, @@ -524,14 +530,167 @@ public function testStartAndAppendCarryNoVerificationClaims(): void self::assertStringContainsString('finish', $r['partial']); } - /** The sections land VERBATIM — the caller owns the bytes; an injected newline would corrupt them. */ + /** + * The sections land VERBATIM — the caller owns the bytes; an injected newline would corrupt them. + * + * STRENGTHENED for the staging discipline: the concatenation is asserted on the STAGING sibling + * (that is where partials live now), and the live scaffold is asserted BYTE-IDENTICAL to what + * `make` left — on origin/main this same append wrote 'ABCD' straight into the live file. + */ public function testAppendIsVerbatimByteConcatenation(): void { + $cascaron = (string) file_get_contents($this->archivo()); $h = $this->handler(); $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'start', 'content' => 'AB']); $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'append', 'content' => 'CD']); - self::assertSame('ABCD', (string) file_get_contents($this->archivo())); + self::assertSame('ABCD', (string) file_get_contents($this->staging())); + self::assertSame($cascaron, (string) file_get_contents($this->archivo()), 'a partial reached the live scaffold'); + } + + // ── The staging discipline (devtools 0.23): a partial NEVER touches the bootable file ───────── + // + // Measured live (greenhouse fixture series, run 12): mode=append wrote each section straight to + // the live plugin source the app loads at boot; one append left an unclosed brace, that + // half-written file was loaded at boot, and the whole app — the agent included — died inside a + // process that would not boot. The discipline: partials live at a `.milpa-part` sibling, invisible + // to every `*.php` autoloader/glob; the live scaffold stays byte-identical through authoring; only + // finish, having judged the assembly green, publishes it atomically (temp file + rename). + + /** + * D-11 CORE: through start and every append — before finish — the live scaffold is BYTE-IDENTICAL + * to what `make` left, so a mid-authoring boot loads valid PHP; the partial lives only at staging. + * (Mutate the start/append arms to write the live file directly and this goes red.) + */ + public function testDuringStartAndAppendTheLiveScaffoldStaysByteIdentical(): void + { + $cascaron = (string) file_get_contents($this->archivo()); + $h = $this->handler(); + + $start = $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'start', + 'content' => "archivo()), 'mode=start touched the live scaffold'); + + $append = $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'append', + 'content' => " public string \$name = 'x';\n"]); + self::assertTrue($append['ok'], $append['error'] ?? ''); + self::assertSame($cascaron, (string) file_get_contents($this->archivo()), 'mode=append touched the live scaffold'); + + // The partial — deliberately unclosed (no class-closing brace), exactly the run-12 killer — + // lives at staging, and the staging path does NOT end in .php, so no autoloader keyed on + // *.php can ever load it, and `php -l` confirms it would not parse if one did. + self::assertFileExists($this->staging()); + self::assertStringEndsWith('.php' . ImplementHandler::STAGING_SUFFIX, $this->staging()); + self::assertStringNotContainsString('}', (string) file_get_contents($this->staging())); + exec('php -l ' . escapeshellarg($this->staging()) . ' 2>&1', $out, $code); + self::assertNotSame(0, $code, 'the deliberately-unclosed partial parsed — the fixture is wrong'); + } + + /** + * FINISH PUBLISHES ATOMICALLY ON GREEN: a valid assembly becomes the live file byte-identical to + * single-shot with the same content, the staging sibling is deleted, and the result says + * verified. The live file was the untouched scaffold right up until finish published it. + */ + public function testFinishPublishesTheGreenAssemblyAtomicallyAndDeletesStaging(): void + { + $cascaron = (string) file_get_contents($this->archivo()); + $c = $this->contenidoValido(); + $mitad = intdiv(\strlen($c), 2); + $h = $this->handler(); + + $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'start', 'content' => substr($c, 0, $mitad)]); + $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'append', 'content' => substr($c, $mitad)]); + // Still scaffold, right up to the moment before finish. + self::assertSame($cascaron, (string) file_get_contents($this->archivo()), 'the live file moved before finish'); + + $fin = $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'finish']); + self::assertTrue($fin['ok'], $fin['error'] ?? ''); + self::assertStringContainsString('syntax', $fin['verified']); + + // The live file now holds the assembly; the staging sibling is spent and gone. + self::assertStringContainsString("return 'hola ' . \$name;", (string) file_get_contents($this->archivo())); + self::assertFileDoesNotExist($this->staging()); + + // Byte-identical to landing the same content single-shot. + file_put_contents($this->archivo(), $cascaron); + $solo = $this->handler()->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'content' => $c]); + self::assertTrue($solo['ok'], $solo['error'] ?? ''); + self::assertSame((string) file_get_contents($this->archivo()), file_get_contents($this->raiz . '/' . $fin['file'])); + } + + /** + * FINISH DISCARDS ON RED: an assembly the class's own test judges red leaves the live file the + * UNTOUCHED scaffold and the staging file KEPT — the caller can append a fix and finish again. + * The bootable file never carries the red assembly. + */ + public function testFinishOnRedKeepsStagingAndLeavesTheLiveFileScaffold(): void + { + $this->conJuezConductual(); + $cascaron = (string) file_get_contents($this->archivo()); + $h = $this->handlerConJuez(); + $falso = str_replace("'hola ' . \$name", "'bye ' . \$name", $this->contenidoValido()); + $mitad = intdiv(\strlen($falso), 2); + + $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'start', 'content' => substr($falso, 0, $mitad)]); + $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'append', 'content' => substr($falso, $mitad)]); + $fin = $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'finish']); + + self::assertFalse($fin['ok']); + self::assertStringContainsString('behavior', $fin['error']); + self::assertSame($cascaron, (string) file_get_contents($this->archivo()), 'the red assembly reached the bootable file'); + self::assertFileExists($this->staging(), 'the caller work was destroyed on red'); + self::assertStringContainsString("'bye ' . \$name", (string) file_get_contents($this->staging())); + + // And the caller can fix and finish again over the SAME staging. + $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'start', 'content' => $this->contenidoValido()]); + $ok = $h->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => 'finish']); + self::assertTrue($ok['ok'], $ok['error'] ?? ''); + self::assertFileDoesNotExist($this->staging()); + } + + /** + * append and finish need STAGING, not merely a scaffold: even with the scaffold present, without a + * prior mode=start there is nothing staged, and the refusal teaches mode=start. (On 0.22 the + * scaffold existing was sufficient; the invariant is stronger now — staging must exist.) + */ + public function testAppendAndFinishWithoutStagingAreRefusedEvenWhenTheScaffoldExists(): void + { + foreach (['append', 'finish'] as $mode) { + self::assertFileDoesNotExist($this->staging(), "a prior arm left staging before mode={$mode}"); + $input = ['plugin' => 'Demo', 'class' => 'GreeterService', 'mode' => $mode]; + if ($mode === 'append') { + $input['content'] = '// x'; + } + $r = $this->handler()->handle($input); + + self::assertFalse($r['ok'], "mode={$mode} accepted with a scaffold but no staging"); + self::assertStringContainsString('mode=start', $r['error']); + } + } + + /** GOLDEN: a single-shot lands directly — no staging sibling is ever created, live bytes as before. */ + public function testASingleShotLeavesNoStagingSibling(): void + { + $r = $this->implement($this->contenidoValido()); + + self::assertTrue($r['ok'], $r['error'] ?? ''); + self::assertFileDoesNotExist($this->staging()); + self::assertStringContainsString("return 'hola ' . \$name;", (string) file_get_contents($this->archivo())); + } + + /** + * The atomic publish is invisible past the bytes: the temp+rename keeps the scaffold's file mode, + * never narrowing the published source to tempnam's owner-only 0600. (Durability is a detail; a + * source silently made unreadable to the group would be an observable regression.) + */ + public function testTheAtomicPublishPreservesTheFileMode(): void + { + chmod($this->archivo(), 0o644); + $r = $this->implement($this->contenidoValido()); + + self::assertTrue($r['ok'], $r['error'] ?? ''); + self::assertSame(0o644, fileperms($this->archivo()) & 0o777); } /** A part without a scaffold has nowhere to land — and the refusal teaches the order. */