diff --git a/bin/baton.bat b/bin/baton.bat new file mode 100644 index 0000000..29ae4ed --- /dev/null +++ b/bin/baton.bat @@ -0,0 +1,5 @@ +@echo off +setlocal DISABLEDELAYEDEXPANSION + +php "%~dp0baton" %* +exit /b %ERRORLEVEL% diff --git a/docs/project-inventory.md b/docs/project-inventory.md index 42cc512..509e8bc 100644 --- a/docs/project-inventory.md +++ b/docs/project-inventory.md @@ -15,6 +15,11 @@ fingerprints. It may contain canonical local roots needed to read sources, but never source bodies, credentials, environment values, processor logs, or cache implementation keys. +Tooling inventories include every declared binary entry in addition to the +autoloaded and generated package surface. Build and run inventories remain +target-specific; this broader source set exists so editor analysis can resolve +symbols across entry files and reusable declarations. + Packages without dependencies do not require `Baton.lock`. Their lock fingerprint is the SHA-256 digest of JSON `null`, so creating or removing a lock invalidates the project snapshot without making lockless packages invalid. diff --git a/packaging/php-runtime/build.php b/packaging/php-runtime/build.php index 7dcb9e4..f9759ba 100644 --- a/packaging/php-runtime/build.php +++ b/packaging/php-runtime/build.php @@ -266,7 +266,7 @@ function hostTarget(): string 'Windows' => 'windows', default => fail('Unsupported build host: ' . PHP_OS_FAMILY, EXIT_USAGE), }; - $machine = strtolower(php_uname('m')); + $machine = strtolower(hostMachine()); $architecture = match ($machine) { 'x86_64', 'amd64' => 'x86_64', 'aarch64', 'arm64' => 'aarch64', @@ -280,6 +280,20 @@ function hostTarget(): string return "{$platform}-{$architecture}"; } +function hostMachine(): string +{ + if (PHP_OS_FAMILY === 'Windows') { + foreach (['PROCESSOR_ARCHITEW6432', 'PROCESSOR_ARCHITECTURE'] as $name) { + $value = getenv($name); + if (is_string($value) && $value !== '') { + return $value; + } + } + } + + return php_uname('m'); +} + function absolutePath(string $path, string $base): string { if (preg_match('~^(?:[A-Za-z]:[\\\\/]|/)~', $path) === 1) { diff --git a/src/Commands/RunCommand.php b/src/Commands/RunCommand.php index 89faf87..0c1f3c4 100644 --- a/src/Commands/RunCommand.php +++ b/src/Commands/RunCommand.php @@ -151,6 +151,10 @@ private function forwardBuildFailure(OutputInterface $output): void /** @param list $arguments */ private function runArtifact(string $artifact, array $arguments, string $projectRoot): int { + if (PHP_OS_FAMILY === 'Windows') { + return $this->runWindowsArtifact($artifact, $arguments, $projectRoot); + } + $process = new Process( [$artifact, ...$arguments], $projectRoot, @@ -180,4 +184,46 @@ private function runArtifact(string $artifact, array $arguments, string $project ); } } + + /** + * Symfony's input pump cannot reliably make a Windows console stream + * nonblocking. Passing STDIN as Process input can therefore pause here + * until the user presses a key even when the program never reads stdin. + * Direct descriptor inheritance preserves interactive input without a + * parent-side read loop. + * + * @param list $arguments + */ + private function runWindowsArtifact(string $artifact, array $arguments, string $projectRoot): int + { + $descriptors = [ + 0 => ['file', 'php://stdin', 'r'], + 1 => ['file', 'php://stdout', 'w'], + 2 => ['file', 'php://stderr', 'w'], + ]; + $pipes = []; + $process = @proc_open( + [$artifact, ...$arguments], + $descriptors, + $pipes, + $projectRoot, + ); + if (!is_resource($process)) { + throw new BatonError( + 'B0404', + 'Built Program Could Not Be Started', + "Failed to run:\n {$artifact}", + ['Rebuild the artifact, then run it again:'], + ['baton build', 'baton run'], + ); + } + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + return proc_close($process); + } } diff --git a/src/Project/ProjectDocumentBuilder.php b/src/Project/ProjectDocumentBuilder.php index 8693700..4e0b138 100644 --- a/src/Project/ProjectDocumentBuilder.php +++ b/src/Project/ProjectDocumentBuilder.php @@ -39,7 +39,7 @@ public function build(ProjectSelection $selection, bool $development, NetworkPol [$rootPackage, $resolvedGraph] = $this->ensureRootPackage($rootMember, $resolvedGraph, $selected); $visible = $this->visiblePackages($rootPackage, $resolvedGraph, $development, $selection->aggregate); $registry = $this->generatedRegistry($selection, $visible, $resolvedGraph->packages); - [$rootPackage, $resolvedGraph, $visible] = $this->withGeneratedSources( + [$rootPackage, $resolvedGraph, $visible] = $this->withToolingSources( $rootPackage, $resolvedGraph, $visible, @@ -179,7 +179,7 @@ private function ensureRootPackage( $root->manifest, new ResolvedPackageSource('workspace', $root->root, $root->relativePath), (new ManifestFingerprint())->calculate($root->manifest), - (new SourceDiscovery($root->root))->discover($root->manifest, $selected), + (new SourceDiscovery($root->root))->discoverForTooling($root->manifest), ); $packages = $graph->packages; $packages[$root->manifest->package->name] = $package; @@ -234,7 +234,7 @@ private function generatedRegistry(ProjectSelection $selection, array $visible, * @param array> $generated * @return array{ResolvedPackage, ResolvedDependencyGraph|ResolvedWorkspaceGraph, array} */ - private function withGeneratedSources( + private function withToolingSources( ResolvedPackage $root, ResolvedDependencyGraph|ResolvedWorkspaceGraph $graph, array $visible, @@ -243,15 +243,9 @@ private function withGeneratedSources( bool $development, bool $aggregate, ): array { - if ($generated === []) { - return [$root, $graph, $visible]; - } $packages = $graph->packages; foreach ($visible as $name => $package) { $inputs = $generated[$name] ?? []; - if ($inputs === []) { - continue; - } $target = $name === $root->manifest->package->name ? $rootTarget : new SelectedPackageTarget( @@ -259,9 +253,8 @@ private function withGeneratedSources( ?? ($package->manifest->targets->binaries[0] ?? throw new \LogicException('A source-visible package must declare a target.')), ); - $inventory = (new SourceDiscovery($package->source->root))->discover( + $inventory = (new SourceDiscovery($package->source->root))->discoverForTooling( $package->manifest, - $target, $inputs, ); $replacement = $package->withInventory($target, $inventory, true); diff --git a/src/Source/SourceDiscovery.php b/src/Source/SourceDiscovery.php index 5a63c8d..34149bc 100644 --- a/src/Source/SourceDiscovery.php +++ b/src/Source/SourceDiscovery.php @@ -33,6 +33,49 @@ public function discover( Schema2Manifest $manifest, SelectedPackageTarget $selected, array $generatedSources = [], + ): SourceInventory { + $entries = []; + if ($selected->isBinary()) { + $entry = $selected->entry(); + if ($entry === null) { + throw $this->error('Binary Target Entry Is Missing', '', 'The selected binary has no entry source.'); + } + $entries[] = $entry; + } + + return $this->discoverSources($manifest, $entries, $generatedSources); + } + + /** + * Returns the complete package surface needed by language tooling rather than + * the source set of one executable target. + * + * @param list $generatedSources + */ + public function discoverForTooling( + Schema2Manifest $manifest, + array $generatedSources = [], + ): SourceInventory { + $entries = []; + foreach ($manifest->targets->binaries as $binary) { + $entries[$this->normalize($binary->entryPath)] = $binary->entryPath; + } + + return $this->discoverSources( + $manifest, + array_values($entries), + $generatedSources, + ); + } + + /** + * @param list $includedEntries + * @param list $generatedSources + */ + private function discoverSources( + Schema2Manifest $manifest, + array $includedEntries, + array $generatedSources, ): SourceInventory { $entryPaths = []; foreach ($manifest->targets->binaries as $binary) { @@ -70,11 +113,7 @@ public function discover( } } - if ($selected->isBinary()) { - $entry = $selected->entry(); - if ($entry === null) { - throw $this->error('Binary Target Entry Is Missing', '', 'The selected binary has no entry source.'); - } + foreach ($includedEntries as $entry) { $resolved = $this->paths->file($entry); $contents = @file_get_contents($resolved->canonicalPath); if ($contents === false || preg_match('//u', $contents) !== 1) { diff --git a/src/Testing/RuntimeOutcomeReader.php b/src/Testing/RuntimeOutcomeReader.php index 556d16f..331d0ea 100644 --- a/src/Testing/RuntimeOutcomeReader.php +++ b/src/Testing/RuntimeOutcomeReader.php @@ -436,21 +436,61 @@ public function u64(): int public static function unsigned64(string $bytes): int { - /** @var array{value: int} $value */ - $value = unpack('Pvalue', $bytes); - if ($value['value'] < 0) { - throw new RuntimeOutcomeInvalid('Runtime outcome integer exceeds the host transport range.'); + return self::decodeUnsigned64($bytes); + } + + public static function signed64(string $bytes): int + { + self::require64Bits($bytes); + if ((ord($bytes[7]) & 0x80) === 0) { + return self::decodeUnsigned64($bytes); } - return $value['value']; + $minimum = str_repeat("\0", PHP_INT_SIZE - 1) + . "\x80" + . str_repeat("\xff", 8 - PHP_INT_SIZE); + if ($bytes === $minimum) { + return PHP_INT_MIN; + } + + $magnitude = ''; + $carry = 1; + for ($index = 0; $index < 8; ++$index) { + $byte = 255 - ord($bytes[$index]) + $carry; + if ($byte === 256) { + $byte = 0; + $carry = 1; + } else { + $carry = 0; + } + $magnitude .= chr($byte); + } + + return -self::decodeUnsigned64($magnitude); } - public static function signed64(string $bytes): int + private static function decodeUnsigned64(string $bytes): int { - /** @var array{value: int} $value */ - $value = unpack('Pvalue', $bytes); + self::require64Bits($bytes); + $value = 0; + for ($index = 7; $index >= 0; --$index) { + $byte = ord($bytes[$index]); + if ($value > intdiv(PHP_INT_MAX - $byte, 256)) { + throw new RuntimeOutcomeInvalid( + 'Runtime outcome integer exceeds the host transport range.', + ); + } + $value = ($value * 256) + $byte; + } - return $value['value']; + return $value; + } + + private static function require64Bits(string $bytes): void + { + if (strlen($bytes) !== 8) { + throw new RuntimeOutcomeInvalid('Runtime outcome integer is not 64 bits wide.'); + } } public function text(int $length): string diff --git a/src/Toolchain/Platform.php b/src/Toolchain/Platform.php index b4cf467..f7f7462 100644 --- a/src/Toolchain/Platform.php +++ b/src/Toolchain/Platform.php @@ -15,10 +15,17 @@ public function __construct( ) { } - public static function host(?string $osFamily = null, ?string $machine = null): self + /** + * @param array|null $environment + */ + public static function host( + ?string $osFamily = null, + ?string $machine = null, + ?array $environment = null, + ): self { $osFamily ??= PHP_OS_FAMILY; - $machine ??= php_uname('m'); + $machine ??= self::hostMachine($osFamily, $environment); $name = match (strtolower($osFamily)) { 'windows' => 'windows', @@ -48,6 +55,29 @@ public static function host(?string $osFamily = null, ?string $machine = null): return new self($name, $architecture); } + /** + * A 32-bit PHP process reports i586 on 64-bit Windows. In that case, + * PROCESSOR_ARCHITEW6432 identifies the native host architecture. + * + * @param array|null $environment + */ + private static function hostMachine(string $osFamily, ?array $environment): string + { + if (strtolower($osFamily) === 'windows') { + foreach (['PROCESSOR_ARCHITEW6432', 'PROCESSOR_ARCHITECTURE'] as $name) { + $value = $environment === null + ? getenv($name) + : ($environment[$name] ?? false); + + if (is_string($value) && $value !== '') { + return $value; + } + } + } + + return php_uname('m'); + } + public function target(): string { return "{$this->name}-{$this->architecture}"; diff --git a/tests/Unit/PlatformTest.php b/tests/Unit/PlatformTest.php new file mode 100644 index 0000000..986816b --- /dev/null +++ b/tests/Unit/PlatformTest.php @@ -0,0 +1,36 @@ + 'x86', + 'PROCESSOR_ARCHITEW6432' => 'AMD64', + ], + ); + + self::assertSame('windows', $host->name); + self::assertSame('x86_64', $host->architecture); + self::assertSame('windows-x86_64', $host->target()); + } + + public function testUsesNativeArm64ArchitectureOnWindows(): void + { + $host = Platform::host( + osFamily: 'Windows', + environment: ['PROCESSOR_ARCHITECTURE' => 'ARM64'], + ); + + self::assertSame('windows-aarch64', $host->target()); + } +} diff --git a/tests/Unit/ProjectDocumentTest.php b/tests/Unit/ProjectDocumentTest.php index 84845f4..406d97b 100644 --- a/tests/Unit/ProjectDocumentTest.php +++ b/tests/Unit/ProjectDocumentTest.php @@ -71,6 +71,55 @@ public function testProjectDocumentSupportsLocklessPackagesWithoutDependencies() ); } + public function testProjectDocumentIncludesBinaryEntriesInTheToolingGraph(): void + { + $root = $this->temporaryDirectory('binary project document'); + $this->write($root . '/src/Core/Application.doria', "namespace Acme\\Core;\nclass Application {}\n"); + $this->write( + $root . '/src/main.doria', + "namespace Acme;\nuse Acme\\Core\\Application;\nfunction main(): void { new Application(); }\n", + ); + $this->write($root . '/Baton.toml', <<<'TOML' +manifest-version = 2 +[package] +name = "acme/app" +version = "1.0.0" +edition = "2026" +kind = "binary" +entry = "src/main.doria" +[autoload.namespaces] +"Acme\\" = "src/" +TOML); + $manifest = (new ManifestLoader())->load($root); + self::assertInstanceOf(Schema2Manifest::class, $manifest); + + $document = (new ProjectDocumentBuilder())->build( + new ProjectSelection($root, $root, $manifest, null, false), + true, + NetworkPolicy::Offline, + ); + + $packages = $document['packages']; + self::assertIsArray($packages); + $package = $packages[0]; + self::assertIsArray($package); + $packageSources = $package['sources']; + self::assertIsArray($packageSources); + + $plan = $document['toolingBuildPlan']; + self::assertIsArray($plan); + $planPackages = $plan['packages']; + self::assertIsArray($planPackages); + $planPackage = $planPackages[0]; + self::assertIsArray($planPackage); + $planSources = $planPackage['sources']; + self::assertIsArray($planSources); + + $expected = ['acme/app:src/Core/Application.doria', 'acme/app:src/main.doria']; + self::assertSame($expected, array_column($packageSources, 'identity')); + self::assertSame($expected, array_column($planSources, 'identity')); + } + public function testProjectDocumentUsesTheCompilerVisibleGraphAndGeneratedInventory(): void { $workspace = $this->temporaryDirectory('project document'); diff --git a/tests/Unit/RuntimeOutcomeReaderTest.php b/tests/Unit/RuntimeOutcomeReaderTest.php index f381658..c5b26cd 100644 --- a/tests/Unit/RuntimeOutcomeReaderTest.php +++ b/tests/Unit/RuntimeOutcomeReaderTest.php @@ -53,6 +53,22 @@ public function testPreservesCompilerOwnedDiagnosticsMatchersAndFacts(): void self::assertSame('FutureMatcher', $assertion->matcherSourceName()); } + public function testDecodesHostIntegerBoundariesPortably(): void + { + $outcome = (new RuntimeOutcomeReader())->decode($this->panicRecord( + 'P1112', + [ + ['minimum', 1, PHP_INT_MIN], + ['maximum', 2, PHP_INT_MAX], + ], + )); + + self::assertSame([ + ['name' => 'minimum', 'value' => PHP_INT_MIN], + ['name' => 'maximum', 'value' => PHP_INT_MAX], + ], $outcome->facts); + } + public function testRejectsMalformedUnsupportedAndOversizedRecords(): void { $reader = new RuntimeOutcomeReader(); @@ -102,8 +118,8 @@ private function panicRecord(string $code = 'P1000', array $facts = []): string . pack('v', strlen($function)) . pack('v', 0) . pack('v', count($facts)) - . pack('P', 0) - . pack('P', 5) + . $this->i64(0) + . $this->i64(5) . $code . $message . $path @@ -114,7 +130,7 @@ private function panicRecord(string $code = 'P1000', array $facts = []): string $scalar = $kind === 3 ? (int) (bool) $value : (is_int($value) ? $value : 0); $bytes .= pack('v', strlen($name)) . chr($kind) - . pack('P', $scalar) + . $this->i64($scalar) . pack('V', strlen($text)) . $name . $text; @@ -134,13 +150,13 @@ private function errorRecord( return "DORIAO3\0" . pack('v', 3) . pack('V', strlen($errorType)) - . pack('P', strlen($message)) + . $this->i64(strlen($message)) . pack('V', strlen($path)) . pack('V', strlen($source)) . pack('V', strlen($function)) . "\x01" - . pack('P', 0) - . pack('P', 5) + . $this->i64(0) + . $this->i64(5) . $errorType . $message . $path @@ -188,8 +204,8 @@ private function assertionRecord( . pack('V', strlen($source)) . pack('V', strlen($function)) . "\x01" - . pack('P', 0) - . pack('P', 6) + . $this->i64(0) + . $this->i64(6) . pack('v', 0) . $errorType . $matcher @@ -205,4 +221,15 @@ private function assertionRecord( return $bytes; } + + private function i64(int $value): string + { + $bytes = ''; + for ($index = 0; $index < 8; ++$index) { + $bytes .= chr($value & 0xff); + $value >>= 8; + } + + return $bytes; + } } diff --git a/tests/Unit/SourceDiscoveryTest.php b/tests/Unit/SourceDiscoveryTest.php index 942eda2..e4b7402 100644 --- a/tests/Unit/SourceDiscoveryTest.php +++ b/tests/Unit/SourceDiscoveryTest.php @@ -65,6 +65,64 @@ public function testLibraryExcludesAllBinaryEntries(): void ); } + public function testToolingDiscoveryIncludesEveryBinaryEntry(): void + { + $root = $this->project(); + $this->write($root, 'src/Domain/Shared.doria'); + $this->write($root, 'src/web.doria'); + $this->write($root, 'src/worker.doria'); + $this->write($root, 'tests/PostTest.doria'); + + $inventory = (new SourceDiscovery($root))->discoverForTooling($this->manifest($root)); + + self::assertSame( + ['src/Domain/Shared.doria', 'src/web.doria', 'src/worker.doria', 'tests/PostTest.doria'], + array_map(static fn ($source): string => $source->relativePath, $inventory->sources), + ); + self::assertSame( + ['autoload', 'entry', 'entry', 'autoload'], + array_map(static fn ($source): string => $source->origin, $inventory->sources), + ); + } + + public function testToolingDiscoveryRegistersASharedBinaryEntryOnce(): void + { + $root = $this->project(); + $this->write($root, 'src/Domain/Shared.doria'); + $this->write($root, 'src/web.doria'); + $this->write($root, 'src/worker.doria'); + self::assertNotFalse(file_put_contents($root . '/Baton.toml', <<<'TOML' +manifest-version = 2 + +[package] +name = "acme/blog" +version = "1.0.0" +edition = "2026" + +[[targets.binary]] +name = "web" +entry = "src/web.doria" + +[[targets.binary]] +name = "worker" +entry = "src/web.doria" + +[autoload.namespaces] +"Acme\\Blog\\" = "src/" +TOML)); + + $inventory = (new SourceDiscovery($root))->discoverForTooling($this->manifest($root)); + + self::assertSame( + ['src/Domain/Shared.doria', 'src/web.doria', 'src/worker.doria'], + array_map(static fn ($source): string => $source->relativePath, $inventory->sources), + ); + self::assertSame( + ['autoload', 'entry', 'autoload'], + array_map(static fn ($source): string => $source->origin, $inventory->sources), + ); + } + public function testDuplicateCanonicalSourceAndScopeConflictAreRejected(): void { $root = $this->temporaryDirectory('duplicate source');