Skip to main content

Build system: Driver.Build, resolvePhp, packaging, CI

Author: research pass for MEP-55 (Mochi-to-PHP 8.4 transpiler). Date: 2026-05-29 15:00 (GMT+7). Sources: transpiler3/php/build/build.go, transpiler3/php/build/packaging.go, transpiler3/php/build/build_test.go, transpiler3/php/build/phase17_test.go, .github/workflows/transpiler3-php-test.yml.

1. Driver struct

Driver (build.go lines 43-57) is the pipeline entry point:

type Driver struct {
CacheDir string
NoCache bool
Deterministic bool
phpPath string
}
  • CacheDir: overrides ~/.cache/mochi/php/. Not yet wired to actual caching; the field exists for forward compatibility.
  • NoCache: reserved. Tests set NoCache: true for isolation.
  • Deterministic: reserved. Today it is a no-op; TestPhase16Non- DeterministicBuildsAlsoMatch verifies the default path is also byte-reproducible.
  • phpPath: cached result of resolvePhp(), set lazily on first TargetPhpRun build.

2. Target enum

const (
TargetPhpSource Target = iota
TargetPhpRun
)

TargetPhpSource writes main.php to outDir and returns its path. TargetPhpRun does the same, then invokes php main.php with stdout and stderr forwarded to the caller's streams.

3. Driver.Build pipeline

Build(src, outDir, target) (build.go lines 64-123):

  1. os.ReadFile(src) — reads the Mochi source bytes (stored in srcBytes for the future cacheKey integration).
  2. parser.Parse(src) — shared Mochi parser.
  3. types.Check(ast, types.NewEnv(nil)) — shared type checker.
  4. clower.Lower(ast)MEP-45 aotir lowerer, produces *aotir.Program.
  5. colour.Compute(prog) — PHP colour pass (all-Blue).
  6. lower.Lower(prog, colours) — PHP-specific lowerer, produces *ptree.PhpFile.
  7. os.MkdirAll(outDir, 0o755) — creates the output directory.
  8. emit.Emit(file, outDir, "main") — writes outDir/main.php.
  9. If TargetPhpRun: resolvePhp() then exec.Command(php, main.php).

The function also touches unused fields to keep the compiler honest:

_ = srcBytes
_ = d.cacheKey
_ = d.effectiveCacheDir
_ = copyFile
_ = sha256.New
_ = io.Copy

This pattern prevents dead-code removal of symbols that will be wired in future phases without requiring a phase-gated build tag.

4. resolvePhp

resolvePhp() (build.go lines 128-145) finds the PHP binary:

  1. Check PHP_PATH environment variable. If it points to a directory, append /php.
  2. Try well-known paths: /usr/bin/php, /usr/local/bin/php, /opt/homebrew/bin/php.
  3. Fall back to exec.LookPath("php").

Error message on failure: "php not found on PATH (set PHP_PATH or add php to PATH)". CI sets PHP_PATH via the shivammathur/setup-php@v2 action output.

5. effectiveCacheDir

effectiveCacheDir() (build.go lines 148-160) resolves the build cache directory:

  1. d.CacheDir if set.
  2. $MOCHI_CACHE_DIR/php if MOCHI_CACHE_DIR is set.
  3. ~/.cache/mochi/php/ as the default.
  4. os.TempDir() if home directory resolution fails.

Currently unused in the build path (every build runs the full pipeline from scratch). Reserved for a future cache integration.

6. cacheKey

cacheKey(srcBytes) (build.go lines 168-178) computes a SHA-256 hash of: source bytes + phpPath + Deterministic flag byte. Currently unused in Build (the _ = d.cacheKey line keeps it live). The design intent is to use this key to skip re-lowering when the source and PHP version have not changed.

7. runtimeSourceDir and copyFile

runtimeSourceDir() (build.go lines 183-195) uses runtime.Caller(0) to locate the transpiler3/php/runtime/ directory relative to the Go source file. This works regardless of the working directory, which is important for test isolation (each test uses t.TempDir()).

