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. */