diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..025bf3b
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,18 @@
+BasedOnStyle: Microsoft
+Standard: c++20
+ColumnLimit: 120
+IndentWidth: 4
+NamespaceIndentation: All
+TabWidth: 4
+UseTab: Never
+BreakBeforeBraces: Custom
+BraceWrapping:
+ AfterClass: true
+ AfterControlStatement: Never
+ AfterEnum: true
+ AfterFunction: true
+ AfterNamespace: true
+ AfterStruct: true
+ BeforeCatch: false
+ BeforeElse: false
+SortIncludes: CaseSensitive
diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644
index 0000000..92a0aa8
--- /dev/null
+++ b/.clang-tidy
@@ -0,0 +1,13 @@
+---
+Checks: >
+ -*,
+ clang-analyzer-*,
+ bugprone-*,
+ performance-*,
+ portability-*,
+ -bugprone-easily-swappable-parameters
+WarningsAsErrors: '*'
+HeaderFilterRegex: '.*[\\/]src[\\/].*'
+SystemHeaders: false
+FormatStyle: file
+...
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index fa796e8..973fa73 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -2,97 +2,236 @@ name: CI
on:
push:
- branches: [ "master" ]
+ branches: [master]
pull_request:
- branches: [ "master" ]
+ branches: [master]
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
- contents: write
+ contents: read
env:
- VCPKG_DEFAULT_BINARY_CACHE: "C:/vcpkg-binary-cache"
+ VCPKG_ROOT: C:\vcpkg
+ VCPKG_DEFAULT_BINARY_CACHE: C:\vcpkg-binary-cache
jobs:
- build:
+ verification:
+ name: ${{ matrix.job }}
runs-on: windows-2022
+ timeout-minutes: 120
strategy:
+ fail-fast: false
matrix:
- arch: [ x64, x86 ]
+ include:
+ - job: source
+ arch: none
+ - job: x86
+ arch: x86
+ - job: x64
+ arch: x64
+ - job: arm64-cross
+ arch: arm64
+
steps:
- - uses: actions/checkout@v4
+ - name: Check out the repository
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
- - name: Setup Developer Command Prompt
- uses: ilammy/msvc-dev-cmd@v1
+ - name: Set up uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
- arch: ${{ matrix.arch }}
+ version: "0.12.1"
+ enable-cache: true
+ cache-dependency-glob: build/uv.lock
+ cache-suffix: ${{ matrix.job }}
- - name: Get project vcpkg baseline
+ - name: Identify the hosted runner image
+ id: runner-image
shell: pwsh
run: |
- $baseline = (Get-Content -Path vcpkg.json | ConvertFrom-Json).'builtin-baseline'
- echo "VCPKG_BASELINE=$baseline" >> $env:GITHUB_ENV
+ if ([string]::IsNullOrWhiteSpace($env:ImageOS) -or
+ [string]::IsNullOrWhiteSpace($env:ImageVersion)) {
+ throw 'GitHub runner image identity is unavailable.'
+ }
+ "identity=$($env:ImageOS)-$($env:ImageVersion)" | Add-Content -Path $env:GITHUB_OUTPUT
+
+ - name: Cache vcpkg binaries
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
+ key: vcpkg-${{ steps.runner-image.outputs.identity }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }}
- - name: Cache vcpkg
- uses: actions/cache@v4
+ - name: Restore completed build nodes
+ id: restore-build-cas
+ if: matrix.job != 'source'
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
- key: vcpkg-${{ matrix.arch }}-${{ hashFiles('vcpkg.json') }}
- path: |
- ${{env.VCPKG_DEFAULT_BINARY_CACHE}}
+ path: out/cas
+ key: cas-v1-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
+ restore-keys: |
+ cas-v1-${{ matrix.job }}-${{ github.sha }}-
+ cas-v1-${{ matrix.job }}-
- - name: Setup vcpkg
+ - name: Prepare the pinned Python environment
+ shell: pwsh
run: |
- New-Item -ItemType Directory -Path C:/my-vcpkg
- Set-Location -Path C:/my-vcpkg
- git init
- git remote add --no-tags origin https://github.com/microsoft/vcpkg.git
- git fetch --depth 1 --no-write-fetch-head origin ${{env.VCPKG_BASELINE}}
- git branch master ${{env.VCPKG_BASELINE}}
- git checkout
- ./bootstrap-vcpkg.bat
- New-Item -ItemType Directory -Path ${{env.VCPKG_DEFAULT_BINARY_CACHE}} -Force
- echo "VCPKG_ROOT=C:/my-vcpkg" >> $env:GITHUB_ENV
-
- - name: Configure CMake
- run: cmake --preset ${{ matrix.arch }}-release
-
- - name: Build
- run: cmake --build ${{github.workspace}}/build/${{ matrix.arch }}-release
-
- - name: Pack
+ "TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV
+ "TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV
+ New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null
+ $baseline = (Get-Content -Raw -LiteralPath 'vcpkg.json' | ConvertFrom-Json).'builtin-baseline'
+ if ($baseline -notmatch '^[0-9a-f]{40}$') {
+ throw "Invalid vcpkg builtin baseline: $baseline"
+ }
+ git -C $env:VCPKG_ROOT cat-file -e "$baseline^{commit}" 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline
+ if ($LASTEXITCODE -ne 0) {
+ throw "Unable to fetch vcpkg baseline $baseline."
+ }
+ }
+ git -C $env:VCPKG_ROOT -c advice.detachedHead=false `
+ checkout --detach --force $baseline
+ if ($LASTEXITCODE -ne 0) {
+ throw "Unable to check out vcpkg baseline $baseline."
+ }
+ & (Join-Path $env:VCPKG_ROOT 'bootstrap-vcpkg.bat') -disableMetrics
+ if ($LASTEXITCODE -ne 0) {
+ throw "Unable to bootstrap vcpkg baseline $baseline."
+ }
+ uv sync --project build --frozen
+
+ - name: Provision external verification tools
+ shell: pwsh
run: |
- cd ${{github.workspace}}/build/${{ matrix.arch }}-release
- cpack --config CPackConfig.cmake -C RelWithDebInfo
+ choco install cppcheck --version=2.19.0 --yes --no-progress
+ $cppcheck = 'C:\Program Files\Cppcheck\cppcheck.exe'
+ if (-not (Test-Path -LiteralPath $cppcheck -PathType Leaf)) {
+ throw "Cppcheck was not found after installation: $cppcheck"
+ }
+ (Split-Path -Parent $cppcheck) | Add-Content -Path $env:GITHUB_PATH
+ Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force
+ $binskimRoot = Join-Path $env:RUNNER_TEMP 'binskim'
+ nuget install Microsoft.CodeAnalysis.BinSkim -Version 4.4.9.11 `
+ -OutputDirectory $binskimRoot -DirectDownload -NonInteractive
+ $binskim = Join-Path $binskimRoot `
+ 'Microsoft.CodeAnalysis.BinSkim.4.4.9.11\tools\net9.0\win-x64\BinSkim.exe'
+ if (-not (Test-Path -LiteralPath $binskim -PathType Leaf)) {
+ throw "BinSkim was not found after installation: $binskim"
+ }
+ (Split-Path -Parent $binskim) | Add-Content -Path $env:GITHUB_PATH
+
+ $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86)
+ $umdh = Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64\umdh.exe'
+ if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) {
+ $installer = Join-Path $env:RUNNER_TEMP 'winsdksetup.exe'
+ Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/?linkid=2349110' -OutFile $installer
+ $signature = Get-AuthenticodeSignature -LiteralPath $installer
+ if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') {
+ throw "Windows SDK installer signature validation failed: $($signature.Status)"
+ }
+ $process = Start-Process -FilePath $installer -ArgumentList @(
+ '/features', 'OptionId.WindowsDesktopDebuggers',
+ '/quiet', '/norestart', '/ceip', 'off'
+ ) -Wait -PassThru
+ if ($process.ExitCode -notin @(0, 3010)) {
+ throw "Windows Debugging Tools installation failed with exit code $($process.ExitCode)."
+ }
+ }
+ if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) {
+ throw "UMDH was not found after Windows Debugging Tools setup: $umdh"
+ }
+
+ - name: Provision the Windows 10 UMDH workaround
+ if: matrix.job == 'x64'
+ shell: pwsh
+ run: |
+ $root = Join-Path $env:RUNNER_TEMP 'winsdk-19041'
+ $extract = Join-Path $root 'extracted'
+ $msi = Join-Path $root 'debuggers-x64.msi'
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
+ Invoke-WebRequest -Uri 'https://download.microsoft.com/download/e119c04b-71aa-4067-ac3c-360c2e13d209/windowssdk/Installers/X64%20Debuggers%20And%20Tools-x64_en-us.msi' -OutFile $msi
+ $expected = '354173D844D5C061050EE2638AA94FAFB4835AC3DE836E220F6A74A992849A3B'
+ if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash -ne $expected) {
+ throw 'Windows 10 Debugging Tools payload hash validation failed.'
+ }
+ $signature = Get-AuthenticodeSignature -LiteralPath $msi
+ if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') {
+ throw "Windows 10 Debugging Tools payload signature validation failed: $($signature.Status)"
+ }
+ $arguments = @('/a', $msi, '/qn', '/norestart', "TARGETDIR=$extract")
+ $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" `
+ -ArgumentList $arguments -Wait -PassThru
+ if ($process.ExitCode -ne 0) {
+ throw "Windows 10 Debugging Tools extraction failed with exit code $($process.ExitCode)."
+ }
+ $debuggers = Join-Path $extract 'Windows Kits\10\Debuggers\x64'
+ $umdh = Join-Path $debuggers 'umdh.exe'
+ $gflags = Join-Path $debuggers 'gflags.exe'
+ if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) {
+ throw "Windows 10 UMDH was not found after setup: $umdh"
+ }
+ if (-not (Test-Path -LiteralPath $gflags -PathType Leaf)) {
+ throw "Windows 10 GFlags was not found after setup: $gflags"
+ }
+ $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($umdh).FileVersion
+ if (-not $version.StartsWith('10.0.19041.', [StringComparison]::Ordinal)) {
+ throw "Unexpected Windows 10 UMDH version: $version"
+ }
+ "OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV
+
+ - name: Check prerequisites through the public entry point
+ shell: pwsh
+ run: ./build.ps1 doctor
- - name: Upload artifacts
- uses: actions/upload-artifact@v4
- with:
- name: observer-modules-${{ matrix.arch }}
- path: ${{github.workspace}}/build/${{ matrix.arch }}-release/*.zip
+ - name: Verify repository sources
+ id: verify-source
+ if: matrix.job == 'source'
+ shell: pwsh
+ run: |
+ $evidence = Join-Path $env:RUNNER_TEMP 'evidence-source'
+ ./build.ps1 verify-source -ExportDir $evidence
- release:
- needs: build
- runs-on: ubuntu-latest
- if: github.event_name == 'push' && github.ref == 'refs/heads/master'
- steps:
- - name: Download all artifacts
- uses: actions/download-artifact@v4
+ - name: Verify one architecture
+ id: verify-arch
+ if: matrix.job != 'source'
+ shell: pwsh
+ run: |
+ $evidence = Join-Path $env:RUNNER_TEMP 'evidence-${{ matrix.job }}'
+ $fuzzSeconds = if ('${{ github.event_name }}' -eq 'pull_request') { 5 } else { 60 }
+ $leakWarmup = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 8 }
+ $leakIterations = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 100 }
+ ./build.ps1 verify-arch -Arch '${{ matrix.arch }}' -ExportDir $evidence `
+ -TestShards 4 -FuzzSeconds $fuzzSeconds -LeakWarmup $leakWarmup `
+ -LeakIterations $leakIterations -LeakWindows 3 -PruneCas
+
+ - name: Save completed build nodes
+ if: always() && matrix.job != 'source'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
- merge-multiple: true
- path: ./artifacts
-
- - name: Generate release tag
- id: tag
- run: echo "tag=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT
+ path: out/cas
+ key: ${{ steps.restore-build-cas.outputs.cache-primary-key }}
- - name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ - name: Upload verification evidence
+ if: always() && (steps.verify-source.outcome != 'skipped' || steps.verify-arch.outcome != 'skipped')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: evidence-${{ matrix.job }}
+ path: |
+ ${{ runner.temp }}/evidence-${{ matrix.job }}/manifest.json
+ ${{ runner.temp }}/evidence-${{ matrix.job }}/reports
+ ${{ runner.temp }}/evidence-${{ matrix.job }}/logs
+ if-no-files-found: error
+ retention-days: ${{ github.event_name == 'pull_request' && 7 || 30 }}
+
+ - name: Upload master packages and symbols
+ if: success() && github.event_name == 'push' && matrix.job != 'source'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- tag_name: release-${{ steps.tag.outputs.tag }}
- name: 'Release ${{ steps.tag.outputs.tag }}'
- body: |
- Automated release from master branch.
-
- Download the *-dll.zip files if you need Observer modules.
- Download the *-pdb.zip files if you need debug symbols.
- files: ./artifacts/*.zip
- prerelease: false
+ name: packages-${{ matrix.job }}
+ path: ${{ runner.temp }}/evidence-${{ matrix.job }}/packages
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index 0530e07..7312e7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
-/.PVS-Studio
-/build
+/out/
+/build/.venv/
+/build/.uv-cache/
+/build/.coverage*
+/.idea/
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index a55e7a1..0000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
deleted file mode 100644
index 4ea72a9..0000000
--- a/.idea/copilot.data.migration.agent.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
deleted file mode 100644
index 7fbe760..0000000
--- a/.idea/dictionaries/project.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
- andelf
- auriemma
- birkenfeld
- bstatic
- catchorg
- debugfarhome
- dependencygraph
- dest
- funcs
- ilammy
- lazyhamster
- luxrck
- makemoduleversion
- mateidavid
- nomoreitems
- popd
- pushd
- refaim's
- ren'
- renpy
- rgssad
- rpatool
- rpgmaker
- shizmob
- softprops
- strbuf
- thirdparty
- userabort
- vcvars
- wstring
- xxhash
- zanzapak
- zanzarah
- zstr
-
-
-
\ No newline at end of file
diff --git a/.idea/editor.xml b/.idea/editor.xml
deleted file mode 100644
index 2c855b4..0000000
--- a/.idea/editor.xml
+++ /dev/null
@@ -1,343 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 0b76fe5..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 348b976..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Copy_x64_debug_to_Far.xml b/.idea/runConfigurations/Copy_x64_debug_to_Far.xml
deleted file mode 100644
index e0a01cc..0000000
--- a/.idea/runConfigurations/Copy_x64_debug_to_Far.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Copy_x64_release_to_Far.xml b/.idea/runConfigurations/Copy_x64_release_to_Far.xml
deleted file mode 100644
index 0c2d86e..0000000
--- a/.idea/runConfigurations/Copy_x64_release_to_Far.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Copy_x86_debug_to_Far.xml b/.idea/runConfigurations/Copy_x86_debug_to_Far.xml
deleted file mode 100644
index 2096b9b..0000000
--- a/.idea/runConfigurations/Copy_x86_debug_to_Far.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Copy_x86_release_to_Far.xml b/.idea/runConfigurations/Copy_x86_release_to_Far.xml
deleted file mode 100644
index 808b0e3..0000000
--- a/.idea/runConfigurations/Copy_x86_release_to_Far.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Far_x64_debug.xml b/.idea/runConfigurations/Far_x64_debug.xml
deleted file mode 100644
index bed2e21..0000000
--- a/.idea/runConfigurations/Far_x64_debug.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Far_x64_release.xml b/.idea/runConfigurations/Far_x64_release.xml
deleted file mode 100644
index 3fe2c78..0000000
--- a/.idea/runConfigurations/Far_x64_release.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Far_x86_debug.xml b/.idea/runConfigurations/Far_x86_debug.xml
deleted file mode 100644
index f1492bb..0000000
--- a/.idea/runConfigurations/Far_x86_debug.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Far_x86_release.xml b/.idea/runConfigurations/Far_x86_release.xml
deleted file mode 100644
index 3dd962a..0000000
--- a/.idea/runConfigurations/Far_x86_release.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Run_Tests.xml b/.idea/runConfigurations/Run_Tests.xml
deleted file mode 100644
index b5dd467..0000000
--- a/.idea/runConfigurations/Run_Tests.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d8aae9e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,92 @@
+# Repository agent instructions
+
+## Build
+
+This project has a console-first Windows build based directly on MSBuild and pinned manifest-mode vcpkg dependencies.
+Repository CMake is not part of the build graph; CMake use inside a vcpkg port is acceptable.
+
+Use the root entry point from an ordinary PowerShell or `cmd.exe` console:
+
+```powershell
+.\build.ps1 doctor
+.\build.ps1 build -Arch all -Config Release
+.\build.ps1 test -Arch x86,x64 -Config Debug
+.\build.ps1 verify-source -ExportDir
+.\build.ps1 verify-arch -Arch x64 -ExportDir
+.\build.ps1 verify -Arch x64
+```
+
+Run the relevant build, tests, and checks after changing code. Do not install or update developer tools automatically;
+report missing prerequisites to the user. Release modules must remain MSVC-built, `/MT`, self-contained binaries for
+x86, x64, and ARM64 with no third-party runtime DLLs.
+
+All production changes follow strict TDD: state the observable requirement or invariant, add a focused test that fails
+for the expected reason, implement the smallest correct change, and refactor only while the suite remains green. Every
+bug fix starts with a regression test. The required coverage gate is 100% first-party source lines and branches; do not
+weaken thresholds, exclude production files, write coverage-only tests with no behavioral assertion, or add broad
+suppressions to make a check pass.
+
+## Development guidelines
+
+This project uses C++23 and follows the high-assurance engineering policy in
+`docs/critical-software-methodology.md`. In particular:
+
+- Preserve clean dependency direction: platform/Observer adapters depend on application and parser code, never the
+ reverse. Format parsing belongs in a platform-neutral core and must not acquire Observer or Win32 dependencies.
+- Keep the C ABI boundary strict. Export only C-compatible, fixed-layout data and explicit sizes. Never let STL types,
+ C++ exceptions, allocator ownership, or implicit lifetime assumptions cross the boundary. Validate all inbound
+ pointers and structure sizes, initialize outputs defensively, and translate every internal failure to the documented
+ ABI result at the outermost boundary.
+- Use value semantics and RAII for every resource, including memory, files, module handles, and temporary output.
+ Application C++ must not contain owning `new`, `delete`, `malloc`, `calloc`, `realloc`, or `free`. A raw pointer or
+ reference is non-owning; prefer references, `std::span`, and `std::string_view` where they express the contract.
+ Prefer `std::unique_ptr` for polymorphic ownership. `std::shared_ptr` requires a documented, genuinely shared
+ lifetime; use `std::weak_ptr` to break cycles.
+- Treat archive bytes, metadata, paths, counts, offsets, sizes, and callback behavior as untrusted input. Validate
+ before use, use checked arithmetic before narrowing/allocation/seeking, impose explicit resource and iteration
+ bounds, guarantee loop progress, and avoid input-driven recursion unless a strict depth limit is enforced.
+- Express security-relevant condition combinations as executable, data-driven decision-table tests.
+- Keep functions cohesive, control flow reviewable, ownership explicit, and preprocessor use minimal. Avoid magic
+ numbers, hidden global state, duplicated policy, and speculative abstraction. KISS and DRY remain subordinate to
+ clear boundaries and independently testable behavior.
+- A check suppression or deviation must be narrow, explained beside the code or in the decision log, and reviewed by
+ the owner. Never catch `std::bad_alloc`, `std::length_error`, access violations, or sanitizer findings merely to make
+ fuzzing or tests pass.
+
+## Architecture overview
+
+This project implements Observer plugin modules for FAR Manager that handle exotic archive formats. Each module
+implements the Observer API for one format family.
+
+### Core components
+
+- **API layer** (`src/api.h`, `src/dll.cpp`): Observer entry points such as `OpenStorage`, `CloseStorage`, `GetItem`,
+ and `ExtractItem`.
+- **Archive wrapper** (`src/archive.h`, `src/archive.cpp`): common archive lifecycle and extraction behavior.
+- **Extractor interface** (`src/modules/extractor.h`): the internal contract implemented by each format module.
+
+### Module structure
+
+Supported modules live under `src/modules/`:
+
+- `renpy/`: Ren'Py RPA archives and their Pickle index parser;
+- `rpgmaker/`: RPG Maker VX Ace RGSS3A archives;
+- `zanzarah/`: Zanzarah PAK archives.
+
+Each contains format-specific implementation, a `.def` export definition, and `observer_user.ini` registration data.
+
+### Data flow
+
+1. FAR Manager/Observer loads the module through `LoadSubModule()`.
+2. `OpenStorage()` creates an archive wrapper with the format extractor.
+3. `PrepareFiles()` validates and indexes archive contents.
+4. `GetItem()` exposes entry metadata.
+5. `ExtractItem()` streams an entry to the requested destination with progress/cancellation reporting.
+
+### Tests
+
+Catch2 tests live in `src/tests/`. Unit tests exercise parser logic directly, while integration and ABI contract tests
+load the actual module binaries without requiring FAR Manager. Small repository-owned fixtures are mandatory. The
+external golden corpus is an optional compatibility/stress layer selected with `-Corpus`.
+
+See `docs/build-system.md` for the current command contract and build architecture.
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index b4e1c08..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Build
-
-This project uses CMake with vcpkg for dependency management, builds only on Windows and requires Visual Studio 2017+.
-
-Do not attempt to build the code or run tests, as the environment is not set up for it.
-
-## Development Guidelines
-
-This project uses **C++23** and follows KISS (Keep It Simple, Stupid) and DRY (Don't Repeat Yourself) principles.
-
-Avoid magic numbers.
-
-## Architecture Overview
-
-This project implements Observer plugin modules for FAR Manager that handle exotic archive formats. The codebase follows
-a plugin architecture where each module implements the Observer API to support different archive formats.
-
-### Core Components
-
-- **API Layer** (`src/api.h`, `src/dll.cpp`): Implements the Observer plugin API with standard functions like
- `OpenStorage`, `CloseStorage`, `GetItem`, `ExtractItem`
-- **Archive Wrapper** (`src/archive.h`, `src/archive.cpp`): Provides a unified interface that wraps format-specific
- extractors
-- **Extractor Interface** (`src/modules/extractor.h`): Defines the abstract interface that all format extractors must
- implement
-
-### Module Structure
-
-Each supported format has its own module under `src/modules/`:
-
-- `renpy/`: RenPy visual novel archives (.rpa files) with pickle support
-- `zanzarah/`: Zanzarah game archives (.pak files)
-- `rpgmaker/`: RPG Maker archives (in development)
-
-Each module contains:
-
-- Format-specific implementation (e.g., `renpy.cpp`)
-- Module definition file (`.def`) for DLL exports
-- Configuration file (`observer_user.ini`)
-
-### Data Flow
-
-1. FAR Manager loads the module DLL via `LoadSubModule()`
-2. `OpenStorage()` creates an archive wrapper with format-specific extractor
-3. `PrepareFiles()` scans and indexes archive contents
-4. `GetItem()` provides file metadata for FAR's file browser
-5. `ExtractItem()` handles actual file extraction with progress callbacks
-
-### Testing Framework
-
-Located in `src/tests/` with a custom framework (`framework/observer.h`) that simulates the Observer API for testing
-archive operations without requiring FAR Manager.
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
deleted file mode 100644
index 6508d49..0000000
--- a/CMakeLists.txt
+++ /dev/null
@@ -1,96 +0,0 @@
-cmake_minimum_required(VERSION 3.31)
-
-if (DEFINED ENV{VCPKG_ROOT})
- set(VCPKG_ROOT "$ENV{VCPKG_ROOT}")
-elseif (DEFINED ENV{USERPROFILE})
- set(VCPKG_ROOT "$ENV{USERPROFILE}/.vcpkg-clion/vcpkg")
-endif ()
-if (NOT EXISTS ${VCPKG_ROOT})
- message(FATAL_ERROR "VCPKG_ROOT is not defined. Please set it to the path of your vcpkg installation.")
-endif ()
-file(TO_CMAKE_PATH ${VCPKG_ROOT} VCPKG_ROOT)
-set(CMAKE_TOOLCHAIN_FILE "${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake")
-
-set(VCPKG_CRT_LINKAGE static)
-set(VCPKG_LIBRARY_LINKAGE static)
-set(VCPKG_TARGET_TRIPLET ${OBSERVER_ARCHITECTURE}-windows-static)
-
-project(observer_modules LANGUAGES CXX)
-set(CMAKE_CXX_STANDARD 23)
-set(CMAKE_CXX_STANDARD_REQUIRED ON)
-add_compile_options("/W3" "/analyze")
-set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>")
-
-set(ZLIB_USE_STATIC_LIBS ON)
-find_package(ZLIB REQUIRED)
-find_path(ZSTR_INCLUDE_DIRS "zstr.hpp")
-
-set(ALL_MODULES renpy rpgmaker zanzarah)
-set(RELEASED_MODULES renpy rpgmaker zanzarah)
-
-set(RENPY_DIR src/modules/renpy)
-add_library(renpy SHARED
- src/dll.cpp
- src/archive.cpp
- ${RENPY_DIR}/renpy.cpp
- ${RENPY_DIR}/pickle.cpp
-)
-target_link_libraries(renpy PRIVATE ZLIB::ZLIB)
-target_include_directories(renpy PRIVATE ${ZSTR_INCLUDE_DIRS})
-
-add_library(rpgmaker SHARED src/dll.cpp src/archive.cpp src/modules/rpgmaker/rpgmaker.cpp)
-
-add_library(zanzarah SHARED src/dll.cpp src/archive.cpp src/modules/zanzarah/zanzarah.cpp)
-
-foreach (module IN LISTS ALL_MODULES)
- set_target_properties(${module} PROPERTIES SUFFIX ".so" PREFIX "" LINK_FLAGS "/DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/modules/${module}/${module}.def")
-endforeach ()
-
-# === CTest ===
-
-find_package(Catch2 REQUIRED)
-find_package(nlohmann_json REQUIRED)
-find_package(xxHash CONFIG REQUIRED)
-add_executable(tests
- src/tests/framework/observer.cpp
- src/tests/framework/testcase.cpp
- src/tests/renpy.cpp
- src/tests/rpgmaker.cpp
- src/tests/zanzarah.cpp
-)
-target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
-target_link_libraries(tests PRIVATE nlohmann_json::nlohmann_json)
-target_link_libraries(tests PRIVATE xxHash::xxhash)
-target_link_libraries(tests PRIVATE ${ALL_MODULES})
-include(CTest)
-include(Catch)
-catch_discover_tests(tests)
-
-# === CPack ===
-
-file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/docs_temp/thirdparty)
-file(COPY ${CMAKE_SOURCE_DIR}/licenses/ DESTINATION ${CMAKE_BINARY_DIR}/docs_temp/thirdparty)
-
-string(TIMESTAMP TODAY "%Y-%m-%d")
-foreach (MODULE IN LISTS RELEASED_MODULES)
- install(TARGETS ${MODULE} RUNTIME DESTINATION . COMPONENT ${MODULE})
- install(FILES ${CMAKE_SOURCE_DIR}/src/modules/${MODULE}/observer_user.ini DESTINATION . COMPONENT ${MODULE})
-
- install(FILES ${CMAKE_SOURCE_DIR}/LICENSE.txt DESTINATION docs RENAME license.txt COMPONENT ${MODULE})
- install(DIRECTORY ${CMAKE_BINARY_DIR}/docs_temp/ DESTINATION docs COMPONENT ${MODULE})
-
- install(FILES "$" DESTINATION . COMPONENT ${MODULE}_pdb)
-
- string(TOUPPER ${MODULE} MODULE_UPPER)
- set(CPACK_ARCHIVE_${MODULE_UPPER}_FILE_NAME "${MODULE}-${TODAY}-${OBSERVER_ARCHITECTURE}-dll")
- set(CPACK_ARCHIVE_${MODULE_UPPER}_PDB_FILE_NAME "${MODULE}-${TODAY}-${OBSERVER_ARCHITECTURE}-pdb")
-endforeach ()
-
-list(TRANSFORM RELEASED_MODULES APPEND "_pdb" OUTPUT_VARIABLE ALL_COMPONENTS)
-list(PREPEND ALL_COMPONENTS ${RELEASED_MODULES})
-
-set(CPACK_GENERATOR ZIP)
-set(CPACK_ARCHIVE_COMPONENT_INSTALL ON)
-set(CPACK_COMPONENTS_ALL ${ALL_COMPONENTS})
-
-include(CPack)
\ No newline at end of file
diff --git a/CMakePresets.json b/CMakePresets.json
deleted file mode 100644
index cdb22cd..0000000
--- a/CMakePresets.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "version": 3,
- "configurePresets": [
- {
- "hidden": true,
- "name": "default",
- "generator": "Ninja",
- "vendor": {
- "jetbrains.com/clion": {
- "toolchain": "Visual Studio"
- }
- }
- },
- {
- "name": "x64-debug",
- "inherits": "default",
- "binaryDir": "${sourceDir}/build/x64-debug",
- "architecture": {
- "value": "x64",
- "strategy": "external"
- },
- "cacheVariables": {
- "CMAKE_BUILD_TYPE": "Debug",
- "OBSERVER_ARCHITECTURE": "x64"
- }
- },
- {
- "name": "x64-release",
- "inherits": "default",
- "binaryDir": "${sourceDir}/build/x64-release",
- "architecture": {
- "value": "x64",
- "strategy": "external"
- },
- "cacheVariables": {
- "CMAKE_BUILD_TYPE": "RelWithDebInfo",
- "OBSERVER_ARCHITECTURE": "x64"
- }
- },
- {
- "name": "x86-debug",
- "inherits": "default",
- "binaryDir": "${sourceDir}/build/x86-debug",
- "architecture": {
- "value": "Win32",
- "strategy": "external"
- },
- "cacheVariables": {
- "CMAKE_BUILD_TYPE": "Debug",
- "OBSERVER_ARCHITECTURE": "x86"
- }
- },
- {
- "name": "x86-release",
- "inherits": "default",
- "binaryDir": "${sourceDir}/build/x86-release",
- "architecture": {
- "value": "Win32",
- "strategy": "external"
- },
- "cacheVariables": {
- "CMAKE_BUILD_TYPE": "RelWithDebInfo",
- "OBSERVER_ARCHITECTURE": "x86"
- }
- }
- ]
-}
diff --git a/README.md b/README.md
index 5063a2e..1afda70 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,6 @@ specific files as needed without having to unpack the entire archive.
| [lazyhamster/Observer](https://github.com/lazyhamster/Observer) | [LGPL-3.0](licenses/Observer.txt) |
| [Cyan4973/xxHash](https://github.com/Cyan4973/xxHash) | [BSD-2-Clause](licenses/xxHash.txt) |
| [zlib](https://zlib.net) | [zlib](licenses/zlib.txt) |
-| [mateidavid/zstr](https://github.com/mateidavid/zstr) | [MIT](licenses/zstr.txt) |
## Sources of inspiration
@@ -79,14 +78,29 @@ specific files as needed without having to unpack the entire archive.
| [birkenfeld/serde-pickle](https://github.com/birkenfeld/serde-pickle) | [MIT](licenses/serde-pickle.txt) |
| [Shizmob/rpatool](https://github.com/Shizmob/rpatool) | [WTFPL](licenses/rpatool.txt) |
| [zanzapak](https://aluigi.altervista.org/papers.htm#others-file) | [GPL-3.0](licenses/zanzapak.txt) |
+| [pg83/ix](https://github.com/pg83/ix/tree/66726a904152246fbef8b27e26e878840f6d7fb7) | [MIT](licenses/IX.txt) |
## Building from Source
### Prerequisites
-- **Visual Studio 2017** compiler
-- **CLion** and/or **CMake** (version 3.31+)
-- **vcpkg**
-
-You can open the included CLion project directly and build through the IDE or use CMake manually to generate the build
-files, then compile using the Visual Studio compiler.
+- Visual Studio Build Tools 2022 with the v143 MSVC tools for x86/x64 and ARM64, Spectre-mitigated libraries, and a
+ Windows 11 SDK
+- PowerShell 7.4 or newer
+- [uv](https://docs.astral.sh/uv/) for the exact-pinned Python build-driver environment
+- vcpkg available on `PATH` or through `VCPKG_ROOT`
+
+No IDE, Visual Studio developer prompt, global vcpkg integration, or repository-level CMake generation is required.
+From a normal Windows console:
+
+```powershell
+uv sync --project build --frozen
+.\build.ps1 doctor
+.\build.ps1 build -Arch all -Config Release
+.\build.ps1 test -Arch x86,x64 -Config Debug
+.\build.ps1 package -Arch all
+```
+
+The build restores pinned static dependencies and produces self-contained `/MT` modules for x86, x64, and ARM64.
+See [the build-system documentation](docs/build-system.md) for analysis, coverage, sanitizer, fuzzing, binary-audit, and
+packaging commands.
diff --git a/build.cmd b/build.cmd
new file mode 100644
index 0000000..493a877
--- /dev/null
+++ b/build.cmd
@@ -0,0 +1,2 @@
+@echo off
+pwsh.exe -NoLogo -NoProfile -File "%~dp0build.ps1" %*
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..9daa2e1
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,9 @@
+#requires -Version 7.4
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$buildProject = Join-Path $PSScriptRoot 'build'
+$env:UV_CACHE_DIR = Join-Path $buildProject '.uv-cache'
+& uv run --project $buildProject --frozen --no-sync python (Join-Path $buildProject 'main.py') @args
+exit $LASTEXITCODE
diff --git a/build/ObserverConfiguration.props b/build/ObserverConfiguration.props
new file mode 100644
index 0000000..bd80ca8
--- /dev/null
+++ b/build/ObserverConfiguration.props
@@ -0,0 +1,15 @@
+
+
+
+
+ true
+ $(ObserverConfigurationType)
+ true
+ false
+ v143
+ ClangCL
+ Unicode
+ true
+ Spectre
+
+
diff --git a/build/ObserverFuzz.props b/build/ObserverFuzz.props
new file mode 100644
index 0000000..c0c455c
--- /dev/null
+++ b/build/ObserverFuzz.props
@@ -0,0 +1,14 @@
+
+
+
+
+ Console
+ $(LLVMRuntimeDir)\clang_rt.asan-x86_64.lib;$(LLVMRuntimeDir)\clang_rt.asan_cxx-x86_64.lib;%(AdditionalDependencies)
+ /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.fuzzer-x86_64.lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan-x86_64.lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan_cxx-x86_64.lib" /INFERASANLIBS:NO %(AdditionalOptions)
+
+
+
+
+
+
diff --git a/build/ObserverNativeAnalysis.ruleset b/build/ObserverNativeAnalysis.ruleset
new file mode 100644
index 0000000..d5b8986
--- /dev/null
+++ b/build/ObserverNativeAnalysis.ruleset
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/build/ObserverProject.props b/build/ObserverProject.props
new file mode 100644
index 0000000..1abcf68
--- /dev/null
+++ b/build/ObserverProject.props
@@ -0,0 +1,87 @@
+
+
+
+ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\'))
+ $(RepositoryRoot)out\work\manual-msbuild\
+ x86
+ x64
+ arm64
+ observer-$(PlatformMoniker)-windows-static
+ observer-$(PlatformMoniker)-windows-static-asan
+ true
+
+ false
+
+ false
+ $(RepositoryRoot)
+
+ $(ArtifactsRoot)vcpkg_installed\$(PlatformMoniker)\
+ $(ArtifactsRoot)vcpkg_installed\$(PlatformMoniker)-asan\
+ false
+ --overlay-triplets=$(RepositoryRoot)build\vcpkg\triplets
+ $(ArtifactsRoot)bin\$(PlatformMoniker)\$(Configuration)\
+ $(ArtifactsRoot)obj\$(PlatformMoniker)\$(Configuration)\$(ProjectName)\
+ $(OutDir)
+ $(ProjectName)
+ true
+ false
+ true
+ true
+ $(RepositoryRoot)build\ObserverNativeAnalysis.ruleset
+ $(ProjectName)
+ true
+
+
+
+
+ stdcpp23
+ stdcpplatest
+ Level4
+ true
+ true
+ true
+ TurnOffAllWarnings
+ true
+ true
+ false
+ true
+ Guard
+ true
+ $(ObserverAnalysisReportDirectory)\$(PlatformMoniker)\$(ObserverAnalysisReportName).sarif
+ $(ObserverAnalysisReportPath)
+ Sync
+ MultiThreaded
+ MultiThreadedDebug
+ OldStyle
+ EnableFastChecks
+ Default
+ false
+ Disabled
+ MaxSpeed
+ true
+ true
+ $(RepositoryRoot)src;%(AdditionalIncludeDirectories)
+ NOMINMAX;%(PreprocessorDefinitions)
+ /utf-8 /Zc:__cplusplus %(AdditionalOptions)
+ /sourceDependencies "$(ObserverSourceDependenciesPath)" %(AdditionalOptions)
+ /clang:-MJ"$(ObserverClangCommandPath)" %(AdditionalOptions)
+ /clang:-fprofile-instr-generate /clang:-fcoverage-mapping %(AdditionalOptions)
+ /fsanitize=address %(AdditionalOptions)
+ /clang:-fsanitize=undefined /clang:-fno-sanitize-recover=all %(AdditionalOptions)
+ /clang:-fsanitize=fuzzer,address %(AdditionalOptions)
+
+
+ true
+ true
+ true
+ true
+ true
+ Guard
+ true
+ true
+ true
+ UseLinkTimeCodeGeneration
+ $(LLVMRuntimeDir)\clang_rt.ubsan_standalone-x86_64.lib;$(LLVMRuntimeDir)\clang_rt.ubsan_standalone_cxx-x86_64.lib;%(AdditionalDependencies)
+
+
+
diff --git a/build/ObserverProjectConfigurations.props b/build/ObserverProjectConfigurations.props
new file mode 100644
index 0000000..73b8947
--- /dev/null
+++ b/build/ObserverProjectConfigurations.props
@@ -0,0 +1,19 @@
+
+
+
+ DebugWin32
+ Debugx64
+ DebugARM64
+ ReleaseWin32
+ Releasex64
+ ReleaseARM64
+ CoverageWin32
+ Coveragex64
+ CoverageARM64
+ ASanWin32
+ ASanx64
+ UBSanx64
+ FuzzWin32
+ Fuzzx64
+
+
diff --git a/build/PSScriptAnalyzerSettings.psd1 b/build/PSScriptAnalyzerSettings.psd1
new file mode 100644
index 0000000..a6b326a
--- /dev/null
+++ b/build/PSScriptAnalyzerSettings.psd1
@@ -0,0 +1,6 @@
+@{
+ Severity = @('Error', 'Warning')
+ ExcludeRules = @(
+ 'PSAvoidUsingWriteHost'
+ )
+}
diff --git a/build/core/__init__.py b/build/core/__init__.py
new file mode 100644
index 0000000..db0d63b
--- /dev/null
+++ b/build/core/__init__.py
@@ -0,0 +1 @@
+"""Core primitives for the local ObserverModules build graph."""
diff --git a/build/core/binary_audit.py b/build/core/binary_audit.py
new file mode 100644
index 0000000..e51a011
--- /dev/null
+++ b/build/core/binary_audit.py
@@ -0,0 +1,131 @@
+"""Release PE and BinSkim policy shared by fine-grained audit leaves."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+from collections.abc import Sequence
+
+
+class AuditError(RuntimeError):
+ pass
+
+
+_MACHINES = {
+ "x86": r"14C machine \(x86\)",
+ "x64": r"8664 machine \(x64\)",
+ "arm64": r"AA64 machine \(ARM64\)",
+}
+_ALLOWED_DLLS = {
+ "advapi32.dll",
+ "bcrypt.dll",
+ "kernel32.dll",
+ "ntdll.dll",
+ "ole32.dll",
+ "oleaut32.dll",
+ "shell32.dll",
+ "shlwapi.dll",
+ "user32.dll",
+}
+_FORBIDDEN_DLL = re.compile(
+ r"^(?:vcruntime|msvcp|ucrtbase|api-ms-win-crt-|ext-ms-win-crt-|zlib|zstd|xxhash|clang_rt\.).*\.dll$",
+ re.IGNORECASE,
+)
+
+
+def require_release_pe(
+ architecture: str, headers: str, dependents: str, exports: str
+) -> None:
+ try:
+ machine = _MACHINES[architecture]
+ except KeyError as error:
+ raise AuditError(f"unsupported machine architecture: {architecture}") from error
+ if re.search(machine, headers) is None:
+ raise AuditError(f"wrong PE machine for {architecture}")
+
+ dependencies = {
+ match.group(1)
+ for line in dependents.splitlines()
+ if (match := re.fullmatch(r"\s+([A-Za-z0-9._-]+\.dll)\s*", line))
+ }
+ unexpected = sorted(
+ dependency
+ for dependency in dependencies
+ if _FORBIDDEN_DLL.match(dependency)
+ or (
+ dependency.casefold() not in _ALLOWED_DLLS
+ and re.match(r"^(?:api|ext)-ms-win-.*\.dll$", dependency, re.IGNORECASE) is None
+ )
+ )
+ if unexpected:
+ raise AuditError("unexpected DLL dependencies: " + ", ".join(unexpected))
+
+ actual_exports = {
+ match.group(1)
+ for line in exports.splitlines()
+ if (match := re.match(r"^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)", line))
+ }
+ expected_exports = {"LoadSubModule", "UnloadSubModule"}
+ if actual_exports != expected_exports:
+ raise AuditError("unexpected exports: " + ", ".join(sorted(actual_exports)))
+
+
+def require_clean_binskim(document: dict[str, object]) -> None:
+ findings = []
+ for run in document.get("runs", []):
+ rules = {
+ rule["id"]: rule.get("defaultConfiguration", {}).get("level", "warning")
+ for rule in run.get("tool", {}).get("driver", {}).get("rules", [])
+ }
+ for result in run.get("results", []):
+ rule = result.get("ruleId", "")
+ level = result.get("level", rules.get(rule, "warning"))
+ if level in {"warning", "error"} and not (level == "warning" and rule == "BA2027"):
+ findings.append(f"{level}:{rule}")
+ if findings:
+ raise AuditError("BinSkim unapproved findings: " + ", ".join(findings))
+
+
+def _run_binskim(tool: Path, binary: Path) -> None:
+ raw_output = os.environ.get("OBSERVER_OUT_DIR")
+ if not raw_output or not (output_root := Path(raw_output)).is_dir():
+ raise AuditError("OBSERVER_OUT_DIR must be an existing directory")
+ report = output_root / "binskim.sarif"
+ argv = [
+ str(tool), "analyze", str(binary), "--level", "Error;Warning", "--kind", "Fail",
+ "--local-symbol-directories", str(binary.parent), "--output", str(report),
+ "--log", "ForceOverwrite", "--quiet", "--disable-telemetry",
+ ]
+ subprocess.run(argv, check=True)
+ if not report.is_file():
+ raise AuditError("BinSkim did not produce binskim.sarif")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ pe = commands.add_parser("pe")
+ for argument in ("architecture", "headers", "dependents", "exports"):
+ pe.add_argument(argument)
+ report = commands.add_parser("binskim")
+ report.add_argument("report")
+ run = commands.add_parser("run-binskim")
+ run.add_argument("tool")
+ run.add_argument("binary")
+ args = parser.parse_args(argv)
+ if args.command == "pe":
+ texts = [Path(getattr(args, name)).read_text(encoding="utf-8-sig") for name in ("headers", "dependents", "exports")]
+ require_release_pe(args.architecture, *texts)
+ elif args.command == "binskim":
+ require_clean_binskim(json.loads(Path(args.report).read_text(encoding="utf-8-sig")))
+ else:
+ _run_binskim(Path(args.tool), Path(args.binary))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/clang_dependencies.py b/build/core/clang_dependencies.py
new file mode 100644
index 0000000..28579af
--- /dev/null
+++ b/build/core/clang_dependencies.py
@@ -0,0 +1,203 @@
+"""Resolve clang-cl translation-unit inputs through ``clang-scan-deps``."""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Sequence
+import json
+import os
+from pathlib import Path
+import subprocess
+from typing import Any
+
+
+class ClangDependencyError(RuntimeError):
+ pass
+
+
+def _file(path: Path, label: str) -> Path:
+ try:
+ resolved = path.resolve(strict=True)
+ except OSError as error:
+ raise ClangDependencyError(f"invalid clang dependency {label}: {path}") from error
+ if not resolved.is_file():
+ raise ClangDependencyError(f"invalid clang dependency {label}: {path}")
+ return resolved
+
+
+def load_compile_command(command: Path, source: Path, compiler: Path) -> dict[str, Any]:
+ """Load one clang ``-MJ`` fragment and remove its capture-only argument."""
+
+ expected_source, expected_compiler = _file(source, "source"), _file(compiler, "compiler")
+ try:
+ content = command.read_text(encoding="utf-8-sig").rstrip()
+ document = json.loads(content[:-1] if content.endswith(",") else content)
+ except (OSError, json.JSONDecodeError) as error:
+ raise ClangDependencyError(f"invalid clang compilation-command JSON: {command}") from error
+ if not isinstance(document, dict):
+ raise ClangDependencyError("invalid clang compilation-command JSON object")
+ try:
+ directory = Path(document["directory"]).resolve(strict=True)
+ except (KeyError, OSError, TypeError) as error:
+ raise ClangDependencyError("invalid clang compilation-command directory") from error
+ if not directory.is_dir():
+ raise ClangDependencyError("invalid clang compilation-command directory")
+ try:
+ reported_source = Path(document["file"])
+ reported_source = (directory / reported_source).resolve(strict=True)
+ except (KeyError, OSError, TypeError) as error:
+ raise ClangDependencyError("invalid clang compilation-command source") from error
+ if reported_source != expected_source:
+ raise ClangDependencyError("clang compilation-command source mismatch")
+ arguments = document.get("arguments")
+ if not isinstance(arguments, list) or not arguments or not all(
+ isinstance(item, str) for item in arguments
+ ):
+ raise ClangDependencyError("invalid clang compilation-command arguments")
+ try:
+ reported_compiler = Path(arguments[0]).resolve(strict=True)
+ except OSError as error:
+ raise ClangDependencyError("invalid clang compilation-command compiler") from error
+ if reported_compiler != expected_compiler:
+ raise ClangDependencyError("clang compilation-command compiler mismatch")
+ return {
+ **document,
+ "directory": str(directory),
+ "file": str(expected_source),
+ "arguments": [
+ str(expected_compiler),
+ *(item for item in arguments[1:] if not item.casefold().startswith("/clang:-mj")),
+ ],
+ }
+
+
+def dependency_manifest(source: Path, document: object) -> dict[str, object]:
+ """Convert experimental-full scanner JSON to the existing canonical manifest."""
+
+ expected_source = _file(source, "source")
+ try:
+ if not isinstance(document, dict):
+ raise TypeError
+ units = document["translation-units"]
+ if not isinstance(units, list) or not units:
+ raise TypeError
+ dependencies: list[Path] = []
+ for unit in units:
+ commands = unit["commands"]
+ if not isinstance(commands, list) or not commands:
+ raise TypeError
+ for command in commands:
+ file_dependencies = command["file-deps"]
+ if not isinstance(file_dependencies, list) or not all(
+ isinstance(item, str) for item in file_dependencies
+ ):
+ raise TypeError
+ for item in file_dependencies:
+ path = Path(item)
+ if not path.is_absolute():
+ raise TypeError
+ resolved = path.resolve(strict=True)
+ if not resolved.is_file():
+ raise OSError
+ dependencies.append(resolved)
+ except (KeyError, OSError, TypeError) as error:
+ raise ClangDependencyError("invalid clang-scan-deps scan output") from error
+ if expected_source not in dependencies:
+ raise ClangDependencyError("invalid clang-scan-deps scan output: source is absent")
+ unique = {
+ os.path.normcase(str(path)): str(path)
+ for path in dependencies
+ if path != expected_source
+ }
+ return {
+ "Data": {
+ "Source": str(expected_source),
+ "Includes": sorted(unique.values(), key=str.casefold),
+ }
+ }
+
+
+def scan_dependencies(
+ source: Path, command: Path, scanner: Path, compiler: Path, build_directory: Path
+) -> dict[str, object]:
+ """Run the exact scanner over the exact clang-cl command captured by MSBuild."""
+
+ scanner = _file(scanner, "scanner")
+ compilation = load_compile_command(command, source, compiler)
+ try:
+ build_directory = build_directory.resolve(strict=True)
+ except OSError as error:
+ raise ClangDependencyError(
+ f"invalid clang dependency build directory: {build_directory}"
+ ) from error
+ if not build_directory.is_dir():
+ raise ClangDependencyError(
+ f"invalid clang dependency build directory: {build_directory}"
+ )
+ database = build_directory / "compile_commands.json"
+ try:
+ database.write_text(
+ json.dumps([compilation], ensure_ascii=False, separators=(",", ":")),
+ encoding="utf-8",
+ )
+ argv = [
+ str(scanner),
+ "-format=experimental-full",
+ f"-compilation-database={database}",
+ "-j",
+ "1",
+ ]
+ result = subprocess.run(
+ argv,
+ cwd=compilation["directory"],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ check=False,
+ )
+ except OSError as error:
+ raise ClangDependencyError(f"failed to launch clang-scan-deps: {scanner}") from error
+ if result.returncode:
+ detail = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "no diagnostic"
+ raise ClangDependencyError(
+ f"clang-scan-deps failed ({result.returncode}): {detail[:400]}"
+ )
+ try:
+ document = json.loads(result.stdout)
+ except json.JSONDecodeError as error:
+ raise ClangDependencyError("invalid clang-scan-deps JSON output") from error
+ return dependency_manifest(source, document)
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ scan = commands.add_parser("scan")
+ scan.add_argument("source")
+ scan.add_argument("command_file")
+ scan.add_argument("scanner")
+ scan.add_argument("compiler")
+ args = parser.parse_args(argv)
+ raw_output = os.environ.get("OBSERVER_OUT_DIR")
+ if not raw_output or not (output := Path(raw_output)).is_dir():
+ raise ClangDependencyError("OBSERVER_OUT_DIR must be an existing directory")
+ raw_build = os.environ.get("OBSERVER_BUILD_DIR")
+ if not raw_build or not (build := Path(raw_build)).is_dir():
+ raise ClangDependencyError("OBSERVER_BUILD_DIR must be an existing directory")
+ document = scan_dependencies(
+ Path(args.source),
+ Path(args.command_file),
+ Path(args.scanner),
+ Path(args.compiler),
+ build,
+ )
+ (output / "dependencies.json").write_text(
+ json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n",
+ encoding="utf-8",
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/clean.py b/build/core/clean.py
new file mode 100644
index 0000000..546d3b6
--- /dev/null
+++ b/build/core/clean.py
@@ -0,0 +1,215 @@
+"""Conservative cleanup of the exact repository-local generated output tree."""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Collection, Sequence
+import os
+from pathlib import Path
+import shutil
+import stat
+
+from filelock import FileLock, Timeout
+
+from core.paths import BuildPaths, PathSafetyError
+
+
+_MODES = ("all", "stale-work")
+
+
+class CleanError(RuntimeError):
+ """Generated output cannot be proven safe and inactive."""
+
+
+def _tree(paths: BuildPaths, root: Path) -> None:
+ for directory, directories, files in root.walk(follow_symlinks=False):
+ for name in (*directories, *files):
+ current = paths.require_confined(directory / name, paths.output_root)
+ mode = os.lstat(current).st_mode
+ if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
+ raise CleanError(f"unsupported output entry type: {current}")
+
+
+def _validate(paths: BuildPaths) -> bool:
+ output = paths.require_confined(paths.output_root, paths.output_root)
+ try:
+ mode = os.lstat(output).st_mode
+ except FileNotFoundError:
+ return False
+ if not stat.S_ISDIR(mode):
+ raise CleanError(f"output root is not a directory: {output}")
+ if output.resolve(strict=True) != paths.repository / "out":
+ raise CleanError(f"output root does not resolve to repository/out: {output}")
+ for child in output.iterdir():
+ current = paths.require_confined(child, paths.output_root)
+ if current.name not in {"cas", "work"}:
+ raise CleanError(f"unexpected output entry: {current}")
+ if not stat.S_ISDIR(os.lstat(current).st_mode):
+ raise CleanError(f"output entry is not a directory: {current}")
+ _tree(paths, output)
+ return True
+
+
+def _runs(paths: BuildPaths) -> tuple[Path, ...]:
+ result = []
+ for entry in sorted(paths.work_root.iterdir()):
+ if entry == paths.locks_root:
+ continue
+ if not entry.is_dir():
+ raise CleanError(f"unexpected work entry: {entry}")
+ try:
+ result.append(paths.run_work(entry.name))
+ except PathSafetyError as error:
+ raise CleanError(f"unexpected work entry: {entry}") from error
+ return tuple(result)
+
+
+def _require_inactive(paths: BuildPaths) -> tuple[Path, ...]:
+ inactive = []
+ for entry in sorted(paths.locks_root.iterdir()):
+ if entry == paths.coordination_lock():
+ continue
+ if not entry.is_file():
+ raise CleanError(f"unexpected lock entry: {entry}")
+ try:
+ if entry.suffix == ".lock":
+ paths.lock(entry.stem)
+ elif entry.name.startswith("run-") and entry.suffix == ".lease":
+ paths.lease(entry.name[4:-6])
+ else:
+ raise PathSafetyError("unknown lock name")
+ except PathSafetyError as error:
+ raise CleanError(f"unexpected lock entry: {entry}") from error
+ lock = FileLock(entry, timeout=0, fallback_to_soft=False, preserve_lock_file=True)
+ try:
+ with lock:
+ pass
+ except Timeout as error:
+ raise CleanError(f"active build lock: {entry}") from error
+ inactive.append(entry)
+ return tuple(inactive)
+
+
+def _require_no_active_runs(paths: BuildPaths, owned_lease: Path | None) -> None:
+ owned_active = owned_lease is None
+ for entry in sorted(paths.locks_root.iterdir()):
+ if not (entry.name.startswith("run-") and entry.suffix == ".lease"):
+ continue
+ try:
+ paths.lease(entry.name[4:-6])
+ except PathSafetyError as error:
+ raise CleanError(f"unexpected run lease: {entry}") from error
+ lock = FileLock(
+ entry, timeout=0, fallback_to_soft=False, preserve_lock_file=True
+ )
+ try:
+ with lock:
+ if entry == owned_lease:
+ raise CleanError(f"owned run lease is not active: {entry}")
+ except Timeout as error:
+ if entry == owned_lease:
+ owned_active = True
+ continue
+ raise CleanError(f"active build lease: {entry}") from error
+ if not owned_active:
+ raise CleanError(f"owned run lease is missing: {owned_lease}")
+
+
+def sweep_cas(
+ repository: Path | str, live_uids: Collection[str], *, owned_run_id: str | None = None
+) -> tuple[Path, ...]:
+ """Remove unlocked canonical CAS entries absent from the explicit live set."""
+
+ paths = BuildPaths(repository)
+ owned_lease = paths.lease(owned_run_id) if owned_run_id is not None else None
+ live = frozenset(live_uids)
+ for uid in live:
+ paths.cas(uid)
+ if not _validate(paths):
+ return ()
+ paths.work_root.mkdir(exist_ok=True)
+ paths.locks_root.mkdir(exist_ok=True)
+ coordination = FileLock(
+ paths.coordination_lock(), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+ try:
+ with coordination:
+ if not _validate(paths) or not paths.cas_root.exists():
+ return ()
+ _require_no_active_runs(paths, owned_lease)
+ removed = []
+ for entry in sorted(paths.cas_root.iterdir()):
+ uid = entry.name
+ if (
+ len(uid) != 32
+ or any(character not in "0123456789abcdef" for character in uid)
+ or uid in live
+ ):
+ continue
+ candidate = paths.cas(uid)
+ node = FileLock(
+ paths.lock(uid), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+ try:
+ with node:
+ candidate = paths.cas(uid)
+ shutil.rmtree(candidate.entry)
+ except Timeout:
+ continue
+ removed.append(candidate.entry)
+ return tuple(removed)
+ except Timeout as error:
+ raise CleanError("active build coordination lock") from error
+
+
+def clean(repository: Path | str, mode: str = "all") -> tuple[Path, ...]:
+ """Remove all output or inactive work, including legacy CAS entry names."""
+
+ if mode not in _MODES:
+ raise ValueError(f"unsupported clean mode: {mode}")
+ paths = BuildPaths(repository)
+ if not _validate(paths):
+ return ()
+ paths.work_root.mkdir(exist_ok=True)
+ paths.locks_root.mkdir(exist_ok=True)
+ coordination = FileLock(
+ paths.coordination_lock(), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+ try:
+ with coordination:
+ if not _validate(paths):
+ return ()
+ runs = _runs(paths)
+ inactive = _require_inactive(paths)
+ if mode == "all":
+ removed = []
+ if paths.cas_root.exists():
+ removed.append(paths.cas_root)
+ shutil.rmtree(paths.cas_root)
+ for run in runs:
+ shutil.rmtree(run)
+ for lock in inactive:
+ lock.unlink()
+ return tuple(removed) + runs + inactive
+ for run in runs:
+ shutil.rmtree(run)
+ return runs
+ except Timeout as error:
+ raise CleanError("active build coordination lock") from error
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(prog="observer-build clean")
+ parser.add_argument("repository", type=Path)
+ parser.add_argument("--mode", choices=_MODES, default="all")
+ args = parser.parse_args(argv)
+ for removed in clean(args.repository, args.mode):
+ print(removed)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/cpp_coverage.py b/build/core/cpp_coverage.py
new file mode 100644
index 0000000..c757e67
--- /dev/null
+++ b/build/core/cpp_coverage.py
@@ -0,0 +1,68 @@
+"""Semantic policy for LLVM coverage reports."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from collections.abc import Mapping, Sequence
+
+
+class CoverageError(RuntimeError):
+ pass
+
+
+def _metric(totals: Mapping[str, object], name: str) -> tuple[int, int]:
+ try:
+ metric = totals[name]
+ if not isinstance(metric, Mapping):
+ raise TypeError
+ count, covered = metric["count"], metric["covered"]
+ if (
+ isinstance(count, bool)
+ or not isinstance(count, int)
+ or isinstance(covered, bool)
+ or not isinstance(covered, int)
+ or count < 0
+ or covered < 0
+ or covered > count
+ ):
+ raise TypeError
+ return count, covered
+ except (KeyError, TypeError) as error:
+ raise CoverageError("malformed LLVM coverage totals") from error
+
+
+def require_full_coverage(document: Mapping[str, object]) -> None:
+ """Require nonempty, exact 100% first-party line and branch coverage."""
+
+ try:
+ data = document["data"]
+ if not isinstance(data, list) or len(data) != 1 or not isinstance(data[0], Mapping):
+ raise TypeError
+ totals = data[0]["totals"]
+ if not isinstance(totals, Mapping):
+ raise TypeError
+ except (KeyError, TypeError) as error:
+ raise CoverageError("malformed LLVM coverage report") from error
+
+ for name in ("lines", "branches"):
+ count, covered = _metric(totals, name)
+ if count == 0:
+ raise CoverageError(f"no first-party {name} in LLVM coverage report")
+ if covered != count:
+ raise CoverageError(f"first-party {name} coverage is {covered}/{count}, required 100%")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ gate = commands.add_parser("gate")
+ gate.add_argument("report")
+ args = parser.parse_args(argv)
+ require_full_coverage(json.loads(Path(args.report).read_text(encoding="utf-8-sig")))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/doctor.py b/build/core/doctor.py
new file mode 100644
index 0000000..beaf7d9
--- /dev/null
+++ b/build/core/doctor.py
@@ -0,0 +1,79 @@
+"""Read-only, fail-soft discovery of complete local build prerequisites."""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+import sys
+import tomllib
+
+from core.quality_tools import discover_quality_tools, resolve_sanitizer_runtimes
+from core.source_tools import discover_source_tools
+from core.toolchain import discover_msvc_toolchain
+
+_MSVC = (("msbuild_version", "MSBuild"), ("vc_tools_version", "MSVC"),
+ ("clang_tidy_version", "LLVM"), ("windows_sdk_version", "SDK"))
+_SOURCE = (("pwsh_version", "PowerShell"), ("clang_format_version", "clang-format"),
+ ("cppcheck_version", "Cppcheck"), ("psscriptanalyzer_version", "PSScriptAnalyzer"))
+
+@dataclass(frozen=True, slots=True)
+class Probe:
+ name: str
+ status: str
+ detail: str
+
+def _python_version() -> str:
+ config = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8"))
+ required = config["project"]["requires-python"]
+ actual = ".".join(map(str, sys.version_info[:3]))
+ if required != f"=={actual}":
+ raise RuntimeError(f"requires {required}, running {actual}")
+ return actual
+
+def _versions(value: object, fields: tuple[tuple[str, str], ...]) -> str:
+ identity = dict(value.identity) # type: ignore[attr-defined]
+ return ", ".join(f"{label}={identity[key]}" for key, label in fields)
+
+def _concise(error: Exception) -> str:
+ return " ".join(str(error).split()) or type(error).__name__
+
+def doctor_report() -> tuple[Probe, ...]:
+ rows: list[Probe] = []
+
+ def probe(name: str, action: Callable[[], object],
+ detail: Callable[[object], str]) -> object | None:
+ try:
+ value = action()
+ rows.append(Probe(name, "OK", detail(value)))
+ return value
+ except Exception as error: # doctor must preserve the remaining independent probes
+ rows.append(Probe(name, "MISSING", _concise(error)))
+ return None
+
+ def require_toolchain() -> object:
+ if toolchain is None:
+ raise RuntimeError("MSVC toolchain unavailable")
+ return toolchain
+
+ probe("python", _python_version, str)
+ toolchain = probe("msvc", discover_msvc_toolchain, lambda value: _versions(value, _MSVC))
+ probe("source-tools", lambda: discover_source_tools(require_toolchain()),
+ lambda value: _versions(value, _SOURCE))
+ probe("quality-tools", lambda: discover_quality_tools(require_toolchain()),
+ lambda _value: "clang-cl, clang-scan-deps, llvm-cov, llvm-profdata, dumpbin, BinSkim, UMDH")
+ probe("sanitizer-runtimes", lambda: resolve_sanitizer_runtimes(require_toolchain()),
+ lambda _value: "ASan x86/x64, UBSan x64")
+ return tuple(rows)
+
+def main(argv: Sequence[str] | None = None) -> int:
+ argparse.ArgumentParser(prog="observer-build doctor").parse_args(argv)
+ report = doctor_report()
+ print("probe\tstatus\tdetail")
+ for item in report:
+ print(f"{item.name}\t{item.status}\t{item.detail}")
+ return int(any(item.status != "OK" for item in report))
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/execute.py b/build/core/execute.py
new file mode 100644
index 0000000..053ad97
--- /dev/null
+++ b/build/core/execute.py
@@ -0,0 +1,127 @@
+"""Demand execution adapted from pg83/ix (MIT) at commit 66726a9."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Awaitable, Callable
+from contextlib import asynccontextmanager
+from typing import AsyncContextManager
+
+from core.graph import Graph, GraphError, Node
+
+
+class ExecutionError(RuntimeError):
+ """A node did not publish a complete cache entry."""
+
+
+CompletionPredicate = Callable[[Node], bool]
+Runner = Callable[[Node], Awaitable[None]]
+Publisher = Callable[[Node], None]
+LockFactory = Callable[[Node], AsyncContextManager[object]]
+
+
+@asynccontextmanager
+async def _unlocked(_node: Node):
+ yield None
+
+
+class Executor:
+ """Execute demanded ancestors once under named and shared-slot limits."""
+
+ def __init__(
+ self,
+ graph: Graph,
+ *,
+ is_complete: CompletionPredicate,
+ runner: Runner,
+ publish: Publisher,
+ acquire_lock: LockFactory = _unlocked,
+ ) -> None:
+ self._graph = graph
+ self._is_complete_hook = is_complete
+ self._runner = runner
+ self._publish = publish
+ self._acquire_lock = acquire_lock
+ self._visited: set[str] = set()
+ self._failed: dict[str, Exception] = {}
+ self._blocked: set[str] = set()
+ self._locks = {node.name: asyncio.Lock() for node in graph.nodes}
+ self._pools = {
+ name: asyncio.Semaphore(capacity) for name, capacity in graph.pools.items()
+ }
+ self._global = self._pools.get("slot")
+ self._used = False
+
+ async def run(self, targets: tuple[str, ...] | None = None) -> None:
+ if self._used:
+ raise ExecutionError("an Executor is single-use")
+ self._used = True
+ requested = self._graph.targets if targets is None else tuple(targets)
+ if not requested:
+ raise GraphError("explicit target set must not be empty")
+ await self._visit_many(tuple(self._graph.node(name) for name in requested))
+ if self._failed:
+ failures = tuple(sorted(self._failed.items()))
+ names = ", ".join(name for name, _error in failures)
+ group = ExceptionGroup(
+ f"{len(failures)} node(s) failed: {names}",
+ tuple(error for _name, error in failures),
+ )
+ group.failed_nodes = tuple(name for name, _error in failures) # type: ignore[attr-defined]
+ raise group
+
+ async def _visit(self, current: Node) -> None:
+ async with self._locks[current.name]:
+ if (
+ current.name in self._visited
+ or current.name in self._failed
+ or current.name in self._blocked
+ ):
+ return
+ try:
+ if self._is_complete(current):
+ self._visited.add(current.name)
+ return
+
+ async with self._acquire_lock(current):
+ if self._is_complete(current):
+ self._visited.add(current.name)
+ return
+ dependencies = self._graph.dependencies_of(current.name)
+ await self._visit_many(dependencies)
+ if any(dependency.name not in self._visited for dependency in dependencies):
+ self._blocked.add(current.name)
+ return
+ async with self._capacity(current):
+ await self._runner(current)
+ self._publish(current)
+ if not self._is_complete(current):
+ raise ExecutionError(
+ f"node {current.name!r} returned without complete output"
+ )
+ self._visited.add(current.name)
+ except Exception as error:
+ self._failed[current.name] = error
+
+ @asynccontextmanager
+ async def _capacity(self, current: Node):
+ pool = self._pools[current.pool]
+ async with pool:
+ if self._global is None or pool is self._global:
+ yield
+ else:
+ async with self._global:
+ yield
+
+ def _is_complete(self, current: Node) -> bool:
+ result = self._is_complete_hook(current)
+ if not isinstance(result, bool):
+ raise ExecutionError(
+ f"completion predicate for node {current.name!r} did not return bool"
+ )
+ return result
+
+ async def _visit_many(self, nodes: tuple[Node, ...]) -> None:
+ async with asyncio.TaskGroup() as tasks:
+ for current in nodes:
+ tasks.create_task(self._visit(current))
diff --git a/build/core/graph.py b/build/core/graph.py
new file mode 100644
index 0000000..fbc8015
--- /dev/null
+++ b/build/core/graph.py
@@ -0,0 +1,236 @@
+"""Small named DAG model adapted from pg83/ix (MIT) at commit 66726a9."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from graphlib import CycleError, TopologicalSorter
+import re
+from types import MappingProxyType
+from typing import Mapping
+
+
+class GraphError(ValueError):
+ """The graph is incomplete, ambiguous, or cyclic."""
+
+
+NODE_SLUG_MAX_LENGTH = 128
+_SLUG = re.compile(rf"[a-z0-9][a-z0-9._-]{{0,{NODE_SLUG_MAX_LENGTH - 1}}}")
+_MD5_UID = re.compile(r"[0-9a-f]{32}")
+_RESULT_ID = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*")
+_RESULT_KIND = re.compile(r"[a-z][a-z0-9._-]*")
+_MEDIA_TYPE = re.compile(r"[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*")
+
+
+def _text(value: object, description: str) -> str:
+ if not isinstance(value, str) or not value or "\0" in value:
+ raise GraphError(f"{description} must be a non-empty string without NUL")
+ return value
+
+
+@dataclass(frozen=True, slots=True)
+class Command:
+ """One literal process invocation; no field is shell-translated."""
+
+ argv: tuple[str, ...]
+ env: tuple[tuple[str, str], ...] = ()
+ cwd: str | None = None
+ stdin: bytes = b""
+
+ def __post_init__(self) -> None:
+ argv = tuple(self.argv)
+ if not argv:
+ raise GraphError("command argv must not be empty")
+ for argument in argv:
+ if not isinstance(argument, str) or "\0" in argument:
+ raise GraphError("command argument must be a string without NUL")
+
+ try:
+ env = tuple((key, value) for key, value in self.env)
+ except (TypeError, ValueError) as error:
+ raise GraphError("command environment must contain key/value pairs") from error
+ for key, value in env:
+ _text(key, "environment key")
+ if not isinstance(value, str) or "\0" in value:
+ raise GraphError("environment value must be a string without NUL")
+ folded_keys = [key.casefold() for key, _value in env]
+ if len(folded_keys) != len(set(folded_keys)):
+ raise GraphError("command environment contains duplicate keys")
+ if self.cwd is not None:
+ _text(self.cwd, "command cwd")
+ if type(self.stdin) is not bytes:
+ raise GraphError("command stdin must be exact bytes")
+
+ object.__setattr__(self, "argv", argv)
+ object.__setattr__(self, "env", tuple(sorted(env, key=lambda pair: pair[0].casefold())))
+
+
+@dataclass(frozen=True, slots=True)
+class Result:
+ """One stable logical result at a portable path below the node output."""
+
+ id: str
+ kind: str
+ media_type: str
+ relative_path: str
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.id, str) or _RESULT_ID.fullmatch(self.id) is None:
+ raise GraphError("result id must be a lowercase slash-separated identifier")
+ if not isinstance(self.kind, str) or _RESULT_KIND.fullmatch(self.kind) is None:
+ raise GraphError("result kind must be a lowercase identifier")
+ if not isinstance(self.media_type, str) or _MEDIA_TYPE.fullmatch(self.media_type) is None:
+ raise GraphError("result media type must be a canonical lowercase MIME type")
+ if not isinstance(self.relative_path, str):
+ raise GraphError("result path must be a portable relative path")
+ parts = self.relative_path.split("/")
+ if (
+ not self.relative_path
+ or "\0" in self.relative_path
+ or "\\" in self.relative_path
+ or ":" in self.relative_path
+ or self.relative_path.startswith("/")
+ or any(part in {"", ".", ".."} for part in parts)
+ ):
+ raise GraphError("result path must be a portable relative path")
+
+
+@dataclass(frozen=True, slots=True)
+class Node:
+ """One cacheable command whose inputs name its direct dependency nodes."""
+
+ name: str
+ uid: str
+ pool: str
+ command: Command
+ inputs: tuple[str, ...] = ()
+ results: tuple[Result, ...] = ()
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.name, str) or _SLUG.fullmatch(self.name) is None:
+ raise GraphError(
+ "node name must be a lowercase readable slug of at most "
+ f"{NODE_SLUG_MAX_LENGTH} ASCII characters"
+ )
+ if not isinstance(self.uid, str) or _MD5_UID.fullmatch(self.uid) is None:
+ raise GraphError("node uid must be a 32-character lowercase MD5")
+ _text(self.pool, "node pool")
+ if not isinstance(self.command, Command):
+ raise GraphError("node command must be a Command")
+ inputs = tuple(self.inputs)
+ for dependency in inputs:
+ _text(dependency, "dependency name")
+ if len(inputs) != len(set(inputs)):
+ raise GraphError(f"node {self.name!r} contains duplicate dependencies")
+ results = tuple(self.results)
+ if any(not isinstance(result, Result) for result in results):
+ raise GraphError("node results must be Result instances")
+ result_ids = tuple(result.id for result in results)
+ if len(result_ids) != len(set(result_ids)):
+ raise GraphError(f"node {self.name!r} contains duplicate result ids")
+ object.__setattr__(self, "inputs", inputs)
+ object.__setattr__(self, "results", results)
+
+
+@dataclass(frozen=True, slots=True)
+class Graph:
+ """Validated DAG with readable node names and stable logical result ids."""
+
+ nodes: tuple[Node, ...]
+ targets: tuple[str, ...]
+ pools: Mapping[str, int]
+ _by_name: Mapping[str, Node] = field(init=False, repr=False, compare=False)
+ _by_result: Mapping[str, tuple[Node, Result]] = field(init=False, repr=False, compare=False)
+
+ def __post_init__(self) -> None:
+ nodes = tuple(self.nodes)
+ targets = tuple(self.targets)
+ pools = dict(self.pools)
+ if not nodes:
+ raise GraphError("graph must contain at least one node")
+ if not targets:
+ raise GraphError("graph must contain at least one target")
+
+ by_name: dict[str, Node] = {}
+ by_result: dict[str, tuple[Node, Result]] = {}
+ for current in nodes:
+ if not isinstance(current, Node):
+ raise GraphError("graph nodes must be Node instances")
+ if current.name in by_name:
+ raise GraphError(f"duplicate node name: {current.name}")
+ by_name[current.name] = current
+ for result in current.results:
+ if result.id in by_result:
+ raise GraphError(f"duplicate result id: {result.id}")
+ by_result[result.id] = (current, result)
+
+ for name, capacity in pools.items():
+ _text(name, "pool name")
+ if isinstance(capacity, bool) or not isinstance(capacity, int) or capacity <= 0:
+ raise GraphError(f"invalid pool capacity for {name!r}: {capacity!r}")
+ for current in nodes:
+ if current.pool not in pools:
+ raise GraphError(f"node {current.name!r} uses unknown pool {current.pool!r}")
+ for dependency in current.inputs:
+ if dependency not in by_name:
+ raise GraphError(
+ f"node {current.name!r} has unknown dependency {dependency!r}"
+ )
+
+ for target in targets:
+ _text(target, "target node name")
+ if target not in by_name:
+ raise GraphError(f"unknown target node {target!r}")
+ if len(targets) != len(set(targets)):
+ raise GraphError("graph contains duplicate targets")
+
+ dependencies = {
+ name: by_name[name].inputs for name in sorted(by_name)
+ }
+ try:
+ TopologicalSorter(dependencies).prepare()
+ except CycleError as error:
+ cycle = error.args[1] if len(error.args) > 1 else ()
+ raise GraphError("dependency cycle: " + " -> ".join(cycle)) from error
+
+ object.__setattr__(self, "nodes", nodes)
+ object.__setattr__(self, "targets", targets)
+ object.__setattr__(self, "pools", MappingProxyType(pools))
+ object.__setattr__(self, "_by_name", MappingProxyType(by_name))
+ object.__setattr__(self, "_by_result", MappingProxyType(by_result))
+
+ def node(self, name: str) -> Node:
+ try:
+ return self._by_name[name]
+ except KeyError as error:
+ raise GraphError(f"unknown node {name!r}") from error
+
+ def dependencies_of(self, name: str) -> tuple[Node, ...]:
+ return tuple(self.node(dependency) for dependency in self.node(name).inputs)
+
+ def result(self, result_id: str) -> tuple[Node, Result]:
+ try:
+ return self._by_result[result_id]
+ except KeyError as error:
+ raise GraphError(f"unknown result {result_id!r}") from error
+
+
+def merge_graphs(*graphs: Graph) -> Graph:
+ """Union independent graphs while deduplicating byte-identical shared nodes."""
+
+ if not graphs:
+ raise GraphError("graph merge requires at least one graph")
+ nodes: dict[str, Node] = {}
+ targets: dict[str, None] = {}
+ pools: dict[str, int] = {}
+ for graph in graphs:
+ for current in graph.nodes:
+ previous = nodes.get(current.name)
+ if previous is not None and previous != current:
+ raise GraphError(f"conflicting node definition: {current.name}")
+ nodes.setdefault(current.name, current)
+ targets.update((name, None) for name in graph.targets)
+ for name, capacity in graph.pools.items():
+ if name in pools and pools[name] != capacity:
+ raise GraphError(f"conflicting pool capacity for {name}")
+ pools[name] = capacity
+ return Graph(tuple(nodes.values()), tuple(targets), pools)
diff --git a/build/core/host.py b/build/core/host.py
new file mode 100644
index 0000000..19cde1d
--- /dev/null
+++ b/build/core/host.py
@@ -0,0 +1,107 @@
+"""Windows host architecture and local test execution policy."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import platform
+
+
+_MACHINE_ARCHITECTURES = {
+ "amd64": "x64",
+ "x86_64": "x64",
+ "arm64": "arm64",
+ "aarch64": "arm64",
+ "x86": "x86",
+ "i386": "x86",
+ "i686": "x86",
+}
+_RUNNABLE = {
+ "x86": frozenset({"x86"}),
+ "x64": frozenset({"x86", "x64"}),
+ "arm64": frozenset({"x86", "x64", "arm64"}),
+}
+
+
+@dataclass(frozen=True, slots=True)
+class DeferredGate:
+ gate: str
+ architecture: str
+ reason: str
+
+
+@dataclass(frozen=True, slots=True)
+class VerifyRoute:
+ runnable: tuple[str, ...]
+ coverage: tuple[str, ...]
+ asan: tuple[str, ...]
+ ubsan: tuple[str, ...]
+ deferred: tuple[DeferredGate, ...]
+
+ @property
+ def run_x64_specialists(self) -> bool:
+ return bool(self.ubsan)
+
+
+def detect_host_architecture(machine: str | None = None) -> str:
+ value = platform.machine() if machine is None else machine
+ try:
+ return _MACHINE_ARCHITECTURES[value.casefold()]
+ except KeyError as error:
+ raise RuntimeError(f"unsupported Windows host architecture: {value}") from error
+
+
+def runnable_architectures(
+ requested: tuple[str, ...], host_architecture: str | None = None
+) -> tuple[str, ...]:
+ host = detect_host_architecture() if host_architecture is None else host_architecture
+ if host not in _RUNNABLE:
+ raise ValueError(f"unsupported host architecture: {host}")
+ unsupported = set(requested) - _RUNNABLE.keys()
+ if unsupported:
+ raise ValueError(f"unsupported requested architecture: {sorted(unsupported)[0]}")
+ return tuple(architecture for architecture in requested if architecture in _RUNNABLE[host])
+
+
+def require_runnable(
+ requested: tuple[str, ...], host_architecture: str | None = None
+) -> tuple[str, ...]:
+ runnable = runnable_architectures(requested, host_architecture)
+ if runnable != requested:
+ missing = next(architecture for architecture in requested if architecture not in runnable)
+ host = detect_host_architecture() if host_architecture is None else host_architecture
+ raise RuntimeError(f"cannot run {missing} tests on {host} host")
+ return runnable
+
+
+def verify_route(
+ requested: tuple[str, ...], host_architecture: str | None = None
+) -> VerifyRoute:
+ """Route locally executable verify work and preserve explicit deferrals."""
+
+ runnable = runnable_architectures(requested, host_architecture)
+ missing = tuple(item for item in requested if item not in runnable)
+ deferred = [
+ DeferredGate(gate, architecture, f"host cannot execute {architecture} {gate}")
+ for architecture in missing
+ for gate in ("tests", "package-runtime")
+ ]
+ specialists = (
+ ("coverage", ("x64",)),
+ ("asan", ("x86", "x64")),
+ ("ubsan", ("x64",)),
+ ("leaks", ("x64",)),
+ ("fuzz", ("x64",)),
+ )
+ deferred.extend(
+ DeferredGate(gate, architecture, f"host cannot execute {architecture} {gate}")
+ for gate, supported in specialists
+ for architecture in missing
+ if architecture in supported
+ )
+ return VerifyRoute(
+ runnable,
+ tuple(item for item in runnable if item == "x64"),
+ tuple(item for item in runnable if item in {"x86", "x64"}),
+ tuple(item for item in runnable if item == "x64"),
+ tuple(deferred),
+ )
diff --git a/build/core/leak.py b/build/core/leak.py
new file mode 100644
index 0000000..e96c4b1
--- /dev/null
+++ b/build/core/leak.py
@@ -0,0 +1,334 @@
+"""Small process-safe worker for fine-grained UMDH leak nodes."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from filelock import FileLock
+import psutil
+
+
+MODES = ("operations", "lifecycle")
+SCENARIOS = (
+ "small-success", "malformed", "cancellation", "read-failure", "write-failure",
+ "large-metadata", "sparse-metadata",
+)
+BINARIES = ("leak-probe.exe", "renpy.so", "rpgmaker.so", "zanzarah.so")
+
+
+class LeakError(RuntimeError):
+ pass
+
+
+def _output() -> Path:
+ value = os.environ.get("OBSERVER_OUT_DIR")
+ if not value or not (output := Path(value)).is_dir():
+ raise LeakError("OBSERVER_OUT_DIR must be an existing directory")
+ return output
+
+
+def _json(name: str, value: object) -> None:
+ (_output() / name).write_text(
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+def _exact(action: str, args: Sequence[str], count: int) -> tuple[str, ...]:
+ if len(args) != count:
+ raise LeakError(f"{action} expects {count} arguments")
+ return tuple(args)
+
+
+def _file(value: str, name: str) -> Path:
+ path = Path(value)
+ if not path.is_file():
+ raise LeakError(f"{name} was not found: {path}")
+ return path
+
+
+def _selection(mode: str, scenario: str) -> None:
+ if mode not in MODES or scenario not in SCENARIOS:
+ raise LeakError(f"invalid leak selection: {mode}/{scenario}")
+
+
+def _count(value: str, name: str, *, minimum: int = 1) -> int:
+ try:
+ result = int(value)
+ except ValueError as error:
+ raise LeakError(f"{name} must be an integer") from error
+ if result < minimum:
+ raise LeakError(f"{name} must be at least {minimum}")
+ return result
+
+
+def _marker(lines: Sequence[str], marker: str) -> str:
+ prefix = f"OBSERVER_LEAK_PROBE|{marker}|"
+ found = [line for line in lines if line.startswith(prefix)]
+ if len(found) != 1:
+ raise LeakError(f"leak probe emitted {len(found)} {marker} markers")
+ return found[0]
+
+
+def _ready(line: str, mode: str, scenario: str, pid: int | None = None) -> None:
+ expected = rf"^OBSERVER_LEAK_PROBE\|READY\|pid=([0-9]+)\|mode={re.escape(mode)}\|configuration=Release\|scenarios={re.escape(scenario)}$"
+ match = re.fullmatch(expected, line)
+ if match is None or (pid is not None and int(match.group(1)) != pid):
+ raise LeakError("leak READY marker does not match the requested process/selection")
+
+
+def _setup(args: Sequence[str]) -> None:
+ sources = tuple(Path(value) for value in _exact("setup", args, len(BINARIES)))
+ output, evidence = _output(), []
+ for name, source in zip(BINARIES, sources, strict=True):
+ if not source.is_file():
+ raise LeakError(f"leak binary was not found: {source}")
+ destination = output / name
+ shutil.copyfile(source, destination)
+ for symbol in source.parent.glob("*.pdb"):
+ shutil.copyfile(symbol, output / symbol.name)
+ with destination.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ evidence.append({"name": name, "path": str(destination), "sha256": digest})
+ _json("release-binaries.json", {"architecture": "x64", "configuration": "Release", "runtimeLibrary": "MT_StaticRelease", "binaries": evidence})
+
+
+def _preflight(args: Sequence[str]) -> None:
+ directory, mode, scenario = _exact("preflight", args, 3)
+ _selection(mode, scenario)
+ probe = _file(str(Path(directory) / BINARIES[0]), "leak probe")
+ command = [str(probe), "--automatic", "--mode", mode, "--scenario", scenario, "--warmup", "1", "--iterations", "1", "--windows", "3"]
+ result = subprocess.run(command, cwd=directory, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace")
+ if result.returncode:
+ raise LeakError(f"leak preflight failed ({result.returncode}): {result.stdout}")
+ lines = result.stdout.splitlines()
+ ready = _marker(lines, "READY")
+ _ready(ready, mode, scenario)
+ _marker(lines, "DONE")
+ _json("preflight.json", {"mode": mode, "scenario": scenario, "ready": ready})
+
+
+def _read(process: psutil.Popen[str], marker: str, label: str = "") -> str:
+ assert process.stdout is not None
+ prefix = f"OBSERVER_LEAK_PROBE|{marker}|{label + '|' if label else ''}"
+ while line := process.stdout.readline():
+ if line.rstrip("\r\n").startswith(prefix):
+ return line.rstrip("\r\n")
+ raise LeakError(f"leak probe ended before {marker} marker")
+
+
+def _kill_tree(process: psutil.Popen[str]) -> None:
+ processes = [*process.children(recursive=True), process]
+ for current in reversed(processes):
+ try:
+ current.kill()
+ except psutil.NoSuchProcess:
+ pass
+ psutil.wait_procs(processes, timeout=5)
+
+
+def _run_gflags(gflags: Path, image: str, flag: str | None = None) -> subprocess.CompletedProcess[str]:
+ command = [str(gflags), "/i", image]
+ if flag is not None:
+ command.append(flag)
+ return subprocess.run(
+ command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ encoding="utf-8", errors="replace",
+ )
+
+
+def _change_stack_traces(gflags: Path, image: str, enabled: bool) -> None:
+ flag = "+ust" if enabled else "-ust"
+ result = _run_gflags(gflags, image, flag)
+ if result.returncode:
+ raise LeakError(f"GFlags {flag} failed ({result.returncode}): {result.stdout}")
+
+
+def _enable_stack_traces(gflags: Path, image: str) -> bool:
+ if not gflags.is_file():
+ return False
+ current = _run_gflags(gflags, image)
+ if current.returncode:
+ raise LeakError(f"GFlags query failed ({current.returncode}): {current.stdout}")
+ match = re.search(
+ r"are:\s*([0-9A-Fa-f]{8}(?:\s*:\s*[0-9A-Fa-f]{8})*)\s*$",
+ current.stdout,
+ )
+ if current.stdout.startswith("No Registry Settings for "):
+ flags = 0
+ elif match is not None:
+ flags = 0
+ for value in match.group(1).split(":"):
+ flags |= int(value.strip(), 16)
+ else:
+ raise LeakError(f"GFlags returned an unrecognized setting: {current.stdout}")
+ if flags & 0x1000:
+ return False
+ try:
+ _change_stack_traces(gflags, image, True)
+ except OSError as error:
+ if getattr(error, "winerror", None) != 740:
+ raise
+ return False
+ return True
+
+
+def _snapshot(
+ umdh: Path, pid: int, destination: Path, baseline: bool, environment: dict[str, str]
+) -> None:
+ result = subprocess.run(
+ [str(umdh), f"-p:{pid}", f"-f:{destination}"],
+ env=environment,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ errors="replace",
+ )
+ text = destination.read_text(encoding="utf-8", errors="replace") if destination.is_file() else ""
+ if baseline:
+ if result.returncode not in (0, 1) or (result.returncode == 1 and "enabled allocation stack collection" not in text):
+ raise LeakError(f"UMDH could not prime stack collection ({result.returncode}): {result.stdout}")
+ elif result.returncode or re.search(r"didn't find any allocations|database is full|stack trace database.*full", text, re.I) or "BackTrace" not in text:
+ raise LeakError(f"UMDH snapshot is unusable: {destination}")
+
+
+def _capture(args: Sequence[str]) -> None:
+ directory, umdh_value, mode, scenario, warmup_value, iterations_value, windows_value = _exact("capture", args, 7)
+ _selection(mode, scenario)
+ probe, umdh = _file(str(Path(directory) / BINARIES[0]), "leak probe"), _file(umdh_value, "UMDH")
+ gflags = umdh.with_name("gflags.exe")
+ warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3)
+ output, snapshot_dir = _output(), _output() / "snapshots"
+ snapshot_dir.mkdir()
+ environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"}
+ command = [str(probe), "--mode", mode, "--scenario", scenario, "--warmup", str(warmup), "--iterations", str(iterations), "--windows", str(windows)]
+ error_path = output / "probe.stderr.log"
+ process: psutil.Popen[str] | None = None
+ with error_path.open("w+", encoding="utf-8") as errors:
+ try:
+ lock = FileLock(Path(tempfile.gettempdir()) / "observer-modules-gflags.lock")
+ with lock:
+ changed = _enable_stack_traces(gflags, probe.name)
+ try:
+ process = psutil.Popen(command, cwd=directory, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, text=True, encoding="utf-8", errors="replace")
+ finally:
+ if changed:
+ _change_stack_traces(gflags, probe.name, False)
+ assert process is not None
+ _ready(_read(process, "READY"), mode, scenario, process.pid)
+ for label in ("baseline", *(f"window-{index}" for index in range(1, windows + 1))):
+ line = _read(process, "SNAPSHOT", label)
+ if not re.search(rf"\|pid={process.pid}\|", line):
+ raise LeakError("leak SNAPSHOT marker has the wrong PID")
+ _snapshot(
+ umdh,
+ process.pid,
+ snapshot_dir / f"{label}.txt",
+ label == "baseline",
+ environment,
+ )
+ assert process.stdin is not None
+ process.stdin.write(f"continue|{label}\n")
+ process.stdin.flush()
+ _read(process, "DONE")
+ try:
+ result = process.wait(timeout=120)
+ except psutil.TimeoutExpired as error:
+ raise LeakError("leak probe did not exit after its final snapshot") from error
+ if result:
+ errors.flush(); errors.seek(0)
+ raise LeakError(f"leak probe failed ({result}): {errors.read()}")
+ finally:
+ if process is not None:
+ if process.poll() is None:
+ _kill_tree(process)
+ assert process.stdin is not None and process.stdout is not None
+ process.stdin.close()
+ process.stdout.close()
+ assert process is not None
+ _json("capture.json", {"mode": mode, "scenario": scenario, "processId": process.pid, "windows": windows})
+
+
+def _diff(args: Sequence[str]) -> None:
+ umdh_value, directory, label, before, after = _exact("diff", args, 5)
+ report = _output() / "report.txt"
+ environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"}
+ result = subprocess.run([umdh_value, "-d", before, after, f"-f:{report}"], env=environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace")
+ if result.returncode or not report.is_file():
+ raise LeakError(f"UMDH comparison failed ({result.returncode}): {result.stdout}")
+ text = report.read_text(encoding="utf-8", errors="replace")
+ totals = list(re.finditer(r"^Total (increase|decrease)\s*==\s*([0-9]+)", text, re.M))
+ if not totals:
+ raise LeakError("UMDH comparison contains no total allocation delta")
+ total = int(totals[-1].group(2)) * (-1 if totals[-1].group(1) == "decrease" else 1)
+ stacks = {match.group(2): int(match.group(1)) for match in re.finditer(r"^\+\s+([0-9]+)\s+\([^)]*\)\s+[0-9]+\s+allocs\s+BackTrace\s*([0-9A-Fa-f]+)", text, re.M)}
+ _json("diff.json", {"label": label, "totalIncrease": total, "positiveStacks": stacks, "report": "report.txt"})
+
+
+def _summary(args: Sequence[str]) -> dict[str, object]:
+ if len(args) < 8 or len(args) % 2:
+ raise LeakError("judge expects settings followed by label/report pairs")
+ mode, scenario, warmup_value, iterations_value, windows_value, tolerance_value, *pairs = args
+ _selection(mode, scenario)
+ warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3)
+ tolerance = _count(tolerance_value, "tolerance", minimum=0)
+ labels, records = pairs[::2], [json.loads(Path(path).read_text(encoding="utf-8")) for path in pairs[1::2]]
+ expected = [*(f"window-{index}" for index in range(1, windows)), "overall"]
+ if labels != expected or [record.get("label") for record in records] != expected:
+ raise LeakError("judge comparison labels do not match the measurement windows")
+ previous, last, overall = records[-3], records[-2], records[-1]
+ repeated = sorted(key for key, value in last["positiveStacks"].items() if value > tolerance and previous["positiveStacks"].get(key, 0) > tolerance)
+ sustained = last["totalIncrease"] > tolerance and previous["totalIncrease"] > tolerance and overall["totalIncrease"] > 2 * tolerance
+ summary = {"mode": mode, "scenario": scenario, "warmupRounds": warmup, "iterationsPerWindow": iterations, "windows": windows, "toleranceBytes": tolerance, "totalGrowthByWindow": [record["totalIncrease"] for record in records[:-1]], "overallGrowthBytes": overall["totalIncrease"], "repeatedGrowingStacks": repeated, "passed": not sustained and not repeated}
+ _json("summary.json", summary)
+ return summary
+
+
+def _summarize(args: Sequence[str]) -> None:
+ _summary(args)
+
+
+def _judge(args: Sequence[str]) -> None:
+ summary = _summary(args)
+ if not summary["passed"]:
+ raise LeakError("UMDH found sustained heap growth")
+
+
+def _gate(args: Sequence[str]) -> None:
+ (path,) = _exact("gate", args, 1)
+ try:
+ passed = json.loads(Path(path).read_text(encoding="utf-8"))["passed"]
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
+ raise LeakError("leak summary is invalid") from error
+ if not isinstance(passed, bool):
+ raise LeakError("leak summary is invalid")
+ if not passed:
+ raise LeakError("UMDH found sustained heap growth")
+
+
+_ACTIONS = {
+ "setup": _setup, "preflight": _preflight, "capture": _capture,
+ "diff": _diff, "summarize": _summarize, "gate": _gate, "judge": _judge,
+}
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ if not arguments or arguments[0] not in _ACTIONS:
+ raise LeakError("expected leak action: setup, preflight, capture, diff, or judge")
+ _ACTIONS[arguments[0]](arguments[1:])
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/node.py b/build/core/node.py
new file mode 100644
index 0000000..8798e47
--- /dev/null
+++ b/build/core/node.py
@@ -0,0 +1,68 @@
+"""Create signed graph nodes from rendered repository recipes."""
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+
+from core.graph import Node, Result
+from core.recipe import Recipe
+from core.render import TemplateRenderer
+from core.sign import content_uid
+
+
+@dataclass(frozen=True, slots=True)
+class NodeFactory:
+ renderer: TemplateRenderer
+ cwd: Path
+ identity: dict[str, str]
+ environment: tuple[tuple[str, str], ...] = ()
+
+ def make(
+ self,
+ template: str,
+ name: str,
+ pool: str,
+ variables: dict[str, object],
+ *,
+ files: dict[str, bytes],
+ dependencies: tuple[Node, ...] = (),
+ results: tuple[Result, ...] = (),
+ config: dict[str, str],
+ identity: dict[str, str] | None = None,
+ environment: tuple[tuple[str, str], ...] | None = None,
+ cwd: Path | None = None,
+ ) -> Node:
+ descriptor = {
+ "name": name,
+ "pool": pool,
+ "inputs": [node.name for node in dependencies],
+ } | variables
+ descriptor["results"] = [
+ {
+ "id": result.id,
+ "kind": result.kind,
+ "media_type": result.media_type,
+ "path": result.relative_path,
+ }
+ for result in results
+ ]
+ rendered = self.renderer.render(template, descriptor)
+ node_environment = self.environment if environment is None else environment
+ node_cwd = cwd or self.cwd
+ runtime = json.dumps(
+ {"cwd": str(node_cwd), "environment": node_environment},
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+ uid = content_uid(
+ recipe=rendered,
+ inputs=files,
+ dependencies={node.name: node.uid for node in dependencies},
+ toolchain=identity or self.identity,
+ config=dict(config) | {"runtime": runtime},
+ )
+ return Recipe.parse(rendered).to_node(
+ uid=uid,
+ env=node_environment,
+ cwd=str(node_cwd),
+ )
diff --git a/build/core/package.py b/build/core/package.py
new file mode 100644
index 0000000..ea71847
--- /dev/null
+++ b/build/core/package.py
@@ -0,0 +1,224 @@
+"""Deterministic staging and ZIP creation for release packages."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+from collections.abc import Sequence
+import zipfile
+
+
+ARCHITECTURES = ("x86", "x64", "arm64")
+LICENSES = {
+ "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"),
+ "rpgmaker": ("Observer.txt", "rgssad.txt"),
+ "zanzarah": ("Observer.txt", "zanzapak.txt"),
+}
+MODULES = tuple(LICENSES)
+_ZIP_TIME = (1980, 1, 1, 0, 0, 0)
+
+
+class PackageError(RuntimeError):
+ pass
+
+
+def _output() -> Path:
+ value = os.environ.get("OBSERVER_OUT_DIR")
+ if not value or not (output := Path(value)).is_dir():
+ raise PackageError("OBSERVER_OUT_DIR must be an existing directory")
+ return output
+
+
+def _sha256(path: Path) -> str:
+ with path.open("rb") as stream:
+ return hashlib.file_digest(stream, "sha256").hexdigest()
+
+
+def _payload_files(payload: Path) -> dict[str, Path]:
+ return {
+ path.relative_to(payload).as_posix(): path
+ for path in sorted(payload.rglob("*"))
+ if path.is_file()
+ }
+
+
+def _entries(files: dict[str, Path]) -> list[dict[str, str]]:
+ return [{"name": name, "sha256": _sha256(path)} for name, path in sorted(files.items())]
+
+
+def _document(kind: str, architecture: str, module: str, payload: Path) -> dict[str, object]:
+ return {
+ "architecture": architecture,
+ "entries": _entries(_payload_files(payload)),
+ "kind": kind,
+ "module": module,
+ }
+
+
+def _write_json(path: Path, value: object) -> None:
+ path.write_text(
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+def _stage(args: argparse.Namespace, kind: str, files: dict[str, Path]) -> None:
+ output = _output()
+ payload = output / "payload"
+ for name, source in files.items():
+ destination = payload / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, destination)
+ _write_json(output / "manifest.json", _document(kind, args.architecture, args.module, payload))
+
+
+def _stage_module(args: argparse.Namespace) -> None:
+ repository = args.repository
+ files = {
+ f"{args.module}.so": args.binary,
+ "observer_user.ini": repository / f"src/modules/{args.module}/observer_user.ini",
+ "docs/license.txt": repository / "LICENSE.txt",
+ } | {f"docs/thirdparty/{name}": repository / "licenses" / name for name in LICENSES[args.module]}
+ _stage(args, "module", files)
+
+
+def _stage_symbol(args: argparse.Namespace) -> None:
+ _stage(args, "symbols", {f"{args.module}.pdb": args.symbol})
+
+
+def _stage_payload(stage: Path, kind: str, architecture: str, module: str) -> dict[str, Path]:
+ payload = stage / "payload"
+ expected = _document(kind, architecture, module, payload)
+ actual = json.loads((stage / "manifest.json").read_text(encoding="utf-8"))
+ if actual != expected:
+ raise PackageError(f"{kind} stage manifest does not match its payload")
+ return _payload_files(payload)
+
+
+def _zip(destination: Path, files: dict[str, Path]) -> None:
+ with zipfile.ZipFile(destination, "w") as archive:
+ for name, path in sorted(files.items()):
+ information = zipfile.ZipInfo(name, _ZIP_TIME)
+ information.compress_type = zipfile.ZIP_DEFLATED
+ information.create_system = 3
+ information.external_attr = 0o100644 << 16
+ with path.open("rb") as source, archive.open(information, "w") as target:
+ shutil.copyfileobj(source, target, 1024 * 1024)
+
+
+def _archive_module(args: argparse.Namespace) -> None:
+ files = _stage_payload(args.stage, "module", args.architecture, args.module)
+ _zip(_output() / f"{args.module}-{args.architecture}-dll.zip", files)
+
+
+def _archive_symbols(args: argparse.Namespace) -> None:
+ _zip(_output() / f"observer-modules-{args.architecture}-pdb.zip", _symbol_payload(args))
+
+
+def _symbol_payload(args: argparse.Namespace) -> dict[str, Path]:
+ documents = [json.loads((stage / "manifest.json").read_text(encoding="utf-8")) for stage in args.stages]
+ modules = [document.get("module") for document in documents]
+ if set(modules) != set(MODULES) or len(modules) != len(MODULES):
+ raise PackageError("symbols archive requires the expected module set")
+ files = {}
+ for module, stage in sorted(zip(modules, args.stages, strict=True)):
+ files.update(_stage_payload(stage, "symbols", args.architecture, str(module)))
+ return files
+
+
+def _archive_entries(path: Path) -> list[dict[str, str]]:
+ try:
+ with zipfile.ZipFile(path) as archive:
+ members = archive.infolist()
+ if len(members) != len({member.filename for member in members}):
+ raise PackageError("package archive does not match exact stage manifests")
+ entries = []
+ for member in sorted(members, key=lambda item: item.filename):
+ with archive.open(member) as stream:
+ entries.append({"name": member.filename, "sha256": hashlib.file_digest(stream, "sha256").hexdigest()})
+ return entries
+ except (OSError, zipfile.BadZipFile, RuntimeError) as error:
+ raise PackageError("package archive does not match exact stage manifests") from error
+
+
+def _validate(archive: Path, files: dict[str, Path]) -> None:
+ entries = _archive_entries(archive)
+ if entries != _entries(files):
+ raise PackageError("package archive does not match exact stage manifests")
+ _write_json(_output() / "validation.json", {"entries": entries, "name": archive.name, "sha256": _sha256(archive)})
+
+
+def _validate_module(args: argparse.Namespace) -> None:
+ _validate(args.archive, _stage_payload(args.stage, "module", args.architecture, args.module))
+
+
+def _validate_symbols(args: argparse.Namespace) -> None:
+ _validate(args.archive, _symbol_payload(args))
+
+
+def _aggregate(args: argparse.Namespace) -> None:
+ names = [archive.name for archive in args.archives]
+ if len(names) != len(set(names)):
+ raise PackageError("duplicate archive name in package manifest")
+ output = _output()
+ for archive in sorted(args.archives, key=lambda path: path.name):
+ shutil.copyfile(archive, output / archive.name)
+ _write_json(
+ output / "packages.json",
+ [
+ {"name": archive.name, "sha256": _sha256(archive)}
+ for archive in sorted(args.archives, key=lambda path: path.name)
+ ],
+ )
+
+
+def _smoke(args: argparse.Namespace) -> None:
+ output = _output()
+ try:
+ with zipfile.ZipFile(args.archive) as archive:
+ module = Path(archive.extract(f"{args.module}.so", output))
+ except KeyError as error:
+ raise PackageError(f"package archive has no {args.module}.so") from error
+ subprocess.run(
+ [str(args.tests), "[package-smoke]", "--reporter", "compact", "--rng-seed", "1"],
+ check=True, cwd=output,
+ env=os.environ | {"OBSERVER_PACKAGE_MODULE": str(module), "OBSERVER_PACKAGE_FORMAT": args.module},
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(required=True)
+ actions = (
+ ("stage-module", ("architecture", "module", "binary", "repository"), _stage_module),
+ ("stage-symbol", ("architecture", "module", "symbol"), _stage_symbol),
+ ("archive-module", ("architecture", "module", "stage"), _archive_module),
+ ("archive-symbols", ("architecture", "stages"), _archive_symbols),
+ ("validate-module", ("architecture", "module", "archive", "stage"), _validate_module),
+ ("validate-symbols", ("architecture", "archive", "stages"), _validate_symbols),
+ ("aggregate", ("archives",), _aggregate),
+ ("smoke", ("architecture", "module", "archive", "tests"), _smoke),
+ )
+ for command, names, action in actions:
+ current = commands.add_parser(command)
+ for name in names:
+ choices = ARCHITECTURES if name == "architecture" else MODULES if name == "module" else None
+ current.add_argument(
+ name,
+ choices=choices,
+ nargs="+" if name in {"stages", "archives"} else None,
+ type=None if choices else Path,
+ )
+ current.set_defaults(run=action)
+ args = parser.parse_args(argv)
+ args.run(args)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/paths.py b/build/core/paths.py
new file mode 100644
index 0000000..34d6bc4
--- /dev/null
+++ b/build/core/paths.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import os
+import re
+import stat
+from dataclasses import dataclass
+from pathlib import Path
+
+_UID_PATTERN = re.compile(r"[0-9a-f]{32}\Z")
+_RUN_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
+
+
+class PathSafetyError(ValueError):
+ """A build path did not satisfy the output confinement policy."""
+
+
+@dataclass(frozen=True)
+class CasPaths:
+ entry: Path
+ output: Path
+ touch: Path
+ log: Path
+
+
+def _is_reparse(path: Path) -> bool:
+ try:
+ information = os.lstat(path)
+ except FileNotFoundError:
+ return False
+ attributes = getattr(information, "st_file_attributes", 0)
+ reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+ return stat.S_ISLNK(information.st_mode) or bool(attributes & reparse_attribute)
+
+
+def _absolute(path: Path) -> Path:
+ return Path(os.path.abspath(os.fspath(path)))
+
+
+def _is_within(path: Path, root: Path) -> bool:
+ return path == root or root in path.parents
+
+
+class BuildPaths:
+ """Derive and validate the repository-local ``out/{cas,work}`` layout."""
+
+ def __init__(self, repository: Path | str) -> None:
+ repository_path = _absolute(Path(repository))
+ if not repository_path.is_dir() or _is_reparse(repository_path):
+ raise PathSafetyError("repository must be an existing directory, not a reparse point")
+
+ self.repository = repository_path
+ self.output_root = repository_path / "out"
+ self.cas_root = self.output_root / "cas"
+ self.work_root = self.output_root / "work"
+ self.locks_root = self.work_root / ".locks"
+
+ def prepare(self) -> None:
+ for path, root in (
+ (self.output_root, self.output_root),
+ (self.cas_root, self.cas_root),
+ (self.work_root, self.work_root),
+ (self.locks_root, self.work_root),
+ ):
+ self.require_confined(path, root).mkdir(exist_ok=True)
+
+ def cas(self, uid: str) -> CasPaths:
+ self._require_uid(uid)
+ entry = self.require_confined(self.cas_root / uid, self.cas_root)
+ paths = CasPaths(
+ entry=entry,
+ output=entry / "out",
+ touch=entry / "touch",
+ log=entry / "log.txt",
+ )
+ for path in (paths.output, paths.touch, paths.log):
+ self._reject_existing_reparse_points(path)
+ return paths
+
+ def run_work(self, run_identifier: str) -> Path:
+ if _RUN_PATTERN.fullmatch(run_identifier) is None:
+ raise PathSafetyError(f"invalid run identifier: {run_identifier!r}")
+ return self.require_confined(self.work_root / run_identifier, self.work_root)
+
+ def lock(self, uid: str) -> Path:
+ self._require_uid(uid)
+ return self.require_confined(self.locks_root / f"{uid}.lock", self.work_root)
+
+ def coordination_lock(self) -> Path:
+ return self.require_confined(self.locks_root / "coordination.lock", self.work_root)
+
+ def lease(self, run_identifier: str) -> Path:
+ self.run_work(run_identifier)
+ return self.require_confined(
+ self.locks_root / f"run-{run_identifier}.lease", self.work_root
+ )
+
+ def require_confined(self, candidate: Path | str, allowed_root: Path | str) -> Path:
+ path = _absolute(Path(candidate))
+ root = _absolute(Path(allowed_root))
+ if root not in (self.output_root, self.cas_root, self.work_root):
+ raise PathSafetyError(f"unexpected allowed root: {root}")
+ if not _is_within(path, root):
+ raise PathSafetyError(f"path is outside allowed root: {path}")
+ self._reject_existing_reparse_points(path)
+ return path
+
+ def _reject_existing_reparse_points(self, path: Path) -> None:
+ if not _is_within(path, self.repository):
+ raise PathSafetyError(f"path is outside repository: {path}")
+ current = path
+ while True:
+ if _is_reparse(current):
+ raise PathSafetyError(f"reparse point is forbidden in build path: {current}")
+ if current == self.repository:
+ return
+ current = current.parent
+
+ @staticmethod
+ def _require_uid(uid: str) -> None:
+ if _UID_PATTERN.fullmatch(uid) is None:
+ raise PathSafetyError(f"UID must be 32 lowercase hexadecimal characters: {uid!r}")
diff --git a/build/core/python_coverage.py b/build/core/python_coverage.py
new file mode 100644
index 0000000..841ab01
--- /dev/null
+++ b/build/core/python_coverage.py
@@ -0,0 +1,56 @@
+"""Run the project-local coverage.py gate and publish its native evidence."""
+
+from __future__ import annotations
+
+import argparse
+import os
+from pathlib import Path
+import shutil
+import subprocess
+from collections.abc import Sequence
+
+
+def _directory(name: str) -> Path:
+ value = os.environ.get(name)
+ if not value or not (path := Path(value)).is_dir():
+ raise RuntimeError(f"{name} must name an existing directory")
+ return path.resolve()
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("coverage", type=Path)
+ parser.add_argument("build_root", type=Path)
+ args = parser.parse_args(argv)
+ coverage = args.coverage.resolve(strict=True)
+ build_root = args.build_root.resolve(strict=True)
+ config = build_root / "pyproject.toml"
+ if not coverage.is_file() or not build_root.is_dir() or not config.is_file():
+ raise FileNotFoundError("coverage executable, build root, and config must exist")
+
+ work, output = _directory("OBSERVER_BUILD_DIR"), _directory("OBSERVER_OUT_DIR")
+ data = work / ".coverage"
+ command = [str(coverage)]
+ subprocess.run(command + ["run", "--rcfile", str(config), "--data-file", str(data),
+ "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"],
+ cwd=build_root, check=True)
+ report = subprocess.run(
+ command + ["report", "--rcfile", str(config), "--data-file", str(data), "--fail-under=100"],
+ cwd=build_root, check=False, stdout=subprocess.PIPE, text=True,
+ )
+ (output / "coverage.txt").write_text(report.stdout, encoding="utf-8")
+ subprocess.run(command + ["json", "--rcfile", str(config), "--data-file", str(data),
+ "--fail-under=0", "-o", str(output / "coverage.json")],
+ cwd=build_root, check=True)
+ subprocess.run(command + ["xml", "--rcfile", str(config), "--data-file", str(data),
+ "--fail-under=0", "-o", str(output / "coverage.xml")],
+ cwd=build_root, check=True)
+ shutil.copyfile(config, output / "coverage.toml")
+ shutil.copyfile(data, output / data.name)
+ print(report.stdout, end="")
+ report.check_returncode()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/quality_tools.py b/build/core/quality_tools.py
new file mode 100644
index 0000000..5ecff94
--- /dev/null
+++ b/build/core/quality_tools.py
@@ -0,0 +1,175 @@
+"""Resolve immutable identities for optional local quality tools without installing them."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import hashlib
+import os
+from pathlib import Path
+import shutil
+
+from core.toolchain import MsvcToolchain
+
+
+@dataclass(frozen=True, slots=True)
+class ResolvedTool:
+ path: Path
+ identity: tuple[tuple[str, str], ...]
+
+
+@dataclass(frozen=True, slots=True)
+class ResolvedDirectory:
+ path: Path
+ files: tuple[ResolvedTool, ...]
+
+ @property
+ def identity(self) -> tuple[tuple[str, str], ...]:
+ return (("path", str(self.path)),) + tuple(
+ (f"{tool.path.name}.{key}", value)
+ for tool in self.files
+ for key, value in tool.identity
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class QualityTools:
+ clang_cl: ResolvedTool
+ clang_scan_deps: ResolvedTool
+ llvm_cov: ResolvedTool
+ llvm_profdata: ResolvedTool
+ dumpbin: ResolvedTool
+ binskim: ResolvedTool
+ umdh: ResolvedTool
+
+
+@dataclass(frozen=True, slots=True)
+class SanitizerRuntimes:
+ asan_x86: ResolvedTool
+ asan_x64: ResolvedTool
+ ubsan: ResolvedDirectory
+
+
+_LLVM_TOOLS = frozenset(("clang-cl", "clang-scan-deps", "llvm-cov", "llvm-profdata"))
+UBSAN_LIBRARIES = (
+ "clang_rt.ubsan_standalone-x86_64.lib",
+ "clang_rt.ubsan_standalone_cxx-x86_64.lib",
+)
+_ASAN_RUNTIMES = {
+ "x86": "clang_rt.asan_dynamic-i386.dll",
+ "x64": "clang_rt.asan_dynamic-x86_64.dll",
+}
+
+
+def resolve_tool(path: Path | str | None, name: str) -> ResolvedTool:
+ try:
+ resolved = Path(path).resolve(strict=True) if path else None
+ except (OSError, RuntimeError) as error:
+ raise FileNotFoundError(f"missing {name}: {path}") from error
+ if resolved is None or not resolved.is_file():
+ raise FileNotFoundError(f"missing {name}: {path}")
+ with resolved.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ return ResolvedTool(resolved, (("path", str(resolved)), ("sha256", digest)))
+
+
+def _identity_value(toolchain: MsvcToolchain, key: str, name: str) -> str:
+ value = dict(toolchain.identity).get(key)
+ if not value:
+ raise FileNotFoundError(f"missing {name} identity")
+ return value
+
+
+def resolve_llvm(toolchain: MsvcToolchain, name: str) -> ResolvedTool:
+ if name not in _LLVM_TOOLS:
+ raise ValueError(f"unsupported LLVM tool: {name}")
+ return resolve_tool(toolchain.llvm_dir / f"bin/{name}.exe", name)
+
+
+def resolve_dumpbin(toolchain: MsvcToolchain) -> ResolvedTool:
+ version = _identity_value(toolchain, "vc_tools_version", "MSVC vc_tools_version")
+ path = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64/x64/dumpbin.exe"
+ return resolve_tool(path, "dumpbin")
+
+
+def resolve_binskim() -> ResolvedTool:
+ return resolve_tool(shutil.which("binskim"), "BinSkim")
+
+
+def resolve_umdh() -> ResolvedTool:
+ override = os.environ.get("OBSERVER_UMDH")
+ if override:
+ path = Path(override)
+ if not path.is_file():
+ raise FileNotFoundError(f"missing UMDH override: {path}")
+ return resolve_tool(path, "UMDH")
+ program_files = os.environ.get("ProgramFiles(x86)")
+ candidates = (() if not program_files else tuple(
+ Path(program_files) / f"Windows Kits/{version}/Debuggers/x64/umdh.exe"
+ for version in ("11", "10")
+ ))
+ for candidate in candidates:
+ if candidate.is_file():
+ return resolve_tool(candidate, "UMDH")
+ searched = ", ".join(map(str, candidates)) or "ProgramFiles(x86) is unset"
+ raise FileNotFoundError(f"missing UMDH: {searched}")
+
+
+def resolve_asan_runtimes(
+ toolchain: MsvcToolchain, architectures: tuple[str, ...]
+) -> tuple[tuple[str, ResolvedTool], ...]:
+ """Resolve only the requested MSVC ASan runtime DLLs."""
+
+ unsupported = next(
+ (architecture for architecture in architectures if architecture not in _ASAN_RUNTIMES),
+ None,
+ )
+ if unsupported is not None:
+ raise ValueError(f"unsupported ASan architecture: {unsupported}")
+ msvc = _identity_value(toolchain, "vc_tools_version", "MSVC vc_tools_version")
+ asan = toolchain.installation / f"VC/Tools/MSVC/{msvc}/bin/Hostx64"
+ return tuple(
+ (
+ architecture,
+ resolve_tool(
+ asan / architecture / _ASAN_RUNTIMES[architecture],
+ f"MSVC ASan {architecture} runtime",
+ ),
+ )
+ for architecture in architectures
+ )
+
+
+def resolve_ubsan_runtime(toolchain: MsvcToolchain) -> ResolvedDirectory:
+ """Resolve only the LLVM x64 UBSan archive pair."""
+
+ llvm = _identity_value(toolchain, "clang_tidy_version", "LLVM clang_tidy_version")
+ root = toolchain.llvm_dir / "lib/clang"
+ candidates = tuple(root / version / "lib/windows" for version in dict.fromkeys((llvm, llvm.split(".")[0])))
+ directory = next((candidate.resolve() for candidate in candidates if candidate.is_dir()), None)
+ if directory is None:
+ raise FileNotFoundError(f"missing UBSan runtime directory: {', '.join(map(str, candidates))}")
+ files = tuple(resolve_tool(directory / name, f"UBSan runtime {name}") for name in UBSAN_LIBRARIES)
+ return ResolvedDirectory(directory, files)
+
+
+def resolve_sanitizer_runtimes(toolchain: MsvcToolchain) -> SanitizerRuntimes:
+ """Resolve every sanitizer runtime required by doctor and aggregate verification."""
+
+ asan = dict(resolve_asan_runtimes(toolchain, ("x86", "x64")))
+ return SanitizerRuntimes(
+ asan["x86"], asan["x64"], resolve_ubsan_runtime(toolchain)
+ )
+
+
+def discover_quality_tools(toolchain: MsvcToolchain) -> QualityTools:
+ """Resolve the exact quality-tool files expected by coverage, sanitizer, audit, and leak graphs."""
+
+ return QualityTools(
+ clang_cl=resolve_llvm(toolchain, "clang-cl"),
+ clang_scan_deps=resolve_llvm(toolchain, "clang-scan-deps"),
+ llvm_cov=resolve_llvm(toolchain, "llvm-cov"),
+ llvm_profdata=resolve_llvm(toolchain, "llvm-profdata"),
+ dumpbin=resolve_dumpbin(toolchain),
+ binskim=resolve_binskim(),
+ umdh=resolve_umdh(),
+ )
diff --git a/build/core/recipe.py b/build/core/recipe.py
new file mode 100644
index 0000000..0c9cd23
--- /dev/null
+++ b/build/core/recipe.py
@@ -0,0 +1,76 @@
+"""Bridge trusted repository-rendered JSON recipes to graph nodes."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+import json
+
+from core.graph import Command, GraphError, Node, Result
+
+
+class RecipeError(ValueError):
+ """A required repository recipe field is missing or malformed."""
+
+
+@dataclass(frozen=True, slots=True)
+class Recipe:
+ name: str
+ pool: str
+ inputs: tuple[str, ...]
+ argv: tuple[str, ...]
+ data: bytes
+ results: tuple[Result, ...] = ()
+
+ @classmethod
+ def parse(cls, rendered: str | bytes) -> Recipe:
+ """Read only the fields emitted by the repository templates."""
+
+ try:
+ document = json.loads(rendered)
+ script = document["script"]
+ declared_results = document["results"]
+ if not isinstance(declared_results, list):
+ raise TypeError("results must be a list")
+ return cls(
+ name=document["name"],
+ pool=document["pool"],
+ inputs=tuple(document["inputs"]),
+ argv=tuple(script["exec"]),
+ data=script["data"].encode("utf-8"),
+ results=tuple(
+ Result(
+ result["id"],
+ result["kind"],
+ result["media_type"],
+ result["path"],
+ )
+ for result in declared_results
+ ),
+ )
+ except (
+ json.JSONDecodeError,
+ UnicodeDecodeError,
+ KeyError,
+ TypeError,
+ AttributeError,
+ GraphError,
+ ) as error:
+ raise RecipeError("rendered recipe is missing required JSON fields") from error
+
+ def to_node(
+ self,
+ *,
+ uid: str,
+ env: Mapping[str, str] | Iterable[tuple[str, str]] = (),
+ cwd: str | None = None,
+ ) -> Node:
+ environment = env.items() if isinstance(env, Mapping) else env
+ return Node(
+ name=self.name,
+ uid=uid,
+ pool=self.pool,
+ command=Command(self.argv, tuple(environment), cwd, self.data),
+ inputs=self.inputs,
+ results=self.results,
+ )
diff --git a/build/core/render.py b/build/core/render.py
new file mode 100644
index 0000000..aa1bfdf
--- /dev/null
+++ b/build/core/render.py
@@ -0,0 +1,55 @@
+"""Deterministic rendering for inherited build recipes."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+import jinja2
+
+
+def ps_quote(value: object) -> str:
+ """Return *value* as one PowerShell single-quoted string literal."""
+
+ text = str(value)
+ return "'" + text.replace("'", "''") + "'"
+
+
+def _json(value: object) -> str:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ allow_nan=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+
+
+class TemplateRenderer:
+ """Render flat, inherited Jinja recipes with undefined values forbidden."""
+
+ def __init__(self, template_dir: Path) -> None:
+ root = template_dir.resolve(strict=True)
+ if not root.is_dir():
+ raise NotADirectoryError(root)
+
+ self._environment = jinja2.Environment(
+ loader=jinja2.FileSystemLoader(root),
+ undefined=jinja2.StrictUndefined,
+ autoescape=False,
+ auto_reload=False,
+ trim_blocks=True,
+ lstrip_blocks=True,
+ keep_trailing_newline=True,
+ newline_sequence="\n",
+ )
+ self._environment.filters["json"] = _json
+ self._environment.filters["ps_quote"] = ps_quote
+
+ def render(self, template_name: str, variables: Mapping[str, Any]) -> str:
+ """Render *template_name* using an explicit variable mapping."""
+
+ template = self._environment.get_template(template_name)
+ return template.render(dict(variables))
diff --git a/build/core/result_export.py b/build/core/result_export.py
new file mode 100644
index 0000000..0218e66
--- /dev/null
+++ b/build/core/result_export.py
@@ -0,0 +1,290 @@
+"""Atomically publish declared graph results without exposing CAS paths."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import stat
+import tempfile
+from typing import Any, Iterable, Mapping
+
+from core.graph import Graph, Node, Result
+from core.store import CasStore
+
+
+_COMMAND = re.compile(r"[a-z0-9][a-z0-9._-]*")
+_STATUSES = {"success", "failed"}
+_PACKAGE_PREFIX = "packages/"
+_RESERVED = ("manifest.json", "logs", "packages")
+_PACKAGE_RESERVED = ("manifest.json",)
+
+
+class ResultExportError(RuntimeError):
+ """Declared results could not be published safely and completely."""
+
+
+def _lstat(path: Path) -> os.stat_result | None:
+ try:
+ return os.lstat(path)
+ except FileNotFoundError:
+ return None
+
+
+def _is_reparse(path: Path) -> bool:
+ information = os.lstat(path)
+ attributes = getattr(information, "st_file_attributes", 0)
+ reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+ return stat.S_ISLNK(information.st_mode) or bool(attributes & reparse_attribute)
+
+
+def _checked(path: Path) -> os.stat_result:
+ information = _lstat(path)
+ if information is None:
+ raise ResultExportError(f"declared result is missing: {path.name}")
+ if _is_reparse(path):
+ raise ResultExportError(f"reparse points are forbidden in exported results: {path.name}")
+ return information
+
+
+def _source(output: Path, result: Result) -> tuple[Path, os.stat_result]:
+ parts = result.relative_path.split("/")
+ current = output / parts[0]
+ information = _checked(current)
+ for part in parts[1:]:
+ if not stat.S_ISDIR(information.st_mode):
+ raise ResultExportError(f"declared result path is not a directory: {current.name}")
+ current /= part
+ information = _checked(current)
+ return current, information
+
+
+def _digest_file(path: Path) -> tuple[int, str]:
+ size = path.stat().st_size
+ with path.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ return size, digest
+
+
+def _copy_file(source: Path, destination: Path) -> tuple[int, str]:
+ information = _checked(source)
+ if not stat.S_ISREG(information.st_mode):
+ raise ResultExportError(f"result is not a regular file: {source.name}")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, destination, follow_symlinks=False)
+ return _digest_file(destination)
+
+
+def _tree_record(
+ digest: Any, marker: bytes, relative: Path, size: int, content_digest: str = ""
+) -> None:
+ encoded = relative.as_posix().encode("utf-8")
+ digest.update(marker)
+ digest.update(len(encoded).to_bytes(8, "big"))
+ digest.update(encoded)
+ digest.update(size.to_bytes(8, "big"))
+ digest.update(bytes.fromhex(content_digest))
+
+
+def _copy_directory(source: Path, destination: Path) -> tuple[int, str]:
+ destination.mkdir(parents=True)
+ total = 0
+ digest = hashlib.sha256()
+ pending = [(source, Path())]
+ while pending:
+ current, relative_root = pending.pop()
+ directories: list[tuple[Path, Path]] = []
+ with os.scandir(current) as entries:
+ for entry in sorted(entries, key=lambda item: item.name):
+ source_entry = Path(entry.path)
+ relative = relative_root / entry.name
+ information = _checked(source_entry)
+ if stat.S_ISDIR(information.st_mode):
+ (destination / relative).mkdir()
+ _tree_record(digest, b"D", relative, 0)
+ directories.append((source_entry, relative))
+ elif stat.S_ISREG(information.st_mode):
+ size, file_digest = _copy_file(source_entry, destination / relative)
+ total += size
+ _tree_record(digest, b"F", relative, size, file_digest)
+ else:
+ raise ResultExportError(f"unsupported result entry: {entry.name}")
+ pending.extend(reversed(directories))
+ return total, digest.hexdigest()
+
+
+def _copy_result(source: Path, information: os.stat_result, destination: Path) -> tuple[str, int, str]:
+ if stat.S_ISREG(information.st_mode):
+ size, digest = _copy_file(source, destination)
+ return "file", size, digest
+ if stat.S_ISDIR(information.st_mode):
+ size, digest = _copy_directory(source, destination)
+ return "directory", size, digest
+ raise ResultExportError(f"result must be a file or directory: {source.name}")
+
+
+def _overlap(first: str, second: str) -> bool:
+ return first == second or first.startswith(second + "/") or second.startswith(first + "/")
+
+
+def _validate_result_paths(graph: Graph) -> None:
+ occupied = list(_RESERVED)
+ package_occupied = list(_PACKAGE_RESERVED)
+ for current in graph.nodes:
+ for result in current.results:
+ if result.id.startswith(_PACKAGE_PREFIX):
+ relative = result.id.removeprefix(_PACKAGE_PREFIX)
+ paths = package_occupied
+ else:
+ relative = result.id
+ paths = occupied
+ if any(_overlap(relative, path) for path in paths):
+ raise ResultExportError(f"colliding export path: {result.id}")
+ paths.append(relative)
+
+
+def _validate_manifest_inputs(
+ graph: Graph, command: str, status: str, failures: Iterable[str]
+) -> tuple[str, ...]:
+ if not isinstance(command, str) or _COMMAND.fullmatch(command) is None:
+ raise ResultExportError("command must be a lowercase logical name")
+ if status not in _STATUSES:
+ raise ResultExportError(f"invalid export status: {status!r}")
+ failure_names = tuple(failures)
+ known = {current.name for current in graph.nodes}
+ if len(failure_names) != len(set(failure_names)) or any(
+ name not in known for name in failure_names
+ ):
+ raise ResultExportError("failures must contain unique graph node names")
+ if status != "failed" and failure_names:
+ raise ResultExportError("successful export cannot contain failures")
+ return failure_names
+
+
+def _validate_destination(destination: Path) -> None:
+ if _lstat(destination) is not None:
+ raise ResultExportError(f"export destination already exists: {destination}")
+ information = _lstat(destination.parent)
+ if information is None or not stat.S_ISDIR(information.st_mode):
+ raise ResultExportError("export destination parent must be an existing directory")
+ current = destination.parent
+ while True:
+ if _is_reparse(current):
+ raise ResultExportError(f"reparse point in export destination: {current}")
+ if current.parent == current:
+ return
+ current = current.parent
+
+
+def _write_manifest(staging: Path, document: dict[str, object]) -> None:
+ with (staging / "manifest.json").open("x", encoding="utf-8", newline="\n") as stream:
+ json.dump(document, stream, ensure_ascii=False, indent=2, sort_keys=True)
+ stream.write("\n")
+
+
+def _result_entry(
+ node: Node, result: Result, staging: Path, store: CasStore, relative: str
+) -> dict[str, object]:
+ source, information = _source(store.paths_for(node).output, result)
+ object_type, size, digest = _copy_result(source, information, staging / relative)
+ return {
+ "id": result.id,
+ "kind": result.kind,
+ "media_type": result.media_type,
+ "object_type": object_type,
+ "path": relative,
+ "producer_uid": node.uid,
+ "sha256": digest,
+ "size": size,
+ }
+
+
+def export_results(
+ graph: Graph,
+ store: CasStore,
+ destination: Path | str,
+ command: str,
+ status: str,
+ *,
+ failures: Iterable[str] = (),
+ cache: Mapping[str, object] | None = None,
+) -> Path:
+ """Publish complete declared results and a relative-path-only manifest."""
+
+ failure_names = _validate_manifest_inputs(graph, command, status, failures)
+ _validate_result_paths(graph)
+ published = Path(os.path.abspath(os.fspath(destination)))
+ _validate_destination(published)
+ try:
+ with tempfile.TemporaryDirectory(
+ prefix=f".{published.name}.tmp-", dir=published.parent
+ ) as temporary:
+ staging = Path(temporary)
+ complete_results = [
+ (current, result)
+ for current in graph.nodes
+ if store.is_complete(current)
+ for result in current.results
+ ]
+ results = [
+ _result_entry(current, result, staging, store, result.id)
+ for current, result in complete_results
+ if not result.id.startswith(_PACKAGE_PREFIX)
+ ]
+ package_results: list[dict[str, object]] = []
+ if status == "success":
+ package_root = staging / "packages"
+ package_results = [
+ _result_entry(
+ current,
+ result,
+ package_root,
+ store,
+ result.id.removeprefix(_PACKAGE_PREFIX),
+ )
+ for current, result in complete_results
+ if result.id.startswith(_PACKAGE_PREFIX)
+ ]
+ if package_results:
+ _write_manifest(
+ package_root,
+ {
+ "schema": 1,
+ "command": command,
+ "status": "success",
+ "failures": [],
+ "results": package_results,
+ "logs": [],
+ },
+ )
+ logs: list[dict[str, object]] = []
+ if status == "failed":
+ for current in graph.nodes:
+ source = store.paths_for(current).log
+ if _lstat(source) is None:
+ continue
+ relative = f"logs/{current.name}.log"
+ size, digest = _copy_file(source, staging / relative)
+ logs.append(
+ {"node": current.name, "path": relative, "sha256": digest, "size": size}
+ )
+ document: dict[str, object] = {
+ "schema": 1,
+ "command": command,
+ "status": status,
+ "failures": list(failure_names),
+ "results": results,
+ "logs": logs,
+ }
+ if cache is not None:
+ document["cache"] = dict(cache)
+ _write_manifest(staging, document)
+ if _lstat(published) is not None:
+ raise ResultExportError(f"export destination already exists: {published}")
+ staging.rename(published)
+ except OSError as error:
+ raise ResultExportError(f"could not publish result export: {error}") from error
+ return published
diff --git a/build/core/runtime.py b/build/core/runtime.py
new file mode 100644
index 0000000..929e6f8
--- /dev/null
+++ b/build/core/runtime.py
@@ -0,0 +1,188 @@
+"""Minimal bridge from graph execution to the repository-local Windows CAS."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager, suppress
+from pathlib import Path
+import shutil
+import time
+
+from filelock import AsyncFileLock
+
+from core.execute import Executor
+from core.graph import Command, Graph, Node
+from core.paths import BuildPaths
+from core.store import CasStore
+from core.windows_process import WindowsProcessRunner
+
+
+class ProcessFailed(RuntimeError):
+ """A build node process returned a nonzero exit code."""
+
+
+class _LeasedExecutor:
+ def __init__(self, runtime: BuildRuntime, executor: Executor) -> None:
+ self._runtime = runtime
+ self._executor = executor
+
+ async def run(self) -> None:
+ if self._runtime.owned_run_id is not None:
+ await self._executor.run()
+ return
+ async with self._runtime.session():
+ await self._executor.run()
+
+
+class BuildRuntime:
+ """Wire the generic executor to locks, CAS paths, and process execution."""
+
+ def __init__(
+ self,
+ repository: Path | str,
+ run_id: str,
+ process_runner: WindowsProcessRunner = WindowsProcessRunner(),
+ ) -> None:
+ self.paths = BuildPaths(repository)
+ self.store = CasStore(self.paths, run_id)
+ self._run_id = run_id
+ self._runner = process_runner
+ self._observed: dict[str, Node] = {}
+ self._durations_ms: dict[str, int] = {}
+ self._session_lease: AsyncFileLock | None = None
+
+ @property
+ def owned_run_id(self) -> str | None:
+ return self._run_id if self._session_lease is not None else None
+
+ @asynccontextmanager
+ async def session(self) -> AsyncIterator[None]:
+ """Hold this runtime's run lease across a complete public operation."""
+
+ if self._session_lease is not None:
+ raise RuntimeError("build runtime session is already active")
+ coordination = self._lock(self.paths.coordination_lock())
+ lease = self._lock(self.paths.lease(self._run_id))
+ async with coordination:
+ self.paths.prepare()
+ await lease.acquire()
+ self._session_lease = lease
+ try:
+ yield
+ finally:
+ self._session_lease = None
+ await lease.release()
+
+ def is_complete(self, current: Node) -> bool:
+ complete = self.store.is_complete(current)
+ self._observed.setdefault(current.uid, current)
+ return complete
+
+ def _known_nodes(self, graph: Graph | None) -> dict[str, Node]:
+ known = dict(self._observed)
+ if graph is not None:
+ for current in graph.nodes:
+ known.setdefault(current.uid, current)
+ return known
+
+ def live_uids(self, graph: Graph | None = None) -> tuple[str, ...]:
+ """Return known graph nodes that have a published completion marker."""
+
+ return tuple(sorted(
+ uid for uid, current in self._known_nodes(graph).items()
+ if self.store.is_complete(current)
+ ))
+
+ def cache_report(self, graph: Graph | None = None) -> dict[str, object]:
+ """Describe deterministic node identities and their result in this process."""
+
+ nodes: list[dict[str, object]] = []
+ summary = {"executed": 0, "failed": 0, "hit": 0, "incomplete": 0}
+ for uid, current in sorted(
+ self._known_nodes(graph).items(), key=lambda item: (item[1].name, item[0])
+ ):
+ complete = self.store.is_complete(current)
+ if uid in self._durations_ms:
+ state = "executed" if complete else "failed"
+ elif complete:
+ state = "hit"
+ else:
+ state = "incomplete"
+ summary[state] += 1
+ nodes.append({
+ "duration_ms": self._durations_ms.get(uid, 0),
+ "name": current.name,
+ "state": state,
+ "uid": uid,
+ })
+ return {"schema": 1, "summary": summary, "nodes": nodes}
+
+ @staticmethod
+ def _lock(path: Path) -> AsyncFileLock:
+ return AsyncFileLock(
+ path,
+ timeout=-1,
+ poll_interval=0.05,
+ fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+
+ def lock(self, current: Node) -> AsyncFileLock:
+ return self._lock(self.paths.lock(current.uid))
+
+ async def run(self, current: Node) -> None:
+ self._observed.setdefault(current.uid, current)
+ started = time.perf_counter()
+ try:
+ await self._run_node(current)
+ finally:
+ self._durations_ms[current.uid] = max(
+ 0, round((time.perf_counter() - started) * 1000)
+ )
+
+ async def _run_node(self, current: Node) -> None:
+ reserved = ("OBSERVER_OUT_DIR", "OBSERVER_BUILD_DIR", "_MSPDBSRV_ENDPOINT_")
+ existing = {key.casefold() for key, _value in current.command.env}
+ for key in reserved:
+ if key.casefold() in existing:
+ raise ValueError(f"command environment conflicts with {key}")
+
+ cas = self.store.prepare_entry(current)
+ run_root = self.paths.run_work(self._run_id)
+ work = self.paths.require_confined(run_root / current.uid, self.paths.work_root)
+ work.mkdir(exist_ok=False)
+ command = Command(
+ current.command.argv,
+ env=current.command.env
+ + (
+ ("OBSERVER_OUT_DIR", str(cas.output)),
+ ("OBSERVER_BUILD_DIR", str(work)),
+ ("_MSPDBSRV_ENDPOINT_", f"observer_{current.uid}"),
+ ),
+ cwd=current.command.cwd,
+ stdin=current.command.stdin,
+ )
+ with cas.log.open("r+b") as log:
+ exit_code = await self._runner.run(command, log=log)
+ if exit_code != 0:
+ raise ProcessFailed(f"process exited {exit_code} for node {current.name}")
+ scratch = self.paths.require_confined(work, self.paths.work_root)
+ await asyncio.to_thread(shutil.rmtree, scratch)
+ with suppress(OSError):
+ run_root.rmdir()
+
+ def publish(self, current: Node) -> None:
+ self.store.mark_complete(current)
+
+ def executor(self, graph: Graph) -> _LeasedExecutor:
+ return _LeasedExecutor(
+ self,
+ Executor(
+ graph,
+ is_complete=self.is_complete,
+ acquire_lock=self.lock,
+ runner=self.run,
+ publish=self.publish,
+ ),
+ )
diff --git a/build/core/sanitizer.py b/build/core/sanitizer.py
new file mode 100644
index 0000000..f93f935
--- /dev/null
+++ b/build/core/sanitizer.py
@@ -0,0 +1,50 @@
+"""Fail-closed semantic gates for native sanitizer logs."""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Sequence
+from pathlib import Path
+import re
+
+
+class SanitizerError(RuntimeError):
+ pass
+
+
+_FINDINGS = {
+ "asan": re.compile(
+ r"(?:ERROR|SUMMARY):\s*AddressSanitizer|AddressSanitizer:DEADLYSIGNAL",
+ re.IGNORECASE,
+ ),
+ "ubsan": re.compile(
+ r"runtime error:|UndefinedBehaviorSanitizer(?::DEADLYSIGNAL|: undefined-behavior)",
+ re.IGNORECASE,
+ ),
+}
+
+
+def require_clean_log(sanitizer: str, content: str) -> None:
+ try:
+ pattern = _FINDINGS[sanitizer]
+ except KeyError as error:
+ raise SanitizerError(f"unsupported sanitizer: {sanitizer}") from error
+ if pattern.search(content):
+ raise SanitizerError(f"{sanitizer} finding in test log")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ gate = commands.add_parser("gate")
+ gate.add_argument("sanitizer", choices=tuple(_FINDINGS))
+ gate.add_argument("log")
+ args = parser.parse_args(argv)
+ require_clean_log(
+ args.sanitizer, Path(args.log).read_text(encoding="utf-8-sig")
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/sarif.py b/build/core/sarif.py
new file mode 100644
index 0000000..23533df
--- /dev/null
+++ b/build/core/sarif.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+from collections.abc import Iterable, Sequence
+from pathlib import Path
+from typing import Any
+
+
+_DIAGNOSTIC = re.compile(
+ r"^(.+)\((\d+),(\d+)\):\s+(warning|error)\s*:\s*(.*?)\s+\[([^\]]+)\]"
+ r"(?:\s+\[[^\]]+\.vcxproj\])?\s*$"
+)
+
+
+class SarifError(ValueError):
+ pass
+
+
+class SarifFindingsError(SarifError):
+ pass
+
+
+def _read(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ try:
+ document = json.loads(path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise SarifError(f"cannot read SARIF report {path}: {error}") from error
+ if not isinstance(document, dict) or document.get("version") != "2.1.0":
+ raise SarifError(f"SARIF report is not version 2.1.0: {path}")
+ runs = document.get("runs")
+ if not isinstance(runs, list) or not runs or any(not isinstance(run, dict) for run in runs):
+ raise SarifError(f"SARIF runs must be a non-empty list of objects: {path}")
+ return document, runs
+
+
+def _write(path: Path, document: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+def _document(runs: list[dict[str, Any]]) -> dict[str, Any]:
+ return {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": runs, "version": "2.1.0"}
+
+
+def _tidy_result(finding: tuple[str, int, int, str, str, str]) -> dict[str, Any]:
+ path, line, column, level, rule, message = finding
+ location = {
+ "artifactLocation": {"uri": path},
+ "region": {"startColumn": column, "startLine": line},
+ }
+ return {
+ "level": level,
+ "locations": [{"physicalLocation": location}],
+ "message": {"text": message},
+ "ruleId": rule,
+ }
+
+
+def _tidy_rule(rule: str) -> dict[str, Any]:
+ return {"id": rule, "name": rule, "shortDescription": {"text": f"clang-tidy check {rule}"}}
+
+
+def clang_tidy_to_sarif(
+ repository: Path, log_root: Path, output: Path, automation_id: str
+) -> None:
+ repository = repository.resolve()
+ findings: set[tuple[str, int, int, str, str, str]] = set()
+ for log in sorted(log_root.rglob("*.ClangTidy.log")) if log_root.is_dir() else ():
+ for line in log.read_text(encoding="utf-8-sig", errors="replace").splitlines():
+ match = _DIAGNOSTIC.match(line)
+ if not match:
+ continue
+ rule = next(
+ (check for raw in match[6].split(",") if (check := raw.strip()) and not check.startswith("-")),
+ None,
+ )
+ if not rule:
+ continue
+ try:
+ relative = Path(match[1]).resolve().relative_to(repository).as_posix()
+ except (OSError, ValueError):
+ continue
+ line_number, column, level = int(match[2]), int(match[3]), match[4]
+ findings.add((relative, line_number, column, level, rule, match[5].strip()))
+
+ ordered = sorted(findings, key=lambda item: (item[0], item[1], item[2], item[4], item[5], item[3]))
+ driver = {
+ "informationUri": "https://clang.llvm.org/extra/clang-tidy/",
+ "name": "clang-tidy",
+ "rules": [_tidy_rule(rule) for rule in sorted({finding[4] for finding in findings})],
+ }
+ run = {
+ "automationDetails": {"id": automation_id},
+ "results": [_tidy_result(finding) for finding in ordered],
+ "tool": {"driver": driver},
+ }
+ _write(output, _document([run]))
+
+
+def normalize_msvc(source: Path, output: Path, automation_id: str) -> None:
+ document, runs = _read(source)
+ for index, run in enumerate(runs, 1):
+ details = run.get("automationDetails")
+ if not isinstance(details, dict):
+ details = run["automationDetails"] = {}
+ details["id"] = automation_id if len(runs) == 1 else f"{automation_id.rstrip('/')}/run-{index}/"
+ _write(output, document)
+
+
+def merge_sarif(inputs: Iterable[Path], output: Path) -> None:
+ identified: list[tuple[str, dict[str, Any]]] = []
+ for path in inputs:
+ _, runs = _read(path)
+ for run in runs:
+ details = run.get("automationDetails")
+ identity = details.get("id") if isinstance(details, dict) else None
+ if not isinstance(identity, str) or not identity:
+ raise SarifError(f"SARIF run has no automationDetails.id: {path}")
+ identified.append((identity, run))
+ if not identified:
+ raise SarifError("SARIF merge requires at least one input")
+ identities = [identity for identity, _ in identified]
+ if len(set(identities)) != len(identities):
+ raise SarifError("SARIF merge found duplicate automationDetails.id values")
+ _write(output, _document([run for _, run in sorted(identified)]))
+
+
+def require_clean(inputs: Iterable[Path]) -> None:
+ count = 0
+ for path in inputs:
+ _, runs = _read(path)
+ for run in runs:
+ results = run.get("results", [])
+ if not isinstance(results, list) or any(not isinstance(result, dict) for result in results):
+ raise SarifError(f"SARIF results must be a list of objects: {path}")
+ count += sum(result.get("level", "warning") in {"warning", "error"} for result in results)
+ if count:
+ raise SarifFindingsError(f"analysis found {count} warning/error finding(s)")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ def add_command(name: str, *arguments: str, output: str | None = None) -> None:
+ command = commands.add_parser(name)
+ for argument in arguments:
+ command.add_argument(argument, **({"nargs": "+"} if argument == "inputs" else {}))
+ if output:
+ command.add_argument("--output-name", default=output)
+
+ add_command("normalize-msvc", "input", "automation_id", output="renpy.sarif")
+ add_command("convert-tidy", "repository", "log_root", "automation_id", output="renpy.sarif")
+ add_command("merge", "inputs", output="analysis.sarif")
+ add_command("gate", "input")
+ args = parser.parse_args(argv)
+
+ raw_root = os.environ.get("OBSERVER_OUT_DIR")
+ if not raw_root:
+ parser.error("OBSERVER_OUT_DIR is required")
+ root = Path(raw_root).resolve()
+ if not root.is_dir():
+ parser.error("OBSERVER_OUT_DIR must be an existing directory")
+ if args.command == "gate":
+ require_clean((Path(args.input),))
+ return 0
+ output = (root / args.output_name).resolve()
+ if output == root or not output.is_relative_to(root):
+ parser.error("output name must be confined to OBSERVER_OUT_DIR")
+ if args.command == "normalize-msvc":
+ normalize_msvc(Path(args.input), output, args.automation_id)
+ elif args.command == "convert-tidy":
+ clang_tidy_to_sarif(Path(args.repository), Path(args.log_root), output, args.automation_id)
+ else:
+ merge_sarif([Path(path) for path in args.inputs], output)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/core/sign.py b/build/core/sign.py
new file mode 100644
index 0000000..cd0820f
--- /dev/null
+++ b/build/core/sign.py
@@ -0,0 +1,83 @@
+"""Canonical MD5 identities for rendered content-addressed recipes."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from collections.abc import Mapping
+from typing import TypeAlias
+
+
+JsonScalar: TypeAlias = None | bool | int | float | str
+JsonValue: TypeAlias = (
+ JsonScalar | list["JsonValue"] | tuple["JsonValue", ...] | Mapping[str, "JsonValue"]
+)
+_MD5_UID = re.compile(r"[0-9a-f]{32}")
+
+
+def _items(
+ mapping: Mapping[str, object], description: str
+) -> list[tuple[str, object]]:
+ result: list[tuple[str, object]] = []
+ for key, value in mapping.items():
+ if not isinstance(key, str):
+ raise TypeError(f"{description} must be strings")
+ result.append((key, value))
+ return sorted(result)
+
+
+def _identity(value: JsonValue) -> JsonValue:
+ if isinstance(value, Mapping):
+ return {
+ key: _identity(item)
+ for key, item in _items(value, "identity mapping keys")
+ }
+ if isinstance(value, (list, tuple)):
+ return [_identity(item) for item in value]
+ if value is None or isinstance(value, (bool, int, float, str)):
+ return value
+ raise TypeError(f"unsupported identity value: {type(value).__name__}")
+
+
+def content_uid(
+ *,
+ recipe: str | bytes,
+ inputs: Mapping[str, bytes],
+ dependencies: Mapping[str, str],
+ toolchain: Mapping[str, JsonValue],
+ config: Mapping[str, JsonValue],
+) -> str:
+ """Hash one unambiguous canonical-JSON description of a node."""
+
+ recipe_bytes = recipe.encode("utf-8") if isinstance(recipe, str) else recipe
+ if type(recipe_bytes) is not bytes:
+ raise TypeError("recipe must be str or bytes")
+
+ input_data: list[list[str]] = []
+ for name, data in _items(inputs, "input names"):
+ if type(data) is not bytes:
+ raise TypeError("input contents must be bytes")
+ input_data.append([name, data.hex()])
+
+ dependency_data: list[list[str]] = []
+ for name, uid in _items(dependencies, "dependency names"):
+ if not isinstance(uid, str) or _MD5_UID.fullmatch(uid) is None:
+ raise ValueError(f"dependency {name!r} does not have a canonical MD5 UID")
+ dependency_data.append([name, uid])
+
+ payload = json.dumps(
+ {
+ "config": _identity(config),
+ "dependencies": dependency_data,
+ "format": "observer-build-content-v1",
+ "inputs": input_data,
+ "recipe": recipe_bytes.hex(),
+ "toolchain": _identity(toolchain),
+ },
+ ensure_ascii=False,
+ allow_nan=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.md5(payload, usedforsecurity=False).hexdigest()
diff --git a/build/core/source_tools.py b/build/core/source_tools.py
new file mode 100644
index 0000000..0bdc086
--- /dev/null
+++ b/build/core/source_tools.py
@@ -0,0 +1,69 @@
+"""Discover and fingerprint the repository source-check tools."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import shutil
+
+from core.toolchain import MsvcToolchain, _output
+
+
+@dataclass(frozen=True, slots=True)
+class SourceTools:
+ pwsh: Path
+ clang_format: Path
+ cppcheck: Path
+ psscriptanalyzer: Path
+ vcpkg_root: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: tuple[tuple[str, str], ...]
+
+
+def discover_source_tools(toolchain: MsvcToolchain) -> SourceTools:
+ """Locate source analyzers and capture their exact cache identity."""
+
+ pwsh = toolchain.pwsh.resolve(strict=True)
+ clang_format = (toolchain.llvm_dir / "bin/clang-format.exe").resolve(strict=True)
+ candidate = shutil.which("cppcheck.exe")
+ if candidate is None:
+ raise FileNotFoundError("cppcheck.exe was not found on PATH")
+ cppcheck = Path(candidate).resolve(strict=True)
+ vcpkg_root = toolchain.vcpkg_root.resolve(strict=True)
+ pwsh_version = _output([str(pwsh), "--version"])
+ clang_format_version = _output([str(clang_format), "--version"])
+ cppcheck_version = _output([str(cppcheck), "--version"])
+
+ pssa_query = """
+$module = Get-Module -ListAvailable PSScriptAnalyzer |
+ Sort-Object Version -Descending |
+ Select-Object -First 1
+if (-not $module) { throw 'PSScriptAnalyzer was not found' }
+[ordered]@{ Path = $module.Path; Version = $module.Version.ToString() } |
+ ConvertTo-Json -Compress
+"""
+ pssa_document = json.loads(
+ _output([str(pwsh), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", pssa_query])
+ )
+ psscriptanalyzer = Path(pssa_document["Path"]).resolve(strict=True)
+ identity = {
+ "clang_format": str(clang_format),
+ "clang_format_version": clang_format_version,
+ "cppcheck": str(cppcheck),
+ "cppcheck_version": cppcheck_version,
+ "psscriptanalyzer": str(psscriptanalyzer),
+ "psscriptanalyzer_version": str(pssa_document["Version"]),
+ "pwsh": str(pwsh),
+ "pwsh_version": pwsh_version,
+ "vcpkg_root": str(vcpkg_root),
+ }
+ return SourceTools(
+ pwsh,
+ clang_format,
+ cppcheck,
+ psscriptanalyzer,
+ vcpkg_root,
+ toolchain.environment,
+ tuple(identity.items()),
+ )
diff --git a/build/core/store.py b/build/core/store.py
new file mode 100644
index 0000000..5dc60eb
--- /dev/null
+++ b/build/core/store.py
@@ -0,0 +1,106 @@
+"""Repository-local CAS using pg83/ix's zero-byte publication marker."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import stat
+
+from core.graph import Node
+from core.paths import BuildPaths, CasPaths
+
+
+class CasStateError(RuntimeError):
+ """A CAS entry could not be prepared or safely published."""
+
+
+def _lstat(path: Path) -> os.stat_result | None:
+ try:
+ return os.lstat(path)
+ except FileNotFoundError:
+ return None
+
+
+def _lstat_as(path: Path, file_type: int) -> os.stat_result | None:
+ information = _lstat(path)
+ if information is None or stat.S_IFMT(information.st_mode) != file_type:
+ return None
+ return information
+
+
+def _complete(paths: CasPaths) -> bool:
+ marker = _lstat_as(paths.touch, stat.S_IFREG)
+ return (
+ _lstat_as(paths.entry, stat.S_IFDIR) is not None
+ and _lstat_as(paths.output, stat.S_IFDIR) is not None
+ and _lstat_as(paths.log, stat.S_IFREG) is not None
+ and marker is not None
+ and marker.st_size == 0
+ )
+
+
+class CasStore:
+ """Own completion state for one build run; callers hold the UID lock."""
+
+ def __init__(self, paths: BuildPaths, run_identifier: str) -> None:
+ self._paths = paths
+ self._run_work = paths.run_work(run_identifier)
+
+ def paths_for(self, current: Node) -> CasPaths:
+ return self._paths.cas(current.uid)
+
+ def is_complete(self, current: Node) -> bool:
+ return _complete(self.paths_for(current))
+
+ def prepare_entry(self, current: Node) -> CasPaths:
+ paths = self.paths_for(current)
+ if _complete(paths):
+ return paths
+
+ self._prepare_run_directory()
+ if _lstat(paths.entry) is not None:
+ self._quarantine(paths)
+
+ paths.entry.mkdir(exist_ok=False)
+ paths.output.mkdir(exist_ok=False)
+ with paths.log.open("xb"):
+ pass
+ return paths
+
+ def mark_complete(self, current: Node) -> None:
+ paths = self.paths_for(current)
+ if _lstat_as(paths.entry, stat.S_IFDIR) is None or _lstat_as(
+ paths.output, stat.S_IFDIR
+ ) is None:
+ raise CasStateError("cannot publish completion without output directory")
+ if _lstat_as(paths.log, stat.S_IFREG) is None:
+ raise CasStateError("cannot publish completion without a regular log file")
+
+ try:
+ with paths.touch.open("xb"):
+ pass
+ except FileExistsError as error:
+ raise CasStateError("completion marker already exists") from error
+
+ def _prepare_run_directory(self) -> None:
+ self._paths.require_confined(self._run_work, self._paths.work_root)
+ self._run_work.mkdir(exist_ok=True)
+
+ def _quarantine(self, paths: CasPaths) -> None:
+ quarantine_root = self._paths.require_confined(
+ self._run_work / "quarantine", self._paths.work_root
+ )
+ quarantine_root.mkdir(exist_ok=True)
+ destination = self._paths.require_confined(
+ quarantine_root / paths.entry.name, self._paths.work_root
+ )
+ if _lstat(destination) is not None:
+ raise CasStateError(
+ f"quarantine destination already exists: {destination}"
+ )
+ try:
+ paths.entry.rename(destination)
+ except OSError as error:
+ raise CasStateError(
+ f"could not quarantine incomplete CAS entry {paths.entry}"
+ ) from error
diff --git a/build/core/toolchain.py b/build/core/toolchain.py
new file mode 100644
index 0000000..68b98a5
--- /dev/null
+++ b/build/core/toolchain.py
@@ -0,0 +1,152 @@
+"""Discover the installed x64 MSVC analysis toolchain."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+
+@dataclass(frozen=True, slots=True)
+class MsvcToolchain:
+ installation: Path
+ msbuild: Path
+ vsdevcmd: Path
+ llvm_dir: Path
+ clang_tidy: Path
+ vcpkg_root: Path
+ pwsh: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: tuple[tuple[str, str], ...]
+
+
+_DEVELOPER_VARIABLES = frozenset(
+ {
+ "devenvdir",
+ "extensionsdkdir",
+ "external_include",
+ "include",
+ "lib",
+ "libpath",
+ "netfxsdkdir",
+ "ucrtversion",
+ "universalcrtsdkdir",
+ "vcideinstalldir",
+ "vcinstalldir",
+ "visualstudioversion",
+ "vs170comntools",
+ "vsinstalldir",
+ "windowslibpath",
+ }
+)
+_DEVELOPER_PREFIXES = ("framework", "vctools", "vscmd_", "windowssdk")
+
+
+def _developer_variable(name: str) -> bool:
+ return name in _DEVELOPER_VARIABLES or name.startswith(_DEVELOPER_PREFIXES)
+
+
+def _existing(path: Path | str | None, directory: bool = False) -> Path:
+ resolved = Path(path).resolve() if path else None
+ if resolved is None or not (resolved.is_dir() if directory else resolved.is_file()):
+ raise FileNotFoundError(f"required path not found: {path}")
+ return resolved
+
+
+def _command_environment(text: str) -> tuple[tuple[str, str], ...]:
+ values: dict[str, tuple[str, str]] = {}
+ for line in text.splitlines():
+ if not line or line.startswith("="):
+ continue
+ key, value = line.split("=", 1)
+ folded = key.casefold()
+ if not _developer_variable(folded):
+ continue
+ canonical = key.upper() if folded in {"path", "lib"} else key
+ if folded == "lib":
+ value = os.pathsep.join(
+ part for part in value.split(os.pathsep) if Path(part).is_dir()
+ )
+ values[folded] = (canonical, value)
+
+ return tuple(sorted(values.values(), key=lambda pair: pair[0].casefold()))
+
+
+def _output(argv: list[str] | str, **options: str) -> str:
+ output = subprocess.run(
+ argv, check=True, capture_output=True, text=True, **options
+ ).stdout.strip()
+ if not output:
+ raise RuntimeError(f"tool returned empty output: {argv[0]}")
+ return output
+def discover_msvc_toolchain() -> MsvcToolchain:
+ """Locate Visual Studio tools and capture an isolated amd64 developer environment."""
+
+ components = (
+ "Microsoft.Component.MSBuild",
+ "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "Microsoft.VisualStudio.Component.VC.Llvm.Clang",
+ )
+ vswhere = _existing(
+ Path(os.environ["ProgramFiles(x86)"])
+ / "Microsoft Visual Studio"
+ / "Installer"
+ / "vswhere.exe"
+ )
+ installation = _existing(
+ _output(
+ [
+ str(vswhere),
+ "-latest",
+ "-products",
+ "*",
+ "-requires",
+ *components,
+ "-property",
+ "installationPath",
+ ]
+ ),
+ directory=True,
+ )
+ bin_dir = installation / "MSBuild" / "Current" / "Bin"
+ amd64_msbuild = bin_dir / "amd64" / "MSBuild.exe"
+ msbuild = _existing(amd64_msbuild if amd64_msbuild.is_file() else bin_dir / "MSBuild.exe")
+ vsdevcmd = _existing(installation / "Common7" / "Tools" / "VsDevCmd.bat")
+ llvm_dir = _existing(installation / "VC" / "Tools" / "Llvm" / "x64", directory=True)
+ clang_tidy = _existing(llvm_dir / "bin" / "clang-tidy.exe")
+ cmd = _existing(Path(os.environ.get("SystemRoot", "")) / "System32" / "cmd.exe")
+ payload = f'call "{vsdevcmd}" -no_logo -arch=amd64 -host_arch=amd64 >nul && set'
+ environment = _command_environment(
+ _output(f'"{cmd}" /d /s /c "{payload}"', executable=str(cmd))
+ )
+ msbuild_version = _output([str(msbuild), "-version", "-nologo"])
+ clang_output = _output([str(clang_tidy), "--version"])
+ clang_tidy_version = next(
+ line.partition("LLVM version")[2].strip()
+ for line in clang_output.splitlines()
+ if "LLVM version" in line
+ )
+ vcpkg_root = _existing(
+ os.environ.get("VCPKG_ROOT")
+ or _existing(shutil.which("vcpkg")).parent,
+ directory=True,
+ )
+ pwsh = _existing(shutil.which("pwsh"))
+ captured = {key.casefold(): value for key, value in environment}
+ identity = (
+ ("clang_tidy", str(clang_tidy)),
+ ("clang_tidy_version", clang_tidy_version),
+ ("installation", str(installation)),
+ ("msbuild", str(msbuild)),
+ ("msbuild_version", msbuild_version),
+ ("pwsh", str(pwsh)),
+ ("vc_tools_version", captured.get("vctoolsversion", "")),
+ ("vcpkg_root", str(vcpkg_root)),
+ ("vsdevcmd", str(vsdevcmd)),
+ ("vsdevcmd_version", captured.get("vscmd_ver", "")),
+ ("windows_sdk_version", captured.get("windowssdkversion", "")),
+ )
+ return MsvcToolchain(installation, msbuild, vsdevcmd, llvm_dir, clang_tidy,
+ vcpkg_root, pwsh, environment, identity)
diff --git a/build/core/windows_job.py b/build/core/windows_job.py
new file mode 100644
index 0000000..3ec68d6
--- /dev/null
+++ b/build/core/windows_job.py
@@ -0,0 +1,44 @@
+"""Minimal kill-on-close Windows Job Object wrapper."""
+
+from __future__ import annotations
+
+import win32job
+
+
+class WindowsJob:
+ """Own one Job handle until an explicit, observable close."""
+
+ def __init__(self) -> None:
+ handle = win32job.CreateJobObject(None, "")
+ self._handle = handle
+ try:
+ information = win32job.QueryInformationJobObject(
+ handle, win32job.JobObjectExtendedLimitInformation
+ )
+ information["BasicLimitInformation"]["LimitFlags"] |= (
+ win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ )
+ win32job.SetInformationJobObject(
+ handle,
+ win32job.JobObjectExtendedLimitInformation,
+ information,
+ )
+ except BaseException as error:
+ self._handle = None
+ try:
+ handle.Close()
+ except BaseException as cleanup_error:
+ error.add_note(f"cleanup failure: {cleanup_error!r}")
+ raise
+
+ def assign_process(self, process_handle: int) -> None:
+ win32job.AssignProcessToJobObject(self._handle, process_handle)
+
+ def terminate(self) -> None:
+ win32job.TerminateJobObject(self._handle, 1)
+
+ def close(self) -> None:
+ handle = self._handle
+ if handle is not None:
+ handle.Close()
+ self._handle = None
diff --git a/build/core/windows_process.py b/build/core/windows_process.py
new file mode 100644
index 0000000..c5961ed
--- /dev/null
+++ b/build/core/windows_process.py
@@ -0,0 +1,122 @@
+"""Small psutil-backed Windows process-tree runner."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+import os
+from pathlib import Path
+import subprocess
+from typing import BinaryIO
+
+import psutil
+
+from core.graph import Command
+from core.windows_job import WindowsJob
+
+
+_CREATE_SUSPENDED = 0x00000004
+_CREATE_UNICODE_ENVIRONMENT = 0x00000400
+
+
+def _attempt(
+ errors: list[BaseException], operation: Callable[[], object]
+) -> bool:
+ try:
+ operation()
+ return True
+ except BaseException as error:
+ errors.append(error)
+ return False
+
+
+def _stop(
+ job: WindowsJob,
+ process: psutil.Popen[bytes] | None,
+ assigned: bool,
+ errors: list[BaseException],
+) -> None:
+ closed = _attempt(errors, job.close)
+ tree_stopped = assigned and closed
+ if assigned and not closed:
+ tree_stopped = _attempt(errors, job.terminate)
+ if process is not None and not tree_stopped:
+ _attempt(errors, process.kill)
+
+
+async def _reap(
+ process: psutil.Popen[bytes],
+ communication: asyncio.Task[object] | None,
+ errors: list[BaseException],
+ primary: BaseException,
+) -> None:
+ if communication is not None:
+ try:
+ await asyncio.shield(communication)
+ except BaseException as error:
+ if error is not primary:
+ errors.append(error)
+ if process.returncode is None:
+ await asyncio.to_thread(_attempt, errors, process.wait)
+
+
+class WindowsProcessRunner:
+ """Run literal argv in a kill-on-close Job and stream exact stdin."""
+
+ async def run(self, command: Command, *, log: BinaryIO) -> int:
+ executable = Path(command.argv[0])
+ if not executable.is_absolute() or not executable.is_file():
+ raise ValueError("command executable must be an absolute existing file")
+
+ job = WindowsJob()
+ process: psutil.Popen[bytes] | None = None
+ communication: asyncio.Task[object] | None = None
+ assigned = False
+ try:
+ environment = {
+ key.casefold(): (key, value) for key, value in os.environ.items()
+ }
+ for key, value in command.env:
+ folded = key.casefold()
+ if folded == "path" and folded in environment:
+ value += os.pathsep + environment[folded][1]
+ environment[folded] = (key, value)
+ process = psutil.Popen(
+ list(command.argv),
+ executable=command.argv[0],
+ shell=False,
+ stdin=subprocess.PIPE,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ cwd=command.cwd,
+ env=dict(environment.values()),
+ close_fds=True,
+ creationflags=_CREATE_SUSPENDED | _CREATE_UNICODE_ENVIRONMENT,
+ )
+ job.assign_process(int(process._handle))
+ assigned = True
+ process.resume()
+ communication = asyncio.create_task(
+ asyncio.to_thread(process.communicate, input=command.stdin)
+ )
+ await asyncio.shield(communication)
+ if process.returncode is None:
+ raise RuntimeError("process completed without an exit code")
+ exit_code = process.returncode
+ except BaseException as primary:
+ errors: list[BaseException] = []
+ _stop(job, process, assigned, errors)
+ if process is not None:
+ await _reap(process, communication, errors, primary)
+ for error in errors:
+ primary.add_note(f"cleanup failure: {error!r}")
+ raise
+
+ errors = []
+ _stop(job, process, assigned, errors)
+ if errors:
+ primary, *cleanup_errors = errors
+ for error in cleanup_errors:
+ primary.add_note(f"cleanup failure: {error!r}")
+ raise primary
+ return exit_code
diff --git a/build/driver.py b/build/driver.py
new file mode 100644
index 0000000..08ec51f
--- /dev/null
+++ b/build/driver.py
@@ -0,0 +1,509 @@
+"""Thin command orchestration over the independently tested build graphs."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+import psutil
+
+from core.clean import sweep_cas
+from core.graph import Graph, merge_graphs
+from core.host import require_runnable, runnable_architectures, verify_route
+from core.node import NodeFactory
+from core.quality_tools import (
+ ResolvedTool,
+ resolve_binskim,
+ resolve_dumpbin,
+ resolve_asan_runtimes,
+ resolve_umdh,
+ resolve_ubsan_runtime,
+)
+from core.runtime import BuildRuntime
+from core.source_tools import SourceTools, discover_source_tools
+from core.toolchain import MsvcToolchain
+from core.render import TemplateRenderer
+from core.result_export import export_results
+from graphs.analysis import (analysis_discovery_slice, analysis_slice,
+ load_dependency_manifests)
+from graphs.audit import audit_graph
+from graphs.common import BUILD_ROOT, require_positive_integers, restore_node, tool_environment
+from graphs.coverage import coverage_dependency_discovery_slice, coverage_graph
+from graphs.fuzz import (
+ FUZZ_TARGETS,
+ FuzzCorpusArtifact,
+ fuzz_dependency_discovery_slice,
+ fuzz_graph,
+)
+from graphs.python_coverage import python_coverage_graph
+from graphs.sanitizer import (
+ AsanRuntime,
+ sanitizer_dependency_discovery_slice,
+ sanitizer_graph,
+)
+from graphs.leak import leak_graph
+from graphs.native import native_dependency_discovery_slice, native_graph
+from graphs.package import package_graph, package_outputs
+from graphs.source import architecture_source_checks, common_source_checks, source_checks as source_checks_graph
+
+
+_MODULES = ("renpy", "rpgmaker", "zanzarah")
+_SANITIZER_ARCHITECTURES = {
+ "asan": ("ASan", frozenset(("x86", "x64"))),
+ "ubsan": ("UBSan", frozenset(("x64",))),
+}
+
+
+def _sanitizer_selections(
+ sanitizer: str, architectures: tuple[str, ...]
+) -> tuple[tuple[str, str], ...]:
+ label, supported = _SANITIZER_ARCHITECTURES[sanitizer]
+ unsupported = next(
+ (architecture for architecture in architectures if architecture not in supported),
+ None,
+ )
+ if unsupported is not None:
+ raise ValueError(f"{label} does not support {unsupported}")
+ return tuple((sanitizer, architecture) for architecture in require_runnable(architectures))
+
+
+class Driver:
+ """Own one repository-local runtime and compose command-specific graph families."""
+
+ def __init__(self, repository: Path, run_id: str, toolchain: MsvcToolchain,
+ *, jobs: int | None = None, prune_cas: bool = False) -> None:
+ self.repository = repository.resolve(strict=True)
+ self.jobs = (psutil.cpu_count() or 1) if jobs is None else jobs
+ require_positive_integers((self.jobs,), "jobs must be a positive integer")
+ self.toolchain = toolchain
+ self.runtime = BuildRuntime(self.repository, run_id)
+ self._last_graph: Graph | None = None
+ self._prune_cas = prune_cas
+ self._prune_ready = False
+
+ def _sweep_cas(self) -> None:
+ if self._prune_cas and self._prune_ready and self._last_graph is not None:
+ sweep_cas(
+ self.repository, self.runtime.live_uids(self._last_graph),
+ owned_run_id=self.runtime.owned_run_id,
+ )
+
+ async def _run(self, graph: Graph) -> tuple[Path, ...]:
+ self._last_graph = graph
+ await self.runtime.executor(graph).run()
+ return tuple(self.runtime.store.paths_for(graph.node(name)).output for name in graph.targets)
+
+ async def _run_final(self, graph: Graph) -> tuple[Path, ...]:
+ self._prune_ready = True
+ return await self._run(graph)
+
+ async def _public(
+ self, command: str, export_dir: Path | None,
+ action: Callable[[], Awaitable[tuple[Path, ...]]],
+ ) -> tuple[Path, ...]:
+ self._last_graph = None
+ self._prune_ready = False
+ async with self.runtime.session():
+ try:
+ outputs = await action()
+ except Exception as error:
+ if export_dir is not None and self._last_graph is not None:
+ failures = tuple(getattr(error, "failed_nodes", ()))
+ try:
+ export_results(
+ self._last_graph, self.runtime.store, export_dir,
+ command, "failed", failures=failures,
+ cache=self.runtime.cache_report(self._last_graph),
+ )
+ except Exception as export_error:
+ raise ExceptionGroup(
+ f"{command} and result export failed", (error, export_error)
+ ) from None
+ try:
+ self._sweep_cas()
+ except Exception as prune_error:
+ raise ExceptionGroup(
+ f"{command} and cache pruning failed", (error, prune_error)
+ ) from None
+ raise
+ try:
+ self._sweep_cas()
+ except Exception as prune_error:
+ if export_dir is not None:
+ assert self._last_graph is not None
+ try:
+ export_results(
+ self._last_graph, self.runtime.store, export_dir,
+ command, "failed", failures=(),
+ cache=self.runtime.cache_report(self._last_graph),
+ )
+ except Exception as export_error:
+ raise ExceptionGroup(
+ "cache pruning and result export failed",
+ (prune_error, export_error),
+ ) from None
+ raise
+ if export_dir is not None:
+ assert self._last_graph is not None
+ export_results(
+ self._last_graph, self.runtime.store, export_dir,
+ command, "success", failures=(),
+ cache=self.runtime.cache_report(self._last_graph),
+ )
+ return outputs
+
+ async def _staged(self, discovery: Graph, compose: Callable[..., Graph],
+ **options: object) -> Graph:
+ await self._run(discovery)
+ return compose(
+ self.repository, self.toolchain, discovery=discovery,
+ manifests=load_dependency_manifests(self.repository, discovery),
+ jobs=self.jobs, **options,
+ )
+
+ async def _native(self, architectures: tuple[str, ...], configurations: tuple[str, ...],
+ runnable: tuple[str, ...], *, test_shards: int = 4,
+ include_leak_probe: bool = False, corpus: Path | None = None,
+ run_nonce: str = "") -> Graph:
+ discovery = native_dependency_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, architectures=architectures,
+ configurations=configurations, include_leak_probe=include_leak_probe,
+ )
+ return await self._staged(
+ discovery, native_graph, architectures=architectures, configurations=configurations,
+ runnable_architectures=runnable, test_shards=test_shards,
+ include_leak_probe=include_leak_probe, corpus=corpus, run_nonce=run_nonce,
+ )
+
+ async def _audited_release(self, architectures: tuple[str, ...], dumpbin: ResolvedTool,
+ binskim: ResolvedTool, *,
+ include_leak_probe: bool = False) -> Graph:
+ upstream = await self._native(
+ architectures, ("Release",), (), include_leak_probe=include_leak_probe
+ )
+ return audit_graph(
+ self.repository, upstream, architectures=architectures,
+ include_leak_probe=include_leak_probe,
+ dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity),
+ binskim=binskim.path, binskim_identity=dict(binskim.identity),
+ jobs=self.jobs,
+ )
+
+ async def restore(self, architectures: tuple[str, ...] = ("x64",),
+ flavors: tuple[str, ...] = ("",)) -> tuple[Path, ...]:
+ factory = NodeFactory(
+ TemplateRenderer(BUILD_ROOT / "templates"), self.repository, {},
+ tool_environment(self.toolchain),
+ )
+ nodes = tuple(
+ restore_node(self.repository, self.toolchain, factory, architecture, flavor=flavor)
+ for flavor in flavors for architecture in architectures
+ )
+ return await self._run(
+ Graph(nodes, tuple(node.name for node in nodes), {"restore": 1})
+ )
+
+ async def build(self, architectures: tuple[str, ...] = ("x64",),
+ configurations: tuple[str, ...] = ("Debug",)) -> tuple[Path, ...]:
+ return await self._run(await self._native(architectures, configurations, ()))
+
+ async def test(self, architectures: tuple[str, ...] = ("x64",),
+ configurations: tuple[str, ...] = ("Debug",), *, test_shards: int = 4,
+ corpus: Path | None = None, run_nonce: str = "") -> tuple[Path, ...]:
+ graph = await self._native(
+ architectures, configurations, require_runnable(architectures),
+ test_shards=test_shards, corpus=corpus, run_nonce=run_nonce,
+ )
+ return await self._run(graph)
+
+ async def source_checks(self, architectures: tuple[str, ...],
+ tools: SourceTools) -> tuple[Path, ...]:
+ graph = source_checks_graph(
+ self.repository, tools, jobs=self.jobs, architectures=architectures
+ )
+ return await self._run(graph)
+
+ async def compiler_analysis(self, architectures: tuple[str, ...] = ("x64",)) -> tuple[Path, ...]:
+ discovery = analysis_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, architectures=architectures
+ )
+ graph = await self._staged(discovery, analysis_slice, architectures=architectures)
+ return await self._run(graph)
+
+ async def test_coverage(
+ self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4,
+ report_jobs: int = 2, corpus: Path | None = None, run_nonce: str = "",
+ ) -> tuple[Path, ...]:
+ if corpus is not None and (
+ not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce
+ ):
+ raise ValueError("corpus run nonce must be a non-empty string without NUL")
+ runnable = require_runnable(architectures)
+ discovery = coverage_dependency_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, architectures=runnable
+ )
+ graph = await self._staged(
+ discovery, coverage_graph, architectures=runnable,
+ test_shards=test_shards, report_jobs=report_jobs,
+ corpus=corpus, run_nonce=run_nonce,
+ )
+ return await self._run(graph)
+
+ async def _sanitizer(
+ self, selections: tuple[tuple[str, str], ...], *, llvm_runtime: Path | None,
+ llvm_runtime_identity: dict[str, str], asan_runtimes: tuple[AsanRuntime, ...],
+ test_shards: int,
+ ) -> tuple[Path, ...]:
+ discovery = sanitizer_dependency_discovery_slice(
+ self.repository, self.toolchain,
+ llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_runtime_identity,
+ selections=selections, jobs=self.jobs,
+ )
+ graph = await self._staged(
+ discovery, sanitizer_graph,
+ llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_runtime_identity,
+ asan_runtimes=asan_runtimes, selections=selections,
+ test_shards=test_shards,
+ )
+ return await self._run(graph)
+
+ async def test_asan(
+ self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4,
+ ) -> tuple[Path, ...]:
+ selections = _sanitizer_selections("asan", architectures)
+ runtimes = tuple(
+ AsanRuntime(architecture, tool.path, dict(tool.identity))
+ for architecture, tool in resolve_asan_runtimes(
+ self.toolchain, tuple(architecture for _, architecture in selections)
+ )
+ )
+ return await self._sanitizer(
+ selections, llvm_runtime=None, llvm_runtime_identity={},
+ asan_runtimes=runtimes, test_shards=test_shards,
+ )
+
+ async def test_ubsan(
+ self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4,
+ ) -> tuple[Path, ...]:
+ selections = _sanitizer_selections("ubsan", architectures)
+ runtime = resolve_ubsan_runtime(self.toolchain)
+ return await self._sanitizer(
+ selections, llvm_runtime=runtime.path,
+ llvm_runtime_identity=dict(runtime.identity), asan_runtimes=(),
+ test_shards=test_shards,
+ )
+
+ async def python_coverage(self) -> tuple[Path, ...]:
+ return await self._run(python_coverage_graph(self.repository))
+
+ async def fuzz(self, *, run_nonce: str, seconds: int = 60, fuzz_jobs: int = 2,
+ prior_corpora: tuple[FuzzCorpusArtifact, ...] = (),
+ targets: tuple[str, ...] = FUZZ_TARGETS) -> tuple[Path, ...]:
+ require_runnable(("x64",))
+ discovery = fuzz_dependency_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, targets=targets
+ )
+ graph = await self._staged(
+ discovery, fuzz_graph, run_nonce=run_nonce, seconds=seconds,
+ fuzz_jobs=fuzz_jobs, prior_corpora=prior_corpora, targets=targets,
+ )
+ return await self._run(graph)
+
+ async def audit(self, architectures: tuple[str, ...] = ("x64",), *,
+ dumpbin: ResolvedTool, binskim: ResolvedTool) -> tuple[Path, ...]:
+ return await self._run(
+ await self._audited_release(architectures, dumpbin, binskim)
+ )
+
+ async def _package(self, architectures: tuple[str, ...], *,
+ dumpbin: ResolvedTool, binskim: ResolvedTool) -> tuple[Path, ...]:
+ audited = await self._audited_release(architectures, dumpbin, binskim)
+ graph = package_graph(
+ self.repository, audited, architectures=architectures,
+ smoke_architectures=runnable_architectures(architectures), jobs=self.jobs,
+ )
+ await self._run_final(graph)
+ return package_outputs(self.repository, graph)
+
+ async def package(self, architectures: tuple[str, ...] = ("x64",), *,
+ dumpbin: ResolvedTool, binskim: ResolvedTool,
+ export_dir: Path | None = None) -> tuple[Path, ...]:
+ return await self._public(
+ "package", export_dir,
+ lambda: self._package(architectures, dumpbin=dumpbin, binskim=binskim),
+ )
+
+ async def test_leaks(self, *, run_nonce: str, dumpbin: ResolvedTool,
+ binskim: ResolvedTool, umdh: ResolvedTool,
+ warmup: int = 8, iterations: int = 100, windows: int = 3,
+ tolerance_bytes: int = 0) -> tuple[Path, ...]:
+ require_runnable(("x64",))
+ audited = await self._audited_release(
+ ("x64",), dumpbin, binskim, include_leak_probe=True
+ )
+ graph = leak_graph(
+ self.repository, audited,
+ umdh=umdh.path, umdh_identity=dict(umdh.identity), run_nonce=run_nonce,
+ warmup=warmup, iterations=iterations, windows=windows,
+ tolerance_bytes=tolerance_bytes, jobs=self.jobs,
+ )
+ return await self._run(graph)
+
+ async def _verify(
+ self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None,
+ run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4,
+ warmup: int = 8, iterations: int = 100, windows: int = 3,
+ tolerance_bytes: int = 0, include_common: bool,
+ ) -> tuple[Path, ...]:
+ """Run every host-capable gate in one shared-pool graph after one discovery union."""
+
+ route = verify_route(architectures)
+ source_tools = discover_source_tools(self.toolchain)
+ dumpbin, binskim = resolve_dumpbin(self.toolchain), resolve_binskim()
+ asan_tools = resolve_asan_runtimes(self.toolchain, route.asan) if route.asan else ()
+ ubsan = resolve_ubsan_runtime(self.toolchain) if route.ubsan else None
+ umdh = resolve_umdh() if route.run_x64_specialists else None
+ selections = tuple(("asan", item) for item in route.asan) + tuple(
+ ("ubsan", item) for item in route.ubsan
+ )
+ llvm_runtime = ubsan.path if ubsan else None
+ llvm_identity = dict(ubsan.identity) if ubsan else {}
+ asan_runtimes = tuple(
+ AsanRuntime(architecture, tool.path, dict(tool.identity))
+ for architecture, tool in asan_tools
+ )
+
+ discoveries = [
+ analysis_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, architectures=architectures
+ ),
+ native_dependency_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, architectures=architectures,
+ configurations=("Debug", "Release"),
+ include_leak_probe=route.run_x64_specialists,
+ ),
+ ]
+ if route.coverage:
+ discoveries.append(coverage_dependency_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs,
+ architectures=route.coverage,
+ ))
+ if selections:
+ discoveries.append(sanitizer_dependency_discovery_slice(
+ self.repository, self.toolchain, llvm_runtime=llvm_runtime,
+ llvm_runtime_identity=llvm_identity, selections=selections, jobs=self.jobs,
+ ))
+ if route.run_x64_specialists:
+ discoveries.append(fuzz_dependency_discovery_slice(
+ self.repository, self.toolchain, jobs=self.jobs, targets=FUZZ_TARGETS,
+ ))
+ discovery = merge_graphs(*discoveries)
+ await self._run(discovery)
+ manifests = load_dependency_manifests(self.repository, discovery)
+
+ native = native_graph(
+ self.repository, self.toolchain, discovery=discovery, manifests=manifests,
+ jobs=self.jobs, architectures=architectures,
+ configurations=("Debug", "Release"), runnable_architectures=route.runnable,
+ test_shards=test_shards, include_leak_probe=route.run_x64_specialists,
+ corpus=corpus, run_nonce=run_nonce,
+ )
+ audit = audit_graph(
+ self.repository, native, architectures=architectures,
+ dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity),
+ binskim=binskim.path, binskim_identity=dict(binskim.identity),
+ jobs=self.jobs,
+ )
+ package = package_graph(
+ self.repository, audit, architectures=architectures,
+ smoke_architectures=route.runnable, jobs=self.jobs,
+ )
+ graphs = [
+ native,
+ analysis_slice(
+ self.repository, self.toolchain, discovery=discovery, manifests=manifests,
+ jobs=self.jobs, architectures=architectures,
+ ),
+ architecture_source_checks(
+ self.repository, source_tools, architectures, jobs=self.jobs,
+ ),
+ package,
+ ]
+ if include_common:
+ graphs.extend((
+ common_source_checks(self.repository, source_tools, jobs=self.jobs),
+ python_coverage_graph(self.repository),
+ ))
+ if route.coverage:
+ graphs.append(coverage_graph(
+ self.repository, self.toolchain, discovery=discovery, manifests=manifests,
+ jobs=self.jobs, architectures=route.coverage, test_shards=test_shards,
+ corpus=corpus, run_nonce=run_nonce,
+ ))
+ if selections:
+ graphs.append(sanitizer_graph(
+ self.repository, self.toolchain, discovery=discovery, manifests=manifests,
+ llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_identity,
+ asan_runtimes=asan_runtimes, selections=selections,
+ jobs=self.jobs, test_shards=test_shards,
+ ))
+ if route.run_x64_specialists:
+ assert umdh is not None
+ graphs.extend((
+ fuzz_graph(
+ self.repository, self.toolchain, discovery=discovery,
+ manifests=manifests, run_nonce=run_nonce, seconds=fuzz_seconds,
+ jobs=self.jobs, targets=FUZZ_TARGETS,
+ ),
+ leak_graph(
+ self.repository, audit,
+ umdh=umdh.path, umdh_identity=dict(umdh.identity),
+ run_nonce=run_nonce, warmup=warmup, iterations=iterations,
+ windows=windows, tolerance_bytes=tolerance_bytes, jobs=self.jobs,
+ ),
+ ))
+ return await self._run_final(merge_graphs(*graphs))
+
+ async def verify_source(self, *, export_dir: Path | None = None) -> tuple[Path, ...]:
+ async def operation() -> tuple[Path, ...]:
+ tools = discover_source_tools(self.toolchain)
+ graph = merge_graphs(
+ common_source_checks(self.repository, tools, jobs=self.jobs),
+ python_coverage_graph(self.repository),
+ )
+ return await self._run_final(graph)
+
+ return await self._public("verify-source", export_dir, operation)
+
+ async def verify_arch(
+ self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None,
+ run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4,
+ warmup: int = 8, iterations: int = 100, windows: int = 3,
+ tolerance_bytes: int = 0, export_dir: Path | None = None,
+ ) -> tuple[Path, ...]:
+ if len(architectures) != 1:
+ raise ValueError("verify-arch requires exactly one architecture")
+ return await self._public(
+ "verify-arch", export_dir,
+ lambda: self._verify(
+ architectures, corpus=corpus, run_nonce=run_nonce,
+ fuzz_seconds=fuzz_seconds, test_shards=test_shards,
+ warmup=warmup, iterations=iterations, windows=windows,
+ tolerance_bytes=tolerance_bytes, include_common=False,
+ ),
+ )
+
+ async def verify(
+ self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None,
+ run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4,
+ warmup: int = 8, iterations: int = 100, windows: int = 3,
+ tolerance_bytes: int = 0, export_dir: Path | None = None,
+ ) -> tuple[Path, ...]:
+ return await self._public(
+ "verify", export_dir,
+ lambda: self._verify(
+ architectures, corpus=corpus, run_nonce=run_nonce,
+ fuzz_seconds=fuzz_seconds, test_shards=test_shards,
+ warmup=warmup, iterations=iterations, windows=windows,
+ tolerance_bytes=tolerance_bytes, include_common=True,
+ ),
+ )
diff --git a/build/graphs/__init__.py b/build/graphs/__init__.py
new file mode 100644
index 0000000..7e6586f
--- /dev/null
+++ b/build/graphs/__init__.py
@@ -0,0 +1 @@
+"""Repository build graphs."""
diff --git a/build/graphs/analysis.py b/build/graphs/analysis.py
new file mode 100644
index 0000000..9159fcd
--- /dev/null
+++ b/build/graphs/analysis.py
@@ -0,0 +1,532 @@
+"""Compiler-derived, translation-unit-grained analysis graphs."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import xml.etree.ElementTree as ET
+
+from core.graph import Graph, Node, Result
+from core.node import NodeFactory
+from core.paths import BuildPaths
+from core.quality_tools import ResolvedTool, resolve_llvm
+from core.toolchain import MsvcToolchain
+from graphs.common import (
+ COMMON_PROJECT_INPUTS, PLATFORMS, project_path, python_action, recipe_factory,
+ restore_node, tool_environment,
+)
+
+
+_BUILD_ROOT = Path(__file__).resolve().parents[1]
+_MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}"
+_HEADER_SUFFIXES = frozenset({".h", ".hh", ".hpp", ".hxx", ".inc", ".inl"})
+
+
+@dataclass(frozen=True, slots=True)
+class MsbuildProject:
+ name: str
+ path: Path
+ inputs: tuple[str, ...]
+ sources: tuple[Path, ...]
+ headers: tuple[Path, ...]
+
+
+def _relative(repository: Path, path: Path) -> str:
+ return path.resolve(strict=True).relative_to(repository).as_posix()
+
+
+def _platform(architecture: str) -> str:
+ try:
+ return PLATFORMS[architecture]
+ except KeyError as error:
+ raise ValueError(f"unsupported architecture: {architecture}") from error
+
+
+def project_inventory(
+ repository: Path, names: tuple[str, ...] | None = None, *,
+ include_link_inputs: bool = True,
+) -> tuple[MsbuildProject, ...]:
+ """Parse each selected project once into immutable signed inputs and sources."""
+
+ root = repository.resolve(strict=True)
+ paths = (tuple(sorted((root / "build/projects").glob("*.vcxproj"))) if names is None
+ else tuple(root / "build/projects" / f"{name}.vcxproj" for name in names))
+ projects = []
+ for project in paths:
+ document = ET.parse(project).getroot()
+ inputs = list(COMMON_PROJECT_INPUTS) + [project.relative_to(root).as_posix()]
+ if include_link_inputs:
+ inputs.extend(
+ project_path(root, item.text.strip(), project) for item in
+ document.iter(f"{_MSBUILD_NS}ModuleDefinitionFile") if item.text
+ )
+ sources = tuple(
+ root / project_path(root, item.get("Include", ""), project) for item in
+ document.iter(f"{_MSBUILD_NS}ClCompile") if item.get("Include")
+ )
+ headers = tuple(
+ root / project_path(root, item.get("Include", ""), project) for item in
+ document.iter(f"{_MSBUILD_NS}ClInclude") if item.get("Include")
+ )
+ projects.append(MsbuildProject(
+ project.stem, project, tuple(dict.fromkeys(inputs)), sources, headers
+ ))
+ return tuple(projects)
+
+
+def _projects(repository: Path) -> tuple[tuple[str, Path, Path, tuple[Path, ...]], ...]:
+ try:
+ projects = project_inventory(repository, include_link_inputs=False)
+ except ValueError as error:
+ message = str(error).replace("unsupported project input", "unsupported ClCompile path", 1)
+ raise ValueError(message) from error
+ return tuple(
+ (project.name, project.path, source, project.headers) for project in projects
+ for source in project.sources
+ )
+
+
+def _unit(repository: Path, source: Path) -> str:
+ return _relative(repository / "src", source).removesuffix(".cpp").replace("/", ".")
+
+
+def dependency_node_name(
+ repository: Path, architecture: str, project_name: str, source: Path,
+ qualifier: str = "",
+) -> str:
+ prefix = f"{architecture}-{qualifier}" if qualifier else architecture
+ return f"discover-dependencies-{prefix}-{project_name}-{_unit(repository, source)}"
+
+
+def _project_files(
+ repository: Path, project_name: str, project: Path, source: Path,
+ extra: tuple[str, ...] = (),
+) -> dict[str, bytes]:
+ inputs = list(COMMON_PROJECT_INPUTS) + [
+ _relative(repository, project), _relative(repository, source)
+ ]
+ if project_name.startswith("fuzz-"):
+ inputs.append("build/ObserverFuzz.props")
+ inputs.extend(extra)
+ return {path: (repository / path).read_bytes() for path in dict.fromkeys(inputs)}
+
+
+def _stable_headers(source: Path, project_headers: tuple[Path, ...]) -> tuple[Path, ...]:
+ local_headers = tuple(
+ path for path in source.parent.iterdir()
+ if path.is_file() and path.suffix.casefold() in _HEADER_SUFFIXES
+ )
+ return tuple(dict.fromkeys((*project_headers, *local_headers)))
+
+
+def _discovery_files(
+ repository: Path,
+ project_name: str,
+ project: Path,
+ source: Path,
+ headers: tuple[Path, ...],
+) -> dict[str, bytes]:
+ extra = tuple(
+ _relative(repository, path)
+ for path in _stable_headers(source, headers)
+ )
+ return _project_files(repository, project_name, project, source, extra)
+
+
+def _compile_variables(
+ toolchain: MsvcToolchain, project: Path, source: Path, restore_output: Path,
+ configuration: str, platform: str,
+) -> dict[str, object]:
+ return {
+ "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild),
+ "project": str(project), "target": "ClCompile",
+ "configuration": configuration, "platform": platform, "msbuild_args": [],
+ "source": str(source), "vcpkg_root": str(toolchain.vcpkg_root),
+ "vcpkg_installed": str(restore_output),
+ }
+
+
+def dependency_inputs(
+ repository: Path, restore_output: Path, source: Path, content: bytes,
+ project_headers: tuple[Path, ...] = (),
+) -> dict[str, bytes]:
+ """Validate one manifest and sign its covered project/package dependency bytes."""
+
+ try:
+ data = json.loads(content)["Data"]
+ reported_source, includes = data["Source"], data["Includes"]
+ if not isinstance(reported_source, str) or not isinstance(includes, list) or not all(
+ isinstance(item, str) for item in includes
+ ):
+ raise TypeError
+ reported = Path(reported_source).resolve(strict=True)
+ dependencies = [Path(item) for item in includes]
+ if not all(path.is_absolute() for path in dependencies):
+ raise TypeError
+ except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, OSError) as error:
+ raise ValueError(f"invalid MSVC dependency manifest for {source}") from error
+ expected = source.resolve(strict=True)
+ if reported != expected:
+ raise ValueError(f"MSVC dependency manifest source mismatch for {source}")
+
+ files = {"compiler/dependencies.json": content}
+ try:
+ source_root = (repository / "src").resolve(strict=True)
+ restore_root = restore_output.resolve(strict=False)
+ covered = {
+ path.resolve(strict=True)
+ for path in _stable_headers(source, project_headers)
+ }
+ for candidate in dict.fromkeys((expected, *dependencies)):
+ resolved = candidate.resolve(strict=False)
+ if resolved.is_relative_to(source_root):
+ path = candidate.resolve(strict=True)
+ name = _relative(repository, path)
+ if path != expected and path not in covered:
+ raise ValueError(
+ "first-party dependency is not covered by the stable header "
+ f"inventory for {source}: {name}"
+ )
+ elif resolved.is_relative_to(restore_root):
+ path = candidate.resolve(strict=True)
+ name = "vcpkg/" + path.relative_to(restore_root).as_posix()
+ else:
+ continue
+ files[name] = path.read_bytes()
+ except OSError as error:
+ raise ValueError(f"invalid MSVC dependency manifest for {source}") from error
+ return files
+
+
+def project_build(
+ repository: Path, factory: NodeFactory, discovery: Graph,
+ manifests: Mapping[str, bytes], restore_output: Path, project: MsbuildProject,
+ architecture: str, qualifier: str, template: str, variables: dict[str, object],
+ *, identity: dict[str, str] | None = None, config: dict[str, str],
+) -> Node:
+ """Create one build from the project's exact signed bytes and TU predecessors."""
+
+ files = {path: (repository / path).read_bytes() for path in project.inputs}
+ dependencies = []
+ for source in project.sources:
+ name = dependency_node_name(
+ repository, architecture, project.name, source, qualifier
+ )
+ dependencies.append(discovery.node(name))
+ try:
+ manifest = manifests[name]
+ except KeyError as error:
+ raise ValueError(f"missing dependency manifest: {name}") from error
+ files.update(dependency_inputs(
+ repository, restore_output, source, manifest, project.headers
+ ))
+ return factory.make(
+ template, f"build-{project.name}-{architecture}-{qualifier}", "slot", variables,
+ files=files, dependencies=tuple(dependencies), identity=identity, config=config,
+ )
+
+
+def _dependency_node(
+ repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, restore: Node,
+ unit: tuple[str, Path, Path, tuple[Path, ...]], namespace: str, architecture: str,
+ platform: str, configuration: str | None, qualifier: str,
+) -> Node:
+ project_name, project, source, headers = unit
+ name = dependency_node_name(
+ repository, architecture, project_name, source, qualifier
+ )
+ restore_output = BuildPaths(repository).cas(restore.uid).output
+ files = _discovery_files(repository, project_name, project, source, headers)
+ variables = _compile_variables(
+ toolchain, project, source, restore_output,
+ configuration or ("Release" if project_name == "leak-probe" else "Debug"),
+ platform,
+ )
+ return factory.make(
+ "source-dependencies.ps1",
+ name,
+ "slot",
+ variables,
+ files=files,
+ dependencies=(restore,),
+ config={"architecture": architecture, "source_namespace": namespace},
+ )
+
+
+def dependency_discovery_slice(
+ repository: Path, toolchain: MsvcToolchain, *,
+ project_names: tuple[str, ...] | None = None, configuration: str | None = None,
+ restore_flavor: str = "", name_qualifier: str = "", jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+) -> Graph:
+ """Create cacheable per-TU MSVC ``/sourceDependencies`` nodes."""
+
+ root = repository.resolve(strict=True)
+ factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain))
+ namespace = "\n".join(
+ _relative(root, path)
+ for path in sorted(path for path in (root / "src").rglob("*") if path.is_file())
+ )
+ projects = tuple(
+ unit for unit in _projects(root) if project_names is None or unit[0] in project_names
+ )
+ def create() -> Graph:
+ nodes, targets = [], []
+ for architecture in architectures:
+ platform = _platform(architecture)
+ restore = restore_node(
+ root, toolchain, factory, architecture, flavor=restore_flavor
+ )
+ nodes.append(restore)
+ for unit in projects:
+ project_name = unit[0]
+ if project_name == "leak-probe" and architecture != "x64":
+ continue
+ node = _dependency_node(
+ root, toolchain, factory, restore, unit, namespace, architecture,
+ platform, configuration, name_qualifier,
+ )
+ nodes.append(node)
+ targets.append(node.name)
+ return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs})
+
+ return create()
+
+
+def _exact_identity(prefix: str, tool: ResolvedTool) -> dict[str, str]:
+ return {f"{prefix}.{key}": value for key, value in tool.identity}
+
+
+def clang_dependency_discovery_slice(
+ repository: Path, toolchain: MsvcToolchain, *,
+ project_names: tuple[str, ...] | None = None, configuration: str,
+ restore_flavor: str = "", name_qualifier: str = "", jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+) -> Graph:
+ """Capture actual clang-cl commands and resolve their exact header graph."""
+
+ root = repository.resolve(strict=True)
+ environment = tool_environment(toolchain)
+ base_identity = dict(toolchain.identity)
+ clang = resolve_llvm(toolchain, "clang-cl")
+ scanner = resolve_llvm(toolchain, "clang-scan-deps")
+ clang_factory = recipe_factory(
+ root,
+ base_identity | _exact_identity("clang_cl", clang),
+ environment,
+ )
+ scan_factory = recipe_factory(root, base_identity, environment)
+ namespace = "\n".join(
+ _relative(root, path)
+ for path in sorted(path for path in (root / "src").rglob("*") if path.is_file())
+ )
+ projects = tuple(
+ unit for unit in _projects(root) if project_names is None or unit[0] in project_names
+ )
+ paths = BuildPaths(root)
+
+ def create() -> Graph:
+ nodes: list[Node] = []
+ targets: list[str] = []
+ for architecture in architectures:
+ platform = _platform(architecture)
+ restore = restore_node(
+ root, toolchain, clang_factory, architecture, flavor=restore_flavor
+ )
+ nodes.append(restore)
+ restore_output = paths.cas(restore.uid).output
+ for project_name, project, source, headers in projects:
+ if project_name == "leak-probe" and architecture != "x64":
+ continue
+ name = dependency_node_name(
+ root, architecture, project_name, source, name_qualifier
+ )
+ files = _discovery_files(root, project_name, project, source, headers)
+ capture = clang_factory.make(
+ "clang-command.ps1",
+ name.replace("discover-dependencies-", "capture-clang-command-", 1),
+ "slot",
+ _compile_variables(
+ toolchain,
+ project,
+ source,
+ restore_output,
+ configuration,
+ platform,
+ ) | {"llvm_dir": str(toolchain.llvm_dir)},
+ files=files,
+ dependencies=(restore,),
+ config={"architecture": architecture, "source_namespace": namespace},
+ )
+ command_file = paths.cas(capture.uid).output / "compile-command.json"
+ scan = python_action(
+ scan_factory,
+ name,
+ "core.clang_dependencies",
+ (
+ "scan",
+ str(source),
+ str(command_file),
+ str(scanner.path),
+ str(clang.path),
+ ),
+ (capture,),
+ pool="slot",
+ environment=environment,
+ identity=_exact_identity("clang_scan_deps", scanner),
+ config={"architecture": architecture, "source_namespace": namespace},
+ )
+ nodes.extend((capture, scan))
+ targets.append(scan.name)
+ return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs})
+
+ return create()
+
+
+def analysis_discovery_slice(
+ repository: Path, toolchain: MsvcToolchain, jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+) -> Graph:
+ return dependency_discovery_slice(
+ repository, toolchain, jobs=jobs, architectures=architectures
+ )
+
+
+def load_dependency_manifests(repository: Path, discovery: Graph) -> dict[str, bytes]:
+ """Load manifests only from completed discovery nodes' exact CAS entries."""
+
+ paths, manifests = BuildPaths(repository.resolve(strict=True)), {}
+ for name in discovery.targets:
+ node = discovery.node(name)
+ cas = paths.cas(node.uid)
+ manifest = paths.require_confined(
+ cas.output / "dependencies.json", paths.cas_root
+ )
+ if not cas.touch.is_file() or cas.touch.stat().st_size or not manifest.is_file():
+ raise FileNotFoundError(f"dependency discovery is incomplete: {name}")
+ manifests[name] = manifest.read_bytes()
+ return manifests
+
+
+def _raw(
+ repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, discovery: Node,
+ restore_output: Path, unit: tuple[str, Path, Path, tuple[Path, ...]], backend: str,
+ dependencies: Mapping[str, bytes],
+ architecture: str, platform: str,
+) -> Node:
+ project_name, project, source, _headers = unit
+ slug, template, extra = {
+ "msvc": ("msvc", "msvc-analyze.ps1", ("build/ObserverNativeAnalysis.ruleset",)),
+ "clang-tidy": ("tidy", "clang-tidy.ps1", (".clang-tidy",)),
+ }[backend]
+ files = _project_files(repository, project_name, project, source, extra)
+ files.update(dependencies)
+ variables = _compile_variables(
+ toolchain, project, source, restore_output,
+ "Release" if project_name == "leak-probe" else "Debug",
+ platform,
+ ) | {"project_name": project_name, "llvm_dir": str(toolchain.llvm_dir)}
+ return factory.make(
+ template,
+ f"analyze-{slug}-{architecture}-{project_name}-{_unit(repository, source)}",
+ "slot",
+ variables,
+ files=files,
+ dependencies=(discovery,),
+ config={"architecture": architecture, "backend": backend},
+ )
+
+
+def analysis_slice(
+ repository: Path, toolchain: MsvcToolchain, *, discovery: Graph,
+ manifests: Mapping[str, bytes], jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+) -> Graph:
+ """Analyze compiler-discovered TU inputs, normalize, merge, and gate."""
+
+ root = repository.resolve(strict=True)
+ factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain))
+ paths = BuildPaths(root)
+
+ def output(node: Node) -> Path:
+ return paths.cas(node.uid).output
+
+ nodes, targets, projects = list(discovery.nodes), [], _projects(root)
+ for architecture in architectures:
+ platform = _platform(architecture)
+ restore = discovery.node(f"restore-vcpkg-{architecture}")
+ normalized = []
+ for unit in projects:
+ project_name, _project, source, headers = unit
+ if project_name == "leak-probe" and architecture != "x64":
+ continue
+ unit_name = _unit(root, source)
+ suffix = f"{architecture}-{project_name}-{unit_name}"
+ discovery_name = dependency_node_name(root, architecture, project_name, source)
+ discovered = discovery.node(discovery_name)
+ try:
+ manifest = manifests[discovery_name]
+ except KeyError as error:
+ raise ValueError(f"missing dependency manifest: {discovery_name}") from error
+ dependencies = dependency_inputs(
+ root, output(restore), source, manifest, headers
+ )
+ raw_nodes = tuple(
+ (backend, _raw(
+ root, toolchain, factory, discovered, output(restore), unit, backend,
+ dependencies, architecture, platform,
+ ))
+ for backend in ("msvc", "clang-tidy")
+ )
+ nodes.extend(raw for _backend, raw in raw_nodes)
+ for backend, raw in raw_nodes:
+ action = "normalize-msvc" if backend == "msvc" else "convert-tidy"
+ source_input = output(raw) / (
+ f"{project_name}.sarif" if backend == "msvc" else "obj"
+ )
+ automation = (
+ f"{'msvc-analyze' if backend == 'msvc' else 'clang-tidy'}/"
+ f"{architecture}/{project_name}/{unit_name}/"
+ )
+ arguments = (
+ (action, str(source_input), automation)
+ if backend == "msvc"
+ else (action, str(root), str(source_input), automation)
+ )
+ normal = python_action(
+ factory,
+ f"normalize-{'msvc' if backend == 'msvc' else 'tidy'}-{suffix}",
+ "core.sarif",
+ arguments,
+ (raw,),
+ pool="misc",
+ )
+ nodes.append(normal)
+ normalized.append(normal)
+ merged = python_action(
+ factory,
+ f"merge-analysis-{architecture}",
+ "core.sarif",
+ ("merge", *(str(output(node) / "renpy.sarif") for node in normalized)),
+ tuple(normalized),
+ pool="misc",
+ results=(Result(
+ f"reports/sarif/{architecture}/analysis.sarif",
+ "sarif",
+ "application/sarif+json",
+ "analysis.sarif",
+ ),),
+ )
+ gate = python_action(
+ factory,
+ f"analysis-{architecture}",
+ "core.sarif",
+ ("gate", str(output(merged) / "analysis.sarif")),
+ (merged,),
+ pool="misc",
+ )
+ nodes.extend((merged, gate))
+ targets.append(gate.name)
+ return Graph(tuple(nodes), tuple(targets), {"misc": jobs, "restore": 1, "slot": jobs})
diff --git a/build/graphs/audit.py b/build/graphs/audit.py
new file mode 100644
index 0000000..09bdf35
--- /dev/null
+++ b/build/graphs/audit.py
@@ -0,0 +1,109 @@
+"""Fine-grained PE and BinSkim audit nodes over explicit release artifacts."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from pathlib import Path
+
+from core.graph import Graph, Node, Result
+from core.paths import BuildPaths
+from graphs.common import BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, require_positive_integers, require_tool
+
+
+_ARCHITECTURES = {"x86", "x64", "arm64"}
+_MODULES = ("renpy", "rpgmaker", "zanzarah")
+
+
+def audit_graph(
+ repository: Path,
+ upstream: Graph,
+ *,
+ architectures: tuple[str, ...] = ("x64",),
+ include_leak_probe: bool = False,
+ dumpbin: Path,
+ dumpbin_identity: Mapping[str, str],
+ binskim: Path,
+ binskim_identity: Mapping[str, str],
+ jobs: int = 2,
+) -> Graph:
+ """Compose independent release-binary audits over a validated upstream graph."""
+
+ require_positive_integers((jobs,), "audit pool capacities must be positive integers")
+ root = repository.resolve(strict=True)
+ paths = BuildPaths(root)
+ if (
+ not architectures
+ or len(architectures) != len(set(architectures))
+ or any(item not in _ARCHITECTURES for item in architectures)
+ ):
+ raise ValueError("audit architectures must be a unique non-empty supported set")
+ dumpbin = require_tool(dumpbin, "dumpbin")
+ binskim = require_tool(binskim, "BinSkim")
+ dump_factory = recipe_factory(root, dict(dumpbin_identity) | {"path": str(dumpbin)})
+ python_factory = recipe_factory(BUILD_ROOT, {})
+ audit_nodes: list[Node] = []
+ targets: list[str] = []
+ modules = (("leak-probe",) if include_leak_probe else ()) + _MODULES
+
+ for architecture in sorted(architectures):
+ for module in modules:
+ producer, binary = canonical_artifact(
+ paths,
+ upstream,
+ f"build-{module}-{architecture}-release",
+ "leak-probe.exe" if module == "leak-probe" else f"{module}.so",
+ )
+
+ dump_nodes = []
+ for mode in ("headers", "dependents", "exports"):
+ current = dump_factory.make(
+ "argv.json",
+ f"audit-dumpbin-{mode}-{architecture}-{module}",
+ "slot",
+ {"argv": (str(dumpbin), f"/{mode}", str(binary))},
+ files={},
+ dependencies=(producer,),
+ config={"action": mode, "architecture": architecture, "module": module},
+ )
+ dump_nodes.append(current)
+ pe_gate = python_action(
+ python_factory,
+ f"audit-pe-{architecture}-{module}",
+ "core.binary_audit",
+ (
+ "pe", architecture,
+ *(str(paths.cas(node.uid).log) for node in dump_nodes),
+ ),
+ tuple(dump_nodes),
+ pool="slot",
+ )
+ binskim_run = python_action(
+ python_factory,
+ f"audit-binskim-run-{architecture}-{module}",
+ "core.binary_audit",
+ ("run-binskim", str(binskim), str(binary)),
+ (producer,),
+ pool="slot",
+ identity=dict(binskim_identity) | {"path": str(binskim)},
+ config={"action": "run-binskim", "architecture": architecture, "module": module},
+ results=(Result(
+ f"reports/sarif/{architecture}/binskim-{module}.sarif",
+ "sarif",
+ "application/sarif+json",
+ "binskim.sarif",
+ ),),
+ )
+ report = paths.cas(binskim_run.uid).output / "binskim.sarif"
+ binskim_gate = python_action(
+ python_factory,
+ f"audit-binskim-{architecture}-{module}",
+ "core.binary_audit",
+ ("binskim", str(report)),
+ (binskim_run,),
+ pool="slot",
+ )
+ audit_nodes.extend((*dump_nodes, pe_gate, binskim_run, binskim_gate))
+ targets.extend((pe_gate.name, binskim_gate.name))
+
+ pools = extend_pools(upstream, {"slot": jobs})
+ return Graph(upstream.nodes + tuple(audit_nodes), tuple(targets), pools)
diff --git a/build/graphs/common.py b/build/graphs/common.py
new file mode 100644
index 0000000..78c4f3f
--- /dev/null
+++ b/build/graphs/common.py
@@ -0,0 +1,166 @@
+"""Shared recipe plumbing for repository build graphs."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+import hashlib
+import os
+from pathlib import Path
+import sys
+
+from core.graph import Graph, Node, Result
+from core.node import NodeFactory
+from core.paths import BuildPaths
+from core.render import TemplateRenderer
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+PROJECTS = ("renpy", "rpgmaker", "zanzarah", "tests")
+BINARIES = {**{name: f"{name}.so" for name in PROJECTS[:-1]}, "tests": "tests.exe"}
+PLATFORMS = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"}
+_PROJECT_PREFIX = "$(RepositoryRoot)"
+COMMON_PROJECT_INPUTS = (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+)
+
+
+def recipe_factory(
+ cwd: Path,
+ identity: dict[str, str],
+ environment: tuple[tuple[str, str], ...] = (),
+) -> NodeFactory:
+ return NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), cwd, identity, environment)
+
+
+def require_positive_integers(values: Iterable[object], message: str) -> None:
+ if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in values):
+ raise ValueError(message)
+
+
+def require_tool(path: Path, name: str) -> Path:
+ resolved = path.resolve(strict=True)
+ if not resolved.is_file():
+ raise FileNotFoundError(f"{name} is not a file: {resolved}")
+ return resolved
+
+
+def prefixed_identity(prefix: str, path: Path, values: Mapping[str, str]) -> dict[str, str]:
+ return {f"{prefix}.{key}": value for key, value in values.items()} | {
+ f"{prefix}.path": str(path)
+ }
+
+
+def extend_pools(upstream: Graph, additions: Mapping[str, int]) -> dict[str, int]:
+ pools = dict(upstream.pools)
+ for name, capacity in additions.items():
+ if name in pools and pools[name] != capacity:
+ raise ValueError(f"conflicting pool capacity for {name}")
+ pools[name] = capacity
+ return pools
+
+
+def canonical_artifact(
+ paths: BuildPaths, upstream: Graph, producer_name: str, relative_path: str
+) -> tuple[Node, Path]:
+ producer = upstream.node(producer_name)
+ return producer, paths.cas(producer.uid).output / relative_path
+
+
+def require_ancestor(upstream: Graph, producer: Node, ancestor: str, message: str) -> None:
+ pending, seen = list(producer.inputs), set()
+ while pending:
+ name = pending.pop()
+ if name == ancestor:
+ return
+ if name not in seen:
+ seen.add(name)
+ pending.extend(upstream.node(name).inputs)
+ raise ValueError(message)
+
+
+def required_audit_gates(upstream: Graph, producer: Node, architecture: str, module: str) -> tuple[Node, Node]:
+ try:
+ gates = tuple(upstream.node(f"audit-{kind}-{architecture}-{module}") for kind in ("pe", "binskim"))
+ except ValueError as error:
+ raise ValueError(f"missing audit gates for {architecture}/{module}") from error
+ for gate in gates:
+ require_ancestor(
+ upstream, gate, producer.name,
+ f"audit gates do not consume producer for {architecture}/{module}",
+ )
+ return gates
+
+
+def project_path(repository: Path, value: str, project: Path) -> str:
+ if not value.startswith(_PROJECT_PREFIX):
+ raise ValueError(f"unsupported project input in {project}: {value}")
+ path = repository / value.removeprefix(_PROJECT_PREFIX).replace("\\", "/")
+ return path.resolve(strict=True).relative_to(repository).as_posix()
+
+
+def tool_environment(toolchain: object, *, prepend_path: Path | None = None,
+ extra: tuple[tuple[str, str], ...] = ()) -> tuple[tuple[str, str], ...]:
+ values = {key.casefold(): (key, value) for key, value in toolchain.environment}
+ if hasattr(toolchain, "vcpkg_root"):
+ values["vcpkg_root"] = ("VCPKG_ROOT", str(toolchain.vcpkg_root))
+ if prepend_path is not None:
+ current = values.get("path", ("PATH", ""))[1]
+ values["path"] = ("PATH", str(prepend_path) + (os.pathsep + current if current else ""))
+ values.update((key.casefold(), (key, value)) for key, value in extra)
+ return tuple(values.values())
+
+
+def _restore_identity(toolchain: object, vcpkg: Path) -> dict[str, str]:
+ identity = {
+ "pwsh": str(toolchain.pwsh),
+ "vcpkg": str(vcpkg),
+ "vcpkg_root": str(toolchain.vcpkg_root),
+ }
+ for name, path in (("pwsh", Path(toolchain.pwsh)), ("vcpkg", vcpkg)):
+ if path.is_file():
+ with path.open("rb") as stream:
+ identity[f"{name}_sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
+ return identity
+
+
+def restore_node(repository: Path, toolchain: object, factory: NodeFactory, architecture: str,
+ *, flavor: str = "") -> Node:
+ qualifier = f"-{flavor}" if flavor else ""
+ triplet = f"observer-{architecture}-windows-static{qualifier}"
+ inputs = ("vcpkg.json", f"build/vcpkg/triplets/{triplet}.cmake")
+ vcpkg = Path(toolchain.vcpkg_root) / "vcpkg.exe"
+ return factory.make(
+ "vcpkg.ps1", f"restore-vcpkg{qualifier}-{architecture}", "restore", {
+ "pwsh": str(toolchain.pwsh),
+ "vcpkg": str(vcpkg),
+ "repository": str(repository),
+ "triplet": triplet,
+ },
+ files={path: (repository / path).read_bytes() for path in inputs},
+ config={"action": "restore", "architecture": architecture, "triplet": triplet},
+ identity=_restore_identity(toolchain, vcpkg),
+ environment=tuple(sorted((key.upper(), value) for key, value in tool_environment(toolchain))),
+ cwd=repository,
+ )
+
+
+def python_action(factory: NodeFactory, name: str, module: str, arguments: tuple[str, ...],
+ dependencies: tuple[Node, ...], *, pool: str,
+ environment: tuple[tuple[str, str], ...] = (), files: Mapping[str, bytes] | None = None,
+ identity: Mapping[str, str] | None = None,
+ config: Mapping[str, str] | None = None,
+ results: tuple[Result, ...] = ()) -> Node:
+ executable = str(Path(sys.executable).resolve())
+ source = BUILD_ROOT.joinpath(*module.split(".")).with_suffix(".py")
+ return factory.make(
+ "argv.json", name, pool, {"argv": (executable, "-m", module) + arguments},
+ files={source.relative_to(BUILD_ROOT.parent).as_posix(): source.read_bytes()} | dict(files or {}),
+ dependencies=dependencies,
+ results=results,
+ identity=dict(identity or {}) | {"python": sys.version, "python_executable": executable},
+ config={"action": arguments[0], "platform": "windows"} | dict(config or {}),
+ environment=environment,
+ cwd=BUILD_ROOT,
+ )
diff --git a/build/graphs/coverage.py b/build/graphs/coverage.py
new file mode 100644
index 0000000..8e25a66
--- /dev/null
+++ b/build/graphs/coverage.py
@@ -0,0 +1,299 @@
+"""Fine-grained LLVM source coverage over explicit instrumented build artifacts."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from pathlib import Path
+
+from core.graph import Graph, Node, Result
+from core.paths import BuildPaths
+from core.quality_tools import resolve_llvm, resolve_tool
+from core.toolchain import MsvcToolchain
+from graphs.common import (
+ BINARIES,
+ BUILD_ROOT,
+ canonical_artifact,
+ extend_pools,
+ prefixed_identity,
+ python_action,
+ recipe_factory,
+ require_ancestor,
+ require_positive_integers,
+ require_tool,
+ tool_environment,
+)
+from graphs.instrumented import InstrumentedVariant, instrumented_build_slice, instrumented_dependency_discovery_slice
+
+
+_ARCHITECTURES = {"x86", "x64", "arm64"}
+_IGNORED_SOURCES = (
+ r"([\\/]src[\\/](tests|fuzz)[\\/])|([\\/]vcpkg_installed[\\/])|"
+ r"([\\/]Microsoft Visual Studio[\\/])|([\\/]Windows Kits[\\/])"
+)
+
+
+def _builds(
+ paths: BuildPaths,
+ upstream: Graph,
+ architectures: tuple[str, ...],
+) -> dict[str, dict[str, tuple[Node, Path]]]:
+ if (
+ not architectures
+ or len(architectures) != len(set(architectures))
+ or any(item not in _ARCHITECTURES for item in architectures)
+ ):
+ raise ValueError("coverage architectures must be a unique non-empty supported set")
+ selected = {}
+ for architecture in architectures:
+ group = {}
+ for name, filename in BINARIES.items():
+ producer, path = canonical_artifact(
+ paths, upstream, f"build-{name}-{architecture}-coverage", filename
+ )
+ group[name] = (producer, path)
+ restore = f"restore-vcpkg-{architecture}"
+ for producer, _path in group.values():
+ require_ancestor(
+ upstream, producer, restore,
+ f"coverage build requires {restore} as a restore ancestor",
+ )
+ selected[architecture] = group
+ return selected
+
+
+def coverage_artifact_graph(
+ repository: Path,
+ upstream: Graph,
+ *,
+ architectures: tuple[str, ...],
+ pwsh: Path,
+ pwsh_identity: Mapping[str, str],
+ llvm_profdata: Path,
+ llvm_profdata_identity: Mapping[str, str],
+ llvm_cov: Path,
+ llvm_cov_identity: Mapping[str, str],
+ environment: tuple[tuple[str, str], ...] = (),
+ test_shards: int = 4,
+ jobs: int = 4,
+ report_jobs: int = 2,
+ corpus: Path | None = None,
+ run_nonce: str = "",
+) -> Graph:
+ """Compose shard, merge, report, and immutable 100% gates per runnable arch."""
+
+ require_positive_integers(
+ (test_shards, jobs, report_jobs),
+ "coverage counts and pool capacities must be positive integers",
+ )
+ corpus_path = None
+ if corpus is not None:
+ if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce:
+ raise ValueError("corpus run nonce must be a non-empty string without NUL")
+ corpus_path = Path(corpus).resolve(strict=True)
+ if not corpus_path.is_dir():
+ raise NotADirectoryError(corpus_path)
+ root = repository.resolve(strict=True)
+ paths = BuildPaths(root)
+ pwsh = require_tool(pwsh, "PowerShell")
+ llvm_profdata = require_tool(llvm_profdata, "llvm-profdata")
+ llvm_cov = require_tool(llvm_cov, "llvm-cov")
+ selected = _builds(paths, upstream, architectures)
+ pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity)
+ shard_factory = recipe_factory(root, pwsh_id, environment)
+ merge_factory = recipe_factory(
+ root,
+ pwsh_id | prefixed_identity("llvm_profdata", llvm_profdata, llvm_profdata_identity),
+ environment,
+ )
+ report_factory = recipe_factory(
+ root,
+ pwsh_id | prefixed_identity("llvm_cov", llvm_cov, llvm_cov_identity),
+ environment,
+ )
+ gate_factory = recipe_factory(BUILD_ROOT, {})
+ nodes: list[Node] = []
+ targets: list[str] = []
+
+ def output(node: Node, filename: str = "") -> Path:
+ return paths.cas(node.uid).output / filename
+
+ for architecture, group in sorted(selected.items()):
+ builds = tuple(group[name][0] for name in BINARIES)
+ copies = tuple(
+ {"name": BINARIES[name], "source": str(group[name][1])} for name in BINARIES
+ )
+ shards = tuple(
+ shard_factory.make(
+ "coverage-test.ps1",
+ f"coverage-test-{architecture}-{index}",
+ "coverage-shard",
+ {
+ "pwsh": str(pwsh), "artifacts": copies,
+ "shard_count": test_shards, "shard_index": index,
+ },
+ files={}, dependencies=builds,
+ results=(Result(
+ f"reports/coverage/cpp/{architecture}/tests/shard-{index}.xml",
+ "test",
+ "application/xml",
+ "tests.xml",
+ ),),
+ config={
+ "action": "coverage-test", "architecture": architecture,
+ "shard_count": str(test_shards), "shard_index": str(index),
+ },
+ )
+ for index in range(test_shards)
+ )
+ corpus_shards: tuple[Node, ...] = ()
+ if corpus_path is not None:
+ corpus_environment = tuple(
+ pair for pair in environment
+ if pair[0].casefold() != "observer_test_corpus"
+ ) + (("OBSERVER_TEST_CORPUS", str(corpus_path)),)
+ corpus_shards = tuple(
+ shard_factory.make(
+ "coverage-corpus-test.ps1",
+ f"coverage-corpus-{architecture}-{index}",
+ "coverage-shard",
+ {
+ "pwsh": str(pwsh), "artifacts": copies,
+ "shard_count": test_shards, "shard_index": index,
+ },
+ files={}, dependencies=builds,
+ results=(Result(
+ f"reports/coverage/cpp/{architecture}/corpus/shard-{index}.xml",
+ "test",
+ "application/xml",
+ "tests.xml",
+ ),),
+ config={
+ "action": "coverage-corpus-test",
+ "architecture": architecture,
+ "shard_count": str(test_shards),
+ "shard_index": str(index),
+ "corpus": str(corpus_path),
+ "run_nonce": run_nonce,
+ },
+ environment=corpus_environment,
+ )
+ for index in range(test_shards)
+ )
+ profile_shards = shards + corpus_shards
+ merge = merge_factory.make(
+ "coverage-merge.ps1",
+ f"coverage-merge-{architecture}",
+ "coverage-merge",
+ {
+ "pwsh": str(pwsh), "llvm_profdata": str(llvm_profdata),
+ "profile_directories": tuple(str(output(shard)) for shard in profile_shards),
+ },
+ files={}, dependencies=profile_shards,
+ config={"action": "coverage-merge", "architecture": architecture},
+ )
+ profile = output(merge, "coverage.profdata")
+ reports = []
+ for kind, summary_only in (("json", True), ("lcov", False)):
+ report = report_factory.make(
+ "coverage-report.ps1",
+ f"coverage-{kind}-{architecture}",
+ "coverage-report",
+ {
+ "pwsh": str(pwsh), "llvm_cov": str(llvm_cov),
+ "test_executable": str(group["tests"][1]),
+ "profile": str(profile), "ignore_regex": _IGNORED_SOURCES,
+ "objects": tuple(str(group[name][1]) for name in BINARIES if name != "tests"),
+ "summary_only": summary_only, "report_name": f"coverage.{kind}",
+ },
+ files={}, dependencies=(merge, *builds),
+ results=(Result(
+ f"reports/coverage/cpp/{architecture}/coverage.{kind}",
+ "coverage",
+ "application/json" if kind == "json" else "text/plain",
+ f"coverage.{kind}",
+ ),),
+ config={"action": f"coverage-{kind}", "architecture": architecture},
+ )
+ reports.append(report)
+ gate = python_action(
+ gate_factory,
+ f"coverage-{architecture}",
+ "core.cpp_coverage",
+ ("gate", str(output(reports[0], "coverage.json"))),
+ tuple(reports),
+ pool="coverage-gate",
+ )
+ nodes.extend((*profile_shards, merge, *reports, gate))
+ targets.append(gate.name)
+
+ pools = extend_pools(
+ upstream,
+ {
+ "coverage-shard": jobs,
+ "coverage-merge": len(selected),
+ "coverage-report": report_jobs,
+ "coverage-gate": jobs,
+ },
+ )
+ return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools)
+
+
+def coverage_dependency_discovery_slice(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+) -> Graph:
+ """Discover exact inputs for every requested Coverage build."""
+
+ variants = tuple(InstrumentedVariant("coverage", item) for item in architectures)
+ return instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants, jobs=jobs
+ )
+
+
+def coverage_graph(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ discovery: Graph | None,
+ manifests: Mapping[str, bytes],
+ jobs: int = 4,
+ architectures: tuple[str, ...] = ("x64",),
+ test_shards: int = 4,
+ report_jobs: int = 2,
+ corpus: Path | None = None,
+ run_nonce: str = "",
+) -> Graph:
+ """Build instrumented C++ artifacts, run shards, report, and gate coverage."""
+
+ variants = tuple(InstrumentedVariant("coverage", item) for item in architectures)
+ upstream = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=variants,
+ jobs=jobs,
+ )
+ pwsh = resolve_tool(toolchain.pwsh, "PowerShell")
+ profdata = resolve_llvm(toolchain, "llvm-profdata")
+ cov = resolve_llvm(toolchain, "llvm-cov")
+ return coverage_artifact_graph(
+ repository,
+ upstream,
+ architectures=architectures,
+ pwsh=pwsh.path,
+ pwsh_identity=dict(pwsh.identity),
+ llvm_profdata=profdata.path,
+ llvm_profdata_identity=dict(profdata.identity),
+ llvm_cov=cov.path,
+ llvm_cov_identity=dict(cov.identity),
+ environment=tool_environment(toolchain),
+ test_shards=test_shards,
+ jobs=jobs,
+ report_jobs=report_jobs,
+ corpus=corpus,
+ run_nonce=run_nonce,
+ )
diff --git a/build/graphs/fuzz.py b/build/graphs/fuzz.py
new file mode 100644
index 0000000..f7c0ee9
--- /dev/null
+++ b/build/graphs/fuzz.py
@@ -0,0 +1,287 @@
+"""Independent build, seed-replay, and bounded-run branches for every fuzzer."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from pathlib import Path
+import re
+
+from core.graph import Graph, Node, Result
+from core.paths import BuildPaths
+from core.toolchain import MsvcToolchain
+from graphs.analysis import (
+ clang_dependency_discovery_slice,
+ dependency_inputs,
+ dependency_node_name,
+ project_inventory,
+)
+from graphs.common import recipe_factory, tool_environment
+
+
+_BUILD_ROOT = Path(__file__).resolve().parents[1]
+_TARGETS = {"pickle": 262144, "renpy": 1048576, "rpgmaker": 1048576, "zanzarah": 1048576}
+FUZZ_TARGETS = tuple(_TARGETS)
+_COMMON = (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/ObserverFuzz.props",
+)
+_ASAN_OPTIONS = "halt_on_error=1:alloc_dealloc_mismatch=1"
+_UID = re.compile(r"[0-9a-f]{32}")
+
+
+@dataclass(frozen=True, slots=True)
+class FuzzCorpusArtifact:
+ """Reference one successfully published timed-run corpus by canonical CAS UID."""
+
+ target: str
+ producer_uid: str
+
+ def __post_init__(self) -> None:
+ if self.target not in _TARGETS:
+ raise ValueError(f"unsupported fuzz corpus target: {self.target}")
+ if not isinstance(self.producer_uid, str) or not _UID.fullmatch(self.producer_uid):
+ raise ValueError("fuzz corpus producer UID must be a canonical MD5")
+
+
+def fuzz_corpus_artifacts(graph: Graph) -> tuple[FuzzCorpusArtifact, ...]:
+ return tuple(
+ FuzzCorpusArtifact(target, graph.node(f"run-fuzz-x64-{target}").uid)
+ for target in FUZZ_TARGETS if f"fuzz-x64-{target}" in graph.targets
+ )
+
+
+def _selected_targets(targets: tuple[str, ...]) -> tuple[str, ...]:
+ if not targets:
+ raise ValueError("fuzz targets must not be empty")
+ selected = set()
+ for target in targets:
+ if target not in _TARGETS:
+ raise ValueError(f"unsupported fuzz target: {target}")
+ if target in selected:
+ raise ValueError(f"duplicate fuzz target: {target}")
+ selected.add(target)
+ return targets
+
+
+def _runtime(toolchain: MsvcToolchain) -> Path:
+ candidates = sorted(toolchain.llvm_dir.glob("lib/clang/*/lib/windows"), reverse=True)
+ if not candidates or not candidates[0].is_dir():
+ raise FileNotFoundError(f"LLVM sanitizer runtimes not found below {toolchain.llvm_dir}")
+ return candidates[0].resolve()
+
+
+def fuzz_dependency_discovery_slice(
+ repository: Path, toolchain: MsvcToolchain, *, jobs: int = 2,
+ targets: tuple[str, ...] = FUZZ_TARGETS,
+) -> Graph:
+ """Discover Fuzz-configuration dependencies against the ASan triplet."""
+
+ return clang_dependency_discovery_slice(
+ repository, toolchain,
+ project_names=tuple(f"fuzz-{target}" for target in _selected_targets(targets)),
+ configuration="Fuzz", restore_flavor="asan", name_qualifier="fuzz", jobs=jobs,
+ )
+
+
+def _project_files(
+ repository: Path, target: str, restore_output: Path, discovery: Graph,
+ manifests: Mapping[str, bytes],
+) -> tuple[dict[str, bytes], tuple[Node, ...]]:
+ try:
+ project = project_inventory(
+ repository, (f"fuzz-{target}",), include_link_inputs=False
+ )[0]
+ except ValueError as error:
+ message = str(error).replace(
+ "unsupported project input", "unsupported ClCompile path", 1
+ )
+ raise ValueError(message) from error
+ paths = [repository / relative for relative in _COMMON] + [project.path]
+ files = {}
+ dependencies = []
+ for source in project.sources:
+ name = dependency_node_name(repository, "x64", f"fuzz-{target}", source, "fuzz")
+ dependencies.append(discovery.node(name))
+ try:
+ manifest = manifests[name]
+ except KeyError as error:
+ raise ValueError(f"missing dependency manifest: {name}") from error
+ files.update(dependency_inputs(
+ repository, restore_output, source, manifest, project.headers
+ ))
+ relative = (path.resolve(strict=True).relative_to(repository).as_posix() for path in paths)
+ files.update({name: (repository / name).read_bytes() for name in dict.fromkeys(relative)})
+ return files, tuple(dependencies)
+
+
+def _seed_files(repository: Path, target: str) -> tuple[Path, dict[str, bytes]]:
+ directory = repository / "src/fuzz/corpus" / target
+ seeds = tuple(sorted(path for path in directory.iterdir() if path.is_file()))
+ if not seeds:
+ raise FileNotFoundError(f"no checked-in fuzzer seeds for {target}")
+ return directory, {
+ path.relative_to(repository).as_posix(): path.read_bytes() for path in seeds
+ }
+
+
+def _prior_corpora(
+ repository: Path, artifacts: tuple[FuzzCorpusArtifact, ...]
+) -> dict[str, tuple[FuzzCorpusArtifact, Path, dict[str, bytes]]]:
+ indexed = {}
+ for artifact in artifacts:
+ if artifact.target in indexed:
+ raise ValueError(f"duplicate prior corpus: {artifact.target}")
+ indexed[artifact.target] = artifact
+
+ paths, result = BuildPaths(repository), {}
+ for target, artifact in indexed.items():
+ node_name = f"run-fuzz-x64-{target}"
+ cas = paths.cas(artifact.producer_uid)
+ corpus = paths.require_confined(cas.output / "corpus", paths.cas_root)
+ if not (
+ cas.entry.is_dir()
+ and cas.output.is_dir()
+ and cas.log.is_file()
+ and cas.touch.is_file()
+ and cas.touch.stat().st_size == 0
+ and corpus.is_dir()
+ ):
+ raise FileNotFoundError(f"prior fuzz corpus is not published: {target}")
+ files = {}
+ for path in sorted(corpus.iterdir()):
+ confined = paths.require_confined(path, paths.cas_root)
+ if not confined.is_file():
+ raise ValueError(f"prior fuzz corpus contains a non-file: {confined}")
+ files[f"prior/{target}/{confined.name}"] = confined.read_bytes()
+ if not files:
+ raise ValueError(f"prior fuzz corpus is empty: {target}")
+ result[target] = (artifact, corpus, files)
+ return result
+
+
+def fuzz_graph(
+ repository: Path, toolchain: MsvcToolchain, *, discovery: Graph | None,
+ manifests: Mapping[str, bytes], run_nonce: str, seconds: int = 60,
+ jobs: int = 2, fuzz_jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+ prior_corpora: tuple[FuzzCorpusArtifact, ...] = (),
+ targets: tuple[str, ...] = FUZZ_TARGETS,
+) -> Graph:
+ """Return the selected demand-independent x64 libFuzzer branches."""
+
+ if architectures != ("x64",):
+ raise ValueError("libFuzzer MSBuild contract is x64-only")
+ if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce:
+ raise ValueError("run nonce must be a non-empty string without NUL")
+ if isinstance(seconds, bool) or seconds <= 0:
+ raise ValueError("fuzz seconds must be positive")
+ selected_targets = _selected_targets(targets)
+ if discovery is None:
+ raise ValueError("fuzz dependency discovery is required")
+ root = repository.resolve(strict=True)
+ prior = _prior_corpora(root, prior_corpora)
+ runtime = _runtime(toolchain)
+ paths = BuildPaths(root)
+ identity = dict(toolchain.identity) | {"llvm_runtime": str(runtime)}
+ factory = recipe_factory(root, identity, tool_environment(toolchain))
+
+ restore = discovery.node("restore-vcpkg-asan-x64")
+ nodes, targets = list(discovery.nodes), []
+ restore_output = paths.cas(restore.uid).output
+
+ for target in selected_targets:
+ max_length = _TARGETS[target]
+ executable_name = f"fuzz-{target}.exe"
+ files, dependencies = _project_files(
+ root, target, restore_output, discovery, manifests
+ )
+ build = factory.make(
+ "fuzz-build.ps1",
+ f"build-fuzz-x64-{target}",
+ "build",
+ {
+ "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild),
+ "project": str(root / f"build/projects/fuzz-{target}.vcxproj"),
+ "target": "Build", "configuration": "Fuzz", "platform": "x64",
+ "vcpkg_root": str(toolchain.vcpkg_root), "vcpkg_installed": str(restore_output),
+ "llvm_dir": str(toolchain.llvm_dir), "llvm_runtime": str(runtime),
+ "executable_name": executable_name,
+ },
+ files=files, dependencies=dependencies,
+ config={"action": "build", "architecture": "x64", "target": target},
+ )
+ seed_dir, seed_files = _seed_files(root, target)
+ fuzzer = paths.cas(build.uid).output / executable_name
+ replay = factory.make(
+ "fuzz-replay.ps1",
+ f"replay-fuzz-x64-{target}",
+ "fuzz",
+ {
+ "pwsh": str(toolchain.pwsh), "fuzzer": str(fuzzer),
+ "seed_dir": str(seed_dir), "max_length": max_length,
+ },
+ files=seed_files, dependencies=(build,),
+ config={"action": "replay", "target": target, "max_length": str(max_length),
+ "asan_options": _ASAN_OPTIONS},
+ environment=tool_environment(
+ toolchain,
+ prepend_path=runtime,
+ extra=(("ASAN_OPTIONS", _ASAN_OPTIONS),),
+ ),
+ )
+ previous = prior.get(target)
+ prior_uid, prior_path, prior_files = (
+ ("", "", {})
+ if previous is None
+ else (previous[0].producer_uid, str(previous[1]), previous[2])
+ )
+ run = factory.make(
+ "fuzz-run.ps1",
+ f"run-fuzz-x64-{target}",
+ "fuzz",
+ {
+ "pwsh": str(toolchain.pwsh), "fuzzer": str(fuzzer), "seed_dir": str(seed_dir),
+ "max_length": max_length, "seconds": seconds, "prior_corpus": prior_path,
+ },
+ files=seed_files | prior_files, dependencies=(replay,),
+ results=(
+ Result(
+ f"reports/fuzz/x64/{target}/status.txt",
+ "fuzz", "text/plain", "status.txt",
+ ),
+ Result(
+ f"reports/fuzz/x64/{target}/corpus",
+ "corpus", "application/octet-stream", "corpus",
+ ),
+ Result(
+ f"reports/fuzz/x64/{target}/artifacts",
+ "evidence", "application/octet-stream", "artifacts",
+ ),
+ ),
+ config={"action": "fuzz", "target": target, "max_length": str(max_length),
+ "seconds": str(seconds), "run_nonce": run_nonce,
+ "asan_options": _ASAN_OPTIONS, "prior_corpus_uid": prior_uid},
+ environment=tool_environment(
+ toolchain,
+ prepend_path=runtime,
+ extra=(("ASAN_OPTIONS", _ASAN_OPTIONS),),
+ ),
+ )
+ gate = factory.make(
+ "fuzz-gate.ps1",
+ f"fuzz-x64-{target}",
+ "fuzz",
+ {"pwsh": str(toolchain.pwsh),
+ "status": str(paths.cas(run.uid).output / "status.txt")},
+ files={}, dependencies=(run,),
+ config={"action": "fuzz-gate", "target": target},
+ )
+ nodes.extend((build, replay, run, gate))
+ targets.append(gate.name)
+ return Graph(
+ tuple(nodes), tuple(targets),
+ {"build": jobs, "fuzz": fuzz_jobs, "restore": 1, "slot": jobs},
+ )
diff --git a/build/graphs/instrumented.py b/build/graphs/instrumented.py
new file mode 100644
index 0000000..e3bf444
--- /dev/null
+++ b/build/graphs/instrumented.py
@@ -0,0 +1,186 @@
+"""Staged MSBuild producers for Coverage, ASan, and UBSan graphs."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from core.graph import Graph, GraphError, merge_graphs
+from core.paths import BuildPaths
+from core.quality_tools import UBSAN_LIBRARIES, resolve_llvm
+from core.toolchain import MsvcToolchain
+from graphs.analysis import (
+ clang_dependency_discovery_slice,
+ dependency_discovery_slice,
+ project_build,
+ project_inventory,
+)
+from graphs.common import (
+ PLATFORMS,
+ PROJECTS,
+ recipe_factory,
+ require_positive_integers,
+ tool_environment,
+)
+
+
+_CONFIGURATIONS = {"coverage": "Coverage", "asan": "ASan", "ubsan": "UBSan"}
+
+
+@dataclass(frozen=True, slots=True)
+class InstrumentedVariant:
+ kind: str
+ architecture: str
+ llvm_runtime: Path | None = None
+ runtime_identity: Mapping[str, str] = field(default_factory=dict)
+
+ @property
+ def configuration(self) -> str:
+ return _CONFIGURATIONS.get(self.kind, self.kind)
+
+
+def _variants(
+ variants: tuple[InstrumentedVariant, ...], jobs: int
+) -> tuple[InstrumentedVariant, ...]:
+ require_positive_integers((jobs,), "jobs must be a positive integer")
+ if not variants:
+ raise ValueError("at least one instrumented variant is required")
+ keys = [(item.kind, item.architecture) for item in variants]
+ if len(keys) != len(set(keys)):
+ raise ValueError("duplicate instrumented variant")
+ for item in variants:
+ supported = (
+ item.kind == "coverage" and item.architecture in PLATFORMS
+ or item.kind == "asan" and item.architecture in {"x86", "x64"}
+ or item.kind == "ubsan" and item.architecture == "x64"
+ )
+ if not supported:
+ raise ValueError(f"unsupported instrumented variant: {(item.kind, item.architecture)}")
+ if item.kind == "ubsan":
+ runtime = item.llvm_runtime.resolve(strict=True) if item.llvm_runtime else None
+ if runtime is None or not runtime.is_dir() or not item.runtime_identity:
+ raise ValueError("UBSan runtime directory and exact identity are required")
+ for name in UBSAN_LIBRARIES:
+ if not (runtime / name).is_file():
+ raise FileNotFoundError(f"UBSan runtime library not found: {runtime / name}")
+ elif item.llvm_runtime is not None or item.runtime_identity:
+ raise ValueError(f"LLVM runtime is invalid for {item.kind} variant")
+ return variants
+
+
+def instrumented_dependency_discovery_slice(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ variants: tuple[InstrumentedVariant, ...],
+ jobs: int = 2,
+) -> Graph:
+ """Discover compiler inputs per instrumented configuration and TU."""
+
+ selected = _variants(variants, jobs)
+ graphs = tuple(
+ (
+ clang_dependency_discovery_slice(
+ repository,
+ toolchain,
+ project_names=PROJECTS,
+ configuration=item.configuration,
+ name_qualifier=item.kind,
+ jobs=jobs,
+ architectures=(item.architecture,),
+ )
+ if item.kind in {"coverage", "ubsan"}
+ else dependency_discovery_slice(
+ repository,
+ toolchain,
+ project_names=PROJECTS,
+ configuration=item.configuration,
+ restore_flavor="asan",
+ name_qualifier=item.kind,
+ jobs=jobs,
+ architectures=(item.architecture,),
+ )
+ )
+ for item in selected
+ )
+ try:
+ return merge_graphs(*graphs)
+ except GraphError as error:
+ name = str(error).rsplit(": ", 1)[-1]
+ raise ValueError(f"conflicting canonical node: {name}") from error
+
+
+def instrumented_build_slice(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ discovery: Graph | None,
+ manifests: Mapping[str, bytes],
+ variants: tuple[InstrumentedVariant, ...],
+ jobs: int = 2,
+) -> Graph:
+ """Build independently cacheable modules/tests from completed discovery."""
+
+ selected = _variants(variants, jobs)
+ if discovery is None:
+ raise ValueError("instrumented dependency discovery is required")
+ if discovery.pools.get("slot") != jobs:
+ raise ValueError("conflicting slot pool capacity")
+ root = repository.resolve(strict=True)
+ factory = recipe_factory(
+ root,
+ dict(toolchain.identity),
+ tool_environment(toolchain),
+ )
+ paths = BuildPaths(root)
+ clang_identity: dict[str, str] = {}
+ if any(item.kind in {"coverage", "ubsan"} for item in selected):
+ clang = resolve_llvm(toolchain, "clang-cl")
+ clang_identity = {
+ f"clang_cl.{key}": value for key, value in clang.identity
+ }
+ builds = []
+ projects = project_inventory(root, PROJECTS)
+ for variant in selected:
+ restore_name = (
+ f"restore-vcpkg-asan-{variant.architecture}"
+ if variant.kind == "asan"
+ else f"restore-vcpkg-{variant.architecture}"
+ )
+ restore = discovery.node(restore_name)
+ restore_output = paths.cas(restore.uid).output
+ runtime = variant.llvm_runtime.resolve(strict=True) if variant.llvm_runtime else None
+ identity = dict(toolchain.identity)
+ if variant.kind in {"coverage", "ubsan"}:
+ identity.update(clang_identity)
+ if runtime is not None:
+ identity.update({
+ f"llvm_runtime.{key}": value
+ for key, value in variant.runtime_identity.items()
+ })
+ identity["llvm_runtime.path"] = str(runtime)
+ for project in projects:
+ build = project_build(
+ root, factory, discovery, manifests, restore_output, project,
+ variant.architecture, variant.kind, "instrumented-build.ps1", {
+ "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild),
+ "project": str(project.path), "target": "Build",
+ "configuration": variant.configuration,
+ "platform": PLATFORMS[variant.architecture],
+ "vcpkg_root": str(toolchain.vcpkg_root),
+ "vcpkg_installed": str(restore_output),
+ "llvm_dir": str(toolchain.llvm_dir)
+ if variant.kind in {"coverage", "ubsan"} else "",
+ "llvm_runtime": str(runtime) if runtime else "",
+ }, identity=identity, config={
+ "action": "build", "kind": variant.kind,
+ "architecture": variant.architecture, "project": project.name,
+ },
+ )
+ builds.append(build)
+ return Graph(
+ discovery.nodes + tuple(builds),
+ tuple(build.name for build in builds),
+ discovery.pools,
+ )
diff --git a/build/graphs/leak.py b/build/graphs/leak.py
new file mode 100644
index 0000000..f4b9989
--- /dev/null
+++ b/build/graphs/leak.py
@@ -0,0 +1,135 @@
+"""Independent UMDH leak scenarios composed over x64 Release binaries."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from pathlib import Path
+
+from core.graph import Graph, Node, Result
+from core.paths import BuildPaths
+from graphs.common import (
+ BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, required_audit_gates,
+ require_positive_integers, require_tool,
+)
+
+
+LEAK_MODES = ("operations", "lifecycle")
+LEAK_SCENARIOS = ("small-success", "malformed", "cancellation", "read-failure", "write-failure", "large-metadata", "sparse-metadata")
+_BINARIES = {"leak-probe": "leak-probe.exe", "renpy": "renpy.so", "rpgmaker": "rpgmaker.so", "zanzarah": "zanzarah.so"}
+
+
+def leak_graph(
+ repository: Path,
+ upstream: Graph,
+ *,
+ umdh: Path,
+ umdh_identity: Mapping[str, str],
+ run_nonce: str,
+ warmup: int = 8,
+ iterations: int = 100,
+ windows: int = 3,
+ tolerance_bytes: int = 0,
+ jobs: int = 4,
+) -> Graph:
+ """Add one demand target per leak mode/scenario without a monolithic gate."""
+
+ capacities = (jobs,)
+ counts = (warmup, iterations, windows)
+ require_positive_integers(capacities, "leak pool capacities must be positive integers")
+ require_positive_integers(counts, "leak measurement counts must be positive integers")
+ if windows < 3:
+ raise ValueError("leak measurement requires at least three windows")
+ if isinstance(tolerance_bytes, bool) or not isinstance(tolerance_bytes, int) or tolerance_bytes < 0:
+ raise ValueError("leak tolerance must be a non-negative integer")
+ if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce:
+ raise ValueError("run nonce must be a non-empty string without NUL")
+
+ root = repository.resolve(strict=True)
+ paths = BuildPaths(root)
+ umdh = require_tool(umdh, "UMDH")
+ selected = {
+ name: canonical_artifact(
+ paths, upstream, f"build-{name}-x64-release", filename
+ )
+ for name, filename in _BINARIES.items()
+ }
+ factory = recipe_factory(BUILD_ROOT, {})
+ umdh_signature = {f"umdh.{key}": value for key, value in umdh_identity.items()} | {"umdh.path": str(umdh)}
+
+ def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...],
+ *, identity: Mapping[str, str] | None = None,
+ config: Mapping[str, str] | None = None,
+ results: tuple[Result, ...] = ()) -> Node:
+ return python_action(
+ factory, name, "core.leak", arguments, dependencies,
+ pool="slot", identity=identity, config=config, results=results,
+ )
+
+ ordered = tuple(selected[name] for name in _BINARIES)
+ setup_dependencies = (selected["leak-probe"][0],) + tuple(
+ dependency for name, (producer, _path) in zip(_BINARIES, ordered, strict=True)
+ if name != "leak-probe"
+ for dependency in (producer, *required_audit_gates(upstream, producer, "x64", name))
+ )
+ setup = action(
+ "leak-setup-x64-release", ("setup", *(str(path) for _producer, path in ordered)), setup_dependencies,
+ config={"architecture": "x64", "configuration": "Release"},
+ )
+ setup_output = paths.cas(setup.uid).output
+ nodes: list[Node] = [setup]
+ targets: list[str] = []
+
+ for mode in LEAK_MODES:
+ for scenario in LEAK_SCENARIOS:
+ stem = f"{mode}-{scenario}"
+ result_root = f"reports/leak/x64/{mode}/{scenario}"
+ preflight = action(f"leak-preflight-{stem}",
+ ("preflight", str(setup_output), mode, scenario), (setup,),
+ results=(Result(
+ f"{result_root}/preflight.json", "leak",
+ "application/json", "preflight.json",
+ ),))
+ capture = action(f"leak-capture-{stem}",
+ ("capture", str(setup_output), str(umdh), mode, scenario,
+ str(warmup), str(iterations), str(windows)),
+ (preflight,), identity=umdh_signature, config={"run_nonce": run_nonce},
+ results=(
+ Result(f"{result_root}/capture.json", "leak", "application/json", "capture.json"),
+ Result(f"{result_root}/snapshots", "leak-snapshots", "application/octet-stream", "snapshots"),
+ Result(f"{result_root}/probe.stderr.log", "log", "text/plain", "probe.stderr.log"),
+ ))
+ capture_output = paths.cas(capture.uid).output
+ snapshots = capture_output / "snapshots"
+ specs = [(f"window-{index}", snapshots / f"window-{index}.txt", snapshots / f"window-{index + 1}.txt")
+ for index in range(1, windows)]
+ specs.append(("overall", snapshots / "window-1.txt", snapshots / f"window-{windows}.txt"))
+ diffs = tuple(
+ action(f"leak-diff-{stem}-{label}",
+ ("diff", str(umdh), str(setup_output), label, str(before), str(after)),
+ (capture,), identity=umdh_signature,
+ results=(
+ Result(f"{result_root}/diffs/{label}.json", "leak-diff", "application/json", "diff.json"),
+ Result(f"{result_root}/diffs/{label}.txt", "leak-diff", "text/plain", "report.txt"),
+ ))
+ for label, before, after in specs
+ )
+ summary = action(f"leak-summary-{stem}",
+ (
+ "summarize", mode, scenario, str(warmup), str(iterations), str(windows),
+ str(tolerance_bytes),
+ *(value for (label, _before, _after), item in zip(specs, diffs, strict=True)
+ for value in (label, str(paths.cas(item.uid).output / "diff.json"))),
+ ), diffs, results=(Result(
+ f"{result_root}/summary.json", "leak-summary",
+ "application/json", "summary.json",
+ ),))
+ gate = action(
+ f"leak-gate-{stem}",
+ ("gate", str(paths.cas(summary.uid).output / "summary.json")),
+ (summary,),
+ )
+ nodes.extend((preflight, capture, *diffs, summary, gate))
+ targets.append(gate.name)
+
+ pools = extend_pools(upstream, {"slot": jobs})
+ return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools)
diff --git a/build/graphs/native.py b/build/graphs/native.py
new file mode 100644
index 0000000..7424c94
--- /dev/null
+++ b/build/graphs/native.py
@@ -0,0 +1,223 @@
+"""Fine-grained native project build and Catch2 execution graph."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from pathlib import Path
+
+from core.graph import Graph, Node, Result, merge_graphs
+from core.node import NodeFactory
+from core.paths import BuildPaths
+from core.toolchain import MsvcToolchain
+from graphs.analysis import (
+ dependency_discovery_slice,
+ project_build,
+ project_inventory,
+)
+from graphs.common import (
+ BINARIES,
+ PLATFORMS,
+ PROJECTS,
+ recipe_factory,
+ require_positive_integers,
+ tool_environment,
+)
+
+
+_CONFIGURATIONS = ("Debug", "Release")
+def _validate_matrix(
+ jobs: int, architectures: tuple[str, ...], configurations: tuple[str, ...]
+) -> None:
+ require_positive_integers((jobs,), "jobs must be a positive integer")
+ unsupported = set(architectures) - PLATFORMS.keys()
+ if unsupported:
+ raise ValueError(f"unsupported architecture: {sorted(unsupported)[0]}")
+ invalid = set(configurations) - set(_CONFIGURATIONS)
+ if invalid:
+ raise ValueError(f"unsupported configuration: {sorted(invalid)[0]}")
+
+
+def native_dependency_discovery_slice(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+ configurations: tuple[str, ...] = ("Debug",),
+ include_leak_probe: bool = False,
+) -> Graph:
+ """Discover exact native build dependencies per configuration and TU."""
+
+ _validate_matrix(jobs, architectures, configurations)
+ graphs = tuple(
+ dependency_discovery_slice(
+ repository,
+ toolchain,
+ project_names=PROJECTS + (
+ ("leak-probe",) if include_leak_probe and config == "Release" else ()
+ ),
+ configuration=config,
+ name_qualifier=config.lower(),
+ jobs=jobs,
+ architectures=architectures,
+ )
+ for config in configurations
+ )
+ return merge_graphs(*graphs)
+
+
+def _test_shard(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ factory: NodeFactory,
+ paths: BuildPaths,
+ builds: tuple[Node, ...],
+ architecture: str,
+ configuration: str,
+ shard_count: int,
+ shard_index: int,
+ corpus: Path | None,
+ run_nonce: str,
+) -> Node:
+ artifacts = [
+ {
+ "name": BINARIES[project],
+ "source": str(paths.cas(node.uid).output / BINARIES[project]),
+ }
+ for project, node in zip(PROJECTS, builds, strict=True)
+ ]
+ prefix = "corpus" if corpus is not None else "test"
+ result_scope = "corpus" if corpus is not None else "unit"
+ name = f"{prefix}-shard-{architecture}-{configuration.lower()}-{shard_index}"
+ config = {
+ "action": "test",
+ "architecture": architecture,
+ "configuration": configuration,
+ "shard_count": str(shard_count),
+ "shard_index": str(shard_index),
+ }
+ options = {}
+ if corpus is not None:
+ config |= {"corpus": str(corpus), "run_nonce": run_nonce}
+ options["environment"] = tool_environment(
+ toolchain, extra=(("OBSERVER_TEST_CORPUS", str(corpus)),)
+ )
+ return factory.make(
+ "native-corpus-test.ps1" if corpus is not None else "native-test.ps1",
+ name,
+ "slot",
+ {
+ "pwsh": str(toolchain.pwsh),
+ "artifacts": artifacts,
+ "shard_count": shard_count,
+ "shard_index": shard_index,
+ },
+ files={},
+ dependencies=builds,
+ results=(Result(
+ f"reports/tests/{architecture}/{configuration.lower()}/{result_scope}/"
+ f"shard-{shard_index}.xml",
+ "test",
+ "application/xml",
+ "tests.xml",
+ ),),
+ config=config,
+ **options,
+ )
+
+
+def native_graph(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ discovery: Graph | None = None,
+ manifests: Mapping[str, bytes] | None = None,
+ jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+ configurations: tuple[str, ...] = ("Debug",),
+ runnable_architectures: tuple[str, ...],
+ test_shards: int = 4,
+ include_leak_probe: bool = False,
+ corpus: Path | None = None,
+ run_nonce: str = "",
+) -> Graph:
+ """Return independently cacheable project builds and safe Catch2 shards."""
+
+ _validate_matrix(jobs, architectures, configurations)
+ require_positive_integers((test_shards,), "test_shards must be a positive integer")
+ unsupported = set(runnable_architectures) - PLATFORMS.keys()
+ if unsupported:
+ raise ValueError(f"unsupported architecture: {sorted(unsupported)[0]}")
+ corpus_path = None
+ if corpus is not None:
+ if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce:
+ raise ValueError("corpus run nonce must be a non-empty string without NUL")
+ corpus_path = Path(corpus).resolve(strict=True)
+ if not corpus_path.is_dir():
+ raise NotADirectoryError(corpus_path)
+ if discovery is None or manifests is None:
+ raise ValueError("native dependency discovery is required")
+
+ root = repository.resolve(strict=True)
+ factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain))
+ paths = BuildPaths(root)
+ nodes: list[Node] = list(discovery.nodes)
+ targets: list[str] = []
+ runnable = set(runnable_architectures)
+ projects = project_inventory(root, PROJECTS)
+
+ for architecture in architectures:
+ restore = discovery.node(f"restore-vcpkg-{architecture}")
+ restore_output = paths.cas(restore.uid).output
+ for configuration in configurations:
+ def build(project):
+ return project_build(
+ root, factory, discovery, manifests, restore_output, project,
+ architecture, configuration.lower(), "native-build.ps1", {
+ "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild),
+ "project": str(project.path), "target": "Build",
+ "configuration": configuration, "platform": PLATFORMS[architecture],
+ "vcpkg_root": str(toolchain.vcpkg_root),
+ "vcpkg_installed": str(restore_output),
+ }, config={
+ "action": "build", "architecture": architecture,
+ "configuration": configuration, "project": project.name,
+ },
+ )
+
+ builds = tuple(build(project) for project in projects)
+ nodes.extend(builds)
+ leak_probe = None
+ if include_leak_probe and architecture == "x64" and configuration == "Release":
+ leak_probe = build(project_inventory(root, ("leak-probe",))[0])
+ nodes.append(leak_probe)
+
+ if architecture in runnable:
+ runs = [(None, "")]
+ if corpus_path is not None:
+ runs.append((corpus_path, run_nonce))
+ for shard_corpus, nonce in runs:
+ shards = tuple(
+ _test_shard(
+ root,
+ toolchain,
+ factory,
+ paths,
+ builds,
+ architecture,
+ configuration,
+ test_shards,
+ index,
+ shard_corpus,
+ nonce,
+ )
+ for index in range(test_shards)
+ )
+ nodes.extend(shards)
+ targets.extend(node.name for node in shards)
+ else:
+ targets.extend(node.name for node in builds)
+ if leak_probe is not None:
+ targets.append(leak_probe.name)
+
+ return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs})
diff --git a/build/graphs/package.py b/build/graphs/package.py
new file mode 100644
index 0000000..59f4c8c
--- /dev/null
+++ b/build/graphs/package.py
@@ -0,0 +1,184 @@
+"""Fine-grained deterministic packaging over explicit release build artifacts."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from core.graph import Graph, Node, Result
+from core.package import ARCHITECTURES, LICENSES, MODULES
+from core.paths import BuildPaths
+from graphs.common import (
+ BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, required_audit_gates, require_positive_integers,
+)
+
+
+def package_outputs(repository: Path, graph: Graph) -> tuple[Path, ...]:
+ """Return gated ZIP paths from the final package-manifest producer."""
+
+ paths = BuildPaths(repository.resolve(strict=True))
+ try:
+ aggregate = graph.node("package-manifest")
+ except ValueError as error:
+ raise ValueError("graph has no package outputs") from error
+ output = paths.cas(aggregate.uid).output
+ result = [output / item.relative_path for item in aggregate.results if item.kind == "package"]
+ if not result:
+ raise ValueError("graph has no package outputs")
+ return tuple(result)
+
+
+def package_graph(
+ repository: Path,
+ upstream: Graph,
+ *,
+ architectures: tuple[str, ...],
+ smoke_architectures: tuple[str, ...] = (),
+ jobs: int = 4,
+) -> Graph:
+ """Compose module and symbol packages without artificial cross-architecture edges."""
+
+ require_positive_integers((jobs,), "package jobs must be a positive integer")
+ root = repository.resolve(strict=True)
+ paths = BuildPaths(root)
+ factory = recipe_factory(BUILD_ROOT, {})
+
+ def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...],
+ files: dict[str, bytes] | None = None,
+ results: tuple[Result, ...] = ()) -> Node:
+ return python_action(factory, name, "core.package", arguments, dependencies,
+ pool="package", files=files, results=results)
+
+ if (
+ not architectures
+ or len(architectures) != len(set(architectures))
+ or any(item not in ARCHITECTURES for item in architectures)
+ ):
+ raise ValueError("package architectures must be a unique non-empty supported set")
+ if (
+ len(smoke_architectures) != len(set(smoke_architectures))
+ or any(item not in architectures for item in smoke_architectures)
+ ):
+ raise ValueError("package smoke architectures must be a unique package subset")
+ selected_architectures = tuple(sorted(architectures))
+
+ nodes: list[Node] = []
+ archives: dict[str, Node] = {}
+ validations: dict[str, Node] = {}
+ symbol_stages = {architecture: [] for architecture in selected_architectures}
+
+ def output(node: Node, name: str = "") -> Path:
+ return paths.cas(node.uid).output / name
+
+ for architecture in selected_architectures:
+ for module in MODULES:
+ producer, binary = canonical_artifact(
+ paths, upstream, f"build-{module}-{architecture}-release", f"{module}.so"
+ )
+ suffix = f"{architecture}-{module}"
+ symbols = paths.cas(producer.uid).output / f"{module}.pdb"
+ gates = required_audit_gates(upstream, producer, architecture, module)
+ dependencies = (producer, *gates)
+
+ repository_inputs = (
+ f"src/modules/{module}/observer_user.ini",
+ "LICENSE.txt",
+ *(f"licenses/{name}" for name in LICENSES[module]),
+ )
+ stage = action(
+ f"package-stage-{suffix}",
+ ("stage-module", architecture, module, str(binary), str(root)),
+ dependencies,
+ {name: (root / name).read_bytes() for name in repository_inputs},
+ )
+ symbol_stage = action(
+ f"package-symbol-stage-{suffix}",
+ ("stage-symbol", architecture, module, str(symbols)),
+ dependencies,
+ )
+ archive = action(
+ f"package-archive-{suffix}",
+ ("archive-module", architecture, module, str(output(stage))),
+ (stage, *gates),
+ )
+ archive_name = f"{module}-{architecture}-dll.zip"
+ validation = action(
+ f"package-validate-{suffix}",
+ ("validate-module", architecture, module, str(output(archive, archive_name)), str(output(stage))),
+ (archive, stage),
+ results=(Result(
+ f"reports/package/{architecture}/{module}/validation.json",
+ "package-validation", "application/json", "validation.json",
+ ),),
+ )
+ nodes.extend((stage, symbol_stage, archive, validation))
+ archives[archive_name] = archive
+ validations[archive_name] = validation
+ symbol_stages[architecture].append(symbol_stage)
+
+ for architecture, stages in sorted(symbol_stages.items()):
+ current = action(
+ f"package-symbols-{architecture}",
+ (
+ "archive-symbols", architecture,
+ *(str(output(stage)) for stage in stages),
+ ),
+ tuple(stages),
+ )
+ nodes.append(current)
+ archive_name = f"observer-modules-{architecture}-pdb.zip"
+ validation = action(
+ f"package-symbols-validate-{architecture}",
+ ("validate-symbols", architecture, str(output(current, archive_name)),
+ *(str(output(stage)) for stage in stages)),
+ (current, *stages),
+ results=(Result(
+ f"reports/package/{architecture}/symbols/validation.json",
+ "package-validation", "application/json", "validation.json",
+ ),),
+ )
+ nodes.append(validation)
+ archives[archive_name] = current
+ validations[archive_name] = validation
+
+ smoke_nodes = []
+ for architecture in sorted(smoke_architectures):
+ test_producer, test_executable = canonical_artifact(
+ paths, upstream, f"build-tests-{architecture}-release", "tests.exe"
+ )
+ for module in MODULES:
+ archive_name = f"{module}-{architecture}-dll.zip"
+ archive = archives[archive_name]
+ validation = validations[archive_name]
+ smoke_node = action(
+ f"package-smoke-{architecture}-{module}",
+ (
+ "smoke", architecture, module,
+ str(output(archive, archive_name)),
+ str(test_executable),
+ ),
+ (validation, test_producer),
+ )
+ nodes.append(smoke_node)
+ smoke_nodes.append(smoke_node)
+
+ package_results = tuple(
+ Result(f"packages/{architecture}/{name}", "package", "application/zip", name)
+ for architecture in sorted(symbol_stages)
+ for name in (
+ *(f"{module}-{architecture}-dll.zip" for module in MODULES),
+ f"observer-modules-{architecture}-pdb.zip",
+ )
+ )
+ aggregate = action(
+ "package-manifest",
+ (
+ "aggregate",
+ *(str(output(node, name)) for name, node in archives.items()),
+ ),
+ tuple(validations.values()) + tuple(smoke_nodes),
+ results=package_results + (Result(
+ "packages/packages.json", "package-manifest", "application/json", "packages.json",
+ ),),
+ )
+ nodes.append(aggregate)
+ return Graph(upstream.nodes + tuple(nodes), (aggregate.name,), extend_pools(upstream, {"package": jobs}))
diff --git a/build/graphs/python_coverage.py b/build/graphs/python_coverage.py
new file mode 100644
index 0000000..0051c07
--- /dev/null
+++ b/build/graphs/python_coverage.py
@@ -0,0 +1,60 @@
+"""Content-addressed Python line-and-branch coverage gate."""
+
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+
+from core.graph import Graph, Result
+from graphs.common import BUILD_ROOT, python_action, recipe_factory
+
+
+def _inputs(repository: Path, build_root: Path) -> dict[str, bytes]:
+ paths = [
+ build_root / name
+ for name in ("driver.py", "main.py", "pyproject.toml", "uv.lock")
+ ]
+ for directory in ("core", "graphs", "tests"):
+ paths.extend(sorted((build_root / directory).rglob("*.py")))
+ return {path.relative_to(repository).as_posix(): path.read_bytes() for path in paths}
+
+
+def _tool_digest(build_root: Path, executable: Path) -> str:
+ package = (build_root / ".venv/Lib/site-packages/coverage").resolve(strict=True)
+ if not package.is_dir():
+ raise FileNotFoundError(f"project coverage package is not a directory: {package}")
+ paths = [executable] + [path for path in sorted(package.rglob("*"))
+ if path.is_file() and "__pycache__" not in path.parts]
+ digest = hashlib.sha256()
+ for path in paths:
+ digest.update(path.relative_to(build_root).as_posix().encode() + b"\0")
+ with path.open("rb") as stream:
+ digest.update(hashlib.file_digest(stream, "sha256").digest())
+ return digest.hexdigest()
+
+
+def python_coverage_graph(repository: Path) -> Graph:
+ """Build one demand gate using only the repository-local pinned coverage executable."""
+
+ root = repository.resolve(strict=True)
+ build_root = root / "build"
+ coverage = (build_root / ".venv/Scripts/coverage.exe").resolve(strict=True)
+ if not coverage.is_file():
+ raise FileNotFoundError(f"project coverage executable is not a file: {coverage}")
+ digest = _tool_digest(build_root, coverage)
+ factory = recipe_factory(BUILD_ROOT, {})
+ gate = python_action(
+ factory, "python-coverage", "core.python_coverage", (str(coverage), str(build_root)), (),
+ pool="python-coverage", files=_inputs(root, build_root),
+ identity={"coverage.path": str(coverage), "coverage.sha256": digest},
+ config={"action": "python-coverage", "coverage": "100-percent-line-and-branch"},
+ environment=(("PYTHONDONTWRITEBYTECODE", "1"),),
+ results=(
+ Result("reports/coverage/python/coverage.json", "coverage", "application/json", "coverage.json"),
+ Result("reports/coverage/python/coverage.xml", "coverage", "application/xml", "coverage.xml"),
+ Result("reports/coverage/python/coverage.txt", "coverage", "text/plain", "coverage.txt"),
+ Result("reports/coverage/python/coverage.toml", "coverage-config", "application/toml", "coverage.toml"),
+ Result("reports/coverage/python/coverage.data", "coverage-data", "application/octet-stream", ".coverage"),
+ ),
+ )
+ return Graph((gate,), (gate.name,), {"python-coverage": 1})
diff --git a/build/graphs/sanitizer.py b/build/graphs/sanitizer.py
new file mode 100644
index 0000000..c3758a6
--- /dev/null
+++ b/build/graphs/sanitizer.py
@@ -0,0 +1,262 @@
+"""Fine-grained ASan/UBSan tests over explicit sanitizer build artifacts."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+from pathlib import Path
+
+from core.graph import Graph, Node, Result
+from core.paths import BuildPaths
+from core.toolchain import MsvcToolchain
+from graphs.common import (
+ BINARIES,
+ BUILD_ROOT,
+ canonical_artifact,
+ extend_pools,
+ prefixed_identity,
+ python_action,
+ recipe_factory,
+ require_ancestor,
+ require_positive_integers,
+ require_tool,
+ tool_environment,
+)
+from graphs.instrumented import InstrumentedVariant, instrumented_build_slice, instrumented_dependency_discovery_slice
+
+
+_SUPPORTED = {("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")}
+_RUNTIME_NAMES = {
+ "x86": "clang_rt.asan_dynamic-i386.dll",
+ "x64": "clang_rt.asan_dynamic-x86_64.dll",
+}
+_OPTIONS = {
+ "asan": ("ASAN_OPTIONS", "halt_on_error=1:alloc_dealloc_mismatch=1"),
+ "ubsan": ("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1"),
+}
+
+
+@dataclass(frozen=True, slots=True)
+class AsanRuntime:
+ architecture: str
+ path: Path
+ identity: Mapping[str, str]
+
+
+def _builds(
+ paths: BuildPaths,
+ upstream: Graph,
+ selections: tuple[tuple[str, str], ...],
+) -> dict[tuple[str, str], dict[str, tuple[Node, Path]]]:
+ if not selections or len(selections) != len(set(selections)) or any(
+ item not in _SUPPORTED for item in selections
+ ):
+ raise ValueError("sanitizer selections must be a unique non-empty supported set")
+ selected = {}
+ for sanitizer, architecture in selections:
+ group = {}
+ for name, filename in BINARIES.items():
+ producer, path = canonical_artifact(
+ paths, upstream, f"build-{name}-{architecture}-{sanitizer}", filename
+ )
+ group[name] = (producer, path)
+ restore = (
+ f"restore-vcpkg-asan-{architecture}"
+ if sanitizer == "asan"
+ else f"restore-vcpkg-{architecture}"
+ )
+ for producer, _path in group.values():
+ require_ancestor(
+ upstream, producer, restore,
+ f"sanitizer build requires {restore} as a restore ancestor",
+ )
+ selected[(sanitizer, architecture)] = group
+ return selected
+
+
+def _runtimes(
+ selected: Mapping[tuple[str, str], object], runtimes: Iterable[AsanRuntime]
+) -> dict[str, AsanRuntime]:
+ result = {}
+ for runtime in runtimes:
+ path = require_tool(runtime.path, "ASan runtime")
+ if (
+ runtime.architecture not in _RUNTIME_NAMES
+ or path.name != _RUNTIME_NAMES[runtime.architecture]
+ or not runtime.identity
+ ):
+ raise ValueError(f"invalid ASan runtime identity: {runtime.architecture}")
+ if runtime.architecture in result:
+ raise ValueError(f"duplicate ASan runtime: {runtime.architecture}")
+ result[runtime.architecture] = AsanRuntime(
+ runtime.architecture, path, runtime.identity
+ )
+ required = {architecture for sanitizer, architecture in selected if sanitizer == "asan"}
+ if result.keys() != required:
+ raise ValueError("an exact ASan runtime is required for each ASan artifact set")
+ return result
+
+
+def sanitizer_artifact_graph(
+ repository: Path,
+ upstream: Graph,
+ *,
+ selections: tuple[tuple[str, str], ...],
+ pwsh: Path,
+ pwsh_identity: Mapping[str, str],
+ asan_runtimes: Iterable[AsanRuntime] = (),
+ environment: tuple[tuple[str, str], ...] = (),
+ test_shards: int = 4,
+ jobs: int = 4,
+) -> Graph:
+ """Compose test-only sanitizer shards and fail-closed log gates."""
+
+ require_positive_integers(
+ (test_shards, jobs),
+ "sanitizer counts and pool capacities must be positive integers",
+ )
+ root = repository.resolve(strict=True)
+ paths = BuildPaths(root)
+ pwsh = require_tool(pwsh, "PowerShell")
+ selected = _builds(paths, upstream, selections)
+ runtimes = _runtimes(selected, asan_runtimes)
+ pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity)
+ gate_factory = recipe_factory(BUILD_ROOT, {})
+ nodes: list[Node] = []
+ targets: list[str] = []
+
+ for (sanitizer, architecture), group in sorted(selected.items()):
+ builds = tuple(group[name][0] for name in BINARIES)
+ runtime = runtimes.get(architecture) if sanitizer == "asan" else None
+ identity = pwsh_id
+ runtime_copy = None
+ if runtime is not None:
+ identity = identity | prefixed_identity(
+ f"asan_runtime.{architecture}", runtime.path, runtime.identity
+ )
+ runtime_copy = {"name": runtime.path.name, "source": str(runtime.path)}
+ factory = recipe_factory(root, identity, environment)
+ copies = tuple(
+ {"name": BINARIES[name], "source": str(group[name][1])}
+ for name in BINARIES
+ )
+ options_name, options_value = _OPTIONS[sanitizer]
+ for index in range(test_shards):
+ shard = factory.make(
+ "sanitizer-test.ps1",
+ f"{sanitizer}-test-{architecture}-{index}",
+ "sanitizer-shard",
+ {
+ "pwsh": str(pwsh), "artifacts": copies, "runtime": runtime_copy,
+ "options_name": options_name, "options_value": options_value,
+ "shard_count": test_shards, "shard_index": index,
+ },
+ files={}, dependencies=builds,
+ results=(Result(
+ f"reports/sanitizers/{sanitizer}/{architecture}/shard-{index}.xml",
+ "sanitizer",
+ "application/xml",
+ "tests.xml",
+ ),),
+ config={
+ "action": "sanitizer-test", "sanitizer": sanitizer,
+ "architecture": architecture, "shard_count": str(test_shards),
+ "shard_index": str(index),
+ },
+ )
+ log = paths.cas(shard.uid).log
+ gate = python_action(
+ gate_factory,
+ f"{sanitizer}-gate-{architecture}-{index}",
+ "core.sanitizer",
+ ("gate", sanitizer, str(log)),
+ (shard,),
+ pool="sanitizer-gate",
+ )
+ nodes.extend((shard, gate))
+ targets.append(gate.name)
+
+ return Graph(
+ upstream.nodes + tuple(nodes), tuple(targets),
+ extend_pools(upstream, {"sanitizer-shard": jobs, "sanitizer-gate": jobs}),
+ )
+
+
+def _instrumented_variants(
+ selections: tuple[tuple[str, str], ...],
+ llvm_runtime: Path | None,
+ llvm_runtime_identity: Mapping[str, str],
+) -> tuple[InstrumentedVariant, ...]:
+ return tuple(
+ InstrumentedVariant(
+ sanitizer,
+ architecture,
+ llvm_runtime if sanitizer == "ubsan" else None,
+ llvm_runtime_identity if sanitizer == "ubsan" else {},
+ )
+ for sanitizer, architecture in selections
+ )
+
+
+def sanitizer_dependency_discovery_slice(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ llvm_runtime: Path | None,
+ llvm_runtime_identity: Mapping[str, str],
+ selections: tuple[tuple[str, str], ...] = (
+ ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")
+ ),
+ jobs: int = 2,
+) -> Graph:
+ """Discover exact TU inputs for each requested sanitizer build."""
+
+ return instrumented_dependency_discovery_slice(
+ repository,
+ toolchain,
+ variants=_instrumented_variants(
+ selections, llvm_runtime, llvm_runtime_identity
+ ),
+ jobs=jobs,
+ )
+
+
+def sanitizer_graph(
+ repository: Path,
+ toolchain: MsvcToolchain,
+ *,
+ discovery: Graph | None,
+ manifests: Mapping[str, bytes],
+ llvm_runtime: Path | None,
+ llvm_runtime_identity: Mapping[str, str],
+ asan_runtimes: Iterable[AsanRuntime] = (),
+ selections: tuple[tuple[str, str], ...] = (
+ ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")
+ ),
+ jobs: int = 4,
+ test_shards: int = 4,
+) -> Graph:
+ """Build sanitizer artifacts and compose independent shard gates."""
+
+ variants = _instrumented_variants(
+ selections, llvm_runtime, llvm_runtime_identity
+ )
+ upstream = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=variants,
+ jobs=jobs,
+ )
+ return sanitizer_artifact_graph(
+ repository,
+ upstream,
+ selections=selections,
+ pwsh=toolchain.pwsh,
+ pwsh_identity=dict(toolchain.identity),
+ asan_runtimes=asan_runtimes,
+ environment=tool_environment(toolchain),
+ test_shards=test_shards,
+ jobs=jobs,
+ )
diff --git a/build/graphs/source.py b/build/graphs/source.py
new file mode 100644
index 0000000..4ceede9
--- /dev/null
+++ b/build/graphs/source.py
@@ -0,0 +1,234 @@
+"""Fine-grained repository source checks."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from pathlib import Path
+
+from core.graph import Graph, Node, Result
+from core.paths import BuildPaths
+from core.source_tools import SourceTools
+from graphs.common import python_action, recipe_factory, restore_node, tool_environment
+
+
+_BUILD_ROOT = Path(__file__).resolve().parents[1]
+_CPP_SUFFIXES = {".cpp", ".h", ".hpp"}
+_POWERSHELL_SUFFIXES = {".ps1", ".psm1"}
+_CPPCHECK_ARCHITECTURES = {
+ "x86": ("win32W", "_M_IX86=600"),
+ "x64": ("win64", "_M_X64=100"),
+ "arm64": ("win64", "_M_ARM64=1"),
+}
+_IGNORED_DIRECTORIES = {".git", ".venv", "__pycache__", "out"}
+
+
+def _relative(repository: Path, path: Path) -> str:
+ return path.resolve(strict=True).relative_to(repository).as_posix()
+
+
+def _slug(repository: Path, path: Path) -> str:
+ return _relative(repository, path).lower().replace("/", ".")
+
+
+def _repository_files(repository: Path) -> tuple[Path, ...]:
+ files = []
+ for directory, directories, names in repository.walk():
+ directories[:] = sorted(set(directories) - _IGNORED_DIRECTORIES)
+ files.extend(directory / name for name in names if not name.startswith(".coverage"))
+ return tuple(sorted(files))
+
+
+def source_checks(repository: Path, tools: SourceTools, jobs: int = 4,
+ architectures: Iterable[str] = _CPPCHECK_ARCHITECTURES) -> Graph:
+ """Return independently cacheable format, analyzer, and contract checks."""
+
+ selected_architectures = tuple(architectures)
+ if not selected_architectures:
+ raise ValueError("source architectures must be unique supported architecture names")
+ return _source_checks(repository, tools, jobs, selected_architectures, include_common=True)
+
+
+def common_source_checks(repository: Path, tools: SourceTools, jobs: int = 4) -> Graph:
+ """Return only architecture-independent repository checks."""
+
+ return _source_checks(repository, tools, jobs, (), include_common=True)
+
+
+def architecture_source_checks(repository: Path, tools: SourceTools,
+ architectures: Iterable[str], jobs: int = 4) -> Graph:
+ """Return only architecture-parameterized Cppcheck gates."""
+
+ return _source_checks(
+ repository, tools, jobs, tuple(architectures), include_common=False
+ )
+
+
+def _source_checks(repository: Path, tools: SourceTools, jobs: int,
+ selected_architectures: tuple[str, ...], *, include_common: bool) -> Graph:
+ if ((not selected_architectures and not include_common) or
+ len(set(selected_architectures)) != len(selected_architectures) or
+ any(item not in _CPPCHECK_ARCHITECTURES for item in selected_architectures)):
+ raise ValueError("source architectures must be unique supported architecture names")
+ root = repository.resolve(strict=True)
+ identities = dict(tools.identity)
+ restore_identity = {key: identities[key] for key in ("pwsh", "pwsh_version", "vcpkg_root")}
+ factory = recipe_factory(root, {}, tools.environment)
+ restore_factory = recipe_factory(root, restore_identity, tool_environment(tools))
+ paths = BuildPaths(root)
+
+ def tool_identity(*names: str) -> dict[str, str]:
+ return {key: identities[key] for name in names for key in (name, f"{name}_version")}
+
+ cpp_sources = tuple(sorted(
+ path for path in (root / "src").rglob("*")
+ if path.is_file() and path.suffix in _CPP_SUFFIXES
+ ))
+ template_root = root / "build/templates"
+ powershell_sources = (root / "build.ps1",) + tuple(sorted(
+ path for path in (root / "build").rglob("*")
+ if (
+ path.is_file()
+ and path.suffix in _POWERSHELL_SUFFIXES
+ # Raw Jinja is not PowerShell; rendered recipes are parser-tested below this graph.
+ and not path.is_relative_to(template_root)
+ )
+ ))
+ contracts = tuple(sorted((root / "build/tests").glob("*.Tests.ps1")))
+
+ def leaf(
+ template: str,
+ name: str,
+ variables: dict[str, object],
+ inputs: tuple[Path, ...],
+ *,
+ dependencies: tuple[Node, ...] = (),
+ identity: dict[str, str],
+ config: dict[str, str],
+ ) -> Node:
+ return factory.make(template, name, "slot", variables,
+ files={_relative(root, path): path.read_bytes() for path in inputs},
+ dependencies=dependencies, identity=identity, config=config,
+ )
+
+ format_nodes = tuple(
+ leaf(
+ "clang-format.ps1",
+ f"format-{_slug(root, source)}",
+ {
+ "pwsh": str(tools.pwsh),
+ "clang_format": str(tools.clang_format),
+ "source": str(source),
+ },
+ (root / ".clang-format", source),
+ identity=tool_identity("pwsh", "clang_format"),
+ config={"action": "format", "source": _relative(root, source)},
+ )
+ for source in cpp_sources
+ ) if include_common else ()
+
+ restore_nodes = []
+ cppcheck_nodes = []
+ for architecture in selected_architectures:
+ platform, architecture_define = _CPPCHECK_ARCHITECTURES[architecture]
+ triplet = f"observer-{architecture}-windows-static"
+ restore = restore_node(root, tools, restore_factory, architecture)
+ restore_nodes.append(restore)
+ include_dir = paths.cas(restore.uid).output / triplet / "include"
+ cppcheck_nodes.append(
+ leaf(
+ "cppcheck.ps1",
+ f"cppcheck-{architecture}",
+ {
+ "pwsh": str(tools.pwsh),
+ "cppcheck": str(tools.cppcheck),
+ "repository": str(root),
+ "source_dir": str(root / "src"),
+ "include_dir": str(include_dir),
+ "triplet": triplet,
+ "platform": platform,
+ "architecture_define": architecture_define,
+ "automation_id": f"cppcheck/{architecture}/",
+ },
+ cpp_sources,
+ dependencies=(restore,),
+ identity=tool_identity("pwsh", "cppcheck"),
+ config={"action": "cppcheck", "architecture": architecture},
+ )
+ )
+ cppcheck_nodes = tuple(cppcheck_nodes)
+ restore_nodes = tuple(restore_nodes)
+
+ settings = root / "build/PSScriptAnalyzerSettings.psd1"
+ pssa_nodes = tuple(
+ leaf(
+ "psscriptanalyzer.ps1",
+ f"pssa-{_slug(root, source)}",
+ {
+ "pwsh": str(tools.pwsh),
+ "psscriptanalyzer": str(tools.psscriptanalyzer),
+ "repository": str(root),
+ "source": str(source),
+ "settings": str(settings),
+ "automation_id": f"psscriptanalyzer/{_relative(root, source)}/",
+ },
+ (settings, source),
+ identity=tool_identity("pwsh", "psscriptanalyzer"),
+ config={"action": "psscriptanalyzer", "source": _relative(root, source)},
+ )
+ for source in powershell_sources
+ ) if include_common else ()
+
+ contract_inputs = _repository_files(root)
+ contract_nodes = tuple(
+ leaf(
+ "contract.ps1",
+ f"contract-{_slug(root, test)}",
+ {
+ "pwsh": str(tools.pwsh),
+ "test": str(test),
+ },
+ contract_inputs,
+ identity=tool_identity("pwsh"),
+ config={"action": "contract", "test": _relative(root, test)},
+ )
+ for test in contracts
+ ) if include_common else ()
+
+ def output(current: Node) -> Path:
+ return paths.cas(current.uid).output
+
+ def report(current: Node) -> Path:
+ name = "cppcheck.sarif" if current.name.startswith("cppcheck-") else "psscriptanalyzer.sarif"
+ return output(current) / name
+
+ finding_nodes = cppcheck_nodes + pssa_nodes
+ qualifier = "-".join(selected_architectures)
+ merge_name = "merge-source-findings" if include_common else f"merge-cppcheck-findings-{qualifier}"
+ gate_name = "source-checks" if include_common else f"cppcheck-checks-{qualifier}"
+ result_id = (
+ "reports/sarif/source/analysis.sarif"
+ if include_common else f"reports/sarif/{qualifier}/cppcheck.sarif"
+ )
+ merged = python_action(
+ factory,
+ merge_name,
+ "core.sarif",
+ ("merge", *(str(report(current)) for current in finding_nodes)),
+ finding_nodes,
+ pool="slot",
+ results=(Result(
+ result_id, "report", "application/sarif+json", "analysis.sarif"
+ ),),
+ )
+ direct = format_nodes + contract_nodes
+ gate = python_action(
+ factory,
+ gate_name,
+ "core.sarif",
+ ("gate", str(output(merged) / "analysis.sarif")),
+ (merged,) + direct,
+ pool="slot",
+ )
+ nodes = restore_nodes + format_nodes + cppcheck_nodes + pssa_nodes + contract_nodes + (merged, gate)
+ pools = {"slot": jobs} | ({"restore": 1} if restore_nodes else {})
+ return Graph(nodes, (gate.name,), pools)
diff --git a/build/main.py b/build/main.py
new file mode 100644
index 0000000..4b360ae
--- /dev/null
+++ b/build/main.py
@@ -0,0 +1,202 @@
+"""PowerShell-compatible table-driven CLI for the local build DAG."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+from collections.abc import Awaitable, Callable, Sequence
+from datetime import UTC, datetime
+import os
+from pathlib import Path
+import sys
+
+from core.doctor import main as doctor_main
+from core.host import verify_route
+from core.quality_tools import resolve_binskim, resolve_dumpbin, resolve_umdh
+from core.source_tools import discover_source_tools
+from core.toolchain import discover_msvc_toolchain
+from driver import Driver as BuildDriver
+
+
+_ARCHITECTURES = ("x86", "x64", "arm64")
+_CONFIGURATIONS = ("Debug", "Release")
+_FUZZ_TARGETS = ("pickle", "renpy", "rpgmaker", "zanzarah")
+
+
+def _selection(choices: tuple[str, ...]) -> Callable[[str], tuple[str, ...]]:
+ def parse(value: str) -> tuple[str, ...]:
+ parts = tuple(item.strip() for item in value.split(","))
+ if any(item.casefold() == "all" for item in parts):
+ return choices
+ lookup = {item.casefold(): item for item in choices}
+ try:
+ selected = tuple(lookup[item.casefold()] for item in parts if item)
+ except KeyError as error:
+ raise argparse.ArgumentTypeError(f"expected all or comma-separated: {','.join(choices)}")
+ if len(selected) != len(parts):
+ raise argparse.ArgumentTypeError(f"expected all or comma-separated: {','.join(choices)}")
+ return tuple(dict.fromkeys(selected))
+ return parse
+
+
+def _integer(minimum: int, maximum: int | None = None) -> Callable[[str], int]:
+ def parse(value: str) -> int:
+ try:
+ number = int(value)
+ except ValueError as error:
+ raise argparse.ArgumentTypeError("expected an integer") from error
+ if number < minimum or maximum is not None and number > maximum:
+ limit = f"{minimum}..{maximum}" if maximum is not None else f">= {minimum}"
+ raise argparse.ArgumentTypeError(f"expected {limit}")
+ return number
+ return parse
+
+
+_OPTIONS: dict[str, tuple[tuple[str, ...], dict[str, object]]] = {
+ "arch": (("-Arch", "--arch"), {"type": _selection(_ARCHITECTURES), "default": ("x64",)}),
+ "config": (("-Config", "--config"), {"type": _selection(_CONFIGURATIONS), "default": ("Debug",)}),
+ "corpus": (("-Corpus", "--corpus"), {"type": Path}),
+ "restore": (("-RestoreFlavor", "--restore-flavor"), {
+ "type": _selection(("default", "asan")), "default": ("default",),
+ }),
+ "shards": (("-TestShards", "--test-shards"), {"type": _integer(1), "default": 4}),
+ "fuzz_seconds": (("-FuzzSeconds", "--fuzz-seconds"), {"type": _integer(1, 86400), "default": 60}),
+ "fuzz_target": (("-FuzzTarget", "--fuzz-target"), {"type": _selection(_FUZZ_TARGETS), "default": _FUZZ_TARGETS}),
+ "leak_warmup": (("-LeakWarmup", "--leak-warmup"), {"type": _integer(1, 1_000_000), "default": 8}),
+ "leak_iterations": (("-LeakIterations", "--leak-iterations"), {"type": _integer(1, 1_000_000), "default": 100}),
+ "leak_windows": (("-LeakWindows", "--leak-windows"), {"type": _integer(3, 10), "default": 3}),
+ "leak_tolerance": (("-LeakToleranceBytes", "--leak-tolerance-bytes"), {
+ "type": _integer(0, 1_073_741_824), "default": 0,
+ }),
+ "export_dir": (("-ExportDir", "--export-dir"), {"type": Path}),
+ "prune_cas": (("-PruneCas", "--prune-cas"), {"action": "store_true"}),
+ "clean_mode": (("-CleanMode", "--clean-mode"), {"choices": ("all", "stale-work"), "default": "all"}),
+}
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="observer-build")
+ commands = parser.add_subparsers(dest="command", required=True)
+ commands.add_parser("doctor")
+ for name, (options, _invoke) in _COMMANDS.items():
+ command = commands.add_parser(name)
+ command.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1])
+ command.add_argument("-Jobs", "--jobs", type=_integer(1))
+ for option in options:
+ flags, settings = _OPTIONS[option]
+ command.add_argument(*flags, dest=option, **settings)
+ clean = commands.add_parser("clean")
+ clean.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1])
+ flags, settings = _OPTIONS["clean_mode"]
+ clean.add_argument(*flags, dest="clean_mode", **settings)
+ return parser
+
+
+def _run_id() -> str:
+ return datetime.now(UTC).strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}"
+
+
+def run_clean(repository: Path, mode: str) -> int:
+ from core.clean import main as clean_main
+ return clean_main((str(repository), "--mode", mode))
+
+
+Invoker = Callable[[object, argparse.Namespace, object], Awaitable[tuple[Path, ...]]]
+
+
+async def _restore(driver: object, args: argparse.Namespace, _toolchain: object) -> tuple[Path, ...]:
+ outputs: tuple[Path, ...] = ()
+ if "default" in args.restore:
+ outputs += await driver.restore(args.arch, flavors=("",))
+ if "asan" in args.restore:
+ architectures = tuple(item for item in args.arch if item != "arm64")
+ if architectures:
+ outputs += await driver.restore(architectures, flavors=("asan",))
+ return outputs
+
+
+_COMMANDS: dict[str, tuple[tuple[str, ...], Invoker]] = {
+ "restore": (("arch", "restore"), _restore),
+ "build": (("arch", "config"), lambda driver, args, _toolchain: driver.build(args.arch, args.config)),
+ "test": (("arch", "config", "corpus", "shards"), lambda driver, args, _toolchain: driver.test(
+ args.arch, args.config, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce
+ )),
+ "source-checks": (("arch",), lambda driver, args, toolchain: driver.source_checks(
+ args.arch, discover_source_tools(toolchain)
+ )),
+ "compiler-analysis": (("arch",), lambda driver, args, _toolchain: driver.compiler_analysis(args.arch)),
+ "test-coverage": (("arch", "corpus", "shards"), lambda driver, args, _toolchain: driver.test_coverage(
+ args.arch, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce
+ )),
+ "test-asan": (("arch", "shards"), lambda driver, args, _toolchain: driver.test_asan(args.arch, test_shards=args.shards)),
+ "test-ubsan": (("arch", "shards"), lambda driver, args, _toolchain: driver.test_ubsan(args.arch, test_shards=args.shards)),
+ "test-leaks": (("arch", "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance"), lambda driver, args, toolchain: driver.test_leaks(
+ run_nonce=args.run_nonce, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(),
+ umdh=resolve_umdh(), warmup=args.leak_warmup, iterations=args.leak_iterations,
+ windows=args.leak_windows, tolerance_bytes=args.leak_tolerance,
+ )),
+ "fuzz": (("arch", "fuzz_seconds", "fuzz_target"), lambda driver, args, _toolchain: driver.fuzz(
+ run_nonce=args.run_nonce, seconds=args.fuzz_seconds, targets=args.fuzz_target
+ )),
+ "audit-binaries": (("arch",), lambda driver, args, toolchain: driver.audit(
+ args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim()
+ )),
+ "package": (("arch", "export_dir"), lambda driver, args, toolchain: driver.package(
+ args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(),
+ export_dir=args.export_dir,
+ )),
+ "verify-source": (("export_dir",), lambda driver, args, _toolchain: driver.verify_source(
+ export_dir=args.export_dir,
+ )),
+ "verify-arch": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup",
+ "leak_iterations", "leak_windows", "leak_tolerance", "export_dir",
+ "prune_cas"),
+ lambda driver, args, _toolchain: driver.verify_arch(
+ args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds,
+ test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations,
+ windows=args.leak_windows, tolerance_bytes=args.leak_tolerance,
+ export_dir=args.export_dir,
+ )),
+ "verify": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup",
+ "leak_iterations", "leak_windows", "leak_tolerance", "export_dir",
+ "prune_cas"),
+ lambda driver, args, _toolchain: driver.verify(
+ args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds,
+ test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations,
+ windows=args.leak_windows, tolerance_bytes=args.leak_tolerance,
+ export_dir=args.export_dir,
+ )),
+}
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = _parser()
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ if not arguments or len(arguments) == 1 and arguments[0].casefold() == "help":
+ parser.print_help()
+ return 0
+ args = parser.parse_args(arguments)
+ if args.command == "doctor":
+ return doctor_main(())
+ if args.command == "clean":
+ return run_clean(args.repository, args.clean_mode)
+ if args.command in {"fuzz", "test-leaks"} and args.arch != ("x64",):
+ parser.error(f"{args.command} requires -Arch x64")
+ if args.command == "verify-arch" and len(args.arch) != 1:
+ parser.error("verify-arch requires exactly one architecture")
+ args.run_nonce = _run_id()
+ toolchain = discover_msvc_toolchain()
+ driver = BuildDriver(
+ args.repository, args.run_nonce, toolchain,
+ jobs=args.jobs, prune_cas=getattr(args, "prune_cas", False),
+ )
+ outputs = asyncio.run(_COMMANDS[args.command][1](driver, args, toolchain))
+ if args.command in {"verify", "verify-arch"}:
+ for item in verify_route(args.arch).deferred:
+ print(f"[DEFERRED] {item.gate} {item.architecture}: {item.reason}")
+ for output in outputs:
+ print(output)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/build/projects/fuzz-pickle.vcxproj b/build/projects/fuzz-pickle.vcxproj
new file mode 100644
index 0000000..d7f0a77
--- /dev/null
+++ b/build/projects/fuzz-pickle.vcxproj
@@ -0,0 +1,36 @@
+
+
+
+
+ 17.0
+ {202A197D-CF6A-47D3-A379-64241A579795}
+ Win32Proj
+ fuzz_pickle
+ 10.0
+ Application
+
+
+
+
+
+
+
+
+
+ fuzz-pickle
+
+
+
+ $(IntDir)pickle_fuzzer.obj
+
+
+ $(IntDir)pickle_parser.obj
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/fuzz-renpy.vcxproj b/build/projects/fuzz-renpy.vcxproj
new file mode 100644
index 0000000..70a9cd1
--- /dev/null
+++ b/build/projects/fuzz-renpy.vcxproj
@@ -0,0 +1,45 @@
+
+
+
+
+ 17.0
+ {086A7516-3492-4D13-8BD5-11123490C810}
+ Win32Proj
+ fuzz_renpy
+ 10.0
+ Application
+
+
+
+
+
+
+
+
+
+ fuzz-renpy
+
+
+
+ zs.lib;%(AdditionalDependencies)
+
+
+
+
+
+ $(IntDir)archive_fuzzer.obj
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/fuzz-rpgmaker.vcxproj b/build/projects/fuzz-rpgmaker.vcxproj
new file mode 100644
index 0000000..ea27dec
--- /dev/null
+++ b/build/projects/fuzz-rpgmaker.vcxproj
@@ -0,0 +1,37 @@
+
+
+
+
+ 17.0
+ {DF60A28B-D6D4-4EF0-811B-4CC53ED93070}
+ Win32Proj
+ fuzz_rpgmaker
+ 10.0
+ Application
+
+
+
+
+
+
+
+
+
+ fuzz-rpgmaker
+
+
+
+
+
+ $(IntDir)archive_fuzzer.obj
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/fuzz-zanzarah.vcxproj b/build/projects/fuzz-zanzarah.vcxproj
new file mode 100644
index 0000000..66615d9
--- /dev/null
+++ b/build/projects/fuzz-zanzarah.vcxproj
@@ -0,0 +1,37 @@
+
+
+
+
+ 17.0
+ {F56AF8F0-E552-41DB-85E9-8131548828E6}
+ Win32Proj
+ fuzz_zanzarah
+ 10.0
+ Application
+
+
+
+
+
+
+
+
+
+ fuzz-zanzarah
+
+
+
+
+
+ $(IntDir)archive_fuzzer.obj
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/leak-probe.vcxproj b/build/projects/leak-probe.vcxproj
new file mode 100644
index 0000000..72b43dc
--- /dev/null
+++ b/build/projects/leak-probe.vcxproj
@@ -0,0 +1,51 @@
+
+
+
+
+ 17.0
+ {3690B34C-A1CA-448D-A301-119156269054}
+ Win32Proj
+ leak_probe
+ 10.0
+ Application
+
+
+
+
+
+
+
+
+ leak-probe
+
+
+
+ MultiThreaded
+
+
+ zs.lib;%(AdditionalDependencies)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/renpy.vcxproj b/build/projects/renpy.vcxproj
new file mode 100644
index 0000000..1c2bab7
--- /dev/null
+++ b/build/projects/renpy.vcxproj
@@ -0,0 +1,48 @@
+
+
+
+
+ 17.0
+ {4E8AE1A2-12B2-4A87-9B28-62E3E7CDB510}
+ Win32Proj
+ renpy
+ 10.0
+ DynamicLibrary
+
+
+
+
+
+
+
+
+ renpy
+ .so
+
+
+
+ $(RepositoryRoot)src\modules\renpy\renpy.def
+ zsd.lib;%(AdditionalDependencies)
+ zs.lib;%(AdditionalDependencies)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/rpgmaker.vcxproj b/build/projects/rpgmaker.vcxproj
new file mode 100644
index 0000000..148b648
--- /dev/null
+++ b/build/projects/rpgmaker.vcxproj
@@ -0,0 +1,43 @@
+
+
+
+
+ 17.0
+ {7DF16D34-0324-4AC4-B99F-637D2A7E53E8}
+ Win32Proj
+ rpgmaker
+ 10.0
+ DynamicLibrary
+
+
+
+
+
+
+
+
+ rpgmaker
+ .so
+
+
+
+ $(RepositoryRoot)src\modules\rpgmaker\rpgmaker.def
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/tests.vcxproj b/build/projects/tests.vcxproj
new file mode 100644
index 0000000..be209d5
--- /dev/null
+++ b/build/projects/tests.vcxproj
@@ -0,0 +1,69 @@
+
+
+
+
+ 17.0
+ {C074B2CD-C481-4C20-A349-902E02D39C6D}
+ Win32Proj
+ tests
+ 10.0
+ Application
+
+
+
+
+
+
+
+
+ tests
+ debug\
+
+
+
+ $(VcpkgInstalledDir)$(VcpkgTriplet)\$(ObserverVcpkgDebugPrefix)lib\manual-link;%(AdditionalLibraryDirectories)
+ Catch2d.lib;xxhash.lib;zsd.lib;%(AdditionalDependencies)
+ Catch2.lib;xxhash.lib;zs.lib;%(AdditionalDependencies)
+
+
+
+
+ $(IntDir)bounded_stream_core.obj
+
+
+
+
+
+
+
+
+
+
+
+
+ $(IntDir)pickle_unit.obj
+
+
+
+ $(IntDir)bounded_stream_unit.obj
+
+
+ $(IntDir)pickle_parser.obj
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/projects/zanzarah.vcxproj b/build/projects/zanzarah.vcxproj
new file mode 100644
index 0000000..c557929
--- /dev/null
+++ b/build/projects/zanzarah.vcxproj
@@ -0,0 +1,43 @@
+
+
+
+
+ 17.0
+ {B313D87B-B15E-4E0E-92F9-DA04231CC41A}
+ Win32Proj
+ zanzarah
+ 10.0
+ DynamicLibrary
+
+
+
+
+
+
+
+
+ zanzarah
+ .so
+
+
+
+ $(RepositoryRoot)src\modules\zanzarah\zanzarah.def
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/pyproject.toml b/build/pyproject.toml
new file mode 100644
index 0000000..658a49f
--- /dev/null
+++ b/build/pyproject.toml
@@ -0,0 +1,27 @@
+[project]
+name = "observer-build"
+version = "0.1.0"
+description = "Local content-addressed build graph for ObserverModules"
+requires-python = "==3.14.6"
+dependencies = [
+ "coverage==7.15.2",
+ "filelock==3.32.2",
+ "jinja2==3.1.6",
+ "psutil==7.2.2",
+ "pywin32==312; sys_platform == 'win32'",
+]
+
+[tool.uv]
+package = false
+
+[tool.coverage.run]
+branch = true
+command_line = "-m unittest discover -s tests -p test_*.py"
+source = ["."]
+
+[tool.coverage.report]
+exclude_lines = []
+fail_under = 100
+include = ["core/*", "graphs/*", "driver.py", "main.py"]
+show_missing = true
+skip_covered = false
diff --git a/build/templates/_output.ps1 b/build/templates/_output.ps1
new file mode 100644
index 0000000..83a273f
--- /dev/null
+++ b/build/templates/_output.ps1
@@ -0,0 +1,5 @@
+{% macro require_output(relative_path, message, path_type='Leaf') -%}
+if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ relative_path | ps_quote }}) -PathType {{ path_type }})) {
+ throw {{ message | ps_quote }}
+}
+{%- endmacro %}
diff --git a/build/templates/analysis.ps1 b/build/templates/analysis.ps1
new file mode 100644
index 0000000..d5e74b0
--- /dev/null
+++ b/build/templates/analysis.ps1
@@ -0,0 +1,6 @@
+{% extends "selected-compile.ps1" %}
+{% block compile_args %}
+ '/p:ObserverRunCodeAnalysis=true'
+ '/p:RunCodeAnalysis=true'
+{% block analyzer_args required %}{% endblock %}
+{% endblock %}
diff --git a/build/templates/argv.json b/build/templates/argv.json
new file mode 100644
index 0000000..1e10f71
--- /dev/null
+++ b/build/templates/argv.json
@@ -0,0 +1,3 @@
+{% extends "script.json" %}
+{% block script_exec %}{{ argv | json }}{% endblock %}
+{% block script_body %}{% endblock %}
diff --git a/build/templates/base.json b/build/templates/base.json
new file mode 100644
index 0000000..5855f42
--- /dev/null
+++ b/build/templates/base.json
@@ -0,0 +1,7 @@
+{
+ "name": {{ name | json }},
+ "pool": {{ pool | json }},
+ "inputs": {{ inputs | json }},
+ "results": {{ results | json }},
+ "script": {% block script %}null{% endblock %}
+}
diff --git a/build/templates/catch2-test.ps1 b/build/templates/catch2-test.ps1
new file mode 100644
index 0000000..654f675
--- /dev/null
+++ b/build/templates/catch2-test.ps1
@@ -0,0 +1,26 @@
+{% extends "pwsh.ps1" %}{% from "_output.ps1" import require_output %}
+{% block pwsh_body %}
+{% for artifact in artifacts %}Copy-Item -LiteralPath {{ artifact.source | ps_quote }} -Destination (Join-Path $outDir {{ artifact.name | ps_quote }})
+{% endfor %}
+{% block catch2_setup %}{% endblock %}Push-Location $outDir
+try {
+ Invoke-Checked (Join-Path $outDir 'tests.exe') @(
+{% block test_filter %}{% endblock %}
+ '--reporter'
+ 'compact'
+ '--reporter'
+ 'JUnit::out=tests.xml'
+ '--durations'
+ 'yes'
+ '--order'
+ 'lex'
+ '--shard-count'
+ {{ shard_count | string | ps_quote }}
+ '--shard-index'
+ {{ shard_index | string | ps_quote }}
+ )
+} finally {
+ Pop-Location
+}
+{% block catch2_post %}{{ require_output('tests.xml', 'Catch2 did not produce tests.xml') }}
+{% endblock %}{% endblock %}
diff --git a/build/templates/clang-command.ps1 b/build/templates/clang-command.ps1
new file mode 100644
index 0000000..e62748c
--- /dev/null
+++ b/build/templates/clang-command.ps1
@@ -0,0 +1,8 @@
+{% extends "selected-compile.ps1" %}{% from "_output.ps1" import require_output %}
+{% block compile_args %}
+ "/p:ObserverClangCommandPath=$outDir\compile-command.json"
+ {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }}
+{% endblock %}
+{% block post_msbuild %}
+{{ require_output('compile-command.json', 'clang-cl did not produce compile-command.json') }}
+{% endblock %}
diff --git a/build/templates/clang-format.ps1 b/build/templates/clang-format.ps1
new file mode 100644
index 0000000..d5dd9cc
--- /dev/null
+++ b/build/templates/clang-format.ps1
@@ -0,0 +1,8 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+Invoke-Checked {{ clang_format | ps_quote }} @(
+ '--dry-run'
+ '--Werror'
+ {{ source | ps_quote }}
+)
+{% endblock %}
diff --git a/build/templates/clang-tidy.ps1 b/build/templates/clang-tidy.ps1
new file mode 100644
index 0000000..4db324c
--- /dev/null
+++ b/build/templates/clang-tidy.ps1
@@ -0,0 +1,13 @@
+{% extends "analysis.ps1" %}{% from "_output.ps1" import require_output %}
+{% block int_dir %}
+ "/p:IntDir=$outDir\obj\"
+{% endblock %}
+{% block analyzer_args %}
+ '/p:EnableMicrosoftCodeAnalysis=false'
+ '/p:ObserverEnableClangTidy=true'
+ {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }}
+ {{ ('/p:ClangTidyLogFile=' ~ project_name ~ '.ClangTidy.log') | ps_quote }}
+{% endblock %}
+{% block post_msbuild %}
+{{ require_output('obj\\' ~ project_name ~ '.ClangTidy.log', 'clang-tidy did not produce ' ~ project_name ~ '.ClangTidy.log') }}
+{% endblock %}
diff --git a/build/templates/contract.ps1 b/build/templates/contract.ps1
new file mode 100644
index 0000000..214e94e
--- /dev/null
+++ b/build/templates/contract.ps1
@@ -0,0 +1,4 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+& {{ test | ps_quote }}
+{% endblock %}
diff --git a/build/templates/coverage-corpus-test.ps1 b/build/templates/coverage-corpus-test.ps1
new file mode 100644
index 0000000..b72d6ff
--- /dev/null
+++ b/build/templates/coverage-corpus-test.ps1
@@ -0,0 +1,3 @@
+{% extends "coverage-test.ps1" %}
+{% block test_filter %} '[compatibility]'
+{% endblock %}
diff --git a/build/templates/coverage-merge.ps1 b/build/templates/coverage-merge.ps1
new file mode 100644
index 0000000..5a98d6d
--- /dev/null
+++ b/build/templates/coverage-merge.ps1
@@ -0,0 +1,11 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+$profiles = @(
+{% for directory in profile_directories %} Get-ChildItem -LiteralPath {{ directory | ps_quote }} -File -Filter '*.profraw'
+{% endfor %}) | Sort-Object FullName | ForEach-Object FullName
+if ($profiles.Count -eq 0) {
+ throw 'Coverage shards produced no LLVM raw profiles'
+}
+$arguments = @('merge', '-sparse') + $profiles + @('-o', (Join-Path $outDir 'coverage.profdata'))
+Invoke-Checked {{ llvm_profdata | ps_quote }} $arguments
+{% endblock %}
diff --git a/build/templates/coverage-report.ps1 b/build/templates/coverage-report.ps1
new file mode 100644
index 0000000..d3d5c75
--- /dev/null
+++ b/build/templates/coverage-report.ps1
@@ -0,0 +1,24 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+$arguments = @(
+ 'export'
+ {{ test_executable | ps_quote }}
+ '--instr-profile'
+ {{ profile | ps_quote }}
+ '--ignore-filename-regex'
+ {{ ignore_regex | ps_quote }}
+{% for object in objects %} '--object'
+ {{ object | ps_quote }}
+{% endfor %}{% if summary_only %} '--summary-only'
+{% else %} '--format=lcov'
+{% endif %})
+$content = & {{ llvm_cov | ps_quote }} @arguments
+if ($LASTEXITCODE -ne 0) {
+ throw "llvm-cov failed with exit code $LASTEXITCODE"
+}
+$text = @($content | Where-Object { $_ -notmatch '^warning:' }) -join "`n"
+if ([string]::IsNullOrWhiteSpace($text)) {
+ throw 'llvm-cov produced an empty report'
+}
+[IO.File]::WriteAllText((Join-Path $outDir {{ report_name | ps_quote }}), $text, [Text.UTF8Encoding]::new($false))
+{% endblock %}
diff --git a/build/templates/coverage-test.ps1 b/build/templates/coverage-test.ps1
new file mode 100644
index 0000000..1fbfe77
--- /dev/null
+++ b/build/templates/coverage-test.ps1
@@ -0,0 +1,7 @@
+{% extends "catch2-test.ps1" %}
+{% block catch2_setup %}$env:LLVM_PROFILE_FILE = Join-Path $outDir 'coverage-%m-%p.profraw'
+{% endblock %}
+{% block catch2_post %}if (-not (Get-ChildItem -LiteralPath $outDir -File -Filter '*.profraw')) {
+ throw 'Instrumented Catch2 shard produced no LLVM raw profiles'
+}
+{% endblock %}
diff --git a/build/templates/cppcheck.ps1 b/build/templates/cppcheck.ps1
new file mode 100644
index 0000000..e015bbe
--- /dev/null
+++ b/build/templates/cppcheck.ps1
@@ -0,0 +1,41 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+if (-not (Test-Path -LiteralPath {{ include_dir | ps_quote }} -PathType Container)) {
+ throw 'Cppcheck dependency headers were not restored'
+}
+Invoke-Checked {{ cppcheck | ps_quote }} @(
+ {{ source_dir | ps_quote }}
+ '--std=c++23'
+ {{ ('--platform=' ~ platform) | ps_quote }}
+ '-DWIN32=1'
+ '-D_WIN32=1'
+ '-DUNICODE=1'
+ '-D_UNICODE=1'
+ {{ ('-D' ~ architecture_define) | ps_quote }}
+ {{ ('-I' ~ source_dir) | ps_quote }}
+ {{ ('-I' ~ include_dir) | ps_quote }}
+ '--enable=warning,style,performance,portability'
+ '--check-level=exhaustive'
+ '--inconclusive'
+ '--inline-suppr'
+ '--suppress=missingIncludeSystem'
+ '--suppress=uninitMemberVarNoCtor:src/api.h'
+ {{ ('--suppress=*:*\\' ~ triplet ~ '\\include\\*') | ps_quote }}
+ '--suppress=functionStatic'
+ {{ ('--relative-paths=' ~ repository) | ps_quote }}
+ '--output-format=sarif'
+ "--output-file=$outDir\cppcheck.sarif"
+ "--cppcheck-build-dir=$buildDir"
+)
+$reportPath = Join-Path $outDir 'cppcheck.sarif'
+if (-not (Test-Path -LiteralPath $reportPath -PathType Leaf)) {
+ throw 'Cppcheck did not produce cppcheck.sarif'
+}
+$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json
+$index = 0
+foreach ($run in @($report.runs)) {
+ $run | Add-Member -NotePropertyName automationDetails -NotePropertyValue ([ordered]@{ id = {{ automation_id | ps_quote }} + "$index/" }) -Force
+ ++$index
+}
+$report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $reportPath -Encoding utf8
+{% endblock %}
diff --git a/build/templates/fuzz-build.ps1 b/build/templates/fuzz-build.ps1
new file mode 100644
index 0000000..3fcbeb6
--- /dev/null
+++ b/build/templates/fuzz-build.ps1
@@ -0,0 +1,9 @@
+{% extends "msbuild.ps1" %}{% from "_output.ps1" import require_output %}
+{% block msbuild_args %}
+ {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }}
+ {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }}
+ {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }}
+ {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }}
+{% endblock %}
+{% block post_msbuild %}{{ require_output(executable_name, 'MSBuild did not produce the fuzzer executable') }}
+{% endblock %}
diff --git a/build/templates/fuzz-common.ps1 b/build/templates/fuzz-common.ps1
new file mode 100644
index 0000000..cea037b
--- /dev/null
+++ b/build/templates/fuzz-common.ps1
@@ -0,0 +1,36 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+$fuzzer = {{ fuzzer | ps_quote }}
+$seedDir = {{ seed_dir | ps_quote }}
+$corpus = Join-Path $buildDir 'corpus'
+$artifactDir = Join-Path $outDir 'artifacts'
+if (-not (Test-Path -LiteralPath $fuzzer -PathType Leaf)) { throw 'Fuzzer executable was not found' }
+if (-not (Test-Path -LiteralPath $seedDir -PathType Container)) { throw 'Fuzzer seed directory was not found' }
+[void](New-Item -ItemType Directory -Path $corpus)
+[void](New-Item -ItemType Directory -Path $artifactDir)
+$seeds = @(Get-ChildItem -LiteralPath $seedDir -File | Sort-Object Name)
+if ($seeds.Count -eq 0) { throw 'No checked-in fuzzer seeds were found' }
+foreach ($seed in $seeds) {
+ if ($seed.Extension -eq '.hex') {
+ $hex = (Get-Content -LiteralPath $seed.FullName -Raw) -replace '\s', ''
+ if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') {
+ throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)"
+ }
+ [IO.File]::WriteAllBytes((Join-Path $corpus $seed.BaseName), [Convert]::FromHexString($hex))
+ } else {
+ Copy-Item -LiteralPath $seed.FullName -Destination $corpus
+ }
+}
+{% if prior_corpus is defined and prior_corpus %}$priorCorpus = {{ prior_corpus | ps_quote }}
+if (-not (Test-Path -LiteralPath $priorCorpus -PathType Container)) { throw 'Prior fuzzer corpus was not found' }
+Get-ChildItem -LiteralPath $priorCorpus -File | Sort-Object Name | ForEach-Object {
+ Copy-Item -LiteralPath $_.FullName -Destination $corpus -Force
+}
+{% endif %}Push-Location (Split-Path -Parent $fuzzer)
+try {
+{% block fuzz_invoke required %}{% endblock %}
+} finally {
+ Pop-Location
+}
+{% block fuzz_post %}{% endblock %}
+{% endblock %}
diff --git a/build/templates/fuzz-gate.ps1 b/build/templates/fuzz-gate.ps1
new file mode 100644
index 0000000..1d3921a
--- /dev/null
+++ b/build/templates/fuzz-gate.ps1
@@ -0,0 +1,10 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+$status = {{ status | ps_quote }}
+if (-not (Test-Path -LiteralPath $status -PathType Leaf)) { throw 'Fuzzer status was not found' }
+$exitCode = 0
+if (-not [int]::TryParse((Get-Content -LiteralPath $status -Raw), [ref]$exitCode)) {
+ throw 'Fuzzer status is not an integer'
+}
+if ($exitCode -ne 0) { throw "Fuzzer exited with code $exitCode" }
+{% endblock %}
diff --git a/build/templates/fuzz-replay.ps1 b/build/templates/fuzz-replay.ps1
new file mode 100644
index 0000000..dbd3afe
--- /dev/null
+++ b/build/templates/fuzz-replay.ps1
@@ -0,0 +1,10 @@
+{% extends "fuzz-common.ps1" %}
+{% block fuzz_invoke %}$inputs = @(Get-ChildItem -LiteralPath $corpus -File | Sort-Object Name)
+Invoke-Checked $fuzzer (@($inputs.FullName) + @(
+ {{ ('-max_len=' ~ max_length) | ps_quote }}
+ '-rss_limit_mb=1024'
+ '-timeout=10'
+ '-print_final_stats=1'
+ "-artifact_prefix=$artifactDir\"
+))
+{% endblock %}
diff --git a/build/templates/fuzz-run.ps1 b/build/templates/fuzz-run.ps1
new file mode 100644
index 0000000..73d6b1c
--- /dev/null
+++ b/build/templates/fuzz-run.ps1
@@ -0,0 +1,21 @@
+{% extends "fuzz-common.ps1" %}
+{% block fuzz_invoke %}$PSNativeCommandUseErrorActionPreference = $false
+& $fuzzer @(
+ $corpus
+ {{ ('-max_total_time=' ~ seconds) | ps_quote }}
+ {{ ('-max_len=' ~ max_length) | ps_quote }}
+ '-rss_limit_mb=1024'
+ '-timeout=10'
+ '-use_value_profile=1'
+ '-print_final_stats=1'
+ "-artifact_prefix=$artifactDir\"
+)
+$fuzzExitCode = $LASTEXITCODE
+{% endblock %}
+{% block fuzz_post %}$publishedCorpus = Join-Path $outDir 'corpus'
+[void](New-Item -ItemType Directory -Path $publishedCorpus)
+Get-ChildItem -LiteralPath $corpus -File | Sort-Object Name | ForEach-Object {
+ Copy-Item -LiteralPath $_.FullName -Destination $publishedCorpus
+}
+[IO.File]::WriteAllText((Join-Path $outDir 'status.txt'), [string]$fuzzExitCode)
+{% endblock %}
diff --git a/build/templates/instrumented-build.ps1 b/build/templates/instrumented-build.ps1
new file mode 100644
index 0000000..7fe1480
--- /dev/null
+++ b/build/templates/instrumented-build.ps1
@@ -0,0 +1,8 @@
+{% extends "msbuild.ps1" %}
+{% block msbuild_args %}
+ {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }}
+ '/p:VcpkgManifestInstall=false'
+ {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }}
+{% if llvm_dir %} {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }}
+{% endif %}{% if llvm_runtime %} {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }}
+{% endif %}{% endblock %}
diff --git a/build/templates/msbuild.ps1 b/build/templates/msbuild.ps1
new file mode 100644
index 0000000..22a6142
--- /dev/null
+++ b/build/templates/msbuild.ps1
@@ -0,0 +1,18 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+{% block pwsh_setup %}{% endblock %}Invoke-Checked {{ msbuild | ps_quote }} @(
+ {{ project | ps_quote }}
+ '/nologo'
+ '/m:1'
+ '/nr:false'
+ {{ ('/t:' ~ target) | ps_quote }}
+ '/p:BuildProjectReferences=false'
+ {{ ('/p:Configuration=' ~ configuration) | ps_quote }}
+ {{ ('/p:Platform=' ~ platform) | ps_quote }}
+ "/p:OutDir=$outDir\"
+{% block int_dir %} "/p:IntDir=$buildDir\"{{ '\n' }}{% endblock %}
+{% block msbuild_args %}{% for argument in msbuild_args %}
+ {{ argument | ps_quote }}
+{% endfor %}{% endblock %}
+){{ '\n' }}{% block post_msbuild %}{% endblock %}
+{% endblock %}
diff --git a/build/templates/msvc-analyze.ps1 b/build/templates/msvc-analyze.ps1
new file mode 100644
index 0000000..92f3d0f
--- /dev/null
+++ b/build/templates/msvc-analyze.ps1
@@ -0,0 +1,10 @@
+{% extends "analysis.ps1" %}{% from "_output.ps1" import require_output %}
+{% block analyzer_args %}
+ '/p:EnableMicrosoftCodeAnalysis=true'
+ '/p:ObserverEnableClangTidy=false'
+ {{ ('/p:ObserverAnalysisReportName=' ~ project_name) | ps_quote }}
+ "/p:ObserverAnalysisReportPath=$outDir\{{ project_name }}.sarif"
+{% endblock %}
+{% block post_msbuild %}
+{{ require_output(project_name ~ '.sarif', 'MSVC analysis did not produce ' ~ project_name ~ '.sarif') }}
+{% endblock %}
diff --git a/build/templates/native-build.ps1 b/build/templates/native-build.ps1
new file mode 100644
index 0000000..af24b02
--- /dev/null
+++ b/build/templates/native-build.ps1
@@ -0,0 +1,10 @@
+{% extends "msbuild.ps1" %}
+{% block msbuild_args %}
+ {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }}
+ '/p:VcpkgManifestInstall=false'
+{% if vcpkg_installed %}
+ {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }}
+{% else %}
+ "/p:VcpkgInstalledDir=$buildDir\vcpkg\"
+{% endif %}
+{% endblock %}
diff --git a/build/templates/native-corpus-test.ps1 b/build/templates/native-corpus-test.ps1
new file mode 100644
index 0000000..fbf2795
--- /dev/null
+++ b/build/templates/native-corpus-test.ps1
@@ -0,0 +1,3 @@
+{% extends "native-test.ps1" %}
+{% block test_filter %} '[compatibility]'
+{% endblock %}
diff --git a/build/templates/native-test.ps1 b/build/templates/native-test.ps1
new file mode 100644
index 0000000..4b03140
--- /dev/null
+++ b/build/templates/native-test.ps1
@@ -0,0 +1 @@
+{% extends "catch2-test.ps1" %}
diff --git a/build/templates/psscriptanalyzer.ps1 b/build/templates/psscriptanalyzer.ps1
new file mode 100644
index 0000000..206118e
--- /dev/null
+++ b/build/templates/psscriptanalyzer.ps1
@@ -0,0 +1,27 @@
+{% extends "pwsh.ps1" %}
+{% block pwsh_body %}
+Import-Module {{ psscriptanalyzer | ps_quote }}
+$diagnostics = @(Invoke-ScriptAnalyzer -Path {{ source | ps_quote }} -Settings {{ settings | ps_quote }})
+$results = @($diagnostics | ForEach-Object {
+ $level = switch ($_.Severity.ToString()) { 'Error' { 'error' } 'Warning' { 'warning' } default { 'note' } }
+ [ordered]@{
+ ruleId = $_.RuleName
+ level = $level
+ message = [ordered]@{ text = $_.Message }
+ locations = @([ordered]@{ physicalLocation = [ordered]@{
+ artifactLocation = [ordered]@{ uri = [IO.Path]::GetRelativePath({{ repository | ps_quote }}, $_.ScriptPath).Replace('\', '/') }
+ region = [ordered]@{ startLine = [int]$_.Line; startColumn = [int]$_.Column }
+ }})
+ }
+})
+$sarif = [ordered]@{
+ version = '2.1.0'
+ '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json'
+ runs = @([ordered]@{
+ automationDetails = [ordered]@{ id = {{ automation_id | ps_quote }} }
+ tool = [ordered]@{ driver = [ordered]@{ name = 'PSScriptAnalyzer' } }
+ results = $results
+ })
+}
+$sarif | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath "$outDir\psscriptanalyzer.sarif" -Encoding utf8
+{% endblock %}
diff --git a/build/templates/pwsh.ps1 b/build/templates/pwsh.ps1
new file mode 100644
index 0000000..c5426e8
--- /dev/null
+++ b/build/templates/pwsh.ps1
@@ -0,0 +1,30 @@
+{% extends "script.json" %}
+
+{% block script_exec %}
+[{{ pwsh | json }},"-NoLogo","-NoProfile","-NonInteractive","-Command","$ErrorActionPreference = 'Stop'; & ([ScriptBlock]::Create([Console]::In.ReadToEnd()))"]
+{% endblock %}
+
+{% block script_body %}
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$outDir = $env:OBSERVER_OUT_DIR
+$buildDir = $env:OBSERVER_BUILD_DIR
+if ([string]::IsNullOrWhiteSpace($outDir) -or [string]::IsNullOrWhiteSpace($buildDir)) {
+ throw 'OBSERVER_OUT_DIR and OBSERVER_BUILD_DIR are required'
+}
+
+function Invoke-Checked {
+ param(
+ [Parameter(Mandatory)][string] $FilePath,
+ [Parameter()][string[]] $ArgumentList = @()
+ )
+
+ & $FilePath @ArgumentList
+ if ($LASTEXITCODE -ne 0) {
+ throw "Command failed with exit code ${LASTEXITCODE}: $FilePath"
+ }
+}
+
+{% block pwsh_body required %}{% endblock %}
+{% endblock %}
diff --git a/build/templates/sanitizer-test.ps1 b/build/templates/sanitizer-test.ps1
new file mode 100644
index 0000000..0dc2264
--- /dev/null
+++ b/build/templates/sanitizer-test.ps1
@@ -0,0 +1,6 @@
+{% extends "catch2-test.ps1" %}{% from "_output.ps1" import require_output %}
+{% block catch2_setup %}{% if runtime %}Copy-Item -LiteralPath {{ runtime.source | ps_quote }} -Destination (Join-Path $outDir {{ runtime.name | ps_quote }})
+{% endif %}$env:{{ options_name }} = {{ options_value | ps_quote }}
+{% endblock %}
+{% block catch2_post %}{{ require_output('tests.xml', 'Sanitizer Catch2 shard did not produce tests.xml') }}
+{% endblock %}
diff --git a/build/templates/script.json b/build/templates/script.json
new file mode 100644
index 0000000..65d691b
--- /dev/null
+++ b/build/templates/script.json
@@ -0,0 +1,8 @@
+{% extends "base.json" %}
+
+{% block script %}
+{
+ "exec": {{ self.script_exec() | trim }},
+ "data": {{ self.script_body() | trim | json }}
+}
+{% endblock %}
diff --git a/build/templates/selected-compile.ps1 b/build/templates/selected-compile.ps1
new file mode 100644
index 0000000..5c774cd
--- /dev/null
+++ b/build/templates/selected-compile.ps1
@@ -0,0 +1,16 @@
+{% extends "msbuild.ps1" %}
+{% block pwsh_setup %}
+{% if vcpkg_installed %}$vcpkgInstalledDir = {{ vcpkg_installed | ps_quote }}
+{% else %}$vcpkgInstalledDir = Join-Path $buildDir 'vcpkg'
+[void](New-Item -ItemType Directory -Force -Path $vcpkgInstalledDir)
+{% endif %}{% endblock %}
+{% block msbuild_args %}
+ '/p:ObserverCompileAnalysis=true'
+ '/p:ForceRebuild=true'
+ {{ ('/p:SelectedFiles=' ~ source) | ps_quote }}
+ '/p:SelectedFilesBuildPCH=false'
+ '/p:SelectedFilesBuildModules=false'
+ {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }}
+ "/p:VcpkgInstalledDir=$vcpkgInstalledDir\"
+{% block compile_args required %}{% endblock %}
+{% endblock %}
diff --git a/build/templates/source-dependencies.ps1 b/build/templates/source-dependencies.ps1
new file mode 100644
index 0000000..5f4f01c
--- /dev/null
+++ b/build/templates/source-dependencies.ps1
@@ -0,0 +1,7 @@
+{% extends "selected-compile.ps1" %}{% from "_output.ps1" import require_output %}
+{% block compile_args %}
+ "/p:ObserverSourceDependenciesPath=$outDir\dependencies.json"
+{% endblock %}
+{% block post_msbuild %}
+{{ require_output('dependencies.json', 'MSVC did not produce dependencies.json') }}
+{% endblock %}
diff --git a/build/templates/vcpkg.ps1 b/build/templates/vcpkg.ps1
new file mode 100644
index 0000000..82a606c
--- /dev/null
+++ b/build/templates/vcpkg.ps1
@@ -0,0 +1,14 @@
+{% extends "pwsh.ps1" %}{% from "_output.ps1" import require_output %}
+{% block pwsh_body %}
+Invoke-Checked {{ vcpkg | ps_quote }} @(
+ 'install'
+ "--x-install-root=$outDir"
+ "--x-buildtrees-root=$buildDir\b"
+ "--x-packages-root=$buildDir\p"
+ '--triplet'
+ {{ triplet | ps_quote }}
+ {{ ('--x-manifest-root=' ~ repository) | ps_quote }}
+ {{ ('--overlay-triplets=' ~ repository ~ '\\build\\vcpkg\\triplets') | ps_quote }}
+)
+{{ require_output(triplet ~ '\\include', 'vcpkg restore did not produce the include directory', 'Container') }}
+{% endblock %}
diff --git a/build/tests/security-mitigation-contract.Tests.ps1 b/build/tests/security-mitigation-contract.Tests.ps1
new file mode 100644
index 0000000..0eaa4f1
--- /dev/null
+++ b/build/tests/security-mitigation-contract.Tests.ps1
@@ -0,0 +1,36 @@
+#requires -Version 7.4
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+$projectPropertiesPath = Join-Path $repositoryRoot 'build\ObserverProject.props'
+[xml]$projectProperties = Get-Content -LiteralPath $projectPropertiesPath -Raw
+$namespace = [System.Xml.XmlNamespaceManager]::new($projectProperties.NameTable)
+$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003')
+
+$cetCompat = $projectProperties.SelectSingleNode(
+ '/msb:Project/msb:ItemDefinitionGroup/msb:Link/msb:CETCompat',
+ $namespace
+)
+if ($null -eq $cetCompat -or $cetCompat.InnerText -ne 'true') {
+ throw 'Release CET compatibility must remain enabled for supported targets.'
+}
+$expectedCondition = "'`$(Configuration)' == 'Release' And '`$(Platform)' != 'ARM64'"
+if ($cetCompat.Condition -ne $expectedCondition) {
+ throw "CET compatibility must exclude only ARM64; actual condition: '$($cetCompat.Condition)'."
+}
+
+$linkControlFlowGuard = $projectProperties.SelectSingleNode(
+ '/msb:Project/msb:ItemDefinitionGroup/msb:Link/msb:ControlFlowGuard',
+ $namespace
+)
+if (
+ $null -eq $linkControlFlowGuard -or
+ $linkControlFlowGuard.InnerText -ne 'Guard' -or
+ $linkControlFlowGuard.Condition -ne "'`$(Configuration)' == 'Release'"
+) {
+ throw 'Control Flow Guard must remain enabled for every Release architecture, including ARM64.'
+}
+
+Write-Host '[OK] Release CET is scoped to supported targets without weakening CFG.'
diff --git a/build/tests/test_analysis_graph.py b/build/tests/test_analysis_graph.py
new file mode 100644
index 0000000..543e842
--- /dev/null
+++ b/build/tests/test_analysis_graph.py
@@ -0,0 +1,660 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.paths import BuildPaths # noqa: E402
+import graphs.analysis as analysis # noqa: E402
+from graphs.analysis import ( # noqa: E402
+ analysis_discovery_slice,
+ analysis_slice,
+ load_dependency_manifests,
+)
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ msbuild: Path
+ pwsh: Path
+ vcpkg_root: Path
+ llvm_dir: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: dict[str, str]
+
+
+class AnalysisSliceTests(unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ project = """
+
+
+
+"""
+ files = {
+ "src/modules/renpy/pickle.cpp": '#include "pickle.h"\n',
+ "src/modules/renpy/pickle.h": "#pragma once\n",
+ "src/modules/rpgmaker/rpgmaker.cpp": "#include \n",
+ "build/projects/renpy.vcxproj": project.format(
+ source="src/modules/renpy/pickle.cpp"
+ ),
+ "build/projects/rpgmaker.vcxproj": project.format(
+ source="src/modules/rpgmaker/rpgmaker.cpp"
+ ),
+ "build/ObserverProjectConfigurations.props": "\n",
+ "build/ObserverConfiguration.props": "\n",
+ "build/ObserverProject.props": "\n",
+ "build/ObserverNativeAnalysis.ruleset": "\n",
+ ".clang-tidy": "Checks: bugprone-*\n",
+ "vcpkg.json": '{"dependencies":["zlib"]}\n',
+ "build/vcpkg/triplets/observer-x64-windows-static.cmake": (
+ "set(VCPKG_LIBRARY_LINKAGE static)\n"
+ ),
+ "build/vcpkg/triplets/observer-x86-windows-static.cmake": (
+ "set(VCPKG_LIBRARY_LINKAGE static)\n"
+ ),
+ }
+ for relative, content in files.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return root
+
+ def toolchain(self, root: Path) -> FakeToolchain:
+ tools = root / "fake tools"
+ tools.mkdir()
+ paths = {
+ "msbuild": tools / "MSBuild.exe",
+ "pwsh": tools / "pwsh.exe",
+ "vcpkg_root": tools / "vcpkg",
+ "llvm_dir": tools / "llvm",
+ }
+ paths["vcpkg_root"].mkdir()
+ (paths["vcpkg_root"] / "vcpkg.exe").touch()
+ paths["llvm_dir"].mkdir()
+ paths["msbuild"].touch()
+ paths["pwsh"].touch()
+ return FakeToolchain(
+ **paths,
+ environment=(
+ ("PATH", str(tools)),
+ ("VCPKG_ROOT", str(tools / "vcpkg-current")),
+ ),
+ identity={"msbuild": "17.14", "msvc": "14.44", "llvm": "19.1.5"},
+ )
+
+ @staticmethod
+ def manifest(source: Path, *includes: Path) -> bytes:
+ return json.dumps(
+ {
+ "Version": "1.2",
+ "Data": {
+ "Source": str(source.resolve()),
+ "ProvidedModule": "",
+ "ImportedModules": [],
+ "Includes": [str(path.resolve()) for path in includes],
+ },
+ },
+ separators=(",", ":"),
+ ).encode()
+
+ def test_project_inventory_parses_each_project_once_and_preserves_requested_order(
+ self,
+ ) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = self.repository(Path(temporary) / "repo")
+ with mock.patch.object(
+ analysis.ET, "parse", wraps=analysis.ET.parse
+ ) as parse:
+ projects = analysis.project_inventory(
+ repository, ("rpgmaker", "renpy")
+ )
+
+ self.assertEqual(parse.call_count, 2)
+ self.assertEqual(tuple(project.name for project in projects), ("rpgmaker", "renpy"))
+ self.assertEqual(
+ projects[0].inputs,
+ (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/projects/rpgmaker.vcxproj",
+ ),
+ )
+ self.assertEqual(
+ projects[0].sources,
+ (repository / "src/modules/rpgmaker/rpgmaker.cpp",),
+ )
+
+ def staged_graphs(
+ self, repository: Path, toolchain: FakeToolchain, *, jobs: int = 2,
+ architectures: tuple[str, ...] = ("x64",),
+ ):
+ discovery = analysis_discovery_slice(
+ repository, toolchain, jobs=jobs, architectures=architectures
+ )
+ paths = BuildPaths(repository)
+ manifests = {}
+ for target in discovery.targets:
+ source = repository / (
+ "src/modules/renpy/pickle.cpp"
+ if target.endswith("modules.renpy.pickle")
+ else "src/modules/rpgmaker/rpgmaker.cpp"
+ )
+ includes = (
+ (repository / "src/modules/renpy/pickle.h",)
+ if target.endswith("modules.renpy.pickle")
+ else ()
+ )
+ if "-rpgmaker-" in target:
+ architecture = target.removeprefix("discover-dependencies-").split("-", 1)[0]
+ restore = discovery.node(f"restore-vcpkg-{architecture}")
+ package = paths.cas(restore.uid).output / "include/zlib.h"
+ package.parent.mkdir(parents=True, exist_ok=True)
+ if not package.exists():
+ package.write_text("#pragma once\n", encoding="utf-8")
+ includes = (package,)
+ manifests[target] = self.manifest(source, *includes)
+ graph = analysis_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ jobs=jobs,
+ architectures=architectures,
+ )
+ return discovery, graph
+
+ def test_two_analysis_backends_are_independent_demand_targets(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery, graph = self.staged_graphs(repository, self.toolchain(root))
+
+ self.assertEqual(
+ graph.targets,
+ ("analysis-x64",),
+ )
+ merged = graph.node("merge-analysis-x64")
+ self.assertEqual(
+ tuple(
+ (result.id, result.kind, result.media_type, result.relative_path)
+ for result in merged.results
+ ),
+ ((
+ "reports/sarif/x64/analysis.sarif",
+ "sarif",
+ "application/sarif+json",
+ "analysis.sarif",
+ ),),
+ )
+ self.assertEqual(graph.node("analysis-x64").results, ())
+ self.assertTrue(all(
+ not node.results
+ for node in graph.nodes
+ if node.name.startswith(("restore-", "discover-", "analyze-", "normalize-"))
+ ))
+ self.assertEqual(graph.pools, {"misc": 2, "restore": 1, "slot": 2})
+ self.assertEqual(
+ tuple(node.name for node in graph.nodes),
+ (
+ "restore-vcpkg-x64",
+ "discover-dependencies-x64-renpy-modules.renpy.pickle",
+ "discover-dependencies-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "analyze-msvc-x64-renpy-modules.renpy.pickle",
+ "analyze-tidy-x64-renpy-modules.renpy.pickle",
+ "normalize-msvc-x64-renpy-modules.renpy.pickle",
+ "normalize-tidy-x64-renpy-modules.renpy.pickle",
+ "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "analyze-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "normalize-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "normalize-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "merge-analysis-x64",
+ "analysis-x64",
+ ),
+ )
+ restore = graph.nodes[0]
+ self.assertEqual(dict(restore.command.env)["VCPKG_ROOT"], str(root / "fake tools/vcpkg"))
+ raw = (graph.nodes[3], graph.nodes[4], graph.nodes[7], graph.nodes[8])
+ for node in raw:
+ self.assertEqual(node.pool, "slot")
+ self.assertEqual(node.command.cwd, str(repository.resolve()))
+ self.assertEqual(dict(node.command.env)["PATH"], str(root / "fake tools"))
+ script = node.command.stdin.decode("utf-8")
+ self.assertIn("'/t:ClCompile'", script)
+ self.assertIn("'/p:Configuration=Debug'", script)
+ self.assertIn("'/p:Platform=x64'", script)
+ self.assertIn("'/p:SelectedFiles=", script)
+ self.assertIn("'/p:VcpkgRoot=", script)
+ self.assertEqual(raw[0].inputs, (discovery.targets[0],))
+ self.assertEqual(raw[1].inputs, (discovery.targets[0],))
+ self.assertEqual(raw[2].inputs, (discovery.targets[1],))
+ self.assertEqual(raw[3].inputs, (discovery.targets[1],))
+ msvc = raw[0].command.stdin.decode("utf-8")
+ tidy = raw[1].command.stdin.decode("utf-8")
+ self.assertIn('"/p:ObserverAnalysisReportPath=$outDir\\renpy.sarif"', msvc)
+ self.assertIn('"/p:IntDir=$buildDir\\"', msvc)
+ self.assertIn("MSVC analysis did not produce renpy.sarif", msvc)
+ self.assertIn(")\nif (-not (Test-Path", msvc)
+ self.assertIn('"/p:IntDir=$outDir\\obj\\"', tidy)
+ self.assertIn("'/p:LLVMInstallDir=", tidy)
+ self.assertIn("clang-tidy did not produce renpy.ClangTidy.log", tidy)
+ self.assertIn(")\nif (-not (Test-Path", tidy)
+ normalize_msvc, normalize_tidy = graph.nodes[5:7]
+ rpg_normalize_msvc, rpg_normalize_tidy, merge, gate = graph.nodes[9:]
+ self.assertEqual(normalize_msvc.inputs, (raw[0].name,))
+ self.assertEqual(normalize_tidy.inputs, (raw[1].name,))
+ self.assertEqual(rpg_normalize_msvc.inputs, (raw[2].name,))
+ self.assertEqual(rpg_normalize_tidy.inputs, (raw[3].name,))
+ self.assertEqual(
+ merge.inputs,
+ (
+ normalize_msvc.name,
+ normalize_tidy.name,
+ rpg_normalize_msvc.name,
+ rpg_normalize_tidy.name,
+ ),
+ )
+ self.assertEqual(gate.inputs, (merge.name,))
+ self.assertEqual(normalize_msvc.command.argv[1:4], ("-m", "core.sarif", "normalize-msvc"))
+ self.assertEqual(normalize_tidy.command.argv[1:4], ("-m", "core.sarif", "convert-tidy"))
+ self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge"))
+ self.assertEqual(gate.command.argv[1:4], ("-m", "core.sarif", "gate"))
+ self.assertTrue(
+ all(node.pool == "misc" for node in graph.nodes[5:7] + graph.nodes[9:])
+ )
+
+ def test_tidy_configuration_invalidates_only_tidy_node(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ _before_discovery, before = self.staged_graphs(repository, toolchain)
+ (repository / ".clang-tidy").write_text(
+ "Checks: bugprone-*,performance-*\n", encoding="utf-8"
+ )
+ _after_discovery, after = self.staged_graphs(repository, toolchain)
+
+ unchanged = (
+ "restore-vcpkg-x64",
+ "analyze-msvc-x64-renpy-modules.renpy.pickle",
+ "normalize-msvc-x64-renpy-modules.renpy.pickle",
+ "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "normalize-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ )
+ changed = (
+ "analyze-tidy-x64-renpy-modules.renpy.pickle",
+ "normalize-tidy-x64-renpy-modules.renpy.pickle",
+ "analyze-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "normalize-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ "merge-analysis-x64",
+ "analysis-x64",
+ )
+ for name in unchanged:
+ self.assertEqual(before.node(name).uid, after.node(name).uid)
+ for name in changed:
+ self.assertNotEqual(before.node(name).uid, after.node(name).uid)
+
+ def test_dynamic_include_forms_are_deferred_to_msvc_dependency_discovery(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ forms = (
+ "#define HEADER \"pickle.h\"\n#include HEADER\n",
+ '#include_next "pickle.h"\n',
+ '#if __has_include("pickle.h")\n#include "pickle.h"\n#endif\n',
+ )
+ for source in forms:
+ with self.subTest(source=source):
+ (repository / "src/modules/renpy/pickle.cpp").write_text(
+ source, encoding="utf-8"
+ )
+ graph = analysis_discovery_slice(repository, toolchain)
+
+ discovery = graph.node(
+ "discover-dependencies-x64-renpy-modules.renpy.pickle"
+ )
+ script = discovery.command.stdin.decode("utf-8")
+ self.assertIn("/p:ObserverSourceDependenciesPath=", script)
+ self.assertIn("dependencies.json", script)
+ self.assertNotIn("ObserverRunCodeAnalysis", script)
+
+ def test_architecture_selects_distinct_platform_triplet_and_target(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery, graph = self.staged_graphs(
+ repository, self.toolchain(root), architectures=("x86",)
+ )
+
+ self.assertEqual(graph.targets, ("analysis-x86",))
+ self.assertEqual(graph.nodes[0].name, "restore-vcpkg-x86")
+ self.assertIn("observer-x86-windows-static", graph.nodes[0].command.stdin.decode())
+ raw_script = graph.nodes[3].command.stdin.decode()
+ self.assertIn("'/p:Platform=Win32'", raw_script)
+ self.assertIn("analyze-msvc-x86-renpy", graph.nodes[3].name)
+
+ def test_phase_two_signs_only_compiler_reported_project_and_package_files(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ unrelated = repository / "src/unused.h"
+ unrelated.write_text("one\n", encoding="utf-8")
+ toolchain = self.toolchain(root)
+ discovery, baseline = self.staged_graphs(repository, toolchain)
+ unrelated.write_text("two\n", encoding="utf-8")
+ _discovery, unrelated_changed = self.staged_graphs(repository, toolchain)
+ (repository / "src/modules/renpy/pickle.h").write_text(
+ "#pragma once\n// changed\n", encoding="utf-8"
+ )
+ _discovery, header_changed = self.staged_graphs(repository, toolchain)
+ restore = discovery.node("restore-vcpkg-x64")
+ package = BuildPaths(repository).cas(restore.uid).output
+ (package / "include/zlib.h").write_text("// changed\n", encoding="utf-8")
+ _discovery, package_changed = self.staged_graphs(repository, toolchain)
+
+ names = {
+ "renpy": "analyze-msvc-x64-renpy-modules.renpy.pickle",
+ "rpgmaker": "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker",
+ }
+ for name in names.values():
+ self.assertEqual(baseline.node(name).uid, unrelated_changed.node(name).uid)
+ self.assertNotEqual(
+ unrelated_changed.node(names["renpy"]).uid,
+ header_changed.node(names["renpy"]).uid,
+ )
+ self.assertEqual(
+ unrelated_changed.node(names["rpgmaker"]).uid,
+ header_changed.node(names["rpgmaker"]).uid,
+ )
+ self.assertNotEqual(
+ header_changed.node(names["rpgmaker"]).uid,
+ package_changed.node(names["rpgmaker"]).uid,
+ )
+
+ def test_cross_directory_first_party_dependency_must_be_declared(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ shared = repository / "src/shared/topology.h"
+ shared.parent.mkdir()
+ shared.write_text("#pragma once\n", encoding="utf-8")
+ toolchain = self.toolchain(root)
+ discovery = analysis_discovery_slice(repository, toolchain)
+ renpy = "discover-dependencies-x64-renpy-modules.renpy.pickle"
+ rpgmaker = "discover-dependencies-x64-rpgmaker-modules.rpgmaker.rpgmaker"
+ manifests = {
+ renpy: self.manifest(
+ repository / "src/modules/renpy/pickle.cpp", shared
+ ),
+ rpgmaker: self.manifest(
+ repository / "src/modules/rpgmaker/rpgmaker.cpp"
+ ),
+ }
+
+ with self.assertRaisesRegex(
+ ValueError, "first-party dependency is not covered.*src/shared/topology.h"
+ ):
+ analysis_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ )
+
+ project = repository / "build/projects/renpy.vcxproj"
+ project.write_text(
+ project.read_text(encoding="utf-8").replace(
+ "",
+ " \n",
+ ),
+ encoding="utf-8",
+ )
+ declared_discovery = analysis_discovery_slice(repository, toolchain)
+ declared = analysis_slice(
+ repository,
+ toolchain,
+ discovery=declared_discovery,
+ manifests=manifests,
+ )
+
+ self.assertIn("analysis-x64", declared.targets)
+
+ def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery = analysis_discovery_slice(repository, self.toolchain(root))
+ paths = BuildPaths(repository)
+ expected = {}
+ for target in discovery.targets:
+ node = discovery.node(target)
+ cas = paths.cas(node.uid)
+ cas.output.mkdir(parents=True)
+ source = repository / (
+ "src/modules/renpy/pickle.cpp"
+ if target.endswith("modules.renpy.pickle")
+ else "src/modules/rpgmaker/rpgmaker.cpp"
+ )
+ content = self.manifest(source)
+ (cas.output / "dependencies.json").write_bytes(content)
+ cas.touch.touch()
+ expected[target] = content
+
+ loaded = load_dependency_manifests(repository, discovery)
+
+ self.assertEqual(loaded, expected)
+
+ def test_incomplete_dependency_discovery_is_not_loadable(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery = analysis_discovery_slice(repository, self.toolchain(root))
+
+ with self.assertRaisesRegex(FileNotFoundError, "dependency discovery is incomplete"):
+ load_dependency_manifests(repository, discovery)
+
+ def test_cached_manifests_do_not_change_discovery_or_downstream_uids(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ cold = analysis_discovery_slice(repository, toolchain)
+ manifests = {}
+ for target in cold.targets:
+ source = repository / (
+ "src/modules/renpy/pickle.cpp"
+ if target.endswith("modules.renpy.pickle")
+ else "src/modules/rpgmaker/rpgmaker.cpp"
+ )
+ content = self.manifest(source)
+ manifests[target] = content
+ cas = BuildPaths(repository).cas(cold.node(target).uid)
+ cas.output.mkdir(parents=True)
+ (cas.output / "dependencies.json").write_bytes(content)
+ cas.touch.touch()
+
+ cold_downstream = analysis_slice(
+ repository,
+ toolchain,
+ discovery=cold,
+ manifests=manifests,
+ )
+ warm = analysis_discovery_slice(repository, toolchain)
+ warm_downstream = analysis_slice(
+ repository,
+ toolchain,
+ discovery=warm,
+ manifests=manifests,
+ )
+
+ self.assertEqual(
+ {node.name: node.uid for node in cold.nodes},
+ {node.name: node.uid for node in warm.nodes},
+ )
+ self.assertEqual(
+ {node.name: node.uid for node in cold_downstream.nodes},
+ {node.name: node.uid for node in warm_downstream.nodes},
+ )
+
+ def test_partial_cached_manifests_do_not_mix_discovery_generations(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ cold = analysis_discovery_slice(repository, toolchain)
+ target = cold.targets[0]
+ manifests = {
+ target: self.manifest(
+ repository / "src/modules/renpy/pickle.cpp",
+ repository / "src/modules/renpy/pickle.h",
+ ),
+ cold.targets[1]: self.manifest(
+ repository / "src/modules/rpgmaker/rpgmaker.cpp"
+ ),
+ }
+ cold_downstream = analysis_slice(
+ repository,
+ toolchain,
+ discovery=cold,
+ manifests=manifests,
+ )
+ cas = BuildPaths(repository).cas(cold.node(target).uid)
+ cas.output.mkdir(parents=True)
+ (cas.output / "dependencies.json").write_bytes(manifests[target])
+ cas.touch.touch()
+
+ partial = analysis_discovery_slice(repository, toolchain)
+ partial_downstream = analysis_slice(
+ repository,
+ toolchain,
+ discovery=partial,
+ manifests=manifests,
+ )
+
+ self.assertEqual(
+ {node.name: node.uid for node in cold.nodes},
+ {node.name: node.uid for node in partial.nodes},
+ )
+ self.assertEqual(
+ {node.name: node.uid for node in cold_downstream.nodes},
+ {node.name: node.uid for node in partial_downstream.nodes},
+ )
+
+ def test_header_content_invalidates_discovery_without_cached_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ before = analysis_discovery_slice(repository, toolchain)
+ (repository / "src/modules/renpy/pickle.h").write_text(
+ "#pragma once\n// dependency topology may have changed\n",
+ encoding="utf-8",
+ )
+ after = analysis_discovery_slice(repository, toolchain)
+
+ target = "discover-dependencies-x64-renpy-modules.renpy.pickle"
+ self.assertNotEqual(before.node(target).uid, after.node(target).uid)
+
+ def test_cached_manifest_does_not_replace_stable_header_invalidation_or_scan_cas(
+ self,
+ ) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ initial = analysis_discovery_slice(repository, toolchain)
+ renpy = initial.node(initial.targets[0])
+ cas = BuildPaths(repository).cas(renpy.uid)
+ cas.output.mkdir(parents=True)
+ (cas.output / "dependencies.json").write_bytes(
+ self.manifest(
+ repository / "src/modules/renpy/pickle.cpp",
+ repository / "src/modules/renpy/pickle.h",
+ )
+ )
+ cas.touch.touch()
+ other = BuildPaths(repository).cas("1" * 32)
+ other.output.mkdir(parents=True)
+ (other.output / "dependencies.json").write_bytes(
+ (cas.output / "dependencies.json").read_bytes()
+ )
+ other.touch.touch()
+ BuildPaths(repository).cas("2" * 32).entry.mkdir(parents=True)
+
+ original_glob = Path.glob
+
+ def reject_cas_scan(path: Path, pattern: str, **kwargs: object):
+ if path == BuildPaths(repository).cas_root:
+ raise AssertionError("CAS must not be scanned")
+ return original_glob(path, pattern, **kwargs)
+
+ with mock.patch.object(
+ Path, "glob", autospec=True, side_effect=reject_cas_scan
+ ):
+ before = analysis_discovery_slice(repository, toolchain)
+ (repository / "src/modules/renpy/pickle.h").write_text(
+ "#pragma once\n// topology may have changed\n", encoding="utf-8"
+ )
+ with mock.patch.object(
+ Path, "glob", autospec=True, side_effect=reject_cas_scan
+ ):
+ after = analysis_discovery_slice(repository, toolchain)
+
+ self.assertNotEqual(before.node(before.targets[0]).uid, after.node(after.targets[0]).uid)
+ self.assertEqual(before.node(before.targets[1]).uid, after.node(after.targets[1]).uid)
+
+ def test_source_namespace_addition_invalidates_discovery_for_has_include(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ before = analysis_discovery_slice(repository, toolchain)
+ (repository / "src/optional.h").write_text("#pragma once\n", encoding="utf-8")
+ after = analysis_discovery_slice(repository, toolchain)
+
+ for name in before.targets:
+ self.assertNotEqual(before.node(name).uid, after.node(name).uid)
+
+ def test_malformed_cas_candidate_is_ignored_during_discovery_seed_lookup(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ name = "discover-dependencies-x64-renpy-modules.renpy.pickle"
+ (repository / "out/cas" / f"{'z' * 32}-{name}").mkdir(parents=True)
+ (repository / "out/cas" / f"{'3' * 32}-discover-dependencies-x64-unused-unit").mkdir()
+
+ graph = analysis_discovery_slice(repository, self.toolchain(root))
+
+ self.assertIn(name, graph.targets)
+
+ def test_x64_only_leak_probe_is_not_scheduled_for_cross_architectures(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ (repository / "build/projects/leak-probe.vcxproj").write_bytes(
+ (repository / "build/projects/renpy.vcxproj").read_bytes()
+ )
+ toolchain = self.toolchain(root)
+ _x86_discovery, x86 = self.staged_graphs(
+ repository, toolchain, architectures=("x86",)
+ )
+ _x64_discovery, x64 = self.staged_graphs(
+ repository, toolchain, architectures=("x64",)
+ )
+
+ self.assertFalse(any("-leak-probe-" in node.name for node in x86.nodes))
+ self.assertTrue(any("-leak-probe-" in node.name for node in x64.nodes))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_audit_graph.py b/build/tests/test_audit_graph.py
new file mode 100644
index 0000000..f54207c
--- /dev/null
+++ b/build/tests/test_audit_graph.py
@@ -0,0 +1,318 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import runpy
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).parents[1]))
+
+from core.binary_audit import AuditError, main as binary_audit_main # noqa: E402
+from core.graph import Command, Graph, Node # noqa: E402
+from core.paths import BuildPaths # noqa: E402
+from graphs.audit import audit_graph # noqa: E402
+
+
+def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "build",
+ Command((str(Path("C:/sdk/build.exe")),)),
+ inputs,
+ )
+
+
+class AuditGraphTests(unittest.TestCase):
+ def fixture(self, root: Path) -> tuple[Path, Graph, Path, Path]:
+ repository = root / "repo"
+ repository.mkdir()
+ restore = node("restore-release")
+ builds = tuple(
+ node(f"build-{module}-{architecture}-release", inputs=(restore.name,))
+ for architecture in ("x64", "x86")
+ for module in ("renpy", "rpgmaker", "zanzarah")
+ )
+ upstream = Graph(
+ (restore, *builds),
+ tuple(item.name for item in builds),
+ {"build": 2},
+ )
+ tools = root / "tools"
+ tools.mkdir()
+ dumpbin, binskim = tools / "dumpbin.exe", tools / "BinSkim.exe"
+ dumpbin.touch()
+ binskim.touch()
+ return repository, upstream, dumpbin, binskim
+
+ def build_graph(
+ self,
+ root: Path,
+ *,
+ dumpbin_version: str = "14.44",
+ binskim_version: str = "4.4",
+ ) -> Graph:
+ repository, upstream, dumpbin, binskim = self.fixture(root)
+ return audit_graph(
+ repository,
+ upstream,
+ architectures=("x64", "x86"),
+ dumpbin=dumpbin,
+ dumpbin_identity={"path": str(dumpbin), "version": dumpbin_version},
+ binskim=binskim,
+ binskim_identity={"path": str(binskim), "version": binskim_version},
+ jobs=3,
+ )
+
+ def test_selects_canonical_release_producers_without_artifact_dtos(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, upstream, dumpbin, binskim = self.fixture(Path(temporary))
+ graph = audit_graph(
+ repository,
+ upstream,
+ architectures=("x64", "x86"),
+ dumpbin=dumpbin,
+ dumpbin_identity={"version": "14.44"},
+ binskim=binskim,
+ binskim_identity={"version": "4.4"},
+ )
+
+ self.assertEqual(
+ graph.node("audit-binskim-run-x64-renpy").inputs,
+ ("build-renpy-x64-release",),
+ )
+ self.assertEqual(
+ graph.node("audit-binskim-run-x86-rpgmaker").inputs,
+ ("build-rpgmaker-x86-release",),
+ )
+
+ def test_each_artifact_composes_six_fine_grained_nodes_over_its_full_upstream(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ graph = self.build_graph(root)
+
+ self.assertEqual(graph.pools, {"build": 2, "slot": 3})
+ self.assertEqual(
+ graph.targets,
+ tuple(
+ f"audit-{kind}-{architecture}-{module}"
+ for architecture in ("x64", "x86")
+ for module in ("renpy", "rpgmaker", "zanzarah")
+ for kind in ("pe", "binskim")
+ ),
+ )
+ self.assertEqual(43, len(graph.nodes))
+ self.assertEqual(graph.node("build-renpy-x64-release").inputs, ("restore-release",))
+
+ for architecture in ("x64", "x86"):
+ for module in ("renpy", "rpgmaker", "zanzarah"):
+ producer = f"build-{module}-{architecture}-release"
+ dump_nodes = tuple(
+ graph.node(f"audit-dumpbin-{mode}-{architecture}-{module}")
+ for mode in ("headers", "dependents", "exports")
+ )
+ pe_gate = graph.node(f"audit-pe-{architecture}-{module}")
+ binskim_run = graph.node(f"audit-binskim-run-{architecture}-{module}")
+ binskim_gate = graph.node(f"audit-binskim-{architecture}-{module}")
+ self.assertTrue(all(current.inputs == (producer,) for current in dump_nodes))
+ self.assertEqual(pe_gate.inputs, tuple(current.name for current in dump_nodes))
+ self.assertEqual(binskim_run.inputs, (producer,))
+ self.assertEqual(binskim_gate.inputs, (binskim_run.name,))
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in binskim_run.results),
+ ((
+ f"reports/sarif/{architecture}/binskim-{module}.sarif",
+ "sarif", "application/sarif+json", "binskim.sarif",
+ ),),
+ )
+ self.assertTrue(all(not current.results for current in (*dump_nodes, pe_gate, binskim_gate)))
+ self.assertTrue(all(current.pool == "slot" for current in dump_nodes))
+ self.assertEqual((pe_gate.pool, binskim_run.pool, binskim_gate.pool), ("slot", "slot", "slot"))
+
+ def test_dumpbin_and_python_gates_receive_exact_binary_logs_and_report_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, dumpbin, binskim = self.fixture(root)
+ graph = audit_graph(
+ repository,
+ upstream,
+ architectures=("x64",),
+ dumpbin=dumpbin,
+ dumpbin_identity={"version": "14.44"},
+ binskim=binskim,
+ binskim_identity={"version": "4.4"},
+ )
+ paths = BuildPaths(repository)
+ binary = paths.cas(upstream.node("build-renpy-x64-release").uid).output / "renpy.so"
+
+ dump_nodes = tuple(
+ graph.node(f"audit-dumpbin-{mode}-x64-renpy")
+ for mode in ("headers", "dependents", "exports")
+ )
+ for mode, current in zip(("headers", "dependents", "exports"), dump_nodes, strict=True):
+ self.assertEqual(current.command.argv, (str(dumpbin), f"/{mode}", str(binary)))
+ pe_gate = graph.node("audit-pe-x64-renpy")
+ self.assertEqual(pe_gate.command.argv[1:5], ("-m", "core.binary_audit", "pe", "x64"))
+ self.assertEqual(
+ pe_gate.command.argv[5:],
+ tuple(str(paths.cas(current.uid).log) for current in dump_nodes),
+ )
+ binskim_run = graph.node("audit-binskim-run-x64-renpy")
+ self.assertEqual(
+ binskim_run.command.argv[1:],
+ ("-m", "core.binary_audit", "run-binskim", str(binskim), str(binary)),
+ )
+ binskim_gate = graph.node("audit-binskim-x64-renpy")
+ report = paths.cas(binskim_run.uid).output / "binskim.sarif"
+ self.assertEqual(binskim_gate.command.argv[1:], ("-m", "core.binary_audit", "binskim", str(report)))
+
+ def test_tool_identity_invalidates_only_its_run_and_semantic_consumers(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, dumpbin, binskim = self.fixture(root)
+
+ def graph(dumpbin_version: str, binskim_version: str) -> Graph:
+ return audit_graph(
+ repository,
+ upstream,
+ architectures=("x64", "x86"),
+ dumpbin=dumpbin,
+ dumpbin_identity={"version": dumpbin_version},
+ binskim=binskim,
+ binskim_identity={"version": binskim_version},
+ )
+
+ before = graph("14.44", "4.4")
+ dump_changed = graph("14.45", "4.4")
+ binskim_changed = graph("14.44", "4.5")
+
+ for current in before.nodes:
+ dump_partition = "dumpbin" in current.name or "audit-pe-" in current.name
+ binskim_partition = "binskim" in current.name
+ self.assertEqual(
+ dump_partition,
+ current.uid != dump_changed.node(current.name).uid,
+ current.name,
+ )
+ self.assertEqual(
+ binskim_partition,
+ current.uid != binskim_changed.node(current.name).uid,
+ current.name,
+ )
+
+ def test_graph_rejects_invalid_axes_tools_pools_and_capacities(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, dumpbin, binskim = self.fixture(root)
+
+ def invoke(
+ *,
+ source: Graph = upstream,
+ architectures: tuple[str, ...] = ("x64", "x86"),
+ dumpbin_path: Path = dumpbin,
+ jobs: object = 2,
+ ) -> Graph:
+ return audit_graph(
+ repository,
+ source,
+ architectures=architectures,
+ dumpbin=dumpbin_path,
+ dumpbin_identity={"version": "14.44"},
+ binskim=binskim,
+ binskim_identity={"version": "4.4"},
+ jobs=jobs, # type: ignore[arg-type]
+ )
+
+ for jobs in (True, "two", 0):
+ with self.subTest(jobs=jobs), self.assertRaisesRegex(
+ ValueError, "capacities"
+ ):
+ invoke(jobs=jobs)
+
+ with self.assertRaisesRegex(FileNotFoundError, "not a file"):
+ invoke(dumpbin_path=dumpbin.parent)
+ for invalid in ((), ("x64", "x64"), ("riscv64",)):
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"):
+ invoke(architectures=invalid)
+
+ incomplete = Graph(
+ tuple(item for item in upstream.nodes if item.name != "build-renpy-x64-release"),
+ tuple(name for name in upstream.targets if name != "build-renpy-x64-release"),
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "unknown node"):
+ invoke(source=incomplete)
+
+ matching_pools = Graph(
+ upstream.nodes,
+ upstream.targets,
+ {"build": 2, "slot": 2},
+ )
+ self.assertEqual(invoke(source=matching_pools).pools["slot"], 2)
+ conflicting = Graph(upstream.nodes, upstream.targets, {"build": 2, "slot": 1})
+ with self.assertRaisesRegex(ValueError, "conflicting pool"):
+ invoke(source=conflicting)
+
+ def test_binary_audit_cli_dispatches_pe_binskim_and_literal_binskim_run(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ headers, dependents, exports = root / "headers.txt", root / "dependents.txt", root / "exports.txt"
+ headers.write_text(" 8664 machine (x64)\n", encoding="utf-8")
+ dependents.write_text(" KERNEL32.dll\n", encoding="utf-8")
+ exports.write_text(
+ " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n", encoding="utf-8"
+ )
+ self.assertEqual(
+ 0,
+ binary_audit_main(("pe", "x64", str(headers), str(dependents), str(exports))),
+ )
+ report = root / "approved.sarif"
+ report.write_text(json.dumps({"runs": []}), encoding="utf-8")
+ self.assertEqual(0, binary_audit_main(("binskim", str(report))))
+
+ output = root / "out"
+ output.mkdir()
+ tool, binary = root / "BinSkim.exe", root / "renpy.so"
+ tool.touch()
+ binary.touch()
+
+ def complete(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ Path(argv[argv.index("--output") + 1]).write_text('{"runs":[]}', encoding="utf-8")
+ return subprocess.CompletedProcess(argv, 0)
+
+ with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False):
+ with mock.patch("core.binary_audit.subprocess.run", side_effect=complete) as invoked:
+ self.assertEqual(0, binary_audit_main(("run-binskim", str(tool), str(binary))))
+ argv = invoked.call_args.args[0]
+ self.assertEqual(argv[:3], [str(tool), "analyze", str(binary)])
+ self.assertIn("--disable-telemetry", argv)
+ self.assertEqual(output / "binskim.sarif", Path(argv[argv.index("--output") + 1]))
+
+ with mock.patch.dict(os.environ, {}, clear=True):
+ with self.assertRaisesRegex(AuditError, "OBSERVER_OUT_DIR"):
+ binary_audit_main(("run-binskim", str(tool), str(binary)))
+ empty_output = root / "empty-output"
+ empty_output.mkdir()
+ with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(empty_output)}, clear=False):
+ with mock.patch("core.binary_audit.subprocess.run"):
+ with self.assertRaisesRegex(AuditError, "did not produce"):
+ binary_audit_main(("run-binskim", str(tool), str(binary)))
+
+ with (
+ mock.patch.object(sys, "argv", ["binary_audit.py", "binskim", str(report)]),
+ self.assertWarnsRegex(RuntimeWarning, "core.binary_audit"),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("core.binary_audit", run_name="__main__")
+ self.assertEqual(0, raised.exception.code)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_binary_audit.py b/build/tests/test_binary_audit.py
new file mode 100644
index 0000000..bb48116
--- /dev/null
+++ b/build/tests/test_binary_audit.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import sys
+import unittest
+from pathlib import Path
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.binary_audit import AuditError, require_clean_binskim, require_release_pe # noqa: E402
+
+
+class BinaryAuditTests(unittest.TestCase):
+ def test_release_pe_requires_machine_static_dependencies_and_exact_exports(self) -> None:
+ require_release_pe(
+ "x64",
+ " 8664 machine (x64)\n",
+ " KERNEL32.dll\n api-ms-win-core-file-l1-1-0.dll\n",
+ " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n",
+ )
+
+ failures = (
+ ("x86", " 8664 machine (x64)\n", " KERNEL32.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 UnloadSubModule\n", "machine"),
+ ("x64", " 8664 machine (x64)\n", " VCRUNTIME140.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 UnloadSubModule\n", "dependencies"),
+ ("x64", " 8664 machine (x64)\n", " KERNEL32.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 Surprise\n", "exports"),
+ )
+ for architecture, headers, dependents, exports, message in failures:
+ with self.subTest(message=message), self.assertRaisesRegex(AuditError, message):
+ require_release_pe(architecture, headers, dependents, exports)
+
+ def test_binskim_allows_only_documented_warning(self) -> None:
+ approved = {
+ "runs": [{
+ "tool": {"driver": {"rules": [
+ {"id": "BA2027", "defaultConfiguration": {"level": "warning"}}
+ ]}},
+ "results": [{"ruleId": "BA2027"}],
+ }]
+ }
+ require_clean_binskim(approved)
+
+ for result in (
+ {"ruleId": "BA2001", "level": "warning"},
+ {"ruleId": "BA2027", "level": "error"},
+ ):
+ document = {
+ "runs": [{
+ "tool": {"driver": {"rules": []}},
+ "results": [result],
+ }]
+ }
+ with self.subTest(result=result), self.assertRaisesRegex(AuditError, result["ruleId"]):
+ require_clean_binskim(document)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_clang_dependencies.py b/build/tests/test_clang_dependencies.py
new file mode 100644
index 0000000..3536f18
--- /dev/null
+++ b/build/tests/test_clang_dependencies.py
@@ -0,0 +1,303 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import runpy
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.clang_dependencies import ( # noqa: E402
+ ClangDependencyError,
+ dependency_manifest,
+ load_compile_command,
+ main,
+ scan_dependencies,
+)
+
+
+class ClangDependencyTests(unittest.TestCase):
+ def fixture(self, root: Path) -> tuple[Path, Path, Path, Path]:
+ source = root / "source file.cpp"
+ compiler = root / "clang-cl.exe"
+ scanner = root / "clang-scan-deps.exe"
+ command = root / "compile-command.json"
+ for path in (source, compiler, scanner):
+ path.touch()
+ command.write_text(
+ json.dumps(
+ {
+ "directory": str(root),
+ "file": str(source),
+ "output": "source.obj",
+ "arguments": [
+ str(compiler),
+ "-xc++",
+ str(source),
+ "-o",
+ "source.obj",
+ "/clang:-MJcapture.json",
+ ],
+ }
+ )
+ + ",\n",
+ encoding="utf-8",
+ )
+ return source, compiler, scanner, command
+
+ @staticmethod
+ def scan_document(source: Path, *includes: Path) -> dict[str, object]:
+ return {
+ "modules": [],
+ "translation-units": [
+ {
+ "commands": [
+ {
+ "input-file": str(source),
+ "file-deps": [str(source), *(str(path) for path in includes)],
+ }
+ ]
+ }
+ ],
+ }
+
+ def test_load_command_validates_exact_compiler_source_and_removes_only_capture_flag(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source, compiler, _scanner, command = self.fixture(root)
+ other_source = root / "other.cpp"
+ other_compiler = root / "other-clang.exe"
+ other_source.touch()
+ other_compiler.touch()
+
+ loaded = load_compile_command(command, source, compiler)
+
+ self.assertEqual(loaded["directory"], str(root.resolve()))
+ self.assertEqual(loaded["file"], str(source.resolve()))
+ self.assertEqual(loaded["arguments"][0], str(compiler.resolve()))
+ self.assertNotIn("/clang:-MJcapture.json", loaded["arguments"])
+ self.assertIn("source.obj", loaded["arguments"])
+
+ for field, value, message in (
+ ("directory", str(root / "missing"), "directory"),
+ ("directory", str(source), "directory"),
+ ("file", str(root / "missing.cpp"), "source"),
+ ("file", str(other_source), "source"),
+ ("arguments", [str(root / "missing-clang.exe")], "compiler"),
+ ("arguments", [str(other_compiler)], "compiler"),
+ ("arguments", "not-an-array", "arguments"),
+ ("arguments", [], "arguments"),
+ ("arguments", [str(compiler), 7], "arguments"),
+ ):
+ document = json.loads(command.read_text(encoding="utf-8").rstrip("\n,"))
+ document[field] = value
+ command.write_text(json.dumps(document) + ",\n", encoding="utf-8")
+ with self.subTest(field=field), self.assertRaisesRegex(
+ ClangDependencyError, message
+ ):
+ load_compile_command(command, source, compiler)
+ self.fixture(root)
+
+ command.write_text("not json,\n", encoding="utf-8")
+ with self.assertRaisesRegex(ClangDependencyError, "JSON"):
+ load_compile_command(command, source, compiler)
+ command.write_text("[]", encoding="utf-8")
+ with self.assertRaisesRegex(ClangDependencyError, "JSON object"):
+ load_compile_command(command, source, compiler)
+ self.fixture(root)
+ command.write_text(
+ command.read_text(encoding="utf-8").rstrip("\n,"), encoding="utf-8"
+ )
+ self.assertEqual(load_compile_command(command, source, compiler)["file"], str(source))
+
+ def test_scan_output_becomes_deterministic_absolute_deduplicated_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source, first, second = root / "source.cpp", root / "first.h", root / "second.h"
+ for path in (source, first, second):
+ path.touch()
+
+ manifest = dependency_manifest(
+ source,
+ self.scan_document(source, second, first, second),
+ )
+
+ self.assertEqual(
+ manifest,
+ {
+ "Data": {
+ "Source": str(source.resolve()),
+ "Includes": [str(first.resolve()), str(second.resolve())],
+ }
+ },
+ )
+
+ def test_scan_output_rejects_missing_source_and_invalid_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source = root / "source.cpp"
+ source.touch()
+ missing = root / "missing.h"
+ other = root / "other.cpp"
+ other.touch()
+ directory = root / "directory"
+ directory.mkdir()
+ invalid_documents = (
+ "bad",
+ {},
+ {"translation-units": "bad"},
+ {"translation-units": []},
+ {"translation-units": [{}]},
+ {"translation-units": [{"commands": []}]},
+ {"translation-units": [{"commands": [{}]}]},
+ {"translation-units": [{"commands": [{"file-deps": "bad"}]}]},
+ {"translation-units": [{"commands": [{"file-deps": [str(source), 7]}]}]},
+ {"translation-units": [{"commands": [{"file-deps": ["relative.h"]}]}]},
+ self.scan_document(source, directory),
+ self.scan_document(source, missing),
+ self.scan_document(other),
+ )
+ for document in invalid_documents:
+ with self.subTest(document=document), self.assertRaisesRegex(
+ ClangDependencyError, "scan output"
+ ):
+ dependency_manifest(source, document)
+ with self.assertRaisesRegex(ClangDependencyError, "source"):
+ dependency_manifest(root / "missing.cpp", self.scan_document(source))
+ with self.assertRaisesRegex(ClangDependencyError, "source"):
+ dependency_manifest(root, self.scan_document(source))
+
+ def test_scan_runs_exact_tool_with_captured_compilation_database(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source, compiler, scanner, command = self.fixture(root)
+ header = root / "header.h"
+ header.touch()
+ build = root / "build"
+ build.mkdir()
+ captured: dict[str, object] = {}
+
+ def run(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]:
+ database = Path(next(item.split("=", 1)[1] for item in argv if item.startswith("-compilation-database=")))
+ captured["argv"] = argv
+ captured["options"] = options
+ captured["database"] = json.loads(database.read_text(encoding="utf-8"))
+ return subprocess.CompletedProcess(
+ argv, 0, json.dumps(self.scan_document(source, header)), ""
+ )
+
+ with mock.patch("core.clang_dependencies.subprocess.run", side_effect=run):
+ manifest = scan_dependencies(source, command, scanner, compiler, build)
+
+ self.assertEqual(manifest["Data"]["Includes"], [str(header.resolve())])
+ self.assertEqual(captured["argv"][0], str(scanner.resolve()))
+ self.assertIn("-format=experimental-full", captured["argv"])
+ self.assertEqual(captured["options"]["cwd"], str(root.resolve()))
+ self.assertEqual(len(captured["database"]), 1)
+ self.assertTrue(Path(captured["argv"][2].split("=", 1)[1]).is_relative_to(build))
+
+ def test_scan_failure_and_invalid_json_fail_closed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source, compiler, scanner, command = self.fixture(root)
+ build = root / "build"
+ build.mkdir()
+ cases = (
+ (subprocess.CompletedProcess([], 2, "", "bad flags"), "failed"),
+ (subprocess.CompletedProcess([], 2, "", ""), "no diagnostic"),
+ (subprocess.CompletedProcess([], 0, "not json", ""), "JSON"),
+ )
+ for result, message in cases:
+ with (
+ self.subTest(message=message),
+ mock.patch("core.clang_dependencies.subprocess.run", return_value=result),
+ self.assertRaisesRegex(ClangDependencyError, message),
+ ):
+ scan_dependencies(source, command, scanner, compiler, build)
+ with (
+ mock.patch("core.clang_dependencies.subprocess.run", side_effect=OSError("boom")),
+ self.assertRaisesRegex(ClangDependencyError, "launch"),
+ ):
+ scan_dependencies(source, command, scanner, compiler, build)
+ for invalid_build in (root / "missing", source):
+ with self.assertRaisesRegex(ClangDependencyError, "build directory"):
+ scan_dependencies(
+ source, command, scanner, compiler, invalid_build
+ )
+
+ def test_cli_writes_canonical_json_only_below_observer_output(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source, compiler, scanner, command = self.fixture(root)
+ output = root / "out"
+ output.mkdir()
+ build = root / "build"
+ build.mkdir()
+ expected = {"Data": {"Source": str(source.resolve()), "Includes": []}}
+ arguments = ("scan", str(source), str(command), str(scanner), str(compiler))
+ with (
+ mock.patch("core.clang_dependencies.scan_dependencies", return_value=expected),
+ mock.patch.dict(
+ os.environ,
+ {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(build)},
+ ),
+ ):
+ self.assertEqual(main(arguments), 0)
+ self.assertEqual(
+ (output / "dependencies.json").read_text(encoding="utf-8"),
+ json.dumps(expected, sort_keys=True, separators=(",", ":")) + "\n",
+ )
+
+ for environment, message in (
+ ({}, "OBSERVER_OUT_DIR"),
+ ({"OBSERVER_OUT_DIR": str(source)}, "OBSERVER_OUT_DIR"),
+ ({"OBSERVER_OUT_DIR": str(output)}, "OBSERVER_BUILD_DIR"),
+ (
+ {
+ "OBSERVER_OUT_DIR": str(output),
+ "OBSERVER_BUILD_DIR": str(source),
+ },
+ "OBSERVER_BUILD_DIR",
+ ),
+ (
+ {
+ "OBSERVER_OUT_DIR": str(output),
+ "OBSERVER_BUILD_DIR": str(root / "missing"),
+ },
+ "OBSERVER_BUILD_DIR",
+ ),
+ ):
+ with (
+ self.subTest(environment=environment),
+ mock.patch.dict(os.environ, environment, clear=True),
+ self.assertRaisesRegex(ClangDependencyError, message),
+ ):
+ main(arguments)
+ with (
+ mock.patch.object(sys, "argv", ["clang_dependencies.py", *arguments]),
+ mock.patch.dict(
+ os.environ,
+ {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(build)},
+ ),
+ mock.patch(
+ "subprocess.run",
+ return_value=subprocess.CompletedProcess(
+ [], 0, json.dumps(self.scan_document(source)), ""
+ ),
+ ),
+ self.assertWarnsRegex(RuntimeWarning, "core.clang_dependencies"),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("core.clang_dependencies", run_name="__main__")
+ self.assertEqual(raised.exception.code, 0)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_clean.py b/build/tests/test_clean.py
new file mode 100644
index 0000000..f7fd8b4
--- /dev/null
+++ b/build/tests/test_clean.py
@@ -0,0 +1,460 @@
+from __future__ import annotations
+
+from contextlib import redirect_stdout
+from io import StringIO
+import os
+from pathlib import Path
+import runpy
+import shutil
+import stat
+import sys
+import tempfile
+from types import SimpleNamespace
+import unittest
+from unittest import mock
+
+from filelock import FileLock, Timeout
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+import core.clean as core_clean # noqa: E402
+from core.clean import CleanError, clean, main # noqa: E402
+from core.paths import BuildPaths, PathSafetyError # noqa: E402
+
+
+UID = "0123456789abcdef0123456789abcdef"
+LIVE_UID = "11111111111111111111111111111111"
+ACTIVE_UID = "22222222222222222222222222222222"
+
+
+class CleanTests(unittest.TestCase):
+ def repository(self, root: Path) -> tuple[Path, BuildPaths]:
+ repository = root / "repo"
+ repository.mkdir(parents=True)
+ return repository, BuildPaths(repository)
+
+ @staticmethod
+ def complete_cas(paths: BuildPaths, uid: str) -> Path:
+ entry = paths.cas(uid)
+ entry.output.mkdir(parents=True)
+ entry.log.touch()
+ entry.touch.touch()
+ return entry.entry
+
+ def test_cas_sweep_uses_explicit_liveness_and_removes_incomplete_entries(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ live = self.complete_cas(paths, LIVE_UID)
+ inactive = self.complete_cas(paths, UID)
+ os.utime(paths.cas(LIVE_UID).touch, ns=(1, 1))
+ os.utime(paths.cas(UID).touch, ns=(2_000_000_000, 2_000_000_000))
+ incomplete = paths.cas(ACTIVE_UID)
+ incomplete.output.mkdir(parents=True)
+ incomplete.log.touch()
+ legacy = paths.cas_root / f"{UID}-legacy"
+ legacy.mkdir()
+
+ removed = core_clean.sweep_cas(repository, {LIVE_UID})
+
+ self.assertEqual(removed, (inactive, incomplete.entry))
+ self.assertTrue(live.is_dir())
+ self.assertFalse(inactive.exists())
+ self.assertFalse(incomplete.entry.exists())
+ self.assertTrue(legacy.is_dir())
+
+ def test_cas_sweep_skips_active_nodes_and_holds_both_locks_while_removing(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ inactive = self.complete_cas(paths, UID)
+ active = self.complete_cas(paths, ACTIVE_UID)
+ original_remove = shutil.rmtree
+
+ def remove(target: Path) -> None:
+ for lock_path in (paths.coordination_lock(), paths.lock(UID)):
+ with self.assertRaises(Timeout), FileLock(
+ lock_path, timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ):
+ pass
+ original_remove(target)
+
+ active_lock = FileLock(
+ paths.lock(ACTIVE_UID), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+ with active_lock, mock.patch(
+ "core.clean.shutil.rmtree", side_effect=remove
+ ):
+ self.assertEqual(core_clean.sweep_cas(repository, ()), (inactive,))
+
+ self.assertTrue(active.is_dir())
+
+ def test_cas_sweep_refuses_to_delete_while_another_run_lease_is_active(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ inactive = self.complete_cas(paths, UID)
+ lease = FileLock(
+ paths.lease("active-run"), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+
+ with lease, self.assertRaisesRegex(CleanError, "active build lease"):
+ core_clean.sweep_cas(repository, ())
+
+ self.assertTrue(inactive.is_dir())
+ self.assertEqual(core_clean.sweep_cas(repository, ()), (inactive,))
+
+ def test_cas_sweep_ignores_only_the_explicit_owned_active_lease(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ inactive = self.complete_cas(paths, UID)
+ owned = FileLock(
+ paths.lease("owned-run"), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+
+ with owned:
+ self.assertEqual(
+ core_clean.sweep_cas(
+ repository, (), owned_run_id="owned-run"
+ ),
+ (inactive,),
+ )
+
+ inactive = self.complete_cas(paths, UID)
+ other = FileLock(
+ paths.lease("other-run"), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+ with owned, other, self.assertRaisesRegex(CleanError, "other-run"):
+ core_clean.sweep_cas(repository, (), owned_run_id="owned-run")
+ self.assertTrue(inactive.is_dir())
+
+ with self.assertRaisesRegex(CleanError, "owned run lease is not active"):
+ core_clean.sweep_cas(repository, (), owned_run_id="owned-run")
+ with self.assertRaisesRegex(CleanError, "owned run lease is missing"):
+ core_clean.sweep_cas(repository, (), owned_run_id="missing-run")
+ self.assertTrue(inactive.is_dir())
+
+ def test_cas_sweep_rechecks_paths_under_lock_and_rejects_unsafe_inputs(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, paths = self.repository(root / "races")
+ paths.prepare()
+ inactive = self.complete_cas(paths, UID)
+ original_cas = BuildPaths.cas
+ calls = 0
+
+ def recheck(instance: BuildPaths, uid: str):
+ nonlocal calls
+ candidate = original_cas(instance, uid)
+ calls += 1
+ if calls == 2:
+ with self.assertRaises(Timeout), FileLock(
+ paths.lock(UID), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ):
+ pass
+ raise PathSafetyError("reparse point appeared")
+ return candidate
+
+ with mock.patch.object(BuildPaths, "cas", autospec=True, side_effect=recheck), \
+ self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ core_clean.sweep_cas(repository, ())
+ self.assertEqual(calls, 2)
+ self.assertTrue(inactive.is_dir())
+
+ with self.assertRaisesRegex(PathSafetyError, "UID"):
+ core_clean.sweep_cas(repository, {UID.upper()})
+ self.assertTrue(inactive.is_dir())
+
+ malformed_lease = paths.locks_root / "run-.lease"
+ malformed_lease.touch()
+ with self.assertRaisesRegex(CleanError, "unexpected run lease"):
+ core_clean.sweep_cas(repository, ())
+ self.assertTrue(inactive.is_dir())
+ malformed_lease.unlink()
+
+ with FileLock(
+ paths.coordination_lock(), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ), self.assertRaisesRegex(CleanError, "coordination lock"):
+ core_clean.sweep_cas(repository, ())
+ self.assertTrue(inactive.is_dir())
+
+ reparse_repository, reparse = self.repository(root / "reparse")
+ reparse.prepare()
+ reparse_entry = self.complete_cas(reparse, UID)
+ with mock.patch(
+ "core.paths._is_reparse",
+ side_effect=lambda path: Path(path) == reparse_entry,
+ ), self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ core_clean.sweep_cas(reparse_repository, ())
+ self.assertTrue(reparse_entry.is_dir())
+
+ empty_repository, _empty = self.repository(root / "empty")
+ self.assertEqual(core_clean.sweep_cas(empty_repository, ()), ())
+
+ vanished_repository, vanished = self.repository(root / "vanished")
+ vanished.prepare()
+ with mock.patch("core.clean._validate", side_effect=(True, False)):
+ self.assertEqual(core_clean.sweep_cas(vanished_repository, ()), ())
+
+ no_cas_repository, no_cas = self.repository(root / "no-cas")
+ no_cas.output_root.mkdir()
+ no_cas.work_root.mkdir()
+ self.assertEqual(core_clean.sweep_cas(no_cas_repository, ()), ())
+
+ def test_all_removes_only_exact_generated_output(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ current = paths.cas(UID).entry
+ current.mkdir()
+ (current / "data").write_text("generated", encoding="utf-8")
+ legacy = paths.cas_root / f"{UID}-legacy-node"
+ legacy.mkdir()
+ (legacy / "data").write_text("old generated", encoding="utf-8")
+ run = paths.run_work("old-run")
+ run.mkdir()
+ inactive_lock = paths.lock(UID)
+ with FileLock(inactive_lock, timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True):
+ pass
+ keep = repository / "keep.txt"
+ keep.write_text("user", encoding="utf-8")
+
+ self.assertEqual(clean(repository), (paths.cas_root, run, inactive_lock))
+
+ self.assertFalse(paths.cas_root.exists())
+ self.assertFalse(run.exists())
+ self.assertEqual(
+ tuple(paths.locks_root.iterdir()), (paths.coordination_lock(),)
+ )
+ self.assertEqual(keep.read_text(encoding="utf-8"), "user")
+
+ paths.prepare()
+ self.assertTrue(paths.cas_root.is_dir())
+ self.assertTrue(paths.work_root.is_dir())
+
+ def test_stale_work_removes_only_inactive_exact_run_directories(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ cached = paths.cas(UID).output
+ cached.mkdir(parents=True)
+ (cached / "module.so").touch()
+ legacy = paths.cas_root / f"{UID}-legacy-node" / "out"
+ legacy.mkdir(parents=True)
+ (legacy / "module.so").touch()
+ runs = tuple(paths.run_work(name) for name in ("run-b", "run-a"))
+ for run in runs:
+ run.mkdir()
+ (run / "scratch.obj").touch()
+ lock = FileLock(paths.lock(UID), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True)
+ with lock:
+ pass
+
+ removed = clean(repository, "stale-work")
+
+ self.assertEqual(removed, tuple(sorted(runs)))
+ self.assertTrue((cached / "module.so").is_file())
+ self.assertTrue((legacy / "module.so").is_file())
+ self.assertTrue(paths.locks_root.is_dir())
+ self.assertTrue(paths.lock(UID).is_file())
+ self.assertTrue(all(not run.exists() for run in runs))
+
+ def test_active_native_lock_refuses_every_mode_without_deleting(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ run = paths.run_work("active-run")
+ run.mkdir()
+ marker = run / "partial.obj"
+ marker.touch()
+ lock = FileLock(paths.lock(UID), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True)
+ with lock:
+ for mode in ("all", "stale-work"):
+ with self.subTest(mode=mode), self.assertRaisesRegex(CleanError, "active build"):
+ clean(repository, mode)
+ self.assertTrue(marker.is_file())
+
+ def test_active_run_lease_refuses_and_coordination_is_held_through_removal(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ run = paths.run_work("leased-run")
+ run.mkdir()
+ lease = FileLock(paths.lease("leased-run"), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True)
+ with lease, self.assertRaisesRegex(CleanError, "active build"):
+ clean(repository, "stale-work")
+ self.assertTrue(run.is_dir())
+
+ original_remove = shutil.rmtree
+
+ def remove(target: Path) -> None:
+ with self.assertRaises(Timeout), FileLock(
+ paths.coordination_lock(), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ):
+ pass
+ original_remove(target)
+
+ with mock.patch("core.clean.shutil.rmtree", side_effect=remove):
+ self.assertEqual(clean(repository, "stale-work"), (run,))
+
+ def test_unsafe_layout_types_and_reparse_points_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ file_repository, file_paths = self.repository(root / "file")
+ file_paths.output_root.touch()
+ with self.assertRaisesRegex(CleanError, "directory"):
+ clean(file_repository)
+
+ unexpected_repository, unexpected = self.repository(root / "unexpected")
+ unexpected.output_root.mkdir()
+ (unexpected.output_root / "personal.txt").touch()
+ with self.assertRaisesRegex(CleanError, "unexpected output entry"):
+ clean(unexpected_repository)
+
+ child_repository, child = self.repository(root / "child-file")
+ child.output_root.mkdir()
+ child.cas_root.touch()
+ with self.assertRaisesRegex(CleanError, "not a directory"):
+ clean(child_repository)
+
+ reparse_repository, reparse = self.repository(root / "reparse")
+ reparse.prepare()
+ reparse_entry = reparse.cas(UID).entry
+ reparse_entry.mkdir()
+ with mock.patch(
+ "core.paths._is_reparse", side_effect=lambda path: Path(path) == reparse_entry
+ ), self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ clean(reparse_repository)
+
+ resolved_repository, resolved = self.repository(root / "resolved")
+ resolved.output_root.mkdir()
+ original_resolve = Path.resolve
+
+ def resolve(path: Path, *, strict: bool = False) -> Path:
+ if path == resolved.output_root:
+ return resolved.repository / "elsewhere"
+ return original_resolve(path, strict=strict)
+
+ with mock.patch.object(Path, "resolve", resolve), self.assertRaisesRegex(
+ CleanError, "does not resolve"
+ ):
+ clean(resolved_repository)
+
+ special_repository, special = self.repository(root / "special")
+ special.prepare()
+ special_entry = special.cas(UID).entry
+ special_entry.touch()
+ original_lstat = os.lstat
+
+ def lstat(path: Path) -> os.stat_result | SimpleNamespace:
+ return (SimpleNamespace(st_mode=stat.S_IFIFO)
+ if Path(path) == special_entry else original_lstat(path))
+
+ with mock.patch("core.clean.os.lstat", side_effect=lstat), self.assertRaisesRegex(
+ CleanError, "unsupported output entry type"
+ ):
+ clean(special_repository)
+
+ def test_invalid_lock_work_entry_mode_and_missing_output_are_safe(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ empty_repository, empty = self.repository(root / "empty")
+ self.assertEqual(clean(empty_repository), ())
+ self.assertFalse(empty.output_root.exists())
+ with self.assertRaisesRegex(ValueError, "mode"):
+ clean(empty_repository, "unknown")
+
+ no_work_repository, no_work = self.repository(root / "no-work")
+ no_work.output_root.mkdir()
+ no_work.cas_root.mkdir()
+ self.assertEqual(clean(no_work_repository, "stale-work"), ())
+
+ lock_repository, locks = self.repository(root / "bad-lock")
+ locks.prepare()
+ (locks.locks_root / "surprise.txt").touch()
+ with self.assertRaisesRegex(CleanError, "lock entry"):
+ clean(lock_repository)
+
+ (locks.locks_root / "surprise.txt").unlink()
+ (locks.locks_root / "bad.lock").touch()
+ with self.assertRaisesRegex(CleanError, "lock entry"):
+ clean(lock_repository)
+
+ (locks.locks_root / "bad.lock").unlink()
+ (locks.locks_root / "directory.lock").mkdir()
+ with self.assertRaisesRegex(CleanError, "lock entry"):
+ clean(lock_repository)
+
+ work_repository, work = self.repository(root / "bad-work")
+ work.prepare()
+ (work.work_root / "unexpected.txt").touch()
+ with self.assertRaisesRegex(CleanError, "work entry"):
+ clean(work_repository, "stale-work")
+ (work.work_root / "unexpected.txt").unlink()
+ (work.work_root / "bad name").mkdir()
+ with self.assertRaisesRegex(CleanError, "work entry"):
+ clean(work_repository, "stale-work")
+
+ def test_coordination_races_are_rejected_without_claiming_removal(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, paths = self.repository(root / "active")
+ paths.prepare()
+ with FileLock(paths.coordination_lock(), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True), self.assertRaisesRegex(
+ CleanError, "coordination lock"
+ ):
+ clean(repository)
+
+ vanished_repository, vanished = self.repository(root / "vanished")
+ vanished.prepare()
+ with mock.patch("core.clean._validate", side_effect=(True, False)):
+ self.assertEqual(clean(vanished_repository), ())
+
+ no_cas_repository, no_cas = self.repository(root / "no-cas")
+ no_cas.work_root.mkdir(parents=True)
+ self.assertEqual(clean(no_cas_repository), ())
+ self.assertEqual(
+ tuple(no_cas.locks_root.iterdir()), (no_cas.coordination_lock(),)
+ )
+
+ def test_cli_prints_exact_removed_paths_and_module_entry_point_is_safe(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, paths = self.repository(Path(temporary))
+ paths.prepare()
+ output = StringIO()
+ with redirect_stdout(output):
+ self.assertEqual(main((str(repository), "--mode", "stale-work")), 0)
+ self.assertEqual(output.getvalue(), "")
+
+ paths.run_work("old-run").mkdir()
+ with redirect_stdout(output):
+ self.assertEqual(main((str(repository), "--mode", "stale-work")), 0)
+ self.assertIn(str(paths.run_work("old-run")), output.getvalue())
+
+ safe = Path(temporary) / "entry"
+ safe.mkdir()
+ with mock.patch.object(sys, "argv", ["clean.py", str(safe)]), \
+ redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised:
+ runpy.run_path(str(BUILD_ROOT / "core/clean.py"), run_name="__main__")
+ self.assertEqual(raised.exception.code, 0)
+ self.assertFalse((safe / "out").exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_common.py b/build/tests/test_common.py
new file mode 100644
index 0000000..69cb977
--- /dev/null
+++ b/build/tests/test_common.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from pathlib import Path
+import tempfile
+import unittest
+
+from core.graph import Graph, Result
+from core.node import NodeFactory
+from core.render import TemplateRenderer
+from graphs.common import BUILD_ROOT, recipe_factory, restore_node
+
+
+@dataclass(frozen=True)
+class Toolchain:
+ pwsh: Path
+ vcpkg_root: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: tuple[tuple[str, str], ...]
+
+
+def repository(root: Path) -> Path:
+ (root / "build/vcpkg/triplets").mkdir(parents=True)
+ (root / "vcpkg.json").write_text("{}\n", encoding="utf-8")
+ for flavor in ("", "-asan"):
+ (root / f"build/vcpkg/triplets/observer-x64-windows-static{flavor}.cmake").write_text(
+ f"triplet{flavor}\n", encoding="utf-8"
+ )
+ return root
+
+
+class RecipeFactoryTests(unittest.TestCase):
+ def test_matches_direct_node_factory_contract(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ cwd = Path(temporary) / "working-directory"
+ identity = {"compiler": "exact-version", "tool": "exact-path"}
+ environment = (("ZED", "last"), ("ALPHA", "first"))
+ result = Result("reports/sample.json", "report", "application/json", "sample.json")
+
+ def make(factory: NodeFactory):
+ return factory.make(
+ "argv.json", "sample", "slot", {"argv": ("tool.exe", "--probe")},
+ files={}, results=(result,), config={"action": "probe"},
+ )
+
+ expected = make(NodeFactory(
+ TemplateRenderer(BUILD_ROOT / "templates"), cwd, identity, environment
+ ))
+ actual = make(recipe_factory(cwd, identity, environment))
+
+ self.assertEqual(actual, expected)
+
+
+class RestoreNodeTests(unittest.TestCase):
+ def fixture(self, root: Path) -> tuple[Path, Toolchain]:
+ repo = repository(root / "repo")
+ tools = root / "tools"
+ vcpkg = tools / "vcpkg"
+ vcpkg.mkdir(parents=True)
+ pwsh = tools / "pwsh.exe"
+ pwsh.write_bytes(b"pwsh-one")
+ (vcpkg / "vcpkg.exe").write_bytes(b"vcpkg-one")
+ return repo, Toolchain(
+ pwsh, vcpkg,
+ (("ZED", "last"), ("Path", str(tools))),
+ (("pwsh_version", "7.5"), ("unrelated", "first")),
+ )
+
+ def test_restore_identity_ignores_parent_factory_and_wrapper_noise(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repo, first = self.fixture(Path(temporary))
+ second = replace(
+ first,
+ environment=(("PATH", str(first.pwsh.parent)), ("zed", "last")),
+ identity=(("unrelated", "second"), ("pwsh_version", "wrapper-noise")),
+ )
+ renderer = TemplateRenderer(BUILD_ROOT / "templates")
+ factories = (
+ NodeFactory(renderer, repo / "wrong-one", {"compiler": "one"}, (("NOISE", "one"),)),
+ NodeFactory(renderer, repo / "wrong-two", {"compiler": "two"}, (("NOISE", "two"),)),
+ )
+ pairs = tuple(
+ (
+ restore_node(repo, first, factories[0], "x64", flavor=flavor),
+ restore_node(repo, second, factories[1], "x64", flavor=flavor),
+ )
+ for flavor in ("", "asan")
+ )
+
+ for left, right in pairs:
+ self.assertEqual(left, right)
+ nodes = tuple({node.name: node for pair in pairs for node in pair}.values())
+ self.assertEqual(len(nodes), 2)
+ Graph(nodes, tuple(node.name for node in nodes), {"restore": 1})
+
+ def test_exact_tool_and_triplet_changes_invalidate_restore(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repo, toolchain = self.fixture(Path(temporary))
+ factory = NodeFactory(
+ TemplateRenderer(BUILD_ROOT / "templates"), repo, {"unrelated": "identity"}
+ )
+
+ baseline = restore_node(repo, toolchain, factory, "x64")
+ toolchain.pwsh.write_bytes(b"pwsh-two")
+ pwsh_changed = restore_node(repo, toolchain, factory, "x64")
+ (toolchain.vcpkg_root / "vcpkg.exe").write_bytes(b"vcpkg-two")
+ vcpkg_changed = restore_node(repo, toolchain, factory, "x64")
+ (repo / "build/vcpkg/triplets/observer-x64-windows-static.cmake").write_text(
+ "changed triplet\n", encoding="utf-8"
+ )
+ triplet_changed = restore_node(repo, toolchain, factory, "x64")
+
+ self.assertNotEqual(baseline.uid, pwsh_changed.uid)
+ self.assertNotEqual(pwsh_changed.uid, vcpkg_changed.uid)
+ self.assertNotEqual(vcpkg_changed.uid, triplet_changed.uid)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_core_coverage.py b/build/tests/test_core_coverage.py
new file mode 100644
index 0000000..3e6520b
--- /dev/null
+++ b/build/tests/test_core_coverage.py
@@ -0,0 +1,380 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import runpy
+import subprocess
+import sys
+import tempfile
+import threading
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.binary_audit import AuditError, require_release_pe # noqa: E402
+from core.execute import ExecutionError, Executor # noqa: E402
+from core.graph import Command, Graph, GraphError, Node # noqa: E402
+from core.node import NodeFactory # noqa: E402
+from core.paths import BuildPaths, PathSafetyError # noqa: E402
+from core.render import TemplateRenderer # noqa: E402
+from core.sarif import ( # noqa: E402
+ SarifError,
+ normalize_msvc,
+ require_clean,
+)
+from core.store import CasStateError, CasStore # noqa: E402
+from core.toolchain import _existing, _output # noqa: E402
+from core.windows_process import WindowsProcessRunner, _reap # noqa: E402
+
+
+UID = "0123456789abcdef0123456789abcdef"
+RUN_ID = "20260802T000000Z-coverage"
+
+
+def node(
+ name: str = "leaf", *, inputs: tuple[str, ...] = (), command: Command | None = None
+) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "cpu",
+ command or Command(("tool",)),
+ inputs,
+ )
+
+
+def write_sarif(path: Path, runs: list[object]) -> None:
+ path.write_text(
+ json.dumps({"version": "2.1.0", "runs": runs}), encoding="utf-8"
+ )
+
+
+class CoreValidationTests(unittest.TestCase):
+ def test_release_audit_reports_an_unsupported_architecture(self) -> None:
+ with self.assertRaisesRegex(AuditError, "unsupported.*riscv64") as raised:
+ require_release_pe("riscv64", "", "", "")
+
+ self.assertIsInstance(raised.exception.__cause__, KeyError)
+
+ def test_command_and_node_reject_ambiguous_runtime_types(self) -> None:
+ invalid_commands = (
+ lambda: Command(()),
+ lambda: Command((1,)),
+ lambda: Command(("tool",), env=(("ONLY-KEY",),)),
+ lambda: Command(("tool",), env=(("KEY", 1),)),
+ lambda: Command(("tool",), env=(("KEY", "bad\0value"),)),
+ lambda: Command(("tool",), env=((1, "value"),)),
+ )
+ for constructor in invalid_commands:
+ with self.subTest(constructor=constructor), self.assertRaises(GraphError):
+ constructor() # type: ignore[call-arg]
+
+ invalid_nodes = (
+ lambda: Node(1, UID, "cpu", Command(("tool",))),
+ lambda: Node("leaf", 1, "cpu", Command(("tool",))),
+ lambda: Node("leaf", UID, 1, Command(("tool",))),
+ lambda: Node("leaf", UID, "cpu", object()),
+ lambda: Node("leaf", UID, "cpu", Command(("tool",)), (1,)),
+ )
+ for constructor in invalid_nodes:
+ with self.subTest(constructor=constructor), self.assertRaises(GraphError):
+ constructor() # type: ignore[call-arg]
+
+ def test_graph_rejects_non_integral_pool_capacity_and_sparse_cycle_error(
+ self,
+ ) -> None:
+ with self.assertRaisesRegex(GraphError, "invalid pool capacity"):
+ Graph((node(),), ("leaf",), {"cpu": "many"}) # type: ignore[dict-item]
+
+ sorter = mock.Mock()
+ sorter.prepare.side_effect = __import__("graphlib").CycleError("cycle")
+ with (
+ mock.patch("core.graph.TopologicalSorter", return_value=sorter),
+ self.assertRaisesRegex(GraphError, r"dependency cycle:\s*$"),
+ ):
+ Graph((node(),), ("leaf",), {"cpu": 1})
+
+ def test_node_factory_renders_signs_and_applies_runtime_overrides(self) -> None:
+ factory = NodeFactory(
+ TemplateRenderer(BUILD_ROOT / "templates"),
+ Path(r"C:\repo\default-work"),
+ {"compiler": "msvc-default"},
+ (("DEFAULT", "environment"),),
+ )
+ dependency = node("restore")
+ variables = {
+ "pwsh": "pwsh.exe",
+ "msbuild": r"C:\VS\MSBuild.exe",
+ "project": r"C:\repo\renpy.vcxproj",
+ "target": "Build",
+ "configuration": "Release",
+ "platform": "x64",
+ "msbuild_args": [],
+ }
+ current = factory.make(
+ "msbuild.ps1",
+ "build-renpy-x64",
+ "cpu",
+ variables,
+ files={"renpy.vcxproj": b"project bytes"},
+ dependencies=(dependency,),
+ config={"architecture": "x64"},
+ )
+ overridden = factory.make(
+ "msbuild.ps1",
+ "build-renpy-x64",
+ "cpu",
+ variables,
+ files={"renpy.vcxproj": b"project bytes"},
+ dependencies=(dependency,),
+ config={"architecture": "x64"},
+ identity={"compiler": "msvc-override"},
+ environment=(("OVERRIDE", "environment"),),
+ cwd=Path(r"C:\repo\override-work"),
+ )
+
+ self.assertEqual(current.inputs, ("restore",))
+ self.assertEqual(current.command.env, (("DEFAULT", "environment"),))
+ self.assertEqual(current.command.cwd, r"C:\repo\default-work")
+ self.assertIn(b"C:\\VS\\MSBuild.exe", current.command.stdin)
+ self.assertEqual(overridden.command.env, (("OVERRIDE", "environment"),))
+ self.assertEqual(overridden.command.cwd, r"C:\repo\override-work")
+ self.assertNotEqual(current.uid, overridden.uid)
+
+ def test_path_policy_rejects_a_candidate_outside_the_repository(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repository"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ with self.assertRaisesRegex(PathSafetyError, "outside repository"):
+ paths._reject_existing_reparse_points(repository.parent)
+
+ def test_cas_cannot_publish_when_entry_exists_without_output(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ paths = BuildPaths(repository)
+ paths.prepare()
+ store = CasStore(paths, RUN_ID)
+ current = node()
+ entry = store.paths_for(current)
+ entry.entry.mkdir()
+ entry.log.touch()
+
+ with self.assertRaisesRegex(CasStateError, "without output directory"):
+ store.mark_complete(current)
+
+ self.assertFalse(entry.touch.exists())
+
+ def test_tool_discovery_explains_missing_paths_and_empty_tool_output(self) -> None:
+ with self.assertRaisesRegex(FileNotFoundError, "required path not found: None"):
+ _existing(None)
+
+ completed = subprocess.CompletedProcess(["tool"], 0, stdout=" \r\n", stderr="")
+ with (
+ mock.patch("core.toolchain.subprocess.run", return_value=completed) as run,
+ self.assertRaisesRegex(RuntimeError, "tool returned empty output: tool"),
+ ):
+ _output(["tool"])
+
+ run.assert_called_once_with(
+ ["tool"], check=True, capture_output=True, text=True
+ )
+
+
+class ExecutorLifecycleTests(unittest.IsolatedAsyncioTestCase):
+ async def test_executor_rejects_reuse_after_a_successful_build(self) -> None:
+ current = node()
+ complete: set[str] = set()
+
+ async def run(built: Node) -> None:
+ self.assertEqual(built, current)
+
+ executor = Executor(
+ Graph((current,), (current.name,), {"cpu": 1}),
+ is_complete=lambda candidate: candidate.uid in complete,
+ runner=run,
+ publish=lambda candidate: complete.add(candidate.uid),
+ )
+ await executor.run()
+
+ with self.assertRaisesRegex(ExecutionError, "single-use"):
+ await executor.run()
+
+
+class SarifFailureTests(unittest.TestCase):
+ def test_sarif_read_errors_retain_the_report_path_and_cause(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ malformed = root / "malformed.sarif"
+ malformed.write_text("{", encoding="utf-8")
+ missing = root / "missing.sarif"
+
+ for path in (malformed, missing):
+ with self.subTest(path=path), self.assertRaises(SarifError) as raised:
+ normalize_msvc(path, root / "output.sarif", "analysis/")
+ self.assertIn(f"cannot read SARIF report {path}", str(raised.exception))
+ self.assertIsNotNone(raised.exception.__cause__)
+
+ def test_gate_rejects_a_non_list_or_non_object_results_collection(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ for name, results in (("mapping", {}), ("scalar", ["warning"])):
+ report = root / f"{name}.sarif"
+ write_sarif(report, [{"results": results}])
+ with self.subTest(results=results), self.assertRaisesRegex(
+ SarifError, "results must be a list of objects"
+ ):
+ require_clean((report,))
+
+ def test_module_entry_point_exits_successfully_for_a_clean_gate(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ report = root / "clean.sarif"
+ write_sarif(report, [{"results": []}])
+ with (
+ mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(root)}),
+ mock.patch.object(sys, "argv", ["sarif.py", "gate", str(report)]),
+ self.assertWarnsRegex(RuntimeWarning, "core.sarif"),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("core.sarif", run_name="__main__")
+
+ self.assertEqual(raised.exception.code, 0)
+
+
+class FakeProcess:
+ def __init__(
+ self,
+ *,
+ communicate_error: BaseException | None = None,
+ leave_returncode_unset: bool = False,
+ kill_error: BaseException | None = None,
+ ) -> None:
+ self._handle = 301
+ self.returncode: int | None = None
+ self.communicate_error = communicate_error
+ self.leave_returncode_unset = leave_returncode_unset
+ self.kill_error = kill_error
+ self.started = threading.Event()
+ self.release = threading.Event()
+ self.release.set()
+ self.events: list[str] = []
+
+ def resume(self) -> None:
+ self.events.append("resume")
+
+ def communicate(self, *, input: bytes) -> tuple[None, None]:
+ self.events.append(f"communicate:{input!r}")
+ self.started.set()
+ self.release.wait(timeout=5)
+ if self.communicate_error is not None:
+ raise self.communicate_error
+ if not self.leave_returncode_unset:
+ self.returncode = 0
+ return None, None
+
+ def kill(self) -> None:
+ self.events.append("kill")
+ self.release.set()
+ if self.kill_error is not None:
+ raise self.kill_error
+
+ def wait(self) -> int:
+ self.events.append("wait")
+ self.returncode = 1
+ return self.returncode
+
+
+class FakeJob:
+ def __init__(
+ self,
+ process: FakeProcess,
+ *,
+ close_error: BaseException | None = None,
+ terminate_error: BaseException | None = None,
+ ) -> None:
+ self.process = process
+ self.close_error = close_error
+ self.terminate_error = terminate_error
+
+ def assign_process(self, process_handle: int) -> None:
+ self.process.events.append(f"assign:{process_handle}")
+
+ def close(self) -> None:
+ self.process.events.append("close")
+ self.process.release.set()
+ if self.close_error is not None:
+ raise self.close_error
+
+ def terminate(self) -> None:
+ self.process.events.append("terminate")
+ self.process.release.set()
+ if self.terminate_error is not None:
+ raise self.terminate_error
+
+
+class WindowsProcessCleanupTests(unittest.IsolatedAsyncioTestCase):
+ @staticmethod
+ def patched(process: FakeProcess, job: FakeJob):
+ return (
+ mock.patch("core.windows_process.psutil.Popen", return_value=process),
+ mock.patch("core.windows_process.WindowsJob", return_value=job),
+ )
+
+ async def test_communication_failure_during_reaping_is_reported(self) -> None:
+ process = FakeProcess(communicate_error=OSError("late pipe failure"))
+ communication = asyncio.create_task(
+ asyncio.to_thread(process.communicate, input=b"recipe")
+ )
+ primary = RuntimeError("runner failed")
+ errors: list[BaseException] = []
+
+ await _reap(process, communication, errors, primary)
+
+ self.assertEqual([str(error) for error in errors], ["late pipe failure"])
+ self.assertIn("wait", process.events)
+
+ async def test_successful_communication_must_set_an_exit_code(self) -> None:
+ process = FakeProcess(leave_returncode_unset=True)
+ job = FakeJob(process)
+ popen, windows_job = self.patched(process, job)
+ with popen, windows_job, self.assertRaisesRegex(
+ RuntimeError, "completed without an exit code"
+ ):
+ await WindowsProcessRunner().run(
+ Command((sys.executable,), stdin=b"recipe"), log=io.BytesIO()
+ )
+
+ self.assertIn("wait", process.events)
+
+ async def test_every_cleanup_failure_is_visible_after_process_success(self) -> None:
+ process = FakeProcess(kill_error=OSError("root kill failed"))
+ job = FakeJob(
+ process,
+ close_error=OSError("job close failed"),
+ terminate_error=OSError("job terminate failed"),
+ )
+ popen, windows_job = self.patched(process, job)
+ with popen, windows_job, self.assertRaisesRegex(
+ OSError, "job close failed"
+ ) as raised:
+ await WindowsProcessRunner().run(
+ Command((sys.executable,)), log=io.BytesIO()
+ )
+
+ self.assertEqual(process.events[-3:], ["close", "terminate", "kill"])
+ notes = raised.exception.__notes__
+ self.assertTrue(any("job terminate failed" in note for note in notes))
+ self.assertTrue(any("root kill failed" in note for note in notes))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_coverage_config.py b/build/tests/test_coverage_config.py
new file mode 100644
index 0000000..d15d128
--- /dev/null
+++ b/build/tests/test_coverage_config.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import unittest
+from pathlib import Path
+
+from coverage import Coverage
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+
+
+class CoverageConfigTests(unittest.TestCase):
+ def test_first_party_python_gate_requires_every_line_and_branch(self) -> None:
+ coverage = Coverage(config_file=str(BUILD_ROOT / "pyproject.toml"))
+
+ self.assertTrue(coverage.get_option("run:branch"))
+ self.assertEqual(coverage.get_option("run:source"), ["."])
+ self.assertEqual(
+ coverage.get_option("run:command_line"),
+ "-m unittest discover -s tests -p test_*.py",
+ )
+ self.assertEqual(coverage.get_option("report:fail_under"), 100)
+ self.assertEqual(coverage.get_option("report:exclude_lines"), [])
+ self.assertEqual(
+ coverage.get_option("report:include"),
+ ["core/*", "graphs/*", "driver.py", "main.py"],
+ )
+ self.assertTrue(coverage.get_option("report:show_missing"))
+ self.assertFalse(coverage.get_option("report:skip_covered"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_cpp_coverage_graph.py b/build/tests/test_cpp_coverage_graph.py
new file mode 100644
index 0000000..287039a
--- /dev/null
+++ b/build/tests/test_cpp_coverage_graph.py
@@ -0,0 +1,512 @@
+from __future__ import annotations
+
+from contextlib import redirect_stderr
+import hashlib
+import io
+import json
+from pathlib import Path
+import runpy
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.cpp_coverage import CoverageError, main as coverage_main, require_full_coverage # noqa: E402
+from core.graph import Command, Graph, Node # noqa: E402
+from core.paths import BuildPaths # noqa: E402
+from graphs.coverage import ( # noqa: E402
+ coverage_artifact_graph,
+ coverage_dependency_discovery_slice,
+ coverage_graph,
+)
+from tests import test_instrumented_graph as instrumented_fixture # noqa: E402
+
+
+def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "build",
+ Command(("C:/sdk/build.exe",)),
+ inputs,
+ )
+
+
+class CppCoverageGraphTests(unittest.TestCase):
+ def fixture(
+ self, root: Path, architectures: tuple[str, ...] = ("x64", "x86")
+ ) -> tuple[Path, Graph, dict[str, Path]]:
+ repository = root / "repo"
+ repository.mkdir()
+ nodes = []
+ for architecture in architectures:
+ restore = node(f"restore-vcpkg-{architecture}")
+ discovery = node(f"coverage-dependencies-{architecture}", inputs=(restore.name,))
+ nodes.extend((restore, discovery))
+ for name, filename in (
+ ("renpy", "renpy.so"),
+ ("rpgmaker", "rpgmaker.so"),
+ ("zanzarah", "zanzarah.so"),
+ ("tests", "tests.exe"),
+ ):
+ producer = node(
+ f"build-{name}-{architecture}-coverage", inputs=(discovery.name,)
+ )
+ nodes.append(producer)
+ upstream = Graph(tuple(nodes), tuple(item.name for item in nodes[1:]), {"build": 8})
+ tools = root / "tools"
+ tools.mkdir()
+ selected = {
+ name: tools / name
+ for name in ("pwsh.exe", "llvm-profdata.exe", "llvm-cov.exe")
+ }
+ for path in selected.values():
+ path.touch()
+ return repository, upstream, selected
+
+ def graph(self, root: Path, **options: object) -> Graph:
+ architectures = options.pop("architectures", ("x64", "x86"))
+ repository, upstream, tools = self.fixture(root, architectures) # type: ignore[arg-type]
+ return coverage_artifact_graph(
+ repository,
+ upstream,
+ architectures=architectures, # type: ignore[arg-type]
+ pwsh=tools["pwsh.exe"],
+ pwsh_identity={"version": options.pop("pwsh_version", "7.5")},
+ llvm_profdata=tools["llvm-profdata.exe"],
+ llvm_profdata_identity={"version": options.pop("profdata_version", "20.1")},
+ llvm_cov=tools["llvm-cov.exe"],
+ llvm_cov_identity={"version": options.pop("cov_version", "20.1")},
+ **options,
+ )
+
+ def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag(
+ self,
+ ) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ graph = self.graph(root, test_shards=2, jobs=6, report_jobs=3)
+ paths = BuildPaths(root / "repo")
+
+ self.assertEqual(
+ graph.pools,
+ {
+ "build": 8,
+ "coverage-shard": 6,
+ "coverage-merge": 2,
+ "coverage-report": 3,
+ "coverage-gate": 6,
+ },
+ )
+ self.assertEqual(graph.targets, ("coverage-x64", "coverage-x86"))
+ self.assertEqual(len(graph.nodes), 24)
+ for architecture in ("x64", "x86"):
+ builds = tuple(
+ graph.node(f"build-{name}-{architecture}-coverage")
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests")
+ )
+ shards = tuple(
+ graph.node(f"coverage-test-{architecture}-{index}") for index in range(2)
+ )
+ for index, shard in enumerate(shards):
+ self.assertEqual(shard.inputs, tuple(item.name for item in builds))
+ self.assertEqual(shard.pool, "coverage-shard")
+ script = shard.command.stdin.decode("utf-8")
+ self.assertIn("LLVM_PROFILE_FILE", script)
+ self.assertIn("coverage-%m-%p.profraw", script)
+ self.assertIn("'--shard-count'", script)
+ self.assertIn("'2'", script)
+ self.assertIn("'--shard-index'", script)
+ self.assertIn(f"'{index}'", script)
+ self.assertEqual(
+ tuple((item.id, item.relative_path) for item in shard.results),
+ ((
+ f"reports/coverage/cpp/{architecture}/tests/shard-{index}.xml",
+ "tests.xml",
+ ),),
+ )
+
+ merge = graph.node(f"coverage-merge-{architecture}")
+ self.assertEqual(merge.inputs, tuple(item.name for item in shards))
+ self.assertEqual(merge.pool, "coverage-merge")
+ merge_script = merge.command.stdin.decode("utf-8")
+ self.assertIn("llvm-profdata.exe", merge_script)
+ self.assertIn("coverage.profdata", merge_script)
+ self.assertIn("$arguments = @('merge', '-sparse') + $profiles", merge_script)
+ self.assertIn("llvm-profdata.exe' $arguments", merge_script)
+ for shard in shards:
+ self.assertIn(str(paths.cas(shard.uid).output), merge_script)
+
+ reports = tuple(
+ graph.node(f"coverage-{kind}-{architecture}") for kind in ("json", "lcov")
+ )
+ for report in reports:
+ self.assertEqual(report.inputs, (merge.name, *(item.name for item in builds)))
+ self.assertEqual(report.pool, "coverage-report")
+ script = report.command.stdin.decode("utf-8")
+ self.assertIn("llvm-cov.exe", script)
+ self.assertIn("--instr-profile", script)
+ self.assertIn("--ignore-filename-regex", script)
+ for module in ("renpy.so", "rpgmaker.so", "zanzarah.so"):
+ self.assertIn(module, script)
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in reports[0].results),
+ ((
+ f"reports/coverage/cpp/{architecture}/coverage.json",
+ "coverage", "application/json", "coverage.json",
+ ),),
+ )
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in reports[1].results),
+ ((
+ f"reports/coverage/cpp/{architecture}/coverage.lcov",
+ "coverage", "text/plain", "coverage.lcov",
+ ),),
+ )
+ self.assertEqual(merge.results, ())
+ self.assertIn("--summary-only", reports[0].command.stdin.decode("utf-8"))
+ self.assertIn("--format=lcov", reports[1].command.stdin.decode("utf-8"))
+
+ gate = graph.node(f"coverage-{architecture}")
+ self.assertEqual(gate.inputs, tuple(item.name for item in reports))
+ self.assertEqual(gate.pool, "coverage-gate")
+ self.assertEqual(gate.command.argv[1:4], ("-m", "core.cpp_coverage", "gate"))
+ self.assertNotIn("threshold", " ".join(gate.command.argv).casefold())
+ self.assertEqual(gate.results, ())
+
+ def test_tool_identities_invalidate_only_their_nodes_and_semantic_consumers(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, tools = self.fixture(root, ("x64",))
+
+ def build(profdata_version: str, cov_version: str) -> Graph:
+ return coverage_artifact_graph(
+ repository,
+ upstream,
+ architectures=("x64",),
+ pwsh=tools["pwsh.exe"],
+ pwsh_identity={"version": "7.5"},
+ llvm_profdata=tools["llvm-profdata.exe"],
+ llvm_profdata_identity={"version": profdata_version},
+ llvm_cov=tools["llvm-cov.exe"],
+ llvm_cov_identity={"version": cov_version},
+ test_shards=2,
+ )
+
+ before, profdata, cov = build("20.1", "20.1"), build("20.2", "20.1"), build("20.1", "20.2")
+
+ for current in before.nodes:
+ if not current.name.startswith("coverage-") or current.name.startswith(
+ "coverage-dependencies-"
+ ):
+ continue
+ profdata_partition = not current.name.startswith("coverage-test-")
+ cov_partition = current.name.startswith(("coverage-json-", "coverage-lcov-")) or current.name == "coverage-x64"
+ self.assertEqual(profdata_partition, current.uid != profdata.node(current.name).uid, current.name)
+ self.assertEqual(cov_partition, current.uid != cov.node(current.name).uid, current.name)
+
+ def test_external_corpus_adds_independent_profile_shards_and_signs_only_path_and_nonce(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, tools = self.fixture(root, ("x64",))
+ corpus = root / "corpus"
+ moved_corpus = root / "moved-corpus"
+ corpus.mkdir()
+ moved_corpus.mkdir()
+
+ def build(selected: Path | None = None, nonce: str = "") -> Graph:
+ return coverage_artifact_graph(
+ repository,
+ upstream,
+ architectures=("x64",),
+ pwsh=tools["pwsh.exe"],
+ pwsh_identity={"version": "7.5"},
+ llvm_profdata=tools["llvm-profdata.exe"],
+ llvm_profdata_identity={"version": "20.1"},
+ llvm_cov=tools["llvm-cov.exe"],
+ llvm_cov_identity={"version": "20.1"},
+ test_shards=2,
+ corpus=selected,
+ run_nonce=nonce,
+ )
+
+ baseline = build()
+ no_corpus = build(None, "ignored")
+ first = build(corpus, "run-one")
+ (corpus / "multi-gigabyte-placeholder.bin").write_bytes(b"not signed")
+ content_changed = build(corpus, "run-one")
+ rerun = build(corpus, "run-two")
+ moved = build(moved_corpus, "run-one")
+ cas_outputs = {
+ name: BuildPaths(repository).cas(first.node(name).uid).output
+ for name in (
+ *(f"coverage-test-x64-{index}" for index in range(2)),
+ *(f"coverage-corpus-x64-{index}" for index in range(2)),
+ )
+ }
+
+ standard_names = tuple(f"coverage-test-x64-{index}" for index in range(2))
+ corpus_names = tuple(f"coverage-corpus-x64-{index}" for index in range(2))
+ for name in standard_names:
+ self.assertEqual(baseline.node(name), no_corpus.node(name))
+ self.assertEqual(baseline.node(name), first.node(name))
+ self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(name).command.env))
+ self.assertFalse(any(node.name.startswith("coverage-corpus-") for node in baseline.nodes))
+ for index, name in enumerate(corpus_names):
+ shard = first.node(name)
+ self.assertEqual(shard.uid, content_changed.node(name).uid)
+ self.assertNotEqual(shard.uid, rerun.node(name).uid)
+ self.assertNotEqual(shard.uid, moved.node(name).uid)
+ self.assertEqual(shard.inputs, first.node(standard_names[index]).inputs)
+ self.assertEqual(
+ dict(shard.command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve())
+ )
+ script = shard.command.stdin.decode("utf-8")
+ self.assertIn("'[compatibility]'", script)
+ self.assertIn("LLVM_PROFILE_FILE", script)
+ self.assertEqual(
+ tuple((item.id, item.relative_path) for item in shard.results),
+ ((
+ f"reports/coverage/cpp/x64/corpus/shard-{index}.xml",
+ "tests.xml",
+ ),),
+ )
+ merge = first.node("coverage-merge-x64")
+ self.assertEqual(merge.inputs, standard_names + corpus_names)
+ for name in (*standard_names, *corpus_names):
+ self.assertIn(str(cas_outputs[name]), merge.command.stdin.decode())
+ self.assertTrue(all(
+ "OBSERVER_TEST_CORPUS" not in dict(node.command.env)
+ for node in first.nodes if not node.name.startswith("coverage-corpus-")
+ ))
+
+ def test_rejects_invalid_axes_lineage_tools_pools_and_corpus(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, tools = self.fixture(root, ("x64",))
+
+ def invoke(
+ source: Graph = upstream,
+ architectures: tuple[str, ...] = ("x64",),
+ **options: object,
+ ) -> Graph:
+ return coverage_artifact_graph(
+ repository,
+ source,
+ architectures=architectures,
+ pwsh=options.pop("pwsh", tools["pwsh.exe"]), # type: ignore[arg-type]
+ pwsh_identity={"version": "7.5"},
+ llvm_profdata=tools["llvm-profdata.exe"],
+ llvm_profdata_identity={"version": "20.1"},
+ llvm_cov=tools["llvm-cov.exe"],
+ llvm_cov_identity={"version": "20.1"},
+ **options,
+ )
+
+ for invalid in ((), ("x64", "x64"), ("armv7",)):
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"):
+ invoke(architectures=invalid)
+
+ missing = Graph(
+ tuple(item for item in upstream.nodes if item.name != "build-tests-x64-coverage"),
+ tuple(name for name in upstream.targets if name != "build-tests-x64-coverage"),
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "unknown node"):
+ invoke(missing)
+
+ shared = node("detached-shared")
+ left = node("detached-left", inputs=(shared.name,))
+ right = node("detached-right", inputs=(shared.name,))
+ detached = node("build-renpy-x64-coverage", inputs=(left.name, right.name))
+ detached_upstream = Graph(
+ tuple(
+ detached if current.name == detached.name else current
+ for current in upstream.nodes
+ ) + (shared, left, right),
+ upstream.targets,
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "restore ancestor"):
+ invoke(detached_upstream)
+
+ for options in ({"test_shards": 0}, {"jobs": True}, {"report_jobs": 0}):
+ with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"):
+ invoke(**options)
+ with self.assertRaisesRegex(FileNotFoundError, "not a file"):
+ invoke(pwsh=tools["pwsh.exe"].parent)
+ conflicting = Graph(
+ upstream.nodes,
+ upstream.targets,
+ dict(upstream.pools) | {"coverage-report": 1},
+ )
+ with self.assertRaisesRegex(ValueError, "conflicting pool"):
+ invoke(conflicting, report_jobs=2)
+ corpus = root / "corpus"
+ corpus.mkdir()
+ for nonce in ("", True, "bad\0nonce"):
+ with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "run nonce"):
+ invoke(corpus=corpus, run_nonce=nonce)
+ with self.assertRaises(FileNotFoundError):
+ invoke(corpus=root / "missing", run_nonce="run")
+ file_corpus = root / "corpus.bin"
+ file_corpus.touch()
+ with self.assertRaises(NotADirectoryError):
+ invoke(corpus=file_corpus, run_nonce="run")
+
+ def test_complete_graph_discovers_and_builds_coverage_artifacts_itself(self) -> None:
+ helper = instrumented_fixture.InstrumentedBuildGraphTests()
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = helper.repository(root / "repo")
+ toolchain = helper.toolchain(root)
+ for name in ("llvm-cov.exe", "llvm-profdata.exe"):
+ (toolchain.llvm_dir / "bin" / name).write_bytes(b"llvm-v1")
+ discovery = coverage_dependency_discovery_slice(
+ repository, toolchain, jobs=4, architectures=("x64",)
+ )
+ graph = coverage_graph(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=helper.manifests(repository, discovery),
+ jobs=4,
+ architectures=("x64",),
+ test_shards=1,
+ )
+ corpus = root / "corpus"
+ corpus.mkdir()
+ corpus_graph = coverage_graph(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=helper.manifests(repository, discovery),
+ jobs=4,
+ architectures=("x64",),
+ test_shards=1,
+ corpus=corpus,
+ run_nonce="high-level-run",
+ )
+
+ self.assertEqual(graph.targets, ("coverage-x64",))
+ self.assertEqual(
+ len(graph.nodes),
+ len(discovery.nodes) + 4 + 1 + 1 + 2 + 1,
+ )
+ shard = graph.node("coverage-test-x64-0")
+ self.assertIn("coverage-corpus-x64-0", tuple(node.name for node in corpus_graph.nodes))
+ for project in ("renpy", "rpgmaker", "zanzarah", "tests"):
+ build = graph.node(f"build-{project}-x64-coverage")
+ self.assertIn("'/p:Configuration=Coverage'", build.command.stdin.decode())
+ self.assertEqual(shard.inputs.count(build.name), 1)
+
+ def test_complete_graph_hashes_exact_llvm_report_tool_bytes(self) -> None:
+ helper = instrumented_fixture.InstrumentedBuildGraphTests()
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = helper.repository(root / "repo")
+ toolchain = helper.toolchain(root)
+ cov = toolchain.llvm_dir / "bin/llvm-cov.exe"
+ profdata = toolchain.llvm_dir / "bin/llvm-profdata.exe"
+ cov.write_bytes(b"cov-v1")
+ profdata.write_bytes(b"profdata-v1")
+ discovery = coverage_dependency_discovery_slice(repository, toolchain, jobs=4)
+ manifests = helper.manifests(repository, discovery)
+
+ def build() -> Graph:
+ return coverage_graph(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ test_shards=1,
+ )
+
+ before = build()
+ profdata.write_bytes(b"profdata-v2")
+ profdata_changed = build()
+ profdata.write_bytes(b"profdata-v1")
+ cov.write_bytes(b"cov-v2")
+ cov_changed = build()
+
+ for name in (
+ "coverage-test-x64-0",
+ "coverage-merge-x64",
+ "coverage-json-x64",
+ "coverage-lcov-x64",
+ "coverage-x64",
+ ):
+ self.assertEqual(
+ name != "coverage-test-x64-0",
+ before.node(name).uid != profdata_changed.node(name).uid,
+ name,
+ )
+ self.assertEqual(
+ name.startswith(("coverage-json-", "coverage-lcov-"))
+ or name == "coverage-x64",
+ before.node(name).uid != cov_changed.node(name).uid,
+ name,
+ )
+
+
+class CppCoverageGateTests(unittest.TestCase):
+ @staticmethod
+ def report(lines: tuple[int, int] = (7, 7), branches: tuple[int, int] = (3, 3)) -> dict[str, object]:
+ return {
+ "type": "llvm.coverage.json.export",
+ "data": [{"totals": {
+ "lines": {"count": lines[0], "covered": lines[1]},
+ "branches": {"count": branches[0], "covered": branches[1]},
+ }}],
+ }
+
+ def test_gate_requires_nonempty_exactly_complete_first_party_lines_and_branches(self) -> None:
+ require_full_coverage(self.report())
+ for document, message in (
+ (self.report(lines=(7, 6)), "lines"),
+ (self.report(branches=(3, 2)), "branches"),
+ (self.report(branches=(0, 0)), "no first-party branches"),
+ (self.report(lines=(0, 0)), "no first-party lines"),
+ ):
+ with self.subTest(message=message), self.assertRaisesRegex(CoverageError, message):
+ require_full_coverage(document)
+
+ def test_gate_rejects_malformed_or_ambiguous_llvm_reports(self) -> None:
+ malformed = (
+ {},
+ {"data": []},
+ {"data": [{"totals": {}}, {"totals": {}}]},
+ {"data": [{"totals": []}]},
+ {"data": [{"totals": {"lines": [], "branches": {"count": 1, "covered": 1}}}]},
+ {"data": [{"totals": {"lines": {"count": True, "covered": 1}, "branches": {"count": 1, "covered": 1}}}]},
+ {"data": [{"totals": {"lines": {"count": 1, "covered": 2}, "branches": {"count": 1, "covered": 1}}}]},
+ )
+ for document in malformed:
+ with self.subTest(document=document), self.assertRaisesRegex(CoverageError, "malformed"):
+ require_full_coverage(document)
+
+ def test_cli_reads_json_without_a_threshold_override(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ report = Path(temporary) / "coverage.json"
+ report.write_text(json.dumps(self.report()), encoding="utf-8")
+ self.assertEqual(coverage_main(("gate", str(report))), 0)
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ coverage_main(("gate", str(report), "99"))
+
+ with (
+ mock.patch.object(sys, "argv", ["cpp_coverage.py", "gate", str(report)]),
+ self.assertWarnsRegex(RuntimeWarning, "core.cpp_coverage"),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("core.cpp_coverage", run_name="__main__")
+ self.assertEqual(raised.exception.code, 0)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_doctor.py b/build/tests/test_doctor.py
new file mode 100644
index 0000000..fd00c33
--- /dev/null
+++ b/build/tests/test_doctor.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from contextlib import redirect_stdout
+from io import StringIO
+from pathlib import Path
+import runpy
+from types import SimpleNamespace
+import sys
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+import core.doctor as doctor # noqa: E402
+
+
+TOOLCHAIN = SimpleNamespace(identity=(
+ ("msbuild_version", "17.14"), ("vc_tools_version", "14.44"),
+ ("clang_tidy_version", "19.1"), ("windows_sdk_version", "10.0"),
+))
+SOURCE = SimpleNamespace(identity=(
+ ("pwsh_version", "7.5"), ("clang_format_version", "19.1"),
+ ("cppcheck_version", "2.18"), ("psscriptanalyzer_version", "1.24"),
+))
+QUALITY = object()
+RUNTIMES = object()
+
+
+class DoctorTests(unittest.TestCase):
+ def patches(self) -> tuple[mock._patch, ...]:
+ return (
+ mock.patch.object(doctor, "discover_msvc_toolchain", return_value=TOOLCHAIN),
+ mock.patch.object(doctor, "discover_source_tools", return_value=SOURCE),
+ mock.patch.object(doctor, "discover_quality_tools", return_value=QUALITY),
+ mock.patch.object(doctor, "resolve_sanitizer_runtimes", return_value=RUNTIMES),
+ )
+
+ def test_complete_report_is_deterministic_and_main_renders_plain_tsv(self) -> None:
+ with self.patches()[0] as msvc, self.patches()[1] as source, \
+ self.patches()[2] as quality, self.patches()[3] as runtimes:
+ report = doctor.doctor_report()
+ self.assertEqual(report, doctor.doctor_report())
+ self.assertEqual(
+ report,
+ (
+ doctor.Probe("python", "OK", "3.14.6"),
+ doctor.Probe("msvc", "OK", "MSBuild=17.14, MSVC=14.44, LLVM=19.1, SDK=10.0"),
+ doctor.Probe("source-tools", "OK", "PowerShell=7.5, clang-format=19.1, Cppcheck=2.18, PSScriptAnalyzer=1.24"),
+ doctor.Probe("quality-tools", "OK", "clang-cl, clang-scan-deps, llvm-cov, llvm-profdata, dumpbin, BinSkim, UMDH"),
+ doctor.Probe("sanitizer-runtimes", "OK", "ASan x86/x64, UBSan x64"),
+ ),
+ )
+ self.assertEqual(msvc.call_count, 2)
+ self.assertEqual(source.call_args, mock.call(TOOLCHAIN))
+ self.assertEqual(quality.call_args, mock.call(TOOLCHAIN))
+ self.assertEqual(runtimes.call_args, mock.call(TOOLCHAIN))
+
+ output = StringIO()
+ with mock.patch.object(doctor, "doctor_report", return_value=report), redirect_stdout(output):
+ self.assertEqual(doctor.main(()), 0)
+ self.assertEqual(
+ output.getvalue(),
+ "probe\tstatus\tdetail\n" + "".join(
+ f"{item.name}\t{item.status}\t{item.detail}\n" for item in report
+ ),
+ )
+
+ def test_failures_are_concise_independent_and_make_main_fail(self) -> None:
+ failing_quality = RuntimeError("quality\n missing")
+ with (
+ mock.patch.object(doctor, "discover_msvc_toolchain", return_value=TOOLCHAIN),
+ mock.patch.object(doctor, "discover_source_tools", return_value=SOURCE),
+ mock.patch.object(doctor, "discover_quality_tools", side_effect=failing_quality),
+ mock.patch.object(doctor, "resolve_sanitizer_runtimes", return_value=RUNTIMES),
+ ):
+ report = doctor.doctor_report()
+ self.assertEqual([item.status for item in report], ["OK", "OK", "OK", "MISSING", "OK"])
+ self.assertEqual(report[3].detail, "quality missing")
+ output = StringIO()
+ with mock.patch.object(doctor, "doctor_report", return_value=report), redirect_stdout(output):
+ self.assertEqual(doctor.main(()), 1)
+ self.assertIn("quality-tools\tMISSING\tquality missing\n", output.getvalue())
+
+ def test_missing_python_and_msvc_still_report_every_probe(self) -> None:
+ missing = RuntimeError()
+ with (
+ mock.patch.object(doctor.sys, "version_info", (3, 14, 5)),
+ mock.patch.object(doctor, "discover_msvc_toolchain", side_effect=missing),
+ mock.patch.object(doctor, "discover_source_tools") as source,
+ mock.patch.object(doctor, "discover_quality_tools") as quality,
+ mock.patch.object(doctor, "resolve_sanitizer_runtimes") as runtimes,
+ ):
+ report = doctor.doctor_report()
+ self.assertEqual([item.status for item in report], ["MISSING"] * 5)
+ self.assertIn("requires ==3.14.6, running 3.14.5", report[0].detail)
+ self.assertEqual(report[1].detail, "RuntimeError")
+ self.assertTrue(all(item.detail == "MSVC toolchain unavailable" for item in report[2:]))
+ source.assert_not_called()
+ quality.assert_not_called()
+ runtimes.assert_not_called()
+
+ def test_module_entry_point_exits_with_main_result(self) -> None:
+ patches = (
+ mock.patch("core.toolchain.discover_msvc_toolchain", return_value=TOOLCHAIN),
+ mock.patch("core.source_tools.discover_source_tools", return_value=SOURCE),
+ mock.patch("core.quality_tools.discover_quality_tools", return_value=QUALITY),
+ mock.patch("core.quality_tools.resolve_sanitizer_runtimes", return_value=RUNTIMES),
+ mock.patch.object(sys, "argv", ["doctor.py"]),
+ )
+ with patches[0], patches[1], patches[2], patches[3], patches[4], \
+ redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised:
+ runpy.run_path(str(BUILD_ROOT / "core/doctor.py"), run_name="__main__")
+ self.assertEqual(raised.exception.code, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_driver.py b/build/tests/test_driver.py
new file mode 100644
index 0000000..913f588
--- /dev/null
+++ b/build/tests/test_driver.py
@@ -0,0 +1,883 @@
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from types import SimpleNamespace
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import Command, Graph, GraphError, Node, Result # noqa: E402
+from core.quality_tools import ResolvedDirectory, ResolvedTool # noqa: E402
+import driver # noqa: E402
+
+
+MODULES = ("renpy", "rpgmaker", "zanzarah")
+
+
+def node(
+ name: str, *, inputs: tuple[str, ...] = (), results: tuple[Result, ...] = ()
+) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "slot",
+ Command(("true",)),
+ inputs,
+ results,
+ )
+
+
+def discovery_graph(architectures: tuple[str, ...]) -> Graph:
+ restores = tuple(node(f"restore-vcpkg-{architecture}") for architecture in architectures)
+ discovered = tuple(
+ node(f"discover-{architecture}", inputs=(restore.name,))
+ for architecture, restore in zip(architectures, restores, strict=True)
+ )
+ return Graph(restores + discovered, tuple(item.name for item in discovered), {"restore": 1, "slot": 4})
+
+
+def native_result(_repository: Path, _toolchain: object, **options: object) -> Graph:
+ discovery = options["discovery"]
+ architectures = options["architectures"]
+ configurations = options["configurations"]
+ builds = tuple(
+ node(f"build-{project}-{architecture}-{configuration.lower()}")
+ for architecture in architectures
+ for configuration in configurations
+ for project in (*MODULES, "tests")
+ )
+ if options["include_leak_probe"]:
+ builds += (node("build-leak-probe-x64-release"),)
+ return Graph(discovery.nodes + builds, tuple(item.name for item in builds), {"restore": 1, "slot": 4})
+
+
+class FakeRuntime:
+ def __init__(self, repository: Path, run_id: str) -> None:
+ self.repository = repository
+ self.run_id = run_id
+ self.session_active = False
+ self.executed: list[Graph] = []
+ self.store = SimpleNamespace(
+ paths_for=lambda current: SimpleNamespace(
+ output=repository / "out/cas" / current.name / "out"
+ )
+ )
+
+ def executor(self, graph: Graph) -> FakeRuntime:
+ self.executed.append(graph)
+ return self
+
+ async def run(self) -> None:
+ return None
+
+ def session(self) -> FakeRuntime:
+ return self
+
+ async def __aenter__(self) -> FakeRuntime:
+ self.session_active = True
+ return self
+
+ async def __aexit__(self, *_exc: object) -> None:
+ self.session_active = False
+
+ @property
+ def owned_run_id(self) -> str | None:
+ return self.run_id if self.session_active else None
+
+ def cache_report(self, _graph: Graph | None = None) -> dict[str, object]:
+ return {
+ "schema": 1,
+ "summary": {"executed": 0, "failed": 0, "hit": 0, "incomplete": 0},
+ "nodes": [],
+ }
+
+ def live_uids(self, graph: Graph) -> tuple[str, ...]:
+ return tuple(sorted(current.uid for current in graph.nodes))
+
+
+class DriverTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ self.temporary = tempfile.TemporaryDirectory()
+ self.repository = Path(self.temporary.name) / "repo"
+ self.repository.mkdir()
+ self.toolchain = SimpleNamespace(identity=(), environment=())
+ self.runtime_patch = mock.patch.object(driver, "BuildRuntime", FakeRuntime)
+ self.runtime_patch.start()
+
+ def tearDown(self) -> None:
+ self.runtime_patch.stop()
+ self.temporary.cleanup()
+
+ def session(
+ self, jobs: int | None = 4, *, prune_cas: bool = False
+ ) -> driver.Driver:
+ return driver.Driver(
+ self.repository, "run-1", self.toolchain,
+ jobs=jobs, prune_cas=prune_cas,
+ )
+
+ def tool(self, name: str) -> ResolvedTool:
+ path = self.repository / f"tools/{name}.exe"
+ return ResolvedTool(path, (("name", name),))
+
+ @mock.patch.object(driver.psutil, "cpu_count", return_value=None)
+ def test_session_owns_one_canonical_repository_runtime_and_validates_jobs(
+ self, cpu_count: mock.Mock
+ ) -> None:
+ session = self.session(None)
+ self.assertEqual(session.repository, self.repository.resolve())
+ self.assertEqual(session.jobs, 1)
+ self.assertEqual(session.runtime.run_id, "run-1")
+ cpu_count.assert_called_once_with()
+ with self.assertRaisesRegex(ValueError, "positive"):
+ self.session(0)
+ with self.assertRaisesRegex(ValueError, "positive"):
+ self.session(True)
+
+ async def test_restore_build_test_source_analysis_and_fuzz_use_shared_staging(self) -> None:
+ session = self.session()
+ source_tools = object()
+ corpus = self.repository / "corpus"
+ corpus.mkdir()
+
+ def restore_node(_repository: Path, _toolchain: object, _factory: object,
+ architecture: str, *, flavor: str = "") -> Node:
+ suffix = f"-{flavor}" if flavor else ""
+ name = f"restore-vcpkg{suffix}-{architecture}"
+ return Node(
+ name,
+ hashlib.md5(f"{flavor}-{architecture}".encode(), usedforsecurity=False).hexdigest(),
+ "restore", Command(("true",)), (),
+ )
+
+ with (
+ mock.patch.object(driver, "restore_node", side_effect=restore_node) as restore,
+ mock.patch.object(
+ driver, "native_dependency_discovery_slice",
+ side_effect=lambda _repository, _toolchain, **options: discovery_graph(options["architectures"]),
+ ) as native_discovery,
+ mock.patch.object(driver, "native_graph", side_effect=native_result) as native,
+ mock.patch.object(driver, "load_dependency_manifests", return_value={"unit": b"{}"}) as manifests,
+ mock.patch.object(
+ driver, "source_checks_graph", return_value=Graph((node("source"),), ("source",), {"slot": 4})
+ ) as source,
+ mock.patch.object(
+ driver, "analysis_discovery_slice", return_value=discovery_graph(("x64",))
+ ) as analysis_discovery,
+ mock.patch.object(
+ driver, "analysis_slice", return_value=Graph((node("analysis"),), ("analysis",), {"slot": 4})
+ ) as analysis,
+ mock.patch.object(
+ driver, "fuzz_dependency_discovery_slice", return_value=discovery_graph(("x64",))
+ ) as fuzz_discovery,
+ mock.patch.object(
+ driver, "fuzz_graph", return_value=Graph((node("fuzz"),), ("fuzz",), {"slot": 2})
+ ) as fuzz,
+ mock.patch.object(driver, "require_runnable", side_effect=lambda value: value) as require,
+ ):
+ await session.restore(("x64",), ("", "asan"))
+ await session.build(("x64",), ("Debug", "Release"))
+ await session.test(("x64",), ("Debug",), test_shards=7, corpus=corpus, run_nonce="nonce")
+ await session.source_checks(("x64",), source_tools)
+ await session.compiler_analysis(("x64",))
+ await session.fuzz(
+ run_nonce="fuzz-run", seconds=9, fuzz_jobs=3,
+ targets=("pickle", "renpy"),
+ )
+
+ self.assertEqual(restore.call_count, 2)
+ self.assertEqual(
+ session.runtime.executed[0].targets,
+ ("restore-vcpkg-x64", "restore-vcpkg-asan-x64"),
+ )
+ self.assertEqual(native_discovery.call_count, 2)
+ self.assertEqual(manifests.call_count, 4)
+ self.assertEqual(native.call_args_list[0].kwargs["runnable_architectures"], ())
+ self.assertFalse(native.call_args_list[0].kwargs["include_leak_probe"])
+ self.assertEqual(native.call_args_list[1].kwargs["runnable_architectures"], ("x64",))
+ self.assertEqual(native.call_args_list[1].kwargs["test_shards"], 7)
+ self.assertEqual(native.call_args_list[1].kwargs["corpus"], corpus)
+ self.assertEqual(native.call_args_list[1].kwargs["run_nonce"], "nonce")
+ source.assert_called_once_with(self.repository, source_tools, jobs=4, architectures=("x64",))
+ analysis_discovery.assert_called_once()
+ self.assertEqual(analysis.call_args.kwargs["manifests"], {"unit": b"{}"})
+ fuzz_discovery.assert_called_once()
+ self.assertEqual(fuzz.call_args.kwargs["run_nonce"], "fuzz-run")
+ self.assertEqual(fuzz.call_args.kwargs["seconds"], 9)
+ self.assertEqual(fuzz.call_args.kwargs["fuzz_jobs"], 3)
+ self.assertEqual(fuzz_discovery.call_args.kwargs["targets"], ("pickle", "renpy"))
+ self.assertEqual(fuzz.call_args.kwargs["targets"], ("pickle", "renpy"))
+ self.assertEqual(require.call_args_list, [mock.call(("x64",)), mock.call(("x64",))])
+
+ async def test_coverage_sanitizers_and_python_coverage_use_staged_graph_apis(self) -> None:
+ session = self.session()
+ corpus = self.repository / "corpus"
+ corpus.mkdir()
+ asan_tools = tuple((architecture, self.tool(f"asan-{architecture}"))
+ for architecture in ("x86", "x64"))
+ ubsan_file = self.tool("ubsan")
+ ubsan = ResolvedDirectory(self.repository / "ubsan", (ubsan_file,))
+
+ with (
+ mock.patch.object(driver, "coverage_dependency_discovery_slice",
+ return_value=discovery_graph(("x64",))) as coverage_discovery,
+ mock.patch.object(driver, "coverage_graph",
+ return_value=Graph((node("coverage"),), ("coverage",), {"slot": 4})) as coverage,
+ mock.patch.object(driver, "sanitizer_dependency_discovery_slice",
+ side_effect=lambda *_args, **_kwargs: discovery_graph(("x64",))) as sanitizer_discovery,
+ mock.patch.object(driver, "sanitizer_graph",
+ side_effect=lambda *_args, **kwargs: Graph(
+ (node(kwargs["selections"][0][0]),),
+ (kwargs["selections"][0][0],), {"slot": 4}
+ )) as sanitizer,
+ mock.patch.object(driver, "python_coverage_graph",
+ return_value=Graph((node("python"),), ("python",), {"slot": 1})) as python_graph,
+ mock.patch.object(driver, "resolve_asan_runtimes", return_value=asan_tools) as resolve_asan,
+ mock.patch.object(driver, "resolve_ubsan_runtime", return_value=ubsan) as resolve_ubsan,
+ mock.patch.object(driver, "load_dependency_manifests", return_value={"tu": b"{}"}) as manifests,
+ mock.patch.object(driver, "require_runnable", side_effect=lambda value: value) as require,
+ ):
+ await session.test_coverage(
+ ("x64",), test_shards=7, report_jobs=3,
+ corpus=corpus, run_nonce="coverage-run",
+ )
+ await session.test_asan(("x86", "x64"), test_shards=5)
+ await session.test_ubsan(("x64",), test_shards=6)
+ await session.python_coverage()
+
+ coverage_discovery.assert_called_once_with(
+ self.repository, self.toolchain, jobs=4, architectures=("x64",)
+ )
+ self.assertEqual(coverage.call_args.kwargs["manifests"], {"tu": b"{}"})
+ self.assertEqual(coverage.call_args.kwargs["test_shards"], 7)
+ self.assertEqual(coverage.call_args.kwargs["report_jobs"], 3)
+ self.assertEqual(coverage.call_args.kwargs["corpus"], corpus)
+ self.assertEqual(coverage.call_args.kwargs["run_nonce"], "coverage-run")
+ self.assertEqual(sanitizer_discovery.call_count, 2)
+ self.assertEqual(sanitizer.call_count, 2)
+ asan_call, ubsan_call = sanitizer.call_args_list
+ self.assertEqual(asan_call.kwargs["selections"], (("asan", "x86"), ("asan", "x64")))
+ self.assertEqual(tuple(item.architecture for item in asan_call.kwargs["asan_runtimes"]),
+ ("x86", "x64"))
+ self.assertIsNone(asan_call.kwargs["llvm_runtime"])
+ self.assertEqual(ubsan_call.kwargs["selections"], (("ubsan", "x64"),))
+ self.assertEqual(ubsan_call.kwargs["llvm_runtime"], ubsan.path)
+ self.assertEqual(ubsan_call.kwargs["llvm_runtime_identity"], dict(ubsan.identity))
+ resolve_asan.assert_called_once_with(self.toolchain, ("x86", "x64"))
+ resolve_ubsan.assert_called_once_with(self.toolchain)
+ self.assertEqual(manifests.call_count, 3)
+ self.assertEqual(require.call_args_list,
+ [mock.call(("x64",)), mock.call(("x86", "x64")), mock.call(("x64",))])
+ python_graph.assert_called_once_with(self.repository)
+
+ async def test_quality_commands_reject_invalid_contracts_before_discovery(self) -> None:
+ session = self.session()
+ corpus = self.repository / "corpus"
+ corpus.mkdir()
+ with (
+ mock.patch.object(driver, "coverage_dependency_discovery_slice") as coverage_discovery,
+ mock.patch.object(driver, "sanitizer_dependency_discovery_slice") as sanitizer_discovery,
+ ):
+ for invalid_nonce in ("", "bad\0nonce", 7):
+ with self.subTest(run_nonce=invalid_nonce), self.assertRaisesRegex(
+ ValueError, "corpus run nonce"
+ ):
+ await session.test_coverage(corpus=corpus, run_nonce=invalid_nonce)
+ with mock.patch.object(
+ driver, "require_runnable", side_effect=RuntimeError("cannot run")
+ ), self.assertRaisesRegex(RuntimeError, "cannot run"):
+ await session.test_coverage(("x64",))
+ with self.assertRaisesRegex(ValueError, "ASan does not support arm64"):
+ await session.test_asan(("arm64",))
+ with self.assertRaisesRegex(ValueError, "UBSan does not support x86"):
+ await session.test_ubsan(("x86",))
+ with self.assertRaisesRegex(ValueError, "exactly one architecture"):
+ await session.verify_arch(("x86", "x64"), run_nonce="verify-run")
+ coverage_discovery.assert_not_called()
+ sanitizer_discovery.assert_not_called()
+
+ async def test_release_audit_package_and_leaks_pass_only_canonical_axes(self) -> None:
+ session = self.session()
+ dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh"))
+
+ def native_discovery(_repository: Path, _toolchain: object, **options: object) -> Graph:
+ return discovery_graph(options["architectures"])
+
+ def audit_result(_repository: Path, upstream: Graph, **options: object) -> Graph:
+ modules = (("leak-probe",) if options["include_leak_probe"] else ()) + MODULES
+ gates = tuple(
+ node(
+ f"audit-pe-{architecture}-{module}",
+ inputs=(f"build-{module}-{architecture}-release",),
+ )
+ for architecture in options["architectures"]
+ for module in modules
+ )
+ return Graph(upstream.nodes + gates, tuple(item.name for item in gates), upstream.pools | {"audit": 4})
+
+ package_result = Graph((node("package-manifest"),), ("package-manifest",), {"slot": 4})
+ leak_result = Graph((node("leak"),), ("leak",), {"slot": 4})
+ expected_packages = (self.repository / "one.zip",)
+ with (
+ mock.patch.object(driver, "native_dependency_discovery_slice", side_effect=native_discovery),
+ mock.patch.object(driver, "native_graph", side_effect=native_result) as native,
+ mock.patch.object(driver, "load_dependency_manifests", return_value={}),
+ mock.patch.object(driver, "audit_graph", side_effect=audit_result) as audit,
+ mock.patch.object(driver, "package_graph", return_value=package_result) as package,
+ mock.patch.object(driver, "package_outputs", return_value=expected_packages) as outputs,
+ mock.patch.object(driver, "leak_graph", return_value=leak_result) as leak,
+ mock.patch.object(driver, "runnable_architectures", return_value=("x86", "x64")) as runnable,
+ mock.patch.object(driver, "require_runnable", return_value=("x64",)) as require,
+ ):
+ await session.audit(("x64",), dumpbin=dumpbin, binskim=binskim)
+ result = await session.package(
+ ("x86", "x64", "arm64"), dumpbin=dumpbin, binskim=binskim
+ )
+ await session.test_leaks(
+ run_nonce="leak-run", dumpbin=dumpbin, binskim=binskim, umdh=umdh,
+ warmup=2, iterations=3, windows=4, tolerance_bytes=5,
+ )
+
+ self.assertEqual(result, expected_packages)
+ self.assertEqual(native.call_count, 3)
+ self.assertTrue(all(call.kwargs["configurations"] == ("Release",) for call in native.call_args_list))
+ self.assertEqual([call.kwargs["include_leak_probe"] for call in native.call_args_list], [False, False, True])
+ self.assertTrue(all(call.kwargs["runnable_architectures"] == () for call in native.call_args_list))
+ self.assertEqual(audit.call_count, 3)
+ self.assertTrue(all(len(call.args) == 2 for call in audit.call_args_list))
+ self.assertEqual(
+ [call.kwargs["architectures"] for call in audit.call_args_list],
+ [("x64",), ("x86", "x64", "arm64"), ("x64",)],
+ )
+ self.assertEqual(
+ [call.kwargs["include_leak_probe"] for call in audit.call_args_list],
+ [False, False, True],
+ )
+ self.assertEqual(audit.call_args_list[0].kwargs["dumpbin_identity"], {"name": "dumpbin"})
+ self.assertEqual(audit.call_args_list[0].kwargs["binskim_identity"], {"name": "binskim"})
+ self.assertTrue(all(call.kwargs["jobs"] == 4 for call in audit.call_args_list))
+ self.assertTrue(all("binskim_jobs" not in call.kwargs for call in audit.call_args_list))
+ self.assertEqual(package.call_args.kwargs["architectures"], ("x86", "x64", "arm64"))
+ self.assertEqual(package.call_args.kwargs["smoke_architectures"], ("x86", "x64"))
+ runnable.assert_called_once_with(("x86", "x64", "arm64"))
+ outputs.assert_called_once_with(self.repository, package_result)
+ self.assertEqual(len(leak.call_args.args), 2)
+ self.assertEqual(leak.call_args.kwargs["umdh"], umdh.path)
+ self.assertEqual(leak.call_args.kwargs["umdh_identity"], {"name": "umdh"})
+ self.assertEqual(leak.call_args.kwargs["run_nonce"], "leak-run")
+ self.assertEqual(leak.call_args.kwargs["warmup"], 2)
+ self.assertEqual(leak.call_args.kwargs["iterations"], 3)
+ self.assertEqual(leak.call_args.kwargs["windows"], 4)
+ self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 5)
+ self.assertEqual(leak.call_args.kwargs["jobs"], 4)
+ self.assertNotIn("session_jobs", leak.call_args.kwargs)
+ self.assertNotIn("diff_jobs", leak.call_args.kwargs)
+ require.assert_called_once_with(("x64",))
+
+ async def test_verify_runs_one_discovery_union_then_one_parallel_family_union(self) -> None:
+ session = self.session()
+ corpus = self.repository / "corpus"
+ corpus.mkdir()
+ dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh"))
+ ubsan = ResolvedDirectory(self.repository / "ubsan", (self.tool("ubsan"),))
+ route = SimpleNamespace(
+ runnable=("x86", "x64"), coverage=("x64",), asan=("x86", "x64"),
+ ubsan=("x64",), run_x64_specialists=True,
+ )
+
+ def discovered(_repository: Path, _toolchain: object, **options: object) -> Graph:
+ architectures = options.get("architectures", ("x64",))
+ return discovery_graph(architectures)
+
+ representative_results = {
+ "native": Result(
+ "reports/tests/x64/debug/unit/shard-0.xml",
+ "test", "application/xml", "tests.xml",
+ ),
+ "analysis": Result(
+ "reports/sarif/x64/analysis.sarif",
+ "report", "application/sarif+json", "analysis.sarif",
+ ),
+ "source": Result(
+ "reports/sarif/source/analysis.sarif",
+ "report", "application/sarif+json", "analysis.sarif",
+ ),
+ "cppcheck": Result(
+ "reports/sarif/x86-x64-arm64/cppcheck.sarif",
+ "report", "application/sarif+json", "analysis.sarif",
+ ),
+ "python": Result(
+ "reports/coverage/python/coverage.json",
+ "coverage", "application/json", "coverage.json",
+ ),
+ "coverage": Result(
+ "reports/coverage/cpp/x64/coverage.json",
+ "coverage", "application/json", "coverage.json",
+ ),
+ "sanitizer": Result(
+ "reports/sanitizers/asan/x64/shard-0.xml",
+ "test", "application/xml", "tests.xml",
+ ),
+ "fuzz": Result(
+ "reports/fuzz/x64/pickle/status.txt",
+ "fuzz", "text/plain", "status.txt",
+ ),
+ "audit": Result(
+ "reports/sarif/x64/binskim-renpy.sarif",
+ "report", "application/sarif+json", "binskim.sarif",
+ ),
+ "package": Result(
+ "packages/x64/renpy-x64-dll.zip",
+ "package", "application/zip", "renpy-x64-dll.zip",
+ ),
+ "leak": Result(
+ "reports/leak/x64/operations/small-success/summary.json",
+ "leak-summary", "application/json", "summary.json",
+ ),
+ }
+
+ def family(name: str, upstream: Graph | None = None) -> Graph:
+ pools = dict(upstream.pools) if upstream else {"slot": 4}
+ nodes = upstream.nodes if upstream else ()
+ marker = node(name, results=(representative_results[name],))
+ return Graph(nodes + (marker,), (marker.name,), pools)
+
+ def native_with_result(
+ repository: Path, toolchain: object, **options: object
+ ) -> Graph:
+ graph = native_result(repository, toolchain, **options)
+ marker = node("native-results", results=(representative_results["native"],))
+ return Graph(graph.nodes + (marker,), graph.targets + (marker.name,), graph.pools)
+
+ source_tools = mock.Mock(return_value="source-tools")
+ asan = mock.Mock(return_value=(
+ ("x86", self.tool("asan-x86")), ("x64", self.tool("asan-x64")),
+ ))
+ resolve_ubsan = mock.Mock(return_value=ubsan)
+ native = mock.Mock(side_effect=native_with_result)
+ common_source = mock.Mock(return_value=family("source"))
+ architecture_source = mock.Mock(return_value=family("cppcheck"))
+ coverage = mock.Mock(return_value=family("coverage"))
+ sanitizer = mock.Mock(return_value=family("sanitizer"))
+ fuzz = mock.Mock(return_value=family("fuzz"))
+ package = mock.Mock(
+ side_effect=lambda _repo, upstream, *_args, **_kwargs: family("package", upstream)
+ )
+ leak = mock.Mock(
+ side_effect=lambda _repo, upstream, *_args, **_kwargs: family("leak", upstream)
+ )
+ with mock.patch.multiple(
+ driver,
+ verify_route=mock.Mock(return_value=route),
+ discover_source_tools=source_tools,
+ resolve_dumpbin=mock.Mock(return_value=dumpbin),
+ resolve_binskim=mock.Mock(return_value=binskim),
+ resolve_umdh=mock.Mock(return_value=umdh),
+ resolve_asan_runtimes=asan,
+ resolve_ubsan_runtime=resolve_ubsan,
+ analysis_discovery_slice=mock.Mock(side_effect=discovered),
+ native_dependency_discovery_slice=mock.Mock(side_effect=discovered),
+ coverage_dependency_discovery_slice=mock.Mock(side_effect=discovered),
+ sanitizer_dependency_discovery_slice=mock.Mock(side_effect=discovered),
+ fuzz_dependency_discovery_slice=mock.Mock(side_effect=discovered),
+ load_dependency_manifests=mock.Mock(return_value={"tu": b"{}"}),
+ native_graph=native,
+ analysis_slice=mock.Mock(return_value=family("analysis")),
+ common_source_checks=common_source,
+ architecture_source_checks=architecture_source,
+ python_coverage_graph=mock.Mock(return_value=family("python")),
+ coverage_graph=coverage,
+ sanitizer_graph=sanitizer,
+ fuzz_graph=fuzz,
+ audit_graph=mock.Mock(
+ side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream)
+ ),
+ package_graph=package,
+ leak_graph=leak,
+ ):
+ await session.verify(
+ ("x86", "x64", "arm64"), corpus=corpus, run_nonce="verify-run",
+ fuzz_seconds=7, test_shards=5, warmup=2, iterations=3,
+ windows=4, tolerance_bytes=6,
+ )
+
+ self.assertEqual(len(session.runtime.executed), 2)
+ final = session.runtime.executed[-1]
+ self.assertTrue({"analysis", "source", "cppcheck", "python", "coverage", "sanitizer", "fuzz",
+ "package", "leak"}.issubset(final.targets))
+ self.assertEqual(
+ {result.id for current in final.nodes for result in current.results},
+ {result.id for result in representative_results.values()},
+ )
+ for result in representative_results.values():
+ self.assertEqual(final.result(result.id)[1], result)
+ self.assertEqual(native.call_args.kwargs["configurations"], ("Debug", "Release"))
+ self.assertEqual(native.call_args.kwargs["runnable_architectures"], ("x86", "x64"))
+ self.assertTrue(native.call_args.kwargs["include_leak_probe"])
+ self.assertEqual(coverage.call_args.kwargs["corpus"], corpus)
+ self.assertEqual(coverage.call_args.kwargs["run_nonce"], "verify-run")
+ self.assertEqual(sanitizer.call_args.kwargs["selections"], (
+ ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64"),
+ ))
+ self.assertEqual(fuzz.call_args.kwargs["seconds"], 7)
+ self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 6)
+ self.assertEqual(
+ package.call_args.kwargs["smoke_architectures"],
+ ("x86", "x64"),
+ )
+ source_tools.assert_called_once_with(self.toolchain)
+ common_source.assert_called_once()
+ architecture_source.assert_called_once()
+ asan.assert_called_once_with(self.toolchain, ("x86", "x64"))
+ resolve_ubsan.assert_called_once_with(self.toolchain)
+
+ async def test_verify_source_rejects_duplicate_result_ids_across_families(self) -> None:
+ session = self.session()
+ duplicate = Result(
+ "reports/sarif/source/analysis.sarif",
+ "report", "application/sarif+json", "analysis.sarif",
+ )
+ common = Graph(
+ (node("source", results=(duplicate,)),), ("source",), {"slot": 4}
+ )
+ python = Graph(
+ (node("python", results=(duplicate,)),), ("python",), {"slot": 4}
+ )
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=common),
+ mock.patch.object(driver, "python_coverage_graph", return_value=python),
+ self.assertRaisesRegex(
+ GraphError,
+ "duplicate result id: reports/sarif/source/analysis[.]sarif",
+ ),
+ ):
+ await session.verify_source()
+
+ self.assertEqual(session.runtime.executed, [])
+
+ async def test_verify_source_runs_only_common_source_and_python_coverage(self) -> None:
+ session = self.session()
+ common = Graph((node("source"),), ("source",), {"slot": 4})
+ python = Graph((node("python"),), ("python",), {"slot": 4})
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools") as tools,
+ mock.patch.object(driver, "common_source_checks", return_value=common) as source,
+ mock.patch.object(driver, "python_coverage_graph", return_value=python) as coverage,
+ mock.patch.object(driver, "export_results") as export,
+ ):
+ await session.verify_source(export_dir=self.repository / "evidence")
+
+ self.assertEqual(len(session.runtime.executed), 1)
+ self.assertEqual(set(session.runtime.executed[0].targets), {"source", "python"})
+ tools.assert_called_once_with(self.toolchain)
+ source.assert_called_once_with(self.repository, "tools", jobs=4)
+ coverage.assert_called_once_with(self.repository)
+ export.assert_called_once_with(
+ session.runtime.executed[0], session.runtime.store,
+ self.repository / "evidence", "verify-source", "success", failures=(),
+ cache=session.runtime.cache_report(session.runtime.executed[0]),
+ )
+
+ async def test_enabled_cas_sweep_runs_before_successful_result_export(self) -> None:
+ session = self.session(prune_cas=True)
+ common = Graph((node("source"),), ("source",), {"slot": 4})
+ python = Graph((node("python"),), ("python",), {"slot": 4})
+ events: list[str] = []
+
+ def record(event: str) -> None:
+ self.assertTrue(session.runtime.session_active)
+ events.append(event)
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=common),
+ mock.patch.object(driver, "python_coverage_graph", return_value=python),
+ mock.patch.object(
+ driver, "export_results", side_effect=lambda *_args, **_kwargs: record("export")
+ ),
+ mock.patch.object(
+ driver, "sweep_cas", side_effect=lambda *_args, **_kwargs: record("sweep")
+ ) as sweep,
+ ):
+ await session.verify_source(export_dir=self.repository / "evidence")
+
+ self.assertEqual(events, ["sweep", "export"])
+ self.assertFalse(session.runtime.session_active)
+ graph = session.runtime.executed[0]
+ sweep.assert_called_once_with(
+ self.repository, session.runtime.live_uids(graph), owned_run_id="run-1"
+ )
+
+ async def test_cas_sweep_failure_exports_failed_status_before_reraising(self) -> None:
+ session = self.session(prune_cas=True)
+ common = Graph((node("source"),), ("source",), {"slot": 4})
+ python = Graph((node("python"),), ("python",), {"slot": 4})
+ maintenance = RuntimeError("prune failed")
+ events: list[str] = []
+
+ def fail_sweep(*_args, **_kwargs) -> None:
+ events.append("sweep")
+ raise maintenance
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=common),
+ mock.patch.object(driver, "python_coverage_graph", return_value=python),
+ mock.patch.object(driver, "sweep_cas", side_effect=fail_sweep),
+ mock.patch.object(
+ driver, "export_results",
+ side_effect=lambda *_args, **_kwargs: events.append("export"),
+ ) as export,
+ self.assertRaises(RuntimeError) as raised,
+ ):
+ await session.verify_source(export_dir=self.repository / "failed-evidence")
+
+ self.assertIs(raised.exception, maintenance)
+ self.assertEqual(events, ["sweep", "export"])
+ self.assertEqual(export.call_args.args[4], "failed")
+ self.assertEqual(export.call_args.kwargs["failures"], ())
+
+ async def test_cas_sweep_failure_without_export_reraises_inside_session(self) -> None:
+ session = self.session(prune_cas=True)
+ common = Graph((node("source"),), ("source",), {"slot": 4})
+ python = Graph((node("python"),), ("python",), {"slot": 4})
+ maintenance = RuntimeError("prune failed")
+
+ def fail_sweep(*_args, **_kwargs) -> None:
+ self.assertTrue(session.runtime.session_active)
+ raise maintenance
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=common),
+ mock.patch.object(driver, "python_coverage_graph", return_value=python),
+ mock.patch.object(driver, "sweep_cas", side_effect=fail_sweep),
+ self.assertRaises(RuntimeError) as raised,
+ ):
+ await session.verify_source()
+
+ self.assertIs(raised.exception, maintenance)
+ self.assertFalse(session.runtime.session_active)
+
+ async def test_cas_sweep_and_failed_export_preserve_both_errors(self) -> None:
+ session = self.session(prune_cas=True)
+ common = Graph((node("source"),), ("source",), {"slot": 4})
+ python = Graph((node("python"),), ("python",), {"slot": 4})
+ maintenance = RuntimeError("prune failed")
+ export_failure = RuntimeError("export failed")
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=common),
+ mock.patch.object(driver, "python_coverage_graph", return_value=python),
+ mock.patch.object(driver, "sweep_cas", side_effect=maintenance),
+ mock.patch.object(driver, "export_results", side_effect=export_failure),
+ self.assertRaises(ExceptionGroup) as raised,
+ ):
+ await session.verify_source(export_dir=self.repository / "failed-evidence")
+
+ self.assertIs(raised.exception.exceptions[0], maintenance)
+ self.assertIs(raised.exception.exceptions[1], export_failure)
+ self.assertFalse(session.runtime.session_active)
+
+ async def test_failed_public_command_exports_then_sweeps_before_reraising(self) -> None:
+ session = self.session(prune_cas=True)
+ common = Graph((node("source"),), ("source",), {"slot": 4})
+ python = Graph((node("python"),), ("python",), {"slot": 4})
+ failure = ExceptionGroup("failed", (RuntimeError("expected"),))
+ failure.failed_nodes = ("source",)
+ live_uids = (common.nodes[0].uid,)
+ events: list[str] = []
+
+ async def fail() -> None:
+ raise failure
+
+ def live(_graph: Graph) -> tuple[str, ...]:
+ events.append("live")
+ return live_uids
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=common),
+ mock.patch.object(driver, "python_coverage_graph", return_value=python),
+ mock.patch.object(session.runtime, "run", side_effect=fail),
+ mock.patch.object(
+ driver, "export_results",
+ side_effect=lambda *_args, **_kwargs: events.append("export"),
+ ) as export,
+ mock.patch.object(session.runtime, "live_uids", side_effect=live),
+ mock.patch.object(
+ driver, "sweep_cas",
+ side_effect=lambda *_args, **_kwargs: events.append("sweep"),
+ ) as sweep,
+ self.assertRaises(ExceptionGroup) as raised,
+ ):
+ await session.verify_source(export_dir=self.repository / "failed-evidence")
+
+ self.assertIs(raised.exception, failure)
+ self.assertEqual(events, ["export", "live", "sweep"])
+ graph = session.runtime.executed[0]
+ export.assert_called_once_with(
+ graph, session.runtime.store, self.repository / "failed-evidence",
+ "verify-source", "failed", failures=("source",),
+ cache=session.runtime.cache_report(graph),
+ )
+ sweep.assert_called_once_with(
+ self.repository, live_uids, owned_run_id="run-1"
+ )
+
+ async def test_export_failure_preserves_the_original_execution_failure(self) -> None:
+ session = self.session()
+ graph = Graph((node("source"),), ("source",), {"slot": 4})
+ execution = ExceptionGroup("failed", (RuntimeError("execution"),))
+ execution.failed_nodes = ("source",)
+
+ async def fail() -> None:
+ raise execution
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=graph),
+ mock.patch.object(
+ driver, "python_coverage_graph",
+ return_value=Graph((node("python"),), ("python",), {"slot": 4}),
+ ),
+ mock.patch.object(session.runtime, "run", side_effect=fail),
+ mock.patch.object(driver, "export_results", side_effect=RuntimeError("export")),
+ self.assertRaises(ExceptionGroup) as raised,
+ ):
+ await session.verify_source(export_dir=self.repository / "failed-evidence")
+
+ self.assertIs(raised.exception.exceptions[0], execution)
+ self.assertRegex(str(raised.exception.exceptions[1]), "export")
+
+ async def test_failed_command_and_cas_sweep_failure_preserve_both_errors(self) -> None:
+ session = self.session(prune_cas=True)
+ graph = Graph((node("source"),), ("source",), {"slot": 4})
+ execution = ExceptionGroup("failed", (RuntimeError("execution"),))
+ execution.failed_nodes = ("source",)
+ maintenance = RuntimeError("prune")
+
+ async def fail() -> None:
+ raise execution
+
+ with (
+ mock.patch.object(driver, "discover_source_tools", return_value="tools"),
+ mock.patch.object(driver, "common_source_checks", return_value=graph),
+ mock.patch.object(
+ driver, "python_coverage_graph",
+ return_value=Graph((node("python"),), ("python",), {"slot": 4}),
+ ),
+ mock.patch.object(session.runtime, "run", side_effect=fail),
+ mock.patch.object(driver, "export_results"),
+ mock.patch.object(driver, "sweep_cas", side_effect=maintenance),
+ self.assertRaises(ExceptionGroup) as raised,
+ ):
+ await session.verify_source(export_dir=self.repository / "failed-evidence")
+
+ self.assertIs(raised.exception.exceptions[0], execution)
+ self.assertIs(raised.exception.exceptions[1], maintenance)
+ self.assertIn("cache pruning failed", str(raised.exception))
+
+ async def test_failure_before_graph_composition_does_not_publish_empty_evidence(self) -> None:
+ session = self.session()
+ with (
+ mock.patch.object(
+ driver, "discover_source_tools", side_effect=RuntimeError("discovery")
+ ),
+ mock.patch.object(driver, "export_results") as export,
+ self.assertRaisesRegex(RuntimeError, "discovery"),
+ ):
+ await session.verify_source(export_dir=self.repository / "evidence")
+ export.assert_not_called()
+
+ async def test_failure_after_discovery_export_does_not_prune_from_partial_graph(self) -> None:
+ session = self.session(prune_cas=True)
+ discovery = discovery_graph(("x64",))
+ composition = RuntimeError("manifest composition failed")
+
+ async def operation() -> tuple[Path, ...]:
+ await session._staged(
+ discovery,
+ mock.Mock(side_effect=composition),
+ )
+ self.fail("composition failure must propagate")
+
+ with (
+ mock.patch.object(driver, "load_dependency_manifests", return_value={}),
+ mock.patch.object(driver, "export_results") as export,
+ mock.patch.object(driver, "sweep_cas") as sweep,
+ self.assertRaises(RuntimeError) as raised,
+ ):
+ await session._public(
+ "verify-arch", self.repository / "failed-evidence", operation
+ )
+
+ self.assertIs(raised.exception, composition)
+ self.assertEqual(session.runtime.executed, [discovery])
+ self.assertEqual(export.call_args.args[4], "failed")
+ sweep.assert_not_called()
+
+ async def test_verify_arch_omits_common_and_nonrunnable_specialists(self) -> None:
+ session = self.session()
+ route = SimpleNamespace(
+ runnable=(), coverage=(), asan=(), ubsan=(), run_x64_specialists=False,
+ )
+ discovery = discovery_graph(("arm64",))
+
+ def family(name: str, upstream: Graph | None = None) -> Graph:
+ pools = dict(upstream.pools) if upstream else {"slot": 4}
+ nodes = upstream.nodes if upstream else ()
+ marker = node(name)
+ return Graph(nodes + (marker,), (marker.name,), pools)
+
+ unused = {
+ name: mock.Mock() for name in (
+ "resolve_umdh", "resolve_asan_runtimes", "resolve_ubsan_runtime",
+ "coverage_dependency_discovery_slice", "sanitizer_dependency_discovery_slice",
+ "fuzz_dependency_discovery_slice", "coverage_graph", "sanitizer_graph",
+ "fuzz_graph", "leak_graph", "common_source_checks", "python_coverage_graph",
+ )
+ }
+ native = mock.Mock(side_effect=native_result)
+ with mock.patch.multiple(
+ driver,
+ verify_route=mock.Mock(return_value=route),
+ discover_source_tools=mock.Mock(return_value="source-tools"),
+ resolve_dumpbin=mock.Mock(return_value=self.tool("dumpbin")),
+ resolve_binskim=mock.Mock(return_value=self.tool("binskim")),
+ analysis_discovery_slice=mock.Mock(return_value=discovery),
+ native_dependency_discovery_slice=mock.Mock(return_value=discovery),
+ load_dependency_manifests=mock.Mock(return_value={}),
+ native_graph=native,
+ analysis_slice=mock.Mock(return_value=family("analysis")),
+ architecture_source_checks=mock.Mock(return_value=family("cppcheck")),
+ audit_graph=mock.Mock(
+ side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream)
+ ),
+ package_graph=mock.Mock(
+ side_effect=lambda _repo, upstream, *_args, **_kwargs: family("package", upstream)
+ ),
+ **unused,
+ ):
+ await session.verify_arch(("arm64",), run_nonce="verify-run")
+
+ self.assertEqual(len(session.runtime.executed), 2)
+ self.assertTrue({"analysis", "cppcheck", "package"}.issubset(
+ session.runtime.executed[-1].targets
+ ))
+ self.assertFalse(native.call_args.kwargs["include_leak_probe"])
+ for current in unused.values():
+ current.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_execute.py b/build/tests/test_execute.py
new file mode 100644
index 0000000..777ade6
--- /dev/null
+++ b/build/tests/test_execute.py
@@ -0,0 +1,276 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager
+import hashlib
+from pathlib import Path
+import sys
+import unittest
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.execute import ExecutionError, Executor # noqa: E402
+from core.graph import Command, Graph, GraphError, Node # noqa: E402
+
+
+def node(name: str, *, inputs: tuple[str, ...] = (), pool: str = "cpu") -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ pool,
+ Command(("synthetic", name)),
+ inputs,
+ )
+
+
+class ExecutorTests(unittest.IsolatedAsyncioTestCase):
+ async def test_explicit_target_subset_returns_none_and_skips_other_targets(self) -> None:
+ graph = Graph(
+ (node("selected"), node("other")),
+ ("selected", "other"),
+ {"cpu": 2},
+ )
+ complete: set[str] = set()
+ calls: list[str] = []
+
+ async def runner(current: Node) -> None:
+ calls.append(current.name)
+
+ result = await Executor(
+ graph,
+ is_complete=lambda current: current.name in complete,
+ runner=runner,
+ publish=lambda current: complete.add(current.name),
+ ).run(("selected",))
+
+ self.assertIsNone(result)
+ self.assertEqual(calls, ["selected"])
+
+ with self.assertRaisesRegex(GraphError, "target.*empty"):
+ await Executor(
+ graph,
+ is_complete=lambda _current: False,
+ runner=runner,
+ publish=lambda _current: None,
+ ).run(())
+
+ async def test_demand_traversal_deduplicates_a_shared_dependency(self) -> None:
+ graph = Graph(
+ (
+ node("shared"),
+ node("left", inputs=("shared",)),
+ node("right", inputs=("shared",)),
+ node("unreachable"),
+ ),
+ ("left", "right"),
+ {"cpu": 4},
+ )
+ complete: set[str] = set()
+ calls: list[str] = []
+
+ async def runner(current: Node) -> None:
+ calls.append(current.name)
+ await asyncio.sleep(0.01)
+
+ await Executor(
+ graph,
+ is_complete=lambda current: current.name in complete,
+ runner=runner,
+ publish=lambda current: complete.add(current.name),
+ ).run()
+
+ self.assertEqual(calls.count("shared"), 1)
+ self.assertCountEqual(calls, ["shared", "left", "right"])
+ self.assertNotIn("unreachable", calls)
+
+ async def test_named_pool_limits_concurrency(self) -> None:
+ graph = Graph(
+ (node("one"), node("two"), node("three")),
+ ("one", "two", "three"),
+ {"cpu": 2},
+ )
+ complete: set[str] = set()
+ active = 0
+ maximum = 0
+
+ async def runner(_current: Node) -> None:
+ nonlocal active, maximum
+ active += 1
+ maximum = max(maximum, active)
+ try:
+ await asyncio.sleep(0.02)
+ finally:
+ active -= 1
+
+ await Executor(
+ graph,
+ is_complete=lambda current: current.name in complete,
+ runner=runner,
+ publish=lambda current: complete.add(current.name),
+ ).run()
+
+ self.assertEqual(maximum, 2)
+
+ async def test_shared_slot_pool_limits_total_cross_family_concurrency(self) -> None:
+ graph = Graph(
+ (node("compile", pool="slot"),) + tuple(
+ node(f"audit-{index}", pool="audit") for index in range(4)
+ ),
+ ("compile",) + tuple(f"audit-{index}" for index in range(4)),
+ {"slot": 2, "audit": 4},
+ )
+ complete: set[str] = set()
+ active = maximum = 0
+
+ async def runner(_current: Node) -> None:
+ nonlocal active, maximum
+ active += 1
+ maximum = max(maximum, active)
+ try:
+ await asyncio.sleep(0.02)
+ finally:
+ active -= 1
+
+ await Executor(
+ graph,
+ is_complete=lambda current: current.name in complete,
+ runner=runner,
+ publish=lambda current: complete.add(current.name),
+ ).run()
+
+ self.assertEqual(maximum, 2)
+
+ async def test_warm_target_cache_skips_dependencies(self) -> None:
+ graph = Graph(
+ (node("dependency"), node("target", inputs=("dependency",))),
+ ("target",),
+ {"cpu": 2},
+ )
+
+ async def runner(_current: Node) -> None:
+ raise AssertionError("warm target and its dependencies must not run")
+
+ await Executor(
+ graph,
+ is_complete=lambda current: current.name == "target",
+ runner=runner,
+ publish=lambda _current: None,
+ ).run()
+
+ async def test_external_lock_rechecks_cache_before_dependencies(self) -> None:
+ graph = Graph(
+ (node("dependency"), node("target", inputs=("dependency",))),
+ ("target",),
+ {"cpu": 1},
+ )
+ complete = False
+ events: list[str] = []
+
+ @asynccontextmanager
+ async def lock(current: Node):
+ nonlocal complete
+ events.append(f"enter:{current.name}")
+ complete = current.name == "target"
+ try:
+ yield
+ finally:
+ events.append(f"leave:{current.name}")
+
+ async def runner(_current: Node) -> None:
+ raise AssertionError("cache was completed while waiting for the lock")
+
+ await Executor(
+ graph,
+ is_complete=lambda current: complete and current.name == "target",
+ runner=runner,
+ publish=lambda _current: None,
+ acquire_lock=lock,
+ ).run()
+
+ self.assertEqual(events, ["enter:target", "leave:target"])
+
+ async def test_runner_must_publish_completion(self) -> None:
+ graph = Graph((node("broken"),), ("broken",), {"cpu": 1})
+
+ async def runner(_current: Node) -> None:
+ pass
+
+ with self.assertRaises(ExceptionGroup) as raised:
+ await Executor(
+ graph,
+ is_complete=lambda _current: False,
+ runner=runner,
+ publish=lambda _current: None,
+ ).run()
+ self.assertIn("broken", repr(raised.exception.subgroup(ExecutionError)))
+
+ async def test_failures_block_descendants_while_independent_successes_publish(self) -> None:
+ graph = Graph(
+ (
+ node("first-failure"),
+ node("blocked", inputs=("first-failure",)),
+ node("blocked-descendant", inputs=("blocked",)),
+ node("second-failure"),
+ node("independent"),
+ ),
+ ("blocked-descendant", "blocked", "first-failure", "second-failure", "independent"),
+ {"cpu": 3},
+ )
+ independent_started = asyncio.Event()
+ complete: set[str] = set()
+ calls: list[str] = []
+
+ async def runner(current: Node) -> None:
+ calls.append(current.name)
+ if current.name == "independent":
+ independent_started.set()
+ await asyncio.sleep(0.02)
+ return
+ await independent_started.wait()
+ if current.name == "first-failure":
+ raise ValueError("first expected failure")
+ if current.name == "second-failure":
+ raise RuntimeError("second expected failure")
+
+ with self.assertRaises(ExceptionGroup) as raised:
+ await Executor(
+ graph,
+ is_complete=lambda current: current.name in complete,
+ runner=runner,
+ publish=lambda current: complete.add(current.name),
+ ).run()
+
+ self.assertEqual(complete, {"independent"})
+ self.assertCountEqual(calls, ["first-failure", "second-failure", "independent"])
+ self.assertNotIn("blocked", calls)
+ self.assertNotIn("blocked-descendant", calls)
+ self.assertEqual(len(raised.exception.exceptions), 2)
+ self.assertEqual(
+ raised.exception.failed_nodes,
+ ("first-failure", "second-failure"),
+ )
+ self.assertIn("first-failure", str(raised.exception))
+ self.assertIn("second-failure", str(raised.exception))
+ self.assertIn("first expected failure", repr(raised.exception.subgroup(ValueError)))
+ self.assertIn("second expected failure", repr(raised.exception.subgroup(RuntimeError)))
+
+ async def test_completion_predicate_is_synchronous_and_returns_bool(self) -> None:
+ graph = Graph((node("invalid"),), ("invalid",), {"cpu": 1})
+
+ async def runner(_current: Node) -> None:
+ pass
+
+ with self.assertRaises(ExceptionGroup) as raised:
+ await Executor(
+ graph,
+ is_complete=lambda _current: "yes", # type: ignore[arg-type,return-value]
+ runner=runner,
+ publish=lambda _current: None,
+ ).run()
+ self.assertIn("did not return bool", repr(raised.exception.subgroup(ExecutionError)))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_fuzz_graph.py b/build/tests/test_fuzz_graph.py
new file mode 100644
index 0000000..901989a
--- /dev/null
+++ b/build/tests/test_fuzz_graph.py
@@ -0,0 +1,456 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, str(Path(__file__).parents[1]))
+
+from core.paths import BuildPaths # noqa: E402
+from graphs.fuzz import ( # noqa: E402
+ FuzzCorpusArtifact,
+ fuzz_corpus_artifacts,
+ fuzz_dependency_discovery_slice,
+ fuzz_graph,
+)
+
+
+TARGETS = ("pickle", "renpy", "rpgmaker", "zanzarah")
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ msbuild: Path
+ pwsh: Path
+ vcpkg_root: Path
+ llvm_dir: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: dict[str, str]
+
+
+class FuzzGraphTests(unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ project = """
+
+
+
+"""
+ files = {
+ "build/ObserverProjectConfigurations.props": "\n",
+ "build/ObserverConfiguration.props": "\n",
+ "build/ObserverProject.props": "\n",
+ "build/ObserverFuzz.props": "\n",
+ "vcpkg.json": '{"dependencies":["zlib"]}\n',
+ "build/vcpkg/triplets/observer-x64-windows-static-asan.cmake": (
+ "set(VCPKG_LIBRARY_LINKAGE static)\n"
+ ),
+ }
+ for target in TARGETS:
+ files[f"build/projects/fuzz-{target}.vcxproj"] = project.format(target=target)
+ files[f"src/fuzz/{target}.cpp"] = f'#include "{target}.h"\n'
+ files[f"src/fuzz/{target}.h"] = "#pragma once\n"
+ files[f"src/fuzz/corpus/{target}/seed.hex"] = "00 7f ff\n"
+ for relative, content in files.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return root
+
+ def toolchain(self, root: Path) -> FakeToolchain:
+ tools = root / "fake tools"
+ llvm = tools / "llvm"
+ llvm_bin = llvm / "bin"
+ runtime = llvm / "lib/clang/19/lib/windows"
+ vcpkg = tools / "vcpkg"
+ llvm_bin.mkdir(parents=True)
+ runtime.mkdir(parents=True)
+ vcpkg.mkdir()
+ (vcpkg / "vcpkg.exe").touch()
+ msbuild, pwsh = tools / "MSBuild.exe", tools / "pwsh.exe"
+ msbuild.touch()
+ pwsh.touch()
+ for name in ("clang-cl.exe", "clang-scan-deps.exe"):
+ (llvm_bin / name).touch()
+ return FakeToolchain(
+ msbuild,
+ pwsh,
+ vcpkg,
+ llvm,
+ (("PATH", str(tools)), ("VCPKG_ROOT", "stale")),
+ {"msbuild": "17.14", "llvm": "19.1.5", "vcpkg": "2026.07"},
+ )
+
+ def graph(self, repository: Path, toolchain: FakeToolchain, **kwargs):
+ jobs = kwargs.get("jobs", 2)
+ targets = kwargs.get("targets", TARGETS)
+ discovery = fuzz_dependency_discovery_slice(
+ repository, toolchain, jobs=jobs, targets=targets
+ )
+ manifests = {}
+ for target, name in zip(targets, discovery.targets, strict=True):
+ source = repository / f"src/fuzz/{target}.cpp"
+ header = repository / f"src/fuzz/{target}.h"
+ manifests[name] = json.dumps(
+ {
+ "Data": {
+ "Source": str(source.resolve()),
+ "Includes": [str(header.resolve())],
+ }
+ }
+ ).encode()
+ return discovery, fuzz_graph(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ **kwargs,
+ )
+
+ def test_discovery_names_are_confined_to_the_fuzz_configuration_namespace(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery = fuzz_dependency_discovery_slice(
+ repository, self.toolchain(root), targets=("pickle",)
+ )
+
+ self.assertTrue(discovery.targets[0].startswith(
+ "discover-dependencies-x64-fuzz-fuzz-pickle-"
+ ))
+ scanned = discovery.node(discovery.targets[0])
+ self.assertEqual(len(scanned.inputs), 1)
+ self.assertTrue(scanned.inputs[0].startswith(
+ "capture-clang-command-x64-fuzz-fuzz-pickle-"
+ ))
+
+ def test_target_subset_is_exact_and_preserves_existing_node_uids(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ _full_discovery, full = self.graph(
+ repository, toolchain, run_nonce="same"
+ )
+ subset_discovery, subset = self.graph(
+ repository, toolchain, run_nonce="same",
+ targets=("pickle", "zanzarah"),
+ )
+
+ self.assertEqual(
+ subset.targets, ("fuzz-x64-pickle", "fuzz-x64-zanzarah")
+ )
+ self.assertEqual(
+ fuzz_corpus_artifacts(subset),
+ tuple(
+ FuzzCorpusArtifact(target, subset.node(f"run-fuzz-x64-{target}").uid)
+ for target in ("pickle", "zanzarah")
+ ),
+ )
+ self.assertTrue(all("renpy" not in node.name and "rpgmaker" not in node.name
+ for node in subset.nodes))
+ self.assertEqual(len(subset_discovery.targets), 2)
+ for current in subset.nodes:
+ self.assertEqual(current.uid, full.node(current.name).uid, current.name)
+
+ def test_targets_must_be_a_unique_nonempty_supported_subset(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ for targets, message in (
+ ((), "must not be empty"),
+ (("pickle", "pickle"), "duplicate fuzz target: pickle"),
+ (("unknown",), "unsupported fuzz target: unknown"),
+ ):
+ with self.subTest(targets=targets), self.assertRaisesRegex(ValueError, message):
+ fuzz_dependency_discovery_slice(
+ repository, toolchain, targets=targets
+ )
+
+ def test_four_targets_are_independent_build_replay_and_bounded_run_branches(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery, graph = self.graph(
+ repository, self.toolchain(root), run_nonce="test-run", seconds=37,
+ jobs=3, fuzz_jobs=2,
+ )
+
+ self.assertEqual(graph.pools, {"build": 3, "fuzz": 2, "restore": 1, "slot": 3})
+ self.assertEqual(graph.targets, tuple(f"fuzz-x64-{target}" for target in TARGETS))
+ self.assertEqual(
+ fuzz_corpus_artifacts(graph),
+ tuple(
+ FuzzCorpusArtifact(target, graph.node(f"run-fuzz-x64-{target}").uid)
+ for target in TARGETS
+ ),
+ )
+ self.assertEqual(len(graph.nodes), 1 + len(TARGETS) * 6)
+ restore = graph.node("restore-vcpkg-asan-x64")
+ self.assertEqual(restore.pool, "restore")
+ self.assertIn("observer-x64-windows-static-asan", restore.command.stdin.decode())
+ self.assertEqual(dict(restore.command.env)["VCPKG_ROOT"], str(root / "fake tools/vcpkg"))
+
+ for target in TARGETS:
+ build = graph.node(f"build-fuzz-x64-{target}")
+ replay = graph.node(f"replay-fuzz-x64-{target}")
+ run = graph.node(f"run-fuzz-x64-{target}")
+ gate = graph.node(f"fuzz-x64-{target}")
+ expected_discovery = next(
+ name for name in discovery.targets if f"-fuzz-{target}-" in name
+ )
+ self.assertEqual(build.inputs, (expected_discovery,))
+ self.assertEqual(replay.inputs, (build.name,))
+ self.assertEqual(run.inputs, (replay.name,))
+ self.assertEqual(gate.inputs, (run.name,))
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in run.results),
+ (
+ (f"reports/fuzz/x64/{target}/status.txt", "fuzz", "text/plain", "status.txt"),
+ (f"reports/fuzz/x64/{target}/corpus", "corpus", "application/octet-stream", "corpus"),
+ (f"reports/fuzz/x64/{target}/artifacts", "evidence", "application/octet-stream", "artifacts"),
+ ),
+ )
+ self.assertTrue(all(not item.results for item in (build, replay, gate)))
+ self.assertEqual(
+ (build.pool, replay.pool, run.pool, gate.pool),
+ ("build", "fuzz", "fuzz", "fuzz"),
+ )
+ self.assertFalse(any(other in " ".join(run.inputs) for other in TARGETS if other != target))
+
+ def test_build_and_run_recipes_preserve_the_current_fuzzer_contract(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ _discovery, graph = self.graph(
+ repository, toolchain, run_nonce="test-run", seconds=37
+ )
+
+ build = graph.node("build-fuzz-x64-pickle")
+ replay = graph.node("replay-fuzz-x64-pickle")
+ run = graph.node("run-fuzz-x64-pickle")
+ gate = graph.node("fuzz-x64-pickle")
+
+ build_script = build.command.stdin.decode("utf-8")
+ self.assertIn(str(repository / "build/projects/fuzz-pickle.vcxproj"), build_script)
+ self.assertIn("'/t:Build'", build_script)
+ self.assertIn("'/p:Configuration=Fuzz'", build_script)
+ self.assertIn("'/p:Platform=x64'", build_script)
+ self.assertRegex(build_script, r"'/p:VcpkgInstalledDir=.*\\'")
+ self.assertIn("'/p:LLVMInstallDir=", build_script)
+ self.assertIn("'/p:LLVMRuntimeDir=", build_script)
+ self.assertIn("fuzz-pickle.exe", build_script)
+
+ replay_script = replay.command.stdin.decode("utf-8")
+ run_script = run.command.stdin.decode("utf-8")
+ for script in (replay_script, run_script):
+ self.assertIn("$buildDir 'corpus'", script)
+ self.assertIn("FromHexString", script)
+ self.assertIn("-max_len=262144", script)
+ self.assertIn("-rss_limit_mb=1024", script)
+ self.assertIn("-timeout=10", script)
+ self.assertNotIn("-max_total_time", replay_script)
+ self.assertIn("Get-ChildItem -LiteralPath $corpus", replay_script)
+ self.assertIn("-max_total_time=37", run_script)
+ self.assertIn("-use_value_profile=1", run_script)
+ self.assertIn("$outDir 'corpus'", run_script)
+ self.assertIn("status.txt", run_script)
+ self.assertIn("$PSNativeCommandUseErrorActionPreference = $false", run_script)
+ self.assertNotIn("Invoke-Checked $fuzzer", run_script)
+ gate_script = gate.command.stdin.decode("utf-8")
+ self.assertIn("status.txt", gate_script)
+ self.assertIn("Fuzzer exited with code", gate_script)
+ runtime = str(toolchain.llvm_dir / "lib/clang/19/lib/windows")
+ self.assertTrue(dict(run.command.env)["PATH"].startswith(runtime))
+ self.assertEqual(
+ dict(run.command.env)["ASAN_OPTIONS"],
+ "halt_on_error=1:alloc_dealloc_mismatch=1",
+ )
+ parser = (
+ "$tokens=$null;$errors=$null;"
+ "[Management.Automation.Language.Parser]::ParseInput("
+ "[Console]::In.ReadToEnd(),[ref]$tokens,[ref]$errors)|Out-Null;"
+ "if($errors.Count){$errors|ForEach-Object ToString;exit 1}"
+ )
+ parsed = subprocess.run(
+ [shutil.which("pwsh"), "-NoLogo", "-NoProfile", "-Command", parser],
+ input="\n".join((build_script, replay_script, run_script, gate_script)),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ self.assertEqual(0, parsed.returncode, parsed.stderr or parsed.stdout)
+
+ def test_one_seed_change_invalidates_only_its_replay_and_run_branch(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ _discovery, before = self.graph(repository, toolchain, run_nonce="test-run")
+ (repository / "src/fuzz/corpus/pickle/seed.hex").write_text("01\n", encoding="utf-8")
+ _discovery, after = self.graph(repository, toolchain, run_nonce="test-run")
+
+ changed = {
+ "replay-fuzz-x64-pickle",
+ "run-fuzz-x64-pickle",
+ "fuzz-x64-pickle",
+ }
+ for node in before.nodes:
+ comparison = after.node(node.name)
+ if node.name in changed:
+ self.assertNotEqual(node.uid, comparison.uid, node.name)
+ else:
+ self.assertEqual(node.uid, comparison.uid, node.name)
+
+ def test_actual_msbuild_contract_rejects_every_non_x64_architecture(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ for architecture in ("x86", "arm64"):
+ with self.subTest(architecture=architecture):
+ with self.assertRaisesRegex(ValueError, "x64-only"):
+ fuzz_graph(
+ repository, toolchain, run_nonce="test-run",
+ discovery=None, manifests={},
+ architectures=(architecture,),
+ )
+
+ with self.assertRaisesRegex(ValueError, "run nonce"):
+ fuzz_graph(repository, toolchain, run_nonce="", discovery=None, manifests={})
+ with self.assertRaisesRegex(ValueError, "dependency discovery is required"):
+ fuzz_graph(
+ repository, toolchain, run_nonce="test-run",
+ discovery=None, manifests={},
+ )
+
+ def test_build_requires_one_compiler_manifest_per_project_tu(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ discovery = fuzz_dependency_discovery_slice(repository, toolchain)
+
+ with self.assertRaisesRegex(ValueError, "missing dependency manifest"):
+ fuzz_graph(
+ repository, toolchain, discovery=discovery, manifests={},
+ run_nonce="test-run",
+ )
+
+ def test_run_nonce_invalidates_only_the_four_bounded_runs(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ _discovery, before = self.graph(repository, toolchain, run_nonce="run-one")
+ _discovery, after = self.graph(repository, toolchain, run_nonce="run-two")
+
+ for node in before.nodes:
+ comparison = after.node(node.name)
+ if node.name.startswith(("run-fuzz-x64-", "fuzz-x64-")):
+ self.assertNotEqual(node.uid, comparison.uid, node.name)
+ else:
+ self.assertEqual(node.uid, comparison.uid, node.name)
+
+ def test_prior_published_corpus_seeds_and_signs_only_its_next_run(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ _discovery, before = self.graph(repository, toolchain, run_nonce="same")
+ producer = before.node("run-fuzz-x64-pickle")
+ cas = BuildPaths(repository).cas(producer.uid)
+ (cas.output / "corpus").mkdir(parents=True)
+ (cas.output / "corpus/evolved").write_bytes(b"first")
+ cas.log.write_text("green\n", encoding="utf-8")
+ cas.touch.touch()
+ artifact = FuzzCorpusArtifact("pickle", producer.uid)
+
+ _discovery, seeded = self.graph(
+ repository, toolchain, run_nonce="same", prior_corpora=(artifact,)
+ )
+ (cas.output / "corpus/evolved").write_bytes(b"second")
+ _discovery, content_changed = self.graph(
+ repository, toolchain, run_nonce="same", prior_corpora=(artifact,)
+ )
+
+ changed = {"run-fuzz-x64-pickle", "fuzz-x64-pickle"}
+ for node in before.nodes:
+ comparison = seeded.node(node.name)
+ if node.name in changed:
+ self.assertNotEqual(node.uid, comparison.uid, node.name)
+ else:
+ self.assertEqual(node.uid, comparison.uid, node.name)
+ self.assertNotEqual(
+ seeded.node("run-fuzz-x64-pickle").uid,
+ content_changed.node("run-fuzz-x64-pickle").uid,
+ )
+ script = seeded.node("run-fuzz-x64-pickle").command.stdin.decode()
+ self.assertIn(str(cas.output / "corpus"), script)
+ self.assertIn("Copy-Item", script)
+
+ def test_prior_corpus_must_be_unique_and_complete_canonical_cas(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ missing = FuzzCorpusArtifact("pickle", "0" * 32)
+ with self.assertRaisesRegex(FileNotFoundError, "not published"):
+ self.graph(
+ repository, toolchain, run_nonce="run", prior_corpora=(missing,)
+ )
+ with self.assertRaisesRegex(ValueError, "duplicate prior corpus"):
+ self.graph(
+ repository,
+ toolchain,
+ run_nonce="run",
+ prior_corpora=(missing, missing),
+ )
+ with self.assertRaisesRegex(ValueError, "unsupported fuzz corpus target"):
+ FuzzCorpusArtifact("unknown", "0" * 32)
+ for invalid_uid in (42, "invalid"):
+ with self.subTest(invalid_uid=invalid_uid), self.assertRaisesRegex(
+ ValueError, "canonical MD5"
+ ):
+ FuzzCorpusArtifact("pickle", invalid_uid)
+
+ def test_prior_corpus_rejects_empty_or_nonfile_content(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ paths = BuildPaths(repository)
+ empty = paths.cas("1" * 32)
+ (empty.output / "corpus").mkdir(parents=True)
+ empty.log.touch()
+ empty.touch.touch()
+ with self.assertRaisesRegex(ValueError, "corpus is empty"):
+ self.graph(
+ repository,
+ toolchain,
+ run_nonce="run",
+ prior_corpora=(FuzzCorpusArtifact("pickle", "1" * 32),),
+ )
+
+ nonfile = paths.cas("2" * 32)
+ (nonfile.output / "corpus/directory").mkdir(parents=True)
+ nonfile.log.touch()
+ nonfile.touch.touch()
+ with self.assertRaisesRegex(ValueError, "contains a non-file"):
+ self.graph(
+ repository,
+ toolchain,
+ run_nonce="run",
+ prior_corpora=(FuzzCorpusArtifact("pickle", "2" * 32),),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_graph.py b/build/tests/test_graph.py
new file mode 100644
index 0000000..6a9fe86
--- /dev/null
+++ b/build/tests/test_graph.py
@@ -0,0 +1,219 @@
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+import sys
+import unittest
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import Command, Graph, GraphError, Node, Result, merge_graphs # noqa: E402
+
+
+def node(
+ name: str,
+ *,
+ inputs: tuple[str, ...] = (),
+ pool: str = "cpu",
+ results: tuple[Result, ...] = (),
+) -> Node:
+ return Node(
+ name=name,
+ uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ pool=pool,
+ command=Command(("tool", name)),
+ inputs=inputs,
+ results=results,
+ )
+
+
+class GraphTests(unittest.TestCase):
+ def test_results_are_typed_and_addressable_without_scanning_directories(self) -> None:
+ report = Result(
+ "analysis/renpy/x64/sarif",
+ "report",
+ "application/sarif+json",
+ "analysis/renpy-x64.sarif",
+ )
+ producer = node("analyze-renpy-x64", results=(report,))
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+
+ self.assertEqual(producer.results, (report,))
+ self.assertEqual(graph.result(report.id), (producer, report))
+ with self.assertRaisesRegex(GraphError, "unknown result"):
+ graph.result("analysis/missing")
+
+ def test_result_contract_rejects_ambiguous_identifiers_and_paths(self) -> None:
+ invalid = (
+ (42, "report", "application/json", "report.json"),
+ ("Uppercase", "report", "application/json", "report.json"),
+ ("report", 42, "application/json", "report.json"),
+ ("report", "Report", "application/json", "report.json"),
+ ("report", "report", 42, "report.json"),
+ ("report", "report", "not-a-media-type", "report.json"),
+ ("report", "report", "application/json", 42),
+ ("report", "report", "application/json", ""),
+ ("report", "report", "application/json", "bad\0path"),
+ ("report", "report", "application/json", "../report.json"),
+ ("report", "report", "application/json", "reports/./report.json"),
+ ("report", "report", "application/json", "reports//report.json"),
+ ("report", "report", "application/json", r"reports\report.json"),
+ ("report", "report", "application/json", "/report.json"),
+ ("report", "report", "application/json", "C:/report.json"),
+ )
+ for values in invalid:
+ with self.subTest(values=values), self.assertRaises(GraphError):
+ Result(*values)
+
+ report = Result("report", "report", "application/json", "report.json")
+ with self.assertRaisesRegex(GraphError, "duplicate result ids"):
+ node("producer", results=(report, report))
+ with self.assertRaisesRegex(GraphError, "results must be Result"):
+ Node(
+ "producer",
+ "0" * 32,
+ "cpu",
+ Command(("tool",)),
+ results=(object(),), # type: ignore[arg-type]
+ )
+ with self.assertRaisesRegex(GraphError, "duplicate result id.*report"):
+ Graph(
+ (node("first", results=(report,)), node("second", results=(report,))),
+ ("first", "second"),
+ {"cpu": 1},
+ )
+
+ def test_graphs_merge_shared_exact_nodes_pools_and_targets(self) -> None:
+ shared = node("shared")
+ first = Graph(
+ (shared, node("first", inputs=(shared.name,))),
+ ("first",),
+ {"cpu": 2},
+ )
+ second = Graph(
+ (shared, node("second", inputs=(shared.name,), pool="io")),
+ ("shared", "second"),
+ {"cpu": 2, "io": 1},
+ )
+
+ merged = merge_graphs(first, second)
+
+ self.assertEqual(tuple(current.name for current in merged.nodes), ("shared", "first", "second"))
+ self.assertEqual(merged.targets, ("first", "shared", "second"))
+ self.assertEqual(dict(merged.pools), {"cpu": 2, "io": 1})
+
+ def test_graph_merge_rejects_missing_inputs_and_conflicting_shared_contracts(self) -> None:
+ with self.assertRaisesRegex(GraphError, "at least one graph"):
+ merge_graphs()
+
+ first = Graph((node("shared"),), ("shared",), {"cpu": 1})
+ conflicting_node = Node("shared", "f" * 32, "cpu", Command(("tool",)))
+ second = Graph((conflicting_node,), ("shared",), {"cpu": 1})
+ with self.assertRaisesRegex(GraphError, "conflicting node definition.*shared"):
+ merge_graphs(first, second)
+
+ third = Graph((node("other"),), ("other",), {"cpu": 2})
+ with self.assertRaisesRegex(GraphError, "conflicting pool capacity.*cpu"):
+ merge_graphs(first, third)
+
+ def test_edges_and_targets_are_direct_node_names(self) -> None:
+ producer = node("producer")
+ consumer = node("consumer", inputs=("producer",))
+ graph = Graph((consumer, producer), ("consumer",), {"cpu": 2})
+
+ self.assertEqual(consumer.inputs, ("producer",))
+ self.assertEqual(graph.node("producer"), producer)
+ self.assertEqual(graph.dependencies_of("consumer"), (producer,))
+
+ def test_unknown_dependencies_targets_and_duplicate_names_are_rejected(self) -> None:
+ with self.assertRaisesRegex(GraphError, "duplicate node name"):
+ Graph((node("same"), node("same")), ("same",), {"cpu": 1})
+ with self.assertRaisesRegex(GraphError, "unknown dependency.*missing"):
+ Graph(
+ (node("consumer", inputs=("missing",)),),
+ ("consumer",),
+ {"cpu": 1},
+ )
+ with self.assertRaisesRegex(GraphError, "unknown target.*missing"):
+ Graph((node("only"),), ("missing",), {"cpu": 1})
+
+ def test_cycles_are_rejected_before_execution(self) -> None:
+ with self.assertRaisesRegex(GraphError, "cycle.*first.*second.*first"):
+ Graph(
+ (
+ node("second", inputs=("first",)),
+ node("first", inputs=("second",)),
+ ),
+ ("first",),
+ {"cpu": 1},
+ )
+
+ def test_each_node_selects_one_known_positive_pool(self) -> None:
+ with self.assertRaisesRegex(GraphError, "unknown pool.*missing"):
+ Graph((node("only", pool="missing"),), ("only",), {"cpu": 1})
+ for invalid in (0, -1, True):
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(
+ GraphError, "invalid pool capacity"
+ ):
+ Graph((node("only"),), ("only",), {"cpu": invalid}) # type: ignore[dict-item]
+
+ def test_node_identity_and_command_are_safe_runtime_descriptors(self) -> None:
+ command = Command(
+ ("pwsh", "argument with spaces", "&literal", ""),
+ env=(("ZED", "last"), ("ALPHA", "first")),
+ cwd="work/a directory",
+ stdin=b"Write-Output 'literal'\r\n",
+ )
+ current = Node("safe-name.1", "0" * 32, "cpu", command)
+
+ self.assertEqual(current.command.env, (("ALPHA", "first"), ("ZED", "last")))
+ self.assertEqual(current.command.stdin, b"Write-Output 'literal'\r\n")
+ self.assertEqual(current.command.argv[-1], "")
+ with self.assertRaisesRegex(GraphError, "uid.*lowercase MD5"):
+ Node("safe", "NOT-A-UID", "cpu", Command(("tool",)))
+ with self.assertRaisesRegex(GraphError, "node name.*128"):
+ node("a" * 129)
+
+ def test_ambiguous_command_and_node_data_are_rejected(self) -> None:
+ invalid_commands = (
+ lambda: Command(()),
+ lambda: Command(("bad\0argument",)),
+ lambda: Command(("tool",), env=(("incomplete",),)), # type: ignore[arg-type]
+ lambda: Command(("tool",), env=(("KEY", object()),)), # type: ignore[arg-type]
+ lambda: Command(("tool",), env=(("PATH", "1"), ("Path", "2"))),
+ lambda: Command(("tool",), cwd="bad\0cwd"),
+ lambda: Command(("tool",), stdin="not bytes"), # type: ignore[arg-type]
+ )
+ for constructor in invalid_commands:
+ with self.subTest(constructor=constructor), self.assertRaises(GraphError):
+ constructor()
+
+ with self.assertRaisesRegex(GraphError, "duplicate dependencies"):
+ Node("safe", "0" * 32, "cpu", Command(("tool",)), ("same", "same"))
+ with self.assertRaisesRegex(GraphError, "command must be a Command"):
+ Node("safe", "0" * 32, "cpu", object()) # type: ignore[arg-type]
+
+ def test_graph_container_and_lookups_are_explicit(self) -> None:
+ only = node("only")
+ invalid_graphs = (
+ lambda: Graph((), ("only",), {"cpu": 1}),
+ lambda: Graph((only,), (), {"cpu": 1}),
+ lambda: Graph((only,), ("only", "only"), {"cpu": 1}),
+ lambda: Graph((object(),), ("only",), {"cpu": 1}), # type: ignore[arg-type]
+ lambda: Graph((only,), ("only",), {"": 1}),
+ )
+ for constructor in invalid_graphs:
+ with self.subTest(constructor=constructor), self.assertRaises(GraphError):
+ constructor()
+
+ graph = Graph((only,), ("only",), {"cpu": 1})
+ with self.assertRaisesRegex(GraphError, "unknown node"):
+ graph.node("missing")
+ with self.assertRaisesRegex(GraphError, "unknown node"):
+ graph.dependencies_of("missing")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_graph_main_coverage.py b/build/tests/test_graph_main_coverage.py
new file mode 100644
index 0000000..0f8a3cc
--- /dev/null
+++ b/build/tests/test_graph_main_coverage.py
@@ -0,0 +1,345 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from graphs import analysis, common, fuzz, native # noqa: E402
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ msbuild: Path
+ pwsh: Path
+ vcpkg_root: Path
+ llvm_dir: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: dict[str, str]
+
+
+def write(root: Path, relative: str, content: str = "") -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def toolchain(root: Path, *, runtime: bool = False) -> FakeToolchain:
+ tools = root / "tools"
+ vcpkg = tools / "vcpkg"
+ llvm = tools / "llvm"
+ vcpkg.mkdir(parents=True)
+ llvm.mkdir()
+ if runtime:
+ (llvm / "lib/clang/19/lib/windows").mkdir(parents=True)
+ for path in (tools / "MSBuild.exe", tools / "pwsh.exe", vcpkg / "vcpkg.exe"):
+ path.touch()
+ return FakeToolchain(
+ tools / "MSBuild.exe",
+ tools / "pwsh.exe",
+ vcpkg,
+ llvm,
+ (("PATH", str(tools)),),
+ {"msbuild": "17.14", "msvc": "14.44", "llvm": "19.1"},
+ )
+
+
+def native_repository(root: Path) -> Path:
+ project = """
+
+
+"""
+ for relative in (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ ):
+ write(root, relative, "\n")
+ write(root, "vcpkg.json", "{}\n")
+ write(root, "build/vcpkg/triplets/observer-x64-windows-static.cmake")
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe"):
+ write(root, f"src/{name}.cpp", "int value;\n")
+ write(root, f"build/projects/{name}.vcxproj", project.format(name=name))
+ return root
+
+
+class AnalysisCoverageTests(unittest.TestCase):
+ def test_tests_project_declares_cross_directory_header_dependencies(self) -> None:
+ repository = BUILD_ROOT.parent
+ project = analysis.project_inventory(
+ repository, ("tests",), include_link_inputs=False
+ )[0]
+ observer = repository / "src/tests/framework/observer.cpp"
+ pickle = repository / "src/tests/unit/pickle.cpp"
+ headers = (
+ repository / "src/api.h",
+ repository / "src/archive.h",
+ repository / "src/modules/extractor.h",
+ )
+ content = json.dumps({
+ "Data": {
+ "Source": str(observer.resolve()),
+ "Includes": [str(headers[0].resolve())],
+ }
+ }).encode()
+
+ observer_files = analysis.dependency_inputs(
+ repository, repository / "out/cas/unused/out",
+ observer, content, project.headers,
+ )
+ pickle_files = analysis.dependency_inputs(
+ repository, repository / "out/cas/unused/out", pickle,
+ json.dumps({
+ "Data": {
+ "Source": str(pickle.resolve()),
+ "Includes": [str(path.resolve()) for path in headers[1:]],
+ }
+ }).encode(),
+ project.headers,
+ )
+
+ self.assertEqual(observer_files["src/api.h"], headers[0].read_bytes())
+ self.assertEqual(pickle_files["src/archive.h"], headers[1].read_bytes())
+ self.assertEqual(
+ pickle_files["src/modules/extractor.h"], headers[2].read_bytes()
+ )
+
+ def test_compiler_manifest_accepts_only_exact_relevant_dependency_bytes(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ source = write(repository, "src/module/source.cpp", "int value;\n")
+ first_party = write(repository, "src/shared.h", "first-party\n")
+ package_root = repository / "out/cas/package/out"
+ package = write(package_root, "include/package.h", "package\n")
+ system = write(repository, "sdk/system.h", "system\n")
+ content = json.dumps(
+ {
+ "Data": {
+ "Source": str(source.resolve()),
+ "Includes": [
+ str(first_party.resolve()),
+ str(first_party.resolve()),
+ str(package.resolve()),
+ str(system.resolve()),
+ ],
+ }
+ }
+ ).encode()
+
+ files = analysis.dependency_inputs(
+ repository, package_root, source, content, (first_party,)
+ )
+
+ self.assertEqual(
+ set(files),
+ {"compiler/dependencies.json", "src/module/source.cpp", "src/shared.h", "vcpkg/include/package.h"},
+ )
+
+ def test_invalid_or_mismatched_compiler_manifests_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ source = write(repository, "src/source.cpp", "int value;\n")
+ package = repository / "out/cas/package/out"
+ package.mkdir(parents=True)
+ invalid = (
+ b"{",
+ b"{}",
+ json.dumps({"Data": {"Source": 1, "Includes": []}}).encode(),
+ json.dumps({"Data": {"Source": str(source), "Includes": {}}}).encode(),
+ json.dumps({"Data": {"Source": str(source), "Includes": [None]}}).encode(),
+ json.dumps({"Data": {"Source": str(source), "Includes": ["relative.h"]}}).encode(),
+ json.dumps({"Data": {"Source": str(source), "Includes": [str(repository / 'src/missing.h')]}}).encode(),
+ )
+ for content in invalid:
+ with self.subTest(content=content), self.assertRaisesRegex(ValueError, "invalid MSVC"):
+ analysis.dependency_inputs(repository, package, source, content)
+ mismatch = json.dumps(
+ {"Data": {"Source": str(write(repository, "src/other.cpp")), "Includes": []}}
+ ).encode()
+ with self.assertRaisesRegex(ValueError, "source mismatch"):
+ analysis.dependency_inputs(repository, package, source, mismatch)
+
+ def test_project_inventory_ignores_empty_items_and_rejects_external_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ write(
+ repository,
+ "build/projects/empty.vcxproj",
+ ''
+ "",
+ )
+ self.assertEqual(analysis._projects(repository), ())
+
+ write(
+ repository,
+ "build/projects/empty.vcxproj",
+ ''
+ '',
+ )
+ with self.assertRaisesRegex(ValueError, "unsupported ClCompile path"):
+ analysis._projects(repository)
+
+ def test_fuzz_analysis_signs_fuzz_props_and_unknown_architecture_is_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = root / "repo"
+ for relative in (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/ObserverFuzz.props",
+ ):
+ write(repository, relative, "\n")
+ write(repository, "build/ObserverNativeAnalysis.ruleset", "\n")
+ write(repository, ".clang-tidy", "Checks: bugprone-*\n")
+ write(repository, "vcpkg.json", "{}\n")
+ write(repository, "build/vcpkg/triplets/observer-x64-windows-static.cmake")
+ write(repository, "src/fuzz/pickle.cpp", "int value;\n")
+ write(
+ repository,
+ "build/projects/fuzz-pickle.vcxproj",
+ ''
+ ''
+ "",
+ )
+ fake = toolchain(root)
+
+ before_discovery = analysis.analysis_discovery_slice(repository, fake)
+ name = before_discovery.targets[0]
+ manifest = json.dumps(
+ {"Data": {"Source": str((repository / "src/fuzz/pickle.cpp").resolve()), "Includes": []}}
+ ).encode()
+ before = analysis.analysis_slice(
+ repository, fake, discovery=before_discovery, manifests={name: manifest}
+ )
+ write(repository, "build/ObserverFuzz.props", "\n")
+ after_discovery = analysis.analysis_discovery_slice(repository, fake)
+ after = analysis.analysis_slice(
+ repository, fake, discovery=after_discovery, manifests={name: manifest}
+ )
+ node_name = "analyze-msvc-x64-fuzz-pickle-fuzz.pickle"
+
+ self.assertNotEqual(before.node(node_name).uid, after.node(node_name).uid)
+ with self.assertRaisesRegex(ValueError, "unsupported architecture: mips"):
+ analysis.analysis_discovery_slice(repository, fake, architectures=("mips",))
+ with self.assertRaisesRegex(ValueError, "unsupported architecture: mips"):
+ analysis.analysis_slice(
+ repository,
+ fake,
+ discovery=after_discovery,
+ manifests={name: manifest},
+ architectures=("mips",),
+ )
+ with self.assertRaisesRegex(ValueError, "missing dependency manifest"):
+ analysis.analysis_slice(
+ repository, fake, discovery=after_discovery, manifests={}
+ )
+
+
+class CommonAndFuzzCoverageTests(unittest.TestCase):
+ def test_environment_without_vcpkg_root_preserves_path_and_extra_values(self) -> None:
+ fake = type("Toolchain", (), {"environment": (("Path", "base"),)})()
+
+ environment = dict(
+ common.tool_environment(
+ fake,
+ prepend_path=Path("runtime"),
+ extra=(("MODE", "checked"),),
+ )
+ )
+
+ self.assertEqual(environment["PATH"], f"runtime{common.os.pathsep}base")
+ self.assertEqual(environment["MODE"], "checked")
+ self.assertNotIn("VCPKG_ROOT", environment)
+
+ def test_missing_sanitizer_runtime_is_reported(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ fake = toolchain(root)
+
+ with self.assertRaisesRegex(FileNotFoundError, "sanitizer runtimes"):
+ fuzz._runtime(fake)
+
+ def test_fuzzer_project_rejects_unsigned_source_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ for relative in fuzz._COMMON:
+ write(repository, relative, "\n")
+ write(
+ repository,
+ "build/projects/fuzz-pickle.vcxproj",
+ ''
+ ''
+ "",
+ )
+
+ with self.assertRaisesRegex(ValueError, "unsupported ClCompile path"):
+ fuzz._project_files(
+ repository, "pickle", Path("unused"), object(), {}
+ )
+
+ def test_empty_seed_corpus_and_nonpositive_duration_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ (repository / "src/fuzz/corpus/pickle").mkdir(parents=True)
+ with self.assertRaisesRegex(FileNotFoundError, "no checked-in fuzzer seeds"):
+ fuzz._seed_files(repository, "pickle")
+
+ with self.assertRaisesRegex(ValueError, "fuzz seconds must be positive"):
+ fuzz.fuzz_graph(
+ Path("unused"), object(), discovery=None, manifests={},
+ run_nonce="run", seconds=0,
+ )
+
+
+class NativeCoverageTests(unittest.TestCase):
+ def test_invalid_job_capacity_and_nonrunnable_release_leak_probe_contract(self) -> None:
+ with self.assertRaisesRegex(ValueError, "jobs must be a positive integer"):
+ native.native_graph(
+ Path("unused"), object(), jobs=0, runnable_architectures=()
+ )
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = native_repository(root / "repo")
+ fake = toolchain(root)
+ discovery = native.native_dependency_discovery_slice(
+ repository, fake, configurations=("Release",), include_leak_probe=True
+ )
+ projects = ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe")
+ manifests = {
+ name: json.dumps(
+ {
+ "Data": {
+ "Source": str((repository / f"src/{next(project for project in projects if name.endswith('-' + project))}.cpp").resolve()),
+ "Includes": [],
+ }
+ }
+ ).encode()
+ for name in discovery.targets
+ }
+ graph = native.native_graph(
+ repository,
+ fake,
+ discovery=discovery,
+ manifests=manifests,
+ architectures=("x64",),
+ configurations=("Release",),
+ runnable_architectures=(),
+ test_shards=1,
+ include_leak_probe=True,
+ )
+
+ self.assertIn("build-leak-probe-x64-release", graph.targets)
+ self.assertFalse(any(name.startswith("test-shard-") for name in graph.targets))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_host.py b/build/tests/test_host.py
new file mode 100644
index 0000000..e45ac4a
--- /dev/null
+++ b/build/tests/test_host.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import unittest
+
+from core.host import (
+ detect_host_architecture,
+ require_runnable,
+ runnable_architectures,
+ verify_route,
+)
+
+
+class HostTests(unittest.TestCase):
+ def test_common_machine_names_are_canonicalized(self) -> None:
+ for machine, expected in (
+ ("AMD64", "x64"),
+ ("x86_64", "x64"),
+ ("ARM64", "arm64"),
+ ("aarch64", "arm64"),
+ ("x86", "x86"),
+ ("i686", "x86"),
+ ):
+ with self.subTest(machine=machine):
+ self.assertEqual(detect_host_architecture(machine), expected)
+
+ def test_unknown_machine_is_rejected(self) -> None:
+ with self.assertRaisesRegex(RuntimeError, "unsupported Windows host architecture"):
+ detect_host_architecture("mips64")
+
+ def test_runnable_matrix_matches_windows_emulation_contract(self) -> None:
+ requested = ("x86", "x64", "arm64")
+ self.assertEqual(runnable_architectures(requested, "x86"), ("x86",))
+ self.assertEqual(runnable_architectures(requested, "x64"), ("x86", "x64"))
+ self.assertEqual(runnable_architectures(requested, "arm64"), requested)
+
+ def test_invalid_requested_and_host_architectures_are_rejected(self) -> None:
+ with self.assertRaisesRegex(ValueError, "unsupported requested architecture"):
+ runnable_architectures(("sparc",), "x64")
+ with self.assertRaisesRegex(ValueError, "unsupported host architecture"):
+ runnable_architectures(("x64",), "sparc")
+
+ def test_test_command_contract_rejects_nonrunnable_requests(self) -> None:
+ with self.assertRaisesRegex(RuntimeError, "cannot run arm64 tests on x64 host"):
+ require_runnable(("x86", "arm64"), "x64")
+
+ self.assertEqual(require_runnable(("x86", "x64"), "x64"), ("x86", "x64"))
+
+ def test_verify_route_selects_host_capable_specialists(self) -> None:
+ route = verify_route(("x86", "x64", "arm64"), "x64")
+
+ self.assertEqual(route.runnable, ("x86", "x64"))
+ self.assertEqual(route.coverage, ("x64",))
+ self.assertEqual(route.asan, ("x86", "x64"))
+ self.assertEqual(route.ubsan, ("x64",))
+ self.assertTrue(route.run_x64_specialists)
+ self.assertEqual(
+ [(item.gate, item.architecture) for item in route.deferred],
+ [("tests", "arm64"), ("package-runtime", "arm64")],
+ )
+ self.assertTrue(all(item.reason for item in route.deferred))
+
+ def test_verify_route_defers_nonrunnable_specialists_explicitly(self) -> None:
+ route = verify_route(("x64",), "x86")
+
+ self.assertEqual(route.runnable, ())
+ self.assertEqual(route.coverage, ())
+ self.assertEqual(route.asan, ())
+ self.assertEqual(route.ubsan, ())
+ self.assertFalse(route.run_x64_specialists)
+ self.assertEqual(
+ [item.gate for item in route.deferred],
+ ["tests", "package-runtime", "coverage", "asan", "ubsan", "leaks", "fuzz"],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_instrumented_graph.py b/build/tests/test_instrumented_graph.py
new file mode 100644
index 0000000..5090ad0
--- /dev/null
+++ b/build/tests/test_instrumented_graph.py
@@ -0,0 +1,701 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+import json
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.paths import BuildPaths # noqa: E402
+from core.graph import Graph, Node # noqa: E402
+from graphs.instrumented import ( # noqa: E402
+ InstrumentedVariant,
+ instrumented_build_slice,
+ instrumented_dependency_discovery_slice,
+)
+from graphs.analysis import ( # noqa: E402
+ clang_dependency_discovery_slice,
+)
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ msbuild: Path
+ pwsh: Path
+ vcpkg_root: Path
+ llvm_dir: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: dict[str, str]
+
+
+class InstrumentedBuildGraphTests(unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ project = """
+
+
+ {definition}
+
+"""
+ files = {
+ "src/renpy.cpp": '#include "renpy.h"\n',
+ "src/renpy.h": "#pragma once\n",
+ "src/rpgmaker.cpp": '#include "rpgmaker.h"\n',
+ "src/rpgmaker.h": "#pragma once\n",
+ "src/zanzarah.cpp": '#include "zanzarah.h"\n',
+ "src/zanzarah.h": "#pragma once\n",
+ "src/tests.cpp": '#include "tests.h"\n',
+ "src/tests.h": "#pragma once\n",
+ "src/leak-probe.cpp": '#include "tests.h"\n',
+ "src/renpy.def": "EXPORTS\n OpenStorage\n",
+ "build/ObserverProjectConfigurations.props": "\n",
+ "build/ObserverConfiguration.props": "\n",
+ "build/ObserverProject.props": "\n",
+ "vcpkg.json": '{"dependencies":["zlib"]}\n',
+ }
+ for architecture in ("x86", "x64"):
+ for flavor in ("", "-asan"):
+ files[
+ f"build/vcpkg/triplets/observer-{architecture}-windows-static{flavor}.cmake"
+ ] = "set(VCPKG_LIBRARY_LINKAGE static)\n"
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests"):
+ definition = (
+ ""
+ "$(RepositoryRoot)src\\renpy.def"
+ ""
+ if name == "renpy"
+ else (
+ ""
+ ""
+ if name == "rpgmaker"
+ else ""
+ )
+ )
+ files[f"build/projects/{name}.vcxproj"] = project.format(
+ name=name, definition=definition
+ )
+ files["build/projects/leak-probe.vcxproj"] = project.format(
+ name="leak-probe", definition=""
+ )
+ for relative, content in files.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return root
+
+ def toolchain(self, root: Path) -> FakeToolchain:
+ tools = root / "tools"
+ tools.mkdir()
+ vcpkg = tools / "vcpkg"
+ vcpkg.mkdir()
+ (vcpkg / "vcpkg.exe").touch()
+ llvm = tools / "llvm"
+ (llvm / "bin").mkdir(parents=True)
+ (llvm / "bin/clang-cl.exe").write_bytes(b"clang-v1")
+ (llvm / "bin/clang-scan-deps.exe").write_bytes(b"scan-v1")
+ msbuild, pwsh = tools / "MSBuild.exe", tools / "pwsh.exe"
+ msbuild.touch()
+ pwsh.touch()
+ return FakeToolchain(
+ msbuild, pwsh, vcpkg, llvm,
+ (("PATH", str(tools)),),
+ {"msbuild": "17.14", "msvc": "14.44", "llvm": "20.1"},
+ )
+
+ def llvm_runtime(self, root: Path) -> Path:
+ runtime = root / "llvm-runtime"
+ runtime.mkdir()
+ for name in (
+ "clang_rt.ubsan_standalone-x86_64.lib",
+ "clang_rt.ubsan_standalone_cxx-x86_64.lib",
+ ):
+ (runtime / name).touch()
+ return runtime
+
+ def variants(self, runtime: Path) -> tuple[InstrumentedVariant, ...]:
+ return (
+ InstrumentedVariant("coverage", "x64"),
+ InstrumentedVariant("asan", "x86"),
+ InstrumentedVariant("asan", "x64"),
+ InstrumentedVariant(
+ "ubsan", "x64", runtime,
+ {"standalone": "sha256:a", "cxx": "sha256:b"},
+ ),
+ )
+
+ def manifests(self, repository: Path, discovery) -> dict[str, bytes]:
+ result = {}
+ for target in discovery.targets:
+ project = next(
+ name
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests")
+ if f"-{name}-" in target
+ )
+ result[target] = json.dumps(
+ {
+ "Data": {
+ "Source": str((repository / f"src/{project}.cpp").resolve()),
+ "Includes": [str((repository / f"src/{project}.h").resolve())],
+ }
+ }
+ ).encode()
+ return result
+
+ def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ variants = self.variants(self.llvm_runtime(root))
+ discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants, jobs=8
+ )
+ graph = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=self.manifests(repository, discovery),
+ variants=variants,
+ jobs=8,
+ )
+ paths = BuildPaths(repository)
+
+ names = tuple(item.name for item in discovery.nodes)
+ self.assertEqual(names.count("restore-vcpkg-x64"), 1)
+ self.assertEqual(names.count("restore-vcpkg-asan-x86"), 1)
+ self.assertEqual(names.count("restore-vcpkg-asan-x64"), 1)
+ self.assertEqual(len(discovery.targets), 16)
+ clang_capture_count = 4 * sum(
+ item.kind in {"coverage", "ubsan"} for item in variants
+ )
+ self.assertEqual(
+ len(discovery.nodes),
+ 3 + len(discovery.targets) + clang_capture_count,
+ )
+ coverage_discovery = discovery.node(
+ next(name for name in discovery.targets if "-x64-coverage-renpy-" in name)
+ )
+ ubsan_discovery = discovery.node(
+ next(name for name in discovery.targets if "-x64-ubsan-renpy-" in name)
+ )
+ asan_discovery = discovery.node(
+ next(name for name in discovery.targets if "-x64-asan-renpy-" in name)
+ ).command.stdin.decode("utf-8")
+ for normalized in (coverage_discovery, ubsan_discovery):
+ self.assertEqual(len(normalized.inputs), 1)
+ raw = discovery.node(normalized.inputs[0])
+ script = raw.command.stdin.decode("utf-8")
+ self.assertIn("/p:ObserverClangCommandPath=", script)
+ self.assertIn("'/p:LLVMInstallDir=", script)
+ self.assertNotIn("PlatformToolset=v143", script)
+ self.assertNotIn("ObserverSourceDependenciesPath", script)
+ self.assertEqual(
+ normalized.command.argv[1:4],
+ ("-m", "core.clang_dependencies", "scan"),
+ )
+ self.assertNotIn("PlatformToolset=v143", asan_discovery)
+ self.assertEqual(len(graph.nodes), len(discovery.nodes) + len(graph.targets))
+ self.assertEqual(len(graph.targets), 16)
+ self.assertEqual(graph.pools, {"restore": 1, "slot": 8})
+
+ for variant in variants:
+ restore = (
+ f"restore-vcpkg-asan-{variant.architecture}"
+ if variant.kind == "asan"
+ else f"restore-vcpkg-{variant.architecture}"
+ )
+ for project in ("renpy", "rpgmaker", "zanzarah", "tests"):
+ build = graph.node(
+ f"build-{project}-{variant.architecture}-{variant.kind}"
+ )
+ self.assertEqual(len(build.inputs), 1)
+ self.assertIn(
+ f"-{variant.architecture}-{variant.kind}-{project}-", build.inputs[0]
+ )
+ script = build.command.stdin.decode("utf-8")
+ self.assertIn(f"'/p:Configuration={variant.configuration}'", script)
+ self.assertIn("'/m:1'", script)
+ self.assertIn("'/p:BuildProjectReferences=false'", script)
+ self.assertIn(str(paths.cas(graph.node(restore).uid).output), script)
+ if variant.kind in {"coverage", "ubsan"}:
+ self.assertIn("'/p:LLVMInstallDir=", script)
+ else:
+ self.assertNotIn("'/p:LLVMInstallDir=", script)
+ if variant.kind == "ubsan":
+ self.assertIn("'/p:LLVMRuntimeDir=", script)
+ else:
+ self.assertNotIn("'/p:LLVMRuntimeDir=", script)
+
+ def test_invalid_variants_manifests_and_runtime_contracts_fail_closed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ runtime = self.llvm_runtime(root)
+ good = (InstrumentedVariant("coverage", "x64"),)
+ discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=good
+ )
+
+ for variant in (
+ InstrumentedVariant("msan", "x64"),
+ InstrumentedVariant("asan", "arm64"),
+ InstrumentedVariant("ubsan", "x86", runtime, {"hash": "x"}),
+ ):
+ with self.subTest(variant=variant), self.assertRaisesRegex(ValueError, "variant"):
+ instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=(variant,)
+ )
+ with self.assertRaisesRegex(ValueError, "duplicate"):
+ instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=good + good
+ )
+ with self.assertRaisesRegex(ValueError, "at least one"):
+ instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=()
+ )
+ with self.assertRaisesRegex(ValueError, "jobs"):
+ instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=good, jobs=True
+ )
+ with self.assertRaisesRegex(ValueError, "runtime"):
+ instrumented_dependency_discovery_slice(
+ repository,
+ toolchain,
+ variants=(InstrumentedVariant("ubsan", "x64"),),
+ )
+ with self.assertRaisesRegex(ValueError, "runtime"):
+ instrumented_dependency_discovery_slice(
+ repository,
+ toolchain,
+ variants=(InstrumentedVariant("coverage", "x64", runtime, {"x": "y"}),),
+ )
+
+ manifests = self.manifests(repository, discovery)
+ with self.assertRaisesRegex(ValueError, "missing dependency manifest"):
+ instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests={},
+ variants=good,
+ )
+ with self.assertRaisesRegex(ValueError, "discovery is required"):
+ instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=None,
+ manifests=manifests,
+ variants=good,
+ )
+ with self.assertRaisesRegex(ValueError, "jobs"):
+ instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=good,
+ jobs=0,
+ )
+ conflicting_pool = Graph(
+ discovery.nodes,
+ discovery.targets,
+ {"restore": 1, "slot": 3},
+ )
+ with self.assertRaisesRegex(ValueError, "conflicting slot"):
+ instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=conflicting_pool,
+ manifests=manifests,
+ variants=good,
+ jobs=2,
+ )
+
+ project = repository / "build/projects/tests.vcxproj"
+ project.write_text(
+ project.read_text(encoding="utf-8").replace(
+ "$(RepositoryRoot)src\\tests.cpp", "src\\tests.cpp"
+ ),
+ encoding="utf-8",
+ )
+ with self.assertRaisesRegex(ValueError, "unsupported project input"):
+ instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=good,
+ )
+ project.write_text(
+ project.read_text(encoding="utf-8").replace(
+ "src\\tests.cpp", "$(RepositoryRoot)src\\tests.cpp"
+ ),
+ encoding="utf-8",
+ )
+
+ empty_runtime = root / "empty-runtime"
+ empty_runtime.mkdir()
+ with self.assertRaisesRegex(FileNotFoundError, "UBSan runtime library"):
+ instrumented_dependency_discovery_slice(
+ repository,
+ toolchain,
+ variants=(
+ InstrumentedVariant(
+ "ubsan", "x64", empty_runtime, {"hash": "x"}
+ ),
+ ),
+ )
+
+ original = discovery.node("restore-vcpkg-x64")
+ conflicting = Node(
+ original.name,
+ "0" * 32,
+ original.pool,
+ original.command,
+ original.inputs,
+ )
+ conflict_graph = Graph(
+ (conflicting,), (conflicting.name,), {"restore": 1, "slot": 2}
+ )
+ with (
+ mock.patch(
+ "graphs.instrumented.clang_dependency_discovery_slice",
+ side_effect=(discovery, conflict_graph),
+ ),
+ self.assertRaisesRegex(ValueError, "conflicting canonical node"),
+ ):
+ instrumented_dependency_discovery_slice(
+ repository,
+ toolchain,
+ variants=(
+ InstrumentedVariant("coverage", "x64"),
+ InstrumentedVariant(
+ "ubsan", "x64", runtime,
+ {"standalone": "a", "cxx": "b"},
+ ),
+ ),
+ )
+
+ def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumers(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ runtime = self.llvm_runtime(root)
+ variants = (
+ InstrumentedVariant("coverage", "x64"),
+ InstrumentedVariant("asan", "x64"),
+ InstrumentedVariant("ubsan", "x64", runtime, {"hash": "before"}),
+ )
+ discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ manifests = self.manifests(repository, discovery)
+ before = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+ clang = toolchain.llvm_dir / "bin/clang-cl.exe"
+ clang.write_bytes(b"clang-v2")
+ clang_discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ clang_changed = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=clang_discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+ clang.write_bytes(b"clang-v1")
+ scanner = toolchain.llvm_dir / "bin/clang-scan-deps.exe"
+ scanner.write_bytes(b"scan-v2")
+ scanner_discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ scanner_changed = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=scanner_discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+ scanner.write_bytes(b"scan-v1")
+ changed_variants = (
+ variants[0],
+ variants[1],
+ InstrumentedVariant("ubsan", "x64", runtime, {"hash": "after"}),
+ )
+ runtime_changed = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=changed_variants,
+ )
+ (repository / "src/renpy.h").write_text(
+ "#pragma once\n// changed\n", encoding="utf-8"
+ )
+ source_changed = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+ changed_toolchain = replace(
+ toolchain, identity=toolchain.identity | {"llvm": "20.2"}
+ )
+ changed_discovery = instrumented_dependency_discovery_slice(
+ repository, changed_toolchain, variants=variants
+ )
+ toolchain_changed = instrumented_build_slice(
+ repository,
+ changed_toolchain,
+ discovery=changed_discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+
+ for kind in ("coverage", "asan", "ubsan"):
+ for project in ("renpy", "rpgmaker", "zanzarah", "tests"):
+ name = f"build-{project}-x64-{kind}"
+ self.assertEqual(
+ kind == "ubsan",
+ before.node(name).uid != runtime_changed.node(name).uid,
+ name,
+ )
+ self.assertEqual(
+ kind in {"coverage", "ubsan"},
+ before.node(name).uid != clang_changed.node(name).uid,
+ name,
+ )
+ self.assertEqual(
+ kind in {"coverage", "ubsan"},
+ before.node(name).uid != scanner_changed.node(name).uid,
+ name,
+ )
+ self.assertEqual(
+ project == "renpy",
+ before.node(name).uid != source_changed.node(name).uid,
+ name,
+ )
+ self.assertNotEqual(
+ before.node(name).uid, toolchain_changed.node(name).uid, name
+ )
+
+ def test_clang_discovery_and_build_uids_ignore_manifest_cache_state(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ runtime = self.llvm_runtime(root)
+ variants = (
+ InstrumentedVariant("coverage", "x64"),
+ InstrumentedVariant(
+ "ubsan", "x64", runtime, {"hash": "runtime"}
+ ),
+ )
+ cold = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ manifests = self.manifests(repository, cold)
+ cold_build = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=cold,
+ manifests=manifests,
+ variants=variants,
+ )
+ paths = BuildPaths(repository)
+ first = cold.targets[0]
+ cas = paths.cas(cold.node(first).uid)
+ cas.output.mkdir(parents=True)
+ (cas.output / "dependencies.json").write_bytes(manifests[first])
+ cas.touch.touch()
+ partial = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ partial_build = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=partial,
+ manifests=manifests,
+ variants=variants,
+ )
+ for target in cold.targets[1:]:
+ cas = paths.cas(cold.node(target).uid)
+ cas.output.mkdir(parents=True)
+ (cas.output / "dependencies.json").write_bytes(manifests[target])
+ cas.touch.touch()
+ full = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ full_build = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=full,
+ manifests=manifests,
+ variants=variants,
+ )
+
+ expected_discovery = {node.name: node.uid for node in cold.nodes}
+ expected_build = {node.name: node.uid for node in cold_build.nodes}
+ self.assertEqual(
+ expected_discovery,
+ {node.name: node.uid for node in partial.nodes},
+ )
+ self.assertEqual(
+ expected_discovery,
+ {node.name: node.uid for node in full.nodes},
+ )
+ self.assertEqual(
+ expected_build,
+ {node.name: node.uid for node in partial_build.nodes},
+ )
+ self.assertEqual(
+ expected_build,
+ {node.name: node.uid for node in full_build.nodes},
+ )
+
+ def test_clang_discovery_signs_stable_header_coverage(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ before = clang_dependency_discovery_slice(
+ repository,
+ toolchain,
+ project_names=("renpy",),
+ configuration="Coverage",
+ name_qualifier="coverage",
+ )
+ (repository / "src/renpy.h").write_text(
+ "#pragma once\n// dependency topology may have changed\n",
+ encoding="utf-8",
+ )
+ after = clang_dependency_discovery_slice(
+ repository,
+ toolchain,
+ project_names=("renpy",),
+ configuration="Coverage",
+ name_qualifier="coverage",
+ )
+
+ target = before.targets[0]
+ self.assertNotEqual(before.node(target).uid, after.node(target).uid)
+ before_capture = before.node(before.node(target).inputs[0])
+ after_capture = after.node(after.node(target).inputs[0])
+ self.assertNotEqual(before_capture.uid, after_capture.uid)
+
+ def test_instrumented_build_requires_declared_cross_directory_dependency(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ shared = repository / "src/shared/topology.h"
+ shared.parent.mkdir()
+ shared.write_text("#pragma once\n", encoding="utf-8")
+ toolchain = self.toolchain(root)
+ variants = (InstrumentedVariant("coverage", "x64"),)
+ discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ manifests = self.manifests(repository, discovery)
+ for target in discovery.targets:
+ if "-renpy-" in target:
+ manifests[target] = json.dumps(
+ {
+ "Data": {
+ "Source": str(
+ (repository / "src/renpy.cpp").resolve()
+ ),
+ "Includes": [str(shared.resolve())],
+ }
+ }
+ ).encode()
+
+ with self.assertRaisesRegex(
+ ValueError, "first-party dependency is not covered.*src/shared/topology.h"
+ ):
+ instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+
+ project = repository / "build/projects/renpy.vcxproj"
+ project.write_text(
+ project.read_text(encoding="utf-8").replace(
+ "",
+ " \n",
+ ),
+ encoding="utf-8",
+ )
+ declared_discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+ declared = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=declared_discovery,
+ manifests=manifests,
+ variants=variants,
+ )
+
+ self.assertIn("build-renpy-x64-coverage", declared.targets)
+
+ def test_clang_discovery_skips_unsupported_leak_architecture(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery = clang_dependency_discovery_slice(
+ repository,
+ self.toolchain(root),
+ project_names=("leak-probe",),
+ configuration="Coverage",
+ name_qualifier="coverage",
+ architectures=("x86", "x64"),
+ )
+
+ self.assertEqual(len(discovery.targets), 1)
+ self.assertIn("-x64-coverage-leak-probe-", discovery.targets[0])
+
+ def test_asan_only_build_does_not_require_clang(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ variants = (InstrumentedVariant("asan", "x64"),)
+ discovery = instrumented_dependency_discovery_slice(
+ repository, toolchain, variants=variants
+ )
+
+ with mock.patch(
+ "graphs.instrumented.resolve_llvm",
+ side_effect=AssertionError("ASan must stay on the MSVC path"),
+ ):
+ graph = instrumented_build_slice(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=self.manifests(repository, discovery),
+ variants=variants,
+ )
+
+ self.assertEqual(len(graph.targets), 4)
+ self.assertTrue(all(name.endswith("-x64-asan") for name in graph.targets))
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py
new file mode 100644
index 0000000..af9a250
--- /dev/null
+++ b/build/tests/test_leak_graph.py
@@ -0,0 +1,737 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import runpy
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).parents[1]))
+
+from core.graph import Command, Graph, Node # noqa: E402
+import core.leak as leak # noqa: E402
+from core.leak import LeakError, main as leak_main # noqa: E402
+from core.paths import BuildPaths # noqa: E402
+from graphs.leak import LEAK_MODES, LEAK_SCENARIOS, leak_graph # noqa: E402
+
+
+def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "build",
+ Command(("build.exe",)),
+ inputs,
+ )
+
+
+class LeakGraphTests(unittest.TestCase):
+ def fixture(
+ self, root: Path
+ ) -> tuple[Path, Graph, Path]:
+ repository = root / "repo"
+ repository.mkdir()
+ restore = node("restore-release")
+ builds = tuple(
+ node(f"build-{name}-x64-release", inputs=(restore.name,))
+ for name in ("leak-probe", "renpy", "rpgmaker", "zanzarah")
+ )
+ audit_gates = tuple(
+ node(f"audit-{kind}-x64-{module}", inputs=(producer.name,))
+ for module, producer in zip(
+ ("leak-probe", "renpy", "rpgmaker", "zanzarah"), builds, strict=True
+ )
+ for kind in ("pe", "binskim")
+ )
+ upstream = Graph((restore, *builds, *audit_gates), tuple(item.name for item in builds), {"build": 4})
+ tools = root / "tools"
+ tools.mkdir()
+ umdh = tools / "umdh.exe"
+ umdh.touch()
+ return repository, upstream, umdh
+
+ def build_graph(self, root: Path, **options: object) -> Graph:
+ repository, upstream, umdh = self.fixture(root)
+ return leak_graph(
+ repository,
+ upstream,
+ umdh=umdh,
+ umdh_identity={"version": "10.0"},
+ run_nonce="run-42",
+ **options,
+ )
+
+ def test_default_graph_has_one_shared_setup_and_fourteen_independent_branches(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ graph = self.build_graph(Path(temporary), jobs=7)
+
+ leak_nodes = tuple(item for item in graph.nodes if item.name.startswith("leak-"))
+ self.assertEqual(99, len(leak_nodes))
+ self.assertEqual(
+ graph.pools,
+ {"build": 4, "slot": 7},
+ )
+ self.assertEqual(
+ graph.targets,
+ tuple(
+ f"leak-gate-{mode}-{scenario}"
+ for mode in LEAK_MODES
+ for scenario in LEAK_SCENARIOS
+ ),
+ )
+
+ setup = graph.node("leak-setup-x64-release")
+ self.assertEqual(
+ setup.inputs,
+ ("build-leak-probe-x64-release",)
+ + tuple(
+ dependency
+ for module in ("renpy", "rpgmaker", "zanzarah")
+ for dependency in (
+ f"build-{module}-x64-release",
+ f"audit-pe-x64-{module}",
+ f"audit-binskim-x64-{module}",
+ )
+ ),
+ )
+ self.assertEqual(setup.command.argv[1:4], ("-m", "core.leak", "setup"))
+
+ for mode in LEAK_MODES:
+ for scenario in LEAK_SCENARIOS:
+ stem = f"{mode}-{scenario}"
+ preflight = graph.node(f"leak-preflight-{stem}")
+ capture = graph.node(f"leak-capture-{stem}")
+ adjacent = tuple(
+ graph.node(f"leak-diff-{stem}-window-{index}") for index in (1, 2)
+ )
+ overall = graph.node(f"leak-diff-{stem}-overall")
+ summary = graph.node(f"leak-summary-{stem}")
+ gate = graph.node(f"leak-gate-{stem}")
+
+ self.assertEqual(preflight.inputs, (setup.name,))
+ self.assertEqual(capture.inputs, (preflight.name,))
+ self.assertTrue(all(item.inputs == (capture.name,) for item in (*adjacent, overall)))
+ self.assertEqual(summary.inputs, tuple(item.name for item in (*adjacent, overall)))
+ self.assertEqual(gate.inputs, (summary.name,))
+ self.assertEqual(
+ (preflight.pool, capture.pool, adjacent[0].pool, summary.pool, gate.pool),
+ ("slot", "slot", "slot", "slot", "slot"),
+ )
+ self.assertEqual(preflight.command.argv[-2:], (mode, scenario))
+ self.assertEqual(capture.command.argv[6:8], (mode, scenario))
+ self.assertEqual((overall.command.argv[3], overall.command.argv[6]), ("diff", "overall"))
+ self.assertEqual(summary.command.argv[9], "0")
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in preflight.results),
+ ((f"reports/leak/x64/{mode}/{scenario}/preflight.json",
+ "leak", "application/json", "preflight.json"),),
+ )
+ self.assertEqual(
+ tuple((item.id, item.relative_path) for item in capture.results),
+ (
+ (f"reports/leak/x64/{mode}/{scenario}/capture.json", "capture.json"),
+ (f"reports/leak/x64/{mode}/{scenario}/snapshots", "snapshots"),
+ (f"reports/leak/x64/{mode}/{scenario}/probe.stderr.log", "probe.stderr.log"),
+ ),
+ )
+ evidence = tuple(zip(("window-1", "window-2"), adjacent, strict=True)) + (
+ ("overall", overall),
+ )
+ for label, item in evidence:
+ self.assertEqual(
+ tuple((result.id, result.relative_path) for result in item.results),
+ (
+ (f"reports/leak/x64/{mode}/{scenario}/diffs/{label}.json", "diff.json"),
+ (f"reports/leak/x64/{mode}/{scenario}/diffs/{label}.txt", "report.txt"),
+ ),
+ )
+ self.assertEqual(
+ tuple((item.id, item.relative_path) for item in summary.results),
+ ((f"reports/leak/x64/{mode}/{scenario}/summary.json", "summary.json"),),
+ )
+ self.assertEqual(gate.results, ())
+
+ def test_measurement_options_expand_snapshot_diffs_and_are_signed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ graph = self.build_graph(
+ root, warmup=2, iterations=3, windows=4, tolerance_bytes=17
+ )
+
+ self.assertEqual(113, len(tuple(item for item in graph.nodes if item.name.startswith("leak-"))))
+ capture = graph.node("leak-capture-operations-small-success")
+ self.assertEqual(capture.command.argv[-3:], ("2", "3", "4"))
+ summary = graph.node("leak-summary-operations-small-success")
+ self.assertEqual(
+ summary.inputs,
+ (
+ "leak-diff-operations-small-success-window-1",
+ "leak-diff-operations-small-success-window-2",
+ "leak-diff-operations-small-success-window-3",
+ "leak-diff-operations-small-success-overall",
+ ),
+ )
+ self.assertEqual(summary.command.argv[3:10], ("summarize", "operations", "small-success", "2", "3", "4", "17"))
+
+ def test_run_and_tool_identities_invalidate_only_the_measurement_partition(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, umdh = self.fixture(root)
+
+ def graph(
+ *,
+ nonce: str = "run-a",
+ umdh_version: str = "10.0",
+ tolerance: int = 4096,
+ ) -> Graph:
+ return leak_graph(
+ repository,
+ upstream,
+ umdh=umdh,
+ umdh_identity={"version": umdh_version},
+ run_nonce=nonce,
+ tolerance_bytes=tolerance,
+ )
+
+ before = graph()
+ rerun = graph(nonce="run-b")
+ new_umdh = graph(umdh_version="10.1")
+ new_tolerance = graph(tolerance=8192)
+
+ for current in before.nodes:
+ if not current.name.startswith("leak-"):
+ continue
+ measured = current.name.startswith(("leak-capture-", "leak-diff-", "leak-summary-", "leak-gate-"))
+ judged = current.name.startswith(("leak-summary-", "leak-gate-"))
+ self.assertEqual(measured, current.uid != rerun.node(current.name).uid, current.name)
+ self.assertEqual(measured, current.uid != new_umdh.node(current.name).uid, current.name)
+ self.assertEqual(judged, current.uid != new_tolerance.node(current.name).uid, current.name)
+
+ def test_rejects_invalid_tools_lineage_pools_and_measurement_options(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, umdh = self.fixture(root)
+
+ def invoke(
+ *,
+ source: Graph = upstream,
+ umdh_path: Path = umdh,
+ nonce: object = "run-42",
+ warmup: object = 8,
+ iterations: object = 100,
+ windows: object = 3,
+ tolerance: object = 4096,
+ jobs: object = 4,
+ ) -> Graph:
+ return leak_graph(
+ repository,
+ source,
+ umdh=umdh_path,
+ umdh_identity={"version": "10.0"},
+ run_nonce=nonce, # type: ignore[arg-type]
+ warmup=warmup, # type: ignore[arg-type]
+ iterations=iterations, # type: ignore[arg-type]
+ windows=windows, # type: ignore[arg-type]
+ tolerance_bytes=tolerance, # type: ignore[arg-type]
+ jobs=jobs, # type: ignore[arg-type]
+ )
+
+ for jobs in (True, "four", 0):
+ with self.subTest(jobs=jobs), self.assertRaisesRegex(
+ ValueError, "capacities"
+ ):
+ invoke(jobs=jobs)
+ for counts in ((True, 100, 3), (8, "many", 3), (8, 100, 0)):
+ with self.subTest(counts=counts), self.assertRaisesRegex(ValueError, "counts"):
+ invoke(warmup=counts[0], iterations=counts[1], windows=counts[2])
+ with self.assertRaisesRegex(ValueError, "at least three"):
+ invoke(windows=2)
+ for tolerance in (True, "zero", -1):
+ with self.subTest(tolerance=tolerance), self.assertRaisesRegex(ValueError, "tolerance"):
+ invoke(tolerance=tolerance)
+ for nonce in (7, "", "bad\0nonce"):
+ with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "nonce"):
+ invoke(nonce=nonce)
+
+ with self.assertRaisesRegex(FileNotFoundError, "not a file"):
+ invoke(umdh_path=umdh.parent)
+
+ missing_names = {
+ "build-leak-probe-x64-release",
+ "audit-pe-x64-leak-probe",
+ "audit-binskim-x64-leak-probe",
+ }
+ missing_build = Graph(
+ tuple(item for item in upstream.nodes if item.name not in missing_names),
+ tuple(name for name in upstream.targets if name != "build-leak-probe-x64-release"),
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "unknown node"):
+ invoke(source=missing_build)
+
+ missing_gate = Graph(
+ tuple(node for node in upstream.nodes if node.name != "audit-binskim-x64-renpy"),
+ upstream.targets,
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "audit gates"):
+ invoke(source=missing_gate)
+
+ matching = Graph(
+ upstream.nodes,
+ upstream.targets,
+ {"build": 4, "slot": 4},
+ )
+ self.assertEqual(4, invoke(source=matching).pools["slot"])
+ conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "slot": 1})
+ with self.assertRaisesRegex(ValueError, "conflicting pool"):
+ invoke(source=conflicting)
+
+
+class FakeProbe:
+ def __init__(self, lines: list[str], *, result: int = 0, timeout: bool = False) -> None:
+ class Stream(io.StringIO):
+ def close(stream) -> None:
+ stream.was_closed = True
+
+ self.stdout = Stream("\n".join(lines) + "\n")
+ self.stdin = Stream()
+ self.pid = 77
+ self.result = result
+ self.timeout = timeout
+ self.returncode: int | None = None
+ self.killed = False
+ self.descendant = mock.Mock()
+
+ def wait(self, timeout: int = 0) -> int:
+ if self.timeout:
+ raise leak.psutil.TimeoutExpired(timeout, self.pid)
+ self.returncode = self.result
+ return self.result
+
+ def poll(self) -> int | None:
+ return self.returncode
+
+ def children(self, *, recursive: bool) -> list[object]:
+ self.recursive = recursive
+ return [self.descendant]
+
+ def kill(self) -> None:
+ self.killed = True
+ self.returncode = -9
+
+
+class LeakWorkerTests(unittest.TestCase):
+ def output(self, root: Path):
+ return mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(root)}, clear=False)
+
+ def test_setup_stages_exact_binaries_symbols_and_hash_evidence(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ output = root / "out"
+ output.mkdir()
+ sources = []
+ for index, name in enumerate(leak.BINARIES):
+ directory = root / str(index)
+ directory.mkdir()
+ source = directory / name
+ source.write_bytes(name.encode())
+ (directory / f"{index}.pdb").write_bytes(bytes([index]))
+ sources.append(source)
+ with self.output(output):
+ self.assertEqual(0, leak_main(("setup", *(str(path) for path in sources))))
+ evidence = json.loads((output / "release-binaries.json").read_text(encoding="utf-8"))
+
+ self.assertEqual(list(leak.BINARIES), [item["name"] for item in evidence["binaries"]])
+ self.assertEqual("MT_StaticRelease", evidence["runtimeLibrary"])
+ self.assertTrue(all((output / name).is_file() for name in leak.BINARIES))
+ self.assertTrue(all((output / f"{index}.pdb").is_file() for index in range(4)))
+
+ with self.output(output), self.assertRaisesRegex(LeakError, "not found"):
+ leak_main(("setup", *(str(path) for path in (*sources[:-1], root / "missing"))))
+
+ def test_preflight_uses_communicate_safe_capture_and_checks_markers(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ (root / "leak-probe.exe").touch()
+ ready = "OBSERVER_LEAK_PROBE|READY|pid=12|mode=operations|configuration=Release|scenarios=malformed"
+ complete = subprocess.CompletedProcess([], 0, ready + "\nOBSERVER_LEAK_PROBE|DONE|pid=12\n")
+ with self.output(root), mock.patch("core.leak.subprocess.run", return_value=complete) as invoked:
+ leak_main(("preflight", str(root), "operations", "malformed"))
+ self.assertIs(invoked.call_args.kwargs["stderr"], subprocess.STDOUT)
+ self.assertIn("--automatic", invoked.call_args.args[0])
+
+ failures = (
+ subprocess.CompletedProcess([], 2, "broken"),
+ subprocess.CompletedProcess([], 0, ready),
+ subprocess.CompletedProcess([], 0, ready.replace("malformed", "read-failure") + "\nOBSERVER_LEAK_PROBE|DONE|pid=12"),
+ )
+ for result in failures:
+ with self.subTest(result=result), self.output(root), mock.patch(
+ "core.leak.subprocess.run", return_value=result
+ ), self.assertRaises(LeakError):
+ leak_main(("preflight", str(root), "operations", "malformed"))
+
+ def test_capture_streams_stdout_redirects_stderr_to_file_and_uses_psutil(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ (root / "leak-probe.exe").touch()
+ umdh = root / "umdh.exe"
+ umdh.touch()
+ gflags = root / "gflags.exe"
+ gflags.touch()
+ lines = [
+ "probe startup noise",
+ "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed",
+ *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|completed_operations=1" for label in ("baseline", "window-1", "window-2", "window-3")),
+ "OBSERVER_LEAK_PROBE|DONE|pid=77|completed_operations=4",
+ ]
+ process = FakeProbe(lines)
+
+ def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ if Path(argv[0]) == gflags:
+ output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else ""
+ return subprocess.CompletedProcess(argv, 0, output)
+ self.assertEqual(str(root), _options["env"]["_NT_SYMBOL_PATH"])
+ self.assertEqual("1", _options["env"]["OANOCACHE"])
+ Path(argv[-1].removeprefix("-f:")).write_text("BackTrace 1\n", encoding="utf-8")
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ with self.output(root), mock.patch("core.leak.psutil.Popen", return_value=process) as popen, mock.patch(
+ "core.leak.subprocess.run", side_effect=snapshot
+ ) as invoked:
+ leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3"))
+
+ commands = [call.args[0] for call in invoked.call_args_list]
+ self.assertEqual(
+ commands[:2],
+ [
+ [str(gflags), "/i", "leak-probe.exe"],
+ [str(gflags), "/i", "leak-probe.exe", "+ust"],
+ ],
+ )
+ self.assertEqual([str(gflags), "/i", "leak-probe.exe", "-ust"], commands[2])
+ self.assertIsNot(popen.call_args.kwargs["stderr"], subprocess.PIPE)
+ self.assertEqual("continue|baseline\ncontinue|window-1\ncontinue|window-2\ncontinue|window-3\n", process.stdin.getvalue())
+ self.assertTrue(process.stdin.was_closed)
+ self.assertTrue(process.stdout.was_closed)
+ self.assertEqual(77, json.loads((root / "capture.json").read_text())["processId"])
+
+ def test_capture_kills_the_probe_tree_on_protocol_timeout_or_exit_failure(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ (root / "leak-probe.exe").touch()
+ umdh = root / "umdh.exe"
+ umdh.touch()
+ gflags = root / "gflags.exe"
+ gflags.touch()
+ cases = (
+ FakeProbe(["OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed"]),
+ FakeProbe([
+ "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed",
+ "OBSERVER_LEAK_PROBE|SNAPSHOT|baseline|pid=12|",
+ ]),
+ FakeProbe([
+ "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed",
+ *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")),
+ "OBSERVER_LEAK_PROBE|DONE|pid=77|",
+ ], timeout=True),
+ FakeProbe([
+ "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed",
+ *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")),
+ "OBSERVER_LEAK_PROBE|DONE|pid=77|",
+ ], result=5),
+ )
+
+ def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ if Path(argv[0]) == gflags:
+ output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else ""
+ return subprocess.CompletedProcess(argv, 0, output)
+ Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8")
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ for index, process in enumerate(cases):
+ output = root / f"output-{index}"
+ output.mkdir()
+ with self.subTest(process=process), self.output(output), mock.patch(
+ "core.leak.psutil.Popen", return_value=process
+ ), mock.patch("core.leak.psutil.wait_procs"), mock.patch(
+ "core.leak.subprocess.run", side_effect=snapshot
+ ), self.assertRaises(LeakError):
+ leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3"))
+ if process.timeout or process.result == 0:
+ self.assertTrue(process.killed)
+
+ rejected = root / "gflags-rejected"
+ rejected.mkdir()
+ failure = subprocess.CompletedProcess([], 1, "access denied")
+
+ def reject(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ if len(argv) == 3:
+ return subprocess.CompletedProcess(
+ argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00000000"
+ )
+ return failure
+
+ with self.output(rejected), mock.patch(
+ "core.leak.subprocess.run", side_effect=reject
+ ), mock.patch("core.leak.psutil.Popen") as popen, self.assertRaisesRegex(
+ LeakError, "GFlags \\+ust failed"
+ ):
+ leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3"))
+ popen.assert_not_called()
+
+ cleanup = root / "gflags-cleanup"
+ cleanup.mkdir()
+
+ def reject_cleanup(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else ""
+ return failure if argv[-1] == "-ust" else subprocess.CompletedProcess(argv, 0, output)
+
+ process = FakeProbe([])
+ with self.output(cleanup), mock.patch(
+ "core.leak.subprocess.run", side_effect=reject_cleanup
+ ), mock.patch("core.leak.psutil.Popen", return_value=process), mock.patch(
+ "core.leak.psutil.wait_procs"
+ ), self.assertRaisesRegex(LeakError, "GFlags -ust failed"):
+ leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3"))
+ self.assertTrue(process.killed)
+
+ elevation = root / "gflags-elevation"
+ elevation.mkdir()
+ elevated = OSError("requires elevation")
+ elevated.winerror = 740 # type: ignore[attr-defined]
+ calls = 0
+
+ def unavailable(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ nonlocal calls
+ if Path(argv[0]) == gflags:
+ calls += 1
+ if calls == 1:
+ return subprocess.CompletedProcess(
+ argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00000000"
+ )
+ raise elevated
+ Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8")
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ process = FakeProbe([
+ "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed",
+ *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")),
+ "OBSERVER_LEAK_PROBE|DONE|pid=77|",
+ ])
+ with self.output(elevation), mock.patch(
+ "core.leak.subprocess.run", side_effect=unavailable
+ ), mock.patch("core.leak.psutil.Popen", return_value=process) as popen:
+ leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3"))
+ popen.assert_called_once()
+
+ existing = mock.Mock(returncode=0, stdout="Current Registry Settings for leak-probe.exe executable are: 00001000")
+ with mock.patch("core.leak.subprocess.run", return_value=existing) as invoked:
+ self.assertFalse(leak._enable_stack_traces(gflags, "leak-probe.exe"))
+ invoked.assert_called_once()
+
+ def test_stack_trace_activation_handles_missing_and_invalid_gflags(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ missing = root / "missing.exe"
+ self.assertFalse(leak._enable_stack_traces(missing, "probe.exe"))
+
+ gflags = root / "gflags.exe"
+ gflags.touch()
+ absent = mock.Mock(returncode=0, stdout="No Registry Settings for probe.exe executable")
+ changed = mock.Mock(returncode=0, stdout="")
+ with mock.patch("core.leak.subprocess.run", side_effect=(absent, changed)):
+ self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe"))
+
+ dual_view = mock.Mock(
+ returncode=0,
+ stdout="Current Registry Settings for probe.exe executable are: 00000000 : 00000000",
+ )
+ with mock.patch("core.leak.subprocess.run", side_effect=(dual_view, changed)):
+ self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe"))
+
+ for result, message in (
+ (mock.Mock(returncode=1, stdout="denied"), "query failed"),
+ (mock.Mock(returncode=0, stdout="unexpected"), "unrecognized"),
+ ):
+ with self.subTest(message=message), mock.patch(
+ "core.leak.subprocess.run", return_value=result
+ ), self.assertRaisesRegex(LeakError, message):
+ leak._enable_stack_traces(gflags, "probe.exe")
+
+ query = mock.Mock(returncode=0, stdout="Current Registry Settings for probe.exe executable are: 00000000")
+ unexpected = OSError("unexpected launch failure")
+ unexpected.winerror = 5 # type: ignore[attr-defined]
+ with mock.patch("core.leak.subprocess.run", side_effect=(query, unexpected)), self.assertRaises(OSError):
+ leak._enable_stack_traces(gflags, "probe.exe")
+
+ def test_diff_and_judge_preserve_growth_evidence_and_reject_leaks(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ report_text = "\n".join(("+ 500 (x) 1 allocs BackTrace ABC", "Total increase == 500"))
+
+ def compare(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ Path(argv[-1].removeprefix("-f:")).write_text(report_text, encoding="utf-8")
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ with self.output(root), mock.patch("core.leak.subprocess.run", side_effect=compare):
+ leak_main(("diff", "umdh", str(root), "window-1", "before", "after"))
+ first = root / "diff.json"
+ self.assertEqual(500, json.loads(first.read_text())["positiveStacks"]["ABC"])
+
+ paths = []
+ for label, total in (("window-1", 500), ("window-2", 500), ("overall", 1001)):
+ path = root / f"{label}.json"
+ path.write_text(json.dumps({"label": label, "totalIncrease": total, "positiveStacks": {"ABC": 500}}))
+ paths.extend((label, str(path)))
+ with self.output(root), self.assertRaisesRegex(LeakError, "sustained"):
+ leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths))
+ self.assertFalse(json.loads((root / "summary.json").read_text())["passed"])
+ with self.output(root):
+ leak_main(("summarize", "operations", "malformed", "1", "2", "3", "100", *paths))
+ with self.assertRaisesRegex(LeakError, "sustained"):
+ leak_main(("gate", str(root / "summary.json")))
+
+ for path in (root / "window-1.json", root / "window-2.json", root / "overall.json"):
+ document = json.loads(path.read_text())
+ document["totalIncrease"] = 0
+ document["positiveStacks"] = {}
+ path.write_text(json.dumps(document))
+ with self.output(root):
+ leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths))
+ leak_main(("gate", str(root / "summary.json")))
+ self.assertTrue(json.loads((root / "summary.json").read_text())["passed"])
+
+ def test_worker_rejects_invalid_arguments_protocol_and_umdh_evidence(self) -> None:
+ with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(LeakError, "OBSERVER_OUT_DIR"):
+ leak._output()
+ with self.assertRaisesRegex(LeakError, "setup expects"):
+ leak_main(("setup",))
+ with self.assertRaisesRegex(LeakError, "not found"):
+ leak_main(("preflight", "missing", "operations", "malformed"))
+ for mode, scenario in (("bad", "malformed"), ("operations", "bad")):
+ with self.subTest(selection=(mode, scenario)), self.assertRaisesRegex(LeakError, "selection"):
+ leak._selection(mode, scenario)
+ for value, message in (("many", "integer"), ("0", "at least")):
+ with self.subTest(value=value), self.assertRaisesRegex(LeakError, message):
+ leak._count(value, "rounds")
+ with self.assertRaisesRegex(LeakError, "requested process"):
+ leak._ready(
+ "OBSERVER_LEAK_PROBE|READY|pid=2|mode=operations|configuration=Release|scenarios=malformed",
+ "operations", "malformed", 1,
+ )
+ with self.assertRaisesRegex(LeakError, "expected leak action"):
+ leak_main(())
+ with self.assertRaisesRegex(LeakError, "expected leak action"):
+ leak_main(("unknown",))
+ with mock.patch.object(sys, "argv", ["leak.py", "unknown"]), self.assertRaises(LeakError):
+ leak_main()
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ invalid = root / "summary.json"
+ with self.output(root):
+ with self.assertRaisesRegex(LeakError, "summary is invalid"):
+ leak_main(("gate", str(invalid)))
+ invalid.write_text('{"passed":"yes"}', encoding="utf-8")
+ with self.assertRaisesRegex(LeakError, "summary is invalid"):
+ leak_main(("gate", str(invalid)))
+
+ process = FakeProbe([])
+ process.descendant.kill.side_effect = leak.psutil.NoSuchProcess(88)
+ with mock.patch("core.leak.psutil.wait_procs") as waited:
+ leak._kill_tree(process) # type: ignore[arg-type]
+ waited.assert_called_once()
+ self.assertTrue(process.killed)
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ destination = root / "snapshot.txt"
+ bad_results = (
+ (subprocess.CompletedProcess([], 2, "bad"), True, ""),
+ (subprocess.CompletedProcess([], 1, "bad"), True, "wrong"),
+ (subprocess.CompletedProcess([], 1, "bad"), False, "BackTrace"),
+ (subprocess.CompletedProcess([], 0, ""), False, "database is full BackTrace"),
+ (subprocess.CompletedProcess([], 0, ""), False, "empty"),
+ )
+ for result, baseline, content in bad_results:
+ if content:
+ destination.write_text(content, encoding="utf-8")
+ elif destination.exists():
+ destination.unlink()
+ with self.subTest(snapshot=(result.returncode, baseline, content)), mock.patch(
+ "core.leak.subprocess.run", return_value=result
+ ), self.assertRaises(LeakError):
+ leak._snapshot(Path("umdh"), 1, destination, baseline, {})
+
+ report = root / "report.txt"
+
+ def diff_result(returncode: int, content: str | None):
+ def run(_argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ if content is not None:
+ report.write_text(content, encoding="utf-8")
+ elif report.exists():
+ report.unlink()
+ return subprocess.CompletedProcess([], returncode, "bad")
+
+ return run
+
+ with self.output(root):
+ for returncode, content, message in (
+ (2, None, "failed"),
+ (0, None, "failed"),
+ (0, "no totals", "no total"),
+ ):
+ with self.subTest(diff=(returncode, content)), mock.patch(
+ "core.leak.subprocess.run", side_effect=diff_result(returncode, content)
+ ), self.assertRaisesRegex(LeakError, message):
+ leak_main(("diff", "umdh", str(root), "window-1", "before", "after"))
+ with mock.patch(
+ "core.leak.subprocess.run",
+ side_effect=diff_result(0, "Total decrease == 7"),
+ ):
+ leak_main(("diff", "umdh", str(root), "window-1", "before", "after"))
+ self.assertEqual(-7, json.loads((root / "diff.json").read_text())["totalIncrease"])
+
+ def test_judge_rejects_shape_and_label_mismatches(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ documents = []
+ for label in ("window-1", "window-2", "overall"):
+ path = root / f"{label}.json"
+ path.write_text(json.dumps({"label": label, "totalIncrease": 0, "positiveStacks": {}}))
+ documents.extend((label, str(path)))
+ with self.output(root):
+ for arguments in (
+ ("judge", "operations"),
+ ("judge", "operations", "malformed", "1", "2", "3", "0", "window-1", "x", "extra"),
+ ):
+ with self.subTest(arguments=arguments), self.assertRaisesRegex(LeakError, "judge expects"):
+ leak_main(arguments)
+ with self.assertRaisesRegex(LeakError, "labels"):
+ leak_main(("judge", "operations", "malformed", "1", "2", "3", "0", "wrong", *documents[1:]))
+ first = Path(documents[1])
+ value = json.loads(first.read_text())
+ value["label"] = "wrong"
+ first.write_text(json.dumps(value))
+ with self.assertRaisesRegex(LeakError, "labels"):
+ leak_main(("judge", "operations", "malformed", "1", "2", "3", "0", *documents))
+
+ with (
+ mock.patch.object(sys, "argv", ["leak.py", "setup"]),
+ self.assertWarnsRegex(RuntimeWarning, "core.leak"),
+ self.assertRaises(RuntimeError),
+ ):
+ runpy.run_module("core.leak", run_name="__main__")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_main.py b/build/tests/test_main.py
new file mode 100644
index 0000000..3a81305
--- /dev/null
+++ b/build/tests/test_main.py
@@ -0,0 +1,291 @@
+from __future__ import annotations
+
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
+from pathlib import Path
+import runpy
+import re
+from types import SimpleNamespace
+import sys
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+import main # noqa: E402
+
+
+class FakeDriver:
+ instances: list[FakeDriver] = []
+
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ self.constructor = (args, kwargs)
+ self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
+ FakeDriver.instances.append(self)
+
+ def __getattr__(self, name: str):
+ async def invoke(*args: object, **kwargs: object) -> tuple[Path, ...]:
+ self.calls.append((name, args, kwargs))
+ return (Path("out") / name,)
+ return invoke
+
+
+class MainTests(unittest.TestCase):
+ def setUp(self) -> None:
+ FakeDriver.instances.clear()
+
+ def invoke(self, argv: list[str], *, deferred: tuple[object, ...] = ()):
+ toolchain = object()
+ source_tools = object()
+ dumpbin, binskim, umdh = object(), object(), object()
+ stdout = StringIO()
+ patches = (
+ mock.patch.object(main, "BuildDriver", FakeDriver),
+ mock.patch.object(main, "_run_id", return_value="run-id"),
+ mock.patch.object(main, "discover_msvc_toolchain", return_value=toolchain),
+ mock.patch.object(main, "discover_source_tools", return_value=source_tools),
+ mock.patch.object(main, "resolve_dumpbin", return_value=dumpbin),
+ mock.patch.object(main, "resolve_binskim", return_value=binskim),
+ mock.patch.object(main, "resolve_umdh", return_value=umdh),
+ mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=deferred)),
+ )
+ with (
+ patches[0] as driver, patches[1], patches[2] as discover,
+ patches[3] as source, patches[4] as find_dumpbin,
+ patches[5] as find_binskim, patches[6] as find_umdh,
+ patches[7] as route, redirect_stdout(stdout),
+ ):
+ result = main.main(argv)
+ return SimpleNamespace(
+ result=result, stdout=stdout.getvalue(), driver=driver, discover=discover,
+ source=source, dumpbin=find_dumpbin, binskim=find_binskim, umdh=find_umdh,
+ route=route, source_tools=source_tools, tools=(dumpbin, binskim, umdh),
+ )
+
+ def test_table_routes_every_driver_command_and_preserves_legacy_options(self) -> None:
+ repository = Path("selected-repo")
+ dumpbin, binskim, umdh = object(), object(), object()
+ cases = (
+ ("build", ["-Arch", " x64, X64,x86 ", "-Config", "debug, RELEASE"],
+ (("x64", "x86"), ("Debug", "Release")), {}),
+ ("test", ["-Config", "Release", "-Corpus", "golden", "-TestShards", "7"],
+ (("x64",), ("Release",)), {"test_shards": 7, "corpus": Path("golden"), "run_nonce": "run-id"}),
+ ("compiler_analysis", [], (("x64",),), {}),
+ ("test_coverage", ["-Corpus", "golden"],
+ (("x64",),), {"test_shards": 4, "corpus": Path("golden"), "run_nonce": "run-id"}),
+ ("test_asan", ["-Arch", "x86,x64"], (("x86", "x64"),), {"test_shards": 4}),
+ ("test_ubsan", [], (("x64",),), {"test_shards": 4}),
+ ("fuzz", ["-FuzzSeconds", "91", "-FuzzTarget", "RenPy"], (),
+ {"run_nonce": "run-id", "seconds": 91, "targets": ("renpy",)}),
+ ("verify_source", ["-ExportDir", "source-evidence"], (),
+ {"export_dir": Path("source-evidence")}),
+ ("verify_arch", ["-Arch", "x86", "-ExportDir", "x86-evidence",
+ "-FuzzSeconds", "17"], (("x86",),),
+ {"corpus": None, "run_nonce": "run-id", "fuzz_seconds": 17,
+ "test_shards": 4, "warmup": 8, "iterations": 100, "windows": 3,
+ "tolerance_bytes": 0, "export_dir": Path("x86-evidence")}),
+ ("verify", ["-Arch", "all", "-Corpus", "golden", "-FuzzSeconds", "17",
+ "-LeakWarmup", "2", "-LeakIterations", "5", "-LeakWindows", "4",
+ "-LeakToleranceBytes", "9", "-ExportDir", "all-evidence"],
+ (("x86", "x64", "arm64"),),
+ {"corpus": Path("golden"), "run_nonce": "run-id", "fuzz_seconds": 17,
+ "test_shards": 4, "warmup": 2, "iterations": 5, "windows": 4,
+ "tolerance_bytes": 9, "export_dir": Path("all-evidence")}),
+ )
+ command_names = {
+ "compiler_analysis": "compiler-analysis", "test_coverage": "test-coverage",
+ "test_asan": "test-asan", "test_ubsan": "test-ubsan",
+ "verify_source": "verify-source", "verify_arch": "verify-arch",
+ }
+ for method, options, positional, keywords in cases:
+ with self.subTest(command=method):
+ result = self.invoke([command_names.get(method, method), "-Repository", str(repository), *options])
+ instance = FakeDriver.instances[-1]
+ self.assertEqual(instance.constructor, (
+ (repository, "run-id", mock.ANY),
+ {"jobs": None, "prune_cas": False},
+ ))
+ self.assertEqual(instance.calls, [(method, positional, keywords)])
+ self.assertEqual(result.result, 0)
+ self.assertEqual(result.stdout.strip(), str(Path("out") / method))
+
+ restore = self.invoke([
+ "restore", "-Repository", str(repository), "-Arch", "arm64,ALL",
+ "-RestoreFlavor", "ALL",
+ ])
+ self.assertEqual(FakeDriver.instances[-1].calls, [
+ ("restore", (("x86", "x64", "arm64"),), {"flavors": ("",)}),
+ ("restore", (("x86", "x64"),), {"flavors": ("asan",)}),
+ ])
+ self.invoke(["restore", "-Repository", str(repository)])
+ self.assertEqual(FakeDriver.instances[-1].calls, [
+ ("restore", (("x64",),), {"flavors": ("",)})
+ ])
+ no_op = self.invoke([
+ "restore", "-Repository", str(repository), "-Arch", "arm64",
+ "-RestoreFlavor", "asan",
+ ])
+ self.assertEqual(FakeDriver.instances[-1].calls, [])
+ self.assertEqual(no_op.stdout, "")
+
+ source = self.invoke(["source-checks", "-Repository", str(repository)])
+ self.assertEqual(FakeDriver.instances[-1].calls, [
+ ("source_checks", (("x64",), source.source_tools), {})
+ ])
+ source.source.assert_called_once()
+
+ for command, method in (("audit-binaries", "audit"), ("package", "package")):
+ with self.subTest(command=command):
+ options = [command, "-Repository", str(repository), "-Jobs", "3"]
+ if command == "package":
+ options.extend(("-ExportDir", "package-evidence"))
+ result = self.invoke(options)
+ dumpbin, binskim, _umdh = result.tools
+ self.assertEqual(FakeDriver.instances[-1].calls, [
+ (method, (("x64",),), {
+ "dumpbin": dumpbin,
+ "binskim": binskim,
+ **({"export_dir": Path("package-evidence")} if command == "package" else {}),
+ })
+ ])
+
+ leak = self.invoke([
+ "test-leaks", "-Repository", str(repository), "-LeakWarmup", "2",
+ "-LeakIterations", "6", "-LeakWindows", "5", "-LeakToleranceBytes", "10",
+ ])
+ dumpbin, binskim, umdh = leak.tools
+ self.assertEqual(FakeDriver.instances[-1].calls, [
+ ("test_leaks", (), {
+ "run_nonce": "run-id", "dumpbin": dumpbin, "binskim": binskim, "umdh": umdh,
+ "warmup": 2, "iterations": 6, "windows": 5, "tolerance_bytes": 10,
+ })
+ ])
+
+ def test_doctor_and_clean_bypass_toolchain_and_driver(self) -> None:
+ with (
+ mock.patch.object(main, "doctor_main", return_value=1) as doctor,
+ mock.patch.object(main, "discover_msvc_toolchain") as discover,
+ ):
+ self.assertEqual(main.main(["doctor"]), 1)
+ doctor.assert_called_once_with(())
+ discover.assert_not_called()
+
+ with mock.patch.object(main, "run_clean", return_value=0) as clean:
+ self.assertEqual(main.main([
+ "clean", "-Repository", "repo", "-CleanMode", "stale-work"
+ ]), 0)
+ clean.assert_called_once_with(Path("repo"), "stale-work")
+ self.assertEqual(FakeDriver.instances, [])
+
+ def test_clean_adapter_and_run_identifier_are_exact(self) -> None:
+ with mock.patch("core.clean.main", return_value=0) as clean:
+ self.assertEqual(main.run_clean(Path("repo"), "all"), 0)
+ clean.assert_called_once_with((str(Path("repo")), "--mode", "all"))
+ self.assertRegex(main._run_id(), re.compile(r"^\d{8}T\d{6}-\d+$"))
+
+ def test_verify_prints_explicit_host_deferrals_before_outputs(self) -> None:
+ deferred = SimpleNamespace(gate="tests", architecture="arm64", reason="not runnable")
+ result = self.invoke(["verify", "-Arch", "arm64"], deferred=(deferred,))
+ self.assertEqual(
+ result.stdout.splitlines(),
+ ["[DEFERRED] tests arm64: not runnable", str(Path("out") / "verify")],
+ )
+
+ async def fail(*_args: object, **_kwargs: object) -> tuple[Path, ...]:
+ raise RuntimeError("verify failed")
+
+ stdout = StringIO()
+ with (
+ mock.patch.dict(
+ main._COMMANDS,
+ {"verify": (main._COMMANDS["verify"][0], fail)},
+ ),
+ mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=(deferred,))),
+ mock.patch.object(main, "discover_msvc_toolchain", return_value=object()),
+ mock.patch.object(main, "BuildDriver", FakeDriver),
+ mock.patch.object(main, "_run_id", return_value="run-id"),
+ redirect_stdout(stdout), self.assertRaisesRegex(RuntimeError, "verify failed"),
+ ):
+ main.main(["verify", "-Arch", "arm64"])
+ self.assertEqual(stdout.getvalue(), "")
+
+ def test_verify_prune_cas_flag_enables_driver_sweep(self) -> None:
+ result = self.invoke([
+ "verify-arch", "-Arch", "x64", "-PruneCas",
+ ])
+
+ instance = FakeDriver.instances[-1]
+ self.assertEqual(
+ instance.constructor,
+ ((Path(__file__).parents[2], "run-id", mock.ANY), {
+ "jobs": None,
+ "prune_cas": True,
+ }),
+ )
+ self.assertEqual(result.result, 0)
+
+ def test_help_and_removed_skip_restore_contract(self) -> None:
+ for argv in ([], ["help"]):
+ with self.subTest(argv=argv), redirect_stdout(StringIO()) as stdout:
+ self.assertEqual(main.main(argv), 0)
+ self.assertIn("audit-binaries", stdout.getvalue())
+
+ for command in ("build", "restore"):
+ with (
+ self.subTest(command=command),
+ redirect_stderr(StringIO()),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ main.main([command, "-SkipDependencyRestore"])
+ self.assertEqual(raised.exception.code, 2)
+
+ def test_invalid_legacy_options_fail_before_discovery(self) -> None:
+ cases = (
+ ["build", "-Arch", "mips"],
+ ["build", "-Arch", ""], ["build", "-Config", "Profile"],
+ ["restore", "-RestoreFlavor", "ubsan"], ["fuzz", "-FuzzSeconds", "0"],
+ ["fuzz", "-FuzzSeconds", "86401"], ["fuzz", "-FuzzTarget", "bad"],
+ ["fuzz", "-Arch", "x86"], ["test-leaks", "-Arch", "x86"],
+ ["test-leaks", "-LeakWarmup", "0"], ["test-leaks", "-LeakIterations", "x"],
+ ["test-leaks", "-LeakWindows", "2"], ["test-leaks", "-LeakWindows", "11"],
+ ["test-leaks", "-LeakToleranceBytes", "-1"],
+ ["test-coverage", "-CoverageThreshold", "99"],
+ ["test-coverage", "-CoverageThreshold", "100"],
+ ["test-coverage", "-CoverageThreshold", "100.0"],
+ ["test", "-TestShards", "0"], ["build", "-Jobs", "0"],
+ ["verify-arch", "-Arch", "all"],
+ )
+ for argv in cases:
+ with (
+ self.subTest(argv=argv),
+ mock.patch.object(main, "discover_msvc_toolchain") as discover,
+ redirect_stderr(StringIO()), self.assertRaises(SystemExit) as raised,
+ ):
+ main.main(argv)
+ self.assertEqual(raised.exception.code, 2)
+ discover.assert_not_called()
+
+ def test_script_entry_point_uses_the_same_cli(self) -> None:
+ with (
+ mock.patch.object(sys, "argv", [str(BUILD_ROOT / "main.py"), "doctor"]),
+ mock.patch("core.doctor.main", return_value=0) as doctor,
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_path(str(BUILD_ROOT / "main.py"), run_name="__main__")
+ self.assertEqual(raised.exception.code, 0)
+ doctor.assert_called_once_with(())
+
+ def test_root_powershell_entry_point_uses_frozen_project_environment(self) -> None:
+ script = (BUILD_ROOT.parent / "build.ps1").read_text(encoding="utf-8")
+
+ self.assertIn("uv run --project $buildProject --frozen --no-sync", script)
+ self.assertIn("$PSScriptRoot 'build'", script)
+ self.assertNotIn("tools\\build", script)
+ self.assertIn("exit $LASTEXITCODE", script)
+ self.assertNotIn("build\\build.ps1", script)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_msbuild_contracts.py b/build/tests/test_msbuild_contracts.py
new file mode 100644
index 0000000..5647de4
--- /dev/null
+++ b/build/tests/test_msbuild_contracts.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from pathlib import Path
+import unittest
+import xml.etree.ElementTree as ET
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+MSBUILD = "{http://schemas.microsoft.com/developer/msbuild/2003}"
+
+
+def _project(relative_path: str) -> ET.Element:
+ return ET.parse(REPOSITORY_ROOT / relative_path).getroot()
+
+
+class MSBuildContractsTests(unittest.TestCase):
+ def test_direct_msbuild_fallback_stays_below_managed_work_root(self) -> None:
+ project = _project("build/ObserverProject.props")
+
+ artifacts_root = project.find(f".//{MSBUILD}ArtifactsRoot")
+ self.assertIsNotNone(artifacts_root)
+ self.assertEqual(
+ artifacts_root.text,
+ "$(RepositoryRoot)out\\work\\manual-msbuild\\",
+ )
+
+ def test_analysis_reports_have_stable_unique_project_paths(self) -> None:
+ project = _project("build/ObserverProject.props")
+
+ report_name = project.find(f".//{MSBUILD}ObserverAnalysisReportName")
+ self.assertIsNotNone(report_name)
+ self.assertEqual(report_name.text, "$(ProjectName)")
+ self.assertEqual(
+ report_name.get("Condition"), "'$(ObserverAnalysisReportName)' == ''"
+ )
+
+ report_logs = project.findall(f".//{MSBUILD}PREfastLog")
+ self.assertIn(
+ "$(ObserverAnalysisReportDirectory)\\$(PlatformMoniker)\\"
+ "$(ObserverAnalysisReportName).sarif",
+ (log.text for log in report_logs),
+ )
+
+ def test_fuzz_validation_allows_only_x64_fuzz_or_compile_analysis(self) -> None:
+ project = _project("build/ObserverFuzz.props")
+ validation = project.find(
+ f".//{MSBUILD}Target[@Name='ValidateFuzzConfiguration']/{MSBUILD}Error"
+ )
+
+ self.assertIsNotNone(validation)
+ self.assertEqual(
+ validation.get("Condition"),
+ "'$(ObserverCompileAnalysis)' != 'true' And "
+ "'$(Configuration)|$(Platform)' != 'Fuzz|x64'",
+ )
+
+ def test_leak_probe_is_shipping_x64_release_with_release_zlib(self) -> None:
+ project = _project("build/projects/leak-probe.vcxproj")
+
+ runtimes = [
+ node.text for node in project.findall(f".//{MSBUILD}RuntimeLibrary")
+ ]
+ dependencies = [
+ node.text for node in project.findall(f".//{MSBUILD}AdditionalDependencies")
+ ]
+ validation = project.find(
+ f".//{MSBUILD}Target[@Name='ValidateLeakProbeConfiguration']/{MSBUILD}Error"
+ )
+
+ self.assertEqual(runtimes, ["MultiThreaded"])
+ self.assertEqual(dependencies, ["zs.lib;%(AdditionalDependencies)"])
+ self.assertIsNotNone(validation)
+ self.assertEqual(
+ validation.get("Condition"),
+ "'$(Configuration)|$(Platform)' != 'Release|x64'",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_native_graph.py b/build/tests/test_native_graph.py
new file mode 100644
index 0000000..6a24a2a
--- /dev/null
+++ b/build/tests/test_native_graph.py
@@ -0,0 +1,415 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from graphs.native import native_dependency_discovery_slice, native_graph # noqa: E402
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ msbuild: Path
+ pwsh: Path
+ vcpkg_root: Path
+ environment: tuple[tuple[str, str], ...]
+ identity: dict[str, str]
+
+
+class NativeGraphTests(unittest.TestCase):
+ def test_parallel_msvc_builds_do_not_depend_on_shared_compiler_pdb_server(
+ self,
+ ) -> None:
+ root = ET.parse(
+ BUILD_ROOT / "ObserverProject.props"
+ ).getroot()
+ debug_information = root.find(
+ ".//{http://schemas.microsoft.com/developer/msbuild/2003}DebugInformationFormat"
+ )
+
+ self.assertIsNotNone(debug_information)
+ self.assertEqual(debug_information.text, "OldStyle")
+
+ def repository(self, root: Path) -> Path:
+ project = """
+
+
+
+
+
+ {definition}
+
+"""
+ files = {
+ "src/renpy.cpp": '#include "renpy.h"\n#include \n',
+ "src/renpy.h": "#pragma once\n",
+ "src/rpgmaker.cpp": '#include "rpgmaker.h"\n',
+ "src/rpgmaker.h": "#pragma once\n",
+ "src/zanzarah.cpp": '#include "zanzarah.h"\n',
+ "src/zanzarah.h": "#pragma once\n",
+ "src/tests.cpp": (
+ '#include "tests.h"\n#include \n'
+ ),
+ "src/tests.h": "#pragma once\n",
+ "src/leak-probe.cpp": '#include "leak-probe.h"\n#include \n',
+ "src/leak-probe.h": "#pragma once\n",
+ "src/renpy.def": "EXPORTS\n OpenStorage\n",
+ "build/ObserverProjectConfigurations.props": "\n",
+ "build/ObserverConfiguration.props": "\n",
+ "build/ObserverProject.props": "\n",
+ "vcpkg.json": '{"dependencies":["zlib"]}\n',
+ }
+ for architecture in ("x64", "arm64"):
+ files[f"build/vcpkg/triplets/observer-{architecture}-windows-static.cmake"] = (
+ "set(VCPKG_LIBRARY_LINKAGE static)\n"
+ )
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe"):
+ definition = (
+ ""
+ "$(RepositoryRoot)src\\renpy.def"
+ ""
+ if name == "renpy"
+ else ""
+ )
+ files[f"build/projects/{name}.vcxproj"] = project.format(
+ name=name, definition=definition
+ )
+ for relative, content in files.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return root
+
+ def toolchain(self, root: Path) -> FakeToolchain:
+ tools = root / "fake tools"
+ tools.mkdir()
+ vcpkg_root = tools / "vcpkg"
+ vcpkg_root.mkdir()
+ (vcpkg_root / "vcpkg.exe").touch()
+ msbuild = tools / "MSBuild.exe"
+ pwsh = tools / "pwsh.exe"
+ msbuild.touch()
+ pwsh.touch()
+ return FakeToolchain(
+ msbuild=msbuild,
+ pwsh=pwsh,
+ vcpkg_root=vcpkg_root,
+ environment=(("PATH", str(tools)),),
+ identity={"msbuild": "17.14", "msvc": "14.44"},
+ )
+
+ def staged_graphs(self, repository: Path, toolchain: FakeToolchain, **options):
+ discovery = native_dependency_discovery_slice(
+ repository,
+ toolchain,
+ jobs=options.get("jobs", 2),
+ architectures=options.get("architectures", ("x64",)),
+ configurations=options.get("configurations", ("Debug",)),
+ include_leak_probe=options.get("include_leak_probe", False),
+ )
+ manifests = {}
+ for target in discovery.targets:
+ project = next(
+ name
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe")
+ if target.endswith(f"-{name}")
+ )
+ source = repository / f"src/{project}.cpp"
+ manifests[target] = json.dumps(
+ {
+ "Data": {
+ "Source": str(source.resolve()),
+ "Includes": [str((repository / f"src/{project}.h").resolve())],
+ }
+ }
+ ).encode()
+ return discovery, native_graph(
+ repository, toolchain, discovery=discovery, manifests=manifests, **options
+ )
+
+ def test_projects_restore_and_test_shards_form_a_fine_grained_dag(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery, graph = self.staged_graphs(
+ repository,
+ self.toolchain(root),
+ architectures=("x64", "arm64"),
+ configurations=("Debug", "Release"),
+ runnable_architectures=("x64",),
+ test_shards=2,
+ jobs=8,
+ )
+
+ names = tuple(node.name for node in graph.nodes)
+ self.assertEqual(names.count("restore-vcpkg-x64"), 1)
+ self.assertEqual(names.count("restore-vcpkg-arm64"), 1)
+ self.assertIn("build-renpy-x64-debug", names)
+ self.assertIn("build-tests-arm64-release", names)
+ self.assertFalse(any("leak-probe" in name for name in names))
+ self.assertEqual(
+ tuple(name for name in names if name.startswith("test-shard-")),
+ (
+ "test-shard-x64-debug-0",
+ "test-shard-x64-debug-1",
+ "test-shard-x64-release-0",
+ "test-shard-x64-release-1",
+ ),
+ )
+ self.assertEqual(graph.pools, {"restore": 1, "slot": 8})
+
+ restore = graph.node("restore-vcpkg-x64")
+ builds = tuple(
+ graph.node(f"build-{project}-x64-debug")
+ for project in ("renpy", "rpgmaker", "zanzarah", "tests")
+ )
+ expected = tuple(
+ next(name for name in discovery.targets if f"-debug-{project}-" in name)
+ for project in ("renpy", "rpgmaker", "zanzarah", "tests")
+ )
+ self.assertEqual(tuple(node.inputs[0] for node in builds), expected)
+ self.assertTrue(all(node.pool == "slot" for node in builds))
+ for node in builds:
+ script = node.command.stdin.decode("utf-8")
+ self.assertIn("'/m:1'", script)
+ self.assertIn("'/p:BuildProjectReferences=false'", script)
+ self.assertIn('"/p:OutDir=$outDir\\"', script)
+ self.assertIn('"/p:IntDir=$buildDir\\"', script)
+ for node in builds:
+ self.assertIn("'/p:VcpkgInstalledDir=", node.command.stdin.decode())
+ shard = graph.node("test-shard-x64-debug-0")
+ self.assertEqual(
+ shard.inputs,
+ tuple(node.name for node in builds),
+ )
+ shard_script = shard.command.stdin.decode("utf-8")
+ for artifact in ("renpy.so", "rpgmaker.so", "zanzarah.so", "tests.exe"):
+ self.assertIn(artifact, shard_script)
+ self.assertIn("'--shard-count'", shard_script)
+ self.assertIn("'2'", shard_script)
+ self.assertIn("'--shard-index'", shard_script)
+ self.assertIn("'0'", shard_script)
+ self.assertIn("'JUnit::out=", shard_script)
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in shard.results),
+ ((
+ "reports/tests/x64/debug/unit/shard-0.xml",
+ "test", "application/xml", "tests.xml",
+ ),),
+ )
+ self.assertTrue(all(
+ not node.results
+ for node in graph.nodes
+ if node.name.startswith(("restore-", "discover-", "build-"))
+ ))
+
+ def test_leak_probe_is_an_explicit_release_only_request(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ discovery, graph = self.staged_graphs(
+ repository,
+ self.toolchain(root),
+ configurations=("Debug", "Release"),
+ runnable_architectures=(),
+ include_leak_probe=True,
+ )
+
+ names = tuple(node.name for node in graph.nodes)
+ self.assertIn("build-leak-probe-x64-release", names)
+ self.assertNotIn("build-leak-probe-x64-debug", names)
+ leak_probe = graph.node("build-leak-probe-x64-release")
+ self.assertEqual(len(leak_probe.inputs), 1)
+ self.assertIn("-release-leak-probe-", leak_probe.inputs[0])
+ self.assertTrue(any("-release-leak-probe-" in name for name in discovery.targets))
+
+ def test_external_corpus_is_test_only_and_nonce_invalidated(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ corpus = root / "corpus"
+ other_corpus = root / "other-corpus"
+ corpus.mkdir()
+ other_corpus.mkdir()
+ options = dict(
+ configurations=("Debug",), runnable_architectures=("x64",), test_shards=1
+ )
+ _discovery, baseline = self.staged_graphs(repository, toolchain, **options)
+ _discovery, no_corpus = self.staged_graphs(
+ repository, toolchain, corpus=None, run_nonce="ignored", **options
+ )
+ _discovery, first = self.staged_graphs(
+ repository, toolchain, corpus=corpus, run_nonce="run-one", **options
+ )
+ (corpus / "large-external-file.bin").write_bytes(b"not signed")
+ _discovery, content_changed = self.staged_graphs(
+ repository, toolchain, corpus=corpus, run_nonce="run-one", **options
+ )
+ _discovery, rerun = self.staged_graphs(
+ repository, toolchain, corpus=corpus, run_nonce="run-two", **options
+ )
+ _discovery, moved = self.staged_graphs(
+ repository, toolchain, corpus=other_corpus, run_nonce="run-one", **options
+ )
+ _discovery, build_only = self.staged_graphs(
+ repository, toolchain, corpus=corpus, run_nonce="build-only",
+ configurations=("Release",), runnable_architectures=(),
+ )
+
+ shard_name = "test-shard-x64-debug-0"
+ corpus_name = "corpus-shard-x64-debug-0"
+ self.assertEqual(baseline.node(shard_name).uid, no_corpus.node(shard_name).uid)
+ self.assertEqual(baseline.node(shard_name).uid, first.node(shard_name).uid)
+ self.assertFalse(any(node.name.startswith("corpus-shard-") for node in baseline.nodes))
+ self.assertEqual(first.node(corpus_name).uid, content_changed.node(corpus_name).uid)
+ self.assertNotEqual(first.node(corpus_name).uid, rerun.node(corpus_name).uid)
+ self.assertNotEqual(first.node(corpus_name).uid, moved.node(corpus_name).uid)
+ self.assertEqual(first.node(corpus_name).inputs, first.node(shard_name).inputs)
+ self.assertEqual(dict(first.node(corpus_name).command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve()))
+ self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(shard_name).command.env))
+ self.assertIn("'[compatibility]'", first.node(corpus_name).command.stdin.decode())
+ self.assertEqual(
+ tuple((item.id, item.relative_path) for item in first.node(corpus_name).results),
+ (("reports/tests/x64/debug/corpus/shard-0.xml", "tests.xml"),),
+ )
+ self.assertTrue(all(
+ "OBSERVER_TEST_CORPUS" not in dict(node.command.env)
+ for node in first.nodes if not node.name.startswith("corpus-shard-")
+ ))
+ self.assertFalse(any(
+ node.name.startswith(("test-shard-", "corpus-")) for node in build_only.nodes
+ ))
+
+ def test_project_content_invalidates_only_its_build_and_consuming_shards(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ options = dict(
+ architectures=("x64",),
+ configurations=("Debug",),
+ runnable_architectures=("x64",),
+ test_shards=2,
+ )
+ _discovery, before = self.staged_graphs(repository, toolchain, **options)
+ (repository / "src/renpy.cpp").write_text(
+ '#include "renpy.h"\n#include \n// changed\n', encoding="utf-8"
+ )
+ _discovery, after = self.staged_graphs(repository, toolchain, **options)
+ (repository / "src/rpgmaker.h").write_text(
+ "#pragma once\n#include \n", encoding="utf-8"
+ )
+ _discovery, unknown_package = self.staged_graphs(
+ repository, toolchain, **options
+ )
+
+ for name in (
+ "restore-vcpkg-x64",
+ "build-rpgmaker-x64-debug",
+ "build-zanzarah-x64-debug",
+ "build-tests-x64-debug",
+ ):
+ self.assertEqual(before.node(name).uid, after.node(name).uid)
+ self.assertNotEqual(
+ before.node("build-renpy-x64-debug").uid,
+ after.node("build-renpy-x64-debug").uid,
+ )
+ for index in range(2):
+ name = f"test-shard-x64-debug-{index}"
+ self.assertNotEqual(before.node(name).uid, after.node(name).uid)
+ rpgmaker = "build-rpgmaker-x64-debug"
+ self.assertEqual(len(unknown_package.node(rpgmaker).inputs), 1)
+ self.assertNotEqual(after.node(rpgmaker).uid, unknown_package.node(rpgmaker).uid)
+
+ def test_invalid_axes_are_rejected_before_graph_construction(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ with self.assertRaisesRegex(ValueError, "unsupported architecture"):
+ native_graph(
+ repository,
+ toolchain,
+ discovery=None,
+ manifests={},
+ architectures=("mips",),
+ runnable_architectures=(),
+ )
+ with self.assertRaisesRegex(ValueError, "unsupported configuration"):
+ native_graph(
+ repository,
+ toolchain,
+ discovery=None,
+ manifests={},
+ configurations=("RelWithDebInfo",),
+ runnable_architectures=(),
+ )
+ with self.assertRaisesRegex(ValueError, "test_shards"):
+ native_graph(
+ repository,
+ toolchain,
+ discovery=None,
+ manifests={},
+ runnable_architectures=("x64",),
+ test_shards=0,
+ )
+ with self.assertRaisesRegex(ValueError, "unsupported configuration"):
+ native_dependency_discovery_slice(
+ repository, toolchain, configurations=("RelWithDebInfo",)
+ )
+ with self.assertRaisesRegex(ValueError, "unsupported architecture"):
+ native_graph(
+ repository, toolchain, discovery=None, manifests={},
+ runnable_architectures=("mips",),
+ )
+ with self.assertRaisesRegex(ValueError, "dependency discovery is required"):
+ native_graph(
+ repository, toolchain, discovery=None, manifests={},
+ runnable_architectures=(),
+ )
+ corpus = root / "corpus"
+ corpus.mkdir()
+ with self.assertRaisesRegex(ValueError, "run nonce"):
+ native_graph(
+ repository, toolchain, discovery=None, manifests={},
+ runnable_architectures=("x64",), corpus=corpus, run_nonce="",
+ )
+ with self.assertRaises(FileNotFoundError):
+ native_graph(
+ repository, toolchain, discovery=None, manifests={},
+ runnable_architectures=("x64",), corpus=root / "missing", run_nonce="run",
+ )
+ file_corpus = root / "corpus.bin"
+ file_corpus.touch()
+ with self.assertRaises(NotADirectoryError):
+ native_graph(
+ repository, toolchain, discovery=None, manifests={},
+ runnable_architectures=("x64",), corpus=file_corpus, run_nonce="run",
+ )
+
+ def test_native_build_requires_every_compiler_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ toolchain = self.toolchain(root)
+ discovery = native_dependency_discovery_slice(repository, toolchain)
+
+ with self.assertRaisesRegex(ValueError, "missing dependency manifest"):
+ native_graph(
+ repository, toolchain, discovery=discovery, manifests={},
+ runnable_architectures=(),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_node.py b/build/tests/test_node.py
new file mode 100644
index 0000000..0573e65
--- /dev/null
+++ b/build/tests/test_node.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import Result # noqa: E402
+from core.node import NodeFactory # noqa: E402
+from core.render import TemplateRenderer # noqa: E402
+
+
+class NodeFactoryTests(unittest.TestCase):
+ def test_declared_results_flow_from_factory_to_node(self) -> None:
+ renderer = TemplateRenderer(BUILD_ROOT / "templates")
+ report = Result(
+ "analysis/example/json",
+ "report",
+ "application/json",
+ "reports/example.json",
+ )
+ with tempfile.TemporaryDirectory() as temporary:
+ factory = NodeFactory(renderer, Path(temporary), {"tool": "1"})
+ current = factory.make(
+ "argv.json",
+ "example",
+ "slot",
+ {"argv": (str(Path(sys.executable).resolve()), "--version")},
+ files={},
+ results=(report,),
+ config={"action": "probe"},
+ )
+
+ self.assertEqual(current.results, (report,))
+
+ def test_runtime_environment_and_cwd_are_signed(self) -> None:
+ renderer = TemplateRenderer(BUILD_ROOT / "templates")
+ with tempfile.TemporaryDirectory() as temporary:
+ first = Path(temporary) / "first"
+ second = Path(temporary) / "second"
+ first.mkdir()
+ second.mkdir()
+
+ def create(cwd: Path, value: str):
+ factory = NodeFactory(renderer, cwd, {"tool": "1"}, (("SETTING", value),))
+ return factory.make(
+ "argv.json",
+ "example",
+ "slot",
+ {"argv": (str(Path(sys.executable).resolve()), "--version")},
+ files={},
+ config={"action": "probe"},
+ )
+
+ baseline = create(first, "one")
+ changed_environment = create(first, "two")
+ changed_cwd = create(second, "one")
+
+ self.assertNotEqual(baseline.uid, changed_environment.uid)
+ self.assertNotEqual(baseline.uid, changed_cwd.uid)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_package_graph.py b/build/tests/test_package_graph.py
new file mode 100644
index 0000000..fd94f74
--- /dev/null
+++ b/build/tests/test_package_graph.py
@@ -0,0 +1,508 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import runpy
+import sys
+import tempfile
+import unittest
+from unittest import mock
+import zipfile
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import Command, Graph, Node # noqa: E402
+from core.package import PackageError, main as package_main # noqa: E402
+from core.paths import BuildPaths # noqa: E402
+from graphs.package import package_graph, package_outputs # noqa: E402
+
+
+MODULES = ("renpy", "rpgmaker", "zanzarah")
+LICENSES = {
+ "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"),
+ "rpgmaker": ("Observer.txt", "rgssad.txt"),
+ "zanzarah": ("Observer.txt", "zanzapak.txt"),
+}
+
+
+def node(name: str, seed: str | None = None, *, inputs: tuple[str, ...] = ()) -> Node:
+ return Node(name, hashlib.md5((seed or name).encode()).hexdigest(), "build", Command(("build",)), inputs)
+
+
+class PackageGraphTests(unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ for module, licenses in LICENSES.items():
+ registration = root / f"src/modules/{module}/observer_user.ini"
+ registration.parent.mkdir(parents=True, exist_ok=True)
+ registration.write_text(f"[{module}]\n", encoding="utf-8")
+ for name in licenses:
+ path = root / "licenses" / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(f"license:{name}\n", encoding="utf-8")
+ (root / "LICENSE.txt").write_text("project license\n", encoding="utf-8")
+ return root
+
+ def fixture(
+ self, root: Path, architectures: tuple[str, ...] = ("x64", "arm64")
+ ) -> tuple[Path, Graph]:
+ repository = self.repository(root / "repo")
+ producers = tuple(
+ node(f"build-{module}-{architecture}-release")
+ for architecture in architectures
+ for module in MODULES
+ )
+ audit_gates = tuple(
+ node(f"audit-{kind}-{architecture}-{module}", inputs=(producer.name,))
+ for producer, (architecture, module) in zip(
+ producers,
+ ((architecture, module) for architecture in architectures for module in MODULES),
+ strict=True,
+ )
+ for kind in ("pe", "binskim")
+ )
+ tests = tuple(node(f"build-tests-{architecture}-release") for architecture in architectures)
+ upstream = Graph(
+ (*producers, *tests, *audit_gates),
+ tuple(item.name for item in producers),
+ {"build": 4},
+ )
+ return repository, upstream
+
+ def test_package_units_fan_out_and_only_inherent_aggregates_fan_in(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, upstream = self.fixture(Path(temporary))
+ graph = package_graph(
+ repository, upstream, architectures=("x64", "arm64"), jobs=7
+ )
+ paths = BuildPaths(repository)
+ outputs = package_outputs(repository, graph)
+ with self.assertRaisesRegex(ValueError, "no package outputs"):
+ package_outputs(repository, upstream)
+ empty_manifest = Graph(
+ (node("package-manifest"),), ("package-manifest",), {"build": 1}
+ )
+ with self.assertRaisesRegex(ValueError, "no package outputs"):
+ package_outputs(repository, empty_manifest)
+
+ self.assertEqual(graph.pools, {"build": 4, "package": 7})
+ self.assertEqual(len(graph.nodes) - len(upstream.nodes), 29)
+ self.assertEqual(graph.targets, ("package-manifest",))
+ for architecture in ("arm64", "x64"):
+ for module in MODULES:
+ suffix = f"{architecture}-{module}"
+ producer = graph.node(f"build-{module}-{architecture}-release")
+ stage = graph.node(f"package-stage-{suffix}")
+ symbols = graph.node(f"package-symbol-stage-{suffix}")
+ archive = graph.node(f"package-archive-{suffix}")
+ validation = graph.node(f"package-validate-{suffix}")
+ gates = (
+ f"audit-pe-{architecture}-{module}",
+ f"audit-binskim-{architecture}-{module}",
+ )
+ self.assertEqual(stage.inputs, (producer.name, *gates))
+ self.assertEqual(symbols.inputs, (producer.name, *gates))
+ self.assertEqual(archive.inputs, (stage.name, *gates))
+ self.assertEqual(validation.inputs, (archive.name, stage.name))
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in validation.results),
+ ((
+ f"reports/package/{architecture}/{module}/validation.json",
+ "package-validation", "application/json", "validation.json",
+ ),),
+ )
+ self.assertEqual(archive.results, ())
+ self.assertEqual(
+ validation.command.argv[3:],
+ ("validate-module", architecture, module,
+ str(paths.cas(archive.uid).output / f"{module}-{architecture}-dll.zip"),
+ str(paths.cas(stage.uid).output)),
+ )
+ self.assertEqual(stage.command.argv[:3], (sys.executable, "-m", "core.package"))
+ producer_output = paths.cas(producer.uid).output
+ self.assertIn(str(producer_output / f"{module}.so"), stage.command.argv)
+ self.assertIn(str(producer_output / f"{module}.pdb"), symbols.command.argv)
+ self.assertIn(str(paths.cas(stage.uid).output), archive.command.argv)
+
+ for architecture in ("arm64", "x64"):
+ combined = graph.node(f"package-symbols-{architecture}")
+ validation = graph.node(f"package-symbols-validate-{architecture}")
+ self.assertEqual(
+ combined.inputs,
+ tuple(f"package-symbol-stage-{architecture}-{module}" for module in MODULES),
+ )
+ self.assertEqual(
+ validation.inputs,
+ (combined.name, *combined.inputs),
+ )
+ self.assertEqual(
+ tuple((item.id, item.relative_path) for item in validation.results),
+ ((f"reports/package/{architecture}/symbols/validation.json", "validation.json"),),
+ )
+ aggregate = graph.node("package-manifest")
+ self.assertEqual(len(aggregate.inputs), 8)
+ self.assertTrue(all(name.startswith("package-validate-") or name.startswith("package-symbols-validate-")
+ for name in aggregate.inputs))
+ expected_names = {
+ *(f"{module}-{architecture}-dll.zip"
+ for architecture in ("arm64", "x64") for module in MODULES),
+ *(f"observer-modules-{architecture}-pdb.zip"
+ for architecture in ("arm64", "x64")),
+ }
+ self.assertEqual(
+ {(item.id, item.kind, item.media_type, item.relative_path)
+ for item in aggregate.results},
+ {
+ (f"packages/{name.split('-')[1] if '-modules-' not in name else name.split('-')[2]}/{name}",
+ "package", "application/zip", name)
+ for name in expected_names
+ } | {("packages/packages.json", "package-manifest", "application/json", "packages.json")},
+ )
+ self.assertEqual(
+ {Path(argument).name for argument in aggregate.command.argv[4:]},
+ expected_names,
+ )
+ self.assertTrue(all(node.pool == "package" for node in graph.nodes[len(upstream.nodes) :]))
+ self.assertEqual(
+ outputs,
+ tuple(
+ paths.cas(aggregate.uid).output / archive_name
+ for architecture in ("arm64", "x64")
+ for archive_name in (
+ *(f"{module}-{architecture}-dll.zip" for module in MODULES),
+ f"observer-modules-{architecture}-pdb.zip",
+ )
+ ),
+ )
+
+ def test_repository_metadata_invalidates_only_consuming_package_partition(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, upstream = self.fixture(Path(temporary), ("x64",))
+ before = package_graph(repository, upstream, architectures=("x64",))
+ (repository / "src/modules/renpy/observer_user.ini").write_text("changed\n", encoding="utf-8")
+ after = package_graph(repository, upstream, architectures=("x64",))
+
+ changed = {current.name for current in before.nodes if current.uid != after.node(current.name).uid}
+ self.assertEqual(
+ changed,
+ {
+ "package-stage-x64-renpy", "package-archive-x64-renpy",
+ "package-validate-x64-renpy", "package-manifest",
+ },
+ )
+
+ def test_package_smokes_run_independently_against_exact_archives(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, upstream = self.fixture(Path(temporary), ("x64",))
+ test_producer = upstream.node("build-tests-x64-release")
+ paths = BuildPaths(repository)
+ executable = paths.cas(test_producer.uid).output / "tests.exe"
+ graph = package_graph(
+ repository,
+ upstream,
+ architectures=("x64",),
+ smoke_architectures=("x64",),
+ )
+
+ smokes = tuple(graph.node(f"package-smoke-x64-{module}") for module in MODULES)
+ for smoke, module in zip(smokes, MODULES, strict=True):
+ self.assertEqual(
+ smoke.inputs,
+ (f"package-validate-x64-{module}", test_producer.name),
+ )
+ self.assertEqual(smoke.command.argv[3:6], ("smoke", "x64", module))
+ archive = graph.node(f"package-archive-x64-{module}")
+ self.assertEqual(
+ Path(smoke.command.argv[6]),
+ paths.cas(archive.uid).output / f"{module}-x64-dll.zip",
+ )
+ self.assertIn(str(executable), smoke.command.argv)
+ manifest_inputs = set(graph.node("package-manifest").inputs)
+ self.assertEqual(
+ manifest_inputs,
+ {smoke.name for smoke in smokes}
+ | {f"package-validate-x64-{module}" for module in MODULES}
+ | {"package-symbols-validate-x64"},
+ )
+
+ def test_invalid_axes_lineage_and_pool_contracts_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, upstream = self.fixture(Path(temporary), ("x64",))
+
+ for jobs in (0, True, "four"):
+ with self.subTest(jobs=jobs), self.assertRaisesRegex(ValueError, "positive integer"):
+ package_graph(
+ repository, upstream, architectures=("x64",), jobs=jobs # type: ignore[arg-type]
+ )
+ for invalid in ((), ("x64", "x64"), ("mips",)):
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"):
+ package_graph(repository, upstream, architectures=invalid)
+
+ removed = {
+ "build-renpy-x64-release",
+ "audit-pe-x64-renpy",
+ "audit-binskim-x64-renpy",
+ }
+ missing_build = Graph(
+ tuple(item for item in upstream.nodes if item.name not in removed),
+ tuple(name for name in upstream.targets if name not in removed),
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "unknown node"):
+ package_graph(repository, missing_build, architectures=("x64",))
+
+ missing_gate = Graph(
+ tuple(node for node in upstream.nodes if node.name != "audit-pe-x64-renpy"),
+ upstream.targets,
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "audit gates"):
+ package_graph(repository, missing_gate, architectures=("x64",))
+ pe_gate = upstream.node("audit-pe-x64-renpy")
+ producer = upstream.node("build-renpy-x64-release")
+ proof = node("audit-proof-x64-renpy", inputs=(producer.name,))
+ indirect_gate = Node(pe_gate.name, pe_gate.uid, pe_gate.pool, pe_gate.command, (proof.name,))
+ indirect = Graph(
+ (*tuple(item for item in upstream.nodes if item != pe_gate), proof, indirect_gate),
+ upstream.targets,
+ upstream.pools,
+ )
+ self.assertEqual(
+ package_graph(repository, indirect, architectures=("x64",)).targets,
+ ("package-manifest",),
+ )
+ unrelated_gate = Node(
+ pe_gate.name,
+ pe_gate.uid,
+ pe_gate.pool,
+ pe_gate.command,
+ (upstream.node("build-rpgmaker-x64-release").name,),
+ )
+ unrelated = Graph(
+ (*tuple(item for item in upstream.nodes if item != pe_gate), unrelated_gate),
+ upstream.targets,
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "do not consume producer"):
+ package_graph(repository, unrelated, architectures=("x64",))
+
+ matching = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 4})
+ self.assertEqual(
+ package_graph(repository, matching, architectures=("x64",)).pools["package"], 4
+ )
+ conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 1})
+ with self.assertRaisesRegex(ValueError, "conflicting pool"):
+ package_graph(repository, conflicting, architectures=("x64",))
+
+ def test_invalid_package_smoke_axes_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository, upstream = self.fixture(Path(temporary), ("x64",))
+ for smokes in (("x64", "x64"), ("x86",)):
+ with self.subTest(smokes=smokes), self.assertRaisesRegex(ValueError, "smoke architectures"):
+ package_graph(
+ repository,
+ upstream,
+ architectures=("x64",),
+ smoke_architectures=smokes,
+ )
+
+ missing_test = Graph(
+ tuple(item for item in upstream.nodes if item.name != "build-tests-x64-release"),
+ upstream.targets,
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "unknown node"):
+ package_graph(
+ repository,
+ missing_test,
+ architectures=("x64",),
+ smoke_architectures=("x64",),
+ )
+
+
+class PackageActionTests(unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ return PackageGraphTests().repository(root)
+
+ @staticmethod
+ def invoke(output: Path, *arguments: str) -> int:
+ output.mkdir()
+ with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False):
+ return package_main(arguments)
+
+ def test_module_stage_archive_and_aggregate_are_reproducible(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ binary = root / "renpy.so"
+ binary.write_bytes(b"module")
+ stage = root / "stage"
+ self.assertEqual(0, self.invoke(stage, "stage-module", "x64", "renpy", str(binary), str(repository)))
+
+ payload = stage / "payload"
+ expected = {
+ "renpy.so", "observer_user.ini", "docs/license.txt",
+ *(f"docs/thirdparty/{name}" for name in LICENSES["renpy"]),
+ }
+ self.assertEqual(
+ expected,
+ {
+ path.relative_to(payload).as_posix()
+ for path in payload.rglob("*")
+ if path.is_file()
+ },
+ )
+ document = json.loads((stage / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(
+ ("x64", "module", "renpy"),
+ (document["architecture"], document["kind"], document["module"]),
+ )
+ self.assertEqual(sorted(expected), [entry["name"] for entry in document["entries"]])
+
+ archive_one, archive_two = root / "archive-one", root / "archive-two"
+ self.invoke(archive_one, "archive-module", "x64", "renpy", str(stage))
+ self.invoke(archive_two, "archive-module", "x64", "renpy", str(stage))
+ first = archive_one / "renpy-x64-dll.zip"
+ second = archive_two / "renpy-x64-dll.zip"
+ self.assertEqual(first.read_bytes(), second.read_bytes())
+ with zipfile.ZipFile(first) as archive:
+ self.assertEqual(sorted(expected), archive.namelist())
+ self.assertTrue(all(item.date_time == (1980, 1, 1, 0, 0, 0) for item in archive.infolist()))
+ self.assertEqual(b"module", archive.read("renpy.so"))
+
+ validation = root / "validation"
+ self.assertEqual(
+ 0,
+ self.invoke(validation, "validate-module", "x64", "renpy", str(first), str(stage)),
+ )
+ proof = json.loads((validation / "validation.json").read_text(encoding="utf-8"))
+ self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), proof["sha256"])
+ self.assertEqual(sorted(expected), [entry["name"] for entry in proof["entries"]])
+
+ tampered = root / "tampered.zip"
+ with zipfile.ZipFile(first) as source, zipfile.ZipFile(tampered, "w") as target:
+ for name in source.namelist():
+ target.writestr(name, b"changed" if name == "renpy.so" else source.read(name))
+ with self.assertRaisesRegex(PackageError, "exact stage manifests"):
+ self.invoke(root / "tampered-validation", "validate-module", "x64", "renpy", str(tampered), str(stage))
+ duplicate = root / "duplicate.zip"
+ with self.assertWarns(UserWarning), zipfile.ZipFile(duplicate, "w") as archive:
+ archive.writestr("renpy.so", b"first")
+ archive.writestr("renpy.so", b"second")
+ for index, invalid in enumerate((duplicate, root / "invalid.zip")):
+ if not invalid.exists():
+ invalid.write_bytes(b"not a zip")
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(PackageError, "exact stage manifests"):
+ self.invoke(root / f"invalid-validation-{index}", "validate-module",
+ "x64", "renpy", str(invalid), str(stage))
+
+ aggregate = root / "aggregate"
+ self.invoke(aggregate, "aggregate", str(first))
+ self.assertEqual((aggregate / first.name).read_bytes(), first.read_bytes())
+ packages = json.loads((aggregate / "packages.json").read_text(encoding="utf-8"))
+ self.assertEqual("renpy-x64-dll.zip", packages[0]["name"])
+ self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), packages[0]["sha256"])
+
+ def test_symbol_stages_fan_into_one_deterministic_architecture_archive(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ stages = []
+ for module in MODULES:
+ symbol = root / f"{module}.pdb"
+ symbol.write_bytes(module.encode())
+ stage = root / f"stage-{module}"
+ self.invoke(stage, "stage-symbol", "arm64", module, str(symbol))
+ stages.append(stage)
+ output = root / "symbols"
+ self.invoke(output, "archive-symbols", "arm64", *(str(stage) for stage in stages))
+ with zipfile.ZipFile(output / "observer-modules-arm64-pdb.zip") as archive:
+ self.assertEqual([f"{module}.pdb" for module in MODULES], archive.namelist())
+ self.assertEqual(b"zanzarah", archive.read("zanzarah.pdb"))
+ validation = root / "symbols-validation"
+ archive = output / "observer-modules-arm64-pdb.zip"
+ self.assertEqual(
+ 0,
+ self.invoke(validation, "validate-symbols", "arm64", str(archive), *(str(stage) for stage in stages)),
+ )
+ self.assertEqual(
+ hashlib.sha256(archive.read_bytes()).hexdigest(),
+ json.loads((validation / "validation.json").read_text())["sha256"],
+ )
+
+ def test_smoke_extracts_and_runs_the_exact_archived_module(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ archive = root / "renpy-x64-dll.zip"
+ with zipfile.ZipFile(archive, "w") as package:
+ package.writestr("renpy.so", b"exact packaged module")
+ tests = root / "tests.exe"
+ tests.write_bytes(b"test runner")
+ output = root / "smoke"
+
+ with mock.patch("core.package.subprocess.run") as run:
+ self.invoke(output, "smoke", "x64", "renpy", str(archive), str(tests))
+
+ module = output / "renpy.so"
+ self.assertEqual(module.read_bytes(), b"exact packaged module")
+ run.assert_called_once()
+ arguments = run.call_args.args[0]
+ self.assertEqual(arguments[:2], [str(tests), "[package-smoke]"])
+ self.assertEqual(run.call_args.kwargs["cwd"], output)
+ self.assertEqual(run.call_args.kwargs["env"]["OBSERVER_PACKAGE_MODULE"], str(module))
+ self.assertEqual(run.call_args.kwargs["env"]["OBSERVER_PACKAGE_FORMAT"], "renpy")
+
+ missing = root / "missing.zip"
+ with zipfile.ZipFile(missing, "w") as package:
+ package.writestr("other.so", b"wrong module")
+ with self.assertRaisesRegex(PackageError, "archive has no renpy.so"):
+ self.invoke(root / "missing-smoke", "smoke", "x64", "renpy", str(missing), str(tests))
+
+ def test_manifest_mismatch_duplicate_archive_and_missing_output_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ binary = root / "renpy.so"
+ binary.write_bytes(b"module")
+ stage = root / "stage"
+ self.invoke(stage, "stage-module", "x86", "renpy", str(binary), str(repository))
+ (stage / "payload/extra.obj").write_bytes(b"unexpected")
+ with self.assertRaisesRegex(PackageError, "manifest does not match"):
+ self.invoke(root / "bad-archive", "archive-module", "x86", "renpy", str(stage))
+
+ archive = root / "same.zip"
+ archive.write_bytes(b"same")
+ with self.assertRaisesRegex(PackageError, "duplicate archive name"):
+ self.invoke(root / "bad-aggregate", "aggregate", str(archive), str(archive))
+
+ with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(PackageError, "OBSERVER_OUT_DIR"):
+ package_main(("aggregate", "missing.zip"))
+
+ def test_module_validation_and_module_entrypoint_dispatch(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ symbol = root / "renpy.pdb"
+ symbol.write_bytes(b"symbols")
+ stage = root / "stage"
+ self.invoke(stage, "stage-symbol", "x64", "renpy", str(symbol))
+ with self.assertRaisesRegex(PackageError, "expected module set"):
+ self.invoke(root / "bad-symbols", "archive-symbols", "x64", str(stage))
+
+ output = root / "entrypoint"
+ output.mkdir()
+ with (
+ mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False),
+ mock.patch.object(sys, "argv", ["package.py", "aggregate", str(symbol)]),
+ self.assertWarnsRegex(RuntimeWarning, "core.package"),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("core.package", run_name="__main__")
+ self.assertEqual(0, raised.exception.code)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_paths.py b/build/tests/test_paths.py
new file mode 100644
index 0000000..18ad658
--- /dev/null
+++ b/build/tests/test_paths.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from core.paths import BuildPaths, PathSafetyError
+
+
+UID = "0123456789abcdef0123456789abcdef"
+
+
+class BuildPathsTests(unittest.TestCase):
+ def test_prepare_creates_only_cas_and_work_at_output_root(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ paths.prepare()
+
+ self.assertEqual(paths.output_root, repository / "out")
+ self.assertEqual(
+ {child.name for child in paths.output_root.iterdir()},
+ {"cas", "work"},
+ )
+ self.assertTrue(paths.cas_root.is_dir())
+ self.assertTrue(paths.work_root.is_dir())
+ self.assertTrue(paths.locks_root.is_dir())
+
+ def test_cas_paths_use_only_uid_for_entry_output_touch_and_log(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ entry = paths.cas(UID)
+
+ self.assertEqual(
+ entry.entry,
+ repository / "out" / "cas" / UID,
+ )
+ self.assertEqual(entry.output, entry.entry / "out")
+ self.assertEqual(entry.touch, entry.entry / "touch")
+ self.assertEqual(entry.log, entry.entry / "log.txt")
+
+ def test_run_work_and_lock_paths_are_confined_under_work(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ self.assertEqual(
+ paths.run_work("20260801-abcdef"),
+ repository / "out" / "work" / "20260801-abcdef",
+ )
+ self.assertEqual(
+ paths.lock(UID),
+ repository / "out" / "work" / ".locks" / f"{UID}.lock",
+ )
+ self.assertEqual(
+ paths.coordination_lock(),
+ repository / "out" / "work" / ".locks" / "coordination.lock",
+ )
+ self.assertEqual(
+ paths.lease("20260801-abcdef"),
+ repository / "out" / "work" / ".locks" / "run-20260801-abcdef.lease",
+ )
+
+ def test_invalid_uid_and_run_identifier_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ for invalid_uid in ("", "ABCDEF" * 5 + "AB", "../escape", "0" * 31):
+ with self.subTest(uid=invalid_uid):
+ with self.assertRaises(PathSafetyError):
+ paths.cas(invalid_uid)
+
+ for invalid_run in ("", ".", "..", "../escape", "with/slash", "a" * 129):
+ with self.subTest(run=invalid_run):
+ with self.assertRaises(PathSafetyError):
+ paths.run_work(invalid_run)
+ with self.assertRaises(PathSafetyError):
+ paths.lease(invalid_run)
+
+ def test_cas_accepts_only_the_content_uid(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ self.assertEqual(paths.cas(UID).entry, repository / "out" / "cas" / UID)
+ with self.assertRaises(TypeError):
+ paths.cas(UID, "legacy-readable-name") # type: ignore[call-arg]
+
+ def test_confined_path_rejects_parent_escape_and_unexpected_root(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+
+ with self.assertRaisesRegex(PathSafetyError, "outside allowed root"):
+ paths.require_confined(paths.cas_root / ".." / "work", paths.cas_root)
+ with self.assertRaisesRegex(PathSafetyError, "unexpected allowed root"):
+ paths.require_confined(repository / "elsewhere", repository / "elsewhere")
+ with self.assertRaisesRegex(PathSafetyError, "outside repository"):
+ paths._reject_existing_reparse_points(repository.parent / "outside")
+
+ def test_existing_reparse_component_and_leaf_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ regular = BuildPaths(repository)
+ regular.prepare()
+ entry = regular.cas(UID)
+ entry.entry.mkdir()
+ entry.touch.touch()
+
+ reparse_paths = {
+ regular.cas_root,
+ entry.touch,
+ }
+
+ def is_reparse(path: Path) -> bool:
+ return path in reparse_paths
+
+ with patch("core.paths._is_reparse", side_effect=is_reparse):
+ paths = BuildPaths(repository)
+ with self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ paths.cas(UID)
+
+ reparse_paths.remove(regular.cas_root)
+ with self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ paths.require_confined(entry.touch, paths.cas_root)
+
+ def test_repository_must_be_an_existing_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ missing = Path(temporary) / "missing"
+ with self.assertRaisesRegex(PathSafetyError, "existing directory"):
+ BuildPaths(missing)
+
+ file_path = Path(temporary) / "file"
+ file_path.touch()
+ with self.assertRaisesRegex(PathSafetyError, "existing directory"):
+ BuildPaths(file_path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_python_coverage_graph.py b/build/tests/test_python_coverage_graph.py
new file mode 100644
index 0000000..15f19b1
--- /dev/null
+++ b/build/tests/test_python_coverage_graph.py
@@ -0,0 +1,236 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import runpy
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.python_coverage import main as coverage_main # noqa: E402
+from graphs.python_coverage import python_coverage_graph # noqa: E402
+
+
+class PythonCoverageGraphTests(unittest.TestCase):
+ def repository(self, root: Path, executable: bool = True) -> Path:
+ build = root / "build"
+ for relative, content in (
+ ("core/example.py", "VALUE = 1\n"),
+ ("graphs/example.py", "VALUE = 2\n"),
+ ("tests/test_example.py", "pass\n"),
+ ("driver.py", "VALUE = 4\n"),
+ ("main.py", "VALUE = 3\n"),
+ ("pyproject.toml", "[project]\nname='fixture'\n"),
+ ("uv.lock", "fixture\n"),
+ ):
+ path = build / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ coverage = build / ".venv/Scripts/coverage.exe"
+ package = build / ".venv/Lib/site-packages/coverage"
+ package.mkdir(parents=True)
+ (package / "version.py").write_text("__version__ = 'fixture'\n", encoding="utf-8")
+ cache = package / "__pycache__"
+ cache.mkdir()
+ (cache / "version.pyc").write_bytes(b"derived")
+ if executable:
+ coverage.parent.mkdir(parents=True)
+ coverage.write_bytes(b"coverage-launcher")
+ else:
+ coverage.mkdir(parents=True)
+ return root
+
+ def test_single_gate_signs_all_first_party_tests_config_lock_and_exact_local_coverage(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = self.repository(Path(temporary) / "repo")
+ before = python_coverage_graph(repository)
+ node = before.node("python-coverage")
+
+ self.assertEqual(before.targets, (node.name,))
+ self.assertEqual(dict(before.pools), {"python-coverage": 1})
+ self.assertEqual(dict(node.command.env), {"PYTHONDONTWRITEBYTECODE": "1"})
+ self.assertEqual(
+ node.command.argv,
+ (sys.executable, "-m", "core.python_coverage",
+ str(repository / "build/.venv/Scripts/coverage.exe"), str(repository / "build")),
+ )
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in node.results),
+ (
+ ("reports/coverage/python/coverage.json", "coverage", "application/json", "coverage.json"),
+ ("reports/coverage/python/coverage.xml", "coverage", "application/xml", "coverage.xml"),
+ ("reports/coverage/python/coverage.txt", "coverage", "text/plain", "coverage.txt"),
+ ("reports/coverage/python/coverage.toml", "coverage-config", "application/toml", "coverage.toml"),
+ ("reports/coverage/python/coverage.data", "coverage-data", "application/octet-stream", ".coverage"),
+ ),
+ )
+ (repository / "build/tests/test_example.py").write_text("changed\n", encoding="utf-8")
+ changed_test = python_coverage_graph(repository)
+ (repository / "build/tests/test_example.py").write_text("pass\n", encoding="utf-8")
+ (repository / "build/driver.py").write_text("changed\n", encoding="utf-8")
+ changed_driver = python_coverage_graph(repository)
+ (repository / "build/driver.py").write_text("VALUE = 4\n", encoding="utf-8")
+ config = repository / "build/pyproject.toml"
+ original_config = config.read_text(encoding="utf-8")
+ config.write_text(original_config + "\n# changed\n", encoding="utf-8")
+ changed_config = python_coverage_graph(repository)
+ config.write_text(original_config, encoding="utf-8")
+ (repository / "build/.venv/Scripts/coverage.exe").write_bytes(b"changed-coverage")
+ changed_tool = python_coverage_graph(repository)
+ (repository / "build/.venv/Scripts/coverage.exe").write_bytes(b"coverage-launcher")
+ (repository / "build/.venv/Lib/site-packages/coverage/version.py").write_text(
+ "__version__ = 'changed'\n", encoding="utf-8"
+ )
+ changed_package = python_coverage_graph(repository)
+
+ self.assertNotEqual(node.uid, changed_test.node(node.name).uid)
+ self.assertNotEqual(node.uid, changed_driver.node(node.name).uid)
+ self.assertNotEqual(node.uid, changed_config.node(node.name).uid)
+ self.assertNotEqual(node.uid, changed_tool.node(node.name).uid)
+ self.assertNotEqual(node.uid, changed_package.node(node.name).uid)
+
+ def test_missing_or_non_file_project_coverage_is_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ missing = self.repository(root / "missing")
+ (missing / "build/.venv/Scripts/coverage.exe").unlink()
+ with self.assertRaises(FileNotFoundError):
+ python_coverage_graph(missing)
+ directory = self.repository(root / "directory", executable=False)
+ with self.assertRaisesRegex(FileNotFoundError, "not a file"):
+ python_coverage_graph(directory)
+ bad_package = self.repository(root / "bad-package")
+ package = bad_package / "build/.venv/Lib/site-packages/coverage"
+ (package / "version.py").unlink()
+ (package / "__pycache__/version.pyc").unlink()
+ (package / "__pycache__").rmdir()
+ package.rmdir()
+ package.write_text("not a directory", encoding="utf-8")
+ with self.assertRaisesRegex(FileNotFoundError, "not a directory"):
+ python_coverage_graph(bad_package)
+
+
+class PythonCoverageWorkerTests(unittest.TestCase):
+ def project(self, root: Path) -> Path:
+ for relative, content in (
+ ("core/__init__.py", ""),
+ ("core/value.py", "VALUE = 1\n"),
+ ("graphs/__init__.py", ""),
+ ("graphs/value.py", "VALUE = 2\n"),
+ ("driver.py", "VALUE = 4\n"),
+ ("main.py", "VALUE = 3\n"),
+ ("pyproject.toml", (BUILD_ROOT / "pyproject.toml").read_text(encoding="utf-8")),
+ ("tests/test_all.py", "import unittest\nfrom core.value import VALUE as CORE\nfrom graphs.value import VALUE as GRAPH\nimport driver\nimport main\nclass T(unittest.TestCase):\n def test_all(self): self.assertEqual((CORE, GRAPH, driver.VALUE, main.VALUE), (1, 2, 4, 3))\n"),
+ ):
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return root
+
+ def test_exact_cli_publishes_line_branch_reports_from_isolated_work_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ project, work, output = self.project(root / "project"), root / "work", root / "output"
+ work.mkdir()
+ output.mkdir()
+ environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}
+ coverage = BUILD_ROOT / ".venv/Scripts/coverage.exe"
+ with mock.patch.dict(os.environ, environment, clear=False):
+ self.assertEqual(0, coverage_main((str(coverage), str(project))))
+
+ document = json.loads((output / "coverage.json").read_text(encoding="utf-8"))
+ self.assertEqual(100.0, document["totals"]["percent_covered"])
+ self.assertTrue((output / "coverage.xml").is_file())
+ self.assertIn("100%", (output / "coverage.txt").read_text(encoding="utf-8"))
+ self.assertEqual(
+ (output / "coverage.toml").read_bytes(),
+ (project / "pyproject.toml").read_bytes(),
+ )
+ self.assertTrue((work / ".coverage").is_file())
+ self.assertEqual((output / ".coverage").read_bytes(), (work / ".coverage").read_bytes())
+ self.assertFalse((project / ".coverage").exists())
+
+ entry_work, entry_output = root / "entry-work", root / "entry-output"
+ entry_work.mkdir()
+ entry_output.mkdir()
+ with (
+ mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(entry_work),
+ "OBSERVER_OUT_DIR": str(entry_output)}, clear=False),
+ mock.patch.object(sys, "argv", ["python_coverage.py", str(coverage), str(project)]),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_path(str(BUILD_ROOT / "core/python_coverage.py"), run_name="__main__")
+ self.assertEqual(0, raised.exception.code)
+
+ def test_below_100_fails_after_publishing_evidence_and_environment_is_required(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ project, work, output = self.project(root / "project"), root / "work", root / "output"
+ work.mkdir()
+ output.mkdir()
+ (project / "tests/test_all.py").write_text(
+ "import unittest\nfrom core.value import VALUE as CORE\nfrom graphs.value import VALUE as GRAPH\n"
+ "class T(unittest.TestCase):\n def test_packages(self): self.assertEqual((CORE, GRAPH), (1, 2))\n",
+ encoding="utf-8",
+ )
+ coverage = BUILD_ROOT / ".venv/Scripts/coverage.exe"
+ with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}):
+ with self.assertRaises(subprocess.CalledProcessError):
+ coverage_main((str(coverage), str(project)))
+ self.assertLess(json.loads((output / "coverage.json").read_text())["totals"]["percent_covered"], 100)
+ self.assertTrue((output / "coverage.txt").is_file())
+
+ (project / "core/value.py").write_text(
+ "def choose(first, second):\n value = 0\n if first:\n value = 1\n if second:\n value = 2\n return value\n",
+ encoding="utf-8",
+ )
+ (project / "tests/test_all.py").write_text(
+ "import unittest\nfrom core.value import choose\nfrom graphs.value import VALUE\nimport driver\nimport main\n"
+ "class T(unittest.TestCase):\n def test_one_branch(self): self.assertEqual((choose(True, True), VALUE, driver.VALUE, main.VALUE), (2, 2, 4, 3))\n",
+ encoding="utf-8",
+ )
+ branch_work, branch_output = root / "branch-work", root / "branch-output"
+ branch_work.mkdir()
+ branch_output.mkdir()
+ with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(branch_work),
+ "OBSERVER_OUT_DIR": str(branch_output)}):
+ with self.assertRaises(subprocess.CalledProcessError):
+ coverage_main((str(coverage), str(project)))
+ totals = json.loads((branch_output / "coverage.json").read_text())["totals"]
+ self.assertEqual(0, totals["missing_lines"])
+ self.assertGreater(totals["missing_branches"], 0)
+
+ with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(RuntimeError, "OBSERVER_BUILD_DIR"):
+ coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(BUILD_ROOT)))
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ work, output, tool, build_file = root / "work", root / "output", root / "coverage", root / "build"
+ work.mkdir()
+ output.mkdir()
+ tool.mkdir()
+ build_file.touch()
+ environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}
+ for arguments in ((tool, BUILD_ROOT), (BUILD_ROOT / ".venv/Scripts/coverage.exe", build_file)):
+ with self.subTest(arguments=arguments), mock.patch.dict(os.environ, environment, clear=False):
+ with self.assertRaises(FileNotFoundError):
+ coverage_main(tuple(map(str, arguments)))
+ missing_config = root / "missing-config"
+ missing_config.mkdir()
+ with mock.patch.dict(os.environ, environment, clear=False), self.assertRaises(FileNotFoundError):
+ coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(missing_config)))
+ with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(root / "missing"),
+ "OBSERVER_OUT_DIR": str(output)}, clear=True):
+ with self.assertRaisesRegex(RuntimeError, "OBSERVER_BUILD_DIR"):
+ coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(BUILD_ROOT)))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_quality_tools.py b/build/tests/test_quality_tools.py
new file mode 100644
index 0000000..2e8cf6d
--- /dev/null
+++ b/build/tests/test_quality_tools.py
@@ -0,0 +1,300 @@
+from __future__ import annotations
+
+from dataclasses import FrozenInstanceError, dataclass
+import hashlib
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from unittest import mock
+
+
+import sys
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.quality_tools import ( # noqa: E402
+ QualityTools,
+ ResolvedDirectory,
+ ResolvedTool,
+ SanitizerRuntimes,
+ discover_quality_tools,
+ resolve_binskim,
+ resolve_dumpbin,
+ resolve_llvm,
+ resolve_asan_runtimes,
+ resolve_sanitizer_runtimes,
+ resolve_tool,
+ resolve_ubsan_runtime,
+ resolve_umdh,
+)
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ installation: Path
+ llvm_dir: Path
+ identity: tuple[tuple[str, str], ...]
+
+
+class QualityToolsTests(unittest.TestCase):
+ def fixture(self, root: Path, *, kit11: bool = True) -> tuple[FakeToolchain, Path, Path]:
+ installation, llvm = root / "Visual Studio", root / "LLVM"
+ version = "14.44.35207"
+ paths = (
+ llvm / "bin/clang-cl.exe",
+ llvm / "bin/clang-scan-deps.exe",
+ llvm / "bin/llvm-cov.exe",
+ llvm / "bin/llvm-profdata.exe",
+ installation / f"VC/Tools/MSVC/{version}/bin/Hostx64/x64/dumpbin.exe",
+ )
+ for index, path in enumerate(paths):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(f"tool-{index}".encode())
+ program_files = root / "Program Files (x86)"
+ kit = "11" if kit11 else "10"
+ umdh = program_files / f"Windows Kits/{kit}/Debuggers/x64/umdh.exe"
+ umdh.parent.mkdir(parents=True)
+ umdh.write_bytes(b"umdh")
+ binskim = root / "shims/BinSkim.exe"
+ binskim.parent.mkdir()
+ binskim.write_bytes(b"binskim")
+ identity = (("clang_tidy_version", "19.1.5"), ("vc_tools_version", version))
+ return FakeToolchain(installation, llvm, identity), binskim, program_files
+
+ def runtime_fixture(
+ self, toolchain: FakeToolchain, llvm_version: str = "19"
+ ) -> tuple[Path, tuple[Path, Path]]:
+ version = dict(toolchain.identity)["vc_tools_version"]
+ asan = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64"
+ for architecture, name in (
+ ("x86", "clang_rt.asan_dynamic-i386.dll"),
+ ("x64", "clang_rt.asan_dynamic-x86_64.dll"),
+ ):
+ path = asan / architecture / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(f"asan-{architecture}".encode())
+ directory = toolchain.llvm_dir / f"lib/clang/{llvm_version}/lib/windows"
+ libraries = tuple(
+ directory / name
+ for name in (
+ "clang_rt.ubsan_standalone-x86_64.lib",
+ "clang_rt.ubsan_standalone_cxx-x86_64.lib",
+ )
+ )
+ directory.mkdir(parents=True)
+ for index, library in enumerate(libraries):
+ library.write_bytes(f"ubsan-{index}".encode())
+ return directory, libraries # type: ignore[return-value]
+
+ def test_resolves_exact_consumers_with_canonical_streaming_sha256_identities(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, binskim, program_files = self.fixture(Path(temporary))
+ with (
+ mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False),
+ mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)) as which,
+ mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")),
+ ):
+ tools = discover_quality_tools(toolchain) # type: ignore[arg-type]
+ expected_digests = {}
+ for tool in (
+ tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata,
+ tools.dumpbin, tools.binskim, tools.umdh,
+ ):
+ with tool.path.open("rb") as stream:
+ expected_digests[tool.path] = hashlib.file_digest(stream, "sha256").hexdigest()
+
+ which.assert_called_once_with("binskim")
+ self.assertIsInstance(tools, QualityTools)
+ expected_names = (
+ "clang-cl.exe", "clang-scan-deps.exe", "llvm-cov.exe", "llvm-profdata.exe",
+ "dumpbin.exe", "BinSkim.exe", "umdh.exe",
+ )
+ self.assertEqual(
+ tuple(tool.path.name for tool in (
+ tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata,
+ tools.dumpbin, tools.binskim, tools.umdh,
+ )),
+ expected_names,
+ )
+ for tool in (
+ tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata,
+ tools.dumpbin, tools.binskim, tools.umdh,
+ ):
+ self.assertEqual(tool.identity[0], ("path", str(tool.path)))
+ self.assertEqual(tool.identity[1][0], "sha256")
+ self.assertEqual(tool.identity[1][1], expected_digests[tool.path])
+ self.assertIn("Windows Kits\\11", str(tools.umdh.path))
+ with self.assertRaises(FrozenInstanceError):
+ tools.binskim = ResolvedTool(tools.binskim.path, tools.binskim.identity) # type: ignore[misc]
+
+ def test_windows_kit_10_is_the_deterministic_fallback(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, binskim, program_files = self.fixture(Path(temporary), kit11=False)
+ with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch(
+ "core.quality_tools.shutil.which", return_value=str(binskim)
+ ):
+ tools = discover_quality_tools(toolchain) # type: ignore[arg-type]
+ self.assertIn("Windows Kits\\10", str(tools.umdh.path))
+
+ def test_explicit_umdh_override_is_strict(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ umdh = Path(temporary) / "sdk-19041/Debuggers/x64/umdh.exe"
+ umdh.parent.mkdir(parents=True)
+ umdh.write_bytes(b"umdh-19041")
+ with mock.patch.dict(os.environ, {"OBSERVER_UMDH": str(umdh)}, clear=False):
+ self.assertEqual(umdh.resolve(), resolve_umdh().path)
+ umdh.unlink()
+ with self.assertRaisesRegex(FileNotFoundError, "missing UMDH override"):
+ resolve_umdh()
+
+ def test_selective_resolvers_are_lazy_and_match_the_aggregate(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, binskim, program_files = self.fixture(Path(temporary))
+ (toolchain.llvm_dir / "bin/llvm-cov.exe").unlink()
+ with (
+ mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False),
+ mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)),
+ ):
+ self.assertEqual(resolve_llvm(toolchain, "clang-cl").path.name, "clang-cl.exe")
+ self.assertEqual(
+ resolve_llvm(toolchain, "clang-scan-deps").path.name,
+ "clang-scan-deps.exe",
+ )
+ self.assertEqual(resolve_dumpbin(toolchain).path.name, "dumpbin.exe")
+ self.assertEqual(resolve_binskim().path, binskim.resolve())
+ self.assertEqual(resolve_umdh().path.name, "umdh.exe")
+ with self.assertRaisesRegex(FileNotFoundError, "missing llvm-cov"):
+ discover_quality_tools(toolchain) # type: ignore[arg-type]
+ with self.assertRaisesRegex(ValueError, "unsupported LLVM tool: clang-tidy"):
+ resolve_llvm(toolchain, "clang-tidy")
+
+ def test_sanitizer_runtimes_are_exact_typed_and_content_addressed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ directory, libraries = self.runtime_fixture(toolchain)
+ with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")):
+ runtimes = resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type]
+
+ self.assertIsInstance(runtimes, SanitizerRuntimes)
+ self.assertEqual(runtimes.asan_x86.path.name, "clang_rt.asan_dynamic-i386.dll")
+ self.assertEqual(runtimes.asan_x64.path.name, "clang_rt.asan_dynamic-x86_64.dll")
+ self.assertIsInstance(runtimes.ubsan, ResolvedDirectory)
+ self.assertEqual(runtimes.ubsan.path, directory.resolve())
+ self.assertEqual(tuple(tool.path for tool in runtimes.ubsan.files), libraries)
+ identity = dict(runtimes.ubsan.identity)
+ self.assertEqual(identity["path"], str(directory.resolve()))
+ for tool in runtimes.ubsan.files:
+ name = tool.path.name
+ self.assertEqual(identity[f"{name}.path"], str(tool.path))
+ self.assertEqual(identity[f"{name}.sha256"], dict(tool.identity)["sha256"])
+ with self.assertRaises(FrozenInstanceError):
+ runtimes.asan_x86 = runtimes.asan_x64 # type: ignore[misc]
+
+ def test_sanitizer_resolvers_load_only_the_requested_runtime_family(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ directory, libraries = self.runtime_fixture(toolchain)
+ version = dict(toolchain.identity)["vc_tools_version"]
+ asan_root = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64"
+
+ libraries[0].unlink()
+ selected = resolve_asan_runtimes(toolchain, ("x64",)) # type: ignore[arg-type]
+ self.assertEqual(tuple(selected), (("x64", selected[0][1]),))
+ self.assertEqual(selected[0][1].path.name, "clang_rt.asan_dynamic-x86_64.dll")
+ with self.assertRaisesRegex(ValueError, "unsupported ASan architecture: arm64"):
+ resolve_asan_runtimes(toolchain, ("arm64",)) # type: ignore[arg-type]
+
+ libraries[0].write_bytes(b"ubsan-0")
+ for runtime in asan_root.rglob("*.dll"):
+ runtime.unlink()
+ ubsan = resolve_ubsan_runtime(toolchain) # type: ignore[arg-type]
+ self.assertEqual(ubsan.path, directory.resolve())
+
+ def test_sanitizer_runtime_errors_name_the_exact_missing_input(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ toolchain, _binskim, _program_files = self.fixture(root)
+ directory, libraries = self.runtime_fixture(toolchain)
+ version = dict(toolchain.identity)["vc_tools_version"]
+ missing_asan = (
+ toolchain.installation
+ / f"VC/Tools/MSVC/{version}/bin/Hostx64/x86/clang_rt.asan_dynamic-i386.dll"
+ )
+ missing_asan.unlink()
+ with self.assertRaisesRegex(FileNotFoundError, "missing MSVC ASan x86 runtime"):
+ resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type]
+ missing_asan.write_bytes(b"asan-x86")
+ libraries[1].unlink()
+ with self.assertRaisesRegex(
+ FileNotFoundError, "missing UBSan runtime clang_rt.ubsan_standalone_cxx-x86_64.lib"
+ ):
+ resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type]
+ libraries[0].unlink()
+ directory.rmdir()
+ with self.assertRaisesRegex(FileNotFoundError, "missing UBSan runtime directory"):
+ resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type]
+
+ no_version = FakeToolchain(toolchain.installation, toolchain.llvm_dir, ())
+ with self.assertRaisesRegex(FileNotFoundError, "missing MSVC vc_tools_version identity"):
+ resolve_dumpbin(no_version) # type: ignore[arg-type]
+
+ def test_full_llvm_version_runtime_precedes_the_major_fallback(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ self.runtime_fixture(toolchain)
+ directory, _libraries = self.runtime_fixture(toolchain, "19.1.5")
+ runtimes = resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type]
+ self.assertEqual(runtimes.ubsan.path, directory.resolve())
+
+ def test_each_missing_tool_is_named_precisely(self) -> None:
+ cases = (
+ ("clang-cl", "LLVM/bin/clang-cl.exe"),
+ ("clang-scan-deps", "LLVM/bin/clang-scan-deps.exe"),
+ ("llvm-cov", "LLVM/bin/llvm-cov.exe"),
+ ("llvm-profdata", "LLVM/bin/llvm-profdata.exe"),
+ ("dumpbin", "Visual Studio/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64/dumpbin.exe"),
+ )
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ for index, (name, relative) in enumerate(cases):
+ toolchain, binskim, program_files = self.fixture(root / str(index))
+ (root / str(index) / relative).unlink()
+ with self.subTest(name=name), mock.patch.dict(
+ os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False
+ ), mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)), self.assertRaisesRegex(
+ FileNotFoundError, f"missing {name}"
+ ):
+ discover_quality_tools(toolchain) # type: ignore[arg-type]
+
+ toolchain, _binskim, program_files = self.fixture(root / "binskim")
+ with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch(
+ "core.quality_tools.shutil.which", return_value=None
+ ), self.assertRaisesRegex(FileNotFoundError, "missing BinSkim"):
+ discover_quality_tools(toolchain) # type: ignore[arg-type]
+
+ toolchain, binskim, program_files = self.fixture(root / "umdh")
+ (program_files / "Windows Kits/11/Debuggers/x64/umdh.exe").unlink()
+ with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch(
+ "core.quality_tools.shutil.which", return_value=str(binskim)
+ ), self.assertRaisesRegex(FileNotFoundError, "missing UMDH"):
+ discover_quality_tools(toolchain) # type: ignore[arg-type]
+
+ def test_resolve_tool_rejects_non_files_and_content_changes_identity(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ path = root / "tool.exe"
+ path.write_bytes(b"one")
+ before = resolve_tool(path, "example")
+ path.write_bytes(b"two")
+ after = resolve_tool(path, "example")
+ with self.assertRaisesRegex(FileNotFoundError, "missing absent"):
+ resolve_tool(None, "absent")
+ with self.assertRaisesRegex(FileNotFoundError, "missing directory"):
+ resolve_tool(root, "directory")
+ self.assertNotEqual(before.identity, after.identity)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_recipe.py b/build/tests/test_recipe.py
new file mode 100644
index 0000000..c0ed051
--- /dev/null
+++ b/build/tests/test_recipe.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import sys
+import unittest
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import GraphError, Result # noqa: E402
+from core.recipe import Recipe, RecipeError # noqa: E402
+
+
+def rendered_recipe(**overrides: object) -> str:
+ document: dict[str, object] = {
+ "name": "analyze-renpy-x64",
+ "pool": "slot",
+ "inputs": ["compile-renpy-x64"],
+ "results": [
+ {
+ "id": "analysis/renpy/x64/sarif",
+ "kind": "report",
+ "media_type": "application/sarif+json",
+ "path": "analysis/renpy-x64.sarif",
+ }
+ ],
+ "script": {
+ "exec": ["pwsh.exe", "-NoProfile", "-Command", "-"],
+ "data": "Write-Output 'привет'\r\n",
+ },
+ }
+ document.update(overrides)
+ return json.dumps(document, ensure_ascii=False)
+
+
+class RecipeTests(unittest.TestCase):
+ def test_repository_recipe_is_parsed_without_reordering_process_data(self) -> None:
+ recipe = Recipe.parse(rendered_recipe())
+
+ self.assertEqual(recipe.name, "analyze-renpy-x64")
+ self.assertEqual(recipe.pool, "slot")
+ self.assertEqual(recipe.inputs, ("compile-renpy-x64",))
+ self.assertEqual(recipe.argv, ("pwsh.exe", "-NoProfile", "-Command", "-"))
+ self.assertEqual(recipe.data, "Write-Output 'привет'\r\n".encode())
+ self.assertEqual(
+ recipe.results,
+ (
+ Result(
+ "analysis/renpy/x64/sarif",
+ "report",
+ "application/sarif+json",
+ "analysis/renpy-x64.sarif",
+ ),
+ ),
+ )
+
+ def test_node_bridge_uses_signed_uid_direct_dependencies_and_process_data(self) -> None:
+ recipe = Recipe.parse(rendered_recipe())
+ current = recipe.to_node(
+ uid="0123456789abcdef0123456789abcdef",
+ env={"OBSERVER_OUT_DIR": r"C:\repo\out", "ZED": "last"},
+ cwd=r"C:\repo\out\work\one",
+ )
+
+ self.assertEqual(current.name, recipe.name)
+ self.assertEqual(current.inputs, ("compile-renpy-x64",))
+ self.assertEqual(current.command.argv, recipe.argv)
+ self.assertEqual(current.command.stdin, recipe.data)
+ self.assertEqual(current.command.cwd, r"C:\repo\out\work\one")
+ self.assertEqual(current.results, recipe.results)
+ self.assertEqual(
+ current.command.env,
+ (("OBSERVER_OUT_DIR", r"C:\repo\out"), ("ZED", "last")),
+ )
+
+ def test_only_required_repository_fields_are_interpreted(self) -> None:
+ recipe = Recipe.parse(rendered_recipe(metadata={"owner": "repository"}))
+ duplicate_name = rendered_recipe().replace(
+ '{"name": "analyze-renpy-x64",',
+ '{"name": "old", "name": "analyze-renpy-x64",',
+ )
+
+ self.assertEqual(recipe.name, "analyze-renpy-x64")
+ self.assertEqual(Recipe.parse(duplicate_name).name, "analyze-renpy-x64")
+
+ def test_invalid_json_or_missing_required_fields_are_rejected(self) -> None:
+ with self.assertRaises(RecipeError):
+ Recipe.parse("not json")
+
+ valid = json.loads(rendered_recipe())
+ for field in ("name", "pool", "inputs", "script"):
+ document = dict(valid)
+ del document[field]
+ with self.subTest(field=field), self.assertRaises(RecipeError):
+ Recipe.parse(json.dumps(document))
+
+ for field in ("exec", "data"):
+ script = dict(valid["script"])
+ del script[field]
+ with self.subTest(script_field=field), self.assertRaises(RecipeError):
+ Recipe.parse(json.dumps({**valid, "script": script}))
+
+ malformed_results = (
+ {"id": "report", "kind": "report", "media_type": "application/json"},
+ {"id": "report", "kind": "report", "media_type": "invalid", "path": "x"},
+ "not-a-list",
+ )
+ for results in malformed_results:
+ with self.subTest(results=results), self.assertRaises(RecipeError):
+ Recipe.parse(rendered_recipe(results=results))
+
+ def test_recipe_requires_the_typed_results_field(self) -> None:
+ document = json.loads(rendered_recipe())
+ del document["results"]
+
+ with self.assertRaises(RecipeError):
+ Recipe.parse(json.dumps(document))
+
+ def test_graph_and_process_descriptors_remain_validation_boundaries(self) -> None:
+ recipe = Recipe.parse(rendered_recipe())
+
+ with self.assertRaises(GraphError):
+ recipe.to_node(uid="not-md5")
+ with self.assertRaises(GraphError):
+ Recipe.parse(rendered_recipe(name="unsafe name")).to_node(uid="0" * 32)
+ with self.assertRaises(GraphError):
+ Recipe.parse(
+ rendered_recipe(script={"exec": ["bad\0argv"], "data": ""})
+ ).to_node(uid="0" * 32)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_render.py b/build/tests/test_render.py
new file mode 100644
index 0000000..612b76f
--- /dev/null
+++ b/build/tests/test_render.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+from jinja2 import UndefinedError
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.render import TemplateRenderer, ps_quote # noqa: E402
+
+
+class TemplateRendererTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.templates = BUILD_ROOT / "templates"
+ self.renderer = TemplateRenderer(self.templates)
+
+ def variables(self) -> dict[str, object]:
+ return {
+ "name": "build-renpy-x64",
+ "pool": "slot",
+ "inputs": ["src/modules/renpy/renpy.vcxproj"],
+ "results": [],
+ "pwsh": "pwsh.exe",
+ "msbuild": r"C:\Program Files\O'Brien Tools\MSBuild.exe",
+ "project": r"C:\repo\RPG's\renpy.vcxproj",
+ "target": "Build",
+ "configuration": "Release",
+ "platform": "x64",
+ "msbuild_args": ["/p:WarningsAsErrors=true"],
+ }
+
+ def test_msbuild_leaf_inherits_complete_json_recipe(self) -> None:
+ rendered = self.renderer.render("msbuild.ps1", self.variables())
+ recipe = json.loads(rendered)
+
+ self.assertEqual(recipe["name"], "build-renpy-x64")
+ self.assertEqual(recipe["pool"], "slot")
+ self.assertNotIn("outputs", recipe)
+ self.assertEqual(recipe["results"], [])
+ self.assertEqual(
+ recipe["script"]["exec"],
+ [
+ "pwsh.exe",
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ "$ErrorActionPreference = 'Stop'; "
+ "& ([ScriptBlock]::Create([Console]::In.ReadToEnd()))",
+ ],
+ )
+ self.assertNotIn("shell", recipe["script"])
+ self.assertIn(
+ "'C:\\Program Files\\O''Brien Tools\\MSBuild.exe'",
+ recipe["script"]["data"],
+ )
+ self.assertIn("'C:\\repo\\RPG''s\\renpy.vcxproj'", recipe["script"]["data"])
+ self.assertIn("'/p:Configuration=Release'", recipe["script"]["data"])
+ self.assertIn("'/p:WarningsAsErrors=true'", recipe["script"]["data"])
+ self.assertIn("$env:OBSERVER_OUT_DIR", recipe["script"]["data"])
+ self.assertIn("$env:OBSERVER_BUILD_DIR", recipe["script"]["data"])
+ self.assertNotIn(r"C:\repo\out\cas", recipe["script"]["data"])
+ self.assertIn("${LASTEXITCODE}: $FilePath", recipe["script"]["data"])
+
+ def test_powershell_stdin_transport_reports_parser_errors(self) -> None:
+ variables = self.variables()
+ variables["pwsh"] = shutil.which("pwsh")
+ argv = json.loads(self.renderer.render("msbuild.ps1", variables))["script"][
+ "exec"
+ ]
+ result = subprocess.run(
+ argv,
+ input=b"$invalid:\n",
+ capture_output=True,
+ )
+ self.assertNotEqual(result.returncode, 0)
+
+ def test_missing_variable_fails_before_a_recipe_is_created(self) -> None:
+ variables = self.variables()
+ del variables["platform"]
+
+ with self.assertRaisesRegex(UndefinedError, "platform.*undefined"):
+ self.renderer.render("msbuild.ps1", variables)
+
+ def test_inherited_recipe_renders_declared_typed_results(self) -> None:
+ variables = self.variables()
+ variables["results"] = [
+ {
+ "id": "binary/renpy/x64",
+ "kind": "module",
+ "media_type": "application/vnd.microsoft.portable-executable",
+ "path": "bin/renpy.dll",
+ }
+ ]
+
+ recipe = json.loads(self.renderer.render("msbuild.ps1", variables))
+
+ self.assertEqual(recipe["results"], variables["results"])
+
+ def test_power_shell_quote_is_a_single_literal(self) -> None:
+ self.assertEqual(ps_quote("plain"), "'plain'")
+ self.assertEqual(ps_quote("O'Brien"), "'O''Brien'")
+ self.assertEqual(ps_quote(Path(r"C:\A B\file.txt")), r"'C:\A B\file.txt'")
+
+ def test_msbuild_family_template_stays_reviewable(self) -> None:
+ meaningful_lines = [
+ line
+ for line in (self.templates / "msbuild.ps1").read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+
+ self.assertLessEqual(len(meaningful_lines), 18)
+
+ def test_output_postconditions_share_one_strict_template_macro(self) -> None:
+ helper = self.templates / "_output.ps1"
+ leaves = (
+ "catch2-test.ps1",
+ "clang-command.ps1",
+ "clang-tidy.ps1",
+ "fuzz-build.ps1",
+ "msvc-analyze.ps1",
+ "sanitizer-test.ps1",
+ "source-dependencies.ps1",
+ "vcpkg.ps1",
+ )
+
+ self.assertTrue(helper.is_file())
+ self.assertIn("macro require_output", helper.read_text(encoding="utf-8"))
+ for name in leaves:
+ with self.subTest(template=name):
+ content = (self.templates / name).read_text(encoding="utf-8")
+ self.assertIn('from "_output.ps1" import require_output', content)
+ self.assertNotIn("if (-not (Test-Path", content)
+
+ variables = self.variables() | {
+ "llvm_dir": "llvm",
+ "source": "unit.cpp",
+ "vcpkg_installed": "installed",
+ "vcpkg_root": "vcpkg",
+ }
+ with self.assertRaisesRegex(UndefinedError, "project_name.*undefined"):
+ self.renderer.render("msvc-analyze.ps1", variables)
+
+ def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) -> None:
+ base = self.templates / "catch2-test.ps1"
+ self.assertTrue(base.is_file(), "Catch2 shard recipes must share one inherited base")
+
+ variables: dict[str, object] = {
+ "name": "fixture",
+ "pool": "slot",
+ "inputs": ["build-tests"],
+ "results": [],
+ "pwsh": "pwsh.exe",
+ "artifacts": [
+ {"name": "tests.exe", "source": "C:/cas/tests.exe"},
+ {"name": "renpy.so", "source": "C:/cas/renpy.so"},
+ ],
+ "shard_count": 4,
+ "shard_index": 2,
+ }
+ cases = {
+ "native-test.ps1": (
+ variables,
+ "7c5777763ff4cd93f343b773511bec55c61acc717128c9bfff8d6735bcb96f83",
+ ),
+ "native-corpus-test.ps1": (
+ variables,
+ "85e7f28d606dac5a70d01be851b5e17ed47a650a8287c32163881dbce21a6033",
+ ),
+ "coverage-test.ps1": (
+ variables,
+ "2ea01c93809ed7985d11147e0a2a060b945c7b2f6645c5d0c7375c023ecaeacc",
+ ),
+ "sanitizer-test.ps1": (
+ variables
+ | {
+ "runtime": {
+ "name": "clang_rt.asan_dynamic-x86_64.dll",
+ "source": "C:/llvm/asan.dll",
+ },
+ "options_name": "ASAN_OPTIONS",
+ "options_value": "halt_on_error=1",
+ },
+ "261dc649182fe2353fcb0393a3eeb2a70ff9f17879adb654048305d6ae4289f1",
+ ),
+ "sanitizer-test.ps1:ubsan": (
+ variables
+ | {
+ "runtime": None,
+ "options_name": "UBSAN_OPTIONS",
+ "options_value": "halt_on_error=1",
+ },
+ "47ddf49be4a5df8279eb7a6bf5229550da255d3256395493e30fad45d8726800",
+ ),
+ }
+ for case, (values, expected) in cases.items():
+ template = case.partition(":")[0]
+ with self.subTest(case=case):
+ rendered = self.renderer.render(template, values).encode()
+ self.assertEqual(hashlib.sha256(rendered).hexdigest(), expected)
+
+ for name in ("native-test.ps1", "coverage-test.ps1", "sanitizer-test.ps1"):
+ with self.subTest(template=name):
+ content = (self.templates / name).read_text(encoding="utf-8")
+ self.assertIn('{% extends "catch2-test.ps1" %}', content)
+ self.assertLessEqual(len(content.splitlines()), 10)
+
+ def test_template_root_must_be_an_existing_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ file_path = Path(directory) / "template.txt"
+ file_path.write_text("content", encoding="utf-8")
+
+ with self.assertRaises(NotADirectoryError):
+ TemplateRenderer(file_path)
+ with self.assertRaises(FileNotFoundError):
+ TemplateRenderer(Path(directory) / "missing")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_result_export.py b/build/tests/test_result_export.py
new file mode 100644
index 0000000..b111cf5
--- /dev/null
+++ b/build/tests/test_result_export.py
@@ -0,0 +1,411 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+import stat
+import sys
+import tempfile
+from types import SimpleNamespace
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+import core.result_export as result_export # noqa: E402
+from core.graph import Command, Graph, Node, Result # noqa: E402
+from core.paths import BuildPaths # noqa: E402
+from core.result_export import ResultExportError, export_results # noqa: E402
+from core.store import CasStore # noqa: E402
+
+
+def node(name: str, *results: Result) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "cpu",
+ Command(("tool",)),
+ results=results,
+ )
+
+
+class ResultExportTests(unittest.TestCase):
+ def fixture(self, root: Path) -> tuple[BuildPaths, CasStore]:
+ repository = root / "repo"
+ repository.mkdir()
+ paths = BuildPaths(repository)
+ paths.prepare()
+ return paths, CasStore(paths, "export-test")
+
+ @staticmethod
+ def prepare(store: CasStore, current: Node, *, complete: bool = True) -> Path:
+ cas = store.prepare_entry(current)
+ cas.log.write_text(f"log for {current.name}\n", encoding="utf-8")
+ if complete:
+ store.mark_complete(current)
+ return cas.output
+
+ def test_files_and_directories_publish_by_logical_id_with_manifest_last(self) -> None:
+ package = Result(
+ "packages/x64/renpy.zip", "package", "application/zip", "artifacts/renpy.zip"
+ )
+ coverage = Result(
+ "reports/coverage/x64", "report", "application/json", "coverage"
+ )
+ producer = node("publish", package, coverage)
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ output = self.prepare(store, producer)
+ (output / "artifacts").mkdir()
+ (output / "artifacts/renpy.zip").write_bytes(b"archive")
+ (output / "coverage/sub").mkdir(parents=True)
+ (output / "coverage/index.json").write_bytes(b"{}")
+ (output / "coverage/sub/detail.json").write_bytes(b'{"ok":true}')
+ destination = root / "export"
+
+ write_manifest = result_export._write_manifest
+ manifest_order: list[Path] = []
+
+ def observe(staging: Path, document: dict[str, object]) -> None:
+ manifest_order.append(staging)
+ root_staging = staging.parent if staging.name == "packages" else staging
+ self.assertTrue((root_staging / "packages/x64/renpy.zip").is_file())
+ self.assertTrue((root_staging / coverage.id / "sub/detail.json").is_file())
+ self.assertFalse((root_staging / "manifest.json").exists())
+ if staging.name == "packages":
+ self.assertEqual(
+ [entry["path"] for entry in document["results"]],
+ ["x64/renpy.zip"],
+ )
+ else:
+ self.assertTrue((staging / "packages/manifest.json").is_file())
+ self.assertEqual(
+ [entry["path"] for entry in document["results"]],
+ [coverage.id],
+ )
+ write_manifest(staging, document)
+
+ with mock.patch.object(result_export, "_write_manifest", side_effect=observe) as write:
+ published = export_results(graph, store, destination, "verify", "success")
+
+ self.assertEqual(published, destination)
+ self.assertEqual(write.call_count, 2)
+ self.assertEqual(manifest_order[0], manifest_order[1] / "packages")
+ self.assertEqual((destination / "packages/x64/renpy.zip").read_bytes(), b"archive")
+ manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8"))
+ package_manifest = json.loads(
+ (destination / "packages/manifest.json").read_text(encoding="utf-8")
+ )
+
+ self.assertEqual(manifest["schema"], 1)
+ self.assertEqual(manifest["command"], "verify")
+ self.assertEqual(manifest["status"], "success")
+ self.assertEqual(manifest["failures"], [])
+ self.assertEqual(manifest["logs"], [])
+ self.assertEqual([entry["path"] for entry in manifest["results"]], [coverage.id])
+ self.assertEqual(package_manifest["schema"], 1)
+ self.assertEqual(package_manifest["command"], "verify")
+ self.assertEqual(package_manifest["status"], "success")
+ self.assertEqual(package_manifest["failures"], [])
+ self.assertEqual(package_manifest["logs"], [])
+ self.assertEqual(
+ [entry["path"] for entry in package_manifest["results"]],
+ ["x64/renpy.zip"],
+ )
+ file_entry = package_manifest["results"][0]
+ self.assertEqual(file_entry["id"], package.id)
+ directory_entry = manifest["results"][0]
+ self.assertEqual(file_entry["size"], 7)
+ self.assertEqual(file_entry["sha256"], hashlib.sha256(b"archive").hexdigest())
+ self.assertEqual(file_entry["object_type"], "file")
+ self.assertEqual(directory_entry["size"], len(b"{}") + len(b'{"ok":true}'))
+ self.assertEqual(directory_entry["object_type"], "directory")
+ self.assertRegex(directory_entry["sha256"], r"^[0-9a-f]{64}$")
+ self.assertNotIn(str(root), json.dumps(manifest))
+ self.assertNotIn(str(root), json.dumps(package_manifest))
+
+ def test_failed_export_includes_existing_logs_and_only_complete_results(self) -> None:
+ report = Result("reports/ready.json", "report", "application/json", "ready.json")
+ package = Result(
+ "packages/x64/ready.zip", "package", "application/zip", "ready.zip"
+ )
+ partial = Result("reports/partial.json", "report", "application/json", "partial.json")
+ ready, failed, pending = node("ready", report, package), node("failed", partial), node("pending")
+ graph = Graph(
+ (ready, failed, pending),
+ (ready.name, failed.name, pending.name),
+ {"cpu": 1},
+ )
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ ready_output = self.prepare(store, ready)
+ (ready_output / "ready.json").write_bytes(b"ready")
+ (ready_output / "ready.zip").write_bytes(b"package")
+ failed_output = self.prepare(store, failed, complete=False)
+ (failed_output / "partial.json").write_bytes(b"partial")
+
+ destination = root / "failed-export"
+ export_results(
+ graph,
+ store,
+ destination,
+ "verify",
+ "failed",
+ failures=(failed.name,),
+ )
+ manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8"))
+
+ self.assertTrue((destination / report.id).is_file())
+ self.assertFalse((destination / "packages").exists())
+ self.assertFalse((destination / partial.id).exists())
+ self.assertEqual((destination / "logs/ready.log").read_text(), "log for ready\n")
+ self.assertEqual((destination / "logs/failed.log").read_text(), "log for failed\n")
+
+ self.assertEqual(manifest["failures"], [failed.name])
+ self.assertEqual([entry["id"] for entry in manifest["results"]], [report.id])
+ self.assertEqual(
+ [entry["path"] for entry in manifest["logs"]],
+ ["logs/ready.log", "logs/failed.log"],
+ )
+
+ def test_root_manifest_publishes_cache_identity_report(self) -> None:
+ producer = node("cached")
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+ cache = {
+ "schema": 1,
+ "summary": {"executed": 0, "failed": 0, "hit": 1, "incomplete": 0},
+ "nodes": [{
+ "duration_ms": 0,
+ "name": producer.name,
+ "state": "hit",
+ "uid": producer.uid,
+ }],
+ }
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ self.prepare(store, producer)
+ destination = root / "export"
+
+ export_results(
+ graph, store, destination, "verify", "success", cache=cache
+ )
+ manifest = json.loads(
+ (destination / "manifest.json").read_text(encoding="utf-8")
+ )
+
+ self.assertEqual(manifest["cache"], cache)
+
+ def test_existing_destination_missing_result_and_path_collisions_are_rejected(self) -> None:
+ cases = (
+ "existing",
+ "missing",
+ "component",
+ "collision",
+ "reserved",
+ "package-reserved",
+ "packages-root",
+ )
+ for case in cases:
+ with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ results = {
+ "collision": (
+ Result("reports", "report", "application/json", "one.json"),
+ Result("reports/child", "report", "application/json", "two.json"),
+ ),
+ "reserved": (
+ Result("manifest.json", "report", "application/json", "one.json"),
+ ),
+ "package-reserved": (
+ Result(
+ "packages/manifest.json",
+ "package",
+ "application/json",
+ "one.json",
+ ),
+ ),
+ "packages-root": (
+ Result("packages", "report", "application/json", "one.json"),
+ ),
+ }.get(case, (Result("report", "report", "application/json", "one.json"),))
+ producer = node("producer", *results)
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+ output = self.prepare(store, producer)
+ if case != "missing":
+ (output / "one.json").write_bytes(b"one")
+ if case == "component":
+ producer = node(
+ "nested",
+ Result("nested", "report", "application/json", "one.json/child.json"),
+ )
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+ nested_output = self.prepare(store, producer)
+ (nested_output / "one.json").write_bytes(b"not a directory")
+ if case == "collision":
+ (output / "two.json").write_bytes(b"two")
+ destination = root / "export"
+ if case == "existing":
+ destination.mkdir()
+ (destination / "sentinel").write_bytes(b"keep")
+
+ with self.assertRaises(ResultExportError):
+ export_results(graph, store, destination, "verify", "success")
+
+ if case == "existing":
+ self.assertEqual((destination / "sentinel").read_bytes(), b"keep")
+ else:
+ self.assertFalse(destination.exists())
+
+ def test_reparse_sources_and_publication_failure_leave_no_partial_destination(self) -> None:
+ result = Result("reports/result.json", "report", "application/json", "result.json")
+ package = Result("packages/x64/result.zip", "package", "application/zip", "result.zip")
+ producer = node("producer", result, package)
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+
+ for case in ("reparse", "manifest-failure"):
+ with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ output = self.prepare(store, producer)
+ source = output / result.relative_path
+ source.write_bytes(b"result")
+ (output / package.relative_path).write_bytes(b"package")
+ destination = root / "export"
+ if case == "reparse":
+ patch = mock.patch.object(
+ result_export, "_is_reparse", side_effect=lambda path: path == source
+ )
+ else:
+ write_manifest = result_export._write_manifest
+
+ def fail_root_manifest(
+ staging: Path, document: dict[str, object]
+ ) -> None:
+ if staging.name == "packages":
+ write_manifest(staging, document)
+ return
+ raise OSError("disk full")
+
+ patch = mock.patch.object(
+ result_export, "_write_manifest", side_effect=fail_root_manifest
+ )
+
+ with patch, self.assertRaises(ResultExportError):
+ export_results(graph, store, destination, "verify", "success")
+
+ self.assertFalse(destination.exists())
+ self.assertEqual([child.name for child in root.iterdir()], ["repo"])
+
+ def test_unsupported_entries_logs_and_destination_paths_are_rejected(self) -> None:
+ directory = Result("tree", "report", "application/octet-stream", "tree")
+ producer = node("producer", directory)
+ graph = Graph((producer,), (producer.name,), {"cpu": 1})
+ cases = ("root-special", "child-special", "log-directory", "missing-parent", "dest-reparse")
+ for case in cases:
+ with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ output = self.prepare(store, producer)
+ (output / "tree").mkdir()
+ special = output / "tree/special"
+ special.write_bytes(b"special")
+ destination = root / "export"
+ if case == "root-special":
+ patch = mock.patch.object(
+ result_export,
+ "_source",
+ return_value=(output / "tree", SimpleNamespace(st_mode=stat.S_IFIFO)),
+ )
+ elif case == "child-special":
+ checked = result_export._checked
+ patch = mock.patch.object(
+ result_export,
+ "_checked",
+ side_effect=lambda path: (
+ SimpleNamespace(st_mode=stat.S_IFIFO)
+ if path == special
+ else checked(path)
+ ),
+ )
+ elif case == "log-directory":
+ cas = store.paths_for(producer)
+ cas.log.unlink()
+ cas.log.mkdir()
+ patch = mock.patch.object(store, "is_complete", return_value=False)
+ elif case == "missing-parent":
+ destination = root / "missing/export"
+ patch = mock.patch.object(result_export, "_write_manifest", wraps=result_export._write_manifest)
+ else:
+ patch = mock.patch.object(
+ result_export, "_is_reparse", side_effect=lambda path: path == root
+ )
+
+ status = "failed" if case == "log-directory" else "success"
+ with patch, self.assertRaises(ResultExportError):
+ export_results(graph, store, destination, "verify", status)
+
+ self.assertFalse(destination.exists())
+
+ def test_publication_race_does_not_replace_the_winner(self) -> None:
+ only = node("only")
+ graph = Graph((only,), (only.name,), {"cpu": 1})
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ destination = root / "export"
+ lstat = result_export._lstat
+ destination_checks = 0
+
+ def raced(path: Path):
+ nonlocal destination_checks
+ if path == destination:
+ destination_checks += 1
+ if destination_checks == 2:
+ return SimpleNamespace(st_mode=stat.S_IFDIR)
+ return lstat(path)
+
+ with mock.patch.object(result_export, "_lstat", side_effect=raced):
+ with self.assertRaisesRegex(ResultExportError, "already exists"):
+ export_results(graph, store, destination, "verify", "success")
+
+ self.assertFalse(destination.exists())
+
+ def test_manifest_inputs_are_closed_over_graph_names_and_status(self) -> None:
+ only = node("only")
+ graph = Graph((only,), (only.name,), {"cpu": 1})
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ _paths, store = self.fixture(root)
+ invalid = (
+ (42, "success", ()),
+ (r"C:\absolute\command.exe", "success", ()),
+ ("verify", "unknown", ()),
+ ("verify", "success", ("missing",)),
+ ("verify", "success", (only.name,)),
+ ("verify", "failed", (only.name, only.name)),
+ )
+ for index, (command, status, failures) in enumerate(invalid):
+ with self.subTest(command=command, status=status, failures=failures):
+ with self.assertRaises(ResultExportError):
+ export_results(
+ graph,
+ store,
+ root / f"export-{index}",
+ command,
+ status,
+ failures=failures,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_runtime.py b/build/tests/test_runtime.py
new file mode 100644
index 0000000..38f5175
--- /dev/null
+++ b/build/tests/test_runtime.py
@@ -0,0 +1,448 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+from pathlib import Path
+import shutil
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+from filelock import FileLock, Timeout
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.clean import CleanError, clean # noqa: E402
+from core.graph import Command, Graph, Node # noqa: E402
+from core.runtime import BuildRuntime, ProcessFailed # noqa: E402
+
+
+RUN_ID = "20260801-runtime"
+
+
+def node(
+ name: str = "analyze-renpy.pickle",
+ *,
+ env: tuple[tuple[str, str], ...] = (),
+ inputs: tuple[str, ...] = (),
+) -> Node:
+ return Node(
+ name=name,
+ uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ pool="cpu",
+ command=Command(
+ (r"C:\tools\analyze.exe", "literal argument"),
+ env=env,
+ cwd=r"C:\repo\source",
+ stdin=b"exact recipe\r\n",
+ ),
+ inputs=inputs,
+ )
+
+
+class FakeRunner:
+ def __init__(self, exit_code: int = 0) -> None:
+ self.exit_code = exit_code
+ self.calls: list[tuple[Command, Path]] = []
+
+ async def run(self, command: Command, *, log) -> int:
+ self.calls.append((command, Path(log.name)))
+ log.write(b"runner log")
+ log.flush()
+ return self.exit_code
+
+
+class BuildRuntimeTests(unittest.IsolatedAsyncioTestCase):
+ async def test_lock_uses_persistent_native_async_filelock(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner())
+ current = node()
+
+ with mock.patch("core.runtime.AsyncFileLock") as factory:
+ lock = runtime.lock(current)
+
+ self.assertIs(lock, factory.return_value)
+ factory.assert_called_once_with(
+ runtime.paths.lock(current.uid),
+ timeout=-1,
+ poll_interval=0.05,
+ fallback_to_soft=False,
+ preserve_lock_file=True,
+ )
+
+ async def test_success_removes_only_exact_scratch_and_empty_run_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runner = FakeRunner()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=runner)
+ runtime.paths.prepare()
+ current = node(env=(("Alpha", "one"),))
+
+ work = runtime.paths.run_work(RUN_ID) / current.uid
+ with (
+ mock.patch("core.runtime.shutil.rmtree", wraps=shutil.rmtree) as remove,
+ mock.patch("asyncio.to_thread", wraps=asyncio.to_thread) as offload,
+ ):
+ await runtime.run(current)
+
+ command, log_path = runner.calls[0]
+ cas = runtime.store.paths_for(current)
+ self.assertEqual(command.argv, current.command.argv)
+ self.assertEqual(command.cwd, current.command.cwd)
+ self.assertEqual(command.stdin, current.command.stdin)
+ self.assertEqual(
+ dict(command.env),
+ {
+ "Alpha": "one",
+ "OBSERVER_BUILD_DIR": str(work),
+ "OBSERVER_OUT_DIR": str(cas.output),
+ "_MSPDBSRV_ENDPOINT_": f"observer_{current.uid}",
+ },
+ )
+ remove.assert_called_once_with(work)
+ offload.assert_awaited_once_with(remove, work)
+ self.assertFalse(work.exists())
+ self.assertFalse(work.parent.exists())
+ self.assertEqual(log_path, cas.log)
+ self.assertEqual(cas.log.read_bytes(), b"runner log")
+ self.assertFalse(cas.touch.exists())
+
+ async def test_long_node_name_does_not_enter_mutable_scratch_path(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runner = FakeRunner()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=runner)
+ runtime.paths.prepare()
+ current = node("restore-vcpkg-asan-x86-" + "dependency" * 10)
+
+ await runtime.run(current)
+
+ command, _log_path = runner.calls[0]
+ work = Path(dict(command.env)["OBSERVER_BUILD_DIR"])
+ self.assertEqual(work, runtime.paths.run_work(RUN_ID) / current.uid)
+ self.assertNotIn(current.name, str(work))
+
+ async def test_runtime_injects_unique_mspdbsrv_endpoint_per_uid(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runner = FakeRunner()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=runner)
+ runtime.paths.prepare()
+ nodes = (node("build-renpy-x86-debug"), node("build-renpy-x64-debug"))
+
+ for current in nodes:
+ await runtime.run(current)
+
+ endpoints = tuple(
+ dict(command.env)["_MSPDBSRV_ENDPOINT_"] for command, _log in runner.calls
+ )
+ self.assertEqual(endpoints, tuple(f"observer_{current.uid}" for current in nodes))
+ self.assertEqual(len(set(endpoints)), len(nodes))
+
+ async def test_run_rejects_case_insensitive_runtime_environment_conflicts(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runner = FakeRunner()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=runner)
+ runtime.paths.prepare()
+
+ with self.assertRaisesRegex(ValueError, "OBSERVER_OUT_DIR"):
+ await runtime.run(node(env=(("observer_out_dir", "hostile"),)))
+
+ self.assertEqual(runner.calls, [])
+
+ with self.assertRaisesRegex(ValueError, "_MSPDBSRV_ENDPOINT_"):
+ await runtime.run(node(env=(("_mspdbsrv_endpoint_", "shared"),)))
+
+ self.assertEqual(runner.calls, [])
+
+ async def test_executor_runs_and_publishes_success(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runner = FakeRunner()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=runner)
+ current = node()
+ graph = Graph((current,), (current.name,), {"cpu": 1})
+
+ self.assertFalse(runtime.paths.output_root.exists())
+ await runtime.executor(graph).run()
+
+ self.assertEqual(len(runner.calls), 1)
+ self.assertTrue(runtime.is_complete(current))
+ self.assertEqual(runtime.store.paths_for(current).touch.stat().st_size, 0)
+
+ async def test_cache_report_distinguishes_executed_and_restored_nodes(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ current = node()
+ graph = Graph((current,), (current.name,), {"cpu": 1})
+
+ cold_runner = FakeRunner()
+ cold = BuildRuntime(repository, "cold-run", process_runner=cold_runner)
+ await cold.executor(graph).run()
+ cold_report = cold.cache_report()
+
+ warm_runner = FakeRunner()
+ warm = BuildRuntime(repository, "warm-run", process_runner=warm_runner)
+ await warm.executor(graph).run()
+ warm_report = warm.cache_report()
+
+ self.assertEqual(cold_report["schema"], 1)
+ self.assertEqual(
+ cold_report["summary"],
+ {"executed": 1, "failed": 0, "hit": 0, "incomplete": 0},
+ )
+ self.assertEqual(cold_report["nodes"][0]["name"], current.name)
+ self.assertEqual(cold_report["nodes"][0]["uid"], current.uid)
+ self.assertEqual(cold_report["nodes"][0]["state"], "executed")
+ self.assertGreaterEqual(cold_report["nodes"][0]["duration_ms"], 0)
+ self.assertEqual(cold.live_uids(), (current.uid,))
+
+ self.assertEqual(
+ warm_report["summary"],
+ {"executed": 0, "failed": 0, "hit": 1, "incomplete": 0},
+ )
+ self.assertEqual(warm_report["nodes"], [
+ {
+ "duration_ms": 0,
+ "name": current.name,
+ "state": "hit",
+ "uid": current.uid,
+ }
+ ])
+ self.assertEqual(warm.live_uids(), (current.uid,))
+ self.assertEqual(len(cold_runner.calls), 1)
+ self.assertEqual(warm_runner.calls, [])
+
+ async def test_warm_cached_target_retains_and_reports_its_complete_dependency(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ dependency = node("dependency")
+ target = node("target", inputs=(dependency.name,))
+ graph = Graph((dependency, target), (target.name,), {"cpu": 1})
+
+ cold = BuildRuntime(repository, "cold-run", process_runner=FakeRunner())
+ await cold.executor(graph).run()
+ warm = BuildRuntime(repository, "warm-run", process_runner=FakeRunner())
+ await warm.executor(graph).run()
+
+ self.assertEqual(
+ warm.live_uids(graph), tuple(sorted((dependency.uid, target.uid)))
+ )
+ self.assertEqual(
+ warm.cache_report(graph)["summary"],
+ {"executed": 0, "failed": 0, "hit": 2, "incomplete": 0},
+ )
+
+ async def test_cache_report_excludes_failed_nodes_from_live_uids(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ current = node()
+ graph = Graph((current,), (current.name,), {"cpu": 1})
+ runtime = BuildRuntime(
+ repository, "failed-run", process_runner=FakeRunner(23)
+ )
+
+ with self.assertRaises(ExceptionGroup):
+ await runtime.executor(graph).run()
+
+ self.assertEqual(runtime.cache_report()["summary"], {
+ "executed": 0,
+ "failed": 1,
+ "hit": 0,
+ "incomplete": 0,
+ })
+ self.assertEqual(runtime.cache_report()["nodes"][0]["state"], "failed")
+ self.assertEqual(runtime.live_uids(), ())
+
+ async def test_cache_report_marks_observed_unpublished_nodes_incomplete(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, "pending-run", process_runner=FakeRunner())
+ current = node()
+
+ self.assertFalse(runtime.is_complete(current))
+
+ self.assertEqual(runtime.cache_report()["summary"], {
+ "executed": 0,
+ "failed": 0,
+ "hit": 0,
+ "incomplete": 1,
+ })
+ self.assertEqual(runtime.cache_report()["nodes"][0]["state"], "incomplete")
+ self.assertEqual(runtime.live_uids(), ())
+
+ async def test_executor_holds_per_run_lease_without_serializing_distinct_runs(self) -> None:
+ class ConcurrentRunner:
+ entered = 0
+ both = asyncio.Event()
+ release = asyncio.Event()
+
+ async def run(self, _command: Command, *, log) -> int:
+ type(self).entered += 1
+ if type(self).entered == 2:
+ type(self).both.set()
+ await type(self).release.wait()
+ return 0
+
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ first = BuildRuntime(repository, "run-one", process_runner=ConcurrentRunner())
+ second = BuildRuntime(repository, "run-two", process_runner=ConcurrentRunner())
+ graphs = tuple(
+ Graph((current,), (current.name,), {"cpu": 1})
+ for current in (node("first"), node("second"))
+ )
+ tasks = tuple(
+ asyncio.create_task(runtime.executor(graph).run())
+ for runtime, graph in zip((first, second), graphs, strict=True)
+ )
+ await asyncio.wait_for(ConcurrentRunner.both.wait(), timeout=2)
+
+ for run_id in ("run-one", "run-two"):
+ with self.assertRaises(Timeout), FileLock(
+ first.paths.lease(run_id), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ):
+ pass
+ with FileLock(first.paths.coordination_lock(), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True):
+ pass
+
+ ConcurrentRunner.release.set()
+ await asyncio.gather(*tasks)
+ for run_id in ("run-one", "run-two"):
+ with FileLock(first.paths.lease(run_id), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True):
+ pass
+
+ async def test_session_reuses_one_lease_across_executors_and_blocks_clean(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner())
+ graphs = tuple(
+ Graph((current,), (current.name,), {"cpu": 1})
+ for current in (node("discovery"), node("final"))
+ )
+
+ async with runtime.session():
+ self.assertEqual(runtime.owned_run_id, RUN_ID)
+ with self.assertRaisesRegex(RuntimeError, "already active"):
+ async with runtime.session():
+ pass
+ await runtime.executor(graphs[0]).run()
+ with self.assertRaises(CleanError):
+ await asyncio.to_thread(clean, repository)
+ await runtime.executor(graphs[1]).run()
+ with self.assertRaises(Timeout), FileLock(
+ runtime.paths.lease(RUN_ID), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ):
+ pass
+
+ self.assertIsNone(runtime.owned_run_id)
+ with FileLock(
+ runtime.paths.lease(RUN_ID), timeout=0, fallback_to_soft=False,
+ preserve_lock_file=True,
+ ):
+ pass
+
+ async def test_nonzero_exit_leaves_entry_incomplete_without_marker(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner(23))
+ runtime.paths.prepare()
+ current = node()
+
+ with self.assertRaisesRegex(ProcessFailed, "23.*analyze-renpy.pickle"):
+ await runtime.run(current)
+
+ cas = runtime.store.paths_for(current)
+ work = runtime.paths.run_work(RUN_ID) / current.uid
+ self.assertTrue(cas.entry.is_dir())
+ self.assertTrue(work.is_dir())
+ self.assertFalse(cas.touch.exists())
+ self.assertFalse(runtime.is_complete(current))
+
+ async def test_cancellation_preserves_scratch(self) -> None:
+ class CancelledRunner:
+ async def run(self, _command: Command, *, log) -> int:
+ raise asyncio.CancelledError
+
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=CancelledRunner())
+ runtime.paths.prepare()
+ current = node()
+ work = runtime.paths.run_work(RUN_ID) / current.uid
+
+ with self.assertRaises(asyncio.CancelledError):
+ await runtime.run(current)
+
+ self.assertTrue(work.is_dir())
+
+ async def test_reparse_scratch_is_rejected_instead_of_removed(self) -> None:
+ reparse = False
+
+ class ReplacingRunner:
+ async def run(self, _command: Command, *, log) -> int:
+ nonlocal reparse
+ reparse = True
+ return 0
+
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=ReplacingRunner())
+ runtime.paths.prepare()
+ current = node()
+ work = runtime.paths.run_work(RUN_ID) / current.uid
+
+ with (
+ mock.patch("core.paths._is_reparse", side_effect=lambda path: reparse and path == work),
+ mock.patch("core.runtime.shutil.rmtree") as remove,
+ self.assertRaisesRegex(ValueError, "reparse point"),
+ ):
+ await runtime.run(current)
+
+ remove.assert_not_called()
+ self.assertTrue(work.is_dir())
+
+ async def test_quarantine_survives_successful_scratch_cleanup(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner())
+ runtime.paths.prepare()
+ current = node()
+ old = runtime.store.paths_for(current)
+ old.output.mkdir(parents=True)
+ (old.output / "partial.obj").write_bytes(b"partial")
+
+ await runtime.run(current)
+
+ quarantine = runtime.paths.run_work(RUN_ID) / "quarantine" / old.entry.name
+ self.assertEqual((quarantine / "out/partial.obj").read_bytes(), b"partial")
+ self.assertFalse((runtime.paths.run_work(RUN_ID) / current.uid).exists())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_sanitizer_graph.py b/build/tests/test_sanitizer_graph.py
new file mode 100644
index 0000000..83fc85b
--- /dev/null
+++ b/build/tests/test_sanitizer_graph.py
@@ -0,0 +1,403 @@
+from __future__ import annotations
+
+from contextlib import redirect_stderr
+import hashlib
+import io
+from pathlib import Path
+import runpy
+import sys
+import tempfile
+import unittest
+from unittest import mock
+import xml.etree.ElementTree as ET
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import Command, Graph, Node # noqa: E402
+from core.paths import BuildPaths # noqa: E402
+from core.sanitizer import SanitizerError, main as sanitizer_main, require_clean_log # noqa: E402
+from graphs.sanitizer import ( # noqa: E402
+ AsanRuntime,
+ sanitizer_artifact_graph,
+ sanitizer_dependency_discovery_slice,
+ sanitizer_graph,
+)
+from tests import test_instrumented_graph as instrumented_fixture # noqa: E402
+
+
+def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node:
+ return Node(
+ name,
+ hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(),
+ "build",
+ Command(("C:/sdk/build.exe",)),
+ inputs,
+ )
+
+
+class SanitizerGraphTests(unittest.TestCase):
+ def fixture(
+ self,
+ root: Path,
+ selections: tuple[tuple[str, str], ...] = (
+ ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")
+ ),
+ ) -> tuple[Path, Graph, tuple[AsanRuntime, ...], Path]:
+ repository = root / "repo"
+ repository.mkdir()
+ nodes = []
+ for sanitizer, architecture in selections:
+ restore_name = (
+ f"restore-vcpkg-asan-{architecture}"
+ if sanitizer == "asan"
+ else f"restore-vcpkg-{architecture}"
+ )
+ restore = node(restore_name)
+ discovery = node(
+ f"discover-{sanitizer}-{architecture}", inputs=(restore.name,)
+ )
+ nodes.extend((restore, discovery))
+ for name, filename in (
+ ("renpy", "renpy.so"),
+ ("rpgmaker", "rpgmaker.so"),
+ ("zanzarah", "zanzarah.so"),
+ ("tests", "tests.exe"),
+ ):
+ producer = node(
+ f"build-{name}-{architecture}-{sanitizer}",
+ inputs=(discovery.name,),
+ )
+ nodes.append(producer)
+ upstream = Graph(
+ tuple(nodes), tuple(current.name for current in nodes[1:]), {"build": 8}
+ )
+ tools = root / "tools"
+ tools.mkdir()
+ pwsh = tools / "pwsh.exe"
+ pwsh.touch()
+ runtimes = []
+ for architecture, filename in (
+ ("x86", "clang_rt.asan_dynamic-i386.dll"),
+ ("x64", "clang_rt.asan_dynamic-x86_64.dll"),
+ ):
+ if ("asan", architecture) in selections:
+ path = tools / filename
+ path.touch()
+ runtimes.append(
+ AsanRuntime(architecture, path, {"sha256": f"runtime-{architecture}"})
+ )
+ return repository, upstream, tuple(runtimes), pwsh
+
+ def build(self, root: Path, **options: object) -> Graph:
+ selections = options.pop(
+ "selections", (("asan", "x86"), ("asan", "x64"), ("ubsan", "x64"))
+ )
+ repository, upstream, runtimes, pwsh = self.fixture(
+ root, selections # type: ignore[arg-type]
+ )
+ return sanitizer_artifact_graph(
+ repository,
+ upstream,
+ selections=selections, # type: ignore[arg-type]
+ pwsh=pwsh,
+ pwsh_identity={"version": options.pop("pwsh_version", "7.5")},
+ asan_runtimes=runtimes,
+ **options,
+ )
+
+ def test_asan_and_ubsan_build_adapters_create_independent_shard_gates(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ graph = self.build(root, test_shards=2, jobs=6)
+ paths = BuildPaths(root / "repo")
+
+ self.assertEqual(
+ graph.pools,
+ {"build": 8, "sanitizer-shard": 6, "sanitizer-gate": 6},
+ )
+ self.assertEqual(
+ graph.targets,
+ (
+ "asan-gate-x64-0", "asan-gate-x64-1",
+ "asan-gate-x86-0", "asan-gate-x86-1",
+ "ubsan-gate-x64-0", "ubsan-gate-x64-1",
+ ),
+ )
+ self.assertEqual(len(graph.nodes), 30)
+ for sanitizer, architecture in (
+ ("asan", "x64"), ("asan", "x86"), ("ubsan", "x64")
+ ):
+ builds = tuple(
+ graph.node(f"build-{name}-{architecture}-{sanitizer}")
+ for name in ("renpy", "rpgmaker", "zanzarah", "tests")
+ )
+ for index in range(2):
+ shard = graph.node(f"{sanitizer}-test-{architecture}-{index}")
+ gate = graph.node(f"{sanitizer}-gate-{architecture}-{index}")
+ self.assertEqual(shard.inputs, tuple(item.name for item in builds))
+ self.assertEqual(gate.inputs, (shard.name,))
+ self.assertEqual((shard.pool, gate.pool), ("sanitizer-shard", "sanitizer-gate"))
+ self.assertEqual(
+ gate.command.argv[1:],
+ (
+ "-m", "core.sanitizer", "gate", sanitizer,
+ str(paths.cas(shard.uid).log),
+ ),
+ )
+ self.assertEqual(
+ tuple((item.id, item.kind, item.media_type, item.relative_path)
+ for item in shard.results),
+ ((
+ f"reports/sanitizers/{sanitizer}/{architecture}/shard-{index}.xml",
+ "sanitizer", "application/xml", "tests.xml",
+ ),),
+ )
+ self.assertEqual(gate.results, ())
+ script = shard.command.stdin.decode("utf-8")
+ self.assertIn("Invoke-Checked", script)
+ self.assertIn("'--shard-count'", script)
+ self.assertIn("'2'", script)
+ self.assertIn("'--shard-index'", script)
+ self.assertIn(f"'{index}'", script)
+ for binary in ("renpy.so", "rpgmaker.so", "zanzarah.so", "tests.exe"):
+ self.assertIn(binary, script)
+ if sanitizer == "asan":
+ self.assertIn("ASAN_OPTIONS", script)
+ self.assertIn("halt_on_error=1:alloc_dealloc_mismatch=1", script)
+ self.assertIn("clang_rt.asan_dynamic-", script)
+ self.assertNotIn("UBSAN_OPTIONS", script)
+ else:
+ self.assertIn("UBSAN_OPTIONS", script)
+ self.assertIn("halt_on_error=1:print_stacktrace=1", script)
+ self.assertNotIn("clang_rt.asan_dynamic-", script)
+
+ def test_runtime_and_pwsh_identities_have_narrow_invalidation_partitions(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, runtimes, pwsh = self.fixture(root)
+
+ def build(
+ pwsh_version: str = "7.5", runtime_x64: str = "runtime-x64"
+ ) -> Graph:
+ changed = tuple(
+ AsanRuntime(
+ item.architecture,
+ item.path,
+ {"sha256": runtime_x64 if item.architecture == "x64" else "runtime-x86"},
+ )
+ for item in runtimes
+ )
+ return sanitizer_artifact_graph(
+ repository,
+ upstream,
+ selections=(("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")),
+ pwsh=pwsh,
+ pwsh_identity={"version": pwsh_version},
+ asan_runtimes=changed,
+ test_shards=2,
+ )
+
+ before = build()
+ runtime_changed = build(runtime_x64="changed")
+ pwsh_changed = build(pwsh_version="7.6")
+
+ for current in before.nodes:
+ if "-test-" not in current.name and "-gate-" not in current.name:
+ continue
+ runtime_partition = current.name.startswith("asan-") and "-x64-" in current.name
+ self.assertEqual(
+ runtime_partition,
+ current.uid != runtime_changed.node(current.name).uid,
+ current.name,
+ )
+ self.assertNotEqual(current.uid, pwsh_changed.node(current.name).uid, current.name)
+
+ def test_rejects_invalid_selections_lineage_runtimes_tools_and_pools(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository, upstream, runtimes, pwsh = self.fixture(
+ root, (("asan", "x64"),)
+ )
+
+ def invoke(
+ *,
+ source: Graph = upstream,
+ selections: tuple[tuple[str, str], ...] = (("asan", "x64"),),
+ selected_runtimes: tuple[AsanRuntime, ...] = runtimes,
+ pwsh_path: Path = pwsh,
+ **options: object,
+ ) -> Graph:
+ return sanitizer_artifact_graph(
+ repository,
+ source,
+ selections=selections,
+ pwsh=pwsh_path,
+ pwsh_identity={"version": "7.5"},
+ asan_runtimes=selected_runtimes,
+ **options,
+ )
+
+ for invalid in (
+ (),
+ (("asan", "x64"), ("asan", "x64")),
+ (("msan", "x64"),),
+ (("asan", "arm64"),),
+ (("ubsan", "x86"),),
+ ):
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "selections"):
+ invoke(selections=invalid)
+
+ missing = Graph(
+ tuple(item for item in upstream.nodes if item.name != "build-tests-x64-asan"),
+ tuple(name for name in upstream.targets if name != "build-tests-x64-asan"),
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "unknown node"):
+ invoke(source=missing)
+
+ shared = node("detached-shared")
+ left = node("detached-left", inputs=(shared.name,))
+ right = node("detached-right", inputs=(shared.name,))
+ detached = node(
+ "build-renpy-x64-asan", inputs=(left.name, right.name)
+ )
+ detached_source = Graph(
+ tuple(
+ detached if current.name == detached.name else current
+ for current in upstream.nodes
+ ) + (shared, left, right),
+ upstream.targets,
+ upstream.pools,
+ )
+ with self.assertRaisesRegex(ValueError, "restore ancestor"):
+ invoke(source=detached_source)
+
+ with self.assertRaisesRegex(ValueError, "runtime.*required"):
+ invoke(selected_runtimes=())
+ with self.assertRaisesRegex(ValueError, "duplicate.*runtime"):
+ invoke(selected_runtimes=runtimes + runtimes)
+ bad_runtime = AsanRuntime("arm64", runtimes[0].path, {})
+ with self.assertRaisesRegex(ValueError, "runtime identity"):
+ invoke(selected_runtimes=(bad_runtime,))
+ bad_name = AsanRuntime("x64", pwsh, {})
+ with self.assertRaisesRegex(ValueError, "runtime identity"):
+ invoke(selected_runtimes=(bad_name,))
+ directory_runtime = AsanRuntime("x64", pwsh.parent, {})
+ with self.assertRaisesRegex(FileNotFoundError, "not a file"):
+ invoke(selected_runtimes=(directory_runtime,))
+ with self.assertRaisesRegex(FileNotFoundError, "not a file"):
+ invoke(pwsh_path=pwsh.parent)
+ for options in ({"test_shards": 0}, {"jobs": True}):
+ with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"):
+ invoke(**options)
+ matching = Graph(
+ upstream.nodes, upstream.targets,
+ dict(upstream.pools) | {"sanitizer-shard": 4, "sanitizer-gate": 4},
+ )
+ self.assertEqual(invoke(source=matching).pools["sanitizer-shard"], 4)
+ conflict = Graph(
+ upstream.nodes, upstream.targets,
+ dict(upstream.pools) | {"sanitizer-shard": 1},
+ )
+ with self.assertRaisesRegex(ValueError, "conflicting pool"):
+ invoke(source=conflict)
+
+ def test_complete_graph_discovers_and_builds_all_sanitizer_artifacts_itself(self) -> None:
+ helper = instrumented_fixture.InstrumentedBuildGraphTests()
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = helper.repository(root / "repo")
+ toolchain = helper.toolchain(root)
+ llvm_runtime = helper.llvm_runtime(root)
+ runtime_path = root / "clang_rt.asan_dynamic-x86_64.dll"
+ runtime_path.touch()
+ runtimes = (
+ AsanRuntime("x64", runtime_path, {"sha256": "asan-x64"}),
+ )
+ selections = (("asan", "x64"), ("ubsan", "x64"))
+ runtime_identity = {"standalone": "sha256:a", "cxx": "sha256:b"}
+ discovery = sanitizer_dependency_discovery_slice(
+ repository,
+ toolchain,
+ selections=selections,
+ llvm_runtime=llvm_runtime,
+ llvm_runtime_identity=runtime_identity,
+ jobs=4,
+ )
+ graph = sanitizer_graph(
+ repository,
+ toolchain,
+ discovery=discovery,
+ manifests=helper.manifests(repository, discovery),
+ selections=selections,
+ llvm_runtime=llvm_runtime,
+ llvm_runtime_identity=runtime_identity,
+ asan_runtimes=runtimes,
+ jobs=4,
+ test_shards=1,
+ )
+
+ self.assertEqual(graph.targets, ("asan-gate-x64-0", "ubsan-gate-x64-0"))
+ self.assertEqual(
+ len(graph.nodes),
+ len(discovery.nodes) + len(selections) * (4 + 1 + 1),
+ )
+ for sanitizer in ("asan", "ubsan"):
+ shard = graph.node(f"{sanitizer}-test-x64-0")
+ for project in ("renpy", "rpgmaker", "zanzarah", "tests"):
+ build = graph.node(f"build-{project}-x64-{sanitizer}")
+ self.assertIn(
+ f"'/p:Configuration={'ASan' if sanitizer == 'asan' else 'UBSan'}'",
+ build.command.stdin.decode(),
+ )
+ self.assertEqual(shard.inputs.count(build.name), 1)
+
+ def test_sanitizer_runtime_is_test_only_and_release_remains_static_mt(self) -> None:
+ root = ET.parse(BUILD_ROOT / "ObserverProject.props").getroot()
+ namespace = "{http://schemas.microsoft.com/developer/msbuild/2003}"
+ runtime_libraries = root.findall(f".//{namespace}RuntimeLibrary")
+ release = next(
+ item for item in runtime_libraries if "$(Configuration)' == 'Release" in item.get("Condition", "")
+ )
+ self.assertEqual(release.text, "MultiThreaded")
+
+
+class SanitizerGateTests(unittest.TestCase):
+ def test_clean_logs_pass_and_findings_fail_for_each_runtime(self) -> None:
+ require_clean_log("asan", "All tests passed (42 assertions)\n")
+ require_clean_log("ubsan", "All tests passed (42 assertions)\n")
+ for sanitizer, finding in (
+ ("asan", "ERROR: AddressSanitizer: heap-use-after-free"),
+ ("asan", "AddressSanitizer:DEADLYSIGNAL"),
+ ("asan", "SUMMARY: AddressSanitizer: double-free"),
+ ("ubsan", "foo.cpp:3: runtime error: signed integer overflow"),
+ ("ubsan", "UndefinedBehaviorSanitizer:DEADLYSIGNAL"),
+ ("ubsan", "SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior"),
+ ):
+ with self.subTest(sanitizer=sanitizer, finding=finding), self.assertRaisesRegex(
+ SanitizerError, "finding"
+ ):
+ require_clean_log(sanitizer, finding)
+ with self.assertRaisesRegex(SanitizerError, "unsupported"):
+ require_clean_log("msan", "clean")
+
+ def test_cli_has_no_relaxation_switch_and_module_entrypoint(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ log = Path(temporary) / "test.log"
+ log.write_text("All tests passed\n", encoding="utf-8")
+ self.assertEqual(sanitizer_main(("gate", "asan", str(log))), 0)
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ sanitizer_main(("gate", "asan", str(log), "--allow-findings"))
+ with (
+ mock.patch.object(sys, "argv", ["sanitizer.py", "gate", "ubsan", str(log)]),
+ self.assertWarnsRegex(RuntimeWarning, "core.sanitizer"),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("core.sanitizer", run_name="__main__")
+ self.assertEqual(raised.exception.code, 0)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_sarif.py b/build/tests/test_sarif.py
new file mode 100644
index 0000000..39a43ef
--- /dev/null
+++ b/build/tests/test_sarif.py
@@ -0,0 +1,306 @@
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).parents[1]))
+
+from core.sarif import ( # noqa: E402
+ SarifError,
+ SarifFindingsError,
+ clang_tidy_to_sarif,
+ merge_sarif,
+ normalize_msvc,
+ require_clean,
+)
+from core import sarif # noqa: E402
+
+
+def write_json(path: Path, document: object) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(document), encoding="utf-8")
+
+
+class SarifTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+
+ def test_clang_tidy_conversion_is_confined_deduplicated_and_deterministic(self) -> None:
+ repository = self.root / "repo"
+ source = repository / "src" / "unit.cpp"
+ other_source = repository / "src" / "other.cpp"
+ outside = self.root / "repo-sibling" / "outside.cpp"
+ for path in (source, other_source, outside):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+
+ logs = self.root / "objects"
+ first = logs / "z" / "z.ClangTidy.log"
+ second = logs / "a" / "a.ClangTidy.log"
+ first.parent.mkdir(parents=True)
+ second.parent.mkdir(parents=True)
+ duplicate = f"{source}(9,3): warning: duplicate message [modernize-use-nullptr] [renpy.vcxproj]"
+ first.write_text(
+ "\n".join(
+ (
+ duplicate,
+ f"{outside}(1,1): error: outside [outside-check] [renpy.vcxproj]",
+ f"{source}(1,1): warning: no rule suffix",
+ f"{source}(2,5): error: second message [-*, bugprone-sizeof-expression] [renpy.vcxproj]",
+ )
+ ),
+ encoding="utf-8",
+ )
+ second.write_text(
+ "\n".join(
+ (
+ f"{other_source}(4,2): warning: first message [alpha-check] [renpy.vcxproj]",
+ duplicate,
+ f"{source}(3,1): warning: disabled only [-*] [renpy.vcxproj]",
+ "ordinary compiler output",
+ )
+ ),
+ encoding="utf-8",
+ )
+
+ output = self.root / "reports" / "tidy.sarif"
+ repeat = self.root / "reports" / "repeat.sarif"
+ automation_id = "clang-tidy/x64/renpy/pickle/"
+ clang_tidy_to_sarif(repository, logs, output, automation_id)
+ clang_tidy_to_sarif(repository, logs, repeat, automation_id)
+
+ self.assertEqual(output.read_bytes(), repeat.read_bytes())
+ document = json.loads(output.read_text(encoding="utf-8"))
+ self.assertEqual("2.1.0", document["version"])
+ self.assertEqual(automation_id, document["runs"][0]["automationDetails"]["id"])
+ driver = document["runs"][0]["tool"]["driver"]
+ self.assertEqual(
+ ["alpha-check", "bugprone-sizeof-expression", "modernize-use-nullptr"],
+ [rule["id"] for rule in driver["rules"]],
+ )
+ results = document["runs"][0]["results"]
+ self.assertEqual(3, len(results))
+ self.assertEqual(
+ [
+ ("src/other.cpp", 4, "alpha-check", "warning", "first message"),
+ ("src/unit.cpp", 2, "bugprone-sizeof-expression", "error", "second message"),
+ ("src/unit.cpp", 9, "modernize-use-nullptr", "warning", "duplicate message"),
+ ],
+ [
+ (
+ result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
+ result["locations"][0]["physicalLocation"]["region"]["startLine"],
+ result["ruleId"],
+ result["level"],
+ result["message"]["text"],
+ )
+ for result in results
+ ],
+ )
+
+ def test_clang_tidy_missing_log_tree_produces_an_empty_run(self) -> None:
+ repository = self.root / "repo"
+ repository.mkdir()
+ output = self.root / "empty.sarif"
+
+ clang_tidy_to_sarif(repository, self.root / "missing", output, "tidy/exact/")
+
+ run = json.loads(output.read_text(encoding="utf-8"))["runs"][0]
+ self.assertEqual([], run["results"])
+ self.assertEqual([], run["tool"]["driver"]["rules"])
+
+ def test_msvc_normalization_retains_document_and_assigns_stable_run_ids(self) -> None:
+ source = self.root / "raw.sarif"
+ output = self.root / "normalized.sarif"
+ original = {
+ "version": "2.1.0",
+ "$schema": "original-schema",
+ "inlineExternalProperties": [{"guid": "kept"}],
+ "runs": [
+ {"automationDetails": {"id": "unstable", "description": {"text": "kept"}}, "results": []},
+ {"automationDetails": "invalid but replaceable", "properties": {"kept": True}},
+ ],
+ }
+ write_json(source, original)
+
+ normalize_msvc(source, output, "msvc-analyze/x64/renpy/pickle/")
+
+ normalized = json.loads(output.read_text(encoding="utf-8"))
+ self.assertEqual("original-schema", normalized["$schema"])
+ self.assertEqual(original["inlineExternalProperties"], normalized["inlineExternalProperties"])
+ self.assertEqual(
+ [
+ "msvc-analyze/x64/renpy/pickle/run-1/",
+ "msvc-analyze/x64/renpy/pickle/run-2/",
+ ],
+ [run["automationDetails"]["id"] for run in normalized["runs"]],
+ )
+ self.assertEqual({"text": "kept"}, normalized["runs"][0]["automationDetails"]["description"])
+ self.assertEqual({"kept": True}, normalized["runs"][1]["properties"])
+
+ single_source = self.root / "single.sarif"
+ single_output = self.root / "single-normalized.sarif"
+ write_json(single_source, {"version": "2.1.0", "runs": [{"results": []}]})
+ normalize_msvc(single_source, single_output, "caller-supplied-exact-id")
+ self.assertEqual(
+ "caller-supplied-exact-id",
+ json.loads(single_output.read_text(encoding="utf-8"))["runs"][0]["automationDetails"]["id"],
+ )
+
+ def test_normalization_rejects_non_21_documents_or_invalid_runs(self) -> None:
+ source = self.root / "raw.sarif"
+ output = self.root / "normalized.sarif"
+ for document in (
+ {"version": "2.0.0", "runs": [{}]},
+ {"version": "2.1.0", "runs": []},
+ {"version": "2.1.0", "runs": ["not an object"]},
+ ):
+ with self.subTest(document=document):
+ write_json(source, document)
+ with self.assertRaises(SarifError):
+ normalize_msvc(source, output, "id")
+
+ def test_merge_sorts_runs_by_identity_and_is_deterministic(self) -> None:
+ first = self.root / "first.sarif"
+ second = self.root / "second.sarif"
+ write_json(first, {"version": "2.1.0", "runs": [{"automationDetails": {"id": "z/"}, "value": 2}]})
+ write_json(
+ second,
+ {
+ "version": "2.1.0",
+ "runs": [
+ {"automationDetails": {"id": "m/"}, "value": 1},
+ {"automationDetails": {"id": "a/"}, "value": 0},
+ ],
+ },
+ )
+ output = self.root / "merged.sarif"
+ reverse = self.root / "merged-reverse.sarif"
+
+ merge_sarif((first, second), output)
+ merge_sarif((second, first), reverse)
+
+ self.assertEqual(output.read_bytes(), reverse.read_bytes())
+ merged = json.loads(output.read_text(encoding="utf-8"))
+ self.assertEqual(["a/", "m/", "z/"], [run["automationDetails"]["id"] for run in merged["runs"]])
+ self.assertEqual([0, 1, 2], [run["value"] for run in merged["runs"]])
+
+ def test_merge_rejects_missing_or_duplicate_identity_and_no_inputs(self) -> None:
+ output = self.root / "merged.sarif"
+ missing = self.root / "missing-id.sarif"
+ duplicate = self.root / "duplicate.sarif"
+ write_json(missing, {"version": "2.1.0", "runs": [{"automationDetails": {}}]})
+ write_json(
+ duplicate,
+ {
+ "version": "2.1.0",
+ "runs": [
+ {"automationDetails": {"id": "same/"}},
+ {"automationDetails": {"id": "same/"}},
+ ],
+ },
+ )
+
+ for inputs in ((), (missing,), (duplicate,)):
+ with self.subTest(inputs=inputs), self.assertRaises(SarifError):
+ merge_sarif(inputs, output)
+
+ def test_gate_ignores_non_findings_and_rejects_warnings_and_errors(self) -> None:
+ clean = self.root / "clean.sarif"
+ warning = self.root / "warning.sarif"
+ write_json(
+ clean,
+ {
+ "version": "2.1.0",
+ "runs": [
+ {
+ "automationDetails": {"id": "clean/"},
+ "results": [{"level": "note"}, {"level": "none"}],
+ }
+ ],
+ },
+ )
+ write_json(
+ warning,
+ {
+ "version": "2.1.0",
+ "runs": [
+ {
+ "automationDetails": {"id": "findings/"},
+ "results": [{"level": "warning"}, {"level": "error"}, {"level": "note"}],
+ }
+ ],
+ },
+ )
+
+ self.assertIsNone(require_clean((clean,)))
+ with self.assertRaisesRegex(SarifFindingsError, "2 warning/error finding"):
+ require_clean((clean, warning))
+
+ def test_cli_dispatches_every_command_into_observer_output_directory(self) -> None:
+ output = self.root / "out"
+ output.mkdir()
+ source = self.root / "raw.sarif"
+ logs = self.root / "logs"
+ repository = self.root / "repo"
+ environment = {"OBSERVER_OUT_DIR": str(output)}
+
+ with mock.patch.dict("os.environ", environment, clear=True):
+ with mock.patch.object(sarif, "normalize_msvc") as operation:
+ self.assertEqual(
+ 0,
+ sarif.main(("normalize-msvc", str(source), "msvc/id/", "--output-name", "renpy.sarif")),
+ )
+ operation.assert_called_once_with(source, output / "renpy.sarif", "msvc/id/")
+
+ with mock.patch.object(sarif, "clang_tidy_to_sarif") as operation:
+ self.assertEqual(
+ 0,
+ sarif.main(("convert-tidy", str(repository), str(logs), "tidy/id/")),
+ )
+ operation.assert_called_once_with(repository, logs, output / "renpy.sarif", "tidy/id/")
+
+ other = self.root / "other.sarif"
+ with mock.patch.object(sarif, "merge_sarif") as operation:
+ self.assertEqual(0, sarif.main(("merge", str(source), str(other))))
+ operation.assert_called_once_with([source, other], output / "analysis.sarif")
+
+ with mock.patch.object(sarif, "require_clean") as operation:
+ self.assertEqual(0, sarif.main(("gate", str(source))))
+ operation.assert_called_once_with((source,))
+ self.assertEqual([], list(output.iterdir()))
+
+ def test_cli_requires_existing_output_directory_and_confines_output_name(self) -> None:
+ source = self.root / "raw.sarif"
+ stderr = io.StringIO()
+ with mock.patch.dict("os.environ", {}, clear=True), contextlib.redirect_stderr(stderr):
+ with self.assertRaises(SystemExit):
+ sarif.main(("gate", str(source)))
+ self.assertIn("OBSERVER_OUT_DIR", stderr.getvalue())
+
+ missing = self.root / "missing"
+ stderr = io.StringIO()
+ with mock.patch.dict("os.environ", {"OBSERVER_OUT_DIR": str(missing)}, clear=True), contextlib.redirect_stderr(stderr):
+ with self.assertRaises(SystemExit):
+ sarif.main(("gate", str(source)))
+ self.assertIn("existing directory", stderr.getvalue())
+
+ output = self.root / "out"
+ output.mkdir()
+ stderr = io.StringIO()
+ with mock.patch.dict("os.environ", {"OBSERVER_OUT_DIR": str(output)}, clear=True), contextlib.redirect_stderr(stderr):
+ with self.assertRaises(SystemExit):
+ sarif.main(("normalize-msvc", str(source), "id", "--output-name", "../escape.sarif"))
+ self.assertIn("confined", stderr.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_sign.py b/build/tests/test_sign.py
new file mode 100644
index 0000000..00fcedc
--- /dev/null
+++ b/build/tests/test_sign.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+import re
+import sys
+import unittest
+from pathlib import Path
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.sign import content_uid # noqa: E402
+
+
+class ContentUidTests(unittest.TestCase):
+ def fields(self) -> dict[str, object]:
+ return {
+ "recipe": '{"script":"build"}\n',
+ "inputs": {
+ "src/archive.cpp": b"archive bytes\x00",
+ "src/archive.h": b"header bytes\n",
+ },
+ "dependencies": {
+ "vcpkg": "0123456789abcdef0123456789abcdef",
+ "headers": "fedcba9876543210fedcba9876543210",
+ },
+ "toolchain": {"msvc": "19.44", "sdk": "10.0.26100.0"},
+ "config": {"arch": "x64", "flags": ["/MT", "/W4"]},
+ }
+
+ def uid(self, **changes: object) -> str:
+ fields = self.fields()
+ fields.update(changes)
+ return content_uid(**fields) # type: ignore[arg-type]
+
+ def test_uid_is_lowercase_md5(self) -> None:
+ uid = self.uid()
+
+ self.assertRegex(uid, re.compile(r"^[0-9a-f]{32}$"))
+
+ def test_mapping_order_does_not_change_uid(self) -> None:
+ fields = self.fields()
+
+ self.assertEqual(
+ content_uid(**fields), # type: ignore[arg-type]
+ content_uid(
+ recipe=fields["recipe"], # type: ignore[arg-type]
+ inputs=dict(reversed(list(fields["inputs"].items()))), # type: ignore[union-attr]
+ dependencies=dict(
+ reversed(list(fields["dependencies"].items())) # type: ignore[union-attr]
+ ),
+ toolchain={"sdk": "10.0.26100.0", "msvc": "19.44"},
+ config={"flags": ["/MT", "/W4"], "arch": "x64"},
+ ),
+ )
+
+ def test_every_signed_field_changes_uid(self) -> None:
+ original = self.uid()
+ changes = [
+ {"recipe": '{"script":"test"}\n'},
+ {"inputs": {"src/other.cpp": b"archive bytes\x00", "src/archive.h": b"header bytes\n"}},
+ {"inputs": {"src/archive.cpp": b"changed\x00", "src/archive.h": b"header bytes\n"}},
+ {"dependencies": {"other": "0123456789abcdef0123456789abcdef", "headers": "fedcba9876543210fedcba9876543210"}},
+ {"dependencies": {"vcpkg": "11111111111111111111111111111111", "headers": "fedcba9876543210fedcba9876543210"}},
+ {"toolchain": {"compiler": "19.44", "sdk": "10.0.26100.0"}},
+ {"toolchain": {"msvc": "19.45", "sdk": "10.0.26100.0"}},
+ {"config": {"architecture": "x64", "flags": ["/MT", "/W4"]}},
+ {"config": {"arch": "ARM64", "flags": ["/MT", "/W4"]}},
+ {"config": {"arch": "x64", "flags": ["/W4", "/MT"]}},
+ ]
+
+ for change in changes:
+ with self.subTest(change=change):
+ self.assertNotEqual(original, self.uid(**change))
+
+ def test_binary_inputs_are_framed_without_concatenation_ambiguity(self) -> None:
+ common = {
+ "recipe": b"recipe",
+ "dependencies": {},
+ "toolchain": {},
+ "config": {},
+ }
+
+ self.assertNotEqual(
+ content_uid(inputs={"a": b"bc"}, **common),
+ content_uid(inputs={"ab": b"c"}, **common),
+ )
+
+ def test_recipe_text_is_signed_as_exact_utf8_bytes(self) -> None:
+ fields = self.fields()
+ text_uid = content_uid(**fields) # type: ignore[arg-type]
+ fields["recipe"] = str(fields["recipe"]).encode("utf-8")
+
+ self.assertEqual(text_uid, content_uid(**fields)) # type: ignore[arg-type]
+
+ def test_noncanonical_inputs_and_dependencies_are_rejected(self) -> None:
+ valid = self.fields()
+ invalid_fields = [
+ {"recipe": object()},
+ {"inputs": {1: b"bytes"}},
+ {"inputs": {"source": "text"}},
+ {"dependencies": {1: "0123456789abcdef0123456789abcdef"}},
+ {"dependencies": {"dep": 1}},
+ {"dependencies": {"dep": "not-an-md5"}},
+ ]
+
+ for change in invalid_fields:
+ fields = dict(valid)
+ fields.update(change)
+ with self.subTest(change=change), self.assertRaises((TypeError, ValueError)):
+ content_uid(**fields) # type: ignore[arg-type]
+
+ def test_identity_is_json_and_rejects_ambiguous_values(self) -> None:
+ valid = self.fields()
+ valid["toolchain"] = {
+ "enabled": True,
+ "generation": 1,
+ "ratio": 0.5,
+ "optional": None,
+ "tuple": ("a", "b"),
+ }
+ self.assertRegex(content_uid(**valid), re.compile(r"^[0-9a-f]{32}$")) # type: ignore[arg-type]
+
+ for toolchain in (
+ {1: "value"},
+ {"nested": {1: "ambiguous"}},
+ {"invalid": object()},
+ {"nan": float("nan")},
+ ):
+ fields = self.fields()
+ fields["toolchain"] = toolchain
+ with self.subTest(toolchain=toolchain), self.assertRaises((TypeError, ValueError)):
+ content_uid(**fields) # type: ignore[arg-type]
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_source_graph.py b/build/tests/test_source_graph.py
new file mode 100644
index 0000000..9778825
--- /dev/null
+++ b/build/tests/test_source_graph.py
@@ -0,0 +1,325 @@
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+import unittest
+from dataclasses import replace
+from pathlib import Path
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+REPOSITORY = BUILD_ROOT.parent
+sys.path.insert(0, str(BUILD_ROOT))
+
+from graphs.source import ( # noqa: E402
+ SourceTools,
+ _repository_files,
+ architecture_source_checks,
+ common_source_checks,
+ source_checks,
+)
+from core.paths import BuildPaths # noqa: E402
+
+
+class SourceGraphTests(unittest.TestCase):
+ def tools(self) -> SourceTools:
+ return SourceTools(
+ pwsh=Path(r"C:\tools\pwsh.exe"),
+ clang_format=Path(r"C:\tools\clang-format.exe"),
+ cppcheck=Path(r"C:\tools\cppcheck.exe"),
+ psscriptanalyzer=Path(r"C:\modules\PSScriptAnalyzer.psd1"),
+ vcpkg_root=Path(r"C:\tools\vcpkg"),
+ environment=(("PATH", r"C:\tools"),),
+ identity=(
+ ("clang_format", r"C:\tools\clang-format.exe"),
+ ("clang_format_version", "21.1"),
+ ("cppcheck", r"C:\tools\cppcheck.exe"),
+ ("cppcheck_version", "2.18"),
+ ("psscriptanalyzer", r"C:\modules\PSScriptAnalyzer.psd1"),
+ ("psscriptanalyzer_version", "1.24"),
+ ("pwsh", r"C:\tools\pwsh.exe"),
+ ("pwsh_version", "7.5"),
+ ("vcpkg_root", r"C:\tools\vcpkg"),
+ ),
+ )
+
+ def graph(self):
+ return source_checks(REPOSITORY, self.tools(), jobs=7)
+
+ def test_contract_inventory_excludes_transient_build_state(self) -> None:
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ source = repository / "src/file.cpp"
+ source.parent.mkdir()
+ source.touch()
+ for relative in (".coverage", "build/.coverage.agent"):
+ path = repository / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+
+ files = _repository_files(repository)
+
+ self.assertEqual(files, (source,))
+
+ def test_every_independent_source_check_is_a_demand_node(self) -> None:
+ graph = self.graph()
+ cpp_sources = sorted(
+ path
+ for path in (REPOSITORY / "src").rglob("*")
+ if path.is_file() and path.suffix in {".cpp", ".h", ".hpp"}
+ )
+ powershell_sources = [REPOSITORY / "build.ps1"] + sorted(
+ path
+ for path in (REPOSITORY / "build").rglob("*")
+ if (
+ path.is_file()
+ and path.suffix in {".ps1", ".psm1"}
+ and not path.is_relative_to(REPOSITORY / "build/templates")
+ )
+ )
+ contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1"))
+
+ self.assertEqual(
+ len(graph.nodes),
+ len(cpp_sources) + len(powershell_sources) + len(contracts) + 8,
+ )
+ self.assertEqual(graph.targets, ("source-checks",))
+ self.assertEqual(dict(graph.pools), {"restore": 1, "slot": 7})
+ self.assertEqual(
+ len([node for node in graph.nodes if node.name.startswith("format-")]),
+ len(cpp_sources),
+ )
+ self.assertEqual(
+ len([node for node in graph.nodes if node.name.startswith("pssa-")]),
+ len(powershell_sources),
+ )
+ self.assertFalse(any(node.name.startswith("pssa-build.templates.") for node in graph.nodes))
+ self.assertEqual(
+ len([node for node in graph.nodes if node.name.startswith("contract-")]),
+ len(contracts),
+ )
+ self.assertEqual(
+ {node.name for node in graph.nodes if node.name.startswith("cppcheck-")},
+ {"cppcheck-x86", "cppcheck-x64", "cppcheck-arm64"},
+ )
+ self.assertEqual(
+ {node.name for node in graph.nodes if node.name.startswith("restore-vcpkg-")},
+ {"restore-vcpkg-x86", "restore-vcpkg-x64", "restore-vcpkg-arm64"},
+ )
+ for architecture in ("x86", "x64", "arm64"):
+ self.assertEqual(
+ graph.node(f"cppcheck-{architecture}").inputs,
+ (f"restore-vcpkg-{architecture}",),
+ )
+ self.assertTrue(
+ all(
+ not node.inputs
+ for node in graph.nodes
+ if node.name.startswith(("format-", "pssa-", "contract-"))
+ )
+ )
+
+ merged = graph.node("merge-source-findings")
+ pssa_names = {
+ node.name for node in graph.nodes if node.name.startswith("pssa-")
+ }
+ self.assertEqual(
+ set(merged.inputs),
+ {"cppcheck-x86", "cppcheck-x64", "cppcheck-arm64"} | pssa_names,
+ )
+ gate = graph.node("source-checks")
+ direct = {
+ node.name
+ for node in graph.nodes
+ if node.name.startswith(("format-", "contract-"))
+ }
+ self.assertEqual(set(gate.inputs), direct | {"merge-source-findings"})
+
+ def test_cppcheck_matrix_contains_only_requested_supported_architectures(self) -> None:
+ graph = source_checks(REPOSITORY, self.tools(), jobs=7, architectures=("arm64", "x64"))
+
+ self.assertEqual(
+ {node.name for node in graph.nodes if node.name.startswith("cppcheck-")},
+ {"cppcheck-arm64", "cppcheck-x64"},
+ )
+ self.assertEqual(
+ {node.name for node in graph.nodes if node.name.startswith("restore-vcpkg-")},
+ {"restore-vcpkg-arm64", "restore-vcpkg-x64"},
+ )
+ self.assertNotIn("cppcheck-x86", graph.node("merge-source-findings").inputs)
+ for architectures in ((), ("mips",), ("x64", "x64")):
+ with self.subTest(architectures=architectures), self.assertRaisesRegex(ValueError, "architectures"):
+ source_checks(REPOSITORY, self.tools(), architectures=architectures)
+
+ def test_ci_slices_do_not_duplicate_common_and_architecture_checks(self) -> None:
+ common = common_source_checks(REPOSITORY, self.tools(), jobs=7)
+ architecture = architecture_source_checks(
+ REPOSITORY, self.tools(), ("arm64",), jobs=7
+ )
+
+ self.assertEqual(common.targets, ("source-checks",))
+ self.assertFalse(any(node.name.startswith("cppcheck-") for node in common.nodes))
+ self.assertFalse(any(node.name.startswith("restore-vcpkg-") for node in common.nodes))
+ self.assertTrue(any(node.name.startswith("format-") for node in common.nodes))
+ self.assertTrue(any(node.name.startswith("pssa-") for node in common.nodes))
+ self.assertTrue(any(node.name.startswith("contract-") for node in common.nodes))
+
+ self.assertEqual(architecture.targets, ("cppcheck-checks-arm64",))
+ self.assertEqual(
+ {node.name for node in architecture.nodes},
+ {
+ "restore-vcpkg-arm64",
+ "cppcheck-arm64",
+ "merge-cppcheck-findings-arm64",
+ "cppcheck-checks-arm64",
+ },
+ )
+ self.assertEqual(dict(architecture.pools), {"restore": 1, "slot": 7})
+ common_producer, common_result = common.result(
+ "reports/sarif/source/analysis.sarif"
+ )
+ self.assertEqual(common_producer.name, "merge-source-findings")
+ self.assertEqual(common_result.relative_path, "analysis.sarif")
+ cppcheck_producer, cppcheck_result = architecture.result(
+ "reports/sarif/arm64/cppcheck.sarif"
+ )
+ self.assertEqual(cppcheck_producer.name, "merge-cppcheck-findings-arm64")
+ self.assertEqual(cppcheck_result.relative_path, "analysis.sarif")
+
+ def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(self) -> None:
+ graph = self.graph()
+
+ formatted = graph.node("format-src.modules.renpy.pickle.cpp")
+ self.assertEqual(formatted.command.argv[0], r"C:\tools\pwsh.exe")
+ format_script = formatted.command.stdin.decode()
+ self.assertIn("Invoke-Checked 'C:\\tools\\clang-format.exe'", format_script)
+ self.assertIn("'--dry-run'", format_script)
+ self.assertIn("'--Werror'", format_script)
+ self.assertIn(str(REPOSITORY / "src/modules/renpy/pickle.cpp"), format_script)
+
+ cppcheck = graph.node("cppcheck-x86")
+ cppcheck_script = cppcheck.command.stdin.decode()
+ restore = graph.node("restore-vcpkg-x86")
+ include_dir = (
+ BuildPaths(REPOSITORY).cas(restore.uid).output
+ / "observer-x86-windows-static/include"
+ )
+ self.assertIn("Invoke-Checked 'C:\\tools\\cppcheck.exe'", cppcheck_script)
+ self.assertIn("'--platform=win32W'", cppcheck_script)
+ self.assertIn("'-D_M_IX86=600'", cppcheck_script)
+ self.assertIn(f"'-I{include_dir}'", cppcheck_script)
+ self.assertNotIn(f"--suppress=*:{include_dir}", cppcheck_script)
+ self.assertIn(
+ "'--suppress=*:*\\observer-x86-windows-static\\include\\*'",
+ cppcheck_script,
+ )
+ self.assertNotIn("*-restore-vcpkg-*", cppcheck_script)
+ self.assertIn('"--output-file=$outDir\\cppcheck.sarif"', cppcheck_script)
+ self.assertNotIn("--error-exitcode", cppcheck_script)
+ self.assertIn("Cppcheck did not produce cppcheck.sarif", cppcheck_script)
+ self.assertIn("'cppcheck/x86/' + \"$index/\"", cppcheck_script)
+
+ pssa = graph.node("pssa-build.ps1")
+ pssa_script = pssa.command.stdin.decode()
+ self.assertIn("Import-Module 'C:\\modules\\PSScriptAnalyzer.psd1'", pssa_script)
+ self.assertIn(str(REPOSITORY / "build.ps1"), pssa_script)
+ self.assertIn(str(REPOSITORY / "build/PSScriptAnalyzerSettings.psd1"), pssa_script)
+ self.assertIn('"$outDir\\psscriptanalyzer.sarif"', pssa_script)
+ self.assertNotIn("PSScriptAnalyzer reported", pssa_script)
+ self.assertIn("'psscriptanalyzer/build.ps1/'", pssa_script)
+
+ contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1"))
+ self.assertTrue(contracts)
+ for test in contracts:
+ relative = test.relative_to(REPOSITORY).as_posix()
+ contract = graph.node(f"contract-{relative.lower().replace('/', '.')}")
+ self.assertEqual(contract.command.argv[0], r"C:\tools\pwsh.exe")
+ self.assertIn(str(test), contract.command.stdin.decode())
+
+ merge = graph.node("merge-source-findings")
+ self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge"))
+ gate = graph.node("source-checks")
+ self.assertEqual(gate.command.argv[1:4], ("-m", "core.sarif", "gate"))
+
+ def test_tool_identity_changes_only_affected_source_check_branch(self) -> None:
+ before = self.graph()
+ identity = dict(self.tools().identity)
+ identity["cppcheck_version"] = "2.19"
+ after = source_checks(
+ REPOSITORY,
+ replace(self.tools(), identity=tuple(identity.items())),
+ jobs=7,
+ )
+
+ self.assertNotEqual(
+ before.node("cppcheck-x64").uid,
+ after.node("cppcheck-x64").uid,
+ )
+ for name in (
+ "format-src.modules.renpy.pickle.cpp",
+ "pssa-build.ps1",
+ "restore-vcpkg-x64",
+ ):
+ self.assertEqual(before.node(name).uid, after.node(name).uid)
+
+ identity = dict(self.tools().identity)
+ identity["vcpkg_root"] = r"C:\new-vcpkg"
+ moved_restore = source_checks(
+ REPOSITORY,
+ replace(
+ self.tools(),
+ vcpkg_root=Path(r"C:\new-vcpkg"),
+ identity=tuple(identity.items()),
+ ),
+ jobs=7,
+ )
+ self.assertNotEqual(
+ before.node("restore-vcpkg-x64").uid,
+ moved_restore.node("restore-vcpkg-x64").uid,
+ )
+ self.assertNotEqual(
+ before.node("cppcheck-x64").uid,
+ moved_restore.node("cppcheck-x64").uid,
+ )
+ for name in (
+ "format-src.modules.renpy.pickle.cpp",
+ "pssa-build.ps1",
+ ):
+ self.assertEqual(before.node(name).uid, moved_restore.node(name).uid)
+
+ def test_rendered_powershell_is_parseable(self) -> None:
+ graph = self.graph()
+ scripts = "\n".join(
+ node.command.stdin.decode()
+ for node in graph.nodes
+ if node.command.argv[0] == r"C:\tools\pwsh.exe"
+ )
+ parser = """
+$tokens = $null
+$errors = $null
+[System.Management.Automation.Language.Parser]::ParseInput(
+ [Console]::In.ReadToEnd(), [ref]$tokens, [ref]$errors) | Out-Null
+if ($errors.Count -ne 0) { $errors | Out-String | Write-Error; exit 1 }
+"""
+ result = subprocess.run(
+ [
+ shutil.which("pwsh"),
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ parser,
+ ],
+ input=scripts,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_source_tools.py b/build/tests/test_source_tools.py
new file mode 100644
index 0000000..1a93b56
--- /dev/null
+++ b/build/tests/test_source_tools.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from dataclasses import dataclass
+from pathlib import Path
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.source_tools import discover_source_tools # noqa: E402
+
+
+def executable(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ return path
+
+
+@dataclass(frozen=True)
+class FakeToolchain:
+ llvm_dir: Path
+ pwsh: Path
+ vcpkg_root: Path
+ environment: tuple[tuple[str, str], ...]
+
+
+class SourceToolDiscoveryTests(unittest.TestCase):
+ def test_discovers_exact_paths_versions_and_preserves_runtime_environment(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ clang_format = executable(root / "llvm/bin/clang-format.exe")
+ pwsh = executable(root / "PowerShell/pwsh.exe")
+ cppcheck = executable(root / "Cppcheck/cppcheck.exe")
+ pssa = executable(root / "Modules/PSScriptAnalyzer/PSScriptAnalyzer.psd1")
+ vcpkg_root = root / "vcpkg"
+ vcpkg_root.mkdir()
+ toolchain = FakeToolchain(
+ root / "llvm",
+ pwsh,
+ vcpkg_root,
+ (("LIB", "sdk"),),
+ )
+ responses = (
+ "PowerShell 7.5.2",
+ "clang-format version 21.1.0",
+ "Cppcheck 2.18.0",
+ json.dumps({"Path": str(pssa), "Version": "1.24.0"}),
+ )
+
+ with (
+ mock.patch("core.source_tools.shutil.which", return_value=str(cppcheck)) as which,
+ mock.patch("core.source_tools._output", side_effect=responses) as output,
+ ):
+ tools = discover_source_tools(toolchain)
+
+ self.assertEqual(tools.pwsh, pwsh.resolve())
+ self.assertEqual(tools.clang_format, clang_format.resolve())
+ self.assertEqual(tools.cppcheck, cppcheck.resolve())
+ self.assertEqual(tools.psscriptanalyzer, pssa.resolve())
+ self.assertEqual(tools.vcpkg_root, vcpkg_root.resolve())
+ self.assertEqual(tools.environment, (("LIB", "sdk"),))
+ self.assertEqual(
+ dict(tools.identity),
+ {
+ "clang_format": str(clang_format.resolve()),
+ "clang_format_version": "clang-format version 21.1.0",
+ "cppcheck": str(cppcheck.resolve()),
+ "cppcheck_version": "Cppcheck 2.18.0",
+ "psscriptanalyzer": str(pssa.resolve()),
+ "psscriptanalyzer_version": "1.24.0",
+ "pwsh": str(pwsh.resolve()),
+ "pwsh_version": "PowerShell 7.5.2",
+ "vcpkg_root": str(vcpkg_root.resolve()),
+ },
+ )
+ which.assert_called_once_with("cppcheck.exe")
+ self.assertEqual(output.call_count, 4)
+ self.assertEqual(output.call_args_list[0].args[0], [str(pwsh.resolve()), "--version"])
+ self.assertEqual(
+ output.call_args_list[1].args[0],
+ [str(clang_format.resolve()), "--version"],
+ )
+ self.assertEqual(output.call_args_list[2].args[0], [str(cppcheck.resolve()), "--version"])
+ self.assertEqual(output.call_args_list[3].args[0][0], str(pwsh.resolve()))
+ self.assertIn("Get-Module -ListAvailable PSScriptAnalyzer", output.call_args_list[3].args[0][-1])
+
+ def test_missing_cppcheck_is_reported_without_running_commands(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ toolchain = FakeToolchain(
+ root / "llvm",
+ executable(root / "pwsh.exe"),
+ root / "vcpkg",
+ (),
+ )
+ executable(root / "llvm/bin/clang-format.exe")
+ (root / "vcpkg").mkdir()
+ with (
+ mock.patch("core.source_tools.shutil.which", return_value=None),
+ mock.patch("core.source_tools._output") as output,
+ self.assertRaisesRegex(FileNotFoundError, "cppcheck.exe"),
+ ):
+ discover_source_tools(toolchain)
+
+ output.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_store.py b/build/tests/test_store.py
new file mode 100644
index 0000000..033b592
--- /dev/null
+++ b/build/tests/test_store.py
@@ -0,0 +1,299 @@
+from __future__ import annotations
+
+import hashlib
+import os
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.graph import Command, Node # noqa: E402
+from core.paths import BuildPaths, PathSafetyError # noqa: E402
+from core.store import CasStateError, CasStore # noqa: E402
+
+
+RUN_ID = "20260801-test"
+
+
+def node(name: str = "analyze-renpy.pickle", uid: str | None = None) -> Node:
+ return Node(
+ name=name,
+ uid=uid or hashlib.md5(name.encode("utf-8"), usedforsecurity=False).hexdigest(),
+ pool="cpu",
+ command=Command(("tool",)),
+ )
+
+
+class CasStoreTests(unittest.TestCase):
+ def make_store(self, repository: Path) -> tuple[BuildPaths, CasStore]:
+ paths = BuildPaths(repository)
+ paths.prepare()
+ return paths, CasStore(paths, RUN_ID)
+
+ @staticmethod
+ def publish_files(paths: BuildPaths, current: Node) -> None:
+ cas = paths.cas(current.uid)
+ cas.entry.mkdir()
+ cas.output.mkdir()
+ cas.log.write_text("command succeeded\n", encoding="utf-8")
+ cas.touch.touch()
+
+ def test_node_maps_to_uid_only_entry(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+
+ cas = store.paths_for(current)
+
+ self.assertEqual(
+ cas.entry,
+ paths.cas_root / current.uid,
+ )
+
+ def test_readable_name_collision_shares_content_addressed_entry(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ _paths, store = self.make_store(repository)
+ shared_uid = "0123456789abcdef0123456789abcdef"
+
+ first = store.paths_for(node("first-node", shared_uid))
+ second = store.paths_for(node("second-node", shared_uid))
+
+ self.assertEqual(first, second)
+ self.assertEqual(first.entry.name, shared_uid)
+
+ def test_complete_entry_is_a_warm_cache_hit(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ self.publish_files(paths, current)
+
+ self.assertTrue(store.is_complete(current))
+
+ def test_missing_or_malformed_marker_and_missing_outputs_are_cache_misses(self) -> None:
+ cases = (
+ "missing-touch",
+ "nonempty-touch",
+ "touch-directory",
+ "missing-log",
+ "log-directory",
+ "missing-output",
+ "output-file",
+ )
+ for case in cases:
+ with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ self.publish_files(paths, current)
+ cas = paths.cas(current.uid)
+
+ if case == "missing-touch":
+ cas.touch.unlink()
+ elif case == "nonempty-touch":
+ cas.touch.write_bytes(b"not a completion marker")
+ elif case == "touch-directory":
+ cas.touch.unlink()
+ cas.touch.mkdir()
+ elif case == "missing-log":
+ cas.log.unlink()
+ elif case == "log-directory":
+ cas.log.unlink()
+ cas.log.mkdir()
+ elif case == "missing-output":
+ cas.output.rmdir()
+ elif case == "output-file":
+ cas.output.rmdir()
+ cas.output.touch()
+
+ self.assertFalse(store.is_complete(current))
+
+ def test_prepare_quarantines_incomplete_entry_inside_current_run(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ old = paths.cas(current.uid)
+ old.entry.mkdir()
+ old.output.mkdir()
+ (old.output / "partial.obj").write_bytes(b"partial")
+
+ prepared = store.prepare_entry(current)
+
+ quarantine = paths.run_work(RUN_ID) / "quarantine" / old.entry.name
+ self.assertEqual(prepared, paths.cas(current.uid))
+ self.assertEqual((quarantine / "out" / "partial.obj").read_bytes(), b"partial")
+ self.assertTrue(prepared.entry.is_dir())
+ self.assertTrue(prepared.output.is_dir())
+ self.assertTrue(prepared.log.is_file())
+ self.assertFalse(prepared.touch.exists())
+
+ def test_prepare_resolves_node_paths_once(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+
+ with mock.patch.object(paths, "cas", wraps=paths.cas) as resolve:
+ store.prepare_entry(current)
+
+ resolve.assert_called_once_with(current.uid)
+
+ def test_prepare_never_mutates_complete_entry(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ self.publish_files(paths, current)
+ cas = paths.cas(current.uid)
+ sentinel = cas.output / "result.bin"
+ sentinel.write_bytes(b"immutable")
+ before = {
+ child.relative_to(cas.entry): (child.stat().st_mtime_ns, child.read_bytes())
+ for child in (cas.log, cas.touch, sentinel)
+ }
+
+ prepared = store.prepare_entry(current)
+
+ after = {
+ child.relative_to(cas.entry): (child.stat().st_mtime_ns, child.read_bytes())
+ for child in (cas.log, cas.touch, sentinel)
+ }
+ self.assertEqual(prepared, cas)
+ self.assertEqual(after, before)
+ self.assertFalse((paths.run_work(RUN_ID) / "quarantine").exists())
+
+ def test_prepare_fails_if_incomplete_entry_cannot_be_quarantined(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ cas = paths.cas(current.uid)
+ cas.entry.mkdir()
+ cas.output.mkdir()
+ destination = paths.run_work(RUN_ID) / "quarantine" / cas.entry.name
+ destination.mkdir(parents=True)
+
+ with self.assertRaisesRegex(CasStateError, "quarantine destination already exists"):
+ store.prepare_entry(current)
+
+ self.assertTrue(cas.entry.is_dir())
+ self.assertTrue(destination.is_dir())
+
+ def test_prepare_reports_move_failure_without_deleting_or_retrying(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ cas = paths.cas(current.uid)
+ cas.entry.mkdir()
+ cas.output.mkdir()
+
+ with mock.patch.object(Path, "rename", side_effect=OSError("locked")) as rename:
+ with self.assertRaisesRegex(CasStateError, "could not quarantine"):
+ store.prepare_entry(current)
+
+ rename.assert_called_once()
+ self.assertTrue(cas.entry.is_dir())
+ self.assertFalse(
+ (paths.run_work(RUN_ID) / "quarantine" / cas.entry.name).exists()
+ )
+
+ def test_reparse_component_is_rejected_instead_of_followed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ regular = BuildPaths(repository)
+ regular.prepare()
+ current = node()
+ self.publish_files(regular, current)
+ cas = regular.cas(current.uid)
+
+ reparse_paths = {cas.output}
+
+ with mock.patch(
+ "core.paths._is_reparse",
+ side_effect=lambda path: path in reparse_paths,
+ ):
+ store = CasStore(BuildPaths(repository), RUN_ID)
+ with self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ store.is_complete(current)
+ with self.assertRaisesRegex(PathSafetyError, "reparse point"):
+ store.prepare_entry(current)
+
+ def test_mark_complete_publishes_zero_byte_marker_after_outputs_exist(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ cas = store.prepare_entry(current)
+ cas.log.write_text("success\n", encoding="utf-8")
+
+ store.mark_complete(current)
+
+ self.assertTrue(cas.touch.is_file())
+ self.assertEqual(cas.touch.stat().st_size, 0)
+ self.assertTrue(store.is_complete(current))
+
+ def test_mark_complete_requires_output_and_log_and_never_overwrites_marker(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ paths, store = self.make_store(repository)
+ current = node()
+ cas = store.prepare_entry(current)
+ cas.log.unlink()
+
+ with self.assertRaisesRegex(CasStateError, "log file"):
+ store.mark_complete(current)
+ self.assertFalse(cas.touch.exists())
+
+ cas.log.write_text("success\n", encoding="utf-8")
+ cas.touch.write_bytes(b"existing")
+ original_stat = os.lstat(cas.touch)
+
+ with self.assertRaisesRegex(CasStateError, "completion marker already exists"):
+ store.mark_complete(current)
+
+ self.assertEqual(cas.touch.read_bytes(), b"existing")
+ self.assertEqual(os.lstat(cas.touch).st_mtime_ns, original_stat.st_mtime_ns)
+
+ def test_mark_complete_reports_exclusive_publication_race(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary) / "repo"
+ repository.mkdir()
+ _paths, store = self.make_store(repository)
+ current = node()
+ cas = store.prepare_entry(current)
+
+ with mock.patch.object(
+ Path,
+ "open",
+ autospec=True,
+ side_effect=FileExistsError("raced"),
+ ):
+ with self.assertRaisesRegex(CasStateError, "marker already exists"):
+ store.mark_complete(current)
+
+ self.assertFalse(cas.touch.exists())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_toolchain.py b/build/tests/test_toolchain.py
new file mode 100644
index 0000000..b29f31c
--- /dev/null
+++ b/build/tests/test_toolchain.py
@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.toolchain import _command_environment, discover_msvc_toolchain # noqa: E402
+
+
+def executable(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ return path
+
+
+class MsvcToolchainTests(unittest.TestCase):
+ def test_command_environment_is_stable_when_canonical_values_are_inherited(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ include = root / "include"
+ lib = root / "lib"
+ libpath = root / "references"
+ for directory in (include, lib, libpath):
+ directory.mkdir()
+
+ canonical = {
+ "INCLUDE": str(include),
+ "LIB": str(lib),
+ "LIBPATH": str(libpath),
+ "VCToolsVersion": "14.44.35207",
+ "VSCMD_VER": "17.14.15",
+ "WindowsSDKVersion": "10.0.26100.0\\",
+ }
+ captured = "\r\n".join(
+ (
+ *(f"{key}={value}" for key, value in canonical.items()),
+ "Path=toolchain;host",
+ "__VSCMD_PREINIT_PATH=host",
+ "GITHUB_SHA=unchanged",
+ "UNRELATED=unchanged",
+ )
+ )
+ inherited = {"GITHUB_SHA": "unchanged", "UNRELATED": "unchanged"}
+
+ with mock.patch.dict(os.environ, inherited, clear=True):
+ absent = _command_environment(captured)
+ with mock.patch.dict(os.environ, inherited | canonical, clear=True):
+ already_equal = _command_environment(captured)
+
+ self.assertEqual(dict(absent), canonical)
+ self.assertEqual(already_equal, absent)
+
+ def test_discovers_x64_tools_and_canonical_command_environment(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ program_files = root / "Program Files (x86)"
+ system_root = root / "Windows"
+ installation = root / "Visual Studio"
+ vcpkg_root = root / "vcpkg" / "2026.07.29"
+ lib = root / "SDK" / "Lib"
+ lib.mkdir(parents=True)
+ vcpkg_root.mkdir(parents=True)
+
+ vswhere = executable(
+ program_files
+ / "Microsoft Visual Studio"
+ / "Installer"
+ / "vswhere.exe"
+ )
+ cmd = executable(system_root / "System32" / "cmd.exe")
+ msbuild = executable(
+ installation / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe"
+ )
+ vsdevcmd = executable(installation / "Common7" / "Tools" / "VsDevCmd.bat")
+ llvm_dir = installation / "VC" / "Tools" / "Llvm" / "x64"
+ clang_tidy = executable(llvm_dir / "bin" / "clang-tidy.exe")
+ pwsh = executable(root / "PowerShell" / "pwsh.exe")
+
+ original = {
+ "ProgramFiles(x86)": str(program_files),
+ "SystemRoot": str(system_root),
+ "VCPKG_ROOT": str(vcpkg_root),
+ "UNCHANGED": "host",
+ "Path": "host",
+ }
+ command_environment = "\r\n".join(
+ (
+ "=C:=C:\\working",
+ "Path=first;host",
+ "PATH=host;second",
+ "__VSCMD_PREINIT_PATH=host",
+ f"Lib={lib};{root / 'missing'}",
+ "VisualStudioVersion=17.0",
+ "VSCMD_VER=17.14.15",
+ "VCToolsVersion=14.44.35207",
+ "WindowsSDKVersion=10.0.26100.0\\",
+ "UNCHANGED=host",
+ "",
+ )
+ )
+ responses = (
+ subprocess.CompletedProcess([], 0, stdout=f"{installation}\r\n", stderr=""),
+ subprocess.CompletedProcess([], 0, stdout=command_environment, stderr=""),
+ subprocess.CompletedProcess([], 0, stdout="17.14.51.32402\r\n", stderr=""),
+ subprocess.CompletedProcess(
+ [],
+ 0,
+ stdout="LLVM tools\r\n LLVM version 19.1.5\r\nOptimized build.\r\n",
+ stderr="",
+ ),
+ )
+
+ with (
+ mock.patch.dict(os.environ, original, clear=True),
+ mock.patch("core.toolchain.shutil.which", return_value=str(pwsh)) as which,
+ mock.patch("core.toolchain.subprocess.run", side_effect=responses) as run,
+ ):
+ host_before = dict(os.environ)
+ toolchain = discover_msvc_toolchain()
+ host_after = dict(os.environ)
+
+ self.assertEqual(toolchain.installation, installation.resolve())
+ self.assertEqual(toolchain.msbuild, msbuild.resolve())
+ self.assertEqual(toolchain.vsdevcmd, vsdevcmd.resolve())
+ self.assertEqual(toolchain.llvm_dir, llvm_dir.resolve())
+ self.assertEqual(toolchain.clang_tidy, clang_tidy.resolve())
+ self.assertEqual(toolchain.vcpkg_root, vcpkg_root.resolve())
+ self.assertEqual(toolchain.pwsh, pwsh.resolve())
+ self.assertEqual(
+ dict(toolchain.environment),
+ {
+ "LIB": str(lib),
+ "VCToolsVersion": "14.44.35207",
+ "VisualStudioVersion": "17.0",
+ "VSCMD_VER": "17.14.15",
+ "WindowsSDKVersion": "10.0.26100.0\\",
+ },
+ )
+ self.assertEqual(
+ dict(toolchain.identity),
+ {
+ "clang_tidy": str(clang_tidy.resolve()),
+ "clang_tidy_version": "19.1.5",
+ "installation": str(installation.resolve()),
+ "msbuild": str(msbuild.resolve()),
+ "msbuild_version": "17.14.51.32402",
+ "pwsh": str(pwsh.resolve()),
+ "vc_tools_version": "14.44.35207",
+ "vcpkg_root": str(vcpkg_root.resolve()),
+ "vsdevcmd": str(vsdevcmd.resolve()),
+ "vsdevcmd_version": "17.14.15",
+ "windows_sdk_version": "10.0.26100.0\\",
+ },
+ )
+ self.assertEqual(host_after, host_before)
+ self.assertEqual(which.call_args_list, [mock.call("pwsh")])
+ self.assertEqual(
+ run.call_args_list[0].args[0],
+ [
+ str(vswhere.resolve()),
+ "-latest",
+ "-products",
+ "*",
+ "-requires",
+ "Microsoft.Component.MSBuild",
+ "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "Microsoft.VisualStudio.Component.VC.Llvm.Clang",
+ "-property",
+ "installationPath",
+ ],
+ )
+ payload = (
+ f'call "{vsdevcmd.resolve()}" -no_logo -arch=amd64 '
+ "-host_arch=amd64 >nul && set"
+ )
+ self.assertEqual(
+ run.call_args_list[1].args[0],
+ f'"{cmd.resolve()}" /d /s /c "{payload}"',
+ )
+ self.assertEqual(
+ run.call_args_list[0].kwargs,
+ {"check": True, "capture_output": True, "text": True},
+ )
+ self.assertEqual(
+ run.call_args_list[1].kwargs,
+ {
+ "check": True,
+ "capture_output": True,
+ "executable": str(cmd.resolve()),
+ "text": True,
+ },
+ )
+ self.assertEqual(run.call_args_list[2].args[0], [str(msbuild.resolve()), "-version", "-nologo"])
+ self.assertEqual(run.call_args_list[3].args[0], [str(clang_tidy.resolve()), "--version"])
+ for call in (run.call_args_list[2], run.call_args_list[3]):
+ self.assertEqual(
+ call.kwargs,
+ {"check": True, "capture_output": True, "text": True},
+ )
+
+ def test_falls_back_to_bin_msbuild_and_vcpkg_on_path(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ program_files = root / "Program Files (x86)"
+ system_root = root / "Windows"
+ installation = root / "Visual Studio"
+ executable(
+ program_files
+ / "Microsoft Visual Studio"
+ / "Installer"
+ / "vswhere.exe"
+ )
+ executable(system_root / "System32" / "cmd.exe")
+ msbuild = executable(
+ installation / "MSBuild" / "Current" / "Bin" / "MSBuild.exe"
+ )
+ executable(installation / "Common7" / "Tools" / "VsDevCmd.bat")
+ executable(installation / "VC" / "Tools" / "Llvm" / "x64" / "bin" / "clang-tidy.exe")
+ pwsh = executable(root / "PowerShell" / "pwsh.exe")
+ vcpkg = executable(root / "vcpkg" / "2026.07.29" / "vcpkg.exe")
+ responses = (
+ subprocess.CompletedProcess([], 0, stdout=str(installation), stderr=""),
+ subprocess.CompletedProcess([], 0, stdout="PATH=tools\r\n", stderr=""),
+ subprocess.CompletedProcess([], 0, stdout="17.14.51.32402\r\n", stderr=""),
+ subprocess.CompletedProcess([], 0, stdout="LLVM version 19.1.5\r\n", stderr=""),
+ )
+
+ def which(name: str) -> str:
+ return str({"pwsh": pwsh, "vcpkg": vcpkg}[name])
+
+ with (
+ mock.patch.dict(
+ os.environ,
+ {
+ "ProgramFiles(x86)": str(program_files),
+ "SystemRoot": str(system_root),
+ },
+ clear=True,
+ ),
+ mock.patch("core.toolchain.shutil.which", side_effect=which) as find,
+ mock.patch("core.toolchain.subprocess.run", side_effect=responses),
+ ):
+ toolchain = discover_msvc_toolchain()
+
+ self.assertEqual(toolchain.msbuild, msbuild.resolve())
+ self.assertEqual(toolchain.vcpkg_root, vcpkg.resolve().parent)
+ self.assertEqual(find.call_args_list, [mock.call("vcpkg"), mock.call("pwsh")])
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_vcpkg_template.py b/build/tests/test_vcpkg_template.py
new file mode 100644
index 0000000..ab11f16
--- /dev/null
+++ b/build/tests/test_vcpkg_template.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import json
+import sys
+import unittest
+from pathlib import Path
+
+from jinja2 import UndefinedError
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.render import TemplateRenderer # noqa: E402
+
+
+class VcpkgTemplateTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.renderer = TemplateRenderer(BUILD_ROOT / "templates")
+ self.variables = {
+ "name": "restore-vcpkg-x64",
+ "pool": "slot",
+ "inputs": [],
+ "results": [],
+ "pwsh": "pwsh.exe",
+ "vcpkg": r"C:\tools\vcpkg.exe",
+ "repository": r"C:\repo with O'Brien",
+ "triplet": "observer-x64-windows-static",
+ }
+
+ def test_manifest_restore_targets_node_output_and_requires_include_directory(self) -> None:
+ recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables))
+ script = recipe["script"]["data"]
+
+ self.assertIn("Invoke-Checked 'C:\\tools\\vcpkg.exe' @(", script)
+ self.assertIn("\n 'install'\n", script)
+ self.assertIn('\n "--x-install-root=$outDir"\n', script)
+ self.assertIn("\n '--triplet'\n 'observer-x64-windows-static'\n", script)
+ self.assertIn("'--x-manifest-root=C:\\repo with O''Brien'", script)
+ self.assertIn("'--overlay-triplets=C:\\repo with O''Brien\\build\\vcpkg\\triplets'", script)
+ self.assertIn(
+ "Test-Path -LiteralPath (Join-Path $outDir "
+ "'observer-x64-windows-static\\include') -PathType Container",
+ script,
+ )
+ self.assertIn("throw 'vcpkg restore did not produce the include directory'", script)
+
+ def test_build_scratch_is_confined_while_vcpkg_manages_shared_downloads(self) -> None:
+ recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables))
+ script = recipe["script"]["data"]
+
+ self.assertIn('\n "--x-buildtrees-root=$buildDir\\b"\n', script)
+ self.assertIn('\n "--x-packages-root=$buildDir\\p"\n', script)
+ self.assertNotIn("--downloads-root", script)
+ self.assertIn('\n "--x-install-root=$outDir"\n', script)
+
+ def test_triplet_is_required(self) -> None:
+ del self.variables["triplet"]
+
+ with self.assertRaisesRegex(UndefinedError, "triplet.*undefined"):
+ self.renderer.render("vcpkg.ps1", self.variables)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_windows_job.py b/build/tests/test_windows_job.py
new file mode 100644
index 0000000..1c1306a
--- /dev/null
+++ b/build/tests/test_windows_job.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import os
+import unittest
+from unittest.mock import patch
+
+import win32job
+
+from core.windows_job import WindowsJob
+
+
+class FakeHandle:
+ def __init__(
+ self,
+ events: list[tuple[object, ...]],
+ *,
+ close_error: BaseException | None = None,
+ ) -> None:
+ self._events = events
+ self._close_error = close_error
+
+ def Close(self) -> None:
+ self._events.append(("close",))
+ if self._close_error is not None:
+ raise self._close_error
+
+
+class FakeWin32Job:
+ JobObjectExtendedLimitInformation = (
+ win32job.JobObjectExtendedLimitInformation
+ )
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = (
+ win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ )
+
+ def __init__(
+ self,
+ *,
+ configure_error: BaseException | None = None,
+ close_error: BaseException | None = None,
+ ) -> None:
+ self.events: list[tuple[object, ...]] = []
+ self.handle = FakeHandle(self.events, close_error=close_error)
+ self.configure_error = configure_error
+ self.information = {
+ "BasicLimitInformation": {"LimitFlags": 0x40},
+ "IoInfo": {},
+ }
+
+ def CreateJobObject(self, attributes: object, name: str) -> FakeHandle:
+ self.events.append(("create", attributes, name))
+ return self.handle
+
+ def QueryInformationJobObject(
+ self, handle: FakeHandle, information_class: int
+ ) -> dict[str, object]:
+ self.events.append(("query", handle, information_class))
+ return self.information
+
+ def SetInformationJobObject(
+ self,
+ handle: FakeHandle,
+ information_class: int,
+ information: dict[str, object],
+ ) -> None:
+ self.events.append(("set", handle, information_class, information))
+ if self.configure_error is not None:
+ raise self.configure_error
+
+ def AssignProcessToJobObject(
+ self, handle: FakeHandle, process_handle: int
+ ) -> None:
+ self.events.append(("assign", handle, process_handle))
+
+ def TerminateJobObject(self, handle: FakeHandle, exit_code: int) -> None:
+ self.events.append(("terminate", handle, exit_code))
+
+
+class WindowsJobTests(unittest.TestCase):
+ def job(self, api: FakeWin32Job) -> WindowsJob:
+ with patch("core.windows_job.win32job", api):
+ return WindowsJob()
+
+ def test_existing_limits_are_preserved_and_kill_on_close_precedes_assign(
+ self,
+ ) -> None:
+ api = FakeWin32Job()
+
+ with patch("core.windows_job.win32job", api):
+ job = WindowsJob()
+ job.assign_process(202)
+ job.close()
+
+ self.assertEqual(
+ [event[0] for event in api.events],
+ ["create", "query", "set", "assign", "close"],
+ )
+ self.assertEqual(api.events[0], ("create", None, ""))
+ self.assertEqual(
+ api.information["BasicLimitInformation"][ # type: ignore[index]
+ "LimitFlags"
+ ],
+ 0x40 | win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+ )
+
+ def test_close_is_idempotent(self) -> None:
+ api = FakeWin32Job()
+ job = self.job(api)
+
+ with patch("core.windows_job.win32job", api):
+ job.close()
+ job.close()
+
+ self.assertEqual(api.events.count(("close",)), 1)
+
+ def test_configuration_failure_closes_handle_and_propagates(self) -> None:
+ api = FakeWin32Job(configure_error=OSError("configuration failed"))
+
+ with patch("core.windows_job.win32job", api):
+ with self.assertRaisesRegex(OSError, "configuration failed"):
+ WindowsJob()
+
+ self.assertEqual(
+ [event[0] for event in api.events],
+ ["create", "query", "set", "close"],
+ )
+
+ def test_configuration_failure_preserves_close_failure_as_note(self) -> None:
+ api = FakeWin32Job(
+ configure_error=OSError("configuration failed"),
+ close_error=OSError("close failed"),
+ )
+
+ with patch("core.windows_job.win32job", api):
+ with self.assertRaisesRegex(OSError, "configuration failed") as raised:
+ WindowsJob()
+
+ self.assertTrue(
+ any("close failed" in note for note in raised.exception.__notes__)
+ )
+
+ def test_close_failure_keeps_handle_available_for_tree_termination(self) -> None:
+ api = FakeWin32Job(close_error=OSError("close failed"))
+ job = self.job(api)
+
+ with self.assertRaisesRegex(OSError, "close failed"):
+ job.close()
+ with patch("core.windows_job.win32job", api):
+ job.terminate()
+
+ self.assertEqual(api.events.count(("close",)), 1)
+ self.assertEqual(api.events[-1], ("terminate", api.handle, 1))
+
+
+@unittest.skipUnless(os.name == "nt", "requires Windows Job Objects")
+class WindowsJobIntegrationTests(unittest.TestCase):
+ def test_real_job_can_be_configured_and_closed(self) -> None:
+ job = WindowsJob()
+ job.close()
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_windows_process.py b/build/tests/test_windows_process.py
new file mode 100644
index 0000000..6d9adb2
--- /dev/null
+++ b/build/tests/test_windows_process.py
@@ -0,0 +1,400 @@
+from __future__ import annotations
+
+import asyncio
+import io
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from unittest.mock import patch
+
+import psutil
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+EXE = str(Path(sys.executable).resolve())
+
+from core.graph import Command # noqa: E402
+from core.windows_process import WindowsProcessRunner # noqa: E402
+
+
+class FakeProcess:
+ def __init__(
+ self,
+ events: list[tuple[object, ...]],
+ *,
+ resume_error: BaseException | None = None,
+ communicate_error: BaseException | None = None,
+ kill_error: BaseException | None = None,
+ exit_code: int = 0,
+ ) -> None:
+ self._handle = 301
+ self.returncode: int | None = None
+ self._events = events
+ self._resume_error = resume_error
+ self._communicate_error = communicate_error
+ self._kill_error = kill_error
+ self._exit_code = exit_code
+ self.communicate_started = threading.Event()
+ self.communicate_release = threading.Event()
+ self.communicate_release.set()
+
+ def resume(self) -> None:
+ self._events.append(("resume",))
+ if self._resume_error is not None:
+ raise self._resume_error
+
+ def communicate(self, *, input: bytes) -> tuple[None, None]:
+ self._events.append(("communicate-start", input))
+ self.communicate_started.set()
+ self.communicate_release.wait(timeout=5)
+ self._events.append(("communicate-done",))
+ if self._communicate_error is not None:
+ raise self._communicate_error
+ self.returncode = self._exit_code
+ return None, None
+
+ def kill(self) -> None:
+ self._events.append(("kill",))
+ self.communicate_release.set()
+ if self._kill_error is not None:
+ raise self._kill_error
+
+ def wait(self) -> int:
+ self._events.append(("wait",))
+ self.communicate_release.wait(timeout=5)
+ self.returncode = self._exit_code
+ return self._exit_code
+
+
+class FakePopenFactory:
+ def __init__(
+ self,
+ process: FakeProcess,
+ *,
+ create_error: BaseException | None = None,
+ ) -> None:
+ self._process = process
+ self._create_error = create_error
+ self.calls: list[tuple[tuple[str, ...], dict[str, object]]] = []
+
+ def __call__(self, argv: list[str], **kwargs: object) -> FakeProcess:
+ self._process._events.append(("popen",))
+ self.calls.append((tuple(argv), kwargs))
+ if self._create_error is not None:
+ raise self._create_error
+ return self._process
+
+
+class FakeJob:
+ def __init__(
+ self,
+ process: FakeProcess,
+ *,
+ assign_error: BaseException | None = None,
+ close_error: BaseException | None = None,
+ terminate_error: BaseException | None = None,
+ ) -> None:
+ self._process = process
+ self._assign_error = assign_error
+ self._close_error = close_error
+ self._terminate_error = terminate_error
+ self.closed = False
+ process._events.append(("create-job",))
+
+ def assign_process(self, process_handle: int) -> None:
+ self._process._events.append(("assign", process_handle))
+ if self._assign_error is not None:
+ raise self._assign_error
+
+ def close(self) -> None:
+ if self.closed:
+ return
+ self.closed = True
+ self._process._events.append(("close-job",))
+ if self._close_error is not None:
+ raise self._close_error
+ self._process.communicate_release.set()
+
+ def terminate(self) -> None:
+ self._process._events.append(("terminate-job",))
+ self._process.communicate_release.set()
+ if self._terminate_error is not None:
+ raise self._terminate_error
+
+
+class WindowsProcessRunnerTests(unittest.IsolatedAsyncioTestCase):
+ def patch_runtime(
+ self,
+ process: FakeProcess,
+ *,
+ create_error: BaseException | None = None,
+ assign_error: BaseException | None = None,
+ close_error: BaseException | None = None,
+ terminate_error: BaseException | None = None,
+ ) -> FakePopenFactory:
+ popen = FakePopenFactory(process, create_error=create_error)
+ self.enterContext(patch("core.windows_process.psutil.Popen", popen))
+ self.enterContext(
+ patch(
+ "core.windows_process.WindowsJob",
+ new=lambda: FakeJob(
+ process,
+ assign_error=assign_error,
+ close_error=close_error,
+ terminate_error=terminate_error,
+ ),
+ )
+ )
+ return popen
+
+ async def test_literal_argv_is_assigned_before_public_resume(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events, exit_code=23)
+ popen = self.patch_runtime(process)
+ log = io.BytesIO()
+ command = Command(
+ (EXE, "literal ; & | argument", 'quote"inside'),
+ env=(("ZED", "last"), ("Alpha", "first"), ("PATH", "tools")),
+ cwd=r"C:\repo\work dir",
+ stdin=b"Write-Output 'exact'\r\nexit 23\r\n",
+ )
+
+ with patch.dict(
+ os.environ, {"HOST": "base", "Path": "host", "zed": "host"}, clear=True
+ ):
+ exit_code = await WindowsProcessRunner().run(command, log=log)
+
+ self.assertEqual(exit_code, 23)
+ argv, options = popen.calls[0]
+ self.assertEqual(argv, command.argv)
+ self.assertIs(options["stdout"], log)
+ self.assertIs(options["stderr"], subprocess.STDOUT)
+ self.assertIs(options["stdin"], subprocess.PIPE)
+ self.assertIs(options["shell"], False)
+ self.assertIs(options["close_fds"], True)
+ self.assertEqual(options["executable"], command.argv[0])
+ self.assertEqual(options["cwd"], command.cwd)
+ self.assertEqual(
+ options["env"],
+ {
+ "Alpha": "first",
+ "HOST": "base",
+ "PATH": "tools" + os.pathsep + "host",
+ "ZED": "last",
+ },
+ )
+ self.assertEqual(options["creationflags"], 0x00000404)
+ self.assertLess(events.index(("assign", 301)), events.index(("resume",)))
+ self.assertLess(
+ events.index(("resume",)),
+ events.index(("communicate-start", command.stdin)),
+ )
+ self.assertEqual(events.count(("close-job",)), 1)
+ self.assertNotIn(("kill",), events)
+
+ async def test_executable_must_be_an_absolute_existing_regular_file(self) -> None:
+ invalid = ("tool.exe", str(BUILD_ROOT / "missing.exe"), str(BUILD_ROOT))
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events)
+ popen = self.patch_runtime(process)
+ for executable in invalid:
+ with self.subTest(executable=executable):
+ with self.assertRaisesRegex(ValueError, "executable"):
+ await WindowsProcessRunner().run(
+ Command((executable,)), log=io.BytesIO()
+ )
+ self.assertEqual(events, [])
+ self.assertEqual(popen.calls, [])
+
+ async def test_popen_failure_closes_job(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events)
+ self.patch_runtime(process, create_error=OSError("create failed"))
+
+ with self.assertRaisesRegex(OSError, "create failed"):
+ await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO())
+
+ self.assertEqual(events, [("create-job",), ("popen",), ("close-job",)])
+
+ async def test_assignment_failure_kills_suspended_process_and_reaps(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events)
+ self.patch_runtime(process, assign_error=OSError("assign failed"))
+
+ with self.assertRaisesRegex(OSError, "assign failed"):
+ await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO())
+
+ self.assertNotIn(("resume",), events)
+ self.assertLess(events.index(("close-job",)), events.index(("kill",)))
+ self.assertIn(("wait",), events)
+
+ async def test_resume_failure_closes_assigned_job_and_reaps(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events, resume_error=OSError("resume failed"))
+ self.patch_runtime(process)
+
+ with self.assertRaisesRegex(OSError, "resume failed"):
+ await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO())
+
+ self.assertLess(events.index(("assign", 301)), events.index(("resume",)))
+ self.assertLess(events.index(("close-job",)), events.index(("wait",)))
+ self.assertNotIn(("kill",), events)
+
+ async def test_cancellation_closes_job_then_waits_for_communicate(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events)
+ process.communicate_release.clear()
+ self.patch_runtime(process)
+ task = asyncio.create_task(
+ WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO())
+ )
+ await asyncio.to_thread(process.communicate_started.wait, 5)
+
+ task.cancel()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+
+ self.assertLess(
+ events.index(("close-job",)), events.index(("communicate-done",))
+ )
+ self.assertNotIn(("kill",), events)
+
+ async def test_job_close_failure_terminates_assigned_job_and_is_noted(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events, communicate_error=OSError("write failed"))
+ self.patch_runtime(process, close_error=OSError("job close failed"))
+
+ with self.assertRaisesRegex(OSError, "write failed") as raised:
+ await WindowsProcessRunner().run(
+ Command((EXE,), stdin=b"recipe"), log=io.BytesIO()
+ )
+
+ self.assertLess(
+ events.index(("close-job",)), events.index(("terminate-job",))
+ )
+ self.assertNotIn(("kill",), events)
+ self.assertTrue(
+ any("job close failed" in note for note in raised.exception.__notes__)
+ )
+
+ async def test_success_does_not_hide_job_close_failure(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(events)
+ self.patch_runtime(process, close_error=OSError("job close failed"))
+
+ with self.assertRaisesRegex(OSError, "job close failed"):
+ await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO())
+
+ self.assertLess(
+ events.index(("close-job",)), events.index(("terminate-job",))
+ )
+ self.assertNotIn(("kill",), events)
+
+ async def test_kill_failure_is_noted_on_primary_failure(self) -> None:
+ events: list[tuple[object, ...]] = []
+ process = FakeProcess(
+ events,
+ communicate_error=OSError("write failed"),
+ kill_error=OSError("kill failed"),
+ )
+ self.patch_runtime(
+ process,
+ close_error=OSError("job close failed"),
+ terminate_error=OSError("job terminate failed"),
+ )
+
+ with self.assertRaisesRegex(OSError, "write failed") as raised:
+ await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO())
+
+ self.assertLess(events.index(("close-job",)), events.index(("terminate-job",)))
+ self.assertLess(events.index(("terminate-job",)), events.index(("kill",)))
+ notes = raised.exception.__notes__
+ self.assertTrue(any("job close failed" in note for note in notes))
+ self.assertTrue(any("job terminate failed" in note for note in notes))
+ self.assertTrue(any("kill failed" in note for note in notes))
+
+
+@unittest.skipUnless(os.name == "nt", "requires Windows Job Objects")
+class WindowsProcessRunnerIntegrationTests(unittest.IsolatedAsyncioTestCase):
+ @staticmethod
+ def command(*arguments: str, stdin: bytes = b"") -> Command:
+ return Command(
+ (EXE, *arguments),
+ env=tuple(os.environ.items()),
+ cwd=str(BUILD_ROOT),
+ stdin=stdin,
+ )
+
+ async def test_real_process_receives_exact_stdin_and_combines_log(self) -> None:
+ script = (
+ "import sys; data=sys.stdin.buffer.read(); "
+ "sys.stdout.buffer.write(b'OUT:' + data); "
+ "sys.stderr.buffer.write(b'|ERR'); raise SystemExit(23)"
+ )
+ payload = b"literal ; & | \x00 recipe\r\n"
+ with tempfile.TemporaryDirectory() as directory:
+ log_path = Path(directory) / "process.log"
+ with log_path.open("w+b") as log:
+ exit_code = await WindowsProcessRunner().run(
+ self.command("-c", script, stdin=payload), log=log
+ )
+
+ self.assertEqual(exit_code, 23)
+ self.assertEqual(log_path.read_bytes(), b"OUT:" + payload + b"|ERR")
+
+ async def test_real_cancellation_kills_descendant_process(self) -> None:
+ script = (
+ "import subprocess,sys,time; "
+ "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(300)']); "
+ "print(p.pid, flush=True); time.sleep(300)"
+ )
+ child: psutil.Process | None = None
+ task: asyncio.Task[int] | None = None
+ with tempfile.TemporaryDirectory() as directory:
+ log_path = Path(directory) / "tree.log"
+ try:
+ with log_path.open("w+b") as log:
+ task = asyncio.create_task(
+ WindowsProcessRunner().run(
+ self.command("-c", script), log=log
+ )
+ )
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline and not task.done():
+ content = log_path.read_text(encoding="ascii").strip()
+ if content:
+ child = psutil.Process(int(content))
+ break
+ await asyncio.sleep(0.05)
+ if child is None:
+ if task.done():
+ await task
+ self.fail("child process pid was not reported")
+
+ task.cancel()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+
+ gone, alive = psutil.wait_procs([child], timeout=10)
+ self.assertEqual(gone, [child])
+ self.assertEqual(alive, [])
+ finally:
+ if task is not None and not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ if child is not None and child.is_running():
+ child.kill()
+ child.wait(timeout=5)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py
new file mode 100644
index 0000000..ebe8ae5
--- /dev/null
+++ b/build/tests/test_workflow_contract.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+from pathlib import Path
+import re
+import unittest
+
+
+REPOSITORY = Path(__file__).parents[2]
+
+
+class WorkflowContractTests(unittest.TestCase):
+ def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None:
+ workflow = (REPOSITORY / ".github/workflows/main.yml").read_text(encoding="utf-8")
+
+ self.assertIn("pull_request:", workflow)
+ self.assertIn("push:", workflow)
+ self.assertGreaterEqual(workflow.count("branches: [master]"), 2)
+ self.assertNotIn("workflow_dispatch", workflow)
+ self.assertNotIn("schedule:", workflow)
+ self.assertNotIn("pull_request_target", workflow)
+ self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow)
+
+ self.assertIn("runs-on: windows-2022", workflow)
+ self.assertIn("fail-fast: false", workflow)
+ matrix_rows = re.findall(r"- job: ([^\n]+)\n\s+arch: ([^\n]+)", workflow)
+ self.assertEqual(
+ matrix_rows,
+ [("source", "none"), ("x86", "x86"), ("x64", "x64"), ("arm64-cross", "arm64")],
+ )
+ self.assertEqual(workflow.count("./build.ps1 verify-source"), 1)
+ self.assertEqual(workflow.count("./build.ps1 verify-arch"), 1)
+ self.assertEqual(workflow.count("-PruneCas"), 1)
+ self.assertNotIn("./build.ps1 verify -Arch", workflow)
+ self.assertNotIn("continue-on-error", workflow)
+
+ self.assertIn("upload-artifact", workflow)
+ self.assertIn("id: verify-source", workflow)
+ self.assertIn("id: verify-arch", workflow)
+ self.assertIn(
+ "if: always() && (steps.verify-source.outcome != 'skipped' || "
+ "steps.verify-arch.outcome != 'skipped')",
+ workflow,
+ )
+ self.assertIn("/manifest.json", workflow)
+ self.assertIn("/reports", workflow)
+ self.assertIn("/logs", workflow)
+ self.assertNotIn("!${{ runner.temp }}", workflow)
+ self.assertIn("if-no-files-found: error", workflow)
+ self.assertIn(
+ "if: success() && github.event_name == 'push' && matrix.job != 'source'",
+ workflow,
+ )
+ self.assertIn("/packages", workflow)
+ self.assertNotIn("if-no-files-found: ignore", workflow)
+ self.assertIn("permissions:\n contents: read", workflow)
+ self.assertEqual(workflow.count("path: out/cas"), 2)
+ self.assertIn("id: restore-build-cas", workflow)
+ self.assertIn("uses: actions/cache/restore@", workflow)
+ self.assertIn("uses: actions/cache/save@", workflow)
+ self.assertIn(
+ "key: cas-v1-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-"
+ "${{ github.run_attempt }}",
+ workflow,
+ )
+ self.assertIn("cas-v1-${{ matrix.job }}-${{ github.sha }}-", workflow)
+ self.assertIn("cas-v1-${{ matrix.job }}-", workflow)
+ self.assertNotIn("cas-v1-${{ steps.runner-image.outputs.identity }}", workflow)
+ self.assertIn("if: always() && matrix.job != 'source'", workflow)
+ self.assertIn(
+ "key: ${{ steps.restore-build-cas.outputs.cache-primary-key }}", workflow
+ )
+ self.assertNotIn("save-always", workflow)
+ self.assertNotIn("out/work", workflow)
+ self.assertNotIn("contents: write", workflow)
+ self.assertNotIn("download-artifact", workflow)
+ self.assertNotIn("upload-sarif", workflow)
+ self.assertNotIn("codeql-action", workflow)
+
+ actions = dict(re.findall(r"uses:\s+([^@\s]+)@([^\s#]+)", workflow))
+ self.assertEqual(
+ actions,
+ {
+ "actions/checkout": "d23441a48e516b6c34aea4fa41551a30e30af803",
+ "astral-sh/setup-uv": "08807647e7069bb48b6ef5acd8ec9567f424441b",
+ "actions/cache": "caa296126883cff596d87d8935842f9db880ef25",
+ "actions/cache/restore": "caa296126883cff596d87d8935842f9db880ef25",
+ "actions/cache/save": "caa296126883cff596d87d8935842f9db880ef25",
+ "actions/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
+ },
+ )
+ for action, revision in actions.items():
+ with self.subTest(action=action):
+ self.assertRegex(revision, r"^[0-9a-f]{40}$")
+ self.assertIn("persist-credentials: false", workflow)
+ self.assertIn('version: "0.12.1"', workflow)
+ self.assertIn("id: runner-image", workflow)
+ self.assertIn("${{ steps.runner-image.outputs.identity }}", workflow)
+ self.assertIn('"TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV', workflow)
+ self.assertIn('"TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV', workflow)
+ self.assertIn(
+ "$baseline = (Get-Content -Raw -LiteralPath 'vcpkg.json' | "
+ "ConvertFrom-Json).'builtin-baseline'",
+ workflow,
+ )
+ self.assertIn(
+ "git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline",
+ workflow,
+ )
+ self.assertIn("checkout --detach --force $baseline", workflow)
+ self.assertIn("bootstrap-vcpkg.bat", workflow)
+ self.assertIn("C:\\Program Files\\Cppcheck", workflow)
+ self.assertIn("nuget install Microsoft.CodeAnalysis.BinSkim", workflow)
+ self.assertIn("tools\\net9.0\\win-x64\\BinSkim.exe", workflow)
+ self.assertNotIn("dotnet tool install --global Microsoft.CodeAnalysis.BinSkim", workflow)
+ self.assertIn("$env:GITHUB_PATH", workflow)
+ self.assertIn("if: matrix.job == 'x64'", workflow)
+ self.assertIn(
+ "https://download.microsoft.com/download/e119c04b-71aa-4067-ac3c-360c2e13d209/"
+ "windowssdk/Installers/X64%20Debuggers%20And%20Tools-x64_en-us.msi",
+ workflow,
+ )
+ self.assertIn("354173D844D5C061050EE2638AA94FAFB4835AC3DE836E220F6A74A992849A3B", workflow)
+ self.assertIn(
+ '$process = Start-Process -FilePath "$env:SystemRoot\\System32\\msiexec.exe"',
+ workflow,
+ )
+ self.assertIn(
+ '$arguments = @(\'/a\', $msi, \'/qn\', \'/norestart\', "TARGETDIR=$extract")',
+ workflow,
+ )
+ self.assertNotIn("'/layout'", workflow)
+ self.assertNotIn("'/installpath'", workflow)
+ self.assertIn("'10.0.19041.'", workflow)
+ self.assertIn("$gflags = Join-Path $debuggers 'gflags.exe'", workflow)
+ self.assertIn('"OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV', workflow)
+
+ for duplicated_gate in (
+ "./build.ps1 source-checks",
+ "./build.ps1 compiler-analysis",
+ "./build.ps1 test-coverage",
+ "./build.ps1 test-asan",
+ "./build.ps1 test-ubsan",
+ "./build.ps1 test-leaks",
+ "./build.ps1 fuzz",
+ "./build.ps1 audit-binaries",
+ "./build.ps1 package",
+ ):
+ self.assertNotIn(duplicated_gate, workflow)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/uv.lock b/build/uv.lock
new file mode 100644
index 0000000..8f37f88
--- /dev/null
+++ b/build/uv.lock
@@ -0,0 +1,146 @@
+version = 1
+revision = 3
+requires-python = "==3.14.6"
+
+[[package]]
+name = "coverage"
+version = "7.15.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
+ { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
+ { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
+ { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.32.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "observer-build"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "coverage" },
+ { name = "filelock" },
+ { name = "jinja2" },
+ { name = "psutil" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "coverage", specifier = "==7.15.2" },
+ { name = "filelock", specifier = "==3.32.2" },
+ { name = "jinja2", specifier = "==3.1.6" },
+ { name = "psutil", specifier = "==7.2.2" },
+ { name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "312"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
+ { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
+]
diff --git a/build/vcpkg/triplets/observer-arm64-windows-static.cmake b/build/vcpkg/triplets/observer-arm64-windows-static.cmake
new file mode 100644
index 0000000..9ea8b57
--- /dev/null
+++ b/build/vcpkg/triplets/observer-arm64-windows-static.cmake
@@ -0,0 +1,5 @@
+set(VCPKG_TARGET_ARCHITECTURE arm64)
+set(VCPKG_CRT_LINKAGE static)
+set(VCPKG_LIBRARY_LINKAGE static)
+set(VCPKG_C_FLAGS "/W4 /Qspectre")
+set(VCPKG_CXX_FLAGS "/W4 /Qspectre")
diff --git a/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake b/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake
new file mode 100644
index 0000000..c347497
--- /dev/null
+++ b/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake
@@ -0,0 +1,6 @@
+set(VCPKG_TARGET_ARCHITECTURE x64)
+set(VCPKG_CRT_LINKAGE static)
+set(VCPKG_LIBRARY_LINKAGE static)
+set(VCPKG_BUILD_TYPE release)
+set(VCPKG_C_FLAGS "/W4 /Qspectre /fsanitize=address")
+set(VCPKG_CXX_FLAGS "/W4 /Qspectre /fsanitize=address")
diff --git a/build/vcpkg/triplets/observer-x64-windows-static.cmake b/build/vcpkg/triplets/observer-x64-windows-static.cmake
new file mode 100644
index 0000000..8e9ad7f
--- /dev/null
+++ b/build/vcpkg/triplets/observer-x64-windows-static.cmake
@@ -0,0 +1,5 @@
+set(VCPKG_TARGET_ARCHITECTURE x64)
+set(VCPKG_CRT_LINKAGE static)
+set(VCPKG_LIBRARY_LINKAGE static)
+set(VCPKG_C_FLAGS "/W4 /Qspectre")
+set(VCPKG_CXX_FLAGS "/W4 /Qspectre")
diff --git a/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake b/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake
new file mode 100644
index 0000000..5909984
--- /dev/null
+++ b/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake
@@ -0,0 +1,6 @@
+set(VCPKG_TARGET_ARCHITECTURE x86)
+set(VCPKG_CRT_LINKAGE static)
+set(VCPKG_LIBRARY_LINKAGE static)
+set(VCPKG_BUILD_TYPE release)
+set(VCPKG_C_FLAGS "/W4 /Qspectre /fsanitize=address")
+set(VCPKG_CXX_FLAGS "/W4 /Qspectre /fsanitize=address")
diff --git a/build/vcpkg/triplets/observer-x86-windows-static.cmake b/build/vcpkg/triplets/observer-x86-windows-static.cmake
new file mode 100644
index 0000000..f4743bb
--- /dev/null
+++ b/build/vcpkg/triplets/observer-x86-windows-static.cmake
@@ -0,0 +1,5 @@
+set(VCPKG_TARGET_ARCHITECTURE x86)
+set(VCPKG_CRT_LINKAGE static)
+set(VCPKG_LIBRARY_LINKAGE static)
+set(VCPKG_C_FLAGS "/W4 /Qspectre")
+set(VCPKG_CXX_FLAGS "/W4 /Qspectre")
diff --git a/copy_dlls.cmd b/copy_dlls.cmd
deleted file mode 100644
index e12a55d..0000000
--- a/copy_dlls.cmd
+++ /dev/null
@@ -1,6 +0,0 @@
-@echo off
-pushd %~dp0build\%1
-set MODULES_DIR=%DEBUGFARHOME%\Plugins\Observer\modules
-copy /Y *.so %MODULES_DIR% || exit 1
-copy /Y *.pdb %MODULES_DIR%
-popd
\ No newline at end of file
diff --git a/docs/build-system.md b/docs/build-system.md
new file mode 100644
index 0000000..135bd37
--- /dev/null
+++ b/docs/build-system.md
@@ -0,0 +1,484 @@
+# Build system and engineering workflow
+
+## Status
+
+This document describes the implemented local build. The repository uses a Python/Jinja content-addressed DAG for
+orchestration and keeps MSBuild as the native compile/link backend.
+
+The IX-aligned build model and automatic CI sections below record implemented contracts. A feature described as
+future or deferred is not part of the current gate.
+
+## Non-negotiable release contract
+
+- Windows-only native C++23, compiled with the MSVC `cl.exe` toolchain.
+- Release modules are built for x86, x64, and ARM64.
+- The MSVC runtime and all third-party libraries are linked statically (`/MT` and static vcpkg triplets).
+- A release archive contains no redistributable runtime and no non-system DLL dependencies.
+- CMake is not part of this repository's build graph. vcpkg ports may use CMake internally.
+- `/clr` is not used. Managed-library integrations require a separate future design that preserves the native,
+ self-contained release contract.
+
+## Layers
+
+```text
+build.ps1 / build.cmd stable command-line entry point
+ |
+ v
+build/main.py command contract and graph composition
+ |
+ v
+build/graphs/* fine-grained content-addressed DAG
+ |
+ v
+build/projects/*.vcxproj compile/link graph
+ |
+ v
+cl.exe / link.exe / lib.exe / rc.exe
+```
+
+The five-line root PowerShell script only enters the exact-pinned `uv` environment and forwards arguments. Python
+builds and executes the outer DAG; short inherited Jinja templates render tool recipes. MSBuild still owns native
+project evaluation, compilation, linking, and C++ header dependencies. Replacing that mature backend would duplicate
+substantial toolchain behavior without a demonstrated critical-path benefit.
+
+### DAG and content-addressed storage
+
+The outer engine adapts the small model used by [pg83/ix](https://github.com/pg83/ix): a node declares `in_dir`,
+`out_dir`, dependencies, data, one resource pool, and a Jinja recipe. Templates use inheritance and `StrictUndefined`;
+PowerShell-specific quoting is centralized instead of repeated in every leaf.
+
+Canonical MD5 covers the fully rendered recipe, descriptor/argv data, declared input bytes and paths, dependency UIDs,
+toolchain/platform identity, and the executor schema. MD5 is a fast local content identity, not a cryptographic trust
+boundary for remote artifacts. A CAS hit requires a canonical entry directory, its `out` directory, regular `log.txt`,
+and a zero-byte regular `touch` publication marker; failed or cancelled work cannot publish that marker.
+
+`filelock` coordinates node publication and clean operations between cooperating local processes. A public command
+holds one run lease across dependency discovery, all executor phases, manifest reads, telemetry, pruning, and result
+export; standalone executor use acquires the same lease implicitly. Mutable paths are confined below the exact
+repository output root and existing reparse points are rejected. `psutil` launches and observes processes; a Windows
+Job Object terminates the complete descendant tree on failure or cancellation. These are explicit local-build
+guarantees, not a claim of hostile-process isolation.
+
+## IX-aligned build model
+
+The goal is not to import IX's package manager. It is to keep the small, proven part that this repository needs: short
+inherited recipes, a dependency DAG, demand execution, content identities, and immutable successful outputs. The
+native compiler backend remains MSBuild; replacing project evaluation, C++ dependency handling, compilation, and
+linking is explicitly outside this stage.
+
+### Recipe model
+
+- One rendered recipe describes one cacheable node: readable name, direct dependencies, `in_dir`, `out_dir`, data,
+ one logical pool, and either an argv command or a shell body.
+- Jinja inheritance owns command construction and repeated tool policy. The intended hierarchy is a small JSON base,
+ an argv or PowerShell base, an MSBuild base where relevant, and a short leaf containing only operation-specific data.
+ `StrictUndefined` remains mandatory.
+- Graph-building Python enumerates targets and real dependency edges and supplies semantic data. It must stop rebuilding
+ the same argv, environment, path, and script boilerplate in every graph family.
+- The rendered artifact remains a JSON/argv descriptor. PowerShell is one recipe backend, not the graph language. A
+ future local WSL2 implementation can add a POSIX-shell base without changing the node, DAG, or CAS model.
+- Typed `results` become part of the recipe contract. Each result has a stable logical id, kind, media type, and a path
+ relative to the node output; graph code and CI must not infer results by scanning directories.
+
+### Identity and storage
+
+- Keep canonical MD5 and the zero-byte `touch` publication marker. MD5 identifies local content; any imported or
+ downloaded artifact must be authenticated separately.
+- The UID covers the rendered recipe, declared input paths and bytes, dependency UIDs, semantic configuration, and a
+ normalized toolchain fingerprint.
+- Dependency discovery signs every project-declared header plus header-like files beside the translation unit. The
+ compiler manifest is then checked fail-closed: any other first-party header dependency must be added to the
+ project's `` inventory before downstream graph construction can continue.
+- Absolute checkout/CAS/export paths, `GITHUB_*`, commit and PR ids, log destinations, `Jobs`, pool capacities, and
+ scheduler order do not affect the UID. The explicit run nonce is the exception: it intentionally changes fuzz,
+ leak, and opt-in corpus test identities; the default CLI nonce is based on time and process id.
+- Change the physical successful-entry key to `out/cas/`. The readable node name remains in graph diagnostics,
+ logs, and exported manifests instead of being duplicated in the CAS directory name.
+- Keep mutable scratch, locks, leases, and incomplete-entry quarantine below `out/work`. A failed node may first leave
+ a markerless directory and log in `out/cas`; a later demand moves it to per-run quarantine before rebuilding. There
+ is no permanent IX-style trash directory: quarantine is recoverable during the run and stale work is removed by the
+ existing safe cleanup contract.
+
+### Execution
+
+- Every runnable node consumes one shared `Jobs` slot. A recipe declares at most one additional logical pool, but a
+ narrower pool is introduced only for a measured resource limit or a demonstrated tool serialization requirement.
+ UMDH and BinSkim have no speculative `2` and `1` caps; by default they use the shared budget like other nodes.
+- Preserve all real fine-grained edges and shards: individual translation units/analyzers, test shards, fuzz targets,
+ leak scenarios and diffs, binary audits, and packages may overlap whenever their inputs are ready.
+- The executor uses keep-going semantics. A failed node blocks only its descendants; independent ready work continues,
+ all failures are collected, successfully produced diagnostics remain publishable, and the command finally exits
+ nonzero.
+- Continue using `filelock`, `psutil`, and the Windows Job Object rather than maintaining substitutes. Prefer a small,
+ well-maintained open-source dependency whenever it removes repository code without weakening the contract.
+
+### Public result boundary
+
+`-ExportDir` on verification and packaging commands copies only declared typed results to stable paths such
+as `reports/sarif/x64/...`, `reports/coverage/x64/...`, and `packages/x64/...`; it never exposes the internal CAS
+layout. The root `manifest.json` describes the self-contained evidence bundle. Successful package exports have a
+separate `packages/manifest.json`, whose paths are relative to that bundle, so CI can publish evidence after failure
+without publishing release ZIPs from pull requests. Manifests are written after their files and record command status,
+result ids, relative paths, producer UIDs, sizes, and SHA-256 digests. Diagnostics produced before a later gate failure
+are exported; release packages are exported only after their complete package gates pass. The export destination is
+not part of recipe identity. The root manifest also records graph nodes and their UIDs as `hit`, `executed`, `failed`,
+or `incomplete`. Durations are measured for executed and failed nodes; hits and incomplete nodes report zero. A hit is
+any valid pre-existing CAS entry, whether restored by CI or already local. `incomplete` means the graph node neither
+hit nor completed in this invocation, commonly because it was blocked; it is not an inventory of markerless CAS
+directories. Telemetry is captured before pruning and does not report removed entries.
+
+## Supported commands
+
+```powershell
+.\build.ps1 doctor
+.\build.ps1 restore -Arch x64
+.\build.ps1 build -Arch x86,x64,arm64 -Config Release
+.\build.ps1 test -Arch x64 -Config Debug
+.\build.ps1 source-checks -Arch x86,x64,arm64
+.\build.ps1 compiler-analysis -Arch x64
+.\build.ps1 test-coverage -Arch x64
+.\build.ps1 test-asan -Arch x64
+.\build.ps1 test-ubsan -Arch x64
+.\build.ps1 test-leaks -Arch x64
+.\build.ps1 fuzz -Arch x64 -FuzzTarget all -FuzzSeconds 60
+.\build.ps1 audit-binaries -Arch x86,x64,arm64
+.\build.ps1 package -Arch x86,x64,arm64
+.\build.ps1 verify-source -ExportDir
+.\build.ps1 verify-arch -Arch x64 -ExportDir
+.\build.ps1 verify -Arch x64
+.\build.ps1 clean -CleanMode stale-work
+```
+
+`build.cmd` is a convenience shim for `cmd.exe`; both entry points execute the same Python driver through the root
+PowerShell launcher.
+Use `-FuzzTarget pickle|renpy|rpgmaker|zanzarah` for a focused local regression run; the default `all` runs every
+format target.
+
+Without `-ExportDir`, commands may print their internal target paths for local diagnostics. Stable consumers use the
+typed export boundary; mutable intermediates and locks remain below `out/work`.
+
+`verify` and `verify-arch` accept `-PruneCas`. The option is intended for bounded cache generations such as CI. On a
+successful command it retains every complete node UID in the full graph, including unvisited dependencies of a cached
+target, and removes unlocked non-live canonical directories before publishing success evidence. Without `-ExportDir`
+the same success-path sweep runs without a manifest; a failed command without export keeps its internal failure data.
+Normal local commands keep prior successful nodes for fast switching between targets and configurations.
+
+`verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs
+deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage,
+the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A
+non-native runtime check is reported explicitly as deferred rather than falsely reported as executed. All gates are
+designed to be runnable locally on the supported Windows host.
+The verify fuzz work covers all four format targets; `-FuzzSeconds` controls each bounded run.
+
+Verification has two executor phases: a merged dependency-discovery graph, then one merged post-discovery graph.
+Within the second phase, ready nodes from builds, tests, analyzers, coverage, sanitizers, fuzzing, leak checks, audit,
+and packaging may overlap whenever their real dependencies allow it. `-Jobs` sets the shared global capacity. The
+implementation has no unmeasured UMDH or BinSkim limits encoded as scheduler policy.
+
+## Configurations
+
+| Configuration | Purpose | CRT | Distributed |
+|---|---|---|---|
+| Debug | development and deterministic tests | `/MTd` | no |
+| Release | optimized modules and packages | `/MT` | yes |
+| Coverage | clang-cl instrumentation and llvm-cov reporting | `/MTd` | no |
+| ASan | AddressSanitizer tests against release-only instrumented dependencies | `/MT` | no |
+| UBSan | clang-cl undefined-behavior tests | `/MT` | no |
+| Fuzz | libFuzzer plus AddressSanitizer | `/MT` | no |
+| Leak | UMDH against optimized x64 Release modules and probe | `/MT` | no |
+
+ASan and fuzzing are supported only on x86 and x64. Their runtime DLLs are test-only dependencies and must never be
+copied into release packages.
+
+## Dependency management
+
+Library dependencies are declared by `vcpkg.json` in manifest mode. Repository-owned overlay triplets explicitly set
+both `VCPKG_CRT_LINKAGE` and `VCPKG_LIBRARY_LINKAGE` to `static` for all three architectures. The vcpkg baseline is
+pinned in source control and packages are restored into architecture/flavor-specific CAS nodes; global vcpkg
+integration is not required. Separate install roots keep manifest-mode vcpkg from pruning another architecture while
+switching targets.
+
+Normal public commands include the exact restore nodes they require. Independent architecture/flavor restores may run
+concurrently; ARM64 ASan is omitted because that configuration is unsupported. There is no restore-skipping switch:
+restore is an ordinary content-addressed graph node and a valid hit is already a no-op.
+
+Developer tools are not library dependencies and are discovered by `doctor`:
+
+- `uv` and the repository environment initialized once with `uv sync --project build --frozen`;
+- Visual Studio Build Tools 2022 with MSVC x86/x64 and ARM64 tools plus a Windows SDK;
+- PowerShell 7.4 or newer;
+- vcpkg;
+- LLVM tools (`clang-format` and `clang-tidy`);
+- Cppcheck;
+- PSScriptAnalyzer for PowerShell sources.
+
+Normal `build.ps1` commands use `uv --frozen --no-sync`: they neither resolve nor download Python packages while a
+build is running. `build/uv.lock` exact-pins Python 3.14.6 and the small runtime dependency set.
+
+## Compiler and static-analysis policy
+
+Normal compilation uses `/W4 /WX /permissive-`, the conforming preprocessor, correct `__cplusplus`, SDL checks, and
+external-header warning suppression. Release additionally enables optimization and link-time code generation.
+
+The blocking analysis stack is:
+
+1. clang-format in check-only mode;
+2. MSVC warnings as errors;
+3. MSVC native code analysis (`/analyze` through MSBuild);
+4. clang-tidy;
+5. Cppcheck for the Release configuration of x86, x64, and ARM64;
+6. PSScriptAnalyzer for PowerShell files.
+
+PVS-Studio is explicitly out of scope. Include What You Use is deferred: it is useful for direct/minimal include
+hygiene, but its Windows mappings, LLVM version coupling, and false-positive cost do not justify making it a gate yet.
+Header self-containment checks and clang-tidy's include diagnostics come first.
+
+Cppcheck suppressions must be narrow and documented. Repository-wide suppression of a diagnostic is not acceptable.
+
+### Analysis evidence
+
+Analysis work and the semantic gate are deliberately separate. Each analyzer may finish and preserve its report before
+the deterministic merge/gate enforces findings. Cppcheck emits one SARIF file per architecture.
+MSVC `/analyze` keeps one raw SARIF file per first-party project and assigns every run a stable
+`msvc-analyze///` identity before the architecture directory is uploaded. The clang-tidy logs produced
+by that same compile-only graph are converted into a deduplicated first-party SARIF file with the stable identity
+`clang-tidy//`.
+
+BinSkim emits one release-binary SARIF file per architecture. Reports remain separate by analyzer and architecture and
+are stored as local CAS evidence. A CI job may repeat these commands, but it must not be the only way to execute or
+inspect any mandatory gate.
+
+The main GitHub workflow remains a thin client: it provisions an otherwise empty hosted runner, invokes public local
+commands, and transports their declared results. It contains no private gate graph, report parser, CAS-path protocol,
+or release logic.
+
+## Automatic CI
+
+CI contains no CI-only quality gate. Everything mandatory in GitHub must remain runnable from an ordinary local
+console through the same public entry points:
+
+```powershell
+.\build.ps1 verify-source -ExportDir
+.\build.ps1 verify-arch -Arch x86 -ExportDir
+.\build.ps1 verify-arch -Arch x64 -ExportDir
+.\build.ps1 verify-arch -Arch arm64 -ExportDir
+.\build.ps1 verify -Arch all -ExportDir
+```
+
+`verify-source` runs architecture-independent formatting, PowerShell analysis, Python tests/100% coverage, and
+repository contracts exactly once. `verify-arch` runs the complete applicable graph for one architecture. The existing
+`verify -Arch all` remains the local aggregate and composes source plus every architecture into one executor for
+maximum local overlap; the split entry points do not define different gates.
+
+Only two automatic triggers are allowed:
+
+```yaml
+on:
+ pull_request:
+ branches: [master]
+ push:
+ branches: [master]
+```
+
+There is no `workflow_dispatch`, scheduled workflow, or ARM64-native runner. The four required jobs are:
+
+| Job | Public command | Contract |
+|---|---|---|
+| `source` | `verify-source` | architecture-independent gates |
+| `x86` | `verify-arch -Arch x86` | Debug/Release, runnable tests, analysis, ASan, audit, package validation |
+| `x64` | `verify-arch -Arch x64` | x86-class gates plus coverage, UBSan, fuzzing, UMDH leaks, and package smoke |
+| `arm64-cross` | `verify-arch -Arch arm64` | MSVC cross-build, analysis, binary audit, and package validation |
+
+ARM64 runtime tests are explicitly deferred; a successful cross job must not report them as executed. All four jobs
+are required for pull requests and pushes to `master`. GitHub matrix/job fail-fast is disabled, and the graph's own
+keep-going behavior preserves independent evidence inside each job. Superseded pull-request runs are cancelled;
+`master` runs are never cancelled by a newer push.
+
+Pull requests use the same gates and thresholds with shorter explicit bounded-work parameters, for example a small
+`-FuzzSeconds` value and minimal leak warm-up/iterations. Pushes to `master` use the full local defaults. There is no
+hidden `pr`/`full` gate composition and no manual profile; changing a bounded duration never removes a target or
+weakens the 100% coverage and zero-finding gates.
+
+The workflow caches dependency transport data and the `out/cas` content-addressed store. CAS caches are isolated by
+architecture job, restored only within GitHub's branch/ref cache scope, and never shared with the source job. Their
+outer restore prefixes deliberately span hosted-runner image revisions: node identities cover recipes, declared
+inputs, dependency identities, configuration, runtime, and toolchain content, so incompatible nodes miss without
+requiring a coarse image key. Each run and attempt has a unique generation key. Separate restore and save steps retain
+the store after a later gate fails. Only canonical complete entries can hit: a demanded incomplete entry is
+quarantined and rebuilt. Fuzz, leak, and opt-in corpus nodes include a run nonce. An absent or evicted cache therefore
+changes only latency. The workflow never caches `out/work`, an installed vcpkg tree, or the separate
+`ExportDir/packages` bundle; package-node outputs can still live inside the CAS cache.
+
+Architecture jobs pass `-PruneCas`. On success, telemetry is captured, mark-and-sweep removes unlocked canonical
+directories not named by the command graph's live UID set, and then the success manifest is published. On execution
+failure inside the complete final graph, available failure evidence is published before pruning so failed logs survive.
+If manifest loading or final graph composition fails after discovery, evidence for the partial graph is exported but
+pruning is skipped because that graph is not a complete liveness boundary. The workflow then saves a new immutable
+GitHub cache generation. Locked and noncanonical entries are preserved; no access time or wall-clock age participates
+in liveness. An early graph/export/prune failure can still reach the `always()` save step with an unpruned store. GitHub
+eventually evicts older whole generations according to its cache retention policy; unlocked canonical stale entries do
+not propagate after a successful prune.
+
+Each job attempts to upload the manifest, reports, and logs from its `-ExportDir` with `if: always()` so evidence from
+completed independent branches survives a later failure; a failure after the verification step starts but before
+manifest creation is reported as missing evidence. Pull-request evidence is retained for seven days; `master`
+evidence for thirty days. The separate packages
+subtree is uploaded only from successful `master` jobs. Actions are pinned to full commit SHAs, permissions default to
+`contents: read`, untrusted pull requests receive no secrets, and `pull_request_target` is forbidden.
+
+The workflow first runs `doctor`, then exactly one public verification command. It uploads the self-contained evidence
+bundle when verification produced one and uploads the separate package bundle only from successful `master`
+architecture jobs. Branch protection requires `source`, `x86`, `x64`, and `arm64-cross`.
+
+The hosted x64 job selects UMDH from the serviced Windows 10 SDK 2004 line explicitly. This avoids the documented
+allocation-stack capture defect in the UMDH shipped with Windows 11 SDKs without changing the locally runnable leak
+gate or the SDK used to compile production binaries.
+
+### Future analysis backlog
+
+The following tools are deliberately recorded for later work so that they are not lost while the build and test
+architecture is being stabilized:
+
+- **Coverity Scan:** evaluate it only if it can be integrated into the locally runnable verification contract. It is
+ not part of the approved CI workflow while submissions require a separate external/manual path.
+- **Infer:** evaluate it locally under the future WSL2 parser-core workflow. Do not add a separate CI-only build path
+ for the Windows DLL adapters merely to accommodate Infer.
+- **Include What You Use:** introduce it after parser/header separation has stabilized. Pin an IWYU release compatible
+ with the selected LLVM version, generate its compile commands from the canonical build graph, and review suggestions
+ rather than applying fixes automatically. It checks direct/minimal include ownership, not runtime correctness.
+- **CBMC:** use it for small, security-critical units with explicit bounds, especially the bounded byte reader and
+ offset/size/allocation arithmetic. Write focused harnesses that prove properties such as no overflow and no
+ out-of-range access; whole-project model checking is not a goal.
+
+PC-lint Plus has been considered and explicitly rejected for this project. Do not add it to the required toolchain or
+local verification matrix.
+
+## Tests
+
+Catch2 remains the test framework. Tests are split conceptually into:
+
+- deterministic unit tests for parsers, decryption, size/offset arithmetic, and error handling;
+- Observer ABI contract tests, including invalid inputs and cancellation;
+- small synthetic archive end-to-end tests committed to the repository;
+- the large external golden corpus, selected with `OBSERVER_TEST_CORPUS` or `-Corpus`;
+- package smoke tests that load the exact binary that will be distributed.
+
+An absent external corpus skips only corpus tests; it must not hide failures in deterministic tests.
+
+### Future parser-core boundary
+
+The current format parsing code is coupled to the Observer DLL adapter, filesystem operations, and parts of the test
+harness. A future refactoring should separate a portable parser core from those concerns. This is an architectural
+boundary, not a new shared runtime DLL: the core remains statically linked into each self-contained module, and the
+published Observer exports and package layout remain unchanged.
+
+The intended responsibilities are:
+
+- the Observer adapter owns the public C ABI, Windows types, module lifetime, callbacks, and error translation;
+- the parser core consumes a small bounded byte-source abstraction and produces validated archive metadata and entry
+ descriptions;
+- format-specific implementations remain independent behind a factory or another narrow dispatch boundary; a virtual
+ base class is optional and should be introduced only if it simplifies the actual call sites and tests;
+- extraction I/O is kept outside pure metadata parsing where practical, while decompression and format algorithms
+ remain testable without loading a plugin DLL.
+
+This boundary is expected to provide the following benefits:
+
+- deterministic unit tests for valid, malformed, truncated, and adversarial inputs without creating files or loading
+ DLLs;
+- direct fuzz targets for every format parser instead of fuzzing through Observer or a large integration harness;
+- substantially cheaper growth toward 100% branch coverage, with failures attributable to a single parser;
+- one implementation of bounded reads, checked offset arithmetic, allocation limits, and entry-range validation;
+- portable Clang builds of production parsing code for UBSan and LeakSanitizer on a supported non-Windows host;
+- smaller test fixtures, reducing dependence on the multi-gigabyte external golden corpus;
+- independent testing of the stable Observer ABI adapter against fake parser results and failure injection.
+
+The refactoring does not replace end-to-end tests. ABI contract tests, real DLL loading, package smoke tests, and a
+smaller compatibility corpus remain necessary because they cover integration behavior that parser-core tests cannot.
+
+## Coverage
+
+The analysis-only Coverage configuration uses `clang-cl` source instrumentation and `llvm-cov` to enforce 100% lines
+and branches in first-party production modules. Functions and regions remain visible in the report but are not separate
+release gates. Test framework, Catch2, vcpkg, generated, and Windows SDK code is excluded. Its reports are evidence
+about the deterministic test suite, not release artifacts.
+
+Canonical correctness tests and all distributed binaries continue to use MSVC `cl.exe`. The same deterministic tests
+must pass under MSVC Debug before their clang-cl coverage result is accepted.
+
+## Sanitizers and fuzzing
+
+The primary ASan path links production core code into a test executable. A dedicated loader smoke test also exercises
+the actual instrumented module DLL inside a controlled host process; real FAR/Observer is not a test dependency.
+
+Windows ASan does not detect memory leaks, and a debug-CRT leak check in the test executable cannot account for all
+allocations made by separately linked shipping module DLLs. Leak testing is therefore a separate required layer rather
+than an ASan option.
+
+The `test-leaks` operation runs an optimized x64 Release `/MT` probe against the exact Release module DLLs and uses
+UMDH from Windows Debugging Tools to compare process-wide heap snapshots. Before measurement it records the loaded
+binary paths and hashes, audits their architecture, imports, and exports, and runs an automatic scenario preflight. The
+probe then:
+
+1. load every module and perform an unmeasured application warm-up;
+2. let the first UMDH attachment enable process-local allocation stack collection, avoiding persistent/elevated GFlags
+ registry state, then run another full workload window before accepting a measured baseline;
+3. repeatedly exercise successful operations, malformed input, cancellation, read/write failure, and bounded
+ large/sparse metadata workloads;
+4. repeat the same scenario table in a separate mode that loads and unloads each real DLL on every round;
+5. take snapshots after multiple measurement windows and retain diffs as test artifacts;
+6. fail when allocation stacks or total live heap bytes show sustained growth across consecutive windows.
+
+The gate must detect a leak slope rather than require a literal zero-byte snapshot difference: the Windows loader,
+CRT, symbol engine, and third-party libraries can retain bounded one-time caches. Any allowance must be narrow,
+stack-specific, documented, and stable across repeated windows. Debug-CRT checkpoints may provide faster feedback in
+unit tests, but they are not accepted as proof that a complete plugin process is leak-free.
+
+After the parser-core boundary exists, a Clang ASan plus LeakSanitizer job on a supported non-Windows host should check
+the same core tests and fuzz regression corpus. It complements UMDH rather than replacing it: LeakSanitizer covers the
+portable production logic, while UMDH covers the shipped Windows DLLs, static CRT instances, ABI adapter, and loader
+lifecycle.
+
+Leak freedom and bounded memory consumption are different requirements. A separate memory-budget stress test should
+measure peak private bytes while processing synthetic large/sparse archives and verify that archive contents are not
+buffered wholesale. The external real-world corpus remains useful as an opt-in local compatibility and stress layer,
+but the required local leak gate uses small, repository-owned fixtures and finishes deterministically.
+
+Fuzzers are standalone executables. They never fuzz through the FAR process. The initial target is the Ren'Py Pickle
+parser; archive-index, path, and decompression fuzzers are added as parsing is separated from filesystem I/O. The local
+gate replays every checked-in seed and then runs Pickle, Ren'Py, RPG Maker, and Zanzarah independently. Checked-in seeds
+include minimized regression inputs and compact representative structures derived from the opt-in external corpus;
+the external archives themselves are not committed. Every crash is minimized and committed as a deterministic
+regression input after triage.
+
+## Output and cleanup
+
+`out/cas` contains immutable successful node results plus markerless directories left by failed or cancelled nodes.
+`out/work` contains in-flight scratch space, quarantined incomplete entries, failed-run scratch, and `.locks`.
+Successful node scratch is removed immediately. Each active execution holds a run lease; `clean` takes a coordination
+lock and refuses to race active runs or node publishers.
+
+`clean -CleanMode stale-work` removes only inactive scratch. `clean -CleanMode all` removes CAS entries, completed run
+data, and inactive locks while retaining the minimal coordination-lock skeleton. Both modes validate the exact
+repository `out` layout and reject reparse points, unsafe types, and unexpected top-level/work/lock entries.
+
+CI uses the narrower `-PruneCas` contract instead of `clean`: complete UIDs from the full current command graph are the
+mark set, and canonical CAS directories outside that set are candidates for removal whether complete or markerless.
+The sweep takes the global coordination lock, refuses to run while another run lease is active, takes each candidate
+node lock, and revalidates its confined directory under that lock. The caller's explicitly identified, verified-active
+session lease is the sole exception. The sweep skips locked candidates, preserves noncanonical entries, and rejects an
+unsafe layout.
+
+After the portable parser core is complete, revisit a separate local WSL2 workflow for Linux-only sanitizers and
+test-quality experiments. It is not part of the current build graph; shipping modules remain Windows/MSVC artifacts.
+
+## Release audit and packaging
+
+Before packaging, `dumpbin` verifies:
+
+- the expected x86, x64, or ARM64 PE machine type;
+- exactly the Observer exports `LoadSubModule` and `UnloadSubModule`;
+- absence of `VCRUNTIME`, `MSVCP`, UCRT, zlib, xxHash, ASan, and other non-system DLL dependencies.
+
+Packaging is rejected if unexpected DLLs, import libraries, or intermediate files enter the staging directory. PDBs
+are published separately from module archives.
diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md
new file mode 100644
index 0000000..ecc40cc
--- /dev/null
+++ b/docs/critical-software-methodology.md
@@ -0,0 +1,164 @@
+# High-assurance software methodology
+
+Status: proposed repository policy, accepted principles with several owner decisions still open.
+
+ObserverModules parses untrusted, sometimes very large archives inside another application's process. A malformed
+input, memory leak, ABI violation, or unbounded operation can therefore corrupt or exhaust the host. The project adopts
+a **critical-software-inspired** engineering method to reduce that risk. This is not a claim of formal MISRA,
+DO-178C, IEC 61508, or other safety certification: the project does not currently have an independent verification
+organization, a certified toolchain, or the complete requirements-to-binary evidence such a claim would require.
+
+## Non-negotiable policy
+
+1. **Requirements and invariants come before implementation.** Each change identifies its observable behavior,
+ failure behavior, input limits, ownership, and ABI impact. Safety-relevant assumptions must be executable as tests
+ or explicit assertions where practical.
+2. **Strict TDD is the default change protocol.** First produce a focused test that fails for the expected reason,
+ then make the smallest production change, then refactor with the suite green. Every defect starts with a regression
+ test. A test that executes a branch without checking behavior is not sufficient.
+3. **All first-party production code has 100% line and branch coverage.** Coverage is a necessary completeness signal,
+ not proof of correctness. Dangerous compound decisions additionally require executable data-driven decision tables
+ and targeted MC/DC reasoning so each independent condition is shown to affect the result.
+4. **Architecture boundaries are enforced.** Observer/FAR and Win32 integration are outer adapters. Archive operations
+ and format parsers are inner policy. Dependencies point inward; parser code must be independently testable without
+ loading FAR, Observer, or Win32 UI infrastructure.
+5. **The C/C++ boundary is explicit and hostile by default.** It exposes only stable C-compatible layouts, functions,
+ result codes, and documented ownership. No C++ exception, STL type, RTTI identity, allocator responsibility, or
+ implicit lifetime crosses the ABI.
+6. **C++ resources use deterministic ownership.** Prefer values and standard containers. Every acquired resource is
+ immediately owned by an RAII handle. Application code contains no naked owning allocation or release. Raw pointers
+ are non-owning; `std::unique_ptr` is the default polymorphic owner, while `std::shared_ptr` is exceptional and must
+ express a genuinely shared lifetime.
+7. **All work caused by input is bounded.** A parser defines and checks maximum sizes, counts, nesting depth, allocation
+ budget, and progress conditions before doing expensive work. Integer calculations are checked before narrowing,
+ seeking, allocating, or indexing. Input-driven recursion is replaced by iteration or given a strict depth bound.
+ There is no arbitrary whole-archive size ceiling: multi-gigabyte archives are legitimate. Declared structural
+ fields must fit the actual input, paths must fit the public ABI, and expanded metadata/index data receives a
+ separately configurable budget so a compact decompression bomb cannot exhaust the host process.
+8. **Failures are deterministic and fail closed.** Partial output is not reported as success. Cancellation, callback
+ failure, malformed input, exhaustion, and I/O errors have tested outcomes. Outermost ABI functions validate inputs,
+ initialize outputs, catch only at the boundary, and translate failures to the documented result contract.
+9. **Evidence is produced by the exact deliverable.** Unit and parser tests may use seams, but ABI integration,
+ import/export audit, packaging smoke tests, and leak tests exercise the MSVC Release DLLs that are shipped.
+10. **A green gate is never manufactured.** Threshold reductions, first-party exclusions, broad suppressions, swallowed
+ sanitizer failures, or catch-all fuzz targets are prohibited. Any necessary deviation is narrow, justified,
+ time-bounded where appropriate, and owner-reviewed.
+
+## C ABI contract
+
+Every exported function and callback must satisfy all of the following:
+
+- use `extern "C"`, an explicit calling convention, fixed-width or ABI-defined types, and fixed-layout structures;
+- version extensible structures with `StructSize` or an equivalent explicit size contract;
+- validate every pointer, buffer length, structure size, enum/range value, and callback before dereference or call;
+- initialize output structures and handles before any operation that can fail;
+- document who owns every buffer and handle, how long borrowed data remains valid, and who releases a resource;
+- never allocate in one CRT and require another module to deallocate it;
+- prohibit exceptions escaping either exported functions or host callbacks; translate internal failures once at the
+ outer boundary and keep that mapping covered by ABI tests;
+- preserve the existing symbol names, calling convention, layouts, and result semantics unless an explicitly reviewed
+ ABI version change is made.
+
+Compatibility is checked at both source and binary levels: compile-time layout assertions, real-DLL contract tests,
+exact export allowlists, import audits, and package smoke tests.
+
+## C++ ownership and resource rules
+
+- Prefer values, `std::vector`, `std::string`, and scoped resource wrappers.
+- Use `std::span`/`std::string_view` for checked borrowed ranges and references for required non-null objects.
+- Use `std::unique_ptr` only when value semantics do not fit, normally for polymorphism or optional ownership.
+- Use `std::shared_ptr` only when no single owner can be identified; record the lifetime reason in the design review.
+- Wrap `FILE*`, Win32 `HANDLE`/`HMODULE`, archive streams, zlib state, and temporary-file cleanup in move-only RAII
+ types with non-throwing destructors.
+- Do not call owning `new`, `delete`, `malloc`, `calloc`, `realloc`, or `free` in application C++. Placement new inside
+ a reviewed low-level resource abstraction is a possible deviation, not a general exception.
+- Destructors and cleanup paths must not throw. Move operations should be `noexcept` when their members permit it.
+- Avoid mutable globals. If shared state is unavoidable, define its lifetime, synchronization, and reset behavior for
+ repeated module load/unload cycles.
+
+These rules follow the C++ Core Guidelines resource-management model: automatic resource handles and RAII, raw
+pointers as non-owning views, no naked `new`/`delete`, and `unique_ptr` preferred over shared ownership.
+
+## Parser safety case
+
+Each supported format gets a small safety case in tests and, as the parser core is extracted, in its module-level
+documentation. At minimum it answers:
+
+- What identifies the format, and how are truncated or contradictory headers rejected?
+- What are the maximum accepted archive size, entry count, path length, nesting depth, metadata/index size, and
+ decompressed size? Which limits come from the format and which are defensive project limits?
+- Which additions, multiplications, casts, seeks, and range calculations can overflow or leave the input bounds?
+- Can every loop demonstrate progress and a finite upper bound? Can decompression or parsing amplify tiny input into
+ excessive CPU, memory, disk, or output?
+- How are path traversal, absolute paths, device names, alternate separators, duplicate names, and Unicode conversion
+ handled before extraction?
+- What happens on cancellation, callback failure, short read/write, close/flush failure, partial output, and host
+ unload/reload?
+
+Required tests include valid minimal and representative archives, every error category, zero/one/maximum boundaries,
+one-past-limit cases, truncation at meaningful byte positions, arithmetic edges, callback failures, cancellation, and
+resource cleanup after every failure path. Multi-gigabyte external corpora remain optional compatibility/stress input;
+small generated repository fixtures are the deterministic local contract.
+
+## Verification ladder
+
+Every layer finds a different defect class; passing one does not substitute for another.
+
+1. **Fast deterministic tests:** parser/unit tests, common archive-operation tests, and ABI contract tests.
+2. **Structural coverage:** 100% LLVM line and branch coverage over first-party production code, plus review of tests
+ that reach each branch.
+3. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on runnable x86 and x64 targets; ARM64 is
+ cross-built, analyzed, audited, and packaged, with unavailable runtime checks reported explicitly as deferred.
+4. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, and PowerShell analysis.
+ Diagnostics are fixed or narrowly justified, never globally muted. Additional services such as CodeQL may repeat or
+ extend this evidence but cannot replace a local gate.
+5. **Dynamic analysis:** MSVC AddressSanitizer where supported; clang-cl AddressSanitizer and
+ UndefinedBehaviorSanitizer as independent diagnostic builds; UMDH across repeated real-DLL operations and repeated
+ load/unload cycles. Peak private bytes and resource budgets are checked separately from leak growth.
+6. **Coverage-guided fuzzing:** every parser and its meaningful decoding surfaces receive libFuzzer targets, curated
+ seed corpora, bounded input/resources, persisted crash artifacts, and regression tests for every confirmed defect.
+7. **Binary and package assurance:** exact exports, forbidden imports, `/MT` runtime audit, BinSkim, PDB archive audit,
+ archive-content allowlists, and smoke tests of the packaged DLL bytes.
+8. **Release evidence:** clean-checkout build, pinned dependencies, full gate results, hashes/artifacts, and a reviewed
+ decision/deviation log.
+
+Coverage and fuzzing must not catch allocation exhaustion or unexpected exceptions merely to keep running. A crash,
+sanitizer report, timeout, leak, or resource-budget violation is a finding and becomes a minimized regression test.
+
+## Change protocol and traceability
+
+For each non-trivial change, preserve this chain in the issue/commit, test names, and reports:
+
+`requirement or hazard -> failing test -> implementation -> coverage -> analysis -> dynamic/fuzz evidence -> binary`
+
+The repository does not need bureaucratic documents for trivial refactors, but a reviewer must be able to answer why
+the change exists, which failure it prevents, which test demonstrates it, which binary contains it, and whether it
+changes an ABI, parser limit, dependency, or accepted risk.
+
+Security- and reliability-relevant deviations are recorded beside the affected code/configuration and in the owning
+issue or commit. Analyzer suppressions include the rule, exact scope, rationale, and a test or other evidence that
+covers the residual risk.
+
+## Standards adapted, not claimed
+
+- **C++ Core Guidelines:** normative baseline for ownership, RAII, interfaces, bounds-aware views, and simplicity.
+- **SEI CERT C++:** secure-coding review source for declarations, integers, containers, strings, memory, I/O, error
+ handling, object lifetime, concurrency, and miscellaneous security hazards.
+- **JPL Power of Ten:** adopt reviewable control flow, bounded loops, no unbounded recursion, small cohesive functions,
+ assertions/contracts, minimal preprocessor use, and warnings/static analysis. Its strict C-oriented prohibition on
+ dynamic allocation is adapted to bounded RAII allocation because archive metadata is inherently variable-sized.
+- **MISRA and formal safety standards:** they can inspire individual engineering practices, but compliance and
+ certification are explicitly out of scope. The repository will not maintain a MISRA profile or claim DO-178C,
+ IEC 61508, ISO 26262, or similar status.
+
+## Decisions to settle with the owner
+
+1. **Internal error model:** keep typed C++ exceptions inside the core and translate them only at the ABI, or migrate
+ fallible parser/application operations toward an explicit result type such as `std::expected`? Either choice must
+ preserve RAII and prohibit exceptions crossing the C boundary.
+2. **Resource budgets:** choose concrete archive, entry-count, path, index, decompressed-output, nesting, CPU/time, and
+ memory ceilings per format, including whether callers may configure them.
+3. **Shared ownership:** forbid `std::shared_ptr` entirely in first-party code unless an ADR is approved, or permit it
+ with a local lifetime rationale?
+4. **Release provenance:** whether reproducible-build comparison, SBOM, signing, and SLSA-style provenance become
+ mandatory release gates.
diff --git a/licenses/IX.txt b/licenses/IX.txt
new file mode 100644
index 0000000..6432aae
--- /dev/null
+++ b/licenses/IX.txt
@@ -0,0 +1,22 @@
+IX build system
+Source: https://github.com/pg83/ix
+Revision: 66726a904152246fbef8b27e26e878840f6d7fb7
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/licenses/zstr.txt b/licenses/zstr.txt
deleted file mode 100644
index 3c33ea6..0000000
--- a/licenses/zstr.txt
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2015 Matei David, Ontario Institute for Cancer Research
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/src/api.h b/src/api.h
index 0fa0292..c7ff66f 100644
--- a/src/api.h
+++ b/src/api.h
@@ -23,16 +23,6 @@ It includes/modifies code originally from Observer (https://github.com/lazyhamst
#define _WIN32_WINNT 0x0600
#endif
-#ifndef _WIN32_WINDOWS
-// Specifies that the minimum required platform is Windows 98.
-#define _WIN32_WINDOWS 0x0410
-#endif
-
-#ifndef _WIN32_IE
-// Specifies that the minimum required platform is Internet Explorer 7.0.
-#define _WIN32_IE 0x0700
-#endif
-
// Exclude rarely-used stuff from Windows headers.
#define WIN32_LEAN_AND_MEAN
@@ -41,7 +31,7 @@ It includes/modifies code originally from Observer (https://github.com/lazyhamst
#define MODULE_EXPORT __stdcall
// Extract progress callbacks
-typedef int (CALLBACK *ExtractProgressFunc)(HANDLE, __int64);
+typedef int(CALLBACK *ExtractProgressFunc)(HANDLE, __int64);
#pragma pack(push, 1)
@@ -93,15 +83,15 @@ struct ExtractOperationParams
ExtractProcessCallbacks Callbacks;
};
-typedef int (MODULE_EXPORT *OpenStorageFunc)(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info);
+typedef int(MODULE_EXPORT *OpenStorageFunc)(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info);
-typedef int (MODULE_EXPORT *PrepareFilesFunc)(HANDLE storage);
+typedef int(MODULE_EXPORT *PrepareFilesFunc)(HANDLE storage);
-typedef void (MODULE_EXPORT *CloseStorageFunc)(HANDLE storage);
+typedef void(MODULE_EXPORT *CloseStorageFunc)(HANDLE storage);
-typedef int (MODULE_EXPORT *GetItemFunc)(HANDLE storage, int item_index, StorageItemInfo *item_info);
+typedef int(MODULE_EXPORT *GetItemFunc)(HANDLE storage, int item_index, StorageItemInfo *item_info);
-typedef int (MODULE_EXPORT *ExtractFunc)(HANDLE storage, ExtractOperationParams params);
+typedef int(MODULE_EXPORT *ExtractFunc)(HANDLE storage, ExtractOperationParams params);
struct module_cbs
{
@@ -114,10 +104,10 @@ struct module_cbs
struct ModuleLoadParameters
{
- //IN
+ // IN
size_t StructSize;
const wchar_t *Settings;
- //OUT
+ // OUT
GUID ModuleId;
DWORD ModuleVersion;
DWORD ApiVersion;
@@ -127,12 +117,12 @@ struct ModuleLoadParameters
#pragma pack(pop)
// Function that should be exported from modules
-typedef int (MODULE_EXPORT *LoadSubModuleFunc)(ModuleLoadParameters *);
+typedef int(MODULE_EXPORT *LoadSubModuleFunc)(ModuleLoadParameters *);
-typedef void (MODULE_EXPORT *UnloadSubModuleFunc)(void);
+typedef void(MODULE_EXPORT *UnloadSubModuleFunc)(void);
-#define MAKEMODULEVERSION(mj,mn) ((mj << 16) | mn)
-#define STRBUF_SIZE(x) ( sizeof(x) / sizeof(x[0]) )
+#define MAKEMODULEVERSION(mj, mn) (((mj) << 16) | (mn))
+#define STRBUF_SIZE(x) (sizeof(x) / sizeof((x)[0]))
// Open storage return results
#define SOR_INVALID_FILE 0
diff --git a/src/archive.cpp b/src/archive.cpp
index e284497..f5e38d1 100644
--- a/src/archive.cpp
+++ b/src/archive.cpp
@@ -9,9 +9,8 @@
namespace archive
{
- archive::archive(std::unique_ptr extractor)
+ archive::archive(std::unique_ptr extractor) : extractor_(std::move(extractor))
{
- extractor_ = std::move(extractor);
}
bool starts_with_bytes(std::span data, std::span signature) noexcept
@@ -50,7 +49,7 @@ namespace archive
throw read_error();
}
- for (const auto &file: files_) {
+ for (const auto &file : files_) {
std::ranges::replace(file->path, '/', '\\');
}
}
@@ -73,46 +72,42 @@ namespace archive
}
output.exceptions(std::ofstream::failbit | std::ofstream::badbit);
- constexpr int64_t buffer_size = 128 * 1024;
+ constexpr int64_t buffer_size = 128LL * 1024;
std::vector buffer(buffer_size);
- if (!file.header.empty()) {
- output.write(file.header.data(), std::ssize(file.header));
- }
-
try {
- stream_->seekg(file.offset);
- } catch (std::ios_base::failure &) {
- throw read_error();
- }
-
- uint32_t magic = file.magic;
- int64_t bytes_left = file.compressed_body_size_in_bytes;
- while (bytes_left > 0) {
- const auto chunk_size = static_cast(std::min(bytes_left, buffer_size));
+ if (!file.header.empty()) {
+ output.write(file.header.data(), std::ssize(file.header));
+ }
try {
- stream_->read(buffer.data(), chunk_size);
+ stream_->seekg(file.offset);
} catch (std::ios_base::failure &) {
throw read_error();
}
- buffer.resize(static_cast(chunk_size));
- magic = extractor_->decrypt(magic, buffer);
+ uint32_t magic = file.magic;
+ int64_t bytes_left = file.compressed_body_size_in_bytes;
+ while (bytes_left > 0) {
+ const auto chunk_size = static_cast(std::min(bytes_left, buffer_size));
- try {
- output.write(buffer.data(), buffer.size());
- } catch (std::ios_base::failure &) {
- throw write_error();
- }
+ try {
+ stream_->read(buffer.data(), chunk_size);
+ } catch (std::ios_base::failure &) {
+ throw read_error();
+ }
- bytes_left -= chunk_size;
+ buffer.resize(static_cast(chunk_size));
+ magic = extractor_->decrypt(magic, buffer);
+ output.write(buffer.data(), static_cast(buffer.size()));
- try {
+ bytes_left -= chunk_size;
report_progress(chunk_size);
- } catch (user_interrupt &) {
- return;
}
+
+ output.close();
+ } catch (std::ios_base::failure &) {
+ throw write_error();
}
}
-}
+} // namespace archive
diff --git a/src/archive.h b/src/archive.h
index 86fdecf..04ed8d6 100644
--- a/src/archive.h
+++ b/src/archive.h
@@ -11,31 +11,31 @@ namespace archive
{
class read_error final : public std::runtime_error
{
- public:
- read_error(): runtime_error("")
+ public:
+ read_error() : runtime_error("")
{
}
};
class write_error final : public std::runtime_error
{
- public:
- write_error(): runtime_error("")
+ public:
+ write_error() : runtime_error("")
{
}
};
class user_interrupt final : public std::runtime_error
{
- public:
- user_interrupt(): runtime_error("")
+ public:
+ user_interrupt() : runtime_error("")
{
}
};
class archive final
{
- public:
+ public:
explicit archive(std::unique_ptr extractor);
extractor::archive_info open(const std::filesystem::path &path, const std::span &data);
@@ -47,9 +47,9 @@ namespace archive
void extract_file(size_t index, const std::filesystem::path &path,
const std::function &report_progress) const;
- private:
+ private:
std::unique_ptr extractor_;
std::unique_ptr stream_;
- std::vector > files_;
+ std::vector> files_;
};
-}
+} // namespace archive
diff --git a/src/core/archive_limits.h b/src/core/archive_limits.h
new file mode 100644
index 0000000..2f48261
--- /dev/null
+++ b/src/core/archive_limits.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#include
+
+namespace observer::archive_limits
+{
+ // The Observer ABI exposes 1024 UTF-16 code units. Four UTF-8 bytes per usable code unit is a safe allocation cap.
+ inline constexpr std::size_t max_path_bytes = std::size_t{4} * (1024 - 1);
+
+ // The repository corpus currently peaks at 2,205 entries. This leaves ample compatibility headroom while keeping
+ // a corrupt count from driving an unbounded allocation.
+ inline constexpr std::size_t max_entry_count = 100'000;
+} // namespace observer::archive_limits
diff --git a/src/core/compression/zlib_codec.cpp b/src/core/compression/zlib_codec.cpp
new file mode 100644
index 0000000..86aa6bd
--- /dev/null
+++ b/src/core/compression/zlib_codec.cpp
@@ -0,0 +1,92 @@
+#include "zlib_codec.h"
+
+#include
+#include
+
+#include
+
+namespace observer::compression
+{
+ namespace
+ {
+ class inflate_context final
+ {
+ public:
+ inflate_context()
+ {
+ if (inflateInit(&stream_) != Z_OK) {
+ throw error("Failed to initialize zlib decompression");
+ }
+ }
+
+ ~inflate_context()
+ {
+ static_cast(inflateEnd(&stream_));
+ }
+
+ inflate_context(const inflate_context &) = delete;
+ inflate_context &operator=(const inflate_context &) = delete;
+ inflate_context(inflate_context &&) = delete;
+ inflate_context &operator=(inflate_context &&) = delete;
+
+ [[nodiscard]] z_stream &stream() noexcept
+ {
+ return stream_;
+ }
+
+ private:
+ z_stream stream_{};
+ };
+
+ [[noreturn]] void throw_zlib_error(const char *operation, const z_stream &stream)
+ {
+ auto message = std::string(operation);
+ if (stream.msg != nullptr) {
+ message.append(": ").append(stream.msg);
+ }
+ throw error(message);
+ }
+ } // namespace
+
+ std::vector decompress_zlib(const std::span input, const std::size_t max_output_bytes)
+ {
+ inflate_context context;
+ auto &stream = context.stream();
+ std::vector output;
+ std::vector chunk(std::size_t{64} * 1024);
+ std::size_t input_offset = 0;
+
+ while (true) {
+ if (stream.avail_in == 0 && input_offset < input.size()) {
+ const auto input_size =
+ std::min(input.size() - input_offset, static_cast(std::numeric_limits::max()));
+ stream.next_in = reinterpret_cast(const_cast(input.data() + input_offset));
+ stream.avail_in = static_cast(input_size);
+ input_offset += input_size;
+ }
+
+ stream.next_out = reinterpret_cast(chunk.data());
+ stream.avail_out = static_cast(chunk.size());
+ const auto result = inflate(&stream, Z_NO_FLUSH);
+ const auto produced = chunk.size() - stream.avail_out;
+ if (produced > max_output_bytes - output.size()) {
+ throw error("zlib output exceeds the configured metadata budget");
+ }
+ output.insert(output.end(), chunk.begin(), chunk.begin() + static_cast(produced));
+
+ if (result == Z_STREAM_END) {
+ const auto consumed_input = input_offset - stream.avail_in;
+ if (consumed_input != input.size()) {
+ throw error("Trailing data after zlib stream");
+ }
+ return output;
+ }
+ if (result == Z_BUF_ERROR) {
+ throw error("Truncated zlib stream");
+ }
+ if (result != Z_OK) {
+ throw_zlib_error("zlib decompression failed", stream);
+ }
+ }
+ }
+} // namespace observer::compression
diff --git a/src/core/compression/zlib_codec.h b/src/core/compression/zlib_codec.h
new file mode 100644
index 0000000..5071d2c
--- /dev/null
+++ b/src/core/compression/zlib_codec.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace observer::compression
+{
+ class error final : public std::runtime_error
+ {
+ public:
+ using std::runtime_error::runtime_error;
+ };
+
+ [[nodiscard]] std::vector decompress_zlib(std::span input,
+ std::size_t max_output_bytes);
+} // namespace observer::compression
diff --git a/src/core/io/bounded_stream.cpp b/src/core/io/bounded_stream.cpp
new file mode 100644
index 0000000..bcaa4cb
--- /dev/null
+++ b/src/core/io/bounded_stream.cpp
@@ -0,0 +1,77 @@
+#include "bounded_stream.h"
+
+#include
+
+namespace observer::io
+{
+ bounded_stream::bounded_stream(std::istream &stream) : stream_(stream)
+ {
+ try {
+ const auto original = stream_.tellg();
+ stream_.seekg(0, std::ios::end);
+ const auto end = stream_.tellg();
+ stream_.seekg(original);
+ if (original < 0 || end < original) {
+ throw read_error();
+ }
+ size_ = static_cast(end);
+ } catch (const std::ios_base::failure &) {
+ throw read_error();
+ }
+ }
+
+ std::streamoff bounded_stream::size() const noexcept
+ {
+ return size_;
+ }
+
+ std::streamoff bounded_stream::position()
+ {
+ try {
+ const auto result = stream_.tellg();
+ if (result < 0 || result > size_) {
+ throw read_error();
+ }
+ return static_cast(result);
+ } catch (const std::ios_base::failure &) {
+ throw read_error();
+ }
+ }
+
+ std::streamoff bounded_stream::remaining()
+ {
+ return size_ - position();
+ }
+
+ void bounded_stream::seek_absolute(const std::streamoff offset)
+ {
+ if (offset < 0 || offset > size_) {
+ throw read_error();
+ }
+ try {
+ stream_.seekg(offset);
+ } catch (const std::ios_base::failure &) {
+ throw read_error();
+ }
+ if (!stream_) {
+ throw read_error();
+ }
+ }
+
+ void bounded_stream::read_exact(char *destination, const std::size_t byte_count)
+ {
+ if (byte_count > static_cast(std::numeric_limits::max()) ||
+ byte_count > static_cast(remaining())) {
+ throw read_error();
+ }
+ const auto stream_size = static_cast(byte_count);
+ try {
+ stream_.read(destination, stream_size);
+ } catch (const std::ios_base::failure &) {
+ throw read_error();
+ }
+ if (stream_.gcount() != stream_size) {
+ throw read_error();
+ }
+ }
+} // namespace observer::io
diff --git a/src/core/io/bounded_stream.h b/src/core/io/bounded_stream.h
new file mode 100644
index 0000000..d5b487c
--- /dev/null
+++ b/src/core/io/bounded_stream.h
@@ -0,0 +1,43 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace observer::io
+{
+ class read_error final : public std::runtime_error
+ {
+ public:
+ read_error() : std::runtime_error("bounded stream read failed")
+ {
+ }
+ };
+
+ class bounded_stream final
+ {
+ public:
+ explicit bounded_stream(std::istream &stream);
+
+ [[nodiscard]] std::streamoff size() const noexcept;
+ [[nodiscard]] std::streamoff position();
+ [[nodiscard]] std::streamoff remaining();
+ void seek_absolute(std::streamoff offset);
+ void read_exact(char *destination, std::size_t byte_count);
+
+ template
+ requires std::is_trivially_copyable_v
+ [[nodiscard]] Value read_trivial()
+ {
+ Value value{};
+ read_exact(reinterpret_cast(&value), sizeof(value));
+ return value;
+ }
+
+ private:
+ std::istream &stream_;
+ std::streamoff size_ = 0;
+ };
+} // namespace observer::io
diff --git a/src/dll.cpp b/src/dll.cpp
index 389606f..9a70619 100644
--- a/src/dll.cpp
+++ b/src/dll.cpp
@@ -7,16 +7,38 @@
#include
#include
-void copy_string(const std::wstring &source, wchar_t *destination, const std::size_t max_len)
+#ifdef _DEBUG
+#include
+#include
+#endif
+
+namespace
{
- if (wcscpy_s(destination, max_len, source.c_str()) != 0) {
- throw std::runtime_error("CopyString failed");
+#ifdef _DEBUG
+ void configure_debug_crt() noexcept
+ {
+ _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT);
+ _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE);
+ _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR);
+ _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE);
+ _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR);
+ _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE);
+ _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR);
}
+#endif
+} // namespace
+
+void copy_string(const std::wstring &source, wchar_t *destination, const std::size_t max_len) noexcept
+{
+ static_cast(wcsncpy_s(destination, max_len, source.c_str(), _TRUNCATE));
}
extern "C" int MODULE_EXPORT OpenStorage(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info)
{
- if (storage == nullptr) return SOR_INVALID_FILE;
+ if (storage == nullptr || info == nullptr || params.FilePath == nullptr)
+ return SOR_INVALID_FILE;
+
+ *storage = nullptr;
const std::filesystem::path path(params.FilePath);
@@ -58,7 +80,7 @@ extern "C" int MODULE_EXPORT PrepareFiles(HANDLE storage)
return FALSE;
}
- const auto archive = static_cast(storage);
+ auto *archive = static_cast(storage);
try {
archive->prepare_files();
} catch (std::runtime_error &) {
@@ -72,11 +94,11 @@ extern "C" int MODULE_EXPORT PrepareFiles(HANDLE storage)
extern "C" int MODULE_EXPORT GetItem(HANDLE storage, int item_index, StorageItemInfo *item_info)
{
- if (storage == nullptr || item_index < 0) {
+ if (storage == nullptr || item_index < 0 || item_info == nullptr) {
return GET_ITEM_ERROR;
}
- const auto archive = static_cast(storage);
+ const auto *archive = static_cast(storage);
try {
const auto &file = archive->get_file(item_index);
const auto header_size = std::ssize(file.header);
@@ -93,28 +115,25 @@ extern "C" int MODULE_EXPORT GetItem(HANDLE storage, int item_index, StorageItem
}
} catch (std::out_of_range &) {
return GET_ITEM_NOMOREITEMS;
- } catch (std::runtime_error &) {
- return GET_ITEM_ERROR;
}
return GET_ITEM_OK;
}
-// ReSharper disable once CppParameterMayBeConst
extern "C" int MODULE_EXPORT ExtractItem(HANDLE storage, ExtractOperationParams params)
{
- if (storage == nullptr || params.ItemIndex < 0 || params.DestPath == nullptr) {
+ if (storage == nullptr || params.ItemIndex < 0 || params.DestPath == nullptr ||
+ params.Callbacks.FileProgress == nullptr) {
return SER_ERROR_SYSTEM;
}
- auto report_progress = [callbacks = params.Callbacks](const int64_t bytes_read)
- {
+ auto report_progress = [callbacks = params.Callbacks](const int64_t bytes_read) {
if (!callbacks.FileProgress(callbacks.signalContext, bytes_read)) {
throw archive::user_interrupt();
}
};
- const auto archive = static_cast(storage);
+ const auto *archive = static_cast(storage);
try {
archive->extract_file(params.ItemIndex, params.DestPath, report_progress);
} catch (archive::user_interrupt &) {
@@ -134,6 +153,13 @@ extern "C" int MODULE_EXPORT ExtractItem(HANDLE storage, ExtractOperationParams
extern "C" int MODULE_EXPORT LoadSubModule(ModuleLoadParameters *LoadParams) noexcept
{
+#ifdef _DEBUG
+ configure_debug_crt();
+#endif
+ if (LoadParams == nullptr) {
+ return FALSE;
+ }
+
const auto [id, major_version, minor_version] = extractor::get_version_info();
const auto [id1, id2, id3, id4] = id;
LoadParams->ModuleId = {id1, id2, id3, {id4[0], id4[1], id4[2], id4[3], id4[4], id4[5], id4[6], id4[7]}};
diff --git a/src/fuzz/archive.cpp b/src/fuzz/archive.cpp
new file mode 100644
index 0000000..bc4fbac
--- /dev/null
+++ b/src/fuzz/archive.cpp
@@ -0,0 +1,51 @@
+#include "../modules/extractor.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+ std::uint32_t initial_magic(const std::uint8_t *data, const std::size_t size) noexcept
+ {
+ std::uint32_t magic = 0;
+ if (size > 0) {
+ std::memcpy(&magic, data, std::min(size, sizeof(magic)));
+ }
+ return magic;
+ }
+} // namespace
+
+extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, const std::size_t size)
+{
+ extractor::extractor parser;
+
+ std::vector body(size);
+ if (size > 0) {
+ std::memcpy(body.data(), data, size);
+ }
+ static_cast(parser.decrypt(initial_magic(data, size), body));
+
+ const std::string bytes(reinterpret_cast(data), size);
+ std::istringstream stream(bytes, std::ios::in | std::ios::binary);
+
+ try {
+ static_cast(parser.list_files(stream));
+ } catch (const std::invalid_argument &) {
+ // Invalid numeric fields are expected.
+ return 0;
+ } catch (const std::out_of_range &) {
+ // Invalid numeric fields are expected. std::length_error is deliberately not caught.
+ return 0;
+ } catch (const std::runtime_error &) {
+ // Malformed/truncated archive input is expected. Allocation failures must still escape.
+ return 0;
+ }
+
+ return 0;
+}
diff --git a/src/fuzz/corpus/pickle/external-catch-canvas-index.hex b/src/fuzz/corpus/pickle/external-catch-canvas-index.hex
new file mode 100644
index 0000000..b66d546
--- /dev/null
+++ b/src/fuzz/corpus/pickle/external-catch-canvas-index.hex
@@ -0,0 +1 @@
+80027d710128581a000000696d616765732f6367732f657374656c6c655f7365782e6a70675d71028a041bba62424aad6c5242550087710361581e000000696d616765732f6367732f657374656c6c65626174685f7365782e6a70675d71048a04594d51424a6fab4f425500877105615816000000696d616765732f6367732f6e616f5f7365782e6a706771065d71078a041b6573424a481f49425500877108615817000000696d616765732f6367732f6461776e5f7365782e6a70675d71098a04714242424a954c5142550087710a61752e
diff --git a/src/fuzz/corpus/pickle/invalid-empty-int.pickle b/src/fuzz/corpus/pickle/invalid-empty-int.pickle
new file mode 100644
index 0000000..db1a5a0
--- /dev/null
+++ b/src/fuzz/corpus/pickle/invalid-empty-int.pickle
@@ -0,0 +1 @@
+I
diff --git a/src/fuzz/corpus/pickle/invalid-mark-position.hex b/src/fuzz/corpus/pickle/invalid-mark-position.hex
new file mode 100644
index 0000000..437c486
--- /dev/null
+++ b/src/fuzz/corpus/pickle/invalid-mark-position.hex
@@ -0,0 +1 @@
+5d4e28616c
diff --git a/src/fuzz/corpus/pickle/none.pickle b/src/fuzz/corpus/pickle/none.pickle
new file mode 100644
index 0000000..f0e2152
--- /dev/null
+++ b/src/fuzz/corpus/pickle/none.pickle
@@ -0,0 +1 @@
+N.
diff --git a/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex b/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex
new file mode 100644
index 0000000..16e68d1
--- /dev/null
+++ b/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex
@@ -0,0 +1 @@
+5250412d332e3020303030303030303030303030303134302034323432343234320a000000000000000000000000000000000000000000000000000000000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff78da6b60aa2d64d48810636060c8cc4d4c4f2dd64f4e2fd6cf4bcc8f2f4eadd0cb2a488f2d64ea6261727272f2727276720a65682f644e2cd50300c4ce1042
diff --git a/src/fuzz/corpus/renpy/valid-rpa2.hex b/src/fuzz/corpus/renpy/valid-rpa2.hex
new file mode 100644
index 0000000..5daa12f
--- /dev/null
+++ b/src/fuzz/corpus/renpy/valid-rpa2.hex
@@ -0,0 +1 @@
+5250412d322e3020303030303030303030303030303032310a00000000000000627801ab0d654c8cf556f0666c4b2cd603001aca03d1
diff --git a/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex b/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex
new file mode 100644
index 0000000..5deed84
--- /dev/null
+++ b/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex
@@ -0,0 +1 @@
+52 47 53 53 41 44 00 03 01 00 00 00 0d 00 00 00 0d 00 00 00 0d 00 00 00 f3 ff ff ff
diff --git a/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex b/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex
new file mode 100644
index 0000000..f7833e9
--- /dev/null
+++ b/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex
@@ -0,0 +1 @@
+52475353414400032b440000b9650200a66502009915020099650200c1176370ee0c6173da266a61f4046174e317715ca23c676cea0a752ed62b4586650200000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
diff --git a/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex b/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex
new file mode 100644
index 0000000..1a9f067
--- /dev/null
+++ b/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex
@@ -0,0 +1 @@
+5247535341440003010000002d0000000d000000745634120d0000006d0c0000001a
diff --git a/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex b/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex
new file mode 100644
index 0000000..290172c
--- /dev/null
+++ b/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex
@@ -0,0 +1 @@
+00 00 00 00 ff ff ff 7f
diff --git a/src/fuzz/corpus/zanzarah/declared-path-overflow.hex b/src/fuzz/corpus/zanzarah/declared-path-overflow.hex
new file mode 100644
index 0000000..147edbb
--- /dev/null
+++ b/src/fuzz/corpus/zanzarah/declared-path-overflow.hex
@@ -0,0 +1 @@
+00 00 00 00 01 00 00 00 ff ff ff 7f
diff --git a/src/fuzz/corpus/zanzarah/external-smallest-entry.hex b/src/fuzz/corpus/zanzarah/external-smallest-entry.hex
new file mode 100644
index 0000000..b6c8d4b
--- /dev/null
+++ b/src/fuzz/corpus/zanzarah/external-smallest-entry.hex
@@ -0,0 +1 @@
+0000000001000000280000002e2e5c5245534f55524345535c54455854555245535c4d4f44454c535c48474c303054482e424d50000000001400000000000000000102030405060708090a0b0c0d0e0f
diff --git a/src/fuzz/corpus/zanzarah/valid-pak.hex b/src/fuzz/corpus/zanzarah/valid-pak.hex
new file mode 100644
index 0000000..99bd93c
--- /dev/null
+++ b/src/fuzz/corpus/zanzarah/valid-pak.hex
@@ -0,0 +1 @@
+0000000001000000010000006100000000050000007856341262
diff --git a/src/fuzz/pickle.cpp b/src/fuzz/pickle.cpp
new file mode 100644
index 0000000..4500b4b
--- /dev/null
+++ b/src/fuzz/pickle.cpp
@@ -0,0 +1,29 @@
+#include "../modules/renpy/pickle.h"
+
+#include
+#include
+#include
+#include
+
+extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, const std::size_t size)
+{
+ const auto bytes = std::span{
+ reinterpret_cast(data),
+ size,
+ };
+
+ try {
+ static_cast(pickle::loads(bytes));
+ } catch (const std::invalid_argument &) {
+ // Invalid numeric Pickle input is expected.
+ return 0;
+ } catch (const std::out_of_range &) {
+ // Invalid numeric Pickle input is expected. std::length_error is deliberately not caught.
+ return 0;
+ } catch (const std::runtime_error &) {
+ // Invalid Pickle structure is expected. Allocation failures must still escape.
+ return 0;
+ }
+
+ return 0;
+}
diff --git a/src/modules/extractor.h b/src/modules/extractor.h
index 5b5e566..fa15456 100644
--- a/src/modules/extractor.h
+++ b/src/modules/extractor.h
@@ -1,5 +1,6 @@
#pragma once
+#include
#include
#include
#include
@@ -27,8 +28,8 @@ namespace extractor
class read_error final : public std::runtime_error
{
- public:
- read_error(): runtime_error("")
+ public:
+ read_error() : runtime_error("")
{
}
};
@@ -44,23 +45,23 @@ namespace extractor
{
std::string path;
std::string header;
- int64_t offset;
- int64_t compressed_body_size_in_bytes;
- int64_t uncompressed_body_size_in_bytes;
+ int64_t offset = 0;
+ int64_t compressed_body_size_in_bytes = 0;
+ int64_t uncompressed_body_size_in_bytes = 0;
uint32_t magic = 0;
};
class extractor
{
- public:
+ public:
virtual ~extractor() = default;
- static std::vector get_signature() noexcept;
+ static std::vector get_signature();
- archive_info get_archive_info(const std::span &data) noexcept;
+ archive_info get_archive_info(const std::span &data);
- std::vector > list_files(std::ifstream &stream);
+ std::vector> list_files(std::istream &stream);
uint32_t decrypt(uint32_t magic, std::vector &data) const;
};
-}
+} // namespace extractor
diff --git a/src/modules/renpy/pickle.cpp b/src/modules/renpy/pickle.cpp
index f3893bf..3ee65b0 100644
--- a/src/modules/renpy/pickle.cpp
+++ b/src/modules/renpy/pickle.cpp
@@ -1,43 +1,80 @@
#include "pickle.h"
+#include
+#include
+#include
+#include
+
namespace pickle
{
+ namespace
+ {
+ list clone_list(const list &source)
+ {
+ list result;
+ result.reserve(source.size());
+ std::ranges::transform(source, std::back_inserter(result), [](const auto &item) { return clone(*item); });
+ return result;
+ }
+
+ dict clone_dict(const dict &source)
+ {
+ dict result;
+ result.reserve(source.size());
+ for (const auto &[key, item] : source) {
+ result.emplace(key, clone(*item));
+ }
+ return result;
+ }
+ } // namespace
+
+ value_ptr clone(const value &source)
+ {
+ switch (source.get_type()) {
+ case value::type::none:
+ return value::none();
+ case value::type::bool_:
+ return value::boolean(source.as_bool());
+ case value::type::int64:
+ return value::int64(source.as_int64());
+ case value::type::float64:
+ return value::float64(source.as_float64());
+ case value::type::bytes:
+ return value::bytes(source.as_string());
+ case value::type::string:
+ return value::string(source.as_string());
+ case value::type::list:
+ return value::list(clone_list(source.as_list()));
+ case value::type::dict:
+ return value::dict(clone_dict(source.as_dict()));
+ case value::type::tuple:
+ return value::tuple(clone_list(source.as_tuple()));
+ default:
+ throw std::runtime_error("Unsupported pickle value type");
+ }
+ }
+
// Pickle opcodes (subset needed for basic functionality)
namespace opcodes
{
constexpr uint8_t MARK = '(';
constexpr uint8_t STOP = '.';
- constexpr uint8_t POP = '0';
- constexpr uint8_t POP_MARK = '1';
- constexpr uint8_t DUP = '2';
- constexpr uint8_t FLOAT = 'F';
constexpr uint8_t INT = 'I';
constexpr uint8_t BININT = 'J';
constexpr uint8_t BININT1 = 'K';
constexpr uint8_t BININT2 = 'M';
constexpr uint8_t NONE = 'N';
- constexpr uint8_t PERSID = 'P';
- constexpr uint8_t BINPERSID = 'Q';
- constexpr uint8_t REDUCE = 'R';
- constexpr uint8_t STRING = 'S';
constexpr uint8_t BINSTRING = 'T';
constexpr uint8_t SHORT_BINSTRING = 'U';
- constexpr uint8_t UNICODE = 'V';
constexpr uint8_t BINUNICODE = 'X';
constexpr uint8_t APPEND = 'a';
- constexpr uint8_t BUILD = 'b';
- constexpr uint8_t GLOBAL = 'c';
constexpr uint8_t DICT = 'd';
constexpr uint8_t EMPTY_DICT = '}';
constexpr uint8_t APPENDS = 'e';
- constexpr uint8_t GET = 'g';
constexpr uint8_t BINGET = 'h';
- constexpr uint8_t INST = 'i';
constexpr uint8_t LONG_BINGET = 'j';
constexpr uint8_t LIST = 'l';
constexpr uint8_t EMPTY_LIST = ']';
- constexpr uint8_t OBJ = 'o';
- constexpr uint8_t PUT = 'p';
constexpr uint8_t BINPUT = 'q';
constexpr uint8_t LONG_BINPUT = 'r';
constexpr uint8_t SETITEM = 's';
@@ -48,17 +85,12 @@ namespace pickle
// Protocol 2
constexpr uint8_t PROTO = 0x80;
- constexpr uint8_t NEWOBJ = 0x81;
- constexpr uint8_t EXT1 = 0x82;
- constexpr uint8_t EXT2 = 0x83;
- constexpr uint8_t EXT4 = 0x84;
constexpr uint8_t TUPLE1 = 0x85;
constexpr uint8_t TUPLE2 = 0x86;
constexpr uint8_t TUPLE3 = 0x87;
constexpr uint8_t NEWTRUE = 0x88;
constexpr uint8_t NEWFALSE = 0x89;
constexpr uint8_t LONG1 = 0x8a;
- constexpr uint8_t LONG4 = 0x8b;
// Protocol 3
constexpr uint8_t BINBYTES = 'B';
@@ -66,16 +98,9 @@ namespace pickle
// Protocol 4
constexpr uint8_t SHORT_BINUNICODE = 0x8c;
- constexpr uint8_t BINUNICODE8 = 0x8d;
- constexpr uint8_t BINBYTES8 = 0x8e;
- constexpr uint8_t EMPTY_SET = 0x8f;
- constexpr uint8_t ADDITEMS = 0x90;
- constexpr uint8_t FROZENSET = 0x91;
- constexpr uint8_t NEWOBJ_EX = 0x92;
- constexpr uint8_t STACK_GLOBAL = 0x93;
constexpr uint8_t MEMOIZE = 0x94;
constexpr uint8_t FRAME = 0x95;
- }
+ } // namespace opcodes
uint8_t parser::read_byte()
{
@@ -90,8 +115,7 @@ namespace pickle
if (pos_ + 2 > data_.size()) {
throw std::runtime_error("Unexpected end of pickle data");
}
- const uint16_t result = static_cast(data_[pos_]) |
- static_cast(data_[pos_ + 1]) << 8;
+ const uint16_t result = static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8;
pos_ += 2;
return result;
}
@@ -101,8 +125,7 @@ namespace pickle
if (pos_ + 4 > data_.size()) {
throw std::runtime_error("Unexpected end of pickle data");
}
- const uint32_t result = static_cast(data_[pos_]) |
- static_cast(data_[pos_ + 1]) << 8 |
+ const uint32_t result = static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8 |
static_cast(data_[pos_ + 2]) << 16 |
static_cast(data_[pos_ + 3]) << 24;
pos_ += 4;
@@ -114,14 +137,11 @@ namespace pickle
if (pos_ + 8 > data_.size()) {
throw std::runtime_error("Unexpected end of pickle data");
}
- const uint64_t result = static_cast(data_[pos_]) |
- static_cast(data_[pos_ + 1]) << 8 |
- static_cast(data_[pos_ + 2]) << 16 |
- static_cast(data_[pos_ + 3]) << 24 |
- static_cast(data_[pos_ + 4]) << 32 |
- static_cast(data_[pos_ + 5]) << 40 |
- static_cast(data_[pos_ + 6]) << 48 |
- static_cast(data_[pos_ + 7]) << 56;
+ const uint64_t result =
+ static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8 |
+ static_cast(data_[pos_ + 2]) << 16 | static_cast(data_[pos_ + 3]) << 24 |
+ static_cast(data_[pos_ + 4]) << 32 | static_cast(data_[pos_ + 5]) << 40 |
+ static_cast(data_[pos_ + 6]) << 48 | static_cast(data_[pos_ + 7]) << 56;
pos_ += 8;
return result;
}
@@ -161,6 +181,9 @@ namespace pickle
throw std::runtime_error("No mark on stack");
}
const size_t mark_pos = mark_stack_.back();
+ if (mark_pos > stack_.size()) {
+ throw std::runtime_error("Invalid mark position");
+ }
mark_stack_.pop_back();
list result;
@@ -175,375 +198,344 @@ namespace pickle
value_ptr parser::parse_value()
{
switch (uint8_t opcode = read_byte()) {
- case opcodes::MARK:
- push_mark();
- break;
+ case opcodes::MARK:
+ push_mark();
+ break;
- case opcodes::STOP:
- if (stack_.size() != 1) {
- throw std::runtime_error("Invalid stack size at end of pickle");
- }
- return std::move(stack_[0]);
+ case opcodes::STOP:
+ if (stack_.size() != 1) {
+ throw std::runtime_error("Invalid stack size at end of pickle");
+ }
+ return std::move(stack_[0]);
- case opcodes::NONE:
- stack_.push_back(value::none());
- break;
+ case opcodes::NONE:
+ stack_.push_back(value::none());
+ break;
- case opcodes::NEWTRUE:
- stack_.push_back(value::boolean(true));
- break;
+ case opcodes::NEWTRUE:
+ stack_.push_back(value::boolean(true));
+ break;
- case opcodes::NEWFALSE:
- stack_.push_back(value::boolean(false));
- break;
+ case opcodes::NEWFALSE:
+ stack_.push_back(value::boolean(false));
+ break;
- case opcodes::BININT:
- stack_.push_back(value::int64(static_cast(read_uint32_le())));
- break;
+ case opcodes::BININT:
+ stack_.push_back(value::int64(static_cast(read_uint32_le())));
+ break;
- case opcodes::BININT1:
- stack_.push_back(value::int64(read_byte()));
- break;
+ case opcodes::BININT1:
+ stack_.push_back(value::int64(read_byte()));
+ break;
- case opcodes::BININT2:
- stack_.push_back(value::int64(read_uint16_le()));
- break;
+ case opcodes::BININT2:
+ stack_.push_back(value::int64(read_uint16_le()));
+ break;
- case opcodes::INT:
- {
- std::string int_str = read_line();
- if (int_str.back() == 'L') {
- int_str.pop_back(); // Remove trailing L
- }
- int64_t val = std::stoll(int_str);
- stack_.push_back(value::int64(val));
- break;
+ case opcodes::INT: {
+ std::string int_str = read_line();
+ if (int_str.empty()) {
+ throw std::runtime_error("Empty INT opcode argument");
}
-
- case opcodes::BINFLOAT:
- {
- uint64_t bits = read_uint64_le();
- double val;
- std::memcpy(&val, &bits, sizeof(double));
- stack_.push_back(value::float64(val));
- break;
+ if (int_str.back() == 'L') {
+ int_str.pop_back(); // Remove trailing L
}
+ int64_t val = std::stoll(int_str);
+ stack_.push_back(value::int64(val));
+ break;
+ }
- case opcodes::SHORT_BINSTRING:
- {
- uint8_t length = read_byte();
- stack_.push_back(value::string(read_string(length)));
- break;
- }
+ case opcodes::BINFLOAT: {
+ const uint64_t bits = std::byteswap(read_uint64_le());
+ double val;
+ std::memcpy(&val, &bits, sizeof(double));
+ stack_.push_back(value::float64(val));
+ break;
+ }
- case opcodes::BINSTRING:
- {
- uint32_t length = read_uint32_le();
- stack_.push_back(value::string(read_string(length)));
- break;
- }
+ case opcodes::SHORT_BINSTRING: {
+ uint8_t length = read_byte();
+ stack_.push_back(value::string(read_string(length)));
+ break;
+ }
- case opcodes::SHORT_BINUNICODE:
- {
- uint8_t length = read_byte();
- stack_.push_back(value::string(read_string(length)));
- break;
- }
+ case opcodes::BINSTRING: {
+ uint32_t length = read_uint32_le();
+ stack_.push_back(value::string(read_string(length)));
+ break;
+ }
- case opcodes::BINUNICODE:
- {
- uint32_t length = read_uint32_le();
- stack_.push_back(value::string(read_string(length)));
- break;
- }
+ case opcodes::SHORT_BINUNICODE: {
+ uint8_t length = read_byte();
+ stack_.push_back(value::string(read_string(length)));
+ break;
+ }
- case opcodes::SHORT_BINBYTES:
- {
- uint8_t length = read_byte();
- stack_.push_back(value::bytes(read_string(length)));
- break;
- }
+ case opcodes::BINUNICODE: {
+ uint32_t length = read_uint32_le();
+ stack_.push_back(value::string(read_string(length)));
+ break;
+ }
- case opcodes::BINBYTES:
- {
- uint32_t length = read_uint32_le();
- stack_.push_back(value::bytes(read_string(length)));
- break;
- }
+ case opcodes::SHORT_BINBYTES: {
+ uint8_t length = read_byte();
+ stack_.push_back(value::bytes(read_string(length)));
+ break;
+ }
- case opcodes::EMPTY_LIST:
- stack_.push_back(value::list({}));
- break;
+ case opcodes::BINBYTES: {
+ uint32_t length = read_uint32_le();
+ stack_.push_back(value::bytes(read_string(length)));
+ break;
+ }
- case opcodes::APPEND:
- {
- if (stack_.size() < 2) {
- throw std::runtime_error("Not enough items on stack for APPEND");
- }
- auto item = std::move(stack_.back());
- stack_.pop_back();
- auto &list_val = stack_.back();
- if (list_val->get_type() != value::type::list) {
- throw std::runtime_error("APPEND target is not a list");
- }
- auto &list_data = const_cast(list_val->as_list());
- list_data.push_back(std::move(item));
- break;
- }
+ case opcodes::EMPTY_LIST:
+ stack_.push_back(value::list({}));
+ break;
- case opcodes::APPENDS:
- {
- auto items = pop_to_mark();
- if (stack_.empty()) {
- throw std::runtime_error("No list on stack for APPENDS");
- }
- auto &list_val = stack_.back();
- if (list_val->get_type() != value::type::list) {
- throw std::runtime_error("APPENDS target is not a list");
- }
- auto &list_data = const_cast(list_val->as_list());
- for (auto &item: items) {
- list_data.push_back(std::move(item));
- }
- break;
+ case opcodes::APPEND: {
+ if (stack_.size() < 2) {
+ throw std::runtime_error("Not enough items on stack for APPEND");
+ }
+ auto item = std::move(stack_.back());
+ stack_.pop_back();
+ const auto &list_val = stack_.back();
+ if (list_val->get_type() != value::type::list) {
+ throw std::runtime_error("APPEND target is not a list");
}
+ auto &list_data = const_cast(list_val->as_list());
+ list_data.push_back(std::move(item));
+ break;
+ }
- case opcodes::LIST:
- {
- auto items = pop_to_mark();
- stack_.push_back(value::list(std::move(items)));
- break;
+ case opcodes::APPENDS: {
+ auto items = pop_to_mark();
+ if (stack_.empty()) {
+ throw std::runtime_error("No list on stack for APPENDS");
}
+ const auto &list_val = stack_.back();
+ if (list_val->get_type() != value::type::list) {
+ throw std::runtime_error("APPENDS target is not a list");
+ }
+ auto &list_data = const_cast(list_val->as_list());
+ std::ranges::move(items, std::back_inserter(list_data));
+ break;
+ }
- case opcodes::EMPTY_TUPLE:
- stack_.push_back(value::tuple({}));
- break;
+ case opcodes::LIST: {
+ auto items = pop_to_mark();
+ stack_.push_back(value::list(std::move(items)));
+ break;
+ }
- case opcodes::TUPLE:
- {
- auto items = pop_to_mark();
- stack_.push_back(value::tuple(std::move(items)));
- break;
- }
+ case opcodes::EMPTY_TUPLE:
+ stack_.push_back(value::tuple({}));
+ break;
- case opcodes::TUPLE1:
- {
- if (stack_.empty()) {
- throw std::runtime_error("Not enough items on stack for TUPLE1");
- }
- auto item = std::move(stack_.back());
- stack_.pop_back();
- list tuple_items;
- tuple_items.push_back(std::move(item));
- stack_.push_back(value::tuple(std::move(tuple_items)));
- break;
+ case opcodes::TUPLE: {
+ auto items = pop_to_mark();
+ stack_.push_back(value::tuple(std::move(items)));
+ break;
+ }
+
+ case opcodes::TUPLE1: {
+ if (stack_.empty()) {
+ throw std::runtime_error("Not enough items on stack for TUPLE1");
}
+ auto item = std::move(stack_.back());
+ stack_.pop_back();
+ list tuple_items;
+ tuple_items.push_back(std::move(item));
+ stack_.push_back(value::tuple(std::move(tuple_items)));
+ break;
+ }
- case opcodes::TUPLE2:
- {
- if (stack_.size() < 2) {
- throw std::runtime_error("Not enough items on stack for TUPLE2");
- }
- auto item2 = std::move(stack_.back());
- stack_.pop_back();
- auto item1 = std::move(stack_.back());
- stack_.pop_back();
- list tuple_items;
- tuple_items.push_back(std::move(item1));
- tuple_items.push_back(std::move(item2));
- stack_.push_back(value::tuple(std::move(tuple_items)));
- break;
+ case opcodes::TUPLE2: {
+ if (stack_.size() < 2) {
+ throw std::runtime_error("Not enough items on stack for TUPLE2");
}
+ auto item2 = std::move(stack_.back());
+ stack_.pop_back();
+ auto item1 = std::move(stack_.back());
+ stack_.pop_back();
+ list tuple_items;
+ tuple_items.push_back(std::move(item1));
+ tuple_items.push_back(std::move(item2));
+ stack_.push_back(value::tuple(std::move(tuple_items)));
+ break;
+ }
- case opcodes::TUPLE3:
- {
- if (stack_.size() < 3) {
- throw std::runtime_error("Not enough items on stack for TUPLE3");
- }
- auto item3 = std::move(stack_.back());
- stack_.pop_back();
- auto item2 = std::move(stack_.back());
- stack_.pop_back();
- auto item1 = std::move(stack_.back());
- stack_.pop_back();
- list tuple_items;
- tuple_items.push_back(std::move(item1));
- tuple_items.push_back(std::move(item2));
- tuple_items.push_back(std::move(item3));
- stack_.push_back(value::tuple(std::move(tuple_items)));
- break;
+ case opcodes::TUPLE3: {
+ if (stack_.size() < 3) {
+ throw std::runtime_error("Not enough items on stack for TUPLE3");
}
+ auto item3 = std::move(stack_.back());
+ stack_.pop_back();
+ auto item2 = std::move(stack_.back());
+ stack_.pop_back();
+ auto item1 = std::move(stack_.back());
+ stack_.pop_back();
+ list tuple_items;
+ tuple_items.push_back(std::move(item1));
+ tuple_items.push_back(std::move(item2));
+ tuple_items.push_back(std::move(item3));
+ stack_.push_back(value::tuple(std::move(tuple_items)));
+ break;
+ }
- case opcodes::EMPTY_DICT:
- stack_.push_back(value::dict({}));
- break;
+ case opcodes::EMPTY_DICT:
+ stack_.push_back(value::dict({}));
+ break;
- case opcodes::DICT:
- {
- auto items = pop_to_mark();
- if (items.size() % 2 != 0) {
- throw std::runtime_error("Odd number of items for DICT");
- }
- dict dict_data;
- for (size_t i = 0; i < items.size(); i += 2) {
- if (items[i]->get_type() != value::type::string) {
- throw std::runtime_error("Dict key must be string");
- }
- std::string key = items[i]->as_string();
- dict_data[std::move(key)] = std::move(items[i + 1]);
- }
- stack_.push_back(value::dict(std::move(dict_data)));
- break;
+ case opcodes::DICT: {
+ auto items = pop_to_mark();
+ if (items.size() % 2 != 0) {
+ throw std::runtime_error("Odd number of items for DICT");
}
-
- case opcodes::SETITEM:
- {
- if (stack_.size() < 3) {
- throw std::runtime_error("Not enough items on stack for SETITEM");
- }
- auto val = std::move(stack_.back());
- stack_.pop_back();
- auto key = std::move(stack_.back());
- stack_.pop_back();
- auto &dict_val = stack_.back();
-
- if (dict_val->get_type() != value::type::dict) {
- throw std::runtime_error("SETITEM target is not a dict");
- }
- if (key->get_type() != value::type::string) {
+ dict dict_data;
+ for (size_t i = 0; i < items.size(); i += 2) {
+ if (items[i]->get_type() != value::type::string) {
throw std::runtime_error("Dict key must be string");
}
+ std::string key = items[i]->as_string();
+ dict_data[std::move(key)] = std::move(items[i + 1]);
+ }
+ stack_.push_back(value::dict(std::move(dict_data)));
+ break;
+ }
- auto &dict_data = const_cast(dict_val->as_dict());
- dict_data[key->as_string()] = std::move(val);
- break;
+ case opcodes::SETITEM: {
+ if (stack_.size() < 3) {
+ throw std::runtime_error("Not enough items on stack for SETITEM");
+ }
+ auto val = std::move(stack_.back());
+ stack_.pop_back();
+ auto key = std::move(stack_.back());
+ stack_.pop_back();
+ const auto &dict_val = stack_.back();
+
+ if (dict_val->get_type() != value::type::dict) {
+ throw std::runtime_error("SETITEM target is not a dict");
+ }
+ if (key->get_type() != value::type::string) {
+ throw std::runtime_error("Dict key must be string");
}
- case opcodes::SETITEMS:
- {
- auto items = pop_to_mark();
- if (items.size() % 2 != 0) {
- throw std::runtime_error("Odd number of items for SETITEMS");
- }
- if (stack_.empty()) {
- throw std::runtime_error("No dict on stack for SETITEMS");
- }
- auto &dict_val = stack_.back();
- if (dict_val->get_type() != value::type::dict) {
- throw std::runtime_error("SETITEMS target is not a dict");
- }
+ auto &dict_data = const_cast(dict_val->as_dict());
+ dict_data[key->as_string()] = std::move(val);
+ break;
+ }
- auto &dict_data = const_cast(dict_val->as_dict());
- for (size_t i = 0; i < items.size(); i += 2) {
- if (items[i]->get_type() != value::type::string) {
- throw std::runtime_error("Dict key must be string");
- }
- std::string key = items[i]->as_string();
- dict_data[std::move(key)] = std::move(items[i + 1]);
- }
- break;
+ case opcodes::SETITEMS: {
+ auto items = pop_to_mark();
+ if (items.size() % 2 != 0) {
+ throw std::runtime_error("Odd number of items for SETITEMS");
}
-
- case opcodes::BINPUT:
- {
- uint8_t memo_id = read_byte();
- if (stack_.empty()) {
- throw std::runtime_error("No item on stack for BINPUT");
- }
- // For simplicity, we create a copy for memo storage
- // In a full implementation, you'd want to share the object
- memo_[memo_id] = nullptr; // Placeholder for now
- break;
+ if (stack_.empty()) {
+ throw std::runtime_error("No dict on stack for SETITEMS");
+ }
+ const auto &dict_val = stack_.back();
+ if (dict_val->get_type() != value::type::dict) {
+ throw std::runtime_error("SETITEMS target is not a dict");
}
- case opcodes::LONG_BINPUT:
- {
- uint32_t memo_id = read_uint32_le();
- if (stack_.empty()) {
- throw std::runtime_error("No item on stack for LONG_BINPUT");
+ auto &dict_data = const_cast(dict_val->as_dict());
+ for (size_t i = 0; i < items.size(); i += 2) {
+ if (items[i]->get_type() != value::type::string) {
+ throw std::runtime_error("Dict key must be string");
}
- // For simplicity, we create a copy for memo storage
- // In a full implementation, you'd want to share the object
- memo_[memo_id] = nullptr; // Placeholder for now
- break;
+ std::string key = items[i]->as_string();
+ dict_data[std::move(key)] = std::move(items[i + 1]);
}
+ break;
+ }
- case opcodes::BINGET:
- {
- uint8_t memo_id = read_byte();
- if (auto it = memo_.find(memo_id); it == memo_.end()) {
- throw std::runtime_error("Memo key not found");
- }
- // For now, just push a placeholder
- stack_.push_back(value::none());
- break;
+ case opcodes::BINPUT: {
+ uint8_t memo_id = read_byte();
+ if (stack_.empty()) {
+ throw std::runtime_error("No item on stack for BINPUT");
}
+ memo_[memo_id] = clone(*stack_.back());
+ break;
+ }
- case opcodes::LONG_BINGET:
- {
- uint32_t memo_id = read_uint32_le();
- if (auto it = memo_.find(memo_id); it == memo_.end()) {
- throw std::runtime_error("Memo key not found");
- }
- // For now, just push a placeholder
- stack_.push_back(value::none());
- break;
+ case opcodes::LONG_BINPUT: {
+ uint32_t memo_id = read_uint32_le();
+ if (stack_.empty()) {
+ throw std::runtime_error("No item on stack for LONG_BINPUT");
}
+ memo_[memo_id] = clone(*stack_.back());
+ break;
+ }
- case opcodes::PROTO:
- {
- uint8_t proto = read_byte();
- // Just ignore protocol version for now
- break;
+ case opcodes::BINGET: {
+ uint8_t memo_id = read_byte();
+ const auto it = memo_.find(memo_id);
+ if (it == memo_.end()) {
+ throw std::runtime_error("Memo key not found");
}
+ stack_.push_back(clone(*it->second));
+ break;
+ }
- case opcodes::FRAME:
- {
- uint64_t frame_size = read_uint64_le();
- // Just ignore frame size for now
- break;
+ case opcodes::LONG_BINGET: {
+ uint32_t memo_id = read_uint32_le();
+ const auto it = memo_.find(memo_id);
+ if (it == memo_.end()) {
+ throw std::runtime_error("Memo key not found");
}
+ stack_.push_back(clone(*it->second));
+ break;
+ }
- case opcodes::LONG1:
- {
- uint8_t length = read_byte();
- if (length == 0) {
- stack_.push_back(value::int64(0));
- } else {
- std::string bytes_data = read_string(length);
- int64_t result = 0;
-
- // Convert little-endian bytes to integer
- for (int i = length - 1; i >= 0; --i) {
- result = result << 8 | static_cast(bytes_data[i]);
- }
-
- // Handle two's complement for negative numbers
- if (length > 0 && static_cast(bytes_data[length - 1]) & 0x80) {
- // Extend sign bit
- for (int i = length; i < 8; ++i) {
- result |= 0xFFLL << i * 8;
- }
- }
-
- stack_.push_back(value::int64(result));
+ case opcodes::PROTO: {
+ read_byte();
+ // Just ignore protocol version for now
+ break;
+ }
+
+ case opcodes::FRAME: {
+ read_uint64_le();
+ // Just ignore frame size for now
+ break;
+ }
+
+ case opcodes::LONG1: {
+ uint8_t length = read_byte();
+ if (length == 0) {
+ stack_.push_back(value::int64(0));
+ } else {
+ if (length > sizeof(std::int64_t)) {
+ throw std::runtime_error("LONG1 value does not fit in int64");
}
- break;
- }
- case opcodes::MEMOIZE:
- {
- if (stack_.empty()) {
- throw std::runtime_error("No item on stack for MEMOIZE");
+ const std::string bytes_data = read_string(length);
+ auto bits = std::accumulate(bytes_data.rbegin(), bytes_data.rend(), std::uint64_t{0},
+ [](const std::uint64_t current, const char byte) {
+ return current << 8 | static_cast(byte);
+ });
+
+ // Handle two's complement for negative numbers
+ if (length < sizeof(bits) && (static_cast(bytes_data.back()) & 0x80) != 0) {
+ bits |= ~std::uint64_t{0} << length * 8;
}
- // Store the top item in memo with auto-incrementing ID
- auto memo_id = static_cast(memo_.size());
- memo_[memo_id] = nullptr; // Placeholder for now
- break;
+
+ stack_.push_back(value::int64(std::bit_cast(bits)));
}
+ break;
+ }
+
+ case opcodes::MEMOIZE: {
+ if (stack_.empty()) {
+ throw std::runtime_error("No item on stack for MEMOIZE");
+ }
+ const auto memo_id = static_cast(memo_.size());
+ memo_[memo_id] = clone(*stack_.back());
+ break;
+ }
- default:
- throw std::runtime_error("Unsupported pickle opcode: " + std::to_string(opcode));
+ default:
+ throw std::runtime_error("Unsupported pickle opcode: " + std::to_string(opcode));
}
return nullptr; // Continue parsing
@@ -570,4 +562,4 @@ namespace pickle
const auto byte_span = std::span(reinterpret_cast(data.data()), data.size());
return loads(byte_span);
}
-}
+} // namespace pickle
diff --git a/src/modules/renpy/pickle.h b/src/modules/renpy/pickle.h
index eacb388..cfae788 100644
--- a/src/modules/renpy/pickle.h
+++ b/src/modules/renpy/pickle.h
@@ -1,12 +1,13 @@
#pragma once
-#include
-#include
-#include
-#include
+#include
#include
#include
#include
+#include
+#include
+#include
+#include
namespace pickle
{
@@ -18,8 +19,8 @@ namespace pickle
class value
{
- public:
- enum class type
+ public:
+ enum class type : std::uint8_t
{
none,
bool_,
@@ -32,19 +33,19 @@ namespace pickle
tuple
};
- private:
+ private:
type type_;
- std::variant<
- std::monostate, // none
- bool, // bool_
- int64_t, // int64
- double, // float64
- std::string, // bytes/string
- list, // list/tuple
- dict // dict
- > data_;
-
- public:
+ std::variant
+ data_;
+
+ public:
explicit value(const type t) : type_(t)
{
}
@@ -110,7 +111,10 @@ namespace pickle
return v;
}
- type get_type() const { return type_; }
+ type get_type() const
+ {
+ return type_;
+ }
bool as_bool() const
{
@@ -169,9 +173,11 @@ namespace pickle
}
};
+ value_ptr clone(const value &source);
+
class parser
{
- private:
+ private:
std::span data_;
size_t pos_ = 0;
std::vector stack_;
@@ -196,7 +202,7 @@ namespace pickle
value_ptr parse_value();
- public:
+ public:
explicit parser(const std::span data) : data_(data)
{
}
@@ -207,4 +213,4 @@ namespace pickle
value_ptr loads(std::span data);
value_ptr loads(const std::string &data);
-}
+} // namespace pickle
diff --git a/src/modules/renpy/renpy.cpp b/src/modules/renpy/renpy.cpp
index 63b047b..9692565 100644
--- a/src/modules/renpy/renpy.cpp
+++ b/src/modules/renpy/renpy.cpp
@@ -1,10 +1,11 @@
+#include "../../core/compression/zlib_codec.h"
+#include "../../core/io/bounded_stream.h"
#include "../extractor.h"
#include "pickle.h"
#include
#include
-
-#include
+#include
namespace extractor
{
@@ -12,11 +13,12 @@ namespace extractor
{
return {
{0x9486718f, 0x8f0a, 0x4de7, {0x98, 0x80, 0x01, 0x14, 0x6b, 0x33, 0x6d, 0x6b}},
- 3, 0,
+ 3,
+ 0,
};
}
- std::vector extractor::get_signature() noexcept
+ std::vector extractor::get_signature()
{
const std::string str = "RPA-";
std::vector signature(str.size());
@@ -24,18 +26,20 @@ namespace extractor
return signature;
}
- // ReSharper disable once CppMemberFunctionMayBeStatic
- archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static)
+ archive_info extractor::get_archive_info(
+ const std::span &data) // NOLINT(*-convert-member-functions-to-static)
{
+ static_cast(data);
return archive_info{L"RenPy", L"", L""};
}
- int64_t read_int64(std::ifstream &stream)
+ int64_t read_int64(observer::io::bounded_stream &stream)
{
std::string buffer(sizeof(int64_t) * 2, '\0');
- stream.read(buffer.data(), std::ssize(buffer));
+ stream.read_exact(buffer.data(), buffer.size());
char *end_ptr = nullptr;
+ errno = 0;
const int64_t result = std::strtoll(buffer.c_str(), &end_ptr, 16);
if (errno == ERANGE || result == 0 || result < 0) {
throw std::out_of_range("NumberReadNotANumberError");
@@ -44,68 +48,68 @@ namespace extractor
return result;
}
- std::pair(int64_t, int64_t)> > parse_header(
- std::ifstream &stream)
+ std::pair(int64_t, int64_t)>> parse_header(
+ observer::io::bounded_stream &stream)
{
std::string version_check(3, '\0');
- stream.seekg(static_cast(extractor::get_signature().size()));
- stream.read(version_check.data(), 3);
+ stream.seek_absolute(static_cast(extractor::get_signature().size()));
+ stream.read_exact(version_check.data(), version_check.size());
if (version_check == "2.0") {
- stream.seekg(static_cast(std::string("RPA-2.0 ").length()));
+ stream.seek_absolute(static_cast(std::string("RPA-2.0 ").length()));
const auto index_offset = read_int64(stream);
- return {
- index_offset, [](int64_t offset, int64_t length)
- {
- return std::make_pair(offset, length);
- }
- };
+ return {index_offset, [](int64_t offset, int64_t length) { return std::make_pair(offset, length); }};
}
if (version_check == "3.0") {
- stream.seekg(static_cast(std::string("RPA-3.0 ").length()));
+ stream.seek_absolute(static_cast(std::string("RPA-3.0 ").length()));
const auto index_offset = read_int64(stream);
const auto encryption_key = read_int64(stream);
- return {
- index_offset, [encryption_key](int64_t offset, int64_t length)
- {
- return std::make_pair(offset ^ encryption_key, length ^ encryption_key);
- }
- };
+ return {index_offset, [encryption_key](int64_t offset, int64_t length) {
+ return std::make_pair(offset ^ encryption_key, length ^ encryption_key);
+ }};
}
throw std::runtime_error("Unsupported RPA version");
}
- // ReSharper disable once CppMemberFunctionMayBeStatic
- std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static)
+ std::vector> extractor::list_files(
+ std::istream &stream) // NOLINT(*-convert-member-functions-to-static)
{
- const auto [index_offset, decoder] = parse_header(stream);
-
- stream.seekg(index_offset);
-
- std::string decompressed_data;
- try {
- zstr::istream zs(stream);
- decompressed_data.assign(std::istreambuf_iterator(zs), std::istreambuf_iterator());
- } catch (const std::ios_base::failure &) {
+ observer::io::bounded_stream input(stream);
+ const auto [index_offset, decoder] = parse_header(input);
+ const auto archive_size = input.size();
+ if (index_offset >= archive_size) {
throw read_error();
}
+ input.seek_absolute(index_offset);
+
+ constexpr std::size_t max_compressed_index_size = 64ULL * 1024 * 1024;
+ constexpr std::size_t max_decompressed_index_size = 64ULL * 1024 * 1024;
+ const auto compressed_size = archive_size - index_offset;
+ if (static_cast(compressed_size) > max_compressed_index_size) {
+ throw std::runtime_error("RPA compressed index exceeds the metadata budget");
+ }
+
+ std::vector compressed_data(static_cast(compressed_size));
+ input.read_exact(reinterpret_cast(compressed_data.data()), compressed_data.size());
+ const auto decompressed_data =
+ observer::compression::decompress_zlib(compressed_data, max_decompressed_index_size);
auto root = pickle::loads(decompressed_data);
const auto &dict = root->as_dict();
- auto files = std::vector >();
+ auto files = std::vector>();
files.reserve(dict.size());
- for (const auto &[file_name, value]: dict) {
+ for (const auto &[file_name, value] : dict) {
const auto &props_container = value->as_list();
if (props_container.size() != 1) {
- throw std::logic_error("Not implemented");
+ throw std::runtime_error("Expected exactly one property tuple");
}
const auto &props = props_container[0]->as_tuple();
if (props.size() < 2) {
- throw std::logic_error("Expected at least 2 elements in tuple");
+ throw std::runtime_error("Expected at least 2 elements in tuple");
}
const auto [offset, body_size] = decoder(props[0]->as_int64(), props[1]->as_int64());
@@ -130,6 +134,7 @@ namespace extractor
uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const
{
+ static_cast(data);
return magic;
}
-}
+} // namespace extractor
diff --git a/src/modules/rpgmaker/rpgmaker.cpp b/src/modules/rpgmaker/rpgmaker.cpp
index af6baf0..6a0fa04 100644
--- a/src/modules/rpgmaker/rpgmaker.cpp
+++ b/src/modules/rpgmaker/rpgmaker.cpp
@@ -1,3 +1,5 @@
+#include "../../core/archive_limits.h"
+#include "../../core/io/bounded_stream.h"
#include "../extractor.h"
#include
@@ -8,11 +10,12 @@ namespace extractor
{
return {
{0xc4674077, 0x464a, 0x425b, {0x89, 0x80, 0x9e, 0x14, 0xe8, 0x16, 0x49, 0x00}},
- 1, 0,
+ 1,
+ 0,
};
}
- std::vector extractor::get_signature() noexcept
+ std::vector extractor::get_signature()
{
const std::string str = "RGSSAD";
std::vector signature(str.size());
@@ -22,40 +25,42 @@ namespace extractor
return signature;
}
- // ReSharper disable once CppMemberFunctionMayBeStatic
- archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static)
+ archive_info extractor::get_archive_info(
+ const std::span &data) // NOLINT(*-convert-member-functions-to-static)
{
+ static_cast(data);
return archive_info{L"RGSS3", L"-", L"RPG Maker VX Ace"};
}
- static uint32_t read_u32(std::ifstream &stream)
+ std::vector> extractor::list_files(
+ std::istream &stream) // NOLINT(*-convert-member-functions-to-static)
{
- uint32_t value;
- stream.read(reinterpret_cast(&value), sizeof(value));
- return value;
- }
-
- // ReSharper disable once CppMemberFunctionMayBeStatic
- std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static)
- {
- stream.seekg(static_cast(get_signature().size()));
+ observer::io::bounded_stream input(stream);
+ input.seek_absolute(static_cast(get_signature().size()));
+ const auto archive_size = input.size();
- std::vector > files;
- const uint32_t magic = read_u32(stream) * 9 + 3;
+ std::vector> files;
+ const uint32_t magic = input.read_trivial() * 9 + 3;
while (true) {
- const uint32_t offset = read_u32(stream) ^ magic;
- if (offset == 0) break;
+ const uint32_t offset = input.read_trivial() ^ magic;
+ if (offset == 0)
+ break;
- const uint32_t size = read_u32(stream) ^ magic;
- const uint32_t file_magic = read_u32(stream) ^ magic;
- const uint32_t name_len = read_u32(stream) ^ magic;
+ const uint32_t size = input.read_trivial() ^ magic;
+ const uint32_t file_magic = input.read_trivial() ^ magic;
+ const uint32_t name_len = input.read_trivial() ^ magic;
+
+ const auto name_position = input.position();
+ if (name_len > observer::archive_limits::max_path_bytes ||
+ static_cast(name_len) > static_cast(archive_size - name_position)) {
+ throw read_error();
+ }
std::vector name_buf(name_len);
- stream.read(name_buf.data(), name_len);
+ input.read_exact(name_buf.data(), name_buf.size());
for (size_t i = 0; i < name_len; ++i) {
- name_buf[i] = static_cast(
- static_cast(name_buf[i]) ^
- static_cast(magic >> (8 * (i % 4))));
+ name_buf[i] = static_cast(static_cast(name_buf[i]) ^
+ static_cast(magic >> (8 * (i % 4))));
}
auto new_file = std::make_unique();
@@ -76,8 +81,8 @@ namespace extractor
return old;
}
- // ReSharper disable once CppMemberFunctionMayBeStatic
- uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const // NOLINT(*-convert-member-functions-to-static)
+ uint32_t extractor::decrypt(uint32_t magic,
+ std::vector &data) const // NOLINT(*-convert-member-functions-to-static)
{
const size_t size = data.size();
size_t i = 0;
@@ -98,4 +103,4 @@ namespace extractor
return magic;
}
-}
+} // namespace extractor
diff --git a/src/modules/zanzarah/zanzarah.cpp b/src/modules/zanzarah/zanzarah.cpp
index e4cb88f..d96a130 100644
--- a/src/modules/zanzarah/zanzarah.cpp
+++ b/src/modules/zanzarah/zanzarah.cpp
@@ -1,3 +1,5 @@
+#include "../../core/archive_limits.h"
+#include "../../core/io/bounded_stream.h"
#include "../extractor.h"
#include
@@ -9,30 +11,26 @@ namespace extractor
{
return {
{0x86e7e4c3, 0xbc44, 0x4e8e, {0x90, 0xaf, 0xbd, 0xbd, 0x1c, 0xb6, 0x1a, 0x83}},
- 2, 0,
+ 2,
+ 0,
};
}
- std::vector extractor::get_signature() noexcept
+ std::vector extractor::get_signature()
{
return {std::byte{0}, std::byte{0}, std::byte{0}, std::byte{0}};
}
- // ReSharper disable once CppMemberFunctionMayBeStatic
- archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static)
+ archive_info extractor::get_archive_info(
+ const std::span &data) // NOLINT(*-convert-member-functions-to-static)
{
+ static_cast(data);
return archive_info{L"Zanzarah", L"", L""};
}
- int32_t read_positive_or_zero_int32(std::ifstream &stream)
+ int32_t read_positive_or_zero_int32(observer::io::bounded_stream &stream)
{
- int32_t value;
-
- try {
- stream.read(reinterpret_cast(&value), sizeof(value));
- } catch (std::ios_base::failure &) {
- throw read_error();
- }
+ const auto value = stream.read_trivial();
if (value < 0) {
throw read_error();
@@ -41,7 +39,7 @@ namespace extractor
return value;
}
- int32_t read_positive_int32(std::ifstream &stream)
+ int32_t read_positive_int32(observer::io::bounded_stream &stream)
{
const int32_t value = read_positive_or_zero_int32(stream);
if (value == 0) {
@@ -50,36 +48,57 @@ namespace extractor
return value;
}
- // ReSharper disable once CppMemberFunctionMayBeStatic
- std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static)
+ std::vector> extractor::list_files(
+ std::istream &stream) // NOLINT(*-convert-member-functions-to-static)
{
- stream.seekg(static_cast(get_signature().size()));
+ observer::io::bounded_stream input(stream);
+ input.seek_absolute(static_cast