copyFile(dst, src) (build.go lines 199-215) copies a file, creating parent directories as needed. Phase 15 uses this when staging the Composer package: it copies composer.json, src/, and related files from runtimeSourceDir() into the sandbox.

8. repoRootForBuild

repoRootForBuild (build.go lines 220-240) walks up from the Go source file to find the go.mod root. Test helpers use repoRoot(t) (defined in build_test.go lines 52-56) to find fixture directories independently of the working directory. This pattern appears in all phase*_test.go files.

9. Phase 17: Packaging

transpiler3/php/build/packaging.go implements three deployment targets.

9.1 Phar archive: emitPharStager

emitPharStager(outDir, mainPhp, dstPhar) (packaging.go lines 53-74) generates a stager PHP script (build_phar.php) that wraps mainPhp into a .phar using PHP's built-in Phar class:

$phar = new Phar($dst, 0, basename($dst));
$phar->startBuffering();
$phar->addFile($src, 'main.php');
$phar->setStub($phar->createDefaultStub('main.php'));
$phar->stopBuffering();

The stager is run with php -d phar.readonly=0 (phase17_test.go line 74) to bypass the default phar.readonly = 1 INI setting. The resulting .phar runs with php out.phar without any special flags.

phpStringLit(s) (packaging.go lines 200-215) escapes the file paths as single-quoted PHP literals, avoiding double-quote interpolation issues.

9.2 FrankenPHP bundle: EmitFrankenPHPBundle

EmitFrankenPHPBundle(outDir, packageName) (packaging.go lines 141-168) writes two files:

Caddyfile (template caddyfileTmpl, lines 76-91):

{
frankenphp {
worker /app/main.php 4
}
}

:8080 {
root * /app
php_server
}
  • worker /app/main.php 4: starts 4 worker processes.
  • php_server: modern FrankenPHP directive (not php_fastcgi).

Dockerfile (template dockerfileTmpl, lines 93-105):

FROM dunglas/frankenphp:php8.4
WORKDIR /app
COPY main.php /app/main.php
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 8080

Pinned to dunglas/frankenphp:php8.4.

9.3 RoadRunner bundle: EmitRoadRunnerBundle

EmitRoadRunnerBundle(outDir, packageName) (packaging.go lines 171-195) writes two files:

.rr.yaml (template rrYamlTmpl, lines 107-123):

version: "3"
server:
command: "php worker.php"
http:
address: ":8080"
pool:
num_workers: 4
max_jobs: 64
allocate_timeout: 60s
destroy_timeout: 60s

worker.php (template rrWorkerTmpl, lines 125-138):

<?php
declare(strict_types=1);
require_once __DIR__ . '/main.php';
// Real apps wire PSR-7 here...

9.4 TestPhase17AllTargetsTogether

TestPhase17AllTargetsTogether (phase17_test.go lines 206-246) runs all three targets for every Phase 17 fixture in one test, asserting that all five artifacts (build_phar.php, Caddyfile, Dockerfile, .rr.yaml, worker.php) are produced. This cross-cut gate ensures a regression in any one packaging path fails the whole suite.

10. CI workflow

.github/workflows/transpiler3-php-test.yml has two jobs:

10.1 go-side

  • Runs on ubuntu-latest.
  • Installs PHP 8.4 via shivammathur/setup-php@v2 with extensions: mbstring, gmp and tools: composer:v2.
  • Runs go vet ./transpiler3/php/..., go build, go test.
  • This job covers the full Go test suite including all fragment tests, the DJB2 hash tests, the reproducibility tests, and the packaging structure tests.

10.2 php-runtime

  • Runs on ubuntu-latest with a PHP version matrix.
  • Runs composer install --no-interaction --prefer-dist in transpiler3/php/runtime/.
  • Runs PHPStan, Psalm, php-cs-fixer (dry-run), and PHPUnit.
  • PHP 8.4.0 and 8.4 latest: allow_failure: false.
  • PHP 8.5: allow_failure: true.

10.3 Timeout

Both jobs have timeout-minutes: 15. The go-side job is fast (no PHP compilation of fixture programs needed for fragment tests). The php-runtime job spends most of its time in Composer install and Psalm analysis.